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  /**
  18   * Quiz statistics report, table for showing statistics of each question in the quiz.
  19   *
  20   * @package   quiz_statistics
  21   * @copyright 2008 Jamie Pratt
  22   * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  23   */
  24  
  25  defined('MOODLE_INTERNAL') || die();
  26  
  27  require_once($CFG->libdir.'/tablelib.php');
  28  
  29  use \core_question\statistics\questions\calculated_question_summary;
  30  
  31  /**
  32   * This table has one row for each question in the quiz, with sub-rows when
  33   * random questions and variants appear.
  34   *
  35   * There are columns for the various item and position statistics.
  36   *
  37   * @copyright 2008 Jamie Pratt
  38   * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  39   */
  40  class quiz_statistics_table extends flexible_table {
  41      /** @var object the quiz settings. */
  42      protected $quiz;
  43  
  44      /** @var integer the quiz course_module id. */
  45      protected $cmid;
  46  
  47      /**
  48       * Constructor.
  49       */
  50      public function __construct() {
  51          parent::__construct('mod-quiz-report-statistics-report');
  52      }
  53  
  54      /**
  55       * Set up the columns and headers and other properties of the table and then
  56       * call flexible_table::setup() method.
  57       *
  58       * @param object $quiz the quiz settings
  59       * @param int $cmid the quiz course_module id
  60       * @param moodle_url $reporturl the URL to redisplay this report.
  61       * @param int $s number of attempts included in the statistics.
  62       */
  63      public function statistics_setup($quiz, $cmid, $reporturl, $s) {
  64          $this->quiz = $quiz;
  65          $this->cmid = $cmid;
  66  
  67          // Define the table columns.
  68          $columns = array();
  69          $headers = array();
  70  
  71          $columns[] = 'number';
  72          $headers[] = get_string('questionnumber', 'quiz_statistics');
  73  
  74          if (!$this->is_downloading()) {
  75              $columns[] = 'icon';
  76              $headers[] = '';
  77              $columns[] = 'actions';
  78              $headers[] = '';
  79          } else {
  80              $columns[] = 'qtype';
  81              $headers[] = get_string('questiontype', 'quiz_statistics');
  82          }
  83  
  84          $columns[] = 'name';
  85          $headers[] = get_string('questionname', 'quiz');
  86  
  87          $columns[] = 's';
  88          $headers[] = get_string('attempts', 'quiz_statistics');
  89  
  90          if ($s > 1) {
  91              $columns[] = 'facility';
  92              $headers[] = get_string('facility', 'quiz_statistics');
  93  
  94              $columns[] = 'sd';
  95              $headers[] = get_string('standarddeviationq', 'quiz_statistics');
  96          }
  97  
  98          $columns[] = 'random_guess_score';
  99          $headers[] = get_string('random_guess_score', 'quiz_statistics');
 100  
 101          $columns[] = 'intended_weight';
 102          $headers[] = get_string('intended_weight', 'quiz_statistics');
 103  
 104          $columns[] = 'effective_weight';
 105          $headers[] = get_string('effective_weight', 'quiz_statistics');
 106  
 107          $columns[] = 'discrimination_index';
 108          $headers[] = get_string('discrimination_index', 'quiz_statistics');
 109  
 110          $columns[] = 'discriminative_efficiency';
 111          $headers[] = get_string('discriminative_efficiency', 'quiz_statistics');
 112  
 113          $this->define_columns($columns);
 114          $this->define_headers($headers);
 115          $this->sortable(false);
 116  
 117          $this->column_class('s', 'numcol');
 118          $this->column_class('facility', 'numcol');
 119          $this->column_class('sd', 'numcol');
 120          $this->column_class('random_guess_score', 'numcol');
 121          $this->column_class('intended_weight', 'numcol');
 122          $this->column_class('effective_weight', 'numcol');
 123          $this->column_class('discrimination_index', 'numcol');
 124          $this->column_class('discriminative_efficiency', 'numcol');
 125  
 126          // Set up the table.
 127          $this->define_baseurl($reporturl->out());
 128  
 129          $this->collapsible(true);
 130  
 131          $this->set_attribute('id', 'questionstatistics');
 132          $this->set_attribute('class', 'generaltable generalbox boxaligncenter');
 133  
 134          parent::setup();
 135      }
 136  
 137      /**
 138       * Open a div tag to wrap statistics table.
 139       */
 140      public function  wrap_html_start() {
 141          // Horrible Moodle 2.0 wide-content work-around.
 142          if (!$this->is_downloading()) {
 143              echo html_writer::start_tag('div', array('id' => 'tablecontainer',
 144                      'class' => 'statistics-tablecontainer'));
 145          }
 146      }
 147  
 148      /**
 149       * Close a statistics table div.
 150       */
 151      public function wrap_html_finish() {
 152          if (!$this->is_downloading()) {
 153              echo html_writer::end_tag('div');
 154          }
 155      }
 156  
 157      /**
 158       * The question number.
 159       * @param \core_question\statistics\questions\calculated $questionstat stats for the question.
 160       * @return string contents of this table cell.
 161       */
 162      protected function col_number($questionstat) {
 163          if ($this->is_calculated_question_summary($questionstat)) {
 164              return '';
 165          }
 166          if (!isset($questionstat->question->number)) {
 167              return '';
 168          }
 169          $number = $questionstat->question->number;
 170  
 171          if (isset($questionstat->subqdisplayorder)) {
 172              $number = $number . '.'.$questionstat->subqdisplayorder;
 173          }
 174  
 175          if ($questionstat->question->qtype != 'random' && !is_null($questionstat->variant)) {
 176              $number = $number . '.'.$questionstat->variant;
 177          }
 178  
 179          return $number;
 180      }
 181  
 182      /**
 183       * The question type icon.
 184       * @param \core_question\statistics\questions\calculated $questionstat stats for the question.
 185       * @return string contents of this table cell.
 186       */
 187      protected function col_icon($questionstat) {
 188          if ($this->is_calculated_question_summary($questionstat)) {
 189              return '';
 190          } else {
 191              $questionobject = $questionstat->question;
 192              return print_question_icon($questionobject);
 193          }
 194      }
 195  
 196      /**
 197       * Actions that can be performed on the question by this user (e.g. edit or preview).
 198       * @param \core_question\statistics\questions\calculated $questionstat stats for the question.
 199       * @return string contents of this table cell.
 200       */
 201      protected function col_actions($questionstat) {
 202          if ($this->is_calculated_question_summary($questionstat)) {
 203              return '';
 204          } else if ($questionstat->question->qtype === 'missingtype') {
 205              return '';
 206          } else {
 207              return quiz_question_action_icons($this->quiz, $this->cmid,
 208                      $questionstat->question, $this->baseurl, $questionstat->variant);
 209          }
 210      }
 211  
 212      /**
 213       * The question type name.
 214       *
 215       * @param \core_question\statistics\questions\calculated $questionstat stats for the question.
 216       * @return string contents of this table cell.
 217       */
 218      protected function col_qtype($questionstat) {
 219          return question_bank::get_qtype_name($questionstat->question->qtype);
 220      }
 221  
 222      /**
 223       * The question name.
 224       *
 225       * @param \core_question\statistics\questions\calculated $questionstat stats for the question.
 226       * @return string contents of this table cell.
 227       */
 228      protected function col_name($questionstat) {
 229          $name = $questionstat->question->name;
 230  
 231          if (!is_null($questionstat->variant)) {
 232              $a = new stdClass();
 233              $a->name = $name;
 234              $a->variant = $questionstat->variant;
 235              $name = get_string('nameforvariant', 'quiz_statistics', $a);
 236          }
 237  
 238          if ($this->is_downloading()) {
 239              return $name;
 240          }
 241  
 242          $baseurl = new moodle_url($this->baseurl);
 243          if (!is_null($questionstat->variant)) {
 244              if ($questionstat->subquestion) {
 245                  // Variant of a sub-question.
 246                  $url = new moodle_url($baseurl, array('qid' => $questionstat->questionid, 'variant' => $questionstat->variant));
 247                  $name = html_writer::link($url, $name, array('title' => get_string('detailedanalysisforvariant',
 248                                                                                     'quiz_statistics',
 249                                                                                     $questionstat->variant)));
 250              } else if ($questionstat->slot) {
 251                  // Variant of a question in a slot.
 252                  $url = new moodle_url($baseurl, array('slot' => $questionstat->slot, 'variant' => $questionstat->variant));
 253                  $name = html_writer::link($url, $name, array('title' => get_string('detailedanalysisforvariant',
 254                                                                                     'quiz_statistics',
 255                                                                                     $questionstat->variant)));
 256              }
 257          } else {
 258              if ($questionstat->subquestion && !$questionstat->get_variants()) {
 259                  // Sub question without variants.
 260                  $url = new moodle_url($baseurl, array('qid' => $questionstat->questionid));
 261                  $name = html_writer::link($url, $name, array('title' => get_string('detailedanalysis', 'quiz_statistics')));
 262              } else if ($baseurl->param('slot') === null && $questionstat->slot) {
 263                  // Question in a slot, we are not on a page showing structural analysis of one slot,
 264                  // we don't want linking on those pages.
 265                  $number = $questionstat->question->number;
 266                  $israndomquestion = $questionstat->question->qtype == 'random';
 267                  $url = new moodle_url($baseurl, array('slot' => $questionstat->slot));
 268  
 269                  if ($this->is_calculated_question_summary($questionstat)) {
 270                      // Only make the random question summary row name link to the slot structure
 271                      // analysis page with specific text to clearly indicate the link to the user.
 272                      // Random and variant question rows will render the name without a link to improve clarity
 273                      // in the UI.
 274                      $name = html_writer::div(get_string('rangeofvalues', 'quiz_statistics'));
 275                  } else if (!$israndomquestion && !$questionstat->get_variants() && !$questionstat->get_sub_question_ids()) {
 276                      // Question cannot be broken down into sub-questions or variants. Link will show response analysis page.
 277                      $name = html_writer::link($url,
 278                                                $name,
 279                                                array('title' => get_string('detailedanalysis', 'quiz_statistics')));
 280                  }
 281              }
 282          }
 283  
 284  
 285          if ($this->is_dubious_question($questionstat)) {
 286              $name = html_writer::tag('div', $name, array('class' => 'dubious'));
 287          }
 288  
 289          if ($this->is_calculated_question_summary($questionstat)) {
 290              $name .= html_writer::link($url, get_string('viewanalysis', 'quiz_statistics'));
 291          } else if (!empty($questionstat->minmedianmaxnotice)) {
 292              $name = get_string($questionstat->minmedianmaxnotice, 'quiz_statistics') . '<br />' . $name;
 293          }
 294  
 295          return $name;
 296      }
 297  
 298      /**
 299       * The number of attempts at this question.
 300       *
 301       * @param \core_question\statistics\questions\calculated $questionstat stats for the question.
 302       * @return string contents of this table cell.
 303       */
 304      protected function col_s($questionstat) {
 305          if ($this->is_calculated_question_summary($questionstat)) {
 306              list($min, $max) = $questionstat->get_min_max_of('s');
 307              $min = $min ?: 0;
 308              $max = $max ?: 0;
 309              return $this->format_range($min, $max);
 310          } else if (!isset($questionstat->s)) {
 311              return 0;
 312          } else {
 313              return $questionstat->s;
 314          }
 315      }
 316  
 317      /**
 318       * The facility index (average fraction).
 319       * @param \core_question\statistics\questions\calculated $questionstat stats for the question.
 320       * @return string contents of this table cell.
 321       */
 322      protected function col_facility($questionstat) {
 323          if ($this->is_calculated_question_summary($questionstat)) {
 324              list($min, $max) = $questionstat->get_min_max_of('facility');
 325              return $this->format_percentage_range($min, $max);
 326          } else if (is_null($questionstat->facility)) {
 327              return '';
 328          } else {
 329              return $this->format_percentage($questionstat->facility);
 330          }
 331      }
 332  
 333      /**
 334       * The standard deviation of the fractions.
 335       * @param \core_question\statistics\questions\calculated $questionstat stats for the question.
 336       * @return string contents of this table cell.
 337       */
 338      protected function col_sd($questionstat) {
 339          if ($this->is_calculated_question_summary($questionstat)) {
 340              list($min, $max) = $questionstat->get_min_max_of('sd');
 341              return $this->format_percentage_range($min, $max);
 342          } else if (is_null($questionstat->sd) || $questionstat->maxmark == 0) {
 343              return '';
 344          } else {
 345              return $this->format_percentage($questionstat->sd / $questionstat->maxmark);
 346          }
 347      }
 348  
 349      /**
 350       * An estimate of the fraction a student would get by guessing randomly.
 351       * @param \core_question\statistics\questions\calculated $questionstat stats for the question.
 352       * @return string contents of this table cell.
 353       */
 354      protected function col_random_guess_score($questionstat) {
 355          if ($this->is_calculated_question_summary($questionstat)) {
 356              list($min, $max) = $questionstat->get_min_max_of('randomguessscore');
 357              return $this->format_percentage_range($min, $max);
 358          } else if (is_null($questionstat->randomguessscore)) {
 359              return '';
 360          } else {
 361              return $this->format_percentage($questionstat->randomguessscore);
 362          }
 363      }
 364  
 365      /**
 366       * The intended question weight. Maximum mark for the question as a percentage
 367       * of maximum mark for the quiz. That is, the indended influence this question
 368       * on the student's overall mark.
 369       * @param \core_question\statistics\questions\calculated $questionstat stats for the question.
 370       * @return string contents of this table cell.
 371       */
 372      protected function col_intended_weight($questionstat) {
 373          if ($this->is_calculated_question_summary($questionstat)) {
 374              list($min, $max) = $questionstat->get_min_max_of('maxmark');
 375  
 376              if (is_null($min) && is_null($max)) {
 377                  return '';
 378              } else {
 379                  $min = quiz_report_scale_summarks_as_percentage($min, $this->quiz);
 380                  $max = quiz_report_scale_summarks_as_percentage($max, $this->quiz);
 381                  return $this->format_range($min, $max);
 382              }
 383          } else {
 384              return quiz_report_scale_summarks_as_percentage($questionstat->maxmark, $this->quiz);
 385          }
 386      }
 387  
 388      /**
 389       * The effective question weight. That is, an estimate of the actual
 390       * influence this question has on the student's overall mark.
 391       * @param \core_question\statistics\questions\calculated $questionstat stats for the question.
 392       * @return string contents of this table cell.
 393       */
 394      protected function col_effective_weight($questionstat) {
 395          global $OUTPUT;
 396  
 397          if ($this->is_calculated_question_summary($questionstat)) {
 398              list($min, $max) = $questionstat->get_min_max_of('effectiveweight');
 399  
 400              if (is_null($min) && is_null($max)) {
 401                  return '';
 402              } else {
 403                  list( , $negcovar) = $questionstat->get_min_max_of('negcovar');
 404                  if ($negcovar) {
 405                      $min = get_string('negcovar', 'quiz_statistics');
 406                  }
 407  
 408                  return $this->format_range($min, $max);
 409              }
 410          } else if (is_null($questionstat->effectiveweight)) {
 411              return '';
 412          } else if ($questionstat->negcovar) {
 413              $negcovar = get_string('negcovar', 'quiz_statistics');
 414  
 415              if (!$this->is_downloading()) {
 416                  $negcovar = html_writer::tag('div',
 417                          $negcovar . $OUTPUT->help_icon('negcovar', 'quiz_statistics'),
 418                          array('class' => 'negcovar'));
 419              }
 420  
 421              return $negcovar;
 422          } else {
 423              return $this->format_percentage($questionstat->effectiveweight, false);
 424          }
 425      }
 426  
 427      /**
 428       * Discrimination index. This is the product moment correlation coefficient
 429       * between the fraction for this question, and the average fraction for the
 430       * other questions in this quiz.
 431       * @param \core_question\statistics\questions\calculated $questionstat stats for the question.
 432       * @return string contents of this table cell.
 433       */
 434      protected function col_discrimination_index($questionstat) {
 435          if ($this->is_calculated_question_summary($questionstat)) {
 436              list($min, $max) = $questionstat->get_min_max_of('discriminationindex');
 437  
 438              if (isset($max)) {
 439                  $min = $min ?: 0;
 440              }
 441  
 442              if (is_numeric($min)) {
 443                  $min = $this->format_percentage($min, false);
 444              }
 445              if (is_numeric($max)) {
 446                  $max = $this->format_percentage($max, false);
 447              }
 448  
 449              return $this->format_range($min, $max);
 450          } else if (!is_numeric($questionstat->discriminationindex)) {
 451              return $questionstat->discriminationindex;
 452          } else {
 453              return $this->format_percentage($questionstat->discriminationindex, false);
 454          }
 455      }
 456  
 457      /**
 458       * Discrimination efficiency, similar to, but different from, the Discrimination index.
 459       *
 460       * @param \core_question\statistics\questions\calculated $questionstat stats for the question.
 461       * @return string contents of this table cell.
 462       */
 463      protected function col_discriminative_efficiency($questionstat) {
 464          if ($this->is_calculated_question_summary($questionstat)) {
 465              list($min, $max) = $questionstat->get_min_max_of('discriminativeefficiency');
 466  
 467              if (!is_numeric($min) && !is_numeric($max)) {
 468                  return '';
 469              } else {
 470                  return $this->format_percentage_range($min, $max, false);
 471              }
 472          } else if (!is_numeric($questionstat->discriminativeefficiency)) {
 473              return '';
 474          } else {
 475              return $this->format_percentage($questionstat->discriminativeefficiency, false);
 476          }
 477      }
 478  
 479      /**
 480       * This method encapsulates the test for wheter a question should be considered dubious.
 481       * @param \core_question\statistics\questions\calculated $questionstat stats for the question.
 482       * @return bool is this question possibly not pulling it's weight?
 483       */
 484      protected function is_dubious_question($questionstat) {
 485          if ($this->is_calculated_question_summary($questionstat)) {
 486              // We only care about the minimum value here.
 487              // If the minimum value is less than the threshold, then we know that there is at least one value below the threshold.
 488              list($discriminativeefficiency) = $questionstat->get_min_max_of('discriminativeefficiency');
 489          } else {
 490              $discriminativeefficiency = $questionstat->discriminativeefficiency;
 491          }
 492  
 493          if (!is_numeric($discriminativeefficiency)) {
 494              return false;
 495          }
 496  
 497          return $discriminativeefficiency < 15;
 498      }
 499  
 500      /**
 501       * Check if the given stats object is an instance of calculated_question_summary.
 502       *
 503       * @param  \core_question\statistics\questions\calculated $questionstat Stats object
 504       * @return bool
 505       */
 506      protected function is_calculated_question_summary($questionstat) {
 507          return $questionstat instanceof calculated_question_summary;
 508      }
 509  
 510      /**
 511       * Format inputs to represent a range between $min and $max.
 512       * This function does not check if $min is less than $max or not.
 513       * If both $min and $max are equal to null, this function returns an empty string.
 514       *
 515       * @param string|null $min The minimum value in the range
 516       * @param string|null $max The maximum value in the range
 517       * @return string
 518       */
 519      protected function format_range(string $min = null, string $max = null) {
 520          if (is_null($min) && is_null($max)) {
 521              return '';
 522          } else {
 523              $a = new stdClass();
 524              $a->min = $min;
 525              $a->max = $max;
 526  
 527              return get_string('rangebetween', 'quiz_statistics', $a);
 528          }
 529      }
 530  
 531      /**
 532       * Format a number to a localised percentage with specified decimal points.
 533       *
 534       * @param float $number The number being formatted
 535       * @param bool $fraction An indicator for whether the number is a fraction or is already multiplied by 100
 536       * @param int $decimals Sets the number of decimal points
 537       * @return string
 538       */
 539      protected function format_percentage(float $number, bool $fraction = true, int $decimals = 2) {
 540          $coefficient = $fraction ? 100 : 1;
 541          return get_string('percents', 'moodle', format_float($number * $coefficient, $decimals));
 542      }
 543  
 544      /**
 545       * Format $min and $max to localised percentages and form a string that represents a range between them.
 546       * This function does not check if $min is less than $max or not.
 547       * If both $min and $max are equal to null, this function returns an empty string.
 548       *
 549       * @param float|null $min The minimum value of the range
 550       * @param float|null $max The maximum value of the range
 551       * @param bool $fraction An indicator for whether min and max are a fractions or are already multiplied by 100
 552       * @param int $decimals Sets the number of decimal points
 553       * @return string A formatted string that represents a range between $min to $max.
 554       */
 555      protected function format_percentage_range(float $min = null, float $max = null, bool $fraction = true, int $decimals = 2) {
 556          if (is_null($min) && is_null($max)) {
 557              return '';
 558          } else {
 559              $min = $min ?: 0;
 560              $max = $max ?: 0;
 561              return $this->format_range(
 562                      $this->format_percentage($min, $fraction, $decimals),
 563                      $this->format_percentage($max, $fraction, $decimals)
 564              );
 565          }
 566      }
 567  }