Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.3.x will end 7 October 2024 (12 months).
  • Bug fixes for security issues in 4.3.x will end 21 April 2025 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.2.x is supported too.

Differences Between: [Versions 400 and 403]

   1  <?php
   2  // This file is part of Moodle - http://moodle.org/
   3  //
   4  // Moodle is free software: you can redistribute it and/or modify
   5  // it under the terms of the GNU General Public License as published by
   6  // the Free Software Foundation, either version 3 of the License, or
   7  // (at your option) any later version.
   8  //
   9  // Moodle is distributed in the hope that it will be useful,
  10  // but WITHOUT ANY WARRANTY; without even the implied warranty of
  11  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  12  // GNU General Public License for more details.
  13  //
  14  // You should have received a copy of the GNU General Public License
  15  // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
  16  
  17  namespace core_question\local\statistics;
  18  
  19  use core_question\local\bank\column_base;
  20  use core_question\statistics\questions\all_calculated_for_qubaid_condition;
  21  use core_component;
  22  
  23  /**
  24   * Helper to efficiently load all the statistics for a set of questions.
  25   *
  26   * If you are implementing a question bank column, do not use this method directly.
  27   * Instead, override the {@see column_base::get_required_statistics_fields()} method
  28   * in your column class, and the question bank view will take care of it for you.
  29   *
  30   * @package   core_question
  31   * @copyright 2023 The Open University
  32   * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  33   */
  34  class statistics_bulk_loader {
  35  
  36      /**
  37       * Load and aggregate the requested statistics for all the places where the given questions are used.
  38       *
  39       * The returned array will contain a values for each questionid and field, which will be null if the value is not available.
  40       *
  41       * @param int[] $questionids array of question ids.
  42       * @param string[] $requiredstatistics array of the fields required, e.g. ['facility', 'discriminationindex'].
  43       * @return float[][] if a value is not available, it will be set to null.
  44       */
  45      public static function load_aggregate_statistics(array $questionids, array $requiredstatistics): array {
  46          // Prevent unnecessary statistics calculations.
  47          if (empty($requiredstatistics)) {
  48              $aggregates = [];
  49              foreach ($questionids as $questionid) {
  50                  $aggregates[$questionid] = [];
  51              }
  52              return $aggregates;
  53          }
  54  
  55          $places = self::get_all_places_where_questions_were_attempted($questionids);
  56  
  57          // Set up blank two-dimensional arrays to store the running totals. Indexed by questionid and field name.
  58          $zerovaluesforonequestion = array_combine($requiredstatistics, array_fill(0, count($requiredstatistics), 0));
  59          $counts = array_combine($questionids, array_fill(0, count($questionids), $zerovaluesforonequestion));
  60          $sums = array_combine($questionids, array_fill(0, count($questionids), $zerovaluesforonequestion));
  61  
  62          // Load the data for each place, and add to the running totals.
  63          foreach ($places as $place) {
  64              $statistics = self::load_statistics_for_place($place->component,
  65                      \context::instance_by_id($place->contextid));
  66              if ($statistics === null) {
  67                  continue;
  68              }
  69  
  70              foreach ($questionids as $questionid) {
  71                  foreach ($requiredstatistics as $item) {
  72                      $value = self::extract_item_value($statistics, $questionid, $item);
  73                      if ($value === null) {
  74                          continue;
  75                      }
  76  
  77                      $counts[$questionid][$item] += 1;
  78                      $sums[$questionid][$item] += $value;
  79                  }
  80              }
  81          }
  82  
  83          // Compute the averages from the final totals.
  84          $aggregates = [];
  85          foreach ($questionids as $questionid) {
  86              $aggregates[$questionid] = [];
  87              foreach ($requiredstatistics as $item) {
  88                  if ($counts[$questionid][$item] > 0) {
  89                      $aggregates[$questionid][$item] = $sums[$questionid][$item] / $counts[$questionid][$item];
  90                  } else {
  91                      $aggregates[$questionid][$item] = null;
  92                  }
  93  
  94              }
  95          }
  96  
  97          return $aggregates;
  98      }
  99  
 100      /**
 101       * For a list of questions find all the places, defined by (component, contextid), where there are attempts.
 102       *
 103       * @param int[] $questionids array of question ids that we are interested in.
 104       * @return \stdClass[] list of objects with fields ->component and ->contextid.
 105       */
 106      protected static function get_all_places_where_questions_were_attempted(array $questionids): array {
 107          global $DB;
 108  
 109          [$questionidcondition, $params] = $DB->get_in_or_equal($questionids);
 110          // The MIN(qu.id) is just to ensure that the rows have a unique key.
 111          $places = $DB->get_records_sql("
 112                  SELECT MIN(qu.id) AS somethingunique, qu.component, qu.contextid
 113                    FROM {question_usages} qu
 114                    JOIN {question_attempts} qatt ON qatt.questionusageid = qu.id
 115                    JOIN {context} ctx ON ctx.id = qu.contextid
 116                   WHERE qatt.questionid $questionidcondition
 117                GROUP BY qu.component, qu.contextid
 118                ORDER BY qu.contextid ASC
 119                  ", $params);
 120  
 121          // Strip out the unwanted ids.
 122          $places = array_values($places);
 123          foreach ($places as $place) {
 124              unset($place->somethingunique);
 125          }
 126  
 127          return $places;
 128      }
 129  
 130      /**
 131       * Load the question statistics for all the attempts belonging to a particular component in a particular context.
 132       *
 133       * @param string $component frankenstyle component name, e.g. 'mod_quiz'.
 134       * @param \context $context the context to load the statistics for.
 135       * @return all_calculated_for_qubaid_condition|null question statistics.
 136       */
 137      protected static function load_statistics_for_place(
 138          string $component,
 139          \context $context
 140      ): ?all_calculated_for_qubaid_condition {
 141          // This check is basically if (component_exists).
 142          if (empty(core_component::get_component_directory($component))) {
 143              return null;
 144          }
 145  
 146          if (!component_callback_exists($component, 'calculate_question_stats')) {
 147              return null;
 148          }
 149  
 150          return component_callback($component, 'calculate_question_stats', [$context]);
 151      }
 152  
 153      /**
 154       * Extract the value for one question and one type of statistic from a set of statistics.
 155       *
 156       * @param all_calculated_for_qubaid_condition $statistics the batch of statistics.
 157       * @param int $questionid a question id.
 158       * @param string $item one of the field names in all_calculated_for_qubaid_condition, e.g. 'facility'.
 159       * @return float|null the required value.
 160       */
 161      protected static function extract_item_value(all_calculated_for_qubaid_condition $statistics,
 162              int $questionid, string $item): ?float {
 163  
 164          // Look in main questions.
 165          foreach ($statistics->questionstats as $stats) {
 166              if ($stats->questionid == $questionid && isset($stats->$item)) {
 167                  return $stats->$item;
 168              }
 169          }
 170  
 171          // If not found, look in sub questions.
 172          foreach ($statistics->subquestionstats as $stats) {
 173              if ($stats->questionid == $questionid && isset($stats->$item)) {
 174                  return $stats->$item;
 175              }
 176          }
 177  
 178          return null;
 179      }
 180  }