From 1700bd4dea94e151d07eb8dc47f9ad73dcd11413 Mon Sep 17 00:00:00 2001 From: M Kassaei Date: Wed, 3 Dec 2014 11:24:46 +0000 Subject: [PATCH] MDL-40992 quiz: let students to redo questions within an attempt --- lang/en/question.php | 2 + mod/quiz/attempt.php | 6 +++ mod/quiz/attemptlib.php | 38 +++++++++++++ mod/quiz/db/install.xml | 1 + mod/quiz/db/upgrade.php | 14 +++++ mod/quiz/lang/en/quiz.php | 3 ++ mod/quiz/mod_form.php | 13 +++++ mod/quiz/processattempt.php | 6 +++ mod/quiz/renderer.php | 1 + mod/quiz/settings.php | 5 ++ mod/quiz/styles.css | 4 ++ .../tests/behat/reattemptquestions.feature | 53 +++++++++++++++++++ mod/quiz/version.php | 2 +- question/behaviour/behaviourtypebase.php | 8 +++ .../behaviour/immediatecbm/behaviourtype.php | 7 +++ .../immediatefeedback/behaviourtype.php | 7 +++ .../behaviour/interactive/behaviourtype.php | 9 ++++ question/engine/datalib.php | 46 ++++++++++++++-- question/engine/questionusage.php | 24 +++++++++ 19 files changed, 245 insertions(+), 4 deletions(-) create mode 100644 mod/quiz/tests/behat/reattemptquestions.feature diff --git a/lang/en/question.php b/lang/en/question.php index 0e7927c1444..1edd07787aa 100644 --- a/lang/en/question.php +++ b/lang/en/question.php @@ -397,6 +397,7 @@ $string['questiontext'] = 'Question text'; $string['requiresgrading'] = 'Requires grading'; $string['responsehistory'] = 'Response history'; $string['restart'] = 'Start again'; +$string['restartquestion'] = 'Restart question'; $string['restartwiththeseoptions'] = 'Start again with these options'; $string['updatedisplayoptions'] = 'Update display options'; $string['rightanswer'] = 'Right answer'; @@ -436,6 +437,7 @@ $string['unknownquestion'] = 'Unknown question: {$a}.'; $string['unknownquestioncatregory'] = 'Unknown question category: {$a}.'; $string['unknownquestiontype'] = 'Unknown question type: {$a}.'; $string['unusedcategorydeleted'] = 'This category has been deleted because, after deleting the course, its questions weren\'t used any more.'; +$string['updatedisplayoptions'] = 'Update display options'; $string['whethercorrect'] = 'Whether correct'; $string['whethercorrect_help'] = 'This covers both the textual description \'Correct\', \'Partially correct\' or \'Incorrect\', and any coloured highlighting that conveys the same information.'; $string['whichtries'] = 'Which tries'; diff --git a/mod/quiz/attempt.php b/mod/quiz/attempt.php index 181c9bc80c4..5ac9659b239 100644 --- a/mod/quiz/attempt.php +++ b/mod/quiz/attempt.php @@ -125,6 +125,12 @@ if ($attemptobj->get_currentpage() != $page) { $DB->set_field('quiz_attempts', 'currentpage', $page, array('id' => $attemptid)); } +// Process replace question action, when user press on 'Replace question' link. +$replacequestioninslot = optional_param('replacequestioninslot', 0, PARAM_INT); +if ($replacequestioninslot) { + $attemptobj->process_replace_question_actions($replacequestioninslot, time()); +} + // Initialise the JavaScript. $headtags = $attemptobj->get_html_head_contributions($page); $PAGE->requires->js_init_call('M.mod_quiz.init_attempt_form', null, false, quiz_get_js_module()); diff --git a/mod/quiz/attemptlib.php b/mod/quiz/attemptlib.php index 91ac3d2ac47..75a603307dc 100644 --- a/mod/quiz/attemptlib.php +++ b/mod/quiz/attemptlib.php @@ -1527,6 +1527,44 @@ class quiz_attempt { $transaction->allow_commit(); } + /** + * Process replace question action + * @param int $slot + * @param int $timestamp + */ + public function process_replace_question_actions($slot, $timestamp) { + global $DB; + + $transaction = $DB->start_delegated_transaction(); + + $this->quba->replace_question($slot); + question_engine::save_questions_usage_by_activity($this->quba); + + $transaction->allow_commit(); + } + + /** + * Return a button which allows students reattempting the current question + * + * @param int $slot, the number of the current slot + */ + public function restart_question_button($slot) { + // If 'reattemptgradedquestions' field is not set, do not display the 'Restart question' button. + if (!$this->get_quiz()->reattemptgradedquestions) { + return; + } + $qa = $this->get_question_attempt($slot); + + // If question is not graded, do not display the 'Restart question' button. + if (!$qa->get_state()->is_graded()) { + return; + } + $buttonvalue = get_string('restartquestion', 'question'); + return html_writer::tag('div', + " + "); + } + /** * Process all the autosaved data that was part of the current request. * diff --git a/mod/quiz/db/install.xml b/mod/quiz/db/install.xml index cf2207fed9e..0cfa40de4a2 100644 --- a/mod/quiz/db/install.xml +++ b/mod/quiz/db/install.xml @@ -17,6 +17,7 @@ + diff --git a/mod/quiz/db/upgrade.php b/mod/quiz/db/upgrade.php index acbdbf01140..b40fe080133 100644 --- a/mod/quiz/db/upgrade.php +++ b/mod/quiz/db/upgrade.php @@ -821,5 +821,19 @@ function xmldb_quiz_upgrade($oldversion) { upgrade_mod_savepoint(true, 2015030500, 'quiz'); } + if ($oldversion < 2015030900) { + // Define field reattemptgradedquestions to be added to quiz. + $table = new xmldb_table('quiz'); + $field = new xmldb_field('reattemptgradedquestions', XMLDB_TYPE_INTEGER, '4', null, null, null, 0, 'completionpass'); + + // Conditionally launch add field completionpass. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // Quiz savepoint reached. + upgrade_mod_savepoint(true, 2015030900, 'quiz'); + } + return true; } diff --git a/mod/quiz/lang/en/quiz.php b/mod/quiz/lang/en/quiz.php index 17699845718..c45c1f1f3ca 100644 --- a/mod/quiz/lang/en/quiz.php +++ b/mod/quiz/lang/en/quiz.php @@ -180,6 +180,7 @@ $string['configpenaltyscheme'] = 'Penalty subtracted for each wrong response in $string['configpopup'] = 'Force the attempt to open in a popup window, and use JavaScript tricks to try to restrict copy and paste, etc. during quiz attempts.'; $string['configrequirepassword'] = 'Students must enter this password before they can attempt the quiz.'; $string['configrequiresubnet'] = 'Students can only attempt the quiz from these computers.'; +$string['configrestartgradedquestions'] = 'If enabled, it allows students to restart graded questions in \'Immediate feedback\', \'Immediate feedback with CBM\' and \'Interactive with multiple tries behaviours\''; $string['configreviewoptions'] = 'These options control what information users can see when they review a quiz attempt or look at the quiz reports.'; $string['configshowblocks'] = 'Show blocks during quiz attempts.'; $string['configshowuserpicture'] = 'Show the user\'s picture on screen during attempts.'; @@ -754,6 +755,8 @@ $string['reviewduring'] = 'During the attempt'; $string['reviewimmediately'] = 'Immediately after the attempt'; $string['marks'] = 'Marks'; $string['marks_help'] = 'The numerical marks for each question, and the overall attempt score.'; +$string['restartgradedquestions'] = 'Restart graded questions'; +$string['restartgradedquestions_help'] = 'If enabled, it allows students to restart graded questions in \'Immediate feedback\', \'Immediate feedback with CBM\' and \'Interactive with multiple tries behaviours\''; $string['reviewnever'] = 'Never allow review'; $string['reviewofattempt'] = 'Review of attempt {$a}'; $string['reviewofpreview'] = 'Review of preview'; diff --git a/mod/quiz/mod_form.php b/mod/quiz/mod_form.php index 694ab98ff93..e1fcb1edb9f 100644 --- a/mod/quiz/mod_form.php +++ b/mod/quiz/mod_form.php @@ -205,6 +205,19 @@ class mod_quiz_mod_form extends moodleform_mod { $mform->addHelpButton('preferredbehaviour', 'howquestionsbehave', 'question'); $mform->setDefault('preferredbehaviour', $quizconfig->preferredbehaviour); + // TODO: Store the 'reattemptgradedquestions' field when new DB structure in place. + $mform->addElement('selectyesno', 'reattemptgradedquestions', get_string('restartgradedquestions', 'quiz')); + $mform->addHelpButton('reattemptgradedquestions', 'restartgradedquestions', 'quiz'); + $mform->setAdvanced('reattemptgradedquestions', $quizconfig->reattemptgradedquestions_adv); + $mform->setDefault('reattemptgradedquestions', $quizconfig->reattemptgradedquestions); + foreach ($behaviours as $behaviour => $notused) { + $qbt = question_engine::get_behaviour_type($behaviour); + if (!$qbt->user_can_reattempt_graded_question()) { + $mform->disabledIf('reattemptgradedquestions', 'preferredbehaviour', 'eq', $behaviour); + } + + } + // Each attempt builds on last. $mform->addElement('selectyesno', 'attemptonlast', get_string('eachattemptbuildsonthelast', 'quiz')); diff --git a/mod/quiz/processattempt.php b/mod/quiz/processattempt.php index edf6d8c617c..ff6cedcd581 100644 --- a/mod/quiz/processattempt.php +++ b/mod/quiz/processattempt.php @@ -44,6 +44,12 @@ $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); +// Process replace question action, when user press on 'Replace question' link. +if (isset($_POST['restartquestioninslot'])) { + redirect(new moodle_url('/mod/quiz/attempt.php', + array('attempt' => $attemptid, 'page' => $thispage, 'replacequestioninslot' => $_POST['restartquestionincurrentslot']))); +} + $transaction = $DB->start_delegated_transaction(); $attemptobj = quiz_attempt::create($attemptid); diff --git a/mod/quiz/renderer.php b/mod/quiz/renderer.php index 9b746718655..5174dbf79ca 100644 --- a/mod/quiz/renderer.php +++ b/mod/quiz/renderer.php @@ -461,6 +461,7 @@ class mod_quiz_renderer extends plugin_renderer_base { foreach ($slots as $slot) { $output .= $attemptobj->render_question($slot, false, $attemptobj->attempt_url($slot, $page)); + $output .= $attemptobj->restart_question_button($slot); } $output .= html_writer::start_tag('div', array('class' => 'submitbtns')); diff --git a/mod/quiz/settings.php b/mod/quiz/settings.php index fc4a28fd6b5..4ef3dc4e711 100644 --- a/mod/quiz/settings.php +++ b/mod/quiz/settings.php @@ -128,6 +128,11 @@ if ($ADMIN->fulltree) { get_string('howquestionsbehave', 'question'), get_string('howquestionsbehave_desc', 'quiz'), 'deferredfeedback')); + // Restart completed questions (reattemptgradedquestions). + $quizsettings->add(new admin_setting_configcheckbox_with_advanced('quiz/reattemptgradedquestions', + get_string('restartgradedquestions', 'quiz'), get_string('configrestartgradedquestions', 'quiz'), + array('value' => 0, 'adv' => true))); + // Each attempt builds on last. $quizsettings->add(new admin_setting_configcheckbox_with_advanced('quiz/attemptonlast', get_string('eachattemptbuildsonthelast', 'quiz'), diff --git a/mod/quiz/styles.css b/mod/quiz/styles.css index e4fdb227758..817caea6366 100644 --- a/mod/quiz/styles.css +++ b/mod/quiz/styles.css @@ -24,6 +24,10 @@ text-align: right; } +#page-mod-quiz-attempt .resatrt-question-btn { + font-size: 0.75em; +} + #page-mod-quiz-attempt .mod_quiz-blocked_question_warning .que .formulation, #page-mod-quiz-review .mod_quiz-blocked_question_warning .que .formulation { background: #eee; diff --git a/mod/quiz/tests/behat/reattemptquestions.feature b/mod/quiz/tests/behat/reattemptquestions.feature new file mode 100644 index 00000000000..0943cc6b3fd --- /dev/null +++ b/mod/quiz/tests/behat/reattemptquestions.feature @@ -0,0 +1,53 @@ +@mod @mod_quiz +Feature: Add a quiz + In order to allow students re-attempting graded question + As a teacher + I need to create a quiz, set 'Restart question' field to 'Yes', add questions to the quiz which can be graded automatically. + + Background: + Given the following "users" exist: + | username | firstname | lastname | email | + | teacher1 | T1 | Teacher1 | teacher1@moodle.com | + | student1 | S1 | Student1 | student1@moodle.com | + And the following "courses" exist: + | fullname | shortname | category | + | Course 1 | C1 | 0 | + And the following "course enrolments" exist: + | user | course | role | + | teacher1 | C1 | editingteacher | + | student1 | C1 | student | + When I log in as "teacher1" + And I follow "Course 1" + And I turn editing mode on + + And I add a "Quiz" to section "1" and I fill the form with: + | Name | Quiz 1 | + | Description | Quiz 1 description | + | How questions behave | Immediate feedback | + | Restart graded questions | Yes | + + And I add a "True/False" question to the "Quiz 1" quiz with: + | Question name | TF001 | + | Question text | Answer question TF001 | + | General feedback | Thank you, this is the general feedback | + | Correct answer | False | + | Feedback for the response 'True'. | So you think it is true | + | Feedback for the response 'False'. | So you think it is false | + And I log out + + @javascript + Scenario: Log in as a student, attempt the quiz and checking whether you can re-attempt a graded question in the appropriate behaviour settings + And I log in as "student1" + And I follow "Course 1" + And I follow "Quiz 1" + And I press "Attempt quiz now" + Then I should see "TF001" + And I should see "Answer question TF001" + And I set the field "True" to "1" + And I press "Check" + And I should see "Incorrect" + Then I press "Restart question" + And I should see "Not complete" + And I set the field "False" to "1" + And I press "Check" + And I should see "Correct" diff --git a/mod/quiz/version.php b/mod/quiz/version.php index d9ddae57917..e0cd1fd5f6f 100644 --- a/mod/quiz/version.php +++ b/mod/quiz/version.php @@ -24,7 +24,7 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2015030500; +$plugin->version = 2015030900; $plugin->requires = 2014110400; $plugin->component = 'mod_quiz'; $plugin->cron = 60; diff --git a/question/behaviour/behaviourtypebase.php b/question/behaviour/behaviourtypebase.php index 8cc0c7dc927..37a8e1af474 100644 --- a/question/behaviour/behaviourtypebase.php +++ b/question/behaviour/behaviourtypebase.php @@ -102,6 +102,14 @@ abstract class question_behaviour_type { public function allows_multiple_submitted_responses() { return false; } + + /** + * Allow user to reattmpt graded question during a quiz attempt + * @return boolean + */ + public function user_can_reattempt_graded_question() { + return false; + } } diff --git a/question/behaviour/immediatecbm/behaviourtype.php b/question/behaviour/immediatecbm/behaviourtype.php index 01a2ec336ff..fa586a09933 100644 --- a/question/behaviour/immediatecbm/behaviourtype.php +++ b/question/behaviour/immediatecbm/behaviourtype.php @@ -39,4 +39,11 @@ class qbehaviour_immediatecbm_type extends qbehaviour_deferredcbm_type { public function get_unused_display_options() { return array(); } + + /** + * Allow user to re-attempt graded questions during a quiz attempt + */ + public function user_can_reattempt_graded_question() { + return true; + } } diff --git a/question/behaviour/immediatefeedback/behaviourtype.php b/question/behaviour/immediatefeedback/behaviourtype.php index c366fe33126..71b6d6f2cdd 100644 --- a/question/behaviour/immediatefeedback/behaviourtype.php +++ b/question/behaviour/immediatefeedback/behaviourtype.php @@ -36,4 +36,11 @@ class qbehaviour_immediatefeedback_type extends question_behaviour_type { public function is_archetypal() { return true; } + + /** + * Allow user to re-attempt graded questions during a quiz attempt + */ + public function user_can_reattempt_graded_question() { + return true; + } } diff --git a/question/behaviour/interactive/behaviourtype.php b/question/behaviour/interactive/behaviourtype.php index 3b443586103..641208dd356 100644 --- a/question/behaviour/interactive/behaviourtype.php +++ b/question/behaviour/interactive/behaviourtype.php @@ -40,4 +40,13 @@ class qbehaviour_interactive_type extends question_behaviour_type { public function allows_multiple_submitted_responses() { return true; } + + /** + * Allow user to re-attempt graded questions during a quiz attempt + */ + public function user_can_reattempt_graded_question() { + return true; + } + + } diff --git a/question/engine/datalib.php b/question/engine/datalib.php index 9574743e853..ab41023ed70 100644 --- a/question/engine/datalib.php +++ b/question/engine/datalib.php @@ -880,6 +880,16 @@ ORDER BY $this->db->update_record('question_attempts', $record); } + /** + * Delete a question_attempts row to reflect any changes in a question_attempt + * (but not any of its steps). + * @param question_attempt $qa the question attempt that has been deleted. + */ + public function delete_question_attempt(question_attempt $qa) { + $conditions = array('questionusageid' => $qa->get_usage_id(), 'slot' => $qa->get_slot()); + $this->db->delete_records('question_attempts', $conditions); + } + /** * Delete a question_usage_by_activity and all its associated * @@ -1250,6 +1260,12 @@ class question_engine_unit_of_work implements question_usage_observer { */ protected $attemptsmodified = array(); + /** + * @var array list of slot => {@link question_attempt}s that + * were already in the usage, and which have been deleted. + */ + protected $attemptsdeleted = array(); + /** * @var array list of slot => {@link question_attempt}s that * have been added to the usage. @@ -1293,8 +1309,28 @@ class question_engine_unit_of_work implements question_usage_observer { } } + /** + * Notify when attempt deleted + * + * @see question_usage_observer::notify_attempt_deleted() + */ + public function notify_attempt_deleted(question_attempt $qa) { + $slot = $qa->get_slot(); + if (!array_key_exists($slot, $this->attemptsadded)) { + $this->attemptsdeleted[$slot] = $qa; + } + } + + /** + * Notify when attempt added + * + * @see question_usage_observer::notify_attempt_added() + */ public function notify_attempt_added(question_attempt $qa) { - $this->attemptsadded[$qa->get_slot()] = $qa; + $slot = $qa->get_slot(); + if (!array_key_exists($slot, $this->attemptsadded)) { + $this->attemptsadded[$slot] = $qa; + } } public function notify_step_added(question_attempt_step $step, question_attempt $qa, $seq) { @@ -1437,6 +1473,10 @@ class question_engine_unit_of_work implements question_usage_observer { $step, $questionattemptid, $seq, $this->quba->get_owning_context()); } + foreach ($this->attemptsdeleted as $qa) { + $dm->delete_question_attempt($qa); + } + foreach ($this->attemptsadded as $qa) { $stepdata[] = $dm->insert_question_attempt( $qa, $this->quba->get_owning_context()); @@ -1598,9 +1638,9 @@ class question_file_loader implements question_response_files { protected $name; /** - * @var string the value to stored in the question_attempt_step_data to + * @var string the value to stored in the question_attempt_step_data to * represent these files. - */ + */ protected $value; /** @var int the context id that the files belong to. */ diff --git a/question/engine/questionusage.php b/question/engine/questionusage.php index 536c8808c82..d5a213b390f 100644 --- a/question/engine/questionusage.php +++ b/question/engine/questionusage.php @@ -822,6 +822,22 @@ class question_usage_by_activity { $this->observer->notify_attempt_modified($newqa); } + /** + * Replace a question in this usage. + * @param int $slot the number used to identify this question within this usage.* + */ + public function replace_question($slot) { + global $OUTPUT; + $oldqa = $this->get_question_attempt($slot); + $newqa = new question_attempt($oldqa->get_question(), $oldqa->get_usage_id(), $this->observer); + $newqa->set_database_id($oldqa->get_database_id()); + $newqa->set_slot($oldqa->get_slot()); + $this->questionattempts[$slot] = $newqa; + $this->observer->notify_attempt_deleted($oldqa); + $this->observer->notify_attempt_added($newqa); + $this->start_question($slot); + } + /** * Regrade all the questions in this usage (without changing their max mark). * @param bool $finished whether each question should be forced to be finished @@ -979,6 +995,12 @@ interface question_usage_observer { */ public function notify_attempt_added(question_attempt $qa); + /** + * Called when the fields of a question attempt in this usage are deleted. + * @param question_attempt $qa + */ + public function notify_attempt_deleted(question_attempt $qa); + /** * Called when a new step is added to a question attempt in this usage. * @param question_attempt_step $step the new step. @@ -1017,6 +1039,8 @@ class question_usage_null_observer implements question_usage_observer { } public function notify_attempt_modified(question_attempt $qa) { } + public function notify_attempt_deleted(question_attempt $qa) { + } public function notify_attempt_added(question_attempt $qa) { } public function notify_step_added(question_attempt_step $step, question_attempt $qa, $seq) {