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 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   * A collection of all the question statistics calculated for an activity instance ie. the stats calculated for slots and
  19   * sub-questions and variants of those questions.
  20   *
  21   * @package    core_question
  22   * @copyright  2014 The Open University
  23   * @author     James Pratt me@jamiep.org
  24   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  25   */
  26  
  27  namespace core_question\statistics\questions;
  28  
  29  use question_bank;
  30  
  31  /**
  32   * A collection of all the question statistics calculated for an activity instance.
  33   *
  34   * @package    core_question
  35   * @copyright  2014 The Open University
  36   * @author     James Pratt me@jamiep.org
  37   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  38   */
  39  class all_calculated_for_qubaid_condition {
  40  
  41      /** @var int No longer used. Previously, the time after which statistics are automatically recomputed. */
  42      const TIME_TO_CACHE = 900; // 15 minutes.
  43  
  44      /**
  45       * @var object[]
  46       */
  47      public $subquestions = [];
  48  
  49      /**
  50       * Holds slot (position) stats and stats for variants of questions in slots.
  51       *
  52       * @var calculated[]
  53       */
  54      public $questionstats = array();
  55  
  56      /**
  57       * Holds sub-question stats and stats for variants of subqs.
  58       *
  59       * @var calculated_for_subquestion[]
  60       */
  61      public $subquestionstats = array();
  62  
  63      /**
  64       * Set up a calculated_for_subquestion instance ready to store a randomly selected question's stats.
  65       *
  66       * @param object     $step
  67       * @param int|null   $variant Is this to keep track of a variant's stats? If so what is the variant, if not null.
  68       */
  69      public function initialise_for_subq($step, $variant = null) {
  70          $newsubqstat = new calculated_for_subquestion($step, $variant);
  71          if ($variant === null) {
  72              $this->subquestionstats[$step->questionid] = $newsubqstat;
  73          } else {
  74              $this->subquestionstats[$step->questionid]->variantstats[$variant] = $newsubqstat;
  75          }
  76      }
  77  
  78      /**
  79       * Set up a calculated instance ready to store a slot question's stats.
  80       *
  81       * @param int      $slot
  82       * @param object   $question
  83       * @param int|null $variant Is this to keep track of a variant's stats? If so what is the variant, if not null.
  84       */
  85      public function initialise_for_slot($slot, $question, $variant = null) {
  86          $newqstat = new calculated($question, $slot, $variant);
  87          if ($variant === null) {
  88              $this->questionstats[$slot] = $newqstat;
  89          } else {
  90              $this->questionstats[$slot]->variantstats[$variant] = $newqstat;
  91          }
  92      }
  93  
  94      /**
  95       * Do we have stats for a particular quesitonid (and optionally variant)?
  96       *
  97       * @param int  $questionid The id of the sub question.
  98       * @param int|null $variant if not null then we want the object to store a variant of a sub-question's stats.
  99       * @return bool whether those stats exist (yet).
 100       */
 101      public function has_subq($questionid, $variant = null) {
 102          if ($variant === null) {
 103              return isset($this->subquestionstats[$questionid]);
 104          } else {
 105              return isset($this->subquestionstats[$questionid]->variantstats[$variant]);
 106          }
 107      }
 108  
 109      /**
 110       * Reference for a item stats instance for a questionid and optional variant no.
 111       *
 112       * @param int  $questionid The id of the sub question.
 113       * @param int|null $variant if not null then we want the object to store a variant of a sub-question's stats.
 114       * @return calculated|calculated_for_subquestion stats instance for a questionid and optional variant no.
 115       *     Will be a calculated_for_subquestion if no variant specified.
 116       * @throws \coding_exception if there is an attempt to respond to a non-existant set of stats.
 117       */
 118      public function for_subq($questionid, $variant = null) {
 119          if ($variant === null) {
 120              if (!isset($this->subquestionstats[$questionid])) {
 121                  throw new \coding_exception('Reference to unknown question id ' . $questionid);
 122              } else {
 123                  return $this->subquestionstats[$questionid];
 124              }
 125          } else {
 126              if (!isset($this->subquestionstats[$questionid]->variantstats[$variant])) {
 127                  throw new \coding_exception('Reference to unknown question id ' . $questionid .
 128                          ' variant ' . $variant);
 129              } else {
 130                  return $this->subquestionstats[$questionid]->variantstats[$variant];
 131              }
 132          }
 133      }
 134  
 135      /**
 136       * ids of all randomly selected question for all slots.
 137       *
 138       * @return int[] An array of all sub-question ids.
 139       */
 140      public function get_all_subq_ids() {
 141          return array_keys($this->subquestionstats);
 142      }
 143  
 144      /**
 145       * All slots nos that stats have been calculated for.
 146       *
 147       * @return int[] An array of all slot nos.
 148       */
 149      public function get_all_slots() {
 150          return array_keys($this->questionstats);
 151      }
 152  
 153      /**
 154       * Do we have stats for a particular slot (and optionally variant)?
 155       *
 156       * @param int  $slot The slot no.
 157       * @param int|null $variant if provided then we want the object which stores a variant of a position's stats.
 158       * @return bool whether those stats exist (yet).
 159       */
 160      public function has_slot($slot, $variant = null) {
 161          if ($variant === null) {
 162              return isset($this->questionstats[$slot]);
 163          } else {
 164              return isset($this->questionstats[$slot]->variantstats[$variant]);
 165          }
 166      }
 167  
 168      /**
 169       * Get position stats instance for a slot and optional variant no.
 170       *
 171       * @param int  $slot The slot no.
 172       * @param int|null $variant if provided then we want the object which stores a variant of a position's stats.
 173       * @return calculated|calculated_for_subquestion An instance of the class storing the calculated position stats.
 174       * @throws \coding_exception if there is an attempt to respond to a non-existant set of stats.
 175       */
 176      public function for_slot($slot, $variant = null) {
 177          if ($variant === null) {
 178              if (!isset($this->questionstats[$slot])) {
 179                  throw new \coding_exception('Reference to unknown slot ' . $slot);
 180              } else {
 181                  return $this->questionstats[$slot];
 182              }
 183          } else {
 184              if (!isset($this->questionstats[$slot]->variantstats[$variant])) {
 185                  throw new \coding_exception('Reference to unknown slot ' . $slot . ' variant ' . $variant);
 186              } else {
 187                  return $this->questionstats[$slot]->variantstats[$variant];
 188              }
 189          }
 190      }
 191  
 192      /**
 193       * Load cached statistics from the database.
 194       *
 195       * @param \qubaid_condition $qubaids Which question usages to load stats for?
 196       */
 197      public function get_cached($qubaids) {
 198          global $DB;
 199  
 200          $timemodified = self::get_last_calculated_time($qubaids);
 201          $questionstatrecs = $DB->get_records('question_statistics',
 202                  ['hashcode' => $qubaids->get_hash_code(), 'timemodified' => $timemodified]);
 203  
 204          $questionids = array();
 205          foreach ($questionstatrecs as $fromdb) {
 206              if (is_null($fromdb->variant) && !$fromdb->slot) {
 207                  $questionids[] = $fromdb->questionid;
 208              }
 209          }
 210          $this->subquestions = question_load_questions($questionids);
 211          foreach ($questionstatrecs as $fromdb) {
 212              if (is_null($fromdb->variant)) {
 213                  if ($fromdb->slot) {
 214                      if (!isset($this->questionstats[$fromdb->slot])) {
 215                          debugging('Statistics found for slot ' . $fromdb->slot .
 216                              ' in stats ' . json_encode($qubaids->from_where_params()) .
 217                              ' which is not an analysable question.', DEBUG_DEVELOPER);
 218                      }
 219                      $this->questionstats[$fromdb->slot]->populate_from_record($fromdb);
 220                  } else {
 221                      $this->subquestionstats[$fromdb->questionid] = new calculated_for_subquestion();
 222                      $this->subquestionstats[$fromdb->questionid]->populate_from_record($fromdb);
 223                      if (isset($this->subquestions[$fromdb->questionid])) {
 224                          $this->subquestionstats[$fromdb->questionid]->question =
 225                              $this->subquestions[$fromdb->questionid];
 226                      } else {
 227                          $this->subquestionstats[$fromdb->questionid]->question = question_bank::get_qtype(
 228                              'missingtype', false)->make_deleted_instance($fromdb->questionid, 1);
 229                      }
 230                  }
 231              }
 232          }
 233          // Add cached variant stats to data structure.
 234          foreach ($questionstatrecs as $fromdb) {
 235              if (!is_null($fromdb->variant)) {
 236                  if ($fromdb->slot) {
 237                      if (!isset($this->questionstats[$fromdb->slot])) {
 238                          debugging('Statistics found for slot ' . $fromdb->slot .
 239                              ' in stats ' . json_encode($qubaids->from_where_params()) .
 240                              ' which is not an analysable question.', DEBUG_DEVELOPER);
 241                          continue;
 242                      }
 243                      $newcalcinstance = new calculated();
 244                      $this->questionstats[$fromdb->slot]->variantstats[$fromdb->variant] = $newcalcinstance;
 245                      $newcalcinstance->question = $this->questionstats[$fromdb->slot]->question;
 246                  } else {
 247                      $newcalcinstance = new calculated_for_subquestion();
 248                      $this->subquestionstats[$fromdb->questionid]->variantstats[$fromdb->variant] = $newcalcinstance;
 249                      if (isset($this->subquestions[$fromdb->questionid])) {
 250                          $newcalcinstance->question = $this->subquestions[$fromdb->questionid];
 251                      } else {
 252                          $newcalcinstance->question = question_bank::get_qtype(
 253                              'missingtype', false)->make_deleted_instance($fromdb->questionid, 1);
 254                      }
 255                  }
 256                  $newcalcinstance->populate_from_record($fromdb);
 257              }
 258          }
 259      }
 260  
 261      /**
 262       * Find time of non-expired statistics in the database.
 263       *
 264       * @param \qubaid_condition $qubaids Which question usages to look for stats for?
 265       * @return int|bool Time of cached record that matches this qubaid_condition or false if non found.
 266       */
 267      public function get_last_calculated_time($qubaids) {
 268          global $DB;
 269          $lastcalculatedtime = $DB->get_field('question_statistics', 'COALESCE(MAX(timemodified), 0)',
 270                  ['hashcode' => $qubaids->get_hash_code()]);
 271          if ($lastcalculatedtime) {
 272              return $lastcalculatedtime;
 273          } else {
 274              return false;
 275          }
 276      }
 277  
 278      /**
 279       * Save stats to db, first cleaning up any old ones.
 280       *
 281       * @param \qubaid_condition $qubaids Which question usages are we caching the stats of?
 282       */
 283      public function cache($qubaids) {
 284          global $DB;
 285  
 286          $transaction = $DB->start_delegated_transaction();
 287          $timemodified = time();
 288  
 289          foreach ($this->get_all_slots() as $slot) {
 290              $this->for_slot($slot)->cache($qubaids, $timemodified);
 291          }
 292  
 293          foreach ($this->get_all_subq_ids() as $subqid) {
 294              $this->for_subq($subqid)->cache($qubaids, $timemodified);
 295          }
 296  
 297          $transaction->allow_commit();
 298      }
 299  
 300      /**
 301       * Return all sub-questions used.
 302       *
 303       * @return \object[] array of questions.
 304       */
 305      public function get_sub_questions() {
 306          return $this->subquestions;
 307      }
 308  
 309      /**
 310       * Return all stats for one slot, stats for the slot itself, and either :
 311       *  - variants of question
 312       *  - variants of randomly selected questions
 313       *  - randomly selected questions
 314       *
 315       * @param int      $slot          the slot no
 316       * @param bool|int $limitvariants limit number of variants and sub-questions displayed?
 317       * @return calculated|calculated_for_subquestion[] stats to display
 318       */
 319      public function structure_analysis_for_one_slot($slot, $limitvariants = false) {
 320          return array_merge(array($this->for_slot($slot)), $this->all_subq_and_variant_stats_for_slot($slot, $limitvariants));
 321      }
 322  
 323      /**
 324       * Call after calculations to output any error messages.
 325       *
 326       * @return string[] Array of strings describing error messages found during stats calculation.
 327       */
 328      public function any_error_messages() {
 329          $errors = array();
 330          foreach ($this->get_all_slots() as $slot) {
 331              foreach ($this->for_slot($slot)->get_sub_question_ids() as $subqid) {
 332                  if ($this->for_subq($subqid)->differentweights) {
 333                      $name = $this->for_subq($subqid)->question->name;
 334                      $errors[] = get_string('erroritemappearsmorethanoncewithdifferentweight', 'question', $name);
 335                  }
 336              }
 337          }
 338          return $errors;
 339      }
 340  
 341      /**
 342       * Return all stats for variants of question in slot $slot.
 343       *
 344       * @param int $slot The slot no.
 345       * @return calculated[] The instances storing the calculated stats.
 346       */
 347      protected function all_variant_stats_for_one_slot($slot) {
 348          $toreturn = array();
 349          foreach ($this->for_slot($slot)->get_variants() as $variant) {
 350              $toreturn[] = $this->for_slot($slot, $variant);
 351          }
 352          return $toreturn;
 353      }
 354  
 355      /**
 356       * Return all stats for variants of randomly selected questions for one slot $slot.
 357       *
 358       * @param int $slot The slot no.
 359       * @return calculated[] The instances storing the calculated stats.
 360       */
 361      protected function all_subq_variants_for_one_slot($slot) {
 362          $toreturn = array();
 363          $displayorder = 1;
 364          foreach ($this->for_slot($slot)->get_sub_question_ids() as $subqid) {
 365              if ($variants = $this->for_subq($subqid)->get_variants()) {
 366                  foreach ($variants as $variant) {
 367                      $toreturn[] = $this->make_new_subq_stat_for($displayorder, $slot, $subqid, $variant);
 368                  }
 369              }
 370              $displayorder++;
 371          }
 372          return $toreturn;
 373      }
 374  
 375      /**
 376       * Return all stats for randomly selected questions for one slot $slot.
 377       *
 378       * @param int $slot The slot no.
 379       * @return calculated[] The instances storing the calculated stats.
 380       */
 381      protected function all_subqs_for_one_slot($slot) {
 382          $displayorder = 1;
 383          $toreturn = array();
 384          foreach ($this->for_slot($slot)->get_sub_question_ids() as $subqid) {
 385              $toreturn[] = $this->make_new_subq_stat_for($displayorder, $slot, $subqid);
 386              $displayorder++;
 387          }
 388          return $toreturn;
 389      }
 390  
 391      /**
 392       * Return all variant or 'sub-question' stats one slot, either :
 393       *  - variants of question
 394       *  - variants of randomly selected questions
 395       *  - randomly selected questions
 396       *
 397       * @param int $slot the slot no
 398       * @param bool $limited limit number of variants and sub-questions displayed?
 399       * @return calculated|calculated_for_subquestion|calculated_question_summary[] stats to display
 400       */
 401      protected function all_subq_and_variant_stats_for_slot($slot, $limited) {
 402          // Random question in this slot?
 403          if ($this->for_slot($slot)->get_sub_question_ids()) {
 404              $toreturn = array();
 405  
 406              if ($limited) {
 407                  $randomquestioncalculated = $this->for_slot($slot);
 408  
 409                  if ($subqvariantstats = $this->all_subq_variants_for_one_slot($slot)) {
 410                      // There are some variants from randomly selected questions.
 411                      // If we're showing a limited view of the statistics then add a question summary stat
 412                      // rather than a stat for each subquestion.
 413                      $summarystat = $this->make_new_calculated_question_summary_stat($randomquestioncalculated, $subqvariantstats);
 414  
 415                      $toreturn = array_merge($toreturn, [$summarystat]);
 416                  }
 417  
 418                  if ($subqstats = $this->all_subqs_for_one_slot($slot)) {
 419                      // There are some randomly selected questions.
 420                      // If we're showing a limited view of the statistics then add a question summary stat
 421                      // rather than a stat for each subquestion.
 422                      $summarystat = $this->make_new_calculated_question_summary_stat($randomquestioncalculated, $subqstats);
 423  
 424                      $toreturn = array_merge($toreturn, [$summarystat]);
 425                  }
 426  
 427                  foreach ($toreturn as $index => $calculated) {
 428                      $calculated->subqdisplayorder = $index;
 429                  }
 430              } else {
 431                  $displaynumber = 1;
 432                  foreach ($this->for_slot($slot)->get_sub_question_ids() as $subqid) {
 433                      $toreturn[] = $this->make_new_subq_stat_for($displaynumber, $slot, $subqid);
 434                      if ($variants = $this->for_subq($subqid)->get_variants()) {
 435                          foreach ($variants as $variant) {
 436                              $toreturn[] = $this->make_new_subq_stat_for($displaynumber, $slot, $subqid, $variant);
 437                          }
 438                      }
 439                      $displaynumber++;
 440                  }
 441              }
 442  
 443              return $toreturn;
 444          } else {
 445              $variantstats = $this->all_variant_stats_for_one_slot($slot);
 446              if ($limited && $variantstats) {
 447                  $variantquestioncalculated = $this->for_slot($slot);
 448  
 449                  // If we're showing a limited view of the statistics then add a question summary stat
 450                  // rather than a stat for each variation.
 451                  $summarystat = $this->make_new_calculated_question_summary_stat($variantquestioncalculated, $variantstats);
 452  
 453                  return [$summarystat];
 454              } else {
 455                  return $variantstats;
 456              }
 457          }
 458      }
 459  
 460      /**
 461       * We need a new object for display. Sub-question stats can appear more than once in different slots.
 462       * So we create a clone of the object and then we can set properties on the object that are per slot.
 463       *
 464       * @param int  $displaynumber                   The display number for this sub question.
 465       * @param int  $slot                            The slot number.
 466       * @param int  $subqid                          The sub question id.
 467       * @param null|int $variant                     The variant no.
 468       * @return calculated_for_subquestion           The object for display.
 469       */
 470      protected function make_new_subq_stat_for($displaynumber, $slot, $subqid, $variant = null) {
 471          $slotstat = fullclone($this->for_subq($subqid, $variant));
 472          $slotstat->question->number = $this->for_slot($slot)->question->number;
 473          $slotstat->subqdisplayorder = $displaynumber;
 474          return $slotstat;
 475      }
 476  
 477      /**
 478       * Create a summary calculated object for a calculated question. This is used as a placeholder
 479       * to indicate that a calculated question has sub questions or variations to show rather than listing each
 480       * subquestion or variation directly.
 481       *
 482       * @param  calculated $randomquestioncalculated The calculated instance for the random question slot.
 483       * @param  calculated[] $subquestionstats The instances of the calculated stats of the questions that are being summarised.
 484       * @return calculated_question_summary
 485       */
 486      protected function make_new_calculated_question_summary_stat($randomquestioncalculated, $subquestionstats) {
 487          $question = $randomquestioncalculated->question;
 488          $slot = $randomquestioncalculated->slot;
 489          $calculatedsummary = new calculated_question_summary($question, $slot, $subquestionstats);
 490  
 491          return $calculatedsummary;
 492      }
 493  }