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