See Release Notes
Long Term Support Release
Differences Between: [Versions 310 and 401] [Versions 311 and 401] [Versions 39 and 401] [Versions 400 and 401] [Versions 401 and 402] [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 * Quiz statistics report class. 19 * 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 24 */ 25 26 defined('MOODLE_INTERNAL') || die(); 27 28 use core_question\statistics\responses\analyser; 29 use core_question\statistics\questions\all_calculated_for_qubaid_condition; 30 31 require_once($CFG->dirroot . '/mod/quiz/report/default.php'); 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'); 37 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. 42 * 43 * @copyright 2008 Jamie Pratt 44 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 45 */ 46 class quiz_statistics_report extends quiz_default_report { 47 48 /** @var context_module context of this quiz.*/ 49 protected $context; 50 51 /** @var quiz_statistics_table instance of table class used for main questions stats table. */ 52 protected $table; 53 54 /** @var \core\progress\base|null $progress Handles progress reporting or not. */ 55 protected $progress = null; 56 57 /** 58 * Display the report. 59 */ 60 public function display($quiz, $cm, $course) { 61 global $OUTPUT, $DB; 62 63 raise_memory_limit(MEMORY_HUGE); 64 65 $this->context = context_module::instance($cm->id); 66 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; 71 } 72 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); 83 84 $pageoptions = array(); 85 $pageoptions['id'] = $cm->id; 86 $pageoptions['mode'] = 'statistics'; 87 88 $reporturl = new moodle_url('/mod/quiz/report.php', $pageoptions); 89 90 $mform = new quiz_statistics_settings_form($reporturl, compact('quiz')); 91 92 $mform->set_data(array('whichattempts' => $whichattempts, 'whichtries' => $whichtries)); 93 94 if ($whichattempts != $quiz->grademethod) { 95 $reporturl->param('whichattempts', $whichattempts); 96 } 97 98 if ($whichtries != question_attempt::LAST_TRY) { 99 $reporturl->param('whichtries', $whichtries); 100 } 101 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(); 108 109 } else if ($currentgroup == self::NO_GROUPS_ALLOWED) { 110 $groupstudentsjoins = new \core\dml\sql_join(); 111 $nostudentsingroup = true; 112 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 array('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; 124 } 125 } 126 } 127 128 $qubaids = quiz_statistics_qubaids_condition($quiz->id, $groupstudentsjoins, $whichattempts); 129 130 // If recalculate was requested, handle that. 131 if ($recalculate && confirm_sesskey()) { 132 $this->clear_cached_data($qubaids); 133 redirect($reporturl); 134 } 135 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'); 142 } 143 $courseshortname = format_string($course->shortname, true, 144 array('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); 149 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'); 153 } 154 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 if (is_null($quizstats)) { 161 echo $OUTPUT->notification(get_string('nostats', 'quiz_statistics'), 'error'); 162 return true; 163 } 164 } else { 165 // Or create empty stats containers. 166 $quizstats = new \quiz_statistics\calculated($whichattempts); 167 $questionstats = new \core_question\statistics\questions\all_calculated_for_qubaid_condition(); 168 } 169 170 // Set up the table. 171 $this->table->statistics_setup($quiz, $cm->id, $reporturl, $quizstats->s()); 172 173 // Print the rest of the page header stuff (if not downloading. 174 if (!$this->table->is_downloading()) { 175 176 if (groups_get_activity_groupmode($cm)) { 177 groups_print_activity_menu($cm, $reporturl->out()); 178 if ($currentgroup && $nostudentsingroup) { 179 $OUTPUT->notification(get_string('nostudentsingroup', 'quiz_statistics')); 180 } 181 } 182 183 if (!$this->table->is_downloading() && $quizstats->s() == 0) { 184 echo $OUTPUT->notification(get_string('nogradedattempts', 'quiz_statistics')); 185 } 186 187 foreach ($questionstats->any_error_messages() as $errormessage) { 188 echo $OUTPUT->notification($errormessage); 189 } 190 191 // Print display options form. 192 $mform->display(); 193 } 194 195 if ($everything) { // Implies is downloading. 196 // Overall report, then the analysis of each question. 197 $quizinfo = $quizstats->get_formatted_quiz_info_data($course, $cm, $quiz); 198 $this->download_quiz_info_table($quizinfo); 199 200 if ($quizstats->s()) { 201 $this->output_quiz_structure_analysis_table($questionstats); 202 203 if ($this->table->is_downloading() == 'html' && $quizstats->s() != 0) { 204 $this->output_statistics_graph($quiz->id, $qubaids); 205 } 206 207 $this->output_all_question_response_analysis($qubaids, $questions, $questionstats, $reporturl, $whichtries); 208 } 209 210 $this->table->export_class_instance()->finish_document(); 211 212 } else if ($qid) { 213 // Report on an individual sub-question indexed questionid. 214 if (!$questionstats->has_subq($qid, $variantno)) { 215 throw new \moodle_exception('questiondoesnotexist', 'question'); 216 } 217 218 $this->output_individual_question_data($quiz, $questionstats->for_subq($qid, $variantno)); 219 $this->output_individual_question_response_analysis($questionstats->for_subq($qid, $variantno)->question, 220 $variantno, 221 $questionstats->for_subq($qid, $variantno)->s, 222 $reporturl, 223 $qubaids, 224 $whichtries); 225 // Back to overview link. 226 echo $OUTPUT->box('<a href="' . $reporturl->out() . '">' . 227 get_string('backtoquizreport', 'quiz_statistics') . '</a>', 228 'boxaligncenter generalbox boxwidthnormal mdl-align'); 229 } else if ($slot) { 230 // Report on an individual question indexed by position. 231 if (!isset($questions[$slot])) { 232 throw new \moodle_exception('questiondoesnotexist', 'question'); 233 } 234 235 if ($variantno === null && 236 ($questionstats->for_slot($slot)->get_sub_question_ids() 237 || $questionstats->for_slot($slot)->get_variants())) { 238 if (!$this->table->is_downloading()) { 239 $number = $questionstats->for_slot($slot)->question->number; 240 echo $OUTPUT->heading(get_string('slotstructureanalysis', 'quiz_statistics', $number), 3); 241 } 242 $this->table->define_baseurl(new moodle_url($reporturl, array('slot' => $slot))); 243 $this->table->format_and_add_array_of_rows($questionstats->structure_analysis_for_one_slot($slot)); 244 } else { 245 $this->output_individual_question_data($quiz, $questionstats->for_slot($slot, $variantno)); 246 $this->output_individual_question_response_analysis($questions[$slot], 247 $variantno, 248 $questionstats->for_slot($slot, $variantno)->s, 249 $reporturl, 250 $qubaids, 251 $whichtries); 252 } 253 if (!$this->table->is_downloading()) { 254 // Back to overview link. 255 echo $OUTPUT->box('<a href="' . $reporturl->out() . '">' . 256 get_string('backtoquizreport', 'quiz_statistics') . '</a>', 257 'backtomainstats boxaligncenter generalbox boxwidthnormal mdl-align'); 258 } else { 259 $this->table->finish_output(); 260 } 261 262 } else if ($this->table->is_downloading()) { 263 // Downloading overview report. 264 $quizinfo = $quizstats->get_formatted_quiz_info_data($course, $cm, $quiz); 265 $this->download_quiz_info_table($quizinfo); 266 if ($quizstats->s()) { 267 $this->output_quiz_structure_analysis_table($questionstats); 268 } 269 $this->table->export_class_instance()->finish_document(); 270 271 } else { 272 // On-screen display of overview report. 273 echo $OUTPUT->heading(get_string('quizinformation', 'quiz_statistics'), 3); 274 echo $this->output_caching_info($quizstats->timemodified, $quiz->id, $groupstudentsjoins, $whichattempts, $reporturl); 275 echo $this->everything_download_options($reporturl); 276 $quizinfo = $quizstats->get_formatted_quiz_info_data($course, $cm, $quiz); 277 echo $this->output_quiz_info_table($quizinfo); 278 if ($quizstats->s()) { 279 echo $OUTPUT->heading(get_string('quizstructureanalysis', 'quiz_statistics'), 3); 280 $this->output_quiz_structure_analysis_table($questionstats); 281 $this->output_statistics_graph($quiz, $qubaids); 282 } 283 } 284 285 return true; 286 } 287 288 /** 289 * Display the statistical and introductory information about a question. 290 * Only called when not downloading. 291 * 292 * @param object $quiz the quiz settings. 293 * @param \core_question\statistics\questions\calculated $questionstat the question to report on. 294 */ 295 protected function output_individual_question_data($quiz, $questionstat) { 296 global $OUTPUT; 297 298 // On-screen display. Show a summary of the question's place in the quiz, 299 // and the question statistics. 300 $datumfromtable = $this->table->format_row($questionstat); 301 302 // Set up the question info table. 303 $questioninfotable = new html_table(); 304 $questioninfotable->align = array('center', 'center'); 305 $questioninfotable->width = '60%'; 306 $questioninfotable->attributes['class'] = 'generaltable titlesleft'; 307 308 $questioninfotable->data = array(); 309 $questioninfotable->data[] = array(get_string('modulename', 'quiz'), $quiz->name); 310 $questioninfotable->data[] = array(get_string('questionname', 'quiz_statistics'), 311 $questionstat->question->name.' '.$datumfromtable['actions']); 312 313 if ($questionstat->variant !== null) { 314 $questioninfotable->data[] = array(get_string('variant', 'quiz_statistics'), $questionstat->variant); 315 316 } 317 $questioninfotable->data[] = array(get_string('questiontype', 'quiz_statistics'), 318 $datumfromtable['icon'] . ' ' . 319 question_bank::get_qtype($questionstat->question->qtype, false)->menu_name() . ' ' . 320 $datumfromtable['icon']); 321 $questioninfotable->data[] = array(get_string('positions', 'quiz_statistics'), 322 $questionstat->positions); 323 324 // Set up the question statistics table. 325 $questionstatstable = new html_table(); 326 $questionstatstable->align = array('center', 'center'); 327 $questionstatstable->width = '60%'; 328 $questionstatstable->attributes['class'] = 'generaltable titlesleft'; 329 330 unset($datumfromtable['number']); 331 unset($datumfromtable['icon']); 332 $actions = $datumfromtable['actions']; 333 unset($datumfromtable['actions']); 334 unset($datumfromtable['name']); 335 $labels = array( 336 's' => get_string('attempts', 'quiz_statistics'), 337 'facility' => get_string('facility', 'quiz_statistics'), 338 'sd' => get_string('standarddeviationq', 'quiz_statistics'), 339 'random_guess_score' => get_string('random_guess_score', 'quiz_statistics'), 340 'intended_weight' => get_string('intended_weight', 'quiz_statistics'), 341 'effective_weight' => get_string('effective_weight', 'quiz_statistics'), 342 'discrimination_index' => get_string('discrimination_index', 'quiz_statistics'), 343 'discriminative_efficiency' => 344 get_string('discriminative_efficiency', 'quiz_statistics') 345 ); 346 foreach ($datumfromtable as $item => $value) { 347 $questionstatstable->data[] = array($labels[$item], $value); 348 } 349 350 // Display the various bits. 351 echo $OUTPUT->heading(get_string('questioninformation', 'quiz_statistics'), 3); 352 echo html_writer::table($questioninfotable); 353 echo $this->render_question_text($questionstat->question); 354 echo $OUTPUT->heading(get_string('questionstatistics', 'quiz_statistics'), 3); 355 echo html_writer::table($questionstatstable); 356 } 357 358 /** 359 * Output question text in a box with urls appropriate for a preview of the question. 360 * 361 * @param object $question question data. 362 * @return string HTML of question text, ready for display. 363 */ 364 protected function render_question_text($question) { 365 global $OUTPUT; 366 367 $text = question_rewrite_question_preview_urls($question->questiontext, $question->id, 368 $question->contextid, 'question', 'questiontext', $question->id, 369 $this->context->id, 'quiz_statistics'); 370 371 return $OUTPUT->box(format_text($text, $question->questiontextformat, 372 array('noclean' => true, 'para' => false, 'overflowdiv' => true)), 373 'questiontext boxaligncenter generalbox boxwidthnormal mdl-align'); 374 } 375 376 /** 377 * Display the response analysis for a question. 378 * 379 * @param object $question the question to report on. 380 * @param int|null $variantno the variant 381 * @param int $s 382 * @param moodle_url $reporturl the URL to redisplay this report. 383 * @param qubaid_condition $qubaids 384 * @param string $whichtries 385 */ 386 protected function output_individual_question_response_analysis($question, $variantno, $s, $reporturl, $qubaids, 387 $whichtries = question_attempt::LAST_TRY) { 388 global $OUTPUT; 389 390 if (!question_bank::get_qtype($question->qtype, false)->can_analyse_responses()) { 391 return; 392 } 393 394 $qtable = new quiz_statistics_question_table($question->id); 395 $exportclass = $this->table->export_class_instance(); 396 $qtable->export_class_instance($exportclass); 397 if (!$this->table->is_downloading()) { 398 // Output an appropriate title. 399 echo $OUTPUT->heading(get_string('analysisofresponses', 'quiz_statistics'), 3); 400 401 } else { 402 // Work out an appropriate title. 403 $a = clone($question); 404 $a->variant = $variantno; 405 406 if (!empty($question->number) && !is_null($variantno)) { 407 $questiontabletitle = get_string('analysisnovariant', 'quiz_statistics', $a); 408 } else if (!empty($question->number)) { 409 $questiontabletitle = get_string('analysisno', 'quiz_statistics', $a); 410 } else if (!is_null($variantno)) { 411 $questiontabletitle = get_string('analysisvariant', 'quiz_statistics', $a); 412 } else { 413 $questiontabletitle = get_string('analysisnameonly', 'quiz_statistics', $a); 414 } 415 416 if ($this->table->is_downloading() == 'html') { 417 $questiontabletitle = get_string('analysisofresponsesfor', 'quiz_statistics', $questiontabletitle); 418 } 419 420 // Set up the table. 421 $exportclass->start_table($questiontabletitle); 422 423 if ($this->table->is_downloading() == 'html') { 424 echo $this->render_question_text($question); 425 } 426 } 427 428 $responesanalyser = new analyser($question, $whichtries); 429 $responseanalysis = $responesanalyser->load_cached($qubaids, $whichtries); 430 431 $qtable->question_setup($reporturl, $question, $s, $responseanalysis); 432 if ($this->table->is_downloading()) { 433 $exportclass->output_headers($qtable->headers); 434 } 435 436 // Where no variant no is specified the variant no is actually one. 437 if ($variantno === null) { 438 $variantno = 1; 439 } 440 foreach ($responseanalysis->get_subpart_ids($variantno) as $partid) { 441 $subpart = $responseanalysis->get_analysis_for_subpart($variantno, $partid); 442 foreach ($subpart->get_response_class_ids() as $responseclassid) { 443 $responseclass = $subpart->get_response_class($responseclassid); 444 $tabledata = $responseclass->data_for_question_response_table($subpart->has_multiple_response_classes(), $partid); 445 foreach ($tabledata as $row) { 446 $qtable->add_data_keyed($qtable->format_row($row)); 447 } 448 } 449 } 450 451 $qtable->finish_output(!$this->table->is_downloading()); 452 } 453 454 /** 455 * Output the table that lists all the questions in the quiz with their statistics. 456 * 457 * @param \core_question\statistics\questions\all_calculated_for_qubaid_condition $questionstats the stats for all questions in 458 * the quiz including subqs and 459 * variants. 460 */ 461 protected function output_quiz_structure_analysis_table($questionstats) { 462 $limitvariants = !$this->table->is_downloading(); 463 foreach ($questionstats->get_all_slots() as $slot) { 464 // Output the data for these question statistics. 465 $structureanalysis = $questionstats->structure_analysis_for_one_slot($slot, $limitvariants); 466 if (is_null($structureanalysis)) { 467 $this->table->add_separator(); 468 } else { 469 foreach ($structureanalysis as $row) { 470 $bgcssclass = ''; 471 // The only way to identify in this point of the report if a row is a summary row 472 // is checking if it's a instance of calculated_question_summary class. 473 if ($row instanceof \core_question\statistics\questions\calculated_question_summary) { 474 // Apply a custom css class to summary row to remove border and reduce paddings. 475 $bgcssclass = 'quiz_statistics-summaryrow'; 476 477 // For question that contain a summary row, we add a "hidden" row in between so the report 478 // display both rows with same background color. 479 $this->table->add_data_keyed([], 'd-none hidden'); 480 } 481 482 $this->table->add_data_keyed($this->table->format_row($row), $bgcssclass); 483 } 484 } 485 } 486 487 $this->table->finish_output(!$this->table->is_downloading()); 488 } 489 490 /** 491 * Return HTML for table of overall quiz statistics. 492 * 493 * @param array $quizinfo as returned by {@link get_formatted_quiz_info_data()}. 494 * @return string the HTML. 495 */ 496 protected function output_quiz_info_table($quizinfo) { 497 498 $quizinfotable = new html_table(); 499 $quizinfotable->align = array('center', 'center'); 500 $quizinfotable->width = '60%'; 501 $quizinfotable->attributes['class'] = 'generaltable titlesleft'; 502 $quizinfotable->data = array(); 503 504 foreach ($quizinfo as $heading => $value) { 505 $quizinfotable->data[] = array($heading, $value); 506 } 507 508 return html_writer::table($quizinfotable); 509 } 510 511 /** 512 * Download the table of overall quiz statistics. 513 * 514 * @param array $quizinfo as returned by {@link get_formatted_quiz_info_data()}. 515 */ 516 protected function download_quiz_info_table($quizinfo) { 517 global $OUTPUT; 518 519 // HTML download is a special case. 520 if ($this->table->is_downloading() == 'html') { 521 echo $OUTPUT->heading(get_string('quizinformation', 'quiz_statistics'), 3); 522 echo $this->output_quiz_info_table($quizinfo); 523 return; 524 } 525 526 // Reformat the data ready for output. 527 $headers = array(); 528 $row = array(); 529 foreach ($quizinfo as $heading => $value) { 530 $headers[] = $heading; 531 $row[] = $value; 532 } 533 534 // Do the output. 535 $exportclass = $this->table->export_class_instance(); 536 $exportclass->start_table(get_string('quizinformation', 'quiz_statistics')); 537 $exportclass->output_headers($headers); 538 $exportclass->add_data($row); 539 $exportclass->finish_table(); 540 } 541 542 /** 543 * Output the HTML needed to show the statistics graph. 544 * 545 * @param int|object $quizorid The quiz, or its ID. 546 * @param qubaid_condition $qubaids the question usages whose responses to analyse. 547 * @param string $whichattempts Which attempts constant. 548 */ 549 protected function output_statistics_graph($quizorid, $qubaids) { 550 global $DB, $PAGE; 551 552 $quiz = $quizorid; 553 if (!is_object($quiz)) { 554 $quiz = $DB->get_record('quiz', array('id' => $quizorid), '*', MUST_EXIST); 555 } 556 557 // Load the rest of the required data. 558 $questions = quiz_report_get_significant_questions($quiz); 559 560 // Only load main question not sub questions. 561 $questionstatistics = $DB->get_records_select('question_statistics', 562 'hashcode = ? AND slot IS NOT NULL AND variant IS NULL', 563 [$qubaids->get_hash_code()]); 564 565 // Configure what to display. 566 $fieldstoplot = [ 567 'facility' => get_string('facility', 'quiz_statistics'), 568 'discriminativeefficiency' => get_string('discriminative_efficiency', 'quiz_statistics') 569 ]; 570 $fieldstoplotfactor = ['facility' => 100, 'discriminativeefficiency' => 1]; 571 572 // Prepare the arrays to hold the data. 573 $xdata = []; 574 foreach (array_keys($fieldstoplot) as $fieldtoplot) { 575 $ydata[$fieldtoplot] = []; 576 } 577 578 // Fill in the data for each question. 579 foreach ($questionstatistics as $questionstatistic) { 580 $number = $questions[$questionstatistic->slot]->number; 581 $xdata[$number] = $number; 582 583 foreach ($fieldstoplot as $fieldtoplot => $notused) { 584 $value = $questionstatistic->$fieldtoplot; 585 if (is_null($value)) { 586 $value = 0; 587 } 588 $value *= $fieldstoplotfactor[$fieldtoplot]; 589 $ydata[$fieldtoplot][$number] = number_format($value, 2); 590 } 591 } 592 593 // Create the chart. 594 sort($xdata); 595 $chart = new \core\chart_bar(); 596 $chart->get_xaxis(0, true)->set_label(get_string('position', 'quiz_statistics')); 597 $chart->set_labels(array_values($xdata)); 598 599 foreach ($fieldstoplot as $fieldtoplot => $notused) { 600 ksort($ydata[$fieldtoplot]); 601 $series = new \core\chart_series($fieldstoplot[$fieldtoplot], array_values($ydata[$fieldtoplot])); 602 $chart->add_series($series); 603 } 604 605 // Find max. 606 $max = 0; 607 foreach ($fieldstoplot as $fieldtoplot => $notused) { 608 $max = max($max, max($ydata[$fieldtoplot])); 609 } 610 611 // Set Y properties. 612 $yaxis = $chart->get_yaxis(0, true); 613 $yaxis->set_stepsize(10); 614 $yaxis->set_label('%'); 615 616 $output = $PAGE->get_renderer('mod_quiz'); 617 $graphname = get_string('statisticsreportgraph', 'quiz_statistics'); 618 echo $output->chart($chart, $graphname); 619 } 620 621 /** 622 * Get the quiz and question statistics, either by loading the cached results, 623 * or by recomputing them. 624 * 625 * @param object $quiz the quiz settings. 626 * @param string $whichattempts which attempts to use, represented internally as one of the constants as used in 627 * $quiz->grademethod ie. 628 * QUIZ_GRADEAVERAGE, QUIZ_GRADEHIGHEST, QUIZ_ATTEMPTLAST or QUIZ_ATTEMPTFIRST 629 * we calculate stats based on which attempts would affect the grade for each student. 630 * @param string $whichtries which tries to analyse for response analysis. Will be one of 631 * question_attempt::FIRST_TRY, LAST_TRY or ALL_TRIES. 632 * @param \core\dml\sql_join $groupstudentsjoins Contains joins, wheres, params for students in this group. 633 * @param array $questions full question data. 634 * @param \core\progress\base|null $progress 635 * @param bool $calculateifrequired if true (the default) the stats will be calculated if not already stored. 636 * If false, [null, null] will be returned if the stats are not already available. 637 * @param bool $performanalysis if true (the default) and there are calculated stats, analysis will be performed 638 * for each question. 639 * @return array with 2 elements: - $quizstats The statistics for overall attempt scores. 640 * - $questionstats \core_question\statistics\questions\all_calculated_for_qubaid_condition 641 * Both may be null, if $calculateifrequired is false. 642 */ 643 public function get_all_stats_and_analysis( 644 $quiz, $whichattempts, $whichtries, \core\dml\sql_join $groupstudentsjoins, 645 $questions, $progress = null, bool $calculateifrequired = true, bool $performanalysis = true) { 646 647 if ($progress === null) { 648 $progress = new \core\progress\none(); 649 } 650 651 $qubaids = quiz_statistics_qubaids_condition($quiz->id, $groupstudentsjoins, $whichattempts); 652 653 $qcalc = new \core_question\statistics\questions\calculator($questions, $progress); 654 655 $quizcalc = new \quiz_statistics\calculator($progress); 656 657 $progress->start_progress('', 4); 658 659 // Get a lock on this set of qubaids before performing calculations. This prevents the same calculation running 660 // concurrently and causing database deadlocks. We use a long timeout here as a big quiz with lots of attempts may 661 // take a long time to process. 662 $lockfactory = \core\lock\lock_config::get_lock_factory('quiz_statistics_get_stats'); 663 $lock = $lockfactory->get_lock($qubaids->get_hash_code(), 0); 664 if (!$lock) { 665 if (!$calculateifrequired) { 666 // We're not going to do the calculation in this request anyway, so just give up here. 667 $progress->progress(4); 668 $progress->end_progress(); 669 return [null, null]; 670 } 671 $locktimeout = get_config('quiz_statistics', 'getstatslocktimeout'); 672 $lock = \core\lock\lock_utils::wait_for_lock_with_progress( 673 $lockfactory, 674 $qubaids->get_hash_code(), 675 $progress, 676 $locktimeout, 677 get_string('getstatslockprogress', 'quiz_statistics'), 678 ); 679 if (!$lock) { 680 // Lock attempt timed out. 681 $progress->progress(4); 682 $progress->end_progress(); 683 debugging('Could not get lock on ' . 684 $qubaids->get_hash_code() . ' (Quiz ID ' . $quiz->id . ') after ' . 685 $locktimeout . ' seconds'); 686 return [null, null]; 687 } 688 } 689 690 try { 691 if ($quizcalc->get_last_calculated_time($qubaids) === false) { 692 if (!$calculateifrequired) { 693 $progress->progress(4); 694 $progress->end_progress(); 695 $lock->release(); 696 return [null, null]; 697 } 698 699 // Recalculate now. 700 $questionstats = $qcalc->calculate($qubaids); 701 $progress->progress(2); 702 703 $quizstats = $quizcalc->calculate( 704 $quiz->id, 705 $whichattempts, 706 $groupstudentsjoins, 707 count($questions), 708 $qcalc->get_sum_of_mark_variance() 709 ); 710 $progress->progress(3); 711 } else { 712 $quizstats = $quizcalc->get_cached($qubaids); 713 $progress->progress(2); 714 $questionstats = $qcalc->get_cached($qubaids); 715 $progress->progress(3); 716 } 717 718 if ($quizstats->s() && $performanalysis) { 719 $subquestions = $questionstats->get_sub_questions(); 720 $this->analyse_responses_for_all_questions_and_subquestions( 721 $questions, 722 $subquestions, 723 $qubaids, 724 $whichtries, 725 $progress 726 ); 727 } 728 $progress->progress(4); 729 $progress->end_progress(); 730 } finally { 731 $lock->release(); 732 } 733 734 return array($quizstats, $questionstats); 735 } 736 737 /** 738 * Appropriate instance depending if we want html output for the user or not. 739 * 740 * @return \core\progress\base child of \core\progress\base to handle the display (or not) of task progress. 741 */ 742 protected function get_progress_trace_instance() { 743 if ($this->progress === null) { 744 if (!$this->table->is_downloading()) { 745 $this->progress = new \core\progress\display_if_slow(get_string('calculatingallstats', 'quiz_statistics')); 746 $this->progress->set_display_names(); 747 } else { 748 $this->progress = new \core\progress\none(); 749 } 750 } 751 return $this->progress; 752 } 753 754 /** 755 * Analyse responses for all questions and sub questions in this quiz. 756 * 757 * @param object[] $questions as returned by self::load_and_initialise_questions_for_calculations 758 * @param object[] $subquestions full question objects. 759 * @param qubaid_condition $qubaids the question usages whose responses to analyse. 760 * @param string $whichtries which tries to analyse \question_attempt::FIRST_TRY, LAST_TRY or ALL_TRIES. 761 * @param null|\core\progress\base $progress Used to indicate progress of task. 762 */ 763 protected function analyse_responses_for_all_questions_and_subquestions($questions, $subquestions, $qubaids, 764 $whichtries, $progress = null) { 765 if ($progress === null) { 766 $progress = new \core\progress\none(); 767 } 768 769 // Starting response analysis tasks. 770 $progress->start_progress('', count($questions) + count($subquestions)); 771 772 $done = $this->analyse_responses_for_questions($questions, $qubaids, $whichtries, $progress); 773 774 $this->analyse_responses_for_questions($subquestions, $qubaids, $whichtries, $progress, $done); 775 776 // Finished all response analysis tasks. 777 $progress->end_progress(); 778 } 779 780 /** 781 * Analyse responses for an array of questions or sub questions. 782 * 783 * @param object[] $questions as returned by self::load_and_initialise_questions_for_calculations. 784 * @param qubaid_condition $qubaids the question usages whose responses to analyse. 785 * @param string $whichtries which tries to analyse \question_attempt::FIRST_TRY, LAST_TRY or ALL_TRIES. 786 * @param null|\core\progress\base $progress Used to indicate progress of task. 787 * @param int[] $done array keys are ids of questions that have been analysed before calling method. 788 * @return array array keys are ids of questions that were analysed after this method call. 789 */ 790 protected function analyse_responses_for_questions($questions, $qubaids, $whichtries, $progress = null, $done = array()) { 791 $countquestions = count($questions); 792 if (!$countquestions) { 793 return array(); 794 } 795 if ($progress === null) { 796 $progress = new \core\progress\none(); 797 } 798 $progress->start_progress('', $countquestions, $countquestions); 799 foreach ($questions as $question) { 800 $progress->increment_progress(); 801 if (question_bank::get_qtype($question->qtype, false)->can_analyse_responses() && !isset($done[$question->id])) { 802 $responesstats = new analyser($question, $whichtries); 803 $responesstats->calculate($qubaids, $whichtries); 804 } 805 $done[$question->id] = 1; 806 } 807 $progress->end_progress(); 808 return $done; 809 } 810 811 /** 812 * Return a little form for the user to request to download the full report, including quiz stats and response analysis for 813 * all questions and sub-questions. 814 * 815 * @param moodle_url $reporturl the base URL of the report. 816 * @return string HTML. 817 */ 818 protected function everything_download_options(moodle_url $reporturl) { 819 global $OUTPUT; 820 return $OUTPUT->download_dataformat_selector(get_string('downloadeverything', 'quiz_statistics'), 821 $reporturl->out_omit_querystring(), 'download', $reporturl->params() + array('everything' => 1)); 822 } 823 824 /** 825 * Return HTML for a message that says when the stats were last calculated and a 'recalculate now' button. 826 * 827 * @param int $lastcachetime the time the stats were last cached. 828 * @param int $quizid the quiz id. 829 * @param array $groupstudentsjoins (joins, wheres, params) for students in the group or empty array if groups not used. 830 * @param string $whichattempts which attempts to use, represented internally as one of the constants as used in 831 * $quiz->grademethod ie. 832 * QUIZ_GRADEAVERAGE, QUIZ_GRADEHIGHEST, QUIZ_ATTEMPTLAST or QUIZ_ATTEMPTFIRST 833 * we calculate stats based on which attempts would affect the grade for each student. 834 * @param moodle_url $reporturl url for this report 835 * @return string HTML. 836 */ 837 protected function output_caching_info($lastcachetime, $quizid, $groupstudentsjoins, $whichattempts, $reporturl) { 838 global $DB, $OUTPUT; 839 840 if (empty($lastcachetime)) { 841 return ''; 842 } 843 844 // Find the number of attempts since the cached statistics were computed. 845 list($fromqa, $whereqa, $qaparams) = quiz_statistics_attempts_sql($quizid, $groupstudentsjoins, $whichattempts, true); 846 $count = $DB->count_records_sql(" 847 SELECT COUNT(1) 848 FROM $fromqa 849 WHERE $whereqa 850 AND quiza.timefinish > {$lastcachetime}", $qaparams); 851 852 if (!$count) { 853 $count = 0; 854 } 855 856 // Generate the output. 857 $a = new stdClass(); 858 $a->lastcalculated = format_time(time() - $lastcachetime); 859 $a->count = $count; 860 861 $recalcualteurl = new moodle_url($reporturl, 862 array('recalculate' => 1, 'sesskey' => sesskey())); 863 $output = ''; 864 $output .= $OUTPUT->box_start( 865 'boxaligncenter generalbox boxwidthnormal mdl-align', 'cachingnotice'); 866 $output .= get_string('lastcalculated', 'quiz_statistics', $a); 867 $output .= $OUTPUT->single_button($recalcualteurl, 868 get_string('recalculatenow', 'quiz_statistics')); 869 $output .= $OUTPUT->box_end(true); 870 871 return $output; 872 } 873 874 /** 875 * Clear the cached data for a particular report configuration. This will trigger a re-computation the next time the report 876 * is displayed. 877 * 878 * @param $qubaids qubaid_condition 879 */ 880 public function clear_cached_data($qubaids) { 881 global $DB; 882 $DB->delete_records('quiz_statistics', array('hashcode' => $qubaids->get_hash_code())); 883 $DB->delete_records('question_statistics', array('hashcode' => $qubaids->get_hash_code())); 884 $DB->delete_records('question_response_analysis', array('hashcode' => $qubaids->get_hash_code())); 885 } 886 887 /** 888 * Load the questions in this quiz and add some properties to the objects needed in the reports. 889 * 890 * @param object $quiz the quiz. 891 * @return array of questions for this quiz. 892 */ 893 public function load_and_initialise_questions_for_calculations($quiz) { 894 // Load the questions. 895 $questions = quiz_report_get_significant_questions($quiz); 896 $questiondata = []; 897 foreach ($questions as $qs => $question) { 898 if ($question->qtype === 'random') { 899 $question->id = 0; 900 $question->name = get_string('random', 'quiz'); 901 $question->questiontext = get_string('random', 'quiz'); 902 $question->parenttype = 'random'; 903 $questiondata[$question->slot] = $question; 904 } else if ($question->qtype === 'missingtype') { 905 $question->id = is_numeric($question->id) ? (int) $question->id : 0; 906 $questiondata[$question->slot] = $question; 907 $question->name = get_string('deletedquestion', 'qtype_missingtype'); 908 $question->questiontext = get_string('deletedquestiontext', 'qtype_missingtype'); 909 } else { 910 $q = question_bank::load_question_data($question->id); 911 $q->maxmark = $question->maxmark; 912 $q->slot = $question->slot; 913 $q->number = $question->number; 914 $q->parenttype = null; 915 $questiondata[$question->slot] = $q; 916 } 917 } 918 919 return $questiondata; 920 } 921 922 /** 923 * Output all response analysis for all questions, sub-questions and variants. For download in a number of formats. 924 * 925 * @param $qubaids 926 * @param $questions 927 * @param $questionstats 928 * @param $reporturl 929 * @param $whichtries string 930 */ 931 protected function output_all_question_response_analysis($qubaids, 932 $questions, 933 $questionstats, 934 $reporturl, 935 $whichtries = question_attempt::LAST_TRY) { 936 foreach ($questions as $slot => $question) { 937 if (question_bank::get_qtype( 938 $question->qtype, false)->can_analyse_responses() 939 ) { 940 if ($questionstats->for_slot($slot)->get_variants()) { 941 foreach ($questionstats->for_slot($slot)->get_variants() as $variantno) { 942 $this->output_individual_question_response_analysis($question, 943 $variantno, 944 $questionstats->for_slot($slot, $variantno)->s, 945 $reporturl, 946 $qubaids, 947 $whichtries); 948 } 949 } else { 950 $this->output_individual_question_response_analysis($question, 951 null, 952 $questionstats->for_slot($slot)->s, 953 $reporturl, 954 $qubaids, 955 $whichtries); 956 } 957 } else if ($subqids = $questionstats->for_slot($slot)->get_sub_question_ids()) { 958 foreach ($subqids as $subqid) { 959 if ($variants = $questionstats->for_subq($subqid)->get_variants()) { 960 foreach ($variants as $variantno) { 961 $this->output_individual_question_response_analysis( 962 $questionstats->for_subq($subqid, $variantno)->question, 963 $variantno, 964 $questionstats->for_subq($subqid, $variantno)->s, 965 $reporturl, 966 $qubaids, 967 $whichtries); 968 } 969 } else { 970 $this->output_individual_question_response_analysis( 971 $questionstats->for_subq($subqid)->question, 972 null, 973 $questionstats->for_subq($subqid)->s, 974 $reporturl, 975 $qubaids, 976 $whichtries); 977 978 } 979 } 980 } 981 } 982 } 983 984 /** 985 * Load question stats for a quiz 986 * 987 * @param int $quizid question usage 988 * @param bool $calculateifrequired if true (the default) the stats will be calculated if not already stored. 989 * If false, null will be returned if the stats are not already available. 990 * @param bool $performanalysis if true (the default) and there are calculated stats, analysis will be performed 991 * for each question. 992 * @return ?all_calculated_for_qubaid_condition question stats 993 */ 994 public function calculate_questions_stats_for_question_bank( 995 int $quizid, 996 bool $calculateifrequired = true, 997 bool $performanalysis = true 998 ): ?all_calculated_for_qubaid_condition { 999 global $DB; 1000 $quiz = $DB->get_record('quiz', ['id' => $quizid], '*', MUST_EXIST); 1001 $questions = $this->load_and_initialise_questions_for_calculations($quiz); 1002 1003 [, $questionstats] = $this->get_all_stats_and_analysis($quiz, 1004 $quiz->grademethod, question_attempt::ALL_TRIES, new \core\dml\sql_join(), 1005 $questions, null, $calculateifrequired, $performanalysis); 1006 1007 return $questionstats; 1008 } 1009 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body