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 400 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  /**
  18   * Quiz statistics report class.
  19   *
  20   * @package   quiz_statistics
  21   * @copyright 2014 Open University
  22   * @author    James Pratt <me@jamiep.org>
  23   * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  24   */
  25  
  26  defined('MOODLE_INTERNAL') || die();
  27  
  28  use core_question\statistics\responses\analyser;
  29  use core_question\statistics\questions\all_calculated_for_qubaid_condition;
  30  
  31  require_once($CFG->dirroot . '/mod/quiz/report/default.php');
  32  require_once($CFG->dirroot . '/mod/quiz/report/reportlib.php');
  33  require_once($CFG->dirroot . '/mod/quiz/report/statistics/statistics_form.php');
  34  require_once($CFG->dirroot . '/mod/quiz/report/statistics/statistics_table.php');
  35  require_once($CFG->dirroot . '/mod/quiz/report/statistics/statistics_question_table.php');
  36  require_once($CFG->dirroot . '/mod/quiz/report/statistics/statisticslib.php');
  37  
  38  /**
  39   * The quiz statistics report provides summary information about each question in
  40   * a quiz, compared to the whole quiz. It also provides a drill-down to more
  41   * detailed information about each question.
  42   *
  43   * @copyright 2008 Jamie Pratt
  44   * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  45   */
  46  class quiz_statistics_report extends quiz_default_report {
  47  
  48      /** @var context_module context of this quiz.*/
  49      protected $context;
  50  
  51      /** @var quiz_statistics_table instance of table class used for main questions stats table. */
  52      protected $table;
  53  
  54      /** @var \core\progress\base|null $progress Handles progress reporting or not. */
  55      protected $progress = null;
  56  
  57      /**
  58       * Display the report.
  59       */
  60      public function display($quiz, $cm, $course) {
  61          global $OUTPUT, $DB;
  62  
  63          raise_memory_limit(MEMORY_HUGE);
  64  
  65          $this->context = context_module::instance($cm->id);
  66  
  67          if (!quiz_has_questions($quiz->id)) {
  68              $this->print_header_and_tabs($cm, $course, $quiz, 'statistics');
  69              echo quiz_no_questions_message($quiz, $cm, $this->context);
  70              return true;
  71          }
  72  
  73          // Work out the display options.
  74          $download = optional_param('download', '', PARAM_ALPHA);
  75          $everything = optional_param('everything', 0, PARAM_BOOL);
  76          $recalculate = optional_param('recalculate', 0, PARAM_BOOL);
  77          // A qid paramter indicates we should display the detailed analysis of a sub question.
  78          $qid = optional_param('qid', 0, PARAM_INT);
  79          $slot = optional_param('slot', 0, PARAM_INT);
  80          $variantno = optional_param('variant', null, PARAM_INT);
  81          $whichattempts = optional_param('whichattempts', $quiz->grademethod, PARAM_INT);
  82          $whichtries = optional_param('whichtries', question_attempt::LAST_TRY, PARAM_ALPHA);
  83  
  84          $pageoptions = array();
  85          $pageoptions['id'] = $cm->id;
  86          $pageoptions['mode'] = 'statistics';
  87  
  88          $reporturl = new moodle_url('/mod/quiz/report.php', $pageoptions);
  89  
  90          $mform = new quiz_statistics_settings_form($reporturl, compact('quiz'));
  91  
  92          $mform->set_data(array('whichattempts' => $whichattempts, 'whichtries' => $whichtries));
  93  
  94          if ($whichattempts != $quiz->grademethod) {
  95              $reporturl->param('whichattempts', $whichattempts);
  96          }
  97  
  98          if ($whichtries != question_attempt::LAST_TRY) {
  99              $reporturl->param('whichtries', $whichtries);
 100          }
 101  
 102          // Find out current groups mode.
 103          $currentgroup = $this->get_current_group($cm, $course, $this->context);
 104          $nostudentsingroup = false; // True if a group is selected and there is no one in it.
 105          if (empty($currentgroup)) {
 106              $currentgroup = 0;
 107              $groupstudentsjoins = new \core\dml\sql_join();
 108  
 109          } else if ($currentgroup == self::NO_GROUPS_ALLOWED) {
 110              $groupstudentsjoins = new \core\dml\sql_join();
 111              $nostudentsingroup = true;
 112  
 113          } else {
 114              // All users who can attempt quizzes and who are in the currently selected group.
 115              $groupstudentsjoins = get_enrolled_with_capabilities_join($this->context, '',
 116                      array('mod/quiz:reviewmyattempts', 'mod/quiz:attempt'), $currentgroup);
 117              if (!empty($groupstudentsjoins->joins)) {
 118                  $sql = "SELECT DISTINCT u.id
 119                      FROM {user} u
 120                      {$groupstudentsjoins->joins}
 121                      WHERE {$groupstudentsjoins->wheres}";
 122                  if (!$DB->record_exists_sql($sql, $groupstudentsjoins->params)) {
 123                      $nostudentsingroup = true;
 124                  }
 125              }
 126          }
 127  
 128          $qubaids = quiz_statistics_qubaids_condition($quiz->id, $groupstudentsjoins, $whichattempts);
 129  
 130          // If recalculate was requested, handle that.
 131          if ($recalculate && confirm_sesskey()) {
 132              $this->clear_cached_data($qubaids);
 133              redirect($reporturl);
 134          }
 135  
 136          // Set up the main table.
 137          $this->table = new quiz_statistics_table();
 138          if ($everything) {
 139              $report = get_string('completestatsfilename', 'quiz_statistics');
 140          } else {
 141              $report = get_string('questionstatsfilename', 'quiz_statistics');
 142          }
 143          $courseshortname = format_string($course->shortname, true,
 144                  array('context' => context_course::instance($course->id)));
 145          $filename = quiz_report_download_filename($report, $courseshortname, $quiz->name);
 146          $this->table->is_downloading($download, $filename,
 147                  get_string('quizstructureanalysis', 'quiz_statistics'));
 148          $questions = $this->load_and_initialise_questions_for_calculations($quiz);
 149  
 150          // Print the page header stuff (if not downloading.
 151          if (!$this->table->is_downloading()) {
 152              $this->print_header_and_tabs($cm, $course, $quiz, 'statistics');
 153          }
 154  
 155          if (!$nostudentsingroup) {
 156              // Get the data to be displayed.
 157              $progress = $this->get_progress_trace_instance();
 158              list($quizstats, $questionstats) =
 159                  $this->get_all_stats_and_analysis($quiz, $whichattempts, $whichtries, $groupstudentsjoins, $questions, $progress);
 160              if (is_null($quizstats)) {
 161                  echo $OUTPUT->notification(get_string('nostats', 'quiz_statistics'), 'error');
 162                  return true;
 163              }
 164          } else {
 165              // Or create empty stats containers.
 166              $quizstats = new \quiz_statistics\calculated($whichattempts);
 167              $questionstats = new \core_question\statistics\questions\all_calculated_for_qubaid_condition();
 168          }
 169  
 170          // Set up the table.
 171          $this->table->statistics_setup($quiz, $cm->id, $reporturl, $quizstats->s());
 172  
 173          // Print the rest of the page header stuff (if not downloading.
 174          if (!$this->table->is_downloading()) {
 175  
 176              if (groups_get_activity_groupmode($cm)) {
 177                  groups_print_activity_menu($cm, $reporturl->out());
 178                  if ($currentgroup && $nostudentsingroup) {
 179                      $OUTPUT->notification(get_string('nostudentsingroup', 'quiz_statistics'));
 180                  }
 181              }
 182  
 183              if (!$this->table->is_downloading() && $quizstats->s() == 0) {
 184                  echo $OUTPUT->notification(get_string('nogradedattempts', 'quiz_statistics'));
 185              }
 186  
 187              foreach ($questionstats->any_error_messages() as $errormessage) {
 188                  echo $OUTPUT->notification($errormessage);
 189              }
 190  
 191              // Print display options form.
 192              $mform->display();
 193          }
 194  
 195          if ($everything) { // Implies is downloading.
 196              // Overall report, then the analysis of each question.
 197              $quizinfo = $quizstats->get_formatted_quiz_info_data($course, $cm, $quiz);
 198              $this->download_quiz_info_table($quizinfo);
 199  
 200              if ($quizstats->s()) {
 201                  $this->output_quiz_structure_analysis_table($questionstats);
 202  
 203                  if ($this->table->is_downloading() == 'html' && $quizstats->s() != 0) {
 204                      $this->output_statistics_graph($quiz->id, $qubaids);
 205                  }
 206  
 207                  $this->output_all_question_response_analysis($qubaids, $questions, $questionstats, $reporturl, $whichtries);
 208              }
 209  
 210              $this->table->export_class_instance()->finish_document();
 211  
 212          } else if ($qid) {
 213              // Report on an individual sub-question indexed questionid.
 214              if (!$questionstats->has_subq($qid, $variantno)) {
 215                  throw new \moodle_exception('questiondoesnotexist', 'question');
 216              }
 217  
 218              $this->output_individual_question_data($quiz, $questionstats->for_subq($qid, $variantno));
 219              $this->output_individual_question_response_analysis($questionstats->for_subq($qid, $variantno)->question,
 220                                                                  $variantno,
 221                                                                  $questionstats->for_subq($qid, $variantno)->s,
 222                                                                  $reporturl,
 223                                                                  $qubaids,
 224                                                                  $whichtries);
 225              // Back to overview link.
 226              echo $OUTPUT->box('<a href="' . $reporturl->out() . '">' .
 227                                get_string('backtoquizreport', 'quiz_statistics') . '</a>',
 228                                'boxaligncenter generalbox boxwidthnormal mdl-align');
 229          } else if ($slot) {
 230              // Report on an individual question indexed by position.
 231              if (!isset($questions[$slot])) {
 232                  throw new \moodle_exception('questiondoesnotexist', 'question');
 233              }
 234  
 235              if ($variantno === null &&
 236                                  ($questionstats->for_slot($slot)->get_sub_question_ids()
 237                                  || $questionstats->for_slot($slot)->get_variants())) {
 238                  if (!$this->table->is_downloading()) {
 239                      $number = $questionstats->for_slot($slot)->question->number;
 240                      echo $OUTPUT->heading(get_string('slotstructureanalysis', 'quiz_statistics', $number), 3);
 241                  }
 242                  $this->table->define_baseurl(new moodle_url($reporturl, array('slot' => $slot)));
 243                  $this->table->format_and_add_array_of_rows($questionstats->structure_analysis_for_one_slot($slot));
 244              } else {
 245                  $this->output_individual_question_data($quiz, $questionstats->for_slot($slot, $variantno));
 246                  $this->output_individual_question_response_analysis($questions[$slot],
 247                                                                      $variantno,
 248                                                                      $questionstats->for_slot($slot, $variantno)->s,
 249                                                                      $reporturl,
 250                                                                      $qubaids,
 251                                                                      $whichtries);
 252              }
 253              if (!$this->table->is_downloading()) {
 254                  // Back to overview link.
 255                  echo $OUTPUT->box('<a href="' . $reporturl->out() . '">' .
 256                          get_string('backtoquizreport', 'quiz_statistics') . '</a>',
 257                          'backtomainstats boxaligncenter generalbox boxwidthnormal mdl-align');
 258              } else {
 259                  $this->table->finish_output();
 260              }
 261  
 262          } else if ($this->table->is_downloading()) {
 263              // Downloading overview report.
 264              $quizinfo = $quizstats->get_formatted_quiz_info_data($course, $cm, $quiz);
 265              $this->download_quiz_info_table($quizinfo);
 266              if ($quizstats->s()) {
 267                  $this->output_quiz_structure_analysis_table($questionstats);
 268              }
 269              $this->table->export_class_instance()->finish_document();
 270  
 271          } else {
 272              // On-screen display of overview report.
 273              echo $OUTPUT->heading(get_string('quizinformation', 'quiz_statistics'), 3);
 274              echo $this->output_caching_info($quizstats->timemodified, $quiz->id, $groupstudentsjoins, $whichattempts, $reporturl);
 275              echo $this->everything_download_options($reporturl);
 276              $quizinfo = $quizstats->get_formatted_quiz_info_data($course, $cm, $quiz);
 277              echo $this->output_quiz_info_table($quizinfo);
 278              if ($quizstats->s()) {
 279                  echo $OUTPUT->heading(get_string('quizstructureanalysis', 'quiz_statistics'), 3);
 280                  $this->output_quiz_structure_analysis_table($questionstats);
 281                  $this->output_statistics_graph($quiz, $qubaids);
 282              }
 283          }
 284  
 285          return true;
 286      }
 287  
 288      /**
 289       * Display the statistical and introductory information about a question.
 290       * Only called when not downloading.
 291       *
 292       * @param object                                         $quiz         the quiz settings.
 293       * @param \core_question\statistics\questions\calculated $questionstat the question to report on.
 294       */
 295      protected function output_individual_question_data($quiz, $questionstat) {
 296          global $OUTPUT;
 297  
 298          // On-screen display. Show a summary of the question's place in the quiz,
 299          // and the question statistics.
 300          $datumfromtable = $this->table->format_row($questionstat);
 301  
 302          // Set up the question info table.
 303          $questioninfotable = new html_table();
 304          $questioninfotable->align = array('center', 'center');
 305          $questioninfotable->width = '60%';
 306          $questioninfotable->attributes['class'] = 'generaltable titlesleft';
 307  
 308          $questioninfotable->data = array();
 309          $questioninfotable->data[] = array(get_string('modulename', 'quiz'), $quiz->name);
 310          $questioninfotable->data[] = array(get_string('questionname', 'quiz_statistics'),
 311                  $questionstat->question->name.'&nbsp;'.$datumfromtable['actions']);
 312  
 313          if ($questionstat->variant !== null) {
 314              $questioninfotable->data[] = array(get_string('variant', 'quiz_statistics'), $questionstat->variant);
 315  
 316          }
 317          $questioninfotable->data[] = array(get_string('questiontype', 'quiz_statistics'),
 318                  $datumfromtable['icon'] . '&nbsp;' .
 319                  question_bank::get_qtype($questionstat->question->qtype, false)->menu_name() . '&nbsp;' .
 320                  $datumfromtable['icon']);
 321          $questioninfotable->data[] = array(get_string('positions', 'quiz_statistics'),
 322                  $questionstat->positions);
 323  
 324          // Set up the question statistics table.
 325          $questionstatstable = new html_table();
 326          $questionstatstable->align = array('center', 'center');
 327          $questionstatstable->width = '60%';
 328          $questionstatstable->attributes['class'] = 'generaltable titlesleft';
 329  
 330          unset($datumfromtable['number']);
 331          unset($datumfromtable['icon']);
 332          $actions = $datumfromtable['actions'];
 333          unset($datumfromtable['actions']);
 334          unset($datumfromtable['name']);
 335          $labels = array(
 336              's' => get_string('attempts', 'quiz_statistics'),
 337              'facility' => get_string('facility', 'quiz_statistics'),
 338              'sd' => get_string('standarddeviationq', 'quiz_statistics'),
 339              'random_guess_score' => get_string('random_guess_score', 'quiz_statistics'),
 340              'intended_weight' => get_string('intended_weight', 'quiz_statistics'),
 341              'effective_weight' => get_string('effective_weight', 'quiz_statistics'),
 342              'discrimination_index' => get_string('discrimination_index', 'quiz_statistics'),
 343              'discriminative_efficiency' =>
 344                                  get_string('discriminative_efficiency', 'quiz_statistics')
 345          );
 346          foreach ($datumfromtable as $item => $value) {
 347              $questionstatstable->data[] = array($labels[$item], $value);
 348          }
 349  
 350          // Display the various bits.
 351          echo $OUTPUT->heading(get_string('questioninformation', 'quiz_statistics'), 3);
 352          echo html_writer::table($questioninfotable);
 353          echo $this->render_question_text($questionstat->question);
 354          echo $OUTPUT->heading(get_string('questionstatistics', 'quiz_statistics'), 3);
 355          echo html_writer::table($questionstatstable);
 356      }
 357  
 358      /**
 359       * Output question text in a box with urls appropriate for a preview of the question.
 360       *
 361       * @param object $question question data.
 362       * @return string HTML of question text, ready for display.
 363       */
 364      protected function render_question_text($question) {
 365          global $OUTPUT;
 366  
 367          $text = question_rewrite_question_preview_urls($question->questiontext, $question->id,
 368                  $question->contextid, 'question', 'questiontext', $question->id,
 369                  $this->context->id, 'quiz_statistics');
 370  
 371          return $OUTPUT->box(format_text($text, $question->questiontextformat,
 372                  array('noclean' => true, 'para' => false, 'overflowdiv' => true)),
 373                  'questiontext boxaligncenter generalbox boxwidthnormal mdl-align');
 374      }
 375  
 376      /**
 377       * Display the response analysis for a question.
 378       *
 379       * @param object           $question  the question to report on.
 380       * @param int|null         $variantno the variant
 381       * @param int              $s
 382       * @param moodle_url       $reporturl the URL to redisplay this report.
 383       * @param qubaid_condition $qubaids
 384       * @param string           $whichtries
 385       */
 386      protected function output_individual_question_response_analysis($question, $variantno, $s, $reporturl, $qubaids,
 387                                                                      $whichtries = question_attempt::LAST_TRY) {
 388          global $OUTPUT;
 389  
 390          if (!question_bank::get_qtype($question->qtype, false)->can_analyse_responses()) {
 391              return;
 392          }
 393  
 394          $qtable = new quiz_statistics_question_table($question->id);
 395          $exportclass = $this->table->export_class_instance();
 396          $qtable->export_class_instance($exportclass);
 397          if (!$this->table->is_downloading()) {
 398              // Output an appropriate title.
 399              echo $OUTPUT->heading(get_string('analysisofresponses', 'quiz_statistics'), 3);
 400  
 401          } else {
 402              // Work out an appropriate title.
 403              $a = clone($question);
 404              $a->variant = $variantno;
 405  
 406              if (!empty($question->number) && !is_null($variantno)) {
 407                  $questiontabletitle = get_string('analysisnovariant', 'quiz_statistics', $a);
 408              } else if (!empty($question->number)) {
 409                  $questiontabletitle = get_string('analysisno', 'quiz_statistics', $a);
 410              } else if (!is_null($variantno)) {
 411                  $questiontabletitle = get_string('analysisvariant', 'quiz_statistics', $a);
 412              } else {
 413                  $questiontabletitle = get_string('analysisnameonly', 'quiz_statistics', $a);
 414              }
 415  
 416              if ($this->table->is_downloading() == 'html') {
 417                  $questiontabletitle = get_string('analysisofresponsesfor', 'quiz_statistics', $questiontabletitle);
 418              }
 419  
 420              // Set up the table.
 421              $exportclass->start_table($questiontabletitle);
 422  
 423              if ($this->table->is_downloading() == 'html') {
 424                  echo $this->render_question_text($question);
 425              }
 426          }
 427  
 428          $responesanalyser = new analyser($question, $whichtries);
 429          $responseanalysis = $responesanalyser->load_cached($qubaids, $whichtries);
 430  
 431          $qtable->question_setup($reporturl, $question, $s, $responseanalysis);
 432          if ($this->table->is_downloading()) {
 433              $exportclass->output_headers($qtable->headers);
 434          }
 435  
 436          // Where no variant no is specified the variant no is actually one.
 437          if ($variantno === null) {
 438              $variantno = 1;
 439          }
 440          foreach ($responseanalysis->get_subpart_ids($variantno) as $partid) {
 441              $subpart = $responseanalysis->get_analysis_for_subpart($variantno, $partid);
 442              foreach ($subpart->get_response_class_ids() as $responseclassid) {
 443                  $responseclass = $subpart->get_response_class($responseclassid);
 444                  $tabledata = $responseclass->data_for_question_response_table($subpart->has_multiple_response_classes(), $partid);
 445                  foreach ($tabledata as $row) {
 446                      $qtable->add_data_keyed($qtable->format_row($row));
 447                  }
 448              }
 449          }
 450  
 451          $qtable->finish_output(!$this->table->is_downloading());
 452      }
 453  
 454      /**
 455       * Output the table that lists all the questions in the quiz with their statistics.
 456       *
 457       * @param \core_question\statistics\questions\all_calculated_for_qubaid_condition $questionstats the stats for all questions in
 458       *                                                                                               the quiz including subqs and
 459       *                                                                                               variants.
 460       */
 461      protected function output_quiz_structure_analysis_table($questionstats) {
 462          $limitvariants = !$this->table->is_downloading();
 463          foreach ($questionstats->get_all_slots() as $slot) {
 464              // Output the data for these question statistics.
 465              $structureanalysis = $questionstats->structure_analysis_for_one_slot($slot, $limitvariants);
 466              if (is_null($structureanalysis)) {
 467                  $this->table->add_separator();
 468              } else {
 469                  foreach ($structureanalysis as $row) {
 470                      $bgcssclass = '';
 471                      // The only way to identify in this point of the report if a row is a summary row
 472                      // is checking if it's a instance of calculated_question_summary class.
 473                      if ($row instanceof \core_question\statistics\questions\calculated_question_summary) {
 474                          // Apply a custom css class to summary row to remove border and reduce paddings.
 475                          $bgcssclass = 'quiz_statistics-summaryrow';
 476  
 477                          // For question that contain a summary row, we add a "hidden" row in between so the report
 478                          // display both rows with same background color.
 479                          $this->table->add_data_keyed([], 'd-none hidden');
 480                      }
 481  
 482                      $this->table->add_data_keyed($this->table->format_row($row), $bgcssclass);
 483                  }
 484              }
 485          }
 486  
 487          $this->table->finish_output(!$this->table->is_downloading());
 488      }
 489  
 490      /**
 491       * Return HTML for table of overall quiz statistics.
 492       *
 493       * @param array $quizinfo as returned by {@link get_formatted_quiz_info_data()}.
 494       * @return string the HTML.
 495       */
 496      protected function output_quiz_info_table($quizinfo) {
 497  
 498          $quizinfotable = new html_table();
 499          $quizinfotable->align = array('center', 'center');
 500          $quizinfotable->width = '60%';
 501          $quizinfotable->attributes['class'] = 'generaltable titlesleft';
 502          $quizinfotable->data = array();
 503  
 504          foreach ($quizinfo as $heading => $value) {
 505               $quizinfotable->data[] = array($heading, $value);
 506          }
 507  
 508          return html_writer::table($quizinfotable);
 509      }
 510  
 511      /**
 512       * Download the table of overall quiz statistics.
 513       *
 514       * @param array $quizinfo as returned by {@link get_formatted_quiz_info_data()}.
 515       */
 516      protected function download_quiz_info_table($quizinfo) {
 517          global $OUTPUT;
 518  
 519          // HTML download is a special case.
 520          if ($this->table->is_downloading() == 'html') {
 521              echo $OUTPUT->heading(get_string('quizinformation', 'quiz_statistics'), 3);
 522              echo $this->output_quiz_info_table($quizinfo);
 523              return;
 524          }
 525  
 526          // Reformat the data ready for output.
 527          $headers = array();
 528          $row = array();
 529          foreach ($quizinfo as $heading => $value) {
 530              $headers[] = $heading;
 531              $row[] = $value;
 532          }
 533  
 534          // Do the output.
 535          $exportclass = $this->table->export_class_instance();
 536          $exportclass->start_table(get_string('quizinformation', 'quiz_statistics'));
 537          $exportclass->output_headers($headers);
 538          $exportclass->add_data($row);
 539          $exportclass->finish_table();
 540      }
 541  
 542      /**
 543       * Output the HTML needed to show the statistics graph.
 544       *
 545       * @param int|object $quizorid The quiz, or its ID.
 546       * @param qubaid_condition $qubaids the question usages whose responses to analyse.
 547       * @param string $whichattempts Which attempts constant.
 548       */
 549      protected function output_statistics_graph($quizorid, $qubaids) {
 550          global $DB, $PAGE;
 551  
 552          $quiz = $quizorid;
 553          if (!is_object($quiz)) {
 554              $quiz = $DB->get_record('quiz', array('id' => $quizorid), '*', MUST_EXIST);
 555          }
 556  
 557          // Load the rest of the required data.
 558          $questions = quiz_report_get_significant_questions($quiz);
 559  
 560          // Only load main question not sub questions.
 561          $questionstatistics = $DB->get_records_select('question_statistics',
 562                  'hashcode = ? AND slot IS NOT NULL AND variant IS NULL',
 563              [$qubaids->get_hash_code()]);
 564  
 565          // Configure what to display.
 566          $fieldstoplot = [
 567              'facility' => get_string('facility', 'quiz_statistics'),
 568              'discriminativeefficiency' => get_string('discriminative_efficiency', 'quiz_statistics')
 569          ];
 570          $fieldstoplotfactor = ['facility' => 100, 'discriminativeefficiency' => 1];
 571  
 572          // Prepare the arrays to hold the data.
 573          $xdata = [];
 574          foreach (array_keys($fieldstoplot) as $fieldtoplot) {
 575              $ydata[$fieldtoplot] = [];
 576          }
 577  
 578          // Fill in the data for each question.
 579          foreach ($questionstatistics as $questionstatistic) {
 580              $number = $questions[$questionstatistic->slot]->number;
 581              $xdata[$number] = $number;
 582  
 583              foreach ($fieldstoplot as $fieldtoplot => $notused) {
 584                  $value = $questionstatistic->$fieldtoplot;
 585                  if (is_null($value)) {
 586                      $value = 0;
 587                  }
 588                  $value *= $fieldstoplotfactor[$fieldtoplot];
 589                  $ydata[$fieldtoplot][$number] = number_format($value, 2);
 590              }
 591          }
 592  
 593          // Create the chart.
 594          sort($xdata);
 595          $chart = new \core\chart_bar();
 596          $chart->get_xaxis(0, true)->set_label(get_string('position', 'quiz_statistics'));
 597          $chart->set_labels(array_values($xdata));
 598  
 599          foreach ($fieldstoplot as $fieldtoplot => $notused) {
 600              ksort($ydata[$fieldtoplot]);
 601              $series = new \core\chart_series($fieldstoplot[$fieldtoplot], array_values($ydata[$fieldtoplot]));
 602              $chart->add_series($series);
 603          }
 604  
 605          // Find max.
 606          $max = 0;
 607          foreach ($fieldstoplot as $fieldtoplot => $notused) {
 608              $max = max($max, max($ydata[$fieldtoplot]));
 609          }
 610  
 611          // Set Y properties.
 612          $yaxis = $chart->get_yaxis(0, true);
 613          $yaxis->set_stepsize(10);
 614          $yaxis->set_label('%');
 615  
 616          $output = $PAGE->get_renderer('mod_quiz');
 617          $graphname = get_string('statisticsreportgraph', 'quiz_statistics');
 618          echo $output->chart($chart, $graphname);
 619      }
 620  
 621      /**
 622       * Get the quiz and question statistics, either by loading the cached results,
 623       * or by recomputing them.
 624       *
 625       * @param object $quiz               the quiz settings.
 626       * @param string $whichattempts      which attempts to use, represented internally as one of the constants as used in
 627       *                                   $quiz->grademethod ie.
 628       *                                   QUIZ_GRADEAVERAGE, QUIZ_GRADEHIGHEST, QUIZ_ATTEMPTLAST or QUIZ_ATTEMPTFIRST
 629       *                                   we calculate stats based on which attempts would affect the grade for each student.
 630       * @param string $whichtries         which tries to analyse for response analysis. Will be one of
 631       *                                   question_attempt::FIRST_TRY, LAST_TRY or ALL_TRIES.
 632       * @param \core\dml\sql_join $groupstudentsjoins Contains joins, wheres, params for students in this group.
 633       * @param array  $questions          full question data.
 634       * @param \core\progress\base|null   $progress
 635       * @param bool $calculateifrequired  if true (the default) the stats will be calculated if not already stored.
 636       *                                   If false, [null, null] will be returned if the stats are not already available.
 637       * @param bool $performanalysis      if true (the default) and there are calculated stats, analysis will be performed
 638       *                                   for each question.
 639       * @return array with 2 elements:    - $quizstats The statistics for overall attempt scores.
 640       *                                   - $questionstats \core_question\statistics\questions\all_calculated_for_qubaid_condition
 641       *                                   Both may be null, if $calculateifrequired is false.
 642       */
 643      public function get_all_stats_and_analysis(
 644              $quiz, $whichattempts, $whichtries, \core\dml\sql_join $groupstudentsjoins,
 645              $questions, $progress = null, bool $calculateifrequired = true, bool $performanalysis = true) {
 646  
 647          if ($progress === null) {
 648              $progress = new \core\progress\none();
 649          }
 650  
 651          $qubaids = quiz_statistics_qubaids_condition($quiz->id, $groupstudentsjoins, $whichattempts);
 652  
 653          $qcalc = new \core_question\statistics\questions\calculator($questions, $progress);
 654  
 655          $quizcalc = new \quiz_statistics\calculator($progress);
 656  
 657          $progress->start_progress('', 4);
 658  
 659          // Get a lock on this set of qubaids before performing calculations. This prevents the same calculation running
 660          // concurrently and causing database deadlocks. We use a long timeout here as a big quiz with lots of attempts may
 661          // take a long time to process.
 662          $lockfactory = \core\lock\lock_config::get_lock_factory('quiz_statistics_get_stats');
 663          $lock = $lockfactory->get_lock($qubaids->get_hash_code(), 0);
 664          if (!$lock) {
 665              if (!$calculateifrequired) {
 666                  // We're not going to do the calculation in this request anyway, so just give up here.
 667                  $progress->progress(4);
 668                  $progress->end_progress();
 669                  return [null, null];
 670              }
 671              $locktimeout = get_config('quiz_statistics', 'getstatslocktimeout');
 672              $lock = \core\lock\lock_utils::wait_for_lock_with_progress(
 673                  $lockfactory,
 674                  $qubaids->get_hash_code(),
 675                  $progress,
 676                  $locktimeout,
 677                  get_string('getstatslockprogress', 'quiz_statistics'),
 678              );
 679              if (!$lock) {
 680                  // Lock attempt timed out.
 681                  $progress->progress(4);
 682                  $progress->end_progress();
 683                  debugging('Could not get lock on ' .
 684                          $qubaids->get_hash_code() . ' (Quiz ID ' . $quiz->id . ') after ' .
 685                          $locktimeout . ' seconds');
 686                  return [null, null];
 687              }
 688          }
 689  
 690          try {
 691              if ($quizcalc->get_last_calculated_time($qubaids) === false) {
 692                  if (!$calculateifrequired) {
 693                      $progress->progress(4);
 694                      $progress->end_progress();
 695                      $lock->release();
 696                      return [null, null];
 697                  }
 698  
 699                  // Recalculate now.
 700                  $questionstats = $qcalc->calculate($qubaids);
 701                  $progress->progress(2);
 702  
 703                  $quizstats = $quizcalc->calculate(
 704                      $quiz->id,
 705                      $whichattempts,
 706                      $groupstudentsjoins,
 707                      count($questions),
 708                      $qcalc->get_sum_of_mark_variance()
 709                  );
 710                  $progress->progress(3);
 711              } else {
 712                  $quizstats = $quizcalc->get_cached($qubaids);
 713                  $progress->progress(2);
 714                  $questionstats = $qcalc->get_cached($qubaids);
 715                  $progress->progress(3);
 716              }
 717  
 718              if ($quizstats->s() && $performanalysis) {
 719                  $subquestions = $questionstats->get_sub_questions();
 720                  $this->analyse_responses_for_all_questions_and_subquestions(
 721                      $questions,
 722                      $subquestions,
 723                      $qubaids,
 724                      $whichtries,
 725                      $progress
 726                  );
 727              }
 728              $progress->progress(4);
 729              $progress->end_progress();
 730          } finally {
 731              $lock->release();
 732          }
 733  
 734          return array($quizstats, $questionstats);
 735      }
 736  
 737      /**
 738       * Appropriate instance depending if we want html output for the user or not.
 739       *
 740       * @return \core\progress\base child of \core\progress\base to handle the display (or not) of task progress.
 741       */
 742      protected function get_progress_trace_instance() {
 743          if ($this->progress === null) {
 744              if (!$this->table->is_downloading()) {
 745                  $this->progress = new \core\progress\display_if_slow(get_string('calculatingallstats', 'quiz_statistics'));
 746                  $this->progress->set_display_names();
 747              } else {
 748                  $this->progress = new \core\progress\none();
 749              }
 750          }
 751          return $this->progress;
 752      }
 753  
 754      /**
 755       * Analyse responses for all questions and sub questions in this quiz.
 756       *
 757       * @param object[] $questions as returned by self::load_and_initialise_questions_for_calculations
 758       * @param object[] $subquestions full question objects.
 759       * @param qubaid_condition $qubaids the question usages whose responses to analyse.
 760       * @param string $whichtries which tries to analyse \question_attempt::FIRST_TRY, LAST_TRY or ALL_TRIES.
 761       * @param null|\core\progress\base $progress Used to indicate progress of task.
 762       */
 763      protected function analyse_responses_for_all_questions_and_subquestions($questions, $subquestions, $qubaids,
 764                                                                              $whichtries, $progress = null) {
 765          if ($progress === null) {
 766              $progress = new \core\progress\none();
 767          }
 768  
 769          // Starting response analysis tasks.
 770          $progress->start_progress('', count($questions) + count($subquestions));
 771  
 772          $done = $this->analyse_responses_for_questions($questions, $qubaids, $whichtries, $progress);
 773  
 774          $this->analyse_responses_for_questions($subquestions, $qubaids, $whichtries, $progress, $done);
 775  
 776          // Finished all response analysis tasks.
 777          $progress->end_progress();
 778      }
 779  
 780      /**
 781       * Analyse responses for an array of questions or sub questions.
 782       *
 783       * @param object[] $questions  as returned by self::load_and_initialise_questions_for_calculations.
 784       * @param qubaid_condition $qubaids the question usages whose responses to analyse.
 785       * @param string $whichtries which tries to analyse \question_attempt::FIRST_TRY, LAST_TRY or ALL_TRIES.
 786       * @param null|\core\progress\base $progress Used to indicate progress of task.
 787       * @param int[] $done array keys are ids of questions that have been analysed before calling method.
 788       * @return array array keys are ids of questions that were analysed after this method call.
 789       */
 790      protected function analyse_responses_for_questions($questions, $qubaids, $whichtries, $progress = null, $done = array()) {
 791          $countquestions = count($questions);
 792          if (!$countquestions) {
 793              return array();
 794          }
 795          if ($progress === null) {
 796              $progress = new \core\progress\none();
 797          }
 798          $progress->start_progress('', $countquestions, $countquestions);
 799          foreach ($questions as $question) {
 800              $progress->increment_progress();
 801              if (question_bank::get_qtype($question->qtype, false)->can_analyse_responses()  && !isset($done[$question->id])) {
 802                  $responesstats = new analyser($question, $whichtries);
 803                  $responesstats->calculate($qubaids, $whichtries);
 804              }
 805              $done[$question->id] = 1;
 806          }
 807          $progress->end_progress();
 808          return $done;
 809      }
 810  
 811      /**
 812       * Return a little form for the user to request to download the full report, including quiz stats and response analysis for
 813       * all questions and sub-questions.
 814       *
 815       * @param moodle_url $reporturl the base URL of the report.
 816       * @return string HTML.
 817       */
 818      protected function everything_download_options(moodle_url $reporturl) {
 819          global $OUTPUT;
 820          return $OUTPUT->download_dataformat_selector(get_string('downloadeverything', 'quiz_statistics'),
 821              $reporturl->out_omit_querystring(), 'download', $reporturl->params() + array('everything' => 1));
 822      }
 823  
 824      /**
 825       * Return HTML for a message that says when the stats were last calculated and a 'recalculate now' button.
 826       *
 827       * @param int    $lastcachetime  the time the stats were last cached.
 828       * @param int    $quizid         the quiz id.
 829       * @param array  $groupstudentsjoins (joins, wheres, params) for students in the group or empty array if groups not used.
 830       * @param string $whichattempts which attempts to use, represented internally as one of the constants as used in
 831       *                                   $quiz->grademethod ie.
 832       *                                   QUIZ_GRADEAVERAGE, QUIZ_GRADEHIGHEST, QUIZ_ATTEMPTLAST or QUIZ_ATTEMPTFIRST
 833       *                                   we calculate stats based on which attempts would affect the grade for each student.
 834       * @param moodle_url $reporturl url for this report
 835       * @return string HTML.
 836       */
 837      protected function output_caching_info($lastcachetime, $quizid, $groupstudentsjoins, $whichattempts, $reporturl) {
 838          global $DB, $OUTPUT;
 839  
 840          if (empty($lastcachetime)) {
 841              return '';
 842          }
 843  
 844          // Find the number of attempts since the cached statistics were computed.
 845          list($fromqa, $whereqa, $qaparams) = quiz_statistics_attempts_sql($quizid, $groupstudentsjoins, $whichattempts, true);
 846          $count = $DB->count_records_sql("
 847                  SELECT COUNT(1)
 848                  FROM $fromqa
 849                  WHERE $whereqa
 850                  AND quiza.timefinish > {$lastcachetime}", $qaparams);
 851  
 852          if (!$count) {
 853              $count = 0;
 854          }
 855  
 856          // Generate the output.
 857          $a = new stdClass();
 858          $a->lastcalculated = format_time(time() - $lastcachetime);
 859          $a->count = $count;
 860  
 861          $recalcualteurl = new moodle_url($reporturl,
 862                  array('recalculate' => 1, 'sesskey' => sesskey()));
 863          $output = '';
 864          $output .= $OUTPUT->box_start(
 865                  'boxaligncenter generalbox boxwidthnormal mdl-align', 'cachingnotice');
 866          $output .= get_string('lastcalculated', 'quiz_statistics', $a);
 867          $output .= $OUTPUT->single_button($recalcualteurl,
 868                  get_string('recalculatenow', 'quiz_statistics'));
 869          $output .= $OUTPUT->box_end(true);
 870  
 871          return $output;
 872      }
 873  
 874      /**
 875       * Clear the cached data for a particular report configuration. This will trigger a re-computation the next time the report
 876       * is displayed.
 877       *
 878       * @param $qubaids qubaid_condition
 879       */
 880      public function clear_cached_data($qubaids) {
 881          global $DB;
 882          $DB->delete_records('quiz_statistics', array('hashcode' => $qubaids->get_hash_code()));
 883          $DB->delete_records('question_statistics', array('hashcode' => $qubaids->get_hash_code()));
 884          $DB->delete_records('question_response_analysis', array('hashcode' => $qubaids->get_hash_code()));
 885      }
 886  
 887      /**
 888       * Load the questions in this quiz and add some properties to the objects needed in the reports.
 889       *
 890       * @param object $quiz the quiz.
 891       * @return array of questions for this quiz.
 892       */
 893      public function load_and_initialise_questions_for_calculations($quiz) {
 894          // Load the questions.
 895          $questions = quiz_report_get_significant_questions($quiz);
 896          $questiondata = [];
 897          foreach ($questions as $qs => $question) {
 898              if ($question->qtype === 'random') {
 899                  $question->id = 0;
 900                  $question->name = get_string('random', 'quiz');
 901                  $question->questiontext = get_string('random', 'quiz');
 902                  $question->parenttype = 'random';
 903                  $questiondata[$question->slot] = $question;
 904              } else if ($question->qtype === 'missingtype') {
 905                  $question->id = is_numeric($question->id) ? (int) $question->id : 0;
 906                  $questiondata[$question->slot] = $question;
 907                  $question->name = get_string('deletedquestion', 'qtype_missingtype');
 908                  $question->questiontext = get_string('deletedquestiontext', 'qtype_missingtype');
 909              } else {
 910                  $q = question_bank::load_question_data($question->id);
 911                  $q->maxmark = $question->maxmark;
 912                  $q->slot = $question->slot;
 913                  $q->number = $question->number;
 914                  $q->parenttype = null;
 915                  $questiondata[$question->slot] = $q;
 916              }
 917          }
 918  
 919          return $questiondata;
 920      }
 921  
 922      /**
 923       * Output all response analysis for all questions, sub-questions and variants. For download in a number of formats.
 924       *
 925       * @param $qubaids
 926       * @param $questions
 927       * @param $questionstats
 928       * @param $reporturl
 929       * @param $whichtries string
 930       */
 931      protected function output_all_question_response_analysis($qubaids,
 932                                                               $questions,
 933                                                               $questionstats,
 934                                                               $reporturl,
 935                                                               $whichtries = question_attempt::LAST_TRY) {
 936          foreach ($questions as $slot => $question) {
 937              if (question_bank::get_qtype(
 938                  $question->qtype, false)->can_analyse_responses()
 939              ) {
 940                  if ($questionstats->for_slot($slot)->get_variants()) {
 941                      foreach ($questionstats->for_slot($slot)->get_variants() as $variantno) {
 942                          $this->output_individual_question_response_analysis($question,
 943                                                                              $variantno,
 944                                                                              $questionstats->for_slot($slot, $variantno)->s,
 945                                                                              $reporturl,
 946                                                                              $qubaids,
 947                                                                              $whichtries);
 948                      }
 949                  } else {
 950                      $this->output_individual_question_response_analysis($question,
 951                                                                          null,
 952                                                                          $questionstats->for_slot($slot)->s,
 953                                                                          $reporturl,
 954                                                                          $qubaids,
 955                                                                          $whichtries);
 956                  }
 957              } else if ($subqids = $questionstats->for_slot($slot)->get_sub_question_ids()) {
 958                  foreach ($subqids as $subqid) {
 959                      if ($variants = $questionstats->for_subq($subqid)->get_variants()) {
 960                          foreach ($variants as $variantno) {
 961                              $this->output_individual_question_response_analysis(
 962                                  $questionstats->for_subq($subqid, $variantno)->question,
 963                                  $variantno,
 964                                  $questionstats->for_subq($subqid, $variantno)->s,
 965                                  $reporturl,
 966                                  $qubaids,
 967                                  $whichtries);
 968                          }
 969                      } else {
 970                          $this->output_individual_question_response_analysis(
 971                              $questionstats->for_subq($subqid)->question,
 972                              null,
 973                              $questionstats->for_subq($subqid)->s,
 974                              $reporturl,
 975                              $qubaids,
 976                              $whichtries);
 977  
 978                      }
 979                  }
 980              }
 981          }
 982      }
 983  
 984      /**
 985       * Load question stats for a quiz
 986       *
 987       * @param int $quizid question usage
 988       * @param bool $calculateifrequired if true (the default) the stats will be calculated if not already stored.
 989       *     If false, null will be returned if the stats are not already available.
 990       * @param bool $performanalysis if true (the default) and there are calculated stats, analysis will be performed
 991       *     for each question.
 992       * @return ?all_calculated_for_qubaid_condition question stats
 993       */
 994      public function calculate_questions_stats_for_question_bank(
 995              int $quizid,
 996              bool $calculateifrequired = true,
 997              bool $performanalysis = true
 998          ): ?all_calculated_for_qubaid_condition {
 999          global $DB;
1000          $quiz = $DB->get_record('quiz', ['id' => $quizid], '*', MUST_EXIST);
1001          $questions = $this->load_and_initialise_questions_for_calculations($quiz);
1002  
1003          [, $questionstats] = $this->get_all_stats_and_analysis($quiz,
1004              $quiz->grademethod, question_attempt::ALL_TRIES, new \core\dml\sql_join(),
1005              $questions, null, $calculateifrequired, $performanalysis);
1006  
1007          return $questionstats;
1008      }
1009  }