Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 4.1.x will end 13 November 2023 (12 months).
  • Bug fixes for security issues in 4.1.x will end 10 November 2025 (36 months).
  • PHP version: minimum PHP 7.4.0 Note: minimum PHP version has increased since Moodle 4.0. PHP 8.0.x is supported too.

Differences Between: [Versions 310 and 401] [Versions 311 and 401] [Versions 39 and 401] [Versions 401 and 402] [Versions 401 and 403]

   1  <?php
   2  // This file is part of Moodle - http://moodle.org/
   3  //
   4  // Moodle is free software: you can redistribute it and/or modify
   5  // it under the terms of the GNU General Public License as published by
   6  // the Free Software Foundation, either version 3 of the License, or
   7  // (at your option) any later version.
   8  //
   9  // Moodle is distributed in the hope that it will be useful,
  10  // but WITHOUT ANY WARRANTY; without even the implied warranty of
  11  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  12  // GNU General Public License for more details.
  13  //
  14  // You should have received a copy of the GNU General Public License
  15  // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
  16  
  17  namespace quiz_overview;
  18  
  19  use core_question\local\bank\question_version_status;
  20  use mod_quiz\external\submit_question_version;
  21  use question_engine;
  22  use quiz;
  23  use quiz_attempt;
  24  use quiz_attempts_report;
  25  use quiz_overview_options;
  26  use quiz_overview_report;
  27  use quiz_overview_table;
  28  
  29  defined('MOODLE_INTERNAL') || die();
  30  
  31  global $CFG;
  32  require_once($CFG->dirroot . '/mod/quiz/locallib.php');
  33  require_once($CFG->dirroot . '/mod/quiz/report/reportlib.php');
  34  require_once($CFG->dirroot . '/mod/quiz/report/default.php');
  35  require_once($CFG->dirroot . '/mod/quiz/report/overview/report.php');
  36  require_once($CFG->dirroot . '/mod/quiz/report/overview/overview_form.php');
  37  require_once($CFG->dirroot . '/mod/quiz/report/overview/tests/helpers.php');
  38  require_once($CFG->dirroot . '/mod/quiz/tests/quiz_question_helper_test_trait.php');
  39  
  40  
  41  /**
  42   * Tests for the quiz overview report.
  43   *
  44   * @package    quiz_overview
  45   * @copyright  2014 The Open University
  46   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  47   */
  48  class report_test extends \advanced_testcase {
  49      use \quiz_question_helper_test_trait;
  50  
  51      /**
  52       * Data provider for test_report_sql.
  53       *
  54       * @return array the data for the test sub-cases.
  55       */
  56      public function report_sql_cases(): array {
  57          return [[null], ['csv']]; // Only need to test on or off, not all download types.
  58      }
  59  
  60      /**
  61       * Test how the report queries the database.
  62       *
  63       * @param string|null $isdownloading a download type, or null.
  64       * @dataProvider report_sql_cases
  65       */
  66      public function test_report_sql(?string $isdownloading): void {
  67          global $DB;
  68          $this->resetAfterTest();
  69  
  70          // Create a course and a quiz.
  71          $generator = $this->getDataGenerator();
  72          $course = $generator->create_course();
  73          $quizgenerator = $generator->get_plugin_generator('mod_quiz');
  74          $quiz = $quizgenerator->create_instance(array('course' => $course->id,
  75                  'grademethod' => QUIZ_GRADEHIGHEST, 'grade' => 100.0, 'sumgrades' => 10.0,
  76                  'attempts' => 10));
  77  
  78          // Add one question.
  79          /** @var core_question_generator $questiongenerator */
  80          $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
  81          $cat = $questiongenerator->create_question_category();
  82          $q = $questiongenerator->create_question('essay', 'plain', ['category' => $cat->id]);
  83          quiz_add_quiz_question($q->id, $quiz, 0 , 10);
  84  
  85          // Create some students and enrol them in the course.
  86          $student1 = $generator->create_user();
  87          $student2 = $generator->create_user();
  88          $student3 = $generator->create_user();
  89          $generator->enrol_user($student1->id, $course->id);
  90          $generator->enrol_user($student2->id, $course->id);
  91          $generator->enrol_user($student3->id, $course->id);
  92          // This line is not really necessary for the test asserts below,
  93          // but what it does is add an extra user row returned by
  94          // get_enrolled_with_capabilities_join because of a second enrolment.
  95          // The extra row returned used to make $table->query_db complain
  96          // about duplicate records. So this is really a test that an extra
  97          // student enrolment does not cause duplicate records in this query.
  98          $generator->enrol_user($student2->id, $course->id, null, 'self');
  99  
 100          // Also create a user who should not appear in the reports,
 101          // because they have a role with neither 'mod/quiz:attempt'
 102          // nor 'mod/quiz:reviewmyattempts'.
 103          $tutor = $generator->create_user();
 104          $generator->enrol_user($tutor->id, $course->id, 'teacher');
 105  
 106          // The test data.
 107          $timestamp = 1234567890;
 108          $attempts = array(
 109              array($quiz, $student1, 1, 0.0,  quiz_attempt::FINISHED),
 110              array($quiz, $student1, 2, 5.0,  quiz_attempt::FINISHED),
 111              array($quiz, $student1, 3, 8.0,  quiz_attempt::FINISHED),
 112              array($quiz, $student1, 4, null, quiz_attempt::ABANDONED),
 113              array($quiz, $student1, 5, null, quiz_attempt::IN_PROGRESS),
 114              array($quiz, $student2, 1, null, quiz_attempt::ABANDONED),
 115              array($quiz, $student2, 2, null, quiz_attempt::ABANDONED),
 116              array($quiz, $student2, 3, 7.0,  quiz_attempt::FINISHED),
 117              array($quiz, $student2, 4, null, quiz_attempt::ABANDONED),
 118              array($quiz, $student2, 5, null, quiz_attempt::ABANDONED),
 119          );
 120  
 121          // Load it in to quiz attempts table.
 122          foreach ($attempts as $attemptdata) {
 123              list($quiz, $student, $attemptnumber, $sumgrades, $state) = $attemptdata;
 124              $timestart = $timestamp + $attemptnumber * 3600;
 125  
 126              $quizobj = quiz::create($quiz->id, $student->id);
 127              $quba = question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context());
 128              $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour);
 129  
 130              // Create the new attempt and initialize the question sessions.
 131              $attempt = quiz_create_attempt($quizobj, $attemptnumber, null, $timestart, false, $student->id);
 132  
 133              $attempt = quiz_start_new_attempt($quizobj, $quba, $attempt, $attemptnumber, $timestamp);
 134              $attempt = quiz_attempt_save_started($quizobj, $quba, $attempt);
 135  
 136              // Process some responses from the student.
 137              $attemptobj = quiz_attempt::create($attempt->id);
 138              switch ($state) {
 139                  case quiz_attempt::ABANDONED:
 140                      $attemptobj->process_abandon($timestart + 300, false);
 141                      break;
 142  
 143                  case quiz_attempt::IN_PROGRESS:
 144                      // Do nothing.
 145                      break;
 146  
 147                  case quiz_attempt::FINISHED:
 148                      // Save answer and finish attempt.
 149                      $attemptobj->process_submitted_actions($timestart + 300, false, [
 150                              1 => ['answer' => 'My essay by ' . $student->firstname, 'answerformat' => FORMAT_PLAIN]]);
 151                      $attemptobj->process_finish($timestart + 600, false);
 152  
 153                      // Manually grade it.
 154                      $quba = $attemptobj->get_question_usage();
 155                      $quba->get_question_attempt(1)->manual_grade(
 156                              'Comment', $sumgrades, FORMAT_HTML, $timestart + 1200);
 157                      question_engine::save_questions_usage_by_activity($quba);
 158                      $update = new \stdClass();
 159                      $update->id = $attemptobj->get_attemptid();
 160                      $update->timemodified = $timestart + 1200;
 161                      $update->sumgrades = $quba->get_total_mark();
 162                      $DB->update_record('quiz_attempts', $update);
 163                      quiz_save_best_grade($attemptobj->get_quiz(), $student->id);
 164                      break;
 165              }
 166          }
 167  
 168          // Actually getting the SQL to run is quite hard. Do a minimal set up of
 169          // some objects.
 170          $context = \context_module::instance($quiz->cmid);
 171          $cm = get_coursemodule_from_id('quiz', $quiz->cmid);
 172          $qmsubselect = quiz_report_qm_filter_select($quiz);
 173          $studentsjoins = get_enrolled_with_capabilities_join($context, '',
 174                  array('mod/quiz:attempt', 'mod/quiz:reviewmyattempts'));
 175          $empty = new \core\dml\sql_join();
 176  
 177          // Set the options.
 178          $reportoptions = new quiz_overview_options('overview', $quiz, $cm, null);
 179          $reportoptions->attempts = quiz_attempts_report::ENROLLED_ALL;
 180          $reportoptions->onlygraded = true;
 181          $reportoptions->states = array(quiz_attempt::IN_PROGRESS, quiz_attempt::OVERDUE, quiz_attempt::FINISHED);
 182  
 183          // Now do a minimal set-up of the table class.
 184          $q->slot = 1;
 185          $q->maxmark = 10;
 186          $table = new quiz_overview_table($quiz, $context, $qmsubselect, $reportoptions,
 187                  $empty, $studentsjoins, array(1 => $q), null);
 188          $table->download = $isdownloading; // Cannot call the is_downloading API, because it gives errors.
 189          $table->define_columns(array('fullname'));
 190          $table->sortable(true, 'uniqueid');
 191          $table->define_baseurl(new \moodle_url('/mod/quiz/report.php'));
 192          $table->setup();
 193  
 194          // Run the query.
 195          $table->setup_sql_queries($studentsjoins);
 196          $table->query_db(30, false);
 197  
 198          // Should be 4 rows, matching count($table->rawdata) tested below.
 199          // The count is only done if not downloading.
 200          if (!$isdownloading) {
 201              $this->assertEquals(4, $table->totalrows);
 202          }
 203  
 204          // Verify what was returned: Student 1's best and in progress attempts.
 205          // Student 2's finshed attempt, and Student 3 with no attempt.
 206          // The array key is {student id}#{attempt number}.
 207          $this->assertEquals(4, count($table->rawdata));
 208          $this->assertArrayHasKey($student1->id . '#3', $table->rawdata);
 209          $this->assertEquals(1, $table->rawdata[$student1->id . '#3']->gradedattempt);
 210          $this->assertArrayHasKey($student1->id . '#3', $table->rawdata);
 211          $this->assertEquals(0, $table->rawdata[$student1->id . '#5']->gradedattempt);
 212          $this->assertArrayHasKey($student2->id . '#3', $table->rawdata);
 213          $this->assertEquals(1, $table->rawdata[$student2->id . '#3']->gradedattempt);
 214          $this->assertArrayHasKey($student3->id . '#0', $table->rawdata);
 215          $this->assertEquals(0, $table->rawdata[$student3->id . '#0']->gradedattempt);
 216  
 217          // Check the calculation of averages.
 218          $averagerow = $table->compute_average_row('overallaverage', $studentsjoins);
 219          $this->assertStringContainsString('75.00', $averagerow['sumgrades']);
 220          $this->assertStringContainsString('75.00', $averagerow['qsgrade1']);
 221          if (!$isdownloading) {
 222              $this->assertStringContainsString('(2)', $averagerow['sumgrades']);
 223              $this->assertStringContainsString('(2)', $averagerow['qsgrade1']);
 224          }
 225  
 226          // Ensure that filtering by initial does not break it.
 227          // This involves setting a private properly of the base class, which is
 228          // only really possible using reflection :-(.
 229          $reflectionobject = new \ReflectionObject($table);
 230          while ($parent = $reflectionobject->getParentClass()) {
 231              $reflectionobject = $parent;
 232          }
 233          $prefsproperty = $reflectionobject->getProperty('prefs');
 234          $prefsproperty->setAccessible(true);
 235          $prefs = $prefsproperty->getValue($table);
 236          $prefs['i_first'] = 'A';
 237          $prefsproperty->setValue($table, $prefs);
 238  
 239          list($fields, $from, $where, $params) = $table->base_sql($studentsjoins);
 240          $table->set_count_sql("SELECT COUNT(1) FROM (SELECT $fields FROM $from WHERE $where) temp WHERE 1 = 1", $params);
 241          $table->set_sql($fields, $from, $where, $params);
 242          $table->query_db(30, false);
 243          // Just verify that this does not cause a fatal error.
 244      }
 245  
 246      /**
 247       * Bands provider.
 248       * @return array
 249       */
 250      public function get_bands_count_and_width_provider(): array {
 251          return [
 252              [10, [20, .5]],
 253              [20, [20, 1]],
 254              [30, [15, 2]],
 255              // TODO MDL-55068 Handle bands better when grade is 50.
 256              // [50, [10, 5]],
 257              [100, [20, 5]],
 258              [200, [20, 10]],
 259          ];
 260      }
 261  
 262      /**
 263       * Test bands.
 264       *
 265       * @dataProvider get_bands_count_and_width_provider
 266       * @param int $grade grade
 267       * @param array $expected
 268       */
 269      public function test_get_bands_count_and_width(int $grade, array $expected): void {
 270          $this->resetAfterTest();
 271          $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz');
 272          $quiz = $quizgenerator->create_instance(['course' => SITEID, 'grade' => $grade]);
 273          $this->assertEquals($expected, quiz_overview_report::get_bands_count_and_width($quiz));
 274      }
 275  
 276      /**
 277       * Test delete_selected_attempts function.
 278       */
 279      public function test_delete_selected_attempts(): void {
 280          $this->resetAfterTest();
 281  
 282          $timestamp = 1234567890;
 283          $timestart = $timestamp + 3600;
 284  
 285          // Create a course and a quiz.
 286          $generator = $this->getDataGenerator();
 287          $course = $generator->create_course();
 288          $quizgenerator = $generator->get_plugin_generator('mod_quiz');
 289          $quiz = $quizgenerator->create_instance([
 290                  'course' => $course->id,
 291                  'grademethod' => QUIZ_GRADEHIGHEST,
 292                  'grade' => 100.0,
 293                  'sumgrades' => 10.0,
 294                  'attempts' => 10
 295          ]);
 296  
 297          // Add one question.
 298          /** @var core_question_generator $questiongenerator */
 299          $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
 300          $cat = $questiongenerator->create_question_category();
 301          $q = $questiongenerator->create_question('essay', 'plain', ['category' => $cat->id]);
 302          quiz_add_quiz_question($q->id, $quiz, 0 , 10);
 303  
 304          // Create student and enrol them in the course.
 305          // Note: we create two enrolments, to test the problem reported in MDL-67942.
 306          $student = $generator->create_user();
 307          $generator->enrol_user($student->id, $course->id);
 308          $generator->enrol_user($student->id, $course->id, null, 'self');
 309  
 310          $context = \context_module::instance($quiz->cmid);
 311          $cm = get_coursemodule_from_id('quiz', $quiz->cmid);
 312          $allowedjoins = get_enrolled_with_capabilities_join($context, '', ['mod/quiz:attempt', 'mod/quiz:reviewmyattempts']);
 313          $quizattemptsreport = new \testable_quiz_attempts_report();
 314  
 315          // Create the new attempt and initialize the question sessions.
 316          $quizobj = quiz::create($quiz->id, $student->id);
 317          $quba = question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context());
 318          $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour);
 319          $attempt = quiz_create_attempt($quizobj, 1, null, $timestart, false, $student->id);
 320          $attempt = quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timestamp);
 321          $attempt = quiz_attempt_save_started($quizobj, $quba, $attempt);
 322  
 323          // Delete the student's attempt.
 324          $quizattemptsreport->delete_selected_attempts($quiz, $cm, [$attempt->id], $allowedjoins);
 325      }
 326  
 327      /**
 328       * Test question regrade for selected versions.
 329       *
 330       * @covers ::regrade_question
 331       */
 332      public function test_regrade_question() {
 333          global $DB;
 334          $this->resetAfterTest();
 335          $this->setAdminUser();
 336  
 337          $course = $this->getDataGenerator()->create_course();
 338          $quiz = $this->create_test_quiz($course);
 339          $cm = get_fast_modinfo($course->id)->get_cm($quiz->cmid);
 340          $context = \context_module::instance($quiz->cmid);
 341  
 342          /** @var core_question_generator $questiongenerator */
 343          $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
 344          // Create a couple of questions.
 345          $cat = $questiongenerator->create_question_category(['contextid' => $context->id]);
 346          $q = $questiongenerator->create_question('shortanswer', null,
 347                  ['category' => $cat->id, 'name' => 'Toad scores 0.8']);
 348  
 349          // Create a version, the last one draft.
 350          // Sadly, update_question is a bit dodgy, so it can't handle updating the answer score.
 351          $q2 = $questiongenerator->update_question($q, null,
 352                  ['name' => 'Toad now scores 1.0']);
 353          $toadanswer = $DB->get_record_select('question_answers',
 354                  'question = ? AND ' . $DB->sql_compare_text('answer') . ' = ?',
 355                  [$q2->id, 'toad'], '*', MUST_EXIST);
 356          $DB->set_field('question_answers', 'fraction', 1, ['id' => $toadanswer->id]);
 357  
 358          // Add the question to the quiz.
 359          quiz_add_quiz_question($q2->id, $quiz, 0, 10);
 360  
 361          // Attempt the quiz, submitting response 'toad'.
 362          $quizobj = quiz::create($quiz->id);
 363          $attempt = quiz_prepare_and_start_new_attempt($quizobj, 1, null);
 364          $attemptobj = quiz_attempt::create($attempt->id);
 365          $attemptobj->process_submitted_actions(time(), false, [1 => ['answer' => 'toad']]);
 366          $attemptobj->process_finish(time(), false);
 367  
 368          // We should be using 'always latest' version, which is currently v2, so should be right.
 369          $this->assertEquals(10, $attemptobj->get_question_usage()->get_total_mark());
 370  
 371          // Now change the quiz to use fixed version 1.
 372          $slot = $quizobj->get_question($q2->id);
 373          submit_question_version::execute($slot->slotid, 1);
 374  
 375          // Regrade.
 376          $report = new quiz_overview_report();
 377          $report->init('overview', 'quiz_overview_settings_form', $quiz, $cm, $course);
 378          $report->regrade_attempt($attempt);
 379  
 380          // The mark should now be 8.
 381          $attemptobj = quiz_attempt::create($attempt->id);
 382          $this->assertEquals(8, $attemptobj->get_question_usage()->get_total_mark());
 383  
 384          // Now add two more versions, the second of which is draft.
 385          $q3 = $questiongenerator->update_question($q, null,
 386                  ['name' => 'Toad now scores 0.5']);
 387          $toadanswer = $DB->get_record_select('question_answers',
 388                  'question = ? AND ' . $DB->sql_compare_text('answer') . ' = ?',
 389                  [$q3->id, 'toad'], '*', MUST_EXIST);
 390          $DB->set_field('question_answers', 'fraction', 0.5, ['id' => $toadanswer->id]);
 391  
 392          $q4 = $questiongenerator->update_question($q, null,
 393                  ['name' => 'Toad now scores 0.3',
 394                      'status' => question_version_status::QUESTION_STATUS_DRAFT]);
 395          $toadanswer = $DB->get_record_select('question_answers',
 396                  'question = ? AND ' . $DB->sql_compare_text('answer') . ' = ?',
 397                  [$q4->id, 'toad'], '*', MUST_EXIST);
 398          $DB->set_field('question_answers', 'fraction', 0.3, ['id' => $toadanswer->id]);
 399  
 400          // Now change the quiz back to always latest and regrade again.
 401          submit_question_version::execute($slot->slotid, 0);
 402          $report->clear_regrade_date_cache();
 403          $report->regrade_attempt($attempt);
 404  
 405          // Score should now be 5, because v3 is the latest non-draft version.
 406          $attemptobj = quiz_attempt::create($attempt->id);
 407          $this->assertEquals(5, $attemptobj->get_question_usage()->get_total_mark());
 408      }
 409  }