Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 3.9.x will end* 10 May 2021 (12 months).
  • Bug fixes for security issues in 3.9.x will end* 8 May 2023 (36 months).
  • PHP version: minimum PHP 7.2.0 Note: minimum PHP version has increased since Moodle 3.8. PHP 7.3.x and 7.4.x are supported too.

Differences Between: [Versions 39 and 400] [Versions 39 and 401] [Versions 39 and 402] [Versions 39 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              return print_question_icon($questionstat->question, true);
 192          }
 193      }
 194  
 195      /**
 196       * Actions that can be performed on the question by this user (e.g. edit or preview).
 197       * @param \core_question\statistics\questions\calculated $questionstat stats for the question.
 198       * @return string contents of this table cell.
 199       */
 200      protected function col_actions($questionstat) {
 201          if ($this->is_calculated_question_summary($questionstat)) {
 202              return '';
 203          } else {
 204              return quiz_question_action_icons($this->quiz, $this->cmid,
 205                      $questionstat->question, $this->baseurl, $questionstat->variant);
 206          }
 207      }
 208  
 209      /**
 210       * The question type name.
 211       *
 212       * @param \core_question\statistics\questions\calculated $questionstat stats for the question.
 213       * @return string contents of this table cell.
 214       */
 215      protected function col_qtype($questionstat) {
 216          return question_bank::get_qtype_name($questionstat->question->qtype);
 217      }
 218  
 219      /**
 220       * The question name.
 221       *
 222       * @param \core_question\statistics\questions\calculated $questionstat stats for the question.
 223       * @return string contents of this table cell.
 224       */
 225      protected function col_name($questionstat) {
 226          $name = $questionstat->question->name;
 227  
 228          if (!is_null($questionstat->variant)) {
 229              $a = new stdClass();
 230              $a->name = $name;
 231              $a->variant = $questionstat->variant;
 232              $name = get_string('nameforvariant', 'quiz_statistics', $a);
 233          }
 234  
 235          if ($this->is_downloading()) {
 236              return $name;
 237          }
 238  
 239          $baseurl = new moodle_url($this->baseurl);
 240          if (!is_null($questionstat->variant)) {
 241              if ($questionstat->subquestion) {
 242                  // Variant of a sub-question.
 243                  $url = new moodle_url($baseurl, array('qid' => $questionstat->questionid, 'variant' => $questionstat->variant));
 244                  $name = html_writer::link($url, $name, array('title' => get_string('detailedanalysisforvariant',
 245                                                                                     'quiz_statistics',
 246                                                                                     $questionstat->variant)));
 247              } else if ($questionstat->slot) {
 248                  // Variant of a question in a slot.
 249                  $url = new moodle_url($baseurl, array('slot' => $questionstat->slot, 'variant' => $questionstat->variant));
 250                  $name = html_writer::link($url, $name, array('title' => get_string('detailedanalysisforvariant',
 251                                                                                     'quiz_statistics',
 252                                                                                     $questionstat->variant)));
 253              }
 254          } else {
 255              if ($questionstat->subquestion && !$questionstat->get_variants()) {
 256                  // Sub question without variants.
 257                  $url = new moodle_url($baseurl, array('qid' => $questionstat->questionid));
 258                  $name = html_writer::link($url, $name, array('title' => get_string('detailedanalysis', 'quiz_statistics')));
 259              } else if ($baseurl->param('slot') === null && $questionstat->slot) {
 260                  // Question in a slot, we are not on a page showing structural analysis of one slot,
 261                  // we don't want linking on those pages.
 262                  $number = $questionstat->question->number;
 263                  $israndomquestion = $questionstat->question->qtype == 'random';
 264                  $url = new moodle_url($baseurl, array('slot' => $questionstat->slot));
 265  
 266                  if ($this->is_calculated_question_summary($questionstat)) {
 267                      // Only make the random question summary row name link to the slot structure
 268                      // analysis page with specific text to clearly indicate the link to the user.
 269                      // Random and variant question rows will render the name without a link to improve clarity
 270                      // in the UI.
 271                      $name = html_writer::div(get_string('rangeofvalues', 'quiz_statistics'));
 272                  } else if (!$israndomquestion && !$questionstat->get_variants() && !$questionstat->get_sub_question_ids()) {
 273                      // Question cannot be broken down into sub-questions or variants. Link will show response analysis page.
 274                      $name = html_writer::link($url,
 275                                                $name,
 276                                                array('title' => get_string('detailedanalysis', 'quiz_statistics')));
 277                  }
 278              }
 279          }
 280  
 281  
 282          if ($this->is_dubious_question($questionstat)) {
 283              $name = html_writer::tag('div', $name, array('class' => 'dubious'));
 284          }
 285  
 286          if ($this->is_calculated_question_summary($questionstat)) {
 287              $name .= html_writer::link($url, get_string('viewanalysis', 'quiz_statistics'));
 288          } else if (!empty($questionstat->minmedianmaxnotice)) {
 289              $name = get_string($questionstat->minmedianmaxnotice, 'quiz_statistics') . '<br />' . $name;
 290          }
 291  
 292          return $name;
 293      }
 294  
 295      /**
 296       * The number of attempts at this question.
 297       *
 298       * @param \core_question\statistics\questions\calculated $questionstat stats for the question.
 299       * @return string contents of this table cell.
 300       */
 301      protected function col_s($questionstat) {
 302          if ($this->is_calculated_question_summary($questionstat)) {
 303              list($min, $max) = $questionstat->get_min_max_of('s');
 304              $min = $min ?: 0;
 305              $max = $max ?: 0;
 306              return $this->format_range($min, $max);
 307          } else if (!isset($questionstat->s)) {
 308              return 0;
 309          } else {
 310              return $questionstat->s;
 311          }
 312      }
 313  
 314      /**
 315       * The facility index (average fraction).
 316       * @param \core_question\statistics\questions\calculated $questionstat stats for the question.
 317       * @return string contents of this table cell.
 318       */
 319      protected function col_facility($questionstat) {
 320          if ($this->is_calculated_question_summary($questionstat)) {
 321              list($min, $max) = $questionstat->get_min_max_of('facility');
 322              return $this->format_percentage_range($min, $max);
 323          } else if (is_null($questionstat->facility)) {
 324              return '';
 325          } else {
 326              return $this->format_percentage($questionstat->facility);
 327          }
 328      }
 329  
 330      /**
 331       * The standard deviation of the fractions.
 332       * @param \core_question\statistics\questions\calculated $questionstat stats for the question.
 333       * @return string contents of this table cell.
 334       */
 335      protected function col_sd($questionstat) {
 336          if ($this->is_calculated_question_summary($questionstat)) {
 337              list($min, $max) = $questionstat->get_min_max_of('sd');
 338              return $this->format_percentage_range($min, $max);
 339          } else if (is_null($questionstat->sd) || $questionstat->maxmark == 0) {
 340              return '';
 341          } else {
 342              return $this->format_percentage($questionstat->sd / $questionstat->maxmark);
 343          }
 344      }
 345  
 346      /**
 347       * An estimate of the fraction a student would get by guessing randomly.
 348       * @param \core_question\statistics\questions\calculated $questionstat stats for the question.
 349       * @return string contents of this table cell.
 350       */
 351      protected function col_random_guess_score($questionstat) {
 352          if ($this->is_calculated_question_summary($questionstat)) {
 353              list($min, $max) = $questionstat->get_min_max_of('randomguessscore');
 354              return $this->format_percentage_range($min, $max);
 355          } else if (is_null($questionstat->randomguessscore)) {
 356              return '';
 357          } else {
 358              return $this->format_percentage($questionstat->randomguessscore);
 359          }
 360      }
 361  
 362      /**
 363       * The intended question weight. Maximum mark for the question as a percentage
 364       * of maximum mark for the quiz. That is, the indended influence this question
 365       * on the student's overall mark.
 366       * @param \core_question\statistics\questions\calculated $questionstat stats for the question.
 367       * @return string contents of this table cell.
 368       */
 369      protected function col_intended_weight($questionstat) {
 370          if ($this->is_calculated_question_summary($questionstat)) {
 371              list($min, $max) = $questionstat->get_min_max_of('maxmark');
 372  
 373              if (is_null($min) && is_null($max)) {
 374                  return '';
 375              } else {
 376                  $min = quiz_report_scale_summarks_as_percentage($min, $this->quiz);
 377                  $max = quiz_report_scale_summarks_as_percentage($max, $this->quiz);
 378                  return $this->format_range($min, $max);
 379              }
 380          } else {
 381              return quiz_report_scale_summarks_as_percentage($questionstat->maxmark, $this->quiz);
 382          }
 383      }
 384  
 385      /**
 386       * The effective question weight. That is, an estimate of the actual
 387       * influence this question has on the student's overall mark.
 388       * @param \core_question\statistics\questions\calculated $questionstat stats for the question.
 389       * @return string contents of this table cell.
 390       */
 391      protected function col_effective_weight($questionstat) {
 392          global $OUTPUT;
 393  
 394          if ($this->is_calculated_question_summary($questionstat)) {
 395              list($min, $max) = $questionstat->get_min_max_of('effectiveweight');
 396  
 397              if (is_null($min) && is_null($max)) {
 398                  return '';
 399              } else {
 400                  list( , $negcovar) = $questionstat->get_min_max_of('negcovar');
 401                  if ($negcovar) {
 402                      $min = get_string('negcovar', 'quiz_statistics');
 403                  }
 404  
 405                  return $this->format_range($min, $max);
 406              }
 407          } else if (is_null($questionstat->effectiveweight)) {
 408              return '';
 409          } else if ($questionstat->negcovar) {
 410              $negcovar = get_string('negcovar', 'quiz_statistics');
 411  
 412              if (!$this->is_downloading()) {
 413                  $negcovar = html_writer::tag('div',
 414                          $negcovar . $OUTPUT->help_icon('negcovar', 'quiz_statistics'),
 415                          array('class' => 'negcovar'));
 416              }
 417  
 418              return $negcovar;
 419          } else {
 420              return $this->format_percentage($questionstat->effectiveweight, false);
 421          }
 422      }
 423  
 424      /**
 425       * Discrimination index. This is the product moment correlation coefficient
 426       * between the fraction for this question, and the average fraction for the
 427       * other questions in this quiz.
 428       * @param \core_question\statistics\questions\calculated $questionstat stats for the question.
 429       * @return string contents of this table cell.
 430       */
 431      protected function col_discrimination_index($questionstat) {
 432          if ($this->is_calculated_question_summary($questionstat)) {
 433              list($min, $max) = $questionstat->get_min_max_of('discriminationindex');
 434  
 435              if (isset($max)) {
 436                  $min = $min ?: 0;
 437              }
 438  
 439              if (is_numeric($min)) {
 440                  $min = $this->format_percentage($min, false);
 441              }
 442              if (is_numeric($max)) {
 443                  $max = $this->format_percentage($max, false);
 444              }
 445  
 446              return $this->format_range($min, $max);
 447          } else if (!is_numeric($questionstat->discriminationindex)) {
 448              return $questionstat->discriminationindex;
 449          } else {
 450              return $this->format_percentage($questionstat->discriminationindex, false);
 451          }
 452      }
 453  
 454      /**
 455       * Discrimination efficiency, similar to, but different from, the Discrimination index.
 456       *
 457       * @param \core_question\statistics\questions\calculated $questionstat stats for the question.
 458       * @return string contents of this table cell.
 459       */
 460      protected function col_discriminative_efficiency($questionstat) {
 461          if ($this->is_calculated_question_summary($questionstat)) {
 462              list($min, $max) = $questionstat->get_min_max_of('discriminativeefficiency');
 463  
 464              if (!is_numeric($min) && !is_numeric($max)) {
 465                  return '';
 466              } else {
 467                  return $this->format_percentage_range($min, $max, false);
 468              }
 469          } else if (!is_numeric($questionstat->discriminativeefficiency)) {
 470              return '';
 471          } else {
 472              return $this->format_percentage($questionstat->discriminativeefficiency, false);
 473          }
 474      }
 475  
 476      /**
 477       * This method encapsulates the test for wheter a question should be considered dubious.
 478       * @param \core_question\statistics\questions\calculated $questionstat stats for the question.
 479       * @return bool is this question possibly not pulling it's weight?
 480       */
 481      protected function is_dubious_question($questionstat) {
 482          if ($this->is_calculated_question_summary($questionstat)) {
 483              // We only care about the minimum value here.
 484              // If the minimum value is less than the threshold, then we know that there is at least one value below the threshold.
 485              list($discriminativeefficiency) = $questionstat->get_min_max_of('discriminativeefficiency');
 486          } else {
 487              $discriminativeefficiency = $questionstat->discriminativeefficiency;
 488          }
 489  
 490          if (!is_numeric($discriminativeefficiency)) {
 491              return false;
 492          }
 493  
 494          return $discriminativeefficiency < 15;
 495      }
 496  
 497      /**
 498       * Check if the given stats object is an instance of calculated_question_summary.
 499       *
 500       * @param  \core_question\statistics\questions\calculated $questionstat Stats object
 501       * @return bool
 502       */
 503      protected function is_calculated_question_summary($questionstat) {
 504          return $questionstat instanceof calculated_question_summary;
 505      }
 506  
 507      /**
 508       * Format inputs to represent a range between $min and $max.
 509       * This function does not check if $min is less than $max or not.
 510       * If both $min and $max are equal to null, this function returns an empty string.
 511       *
 512       * @param string|null $min The minimum value in the range
 513       * @param string|null $max The maximum value in the range
 514       * @return string
 515       */
 516      protected function format_range(string $min = null, string $max = null) {
 517          if (is_null($min) && is_null($max)) {
 518              return '';
 519          } else {
 520              $a = new stdClass();
 521              $a->min = $min;
 522              $a->max = $max;
 523  
 524              return get_string('rangebetween', 'quiz_statistics', $a);
 525          }
 526      }
 527  
 528      /**
 529       * Format a number to a localised percentage with specified decimal points.
 530       *
 531       * @param float $number The number being formatted
 532       * @param bool $fraction An indicator for whether the number is a fraction or is already multiplied by 100
 533       * @param int $decimals Sets the number of decimal points
 534       * @return string
 535       */
 536      protected function format_percentage(float $number, bool $fraction = true, int $decimals = 2) {
 537          $coefficient = $fraction ? 100 : 1;
 538          return get_string('percents', 'moodle', format_float($number * $coefficient, $decimals));
 539      }
 540  
 541      /**
 542       * Format $min and $max to localised percentages and form a string that represents a range between them.
 543       * This function does not check if $min is less than $max or not.
 544       * If both $min and $max are equal to null, this function returns an empty string.
 545       *
 546       * @param float|null $min The minimum value of the range
 547       * @param float|null $max The maximum value of the range
 548       * @param bool $fraction An indicator for whether min and max are a fractions or are already multiplied by 100
 549       * @param int $decimals Sets the number of decimal points
 550       * @return string A formatted string that represents a range between $min to $max.
 551       */
 552      protected function format_percentage_range(float $min = null, float $max = null, bool $fraction = true, int $decimals = 2) {
 553          if (is_null($min) && is_null($max)) {
 554              return '';
 555          } else {
 556              $min = $min ?: 0;
 557              $max = $max ?: 0;
 558              return $this->format_range(
 559                      $this->format_percentage($min, $fraction, $decimals),
 560                      $this->format_percentage($max, $fraction, $decimals)
 561              );
 562          }
 563      }
 564  }