Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.0.x will end 8 May 2023 (12 months).
  • Bug fixes for security issues in 4.0.x will end 13 November 2023 (18 months).
  • PHP version: minimum PHP 7.3.0 Note: the minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is also supported.

Differences Between: [Versions 310 and 400] [Versions 311 and 400] [Versions 39 and 400] [Versions 400 and 401] [Versions 400 and 402] [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  /**
  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                      $this->questionstats[$fromdb->slot]->populate_from_record($fromdb);
 215                      // Array created in constructor and populated from question.
 216                  } else {
 217                      $this->subquestionstats[$fromdb->questionid] = new calculated_for_subquestion();
 218                      $this->subquestionstats[$fromdb->questionid]->populate_from_record($fromdb);
 219                      if (isset($this->subquestions[$fromdb->questionid])) {
 220                          $this->subquestionstats[$fromdb->questionid]->question =
 221                                  $this->subquestions[$fromdb->questionid];
 222                      } else {
 223                          $this->subquestionstats[$fromdb->questionid]->question =
 224                                  question_bank::get_qtype('missingtype', false)->make_deleted_instance($fromdb->questionid, 1);
 225                      }
 226                  }
 227              }
 228          }
 229          // Add cached variant stats to data structure.
 230          foreach ($questionstatrecs as $fromdb) {
 231              if (!is_null($fromdb->variant)) {
 232                  if ($fromdb->slot) {
 233                      $newcalcinstance = new calculated();
 234                      $this->questionstats[$fromdb->slot]->variantstats[$fromdb->variant] = $newcalcinstance;
 235                      $newcalcinstance->question = $this->questionstats[$fromdb->slot]->question;
 236                  } else {
 237                      $newcalcinstance = new calculated_for_subquestion();
 238                      $this->subquestionstats[$fromdb->questionid]->variantstats[$fromdb->variant] = $newcalcinstance;
 239                      $newcalcinstance->question = $this->subquestions[$fromdb->questionid];
 240                  }
 241                  $newcalcinstance->populate_from_record($fromdb);
 242              }
 243          }
 244      }
 245  
 246      /**
 247       * Find time of non-expired statistics in the database.
 248       *
 249       * @param \qubaid_condition $qubaids Which question usages to look for stats for?
 250       * @return int|bool Time of cached record that matches this qubaid_condition or false if non found.
 251       */
 252      public function get_last_calculated_time($qubaids) {
 253          global $DB;
 254          $lastcalculatedtime = $DB->get_field('question_statistics', 'COALESCE(MAX(timemodified), 0)',
 255                  ['hashcode' => $qubaids->get_hash_code()]);
 256          if ($lastcalculatedtime) {
 257              return $lastcalculatedtime;
 258          } else {
 259              return false;
 260          }
 261      }
 262  
 263      /**
 264       * Save stats to db, first cleaning up any old ones.
 265       *
 266       * @param \qubaid_condition $qubaids Which question usages are we caching the stats of?
 267       */
 268      public function cache($qubaids) {
 269          global $DB;
 270  
 271          $transaction = $DB->start_delegated_transaction();
 272          $timemodified = time();
 273  
 274          foreach ($this->get_all_slots() as $slot) {
 275              $this->for_slot($slot)->cache($qubaids, $timemodified);
 276          }
 277  
 278          foreach ($this->get_all_subq_ids() as $subqid) {
 279              $this->for_subq($subqid)->cache($qubaids, $timemodified);
 280          }
 281  
 282          $transaction->allow_commit();
 283      }
 284  
 285      /**
 286       * Return all sub-questions used.
 287       *
 288       * @return \object[] array of questions.
 289       */
 290      public function get_sub_questions() {
 291          return $this->subquestions;
 292      }
 293  
 294      /**
 295       * Return all stats for one slot, stats for the slot itself, and either :
 296       *  - variants of question
 297       *  - variants of randomly selected questions
 298       *  - randomly selected questions
 299       *
 300       * @param int      $slot          the slot no
 301       * @param bool|int $limitvariants limit number of variants and sub-questions displayed?
 302       * @return calculated|calculated_for_subquestion[] stats to display
 303       */
 304      public function structure_analysis_for_one_slot($slot, $limitvariants = false) {
 305          return array_merge(array($this->for_slot($slot)), $this->all_subq_and_variant_stats_for_slot($slot, $limitvariants));
 306      }
 307  
 308      /**
 309       * Call after calculations to output any error messages.
 310       *
 311       * @return string[] Array of strings describing error messages found during stats calculation.
 312       */
 313      public function any_error_messages() {
 314          $errors = array();
 315          foreach ($this->get_all_slots() as $slot) {
 316              foreach ($this->for_slot($slot)->get_sub_question_ids() as $subqid) {
 317                  if ($this->for_subq($subqid)->differentweights) {
 318                      $name = $this->for_subq($subqid)->question->name;
 319                      $errors[] = get_string('erroritemappearsmorethanoncewithdifferentweight', 'question', $name);
 320                  }
 321              }
 322          }
 323          return $errors;
 324      }
 325  
 326      /**
 327       * Return all stats for variants of question in slot $slot.
 328       *
 329       * @param int $slot The slot no.
 330       * @return calculated[] The instances storing the calculated stats.
 331       */
 332      protected function all_variant_stats_for_one_slot($slot) {
 333          $toreturn = array();
 334          foreach ($this->for_slot($slot)->get_variants() as $variant) {
 335              $toreturn[] = $this->for_slot($slot, $variant);
 336          }
 337          return $toreturn;
 338      }
 339  
 340      /**
 341       * Return all stats for variants of randomly selected questions for one slot $slot.
 342       *
 343       * @param int $slot The slot no.
 344       * @return calculated[] The instances storing the calculated stats.
 345       */
 346      protected function all_subq_variants_for_one_slot($slot) {
 347          $toreturn = array();
 348          $displayorder = 1;
 349          foreach ($this->for_slot($slot)->get_sub_question_ids() as $subqid) {
 350              if ($variants = $this->for_subq($subqid)->get_variants()) {
 351                  foreach ($variants as $variant) {
 352                      $toreturn[] = $this->make_new_subq_stat_for($displayorder, $slot, $subqid, $variant);
 353                  }
 354              }
 355              $displayorder++;
 356          }
 357          return $toreturn;
 358      }
 359  
 360      /**
 361       * Return all stats for randomly selected questions for one slot $slot.
 362       *
 363       * @param int $slot The slot no.
 364       * @return calculated[] The instances storing the calculated stats.
 365       */
 366      protected function all_subqs_for_one_slot($slot) {
 367          $displayorder = 1;
 368          $toreturn = array();
 369          foreach ($this->for_slot($slot)->get_sub_question_ids() as $subqid) {
 370              $toreturn[] = $this->make_new_subq_stat_for($displayorder, $slot, $subqid);
 371              $displayorder++;
 372          }
 373          return $toreturn;
 374      }
 375  
 376      /**
 377       * Return all variant or 'sub-question' stats one slot, either :
 378       *  - variants of question
 379       *  - variants of randomly selected questions
 380       *  - randomly selected questions
 381       *
 382       * @param int $slot the slot no
 383       * @param bool $limited limit number of variants and sub-questions displayed?
 384       * @return calculated|calculated_for_subquestion|calculated_question_summary[] stats to display
 385       */
 386      protected function all_subq_and_variant_stats_for_slot($slot, $limited) {
 387          // Random question in this slot?
 388          if ($this->for_slot($slot)->get_sub_question_ids()) {
 389              $toreturn = array();
 390  
 391              if ($limited) {
 392                  $randomquestioncalculated = $this->for_slot($slot);
 393  
 394                  if ($subqvariantstats = $this->all_subq_variants_for_one_slot($slot)) {
 395                      // There are some variants from randomly selected questions.
 396                      // If we're showing a limited view of the statistics then add a question summary stat
 397                      // rather than a stat for each subquestion.
 398                      $summarystat = $this->make_new_calculated_question_summary_stat($randomquestioncalculated, $subqvariantstats);
 399  
 400                      $toreturn = array_merge($toreturn, [$summarystat]);
 401                  }
 402  
 403                  if ($subqstats = $this->all_subqs_for_one_slot($slot)) {
 404                      // There are some randomly selected questions.
 405                      // If we're showing a limited view of the statistics then add a question summary stat
 406                      // rather than a stat for each subquestion.
 407                      $summarystat = $this->make_new_calculated_question_summary_stat($randomquestioncalculated, $subqstats);
 408  
 409                      $toreturn = array_merge($toreturn, [$summarystat]);
 410                  }
 411  
 412                  foreach ($toreturn as $index => $calculated) {
 413                      $calculated->subqdisplayorder = $index;
 414                  }
 415              } else {
 416                  $displaynumber = 1;
 417                  foreach ($this->for_slot($slot)->get_sub_question_ids() as $subqid) {
 418                      $toreturn[] = $this->make_new_subq_stat_for($displaynumber, $slot, $subqid);
 419                      if ($variants = $this->for_subq($subqid)->get_variants()) {
 420                          foreach ($variants as $variant) {
 421                              $toreturn[] = $this->make_new_subq_stat_for($displaynumber, $slot, $subqid, $variant);
 422                          }
 423                      }
 424                      $displaynumber++;
 425                  }
 426              }
 427  
 428              return $toreturn;
 429          } else {
 430              $variantstats = $this->all_variant_stats_for_one_slot($slot);
 431              if ($limited && $variantstats) {
 432                  $variantquestioncalculated = $this->for_slot($slot);
 433  
 434                  // If we're showing a limited view of the statistics then add a question summary stat
 435                  // rather than a stat for each variation.
 436                  $summarystat = $this->make_new_calculated_question_summary_stat($variantquestioncalculated, $variantstats);
 437  
 438                  return [$summarystat];
 439              } else {
 440                  return $variantstats;
 441              }
 442          }
 443      }
 444  
 445      /**
 446       * We need a new object for display. Sub-question stats can appear more than once in different slots.
 447       * So we create a clone of the object and then we can set properties on the object that are per slot.
 448       *
 449       * @param int  $displaynumber                   The display number for this sub question.
 450       * @param int  $slot                            The slot number.
 451       * @param int  $subqid                          The sub question id.
 452       * @param null|int $variant                     The variant no.
 453       * @return calculated_for_subquestion           The object for display.
 454       */
 455      protected function make_new_subq_stat_for($displaynumber, $slot, $subqid, $variant = null) {
 456          $slotstat = fullclone($this->for_subq($subqid, $variant));
 457          $slotstat->question->number = $this->for_slot($slot)->question->number;
 458          $slotstat->subqdisplayorder = $displaynumber;
 459          return $slotstat;
 460      }
 461  
 462      /**
 463       * Create a summary calculated object for a calculated question. This is used as a placeholder
 464       * to indicate that a calculated question has sub questions or variations to show rather than listing each
 465       * subquestion or variation directly.
 466       *
 467       * @param  calculated $randomquestioncalculated The calculated instance for the random question slot.
 468       * @param  calculated[] $subquestionstats The instances of the calculated stats of the questions that are being summarised.
 469       * @return calculated_question_summary
 470       */
 471      protected function make_new_calculated_question_summary_stat($randomquestioncalculated, $subquestionstats) {
 472          $question = $randomquestioncalculated->question;
 473          $slot = $randomquestioncalculated->slot;
 474          $calculatedsummary = new calculated_question_summary($question, $slot, $subquestionstats);
 475  
 476          return $calculatedsummary;
 477      }
 478  }