MDL-30592 question_bank: make files in qtext work in the question list.
[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         $groupmode = groups_get_activity_groupmode($cm);
89         $currentgroup = groups_get_activity_group($cm, true);
90         $nostudentsingroup = false; // True if a group is selected and there is no one in it.
91         if (empty($currentgroup)) {
92             $currentgroup = 0;
93             $groupstudents = array();
95         } else {
96             // All users who can attempt quizzes and who are in the currently selected group
97             $groupstudents = get_users_by_capability($this->context,
98                     array('mod/quiz:reviewmyattempts', 'mod/quiz:attempt'),
99                     '', '', '', '', $currentgroup, '', false);
100             if (!$groupstudents) {
101                 $nostudentsingroup = true;
102             }
103         }
105         // If recalculate was requested, handle that.
106         if ($recalculate && confirm_sesskey()) {
107             $this->clear_cached_data($quiz->id, $currentgroup, $useallattempts);
108             redirect($reporturl);
109         }
111         // Set up the main table.
112         $this->table = new quiz_report_statistics_table();
113         if ($everything) {
114             $report = get_string('completestatsfilename', 'quiz_statistics');
115         } else {
116             $report = get_string('questionstatsfilename', 'quiz_statistics');
117         }
118         $coursecontext = get_context_instance(CONTEXT_COURSE, $course->id);
119         $courseshortname = format_string($course->shortname, true,
120                 array('context' => $coursecontext));
121         $filename = quiz_report_download_filename($report, $courseshortname, $quiz->name);
122         $this->table->is_downloading($download, $filename,
123                 get_string('quizstructureanalysis', 'quiz_statistics'));
125         // Load the questions.
126         $questions = quiz_report_get_significant_questions($quiz);
127         $questionids = array();
128         foreach ($questions as $question) {
129             $questionids[] = $question->id;
130         }
131         $fullquestions = question_load_questions($questionids);
132         foreach ($questions as $qno => $question) {
133             $q = $fullquestions[$question->id];
134             $q->maxmark = $question->maxmark;
135             $q->slot = $qno;
136             $q->number = $question->number;
137             $questions[$qno] = $q;
138         }
140         // Get the data to be displayed.
141         list($quizstats, $questions, $subquestions, $s) =
142                 $this->get_quiz_and_questions_stats($quiz, $currentgroup,
143                         $nostudentsingroup, $useallattempts, $groupstudents, $questions);
144         $quizinfo = $this->get_formatted_quiz_info_data($course, $cm, $quiz, $quizstats);
146         // Set up the table, if there is data.
147         if ($s) {
148             $this->table->setup($quiz, $cm->id, $reporturl, $s);
149         }
151         // Print the page header stuff (if not downloading.
152         if (!$this->table->is_downloading()) {
153             $this->print_header_and_tabs($cm, $course, $quiz, 'statistics');
155             if ($groupmode) {
156                 groups_print_activity_menu($cm, $reporturl->out());
157                 if ($currentgroup && !$groupstudents) {
158                     $OUTPUT->notification(get_string('nostudentsingroup', 'quiz_statistics'));
159                 }
160             }
162             if (!quiz_questions_in_quiz($quiz->questions)) {
163                 echo quiz_no_questions_message($quiz, $cm, $this->context);
164             } else if (!$this->table->is_downloading() && $s == 0) {
165                 echo $OUTPUT->notification(get_string('noattempts', 'quiz'));
166             }
168             // Print display options form.
169             $mform->set_data(array('useallattempts' => $useallattempts));
170             $mform->display();
171         }
173         if ($everything) { // Implies is downloading.
174             // Overall report, then the analysis of each question.
175             $this->download_quiz_info_table($quizinfo);
177             if ($s) {
178                 $this->output_quiz_structure_analysis_table($s, $questions, $subquestions);
180                 if ($this->table->is_downloading() == 'xhtml') {
181                     $this->output_statistics_graph($quizstats->id, $s);
182                 }
184                 foreach ($questions as $question) {
185                     if (question_bank::get_qtype(
186                             $question->qtype, false)->can_analyse_responses()) {
187                         $this->output_individual_question_response_analysis(
188                                 $question, $reporturl, $quizstats);
190                     } else if (!empty($question->_stats->subquestions)) {
191                         $subitemstodisplay = explode(',', $question->_stats->subquestions);
192                         foreach ($subitemstodisplay as $subitemid) {
193                             $this->output_individual_question_response_analysis(
194                                     $subquestions[$subitemid], $reporturl, $quizstats);
195                         }
196                     }
197                 }
198             }
200             $this->table->export_class_instance()->finish_document();
202         } else if ($slot) {
203             // Report on an individual question indexed by position.
204             if (!isset($questions[$slot])) {
205                 print_error('questiondoesnotexist', 'question');
206             }
208             $this->output_individual_question_data($quiz, $questions[$slot]);
209             $this->output_individual_question_response_analysis(
210                     $questions[$slot], $reporturl, $quizstats);
212             // Back to overview link.
213             echo $OUTPUT->box('<a href="' . $reporturl->out() . '">' .
214                     get_string('backtoquizreport', 'quiz_statistics') . '</a>',
215                     'backtomainstats boxaligncenter generalbox boxwidthnormal mdl-align');
217         } else if ($qid) {
218             // Report on an individual sub-question indexed questionid.
219             if (!isset($subquestions[$qid])) {
220                 print_error('questiondoesnotexist', 'question');
221             }
223             $this->output_individual_question_data($quiz, $subquestions[$qid]);
224             $this->output_individual_question_response_analysis(
225                     $subquestions[$qid], $reporturl, $quizstats);
227             // Back to overview link.
228             echo $OUTPUT->box('<a href="' . $reporturl->out() . '">' .
229                     get_string('backtoquizreport', 'quiz_statistics') . '</a>',
230                     'boxaligncenter generalbox boxwidthnormal mdl-align');
232         } else if ($this->table->is_downloading()) {
233             // Downloading overview report.
234             $this->download_quiz_info_table($quizinfo);
235             $this->output_quiz_structure_analysis_table($s, $questions, $subquestions);
236             $this->table->finish_output();
238         } else {
239             // On-screen display of overview report.
240             echo $OUTPUT->heading(get_string('quizinformation', 'quiz_statistics'));
241             echo $this->output_caching_info($quizstats, $quiz->id, $currentgroup,
242                     $groupstudents, $useallattempts, $reporturl);
243             echo $this->everything_download_options();
244             echo $this->output_quiz_info_table($quizinfo);
245             if ($s) {
246                 echo $OUTPUT->heading(get_string('quizstructureanalysis', 'quiz_statistics'));
247                 $this->output_quiz_structure_analysis_table($s, $questions, $subquestions);
248                 $this->output_statistics_graph($quizstats->id, $s);
249             }
250         }
252         return true;
253     }
255     /**
256      * Display the statistical and introductory information about a question.
257      * Only called when not downloading.
258      * @param object $quiz the quiz settings.
259      * @param object $question the question to report on.
260      * @param moodle_url $reporturl the URL to resisplay this report.
261      * @param object $quizstats Holds the quiz statistics.
262      */
263     protected function output_individual_question_data($quiz, $question) {
264         global $OUTPUT;
266         // On-screen display. Show a summary of the question's place in the quiz,
267         // and the question statistics.
268         $datumfromtable = $this->table->format_row($question);
270         // Set up the question info table.
271         $questioninfotable = new html_table();
272         $questioninfotable->align = array('center', 'center');
273         $questioninfotable->width = '60%';
274         $questioninfotable->attributes['class'] = 'generaltable titlesleft';
276         $questioninfotable->data = array();
277         $questioninfotable->data[] = array(get_string('modulename', 'quiz'), $quiz->name);
278         $questioninfotable->data[] = array(get_string('questionname', 'quiz_statistics'),
279                 $question->name.'&nbsp;'.$datumfromtable['actions']);
280         $questioninfotable->data[] = array(get_string('questiontype', 'quiz_statistics'),
281                 $datumfromtable['icon'] . '&nbsp;' .
282                 question_bank::get_qtype($question->qtype, false)->menu_name() . '&nbsp;' .
283                 $datumfromtable['icon']);
284         $questioninfotable->data[] = array(get_string('positions', 'quiz_statistics'),
285                 $question->_stats->positions);
287         // Set up the question statistics table.
288         $questionstatstable = new html_table();
289         $questionstatstable->align = array('center', 'center');
290         $questionstatstable->width = '60%';
291         $questionstatstable->attributes['class'] = 'generaltable titlesleft';
293         unset($datumfromtable['number']);
294         unset($datumfromtable['icon']);
295         $actions = $datumfromtable['actions'];
296         unset($datumfromtable['actions']);
297         unset($datumfromtable['name']);
298         $labels = array(
299             's' => get_string('attempts', 'quiz_statistics'),
300             'facility' => get_string('facility', 'quiz_statistics'),
301             'sd' => get_string('standarddeviationq', 'quiz_statistics'),
302             'random_guess_score' => get_string('random_guess_score', 'quiz_statistics'),
303             'intended_weight' => get_string('intended_weight', 'quiz_statistics'),
304             'effective_weight' => get_string('effective_weight', 'quiz_statistics'),
305             'discrimination_index' => get_string('discrimination_index', 'quiz_statistics'),
306             'discriminative_efficiency' =>
307                                 get_string('discriminative_efficiency', 'quiz_statistics')
308         );
309         foreach ($datumfromtable as $item => $value) {
310             $questionstatstable->data[] = array($labels[$item], $value);
311         }
313         // Display the various bits.
314         echo $OUTPUT->heading(get_string('questioninformation', 'quiz_statistics'));
315         echo html_writer::table($questioninfotable);
316         echo $this->render_question_text($question);
317         echo $OUTPUT->heading(get_string('questionstatistics', 'quiz_statistics'));
318         echo html_writer::table($questionstatstable);
319     }
320     public function format_text($text, $format, $qa, $component, $filearea, $itemid,
321             $clean = false) {
322         $formatoptions = new stdClass();
323         $formatoptions->noclean = !$clean;
324         $formatoptions->para = false;
325         $text = $qa->rewrite_pluginfile_urls($text, $component, $filearea, $itemid);
326         return format_text($text, $format, $formatoptions);
327     }
329     /** @return the result of applying {@link format_text()} to the question text. */
330     public function format_questiontext($qa) {
331         return $this->format_text($this->questiontext, $this->questiontextformat,
332         $qa, 'question', 'questiontext', $this->id);
333     }
335     /**
336      * @param object $question question data.
337      * @return string HTML of question text, ready for display.
338      */
339     protected function render_question_text($question) {
340         global $OUTPUT;
342         $text = question_rewrite_questiontext_preview_urls($question->questiontext,
343                 $this->context->id, 'quiz_statistics', $question->id);
345         return $OUTPUT->box(format_text($text, $question->questiontextformat,
346                 array('noclean' => true, 'para' => false, 'overflowdiv' => true)),
347                 'questiontext boxaligncenter generalbox boxwidthnormal mdl-align');
348     }
350     /**
351      * Display the response analysis for a question.
352      * @param object $question the question to report on.
353      * @param moodle_url $reporturl the URL to resisplay this report.
354      * @param object $quizstats Holds the quiz statistics.
355      */
356     protected function output_individual_question_response_analysis($question,
357             $reporturl, $quizstats) {
358         global $OUTPUT;
360         if (!question_bank::get_qtype($question->qtype, false)->can_analyse_responses()) {
361             return;
362         }
364         $qtable = new quiz_report_statistics_question_table($question->id);
365         $exportclass = $this->table->export_class_instance();
366         $qtable->export_class_instance($exportclass);
367         if (!$this->table->is_downloading()) {
368             // Output an appropriate title.
369             echo $OUTPUT->heading(get_string('analysisofresponses', 'quiz_statistics'));
371         } else {
372             // Work out an appropriate title.
373             $questiontabletitle = '"' . $question->name . '"';
374             if (!empty($question->number)) {
375                 $questiontabletitle = '(' . $question->number . ') ' . $questiontabletitle;
376             }
377             if ($this->table->is_downloading() == 'xhtml') {
378                 $questiontabletitle = get_string('analysisofresponsesfor',
379                         'quiz_statistics', $questiontabletitle);
380             }
382             // Set up the table.
383             $exportclass->start_table($questiontabletitle);
385             if ($this->table->is_downloading() == 'xhtml') {
386                 echo $this->render_question_text($question);
387             }
388         }
390         $responesstats = new quiz_statistics_response_analyser($question);
391         $responesstats->load_cached($quizstats->id);
393         $qtable->setup($reporturl, $question, $responesstats);
394         if ($this->table->is_downloading()) {
395             $exportclass->output_headers($qtable->headers);
396         }
398         foreach ($responesstats->responseclasses as $partid => $partclasses) {
399             $rowdata = new stdClass();
400             $rowdata->part = $partid;
401             foreach ($partclasses as $responseclassid => $responseclass) {
402                 $rowdata->responseclass = $responseclass->responseclass;
404                 $responsesdata = $responesstats->responses[$partid][$responseclassid];
405                 if (empty($responsesdata)) {
406                     if (!array_key_exists('responseclass', $qtable->columns)) {
407                         $rowdata->response = $responseclass->responseclass;
408                     } else {
409                         $rowdata->response = '';
410                     }
411                     $rowdata->fraction = $responseclass->fraction;
412                     $rowdata->count = 0;
413                     $qtable->add_data_keyed($qtable->format_row($rowdata));
414                     continue;
415                 }
417                 foreach ($responsesdata as $response => $data) {
418                     $rowdata->response = $response;
419                     $rowdata->fraction = $data->fraction;
420                     $rowdata->count = $data->count;
421                     $qtable->add_data_keyed($qtable->format_row($rowdata));
422                 }
423             }
424         }
426         $qtable->finish_output(!$this->table->is_downloading());
427     }
429     /**
430      * Output the table that lists all the questions in the quiz with their statistics.
431      * @param int $s number of attempts.
432      * @param array $questions the questions in the quiz.
433      * @param array $subquestions the subquestions of any random questions.
434      */
435     protected function output_quiz_structure_analysis_table($s, $questions, $subquestions) {
436         if (!$s) {
437             return;
438         }
440         foreach ($questions as $question) {
441             // Output the data for this questions.
442             $this->table->add_data_keyed($this->table->format_row($question));
444             if (empty($question->_stats->subquestions)) {
445                 continue;
446             }
448             // And its subquestions, if it has any.
449             $subitemstodisplay = explode(',', $question->_stats->subquestions);
450             foreach ($subitemstodisplay as $subitemid) {
451                 $subquestions[$subitemid]->maxmark = $question->maxmark;
452                 $this->table->add_data_keyed($this->table->format_row($subquestions[$subitemid]));
453             }
454         }
456         $this->table->finish_output(!$this->table->is_downloading());
457     }
459     protected function get_formatted_quiz_info_data($course, $cm, $quiz, $quizstats) {
461         // You can edit this array to control which statistics are displayed.
462         $todisplay = array('firstattemptscount' => 'number',
463                     'allattemptscount' => 'number',
464                     'firstattemptsavg' => 'summarks_as_percentage',
465                     'allattemptsavg' => 'summarks_as_percentage',
466                     'median' => 'summarks_as_percentage',
467                     'standarddeviation' => 'summarks_as_percentage',
468                     'skewness' => 'number_format',
469                     'kurtosis' => 'number_format',
470                     'cic' => 'number_format_percent',
471                     'errorratio' => 'number_format_percent',
472                     'standarderror' => 'summarks_as_percentage');
474         // General information about the quiz.
475         $quizinfo = array();
476         $quizinfo[get_string('quizname', 'quiz_statistics')] = format_string($quiz->name);
477         $quizinfo[get_string('coursename', 'quiz_statistics')] = format_string($course->fullname);
478         if ($cm->idnumber) {
479             $quizinfo[get_string('idnumbermod')] = $cm->idnumber;
480         }
481         if ($quiz->timeopen) {
482             $quizinfo[get_string('quizopen', 'quiz')] = userdate($quiz->timeopen);
483         }
484         if ($quiz->timeclose) {
485             $quizinfo[get_string('quizclose', 'quiz')] = userdate($quiz->timeclose);
486         }
487         if ($quiz->timeopen && $quiz->timeclose) {
488             $quizinfo[get_string('duration', 'quiz_statistics')] =
489                     format_time($quiz->timeclose - $quiz->timeopen);
490         }
492         // The statistics.
493         foreach ($todisplay as $property => $format) {
494             if (!isset($quizstats->$property) || empty($format[$property])) {
495                 continue;
496             }
497             $value = $quizstats->$property;
499             switch ($format) {
500                 case 'summarks_as_percentage':
501                     $formattedvalue = quiz_report_scale_summarks_as_percentage($value, $quiz);
502                     break;
503                 case 'number_format_percent':
504                     $formattedvalue = quiz_format_grade($quiz, $value) . '%';
505                     break;
506                 case 'number_format':
507                     // + 2 decimal places, since not a percentage,
508                     // and we want the same number of sig figs.
509                     $formattedvalue = format_float($value, $quiz->decimalpoints + 2);
510                     break;
511                 case 'number':
512                     $formattedvalue = $value + 0;
513                     break;
514                 default:
515                     $formattedvalue = $value;
516             }
518             $quizinfo[get_string($property, 'quiz_statistics',
519                     $this->using_attempts_string(!empty($quizstats->allattempts)))] =
520                     $formattedvalue;
521         }
523         return $quizinfo;
524     }
526     /**
527      * Output the table of overall quiz statistics.
528      * @param array $quizinfo as returned by {@link get_formatted_quiz_info_data()}.
529      * @return string the HTML.
530      */
531     protected function output_quiz_info_table($quizinfo) {
533         $quizinfotable = new html_table();
534         $quizinfotable->align = array('center', 'center');
535         $quizinfotable->width = '60%';
536         $quizinfotable->attributes['class'] = 'generaltable titlesleft';
537         $quizinfotable->data = array();
539         foreach ($quizinfo as $heading => $value) {
540              $quizinfotable->data[] = array($heading, $value);
541         }
543         return html_writer::table($quizinfotable);
544     }
546     /**
547      * Download the table of overall quiz statistics.
548      * @param array $quizinfo as returned by {@link get_formatted_quiz_info_data()}.
549      */
550     protected function download_quiz_info_table($quizinfo) {
551         global $OUTPUT;
553         // XHTML download is a special case.
554         if ($this->table->is_downloading() == 'xhtml') {
555             echo $OUTPUT->heading(get_string('quizinformation', 'quiz_statistics'));
556             echo $this->output_quiz_info_table($quizinfo);
557             return;
558         }
560         // Reformat the data ready for output.
561         $headers = array();
562         $row = array();
563         foreach ($quizinfo as $heading => $value) {
564             $headers[] = $heading;
565             $row[] = $value;
566         }
568         // Do the output.
569         $exportclass = $this->table->export_class_instance();
570         $exportclass->start_table(get_string('quizinformation', 'quiz_statistics'));
571         $exportclass->output_headers($headers);
572         $exportclass->add_data($row);
573         $exportclass->finish_table();
574     }
576     /**
577      * Output the HTML needed to show the statistics graph.
578      * @param int $quizstatsid the id of the statistics to show in the graph.
579      */
580     protected function output_statistics_graph($quizstatsid, $s) {
581         global $OUTPUT;
583         if ($s == 0) {
584             return;
585         }
587         $imageurl = new moodle_url('/mod/quiz/report/statistics/statistics_graph.php',
588                 array('id' => $quizstatsid));
589         $OUTPUT->heading(get_string('statisticsreportgraph', 'quiz_statistics'));
590         echo html_writer::tag('div', html_writer::empty_tag('img', array('src' => $imageurl,
591                 'alt' => get_string('statisticsreportgraph', 'quiz_statistics'))),
592                 array('class' => 'graph'));
593     }
595     /**
596      * Return the stats data for when there are no stats to show.
597      *
598      * @param array $questions question definitions.
599      * @param int $firstattemptscount number of first attempts (optional).
600      * @param int $firstattemptscount total number of attempts (optional).
601      * @return array with three elements:
602      *      - integer $s Number of attempts included in the stats (0).
603      *      - array $quizstats The statistics for overall attempt scores.
604      *      - array $qstats The statistics for each question.
605      */
606     protected function get_emtpy_stats($questions, $firstattemptscount = 0,
607             $allattemptscount = 0) {
608         $quizstats = new stdClass();
609         $quizstats->firstattemptscount = $firstattemptscount;
610         $quizstats->allattemptscount = $allattemptscount;
612         $qstats = new stdClass();
613         $qstats->questions = $questions;
614         $qstats->subquestions = array();
615         $qstats->responses = array();
617         return array(0, $quizstats, false);
618     }
620     /**
621      * Compute the quiz statistics.
622      *
623      * @param object $quizid the quiz id.
624      * @param int $currentgroup the current group. 0 for none.
625      * @param bool $nostudentsingroup true if there a no students.
626      * @param bool $useallattempts use all attempts, or just first attempts.
627      * @param array $groupstudents students in this group.
628      * @param array $questions question definitions.
629      * @return array with three elements:
630      *      - integer $s Number of attempts included in the stats.
631      *      - array $quizstats The statistics for overall attempt scores.
632      *      - array $qstats The statistics for each question.
633      */
634     protected function compute_stats($quizid, $currentgroup, $nostudentsingroup,
635             $useallattempts, $groupstudents, $questions) {
636         global $DB;
638         // Calculating MEAN of marks for all attempts by students
639         // http://docs.moodle.org/dev/Quiz_item_analysis_calculations_in_practise
640         //        #Calculating_MEAN_of_grades_for_all_attempts_by_students
641         if ($nostudentsingroup) {
642             return $this->get_emtpy_stats($questions);
643         }
645         list($fromqa, $whereqa, $qaparams) = quiz_statistics_attempts_sql(
646                 $quizid, $currentgroup, $groupstudents, true);
648         $attempttotals = $DB->get_records_sql("
649                 SELECT
650                     CASE WHEN attempt = 1 THEN 1 ELSE 0 END AS isfirst,
651                     COUNT(1) AS countrecs,
652                     SUM(sumgrades) AS total
653                 FROM $fromqa
654                 WHERE $whereqa
655                 GROUP BY CASE WHEN attempt = 1 THEN 1 ELSE 0 END", $qaparams);
657         if (!$attempttotals) {
658             return $this->get_emtpy_stats($questions);
659         }
661         if (isset($attempttotals[1])) {
662             $firstattempts = $attempttotals[1];
663             $firstattempts->average = $firstattempts->total / $firstattempts->countrecs;
664         } else {
665             $firstattempts = new stdClass();
666             $firstattempts->countrecs = 0;
667             $firstattempts->total = 0;
668             $firstattempts->average = '-';
669         }
671         $allattempts = new stdClass();
672         if (isset($attempttotals[0])) {
673             $allattempts->countrecs = $firstattempts->countrecs + $attempttotals[0]->countrecs;
674             $allattempts->total = $firstattempts->total + $attempttotals[0]->total;
675         } else {
676             $allattempts->countrecs = $firstattempts->countrecs;
677             $allattempts->total = $firstattempts->total;
678         }
680         if ($useallattempts) {
681             $usingattempts = $allattempts;
682             $usingattempts->sql = '';
683         } else {
684             $usingattempts = $firstattempts;
685             $usingattempts->sql = 'AND quiza.attempt = 1 ';
686         }
688         $s = $usingattempts->countrecs;
689         if ($s == 0) {
690             return $this->get_emtpy_stats($questions, $firstattempts->countrecs,
691                     $allattempts->countrecs);
692         }
693         $summarksavg = $usingattempts->total / $usingattempts->countrecs;
695         $quizstats = new stdClass();
696         $quizstats->allattempts = $useallattempts;
697         $quizstats->firstattemptscount = $firstattempts->countrecs;
698         $quizstats->allattemptscount = $allattempts->countrecs;
699         $quizstats->firstattemptsavg = $firstattempts->average;
700         $quizstats->allattemptsavg = $allattempts->total / $allattempts->countrecs;
702         // Recalculate sql again this time possibly including test for first attempt.
703         list($fromqa, $whereqa, $qaparams) = quiz_statistics_attempts_sql(
704                 $quizid, $currentgroup, $groupstudents, $useallattempts);
706         // Median
707         if ($s % 2 == 0) {
708             //even number of attempts
709             $limitoffset = $s/2 - 1;
710             $limit = 2;
711         } else {
712             $limitoffset = floor($s/2);
713             $limit = 1;
714         }
715         $sql = "SELECT id, sumgrades
716                 FROM $fromqa
717                 WHERE $whereqa
718                 ORDER BY sumgrades";
720         $medianmarks = $DB->get_records_sql_menu($sql, $qaparams, $limitoffset, $limit);
722         $quizstats->median = array_sum($medianmarks) / count($medianmarks);
723         if ($s > 1) {
724             //fetch sum of squared, cubed and power 4d
725             //differences between marks and mean mark
726             $mean = $usingattempts->total / $s;
727             $sql = "SELECT
728                     SUM(POWER((quiza.sumgrades - $mean), 2)) AS power2,
729                     SUM(POWER((quiza.sumgrades - $mean), 3)) AS power3,
730                     SUM(POWER((quiza.sumgrades - $mean), 4)) AS power4
731                     FROM $fromqa
732                     WHERE $whereqa";
733             $params = array('mean1' => $mean, 'mean2' => $mean, 'mean3' => $mean)+$qaparams;
735             $powers = $DB->get_record_sql($sql, $params, MUST_EXIST);
737             // Standard_Deviation
738             // see http://docs.moodle.org/dev/Quiz_item_analysis_calculations_in_practise
739             //         #Standard_Deviation
741             $quizstats->standarddeviation = sqrt($powers->power2 / ($s - 1));
743             // Skewness
744             if ($s > 2) {
745                 // see http://docs.moodle.org/dev/
746                 //      Quiz_item_analysis_calculations_in_practise#Skewness_and_Kurtosis
747                 $m2= $powers->power2 / $s;
748                 $m3= $powers->power3 / $s;
749                 $m4= $powers->power4 / $s;
751                 $k2= $s*$m2/($s-1);
752                 $k3= $s*$s*$m3/(($s-1)*($s-2));
753                 if ($k2) {
754                     $quizstats->skewness = $k3 / (pow($k2, 3/2));
755                 }
756             }
758             // Kurtosis
759             if ($s > 3) {
760                 $k4= $s*$s*((($s+1)*$m4)-(3*($s-1)*$m2*$m2))/(($s-1)*($s-2)*($s-3));
761                 if ($k2) {
762                     $quizstats->kurtosis = $k4 / ($k2*$k2);
763                 }
764             }
765         }
767         $qstats = new quiz_statistics_question_stats($questions, $s, $summarksavg);
768         $qstats->load_step_data($quizid, $currentgroup, $groupstudents, $useallattempts);
769         $qstats->compute_statistics();
771         if ($s > 1) {
772             $p = count($qstats->questions); // No of positions
773             if ($p > 1 && isset($k2)) {
774                 $quizstats->cic = (100 * $p / ($p -1)) *
775                         (1 - ($qstats->get_sum_of_mark_variance()) / $k2);
776                 $quizstats->errorratio = 100 * sqrt(1 - ($quizstats->cic / 100));
777                 $quizstats->standarderror = $quizstats->errorratio *
778                         $quizstats->standarddeviation / 100;
779             }
780         }
782         return array($s, $quizstats, $qstats);
783     }
785     /**
786      * Load the cached statistics from the database.
787      *
788      * @param object $quiz the quiz settings
789      * @param int $currentgroup the current group. 0 for none.
790      * @param bool $nostudentsingroup true if there a no students.
791      * @param bool $useallattempts use all attempts, or just first attempts.
792      * @param array $groupstudents students in this group.
793      * @param array $questions question definitions.
794      * @return array with 4 elements:
795      *     - $quizstats The statistics for overall attempt scores.
796      *     - $questions The questions, with an additional _stats field.
797      *     - $subquestions The subquestions, if any, with an additional _stats field.
798      *     - $s Number of attempts included in the stats.
799      * If there is no cached data in the database, returns an array of four nulls.
800      */
801     protected function try_loading_cached_stats($quiz, $currentgroup,
802             $nostudentsingroup, $useallattempts, $groupstudents, $questions) {
803         global $DB;
805         $timemodified = time() - self::TIME_TO_CACHE_STATS;
806         $quizstats = $DB->get_record_select('quiz_statistics',
807                 'quizid = ? AND groupid = ? AND allattempts = ? AND timemodified > ?',
808                 array($quiz->id, $currentgroup, $useallattempts, $timemodified));
810         if (!$quizstats) {
811             // No cached data found.
812             return array(null, $questions, null, null);
813         }
815         if ($useallattempts) {
816             $s = $quizstats->allattemptscount;
817         } else {
818             $s = $quizstats->firstattemptscount;
819         }
821         $subquestions = array();
822         $questionstats = $DB->get_records('quiz_question_statistics',
823                 array('quizstatisticsid' => $quizstats->id));
825         $subquestionstats = array();
826         foreach ($questionstats as $stat) {
827             if ($stat->slot) {
828                 $questions[$stat->slot]->_stats = $stat;
829             } else {
830                 $subquestionstats[$stat->questionid] = $stat;
831             }
832         }
834         if (!empty($subquestionstats)) {
835             $subqstofetch = array_keys($subquestionstats);
836             $subquestions = question_load_questions($subqstofetch);
837             foreach ($subquestions as $subqid => $subq) {
838                 $subquestions[$subqid]->_stats = $subquestionstats[$subqid];
839                 $subquestions[$subqid]->maxmark = $subq->defaultmark;
840             }
841         }
843         return array($quizstats, $questions, $subquestions, $s);
844     }
846     /**
847      * Store the statistics in the cache tables in the database.
848      *
849      * @param object $quizid the quiz id.
850      * @param int $currentgroup the current group. 0 for none.
851      * @param bool $useallattempts use all attempts, or just first attempts.
852      * @param object $quizstats The statistics for overall attempt scores.
853      * @param array $questions The questions, with an additional _stats field.
854      * @param array $subquestions The subquestions, if any, with an additional _stats field.
855      */
856     protected function cache_stats($quizid, $currentgroup,
857             $quizstats, $questions, $subquestions) {
858         global $DB;
860         $toinsert = clone($quizstats);
861         $toinsert->quizid = $quizid;
862         $toinsert->groupid = $currentgroup;
863         $toinsert->timemodified = time();
865         // Fix up some dodgy data.
866         if (isset($toinsert->errorratio) && is_nan($toinsert->errorratio)) {
867             $toinsert->errorratio = null;
868         }
869         if (isset($toinsert->standarderror) && is_nan($toinsert->standarderror)) {
870             $toinsert->standarderror = null;
871         }
873         // Store the data.
874         $quizstats->id = $DB->insert_record('quiz_statistics', $toinsert);
876         foreach ($questions as $question) {
877             $question->_stats->quizstatisticsid = $quizstats->id;
878             $DB->insert_record('quiz_question_statistics', $question->_stats, false);
879         }
881         foreach ($subquestions as $subquestion) {
882             $subquestion->_stats->quizstatisticsid = $quizstats->id;
883             $DB->insert_record('quiz_question_statistics', $subquestion->_stats, false);
884         }
886         return $quizstats->id;
887     }
889     /**
890      * Get the quiz and question statistics, either by loading the cached results,
891      * or by recomputing them.
892      *
893      * @param object $quiz the quiz settings.
894      * @param int $currentgroup the current group. 0 for none.
895      * @param bool $nostudentsingroup true if there a no students.
896      * @param bool $useallattempts use all attempts, or just first attempts.
897      * @param array $groupstudents students in this group.
898      * @param array $questions question definitions.
899      * @return array with 4 elements:
900      *     - $quizstats The statistics for overall attempt scores.
901      *     - $questions The questions, with an additional _stats field.
902      *     - $subquestions The subquestions, if any, with an additional _stats field.
903      *     - $s Number of attempts included in the stats.
904      */
905     protected function get_quiz_and_questions_stats($quiz, $currentgroup,
906             $nostudentsingroup, $useallattempts, $groupstudents, $questions) {
908         list($quizstats, $questions, $subquestions, $s) =
909                 $this->try_loading_cached_stats($quiz, $currentgroup, $nostudentsingroup,
910                         $useallattempts, $groupstudents, $questions);
912         if (is_null($quizstats)) {
913             list($s, $quizstats, $qstats) = $this->compute_stats($quiz->id,
914                     $currentgroup, $nostudentsingroup, $useallattempts, $groupstudents, $questions);
916             if ($s) {
917                 $questions = $qstats->questions;
918                 $subquestions = $qstats->subquestions;
920                 $quizstatisticsid = $this->cache_stats($quiz->id, $currentgroup,
921                         $quizstats, $questions, $subquestions);
923                 $this->analyse_responses($quizstatisticsid, $quiz->id, $currentgroup,
924                         $nostudentsingroup, $useallattempts, $groupstudents,
925                         $questions, $subquestions);
926             }
927         }
929         return array($quizstats, $questions, $subquestions, $s);
930     }
932     protected function analyse_responses($quizstatisticsid, $quizid, $currentgroup,
933             $nostudentsingroup, $useallattempts, $groupstudents, $questions, $subquestions) {
935         $qubaids = quiz_statistics_qubaids_condition(
936                 $quizid, $currentgroup, $groupstudents, $useallattempts);
938         $done = array();
939         foreach ($questions as $question) {
940             if (!question_bank::get_qtype($question->qtype, false)->can_analyse_responses()) {
941                 continue;
942             }
943             $done[$question->id] = 1;
945             $responesstats = new quiz_statistics_response_analyser($question);
946             $responesstats->analyse($qubaids);
947             $responesstats->store_cached($quizstatisticsid);
948         }
950         foreach ($subquestions as $question) {
951             if (!question_bank::get_qtype($question->qtype, false)->can_analyse_responses() ||
952                     isset($done[$question->id])) {
953                 continue;
954             }
955             $done[$question->id] = 1;
957             $responesstats = new quiz_statistics_response_analyser($question);
958             $responesstats->analyse($qubaids);
959             $responesstats->store_cached($quizstatisticsid);
960         }
961     }
963     /**
964      * @return string HTML snipped for the Download full report as UI.
965      */
966     protected function everything_download_options() {
967         $downloadoptions = $this->table->get_download_menu();
969         $output = '<form action="'. $this->table->baseurl .'" method="post">';
970         $output .= '<div class="mdl-align">';
971         $output .= '<input type="hidden" name="everything" value="1"/>';
972         $output .= '<input type="submit" value="' .
973                 get_string('downloadeverything', 'quiz_statistics') . '"/>';
974         $output .= html_writer::select($downloadoptions, 'download',
975                 $this->table->defaultdownloadformat, false);
976         $output .= '</div></form>';
978         return $output;
979     }
981     /**
982      * Generate the snipped of HTML that says when the stats were last caculated,
983      * with a recalcuate now button.
984      * @param object $quizstats the overall quiz statistics.
985      * @param int $quizid the quiz id.
986      * @param int $currentgroup the id of the currently selected group, or 0.
987      * @param array $groupstudents ids of students in the group.
988      * @param bool $useallattempts whether to use all attempts, instead of just
989      *      first attempts.
990      * @return string a HTML snipped saying when the stats were last computed,
991      *      or blank if that is not appropriate.
992      */
993     protected function output_caching_info($quizstats, $quizid, $currentgroup,
994             $groupstudents, $useallattempts, $reporturl) {
995         global $DB, $OUTPUT;
997         if (empty($quizstats->timemodified)) {
998             return '';
999         }
1001         // Find the number of attempts since the cached statistics were computed.
1002         list($fromqa, $whereqa, $qaparams) = quiz_statistics_attempts_sql(
1003                 $quizid, $currentgroup, $groupstudents, $useallattempts, true);
1004         $count = $DB->count_records_sql("
1005                 SELECT COUNT(1)
1006                 FROM $fromqa
1007                 WHERE $whereqa
1008                 AND quiza.timefinish > {$quizstats->timemodified}", $qaparams);
1010         if (!$count) {
1011             $count = 0;
1012         }
1014         // Generate the output.
1015         $a = new stdClass();
1016         $a->lastcalculated = format_time(time() - $quizstats->timemodified);
1017         $a->count = $count;
1019         $recalcualteurl = new moodle_url($reporturl,
1020                 array('recalculate' => 1, 'sesskey' => sesskey()));
1021         $output = '';
1022         $output .= $OUTPUT->box_start(
1023                 'boxaligncenter generalbox boxwidthnormal mdl-align', 'cachingnotice');
1024         $output .= get_string('lastcalculated', 'quiz_statistics', $a);
1025         $output .= $OUTPUT->single_button($recalcualteurl,
1026                 get_string('recalculatenow', 'quiz_statistics'));
1027         $output .= $OUTPUT->box_end(true);
1029         return $output;
1030     }
1032     /**
1033      * Clear the cached data for a particular report configuration. This will
1034      * trigger a re-computation the next time the report is displayed.
1035      * @param int $quizid the quiz id.
1036      * @param int $currentgroup a group id, or 0.
1037      * @param bool $useallattempts whether all attempts, or just first attempts are included.
1038      */
1039     protected function clear_cached_data($quizid, $currentgroup, $useallattempts) {
1040         global $DB;
1042         $todelete = $DB->get_records_menu('quiz_statistics', array('quizid' => $quizid,
1043                 'groupid' => $currentgroup, 'allattempts' => $useallattempts), '', 'id, 1');
1045         if (!$todelete) {
1046             return;
1047         }
1049         list($todeletesql, $todeleteparams) = $DB->get_in_or_equal(array_keys($todelete));
1051         $DB->delete_records_select('quiz_question_statistics',
1052                 'quizstatisticsid ' . $todeletesql, $todeleteparams);
1053         $DB->delete_records_select('quiz_question_response_stats',
1054                 'quizstatisticsid ' . $todeletesql, $todeleteparams);
1055         $DB->delete_records_select('quiz_statistics',
1056                 'id ' . $todeletesql, $todeleteparams);
1057     }
1059     /**
1060      * @param bool $useallattempts whether we are using all attempts.
1061      * @return the appropriate lang string to describe this option.
1062      */
1063     protected function using_attempts_string($useallattempts) {
1064         if ($useallattempts) {
1065             return get_string('allattempts', 'quiz_statistics');
1066         } else {
1067             return get_string('firstattempts', 'quiz_statistics');
1068         }
1069     }
1072 function quiz_statistics_attempts_sql($quizid, $currentgroup, $groupstudents,
1073         $allattempts = true, $includeungraded = false) {
1074     global $DB;
1076     $fromqa = '{quiz_attempts} quiza ';
1078     $whereqa = 'quiza.quiz = :quizid AND quiza.preview = 0 AND quiza.timefinish <> 0';
1079     $qaparams = array('quizid' => $quizid);
1081     if (!empty($currentgroup) && $groupstudents) {
1082         list($grpsql, $grpparams) = $DB->get_in_or_equal(array_keys($groupstudents),
1083                 SQL_PARAMS_NAMED, 'u');
1084         $whereqa .= " AND quiza.userid $grpsql";
1085         $qaparams += $grpparams;
1086     }
1088     if (!$allattempts) {
1089         $whereqa .= ' AND quiza.attempt = 1';
1090     }
1092     if (!$includeungraded) {
1093         $whereqa .= ' AND quiza.sumgrades IS NOT NULL';
1094     }
1096     return array($fromqa, $whereqa, $qaparams);
1099 /**
1100  * Return a {@link qubaid_condition} from the values returned by
1101  * {@link quiz_statistics_attempts_sql}
1102  * @param string $fromqa from quiz_statistics_attempts_sql.
1103  * @param string $whereqa from quiz_statistics_attempts_sql.
1104  */
1105 function quiz_statistics_qubaids_condition($quizid, $currentgroup, $groupstudents,
1106         $allattempts = true, $includeungraded = false) {
1107     list($fromqa, $whereqa, $qaparams) = quiz_statistics_attempts_sql($quizid, $currentgroup,
1108             $groupstudents, $allattempts, $includeungraded);
1109     return new qubaid_join($fromqa, 'quiza.uniqueid', $whereqa, $qaparams);