diff --git a/lang/en_utf8/question.php b/lang/en_utf8/question.php index b7e08fce5e3..97c5c3bc73c 100644 --- a/lang/en_utf8/question.php +++ b/lang/en_utf8/question.php @@ -44,6 +44,8 @@ $string['categorycurrent'] = 'Current Category'; $string['categorycurrentuse'] = 'Use This Category'; $string['categorymoveto'] = 'Save in Category'; $string['changepublishstatuscat'] = 'caturl\">Category \"$a->name\" in course \"$a->coursename\" will have it\'s sharing status changed from $a->changefrom to $a->changeto.'; +$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'] = '

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**'; diff --git a/lang/en_utf8/role.php b/lang/en_utf8/role.php index 2f6ba30357c..471ec5212b2 100644 --- a/lang/en_utf8/role.php +++ b/lang/en_utf8/role.php @@ -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'; diff --git a/lib/db/access.php b/lib/db/access.php index c4c5c938a54..5a9ccfc415a 100644 --- a/lib/db/access.php +++ b/lib/db/access.php @@ -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( diff --git a/lib/db/install.xml b/lib/db/install.xml index 707fa3318bb..dda246b2840 100644 --- a/lib/db/install.xml +++ b/lib/db/install.xml @@ -1043,7 +1043,8 @@ - + + diff --git a/lib/db/upgrade.php b/lib/db/upgrade.php index ff96c0a0111..00f20b31d34 100644 --- a/lib/db/upgrade.php +++ b/lib/db/upgrade.php @@ -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; } diff --git a/lib/questionlib.php b/lib/questionlib.php index d5474a22c18..d157c36eb8a 100644 --- a/lib/questionlib.php +++ b/lib/questionlib.php @@ -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 = '\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); +} + ?> diff --git a/mod/quiz/attemptlib.php b/mod/quiz/attemptlib.php index 7045b7fb838..53a9942c08c 100644 --- a/mod/quiz/attemptlib.php +++ b/mod/quiz/attemptlib.php @@ -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 ''; + $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 '' . $number . ''; } diff --git a/mod/quiz/lib.php b/mod/quiz/lib.php index 3d966e5ceaa..6d2c6a32afc 100644 --- a/mod/quiz/lib.php +++ b/mod/quiz/lib.php @@ -1257,6 +1257,7 @@ function quiz_get_extra_capabilities() { 'moodle/question:movemine', 'moodle/question:moveall', 'moodle/question:managecategory', + 'moodle/question:flag', ); } diff --git a/mod/quiz/locallib.php b/mod/quiz/locallib.php index 61565909ed2..17dbc0dbd0a 100644 --- a/mod/quiz/locallib.php +++ b/mod/quiz/locallib.php @@ -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; diff --git a/pix/i/flagged.png b/pix/i/flagged.png new file mode 100644 index 00000000000..1e3bed1fea6 Binary files /dev/null and b/pix/i/flagged.png differ diff --git a/pix/i/unflagged.png b/pix/i/unflagged.png new file mode 100644 index 00000000000..16d9362bf96 Binary files /dev/null and b/pix/i/unflagged.png differ diff --git a/question/preview.php b/question/preview.php index 19779cb9e3b..1cbdf50ba77 100644 --- a/question/preview.php +++ b/question/preview.php @@ -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) { diff --git a/question/qengine.js b/question/qengine.js new file mode 100644 index 00000000000..a72804e40e1 --- /dev/null +++ b/question/qengine.js @@ -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; + } + } +}; diff --git a/question/toggleflag.php b/question/toggleflag.php new file mode 100644 index 00000000000..d095674dbe6 --- /dev/null +++ b/question/toggleflag.php @@ -0,0 +1,48 @@ +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'; +?> \ No newline at end of file diff --git a/question/type/question.html b/question/type/question.html index 9a076528aef..12154dc84e5 100644 --- a/question/type/question.html +++ b/question/type/question.html @@ -13,7 +13,8 @@

- + print_question_flag($question, $state, $options->flags); ?>
print_question_formulation_and_controls($question, $state, $cmoptions, $options); diff --git a/question/type/questiontype.php b/question/type/questiontype.php index 9eda7b8f8a8..296b98fed1b 100644 --- a/question/type/questiontype.php +++ b/question/type/questiontype.php @@ -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&aid=$aid&qid=$qid&checksum=$checksum&sesskey=" . sesskey(); + $flagcontent = '' . + '' . + "\n" . '"; + break; + default: + $flagcontent = ''; + } + if ($flagcontent) { + echo '
' . $flagcontent . "
\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 '' . get_string('flagthisquestion', 'question') . ''; + } + + /** * Print history of responses * * Used by print_question() diff --git a/theme/standard/styles_layout.css b/theme/standard/styles_layout.css index 60ca5ec6db7..c29cf6c6d9d 100644 --- a/theme/standard/styles_layout.css +++ b/theme/standard/styles_layout.css @@ -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; diff --git a/version.php b/version.php index 3a91811a51b..581230faaa2 100644 --- a/version.php +++ b/version.php @@ -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