MDL-34211 Use the $table_name argument to check against $metatables key values
[moodle.git] / mod / quiz / report / statistics / report.php
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/>.
17 /**
18  * Quiz statistics report class.
19  *
20  * @package   quiz_statistics
21  * @copyright 2008 Jamie Pratt
22  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23  */
26 defined('MOODLE_INTERNAL') || die();
28 require_once($CFG->dirroot . '/mod/quiz/report/statistics/statistics_form.php');
29 require_once($CFG->dirroot . '/mod/quiz/report/statistics/statistics_table.php');
30 require_once($CFG->dirroot . '/mod/quiz/report/statistics/statistics_question_table.php');
31 require_once($CFG->dirroot . '/mod/quiz/report/statistics/qstats.php');
32 require_once($CFG->dirroot . '/mod/quiz/report/statistics/responseanalysis.php');
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  *
40  * @copyright 2008 Jamie Pratt
41  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
42  */
43 class quiz_statistics_report extends quiz_default_report {
44     /** @var integer Time after which statistics are automatically recomputed. */
45     const TIME_TO_CACHE_STATS = 900; // 15 minutes.
47     /** @var object instance of table class used for main questions stats table. */
48     protected $table;
50     /**
51      * Display the report.
52      */
53     public function display($quiz, $cm, $course) {
54         global $CFG, $DB, $OUTPUT, $PAGE;
56         $this->context = context_module::instance($cm->id);
58         // Work out the display options.
59         $download = optional_param('download', '', PARAM_ALPHA);
60         $everything = optional_param('everything', 0, PARAM_BOOL);
61         $recalculate = optional_param('recalculate', 0, PARAM_BOOL);
62         // A qid paramter indicates we should display the detailed analysis of a question.
63         $qid = optional_param('qid', 0, PARAM_INT);
64         $slot = optional_param('slot', 0, PARAM_INT);
66         $pageoptions = array();
67         $pageoptions['id'] = $cm->id;
68         $pageoptions['mode'] = 'statistics';
70         $reporturl = new moodle_url('/mod/quiz/report.php', $pageoptions);
72         $mform = new quiz_statistics_settings_form($reporturl);
73         if ($fromform = $mform->get_data()) {
74             $useallattempts = $fromform->useallattempts;
75             if ($fromform->useallattempts) {
76                 set_user_preference('quiz_report_statistics_useallattempts',
77                         $fromform->useallattempts);
78             } else {
79                 unset_user_preference('quiz_report_statistics_useallattempts');
80             }
82         } else {
83             $useallattempts = get_user_preferences('quiz_report_statistics_useallattempts', 0);
84         }
86         // Find out current groups mode.
87         $currentgroup = $this->get_current_group($cm, $course, $this->context);
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();
93         } else if ($currentgroup == self::NO_GROUPS_ALLOWED) {
94             $groupstudents = array();
95             $nostudentsingroup = true;
97         } else {
98             // All users who can attempt quizzes and who are in the currently selected group.
99             $groupstudents = get_users_by_capability($this->context,
100                     array('mod/quiz:reviewmyattempts', 'mod/quiz:attempt'),
101                     '', '', '', '', $currentgroup, '', false);
102             if (!$groupstudents) {
103                 $nostudentsingroup = true;
104             }
105         }
107         // If recalculate was requested, handle that.
108         if ($recalculate && confirm_sesskey()) {
109             $this->clear_cached_data($quiz->id, $currentgroup, $useallattempts);
110             redirect($reporturl);
111         }
113         // Set up the main table.
114         $this->table = new quiz_statistics_table();
115         if ($everything) {
116             $report = get_string('completestatsfilename', 'quiz_statistics');
117         } else {
118             $report = get_string('questionstatsfilename', 'quiz_statistics');
119         }
120         $courseshortname = format_string($course->shortname, true,
121                 array('context' => context_course::instance($course->id)));
122         $filename = quiz_report_download_filename($report, $courseshortname, $quiz->name);
123         $this->table->is_downloading($download, $filename,
124                 get_string('quizstructureanalysis', 'quiz_statistics'));
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         }
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);
147         // Set up the table, if there is data.
148         if ($s) {
149             $this->table->statistics_setup($quiz, $cm->id, $reporturl, $s);
150         }
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');
156             if (groups_get_activity_groupmode($cm)) {
157                 groups_print_activity_menu($cm, $reporturl->out());
158                 if ($currentgroup && !$groupstudents) {
159                     $OUTPUT->notification(get_string('nostudentsingroup', 'quiz_statistics'));
160                 }
161             }
163             if (!quiz_questions_in_quiz($quiz->questions)) {
164                 echo quiz_no_questions_message($quiz, $cm, $this->context);
165             } else if (!$this->table->is_downloading() && $s == 0) {
166                 echo $OUTPUT->notification(get_string('noattempts', 'quiz'));
167             }
169             // Print display options form.
170             $mform->set_data(array('useallattempts' => $useallattempts));
171             $mform->display();
172         }
174         if ($everything) { // Implies is downloading.
175             // Overall report, then the analysis of each question.
176             $this->download_quiz_info_table($quizinfo);
178             if ($s) {
179                 $this->output_quiz_structure_analysis_table($s, $questions, $subquestions);
181                 if ($this->table->is_downloading() == 'xhtml') {
182                     $this->output_statistics_graph($quizstats->id, $s);
183                 }
185                 foreach ($questions as $question) {
186                     if (question_bank::get_qtype(
187                             $question->qtype, false)->can_analyse_responses()) {
188                         $this->output_individual_question_response_analysis(
189                                 $question, $reporturl, $quizstats);
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);
196                         }
197                     }
198                 }
199             }
201             $this->table->export_class_instance()->finish_document();
203         } else if ($slot) {
204             // Report on an individual question indexed by position.
205             if (!isset($questions[$slot])) {
206                 print_error('questiondoesnotexist', 'question');
207             }
209             $this->output_individual_question_data($quiz, $questions[$slot]);
210             $this->output_individual_question_response_analysis(
211                     $questions[$slot], $reporturl, $quizstats);
213             // Back to overview link.
214             echo $OUTPUT->box('<a href="' . $reporturl->out() . '">' .
215                     get_string('backtoquizreport', 'quiz_statistics') . '</a>',
216                     'backtomainstats boxaligncenter generalbox boxwidthnormal mdl-align');
218         } else if ($qid) {
219             // Report on an individual sub-question indexed questionid.
220             if (!isset($subquestions[$qid])) {
221                 print_error('questiondoesnotexist', 'question');
222             }
224             $this->output_individual_question_data($quiz, $subquestions[$qid]);
225             $this->output_individual_question_response_analysis(
226                     $subquestions[$qid], $reporturl, $quizstats);
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');
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();
239         } else {
240             // On-screen display of overview report.
241             echo $OUTPUT->heading(get_string('quizinformation', 'quiz_statistics'));
242             echo $this->output_caching_info($quizstats, $quiz->id, $currentgroup,
243                     $groupstudents, $useallattempts, $reporturl);
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);
250             }
251         }
253         return true;
254     }
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;
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);
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';
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);
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';
294         unset($datumfromtable['number']);
295         unset($datumfromtable['icon']);
296         $actions = $datumfromtable['actions'];
297         unset($datumfromtable['actions']);
298         unset($datumfromtable['name']);
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         );
310         foreach ($datumfromtable as $item => $value) {
311             $questionstatstable->data[] = array($labels[$item], $value);
312         }
314         // Display the various bits.
315         echo $OUTPUT->heading(get_string('questioninformation', 'quiz_statistics'));
316         echo html_writer::table($questioninfotable);
317         echo $this->render_question_text($question);
318         echo $OUTPUT->heading(get_string('questionstatistics', 'quiz_statistics'));
319         echo html_writer::table($questionstatstable);
320     }
321     public function format_text($text, $format, $qa, $component, $filearea, $itemid,
322             $clean = false) {
323         $formatoptions = new stdClass();
324         $formatoptions->noclean = !$clean;
325         $formatoptions->para = false;
326         $text = $qa->rewrite_pluginfile_urls($text, $component, $filearea, $itemid);
327         return format_text($text, $format, $formatoptions);
328     }
330     /** @return the result of applying {@link format_text()} to the question text. */
331     public function format_questiontext($qa) {
332         return $this->format_text($this->questiontext, $this->questiontextformat,
333         $qa, 'question', 'questiontext', $this->id);
334     }
336     /**
337      * @param object $question question data.
338      * @return string HTML of question text, ready for display.
339      */
340     protected function render_question_text($question) {
341         global $OUTPUT;
343         $text = question_rewrite_questiontext_preview_urls($question->questiontext,
344                 $this->context->id, 'quiz_statistics', $question->id);
346         return $OUTPUT->box(format_text($text, $question->questiontextformat,
347                 array('noclean' => true, 'para' => false, 'overflowdiv' => true)),
348                 'questiontext boxaligncenter generalbox boxwidthnormal mdl-align');
349     }
351     /**
352      * Display the response analysis for a question.
353      * @param object $question the question to report on.
354      * @param moodle_url $reporturl the URL to resisplay this report.
355      * @param object $quizstats Holds the quiz statistics.
356      */
357     protected function output_individual_question_response_analysis($question,
358             $reporturl, $quizstats) {
359         global $OUTPUT;
361         if (!question_bank::get_qtype($question->qtype, false)->can_analyse_responses()) {
362             return;
363         }
365         $qtable = new quiz_statistics_question_table($question->id);
366         $exportclass = $this->table->export_class_instance();
367         $qtable->export_class_instance($exportclass);
368         if (!$this->table->is_downloading()) {
369             // Output an appropriate title.
370             echo $OUTPUT->heading(get_string('analysisofresponses', 'quiz_statistics'));
372         } else {
373             // Work out an appropriate title.
374             $questiontabletitle = '"' . $question->name . '"';
375             if (!empty($question->number)) {
376                 $questiontabletitle = '(' . $question->number . ') ' . $questiontabletitle;
377             }
378             if ($this->table->is_downloading() == 'xhtml') {
379                 $questiontabletitle = get_string('analysisofresponsesfor',
380                         'quiz_statistics', $questiontabletitle);
381             }
383             // Set up the table.
384             $exportclass->start_table($questiontabletitle);
386             if ($this->table->is_downloading() == 'xhtml') {
387                 echo $this->render_question_text($question);
388             }
389         }
391         $responesstats = new quiz_statistics_response_analyser($question);
392         $responesstats->load_cached($quizstats->id);
394         $qtable->question_setup($reporturl, $question, $responesstats);
395         if ($this->table->is_downloading()) {
396             $exportclass->output_headers($qtable->headers);
397         }
399         foreach ($responesstats->responseclasses as $partid => $partclasses) {
400             $rowdata = new stdClass();
401             $rowdata->part = $partid;
402             foreach ($partclasses as $responseclassid => $responseclass) {
403                 $rowdata->responseclass = $responseclass->responseclass;
405                 $responsesdata = $responesstats->responses[$partid][$responseclassid];
406                 if (empty($responsesdata)) {
407                     if (!array_key_exists('responseclass', $qtable->columns)) {
408                         $rowdata->response = $responseclass->responseclass;
409                     } else {
410                         $rowdata->response = '';
411                     }
412                     $rowdata->fraction = $responseclass->fraction;
413                     $rowdata->count = 0;
414                     $qtable->add_data_keyed($qtable->format_row($rowdata));
415                     continue;
416                 }
418                 foreach ($responsesdata as $response => $data) {
419                     $rowdata->response = $response;
420                     $rowdata->fraction = $data->fraction;
421                     $rowdata->count = $data->count;
422                     $qtable->add_data_keyed($qtable->format_row($rowdata));
423                 }
424             }
425         }
427         $qtable->finish_output(!$this->table->is_downloading());
428     }
430     /**
431      * Output the table that lists all the questions in the quiz with their statistics.
432      * @param int $s number of attempts.
433      * @param array $questions the questions in the quiz.
434      * @param array $subquestions the subquestions of any random questions.
435      */
436     protected function output_quiz_structure_analysis_table($s, $questions, $subquestions) {
437         if (!$s) {
438             return;
439         }
441         foreach ($questions as $question) {
442             // Output the data for this questions.
443             $this->table->add_data_keyed($this->table->format_row($question));
445             if (empty($question->_stats->subquestions)) {
446                 continue;
447             }
449             // And its subquestions, if it has any.
450             $subitemstodisplay = explode(',', $question->_stats->subquestions);
451             foreach ($subitemstodisplay as $subitemid) {
452                 $subquestions[$subitemid]->maxmark = $question->maxmark;
453                 $this->table->add_data_keyed($this->table->format_row($subquestions[$subitemid]));
454             }
455         }
457         $this->table->finish_output(!$this->table->is_downloading());
458     }
460     protected function get_formatted_quiz_info_data($course, $cm, $quiz, $quizstats) {
462         // You can edit this array to control which statistics are displayed.
463         $todisplay = array('firstattemptscount' => 'number',
464                     'allattemptscount' => 'number',
465                     'firstattemptsavg' => 'summarks_as_percentage',
466                     'allattemptsavg' => 'summarks_as_percentage',
467                     'median' => 'summarks_as_percentage',
468                     'standarddeviation' => 'summarks_as_percentage',
469                     'skewness' => 'number_format',
470                     'kurtosis' => 'number_format',
471                     'cic' => 'number_format_percent',
472                     'errorratio' => 'number_format_percent',
473                     'standarderror' => 'summarks_as_percentage');
475         // General information about the quiz.
476         $quizinfo = array();
477         $quizinfo[get_string('quizname', 'quiz_statistics')] = format_string($quiz->name);
478         $quizinfo[get_string('coursename', 'quiz_statistics')] = format_string($course->fullname);
479         if ($cm->idnumber) {
480             $quizinfo[get_string('idnumbermod')] = $cm->idnumber;
481         }
482         if ($quiz->timeopen) {
483             $quizinfo[get_string('quizopen', 'quiz')] = userdate($quiz->timeopen);
484         }
485         if ($quiz->timeclose) {
486             $quizinfo[get_string('quizclose', 'quiz')] = userdate($quiz->timeclose);
487         }
488         if ($quiz->timeopen && $quiz->timeclose) {
489             $quizinfo[get_string('duration', 'quiz_statistics')] =
490                     format_time($quiz->timeclose - $quiz->timeopen);
491         }
493         // The statistics.
494         foreach ($todisplay as $property => $format) {
495             if (!isset($quizstats->$property) || empty($format[$property])) {
496                 continue;
497             }
498             $value = $quizstats->$property;
500             switch ($format) {
501                 case 'summarks_as_percentage':
502                     $formattedvalue = quiz_report_scale_summarks_as_percentage($value, $quiz);
503                     break;
504                 case 'number_format_percent':
505                     $formattedvalue = quiz_format_grade($quiz, $value) . '%';
506                     break;
507                 case 'number_format':
508                     // 2 extra decimal places, since not a percentage,
509                     // and we want the same number of sig figs.
510                     $formattedvalue = format_float($value, $quiz->decimalpoints + 2);
511                     break;
512                 case 'number':
513                     $formattedvalue = $value + 0;
514                     break;
515                 default:
516                     $formattedvalue = $value;
517             }
519             $quizinfo[get_string($property, 'quiz_statistics',
520                     $this->using_attempts_string(!empty($quizstats->allattempts)))] =
521                     $formattedvalue;
522         }
524         return $quizinfo;
525     }
527     /**
528      * Output the table of overall quiz statistics.
529      * @param array $quizinfo as returned by {@link get_formatted_quiz_info_data()}.
530      * @return string the HTML.
531      */
532     protected function output_quiz_info_table($quizinfo) {
534         $quizinfotable = new html_table();
535         $quizinfotable->align = array('center', 'center');
536         $quizinfotable->width = '60%';
537         $quizinfotable->attributes['class'] = 'generaltable titlesleft';
538         $quizinfotable->data = array();
540         foreach ($quizinfo as $heading => $value) {
541              $quizinfotable->data[] = array($heading, $value);
542         }
544         return html_writer::table($quizinfotable);
545     }
547     /**
548      * Download the table of overall quiz statistics.
549      * @param array $quizinfo as returned by {@link get_formatted_quiz_info_data()}.
550      */
551     protected function download_quiz_info_table($quizinfo) {
552         global $OUTPUT;
554         // XHTML download is a special case.
555         if ($this->table->is_downloading() == 'xhtml') {
556             echo $OUTPUT->heading(get_string('quizinformation', 'quiz_statistics'));
557             echo $this->output_quiz_info_table($quizinfo);
558             return;
559         }
561         // Reformat the data ready for output.
562         $headers = array();
563         $row = array();
564         foreach ($quizinfo as $heading => $value) {
565             $headers[] = $heading;
566             $row[] = $value;
567         }
569         // Do the output.
570         $exportclass = $this->table->export_class_instance();
571         $exportclass->start_table(get_string('quizinformation', 'quiz_statistics'));
572         $exportclass->output_headers($headers);
573         $exportclass->add_data($row);
574         $exportclass->finish_table();
575     }
577     /**
578      * Output the HTML needed to show the statistics graph.
579      * @param int $quizstatsid the id of the statistics to show in the graph.
580      */
581     protected function output_statistics_graph($quizstatsid, $s) {
582         global $OUTPUT;
584         if ($s == 0) {
585             return;
586         }
588         $imageurl = new moodle_url('/mod/quiz/report/statistics/statistics_graph.php',
589                 array('id' => $quizstatsid));
590         $OUTPUT->heading(get_string('statisticsreportgraph', 'quiz_statistics'));
591         echo html_writer::tag('div', html_writer::empty_tag('img', array('src' => $imageurl,
592                 'alt' => get_string('statisticsreportgraph', 'quiz_statistics'))),
593                 array('class' => 'graph'));
594     }
596     /**
597      * Return the stats data for when there are no stats to show.
598      *
599      * @param array $questions question definitions.
600      * @param int $firstattemptscount number of first attempts (optional).
601      * @param int $firstattemptscount total number of attempts (optional).
602      * @return array with three elements:
603      *      - integer $s Number of attempts included in the stats (0).
604      *      - array $quizstats The statistics for overall attempt scores.
605      *      - array $qstats The statistics for each question.
606      */
607     protected function get_emtpy_stats($questions, $firstattemptscount = 0,
608             $allattemptscount = 0) {
609         $quizstats = new stdClass();
610         $quizstats->firstattemptscount = $firstattemptscount;
611         $quizstats->allattemptscount = $allattemptscount;
613         $qstats = new stdClass();
614         $qstats->questions = $questions;
615         $qstats->subquestions = array();
616         $qstats->responses = array();
618         return array(0, $quizstats, false);
619     }
621     /**
622      * Compute the quiz statistics.
623      *
624      * @param object $quizid the quiz id.
625      * @param int $currentgroup the current group. 0 for none.
626      * @param bool $nostudentsingroup true if there a no students.
627      * @param bool $useallattempts use all attempts, or just first attempts.
628      * @param array $groupstudents students in this group.
629      * @param array $questions question definitions.
630      * @return array with three elements:
631      *      - integer $s Number of attempts included in the stats.
632      *      - array $quizstats The statistics for overall attempt scores.
633      *      - array $qstats The statistics for each question.
634      */
635     protected function compute_stats($quizid, $currentgroup, $nostudentsingroup,
636             $useallattempts, $groupstudents, $questions) {
637         global $DB;
639         // Calculating MEAN of marks for all attempts by students
640         // http://docs.moodle.org/dev/Quiz_item_analysis_calculations_in_practise
641         //     #Calculating_MEAN_of_grades_for_all_attempts_by_students.
642         if ($nostudentsingroup) {
643             return $this->get_emtpy_stats($questions);
644         }
646         list($fromqa, $whereqa, $qaparams) = quiz_statistics_attempts_sql(
647                 $quizid, $currentgroup, $groupstudents, true);
649         $attempttotals = $DB->get_records_sql("
650                 SELECT
651                     CASE WHEN attempt = 1 THEN 1 ELSE 0 END AS isfirst,
652                     COUNT(1) AS countrecs,
653                     SUM(sumgrades) AS total
654                 FROM $fromqa
655                 WHERE $whereqa
656                 GROUP BY CASE WHEN attempt = 1 THEN 1 ELSE 0 END", $qaparams);
658         if (!$attempttotals) {
659             return $this->get_emtpy_stats($questions);
660         }
662         if (isset($attempttotals[1])) {
663             $firstattempts = $attempttotals[1];
664             $firstattempts->average = $firstattempts->total / $firstattempts->countrecs;
665         } else {
666             $firstattempts = new stdClass();
667             $firstattempts->countrecs = 0;
668             $firstattempts->total = 0;
669             $firstattempts->average = null;
670         }
672         $allattempts = new stdClass();
673         if (isset($attempttotals[0])) {
674             $allattempts->countrecs = $firstattempts->countrecs + $attempttotals[0]->countrecs;
675             $allattempts->total = $firstattempts->total + $attempttotals[0]->total;
676         } else {
677             $allattempts->countrecs = $firstattempts->countrecs;
678             $allattempts->total = $firstattempts->total;
679         }
681         if ($useallattempts) {
682             $usingattempts = $allattempts;
683             $usingattempts->sql = '';
684         } else {
685             $usingattempts = $firstattempts;
686             $usingattempts->sql = 'AND quiza.attempt = 1 ';
687         }
689         $s = $usingattempts->countrecs;
690         if ($s == 0) {
691             return $this->get_emtpy_stats($questions, $firstattempts->countrecs,
692                     $allattempts->countrecs);
693         }
694         $summarksavg = $usingattempts->total / $usingattempts->countrecs;
696         $quizstats = new stdClass();
697         $quizstats->allattempts = $useallattempts;
698         $quizstats->firstattemptscount = $firstattempts->countrecs;
699         $quizstats->allattemptscount = $allattempts->countrecs;
700         $quizstats->firstattemptsavg = $firstattempts->average;
701         $quizstats->allattemptsavg = $allattempts->total / $allattempts->countrecs;
703         // Recalculate sql again this time possibly including test for first attempt.
704         list($fromqa, $whereqa, $qaparams) = quiz_statistics_attempts_sql(
705                 $quizid, $currentgroup, $groupstudents, $useallattempts);
707         // Median ...
708         if ($s % 2 == 0) {
709             // An even number of attempts.
710             $limitoffset = $s/2 - 1;
711             $limit = 2;
712         } else {
713             $limitoffset = floor($s/2);
714             $limit = 1;
715         }
716         $sql = "SELECT id, sumgrades
717                 FROM $fromqa
718                 WHERE $whereqa
719                 ORDER BY sumgrades";
721         $medianmarks = $DB->get_records_sql_menu($sql, $qaparams, $limitoffset, $limit);
723         $quizstats->median = array_sum($medianmarks) / count($medianmarks);
724         if ($s > 1) {
725             // Fetch the sum of squared, cubed and power 4d
726             // differences between marks and mean mark.
727             $mean = $usingattempts->total / $s;
728             $sql = "SELECT
729                     SUM(POWER((quiza.sumgrades - $mean), 2)) AS power2,
730                     SUM(POWER((quiza.sumgrades - $mean), 3)) AS power3,
731                     SUM(POWER((quiza.sumgrades - $mean), 4)) AS power4
732                     FROM $fromqa
733                     WHERE $whereqa";
734             $params = array('mean1' => $mean, 'mean2' => $mean, 'mean3' => $mean)+$qaparams;
736             $powers = $DB->get_record_sql($sql, $params, MUST_EXIST);
738             // Standard_Deviation:
739             // see http://docs.moodle.org/dev/Quiz_item_analysis_calculations_in_practise
740             //         #Standard_Deviation.
742             $quizstats->standarddeviation = sqrt($powers->power2 / ($s - 1));
744             // Skewness.
745             if ($s > 2) {
746                 // See http://docs.moodle.org/dev/
747                 //      Quiz_item_analysis_calculations_in_practise#Skewness_and_Kurtosis.
748                 $m2= $powers->power2 / $s;
749                 $m3= $powers->power3 / $s;
750                 $m4= $powers->power4 / $s;
752                 $k2= $s*$m2/($s-1);
753                 $k3= $s*$s*$m3/(($s-1)*($s-2));
754                 if ($k2) {
755                     $quizstats->skewness = $k3 / (pow($k2, 3/2));
756                 }
757             }
759             // Kurtosis.
760             if ($s > 3) {
761                 $k4= $s*$s*((($s+1)*$m4)-(3*($s-1)*$m2*$m2))/(($s-1)*($s-2)*($s-3));
762                 if ($k2) {
763                     $quizstats->kurtosis = $k4 / ($k2*$k2);
764                 }
765             }
766         }
768         $qstats = new quiz_statistics_question_stats($questions, $s, $summarksavg);
769         $qstats->load_step_data($quizid, $currentgroup, $groupstudents, $useallattempts);
770         $qstats->compute_statistics();
772         if ($s > 1) {
773             $p = count($qstats->questions); // Number of positions.
774             if ($p > 1 && isset($k2)) {
775                 $quizstats->cic = (100 * $p / ($p -1)) *
776                         (1 - ($qstats->get_sum_of_mark_variance()) / $k2);
777                 $quizstats->errorratio = 100 * sqrt(1 - ($quizstats->cic / 100));
778                 $quizstats->standarderror = $quizstats->errorratio *
779                         $quizstats->standarddeviation / 100;
780             }
781         }
783         return array($s, $quizstats, $qstats);
784     }
786     /**
787      * Load the cached statistics from the database.
788      *
789      * @param object $quiz the quiz settings
790      * @param int $currentgroup the current group. 0 for none.
791      * @param bool $nostudentsingroup true if there a no students.
792      * @param bool $useallattempts use all attempts, or just first attempts.
793      * @param array $groupstudents students in this group.
794      * @param array $questions question definitions.
795      * @return array with 4 elements:
796      *     - $quizstats The statistics for overall attempt scores.
797      *     - $questions The questions, with an additional _stats field.
798      *     - $subquestions The subquestions, if any, with an additional _stats field.
799      *     - $s Number of attempts included in the stats.
800      * If there is no cached data in the database, returns an array of four nulls.
801      */
802     protected function try_loading_cached_stats($quiz, $currentgroup,
803             $nostudentsingroup, $useallattempts, $groupstudents, $questions) {
804         global $DB;
806         $timemodified = time() - self::TIME_TO_CACHE_STATS;
807         $quizstats = $DB->get_record_select('quiz_statistics',
808                 'quizid = ? AND groupid = ? AND allattempts = ? AND timemodified > ?',
809                 array($quiz->id, $currentgroup, $useallattempts, $timemodified));
811         if (!$quizstats) {
812             // No cached data found.
813             return array(null, $questions, null, null);
814         }
816         if ($useallattempts) {
817             $s = $quizstats->allattemptscount;
818         } else {
819             $s = $quizstats->firstattemptscount;
820         }
822         $subquestions = array();
823         $questionstats = $DB->get_records('quiz_question_statistics',
824                 array('quizstatisticsid' => $quizstats->id));
826         $subquestionstats = array();
827         foreach ($questionstats as $stat) {
828             if ($stat->slot) {
829                 $questions[$stat->slot]->_stats = $stat;
830             } else {
831                 $subquestionstats[$stat->questionid] = $stat;
832             }
833         }
835         if (!empty($subquestionstats)) {
836             $subqstofetch = array_keys($subquestionstats);
837             $subquestions = question_load_questions($subqstofetch);
838             foreach ($subquestions as $subqid => $subq) {
839                 $subquestions[$subqid]->_stats = $subquestionstats[$subqid];
840                 $subquestions[$subqid]->maxmark = $subq->defaultmark;
841             }
842         }
844         return array($quizstats, $questions, $subquestions, $s);
845     }
847     /**
848      * Store the statistics in the cache tables in the database.
849      *
850      * @param object $quizid the quiz id.
851      * @param int $currentgroup the current group. 0 for none.
852      * @param bool $useallattempts use all attempts, or just first attempts.
853      * @param object $quizstats The statistics for overall attempt scores.
854      * @param array $questions The questions, with an additional _stats field.
855      * @param array $subquestions The subquestions, if any, with an additional _stats field.
856      */
857     protected function cache_stats($quizid, $currentgroup,
858             $quizstats, $questions, $subquestions) {
859         global $DB;
861         $toinsert = clone($quizstats);
862         $toinsert->quizid = $quizid;
863         $toinsert->groupid = $currentgroup;
864         $toinsert->timemodified = time();
866         // Fix up some dodgy data.
867         if (isset($toinsert->errorratio) && is_nan($toinsert->errorratio)) {
868             $toinsert->errorratio = null;
869         }
870         if (isset($toinsert->standarderror) && is_nan($toinsert->standarderror)) {
871             $toinsert->standarderror = null;
872         }
874         // Store the data.
875         $quizstats->id = $DB->insert_record('quiz_statistics', $toinsert);
877         foreach ($questions as $question) {
878             $question->_stats->quizstatisticsid = $quizstats->id;
879             $DB->insert_record('quiz_question_statistics', $question->_stats, false);
880         }
882         foreach ($subquestions as $subquestion) {
883             $subquestion->_stats->quizstatisticsid = $quizstats->id;
884             $DB->insert_record('quiz_question_statistics', $subquestion->_stats, false);
885         }
887         return $quizstats->id;
888     }
890     /**
891      * Get the quiz and question statistics, either by loading the cached results,
892      * or by recomputing them.
893      *
894      * @param object $quiz the quiz settings.
895      * @param int $currentgroup the current group. 0 for none.
896      * @param bool $nostudentsingroup true if there a no students.
897      * @param bool $useallattempts use all attempts, or just first attempts.
898      * @param array $groupstudents students in this group.
899      * @param array $questions question definitions.
900      * @return array with 4 elements:
901      *     - $quizstats The statistics for overall attempt scores.
902      *     - $questions The questions, with an additional _stats field.
903      *     - $subquestions The subquestions, if any, with an additional _stats field.
904      *     - $s Number of attempts included in the stats.
905      */
906     protected function get_quiz_and_questions_stats($quiz, $currentgroup,
907             $nostudentsingroup, $useallattempts, $groupstudents, $questions) {
909         list($quizstats, $questions, $subquestions, $s) =
910                 $this->try_loading_cached_stats($quiz, $currentgroup, $nostudentsingroup,
911                         $useallattempts, $groupstudents, $questions);
913         if (is_null($quizstats)) {
914             list($s, $quizstats, $qstats) = $this->compute_stats($quiz->id,
915                     $currentgroup, $nostudentsingroup, $useallattempts, $groupstudents, $questions);
917             if ($s) {
918                 $questions = $qstats->questions;
919                 $subquestions = $qstats->subquestions;
921                 $quizstatisticsid = $this->cache_stats($quiz->id, $currentgroup,
922                         $quizstats, $questions, $subquestions);
924                 $this->analyse_responses($quizstatisticsid, $quiz->id, $currentgroup,
925                         $nostudentsingroup, $useallattempts, $groupstudents,
926                         $questions, $subquestions);
927             }
928         }
930         return array($quizstats, $questions, $subquestions, $s);
931     }
933     protected function analyse_responses($quizstatisticsid, $quizid, $currentgroup,
934             $nostudentsingroup, $useallattempts, $groupstudents, $questions, $subquestions) {
936         $qubaids = quiz_statistics_qubaids_condition(
937                 $quizid, $currentgroup, $groupstudents, $useallattempts);
939         $done = array();
940         foreach ($questions as $question) {
941             if (!question_bank::get_qtype($question->qtype, false)->can_analyse_responses()) {
942                 continue;
943             }
944             $done[$question->id] = 1;
946             $responesstats = new quiz_statistics_response_analyser($question);
947             $responesstats->analyse($qubaids);
948             $responesstats->store_cached($quizstatisticsid);
949         }
951         foreach ($subquestions as $question) {
952             if (!question_bank::get_qtype($question->qtype, false)->can_analyse_responses() ||
953                     isset($done[$question->id])) {
954                 continue;
955             }
956             $done[$question->id] = 1;
958             $responesstats = new quiz_statistics_response_analyser($question);
959             $responesstats->analyse($qubaids);
960             $responesstats->store_cached($quizstatisticsid);
961         }
962     }
964     /**
965      * @return string HTML snipped for the Download full report as UI.
966      */
967     protected function everything_download_options() {
968         $downloadoptions = $this->table->get_download_menu();
970         $output = '<form action="'. $this->table->baseurl .'" method="post">';
971         $output .= '<div class="mdl-align">';
972         $output .= '<input type="hidden" name="everything" value="1"/>';
973         $output .= '<input type="submit" value="' .
974                 get_string('downloadeverything', 'quiz_statistics') . '"/>';
975         $output .= html_writer::select($downloadoptions, 'download',
976                 $this->table->defaultdownloadformat, false);
977         $output .= '</div></form>';
979         return $output;
980     }
982     /**
983      * Generate the snipped of HTML that says when the stats were last caculated,
984      * with a recalcuate now button.
985      * @param object $quizstats the overall quiz statistics.
986      * @param int $quizid the quiz id.
987      * @param int $currentgroup the id of the currently selected group, or 0.
988      * @param array $groupstudents ids of students in the group.
989      * @param bool $useallattempts whether to use all attempts, instead of just
990      *      first attempts.
991      * @return string a HTML snipped saying when the stats were last computed,
992      *      or blank if that is not appropriate.
993      */
994     protected function output_caching_info($quizstats, $quizid, $currentgroup,
995             $groupstudents, $useallattempts, $reporturl) {
996         global $DB, $OUTPUT;
998         if (empty($quizstats->timemodified)) {
999             return '';
1000         }
1002         // Find the number of attempts since the cached statistics were computed.
1003         list($fromqa, $whereqa, $qaparams) = quiz_statistics_attempts_sql(
1004                 $quizid, $currentgroup, $groupstudents, $useallattempts, true);
1005         $count = $DB->count_records_sql("
1006                 SELECT COUNT(1)
1007                 FROM $fromqa
1008                 WHERE $whereqa
1009                 AND quiza.timefinish > {$quizstats->timemodified}", $qaparams);
1011         if (!$count) {
1012             $count = 0;
1013         }
1015         // Generate the output.
1016         $a = new stdClass();
1017         $a->lastcalculated = format_time(time() - $quizstats->timemodified);
1018         $a->count = $count;
1020         $recalcualteurl = new moodle_url($reporturl,
1021                 array('recalculate' => 1, 'sesskey' => sesskey()));
1022         $output = '';
1023         $output .= $OUTPUT->box_start(
1024                 'boxaligncenter generalbox boxwidthnormal mdl-align', 'cachingnotice');
1025         $output .= get_string('lastcalculated', 'quiz_statistics', $a);
1026         $output .= $OUTPUT->single_button($recalcualteurl,
1027                 get_string('recalculatenow', 'quiz_statistics'));
1028         $output .= $OUTPUT->box_end(true);
1030         return $output;
1031     }
1033     /**
1034      * Clear the cached data for a particular report configuration. This will
1035      * trigger a re-computation the next time the report is displayed.
1036      * @param int $quizid the quiz id.
1037      * @param int $currentgroup a group id, or 0.
1038      * @param bool $useallattempts whether all attempts, or just first attempts are included.
1039      */
1040     protected function clear_cached_data($quizid, $currentgroup, $useallattempts) {
1041         global $DB;
1043         $todelete = $DB->get_records_menu('quiz_statistics', array('quizid' => $quizid,
1044                 'groupid' => $currentgroup, 'allattempts' => $useallattempts), '', 'id, 1');
1046         if (!$todelete) {
1047             return;
1048         }
1050         list($todeletesql, $todeleteparams) = $DB->get_in_or_equal(array_keys($todelete));
1052         $DB->delete_records_select('quiz_question_statistics',
1053                 'quizstatisticsid ' . $todeletesql, $todeleteparams);
1054         $DB->delete_records_select('quiz_question_response_stats',
1055                 'quizstatisticsid ' . $todeletesql, $todeleteparams);
1056         $DB->delete_records_select('quiz_statistics',
1057                 'id ' . $todeletesql, $todeleteparams);
1058     }
1060     /**
1061      * @param bool $useallattempts whether we are using all attempts.
1062      * @return the appropriate lang string to describe this option.
1063      */
1064     protected function using_attempts_string($useallattempts) {
1065         if ($useallattempts) {
1066             return get_string('allattempts', 'quiz_statistics');
1067         } else {
1068             return get_string('firstattempts', 'quiz_statistics');
1069         }
1070     }
1073 function quiz_statistics_attempts_sql($quizid, $currentgroup, $groupstudents,
1074         $allattempts = true, $includeungraded = false) {
1075     global $DB;
1077     $fromqa = '{quiz_attempts} quiza ';
1079     $whereqa = 'quiza.quiz = :quizid AND quiza.preview = 0 AND quiza.state = :quizstatefinished';
1080     $qaparams = array('quizid' => $quizid, 'quizstatefinished' => quiz_attempt::FINISHED);
1082     if (!empty($currentgroup) && $groupstudents) {
1083         list($grpsql, $grpparams) = $DB->get_in_or_equal(array_keys($groupstudents),
1084                 SQL_PARAMS_NAMED, 'u');
1085         $whereqa .= " AND quiza.userid $grpsql";
1086         $qaparams += $grpparams;
1087     }
1089     if (!$allattempts) {
1090         $whereqa .= ' AND quiza.attempt = 1';
1091     }
1093     if (!$includeungraded) {
1094         $whereqa .= ' AND quiza.sumgrades IS NOT NULL';
1095     }
1097     return array($fromqa, $whereqa, $qaparams);
1100 /**
1101  * Return a {@link qubaid_condition} from the values returned by
1102  * {@link quiz_statistics_attempts_sql}
1103  * @param string $fromqa from quiz_statistics_attempts_sql.
1104  * @param string $whereqa from quiz_statistics_attempts_sql.
1105  */
1106 function quiz_statistics_qubaids_condition($quizid, $currentgroup, $groupstudents,
1107         $allattempts = true, $includeungraded = false) {
1108     list($fromqa, $whereqa, $qaparams) = quiz_statistics_attempts_sql($quizid, $currentgroup,
1109             $groupstudents, $allattempts, $includeungraded);
1110     return new qubaid_join($fromqa, 'quiza.uniqueid', $whereqa, $qaparams);