Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 4.1.x will end 13 November 2023 (12 months).
  • Bug fixes for security issues in 4.1.x will end 10 November 2025 (36 months).
  • PHP version: minimum PHP 7.4.0 Note: minimum PHP version has increased since Moodle 4.0. PHP 8.0.x is supported too.
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.

/**
 * Quiz statistics report class.
 *
 * @package   quiz_statistics
 * @copyright 2014 Open University
 * @author    James Pratt <me@jamiep.org>
 * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */

defined('MOODLE_INTERNAL') || die();

use core_question\statistics\responses\analyser;
use core_question\statistics\questions\all_calculated_for_qubaid_condition;

require_once($CFG->dirroot . '/mod/quiz/report/default.php');
require_once($CFG->dirroot . '/mod/quiz/report/reportlib.php');
require_once($CFG->dirroot . '/mod/quiz/report/statistics/statistics_form.php');
require_once($CFG->dirroot . '/mod/quiz/report/statistics/statistics_table.php');
require_once($CFG->dirroot . '/mod/quiz/report/statistics/statistics_question_table.php');
require_once($CFG->dirroot . '/mod/quiz/report/statistics/statisticslib.php');

/**
 * The quiz statistics report provides summary information about each question in
 * a quiz, compared to the whole quiz. It also provides a drill-down to more
 * detailed information about each question.
 *
 * @copyright 2008 Jamie Pratt
 * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */
class quiz_statistics_report extends quiz_default_report {

    /** @var context_module context of this quiz.*/
    protected $context;

    /** @var quiz_statistics_table instance of table class used for main questions stats table. */
    protected $table;

    /** @var \core\progress\base|null $progress Handles progress reporting or not. */
    protected $progress = null;

