Proyectos de Subversion Moodle

Rev

Ir a la última revisión | | Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 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/>.
16
 
17
/**
18
 * Quiz statistics report class.
19
 *
20
 * @package   quiz_statistics
21
 * @copyright 2014 Open University
22
 * @author    James Pratt <me@jamiep.org>
23
 * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24
 */
25
 
26
defined('MOODLE_INTERNAL') || die();
27
 
28
use core_question\statistics\responses\analyser;
29
use mod_quiz\local\reports\report_base;
30
use core_question\statistics\questions\all_calculated_for_qubaid_condition;
31
 
32
require_once($CFG->dirroot . '/mod/quiz/report/reportlib.php');
33
require_once($CFG->dirroot . '/mod/quiz/report/statistics/statistics_form.php');
34
require_once($CFG->dirroot . '/mod/quiz/report/statistics/statistics_table.php');
35
require_once($CFG->dirroot . '/mod/quiz/report/statistics/statistics_question_table.php');
36
require_once($CFG->dirroot . '/mod/quiz/report/statistics/statisticslib.php');
37
 
38
/**
39
 * The quiz statistics report provides summary information about each question in
40
 * a quiz, compared to the whole quiz. It also provides a drill-down to more
41
 * detailed information about each question.
42
 *
43
 * @copyright 2008 Jamie Pratt
44
 * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
45
 */
