Merge branch 'MDL-34211' of git://github.com/appalachianstate/moodle into MOODLE_23_S...
[moodle.git] / mod / quiz / report / statistics / report.php
1 <?php
2 // This file is part of Moodle - http://moodle.org/
3 //
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
8 //
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 // GNU General Public License for more details.
13 //
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
17 /**
18  * Quiz statistics report class.
19  *
20  * @package   quiz_statistics
21  * @copyright 2008 Jamie Pratt
22  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23  */
26 defined('MOODLE_INTERNAL') || die();
28 require_once($CFG->dirroot . '/mod/quiz/report/statistics/statistics_form.php');
29 require_once($CFG->dirroot . '/mod/quiz/report/statistics/statistics_table.php');
30 require_once($CFG->dirroot . '/mod/quiz/report/statistics/statistics_question_table.php');
31 require_once($CFG->dirroot . '/mod/quiz/report/statistics/qstats.php');
32 require_once($CFG->dirroot . '/mod/quiz/report/statistics/responseanalysis.php');
35 /**
36  * The quiz statistics report provides summary information about each question in
37  * a quiz, compared to the whole quiz. It also provides a drill-down to more
38  * detailed information about each question.
39  *
40  * @copyright 2008 Jamie Pratt
41  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
42  */
43 class quiz_statistics_report extends quiz_default_report {
44     /** @var integer Time after which statistics are automatically recomputed. */
45     const TIME_TO_CACHE_STATS = 900; // 15 minutes.
47     /** @var object instance of table class used for main questions stats table. */
48     protected $table;
50     /**
51      * Display the report.
52      */
53     public function display($quiz, $cm, $course) {
54         global $CFG, $DB, $OUTPUT, $PAGE;
56         $this->context = context_module::instance($cm->id);
58         // Work out the display options.
59         $download = optional_param('download', '', PARAM_ALPHA);
60         $everything = optional_param('everything', 0, PARAM_BOOL);
61         $recalculate = optional_param('recalculate', 0, PARAM_BOOL);
62         // A qid paramter indicates we should display the detailed analysis of a question.
63         $qid = optional_param('qid', 0, PARAM_INT);
64         $slot = optional_param('slot', 0, PARAM_INT);
66         $pageoptions = array();
67         $pageoptions['id'] = $cm->id;
68         $pageoptions['mode'] = 'statistics';
70         $reporturl = new moodle_url('/mod/quiz/report.php', $pageoptions);
72         $mform = new quiz_statistics_settings_form($reporturl);
73         if ($fromform = $mform->get_data()) {
74             $useallattempts = $fromform->useallattempts;
75             if ($fromform->useallattempts) {
76                 set_user_preference('quiz_report_statistics_useallattempts',
77                         $fromform->useallattempts);
78             } else {
79                 unset_user_preference('quiz_report_statistics_useallattempts');
80             }
82         } else {
83             $useallattempts = get_user_preferences('quiz_report_statistics_useallattempts', 0);
84         }
86         // Find out current groups mode.
87         $currentgroup = $this->get_current_group($cm, $course, $this->context);
88         $nostudentsingroup = false; // True if a group is selected and there is no one in it.
89         if (empty($currentgroup)) {
90             $currentgroup = 0;
91             $groupstudents = array();
93         } else if ($currentgroup == self::NO_GROUPS_ALLOWED) {
94             $groupstudents = array();
95             $nostudentsingroup = true;
97         } else {
98             // All users who can attempt quizzes and who are in the currently selected group.
99             $groupstudents = get_users_by_capability($this->context,
100                     array('mod/quiz:reviewmyattempts', 'mod/quiz:attempt'),
101                     '', '', '', '', $currentgroup, '', false);
102             if (!$groupstudents) {
103                 $nostudentsingroup = true;
104             }
105         }
107         // If recalculate was requested, handle that.
108         if ($recalculate && confirm_sesskey()) {
109             $this->clear_cached_data($quiz->id, $currentgroup, $useallattempts);
110             redirect($reporturl);
111         }
113         // Set up the main table.
114         $this->table = new quiz_statistics_table();
115         if ($everything) {
116             $report = get_string('completestatsfilename', 'quiz_statistics');
117         } else {
118             $report = get_string('questionstatsfilename', 'quiz_statistics');
119         }
120         $courseshortname = format_string($course->shortname, true,
121                 array('context' => context_course::instance($course->id)));
122         $filename = quiz_report_download_filename($report, $courseshortname, $quiz->name);
123         $this->table->is_downloading($download, $filename,
124                 get_string('quizstructureanalysis', 'quiz_statistics'));
126         // Load the questions.
127         $questions = quiz_report_get_significant_questions($quiz);
128         $questionids = array();
129         foreach ($questions as $question) {
130             $questionids[] = $question->id;
131         }
132         $fullquestions = question_load_questions($questionids);
133         foreach ($questions as $qno => $question) {
134             $q = $fullquestions[$question->id];
135             $q->maxmark = $question->maxmark;
136             $q->slot = $qno;
137             $q->number = $question->number;
138             $questions[$qno] = $q;
139         }
141         // Get the data to be displayed.
142         list($quizstats, $questions, $subquestions, $s) =
143                 $this->get_quiz_and_questions_stats($quiz, $currentgroup,
144                         $nostudentsingroup, $useallattempts, $groupstudents, $questions);
145         $quizinfo = $this->get_formatted_quiz_info_data($course, $cm, $quiz, $quizstats);
147         // Set up the table, if there is data.
148         if ($s) {
149             $this->table->statistics_setup($quiz, $cm->id, $reporturl, $s);
150         }
152         // Print the page header stuff (if not downloading.
153         if (!$this->table->is_downloading()) {
154             $this->print_header_and_tabs($cm, $course, $quiz, 'statistics');
156             if (groups_get_activity_groupmode($cm)) {
157                 groups_print_activity_menu($cm, $reporturl->out());
158                 if ($currentgroup && !$groupstudents) {
159                     $OUTPUT->notification(get_string('nostudentsingroup', 'quiz_statistics'));
160                 }
161             }
163             if (!quiz_questions_in_quiz($quiz->questions)) {
164                 echo quiz_no_questions_message($quiz, $cm, $this->context);
165             } else if (!$this->table->is_downloading() && $s == 0) {
166                 echo $OUTPUT->notification(get_string('noattempts', 'quiz'));
167             }
169             // Print display options form.
170             $mform->set_data(array('useallattempts' => $useallattempts));
171             $mform->display();
172         }
174         if ($everything) { // Implies is downloading.
175             // Overall report, then the analysis of each question.
176             $this->download_quiz_info_table($quizinfo);
178             if ($s) {
179                 $this->output_quiz_structure_analysis_table($s, $questions, $subquestions);
181                 if ($this->table->is_downloading() == 'xhtml') {
182                     $this->output_statistics_graph($quizstats->id, $s);
183                 }
185                 foreach ($questions as $question) {
186                     if (question_bank::get_qtype(
187                             $question->qtype, false)->can_analyse_responses()) {
188                         $this->output_individual_question_response_analysis(
189                                 $question, $reporturl, $quizstats);
191                     } else if (!empty($question->_stats->subquestions)) {
192                         $subitemstodisplay = explode(',', $question->_stats->subquestions);
193                         foreach ($subitemstodisplay as $subitemid) {
194                             $this->output_individual_question_response_analysis(
195                                     $subquestions[$subitemid], $reporturl, $quizstats);
196                         }
197                     }
198                 }
199             }
201             $this->table->export_class_instance()->finish_document();
203         } else if ($slot) {
204             // Report on an individual question indexed by position.
205             if (!isset($questions[$slot])) {
206                 print_error('questiondoesnotexist', 'question');
207             }
209             $this->output_individual_question_data($quiz, $questions[$slot]);
210             $this->output_individual_question_response_analysis(
211                     $questions[$slot], $reporturl, $quizstats);
213             // Back to overview link.
214             echo $OUTPUT->box('<a href="' . $reporturl->out() . '">' .
215                     get_string('backtoquizreport', 'quiz_statistics') . '</a>',
216                     'backtomainstats boxaligncenter generalbox boxwidthnormal mdl-align');
218         } else if ($qid) {
219             // Report on an individual sub-question indexed questionid.
220             if (!isset($subquestions[$qid])) {
221                 print_error('questiondoesnotexist', 'question');
222             }
224             $this->output_individual_question_data($quiz, $subquestions[$qid]);
225             $this->output_individual_question_response_analysis(
226                     $subquestions[$qid], $reporturl, $quizstats);
228             // Back to overview link.
229             echo $OUTPUT->box('<a href="' . $reporturl->out() . '">' .
230                     get_string('backtoquizreport', 'quiz_statistics') . '</a>',
231                     'boxaligncenter generalbox boxwidthnormal mdl-align');
233         } else if ($this->table->is_downloading()) {
234             // Downloading overview report.
235             $this->download_quiz_info_table($quizinfo);
236             $this->output_quiz_structure_analysis_table($s, $questions, $subquestions);
237             $this->table->finish_output();
239         } else {
240             // On-screen display of overview report.
241             echo $OUTPUT->heading(get_string('quizinformation', 'quiz_statistics'));
242             echo $this->output_caching_info($quizstats, $quiz->id, $currentgroup,
243                     $groupstudents, $useallattempts, $reporturl);
244             echo $this->everything_download_options();
245             echo $this->output_quiz_info_table($quizinfo);
246             if ($s) {
247                 echo $OUTPUT->heading(get_string('quizstructureanalysis', 'quiz_statistics'));
248                 $this->output_quiz_structure_analysis_table($s, $questions, $subquestions);
249                 $this->output_statistics_graph($quizstats->id, $s);
250             }
251         }
253         return true;
254     }
256     /**
257      * Display the statistical and introductory information about a question.
258      * Only called when not downloading.
259      * @param object $quiz the quiz settings.
260      * @param object $question the question to report on.
261      * @param moodle_url $reporturl the URL to resisplay this report.
262      * @param object $quizstats Holds the quiz statistics.
263      */
264     protected function output_individual_question_data($quiz, $question) {
265         global $OUTPUT;
267         // On-screen display. Show a summary of the question's place in the quiz,
268         // and the question statistics.
269         $datumfromtable = $this->table->format_row($question);
271         // Set up the question info table.
272         $questioninfotable = new html_table();
273         $questioninfotable->align = array('center', 'center');
274         $questioninfotable->width = '60%';
275         $questioninfotable->attributes['class'] = 'generaltable titlesleft';
277         $questioninfotable->data = array();
278         $questioninfotable->data[] = array(get_string('modulename', 'quiz'), $quiz->name);
279         $questioninfotable->data[] = array(get_string('questionname', 'quiz_statistics'),
280                 $question->name.'&nbsp;'.$datumfromtable['actions']);
281         $questioninfotable->data[] = array(get_string('questiontype', 'quiz_statistics'),
282                 $datumfromtable['icon'] . '&nbsp;' .
283                 question_bank::get_qtype($question->qtype, false)->menu_name() . '&nbsp;' .
284                 $datumfromtable['icon']);
285         $questioninfotable->data[] = array(get_string('positions', 'quiz_statistics'),
286                 $question->_stats->positions);
288         // Set up the question statistics table.
289         $questionstatstable = new html_table();
290         $questionstatstable->align = array('center', 'center');
291         $questionstatstable->width = '60%';
292         $questionstatstable->attributes['class'] = 'generaltable titlesleft';
294         unset($datumfromtable['number']);
295         unset($datumfromtable['icon']);
296         $actions = $datumfromtable['actions'];
297         unset($datumfromtable['actions']);
298         unset($datumfromtable['name']);
299         $labels = array(
300             's' => get_string('attempts', 'quiz_statistics'),
301             'facility' => get_string('facility', 'quiz_statistics'),
302             'sd' => get_string('standarddeviationq', 'quiz_statistics'),
303             'random_guess_score' => get_string('random_guess_score', 'quiz_statistics'),
304             'intended_weight' => get_string('intended_weight', 'quiz_statistics'),
305             'effective_weight' => get_string('effective_weight', 'quiz_statistics'),
306             'discrimination_index' => get_string('discrimination_index', 'quiz_statistics'),
307             'discriminative_efficiency' =>
308                                 get_string('discriminative_efficiency', 'quiz_statistics')
309         );
310         foreach ($datumfromtable as $item => $value) {
311             $questionstatstable->data[] = array($labels[$item], $value);
312         }
314         // Display the various bits.
315         echo $OUTPUT->heading(get_string('questioninformation', 'quiz_statistics'));
316         echo html_writer::table($questioninfotable);
317         echo $this->render_question_text($question);
318         echo $OUTPUT->heading(get_string('questionstatistics', 'quiz_statistics'));
319         echo html_writer::table($questionstatstable);
320     }
321     public function format_text($text, $format, $qa, $component, $filearea, $itemid,
322             $clean = false) {
323         $formatoptions = new stdClass();
324         $formatoptions->noclean = !$clean;
325         $formatoptions->para = false;
326         $text = $qa->rewrite_pluginfile_urls($text, $component, $filearea, $itemid);
327         return format_text($text, $format, $formatoptions);
328     }
330     /** @return the result of applying {@link format_text()} to the question text. */
331     public function format_questiontext($qa) {
332         return $this->format_text($this->questiontext, $this->questiontextformat,
333         $qa, 'question', 'questiontext', $this->id);
334     }
336     /**
337      * @param object $question question data.
338      * @return string HTML of question text, ready for display.
339      */
340     protected function render_question_text($question) {
341         global $OUTPUT;
343         $text = question_rewrite_questiontext_preview_urls($question->questiontext,
344                 $this->context->id, 'quiz_statistics', $question->id);
346         return $OUTPUT->box(format_text($text, $question->questiontextformat,
347                 array('noclean' => true, 'para' => false, 'overflowdiv' => true)),
348                 'questiontext boxaligncenter generalbox boxwidthnormal mdl-align');
349     }
351     /**
352      * Display the response analysis for a question.
353      * @param object $question the question to report on.
354      * @param moodle_url $reporturl the URL to resisplay this report.
355      * @param object $quizstats Holds the quiz statistics.
356      */
357     protected function output_individual_question_response_analysis($question,
358             $reporturl, $quizstats) {
359         global $OUTPUT;
361         if (!question_bank::get_qtype($question->qtype, false)->can_analyse_responses()) {
362             return;
363         }
365         $qtable = new quiz_statistics_question_table($question->id);
366         $exportclass = $this->table->export_class_instance();
367         $qtable->export_class_instance($exportclass);
368         if (!$this->table->is_downloading()) {
369             // Output an appropriate title.
370             echo $OUTPUT->heading(get_string('analysisofresponses', 'quiz_statistics'));
372         } else {
373             // Work out an appropriate title.
374             $questiontabletitle = '"' . $question->name . '"';
375             if (!empty($question->number)) {
376                 $questiontabletitle = '(' . $question->number . ') ' . $questiontabletitle;
377             }
378             if ($this->table->is_downloading() == 'xhtml') {
379                 $questiontabletitle = get_string('analysisofresponsesfor',
380                         'quiz_statistics', $questiontabletitle);
381             }
383             // Set up the table.
384             $exportclass->start_table($questiontabletitle);
386             if ($this->table->is_downloading() == 'xhtml') {
387                 echo $this->render_question_text($question);
388             }
389         }
391         $responesstats = new quiz_statistics_response_analyser($question);
392         $responesstats->load_cached($quizstats->id);
394         $qtable->question_setup($reporturl, $question, $responesstats);
395         if ($this->table->is_downloading()) {
396             $exportclass->output_headers($qtable->headers);
397         }
399         foreach ($responesstats->responseclasses as $partid => $partclasses) {
400             $rowdata = new stdClass();
401             $rowdata->part = $partid;
402             foreach ($partclasses as $responseclassid => $responseclass) {
403                 $rowdata->responseclass = $responseclass->responseclass;
405                 $responsesdata = $responesstats->responses[$partid][$responseclassid];
406                 if (empty($responsesdata)) {
407                     if (!array_key_exists('responseclass', $qtable->columns)) {
408                         $rowdata->response = $responseclass->responseclass;
409                     } else {
410                         $rowdata->response = '';
411                     }
412                     $rowdata->fraction = $responseclass->fraction;
413                     $rowdata->count = 0;
414                     $qtable->add_data_keyed($qtable->format_row($rowdata));
415                     continue;
416                 }
418                 foreach ($responsesdata as $response => $data) {
419                     $rowdata->response = $response;
420                     $rowdata->fraction = $data->fraction;
421                     $rowdata->count = $data->count;
422                     $qtable->add_data_keyed($qtable->format_row($rowdata));
423                 }
424             }
425         }
427         $qtable->finish_output(!$this->table->is_downloading());
428     }
430     /**
431      * Output the table that lists all the questions in the quiz with their statistics.
432      * @param int $s number of attempts.
433      * @param array $questions the questions in the quiz.
434      * @param array $subquestions the subquestions of any random questions.
435      */
436     protected function output_quiz_structure_analysis_table($s, $questions, $subquestions) {
437         if (!$s) {
438             return;
439         }
441         foreach ($questions as $question) {
442             // Output the data for this questions.
443             $this->table->add_data_keyed($this->table->format_row($question));
445             if (empty($question->_stats->subquestions)) {
446                 continue;
447             }
449             // And its subquestions, if it has any.
450             $subitemstodisplay = explode(',', $question->_stats->subquestions);
451             foreach ($subitemstodisplay as $subitemid) {
452                 $subquestions[$subitemid]->maxmark = $question->maxmark;
453                 $this->table->add_data_keyed($this->table->format_row($subquestions[$subitemid]));
454             }
455         }
457         $this->table->finish_output(!$this->table->is_downloading());
458     }
460     protected function get_formatted_quiz_info_data($course, $cm, $quiz, $quizstats) {
462         // You can edit this array to control which statistics are displayed.
463         $todisplay = array('firstattemptscount' => 'number',
464                     'allattemptscount' => 'number',
465                     'firstattemptsavg' => 'summarks_as_percentage',
466                     'allattemptsavg' => 'summarks_as_percentage',
467                     'median' => 'summarks_as_percentage',
468                     'standarddeviation' => 'summarks_as_percentage',
469                     'skewness' => 'number_format',
470                     'kurtosis' => 'number_format',
471                     'cic' => 'number_format_percent',
472                     'errorratio' => 'number_format_percent',
473                     'standarderror' => 'summarks_as_percentage');
475         // General information about the quiz.
476         $quizinfo = array();
477         $quizinfo[get_string('quizname', 'quiz_statistics')] = format_string($quiz->name);
478         $quizinfo[get_string('coursename', 'quiz_statistics')] = format_string($course->fullname);
479         if ($cm->idnumber) {
480             $quizinfo[get_string('idnumbermod')] = $cm->idnumber;
481         }
482         if ($quiz->timeopen) {
483             $quizinfo[get_string('quizopen', 'quiz')] = userdate($quiz->timeopen);
484         }
485         if ($quiz->timeclose) {
486             $quizinfo[get_string('quizclose', 'quiz')] = userdate($quiz->timeclose);
487         }
488         if ($quiz->timeopen && $quiz->timeclose) {
489             $quizinfo[get_string('duration', 'quiz_statistics')] =
490                     format_time($quiz->timeclose - $quiz->timeopen);
491         }
493         // The statistics.
494         foreach ($todisplay as $property => $format) {
495             if (!isset($quizstats->$property) || empty($format[$property])) {
496                 continue;
497             }
498             $value = $quizstats->$property;
500             switch ($format) {
501                 case 'summarks_as_percentage':
502                     $formattedvalue = quiz_report_scale_summarks_as_percentage($value, $quiz);
503                     break;
504                 case 'number_format_percent':
505                     $formattedvalue = quiz_format_grade($quiz, $value) . '%';
506                     break;
507                 case 'number_format':
508                     // 2 extra decimal places, since not a percentage,
509                     // and we want the same number of sig figs.
510                     $formattedvalue = format_float($value, $quiz->decimalpoints + 2);
511                     break;
512                 case 'number':
513                     $formattedvalue = $value + 0;
514                     break;
515                 default:
516                     $formattedvalue = $value;
517             }
519             $quizinfo[get_string($property, 'quiz_statistics',
520                     $this->using_attempts_string(!empty($quizstats->allattempts)))] =
521                     $formattedvalue;
522         }
524         return $quizinfo;
525     }
527     /**
528      * Output the table of overall quiz statistics.
529      * @param array $quizinfo as returned by {@link get_formatted_quiz_info_data()}.
530      * @return string the HTML.
531      */
532     protected function output_quiz_info_table($quizinfo) {
534         $quizinfotable = new html_table();
535         $quizinfotable->align = array('center', 'center');
536         $quizinfotable->width = '60%';
537         $quizinfotable->attributes['class'] = 'generaltable titlesleft';
538         $quizinfotable->data = array();
540         foreach ($quizinfo as $heading => $value) {
541              $quizinfotable->data[] = array($heading, $value);
542         }
544         return html_writer::table($quizinfotable);
545     }
547     /**
548      * Download the table of overall quiz statistics.
549      * @param array $quizinfo as returned by {@link get_formatted_quiz_info_data()}.
550      */
551     protected function download_quiz_info_table($quizinfo) {
552         global $OUTPUT;
554         // XHTML download is a special case.
555         if ($this->table->is_downloading() == 'xhtml') {
556             echo $OUTPUT->heading(get_string('quizinformation', 'quiz_statistics'));
557             echo $this->output_quiz_info_table($quizinfo);
558             return;
559         }
561         // Reformat the data ready for output.
562         $headers = array();
563         $row = array();
564         foreach ($quizinfo as $heading => $value) {
565             $headers[] = $heading;
566             $row[] = $value;
567         }
569         // Do the output.
570         $exportclass = $this->table->export_class_instance();
571         $exportclass->start_table(get_string('quizinformation', 'quiz_statistics'));
572         $exportclass->output_headers($headers);
573         $exportclass->add_data($row);
574         $exportclass->finish_table();
575     }
577     /**
578      * Output the HTML needed to show the statistics graph.
579      * @param int $quizstatsid the id of the statistics to show in the graph.
580      */
581     protected function output_statistics_graph($quizstatsid, $s) {
582         global $PAGE;
584         if ($s == 0) {
585             return;
586         }
588         $output = $PAGE->get_renderer('mod_quiz');
589         $imageurl = new moodle_url('/mod/quiz/report/statistics/statistics_graph.php',
590                 array('id' => $quizstatsid));
591         $graphname = get_string('statisticsreportgraph', 'quiz_statistics');
592         echo $output->graph($imageurl, $graphname);
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 = null;
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             // An 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 the 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); // Number 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.state = :quizstatefinished';
1079     $qaparams = array('quizid' => $quizid, 'quizstatefinished' => quiz_attempt::FINISHED);
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);