+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \mod_quiz\quiz_attempt
*/
class attempt_walkthrough_test extends \advanced_testcase {
@@ -154,9 +154,10 @@ class attempt_walkthrough_test extends \advanced_testcase {
* The quiz is set to close 1 hour from now.
* The quiz is set to use a grade period of 1 hour once time expires.
*
+ * @param string $overduehandling value for the overduehandling quiz setting.
* @return \stdClass the quiz that was created.
*/
- protected function create_quiz_with_one_question(): \stdClass {
+ protected function create_quiz_with_one_question(string $overduehandling = 'graceperiod'): \stdClass {
global $SITE;
$this->resetAfterTest();
@@ -166,7 +167,7 @@ class attempt_walkthrough_test extends \advanced_testcase {
$quiz = $quizgenerator->create_instance(
['course' => $SITE->id, 'timeclose' => $timeclose,
- 'overduehandling' => 'graceperiod', 'graceperiod' => HOURSECS]);
+ 'overduehandling' => $overduehandling, 'graceperiod' => HOURSECS]);
// Create a question.
/** @var \core_question_generator $questiongenerator */
@@ -461,4 +462,120 @@ class attempt_walkthrough_test extends \advanced_testcase {
$gradebookgrade = array_shift($gradebookitem->grades);
$this->assertEquals(100, $gradebookgrade->grade);
}
+
+ public function test_quiz_attempt_walkthrough_abandoned_attempt_reopened_with_timelimit_override() {
+ global $DB;
+
+ $quiz = $this->create_quiz_with_one_question('autoabandon');
+ $originaltimeclose = $quiz->timeclose;
+
+ // Make a user to do the quiz.
+ $user = $this->getDataGenerator()->create_user();
+ $this->setUser($user);
+ $quizobj = quiz_settings::create($quiz->id, $user->id);
+
+ // Start the attempt.
+ $attempt = quiz_prepare_and_start_new_attempt($quizobj, 1, null);
+
+ // Process some responses from the student during the attempt.
+ $attemptobj = quiz_attempt::create($attempt->id);
+ $attemptobj->process_submitted_actions($originaltimeclose - 30 * MINSECS, false, [1 => ['answer' => 'frog']]);
+
+ // Student leaves, so cron closes the attempt when time expires.
+ $attemptobj->process_abandon($originaltimeclose + 5 * MINSECS, false);
+
+ // Verify the attempt state.
+ $attemptobj = quiz_attempt::create($attempt->id);
+ $this->assertEquals(quiz_attempt::ABANDONED, $attemptobj->get_state());
+ $this->assertEquals(0, $attemptobj->get_submitted_date());
+ $this->assertEquals($user->id, $attemptobj->get_userid());
+
+ // The teacher feels kind, so adds an override for the student, and re-opens the attempt.
+ $sink = $this->redirectEvents();
+ $overriddentimeclose = $originaltimeclose + HOURSECS;
+ $DB->insert_record('quiz_overrides', [
+ 'quiz' => $quiz->id,
+ 'userid' => $user->id,
+ 'timeclose' => $overriddentimeclose,
+ ]);
+ $attemptobj = quiz_attempt::create($attempt->id);
+ $reopentime = $originaltimeclose + 10 * MINSECS;
+ $attemptobj->process_reopen_abandoned($reopentime);
+
+ // Verify the attempt state.
+ $attemptobj = quiz_attempt::create($attempt->id);
+ $this->assertEquals(1, $attemptobj->get_attempt_number());
+ $this->assertFalse($attemptobj->is_finished());
+ $this->assertEquals(quiz_attempt::IN_PROGRESS, $attemptobj->get_state());
+ $this->assertEquals(0, $attemptobj->get_submitted_date());
+ $this->assertEquals($user->id, $attemptobj->get_userid());
+ $this->assertEquals($overriddentimeclose,
+ $attemptobj->get_access_manager($reopentime)->get_end_time($attemptobj->get_attempt()));
+
+ // Verify this was logged correctly.
+ $events = $sink->get_events();
+ $this->assertCount(1, $events);
+
+ $reopenedevent = array_shift($events);
+ $this->assertInstanceOf('\mod_quiz\event\attempt_reopened', $reopenedevent);
+ $this->assertEquals($attemptobj->get_context(), $reopenedevent->get_context());
+ $this->assertEquals(new moodle_url('/mod/quiz/review.php', ['attempt' => $attemptobj->get_attemptid()]),
+ $reopenedevent->get_url());
+ }
+
+ public function test_quiz_attempt_walkthrough_abandoned_attempt_reopened_after_close_time() {
+ $quiz = $this->create_quiz_with_one_question('autoabandon');
+ $originaltimeclose = $quiz->timeclose;
+
+ // Make a user to do the quiz.
+ $user = $this->getDataGenerator()->create_user();
+ $this->setUser($user);
+ $quizobj = quiz_settings::create($quiz->id, $user->id);
+
+ // Start the attempt.
+ $attempt = quiz_prepare_and_start_new_attempt($quizobj, 1, null);
+
+ // Process some responses from the student during the attempt.
+ $attemptobj = quiz_attempt::create($attempt->id);
+ $attemptobj->process_submitted_actions($originaltimeclose - 30 * MINSECS, false, [1 => ['answer' => 'frog']]);
+
+ // Student leaves, so cron closes the attempt when time expires.
+ $attemptobj->process_abandon($originaltimeclose + 5 * MINSECS, false);
+
+ // Verify the attempt state.
+ $attemptobj = quiz_attempt::create($attempt->id);
+ $this->assertEquals(quiz_attempt::ABANDONED, $attemptobj->get_state());
+ $this->assertEquals(0, $attemptobj->get_submitted_date());
+ $this->assertEquals($user->id, $attemptobj->get_userid());
+
+ // The teacher reopens the attempt without granting more time, so previously submitted responess are graded.
+ $sink = $this->redirectEvents();
+ $reopentime = $originaltimeclose + 10 * MINSECS;
+ $attemptobj->process_reopen_abandoned($reopentime);
+
+ // Verify the attempt state.
+ $attemptobj = quiz_attempt::create($attempt->id);
+ $this->assertEquals(1, $attemptobj->get_attempt_number());
+ $this->assertTrue($attemptobj->is_finished());
+ $this->assertEquals(quiz_attempt::FINISHED, $attemptobj->get_state());
+ $this->assertEquals($originaltimeclose, $attemptobj->get_submitted_date());
+ $this->assertEquals($user->id, $attemptobj->get_userid());
+ $this->assertEquals(1, $attemptobj->get_sum_marks());
+
+ // Verify this was logged correctly - there are some gradebook events between the two we want to check.
+ $events = $sink->get_events();
+ $this->assertGreaterThanOrEqual(2, $events);
+
+ $reopenedevent = array_shift($events);
+ $this->assertInstanceOf('\mod_quiz\event\attempt_reopened', $reopenedevent);
+ $this->assertEquals($attemptobj->get_context(), $reopenedevent->get_context());
+ $this->assertEquals(new moodle_url('/mod/quiz/review.php', ['attempt' => $attemptobj->get_attemptid()]),
+ $reopenedevent->get_url());
+
+ $submittedevent = array_pop($events);
+ $this->assertInstanceOf('\mod_quiz\event\attempt_submitted', $submittedevent);
+ $this->assertEquals($attemptobj->get_context(), $submittedevent->get_context());
+ $this->assertEquals(new moodle_url('/mod/quiz/review.php', ['attempt' => $attemptobj->get_attemptid()]),
+ $submittedevent->get_url());
+ }
}
diff --git a/mod/quiz/tests/behat/behat_mod_quiz.php b/mod/quiz/tests/behat/behat_mod_quiz.php
index dc16c7f7d04..3ddddfb0bf9 100644
--- a/mod/quiz/tests/behat/behat_mod_quiz.php
+++ b/mod/quiz/tests/behat/behat_mod_quiz.php
@@ -842,10 +842,6 @@ class behat_mod_quiz extends behat_question_base {
/**
* Start a quiz attempt without answers.
*
- * Then there should be a number of rows of data, one for each question you want to add.
- * There is no need to supply answers to all questions. If so, other qusetions will be
- * left unanswered.
- *
* @param string $username the username of the user that will attempt.
* @param string $quizname the name of the quiz the user will attempt.
* @Given /^user "([^"]*)" has started an attempt at quiz "([^"]*)"$/
@@ -988,6 +984,31 @@ class behat_mod_quiz extends behat_question_base {
$this->set_user();
}
+ /**
+ * Finish an existing quiz attempt.
+ *
+ * @param string $quizname the name of the quiz the user will attempt.
+ * @param string $username the username of the user that will attempt.
+ * @Given the attempt at :quizname by :username was never submitted
+ */
+ public function attempt_was_abandoned($quizname, $username) {
+ global $DB;
+
+ $quizid = $DB->get_field('quiz', 'id', ['name' => $quizname], MUST_EXIST);
+ $user = $DB->get_record('user', ['username' => $username], '*', MUST_EXIST);
+
+ $this->set_user($user);
+
+ $attempt = quiz_get_user_attempt_unfinished($quizid, $user->id);
+ if (!$attempt) {
+ throw new coding_exception("No in-progress attempt found for $username and quiz $quizname.");
+ }
+ $attemptobj = quiz_attempt::create($attempt->id);
+ $attemptobj->process_abandon(time(), false);
+
+ $this->set_user();
+ }
+
/**
* Return a list of the exact named selectors for the component.
*
diff --git a/mod/quiz/tests/external/reopen_attempt_test.php b/mod/quiz/tests/external/reopen_attempt_test.php
new file mode 100644
index 00000000000..d81c242e1e2
--- /dev/null
+++ b/mod/quiz/tests/external/reopen_attempt_test.php
@@ -0,0 +1,177 @@
+.
+
+namespace mod_quiz\external;
+
+use coding_exception;
+use core_question_generator;
+use externallib_advanced_testcase;
+use mod_quiz\quiz_attempt;
+use mod_quiz\quiz_settings;
+use required_capability_exception;
+use stdClass;
+
+/**
+ * Test for the reopen_attempt and get_reopen_attempt_confirmation services.
+ *
+ * @package mod_quiz
+ * @category external
+ * @copyright 2023 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @covers \mod_quiz\external\reopen_attempt
+ * @covers \mod_quiz\external\get_reopen_attempt_confirmation
+ */
+class reopen_attempt_test extends externallib_advanced_testcase {
+ /** @var stdClass|null if we make a quiz attempt, we store the student object here. */
+ protected $student;
+
+ public function test_reopen_attempt_service_works() {
+ [$attemptid] = $this->create_attempt_at_quiz_with_one_shortanswer_question();
+
+ reopen_attempt::execute($attemptid);
+
+ $attemptobj = quiz_attempt::create($attemptid);
+ $this->assertEquals(quiz_attempt::IN_PROGRESS, $attemptobj->get_state());
+ }
+
+ public function test_reopen_attempt_service_checks_permissions() {
+ [$attemptid] = $this->create_attempt_at_quiz_with_one_shortanswer_question();
+
+ $unprivilegeduser = $this->getDataGenerator()->create_user();
+ $this->setUser($unprivilegeduser);
+
+ $this->expectException(required_capability_exception::class);
+ reopen_attempt::execute($attemptid);
+ }
+
+ public function test_reopen_attempt_service_checks_attempt_state() {
+ [$attemptid] = $this->create_attempt_at_quiz_with_one_shortanswer_question(quiz_attempt::IN_PROGRESS);
+
+ $this->expectExceptionMessage("Attempt $attemptid is in the wrong state (In progress) to be re-openend.");
+ reopen_attempt::execute($attemptid);
+ }
+
+ public function test_get_reopen_attempt_confirmation_staying_open() {
+ global $DB;
+ [$attemptid, $quizid] = $this->create_attempt_at_quiz_with_one_shortanswer_question();
+ $DB->set_field('quiz', 'timeclose', 0, ['id' => $quizid]);
+
+ $message = get_reopen_attempt_confirmation::execute($attemptid);
+
+ $this->assertEquals('Are you sure you wish to re-open quiz attempt 1 by ' . fullname($this->student) .
+ '?
The re-opened attempt will remain open so that it can be continued.
',
+ $message);
+ }
+
+ public function test_get_reopen_attempt_confirmation_staying_open_until() {
+ global $DB;
+ [$attemptid, $quizid] = $this->create_attempt_at_quiz_with_one_shortanswer_question();
+ $timeclose = time() + HOURSECS;
+ $DB->set_field('quiz', 'timeclose', $timeclose, ['id' => $quizid]);
+
+ $message = get_reopen_attempt_confirmation::execute($attemptid);
+
+ $this->assertEquals('Are you sure you wish to re-open quiz attempt 1 by ' . fullname($this->student) .
+ '?
The re-opened attempt will remain open so that it can be continued. It will be due for submission by ' .
+ userdate($timeclose) . '.
',
+ $message);
+ }
+
+ public function test_get_reopen_attempt_confirmation_submitting() {
+ global $DB;
+ [$attemptid, $quizid] = $this->create_attempt_at_quiz_with_one_shortanswer_question();
+ $timeclose = time() - HOURSECS;
+ $DB->set_field('quiz', 'timeclose', $timeclose, ['id' => $quizid]);
+
+ $message = get_reopen_attempt_confirmation::execute($attemptid);
+
+ $this->assertEquals('Are you sure you wish to re-open quiz attempt 1 by ' . fullname($this->student) .
+ '?
The re-opened attempt will be immediately submitted for grading.
',
+ $message);
+ }
+
+ public function test_get_reopen_attempt_confirmation_service_checks_permissions() {
+ [$attemptid] = $this->create_attempt_at_quiz_with_one_shortanswer_question();
+
+ $unprivilegeduser = $this->getDataGenerator()->create_user();
+ $this->setUser($unprivilegeduser);
+
+ $this->expectException(required_capability_exception::class);
+ get_reopen_attempt_confirmation::execute($attemptid);
+ }
+
+ public function test_get_reopen_attempt_confirmation_service_checks_attempt_state() {
+ [$attemptid] = $this->create_attempt_at_quiz_with_one_shortanswer_question(quiz_attempt::IN_PROGRESS);
+
+ $this->expectExceptionMessage("Attempt $attemptid is in the wrong state (In progress) to be re-openend.");
+ get_reopen_attempt_confirmation::execute($attemptid);
+ }
+
+ /**
+ * Create a quiz of one shortanswer question and an attempt in a given state.
+ *
+ * @param string $attemptstate the desired attempt state. quiz_attempt::ABANDONED or ::IN_PROGRESS.
+ * @return array with two elements, the attempt id and the quiz id.
+ */
+ protected function create_attempt_at_quiz_with_one_shortanswer_question(
+ string $attemptstate = quiz_attempt::ABANDONED
+ ): array {
+ global $SITE;
+ $this->resetAfterTest();
+
+ // Make a quiz.
+ $timeclose = time() + HOURSECS;
+ $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz');
+
+ $quiz = $quizgenerator->create_instance([
+ 'course' => $SITE->id,
+ 'timeclose' => $timeclose,
+ 'overduehandling' => 'autoabandon'
+ ]);
+
+ // Create a question.
+ /** @var core_question_generator $questiongenerator */
+ $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
+ $cat = $questiongenerator->create_question_category();
+ $saq = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]);
+
+ // Add them to the quiz.
+ $quizobj = quiz_settings::create($quiz->id);
+ quiz_add_quiz_question($saq->id, $quiz, 0, 1);
+ $quizobj->get_grade_calculator()->recompute_quiz_sumgrades();
+
+ // Make a user to do the quiz.
+ $this->student = $this->getDataGenerator()->create_user();
+ $this->setUser($this->student);
+ $quizobj = quiz_settings::create($quiz->id, $this->student->id);
+
+ // Start the attempt.
+ $attempt = quiz_prepare_and_start_new_attempt($quizobj, 1, null);
+ $attemptobj = quiz_attempt::create($attempt->id);
+
+ if ($attemptstate === quiz_attempt::ABANDONED) {
+ // Attempt goes overdue (e.g. if cron ran).
+ $attemptobj->process_abandon($timeclose + 2 * get_config('quiz', 'graceperiodmin'), false);
+ } else if ($attemptstate !== quiz_attempt::IN_PROGRESS) {
+ throw new coding_exception('State ' . $attemptstate . ' not currently supported.');
+ }
+
+ // Set current user to admin before we return.
+ $this->setAdminUser();
+
+ return [$attemptobj->get_attemptid(), $attemptobj->get_quizid()];
+ }
+}
diff --git a/mod/quiz/version.php b/mod/quiz/version.php
index d03cd894795..0e8ed97e262 100644
--- a/mod/quiz/version.php
+++ b/mod/quiz/version.php
@@ -24,6 +24,6 @@
defined('MOODLE_INTERNAL') || die();
-$plugin->version = 2022120500;
-$plugin->requires = 2022111800;
+$plugin->version = 2023030300;
+$plugin->requires = 2022111800;
$plugin->component = 'mod_quiz';