MDL-20636 fix upgrade issue.
[moodle.git] / mod / quiz / report / statistics / report.php
CommitLineData
0c1c764e 1<?php
04853f27
TH
2
3// This file is part of Moodle - http://moodle.org/
4//
5// Moodle is free software: you can redistribute it and/or modify
6// it under the terms of the GNU General Public License as published by
7// the Free Software Foundation, either version 3 of the License, or
8// (at your option) any later version.
9//
10// Moodle is distributed in the hope that it will be useful,
11// but WITHOUT ANY WARRANTY; without even the implied warranty of
12// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13// GNU General Public License for more details.
14//
15// You should have received a copy of the GNU General Public License
16// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
17
0c1c764e 18/**
04853f27 19 * Quiz statistics report class.
0c1c764e 20 *
04853f27
TH
21 * @package quiz
22 * @subpackage statistics
23 * @copyright 2008 Jamie Pratt
24 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
25 */
26
0c1c764e 27
a17b297d
TH
28defined('MOODLE_INTERNAL') || die();
29
04853f27
TH
30require_once($CFG->dirroot . '/mod/quiz/report/statistics/statistics_form.php');
31require_once($CFG->dirroot . '/mod/quiz/report/statistics/statistics_table.php');
32require_once($CFG->dirroot . '/mod/quiz/report/statistics/statistics_question_table.php');
33require_once($CFG->dirroot . '/mod/quiz/report/statistics/qstats.php');
34require_once($CFG->dirroot . '/mod/quiz/report/statistics/responseanalysis.php');
0c1c764e 35
a17b297d 36
04853f27
TH
37/**
38 * The quiz statistics report provides summary information about each question in
39 * a quiz, compared to the whole quiz. It also provides a drill-down to more
40 * detailed information about each question.
41 *
a17b297d
TH
42 * @copyright 2008 Jamie Pratt
43 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
04853f27 44 */
c386eaa3 45class quiz_statistics_report extends quiz_default_report {
04853f27
TH
46 /** @var integer Time after which statistics are automatically recomputed. */
47 const TIME_TO_CACHE_STATS = 900; // 15 minutes
3b1d5cc4 48
04853f27
TH
49 /** @var object instance of table class used for main questions stats table. */
50 protected $table;
0c1c764e 51
52 /**
53 * Display the report.
54 */
04853f27
TH
55 public function display($quiz, $cm, $course) {
56 global $CFG, $DB, $OUTPUT, $PAGE;
0c1c764e 57
58 $context = get_context_instance(CONTEXT_MODULE, $cm->id);
59
04853f27 60 // Work out the display options.
0c1c764e 61 $download = optional_param('download', '', PARAM_ALPHA);
869309b8 62 $everything = optional_param('everything', 0, PARAM_BOOL);
d1789d5d 63 $recalculate = optional_param('recalculate', 0, PARAM_BOOL);
04853f27 64 // A qid paramter indicates we should display the detailed analysis of a question.
43ec99aa 65 $qid = optional_param('qid', 0, PARAM_INT);
04853f27
TH
66 $slot = optional_param('slot', 0, PARAM_INT);
67
0c1c764e 68 $pageoptions = array();
69 $pageoptions['id'] = $cm->id;
0c1c764e 70 $pageoptions['mode'] = 'statistics';
71
a6855934 72 $reporturl = new moodle_url('/mod/quiz/report.php', $pageoptions);
0c1c764e 73
04853f27
TH
74 $mform = new quiz_statistics_statistics_settings_form($reporturl);
75 if ($fromform = $mform->get_data()) {
0c1c764e 76 $useallattempts = $fromform->useallattempts;
04853f27 77 if ($fromform->useallattempts) {
0c1c764e 78 set_user_preference('quiz_report_statistics_useallattempts', $fromform->useallattempts);
79 } else {
80 unset_user_preference('quiz_report_statistics_useallattempts');
81 }
04853f27 82
0c1c764e 83 } else {
84 $useallattempts = get_user_preferences('quiz_report_statistics_useallattempts', 0);
85 }
86
04853f27
TH
87 // Find out current groups mode
88 $groupmode = groups_get_activity_groupmode($cm);
0c1c764e 89 $currentgroup = groups_get_activity_group($cm, true);
04853f27
TH
90 $nostudentsingroup = false; // True if a group is selected and there is no one in it.
91 if (empty($currentgroup)) {
92 $currentgroup = 0;
93 $groupstudents = array();
3b1d5cc4 94
04853f27
TH
95 } else {
96 // All users who can attempt quizzes and who are in the currently selected group
97 $groupstudents = get_users_by_capability($context,
98 array('mod/quiz:reviewmyattempts', 'mod/quiz:attempt'),
99 '', '', '', '', $currentgroup, '', false);
100 if (!$groupstudents) {
e72efdd4 101 $nostudentsingroup = true;
0c1c764e 102 }
0c1c764e 103 }
3b1d5cc4 104
04853f27 105 // If recalculate was requested, handle that.
db77f410 106 if ($recalculate && confirm_sesskey()) {
04853f27 107 $this->clear_cached_data($quiz->id, $currentgroup, $useallattempts);
e42f153c 108 redirect($reporturl);
43ec99aa 109 }
3b1d5cc4 110
04853f27 111 // Set up the main table.
43ec99aa 112 $this->table = new quiz_report_statistics_table();
04853f27
TH
113 if ($everything) {
114 $report = get_string('completestatsfilename', 'quiz_statistics');
115 } else {
116 $report = get_string('questionstatsfilename', 'quiz_statistics');
117 }
118 $filename = quiz_report_download_filename($report, $course->shortname, $quiz->name);
43ec99aa 119 $this->table->is_downloading($download, $filename, get_string('quizstructureanalysis', 'quiz_statistics'));
71a2b878 120
04853f27
TH
121 // Load the questions.
122 $questions = quiz_report_get_significant_questions($quiz);
123 $questionids = array();
124 foreach ($questions as $question) {
125 $questionids[] = $question->id;
126 }
127 $fullquestions = question_load_questions($questionids);
128 foreach ($questions as $qno => $question) {
129 $q = $fullquestions[$question->id];
130 $q->maxmark = $question->maxmark;
131 $q->slot = $qno;
132 $q->number = $question->number;
133 $questions[$qno] = $q;
134 }
135
136 // Get the data to be displayed.
137 list($quizstats, $questions, $subquestions, $s) =
138 $this->get_quiz_and_questions_stats($quiz, $currentgroup,
139 $nostudentsingroup, $useallattempts, $groupstudents, $questions);
140 $quizinfo = $this->get_formatted_quiz_info_data($course, $cm, $quiz, $quizstats);
141
04853f27 142 // Set up the table, if there is data.
b0e4fa41 143 if ($s) {
43ec99aa 144 $this->table->setup($quiz, $cm->id, $reporturl, $s);
145 }
3b1d5cc4 146
04853f27
TH
147 // Print the page header stuff (if not downloading.
148 if (!$this->table->is_downloading()) {
149 $this->print_header_and_tabs($cm, $course, $quiz, 'statistics');
150
151 if ($groupmode) {
152 groups_print_activity_menu($cm, $reporturl->out());
153 if ($currentgroup && !$groupstudents) {
154 $OUTPUT->notification(get_string('nostudentsingroup', 'quiz_statistics'));
b0e4fa41
TH
155 }
156 }
157
3c6185e9
TH
158 if (!quiz_questions_in_quiz($quiz->questions)) {
159 echo quiz_no_questions_message($quiz, $cm, $context);
160 } else if (!$this->table->is_downloading() && $s == 0) {
161 echo $OUTPUT->notification(get_string('noattempts', 'quiz'));
162 }
163
04853f27
TH
164 // Print display options form.
165 $mform->set_data(array('useallattempts' => $useallattempts));
166 $mform->display();
167 }
168
169 if ($everything) { // Implies is downloading.
170 // Overall report, then the analysis of each question.
171 $this->download_quiz_info_table($quizinfo);
172
173 if ($s) {
174 $this->output_quiz_structure_analysis_table($s, $questions, $subquestions);
175
176 if ($this->table->is_downloading() == 'xhtml') {
177 $this->output_statistics_graph($quizstats->id, $s);
869309b8 178 }
04853f27
TH
179
180 foreach ($questions as $question) {
181 if (question_bank::get_qtype($question->qtype, false)->can_analyse_responses()) {
182 $this->output_individual_question_response_analysis(
183 $question, $reporturl, $quizstats);
184
185 } else if (!empty($question->_stats->subquestions)) {
186 $subitemstodisplay = explode(',', $question->_stats->subquestions);
187 foreach ($subitemstodisplay as $subitemid) {
188 $this->output_individual_question_response_analysis(
189 $subquestions[$subitemid], $reporturl, $quizstats);
869309b8 190 }
191 }
869309b8 192 }
193 }
04853f27
TH
194
195 $this->table->export_class_instance()->finish_document();
196
197 } else if ($slot) {
198 // Report on an individual question indexed by position.
199 if (!isset($questions[$slot])) {
200 print_error('questiondoesnotexist', 'question');
869309b8 201 }
04853f27
TH
202
203 $this->output_individual_question_data($quiz, $questions[$slot]);
204 $this->output_individual_question_response_analysis(
205 $questions[$slot], $reporturl, $quizstats);
206
207 // Back to overview link.
208 echo $OUTPUT->box('<a href="' . $reporturl->out() . '">' .
209 get_string('backtoquizreport', 'quiz_statistics') . '</a>',
210 'boxaligncenter generalbox boxwidthnormal mdl-align');
211
212 } else if ($qid) {
213 // Report on an individual sub-question indexed questionid.
214 if (!isset($subquestions[$qid])) {
43ec99aa 215 print_error('questiondoesnotexist', 'question');
71a2b878 216 }
04853f27
TH
217
218 $this->output_individual_question_data($quiz, $subquestions[$qid]);
219 $this->output_individual_question_response_analysis(
220 $subquestions[$qid], $reporturl, $quizstats);
221
222 // Back to overview link.
223 echo $OUTPUT->box('<a href="' . $reporturl->out() . '">' .
224 get_string('backtoquizreport', 'quiz_statistics') . '</a>',
225 'boxaligncenter generalbox boxwidthnormal mdl-align');
226
227 } else if ($this->table->is_downloading()) {
228 // Downloading overview report.
229 $this->download_quiz_info_table($quizinfo);
230 $this->output_quiz_structure_analysis_table($s, $questions, $subquestions);
231 $this->table->finish_output();
232
233 } else {
234 // On-screen display of overview report.
235 echo $OUTPUT->heading(get_string('quizinformation', 'quiz_statistics'));
236 echo $this->output_caching_info($quizstats, $quiz->id, $currentgroup, $groupstudents, $useallattempts, $reporturl);
237 echo $this->everything_download_options();
238 echo $this->output_quiz_info_table($quizinfo);
239 if ($s) {
240 echo $OUTPUT->heading(get_string('quizstructureanalysis', 'quiz_statistics'));
241 $this->output_quiz_structure_analysis_table($s, $questions, $subquestions);
242 $this->output_statistics_graph($quizstats->id, $s);
b0e4fa41 243 }
43ec99aa 244 }
04853f27 245
43ec99aa 246 return true;
247 }
3b1d5cc4 248
04853f27
TH
249 /**
250 * Display the statistical and introductory information about a question.
251 * Only called when not downloading.
252 * @param object $quiz the quiz settings.
253 * @param object $question the question to report on.
254 * @param moodle_url $reporturl the URL to resisplay this report.
255 * @param object $quizstats Holds the quiz statistics.
256 */
257 protected function output_individual_question_data($quiz, $question) {
258 global $OUTPUT;
259
260 // On-screen display. Show a summary of the question's place in the quiz,
261 // and the question statistics.
262 $datumfromtable = $this->table->format_row($question);
263
264 // Set up the question info table.
265 $questioninfotable = new html_table();
266 $questioninfotable->align = array('center', 'center');
267 $questioninfotable->width = '60%';
268 $questioninfotable->attributes['class'] = 'generaltable titlesleft';
269
270 $questioninfotable->data = array();
271 $questioninfotable->data[] = array(get_string('modulename', 'quiz'), $quiz->name);
272 $questioninfotable->data[] = array(get_string('questionname', 'quiz_statistics'),
273 $question->name.'&nbsp;'.$datumfromtable['actions']);
274 $questioninfotable->data[] = array(get_string('questiontype', 'quiz_statistics'),
275 $datumfromtable['icon'] . '&nbsp;' .
276 question_bank::get_qtype($question->qtype, false)->menu_name() . '&nbsp;' .
277 $datumfromtable['icon']);
278 $questioninfotable->data[] = array(get_string('positions', 'quiz_statistics'),
279 $question->_stats->positions);
280
281 // Set up the question statistics table.
282 $questionstatstable = new html_table();
283 $questionstatstable->align = array('center', 'center');
284 $questionstatstable->width = '60%';
285 $questionstatstable->attributes['class'] = 'generaltable titlesleft';
286
287 unset($datumfromtable['number']);
288 unset($datumfromtable['icon']);
289 $actions = $datumfromtable['actions'];
290 unset($datumfromtable['actions']);
291 unset($datumfromtable['name']);
292 $labels = array('s' => get_string('attempts', 'quiz_statistics'),
293 'facility' => get_string('facility', 'quiz_statistics'),
294 'sd' => get_string('standarddeviationq', 'quiz_statistics'),
295 'random_guess_score' => get_string('random_guess_score', 'quiz_statistics'),
296 'intended_weight' => get_string('intended_weight', 'quiz_statistics'),
297 'effective_weight' => get_string('effective_weight', 'quiz_statistics'),
298 'discrimination_index' => get_string('discrimination_index', 'quiz_statistics'),
299 'discriminative_efficiency' => get_string('discriminative_efficiency', 'quiz_statistics'));
300 foreach ($datumfromtable as $item => $value) {
301 $questionstatstable->data[] = array($labels[$item], $value);
869309b8 302 }
04853f27
TH
303
304 // Display the various bits.
305 echo $OUTPUT->heading(get_string('questioninformation', 'quiz_statistics'));
306 echo html_writer::table($questioninfotable);
307
308 echo $OUTPUT->box(format_text($question->questiontext, $question->questiontextformat, array('overflowdiv'=>true)).$actions, 'boxaligncenter generalbox boxwidthnormal mdl-align');
309
310 echo $OUTPUT->heading(get_string('questionstatistics', 'quiz_statistics'));
311 echo html_writer::table($questionstatstable);
869309b8 312 }
3b1d5cc4 313
04853f27
TH
314 /**
315 * Display the response analysis for a question.
316 * @param object $question the question to report on.
317 * @param moodle_url $reporturl the URL to resisplay this report.
318 * @param object $quizstats Holds the quiz statistics.
319 */
320 protected function output_individual_question_response_analysis($question, $reporturl, $quizstats) {
321 global $OUTPUT;
3b1d5cc4 322
04853f27
TH
323 if (!question_bank::get_qtype($question->qtype, false)->can_analyse_responses()) {
324 return;
325 }
3b1d5cc4 326
04853f27
TH
327 $qtable = new quiz_report_statistics_question_table($question->id);
328 $exportclass = $this->table->export_class_instance();
329 $qtable->export_class_instance($exportclass);
330 if (!$this->table->is_downloading()) {
331 // Output an appropriate title.
332 echo $OUTPUT->heading(get_string('analysisofresponses', 'quiz_statistics'));
3b1d5cc4 333
869309b8 334 } else {
04853f27
TH
335 // Work out an appropriate title.
336 $questiontabletitle = '"' . $question->name . '"';
337 if (!empty($question->number)) {
338 $questiontabletitle = '(' . $question->number . ') ' . $questiontabletitle;
339 }
340 if ($this->table->is_downloading() == 'xhtml') {
869309b8 341 $questiontabletitle = get_string('analysisofresponsesfor', 'quiz_statistics', $questiontabletitle);
342 }
04853f27
TH
343
344 // Set up the table.
869309b8 345 $exportclass->start_table($questiontabletitle);
346 }
3b1d5cc4 347
04853f27
TH
348 $responesstats = new quiz_statistics_response_analyser($question);
349 $responesstats->load_cached($quizstats->id);
350
351 $qtable->setup($reporturl, $question, $responesstats);
352 if ($this->table->is_downloading()) {
353 $exportclass->output_headers($qtable->headers);
354 }
355
356 foreach ($responesstats->responseclasses as $partid => $partclasses) {
0ff4bd08 357 $rowdata = new stdClass();
04853f27
TH
358 $rowdata->part = $partid;
359 foreach ($partclasses as $responseclassid => $responseclass) {
360 $rowdata->responseclass = $responseclass->responseclass;
361
362 $responsesdata = $responesstats->responses[$partid][$responseclassid];
363 if (empty($responsesdata)) {
364 if (!array_key_exists('responseclass', $qtable->columns)) {
365 $rowdata->response = $responseclass->responseclass;
869309b8 366 } else {
04853f27 367 $rowdata->response = '';
869309b8 368 }
04853f27
TH
369 $rowdata->fraction = $responseclass->fraction;
370 $rowdata->count = 0;
371 $qtable->add_data_keyed($qtable->format_row($rowdata));
372 continue;
373 }
374
375 foreach ($responsesdata as $response => $data) {
376 $rowdata->response = $response;
377 $rowdata->fraction = $data->fraction;
378 $rowdata->count = $data->count;
379 $qtable->add_data_keyed($qtable->format_row($rowdata));
869309b8 380 }
869309b8 381 }
43ec99aa 382 }
04853f27
TH
383
384 $qtable->finish_output(!$this->table->is_downloading());
869309b8 385 }
3b1d5cc4 386
04853f27
TH
387 /**
388 * Output the table that lists all the questions in the quiz with their statistics.
f7970e3c 389 * @param int $s number of attempts.
04853f27
TH
390 * @param array $questions the questions in the quiz.
391 * @param array $subquestions the subquestions of any random questions.
392 */
393 protected function output_quiz_structure_analysis_table($s, $questions, $subquestions) {
394 if (!$s) {
395 return;
396 }
397
398 foreach ($questions as $question) {
399 // Output the data for this questions.
400 $this->table->add_data_keyed($this->table->format_row($question));
401
402 if (empty($question->_stats->subquestions)) {
403 continue;
71a2b878 404 }
43ec99aa 405
04853f27
TH
406 // And its subquestions, if it has any.
407 $subitemstodisplay = explode(',', $question->_stats->subquestions);
408 foreach ($subitemstodisplay as $subitemid) {
409 $subquestions[$subitemid]->maxmark = $question->maxmark;
410 $this->table->add_data_keyed($this->table->format_row($subquestions[$subitemid]));
411 }
0c1c764e 412 }
04853f27
TH
413
414 $this->table->finish_output(!$this->table->is_downloading());
43ec99aa 415 }
3b1d5cc4 416
04853f27
TH
417 protected function get_formatted_quiz_info_data($course, $cm, $quiz, $quizstats) {
418
419 // You can edit this array to control which statistics are displayed.
420 $todisplay = array('firstattemptscount' => 'number',
421 'allattemptscount' => 'number',
422 'firstattemptsavg' => 'summarks_as_percentage',
423 'allattemptsavg' => 'summarks_as_percentage',
424 'median' => 'summarks_as_percentage',
425 'standarddeviation' => 'summarks_as_percentage',
426 'skewness' => 'number_format',
427 'kurtosis' => 'number_format',
428 'cic' => 'number_format_percent',
429 'errorratio' => 'number_format_percent',
430 'standarderror' => 'summarks_as_percentage');
431
432 // General information about the quiz.
433 $quizinfo = array();
434 $quizinfo[get_string('quizname', 'quiz_statistics')] = format_string($quiz->name);
435 $quizinfo[get_string('coursename', 'quiz_statistics')] = format_string($course->fullname);
436 if ($cm->idnumber) {
437 $quizinfo[get_string('idnumbermod')] = $cm->idnumber;
438 }
439 if ($quiz->timeopen) {
440 $quizinfo[get_string('quizopen', 'quiz')] = userdate($quiz->timeopen);
441 }
442 if ($quiz->timeclose) {
443 $quizinfo[get_string('quizclose', 'quiz')] = userdate($quiz->timeclose);
444 }
445 if ($quiz->timeopen && $quiz->timeclose) {
446 $quizinfo[get_string('duration', 'quiz_statistics')] = format_time($quiz->timeclose - $quiz->timeopen);
447 }
448
449 // The statistics.
450 foreach ($todisplay as $property => $format) {
451 if (!isset($quizstats->$property) || empty($format[$property])) {
43ec99aa 452 continue;
71a2b878 453 }
04853f27
TH
454 $value = $quizstats->$property;
455
456 switch ($format) {
457 case 'summarks_as_percentage':
458 $formattedvalue = quiz_report_scale_summarks_as_percentage($value, $quiz);
459 break;
460 case 'number_format_percent':
461 $formattedvalue = quiz_format_grade($quiz, $value) . '%';
462 break;
463 case 'number_format':
464 // + 2 decimal places, since not a percentage, and we want the same number of sig figs.
465 $formattedvalue = format_float($value, $quiz->decimalpoints + 2);
466 break;
467 case 'number':
468 $formattedvalue = $value + 0;
469 break;
470 default:
471 $formattedvalue = $value;
71a2b878 472 }
04853f27
TH
473
474 $quizinfo[get_string($property, 'quiz_statistics',
475 $this->using_attempts_string(!empty($quizstats->allattempts)))] =
476 $formattedvalue;
43ec99aa 477 }
04853f27
TH
478
479 return $quizinfo;
480 }
481
482 /**
483 * Output the table of overall quiz statistics.
484 * @param array $quizinfo as returned by {@link get_formatted_quiz_info_data()}.
485 * @return string the HTML.
486 */
487 protected function output_quiz_info_table($quizinfo) {
488
489 $quizinfotable = new html_table();
490 $quizinfotable->align = array('center', 'center');
491 $quizinfotable->width = '60%';
492 $quizinfotable->attributes['class'] = 'generaltable titlesleft';
493 $quizinfotable->data = array();
494
495 foreach ($quizinfo as $heading => $value) {
496 $quizinfotable->data[] = array($heading, $value);
71a2b878 497 }
04853f27
TH
498
499 return html_writer::table($quizinfotable);
71a2b878 500 }
43ec99aa 501
04853f27
TH
502 /**
503 * Download the table of overall quiz statistics.
504 * @param array $quizinfo as returned by {@link get_formatted_quiz_info_data()}.
505 */
506 protected function download_quiz_info_table($quizinfo) {
507 global $OUTPUT;
3b1d5cc4 508
04853f27
TH
509 // XHTML download is a special case.
510 if ($this->table->is_downloading() == 'xhtml') {
511 echo $OUTPUT->heading(get_string('quizinformation', 'quiz_statistics'));
512 echo $this->output_quiz_info_table($quizinfo);
513 return;
514 }
3b1d5cc4 515
04853f27
TH
516 // Reformat the data ready for output.
517 $headers = array();
518 $row = array();
519 foreach ($quizinfo as $heading => $value) {
520 $headers[] = $heading;
521 $row[] = $value;
522 }
3b1d5cc4 523
04853f27
TH
524 // Do the output.
525 $exportclass = $this->table->export_class_instance();
526 $exportclass->start_table(get_string('quizinformation', 'quiz_statistics'));
527 $exportclass->output_headers($headers);
528 $exportclass->add_data($row);
529 $exportclass->finish_table();
530 }
531
532 /**
533 * Output the HTML needed to show the statistics graph.
f7970e3c 534 * @param int $quizstatsid the id of the statistics to show in the graph.
04853f27
TH
535 */
536 protected function output_statistics_graph($quizstatsid, $s) {
537 global $OUTPUT;
538
539 if ($s == 0) {
540 return;
541 }
542
543 $imageurl = new moodle_url('/mod/quiz/report/statistics/statistics_graph.php',
544 array('id' => $quizstatsid));
545 $OUTPUT->heading(get_string('statisticsreportgraph', 'quiz_statistics'));
546 echo html_writer::tag('div', html_writer::empty_tag('img',
547 array('src' => $imageurl, 'alt' => get_string('statisticsreportgraph', 'quiz_statistics'))),
e24ee794 548 array('class' => 'graph'));
04853f27
TH
549 }
550
551 /**
552 * Return the stats data for when there are no stats to show.
553 *
554 * @param array $questions question definitions.
f7970e3c
TH
555 * @param int $firstattemptscount number of first attempts (optional).
556 * @param int $firstattemptscount total number of attempts (optional).
04853f27
TH
557 * @return array with three elements:
558 * - integer $s Number of attempts included in the stats (0).
559 * - array $quizstats The statistics for overall attempt scores.
560 * - array $qstats The statistics for each question.
561 */
562 protected function get_emtpy_stats($questions, $firstattemptscount = 0,
563 $allattemptscount = 0) {
0ff4bd08 564 $quizstats = new stdClass();
04853f27
TH
565 $quizstats->firstattemptscount = $firstattemptscount;
566 $quizstats->allattemptscount = $allattemptscount;
567
0ff4bd08 568 $qstats = new stdClass();
04853f27
TH
569 $qstats->questions = $questions;
570 $qstats->subquestions = array();
571 $qstats->responses = array();
572
573 return array(0, $quizstats, false);
574 }
575
576 /**
577 * Compute the quiz statistics.
578 *
579 * @param object $quizid the quiz id.
f7970e3c
TH
580 * @param int $currentgroup the current group. 0 for none.
581 * @param bool $nostudentsingroup true if there a no students.
582 * @param bool $useallattempts use all attempts, or just first attempts.
04853f27
TH
583 * @param array $groupstudents students in this group.
584 * @param array $questions question definitions.
585 * @return array with three elements:
586 * - integer $s Number of attempts included in the stats.
587 * - array $quizstats The statistics for overall attempt scores.
588 * - array $qstats The statistics for each question.
589 */
590 protected function compute_stats($quizid, $currentgroup, $nostudentsingroup,
591 $useallattempts, $groupstudents, $questions) {
592 global $DB;
593
594 // Calculating MEAN of marks for all attempts by students
595 // http://docs.moodle.org/en/Development:Quiz_item_analysis_calculations_in_practise#Calculating_MEAN_of_grades_for_all_attempts_by_students
596 if ($nostudentsingroup) {
597 return $this->get_emtpy_stats($questions);
598 }
599
600 list($fromqa, $whereqa, $qaparams) = quiz_statistics_attempts_sql(
601 $quizid, $currentgroup, $groupstudents, true);
602
603 $attempttotals = $DB->get_records_sql("
604 SELECT
605 CASE WHEN attempt = 1 THEN 1 ELSE 0 END AS isfirst,
606 COUNT(1) AS countrecs,
607 SUM(sumgrades) AS total
608 FROM $fromqa
609 WHERE $whereqa
610 GROUP BY attempt = 1", $qaparams);
611
612 if (!$attempttotals) {
613 return $this->get_emtpy_stats($questions);
614 }
615
616 if (isset($attempttotals[1])) {
617 $firstattempts = $attempttotals[1];
618 $firstattempts->average = $firstattempts->total / $firstattempts->countrecs;
0c1c764e 619 } else {
0ff4bd08 620 $firstattempts = new stdClass();
04853f27
TH
621 $firstattempts->countrecs = 0;
622 $firstattempts->total = 0;
623 $firstattempts->average = '-';
0c1c764e 624 }
04853f27 625
0ff4bd08 626 $allattempts = new stdClass();
04853f27
TH
627 if (isset($attempttotals[0])) {
628 $allattempts->countrecs = $firstattempts->countrecs + $attempttotals[0]->countrecs;
629 $allattempts->total = $firstattempts->total + $attempttotals[0]->total;
71a2b878 630 } else {
04853f27
TH
631 $allattempts->countrecs = $firstattempts->countrecs;
632 $allattempts->total = $firstattempts->total;
0c1c764e 633 }
3b1d5cc4 634
04853f27
TH
635 if ($useallattempts) {
636 $usingattempts = $allattempts;
637 $usingattempts->sql = '';
638 } else {
639 $usingattempts = $firstattempts;
640 $usingattempts->sql = 'AND quiza.attempt = 1 ';
641 }
e72efdd4 642
04853f27
TH
643 $s = $usingattempts->countrecs;
644 if ($s == 0) {
645 return $this->get_emtpy_stats($questions, $firstattempts->countrecs,
646 $allattempts->countrecs);
647 }
648 $summarksavg = $usingattempts->total / $usingattempts->countrecs;
3b1d5cc4 649
0ff4bd08 650 $quizstats = new stdClass();
04853f27
TH
651 $quizstats->allattempts = $useallattempts;
652 $quizstats->firstattemptscount = $firstattempts->countrecs;
653 $quizstats->allattemptscount = $allattempts->countrecs;
654 $quizstats->firstattemptsavg = $firstattempts->average;
655 $quizstats->allattemptsavg = $allattempts->total / $allattempts->countrecs;
3b1d5cc4 656
04853f27
TH
657 // Recalculate sql again this time possibly including test for first attempt.
658 list($fromqa, $whereqa, $qaparams) = quiz_statistics_attempts_sql(
659 $quizid, $currentgroup, $groupstudents, $useallattempts);
6f51ed72 660
04853f27
TH
661 // Median
662 if ($s % 2 == 0) {
663 //even number of attempts
664 $limitoffset = $s/2 - 1;
665 $limit = 2;
666 } else {
667 $limitoffset = floor($s/2);
668 $limit = 1;
669 }
670 $sql = "SELECT id, sumgrades
671 FROM $fromqa
672 WHERE $whereqa
673 ORDER BY sumgrades";
3b1d5cc4 674
04853f27 675 $medianmarks = $DB->get_records_sql_menu($sql, $qaparams, $limitoffset, $limit);
3b1d5cc4 676
04853f27
TH
677 $quizstats->median = array_sum($medianmarks) / count($medianmarks);
678 if ($s > 1) {
679 //fetch sum of squared, cubed and power 4d
680 //differences between marks and mean mark
681 $mean = $usingattempts->total / $s;
0ff4bd08 682 $sql = "SELECT
04853f27
TH
683 SUM(POWER((quiza.sumgrades - $mean),2)) AS power2,
684 SUM(POWER((quiza.sumgrades - $mean),3)) AS power3,
685 SUM(POWER((quiza.sumgrades - $mean),4)) AS power4
686 FROM $fromqa
687 WHERE $whereqa";
688 $params = array('mean1' => $mean, 'mean2' => $mean, 'mean3' => $mean)+$qaparams;
3b1d5cc4 689
04853f27 690 $powers = $DB->get_record_sql($sql, $params, MUST_EXIST);
3b1d5cc4 691
04853f27
TH
692 // Standard_Deviation
693 //see http://docs.moodle.org/en/Development:Quiz_item_analysis_calculations_in_practise#Standard_Deviation
3b1d5cc4 694
04853f27
TH
695 $quizstats->standarddeviation = sqrt($powers->power2 / ($s - 1));
696
697 // Skewness
698 if ($s > 2) {
699 //see http://docs.moodle.org/en/Development:Quiz_item_analysis_calculations_in_practise#Skewness_and_Kurtosis
700 $m2= $powers->power2 / $s;
701 $m3= $powers->power3 / $s;
702 $m4= $powers->power4 / $s;
703
704 $k2= $s*$m2/($s-1);
705 $k3= $s*$s*$m3/(($s-1)*($s-2));
706 if ($k2) {
707 $quizstats->skewness = $k3 / (pow($k2, 3/2));
6f51ed72 708 }
e72efdd4 709 }
04853f27
TH
710
711 // Kurtosis
712 if ($s > 3) {
713 $k4= $s*$s*((($s+1)*$m4)-(3*($s-1)*$m2*$m2))/(($s-1)*($s-2)*($s-3));
714 if ($k2) {
715 $quizstats->kurtosis = $k4 / ($k2*$k2);
43ec99aa 716 }
4f5ffac0 717 }
08a7ead5 718 }
04853f27
TH
719
720 $qstats = new quiz_statistics_question_stats($questions, $s, $summarksavg);
721 $qstats->load_step_data($quizid, $currentgroup, $groupstudents, $useallattempts);
722 $qstats->compute_statistics();
723
724 if ($s > 1) {
725 $p = count($qstats->questions); // No of positions
726 if ($p > 1 && isset($k2)) {
727 $quizstats->cic = (100 * $p / ($p -1)) * (1 - ($qstats->get_sum_of_mark_variance())/$k2);
728 $quizstats->errorratio = 100 * sqrt(1-($quizstats->cic/100));
729 $quizstats->standarderror = ($quizstats->errorratio * $quizstats->standarddeviation / 100);
730 }
731 }
732
733 return array($s, $quizstats, $qstats);
0c1c764e 734 }
3b1d5cc4 735
04853f27
TH
736 /**
737 * Load the cached statistics from the database.
738 *
739 * @param object $quiz the quiz settings
f7970e3c
TH
740 * @param int $currentgroup the current group. 0 for none.
741 * @param bool $nostudentsingroup true if there a no students.
742 * @param bool $useallattempts use all attempts, or just first attempts.
04853f27
TH
743 * @param array $groupstudents students in this group.
744 * @param array $questions question definitions.
745 * @return array with 4 elements:
746 * - $quizstats The statistics for overall attempt scores.
747 * - $questions The questions, with an additional _stats field.
748 * - $subquestions The subquestions, if any, with an additional _stats field.
749 * - $s Number of attempts included in the stats.
750 * If there is no cached data in the database, returns an array of four nulls.
751 */
752 protected function try_loading_cached_stats($quiz, $currentgroup,
753 $nostudentsingroup, $useallattempts, $groupstudents, $questions) {
43ec99aa 754 global $DB;
04853f27
TH
755
756 $timemodified = time() - self::TIME_TO_CACHE_STATS;
757 $quizstats = $DB->get_record_select('quiz_statistics',
758 'quizid = ? AND groupid = ? AND allattempts = ? AND timemodified > ?',
759 array($quiz->id, $currentgroup, $useallattempts, $timemodified));
760
761 if (!$quizstats) {
762 // No cached data found.
763 return array(null, $questions, null, null);
764 }
765
766 if ($useallattempts) {
767 $s = $quizstats->allattemptscount;
768 } else {
769 $s = $quizstats->firstattemptscount;
770 }
771
772 $subquestions = array();
773 $questionstats = $DB->get_records('quiz_question_statistics',
774 array('quizstatisticsid' => $quizstats->id));
775
776 $subquestionstats = array();
777 foreach ($questionstats as $stat) {
778 if ($stat->slot) {
779 $questions[$stat->slot]->_stats = $stat;
780 } else {
781 $subquestionstats[$stat->questionid] = $stat;
43ec99aa 782 }
04853f27
TH
783 }
784
785 if (!empty($subquestionstats)) {
786 $subqstofetch = array_keys($subquestionstats);
787 $subquestions = question_load_questions($subqstofetch);
788 foreach ($subquestions as $subqid => $subq) {
789 $subquestions[$subqid]->_stats = $subquestionstats[$subqid];
790 $subquestions[$subqid]->maxmark = $subq->defaultmark;
791 }
792 }
793
794 return array($quizstats, $questions, $subquestions, $s);
795 }
796
797 /**
798 * Store the statistics in the cache tables in the database.
799 *
800 * @param object $quizid the quiz id.
f7970e3c
TH
801 * @param int $currentgroup the current group. 0 for none.
802 * @param bool $useallattempts use all attempts, or just first attempts.
04853f27
TH
803 * @param object $quizstats The statistics for overall attempt scores.
804 * @param array $questions The questions, with an additional _stats field.
805 * @param array $subquestions The subquestions, if any, with an additional _stats field.
806 */
807 protected function cache_stats($quizid, $currentgroup,
808 $quizstats, $questions, $subquestions) {
809 global $DB;
810
811 $toinsert = clone($quizstats);
812 $toinsert->quizid = $quizid;
813 $toinsert->groupid = $currentgroup;
814 $toinsert->timemodified = time();
815
816 // Fix up some dodgy data.
817 if (isset($toinsert->errorratio) && is_nan($toinsert->errorratio)) {
818 $toinsert->errorratio = null;
819 }
820 if (isset($toinsert->standarderror) && is_nan($toinsert->standarderror)) {
821 $toinsert->standarderror = null;
822 }
823
824 // Store the data.
825 $quizstats->id = $DB->insert_record('quiz_statistics', $toinsert);
826
827 foreach ($questions as $question) {
828 $question->_stats->quizstatisticsid = $quizstats->id;
829 $DB->insert_record('quiz_question_statistics', $question->_stats, false);
830 }
831
832 foreach ($subquestions as $subquestion) {
833 $subquestion->_stats->quizstatisticsid = $quizstats->id;
834 $DB->insert_record('quiz_question_statistics', $subquestion->_stats, false);
835 }
836
837 return $quizstats->id;
838 }
839
840 /**
841 * Get the quiz and question statistics, either by loading the cached results,
842 * or by recomputing them.
843 *
844 * @param object $quiz the quiz settings.
f7970e3c
TH
845 * @param int $currentgroup the current group. 0 for none.
846 * @param bool $nostudentsingroup true if there a no students.
847 * @param bool $useallattempts use all attempts, or just first attempts.
04853f27
TH
848 * @param array $groupstudents students in this group.
849 * @param array $questions question definitions.
850 * @return array with 4 elements:
851 * - $quizstats The statistics for overall attempt scores.
852 * - $questions The questions, with an additional _stats field.
853 * - $subquestions The subquestions, if any, with an additional _stats field.
854 * - $s Number of attempts included in the stats.
855 */
856 protected function get_quiz_and_questions_stats($quiz, $currentgroup,
857 $nostudentsingroup, $useallattempts, $groupstudents, $questions) {
858
859 list($quizstats, $questions, $subquestions, $s) =
860 $this->try_loading_cached_stats($quiz, $currentgroup, $nostudentsingroup,
861 $useallattempts, $groupstudents, $questions);
862
863 if (is_null($quizstats)) {
864 list($s, $quizstats, $qstats) = $this->compute_stats($quiz->id,
865 $currentgroup, $nostudentsingroup, $useallattempts, $groupstudents, $questions);
866
867 if ($s) {
43ec99aa 868 $questions = $qstats->questions;
869 $subquestions = $qstats->subquestions;
04853f27
TH
870
871 $quizstatisticsid = $this->cache_stats($quiz->id, $currentgroup,
872 $quizstats, $questions, $subquestions);
873
874 $this->analyse_responses($quizstatisticsid, $quiz->id, $currentgroup,
875 $nostudentsingroup, $useallattempts, $groupstudents,
876 $questions, $subquestions);
43ec99aa 877 }
04853f27
TH
878 }
879
880 return array($quizstats, $questions, $subquestions, $s);
881 }
882
883 protected function analyse_responses($quizstatisticsid, $quizid, $currentgroup,
884 $nostudentsingroup, $useallattempts, $groupstudents, $questions, $subquestions) {
885
886 $qubaids = quiz_statistics_qubaids_condition($quizid, $currentgroup, $groupstudents, $useallattempts);
887
888 $done = array();
889 foreach ($questions as $question) {
890 if (!question_bank::get_qtype($question->qtype, false)->can_analyse_responses()) {
891 continue;
43ec99aa 892 }
04853f27
TH
893 $done[$question->id] = 1;
894
895 $responesstats = new quiz_statistics_response_analyser($question);
896 $responesstats->analyse($qubaids);
897 $responesstats->store_cached($quizstatisticsid);
898 }
899
900 foreach ($subquestions as $question) {
901 if (!question_bank::get_qtype($question->qtype, false)->can_analyse_responses() ||
902 isset($done[$question->id])) {
903 continue;
43ec99aa 904 }
04853f27
TH
905 $done[$question->id] = 1;
906
907 $responesstats = new quiz_statistics_response_analyser($question);
908 $responesstats->analyse($qubaids);
909 $responesstats->store_cached($quizstatisticsid);
910 }
911 }
912
913 /**
914 * @return string HTML snipped for the Download full report as UI.
915 */
916 protected function everything_download_options() {
917 $downloadoptions = $this->table->get_download_menu();
918
919 $output = '<form action="'. $this->table->baseurl .'" method="post">';
920 $output .= '<div class="mdl-align">';
921 $output .= '<input type="hidden" name="everything" value="1"/>';
922 $output .= '<input type="submit" value="'.get_string('downloadeverything', 'quiz_statistics').'"/>';
923 $output .= html_writer::select($downloadoptions, 'download', $this->table->defaultdownloadformat, false);
924 $output .= '</div></form>';
925
926 return $output;
927 }
928
929 /**
930 * Generate the snipped of HTML that says when the stats were last caculated,
931 * with a recalcuate now button.
932 * @param object $quizstats the overall quiz statistics.
f7970e3c
TH
933 * @param int $quizid the quiz id.
934 * @param int $currentgroup the id of the currently selected group, or 0.
04853f27 935 * @param array $groupstudents ids of students in the group.
f7970e3c 936 * @param bool $useallattempts whether to use all attempts, instead of just first attempts.
04853f27
TH
937 * @return string a HTML snipped saying when the stats were last computed, or blank if that is not appropriate.
938 */
939 protected function output_caching_info($quizstats, $quizid, $currentgroup,
940 $groupstudents, $useallattempts, $reporturl) {
941 global $DB, $OUTPUT;
942
943 if (empty($quizstats->timemodified)) {
944 return '';
945 }
946
947 // Find the number of attempts since the cached statistics were computed.
948 list($fromqa, $whereqa, $qaparams) = quiz_statistics_attempts_sql(
949 $quizid, $currentgroup, $groupstudents, $useallattempts, true);
950 $count = $DB->count_records_sql("
951 SELECT COUNT(1)
952 FROM $fromqa
953 WHERE $whereqa
954 AND quiza.timefinish > {$quizstats->timemodified}", $qaparams);
955
956 if (!$count) {
957 $count = 0;
958 }
959
960 // Generate the output.
0ff4bd08 961 $a = new stdClass();
04853f27
TH
962 $a->lastcalculated = format_time(time() - $quizstats->timemodified);
963 $a->count = $count;
964
965 $recalcualteurl = new moodle_url($reporturl, array('recalculate' => 1, 'sesskey' => sesskey()));
966 $output = '';
967 $output .= $OUTPUT->box_start('boxaligncenter generalbox boxwidthnormal mdl-align', 'cachingnotice');
968 $output .= get_string('lastcalculated', 'quiz_statistics', $a);
969 $output .= $OUTPUT->single_button($recalcualteurl, get_string('recalculatenow', 'quiz_statistics'));
970 $output .= $OUTPUT->box_end(true);
971
972 return $output;
973 }
974
975 /**
976 * Clear the cached data for a particular report configuration. This will
977 * trigger a re-computation the next time the report is displayed.
f7970e3c
TH
978 * @param int $quizid the quiz id.
979 * @param int $currentgroup a group id, or 0.
980 * @param bool $useallattempts whether all attempts, or just first attempts are included.
04853f27
TH
981 */
982 protected function clear_cached_data($quizid, $currentgroup, $useallattempts) {
983 global $DB;
984
985 $todelete = $DB->get_records_menu('quiz_statistics', array('quizid' => $quizid,
986 'groupid' => $currentgroup, 'allattempts' => $useallattempts), '', 'id,1');
987
988 if (!$todelete) {
989 return;
990 }
991
992 list($todeletesql, $todeleteparams) = $DB->get_in_or_equal(array_keys($todelete));
993
994 $DB->delete_records_select('quiz_question_statistics', 'quizstatisticsid ' . $todeletesql, $todeleteparams);
995 $DB->delete_records_select('quiz_question_response_stats', 'quizstatisticsid ' . $todeletesql, $todeleteparams);
996 $DB->delete_records_select('quiz_statistics', 'id ' . $todeletesql, $todeleteparams);
997 }
998
999 /**
f7970e3c 1000 * @param bool $useallattempts whether we are using all attempts.
04853f27
TH
1001 * @return the appropriate lang string to describe this option.
1002 */
1003 protected function using_attempts_string($useallattempts) {
1004 if ($useallattempts) {
1005 return get_string('allattempts', 'quiz_statistics');
1006 } else {
1007 return get_string('firstattempts', 'quiz_statistics');
43ec99aa 1008 }
43ec99aa 1009 }
0c1c764e 1010}
04853f27
TH
1011
1012function quiz_statistics_attempts_sql($quizid, $currentgroup, $groupstudents,
1013 $allattempts = true, $includeungraded = false) {
1014 global $CFG;
1015
1016 $fromqa = '{quiz_attempts} quiza ';
1017
1018 $whereqa = 'quiza.quiz = :quizid AND quiza.preview = 0 AND quiza.timefinish <> 0';
1019 $qaparams = array('quizid' => $quizid);
1020
71a2b878 1021 if (!empty($currentgroup) && $groupstudents) {
a2ac2349
TH
1022 list($grpsql, $grpparams) = get_in_or_equal(array_keys($groupstudents),
1023 SQL_PARAMS_NAMED, 'u');
04853f27 1024 $whereqa .= " AND quiza.userid $grpsql";
71a2b878 1025 $qaparams += $grpparams;
1026 }
04853f27
TH
1027
1028 if (!$allattempts) {
1029 $whereqa .= ' AND quiza.attempt = 1';
1030 }
1031
1032 if (!$includeungraded) {
1033 $whereqa .= ' AND quiza.sumgrades IS NOT NULL';
71a2b878 1034 }
04853f27 1035
71a2b878 1036 return array($fromqa, $whereqa, $qaparams);
1037}
869309b8 1038
04853f27
TH
1039/**
1040 * Return a {@link qubaid_condition} from the values returned by
1041 * {@link quiz_statistics_attempts_sql}
1042 * @param string $fromqa from quiz_statistics_attempts_sql.
1043 * @param string $whereqa from quiz_statistics_attempts_sql.
1044 */
1045function quiz_statistics_qubaids_condition($quizid, $currentgroup, $groupstudents,
1046 $allattempts = true, $includeungraded = false) {
1047 list($fromqa, $whereqa, $qaparams) = quiz_statistics_attempts_sql($quizid, $currentgroup,
1048 $groupstudents, $allattempts, $includeungraded);
1049 return new qubaid_join($fromqa, 'quiza.uniqueid', $whereqa, $qaparams);
1050}