MDL-79863 qtype_ordering: qtype/ordering fix display of correct/wrong items for question review

This commit is contained in:
Gordon Bateson 2014-07-28 08:56:10 +09:00 committed by Mathew May
parent 12472692db
commit e5e7f24f35
6 changed files with 326 additions and 155 deletions

View File

@ -1,4 +1,4 @@
<?php // $Id: qtype_TEMPLATE.php,v 1.2 2006/08/25 21:40:44 Serafim Panov Exp $
<?php
/**
* The language strings for the QTYPENAME question type.
*
@ -27,16 +27,19 @@ $string['ordering_contiguous'] = 'Contiguous';
$string['ordering_logicalpossibilities'] = 'Logical possibilities';
$string['ordering_addmoreanswers'] = 'Blank for {no} more answers';
$string['ordering_choiceno'] = 'Answer {$a}';
$string['ordering_choiceno'] = 'Answer {$a}';
$string['ordering_choices'] = 'Available answers';
$string['ordering_answer'] = 'Answer';
$string['ordering_itemsforstudent'] = 'How many items the student will see';
$string['correctfeedback'] = 'For any correct answer';
$string['incorrectfeedback'] = 'For any incorrect answer';
$string['correctorder'] = 'The correct order for these items is as follows:';
$string['noresponsedetails'] = 'Sorry, no details of the response to this question are available.';
$string['correctfeedback'] = 'Correct answer';
$string['incorrectfeedback'] = 'Incorrect answer';
$string['overallfeedback'] = 'Overall Feedback';
$string['partiallycorrectfeedback'] = 'For any partially correct answer';
$string['partiallycorrectfeedback'] = 'Partially correct answer ({$a})';
$string['ordering_notenoughanswers'] = 'not enough answers';
$string['ordering_comment'] = 'Drag and drop the items into the correct order.';

View File

@ -32,18 +32,32 @@ defined('MOODLE_INTERNAL') || die();
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class qtype_ordering_question extends question_graded_automatically {
public $answers;
public $options;
public $rightanswer;
public $truefeedback;
public $falsefeedback;
public $trueanswerid;
public $falseanswerid;
public function get_response_fieldname() {
return 'response_'.$this->id;
}
public function format_questiontext($qa) {
$text = parent::format_questiontext($qa);
return stripslashes($text);
}
public function get_expected_data() {
return array('answer' => PARAM_INTEGER);
$name = $this->get_response_fieldname();
return array($name => PARAM_TEXT);
}
public function get_correct_response() {
return array('answer' => (int) $this->rightanswer);
if ($this->rightanswer===null) {
$this->rightanswer = $this->get_ordering_answers();
$this->rightanswer = array_keys($this->rightanswer);
$this->rightanswer = implode(',', $this->rightanswer);
}
return array('answer' => $this->rightanswer);
}
public function summarise_response(array $response) {
@ -59,10 +73,56 @@ class qtype_ordering_question extends question_graded_automatically {
}
}
public function get_ordering_options() {
global $DB;
if ($this->options===null) {
$this->options = $DB->get_record('question_ordering', array('question' => $this->id));
if ($this->options==false) {
$this->options = (object)array(
'question' => $this->id,
'logical' => 0, // require all answers
'studentsee' => 0,
'correctfeedback' => '',
'incorrectfeedback' => '',
'partiallycorrectfeedback' => ''
);
$this->options->id = $DB->insert_record('question_ordering', $options);
}
}
return $this->options;
}
public function get_ordering_answers() {
global $CFG, $DB;
if ($this->answers===null) {
$this->answers = $DB->get_records('question_answers', array('question' => $this->id), 'fraction,id');
if ($this->answers) {
if (isset($CFG->passwordsaltmain)) {
$salt = $CFG->passwordsaltmain;
} else {
$salt = ''; // complex_random_string()
}
foreach ($this->answers as $answerid => $answer) {
$this->answers[$answerid]->md5key = 'ordering_item_'.md5($salt.$answer->answer);
}
} else {
$this->answers = array();
}
}
return $this->answers;
}
public function is_complete_response(array $response) {
global $CFG, $DB;
$responses = explode(',', $_POST['q'.$this->id]);
$name = $this->get_response_fieldname();
if (isset($response[$name])) {
$responses = $response[$name];
} else {
$responses = optional_param($name, '', PARAM_TEXT);
}
$responses = preg_replace('[^a-zA-Z0-9,_-]', '', $responses);
$responses = explode(',', $responses); // convert to array
$responses = array_filter($responses); // remove blanks
$responses = array_unique($responses); // remove duplicates
@ -74,23 +134,25 @@ class qtype_ordering_question extends question_graded_automatically {
}
}
if (! $options = $DB->get_record ('question_ordering', array('question' => $this->id))) {
$options = (object)array('logical' => 0); // shouldn't happen !!
}
if (! $answers = $DB->get_records ('question_answers', array('question' => $this->id), 'fraction,id')) {
$answers = array(); // shouldn't happen !!
}
$ordering = $this->get_ordering_options();
$answers = $this->get_ordering_answers();
if ($options->logical==0) {
if ($ordering->logical==0) {
$total = count($answers); // require all answers
} else {
$total = $options->studentsee + 2; // a subset of answers
$total = $ordering->studentsee + 2; // a subset of answers
}
if (isset($CFG->passwordsaltmain)) {
$salt = $CFG->passwordsaltmain;
} else {
$salt = ''; // complex_random_string()
}
$validresponses = array();
foreach ($answers as $answerid => $answer) {
$response = md5($CFG->passwordsaltmain.$answer->answer);
$response = md5($salt.$answer->answer);
$sortorder = intval($answer->fraction);
if (in_array($response, $responses)) {
@ -165,7 +227,7 @@ class qtype_ordering_question extends question_graded_automatically {
return array($fraction, question_state::graded_state_for_fraction($fraction));
}
public function check_file_access($qa, $options, $component, $filearea, $args, $forcedownload) {
public function check_file_access($qa, $ordering, $component, $filearea, $args, $forcedownload) {
// do nothing
}
}

View File

@ -41,9 +41,10 @@ if (class_exists('question_type')) {
*/
class qtype_ordering extends question_type {
public function is_not_blank($value) {
$value = trim($value);
return ($value || $value==='0');
protected function initialise_question_instance(question_definition $question, $questiondata) {
parent::initialise_question_instance($question, $questiondata);
$answers = array_keys($questiondata->options->answers);
$question->rightanswer = implode(',', $answers);
}
public function save_question_options($question) {
@ -128,6 +129,11 @@ class qtype_ordering extends question_type {
return true;
}
public function is_not_blank($value) {
$value = trim($value);
return ($value || $value==='0');
}
public function get_question_options($question) {
global $DB, $OUTPUT;

View File

@ -35,53 +35,69 @@ class qtype_ordering_renderer extends qtype_renderer {
public function formulation_and_controls(question_attempt $qa, question_display_options $options) {
global $CFG, $DB;
static $addStyle = true;
static $addScript = true;
$question = $qa->get_question();
$ordering = $question->get_ordering_options();
$answers = $question->get_ordering_answers();
if (! $options = $DB->get_record('question_ordering', array('question' => $question->id))) {
return '';
}
if (! $answers = $DB->get_records('question_answers', array('question' => $question->id), '', '*')) {
if (empty($ordering) || empty($answers)) {
return ''; // shouldn't happen !!
}
if ($options->studentsee==0) { // all items
$options->studentsee = count($answers);
if ($ordering->studentsee==0) { // all items
$ordering->studentsee = count($answers);
} else {
// a nasty hack so that "studentsee" is the same
// as what is displayed by edit_ordering_form.php
$options->studentsee += 2;
$ordering->studentsee += 2;
}
switch ($options->logical) {
case 0: // all
$answerids = array_keys($answers);
break;
case 1: // random subset
$answerids = array_rand($answers, $options->studentsee);
break;
case 2: // contiguous subset
if (count($answers) > $options->studentsee) {
$offset = mt_rand(0, count($answers) - $options->studentsee);
$answers = array_slice($answers, $offset, $options->studentsee, true);
}
$answerids = array_keys($answers);
break;
if ($options->readonly || $options->correctness) {
// don't allow items to be dragged and dropped
$readonly = true;
} else {
$readonly = false;
}
shuffle($answerids);
if ($options->correctness) {
list($answerids, $correctorder) = $this->get_response($qa, $question, $answers);
} else {
$correctorder = array();
switch ($ordering->logical) {
case 0: // all
$answerids = array_keys($answers);
break;
case 1: // random subset
$answerids = array_rand($answers, $ordering->studentsee);
break;
case 2: // contiguous subset
if (count($answers) > $ordering->studentsee) {
$offset = mt_rand(0, count($answers) - $ordering->studentsee);
$answers = array_slice($answers, $offset, $ordering->studentsee, true);
}
$answerids = array_keys($answers);
break;
}
shuffle($answerids);
}
$response_name = $qa->get_qt_field_name($question->get_response_fieldname());
$response_id = 'id_'.preg_replace('/[^a-zA-Z0-9]+/', '_', $response_name);
$sortable_id = 'id_sortable_'.$question->id;
$result = '';
$result .= html_writer::tag('script', '', array('type'=>'text/javascript', 'src'=>$CFG->wwwroot.'/question/type/ordering/js/jquery.js'));
$result .= html_writer::tag('script', '', array('type'=>'text/javascript', 'src'=>$CFG->wwwroot.'/question/type/ordering/js/jquery-ui.js'));
if ($readonly==false) {
$result .= html_writer::tag('script', '', array('type'=>'text/javascript', 'src'=>$CFG->wwwroot.'/question/type/ordering/js/jquery.js'));
$result .= html_writer::tag('script', '', array('type'=>'text/javascript', 'src'=>$CFG->wwwroot.'/question/type/ordering/js/jquery-ui.js'));
}
$style = "\n";
$style .= "ul.sortable".$question->id." li {\n";
$style .= "ul#$sortable_id li {\n";
$style .= " position: relative;\n";
$style .= "}\n";
if ($addStyle) {
@ -101,7 +117,9 @@ class qtype_ordering_renderer extends qtype_renderer {
$style .= " background-color: #eeeeee;\n";
$style .= " border: 1px solid #cccccc;\n";
$style .= " border-image: initial;\n";
$style .= " cursor: move;\n";
if ($readonly==false) {
$style .= " cursor: move;\n";
}
$style .= " list-style-type: none;\n";
$style .= " margin-bottom: 1px;\n";
$style .= " min-height: 20px;\n";
@ -110,92 +128,109 @@ class qtype_ordering_renderer extends qtype_renderer {
}
$result .= html_writer::tag('style', $style, array('type' => 'text/css'));
$script = "\n";
$script .= "//<![CDATA[\n";
$script .= "$(function() {\n";
$script .= " $('#sortable".$question->id."').sortable({\n";
$script .= " update: function(event, ui) {\n";
$script .= " var ItemsOrder = $(this).sortable('toArray').toString();\n";
$script .= " $('#q".$question->id."').attr('value', ItemsOrder);\n";
$script .= " }\n";
$script .= " });\n";
$script .= " $('#sortable".$question->id."').disableSelection();\n";
$script .= "});\n";
$script .= "$(document).ready(function() {\n";
$script .= " var ItemsOrder = $('#sortable".$question->id."').sortable('toArray').toString();\n";
$script .= " $('#q".$question->id."').attr('value', ItemsOrder);\n";
$script .= "});\n";
$script .= "//]]>\n";
$result .= html_writer::tag('script', $script, array('type' => 'text/javascript'));
$result .= html_writer::tag('div', stripslashes($question->format_questiontext($qa)), array('class' => 'qtext'));
$result .= html_writer::start_tag('div', array('class' => 'ablock'));
$result .= html_writer::start_tag('div', array('class' => 'answer'));
$result .= html_writer::start_tag('ul', array('class' => 'boxy', 'id' => 'sortable'.$question->id));
// a salt (=random string) to help disguise answer ids
if (isset($CFG->passwordsaltmain)) {
$salt = $CFG->passwordsaltmain;
} else {
$salt = complex_random_string();
}
// generate ordering items
foreach ($answerids as $i => $answerid) {
// the original "id" revealed the correct order of the answers
// because $answer->fraction holds the correct order number
// $id = 'ordering_item_'.$answerid.'_'.intval($answers[$answerid]->fraction);
$id = 'ordering_item_'.md5($salt.$answers[$answerid]->answer);
$params = array('class' => 'ui-state-default', 'id' => $id);
$result .= html_writer::tag('li', $answers[$answerid]->answer, $params);
}
$result .= html_writer::end_tag('ul');
$result .= html_writer::end_tag('div'); // answer
$result .= html_writer::end_tag('div'); // ablock
$result .= html_writer::empty_tag('input', array('type'=>'hidden', 'name'=>'q'.$question->id, 'id' => 'q'.$question->id, 'value' => '9'));
$result .= html_writer::empty_tag('input', array('type'=>'hidden', 'name'=>'answer', 'value' => ''));
$result .= html_writer::tag('div', '', array('style' => 'clear:both;'));
$script = "\n";
$script .= "//<![CDATA[\n";
if ($addScript) {
$addScript = false; // only add these functions once
$script .= "function orderingTouchHandler(event) {\n";
$script .= " var touch = event.changedTouches[0];\n";
$script .= " switch (event.type) {\n";
$script .= " case 'touchstart': var type = 'mousedown'; break;\n";
$script .= " case 'touchmove': var type = 'mousemove'; event.preventDefault(); break;\n";
$script .= " case 'touchend': var type = 'mouseup'; break;\n";
$script .= " default: return;\n";
$script .= " }\n";
$script .= " var simulatedEvent = document.createEvent('MouseEvent');\n";
$script .= " // initMouseEvent(type, canBubble, cancelable, view, clickCount, screenX, screenY, clientX, clientY, ctrlKey, altKey, shiftKey, metaKey, button, relatedTarget)\n";
$script .= " simulatedEvent.initMouseEvent(type, true, true, window, 1, touch.screenX, touch.screenY, touch.clientX, touch.clientY, false, false, false, false, 0, null);\n";
$script .= " touch.target.dispatchEvent(simulatedEvent);\n";
$script .= " event.preventDefault();\n";
$script .= "}\n";
$script .= "function orderingInit(sortableid) {\n";
$script .= " var obj = document.getElementById(sortableid);\n";
$script .= " if (obj) {\n";
$script .= " for (var i=0; i<obj.childNodes.length; i++) {\n";
$script .= " obj.childNodes.item(i).addEventListener('touchstart', orderingTouchHandler, false);\n";
$script .= " obj.childNodes.item(i).addEventListener('touchmove', orderingTouchHandler, false);\n";
$script .= " obj.childNodes.item(i).addEventListener('touchend', orderingTouchHandler, false);\n";
$script .= " obj.childNodes.item(i).addEventListener('touchcancel', orderingTouchHandler, false);\n";
if ($readonly==false) {
$script = "\n";
$script .= "//<![CDATA[\n";
$script .= "$(function() {\n";
$script .= " $('#$sortable_id').sortable({\n";
$script .= " update: function(event, ui) {\n";
$script .= " var ItemsOrder = $(this).sortable('toArray').toString();\n";
$script .= " $('#$response_id').attr('value', ItemsOrder);\n";
$script .= " }\n";
$script .= " obj = null;\n";
$script .= " } else {\n";
$script .= " // try again in 1/2 a second - shouldn't be necessary !!\n";
$script .= " setTimeout(new Function('orderingInit(".'"'."'+sortableid+'".'"'.")'), 500);\n";
$script .= " }\n";
$script .= "}\n";
$script .= " });\n";
$script .= " $('#$sortable_id').disableSelection();\n";
$script .= "});\n";
$script .= "$(document).ready(function() {\n";
$script .= " var ItemsOrder = $('#$sortable_id').sortable('toArray').toString();\n";
$script .= " $('#$response_id').attr('value', ItemsOrder);\n";
$script .= "});\n";
$script .= "//]]>\n";
$result .= html_writer::tag('script', $script, array('type' => 'text/javascript'));
}
$result .= html_writer::tag('div', $question->format_questiontext($qa), array('class' => 'qtext'));
if (count($answerids)) {
$result .= html_writer::start_tag('div', array('class' => 'ablock'));
$result .= html_writer::start_tag('div', array('class' => 'answer'));
$result .= html_writer::start_tag('ul', array('class' => 'boxy', 'id' => $sortable_id));
// generate ordering items
foreach ($answerids as $position => $answerid) {
if (array_key_exists($answerid, $answers)) {
if ($options->correctness) {
if ($correctorder[$position]==$answerid) {
$class = 'correctposition';
$img = $this->feedback_image(1);
} else {
$class = 'wrongposition';
$img = $this->feedback_image(0);
}
$img = "$img ";
} else {
$class = 'ui-state-default';
$img = '';
}
// the original "id" revealed the correct order of the answers
// because $answer->fraction holds the correct order number
// $id = 'ordering_item_'.$answerid.'_'.intval($answers[$answerid]->fraction);
$params = array('class' => $class, 'id' => $answers[$answerid]->md5key);
$result .= html_writer::tag('li', $img.$answers[$answerid]->answer, $params);
}
}
$result .= html_writer::end_tag('ul');
$result .= html_writer::end_tag('div'); // answer
$result .= html_writer::end_tag('div'); // ablock
$params = array('type' => 'hidden', 'name' => $response_name, 'id' => $response_id, 'value' => '');
$result .= html_writer::empty_tag('input', $params);
$result .= html_writer::tag('div', '', array('style' => 'clear:both;'));
}
if ($readonly==false) {
$script = "\n";
$script .= "//<![CDATA[\n";
if ($addScript) {
$addScript = false; // only add these functions once
$script .= "function orderingTouchHandler(evt) {\n";
$script .= " var touchEvt = evt.changedTouches[0];\n";
$script .= " switch (evt.type) {\n";
$script .= " case 'touchstart': var type = 'mousedown'; break;\n";
$script .= " case 'touchmove': var type = 'mousemove'; break;\n";
$script .= " case 'touchend': var type = 'mouseup'; break;\n";
$script .= " default: return;\n";
$script .= " }\n";
$script .= " var mouseEvt = document.createEvent('MouseEvent');\n";
$script .= " // initMouseEvent(type, canBubble, cancelable, view, clickCount, screenX, screenY, clientX, clientY, ctrlKey, altKey, shiftKey, metaKey, button, relatedTarget)\n";
$script .= " mouseEvt.initMouseEvent(type, true, true, window, 1, touchEvt.screenX, touchEvt.screenY, touchEvt.clientX, touchEvt.clientY, false, false, false, false, 0, null);\n";
$script .= " touchEvt.target.dispatchEvent(mouseEvt);\n";
$script .= " evt.preventDefault();\n";
$script .= "}\n";
$script .= "function orderingTouchHandlers(sortableid) {\n";
$script .= " var obj = document.getElementById(sortableid);\n";
$script .= " if (obj) {\n";
$script .= " for (var i=0; i<obj.childNodes.length; i++) {\n";
$script .= " obj.childNodes.item(i).addEventListener('touchstart', orderingTouchHandler, false);\n";
$script .= " obj.childNodes.item(i).addEventListener('touchmove', orderingTouchHandler, false);\n";
$script .= " obj.childNodes.item(i).addEventListener('touchend', orderingTouchHandler, false);\n";
$script .= " obj.childNodes.item(i).addEventListener('touchcancel', orderingTouchHandler, false);\n";
$script .= " }\n";
$script .= " obj = null;\n";
$script .= " } else {\n";
$script .= " // try again in 1/2 a second - shouldn't be necessary !!\n";
$script .= " setTimeout(new Function('orderingTouchHandlers(".'"'."'+sortableid+'".'"'.")'), 500);\n";
$script .= " }\n";
$script .= "}\n";
}
$script .= "if (document.body.ontouchstart) {\n";
$script .= " orderingTouchHandlers('$sortable_id');\n";
$script .= "}\n";
$script .= "//]]>\n";
$result .= html_writer::tag('script', $script, array('type' => 'text/javascript'));
}
$script .= "orderingInit('sortable".$question->id."');\n";
$script .= "//]]>\n";
$result .= html_writer::tag('script', $script, array('type' => 'text/javascript'));
return $result;
}
@ -203,21 +238,75 @@ class qtype_ordering_renderer extends qtype_renderer {
public function correct_response(question_attempt $qa) {
global $DB;
$question = $qa->get_question();
$output = '';
if (! $step = $DB->get_records('question_attempt_steps', array('questionattemptid' => $question->contextid), 'id DESC')) {
return ''; // shouldn't happen !!
}
$step = current($step); // first one
if ($step->fraction >= 1) {
$feedback = get_string('correctfeedback', 'qtype_ordering');
} else if ($step->fraction > 0) {
$feedback = get_string('partiallycorrectfeedback', 'qtype_ordering').' '.round($step->fraction, 2);
} else {
$feedback = get_string('incorrectfeedback', 'qtype_ordering');
$showcorrect = false;
if ($step = $qa->get_last_step()) {
switch ($step->get_state()) {
case 'gradedright':
$msg = get_string('correctfeedback', 'qtype_ordering');
break;
case 'gradedpartial':
$showcorrect = true;
$fraction = round($step->get_fraction(), 2);
$msg = get_string('partiallycorrectfeedback', 'qtype_ordering', $fraction);
break;
case 'gradedwrong':
$showcorrect = true;
$msg = get_string('incorrectfeedback', 'qtype_ordering');
break;
default:
$msg = '';
}
if ($msg) {
$output .= html_writer::tag('p', $msg);
}
}
return $feedback;
if ($showcorrect) {
$question = $qa->get_question();
$answers = $question->get_ordering_answers();
list($answerids, $correctorder) = $this->get_response($qa, $question, $answers);
if (count($correctorder)) {
$output .= html_writer::tag('p', get_string('correctorder', 'qtype_ordering'));
$output .= html_writer::start_tag('ol');
foreach ($correctorder as $position => $answerid) {
$output .= html_writer::tag('li', $answers[$answerid]->answer);
}
$output .= html_writer::end_tag('ol');
} else {
$output .= html_writer::tag('p', get_string('noresponsedetails', 'qtype_ordering'));
}
}
return $output;
}
public function get_response($qa, $question, $answers) {
$answerids = array();
$correctorder = array();
$name = $question->get_response_fieldname();
if ($step = $qa->get_last_step_with_qt_var($name)) {
$response = $step->get_qt_var($name); // "$md5key, ..."
$response = explode(',', $response); // array($position => $md5key, ...)
$response = array_flip($response); // array($md5key => $position, ...)
foreach ($answers as $answer) {
if (array_key_exists($answer->md5key, $response)) {
$position = $response[$answer->md5key];
$sortorder = intval($answer->fraction);
$answerids[$position] = $answer->id;
$correctorder[$sortorder] = $answer->id;
}
}
ksort($answerids);
ksort($correctorder);
$correctorder = array_values($correctorder);
}
return array($answerids, $correctorder);
}
}

View File

@ -0,0 +1,11 @@
ul.boxy li.correctposition {
border: 4px solid #99ff66; /* gentle green */
}
ul.boxy li.wrongposition {
border: 4px solid #ff7373; /* gentle red */
}
ul.boxy li.correctposition,
ul.boxy li.wrongposition {
margin-bottom: 4px;
margin-top: 4px;
}

View File

@ -30,6 +30,6 @@ defined('MOODLE_INTERNAL') || die();
$plugin->cron = 0;
$plugin->component = 'qtype_ordering';
$plugin->maturity = MATURITY_STABLE; // ALPHA=50, BETA=100, RC=150, STABLE=200
$plugin->release = '2014-05-14.01';
$plugin->version = 2014051401;
$plugin->release = '2014-07-26 (02)';
$plugin->version = 2014072602;
$plugin->requires = 2010112400; // Moodle 2.0