    /**
     * Display the report.
     */
    public function display($quiz, $cm, $course) {
        global $OUTPUT, $DB;

        raise_memory_limit(MEMORY_HUGE);

        $this->context = context_module::instance($cm->id);

        if (!quiz_has_questions($quiz->id)) {
            $this->print_header_and_tabs($cm, $course, $quiz, 'statistics');
            echo quiz_no_questions_message($quiz, $cm, $this->context);
            return true;
        }

        // Work out the display options.
        $download = optional_param('download', '', PARAM_ALPHA);
        $everything = optional_param('everything', 0, PARAM_BOOL);
        $recalculate = optional_param('recalculate', 0, PARAM_BOOL);
        // A qid paramter indicates we should display the detailed analysis of a sub question.
        $qid = optional_param('qid', 0, PARAM_INT);
        $slot = optional_param('slot', 0, PARAM_INT);
        $variantno = optional_param('variant', null, PARAM_INT);
        $whichattempts = optional_param('whichattempts', $quiz->grademethod, PARAM_INT);
        $whichtries = optional_param('whichtries', question_attempt::LAST_TRY, PARAM_ALPHA);

        $pageoptions = array();
        $pageoptions['id'] = $cm->id;
        $pageoptions['mode'] = 'statistics';

        $reporturl = new moodle_url('/mod/quiz/report.php', $pageoptions);

        $mform = new quiz_statistics_settings_form($reporturl, compact('quiz'));

        $mform->set_data(array('whichattempts' => $whichattempts, 'whichtries' => $whichtries));

        if ($whichattempts != $quiz->grademethod) {
            $reporturl->param('whichattempts', $whichattempts);
        }

        if ($whichtries != question_attempt::LAST_TRY) {
            $reporturl->param('whichtries', $whichtries);
        }

        // Find out current groups mode.
        $currentgroup = $this->get_current_group($cm, $course, $this->context);
        $nostudentsingroup = false; // True if a group is selected and there is no one in it.
        if (empty($currentgroup)) {
            $currentgroup = 0;
            $groupstudentsjoins = new \core\dml\sql_join();

        } else if ($currentgroup == self::NO_GROUPS_ALLOWED) {
            $groupstudentsjoins = new \core\dml\sql_join();
            $nostudentsingroup = true;

        } else {
            // All users who can attempt quizzes and who are in the currently selected group.
            $groupstudentsjoins = get_enrolled_with_capabilities_join($this->context, '',
                    array('mod/quiz:reviewmyattempts', 'mod/quiz:attempt'), $currentgroup);
            if (!empty($groupstudentsjoins->joins)) {
                $sql = "SELECT DISTINCT u.id
                    FROM {user} u
                    {$groupstudentsjoins->joins}
                    WHERE {$groupstudentsjoins->wheres}";
                if (!$DB->record_exists_sql($sql, $groupstudentsjoins->params)) {
                    $nostudentsingroup = true;
                }
            }
        }

        $qubaids = quiz_statistics_qubaids_condition($quiz->id, $groupstudentsjoins, $whichattempts);

        // If recalculate was requested, handle that.
        if ($recalculate && confirm_sesskey()) {
            $this->clear_cached_data($qubaids);
            redirect($reporturl);
        }

        // Set up the main table.
        $this->table = new quiz_statistics_table();
        if ($everything) {
            $report = get_string('completestatsfilename', 'quiz_statistics');
        } else {
            $report = get_string('questionstatsfilename', 'quiz_statistics');
        }
        $courseshortname = format_string($course->shortname, true,
                array('context' => context_course::instance($course->id)));
        $filename = quiz_report_download_filename($report, $courseshortname, $quiz->name);
        $this->table->is_downloading($download, $filename,
                get_string('quizstructureanalysis', 'quiz_statistics'));
        $questions = $this->load_and_initialise_questions_for_calculations($quiz);

        // Print the page header stuff (if not downloading.
        if (!$this->table->is_downloading()) {
            $this->print_header_and_tabs($cm, $course, $quiz, 'statistics');
        }

        if (!$nostudentsingroup) {
            // Get the data to be displayed.
            $progress = $this->get_progress_trace_instance();
            list($quizstats, $questionstats) =
                $this->get_all_stats_and_analysis($quiz, $whichattempts, $whichtries, $groupstudentsjoins, $questions, $progress);
> if (is_null($quizstats)) { } else { > echo $OUTPUT->notification(get_string('nostats', 'quiz_statistics'), 'error'); // Or create empty stats containers. > return true; $quizstats = new \quiz_statistics\calculated($whichattempts); > }
$questionstats = new \core_question\statistics\questions\all_calculated_for_qubaid_condition(); } // Set up the table. $this->table->statistics_setup($quiz, $cm->id, $reporturl, $quizstats->s()); // Print the rest of the page header stuff (if not downloading. if (!$this->table->is_downloading()) { if (groups_get_activity_groupmode($cm)) { groups_print_activity_menu($cm, $reporturl->out()); if ($currentgroup && $nostudentsingroup) { $OUTPUT->notification(get_string('nostudentsingroup', 'quiz_statistics')); } } if (!$this->table->is_downloading() && $quizstats->s() == 0) { echo $OUTPUT->notification(get_string('nogradedattempts', 'quiz_statistics')); } foreach ($questionstats->any_error_messages() as $errormessage) { echo $OUTPUT->notification($errormessage); } // Print display options form. $mform->display(); } if ($everything) { // Implies is downloading. // Overall report, then the analysis of each question. $quizinfo = $quizstats->get_formatted_quiz_info_data($course, $cm, $quiz); $this->download_quiz_info_table($quizinfo); if ($quizstats->s()) { $this->output_quiz_structure_analysis_table($questionstats); if ($this->table->is_downloading() == 'html' && $quizstats->s() != 0) { $this->output_statistics_graph($quiz->id, $qubaids); } $this->output_all_question_response_analysis($qubaids, $questions, $questionstats, $reporturl, $whichtries); } $this->table->export_class_instance()->finish_document(); } else if ($qid) { // Report on an individual sub-question indexed questionid. if (!$questionstats->has_subq($qid, $variantno)) {
< print_error('questiondoesnotexist', 'question');
> throw new \moodle_exception('questiondoesnotexist', 'question');
} $this->output_individual_question_data($quiz, $questionstats->for_subq($qid, $variantno)); $this->output_individual_question_response_analysis($questionstats->for_subq($qid, $variantno)->question, $variantno, $questionstats->for_subq($qid, $variantno)->s, $reporturl, $qubaids, $whichtries); // Back to overview link. echo $OUTPUT->box('<a href="' . $reporturl->out() . '">' . get_string('backtoquizreport', 'quiz_statistics') . '</a>', 'boxaligncenter generalbox boxwidthnormal mdl-align'); } else if ($slot) { // Report on an individual question indexed by position. if (!isset($questions[$slot])) {
< print_error('questiondoesnotexist', 'question');
> throw new \moodle_exception('questiondoesnotexist', 'question');
} if ($variantno === null && ($questionstats->for_slot($slot)->get_sub_question_ids() || $questionstats->for_slot($slot)->get_variants())) { if (!$this->table->is_downloading()) { $number = $questionstats->for_slot($slot)->question->number; echo $OUTPUT->heading(get_string('slotstructureanalysis', 'quiz_statistics', $number), 3); } $this->table->define_baseurl(new moodle_url($reporturl, array('slot' => $slot))); $this->table->format_and_add_array_of_rows($questionstats->structure_analysis_for_one_slot($slot)); } else { $this->output_individual_question_data($quiz, $questionstats->for_slot($slot, $variantno)); $this->output_individual_question_response_analysis($questions[$slot], $variantno, $questionstats->for_slot($slot, $variantno)->s, $reporturl, $qubaids, $whichtries); } if (!$this->table->is_downloading()) { // Back to overview link. echo $OUTPUT->box('<a href="' . $reporturl->out() . '">' . get_string('backtoquizreport', 'quiz_statistics') . '</a>', 'backtomainstats boxaligncenter generalbox boxwidthnormal mdl-align'); } else { $this->table->finish_output(); } } else if ($this->table->is_downloading()) { // Downloading overview report. $quizinfo = $quizstats->get_formatted_quiz_info_data($course, $cm, $quiz); $this->download_quiz_info_table($quizinfo); if ($quizstats->s()) { $this->output_quiz_structure_analysis_table($questionstats); } $this->table->export_class_instance()->finish_document(); } else { // On-screen display of overview report. echo $OUTPUT->heading(get_string('quizinformation', 'quiz_statistics'), 3); echo $this->output_caching_info($quizstats->timemodified, $quiz->id, $groupstudentsjoins, $whichattempts, $reporturl); echo $this->everything_download_options($reporturl); $quizinfo = $quizstats->get_formatted_quiz_info_data($course, $cm, $quiz); echo $this->output_quiz_info_table($quizinfo); if ($quizstats->s()) { echo $OUTPUT->heading(get_string('quizstructureanalysis', 'quiz_statistics'), 3); $this->output_quiz_structure_analysis_table($questionstats); $this->output_statistics_graph($quiz, $qubaids); } } return true; } /** * Display the statistical and introductory information about a question. * Only called when not downloading. * * @param object $quiz the quiz settings. * @param \core_question\statistics\questions\calculated $questionstat the question to report on. */ protected function output_individual_question_data($quiz, $questionstat) { global $OUTPUT; // On-screen display. Show a summary of the question's place in the quiz, // and the question statistics. $datumfromtable = $this->table->format_row($questionstat); // Set up the question info table. $questioninfotable = new html_table(); $questioninfotable->align = array('center', 'center'); $questioninfotable->width = '60%'; $questioninfotable->attributes['class'] = 'generaltable titlesleft'; $questioninfotable->data = array(); $questioninfotable->data[] = array(get_string('modulename', 'quiz'), $quiz->name); $questioninfotable->data[] = array(get_string('questionname', 'quiz_statistics'), $questionstat->question->name.'&nbsp;'.$datumfromtable['actions']); if ($questionstat->variant !== null) { $questioninfotable->data[] = array(get_string('variant', 'quiz_statistics'), $questionstat->variant); } $questioninfotable->data[] = array(get_string('questiontype', 'quiz_statistics'), $datumfromtable['icon'] . '&nbsp;' . question_bank::get_qtype($questionstat->question->qtype, false)->menu_name() . '&nbsp;' . $datumfromtable['icon']); $questioninfotable->data[] = array(get_string('positions', 'quiz_statistics'), $questionstat->positions); // Set up the question statistics table. $questionstatstable = new html_table(); $questionstatstable->align = array('center', 'center'); $questionstatstable->width = '60%'; $questionstatstable->attributes['class'] = 'generaltable titlesleft'; unset($datumfromtable['number']); unset($datumfromtable['icon']); $actions = $datumfromtable['actions']; unset($datumfromtable['actions']); unset($datumfromtable['name']); $labels = array( 's' => get_string('attempts', 'quiz_statistics'), 'facility' => get_string('facility', 'quiz_statistics'), 'sd' => get_string('standarddeviationq', 'quiz_statistics'), 'random_guess_score' => get_string('random_guess_score', 'quiz_statistics'), 'intended_weight' => get_string('intended_weight', 'quiz_statistics'), 'effective_weight' => get_string('effective_weight', 'quiz_statistics'), 'discrimination_index' => get_string('discrimination_index', 'quiz_statistics'), 'discriminative_efficiency' => get_string('discriminative_efficiency', 'quiz_statistics') ); foreach ($datumfromtable as $item => $value) { $questionstatstable->data[] = array($labels[$item], $value); } // Display the various bits. echo $OUTPUT->heading(get_string('questioninformation', 'quiz_statistics'), 3); echo html_writer::table($questioninfotable); echo $this->render_question_text($questionstat->question); echo $OUTPUT->heading(get_string('questionstatistics', 'quiz_statistics'), 3); echo html_writer::table($questionstatstable); } /** * Output question text in a box with urls appropriate for a preview of the question. * * @param object $question question data. * @return string HTML of question text, ready for display. */ protected function render_question_text($question) { global $OUTPUT; $text = question_rewrite_question_preview_urls($question->questiontext, $question->id, $question->contextid, 'question', 'questiontext', $question->id, $this->context->id, 'quiz_statistics'); return $OUTPUT->box(format_text($text, $question->questiontextformat, array('noclean' => true, 'para' => false, 'overflowdiv' => true)), 'questiontext boxaligncenter generalbox boxwidthnormal mdl-align'); } /** * Display the response analysis for a question. * * @param object $question the question to report on. * @param int|null $variantno the variant * @param int $s * @param moodle_url $reporturl the URL to redisplay this report. * @param qubaid_condition $qubaids * @param string $whichtries */ protected function output_individual_question_response_analysis($question, $variantno, $s, $reporturl, $qubaids, $whichtries = question_attempt::LAST_TRY) { global $OUTPUT; if (!question_bank::get_qtype($question->qtype, false)->can_analyse_responses()) { return; } $qtable = new quiz_statistics_question_table($question->id); $exportclass = $this->table->export_class_instance(); $qtable->export_class_instance($exportclass); if (!$this->table->is_downloading()) { // Output an appropriate title. echo $OUTPUT->heading(get_string('analysisofresponses', 'quiz_statistics'), 3); } else { // Work out an appropriate title. $a = clone($question); $a->variant = $variantno; if (!empty($question->number) && !is_null($variantno)) { $questiontabletitle = get_string('analysisnovariant', 'quiz_statistics', $a); } else if (!empty($question->number)) { $questiontabletitle = get_string('analysisno', 'quiz_statistics', $a); } else if (!is_null($variantno)) { $questiontabletitle = get_string('analysisvariant', 'quiz_statistics', $a); } else { $questiontabletitle = get_string('analysisnameonly', 'quiz_statistics', $a); } if ($this->table->is_downloading() == 'html') { $questiontabletitle = get_string('analysisofresponsesfor', 'quiz_statistics', $questiontabletitle); } // Set up the table. $exportclass->start_table($questiontabletitle); if ($this->table->is_downloading() == 'html') { echo $this->render_question_text($question); } } $responesanalyser = new analyser($question, $whichtries); $responseanalysis = $responesanalyser->load_cached($qubaids, $whichtries); $qtable->question_setup($reporturl, $question, $s, $responseanalysis); if ($this->table->is_downloading()) { $exportclass->output_headers($qtable->headers); } // Where no variant no is specified the variant no is actually one. if ($variantno === null) { $variantno = 1; } foreach ($responseanalysis->get_subpart_ids($variantno) as $partid) { $subpart = $responseanalysis->get_analysis_for_subpart($variantno, $partid); foreach ($subpart->get_response_class_ids() as $responseclassid) { $responseclass = $subpart->get_response_class($responseclassid); $tabledata = $responseclass->data_for_question_response_table($subpart->has_multiple_response_classes(), $partid); foreach ($tabledata as $row) { $qtable->add_data_keyed($qtable->format_row($row)); } } } $qtable->finish_output(!$this->table->is_downloading()); } /** * Output the table that lists all the questions in the quiz with their statistics. * * @param \core_question\statistics\questions\all_calculated_for_qubaid_condition $questionstats the stats for all questions in * the quiz including subqs and * variants. */ protected function output_quiz_structure_analysis_table($questionstats) { $limitvariants = !$this->table->is_downloading(); foreach ($questionstats->get_all_slots() as $slot) { // Output the data for these question statistics. $structureanalysis = $questionstats->structure_analysis_for_one_slot($slot, $limitvariants); if (is_null($structureanalysis)) { $this->table->add_separator(); } else { foreach ($structureanalysis as $row) { $bgcssclass = ''; // The only way to identify in this point of the report if a row is a summary row // is checking if it's a instance of calculated_question_summary class. if ($row instanceof \core_question\statistics\questions\calculated_question_summary) { // Apply a custom css class to summary row to remove border and reduce paddings. $bgcssclass = 'quiz_statistics-summaryrow'; // For question that contain a summary row, we add a "hidden" row in between so the report // display both rows with same background color. $this->table->add_data_keyed([], 'd-none hidden'); } $this->table->add_data_keyed($this->table->format_row($row), $bgcssclass); } } } $this->table->finish_output(!$this->table->is_downloading()); } /** * Return HTML for table of overall quiz statistics. * * @param array $quizinfo as returned by {@link get_formatted_quiz_info_data()}. * @return string the HTML. */ protected function output_quiz_info_table($quizinfo) { $quizinfotable = new html_table(); $quizinfotable->align = array('center', 'center'); $quizinfotable->width = '60%'; $quizinfotable->attributes['class'] = 'generaltable titlesleft'; $quizinfotable->data = array(); foreach ($quizinfo as $heading => $value) { $quizinfotable->data[] = array($heading, $value); } return html_writer::table($quizinfotable); } /** * Download the table of overall quiz statistics. * * @param array $quizinfo as returned by {@link get_formatted_quiz_info_data()}. */ protected function download_quiz_info_table($quizinfo) { global $OUTPUT; // HTML download is a special case. if ($this->table->is_downloading() == 'html') { echo $OUTPUT->heading(get_string('quizinformation', 'quiz_statistics'), 3); echo $this->output_quiz_info_table($quizinfo); return; } // Reformat the data ready for output. $headers = array(); $row = array(); foreach ($quizinfo as $heading => $value) { $headers[] = $heading; $row[] = $value; } // Do the output. $exportclass = $this->table->export_class_instance(); $exportclass->start_table(get_string('quizinformation', 'quiz_statistics')); $exportclass->output_headers($headers); $exportclass->add_data($row); $exportclass->finish_table(); } /** * Output the HTML needed to show the statistics graph. * * @param int|object $quizorid The quiz, or its ID. * @param qubaid_condition $qubaids the question usages whose responses to analyse. * @param string $whichattempts Which attempts constant. */ protected function output_statistics_graph($quizorid, $qubaids) { global $DB, $PAGE; $quiz = $quizorid; if (!is_object($quiz)) { $quiz = $DB->get_record('quiz', array('id' => $quizorid), '*', MUST_EXIST); } // Load the rest of the required data. $questions = quiz_report_get_significant_questions($quiz); // Only load main question not sub questions. $questionstatistics = $DB->get_records_select('question_statistics', 'hashcode = ? AND slot IS NOT NULL AND variant IS NULL', [$qubaids->get_hash_code()]); // Configure what to display. $fieldstoplot = [ 'facility' => get_string('facility', 'quiz_statistics'), 'discriminativeefficiency' => get_string('discriminative_efficiency', 'quiz_statistics') ]; $fieldstoplotfactor = ['facility' => 100, 'discriminativeefficiency' => 1]; // Prepare the arrays to hold the data. $xdata = []; foreach (array_keys($fieldstoplot) as $fieldtoplot) { $ydata[$fieldtoplot] = []; } // Fill in the data for each question. foreach ($questionstatistics as $questionstatistic) { $number = $questions[$questionstatistic->slot]->number; $xdata[$number] = $number; foreach ($fieldstoplot as $fieldtoplot => $notused) { $value = $questionstatistic->$fieldtoplot; if (is_null($value)) { $value = 0; } $value *= $fieldstoplotfactor[$fieldtoplot]; $ydata[$fieldtoplot][$number] = number_format($value, 2); } } // Create the chart. sort($xdata); $chart = new \core\chart_bar(); $chart->get_xaxis(0, true)->set_label(get_string('position', 'quiz_statistics')); $chart->set_labels(array_values($xdata)); foreach ($fieldstoplot as $fieldtoplot => $notused) { ksort($ydata[$fieldtoplot]); $series = new \core\chart_series($fieldstoplot[$fieldtoplot], array_values($ydata[$fieldtoplot])); $chart->add_series($series); } // Find max. $max = 0; foreach ($fieldstoplot as $fieldtoplot => $notused) { $max = max($max, max($ydata[$fieldtoplot])); } // Set Y properties. $yaxis = $chart->get_yaxis(0, true); $yaxis->set_stepsize(10); $yaxis->set_label('%'); $output = $PAGE->get_renderer('mod_quiz'); $graphname = get_string('statisticsreportgraph', 'quiz_statistics'); echo $output->chart($chart, $graphname); } /** * Get the quiz and question statistics, either by loading the cached results, * or by recomputing them. * * @param object $quiz the quiz settings. * @param string $whichattempts which attempts to use, represented internally as one of the constants as used in * $quiz->grademethod ie. * QUIZ_GRADEAVERAGE, QUIZ_GRADEHIGHEST, QUIZ_ATTEMPTLAST or QUIZ_ATTEMPTFIRST * we calculate stats based on which attempts would affect the grade for each student. * @param string $whichtries which tries to analyse for response analysis. Will be one of * question_attempt::FIRST_TRY, LAST_TRY or ALL_TRIES. * @param \core\dml\sql_join $groupstudentsjoins Contains joins, wheres, params for students in this group. * @param array $questions full question data. * @param \core\progress\base|null $progress * @param bool $calculateifrequired if true (the default) the stats will be calculated if not already stored. * If false, [null, null] will be returned if the stats are not already available.
> * @param bool $performanalysis if true (the default) and there are calculated stats, analysis will be performed * @return array with 2 elements: - $quizstats The statistics for overall attempt scores. > * for each question.
* - $questionstats \core_question\statistics\questions\all_calculated_for_qubaid_condition * Both may be null, if $calculateifrequired is false. */ public function get_all_stats_and_analysis( $quiz, $whichattempts, $whichtries, \core\dml\sql_join $groupstudentsjoins,
< $questions, $progress = null, bool $calculateifrequired = true) {
> $questions, $progress = null, bool $calculateifrequired = true, bool $performanalysis = true) {
if ($progress === null) { $progress = new \core\progress\none(); } $qubaids = quiz_statistics_qubaids_condition($quiz->id, $groupstudentsjoins, $whichattempts); $qcalc = new \core_question\statistics\questions\calculator($questions, $progress); $quizcalc = new \quiz_statistics\calculator($progress);
< $progress->start_progress('', 3);
> $progress->start_progress('', 4); > > // Get a lock on this set of qubaids before performing calculations. This prevents the same calculation running > // concurrently and causing database deadlocks. We use a long timeout here as a big quiz with lots of attempts may > // take a long time to process. > $lockfactory = \core\lock\lock_config::get_lock_factory('quiz_statistics_get_stats'); > $lock = $lockfactory->get_lock($qubaids->get_hash_code(), 0); > if (!$lock) { > if (!$calculateifrequired) { > // We're not going to do the calculation in this request anyway, so just give up here. > $progress->progress(4); > $progress->end_progress(); > return [null, null]; > } > $locktimeout = get_config('quiz_statistics', 'getstatslocktimeout'); > $lock = \core\lock\lock_utils::wait_for_lock_with_progress( > $lockfactory, > $qubaids->get_hash_code(), > $progress, > $locktimeout, > get_string('getstatslockprogress', 'quiz_statistics'), > ); > if (!$lock) { > // Lock attempt timed out. > $progress->progress(4); > $progress->end_progress(); > debugging('Could not get lock on ' . > $qubaids->get_hash_code() . ' (Quiz ID ' . $quiz->id . ') after ' . > $locktimeout . ' seconds'); > return [null, null]; > } > } > > try {
if ($quizcalc->get_last_calculated_time($qubaids) === false) { if (!$calculateifrequired) {
< $progress->progress(3);
> $progress->progress(4);
$progress->end_progress();
> $lock->release();
return [null, null]; } // Recalculate now. $questionstats = $qcalc->calculate($qubaids);
< $progress->progress(1); < < $quizstats = $quizcalc->calculate($quiz->id, $whichattempts, $groupstudentsjoins, count($questions), < $qcalc->get_sum_of_mark_variance());
$progress->progress(2);
> } else { > $quizstats = $quizcalc->calculate( $quizstats = $quizcalc->get_cached($qubaids); > $quiz->id, $progress->progress(1); > $whichattempts, $questionstats = $qcalc->get_cached($qubaids); > $groupstudentsjoins, $progress->progress(2); > count($questions), } > $qcalc->get_sum_of_mark_variance() > ); if ($quizstats->s()) { > $progress->progress(3);
< $progress->progress(1); < $questionstats = $qcalc->get_cached($qubaids);
$subquestions,
> $questionstats = $qcalc->get_cached($qubaids); $qubaids, > $progress->progress(3);
< if ($quizstats->s()) {
> if ($quizstats->s() && $performanalysis) {
< $this->analyse_responses_for_all_questions_and_subquestions($questions,
> $this->analyse_responses_for_all_questions_and_subquestions( > $questions,
< $progress);
> $progress > );
< $progress->progress(3);
> $progress->progress(4);
$progress->end_progress();
> } finally { > $lock->release(); return array($quizstats, $questionstats); > }
} /** * Appropriate instance depending if we want html output for the user or not. * * @return \core\progress\base child of \core\progress\base to handle the display (or not) of task progress. */ protected function get_progress_trace_instance() { if ($this->progress === null) { if (!$this->table->is_downloading()) { $this->progress = new \core\progress\display_if_slow(get_string('calculatingallstats', 'quiz_statistics')); $this->progress->set_display_names(); } else { $this->progress = new \core\progress\none(); } } return $this->progress; } /** * Analyse responses for all questions and sub questions in this quiz. * * @param object[] $questions as returned by self::load_and_initialise_questions_for_calculations * @param object[] $subquestions full question objects. * @param qubaid_condition $qubaids the question usages whose responses to analyse. * @param string $whichtries which tries to analyse \question_attempt::FIRST_TRY, LAST_TRY or ALL_TRIES. * @param null|\core\progress\base $progress Used to indicate progress of task. */ protected function analyse_responses_for_all_questions_and_subquestions($questions, $subquestions, $qubaids, $whichtries, $progress = null) { if ($progress === null) { $progress = new \core\progress\none(); } // Starting response analysis tasks. $progress->start_progress('', count($questions) + count($subquestions)); $done = $this->analyse_responses_for_questions($questions, $qubaids, $whichtries, $progress); $this->analyse_responses_for_questions($subquestions, $qubaids, $whichtries, $progress, $done); // Finished all response analysis tasks. $progress->end_progress(); } /** * Analyse responses for an array of questions or sub questions. * * @param object[] $questions as returned by self::load_and_initialise_questions_for_calculations. * @param qubaid_condition $qubaids the question usages whose responses to analyse. * @param string $whichtries which tries to analyse \question_attempt::FIRST_TRY, LAST_TRY or ALL_TRIES. * @param null|\core\progress\base $progress Used to indicate progress of task. * @param int[] $done array keys are ids of questions that have been analysed before calling method. * @return array array keys are ids of questions that were analysed after this method call. */ protected function analyse_responses_for_questions($questions, $qubaids, $whichtries, $progress = null, $done = array()) { $countquestions = count($questions); if (!$countquestions) { return array(); } if ($progress === null) { $progress = new \core\progress\none(); } $progress->start_progress('', $countquestions, $countquestions); foreach ($questions as $question) { $progress->increment_progress(); if (question_bank::get_qtype($question->qtype, false)->can_analyse_responses() && !isset($done[$question->id])) { $responesstats = new analyser($question, $whichtries); $responesstats->calculate($qubaids, $whichtries); } $done[$question->id] = 1; } $progress->end_progress(); return $done; } /** * Return a little form for the user to request to download the full report, including quiz stats and response analysis for * all questions and sub-questions. * * @param moodle_url $reporturl the base URL of the report. * @return string HTML. */ protected function everything_download_options(moodle_url $reporturl) { global $OUTPUT; return $OUTPUT->download_dataformat_selector(get_string('downloadeverything', 'quiz_statistics'), $reporturl->out_omit_querystring(), 'download', $reporturl->params() + array('everything' => 1)); } /** * Return HTML for a message that says when the stats were last calculated and a 'recalculate now' button. * * @param int $lastcachetime the time the stats were last cached. * @param int $quizid the quiz id. * @param array $groupstudentsjoins (joins, wheres, params) for students in the group or empty array if groups not used. * @param string $whichattempts which attempts to use, represented internally as one of the constants as used in * $quiz->grademethod ie. * QUIZ_GRADEAVERAGE, QUIZ_GRADEHIGHEST, QUIZ_ATTEMPTLAST or QUIZ_ATTEMPTFIRST * we calculate stats based on which attempts would affect the grade for each student. * @param moodle_url $reporturl url for this report * @return string HTML. */ protected function output_caching_info($lastcachetime, $quizid, $groupstudentsjoins, $whichattempts, $reporturl) { global $DB, $OUTPUT; if (empty($lastcachetime)) { return ''; } // Find the number of attempts since the cached statistics were computed. list($fromqa, $whereqa, $qaparams) = quiz_statistics_attempts_sql($quizid, $groupstudentsjoins, $whichattempts, true); $count = $DB->count_records_sql(" SELECT COUNT(1) FROM $fromqa WHERE $whereqa AND quiza.timefinish > {$lastcachetime}", $qaparams); if (!$count) { $count = 0; } // Generate the output. $a = new stdClass(); $a->lastcalculated = format_time(time() - $lastcachetime); $a->count = $count; $recalcualteurl = new moodle_url($reporturl, array('recalculate' => 1, 'sesskey' => sesskey())); $output = ''; $output .= $OUTPUT->box_start( 'boxaligncenter generalbox boxwidthnormal mdl-align', 'cachingnotice'); $output .= get_string('lastcalculated', 'quiz_statistics', $a); $output .= $OUTPUT->single_button($recalcualteurl, get_string('recalculatenow', 'quiz_statistics')); $output .= $OUTPUT->box_end(true); return $output; } /** * Clear the cached data for a particular report configuration. This will trigger a re-computation the next time the report * is displayed. * * @param $qubaids qubaid_condition */ public function clear_cached_data($qubaids) { global $DB; $DB->delete_records('quiz_statistics', array('hashcode' => $qubaids->get_hash_code())); $DB->delete_records('question_statistics', array('hashcode' => $qubaids->get_hash_code())); $DB->delete_records('question_response_analysis', array('hashcode' => $qubaids->get_hash_code())); } /** * Load the questions in this quiz and add some properties to the objects needed in the reports. * * @param object $quiz the quiz. * @return array of questions for this quiz. */ public function load_and_initialise_questions_for_calculations($quiz) { // Load the questions. $questions = quiz_report_get_significant_questions($quiz); $questiondata = []; foreach ($questions as $qs => $question) { if ($question->qtype === 'random') { $question->id = 0; $question->name = get_string('random', 'quiz'); $question->questiontext = get_string('random', 'quiz'); $question->parenttype = 'random'; $questiondata[$question->slot] = $question; } else if ($question->qtype === 'missingtype') { $question->id = is_numeric($question->id) ? (int) $question->id : 0; $questiondata[$question->slot] = $question; $question->name = get_string('deletedquestion', 'qtype_missingtype'); $question->questiontext = get_string('deletedquestiontext', 'qtype_missingtype'); } else { $q = question_bank::load_question_data($question->id); $q->maxmark = $question->maxmark; $q->slot = $question->slot; $q->number = $question->number; $q->parenttype = null; $questiondata[$question->slot] = $q; } } return $questiondata; } /** * Output all response analysis for all questions, sub-questions and variants. For download in a number of formats. * * @param $qubaids * @param $questions * @param $questionstats * @param $reporturl * @param $whichtries string */ protected function output_all_question_response_analysis($qubaids, $questions, $questionstats, $reporturl, $whichtries = question_attempt::LAST_TRY) { foreach ($questions as $slot => $question) { if (question_bank::get_qtype( $question->qtype, false)->can_analyse_responses() ) { if ($questionstats->for_slot($slot)->get_variants()) { foreach ($questionstats->for_slot($slot)->get_variants() as $variantno) { $this->output_individual_question_response_analysis($question, $variantno, $questionstats->for_slot($slot, $variantno)->s, $reporturl, $qubaids, $whichtries); } } else { $this->output_individual_question_response_analysis($question, null, $questionstats->for_slot($slot)->s, $reporturl, $qubaids, $whichtries); } } else if ($subqids = $questionstats->for_slot($slot)->get_sub_question_ids()) { foreach ($subqids as $subqid) { if ($variants = $questionstats->for_subq($subqid)->get_variants()) { foreach ($variants as $variantno) { $this->output_individual_question_response_analysis( $questionstats->for_subq($subqid, $variantno)->question, $variantno, $questionstats->for_subq($subqid, $variantno)->s, $reporturl, $qubaids, $whichtries); } } else { $this->output_individual_question_response_analysis( $questionstats->for_subq($subqid)->question, null, $questionstats->for_subq($subqid)->s, $reporturl, $qubaids, $whichtries); } } } } } /** * Load question stats for a quiz * * @param int $quizid question usage * @param bool $calculateifrequired if true (the default) the stats will be calculated if not already stored. * If false, null will be returned if the stats are not already available.
> * @param bool $performanalysis if true (the default) and there are calculated stats, analysis will be performed * @return ?all_calculated_for_qubaid_condition question stats > * for each question.
*/ public function calculate_questions_stats_for_question_bank( int $quizid,
< bool $calculateifrequired = true
> bool $calculateifrequired = true, > bool $performanalysis = true
): ?all_calculated_for_qubaid_condition { global $DB; $quiz = $DB->get_record('quiz', ['id' => $quizid], '*', MUST_EXIST); $questions = $this->load_and_initialise_questions_for_calculations($quiz); [, $questionstats] = $this->get_all_stats_and_analysis($quiz, $quiz->grademethod, question_attempt::ALL_TRIES, new \core\dml\sql_join(),
< $questions, null, $calculateifrequired);
> $questions, null, $calculateifrequired, $performanalysis);
return $questionstats; } }