MDL-16263 A way for students to flag/bookmark, particular questions during a quiz attempt for later review.

This is an initial implementation that is now at a working state, but with a few things left to do. It seemed like a good idea to commit it before leaving work on Friday night.
This commit is contained in:
tjhunt 2008-08-29 10:08:27 +00:00
parent 57f43d239a
commit 62e76c6766
18 changed files with 340 additions and 29 deletions

View File

@ -44,6 +44,8 @@ $string['categorycurrent'] = 'Current Category';
$string['categorycurrentuse'] = 'Use This Category';
$string['categorymoveto'] = 'Save in Category';
$string['changepublishstatuscat'] = '<a href=\"$a->caturl\">Category \"$a->name\"</a> in course \"$a->coursename\" will have it\'s sharing status changed from <strong>$a->changefrom to $a->changeto</strong>.';
$string['clicktoflag'] = 'Click to flag this question';
$string['clicktounflag'] = 'Click to un-flag this question';
$string['cwrqpfs'] = 'Random questions selecting questions from sub categories.';
$string['cwrqpfsinfo'] = '<p>During the upgrade to Moodle 1.9 we will separate question categories into
different contexts. Some question categories and questions on your site will have to have their sharing
@ -86,6 +88,8 @@ $string['exporterror'] = 'Errors occur during exporting!';
$string['filesareasite']= 'the site files area';
$string['filesareacourse']= 'the course files area';
$string['filestomove']= 'Move / copy files to $a?';
$string['flagged'] = 'Flagged';
$string['flagthisquestion'] = 'Flag this question';
$string['formquestionnotinids'] = 'Form contained question that is not in questionids';
$string['fractionsnomax'] = 'One of the answers should have a score of 100%% so it is possible to get full marks for this question.';
$string['getcategoryfromfile'] = 'Get category from file';
@ -123,6 +127,7 @@ $string['nopermissionadd'] = 'You don\'t have permission to add questions here.'
$string['noprobs'] = 'No problems found in your question database.';
$string['notenoughdatatoeditaquestion'] = 'Neither a question id, nor a category id and question type, was specified.';
$string['notenoughdatatomovequestions'] = 'You need to provide the question ids of questions you want to move.';
$string['notflagged'] = 'Not flagged';
$string['novirtualquestiontype'] = 'No virtual question type for question type $a';
$string['parenthesisinproperstart'] = 'Parenthesis before ** is not properly started in $a**';
$string['parenthesisinproperclose'] = 'Parenthesis before ** is not properly closed in $a**';

View File

@ -110,6 +110,7 @@ $string['question:add'] = 'Add new questions';
$string['question:config'] = 'Configure question types';
$string['question:editall'] = 'Edit all questions';
$string['question:editmine'] = 'Edit your own questions';
$string['question:flag'] = 'Flag questions while attempting them';
$string['question:managecategory'] = 'Edit question categories';
$string['question:moveall'] = 'Move all questions';
$string['question:movemine'] = 'Move your own questions';

View File

