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