MDL-41878 JavaScript: Add an alias for Moodle in the YUI loader
[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 . '/question/engine/statistics.php');
32 require_once($CFG->dirroot . '/question/engine/responseanalysis.php');
33 require_once($CFG->dirroot . '/mod/quiz/report/statistics/statisticslib.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         $qubaids = quiz_statistics_qubaids_condition($quiz->id, $currentgroup, $groupstudents, $useallattempts);
110         // If recalculate was requested, handle that.
111         if ($recalculate && confirm_sesskey()) {
112             $this->clear_cached_data($qubaids);
113             redirect($reporturl);
114         }
116         // Set up the main table.
117         $this->table = new quiz_statistics_table();
118         if ($everything) {
119             $report = get_string('completestatsfilename', 'quiz_statistics');
120         } else {
121             $report = get_string('questionstatsfilename', 'quiz_statistics');
122         }
123         $courseshortname = format_string($course->shortname, true,
124                 array('context' => context_course::instance($course->id)));
125         $filename = quiz_report_download_filename($report, $courseshortname, $quiz->name);
126         $this->table->is_downloading($download, $filename,
127                 get_string('quizstructureanalysis', 'quiz_statistics'));
128         $questions = $this->load_and_initialise_questions_for_calculations($quiz);
130         // Get the data to be displayed.
131         list($quizstats, $questions, $subquestions, $s) =
132                 $this->get_quiz_and_questions_stats($quiz, $currentgroup,
133                         $nostudentsingroup, $useallattempts, $groupstudents, $questions);
134         $quizinfo = $this->get_formatted_quiz_info_data($course, $cm, $quiz, $quizstats);
136         // Set up the table, if there is data.
137         if ($s) {
138             $this->table->statistics_setup($quiz, $cm->id, $reporturl, $s);
139         }
141         // Print the page header stuff (if not downloading.
142         if (!$this->table->is_downloading()) {
143             $this->print_header_and_tabs($cm, $course, $quiz, 'statistics');
145             if (groups_get_activity_groupmode($cm)) {
146                 groups_print_activity_menu($cm, $reporturl->out());
147                 if ($currentgroup && !$groupstudents) {
148                     $OUTPUT->notification(get_string('nostudentsingroup', 'quiz_statistics'));
149                 }
150             }
152             if (!quiz_questions_in_quiz($quiz->questions)) {
153                 echo quiz_no_questions_message($quiz, $cm, $this->context);
154             } else if (!$this->table->is_downloading() && $s == 0) {
155                 echo $OUTPUT->notification(get_string('noattempts', 'quiz'));
156             }
158             // Print display options form.
159             $mform->set_data(array('useallattempts' => $useallattempts));
160             $mform->display();
161         }
163         if ($everything) { // Implies is downloading.
164             // Overall report, then the analysis of each question.
165             $this->download_quiz_info_table($quizinfo);
167             if ($s) {
168                 $this->output_quiz_structure_analysis_table($s, $questions, $subquestions);
170                 if ($this->table->is_downloading() == 'xhtml' && $s != 0) {
171                     $this->output_statistics_graph($quiz->id, $currentgroup, $useallattempts);
172                 }
174                 foreach ($questions as $question) {
175                     if (question_bank::get_qtype(
176                             $question->qtype, false)->can_analyse_responses()) {
177                         $this->output_individual_question_response_analysis(
178                                 $question, $reporturl, $qubaids);
180                     } else if (!empty($question->_stats->subquestions)) {
181                         $subitemstodisplay = explode(',', $question->_stats->subquestions);
182                         foreach ($subitemstodisplay as $subitemid) {
183                             $this->output_individual_question_response_analysis(
184                                     $subquestions[$subitemid], $reporturl, $qubaids);
185                         }
186                     }
187                 }
188             }
190             $this->table->export_class_instance()->finish_document();
192         } else if ($slot) {
193             // Report on an individual question indexed by position.
194             if (!isset($questions[$slot])) {
195                 print_error('questiondoesnotexist', 'question');
196             }
198             $this->output_individual_question_data($quiz, $questions[$slot]);
199             $this->output_individual_question_response_analysis(
200                     $questions[$slot], $reporturl, $qubaids);
202             // Back to overview link.
203             echo $OUTPUT->box('<a href="' . $reporturl->out() . '">' .
204                     get_string('backtoquizreport', 'quiz_statistics') . '</a>',
205                     'backtomainstats boxaligncenter generalbox boxwidthnormal mdl-align');
207         } else if ($qid) {
208             // Report on an individual sub-question indexed questionid.
209             if (!isset($subquestions[$qid])) {
210                 print_error('questiondoesnotexist', 'question');
211             }
213             $this->output_individual_question_data($quiz, $subquestions[$qid]);
214             $this->output_individual_question_response_analysis(
215                     $subquestions[$qid], $reporturl, $qubaids);
217             // Back to overview link.
218             echo $OUTPUT->box('<a href="' . $reporturl->out() . '">' .
219                     get_string('backtoquizreport', 'quiz_statistics') . '</a>',
220                     'boxaligncenter generalbox boxwidthnormal mdl-align');
222         } else if ($this->table->is_downloading()) {
223             // Downloading overview report.
224             $this->download_quiz_info_table($quizinfo);
225             $this->output_quiz_structure_analysis_table($s, $questions, $subquestions);
226             $this->table->finish_output();
228         } else {
229             // On-screen display of overview report.
230             echo $OUTPUT->heading(get_string('quizinformation', 'quiz_statistics'));
231             echo $this->output_caching_info($quizstats, $quiz->id, $currentgroup,
232                     $groupstudents, $useallattempts, $reporturl);
233             echo $this->everything_download_options();
234             echo $this->output_quiz_info_table($quizinfo);
235             if ($s) {
236                 echo $OUTPUT->heading(get_string('quizstructureanalysis', 'quiz_statistics'));
237                 $this->output_quiz_structure_analysis_table($s, $questions, $subquestions);
238                 $this->output_statistics_graph($quiz->id, $currentgroup, $useallattempts);
239             }
240         }
242         return true;
243     }
245     /**
246      * Display the statistical and introductory information about a question.
247      * Only called when not downloading.
248      * @param object $quiz the quiz settings.
249      * @param object $question the question to report on.
250      * @param moodle_url $reporturl the URL to resisplay this report.
251      * @param object $quizstats Holds the quiz statistics.
252      */
253     protected function output_individual_question_data($quiz, $question) {
254         global $OUTPUT;
256         // On-screen display. Show a summary of the question's place in the quiz,
257         // and the question statistics.
258         $datumfromtable = $this->table->format_row($question);
260         // Set up the question info table.
261         $questioninfotable = new html_table();
262         $questioninfotable->align = array('center', 'center');
263         $questioninfotable->width = '60%';
264         $questioninfotable->attributes['class'] = 'generaltable titlesleft';
266         $questioninfotable->data = array();
267         $questioninfotable->data[] = array(get_string('modulename', 'quiz'), $quiz->name);
268         $questioninfotable->data[] = array(get_string('questionname', 'quiz_statistics'),
269                 $question->name.'&nbsp;'.$datumfromtable['actions']);
270         $questioninfotable->data[] = array(get_string('questiontype', 'quiz_statistics'),
271                 $datumfromtable['icon'] . '&nbsp;' .
272                 question_bank::get_qtype($question->qtype, false)->menu_name() . '&nbsp;' .
273                 $datumfromtable['icon']);
274         $questioninfotable->data[] = array(get_string('positions', 'quiz_statistics'),
275                 $question->_stats->positions);
277         // Set up the question statistics table.
278         $questionstatstable = new html_table();
279         $questionstatstable->align = array('center', 'center');
280         $questionstatstable->width = '60%';
281         $questionstatstable->attributes['class'] = 'generaltable titlesleft';
283         unset($datumfromtable['number']);
284         unset($datumfromtable['icon']);
285         $actions = $datumfromtable['actions'];
286         unset($datumfromtable['actions']);
287         unset($datumfromtable['name']);
288         $labels = array(
289             's' => get_string('attempts', 'quiz_statistics'),
290             'facility' => get_string('facility', 'quiz_statistics'),
291             'sd' => get_string('standarddeviationq', 'quiz_statistics'),
292             'random_guess_score' => get_string('random_guess_score', 'quiz_statistics'),
293             'intended_weight' => get_string('intended_weight', 'quiz_statistics'),
294             'effective_weight' => get_string('effective_weight', 'quiz_statistics'),
295             'discrimination_index' => get_string('discrimination_index', 'quiz_statistics'),
296             'discriminative_efficiency' =>
297                                 get_string('discriminative_efficiency', 'quiz_statistics')
298         );
299         foreach ($datumfromtable as $item => $value) {
300             $questionstatstable->data[] = array($labels[$item], $value);
301         }
303         // Display the various bits.
304         echo $OUTPUT->heading(get_string('questioninformation', 'quiz_statistics'));
305         echo html_writer::table($questioninfotable);
306         echo $this->render_question_text($question);
307         echo $OUTPUT->heading(get_string('questionstatistics', 'quiz_statistics'));
308         echo html_writer::table($questionstatstable);
309     }
311     /**
312      * @param object $question question data.
313      * @return string HTML of question text, ready for display.
314      */
315     protected function render_question_text($question) {
316         global $OUTPUT;
318         $text = question_rewrite_question_preview_urls($question->questiontext, $question->id,
319                 $question->contextid, 'question', 'questiontext', $question->id,
320                 $this->context->id, 'quiz_statistics');
322         return $OUTPUT->box(format_text($text, $question->questiontextformat,
323                 array('noclean' => true, 'para' => false, 'overflowdiv' => true)),
324                 'questiontext boxaligncenter generalbox boxwidthnormal mdl-align');
325     }
327     /**
328      * Display the response analysis for a question.
329      * @param object     $question  the question to report on.
330      * @param moodle_url $reporturl the URL to resisplay this report.
331      * @param qubaid_condition $qubaids
332      */
333     protected function output_individual_question_response_analysis($question,
334             $reporturl, $qubaids) {
335         global $OUTPUT;
337         if (!question_bank::get_qtype($question->qtype, false)->can_analyse_responses()) {
338             return;
339         }
341         $qtable = new quiz_statistics_question_table($question->id);
342         $exportclass = $this->table->export_class_instance();
343         $qtable->export_class_instance($exportclass);
344         if (!$this->table->is_downloading()) {
345             // Output an appropriate title.
346             echo $OUTPUT->heading(get_string('analysisofresponses', 'quiz_statistics'));
348         } else {
349             // Work out an appropriate title.
350             $questiontabletitle = '"' . $question->name . '"';
351             if (!empty($question->number)) {
352                 $questiontabletitle = '(' . $question->number . ') ' . $questiontabletitle;
353             }
354             if ($this->table->is_downloading() == 'xhtml') {
355                 $questiontabletitle = get_string('analysisofresponsesfor',
356                         'quiz_statistics', $questiontabletitle);
357             }
359             // Set up the table.
360             $exportclass->start_table($questiontabletitle);
362             if ($this->table->is_downloading() == 'xhtml') {
363                 echo $this->render_question_text($question);
364             }
365         }
367         $responesstats = new question_response_analyser($question);
368         $responesstats->load_cached($qubaids);
370         $qtable->question_setup($reporturl, $question, $responesstats);
371         if ($this->table->is_downloading()) {
372             $exportclass->output_headers($qtable->headers);
373         }
375         foreach ($responesstats->responseclasses as $partid => $partclasses) {
376             $rowdata = new stdClass();
377             $rowdata->part = $partid;
378             foreach ($partclasses as $responseclassid => $responseclass) {
379                 $rowdata->responseclass = $responseclass->responseclass;
381                 $responsesdata = $responesstats->responses[$partid][$responseclassid];
382                 if (empty($responsesdata)) {
383                     if (!array_key_exists('responseclass', $qtable->columns)) {
384                         $rowdata->response = $responseclass->responseclass;
385                     } else {
386                         $rowdata->response = '';
387                     }
388                     $rowdata->fraction = $responseclass->fraction;
389                     $rowdata->count = 0;
390                     $qtable->add_data_keyed($qtable->format_row($rowdata));
391                     continue;
392                 }
394                 foreach ($responsesdata as $response => $data) {
395                     $rowdata->response = $response;
396                     $rowdata->fraction = $data->fraction;
397                     $rowdata->count = $data->count;
398                     $qtable->add_data_keyed($qtable->format_row($rowdata));
399                 }
400             }
401         }
403         $qtable->finish_output(!$this->table->is_downloading());
404     }
406     /**
407      * Output the table that lists all the questions in the quiz with their statistics.
408      * @param int $s number of attempts.
409      * @param array $questions the questions in the quiz.
410      * @param array $subquestions the subquestions of any random questions.
411      */
412     protected function output_quiz_structure_analysis_table($s, $questions, $subquestions) {
413         if (!$s) {
414             return;
415         }
417         foreach ($questions as $question) {
418             // Output the data for this questions.
419             $this->table->add_data_keyed($this->table->format_row($question));
421             if (empty($question->_stats->subquestions)) {
422                 continue;
423             }
425             // And its subquestions, if it has any.
426             $subitemstodisplay = explode(',', $question->_stats->subquestions);
427             foreach ($subitemstodisplay as $subitemid) {
428                 $subquestions[$subitemid]->maxmark = $question->maxmark;
429                 $this->table->add_data_keyed($this->table->format_row($subquestions[$subitemid]));
430             }
431         }
433         $this->table->finish_output(!$this->table->is_downloading());
434     }
436     protected function get_formatted_quiz_info_data($course, $cm, $quiz, $quizstats) {
438         // You can edit this array to control which statistics are displayed.
439         $todisplay = array('firstattemptscount' => 'number',
440                     'allattemptscount' => 'number',
441                     'firstattemptsavg' => 'summarks_as_percentage',
442                     'allattemptsavg' => 'summarks_as_percentage',
443                     'median' => 'summarks_as_percentage',
444                     'standarddeviation' => 'summarks_as_percentage',
445                     'skewness' => 'number_format',
446                     'kurtosis' => 'number_format',
447                     'cic' => 'number_format_percent',
448                     'errorratio' => 'number_format_percent',
449                     'standarderror' => 'summarks_as_percentage');
451         // General information about the quiz.
452         $quizinfo = array();
453         $quizinfo[get_string('quizname', 'quiz_statistics')] = format_string($quiz->name);
454         $quizinfo[get_string('coursename', 'quiz_statistics')] = format_string($course->fullname);
455         if ($cm->idnumber) {
456             $quizinfo[get_string('idnumbermod')] = $cm->idnumber;
457         }
458         if ($quiz->timeopen) {
459             $quizinfo[get_string('quizopen', 'quiz')] = userdate($quiz->timeopen);
460         }
461         if ($quiz->timeclose) {
462             $quizinfo[get_string('quizclose', 'quiz')] = userdate($quiz->timeclose);
463         }
464         if ($quiz->timeopen && $quiz->timeclose) {
465             $quizinfo[get_string('duration', 'quiz_statistics')] =
466                     format_time($quiz->timeclose - $quiz->timeopen);
467         }
469         // The statistics.
470         foreach ($todisplay as $property => $format) {
471             if (!isset($quizstats->$property) || !$format) {
472                 continue;
473             }
474             $value = $quizstats->$property;
476             switch ($format) {
477                 case 'summarks_as_percentage':
478                     $formattedvalue = quiz_report_scale_summarks_as_percentage($value, $quiz);
479                     break;
480                 case 'number_format_percent':
481                     $formattedvalue = quiz_format_grade($quiz, $value) . '%';
482                     break;
483                 case 'number_format':
484                     // 2 extra decimal places, since not a percentage,
485                     // and we want the same number of sig figs.
486                     $formattedvalue = format_float($value, $quiz->decimalpoints + 2);
487                     break;
488                 case 'number':
489                     $formattedvalue = $value + 0;
490                     break;
491                 default:
492                     $formattedvalue = $value;
493             }
495             $quizinfo[get_string($property, 'quiz_statistics',
496                     $this->using_attempts_string(!empty($quizstats->allattempts)))] =
497                     $formattedvalue;
498         }
500         return $quizinfo;
501     }
503     /**
504      * Output the table of overall quiz statistics.
505      * @param array $quizinfo as returned by {@link get_formatted_quiz_info_data()}.
506      * @return string the HTML.
507      */
508     protected function output_quiz_info_table($quizinfo) {
510         $quizinfotable = new html_table();
511         $quizinfotable->align = array('center', 'center');
512         $quizinfotable->width = '60%';
513         $quizinfotable->attributes['class'] = 'generaltable titlesleft';
514         $quizinfotable->data = array();
516         foreach ($quizinfo as $heading => $value) {
517              $quizinfotable->data[] = array($heading, $value);
518         }
520         return html_writer::table($quizinfotable);
521     }
523     /**
524      * Download the table of overall quiz statistics.
525      * @param array $quizinfo as returned by {@link get_formatted_quiz_info_data()}.
526      */
527     protected function download_quiz_info_table($quizinfo) {
528         global $OUTPUT;
530         // XHTML download is a special case.
531         if ($this->table->is_downloading() == 'xhtml') {
532             echo $OUTPUT->heading(get_string('quizinformation', 'quiz_statistics'));
533             echo $this->output_quiz_info_table($quizinfo);
534             return;
535         }
537         // Reformat the data ready for output.
538         $headers = array();
539         $row = array();
540         foreach ($quizinfo as $heading => $value) {
541             $headers[] = $heading;
542             $row[] = $value;
543         }
545         // Do the output.
546         $exportclass = $this->table->export_class_instance();
547         $exportclass->start_table(get_string('quizinformation', 'quiz_statistics'));
548         $exportclass->output_headers($headers);
549         $exportclass->add_data($row);
550         $exportclass->finish_table();
551     }
553     /**
554      * Output the HTML needed to show the statistics graph.
555      * @param $quizid
556      * @param $currentgroup
557      * @param $useallattempts
558      */
559     protected function output_statistics_graph($quizid, $currentgroup, $useallattempts) {
560         global $PAGE;
562         $output = $PAGE->get_renderer('mod_quiz');
563         $imageurl = new moodle_url('/mod/quiz/report/statistics/statistics_graph.php',
564                                     compact('quizid', 'currentgroup', 'useallattempts'));
565         $graphname = get_string('statisticsreportgraph', 'quiz_statistics');
566         echo $output->graph($imageurl, $graphname);
567     }
569     /**
570      * Return the stats data for when there are no stats to show.
571      *
572      * @param int $firstattemptscount number of first attempts (optional).
573      * @param int $allattemptscount total number of attempts (optional).
574      * @return array with two elements:
575      *      - integer $s Number of attempts included in the stats (0).
576      *      - object $quizstats The statistics for overall attempt scores.
577      */
578     protected function get_empty_stats($firstattemptscount = 0, $allattemptscount = 0) {
579         $quizstats = new stdClass();
580         $quizstats->firstattemptscount = $firstattemptscount;
581         $quizstats->allattemptscount = $allattemptscount;
583         return array(0, $quizstats);
584     }
586     /**
587      * Compute the quiz statistics.
588      *
589      * @param int   $quizid            the quiz id.
590      * @param int   $currentgroup      the current group. 0 for none.
591      * @param bool  $useallattempts    use all attempts, or just first attempts.
592      * @param array $groupstudents     students in this group.
593      * @param int   $p                 number of positions (slots).
594      * @param float $sumofmarkvariance sum of mark variance, calculated as part of question statistics
595      * @return array with two elements:
596      *      - integer $s Number of attempts included in the stats.
597      *      - object $quizstats The statistics for overall attempt scores.
598      */
599     protected function calculate_quiz_stats($quizid, $currentgroup, $useallattempts, $groupstudents, $p, $sumofmarkvariance) {
600         global $DB;
602         // Calculating MEAN of marks for all attempts by students
603         // http://docs.moodle.org/dev/Quiz_item_analysis_calculations_in_practise
604         //     #Calculating_MEAN_of_grades_for_all_attempts_by_students.
605         list($fromqa, $whereqa, $qaparams) = quiz_statistics_attempts_sql(
606                 $quizid, $currentgroup, $groupstudents, true);
608         $attempttotals = $DB->get_records_sql("
609                 SELECT
610                     CASE WHEN attempt = 1 THEN 1 ELSE 0 END AS isfirst,
611                     COUNT(1) AS countrecs,
612                     SUM(sumgrades) AS total
613                 FROM $fromqa
614                 WHERE $whereqa
615                 GROUP BY CASE WHEN attempt = 1 THEN 1 ELSE 0 END", $qaparams);
617         if (!$attempttotals) {
618             return $this->get_empty_stats();
619         }
621         if (isset($attempttotals[1])) {
622             $firstattempts = $attempttotals[1];
623             $firstattempts->average = $firstattempts->total / $firstattempts->countrecs;
624         } else {
625             $firstattempts = new stdClass();
626             $firstattempts->countrecs = 0;
627             $firstattempts->total = 0;
628             $firstattempts->average = null;
629         }
631         $allattempts = new stdClass();
632         if (isset($attempttotals[0])) {
633             $allattempts->countrecs = $firstattempts->countrecs + $attempttotals[0]->countrecs;
634             $allattempts->total = $firstattempts->total + $attempttotals[0]->total;
635         } else {
636             $allattempts->countrecs = $firstattempts->countrecs;
637             $allattempts->total = $firstattempts->total;
638         }
640         if ($useallattempts) {
641             $usingattempts = $allattempts;
642             $usingattempts->sql = '';
643         } else {
644             $usingattempts = $firstattempts;
645             $usingattempts->sql = 'AND quiza.attempt = 1 ';
646         }
648         $s = $usingattempts->countrecs;
649         if ($s == 0) {
650             return $this->get_empty_stats($firstattempts->countrecs, $allattempts->countrecs);
651         }
653         $quizstats = new stdClass();
654         $quizstats->allattempts = $useallattempts;
655         $quizstats->firstattemptscount = $firstattempts->countrecs;
656         $quizstats->allattemptscount = $allattempts->countrecs;
657         $quizstats->firstattemptsavg = $firstattempts->average;
658         $quizstats->allattemptsavg = $allattempts->total / $allattempts->countrecs;
660         // Recalculate sql again this time possibly including test for first attempt.
661         list($fromqa, $whereqa, $qaparams) = quiz_statistics_attempts_sql(
662                 $quizid, $currentgroup, $groupstudents, $useallattempts);
664         // Median ...
665         if ($s % 2 == 0) {
666             // An even number of attempts.
667             $limitoffset = $s/2 - 1;
668             $limit = 2;
669         } else {
670             $limitoffset = floor($s/2);
671             $limit = 1;
672         }
673         $sql = "SELECT id, sumgrades
674                 FROM $fromqa
675                 WHERE $whereqa
676                 ORDER BY sumgrades";
678         $medianmarks = $DB->get_records_sql_menu($sql, $qaparams, $limitoffset, $limit);
680         $quizstats->median = array_sum($medianmarks) / count($medianmarks);
681         if ($s > 1) {
682             // Fetch the sum of squared, cubed and power 4d
683             // differences between marks and mean mark.
684             $mean = $usingattempts->total / $s;
685             $sql = "SELECT
686                     SUM(POWER((quiza.sumgrades - $mean), 2)) AS power2,
687                     SUM(POWER((quiza.sumgrades - $mean), 3)) AS power3,
688                     SUM(POWER((quiza.sumgrades - $mean), 4)) AS power4
689                     FROM $fromqa
690                     WHERE $whereqa";
691             $params = array('mean1' => $mean, 'mean2' => $mean, 'mean3' => $mean)+$qaparams;
693             $powers = $DB->get_record_sql($sql, $params, MUST_EXIST);
695             // Standard_Deviation:
696             // see http://docs.moodle.org/dev/Quiz_item_analysis_calculations_in_practise
697             //         #Standard_Deviation.
699             $quizstats->standarddeviation = sqrt($powers->power2 / ($s - 1));
701             // Skewness.
702             if ($s > 2) {
703                 // See http://docs.moodle.org/dev/
704                 //      Quiz_item_analysis_calculations_in_practise#Skewness_and_Kurtosis.
705                 $m2= $powers->power2 / $s;
706                 $m3= $powers->power3 / $s;
707                 $m4= $powers->power4 / $s;
709                 $k2= $s*$m2/($s-1);
710                 $k3= $s*$s*$m3/(($s-1)*($s-2));
711                 if ($k2) {
712                     $quizstats->skewness = $k3 / (pow($k2, 3/2));
713                 }
715                 // Kurtosis.
716                 if ($s > 3) {
717                     $k4= $s*$s*((($s+1)*$m4)-(3*($s-1)*$m2*$m2))/(($s-1)*($s-2)*($s-3));
718                     if ($k2) {
719                         $quizstats->kurtosis = $k4 / ($k2*$k2);
720                     }
721                 }
722             }
723         }
725         if ($s > 1) {
726             if ($p > 1 && isset($k2)) {
727                 $quizstats->cic = (100 * $p / ($p -1)) *
728                         (1 - ($sumofmarkvariance / $k2));
729                 $quizstats->errorratio = 100 * sqrt(1 - ($quizstats->cic / 100));
730                 $quizstats->standarderror = $quizstats->errorratio *
731                         $quizstats->standarddeviation / 100;
732             }
733         }
735         $this->cache_stats(quiz_statistics_qubaids_condition($quizid, $currentgroup, $groupstudents, $useallattempts), $quizstats);
737         return array($s, $quizstats);
738     }
740     /**
741      * Load the cached statistics from the database.
742      *
743      * @param $qubaids qubaid_condition
744      * @return The statistics for overall attempt scores or false if not cached.
745      */
746     protected function get_cached_quiz_stats($qubaids) {
747         global $DB;
749         $timemodified = time() - self::TIME_TO_CACHE_STATS;
750         return  $DB->get_record_select('quiz_statistics', 'hashcode = ? AND timemodified > ?',
751                                        array($qubaids->get_hash_code(), $timemodified));
752     }
754     /**
755      * @param $qubaids    qubaid_condition
756      * @param $quizstats  object            the quiz stats to cache
757      */
758     protected function cache_stats($qubaids, $quizstats) {
759         global $DB;
761         $toinsert = clone($quizstats);
762         $toinsert->hashcode = $qubaids->get_hash_code();
763         $toinsert->timemodified = time();
765         // Fix up some dodgy data.
766         if (isset($toinsert->errorratio) && is_nan($toinsert->errorratio)) {
767             $toinsert->errorratio = null;
768         }
769         if (isset($toinsert->standarderror) && is_nan($toinsert->standarderror)) {
770             $toinsert->standarderror = null;
771         }
773         // Store the data.
774         $DB->insert_record('quiz_statistics', $toinsert);
776     }
778     /**
779      * Get the quiz and question statistics, either by loading the cached results,
780      * or by recomputing them.
781      *
782      * @param object $quiz the quiz settings.
783      * @param int $currentgroup the current group. 0 for none.
784      * @param bool $nostudentsingroup true if there a no students.
785      * @param bool $useallattempts use all attempts, or just first attempts.
786      * @param array $groupstudents students in this group.
787      * @param array $questions question definitions.
788      * @return array with 4 elements:
789      *     - $quizstats The statistics for overall attempt scores.
790      *     - $questions The questions, with an additional _stats field.
791      *     - $subquestions The subquestions, if any, with an additional _stats field.
792      *     - $s Number of attempts included in the stats.
793      */
794     protected function get_quiz_and_questions_stats($quiz, $currentgroup,
795             $nostudentsingroup, $useallattempts, $groupstudents, $questions) {
797         $qubaids = quiz_statistics_qubaids_condition($quiz->id, $currentgroup, $groupstudents, $useallattempts);
799         $quizstats = $this->get_cached_quiz_stats($qubaids);
801         $qstats = new question_statistics($questions);
803         if (empty($quizstats)) {
804             // Recalculate now.
805             $qstats->calculate($qubaids);
807             if ($nostudentsingroup) {
808                 list($s, $quizstats) = $this->get_empty_stats();
809             } else {
810                 list($s, $quizstats) = $this->calculate_quiz_stats($quiz->id, $currentgroup, $useallattempts,
811                                                            $groupstudents, count($questions), $qstats->get_sum_of_mark_variance());
812             }
814             $questions = $qstats->questions;
815             $subquestions = $qstats->subquestions;
817             if ($s) {
818                 $this->calculate_responses_for_all_questions_and_subquestions($qubaids, $questions, $subquestions);
819             }
820         } else {
821             if ($useallattempts) {
822                 $s = $quizstats->allattemptscount;
823             } else {
824                 $s = $quizstats->firstattemptscount;
825             }
826             $qstats->get_cached($qubaids);
827             $questions = $qstats->questions;
828             $subquestions = $qstats->subquestions;
830         }
832         return array($quizstats, $questions, $subquestions, $s);
833     }
835     protected function calculate_responses_for_all_questions_and_subquestions($qubaids, $questions, $subquestions) {
837         $done = array();
838         foreach ($questions as $question) {
839             if (!question_bank::get_qtype($question->qtype, false)->can_analyse_responses()) {
840                 continue;
841             }
842             $done[$question->id] = 1;
844             $responesstats = new question_response_analyser($question);
845             $responesstats->calculate($qubaids);
846         }
848         foreach ($subquestions as $question) {
849             if (!question_bank::get_qtype($question->qtype, false)->can_analyse_responses() ||
850                     isset($done[$question->id])) {
851                 continue;
852             }
853             $done[$question->id] = 1;
855             $responesstats = new question_response_analyser($question);
856             $responesstats->calculate($qubaids);
857         }
858     }
860     /**
861      * @return string HTML snipped for the Download full report as UI.
862      */
863     protected function everything_download_options() {
864         $downloadoptions = $this->table->get_download_menu();
866         $downloadelements = new stdClass();
867         $downloadelements->formatsmenu = html_writer::select($downloadoptions, 'download',
868                 $this->table->defaultdownloadformat, false);
869         $downloadelements->downloadbutton = '<input type="submit" value="' .
870                 get_string('download') . '"/>';
872         $output = '<form action="'. $this->table->baseurl .'" method="post">';
873         $output .= '<div class="mdl-align">';
874         $output .= '<input type="hidden" name="everything" value="1"/>';
875         $output .= html_writer::tag('label', get_string('downloadeverything', 'quiz_statistics', $downloadelements));
876         $output .= '</div></form>';
878         return $output;
879     }
881     /**
882      * Generate the snipped of HTML that says when the stats were last caculated,
883      * with a recalcuate now button.
884      * @param object $quizstats      the overall quiz statistics.
885      * @param int    $quizid         the quiz id.
886      * @param int    $currentgroup   the id of the currently selected group, or 0.
887      * @param array  $groupstudents  ids of students in the group.
888      * @param bool   $useallattempts whether to use all attempts, instead of just
889      *                               first attempts.
890      * @param moodle_url $reporturl url for this report
891      * @return string a HTML snipped saying when the stats were last computed,
892      *      or blank if that is not appropriate.
893      */
894     protected function output_caching_info($quizstats, $quizid, $currentgroup,
895             $groupstudents, $useallattempts, $reporturl) {
896         global $DB, $OUTPUT;
898         if (empty($quizstats->timemodified)) {
899             return '';
900         }
902         // Find the number of attempts since the cached statistics were computed.
903         list($fromqa, $whereqa, $qaparams) = quiz_statistics_attempts_sql(
904                 $quizid, $currentgroup, $groupstudents, $useallattempts, true);
905         $count = $DB->count_records_sql("
906                 SELECT COUNT(1)
907                 FROM $fromqa
908                 WHERE $whereqa
909                 AND quiza.timefinish > {$quizstats->timemodified}", $qaparams);
911         if (!$count) {
912             $count = 0;
913         }
915         // Generate the output.
916         $a = new stdClass();
917         $a->lastcalculated = format_time(time() - $quizstats->timemodified);
918         $a->count = $count;
920         $recalcualteurl = new moodle_url($reporturl,
921                 array('recalculate' => 1, 'sesskey' => sesskey()));
922         $output = '';
923         $output .= $OUTPUT->box_start(
924                 'boxaligncenter generalbox boxwidthnormal mdl-align', 'cachingnotice');
925         $output .= get_string('lastcalculated', 'quiz_statistics', $a);
926         $output .= $OUTPUT->single_button($recalcualteurl,
927                 get_string('recalculatenow', 'quiz_statistics'));
928         $output .= $OUTPUT->box_end(true);
930         return $output;
931     }
933     /**
934      * Clear the cached data for a particular report configuration. This will
935      * trigger a re-computation the next time the report is displayed.
936      * @param $qubaids qubaid_condition
937      */
938     protected function clear_cached_data($qubaids) {
939         global $DB;
940         $DB->delete_records('quiz_statistics', array('hashcode' => $qubaids->get_hash_code()));
941         $DB->delete_records('question_statistics', array('hashcode' => $qubaids->get_hash_code()));
942         $DB->delete_records('question_response_analysis', array('hashcode' => $qubaids->get_hash_code()));
943     }
945     /**
946      * @param bool $useallattempts whether we are using all attempts.
947      * @return the appropriate lang string to describe this option.
948      */
949     protected function using_attempts_string($useallattempts) {
950         if ($useallattempts) {
951             return get_string('allattempts', 'quiz_statistics');
952         } else {
953             return get_string('firstattempts', 'quiz_statistics');
954         }
955     }
957     /**
958      * @param object $quiz the quiz.
959      * @return array of questions for this quiz.
960      */
961     public function load_and_initialise_questions_for_calculations($quiz) {
962         // Load the questions.
963         $questions = quiz_report_get_significant_questions($quiz);
964         $questionids = array();
965         foreach ($questions as $question) {
966             $questionids[] = $question->id;
967         }
968         $fullquestions = question_load_questions($questionids);
969         foreach ($questions as $qno => $question) {
970             $q = $fullquestions[$question->id];
971             $q->maxmark = $question->maxmark;
972             $q->slot = $qno;
973             $q->number = $question->number;
974             $questions[$qno] = $q;
975         }
976         return $questions;
977     }