mirror of
https://github.com/moodle/moodle.git
synced 2025-01-17 21:49:15 +01:00
MDL-74762 qbank_statistics: improve performance loading the data
This issue greatly improves the performance of displaying statistics in the question bank. 1. The required quiz statistics are now pre-computed by a scheduled task. 2. Cached statistics in the database are now never cleaned up, so the pre-computed stats are always available. 3. The way the cached statistics are loaded for each question that is being displayed is now a bit more efficient. 4. Related to that, there is a new callback which activities can implement, if they want their question statistics to be included in the ones shown in the question bank. Note, there is still further improvement possible to load the statistics for all questions being displayed in bulk. However, that must wait for a future issue, MDL-75576. The other improvements in this issue are significant and we did not want to delay releasing them. Co-authored-by: Jonathan Champ <jrchamp@ncsu.edu> Co-authored-by: Tim Hunt <t.j.hunt@open.ac.uk>
This commit is contained in:
parent
b077af7e89
commit
f02c16c445
@ -29,6 +29,7 @@
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
use mod_quiz\question\bank\custom_view;
|
||||
use core_question\statistics\questions\all_calculated_for_qubaid_condition;
|
||||
|
||||
require_once($CFG->dirroot . '/calendar/lib.php');
|
||||
|
||||
@ -2485,3 +2486,19 @@ function quiz_delete_references($quizid): void {
|
||||
$DB->delete_records('question_references', $params);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Implement the calculate_question_stats callback.
|
||||
*
|
||||
* This enables quiz statistics to be shown in statistics columns in the database.
|
||||
*
|
||||
* @param context $context return the statistics related to this context (which will be a quiz context).
|
||||
* @return all_calculated_for_qubaid_condition|null The statistics for this quiz, if any, else null.
|
||||
*/
|
||||
function mod_quiz_calculate_question_stats(context $context): ?all_calculated_for_qubaid_condition {
|
||||
global $CFG;
|
||||
require_once($CFG->dirroot . '/mod/quiz/report/statistics/report.php');
|
||||
$cm = get_coursemodule_from_id('quiz', $context->instanceid);
|
||||
$report = new quiz_statistics_report();
|
||||
return $report->calculate_questions_stats_for_question_bank($cm->instance);
|
||||
}
|
||||
|
@ -26,6 +26,7 @@
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
require_once($CFG->dirroot . '/mod/quiz/lib.php');
|
||||
require_once($CFG->dirroot . '/mod/quiz/attemptlib.php');
|
||||
require_once($CFG->libdir . '/filelib.php');
|
||||
require_once($CFG->dirroot . '/mod/quiz/accessmanager.php');
|
||||
|
||||
|
@ -1,55 +0,0 @@
|
||||
<?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/>.
|
||||
|
||||
/**
|
||||
* Legacy Cron Quiz Reports Task
|
||||
*
|
||||
* @package quiz_statistics
|
||||
* @copyright 2017 Michael Hughes, University of Strathclyde
|
||||
* @author Michael Hughes <michaelhughes@strath.ac.uk>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*
|
||||
*/
|
||||
namespace quiz_statistics\task;
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
/**
|
||||
* Legacy Cron Quiz Reports Task
|
||||
*
|
||||
* @package quiz_statistics
|
||||
* @copyright 2017 Michael Hughes
|
||||
* @author Michael Hughes
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*
|
||||
*/
|
||||
class quiz_statistics_cleanup extends \core\task\scheduled_task {
|
||||
public function get_name() {
|
||||
return get_string('quizstatisticscleanuptask', 'quiz_statistics');
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the clean up task.
|
||||
*/
|
||||
public function execute() {
|
||||
global $DB;
|
||||
|
||||
$expiretime = time() - 4 * HOURSECS;
|
||||
$DB->delete_records_select('quiz_statistics', 'timemodified < ?', array($expiretime));
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
88
mod/quiz/report/statistics/classes/task/recalculate.php
Normal file
88
mod/quiz/report/statistics/classes/task/recalculate.php
Normal file
@ -0,0 +1,88 @@
|
||||
<?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/>.
|
||||
|
||||
namespace quiz_statistics\task;
|
||||
|
||||
use quiz_attempt;
|
||||
use quiz;
|
||||
use quiz_statistics_report;
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
require_once($CFG->dirroot . '/mod/quiz/locallib.php');
|
||||
require_once($CFG->dirroot . '/mod/quiz/report/statistics/statisticslib.php');
|
||||
require_once($CFG->dirroot . '/mod/quiz/report/reportlib.php');
|
||||
require_once($CFG->dirroot . '/mod/quiz/report/statistics/report.php');
|
||||
|
||||
/**
|
||||
* Re-calculate question statistics.
|
||||
*
|
||||
* @package quiz_statistics
|
||||
* @copyright 2022 Catalyst IT Australia Pty Ltd
|
||||
* @author Nathan Nguyen <nathannguyen@catalyst-au.net>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
class recalculate extends \core\task\scheduled_task {
|
||||
|
||||
public function get_name() {
|
||||
return get_string('recalculatetask', 'quiz_statistics');
|
||||
}
|
||||
|
||||
public function execute() {
|
||||
global $DB;
|
||||
// TODO: MDL-75197, add quizid in quiz_statistics so that it is simpler to find quizzes for stats calculation.
|
||||
// Only calculate stats for quizzes which have recently finished attempt.
|
||||
$sql = "
|
||||
SELECT qa.quiz, MAX(qa.timefinish) as timefinish
|
||||
FROM {quiz_attempts} qa
|
||||
WHERE qa.preview = 0
|
||||
AND qa.state = :quizstatefinished
|
||||
GROUP BY qa.quiz
|
||||
";
|
||||
|
||||
$params = [
|
||||
"quizstatefinished" => quiz_attempt::FINISHED,
|
||||
];
|
||||
|
||||
$latestattempts = $DB->get_records_sql($sql, $params);
|
||||
|
||||
foreach ($latestattempts as $attempt) {
|
||||
$quizobj = quiz::create($attempt->quiz);
|
||||
$quiz = $quizobj->get_quiz();
|
||||
// Hash code for question stats option in question bank.
|
||||
$qubaids = quiz_statistics_qubaids_condition($quiz->id, new \core\dml\sql_join(), $quiz->grademethod);
|
||||
|
||||
// Check if there is any existing question stats, and it has been calculated after latest quiz attempt.
|
||||
$records = $DB->get_records_select(
|
||||
'quiz_statistics',
|
||||
'hashcode = :hashcode AND timemodified > :timefinish',
|
||||
[
|
||||
'hashcode' => $qubaids->get_hash_code(),
|
||||
'timefinish' => $attempt->timefinish
|
||||
]
|
||||
);
|
||||
|
||||
if (empty($records)) {
|
||||
$report = new quiz_statistics_report();
|
||||
// Clear old cache.
|
||||
$report->clear_cached_data($qubaids);
|
||||
// Calculate new stats.
|
||||
$report->calculate_questions_stats_for_question_bank($quiz->id);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
@ -28,10 +28,10 @@ defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
$tasks = [
|
||||
[
|
||||
'classname' => 'quiz_statistics\task\quiz_statistics_cleanup',
|
||||
'classname' => 'quiz_statistics\task\recalculate',
|
||||
'blocking' => 0,
|
||||
'minute' => 'R',
|
||||
'hour' => '*/5',
|
||||
'hour' => '*/4',
|
||||
'day' => '*',
|
||||
'dayofweek' => '*',
|
||||
'month' => '*'
|
||||
|
@ -93,7 +93,6 @@ $string['questioninformation'] = 'Question information';
|
||||
$string['questionname'] = 'Question name';
|
||||
$string['questionnumber'] = 'Q#';
|
||||
$string['questionstatistics'] = 'Question statistics';
|
||||
$string['quizstatisticscleanuptask'] = 'Clean up old quiz statistics cache records';
|
||||
$string['questionstatsfilename'] = 'questionstats';
|
||||
$string['questiontype'] = 'Question type';
|
||||
$string['quizinformation'] = 'Quiz information';
|
||||
@ -104,6 +103,7 @@ $string['random_guess_score'] = 'Random guess score';
|
||||
$string['rangeofvalues'] = 'Range of statistics for these questions';
|
||||
$string['rangebetween'] = '{$a->min} − {$a->max}';
|
||||
$string['recalculatenow'] = 'Recalculate now';
|
||||
$string['recalculatetask'] = 'Re-calculate question statistics';
|
||||
$string['reportsettings'] = 'Statistics calculation settings';
|
||||
$string['response'] = 'Response';
|
||||
$string['slotstructureanalysis'] = 'Structural analysis for question number {$a}';
|
||||
|
@ -25,10 +25,15 @@
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
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
|
||||
@ -814,7 +819,7 @@ class quiz_statistics_report extends quiz_default_report {
|
||||
*
|
||||
* @param $qubaids qubaid_condition
|
||||
*/
|
||||
protected function clear_cached_data($qubaids) {
|
||||
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()));
|
||||
@ -921,4 +926,21 @@ class quiz_statistics_report extends quiz_default_report {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load question stats for a quiz
|
||||
*
|
||||
* @param int $quizid question usage
|
||||
* @return all_calculated_for_qubaid_condition question stats
|
||||
*/
|
||||
public function calculate_questions_stats_for_question_bank(int $quizid): 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);
|
||||
|
||||
return $questionstats;
|
||||
}
|
||||
}
|
||||
|
@ -24,6 +24,6 @@
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
$plugin->version = 2022041900;
|
||||
$plugin->version = 2022041901;
|
||||
$plugin->requires = 2022041200;
|
||||
$plugin->component = 'quiz_statistics';
|
||||
|
@ -13,6 +13,9 @@ information provided here is intended especially for developers.
|
||||
in returning inaccurate data in this course format, therefore it is advisable to use $settingsnavigation->get_page().
|
||||
* A new style of icons has been created for activities. When creating an icon in the new style it should be named
|
||||
'monologo' and can site alongside the legacy icon if desired. Only the new logo types will be used.
|
||||
* There is a new callback ..._calculate_question_stats which needs to be implemented by components which want
|
||||
to contribute statistics to the display in the question bank. There is an example implementation in mod_quiz.
|
||||
(Added in 4.0.4 / 4.1.)
|
||||
|
||||
=== 3.9 ===
|
||||
|
||||
|
@ -17,15 +17,7 @@
|
||||
namespace qbank_statistics;
|
||||
|
||||
use core_question\statistics\questions\all_calculated_for_qubaid_condition;
|
||||
use quiz_statistics_report;
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
require_once($CFG->dirroot . '/mod/quiz/report/statistics/statisticslib.php');
|
||||
require_once($CFG->dirroot . '/mod/quiz/report/default.php');
|
||||
require_once($CFG->dirroot . '/mod/quiz/report/statistics/report.php');
|
||||
require_once($CFG->dirroot . '/mod/quiz/report/reportlib.php');
|
||||
require_once($CFG->dirroot . '/mod/quiz/attemptlib.php');
|
||||
use core_component;
|
||||
|
||||
/**
|
||||
* Helper for statistics
|
||||
@ -48,106 +40,119 @@ class helper {
|
||||
private const NEED_FOR_REVISION_UPPER_THRESHOLD = 50;
|
||||
|
||||
/**
|
||||
* Return ids of all quizzes that use the question
|
||||
* For a list of questions find all the places (defined by (component, contextid) where there are attempts.
|
||||
*
|
||||
* @param int $questionid id of the question
|
||||
* @return array list of quizids
|
||||
* @throws \dml_exception
|
||||
* @param int[] $questionids array of question ids that we are interested in.
|
||||
* @return \stdClass[] list of objects with fields ->component and ->contextid.
|
||||
*/
|
||||
public static function get_quizzes(int $questionid): array {
|
||||
private static function get_all_places_where_questions_were_attempted(array $questionids): array {
|
||||
global $DB;
|
||||
|
||||
$quizzes = $DB->get_fieldset_sql("
|
||||
SELECT DISTINCT qa.quiz as id
|
||||
FROM {quiz_attempts} qa
|
||||
JOIN {question_usages} qu ON qu.id = qa.uniqueid
|
||||
JOIN {question_attempts} qatt ON qatt.questionusageid = qu.id
|
||||
WHERE qatt.questionid = :questionid",
|
||||
['questionid' => $questionid]
|
||||
);
|
||||
return $quizzes;
|
||||
}
|
||||
[$questionidcondition, $params] = $DB->get_in_or_equal($questionids);
|
||||
// The MIN(qu.id) is just to ensure that the rows have a unique key.
|
||||
$places = $DB->get_records_sql("
|
||||
SELECT MIN(qu.id) AS somethingunique, qu.component, qu.contextid
|
||||
FROM {question_usages} qu
|
||||
JOIN {question_attempts} qatt ON qatt.questionusageid = qu.id
|
||||
WHERE qatt.questionid $questionidcondition
|
||||
GROUP BY qu.component, qu.contextid
|
||||
", $params);
|
||||
|
||||
/**
|
||||
* Load question stats from a quiz
|
||||
*
|
||||
* @param int $quizid quiz object or its id
|
||||
* @return all_calculated_for_qubaid_condition
|
||||
*/
|
||||
private static function load_question_stats(int $quizid): all_calculated_for_qubaid_condition {
|
||||
// Turn to quiz object.
|
||||
$quiz = new \stdClass();
|
||||
$quiz->id = $quizid;
|
||||
// All questions, no groups.
|
||||
$report = new quiz_statistics_report();
|
||||
$questions = $report->load_and_initialise_questions_for_calculations($quiz);
|
||||
$qubaids = quiz_statistics_qubaids_condition($quiz->id, new \core\dml\sql_join());
|
||||
$progress = new \core\progress\none();
|
||||
$qcalc = new \core_question\statistics\questions\calculator($questions, $progress);
|
||||
$quizcalc = new \quiz_statistics\calculator($progress);
|
||||
if ($quizcalc->get_last_calculated_time($qubaids) === false) {
|
||||
$questionstats = $qcalc->calculate($qubaids);
|
||||
} else {
|
||||
$questionstats = $qcalc->get_cached($qubaids);
|
||||
// Strip out the unwanted ids.
|
||||
$places = array_values($places);
|
||||
foreach ($places as $place) {
|
||||
unset($place->somethingunique);
|
||||
}
|
||||
return $questionstats;
|
||||
|
||||
return $places;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a specified stats item for a question
|
||||
* Load the question statistics for all the attempts belonging to a particular component in a particular context.
|
||||
*
|
||||
* @param int $quizid quiz id
|
||||
* @param int $questionid question id
|
||||
* @param string $item a stats item
|
||||
* @return float|int
|
||||
* @param string $component frankenstyle component name, e.g. 'mod_quiz'.
|
||||
* @param \context $context the context to load the statistics for.
|
||||
* @return all_calculated_for_qubaid_condition|null question statistics.
|
||||
*/
|
||||
public static function load_question_stats_item(int $quizid, int $questionid, string $item): ?float {
|
||||
$questionstats = self::load_question_stats($quizid);
|
||||
// Find in main question.
|
||||
foreach ($questionstats->questionstats as $stats) {
|
||||
private static function load_statistics_for_place(string $component, \context $context): ?all_calculated_for_qubaid_condition {
|
||||
// This check is basically if (component_exists).
|
||||
if (empty(core_component::get_component_directory($component))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!component_callback_exists($component, 'calculate_question_stats')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return component_callback($component, 'calculate_question_stats', [$context]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the value for one question and one type of statistic from a set of statistics.
|
||||
*
|
||||
* @param all_calculated_for_qubaid_condition $statistics the batch of statistics.
|
||||
* @param int $questionid a question id.
|
||||
* @param string $item ane of the field names in all_calculated_for_qubaid_condition, e.g. 'facility'.
|
||||
* @return float|null the required value.
|
||||
*/
|
||||
private static function extract_item_value(all_calculated_for_qubaid_condition $statistics,
|
||||
int $questionid, string $item): ?float {
|
||||
|
||||
// Look in main questions.
|
||||
foreach ($statistics->questionstats as $stats) {
|
||||
if ($stats->questionid == $questionid && isset($stats->$item)) {
|
||||
return $stats->$item;
|
||||
}
|
||||
}
|
||||
// If not found, find in sub questions.
|
||||
foreach ($questionstats->subquestionstats as $stats) {
|
||||
|
||||
// If not found, look in sub questions.
|
||||
foreach ($statistics->subquestionstats as $stats) {
|
||||
if ($stats->questionid == $questionid && isset($stats->$item)) {
|
||||
return $stats->$item;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate average for a stats item on a question.
|
||||
* Calculate average for a stats item on a list of questions.
|
||||
*
|
||||
* @param int $questionid id of the question
|
||||
* @param string $item stats item
|
||||
* @return float|null
|
||||
* @param int[] $questionids list of ids of the questions we are interested in.
|
||||
* @param string $item ane of the field names in all_calculated_for_qubaid_condition, e.g. 'facility'.
|
||||
* @return array array keys are question ids and the corresponding values are the average values.
|
||||
* Only questions for which there are data are included.
|
||||
*/
|
||||
private static function calculate_average_question_stats_item(int $questionid, string $item): ?float {
|
||||
$quizzes = self::get_quizzes($questionid);
|
||||
private static function calculate_average_question_stats_item(array $questionids, string $item): array {
|
||||
$places = self::get_all_places_where_questions_were_attempted($questionids);
|
||||
|
||||
$sum = 0;
|
||||
$quizcount = count($quizzes);
|
||||
foreach ($quizzes as $quizid) {
|
||||
$value = self::load_question_stats_item($quizid, $questionid, $item);
|
||||
if (!is_null($value)) {
|
||||
$sum += $value;
|
||||
} else {
|
||||
// Exclude this value when it is null.
|
||||
$quizcount--;
|
||||
$counts = [];
|
||||
$sums = [];
|
||||
|
||||
foreach ($places as $place) {
|
||||
$statistics = self::load_statistics_for_place($place->component,
|
||||
\context::instance_by_id($place->contextid));
|
||||
if ($statistics === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($questionids as $questionid) {
|
||||
$value = self::extract_item_value($statistics, $questionid, $item);
|
||||
if ($value === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$counts[$questionid] = ($counts[$questionid] ?? 0) + 1;
|
||||
$sums[$questionid] = ($sums[$questionid] ?? 0) + $value;
|
||||
}
|
||||
}
|
||||
|
||||
// Return null if there is no quizzes.
|
||||
if (empty($quizcount)) {
|
||||
return null;
|
||||
$averages = [];
|
||||
foreach ($sums as $questionid => $sum) {
|
||||
$averages[$questionid] = $sum / $counts[$questionid];
|
||||
}
|
||||
|
||||
// Average value per quiz.
|
||||
$average = $sum / $quizcount;
|
||||
return $average;
|
||||
return $averages;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -157,7 +162,8 @@ class helper {
|
||||
* @return float|null
|
||||
*/
|
||||
public static function calculate_average_question_facility(int $questionid): ?float {
|
||||
return self::calculate_average_question_stats_item($questionid, 'facility');
|
||||
$averages = self::calculate_average_question_stats_item([$questionid], 'facility');
|
||||
return $averages[$questionid] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -167,7 +173,8 @@ class helper {
|
||||
* @return float|null
|
||||
*/
|
||||
public static function calculate_average_question_discriminative_efficiency(int $questionid): ?float {
|
||||
return self::calculate_average_question_stats_item($questionid, 'discriminativeefficiency');
|
||||
$averages = self::calculate_average_question_stats_item([$questionid], 'discriminativeefficiency');
|
||||
return $averages[$questionid] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -177,7 +184,8 @@ class helper {
|
||||
* @return float|null
|
||||
*/
|
||||
public static function calculate_average_question_discrimination_index(int $questionid): ?float {
|
||||
return self::calculate_average_question_stats_item($questionid, 'discriminationindex');
|
||||
$averages = self::calculate_average_question_stats_item([$questionid], 'discriminationindex');
|
||||
return $averages[$questionid] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -16,13 +16,11 @@
|
||||
|
||||
namespace qbank_statistics;
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
global $CFG;
|
||||
require_once($CFG->dirroot . '/mod/quiz/locallib.php');
|
||||
use core_question\statistics\questions\all_calculated_for_qubaid_condition;
|
||||
use quiz;
|
||||
use question_engine;
|
||||
use quiz_attempt;
|
||||
|
||||
/**
|
||||
* Tests for question statistics.
|
||||
*
|
||||
@ -36,103 +34,84 @@ class helper_test extends \advanced_testcase {
|
||||
/**
|
||||
* Test quizzes that contain a specified question.
|
||||
*
|
||||
* @covers ::get_all_places_where_questions_were_attempted
|
||||
* @throws \coding_exception
|
||||
* @throws \dml_exception
|
||||
*/
|
||||
public function test_get_quizziess(): void {
|
||||
global $DB;
|
||||
public function test_get_all_places_where_questions_were_attempted(): void {
|
||||
$this->resetAfterTest();
|
||||
$this->setAdminUser();
|
||||
|
||||
$rcm = new \ReflectionMethod(helper::class, 'get_all_places_where_questions_were_attempted');
|
||||
$rcm->setAccessible(true);
|
||||
|
||||
// Create a course.
|
||||
$course = $this->getDataGenerator()->create_course();
|
||||
|
||||
// Create quizzes.
|
||||
// Create three quizzes.
|
||||
$quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz');
|
||||
$quiz1 = $quizgenerator->create_instance([
|
||||
'course' => $course->id,
|
||||
'grade' => 100.0, 'sumgrades' => 2,
|
||||
'layout' => '1,2,0'
|
||||
]);
|
||||
$quiz1context = \context_module::instance($quiz1->cmid);
|
||||
|
||||
$quiz2 = $quizgenerator->create_instance([
|
||||
'course' => $course->id,
|
||||
'grade' => 100.0, 'sumgrades' => 2,
|
||||
'layout' => '1,2,0'
|
||||
]);
|
||||
$quiz2context = \context_module::instance($quiz2->cmid);
|
||||
|
||||
$quiz3 = $quizgenerator->create_instance([
|
||||
'course' => $course->id,
|
||||
'grade' => 100.0, 'sumgrades' => 2,
|
||||
'layout' => '1,2,0'
|
||||
]);
|
||||
$this->assertEquals(3, $DB->count_records('quiz'));
|
||||
$quiz3context = \context_module::instance($quiz3->cmid);
|
||||
|
||||
// Create questions.
|
||||
/** @var \core_question_generator $questiongenerator */
|
||||
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
|
||||
$cat = $questiongenerator->create_question_category();
|
||||
$question1 = $questiongenerator->create_question('shortanswer', null, array('category' => $cat->id));
|
||||
$question2 = $questiongenerator->create_question('numerical', null, array('category' => $cat->id));
|
||||
|
||||
// Add question 1 to quiz 1, 2.
|
||||
// Add question 1 to quiz 1 and make an attempt.
|
||||
quiz_add_quiz_question($question1->id, $quiz1);
|
||||
quiz_add_quiz_question($question1->id, $quiz2);
|
||||
// Quiz 1 attempt.
|
||||
$attempt = ['answer' => 'frog', 'answer' => 10];
|
||||
$this->submit_quiz($quiz1, $attempt);
|
||||
$this->submit_quiz($quiz1, [1 => ['answer' => 'frog']]);
|
||||
|
||||
// Add question 2 to quiz 2.
|
||||
// Add questions 1 and 2 to quiz 2.
|
||||
quiz_add_quiz_question($question1->id, $quiz2);
|
||||
quiz_add_quiz_question($question2->id, $quiz2);
|
||||
$this->submit_quiz($quiz2, $attempt);
|
||||
$this->submit_quiz($quiz2, [1 => ['answer' => 'frog'], 2 => ['answer' => 10]]);
|
||||
|
||||
// Checking quizzes that use question 1.
|
||||
$question1quizzes = helper::get_quizzes($question1->id);
|
||||
$this->assertCount(2, $question1quizzes);
|
||||
$this->assertContains($quiz1->id, $question1quizzes);
|
||||
$this->assertContains($quiz2->id, $question1quizzes);
|
||||
$q1places = $rcm->invoke(null, [$question1->id]);
|
||||
$this->assertCount(2, $q1places);
|
||||
$this->assertEquals((object) ['component' => 'mod_quiz', 'contextid' => $quiz1context->id], $q1places[0]);
|
||||
$this->assertEquals((object) ['component' => 'mod_quiz', 'contextid' => $quiz2context->id], $q1places[1]);
|
||||
|
||||
// Checking quizzes that contain question 2.
|
||||
$question2quizzes = helper::get_quizzes($question2->id);
|
||||
$this->assertCount(1, $question2quizzes);
|
||||
$this->assertContains($quiz2->id, $question2quizzes);
|
||||
$q2places = $rcm->invoke(null, [$question2->id]);
|
||||
$this->assertCount(1, $q2places);
|
||||
$this->assertEquals((object) ['component' => 'mod_quiz', 'contextid' => $quiz2context->id], $q2places[0]);
|
||||
|
||||
// Add random question to quiz3.
|
||||
// Add a random question to quiz3.
|
||||
quiz_add_random_questions($quiz3, 0, $cat->id, 1, false);
|
||||
$this->submit_quiz($quiz3, $attempt);
|
||||
// Quiz 3 will be in one of these arrays.
|
||||
$question1quizzes = helper::get_quizzes($question1->id);
|
||||
$question2quizzes = helper::get_quizzes($question2->id);
|
||||
$this->assertContains($quiz3->id, array_merge($question1quizzes, $question2quizzes));
|
||||
}
|
||||
$this->submit_quiz($quiz3, [1 => ['answer' => 'willbewrong']]);
|
||||
|
||||
/**
|
||||
* Load facility for a question
|
||||
*
|
||||
* @param object $quiz quiz object
|
||||
* @param int $questionid question id
|
||||
* @return float|int
|
||||
*/
|
||||
private function load_question_facility(object $quiz, int $questionid): ?float {
|
||||
return helper::load_question_stats_item($quiz->id, $questionid, 'facility');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load discriminative efficiency for a question
|
||||
*
|
||||
* @param object $quiz quiz object
|
||||
* @param int $questionid question id
|
||||
* @return float|int
|
||||
*/
|
||||
private function load_question_discriminative_efficiency(object $quiz, int $questionid): ?float {
|
||||
return helper::load_question_stats_item($quiz->id, $questionid, 'discriminativeefficiency');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load discrimination index for a question
|
||||
*
|
||||
* @param object $quiz quiz object
|
||||
* @param int $questionid question id
|
||||
* @return float|int
|
||||
*/
|
||||
private function load_question_discrimination_index(object $quiz, int $questionid): ?float {
|
||||
return helper::load_question_stats_item($quiz->id, $questionid, 'discriminationindex');
|
||||
// Quiz 3 will now be in one of these arrays.
|
||||
$q1places = $rcm->invoke(null, [$question1->id]);
|
||||
$q2places = $rcm->invoke(null, [$question2->id]);
|
||||
if (count($q1places) == 3) {
|
||||
$newplace = end($q1places);
|
||||
} else {
|
||||
$newplace = end($q2places);
|
||||
}
|
||||
$this->assertEquals((object) ['component' => 'mod_quiz', 'contextid' => $quiz3context->id], $newplace);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -160,6 +139,7 @@ class helper_test extends \advanced_testcase {
|
||||
'layout' => $layout
|
||||
]);
|
||||
|
||||
/** @var \core_question_generator $questiongenerator */
|
||||
$questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
|
||||
$cat = $questiongenerator->create_question_category();
|
||||
|
||||
@ -195,7 +175,7 @@ class helper_test extends \advanced_testcase {
|
||||
$quba = question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context());
|
||||
$quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour);
|
||||
$timenow = time();
|
||||
$attempt = quiz_create_attempt($quizobj, 1, false, $timenow, false, $user->id);
|
||||
$attempt = quiz_create_attempt($quizobj, 1, null, $timenow, false, $user->id);
|
||||
quiz_start_new_attempt($quizobj, $quba, $attempt, 1, $timenow);
|
||||
quiz_attempt_save_started($quizobj, $quba, $attempt);
|
||||
// Submit attempt.
|
||||
@ -248,6 +228,33 @@ class helper_test extends \advanced_testcase {
|
||||
return [$quiz1, $quiz2, $questions];
|
||||
}
|
||||
|
||||
/**
|
||||
* To use private helper::extract_item_value function.
|
||||
*
|
||||
* @param all_calculated_for_qubaid_condition $statistics the batch of statistics.
|
||||
* @param int $questionid a question id.
|
||||
* @param string $item ane of the field names in all_calculated_for_qubaid_condition, e.g. 'facility'.
|
||||
* @return float|null the required value.
|
||||
*/
|
||||
private function extract_item_value(all_calculated_for_qubaid_condition $statistics,
|
||||
int $questionid, string $item): ?float {
|
||||
$rcm = new \ReflectionMethod(helper::class, 'extract_item_value');
|
||||
$rcm->setAccessible(true);
|
||||
return $rcm->invoke(null, $statistics, $questionid, $item);
|
||||
}
|
||||
|
||||
/**
|
||||
* To use private helper::load_statistics_for_place function (with mod_quiz component).
|
||||
*
|
||||
* @param \context $context the context to load the statistics for.
|
||||
* @return all_calculated_for_qubaid_condition|null question statistics.
|
||||
*/
|
||||
private function load_quiz_statistics_for_place(\context $context): ?all_calculated_for_qubaid_condition {
|
||||
$rcm = new \ReflectionMethod(helper::class, 'load_statistics_for_place');
|
||||
$rcm->setAccessible(true);
|
||||
return $rcm->invoke(null, 'mod_quiz', $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Data provider for {@see test_load_question_facility()}.
|
||||
*
|
||||
@ -307,10 +314,11 @@ class helper_test extends \advanced_testcase {
|
||||
list($quiz1, $quiz2, $questions) = $this->prepare_and_submit_quizzes($quiz1attempts, $quiz2attempts);
|
||||
|
||||
// Quiz 1 facilities.
|
||||
$quiz1facility1 = $this->load_question_facility($quiz1, $questions[1]->id);
|
||||
$quiz1facility2 = $this->load_question_facility($quiz1, $questions[2]->id);
|
||||
$quiz1facility3 = $this->load_question_facility($quiz1, $questions[3]->id);
|
||||
$quiz1facility4 = $this->load_question_facility($quiz1, $questions[4]->id);
|
||||
$stats = $this->load_quiz_statistics_for_place(\context_module::instance($quiz1->cmid));
|
||||
$quiz1facility1 = $this->extract_item_value($stats, $questions[1]->id, 'facility');
|
||||
$quiz1facility2 = $this->extract_item_value($stats, $questions[2]->id, 'facility');
|
||||
$quiz1facility3 = $this->extract_item_value($stats, $questions[3]->id, 'facility');
|
||||
$quiz1facility4 = $this->extract_item_value($stats, $questions[4]->id, 'facility');
|
||||
|
||||
$this->assertEquals($expectedquiz1facilities[0], helper::format_percentage($quiz1facility1));
|
||||
$this->assertEquals($expectedquiz1facilities[1], helper::format_percentage($quiz1facility2));
|
||||
@ -318,10 +326,11 @@ class helper_test extends \advanced_testcase {
|
||||
$this->assertEquals($expectedquiz1facilities[3], helper::format_percentage($quiz1facility4));
|
||||
|
||||
// Quiz 2 facilities.
|
||||
$quiz2facility1 = $this->load_question_facility($quiz2, $questions[1]->id);
|
||||
$quiz2facility2 = $this->load_question_facility($quiz2, $questions[2]->id);
|
||||
$quiz2facility3 = $this->load_question_facility($quiz2, $questions[3]->id);
|
||||
$quiz2facility4 = $this->load_question_facility($quiz2, $questions[4]->id);
|
||||
$stats = $this->load_quiz_statistics_for_place(\context_module::instance($quiz2->cmid));
|
||||
$quiz2facility1 = $this->extract_item_value($stats, $questions[1]->id, 'facility');
|
||||
$quiz2facility2 = $this->extract_item_value($stats, $questions[2]->id, 'facility');
|
||||
$quiz2facility3 = $this->extract_item_value($stats, $questions[3]->id, 'facility');
|
||||
$quiz2facility4 = $this->extract_item_value($stats, $questions[4]->id, 'facility');
|
||||
|
||||
$this->assertEquals($expectedquiz2facilities[0], helper::format_percentage($quiz2facility1));
|
||||
$this->assertEquals($expectedquiz2facilities[1], helper::format_percentage($quiz2facility2));
|
||||
@ -344,7 +353,7 @@ class helper_test extends \advanced_testcase {
|
||||
* Data provider for {@see test_load_question_discriminative_efficiency()}.
|
||||
* @return \Generator
|
||||
*/
|
||||
public function load_question_discriminative_efficiency_provider() {
|
||||
public function load_question_discriminative_efficiency_provider(): \Generator {
|
||||
yield 'Discriminative efficiency' => [
|
||||
'Quiz 1 attempts' => [
|
||||
$this->generate_attempt_answers([1, 0, 0, 0]),
|
||||
@ -387,10 +396,11 @@ class helper_test extends \advanced_testcase {
|
||||
list($quiz1, $quiz2, $questions) = $this->prepare_and_submit_quizzes($quiz1attempts, $quiz2attempts);
|
||||
|
||||
// Quiz 1 discriminative efficiency.
|
||||
$discriminativeefficiency1 = $this->load_question_discriminative_efficiency($quiz1, $questions[1]->id);
|
||||
$discriminativeefficiency2 = $this->load_question_discriminative_efficiency($quiz1, $questions[2]->id);
|
||||
$discriminativeefficiency3 = $this->load_question_discriminative_efficiency($quiz1, $questions[3]->id);
|
||||
$discriminativeefficiency4 = $this->load_question_discriminative_efficiency($quiz1, $questions[4]->id);
|
||||
$stats = $this->load_quiz_statistics_for_place(\context_module::instance($quiz1->cmid));
|
||||
$discriminativeefficiency1 = $this->extract_item_value($stats, $questions[1]->id, 'discriminativeefficiency');
|
||||
$discriminativeefficiency2 = $this->extract_item_value($stats, $questions[2]->id, 'discriminativeefficiency');
|
||||
$discriminativeefficiency3 = $this->extract_item_value($stats, $questions[3]->id, 'discriminativeefficiency');
|
||||
$discriminativeefficiency4 = $this->extract_item_value($stats, $questions[4]->id, 'discriminativeefficiency');
|
||||
|
||||
$this->assertEquals($expectedquiz1discriminativeefficiency[0],
|
||||
helper::format_percentage($discriminativeefficiency1, false),
|
||||
@ -406,10 +416,11 @@ class helper_test extends \advanced_testcase {
|
||||
"Failure in quiz 1 - question 4 discriminative efficiency");
|
||||
|
||||
// Quiz 2 discriminative efficiency.
|
||||
$discriminativeefficiency1 = $this->load_question_discriminative_efficiency($quiz2, $questions[1]->id);
|
||||
$discriminativeefficiency2 = $this->load_question_discriminative_efficiency($quiz2, $questions[2]->id);
|
||||
$discriminativeefficiency3 = $this->load_question_discriminative_efficiency($quiz2, $questions[3]->id);
|
||||
$discriminativeefficiency4 = $this->load_question_discriminative_efficiency($quiz2, $questions[4]->id);
|
||||
$stats = $this->load_quiz_statistics_for_place(\context_module::instance($quiz2->cmid));
|
||||
$discriminativeefficiency1 = $this->extract_item_value($stats, $questions[1]->id, 'discriminativeefficiency');
|
||||
$discriminativeefficiency2 = $this->extract_item_value($stats, $questions[2]->id, 'discriminativeefficiency');
|
||||
$discriminativeefficiency3 = $this->extract_item_value($stats, $questions[3]->id, 'discriminativeefficiency');
|
||||
$discriminativeefficiency4 = $this->extract_item_value($stats, $questions[4]->id, 'discriminativeefficiency');
|
||||
|
||||
$this->assertEquals($expectedquiz2discriminativeefficiency[0],
|
||||
helper::format_percentage($discriminativeefficiency1, false),
|
||||
@ -448,7 +459,7 @@ class helper_test extends \advanced_testcase {
|
||||
* Data provider for {@see test_load_question_discrimination_index()}.
|
||||
* @return \Generator
|
||||
*/
|
||||
public function load_question_discrimination_index_provider() {
|
||||
public function load_question_discrimination_index_provider(): \Generator {
|
||||
yield 'Discrimination Index' => [
|
||||
'Quiz 1 attempts' => [
|
||||
$this->generate_attempt_answers([1, 0, 0, 0]),
|
||||
@ -491,10 +502,11 @@ class helper_test extends \advanced_testcase {
|
||||
list($quiz1, $quiz2, $questions) = $this->prepare_and_submit_quizzes($quiz1attempts, $quiz2attempts);
|
||||
|
||||
// Quiz 1 discrimination index.
|
||||
$discriminationindex1 = $this->load_question_discrimination_index($quiz1, $questions[1]->id);
|
||||
$discriminationindex2 = $this->load_question_discrimination_index($quiz1, $questions[2]->id);
|
||||
$discriminationindex3 = $this->load_question_discrimination_index($quiz1, $questions[3]->id);
|
||||
$discriminationindex4 = $this->load_question_discrimination_index($quiz1, $questions[4]->id);
|
||||
$stats = $this->load_quiz_statistics_for_place(\context_module::instance($quiz1->cmid));
|
||||
$discriminationindex1 = $this->extract_item_value($stats, $questions[1]->id, 'discriminationindex');
|
||||
$discriminationindex2 = $this->extract_item_value($stats, $questions[2]->id, 'discriminationindex');
|
||||
$discriminationindex3 = $this->extract_item_value($stats, $questions[3]->id, 'discriminationindex');
|
||||
$discriminationindex4 = $this->extract_item_value($stats, $questions[4]->id, 'discriminationindex');
|
||||
|
||||
$this->assertEquals($expectedquiz1discriminationindex[0],
|
||||
helper::format_percentage($discriminationindex1, false),
|
||||
@ -510,10 +522,11 @@ class helper_test extends \advanced_testcase {
|
||||
"Failure in quiz 1 - question 4 discrimination index");
|
||||
|
||||
// Quiz 2 discrimination index.
|
||||
$discriminationindex1 = $this->load_question_discrimination_index($quiz2, $questions[1]->id);
|
||||
$discriminationindex2 = $this->load_question_discrimination_index($quiz2, $questions[2]->id);
|
||||
$discriminationindex3 = $this->load_question_discrimination_index($quiz2, $questions[3]->id);
|
||||
$discriminationindex4 = $this->load_question_discrimination_index($quiz2, $questions[4]->id);
|
||||
$stats = $this->load_quiz_statistics_for_place(\context_module::instance($quiz2->cmid));
|
||||
$discriminationindex1 = $this->extract_item_value($stats, $questions[1]->id, 'discriminationindex');
|
||||
$discriminationindex2 = $this->extract_item_value($stats, $questions[2]->id, 'discriminationindex');
|
||||
$discriminationindex3 = $this->extract_item_value($stats, $questions[3]->id, 'discriminationindex');
|
||||
$discriminationindex4 = $this->extract_item_value($stats, $questions[4]->id, 'discriminationindex');
|
||||
|
||||
$this->assertEquals($expectedquiz2discriminationindex[0],
|
||||
helper::format_percentage($discriminationindex1, false),
|
||||
|
Loading…
x
Reference in New Issue
Block a user