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