Commit b3d47cb7 by Xavier Antoviaque

Merge pull request #5 from aboudreault/mrq-max-attempts

Mrq max attempts
parents 7d794f08 c810ef22
......@@ -75,6 +75,28 @@ Second XBlock instance:
</mentoring>
```
### Self-assessment MRQs
```xml
<mentoring url_name="mcq_1" enforce_dependency="false">
<mrq name="mrq_1_1" type="choices" max_attempts="3">
<question>What do you like in this MRQ?</question>
<choice value="elegance">Its elegance</choice>
<choice value="beauty">Its beauty</choice>
<choice value="gracefulness">Its gracefulness</choice>
<choice value="bugs">Its bugs</choice>
<tip require="gracefulness">This MRQ is indeed very graceful</tip>
<tip require="elegance,beauty">This is something everyone has to like about this MRQ</tip>
<tip reject="bugs">Nah, there isn't any!</tip>
<message type="on-submit">Thank you for answering!</message>
</mrq>
<message type="completed">
All is good now...
<html><p>Congratulations!</p></html>
</message>
</mentoring>
### Tables
```xml
......
......@@ -55,7 +55,7 @@ class AnswerBlock(LightChild):
@lazy
def student_input(self):
"""
Use lazy property instead of XBlock field, as __init__() doesn't support
Use lazy property instead of XBlock field, as __init__() doesn't support
overwriting field values
"""
# Only attempt to locate a model object for this block when the answer has a name
......@@ -79,7 +79,7 @@ class AnswerBlock(LightChild):
html = render_template('templates/html/answer_read_only.html', {
'self': self,
})
fragment = Fragment(html)
fragment.add_css_url(self.runtime.local_resource_url(self.xblock_container, 'public/css/answer.css'))
fragment.add_javascript_url(self.runtime.local_resource_url(self.xblock_container,
......
......@@ -24,6 +24,10 @@
# Imports ###########################################################
import logging
import json
from lazy import lazy
from weakref import WeakKeyDictionary
from cStringIO import StringIO
from lxml import etree
......@@ -34,6 +38,8 @@ from xblock.core import XBlock
from xblock.fragment import Fragment
from xblock.plugin import Plugin
from .models import LightChild as LightChildModel
try:
from xmodule_modifiers import replace_jump_to_id_urls
except:
......@@ -133,6 +139,7 @@ class LightChildrenMixin(XBlockWithChildrenFragmentsMixin):
"""
Replacement for ```self.runtime.render_child()```
"""
frag = getattr(child, view_name)(context)
frag.content = u'<div class="xblock-light-child" name="{}" data-type="{}">{}</div>'.format(
child.name, child.__class__.__name__, frag.content)
......@@ -167,6 +174,7 @@ class XBlockWithLightChildren(LightChildrenMixin, XBlock):
"""
Current HTML view of the XBlock, for refresh by client
"""
frag = self.student_view({})
frag = self.fragment_text_rewriting(frag)
......@@ -194,6 +202,7 @@ class XBlockWithLightChildren(LightChildrenMixin, XBlock):
fragment = replace_jump_to_id_urls(course_id, jump_to_url, self, 'student_view', fragment, {})
return fragment
class LightChild(Plugin, LightChildrenMixin):
"""
Base class for the light children
......@@ -203,6 +212,7 @@ class LightChild(Plugin, LightChildrenMixin):
def __init__(self, parent):
self.parent = parent
self.xblock_container = parent.xblock_container
self._student_data_loaded = False
@property
def runtime(self):
......@@ -220,30 +230,125 @@ class LightChild(Plugin, LightChildrenMixin):
xmodule_runtime = xmodule_runtime()
return xmodule_runtime
@lazy
def student_data(self):
"""
Use lazy property instead of XBlock field, as __init__() doesn't support
overwriting field values
"""
if not self.name:
return ''
student_data = self.get_lightchild_model_object().student_data
return student_data
def load_student_data(self):
"""
Load the student data from the database.
"""
if self._student_data_loaded:
return
fields = self.get_fields_to_save()
if not fields or not self.student_data:
return
student_data = json.loads(self.student_data)
for field in fields:
if field in student_data:
setattr(self, field, student_data[field])
self._student_data_loaded = True
@classmethod
def get_fields_to_save(cls):
"""
Returns a list of all LightChildField of the class. Used for saving student data.
"""
return []
def save(self):
pass
"""
Replicate data changes on the related Django model used for sharing of data accross XBlocks
"""
# Save all children
for child in self.get_children_objects():
child.save()
self.student_data = {}
# Get All LightChild fields to save
for field in self.get_fields_to_save():
self.student_data[field] = getattr(self, field)
if self.name:
lightchild_data = self.get_lightchild_model_object()
if lightchild_data.student_data != self.student_data:
lightchild_data.student_data = json.dumps(self.student_data)
lightchild_data.save()
def get_lightchild_model_object(self, name=None):
"""
Fetches the LightChild model object for the lightchild named `name`
"""
if not name:
name = self.name
if not name:
raise ValueError, 'LightChild.name field need to be set to a non-null/empty value'
student_id = self.xmodule_runtime.anonymous_student_id
course_id = self.xmodule_runtime.course_id
lightchild_data, created = LightChildModel.objects.get_or_create(
student_id=student_id,
course_id=course_id,
name=name,
)
return lightchild_data
class LightChildField(object):
"""
Fake field with no persistence - allows to keep XBlocks fields definitions on LightChild
"""
def __init__(self, *args, **kwargs):
self.value = kwargs.get('default', '')
self.default = kwargs.get('default', '')
self.data = WeakKeyDictionary()
def __get__(self, instance, name):
def __nonzero__(self):
return bool(self.value)
# A LightChildField can depend on student_data
instance.load_student_data()
return self.data.get(instance, self.default)
def __set__(self, instance, value):
self.data[instance] = value
class String(LightChildField):
def __init__(self, *args, **kwargs):
self.value = kwargs.get('default', '') or ''
super(String, self).__init__(*args, **kwargs)
self.default = kwargs.get('default', '') or ''
# def split(self, *args, **kwargs):
# return self.value.split(*args, **kwargs)
def __str__(self):
return self.value
def split(self, *args, **kwargs):
return self.value.split(*args, **kwargs)
class Integer(LightChildField):
def __init__(self, *args, **kwargs):
super(Integer, self).__init__(*args, **kwargs)
self.default = kwargs.get('default', 0)
def __set__(self, instance, value):
try:
self.data[instance] = int(value)
except (TypeError, ValueError): # not an integer
self.data[instance] = 0
class Boolean(LightChildField):
......@@ -252,7 +357,8 @@ class Boolean(LightChildField):
class List(LightChildField):
def __init__(self, *args, **kwargs):
self.value = kwargs.get('default', [])
super(List, self).__init__(*args, **kwargs)
self.default = kwargs.get('default', [])
class Scope(object):
......
......@@ -84,6 +84,7 @@ class MentoringBlock(XBlockWithLightChildren):
self.runtime.local_resource_url(self, 'public/js/vendor/underscore-min.js'))
fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/mentoring.js'))
fragment.add_resource(load_resource('templates/html/mentoring_progress.html'), "text/html")
fragment.add_resource(load_resource('templates/html/mrqblock_attempts.html'), "text/html")
fragment.initialize_js('MentoringBlock')
......
......@@ -43,4 +43,4 @@ class Migration(SchemaMigration):
}
}
complete_apps = ['mentoring']
\ No newline at end of file
complete_apps = ['mentoring']
# -*- coding: utf-8 -*-
import datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models
class Migration(SchemaMigration):
def forwards(self, orm):
# Adding model 'LightChild'
db.create_table('mentoring_lightchild', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('name', self.gf('django.db.models.fields.CharField')(max_length=50, db_index=True)),
('student_id', self.gf('django.db.models.fields.CharField')(max_length=32, db_index=True)),
('course_id', self.gf('django.db.models.fields.CharField')(max_length=50, db_index=True)),
('student_data', self.gf('django.db.models.fields.TextField')(default='', blank=True)),
('created_on', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)),
('modified_on', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, blank=True)),
))
db.send_create_signal('mentoring', ['LightChild'])
# Adding unique constraint on 'LightChild', fields ['student_id', 'course_id', 'name']
db.create_unique('mentoring_lightchild', ['student_id', 'course_id', 'name'])
def backwards(self, orm):
# Removing unique constraint on 'LightChild', fields ['student_id', 'course_id', 'name']
db.delete_unique('mentoring_lightchild', ['student_id', 'course_id', 'name'])
# Deleting model 'LightChild'
db.delete_table('mentoring_lightchild')
models = {
'mentoring.answer': {
'Meta': {'unique_together': "(('student_id', 'course_id', 'name'),)", 'object_name': 'Answer'},
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}),
'created_on': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'modified_on': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}),
'student_id': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}),
'student_input': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'})
},
'mentoring.lightchild': {
'Meta': {'unique_together': "(('student_id', 'course_id', 'name'),)", 'object_name': 'LightChild'},
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}),
'created_on': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
'student_data': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'modified_on': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}),
'student_id': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'})
}
}
complete_apps = ['mentoring']
......@@ -49,3 +49,21 @@ class Answer(models.Model):
# Force validation of max_length
self.full_clean()
super(Answer, self).save(*args, **kwargs)
class LightChild(models.Model):
"""
Django model used to store LightChild student data that need to be shared and queried accross
XBlock instances (workaround). Since this is temporary, `data` are stored in json.
"""
class Meta:
app_label = 'mentoring'
unique_together = (('student_id', 'course_id', 'name'),)
name = models.CharField(max_length=50, db_index=True)
student_id = models.CharField(max_length=32, db_index=True)
course_id = models.CharField(max_length=50, db_index=True)
student_data = models.TextField(blank=True, default='')
created_on = models.DateTimeField('created on', auto_now_add=True)
modified_on = models.DateTimeField('modified on', auto_now=True)
......@@ -26,7 +26,7 @@
import logging
from .light_children import List, Scope
from .light_children import Integer, List, Scope
from .questionnaire import QuestionnaireAbstractBlock
from .utils import render_template
......@@ -43,11 +43,21 @@ class MRQBlock(QuestionnaireAbstractBlock):
An XBlock used to ask multiple-response questions
"""
student_choices = List(help="Last submissions by the student", default=[], scope=Scope.user_state)
max_attempts = Integer(help="Number of max attempts for this questions", scope=Scope.content)
num_attempts = Integer(help="Number of attempts a user has answered for this questions", scope=Scope.user_state)
# TODO REMOVE THIS, ONLY NEEDED FOR LIGHTCHILDREN
@classmethod
def get_fields_to_save(cls):
return [
'num_attempts'
]
def submit(self, submissions):
log.debug(u'Received MRQ submissions: "%s"', submissions)
completed = True
results = []
for choice in self.custom_choices:
choice_completed = True
......@@ -73,12 +83,26 @@ class MRQBlock(QuestionnaireAbstractBlock):
}),
})
self.student_choices = submissions
self.message = u'Your answer is correct!' if completed else u'Your answer is incorrect.'
# Do not increase the counter is the answer is correct
if not completed:
setattr(self, 'num_attempts', self.num_attempts + 1)
if self.max_attempts > 0 and self.num_attempts >= self.max_attempts:
completed = True
self.message += u' You have reached the maximum number of attempts for this question. ' \
u'Your next answers won''t be saved. You can check the answer(s) using the "Show Answer(s)" button.'
else:
self.student_choices = submissions
result = {
'submissions': submissions,
'completed': completed,
'choices': results,
'message': self.message,
'submissions': submissions,
'completed': completed,
'choices': results,
'message': self.message,
'max_attempts': self.max_attempts,
'num_attempts': self.num_attempts
}
log.debug(u'MRQ submissions result: %s', result)
return result
......@@ -51,7 +51,7 @@
.mentoring .progress .indicator {
display: inline-block;
margin-top: 5px;
vertical-align: middle;
}
.mentoring .progress .indicator .checkmark-correct {
......
......@@ -11,21 +11,28 @@
margin: 10px 0;
}
.mentoring .choices .choice-checkbox {
display: inline-block;
margin-top: 5px;
margin-bottom: 5px;
}
.mentoring .choices .choice-result {
padding-right: 10px;
display: inline-block;
width: 40px;
vertical-align: middle;
cursor: pointer;
}
.mentoring .choices .choice-result.correct {
.mentoring .choices .choice-result.correct, .choice-answer.correct {
cursor: pointer;
color: #006600;
position: relative;
top: -3px;
}
.mentoring .choices .choice-result.incorrect {
margin-right: 10px;
padding-left: 10px;
padding-right: 10px;
text-align:center;
color: #ff0000;
}
......@@ -78,3 +85,15 @@
.mentoring .choices-list .choice-selector {
margin-right: 5px;
}
.mentoring .mrq-attempts {
display: inline-block;
vertical-align: baseline;
color: #777;
font-style: italic;
webkit-font-smoothing: antialiased;
}
.mentoring .mrq-attempts div {
display: inline-block;
}
......@@ -74,7 +74,7 @@ function MentoringBlock(runtime, element) {
}
function initXBlock() {
var submit_dom = $(element).find('.submit');
var submit_dom = $(element).find('.submit .input-main');
submit_dom.bind('click', function() {
var data = {};
......@@ -89,6 +89,12 @@ function MentoringBlock(runtime, element) {
$.post(handlerUrl, JSON.stringify(data)).success(handleSubmitResults);
});
// init children (especially mrq blocks)
var children = getChildren(element);
_.each(children, function(child) {
callIfExists(child, 'init');
});
if (submit_dom.length) {
renderProgress();
}
......
// TODO: Split in two files
var mrqAttemptsTemplate = _.template($('#xblock-mrq-attempts').html());
function MCQBlock(runtime, element) {
return {
......@@ -25,7 +26,29 @@ function MCQBlock(runtime, element) {
function MRQBlock(runtime, element) {
return {
renderAttempts: function() {
var data = $('.mrq-attempts', element).data();
$('.mrq-attempts', element).html(mrqAttemptsTemplate(data));
// bind show answer button
var showAnswerButton = $('button', element);
if (showAnswerButton.length != 0) {
if (_.isUndefined(this.answers))
showAnswerButton.hide();
else
showAnswerButton.on('click', _.bind(this.toggleAnswers, this));
}
},
init: function() {
this.renderAttempts();
},
submit: function() {
// hide answers
var choiceInputDOM = $('.choice input', element),
choiceResultDOM = $('.choice-answer', choiceInputDOM.closest('.choice'));
choiceResultDOM.removeClass('incorrect icon-exclamation correct icon-ok');
var checkedCheckboxes = $('input[type=checkbox]:checked', element),
checkedValues = [];
......@@ -58,17 +81,29 @@ function MRQBlock(runtime, element) {
showPopup(messageDOM);
}
var answers = []; // used in displayAnswers
$.each(result.choices, function(index, choice) {
var choiceInputDOM = $('.choice input[value='+choice.value+']', element),
choiceDOM = choiceInputDOM.closest('.choice'),
choiceResultDOM = $('.choice-result', choiceDOM),
choiceAnswerDOM = $('.choice-answer', choiceDOM),
choiceTipsDOM = $('.choice-tips', choiceDOM),
choiceTipsCloseDOM;
if (choice.completed) {
choiceResultDOM.removeClass('incorrect icon-exclamation').addClass('correct icon-ok');
} else {
choiceResultDOM.removeClass('correct icon-ok').addClass('incorrect icon-exclamation');
/* update our answers dict */
answers.push({
input: choiceInputDOM,
answer: choice.completed ? choiceInputDOM.attr('checked') : !choiceInputDOM.attr('checked')
});
choiceResultDOM.removeClass('incorrect icon-exclamation correct icon-ok');
/* show hint if checked or max_attempts is disabled */
if (result.completed || choiceInputDOM.prop('checked') || result.max_attempts <= 0) {
if (choice.completed) {
choiceResultDOM.addClass('correct icon-ok');
} else if (!choice.completed) {
choiceResultDOM.addClass('incorrect icon-exclamation');
}
}
choiceTipsDOM.html(choice.tips);
......@@ -77,7 +112,36 @@ function MRQBlock(runtime, element) {
choiceResultDOM.off('click').on('click', function() {
showPopup(choiceTipsDOM);
});
choiceAnswerDOM.off('click').on('click', function() {
showPopup(choiceTipsDOM);
});
});
}
this.answers = answers;
$('.mrq-attempts', element).data('num_attempts', result.num_attempts);
this.renderAttempts();
},
toggleAnswers: function() {
var showAnswerButton = $('button span', element);
var answers_displayed = this.answers_displayed = !this.answers_displayed;
_.each(this.answers, function(answer) {
var choiceResultDOM = $('.choice-answer', answer.input.closest('.choice'));
choiceResultDOM.removeClass('correct icon-ok');
if (answers_displayed) {
if (answer.answer)
choiceResultDOM.addClass('correct icon-ok');
showAnswerButton.text('Hide Answer(s)');
}
else {
showAnswerButton.text('Show Answer(s)');
}
});
}
};
}
......@@ -8,7 +8,7 @@
{% endfor %}
{% if self.display_submit %}
<div class="submit">
<input type="button" value="Submit"></input>
<input type="button" class="input-main" value="Submit"></input>
<span class="progress" data-completed="{{ self.completed }}" data-attempted="{{ self.attempted }}">
<span class='indicator'></span>
</span>
......
<script type="text/template" id="xblock-mrq-attempts">
<% if (_.isNumber(max_attempts) && max_attempts > 0) {{ %>
<% if (num_attempts >= max_attempts) {{ %>
<button class="show">
<span class="show-label">Show Answer(s)</span>
</button>
<% }} %>
<div> You have used <%= _.min([num_attempts, max_attempts]) %> of <%= max_attempts %> attempts for this question.</div>
<% }} %>
</script>
......@@ -5,12 +5,20 @@
{% for choice in custom_choices %}
<div class="choice">
<span class="choice-result icon-2x"></span>
<label class="choice-label">
<input class="choice-selector" type="checkbox" name="{{ self.name }}" value="{{ choice.value }}"{% if choice.value in self.student_choices %} checked{% endif %}> {{ choice.content }}
</label>
<div class="choice-checkbox">
<label class="choice-label">
<input class="choice-selector" type="checkbox" name="{{ self.name }}"
value="{{ choice.value }}"
{% if choice.value in self.student_choices %} checked{% endif %}>
{{ choice.content }}
</input>
</label>
</div>
<span class="choice-answer icon-2x"></span>
<div class="choice-tips"></div>
</div>
{% endfor %}
<div class="choice-message"></div>
</div>
</fieldset>
<div class="mrq-attempts" data-max_attempts="{{ self.max_attempts }}" data-num_attempts="{{ self.num_attempts }}"></div>
......@@ -105,9 +105,9 @@ class XBlockWithChildrenFragmentsMixin(object):
and a list of fragments, one per children
- `view_name` allows to select a specific view method on the children
- `instance_of` allows to only return fragments for children which are instances of
- `instance_of` allows to only return fragments for children which are instances of
the provided class
- `not_instance_of` allows to only return fragments for children which are *NOT*
- `not_instance_of` allows to only return fragments for children which are *NOT*
instances of the provided class
"""
fragment = Fragment()
......
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