first commit

This commit is contained in:
CHIEFSOFT\ameye
2024-09-30 18:11:26 -04:00
commit e592ca6823
27270 changed files with 5002257 additions and 0 deletions
@@ -0,0 +1,152 @@
<?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/>.
/**
* Manage question banks page.
*
* @package core_question
* @copyright 2021 Catalyst IT Australia Pty Ltd
* @author Safat Shahin <safatshahin@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_question\admin;
/**
* Class manage_qbank_plugins_page.
*
* @copyright 2021 Catalyst IT Australia Pty Ltd
* @author Safat Shahin <safatshahin@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class manage_qbank_plugins_page extends \admin_setting {
/**
* Class admin_page_manageqbanks constructor.
*/
public function __construct() {
$this->nosave = true;
parent::__construct('manageqbanks',
new \lang_string('manageqbanks', 'admin'), '', '');
}
public function get_setting(): bool {
return true;
}
public function get_defaultsetting(): bool {
return true;
}
public function write_setting($data): string {
// Do not write any setting.
return '';
}
public function is_related($query): bool {
if (parent::is_related($query)) {
return true;
}
$types = \core_plugin_manager::instance()->get_plugins_of_type('qbank');
foreach ($types as $type) {
if (strpos($type->component, $query) !== false ||
strpos(\core_text::strtolower($type->displayname), $query) !== false) {
return true;
}
}
return false;
}
public function output_html($data, $query = ''): string {
global $CFG, $OUTPUT;
$return = '';
$pluginmanager = \core_plugin_manager::instance();
$types = $pluginmanager->get_plugins_of_type('qbank');
if (empty($types)) {
return get_string('noquestionbanks', 'question');
}
$txt = get_strings(['settings', 'name', 'enable', 'disable', 'default']);
$txt->uninstall = get_string('uninstallplugin', 'core_admin');
$table = new \html_table();
$table->head = [$txt->name, $txt->enable, $txt->settings, $txt->uninstall];
$table->align = ['left', 'center', 'center', 'center', 'center'];
$table->attributes['class'] = 'manageqbanktable generaltable admintable';
$table->data = [];
$totalenabled = 0;
$count = 0;
foreach ($types as $type) {
if ($type->is_enabled() && $type->is_installed_and_upgraded()) {
$totalenabled++;
}
}
foreach ($types as $type) {
$url = new \moodle_url('/admin/qbankplugins.php', ['sesskey' => sesskey(), 'name' => $type->name]);
$class = '';
if ($pluginmanager->get_plugin_info('qbank_'.$type->name)->get_status() ===
\core_plugin_manager::PLUGIN_STATUS_MISSING) {
$strtypename = $type->displayname.' ('.get_string('missingfromdisk').')';
} else {
$strtypename = $type->displayname;
}
if ($type->is_enabled()) {
$hideshow = \html_writer::link($url->out(false, ['action' => 'disable']),
$OUTPUT->pix_icon('t/hide', $txt->disable, 'moodle', ['class' => 'iconsmall']));
} else {
$class = 'dimmed_text';
$hideshow = \html_writer::link($url->out(false, ['action' => 'enable']),
$OUTPUT->pix_icon('t/show', $txt->enable, 'moodle', ['class' => 'iconsmall']));
}
$settings = '';
if ($type->get_settings_url()) {
$settings = \html_writer::link($type->get_settings_url(), $txt->settings);
}
$uninstall = '';
if ($uninstallurl = \core_plugin_manager::instance()->get_uninstall_url(
'qbank_'.$type->name, 'manage')) {
$uninstall = \html_writer::link($uninstallurl, $txt->uninstall);
}
$row = new \html_table_row([$strtypename, $hideshow, $settings, $uninstall]);
if ($class) {
$row->attributes['class'] = $class;
}
$table->data[] = $row;
$count++;
}
// Sort table data.
usort($table->data, function($a, $b) {
$aid = $a->cells[0]->text;
$bid = $b->cells[0]->text;
if ($aid == $bid) {
return 0;
}
return $aid < $bid ? -1 : 1;
});
$return .= \html_writer::table($table);
return highlight($query, $return);
}
}
@@ -0,0 +1,137 @@
<?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/>.
/**
* A {@link \question_variant_selection_strategy} that randomly selects variants that were not used yet.
*
* @package core_question
* @copyright 2015 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_question\engine\variants;
defined('MOODLE_INTERNAL') || die();
/**
* A {@link \question_variant_selection_strategy} that randomly selects variants that were not used yet.
*
* If all variants have been used at least once in the set of usages under
* consideration, then then it picks one of the least often used.
*
* Within one particular use of this class, each seed will always select the
* same variant. This is so that shared datasets work in calculated questions,
* and similar features in question types like varnumeric and STACK.
*
* @copyright 2015 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class least_used_strategy implements \question_variant_selection_strategy {
/** @var array seed => variant number => number of uses. */
protected $variantsusecounts = array();
/** @var array seed => variant number. */
protected $selectedvariant = array();
/**
* Constructor.
* @param \question_usage_by_activity $quba the question usage we will be picking variants for.
* @param \qubaid_condition $qubaids ids of the usages to consider when counting previous uses of each variant.
*/
public function __construct(\question_usage_by_activity $quba, \qubaid_condition $qubaids) {
$questionidtoseed = array();
foreach ($quba->get_attempt_iterator() as $qa) {
$question = $qa->get_question();
if ($question->get_num_variants() > 1) {
$questionidtoseed[$question->id] = $question->get_variants_selection_seed();
}
}
if (empty($questionidtoseed)) {
return;
}
$this->variantsusecounts = array_fill_keys($questionidtoseed, array());
$variantsused = \question_engine::load_used_variants(array_keys($questionidtoseed), $qubaids);
foreach ($variantsused as $questionid => $usagecounts) {
$seed = $questionidtoseed[$questionid];
foreach ($usagecounts as $variant => $count) {
if (isset($this->variantsusecounts[$seed][$variant])) {
$this->variantsusecounts[$seed][$variant] += $count;
} else {
$this->variantsusecounts[$seed][$variant] = $count;
}
}
}
}
public function choose_variant($maxvariants, $seed) {
if ($maxvariants == 1) {
return 1;
}
if (isset($this->selectedvariant[$seed])) {
return $this->selectedvariant[$seed];
}
// Catch a possible programming error, and make the problem clear.
if (!isset($this->variantsusecounts[$seed])) {
debugging('Variant requested for unknown seed ' . $seed . '. ' .
'You must add all questions to the usage before creating the least_used_strategy. ' .
'Continuing, but the variant choses may not actually be least used.',
DEBUG_DEVELOPER);
$this->variantsusecounts[$seed] = array();
}
if ($maxvariants > 2 * count($this->variantsusecounts[$seed])) {
// Many many more variants exist than have been used so far.
// It will be quicker to just pick until we miss a collision.
do {
$variant = rand(1, $maxvariants);
} while (isset($this->variantsusecounts[$seed][$variant]));
} else {
// We need to work harder to find a least-used one.
$leastusedvariants = array();
for ($variant = 1; $variant <= $maxvariants; ++$variant) {
if (!isset($this->variantsusecounts[$seed][$variant])) {
$leastusedvariants[$variant] = 1;
}
}
if (empty($leastusedvariants)) {
// All variants used at least once, try again.
$leastuses = min($this->variantsusecounts[$seed]);
foreach ($this->variantsusecounts[$seed] as $variant => $uses) {
if ($uses == $leastuses) {
$leastusedvariants[$variant] = 1;
}
}
}
$variant = array_rand($leastusedvariants);
}
$this->selectedvariant[$seed] = $variant;
if (isset($variantsusecounts[$seed][$variant])) {
$variantsusecounts[$seed][$variant] += 1;
} else {
$variantsusecounts[$seed][$variant] = 1;
}
return $variant;
}
}
+242
View File
@@ -0,0 +1,242 @@
<?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/>.
/**
* External question API
*
* @package core_question
* @category external
* @copyright 2016 Pau Ferrer <pau@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
use core_external\external_api;
use core_external\external_description;
use core_external\external_value;
use core_external\external_single_structure;
use core_external\external_multiple_structure;
use core_external\external_function_parameters;
use core_external\external_warnings;
defined('MOODLE_INTERNAL') || die();
require_once($CFG->dirroot . '/question/engine/lib.php');
require_once($CFG->dirroot . '/question/engine/datalib.php');
require_once($CFG->libdir . '/questionlib.php');
/**
* Question external functions
*
* @package core_question
* @category external
* @copyright 2016 Pau Ferrer <pau@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @since Moodle 3.1
*/
class core_question_external extends external_api {
/**
* Returns description of method parameters
*
* @return external_function_parameters
* @since Moodle 3.1
*/
public static function update_flag_parameters() {
return new external_function_parameters(
array(
'qubaid' => new external_value(PARAM_INT, 'the question usage id.'),
'questionid' => new external_value(PARAM_INT, 'the question id'),
'qaid' => new external_value(PARAM_INT, 'the question_attempt id'),
'slot' => new external_value(PARAM_INT, 'the slot number within the usage'),
'checksum' => new external_value(PARAM_ALPHANUM, 'computed checksum with the last three arguments and
the users username'),
'newstate' => new external_value(PARAM_BOOL, 'the new state of the flag. true = flagged')
)
);
}
/**
* Update the flag state of a question attempt.
*
* @param int $qubaid the question usage id.
* @param int $questionid the question id.
* @param int $qaid the question_attempt id.
* @param int $slot the slot number within the usage.
* @param string $checksum checksum, as computed by {@link get_toggle_checksum()}
* corresponding to the last three arguments and the users username.
* @param bool $newstate the new state of the flag. true = flagged.
* @return array (success infos and fail infos)
* @since Moodle 3.1
*/
public static function update_flag($qubaid, $questionid, $qaid, $slot, $checksum, $newstate) {
global $CFG, $DB;
$params = self::validate_parameters(self::update_flag_parameters(),
array(
'qubaid' => $qubaid,
'questionid' => $questionid,
'qaid' => $qaid,
'slot' => $slot,
'checksum' => $checksum,
'newstate' => $newstate
)
);
$warnings = array();
self::validate_context(context_system::instance());
// The checksum will be checked to provide security flagging other users questions.
question_flags::update_flag($params['qubaid'], $params['questionid'], $params['qaid'], $params['slot'], $params['checksum'],
$params['newstate']);
$result = array();
$result['status'] = true;
$result['warnings'] = $warnings;
return $result;
}
/**
* Returns description of method result value
*
* @return external_description
* @since Moodle 3.1
*/
public static function update_flag_returns() {
return new external_single_structure(
array(
'status' => new external_value(PARAM_BOOL, 'status: true if success'),
'warnings' => new external_warnings()
)
);
}
/**
* Returns description of method parameters.
*
* @return external_function_parameters.
*/
public static function get_random_question_summaries_parameters() {
return new external_function_parameters([
'categoryid' => new external_value(PARAM_INT, 'Category id to find random questions'),
'includesubcategories' => new external_value(PARAM_BOOL, 'Include the subcategories in the search'),
'tagids' => new external_multiple_structure(
new external_value(PARAM_INT, 'Tag id')
),
'contextid' => new external_value(PARAM_INT,
'Context id that the questions will be rendered in (used for exporting)'),
'limit' => new external_value(PARAM_INT, 'Maximum number of results to return',
VALUE_DEFAULT, 0),
'offset' => new external_value(PARAM_INT, 'Number of items to skip from the begging of the result set',
VALUE_DEFAULT, 0)
]);
}
/**
* Gets the list of random questions for the given criteria. The questions
* will be exported in a summaries format and won't include all of the
* question data.
*
* @param int $categoryid Category id to find random questions
* @param bool $includesubcategories Include the subcategories in the search
* @param int[] $tagids Only include questions with these tags
* @param int $contextid The context id where the questions will be rendered
* @param int $limit Maximum number of results to return
* @param int $offset Number of items to skip from the beginning of the result set.
* @return array The list of questions and total question count.
*/
public static function get_random_question_summaries(
$categoryid,
$includesubcategories,
$tagids,
$contextid,
$limit = 0,
$offset = 0
) {
global $DB, $PAGE;
// Parameter validation.
$params = self::validate_parameters(
self::get_random_question_summaries_parameters(),
[
'categoryid' => $categoryid,
'includesubcategories' => $includesubcategories,
'tagids' => $tagids,
'contextid' => $contextid,
'limit' => $limit,
'offset' => $offset
]
);
$categoryid = $params['categoryid'];
$includesubcategories = $params['includesubcategories'];
$tagids = $params['tagids'];
$contextid = $params['contextid'];
$limit = $params['limit'];
$offset = $params['offset'];
$context = \context::instance_by_id($contextid);
self::validate_context($context);
$categorycontextid = $DB->get_field('question_categories', 'contextid', ['id' => $categoryid], MUST_EXIST);
$categorycontext = \context::instance_by_id($categorycontextid);
$editcontexts = new \core_question\local\bank\question_edit_contexts($categorycontext);
// The user must be able to view all questions in the category that they are requesting.
$editcontexts->require_cap('moodle/question:viewall');
$loader = new \core_question\local\bank\random_question_loader(new qubaid_list([]));
// Only load the properties we require from the DB.
$properties = \core_question\external\question_summary_exporter::get_mandatory_properties();
// Transform to filters.
$filters = [
'category' => [
'jointype' => \qbank_managecategories\category_condition::JOINTYPE_DEFAULT,
'values' => [$categoryid],
'filteroptions' => ['includesubcategories' => $includesubcategories],
],
'qtagids' => [
'jointype' => \qbank_tagquestion\tag_condition::JOINTYPE_DEFAULT,
'values' => $tagids,
],
];
$questions = $loader->get_filtered_questions($filters, $limit, $offset, $properties);
$totalcount = $loader->count_filtered_questions($filters);
$renderer = $PAGE->get_renderer('core');
$formattedquestions = array_map(function($question) use ($context, $renderer) {
$exporter = new \core_question\external\question_summary_exporter($question, ['context' => $context]);
return $exporter->export($renderer);
}, $questions);
return [
'totalcount' => $totalcount,
'questions' => $formattedquestions
];
}
/**
* Returns description of method result value.
*/
public static function get_random_question_summaries_returns() {
return new external_single_structure([
'totalcount' => new external_value(PARAM_INT, 'total number of questions in result set'),
'questions' => new external_multiple_structure(
\core_question\external\question_summary_exporter::get_read_structure()
)
]);
}
}
+74
View File
@@ -0,0 +1,74 @@
<?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/>.
/**
* Class for exporting a question icon from an stdClass.
*
* @package core_question
* @copyright 2018 Ryan Wyllie <ryan@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_question\external;
defined('MOODLE_INTERNAL') || die();
require_once($CFG->dirroot . '/question/engine/bank.php');
/**
* Class for exporting a question from an stdClass.
*
* @copyright 2018 Ryan Wyllie <ryan@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class question_icon_exporter extends \core\external\exporter {
/**
* Constructor.
*
* @param \stdClass $question
* @param array $related The related data.
*/
public function __construct(\stdClass $question, $related = []) {
$qtype = \question_bank::get_qtype($question->qtype, false);
parent::__construct((object) [
'key' => 'icon',
'component' => $qtype->plugin_name(),
'alttext' => $qtype->local_name()
], $related);
}
/**
* Set the moodle context as a required related object.
*
* @return array Required related objects.
*/
protected static function define_related() {
return ['context' => '\\context'];
}
/**
* Return the list of properties.
*
* @return array
*/
protected static function define_properties() {
return [
'key' => ['type' => PARAM_TEXT],
'component' => ['type' => PARAM_COMPONENT],
'alttext' => ['type' => PARAM_TEXT],
];
}
}
+128
View File
@@ -0,0 +1,128 @@
<?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/>.
/**
* Class for exporting a question summary from an stdClass.
*
* @package core_question
* @copyright 2018 Ryan Wyllie <ryan@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_question\external;
defined('MOODLE_INTERNAL') || die();
use \renderer_base;
/**
* Class for exporting a question summary from an stdClass.
*
* @copyright 2018 Ryan Wyllie <ryan@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class question_summary_exporter extends \core\external\exporter {
/**
* @var \stdClass $question
*/
protected $question;
/**
* Constructor.
*
* @param \stdClass $question
* @param array $related The related data.
*/
public function __construct(\stdClass $question, $related = []) {
$this->question = $question;
return parent::__construct($question, $related);
}
/**
* Set the moodle context as a required related object.
*
* @return array Required related objects.
*/
protected static function define_related() {
return ['context' => '\\context'];
}
/**
* The list of mandatory properties required on the question object to
* export.
*
* @return string[] List of properties.
*/
public static function get_mandatory_properties() {
$properties = self::define_properties();
$mandatoryproperties = array_filter($properties, function($property) {
return empty($property['optional']);
});
return array_keys($mandatoryproperties);
}
/**
* The list of static properties returned.
*
* @return array List of properties.
*/
public static function define_properties() {
return [
'id' => [
'type' => PARAM_INT,
],
'category' => [
'type' => PARAM_INT,
],
'parent' => [
'type' => PARAM_INT,
],
'name' => [
'type' => PARAM_TEXT,
],
'qtype' => [
'type' => PARAM_COMPONENT,
]
];
}
/**
* Define the list of calculated properties.
*
* @return array The list of properties.
*/
protected static function define_other_properties() {
return [
'icon' => [
'type' => question_icon_exporter::read_properties_definition(),
]
];
}
/**
* Calculate the values for the properties defined in the define_other_properties
* function.
*
* @param renderer_base $output A renderer.
* @return array The list of properties.
*/
protected function get_other_values(\renderer_base $output) {
$iconexporter = new question_icon_exporter($this->question, $this->related);
return [
'icon' => $iconexporter->export($output),
];
}
}
@@ -0,0 +1,93 @@
<?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/>.
/**
* A base class for actions that are an icon that lets you manipulate the question in some way.
*
* @package core_question
* @copyright 2009 Tim Hunt
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_question\local\bank;
/**
* A base class for actions that are an icon that lets you manipulate the question in some way.
*
* @copyright 2009 Tim Hunt
* @author 2021 Safat Shahin <safatshahin@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @deprecated Since Moodle 4.3 MDL-75125 - Use question_action_base instead.
* @todo MDL-78090 This class will be deleted in Moodle 4.7
*/
abstract class action_column_base extends column_base {
/**
* @return string
* @deprecated Since Moodle 4.3
*/
public function get_title(): string {
debugging('The action_column_base class is deprecated. Please use question_action_base instead.', DEBUG_DEVELOPER);
return '&#160;';
}
/**
* @return string[]
* @deprecated Since Moodle 4.3
*/
public function get_extra_classes(): array {
debugging('The action_column_base class is deprecated. Please use question_action_base instead.', DEBUG_DEVELOPER);
return ['iconcol'];
}
/**
* @param $icon
* @param $title
* @param $url
* @return void
* @deprecated Since Moodle 4.3
*/
protected function print_icon($icon, $title, $url): void {
debugging('The action_column_base class is deprecated. Please use question_action_base instead.', DEBUG_DEVELOPER);
global $OUTPUT;
echo \html_writer::tag('a', $OUTPUT->pix_icon($icon, $title), ['title' => $title, 'href' => $url]);
}
/**
* @return string[]
* @deprecated Since Moodle 4.3
*/
public function get_extra_joins(): array {
debugging('The action_column_base class is deprecated. Please use question_action_base instead.', DEBUG_DEVELOPER);
return ['qv' => 'JOIN {question_versions} qv ON qv.questionid = q.id',
'qbe' => 'JOIN {question_bank_entries} qbe on qbe.id = qv.questionbankentryid',
'qc' => 'JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid'];
}
/**
* @return string[]
* @deprecated Since Moodle 4.3
*/
public function get_required_fields(): array {
debugging('The action_column_base class is deprecated. Please use question_action_base instead.', DEBUG_DEVELOPER);
// Createdby is required for permission checks.
// Qtype so we can easily avoid applying actions to question types that
// are no longer installed.
return ['q.id', 'q.qtype', 'q.createdby', 'qc.contextid'];
}
}
@@ -0,0 +1,78 @@
<?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 core_question\local\bank;
/**
* Class bulk_action_base is the base class for bulk actions ui.
*
* Every plugin wants to implement a bulk action, should extend this class, add appropriate values to the methods
* and finally pass this object via plugin_feature class.
*
* @package core_question
* @copyright 2021 Catalyst IT Australia Pty Ltd
* @author Safat Shahin <safatshahin@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class bulk_action_base {
/**
* Title of the bulk action.
* Every bulk action will have a string to show in the list.
*
* @return string
*/
abstract public function get_bulk_action_title(): string;
/**
* A unique key for the bulk action, this will be used in the api to identify the action data.
* Every bulk must have a unique key to perform the action as a part of the form post in the base view.
* When questions are selected, it will post according to the key its selected from the dropdown.
*
* @return string
*/
abstract function get_key(): string;
/**
* URL of the bulk action redirect page.
* Bulk action can be performed by redirecting to a page and doing the appropriate selection
* and finally doing the action. The url will be url of the page where users will be redirected to
* select what to do with the selected questions.
*
* @return \moodle_url
*/
abstract public function get_bulk_action_url(): \moodle_url;
/**
* Get the capabilities for the bulk action.
* The bulk actions might have some capabilities to action them as a user.
* This method helps to get those caps which will be used by the base view before actioning the bulk action.
* For ex: ['moodle/question:moveall', 'moodle/question:add']
* At least one of the cap need to be true for the user to use this action.
*
* @return array|null
*/
public function get_bulk_action_capabilities(): ?array {
return null;
}
/**
* @deprecated since Moodle 4.0
*/
public function get_bulk_action_key() {
throw new \coding_exception(__FUNCTION__ . '() has been removed.');
}
}
@@ -0,0 +1,102 @@
<?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/>.
/**
* A column with a checkbox for each question with name q{questionid}.
*
* @package core_question
* @copyright 2009 Tim Hunt
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_question\local\bank;
use core\output\checkbox_toggleall;
/**
* A column with a checkbox for each question with name q{questionid}.
*
* @copyright 2009 Tim Hunt
* @author 2021 Safat Shahin <safatshahin@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class checkbox_column extends column_base {
public function get_name(): string {
return 'checkbox';
}
public function get_title() {
global $OUTPUT;
$mastercheckbox = new checkbox_toggleall('qbank', true, [
'id' => 'qbheadercheckbox',
'name' => 'qbheadercheckbox',
'value' => '1',
'label' => get_string('selectall'),
'labelclasses' => 'accesshide',
]);
return $OUTPUT->render($mastercheckbox);
}
public function get_title_tip() {
return get_string('selectquestionsforbulk', 'question');
}
public function display_header(array $columnactions = [], string $width = ''): void {
global $PAGE;
$renderer = $PAGE->get_renderer('core_question', 'bank');
$data = [];
$data['sortable'] = false;
$data['extraclasses'] = $this->get_classes();
$name = get_class($this);
$data['sorttip'] = true;
$data['tiptitle'] = $this->get_title();
$data['tip'] = $this->get_title_tip();
$data['colname'] = $this->get_column_name();
$data['columnid'] = $this->get_column_id();
$data['name'] = get_string('selectall');
$data['class'] = $name;
$data['width'] = $width;
echo $renderer->render_column_header($data);
}
protected function display_content($question, $rowclasses): void {
global $OUTPUT;
$checkbox = new checkbox_toggleall('qbank', false, [
'id' => "checkq{$question->id}",
'name' => "q{$question->id}",
'value' => '1',
'label' => get_string('select'),
'labelclasses' => 'accesshide',
]);
echo $OUTPUT->render($checkbox);
}
public function get_required_fields(): array {
return ['q.id'];
}
public function get_default_width(): int {
return 30;
}
}
@@ -0,0 +1,46 @@
<?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 core_question\local\bank;
/**
* Base class to implement actions that can be performed on any column.
*
* A plugin should define subclasses of this for each action it provides, and return an instance of each from
* plugin_feature::get_column_actions(). The action returned from {@see get_action_menu_link()} will be displayed in each column
* header.
*
* @package core_question
* @copyright 2023 onwards Catalyst IT EU {@link https://catalyst-eu.net}
* @author Mark Johnson <mark.johnson@catalyst-eu.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class column_action_base extends view_component {
/**
* A chance for subclasses to initialise themselves, for example to load lang strings,
* without having to override the constructor.
*/
protected function init(): void {
}
/**
* Return the action menu link for this action on the supplied column.
*
* @param column_base $column The column we are providing the action for.
* @return ?\action_menu_link The action to display in the column header.
*/
abstract public function get_action_menu_link(column_base $column): ?\action_menu_link;
}
+477
View File
@@ -0,0 +1,477 @@
<?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/>.
/**
* Base class for representing a column.
*
* @package core_question
* @copyright 1999 onwards Martin Dougiamas and others {@link http://moodle.com}
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_question\local\bank;
/**
* Base class for representing a column.
*
* @copyright 2009 Tim Hunt
* @author 2021 Safat Shahin <safatshahin@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class column_base extends view_component {
/**
* @var string A separator for joining column attributes together into a unique ID string.
*/
const ID_SEPARATOR = '-';
/**
* @var view $qbank the question bank view we are helping to render.
*/
protected $qbank;
/** @var bool determine whether the column is td or th. */
protected $isheading = false;
/** @var bool determine whether the column is visible */
public $isvisible = true;
/**
* Return an instance of this column, based on the column name.
*
* In the case of the base class, we don't actually use the column name since the class represents one specific column.
* However, sub-classes may use the column name as an additional constructor to the parameter.
*
* @param view $view Question bank view
* @param string $columnname The column name for this instance, as returned by {@see get_column_name()}
* @param bool $ingoremissing Whether to ignore if the class does not exist.
* @return column_base|null An instance of this class.
*/
public static function from_column_name(view $view, string $columnname, bool $ingoremissing = false): ?column_base {
return new static($view);
}
/**
* Set the column as heading
*/
public function set_as_heading(): void {
$this->isheading = true;
}
/**
* Check if the column is an extra row of not.
*/
public function is_extra_row(): bool {
return false;
}
/**
* Check if the row has an extra preference to view/hide.
*/
public function has_preference(): bool {
return false;
}
/**
* Get if the preference key of the row.
*/
public function get_preference_key(): string {
return '';
}
/**
* Get if the preference of the row.
*/
public function get_preference(): bool {
return false;
}
/**
* Output the column header cell.
*
* @param column_action_base[] $columnactions A list of column actions to include in the header.
* @param string $width A CSS width property value.
*/
public function display_header(array $columnactions = [], string $width = ''): void {
global $PAGE;
$renderer = $PAGE->get_renderer('core_question', 'bank');
$data = [];
$data['sortable'] = true;
$data['extraclasses'] = $this->get_classes();
$sortable = $this->is_sortable();
$name = str_replace('\\', '__', get_class($this));
$title = $this->get_title();
$tip = $this->get_title_tip();
$links = [];
if (is_array($sortable)) {
if ($title) {
$data['title'] = $title;
}
foreach ($sortable as $subsort => $details) {
$links[] = $this->make_sort_link($name . '-' . $subsort,
$details['title'], isset($details['tip']) ? $details['tip'] : '', !empty($details['reverse']));
}
$data['sortlinks'] = implode(' / ', $links);
} else if ($sortable) {
$data['sortlinks'] = $this->make_sort_link($name, $title, $tip);
} else {
$data['sortable'] = false;
$data['tiptitle'] = $title;
if ($tip) {
$data['sorttip'] = true;
$data['tip'] = $tip;
}
}
$help = $this->help_icon();
if ($help) {
$data['help'] = $help->export_for_template($renderer);
}
$data['colname'] = $this->get_column_name();
$data['columnid'] = $this->get_column_id();
$data['name'] = $title;
$data['class'] = $name;
$data['width'] = $width;
if (!empty($columnactions)) {
$actions = array_map(fn($columnaction) => $columnaction->get_action_menu_link($this), $columnactions);
$actionmenu = new \action_menu($actions);
$data['actionmenu'] = $actionmenu->export_for_template($renderer);
}
echo $renderer->render_column_header($data);
}
/**
* Title for this column. Not used if is_sortable returns an array.
*/
abstract public function get_title();
/**
* Use this when get_title() returns
* something very short, and you want a longer version as a tool tip.
*
* @return string a fuller version of the name.
*/
public function get_title_tip() {
return '';
}
/**
* If you return a help icon here, it is shown in the column header after the title.
*
* @return \help_icon|null help icon to show, if required.
*/
public function help_icon(): ?\help_icon {
return null;
}
/**
* Get a link that changes the sort order, and indicates the current sort state.
*
* @param string $sortname the column to sort on.
* @param string $title the link text.
* @param string $tip the link tool-tip text. If empty, defaults to title.
* @param bool $defaultreverse whether the default sort order for this column is descending, rather than ascending.
* @return string
*/
protected function make_sort_link($sortname, $title, $tip, $defaultreverse = false): string {
global $PAGE;
$sortdata = [];
$currentsort = $this->qbank->get_primary_sort_order($sortname);
$newsortreverse = $defaultreverse;
if ($currentsort) {
$newsortreverse = $currentsort == SORT_ASC;
}
if (!$tip) {
$tip = $title;
}
if ($newsortreverse) {
$tip = get_string('sortbyxreverse', '', $tip);
} else {
$tip = get_string('sortbyx', '', $tip);
}
$link = $title;
if ($currentsort) {
$link .= $this->get_sort_icon($currentsort == SORT_DESC);
}
$sortdata['sorturl'] = $this->qbank->new_sort_url($sortname, $newsortreverse);
$sortdata['sortname'] = $sortname;
$sortdata['sortcontent'] = $link;
$sortdata['sorttip'] = $tip;
$sortdata['sortorder'] = $newsortreverse ? SORT_DESC : SORT_ASC;
$renderer = $PAGE->get_renderer('core_question', 'bank');
return $renderer->render_column_sort($sortdata);
}
/**
* Get an icon representing the corrent sort state.
* @param bool $reverse sort is descending, not ascending.
* @return string HTML image tag.
*/
protected function get_sort_icon($reverse): string {
global $OUTPUT;
if ($reverse) {
return $OUTPUT->pix_icon('t/sort_desc', get_string('desc'), '', ['class' => 'iconsort']);
} else {
return $OUTPUT->pix_icon('t/sort_asc', get_string('asc'), '', ['class' => 'iconsort']);
}
}
/**
* Output this column.
* @param object $question the row from the $question table, augmented with extra information.
* @param string $rowclasses CSS class names that should be applied to this row of output.
*/
public function display($question, $rowclasses): void {
$this->display_start($question, $rowclasses);
$this->display_content($question, $rowclasses);
$this->display_end($question, $rowclasses);
}
/**
* Output the opening column tag. If it is set as heading, it will use <th> tag instead of <td>
*
* @param \stdClass $question
* @param string $rowclasses
*/
protected function display_start($question, $rowclasses): void {
$tag = 'td';
$attr = [
'class' => $this->get_classes(),
'data-columnid' => $this->get_column_id(),
];
if ($this->isheading) {
$tag = 'th';
$attr['scope'] = 'row';
}
echo \html_writer::start_tag($tag, $attr);
}
/**
* The CSS classes to apply to every cell in this column.
*
* @return string
*/
protected function get_classes(): string {
$classes = $this->get_extra_classes();
$classes[] = $this->get_name();
return implode(' ', $classes);
}
/**
* Get the internal name for this column. Used as a CSS class name,
* and to store information about the current sort. Must match PARAM_ALPHA.
*
* @return string column name.
*/
abstract public function get_name();
/**
* Get the name of this column. This must be unique.
* When using the inherited class to make many columns from one parent,
* ensure each instance returns a unique value.
*
* @return string The unique name;
*/
public function get_column_name() {
return (new \ReflectionClass($this))->getShortName();
}
/**
* Return a unique ID for this column object.
*
* This is constructed using the class name and get_column_name(), which must be unique.
*
* The combination of these attributes allows the object to be reconstructed, by splitting the ID into its constituent
* parts then calling {@see from_column_name()}, like this:
* [$class, $columnname] = explode(column_base::ID_SEPARATOR, $columnid, 2);
* $column = $class::from_column_name($qbank, $columnname);
* Including 2 as the $limit parameter for explode() is a good idea for safely, in case a plugin defines a column with the
* ID_SEPARATOR in the column name.
*
* @return string The column ID.
*/
final public function get_column_id(): string {
return implode(self::ID_SEPARATOR, [static::class, $this->get_column_name()]);
}
/**
* Any extra class names you would like applied to every cell in this column.
*
* @return array
*/
public function get_extra_classes(): array {
return [];
}
/**
* Return the default column width in pixels.
*
* @return int
*/
public function get_default_width(): int {
return 120;
}
/**
* Output the contents of this column.
* @param object $question the row from the $question table, augmented with extra information.
* @param string $rowclasses CSS class names that should be applied to this row of output.
*/
abstract protected function display_content($question, $rowclasses);
/**
* Output the closing column tag
*
* @param object $question
* @param string $rowclasses
*/
protected function display_end($question, $rowclasses): void {
$tag = 'td';
if ($this->isheading) {
$tag = 'th';
}
echo \html_writer::end_tag($tag);
}
public function get_extra_joins(): array {
return [];
}
public function get_required_fields(): array {
return [];
}
/**
* If this column requires any aggregated statistics, it should declare that here.
*
* This is those statistics can be efficiently loaded in bulk.
*
* The statistics are all loaded just before load_additional_data is called on each column.
* The values are then available from $this->qbank->get_aggregate_statistic(...);
*
* @return string[] the names of the required statistics fields. E.g. ['facility'].
*/
public function get_required_statistics_fields(): array {
return [];
}
/**
* If this column needs extra data (e.g. tags) then load that here.
*
* The extra data should be added to the question object in the array.
* Probably a good idea to check that another column has not already
* loaded the data you want.
*
* @param \stdClass[] $questions the questions that will be displayed, indexed by question id.
*/
public function load_additional_data(array $questions) {
}
/**
* Load the tags for each question.
*
* Helper that can be used from {@see load_additional_data()};
*
* @param array $questions
*/
public function load_question_tags(array $questions): void {
$firstquestion = reset($questions);
if (isset($firstquestion->tags)) {
// Looks like tags are already loaded, so don't do it again.
return;
}
// Load the tags.
$tagdata = \core_tag_tag::get_items_tags('core_question', 'question',
array_keys($questions));
// Add them to the question objects.
foreach ($tagdata as $questionid => $tags) {
$questions[$questionid]->tags = $tags;
}
}
/**
* Can this column be sorted on? You can return either:
* + false for no (the default),
* + a field name, if sorting this column corresponds to sorting on that datbase field.
* + an array of subnames to sort on as follows
* return [
* 'firstname' => ['field' => 'uc.firstname', 'title' => get_string('firstname')],
* 'lastname' => ['field' => 'uc.lastname', 'title' => get_string('lastname')],
* ];
* As well as field, and field, you can also add 'revers' => 1 if you want the default sort
* order to be DESC.
* @return mixed as above.
*/
public function is_sortable() {
return false;
}
/**
* Helper method for building sort clauses.
* @param bool $reverse whether the normal direction should be reversed.
* @return string 'ASC' or 'DESC'
*/
protected function sortorder($reverse): string {
if ($reverse) {
return ' DESC';
} else {
return ' ASC';
}
}
/**
* Sorts the expressions.
*
* @param bool $reverse Whether to sort in the reverse of the default sort order.
* @param string $subsort if is_sortable returns an array of subnames, then this will be
* one of those. Otherwise will be empty.
* @return string some SQL to go in the order by clause.
*/
public function sort_expression($reverse, $subsort): string {
$sortable = $this->is_sortable();
if (is_array($sortable)) {
if (array_key_exists($subsort, $sortable)) {
return $sortable[$subsort]['field'] . $this->sortorder($reverse);
} else {
throw new \coding_exception('Unexpected $subsort type: ' . $subsort);
}
} else if ($sortable) {
return $sortable . $this->sortorder($reverse);
} else {
throw new \coding_exception('sort_expression called on a non-sortable column.');
}
}
/**
* Output the column with an example value.
*
* By default, this will call $this->display() using whatever dummy data is passed in. Columns can override this
* to provide example output without requiring valid data.
*
* @param \stdClass $question the row from the $question table, augmented with extra information.
* @param string $rowclasses CSS class names that should be applied to this row of output.
*/
public function display_preview(\stdClass $question, string $rowclasses): void {
$this->display($question, $rowclasses);
}
}
@@ -0,0 +1,73 @@
<?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 core_question\local\bank;
/**
* Default column manager class
*
* This class defines stub methods that can be overridden by a plugin defining its own column manager.
*
* @package core_question
* @copyright 2023 onwards Catalyst IT EU {@link https://catalyst-eu.net}
* @author Mark Johnson <mark.johnson@catalyst-eu.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class column_manager_base {
/**
* Sort the list of columns
*
* Sort the provided list of columns into the order implemented in this column manager.
*
* @param array $unsortedcolumns Unordered array of columns
* @return array Columns in the desired order.
*/
public function get_sorted_columns(array $unsortedcolumns): array {
return $unsortedcolumns;
}
/**
* Given an array of columns, set the isvisible attribute.
*
* This base class leave all columns visible.
*
* @param column_base[] $columns
* @return array
*/
public function set_columns_visibility(array $columns): array {
return $columns;
}
/**
* Return a list of actions to display in an action menu for each column.
*
* @param view $qbank Question bank view.
* @return column_action_base[] A list of column actions.
*/
public function get_column_actions(view $qbank): array {
return [];
}
/**
* Given a column, return a value for its width CSS property.
*
* @param column_base $column
* @return string CSS width property value.
*/
public function get_column_width(column_base $column): string {
return $column->get_default_width() . 'px';
}
}
+222
View File
@@ -0,0 +1,222 @@
<?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 core_question\local\bank;
use core\output\datafilter;
/**
* An abstract class for filtering/searching questions.
*
* @package core_question
* @copyright 2013 Ray Morris
* @author Safat Shahin <safatshahin@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class condition {
/** @var int The default filter type */
const JOINTYPE_DEFAULT = datafilter::JOINTYPE_ANY;
/** @var ?array Filter properties for this condition. */
public ?array $filter;
/** @var string SQL fragment to add to the where clause. */
protected string $where = '';
/** @var array query param used in where. */
protected array $params = [];
/**
* Return title of the condition
*
* @return string title of the condition
*/
abstract public function get_title();
/**
* Return filter class associated with this condition
*
* @return string filter class
*/
abstract public function get_filter_class();
/**
* Extract the required filter from the provided question bank view.
*
* This will look for the filter matching {@see get_condition_key()}
*
* @param view|null $qbank
*/
public function __construct(view $qbank = null) {
if (is_null($qbank)) {
return;
}
$this->filter = static::get_filter_from_list($qbank->get_pagevars('filter'));
// Build where and params.
[$this->where, $this->params] = $this->filter ? static::build_query_from_filter($this->filter) : ['', []];
}
/**
* Whether customisation is allowed.
*
* @return bool
*/
public function allow_custom() {
return true;
}
/**
* Whether multiple values are allowed .
*
* @return bool
*/
public function allow_multiple() {
return true;
}
/**
* Initial values of the condition
*
* @return array
*/
public function get_initial_values() {
return [];
}
/**
* Extra data specific to this condition.
*
* @return \stdClass
*/
public function get_filteroptions(): \stdClass {
return (object)[];
}
/**
* Whether empty value is allowed
*
* @return bool
*/
public function allow_empty() {
return true;
}
/**
* Whether this filter is required - if so it cannot be removed from the list of filters.
*
* @return bool
*/
public function is_required(): bool {
return false;
}
/**
* Return this condition class
*
* @return string
*/
public function get_condition_class() {
return get_class($this);
}
/**
* Each condition will need a unique key to be identified and sequenced by the api.
* Use a unique string for the condition identifier, use string directly, dont need to use language pack.
* Using language pack might break the filter object for multilingual support.
*
* @return string
*/
public static function get_condition_key() {
return '';
}
/**
* Return an SQL fragment to be ANDed into the WHERE clause to filter which questions are shown.
*
* @return string SQL fragment. Must use named parameters.
*/
public function where() {
return $this->where;
}
/**
* Return parameters to be bound to the above WHERE clause fragment.
* @return array parameter name => value.
*/
public function params() {
return $this->params;
}
/**
* Display GUI for selecting criteria for this condition. Displayed when Show More is open.
*
* Compare display_options(), which displays always, whether Show More is open or not.
* @return bool|string HTML form fragment
* @deprecated since Moodle 4.0 MDL-72321 - please do not use this function any more.
* @todo Final deprecation on Moodle 4.1 MDL-72572
*/
public function display_options_adv() {
debugging('Function display_options_adv() is deprecated, please use filtering objects', DEBUG_DEVELOPER);
return false;
}
/**
* Display GUI for selecting criteria for this condition. Displayed always, whether Show More is open or not.
*
* Compare display_options_adv(), which displays when Show More is open.
* @return bool|string HTML form fragment
* @deprecated since Moodle 4.0 MDL-72321 - please do not use this function any more.
* @todo Final deprecation on Moodle 4.1 MDL-72572
*/
public function display_options() {
debugging('Function display_options() is deprecated, please use filtering objects', DEBUG_DEVELOPER);
return false;
}
/**
* Get the list of available joins for the filter.
*
* @return array
*/
public function get_join_list(): array {
return [
datafilter::JOINTYPE_NONE,
datafilter::JOINTYPE_ANY,
datafilter::JOINTYPE_ALL,
];
}
/**
* Given an array of filters, pick the entry that matches the condition key and return it.
*
* @param array $filters Array of filters, keyed by condition.
* @return ?array The filter that matches this condition
*/
public static function get_filter_from_list(array $filters): ?array {
return $filters[static::get_condition_key()] ?? null;
}
/**
* Build query from filter value
*
* @param array $filter filter properties
* @return array where sql and params
*/
public static function build_query_from_filter(array $filter): array {
return ['', []];
}
}
@@ -0,0 +1,98 @@
<?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 core_question\local\bank;
/**
* Converts contextlevels to strings and back to help with reading/writing contexts to/from import/export files.
*
* @package core_question
* @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com}
* @author 2021 Safat Shahin <safatshahin@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class context_to_string_translator {
/**
* @var array used to translate between contextids and strings for this context.
*/
protected $contexttostringarray = [];
/**
* context_to_string_translator constructor.
*
* @param \context[] $contexts
*/
public function __construct($contexts) {
$this->generate_context_to_string_array($contexts);
}
/**
* Context to string.
*
* @param int $contextid
* @return mixed
*/
public function context_to_string($contextid) {
return $this->contexttostringarray[$contextid];
}
/**
* String to context.
*
* @param string $contextname
* @return false|int|string
*/
public function string_to_context($contextname) {
return array_search($contextname, $this->contexttostringarray);
}
/**
* Generate context to array.
*
* @param \context[] $contexts
*/
protected function generate_context_to_string_array($contexts) {
if (!$this->contexttostringarray) {
$catno = 1;
/** @var \context $context */
foreach ($contexts as $context) {
switch ($context->contextlevel) {
case CONTEXT_MODULE :
$contextstring = 'module';
break;
case CONTEXT_COURSE :
$contextstring = 'course';
break;
case CONTEXT_COURSECAT :
$contextstring = "cat$catno";
$catno++;
break;
case CONTEXT_SYSTEM :
$contextstring = 'system';
break;
default:
throw new \coding_exception('Unexpected context level ' .
\context_helper::get_level_name($context->contextlevel) . ' for context ' .
$context->id . ' in generate_context_to_string_array. ' .
'Questions can never exist in this type of context.');
}
$this->contexttostringarray[$context->id] = $contextstring;
}
}
}
}
@@ -0,0 +1,90 @@
<?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/>.
/**
* A question bank column which gathers together all the actions into a menu.
*
* @package core_question
* @copyright 2019 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_question\local\bank;
use \core\plugininfo\qbank;
/**
* A question bank column which gathers together all the actions into a menu.
*
* This question bank column, if added to the question bank, will
* replace all of the other columns which implement the
* {@see menu_action_column_base} interface and replace them with a single
* column containing an Edit menu.
*
* @copyright 2019 The Open University
* @author 2021 Safat Shahin <safatshahin@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class edit_menu_column extends column_base {
public function get_title() {
return get_string('actions');
}
public function get_name(): string {
return 'editmenu';
}
protected function display_content($question, $rowclasses): void {
global $OUTPUT;
$actions = $this->qbank->get_question_actions();
$menu = new \action_menu();
$menu->set_menu_trigger(get_string('edit'));
$menu->set_boundary('window');
foreach ($actions as $action) {
$action = $action->get_action_menu_link($question);
if ($action) {
$menu->add($action);
}
}
$qtypeactions = \question_bank::get_qtype($question->qtype, false)
->get_extra_question_bank_actions($question);
foreach ($qtypeactions as $action) {
$menu->add($action);
}
echo $OUTPUT->render($menu);
}
public function get_required_fields(): array {
return ['q.qtype'];
}
/**
* Get menuable actions.
*
* @return menu_action_column_base Menuable actions.
*/
public function get_actions(): array {
return $this->actions;
}
public function get_extra_classes(): array {
return ['pr-3'];
}
}
@@ -0,0 +1,120 @@
<?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/>.
/**
* Functions for managing and manipulating question filter conditions
*
* @package core_question
* @copyright 2023 onwards Catalyst IT EU {@link https://catalyst-eu.net}
* @author Mark Johnson <mark.johnson@catalyst-eu.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_question\local\bank;
/**
* Static methods for parsing and formatting data related to filter conditions.
*/
class filter_condition_manager {
/**
* Extract parameters from args list.
*
* @param array $args
* @return array the param and extra param
*/
public static function extract_parameters_from_fragment_args(array $args): array {
$params = [];
if (array_key_exists('filter', $args)) {
$params['filter'] = json_decode($args['filter'], true);
}
if (array_key_exists('cmid', $args)) {
$params['cmid'] = $args['cmid'];
}
if (array_key_exists('courseid', $args)) {
$params['courseid'] = $args['courseid'];
}
$params['jointype'] = $args['jointype'] ?? condition::JOINTYPE_DEFAULT;
$params['qpage'] = $args['qpage'] ?? 0;
$params['qperpage'] = $args['qperpage'] ?? 100;
$params['sortdata'] = json_decode($args['sortdata'] ?? '', true);
$extraparams = json_decode($args['extraparams'] ?? '', true);
return [$params, $extraparams];
}
/**
* List of condition classes
*
* @return condition[] condition classes: [condition_key] = class
*/
public static function get_condition_classes(): array {
$classes = [];
$plugins = \core_component::get_plugin_list_with_class('qbank', 'plugin_feature', 'plugin_feature.php');
foreach ($plugins as $componentname => $plugin) {
if (\core\plugininfo\qbank::is_plugin_enabled($componentname)) {
$pluginentrypointobject = new $plugin();
$conditions = $pluginentrypointobject->get_question_filters();
foreach ($conditions as $condition) {
$classes[$condition->get_condition_key()] = $condition->get_condition_class();
}
}
}
return $classes;
}
/**
* Given a JSON-encoded "filter" URL param, create or replace the category filter with the provided category.
*
* @param string $filterparam The json-encoded filter param from the URL, containing the list of filters.
* @param int $newcategoryid The new ID to set for the "category" filter condition's value.
* @return string JSON-encoded filter param with the new category.
*/
public static function update_filter_param_to_category(string $filterparam, int $newcategoryid): string {
$returnfilters = json_decode($filterparam, true);
if (!$returnfilters) {
$returnfilters = [
'category' => [
'name' => 'category',
'jointype' => \core_question\local\bank\condition::JOINTYPE_DEFAULT,
]
];
}
$returnfilters['category']['values'] = [$newcategoryid];
return json_encode($returnfilters);
}
/**
* Unpack filteroptions passed in a request's filter param if required.
*
* Filteroptions are passed via AJAX as an array of {name:, value:} pairs for compatibility with external functions.
*
* @param array $filters List of filters, each optionally including an array of filteroptions.
* @return array The input array, with filteroptions unpacked from [{name:, value:}, ...] to [name => value, ...].
*/
public static function unpack_filteroptions_param(array $filters): array {
foreach ($filters as $name => $filter) {
if (!empty($filter['filteroptions']) && isset(reset($filter['filteroptions'])['name'])) {
$unpacked = [];
foreach ($filter['filteroptions'] as $filteroption) {
$unpacked[$filteroption['name']] = $filteroption['value'];
}
$filters[$name]['filteroptions'] = $unpacked;
}
}
return $filters;
}
}
+54
View File
@@ -0,0 +1,54 @@
<?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/>.
/**
* Helper class for question bank and its plugins.
*
* All the functions which has a potential to be used by different features or
* plugins, should go here.
*
* @package core_question
* @copyright 2021 Catalyst IT Australia Pty Ltd
* @author Safat Shahin <safatshahin@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_question\local\bank;
/**
* Class helper
*
* @package core_question
* @copyright 2021 Catalyst IT Australia Pty Ltd
* @author Safat Shahin <safatshahin@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class helper {
/**
* Check the status of a plugin and throw exception if not enabled and called manually.
*
* Any action plugin having a php script, should call this function for a safer enable/disable implementation.
*
* @param string $pluginname
* @return void
*/
public static function require_plugin_enabled(string $pluginname): void {
if (!\core\plugininfo\qbank::is_plugin_enabled($pluginname)) {
throw new \moodle_exception('The following plugin is either disabled or missing from disk: ' . $pluginname);
}
}
}
@@ -0,0 +1,73 @@
<?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/>.
/**
* Base class to make it easier to implement actions that are menuable_actions.
*
* @package core_question
* @copyright 2019 Tim Hunt
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_question\local\bank;
/**
* Base class to make it easier to implement actions that are menuable_actions.
*
* Use this class if your action is simple (defined by just a URL, label and icon).
* If your action is not simple enough to fit into the pattern that this
* class implements, then you will have to implement the menuable_action
* interface yourself.
*
* @copyright 2019 Tim Hunt
* @author 2021 Safat Shahin <safatshahin@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @deprecated Since Moodle 4.3 MDL-75125 - Use question_action_base instead.
* @todo MDL-78090 This class will be deleted in Moodle 4.7
*/
abstract class menu_action_column_base extends action_column_base implements menuable_action {
/**
* Get the information required to display this action either as a menu item or a separate action column.
*
* If this action cannot apply to this question (e.g. because the user does not have
* permission, then return [null, null, null].
*
* @param \stdClass $question the row from the $question table, augmented with extra information.
* @return array with three elements.
* $url - the URL to perform the action.
* $icon - the icon for this action. E.g. 't/delete'.
* $label - text label to display in the UI (either in the menu, or as a tool-tip on the icon)
*/
abstract protected function get_url_icon_and_label(\stdClass $question);
protected function display_content($question, $rowclasses): void {
debugging('The menu_action_column_base class is deprecated. Please use question_action_base instead.', DEBUG_DEVELOPER);
[$url, $icon, $label] = $this->get_url_icon_and_label($question);
if ($url) {
$this->print_icon($icon, $label, $url);
}
}
public function get_action_menu_link(\stdClass $question): ?\action_menu_link {
debugging('The menu_action_column_base class is deprecated. Please use question_action_base instead.', DEBUG_DEVELOPER);
[$url, $icon, $label] = $this->get_url_icon_and_label($question);
if (!$url) {
return null;
}
return new \action_menu_link_secondary($url, new \pix_icon($icon, ''), $label);
}
}
@@ -0,0 +1,57 @@
<?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/>.
/**
* Interface to indicate that a question bank column can go in the action menu.
*
* @package core_question
* @copyright 2019 Tim Hunt
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_question\local\bank;
/**
* Interface to indicate that a question bank column can go in the action menu.
*
* If a question bank column implements this interface, and if the {@see edit_menu_column}
* is present in the question bank view, then the 'column' will be shown as an entry in the
* edit menu instead of as a separate column.
*
* Probably most columns that want to implement this will be subclasses of
* {@see action_column_base}, and most such columns should probably implement
* this interface.
*
* If your column is a simple action, you can probably save duplicated code by
* using the base class action_column_menuable as an easy way to implement both
* action_column_base and this interface.
*
* @copyright 2019 Tim Hunt
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @deprecated Since Moodle 4.3 MDL-75125 - Use question_action_base instead.
* @todo MDL-78090 This interface will be deleted in Moodle 4.7
*/
interface menuable_action {
/**
* Return the appropriate action menu link, or null if it does not apply to this question.
*
* @param \stdClass $question data about the question being displayed in this row.
* @return \action_menu_link|null the action, if applicable to this question.
* @deprecated Since Moodle 4.3
*/
public function get_action_menu_link(\stdClass $question): ?\action_menu_link;
}
@@ -0,0 +1,64 @@
<?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/>.
/**
* Base class class for navigation node.
*
* @package core_question
* @copyright 2021 Catalyst IT Australia Pty Ltd
* @author Safat Shahin <safatshahin@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_question\local\bank;
/**
* Class navigation_node_base is the base class for navigation node.
*
* @package core_question
* @copyright 2021 Catalyst IT Australia Pty Ltd
* @author Safat Shahin <safatshahin@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class navigation_node_base {
/**
* Title for this node.
*/
abstract public function get_navigation_title();
/**
* Key for this node.
*/
abstract public function get_navigation_key();
/**
* URL for this node
*/
abstract public function get_navigation_url();
/**
* Tab capabilities.
*
* If it has capabilities to be checked, it will return the array of capabilities.
*
* @return null|array
*/
public function get_navigation_capabilities(): ?array {
return null;
}
}
@@ -0,0 +1,117 @@
<?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/>.
/**
* Base class class for qbank plugins.
*
* Every qbank plugin must extent this class.
*
* @package core_question
* @copyright 2021 Catalyst IT Australia Pty Ltd
* @author Safat Shahin <safatshahin@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_question\local\bank;
use core\context;
use qbank_columnsortorder\local\qbank\column_action_move;
use qbank_columnsortorder\local\qbank\column_action_remove;
use qbank_columnsortorder\local\qbank\column_action_resize;
/**
* Class plugin_features_base is the base class for qbank plugins.
*
* @package core_question
* @copyright 2021 Catalyst IT Australia Pty Ltd
* @author Safat Shahin <safatshahin@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class plugin_features_base {
/**
* This method will return the array of objects to be rendered as a part of question bank columns.
*
* @param view $qbank
* @return array
*/
public function get_question_columns(view $qbank): ?array {
return [];
}
/**
* This method will return the array of objects to be rendered as a part of question bank actions.
*
* @param view $qbank
* @return question_action_base[]
*/
public function get_question_actions(view $qbank): array {
return [];
}
/**
* This method will return the object for the navigation node.
*
* @return null|navigation_node_base
*/
public function get_navigation_node(): ?navigation_node_base {
return null;
}
/**
* This method will return the array objects for the bulk actions ui.
*
* @return bulk_action_base[]
*/
public function get_bulk_actions() {
return [];
}
/**
* This method will return a column manager object, if this plugin provides one.
*
* @return ?column_manager_base
*/
public function get_column_manager(): ?column_manager_base {
return null;
}
/**
* This method will return an array of renderable objects, for adding additional controls to the question bank screen.
*
* The array returned can include a numeric index for each object, to indicate the position in which it should be displayed
* relative to other controls. If two plugins return controls with the same position, they will be displayed after one another,
* based on the alphabetical order of the plugin component names.
*
* @param view $qbank The question bank view.
* @param context $context The current context, for permission checks.
* @param int $categoryid The current question category ID.
* @return \renderable[]
*/
public function get_question_bank_controls(view $qbank, context $context, int $categoryid): array {
return [];
}
/**
* Return search conditions for the plugin.
*
* @param view|null $qbank
* @return condition[]
*/
public function get_question_filters(view $qbank = null): array {
return [];
}
}
@@ -0,0 +1,77 @@
<?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/>.
/**
* Base class to make it easier to implement actions that are menuable_actions.
*
* @package core_question
* @copyright 2019 Tim Hunt
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_question\local\bank;
/**
* Base class to make it easier to implement actions that are menuable_actions.
*
* Use this class if your action is simple (defined by just a URL, label and icon).
* If your action is not simple enough to fit into the pattern that this
* class implements, then you will have to implement the menuable_action
* interface yourself.
*
* @copyright 2019 Tim Hunt
* @author 2021 Safat Shahin <safatshahin@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class question_action_base extends view_component {
/**
* Get the information required to display this action either as a menu item or a separate action column.
*
* For most actions, it should be sufficient to override just this method. {@see get_action_menu_link()} is the public interface
* and handles building a renderable menu link object from this data.
*
* If this action cannot apply to this question (e.g. because the user does not have
* permission, then return [null, null, null].
*
* @param \stdClass $question the row from the $question table, augmented with extra information.
* @return array with three elements.
* $url - the URL to perform the action.
* $icon - the icon for this action. E.g. 't/delete'.
* $label - text label to display in the UI (either in the menu, or as a tool-tip on the icon)
*/
protected function get_url_icon_and_label(\stdClass $question): array {
return [null, null, null];
}
/**
* Return the action menu link for this action on the supplied question.
*
* For most actions, you will just need to override {@see get_url_icon_and_label()}. You only need to override
* this method if you need to pass additional attributes to {@see action_menu_link_secondary}, or use a different class to
* render the link.
*
* @param \stdClass $question
* @return \action_menu_link|null
*/
public function get_action_menu_link(\stdClass $question): ?\action_menu_link {
[$url, $icon, $label] = $this->get_url_icon_and_label($question);
if (!$url) {
return null;
}
return new \action_menu_link_secondary($url, new \pix_icon($icon, ''), $label);
}
}
@@ -0,0 +1,222 @@
<?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 core_question\local\bank;
/**
* Tracks all the contexts related to the one we are currently editing questions and provides helper methods to check permissions.
*
* @package core_question
* @copyright 2007 Jamie Pratt me@jamiep.org
* @author 2021 Safat Shahin <safatshahin@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class question_edit_contexts {
/**
* @var \string[][] array of the capabilities.
*/
public static $caps = [
'editq' => [
'moodle/question:add',
'moodle/question:editmine',
'moodle/question:editall',
'moodle/question:viewmine',
'moodle/question:viewall',
'moodle/question:usemine',
'moodle/question:useall',
'moodle/question:movemine',
'moodle/question:moveall'],
'questions' => [
'moodle/question:add',
'moodle/question:editmine',
'moodle/question:editall',
'moodle/question:viewmine',
'moodle/question:viewall',
'moodle/question:movemine',
'moodle/question:moveall'],
'categories' => [
'moodle/question:managecategory'],
'import' => [
'moodle/question:add'],
'export' => [
'moodle/question:viewall',
'moodle/question:viewmine']];
/**
* @var array of contexts.
*/
protected $allcontexts;
/**
* Constructor
* @param \context $thiscontext the current context.
*/
public function __construct(\context $thiscontext) {
$this->allcontexts = array_values($thiscontext->get_parent_contexts(true));
}
/**
* Get all the contexts.
*
* @return \context[] all parent contexts
*/
public function all() {
return $this->allcontexts;
}
/**
* Get the lowest context.
*
* @return \context lowest context which must be either the module or course context
*/
public function lowest() {
return $this->allcontexts[0];
}
/**
* Get the contexts having cap.
*
* @param string $cap capability
* @return \context[] parent contexts having capability, zero based index
*/
public function having_cap($cap) {
$contextswithcap = [];
foreach ($this->allcontexts as $context) {
if (has_capability($cap, $context)) {
$contextswithcap[] = $context;
}
}
return $contextswithcap;
}
/**
* Get the contexts having at least one cap.
*
* @param array $caps capabilities
* @return \context[] parent contexts having at least one of $caps, zero based index
*/
public function having_one_cap($caps) {
$contextswithacap = [];
foreach ($this->allcontexts as $context) {
foreach ($caps as $cap) {
if (has_capability($cap, $context)) {
$contextswithacap[] = $context;
break; // Done with caps loop.
}
}
}
return $contextswithacap;
}
/**
* Context having at least one cap.
*
* @param string $tabname edit tab name
* @return \context[] parent contexts having at least one of $caps, zero based index
*/
public function having_one_edit_tab_cap($tabname) {
return $this->having_one_cap(self::$caps[$tabname]);
}
/**
* Contexts for adding question and also using it.
*
* @return \context[] those contexts where a user can add a question and then use it.
*/
public function having_add_and_use() {
$contextswithcap = [];
foreach ($this->allcontexts as $context) {
if (!has_capability('moodle/question:add', $context)) {
continue;
}
if (!has_any_capability(['moodle/question:useall', 'moodle/question:usemine'], $context)) {
continue;
}
$contextswithcap[] = $context;
}
return $contextswithcap;
}
/**
* Has at least one parent context got the cap $cap?
*
* @param string $cap capability
* @return boolean
*/
public function have_cap($cap) {
return (count($this->having_cap($cap)));
}
/**
* Has at least one parent context got one of the caps $caps?
*
* @param array $caps capability
* @return boolean
*/
public function have_one_cap($caps) {
foreach ($caps as $cap) {
if ($this->have_cap($cap)) {
return true;
}
}
return false;
}
/**
* Has at least one parent context got one of the caps for actions on $tabname
*
* @param string $tabname edit tab name
* @return boolean
*/
public function have_one_edit_tab_cap($tabname) {
return $this->have_one_cap(self::$caps[$tabname]);
}
/**
* Throw error if at least one parent context hasn't got the cap $cap
*
* @param string $cap capability
*/
public function require_cap($cap) {
if (!$this->have_cap($cap)) {
throw new \moodle_exception('nopermissions', '', '', $cap);
}
}
/**
* Throw error if at least one parent context hasn't got one of the caps $caps
*
* @param array $caps capabilities
*/
public function require_one_cap($caps) {
if (!$this->have_one_cap($caps)) {
$capsstring = join(', ', $caps);
throw new \moodle_exception('nopermissions', '', '', $capsstring);
}
}
/**
* Throw error if at least one parent context hasn't got one of the caps $caps
*
* @param string $tabname edit tab name
*/
public function require_one_edit_tab_cap($tabname) {
if (!$this->have_one_edit_tab_cap($tabname)) {
throw new \moodle_exception('nopermissions', '', '', 'access question edit tab '.$tabname);
}
}
}
@@ -0,0 +1,43 @@
<?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 core_question\local\bank;
/**
* Class question_version_status contains the statuses for a question.
*
* @package core_question
* @copyright 2021 Catalyst IT Australia Pty Ltd
* @author Safat Shahin <safatshahin@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class question_version_status {
/**
* Const if the question is ready to use.
*/
const QUESTION_STATUS_READY = 'ready';
/**
* Const if the question is hidden.
*/
const QUESTION_STATUS_HIDDEN = 'hidden';
/**
* const if the question is in draft.
*/
const QUESTION_STATUS_DRAFT = 'draft';
}
@@ -0,0 +1,609 @@
<?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/>.
/**
* A class for efficiently finds questions at random from the question bank.
*
* @package core_question
* @copyright 2015 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_question\local\bank;
/**
* This class efficiently finds questions at random from the question bank.
*
* You can ask for questions at random one at a time. Each time you ask, you
* pass a category id, and whether to pick from that category and all subcategories
* or just that category.
*
* The number of teams each question has been used is tracked, and we will always
* return a question from among those elegible that has been used the fewest times.
* So, if there are questions that have not been used yet in the category asked for,
* one of those will be returned. However, within one instantiation of this class,
* we will never return a given question more than once, and we will never return
* questions passed into the constructor as $usedquestions.
*
* @copyright 2015 The Open University
* @author 2021 Safat Shahin <safatshahin@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class random_question_loader {
/** @var \qubaid_condition which usages to consider previous attempts from. */
protected $qubaids;
/** @var array qtypes that cannot be used by random questions. */
protected $excludedqtypes;
/** @var array categoryid & include subcategories => num previous uses => questionid => 1. */
protected $availablequestionscache = [];
/**
* @var array questionid => num recent uses. Questions that have been used,
* but that is not yet recorded in the DB.
*/
protected $recentlyusedquestions;
/**
* Constructor.
*
* @param \qubaid_condition $qubaids the usages to consider when counting previous uses of each question.
* @param array $usedquestions questionid => number of times used count. If we should allow for
* further existing uses of a question in addition to the ones in $qubaids.
*/
public function __construct(\qubaid_condition $qubaids, array $usedquestions = []) {
$this->qubaids = $qubaids;
$this->recentlyusedquestions = $usedquestions;
foreach (\question_bank::get_all_qtypes() as $qtype) {
if (!$qtype->is_usable_by_random()) {
$this->excludedqtypes[] = $qtype->name();
}
}
}
/**
* Pick a random question based on filter conditions
*
* @param array $filters filter array
* @return int|null
*/
public function get_next_filtered_question_id(array $filters): ?int {
$this->ensure_filtered_questions_loaded($filters);
$key = $this->get_filtered_questions_key($filters);
if (empty($this->availablequestionscache[$key])) {
return null;
}
reset($this->availablequestionscache[$key]);
$lowestcount = key($this->availablequestionscache[$key]);
reset($this->availablequestionscache[$key][$lowestcount]);
$questionid = key($this->availablequestionscache[$key][$lowestcount]);
$this->use_question($questionid);
return $questionid;
}
/**
* Pick a question at random from the given category, from among those with the fewest uses.
* If an array of tag ids are specified, then only the questions that are tagged with ALL those tags will be selected.
*
* It is up the the caller to verify that the cateogry exists. An unknown category
* behaves like an empty one.
*
* @param int $categoryid the id of a category in the question bank.
* @param bool $includesubcategories wether to pick a question from exactly
* that category, or that category and subcategories.
* @param array $tagids An array of tag ids. A question has to be tagged with all the provided tagids (if any)
* in order to be eligible for being picked.
* @return int|null the id of the question picked, or null if there aren't any.
* @deprecated since Moodle 4.3
* @todo Final deprecation on Moodle 4.7 MDL-78091
*/
public function get_next_question_id($categoryid, $includesubcategories, $tagids = []): ?int {
debugging(
'Function get_next_question_id() is deprecated, please use get_next_filtered_question_id() instead.',
DEBUG_DEVELOPER
);
$this->ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids);
$categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids);
if (empty($this->availablequestionscache[$categorykey])) {
return null;
}
reset($this->availablequestionscache[$categorykey]);
$lowestcount = key($this->availablequestionscache[$categorykey]);
reset($this->availablequestionscache[$categorykey][$lowestcount]);
$questionid = key($this->availablequestionscache[$categorykey][$lowestcount]);
$this->use_question($questionid);
return $questionid;
}
/**
* Key for filtered questions.
* This function replace get_category_key
*
* @param array $filters filter array
* @return String
*/
protected function get_filtered_questions_key(array $filters): String {
return sha1(json_encode($filters));
}
/**
* Get the key into {@see $availablequestionscache} for this combination of options.
*
* @param int $categoryid the id of a category in the question bank.
* @param bool $includesubcategories wether to pick a question from exactly
* that category, or that category and subcategories.
* @param array $tagids an array of tag ids.
* @return string the cache key.
*
* @deprecated since Moodle 4.3
* @todo Final deprecation on Moodle 4.7 MDL-78091
*/
protected function get_category_key($categoryid, $includesubcategories, $tagids = []): string {
debugging(
'Function get_category_key() is deprecated, please get_fitlered_questions_key instead.',
DEBUG_DEVELOPER
);
if ($includesubcategories) {
$key = $categoryid . '|1';
} else {
$key = $categoryid . '|0';
}
if (!empty($tagids)) {
$key .= '|' . implode('|', $tagids);
}
return $key;
}
/**
* Populate {@see $availablequestionscache} according to filter conditions.
*
* @param array $filters filter array
* @return void
*/
protected function ensure_filtered_questions_loaded(array $filters) {
global $DB;
// Check if this is already done.
$key = $this->get_filtered_questions_key($filters);
if (isset($this->availablequestionscache[$key])) {
// Data is already in the cache, nothing to do.
return;
}
// Build filter conditions.
$params = [];
$filterconditions = [];
foreach (filter_condition_manager::get_condition_classes() as $conditionclass) {
$filter = $conditionclass::get_filter_from_list($filters);
if (is_null($filter)) {
continue;
}
[$filterwhere, $filterparams] = $conditionclass::build_query_from_filter($filter);
if (!empty($filterwhere)) {
$filterconditions[] = '(' . $filterwhere . ')';
}
if (!empty($filterparams)) {
$params = array_merge($params, $filterparams);
}
}
$filtercondition = $filterconditions ? 'AND ' . implode(' AND ', $filterconditions) : '';
// Prepare qtype check.
[$qtypecondition, $qtypeparams] = $DB->get_in_or_equal($this->excludedqtypes,
SQL_PARAMS_NAMED, 'excludedqtype', false);
if ($qtypecondition) {
$qtypecondition = 'AND q.qtype ' . $qtypecondition;
}
$questionidsandcounts = $DB->get_records_sql_menu("
SELECT q.id,
(
SELECT COUNT(1)
FROM {$this->qubaids->from_question_attempts('qa')}
WHERE qa.questionid = q.id AND {$this->qubaids->where()}
) AS previous_attempts
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
WHERE q.parent = :noparent
$qtypecondition
$filtercondition
AND qv.version = (
SELECT MAX(version)
FROM {question_versions}
WHERE questionbankentryid = qbe.id
AND status = :ready
)
ORDER BY previous_attempts
", array_merge(
$params,
$this->qubaids->from_where_params(),
['noparent' => 0, 'ready' => question_version_status::QUESTION_STATUS_READY],
$qtypeparams,
));
if (!$questionidsandcounts) {
// No questions in this category.
$this->availablequestionscache[$key] = [];
return;
}
// Put all the questions with each value of $prevusecount in separate arrays.
$idsbyusecount = [];
foreach ($questionidsandcounts as $questionid => $prevusecount) {
if (isset($this->recentlyusedquestions[$questionid])) {
// Recently used questions are never returned.
continue;
}
$idsbyusecount[$prevusecount][] = $questionid;
}
// Now put that data into our cache. For each count, we need to shuffle
// questionids, and make those the keys of an array.
$this->availablequestionscache[$key] = [];
foreach ($idsbyusecount as $prevusecount => $questionids) {
shuffle($questionids);
$this->availablequestionscache[$key][$prevusecount] = array_combine(
$questionids, array_fill(0, count($questionids), 1));
}
ksort($this->availablequestionscache[$key]);
}
/**
* Populate {@see $availablequestionscache} for this combination of options.
*
* @param int $categoryid The id of a category in the question bank.
* @param bool $includesubcategories Whether to pick a question from exactly
* that category, or that category and subcategories.
* @param array $tagids An array of tag ids. If an array is provided, then
* only the questions that are tagged with ALL the provided tagids will be loaded.
* @deprecated since Moodle 4.3
* @todo Final deprecation on Moodle 4.7 MDL-78091
*/
protected function ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids = []): void {
debugging(
'Function ensure_questions_for_category_loaded() is deprecated, please use the function ' .
'ensure_filtered_questions_loaded.',
DEBUG_DEVELOPER
);
global $DB;
$categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids);
if (isset($this->availablequestionscache[$categorykey])) {
// Data is already in the cache, nothing to do.
return;
}
// Load the available questions from the question bank.
if ($includesubcategories) {
$categoryids = question_categorylist($categoryid);
} else {
$categoryids = [$categoryid];
}
list($extraconditions, $extraparams) = $DB->get_in_or_equal($this->excludedqtypes,
SQL_PARAMS_NAMED, 'excludedqtype', false);
$questionidsandcounts = \question_bank::get_finder()->get_questions_from_categories_and_tags_with_usage_counts(
$categoryids, $this->qubaids, 'q.qtype ' . $extraconditions, $extraparams, $tagids);
if (!$questionidsandcounts) {
// No questions in this category.
$this->availablequestionscache[$categorykey] = [];
return;
}
// Put all the questions with each value of $prevusecount in separate arrays.
$idsbyusecount = [];
foreach ($questionidsandcounts as $questionid => $prevusecount) {
if (isset($this->recentlyusedquestions[$questionid])) {
// Recently used questions are never returned.
continue;
}
$idsbyusecount[$prevusecount][] = $questionid;
}
// Now put that data into our cache. For each count, we need to shuffle
// questionids, and make those the keys of an array.
$this->availablequestionscache[$categorykey] = [];
foreach ($idsbyusecount as $prevusecount => $questionids) {
shuffle($questionids);
$this->availablequestionscache[$categorykey][$prevusecount] = array_combine(
$questionids, array_fill(0, count($questionids), 1));
}
ksort($this->availablequestionscache[$categorykey]);
}
/**
* Update the internal data structures to indicate that a given question has
* been used one more time.
*
* @param int $questionid the question that is being used.
*/
protected function use_question($questionid): void {
if (isset($this->recentlyusedquestions[$questionid])) {
$this->recentlyusedquestions[$questionid] += 1;
} else {
$this->recentlyusedquestions[$questionid] = 1;
}
foreach ($this->availablequestionscache as $categorykey => $questionsforcategory) {
foreach ($questionsforcategory as $numuses => $questionids) {
if (!isset($questionids[$questionid])) {
continue;
}
unset($this->availablequestionscache[$categorykey][$numuses][$questionid]);
if (empty($this->availablequestionscache[$categorykey][$numuses])) {
unset($this->availablequestionscache[$categorykey][$numuses]);
}
}
}
}
/**
* Get filtered questions.
*
* @param array $filters filter array
* @return array list of filtered questions
*/
protected function get_filtered_question_ids(array $filters): array {
$this->ensure_filtered_questions_loaded($filters);
$key = $this->get_filtered_questions_key($filters);
$cachedvalues = $this->availablequestionscache[$key];
$questionids = [];
foreach ($cachedvalues as $usecount => $ids) {
$questionids = array_merge($questionids, array_keys($ids));
}
return $questionids;
}
/**
* Get the list of available question ids for the given criteria.
*
* @param int $categoryid The id of a category in the question bank.
* @param bool $includesubcategories Whether to pick a question from exactly
* that category, or that category and subcategories.
* @param array $tagids An array of tag ids. If an array is provided, then
* only the questions that are tagged with ALL the provided tagids will be loaded.
* @return int[] The list of question ids
* @deprecated since Moodle 4.3
* @todo Final deprecation on Moodle 4.7 MDL-78091
*/
protected function get_question_ids($categoryid, $includesubcategories, $tagids = []): array {
debugging(
'Function get_question_ids() is deprecated, please use get_filtered_question_ids() instead.',
DEBUG_DEVELOPER
);
$this->ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids);
$categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids);
$cachedvalues = $this->availablequestionscache[$categorykey];
$questionids = [];
foreach ($cachedvalues as $usecount => $ids) {
$questionids = array_merge($questionids, array_keys($ids));
}
return $questionids;
}
/**
* Check whether a given question is available in a given category. If so, mark it used.
* If an optional list of tag ids are provided, then the question must be tagged with
* ALL of the provided tags to be considered as available.
*
* @param array $filters filter array
* @param int $questionid the question that is being used.
* @return bool whether the question is available in the requested category.
*/
public function is_filtered_question_available(array $filters, int $questionid): bool {
$this->ensure_filtered_questions_loaded($filters);
$categorykey = $this->get_filtered_questions_key($filters);
foreach ($this->availablequestionscache[$categorykey] as $questionids) {
if (isset($questionids[$questionid])) {
$this->use_question($questionid);
return true;
}
}
return false;
}
/**
* Check whether a given question is available in a given category. If so, mark it used.
* If an optional list of tag ids are provided, then the question must be tagged with
* ALL of the provided tags to be considered as available.
*
* @param int $categoryid the id of a category in the question bank.
* @param bool $includesubcategories wether to pick a question from exactly
* that category, or that category and subcategories.
* @param int $questionid the question that is being used.
* @param array $tagids An array of tag ids. Only the questions that are tagged with all the provided tagids can be available.
* @return bool whether the question is available in the requested category.
* @deprecated since Moodle 4.3
* @todo Final deprecation on Moodle 4.7 MDL-78091
*/
public function is_question_available($categoryid, $includesubcategories, $questionid, $tagids = []): bool {
debugging(
'Function is_question_available() is deprecated, please use is_filtered_question_available() instead.',
DEBUG_DEVELOPER
);
$this->ensure_questions_for_category_loaded($categoryid, $includesubcategories, $tagids);
$categorykey = $this->get_category_key($categoryid, $includesubcategories, $tagids);
foreach ($this->availablequestionscache[$categorykey] as $questionids) {
if (isset($questionids[$questionid])) {
$this->use_question($questionid);
return true;
}
}
return false;
}
/**
* Get the list of available questions for the given criteria.
*
* @param array $filters filter array
* @param int $limit Maximum number of results to return.
* @param int $offset Number of items to skip from the begging of the result set.
* @param string[] $fields The fields to return for each question.
* @return \stdClass[] The list of question records
*/
public function get_filtered_questions($filters, $limit = 100, $offset = 0, $fields = []) {
global $DB;
$questionids = $this->get_filtered_question_ids($filters);
if (empty($questionids)) {
return [];
}
if (empty($fields)) {
// Return all fields.
$fieldsstring = '*';
} else {
$fieldsstring = implode(',', $fields);
}
// Create the query to get the questions (validate that at least we have a question id. If not, do not execute the sql).
$hasquestions = false;
if (!empty($questionids)) {
$hasquestions = true;
}
if ($hasquestions) {
[$condition, $param] = $DB->get_in_or_equal($questionids, SQL_PARAMS_NAMED, 'questionid');
$condition = 'WHERE q.id ' . $condition;
$sql = "SELECT {$fieldsstring}
FROM (SELECT q.*, qbe.questioncategoryid as category
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
{$condition}) q
ORDER BY q.id";
return $DB->get_records_sql($sql, $param, $offset, $limit);
} else {
return [];
}
}
/**
* Get the list of available questions for the given criteria.
*
* @param int $categoryid The id of a category in the question bank.
* @param bool $includesubcategories Whether to pick a question from exactly
* that category, or that category and subcategories.
* @param array $tagids An array of tag ids. If an array is provided, then
* only the questions that are tagged with ALL the provided tagids will be loaded.
* @param int $limit Maximum number of results to return.
* @param int $offset Number of items to skip from the begging of the result set.
* @param string[] $fields The fields to return for each question.
* @return \stdClass[] The list of question records
* @deprecated since Moodle 4.3
* @todo Final deprecation on Moodle 4.7 MDL-78091
*/
public function get_questions($categoryid, $includesubcategories, $tagids = [], $limit = 100, $offset = 0, $fields = []) {
debugging(
'Function get_questions() is deprecated, please use get_filtered_questions() instead.',
DEBUG_DEVELOPER
);
global $DB;
$questionids = $this->get_question_ids($categoryid, $includesubcategories, $tagids);
if (empty($questionids)) {
return [];
}
if (empty($fields)) {
// Return all fields.
$fieldsstring = '*';
} else {
$fieldsstring = implode(',', $fields);
}
// Create the query to get the questions (validate that at least we have a question id. If not, do not execute the sql).
$hasquestions = false;
if (!empty($questionids)) {
$hasquestions = true;
}
if ($hasquestions) {
list($condition, $param) = $DB->get_in_or_equal($questionids, SQL_PARAMS_NAMED, 'questionid');
$condition = 'WHERE q.id ' . $condition;
$sql = "SELECT {$fieldsstring}
FROM (SELECT q.*, qbe.questioncategoryid as category
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
{$condition}) q ORDER BY q.id";
return $DB->get_records_sql($sql, $param, $offset, $limit);
} else {
return [];
}
}
/**
* Count number of filtered questions
*
* @param array $filters filter array
* @return int number of question
*/
public function count_filtered_questions(array $filters): int {
$questionids = $this->get_filtered_question_ids($filters);
return count($questionids);
}
/**
* Count the number of available questions for the given criteria.
*
* @param int $categoryid The id of a category in the question bank.
* @param bool $includesubcategories Whether to pick a question from exactly
* that category, or that category and subcategories.
* @param array $tagids An array of tag ids. If an array is provided, then
* only the questions that are tagged with ALL the provided tagids will be loaded.
* @return int The number of questions matching the criteria.
* @deprecated since Moodle 4.3
* @todo Final deprecation on Moodle 4.7 MDL-78091
*/
public function count_questions($categoryid, $includesubcategories, $tagids = []): int {
debugging(
'Function count_questions() is deprecated, please use count_filtered_questions() instead.',
DEBUG_DEVELOPER
);
$questionids = $this->get_question_ids($categoryid, $includesubcategories, $tagids);
return count($questionids);
}
}
+70
View File
@@ -0,0 +1,70 @@
<?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/>.
/**
* Base class for 'columns' that are actually displayed as a row following the main question row.
*
* @package core_question
* @copyright 2009 Tim Hunt
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_question\local\bank;
/**
* Base class for 'columns' that are actually displayed as a row following the main question row.
*
* @copyright 2009 Tim Hunt
* @author 2021 Safat Shahin <safatshahin@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class row_base extends column_base {
/**
* Check if the column is an extra row of not.
*/
public function is_extra_row(): bool {
return true;
}
/**
* Output the opening column tag. If it is set as heading, it will use <th> tag instead of <td>
*
* @param \stdClass $question
* @param string $rowclasses
*/
protected function display_start($question, $rowclasses): void {
if ($rowclasses) {
echo \html_writer::start_tag('tr', ['class' => $rowclasses]);
} else {
echo \html_writer::start_tag('tr');
}
echo \html_writer::start_tag('td',
['colspan' => $this->qbank->get_column_count(), 'class' => $this->get_name()]);
}
/**
* Output the closing column tag
*
* @param object $question
* @param string $rowclasses
*/
protected function display_end($question, $rowclasses): void {
echo \html_writer::end_tag('td');
echo \html_writer::end_tag('tr');
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,102 @@
<?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 core_question\local\bank;
/**
* Abstract class to define functionality shared by all pluggable components used in the question bank view.
*
* @package core_question
* @copyright 2023 onwards Catalyst IT EU {@link https://catalyst-eu.net}
* @author Mark Johnson <mark.johnson@catalyst-eu.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
abstract class view_component {
/** @var int value we return from get_menu_position here. Subclasses should override this. */
const MENU_POSITION_NOT_SET = 6666;
/** @var view Question bank view. */
protected $qbank;
/**
* Constructor.
* @param view $qbank the question bank view we are helping to render.
*/
public function __construct(view $qbank) {
$this->qbank = $qbank;
$this->init();
}
/**
* A chance for subclasses to initialise themselves, for example to load lang strings,
* without having to override the constructor.
*/
protected function init(): void {
}
/**
* Return an integer to indicate the desired position in the menu for this link, smaller at the top.
*
* The standard menu items in Moodle core return these numbers:
* 100 preview_action
* 200 edit_action
* 250 copy_action
* 300 tags_action
* 400 delete_action
* 500 history_action
* 600 export_xml_action
* (So, if you want your action at a particular place in the order, there should be space.)
*
* If two actions get the same order number, then the tie-break on the sort
* is plugin name, then the order returned by get_question_actions for that plugin.
*
* @return int desired position. Smallest at the top.
*/
public function get_menu_position(): int {
// We return a big number by default, which is after all the standard core links,
// so they go first. This should be overridden by all plugins, and not overriding will
// generate a debugging warning from {@see \core_question\local\bank\view::init_question_actions()}.
return self::MENU_POSITION_NOT_SET;
}
/**
* Return an array 'table_alias' => 'JOIN clause' to bring in any data that
* this feature requires.
*
* The return values for all the features will be checked. It is OK if two
* features join in the same table with the same alias and identical JOIN clauses.
* If two features try to use the same alias with different joins, you get an error.
* Tables included by default are question (alias q) and those defined in {@see view::get_required_joins()}
*
* It is importnat that your join simply adds additional data (or NULLs) to the
* existing rows of the query. It must not cause additional rows.
*
* @return string[] 'table_alias' => 'JOIN clause'
*/
public function get_extra_joins(): array {
return [];
}
/**
* Use table alias 'q' for the question table, or one of the
* ones from get_extra_joins. Every field requested must specify a table prefix.
*
* @return string[] fields required.
*/
public function get_required_fields(): array {
return [];
}
}
@@ -0,0 +1,180 @@
<?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 core_question\local\statistics;
use core_question\local\bank\column_base;
use core_question\statistics\questions\all_calculated_for_qubaid_condition;
use core_component;
/**
* Helper to efficiently load all the statistics for a set of questions.
*
* If you are implementing a question bank column, do not use this method directly.
* Instead, override the {@see column_base::get_required_statistics_fields()} method
* in your column class, and the question bank view will take care of it for you.
*
* @package core_question
* @copyright 2023 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class statistics_bulk_loader {
/**
* Load and aggregate the requested statistics for all the places where the given questions are used.
*
* The returned array will contain a values for each questionid and field, which will be null if the value is not available.
*
* @param int[] $questionids array of question ids.
* @param string[] $requiredstatistics array of the fields required, e.g. ['facility', 'discriminationindex'].
* @return float[][] if a value is not available, it will be set to null.
*/
public static function load_aggregate_statistics(array $questionids, array $requiredstatistics): array {
// Prevent unnecessary statistics calculations.
if (empty($requiredstatistics)) {
$aggregates = [];
foreach ($questionids as $questionid) {
$aggregates[$questionid] = [];
}
return $aggregates;
}
$places = self::get_all_places_where_questions_were_attempted($questionids);
// Set up blank two-dimensional arrays to store the running totals. Indexed by questionid and field name.
$zerovaluesforonequestion = array_combine($requiredstatistics, array_fill(0, count($requiredstatistics), 0));
$counts = array_combine($questionids, array_fill(0, count($questionids), $zerovaluesforonequestion));
$sums = array_combine($questionids, array_fill(0, count($questionids), $zerovaluesforonequestion));
// Load the data for each place, and add to the running totals.
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) {
foreach ($requiredstatistics as $item) {
$value = self::extract_item_value($statistics, $questionid, $item);
if ($value === null) {
continue;
}
$counts[$questionid][$item] += 1;
$sums[$questionid][$item] += $value;
}
}
}
// Compute the averages from the final totals.
$aggregates = [];
foreach ($questionids as $questionid) {
$aggregates[$questionid] = [];
foreach ($requiredstatistics as $item) {
if ($counts[$questionid][$item] > 0) {
$aggregates[$questionid][$item] = $sums[$questionid][$item] / $counts[$questionid][$item];
} else {
$aggregates[$questionid][$item] = null;
}
}
}
return $aggregates;
}
/**
* For a list of questions find all the places, defined by (component, contextid), where there are attempts.
*
* @param int[] $questionids array of question ids that we are interested in.
* @return \stdClass[] list of objects with fields ->component and ->contextid.
*/
protected static function get_all_places_where_questions_were_attempted(array $questionids): array {
global $DB;
[$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
JOIN {context} ctx ON ctx.id = qu.contextid
WHERE qatt.questionid $questionidcondition
GROUP BY qu.component, qu.contextid
ORDER BY qu.contextid ASC
", $params);
// Strip out the unwanted ids.
$places = array_values($places);
foreach ($places as $place) {
unset($place->somethingunique);
}
return $places;
}
/**
* Load the question statistics for all the attempts belonging to a particular component in a particular context.
*
* @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.
*/
protected 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 one of the field names in all_calculated_for_qubaid_condition, e.g. 'facility'.
* @return float|null the required value.
*/
protected 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, look in sub questions.
foreach ($statistics->subquestionstats as $stats) {
if ($stats->questionid == $questionid && isset($stats->$item)) {
return $stats->$item;
}
}
return null;
}
}
@@ -0,0 +1,112 @@
<?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 core_question\output;
use core_question\local\bank\navigation_node_base;
use core_question\local\bank\plugin_features_base;
use moodle_url;
use renderer_base;
use templatable;
use renderable;
use url_select;
/**
* Rendered HTML elements for tertiary nav for Question bank.
*
* Provides a menu of links for question bank tertiary navigation, based on get_navigation_node() implemented by each plugin.
* Optionally includes and additional action button to display alongside the menu.
*
* @package core_question
* @copyright 2021 Sujith Haridasan <sujith@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class qbank_action_menu implements templatable, renderable {
/** @var moodle_url */
private $currenturl;
/** @var ?moodle_url $actionurl URL for additional action button */
protected ?moodle_url $actionurl = null;
/** @var ?string $actionlabel Label for additional action button */
protected ?string $actionlabel = null;
/**
* qbank_actionbar constructor.
*
* @param moodle_url $currenturl The current URL.
*/
public function __construct(moodle_url $currenturl) {
$this->currenturl = $currenturl;
}
/**
* Set the properties of an additional action button specific to the current page.
*
* @param moodle_url $url
* @param string $label
* @return void
*/
public function set_action_button(moodle_url $url, string $label): void {
$this->actionurl = $url;
$this->actionlabel = $label;
}
/**
* Provides the data for the template.
*
* @param renderer_base $output renderer_base object.
* @return array data for the template
*/
public function export_for_template(renderer_base $output): array {
$questionslink = new moodle_url('/question/edit.php', $this->currenturl->params());
$menu = [
$questionslink->out(false) => get_string('questions', 'question'),
];
$plugins = \core_component::get_plugin_list_with_class('qbank', 'plugin_feature', 'plugin_feature.php');
foreach ($plugins as $componentname => $pluginfeaturesclass) {
if (!\core\plugininfo\qbank::is_plugin_enabled($componentname)) {
continue;
}
/** @var plugin_features_base $pluginfeatures */
$pluginfeatures = new $pluginfeaturesclass();
$navigationnode = $pluginfeatures->get_navigation_node();
if (is_null($navigationnode)) {
continue;
}
/** @var moodle_url $url */
$url = $navigationnode->get_navigation_url();
$url->params($this->currenturl->params());
$menu[$url->out(false)] = $navigationnode->get_navigation_title();
}
$actionbutton = null;
if ($this->actionurl) {
$actionbutton = [
'url' => $this->actionurl->out(false),
'label' => $this->actionlabel,
];
}
$urlselect = new url_select($menu, $this->currenturl->out(false), null, 'questionbankaction');
$urlselect->set_label(get_string('questionbanknavigation', 'question'), ['class' => 'accesshide']);
return [
'questionbankselect' => $urlselect->export_for_template($output),
'actionbutton' => $actionbutton
];
}
}
@@ -0,0 +1,142 @@
<?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 core_question\output;
use core\output\datafilter;
use renderer_base;
use stdClass;
/**
* Class for rendering filters on the base view.
*
* @package core_question
* @copyright 2021 Catalyst IT Australia Pty Ltd
* @author Tomo Tsuyuki <tomotsuyuki@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class question_bank_filter_ui extends datafilter {
/** @var int $perpage number of records per page. */
protected $perpage = 0;
/** @var array Parameters for the page URL. */
protected $pagevars;
/** @var array $searchconditions Searchconditions for the filter. */
/** @var array $additionalparams Conditino objects for the current filter. */
/** @var string $component Component for calling the fragment callback. */
/** @var string $callback Fragment callback. */
/** @var string $view View class name. */
/** @var int|null $cmid if in an activity, the cmid. */
/** @var array $extraparams Additional parameters used by view classes. */
/**
* Create a new datafilter
*
* @param \context $context The context of the course being displayed
* @param array $searchconditions Array of condition objects for the current filters
* @param array $additionalparams Additional display parameters
* @param string $component the component for the fragment
* @param string $callback the callback for the fragment
* @param string $view the view class
* @param ?string $tableregionid The ID of the region to update with the fragment
* @param ?int $cmid if in an activity, the cmid.
* @param array $pagevars current filter parameters
* @param array $extraparams additional parameters required for a particular view class.
*/
public function __construct(
\context $context,
protected array $searchconditions,
protected array $additionalparams,
protected string $component,
protected string $callback,
protected string $view,
?string $tableregionid = null,
protected ?int $cmid = null,
array $pagevars = [],
protected array $extraparams = []
) {
parent::__construct($context, $tableregionid);
if (array_key_exists('sortData', $pagevars)) {
foreach ($pagevars['sortData'] as $sortname => $sortorder) {
unset($pagevars['sortData'][$sortname]);
$pagevars['sortData'][str_replace('\\', '\\\\', $sortname)] = $sortorder;
}
}
$this->pagevars = $pagevars;
}
/**
* Get data for all filter types.
*
* @return array
*/
protected function get_filtertypes(): array {
$filtertypes = [];
foreach ($this->searchconditions as $searchcondition) {
$filtertypes[] = $this->get_filter_object(
$searchcondition->get_condition_key(),
$searchcondition->get_title(),
$searchcondition->allow_custom(),
$searchcondition->allow_multiple(),
$searchcondition->get_filter_class(),
$searchcondition->get_initial_values(),
$searchcondition->allow_empty(),
$searchcondition->get_filteroptions(),
$searchcondition->is_required(),
$searchcondition->get_join_list(),
);
}
return $filtertypes;
}
/**
* Export the renderer data in a mustache template friendly format.
*
* @param renderer_base $output Unused.
* @return stdClass Data in a format compatible with a mustache template.
*/
public function export_for_template(renderer_base $output): stdClass {
$defaultcategory = question_get_default_category($this->context->id);
$courseid = null;
if ($this->context->contextlevel == CONTEXT_COURSE) {
$courseid = $this->context->instanceid;
}
if (empty($courseid)) {
$courseid = $this->searchconditions['category']->get_course_id();
}
return (object) [
'tableregionid' => $this->tableregionid,
'courseid' => $courseid,
'filtertypes' => $this->get_filtertypes(),
'selected' => 'category',
'rownumber' => 1,
'categoryid' => $defaultcategory->id,
'perpage' => $this->additionalparams['perpage'] ?? 0,
'contextid' => $this->context->id,
'component' => $this->component,
'callback' => $this->callback,
'view' => str_replace('\\', '\\\\', $this->view),
'cmid' => $this->cmid ?? 0,
'pagevars' => json_encode($this->pagevars),
'extraparams' => json_encode($this->extraparams),
];
}
}
@@ -0,0 +1,115 @@
<?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 core_question\output;
use renderer_base;
/**
* Track and display question version information.
*
* This class handles rendering the question version information (the current version of the question, the total number of versions,
* and if the current version is the latest). It also tracks loaded question definitions that don't yet have the latest version
* loaded, and handles loading the latest version of all pending questions.
*
* @package core_question
* @copyright 2023 onwards Catalyst IT EU {@link https://catalyst-eu.net}
* @author Mark Johnson <mark.johnson@catalyst-eu.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class question_version_info implements \renderable, \templatable {
/**
* @var array List of definitions that don't know whether they are the latest version yet.
*/
public static array $pendingdefinitions = [];
/**
* @var int $version The current version number.
*/
public int $version;
/**
* @var ?int $latestversion The latest version number of this question.
*/
public ?int $latestversion;
/**
* @var bool $shortversion Are we displaying an abbreviation for "version" rather than the full word?
*/
protected bool $shortversion;
/**
* Store the current and latest versions of the question, and whether we want to abbreviate the output string.
*
* @param \question_definition $question
* @param bool $shortversion
*/
public function __construct(\question_definition $question, bool $shortversion = false) {
$this->version = $question->version;
$this->latestversion = $question->latestversion;
$this->shortversion = $shortversion;
}
/**
* Find and set the latest version of all pending question_definition objects.
*
* This will update all pending objects in one go, saving us having to do a query for each question.
*
* @return void
*/
public static function populate_latest_versions(): void {
global $DB;
$pendingentryids = array_map(fn($definition) => $definition->questionbankentryid, self::$pendingdefinitions);
[$insql, $params] = $DB->get_in_or_equal($pendingentryids);
$sql = "SELECT questionbankentryid, MAX(version) AS latestversion
FROM {question_versions}
WHERE questionbankentryid $insql
GROUP BY questionbankentryid";
$latestversions = $DB->get_records_sql_menu($sql, $params);
array_walk(self::$pendingdefinitions, function($definition) use ($latestversions) {
if (!isset($latestversions[$definition->questionbankentryid])) {
return;
}
$definition->set_latest_version($latestversions[$definition->questionbankentryid]);
unset(self::$pendingdefinitions[$definition->id]);
});
}
/**
* Return the question version info as a string, including the version number and whether this is the latest version.
*
* @param renderer_base $output
* @return array
* @throws \coding_exception
*/
public function export_for_template(renderer_base $output): array {
if (is_null($this->latestversion)) {
return [];
}
$identifier = 'versioninfo';
if ($this->version === $this->latestversion) {
$identifier .= 'latest';
}
if ($this->shortversion) {
$identifier = 'short' . $identifier;
}
return [
'versioninfo' => get_string($identifier, 'question', $this)
];
}
}
@@ -0,0 +1,91 @@
<?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 core_question\output;
use renderer_base;
use templatable;
use renderable;
use question_bank;
require_once($CFG->dirroot . '/question/engine/bank.php');
/**
* A UI widget to select other versions of a particular question.
*
* It will help plugins to enable version selection in locations like modal, page etc.
*
* @package core_question
* @copyright 2022 Catalyst IT Australia Pty Ltd
* @author Safat Shahin <safatshahin@catalyst-au.net>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class question_version_selection implements templatable, renderable {
/** @var string */
private $uniqueidentifier;
/** @var int */
private $currentselectedquestionid = null;
/**
* Constructor.
*
* @param string $uniqueidentifier unique identifier for the api usage.
* @param int $currentlyselectedquestionid selected question id in dropdown.
*/
protected function __construct(string $uniqueidentifier, int $currentlyselectedquestionid) {
$this->uniqueidentifier = $uniqueidentifier;
$this->currentselectedquestionid = $currentlyselectedquestionid;
}
/**
* Set the selected question id for the currently selected question.
*
* @param string $uniqueidentifier unique identifier for the api usage.
* @param int $currentlyselectedquestionid selected question id in dropdown.
* @return self an instance of this UI widget for the given question.
*/
public static function make_for_question(string $uniqueidentifier, int $currentlyselectedquestionid): self {
return new self($uniqueidentifier, $currentlyselectedquestionid);
}
/**
* Export the data for version selection mustache.
*
* @param renderer_base $output renderer of the output
* @return array
*/
public function export_for_template(renderer_base $output): array {
$displaydata = [];
$versionsoptions = question_bank::get_all_versions_of_question($this->currentselectedquestionid);
foreach ($versionsoptions as $versionsoption) {
$versionsoption->selected = false;
$a = new \stdClass();
$a->version = $versionsoption->version;
$versionsoption->name = get_string('version_selection', 'core_question', $a);
if ($versionsoption->questionid == $this->currentselectedquestionid) {
$versionsoption->selected = true;
}
$displaydata[] = $versionsoption;
}
return [
'options' => $displaydata,
'uniqueidentifier' => $this->uniqueidentifier,
];
}
}
+615
View File
@@ -0,0 +1,615 @@
<?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/>.
/**
* Privacy Subsystem implementation for core_question.
*
* @package core_question
* @category privacy
* @copyright 2018 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_question\privacy;
use core_privacy\local\metadata\collection;
use core_privacy\local\request\approved_contextlist;
use core_privacy\local\request\approved_userlist;
use core_privacy\local\request\contextlist;
use core_privacy\local\request\transform;
use core_privacy\local\request\userlist;
use core_privacy\local\request\writer;
defined('MOODLE_INTERNAL') || die();
require_once($CFG->libdir . '/questionlib.php');
require_once($CFG->dirroot . '/question/format.php');
require_once($CFG->dirroot . '/question/editlib.php');
require_once($CFG->dirroot . '/question/engine/datalib.php');
/**
* Privacy Subsystem implementation for core_question.
*
* @copyright 2018 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class provider implements
// This component has data.
// We need to return all question information where the user is
// listed in either the question.createdby or question.modifiedby fields.
// We may also need to fetch this informtion from individual plugins in some cases.
// e.g. to fetch the full and other question-specific meta-data.
\core_privacy\local\metadata\provider,
// This is a subsysytem which provides information to core.
\core_privacy\local\request\subsystem\provider,
// This is a subsysytem which provides information to plugins.
\core_privacy\local\request\subsystem\plugin_provider,
// This plugin is capable of determining which users have data within it.
\core_privacy\local\request\core_userlist_provider,
// This plugin is capable of determining which users have data within it for the plugins it provides data to.
\core_privacy\local\request\shared_userlist_provider
{
/**
* Describe the types of data stored by the question subsystem.
*
* @param collection $items The collection to add metadata to.
* @return collection The array of metadata
*/
public static function get_metadata(collection $items): collection {
// Other tables link against it.
// The 'question_usages' table does not contain any user data.
// The table links the but doesn't store itself.
// The 'question_attempts' table contains data about question attempts.
// It does not contain any user ids - these are stored by the caller.
$items->add_database_table('question_attempts', [
'flagged' => 'privacy:metadata:database:question_attempts:flagged',
'responsesummary' => 'privacy:metadata:database:question_attempts:responsesummary',
'timemodified' => 'privacy:metadata:database:question_attempts:timemodified',
], 'privacy:metadata:database:question_attempts');;
// The 'question_attempt_steps' table contains data about changes to the state of a question attempt.
$items->add_database_table('question_attempt_steps', [
'state' => 'privacy:metadata:database:question_attempt_steps:state',
'timecreated' => 'privacy:metadata:database:question_attempt_steps:timecreated',
'fraction' => 'privacy:metadata:database:question_attempt_steps:fraction',
'userid' => 'privacy:metadata:database:question_attempt_steps:userid',
], 'privacy:metadata:database:question_attempt_steps');
// The 'question_attempt_step_data' table contains specific all metadata for each state.
$items->add_database_table('question_attempt_step_data', [
'name' => 'privacy:metadata:database:question_attempt_step_data:name',
'value' => 'privacy:metadata:database:question_attempt_step_data:value',
], 'privacy:metadata:database:question_attempt_step_data');
// These are all part of the set of the question definition
// The 'question' table is used to store instances of each question.
// It contains a createdby and modifiedby which related to specific users.
$items->add_database_table('question', [
'name' => 'privacy:metadata:database:question:name',
'questiontext' => 'privacy:metadata:database:question:questiontext',
'generalfeedback' => 'privacy:metadata:database:question:generalfeedback',
'timecreated' => 'privacy:metadata:database:question:timecreated',
'timemodified' => 'privacy:metadata:database:question:timemodified',
'createdby' => 'privacy:metadata:database:question:createdby',
'modifiedby' => 'privacy:metadata:database:question:modifiedby',
], 'privacy:metadata:database:question');
// The 'question_answers' table is used to store the set of answers, with appropriate feedback for each question.
// It does not contain user data.
// The 'question_hints' table is used to store hints about the correct answer for a question.
// It does not contain user data.
// The 'question_categories' table contains structural information about how questions are presented in the UI.
// It does not contain user data.
// The 'question_statistics' table contains aggregated statistics about responses.
// It does not contain any identifiable user data.
$items->add_database_table('question_bank_entries', [
'ownerid' => 'privacy:metadata:database:question_bank_entries:ownerid',
], 'privacy:metadata:database:question_bank_entries');
// The question subsystem makes use of the qtype, qformat, and qbehaviour plugin types.
$items->add_plugintype_link('qtype', [], 'privacy:metadata:link:qtype');
$items->add_plugintype_link('qformat', [], 'privacy:metadata:link:qformat');
$items->add_plugintype_link('qbehaviour', [], 'privacy:metadata:link:qbehaviour');
return $items;
}
/**
* Export the data for all question attempts on this question usage.
*
* Where a user is the owner of the usage, then the full detail of that usage will be included.
* Where a user has been involved in the usage, but it is not their own usage, then only their specific
* involvement will be exported.
*
* @param int $userid The userid to export.
* @param \context $context The context that the question was used within.
* @param array $usagecontext The subcontext of this usage.
* @param int $usage The question usage ID.
* @param \question_display_options $options The display options used for formatting.
* @param bool $isowner Whether the user being exported is the user who used the question.
*/
public static function export_question_usage(
int $userid,
\context $context,
array $usagecontext,
int $usage,
\question_display_options $options,
bool $isowner
) {
// Determine the questions in this usage.
$quba = \question_engine::load_questions_usage_by_activity($usage);
$basepath = $usagecontext;
$questionscontext = array_merge($usagecontext, [
get_string('questions', 'core_question'),
]);
foreach ($quba->get_attempt_iterator() as $qa) {
$question = $qa->get_question(false);
$slotno = $qa->get_slot();
$questionnocontext = array_merge($questionscontext, [$slotno]);
if ($isowner) {
// This user is the overal owner of the question attempt and all data wil therefore be exported.
//
// Respect _some_ of the question_display_options to ensure that they don't have access to
// generalfeedback and mark if the display options prevent this.
// This is defensible because they can submit questions without completing a quiz and perform an SAR to
// get prior access to the feedback and mark to improve upon it.
// Export the response.
$data = (object) [
'name' => $question->name,
'question' => $qa->get_question_summary(),
'answer' => $qa->get_response_summary(),
'timemodified' => transform::datetime($qa->timemodified),
];
if ($options->marks >= \question_display_options::MARK_AND_MAX) {
$data->mark = $qa->format_mark($options->markdp);
}
if ($options->flags != \question_display_options::HIDDEN) {
$data->flagged = transform::yesno($qa->is_flagged());
}
if ($options->generalfeedback != \question_display_options::HIDDEN) {
$data->generalfeedback = $question->format_generalfeedback($qa);
}
if ($options->manualcomment != \question_display_options::HIDDEN) {
if ($qa->has_manual_comment()) {
// Note - the export of the step data will ensure that the files are exported.
// No need to do it again here.
list($comment, $commentformat, $step) = $qa->get_manual_comment();
$comment = writer::with_context($context)
->rewrite_pluginfile_urls(
$questionnocontext,
'question',
'response_bf_comment',
$step->get_id(),
$comment
);
$data->comment = $qa->get_behaviour(false)->format_comment($comment, $commentformat);
}
}
writer::with_context($context)
->export_data($questionnocontext, $data);
// Export the step data.
static::export_question_attempt_steps($userid, $context, $questionnocontext, $qa, $options, $isowner);
}
}
}
/**
* Export the data for each step transition for each question in each question attempt.
*
* Where a user is the owner of the usage, then all steps in the question usage will be exported.
* Where a user is not the owner, but has been involved in the usage, then only their specific
* involvement will be exported.
*
* @param int $userid The user to export for
* @param \context $context The context that the question was used within.
* @param array $questionnocontext The subcontext of this question number.
* @param \question_attempt $qa The attempt being checked
* @param \question_display_options $options The display options used for formatting.
* @param bool $isowner Whether the user being exported is the user who used the question.
*/
public static function export_question_attempt_steps(
int $userid,
\context $context,
array $questionnocontext,
\question_attempt $qa,
\question_display_options $options,
$isowner
) {
$attemptdata = (object) [
'steps' => [],
];
$stepno = 0;
foreach ($qa->get_step_iterator() as $i => $step) {
$stepno++;
if ($isowner || ($step->get_user_id() != $userid)) {
// The user is the owner, or the author of the step.
$restrictedqa = new \question_attempt_with_restricted_history($qa, $i, null);
$stepdata = (object) [
// Note: Do not include the user here.
'time' => transform::datetime($step->get_timecreated()),
'action' => $qa->summarise_action($step),
];
if ($options->marks >= \question_display_options::MARK_AND_MAX) {
$stepdata->mark = $qa->format_fraction_as_mark($step->get_fraction(), $options->markdp);
}
if ($options->correctness != \question_display_options::HIDDEN) {
$stepdata->state = $restrictedqa->get_state_string($options->correctness);
}
if ($step->has_behaviour_var('comment')) {
$comment = $step->get_behaviour_var('comment');
$commentformat = $step->get_behaviour_var('commentformat');
if (empty(trim($comment))) {
// Skip empty comments.
continue;
}
// Format the comment.
$comment = writer::with_context($context)
->rewrite_pluginfile_urls(
$questionnocontext,
'question',
'response_bf_comment',
$step->get_id(),
$comment
);
// Export any files associated with the comment files area.
writer::with_context($context)
->export_area_files(
$questionnocontext,
'question',
"response_bf_comment",
$step->get_id()
);
$stepdata->comment = $qa->get_behaviour(false)->format_comment($comment, $commentformat);
}
// Export any response files associated with this step.
foreach (\question_engine::get_all_response_file_areas() as $filearea) {
writer::with_context($context)
->export_area_files(
$questionnocontext,
'question',
$filearea,
$step->get_id()
);
}
$attemptdata->steps[$stepno] = $stepdata;
}
}
if (!empty($attemptdata->steps)) {
writer::with_context($context)
->export_related_data($questionnocontext, 'steps', $attemptdata);
}
}
/**
* Get the list of contexts where the specified user has either created, or edited a question.
*
* To export usage of a question, please call {@link provider::export_question_usage()} from the module which
* instantiated the usage of the question.
*
* @param int $userid The user to search.
* @return contextlist $contextlist The contextlist containing the list of contexts used in this plugin.
*/
public static function get_contexts_for_userid(int $userid): contextlist {
$contextlist = new contextlist();
// A user may have created or updated a question.
// Questions are linked against a question category, which has a contextid field.
$sql = "SELECT qc.contextid
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid
WHERE q.createdby = :useridcreated
OR q.modifiedby = :useridmodified";
$params = [
'useridcreated' => $userid,
'useridmodified' => $userid,
];
$contextlist->add_from_sql($sql, $params);
return $contextlist;
}
/**
* Get the list of users who have data within a context.
*
* @param userlist $userlist The userlist containing the list of users who have data in this context/plugin combination.
*/
public static function get_users_in_context(userlist $userlist) {
$context = $userlist->get_context();
// A user may have created or updated a question.
// Questions are linked against a question category, which has a contextid field.
$sql = "SELECT q.createdby, q.modifiedby
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid
WHERE qc.contextid = :contextid";
$params = [
'contextid' => $context->id
];
$userlist->add_from_sql('createdby', $sql, $params);
$userlist->add_from_sql('modifiedby', $sql, $params);
}
/**
* Determine related question usages for a user.
*
* @param string $prefix A unique prefix to add to the table alias
* @param string $component The name of the component to fetch usages for.
* @param string $joinfield The SQL field name to use in the JOIN ON - e.g. q.usageid
* @param int $userid The user to search.
* @return \qubaid_join
*/
public static function get_related_question_usages_for_user(string $prefix, string $component, string $joinfield, int $userid): \qubaid_join {
return new \qubaid_join("
JOIN {question_usages} {$prefix}_qu ON {$prefix}_qu.id = {$joinfield}
AND {$prefix}_qu.component = :{$prefix}_usagecomponent
JOIN {question_attempts} {$prefix}_qa ON {$prefix}_qa.questionusageid = {$prefix}_qu.id
JOIN {question_attempt_steps} {$prefix}_qas ON {$prefix}_qas.questionattemptid = {$prefix}_qa.id",
"{$prefix}_qu.id",
"{$prefix}_qas.userid = :{$prefix}_stepuserid",
[
"{$prefix}_stepuserid" => $userid,
"{$prefix}_usagecomponent" => $component,
]);
}
/**
* Add the list of users who have rated in the specified constraints.
*
* @param userlist $userlist The userlist to add the users to.
* @param string $prefix A unique prefix to add to the table alias to avoid interference with your own sql.
* @param string $insql The SQL to use in a sub-select for the question_usages.id query.
* @param array $params The params required for the insql.
* @param int|null $contextid An optional context id, in case the $sql query is not already filtered by that.
*/
public static function get_users_in_context_from_sql(userlist $userlist, string $prefix, string $insql, $params,
int $contextid = null) {
$sql = "SELECT {$prefix}_qas.userid
FROM {question_attempt_steps} {$prefix}_qas
JOIN {question_attempts} {$prefix}_qa ON {$prefix}_qas.questionattemptid = {$prefix}_qa.id
JOIN {question_usages} {$prefix}_qu ON {$prefix}_qa.questionusageid = {$prefix}_qu.id
WHERE {$prefix}_qu.id IN ({$insql})";
if ($contextid) {
$sql .= " AND {$prefix}_qu.contextid = :{$prefix}_contextid";
$params["{$prefix}_contextid"] = $contextid;
}
$userlist->add_from_sql('userid', $sql, $params);
}
/**
* Export all user data for the specified user, in the specified contexts.
*
* @param approved_contextlist $contextlist The approved contexts to export information for.
*/
public static function export_user_data(approved_contextlist $contextlist) {
global $CFG, $DB, $SITE;
if (empty($contextlist)) {
return;
}
// Use the Moodle XML Data format.
// It is the only lossless format that we support.
$format = "xml";
require_once($CFG->dirroot . "/question/format/{$format}/format.php");
// THe export system needs questions in a particular format.
// The easiest way to fetch these is with get_questions_category() which takes the details of a question
// category.
// We fetch the root question category for each context and the get_questions_category function recurses to
// After fetching them, we filter out any not created or modified by the requestor.
$user = $contextlist->get_user();
$userid = $user->id;
list($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED);
$categories = $DB->get_records_select('question_categories', "contextid {$contextsql} AND parent = 0", $contextparams);
$classname = "qformat_{$format}";
foreach ($categories as $category) {
$context = \context::instance_by_id($category->contextid);
$questions = get_questions_category($category, true);
$questions = array_filter($questions, function($question) use ($userid) {
return ($question->createdby == $userid) || ($question->modifiedby == $userid);
}, ARRAY_FILTER_USE_BOTH);
if (empty($questions)) {
continue;
}
$qformat = new $classname();
$qformat->setQuestions($questions);
$qformat->setContexts([$context]);
$qformat->setContexttofile(true);
// We do not know which course this belongs to, and it's not actually used except in error, so use Site.
$qformat->setCourse($SITE);
$content = '';
if ($qformat->exportpreprocess()) {
$content = $qformat->exportprocess(false);
}
$subcontext = [
get_string('questionbank', 'core_question'),
];
writer::with_context($context)->export_custom_file($subcontext, 'questions.xml', $content);
}
}
/**
* Delete all data for all users in the specified context.
*
* @param \context $context The specific context to delete data for.
* @throws \dml_exception
*/
public static function delete_data_for_all_users_in_context(\context $context) {
global $DB;
// Questions are considered to be 'owned' by the institution, even if they were originally written by a specific
// user. They are still exported in the list of a users data, but they are not removed.
// The userid is instead anonymised.
$sql = 'SELECT q.*
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid
WHERE qc.contextid = ?';
$questions = $DB->get_records_sql($sql, [$context->id]);
foreach ($questions as $question) {
$question->createdby = 0;
$question->modifiedby = 0;
$DB->update_record('question', $question);
}
}
/**
* Delete all user data for the specified user, in the specified contexts.
*
* @param approved_contextlist $contextlist The approved contexts and user information to delete information for.
*/
public static function delete_data_for_user(approved_contextlist $contextlist) {
global $DB;
// Questions are considered to be 'owned' by the institution, even if they were originally written by a specific
// user. They are still exported in the list of a users data, but they are not removed.
// The userid is instead anonymised.
list($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED);
$contextparams['createdby'] = $contextlist->get_user()->id;
$questiondata = $DB->get_records_sql(
"SELECT q.*
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid
WHERE qc.contextid {$contextsql}
AND q.createdby = :createdby", $contextparams);
foreach ($questiondata as $question) {
$question->createdby = 0;
$DB->update_record('question', $question);
}
list($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED);
$contextparams['modifiedby'] = $contextlist->get_user()->id;
$questiondata = $DB->get_records_sql(
"SELECT q.*
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid
WHERE qc.contextid {$contextsql}
AND q.modifiedby = :modifiedby", $contextparams);
foreach ($questiondata as $question) {
$question->modifiedby = 0;
$DB->update_record('question', $question);
}
}
/**
* Delete multiple users within a single context.
*
* @param approved_userlist $userlist The approved context and user information to delete information for.
*/
public static function delete_data_for_users(approved_userlist $userlist) {
global $DB;
// Questions are considered to be 'owned' by the institution, even if they were originally written by a specific
// user. They are still exported in the list of a users data, but they are not removed.
// The userid is instead anonymised.
$context = $userlist->get_context();
$userids = $userlist->get_userids();
list($createdbysql, $createdbyparams) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED);
list($modifiedbysql, $modifiedbyparams) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED);
$params = ['contextid' => $context->id];
$questiondata = $DB->get_records_sql(
"SELECT q.*
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid
WHERE qc.contextid = :contextid
AND q.createdby {$createdbysql}", $params + $createdbyparams);
foreach ($questiondata as $question) {
$question->createdby = 0;
$DB->update_record('question', $question);
}
$questiondata = $DB->get_records_sql(
"SELECT q.*
FROM {question} q
JOIN {question_versions} qv ON qv.questionid = q.id
JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid
JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid
WHERE qc.contextid = :contextid
AND q.modifiedby {$modifiedbysql}", $params + $modifiedbyparams);
foreach ($questiondata as $question) {
$question->modifiedby = 0;
$DB->update_record('question', $question);
}
}
}
@@ -0,0 +1,129 @@
<?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 core_question;
use core_question\local\bank\question_version_status;
/**
* This class should provide an API for managing question_references.
*
* Unfortunately, question_references were introduced in the DB structure
* without an nice API. This class is being added later, and is currently
* terribly incomplete, but hopefully it can be improved in time.
*
* @package core_question
* @copyright 2023 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class question_reference_manager {
/**
* Return a list of those questions from the list passed in, which are referenced.
*
* A question is referenced if either:
* - There is a question_reference pointing at exactly that version of that question; or
* - There is an 'always latest' reference, and the question id is the latest non-draft version
* of that question_bank_entry.
*
* @param array $questionids a list of question ids to check.
* @return array a list of the question ids from the input array which are referenced.
*/
public static function questions_with_references(array $questionids): array {
global $DB;
if (empty($questionids)) {
return [];
}
[$qidtest, $params] = $DB->get_in_or_equal($questionids, SQL_PARAMS_NAMED, 'outerqid');
[$lqidtest, $lparams] = $DB->get_in_or_equal($questionids, SQL_PARAMS_NAMED, 'innerqid');
return $DB->get_fieldset_sql("
SELECT qv.questionid
FROM {question_versions} qv
-- This is a performant to get the latest non-draft version for each
-- question_bank_entry that relates to one of our questionids.
LEFT JOIN (
SELECT lqv.questionbankentryid,
MAX(lv.version) AS latestusableversion
FROM {question_versions} lqv
JOIN {question_versions} lv ON lv.questionbankentryid = lqv.questionbankentryid
WHERE lqv.questionid $lqidtest
AND lv.status <> :draft
GROUP BY lqv.questionbankentryid
) latestversions ON latestversions.questionbankentryid = qv.questionbankentryid
JOIN {question_references} qr ON qr.questionbankentryid = qv.questionbankentryid
AND (qr.version = qv.version OR qr.version IS NULL AND qv.version = latestversions.latestusableversion)
WHERE qv.questionid $qidtest
", array_merge($params, $lparams, ['draft' => question_version_status::QUESTION_STATUS_DRAFT]));
}
/**
* This will transform set reference filter conditions to use the new filter structure.
*
* Previously filterconditions had questioncategoryid, includesubcategories and tags options.
* These have been replaced by the new category and tags filters. This function convers the old
* pre-4.3 filter condition structure to the new one.
*
* @param array $filtercondition Pre-4.3 filter condition.
* @return array Post-4.3 filter condition.
*/
public static function convert_legacy_set_reference_filter_condition(array $filtercondition): array {
if (!isset($filtercondition['filter'])) {
$filtercondition['filter'] = [];
// Question category filter.
if (isset($filtercondition['questioncategoryid'])) {
$filtercondition['filter']['category'] = [
'jointype' => \qbank_managecategories\category_condition::JOINTYPE_DEFAULT,
'values' => [$filtercondition['questioncategoryid']],
'filteroptions' => ['includesubcategories' => $filtercondition['includingsubcategories']],
];
unset($filtercondition['questioncategoryid']);
unset($filtercondition['includingsubcategories']);
}
// Tag filters.
if (isset($filtercondition['tags'])) {
// Get the names of the tags in the condition. Find or create corresponding tags,
// and set their ids in the new condition.
$oldtags = array_map(fn($oldtag) => explode(',', $oldtag)[1], $filtercondition['tags']);
$newtags = \core_tag_tag::create_if_missing(1, $oldtags);
$newtagids = array_map(fn($newtag) => $newtag->id, $newtags);
$filtercondition['filter']['qtagids'] = [
'jointype' => \qbank_tagquestion\tag_condition::JOINTYPE_DEFAULT,
'values' => array_values($newtagids),
];
unset($filtercondition['tags']);
}
// Add additional default properties to the filtercondition.
$filtercondition['tabname'] = 'questions';
$filtercondition['qpage'] = 0;
$filtercondition['qperpage'] = 100;
$filtercondition['jointype'] = \core\output\datafilter::JOINTYPE_ALL;
} else if (isset($filtercondition['filter']['category']['includesubcategories'])) {
$filtercondition['filter']['category']['filteroptions'] =
['includesubcategories' => $filtercondition['filter']['category']['includesubcategories']];
unset($filtercondition['filter']['category']['includesubcategories']);
}
return $filtercondition;
}
}
@@ -0,0 +1,497 @@
<?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/>.
/**
* A collection of all the question statistics calculated for an activity instance ie. the stats calculated for slots and
* sub-questions and variants of those questions.
*
* @package core_question
* @copyright 2014 The Open University
* @author James Pratt me@jamiep.org
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_question\statistics\questions;
use question_bank;
/**
* A collection of all the question statistics calculated for an activity instance.
*
* @package core_question
* @copyright 2014 The Open University
* @author James Pratt me@jamiep.org
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class all_calculated_for_qubaid_condition {
/**
* @var int previously, the time after which statistics are automatically recomputed.
* @deprecated since Moodle 4.3. Use of pre-computed stats is no longer time-limited.
* @todo MDL-78090 Final deprecation in Moodle 4.7
*/
const TIME_TO_CACHE = 900; // 15 minutes.
/**
* @var object[]
*/
public $subquestions = [];
/**
* Holds slot (position) stats and stats for variants of questions in slots.
*
* @var calculated[]
*/
public $questionstats = array();
/**
* Holds sub-question stats and stats for variants of subqs.
*
* @var calculated_for_subquestion[]
*/
public $subquestionstats = array();
/**
* Set up a calculated_for_subquestion instance ready to store a randomly selected question's stats.
*
* @param object $step
* @param int|null $variant Is this to keep track of a variant's stats? If so what is the variant, if not null.
*/
public function initialise_for_subq($step, $variant = null) {
$newsubqstat = new calculated_for_subquestion($step, $variant);
if ($variant === null) {
$this->subquestionstats[$step->questionid] = $newsubqstat;
} else {
$this->subquestionstats[$step->questionid]->variantstats[$variant] = $newsubqstat;
}
}
/**
* Set up a calculated instance ready to store a slot question's stats.
*
* @param int $slot
* @param object $question
* @param int|null $variant Is this to keep track of a variant's stats? If so what is the variant, if not null.
*/
public function initialise_for_slot($slot, $question, $variant = null) {
$newqstat = new calculated($question, $slot, $variant);
if ($variant === null) {
$this->questionstats[$slot] = $newqstat;
} else {
$this->questionstats[$slot]->variantstats[$variant] = $newqstat;
}
}
/**
* Do we have stats for a particular quesitonid (and optionally variant)?
*
* @param int $questionid The id of the sub question.
* @param int|null $variant if not null then we want the object to store a variant of a sub-question's stats.
* @return bool whether those stats exist (yet).
*/
public function has_subq($questionid, $variant = null) {
if ($variant === null) {
return isset($this->subquestionstats[$questionid]);
} else {
return isset($this->subquestionstats[$questionid]->variantstats[$variant]);
}
}
/**
* Reference for a item stats instance for a questionid and optional variant no.
*
* @param int $questionid The id of the sub question.
* @param int|null $variant if not null then we want the object to store a variant of a sub-question's stats.
* @return calculated|calculated_for_subquestion stats instance for a questionid and optional variant no.
* Will be a calculated_for_subquestion if no variant specified.
* @throws \coding_exception if there is an attempt to respond to a non-existant set of stats.
*/
public function for_subq($questionid, $variant = null) {
if ($variant === null) {
if (!isset($this->subquestionstats[$questionid])) {
throw new \coding_exception('Reference to unknown question id ' . $questionid);
} else {
return $this->subquestionstats[$questionid];
}
} else {
if (!isset($this->subquestionstats[$questionid]->variantstats[$variant])) {
throw new \coding_exception('Reference to unknown question id ' . $questionid .
' variant ' . $variant);
} else {
return $this->subquestionstats[$questionid]->variantstats[$variant];
}
}
}
/**
* ids of all randomly selected question for all slots.
*
* @return int[] An array of all sub-question ids.
*/
public function get_all_subq_ids() {
return array_keys($this->subquestionstats);
}
/**
* All slots nos that stats have been calculated for.
*
* @return int[] An array of all slot nos.
*/
public function get_all_slots() {
return array_keys($this->questionstats);
}
/**
* Do we have stats for a particular slot (and optionally variant)?
*
* @param int $slot The slot no.
* @param int|null $variant if provided then we want the object which stores a variant of a position's stats.
* @return bool whether those stats exist (yet).
*/
public function has_slot($slot, $variant = null) {
if ($variant === null) {
return isset($this->questionstats[$slot]);
} else {
return isset($this->questionstats[$slot]->variantstats[$variant]);
}
}
/**
* Get position stats instance for a slot and optional variant no.
*
* @param int $slot The slot no.
* @param int|null $variant if provided then we want the object which stores a variant of a position's stats.
* @return calculated|calculated_for_subquestion An instance of the class storing the calculated position stats.
* @throws \coding_exception if there is an attempt to respond to a non-existant set of stats.
*/
public function for_slot($slot, $variant = null) {
if ($variant === null) {
if (!isset($this->questionstats[$slot])) {
throw new \coding_exception('Reference to unknown slot ' . $slot);
} else {
return $this->questionstats[$slot];
}
} else {
if (!isset($this->questionstats[$slot]->variantstats[$variant])) {
throw new \coding_exception('Reference to unknown slot ' . $slot . ' variant ' . $variant);
} else {
return $this->questionstats[$slot]->variantstats[$variant];
}
}
}
/**
* Load cached statistics from the database.
*
* @param \qubaid_condition $qubaids Which question usages to load stats for?
*/
public function get_cached($qubaids) {
global $DB;
$timemodified = self::get_last_calculated_time($qubaids);
$questionstatrecs = $DB->get_records('question_statistics',
['hashcode' => $qubaids->get_hash_code(), 'timemodified' => $timemodified]);
$questionids = array();
foreach ($questionstatrecs as $fromdb) {
if (is_null($fromdb->variant) && !$fromdb->slot) {
$questionids[] = $fromdb->questionid;
}
}
$this->subquestions = question_load_questions($questionids);
foreach ($questionstatrecs as $fromdb) {
if (is_null($fromdb->variant)) {
if ($fromdb->slot) {
if (!isset($this->questionstats[$fromdb->slot])) {
debugging('Statistics found for slot ' . $fromdb->slot .
' in stats ' . json_encode($qubaids->from_where_params()) .
' which is not an analysable question.', DEBUG_DEVELOPER);
}
$this->questionstats[$fromdb->slot]->populate_from_record($fromdb);
} else {
$this->subquestionstats[$fromdb->questionid] = new calculated_for_subquestion();
$this->subquestionstats[$fromdb->questionid]->populate_from_record($fromdb);
if (isset($this->subquestions[$fromdb->questionid])) {
$this->subquestionstats[$fromdb->questionid]->question =
$this->subquestions[$fromdb->questionid];
} else {
$this->subquestionstats[$fromdb->questionid]->question = question_bank::get_qtype(
'missingtype', false)->make_deleted_instance($fromdb->questionid, 1);
}
}
}
}
// Add cached variant stats to data structure.
foreach ($questionstatrecs as $fromdb) {
if (!is_null($fromdb->variant)) {
if ($fromdb->slot) {
if (!isset($this->questionstats[$fromdb->slot])) {
debugging('Statistics found for slot ' . $fromdb->slot .
' in stats ' . json_encode($qubaids->from_where_params()) .
' which is not an analysable question.', DEBUG_DEVELOPER);
continue;
}
$newcalcinstance = new calculated();
$this->questionstats[$fromdb->slot]->variantstats[$fromdb->variant] = $newcalcinstance;
$newcalcinstance->question = $this->questionstats[$fromdb->slot]->question;
} else {
$newcalcinstance = new calculated_for_subquestion();
$this->subquestionstats[$fromdb->questionid]->variantstats[$fromdb->variant] = $newcalcinstance;
if (isset($this->subquestions[$fromdb->questionid])) {
$newcalcinstance->question = $this->subquestions[$fromdb->questionid];
} else {
$newcalcinstance->question = question_bank::get_qtype(
'missingtype', false)->make_deleted_instance($fromdb->questionid, 1);
}
}
$newcalcinstance->populate_from_record($fromdb);
}
}
}
/**
* Find time of non-expired statistics in the database.
*
* @param \qubaid_condition $qubaids Which question usages to look for stats for?
* @return int|bool Time of cached record that matches this qubaid_condition or false if non found.
*/
public function get_last_calculated_time($qubaids) {
global $DB;
$lastcalculatedtime = $DB->get_field('question_statistics', 'COALESCE(MAX(timemodified), 0)',
['hashcode' => $qubaids->get_hash_code()]);
if ($lastcalculatedtime) {
return $lastcalculatedtime;
} else {
return false;
}
}
/**
* Save stats to db, first cleaning up any old ones.
*
* @param \qubaid_condition $qubaids Which question usages are we caching the stats of?
*/
public function cache($qubaids) {
global $DB;
$transaction = $DB->start_delegated_transaction();
$timemodified = time();
foreach ($this->get_all_slots() as $slot) {
$this->for_slot($slot)->cache($qubaids, $timemodified);
}
foreach ($this->get_all_subq_ids() as $subqid) {
$this->for_subq($subqid)->cache($qubaids, $timemodified);
}
$transaction->allow_commit();
}
/**
* Return all sub-questions used.
*
* @return \object[] array of questions.
*/
public function get_sub_questions() {
return $this->subquestions;
}
/**
* Return all stats for one slot, stats for the slot itself, and either :
* - variants of question
* - variants of randomly selected questions
* - randomly selected questions
*
* @param int $slot the slot no
* @param bool|int $limitvariants limit number of variants and sub-questions displayed?
* @return calculated|calculated_for_subquestion[] stats to display
*/
public function structure_analysis_for_one_slot($slot, $limitvariants = false) {
return array_merge(array($this->for_slot($slot)), $this->all_subq_and_variant_stats_for_slot($slot, $limitvariants));
}
/**
* Call after calculations to output any error messages.
*
* @return string[] Array of strings describing error messages found during stats calculation.
*/
public function any_error_messages() {
$errors = array();
foreach ($this->get_all_slots() as $slot) {
foreach ($this->for_slot($slot)->get_sub_question_ids() as $subqid) {
if ($this->for_subq($subqid)->differentweights) {
$name = $this->for_subq($subqid)->question->name;
$errors[] = get_string('erroritemappearsmorethanoncewithdifferentweight', 'question', $name);
}
}
}
return $errors;
}
/**
* Return all stats for variants of question in slot $slot.
*
* @param int $slot The slot no.
* @return calculated[] The instances storing the calculated stats.
*/
protected function all_variant_stats_for_one_slot($slot) {
$toreturn = array();
foreach ($this->for_slot($slot)->get_variants() as $variant) {
$toreturn[] = $this->for_slot($slot, $variant);
}
return $toreturn;
}
/**
* Return all stats for variants of randomly selected questions for one slot $slot.
*
* @param int $slot The slot no.
* @return calculated[] The instances storing the calculated stats.
*/
protected function all_subq_variants_for_one_slot($slot) {
$toreturn = array();
$displayorder = 1;
foreach ($this->for_slot($slot)->get_sub_question_ids() as $subqid) {
if ($variants = $this->for_subq($subqid)->get_variants()) {
foreach ($variants as $variant) {
$toreturn[] = $this->make_new_subq_stat_for($displayorder, $slot, $subqid, $variant);
}
}
$displayorder++;
}
return $toreturn;
}
/**
* Return all stats for randomly selected questions for one slot $slot.
*
* @param int $slot The slot no.
* @return calculated[] The instances storing the calculated stats.
*/
protected function all_subqs_for_one_slot($slot) {
$displayorder = 1;
$toreturn = array();
foreach ($this->for_slot($slot)->get_sub_question_ids() as $subqid) {
$toreturn[] = $this->make_new_subq_stat_for($displayorder, $slot, $subqid);
$displayorder++;
}
return $toreturn;
}
/**
* Return all variant or 'sub-question' stats one slot, either :
* - variants of question
* - variants of randomly selected questions
* - randomly selected questions
*
* @param int $slot the slot no
* @param bool $limited limit number of variants and sub-questions displayed?
* @return calculated|calculated_for_subquestion|calculated_question_summary[] stats to display
*/
protected function all_subq_and_variant_stats_for_slot($slot, $limited) {
// Random question in this slot?
if ($this->for_slot($slot)->get_sub_question_ids()) {
$toreturn = array();
if ($limited) {
$randomquestioncalculated = $this->for_slot($slot);
if ($subqvariantstats = $this->all_subq_variants_for_one_slot($slot)) {
// There are some variants from randomly selected questions.
// If we're showing a limited view of the statistics then add a question summary stat
// rather than a stat for each subquestion.
$summarystat = $this->make_new_calculated_question_summary_stat($randomquestioncalculated, $subqvariantstats);
$toreturn = array_merge($toreturn, [$summarystat]);
}
if ($subqstats = $this->all_subqs_for_one_slot($slot)) {
// There are some randomly selected questions.
// If we're showing a limited view of the statistics then add a question summary stat
// rather than a stat for each subquestion.
$summarystat = $this->make_new_calculated_question_summary_stat($randomquestioncalculated, $subqstats);
$toreturn = array_merge($toreturn, [$summarystat]);
}
foreach ($toreturn as $index => $calculated) {
$calculated->subqdisplayorder = $index;
}
} else {
$displaynumber = 1;
foreach ($this->for_slot($slot)->get_sub_question_ids() as $subqid) {
$toreturn[] = $this->make_new_subq_stat_for($displaynumber, $slot, $subqid);
if ($variants = $this->for_subq($subqid)->get_variants()) {
foreach ($variants as $variant) {
$toreturn[] = $this->make_new_subq_stat_for($displaynumber, $slot, $subqid, $variant);
}
}
$displaynumber++;
}
}
return $toreturn;
} else {
$variantstats = $this->all_variant_stats_for_one_slot($slot);
if ($limited && $variantstats) {
$variantquestioncalculated = $this->for_slot($slot);
// If we're showing a limited view of the statistics then add a question summary stat
// rather than a stat for each variation.
$summarystat = $this->make_new_calculated_question_summary_stat($variantquestioncalculated, $variantstats);
return [$summarystat];
} else {
return $variantstats;
}
}
}
/**
* We need a new object for display. Sub-question stats can appear more than once in different slots.
* So we create a clone of the object and then we can set properties on the object that are per slot.
*
* @param int $displaynumber The display number for this sub question.
* @param int $slot The slot number.
* @param int $subqid The sub question id.
* @param null|int $variant The variant no.
* @return calculated_for_subquestion The object for display.
*/
protected function make_new_subq_stat_for($displaynumber, $slot, $subqid, $variant = null) {
$slotstat = fullclone($this->for_subq($subqid, $variant));
$slotstat->question->number = $this->for_slot($slot)->question->number;
$slotstat->subqdisplayorder = $displaynumber;
return $slotstat;
}
/**
* Create a summary calculated object for a calculated question. This is used as a placeholder
* to indicate that a calculated question has sub questions or variations to show rather than listing each
* subquestion or variation directly.
*
* @param calculated $randomquestioncalculated The calculated instance for the random question slot.
* @param calculated[] $subquestionstats The instances of the calculated stats of the questions that are being summarised.
* @return calculated_question_summary
*/
protected function make_new_calculated_question_summary_stat($randomquestioncalculated, $subquestionstats) {
$question = $randomquestioncalculated->question;
$slot = $randomquestioncalculated->slot;
$calculatedsummary = new calculated_question_summary($question, $slot, $subquestionstats);
return $calculatedsummary;
}
}
@@ -0,0 +1,304 @@
<?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/>.
/**
* Question statistics calculations class. Used in the quiz statistics report but also available for use elsewhere.
*
* @package core
* @subpackage questionbank
* @copyright 2013 Open University
* @author Jamie Pratt <me@jamiep.org>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_question\statistics\questions;
defined('MOODLE_INTERNAL') || die();
/**
* This class is used to return the stats as calculated by {@link \core_question\statistics\questions\calculator}
*
* @copyright 2013 Open University
* @author Jamie Pratt <me@jamiep.org>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class calculated {
public $questionid;
// These first fields are the final fields cached in the db and shown in reports.
// See : http://docs.moodle.org/dev/Quiz_statistics_calculations#Position_statistics .
public $slot = null;
/**
* @var null|integer if this property is not null then this is the stats for a variant of a question or when inherited by
* calculated_for_subquestion and not null then this is the stats for a variant of a sub question.
*/
public $variant = null;
/**
* @var bool is this a sub question.
*/
public $subquestion = false;
/**
* @var string if this stat has been picked as a min, median or maximum facility value then this string says which stat this
* is. Prepended to question name for display.
*/
public $minmedianmaxnotice = '';
/**
* @var int total attempts at this question.
*/
public $s = 0;
/**
* @var float effective weight of this question.
*/
public $effectiveweight;
/**
* @var bool is covariance of this questions mark with other question marks negative?
*/
public $negcovar;
/**
* @var float
*/
public $discriminationindex;
/**
* @var float
*/
public $discriminativeefficiency;
/**
* @var float standard deviation
*/
public $sd;
/**
* @var float
*/
public $facility;
/**
* @var float max mark achievable for this question.
*/
public $maxmark;
/**
* @var string comma separated list of the positions in which this question appears.
*/
public $positions;
/**
* @var null|float The average score that students would have got by guessing randomly. Or null if not calculable.
*/
public $randomguessscore = null;
// End of fields in db.
protected $fieldsindb = array('questionid', 'slot', 'subquestion', 's', 'effectiveweight', 'negcovar', 'discriminationindex',
'discriminativeefficiency', 'sd', 'facility', 'subquestions', 'maxmark', 'positions', 'randomguessscore', 'variant');
// Fields used for intermediate calculations.
public $totalmarks = 0;
public $totalothermarks = 0;
/**
* @var float The total of marks achieved for all positions in all attempts where this item was seen.
*/
public $totalsummarks = 0;
public $markvariancesum = 0;
public $othermarkvariancesum = 0;
public $covariancesum = 0;
public $covariancemaxsum = 0;
public $subquestions = '';
public $covariancewithoverallmarksum = 0;
public $markarray = array();
public $othermarksarray = array();
public $markaverage;
public $othermarkaverage;
/**
* @var float The average for all attempts, of the sum of the marks for all positions in which this item appeared.
*/
public $summarksaverage;
public $markvariance;
public $othermarkvariance;
public $covariance;
public $covariancemax;
public $covariancewithoverallmark;
/**
* @var object full question data
*/
public $question;
/**
* An array of calculated stats for each variant of the question. Even when there is just one variant we still calculate this
* data as there is no way to know if there are variants before we have finished going through the attempt data one time.
*
* @var calculated[] $variants
*/
public $variantstats = array();
/**
* Set if this record has been retrieved from cache. This is the time that the statistics were calculated.
*
* @var integer
*/
public $timemodified;
/**
* Set up a calculated instance ready to store a question's (or a variant of a slot's question's)
* stats for one slot in the quiz.
*
* @param null|object $question
* @param null|int $slot
* @param null|int $variant
*/
public function __construct($question = null, $slot = null, $variant = null) {
if ($question !== null) {
$this->questionid = $question->id;
$this->maxmark = $question->maxmark;
$this->positions = $question->number;
$this->question = $question;
}
if ($slot !== null) {
$this->slot = $slot;
}
if ($variant !== null) {
$this->variant = $variant;
}
}
/**
* Used to determine which random questions pull sub questions from the same pools. Where pool means category and possibly
* all the sub categories of that category.
*
* @return null|string represents the pool of questions from which this question draws if it is random, or null if not.
*/
public function random_selector_string() {
if ($this->question->qtype == 'random') {
return $this->question->category .'/'. $this->question->questiontext;
} else {
return null;
}
}
/**
* Cache calculated stats stored in this object in 'question_statistics' table.
*
* @param \qubaid_condition $qubaids
* @param int|null $timemodified the modified time to store. Defaults to the current time.
*/
public function cache($qubaids, $timemodified = null) {
global $DB;
$toinsert = new \stdClass();
$toinsert->hashcode = $qubaids->get_hash_code();
$toinsert->timemodified = $timemodified ?? time();
foreach ($this->fieldsindb as $field) {
$toinsert->{$field} = $this->{$field};
}
$DB->insert_record('question_statistics', $toinsert, false);
if ($this->get_variants()) {
foreach ($this->variantstats as $variantstat) {
$variantstat->cache($qubaids, $timemodified);
}
}
}
/**
* Load properties of this class from db record.
*
* @param object $record Given a record from 'question_statistics' copy stats from record to properties.
*/
public function populate_from_record($record) {
foreach ($this->fieldsindb as $field) {
$this->$field = $record->$field;
}
$this->timemodified = $record->timemodified;
}
/**
* Sort the variants of this question by variant number.
*/
public function sort_variants() {
ksort($this->variantstats);
}
/**
* Get any sub question ids for this question.
*
* @return int[] array of sub-question ids or empty array if there are none.
*/
public function get_sub_question_ids() {
if ($this->subquestions !== '') {
return explode(',', $this->subquestions);
} else {
return array();
}
}
/**
* Array of variants that have appeared in the attempt data for this question. Or an empty array if there is only one variant.
*
* @return int[] the variant nos.
*/
public function get_variants() {
$variants = array_keys($this->variantstats);
if (count($variants) > 1 || reset($variants) != 1) {
return $variants;
} else {
return array();
}
}
/**
* Do we break down the stats for this question by variant or not?
*
* @return bool Do we?
*/
public function break_down_by_variant() {
$qtype = \question_bank::get_qtype($this->question->qtype);
return $qtype->break_down_stats_and_response_analysis_by_variant($this->question);
}
/**
* Delete the data structure for storing variant stats.
*/
public function clear_variants() {
$this->variantstats = array();
}
}
@@ -0,0 +1,70 @@
<?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/>.
/**
* Class for storing calculated sub question statistics and intermediate calculation values.
*
* @package core_question
* @copyright 2013 The Open University
* @author James Pratt me@jamiep.org
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_question\statistics\questions;
defined('MOODLE_INTERNAL') || die();
/**
* A class to store calculated stats for a sub question.
*
* @package core_question
* @copyright 2013 The Open University
* @author James Pratt me@jamiep.org
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class calculated_for_subquestion extends calculated {
public $subquestion = true;
/**
* @var array What slots is this sub question used in?
*/
public $usedin = array();
/**
* @var bool Have the slots this sub question has been used in got different grades?
*/
public $differentweights = false;
public $negcovar = 0;
/**
* @var int only set immediately before display in the table. The order of display in the table.
*/
public $subqdisplayorder;
/**
* Constructor.
*
* @param object|null $step the step data for the step that this sub-question was first encountered in.
* @param int|null $variant the variant no
*/
public function __construct($step = null, $variant = null) {
if ($step !== null) {
$this->questionid = $step->questionid;
$this->maxmark = $step->maxmark;
}
$this->variant = $variant;
}
}
@@ -0,0 +1,187 @@
<?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/>.
/**
* Question statistics calculations class. Used in the quiz statistics report.
*
* @package core_question
* @copyright 2018 Ryan Wyllie <ryan@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_question\statistics\questions;
defined('MOODLE_INTERNAL') || die();
require_once($CFG->dirroot . '/question/engine/lib.php');
/**
* Class calculated_question_summary
*
* This class is used to indicate the statistics for a random question slot should
* be rendered with a link to a summary of the displayed questions.
*
* It's used in the limited view of the statistics calculation in lieu of adding
* the stats for each subquestion individually.
*
* @copyright 2018 Ryan Wyllie <ryan@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class calculated_question_summary extends calculated {
/**
* @var int only set immediately before display in the table. The order of display in the table.
*/
public $subqdisplayorder;
/**
* @var calculated[] The instances storing the calculated stats of the questions that are being summarised.
*/
protected $subqstats;
/**
* calculated_question_summary constructor.
*
* @param \stdClass $question
* @param int $slot
* @param calculated[] $subqstats The instances of the calculated stats of the questions that are being summarised.
*/
public function __construct($question, $slot, $subqstats) {
parent::__construct($question, $slot);
$this->subqstats = $subqstats;
$this->subquestions = implode(',', array_column($subqstats, 'questionid'));
}
/**
* This is a summary stat so never breakdown by variant.
*
* @return bool
*/
public function break_down_by_variant() {
return false;
}
/**
* Returns the minimum and maximum values of the given attribute in the summarised calculated stats.
*
* @param string $attribute The attribute that we are looking for its extremums.
* @return array An array of [min,max]
*/
public function get_min_max_of($attribute) {
$getmethod = 'get_min_max_of_' . $attribute;
if (method_exists($this, $getmethod)) {
return $this->$getmethod();
} else {
$min = $max = null;
$set = false;
// We cannot simply use min or max functions because, in theory, some attributes might be non-scalar.
foreach (array_column($this->subqstats, $attribute) as $value) {
if (is_scalar($value) || is_null($value)) {
if (!$set) { // It is not good enough to check if (!isset($min)),
// because $min might have been set to null in an earlier iteration.
$min = $value;
$max = $value;
$set = true;
}
$min = $this->min($min, $value);
$max = $this->max($max, $value);
}
}
return [$min, $max];
}
}
/**
* Returns the minimum and maximum values of the standard deviation in the summarised calculated stats.
* @return array An array of [min,max]
*/
protected function get_min_max_of_sd() {
$min = $max = null;
$set = false;
foreach ($this->subqstats as $subqstat) {
if (isset($subqstat->sd) && $subqstat->maxmark > \question_utils::MARK_TOLERANCE) {
$value = $subqstat->sd / $subqstat->maxmark;
} else {
$value = null;
}
if (!$set) { // It is not good enough to check if (!isset($min)),
// because $min might have been set to null in an earlier iteration.
$min = $value;
$max = $value;
$set = true;
}
$min = $this->min($min, $value);
$max = $this->max($max, $value);
}
return [$min, $max];
}
/**
* Find higher value.
* A zero value is almost considered equal to zero in comparisons. The only difference is that when being compared to zero,
* zero is higher than null.
*
* @param float|null $value1
* @param float|null $value2
* @return float|null
*/
protected function max(float $value1 = null, float $value2 = null) {
$temp1 = $value1 ?: 0;
$temp2 = $value2 ?: 0;
$tempmax = max($temp1, $temp2);
if (!$tempmax && $value1 !== 0 && $value2 !== 0) {
$max = null;
} else {
$max = $tempmax;
}
return $max;
}
/**
* Find lower value.
* A zero value is almost considered equal to zero in comparisons. The only difference is that when being compared to zero,
* zero is lower than null.
*
* @param float|null $value1
* @param float|null $value2
* @return mixed|null
*/
protected function min(float $value1 = null, float $value2 = null) {
$temp1 = $value1 ?: 0;
$temp2 = $value2 ?: 0;
$tempmin = min($temp1, $temp2);
if (!$tempmin && $value1 !== 0 && $value2 !== 0) {
$min = null;
} else {
$min = $tempmin;
}
return $min;
}
}
@@ -0,0 +1,494 @@
<?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/>.
/**
* Question statistics calculator class. Used in the quiz statistics report but also available for use elsewhere.
*
* @package core
* @subpackage questionbank
* @copyright 2013 Open University
* @author Jamie Pratt <me@jamiep.org>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_question\statistics\questions;
defined('MOODLE_INTERNAL') || die();
/**
* This class has methods to compute the question statistics from the raw data.
*
* @copyright 2013 Open University
* @author Jamie Pratt <me@jamiep.org>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class calculator {
/**
* @var all_calculated_for_qubaid_condition all the stats calculated for slots and sub-questions and variants of those
* questions.
*/
protected $stats;
/**
* @var float
*/
protected $sumofmarkvariance = 0;
/**
* @var array[] keyed by a string representing the pool of questions that this random question draws from.
* string as returned from {@link \core_question\statistics\questions\calculated::random_selector_string}
*/
protected $randomselectors = array();
/**
* @var \progress_trace
*/
protected $progress;
/**
* @var string The class name of the class to instantiate to store statistics calculated.
*/
protected $statscollectionclassname = '\core_question\statistics\questions\all_calculated_for_qubaid_condition';
/**
* Constructor.
*
* @param object[] questions to analyze, keyed by slot, also analyses sub questions for random questions.
* we expect some extra fields - slot, maxmark and number on the full question data objects.
* @param \core\progress\base|null $progress the element to send progress messages to, default is {@link \core\progress\none}.
*/
public function __construct($questions, $progress = null) {
if ($progress === null) {
$progress = new \core\progress\none();
}
$this->progress = $progress;
$this->stats = new $this->statscollectionclassname();
foreach ($questions as $slot => $question) {
$this->stats->initialise_for_slot($slot, $question);
$this->stats->for_slot($slot)->randomguessscore = $this->get_random_guess_score($question);
}
}
/**
* Calculate the stats.
*
* @param \qubaid_condition $qubaids Which question usages to calculate the stats for?
* @return all_calculated_for_qubaid_condition The calculated stats.
*/
public function calculate($qubaids) {
$this->progress->start_progress('', 6);
list($lateststeps, $summarks) = $this->get_latest_steps($qubaids);
if ($lateststeps) {
$this->progress->start_progress('', count($lateststeps), 1);
// Compute the statistics of position, and for random questions, work
// out which questions appear in which positions.
foreach ($lateststeps as $step) {
$this->progress->increment_progress();
$israndomquestion = ($step->questionid != $this->stats->for_slot($step->slot)->questionid);
$breakdownvariants = !$israndomquestion && $this->stats->for_slot($step->slot)->break_down_by_variant();
// If this is a variant we have not seen before create a place to store stats calculations for this variant.
if ($breakdownvariants && !$this->stats->has_slot($step->slot, $step->variant)) {
$question = $this->stats->for_slot($step->slot)->question;
$this->stats->initialise_for_slot($step->slot, $question, $step->variant);
$this->stats->for_slot($step->slot, $step->variant)->randomguessscore =
$this->get_random_guess_score($question);
}
// Step data walker for main question.
$this->initial_steps_walker($step, $this->stats->for_slot($step->slot), $summarks, true, $breakdownvariants);
// If this is a random question do the calculations for sub question stats.
if ($israndomquestion) {
if (!$this->stats->has_subq($step->questionid)) {
$this->stats->initialise_for_subq($step);
} else if ($this->stats->for_subq($step->questionid)->maxmark != $step->maxmark) {
$this->stats->for_subq($step->questionid)->differentweights = true;
}
// If this is a variant of this subq we have not seen before create a place to store stats calculations for it.
if (!$this->stats->has_subq($step->questionid, $step->variant)) {
$this->stats->initialise_for_subq($step, $step->variant);
}
$this->initial_steps_walker($step, $this->stats->for_subq($step->questionid), $summarks, false);
// Extra stuff we need to do in this loop for subqs to keep track of where they need to be displayed later.
$number = $this->stats->for_slot($step->slot)->question->number;
$this->stats->for_subq($step->questionid)->usedin[$number] = $number;
// Keep track of which random questions are actually selected from each pool of questions that random
// questions are pulled from.
$randomselectorstring = $this->stats->for_slot($step->slot)->random_selector_string();
if (!isset($this->randomselectors[$randomselectorstring])) {
$this->randomselectors[$randomselectorstring] = array();
}
$this->randomselectors[$randomselectorstring][$step->questionid] = $step->questionid;
}
}
$this->progress->end_progress();
foreach ($this->randomselectors as $key => $notused) {
ksort($this->randomselectors[$key]);
$this->randomselectors[$key] = implode(',', $this->randomselectors[$key]);
}
$this->stats->subquestions = question_load_questions($this->stats->get_all_subq_ids());
// Compute the statistics for sub questions, if there are any.
$this->progress->start_progress('', count($this->stats->subquestions), 1);
foreach ($this->stats->subquestions as $qid => $subquestion) {
$this->progress->increment_progress();
$subquestion->maxmark = $this->stats->for_subq($qid)->maxmark;
$this->stats->for_subq($qid)->question = $subquestion;
$this->stats->for_subq($qid)->randomguessscore = $this->get_random_guess_score($subquestion);
if ($variants = $this->stats->for_subq($qid)->get_variants()) {
foreach ($variants as $variant) {
$this->stats->for_subq($qid, $variant)->question = $subquestion;
$this->stats->for_subq($qid, $variant)->randomguessscore = $this->get_random_guess_score($subquestion);
}
$this->stats->for_subq($qid)->sort_variants();
}
$this->initial_question_walker($this->stats->for_subq($qid));
if ($this->stats->for_subq($qid)->usedin) {
sort($this->stats->for_subq($qid)->usedin, SORT_NUMERIC);
$this->stats->for_subq($qid)->positions = implode(',', $this->stats->for_subq($qid)->usedin);
} else {
$this->stats->for_subq($qid)->positions = '';
}
}
$this->progress->end_progress();
// Finish computing the averages, and put the sub-question data into the
// corresponding questions.
$slots = $this->stats->get_all_slots();
$totalnumberofslots = count($slots);
$maxindex = $totalnumberofslots - 1;
$this->progress->start_progress('', $totalnumberofslots, 1);
foreach ($slots as $index => $slot) {
$this->stats->for_slot($slot)->sort_variants();
$this->progress->increment_progress();
$nextslotindex = $index + 1;
$nextslot = ($nextslotindex > $maxindex) ? false : $slots[$nextslotindex];
$this->initial_question_walker($this->stats->for_slot($slot));
// The rest of this loop is to finish working out where randomly selected question stats should be displayed.
if ($this->stats->for_slot($slot)->question->qtype == 'random') {
$randomselectorstring = $this->stats->for_slot($slot)->random_selector_string();
if ($nextslot && ($randomselectorstring == $this->stats->for_slot($nextslot)->random_selector_string())) {
continue; // Next loop iteration.
}
if (isset($this->randomselectors[$randomselectorstring])) {
$this->stats->for_slot($slot)->subquestions = $this->randomselectors[$randomselectorstring];
}
}
}
$this->progress->end_progress();
// Go through the records one more time.
$this->progress->start_progress('', count($lateststeps), 1);
foreach ($lateststeps as $step) {
$this->progress->increment_progress();
$israndomquestion = ($this->stats->for_slot($step->slot)->question->qtype == 'random');
$this->secondary_steps_walker($step, $this->stats->for_slot($step->slot), $summarks);
if ($israndomquestion) {
$this->secondary_steps_walker($step, $this->stats->for_subq($step->questionid), $summarks);
}
}
$this->progress->end_progress();
$slots = $this->stats->get_all_slots();
$this->progress->start_progress('', count($slots), 1);
$sumofcovariancewithoverallmark = 0;
foreach ($this->stats->get_all_slots() as $slot) {
$this->progress->increment_progress();
$this->secondary_question_walker($this->stats->for_slot($slot));
$this->sumofmarkvariance += $this->stats->for_slot($slot)->markvariance;
$covariancewithoverallmark = $this->stats->for_slot($slot)->covariancewithoverallmark;
if (null !== $covariancewithoverallmark && $covariancewithoverallmark >= 0) {
$sumofcovariancewithoverallmark += sqrt($covariancewithoverallmark);
}
}
$this->progress->end_progress();
$subqids = $this->stats->get_all_subq_ids();
$this->progress->start_progress('', count($subqids), 1);
foreach ($subqids as $subqid) {
$this->progress->increment_progress();
$this->secondary_question_walker($this->stats->for_subq($subqid));
}
$this->progress->end_progress();
foreach ($this->stats->get_all_slots() as $slot) {
if ($sumofcovariancewithoverallmark) {
if ($this->stats->for_slot($slot)->negcovar) {
$this->stats->for_slot($slot)->effectiveweight = null;
} else {
$this->stats->for_slot($slot)->effectiveweight =
100 * sqrt($this->stats->for_slot($slot)->covariancewithoverallmark) /
$sumofcovariancewithoverallmark;
}
} else {
$this->stats->for_slot($slot)->effectiveweight = null;
}
}
$this->stats->cache($qubaids);
}
// All finished.
$this->progress->end_progress();
return $this->stats;
}
/**
* Used when computing Coefficient of Internal Consistency by quiz statistics.
*
* @return float
*/
public function get_sum_of_mark_variance() {
return $this->sumofmarkvariance;
}
/**
* Get the latest step data from the db, from which we will calculate stats.
*
* @param \qubaid_condition $qubaids Which question usages to get the latest steps for?
* @return array with two items
* - $lateststeps array of latest step data for the question usages
* - $summarks array of total marks for each usage, indexed by usage id
*/
protected function get_latest_steps($qubaids) {
$dm = new \question_engine_data_mapper();
$fields = " qas.id,
qa.questionusageid,
qa.questionid,
qa.variant,
qa.slot,
qa.maxmark,
qas.fraction * qa.maxmark as mark";
$lateststeps = $dm->load_questions_usages_latest_steps($qubaids, $this->stats->get_all_slots(), $fields);
$summarks = array();
if ($lateststeps) {
foreach ($lateststeps as $step) {
if (!isset($summarks[$step->questionusageid])) {
$summarks[$step->questionusageid] = 0;
}
$summarks[$step->questionusageid] += $step->mark;
}
}
return array($lateststeps, $summarks);
}
/**
* Calculating the stats is a four step process.
*
* We loop through all 'last step' data first.
*
* Update $stats->totalmarks, $stats->markarray, $stats->totalothermarks
* and $stats->othermarksarray to include another state.
*
* @param object $step the state to add to the statistics.
* @param calculated $stats the question statistics we are accumulating.
* @param array $summarks of the sum of marks for each question usage, indexed by question usage id
* @param bool $positionstat whether this is a statistic of position of question.
* @param bool $dovariantalso do we also want to do the same calculations for this variant?
*/
protected function initial_steps_walker($step, $stats, $summarks, $positionstat = true, $dovariantalso = true) {
$stats->s++;
$stats->totalmarks += $step->mark;
$stats->markarray[] = $step->mark;
if ($positionstat) {
$stats->totalothermarks += $summarks[$step->questionusageid] - $step->mark;
$stats->othermarksarray[] = $summarks[$step->questionusageid] - $step->mark;
} else {
$stats->totalothermarks += $summarks[$step->questionusageid];
$stats->othermarksarray[] = $summarks[$step->questionusageid];
}
if ($dovariantalso) {
$this->initial_steps_walker($step, $stats->variantstats[$step->variant], $summarks, $positionstat, false);
}
}
/**
* Then loop through all questions for the first time.
*
* Perform some computations on the per-question statistics calculations after
* we have been through all the step data.
*
* @param calculated $stats question stats to update.
*/
protected function initial_question_walker($stats) {
if ($stats->s != 0) {
$stats->markaverage = $stats->totalmarks / $stats->s;
$stats->othermarkaverage = $stats->totalothermarks / $stats->s;
$stats->summarksaverage = $stats->totalsummarks / $stats->s;
} else {
$stats->markaverage = 0;
$stats->othermarkaverage = 0;
$stats->summarksaverage = 0;
}
if ($stats->maxmark != 0) {
$stats->facility = $stats->markaverage / $stats->maxmark;
} else {
$stats->facility = null;
}
sort($stats->markarray, SORT_NUMERIC);
sort($stats->othermarksarray, SORT_NUMERIC);
// Here we have collected enough data to make the decision about which questions have variants whose stats we also want to
// calculate. We delete the initialised structures where they are not needed.
if (!$stats->get_variants() || !$stats->break_down_by_variant()) {
$stats->clear_variants();
}
foreach ($stats->get_variants() as $variant) {
$this->initial_question_walker($stats->variantstats[$variant]);
}
}
/**
* Loop through all last step data again.
*
* Now we know the averages, accumulate the date needed to compute the higher
* moments of the question scores.
*
* @param object $step the state to add to the statistics.
* @param calculated $stats the question statistics we are accumulating.
* @param float[] $summarks of the sum of marks for each question usage, indexed by question usage id
*/
protected function secondary_steps_walker($step, $stats, $summarks) {
$markdifference = $step->mark - $stats->markaverage;
if ($stats->subquestion) {
$othermarkdifference = $summarks[$step->questionusageid] - $stats->othermarkaverage;
} else {
$othermarkdifference = $summarks[$step->questionusageid] - $step->mark - $stats->othermarkaverage;
}
$overallmarkdifference = $summarks[$step->questionusageid] - $stats->summarksaverage;
$sortedmarkdifference = array_shift($stats->markarray) - $stats->markaverage;
$sortedothermarkdifference = array_shift($stats->othermarksarray) - $stats->othermarkaverage;
$stats->markvariancesum += pow($markdifference, 2);
$stats->othermarkvariancesum += pow($othermarkdifference, 2);
$stats->covariancesum += $markdifference * $othermarkdifference;
$stats->covariancemaxsum += $sortedmarkdifference * $sortedothermarkdifference;
$stats->covariancewithoverallmarksum += $markdifference * $overallmarkdifference;
if (isset($stats->variantstats[$step->variant])) {
$this->secondary_steps_walker($step, $stats->variantstats[$step->variant], $summarks);
}
}
/**
* And finally loop through all the questions again.
*
* Perform more per-question statistics calculations.
*
* @param calculated $stats question stats to update.
*/
protected function secondary_question_walker($stats) {
if ($stats->s > 1) {
$stats->markvariance = $stats->markvariancesum / ($stats->s - 1);
$stats->othermarkvariance = $stats->othermarkvariancesum / ($stats->s - 1);
$stats->covariance = $stats->covariancesum / ($stats->s - 1);
$stats->covariancemax = $stats->covariancemaxsum / ($stats->s - 1);
$stats->covariancewithoverallmark = $stats->covariancewithoverallmarksum /
($stats->s - 1);
$stats->sd = sqrt($stats->markvariancesum / ($stats->s - 1));
if ($stats->covariancewithoverallmark >= 0) {
$stats->negcovar = 0;
} else {
$stats->negcovar = 1;
}
} else {
$stats->markvariance = null;
$stats->othermarkvariance = null;
$stats->covariance = null;
$stats->covariancemax = null;
$stats->covariancewithoverallmark = null;
$stats->sd = null;
$stats->negcovar = 0;
}
if ($stats->markvariance * $stats->othermarkvariance) {
$stats->discriminationindex = 100 * $stats->covariance /
sqrt($stats->markvariance * $stats->othermarkvariance);
} else {
$stats->discriminationindex = null;
}
if ($stats->covariancemax) {
$stats->discriminativeefficiency = 100 * $stats->covariance /
$stats->covariancemax;
} else {
$stats->discriminativeefficiency = null;
}
foreach ($stats->variantstats as $variantstat) {
$this->secondary_question_walker($variantstat);
}
}
/**
* Given the question data find the average grade that random guesses would get.
*
* @param object $questiondata the full question object.
* @return float the random guess score for this question.
*/
protected function get_random_guess_score($questiondata) {
return \question_bank::get_qtype(
$questiondata->qtype, false)->get_random_guess_score($questiondata);
}
/**
* Find time of non-expired statistics in the database.
*
* @param \qubaid_condition $qubaids Which question usages to look for?
* @return int|bool Time of cached record that matches this qubaid_condition or false is non found.
*/
public function get_last_calculated_time($qubaids) {
return $this->stats->get_last_calculated_time($qubaids);
}
/**
* Load cached statistics from the database.
*
* @param \qubaid_condition $qubaids Which question usages to load the cached stats for?
* @return all_calculated_for_qubaid_condition The cached stats.
*/
public function get_cached($qubaids) {
$this->stats->get_cached($qubaids);
return $this->stats;
}
}
@@ -0,0 +1,200 @@
<?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/>.
/**
* This file contains the code to analyse all the responses to a particular question.
*
* @package core_question
* @copyright 2014 Open University
* @author Jamie Pratt <me@jamiep.org>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_question\statistics\responses;
defined('MOODLE_INTERNAL') || die();
/**
* This class can compute, store and cache the analysis of the responses to a particular question.
*
* @package core_question
* @copyright 2014 The Open University
* @author James Pratt me@jamiep.org
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class analyser {
/**
* @var int When analysing responses and breaking down the count of responses per try, how many columns should we break down
* tries into? This is set to 5 columns, any response in a try more than try 5 will be counted in the fifth column.
*/
const MAX_TRY_COUNTED = 5;
/**
* @var int previously, the time after which statistics are automatically recomputed.
* @deprecated since Moodle 4.3. Use of pre-computed stats is no longer time-limited.
* @todo MDL-78090 Final deprecation in Moodle 4.7
*/
const TIME_TO_CACHE = 900; // 15 minutes.
/** @var object full question data from db. */
protected $questiondata;
/**
* @var analysis_for_question|analysis_for_question_all_tries
*/
public $analysis;
/**
* @var int used during calculations, so all results are stored with the same timestamp.
*/
protected $calculationtime;
/**
* @var array Two index array first index is unique string for each sub question part, the second string index is the 'class'
* that sub-question part can be classified into.
*
* This is the return value from {@link \question_type::get_possible_responses()} see that method for fuller documentation.
*/
public $responseclasses = array();
/**
* @var bool whether to break down response analysis by variant. This only applies to questions that have variants and is
* used to suppress the break down of analysis by variant when there are going to be very many variants.
*/
protected $breakdownbyvariant;
/**
* Create a new instance of this class for holding/computing the statistics
* for a particular question.
*
* @param object $questiondata the full question data from the database defining this question.
* @param string $whichtries which tries to analyse.
*/
public function __construct($questiondata, $whichtries = \question_attempt::LAST_TRY) {
$this->questiondata = $questiondata;
$qtypeobj = \question_bank::get_qtype($this->questiondata->qtype);
if ($whichtries != \question_attempt::ALL_TRIES) {
$this->analysis = new analysis_for_question($qtypeobj->get_possible_responses($this->questiondata));
} else {
$this->analysis = new analysis_for_question_all_tries($qtypeobj->get_possible_responses($this->questiondata));
}
$this->breakdownbyvariant = $qtypeobj->break_down_stats_and_response_analysis_by_variant($this->questiondata);
}
/**
* Does the computed analysis have sub parts?
*
* @return bool whether this analysis has more than one subpart.
*/
public function has_subparts() {
return count($this->responseclasses) > 1;
}
/**
* Does the computed analysis's sub parts have classes?
*
* @return bool whether this analysis has (a subpart with) more than one response class.
*/
public function has_response_classes() {
foreach ($this->responseclasses as $partclasses) {
if (count($partclasses) > 1) {
return true;
}
}
return false;
}
/**
* Analyse all the response data for all the specified attempts at this question.
*
* @param \qubaid_condition $qubaids which attempts to consider.
* @param string $whichtries which tries to analyse. Will be one of
* \question_attempt::FIRST_TRY, LAST_TRY or ALL_TRIES.
* @return analysis_for_question
*/
public function calculate($qubaids, $whichtries = \question_attempt::LAST_TRY) {
$this->calculationtime = time();
// Load data.
$dm = new \question_engine_data_mapper();
$questionattempts = $dm->load_attempts_at_question($this->questiondata->id, $qubaids);
// Analyse it.
foreach ($questionattempts as $qa) {
$responseparts = $qa->classify_response($whichtries);
if ($this->breakdownbyvariant) {
$this->analysis->count_response_parts($qa->get_variant(), $responseparts);
} else {
$this->analysis->count_response_parts(1, $responseparts);
}
}
$this->analysis->cache($qubaids, $whichtries, $this->questiondata->id, $this->calculationtime);
return $this->analysis;
}
/**
* Retrieve the computed response analysis from the question_response_analysis table.
*
* @param \qubaid_condition $qubaids load the analysis of which question usages?
* @param string $whichtries load the analysis of which tries?
* @return analysis_for_question|boolean analysis or false if no cached analysis found.
*/
public function load_cached($qubaids, $whichtries) {
global $DB;
$timemodified = self::get_last_analysed_time($qubaids, $whichtries);
// Variable name 'analyses' is the plural of 'analysis'.
$responseanalyses = $DB->get_records('question_response_analysis',
['hashcode' => $qubaids->get_hash_code(), 'whichtries' => $whichtries,
'questionid' => $this->questiondata->id, 'timemodified' => $timemodified]);
if (!$responseanalyses) {
return false;
}
$analysisids = [];
foreach ($responseanalyses as $responseanalysis) {
$analysisforsubpart = $this->analysis->get_analysis_for_subpart($responseanalysis->variant, $responseanalysis->subqid);
$class = $analysisforsubpart->get_response_class($responseanalysis->aid);
$class->add_response($responseanalysis->response, $responseanalysis->credit);
$analysisids[] = $responseanalysis->id;
}
[$sql, $params] = $DB->get_in_or_equal($analysisids);
$counts = $DB->get_records_select('question_response_count', "analysisid {$sql}", $params);
foreach ($counts as $count) {
$responseanalysis = $responseanalyses[$count->analysisid];
$analysisforsubpart = $this->analysis->get_analysis_for_subpart($responseanalysis->variant, $responseanalysis->subqid);
$class = $analysisforsubpart->get_response_class($responseanalysis->aid);
$class->set_response_count($responseanalysis->response, $count->try, $count->rcount);
}
return $this->analysis;
}
/**
* Find time of non-expired analysis in the database.
*
* @param \qubaid_condition $qubaids check for the analysis of which question usages?
* @param string $whichtries check for the analysis of which tries?
* @return integer|boolean Time of cached record that matches this qubaid_condition or false if none found.
*/
public function get_last_analysed_time($qubaids, $whichtries) {
global $DB;
return $DB->get_field('question_response_analysis', 'MAX(timemodified)',
['hashcode' => $qubaids->get_hash_code(), 'whichtries' => $whichtries,
'questionid' => $this->questiondata->id]);
}
}
@@ -0,0 +1,176 @@
<?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/>.
/**
* @package core_question
* @copyright 2013 The Open University
* @author James Pratt me@jamiep.org
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_question\statistics\responses;
/**
* The leafs of the analysis data structure.
*
* - There is a separate data structure for each question or sub question's analysis
* {@link \core_question\statistics\responses\analysis_for_question}
* or {@link \core_question\statistics\responses\analysis_for_question_all_tries}.
* - There are separate analysis for each variant in this top level instance.
* - Then there are class instances representing the analysis of each of the sub parts of each variant of the question.
* {@link \core_question\statistics\responses\analysis_for_subpart}.
* - Then within the sub part analysis there are response class analysis
* {@link \core_question\statistics\responses\analysis_for_class}.
* - Then within each class analysis there are analysis for each actual response
* {@link \core_question\statistics\responses\analysis_for_actual_response}.
*
* @package core_question
* @copyright 2014 The Open University
* @author James Pratt me@jamiep.org
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class analysis_for_actual_response {
/**
* @var int[] count per try for this response.
*/
protected $trycount = array();
/**
* @var int total count of tries with this response.
*/
protected $totalcount = 0;
/**
* @var float grade for this response, normally between 0 and 1.
*/
protected $fraction;
/**
* @var string the response as it will be displayed in report.
*/
protected $response;
/**
* @param string $response
* @param float $fraction
*/
public function __construct($response, $fraction) {
$this->response = $response;
$this->fraction = $fraction;
}
/**
* Used to count the occurrences of response sub parts.
*
* @param int $try the try number, or 0 if only keeping one count, not a count for each try.
*/
public function increment_count($try = 0) {
$this->totalcount++;
if ($try != 0) {
if ($try > analyser::MAX_TRY_COUNTED) {
$try = analyser::MAX_TRY_COUNTED;
}
if (!isset($this->trycount[$try])) {
$this->trycount[$try] = 0;
}
$this->trycount[$try]++;
}
}
/**
* Used to set the count of occurrences of response sub parts, when loading count from cache.
*
* @param int $try the try number, or 0 if only keeping one count, not a count for each try.
* @param int $count
*/
public function set_count($try, $count) {
$this->totalcount = $this->totalcount + $count;
$this->trycount[$try] = $count;
}
/**
* Cache analysis for class.
*
* @param \qubaid_condition $qubaids which question usages have been analysed.
* @param string $whichtries which tries have been analysed?
* @param int $questionid which question.
* @param int $variantno which variant.
* @param string $subpartid which sub part is this actual response in?
* @param string $responseclassid which response class is this actual response in?
* @param int|null $calculationtime time when the analysis was done. (Defaults to time()).
*/
public function cache($qubaids, $whichtries, $questionid, $variantno, $subpartid, $responseclassid, $calculationtime = null) {
global $DB;
$row = new \stdClass();
$row->hashcode = $qubaids->get_hash_code();
$row->whichtries = $whichtries;
$row->questionid = $questionid;
$row->variant = $variantno;
$row->subqid = $subpartid;
if ($responseclassid === '') {
$row->aid = null;
} else {
$row->aid = $responseclassid;
}
$row->response = $this->response;
$row->credit = $this->fraction;
$row->timemodified = $calculationtime ?? time();
$analysisid = $DB->insert_record('question_response_analysis', $row);
if ($whichtries === \question_attempt::ALL_TRIES) {
foreach ($this->trycount as $try => $count) {
$countrow = new \stdClass();
$countrow->try = $try;
$countrow->rcount = $count;
$countrow->analysisid = $analysisid;
$DB->insert_record('question_response_count', $countrow, false);
}
} else {
$countrow = new \stdClass();
$countrow->try = 0;
$countrow->rcount = $this->totalcount;
$countrow->analysisid = $analysisid;
$DB->insert_record('question_response_count', $countrow, false);
}
}
/**
* Returns an object with a property for each column of the question response analysis table.
*
* @param string $partid
* @param string $modelresponse
* @return object
*/
public function data_for_question_response_table($partid, $modelresponse) {
$rowdata = new \stdClass();
$rowdata->part = $partid;
$rowdata->responseclass = $modelresponse;
$rowdata->response = $this->response;
$rowdata->fraction = $this->fraction;
$rowdata->totalcount = $this->totalcount;
$rowdata->trycount = $this->trycount;
return $rowdata;
}
/**
* What is the highest try number that this response has been seen?
*
* @return int try number
*/
public function get_maximum_tries() {
return max(array_keys($this->trycount));
}
}
@@ -0,0 +1,217 @@
<?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/>.
/**
* @package core_question
* @copyright 2013 The Open University
* @author James Pratt me@jamiep.org
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_question\statistics\responses;
/**
* Counts a class of responses for this sub part of the question.
*
* No response is one possible class of response to a question.
*
* - There is a separate data structure for each question or sub question's analysis
* {@link \core_question\statistics\responses\analysis_for_question}
* or {@link \core_question\statistics\responses\analysis_for_question_all_tries}.
* - There are separate analysis for each variant in this top level instance.
* - Then there are class instances representing the analysis of each of the sub parts of each variant of the question.
* {@link \core_question\statistics\responses\analysis_for_subpart}.
* - Then within the sub part analysis there are response class analysis
* {@link \core_question\statistics\responses\analysis_for_class}.
* - Then within each class analysis there are analysis for each actual response
* {@link \core_question\statistics\responses\analysis_for_actual_response}.
*
* @package core_question
* @copyright 2014 The Open University
* @author James Pratt me@jamiep.org
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class analysis_for_class {
/**
* @var string must be unique for each response class within this sub part.
*/
protected $responseclassid;
/**
* @var string represent this class in the response analysis table.
*/
protected $modelresponse;
/** @var string the (partial) credit awarded for this responses. */
protected $fraction;
/** @var analysis_for_actual_response[] key is the actual response represented as a string as it will be displayed in report.
*/
protected $actualresponses = array();
/**
* Constructor, just an easy way to set the fields.
*
* @param \question_possible_response $possibleresponse
* @param string $responseclassid
*/
public function __construct($possibleresponse, $responseclassid) {
$this->modelresponse = $possibleresponse->responseclass;
$this->fraction = $possibleresponse->fraction;
$this->responseclassid = $responseclassid;
}
/**
* Keep a count of a response to this question sub part that falls within this class.
*
* @param string $actualresponse
* @param float|null $fraction
* @param int $try
* @return \core_question\statistics\responses\analysis_for_actual_response
*/
public function count_response($actualresponse, $fraction, $try) {
if (!isset($this->actualresponses[$actualresponse])) {
if ($fraction === null) {
$fraction = $this->fraction;
}
$this->add_response($actualresponse, $fraction);
}
$this->get_response($actualresponse)->increment_count($try);
}
/**
* Cache analysis for class.
*
* @param \qubaid_condition $qubaids which question usages have been analysed.
* @param string $whichtries which tries have been analysed?
* @param int $questionid which question.
* @param int $variantno which variant.
* @param string $subpartid which sub part.
* @param int|null $calculationtime time when the analysis was done. (Defaults to time()).
*/
public function cache($qubaids, $whichtries, $questionid, $variantno, $subpartid, $calculationtime = null) {
foreach ($this->get_responses() as $response) {
$analysisforactualresponse = $this->get_response($response);
$analysisforactualresponse->cache($qubaids, $whichtries, $questionid, $variantno, $subpartid,
$this->responseclassid, $calculationtime);
}
}
/**
* Add an actual response to the data structure.
*
* @param string $response A string representing the actual response.
* @param float $fraction The fraction of grade awarded for this response.
*/
public function add_response($response, $fraction) {
$this->actualresponses[$response] = new analysis_for_actual_response($response, $fraction);
}
/**
* Used when loading cached counts.
*
* @param string $response
* @param int $try the try number, will be zero if not keeping track of try.
* @param int $count the count
*/
public function set_response_count($response, $try, $count) {
$this->actualresponses[$response]->set_count($try, $count);
}
/**
* Are there actual responses to sub parts that where classified into this class?
*
* @return bool whether this analysis has a response class with more than one
* different actual response, or if the actual response is different from
* the model response.
*/
public function has_actual_responses() {
$actualresponses = $this->get_responses();
if (count($actualresponses) > 1) {
return true;
} else if (count($actualresponses) === 1) {
$singleactualresponse = reset($actualresponses);
return (string)$singleactualresponse !== (string)$this->modelresponse;
}
return false;
}
/**
* Return the data to display in the response analysis table.
*
* @param bool $responseclasscolumn
* @param string $partid
* @return object[]
*/
public function data_for_question_response_table($responseclasscolumn, $partid) {
$return = array();
if (count($this->get_responses()) == 0) {
$rowdata = new \stdClass();
$rowdata->part = $partid;
$rowdata->responseclass = $this->modelresponse;
if (!$responseclasscolumn) {
$rowdata->response = $this->modelresponse;
} else {
$rowdata->response = '';
}
$rowdata->fraction = $this->fraction;
$rowdata->totalcount = 0;
$rowdata->trycount = array();
$return[] = $rowdata;
} else {
foreach ($this->get_responses() as $actualresponse) {
$response = $this->get_response($actualresponse);
$return[] = $response->data_for_question_response_table($partid, $this->modelresponse);
}
}
return $return;
}
/**
* What is the highest try number that an actual response of this response class has been seen?
*
* @return int try number
*/
public function get_maximum_tries() {
$max = 1;
foreach ($this->get_responses() as $actualresponse) {
$max = max($max, $this->get_response($actualresponse)->get_maximum_tries());
}
return $max;
}
/**
* Return array of the actual responses to this sub part that were classified into this class.
*
* @return string[] the actual responses we are counting tries at.
*/
protected function get_responses() {
return array_keys($this->actualresponses);
}
/**
* Get the data structure used to count the responses that match an actual response within this class of responses.
*
* @param string $response
* @return analysis_for_actual_response the instance for keeping count of tries for $response.
*/
protected function get_response($response) {
return $this->actualresponses[$response];
}
}
@@ -0,0 +1,252 @@
<?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/>.
/**
* This file contains the code to analyse all the responses to a particular
* question.
*
* @package core_question
* @copyright 2013 Open University
* @author Jamie Pratt <me@jamiep.org>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_question\statistics\responses;
defined('MOODLE_INTERNAL') || die();
/**
* Analysis for possible responses for parts of a question. It is up to a question type designer to decide on how many parts their
* question has. See {@link \question_type::get_possible_responses()} and sub classes where the sub parts and response classes are
* defined.
*
* A sub part might represent a sub question embedded in the question for example in a matching question there are
* several sub parts. A numeric question with a unit might be divided into two sub parts for the purposes of response analysis
* or the question type designer might decide to treat the answer, both the numeric and unit part,
* as a whole for the purposes of response analysis.
*
* - There is a separate data structure for each question or sub question's analysis
* {@link \core_question\statistics\responses\analysis_for_question}
* or {@link \core_question\statistics\responses\analysis_for_question_all_tries}.
* - There are separate analysis for each variant in this top level instance.
* - Then there are class instances representing the analysis of each of the sub parts of each variant of the question.
* {@link \core_question\statistics\responses\analysis_for_subpart}.
* - Then within the sub part analysis there are response class analysis
* {@link \core_question\statistics\responses\analysis_for_class}.
* - Then within each class analysis there are analysis for each actual response
* {@link \core_question\statistics\responses\analysis_for_actual_response}.
*
* @package core_question
* @copyright 2014 The Open University
* @author James Pratt me@jamiep.org
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class analysis_for_question {
/**
* Constructor method.
*
* @param array[] Two index array, first index is unique string for each sub question part,
* the second string index is the 'class' that sub-question part can be classified into.
* Value in array is instance of {@link \question_possible_response}
* This is the return value from {@link \question_type::get_possible_responses()}
* see that method for fuller documentation.
*/
public function __construct(array $possiblereponses = null) {
if ($possiblereponses !== null) {
$this->possibleresponses = $possiblereponses;
}
}
/**
* @var array[] See description above in constructor method.
*/
protected $possibleresponses = array();
/**
* A multidimensional array whose first index is variant no and second index is subpart id, array contents are of type
* {@link analysis_for_subpart}.
*
* @var array[]
*/
protected $subparts = array();
/**
* Initialise data structure for response analysis of one variant.
*
* @param int $variantno
*/
protected function initialise_stats_for_variant($variantno) {
$this->subparts[$variantno] = array();
foreach ($this->possibleresponses as $subpartid => $classes) {
$this->subparts[$variantno][$subpartid] = new analysis_for_subpart($classes);
}
}
/**
* Variant nos found in this question's attempt data.
*
* @return int[]
*/
public function get_variant_nos() {
return array_keys($this->subparts);
}
/**
* Unique ids for sub parts.
*
* @param int $variantno
* @return string[]
*/
public function get_subpart_ids($variantno) {
return array_keys($this->subparts[$variantno]);
}
/**
* Get the response counts etc. for variant $variantno, question sub part $subpartid.
*
* Or if there is no recorded analysis yet then initialise the data structure for that part of the analysis and return the
* initialised analysis objects.
*
* @param int $variantno
* @param string $subpartid id for sub part.
* @return analysis_for_subpart
*/
public function get_analysis_for_subpart($variantno, $subpartid) {
if (!isset($this->subparts[$variantno])) {
$this->initialise_stats_for_variant($variantno);
}
if (!isset($this->subparts[$variantno][$subpartid])) {
debugging('Unexpected sub-part id ' . $subpartid .
' encountered.');
$this->subparts[$variantno][$subpartid] = new analysis_for_subpart();
}
return $this->subparts[$variantno][$subpartid];
}
/**
* Used to work out what kind of table is needed to display stats.
*
* @return bool whether this question has (a subpart with) more than one response class.
*/
public function has_multiple_response_classes() {
foreach ($this->get_variant_nos() as $variantno) {
foreach ($this->get_subpart_ids($variantno) as $subpartid) {
if ($this->get_analysis_for_subpart($variantno, $subpartid)->has_multiple_response_classes()) {
return true;
}
}
}
return false;
}
/**
* Used to work out what kind of table is needed to display stats.
*
* @return bool whether this analysis has more than one subpart.
*/
public function has_subparts() {
foreach ($this->get_variant_nos() as $variantno) {
if (count($this->get_subpart_ids($variantno)) > 1) {
return true;
}
}
return false;
}
/**
* @return bool Does this response analysis include counts for responses for multiple tries of the question?
*/
public function has_multiple_tries_data() {
return false;
}
/**
* What is the highest number of tries at this question?
*
* @return int always 1 as this class is for analysing only one try.
*/
public function get_maximum_tries() {
return 1;
}
/**
* Takes an array of {@link \question_classified_response} and adds counts of the responses to the sub parts and classes.
*
* @param int $variantno
* @param \question_classified_response[] $responseparts keys are sub-part id.
*/
public function count_response_parts($variantno, $responseparts) {
foreach ($responseparts as $subpartid => $responsepart) {
$this->get_analysis_for_subpart($variantno, $subpartid)->count_response($responsepart);
}
}
/**
* Save the analysis to the DB, first cleaning up any old ones.
*
* @param \qubaid_condition $qubaids which question usages have been analysed.
* @param string $whichtries which tries have been analysed?
* @param int $questionid which question.
* @param int|null $calculationtime time when the analysis was done. (Defaults to time()).
*/
public function cache($qubaids, $whichtries, $questionid, $calculationtime = null) {
global $DB;
$transaction = $DB->start_delegated_transaction();
$analysisids = $DB->get_fieldset(
'question_response_analysis',
'id',
[
'hashcode' => $qubaids->get_hash_code(),
'whichtries' => $whichtries,
'questionid' => $questionid,
]
);
if (!empty($analysisids)) {
[$insql, $params] = $DB->get_in_or_equal($analysisids);
$DB->delete_records_select('question_response_count', 'analysisid ' . $insql, $params);
$DB->delete_records_select('question_response_analysis', 'id ' . $insql, $params);
}
foreach ($this->get_variant_nos() as $variantno) {
foreach ($this->get_subpart_ids($variantno) as $subpartid) {
$analysisforsubpart = $this->get_analysis_for_subpart($variantno, $subpartid);
$analysisforsubpart->cache($qubaids, $whichtries, $questionid, $variantno, $subpartid, $calculationtime);
}
}
$transaction->allow_commit();
}
/**
* @return bool whether this analysis has a response class with more than one
* different actual response, or if the actual response is different from
* the model response.
*/
public function has_actual_responses() {
foreach ($this->get_variant_nos() as $variantno) {
foreach ($this->get_subpart_ids($variantno) as $subpartid) {
if ($this->get_analysis_for_subpart($variantno, $subpartid)->has_actual_responses()) {
return true;
}
}
}
return false;
}
}
@@ -0,0 +1,84 @@
<?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/>.
/**
* This file contains a class to analyse all the responses for multiple tries at a particular question.
*
* @package core_question
* @copyright 2014 Open University
* @author Jamie Pratt <me@jamiep.org>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_question\statistics\responses;
/**
* Analysis for possible responses for parts of a question with multiple submitted responses.
*
* If the analysis was for a single try it would be handled by {@link \core_question\statistics\responses\analysis_for_question}.
*
* - There is a separate data structure for each question or sub question's analysis
* {@link \core_question\statistics\responses\analysis_for_question}
* or {@link \core_question\statistics\responses\analysis_for_question_all_tries}.
* - There are separate analysis for each variant in this top level instance.
* - Then there are class instances representing the analysis of each of the sub parts of each variant of the question.
* {@link \core_question\statistics\responses\analysis_for_subpart}.
* - Then within the sub part analysis there are response class analysis
* {@link \core_question\statistics\responses\analysis_for_class}.
* - Then within each class analysis there are analysis for each actual response
* {@link \core_question\statistics\responses\analysis_for_actual_response}.
*
* @package core_question
* @copyright 2014 The Open University
* @author James Pratt me@jamiep.org
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class analysis_for_question_all_tries extends analysis_for_question{
/**
* Constructor.
*
* @param int $variantno variant number
* @param \array[] $responsepartsforeachtry for question with multiple tries we expect an array with first index being try no
* then second index is subpartid and values are \question_classified_response
*/
public function count_response_parts($variantno, $responsepartsforeachtry) {
foreach ($responsepartsforeachtry as $try => $responseparts) {
foreach ($responseparts as $subpartid => $responsepart) {
$this->get_analysis_for_subpart($variantno, $subpartid)->count_response($responsepart, $try);
}
}
}
public function has_multiple_tries_data() {
return true;
}
/**
* What is the highest number of tries at this question?
*
* @return int try number
*/
public function get_maximum_tries() {
$max = 1;
foreach ($this->get_variant_nos() as $variantno) {
foreach ($this->get_subpart_ids($variantno) as $subpartid) {
$max = max($max, $this->get_analysis_for_subpart($variantno, $subpartid)->get_maximum_tries());
}
}
return $max;
}
}
@@ -0,0 +1,159 @@
<?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/>.
/**
*
* Data structure to count responses for each of the sub parts of a question.
*
* @package core_question
* @copyright 2014 The Open University
* @author James Pratt me@jamiep.org
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
namespace core_question\statistics\responses;
/**
* Representing the analysis of each of the sub parts of each variant of the question.
*
* - There is a separate data structure for each question or sub question's analysis
* {@link \core_question\statistics\responses\analysis_for_question}
* or {@link \core_question\statistics\responses\analysis_for_question_all_tries}.
* - There are separate analysis for each variant in this top level instance.
* - Then there are class instances representing the analysis of each of the sub parts of each variant of the question.
* {@link \core_question\statistics\responses\analysis_for_subpart}.
* - Then within the sub part analysis there are response class analysis
* {@link \core_question\statistics\responses\analysis_for_class}.
* - Then within each class analysis there are analysis for each actual response
* {@link \core_question\statistics\responses\analysis_for_actual_response}.
*
* @package core_question
* @copyright 2014 The Open University
* @author James Pratt me@jamiep.org
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class analysis_for_subpart {
/**
* @var analysis_for_class[]
*/
protected $responseclasses;
/**
* Takes an array of possible_responses as returned from {@link \question_type::get_possible_responses()}.
*
* @param \question_possible_response[] $responseclasses as returned from {@link \question_type::get_possible_responses()}.
*/
public function __construct(array $responseclasses = null) {
if (is_array($responseclasses)) {
foreach ($responseclasses as $responseclassid => $responseclass) {
$this->responseclasses[$responseclassid] = new analysis_for_class($responseclass, $responseclassid);
}
} else {
$this->responseclasses = [];
}
}
/**
* Unique ids for response classes.
*
* @return string[]
*/
public function get_response_class_ids() {
return array_keys($this->responseclasses);
}
/**
* Get the instance of the class handling the analysis of $classid for this sub part.
*
* @param string $classid id for response class.
* @return analysis_for_class
*/
public function get_response_class($classid) {
if (!isset($this->responseclasses[$classid])) {
debugging('Unexpected class id ' . $classid . ' encountered.');
$this->responseclasses[$classid] = new analysis_for_class('[Unknown]', $classid);
}
return $this->responseclasses[$classid];
}
/**
* Whether there is more than one response class for responses in this question sub part?
*
* @return bool Are there?
*/
public function has_multiple_response_classes() {
return count($this->get_response_class_ids()) > 1;
}
/**
* Count a part of a response.
*
* @param \question_classified_response $subpart
* @param int $try the try number or zero if not keeping track of try number
*/
public function count_response($subpart, $try = 0) {
$responseanalysisforclass = $this->get_response_class($subpart->responseclassid);
$responseanalysisforclass->count_response($subpart->response, $subpart->fraction, $try);
}
/**
* Cache analysis for sub part.
*
* @param \qubaid_condition $qubaids which question usages have been analysed.
* @param string $whichtries which tries have been analysed?
* @param int $questionid which question.
* @param int $variantno which variant.
* @param string $subpartid which sub part.
* @param int|null $calculationtime time when the analysis was done. (Defaults to time()).
*/
public function cache($qubaids, $whichtries, $questionid, $variantno, $subpartid, $calculationtime = null) {
foreach ($this->get_response_class_ids() as $responseclassid) {
$analysisforclass = $this->get_response_class($responseclassid);
$analysisforclass->cache($qubaids, $whichtries, $questionid, $variantno, $subpartid, $calculationtime);
}
}
/**
* Has actual responses different to the model response for this class?
*
* @return bool whether this analysis has a response class with more than one
* different actual response, or if the actual response is different from
* the model response.
*/
public function has_actual_responses() {
foreach ($this->get_response_class_ids() as $responseclassid) {
if ($this->get_response_class($responseclassid)->has_actual_responses()) {
return true;
}
}
return false;
}
/**
* What is the highest try number for this sub part?
*
* @return int max tries
*/
public function get_maximum_tries() {
$max = 1;
foreach ($this->get_response_class_ids() as $responseclassid) {
$max = max($max, $this->get_response_class($responseclassid)->get_maximum_tries());
}
return $max;
}
}