@ -1002,8 +1002,20 @@ $moodle_capabilities = array(
)
),
'moodle/site:doclinks' => array(
// While attempting questions, the ability to flag particular questions for later reference.
'moodle/question:flag' => array(
'captype' => 'write',
'contextlevel' => CONTEXT_COURSE,
'legacy' => array(
'student' => CAP_ALLOW,
'teacher' => CAP_ALLOW,
'editingteacher' => CAP_ALLOW,
'coursecreator' => CAP_ALLOW,
'admin' => CAP_ALLOW
)
),
'moodle/site:doclinks' => array(
'captype' => 'read',
'contextlevel' => CONTEXT_SYSTEM,
'legacy' => array(

View File

@ -1043,7 +1043,8 @@
<FIELD NAME="newest" TYPE="int" LENGTH="10" NOTNULL="true" UNSIGNED="true" DEFAULT="0" SEQUENCE="false" ENUM="false" PREVIOUS="questionid" NEXT="newgraded"/>
<FIELD NAME="newgraded" TYPE="int" LENGTH="10" NOTNULL="true" UNSIGNED="true" DEFAULT="0" SEQUENCE="false" ENUM="false" PREVIOUS="newest" NEXT="sumpenalty"/>
<FIELD NAME="sumpenalty" TYPE="number" LENGTH="12" NOTNULL="true" UNSIGNED="false" DEFAULT="0" SEQUENCE="false" ENUM="false" DECIMALS="7" PREVIOUS="newgraded" NEXT="manualcomment"/>
<FIELD NAME="manualcomment" TYPE="text" LENGTH="small" NOTNULL="true" SEQUENCE="false" ENUM="false" PREVIOUS="sumpenalty"/>
<FIELD NAME="manualcomment" TYPE="text" LENGTH="small" NOTNULL="true" SEQUENCE="false" ENUM="false" PREVIOUS="sumpenalty" NEXT="flagged"/>
<FIELD NAME="flagged" TYPE="int" LENGTH="2" NOTNULL="true" UNSIGNED="false" DEFAULT="0" SEQUENCE="false" ENUM="false" COMMENT="The person attempting the question may mark certain questions within their question_attempt if the module that owns the attempt allow it. This field stores the status of that flag." PREVIOUS="manualcomment"/>
</FIELDS>
<KEYS>
<KEY NAME="primary" TYPE="primary" FIELDS="id" NEXT="attemptid"/>

View File

@ -721,6 +721,22 @@ function xmldb_main_upgrade($oldversion) {
upgrade_main_savepoint($result, 2008082602);
}
if ($result && $oldversion < 2008082700) {
/// Add a new column to the question sessions table to record whether a
/// question has been flagged.
/// Define field flagged to be added to question_sessions
$table = new xmldb_table('question_sessions');
$field = new xmldb_field('flagged', XMLDB_TYPE_INTEGER, '2', null, XMLDB_NOTNULL, null, null, null, '0', 'manualcomment');
/// Conditionally launch add field flagged
if (!$dbman->field_exists($table, $field)) {
$dbman->add_field($table, $field);
}
/// Main savepoint reached
upgrade_main_savepoint($result, 2008082700);
}
return $result;
}

View File

@ -94,17 +94,24 @@ define('QUESTION_PREVIEW_POPUP_OPTIONS', 'scrollbars=yes,resizable=yes,width=700
* is how question is Moodle always worked before version 1.5
*/
define('QUESTION_ADAPTIVE', 1);
/**#@-*/
/**
* options used in forms that move files.
*
/**#@+
* Options used in forms that move files.
*/
define('QUESTION_FILENOTHINGSELECTED', 0);
define('QUESTION_FILEDONOTHING', 1);
define('QUESTION_FILECOPY', 2);
define('QUESTION_FILEMOVE', 3);
define('QUESTION_FILEMOVELINKSONLY', 4);
/**#@-*/
/**#@+
* Options for whether flags are shown/editable when rendering questions.
*/
define('QUESTION_FLAGSHIDDEN', 0);
define('QUESTION_FLAGSSHOWN', 1);
define('QUESTION_FLAGSEDITABLE', 2);
/**#@-*/
/// QTYPES INITIATION //////////////////
@ -909,7 +916,7 @@ function get_question_states(&$questions, $cmoptions, $attempt, $lastattemptid =
// The question field must be listed first so that it is used as the
// array index in the array returned by $DB->get_records_sql
$statefields = 'n.questionid as question, s.*, n.sumpenalty, n.manualcomment';
$statefields = 'n.questionid as question, s.*, n.sumpenalty, n.manualcomment, n.flagged, n.id as questionsessionid';
// Load the newest states for the questions
$sql = "SELECT $statefields
FROM {question_states} s, {question_sessions} n
@ -1816,6 +1823,34 @@ function question_format_grade($cmoptions, $grade) {
return format_float($grade, $cmoptions->decimalpoints);
}
/**
* @return string An inline script that creates a JavaScript object storing
* various strings and bits of configuration that the scripts in qengine.js need
* to get from PHP.
*/
function question_init_qenginejs_script() {
global $CFG;
// Get the properties we want into a PHP array first, becase that is easier
// to build.
$config = array(
'pixpath' => $CFG->pixpath,
'wwwroot' => $CFG->wwwroot,
'flagtooltip' => get_string('clicktoflag', 'question'),
'unflagtooltip' => get_string('clicktounflag', 'question'),
'flaggedalt' => get_string('flagged', 'question'),
'unflaggedalt' => get_string('notflagged', 'question'),
);
// Then generate the script tag.
$script = '<script type="text/javascript">qengine_config = {' . "\n";
foreach ($config as $property => $value) {
$script .= " $property: '" . addslashes_js($value) . "',\n";
}
$script .= "};</script>\n";
return $script;
}
/// FUNCTIONS THAT SIMPLY WRAP QUESTIONTYPE METHODS //////////////////////////////////
/**
* Get the HTML that needs to be included in the head tag when the
@ -1830,15 +1865,23 @@ function question_format_grade($cmoptions, $grade) {
* @return string some HTML code that can go inside the head tag.
*/
function get_html_head_contributions($questionlist, &$questions, &$states) {
global $QTYPES;
global $CFG, $QTYPES;
$contributions = array();
// The question engine's own JavaScript.
require_js(array('yui_yahoo','yui_event', 'yui_connection'));
require_js($CFG->wwwroot . '/question/qengine.js');
// An inline script to record various lang strings, etc. that qengine.js needs.
$contributions = array(question_init_qenginejs_script());
// Anything that questions on this page need.
foreach ($questionlist as $questionid) {
$question = $questions[$questionid];
$contributions = array_merge($contributions,
$QTYPES[$question->qtype]->get_html_head_contributions(
$question, $states[$questionid]));
}
return implode("\n", array_unique($contributions));
}
@ -2509,4 +2552,31 @@ function question_get_real_state($state){
}
}
/**
* Update the flagged state of a particular question session.
*
* @param integer $sessionid question_session id.
* @param boolean $newstate the new state for the flag.
* @return boolean success or failure.
*/
function question_update_flag($sessionid, $newstate) {
global $DB;
return $DB->set_field('question_sessions', 'flagged', $newstate, array('id' => $sessionid));
}
/**
* @param integer $attemptid the question_attempt id.
* @param integer $questionid the question id.
* @param integer $sessionid the question_session id.
* @param object $user a user, or null to use $USER.
* @return string that needs to be sent to question/toggleflag.php for it to work.
*/
function question_get_toggleflag_checksum($attemptid, $questionid, $sessionid, $user = null) {
if (is_null($user)) {
global $USER;
$user = $USER;
}
return md5($attemptid . "_" . $user->secret . "_" . $questionid . "_" . $sessionid);
}
?>

View File

@ -497,7 +497,7 @@ class quiz_attempt extends quiz {
* @return object the render options for this user on this attempt.
*/
public function get_render_options($state) {
return quiz_get_renderoptions($this->quiz->review, $state);
return quiz_get_renderoptions($this->quiz, $this->attempt, $this->context, $state);
}
/**
@ -534,7 +534,7 @@ class quiz_attempt extends quiz {
case QUESTION_EVENTCLOSEANDGRADE:
case QUESTION_EVENTCLOSE:
case QUESTION_EVENTMANUALGRADE:
$options = quiz_get_renderoptions($this->quiz->review, $this->states[$questionid]);
$options = $this->get_render_options($this->states[$questionid]);
if ($options->scores) {
return question_get_feedback_class($state->last_graded->raw_grade /
$this->questions[$questionid]->maxgrade);
@ -551,6 +551,16 @@ class quiz_attempt extends quiz {
}
}
/**
* @param integer $questionid question id of a question that belongs to this quiz.
* @return boolean whether this question hss been flagged by the attempter.
*/
public function is_question_flagged($questionid) {
$this->ensure_state_loaded($questionid);
$state = $this->states[$questionid];
return $state->flagged;
}
/**
* Return the grade obtained on a particular question, if the user is permitted to see it.
* You must previously have called load_question_states to load the state data about this question.
@ -560,7 +570,7 @@ class quiz_attempt extends quiz {
*/
public function get_question_score($questionid) {
$this->ensure_state_loaded($questionid);
$options = quiz_get_renderoptions($this->quiz->review, $this->states[$questionid]);
$options = $this->get_render_options($this->quiz->review, $this->states[$questionid]);
if ($options->scores) {
return quiz_format_grade($this->quiz, $this->states[$questionid]->last_graded->grade);
} else {
@ -803,12 +813,20 @@ abstract class quiz_nav_panel_base {
abstract protected function get_end_bits();
protected function get_question_state($question) {
$state = 'todo'; // TODO MDL-15653
protected function get_question_state_classes($question) {
// The current status of the question.
$classes = $this->attemptobj->get_question_status($question->id);
// Plus a marker for the current page.
if ($question->_page == $this->page) {
$state .= ' thispage';
$classes .= ' thispage';
}
return $state;
// Plus a marker for flagged questions.
if ($this->attemptobj->is_question_flagged($question->id)) {
$classes .= ' flagged';
}
return $classes;
}
public function display() {
@ -833,7 +851,7 @@ class quiz_attempt_nav_panel extends quiz_nav_panel_base {
}
return '<input type="submit" name="gotopage' . $question->_page .
'" value="' . $number . '" class="qnbutton ' .
$this->get_question_state($question) . '"' . $onclick . '/>';
$this->get_question_state_classes($question) . '"' . $onclick . '/>';
}
protected function get_end_bits() {
@ -853,7 +871,7 @@ class quiz_review_nav_panel extends quiz_nav_panel_base {
protected function get_question_button($number, $question) {
return '<a href="' . $this->attemptobj->review_url($question->id) .
'" class="qnbutton ' . $this->get_question_state($question) .
'" class="qnbutton ' . $this->get_question_state_classes($question) .
'">' . $number . '</a>';
}

View File

@ -1257,6 +1257,7 @@ function quiz_get_extra_capabilities() {
'moodle/question:movemine',
'moodle/question:moveall',
'moodle/question:managecategory',
'moodle/question:flag',
);
}

View File

@ -752,15 +752,38 @@ function quiz_question_preview_button($quiz, $question) {
0, 0, $strpreview, QUESTION_PREVIEW_POPUP_OPTIONS, true);
}
/**
* @param object $attempt the attempt.
* @param object $context the quiz context.
* @return integer whether flags should be shown/editable to the current user for this attempt.
*/
function quiz_get_flag_option($attempt, $context) {
global $USER;
static $flagmode = null;
if (is_null($flagmode)) {
if (!has_capability('moodle/question:flag', $context)) {
$flagmode = QUESTION_FLAGSHIDDEN;
} else if ($attempt->userid == $USER->id) {
$flagmode = QUESTION_FLAGSEDITABLE;
} else {
$flagmode = QUESTION_FLAGSSHOWN;
}
}
return $flagmode;
}
/**
* Determine render options
*
* @param int $reviewoptions
* @param object $state
*/
function quiz_get_renderoptions($reviewoptions, $state) {
function quiz_get_renderoptions($quiz, $attempt, $context, $state) {
$reviewoptions = $quiz->review;
$options = new stdClass;
$options->flags = quiz_get_flag_option($attempt, $context);
// Show the question in readonly (review) mode if the question is in
// the closed state
$options->readonly = question_state_is_closed($state);
@ -791,28 +814,31 @@ function quiz_get_renderoptions($reviewoptions, $state) {
*
* @param object $quiz the quiz instance.
* @param object $attempt the attempt in question.
* @param $context the roles and permissions context,
* normally the context for the quiz module instance.
* @param $context the quiz module context.
*
* @return object an object with boolean fields responses, scores, feedback,
* correct_responses, solutions and general feedback
*/
function quiz_get_reviewoptions($quiz, $attempt, $context=null) {
function quiz_get_reviewoptions($quiz, $attempt, $context) {
global $USER;
$options = new stdClass;
$options->readonly = true;
$options->flags = quiz_get_flag_option($attempt, $context);
// Provide the links to the question review and comment script
if (!empty($attempt->id)) {
$options->questionreviewlink = '/mod/quiz/reviewquestion.php?attempt=' . $attempt->id;
}
// Show a link to the comment box only for closed attempts
if ($attempt->timefinish && !is_null($context) && has_capability('mod/quiz:grade', $context)) {
if ($attempt->timefinish && has_capability('mod/quiz:grade', $context)) {
$options->questioncommentlink = '/mod/quiz/comment.php';
}
// Whether to display a response history.
$canviewreports = !is_null($context) && has_capability('mod/quiz:viewreports', $context);
$canviewreports = has_capability('mod/quiz:viewreports', $context);
$options->history = ($canviewreports && !$attempt->preview) ? 'all' : 'graded';
if ($canviewreports && has_capability('moodle/grade:viewhidden', $context) && !$attempt->preview) {
@ -867,7 +893,7 @@ function quiz_get_reviewoptions($quiz, $attempt, $context=null) {
* at least one of the attempts, the other showing which options are true
* for all attempts.
*/
function quiz_get_combined_reviewoptions($quiz, $attempts, $context=null) {
function quiz_get_combined_reviewoptions($quiz, $attempts, $context) {
$fields = array('readonly', 'scores', 'feedback', 'correct_responses', 'solutions', 'generalfeedback', 'overallfeedback');
$someoptions = new stdClass;
$alloptions = new stdClass;

BIN
pix/i/flagged.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 B

BIN
pix/i/unflagged.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 B

View File

@ -68,10 +68,13 @@
$quiz->review = $CFG->quiz_review;
require_login($courseid, false);
$quiz->course = $courseid;
$context = get_context_instance(CONTEXT_COURSE, $courseid);
} else if (!$quiz = $DB->get_record('quiz', array('id' => $quizid))) {
print_error('invalidquizid', 'quiz', '', $quizid);
} else {
require_login($quiz->course, false, get_coursemodule_from_instance('quiz', $quizid, $quiz->course));
$cm = get_coursemodule_from_instance('quiz', $quizid, $quiz->course);
require_login($quiz->course, false, $cm);
$context = get_context_instance(CONTEXT_MODULE, $cm->id);
}
@ -187,7 +190,7 @@
}
// TODO: should not use quiz-specific function here
$options = quiz_get_renderoptions($quiz->review, $curstate);
$options = quiz_get_renderoptions($quiz, $attempt, $context, $curstate);
// Fill in the correct responses (unless the question is in readonly mode)
if ($fillcorrect && !$options->readonly) {

38
question/qengine.js Normal file
View File

@ -0,0 +1,38 @@
// This script, and the YUI libraries that it needs, are inluded by
// the require_js calls in get_html_head_contributions in lib/questionlib.php.
question_flag_changer = {
init_flag: function(checkboxid, postdata) {
var checkbox = document.getElementById(checkboxid);
checkbox.ajaxpostdata = postdata;
checkbox.className += ' jsworking';
question_flag_changer.update_image(checkbox);
YAHOO.util.Event.addListener(checkbox, 'change', this.checkbox_state_change);
YAHOO.util.Event.addListener(checkbox, 'focus', 'blur()');
},
checkbox_state_change: function(e) {
var checkbox = e.target ? e.target : e.srcElement;
question_flag_changer.update_image(checkbox);
var postdata = checkbox.ajaxpostdata
if (checkbox.checked) {
postdata += '&newstate=1'
} else {
postdata += '&newstate=0'
}
YAHOO.util.Connect.asyncRequest('POST', qengine_config.wwwroot + '/question/toggleflag.php', null, postdata);
},
update_image: function(checkbox) {
var img = document.getElementById(checkbox.id + 'img');
if (checkbox.checked) {
img.src = qengine_config.pixpath + '/i/flagged.png';
img.alt = qengine_config.flaggedalt;
img.title = qengine_config.unflagtooltip;
} else {
img.src = qengine_config.pixpath + '/i/unflagged.png';
img.alt = qengine_config.unflaggedalt;
img.title = qengine_config.flagtooltip;
}
}
};

48
question/toggleflag.php Normal file
View File

@ -0,0 +1,48 @@
<?php // $Id$
/**
* Used by ajax calls to toggle the flagged state of a question in an attempt.
* @license http://www.gnu.org/copyleft/gpl.html GNU Public License
* @package questionbank
*/
require_once('../config.php');
require_once($CFG->libdir.'/questionlib.php');
// Parameters
$sessionid = required_param('qsid', PARAM_INT);
$attemptid = required_param('aid', PARAM_INT);
$questionid = required_param('qid', PARAM_INT);
$newstate = required_param('newstate', PARAM_BOOL);
$checksum = required_param('checksum', PARAM_ALPHANUM);
// Check user is logged in.
require_login();
// Check the sesskey.
if (!confirm_sesskey()) {
echo 'sesskey failure';
}
// Check the checksum - it is very hard to know who a question session belongs
// to, so we require that checksum parameter is matches an md5 hash of the
// three ids and the users username. Since we are only updating a flag, that
// probably makes it sufficiently difficult for malicious users to toggle
// other users flags.
if ($checksum != md5($attemptid . "_" . $USER->secret . "_" . $questionid . "_" . $sessionid)) {
echo 'checksum failure';
}
// Check that the requested session really exists
$questionsession = $DB->get_record('question_sessions', array('id' => $sessionid,
'attemptid' => $attemptid, 'questionid' => $questionid));
if (!$questionsession) {
echo 'invalid ids';
}
// Now change state
if (!question_update_flag($sessionid, $newstate)) {
echo 'update failed';
}
echo 'OK';
?>

View File

@ -13,7 +13,8 @@
<div class="grade">
<?php echo get_string('marks', 'quiz').': '.$grade; ?>
</div>
<?php } ?>
<?php }
$this->print_question_flag($question, $state, $options->flags); ?>
</div>
<div class="content">
<?php $this->print_question_formulation_and_controls($question, $state, $cmoptions, $options);

View File

@ -879,7 +879,69 @@ class default_questiontype {
include "$CFG->dirroot/question/type/question.html";
}
/*
/**
* Render the question flag, assuming $flagsoption allows it. You will probably
* never need to override this method.
*
* @param object $question the question
* @param object $state its current state
* @param integer $flagsoption the option that says whether flags should be displayed.
*/
protected function print_question_flag($question, $state, $flagsoption) {
global $CFG;
switch ($flagsoption) {
case QUESTION_FLAGSSHOWN:
$flagcontent = $this->get_question_flag_tag($state->flagged);
break;
case QUESTION_FLAGSEDITABLE:
$id = $question->name_prefix . '_flagged';
if ($state->flagged) {
$checked = 'checked="checked" ';
} else {
$checked = '';
}
$qsid = $state->questionsessionid;
$aid = $state->attempt;
$qid = $state->question;
$checksum = question_get_toggleflag_checksum($aid, $qid, $qsid);
$postdata = "qsid=$qsid&amp;aid=$aid&amp;qid=$qid&amp;checksum=$checksum&amp;sesskey=" . sesskey();
$flagcontent = '<input type="checkbox" id="' . $id . '" name="' . $id .
'" value="1" ' . $checked . ' />' .
'<label for="' . $id . '">' . $this->get_question_flag_tag(
$state->flagged, $id . 'img') . '</label>' .
"\n" . '<script type="text/javascript">question_flag_changer.init_flag(' .
"'$id', '$postdata');</script>";
break;
default:
$flagcontent = '';
}
if ($flagcontent) {
echo '<div class="questionflag">' . $flagcontent . "</div>\n";
}
}
/**
* Work out the actual img tag needed for the flag
*
* @param boolean $flagged whether the question is currently flagged.
* @param string $id an id to be added as an attribute to the img (optional).
* @return string the img tag.
*/
protected function get_question_flag_tag($flagged, $id = '') {
global $CFG;
if ($id) {
$id = 'id="' . $id . '" ';
}
if ($flagged) {
$img = 'flagged.png';
} else {
$img = 'unflagged.png';
}
return '<img ' . $id . 'src="' . $CFG->pixpath . '/i/' . $img .
'" alt="' . get_string('flagthisquestion', 'question') . '" />';
}
/**
* Print history of responses
*
* Used by print_question()

View File

@ -2623,6 +2623,15 @@ body.notes .notesgroup {
.que .info div {
margin-left: 1em;
}
.que .info .questionflag {
margin-top: 1em;
margin-right: 1em;
text-align: center;
}
.que .info .questionflag .jsworking {
position: absolute;
visibility: hidden;
}
.que .content {
float: left;
margin: 0;

View File

@ -6,7 +6,7 @@
// This is compared against the values stored in the database to determine
// whether upgrades should be performed (see lib/db/*.php)
$version = 2008082602; // YYYYMMDD = date of the last version bump
$version = 2008082702; // YYYYMMDD = date of the last version bump
// XX = daily increments
$release = '2.0 dev (Build: 20080829)'; // Human-friendly version name