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