MDL-75576 quiz/question statistics: don't expire by time
[moodle.git] / mod / quiz / report / statistics / report.php
blob4dba84328a57e00bbef7b51ae74bf2cb82a68478
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 * Quiz statistics report class.
20 * @package quiz_statistics
21 * @copyright 2014 Open University
22 * @author James Pratt <me@jamiep.org>
23 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
26 defined('MOODLE_INTERNAL') || die();
28 use core_question\statistics\responses\analyser;
29 use mod_quiz\local\reports\report_base;
30 use core_question\statistics\questions\all_calculated_for_qubaid_condition;
32 require_once($CFG->dirroot . '/mod/quiz/report/reportlib.php');
33 require_once($CFG->dirroot . '/mod/quiz/report/statistics/statistics_form.php');
34 require_once($CFG->dirroot . '/mod/quiz/report/statistics/statistics_table.php');
35 require_once($CFG->dirroot . '/mod/quiz/report/statistics/statistics_question_table.php');
36 require_once($CFG->dirroot . '/mod/quiz/report/statistics/statisticslib.php');
38 /**
39 * The quiz statistics report provides summary information about each question in
40 * a quiz, compared to the whole quiz. It also provides a drill-down to more
41 * detailed information about each question.
43 * @copyright 2008 Jamie Pratt
44 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
46 class quiz_statistics_report extends report_base {
48 /** @var context_module context of this quiz.*/
49 protected $context;
51 /** @var quiz_statistics_table instance of table class used for main questions stats table. */
52 protected $table;
54 /** @var \core\progress\base|null $progress Handles progress reporting or not. */
55 protected $progress = null;
57 /**
58 * Display the report.
60 public function display($quiz, $cm, $course) {
61 global $OUTPUT, $DB;
63 raise_memory_limit(MEMORY_HUGE);
65 $this->context = context_module::instance($cm->id);
67 if (!quiz_has_questions($quiz->id)) {
68 $this->print_header_and_tabs($cm, $course, $quiz, 'statistics');
69 echo quiz_no_questions_message($quiz, $cm, $this->context);
70 return true;
73 // Work out the display options.
74 $download = optional_param('download', '', PARAM_ALPHA);
75 $everything = optional_param('everything', 0, PARAM_BOOL);
76 $recalculate = optional_param('recalculate', 0, PARAM_BOOL);
77 // A qid paramter indicates we should display the detailed analysis of a sub question.
78 $qid = optional_param('qid', 0, PARAM_INT);
79 $slot = optional_param('slot', 0, PARAM_INT);
80 $variantno = optional_param('variant', null, PARAM_INT);
81 $whichattempts = optional_param('whichattempts', $quiz->grademethod, PARAM_INT);
82 $whichtries = optional_param('whichtries', question_attempt::LAST_TRY, PARAM_ALPHA);
84 $pageoptions = [];
85 $pageoptions['id'] = $cm->id;
86 $pageoptions['mode'] = 'statistics';
88 $reporturl = new moodle_url('/mod/quiz/report.php', $pageoptions);
90 $mform = new quiz_statistics_settings_form($reporturl, compact('quiz'));
92 $mform->set_data(['whichattempts' => $whichattempts, 'whichtries' => $whichtries]);
94 if ($whichattempts != $quiz->grademethod) {
95 $reporturl->param('whichattempts', $whichattempts);
98 if ($whichtries != question_attempt::LAST_TRY) {
99 $reporturl->param('whichtries', $whichtries);
102 // Find out current groups mode.
103 $currentgroup = $this->get_current_group($cm, $course, $this->context);
104 $nostudentsingroup = false; // True if a group is selected and there is no one in it.
105 if (empty($currentgroup)) {
106 $currentgroup = 0;
107 $groupstudentsjoins = new \core\dml\sql_join();
109 } else if ($currentgroup == self::NO_GROUPS_ALLOWED) {
110 $groupstudentsjoins = new \core\dml\sql_join();
111 $nostudentsingroup = true;
113 } else {
114 // All users who can attempt quizzes and who are in the currently selected group.
115 $groupstudentsjoins = get_enrolled_with_capabilities_join($this->context, '',
116 ['mod/quiz:reviewmyattempts', 'mod/quiz:attempt'], $currentgroup);
117 if (!empty($groupstudentsjoins->joins)) {
118 $sql = "SELECT DISTINCT u.id
119 FROM {user} u
120 {$groupstudentsjoins->joins}
121 WHERE {$groupstudentsjoins->wheres}";
122 if (!$DB->record_exists_sql($sql, $groupstudentsjoins->params)) {
123 $nostudentsingroup = true;
128 $qubaids = quiz_statistics_qubaids_condition($quiz->id, $groupstudentsjoins, $whichattempts);
130 // If recalculate was requested, handle that.
131 if ($recalculate && confirm_sesskey()) {
132 $this->clear_cached_data($qubaids);
133 redirect($reporturl);
136 // Set up the main table.
137 $this->table = new quiz_statistics_table();
138 if ($everything) {
139 $report = get_string('completestatsfilename', 'quiz_statistics');
140 } else {
141 $report = get_string('questionstatsfilename', 'quiz_statistics');
143 $courseshortname = format_string($course->shortname, true,
144 ['context' => context_course::instance($course->id)]);
145 $filename = quiz_report_download_filename($report, $courseshortname, $quiz->name);
146 $this->table->is_downloading($download, $filename,
147 get_string('quizstructureanalysis', 'quiz_statistics'));
148 $questions = $this->load_and_initialise_questions_for_calculations($quiz);
150 // Print the page header stuff (if not downloading.
151 if (!$this->table->is_downloading()) {
152 $this->print_header_and_tabs($cm, $course, $quiz, 'statistics');
155 if (!$nostudentsingroup) {
156 // Get the data to be displayed.
157 $progress = $this->get_progress_trace_instance();
158 list($quizstats, $questionstats) =
159 $this->get_all_stats_and_analysis($quiz, $whichattempts, $whichtries, $groupstudentsjoins, $questions, $progress);
160 } else {
161 // Or create empty stats containers.
162 $quizstats = new \quiz_statistics\calculated($whichattempts);
163 $questionstats = new \core_question\statistics\questions\all_calculated_for_qubaid_condition();
166 // Set up the table.
167 $this->table->statistics_setup($quiz, $cm->id, $reporturl, $quizstats->s());
169 // Print the rest of the page header stuff (if not downloading.
170 if (!$this->table->is_downloading()) {
172 if (groups_get_activity_groupmode($cm)) {
173 groups_print_activity_menu($cm, $reporturl->out());
174 if ($currentgroup && $nostudentsingroup) {
175 $OUTPUT->notification(get_string('nostudentsingroup', 'quiz_statistics'));
179 if (!$this->table->is_downloading() && $quizstats->s() == 0) {
180 echo $OUTPUT->notification(get_string('nogradedattempts', 'quiz_statistics'));
183 foreach ($questionstats->any_error_messages() as $errormessage) {
184 echo $OUTPUT->notification($errormessage);
187 // Print display options form.
188 $mform->display();
191 if ($everything) { // Implies is downloading.
192 // Overall report, then the analysis of each question.
193 $quizinfo = $quizstats->get_formatted_quiz_info_data($course, $cm, $quiz);
194 $this->download_quiz_info_table($quizinfo);
196 if ($quizstats->s()) {
197 $this->output_quiz_structure_analysis_table($questionstats);
199 if ($this->table->is_downloading() == 'html' && $quizstats->s() != 0) {
200 $this->output_statistics_graph($quiz, $qubaids);
203 $this->output_all_question_response_analysis($qubaids, $questions, $questionstats, $reporturl, $whichtries);
206 $this->table->export_class_instance()->finish_document();
208 } else if ($qid) {
209 // Report on an individual sub-question indexed questionid.
210 if (!$questionstats->has_subq($qid, $variantno)) {
211 throw new \moodle_exception('questiondoesnotexist', 'question');
214 $this->output_individual_question_data($quiz, $questionstats->for_subq($qid, $variantno));
215 $this->output_individual_question_response_analysis($questionstats->for_subq($qid, $variantno)->question,
216 $variantno,
217 $questionstats->for_subq($qid, $variantno)->s,
218 $reporturl,
219 $qubaids,
220 $whichtries);
221 // Back to overview link.
222 echo $OUTPUT->box('<a href="' . $reporturl->out() . '">' .
223 get_string('backtoquizreport', 'quiz_statistics') . '</a>',
224 'boxaligncenter generalbox boxwidthnormal mdl-align');
225 } else if ($slot) {
226 // Report on an individual question indexed by position.
227 if (!isset($questions[$slot])) {
228 throw new \moodle_exception('questiondoesnotexist', 'question');
231 if ($variantno === null &&
232 ($questionstats->for_slot($slot)->get_sub_question_ids()
233 || $questionstats->for_slot($slot)->get_variants())) {
234 if (!$this->table->is_downloading()) {
235 $number = $questionstats->for_slot($slot)->question->number;
236 echo $OUTPUT->heading(get_string('slotstructureanalysis', 'quiz_statistics', $number), 3);
238 $this->table->define_baseurl(new moodle_url($reporturl, ['slot' => $slot]));
239 $this->table->format_and_add_array_of_rows($questionstats->structure_analysis_for_one_slot($slot));
240 } else {
241 $this->output_individual_question_data($quiz, $questionstats->for_slot($slot, $variantno));
242 $this->output_individual_question_response_analysis($questions[$slot],
243 $variantno,
244 $questionstats->for_slot($slot, $variantno)->s,
245 $reporturl,
246 $qubaids,
247 $whichtries);
249 if (!$this->table->is_downloading()) {
250 // Back to overview link.
251 echo $OUTPUT->box('<a href="' . $reporturl->out() . '">' .
252 get_string('backtoquizreport', 'quiz_statistics') . '</a>',
253 'backtomainstats boxaligncenter generalbox boxwidthnormal mdl-align');
254 } else {
255 $this->table->finish_output();
258 } else if ($this->table->is_downloading()) {
259 // Downloading overview report.
260 $quizinfo = $quizstats->get_formatted_quiz_info_data($course, $cm, $quiz);
261 $this->download_quiz_info_table($quizinfo);
262 if ($quizstats->s()) {
263 $this->output_quiz_structure_analysis_table($questionstats);
265 $this->table->export_class_instance()->finish_document();
267 } else {
268 // On-screen display of overview report.
269 echo $OUTPUT->heading(get_string('quizinformation', 'quiz_statistics'), 3);
270 echo $this->output_caching_info($quizstats->timemodified, $quiz->id, $groupstudentsjoins, $whichattempts, $reporturl);
271 echo $this->everything_download_options($reporturl);
272 $quizinfo = $quizstats->get_formatted_quiz_info_data($course, $cm, $quiz);
273 echo $this->output_quiz_info_table($quizinfo);
274 if ($quizstats->s()) {
275 echo $OUTPUT->heading(get_string('quizstructureanalysis', 'quiz_statistics'), 3);
276 $this->output_quiz_structure_analysis_table($questionstats);
277 $this->output_statistics_graph($quiz, $qubaids);
281 return true;
285 * Display the statistical and introductory information about a question.
286 * Only called when not downloading.
288 * @param stdClass $quiz the quiz settings.
289 * @param \core_question\statistics\questions\calculated $questionstat the question to report on.
291 protected function output_individual_question_data($quiz, $questionstat) {
292 global $OUTPUT;
294 // On-screen display. Show a summary of the question's place in the quiz,
295 // and the question statistics.
296 $datumfromtable = $this->table->format_row($questionstat);
298 // Set up the question info table.
299 $questioninfotable = new html_table();
300 $questioninfotable->align = ['center', 'center'];
301 $questioninfotable->width = '60%';
302 $questioninfotable->attributes['class'] = 'generaltable titlesleft';
304 $questioninfotable->data = [];
305 $questioninfotable->data[] = [get_string('modulename', 'quiz'), $quiz->name];
306 $questioninfotable->data[] = [get_string('questionname', 'quiz_statistics'),
307 $questionstat->question->name.'&nbsp;'.$datumfromtable['actions']];
309 if ($questionstat->variant !== null) {
310 $questioninfotable->data[] = [get_string('variant', 'quiz_statistics'), $questionstat->variant];
313 $questioninfotable->data[] = [get_string('questiontype', 'quiz_statistics'),
314 $datumfromtable['icon'] . '&nbsp;' .
315 question_bank::get_qtype($questionstat->question->qtype, false)->menu_name() . '&nbsp;' .
316 $datumfromtable['icon']];
317 $questioninfotable->data[] = [get_string('positions', 'quiz_statistics'),
318 $questionstat->positions];
320 // Set up the question statistics table.
321 $questionstatstable = new html_table();
322 $questionstatstable->align = ['center', 'center'];
323 $questionstatstable->width = '60%';
324 $questionstatstable->attributes['class'] = 'generaltable titlesleft';
326 unset($datumfromtable['number']);
327 unset($datumfromtable['icon']);
328 $actions = $datumfromtable['actions'];
329 unset($datumfromtable['actions']);
330 unset($datumfromtable['name']);
331 $labels = [
332 's' => get_string('attempts', 'quiz_statistics'),
333 'facility' => get_string('facility', 'quiz_statistics'),
334 'sd' => get_string('standarddeviationq', 'quiz_statistics'),
335 'random_guess_score' => get_string('random_guess_score', 'quiz_statistics'),
336 'intended_weight' => get_string('intended_weight', 'quiz_statistics'),
337 'effective_weight' => get_string('effective_weight', 'quiz_statistics'),
338 'discrimination_index' => get_string('discrimination_index', 'quiz_statistics'),
339 'discriminative_efficiency' =>
340 get_string('discriminative_efficiency', 'quiz_statistics')
342 foreach ($datumfromtable as $item => $value) {
343 $questionstatstable->data[] = [$labels[$item], $value];
346 // Display the various bits.
347 echo $OUTPUT->heading(get_string('questioninformation', 'quiz_statistics'), 3);
348 echo html_writer::table($questioninfotable);
349 echo $this->render_question_text($questionstat->question);
350 echo $OUTPUT->heading(get_string('questionstatistics', 'quiz_statistics'), 3);
351 echo html_writer::table($questionstatstable);
355 * Output question text in a box with urls appropriate for a preview of the question.
357 * @param stdClass $question question data.
358 * @return string HTML of question text, ready for display.
360 protected function render_question_text($question) {
361 global $OUTPUT;
363 $text = question_rewrite_question_preview_urls($question->questiontext, $question->id,
364 $question->contextid, 'question', 'questiontext', $question->id,
365 $this->context->id, 'quiz_statistics');
367 return $OUTPUT->box(format_text($text, $question->questiontextformat,
368 ['noclean' => true, 'para' => false, 'overflowdiv' => true]),
369 'questiontext boxaligncenter generalbox boxwidthnormal mdl-align');
373 * Display the response analysis for a question.
375 * @param stdClass $question the question to report on.
376 * @param int|null $variantno the variant
377 * @param int $s
378 * @param moodle_url $reporturl the URL to redisplay this report.
379 * @param qubaid_condition $qubaids
380 * @param string $whichtries
382 protected function output_individual_question_response_analysis($question, $variantno, $s, $reporturl, $qubaids,
383 $whichtries = question_attempt::LAST_TRY) {
384 global $OUTPUT;
386 if (!question_bank::get_qtype($question->qtype, false)->can_analyse_responses()) {
387 return;
390 $qtable = new quiz_statistics_question_table($question->id);
391 $exportclass = $this->table->export_class_instance();
392 $qtable->export_class_instance($exportclass);
393 if (!$this->table->is_downloading()) {
394 // Output an appropriate title.
395 echo $OUTPUT->heading(get_string('analysisofresponses', 'quiz_statistics'), 3);
397 } else {
398 // Work out an appropriate title.
399 $a = clone($question);
400 $a->variant = $variantno;
402 if (!empty($question->number) && !is_null($variantno)) {
403 $questiontabletitle = get_string('analysisnovariant', 'quiz_statistics', $a);
404 } else if (!empty($question->number)) {
405 $questiontabletitle = get_string('analysisno', 'quiz_statistics', $a);
406 } else if (!is_null($variantno)) {
407 $questiontabletitle = get_string('analysisvariant', 'quiz_statistics', $a);
408 } else {
409 $questiontabletitle = get_string('analysisnameonly', 'quiz_statistics', $a);
412 if ($this->table->is_downloading() == 'html') {
413 $questiontabletitle = get_string('analysisofresponsesfor', 'quiz_statistics', $questiontabletitle);
416 // Set up the table.
417 $exportclass->start_table($questiontabletitle);
419 if ($this->table->is_downloading() == 'html') {
420 echo $this->render_question_text($question);
424 $responesanalyser = new analyser($question, $whichtries);
425 $responseanalysis = $responesanalyser->load_cached($qubaids, $whichtries);
427 $qtable->question_setup($reporturl, $question, $s, $responseanalysis);
428 if ($this->table->is_downloading()) {
429 $exportclass->output_headers($qtable->headers);
432 // Where no variant no is specified the variant no is actually one.
433 if ($variantno === null) {
434 $variantno = 1;
436 foreach ($responseanalysis->get_subpart_ids($variantno) as $partid) {
437 $subpart = $responseanalysis->get_analysis_for_subpart($variantno, $partid);
438 foreach ($subpart->get_response_class_ids() as $responseclassid) {
439 $responseclass = $subpart->get_response_class($responseclassid);
440 $tabledata = $responseclass->data_for_question_response_table($subpart->has_multiple_response_classes(), $partid);
441 foreach ($tabledata as $row) {
442 $qtable->add_data_keyed($qtable->format_row($row));
447 $qtable->finish_output(!$this->table->is_downloading());
451 * Output the table that lists all the questions in the quiz with their statistics.
453 * @param \core_question\statistics\questions\all_calculated_for_qubaid_condition $questionstats the stats for all questions in
454 * the quiz including subqs and
455 * variants.
457 protected function output_quiz_structure_analysis_table($questionstats) {
458 $limitvariants = !$this->table->is_downloading();
459 foreach ($questionstats->get_all_slots() as $slot) {
460 // Output the data for these question statistics.
461 $structureanalysis = $questionstats->structure_analysis_for_one_slot($slot, $limitvariants);
462 if (is_null($structureanalysis)) {
463 $this->table->add_separator();
464 } else {
465 foreach ($structureanalysis as $row) {
466 $bgcssclass = '';
467 // The only way to identify in this point of the report if a row is a summary row
468 // is checking if it's a instance of calculated_question_summary class.
469 if ($row instanceof \core_question\statistics\questions\calculated_question_summary) {
470 // Apply a custom css class to summary row to remove border and reduce paddings.
471 $bgcssclass = 'quiz_statistics-summaryrow';
473 // For question that contain a summary row, we add a "hidden" row in between so the report
474 // display both rows with same background color.
475 $this->table->add_data_keyed([], 'd-none hidden');
478 $this->table->add_data_keyed($this->table->format_row($row), $bgcssclass);
483 $this->table->finish_output(!$this->table->is_downloading());
487 * Return HTML for table of overall quiz statistics.
489 * @param array $quizinfo as returned by {@link get_formatted_quiz_info_data()}.
490 * @return string the HTML.
492 protected function output_quiz_info_table($quizinfo) {
494 $quizinfotable = new html_table();
495 $quizinfotable->align = ['center', 'center'];
496 $quizinfotable->width = '60%';
497 $quizinfotable->attributes['class'] = 'generaltable titlesleft';
498 $quizinfotable->data = [];
500 foreach ($quizinfo as $heading => $value) {
501 $quizinfotable->data[] = [$heading, $value];
504 return html_writer::table($quizinfotable);
508 * Download the table of overall quiz statistics.
510 * @param array $quizinfo as returned by {@link get_formatted_quiz_info_data()}.
512 protected function download_quiz_info_table($quizinfo) {
513 global $OUTPUT;
515 // HTML download is a special case.
516 if ($this->table->is_downloading() == 'html') {
517 echo $OUTPUT->heading(get_string('quizinformation', 'quiz_statistics'), 3);
518 echo $this->output_quiz_info_table($quizinfo);
519 return;
522 // Reformat the data ready for output.
523 $headers = [];
524 $row = [];
525 foreach ($quizinfo as $heading => $value) {
526 $headers[] = $heading;
527 $row[] = $value;
530 // Do the output.
531 $exportclass = $this->table->export_class_instance();
532 $exportclass->start_table(get_string('quizinformation', 'quiz_statistics'));
533 $exportclass->output_headers($headers);
534 $exportclass->add_data($row);
535 $exportclass->finish_table();
539 * Output the HTML needed to show the statistics graph.
541 * @param stdClass $quiz the quiz.
542 * @param qubaid_condition $qubaids the question usages whose responses to analyse.
544 protected function output_statistics_graph($quiz, $qubaids) {
545 global $DB, $PAGE;
547 // Load the rest of the required data.
548 $questions = quiz_report_get_significant_questions($quiz);
550 // Only load main question not sub questions.
551 $questionstatistics = $DB->get_records_select('question_statistics',
552 'hashcode = ? AND slot IS NOT NULL AND variant IS NULL',
553 [$qubaids->get_hash_code()]);
555 // Configure what to display.
556 $fieldstoplot = [
557 'facility' => get_string('facility', 'quiz_statistics'),
558 'discriminativeefficiency' => get_string('discriminative_efficiency', 'quiz_statistics')
560 $fieldstoplotfactor = ['facility' => 100, 'discriminativeefficiency' => 1];
562 // Prepare the arrays to hold the data.
563 $xdata = [];
564 foreach (array_keys($fieldstoplot) as $fieldtoplot) {
565 $ydata[$fieldtoplot] = [];
568 // Fill in the data for each question.
569 foreach ($questionstatistics as $questionstatistic) {
570 $number = $questions[$questionstatistic->slot]->number;
571 $xdata[$number] = $number;
573 foreach ($fieldstoplot as $fieldtoplot => $notused) {
574 $value = $questionstatistic->$fieldtoplot;
575 if (is_null($value)) {
576 $value = 0;
578 $value *= $fieldstoplotfactor[$fieldtoplot];
579 $ydata[$fieldtoplot][$number] = number_format($value, 2);
583 // Create the chart.
584 sort($xdata);
585 $chart = new \core\chart_bar();
586 $chart->get_xaxis(0, true)->set_label(get_string('position', 'quiz_statistics'));
587 $chart->set_labels(array_values($xdata));
589 foreach ($fieldstoplot as $fieldtoplot => $notused) {
590 ksort($ydata[$fieldtoplot]);
591 $series = new \core\chart_series($fieldstoplot[$fieldtoplot], array_values($ydata[$fieldtoplot]));
592 $chart->add_series($series);
595 // Find max.
596 $max = 0;
597 foreach ($fieldstoplot as $fieldtoplot => $notused) {
598 $max = max($max, max($ydata[$fieldtoplot]));
601 // Set Y properties.
602 $yaxis = $chart->get_yaxis(0, true);
603 $yaxis->set_stepsize(10);
604 $yaxis->set_label('%');
606 $output = $PAGE->get_renderer('mod_quiz');
607 $graphname = get_string('statisticsreportgraph', 'quiz_statistics');
608 echo $output->chart($chart, $graphname);
612 * Get the quiz and question statistics, either by loading the cached results,
613 * or by recomputing them.
615 * @param stdClass $quiz the quiz settings.
616 * @param string $whichattempts which attempts to use, represented internally as one of the constants as used in
617 * $quiz->grademethod ie.
618 * QUIZ_GRADEAVERAGE, QUIZ_GRADEHIGHEST, QUIZ_ATTEMPTLAST or QUIZ_ATTEMPTFIRST
619 * we calculate stats based on which attempts would affect the grade for each student.
620 * @param string $whichtries which tries to analyse for response analysis. Will be one of
621 * question_attempt::FIRST_TRY, LAST_TRY or ALL_TRIES.
622 * @param \core\dml\sql_join $groupstudentsjoins Contains joins, wheres, params for students in this group.
623 * @param array $questions full question data.
624 * @param \core\progress\base|null $progress
625 * @param bool $calculateifrequired if true (the default) the stats will be calculated if not already stored.
626 * If false, [null, null] will be returned if the stats are not already available.
627 * @return array with 2 elements: - $quizstats The statistics for overall attempt scores.
628 * - $questionstats \core_question\statistics\questions\all_calculated_for_qubaid_condition
629 * Both may be null, if $calculateifrequired is false.
631 public function get_all_stats_and_analysis(
632 $quiz, $whichattempts, $whichtries, \core\dml\sql_join $groupstudentsjoins,
633 $questions, $progress = null, bool $calculateifrequired = true) {
635 if ($progress === null) {
636 $progress = new \core\progress\none();
639 $qubaids = quiz_statistics_qubaids_condition($quiz->id, $groupstudentsjoins, $whichattempts);
641 $qcalc = new \core_question\statistics\questions\calculator($questions, $progress);
643 $quizcalc = new \quiz_statistics\calculator($progress);
645 $progress->start_progress('', 3);
646 if ($quizcalc->get_last_calculated_time($qubaids) === false) {
647 if (!$calculateifrequired) {
648 $progress->progress(3);
649 $progress->end_progress();
650 return [null, null];
653 // Recalculate now.
654 $questionstats = $qcalc->calculate($qubaids);
655 $progress->progress(1);
657 $quizstats = $quizcalc->calculate($quiz->id, $whichattempts, $groupstudentsjoins, count($questions),
658 $qcalc->get_sum_of_mark_variance());
659 $progress->progress(2);
660 } else {
661 $quizstats = $quizcalc->get_cached($qubaids);
662 $progress->progress(1);
663 $questionstats = $qcalc->get_cached($qubaids);
664 $progress->progress(2);
667 if ($quizstats->s()) {
668 $subquestions = $questionstats->get_sub_questions();
669 $this->analyse_responses_for_all_questions_and_subquestions($questions,
670 $subquestions,
671 $qubaids,
672 $whichtries,
673 $progress);
675 $progress->progress(3);
676 $progress->end_progress();
678 return [$quizstats, $questionstats];
682 * Appropriate instance depending if we want html output for the user or not.
684 * @return \core\progress\base child of \core\progress\base to handle the display (or not) of task progress.
686 protected function get_progress_trace_instance() {
687 if ($this->progress === null) {
688 if (!$this->table->is_downloading()) {
689 $this->progress = new \core\progress\display_if_slow(get_string('calculatingallstats', 'quiz_statistics'));
690 $this->progress->set_display_names();
691 } else {
692 $this->progress = new \core\progress\none();
695 return $this->progress;
699 * Analyse responses for all questions and sub questions in this quiz.
701 * @param stdClass[] $questions as returned by self::load_and_initialise_questions_for_calculations
702 * @param stdClass[] $subquestions full question objects.
703 * @param qubaid_condition $qubaids the question usages whose responses to analyse.
704 * @param string $whichtries which tries to analyse \question_attempt::FIRST_TRY, LAST_TRY or ALL_TRIES.
705 * @param null|\core\progress\base $progress Used to indicate progress of task.
707 protected function analyse_responses_for_all_questions_and_subquestions($questions, $subquestions, $qubaids,
708 $whichtries, $progress = null) {
709 if ($progress === null) {
710 $progress = new \core\progress\none();
713 // Starting response analysis tasks.
714 $progress->start_progress('', count($questions) + count($subquestions));
716 $done = $this->analyse_responses_for_questions($questions, $qubaids, $whichtries, $progress);
718 $this->analyse_responses_for_questions($subquestions, $qubaids, $whichtries, $progress, $done);
720 // Finished all response analysis tasks.
721 $progress->end_progress();
725 * Analyse responses for an array of questions or sub questions.
727 * @param stdClass[] $questions as returned by self::load_and_initialise_questions_for_calculations.
728 * @param qubaid_condition $qubaids the question usages whose responses to analyse.
729 * @param string $whichtries which tries to analyse \question_attempt::FIRST_TRY, LAST_TRY or ALL_TRIES.
730 * @param null|\core\progress\base $progress Used to indicate progress of task.
731 * @param int[] $done array keys are ids of questions that have been analysed before calling method.
732 * @return array array keys are ids of questions that were analysed after this method call.
734 protected function analyse_responses_for_questions($questions, $qubaids, $whichtries, $progress = null, $done = []) {
735 $countquestions = count($questions);
736 if (!$countquestions) {
737 return [];
739 if ($progress === null) {
740 $progress = new \core\progress\none();
742 $progress->start_progress('', $countquestions, $countquestions);
743 foreach ($questions as $question) {
744 $progress->increment_progress();
745 if (question_bank::get_qtype($question->qtype, false)->can_analyse_responses() && !isset($done[$question->id])) {
746 $responesstats = new analyser($question, $whichtries);
747 $responesstats->calculate($qubaids, $whichtries);
749 $done[$question->id] = 1;
751 $progress->end_progress();
752 return $done;
756 * Return a little form for the user to request to download the full report, including quiz stats and response analysis for
757 * all questions and sub-questions.
759 * @param moodle_url $reporturl the base URL of the report.
760 * @return string HTML.
762 protected function everything_download_options(moodle_url $reporturl) {
763 global $OUTPUT;
764 return $OUTPUT->download_dataformat_selector(get_string('downloadeverything', 'quiz_statistics'),
765 $reporturl->out_omit_querystring(), 'download', $reporturl->params() + ['everything' => 1]);
769 * Return HTML for a message that says when the stats were last calculated and a 'recalculate now' button.
771 * @param int $lastcachetime the time the stats were last cached.
772 * @param int $quizid the quiz id.
773 * @param \core\dml\sql_join $groupstudentsjoins (joins, wheres, params) for students in the group
774 * or empty array if groups not used.
775 * @param string $whichattempts which attempts to use, represented internally as one of the constants as used in
776 * $quiz->grademethod ie.
777 * QUIZ_GRADEAVERAGE, QUIZ_GRADEHIGHEST, QUIZ_ATTEMPTLAST or QUIZ_ATTEMPTFIRST
778 * we calculate stats based on which attempts would affect the grade for each student.
779 * @param moodle_url $reporturl url for this report
780 * @return string HTML.
782 protected function output_caching_info($lastcachetime, $quizid, $groupstudentsjoins, $whichattempts, $reporturl) {
783 global $DB, $OUTPUT;
785 if (empty($lastcachetime)) {
786 return '';
789 // Find the number of attempts since the cached statistics were computed.
790 list($fromqa, $whereqa, $qaparams) = quiz_statistics_attempts_sql($quizid, $groupstudentsjoins, $whichattempts, true);
791 $count = $DB->count_records_sql("
792 SELECT COUNT(1)
793 FROM $fromqa
794 WHERE $whereqa
795 AND quiza.timefinish > {$lastcachetime}", $qaparams);
797 if (!$count) {
798 $count = 0;
801 // Generate the output.
802 $a = new stdClass();
803 $a->lastcalculated = format_time(time() - $lastcachetime);
804 $a->count = $count;
806 $recalcualteurl = new moodle_url($reporturl,
807 ['recalculate' => 1, 'sesskey' => sesskey()]);
808 $output = '';
809 $output .= $OUTPUT->box_start(
810 'boxaligncenter generalbox boxwidthnormal mdl-align', 'cachingnotice');
811 $output .= get_string('lastcalculated', 'quiz_statistics', $a);
812 $output .= $OUTPUT->single_button($recalcualteurl,
813 get_string('recalculatenow', 'quiz_statistics'));
814 $output .= $OUTPUT->box_end(true);
816 return $output;
820 * Clear the cached data for a particular report configuration. This will trigger a re-computation the next time the report
821 * is displayed.
823 * @param $qubaids qubaid_condition
825 public function clear_cached_data($qubaids) {
826 global $DB;
827 $DB->delete_records('quiz_statistics', ['hashcode' => $qubaids->get_hash_code()]);
828 $DB->delete_records('question_statistics', ['hashcode' => $qubaids->get_hash_code()]);
829 $DB->delete_records('question_response_analysis', ['hashcode' => $qubaids->get_hash_code()]);
833 * Load the questions in this quiz and add some properties to the objects needed in the reports.
835 * @param stdClass $quiz the quiz.
836 * @return array of questions for this quiz.
838 public function load_and_initialise_questions_for_calculations($quiz) {
839 // Load the questions.
840 $questions = quiz_report_get_significant_questions($quiz);
841 $questiondata = [];
842 foreach ($questions as $qs => $question) {
843 if ($question->qtype === 'random') {
844 $question->id = 0;
845 $question->name = get_string('random', 'quiz');
846 $question->questiontext = get_string('random', 'quiz');
847 $question->parenttype = 'random';
848 $questiondata[$question->slot] = $question;
849 } else if ($question->qtype === 'missingtype') {
850 $question->id = is_numeric($question->id) ? (int) $question->id : 0;
851 $questiondata[$question->slot] = $question;
852 $question->name = get_string('deletedquestion', 'qtype_missingtype');
853 $question->questiontext = get_string('deletedquestiontext', 'qtype_missingtype');
854 } else {
855 $q = question_bank::load_question_data($question->id);
856 $q->maxmark = $question->maxmark;
857 $q->slot = $question->slot;
858 $q->number = $question->number;
859 $q->parenttype = null;
860 $questiondata[$question->slot] = $q;
864 return $questiondata;
868 * Output all response analysis for all questions, sub-questions and variants. For download in a number of formats.
870 * @param $qubaids
871 * @param $questions
872 * @param $questionstats
873 * @param $reporturl
874 * @param $whichtries string
876 protected function output_all_question_response_analysis($qubaids,
877 $questions,
878 $questionstats,
879 $reporturl,
880 $whichtries = question_attempt::LAST_TRY) {
881 foreach ($questions as $slot => $question) {
882 if (question_bank::get_qtype(
883 $question->qtype, false)->can_analyse_responses()
885 if ($questionstats->for_slot($slot)->get_variants()) {
886 foreach ($questionstats->for_slot($slot)->get_variants() as $variantno) {
887 $this->output_individual_question_response_analysis($question,
888 $variantno,
889 $questionstats->for_slot($slot, $variantno)->s,
890 $reporturl,
891 $qubaids,
892 $whichtries);
894 } else {
895 $this->output_individual_question_response_analysis($question,
896 null,
897 $questionstats->for_slot($slot)->s,
898 $reporturl,
899 $qubaids,
900 $whichtries);
902 } else if ($subqids = $questionstats->for_slot($slot)->get_sub_question_ids()) {
903 foreach ($subqids as $subqid) {
904 if ($variants = $questionstats->for_subq($subqid)->get_variants()) {
905 foreach ($variants as $variantno) {
906 $this->output_individual_question_response_analysis(
907 $questionstats->for_subq($subqid, $variantno)->question,
908 $variantno,
909 $questionstats->for_subq($subqid, $variantno)->s,
910 $reporturl,
911 $qubaids,
912 $whichtries);
914 } else {
915 $this->output_individual_question_response_analysis(
916 $questionstats->for_subq($subqid)->question,
917 null,
918 $questionstats->for_subq($subqid)->s,
919 $reporturl,
920 $qubaids,
921 $whichtries);
930 * Load question stats for a quiz
932 * @param int $quizid question usage
933 * @param bool $calculateifrequired if true (the default) the stats will be calculated if not already stored.
934 * If false, null will be returned if the stats are not already available.
935 * @return ?all_calculated_for_qubaid_condition question stats
937 public function calculate_questions_stats_for_question_bank(
938 int $quizid,
939 bool $calculateifrequired = true
940 ): ?all_calculated_for_qubaid_condition {
941 global $DB;
942 $quiz = $DB->get_record('quiz', ['id' => $quizid], '*', MUST_EXIST);
943 $questions = $this->load_and_initialise_questions_for_calculations($quiz);
945 [, $questionstats] = $this->get_all_stats_and_analysis($quiz,
946 $quiz->grademethod, question_attempt::ALL_TRIES, new \core\dml\sql_join(),
947 $questions, null, $calculateifrequired);
949 return $questionstats;