46
class quiz_statistics_report extends report_base {
47
 
48
    /** @var context_module context of this quiz.*/
49
    protected $context;
50
 
51
    /** @var quiz_statistics_table instance of table class used for main questions stats table. */
52
    protected $table;
53
 
54
    /** @var \core\progress\base|null $progress Handles progress reporting or not. */
55
    protected $progress = null;
56
 
57
    /**
58
     * Display the report.
59
     */
60
    public function display($quiz, $cm, $course) {
61
        global $OUTPUT, $DB;
62
 
63
        raise_memory_limit(MEMORY_HUGE);
64
 
65
        $this->context = context_module::instance($cm->id);
66
 
67
        if (!quiz_has_questions($quiz->id)) {
68
            $this->print_header_and_tabs($cm, $course, $quiz, 'statistics');
69
            echo quiz_no_questions_message($quiz, $cm, $this->context);
70
            return true;
71
        }
72
 
73
        // Work out the display options.
74
        $download = optional_param('download', '', PARAM_ALPHA);
75
        $everything = optional_param('everything', 0, PARAM_BOOL);
76
        $recalculate = optional_param('recalculate', 0, PARAM_BOOL);
77
        // A qid paramter indicates we should display the detailed analysis of a sub question.
78
        $qid = optional_param('qid', 0, PARAM_INT);
79
        $slot = optional_param('slot', 0, PARAM_INT);
80
        $variantno = optional_param('variant', null, PARAM_INT);
81
        $whichattempts = optional_param('whichattempts', $quiz->grademethod, PARAM_INT);
82
        $whichtries = optional_param('whichtries', question_attempt::LAST_TRY, PARAM_ALPHA);
83
 
84
        $pageoptions = [];
85
        $pageoptions['id'] = $cm->id;
86
        $pageoptions['mode'] = 'statistics';
87
 
88
        $reporturl = new moodle_url('/mod/quiz/report.php', $pageoptions);
89
 
90
        $mform = new quiz_statistics_settings_form($reporturl, compact('quiz'));
91
 
92
        $mform->set_data(['whichattempts' => $whichattempts, 'whichtries' => $whichtries]);
93
 
94
        if ($whichattempts != $quiz->grademethod) {
95
            $reporturl->param('whichattempts', $whichattempts);
96
        }
97
 
98
        if ($whichtries != question_attempt::LAST_TRY) {
99
            $reporturl->param('whichtries', $whichtries);
100
        }
101
 
102
        // Find out current groups mode.
103
        $currentgroup = $this->get_current_group($cm, $course, $this->context);
104
        $nostudentsingroup = false; // True if a group is selected and there is no one in it.
105
        if (empty($currentgroup)) {
106
            $currentgroup = 0;
107
            $groupstudentsjoins = new \core\dml\sql_join();
108
 
109
        } else if ($currentgroup == self::NO_GROUPS_ALLOWED) {
110
            $groupstudentsjoins = new \core\dml\sql_join();
111
            $nostudentsingroup = true;
112
 
113
        } else {
114
            // All users who can attempt quizzes and who are in the currently selected group.
115
            $groupstudentsjoins = get_enrolled_with_capabilities_join($this->context, '',
116
                    ['mod/quiz:reviewmyattempts', 'mod/quiz:attempt'], $currentgroup);
117
            if (!empty($groupstudentsjoins->joins)) {
118
                $sql = "SELECT DISTINCT u.id
119
                    FROM {user} u
120
                    {$groupstudentsjoins->joins}
121
                    WHERE {$groupstudentsjoins->wheres}";
122
                if (!$DB->record_exists_sql($sql, $groupstudentsjoins->params)) {
123
                    $nostudentsingroup = true;
124
                }
125
            }
126
        }
127
 
128
        $qubaids = quiz_statistics_qubaids_condition($quiz->id, $groupstudentsjoins, $whichattempts);
129
 
130
        // If recalculate was requested, handle that.
131
        if ($recalculate && confirm_sesskey()) {
132
            $this->clear_cached_data($qubaids);
133
            redirect($reporturl);
134
        }
135
 
136
        // Set up the main table.
137
        $this->table = new quiz_statistics_table();
138
        if ($everything) {
139
            $report = get_string('completestatsfilename', 'quiz_statistics');
140
        } else {
141
            $report = get_string('questionstatsfilename', 'quiz_statistics');
142
        }
143
        $courseshortname = format_string($course->shortname, true,
144
                ['context' => context_course::instance($course->id)]);
145
        $filename = quiz_report_download_filename($report, $courseshortname, $quiz->name);
146
        $this->table->is_downloading($download, $filename,
147
                get_string('quizstructureanalysis', 'quiz_statistics'));
148
        $questions = $this->load_and_initialise_questions_for_calculations($quiz);
149
 
150
        // Print the page header stuff (if not downloading.
151
        if (!$this->table->is_downloading()) {
152
            $this->print_header_and_tabs($cm, $course, $quiz, 'statistics');
153
        }
154
 
155
        if (!$nostudentsingroup) {
156
            // Get the data to be displayed.
157
            $progress = $this->get_progress_trace_instance();
158
            list($quizstats, $questionstats) =
159
                $this->get_all_stats_and_analysis($quiz, $whichattempts, $whichtries, $groupstudentsjoins, $questions, $progress);
160
            if (is_null($quizstats)) {
161
                echo $OUTPUT->notification(get_string('nostats', 'quiz_statistics'), 'error');
162
                return true;
163
            }
164
        } else {
165
            // Or create empty stats containers.
166
            $quizstats = new \quiz_statistics\calculated($whichattempts);
167
            $questionstats = new \core_question\statistics\questions\all_calculated_for_qubaid_condition();
168
        }
169
 
170
        // Set up the table.
171
        $this->table->statistics_setup($quiz, $cm->id, $reporturl, $quizstats->s());
172
 
173
        // Print the rest of the page header stuff (if not downloading.
174
        if (!$this->table->is_downloading()) {
175
 
176
            if (groups_get_activity_groupmode($cm)) {
177
                groups_print_activity_menu($cm, $reporturl->out());
178
                if ($currentgroup && $nostudentsingroup) {
179
                    $OUTPUT->notification(get_string('nostudentsingroup', 'quiz_statistics'));
180
                }
181
            }
182
 
183
            if (!$this->table->is_downloading() && $quizstats->s() == 0) {
184
                echo $OUTPUT->notification(get_string('nogradedattempts', 'quiz_statistics'));
185
            }
186
 
187
            foreach ($questionstats->any_error_messages() as $errormessage) {
188
                echo $OUTPUT->notification($errormessage);
189
            }
190
 
191
            // Print display options form.
192
            $mform->display();
193
        }
194
 
195
        if ($everything) { // Implies is downloading.
196
            // Overall report, then the analysis of each question.
197
            $quizinfo = $quizstats->get_formatted_quiz_info_data($course, $cm, $quiz);
198
            $this->download_quiz_info_table($quizinfo);
199
 
200
            if ($quizstats->s()) {
201
                $this->output_quiz_structure_analysis_table($questionstats);
202
 
203
                if ($this->table->is_downloading() == 'html' && $quizstats->s() != 0) {
204
                    $this->output_statistics_graph($quiz, $qubaids);
205
                }
206
 
207
                $this->output_all_question_response_analysis($qubaids, $questions, $questionstats, $reporturl, $whichtries);
208
            }
209
 
210
            $this->table->export_class_instance()->finish_document();
211
 
212
        } else if ($qid) {
213
            // Report on an individual sub-question indexed questionid.
214
            if (!$questionstats->has_subq($qid, $variantno)) {
215
                throw new \moodle_exception('questiondoesnotexist', 'question');
216
            }
217
 
218
            $this->output_individual_question_data($quiz, $questionstats->for_subq($qid, $variantno));
219
            $this->output_individual_question_response_analysis($questionstats->for_subq($qid, $variantno)->question,
220
                                                                $variantno,
221
                                                                $questionstats->for_subq($qid, $variantno)->s,
222
                                                                $reporturl,
223
                                                                $qubaids,
224
                                                                $whichtries);
225
            // Back to overview link.
226
            echo $OUTPUT->box('<a href="' . $reporturl->out() . '">' .
227
                              get_string('backtoquizreport', 'quiz_statistics') . '</a>',
228
                              'boxaligncenter generalbox boxwidthnormal mdl-align');
229
        } else if ($slot) {
230
            // Report on an individual question indexed by position.
231
            if (!isset($questions[$slot])) {
232
                throw new \moodle_exception('questiondoesnotexist', 'question');
233
            }
234
 
235
            if ($variantno === null &&
236
                                ($questionstats->for_slot($slot)->get_sub_question_ids()
237
                                || $questionstats->for_slot($slot)->get_variants())) {
238
                if (!$this->table->is_downloading()) {
239
                    $number = $questionstats->for_slot($slot)->question->number;
240
                    echo $OUTPUT->heading(get_string('slotstructureanalysis', 'quiz_statistics', $number), 3);
241
                }
242
                $this->table->define_baseurl(new moodle_url($reporturl, ['slot' => $slot]));
243
                $this->table->format_and_add_array_of_rows($questionstats->structure_analysis_for_one_slot($slot));
244
            } else {
245
                $this->output_individual_question_data($quiz, $questionstats->for_slot($slot, $variantno));
246
                $this->output_individual_question_response_analysis($questions[$slot],
247
                                                                    $variantno,
248
                                                                    $questionstats->for_slot($slot, $variantno)->s,
249
                                                                    $reporturl,
250
                                                                    $qubaids,
251
                                                                    $whichtries);
252
            }
253
            if (!$this->table->is_downloading()) {
254
                // Back to overview link.
255
                echo $OUTPUT->box('<a href="' . $reporturl->out() . '">' .
256
                        get_string('backtoquizreport', 'quiz_statistics') . '</a>',
257
                        'backtomainstats boxaligncenter generalbox boxwidthnormal mdl-align');
258
            } else {
259
                $this->table->finish_output();
260
            }
261
 
262
        } else if ($this->table->is_downloading()) {
263
            // Downloading overview report.
264
            $quizinfo = $quizstats->get_formatted_quiz_info_data($course, $cm, $quiz);
265
            $this->download_quiz_info_table($quizinfo);
266
            if ($quizstats->s()) {
267
                $this->output_quiz_structure_analysis_table($questionstats);
268
            }
269
            $this->table->export_class_instance()->finish_document();
270
 
271
        } else {
272
            // On-screen display of overview report.
273
            echo $OUTPUT->heading(get_string('quizinformation', 'quiz_statistics'), 3);
274
            echo $this->output_caching_info($quizstats->timemodified, $quiz->id, $groupstudentsjoins, $whichattempts, $reporturl);
275
            echo $this->everything_download_options($reporturl);
276
            $quizinfo = $quizstats->get_formatted_quiz_info_data($course, $cm, $quiz);
277
            echo $this->output_quiz_info_table($quizinfo);
278
            if ($quizstats->s()) {
279
                echo $OUTPUT->heading(get_string('quizstructureanalysis', 'quiz_statistics'), 3);
280
                $this->output_quiz_structure_analysis_table($questionstats);
281
                $this->output_statistics_graph($quiz, $qubaids);
282
            }
283
        }
284
 
285
        return true;
286
    }
287
 
288
    /**
289
     * Display the statistical and introductory information about a question.
290
     * Only called when not downloading.
291
     *
292
     * @param stdClass                                         $quiz         the quiz settings.
293
     * @param \core_question\statistics\questions\calculated $questionstat the question to report on.
294
     */
295
    protected function output_individual_question_data($quiz, $questionstat) {
296
        global $OUTPUT;
297
 
298
        // On-screen display. Show a summary of the question's place in the quiz,
299
        // and the question statistics.
300
        $datumfromtable = $this->table->format_row($questionstat);
301
 
302
        // Set up the question info table.
303
        $questioninfotable = new html_table();
304
        $questioninfotable->align = ['center', 'center'];
305
        $questioninfotable->width = '60%';
306
        $questioninfotable->attributes['class'] = 'generaltable titlesleft';
307
 
308
        $questioninfotable->data = [];
309
        $questioninfotable->data[] = [get_string('modulename', 'quiz'), $quiz->name];
310
        $questioninfotable->data[] = [get_string('questionname', 'quiz_statistics'),
311
                $questionstat->question->name.'&nbsp;'.$datumfromtable['actions']];
312
 
313
        if ($questionstat->variant !== null) {
314
            $questioninfotable->data[] = [get_string('variant', 'quiz_statistics'), $questionstat->variant];
315
 
316
        }
317
        $questioninfotable->data[] = [get_string('questiontype', 'quiz_statistics'),
318
                $datumfromtable['icon'] . '&nbsp;' .
319
                question_bank::get_qtype($questionstat->question->qtype, false)->menu_name() . '&nbsp;' .
320
                $datumfromtable['icon']];
321
        $questioninfotable->data[] = [get_string('positions', 'quiz_statistics'),
322
                $questionstat->positions];
323
 
324
        // Set up the question statistics table.
325
        $questionstatstable = new html_table();
326
        $questionstatstable->align = ['center', 'center'];
327
        $questionstatstable->width = '60%';
328
        $questionstatstable->attributes['class'] = 'generaltable titlesleft';
329
 
330
        unset($datumfromtable['number']);
331
        unset($datumfromtable['icon']);
332
        $actions = $datumfromtable['actions'];
333
        unset($datumfromtable['actions']);
334
        unset($datumfromtable['name']);
335
        $labels = [
336
            's' => get_string('attempts', 'quiz_statistics'),
337
            'facility' => get_string('facility', 'quiz_statistics'),
338
            'sd' => get_string('standarddeviationq', 'quiz_statistics'),
339
            'random_guess_score' => get_string('random_guess_score', 'quiz_statistics'),
340
            'intended_weight' => get_string('intended_weight', 'quiz_statistics'),
341
            'effective_weight' => get_string('effective_weight', 'quiz_statistics'),
342
            'discrimination_index' => get_string('discrimination_index', 'quiz_statistics'),
343
            'discriminative_efficiency' =>
344
                                get_string('discriminative_efficiency', 'quiz_statistics')
345
        ];
346
        foreach ($datumfromtable as $item => $value) {
347
            $questionstatstable->data[] = [$labels[$item], $value];
348
        }
349
 
350
        // Display the various bits.
351
        echo $OUTPUT->heading(get_string('questioninformation', 'quiz_statistics'), 3);
352
        echo html_writer::table($questioninfotable);
353
        echo $this->render_question_text($questionstat->question);
354
        echo $OUTPUT->heading(get_string('questionstatistics', 'quiz_statistics'), 3);
355
        echo html_writer::table($questionstatstable);
356
    }
357
 
358
    /**
359
     * Output question text in a box with urls appropriate for a preview of the question.
360
     *
361
     * @param stdClass $question question data.
362
     * @return string HTML of question text, ready for display.
363
     */
364
    protected function render_question_text($question) {
365
        global $OUTPUT;
366
 
367
        $text = question_rewrite_question_preview_urls($question->questiontext, $question->id,
368
                $question->contextid, 'question', 'questiontext', $question->id,
369
                $this->context->id, 'quiz_statistics');
370
 
371
        return $OUTPUT->box(format_text($text, $question->questiontextformat,
372
                ['noclean' => true, 'para' => false, 'overflowdiv' => true]),
373
                'questiontext boxaligncenter generalbox boxwidthnormal mdl-align');
374
    }
375
 
376
    /**
377
     * Display the response analysis for a question.
378
     *
379
     * @param stdClass           $question  the question to report on.
380
     * @param int|null         $variantno the variant
381
     * @param int              $s
382
     * @param moodle_url       $reporturl the URL to redisplay this report.
383
     * @param qubaid_condition $qubaids
384
     * @param string           $whichtries
385
     */
386
    protected function output_individual_question_response_analysis($question, $variantno, $s, $reporturl, $qubaids,
387
                                                                    $whichtries = question_attempt::LAST_TRY) {
388
        global $OUTPUT;
389
 
390
        if (!question_bank::get_qtype($question->qtype, false)->can_analyse_responses()) {
391
            return;
392
        }
393
 
394
        $qtable = new quiz_statistics_question_table($question->id);
395
        $exportclass = $this->table->export_class_instance();
396
        $qtable->export_class_instance($exportclass);
397
        if (!$this->table->is_downloading()) {
398
            // Output an appropriate title.
399
            echo $OUTPUT->heading(get_string('analysisofresponses', 'quiz_statistics'), 3);
400
 
401
        } else {
402
            // Work out an appropriate title.
403
            $a = clone($question);
404
            $a->variant = $variantno;
405
 
406
            if (!empty($question->number) && !is_null($variantno)) {
407
                $questiontabletitle = get_string('analysisnovariant', 'quiz_statistics', $a);
408
            } else if (!empty($question->number)) {
409
                $questiontabletitle = get_string('analysisno', 'quiz_statistics', $a);
410
            } else if (!is_null($variantno)) {
411
                $questiontabletitle = get_string('analysisvariant', 'quiz_statistics', $a);
412
            } else {
413
                $questiontabletitle = get_string('analysisnameonly', 'quiz_statistics', $a);
414
            }
415
 
416
            if ($this->table->is_downloading() == 'html') {
417
                $questiontabletitle = get_string('analysisofresponsesfor', 'quiz_statistics', $questiontabletitle);
418
            }
419
 
420
            // Set up the table.
421
            $exportclass->start_table($questiontabletitle);
422
 
423
            if ($this->table->is_downloading() == 'html') {
424
                echo $this->render_question_text($question);
425
            }
426
        }
427
 
428
        $responesanalyser = new analyser($question, $whichtries);
429
        $responseanalysis = $responesanalyser->load_cached($qubaids, $whichtries);
430
 
431
        $qtable->question_setup($reporturl, $question, $s, $responseanalysis);
432
        if ($this->table->is_downloading()) {
433
            $exportclass->output_headers($qtable->headers);
434
        }
435
 
436
        // Where no variant no is specified the variant no is actually one.
437
        if ($variantno === null) {
438
            $variantno = 1;
439
        }
440
        foreach ($responseanalysis->get_subpart_ids($variantno) as $partid) {
441
            $subpart = $responseanalysis->get_analysis_for_subpart($variantno, $partid);
442
            foreach ($subpart->get_response_class_ids() as $responseclassid) {
443
                $responseclass = $subpart->get_response_class($responseclassid);
444
                $tabledata = $responseclass->data_for_question_response_table($subpart->has_multiple_response_classes(), $partid);
445
                foreach ($tabledata as $row) {
446
                    $qtable->add_data_keyed($qtable->format_row($row));
447
                }
448
            }
449
        }
450
 
451
        $qtable->finish_output(!$this->table->is_downloading());
452
    }
453
 
454
    /**
455
     * Output the table that lists all the questions in the quiz with their statistics.
456
     *
457
     * @param \core_question\statistics\questions\all_calculated_for_qubaid_condition $questionstats the stats for all questions in
458
     *                                                                                               the quiz including subqs and
459
     *                                                                                               variants.
460
     */
461
    protected function output_quiz_structure_analysis_table($questionstats) {
462
        $limitvariants = !$this->table->is_downloading();
463
        foreach ($questionstats->get_all_slots() as $slot) {
464
            // Output the data for these question statistics.
465
            $structureanalysis = $questionstats->structure_analysis_for_one_slot($slot, $limitvariants);
466
            if (is_null($structureanalysis)) {
467
                $this->table->add_separator();
468
            } else {
469
                foreach ($structureanalysis as $row) {
470
                    $bgcssclass = '';
471
                    // The only way to identify in this point of the report if a row is a summary row
472
                    // is checking if it's a instance of calculated_question_summary class.
473
                    if ($row instanceof \core_question\statistics\questions\calculated_question_summary) {
474
                        // Apply a custom css class to summary row to remove border and reduce paddings.
475
                        $bgcssclass = 'quiz_statistics-summaryrow';
476
 
477
                        // For question that contain a summary row, we add a "hidden" row in between so the report
478
                        // display both rows with same background color.
479
                        $this->table->add_data_keyed([], 'd-none hidden');
480
                    }
481
 
482
                    $this->table->add_data_keyed($this->table->format_row($row), $bgcssclass);
483
                }
484
            }
485
        }
486
 
487
        $this->table->finish_output(!$this->table->is_downloading());
488
    }
489
 
490
    /**
491
     * Return HTML for table of overall quiz statistics.
492
     *
493
     * @param array $quizinfo as returned by {@link get_formatted_quiz_info_data()}.
494
     * @return string the HTML.
495
     */
496
    protected function output_quiz_info_table($quizinfo) {
497
 
498
        $quizinfotable = new html_table();
499
        $quizinfotable->align = ['center', 'center'];
500
        $quizinfotable->width = '60%';
501
        $quizinfotable->attributes['class'] = 'generaltable titlesleft';
502
        $quizinfotable->data = [];
503
 
504
        foreach ($quizinfo as $heading => $value) {
505
             $quizinfotable->data[] = [$heading, $value];
506
        }
507
 
508
        return html_writer::table($quizinfotable);
509
    }
510
 
511
    /**
512
     * Download the table of overall quiz statistics.
513
     *
514
     * @param array $quizinfo as returned by {@link get_formatted_quiz_info_data()}.
515
     */
516
    protected function download_quiz_info_table($quizinfo) {
517
        global $OUTPUT;
518
 
519
        // HTML download is a special case.
520
        if ($this->table->is_downloading() == 'html') {
521
            echo $OUTPUT->heading(get_string('quizinformation', 'quiz_statistics'), 3);
522
            echo $this->output_quiz_info_table($quizinfo);
523
            return;
524
        }
525
 
526
        // Reformat the data ready for output.
527
        $headers = [];
528
        $row = [];
529
        foreach ($quizinfo as $heading => $value) {
530
            $headers[] = $heading;
531
            $row[] = $value;
532
        }
533
 
534
        // Do the output.
535
        $exportclass = $this->table->export_class_instance();
536
        $exportclass->start_table(get_string('quizinformation', 'quiz_statistics'));
537
        $exportclass->output_headers($headers);
538
        $exportclass->add_data($row);
539
        $exportclass->finish_table();
540
    }
541
 
542
    /**
543
     * Output the HTML needed to show the statistics graph.
544
     *
545
     * @param stdClass $quiz the quiz.
546
     * @param qubaid_condition $qubaids the question usages whose responses to analyse.
547
     */
548
    protected function output_statistics_graph($quiz, $qubaids) {
549
        global $DB, $PAGE;
550
 
551
        // Load the rest of the required data.
552
        $questions = quiz_report_get_significant_questions($quiz);
553
 
554
        // Only load main question not sub questions.
555
        $questionstatistics = $DB->get_records_select('question_statistics',
556
                'hashcode = ? AND slot IS NOT NULL AND variant IS NULL',
557
            [$qubaids->get_hash_code()]);
558
 
559
        // Configure what to display.
560
        $fieldstoplot = [
561
            'facility' => get_string('facility', 'quiz_statistics'),
562
            'discriminativeefficiency' => get_string('discriminative_efficiency', 'quiz_statistics')
563
        ];
564
        $fieldstoplotfactor = ['facility' => 100, 'discriminativeefficiency' => 1];
565
 
566
        // Prepare the arrays to hold the data.
567
        $xdata = [];
568
        foreach (array_keys($fieldstoplot) as $fieldtoplot) {
569
            $ydata[$fieldtoplot] = [];
570
        }
571
 
572
        // Fill in the data for each question.
573
        foreach ($questionstatistics as $questionstatistic) {
574
            $number = $questions[$questionstatistic->slot]->number;
575
            $xdata[$number] = $number;
576
 
577
            foreach ($fieldstoplot as $fieldtoplot => $notused) {
578
                $value = $questionstatistic->$fieldtoplot;
579
                if (is_null($value)) {
580
                    $value = 0;
581
                }
582
                $value *= $fieldstoplotfactor[$fieldtoplot];
583
                $ydata[$fieldtoplot][$number] = number_format($value, 2);
584
            }
585
        }
586
 
587
        // Create the chart.
588
        sort($xdata);
589
        $chart = new \core\chart_bar();
590
        $chart->get_xaxis(0, true)->set_label(get_string('position', 'quiz_statistics'));
591
        $chart->set_labels(array_values($xdata));
592
 
593
        foreach ($fieldstoplot as $fieldtoplot => $notused) {
594
            ksort($ydata[$fieldtoplot]);
595
            $series = new \core\chart_series($fieldstoplot[$fieldtoplot], array_values($ydata[$fieldtoplot]));
596
            $chart->add_series($series);
597
        }
598
 
599
        // Find max.
600
        $max = 0;
601
        foreach ($fieldstoplot as $fieldtoplot => $notused) {
602
            $max = max($max, max($ydata[$fieldtoplot]));
603
        }
604
 
605
        // Set Y properties.
606
        $yaxis = $chart->get_yaxis(0, true);
607
        $yaxis->set_stepsize(10);
608
        $yaxis->set_label('%');
609
 
610
        $output = $PAGE->get_renderer('mod_quiz');
611
        $graphname = get_string('statisticsreportgraph', 'quiz_statistics');
612
        echo $output->chart($chart, $graphname);
613
    }
614
 
615
    /**
616
     * Get the quiz and question statistics, either by loading the cached results,
617
     * or by recomputing them.
618
     *
619
     * @param stdClass $quiz               the quiz settings.
620
     * @param string $whichattempts      which attempts to use, represented internally as one of the constants as used in
621
     *                                   $quiz->grademethod ie.
622
     *                                   QUIZ_GRADEAVERAGE, QUIZ_GRADEHIGHEST, QUIZ_ATTEMPTLAST or QUIZ_ATTEMPTFIRST
623
     *                                   we calculate stats based on which attempts would affect the grade for each student.
624
     * @param string $whichtries         which tries to analyse for response analysis. Will be one of
625
     *                                   question_attempt::FIRST_TRY, LAST_TRY or ALL_TRIES.
626
     * @param \core\dml\sql_join $groupstudentsjoins Contains joins, wheres, params for students in this group.
627
     * @param array  $questions          full question data.
628
     * @param \core\progress\base|null   $progress
629
     * @param bool $calculateifrequired  if true (the default) the stats will be calculated if not already stored.
630
     *                                   If false, [null, null] will be returned if the stats are not already available.
631
     * @param bool $performanalysis      if true (the default) and there are calculated stats, analysis will be performed
632
     *                                   for each question.
633
     * @return array with 2 elements:    - $quizstats The statistics for overall attempt scores.
634
     *                                   - $questionstats \core_question\statistics\questions\all_calculated_for_qubaid_condition
635
     *                                   Both may be null, if $calculateifrequired is false.
636
     */
637
    public function get_all_stats_and_analysis(
638
            $quiz, $whichattempts, $whichtries, \core\dml\sql_join $groupstudentsjoins,
639
            $questions, $progress = null, bool $calculateifrequired = true, bool $performanalysis = true) {
640
 
641
        if ($progress === null) {
642
            $progress = new \core\progress\none();
643
        }
644
 
645
        $qubaids = quiz_statistics_qubaids_condition($quiz->id, $groupstudentsjoins, $whichattempts);
646
 
647
        $qcalc = new \core_question\statistics\questions\calculator($questions, $progress);
648
 
649
        $quizcalc = new \quiz_statistics\calculator($progress);
650
 
651
        $progress->start_progress('', 4);
652
 
653
        // Get a lock on this set of qubaids before performing calculations. This prevents the same calculation running
654
        // concurrently and causing database deadlocks. We use a long timeout here as a big quiz with lots of attempts may
655
        // take a long time to process.
656
        $lockfactory = \core\lock\lock_config::get_lock_factory('quiz_statistics_get_stats');
657
        $lock = $lockfactory->get_lock($qubaids->get_hash_code(), 0);
658
        if (!$lock) {
659
            if (!$calculateifrequired) {
660
                // We're not going to do the calculation in this request anyway, so just give up here.
661
                $progress->progress(4);
662
                $progress->end_progress();
663
                return [null, null];
664
            }
665
            $locktimeout = get_config('quiz_statistics', 'getstatslocktimeout');
666
            $lock = \core\lock\lock_utils::wait_for_lock_with_progress(
667
                $lockfactory,
668
                $qubaids->get_hash_code(),
669
                $progress,
670
                $locktimeout,
671
                get_string('getstatslockprogress', 'quiz_statistics'),
672
            );
673
            if (!$lock) {
674
                // Lock attempt timed out.
675
                $progress->progress(4);
676
                $progress->end_progress();
677
                debugging('Could not get lock on ' .
678
                        $qubaids->get_hash_code() . ' (Quiz ID ' . $quiz->id . ') after ' .
679
                        $locktimeout . ' seconds');
680
                return [null, null];
681
            }
682
        }
683
 
684
        try {
685
            if ($quizcalc->get_last_calculated_time($qubaids) === false) {
686
                if (!$calculateifrequired) {
687
                    $progress->progress(4);
688
                    $progress->end_progress();
689
                    $lock->release();
690
                    return [null, null];
691
                }
692
 
693
                // Recalculate now.
694
                $questionstats = $qcalc->calculate($qubaids);
695
                $progress->progress(2);
696
 
697
                $quizstats = $quizcalc->calculate(
698
                    $quiz->id,
699
                    $whichattempts,
700
                    $groupstudentsjoins,
701
                    count($questions),
702
                    $qcalc->get_sum_of_mark_variance()
703
                );
704
                $progress->progress(3);
705
            } else {
706
                $quizstats = $quizcalc->get_cached($qubaids);
707
                $progress->progress(2);
708
                $questionstats = $qcalc->get_cached($qubaids);
709
                $progress->progress(3);
710
            }
711
 
712
            if ($quizstats->s() && $performanalysis) {
713
                $subquestions = $questionstats->get_sub_questions();
714
                $this->analyse_responses_for_all_questions_and_subquestions(
715
                    $questions,
716
                    $subquestions,
717
                    $qubaids,
718
                    $whichtries,
719
                    $progress
720
                );
721
            }
722
            $progress->progress(4);
723
            $progress->end_progress();
724
        } finally {
725
            $lock->release();
726
        }
727
 
728
        return [$quizstats, $questionstats];
729
    }
730
 
731
    /**
732
     * Appropriate instance depending if we want html output for the user or not.
733
     *
734
     * @return \core\progress\base child of \core\progress\base to handle the display (or not) of task progress.
735
     */
736
    protected function get_progress_trace_instance() {
737
        if ($this->progress === null) {
738
            if (!$this->table->is_downloading()) {
739
                $this->progress = new \core\progress\display_if_slow(get_string('calculatingallstats', 'quiz_statistics'));
740
                $this->progress->set_display_names();
741
            } else {
742
                $this->progress = new \core\progress\none();
743
            }
744
        }
745
        return $this->progress;
746
    }
747
 
748
    /**
749
     * Analyse responses for all questions and sub questions in this quiz.
750
     *
751
     * @param stdClass[] $questions as returned by self::load_and_initialise_questions_for_calculations
752
     * @param stdClass[] $subquestions full question objects.
753
     * @param qubaid_condition $qubaids the question usages whose responses to analyse.
754
     * @param string $whichtries which tries to analyse \question_attempt::FIRST_TRY, LAST_TRY or ALL_TRIES.
755
     * @param null|\core\progress\base $progress Used to indicate progress of task.
756
     */
757
    protected function analyse_responses_for_all_questions_and_subquestions($questions, $subquestions, $qubaids,
758
                                                                            $whichtries, $progress = null) {
759
        if ($progress === null) {
760
            $progress = new \core\progress\none();
761
        }
762
 
763
        // Starting response analysis tasks.
764
        $progress->start_progress('', count($questions) + count($subquestions));
765
 
766
        $done = $this->analyse_responses_for_questions($questions, $qubaids, $whichtries, $progress);
767
 
768
        $this->analyse_responses_for_questions($subquestions, $qubaids, $whichtries, $progress, $done);
769
 
770
        // Finished all response analysis tasks.
771
        $progress->end_progress();
772
    }
773
 
774
    /**
775
     * Analyse responses for an array of questions or sub questions.
776
     *
777
     * @param stdClass[] $questions  as returned by self::load_and_initialise_questions_for_calculations.
778
     * @param qubaid_condition $qubaids the question usages whose responses to analyse.
779
     * @param string $whichtries which tries to analyse \question_attempt::FIRST_TRY, LAST_TRY or ALL_TRIES.
780
     * @param null|\core\progress\base $progress Used to indicate progress of task.
781
     * @param int[] $done array keys are ids of questions that have been analysed before calling method.
782
     * @return array array keys are ids of questions that were analysed after this method call.
783
     */
784
    protected function analyse_responses_for_questions($questions, $qubaids, $whichtries, $progress = null, $done = []) {
785
        $countquestions = count($questions);
786
        if (!$countquestions) {
787
            return [];
788
        }
789
        if ($progress === null) {
790
            $progress = new \core\progress\none();
791
        }
792
        $progress->start_progress('', $countquestions, $countquestions);
793
        foreach ($questions as $question) {
794
            $progress->increment_progress();
795
            if (question_bank::get_qtype($question->qtype, false)->can_analyse_responses()  && !isset($done[$question->id])) {
796
                $responesstats = new analyser($question, $whichtries);
797
                $responesstats->calculate($qubaids, $whichtries);
798
            }
799
            $done[$question->id] = 1;
800
        }
801
        $progress->end_progress();
802
        return $done;
803
    }
804
 
805
    /**
806
     * Return a little form for the user to request to download the full report, including quiz stats and response analysis for
807
     * all questions and sub-questions.
808
     *
809
     * @param moodle_url $reporturl the base URL of the report.
810
     * @return string HTML.
811
     */
812
    protected function everything_download_options(moodle_url $reporturl) {
813
        global $OUTPUT;
814
        return $OUTPUT->download_dataformat_selector(get_string('downloadeverything', 'quiz_statistics'),
815
            $reporturl->out_omit_querystring(), 'download', $reporturl->params() + ['everything' => 1]);
816
    }
817
 
818
    /**
819
     * Return HTML for a message that says when the stats were last calculated and a 'recalculate now' button.
820
     *
821
     * @param int    $lastcachetime  the time the stats were last cached.
822
     * @param int    $quizid         the quiz id.
823
     * @param \core\dml\sql_join $groupstudentsjoins (joins, wheres, params) for students in the group
824
     *                                   or empty array if groups not used.
825
     * @param string $whichattempts which attempts to use, represented internally as one of the constants as used in
826
     *                                   $quiz->grademethod ie.
827
     *                                   QUIZ_GRADEAVERAGE, QUIZ_GRADEHIGHEST, QUIZ_ATTEMPTLAST or QUIZ_ATTEMPTFIRST
828
     *                                   we calculate stats based on which attempts would affect the grade for each student.
829
     * @param moodle_url $reporturl url for this report
830
     * @return string HTML.
831
     */
832
    protected function output_caching_info($lastcachetime, $quizid, $groupstudentsjoins, $whichattempts, $reporturl) {
833
        global $DB, $OUTPUT;
834
 
835
        if (empty($lastcachetime)) {
836
            return '';
837
        }
838
 
839
        // Find the number of attempts since the cached statistics were computed.
840
        list($fromqa, $whereqa, $qaparams) = quiz_statistics_attempts_sql($quizid, $groupstudentsjoins, $whichattempts, true);
841
        $count = $DB->count_records_sql("
842
                SELECT COUNT(1)
843
                FROM $fromqa
844
                WHERE $whereqa
845
                AND quiza.timefinish > {$lastcachetime}", $qaparams);
846
 
847
        if (!$count) {
848
            $count = 0;
849
        }
850
 
851
        // Generate the output.
852
        $a = new stdClass();
853
        $a->lastcalculated = format_time(time() - $lastcachetime);
854
        $a->count = $count;
855
 
856
        $recalcualteurl = new moodle_url($reporturl,
857
                ['recalculate' => 1, 'sesskey' => sesskey()]);
858
        $output = '';
859
        $output .= $OUTPUT->box_start(
860
                'boxaligncenter generalbox boxwidthnormal mdl-align', 'cachingnotice');
861
        $output .= get_string('lastcalculated', 'quiz_statistics', $a);
862
        $output .= $OUTPUT->single_button($recalcualteurl,
863
                get_string('recalculatenow', 'quiz_statistics'));
864
        $output .= $OUTPUT->box_end(true);
865
 
866
        return $output;
867
    }
868
 
869
    /**
870
     * Clear the cached data for a particular report configuration. This will trigger a re-computation the next time the report
871
     * is displayed.
872
     *
873
     * @param $qubaids qubaid_condition
874
     */
875
    public function clear_cached_data($qubaids) {
876
        global $DB;
877
        $DB->delete_records('quiz_statistics', ['hashcode' => $qubaids->get_hash_code()]);
878
        $DB->delete_records('question_statistics', ['hashcode' => $qubaids->get_hash_code()]);
879
        $DB->delete_records('question_response_analysis', ['hashcode' => $qubaids->get_hash_code()]);
880
    }
881
 
882
    /**
883
     * Load the questions in this quiz and add some properties to the objects needed in the reports.
884
     *
885
     * @param stdClass $quiz the quiz.
886
     * @return array of questions for this quiz.
887
     */
888
    public function load_and_initialise_questions_for_calculations($quiz) {
889
        // Load the questions.
890
        $questions = quiz_report_get_significant_questions($quiz);
891
        $questiondata = [];
892
        foreach ($questions as $qs => $question) {
893
            if ($question->qtype === 'random') {
894
                $question->id = 0;
895
                $question->name = get_string('random', 'quiz');
896
                $question->questiontext = get_string('random', 'quiz');
897
                $question->parenttype = 'random';
898
                $questiondata[$question->slot] = $question;
899
            } else if ($question->qtype === 'missingtype') {
900
                $question->id = is_numeric($question->id) ? (int) $question->id : 0;
901
                $questiondata[$question->slot] = $question;
902
                $question->name = get_string('deletedquestion', 'qtype_missingtype');
903
                $question->questiontext = get_string('deletedquestiontext', 'qtype_missingtype');
904
            } else {
905
                $q = question_bank::load_question_data($question->id);
906
                $q->maxmark = $question->maxmark;
907
                $q->slot = $question->slot;
908
                $q->number = $question->number;
909
                $q->parenttype = null;
910
                $questiondata[$question->slot] = $q;
911
            }
912
        }
913
 
914
        return $questiondata;
915
    }
916
 
917
    /**
918
     * Output all response analysis for all questions, sub-questions and variants. For download in a number of formats.
919
     *
920
     * @param $qubaids
921
     * @param $questions
922
     * @param $questionstats
923
     * @param $reporturl
924
     * @param $whichtries string
925
     */
926
    protected function output_all_question_response_analysis($qubaids,
927
                                                             $questions,
928
                                                             $questionstats,
929
                                                             $reporturl,
930
                                                             $whichtries = question_attempt::LAST_TRY) {
931
        foreach ($questions as $slot => $question) {
932
            if (question_bank::get_qtype(
933
                $question->qtype, false)->can_analyse_responses()
934
            ) {
935
                if ($questionstats->for_slot($slot)->get_variants()) {
936
                    foreach ($questionstats->for_slot($slot)->get_variants() as $variantno) {
937
                        $this->output_individual_question_response_analysis($question,
938
                                                                            $variantno,
939
                                                                            $questionstats->for_slot($slot, $variantno)->s,
940
                                                                            $reporturl,
941
                                                                            $qubaids,
942
                                                                            $whichtries);
943
                    }
944
                } else {
945
                    $this->output_individual_question_response_analysis($question,
946
                                                                        null,
947
                                                                        $questionstats->for_slot($slot)->s,
948
                                                                        $reporturl,
949
                                                                        $qubaids,
950
                                                                        $whichtries);
951
                }
952
            } else if ($subqids = $questionstats->for_slot($slot)->get_sub_question_ids()) {
953
                foreach ($subqids as $subqid) {
954
                    if ($variants = $questionstats->for_subq($subqid)->get_variants()) {
955
                        foreach ($variants as $variantno) {
956
                            $this->output_individual_question_response_analysis(
957
                                $questionstats->for_subq($subqid, $variantno)->question,
958
                                $variantno,
959
                                $questionstats->for_subq($subqid, $variantno)->s,
960
                                $reporturl,
961
                                $qubaids,
962
                                $whichtries);
963
                        }
964
                    } else {
965
                        $this->output_individual_question_response_analysis(
966
                            $questionstats->for_subq($subqid)->question,
967
                            null,
968
                            $questionstats->for_subq($subqid)->s,
969
                            $reporturl,
970
                            $qubaids,
971
                            $whichtries);
972
 
973
                    }
974
                }
975
            }
976
        }
977
    }
978
 
979
    /**
980
     * Load question stats for a quiz
981
     *
982
     * @param int $quizid question usage
983
     * @param bool $calculateifrequired if true (the default) the stats will be calculated if not already stored.
984
     *     If false, null will be returned if the stats are not already available.
985
     * @param bool $performanalysis if true (the default) and there are calculated stats, analysis will be performed
986
     *     for each question.
987
     * @return ?all_calculated_for_qubaid_condition question stats
988
     */
989
    public function calculate_questions_stats_for_question_bank(
990
            int $quizid,
991
            bool $calculateifrequired = true,
992
            bool $performanalysis = true,
993
        ): ?all_calculated_for_qubaid_condition {
994
        global $DB;
995
        $quiz = $DB->get_record('quiz', ['id' => $quizid], '*', MUST_EXIST);
996
        $questions = $this->load_and_initialise_questions_for_calculations($quiz);
997
 
998
        [, $questionstats] = $this->get_all_stats_and_analysis($quiz,
999
            $quiz->grademethod, question_attempt::ALL_TRIES, new \core\dml\sql_join(),
1000
            $questions, null, $calculateifrequired, $performanalysis);
1001
 
1002
        return $questionstats;
1003
    }
1004
}