From 20b77578d070528dd8550d225f6cb97907a47089 Mon Sep 17 00:00:00 2001 From: Tim Hunt Date: Thu, 10 Nov 2011 16:37:18 +0000 Subject: [PATCH 01/20] MDL-3030 quiz overdue handling: new DB columns. --- mod/quiz/db/install.xml | 12 +++-- mod/quiz/db/upgrade.php | 99 +++++++++++++++++++++++++++++++++++++++++ mod/quiz/version.php | 2 +- 3 files changed, 108 insertions(+), 5 deletions(-) diff --git a/mod/quiz/db/install.xml b/mod/quiz/db/install.xml index 8183c76ff6a..0ec9326e4cc 100644 --- a/mod/quiz/db/install.xml +++ b/mod/quiz/db/install.xml @@ -42,7 +42,9 @@ - + + + @@ -65,15 +67,17 @@ - + + - + + - + diff --git a/mod/quiz/db/upgrade.php b/mod/quiz/db/upgrade.php index f6a80e4be27..a7e63951cba 100644 --- a/mod/quiz/db/upgrade.php +++ b/mod/quiz/db/upgrade.php @@ -94,6 +94,105 @@ function xmldb_quiz_upgrade($oldversion) { upgrade_mod_savepoint(true, 2012030901, 'quiz'); } + if ($oldversion < 2012040200) { + // Define index quiz-userid-attempt (unique) to be dropped form quiz_attempts + $table = new xmldb_table('quiz_attempts'); + $index = new xmldb_index('userid', XMLDB_INDEX_NOTUNIQUE, array('userid')); + + // Conditionally launch drop index quiz-userid-attempt + if ($dbman->index_exists($table, $index)) { + $dbman->drop_index($table, $index); + } + + // quiz savepoint reached + upgrade_mod_savepoint(true, 2012040200, 'quiz'); + } + + if ($oldversion < 2012040201) { + + // Define key userid (foreign) to be added to quiz_attempts + $table = new xmldb_table('quiz_attempts'); + $key = new xmldb_key('userid', XMLDB_KEY_FOREIGN, array('userid'), 'user', array('id')); + + // Launch add key userid + $dbman->add_key($table, $key); + + // quiz savepoint reached + upgrade_mod_savepoint(true, 2012040201, 'quiz'); + } + + if ($oldversion < 2012040202) { + + // Define index quiz-userid-attempt (unique) to be added to quiz_attempts + $table = new xmldb_table('quiz_attempts'); + $index = new xmldb_index('quiz-userid-attempt', XMLDB_INDEX_UNIQUE, array('quiz', 'userid', 'attempt')); + + // Conditionally launch add index quiz-userid-attempt + if (!$dbman->index_exists($table, $index)) { + $dbman->add_index($table, $index); + } + + // quiz savepoint reached + upgrade_mod_savepoint(true, 2012040202, 'quiz'); + } + + if ($oldversion < 2012040203) { + + // Define field state to be added to quiz_attempts + $table = new xmldb_table('quiz_attempts'); + $field = new xmldb_field('state', XMLDB_TYPE_CHAR, '16', null, XMLDB_NOTNULL, null, 'inprogress', 'preview'); + + // Conditionally launch add field state + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // quiz savepoint reached + upgrade_mod_savepoint(true, 2012040203, 'quiz'); + } + + if ($oldversion < 2012040204) { + + // Update quiz_attempts.state for finished attempts. + $DB->set_field_select('quiz_attempts', 'state', 'finished', 'timefinish > 0'); + + // Other, more complex transitions (basically abandoned attempts), will + // be handled by cron later. + + // quiz savepoint reached + upgrade_mod_savepoint(true, 2012040204, 'quiz'); + } + + if ($oldversion < 2012040205) { + + // Define field overduehandling to be added to quiz + $table = new xmldb_table('quiz'); + $field = new xmldb_field('overduehandling', XMLDB_TYPE_CHAR, '16', null, XMLDB_NOTNULL, null, 'autoabandon', 'timelimit'); + + // Conditionally launch add field overduehandling + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // quiz savepoint reached + upgrade_mod_savepoint(true, 2012040205, 'quiz'); + } + + if ($oldversion < 2012040206) { + + // Define field graceperiod to be added to quiz + $table = new xmldb_table('quiz'); + $field = new xmldb_field('graceperiod', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0', 'overduehandling'); + + // Conditionally launch add field graceperiod + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // quiz savepoint reached + upgrade_mod_savepoint(true, 2012040206, 'quiz'); + } + return true; } diff --git a/mod/quiz/version.php b/mod/quiz/version.php index 122bfeb67c2..a3dffd4719e 100644 --- a/mod/quiz/version.php +++ b/mod/quiz/version.php @@ -25,7 +25,7 @@ defined('MOODLE_INTERNAL') || die(); -$module->version = 2012030901; // The current module version (Date: YYYYMMDDXX) +$module->version = 2012040206; // The current module version (Date: YYYYMMDDXX) $module->requires = 2012030100.04; // Requires this Moodle version $module->component = 'mod_quiz'; // Full name of the plugin (used for diagnostics) $module->cron = 60; From c761b7757d8be1671de1807c0b3288097793fdd8 Mon Sep 17 00:00:00 2001 From: Tim Hunt Date: Thu, 10 Nov 2011 17:15:18 +0000 Subject: [PATCH 02/20] MDL-3030 quiz db: add comments to many colums, and reorder more logically. --- mod/quiz/db/install.xml | 102 ++++++++++++++++++++-------------------- 1 file changed, 51 insertions(+), 51 deletions(-) diff --git a/mod/quiz/db/install.xml b/mod/quiz/db/install.xml index 0ec9326e4cc..0c15b604fbf 100644 --- a/mod/quiz/db/install.xml +++ b/mod/quiz/db/install.xml @@ -4,21 +4,24 @@ xsi:noNamespaceSchemaLocation="../../../lib/xmldb/xmldb.xsd" > -
+
- - - - - - - - - - - - - + + + + + + + + + + + + + + + + @@ -26,25 +29,22 @@ - - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - - + @@ -53,34 +53,34 @@
- +
- - - - - - - - - - + + + + + + - - - + + + + + + + - - - - + + + +
- +
From 84da4788fde3b91bb73ca8ab09a780cd0e5b04ea Mon Sep 17 00:00:00 2001 From: Tim Hunt Date: Thu, 10 Nov 2011 17:25:49 +0000 Subject: [PATCH 03/20] MDL-3030 quiz settings: reorganise form before adding new fields. --- mod/quiz/lang/en/quiz.php | 1 + mod/quiz/mod_form.php | 19 +++++++++++-------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/mod/quiz/lang/en/quiz.php b/mod/quiz/lang/en/quiz.php index a7cc6c4a535..ede2da42a1e 100644 --- a/mod/quiz/lang/en/quiz.php +++ b/mod/quiz/lang/en/quiz.php @@ -757,6 +757,7 @@ $string['timelimitsec'] = 'Time limit (seconds)'; $string['timestr'] = '%H:%M:%S on %d/%m/%y'; $string['timesup'] = 'Time is up!'; $string['timetaken'] = 'Time taken'; +$string['timing'] = 'Timing'; $string['tofile'] = 'to file'; $string['tolerance'] = 'Tolerance'; $string['toomanyrandom'] = 'The number of random questions required is more than are still available in the category!'; diff --git a/mod/quiz/mod_form.php b/mod/quiz/mod_form.php index 6c45f5db777..c821bed469d 100644 --- a/mod/quiz/mod_form.php +++ b/mod/quiz/mod_form.php @@ -73,6 +73,9 @@ class mod_quiz_mod_form extends moodleform_mod { // Introduction. $this->add_intro_editor(false, get_string('introduction', 'quiz')); + //------------------------------------------------------------------------------- + $mform->addElement('header', 'timing', get_string('timing', 'quiz')); + // Open and close dates. $mform->addElement('date_time_selector', 'timeopen', get_string('quizopen', 'quiz'), array('optional' => true, 'step' => 1)); @@ -88,6 +91,14 @@ class mod_quiz_mod_form extends moodleform_mod { $mform->setAdvanced('timelimit', $quizconfig->timelimit_adv); $mform->setDefault('timelimit', $quizconfig->timelimit); + //------------------------------------------------------------------------------- + // Grade settings + $this->standard_grading_coursemodule_elements(); + + $mform->removeElement('grade'); + $mform->addElement('hidden', 'grade', $quizconfig->maximumgrade); + $mform->setType('grade', PARAM_NUMBER); + // Number of attempts. $attemptoptions = array('0' => get_string('unlimited')); for ($i = 1; $i <= QUIZ_MAX_ATTEMPT_OPTION; $i++) { @@ -106,14 +117,6 @@ class mod_quiz_mod_form extends moodleform_mod { $mform->setDefault('grademethod', $quizconfig->grademethod); $mform->disabledIf('grademethod', 'attempts', 'eq', 1); - //------------------------------------------------------------------------------- - // Grade settings - $this->standard_grading_coursemodule_elements(); - - $mform->removeElement('grade'); - $mform->addElement('hidden', 'grade', $quizconfig->maximumgrade); - $mform->setType('grade', PARAM_NUMBER); - //------------------------------------------------------------------------------- $mform->addElement('header', 'layouthdr', get_string('layout', 'quiz')); From 87427d9c7cf9fa36c70dbc7be743333ce68f582e Mon Sep 17 00:00:00 2001 From: Tim Hunt Date: Thu, 10 Nov 2011 18:27:31 +0000 Subject: [PATCH 04/20] MDL-3030 quiz overdue handling: new settings on quiz form and admin. --- mod/quiz/lang/en/quiz.php | 10 ++++++++++ mod/quiz/locallib.php | 15 ++++++++++++++- mod/quiz/mod_form.php | 17 +++++++++++++++++ mod/quiz/settings.php | 15 +++++++++++++++ mod/quiz/settingslib.php | 24 ++++++++++++++++++++++++ 5 files changed, 80 insertions(+), 1 deletion(-) diff --git a/mod/quiz/lang/en/quiz.php b/mod/quiz/lang/en/quiz.php index ede2da42a1e..0539bfdbb4a 100644 --- a/mod/quiz/lang/en/quiz.php +++ b/mod/quiz/lang/en/quiz.php @@ -317,6 +317,11 @@ $string['fromfile'] = 'from file:'; $string['functiondisabledbysecuremode'] = 'That functionality is currently disabled'; $string['generalfeedback'] = 'General feedback'; $string['generalfeedback_help'] = 'General feedback is text which is shown after a question has been attempted. Unlike feedback for a specific question which depends on the response given, the same general feedback is always shown.'; +$string['graceperiod'] = 'Submission grace period'; +$string['graceperiod_desc'] = 'If what to do when time expires is set to \'Allow a grace period to submit, but not change any responses\', the default amount of extra time that is allowed, in seconds.'; +$string['graceperiod_help'] = 'If what to do when time expires is set to \'Allow a grace period to submit, but not change any responses\', the amount of extra time that is allowed.'; +$string['graceperiodmin'] = 'Last submission grace period'; +$string['graceperiodmin_desc'] = 'There is a potential problem right at the end of the quiz. On the one hand, we want to let students continue working right up until the last second - with the help of the timer that automatically submits the quiz when time runs out. On the other hand, the server may then be overloaded, and take some time to get to process the responses. Therefore, we will accept responses for up to this many seconds after time expires, so they are not penalised for the server being slow. However, the student could cheat and get this many seconds to answer the quiz. You have to make a trade-off based on how much you trust the performance of your server during quizzes.'; $string['grade'] = 'Grade'; $string['gradeall'] = 'Grade all'; $string['gradeaverage'] = 'Average grade'; @@ -485,6 +490,11 @@ $string['outofshort'] = '{$a->grade}/{$a->maxgrade}'; $string['overallfeedback'] = 'Overall feedback'; $string['overallfeedback_help'] = 'Overall feedback is text that is shown after a quiz has been attempted. By specifying additional grade boundaries (as a percentage or as a number), the text shown can depend on the grade obtained.'; $string['overdue'] = 'Overdue'; +$string['overduehandling'] = 'When time expires'; +$string['overduehandling_desc'] = 'What should happen by default if a student does not submit the quiz before time expires.'; +$string['overduehandlingautosubmit'] = 'the attempt is submitted automatically'; +$string['overduehandlinggraceperiod'] = 'there is a grace period in which to submit the attempt, but not answer more questions'; +$string['overduehandlingautoabandon'] = 'that is it. The attempt must be submitted before time expires, or it is not counted'; $string['override'] = 'Override'; $string['overridedeletegroupsure'] = 'Are you sure you want to delete the override for group {$a}?'; $string['overridedeleteusersure'] = 'Are you sure you want to delete the override for user {$a}?'; diff --git a/mod/quiz/locallib.php b/mod/quiz/locallib.php index 6ad5cd93a88..9953761d04e 100644 --- a/mod/quiz/locallib.php +++ b/mod/quiz/locallib.php @@ -776,7 +776,8 @@ function quiz_calculate_best_attempt($quiz, $attempts) { } /** - * @return the options for calculating the quiz grade from the individual attempt grades. + * @return array int => lang string the options for calculating the quiz grade + * from the individual attempt grades. */ function quiz_get_grading_options() { return array( @@ -797,6 +798,18 @@ function quiz_get_grading_option_name($option) { return $strings[$option]; } +/** + * @return array string => lang string the options for handling overdue quiz + * attempts. + */ +function quiz_get_overdue_handling_options() { + return array( + 'autosubmit' => get_string('overduehandlingautosubmit', 'quiz'), + 'graceperiod' => get_string('overduehandlinggraceperiod', 'quiz'), + 'autoabandon' => get_string('overduehandlingautoabandon', 'quiz'), + ); +} + /// Other quiz functions //////////////////////////////////////////////////// /** diff --git a/mod/quiz/mod_form.php b/mod/quiz/mod_form.php index c821bed469d..dd16667b6af 100644 --- a/mod/quiz/mod_form.php +++ b/mod/quiz/mod_form.php @@ -91,6 +91,23 @@ class mod_quiz_mod_form extends moodleform_mod { $mform->setAdvanced('timelimit', $quizconfig->timelimit_adv); $mform->setDefault('timelimit', $quizconfig->timelimit); + // What to do with overdue attempts. + $mform->addElement('select', 'overduehandling', get_string('overduehandling', 'quiz'), + quiz_get_overdue_handling_options()); + $mform->setAdvanced('overduehandling', $quizconfig->overduehandling_adv); + $mform->setDefault('overduehandling', $quizconfig->overduehandling); + // TODO Formslib does OR logic on disableif, and we need AND logic here. + // $mform->disabledIf('overduehandling', 'timelimit', 'eq', 0); + // $mform->disabledIf('overduehandling', 'timeclose', 'eq', 0); + + // Grace period time. + $mform->addElement('duration', 'graceperiod', get_string('graceperiod', 'quiz'), + array('optional' => true)); + $mform->addHelpButton('graceperiod', 'graceperiod', 'quiz'); + $mform->setAdvanced('graceperiod', $quizconfig->graceperiod_adv); + $mform->setDefault('graceperiod', $quizconfig->graceperiod); + $mform->disabledIf('graceperiod', 'overduehandling', 'neq', 'graceperiod'); + //------------------------------------------------------------------------------- // Grade settings $this->standard_grading_coursemodule_elements(); diff --git a/mod/quiz/settings.php b/mod/quiz/settings.php index e122eb65cb2..938e1859d10 100644 --- a/mod/quiz/settings.php +++ b/mod/quiz/settings.php @@ -55,6 +55,21 @@ $quizsettings->add(new admin_setting_configtext_with_advanced('quiz/timelimit', get_string('timelimitsec', 'quiz'), get_string('configtimelimitsec', 'quiz'), array('value' => '0', 'fix' => false), PARAM_INT)); +// What to do with overdue attempts. +$quizsettings->add(new mod_quiz_admin_setting_overduehandling('quiz/overduehandling', + get_string('overduehandling', 'quiz'), get_string('overduehandling_desc', 'quiz'), + array('value' => 'autoabandon', 'fix' => false), null)); + +// Grace period time. +$quizsettings->add(new admin_setting_configtext_with_advanced('quiz/graceperiod', + get_string('graceperiod', 'quiz'), get_string('graceperiod_desc', 'quiz'), + array('value' => '86400', 'fix' => false), PARAM_INT)); + +// Minimum grace period used behind the scenes. +$quizsettings->add(new admin_setting_configtext('quiz/graceperiodmin', + get_string('graceperiodmin', 'quiz'), get_string('graceperiodmin_desc', 'quiz'), + 60, PARAM_INT)); + // Number of attempts $options = array(get_string('unlimited')); for ($i = 1; $i <= QUIZ_MAX_ATTEMPT_OPTION; $i++) { diff --git a/mod/quiz/settingslib.php b/mod/quiz/settingslib.php index d8c2a02c416..70b0e614dbe 100644 --- a/mod/quiz/settingslib.php +++ b/mod/quiz/settingslib.php @@ -195,3 +195,27 @@ class mod_quiz_admin_setting_browsersecurity extends admin_setting_configselect_ return true; } } + + +/** +* Admin settings class for the quiz overdue attempt handling method. +* +* Just so we can lazy-load the choices. +* +* @copyright 2011 The Open University +* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later +*/ +class mod_quiz_admin_setting_overduehandling extends admin_setting_configselect_with_advanced { + public function load_choices() { + global $CFG; + + if (is_array($this->choices)) { + return true; + } + + require_once($CFG->dirroot . '/mod/quiz/locallib.php'); + $this->choices = quiz_get_overdue_handling_options(); + + return true; + } +} From 18efef384b0083b557e234e37cb839522bb8a0fb Mon Sep 17 00:00:00 2001 From: Tim Hunt Date: Thu, 10 Nov 2011 18:32:34 +0000 Subject: [PATCH 05/20] MDL-3030 quiz overdue handling: new capability --- mod/quiz/db/access.php | 9 ++++++++- mod/quiz/lang/en/quiz.php | 1 + 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/mod/quiz/db/access.php b/mod/quiz/db/access.php index 57f0e7d6236..882cc494aff 100644 --- a/mod/quiz/db/access.php +++ b/mod/quiz/db/access.php @@ -173,6 +173,13 @@ $capabilities = array( 'captype' => 'read', 'contextlevel' => CONTEXT_MODULE, 'archetypes' => array() - ) + ), + + // Receive a notification message when a quiz attempt becomes overdue. + 'mod/quiz:emailwarnoverdue' => array( + 'captype' => 'read', + 'contextlevel' => CONTEXT_MODULE, + 'archetypes' => array() + ), ); diff --git a/mod/quiz/lang/en/quiz.php b/mod/quiz/lang/en/quiz.php index 0539bfdbb4a..51b7565d2af 100644 --- a/mod/quiz/lang/en/quiz.php +++ b/mod/quiz/lang/en/quiz.php @@ -565,6 +565,7 @@ $string['quizcloseson'] = 'This quiz will close at {$a}'; $string['quiz:deleteattempts'] = 'Delete quiz attempts'; $string['quiz:emailconfirmsubmission'] = 'Get a confirmation message when submitting'; $string['quiz:emailnotifysubmission'] = 'Get a notification message when an attempt is submitted'; +$string['quiz:emailwarnoverdue'] = 'Get a notification message when an attempt becomes overdue and needs to be submitted.'; $string['quiz:grade'] = 'Grade quizzes manually'; $string['quiz:ignoretimelimits'] = 'Ignores time limit on quizzes'; $string['quizisclosed'] = 'This quiz is closed'; From fd55f4bf4978af52593944d4829998ce9559e22d Mon Sep 17 00:00:00 2001 From: Tim Hunt Date: Thu, 10 Nov 2011 18:39:33 +0000 Subject: [PATCH 06/20] MDL-3030 quiz overdue handling: backup and restore new settings. --- .../backup/moodle2/backup_quiz_stepslib.php | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/mod/quiz/backup/moodle2/backup_quiz_stepslib.php b/mod/quiz/backup/moodle2/backup_quiz_stepslib.php index bd66c7d6590..12d9f24053b 100644 --- a/mod/quiz/backup/moodle2/backup_quiz_stepslib.php +++ b/mod/quiz/backup/moodle2/backup_quiz_stepslib.php @@ -40,17 +40,16 @@ class backup_quiz_activity_structure_step extends backup_questions_activity_stru // Define each element separated $quiz = new backup_nested_element('quiz', array('id'), array( - 'name', 'intro', 'introformat', 'timeopen', - 'timeclose', 'preferredbehaviour', 'attempts_number', - 'attemptonlast', 'grademethod', 'navmethod', 'decimalpoints', 'questiondecimalpoints', + 'name', 'intro', 'introformat', 'timeopen', 'timeclose', 'timelimit', + 'overduehandling', 'graceperiod', 'preferredbehaviour', 'attempts_number', + 'attemptonlast', 'grademethod', 'decimalpoints', 'questiondecimalpoints', 'reviewattempt', 'reviewcorrectness', 'reviewmarks', 'reviewspecificfeedback', 'reviewgeneralfeedback', 'reviewrightanswer', 'reviewoverallfeedback', - 'questionsperpage', 'shufflequestions', 'shuffleanswers', + 'questionsperpage', 'navmethod', 'shufflequestions', 'shuffleanswers', 'questions', 'sumgrades', 'grade', 'timecreated', - 'timemodified', 'timelimit', 'password', 'subnet', - 'browsersecurity', 'delay1', 'delay2', 'showuserpicture', - 'showblocks')); + 'timemodified', 'password', 'subnet', 'browsersecurity', + 'delay1', 'delay2', 'showuserpicture', 'showblocks')); // Define elements for access rule subplugin settings. $this->add_subplugin_structure('quizaccess', $quiz, true); @@ -79,9 +78,8 @@ class backup_quiz_activity_structure_step extends backup_questions_activity_stru $attempts = new backup_nested_element('attempts'); $attempt = new backup_nested_element('attempt', array('id'), array( - 'uniqueid', 'userid', 'attemptnum', 'sumgrades', - 'timestart', 'timefinish', 'timemodified', 'layout', - 'preview', 'currentpage')); + 'userid', 'attemptnum', 'uniqueid', 'layout', 'currentpage', 'preview', + 'state', 'timestart', 'timefinish', 'timemodified', 'sumgrades')); // This module is using questions, so produce the related question states and sessions // attaching them to the $attempt element based in 'uniqueid' matching From 2de9be52aa2a15b3a987c9007429f9a96c173da8 Mon Sep 17 00:00:00 2001 From: Tim Hunt Date: Mon, 14 Nov 2011 15:35:46 +0000 Subject: [PATCH 07/20] MDL-3030 quiz overdue handling: show attempt state on the view page --- mod/quiz/accessmanager.php | 28 +++++++--- mod/quiz/attemptlib.php | 60 +++++++++++++++++++-- mod/quiz/lang/en/quiz.php | 7 +++ mod/quiz/renderer.php | 104 ++++++++++++++++++++----------------- mod/quiz/styles.css | 6 +++ mod/quiz/view.php | 29 ++++++----- 6 files changed, 159 insertions(+), 75 deletions(-) diff --git a/mod/quiz/accessmanager.php b/mod/quiz/accessmanager.php index 39c5e80b2dd..369dc0b7605 100644 --- a/mod/quiz/accessmanager.php +++ b/mod/quiz/accessmanager.php @@ -391,6 +391,25 @@ class quiz_access_manager { } } + /** + * Compute how much time is left before this attempt must be submitted. + * + * @param object $attempt the data from the relevant quiz_attempts row. + * @param int $timenow the time to consider as 'now'. + * @return int|false the number of seconds remaining for this attempt. + * False if there is no limit. + */ + public function get_time_left($attempt, $timenow) { + $timeleft = false; + foreach ($this->rules as $rule) { + $ruletimeleft = $rule->time_left($attempt, $timenow); + if ($ruletimeleft !== false && ($timeleft === false || $ruletimeleft < $timeleft)) { + $timeleft = $ruletimeleft; + } + } + return $timeleft; + } + /** * Will cause the attempt time to start counting down after the page has loaded, * if that is necessary. @@ -400,14 +419,7 @@ class quiz_access_manager { * @param mod_quiz_renderer $output the quiz renderer. */ public function show_attempt_timer_if_needed($attempt, $timenow, $output) { - - $timeleft = false; - foreach ($this->rules as $rule) { - $ruletimeleft = $rule->time_left($attempt, $timenow); - if ($ruletimeleft !== false && ($timeleft === false || $ruletimeleft < $timeleft)) { - $timeleft = $ruletimeleft; - } - } + $timeleft = $this->get_time_left($attempt, $timenow); if ($timeleft !== false) { // Make sure the timer starts just above zero. If $timeleft was <= 0, then diff --git a/mod/quiz/attemptlib.php b/mod/quiz/attemptlib.php index c9df93495f9..09627723a48 100644 --- a/mod/quiz/attemptlib.php +++ b/mod/quiz/attemptlib.php @@ -411,12 +411,22 @@ class quiz { * @since Moodle 2.0 */ class quiz_attempt { - // Fields initialised in the constructor. + + /** @var string to identify the in progress state. */ + const IN_PROGRESS = 'inprogress'; + /** @var string to identify the overdue state. */ + const OVERDUE = 'overdue'; + /** @var string to identify the finished state. */ + const FINISHED = 'finished'; + /** @var string to identify the abandoned state. */ + const ABANDONED = 'abandoned'; + + // Basic data protected $quizobj; protected $attempt; - protected $quba; - // Fields set later if that data is needed. + // More details of what happened for each question. + protected $quba; protected $pagelayout; // array page no => array of numbers on the page in order. protected $reviewoptions = null; @@ -612,6 +622,10 @@ class quiz_attempt { return $this->attempt->attempt; } + public function get_state() { + return $this->attempt->state; + } + /** @return int the id of the user this attempt belongs to. */ public function get_userid() { return $this->attempt->userid; @@ -622,12 +636,16 @@ class quiz_attempt { return $this->attempt->currentpage; } + public function get_sum_marks() { + return $this->attempt->sumgrades; + } + /** * @return bool whether this attempt has been finished (true) or is still * in progress (false). */ public function is_finished() { - return $this->attempt->timefinish != 0; + return $this->attempt->state == self::FINISHED; } /** @return bool whether this attempt is a preview attempt. */ @@ -927,6 +945,40 @@ class quiz_attempt { return $this->quba->get_question_action_time($slot); } + + /** + * @return int the time when this attempt was submitted. 0 if it has not been + * submitted yet. + */ + public function get_submitted_date() { + return $this->attempt->timefinish; + } + + /** + * If the attempt is in an applicable state, work out the time by which the + * student should next do something. + * @return int timestamp by which the student needs to do something. + */ + function get_due_date($timenow) { + + switch ($attempt->state) { + case self::IN_PROGRESS: + $timeleft = $this->get_access_manager($timenow)->get_time_left( + $this->attempt, $timenow); + if ($timeleft === false) { + return false; + } else { + return $timenow + $timeleft; + } + + case self::OVERDUE: + return $this->attempt->timefinished + $this->quizobj->get_quiz()->graceperiod; + + default: + throw new coding_exception('Unexpected state: ' . $attempt->state); + } + } + // URLs related to this attempt ======================================================== /** * @return string quiz view url. diff --git a/mod/quiz/lang/en/quiz.php b/mod/quiz/lang/en/quiz.php index 51b7565d2af..ba618d9a200 100644 --- a/mod/quiz/lang/en/quiz.php +++ b/mod/quiz/lang/en/quiz.php @@ -94,6 +94,7 @@ $string['attemptsnum'] = 'Attempts: {$a}'; $string['attemptsnumthisgroup'] = 'Attempts: {$a->total} ({$a->group} from this group)'; $string['attemptsnumyourgroups'] = 'Attempts: {$a->total} ({$a->group} from your groups)'; $string['attemptsonly'] = 'Show only students with attempts'; +$string['attemptstate'] = 'State'; $string['attemptstillinprogress'] = 'Attempt still in progress'; $string['attemptsunlimited'] = 'Unlimited attempts'; $string['back'] = 'Back to preview question'; @@ -740,7 +741,13 @@ $string['startagain'] = 'Start again'; $string['startattempt'] = 'Start attempt'; $string['startedon'] = 'Started on'; $string['startnewpreview'] = 'Start a new preview'; +$string['stateabandoned'] = 'Never submitted'; +$string['statefinished'] = 'Finished'; +$string['statefinisheddetails'] = 'Submitted {$a}'; +$string['stateinprogress'] = 'In progress'; $string['statenotloaded'] = 'The state for question {$a} has not been loaded from the database'; +$string['stateoverdue'] = 'Overdue'; +$string['stateoverduedetails'] = 'Must be submitted by {$a}'; $string['status'] = 'Status'; $string['stoponerror'] = 'Stop on error'; $string['submitallandfinish'] = 'Submit all and finish'; diff --git a/mod/quiz/renderer.php b/mod/quiz/renderer.php index 0fbd157b576..7a1420a8df8 100644 --- a/mod/quiz/renderer.php +++ b/mod/quiz/renderer.php @@ -856,11 +856,9 @@ class mod_quiz_renderer extends plugin_renderer_base { * @param mod_quiz_view_object $viewobj */ public function view_table($quiz, $context, $viewobj) { - $output = ''; if (!$viewobj->attempts) { - return $output; + return ''; } - $output .= $this->view_table_heading(); // Prepare table header $table = new html_table(); @@ -873,7 +871,7 @@ class mod_quiz_renderer extends plugin_renderer_base { $table->align[] = 'center'; $table->size[] = ''; } - $table->head[] = get_string('timecompleted', 'quiz'); + $table->head[] = get_string('attemptstate', 'quiz'); $table->align[] = 'left'; $table->size[] = ''; if ($viewobj->markcolumn) { @@ -898,79 +896,59 @@ class mod_quiz_renderer extends plugin_renderer_base { $table->align[] = 'left'; $table->size[] = ''; } - if (isset($quiz->showtimetaken)) { - $table->head[] = get_string('timetaken', 'quiz'); - $table->align[] = 'left'; - $table->size[] = ''; - } // One row for each attempt - foreach ($viewobj->attempts as $attempt) { - $attemptoptions = quiz_get_review_options($quiz, $attempt, $context); + foreach ($viewobj->attemptobjs as $attemptobj) { + $attemptoptions = $attemptobj->get_display_options(true); $row = array(); - // Add the attempt number, making it a link, if appropriate. + // Add the attempt number. if ($viewobj->attemptcolumn) { - if ($attempt->preview) { + if ($attemptobj->is_preview()) { $row[] = get_string('preview', 'quiz'); } else { - $row[] = $attempt->attempt; + $row[] = $attemptobj->get_attempt_number(); } } - // prepare strings for time taken and date completed - $timetaken = ''; - $datecompleted = ''; - if ($attempt->timefinish > 0) { - // attempt has finished - $timetaken = format_time($attempt->timefinish - $attempt->timestart); - $datecompleted = userdate($attempt->timefinish); - } else if (!$quiz->timeclose || $viewobj->timenow < $quiz->timeclose) { - // The attempt is still in progress. - $timetaken = format_time($viewobj->timenow - $attempt->timestart); - $datecompleted = get_string('inprogress', 'quiz'); - } else { - $timetaken = format_time($quiz->timeclose - $attempt->timestart); - $datecompleted = userdate($quiz->timeclose); - } - $row[] = $datecompleted; + $row[] = $this->attempt_state($attemptobj, $viewobj->timenow); if ($viewobj->markcolumn) { if ($attemptoptions->marks >= question_display_options::MARK_AND_MAX && - $attempt->timefinish > 0) { - $row[] = quiz_format_grade($quiz, $attempt->sumgrades); + $attemptobj->is_finished()) { + $row[] = quiz_format_grade($quiz, $attemptobj->get_sum_marks()); } else { $row[] = ''; } } // Ouside the if because we may be showing feedback but not grades. - $attemptgrade = quiz_rescale_grade($attempt->sumgrades, $quiz, false); + $attemptgrade = quiz_rescale_grade($attemptobj->get_sum_marks(), $quiz, false); if ($viewobj->gradecolumn) { if ($attemptoptions->marks >= question_display_options::MARK_AND_MAX && - $attempt->timefinish > 0) { - $formattedgrade = quiz_format_grade($quiz, $attemptgrade); - // highlight the highest grade if appropriate - if ($viewobj->overallstats && !$attempt->preview + $attemptobj->is_finished()) { + + // Highlight the highest grade if appropriate + if ($viewobj->overallstats && !$attemptobj->is_preview() && $viewobj->numattempts > 1 && !is_null($viewobj->mygrade) && $attemptgrade == $viewobj->mygrade && $quiz->grademethod == QUIZ_GRADEHIGHEST) { - $table->rowclasses[$attempt->attempt] = 'bestrow'; + $table->rowclasses[$attemptobj->get_attempt_number()] = 'bestrow'; } - $row[] = $formattedgrade; + $row[] = quiz_format_grade($quiz, $attemptgrade); } else { $row[] = ''; } } if ($viewobj->canreviewmine) { - $row[] = $viewobj->accessmanager->make_review_link($attempt, + $row[] = $viewobj->accessmanager->make_review_link($attemptobj->get_attempt(), $attemptoptions, $this); } - if ($viewobj->feedbackcolumn && $attempt->timefinish > 0) { + if ($viewobj->feedbackcolumn && $attemptobj->is_finished()) { if ($attemptoptions->overallfeedback) { $row[] = quiz_feedback_for_grade($attemptgrade, $quiz, $context); } else { @@ -978,21 +956,47 @@ class mod_quiz_renderer extends plugin_renderer_base { } } - if (isset($quiz->showtimetaken)) { - $row[] = $timetaken; - } - - if ($attempt->preview) { + if ($attemptobj->is_preview()) { $table->data['preview'] = $row; } else { - $table->data[$attempt->attempt] = $row; + $table->data[$attemptobj->get_attempt_number()] = $row; } } // End of loop over attempts. - $output .= html_writer::table($table); + $output = ''; + $output .= $this->view_table_heading(); + $output .= html_writer::table($table); return $output; } + /** + * Generate a brief textual desciption of the current state of an attempt. + * @param quiz_attempt $attemptobj the attempt + * @param int $timenow the time to use as 'now'. + * @return string the appropriate lang string to describe the state. + */ + public function attempt_state($attemptobj, $timenow) { + switch ($attemptobj->get_state()) { + case quiz_attempt::IN_PROGRESS: + return get_string('stateinprogress', 'quiz'); + + case quiz_attempt::OVERDUE: + return get_string('stateoverdue', 'quiz') . html_writer::tag('span', + get_string('stateoverduedetails', 'quiz', + userdate($attemptobj->get_due_date($timenow))), + array('class' => 'statedetails')); + + case quiz_attempt::FINISHED: + return get_string('statefinished', 'quiz') . html_writer::tag('span', + get_string('statefinisheddetails', 'quiz', + userdate($attemptobj->get_submitted_date())), + array('class' => 'statedetails')); + + case quiz_attempt::ABANDONED: + return get_string('stateabandoned', 'quiz'); + } + } + /** * Prints the students best score * @@ -1134,8 +1138,10 @@ class mod_quiz_links_to_other_attempts implements renderable { class mod_quiz_view_object { /** @var array $infomessages of messages with information to display about the quiz. */ public $infomessages; - /** @var array $attempt contains all the user's attempts at this quiz. */ + /** @var array $attempts contains all the user's attempts at this quiz. */ public $attempts; + /** @var array $attemptobjs quiz_attempt objects corresponding to $attempts. */ + public $attemptobjs; /** @var quiz_access_manager $accessmanager contains various access rules. */ public $accessmanager; /** @var bool $canreviewmine whether the current user has the capability to diff --git a/mod/quiz/styles.css b/mod/quiz/styles.css index 3331a7f2f18..d19de769a09 100644 --- a/mod/quiz/styles.css +++ b/mod/quiz/styles.css @@ -1,3 +1,9 @@ +/** Bits that can appear on any page. */ +.path-mod-quiz .statedetails { + display: block; + font-size: 0.7em; +} + /** Attempt and review pages **/ #page-mod-quiz-attempt #page .controls, #page-mod-quiz-summary #page .controls, diff --git a/mod/quiz/view.php b/mod/quiz/view.php index 933f6620024..b0119e568f6 100644 --- a/mod/quiz/view.php +++ b/mod/quiz/view.php @@ -78,6 +78,11 @@ $completion->set_module_viewed($cm); // Initialize $PAGE, compute blocks $PAGE->set_url('/mod/quiz/view.php', array('id' => $cm->id)); +// Create view object which collects all the information the renderer will need. +$viewobj = new mod_quiz_view_object(); +$viewobj->accessmanager = $accessmanager; +$viewobj->canreviewmine = $canreviewmine; + // Get this user's attempts. $attempts = quiz_get_user_attempts($quiz->id, $USER->id, 'finished', true); $lastfinishedattempt = end($attempts); @@ -88,6 +93,12 @@ if ($unfinishedattempt = quiz_get_user_attempt_unfinished($quiz->id, $USER->id)) } $numattempts = count($attempts); +$viewobj->attempts = $attempts; +$viewobj->attemptobjs = array(); +foreach ($attempts as $attempt) { + $viewobj->attemptobjs[] = new quiz_attempt($attempt, $quiz, $cm, $course, false); +} + // Work out the final grade, checking whether it was overridden in the gradebook. if (!$canpreview) { $mygrade = quiz_get_best_grade($quiz, $USER->id); @@ -123,29 +134,19 @@ $PAGE->set_title($title); $PAGE->set_heading($course->fullname); $output = $PAGE->get_renderer('mod_quiz'); -/* - * Create view object for use within renderers file - */ -$viewobj = new mod_quiz_view_object(); -$viewobj->attempts = $attempts; -$viewobj->accessmanager = $accessmanager; -$viewobj->canreviewmine = $canreviewmine; - // Print table with existing attempts if ($attempts) { // Work out which columns we need, taking account what data is available in each attempt. list($someoptions, $alloptions) = quiz_get_combined_reviewoptions($quiz, $attempts, $context); - $viewobj->attemptcolumn = $quiz->attempts != 1; + $viewobj->attemptcolumn = $quiz->attempts != 1; - $viewobj->gradecolumn = $someoptions->marks >= question_display_options::MARK_AND_MAX && + $viewobj->gradecolumn = $someoptions->marks >= question_display_options::MARK_AND_MAX && quiz_has_grades($quiz); - $viewobj->markcolumn = $viewobj->gradecolumn && ($quiz->grade != $quiz->sumgrades); - $viewobj->overallstats = $lastfinishedattempt && $alloptions->marks >= question_display_options::MARK_AND_MAX; + $viewobj->markcolumn = $viewobj->gradecolumn && ($quiz->grade != $quiz->sumgrades); + $viewobj->overallstats = $lastfinishedattempt && $alloptions->marks >= question_display_options::MARK_AND_MAX; $viewobj->feedbackcolumn = quiz_has_feedback($quiz) && $alloptions->overallfeedback; -} else { - $viewobj->attemptcolumn = 1; } $viewobj->timenow = $timenow; From 2b2b6458422e38e126bbdae752d84f90b93761c2 Mon Sep 17 00:00:00 2001 From: Tim Hunt Date: Tue, 15 Nov 2011 17:25:26 +0000 Subject: [PATCH 08/20] MDL-3030 quiz overdue handling: display message on the summary page. This change also includes a clean-up of how we display and initialise the countdown timer. --- mod/quiz/accessmanager.php | 19 ----------------- mod/quiz/attempt.php | 1 - mod/quiz/attemptlib.php | 43 +++++++++++++++++++++++++++----------- mod/quiz/lang/en/quiz.php | 2 ++ mod/quiz/module.js | 6 +++++- mod/quiz/renderer.php | 39 +++++++++++++++++++++++++--------- mod/quiz/summary.php | 2 -- 7 files changed, 67 insertions(+), 45 deletions(-) diff --git a/mod/quiz/accessmanager.php b/mod/quiz/accessmanager.php index 369dc0b7605..7d4cd70bcc0 100644 --- a/mod/quiz/accessmanager.php +++ b/mod/quiz/accessmanager.php @@ -410,25 +410,6 @@ class quiz_access_manager { return $timeleft; } - /** - * Will cause the attempt time to start counting down after the page has loaded, - * if that is necessary. - * - * @param object $attempt the data from the relevant quiz_attempts row. - * @param int $timenow the time to consider as 'now'. - * @param mod_quiz_renderer $output the quiz renderer. - */ - public function show_attempt_timer_if_needed($attempt, $timenow, $output) { - $timeleft = $this->get_time_left($attempt, $timenow); - - if ($timeleft !== false) { - // Make sure the timer starts just above zero. If $timeleft was <= 0, then - // this will just have the effect of causing the quiz to be submitted immediately. - $timerstartvalue = max($timeleft, 1); - $output->initialise_timer($timerstartvalue); - } - } - /** * @return bolean if this quiz should only be shown to students in a popup window. */ diff --git a/mod/quiz/attempt.php b/mod/quiz/attempt.php index 87effe92b62..1c5856ebe9d 100644 --- a/mod/quiz/attempt.php +++ b/mod/quiz/attempt.php @@ -126,5 +126,4 @@ if ($attemptobj->is_last_page($page)) { $nextpage = $page + 1; } -$accessmanager->show_attempt_timer_if_needed($attemptobj->get_attempt(), time(), $output); echo $output->attempt_page($attemptobj, $page, $accessmanager, $messages, $slots, $id, $nextpage); diff --git a/mod/quiz/attemptlib.php b/mod/quiz/attemptlib.php index 09627723a48..f9ff24e589e 100644 --- a/mod/quiz/attemptlib.php +++ b/mod/quiz/attemptlib.php @@ -945,6 +945,19 @@ class quiz_attempt { return $this->quba->get_question_action_time($slot); } + /** + * Get the time remaining for an in-progress attempt, if the time is short + * enought that it would be worth showing a timer. + * @param int $timenow the time to consider as 'now'. + * @return int|false the number of seconds remaining for this attempt. + * False if there is no limit. + */ + public function get_time_left($timenow) { + if ($this->attempt->state != self::IN_PROGRESS) { + return false; + } + return $this->get_access_manager($timenow)->get_time_left($this->attempt, $timenow); + } /** * @return int the time when this attempt was submitted. 0 if it has not been @@ -959,23 +972,29 @@ class quiz_attempt { * student should next do something. * @return int timestamp by which the student needs to do something. */ - function get_due_date($timenow) { + function get_due_date() { + $deadlines = array(); + if ($this->quizobj->get_quiz()->timelimit) { + $deadlines[] = $this->attempt->timestart + $this->quizobj->get_quiz()->timelimit; + } + if ($this->quizobj->get_quiz()->timeclose) { + $deadlines[] = $this->quizobj->get_quiz()->timeclose; + } + if ($deadlines) { + $duedate = min($deadlines); + } else { + return false; + } - switch ($attempt->state) { + switch ($this->attempt->state) { case self::IN_PROGRESS: - $timeleft = $this->get_access_manager($timenow)->get_time_left( - $this->attempt, $timenow); - if ($timeleft === false) { - return false; - } else { - return $timenow + $timeleft; - } + return $duedate; case self::OVERDUE: - return $this->attempt->timefinished + $this->quizobj->get_quiz()->graceperiod; + return $duedate + $this->quizobj->get_quiz()->graceperiod; default: - throw new coding_exception('Unexpected state: ' . $attempt->state); + throw new coding_exception('Unexpected state: ' . $this->attempt->state); } } @@ -1473,7 +1492,7 @@ class quiz_attempt_nav_panel extends quiz_nav_panel_base { public function render_end_bits(mod_quiz_renderer $output) { return html_writer::link($this->attemptobj->summary_url(), get_string('endtest', 'quiz'), array('class' => 'endtestlink')) . - $output->countdown_timer() . + $output->countdown_timer($this->attemptobj, time()) . $this->render_restart_preview_link($output); } } diff --git a/mod/quiz/lang/en/quiz.php b/mod/quiz/lang/en/quiz.php index ba618d9a200..06b780bbce4 100644 --- a/mod/quiz/lang/en/quiz.php +++ b/mod/quiz/lang/en/quiz.php @@ -418,6 +418,7 @@ $string['moveselectedonpage'] = 'Move selected questions to page: {$a}'; $string['multichoice'] = 'Multiple choice'; $string['multipleanswers'] = 'Choose at least one answer.'; $string['multiplier'] = 'Multiplier'; +$string['mustbesubmittedby'] = 'This attempt must be submitted by {$a}.'; $string['name'] = 'Name'; $string['navmethod'] = 'Navigation method'; $string['navmethod_free'] = 'Free'; @@ -496,6 +497,7 @@ $string['overduehandling_desc'] = 'What should happen by default if a student do $string['overduehandlingautosubmit'] = 'the attempt is submitted automatically'; $string['overduehandlinggraceperiod'] = 'there is a grace period in which to submit the attempt, but not answer more questions'; $string['overduehandlingautoabandon'] = 'that is it. The attempt must be submitted before time expires, or it is not counted'; +$string['overduemustbesubmittedby'] = 'This attempt is now overdue. It should already have been submitted. If you would like this quiz to be graded, you must submit it by {$a}. If you not submit it by then, no marks from this attempt will be counted.'; $string['override'] = 'Override'; $string['overridedeletegroupsure'] = 'Are you sure you want to delete the override for group {$a}?'; $string['overridedeleteusersure'] = 'Are you sure you want to delete the override for user {$a}?'; diff --git a/mod/quiz/module.js b/mod/quiz/module.js index 3ac6032be1d..fa45821d48e 100644 --- a/mod/quiz/module.js +++ b/mod/quiz/module.js @@ -96,7 +96,11 @@ M.mod_quiz.timer = { Y.one('#quiz-time-left').setContent(M.str.quiz.timesup); var input = Y.one('input[name=timeup]'); input.set('value', 1); - input.ancestor('form').submit(); + var form = input.ancestor('form'); + if (form.one('input[name=finishattempt]')) { + form.one('input[name=finishattempt]').set('value', 0); + } + form.submit(); return; } diff --git a/mod/quiz/renderer.php b/mod/quiz/renderer.php index 7a1420a8df8..7702d470c96 100644 --- a/mod/quiz/renderer.php +++ b/mod/quiz/renderer.php @@ -261,7 +261,16 @@ class mod_quiz_renderer extends plugin_renderer_base { * Return the HTML of the quiz timer. * @return string HTML content. */ - public function countdown_timer() { + public function countdown_timer(quiz_attempt $attemptobj, $timenow) { + + $timeleft = $attemptobj->get_time_left($timenow); + if ($timeleft !== false) { + // Make sure the timer starts just above zero. If $timeleft was <= 0, then + // this will just have the effect of causing the quiz to be submitted immediately. + $timerstartvalue = max($timeleft, 1); + $this->initialise_timer($timerstartvalue); + } + return html_writer::tag('div', get_string('timeleft', 'quiz') . ' ' . html_writer::tag('span', '', array('id' => 'quiz-time-left')), array('id' => 'quiz-timer')); @@ -604,8 +613,6 @@ class mod_quiz_renderer extends plugin_renderer_base { */ public function summary_page_controls($attemptobj) { $output = ''; - // countdown timer - $output .= $this->countdown_timer(); // Return to place button $button = new single_button( @@ -626,11 +633,23 @@ class mod_quiz_renderer extends plugin_renderer_base { new moodle_url($attemptobj->processattempt_url(), $options), get_string('submitallandfinish', 'quiz')); $button->id = 'responseform'; - $button->add_action(new confirm_action(get_string('confirmclose', 'quiz'), null, - get_string('submitallandfinish', 'quiz'))); + if ($attemptobj->get_state() == quiz_attempt::IN_PROGRESS) { + $button->add_action(new confirm_action(get_string('confirmclose', 'quiz'), null, + get_string('submitallandfinish', 'quiz'))); + } - $output .= $this->container($this->container($this->render($button), - 'controls'), 'submitbtns mdl-align'); + $duedate = $attemptobj->get_due_date(); + $message = ''; + if ($attemptobj->get_state() == quiz_attempt::OVERDUE) { + $message = get_string('overduemustbesubmittedby', 'quiz', userdate($duedate)); + + } else if ($duedate) { + $message = get_string('mustbesubmittedby', 'quiz', userdate($duedate)); + } + + $output .= $this->countdown_timer($attemptobj, time()); + $output .= $this->container($message . $this->container( + $this->render($button), 'controls'), 'submitbtns mdl-align'); return $output; } @@ -911,7 +930,7 @@ class mod_quiz_renderer extends plugin_renderer_base { } } - $row[] = $this->attempt_state($attemptobj, $viewobj->timenow); + $row[] = $this->attempt_state($attemptobj); if ($viewobj->markcolumn) { if ($attemptoptions->marks >= question_display_options::MARK_AND_MAX && @@ -975,7 +994,7 @@ class mod_quiz_renderer extends plugin_renderer_base { * @param int $timenow the time to use as 'now'. * @return string the appropriate lang string to describe the state. */ - public function attempt_state($attemptobj, $timenow) { + public function attempt_state($attemptobj) { switch ($attemptobj->get_state()) { case quiz_attempt::IN_PROGRESS: return get_string('stateinprogress', 'quiz'); @@ -983,7 +1002,7 @@ class mod_quiz_renderer extends plugin_renderer_base { case quiz_attempt::OVERDUE: return get_string('stateoverdue', 'quiz') . html_writer::tag('span', get_string('stateoverduedetails', 'quiz', - userdate($attemptobj->get_due_date($timenow))), + userdate($attemptobj->get_due_date())), array('class' => 'statedetails')); case quiz_attempt::FINISHED: diff --git a/mod/quiz/summary.php b/mod/quiz/summary.php index 94b4d46abac..0b1cdb0e762 100644 --- a/mod/quiz/summary.php +++ b/mod/quiz/summary.php @@ -89,6 +89,4 @@ $PAGE->set_heading($attemptobj->get_course()->fullname); $accessmanager->setup_attempt_page($PAGE); // Display the page. - -$accessmanager->show_attempt_timer_if_needed($attemptobj->get_attempt(), time(), $output); echo $output->summary_page($attemptobj, $displayoptions); From b39b5d77c3553f4a99832b7221fe69233dcfabbc Mon Sep 17 00:00:00 2001 From: Tim Hunt Date: Wed, 16 Nov 2011 16:37:14 +0000 Subject: [PATCH 09/20] MDL-3030 quiz overdue handling: show attempt state on the review page. --- mod/quiz/attemptlib.php | 17 +++++++++++++++++ mod/quiz/renderer.php | 4 ++-- mod/quiz/review.php | 10 ++++++++-- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/mod/quiz/attemptlib.php b/mod/quiz/attemptlib.php index f9ff24e589e..cbd88ca030e 100644 --- a/mod/quiz/attemptlib.php +++ b/mod/quiz/attemptlib.php @@ -492,6 +492,23 @@ class quiz_attempt { return self::create_helper(array('uniqueid' => $usageid)); } + /** + * @param string $state one of the state constants like IN_PROGRESS. + * @return string the human-readable state name. + */ + public static function state_name($state) { + switch ($state) { + case quiz_attempt::IN_PROGRESS: + return get_string('stateinprogress', 'quiz'); + case quiz_attempt::OVERDUE: + return get_string('stateoverdue', 'quiz'); + case quiz_attempt::FINISHED: + return get_string('statefinished', 'quiz'); + case quiz_attempt::ABANDONED: + return get_string('stateabandoned', 'quiz'); + } + } + private function determine_layout() { $this->pagelayout = array(); diff --git a/mod/quiz/renderer.php b/mod/quiz/renderer.php index 7702d470c96..70fd27608a9 100644 --- a/mod/quiz/renderer.php +++ b/mod/quiz/renderer.php @@ -112,7 +112,7 @@ class mod_quiz_renderer extends plugin_renderer_base { * @param int $page the current page number * @return $summarydata containing filtered row data */ - protected function filter_summary_table($summarydata, $page) { + protected function filter_review_summary_table($summarydata, $page) { if ($page == 0) { return $summarydata; } @@ -134,7 +134,7 @@ class mod_quiz_renderer extends plugin_renderer_base { * @param int $page contains the current page number */ public function review_summary_table($summarydata, $page) { - $summarydata = $this->filter_summary_table($summarydata, $page); + $summarydata = $this->filter_review_summary_table($summarydata, $page); if (empty($summarydata)) { return ''; } diff --git a/mod/quiz/review.php b/mod/quiz/review.php index 431dc8d02a2..14a1784a127 100644 --- a/mod/quiz/review.php +++ b/mod/quiz/review.php @@ -112,7 +112,7 @@ $attempt = $attemptobj->get_attempt(); $quiz = $attemptobj->get_quiz(); $overtime = 0; -if ($attempt->timefinish) { +if ($attempt->state == quiz_attempt::FINISHED) { if ($timetaken = ($attempt->timefinish - $attempt->timestart)) { if ($quiz->timelimit && $timetaken > ($quiz->timelimit + 60)) { $overtime = $timetaken - $quiz->timelimit; @@ -140,6 +140,7 @@ if (!$attemptobj->get_quiz()->showuserpicture && $attemptobj->get_userid() != $U fullname($student, true)), ); } + if ($attemptobj->has_capability('mod/quiz:viewreports')) { $attemptlist = $attemptobj->links_to_other_attempts($attemptobj->review_url(null, $page, $showall)); @@ -157,7 +158,12 @@ $summarydata['startedon'] = array( 'content' => userdate($attempt->timestart), ); -if ($attempt->timefinish) { +$summarydata['state'] = array( + 'title' => get_string('attemptstate', 'quiz'), + 'content' => quiz_attempt::state_name($attempt->state), +); + +if ($attempt->state == quiz_attempt::FINISHED) { $summarydata['completedon'] = array( 'title' => get_string('completedon', 'quiz'), 'content' => userdate($attempt->timefinish), From c547514a9e46a8245f2cb935dcbbea995e501b1c Mon Sep 17 00:00:00 2001 From: Tim Hunt Date: Wed, 16 Nov 2011 16:42:38 +0000 Subject: [PATCH 10/20] MDL-3030 quiz overdue handling: show attempt state in the reports. --- mod/quiz/report/attemptsreport.php | 10 ++++++++++ mod/quiz/report/attemptsreport_table.php | 10 ++++++++++ mod/quiz/report/overview/report.php | 2 +- mod/quiz/report/responses/report.php | 1 + 4 files changed, 22 insertions(+), 1 deletion(-) diff --git a/mod/quiz/report/attemptsreport.php b/mod/quiz/report/attemptsreport.php index b509acc8bde..7df436902c6 100644 --- a/mod/quiz/report/attemptsreport.php +++ b/mod/quiz/report/attemptsreport.php @@ -199,6 +199,16 @@ abstract class quiz_attempts_report extends quiz_default_report { $table->column_class('fullname', 'bold'); } + /** + * Add the state column to the $columns and $headers arrays. + * @param array $columns the list of columns. Added to. + * @param array $headers the columns headings. Added to. + */ + protected function add_state_column(&$columns, &$headers) { + $columns[] = 'state'; + $headers[] = get_string('attemptstate', 'quiz'); + } + /** * Add all the time-related columns to the $columns and $headers arrays. * @param array $columns the list of columns. Added to. diff --git a/mod/quiz/report/attemptsreport_table.php b/mod/quiz/report/attemptsreport_table.php index 8d5cdfe8a6e..0beacdd3b8a 100644 --- a/mod/quiz/report/attemptsreport_table.php +++ b/mod/quiz/report/attemptsreport_table.php @@ -154,6 +154,15 @@ abstract class quiz_attempts_report_table extends table_sql { get_string('reviewattempt', 'quiz'), array('class' => 'reviewlink')); } + /** + * Generate the display of the attempt state column. + * @param object $attempt the table row being output. + * @return string HTML content to go inside the td. + */ + public function col_state($attempt) { + return quiz_attempt::state_name($attempt->state); + } + /** * Generate the display of the start time column. * @param object $attempt the table row being output. @@ -363,6 +372,7 @@ abstract class quiz_attempts_report_table extends table_sql { u.institution, u.department, u.email' . $extrafields . ', + quiza.state, quiza.sumgrades, quiza.timefinish, quiza.timestart, diff --git a/mod/quiz/report/overview/report.php b/mod/quiz/report/overview/report.php index d352ca1e002..0f101acead2 100644 --- a/mod/quiz/report/overview/report.php +++ b/mod/quiz/report/overview/report.php @@ -204,7 +204,7 @@ class quiz_overview_report extends quiz_attempts_report { } $this->add_user_columns($table, $columns, $headers); - + $this->add_state_column($columns, $headers); $this->add_time_columns($columns, $headers); if ($options->slotmarks) { diff --git a/mod/quiz/report/responses/report.php b/mod/quiz/report/responses/report.php index dfa2ff77b25..602202f9d2d 100644 --- a/mod/quiz/report/responses/report.php +++ b/mod/quiz/report/responses/report.php @@ -150,6 +150,7 @@ class quiz_responses_report extends quiz_attempts_report { } $this->add_user_columns($table, $columns, $headers); + $this->add_state_column($columns, $headers); if ($table->is_downloading()) { $this->add_time_columns($columns, $headers); From 2cdd2df92dd0577d343c81cb674f4302ca980c60 Mon Sep 17 00:00:00 2001 From: Tim Hunt Date: Wed, 16 Nov 2011 16:48:51 +0000 Subject: [PATCH 11/20] MDL-3030 quiz overdue handling: only finished attempts in stats. --- mod/quiz/report/statistics/report.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mod/quiz/report/statistics/report.php b/mod/quiz/report/statistics/report.php index a884d1e83a8..99cbd31fdc4 100644 --- a/mod/quiz/report/statistics/report.php +++ b/mod/quiz/report/statistics/report.php @@ -1076,8 +1076,8 @@ function quiz_statistics_attempts_sql($quizid, $currentgroup, $groupstudents, $fromqa = '{quiz_attempts} quiza '; - $whereqa = 'quiza.quiz = :quizid AND quiza.preview = 0 AND quiza.timefinish <> 0'; - $qaparams = array('quizid' => $quizid); + $whereqa = 'quiza.quiz = :quizid AND quiza.preview = 0 AND quiza.state = :quizstatefinished'; + $qaparams = array('quizid' => $quizid, 'quizstatefinished' => quiz_attempt::FINISHED); if (!empty($currentgroup) && $groupstudents) { list($grpsql, $grpparams) = $DB->get_in_or_equal(array_keys($groupstudents), From 863872e3e721fc254eec9e25033a7df234c635f9 Mon Sep 17 00:00:00 2001 From: Tim Hunt Date: Wed, 18 Apr 2012 14:38:00 +0100 Subject: [PATCH 12/20] MDL-3030 quiz overdue handling: show the attempt state in the reports. --- mod/quiz/lang/en/quiz.php | 13 +- mod/quiz/report/attemptsreport.php | 16 +-- mod/quiz/report/attemptsreport_form.php | 118 ++++++++++-------- mod/quiz/report/attemptsreport_options.php | 93 ++++++++++---- mod/quiz/report/attemptsreport_table.php | 28 ++--- .../report/overview/lang/en/quiz_overview.php | 2 +- mod/quiz/report/overview/overview_form.php | 31 +++-- mod/quiz/report/overview/overview_options.php | 22 ++-- mod/quiz/report/overview/overview_table.php | 7 +- mod/quiz/report/overview/report.php | 4 +- mod/quiz/report/reportlib.php | 9 ++ .../responses/lang/en/quiz_responses.php | 7 +- mod/quiz/report/responses/report.php | 4 +- mod/quiz/report/responses/responses_form.php | 34 +++-- .../report/responses/responses_options.php | 2 +- 15 files changed, 245 insertions(+), 145 deletions(-) diff --git a/mod/quiz/lang/en/quiz.php b/mod/quiz/lang/en/quiz.php index 06b780bbce4..d207e9e4b6f 100644 --- a/mod/quiz/lang/en/quiz.php +++ b/mod/quiz/lang/en/quiz.php @@ -508,7 +508,7 @@ $string['overrideuser'] = 'Override user'; $string['overrideusereventname'] = '{$a->quiz} - Override'; $string['page-mod-quiz-x'] = 'Any quiz module page'; $string['page-mod-quiz-edit'] = 'Edit quiz page'; -$string['pagesize'] = 'Attempts shown per page:'; +$string['pagesize'] = 'Page size'; $string['parent'] = 'Parent'; $string['parentcategory'] = 'Parent category'; $string['parsingquestions'] = 'Parsing questions from import file.'; @@ -628,16 +628,26 @@ $string['replace'] = 'Replace'; $string['replacementoptions'] = 'Replacement options'; $string['report'] = 'Reports'; $string['reportanalysis'] = 'Item analysis'; +$string['reportattemptsfrom'] = 'Attempts from'; +$string['reportattemptsthatare'] = 'Attempts that are'; +$string['reportdisplayoptions'] = 'Display options'; $string['reportfullstat'] = 'Detailed statistics'; $string['reportmulti_percent'] = 'Multi-percentages'; $string['reportmulti_q_x_student'] = 'Multi-student choices'; $string['reportmulti_resp'] = 'Individual responses'; +$string['reportmustselectstate'] = 'You must select at least one state.'; $string['reportnotfound'] = 'Report not known ({$a})'; $string['reportoverview'] = 'Overview'; $string['reportregrade'] = 'Regrade attempts'; $string['reportresponses'] = 'Detailed responses'; $string['reports'] = 'Reports'; +$string['reportshowonly'] = 'Show only attempts'; $string['reportsimplestat'] = 'Simple statistics'; +$string['reportusersall'] = 'all users who have attempted the quiz'; +$string['reportuserswith'] = 'enrolled users who have attempted the quiz'; +$string['reportuserswithorwithout'] = 'enrolled users who have, or have not, attempted the quiz'; +$string['reportuserswithout'] = 'enrolled users who have not attempted the quiz'; +$string['reportwhattoinclude'] = 'What to include in the report'; $string['requirepassword'] = 'Require password'; $string['requirepassword_help'] = 'If a password is specified, a student must enter it in order to attempt the quiz.'; $string['requiresubnet'] = 'Require network address'; @@ -721,6 +731,7 @@ $string['showfeedback'] = 'After answering, show feedback?'; $string['showinsecurepopup'] = 'Use a \'secure\' popup window for attempts'; $string['shownoattempts'] = 'Show students with no attempts'; $string['shownoattemptsonly'] = 'Show only students with no attempts'; +$string['showreport'] = 'Show report'; $string['showteacherattempts'] = 'Show teacher attempts'; $string['showuserpicture'] = 'Show the user\'s picture'; $string['showuserpicture_help'] = 'If enabled, the student\'s name and picture will be shown on-screen during the attempt, and on the review screen, making it easier to check that the student is logged in as themself in an invigilated (proctored) exam.'; diff --git a/mod/quiz/report/attemptsreport.php b/mod/quiz/report/attemptsreport.php index 7df436902c6..66ea124cd7c 100644 --- a/mod/quiz/report/attemptsreport.php +++ b/mod/quiz/report/attemptsreport.php @@ -39,14 +39,14 @@ abstract class quiz_attempts_report extends quiz_default_report { /** @var int default page size for reports. */ const DEFAULT_PAGE_SIZE = 30; - /** @var int include all attempts. */ - const ALL_ATTEMPTS = 0; - /** @var int include just enroled users who have not attempted the quiz. */ - const STUDENTS_WITH_NO = 1; - /** @var int include just enroled users who have attempted the quiz. */ - const STUDENTS_WITH = 2; - /** @var int include all enroled users. */ - const ALL_STUDENTS = 3; + /** @var string constant used for the options, means all users with attempts. */ + const ALL_WITH = 'all_with'; + /** @var string constant used for the options, means only enrolled users with attempts. */ + const ENROLLED_WITH = 'enrolled_with'; + /** @var string constant used for the options, means only enrolled users without attempts. */ + const ENROLLED_WITHOUT = 'enrolled_without'; + /** @var string constant used for the options, means all enrolled users. */ + const ENROLLED_ALL = 'enrolled_any'; /** @var string the mode this report is. */ protected $mode; diff --git a/mod/quiz/report/attemptsreport_form.php b/mod/quiz/report/attemptsreport_form.php index 6e84f4fb09c..c62a7066fb1 100644 --- a/mod/quiz/report/attemptsreport_form.php +++ b/mod/quiz/report/attemptsreport_form.php @@ -40,65 +40,81 @@ abstract class mod_quiz_attempts_report_form extends moodleform { $mform = $this->_form; $mform->addElement('header', 'preferencespage', - get_string('preferencespage', 'quiz_overview')); + get_string('reportwhattoinclude', 'quiz')); - if (!$this->_customdata['currentgroup']) { - $studentsstring = get_string('participants'); - } else { - $a = new stdClass(); - $a->coursestudent = get_string('participants'); - $a->groupname = groups_get_group_name($this->_customdata['currentgroup']); - if (20 < strlen($a->groupname)) { - $studentsstring = get_string('studentingrouplong', 'quiz_overview', $a); - } else { - $studentsstring = get_string('studentingroup', 'quiz_overview', $a); - } - } - $options = array(); - if (!$this->_customdata['currentgroup']) { - $options[quiz_attempts_report::ALL_ATTEMPTS] = get_string('optallattempts', 'quiz_overview'); - } - if ($this->_customdata['currentgroup'] || - !is_inside_frontpage($this->_customdata['context'])) { - $options[quiz_attempts_report::ALL_STUDENTS] = - get_string('optallstudents', 'quiz_overview', $studentsstring); - $options[quiz_attempts_report::STUDENTS_WITH] = - get_string('optattemptsonly', 'quiz_overview', $studentsstring); - $options[quiz_attempts_report::STUDENTS_WITH_NO] = - get_string('optnoattemptsonly', 'quiz_overview', $studentsstring); - } - $mform->addElement('select', 'attemptsmode', - get_string('show', 'quiz_overview'), $options); - - $this->definition_inner($mform); + $this->standard_attempt_fields($mform); + $this->other_attempt_fields($mform); $mform->addElement('header', 'preferencesuser', - get_string('preferencesuser', 'quiz_overview')); + get_string('reportdisplayoptions', 'quiz')); - $mform->addElement('text', 'pagesize', get_string('pagesize', 'quiz_overview')); - $mform->setType('pagesize', PARAM_INT); + $this->standard_preference_fields($mform); + $this->other_preference_fields($mform); $mform->addElement('submit', 'submitbutton', - get_string('preferencessave', 'quiz_overview')); + get_string('showreport', 'quiz')); } - /** - * Add any report-specific options to the form. - * - * @param MoodleQuickForm $mform the form we are building. - */ - protected abstract function definition_inner(MoodleQuickForm $mform); + protected function standard_attempt_fields(MoodleQuickForm $mform) { - /** - * Create the standard checkbox for the 'include highest graded only' option. - * - * @param MoodleQuickForm $mform the form we are building. - */ - protected function create_qmfilter_checkbox(MoodleQuickForm $mform) { - $gm = html_writer::tag('span', quiz_get_grading_option_name( - $this->_customdata['quiz']->grademethod), array('class' => 'highlight')); - return $mform->createElement('advcheckbox', 'qmfilter', - get_string('showattempts', 'quiz_overview'), - get_string('optonlygradedattempts', 'quiz_overview', $gm), null, array(0, 1)); + $mform->addElement('select', 'attempts', get_string('reportattemptsfrom', 'quiz'), array( + quiz_attempts_report::ENROLLED_WITH => get_string('reportuserswith', 'quiz'), + quiz_attempts_report::ENROLLED_WITHOUT => get_string('reportuserswithout', 'quiz'), + quiz_attempts_report::ENROLLED_ALL => get_string('reportuserswithorwithout', 'quiz'), + quiz_attempts_report::ALL_WITH => get_string('reportusersall', 'quiz'), + )); + + $stategroup = array( + $mform->createElement('advcheckbox', 'stateinprogress', '', + get_string('stateinprogress', 'quiz')), + $mform->createElement('advcheckbox', 'stateoverdue', '', + get_string('stateoverdue', 'quiz')), + $mform->createElement('advcheckbox', 'statefinished', '', + get_string('statefinished', 'quiz')), + $mform->createElement('advcheckbox', 'stateabandoned', '', + get_string('stateabandoned', 'quiz')), + ); + $mform->addGroup($stategroup, 'stateoptions', + get_string('reportattemptsthatare', 'quiz'), array(' '), false); + $mform->setDefault('stateinprogress', 1); + $mform->setDefault('stateoverdue', 1); + $mform->setDefault('statefinished', 1); + $mform->setDefault('stateabandoned', 1); + $mform->disabledIf('stateinprogress', 'attempts', 'eq', quiz_attempts_report::ENROLLED_WITHOUT); + $mform->disabledIf('stateoverdue', 'attempts', 'eq', quiz_attempts_report::ENROLLED_WITHOUT); + $mform->disabledIf('statefinished', 'attempts', 'eq', quiz_attempts_report::ENROLLED_WITHOUT); + $mform->disabledIf('stateabandoned', 'attempts', 'eq', quiz_attempts_report::ENROLLED_WITHOUT); + + if (quiz_report_can_filter_only_graded($this->_customdata['quiz'])) { + $gm = html_writer::tag('span', + quiz_get_grading_option_name($this->_customdata['quiz']->grademethod), + array('class' => 'highlight')); + $mform->addElement('advcheckbox', 'onlygraded', get_string('reportshowonly', 'quiz'), + get_string('optonlygradedattempts', 'quiz_overview', $gm)); + $mform->disabledIf('onlygraded', 'attempts', 'eq', quiz_attempts_report::ENROLLED_WITHOUT); + $mform->disabledIf('onlygraded', 'statefinished', 'notchecked'); + } + } + + protected function other_attempt_fields(MoodleQuickForm $mform) { + } + + protected function standard_preference_fields(MoodleQuickForm $mform) { + $mform->addElement('text', 'pagesize', get_string('pagesize', 'quiz')); + $mform->setType('pagesize', PARAM_INT); + } + + protected function other_preference_fields(MoodleQuickForm $mform) { + } + + public function validation($data, $files) { + $errors = parent::validation($data, $files); + + if ($data['attempts'] != quiz_attempts_report::ENROLLED_WITHOUT && !( + $data['stateinprogress'] || $data['stateoverdue'] || $data['statefinished'] || $data['stateabandoned'])) { + $errors['stateoptions'] = get_string('reportmustselectstate', 'quiz'); + } + + return $errors; } } diff --git a/mod/quiz/report/attemptsreport_options.php b/mod/quiz/report/attemptsreport_options.php index 17c0b95c094..cca3d30465d 100644 --- a/mod/quiz/report/attemptsreport_options.php +++ b/mod/quiz/report/attemptsreport_options.php @@ -48,12 +48,32 @@ class mod_quiz_attempts_report_options { /** @var object the course settings for the course the quiz is in. */ public $course; - /** @var string quiz_attempts_report:: constants. */ - public $attempts = quiz_attempts_report::STUDENTS_WITH; + /** + * @var array form field name => corresponding quiz_attempt:: state constant. + */ + protected static $statefields = array( + 'stateinprogress' => quiz_attempt::IN_PROGRESS, + 'stateoverdue' => quiz_attempt::OVERDUE, + 'statefinished' => quiz_attempt::FINISHED, + 'stateabandoned' => quiz_attempt::ABANDONED, + ); + + /** + * @var string quiz_attempts_report::ALL_WITH or quiz_attempts_report::ENROLLED_WITH + * quiz_attempts_report::ENROLLED_WITHOUT or quiz_attempts_report::ENROLLED_ALL + */ + public $attempts = quiz_attempts_report::ENROLLED_WITH; /** @var int the currently selected group. 0 if no group is selected. */ public $group = 0; + /** + * @var array|null of quiz_attempt::IN_PROGRESS, etc. constants. null means + * no restriction. + */ + public $states = array(quiz_attempt::IN_PROGRESS, quiz_attempt::OVERDUE, + quiz_attempt::FINISHED, quiz_attempt::ABANDONED); + /** * @var bool whether to show all finished attmepts, or just the one that gave * the final grade for the user. @@ -94,10 +114,10 @@ class mod_quiz_attempts_report_options { */ protected function get_url_params() { return array( - 'id' => $this->cm->id, - 'mode' => $this->mode, - 'attemptsmode' => $this->attempts, - 'qmfilter' => $this->onlygraded, + 'id' => $this->cm->id, + 'mode' => $this->mode, + 'attempts' => $this->attempts, + 'onlygraded' => $this->onlygraded, ); } @@ -136,9 +156,9 @@ class mod_quiz_attempts_report_options { */ public function get_initial_form_data() { $toform = new stdClass(); - $toform->attemptsmode = $this->attempts; - $toform->qmfilter = $this->onlygraded; - $toform->pagesize = $this->pagesize; + $toform->attempts = $this->attempts; + $toform->onlygraded = $this->onlygraded; + $toform->pagesize = $this->pagesize; return $toform; } @@ -148,21 +168,31 @@ class mod_quiz_attempts_report_options { * @param object $fromform The data from $mform->get_data() from the settings form. */ public function setup_from_form_data($fromform) { - $this->attempts = $fromform->attemptsmode; + $this->attempts = $fromform->attempts; $this->group = groups_get_activity_group($this->cm, true); - $this->onlygraded = !empty($fromform->qmfilter); + $this->onlygraded = !empty($fromform->onlygraded); $this->pagesize = $fromform->pagesize; + + $this->states = array(); + foreach (self::$statefields as $field => $state) { + if (!empty($fromform->$field)) { + $this->states[] = $state; + } + } } /** * Set the fields of this object from the user's preferences. */ public function setup_from_params() { - $this->attempts = optional_param('attemptsmode', $this->attempts, PARAM_ALPHAEXT); + $this->attempts = optional_param('attempts', $this->attempts, PARAM_ALPHAEXT); $this->group = groups_get_activity_group($this->cm, true); - $this->onlygraded = optional_param('qmfilter', $this->onlygraded, PARAM_BOOL); + $this->onlygraded = optional_param('onlygraded', $this->onlygraded, PARAM_BOOL); $this->pagesize = optional_param('pagesize', $this->pagesize, PARAM_INT); + $this->states = explode('-', optional_param('states', + implode('-', $this->states), PARAM_ALPHAEXT)); + $this->download = optional_param('download', $this->download, PARAM_ALPHA); } @@ -188,30 +218,49 @@ class mod_quiz_attempts_report_options { public function resolve_dependencies() { if ($this->group) { // Default for when a group is selected. - if ($this->attempts === null || $this->attempts == quiz_attempts_report::ALL_ATTEMPTS) { - $this->attempts = quiz_attempts_report::STUDENTS_WITH; + if ($this->attempts === null || $this->attempts == quiz_attempts_report::ALL_WITH) { + $this->attempts = quiz_attempts_report::ENROLLED_WITH; } } else if (!$this->group && $this->course->id == SITEID) { // Force report on front page to show all, unless a group is selected. - $this->attempts = quiz_attempts_report::ALL_ATTEMPTS; + $this->attempts = quiz_attempts_report::ALL_WITH; - } else if ($this->attempts === null) { - $this->attempts = quiz_attempts_report::ALL_ATTEMPTS; + } else if (!in_array($this->attempts, array(quiz_attempts_report::ALL_WITH, quiz_attempts_report::ENROLLED_WITH, + quiz_attempts_report::ENROLLED_WITHOUT, quiz_attempts_report::ENROLLED_ALL))) { + $this->attempts = quiz_attempts_report::ENROLLED_WITH; } - if ($this->pagesize < 1) { - $this->pagesize = quiz_attempts_report::DEFAULT_PAGE_SIZE; + $cleanstates = array(); + foreach (self::$statefields as $state) { + if (in_array($state, $this->states)) { + $cleanstates[] = $state; + } + } + $this->states = $cleanstates; + if (count($this->states) == count(self::$statefields)) { + // If all states have been selected, then there is no constraint + // required in the SQL, so clear the array. + $this->states = null; } - if (!quiz_report_qm_filter_select($this->quiz)) { + if (!quiz_report_can_filter_only_graded($this->quiz)) { // A grading mode like 'average' has been selected, so we cannot do // the show the attempt that gave the final grade thing. $this->onlygraded = false; } - if ($this->attempts == quiz_attempts_report::STUDENTS_WITH_NO) { + if ($this->attempts == quiz_attempts_report::ENROLLED_WITHOUT) { + $this->states = null; $this->onlygraded = false; } + + if ($this->onlygraded) { + $this->states = array(quiz_attempt::FINISHED); + } + + if ($this->pagesize < 1) { + $this->pagesize = quiz_attempts_report::DEFAULT_PAGE_SIZE; + } } } diff --git a/mod/quiz/report/attemptsreport_table.php b/mod/quiz/report/attemptsreport_table.php index 0beacdd3b8a..096bae6d5cc 100644 --- a/mod/quiz/report/attemptsreport_table.php +++ b/mod/quiz/report/attemptsreport_table.php @@ -61,12 +61,6 @@ abstract class quiz_attempts_report_table extends table_sql { /** @var object mod_quiz_attempts_report_options the options affecting this report. */ protected $options; - /** @var bool whether to only display the first/best/last attempt for each student. */ - protected $qmfilter; - - /** @var int which attempts/students to include in the report.. */ - protected $attemptsmode; - /** @var object the ids of the students in the currently selected group, if applicable. */ protected $groupstudents; @@ -98,8 +92,6 @@ abstract class quiz_attempts_report_table extends table_sql { $this->quiz = $quiz; $this->context = $context; $this->qmsubselect = $qmsubselect; - $this->qmfilter = $options->onlygraded; - $this->attemptsmode = $options->attempts; $this->groupstudents = $groupstudents; $this->students = $students; $this->questions = $questions; @@ -392,29 +384,30 @@ abstract class quiz_attempts_report_table extends table_sql { quiza.userid = u.id AND quiza.quiz = :quizid"; $params = array('quizid' => $this->quiz->id); - if ($this->qmsubselect && $this->qmfilter) { + if ($this->qmsubselect && $this->options->onlygraded) { $from .= " AND $this->qmsubselect"; } - switch ($this->attemptsmode) { - case quiz_attempts_report::ALL_ATTEMPTS: + + switch ($this->options->attempts) { + case quiz_attempts_report::ALL_WITH: // Show all attempts, including students who are no longer in the course. $where = 'quiza.id IS NOT NULL AND quiza.preview = 0'; break; - case quiz_attempts_report::STUDENTS_WITH: + case quiz_attempts_report::ENROLLED_WITH: // Show only students with attempts. list($usql, $uparams) = $DB->get_in_or_equal( $reportstudents, SQL_PARAMS_NAMED, 'u'); $params += $uparams; $where = "u.id $usql AND quiza.preview = 0 AND quiza.id IS NOT NULL"; break; - case quiz_attempts_report::STUDENTS_WITH_NO: + case quiz_attempts_report::ENROLLED_WITHOUT: // Show only students without attempts. list($usql, $uparams) = $DB->get_in_or_equal( $reportstudents, SQL_PARAMS_NAMED, 'u'); $params += $uparams; $where = "u.id $usql AND quiza.id IS NULL"; break; - case quiz_attempts_report::ALL_STUDENTS: + case quiz_attempts_report::ENROLLED_ALL: // Show all students with or without attempts. list($usql, $uparams) = $DB->get_in_or_equal( $reportstudents, SQL_PARAMS_NAMED, 'u'); @@ -423,6 +416,13 @@ abstract class quiz_attempts_report_table extends table_sql { break; } + if ($this->options->states) { + list($statesql, $stateparams) = $DB->get_in_or_equal($this->options->states, + SQL_PARAMS_NAMED, 'state'); + $params += $stateparams; + $where .= " AND (quiza.state $statesql OR quiza.state IS NULL)"; + } + return array($fields, $from, $where, $params); } diff --git a/mod/quiz/report/overview/lang/en/quiz_overview.php b/mod/quiz/report/overview/lang/en/quiz_overview.php index f4b928f91f0..4a1eabc51e7 100644 --- a/mod/quiz/report/overview/lang/en/quiz_overview.php +++ b/mod/quiz/report/overview/lang/en/quiz_overview.php @@ -65,7 +65,7 @@ $string['regradeheader'] = 'Regrading'; $string['regradeselected'] = 'Regrade selected attempts'; $string['show'] = 'Show / download'; $string['showattempts'] = 'Only show / download attempts'; -$string['showdetailedmarks'] = 'Show / download marks for each question'; +$string['showdetailedmarks'] = 'Marks for each question'; $string['showinggraded'] = 'Showing only the attempt graded for each user.'; $string['showinggradedandungraded'] = 'Showing graded and ungraded attempts for each user. The one attempt for each user that is graded is highlighted. The grading method for this quiz is {$a}.'; $string['studentingroup'] = '\'{$a->coursestudent}\' in group \'{$a->groupname}\''; diff --git a/mod/quiz/report/overview/overview_form.php b/mod/quiz/report/overview/overview_form.php index 8aa34c159e6..428bbbbc7e7 100644 --- a/mod/quiz/report/overview/overview_form.php +++ b/mod/quiz/report/overview/overview_form.php @@ -35,22 +35,21 @@ require_once($CFG->dirroot . '/mod/quiz/report/attemptsreport_form.php'); * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class quiz_overview_settings_form extends mod_quiz_attempts_report_form { - protected function definition_inner(MoodleQuickForm $mform) { - $showattemptsgrp = array(); - if ($this->_customdata['qmsubselect']) { - $showattemptsgrp[] = $this->create_qmfilter_checkbox($mform); - } - if (has_capability('mod/quiz:regrade', $this->_customdata['context'])) { - $showattemptsgrp[] = $mform->createElement('advcheckbox', 'regradefilter', - get_string('showattempts', 'quiz_overview'), - get_string('optonlyregradedattempts', 'quiz_overview'), null, array(0, 1)); - } - if ($showattemptsgrp) { - $mform->addGroup($showattemptsgrp, null, - get_string('showattempts', 'quiz_overview'), '
', false); - } - $mform->addElement('selectyesno', 'detailedmarks', - get_string('showdetailedmarks', 'quiz_overview')); + protected function other_attempt_fields(MoodleQuickForm $mform) { + if (has_capability('mod/quiz:regrade', $this->_customdata['context'])) { + $mform->addElement('advcheckbox', 'onlyregraded', '', + get_string('optonlyregradedattempts', 'quiz_overview')); + $mform->disabledIf('onlyregraded', 'attempts', 'eq', quiz_attempts_report::ENROLLED_WITHOUT); + } + } + + protected function other_preference_fields(MoodleQuickForm $mform) { + if (quiz_has_grades($this->_customdata['quiz'])) { + $mform->addElement('selectyesno', 'slotmarks', + get_string('showdetailedmarks', 'quiz_overview')); + } else { + $mform->addElement('hidden', 'slotmarks', 0); + } } } diff --git a/mod/quiz/report/overview/overview_options.php b/mod/quiz/report/overview/overview_options.php index e985c098587..e0c36991d54 100644 --- a/mod/quiz/report/overview/overview_options.php +++ b/mod/quiz/report/overview/overview_options.php @@ -44,15 +44,15 @@ class quiz_overview_options extends mod_quiz_attempts_report_options { protected function get_url_params() { $params = parent::get_url_params(); - $params['regradefilter'] = $this->onlyregraded; - $params['detailedmarks'] = $this->slotmarks; + $params['onlyregraded'] = $this->onlyregraded; + $params['slotmarks'] = $this->slotmarks; return $params; } public function get_initial_form_data() { $toform = parent::get_initial_form_data(); - $toform->regradefilter = $this->onlyregraded; - $toform->detailedmarks = $this->slotmarks; + $toform->onlyregraded = $this->onlyregraded; + $toform->slotmarks = $this->slotmarks; return $toform; } @@ -60,15 +60,15 @@ class quiz_overview_options extends mod_quiz_attempts_report_options { public function setup_from_form_data($fromform) { parent::setup_from_form_data($fromform); - $this->onlyregraded = !empty($fromform->regradefilter); - $this->slotmarks = $fromform->detailedmarks; + $this->onlyregraded = !empty($fromform->onlyregraded); + $this->slotmarks = $fromform->slotmarks; } public function setup_from_params() { parent::setup_from_params(); - $this->onlyregraded = optional_param('regradefilter', $this->onlyregraded, PARAM_BOOL); - $this->slotmarks = optional_param('detailedmarks', $this->slotmarks, PARAM_BOOL); + $this->onlyregraded = optional_param('onlyregraded', $this->onlyregraded, PARAM_BOOL); + $this->slotmarks = optional_param('slotmarks', $this->slotmarks, PARAM_BOOL); } public function setup_from_user_preferences() { @@ -80,7 +80,9 @@ class quiz_overview_options extends mod_quiz_attempts_report_options { public function update_user_preferences() { parent::update_user_preferences(); - set_user_preference('quiz_overview_slotmarks', $this->slotmarks); + if (quiz_has_grades($this->quiz)) { + set_user_preference('quiz_overview_slotmarks', $this->slotmarks); + } } public function resolve_dependencies() { @@ -94,6 +96,6 @@ class quiz_overview_options extends mod_quiz_attempts_report_options { // if the user has permissions and if the report mode is showing attempts. $this->checkboxcolumn = has_any_capability( array('mod/quiz:regrade', 'mod/quiz:deleteattempts'), context_module::instance($this->cm->id)) - && ($this->attempts != quiz_attempts_report::STUDENTS_WITH_NO); + && ($this->attempts != quiz_attempts_report::ENROLLED_WITHOUT); } } diff --git a/mod/quiz/report/overview/overview_table.php b/mod/quiz/report/overview/overview_table.php index 2aa63e2ce05..8c815f43e2c 100644 --- a/mod/quiz/report/overview/overview_table.php +++ b/mod/quiz/report/overview/overview_table.php @@ -53,7 +53,6 @@ class quiz_overview_table extends quiz_attempts_report_table { quiz_overview_options $options, $groupstudents, $students, $questions, $reporturl) { parent::__construct('mod-quiz-report-overview-report', $quiz , $context, $qmsubselect, $options, $groupstudents, $students, $questions, $reporturl); - $this->detailedmarks = $options->slotmarks; } public function build_table() { @@ -105,7 +104,7 @@ class quiz_overview_table extends quiz_attempts_report_table { $record->grade, $this->quiz->id, $this->context)) ); - if ($this->detailedmarks) { + if ($this->options->slotmarks) { $dm = new question_engine_data_mapper(); $qubaids = new qubaid_join($from, 'quiza.uniqueid', $where, $params); $avggradebyq = $dm->load_average_marks($qubaids, array_keys($this->questions)); @@ -281,7 +280,7 @@ class quiz_overview_table extends quiz_attempts_report_table { } protected function requires_latest_steps_loaded() { - return $this->detailedmarks; + return $this->options->slotmarks; } protected function is_latest_step_column($column) { @@ -298,7 +297,7 @@ class quiz_overview_table extends quiz_attempts_report_table { public function query_db($pagesize, $useinitialsbar = true) { parent::query_db($pagesize, $useinitialsbar); - if ($this->detailedmarks && has_capability('mod/quiz:regrade', $this->context)) { + if ($this->options->slotmarks && has_capability('mod/quiz:regrade', $this->context)) { $this->regradedqs = $this->get_regraded_questions(); } } diff --git a/mod/quiz/report/overview/report.php b/mod/quiz/report/overview/report.php index 0f101acead2..811ec200de5 100644 --- a/mod/quiz/report/overview/report.php +++ b/mod/quiz/report/overview/report.php @@ -55,7 +55,7 @@ class quiz_overview_report extends quiz_attempts_report { $this->form->set_data($options->get_initial_form_data()); - if ($options->attempts == self::ALL_ATTEMPTS) { + if ($options->attempts == self::ALL_WITH) { // This option is only available to users who can access all groups in // groups mode, so setting allowed to empty (which means all quiz attempts // are accessible, is not a security porblem. @@ -116,7 +116,7 @@ class quiz_overview_report extends quiz_attempts_report { } $hasstudents = $students && (!$currentgroup || $groupstudents); - if ($hasquestions && ($hasstudents || $options->attempts == self::ALL_ATTEMPTS)) { + if ($hasquestions && ($hasstudents || $options->attempts == self::ALL_WITH)) { // Construct the SQL. $fields = $DB->sql_concat('u.id', "'#'", 'COALESCE(quiza.attempt, 0)') . ' AS uniqueid, '; diff --git a/mod/quiz/report/reportlib.php b/mod/quiz/report/reportlib.php index 5ec5cae298b..9bfa402fad3 100644 --- a/mod/quiz/report/reportlib.php +++ b/mod/quiz/report/reportlib.php @@ -127,6 +127,15 @@ WHERE return $qsbyslot; } +/** + * @param object $quiz the quiz settings. + * @return bool whether, for this quiz, it is possible to filter attempts to show + * only those that gave the final grade. + */ +function quiz_report_can_filter_only_graded($quiz) { + return $quiz->attempts != 1 && $quiz->grademethod != QUIZ_GRADEAVERAGE; +} + /** * Given the quiz grading method return sub select sql to find the id of the * one attempt that will be graded for each user. Or return diff --git a/mod/quiz/report/responses/lang/en/quiz_responses.php b/mod/quiz/report/responses/lang/en/quiz_responses.php index 997703cf78a..7afc8a6973a 100644 --- a/mod/quiz/report/responses/lang/en/quiz_responses.php +++ b/mod/quiz/report/responses/lang/en/quiz_responses.php @@ -23,10 +23,13 @@ */ $string['cannotloadoptions'] = 'Could not load question options'; -$string['include'] = 'Include'; +$string['include'] = 'Include the'; +$string['mustselectcols'] = 'You must include something.'; $string['pagesize'] = 'Page size'; $string['pluginname'] = 'Responses'; +$string['questiontext'] = 'question text'; $string['reportresponses'] = 'Responses'; +$string['response'] = 'response'; $string['responses'] = 'Responses'; $string['responsesdownload'] = 'Responses download'; $string['responsesfilename'] = 'responses'; @@ -34,8 +37,10 @@ $string['responsesoptions'] = 'Responses options'; $string['responsesreport'] = 'Responses report'; $string['responsestitle'] = 'Responses'; $string['responsex'] = 'Response {$a}'; +$string['rightanswer'] = 'right answer'; $string['rightanswerx'] = 'Right answer {$a}'; $string['showattempts'] = 'Only show / download attempts'; +$string['showthe'] = 'Show the'; $string['summaryofquestiontext'] = 'Summary of the question'; $string['summaryofresponse'] = 'Summary of the response given'; $string['summaryofrightanswer'] = 'Summary of the right answer'; diff --git a/mod/quiz/report/responses/report.php b/mod/quiz/report/responses/report.php index 602202f9d2d..800637b5f6a 100644 --- a/mod/quiz/report/responses/report.php +++ b/mod/quiz/report/responses/report.php @@ -63,7 +63,7 @@ class quiz_responses_report extends quiz_attempts_report { $this->form->set_data($options->get_initial_form_data()); - if ($options->attempts == self::ALL_ATTEMPTS) { + if ($options->attempts == self::ALL_WITH) { // This option is only available to users who can access all groups in // groups mode, so setting allowed to empty (which means all quiz attempts // are accessible, is not a security porblem. @@ -124,7 +124,7 @@ class quiz_responses_report extends quiz_attempts_report { } $hasstudents = $students && (!$currentgroup || $groupstudents); - if ($hasquestions && ($hasstudents || $options->attempts == self::ALL_ATTEMPTS)) { + if ($hasquestions && ($hasstudents || $options->attempts == self::ALL_WITH)) { list($fields, $from, $where, $params) = $table->base_sql($allowed); diff --git a/mod/quiz/report/responses/responses_form.php b/mod/quiz/report/responses/responses_form.php index 038673eaf0f..0a2f55239d3 100644 --- a/mod/quiz/report/responses/responses_form.php +++ b/mod/quiz/report/responses/responses_form.php @@ -35,19 +35,29 @@ require_once($CFG->dirroot . '/mod/quiz/report/attemptsreport_form.php'); * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class quiz_responses_settings_form extends mod_quiz_attempts_report_form { - protected function definition_inner(MoodleQuickForm $mform) { - if ($this->_customdata['qmsubselect']) { - $mform->addElement($this->create_qmfilter_checkbox($mform)); + + protected function other_preference_fields(MoodleQuickForm $mform) { + $mform->addGroup(array( + $mform->createElement('advcheckbox', 'qtext', '', + get_string('questiontext', 'quiz_responses')), + $mform->createElement('advcheckbox', 'resp', '', + get_string('response', 'quiz_responses')), + $mform->createElement('advcheckbox', 'right', '', + get_string('rightanswer', 'quiz_responses')), + ), 'coloptions', get_string('showthe', 'quiz_responses'), array(' '), false); + $mform->disabledIf('qtext', 'attempts', 'eq', quiz_attempts_report::ENROLLED_WITHOUT); + $mform->disabledIf('resp', 'attempts', 'eq', quiz_attempts_report::ENROLLED_WITHOUT); + $mform->disabledIf('right', 'attempts', 'eq', quiz_attempts_report::ENROLLED_WITHOUT); + } + + public function validation($data, $files) { + $errors = parent::validation($data, $files); + + if ($data['attempts'] != quiz_attempts_report::ENROLLED_WITHOUT && !( + $data['qtext'] || $data['resp'] || $data['right'])) { + $errors['coloptions'] = get_string('reportmustselectstate', 'quiz'); } - $colsgroup = array(); - $colsgroup[] = $mform->createElement('advcheckbox', 'qtext', '', - get_string('summaryofquestiontext', 'quiz_responses')); - $colsgroup[] = $mform->createElement('advcheckbox', 'resp', '', - get_string('summaryofresponse', 'quiz_responses')); - $colsgroup[] = $mform->createElement('advcheckbox', 'right', '', - get_string('summaryofrightanswer', 'quiz_responses')); - $mform->addGroup($colsgroup, null, - get_string('include', 'quiz_responses'), '
', false); + return $errors; } } diff --git a/mod/quiz/report/responses/responses_options.php b/mod/quiz/report/responses/responses_options.php index 52a7c966201..15c57e69945 100644 --- a/mod/quiz/report/responses/responses_options.php +++ b/mod/quiz/report/responses/responses_options.php @@ -105,6 +105,6 @@ class quiz_responses_options extends mod_quiz_attempts_report_options { // We only want to show the checkbox to delete attempts // if the user has permissions and if the report mode is showing attempts. $this->checkboxcolumn = has_capability('mod/quiz:deleteattempts', context_module::instance($this->cm->id)) - && ($this->attempts != quiz_attempts_report::STUDENTS_WITH_NO); + && ($this->attempts != quiz_attempts_report::ENROLLED_WITHOUT); } } From 34b7d8383d0bfdd5e1582e71420fc2aab5c67c3e Mon Sep 17 00:00:00 2001 From: Tim Hunt Date: Wed, 18 Apr 2012 19:18:55 +0100 Subject: [PATCH 13/20] MDL-3030 quiz overdue handling: trigger automatic state transitions. Here, we catch all the places where a student might be accessing their own attempts, and make sure any automatic state transitions that should happen, do happen, before the student sees the attempt. The places where we need to check this are view.php, startattempt.php and processattempt.php. We do not really need to check attempt.php or summary.php, because if the student is on one of those pages, the JavaScript timer will auto-submit when time expires, taking them to processattempt.php, which will do the acutal work. We intentionally do not trigger state transition when a teacher is looking at a student's quiz attemp. We will trigger state transitions on cron, but that is still to do. Also, the body of the process_... methods still needs to be written. --- mod/quiz/attempt.php | 9 ++- mod/quiz/attemptlib.php | 124 ++++++++++++++++++++++++++--- mod/quiz/comment.php | 2 +- mod/quiz/lib.php | 32 ++++++-- mod/quiz/locallib.php | 8 ++ mod/quiz/processattempt.php | 30 +++++-- mod/quiz/report/grading/report.php | 2 +- mod/quiz/review.php | 7 +- mod/quiz/settingslib.php | 14 ++-- mod/quiz/startattempt.php | 36 ++++++--- mod/quiz/view.php | 11 ++- 11 files changed, 218 insertions(+), 57 deletions(-) diff --git a/mod/quiz/attempt.php b/mod/quiz/attempt.php index 1c5856ebe9d..fba97122439 100644 --- a/mod/quiz/attempt.php +++ b/mod/quiz/attempt.php @@ -17,10 +17,9 @@ /** * This script displays a particular page of a quiz attempt that is in progress. * - * @package mod - * @subpackage quiz - * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @package mod_quiz + * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ require_once(dirname(__FILE__) . '/../../config.php'); @@ -70,6 +69,8 @@ if (!$attemptobj->is_preview_user()) { // If the attempt is already closed, send them to the review page. if ($attemptobj->is_finished()) { redirect($attemptobj->review_url(null, $page)); +} else if ($attemptobj->get_state() == quiz_attempt::OVERDUE) { + redirect($attemptobj->summary_url()); } // Check the access rules. diff --git a/mod/quiz/attemptlib.php b/mod/quiz/attemptlib.php index cbd88ca030e..13432c1b2e5 100644 --- a/mod/quiz/attemptlib.php +++ b/mod/quiz/attemptlib.php @@ -20,10 +20,9 @@ * There are classes for loading all the information about a quiz and attempts, * and for displaying the navigation panel. * - * @package mod - * @subpackage quiz - * @copyright 2008 onwards Tim Hunt - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @package mod_quiz + * @copyright 2008 onwards Tim Hunt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ @@ -34,9 +33,9 @@ defined('MOODLE_INTERNAL') || die(); * Class for quiz exceptions. Just saves a couple of arguments on the * constructor for a moodle_exception. * - * @copyright 2008 Tim Hunt - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - * @since Moodle 2.0 + * @copyright 2008 Tim Hunt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @since Moodle 2.0 */ class moodle_quiz_exception extends moodle_exception { public function __construct($quizobj, $errorcode, $a = null, $link = '', $debuginfo = null) { @@ -118,6 +117,15 @@ class quiz { return new quiz($quiz, $cm, $course); } + /** + * Create a {@link quiz_attempt} for an attempt at this quiz. + * @param object $attemptdata row from the quiz_attempts table. + * @return quiz_attempt the new quiz_attempt object. + */ + public function create_attempt_object($attemptdata) { + return new quiz_attempt($attemptdata, $this->quiz, $this->cm, $this->course); + } + // Functions for loading more data ===================================================== /** @@ -324,6 +332,14 @@ class quiz { return new moodle_url('/mod/quiz/review.php', array('attempt' => $attemptid)); } + /** + * @param int $attemptid the id of an attempt. + * @return string the URL of the review of that attempt. + */ + public function summary_url($attemptid) { + return new moodle_url('/mod/quiz/summary.php', array('attempt' => $attemptid)); + } + // Bits of content ===================================================================== /** @@ -659,10 +675,11 @@ class quiz_attempt { /** * @return bool whether this attempt has been finished (true) or is still - * in progress (false). + * in progress (false). Be warned that this is not just state == self::FINISHED, + * it also includes self::ABANDONED. */ public function is_finished() { - return $this->attempt->state == self::FINISHED; + return $this->attempt->state == self::FINISHED || $this->attempt->state == self::ABANDONED; } /** @return bool whether this attempt is a preview attempt. */ @@ -1234,26 +1251,80 @@ class quiz_attempt { // Methods for processing ================================================== + /** + * Check this attempt, to see if there are any state transitions that should + * happen automatically. + * @param int $timestamp the timestamp that should be stored as the modifed + */ + public function handle_if_time_expired($timestamp) { + global $DB; + + if ($this->get_time_left($timestamp) > 0) { + return; // Time has not yet expired. + } + + // If the attempt is already overdue, look to see if it should be abandoned ... + if ($this->attempt->state == quiz_attempt::OVERDUE) { + $timeover = -$this->get_access_manager($timestamp)->get_time_left($this->attempt, $timestamp); + if ($timeover > $this->quizobj->get_quiz()->graceperiod) { + $this->process_abandon($timestamp); + } + + return; // ... and we are done. + } + + if ($this->attempt->state != quiz_attempt::IN_PROGRESS) { + return; // Attempt is already in a final state. + } + + // Otherwise, we were in quiz_attempt::IN_PROGRESS, and time has now expired. + // Transition to the appropriate state. + switch ($this->quizobj->get_quiz()->overduehandling) { + case 'autosubmit': + $this->process_finish($timestamp, false); + return; + + case 'graceperiod': + $this->process_going_overdue($timestamp); + return; + + case 'autoabandon': + $this->process_abandon($timestamp); + return; + } + } + /** * Process all the actions that were submitted as part of the current request. * * @param int $timestamp the timestamp that should be stored as the modifed * time in the database for these actions. If null, will use the current time. */ - public function process_all_actions($timestamp) { + public function process_submitted_actions($timestamp, $becomingoverdue = false) { global $DB; + + $transaction = $DB->start_delegated_transaction(); + $this->quba->process_all_actions($timestamp); question_engine::save_questions_usage_by_activity($this->quba); $this->attempt->timemodified = $timestamp; - if ($this->attempt->timefinish) { + if ($this->attempt->state == self::FINISHED) { $this->attempt->sumgrades = $this->quba->get_total_mark(); } + if ($becomingoverdue) { + // We do not trigger a full becoming overdue transition, because we + // don't want an email if the student is actively doing the quiz when + // time expires - I think. + $this->attempt->state == self::OVERDUE; + } $DB->update_record('quiz_attempts', $this->attempt); if (!$this->is_preview() && $this->attempt->timefinish) { quiz_save_best_grade($this->get_quiz(), $this->get_userid()); } + + $transaction->allow_commit(); } /** @@ -1261,13 +1332,20 @@ class quiz_attempt { * flagged state was changed in the request. */ public function save_question_flags() { + $transaction = $DB->start_delegated_transaction(); $this->quba->update_question_flags(); question_engine::save_questions_usage_by_activity($this->quba); + $transaction->allow_commit(); } - public function finish_attempt($timestamp) { + public function process_finish($timestamp, $processsubmitted) { global $DB, $USER; - $this->quba->process_all_actions($timestamp); + + $transaction = $DB->start_delegated_transaction(); + + if ($processsubmitted) { + $this->quba->process_all_actions($timestamp); + } $this->quba->finish_all_questions($timestamp); question_engine::save_questions_usage_by_activity($this->quba); @@ -1295,6 +1373,26 @@ class quiz_attempt { // Tell any access rules that care that the attempt is over. $this->get_access_manager($timestamp)->current_attempt_finished(); } + + $transaction->allow_commit(); + } + + /** + * Mark this attempt as now overdue. + * @param int $timestamp the time to deem as now. + */ + public function process_going_overdue($timestamp) { + // TODO update DB. + // TODO raise event. + } + + /** + * Mark this attempt as abandoned. + * @param int $timestamp the time to deem as now. + */ + public function process_abandon($timestamp) { + // TODO update DB. + // TODO raise event. } /** diff --git a/mod/quiz/comment.php b/mod/quiz/comment.php index 1d51ade4ffd..2249da3cb21 100644 --- a/mod/quiz/comment.php +++ b/mod/quiz/comment.php @@ -57,7 +57,7 @@ echo $OUTPUT->heading(format_string($attemptobj->get_question_name($slot))); if (data_submitted() && confirm_sesskey()) { if (optional_param('submit', false, PARAM_BOOL)) { $transaction = $DB->start_delegated_transaction(); - $attemptobj->process_all_actions(time()); + $attemptobj->process_submitted_actions(time()); $transaction->allow_commit(); echo $OUTPUT->notification(get_string('changessaved'), 'notifysuccess'); close_window(2, true); diff --git a/mod/quiz/lib.php b/mod/quiz/lib.php index 004487fea62..fcf2a62f9c7 100644 --- a/mod/quiz/lib.php +++ b/mod/quiz/lib.php @@ -463,18 +463,36 @@ function quiz_cron() { */ function quiz_get_user_attempts($quizid, $userid, $status = 'finished', $includepreviews = false) { global $DB; - $status_condition = array( - 'all' => '', - 'finished' => ' AND timefinish > 0', - 'unfinished' => ' AND timefinish = 0' - ); + + $params = array(); + switch ($status) { + case 'all': + $statuscondition = ''; + break; + + case 'finished': + $statuscondition = ' AND state IN (:state1, :state2)'; + $params['state1'] = quiz_attempt::FINISHED; + $params['state2'] = quiz_attempt::ABANDONED; + break; + + case 'unfinished': + $statuscondition = ' AND state IN (:state1, :state2)'; + $params['state1'] = quiz_attempt::IN_PROGRESS; + $params['state2'] = quiz_attempt::OVERDUE; + break; + } + $previewclause = ''; if (!$includepreviews) { $previewclause = ' AND preview = 0'; } + + $params['quizid'] = $quizid; + $params['userid'] = $userid; return $DB->get_records_select('quiz_attempts', - 'quiz = ? AND userid = ?' . $previewclause . $status_condition[$status], - array($quizid, $userid), 'attempt ASC'); + 'quiz = :quizid AND userid = :userid' . $previewclause . $statuscondition, + $params, 'attempt ASC'); } /** diff --git a/mod/quiz/locallib.php b/mod/quiz/locallib.php index 9953761d04e..82797a68ee8 100644 --- a/mod/quiz/locallib.php +++ b/mod/quiz/locallib.php @@ -48,6 +48,14 @@ require_once($CFG->libdir . '/filelib.php'); */ define('QUIZ_SHOW_TIME_BEFORE_DEADLINE', '3600'); +/** + * @var int If there are fewer than this many seconds left when the student submits + * a page of the quiz, then do not take them to the next page of the quiz. Instead + * close the quiz immediately. + */ +define('QUIZ_MIN_TIME_TO_CONTINUE', '2'); + + /// Functions related to attempts ///////////////////////////////////////// /** diff --git a/mod/quiz/processattempt.php b/mod/quiz/processattempt.php index 4ec7afe5b53..f8ccee9859d 100644 --- a/mod/quiz/processattempt.php +++ b/mod/quiz/processattempt.php @@ -41,7 +41,7 @@ $attemptid = required_param('attempt', PARAM_INT); $next = optional_param('next', false, PARAM_BOOL); $thispage = optional_param('thispage', 0, PARAM_INT); $nextpage = optional_param('nextpage', 0, PARAM_INT); -$finishattempt = optional_param('finishattempt', 0, PARAM_BOOL); +$finishattempt = optional_param('finishattempt', false, PARAM_BOOL); $timeup = optional_param('timeup', 0, PARAM_BOOL); // True if form was submitted by timer. $scrollpos = optional_param('scrollpos', '', PARAM_RAW); @@ -63,9 +63,11 @@ if ($page == -1) { } } -// We treat automatically closed attempts just like normally closed attempts -if ($timeup) { - $finishattempt = 1; +// If there is only a very small amount of time left, there is not point trying +// to show the student another page of the quiz. Just finish now. +$accessmanager = $attemptobj->get_access_manager($timenow); +if ($accessmanager->get_time_left($attempt, $timenow) < QUIZ_MIN_TIME_TO_CONTINUE) { + $timeup = true; } // Check login. @@ -88,12 +90,22 @@ if ($attemptobj->is_finished()) { 'attemptalreadyclosed', null, $attemptobj->review_url()); } +// If time is running out, trigger the appropriate action. +$becomingoverdue = false; +if ($timeup) { + if ($attemptobj->get_quiz()->overduehandling == 'graceperiod') { + $becomingoverdue = true; + } else { + $finishattempt = true; + } +} + // Don't log - we will end with a redirect to a page that is logged. if (!$finishattempt) { // Just process the responses for this page and go to the next page. try { - $attemptobj->process_all_actions($timenow); + $attemptobj->process_submitted_actions($timenow, $becomingoverdue); } catch (question_out_of_sequence_exception $e) { print_error('submissionoutofsequencefriendlymessage', 'question', @@ -111,7 +123,11 @@ if (!$finishattempt) { } $transaction->allow_commit(); - redirect($nexturl); + if ($becomingoverdue) { + redirect($attemptobj->summary_url()); + } else { + redirect($nexturl); + } } // Otherwise, we have been asked to finish attempt, so do that. @@ -123,7 +139,7 @@ add_to_log($attemptobj->get_courseid(), 'quiz', 'close attempt', // Update the quiz attempt record. try { - $attemptobj->finish_attempt($timenow); + $attemptobj->process_finish($timenow, true); } catch (question_out_of_sequence_exception $e) { print_error('submissionoutofsequencefriendlymessage', 'question', diff --git a/mod/quiz/report/grading/report.php b/mod/quiz/report/grading/report.php index 02947f2179c..3cd33369db2 100644 --- a/mod/quiz/report/grading/report.php +++ b/mod/quiz/report/grading/report.php @@ -489,7 +489,7 @@ class quiz_grading_report extends quiz_default_report { $attempt = $attempts[$qubaid]; $quba = question_engine::load_questions_usage_by_activity($qubaid); $attemptobj = new quiz_attempt($attempt, $this->quiz, $this->cm, $this->course); - $attemptobj->process_all_actions(time()); + $attemptobj->process_submitted_actions(time()); } $transaction->allow_commit(); } diff --git a/mod/quiz/review.php b/mod/quiz/review.php index 14a1784a127..9882ef6c765 100644 --- a/mod/quiz/review.php +++ b/mod/quiz/review.php @@ -20,10 +20,9 @@ * It is used either by the student whose attempts this is, after the attempt, * or by a teacher reviewing another's attempt during or afterwards. * - * @package mod - * @subpackage quiz - * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @package mod_quiz + * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ diff --git a/mod/quiz/settingslib.php b/mod/quiz/settingslib.php index 70b0e614dbe..af5e0d35f47 100644 --- a/mod/quiz/settingslib.php +++ b/mod/quiz/settingslib.php @@ -198,13 +198,13 @@ class mod_quiz_admin_setting_browsersecurity extends admin_setting_configselect_ /** -* Admin settings class for the quiz overdue attempt handling method. -* -* Just so we can lazy-load the choices. -* -* @copyright 2011 The Open University -* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later -*/ + * Admin settings class for the quiz overdue attempt handling method. + * + * Just so we can lazy-load the choices. + * + * @copyright 2011 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ class mod_quiz_admin_setting_overduehandling extends admin_setting_configselect_with_advanced { public function load_choices() { global $CFG; diff --git a/mod/quiz/startattempt.php b/mod/quiz/startattempt.php index 44636d1d3e0..6dcac641ee2 100644 --- a/mod/quiz/startattempt.php +++ b/mod/quiz/startattempt.php @@ -21,10 +21,9 @@ * * This code used to be at the top of attempt.php, if you are looking for CVS history. * - * @package mod - * @subpackage quiz - * @copyright 2009 The Open University - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @package mod_quiz + * @copyright 2009 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ require_once(dirname(__FILE__) . '/../../config.php'); @@ -60,7 +59,8 @@ if (!$quizobj->has_questions()) { } // Create an object to manage all the other (non-roles) access rules. -$accessmanager = $quizobj->get_access_manager(time()); +$timenow = time(); +$accessmanager = $quizobj->get_access_manager($timenow); if ($quizobj->is_preview_user() && $forcenew) { $accessmanager->current_attempt_finished(); } @@ -72,9 +72,9 @@ if (!$quizobj->is_preview_user()) { // Check to see if a new preview was requested. if ($quizobj->is_preview_user() && $forcenew) { - // To force the creation of a new preview, we set a finish time on the - // current attempt (if any). It will then automatically be deleted below - $DB->set_field('quiz_attempts', 'timefinish', time(), + // To force the creation of a new preview, we mark the current attempt (if any) + // as finished. It will then automatically be deleted below + $DB->set_field('quiz_attempts', 'state', quiz_attempt::FINISHED, array('quiz' => $quizobj->get_quizid(), 'userid' => $USER->id)); } @@ -83,10 +83,22 @@ $attempts = quiz_get_user_attempts($quizobj->get_quizid(), $USER->id, 'all', tru $lastattempt = end($attempts); // If an in-progress attempt exists, check password then redirect to it. -if ($lastattempt && !$lastattempt->timefinish) { +if ($lastattempt && ($lastattempt->state == quiz_attempt::IN_PROGRESS || + $lastattempt->state == quiz_attempt::OVERDUE)) { $currentattemptid = $lastattempt->id; $messages = $accessmanager->prevent_access(); + // If the attempt is now overdue, deal with that. + $quizobj->create_attempt_object($lastattempt)->handle_if_time_expired($timenow); + + // And, if the attempt is now no longer in progress, redirect to the appropriate place. + if ($lastattempt->state == quiz_attempt::OVERDUE) { + redirect($quizobj->summary_url($lastattempt->id)); + } else if ($lastattempt->state != quiz_attempt::IN_PROGRESS) { + redirect($quizobj->review_url($lastattempt->id)); + } + + // If the page number was not explicitly in the URL, go to the current page. if ($page == -1) { $page = $lastattempt->currentpage; } @@ -152,7 +164,8 @@ $quba = question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj-> $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour); // Create the new attempt and initialize the question sessions -$attempt = quiz_create_attempt($quizobj->get_quiz(), $attemptnumber, $lastattempt, time(), +$timenow = time(); // Update time now, in case the server is running really slowly. +$attempt = quiz_create_attempt($quizobj->get_quiz(), $attemptnumber, $lastattempt, $timenow, $quizobj->is_preview_user()); if (!($quizobj->get_quiz()->attemptonlast && $lastattempt)) { @@ -192,8 +205,7 @@ if (!($quizobj->get_quiz()->attemptonlast && $lastattempt)) { $variantoffset = $attemptnumber; } $quba->start_all_questions( - new question_variant_pseudorandom_no_repeats_strategy($variantoffset), - time()); + new question_variant_pseudorandom_no_repeats_strategy($variantoffset), $timenow); // Update attempt layout. $newlayout = array(); diff --git a/mod/quiz/view.php b/mod/quiz/view.php index b0119e568f6..9867e422ff8 100644 --- a/mod/quiz/view.php +++ b/mod/quiz/view.php @@ -89,7 +89,16 @@ $lastfinishedattempt = end($attempts); $unfinished = false; if ($unfinishedattempt = quiz_get_user_attempt_unfinished($quiz->id, $USER->id)) { $attempts[] = $unfinishedattempt; - $unfinished = true; + + // If the attempt is now overdue, deal with that. + $quizobj->create_attempt_object($unfinishedattempt)->handle_if_time_expired(time()); + + $unfinished = $unfinishedattempt->state == quiz_attempt::IN_PROGRESS || + $unfinishedattempt->state == quiz_attempt::OVERDUE; + if (!$unfinished) { + $lastfinishedattempt = $unfinishedattempt; + } + $unfinishedattempt = null; // To make it clear we do not use this again. } $numattempts = count($attempts); From a403bce0bd15536c4462e1993b79068788b2d167 Mon Sep 17 00:00:00 2001 From: Tim Hunt Date: Fri, 20 Apr 2012 14:51:15 +0100 Subject: [PATCH 14/20] MDL-3030 quiz overdue handling: back-end code for the new transitions --- mod/quiz/attemptlib.php | 104 +++++++++++++++++++++++++++----------- mod/quiz/db/events.php | 22 ++++++++ mod/quiz/startattempt.php | 3 +- mod/quiz/view.php | 5 +- 4 files changed, 102 insertions(+), 32 deletions(-) diff --git a/mod/quiz/attemptlib.php b/mod/quiz/attemptlib.php index 13432c1b2e5..9710bf991b5 100644 --- a/mod/quiz/attemptlib.php +++ b/mod/quiz/attemptlib.php @@ -1255,19 +1255,22 @@ class quiz_attempt { * Check this attempt, to see if there are any state transitions that should * happen automatically. * @param int $timestamp the timestamp that should be stored as the modifed + * @param bool $studentisonline is the student currently interacting with Moodle? */ - public function handle_if_time_expired($timestamp) { + public function handle_if_time_expired($timestamp, $studentisonline) { global $DB; - if ($this->get_time_left($timestamp) > 0) { + $timeleft = $this->get_access_manager($timestamp)->get_time_left($this->attempt, $timestamp); + + if ($timeleft === false || $timeleft > 0) { return; // Time has not yet expired. } // If the attempt is already overdue, look to see if it should be abandoned ... if ($this->attempt->state == quiz_attempt::OVERDUE) { - $timeover = -$this->get_access_manager($timestamp)->get_time_left($this->attempt, $timestamp); - if ($timeover > $this->quizobj->get_quiz()->graceperiod) { - $this->process_abandon($timestamp); + $timeoverdue = -$timeleft; + if ($timeoverdue > $this->quizobj->get_quiz()->graceperiod) { + $this->process_abandon($timestamp, $studentisonline); } return; // ... and we are done. @@ -1285,11 +1288,11 @@ class quiz_attempt { return; case 'graceperiod': - $this->process_going_overdue($timestamp); + $this->process_going_overdue($timestamp, $studentisonline); return; case 'autoabandon': - $this->process_abandon($timestamp); + $this->process_abandon($timestamp, $studentisonline); return; } } @@ -1313,12 +1316,10 @@ class quiz_attempt { $this->attempt->sumgrades = $this->quba->get_total_mark(); } if ($becomingoverdue) { - // We do not trigger a full becoming overdue transition, because we - // don't want an email if the student is actively doing the quiz when - // time expires - I think. - $this->attempt->state == self::OVERDUE; + $this->process_going_overdue($timestamp, true); + } else { + $DB->update_record('quiz_attempts', $this->attempt); } - $DB->update_record('quiz_attempts', $this->attempt); if (!$this->is_preview() && $this->attempt->timefinish) { quiz_save_best_grade($this->get_quiz(), $this->get_userid()); @@ -1339,7 +1340,7 @@ class quiz_attempt { } public function process_finish($timestamp, $processsubmitted) { - global $DB, $USER; + global $DB; $transaction = $DB->start_delegated_transaction(); @@ -1353,22 +1354,14 @@ class quiz_attempt { $this->attempt->timemodified = $timestamp; $this->attempt->timefinish = $timestamp; $this->attempt->sumgrades = $this->quba->get_total_mark(); + $this->attempt->state = self::FINISHED; $DB->update_record('quiz_attempts', $this->attempt); if (!$this->is_preview()) { quiz_save_best_grade($this->get_quiz(), $this->attempt->userid); // Trigger event - $eventdata = new stdClass(); - $eventdata->component = 'mod_quiz'; - $eventdata->attemptid = $this->attempt->id; - $eventdata->timefinish = $this->attempt->timefinish; - $eventdata->userid = $this->attempt->userid; - $eventdata->submitterid = $USER->id; - $eventdata->quizid = $this->get_quizid(); - $eventdata->cmid = $this->get_cmid(); - $eventdata->courseid = $this->get_courseid(); - events_trigger('quiz_attempt_submitted', $eventdata); + $this->fire_state_transition_event('quiz_attempt_submitted', $timestamp); // Tell any access rules that care that the attempt is over. $this->get_access_manager($timestamp)->current_attempt_finished(); @@ -1380,19 +1373,72 @@ class quiz_attempt { /** * Mark this attempt as now overdue. * @param int $timestamp the time to deem as now. + * @param bool $studentisonline is the student currently interacting with Moodle? */ - public function process_going_overdue($timestamp) { - // TODO update DB. - // TODO raise event. + public function process_going_overdue($timestamp, $studentisonline) { + global $DB; + + $transaction = $DB->start_delegated_transaction(); + $this->attempt->timemodified = $timestamp; + $this->attempt->state = self::OVERDUE; + $DB->update_record('quiz_attempts', $this->attempt); + + $this->fire_state_transition_event('quiz_attempt_overdue', $timestamp); + + $transaction->allow_commit(); } /** * Mark this attempt as abandoned. * @param int $timestamp the time to deem as now. + * @param bool $studentisonline is the student currently interacting with Moodle? */ - public function process_abandon($timestamp) { - // TODO update DB. - // TODO raise event. + public function process_abandon($timestamp, $studentisonline) { + global $DB; + + $transaction = $DB->start_delegated_transaction(); + $this->attempt->timemodified = $timestamp; + $this->attempt->state = self::ABANDONED; + $DB->update_record('quiz_attempts', $this->attempt); + + $this->fire_state_transition_event('quiz_attempt_abandoned', $timestamp); + + $transaction->allow_commit(); + } + + /** + * Fire a state transition event. + * @param string $event the type of event. Should be listed in db/events.php. + * @param int $timestamp the timestamp to include in the event. + */ + protected function fire_state_transition_event($event, $timestamp) { + global $USER; + + // Trigger event + $eventdata = new stdClass(); + $eventdata->component = 'mod_quiz'; + $eventdata->attemptid = $this->attempt->id; + $eventdata->timestamp = $timestamp; + $eventdata->userid = $this->attempt->userid; + $eventdata->quizid = $this->get_quizid(); + $eventdata->cmid = $this->get_cmid(); + $eventdata->courseid = $this->get_courseid(); + + // I don't think if (CLI_SCRIPT) is really the right logic here. The + // question is really 'is $USER currently set to a real user', but I cannot + // see standard Moodle function to answer that question. For example, + // cron fakes $USER. + if (CLI_SCRIPT) { + $eventdata->submitterid = null; + } else { + $eventdata->submitterid = $USER->id; + } + + if ($event == 'quiz_attempt_submitted') { + $eventdata->timefinish = $timestamp; // Backwards compatibility. + } + + events_trigger($event, $eventdata); } /** diff --git a/mod/quiz/db/events.php b/mod/quiz/db/events.php index 0a9de7edcee..84b4b1422b9 100644 --- a/mod/quiz/db/events.php +++ b/mod/quiz/db/events.php @@ -43,6 +43,7 @@ quiz_attempt_started ->component = 'mod_quiz'; ->attemptid = // The id of the new quiz attempt. ->timestart = // The timestamp of when the attempt was started. + ->timestamp = // The timestamp of when the attempt was started. ->userid = // The user id that the attempt belongs to. ->quizid = // The quiz id of the quiz the attempt belongs to. ->cmid = // The course_module id of the quiz the attempt belongs to. @@ -52,10 +53,31 @@ quiz_attempt_submitted ->component = 'mod_quiz'; ->attemptid = // The id of the quiz attempt that was submitted. ->timefinish = // The timestamp of when the attempt was submitted. + ->timestamp = // The timestamp of when the attempt was submitted. ->userid = // The user id that the attempt belongs to. ->submitterid = // The user id of the user who sumitted the attempt. ->quizid = // The quiz id of the quiz the attempt belongs to. ->cmid = // The course_module id of the quiz the attempt belongs to. ->courseid = // The course id of the course the quiz belongs to. +quiz_attempt_overdue + ->component = 'mod_quiz'; + ->attemptid = // The id of the quiz attempt that has become overdue. + ->timestamp = // The timestamp of when the attempt become overdue. + ->userid = // The user id that the attempt belongs to. + ->submitterid = // The user id of the user who triggered this transition (may be null, e.g. on cron.). + ->quizid = // The quiz id of the quiz the attempt belongs to. + ->cmid = // The course_module id of the quiz the attempt belongs to. + ->courseid = // The course id of the course the quiz belongs to. + +quiz_attempt_abandoned + ->component = 'mod_quiz'; + ->attemptid = // The id of the quiz attempt that was submitted. + ->timestamp = // The timestamp of when the attempt was submitted. + ->userid = // The user id that the attempt belongs to. + ->submitterid = // The user id of the user who triggered this transition (may be null, e.g. on cron.). + ->quizid = // The quiz id of the quiz the attempt belongs to. + ->cmid = // The course_module id of the quiz the attempt belongs to. + ->courseid = // The course id of the course the quiz belongs to. + */ diff --git a/mod/quiz/startattempt.php b/mod/quiz/startattempt.php index 6dcac641ee2..083d369b062 100644 --- a/mod/quiz/startattempt.php +++ b/mod/quiz/startattempt.php @@ -89,7 +89,7 @@ if ($lastattempt && ($lastattempt->state == quiz_attempt::IN_PROGRESS || $messages = $accessmanager->prevent_access(); // If the attempt is now overdue, deal with that. - $quizobj->create_attempt_object($lastattempt)->handle_if_time_expired($timenow); + $quizobj->create_attempt_object($lastattempt)->handle_if_time_expired($timenow, true); // And, if the attempt is now no longer in progress, redirect to the appropriate place. if ($lastattempt->state == quiz_attempt::OVERDUE) { @@ -264,6 +264,7 @@ $eventdata = new stdClass(); $eventdata->component = 'mod_quiz'; $eventdata->attemptid = $attempt->id; $eventdata->timestart = $attempt->timestart; +$eventdata->timestamp = $attempt->timestart; $eventdata->userid = $attempt->userid; $eventdata->quizid = $quizobj->get_quizid(); $eventdata->cmid = $quizobj->get_cmid(); diff --git a/mod/quiz/view.php b/mod/quiz/view.php index 9867e422ff8..1c86e57e530 100644 --- a/mod/quiz/view.php +++ b/mod/quiz/view.php @@ -90,8 +90,9 @@ $unfinished = false; if ($unfinishedattempt = quiz_get_user_attempt_unfinished($quiz->id, $USER->id)) { $attempts[] = $unfinishedattempt; - // If the attempt is now overdue, deal with that. - $quizobj->create_attempt_object($unfinishedattempt)->handle_if_time_expired(time()); + // If the attempt is now overdue, deal with that - and pass isonline = false. + // We want the student notified in this case. + $quizobj->create_attempt_object($unfinishedattempt)->handle_if_time_expired(time(), false); $unfinished = $unfinishedattempt->state == quiz_attempt::IN_PROGRESS || $unfinishedattempt->state == quiz_attempt::OVERDUE; From fd0537ce365c05edd14389d657bca6217f2ee9d2 Mon Sep 17 00:00:00 2001 From: Tim Hunt Date: Fri, 20 Apr 2012 16:24:33 +0100 Subject: [PATCH 15/20] MDL-3030 quiz overdue handling: catch the overdue event and send message. --- mod/quiz/db/events.php | 8 +++ mod/quiz/db/messages.php | 22 +++++--- mod/quiz/lang/en/quiz.php | 19 +++++-- mod/quiz/locallib.php | 110 ++++++++++++++++++++++++++++++++++++-- 4 files changed, 144 insertions(+), 15 deletions(-) diff --git a/mod/quiz/db/events.php b/mod/quiz/db/events.php index 84b4b1422b9..0ff77f52866 100644 --- a/mod/quiz/db/events.php +++ b/mod/quiz/db/events.php @@ -35,6 +35,14 @@ $handlers = array( 'handlerfunction' => 'quiz_attempt_submitted_handler', 'schedule' => 'cron', ), + + // Handle our own quiz_attempt_overdue event, to email the student to let them + // know they forgot to submit, and that they have another chance. + 'quiz_attempt_overdue' => array ( + 'handlerfile' => '/mod/quiz/locallib.php', + 'handlerfunction' => 'quiz_attempt_overdue_handler', + 'schedule' => 'cron', + ), ); /* List of events generated by the quiz module, with the fields on the event object. diff --git a/mod/quiz/db/messages.php b/mod/quiz/db/messages.php index e8094070f62..c6ab3905d17 100644 --- a/mod/quiz/db/messages.php +++ b/mod/quiz/db/messages.php @@ -25,14 +25,20 @@ defined('MOODLE_INTERNAL') || die(); -$messageproviders = array ( - // Notify teacher that a student has submitted a quiz attempt - 'submission' => array ( - 'capability' => 'mod/quiz:emailnotifysubmission' +$messageproviders = array( + // Notify teacher that a student has submitted a quiz attempt. + 'submission' => array( + 'capability' => 'mod/quiz:emailnotifysubmission' ), - // Confirm a student's quiz attempt - 'confirmation' => array ( - 'capability' => 'mod/quiz:emailconfirmsubmission' - ) + // Confirm a student's quiz attempt. + 'confirmation' => array( + 'capability' => 'mod/quiz:emailconfirmsubmission' + ), + + // Warning to the student that their quiz attempt is now overdue, if the quiz + // has a grace period. + 'attempt_overdue' => array( + 'capability' => 'mod/quiz:emailwarnoverdue' + ), ); diff --git a/mod/quiz/lang/en/quiz.php b/mod/quiz/lang/en/quiz.php index d207e9e4b6f..340e678612a 100644 --- a/mod/quiz/lang/en/quiz.php +++ b/mod/quiz/lang/en/quiz.php @@ -262,16 +262,27 @@ This message confirms that we have safely received your answers. You can access this quiz at {$a->quizurl}.'; $string['emailconfirmsmall'] = 'Thank you for submitting your answers to \'{$a->quizname}\''; -$string['emailconfirmsubject'] = 'Quiz submission confirmation: {$a->quizname}'; +$string['emailconfirmsubject'] = 'Submission confirmation: {$a->quizname}'; $string['emailnotifybody'] = 'Dear {$a->username}, -{$a->studentname} has completed the quiz +{$a->studentname} has completed \'{$a->quizname}\' ({$a->quizurl}) in course \'{$a->coursename}\' You can review this attempt at {$a->quizreviewurl}.'; -$string['emailnotifysmall'] = '{$a->studentname} has completed {$a->quizname}'; -$string['emailnotifysubject'] = '{$a->studentname} has completed quiz {$a->quizname}'; +$string['emailnotifysmall'] = '{$a->studentname} has completed {$a->quizname}. See {$a->quizreviewurl}'; +$string['emailnotifysubject'] = '{$a->studentname} has completed {$a->quizname}'; +$string['emailoverduebody'] = 'Dear {$a->studentname}, + +You started an attempt at \'{$a->quizname}\' +in course \'{$a->coursename}\', but you never submitted it. + +If you would would still like to submit this attempt, please go to +{$a->attemptsummaryurl} and click the submit button. +You must do this before {$a->attemptgraceend} +otherwise you attempt will not be counted.'; +$string['emailoverduesmall'] = 'You did not submit your attempt at {$a->quizname}. Please go to {$a->attemptsummaryurl} before {$a->attemptgraceend} if you would still like to submit.'; +$string['emailoverduesubject'] = 'Attempt now overdue: {$a->quizname}'; $string['empty'] = 'Empty'; $string['enabled'] = 'Enabled'; $string['endtest'] = 'Finish attempt ...'; diff --git a/mod/quiz/locallib.php b/mod/quiz/locallib.php index 82797a68ee8..0e8fd9092c3 100644 --- a/mod/quiz/locallib.php +++ b/mod/quiz/locallib.php @@ -1255,14 +1255,14 @@ function quiz_send_notification_messages($course, $quiz, $attempt, $context, $cm $a->quizreporturl = $CFG->wwwroot . '/mod/quiz/report.php?id=' . $cm->id; $a->quizreportlink = '' . format_string($quiz->name) . ' report'; - $a->quizreviewurl = $CFG->wwwroot . '/mod/quiz/review.php?attempt=' . $attempt->id; - $a->quizreviewlink = '' . - format_string($quiz->name) . ' review'; $a->quizurl = $CFG->wwwroot . '/mod/quiz/view.php?id=' . $cm->id; $a->quizlink = '' . format_string($quiz->name) . ''; // Attempt info $a->submissiontime = userdate($attempt->timefinish); $a->timetaken = format_time($attempt->timefinish - $attempt->timestart); + $a->quizreviewurl = $CFG->wwwroot . '/mod/quiz/review.php?attempt=' . $attempt->id; + $a->quizreviewlink = '' . + format_string($quiz->name) . ' review'; // Student who sat the quiz info $a->studentidnumber = $submitter->idnumber; $a->studentname = fullname($submitter); @@ -1288,6 +1288,80 @@ function quiz_send_notification_messages($course, $quiz, $attempt, $context, $cm return $allok; } +/** + * Send the notification message when a quiz attempt becomes overdue. + * + * @param object $course the course + * @param object $quiz the quiz + * @param object $attempt this attempt just finished + * @param object $context the quiz context + * @param object $cm the coursemodule for this quiz + */ +function quiz_send_overdue_message($course, $quiz, $attempt, $context, $cm) { + global $CFG, $DB; + + // Do nothing if required objects not present + if (empty($course) or empty($quiz) or empty($attempt) or empty($context)) { + throw new coding_exception('$course, $quiz, $attempt, $context and $cm must all be set.'); + } + + $submitter = $DB->get_record('user', array('id' => $attempt->userid), '*', MUST_EXIST); + + if (!has_capability('mod/quiz:emailwarnoverdue', $context, $submitter, false)) { + return; // Message not required. + } + + // Prepare lots of useful information that admins might want to include in + // the email message. + $quizname = format_string($quiz->name); + + $deadlines = array(); + if ($quiz->timelimit) { + $deadlines[] = $attempt->timestart + $quiz->timelimit; + } + if ($quiz->timeclose) { + $deadlines[] = $quiz->timeclose; + } + $duedate = min($deadlines) + $quiz->graceperiod; + + $a = new stdClass(); + // Course info. + $a->coursename = $course->fullname; + $a->courseshortname = $course->shortname; + // Quiz info. + $a->quizname = $quizname; + $a->quizurl = $CFG->wwwroot . '/mod/quiz/view.php?id=' . $cm->id; + $a->quizlink = '' . $quizname . ''; + // Attempt info. + $a->attemptgraceend = format_time($duedate); + $a->attemptsummaryurl = $CFG->wwwroot . '/mod/quiz/summary.php?attempt=' . $attempt->id; + $a->attemptsummarylink = '' . $quizname . ' review'; + // Student's info. + $a->studentidnumber = $submitter->idnumber; + $a->studentname = fullname($submitter); + $a->studentusername = $submitter->username; + + // Prepare the message. + $eventdata = new stdClass(); + $eventdata->component = 'mod_quiz'; + $eventdata->name = 'attempt_overdue'; + $eventdata->notification = 1; + + $eventdata->userfrom = get_admin(); + $eventdata->userto = $submitter; + $eventdata->subject = get_string('emailoverduesubject', 'quiz', $a); + $eventdata->fullmessage = get_string('emailoverduebody', 'quiz', $a); + $eventdata->fullmessageformat = FORMAT_PLAIN; + $eventdata->fullmessagehtml = ''; + + $eventdata->smallmessage = get_string('emailoverduesmall', 'quiz', $a); + $eventdata->contexturl = $a->quizurl; + $eventdata->contexturlname = $a->quizname; + + // Send the message. + return message_send($eventdata); +} + /** * Handle the quiz_attempt_submitted event. * @@ -1313,6 +1387,36 @@ function quiz_attempt_submitted_handler($event) { get_context_instance(CONTEXT_MODULE, $cm->id), $cm); } +/** + * Handle the quiz_attempt_overdue event. + * + * For quizzes with applicable settings, this sends a message to the user, reminding + * them that they forgot to submit, and that they have another chance to do so. + * + * @param object $event the event object. + */ +function quiz_attempt_overdue_handler($event) { + global $DB; + + $course = $DB->get_record('course', array('id' => $event->courseid)); + $quiz = $DB->get_record('quiz', array('id' => $event->quizid)); + $cm = get_coursemodule_from_id('quiz', $event->cmid, $event->courseid); + $attempt = $DB->get_record('quiz_attempts', array('id' => $event->attemptid)); + + if (!($course && $quiz && $cm && $attempt)) { + // Something has been deleted since the event was raised. Therefore, the + // event is no longer relevant. + return true; + } + + return quiz_send_overdue_message($course, $quiz, $attempt, + get_context_instance(CONTEXT_MODULE, $cm->id), $cm); +} + +/** + * Get the information about the standard quiz JavaScript module. + * @return array a standard jsmodule structure. + */ function quiz_get_js_module() { global $PAGE; From 3e77b60f3659b70870772bd0235182a0840df7cd Mon Sep 17 00:00:00 2001 From: Tim Hunt Date: Fri, 20 Apr 2012 16:55:52 +0100 Subject: [PATCH 16/20] MDL-3030 quiz overdue handling: ignore responses received much too late. --- mod/quiz/processattempt.php | 43 +++++++++++++++++++++++-------------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/mod/quiz/processattempt.php b/mod/quiz/processattempt.php index f8ccee9859d..5147ff0ee6e 100644 --- a/mod/quiz/processattempt.php +++ b/mod/quiz/processattempt.php @@ -63,11 +63,16 @@ if ($page == -1) { } } -// If there is only a very small amount of time left, there is not point trying +// If there is only a very small amount of time left, there is no point trying // to show the student another page of the quiz. Just finish now. $accessmanager = $attemptobj->get_access_manager($timenow); -if ($accessmanager->get_time_left($attempt, $timenow) < QUIZ_MIN_TIME_TO_CONTINUE) { +$timeleft = $accessmanager->get_time_left($attemptobj->get_attempt(), $timenow); +$toolate = false; +if ($timeleft !== false && $timeleft < QUIZ_MIN_TIME_TO_CONTINUE) { $timeup = true; + if ($timeleft < -get_config('quiz', 'graceperiodmin')) { + $toolate = true; + } } // Check login. @@ -104,22 +109,28 @@ if ($timeup) { if (!$finishattempt) { // Just process the responses for this page and go to the next page. - try { - $attemptobj->process_submitted_actions($timenow, $becomingoverdue); + if (!$toolate) { + try { + $attemptobj->process_submitted_actions($timenow, $becomingoverdue); - } catch (question_out_of_sequence_exception $e) { - print_error('submissionoutofsequencefriendlymessage', 'question', - $attemptobj->attempt_url(null, $thispage)); + } catch (question_out_of_sequence_exception $e) { + print_error('submissionoutofsequencefriendlymessage', 'question', + $attemptobj->attempt_url(null, $thispage)); - } catch (Exception $e) { - // This sucks, if we display our own custom error message, there is no way - // to display the original stack trace. - $debuginfo = ''; - if (!empty($e->debuginfo)) { - $debuginfo = $e->debuginfo; + } catch (Exception $e) { + // This sucks, if we display our own custom error message, there is no way + // to display the original stack trace. + $debuginfo = ''; + if (!empty($e->debuginfo)) { + $debuginfo = $e->debuginfo; + } + print_error('errorprocessingresponses', 'question', + $attemptobj->attempt_url(null, $thispage), $e->getMessage(), $debuginfo); } - print_error('errorprocessingresponses', 'question', - $attemptobj->attempt_url(null, $thispage), $e->getMessage(), $debuginfo); + + } else { + // The student is too late. + $attemptobj->process_going_overdue($timenow, true); } $transaction->allow_commit(); @@ -139,7 +150,7 @@ add_to_log($attemptobj->get_courseid(), 'quiz', 'close attempt', // Update the quiz attempt record. try { - $attemptobj->process_finish($timenow, true); + $attemptobj->process_finish($timenow, !$toolate); } catch (question_out_of_sequence_exception $e) { print_error('submissionoutofsequencefriendlymessage', 'question', From be18f589e2dd0627b3f377bab663e84d20275951 Mon Sep 17 00:00:00 2001 From: Tim Hunt Date: Tue, 24 Apr 2012 15:01:12 +0100 Subject: [PATCH 17/20] MDL-3030 quiz overdue handling: test state, not timefinish where applicable. --- mod/quiz/accessmanager.php | 4 +- mod/quiz/attemptlib.php | 16 ++---- mod/quiz/lib.php | 9 ++-- mod/quiz/locallib.php | 51 ++++++++++++++----- mod/quiz/report/attemptsreport_table.php | 7 +-- mod/quiz/report/grading/report.php | 7 +-- mod/quiz/report/overview/overview_table.php | 2 +- mod/quiz/report/overview/report.php | 2 +- mod/quiz/report/responses/responses_table.php | 2 +- mod/quiz/review.php | 2 +- 10 files changed, 60 insertions(+), 42 deletions(-) diff --git a/mod/quiz/accessmanager.php b/mod/quiz/accessmanager.php index 7d4cd70bcc0..9c0f14835a3 100644 --- a/mod/quiz/accessmanager.php +++ b/mod/quiz/accessmanager.php @@ -462,8 +462,8 @@ class quiz_access_manager { */ public function make_review_link($attempt, $reviewoptions, $output) { - // If review of responses is not allowed, or the attempt is still open, don't link. - if (!$attempt->timefinish) { + // If the attempt is still open, don't link. + if (in_array($attempt->state, array(quiz_attempt::IN_PROGRESS, quiz_attempt::OVERDUE))) { return $output->no_review_message(''); } diff --git a/mod/quiz/attemptlib.php b/mod/quiz/attemptlib.php index 9710bf991b5..fb5bf7ca095 100644 --- a/mod/quiz/attemptlib.php +++ b/mod/quiz/attemptlib.php @@ -513,16 +513,7 @@ class quiz_attempt { * @return string the human-readable state name. */ public static function state_name($state) { - switch ($state) { - case quiz_attempt::IN_PROGRESS: - return get_string('stateinprogress', 'quiz'); - case quiz_attempt::OVERDUE: - return get_string('stateoverdue', 'quiz'); - case quiz_attempt::FINISHED: - return get_string('statefinished', 'quiz'); - case quiz_attempt::ABANDONED: - return get_string('stateabandoned', 'quiz'); - } + return quiz_attempt_state_name($state); } private function determine_layout() { @@ -1321,7 +1312,7 @@ class quiz_attempt { $DB->update_record('quiz_attempts', $this->attempt); } - if (!$this->is_preview() && $this->attempt->timefinish) { + if (!$this->is_preview() && $this->attempt->state == quiz_attempt::FINISHED) { quiz_save_best_grade($this->get_quiz(), $this->get_userid()); } @@ -1435,7 +1426,8 @@ class quiz_attempt { } if ($event == 'quiz_attempt_submitted') { - $eventdata->timefinish = $timestamp; // Backwards compatibility. + // Backwards compatibility for this event type. $eventdata->timestamp is now preferred. + $eventdata->timefinish = $timestamp; } events_trigger($event, $eventdata); diff --git a/mod/quiz/lib.php b/mod/quiz/lib.php index fcf2a62f9c7..e6895ac035d 100644 --- a/mod/quiz/lib.php +++ b/mod/quiz/lib.php @@ -402,7 +402,6 @@ function quiz_user_outline($course, $user, $mod, $quiz) { * Print a detailed representation of what a user has done with * a given particular instance of this module, for user activity reports. * - * @global object * @param object $course * @param object $user * @param object $mod @@ -411,7 +410,8 @@ function quiz_user_outline($course, $user, $mod, $quiz) { */ function quiz_user_complete($course, $user, $mod, $quiz) { global $DB, $CFG, $OUTPUT; - require_once("$CFG->libdir/gradelib.php"); + require_once($CFG->libdir . '/gradelib.php'); + require_once($CFG->libdir . '/mod/quiz/locallib.php'); $grades = grade_get_grades($course->id, 'mod', 'quiz', $quiz->id, $user->id); if (!empty($grades->items[0]->grades)) { @@ -426,8 +426,8 @@ function quiz_user_complete($course, $user, $mod, $quiz) { array('userid' => $user->id, 'quiz' => $quiz->id), 'attempt')) { foreach ($attempts as $attempt) { echo get_string('attempt', 'quiz').' '.$attempt->attempt.': '; - if ($attempt->timefinish == 0) { - print_string('unfinished'); + if ($attempt->state != quiz_attempt::FINISHED) { + echo quiz_attempt_state_name($attempt->state); } else { echo quiz_format_grade($quiz, $attempt->sumgrades) . '/' . quiz_format_grade($quiz, $quiz->sumgrades); @@ -445,6 +445,7 @@ function quiz_user_complete($course, $user, $mod, $quiz) { * Quiz periodic clean-up tasks. */ function quiz_cron() { + global $CFG; // Run cron for our sub-plugin types. cron_execute_plugin_type('quiz', 'quiz reports'); diff --git a/mod/quiz/locallib.php b/mod/quiz/locallib.php index 0e8fd9092c3..1985c5cd3bc 100644 --- a/mod/quiz/locallib.php +++ b/mod/quiz/locallib.php @@ -105,6 +105,7 @@ function quiz_create_attempt($quiz, $attemptnumber, $lastattempt, $timenow, $isp $attempt->timestart = $timenow; $attempt->timefinish = 0; $attempt->timemodified = $timenow; + $attempt->state = quiz_attempt::IN_PROGRESS; // If this is a preview, mark it as such. if ($ispreview) { @@ -445,8 +446,9 @@ function quiz_update_all_attempt_sumgrades($quiz) { sumgrades = ( {$dm->sum_usage_marks_subquery('uniqueid')} ) - WHERE quiz = :quizid AND timefinish <> 0"; - $DB->execute($sql, array('timenow' => $timenow, 'quizid' => $quiz->id)); + WHERE quiz = :quizid AND state = :finishedstate"; + $DB->execute($sql, array('timenow' => $timenow, 'quizid' => $quiz->id, + 'finishedstate' => quiz_attempt::FINISHED)); } /** @@ -613,7 +615,7 @@ function quiz_update_all_final_grades($quiz) { return; } - $param = array('iquizid' => $quiz->id); + $param = array('iquizid' => $quiz->id, 'istatefinished' => quiz_attempt::FINISHED); $firstlastattemptjoin = "JOIN ( SELECT iquiza.userid, @@ -623,7 +625,7 @@ function quiz_update_all_final_grades($quiz) { FROM {quiz_attempts} iquiza WHERE - iquiza.timefinish <> 0 AND + iquiza.state = :istatefinished iquiza.preview = 0 AND iquiza.quiz = :iquizid @@ -670,13 +672,15 @@ function quiz_update_all_final_grades($quiz) { $param['quizid2'] = $quiz->id; $param['quizid3'] = $quiz->id; $param['quizid4'] = $quiz->id; + $param['statefinished'] = quiz_attempt::FINISHED; + $param['statefinished2'] = quiz_attempt::FINISHED; $finalgradesubquery = " SELECT quiza.userid, $finalgrade AS newgrade FROM {quiz_attempts} quiza $join WHERE $where - quiza.timefinish <> 0 AND + quiza.quiza.state = :statefinished AND quiza.preview = 0 AND quiza.quiz = :quizid3 GROUP BY quiza.userid"; @@ -692,7 +696,7 @@ function quiz_update_all_final_grades($quiz) { SELECT DISTINCT userid FROM {quiz_attempts} quiza2 WHERE - quiza2.timefinish <> 0 AND + quiza2.quiza.state = :statefinished2 AND quiza2.preview = 0 AND quiza2.quiz = :quizid2 ) users @@ -818,6 +822,25 @@ function quiz_get_overdue_handling_options() { ); } +/** + * @param string $state one of the state constants like IN_PROGRESS. + * @return string the human-readable state name. + */ +function quiz_attempt_state_name($state) { + switch ($state) { + case quiz_attempt::IN_PROGRESS: + return get_string('stateinprogress', 'quiz'); + case quiz_attempt::OVERDUE: + return get_string('stateoverdue', 'quiz'); + case quiz_attempt::FINISHED: + return get_string('statefinished', 'quiz'); + case quiz_attempt::ABANDONED: + return get_string('stateabandoned', 'quiz'); + default: + throw new coding_exception('Unknown quiz attempt state.'); + } +} + /// Other quiz functions //////////////////////////////////////////////////// /** @@ -950,14 +973,15 @@ function quiz_get_flag_option($attempt, $context) { } /** - * Work out what state this quiz attempt is in. + * Work out what state this quiz attempt is in - in the sense used by + * quiz_get_review_options, not in the sense of $attempt->state. * @param object $quiz the quiz settings * @param object $attempt the quiz_attempt database row. * @return int one of the mod_quiz_display_options::DURING, * IMMEDIATELY_AFTER, LATER_WHILE_OPEN or AFTER_CLOSE constants. */ function quiz_attempt_state($quiz, $attempt) { - if ($attempt->timefinish == 0) { + if ($attempt->state != quiz_attempt::FINISHED) { return mod_quiz_display_options::DURING; } else if (time() < $attempt->timefinish + 120) { return mod_quiz_display_options::IMMEDIATELY_AFTER; @@ -989,7 +1013,7 @@ function quiz_get_review_options($quiz, $attempt, $context) { } // Show a link to the comment box only for closed attempts - if (!empty($attempt->id) && $attempt->timefinish && !$attempt->preview && + if (!empty($attempt->id) && $attempt->state == quiz_attempt::FINISHED && !$attempt->preview && !is_null($context) && has_capability('mod/quiz:grade', $context)) { $options->manualcomment = question_display_options::VISIBLE; $options->manualcommentlink = new moodle_url('/mod/quiz/comment.php', @@ -1517,14 +1541,17 @@ class mod_quiz_display_options extends question_display_options { class qubaids_for_quiz extends qubaid_join { public function __construct($quizid, $includepreviews = true, $onlyfinished = false) { $where = 'quiza.quiz = :quizaquiz'; + $params = array('quizaquiz' => $quizid); + if (!$includepreviews) { $where .= ' AND preview = 0'; } + if ($onlyfinished) { - $where .= ' AND timefinish <> 0'; + $where .= ' AND state == :statefinished'; + $params['statefinished'] = quiz_attempt::FINISHED; } - parent::__construct('{quiz_attempts} quiza', 'quiza.uniqueid', $where, - array('quizaquiz' => $quizid)); + parent::__construct('{quiz_attempts} quiza', 'quiza.uniqueid', $where, $params); } } diff --git a/mod/quiz/report/attemptsreport_table.php b/mod/quiz/report/attemptsreport_table.php index 096bae6d5cc..03f8959e4df 100644 --- a/mod/quiz/report/attemptsreport_table.php +++ b/mod/quiz/report/attemptsreport_table.php @@ -202,7 +202,7 @@ abstract class quiz_attempts_report_table extends table_sql { * @return string HTML content to go inside the td. */ public function col_feedbacktext($attempt) { - if (!$attempt->timefinish) { + if ($attempt->state != quiz_attempt::FINISHED) { return '-'; } @@ -371,10 +371,7 @@ abstract class quiz_attempts_report_table extends table_sql { CASE WHEN quiza.timefinish = 0 THEN null WHEN quiza.timefinish > quiza.timestart THEN quiza.timefinish - quiza.timestart ELSE 0 END AS duration'; - // To explain that last bit, in MySQL, qa.timestart and qa.timefinish - // are unsigned. Since MySQL 5.5.5, when they introduced strict mode, - // subtracting a larger unsigned int from a smaller one gave an error. - // Therefore, we avoid doing that. timefinish can be non-zero and less + // To explain that last bit, timefinish can be non-zero and less // than timestart when you have two load-balanced servers with very // badly synchronised clocks, and a student does a really quick attempt. diff --git a/mod/quiz/report/grading/report.php b/mod/quiz/report/grading/report.php index 3cd33369db2..1e7e7c0d769 100644 --- a/mod/quiz/report/grading/report.php +++ b/mod/quiz/report/grading/report.php @@ -162,8 +162,8 @@ class quiz_grading_report extends quiz_default_report { $where = "quiza.quiz = :mangrquizid AND quiza.preview = 0 AND - quiza.timefinish <> 0"; - $params = array('mangrquizid' => $this->cm->instance); + quiza.state = :statefinished"; + $params = array('mangrquizid' => $this->cm->instance, 'statefinished' => quiz_attempt::FINISHED); $currentgroup = groups_get_activity_group($this->cm, true); if ($currentgroup) { @@ -187,13 +187,14 @@ class quiz_grading_report extends quiz_default_report { global $DB; list($asql, $params) = $DB->get_in_or_equal($qubaids); + $params[] = quiz_attempt::FINISHED; $params[] = $this->quiz->id; $attemptsbyid = $DB->get_records_sql(" SELECT quiza.*, u.firstname, u.lastname, u.idnumber FROM {quiz_attempts} quiza JOIN {user} u ON u.id = quiza.userid - WHERE quiza.uniqueid $asql AND quiza.timefinish <> 0 AND quiza.quiz = ?", + WHERE quiza.uniqueid $asql AND quiza.state == ? AND quiza.quiz = ?", $params); $attempts = array(); diff --git a/mod/quiz/report/overview/overview_table.php b/mod/quiz/report/overview/overview_table.php index 8c815f43e2c..5f8f0e3eef3 100644 --- a/mod/quiz/report/overview/overview_table.php +++ b/mod/quiz/report/overview/overview_table.php @@ -180,7 +180,7 @@ class quiz_overview_table extends quiz_attempts_report_table { } public function col_sumgrades($attempt) { - if (!$attempt->timefinish) { + if ($attempt->state != quiz_attempt::FINISHED) { return '-'; } diff --git a/mod/quiz/report/overview/report.php b/mod/quiz/report/overview/report.php index 811ec200de5..0fb535ef0ed 100644 --- a/mod/quiz/report/overview/report.php +++ b/mod/quiz/report/overview/report.php @@ -321,7 +321,7 @@ class quiz_overview_report extends quiz_attempts_report { $slots = $quba->get_slots(); } - $finished = $attempt->timefinish > 0; + $finished = $attempt->state == quiz_attempt::FINISHED; foreach ($slots as $slot) { $qqr = new stdClass(); $qqr->oldfraction = $quba->get_question_fraction($slot); diff --git a/mod/quiz/report/responses/responses_table.php b/mod/quiz/report/responses/responses_table.php index efe33ee500b..ed6ada43198 100644 --- a/mod/quiz/report/responses/responses_table.php +++ b/mod/quiz/report/responses/responses_table.php @@ -63,7 +63,7 @@ class quiz_responses_table extends quiz_attempts_report_table { } public function col_sumgrades($attempt) { - if (!$attempt->timefinish) { + if ($attempt->state == quiz_attempt::FINISHED) { return '-'; } diff --git a/mod/quiz/review.php b/mod/quiz/review.php index 9882ef6c765..834d480d5ec 100644 --- a/mod/quiz/review.php +++ b/mod/quiz/review.php @@ -184,7 +184,7 @@ if (!empty($overtime)) { $grade = quiz_rescale_grade($attempt->sumgrades, $quiz, false); if ($options->marks >= question_display_options::MARK_AND_MAX && quiz_has_grades($quiz)) { - if (!$attempt->timefinish) { + if ($attempt->state != quiz_attempt::FINISHED) { $summarydata['grade'] = array( 'title' => get_string('grade', 'quiz'), 'content' => get_string('attemptstillinprogress', 'quiz'), From e1bf1619e16ceabfdd5a621b0c7f8ffa43c40e81 Mon Sep 17 00:00:00 2001 From: Tim Hunt Date: Wed, 25 Apr 2012 12:25:06 +0100 Subject: [PATCH 18/20] MDL-3030 quiz overdue handling: cron to update overdue attempts. --- mod/quiz/cronlib.php | 149 +++++++++++++++++++++++++++++++++++++++++++ mod/quiz/lib.php | 20 ++++++ 2 files changed, 169 insertions(+) create mode 100644 mod/quiz/cronlib.php diff --git a/mod/quiz/cronlib.php b/mod/quiz/cronlib.php new file mode 100644 index 00000000000..26bc18a3f3f --- /dev/null +++ b/mod/quiz/cronlib.php @@ -0,0 +1,149 @@ +. + +/** + * Library code used by quiz cron. + * + * @package mod_quiz + * @copyright 2012 the Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + + +defined('MOODLE_INTERNAL') || die(); + +require_once($CFG->dirroot . '/mod/quiz/locallib.php'); + + +/** + * This class holds all the code for automatically updating all attempts that have + * gone over their time limit. + * + * @copyright 2012 the Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class mod_quiz_overdue_attempt_updater { + + /** + * Do the processing required. + * @param int $timenow the time to consider as 'now' during the processing. + * @param int $processfrom the value of $processupto the last time update_overdue_attempts was + * called called and completed successfully. + * @param int $processto only process attempt modifed longer ago than this. + */ + public function update_overdue_attempts($timenow, $processfrom, $processto) { + global $DB; + + $attemptstoprocess = $this->get_list_of_overdue_attempts($processfrom, $processto); + + $course = null; + $quiz = null; + $cm = null; + + foreach ($attemptstoprocess as $attempt) { + // If we have moved on to a different quiz, fetch the new data. + if (!$quiz || $attempt->quiz != $quiz->id) { + $quiz = $DB->get_record('quiz', array('id' => $attempt->quiz), '*', MUST_EXIST); + $cm = get_coursemodule_from_instance('quiz', $attempt->quiz); + } + + // If we have moved on to a different course, fetch the new data. + if (!$course || $course->id != $quiz->course) { + $course = $DB->get_record('course', array('id' => $quiz->course), '*', MUST_EXIST); + } + + // Make a specialised version of the quiz settings, with the relevant overrides. + $quizforuser = clone($quiz); + $quizforuser->timeclose = $attempt->usertimeclose; + $quizforuser->timelimit = $attempt->usertimelimit; + + // Trigger any transitions that are required. + $attemptobj = new quiz_attempt($attempt, $quizforuser, $cm, $course); + $attemptobj->handle_if_time_expired($timenow, false); + } + + $attemptstoprocess->close(); + } + + /** + * @return moodle_recordset of quiz_attempts that need to be processed because time has + * passed. The array is sorted by courseid then quizid. + */ + protected function get_list_of_overdue_attempts($processfrom, $processto) { + global $DB; + + // This query should have all the quiz_attempts columns. + return $DB->get_recordset_sql(" + SELECT quiza.id, + quiza.quiz, + quiza.userid, + quiza.attempt, + quiza.uniqueid, + quiza.layout, + quiza.currentpage, + quiza.preview, + quiza.state, + quiza.timestart, + quiza.timefinish, + quiza.timemodified, + quiza.sumgrades, + COALESCE(quo.timeclose, MAX(qgo.timeclose), quiz.timeclose) AS usertimeclose, + COALESCE(quo.timelimit, MAX(qgo.timelimit), quiz.timelimit) AS usertimelimit + + FROM {quiz_attempts} quiza + JOIN {quiz} quiz ON quiz.id = quiza.quiz + LEFT JOIN {quiz_overrides} quo ON quo.quiz = quiz.id AND quo.userid = quiza.userid + LEFT JOIN {quiz_overrides} qgo ON qgo.quiz = quiz.id + LEFT JOIN {groups_members} gm ON gm.userid = quiza.userid AND gm.groupid = qgo.groupid + + WHERE quiza.state IN ('inprogress', 'overdue') + AND quiza.timemodified >= :processfrom + AND quiza.timemodified < :processto + + GROUP BY quiza.id, + quiza.quiz, + quiza.userid, + quiza.attempt, + quiza.uniqueid, + quiza.layout, + quiza.currentpage, + quiza.preview, + quiza.state, + quiza.timestart, + quiza.timefinish, + quiza.timemodified, + quiza.sumgrades, + quiz.course, + quiz.timeclose, + quiz.timelimit, + quiz.graceperiod, + quo.timeclose, + quo.timelimit + + HAVING (quiza.state = 'inprogress' AND ( + :timenow1 > COALESCE(quo.timeclose, MAX(qgo.timeclose), quiz.timeclose) + OR :timenow2 > timestart + COALESCE(quo.timelimit, MAX(qgo.timelimit), quiz.timelimit))) + OR (quiza.state = 'overdue' AND ( + :timenow3 > graceperiod + COALESCE(quo.timeclose, MAX(qgo.timeclose), quiz.timeclose) + OR :timenow4 > timestart + graceperiod + COALESCE(quo.timelimit, MAX(qgo.timelimit), quiz.timelimit))) + + ORDER BY quiz.course, quiza.quiz", + + array('processfrom' => $processfrom, 'processto' => $processto, + 'timenow1' => $processto, 'timenow2' => $processto, + 'timenow3' => $processto, 'timenow4' => $processto)); + } +} diff --git a/mod/quiz/lib.php b/mod/quiz/lib.php index e6895ac035d..34a07413073 100644 --- a/mod/quiz/lib.php +++ b/mod/quiz/lib.php @@ -447,6 +447,26 @@ function quiz_user_complete($course, $user, $mod, $quiz) { function quiz_cron() { global $CFG; + // Since the quiz specifies $module->cron = 60, so that the subplugins can + // have frequent cron if they need it, we now need to do our own scheduling. + $quizconfig = get_config('quiz'); + if (!isset($quizconfig->overduelastrun)) { + $quizconfig->overduelastrun = 0; + $quizconfig->overduedoneto = 0; + } + + $timenow = time(); + if ($timenow > $quizconfig->overduelastrun + 3600) { + require_once($CFG->dirroot . '/mod/quiz/cronlib.php'); + $overduehander = new mod_quiz_overdue_attempt_updater(); + + $processto = $timenow - $quizconfig->graceperiodmin; + $overduehander->update_overdue_attempts($timenow, $quizconfig->overduedoneto, $processto); + + set_config('overduelastrun', $timenow, 'quiz'); + set_config('overduedoneto', $processto, 'quiz'); + } + // Run cron for our sub-plugin types. cron_execute_plugin_type('quiz', 'quiz reports'); cron_execute_plugin_type('quizaccess', 'quiz access rules'); From 2512341872db7da8ce89dd19676e68678e1e8caf Mon Sep 17 00:00:00 2001 From: Tim Hunt Date: Thu, 26 Apr 2012 10:12:16 +0100 Subject: [PATCH 19/20] MDL-3030 quiz statistics: fix stict syntax problem. --- mod/quiz/report/statistics/responseanalysis.php | 1 + 1 file changed, 1 insertion(+) diff --git a/mod/quiz/report/statistics/responseanalysis.php b/mod/quiz/report/statistics/responseanalysis.php index 9239124c688..332555a98ee 100644 --- a/mod/quiz/report/statistics/responseanalysis.php +++ b/mod/quiz/report/statistics/responseanalysis.php @@ -182,6 +182,7 @@ class quiz_statistics_response_analyser { } foreach ($rows as $row) { + $this->responses[$row->subqid][$row->aid][$row->response] = new stdClass(); $this->responses[$row->subqid][$row->aid][$row->response]->count = $row->rcount; $this->responses[$row->subqid][$row->aid][$row->response]->fraction = $row->credit; } From b640e6815a05e8c6e7b3d6e7aa25a19a940b6dcf Mon Sep 17 00:00:00 2001 From: Tim Hunt Date: Thu, 26 Apr 2012 19:02:43 +0100 Subject: [PATCH 20/20] MDL-3030 quiz overdue handling: allow access to summary during grace period --- mod/quiz/accessrule/openclosedate/rule.php | 26 +++++++++++++--- .../openclosedate/tests/rule_test.php | 31 +++++++++++++++++++ mod/quiz/locallib.php | 1 + 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/mod/quiz/accessrule/openclosedate/rule.php b/mod/quiz/accessrule/openclosedate/rule.php index 19519018bf8..20b7f00e94a 100644 --- a/mod/quiz/accessrule/openclosedate/rule.php +++ b/mod/quiz/accessrule/openclosedate/rule.php @@ -64,11 +64,29 @@ class quizaccess_openclosedate extends quiz_access_rule_base { } public function prevent_access() { - if ($this->timenow < $this->quiz->timeopen || - ($this->quiz->timeclose && $this->timenow > $this->quiz->timeclose)) { - return get_string('notavailable', 'quizaccess_openclosedate'); + $message = get_string('notavailable', 'quizaccess_openclosedate'); + + if ($this->timenow < $this->quiz->timeopen) { + return $message; } - return false; + + if (!$this->quiz->timeclose) { + return false; + } + + if ($this->timenow <= $this->quiz->timeclose) { + return false; + } + + if ($this->quiz->overduehandling != 'graceperiod') { + return $message; + } + + if ($this->timenow <= $this->quiz->timeclose + $this->quiz->graceperiod) { + return false; + } + + return $message; } public function is_finished($numprevattempts, $lastattempt) { diff --git a/mod/quiz/accessrule/openclosedate/tests/rule_test.php b/mod/quiz/accessrule/openclosedate/tests/rule_test.php index b4488cabbc4..ea299cb7e39 100644 --- a/mod/quiz/accessrule/openclosedate/tests/rule_test.php +++ b/mod/quiz/accessrule/openclosedate/tests/rule_test.php @@ -42,6 +42,7 @@ class quizaccess_openclosedate_testcase extends basic_testcase { $quiz = new stdClass(); $quiz->timeopen = 0; $quiz->timeclose = 0; + $quiz->overduehandling = 'autosubmit'; $quiz->questions = ''; $cm = new stdClass(); $cm->id = 0; @@ -69,6 +70,7 @@ class quizaccess_openclosedate_testcase extends basic_testcase { $quiz = new stdClass(); $quiz->timeopen = 10000; $quiz->timeclose = 0; + $quiz->overduehandling = 'autosubmit'; $quiz->questions = ''; $cm = new stdClass(); $cm->id = 0; @@ -98,6 +100,7 @@ class quizaccess_openclosedate_testcase extends basic_testcase { $quiz = new stdClass(); $quiz->timeopen = 0; $quiz->timeclose = 20000; + $quiz->overduehandling = 'autosubmit'; $quiz->questions = ''; $cm = new stdClass(); $cm->id = 0; @@ -133,6 +136,7 @@ class quizaccess_openclosedate_testcase extends basic_testcase { $quiz = new stdClass(); $quiz->timeopen = 10000; $quiz->timeclose = 20000; + $quiz->overduehandling = 'autosubmit'; $quiz->questions = ''; $cm = new stdClass(); $cm->id = 0; @@ -177,4 +181,31 @@ class quizaccess_openclosedate_testcase extends basic_testcase { $this->assertEquals($rule->time_left($attempt, 20000), 0); $this->assertEquals($rule->time_left($attempt, 20100), -100); } + + public function test_close_date_with_overdue() { + $quiz = new stdClass(); + $quiz->timeopen = 0; + $quiz->timeclose = 20000; + $quiz->overduehandling = 'graceperiod'; + $quiz->graceperiod = 1000; + $quiz->questions = ''; + $cm = new stdClass(); + $cm->id = 0; + $quizobj = new quiz($quiz, $cm, null); + $attempt = new stdClass(); + $attempt->preview = 0; + + $rule = new quizaccess_openclosedate($quizobj, 20000); + $this->assertFalse($rule->prevent_access()); + + $rule = new quizaccess_openclosedate($quizobj, 20001); + $this->assertFalse($rule->prevent_access()); + + $rule = new quizaccess_openclosedate($quizobj, 21000); + $this->assertFalse($rule->prevent_access()); + + $rule = new quizaccess_openclosedate($quizobj, 21001); + $this->assertEquals($rule->prevent_access(), + get_string('notavailable', 'quizaccess_openclosedate')); + } } diff --git a/mod/quiz/locallib.php b/mod/quiz/locallib.php index 1985c5cd3bc..92d62c8a417 100644 --- a/mod/quiz/locallib.php +++ b/mod/quiz/locallib.php @@ -545,6 +545,7 @@ function quiz_save_best_grade($quiz, $userid = null, $attempts = array()) { $DB->update_record('quiz_grades', $grade); } else { + $grade = new stdClass(); $grade->quiz = $quiz->id; $grade->userid = $userid; $grade->grade = $bestgrade;