MDL-75576 quiz/question statistics: don't expire by time
[moodle.git] / question / classes / statistics / questions / all_calculated_for_qubaid_condition.php
blob9d993000a97b84914113e106e5d944845d85f7b3
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.
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/>.
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.
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
27 namespace core_question\statistics\questions;
29 use question_bank;
31 /**
32 * A collection of all the question statistics calculated for an activity instance.
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
39 class all_calculated_for_qubaid_condition {
41 /**
42 * @var int previously, the time after which statistics are automatically recomputed.
43 * @deprecated since Moodle 4.3. Use of pre-computed stats is no longer time-limited.
44 * @todo MDL-78090 Final deprecation in Moodle 4.7
46 const TIME_TO_CACHE = 900; // 15 minutes.
48 /**
49 * @var object[]
51 public $subquestions = [];
53 /**
54 * Holds slot (position) stats and stats for variants of questions in slots.
56 * @var calculated[]
58 public $questionstats = array();
60 /**
61 * Holds sub-question stats and stats for variants of subqs.
63 * @var calculated_for_subquestion[]
65 public $subquestionstats = array();
67 /**
68 * Set up a calculated_for_subquestion instance ready to store a randomly selected question's stats.
70 * @param object $step
71 * @param int|null $variant Is this to keep track of a variant's stats? If so what is the variant, if not null.
73 public function initialise_for_subq($step, $variant = null) {
74 $newsubqstat = new calculated_for_subquestion($step, $variant);
75 if ($variant === null) {
76 $this->subquestionstats[$step->questionid] = $newsubqstat;
77 } else {
78 $this->subquestionstats[$step->questionid]->variantstats[$variant] = $newsubqstat;
82 /**
83 * Set up a calculated instance ready to store a slot question's stats.
85 * @param int $slot
86 * @param object $question
87 * @param int|null $variant Is this to keep track of a variant's stats? If so what is the variant, if not null.
89 public function initialise_for_slot($slot, $question, $variant = null) {
90 $newqstat = new calculated($question, $slot, $variant);
91 if ($variant === null) {
92 $this->questionstats[$slot] = $newqstat;
93 } else {
94 $this->questionstats[$slot]->variantstats[$variant] = $newqstat;
98 /**
99 * Do we have stats for a particular quesitonid (and optionally variant)?
101 * @param int $questionid The id of the sub question.
102 * @param int|null $variant if not null then we want the object to store a variant of a sub-question's stats.
103 * @return bool whether those stats exist (yet).
105 public function has_subq($questionid, $variant = null) {
106 if ($variant === null) {
107 return isset($this->subquestionstats[$questionid]);
108 } else {
109 return isset($this->subquestionstats[$questionid]->variantstats[$variant]);
114 * Reference for a item stats instance for a questionid and optional variant no.
116 * @param int $questionid The id of the sub question.
117 * @param int|null $variant if not null then we want the object to store a variant of a sub-question's stats.
118 * @return calculated|calculated_for_subquestion stats instance for a questionid and optional variant no.
119 * Will be a calculated_for_subquestion if no variant specified.
120 * @throws \coding_exception if there is an attempt to respond to a non-existant set of stats.
122 public function for_subq($questionid, $variant = null) {
123 if ($variant === null) {
124 if (!isset($this->subquestionstats[$questionid])) {
125 throw new \coding_exception('Reference to unknown question id ' . $questionid);
126 } else {
127 return $this->subquestionstats[$questionid];
129 } else {
130 if (!isset($this->subquestionstats[$questionid]->variantstats[$variant])) {
131 throw new \coding_exception('Reference to unknown question id ' . $questionid .
132 ' variant ' . $variant);
133 } else {
134 return $this->subquestionstats[$questionid]->variantstats[$variant];
140 * ids of all randomly selected question for all slots.
142 * @return int[] An array of all sub-question ids.
144 public function get_all_subq_ids() {
145 return array_keys($this->subquestionstats);
149 * All slots nos that stats have been calculated for.
151 * @return int[] An array of all slot nos.
153 public function get_all_slots() {
154 return array_keys($this->questionstats);
158 * Do we have stats for a particular slot (and optionally variant)?
160 * @param int $slot The slot no.
161 * @param int|null $variant if provided then we want the object which stores a variant of a position's stats.
162 * @return bool whether those stats exist (yet).
164 public function has_slot($slot, $variant = null) {
165 if ($variant === null) {
166 return isset($this->questionstats[$slot]);
167 } else {
168 return isset($this->questionstats[$slot]->variantstats[$variant]);
173 * Get position stats instance for a slot and optional variant no.
175 * @param int $slot The slot no.
176 * @param int|null $variant if provided then we want the object which stores a variant of a position's stats.
177 * @return calculated|calculated_for_subquestion An instance of the class storing the calculated position stats.
178 * @throws \coding_exception if there is an attempt to respond to a non-existant set of stats.
180 public function for_slot($slot, $variant = null) {
181 if ($variant === null) {
182 if (!isset($this->questionstats[$slot])) {
183 throw new \coding_exception('Reference to unknown slot ' . $slot);
184 } else {
185 return $this->questionstats[$slot];
187 } else {
188 if (!isset($this->questionstats[$slot]->variantstats[$variant])) {
189 throw new \coding_exception('Reference to unknown slot ' . $slot . ' variant ' . $variant);
190 } else {
191 return $this->questionstats[$slot]->variantstats[$variant];
197 * Load cached statistics from the database.
199 * @param \qubaid_condition $qubaids Which question usages to load stats for?
201 public function get_cached($qubaids) {
202 global $DB;
204 $timemodified = self::get_last_calculated_time($qubaids);
205 $questionstatrecs = $DB->get_records('question_statistics',
206 ['hashcode' => $qubaids->get_hash_code(), 'timemodified' => $timemodified]);
208 $questionids = array();
209 foreach ($questionstatrecs as $fromdb) {
210 if (is_null($fromdb->variant) && !$fromdb->slot) {
211 $questionids[] = $fromdb->questionid;
214 $this->subquestions = question_load_questions($questionids);
215 foreach ($questionstatrecs as $fromdb) {
216 if (is_null($fromdb->variant)) {
217 if ($fromdb->slot) {
218 $this->questionstats[$fromdb->slot]->populate_from_record($fromdb);
219 // Array created in constructor and populated from question.
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 =
228 question_bank::get_qtype('missingtype', false)->make_deleted_instance($fromdb->questionid, 1);
233 // Add cached variant stats to data structure.
234 foreach ($questionstatrecs as $fromdb) {
235 if (!is_null($fromdb->variant)) {
236 if ($fromdb->slot) {
237 $newcalcinstance = new calculated();
238 $this->questionstats[$fromdb->slot]->variantstats[$fromdb->variant] = $newcalcinstance;
239 $newcalcinstance->question = $this->questionstats[$fromdb->slot]->question;
240 } else {
241 $newcalcinstance = new calculated_for_subquestion();
242 $this->subquestionstats[$fromdb->questionid]->variantstats[$fromdb->variant] = $newcalcinstance;
243 $newcalcinstance->question = $this->subquestions[$fromdb->questionid];
245 $newcalcinstance->populate_from_record($fromdb);
251 * Find time of non-expired statistics in the database.
253 * @param \qubaid_condition $qubaids Which question usages to look for stats for?
254 * @return int|bool Time of cached record that matches this qubaid_condition or false if non found.
256 public function get_last_calculated_time($qubaids) {
257 global $DB;
258 $lastcalculatedtime = $DB->get_field('question_statistics', 'COALESCE(MAX(timemodified), 0)',
259 ['hashcode' => $qubaids->get_hash_code()]);
260 if ($lastcalculatedtime) {
261 return $lastcalculatedtime;
262 } else {
263 return false;
268 * Save stats to db, first cleaning up any old ones.
270 * @param \qubaid_condition $qubaids Which question usages are we caching the stats of?
272 public function cache($qubaids) {
273 global $DB;
275 $transaction = $DB->start_delegated_transaction();
276 $timemodified = time();
278 foreach ($this->get_all_slots() as $slot) {
279 $this->for_slot($slot)->cache($qubaids);
282 foreach ($this->get_all_subq_ids() as $subqid) {
283 $this->for_subq($subqid)->cache($qubaids);
286 $transaction->allow_commit();
290 * Return all sub-questions used.
292 * @return \object[] array of questions.
294 public function get_sub_questions() {
295 return $this->subquestions;
299 * Return all stats for one slot, stats for the slot itself, and either :
300 * - variants of question
301 * - variants of randomly selected questions
302 * - randomly selected questions
304 * @param int $slot the slot no
305 * @param bool|int $limitvariants limit number of variants and sub-questions displayed?
306 * @return calculated|calculated_for_subquestion[] stats to display
308 public function structure_analysis_for_one_slot($slot, $limitvariants = false) {
309 return array_merge(array($this->for_slot($slot)), $this->all_subq_and_variant_stats_for_slot($slot, $limitvariants));
313 * Call after calculations to output any error messages.
315 * @return string[] Array of strings describing error messages found during stats calculation.
317 public function any_error_messages() {
318 $errors = array();
319 foreach ($this->get_all_slots() as $slot) {
320 foreach ($this->for_slot($slot)->get_sub_question_ids() as $subqid) {
321 if ($this->for_subq($subqid)->differentweights) {
322 $name = $this->for_subq($subqid)->question->name;
323 $errors[] = get_string('erroritemappearsmorethanoncewithdifferentweight', 'question', $name);
327 return $errors;
331 * Return all stats for variants of question in slot $slot.
333 * @param int $slot The slot no.
334 * @return calculated[] The instances storing the calculated stats.
336 protected function all_variant_stats_for_one_slot($slot) {
337 $toreturn = array();
338 foreach ($this->for_slot($slot)->get_variants() as $variant) {
339 $toreturn[] = $this->for_slot($slot, $variant);
341 return $toreturn;
345 * Return all stats for variants of randomly selected questions for one slot $slot.
347 * @param int $slot The slot no.
348 * @return calculated[] The instances storing the calculated stats.
350 protected function all_subq_variants_for_one_slot($slot) {
351 $toreturn = array();
352 $displayorder = 1;
353 foreach ($this->for_slot($slot)->get_sub_question_ids() as $subqid) {
354 if ($variants = $this->for_subq($subqid)->get_variants()) {
355 foreach ($variants as $variant) {
356 $toreturn[] = $this->make_new_subq_stat_for($displayorder, $slot, $subqid, $variant);
359 $displayorder++;
361 return $toreturn;
365 * Return all stats for randomly selected questions for one slot $slot.
367 * @param int $slot The slot no.
368 * @return calculated[] The instances storing the calculated stats.
370 protected function all_subqs_for_one_slot($slot) {
371 $displayorder = 1;
372 $toreturn = array();
373 foreach ($this->for_slot($slot)->get_sub_question_ids() as $subqid) {
374 $toreturn[] = $this->make_new_subq_stat_for($displayorder, $slot, $subqid);
375 $displayorder++;
377 return $toreturn;
381 * Return all variant or 'sub-question' stats one slot, either :
382 * - variants of question
383 * - variants of randomly selected questions
384 * - randomly selected questions
386 * @param int $slot the slot no
387 * @param bool $limited limit number of variants and sub-questions displayed?
388 * @return calculated|calculated_for_subquestion|calculated_question_summary[] stats to display
390 protected function all_subq_and_variant_stats_for_slot($slot, $limited) {
391 // Random question in this slot?
392 if ($this->for_slot($slot)->get_sub_question_ids()) {
393 $toreturn = array();
395 if ($limited) {
396 $randomquestioncalculated = $this->for_slot($slot);
398 if ($subqvariantstats = $this->all_subq_variants_for_one_slot($slot)) {
399 // There are some variants from randomly selected questions.
400 // If we're showing a limited view of the statistics then add a question summary stat
401 // rather than a stat for each subquestion.
402 $summarystat = $this->make_new_calculated_question_summary_stat($randomquestioncalculated, $subqvariantstats);
404 $toreturn = array_merge($toreturn, [$summarystat]);
407 if ($subqstats = $this->all_subqs_for_one_slot($slot)) {
408 // There are some randomly selected questions.
409 // If we're showing a limited view of the statistics then add a question summary stat
410 // rather than a stat for each subquestion.
411 $summarystat = $this->make_new_calculated_question_summary_stat($randomquestioncalculated, $subqstats);
413 $toreturn = array_merge($toreturn, [$summarystat]);
416 foreach ($toreturn as $index => $calculated) {
417 $calculated->subqdisplayorder = $index;
419 } else {
420 $displaynumber = 1;
421 foreach ($this->for_slot($slot)->get_sub_question_ids() as $subqid) {
422 $toreturn[] = $this->make_new_subq_stat_for($displaynumber, $slot, $subqid);
423 if ($variants = $this->for_subq($subqid)->get_variants()) {
424 foreach ($variants as $variant) {
425 $toreturn[] = $this->make_new_subq_stat_for($displaynumber, $slot, $subqid, $variant);
428 $displaynumber++;
432 return $toreturn;
433 } else {
434 $variantstats = $this->all_variant_stats_for_one_slot($slot);
435 if ($limited && $variantstats) {
436 $variantquestioncalculated = $this->for_slot($slot);
438 // If we're showing a limited view of the statistics then add a question summary stat
439 // rather than a stat for each variation.
440 $summarystat = $this->make_new_calculated_question_summary_stat($variantquestioncalculated, $variantstats);
442 return [$summarystat];
443 } else {
444 return $variantstats;
450 * We need a new object for display. Sub-question stats can appear more than once in different slots.
451 * So we create a clone of the object and then we can set properties on the object that are per slot.
453 * @param int $displaynumber The display number for this sub question.
454 * @param int $slot The slot number.
455 * @param int $subqid The sub question id.
456 * @param null|int $variant The variant no.
457 * @return calculated_for_subquestion The object for display.
459 protected function make_new_subq_stat_for($displaynumber, $slot, $subqid, $variant = null) {
460 $slotstat = fullclone($this->for_subq($subqid, $variant));
461 $slotstat->question->number = $this->for_slot($slot)->question->number;
462 $slotstat->subqdisplayorder = $displaynumber;
463 return $slotstat;
467 * Create a summary calculated object for a calculated question. This is used as a placeholder
468 * to indicate that a calculated question has sub questions or variations to show rather than listing each
469 * subquestion or variation directly.
471 * @param calculated $randomquestioncalculated The calculated instance for the random question slot.
472 * @param calculated[] $subquestionstats The instances of the calculated stats of the questions that are being summarised.
473 * @return calculated_question_summary
475 protected function make_new_calculated_question_summary_stat($randomquestioncalculated, $subquestionstats) {
476 $question = $randomquestioncalculated->question;
477 $slot = $randomquestioncalculated->slot;
478 $calculatedsummary = new calculated_question_summary($question, $slot, $subquestionstats);
480 return $calculatedsummary;