From c34b89a3c2a681ebae2a03af7c599863069c1b63 Mon Sep 17 00:00:00 2001 From: Guillermo Gomez Date: Wed, 2 Jun 2021 19:16:18 +1000 Subject: [PATCH 1/8] MDL-71696 core_question: Implement new database schema This commit implements the new database structure for versioning in question. It also does the migration of current data to the new structure. Co-Authored-By: Safat Shahin Co-Authored-By: Guillermo Gomez Arias --- lang/en/question.php | 8 +- lib/db/install.xml | 70 +++++++- lib/db/upgrade.php | 153 ++++++++++++++++++ lib/db/upgradelib.php | 134 +++++++++++++++ lib/upgrade.txt | 15 +- .../local/bank/question_version_status.php | 43 +++++ question/classes/privacy/provider.php | 112 +++++++++---- version.php | 2 +- 8 files changed, 493 insertions(+), 44 deletions(-) create mode 100644 question/classes/local/bank/question_version_status.php diff --git a/lang/en/question.php b/lang/en/question.php index 3b4ef630d4b..cc375597e02 100644 --- a/lang/en/question.php +++ b/lang/en/question.php @@ -426,9 +426,11 @@ $string['privacy:metadata:database:question_attempts'] = 'The information about $string['privacy:metadata:database:question_attempts:flagged'] = 'An indication that the user has flagged this question within the attempt.'; $string['privacy:metadata:database:question_attempts:responsesummary'] = 'A summary of the question response.'; $string['privacy:metadata:database:question_attempts:timemodified'] = 'The time that the question attempt was updated.'; -$string['privacy:metadata:link:qbehaviour'] = 'The question subsystem makes use of the Question behaviours plugin type.'; -$string['privacy:metadata:link:qformat'] = 'The question subsystem makes use of the Question import/export formats plugin type for the purpose of importing and exporting questions in different formats.'; -$string['privacy:metadata:link:qtype'] = 'The question subsystem interacts with the Question types plugin type which contains the different types of questions.'; +$string['privacy:metadata:database:question_bank_entries'] = 'The details about a specific question bank entry.'; +$string['privacy:metadata:database:question_bank_entries:ownerid'] = 'The person who owns the question bank entry.'; +$string['privacy:metadata:link:qbehaviour'] = 'The Question subsystem makes use of the Question Behaviour plugintype.'; +$string['privacy:metadata:link:qformat'] = 'The Question subsystem makes use of the Question Format plugintype for the purpose of importing and exporting questions in different formats.'; +$string['privacy:metadata:link:qtype'] = 'The Question subsystem interacts with the Question Type plugintype which contains the different types of questions.'; $string['questionbehaviouradminsetting'] = 'Question behaviour settings'; $string['questionbehavioursdisabled'] = 'Question behaviours to disable'; $string['questionbehavioursdisabledexplained'] = 'Enter a comma-separated list of behaviours you do not want to appear in the drop-down menu.'; diff --git a/lib/db/install.xml b/lib/db/install.xml index 0a62801cf5c..a40a4c03fdf 100644 --- a/lib/db/install.xml +++ b/lib/db/install.xml @@ -1402,10 +1402,9 @@ - +
- @@ -1417,26 +1416,83 @@ - - - - -
+ + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + +
diff --git a/lib/db/upgrade.php b/lib/db/upgrade.php index c531c50163c..cc64745eb06 100644 --- a/lib/db/upgrade.php +++ b/lib/db/upgrade.php @@ -3809,5 +3809,158 @@ privatefiles,moodle|/user/files.php'; upgrade_main_savepoint(true, 2022012100.02); } + // Introduce question versioning to core. + // First, create the new tables. + if ($oldversion < 2022020200.01) { + // Define table question_bank_entries to be created. + $table = new xmldb_table('question_bank_entries'); + + // Adding fields to table question_bank_entries. + $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null); + $table->add_field('questioncategoryid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, 0); + $table->add_field('idnumber', XMLDB_TYPE_CHAR, '100', null, null, null, null); + $table->add_field('ownerid', XMLDB_TYPE_INTEGER, '10', null, null, null, null); + + // Adding keys to table question_bank_entries. + $table->add_key('primary', XMLDB_KEY_PRIMARY, ['id']); + $table->add_key('questioncategoryid', XMLDB_KEY_FOREIGN, ['questioncategoryid'], 'question_categories', ['id']); + $table->add_key('ownerid', XMLDB_KEY_FOREIGN, ['ownerid'], 'user', ['id']); + + // Conditionally launch create table for question_bank_entries. + if (!$dbman->table_exists($table)) { + $dbman->create_table($table); + } + + // Create category id and id number index. + $index = new xmldb_index('categoryidnumber', XMLDB_INDEX_UNIQUE, ['questioncategoryid', 'idnumber']); + + // Conditionally launch add index categoryidnumber. + if (!$dbman->index_exists($table, $index)) { + $dbman->add_index($table, $index); + } + + // Define table question_versions to be created. + $table = new xmldb_table('question_versions'); + + // Adding fields to table question_versions. + $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null); + $table->add_field('questionbankentryid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null); + $table->add_field('version', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, 1); + $table->add_field('questionid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, 0); + $table->add_field('status', XMLDB_TYPE_CHAR, '10', null, XMLDB_NOTNULL, null, 'ready'); + + // Adding keys to table question_versions. + $table->add_key('primary', XMLDB_KEY_PRIMARY, ['id']); + $table->add_key('questionbankentryid', XMLDB_KEY_FOREIGN, ['questionbankentryid'], 'question_bank_entries', ['id']); + $table->add_key('questionid', XMLDB_KEY_FOREIGN, ['questionid'], 'question', ['id']); + + // Conditionally launch create table for question_versions. + if (!$dbman->table_exists($table)) { + $dbman->create_table($table); + } + + // Define table question_references to be created. + $table = new xmldb_table('question_references'); + + // Adding fields to table question_references. + $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null); + $table->add_field('usingcontextid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, 0); + $table->add_field('component', XMLDB_TYPE_CHAR, '100', null, null, null, null); + $table->add_field('questionarea', XMLDB_TYPE_CHAR, '50', null, null, null, null); + $table->add_field('itemid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null); + $table->add_field('questionbankentryid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null); + $table->add_field('version', XMLDB_TYPE_INTEGER, '10', null, null, null, null); + + // Adding keys to table question_references. + $table->add_key('primary', XMLDB_KEY_PRIMARY, ['id']); + $table->add_key('usingcontextid', XMLDB_KEY_FOREIGN, ['usingcontextid'], 'context', ['id']); + $table->add_key('questionbankentryid', XMLDB_KEY_FOREIGN, ['questionbankentryid'], 'question_bank_entries', ['id']); + + // Conditionally launch create table for question_references. + if (!$dbman->table_exists($table)) { + $dbman->create_table($table); + } + + // Define table question_set_references to be created. + $table = new xmldb_table('question_set_references'); + + // Adding fields to table question_set_references. + $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null); + $table->add_field('usingcontextid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, 0); + $table->add_field('component', XMLDB_TYPE_CHAR, '100', null, null, null, null); + $table->add_field('questionarea', XMLDB_TYPE_CHAR, '50', null, null, null, null); + $table->add_field('itemid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null); + $table->add_field('questionscontextid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, 0); + $table->add_field('filtercondition', XMLDB_TYPE_TEXT, null, null, null, null, null); + + // Adding keys to table question_set_references. + $table->add_key('primary', XMLDB_KEY_PRIMARY, ['id']); + $table->add_key('usingcontextid', XMLDB_KEY_FOREIGN, ['usingcontextid'], 'context', ['id']); + $table->add_key('itemid', XMLDB_KEY_FOREIGN, ['itemid'], 'quiz_slots', ['id']); + $table->add_key('questionscontextid', XMLDB_KEY_FOREIGN, ['questionscontextid'], 'context', ['id']); + + // Conditionally launch create table for question_set_references. + if (!$dbman->table_exists($table)) { + $dbman->create_table($table); + } + + // Main savepoint reached. + upgrade_main_savepoint(true, 2022020200.01); + } + + if ($oldversion < 2022020200.02) { + // Next, split question records into the new tables. + upgrade_migrate_question_table(); + // Main savepoint reached. + upgrade_main_savepoint(true, 2022020200.02); + } + + // Finally, drop fields from question table. + if ($oldversion < 2022020200.03) { + // Define fields to be dropped from questions. + $table = new xmldb_table('question'); + + $field = new xmldb_field('version'); + // Conditionally launch drop field version. + if ($dbman->field_exists($table, $field)) { + $dbman->drop_field($table, $field); + } + + $field = new xmldb_field('hidden'); + // Conditionally launch drop field hidden. + if ($dbman->field_exists($table, $field)) { + $dbman->drop_field($table, $field); + } + + // Define index categoryidnumber (not unique) to be dropped form question. + $index = new xmldb_index('categoryidnumber', XMLDB_INDEX_UNIQUE, ['category', 'idnumber']); + + // Conditionally launch drop index categoryidnumber. + if ($dbman->index_exists($table, $index)) { + $dbman->drop_index($table, $index); + } + + // Define key category (foreign) to be dropped form questions. + $key = new xmldb_key('category', XMLDB_KEY_FOREIGN, ['category'], 'question_categories', ['id']); + + // Launch drop key category. + $dbman->drop_key($table, $key); + + $field = new xmldb_field('idnumber'); + // Conditionally launch drop field idnumber. + if ($dbman->field_exists($table, $field)) { + $dbman->drop_field($table, $field); + } + + $field = new xmldb_field('category'); + // Conditionally launch drop field category. + if ($dbman->field_exists($table, $field)) { + $dbman->drop_field($table, $field); + } + + // Main savepoint reached. + upgrade_main_savepoint(true, 2022020200.03); + } + return true; } diff --git a/lib/db/upgradelib.php b/lib/db/upgradelib.php index e502dd535ea..e67af9972de 100644 --- a/lib/db/upgradelib.php +++ b/lib/db/upgradelib.php @@ -1274,3 +1274,137 @@ function upgrade_calendar_override_events_fix(stdClass $info, bool $output = tru upgrade_calendar_events_mtrace('', $output); return $return; } + +/** + * Split question table in 2 new tables: + * + * question_bank_entries + * question_versions + * + * Move the random questions records to the following table: + * question_set_reference + * + * Move the question related records from quiz_slots table to: + * question_reference + * + * Move the tag related data from quiz_slot_tags to: + * question_references + * + * For more information: https://moodle.org/mod/forum/discuss.php?d=417599#p1688163 + */ +function upgrade_migrate_question_table(): void { + global $DB; + + // Maximum size of array. + $maxlength = 30000; + + // Array of question_versions objects. + $questionversions = []; + + // Array of question_set_references objects. + $questionsetreferences = []; + + // The actual update/insert done with multiple DB access, so we do it in a transaction. + $transaction = $DB->start_delegated_transaction(); + + // Count all questions to be migrated (for progress bar). + $total = $DB->count_records('question'); + $pbar = new progress_bar('migratequestions', 1000, true); + $i = 0; + // Get all records in question table, we dont need the subquestions, just regular questions and random questions. + $questions = $DB->get_recordset('question'); + foreach ($questions as $question) { + upgrade_set_timeout(60); + // Populate table question_bank_entries. + $questionbankentry = new \stdClass(); + $questionbankentry->questioncategoryid = $question->category; + $questionbankentry->idnumber = $question->idnumber; + $questionbankentry->ownerid = $question->createdby; + // Insert a question_bank_entries record here as the id is required to populate other tables. + $questionbankentry->id = $DB->insert_record('question_bank_entries', $questionbankentry); + + // Create question_versions records to be added. + $questionversion = new \stdClass(); + $questionversion->questionbankentryid = $questionbankentry->id; + $questionversion->questionid = $question->id; + $questionstatus = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; + if ((int)$question->hidden === 1) { + $questionstatus = \core_question\local\bank\question_version_status::QUESTION_STATUS_HIDDEN; + } + $questionversion->status = $questionstatus; + $questionversions[] = $questionversion; + + // Insert the records if the array limit is reached. + if (count($questionversions) >= $maxlength) { + $DB->insert_records('question_versions', $questionversions); + $questionversions = []; + } + + // Create question_set_references records to be added. + // Only if the question type is random and the question is used in a quiz. + if ($question->qtype === 'random') { + $quizslots = $DB->get_records('quiz_slots', ['questionid' => $question->id]); + foreach ($quizslots as $quizslot) { + $questionsetreference = new \stdClass(); + $cm = get_coursemodule_from_instance('quiz', $quizslot->quizid); + $questionsetreference->usingcontextid = context_module::instance($cm->id)->id; + $questionsetreference->component = 'mod_quiz'; + $questionsetreference->questionarea = 'slot'; + $questionsetreference->itemid = $quizslot->id; + $catcontext = $DB->get_field('question_categories', 'contextid', ['id' => $question->category]); + $questionsetreference->questionscontextid = $catcontext; + // Migration of the slot tags and filter identifiers from slot table to filtercondition. + $filtercondition = new stdClass(); + $filtercondition->questioncategoryid = $question->category; + $filtercondition->includingsubcategories = $quizslot->includingsubcategories; + $tags = $DB->get_records('quiz_slot_tags', ['slotid' => $quizslot->id]); + $tagstrings = []; + foreach ($tags as $tag) { + $tagstrings [] = "{$tag->id},{$tag->name}"; + } + if (!empty($tagstrings)) { + $filtercondition->tags = $tagstrings; + } + $questionsetreference->filtercondition = json_encode($filtercondition); + + $questionsetreferences[] = $questionsetreference; + + // Insert the records if the array limit is reached. + if (count($questionsetreferences) >= $maxlength) { + $DB->insert_records('question_set_references', $questionsetreferences); + $questionsetreferences = []; + } + } + } + // Update progress. + $i++; + $pbar->update($i, $total, "Migrating questions - $i/$total."); + } + $questions->close(); + + // Insert the remaining question_versions records. + if ($questionversions) { + $DB->insert_records('question_versions', $questionversions); + } + + // Insert the remaining question_set_references records. + if ($questionsetreferences) { + $DB->insert_records('question_set_references', $questionsetreferences); + } + + // Create question_references record for each question. + // Except if qtype is random. That case is handled by question_set_reference. + $sql = "INSERT INTO {question_references} + (usingcontextid, component, questionarea, itemid, questionbankentryid) + SELECT c.id, 'mod_quiz', 'slot', qs.id, qv.questionbankentryid + FROM {question} q + JOIN {question_versions} qv ON q.id = qv.questionid + JOIN {quiz_slots} qs ON q.id = qs.questionid + JOIN {modules} m ON m.name = 'quiz' + JOIN {course_modules} cm ON cm.module = m.id AND cm.instance = qs.quizid + JOIN {context} c ON c.instanceid = cm.id AND c.contextlevel = " . CONTEXT_MODULE . " + WHERE q.qtype <> 'random'"; + $DB->execute($sql); + + $transaction->allow_commit(); +} diff --git a/lib/upgrade.txt b/lib/upgrade.txt index d554dd1c8a4..107823bed33 100644 --- a/lib/upgrade.txt +++ b/lib/upgrade.txt @@ -114,8 +114,19 @@ completely removed from Moodle core too. Refer to upgrade.php to see transitioning from similar plugin criteria to core Refer to completion/upgrade.txt for additional information. * The method enable_plugin() has been added to the core_plugininfo\base class and it has been implemented by all the plugininfo - classes extending it. When possible, the enable_plugin() method will store these changes into the config_log table, to let admins - check when and who has enabled/disabled plugins. +classes extending it. When possible, the enable_plugin() method will store these changes into the config_log table, to let admins +check when and who has enabled/disabled plugins. +* New tables are included as a part of https://docs.moodle.org/dev/Question_bank_improvements_for_Moodle_4.0 + - question_bank_entries -> Each question bank entry. This table has one row for each question that appears in the question bank. + - question_versions -> Versions of the question. Store the data that defines how a particular version of the question works. + - question_references -> Records where a specific question is used. + - question_set_references -> Records where groups of questions are used (e.g.: Random questions). + Also, some tables have been updated or removed: + - question (fields migrated to the new tables) + - quiz_slot (fields removed) + - quiz_slot_tags (table removed) + During the upgrade, data from the question table will be copied to the new tables. After this process, + the data copied will be removed from question table quiz_slot and finally the the quiz_slot_tags table will be removed. * Final deprecation: The following functions along with associated tests have been removed: - core_grades_external::get_grades - core_grades_external::get_grade_item diff --git a/question/classes/local/bank/question_version_status.php b/question/classes/local/bank/question_version_status.php new file mode 100644 index 00000000000..4e063ead323 --- /dev/null +++ b/question/classes/local/bank/question_version_status.php @@ -0,0 +1,43 @@ +. + +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 + * @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'; +} diff --git a/question/classes/privacy/provider.php b/question/classes/privacy/provider.php index 1b6e3c4d8ac..7434ca5869e 100644 --- a/question/classes/privacy/provider.php +++ b/question/classes/privacy/provider.php @@ -126,6 +126,10 @@ class provider implements // 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'); @@ -336,12 +340,13 @@ class provider implements // A user may have created or updated a question. // Questions are linked against a question category, which has a contextid field. - $sql = "SELECT cat.contextid + $sql = "SELECT qc.contextid FROM {question} q - INNER JOIN {question_categories} cat ON cat.id = q.category - WHERE - q.createdby = :useridcreated OR - q.modifiedby = :useridmodified"; + 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, @@ -363,9 +368,10 @@ class provider implements // Questions are linked against a question category, which has a contextid field. $sql = "SELECT q.createdby, q.modifiedby FROM {question} q - JOIN {question_categories} cat - ON cat.id = q.category - WHERE cat.contextid = :contextid"; + 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 @@ -487,7 +493,8 @@ class provider implements /** * Delete all data for all users in the specified context. * - * @param context $context The specific context to delete data for. + * @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; @@ -496,17 +503,19 @@ class provider implements // user. They are still exported in the list of a users data, but they are not removed. // The userid is instead anonymised. - $DB->set_field_select('question', 'createdby', 0, - 'category IN (SELECT id FROM {question_categories} WHERE contextid = :contextid)', - [ - 'contextid' => $context->id, - ]); + $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 = ?'; - $DB->set_field_select('question', 'modifiedby', 0, - 'category IN (SELECT id FROM {question_categories} WHERE contextid = :contextid)', - [ - 'contextid' => $context->id, - ]); + $questions = $DB->get_records_sql($sql, [$context->id]); + foreach ($questions as $question) { + $question->createdby = 0; + $question->modifiedby = 0; + $DB->update_record('question', $question); + } } /** @@ -523,15 +532,36 @@ class provider implements list($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED); $contextparams['createdby'] = $contextlist->get_user()->id; - $DB->set_field_select('question', 'createdby', 0, " - category IN (SELECT id FROM {question_categories} WHERE contextid {$contextsql}) - AND createdby = :createdby", $contextparams); + $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; - $DB->set_field_select('question', 'modifiedby', 0, " - category IN (SELECT id FROM {question_categories} WHERE contextid {$contextsql}) - AND modifiedby = :modifiedby", $contextparams); + $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); + } + } /** @@ -554,12 +584,32 @@ class provider implements $params = ['contextid' => $context->id]; - $DB->set_field_select('question', 'createdby', 0, " - category IN (SELECT id FROM {question_categories} WHERE contextid = :contextid) - AND createdby {$createdbysql}", $params + $createdbyparams); + $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); - $DB->set_field_select('question', 'modifiedby', 0, " - category IN (SELECT id FROM {question_categories} WHERE contextid = :contextid) - AND modifiedby {$modifiedbysql}", $params + $modifiedbyparams); + 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); + } } } diff --git a/version.php b/version.php index 2886ac30c26..73fa3d3446d 100644 --- a/version.php +++ b/version.php @@ -29,7 +29,7 @@ defined('MOODLE_INTERNAL') || die(); -$version = 2022020200.00; // YYYYMMDD = weekly release date of this DEV branch. +$version = 2022020200.03; // YYYYMMDD = weekly release date of this DEV branch. // RR = release increments - 00 in DEV branches. // .XX = incremental changes. $release = '4.0dev+ (Build: 20220202)'; // Human-friendly version name From fc4c108f5b8988d7e4c149a0fa0bbedfd62f36a8 Mon Sep 17 00:00:00 2001 From: Safat Shahin Date: Thu, 12 Aug 2021 16:32:23 +1000 Subject: [PATCH 2/8] MDL-71696 core_question: question instance report This commit implements changes in the question instance report to support the new db structure for versioning. I considers each question as a an entity rather than counting each versions. --- report/questioninstances/index.php | 31 +++++++--- .../tests/behat/question_reports.feature | 61 +++++++++++++++++++ 2 files changed, 84 insertions(+), 8 deletions(-) create mode 100644 report/questioninstances/tests/behat/question_reports.feature diff --git a/report/questioninstances/index.php b/report/questioninstances/index.php index 33e42376281..3a34cb25b7f 100644 --- a/report/questioninstances/index.php +++ b/report/questioninstances/index.php @@ -89,14 +89,29 @@ if ($requestedqtype) { $ctxpreload = context_helper::get_preload_record_columns_sql('con'); $ctxgroupby = implode(',', array_keys(context_helper::get_preload_record_columns('con'))); $counts = $DB->get_records_sql(" - SELECT qc.contextid, count(1) as numquestions, sum(hidden) as numhidden, $ctxpreload - FROM {question} q - JOIN {question_categories} qc ON q.category = qc.id - JOIN {context} con ON con.id = qc.contextid - $sqlqtypetest - AND (q.parent = 0 OR q.parent = q.id) - GROUP BY qc.contextid, $ctxgroupby - ORDER BY numquestions DESC, numhidden ASC, con.contextlevel ASC, con.id ASC", $params); + SELECT result.contextid, SUM(numquestions) AS numquestions, SUM(numhidden) AS numhidden, $ctxpreload + FROM (SELECT data.contextid, COUNT(data.numquestions) AS numquestions, + (SELECT COUNT(qv.id) + FROM {question_versions} qv + WHERE qv.id = data.versionid + AND qv.status = 1) AS numhidden + FROM (SELECT qv.id as versionid, qc.contextid, 1 AS numquestions + 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 + JOIN {context} con ON con.id = qc.contextid + $sqlqtypetest + AND qv.version = (SELECT MAX(v.version) + FROM {question_versions} v + JOIN {question_bank_entries} be + ON be.id = v.questionbankentryid + WHERE be.id = qbe.id) + AND (q.parent = 0 OR q.parent = q.id)) data + GROUP BY data.contextid, data.versionid) result + JOIN {context} con ON con.id = result.contextid + GROUP BY result.contextid, $ctxgroupby + ORDER BY numquestions DESC, numhidden ASC, con.contextlevel ASC, con.id ASC", $params); // Print the report heading. echo $OUTPUT->heading($title); diff --git a/report/questioninstances/tests/behat/question_reports.feature b/report/questioninstances/tests/behat/question_reports.feature new file mode 100644 index 00000000000..78d2d2b2f47 --- /dev/null +++ b/report/questioninstances/tests/behat/question_reports.feature @@ -0,0 +1,61 @@ +@core @core_question @javascript @report @report_questioninstance +Feature: A Teacher can generate question instance reports + In order to see question instance reports + As a Teacher + I need to generate them + + Background: + Given the following "users" exist: + | username | firstname | lastname | email | + | teacher1 | Teacher | 1 | teacher1@example.com | + And the following "courses" exist: + | fullname | shortname | format | + | Course 1 | C1 | weeks | + And the following "question categories" exist: + | contextlevel | reference | name | + | Course | C1 | Test questions | + And the following "activities" exist: + | activity | course | name | + | quiz | C1 | Test quiz Q001 | + And the following "questions" exist: + | questioncategory | qtype | name | + | Test questions | truefalse | TF | + | Test questions | shortanswer | SA | + And quiz "Test quiz Q001" contains the following questions: + | question | page | maxmark | + | TF | 1 | 5.0 | + | SA | 1 | 5.0 | + And the following "course enrolments" exist: + | user | course | role | + | teacher1 | C1 | editingteacher | + And the following "question categories" exist: + | contextlevel | reference | name | + | Activity module | Test quiz Q001 | Quiz category | + + @javascript + Scenario: Generate general and specific report + Given I am on the "C1" "Course" page logged in as "admin" + And I navigate to "Reports > Question instances" in site administration + When I press "Get the report" + Then "Course: Course 1" row "Total" column of "generaltable" table should contain "2" + And "Course: Course 1" row "Visible" column of "generaltable" table should contain "2" + And "Course: Course 1" row "Hidden" column of "generaltable" table should contain "0" + + @javascript + Scenario: Generate report displaying hidden questions + Given I am on the "Test quiz Q001" "quiz activity" page logged in as "admin" + And I navigate to "Question bank > Questions" in current page administration + And I click on "Edit" "link" in the "TF" "table_row" + And I choose "Delete" in the open action menu + And I press "Delete" + And I navigate to "Reports > Question instances" in site administration + When I press "Get the report" + Then "Course: Course 1" row "Total" column of "generaltable" table should contain "2" + And "Course: Course 1" row "Visible" column of "generaltable" table should contain "1" + And "Course: Course 1" row "Hidden" column of "generaltable" table should contain "1" + And I click on "menuqtype" "select" + And I click on "True/False" "option" + And I press "Get the report" + And "Course: Course 1" row "Total" column of "generaltable" table should contain "1" + And "Course: Course 1" row "Visible" column of "generaltable" table should contain "0" + And "Course: Course 1" row "Hidden" column of "generaltable" table should contain "1" From 7547f3e9f0a4f58c94727d5c709e55785d6fa8bb Mon Sep 17 00:00:00 2001 From: Safat Shahin Date: Tue, 24 Aug 2021 11:51:56 +1000 Subject: [PATCH 3/8] MDL-71696 tool_brickfield: accessibility changes for versioning This implementation will introduce changes in the admin tools plugin brickfield to support versioning and the changed db schema. --- .../local/areas/core_question/answerbase.php | 150 ++++++++------ .../local/areas/core_question/base.php | 190 +++++++++++------- .../areas/core_question/questiontext_test.php | 29 +++ 3 files changed, 237 insertions(+), 132 deletions(-) diff --git a/admin/tool/brickfield/classes/local/areas/core_question/answerbase.php b/admin/tool/brickfield/classes/local/areas/core_question/answerbase.php index c0158264609..66c96fbee78 100644 --- a/admin/tool/brickfield/classes/local/areas/core_question/answerbase.php +++ b/admin/tool/brickfield/classes/local/areas/core_question/answerbase.php @@ -20,9 +20,9 @@ use core\event\question_created; use core\event\question_updated; /** - * Base class for various question-related areas + * Base class for various question-related areas. * - * This is an abstract class so it will be skipped by manager when it finds all areas + * This is an abstract class so it will be skipped by manager when it finds all areas. * * @package tool_brickfield * @copyright 2020 onward: Brickfield Education Labs, www.brickfield.ie @@ -32,6 +32,7 @@ abstract class answerbase extends base { /** * Get table name reference. + * * @return string */ public function get_ref_tablename(): string { @@ -40,34 +41,38 @@ abstract class answerbase extends base { /** * Find recordset of the relevant areas. + * * @param \core\event\base $event * @return \moodle_recordset|null - * @throws \coding_exception - * @throws \dml_exception */ public function find_relevant_areas(\core\event\base $event): ?\moodle_recordset { global $DB; - if (($event instanceof question_created) || ($event instanceof question_updated)) { - $rs = $DB->get_recordset_sql( - "SELECT {$this->get_type()} AS type, - ctx.id AS contextid, - {$this->get_standard_area_fields_sql()} - a.id AS itemid, - {$this->get_reftable_field_sql()} - t.id AS refid, - {$this->get_course_and_cat_sql($event)} - a.{$this->get_fieldname()} AS content - FROM {question} t - INNER JOIN {question_answers} a ON a.question = t.id - INNER JOIN {question_categories} qc ON qc.id = t.category - INNER JOIN {context} ctx ON ctx.id = qc.contextid - WHERE (t.id = :refid) - ORDER BY a.id", - [ - 'refid' => $event->objectid, - ]); + $sql = "SELECT {$this->get_type()} AS type, + ctx.id AS contextid, + {$this->get_standard_area_fields_sql()} + a.id AS itemid, + {$this->get_reftable_field_sql()} + q.id AS refid, + {$this->get_course_and_cat_sql($event)} + a.{$this->get_fieldname()} AS content + FROM {question} q + INNER JOIN {question_answers} a + ON a.question = q.id + INNER JOIN {question_versions} qv + ON qv.questionid = q.id + INNER JOIN {question_bank_entries} qbe + ON qbe.id = qv.questionbankentryid + INNER JOIN {question_categories} qc + ON qc.id = qbe.questioncategoryid + INNER JOIN {context} ctx + ON ctx.id = qc.contextid + WHERE (q.id = :refid) + ORDER BY a.id"; + + $rs = $DB->get_recordset_sql($sql, ['refid' => $event->objectid]); return $rs; + } return null; } @@ -75,61 +80,90 @@ abstract class answerbase extends base { /** * Return an array of area objects that contain content at the site and system levels only. This would be question content from * question categories at the system context, or course category context. + * * @return mixed - * @throws \dml_exception */ public function find_system_areas(): ?\moodle_recordset { global $DB; - $select = 'SELECT ' . $this->get_type() . ' AS type, qc.contextid AS contextid, ' . $this->get_standard_area_fields_sql() . - ' a.id AS itemid, ' . $this->get_reftable_field_sql() . 't.id AS refid, '. - SITEID . ' as courseid, cc.id as categoryid, a.'.$this->get_fieldname().' AS content '; - $from = 'FROM {question} t ' . - 'INNER JOIN {question_answers} a ON a.question = t.id ' . - 'INNER JOIN {question_categories} qc ON qc.id = t.category ' . - 'INNER JOIN {context} ctx ON ctx.id = qc.contextid ' . - 'LEFT JOIN {course_categories} cc ON cc.id = ctx.instanceid AND ctx.contextlevel = :coursecat '; - $where = 'WHERE (ctx.contextlevel = :syscontext) OR (ctx.contextlevel = :coursecat2) '; - $order = 'ORDER BY a.id'; $params = [ 'syscontext' => CONTEXT_SYSTEM, 'coursecat' => CONTEXT_COURSECAT, 'coursecat2' => CONTEXT_COURSECAT, ]; - return $DB->get_recordset_sql($select . $from . $where . $order, $params); + $sql = "SELECT {$this->get_type()} AS type, + qc.contextid AS contextid, + {$this->get_standard_area_fields_sql()} + a.id AS itemid, + {$this->get_reftable_field_sql()} + q.id AS refid, + " . SITEID . " as courseid, + cc.id as categoryid, + a.{$this->get_fieldname()} AS content + FROM {question} q + INNER JOIN {question_answers} a + ON a.question = q.id + INNER JOIN {question_versions} qv + ON qv.questionid = q.id + INNER JOIN {question_bank_entries} qbe + ON qbe.id = qv.questionbankentryid + INNER JOIN {question_categories} qc + ON qc.id = qbe.questioncategoryid + INNER JOIN {context} ctx + ON ctx.id = qc.contextid + LEFT JOIN {course_categories} cc + ON cc.id = ctx.instanceid + AND ctx.contextlevel = :coursecat + WHERE (ctx.contextlevel = :syscontext) + OR (ctx.contextlevel = :coursecat2) + ORDER BY a.id"; + + return $DB->get_recordset_sql($sql, $params); } /** * Find recordset of the course areas. + * * @param int $courseid * @return \moodle_recordset - * @throws \coding_exception - * @throws \dml_exception */ public function find_course_areas(int $courseid): ?\moodle_recordset { global $DB; $coursecontext = \context_course::instance($courseid); - return $DB->get_recordset_sql( - "SELECT {$this->get_type()} AS type, - ctx.id AS contextid, - {$this->get_standard_area_fields_sql()} - a.id AS itemid, - {$this->get_reftable_field_sql()} - t.id AS refid, - {$courseid} AS courseid, - a.{$this->get_fieldname()} AS content - FROM {question} t - INNER JOIN {question_answers} a ON a.question = t.id - INNER JOIN {question_categories} qc ON qc.id = t.category - INNER JOIN {context} ctx ON ctx.id = qc.contextid - WHERE (ctx.contextlevel = :ctxcourse AND ctx.id = qc.contextid AND ctx.instanceid = :courseid) OR - (ctx.contextlevel = :module AND {$DB->sql_like('ctx.path', ':coursecontextpath')}) - ORDER BY a.id", - ['ctxcourse' => CONTEXT_COURSE, - 'courseid' => $courseid, - 'module' => CONTEXT_MODULE, - 'coursecontextpath' => $DB->sql_like_escape($coursecontext->path) . '/%', - ]); + $param = [ + 'ctxcourse' => CONTEXT_COURSE, + 'courseid' => $courseid, + 'module' => CONTEXT_MODULE, + 'coursecontextpath' => $DB->sql_like_escape($coursecontext->path) . '/%', + ]; + + $sql = "SELECT {$this->get_type()} AS type, + ctx.id AS contextid, + {$this->get_standard_area_fields_sql()} + a.id AS itemid, + {$this->get_reftable_field_sql()} + q.id AS refid, + {$courseid} AS courseid, + a.{$this->get_fieldname()} AS content + FROM {question} q + INNER JOIN {question_answers} a + ON a.question = q.id + INNER JOIN {question_versions} qv + ON qv.questionid = q.id + INNER JOIN {question_bank_entries} qbe + ON qbe.id = qv.questionbankentryid + INNER JOIN {question_categories} qc + ON qc.id = qbe.questioncategoryid + INNER JOIN {context} ctx + ON ctx.id = qc.contextid + WHERE (ctx.contextlevel = :ctxcourse + AND ctx.id = qc.contextid + AND ctx.instanceid = :courseid) + OR (ctx.contextlevel = :module + AND {$DB->sql_like('ctx.path', ':coursecontextpath')}) + ORDER BY a.id ASC"; + + return $DB->get_recordset_sql($sql, $param); } } diff --git a/admin/tool/brickfield/classes/local/areas/core_question/base.php b/admin/tool/brickfield/classes/local/areas/core_question/base.php index 0dbf58be1b6..195b3da8857 100644 --- a/admin/tool/brickfield/classes/local/areas/core_question/base.php +++ b/admin/tool/brickfield/classes/local/areas/core_question/base.php @@ -21,7 +21,7 @@ use core\event\question_updated; use tool_brickfield\area_base; /** - * Base class for various question-related areas + * Base class for various question-related areas. * * This is an abstract class so it will be skipped by manager when it finds all areas * @@ -33,30 +33,32 @@ abstract class base extends area_base { /** * Find recordset of the relevant areas. + * * @param \core\event\base $event * @return \moodle_recordset|null - * @throws \coding_exception - * @throws \dml_exception */ public function find_relevant_areas(\core\event\base $event): ?\moodle_recordset { global $DB; - if (($event instanceof question_created) || ($event instanceof question_updated)) { - $rs = $DB->get_recordset_sql( - "SELECT {$this->get_type()} AS type, - ctx.id AS contextid, - {$this->get_standard_area_fields_sql()} - t.id AS itemid, - {$this->get_course_and_cat_sql($event)} - t.{$this->get_fieldname()} AS content - FROM {question} t - INNER JOIN {question_categories} qc ON qc.id = t.category - INNER JOIN {context} ctx ON ctx.id = qc.contextid - WHERE (t.id = :refid) - ORDER BY t.id", - [ - 'refid' => $event->objectid, - ]); + $sql = "SELECT {$this->get_type()} AS type, + ctx.id AS contextid, + {$this->get_standard_area_fields_sql()} + q.id AS itemid, + {$this->get_course_and_cat_sql($event)} + q.{$this->get_fieldname()} AS content + FROM {question} q + INNER JOIN {question_versions} qv + ON qv.questionid = q.id + INNER JOIN {question_bank_entries} qbe + ON qbe.id = qv.questionbankentryid + INNER JOIN {question_categories} qc + ON qc.id = qbe.questioncategoryid + INNER JOIN {context} ctx + ON ctx.id = qc.contextid + WHERE (q.id = :refid) + ORDER BY q.id"; + + $rs = $DB->get_recordset_sql($sql, ['refid' => $event->objectid]); return $rs; } return null; @@ -64,75 +66,97 @@ abstract class base extends area_base { /** * Find recordset of the course areas. + * * @param int $courseid * @return \moodle_recordset - * @throws \coding_exception - * @throws \dml_exception */ public function find_course_areas(int $courseid): ?\moodle_recordset { global $DB; - $coursecontext = \context_course::instance($courseid); - return $DB->get_recordset_sql( - "SELECT {$this->get_type()} AS type, - ctx.id AS contextid, - {$this->get_standard_area_fields_sql()} - t.id AS itemid, - {$courseid} AS courseid, - null AS categoryid, - t.{$this->get_fieldname()} AS content - FROM {question} t - INNER JOIN {question_categories} qc ON qc.id = t.category - INNER JOIN {context} ctx ON ctx.id = qc.contextid - WHERE (ctx.contextlevel = :ctxcourse AND ctx.id = qc.contextid AND ctx.instanceid = :courseid) OR - (ctx.contextlevel = :module AND {$DB->sql_like('ctx.path', ':coursecontextpath')}) - ORDER BY t.id ASC", - [ - 'ctxcourse' => CONTEXT_COURSE, - 'courseid' => $courseid, - 'module' => CONTEXT_MODULE, - 'coursecontextpath' => $DB->sql_like_escape($coursecontext->path) . '/%', - ]); + $param = [ + 'ctxcourse' => CONTEXT_COURSE, + 'courseid' => $courseid, + 'module' => CONTEXT_MODULE, + 'coursecontextpath' => $DB->sql_like_escape($coursecontext->path) . '/%', + ]; + + $sql = "SELECT {$this->get_type()} AS type, + ctx.id AS contextid, + {$this->get_standard_area_fields_sql()} + q.id AS itemid, + {$courseid} AS courseid, + null AS categoryid, + q.{$this->get_fieldname()} AS content + FROM {question} q + INNER JOIN {question_versions} qv + ON qv.questionid = q.id + INNER JOIN {question_bank_entries} qbe + ON qbe.id = qv.questionbankentryid + INNER JOIN {question_categories} qc + ON qc.id = qbe.questioncategoryid + INNER JOIN {context} ctx + ON ctx.id = qc.contextid + WHERE (ctx.contextlevel = :ctxcourse + AND ctx.id = qc.contextid + AND ctx.instanceid = :courseid) + OR (ctx.contextlevel = :module + AND {$DB->sql_like('ctx.path', ':coursecontextpath')}) + ORDER BY q.id ASC"; + + return $DB->get_recordset_sql($sql, $param); } /** * Return an array of area objects that contain content at the site and system levels only. This would be question content from * question categories at the system context only. + * * @return \moodle_recordset - * @throws \dml_exception */ public function find_system_areas(): ?\moodle_recordset { global $DB; - - $select = 'SELECT ' . $this->get_type() . ' AS type, qc.contextid AS contextid, ' . $this->get_standard_area_fields_sql() . - ' t.id AS itemid, ' . SITEID . ' as courseid, cc.id as categoryid,' . - ' t.'.$this->get_fieldname().' AS content '; - $from = 'FROM {question} t ' . - 'INNER JOIN {question_categories} qc ON qc.id = t.category ' . - 'INNER JOIN {context} ctx ON ctx.id = qc.contextid ' . - 'LEFT JOIN {course_categories} cc ON cc.id = ctx.instanceid AND ctx.contextlevel = :coursecat '; - $where = 'WHERE (ctx.contextlevel = :syscontext) OR (ctx.contextlevel = :coursecat2) '; - $order = 'ORDER BY t.id'; $params = [ 'syscontext' => CONTEXT_SYSTEM, 'coursecat' => CONTEXT_COURSECAT, 'coursecat2' => CONTEXT_COURSECAT, ]; - return $DB->get_recordset_sql($select . $from . $where . $order, $params); + $sql = "SELECT {$this->get_type()} AS type, + qc.contextid AS contextid, + {$this->get_standard_area_fields_sql()} + q.id AS itemid, + " . SITEID . " as courseid, + cc.id as categoryid, + q.{$this->get_fieldname()} AS content + FROM {question} q + INNER JOIN {question_versions} qv + ON qv.questionid = q.id + INNER JOIN {question_bank_entries} qbe + ON qbe.id = qv.questionbankentryid + INNER JOIN {question_categories} qc + ON qc.id = qbe.questioncategoryid + INNER JOIN {context} ctx + ON ctx.id = qc.contextid + LEFT JOIN {course_categories} cc + ON cc.id = ctx.instanceid + AND ctx.contextlevel = :coursecat + WHERE (ctx.contextlevel = :syscontext) + OR (ctx.contextlevel = :coursecat2) + ORDER BY q.id"; + + return $DB->get_recordset_sql($sql, $params); } /** * Returns the moodle_url of the page to edit the error. + * * @param \stdClass $componentinfo * @return \moodle_url - * @throws \moodle_exception */ public static function get_edit_url(\stdClass $componentinfo): \moodle_url { $questionid = $componentinfo->itemid; - // Question answers are editable on main question page + // Question answers are editable on main question page. // Hence, use refid for these links. - if ($componentinfo->tablename == 'question_answers') { + if ($componentinfo->tablename === 'question_answers') { $questionid = $componentinfo->refid; } // Default to SITEID if courseid is null, i.e. system or category level questions. @@ -142,30 +166,15 @@ abstract class base extends area_base { /** * Determine the course and category id SQL depending on the specific context associated with question data. + * * @param \core\event\base $event * @return string - * @throws \dml_exception */ protected function get_course_and_cat_sql(\core\event\base $event): string { - global $DB; - $courseid = 'null'; $catid = 'null'; - $sql = " - SELECT ctx.instanceid, cm.course as courseid, ctx.contextlevel - FROM {question} q - INNER JOIN {question_categories} qc ON qc.id = q.category - INNER JOIN {context} ctx ON ctx.id = qc.contextid - LEFT JOIN {course_modules} cm ON cm.id = ctx.instanceid AND ctx.contextlevel = :coursemodule - WHERE q.id = :refid - "; - $params = [ - 'coursemodule' => CONTEXT_MODULE, - 'refid' => $event->objectid, - ]; - - if ($record = $DB->get_record_sql($sql, $params)) { + if ($record = self::get_course_and_category(CONTEXT_MODULE, $event->objectid)) { if ($record->contextlevel == CONTEXT_MODULE) { $courseid = $record->courseid; } else if ($record->contextlevel == CONTEXT_COURSE) { @@ -182,4 +191,37 @@ abstract class base extends area_base { {$catid} AS categoryid, "; } + + /** + * Get the course and category data for the question. + * + * @param int $coursemodule + * @param int $refid + * @return \stdClass|false + */ + public static function get_course_and_category($coursemodule, $refid) { + global $DB; + + $sql = 'SELECT ctx.instanceid, + cm.course as courseid, + ctx.contextlevel + FROM {question} q + INNER JOIN {question_versions} qv + ON qv.questionid = q.id + INNER JOIN {question_bank_entries} qbe + ON qbe.id = qv.questionbankentryid + INNER JOIN {question_categories} qc + ON qc.id = qbe.questioncategoryid + INNER JOIN {context} ctx + ON ctx.id = qc.contextid + LEFT JOIN {course_modules} cm + ON cm.id = ctx.instanceid + AND ctx.contextlevel = :coursemodule + WHERE q.id = :refid'; + $params = [ + 'coursemodule' => $coursemodule, + 'refid' => $refid + ]; + return $DB->get_record_sql($sql, $params); + } } diff --git a/admin/tool/brickfield/tests/local/areas/core_question/questiontext_test.php b/admin/tool/brickfield/tests/local/areas/core_question/questiontext_test.php index ad21c5ddd03..4e90f2f932d 100644 --- a/admin/tool/brickfield/tests/local/areas/core_question/questiontext_test.php +++ b/admin/tool/brickfield/tests/local/areas/core_question/questiontext_test.php @@ -28,6 +28,7 @@ use tool_brickfield\area_test_base; * @package tool_brickfield * @copyright 2020 onward: Brickfield Education Labs, https://www.brickfield.ie * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @coversDefaultClass \tool_brickfield\local\areas\core_question\base */ class questiontext_test extends area_test_base { /** @@ -190,4 +191,32 @@ class questiontext_test extends area_test_base { $category->id ); } + + /** + * Test get course and category. + * + * @covers ::get_course_and_category + */ + public function test_get_course_and_category() { + $this->resetAfterTest(); + $this->setAdminUser(); + + $course = $this->getDataGenerator()->create_course(); + $coursecontext = \context_course::instance($course->id); + $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); + $cat1 = $generator->create_question_category(['contextid' => $coursecontext->id]); + $question1 = $generator->create_question('multichoice', null, ['category' => $cat1->id]); + $event = \core\event\question_updated::create_from_question_instance($question1, + \context_course::instance($course->id)); + $rs = base::get_course_and_category(CONTEXT_COURSE, $event->objectid); + $this->assertNotNull($rs); + $this->assertEquals(CONTEXT_COURSE, $rs->contextlevel); + $this->assertNotEquals(CONTEXT_MODULE, $rs->contextlevel); + // Invalid objectid. + $rs = base::get_course_and_category(CONTEXT_COURSE, 0); + $this->assertFalse($rs); + // Incorrect objectid. + $rs = base::get_course_and_category(CONTEXT_COURSE, 100); + $this->assertFalse($rs); + } } From c6cfca2a08d1ae9c713ee3fa53c504c94810d17d Mon Sep 17 00:00:00 2001 From: Guillermo Gomez Date: Tue, 3 Aug 2021 16:25:06 +1000 Subject: [PATCH 4/8] MDL-71696 core_question: Changes for versioning This commit adds the changes in questiontype base to work with new question tables and the new structure in the databse. Also needed for versioning. This implementation will also introduct the question status which allows a question to be in draft and ready status. I also introduces changes to the base view where it shows the latest version of the questions. The view of versions for a question is not implemented in this commit. This implementation will also introduce changes in the core qtype plugins to support versioning and the changed db schema. --- admin/qtypes.php | 46 +- lang/en/question.php | 3 +- lib/db/renamedclasses.php | 1 - lib/questionlib.php | 1666 ++++++++++------- lib/tests/questionlib_test.php | 285 ++- lib/upgrade.txt | 46 + mod/quiz/attemptlib.php | 3 +- mod/quiz/classes/output/edit_renderer.php | 22 +- .../classes/question/bank/custom_view.php | 4 +- .../bank/question_name_text_column.php | 2 +- mod/quiz/classes/structure.php | 10 +- mod/quiz/editrandom.php | 10 +- mod/quiz/lib.php | 2 +- mod/quiz/locallib.php | 22 +- .../local_structure_slot_random_test.php | 4 +- .../tests/quiz_question_bank_view_test.php | 2 +- question/bank/bulkmove/tests/helper_test.php | 2 +- .../tests/behat/behat_qbank_comment.php | 27 +- .../tests/behat/question_comment.feature | 5 +- .../customfields/tests/customfield_test.php | 9 +- .../classes/delete_action_column.php | 4 +- question/bank/deletequestion/delete.php | 6 +- .../behat/delete_question_column.feature | 3 +- .../classes/editquestion_helper.php | 34 + .../editquestion/classes/output/renderer.php | 10 + .../editquestion/classes/plugin_feature.php | 3 +- .../classes/question_status_column.php | 56 + .../lang/en/qbank_editquestion.php | 11 + question/bank/editquestion/question.php | 19 +- .../templates/question_info.mustache} | 27 +- question/bank/exporttoxml/exportone.php | 2 +- question/bank/managecategories/category.php | 3 +- .../bank/managecategories/classes/helper.php | 51 +- .../classes/question_category_object.php | 34 +- .../managecategories/tests/helper_test.php | 48 +- .../tests/question_category_object_test.php | 2 +- .../classes/external/submit_tags.php | 10 +- question/bank/tagquestion/lib.php | 10 +- .../classes/modifier_name_column.php | 74 - .../viewcreator/classes/output/renderer.php | 6 +- .../viewcreator/classes/plugin_feature.php | 3 +- .../viewcreator/lang/en/qbank_viewcreator.php | 3 +- question/bank/viewcreator/lib.php | 46 + .../templates/version_info.mustache | 37 + .../behat/view_view_creator_column.feature | 2 - .../question_name_idnumber_tags_column.php | 4 +- .../classes/question_text_row.php | 6 +- .../bank/search/category_condition.php | 2 +- .../classes/bank/search/hidden_condition.php | 5 +- question/classes/external.php | 4 +- .../lib/context_to_string_translator.php | 104 + .../classes/lib/question_edit_contexts.php | 233 +++ .../classes/local/bank/action_column_base.php | 4 +- .../local/bank/random_question_loader.php | 24 +- question/classes/local/bank/view.php | 18 +- question/editlib.php | 51 +- question/engine/bank.php | 88 +- question/engine/tests/helpers.php | 11 +- question/format.php | 33 +- question/format/multianswer/format.php | 4 + question/format/xml/format.php | 2 +- .../tests/qformat_xml_import_export_test.php | 8 +- question/format/xml/tests/xmlformat_test.php | 19 +- question/lib.php | 10 +- question/tests/backup_test.php | 14 +- question/tests/bank_view_test.php | 4 +- .../tests/behat/duplicate_questions.feature | 1 - question/tests/behat/edit_questions.feature | 4 +- question/tests/event/events_test.php | 11 +- question/tests/generator/lib.php | 43 +- question/tests/generator_test.php | 2 +- question/tests/privacy/provider_test.php | 30 +- question/tests/question_bank_column_test.php | 4 +- .../tests/random_question_loader_test.php | 2 +- question/tests/version_test.php | 256 +++ question/type/calculated/tests/helper.php | 3 + .../calculated/tests/questiontype_test.php | 6 +- .../type/calculatedsimple/tests/helper.php | 5 +- .../tests/questiontype_test.php | 2 +- .../ddimageortext/tests/edit_form_test.php | 2 +- question/type/ddimageortext/tests/helper.php | 2 + .../type/ddmarker/tests/edit_form_test.php | 2 +- question/type/ddmarker/tests/helper.php | 4 + question/type/ddwtos/tests/edit_form_test.php | 2 +- question/type/ddwtos/tests/helper.php | 4 + .../type/ddwtos/tests/questiontype_test.php | 9 +- question/type/description/tests/helper.php | 4 +- .../description/tests/questiontype_test.php | 2 +- question/type/edit_question_form.php | 82 +- question/type/essay/tests/helper.php | 3 + question/type/essay/tests/restore_test.php | 4 +- .../type/gapselect/tests/edit_form_test.php | 2 +- question/type/gapselect/tests/helper.php | 11 +- .../gapselect/tests/questiontype_test.php | 3 +- question/type/match/tests/helper.php | 18 +- .../type/match/tests/questiontype_test.php | 9 +- question/type/missingtype/questiontype.php | 3 +- .../missingtype/tests/missingtype_test.php | 6 +- question/type/multianswer/questiontype.php | 28 +- question/type/multianswer/tests/helper.php | 7 + .../multianswer/tests/questiontype_test.php | 30 +- question/type/multichoice/tests/helper.php | 19 +- .../multichoice/tests/questiontype_test.php | 3 +- question/type/numerical/tests/helper.php | 5 +- question/type/questionbase.php | 18 +- question/type/questiontypebase.php | 149 +- .../classes/task/remove_unused_questions.php | 69 - question/type/random/db/tasks.php | 38 - question/type/random/questiontype.php | 19 +- .../type/random/tests/cleanup_task_test.php | 70 - question/type/random/tests/helper.php | 1 + .../type/random/tests/questiontype_test.php | 6 +- question/type/random/version.php | 2 +- question/type/shortanswer/tests/helper.php | 3 + .../shortanswer/tests/questiontype_test.php | 2 +- question/type/truefalse/tests/helper.php | 3 +- .../truefalse/tests/questiontype_test.php | 6 +- question/upgrade.txt | 1 - 118 files changed, 2946 insertions(+), 1397 deletions(-) create mode 100644 question/bank/editquestion/classes/question_status_column.php rename question/bank/{viewcreator/templates/modifier_display.mustache => editquestion/templates/question_info.mustache} (71%) delete mode 100644 question/bank/viewcreator/classes/modifier_name_column.php create mode 100644 question/bank/viewcreator/lib.php create mode 100644 question/bank/viewcreator/templates/version_info.mustache create mode 100644 question/classes/lib/context_to_string_translator.php create mode 100644 question/classes/lib/question_edit_contexts.php create mode 100644 question/tests/version_test.php delete mode 100644 question/type/random/classes/task/remove_unused_questions.php delete mode 100644 question/type/random/db/tasks.php delete mode 100644 question/type/random/tests/cleanup_task_test.php diff --git a/admin/qtypes.php b/admin/qtypes.php index cb53268f509..954f89bc18a 100644 --- a/admin/qtypes.php +++ b/admin/qtypes.php @@ -42,25 +42,55 @@ $qtypes = question_bank::get_all_qtypes(); $pluginmanager = core_plugin_manager::instance(); // Get some data we will need - question counts and which types are needed. -$counts = $DB->get_records_sql(" - SELECT qtype, COUNT(1) as numquestions, SUM(hidden) as numhidden - FROM {question} GROUP BY qtype", array()); -$needed = array(); +$hiddenstatus = \core_question\local\bank\constants::QUESTION_STATUS_HIDDEN; +$draftstatus = \core_question\local\bank\constants::QUESTION_STATUS_DRAFT; + +$sql = "SELECT result.qtype, + SUM(result.numquestions) AS numquestions, + SUM(result.numhidden) AS numhidden, + SUM(result.numdraft) AS numdraft + FROM ( + SELECT data.qtype, + COUNT(data.numquestions) AS numquestions, + (SELECT COUNT(qv.id) + FROM {question_versions} qv + WHERE qv.id = data.versionid + AND qv.status = :hiddenstatus) AS numhidden, + (SELECT COUNT(qv.id) + FROM {question_versions} qv + WHERE qv.id = data.versionid + AND qv.status = :draftstatus) AS numdraft + FROM ( + SELECT q.qtype, qv.id AS versionid, 1 AS numquestions + FROM {question} q + JOIN {question_versions} qv ON qv.questionid = q.id + JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid + AND qv.version = (SELECT MAX(v.version) + FROM {question_versions} v + JOIN {question_bank_entries} be ON be.id = v.questionbankentryid + WHERE be.id = qbe.id)) data + GROUP BY data.qtype, data.versionid) result + GROUP BY result.qtype"; + +$counts = $DB->get_records_sql($sql, ['hiddenstatus' => $hiddenstatus, 'draftstatus' => $draftstatus]); +$needed = []; foreach ($qtypes as $qtypename => $qtype) { if (!isset($counts[$qtypename])) { $counts[$qtypename] = new stdClass; $counts[$qtypename]->numquestions = 0; $counts[$qtypename]->numhidden = 0; + $counts[$qtypename]->numdraft = 0; } $needed[$qtypename] = $counts[$qtypename]->numquestions > 0 || $pluginmanager->other_plugins_that_require($qtype->plugin_name()); - $counts[$qtypename]->numquestions -= $counts[$qtypename]->numhidden; + $counts[$qtypename]->numquestions -= ($counts[$qtypename]->numhidden + $counts[$qtypename]->numdraft); } $needed['missingtype'] = true; // The system needs the missing question type. foreach ($counts as $qtypename => $count) { if (!isset($qtypes[$qtypename])) { - $counts['missingtype']->numquestions += $count->numquestions - $count->numhidden; + $counts['missingtype']->numquestions += $count->numquestions - ($count->numhidden + $count->numdraft); $counts['missingtype']->numhidden += $count->numhidden; + $counts['missingtype']->numdraft += $count->numdraft; } } @@ -153,8 +183,8 @@ foreach ($sortedqtypes as $qtypename => $localname) { $row[] = $icon . ' ' . $localname; // Number of questions of this type. - if ($counts[$qtypename]->numquestions + $counts[$qtypename]->numhidden > 0) { - if ($counts[$qtypename]->numhidden > 0) { + if ($counts[$qtypename]->numquestions + $counts[$qtypename]->numhidden + $counts[$qtypename]->numdraft > 0) { + if ($counts[$qtypename]->numhidden + $counts[$qtypename]->numdraft > 0) { $strcount = get_string('numquestionsandhidden', 'question', $counts[$qtypename]); } else { $strcount = $counts[$qtypename]->numquestions; diff --git a/lang/en/question.php b/lang/en/question.php index cc375597e02..7d9d054a2b7 100644 --- a/lang/en/question.php +++ b/lang/en/question.php @@ -257,7 +257,7 @@ $string['notflagged'] = 'Not flagged'; $string['novirtualquestiontype'] = 'No virtual question type for question type {$a}'; $string['numqas'] = 'No. question attempts'; $string['numquestions'] = 'No. questions'; -$string['numquestionsandhidden'] = '{$a->numquestions} (+{$a->numhidden} hidden)'; +$string['numquestionsandhidden'] = '{$a->numquestions} (+{$a->numhidden} hidden +{$a->numdraft} draft)'; $string['page-question-x'] = 'Any question page'; $string['page-question-edit'] = 'Question editing page'; $string['page-question-category'] = 'Question category page'; @@ -498,3 +498,4 @@ $string['xoutofmax'] = '{$a->mark} out of {$a->max}'; $string['yougotnright'] = 'You have correctly selected {$a->num}.'; $string['qbanknotfound'] = 'The \'{$a}\' question bank plugin doesn\'t exist or is not recognised.'; $string['noquestionbanks'] = 'No question bank plugin found.'; +$string['questionloaderror'] = 'Could not load the question options.'; diff --git a/lib/db/renamedclasses.php b/lib/db/renamedclasses.php index f5eedc44266..d44d2cd7cf7 100644 --- a/lib/db/renamedclasses.php +++ b/lib/db/renamedclasses.php @@ -61,7 +61,6 @@ $renamedclasses = [ 'core_question\\bank\\copy_action_column' => 'qbank_editquestion\\copy_action_column', 'core_question\\bank\\edit_action_column' => 'qbank_editquestion\\edit_action_column', 'core_question\\bank\\creator_name_column' => 'qbank_viewcreator\\creator_name_column', - 'core_question\\bank\\modifier_name_column' => 'qbank_viewcreator\\modifier_name_column', 'core_question\\bank\\question_name_column' => 'qbank_viewquestionname\\viewquestionname_column_helper', 'core_question\\bank\\question_name_idnumber_tags_column' => 'qbank_viewquestionname\\question_name_idnumber_tags_column', 'core_question\\bank\\delete_action_column' => 'qbank_deletequestion\\delete_action_column', diff --git a/lib/questionlib.php b/lib/questionlib.php index ec1853e2318..541e4505449 100644 --- a/lib/questionlib.php +++ b/lib/questionlib.php @@ -15,20 +15,17 @@ // along with Moodle. If not, see . /** - * Code for handling and processing questions + * Code for handling and processing questions. * * This is code that is module independent, i.e., can be used by any module that - * uses questions, like quiz, lesson, .. - * This script also loads the questiontype classes - * Code for handling the editing of questions is in {@link question/editlib.php} + * uses questions, like quiz, lesson, etc. + * This script also loads the questiontype classes. + * Code for handling the editing of questions is in question/editlib.php * - * TODO: separate those functions which form part of the API - * from the helper functions. - * - * @package moodlecore + * @package core * @subpackage questionbank - * @copyright 1999 onwards Martin Dougiamas and others {@link http://moodle.com} - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @copyright 1999 onwards Martin Dougiamas and others {@link http://moodle.com} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ @@ -39,7 +36,7 @@ require_once($CFG->dirroot . '/question/type/questiontypebase.php'); -/// CONSTANTS /////////////////////////////////// +// CONSTANTS. /** * Constant determines the number of answer boxes supplied in the editing @@ -72,7 +69,7 @@ define("QUESTION_NUMANS_ADD", 3); * the $qtypes in the same order as $sortedqtypes, except that $tomove will * have been moved one place. */ -function question_reorder_qtypes($sortedqtypes, $tomove, $direction) { +function question_reorder_qtypes($sortedqtypes, $tomove, $direction): array { $neworder = array_keys($sortedqtypes); // Find the element to move. $key = array_search($tomove, $neworder); @@ -93,13 +90,11 @@ function question_reorder_qtypes($sortedqtypes, $tomove, $direction) { /** * Save a new question type order to the config_plugins table. - * @global object - * @param $neworder An arra $index => $qtype. Indices should start at 0 and be in order. - * @param $config get_config('question'), if you happen to have it around, to save one DB query. + * + * @param array $neworder An arra $index => $qtype. Indices should start at 0 and be in order. + * @param object $config get_config('question'), if you happen to have it around, to save one DB query. */ -function question_save_qtype_order($neworder, $config = null) { - global $DB; - +function question_save_qtype_order($neworder, $config = null): void { if (is_null($config)) { $config = get_config('question'); } @@ -112,13 +107,15 @@ function question_save_qtype_order($neworder, $config = null) { } } -/// FUNCTIONS ////////////////////////////////////////////////////// +// FUNCTIONS. /** + * Check if the question is used. + * * @param array $questionids of question ids. * @return boolean whether any of these questions are being used by any part of Moodle. */ -function questions_in_use($questionids) { +function questions_in_use($questionids): bool { // Are they used by the core question system? if (question_engine::questions_in_use($questionids)) { @@ -156,7 +153,7 @@ function questions_in_use($questionids) { } /** - * Determine whether there arey any questions belonging to this context, that is whether any of its + * Determine whether there are any questions belonging to this context, that is whether any of its * question categories contain any questions. This will return true even if all the questions are * hidden. * @@ -164,34 +161,36 @@ function questions_in_use($questionids) { * @return boolean whether any of the question categories beloning to this context have * any questions in them. */ -function question_context_has_any_questions($context) { +function question_context_has_any_questions($context): bool { global $DB; if (is_object($context)) { $contextid = $context->id; } else if (is_numeric($context)) { $contextid = $context; } else { - print_error('invalidcontextinhasanyquestions', 'question'); + throw new moodle_exception('invalidcontextinhasanyquestions', 'question'); } - return $DB->record_exists_sql("SELECT * - FROM {question} q - JOIN {question_categories} qc ON qc.id = q.category - WHERE qc.contextid = ? AND q.parent = 0", array($contextid)); + $sql = 'SELECT qbe.* + FROM {question_bank_entry} qbe + JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid + WHERE qc.contextid = ?'; + return $DB->record_exists_sql($sql, [$contextid]); } /** * Check whether a given grade is one of a list of allowed options. If not, * depending on $matchgrades, either return the nearest match, or return false * to signal an error. + * * @param array $gradeoptionsfull list of valid options * @param int $grade grade to be tested * @param string $matchgrades 'error' or 'nearest' - * @return mixed either 'fixed' value or false if error. + * @return false|int|string either 'fixed' value or false if error. */ function match_grade_options($gradeoptionsfull, $grade, $matchgrades = 'error') { if ($matchgrades == 'error') { - // (Almost) exact match, or an error. + // ...(Almost) exact match, or an error. foreach ($gradeoptionsfull as $value => $option) { // Slightly fuzzy test, never check floats for equality. if (abs($grade - $value) < 0.00001) { @@ -202,7 +201,7 @@ function match_grade_options($gradeoptionsfull, $grade, $matchgrades = 'error') return false; } else if ($matchgrades == 'nearest') { - // Work out nearest value + // Work out nearest value. $best = false; $bestmismatch = 2; foreach ($gradeoptionsfull as $value => $option) { @@ -221,35 +220,6 @@ function match_grade_options($gradeoptionsfull, $grade, $matchgrades = 'error') } } -/** - * Remove stale questions from a category. - * - * While questions should not be left behind when they are not used any more, - * it does happen, maybe via restore, or old logic, or uncovered scenarios. When - * this happens, the users are unable to delete the question category unless - * they move those stale questions to another one category, but to them the - * category is empty as it does not contain anything. The purpose of this function - * is to detect the questions that may have gone stale and remove them. - * - * You will typically use this prior to checking if the category contains questions. - * - * The stale questions (unused and hidden to the user) handled are: - * - hidden questions - * - random questions - * - * @param int $categoryid The category ID. - * @deprecated since Moodle 4.0 MDL-71585 - * @see qbank_managecategories\helper - * @todo Final deprecation on Moodle 4.4 MDL-72438 - */ -function question_remove_stale_questions_from_category($categoryid) { - debugging('Function question_remove_stale_questions_from_category() - has been deprecated and moved to qbank_managecategories plugin, - Please use qbank_managecategories\helper::question_remove_stale_questions_from_category() instead.', - DEBUG_DEVELOPER); - \qbank_managecategories\helper::question_remove_stale_questions_from_category($categoryid); -} - /** * Category is about to be deleted, * 1/ All questions are deleted for this question category. @@ -258,18 +228,23 @@ function question_remove_stale_questions_from_category($categoryid) { * * @param object|core_course_category $category course category object */ -function question_category_delete_safe($category) { +function question_category_delete_safe($category): void { global $DB; - $criteria = array('category' => $category->id); + $criteria = ['questioncategoryid' => $category->id]; $context = context::instance_by_id($category->contextid, IGNORE_MISSING); $rescue = null; // See the code around the call to question_save_from_deletion. // Deal with any questions in the category. - if ($questions = $DB->get_records('question', $criteria, '', 'id,qtype')) { + if ($questionentries = $DB->get_records('question_bank_entry', $criteria, '', 'id')) { - // Try to delete each question. - foreach ($questions as $question) { - question_delete_question($question->id); + foreach ($questionentries as $questionentry) { + $questionids = $DB->get_records('question_versions', + ['questionbankentryid' => $questionentry->id], '', 'questionid'); + + // Try to delete each question. + foreach ($questionids as $questionid) { + question_delete_question($questionid->questionid, $category->contextid); + } } // Check to see if there were any questions that were kept because @@ -277,7 +252,13 @@ function question_category_delete_safe($category) { // in this category will already have been deleted. This could // happen, for example, if questions are added to a course, // and then that course is moved to another category (MDL-14802). - $questionids = $DB->get_records_menu('question', $criteria, '', 'id, 1'); + $questionids = []; + foreach ($questionentries as $questionentry) { + $versions = $DB->get_records('question_versions', ['questionbankentryid' => $questionentry->id], '', 'questionid'); + foreach ($versions as $key => $version) { + $questionids[$key] = $version; + } + } if (!empty($questionids)) { $parentcontextid = SYSCONTEXTID; $name = get_string('unknown', 'question'); @@ -293,7 +274,7 @@ function question_category_delete_safe($category) { } // Now delete the category. - $DB->delete_records('question_categories', array('id' => $category->id)); + $DB->delete_records('question_categories', ['id' => $category->id]); } /** @@ -303,13 +284,13 @@ function question_category_delete_safe($category) { * @param boolean $recursive whether to check child categories too. * @return boolean whether any question in this category is in use. */ -function question_category_in_use($categoryid, $recursive = false) { +function question_category_in_use($categoryid, $recursive = false): bool { global $DB; - //Look at each question in the category - if ($questions = $DB->get_records_menu('question', - array('category' => $categoryid), '', 'id, 1')) { - if (questions_in_use(array_keys($questions))) { + // Look at each question in the category. + $questionids = question_bank::get_finder()->get_questions_from_categories([$categoryid], null); + if ($questionids) { + if (questions_in_use(array_keys($questionids))) { return true; } } @@ -317,9 +298,9 @@ function question_category_in_use($categoryid, $recursive = false) { return false; } - //Look under child categories recursively + // Look under child categories recursively. if ($children = $DB->get_records('question_categories', - array('parent' => $categoryid), '', 'id, 1')) { + ['parent' => $categoryid], '', 'id, 1')) { foreach ($children as $child) { if (question_category_in_use($child->id, $recursive)) { return true; @@ -330,22 +311,30 @@ function question_category_in_use($categoryid, $recursive = false) { return false; } +/** + * Check if there is more versions left for the entry. + * If not delete the entry. + * + * @param int $entryid + */ +function delete_question_bank_entry($entryid): void { + global $DB; + if (!$DB->record_exists('question_versions', ['questionbankentryid' => $entryid])) { + $DB->delete_records('question_bank_entries', ['id' => $entryid]); + } +} + /** * Deletes question and all associated data from the database * - * It will not delete a question if it is used somewhere. + * It will not delete a question if it is used somewhere, instead it will just delete the reference. * - * @param object $question The question being deleted + * @param int $questionid The id of the question being deleted */ -function question_delete_question($questionid) { +function question_delete_question($questionid): void { global $DB; - $question = $DB->get_record_sql(' - SELECT q.*, ctx.id AS contextid - FROM {question} q - LEFT JOIN {question_categories} qc ON qc.id = q.category - LEFT JOIN {context} ctx ON ctx.id = qc.contextid - WHERE q.id = ?', array($questionid)); + $question = $DB->get_record('question', ['id' => $questionid]); if (!$question) { // In some situations, for example if this was a child of a // Cloze question that was previously deleted, the question may already @@ -353,26 +342,38 @@ function question_delete_question($questionid) { return; } + $sql = 'SELECT qv.id as versionid, + qv.version, + qbe.id as entryid, + qc.id as categoryid, + qc.contextid as contextid + FROM {question} q + LEFT JOIN {question_versions} qv ON qv.questionid = q.id + LEFT JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid + LEFT JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid + WHERE q.id = ?'; + $questiondata = $DB->get_record_sql($sql, [$question->id]); + // Do not delete a question if it is used by an activity module - if (questions_in_use(array($questionid))) { + if (questions_in_use([$questionid])) { return; } // This sometimes happens in old sites with bad data. - if (!$question->contextid) { + if (!$questiondata->contextid) { debugging('Deleting question ' . $question->id . ' which is no longer linked to a context. ' . - 'Assuming system context to avoid errors, but this may mean that some data like files, ' . - 'tags, are not cleaned up.'); - $question->contextid = context_system::instance()->id; + 'Assuming system context to avoid errors, but this may mean that some data like files, ' . + 'tags, are not cleaned up.'); + $questiondata->contextid = context_system::instance()->id; + $questiondata->categoryid = 0; } // Delete previews of the question. $dm = new question_engine_data_mapper(); - $dm->delete_previews($questionid); + $dm->delete_previews($question->id); - // delete questiontype-specific data - question_bank::get_qtype($question->qtype, false)->delete_question( - $questionid, $question->contextid); + // Delete questiontype-specific data. + question_bank::get_qtype($question->qtype, false)->delete_question($question->id, $questiondata->contextid); // Delete all tag instances. core_tag_tag::remove_all_item_tags('core_question', 'question', $question->id); @@ -394,11 +395,20 @@ function question_delete_question($questionid) { // Delete question comments. $DB->delete_records('comments', ['itemid' => $questionid, 'component' => 'qbank_comment', 'commentarea' => 'question']); - // Finally delete the question record itself - $DB->delete_records('question', array('id' => $questionid)); - question_bank::notify_question_edited($questionid); + // Finally delete the question record itself. + $DB->delete_records('question', ['id' => $question->id]); + $DB->delete_records('question_versions', ['id' => $questiondata->versionid]); + $DB->delete_records('question_references', + [ + 'version' => $questiondata->version, + 'questionbankentryid' => $questiondata->entryid, + ]); + delete_question_bank_entry($questiondata->entryid); + question_bank::notify_question_edited($question->id); // Log the deletion of this question. + $question->category = $questiondata->categoryid; + $question->contextid = $questiondata->contextid; $event = \core\event\question_deleted::create_from_question_instance($question); $event->add_record_snapshot('question', $question); $event->trigger(); @@ -410,15 +420,13 @@ function question_delete_question($questionid) { * @param int $contextid The contextid to delete question categories from * @return array only returns an empty array for backwards compatibility. */ -function question_delete_context($contextid) { +function question_delete_context($contextid): array { global $DB; $fields = 'id, parent, name, contextid'; - if ($categories = $DB->get_records('question_categories', array('contextid' => $contextid), 'parent', $fields)) { - //Sort categories following their tree (parent-child) relationships - //this will make the feedback more readable + if ($categories = $DB->get_records('question_categories', ['contextid' => $contextid], 'parent', $fields)) { + // Sort categories following their tree (parent-child) relationships this will make the feedback more readable. $categories = sort_categories_by_tree($categories); - foreach ($categories as $category) { question_category_delete_safe($category); } @@ -433,7 +441,7 @@ function question_delete_context($contextid) { * @param bool $notused this argument is not used any more. Kept for backwards compatibility. * @return bool always true. */ -function question_delete_course($course, $notused = false) { +function question_delete_course($course, $notused = false): bool { $coursecontext = context_course::instance($course->id); question_delete_context($coursecontext->id); return true; @@ -450,7 +458,7 @@ function question_delete_course($course, $notused = false) { * @param bool $notused this argument is no longer used. Kept for backwards compatibility. * @return boolean */ -function question_delete_course_category($category, $newcategory, $notused=false) { +function question_delete_course_category($category, $newcategory, $notused=false): bool { global $DB; $context = context_coursecat::instance($category->id); @@ -468,9 +476,9 @@ function question_delete_course_category($category, $newcategory, $notused=false $newtopcategory = question_get_top_category($newcontext->id, true); question_move_category_to_context($topcategory->id, $context->id, $newcontext->id); - $DB->set_field('question_categories', 'parent', $newtopcategory->id, array('parent' => $topcategory->id)); + $DB->set_field('question_categories', 'parent', $newtopcategory->id, ['parent' => $topcategory->id]); // Now delete the top category. - $DB->delete_records('question_categories', array('id' => $topcategory->id)); + $DB->delete_records('question_categories', ['id' => $topcategory->id]); } } @@ -478,7 +486,7 @@ function question_delete_course_category($category, $newcategory, $notused=false } /** - * Enter description here... + * Creates a new category to save the questions in use. * * @param array $questionids of question ids * @param object $newcontextid the context to create the saved category in. @@ -487,8 +495,7 @@ function question_delete_course_category($category, $newcategory, $notused=false * @param object $newcategory * @return mixed false on */ -function question_save_from_deletion($questionids, $newcontextid, $oldplace, - $newcategory = null) { +function question_save_from_deletion($questionids, $newcontextid, $oldplace, $newcategory = null) { global $DB; // Make a category in the parent context to move the questions to. @@ -518,9 +525,7 @@ function question_save_from_deletion($questionids, $newcontextid, $oldplace, * @param bool $notused the argument is not used any more. Kept for backwards compatibility. * @return boolean */ -function question_delete_activity($cm, $notused = false) { - global $DB; - +function question_delete_activity($cm, $notused = false): bool { $modcontext = context_module::instance($cm->id); question_delete_context($modcontext->id); return true; @@ -551,7 +556,7 @@ function question_delete_activity($cm, $notused = false) { * the id and contextid) * @param context $newcontext The Moodle context the questions are being moved to */ -function question_move_question_tags_to_new_context(array $questions, context $newcontext) { +function question_move_question_tags_to_new_context(array $questions, context $newcontext): void { // If the questions are moving to a new course/activity context then we need to // find any existing tag instances from any unavailable course contexts and // delete them because they will no longer be applicable (we don't support @@ -640,6 +645,38 @@ function question_move_question_tags_to_new_context(array $questions, context $n } } +/** + * Check if an idnumber exist in the category. + * + * @param int $questionidnumber + * @param int $categoryid + * @param int $limitfrom + * @param int $limitnum + * @return array + */ +function idnumber_exist_in_question_category($questionidnumber, $categoryid, $limitfrom = 0, $limitnum = 1): array { + global $DB; + $response = false; + $record = []; + // Check if the idnumber exist in the category. + $sql = 'SELECT q.id, qbe.idnumber + FROM {question} q + JOIN {question_versions} qv + ON qv.questionid = q.id + JOIN {question_bank_entry} qbe + ON qbe.id = qv.questionbankentryid + WHERE qbe.idnumber LIKE ? + AND qbe.questioncategoryid = ? + ORDER BY qbe.idnumber DESC'; + $questionrecord = $DB->record_exists_sql($sql, [$questionidnumber, $categoryid]); + if ((string) $questionidnumber !== '' && $questionrecord) { + $record = $DB->get_records_sql($sql, [$questionidnumber . '_%', $categoryid], 0, 1); + $response = true; + } + + return [$response, $record]; +} + /** * This function should be considered private to the question bank, it is called from * question/editlib.php question/contextmoveq.php and a few similar places to to the @@ -649,28 +686,42 @@ function question_move_question_tags_to_new_context(array $questions, context $n * * @param array $questionids of question ids. * @param integer $newcategoryid the id of the category to move to. + * @return bool */ -function question_move_questions_to_category($questionids, $newcategoryid) { +function question_move_questions_to_category($questionids, $newcategoryid): bool { global $DB; - $newcontextid = $DB->get_field('question_categories', 'contextid', - array('id' => $newcategoryid)); + $newcategorydata = $DB->get_record('question_categories', ['id' => $newcategoryid]); + if (!$newcategorydata) { + return false; + } list($questionidcondition, $params) = $DB->get_in_or_equal($questionids); - $questions = $DB->get_records_sql(" - SELECT q.id, q.qtype, qc.contextid, q.idnumber, q.category + + $sql = "SELECT qv.id as versionid, + qbe.id as entryid, + qc.id as category, + qc.contextid as contextid, + q.id, + q.qtype, + qbe.idnumber FROM {question} q - JOIN {question_categories} qc ON q.category = qc.id - WHERE q.id $questionidcondition", $params); + JOIN {question_versions} qv ON qv.questionid = q.id + JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid + JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid + WHERE q.id $questionidcondition + OR (q.parent <> 0 AND q.parent $questionidcondition)"; + + // Also, we need to move children questions. + $params = array_merge($params, $params); + $questions = $DB->get_records_sql($sql, $params); foreach ($questions as $question) { - if ($newcontextid != $question->contextid) { + if ($newcategorydata->contextid != $question->contextid) { question_bank::get_qtype($question->qtype)->move_files( - $question->id, $question->contextid, $newcontextid); + $question->id, $question->contextid, $newcategorydata->contextid); } // Check whether there could be a clash of idnumbers in the new category. - if (((string) $question->idnumber !== '') && - $DB->record_exists('question', ['idnumber' => $question->idnumber, 'category' => $newcategoryid])) { - $rec = $DB->get_records_select('question', "category = ? AND idnumber LIKE ?", - [$newcategoryid, $question->idnumber . '_%'], 'idnumber DESC', 'id, idnumber', 0, 1); + list($idnumberclash, $rec) = idnumber_exist_in_question_category($question->idnumber, $newcategoryid); + if ($idnumberclash) { $unique = 1; if (count($rec)) { $rec = reset($rec); @@ -681,28 +732,38 @@ function question_move_questions_to_category($questionids, $newcategoryid) { } // For the move process, add a numerical increment to the idnumber. This means that if a question is // mistakenly moved then the idnumber will not be completely lost. - $q = new stdClass(); - $q->id = $question->id; - $q->category = $newcategoryid; - $q->idnumber = $question->idnumber . '_' . $unique; - $DB->update_record('question', $q); + $qbankentry = new stdClass(); + $qbankentry->id = $question->entryid; + $qbankentry->idnumber = $question->idnumber . '_' . $unique; + $DB->update_record('question_bank_entry', $qbankentry); } + // TODO: Now is commented because we are deciding if we should have an area: core_question. + // Update the reference to point to the new category context. + /*$references = $DB->get_records('question_references', + [ + 'questionbankentryid' => $question->entryid, + 'versionid' => $question->versionid + ] + ); + foreach ($references as $reference) { + $reference->usingcontextid = $newcategorydata->contextid; + $DB->update_record('question_references', $reference); + }*/ + + // Update the entry to the new category id. + $entry = new stdClass(); + $entry->id = $question->entryid; + $entry->questioncategoryid = $newcategorydata->id; + $DB->update_record('question_bank_entry', $entry); + // Log this question move. $event = \core\event\question_moved::create_from_question_instance($question, context::instance_by_id($question->contextid), - ['oldcategoryid' => $question->category, 'newcategoryid' => $newcategoryid]); + ['oldcategoryid' => $question->category, 'newcategoryid' => $newcategorydata->id]); $event->trigger(); } - // Move the questions themselves. - $DB->set_field_select('question', 'category', $newcategoryid, - "id $questionidcondition", $params); - - // Move any subquestions belonging to them. - $DB->set_field_select('question', 'category', $newcategoryid, - "parent $questionidcondition", $params); - - $newcontext = context::instance_by_id($newcontextid); + $newcontext = context::instance_by_id($newcategorydata->contextid); question_move_question_tags_to_new_context($questions, $newcontext); // TODO Deal with datasets. @@ -723,15 +784,19 @@ function question_move_questions_to_category($questionids, $newcategoryid) { * @param integer $oldcontextid the old context id. * @param integer $newcontextid the new context id. */ -function question_move_category_to_context($categoryid, $oldcontextid, $newcontextid) { +function question_move_category_to_context($categoryid, $oldcontextid, $newcontextid): void { global $DB; $questions = []; - $questionids = $DB->get_records_menu('question', - array('category' => $categoryid), '', 'id,qtype'); + $sql = "SELECT q.id, q.qtype + FROM {question} q + JOIN {question_versions} qv ON qv.questionid = q.id + JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid + WHERE qbe.questioncategoryid = ?"; + + $questionids = $DB->get_records_sql_menu($sql, [$categoryid]); foreach ($questionids as $questionid => $qtype) { - question_bank::get_qtype($qtype)->move_files( - $questionid, $oldcontextid, $newcontextid); + question_bank::get_qtype($qtype)->move_files($questionid, $oldcontextid, $newcontextid); // Purge this question from the cache. question_bank::notify_question_edited($questionid); @@ -744,52 +809,13 @@ function question_move_category_to_context($categoryid, $oldcontextid, $newconte $newcontext = context::instance_by_id($newcontextid); question_move_question_tags_to_new_context($questions, $newcontext); - $subcatids = $DB->get_records_menu('question_categories', - array('parent' => $categoryid), '', 'id,1'); + $subcatids = $DB->get_records_menu('question_categories', ['parent' => $categoryid], '', 'id,1'); foreach ($subcatids as $subcatid => $notused) { - $DB->set_field('question_categories', 'contextid', $newcontextid, - array('id' => $subcatid)); + $DB->set_field('question_categories', 'contextid', $newcontextid, ['id' => $subcatid]); question_move_category_to_context($subcatid, $oldcontextid, $newcontextid); } } -/** - * Generate the URL for starting a new preview of a given question with the given options. - * @param integer $questionid the question to preview. - * @param string $preferredbehaviour the behaviour to use for the preview. - * @param float $maxmark the maximum to mark the question out of. - * @param question_display_options $displayoptions the display options to use. - * @param int $variant the variant of the question to preview. If null, one will - * be picked randomly. - * @param object $context context to run the preview in (affects things like - * filter settings, theme, lang, etc.) Defaults to $PAGE->context. - * @return moodle_url the URL. - * @deprecated since Moodle 4.0 - * @see qbank_previewquestion\helper::question_preview_url() - * @todo Final deprecation on Moodle 4.4 MDL-72438 - */ -function question_preview_url($questionid, $preferredbehaviour = null, - $maxmark = null, $displayoptions = null, $variant = null, $context = null) { - debugging('Function question_preview_url() has been deprecated and moved to qbank_previewquestion plugin, - Please use qbank_previewquestion\helper::question_preview_url() instead.', DEBUG_DEVELOPER); - - return \qbank_previewquestion\helper::question_preview_url($questionid, $preferredbehaviour = null, - $maxmark = null, $displayoptions = null, $variant = null, $context = null); -} - -/** - * @return array that can be passed as $params to the {@link popup_action} constructor. - * @deprecated since Moodle 4.0 - * @see qbank_previewquestion\helper::question_preview_popup_params() - * @todo Final deprecation on Moodle 4.4 MDL-72438 - */ -function question_preview_popup_params() { - debugging('Function question_preview_popup_params() has been deprecated and moved to qbank_previewquestion plugin, - Please use qbank_previewquestion\helper::question_preview_popup_params() instead.', DEBUG_DEVELOPER); - - return \qbank_previewquestion\helper::question_preview_popup_params(); -} - /** * Given a list of ids, load the basic information about a set of questions from * the questions table. The $join and $extrafields arguments can be used together @@ -807,21 +833,19 @@ function question_preview_popup_params() { * @return array partially complete question objects. You need to call get_question_options * on them before they can be properly used. */ -function question_preload_questions($questionids = null, $extrafields = '', $join = '', - $extraparams = array(), $orderby = '') { +function question_preload_questions($questionids = null, $extrafields = '', $join = '', $extraparams = [], $orderby = ''): array { global $DB; if ($questionids === null) { - $where = ''; - $params = array(); + $extracondition = ''; + $params = []; } else { if (empty($questionids)) { - return array(); + return []; } - list($questionidcondition, $params) = $DB->get_in_or_equal( - $questionids, SQL_PARAMS_NAMED, 'qid0000'); - $where = 'WHERE q.id ' . $questionidcondition; + list($questionidcondition, $params) = $DB->get_in_or_equal($questionids, SQL_PARAMS_NAMED, 'qid0000'); + $extracondition = 'WHERE q.id ' . $questionidcondition; } if ($join) { @@ -836,12 +860,26 @@ function question_preload_questions($questionids = null, $extrafields = '', $joi $orderby = 'ORDER BY ' . $orderby; } - $sql = "SELECT q.*, qc.contextid{$extrafields} + $sql = "SELECT q.id, qc.id as category, q.parent, q.name, q.questiontext, q.questiontextformat, + q.generalfeedback, q.generalfeedbackformat, q.defaultmark, q.penalty, q.qtype, + q.length, q.stamp, q.timecreated, q.timemodified, + q.createdby, q.modifiedby, qbe.idnumber, + qv.status, + qv.id as versionid, + qv.version, + qv.questionbankentryid, + qc.contextid as contextid + {$extrafields} FROM {question} q - JOIN {question_categories} qc ON q.category = qc.id + JOIN {question_versions} qv + ON qv.questionid = q.id + JOIN {question_bank_entry} qbe + ON qbe.id = qv.questionbankentryid + JOIN {question_categories} qc + ON qc.id = qbe.questioncategoryid {$join} - {$where} - {$orderby}"; + {$extracondition} + {$orderby}"; // Load the questions. $questions = $DB->get_records_sql($sql, $extraparams + $params); @@ -860,17 +898,14 @@ function question_preload_questions($questionids = null, $extrafields = '', $joi * @param array $questionids array of question ids. * @param string $extrafields extra SQL code to be added to the query. * @param string $join extra SQL code to be added to the query. - * @param array $extraparams values for any placeholders in $join. - * You are strongly recommended to use named placeholder. - * - * @return array question objects. + * @return array|string question objects. */ function question_load_questions($questionids, $extrafields = '', $join = '') { $questions = question_preload_questions($questionids, $extrafields, $join); - // Load the question type specific information + // Load the question type specific information. if (!get_question_options($questions)) { - return 'Could not load the question options'; + return get_string('questionloaderror', 'question'); } return $questions; @@ -884,15 +919,14 @@ function question_load_questions($questionids, $extrafields = '', $join = '') { * @param stdClass[]|null $tagobjects The tags for the given $question. * @param stdClass[]|null $filtercourses The courses to filter the course tags by. */ -function _tidy_question($question, $category, array $tagobjects = null, array $filtercourses = null) { +function _tidy_question($question, $category, array $tagobjects = null, array $filtercourses = null): void { // Load question-type specific fields. if (!question_bank::is_qtype_installed($question->qtype)) { $question->questiontext = html_writer::tag('p', get_string('warningmissingtype', 'qtype_missingtype')) . $question->questiontext; } - question_bank::get_qtype($question->qtype)->get_question_options($question); - // Convert numeric fields to float. (Prevents these being displayed as 1.0000000.) + // Convert numeric fields to float (Prevents these being displayed as 1.0000000.). $question->defaultmark += 0; $question->penalty += 0; @@ -901,6 +935,7 @@ function _tidy_question($question, $category, array $tagobjects = null, array $f } $question->categoryobject = $category; + question_bank::get_qtype($question->qtype)->get_question_options($question); if (!is_null($tagobjects)) { $categorycontext = context::instance_by_id($category->contextid); @@ -914,7 +949,7 @@ function _tidy_question($question, $category, array $tagobjects = null, array $f /** * Updates the question objects with question type specific - * information by calling {@link get_question_options()} + * information by calling {@see get_question_options()} * * Can be called either with an array of question objects or with a single * question object. @@ -938,9 +973,15 @@ function get_question_options(&$questions, $loadtags = false, $filtercourses = n foreach ($questionlist as $question) { $questionids[] = $question->id; + if (isset($question->category)) { + $qcategoryid = $question->category; + } else { + $qcategoryid = get_question_bank_entry($question->id)->questioncategoryid; + $question->questioncategoryid = $qcategoryid; + } - if (!in_array($question->category, $categoryids)) { - $categoryids[] = $question->category; + if (!in_array($qcategoryid, $categoryids)) { + $categoryids[] = $qcategoryid; } } @@ -958,8 +999,10 @@ function get_question_options(&$questions, $loadtags = false, $filtercourses = n } else { $tagobjects = $tagobjectsbyquestion[$question->id]; } + $qcategoryid = $question->category ?? $question->questioncategoryid ?? + get_question_bank_entry($question->id)->questioncategoryid; - _tidy_question($question, $categories[$question->category], $tagobjects, $filtercourses); + _tidy_question($question, $categories[$qcategoryid], $tagobjects, $filtercourses); } return true; @@ -976,7 +1019,7 @@ function get_question_options(&$questions, $loadtags = false, $filtercourses = n * @param stdClass[]|null $filtercourses The courses to filter the course tags by. * @return stdClass $sortedtagobjects Sorted tag objects. */ -function question_sort_tags($tagobjects, $categorycontext, $filtercourses = null) { +function question_sort_tags($tagobjects, $categorycontext, $filtercourses = null): stdClass { // Questions can have two sets of tag instances. One set at the // course context level and another at the context the question @@ -1055,35 +1098,27 @@ function question_sort_tags($tagobjects, $categorycontext, $filtercourses = null * Only $question->qtype is used. * @return string the HTML for the img tag. */ -function print_question_icon($question) { +function print_question_icon($question): string { global $PAGE; return $PAGE->get_renderer('question', 'bank')->qtype_icon($question->qtype); } -/** - * Creates a stamp that uniquely identifies this version of the question - * - * In future we want this to use a hash of the question data to guarantee that - * identical versions have the same version stamp. - * - * @param object $question - * @return string A unique version stamp - */ -function question_hash($question) { - return make_unique_id_code(); -} - -/// CATEGORY FUNCTIONS ///////////////////////////////////////////////////////////////// +// CATEGORY FUNCTIONS. /** - * returns the categories with their names ordered following parent-child relationships + * Returns the categories with their names ordered following parent-child relationships. * finally it tries to return pending categories (those being orphaned, whose parent is * incorrect) to avoid missing any category from original array. + * + * @param array $categories + * @param int $id + * @param int $level + * @return array */ -function sort_categories_by_tree(&$categories, $id = 0, $level = 1) { +function sort_categories_by_tree(&$categories, $id = 0, $level = 1): array { global $DB; - $children = array(); + $children = []; $keys = array_keys($categories); foreach ($keys as $key) { @@ -1091,15 +1126,13 @@ function sort_categories_by_tree(&$categories, $id = 0, $level = 1) { $children[$key] = $categories[$key]; $categories[$key]->processed = true; $children = $children + sort_categories_by_tree( - $categories, $children[$key]->id, $level+1); + $categories, $children[$key]->id, $level + 1); } } - //If level = 1, we have finished, try to look for non processed categories - // (bad parent) and sort them too + // If level = 1, we have finished, try to look for non processed categories (bad parent) and sort them too. if ($level == 1) { foreach ($keys as $key) { - // If not processed and it's a good candidate to start (because its - // parent doesn't exist in the course) + // If not processed and it's a good candidate to start (because its parent doesn't exist in the course). if (!isset($categories[$key]->processed) && !$DB->record_exists('question_categories', array('contextid' => $categories[$key]->contextid, 'id' => $categories[$key]->parent))) { @@ -1114,73 +1147,15 @@ function sort_categories_by_tree(&$categories, $id = 0, $level = 1) { } /** - * Private method, only for the use of add_indented_names(). + * Get the default category for the context. * - * Recursively adds an indentedname field to each category, starting with the category - * with id $id, and dealing with that category and all its children, and - * return a new array, with those categories in the right order. - * - * @param array $categories an array of categories which has had childids - * fields added by flatten_category_tree(). Passed by reference for - * performance only. It is not modfied. - * @param int $id the category to start the indenting process from. - * @param int $depth the indent depth. Used in recursive calls. - * @return array a new array of categories, in the right order for the tree. - * @deprecated since Moodle 4.0 MDL-71585 - * @see qbank_managecategories\helper - * @todo Final deprecation on Moodle 4.4 MDL-72438 - */ -function flatten_category_tree(&$categories, $id, $depth = 0, $nochildrenof = -1) { - debugging('Function flatten_category_tree() has been deprecated and moved to qbank_managecategories plugin, - Please use qbank_managecategories\helper::flatten_category_tree() instead.', DEBUG_DEVELOPER); - return \qbank_managecategories\helper::flatten_category_tree($categories, $id, $depth, $nochildrenof); -} - -/** - * Format categories into an indented list reflecting the tree structure. - * - * @param array $categories An array of category objects, for example from the. - * @return array The formatted list of categories. - * @deprecated since Moodle 4.0 MDL-71585 - * @see qbank_managecategories\helper - * @todo Final deprecation on Moodle 4.4 MDL-72438 - */ -function add_indented_names($categories, $nochildrenof = -1) { - debugging('Function add_indented_names() has been deprecated and moved to qbank_managecategories plugin, - Please use qbank_managecategories\helper::add_indented_names() instead.', DEBUG_DEVELOPER); - return \qbank_managecategories\helper::add_indented_names($categories, $nochildrenof); -} - -/** - * Output a select menu of question categories. - * - * Categories from this course and (optionally) published categories from other courses - * are included. Optionally, only categories the current user may edit can be included. - * - * @param integer $courseid the id of the course to get the categories for. - * @param integer $published if true, include publised categories from other courses. - * @param integer $only_editable if true, exclude categories this user is not allowed to edit. - * @param integer $selected optionally, the id of a category to be selected by - * default in the dropdown. - * @deprecated since Moodle 4.0 MDL-71585 - * @see qbank_managecategories\helper - * @todo Final deprecation on Moodle 4.4 MDL-72438 - */ -function question_category_select_menu($contexts, $top = false, $currentcat = 0, - $selected = "", $nochildrenof = -1) { - debugging('Function question_category_select_menu() has been deprecated and moved to qbank_managecategories plugin, - Please use qbank_managecategories\helper::question_category_select_menu() instead.', DEBUG_DEVELOPER); - \qbank_managecategories\helper::question_category_select_menu($contexts, $top, $currentcat, $selected, $nochildrenof); -} - -/** * @param integer $contextid a context id. - * @return object the default question category for that context, or false if none. + * @return object|bool the default question category for that context, or false if none. */ function question_get_default_category($contextid) { global $DB; $category = $DB->get_records_select('question_categories', 'contextid = ? AND parent <> 0', - array($contextid), 'id', '*', 0, 1); + [$contextid], 'id', '*', 0, 1); if (!empty($category)) { return reset($category); } else { @@ -1198,8 +1173,7 @@ function question_get_default_category($contextid) { */ function question_get_top_category($contextid, $create = false) { global $DB; - $category = $DB->get_record('question_categories', - array('contextid' => $contextid, 'parent' => 0)); + $category = $DB->get_record('question_categories', ['contextid' => $contextid, 'parent' => 0]); if (!$category && $create) { // We need to make one. @@ -1222,12 +1196,16 @@ function question_get_top_category($contextid, $create = false) { * @param array $contextids List of context ids * @return array */ -function question_get_top_categories_for_contexts($contextids) { +function question_get_top_categories_for_contexts($contextids): array { global $DB; $concatsql = $DB->sql_concat_join("','", ['id', 'contextid']); list($insql, $params) = $DB->get_in_or_equal($contextids); - $sql = "SELECT $concatsql FROM {question_categories} WHERE contextid $insql AND parent = 0"; + $sql = "SELECT $concatsql + FROM {question_categories} + WHERE contextid $insql + AND parent = 0"; + $topcategories = $DB->get_fieldset_sql($sql, $params); return $topcategories; @@ -1240,7 +1218,7 @@ function question_get_top_categories_for_contexts($contextids) { * @param array $contexts The context objects for this context and all parent contexts. * @return object The default category - the category in the course context */ -function question_make_default_categories($contexts) { +function question_make_default_categories($contexts): object { global $DB; static $preferredlevels = array( CONTEXT_COURSE => 4, @@ -1256,7 +1234,7 @@ function question_make_default_categories($contexts) { $topcategory = question_get_top_category($context->id, true); if (!$exists = $DB->record_exists("question_categories", array('contextid' => $context->id, 'parent' => $topcategory->id))) { - // Otherwise, we need to make one + // Otherwise, we need to make one. $category = new stdClass(); $contextname = $context->get_context_name(false, true); // Max length of name field is 255. @@ -1288,87 +1266,23 @@ function question_make_default_categories($contexts) { } /** - * Get all the category objects, including a count of the number of questions in that category, - * for all the categories in the lists $contexts. + * Get the list of categories. * - * @param mixed $contexts either a single contextid, or a comma-separated list of context ids. - * @param string $sortorder used as the ORDER BY clause in the select statement. - * @param bool $top Whether to return the top categories or not. - * @return array of category objects. - * @deprecated since Moodle 4.0 MDL-71585 - * @see qbank_managecategories\helper - * @todo Final deprecation on Moodle 4.4 MDL-72438 - */ -function get_categories_for_contexts($contexts, $sortorder = 'parent, sortorder, name ASC', $top = false) { - debugging('Function get_categories_for_contexts() has been deprecated and moved to qbank_managecategories plugin, - Please use qbank_managecategories\helper::get_categories_for_contexts() instead.', DEBUG_DEVELOPER); - return \qbank_managecategories\helper::get_categories_for_contexts($contexts, $sortorder, $top); -} - -/** - * Output an array of question categories. - * - * @param array $contexts The list of contexts. - * @param bool $top Whether to return the top categories or not. - * @param int $currentcat - * @param bool $popupform - * @param int $nochildrenof - * @param boolean $escapecontextnames Whether the returned name of the thing is to be HTML escaped or not. - * @return array - * @deprecated since Moodle 4.0 MDL-71585 - * @see qbank_managecategories\helper - * @todo Final deprecation on Moodle 4.4 MDL-72438 - */ -function question_category_options($contexts, $top = false, $currentcat = 0, - $popupform = false, $nochildrenof = -1, $escapecontextnames = true) { - debugging('Function question_category_options() has been deprecated and moved to qbank_managecategories plugin, - Please use qbank_managecategories\helper::question_category_options() instead.', DEBUG_DEVELOPER); - return \qbank_managecategories\helper::question_category_options($contexts, $top, $currentcat, - $popupform, $nochildrenof, $escapecontextnames); -} - -/** - * @deprecated since Moodle 4.0 MDL-71585 - * @see qbank_managecategories\helper - * @todo Final deprecation on Moodle 4.4 MDL-72438 - */ -function question_add_context_in_key($categories) { - debugging('Function question_add_context_in_key() has been deprecated and moved to qbank_managecategories plugin, - Please use qbank_managecategories\helper::question_add_context_in_key() instead.', DEBUG_DEVELOPER); - return \qbank_managecategories\helper::question_add_context_in_key($categories); -} - -/** - * Finds top categories in the given categories hierarchy and replace their name with a proper localised string. - * - * @param array $categories An array of question categories. - * @param boolean $escape Whether the returned name of the thing is to be HTML escaped or not. - * @return array The same question category list given to the function, with the top category names being translated. - * @deprecated since Moodle 4.0 MDL-71585 - * @see qbank_managecategories\helper - * @todo Final deprecation on Moodle 4.4 MDL-72438 - */ -function question_fix_top_names($categories, $escape = true) { - debugging('Function question_fix_top_names() has been deprecated and moved to qbank_managecategories plugin, - Please use qbank_managecategories\helper::question_fix_top_names() instead.', DEBUG_DEVELOPER); - return \qbank_managecategories\helper::question_fix_top_names($categories, $escape); -} - -/** + * @param int $categoryid * @return array of question category ids of the category and all subcategories. */ -function question_categorylist($categoryid) { +function question_categorylist($categoryid): array { global $DB; - // final list of category IDs + // Final list of category IDs. $categorylist = array(); - // a list of category IDs to check for any sub-categories + // A list of category IDs to check for any sub-categories. $subcategories = array($categoryid); while ($subcategories) { foreach ($subcategories as $subcategory) { - // if anything from the temporary list was added already, then we have a loop + // If anything from the temporary list was added already, then we have a loop. if (isset($categorylist[$subcategory])) { throw new coding_exception("Category id=$subcategory is already on the list - loop of categories detected."); } @@ -1377,8 +1291,8 @@ function question_categorylist($categoryid) { list ($in, $params) = $DB->get_in_or_equal($subcategories); - $subcategories = $DB->get_records_select_menu('question_categories', - "parent $in", $params, NULL, 'id,id AS id2'); + $subcategories = $DB->get_records_select_menu('question_categories', "parent $in", $params, + null, 'id,id AS id2'); } return $categorylist; @@ -1389,7 +1303,7 @@ function question_categorylist($categoryid) { * @param int $categoryid for which you want to find the parents. * @return array of question category ids of all parents categories. */ -function question_categorylist_parents(int $categoryid) { +function question_categorylist_parents(int $categoryid): array { global $DB; $parent = $DB->get_field('question_categories', 'parent', array('id' => $categoryid)); if (!$parent) { @@ -1408,16 +1322,14 @@ function question_categorylist_parents(int $categoryid) { return $categorylist; } -//=========================== -// Import/Export Functions -//=========================== +// Import/Export Functions. /** * Get list of available import or export formats * @param string $type 'import' if import list, otherwise export list assumed * @return array sorted list of import/export formats available */ -function get_import_export_formats($type) { +function get_import_export_formats($type): array { global $CFG; require_once($CFG->dirroot . '/question/format.php'); @@ -1451,7 +1363,7 @@ function get_import_export_formats($type) { * @param object $category the question category. * @return string the filename. */ -function question_default_export_filename($course, $category) { +function question_default_export_filename($course, $category): string { // We build a string that is an appropriate name (questions) from the lang pack, // then the corse shortname, then the question category name, then a timestamp. @@ -1468,73 +1380,18 @@ function question_default_export_filename($course, $category) { $categoryname = clean_filename(format_string($category->name)); return "{$base}-{$shortname}-{$categoryname}-{$timestamp}"; - - return $export_name; } /** - * Converts contextlevels to strings and back to help with reading/writing contexts - * to/from import/export files. - * - * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} - * @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 = array(); - - public function __construct($contexts) { - $this->generate_context_to_string_array($contexts); - } - - public function context_to_string($contextid) { - return $this->contexttostringarray[$contextid]; - } - - public function string_to_context($contextname) { - $contextid = array_search($contextname, $this->contexttostringarray); - return $contextid; - } - - protected function generate_context_to_string_array($contexts) { - if (!$this->contexttostringarray) { - $catno = 1; - 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; - } - $this->contexttostringarray[$context->id] = $contextstring; - } - } - } - -} - -/** - * Check capability on category + * Check capability on category. * * @param int|stdClass|question_definition $questionorid object or id. * If an object is passed, it should include ->contextid and ->createdby. * @param string $cap 'add', 'edit', 'view', 'use', 'move' or 'tag'. * @param int $notused no longer used. * @return bool this user has the capability $cap for this question $question? - * @throws coding_exception */ -function question_has_capability_on($questionorid, $cap, $notused = -1) { +function question_has_capability_on($questionorid, $cap, $notused = -1): bool { global $USER, $DB; if (is_numeric($questionorid)) { @@ -1560,11 +1417,20 @@ function question_has_capability_on($questionorid, $cap, $notused = -1) { debugging($e->getMessage(), DEBUG_NORMAL, $e->getTrace()); } + $sql = 'SELECT q.id, + q.createdby, + qc.contextid + FROM {question} q + JOIN {question_versions} qv + ON qv.questionid = q.id + JOIN {question_bank_entry} qbe + ON qbe.id = qv.questionbankentryid + JOIN {question_categories} qc + ON qc.id = qbe.questioncategoryid + WHERE q.id = :id'; + // Well, at least we tried. Seems that we really have to read from DB. - $question = $DB->get_record_sql('SELECT q.id, q.createdby, qc.contextid - FROM {question} q - JOIN {question_categories} qc ON q.category = qc.id - WHERE q.id = :id', ['id' => $questionid]); + $question = $DB->get_record_sql($sql, ['id' => $questionid]); } } @@ -1588,17 +1454,23 @@ function question_has_capability_on($questionorid, $cap, $notused = -1) { /** * Require capability on question. + * + * @param object $question + * @param string $cap + * @return bool */ -function question_require_capability_on($question, $cap) { +function question_require_capability_on($question, $cap): bool { if (!question_has_capability_on($question, $cap)) { - print_error('nopermissions', '', '', $cap); + throw new moodle_exception('nopermissions', '', '', $cap); } return true; } /** + * Gets the question edit url. + * * @param object $context a context - * @return string A URL for editing questions in this context. + * @return string|bool A URL for editing questions in this context. */ function question_edit_url($context) { global $CFG, $SITE; @@ -1712,7 +1584,7 @@ function question_extend_settings_navigation(navigation_node $navigationnode, $c ]; } - $contexts = new question_edit_contexts($context); + $contexts = new core_question\lib\question_edit_contexts($context); foreach ($corenavigations as $key => $corenavigation) { if ($contexts->have_one_edit_tab_cap($key)) { $questionnode->add($corenavigation['title'], new moodle_url( @@ -1734,10 +1606,12 @@ function question_extend_settings_navigation(navigation_node $navigationnode, $c } /** + * Get the array of capabilities for question. + * * @return array all the capabilities that relate to accessing particular questions. */ -function question_get_question_capabilities() { - return array( +function question_get_question_capabilities(): array { + return [ 'moodle/question:add', 'moodle/question:editmine', 'moodle/question:editall', @@ -1751,201 +1625,21 @@ function question_get_question_capabilities() { 'moodle/question:tagall', 'moodle/question:commentmine', 'moodle/question:commentall', - ); + ]; } /** + * Get the question bank caps. + * * @return array all the question bank capabilities. */ -function question_get_all_capabilities() { +function question_get_all_capabilities(): array { $caps = question_get_question_capabilities(); $caps[] = 'moodle/question:managecategory'; $caps[] = 'moodle/question:flag'; return $caps; } - -/** - * Tracks all the contexts related to the one where we are currently editing - * questions, and provides helper methods to check permissions. - * - * @copyright 2007 Jamie Pratt me@jamiep.org - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ -class question_edit_contexts { - - public static $caps = array( - 'editq' => array('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'=>array('moodle/question:add', - 'moodle/question:editmine', - 'moodle/question:editall', - 'moodle/question:viewmine', - 'moodle/question:viewall', - 'moodle/question:movemine', - 'moodle/question:moveall'), - 'categories'=>array('moodle/question:managecategory'), - 'import'=>array('moodle/question:add'), - 'export'=>array('moodle/question:viewall', 'moodle/question:viewmine')); - - protected $allcontexts; - - /** - * Constructor - * @param context the current context. - */ - public function __construct(context $thiscontext) { - $this->allcontexts = array_values($thiscontext->get_parent_contexts(true)); - } - - /** - * @return context[] all parent contexts - */ - public function all() { - return $this->allcontexts; - } - - /** - * @return context lowest context which must be either the module or course context - */ - public function lowest() { - return $this->allcontexts[0]; - } - - /** - * @param string $cap capability - * @return context[] parent contexts having capability, zero based index - */ - public function having_cap($cap) { - $contextswithcap = array(); - foreach ($this->allcontexts as $context) { - if (has_capability($cap, $context)) { - $contextswithcap[] = $context; - } - } - return $contextswithcap; - } - - /** - * @param array $caps capabilities - * @return context[] parent contexts having at least one of $caps, zero based index - */ - public function having_one_cap($caps) { - $contextswithacap = array(); - foreach ($this->allcontexts as $context) { - foreach ($caps as $cap) { - if (has_capability($cap, $context)) { - $contextswithacap[] = $context; - break; //done with caps loop - } - } - } - return $contextswithacap; - } - - /** - * @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]); - } - - /** - * @return context[] those contexts where a user can add a question and then use it. - */ - public function having_add_and_use() { - $contextswithcap = array(); - foreach ($this->allcontexts as $context) { - if (!has_capability('moodle/question:add', $context)) { - continue; - } - if (!has_any_capability(array('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)) { - print_error('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); - print_error('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)) { - print_error('nopermissions', '', '', 'access question edit tab '.$tabname); - } - } -} - - /** * Helps call file_rewrite_pluginfile_urls with the right parameters. * @@ -1961,8 +1655,8 @@ class question_edit_contexts { * @param array $options options * @return string */ -function question_rewrite_question_urls($text, $file, $contextid, $component, - $filearea, array $ids, $itemid, array $options=null) { +function question_rewrite_question_urls($text, $file, $contextid, $component, $filearea, + array $ids, $itemid, array $options=null): string { $idsstr = ''; if (!empty($ids)) { @@ -1991,9 +1685,8 @@ function question_rewrite_question_urls($text, $file, $contextid, $component, * @param array $options text and file options ('forcehttps'=>false) * @return string $questiontext with URLs rewritten. */ -function question_rewrite_question_preview_urls($text, $questionid, - $filecontextid, $filecomponent, $filearea, $itemid, - $previewcontextid, $previewcomponent, $options = null) { +function question_rewrite_question_preview_urls($text, $questionid, $filecontextid, $filecomponent, $filearea, $itemid, + $previewcontextid, $previewcomponent, $options = null): string { $path = "preview/$previewcontextid/$previewcomponent/$questionid"; if ($itemid) { @@ -2017,7 +1710,6 @@ function question_rewrite_question_preview_urls($text, $questionid, * * Does not return, either calls send_file_not_found(); or serves the file. * - * @package core_question * @category files * @param stdClass $course course settings object * @param stdClass $context context object @@ -2026,8 +1718,9 @@ function question_rewrite_question_preview_urls($text, $questionid, * @param array $args the remaining bits of the file path. * @param bool $forcedownload whether the user must be forced to download the file. * @param array $options additional options affecting the file serving + * @return array|bool */ -function question_pluginfile($course, $context, $component, $filearea, $args, $forcedownload, array $options=array()) { +function question_pluginfile($course, $context, $component, $filearea, $args, $forcedownload, $options = []) { global $DB, $CFG; // Special case, sending a question bank export. @@ -2036,16 +1729,16 @@ function question_pluginfile($course, $context, $component, $filearea, $args, $f require_login($course, false, $cm); require_once($CFG->dirroot . '/question/editlib.php'); - $contexts = new question_edit_contexts($context); - // check export capability + $contexts = new core_question\lib\question_edit_contexts($context); + // Check export capability. $contexts->require_one_edit_tab_cap('export'); - $category_id = (int)array_shift($args); + $categoryid = (int)array_shift($args); $format = array_shift($args); $cattofile = array_shift($args); $contexttofile = array_shift($args); $filename = array_shift($args); - // load parent class for import/export + // Load parent class for import/export. require_once($CFG->dirroot . '/question/format.php'); require_once($CFG->dirroot . '/question/editlib.php'); require_once($CFG->dirroot . '/question/format/' . $format . '/format.php'); @@ -2057,7 +1750,7 @@ function question_pluginfile($course, $context, $component, $filearea, $args, $f $qformat = new $classname(); - if (!$category = $DB->get_record('question_categories', array('id' => $category_id))) { + if (!$category = $DB->get_record('question_categories', array('id' => $categoryid))) { send_file_not_found(); } @@ -2079,10 +1772,10 @@ function question_pluginfile($course, $context, $component, $filearea, $args, $f if (!$qformat->exportpreprocess()) { send_file_not_found(); - print_error('exporterror', 'question', $thispageurl->out()); + throw new moodle_exception('exporterror', 'question', $thispageurl->out()); } - // export data to moodle file pool + // Export data to moodle file pool. if (!$content = $qformat->exportprocess()) { send_file_not_found(); } @@ -2157,28 +1850,32 @@ function question_pluginfile($course, $context, $component, $filearea, $args, $f /** * Serve questiontext files in the question text when they are displayed in this report. * - * @package core_files - * @category files * @param context $previewcontext the context in which the preview is happening. * @param int $questionid the question id. * @param context $filecontext the file (question) context. * @param string $filecomponent the component the file belongs to. * @param string $filearea the file area. * @param array $args remaining file args. - * @param bool $forcedownload. + * @param bool $forcedownload * @param array $options additional options affecting the file serving. */ -function core_question_question_preview_pluginfile($previewcontext, $questionid, - $filecontext, $filecomponent, $filearea, $args, $forcedownload, $options = array()) { +function core_question_question_preview_pluginfile($previewcontext, $questionid, $filecontext, $filecomponent, + $filearea, $args, $forcedownload, $options = []): void { global $DB; + $sql = 'SELECT q.*, + qc.contextid + FROM {question} q + JOIN {question_versions} qv + ON qv.questionid = q.id + JOIN {question_bank_entry} qbe + ON qbe.id = qv.questionbankentryid + JOIN {question_categories} qc + ON qc.id = qbe.questioncategoryid + WHERE q.id = :id + AND qc.contextid = :contextid'; // Verify that contextid matches the question. - $question = $DB->get_record_sql(' - SELECT q.*, qc.contextid - FROM {question} q - JOIN {question_categories} qc ON qc.id = q.category - WHERE q.id = :id AND qc.contextid = :contextid', - array('id' => $questionid, 'contextid' => $filecontext->id), MUST_EXIST); + $question = $DB->get_record_sql($sql, ['id' => $questionid, 'contextid' => $filecontext->id], MUST_EXIST); // Check the capability. list($context, $course, $cm) = get_context_info_array($previewcontext->id); @@ -2196,61 +1893,22 @@ function core_question_question_preview_pluginfile($previewcontext, $questionid, send_stored_file($file, 0, 0, $forcedownload, $options); } -/** - * Create url for question export - * - * @param int $contextid, current context - * @param int $categoryid, categoryid - * @param string $format - * @param string $withcategories - * @param string $ithcontexts - * @param moodle_url export file url - * @deprecated since Moodle 4.0 MDL-71573 - * @see qbank_exportquestions\exportquestions_helper - */ -function question_make_export_url($contextid, $categoryid, $format, $withcategories, - $withcontexts, $filename) { - debugging('Function question_make_export_url() has been deprecated and moved to qbank_exportquestions plugin, - Please use qbank_exportquestions\exportquestions_helper::question_make_export_url() instead.', DEBUG_DEVELOPER); - global $CFG; - $urlbase = "$CFG->wwwroot/pluginfile.php"; - return moodle_url::make_file_url($urlbase, - "/$contextid/question/export/{$categoryid}/{$format}/{$withcategories}" . - "/{$withcontexts}/{$filename}", true); -} - -/** - * Get the URL to export a single question (exportone.php). - * - * @param stdClass|question_definition $question the question definition as obtained from - * question_bank::load_question_data() or question_bank::make_question(). - * (Only ->id and ->contextid are used.) - * @return moodle_url the requested URL. - * @deprecated since Moodle 4.0 - * @see \qbank_exporttoxml\helper::question_get_export_single_question_url() - * @todo Final deprecation on Moodle 4.4 MDL-72438 - */ -function question_get_export_single_question_url($question) { - debugging('Function question_get_export_single_question_url() has been deprecated and moved to qbank_exporttoxml plugin, - please use qbank_exporttoxml\helper::question_get_export_single_question_url() instead.', DEBUG_DEVELOPER); - qbank_exporttoxml\helper::question_get_export_single_question_url($question); -} - /** * Return a list of page types * @param string $pagetype current page type * @param stdClass $parentcontext Block's parent context * @param stdClass $currentcontext Current context of block + * @return array */ -function question_page_type_list($pagetype, $parentcontext, $currentcontext) { +function question_page_type_list($pagetype, $parentcontext, $currentcontext): array { global $CFG; - $types = array( - 'question-*'=>get_string('page-question-x', 'question'), - 'question-edit'=>get_string('page-question-edit', 'question'), - 'question-category'=>get_string('page-question-category', 'question'), - 'question-export'=>get_string('page-question-export', 'question'), - 'question-import'=>get_string('page-question-import', 'question') - ); + $types = [ + 'question-*' => get_string('page-question-x', 'question'), + 'question-edit' => get_string('page-question-edit', 'question'), + 'question-category' => get_string('page-question-category', 'question'), + 'question-export' => get_string('page-question-export', 'question'), + 'question-import' => get_string('page-question-import', 'question') + ]; if ($currentcontext->contextlevel == CONTEXT_COURSE) { require_once($CFG->dirroot . '/course/lib.php'); return array_merge(course_page_type_list($pagetype, $parentcontext, $currentcontext), $types); @@ -2265,7 +1923,7 @@ function question_page_type_list($pagetype, $parentcontext, $currentcontext) { * @param string $modname The name of the module (without mod_ prefix). * @return bool true if the module uses questions. */ -function question_module_uses_questions($modname) { +function question_module_uses_questions($modname): bool { if (plugin_supports('mod', $modname, FEATURE_USES_QUESTIONS)) { return true; } @@ -2298,7 +1956,7 @@ function core_question_find_next_unused_idnumber(?string $oldidnumber, int $cate } // Find all used idnumbers in one DB query. - $usedidnumbers = $DB->get_records_select_menu('question', 'category = ? AND idnumber IS NOT NULL', + $usedidnumbers = $DB->get_records_select_menu('question_bank_entry', 'questioncategoryid = ? AND idnumber IS NOT NULL', [$categoryid], '', 'idnumber, 1'); // Find the next unused idnumber. @@ -2316,3 +1974,601 @@ function core_question_find_next_unused_idnumber(?string $oldidnumber, int $cate return (string) $newidnumber; } + +/** + * Get the question_bank_entry object given a question id. + * + * @param $questionid int Question id. + * @return false|mixed + * @throws dml_exception + */ +function get_question_bank_entry(int $questionid): object { + global $DB; + + $sql = "SELECT qbe.* + FROM {question} q + JOIN {question_versions} qv ON qv.questionid = q.id + JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid + WHERE q.id = :id"; + + $qbankentry = $DB->get_record_sql($sql, ['id' => $questionid]); + + return $qbankentry; +} + +/** + * Get the question versions given a question id in a descending sort . + * + * @param $questionid int Question id. + * @return array + * @throws dml_exception + */ +function get_question_version($questionid): array { + global $DB; + + $version = $DB->get_records('question_versions', ['questionid' => $questionid]); + krsort($version); + + return $version; +} + +/** + * Get the next version number to create base on a Question bank entry id. + * + * @param $questionbankentryid int Question bank entry id. + * @return int next version number. + * @throws dml_exception + */ +function get_next_version(int $questionbankentryid): int { + global $DB; + + $sql = "SELECT MAX(qv.version) + FROM {question_versions} qv + JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid + WHERE qbe.id = :id"; + + $nextversion = $DB->get_field_sql($sql, ['id' => $questionbankentryid]); + + if ($nextversion) { + return (int)$nextversion + 1; + } + + return 1; +} + +// Deprecated classes and functions from Moodle 4.0. + +/** + * Converts contextlevels to strings and back to help with reading/writing contexts to/from import/export files. + * + * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} + * @author 2021 Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @deprecated Since Moodle 4.0 + * @see \core_question\lib\context_to_string_translator + * @todo MDL-72004 delete the class + */ +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) { + debugging('Class context_to_string_translator is deprecated and + moved to \core_question\lib\context_to_string_translator, + please use new \core_question\lib\context_to_string_translator($contexts) instead.', DEBUG_DEVELOPER); + $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) { + $contextid = array_search($contextname, $this->contexttostringarray); + return $contextid; + } + + /** + * Generate context to array. + * + * @param context $contexts + */ + protected function generate_context_to_string_array($contexts) { + if (!$this->contexttostringarray) { + $catno = 1; + 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; + } + $this->contexttostringarray[$context->id] = $contextstring; + } + } + } + +} + +/** + * Tracks all the contexts related to the one we are currently editing questions and provides helper methods to check permissions. + * + * @copyright 2007 Jamie Pratt me@jamiep.org + * @author 2021 Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @deprecated Since Moodle 4.0 + * @see \core_question\lib\context_to_string_translator + * @todo MDL-72004 delete the class + */ +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) { + debugging('Class question_edit_contexts is deprecated and + moved to \core_question\lib\question_edit_contexts, + please use new \core_question\lib\question_edit_contexts($thiscontext) instead.', DEBUG_DEVELOPER); + $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); + } + } +} + +/** + * Generate the URL for starting a new preview of a given question with the given options. + * @param integer $questionid the question to preview. + * @param string $preferredbehaviour the behaviour to use for the preview. + * @param float $maxmark the maximum to mark the question out of. + * @param question_display_options $displayoptions the display options to use. + * @param int $variant the variant of the question to preview. If null, one will + * be picked randomly. + * @param object $context context to run the preview in (affects things like + * filter settings, theme, lang, etc.) Defaults to $PAGE->context. + * @return moodle_url the URL. + * @deprecated since Moodle 4.0 + * @see qbank_previewquestion\previewquestion_helper::question_preview_url() + */ +function question_preview_url($questionid, $preferredbehaviour = null, + $maxmark = null, $displayoptions = null, $variant = null, $context = null) { + debugging('Function question_preview_url() has been deprecated and moved to qbank_previewquestion plugin, + Please use qbank_previewquestion\previewquestion_helper::question_preview_url() instead.', DEBUG_DEVELOPER); + + return \qbank_previewquestion\previewquestion_helper::question_preview_url($questionid, $preferredbehaviour, + $maxmark, $displayoptions, $variant, $context); +} + +/** + * @return array that can be passed as $params to the {@link popup_action} constructor. + * @deprecated since Moodle 4.0 + * @see qbank_previewquestion\previewquestion_helper::question_preview_popup_params() + */ +function question_preview_popup_params() { + debugging('Function question_preview_popup_params() has been deprecated and moved to qbank_previewquestion plugin, + Please use qbank_previewquestion\previewquestion_helper::question_preview_popup_params() instead.', DEBUG_DEVELOPER); + + return \qbank_previewquestion\previewquestion_helper::question_preview_popup_params(); +} + +/** + * Creates a stamp that uniquely identifies this version of the question + * + * In future we want this to use a hash of the question data to guarantee that + * identical versions have the same version stamp. + * + * @param object $question + * @return string A unique version stamp + * @deprecated since Moodle 4.0 + */ +function question_hash($question) { + debugging('Function question_hash() has been deprecated without replacement.', DEBUG_DEVELOPER); + return make_unique_id_code(); +} + +/** + * Create url for question export. + * + * @param int $contextid + * @param int $categoryid + * @param string $format + * @param string $withcategories + * @param string $withcontexts + * @param string $filename + * @param moodle_url export file url + * @deprecated since Moodle 4.0 MDL-71573 + * @see qbank_exportquestions\exportquestions_helper + */ +function question_make_export_url($contextid, $categoryid, $format, $withcategories, + $withcontexts, $filename) { + debugging('Function question_make_export_url() has been deprecated and moved to qbank_exportquestions plugin, + Please use qbank_exportquestions\exportquestions_helper::question_make_export_url() instead.', DEBUG_DEVELOPER); + + return \qbank_exportquestions\exportquestions_helper::question_make_export_url($contextid, $categoryid, $format, + $withcategories, $withcontexts, $filename); +} + +/** + * Get the URL to export a single question (exportone.php). + * + * @param stdClass|question_definition $question the question definition as obtained from + * question_bank::load_question_data() or question_bank::make_question(). + * (Only ->id and ->contextid are used.) + * @return moodle_url the requested URL. + * @deprecated since Moodle 4.0 + * @see \qbank_exporttoxml\helper::question_get_export_single_question_url() + * @todo Final deprecation on Moodle 4.4 MDL-72438 + */ +function question_get_export_single_question_url($question) { + debugging('Function question_get_export_single_question_url() has been deprecated and moved to qbank_exporttoxml plugin, + please use qbank_exporttoxml\helper::question_get_export_single_question_url() instead.', DEBUG_DEVELOPER); + qbank_exporttoxml\helper::question_get_export_single_question_url($question); +} + +/** + * Remove stale questions from a category. + * + * While questions should not be left behind when they are not used any more, + * it does happen, maybe via restore, or old logic, or uncovered scenarios. When + * this happens, the users are unable to delete the question category unless + * they move those stale questions to another one category, but to them the + * category is empty as it does not contain anything. The purpose of this function + * is to detect the questions that may have gone stale and remove them. + * + * You will typically use this prior to checking if the category contains questions. + * + * The stale questions (unused and hidden to the user) handled are: + * - hidden questions + * - random questions + * + * @param int $categoryid The category ID. + * @deprecated since Moodle 4.0 MDL-71585 + * @see qbank_managecategories\helper + * @todo Final deprecation on Moodle 4.4 MDL-72438 + */ +function question_remove_stale_questions_from_category($categoryid) { + debugging('Function question_remove_stale_questions_from_category() + has been deprecated and moved to qbank_managecategories plugin, + Please use qbank_managecategories\helper::question_remove_stale_questions_from_category() instead.', + DEBUG_DEVELOPER); + \qbank_managecategories\helper::question_remove_stale_questions_from_category($categoryid); +} + +/** + * Private method, only for the use of add_indented_names(). + * + * Recursively adds an indentedname field to each category, starting with the category + * with id $id, and dealing with that category and all its children, and + * return a new array, with those categories in the right order. + * + * @param array $categories an array of categories which has had childids + * fields added by flatten_category_tree(). Passed by reference for + * performance only. It is not modfied. + * @param int $id the category to start the indenting process from. + * @param int $depth the indent depth. Used in recursive calls. + * @return array a new array of categories, in the right order for the tree. + * @deprecated since Moodle 4.0 MDL-71585 + * @see qbank_managecategories\helper + * @todo Final deprecation on Moodle 4.4 MDL-72438 + */ +function flatten_category_tree(&$categories, $id, $depth = 0, $nochildrenof = -1) { + debugging('Function flatten_category_tree() has been deprecated and moved to qbank_managecategories plugin, + Please use qbank_managecategories\helper::flatten_category_tree() instead.', DEBUG_DEVELOPER); + return \qbank_managecategories\helper::flatten_category_tree($categories, $id, $depth, $nochildrenof); +} + +/** + * Format categories into an indented list reflecting the tree structure. + * + * @param array $categories An array of category objects, for example from the. + * @return array The formatted list of categories. + * @deprecated since Moodle 4.0 MDL-71585 + * @see qbank_managecategories\helper + * @todo Final deprecation on Moodle 4.4 MDL-72438 + */ +function add_indented_names($categories, $nochildrenof = -1) { + debugging('Function add_indented_names() has been deprecated and moved to qbank_managecategories plugin, + Please use qbank_managecategories\helper::add_indented_names() instead.', DEBUG_DEVELOPER); + return \qbank_managecategories\helper::add_indented_names($categories, $nochildrenof); +} + +/** + * Output a select menu of question categories. + * Categories from this course and (optionally) published categories from other courses + * are included. Optionally, only categories the current user may edit can be included. + * + * @param integer $courseid the id of the course to get the categories for. + * @param integer $published if true, include publised categories from other courses. + * @param integer $only_editable if true, exclude categories this user is not allowed to edit. + * @param integer $selected optionally, the id of a category to be selected by + * default in the dropdown. + * @deprecated since Moodle 4.0 MDL-71585 + * @see qbank_managecategories\helper + * @todo Final deprecation on Moodle 4.4 MDL-72438 + */ +function question_category_select_menu($contexts, $top = false, $currentcat = 0, + $selected = "", $nochildrenof = -1) { + debugging('Function question_category_select_menu() has been deprecated and moved to qbank_managecategories plugin, + Please use qbank_managecategories\helper::question_category_select_menu() instead.', DEBUG_DEVELOPER); + \qbank_managecategories\helper::question_category_select_menu($contexts, $top, $currentcat, $selected, $nochildrenof); +} + +/** + * Get all the category objects, including a count of the number of questions in that category, + * for all the categories in the lists $contexts. + * + * @param mixed $contexts either a single contextid, or a comma-separated list of context ids. + * @param string $sortorder used as the ORDER BY clause in the select statement. + * @param bool $top Whether to return the top categories or not. + * @return array of category objects. + * @deprecated since Moodle 4.0 MDL-71585 + * @see qbank_managecategories\helper + * @todo Final deprecation on Moodle 4.4 MDL-72438 + */ +function get_categories_for_contexts($contexts, $sortorder = 'parent, sortorder, name ASC', $top = false) { + debugging('Function get_categories_for_contexts() has been deprecated and moved to qbank_managecategories plugin, + Please use qbank_managecategories\helper::get_categories_for_contexts() instead.', DEBUG_DEVELOPER); + return \qbank_managecategories\helper::get_categories_for_contexts($contexts, $sortorder, $top); +} + +/** + * Output an array of question categories. + * + * @param array $contexts The list of contexts. + * @param bool $top Whether to return the top categories or not. + * @param int $currentcat + * @param bool $popupform + * @param int $nochildrenof + * @param boolean $escapecontextnames Whether the returned name of the thing is to be HTML escaped or not. + * @return array + * @deprecated since Moodle 4.0 MDL-71585 + * @see qbank_managecategories\helper + * @todo Final deprecation on Moodle 4.4 MDL-72438 + */ +function question_category_options($contexts, $top = false, $currentcat = 0, + $popupform = false, $nochildrenof = -1, $escapecontextnames = true) { + debugging('Function question_category_options() has been deprecated and moved to qbank_managecategories plugin, + Please use qbank_managecategories\helper::question_category_options() instead.', DEBUG_DEVELOPER); + return \qbank_managecategories\helper::question_category_options($contexts, $top, $currentcat, + $popupform, $nochildrenof, $escapecontextnames); +} + +/** + * @deprecated since Moodle 4.0 MDL-71585 + * @see qbank_managecategories\helper + * @todo Final deprecation on Moodle 4.4 MDL-72438 + */ +function question_add_context_in_key($categories) { + debugging('Function question_add_context_in_key() has been deprecated and moved to qbank_managecategories plugin, + Please use qbank_managecategories\helper::question_add_context_in_key() instead.', DEBUG_DEVELOPER); + return \qbank_managecategories\helper::question_add_context_in_key($categories); +} + +/** + * Finds top categories in the given categories hierarchy and replace their name with a proper localised string. + * + * @param array $categories An array of question categories. + * @param boolean $escape Whether the returned name of the thing is to be HTML escaped or not. + * @return array The same question category list given to the function, with the top category names being translated. + * @deprecated since Moodle 4.0 MDL-71585 + * @see qbank_managecategories\helper + * @todo Final deprecation on Moodle 4.4 MDL-72438 + */ +function question_fix_top_names($categories, $escape = true) { + debugging('Function question_fix_top_names() has been deprecated and moved to qbank_managecategories plugin, + Please use qbank_managecategories\helper::question_fix_top_names() instead.', DEBUG_DEVELOPER); + return \qbank_managecategories\helper::question_fix_top_names($categories, $escape); +} diff --git a/lib/tests/questionlib_test.php b/lib/tests/questionlib_test.php index b22b404e4f6..268cb271ff9 100644 --- a/lib/tests/questionlib_test.php +++ b/lib/tests/questionlib_test.php @@ -109,6 +109,18 @@ class core_questionlib_testcase extends advanced_testcase { return array($category, $course, $quiz, $qcat, $questions); } + /** + * Assert that a category contains a specific number of questions. + * + * @param int $categoryid int Category id. + * @param int $numberofquestions Number of question in a category. + * @return void Questions in a category. + */ + function assert_category_contains_questions(int $categoryid, int $numberofquestions): void { + $questionsid = question_bank::get_finder()->get_questions_from_categories([$categoryid], null); + $this->assertEquals($numberofquestions, count($questionsid)); + } + public function test_question_reorder_qtypes() { $this->assertEquals( array(0 => 't2', 1 => 't1', 2 => 't3'), @@ -327,8 +339,7 @@ class core_questionlib_testcase extends advanced_testcase { $this->assertEquals(0, $DB->count_records('question_categories', $criteria)); // Verify questions deleted or moved. - $criteria = array('category' => $qcat->id); - $this->assertEquals(0, $DB->count_records('question', $criteria)); + $this->assert_category_contains_questions($qcat->id, 0); // Verify question not deleted. $criteria = array('id' => $questions[0]->id); @@ -355,8 +366,7 @@ class core_questionlib_testcase extends advanced_testcase { $this->assertEquals(0, $DB->count_records('question_categories', $criteria)); // Verify questions deleted or moved. - $criteria = array('category' => $qcat->id); - $this->assertEquals(0, $DB->count_records('question', $criteria)); + $this->assert_category_contains_questions($qcat->id, 0); } /** @@ -377,8 +387,7 @@ class core_questionlib_testcase extends advanced_testcase { $this->assertEquals(0, $DB->count_records('question_categories', $criteria)); // Verify questions deleted or moved. - $criteria = array('category' => $qcat->id); - $this->assertEquals(0, $DB->count_records('question', $criteria)); + $this->assert_category_contains_questions($qcat->id, 0); } /** @@ -399,8 +408,7 @@ class core_questionlib_testcase extends advanced_testcase { $this->assertEquals(0, $DB->count_records('question_categories', $criteria)); // Verify questions deleted or moved. - $criteria = array('category' => $qcat->id); - $this->assertEquals(0, $DB->count_records('question', $criteria)); + $this->assert_category_contains_questions($qcat->id, 0); } /** @@ -421,8 +429,7 @@ class core_questionlib_testcase extends advanced_testcase { $this->assertEquals(0, $DB->count_records('question_categories', $criteria)); // Verify questions deleted or moved. - $criteria = array('category' => $qcat->id); - $this->assertEquals(0, $DB->count_records('question', $criteria)); + $this->assert_category_contains_questions($qcat->id, 0); } /** @@ -449,9 +456,11 @@ class core_questionlib_testcase extends advanced_testcase { // Verify questions are moved. $params = array($qcat2->contextid); $actualquestionscount = $DB->count_records_sql("SELECT COUNT(*) - FROM {question} q - JOIN {question_categories} qc ON q.category = qc.id - WHERE qc.contextid = ?", $params); + FROM {question} q + JOIN {question_versions} qv ON qv.questionid = q.id + JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid + JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid + WHERE qc.contextid = ?", $params); $this->assertEquals($questionsinqcat1 + $questionsinqcat2, $actualquestionscount); // Verify there is just a single top-level category. @@ -1728,16 +1737,11 @@ class core_questionlib_testcase extends advanced_testcase { $qtype = 'truefalse'; $overrides = [ 'category' => $questioncat->id, + 'createdby' => ($isowner) ? $user->id : $otheruser->id, ]; $question = $questiongenerator->create_question($qtype, null, $overrides); - // The question generator does not support setting of the createdby for some reason. - $question->createdby = ($isowner) ? $user->id : $otheruser->id; - $fromform = test_question_maker::get_question_form_data($qtype, null); - $fromform = (object) $generator->combine_defaults_and_record((array) $fromform, $overrides); - question_bank::get_qtype($qtype)->save_question($question, $fromform); - $this->setUser($user); $result = question_has_capability_on($question, $capability); $this->assertEquals($expect, $result); @@ -1779,16 +1783,11 @@ class core_questionlib_testcase extends advanced_testcase { $qtype = 'truefalse'; $overrides = [ 'category' => $questioncat->id, + 'createdby' => ($isowner) ? $user->id : $otheruser->id, ]; $question = $questiongenerator->create_question($qtype, null, $overrides); - // The question generator does not support setting of the createdby for some reason. - $question->createdby = ($isowner) ? $user->id : $otheruser->id; - $fromform = test_question_maker::get_question_form_data($qtype, null); - $fromform = (object) $generator->combine_defaults_and_record((array) $fromform, $overrides); - question_bank::get_qtype($qtype)->save_question($question, $fromform); - $this->setUser($user); $result = question_has_capability_on($question->id, $capability); $this->assertEquals($expect, $result); @@ -1830,16 +1829,11 @@ class core_questionlib_testcase extends advanced_testcase { $qtype = 'truefalse'; $overrides = [ 'category' => $questioncat->id, + 'createdby' => ($isowner) ? $user->id : $otheruser->id, ]; $question = $questiongenerator->create_question($qtype, null, $overrides); - // The question generator does not support setting of the createdby for some reason. - $question->createdby = ($isowner) ? $user->id : $otheruser->id; - $fromform = test_question_maker::get_question_form_data($qtype, null); - $fromform = (object) $generator->combine_defaults_and_record((array) $fromform, $overrides); - question_bank::get_qtype($qtype)->save_question($question, $fromform); - $this->setUser($user); $result = question_has_capability_on((string) $question->id, $capability); $this->assertEquals($expect, $result); @@ -1887,16 +1881,11 @@ class core_questionlib_testcase extends advanced_testcase { $qtype = 'truefalse'; $overrides = [ 'category' => $questioncat->id, + 'createdby' => ($isowner) ? $user->id : $otheruser->id, ]; $question = $questiongenerator->create_question($qtype, null, $overrides); - // The question generator does not support setting of the createdby for some reason. - $question->createdby = ($isowner) ? $user->id : $otheruser->id; - $fromform = test_question_maker::get_question_form_data($qtype, null); - $fromform = (object) $generator->combine_defaults_and_record((array) $fromform, $overrides); - question_bank::get_qtype($qtype)->save_question($question, $fromform); - // Move the question. question_move_questions_to_category([$question->id], $newquestioncat->id); @@ -1941,12 +1930,10 @@ class core_questionlib_testcase extends advanced_testcase { // Create the question. $question = $questiongenerator->create_question('truefalse', null, [ 'category' => $questioncat->id, + 'createdby' => ($isowner) ? $user->id : $otheruser->id, ]); $question = question_bank::load_question_data($question->id); - // The question generator does not support setting of the createdby for some reason. - $question->createdby = ($isowner) ? $user->id : $otheruser->id; - $this->setUser($user); $result = question_has_capability_on($question, $capability); $this->assertEquals($expect, $result); @@ -1970,12 +1957,10 @@ class core_questionlib_testcase extends advanced_testcase { // Create the question. $question = $questiongenerator->create_question('truefalse', null, [ 'category' => $questioncat->id, + 'createdby' => $user->id, ]); $question = question_bank::load_question_data($question->id); - // The question generator does not support setting of the createdby for some reason. - $question->createdby = $user->id; - $this->setUser($user); $result = question_has_capability_on((string)$question->id, 'tag'); $this->assertFalse($result); @@ -2057,4 +2042,218 @@ class core_questionlib_testcase extends advanced_testcase { $this->assertSame('id11', core_question_find_next_unused_idnumber('id9', $category->id)); $this->assertSame('id11', core_question_find_next_unused_idnumber('id8', $category->id)); } + + /** + * Tests for the question_move_questions_to_category function. + * + * @covers ::question_move_questions_to_category + */ + public function test_question_move_questions_to_category() { + $this->resetAfterTest(); + + // Create the test data. + list($category1, $course1, $quiz1, $questioncat1, $questions1) = $this->setup_quiz_and_questions(); + list($category2, $course2, $quiz2, $questioncat2, $questions2) = $this->setup_quiz_and_questions(); + + $this->assertCount(2, $questions1); + $this->assertCount(2, $questions2); + $questionsidtomove = []; + foreach ($questions1 as $question1) { + $questionsidtomove[] = $question1->id; + } + + // Move the question from quiz 1 to quiz 2. + question_move_questions_to_category($questionsidtomove, $questioncat2->id); + $this->assert_category_contains_questions($questioncat2->id, 4); + } + + /** + * Tests for the idnumber_exist_in_question_category function. + * + * @covers ::idnumber_exist_in_question_category + */ + public function test_idnumber_exist_in_question_category() { + global $DB; + + $this->resetAfterTest(); + + // Create the test data. + list($category1, $course1, $quiz1, $questioncat1, $questions1) = $this->setup_quiz_and_questions(); + list($category2, $course2, $quiz2, $questioncat2, $questions2) = $this->setup_quiz_and_questions(); + + $questionbankentry1 = get_question_bank_entry($questions1[0]->id); + $entry = new stdClass(); + $entry->id = $questionbankentry1->id; + $entry->idnumber = 1; + $DB->update_record('question_bank_entry', $entry); + + $questionbankentry2 = get_question_bank_entry($questions2[0]->id); + $entry2 = new stdClass(); + $entry2->id = $questionbankentry2->id; + $entry2->idnumber = 1; + $DB->update_record('question_bank_entry', $entry2); + + $questionbe = $DB->get_record('question_bank_entry', ['id' => $questionbankentry1->id]); + + // Validate that a first stage of an idnumber exists (this format: xxxx_x). + list($response, $record) = idnumber_exist_in_question_category($questionbe->idnumber, $questioncat1->id); + $this->assertEquals([], $record); + $this->assertEquals(true, $response); + + // Move the question to a category that has a question with the same idnumber. + question_move_questions_to_category($questions1[0]->id, $questioncat2->id); + + // Validate that function return the last record used for the idnumber. + list($response, $record) = idnumber_exist_in_question_category($questionbe->idnumber, $questioncat2->id); + $record = reset($record); + $idnumber = $record->idnumber; + $this->assertEquals($idnumber, '1_1'); + $this->assertEquals(true, $response); + } + + /** + * Test method is_latest(). + * + * @covers ::is_latest + * + */ + public function test_is_latest() { + global $DB; + $this->resetAfterTest(); + $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); + $qcat1 = $generator->create_question_category(['name' => 'My category', 'sortorder' => 1, 'idnumber' => 'myqcat']); + $question = $generator->create_question('shortanswer', null, ['name' => 'q1', 'category' => $qcat1->id]); + $record = $DB->get_record('question_versions', ['questionid' => $question->id]); + $firstversion = $record->version; + $questionbankentryid = $record->questionbankentryid; + $islatest = is_latest($firstversion, $questionbankentryid); + $this->assertTrue($islatest); + } + + /** + * Test question bank entry deletion. + * + * @covers ::delete_question_bank_entry + */ + public function test_delete_question_bank_entry() { + global $DB; + $this->resetAfterTest(); + // Setup. + $context = context_system::instance(); + $qgen = $this->getDataGenerator()->get_plugin_generator('core_question'); + $qcat = $qgen->create_question_category(array('contextid' => $context->id)); + $q1 = $qgen->create_question('shortanswer', null, array('category' => $qcat->id)); + // Make sure there is an entry in the entry table. + $sql = 'SELECT qbe.id as id, + qv.id as versionid + FROM {question_bank_entries} qbe + JOIN {question_versions} qv + ON qbe.id = qv.questionbankentryid + JOIN {question} q + ON qv.questionid = q.id + WHERE q.id = ?'; + $records = $DB->get_records_sql($sql, [$q1->id]); + $this->assertCount(1, $records); + // Delete the record. + $record = reset($records); + delete_question_bank_entry($record->id); + $records = $DB->get_records('question_bank_entries', ['id' => $record->id]); + // As the version record exists, it wont delete the data to resolve any errors. + $this->assertCount(1, $records); + $DB->delete_records('question_versions', ['id' => $record->versionid]); + delete_question_bank_entry($record->id); + $records = $DB->get_records('question_bank_entries', ['id' => $record->id]); + $this->assertCount(0, $records); + } + + /** + * Test question bank entry object. + * + * @covers ::get_question_bank_entry + */ + public function test_get_question_bank_entry() { + global $DB; + $this->resetAfterTest(); + // Setup. + $context = context_system::instance(); + $qgen = $this->getDataGenerator()->get_plugin_generator('core_question'); + $qcat = $qgen->create_question_category(array('contextid' => $context->id)); + $q1 = $qgen->create_question('shortanswer', null, array('category' => $qcat->id)); + // Make sure there is an entry in the entry table. + $sql = 'SELECT qbe.id as id, + qv.id as versionid + FROM {question_bank_entries} qbe + JOIN {question_versions} qv + ON qbe.id = qv.questionbankentryid + JOIN {question} q + ON qv.questionid = q.id + WHERE q.id = ?'; + $records = $DB->get_records_sql($sql, [$q1->id]); + $this->assertCount(1, $records); + $record = reset($records); + $questionbankentry = get_question_bank_entry($q1->id); + $this->assertEquals($questionbankentry->id, $record->id); + } + + /** + * Test the version objects for a question. + * + * @covers ::get_question_version + */ + public function test_get_question_version() { + global $DB; + $this->resetAfterTest(); + // Setup. + $context = context_system::instance(); + $qgen = $this->getDataGenerator()->get_plugin_generator('core_question'); + $qcat = $qgen->create_question_category(array('contextid' => $context->id)); + $q1 = $qgen->create_question('shortanswer', null, array('category' => $qcat->id)); + // Make sure there is an entry in the entry table. + $sql = 'SELECT qbe.id as id, + qv.id as versionid + FROM {question_bank_entries} qbe + JOIN {question_versions} qv + ON qbe.id = qv.questionbankentryid + JOIN {question} q + ON qv.questionid = q.id + WHERE q.id = ?'; + $records = $DB->get_records_sql($sql, [$q1->id]); + $this->assertCount(1, $records); + $record = reset($records); + $questionversions = get_question_version($q1->id); + $questionversion = reset($questionversions); + $this->assertEquals($questionversion->id, $record->versionid); + } + + /** + * Test get next version of a question. + * + * @covers ::get_next_version + */ + public function test_get_next_version() { + global $DB; + $this->resetAfterTest(); + // Setup. + $context = context_system::instance(); + $qgen = $this->getDataGenerator()->get_plugin_generator('core_question'); + $qcat = $qgen->create_question_category(array('contextid' => $context->id)); + $q1 = $qgen->create_question('shortanswer', null, array('category' => $qcat->id)); + // Make sure there is an entry in the entry table. + $sql = 'SELECT qbe.id as id, + qv.id as versionid, + qv.version + FROM {question_bank_entries} qbe + JOIN {question_versions} qv + ON qbe.id = qv.questionbankentryid + JOIN {question} q + ON qv.questionid = q.id + WHERE q.id = ?'; + $records = $DB->get_records_sql($sql, [$q1->id]); + $this->assertCount(1, $records); + $record = reset($records); + $this->assertEquals(1, $record->version); + $nextversion = get_next_version($record->id); + $this->assertEquals(2, $nextversion); + } + } diff --git a/lib/upgrade.txt b/lib/upgrade.txt index 107823bed33..0f4130886f6 100644 --- a/lib/upgrade.txt +++ b/lib/upgrade.txt @@ -97,6 +97,51 @@ information provided here is intended especially for developers. - question_preview_popup_params() is moved to \qbank_previewquestion\helper::question_preview_popup_params() Calling these functions in the question will point to the plugin, but the deprecation message will be activated in MDL-72004. The deprecated codes are removed from the questionlib for those two methods. +* Function question_hash() from questionlib.php is deprecated without replacement. +* Some of the new and old methods in the questionlib.php now using type hinting. Please make a note of this while making changes +or implementing any question bank related feature in a plugin. These are the list of methods: + - is_latest() + - get_next_version() + - get_question_version() + - get_question_bank_entry() + - core_question_find_next_unused_idnumber() + - question_module_uses_questions() + - question_page_type_list() + - core_question_question_preview_pluginfile() + - question_rewrite_question_preview_urls() + - question_rewrite_question_urls() + - question_get_all_capabilities() + - question_get_question_capabilities() + - question_require_capability_on() + - question_has_capability_on() + - question_default_export_filename() + - get_import_export_formats() + - question_categorylist_parents() + - question_categorylist() + - question_make_default_categories() + - question_get_top_categories_for_contexts() + - sort_categories_by_tree() + - print_question_icon() + - question_sort_tags() + - _tidy_question() + - question_preload_questions() + - question_move_category_to_context() + - move_question_set_references() + - question_move_questions_to_category() + - idnumber_exist_in_question_category() + - question_move_question_tags_to_new_context() + - question_delete_activity() + - question_delete_course_category() + - question_delete_course() + - question_delete_context() + - question_delete_question() + - delete_question_bank_entry() + - question_category_in_use() + - question_category_delete_safe() + - question_context_has_any_questions() + - questions_in_use() + - question_save_qtype_order() + - question_reorder_qtypes() * The postgres driver now wraps calls to pg_field_type() and caches them in databasemeta to save an invisible internal DB call on every request. * The default type of 'core/toast' messages has been changed to 'information' (callers can still explicitely set the type) @@ -183,6 +228,7 @@ value to get the list of blocks that won't be displayed for a theme. core_message_get_user_notification_preferences and core_message_get_user_message_preferences Webservice are now returning enabled boolean on components > notifications > processors. loggedin and loggedoff are deprecated but present for backward compatibility. + === 3.11.4 === * A new option dontforcesvgdownload has been added to the $options parameter of the send_file() function. Note: This option overrides the forced download of directly accessed SVGs, so should only be used where the calling method is diff --git a/mod/quiz/attemptlib.php b/mod/quiz/attemptlib.php index e281b05f9d8..f32042dd1eb 100644 --- a/mod/quiz/attemptlib.php +++ b/mod/quiz/attemptlib.php @@ -1807,8 +1807,7 @@ class quiz_attempt { $question->length = $replacedquestion->length; $question->penalty = 0; $question->stamp = ''; - $question->version = 0; - $question->hidden = 0; + $question->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; $question->timecreated = null; $question->timemodified = null; $question->createdby = null; diff --git a/mod/quiz/classes/output/edit_renderer.php b/mod/quiz/classes/output/edit_renderer.php index e58956e4819..2674bbf8dfd 100644 --- a/mod/quiz/classes/output/edit_renderer.php +++ b/mod/quiz/classes/output/edit_renderer.php @@ -46,13 +46,13 @@ class edit_renderer extends \plugin_renderer_base { * * @param \quiz $quizobj object containing all the quiz settings information. * @param structure $structure object containing the structure of the quiz. - * @param \question_edit_contexts $contexts the relevant question bank contexts. + * @param \core_question\lib\question_edit_contexts $contexts the relevant question bank contexts. * @param \moodle_url $pageurl the canonical URL of this page. * @param array $pagevars the variables from {@link question_edit_setup()}. * @return string HTML to output. */ public function edit_page(\quiz $quizobj, structure $structure, - \question_edit_contexts $contexts, \moodle_url $pageurl, array $pagevars) { + \core_question\lib\question_edit_contexts $contexts, \moodle_url $pageurl, array $pagevars) { $output = ''; // Information at the top. @@ -493,7 +493,7 @@ class edit_renderer extends \plugin_renderer_base { * * @param structure $structure object containing the structure of the quiz. * @param \stdClass $section information about the section. - * @param \question_edit_contexts $contexts the relevant question bank contexts. + * @param \core_question\lib\question_edit_contexts $contexts the relevant question bank contexts. * @param array $pagevars the variables from {@link \question_edit_setup()}. * @param \moodle_url $pageurl the canonical URL of this page. * @return string HTML to output. @@ -513,7 +513,7 @@ class edit_renderer extends \plugin_renderer_base { * * @param structure $structure object containing the structure of the quiz. * @param int $slot which slot we are outputting. - * @param \question_edit_contexts $contexts the relevant question bank contexts. + * @param \core_question\lib\question_edit_contexts $contexts the relevant question bank contexts. * @param array $pagevars the variables from {@link \question_edit_setup()}. * @param \moodle_url $pageurl the canonical URL of this page. * @return string HTML to output. @@ -546,7 +546,7 @@ class edit_renderer extends \plugin_renderer_base { * * @param structure $structure object containing the structure of the quiz. * @param int $slot the first slot on the page we are outputting. - * @param \question_edit_contexts $contexts the relevant question bank contexts. + * @param \core_question\lib\question_edit_contexts $contexts the relevant question bank contexts. * @param array $pagevars the variables from {@link \question_edit_setup()}. * @param \moodle_url $pageurl the canonical URL of this page. * @return string HTML to output. @@ -580,12 +580,12 @@ class edit_renderer extends \plugin_renderer_base { * @param structure $structure object containing the structure of the quiz. * @param int $page the page number that this menu will add to. * @param \moodle_url $pageurl the canonical URL of this page. - * @param \question_edit_contexts $contexts the relevant question bank contexts. + * @param \core_question\lib\question_edit_contexts $contexts the relevant question bank contexts. * @param array $pagevars the variables from {@link \question_edit_setup()}. * @return string HTML to output. */ public function add_menu_actions(structure $structure, $page, \moodle_url $pageurl, - \question_edit_contexts $contexts, array $pagevars) { + \core_question\lib\question_edit_contexts $contexts, array $pagevars) { $actions = $this->edit_menu_actions($structure, $page, $pageurl, $pagevars); if (empty($actions)) { @@ -1084,13 +1084,13 @@ class edit_renderer extends \plugin_renderer_base { * is handled with the specific code for those.) * * @param structure $structure object containing the structure of the quiz. - * @param \question_edit_contexts $contexts the relevant question bank contexts. + * @param \core_question\lib\question_edit_contexts $contexts the relevant question bank contexts. * @param array $pagevars the variables from {@link \question_edit_setup()}. * @param \moodle_url $pageurl the canonical URL of this page. * @return bool Always returns true */ protected function initialise_editing_javascript(structure $structure, - \question_edit_contexts $contexts, array $pagevars, \moodle_url $pageurl) { + \core_question\lib\question_edit_contexts $contexts, array $pagevars, \moodle_url $pageurl) { $config = new \stdClass(); $config->resourceurl = '/mod/quiz/edit_rest.php'; @@ -1190,13 +1190,13 @@ class edit_renderer extends \plugin_renderer_base { * HTML for a page, with ids stripped, so it can be used as a javascript template. * * @param structure $structure object containing the structure of the quiz. - * @param \question_edit_contexts $contexts the relevant question bank contexts. + * @param \core_question\lib\question_edit_contexts $contexts the relevant question bank contexts. * @param array $pagevars the variables from {@link \question_edit_setup()}. * @param \moodle_url $pageurl the canonical URL of this page. * @return string HTML for a new page. */ protected function new_page_template(structure $structure, - \question_edit_contexts $contexts, array $pagevars, \moodle_url $pageurl) { + \core_question\lib\question_edit_contexts $contexts, array $pagevars, \moodle_url $pageurl) { if (!$structure->has_questions()) { return ''; } diff --git a/mod/quiz/classes/question/bank/custom_view.php b/mod/quiz/classes/question/bank/custom_view.php index 2a39fcc7570..177883782f9 100644 --- a/mod/quiz/classes/question/bank/custom_view.php +++ b/mod/quiz/classes/question/bank/custom_view.php @@ -44,8 +44,8 @@ class custom_view extends \core_question\local\bank\view { const MAX_TEXT_LENGTH = 200; /** - * Constructor for custom_view. - * @param \question_edit_contexts $contexts + * Constructor. + * @param \core_question\lib\question_edit_contexts $contexts * @param \moodle_url $pageurl * @param \stdClass $course course settings * @param \stdClass $cm activity settings. diff --git a/mod/quiz/classes/question/bank/question_name_text_column.php b/mod/quiz/classes/question/bank/question_name_text_column.php index 2439d315f49..9e6e568d029 100644 --- a/mod/quiz/classes/question/bank/question_name_text_column.php +++ b/mod/quiz/classes/question/bank/question_name_text_column.php @@ -48,7 +48,7 @@ class question_name_text_column extends question_name_column { $fields = parent::get_required_fields(); $fields[] = 'q.questiontext'; $fields[] = 'q.questiontextformat'; - $fields[] = 'q.idnumber'; + $fields[] = 'qbe.idnumber'; return $fields; } diff --git a/mod/quiz/classes/structure.php b/mod/quiz/classes/structure.php index 7779b82e6b6..94b568a6b2c 100644 --- a/mod/quiz/classes/structure.php +++ b/mod/quiz/classes/structure.php @@ -608,10 +608,12 @@ class structure { $slots = $DB->get_records_sql(" SELECT slot.id AS slotid, slot.slot, slot.questionid, slot.page, slot.maxmark, - slot.requireprevious, q.*, qc.contextid + slot.requireprevious, q.*, qc.id as category, qc.contextid FROM {quiz_slots} slot - LEFT JOIN {question} q ON q.id = slot.questionid - LEFT JOIN {question_categories} qc ON qc.id = q.category + LEFT JOIN {question} q ON q.id = slot.questionid + LEFT JOIN {question_versions} qv ON qv.questionid = q.id + LEFT JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid + LEFT JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid WHERE slot.quizid = ? ORDER BY slot.slot", array($quiz->id)); @@ -1237,7 +1239,7 @@ class structure { public function can_add_random_questions() { if ($this->canaddrandom === null) { $quizcontext = $this->quizobj->get_context(); - $relatedcontexts = new \question_edit_contexts($quizcontext); + $relatedcontexts = new \core_question\lib\question_edit_contexts($quizcontext); $usablecontexts = $relatedcontexts->having_cap('moodle/question:useall'); $this->canaddrandom = !empty($usablecontexts); diff --git a/mod/quiz/editrandom.php b/mod/quiz/editrandom.php index c3798dd33b2..fac51c0b0ba 100644 --- a/mod/quiz/editrandom.php +++ b/mod/quiz/editrandom.php @@ -53,7 +53,13 @@ $PAGE->set_url($url); $PAGE->set_pagelayout('admin'); $PAGE->add_body_class('limitedwidth'); -if (!$question = $DB->get_record('question', array('id' => $slot->questionid))) { +// Get the question object + category id. +$sql = "SELECT q.*, qv.status, qbe.questioncategoryid AS category + FROM {question} q + JOIN {question_versions} qv ON qv.questionid = q.id + JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid + WHERE q.id = :id"; +if (!$question = $DB->get_record_sql($sql, ['id' => $slot->questionid])) { print_error('questiondoesnotexist', 'question', $returnurl); } @@ -68,7 +74,7 @@ if (!$category = $DB->get_record('question_categories', array('id' => $question- question_require_capability_on($question, 'edit'); $thiscontext = context_module::instance($cm->id); -$contexts = new question_edit_contexts($thiscontext); +$contexts = new core_question\lib\question_edit_contexts($thiscontext); // Create the question editing form. $mform = new mod_quiz\form\randomquestion_form(new moodle_url('/mod/quiz/editrandom.php'), diff --git a/mod/quiz/lib.php b/mod/quiz/lib.php index 4e6f623f305..9060c752988 100644 --- a/mod/quiz/lib.php +++ b/mod/quiz/lib.php @@ -2421,7 +2421,7 @@ function mod_quiz_output_fragment_add_random_question_form($args) { global $CFG; require_once($CFG->dirroot . '/mod/quiz/addrandomform.php'); - $contexts = new \question_edit_contexts($args['context']); + $contexts = new \core_question\lib\question_edit_contexts($args['context']); $formoptions = [ 'contexts' => $contexts, 'cat' => $args['cat'] diff --git a/mod/quiz/locallib.php b/mod/quiz/locallib.php index 9df56a5b5a0..bd470be37e0 100644 --- a/mod/quiz/locallib.php +++ b/mod/quiz/locallib.php @@ -2432,15 +2432,17 @@ function quiz_add_random_questions($quiz, $addonpage, $categoryid, $number, // Find existing random questions in this category that are // not used by any quiz. $existingquestions = $DB->get_records_sql( - "SELECT q.id, q.qtype FROM {question} q - WHERE qtype = 'random' - AND category = ? - AND " . $DB->sql_compare_text('questiontext') . " = ? - AND NOT EXISTS ( - SELECT * - FROM {quiz_slots} - WHERE questionid = q.id) - ORDER BY id", array($category->id, $includesubcategories ? '1' : '0')); + "SELECT q.id, q.qtype + FROM {question} q + JOIN {question_versions} qv ON qv.questionid = q.id + JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid + WHERE q.qtype = 'random' + AND qbe.questioncategoryid = ? + AND " . $DB->sql_compare_text('questiontext') . " = ? + AND NOT EXISTS (SELECT * + FROM {quiz_slots} + WHERE questionid = q.id) + ORDER BY q.id", array($category->id, $includesubcategories ? '1' : '0')); for ($i = 0; $i < $number; $i++) { // Take as many of orphaned "random" questions as needed. @@ -2450,7 +2452,7 @@ function quiz_add_random_questions($quiz, $addonpage, $categoryid, $number, $form->includesubcategories = $includesubcategories; $form->fromtags = $tagstrings; $form->defaultmark = 1; - $form->hidden = 1; + $form->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; $form->stamp = make_unique_id_code(); // Set the unique code (not to be changed). $question = new stdClass(); $question->qtype = 'random'; diff --git a/mod/quiz/tests/local_structure_slot_random_test.php b/mod/quiz/tests/local_structure_slot_random_test.php index 618726206eb..d46781ba8c1 100644 --- a/mod/quiz/tests/local_structure_slot_random_test.php +++ b/mod/quiz/tests/local_structure_slot_random_test.php @@ -57,7 +57,7 @@ class mod_quiz_local_structure_slot_random_test extends advanced_testcase { $form->includesubcategories = true; $form->fromtags = []; $form->defaultmark = 1; - $form->hidden = 1; + $form->status = \core_question\local\bank\constants::QUESTION_STATUS_HIDDEN; $form->stamp = make_unique_id_code(); $question = new stdClass(); $question->qtype = 'random'; @@ -355,7 +355,7 @@ class mod_quiz_local_structure_slot_random_test extends advanced_testcase { $form->includesubcategories = true; $form->fromtags = []; $form->defaultmark = 1; - $form->hidden = 1; + $form->status = \core_question\local\bank\constants::QUESTION_STATUS_HIDDEN; $form->stamp = make_unique_id_code(); $question = new stdClass(); $question->qtype = 'random'; diff --git a/mod/quiz/tests/quiz_question_bank_view_test.php b/mod/quiz/tests/quiz_question_bank_view_test.php index 39115168315..faad9b0f322 100644 --- a/mod/quiz/tests/quiz_question_bank_view_test.php +++ b/mod/quiz/tests/quiz_question_bank_view_test.php @@ -51,7 +51,7 @@ class quiz_question_bank_view_testcase extends advanced_testcase { $cm = get_coursemodule_from_instance('quiz', $quiz->id); // Create a question in the default category. - $contexts = new question_edit_contexts($context); + $contexts = new core_question\lib\question_edit_contexts($context); $cat = question_make_default_categories($contexts->all()); $questiondata = $questiongenerator->create_question('numerical', null, ['name' => 'Example question', 'category' => $cat->id]); diff --git a/question/bank/bulkmove/tests/helper_test.php b/question/bank/bulkmove/tests/helper_test.php index 22591569c41..d96b31d0525 100644 --- a/question/bank/bulkmove/tests/helper_test.php +++ b/question/bank/bulkmove/tests/helper_test.php @@ -202,7 +202,7 @@ class helper_test extends \advanced_testcase { public function test_get_displaydata() { $this->helper_setup(); $coursecontext = \context_course::instance($this->course->id); - $contexts = new \question_edit_contexts($coursecontext); + $contexts = new question_edit_contexts($coursecontext); $addcontexts = $contexts->having_cap('moodle/question:add'); $url = new \moodle_url('/question/bank/bulkmove/move.php'); $displaydata = \qbank_bulkmove\helper::get_displaydata($addcontexts, $url, $url); diff --git a/question/bank/comment/tests/behat/behat_qbank_comment.php b/question/bank/comment/tests/behat/behat_qbank_comment.php index 344ae8a9bd7..7b22aa2dded 100644 --- a/question/bank/comment/tests/behat/behat_qbank_comment.php +++ b/question/bank/comment/tests/behat/behat_qbank_comment.php @@ -104,7 +104,7 @@ class behat_qbank_comment extends behat_question_base { if ($this->running_javascript()) { $commentstextarea = $this->find('css', - '.question-comment-view .comment-area textarea', $exception); + '.comment-area textarea', $exception); $commentstextarea->setValue($comment); // We delay 1 second which is all we need. @@ -115,6 +115,31 @@ class behat_qbank_comment extends behat_question_base { } } + /** + * Deletes the specified comment from the current question comment preview. + * + * @Then I delete :arg comment from question preview + * @param string $comment + */ + public function i_delete_comment_from_question_preview($comment) { + + $exception = new ElementNotFoundException($this->getSession(), '"' . $comment . '" comment '); + + // Using xpath liternal to avoid possible problems with comments containing quotes. + $commentliteral = behat_context_helper::escape($comment); + + $commentxpath = "//*[contains(concat(' ', normalize-space(@class), ' '), ' comment-ctrl ')]" . + "/descendant::div[@class='comment-message'][contains(., $commentliteral)]"; + + // Click on delete icon. + $this->execute('behat_general::i_click_on_in_the', + ["Delete comment posted by", "icon", $this->escape($commentxpath), "xpath_element"] + ); + + // Wait for the animation to finish, in theory is just 1 sec, adding 4 just in case. + $this->getSession()->wait(4 * 1000); + } + /** * Deletes the specified comment from the current question comment modal. * diff --git a/question/bank/comment/tests/behat/question_comment.feature b/question/bank/comment/tests/behat/question_comment.feature index 3ec89c3d742..f1795f880c4 100644 --- a/question/bank/comment/tests/behat/question_comment.feature +++ b/question/bank/comment/tests/behat/question_comment.feature @@ -63,6 +63,7 @@ Feature: A Teacher can comment in a question And I navigate to "Question bank" in current page administration And I set the field "Select a category" to "Test questions" And I choose "Preview" action for "First question" in the question bank + And I click on "Comments" "link" Then I should see "Save comment" And I add "Super test comment 01" comment to question preview And I click on "Save comment" "link" @@ -71,7 +72,8 @@ Feature: A Teacher can comment in a question And I click on "Close preview" "button" Then I should see "1" on the comments column And I choose "Preview" action for "First question" in the question bank - And I delete "Super test comment 01" comment from question + And I click on "Comments" "link" + And I delete "Super test comment 01" comment from question preview And I should not see "Super test comment 01" And I click on "Close preview" "button" Then I should see "0" on the comments column @@ -100,6 +102,7 @@ Feature: A Teacher can comment in a question And I press "id_submitbutton" Then I should see "Essay 01 new" And I choose "Preview" action for "Essay 01 new" in the question bank + And I click on "Comments" "link" Then I should see "Save comment" And I log out Then I log in as "teacher2" diff --git a/question/bank/customfields/tests/customfield_test.php b/question/bank/customfields/tests/customfield_test.php index cb7df9109f6..07c3d86781a 100644 --- a/question/bank/customfields/tests/customfield_test.php +++ b/question/bank/customfields/tests/customfield_test.php @@ -287,7 +287,12 @@ class qbank_customfields_customfield_testcase extends advanced_testcase { $this->restore_course($backupid, $coursefullname, $courseshortname . '_2', $newcategory->id); // The questions and their associated custom fields should have been restored. - $newquestion1 = $DB->get_record('question', ['idnumber' => 'q1']); + $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 + WHERE qbe.idnumber = ?'; + $newquestion1 = $DB->get_record_sql($sql, ['q1']); $newquestion1cfdata = $customfieldhandler->export_instance_data_object($newquestion1->id); $this->assertEquals('some text', $newquestion1cfdata->f1); $this->assertEquals('Yes', $newquestion1cfdata->f2); @@ -295,7 +300,7 @@ class qbank_customfields_customfield_testcase extends advanced_testcase { $this->assertEquals('b', $newquestion1cfdata->f4); $this->assertEquals('test', $newquestion1cfdata->f5); - $newquestion2 = $DB->get_record('question', ['idnumber' => 'q2']); + $newquestion2 = $DB->get_record_sql($sql, ['q2']); $newquestion2cfdata = $customfieldhandler->export_instance_data_object($newquestion2->id); $this->assertEquals('some more text', $newquestion2cfdata->f1); $this->assertEquals('No', $newquestion2cfdata->f2); diff --git a/question/bank/deletequestion/classes/delete_action_column.php b/question/bank/deletequestion/classes/delete_action_column.php index b3af0380ee0..dc5f005224f 100644 --- a/question/bank/deletequestion/classes/delete_action_column.php +++ b/question/bank/deletequestion/classes/delete_action_column.php @@ -24,6 +24,7 @@ namespace qbank_deletequestion; +use core_question\local\bank\constants; use core_question\local\bank\menu_action_column_base; /** @@ -82,7 +83,7 @@ class delete_action_column extends menu_action_column_base { if (!question_has_capability_on($question, 'edit')) { return [null, null, null]; } - if ($question->hidden) { + if ($question->status === constants::QUESTION_STATUS_HIDDEN) { $hiddenparams = array( 'unhide' => $question->id, 'sesskey' => sesskey()); @@ -102,7 +103,6 @@ class delete_action_column extends menu_action_column_base { public function get_required_fields(): array { $required = parent::get_required_fields(); - $required[] = 'q.hidden'; return $required; } } diff --git a/question/bank/deletequestion/delete.php b/question/bank/deletequestion/delete.php index a2a6f58752b..5d532ac48c3 100644 --- a/question/bank/deletequestion/delete.php +++ b/question/bank/deletequestion/delete.php @@ -51,7 +51,7 @@ if ($cmid) { throw new moodle_exception('missingcourseorcmid', 'question'); } -$contexts = new question_edit_contexts($thiscontext); +$contexts = new core_question\lib\question_edit_contexts($thiscontext); $url = new moodle_url('/question/bank/deletequestion/delete.php'); $PAGE->set_url($url); @@ -62,7 +62,7 @@ $PAGE->set_heading($COURSE->fullname); // Unhide a question. if (($unhide = optional_param('unhide', '', PARAM_INT)) and confirm_sesskey()) { question_require_capability_on($unhide, 'edit'); - $DB->set_field('question', 'hidden', 0, array('id' => $unhide)); + $DB->set_field('question_versions', 'status', 0, ['questionid' => $questionid]); // Purge these questions from the cache. \question_bank::notify_question_edited($unhide); @@ -81,7 +81,7 @@ if ($deleteselected && ($confirm = optional_param('confirm', '', PARAM_ALPHANUM) $questionid = (int)$questionid; question_require_capability_on($questionid, 'edit'); if (questions_in_use(array($questionid))) { - $DB->set_field('question', 'hidden', 1, array('id' => $questionid)); + $DB->set_field('question_versions', 'status', 1, ['questionid' => $questionid]); } else { question_delete_question($questionid); } diff --git a/question/bank/deletequestion/tests/behat/delete_question_column.feature b/question/bank/deletequestion/tests/behat/delete_question_column.feature index 5baea132dd8..70dde129068 100644 --- a/question/bank/deletequestion/tests/behat/delete_question_column.feature +++ b/question/bank/deletequestion/tests/behat/delete_question_column.feature @@ -55,7 +55,8 @@ Feature: Use the qbank plugin manager page for deletequestion Given I log in as "admin" And I am on the "Test quiz" "quiz activity" page And I navigate to "Question bank > Questions" in current page administration - And I click on "Select all" "checkbox" + And I click on "First question" "checkbox" + And I click on "First question second" "checkbox" And I click on "With selected" "button" And I click on question bulk action "deleteselected" And I click on "Delete" "button" in the "Confirm" "dialogue" diff --git a/question/bank/editquestion/classes/editquestion_helper.php b/question/bank/editquestion/classes/editquestion_helper.php index 7430eece457..a5ba652d7f2 100644 --- a/question/bank/editquestion/classes/editquestion_helper.php +++ b/question/bank/editquestion/classes/editquestion_helper.php @@ -27,6 +27,8 @@ namespace qbank_editquestion; +use core_question\local\bank\constants; + /** * Class editquestion_helper for methods related to add/edit/copy * @@ -83,4 +85,36 @@ class editquestion_helper { } return $PAGE->get_renderer('qbank_editquestion')->render_create_new_question_button($addquestiondisplay); } + + /** + * Get the string for the status of the question. + * + * @param int $status + * @return string + */ + public static function get_question_status_string($status): string { + if ((int)$status == constants::QUESTION_STATUS_READY) { + $statusstring = get_string('questionstatusready', 'qbank_editquestion'); + } else if ((int)$status == constants::QUESTION_STATUS_HIDDEN) { + $statusstring = get_string('questionstatushidden', 'qbank_editquestion'); + } else if ((int)$status == constants::QUESTION_STATUS_DRAFT) { + $statusstring = get_string('questionstatusdraft', 'qbank_editquestion'); + } else { + $statusstring = get_string('questionstatusunknown', 'qbank_editquestion'); + } + return $statusstring; + } + + /** + * Get the array of status of the questions. + * + * @return array + */ + public static function get_question_status_list(): array { + $statuslist = []; + $statuslist[constants::QUESTION_STATUS_READY] = get_string('questionstatusready', 'qbank_editquestion'); + $statuslist[constants::QUESTION_STATUS_DRAFT] = get_string('questionstatusdraft', 'qbank_editquestion'); + return $statuslist; + } + } diff --git a/question/bank/editquestion/classes/output/renderer.php b/question/bank/editquestion/classes/output/renderer.php index c3ea82e536b..3d63699cc44 100644 --- a/question/bank/editquestion/classes/output/renderer.php +++ b/question/bank/editquestion/classes/output/renderer.php @@ -57,4 +57,14 @@ class renderer extends \plugin_renderer_base { return $this->render_from_template('qbank_editquestion/add_new_question', $addquestiondata); } + /** + * Render question information for edit form. + * + * @param array $questiondata + * @return bool|string + */ + public function render_question_info($questiondata) { + return $this->render_from_template('qbank_editquestion/question_info', $questiondata); + } + } diff --git a/question/bank/editquestion/classes/plugin_feature.php b/question/bank/editquestion/classes/plugin_feature.php index 1cd8bf9492e..98ebb47f614 100644 --- a/question/bank/editquestion/classes/plugin_feature.php +++ b/question/bank/editquestion/classes/plugin_feature.php @@ -38,7 +38,8 @@ class plugin_feature extends \core_question\local\bank\plugin_features_base{ public function get_question_columns($qbank): array { return [ new edit_action_column($qbank), - new copy_action_column($qbank) + new copy_action_column($qbank), + new question_status_column($qbank) ]; } diff --git a/question/bank/editquestion/classes/question_status_column.php b/question/bank/editquestion/classes/question_status_column.php new file mode 100644 index 00000000000..c0980fc2591 --- /dev/null +++ b/question/bank/editquestion/classes/question_status_column.php @@ -0,0 +1,56 @@ +. + +/** + * A column to show the status of the question. + * + * @package qbank_editquestion + * @copyright 2021 Catalyst IT Australia Pty Ltd + * @author Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace qbank_editquestion; + +defined('MOODLE_INTERNAL') || die(); + +use core_question\local\bank\column_base; + +/** + * Class question_status_column. + * + * @package qbank_editquestion + * @copyright 2021 Catalyst IT Australia Pty Ltd + * @author Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class question_status_column extends column_base { + + public function get_name(): string { + return 'questionstatus'; + } + + protected function get_title(): string { + return get_string('questionstatus', 'qbank_editquestion'); + } + + protected function display_content($question, $rowclasses): void { + global $DB; + $version = $DB->get_record('question_versions', ['questionid' => $question->id], 'status'); + echo \html_writer::tag('a', editquestion_helper::get_question_status_string($version->status)); + } + +} diff --git a/question/bank/editquestion/lang/en/qbank_editquestion.php b/question/bank/editquestion/lang/en/qbank_editquestion.php index fc1f052af13..853d50530c9 100644 --- a/question/bank/editquestion/lang/en/qbank_editquestion.php +++ b/question/bank/editquestion/lang/en/qbank_editquestion.php @@ -25,3 +25,14 @@ $string['pluginname'] = 'Edit questions'; $string['privacy:metadata'] = 'The Edit questions question bank plugin does not store any user data.'; + +// Question status. +$string['questionstatus'] = 'Status'; +$string['questionstatusready'] = 'Ready'; +$string['questionstatushidden'] = 'Hidden'; +$string['questionstatusdraft'] = 'Draft'; +$string['questionstatusunknown'] = 'Unknown'; + +// Edit form. +$string['versioninfo'] = 'Version'; +$string['status'] = 'Question status'; diff --git a/question/bank/editquestion/question.php b/question/bank/editquestion/question.php index e04feb444ad..4da06924855 100644 --- a/question/bank/editquestion/question.php +++ b/question/bank/editquestion/question.php @@ -108,7 +108,7 @@ if ($cmid) { } else { throw new moodle_exception('missingcourseorcmid', 'question'); } -$contexts = new question_edit_contexts($thiscontext); +$contexts = new core_question\lib\question_edit_contexts($thiscontext); $PAGE->set_pagelayout('admin'); if (optional_param('addcancel', false, PARAM_BOOL)) { @@ -174,6 +174,7 @@ if ($id) { if (!$formeditable) { question_require_capability_on($question, 'view'); } + $question->beingcopied = false; if ($makecopy) { // If we are duplicating a question, add some indication to the question name. $question->name = get_string('questionnamecopy', 'question', $question->name); @@ -210,6 +211,13 @@ if ($formeditable && $id) { $toform->appendqnumstring = $appendqnumstring; $toform->returnurl = $originalreturnurl; $toform->makecopy = $makecopy; +if (isset($question->id)) { + $questionobject = question_bank::load_question($question->id); + $toform->status = $questionobject->status; + $toform->idnumber = $questionobject->idnumber; +} else { + $toform->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; +} if ($cm !== null) { $toform->cmid = $cm->id; $toform->courseid = $cm->course; @@ -236,7 +244,12 @@ if ($mform->is_cancelled()) { // If we are saving as a copy, break the connection to the old question. if ($makecopy) { $question->id = 0; - $question->hidden = 0; // Copies should not be hidden. + $question->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; // Copies should not be hidden. + } + + // If is will be added directly to a module send the module name to be referenced. + if ($appendqnumstring && $cm) { + $fromform->modulename = 'mod_' . $cm->modname; } // Process the combination of usecurrentcat, categorymoveto and category form @@ -248,7 +261,7 @@ if ($mform->is_cancelled()) { // If we are moving a question, check we have permission to move it from // whence it came (Where we are moving to is validated by the form). list($newcatid, $newcontextid) = explode(',', $fromform->category); - if (!empty($question->id) && $newcatid != $question->category) { + if (!empty($question->id) && $newcatid != $question->categoryobject->id) { $contextid = $newcontextid; question_require_capability_on($question, 'move'); } else { diff --git a/question/bank/viewcreator/templates/modifier_display.mustache b/question/bank/editquestion/templates/question_info.mustache similarity index 71% rename from question/bank/viewcreator/templates/modifier_display.mustache rename to question/bank/editquestion/templates/question_info.mustache index 9f950643576..387514488ff 100644 --- a/question/bank/viewcreator/templates/modifier_display.mustache +++ b/question/bank/editquestion/templates/question_info.mustache @@ -1,33 +1,32 @@ {{! 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 . }} {{! - @template qbank_viewcreator/modifier_display + @template qbank_editquestion/question_info + Example context (json): { - "displaydata": [ - { - "modifier": "Marc Ghaly", - "date": "2 June 2021, 5:32 PM", - } + "elements": [ + {"pluginhtml":"
Version: 1
"}, + {"pluginhtml":"
Usage: 1
"}, ] } }} - - - {{modifier}} - -
- - {{date}} - +
+ {{#editelements}} + {{{pluginhtml}}} + {{/editelements}} +
diff --git a/question/bank/exporttoxml/exportone.php b/question/bank/exporttoxml/exportone.php index e4303c3ae72..c91282bd0d9 100644 --- a/question/bank/exporttoxml/exportone.php +++ b/question/bank/exporttoxml/exportone.php @@ -50,7 +50,7 @@ if ($cmid) { require_sesskey(); // Load the necessary data. -$contexts = new question_edit_contexts($thiscontext); +$contexts = new core_question\lib\question_edit_contexts($thiscontext); $questiondata = question_bank::load_question_data($questionid); // Check permissions. diff --git a/question/bank/managecategories/category.php b/question/bank/managecategories/category.php index a76021258bd..339c2a2da5e 100644 --- a/question/bank/managecategories/category.php +++ b/question/bank/managecategories/category.php @@ -102,7 +102,8 @@ if ($param->delete) { } helper::question_remove_stale_questions_from_category($param->delete); - $questionstomove = $DB->count_records("question", ["category" => $param->delete]); + + $questionstomove = $DB->count_records('question_bank_entries', ['questioncategoryid' => $param->delete]); // Second pass, if we still have questions to move, setup the form. if ($questionstomove) { diff --git a/question/bank/managecategories/classes/helper.php b/question/bank/managecategories/classes/helper.php index cb4f1080ce0..cece45aeaae 100644 --- a/question/bank/managecategories/classes/helper.php +++ b/question/bank/managecategories/classes/helper.php @@ -17,6 +17,7 @@ namespace qbank_managecategories; use context; +use core_question\local\bank\constants; use moodle_exception; use html_writer; @@ -60,14 +61,19 @@ class helper { public static function question_remove_stale_questions_from_category(int $categoryid): void { global $DB; - $select = 'category = :categoryid AND (qtype = :qtype OR hidden = :hidden)'; - $params = ['categoryid' => $categoryid, 'qtype' => 'random', 'hidden' => 1]; - $questions = $DB->get_recordset_select("question", $select, $params, '', 'id'); + $sql = 'SELECT q.id + FROM {question} q + JOIN {question_versions} qv ON qv.questionid = q.id + JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid + WHERE qbe.questioncategoryid = :categoryid + AND (q.qtype = :qtype OR qv.status = :status)'; + + $params = ['categoryid' => $categoryid, 'qtype' => 'random', 'status' => 1]; + $questions = $DB->get_records_sql($sql, $params); foreach ($questions as $question) { // The function question_delete_question does not delete questions in use. question_delete_question($question->id); } - $questions->close(); } /** @@ -240,22 +246,43 @@ class helper { * Get all the category objects, including a count of the number of questions in that category, * for all the categories in the lists $contexts. * - * @param mixed $contexts either a single contextid, or a comma-separated list of context ids. + * @param $contexts * @param string $sortorder used as the ORDER BY clause in the select statement. * @param bool $top Whether to return the top categories or not. + * @param int $showallversions 1 to show all versions not only the latest. + * @param int|null $statustocount The version status to count. If null will get all except hidden. * @return array of category objects. * @throws \dml_exception */ public static function get_categories_for_contexts($contexts, string $sortorder = 'parent, sortorder, name ASC', - bool $top = false): array { + bool $top = false, int $showallversions = 0, + ?int $statustocount = null): array { global $DB; $topwhere = $top ? '' : 'AND c.parent <> 0'; - return $DB->get_records_sql(" - SELECT c.*, (SELECT count(1) FROM {question} q - WHERE c.id = q.category AND q.hidden='0' AND q.parent='0') AS questioncount - FROM {question_categories} c - WHERE c.contextid IN ($contexts) $topwhere - ORDER BY $sortorder"); + $statuscondition = "AND (qv.status = '". question_version_status::QUESTION_STATUS_READY . "' " . + " OR qv.status = '" . question_version_status::QUESTION_STATUS_DRAFT . "' )"; + + $sql = "SELECT c.*, + (SELECT COUNT(1) + 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 = '0' + $statuscondition + AND c.id = qbe.questioncategoryid + AND ($showallversions = 1 + OR (qv.version = (SELECT MAX(v.version) + FROM {question_versions} v + JOIN {question_bank_entries} be ON be.id = v.questionbankentryid + WHERE be.id = qbe.id) + ) + ) + ) AS questioncount + FROM {question_categories} c + WHERE c.contextid IN ($contexts) $topwhere + ORDER BY $sortorder"; + + return $DB->get_records_sql($sql); } /** diff --git a/question/bank/managecategories/classes/question_category_object.php b/question/bank/managecategories/classes/question_category_object.php index e5c5f0ef795..66cf3df6879 100644 --- a/question/bank/managecategories/classes/question_category_object.php +++ b/question/bank/managecategories/classes/question_category_object.php @@ -328,8 +328,15 @@ class question_category_object { */ public function move_questions(int $oldcat, int $newcat): void { global $DB; - $questionids = $DB->get_records_select_menu('question', - 'category = ? AND (parent = 0 OR parent = id)', [$oldcat], '', 'id,1'); + + $sql = "SELECT q.id, 1 + FROM {question} q + JOIN {question_versions} qv ON qv.questionid = q.id + JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid + WHERE qbe.questioncategoryid = ? + AND (q.parent = 0 OR q.parent = q.id)"; + + $questionids = $DB->get_records_sql_menu($sql, [$oldcat]); question_move_questions_to_category(array_keys($questionids), $newcat); } @@ -479,14 +486,25 @@ class question_category_object { // If the category name has changed, rename any random questions in that category. if ($oldcat->name != $cat->name) { - $where = "qtype = 'random' AND category = ? AND " . $DB->sql_compare_text('questiontext') . " = ?"; + // Get the question ids for each question category. + $sql = "SELECT q.id + FROM {question} q + JOIN {question_versions} qv ON qv.questionid = q.id + JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid + WHERE qbe.questioncategoryid = ?"; - $randomqtype = question_bank::get_qtype('random'); - $randomqname = $randomqtype->question_name($cat, false); - $DB->set_field_select('question', 'name', $randomqname, $where, [$cat->id, '0']); + $questionids = $DB->get_records_sql($sql, [$cat->id]); - $randomqname = $randomqtype->question_name($cat, true); - $DB->set_field_select('question', 'name', $randomqname, $where, [$cat->id, '1']); + foreach ($questionids as $question) { + $where = "qtype = 'random' AND id = ? AND " . $DB->sql_compare_text('questiontext') . " = ?"; + + $randomqtype = question_bank::get_qtype('random'); + $randomqname = $randomqtype->question_name($cat, false); + $DB->set_field_select('question', 'name', $randomqname, $where, [$question->id, '0']); + + $randomqname = $randomqtype->question_name($cat, true); + $DB->set_field_select('question', 'name', $randomqname, $where, [$question->id, '1']); + } } if ($oldcat->contextid != $tocontextid) { diff --git a/question/bank/managecategories/tests/helper_test.php b/question/bank/managecategories/tests/helper_test.php index b033c211241..7c9248234ec 100644 --- a/question/bank/managecategories/tests/helper_test.php +++ b/question/bank/managecategories/tests/helper_test.php @@ -72,21 +72,21 @@ class helper_test extends \advanced_testcase { $qcat1 = $this->qgenerator->create_question_category(['contextid' => $this->context->id]); $q1a = $this->qgenerator->create_question('shortanswer', null, ['category' => $qcat1->id]); // Will be hidden. - $DB->set_field('question', 'hidden', 1, ['id' => $q1a->id]); + $DB->set_field('question_versions', 'status', 1, ['questionid' => $q1a->id]); $qcat2 = $this->qgenerator->create_question_category(['contextid' => $this->context->id]); $q2a = $this->qgenerator->create_question('shortanswer', null, ['category' => $qcat2->id]); // Will be hidden. $q2b = $this->qgenerator->create_question('shortanswer', null, ['category' => $qcat2->id]); // Will be hidden but used. - $DB->set_field('question', 'hidden', 1, ['id' => $q2a->id]); - $DB->set_field('question', 'hidden', 1, ['id' => $q2b->id]); + $DB->set_field('question_versions', 'status', 1, ['questionid' => $q2a->id]); + $DB->set_field('question_versions', 'status', 1, ['questionid' => $q2b->id]); quiz_add_quiz_question($q2b->id, $this->quiz); quiz_add_random_questions($this->quiz, 0, $qcat2->id, 1, false); // We added one random question to the quiz and we expect the quiz to have only one random question. $q2d = $DB->get_record_sql("SELECT q.* - FROM {question} q - JOIN {quiz_slots} s ON s.questionid = q.id - WHERE q.qtype = :qtype + FROM {question} q + JOIN {quiz_slots} s ON s.questionid = q.id + WHERE q.qtype = :qtype AND s.quizid = :quizid", ['qtype' => 'random', 'quizid' => $this->quiz->id], MUST_EXIST); @@ -95,25 +95,45 @@ class helper_test extends \advanced_testcase { $q1b = $this->qgenerator->create_question('random', null, ['category' => $qcat1->id]); // Will not be used. $q2c = $this->qgenerator->create_question('random', null, ['category' => $qcat2->id]); // Will not be used. - $this->assertEquals(2, $DB->count_records('question', ['category' => $qcat1->id])); - $this->assertEquals(4, $DB->count_records('question', ['category' => $qcat2->id])); + $sql = "SELECT count(q.id) + FROM {question} q + JOIN {question_versions} qv ON qv.questionid = q.id + JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid + WHERE qbe.questioncategoryid = ?"; + $this->assertEquals(2, $DB->count_records_sql($sql, [$qcat1->id])); + $this->assertEquals(4, $DB->count_records_sql($sql, [$qcat2->id])); // Non-existing category, nothing will happen. helper::question_remove_stale_questions_from_category(0); - $this->assertEquals(2, $DB->count_records('question', ['category' => $qcat1->id])); - $this->assertEquals(4, $DB->count_records('question', ['category' => $qcat2->id])); + $sql = "SELECT count(q.id) + FROM {question} q + JOIN {question_versions} qv ON qv.questionid = q.id + JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid + WHERE qbe.questioncategoryid = ?"; + $this->assertEquals(2, $DB->count_records_sql($sql, [$qcat1->id])); + $this->assertEquals(4, $DB->count_records_sql($sql, [$qcat2->id])); // First category, should be empty afterwards. helper::question_remove_stale_questions_from_category($qcat1->id); - $this->assertEquals(0, $DB->count_records('question', ['category' => $qcat1->id])); - $this->assertEquals(4, $DB->count_records('question', ['category' => $qcat2->id])); + $sql = "SELECT count(q.id) + FROM {question} q + JOIN {question_versions} qv ON qv.questionid = q.id + JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid + WHERE qbe.questioncategoryid = ?"; + $this->assertEquals(0, $DB->count_records_sql($sql, [$qcat1->id])); + $this->assertEquals(4, $DB->count_records_sql($sql, [$qcat2->id])); $this->assertFalse($DB->record_exists('question', ['id' => $q1a->id])); $this->assertFalse($DB->record_exists('question', ['id' => $q1b->id])); // Second category, used questions should be left untouched. helper::question_remove_stale_questions_from_category($qcat2->id); - $this->assertEquals(0, $DB->count_records('question', ['category' => $qcat1->id])); - $this->assertEquals(2, $DB->count_records('question', ['category' => $qcat2->id])); + $sql = "SELECT count(q.id) + FROM {question} q + JOIN {question_versions} qv ON qv.questionid = q.id + JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid + WHERE qbe.questioncategoryid = ?"; + $this->assertEquals(0, $DB->count_records_sql($sql, [$qcat1->id])); + $this->assertEquals(2, $DB->count_records_sql($sql, [$qcat2->id])); $this->assertFalse($DB->record_exists('question', ['id' => $q2a->id])); $this->assertTrue($DB->record_exists('question', ['id' => $q2b->id])); $this->assertFalse($DB->record_exists('question', ['id' => $q2c->id])); diff --git a/question/bank/managecategories/tests/question_category_object_test.php b/question/bank/managecategories/tests/question_category_object_test.php index 3f677364137..0fd03de2a7e 100644 --- a/question/bank/managecategories/tests/question_category_object_test.php +++ b/question/bank/managecategories/tests/question_category_object_test.php @@ -25,7 +25,7 @@ use context; use context_course; use context_module; use moodle_url; -use question_edit_contexts; +use core_question\lib\question_edit_contexts; use stdClass; /** diff --git a/question/bank/tagquestion/classes/external/submit_tags.php b/question/bank/tagquestion/classes/external/submit_tags.php index 9d6b36fb3ab..c4a79153ffd 100644 --- a/question/bank/tagquestion/classes/external/submit_tags.php +++ b/question/bank/tagquestion/classes/external/submit_tags.php @@ -79,15 +79,17 @@ class submit_tags extends external_api { if (!$question = $DB->get_record_sql(' SELECT q.*, qc.contextid - FROM {question} q - JOIN {question_categories} qc ON qc.id = q.category - WHERE q.id = ?', [$params['questionid']])) { + FROM {question} q + JOIN {question_versions} qv ON qv.questionid = q.id + JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid + JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid + WHERE q.id = ?', [$questionid])) { throw new \moodle_exception('questiondoesnotexist', 'question'); } $cantag = question_has_capability_on($question, 'tag'); $questioncontext = \context::instance_by_id($question->contextid); - $contexts = new \question_edit_contexts($editingcontext); + $contexts = new \core_question\lib\question_edit_contexts($editingcontext); $formoptions = [ 'editingcontext' => $editingcontext, diff --git a/question/bank/tagquestion/lib.php b/question/bank/tagquestion/lib.php index 936490010e4..d75835876e4 100644 --- a/question/bank/tagquestion/lib.php +++ b/question/bank/tagquestion/lib.php @@ -53,9 +53,15 @@ function qbank_tagquestion_output_fragment_tags_form($args) { $filtercourses = null; } - $category = $DB->get_record('question_categories', ['id' => $question->category]); + $sql = "SELECT qc.* + FROM {question} q + JOIN {question_versions} qv ON qv.questionid = q.id + JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid + JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid + WHERE q.id = :id"; + $category = $DB->get_record_sql($sql, ['id' => $question->id]); $questioncontext = \context::instance_by_id($category->contextid); - $contexts = new \question_edit_contexts($editingcontext); + $contexts = new \core_question\lib\question_edit_contexts($editingcontext); // Load the question tags and filter the course tags by the current course. if (core_tag_tag::is_enabled('core_question', 'question')) { diff --git a/question/bank/viewcreator/classes/modifier_name_column.php b/question/bank/viewcreator/classes/modifier_name_column.php deleted file mode 100644 index 19e6737181b..00000000000 --- a/question/bank/viewcreator/classes/modifier_name_column.php +++ /dev/null @@ -1,74 +0,0 @@ -. - -namespace qbank_viewcreator; - -use core_question\local\bank\column_base; - -/** - * A column type for the name of the question last modifier. - * - * @package qbank_viewcreator - * @copyright 2009 Tim Hunt - * @author 2021 Ghaly Marc-Alexandre - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ -class modifier_name_column extends column_base { - - public function get_name(): string { - return 'modifiername'; - } - - protected function get_title(): string { - return get_string('lastmodifiedby', 'question'); - } - - protected function display_content($question, $rowclasses): void { - global $PAGE; - $displaydata = []; - - if (!empty($question->modifierfirstname) && !empty($question->modifierlastname)) { - $u = new \stdClass(); - $u = username_load_fields_from_object($u, $question, 'modifier'); - $displaydata['date'] = userdate($question->timemodified, get_string('strftimedatetime', 'langconfig')); - $displaydata['modifier'] = fullname($u); - echo $PAGE->get_renderer('qbank_viewcreator')->render_modifier_name($displaydata); - } - } - - public function get_extra_joins(): array { - return ['um' => 'LEFT JOIN {user} um ON um.id = q.modifiedby']; - } - - public function get_required_fields(): array { - $allnames = \core_user\fields::get_name_fields(); - $requiredfields = []; - foreach ($allnames as $allname) { - $requiredfields[] = 'um.' . $allname . ' AS modifier' . $allname; - } - $requiredfields[] = 'q.timemodified'; - return $requiredfields; - } - - public function is_sortable(): array { - return [ - 'firstname' => ['field' => 'um.firstname', 'title' => get_string('firstname')], - 'lastname' => ['field' => 'um.lastname', 'title' => get_string('lastname')], - 'timemodified' => ['field' => 'q.timemodified', 'title' => get_string('date')] - ]; - } - -} diff --git a/question/bank/viewcreator/classes/output/renderer.php b/question/bank/viewcreator/classes/output/renderer.php index faa072a280f..1009be51892 100644 --- a/question/bank/viewcreator/classes/output/renderer.php +++ b/question/bank/viewcreator/classes/output/renderer.php @@ -36,13 +36,13 @@ class renderer extends \plugin_renderer_base { } /** - * Render question modifier. + * Render question edit form callback. * * @param array $displaydata * @return string */ - public function render_modifier_name($displaydata) { - return $this->render_from_template('qbank_viewcreator/modifier_display', $displaydata); + public function render_version_info($displaydata) { + return $this->render_from_template('qbank_viewcreator/version_info', $displaydata); } } diff --git a/question/bank/viewcreator/classes/plugin_feature.php b/question/bank/viewcreator/classes/plugin_feature.php index 0af1f7e9e09..a5e0185aab9 100644 --- a/question/bank/viewcreator/classes/plugin_feature.php +++ b/question/bank/viewcreator/classes/plugin_feature.php @@ -30,8 +30,7 @@ class plugin_feature extends plugin_features_base { public function get_question_columns($qbank): array { return [ - new creator_name_column($qbank), - new modifier_name_column($qbank) + new creator_name_column($qbank) ]; } } diff --git a/question/bank/viewcreator/lang/en/qbank_viewcreator.php b/question/bank/viewcreator/lang/en/qbank_viewcreator.php index 097d848203f..06c0098eaa0 100644 --- a/question/bank/viewcreator/lang/en/qbank_viewcreator.php +++ b/question/bank/viewcreator/lang/en/qbank_viewcreator.php @@ -24,4 +24,5 @@ */ $string['pluginname'] = 'View creator'; -$string['privacy:metadata'] = 'The View creator question bank plugin does not store any personal data.'; +$string['privacy:metadata'] = 'View creator question bank plugin does not store any user data.'; +$string['version'] = 'Version {$a}'; diff --git a/question/bank/viewcreator/lib.php b/question/bank/viewcreator/lib.php new file mode 100644 index 00000000000..1ce462d9d23 --- /dev/null +++ b/question/bank/viewcreator/lib.php @@ -0,0 +1,46 @@ +. + +/** + * Helper functions and callbacks. + * + * @package qbank_viewcreator + * @copyright 2021 Catalyst IT Australia Pty Ltd + * @author Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +/** + * Edit page callback for information. + * + * @param object $question + * @return string + */ +function qbank_viewcreator_edit_form_display($question): string { + global $DB, $PAGE; + $versiondata = []; + $questionversion = $DB->get_record('question_versions', ['questionid' => $question->id])->version; + $versiondata['versionnumber'] = $questionversion; + if (!empty($question->createdby)) { + $a = new stdClass(); + $a->time = userdate($question->timecreated); + $a->user = fullname($DB->get_record('user', ['id' => $question->createdby])); + $versiondata['createdby'] = get_string('created', 'question') . ' ' . + get_string('byandon', 'question', $a) ; + } + return $PAGE->get_renderer('qbank_viewcreator')->render_version_info($versiondata); + +} diff --git a/question/bank/viewcreator/templates/version_info.mustache b/question/bank/viewcreator/templates/version_info.mustache new file mode 100644 index 00000000000..b7e6e64779f --- /dev/null +++ b/question/bank/viewcreator/templates/version_info.mustache @@ -0,0 +1,37 @@ +{{! + 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 . +}} +{{! + @template qbank_editquestion/version_info + + Example context (json): + { + "addquestiondata": [ + { + "versionnumber": 1, + "createdby": "Admin User on Wednesday, 20 October 2021, 5:33 AM" + } + ] + } +}} + +{{#createdby}} + +{{/createdby}} diff --git a/question/bank/viewcreator/tests/behat/view_view_creator_column.feature b/question/bank/viewcreator/tests/behat/view_view_creator_column.feature index 7b1244266a2..ec5bd5d8f7d 100644 --- a/question/bank/viewcreator/tests/behat/view_view_creator_column.feature +++ b/question/bank/viewcreator/tests/behat/view_view_creator_column.feature @@ -24,10 +24,8 @@ Feature: Use the qbank plugin manager page for viewcreator plugin And I am on the "Test quiz" "quiz activity" page And I navigate to "Question bank" in current page administration Then I should not see "Created by" - And I should not see "Last modified by" And I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration And I click on "Enable" "link" in the "View creator" "table_row" And I am on the "Test quiz" "quiz activity" page And I navigate to "Question bank" in current page administration Then I should see "Created by" - And I should see "Last modified by" diff --git a/question/bank/viewquestionname/classes/question_name_idnumber_tags_column.php b/question/bank/viewquestionname/classes/question_name_idnumber_tags_column.php index 8956189cc4d..ec89b7946ee 100644 --- a/question/bank/viewquestionname/classes/question_name_idnumber_tags_column.php +++ b/question/bank/viewquestionname/classes/question_name_idnumber_tags_column.php @@ -64,14 +64,14 @@ class question_name_idnumber_tags_column extends viewquestionname_column_helper public function get_required_fields(): array { $fields = parent::get_required_fields(); - $fields[] = 'q.idnumber'; + $fields[] = 'qbe.idnumber'; return $fields; } public function is_sortable(): array { return [ 'name' => ['field' => 'q.name', 'title' => get_string('questionname', 'question')], - 'idnumber' => ['field' => 'q.idnumber', 'title' => get_string('idnumber', 'question')], + 'idnumber' => ['field' => 'qbe.idnumber', 'title' => get_string('idnumber', 'question')], ]; } diff --git a/question/bank/viewquestiontext/classes/question_text_row.php b/question/bank/viewquestiontext/classes/question_text_row.php index de69c744121..1279e9ca183 100644 --- a/question/bank/viewquestiontext/classes/question_text_row.php +++ b/question/bank/viewquestiontext/classes/question_text_row.php @@ -60,12 +60,8 @@ class question_text_row extends row_base { echo $text; } - public function get_extra_joins(): array { - return ['qc' => 'JOIN {question_categories} qc ON qc.id = q.category']; - } - public function get_required_fields(): array { - return ['q.id', 'q.questiontext', 'q.questiontextformat', 'qc.contextid']; + return ['q.questiontext', 'q.questiontextformat']; } public function has_preference(): bool { diff --git a/question/classes/bank/search/category_condition.php b/question/classes/bank/search/category_condition.php index 3dcb84c27cb..2bee9f54e5c 100644 --- a/question/classes/bank/search/category_condition.php +++ b/question/classes/bank/search/category_condition.php @@ -92,7 +92,7 @@ class category_condition extends condition { $categoryids = [$this->category->id]; } list($catidtest, $this->params) = $DB->get_in_or_equal($categoryids, SQL_PARAMS_NAMED, 'cat'); - $this->where = 'q.category ' . $catidtest; + $this->where = 'qbe.questioncategoryid ' . $catidtest; } /** diff --git a/question/classes/bank/search/hidden_condition.php b/question/classes/bank/search/hidden_condition.php index 758240a7a3b..a7f16654fd0 100644 --- a/question/classes/bank/search/hidden_condition.php +++ b/question/classes/bank/search/hidden_condition.php @@ -25,6 +25,8 @@ namespace core_question\bank\search; +use core_question\local\bank\constants; + /** * This class controls whether hidden / deleted questions are hidden in the list. * @@ -46,7 +48,8 @@ class hidden_condition extends condition { public function __construct($hide = true) { $this->hide = $hide; if ($hide) { - $this->where = 'q.hidden = 0'; + $this->where = 'qv.status = ' . constants::QUESTION_STATUS_READY . + ' OR qv.status = ' . constants::QUESTION_STATUS_DRAFT; } } diff --git a/question/classes/external.php b/question/classes/external.php index 838afb87a41..751ffdd45d9 100644 --- a/question/classes/external.php +++ b/question/classes/external.php @@ -173,7 +173,7 @@ class core_question_external extends external_api { $cantag = question_has_capability_on($question, 'tag'); $questioncontext = \context::instance_by_id($question->contextid); - $contexts = new \question_edit_contexts($editingcontext); + $contexts = new \core_question\lib\question_edit_contexts($editingcontext); $formoptions = [ 'editingcontext' => $editingcontext, @@ -300,7 +300,7 @@ class core_question_external extends external_api { $categorycontextid = $DB->get_field('question_categories', 'contextid', ['id' => $categoryid], MUST_EXIST); $categorycontext = \context::instance_by_id($categorycontextid); - $editcontexts = new \question_edit_contexts($categorycontext); + $editcontexts = new \core_question\lib\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'); diff --git a/question/classes/lib/context_to_string_translator.php b/question/classes/lib/context_to_string_translator.php new file mode 100644 index 00000000000..109372a3ce2 --- /dev/null +++ b/question/classes/lib/context_to_string_translator.php @@ -0,0 +1,104 @@ +. + +/** + * 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 + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_question\lib; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Class context_to_string_translator. + * + * @package core_question + * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} + * @author 2021 Safat Shahin + * @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) { + $contextid = array_search($contextname, $this->contexttostringarray); + return $contextid; + } + + /** + * Generate context to array. + * + * @param \context[] $contexts + */ + protected function generate_context_to_string_array($contexts) { + if (!$this->contexttostringarray) { + $catno = 1; + 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; + } + $this->contexttostringarray[$context->id] = $contextstring; + } + } + } + +} diff --git a/question/classes/lib/question_edit_contexts.php b/question/classes/lib/question_edit_contexts.php new file mode 100644 index 00000000000..a7b115215dc --- /dev/null +++ b/question/classes/lib/question_edit_contexts.php @@ -0,0 +1,233 @@ +. + +/** + * 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 + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_question\lib; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Class question_edit_contexts. + * + * @package core_question + * @copyright 2007 Jamie Pratt me@jamiep.org + * @author 2021 Safat Shahin + * @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); + } + } +} diff --git a/question/classes/local/bank/action_column_base.php b/question/classes/local/bank/action_column_base.php index a3e1f2248cf..fb20d2c59c9 100644 --- a/question/classes/local/bank/action_column_base.php +++ b/question/classes/local/bank/action_column_base.php @@ -47,7 +47,9 @@ abstract class action_column_base extends column_base { } public function get_extra_joins(): array { - return ['qc' => 'JOIN {question_categories} qc ON qc.id = q.category']; + return ['qv' => 'JOIN {question_versions} qv ON qv.questionid = q.id', + 'qbe' => 'JOIN {question_bank_entry} qbe on qbe.id = qv.questionbankentryid', + 'qc' => 'JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid']; } public function get_required_fields(): array { diff --git a/question/classes/local/bank/random_question_loader.php b/question/classes/local/bank/random_question_loader.php index 829aa2607db..9267ee52a5c 100644 --- a/question/classes/local/bank/random_question_loader.php +++ b/question/classes/local/bank/random_question_loader.php @@ -290,7 +290,29 @@ class random_question_loader { $fieldsstring = implode(',', $fields); } - return $DB->get_records_list('question', 'id', $questionids, 'id', $fieldsstring, $offset, $limit); + // 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; + $where = 'IN ('; + foreach ($questionids as $questionid) { + $where = $where . $questionid . ','; + // Extra validation, we do not want an infinite loop. + $hasquestions = (bool)$questionid; + } + $where = rtrim($where,','); + $where = $where . ')'; + + if ($hasquestions) { + $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_entry} qbe ON qbe.id = qv.questionbankentryid + WHERE q.id {$where}) q"; + + return $DB->get_records_sql($sql, [], $offset, $limit); + } else { + return []; + } } /** diff --git a/question/classes/local/bank/view.php b/question/classes/local/bank/view.php index da5179f9154..c9f2b0123c6 100644 --- a/question/classes/local/bank/view.php +++ b/question/classes/local/bank/view.php @@ -70,7 +70,7 @@ class view { protected $editquestionurl; /** - * @var \question_edit_contexts + * @var \core_question\lib\question_edit_contexts */ protected $contexts; @@ -157,7 +157,7 @@ class view { /** * Constructor for view. * - * @param \question_edit_contexts $contexts + * @param \core_question\lib\question_edit_contexts $contexts * @param \moodle_url $pageurl * @param object $course course settings * @param object $cm (optional) activity settings. @@ -244,8 +244,9 @@ class view { 'preview_action_column', 'delete_action_column', 'export_xml_action_column', + 'question_status_column', 'creator_name_column', - 'modifier_name_column' + 'comment_count_column' ]; if (question_get_display_preference('qbshowtext', 0, PARAM_BOOL, new \moodle_url(''))) { $corequestionbankcolumns[] = 'question_text_row'; @@ -560,7 +561,7 @@ class view { protected function build_query(): void { // Get the required tables and fields. $joins = []; - $fields = ['q.hidden', 'q.category']; + $fields = ['qv.status', 'qc.id', 'qv.version', 'qv.id as versionid', 'qbe.id as questionbankentryid']; if (!empty($this->requiredcolumns)) { foreach ($this->requiredcolumns as $column) { $extrajoins = $column->get_extra_joins(); @@ -583,7 +584,12 @@ class view { } // Build the where clause. - $tests = ['q.parent = 0']; + $latestversion = 'qv.version = (SELECT MAX(v.version) + FROM {question_versions} v + JOIN {question_bank_entry} be + ON be.id = v.questionbankentryid + WHERE be.id = qbe.id)'; + $tests = ['q.parent = 0', $latestversion]; $this->sqlparams = []; foreach ($this->searchconditions as $searchcondition) { if ($searchcondition->where()) { @@ -1153,7 +1159,7 @@ class view { */ protected function get_row_classes($question, $rowcount): array { $classes = []; - if ($question->hidden) { + if ((int)$question->status === 1) { $classes[] = 'dimmed_text'; } if ($question->id == $this->lastchangedid) { diff --git a/question/editlib.php b/question/editlib.php index 871d7b92b4f..c1b49d3dccc 100644 --- a/question/editlib.php +++ b/question/editlib.php @@ -51,35 +51,54 @@ function get_module_from_cmid($cmid) { return array($modrec, $cmrec); } /** -* Function to read all questions for category into big array -* -* @param int $category category number -* @param bool $noparent if true only questions with NO parent will be selected -* @param bool $recurse include subdirectories -* @param bool $export set true if this is called by questionbank export + * Function to read all questions for category into big array + * + * @param object $category category number + * @param bool $noparent if true only questions with NO parent will be selected + * @param bool $recurse include subdirectories + * @param bool $export set true if this is called by questionbank export + * @return array */ -function get_questions_category( $category, $noparent=false, $recurse=true, $export=true ) { +function get_questions_category(object $category, bool $noparent, bool $recurse = true, bool $export = true, + bool $latestversion = false): array { global $DB; - // Build sql bit for $noparent + // Build sql bit for $noparent. $npsql = ''; if ($noparent) { - $npsql = " and parent='0' "; + $npsql = " and q.parent='0' "; } - // Get list of categories + // Get list of categories. if ($recurse) { $categorylist = question_categorylist($category->id); } else { - $categorylist = array($category->id); + $categorylist = [$category->id]; } - // Get the list of questions for the category + // Get the list of questions for the category. list($usql, $params) = $DB->get_in_or_equal($categorylist); - $questions = $DB->get_records_select('question', "category {$usql} {$npsql}", $params, 'category, qtype, name'); - // Iterate through questions, getting stuff we need - $qresults = array(); + // Get the latest version of a question. + $version = ''; + if ($latestversion) { + $version = 'AND (qv.version = (SELECT MAX(v.version) + FROM {question_versions} v + JOIN {question_bank_entry} be + ON be.id = v.questionbankentryid + WHERE be.id = qbe.id) OR qv.version is null)'; + } + $questions = $DB->get_records_sql( + "SELECT q.*, qv.status, qc.id AS category + FROM {question} q + JOIN {question_versions} qv ON qv.questionid = q.id + JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid + JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid + WHERE qc.id {$usql} {$npsql} {$version} + ORDER BY qc.id, q.qtype, q.name", $params); + + // Iterate through questions, getting stuff we need. + $qresults = []; foreach($questions as $key => $question) { $question->export_process = $export; $qtype = question_bank::get_qtype($question->qtype, false); @@ -307,7 +326,7 @@ function question_build_edit_resources($edittab, $baseurl, $params) { } if ($thiscontext){ - $contexts = new question_edit_contexts($thiscontext); + $contexts = new core_question\lib\question_edit_contexts($thiscontext); $contexts->require_one_edit_tab_cap($edittab); } else { $contexts = null; diff --git a/question/engine/bank.php b/question/engine/bank.php index 5f01a2ece02..aa972e77d5f 100644 --- a/question/engine/bank.php +++ b/question/engine/bank.php @@ -419,8 +419,10 @@ abstract class question_bank { list($categorysql, $params) = $DB->get_in_or_equal($categories); $sql = "SELECT DISTINCT q.qtype - FROM {question} q - WHERE q.category $categorysql"; + FROM {question} q + JOIN {question_versions} qv ON qv.questionid = q.id + JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid + WHERE qbe.questioncategoryid $categorysql"; $qtypes = $DB->get_fieldset_sql($sql, $params); return $qtypes; @@ -454,7 +456,7 @@ class question_finder implements cache_data_source { } /** - * @return get the question definition cache we are using. + * @return cache_application|cache_session|cache_store the question definition cache we are using. */ protected function get_data_cache() { // Do not double cache here because it may break cache resetting. @@ -497,11 +499,17 @@ class question_finder implements cache_data_source { $extraconditions = ' AND (' . $extraconditions . ')'; } - return $DB->get_records_select_menu('question', - "category {$qcsql} - AND parent = 0 - AND hidden = 0 - {$extraconditions}", $qcparams + $extraparams, '', 'id,id AS id2'); + $readystatus = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $sql = "SELECT q.id, q.id AS id2 + FROM {question} q + JOIN {question_versions} qv ON qv.questionid = q.id + JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid + WHERE qbe.questioncategoryid {$qcsql} + AND q.parent = 0 + AND qv.status = $readystatus + {$extraconditions}"; + + return $DB->get_records_sql_menu($sql, $qcparams + $extraparams); } /** @@ -543,14 +551,23 @@ class question_finder implements cache_data_source { list($qcsql, $qcparams) = $DB->get_in_or_equal($categoryids, SQL_PARAMS_NAMED, 'qc'); + $readystatus = \core_question\local\bank\constants::QUESTION_STATUS_READY; $select = "q.id, (SELECT COUNT(1) FROM " . $qubaids->from_question_attempts('qa') . " WHERE qa.questionid = q.id AND " . $qubaids->where() . " ) AS previous_attempts"; $from = "{question} q"; - $where = "q.category {$qcsql} + $join = "JOIN {question_versions} qv ON qv.questionid = q.id + JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid"; + $from = $from . " " . $join; + $where = "qbe.questioncategoryid {$qcsql} AND q.parent = 0 - AND q.hidden = 0"; + AND qv.status = " . $readystatus . " + AND qv.version = (SELECT MAX(v.version) + FROM {question_versions} v + JOIN {question_bank_entries} be + ON be.id = v.questionbankentryid + WHERE be.id = qbe.id)"; $params = $qcparams; if (!empty($tagids)) { @@ -580,20 +597,32 @@ class question_finder implements cache_data_source { } return $DB->get_records_sql_menu("SELECT $select - FROM $from - WHERE $where $extraconditions - ORDER BY previous_attempts", + FROM $from + WHERE $where $extraconditions + ORDER BY previous_attempts", $qubaids->from_where_params() + $params + $extraparams); } /* See cache_data_source::load_for_cache. */ public function load_for_cache($questionid) { global $DB; - $questiondata = $DB->get_record_sql(' - SELECT q.*, qc.contextid - FROM {question} q - JOIN {question_categories} qc ON q.category = qc.id - WHERE q.id = :id', array('id' => $questionid), MUST_EXIST); + + $sql = 'SELECT q.id, qc.id as category, q.parent, q.name, q.questiontext, q.questiontextformat, + q.generalfeedback, q.generalfeedbackformat, q.defaultmark, q.penalty, q.qtype, + q.length, q.stamp, q.timecreated, q.timemodified, + q.createdby, q.modifiedby, qbe.idnumber, + qc.contextid, + qv.status, + qv.id as versionid, + qv.version, + qv.questionbankentryid + FROM {question} q + JOIN {question_versions} qv ON qv.questionid = q.id + JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid + JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid + WHERE q.id = :id'; + + $questiondata = $DB->get_record_sql($sql, ['id' => $questionid], MUST_EXIST); get_question_options($questiondata); return $questiondata; } @@ -602,15 +631,26 @@ class question_finder implements cache_data_source { public function load_many_for_cache(array $questionids) { global $DB; list($idcondition, $params) = $DB->get_in_or_equal($questionids); - $questiondata = $DB->get_records_sql(' - SELECT q.*, qc.contextid - FROM {question} q - JOIN {question_categories} qc ON q.category = qc.id - WHERE q.id ' . $idcondition, $params); + $sql = 'SELECT q.id, qc.id as category, q.parent, q.name, q.questiontext, q.questiontextformat, + q.generalfeedback, q.generalfeedbackformat, q.defaultmark, q.penalty, q.qtype, + q.length, q.stamp, q.timecreated, q.timemodified, + q.createdby, q.modifiedby, qbe.idnumber, + qc.contextid, + qv.status, + qv.id as versionid, + qv.version, + qv.questionbankentryid + FROM {question} q + JOIN {question_versions} qv ON qv.questionid = q.id + JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid + JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid + WHERE q.id '; + + $questiondata = $DB->get_records_sql($sql . $idcondition, $params); foreach ($questionids as $id) { if (!array_key_exists($id, $questiondata)) { - throw new dml_missing_record_exception('question', '', array('id' => $id)); + throw new dml_missing_record_exception('question', '', ['id' => $id]); } get_question_options($questiondata[$id]); } diff --git a/question/engine/tests/helpers.php b/question/engine/tests/helpers.php index 3dae7bca14e..bab3b0f5aa8 100644 --- a/question/engine/tests/helpers.php +++ b/question/engine/tests/helpers.php @@ -117,12 +117,13 @@ abstract class question_test_helper { */ public static function get_question_editing_form($cat, $questiondata) { $catcontext = context::instance_by_id($cat->contextid, MUST_EXIST); - $contexts = new question_edit_contexts($catcontext); + $contexts = new core_question\lib\question_edit_contexts($catcontext); $dataforformconstructor = new stdClass(); $dataforformconstructor->createdby = $questiondata->createdby; $dataforformconstructor->qtype = $questiondata->qtype; $dataforformconstructor->contextid = $questiondata->contextid = $catcontext->id; $dataforformconstructor->category = $questiondata->category = $cat->id; + $dataforformconstructor->status = $questiondata->status; $dataforformconstructor->formoptions = new stdClass(); $dataforformconstructor->formoptions->canmove = true; $dataforformconstructor->formoptions->cansaveasnew = true; @@ -178,8 +179,8 @@ class test_question_maker { $q->penalty = 0.3333333; $q->length = 1; $q->stamp = make_unique_id_code(); - $q->version = make_unique_id_code(); - $q->hidden = 0; + $q->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $q->version = 1; $q->timecreated = time(); $q->timemodified = time(); $q->createdby = $USER->id; @@ -200,8 +201,8 @@ class test_question_maker { $qdata->penalty = 0.3333333; $qdata->length = 1; $qdata->stamp = make_unique_id_code(); - $qdata->version = make_unique_id_code(); - $qdata->hidden = 0; + $qdata->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $qdata->version = 1; $qdata->timecreated = time(); $qdata->timemodified = time(); $qdata->createdby = $USER->id; diff --git a/question/format.php b/question/format.php index 6c77a2b8c16..5a59ecf9550 100644 --- a/question/format.php +++ b/question/format.php @@ -136,7 +136,7 @@ class qformat_default { */ public function setContexts($contexts) { $this->contexts = $contexts; - $this->translator = new context_to_string_translator($this->contexts); + $this->translator = new core_question\lib\context_to_string_translator($this->contexts); } /** @@ -411,8 +411,8 @@ class qformat_default { // Id number not really set. Get rid of it. unset($question->idnumber); } else { - if ($DB->record_exists('question', - ['idnumber' => $question->idnumber, 'category' => $question->category])) { + if ($DB->record_exists('question_bank_entry', + ['idnumber' => $question->idnumber, 'questioncategoryid' => $question->category])) { // We cannot have duplicate idnumbers in a category. Just remove it. unset($question->idnumber); } @@ -426,6 +426,20 @@ class qformat_default { ); $question->id = $DB->insert_record('question', $question); + // Create a bank entry for each question imported. + $questionbankentry = new \stdClass(); + $questionbankentry->questioncategoryid = $question->category; + $questionbankentry->idnumber = $question->idnumber ?? null; + $questionbankentry->ownerid = $question->createdby; + $questionbankentry->id = $DB->insert_record('question_bank_entry', $questionbankentry); + // Create a version for each question imported. + $questionversion = new \stdClass(); + $questionversion->questionbankentryid = $questionbankentry->id; + $questionversion->questionid = $question->id; + $questionversion->version = 1; + $questionversion->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $questionversion->id = $DB->insert_record('question_versions', $questionversion); + $event = \core\event\question_created::create_from_question_instance($question, $this->importcontext); $event->trigger(); @@ -499,9 +513,6 @@ class qformat_default { return true; } - // Give the question a unique version stamp determined by question_hash() - $DB->set_field('question', 'version', question_hash($question), - array('id' => $question->id)); } return true; } @@ -886,7 +897,8 @@ class qformat_default { // get the questions (from database) in this category // only get q's with no parents (no cloze subquestions specifically) if ($this->category) { - $questions = get_questions_category($this->category, true); + // Export only the latest version of a question. + $questions = get_questions_category($this->category, true, true, true, true); } else { $questions = $this->questions; } @@ -907,8 +919,9 @@ class qformat_default { foreach ($questions as $question) { // used by file api - $contextid = $DB->get_field('question_categories', 'contextid', - array('id' => $question->category)); + $questionbankentry = question_bank::load_question($question->id); + $qcategory = $questionbankentry->category; + $contextid = $DB->get_field('question_categories', 'contextid', ['id' => $qcategory]); $question->contextid = $contextid; // do not export hidden questions @@ -934,7 +947,7 @@ class qformat_default { // If parent wasn't written. if (!in_array($trackcategoryparent, $writtencategories)) { // If parent is empty. - if (!count($DB->get_records('question', array('category' => $trackcategoryparent)))) { + if (!count($DB->get_records('question_bank_entry', ['questioncategoryid' => $trackcategoryparent]))) { $categoryname = $this->get_category_path($trackcategoryparent, $this->contexttofile); $categoryinfo = $DB->get_record('question_categories', array('id' => $trackcategoryparent), 'name, info, infoformat, idnumber', MUST_EXIST); diff --git a/question/format/multianswer/format.php b/question/format/multianswer/format.php index 2ab3109fabf..b4cce7c9687 100644 --- a/question/format/multianswer/format.php +++ b/question/format/multianswer/format.php @@ -67,6 +67,10 @@ class qformat_multianswer extends qformat_default { $question->generalfeedbackformat = FORMAT_MOODLE; $question->length = 1; $question->penalty = 0.3333333; + $question->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $question->version = 1; + $question->versionid = 0; + $question->questionbankentryid = 0; if (!empty($question)) { $question->name = $this->create_default_question_name($question->questiontext, get_string('questionname', 'question')); diff --git a/question/format/xml/format.php b/question/format/xml/format.php index e32d1ace76a..59acc379028 100644 --- a/question/format/xml/format.php +++ b/question/format/xml/format.php @@ -1242,7 +1242,7 @@ class qformat_xml extends qformat_default { $expout .= " {$question->defaultmark}\n"; } $expout .= " {$question->penalty}\n"; - $expout .= " {$question->hidden}\n"; + $expout .= " {$question->status}\n"; $expout .= " {$idnumber}\n"; // The rest of the output depends on question type. diff --git a/question/format/xml/tests/qformat_xml_import_export_test.php b/question/format/xml/tests/qformat_xml_import_export_test.php index 04282406479..498d52e44db 100644 --- a/question/format/xml/tests/qformat_xml_import_export_test.php +++ b/question/format/xml/tests/qformat_xml_import_export_test.php @@ -132,7 +132,13 @@ class qformat_xml_import_export_test extends advanced_testcase { */ public function assert_question_in_category($qname, $catname) { global $DB; - $question = $DB->get_record('question', ['name' => $qname], '*', MUST_EXIST); + + $sql = "SELECT q.*, qbe.questioncategoryid AS category + FROM {question} q + JOIN {question_versions} qv ON qv.questionid = q.id + JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid + WHERE q.name = :name"; + $question = $DB->get_record_sql($sql, ['name' => $qname], MUST_EXIST); $category = $DB->get_record('question_categories', ['name' => $catname], '*', MUST_EXIST); $this->assertEquals($category->id, $question->category); } diff --git a/question/format/xml/tests/xmlformat_test.php b/question/format/xml/tests/xmlformat_test.php index 4eb599b2fe0..482c77b5502 100644 --- a/question/format/xml/tests/xmlformat_test.php +++ b/question/format/xml/tests/xmlformat_test.php @@ -57,8 +57,7 @@ class qformat_xml_test extends question_testcase { $q->penalty = 0.3333333; $q->length = 1; $q->stamp = make_unique_id_code(); - $q->version = make_unique_id_code(); - $q->hidden = 0; + $q->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; $q->timecreated = time(); $q->timemodified = time(); $q->createdby = $USER->id; @@ -342,7 +341,7 @@ END; $qdata->defaultmark = 0; $qdata->length = 0; $qdata->penalty = 0; - $qdata->hidden = 0; + $qdata->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; $qdata->idnumber = null; $exporter = new qformat_xml(); @@ -570,7 +569,7 @@ END; $qdata->defaultmark = 1; $qdata->length = 1; $qdata->penalty = 0; - $qdata->hidden = 0; + $qdata->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; $qdata->idnumber = null; $qdata->options = new stdClass(); $qdata->options->id = 456; @@ -742,7 +741,7 @@ END; $qdata->defaultmark = 1; $qdata->length = 1; $qdata->penalty = 0.3333333; - $qdata->hidden = 0; + $qdata->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; $qdata->idnumber = null; $qdata->options = new stdClass(); @@ -975,7 +974,7 @@ END; $qdata->defaultmark = 2; $qdata->length = 1; $qdata->penalty = 0.3333333; - $qdata->hidden = 0; + $qdata->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; $qdata->idnumber = null; $qdata->options = new stdClass(); @@ -1152,7 +1151,7 @@ END; $qdata->defaultmark = 1; $qdata->length = 1; $qdata->penalty = 0.1; - $qdata->hidden = 0; + $qdata->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; $qdata->idnumber = null; $qdata->options = new stdClass(); @@ -1284,7 +1283,7 @@ END; $qdata->defaultmark = 1; $qdata->length = 1; $qdata->penalty = 0.3333333; - $qdata->hidden = 0; + $qdata->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; $qdata->idnumber = null; $qdata->options = new stdClass(); @@ -1460,7 +1459,7 @@ END; $qdata->defaultmark = 1; $qdata->length = 1; $qdata->penalty = 1; - $qdata->hidden = 0; + $qdata->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; $qdata->idnumber = null; $qdata->options = new stdClass(); @@ -1520,7 +1519,7 @@ END; $qdata->defaultmark = 1; $qdata->length = 1; $qdata->penalty = 1; - $qdata->hidden = 0; + $qdata->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; $qdata->idnumber = 'TestIDNum2'; $qdata->options = new stdClass(); diff --git a/question/lib.php b/question/lib.php index 158bba6cd24..c82078c790c 100644 --- a/question/lib.php +++ b/question/lib.php @@ -57,9 +57,15 @@ function core_question_output_fragment_tags_form($args) { $filtercourses = null; } - $category = $DB->get_record('question_categories', ['id' => $question->category]); + $sql = "SELECT qc.* + FROM {question} q + JOIN {question_versions} qv ON qv.questionid = q.id + JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid + JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid + WHERE q.id = :id"; + $category = $DB->get_record_sql($sql, ['id' => $question->id]); $questioncontext = \context::instance_by_id($category->contextid); - $contexts = new \question_edit_contexts($editingcontext); + $contexts = new \core_question\lib\question_edit_contexts($editingcontext); // Load the question tags and filter the course tags by the current course. if (core_tag_tag::is_enabled('core_question', 'question')) { diff --git a/question/tests/backup_test.php b/question/tests/backup_test.php index f5836094241..f5c53ab54fc 100644 --- a/question/tests/backup_test.php +++ b/question/tests/backup_test.php @@ -173,6 +173,12 @@ class core_question_backup_testcase extends advanced_testcase { delete_course($courseid2, false); foreach ($questions as $question) { question_delete_question($question->id); + + // TODO: Remove this assert when restore a question is fixed (create version and bank entry). + $this->assertDebuggingCalled('Deleting question ' . $question->id . + ' which is no longer linked to a context. Assuming system context ' . + 'to avoid errors, but this may mean that some data like ' . + 'files, tags, are not cleaned up.'); } $category1->delete_full(false); @@ -190,9 +196,11 @@ class core_question_backup_testcase extends advanced_testcase { // The questions should have been moved to a question category that belongs to a course context. $questions = $DB->get_records_sql("SELECT q.* - FROM {question} q - JOIN {question_categories} qc ON q.category = qc.id - WHERE qc.contextid = ?", [$coursecontext3->id]); + FROM {question} q + JOIN {question_versions} qv ON qv.questionid = q.id + JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid + JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid + WHERE qc.contextid = ?", [$coursecontext3->id]); $this->assertCount(2, $questions); // Now, retrieve tags for each question and check if they are assigned at the right context. diff --git a/question/tests/bank_view_test.php b/question/tests/bank_view_test.php index c76b285ea26..7a6c5c092a7 100644 --- a/question/tests/bank_view_test.php +++ b/question/tests/bank_view_test.php @@ -49,7 +49,7 @@ class core_question_bank_view_testcase extends advanced_testcase { $context = context_course::instance($course->id); // Create a question in the default category. - $contexts = new question_edit_contexts($context); + $contexts = new core_question\lib\question_edit_contexts($context); $cat = question_make_default_categories($contexts->all()); $questiondata = $questiongenerator->create_question('numerical', null, ['name' => 'Example question', 'category' => $cat->id]); @@ -93,7 +93,7 @@ class core_question_bank_view_testcase extends advanced_testcase { $context = context_course::instance($course->id); // Create a question in the default category. - $contexts = new question_edit_contexts($context); + $contexts = new core_question\lib\question_edit_contexts($context); $cat = question_make_default_categories($contexts->all()); $questiondata = $questiongenerator->create_question('numerical', null, ['name' => 'Example question', 'category' => $cat->id]); diff --git a/question/tests/behat/duplicate_questions.feature b/question/tests/behat/duplicate_questions.feature index 65a80a8722e..5a03e49bbba 100644 --- a/question/tests/behat/duplicate_questions.feature +++ b/question/tests/behat/duplicate_questions.feature @@ -32,7 +32,6 @@ Feature: A teacher can duplicate questions in the question bank And I press "id_submitbutton" Then I should see "Duplicated question name" And I should see "Test question to be copied" - And "Duplicated question name" row "Last modified by" column of "categoryquestions" table should contain "Teacher One" And "Test question to be copied ID number qid" row "Created by" column of "categoryquestions" table should contain "Admin User" Scenario: Duplicated questions automatically get a new name suggested diff --git a/question/tests/behat/edit_questions.feature b/question/tests/behat/edit_questions.feature index d46215a5a92..2e342e3222e 100644 --- a/question/tests/behat/edit_questions.feature +++ b/question/tests/behat/edit_questions.feature @@ -32,8 +32,7 @@ Feature: A teacher can edit questions in the question bank And I press "id_submitbutton" Then I should see "Edited question name" And I should not see "Test question to be edited" - And "Edited question name" row "Created by" column of "categoryquestions" table should contain "Admin User" - And "Edited question name" row "Last modified by" column of "categoryquestions" table should contain "Teacher 1" + And "Edited question name" row "Created by" column of "categoryquestions" table should contain "Teacher 1" Scenario: Editing a question can be cancelled When I choose "Edit question" action for "Test question to be edited" in the question bank @@ -41,7 +40,6 @@ Feature: A teacher can edit questions in the question bank And I press "Cancel" Then I should see "Test question to be edited" And "Test question to be edited" row "Created by" column of "categoryquestions" table should contain "Admin User" - And "Test question to be edited" row "Last modified by" column of "categoryquestions" table should contain "Admin User" Scenario: A question can have its idnumber removed Given the following "questions" exist: diff --git a/question/tests/event/events_test.php b/question/tests/event/events_test.php index 18f4470b40a..d6c0528d27c 100644 --- a/question/tests/event/events_test.php +++ b/question/tests/event/events_test.php @@ -57,7 +57,7 @@ class events_test extends \advanced_testcase { $course = $this->getDataGenerator()->create_course(); $quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]); - $contexts = new question_edit_contexts(\context_module::instance($quiz->cmid)); + $contexts = new \core_question\lib\question_edit_contexts(context_module::instance($quiz->cmid)); $defaultcategoryobj = question_make_default_categories([$contexts->lowest()]); $defaultcategory = $defaultcategoryobj->id . ',' . $defaultcategoryobj->contextid; @@ -108,7 +108,11 @@ class events_test extends \advanced_testcase { $course = $this->getDataGenerator()->create_course(); $quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]); +<<<<<<< HEAD:question/tests/event/events_test.php $contexts = new question_edit_contexts(\context_module::instance($quiz->cmid)); +======= + $contexts = new core_question\lib\question_edit_contexts(context_module::instance($quiz->cmid)); +>>>>>>> MDL-71696 core_question: Changes for versioning:question/tests/events_test.php $defaultcategoryobj = question_make_default_categories([$contexts->lowest()]); $defaultcategory = $defaultcategoryobj->id . ',' . $defaultcategoryobj->contextid; @@ -229,12 +233,13 @@ class events_test extends \advanced_testcase { // Trigger and capture the event. $sink = $this->redirectEvents(); - $qtype->save_question($questiondata, $fromform); + $question = $qtype->save_question($questiondata, $fromform); $events = $sink->get_events(); $event = reset($events); // Check that the event data is valid. - $this->assertInstanceOf('\core\event\question_updated', $event); + // Every save is a new question after Moodle 4.0. + $this->assertInstanceOf('\core\event\question_created', $event); $this->assertEquals($question->id, $event->objectid); $this->assertEquals($cat->id, $event->other['categoryid']); $this->assertDebuggingNotCalled(); diff --git a/question/tests/generator/lib.php b/question/tests/generator/lib.php index f57d1f22e79..c4afee7831c 100644 --- a/question/tests/generator/lib.php +++ b/question/tests/generator/lib.php @@ -14,15 +14,25 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . +/** + * Quiz module test data generator. + * + * @package core_question + * @copyright 2013 The Open University + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +use core_question\local\bank\constants; + defined('MOODLE_INTERNAL') || die(); /** - * Quiz module test data generator class + * Class core_question_generator for generating question data. * - * @package moodlecore - * @subpackage question - * @copyright 2013 The Open University - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @package core_question + * @copyright 2013 The Open University + * @author 2021 Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class core_question_generator extends component_generator_base { @@ -31,19 +41,23 @@ class core_question_generator extends component_generator_base { */ protected $categorycount = 0; + /** + * Make the category count to zero. + */ public function reset() { $this->categorycount = 0; } /** * Create a new question category. + * * @param array|stdClass $record * @return stdClass question_categories record. */ public function create_question_category($record = null) { global $DB; - $this->categorycount++; + $this->categorycount ++; $defaults = [ 'name' => 'Test question category ' . $this->categorycount, @@ -79,18 +93,11 @@ class core_question_generator extends component_generator_base { * @return stdClass the question data. */ public function create_question($qtype, $which = null, $overrides = null) { - global $CFG; - require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); - - $fromform = test_question_maker::get_question_form_data($qtype, $which); - $fromform = (object) $this->datagenerator->combine_defaults_and_record( - (array) $fromform, $overrides); - $question = new stdClass(); - $question->category = $fromform->category; - $question->qtype = $qtype; + $question->qtype = $qtype; $question->createdby = 0; $question->idnumber = null; + $question->status = constants::QUESTION_STATUS_READY; return $this->update_question($question, $which, $overrides); } @@ -122,10 +129,8 @@ class core_question_generator extends component_generator_base { $qtype = $question->qtype; $fromform = test_question_maker::get_question_form_data($qtype, $which); - $fromform = (object) $this->datagenerator->combine_defaults_and_record( - (array) $question, $fromform); - $fromform = (object) $this->datagenerator->combine_defaults_and_record( - (array) $fromform, $overrides); + $fromform = (object) $this->datagenerator->combine_defaults_and_record((array) $question, $fromform); + $fromform = (object) $this->datagenerator->combine_defaults_and_record((array) $fromform, $overrides); $question = question_bank::get_qtype($qtype)->save_question($question, $fromform); diff --git a/question/tests/generator_test.php b/question/tests/generator_test.php index 2fe2db6ff67..67de12f442e 100644 --- a/question/tests/generator_test.php +++ b/question/tests/generator_test.php @@ -71,7 +71,7 @@ class generator_test extends \advanced_testcase { question_move_questions_to_category([$quest1->id], $qcat1->id); $this->assertSame('myquest', \question_bank::load_question_data($quest1->id)->idnumber); // Can only change idnumber of quest2 once quest1 has been moved to another category. - $quest2 = $generator->update_question($questions[1], null, ['idnumber' => 'myquest']); + $quest2 = $generator->update_question($questions[1], null, ['idnumber' => 'myquest_4']); question_move_questions_to_category([$quest2->id], $qcat1->id); $this->assertSame('myquest_4', \question_bank::load_question_data($quest2->id)->idnumber); // Check can add an idnumber of 0. diff --git a/question/tests/privacy/provider_test.php b/question/tests/privacy/provider_test.php index cafe94f5e38..eedf6f819c8 100644 --- a/question/tests/privacy/provider_test.php +++ b/question/tests/privacy/provider_test.php @@ -310,12 +310,14 @@ class provider_test extends \core_privacy\tests\provider_testcase { $q2 = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); $this->setUser($otheruser); - $questiongenerator->update_question($q2); + // When we update a question, a new question/version is created. + $q2updated = $questiongenerator->update_question($q2); $q3 = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); $q4 = $questiongenerator->create_question('shortanswer', null, ['category' => $cat->id]); $this->setUser($user); - $questiongenerator->update_question($q3); + // When we update a question, a new question/version is created. + $q3updated = $questiongenerator->update_question($q3); $q5 = $questiongenerator->create_question('shortanswer', null, ['category' => $othercat->id]); $approvedcontextlist = new \core_privacy\tests\request\approved_contextlist( @@ -337,12 +339,12 @@ class provider_test extends \core_privacy\tests\provider_testcase { $this->assertEquals(0, $qrecord->createdby); $this->assertEquals(0, $qrecord->modifiedby); - $qrecord = $DB->get_record('question', ['id' => $q2->id]); - $this->assertEquals(0, $qrecord->createdby); + $qrecord = $DB->get_record('question', ['id' => $q2updated->id]); + $this->assertEquals($otheruser->id, $qrecord->createdby); $this->assertEquals($otheruser->id, $qrecord->modifiedby); - $qrecord = $DB->get_record('question', ['id' => $q3->id]); - $this->assertEquals($otheruser->id, $qrecord->createdby); + $qrecord = $DB->get_record('question', ['id' => $q3updated->id]); + $this->assertEquals(0, $qrecord->createdby); $this->assertEquals(0, $qrecord->modifiedby); $qrecord = $DB->get_record('question', ['id' => $q4->id]); @@ -513,9 +515,11 @@ class provider_test extends \core_privacy\tests\provider_testcase { $this->assertEquals( 0, $DB->count_records_sql("SELECT COUNT(q.id) - FROM {question} q - JOIN {question_categories} qc ON q.category = qc.id - WHERE qc.contextid = ? + FROM {question} q + JOIN {question_versions} qv ON qv.questionid = q.id + JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid + JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid + WHERE qc.contextid = ? AND (q.createdby = ? OR q.modifiedby = ? OR q.createdby = ? OR q.modifiedby = ?)", [$course1context->id, $user1->id, $user1->id, $user2->id, $user2->id]) ); @@ -524,9 +528,11 @@ class provider_test extends \core_privacy\tests\provider_testcase { $this->assertEquals( 2, $DB->count_records_sql("SELECT COUNT(q.id) - FROM {question} q - JOIN {question_categories} qc ON q.category = qc.id - WHERE qc.contextid = ? AND (q.createdby = ? OR q.modifiedby = ?)", + FROM {question} q + JOIN {question_versions} qv ON qv.questionid = q.id + JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid + JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid + WHERE qc.contextid = ? AND (q.createdby = ? OR q.modifiedby = ?)", [$course1context->id, $user3->id, $user3->id]) ); diff --git a/question/tests/question_bank_column_test.php b/question/tests/question_bank_column_test.php index 21648a203ac..e1a7c980d0b 100644 --- a/question/tests/question_bank_column_test.php +++ b/question/tests/question_bank_column_test.php @@ -45,7 +45,7 @@ class question_bank_column_testcase extends advanced_testcase { $this->resetAfterTest(); $course = $this->getDataGenerator()->create_course(); $questionbank = new core_question\local\bank\view( - new question_edit_contexts(context_course::instance($course->id)), + new core_question\lib\question_edit_contexts(context_course::instance($course->id)), new moodle_url('/'), $course ); @@ -79,7 +79,7 @@ class question_bank_column_testcase extends advanced_testcase { $this->resetAfterTest(); $course = $this->getDataGenerator()->create_course(); $questionbank = new core_question\local\bank\view( - new question_edit_contexts(context_course::instance($course->id)), + new core_question\lib\question_edit_contexts(context_course::instance($course->id)), new moodle_url('/'), $course ); diff --git a/question/tests/random_question_loader_test.php b/question/tests/random_question_loader_test.php index a2c2186770d..b78c1506080 100644 --- a/question/tests/random_question_loader_test.php +++ b/question/tests/random_question_loader_test.php @@ -68,7 +68,7 @@ class random_question_loader_testcase extends advanced_testcase { $cat = $generator->create_question_category(); $question1 = $generator->create_question('shortanswer', null, ['category' => $cat->id]); - $DB->set_field('question', 'hidden', 1, ['id' => $question1->id]); + $DB->set_field('question_versions', 'status', 1, ['questionid' => $question1->id]); $loader = new \core_question\local\bank\random_question_loader(new qubaid_list([])); $this->assertNull($loader->get_next_question_id($cat->id, 0)); diff --git a/question/tests/version_test.php b/question/tests/version_test.php new file mode 100644 index 00000000000..bd257027e60 --- /dev/null +++ b/question/tests/version_test.php @@ -0,0 +1,256 @@ +. + +namespace core_question; + +use question_bank; + +/** + * Comment lib unit tests. + * + * @package core_question + * @copyright 2021 Catalyst IT Australia Pty Ltd + * @author Guillermo Gomez Arias + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @coversDefaultClass \question_bank + */ +class version_test extends \advanced_testcase { + + /** + * @var \context_module module context. + */ + protected $context; + + /** + * @var \stdClass course object. + */ + protected $course; + + /** + * @var \component_generator_base question generator. + */ + protected $qgenerator; + + /** + * @var \stdClass quiz object. + */ + protected $quiz; + + /** + * Called before every test. + */ + protected function setUp(): void { + parent::setUp(); + self::setAdminUser(); + $this->resetAfterTest(); + + $datagenerator = $this->getDataGenerator(); + $this->course = $datagenerator->create_course(); + $this->quiz = $datagenerator->create_module('quiz', ['course' => $this->course->id]); + $this->qgenerator = $datagenerator->get_plugin_generator('core_question'); + $this->context = \context_module::instance($this->quiz->cmid); + } + + /** + * Test if creating a question a new version and bank entry records are created. + * + * @covers ::load_question + */ + public function test_make_question_create_version_and_bank_entry() { + global $DB; + + $qcategory = $this->qgenerator->create_question_category(['contextid' => $this->context->id]); + $question = $this->qgenerator->create_question('shortanswer', null, ['category' => $qcategory->id]); + + // Get the question object after creating a question. + $questiondefinition = question_bank::load_question($question->id); + + // The version and bank entry in the object should be the same. + $sql = "SELECT qv.id AS versionid, qv.questionbankentryid + FROM {question} q + JOIN {question_versions} qv ON qv.questionid = q.id + JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid + WHERE q.id = ?"; + $questionversion = $DB->get_record_sql($sql, [$questiondefinition->id]); + $this->assertEquals($questionversion->versionid, $questiondefinition->versionid); + $this->assertEquals($questionversion->questionbankentryid, $questiondefinition->questionbankentryid); + + // If a question is updated, a new version should be created. + $this->qgenerator->update_question($question, null, ['name' => 'This is a new version']); + $newquestiondefinition = question_bank::load_question($question->id); + // The version should be 2. + $this->assertEquals('2', $newquestiondefinition->version); + + // Both versions should be in same bank entry. + $this->assertEquals($questiondefinition->questionbankentryid, $newquestiondefinition->questionbankentryid); + } + + /** + * Test if deleting a question the related version and bank entry records are deleted. + * + * @covers ::load_question + * @covers ::question_delete_question + */ + public function test_delete_question_delete_versions() { + global $DB; + + $qcategory = $this->qgenerator->create_question_category(['contextid' => $this->context->id]); + $question = $this->qgenerator->create_question('shortanswer', null, ['category' => $qcategory->id]); + $questionfirstversionid = $question->id; + + // Create a new version and try to remove it. + $this->qgenerator->update_question($question, null, ['name' => 'This is a new version']); + + // The new version and bank entry record should exist. + $sql = "SELECT q.id, qv.id AS versionid, qv.questionbankentryid + FROM {question} q + JOIN {question_versions} qv ON qv.questionid = q.id + JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid + WHERE q.id = ?"; + $questionobject = $DB->get_records_sql($sql, [$question->id]); + $this->assertCount(1, $questionobject); + + // Try to delete new version. + question_delete_question($question->id); + + // The version record should not exist. + $sql = "SELECT qv.* + FROM {question_versions} qv + WHERE qv.id = ?"; + $questionversion = $DB->get_record_sql($sql, [$questionobject[$question->id]->versionid]); + $this->assertFalse($questionversion); + + // The bank entry record should exist because there is an older version. + $sql = "SELECT qbe.* + FROM {question_bank_entries} qbe + WHERE qbe.id = ?"; + $questionbankentry = $DB->get_records_sql($sql, [$questionobject[$question->id]->questionbankentryid]); + $this->assertCount(1, $questionbankentry); + + // Now remove the first version. + question_delete_question($questionfirstversionid); + $sql = "SELECT qbe.* + FROM {question_bank_entries} qbe + WHERE qbe.id = ?"; + $questionbankentry = $DB->get_record_sql($sql, [$questionobject[$question->id]->questionbankentryid]); + // The bank entry record should not exist. + $this->assertFalse($questionbankentry); + } + + /** + * Test if deleting a question will not break a quiz. + * + * @covers ::load_question + * @covers ::quiz_add_quiz_question + * @covers ::question_delete_question + */ + public function test_delete_question_in_use() { + $qcategory = $this->qgenerator->create_question_category(['contextid' => $this->context->id]); + $question = $this->qgenerator->create_question('shortanswer', null, ['category' => $qcategory->id]); + $questionfirstversionid = $question->id; + + // Create a new version and try to remove it after adding it to a quiz. + $this->qgenerator->update_question($question, null, ['name' => 'This is a new version']); + + // Add it to the quiz. + quiz_add_quiz_question($question->id, $this->quiz); + + // Try to delete new version. + question_delete_question($question->id); + // Try to delete old version. + question_delete_question($questionfirstversionid); + + // The questions should exist even after trying to remove it. + $questionversion1 = question_bank::load_question($question->id); + $questionversion2 = question_bank::load_question($questionfirstversionid); + $this->assertEquals($questionversion1->id, $question->id); + $this->assertEquals($questionversion2->id, $questionfirstversionid); + } + + /** + * Test if moving a category will not break a quiz. + * + * @covers ::load_question + * @covers ::quiz_add_quiz_question + */ + public function test_move_category_with_questions() { + global $DB; + + $qcategory = $this->qgenerator->create_question_category(['contextid' => $this->context->id]); + $qcategorychild = $this->qgenerator->create_question_category(['contextid' => $this->context->id, 'parent' => $qcategory->id]); + $systemcontext = \context_system::instance(); + $qcategorysys = $this->qgenerator->create_question_category(['contextid' => $systemcontext->id]); + $question = $this->qgenerator->create_question('shortanswer', null, ['category' => $qcategorychild->id]); + $questiondefinition = question_bank::load_question($question->id); + + // Add it to the quiz. + quiz_add_quiz_question($question->id, $this->quiz); + + // Move the category to system context. + $contexts = new \core_question\local\bank\question_edit_contexts($systemcontext); + $qcobject = new \qbank_managecategories\question_category_object(null, + new \moodle_url('/question/bank/managecategories/category.php', ['courseid' => SITEID]), + $contexts->having_one_edit_tab_cap('categories'), 0, null, 0, + $contexts->having_cap('moodle/question:add')); + $qcobject->move_questions_and_delete_category($qcategorychild->id, $qcategorysys->id); + + // The bank entry record should point to the new category in order to not break quizzes. + $sql = "SELECT qbe.questioncategoryid + FROM {question_bank_entries} qbe + WHERE qbe.id = ?"; + $questionbankentry = $DB->get_record_sql($sql, [$questiondefinition->questionbankentryid]); + $this->assertEquals($qcategorysys->id, $questionbankentry->questioncategoryid); + } + + /** + * Test that all versions will have the same bank entry idnumber value. + * + * @covers ::load_question + */ + public function test_id_number_in_bank_entry() { + global $DB; + + $qcategory = $this->qgenerator->create_question_category(['contextid' => $this->context->id]); + $question = $this->qgenerator->create_question('shortanswer', null, + [ + 'category' => $qcategory->id, + 'idnumber' => 'id1' + ]); + $questionid1 = $question->id; + + // Create a new version and try to remove it after adding it to a quiz. + $this->qgenerator->update_question($question, null, ['idnumber' => 'id2']); + $questionid2 = $question->id; + // Change the id number and get the question object. + $this->qgenerator->update_question($question, null, ['idnumber' => 'id3']); + $questionid3 = $question->id; + + // The new version and bank entry record should exist. + $questiondefinition = question_bank::load_question($question->id); + $sql = "SELECT q.id AS questionid, qv.id AS versionid, qbe.id AS questionbankentryid, qbe.idnumber + FROM {question_bank_entries} qbe + JOIN {question_versions} qv ON qv.questionbankentryid = qbe.id + JOIN {question} q ON q.id = qv.questionid + WHERE qbe.id = ?"; + $questionbankentry = $DB->get_records_sql($sql, [$questiondefinition->questionbankentryid]); + + // We should have 3 versions and 1 question bank entry with the same idnumber. + $this->assertCount(3, $questionbankentry); + $this->assertEquals($questionbankentry[$questionid1]->idnumber, 'id3'); + $this->assertEquals($questionbankentry[$questionid2]->idnumber, 'id3'); + $this->assertEquals($questionbankentry[$questionid3]->idnumber, 'id3'); + } +} diff --git a/question/type/calculated/tests/helper.php b/question/type/calculated/tests/helper.php index d0bfb942834..f49641a763f 100644 --- a/question/type/calculated/tests/helper.php +++ b/question/type/calculated/tests/helper.php @@ -97,6 +97,7 @@ class qtype_calculated_test_helper extends question_test_helper { $qdata->name = 'Simple sum'; $qdata->questiontext = 'What is {a} + {b}?'; $qdata->generalfeedback = 'Generalfeedback: {={a} + {b}} is the right answer.'; + $qdata->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; $qdata->options = new stdClass(); $qdata->options->unitgradingtype = 0; @@ -186,6 +187,8 @@ class qtype_calculated_test_helper extends question_test_helper { $fromform->feedback[2]['format'] = FORMAT_HTML; $fromform->feedback[2]['text'] = 'Completely wrong.'; + $fromform->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + return $fromform; } } diff --git a/question/type/calculated/tests/questiontype_test.php b/question/type/calculated/tests/questiontype_test.php index 7fad0510426..fcae5aa0adf 100644 --- a/question/type/calculated/tests/questiontype_test.php +++ b/question/type/calculated/tests/questiontype_test.php @@ -88,8 +88,8 @@ class qtype_calculated_test extends advanced_testcase { $this->assertEquals(['id', 'category', 'parent', 'name', 'questiontext', 'questiontextformat', 'generalfeedback', 'generalfeedbackformat', 'defaultmark', 'penalty', 'qtype', - 'length', 'stamp', 'version', 'hidden', 'timecreated', 'timemodified', - 'createdby', 'modifiedby', 'idnumber', 'contextid', 'options', 'hints', 'categoryobject'], + 'length', 'stamp', 'timecreated', 'timemodified', 'createdby', 'modifiedby', 'idnumber', 'contextid', + 'status', 'versionid', 'version', 'questionbankentryid', 'categoryobject', 'options', 'hints'], array_keys(get_object_vars($questiondata))); $this->assertEquals($category->id, $questiondata->category); $this->assertEquals(0, $questiondata->parent); @@ -102,7 +102,7 @@ class qtype_calculated_test extends advanced_testcase { $this->assertEquals(0, $questiondata->penalty); $this->assertEquals('calculated', $questiondata->qtype); $this->assertEquals(1, $questiondata->length); - $this->assertEquals(0, $questiondata->hidden); + $this->assertEquals(0, $questiondata->status); $this->assertEquals($question->createdby, $questiondata->createdby); $this->assertEquals($question->createdby, $questiondata->modifiedby); $this->assertEquals('', $questiondata->idnumber); diff --git a/question/type/calculatedsimple/tests/helper.php b/question/type/calculatedsimple/tests/helper.php index 2be6acd6401..69874a3a470 100644 --- a/question/type/calculatedsimple/tests/helper.php +++ b/question/type/calculatedsimple/tests/helper.php @@ -205,6 +205,8 @@ class qtype_calculatedsimple_test_helper extends question_test_helper { $form->definition[19] = '1-0-b'; $form->definition[20] = '1-0-a'; + $form->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + return $form; } @@ -222,7 +224,8 @@ class qtype_calculatedsimple_test_helper extends question_test_helper { $q->penalty = 0.3333333; $q->qtype = 'calculatedsimple'; $q->length = '1'; - $q->hidden = '0'; + $q->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $q->version = 1; $q->options = new stdClass(); $q->options->synchronize = 0; $q->options->single = 0; diff --git a/question/type/calculatedsimple/tests/questiontype_test.php b/question/type/calculatedsimple/tests/questiontype_test.php index d18bd3f35f2..efc037033c4 100644 --- a/question/type/calculatedsimple/tests/questiontype_test.php +++ b/question/type/calculatedsimple/tests/questiontype_test.php @@ -89,7 +89,7 @@ class qtype_calculatedsimple_test extends advanced_testcase { $actualquestiondata = end($actualquestionsdata); foreach ($questiondata as $property => $value) { - if (!in_array($property, array('id', 'version', 'timemodified', 'timecreated', 'options'))) { + if (!in_array($property, array('id', 'timemodified', 'timecreated', 'options'))) { $this->assertEquals($value, $actualquestiondata->$property); } } diff --git a/question/type/ddimageortext/tests/edit_form_test.php b/question/type/ddimageortext/tests/edit_form_test.php index 4e7b2245611..df9f1c05113 100644 --- a/question/type/ddimageortext/tests/edit_form_test.php +++ b/question/type/ddimageortext/tests/edit_form_test.php @@ -63,7 +63,7 @@ class qtype_ddimageortext_edit_form_test extends advanced_testcase { $fakequestion->inputs = null; $form = new qtype_ddimageortext_edit_form(new moodle_url('/'), $fakequestion, $category, - new question_edit_contexts($syscontext)); + new core_question\lib\question_edit_contexts($syscontext)); return [$form, $category]; } diff --git a/question/type/ddimageortext/tests/helper.php b/question/type/ddimageortext/tests/helper.php index 718469f67f7..948642d80fd 100644 --- a/question/type/ddimageortext/tests/helper.php +++ b/question/type/ddimageortext/tests/helper.php @@ -246,6 +246,8 @@ class qtype_ddimageortext_test_helper extends question_test_helper { $fromform->hintclearwrong = array(1, 0, 1, 0); $fromform->hintshownumcorrect = array(1, 1, 1, 1); + $fromform->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + return $fromform; } diff --git a/question/type/ddmarker/tests/edit_form_test.php b/question/type/ddmarker/tests/edit_form_test.php index 45032c54887..3c6bb37146a 100644 --- a/question/type/ddmarker/tests/edit_form_test.php +++ b/question/type/ddmarker/tests/edit_form_test.php @@ -63,7 +63,7 @@ class qtype_ddmarker_edit_form_test extends advanced_testcase { $fakequestion->inputs = null; $form = new qtype_ddmarker_edit_form(new moodle_url('/'), $fakequestion, $category, - new question_edit_contexts($syscontext)); + new core_question\lib\question_edit_contexts($syscontext)); return [$form, $category]; } diff --git a/question/type/ddmarker/tests/helper.php b/question/type/ddmarker/tests/helper.php index 64756d0b10b..959b053aed5 100644 --- a/question/type/ddmarker/tests/helper.php +++ b/question/type/ddmarker/tests/helper.php @@ -193,6 +193,8 @@ class qtype_ddmarker_test_helper extends question_test_helper { $fromform->hintclearwrong = array(0, 1); $fromform->hintoptions = array(0, 1); + $fromform->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + return $fromform; } @@ -258,6 +260,8 @@ class qtype_ddmarker_test_helper extends question_test_helper { $fromform->hintclearwrong = array(0, 1); $fromform->hintoptions = array(0, 1); + $fromform->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + return $fromform; } } diff --git a/question/type/ddwtos/tests/edit_form_test.php b/question/type/ddwtos/tests/edit_form_test.php index e33fb7ae9ed..f7b0c2a7668 100644 --- a/question/type/ddwtos/tests/edit_form_test.php +++ b/question/type/ddwtos/tests/edit_form_test.php @@ -65,7 +65,7 @@ class qtype_ddwtos_edit_form_test extends advanced_testcase { $fakequestion->inputs = null; $form = new $classname(new moodle_url('/'), $fakequestion, $category, - new question_edit_contexts($syscontext)); + new core_question\lib\question_edit_contexts($syscontext)); return [$form, $category]; } diff --git a/question/type/ddwtos/tests/helper.php b/question/type/ddwtos/tests/helper.php index a317695a993..22fb849f2cf 100644 --- a/question/type/ddwtos/tests/helper.php +++ b/question/type/ddwtos/tests/helper.php @@ -97,6 +97,7 @@ class qtype_ddwtos_test_helper extends question_test_helper { test_question_maker::set_standard_combined_feedback_form_data($fromform); $fromform->shownumcorrect = 0; $fromform->penalty = 0.3333333; + $fromform->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; return $fromform; } @@ -124,6 +125,7 @@ class qtype_ddwtos_test_helper extends question_test_helper { test_question_maker::set_standard_combined_feedback_form_data($fromform); $fromform->shownumcorrect = 0; $fromform->penalty = 0.3333333; + $fromform->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; return $fromform; } @@ -149,6 +151,7 @@ class qtype_ddwtos_test_helper extends question_test_helper { test_question_maker::set_standard_combined_feedback_form_data($fromform); $fromform->shownumcorrect = 0; $fromform->penalty = 0.3333333; + $fromform->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; return $fromform; } @@ -207,6 +210,7 @@ class qtype_ddwtos_test_helper extends question_test_helper { test_question_maker::set_standard_combined_feedback_form_data($fromform); $fromform->shownumcorrect = 0; $fromform->penalty = 0.3333333; + $fromform->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; return $fromform; } diff --git a/question/type/ddwtos/tests/questiontype_test.php b/question/type/ddwtos/tests/questiontype_test.php index b98324e1601..89454c4c01a 100644 --- a/question/type/ddwtos/tests/questiontype_test.php +++ b/question/type/ddwtos/tests/questiontype_test.php @@ -73,8 +73,10 @@ class qtype_ddwtos_test extends question_testcase { $dd->penalty = 0.3333333; $dd->length = 1; $dd->stamp = make_unique_id_code(); - $dd->version = make_unique_id_code(); - $dd->hidden = 0; + $dd->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $dd->version = 1; + $dd->versionid = 0; + $dd->questionbankentryid = 0; $dd->idnumber = null; $dd->timecreated = time(); $dd->timemodified = time(); @@ -147,7 +149,6 @@ class qtype_ddwtos_test extends question_testcase { $expected = test_question_maker::make_question('ddwtos'); $expected->stamp = $qdata->stamp; - $expected->version = $qdata->version; $expected->idnumber = null; $q = $this->qtype->make_question($qdata); @@ -284,7 +285,7 @@ class qtype_ddwtos_test extends question_testcase { $qdata->defaultmark = 3; $qdata->length = 1; $qdata->penalty = 0.3333333; - $qdata->hidden = 0; + $qdata->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; $qdata->options = new stdClass(); $qdata->options->shuffleanswers = 1; diff --git a/question/type/description/tests/helper.php b/question/type/description/tests/helper.php index a8c68ff435d..3f577f0876e 100644 --- a/question/type/description/tests/helper.php +++ b/question/type/description/tests/helper.php @@ -70,7 +70,6 @@ class qtype_description_test_helper extends question_test_helper { $qdata->category = 0; $qdata->parent = 0; $qdata->stamp = make_unique_id_code(); - $qdata->version = make_unique_id_code(); $qdata->timecreated = time(); $qdata->timemodified = time(); $qdata->createdby = $USER->id; @@ -84,7 +83,7 @@ class qtype_description_test_helper extends question_test_helper { $qdata->defaultmark = 0; $qdata->length = 0; $qdata->penalty = 0; - $qdata->hidden = 0; + $qdata->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; $qdata->hints = array(); $qdata->options = new stdClass(); $qdata->options->answers = array(); @@ -105,6 +104,7 @@ class qtype_description_test_helper extends question_test_helper { 'format' => FORMAT_HTML); $form->generalfeedback = array('text' => 'And here is some more text shown only on the review page.', 'format' => FORMAT_HTML); + $form->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; return $form; } diff --git a/question/type/description/tests/questiontype_test.php b/question/type/description/tests/questiontype_test.php index 7e0dd575be9..ec685d0f986 100644 --- a/question/type/description/tests/questiontype_test.php +++ b/question/type/description/tests/questiontype_test.php @@ -94,7 +94,7 @@ class qtype_description_test extends advanced_testcase { $actualquestiondata = end($actualquestionsdata); foreach ($questiondata as $property => $value) { - if (!in_array($property, array('id', 'version', 'timemodified', 'timecreated'))) { + if (!in_array($property, array('id', 'timemodified', 'timecreated'))) { $this->assertEquals($value, $actualquestiondata->$property); } } diff --git a/question/type/edit_question_form.php b/question/type/edit_question_form.php index 642894d7016..fb39f7e492d 100644 --- a/question/type/edit_question_form.php +++ b/question/type/edit_question_form.php @@ -103,8 +103,15 @@ abstract class question_edit_form extends question_wizard_form { $this->question = $question; $this->contexts = $contexts; + // Get the question category id. + if (isset($question->id)) { + $qcategory = $question->categoryobject->id ?? get_question_bank_entry($question->id)->questioncategoryid; + } else { + $qcategory = $question->category; + } + $record = $DB->get_record('question_categories', - array('id' => $question->category), 'contextid'); + array('id' => $qcategory), 'contextid'); $this->context = context::instance_by_id($record->contextid); $this->editoroptions = array('subdirs' => 1, 'maxfiles' => EDITOR_UNLIMITED_FILES, @@ -175,8 +182,13 @@ abstract class question_edit_form extends question_wizard_form { $currentgrp[0] = $mform->createElement('questioncategory', 'category', get_string('categorycurrent', 'question'), array('contexts' => array($this->categorycontext))); - if ($this->question->formoptions->canedit || - $this->question->formoptions->cansaveasnew) { + // Validate if the question is being duplicated. + $beingcopied = false; + if (isset($this->question->beingcopied)) { + $beingcopied = $this->question->beingcopied; + } + if (($this->question->formoptions->canedit || + $this->question->formoptions->cansaveasnew) && ($beingcopied)) { // Not move only form. $currentgrp[1] = $mform->createElement('checkbox', 'usecurrentcat', '', get_string('categorycurrentuse', 'question')); @@ -187,16 +199,32 @@ abstract class question_edit_form extends question_wizard_form { $mform->addGroup($currentgrp, 'currentgrp', get_string('categorycurrent', 'question'), null, false); - $mform->addElement('questioncategory', 'categorymoveto', + if (($beingcopied)) { + $mform->addElement('questioncategory', 'categorymoveto', get_string('categorymoveto', 'question'), array('contexts' => array($this->categorycontext))); - if ($this->question->formoptions->canedit || + if ($this->question->formoptions->canedit || $this->question->formoptions->cansaveasnew) { - // Not move only form. - $mform->disabledIf('categorymoveto', 'usecurrentcat', 'checked'); + // Not move only form. + $mform->disabledIf('categorymoveto', 'usecurrentcat', 'checked'); + } } } + if (!empty($this->question->id) && !$this->question->beingcopied) { + // Add extra information from plugins when editing a question (e.g.: Authors, version control and usage). + $functionname = 'edit_form_display'; + $questiondata = []; + $plugins = get_plugin_list_with_function('qbank', $functionname); + foreach ($plugins as $componentname => $plugin) { + $element = new StdClass(); + $element->pluginhtml = component_callback($componentname, $functionname, [$this->question]); + $questiondata['editelements'][] = $element; + } + $mform->addElement('static', 'versioninfo', get_string('versioninfo', 'qbank_editquestion'), + $PAGE->get_renderer('qbank_editquestion')->render_question_info($questiondata)); + } + $mform->addElement('text', 'name', get_string('questionname', 'question'), array('size' => 50, 'maxlength' => 255)); $mform->setType('name', PARAM_TEXT); @@ -207,6 +235,9 @@ abstract class question_edit_form extends question_wizard_form { $mform->setType('questiontext', PARAM_RAW); $mform->addRule('questiontext', null, 'required', null, 'client'); + $mform->addElement('select', 'status', get_string('status', 'qbank_editquestion'), + \qbank_editquestion\editquestion_helper::get_question_status_list()); + $mform->addElement('float', 'defaultmark', get_string('defaultmark', 'question'), array('size' => 7)); $mform->setDefault('defaultmark', $this->get_default_value('defaultmark', 1)); @@ -237,30 +268,6 @@ abstract class question_edit_form extends question_wizard_form { $this->customfieldhandler->instance_form_definition($mform, empty($this->question->id) ? 0 : $this->question->id); } - if (!empty($this->question->id)) { - $mform->addElement('header', 'createdmodifiedheader', - get_string('createdmodifiedheader', 'question')); - $a = new stdClass(); - if (!empty($this->question->createdby)) { - $a->time = userdate($this->question->timecreated); - $a->user = fullname($DB->get_record( - 'user', array('id' => $this->question->createdby))); - } else { - $a->time = get_string('unknown', 'question'); - $a->user = get_string('unknown', 'question'); - } - $mform->addElement('static', 'created', get_string('created', 'question'), - get_string('byandon', 'question', $a)); - if (!empty($this->question->modifiedby)) { - $a = new stdClass(); - $a->time = userdate($this->question->timemodified); - $a->user = fullname($DB->get_record( - 'user', array('id' => $this->question->modifiedby))); - $mform->addElement('static', 'modified', get_string('modified', 'question'), - get_string('byandon', 'question', $a)); - } - } - $this->add_hidden_fields(); $mform->addElement('hidden', 'qtype'); @@ -870,13 +877,20 @@ abstract class question_edit_form extends question_wizard_form { $categoryinfo = $fromform['category']; } list($categoryid, $notused) = explode(',', $categoryinfo); - $conditions = 'category = ? AND idnumber = ?'; + $conditions = 'questioncategoryid = ? AND idnumber = ?'; $params = [$categoryid, $fromform['idnumber']]; if (!empty($this->question->id)) { + // Get the question bank entry id to not check the idnumber for the same bank entry. + $sql = "SELECT DISTINCT qbe.id + FROM {question_versions} qv + JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid + WHERE qv.questionid = ?"; + $bankentry = $DB->get_record_sql($sql, ['id' => $this->question->id]); $conditions .= ' AND id <> ?'; - $params[] = $this->question->id; + $params[] = $bankentry->id; } - if ($DB->record_exists_select('question', $conditions, $params)) { + + if ($DB->record_exists_select('question_bank_entry', $conditions, $params)) { $errors['idnumber'] = get_string('idnumbertaken', 'error'); } } diff --git a/question/type/essay/tests/helper.php b/question/type/essay/tests/helper.php index 48ba2b04e00..58509a4600a 100644 --- a/question/type/essay/tests/helper.php +++ b/question/type/essay/tests/helper.php @@ -95,6 +95,7 @@ class qtype_essay_test_helper extends question_test_helper { $fromform->filetypeslist = ''; // Although once saved in the DB, this becomes null, the form returns '' here. $fromform->graderinfo = array('text' => '', 'format' => FORMAT_HTML); $fromform->responsetemplate = array('text' => '', 'format' => FORMAT_HTML); + $fromform->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; return $fromform; } @@ -147,6 +148,7 @@ class qtype_essay_test_helper extends question_test_helper { $fromform->filetypeslist = ''; // Although once saved in the DB, this becomes null, the form returns '' here. $fromform->graderinfo = array('text' => '', 'format' => FORMAT_HTML); $fromform->responsetemplate = array('text' => '', 'format' => FORMAT_HTML); + $fromform->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; return $fromform; } @@ -184,6 +186,7 @@ class qtype_essay_test_helper extends question_test_helper { $fromform->filetypeslist = ''; // Although once saved in the DB, this becomes null, the form returns '' here. $fromform->graderinfo = array('text' => '', 'format' => FORMAT_HTML); $fromform->responsetemplate = array('text' => '', 'format' => FORMAT_HTML); + $fromform->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; return $fromform; } diff --git a/question/type/essay/tests/restore_test.php b/question/type/essay/tests/restore_test.php index 4d2811b49c9..a2bfda292c9 100644 --- a/question/type/essay/tests/restore_test.php +++ b/question/type/essay/tests/restore_test.php @@ -48,7 +48,7 @@ class qtype_essay_restore_testcase extends restore_date_testcase { // Create a course with one essay question in its question bank. $generator = $this->getDataGenerator(); $course = $generator->create_course(); - $contexts = new question_edit_contexts(context_course::instance($course->id)); + $contexts = new core_question\lib\question_edit_contexts(context_course::instance($course->id)); $category = question_make_default_categories($contexts->all()); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $essay = $questiongenerator->create_question('essay', null, array('category' => $category->id)); @@ -60,7 +60,7 @@ class qtype_essay_restore_testcase extends restore_date_testcase { $newcourseid = $this->backup_and_restore($course); // Verify that the restored question has options. - $contexts = new question_edit_contexts(context_course::instance($newcourseid)); + $contexts = new core_question\lib\question_edit_contexts(context_course::instance($newcourseid)); $newcategory = question_make_default_categories($contexts->all()); $newessay = $DB->get_record('question', ['category' => $newcategory->id, 'qtype' => 'essay']); $this->assertTrue($DB->record_exists('qtype_essay_options', ['questionid' => $newessay->id])); diff --git a/question/type/gapselect/tests/edit_form_test.php b/question/type/gapselect/tests/edit_form_test.php index 53b962a0f1c..1b3394dbc96 100644 --- a/question/type/gapselect/tests/edit_form_test.php +++ b/question/type/gapselect/tests/edit_form_test.php @@ -94,7 +94,7 @@ class qtype_gapselect_edit_form_test extends advanced_testcase { $fakequestion->inputs = null; $form = new $classname(new moodle_url('/'), $fakequestion, $category, - new question_edit_contexts($syscontext)); + new core_question\lib\question_edit_contexts($syscontext)); return [$form, $category]; } diff --git a/question/type/gapselect/tests/helper.php b/question/type/gapselect/tests/helper.php index 5907cc122c8..40b464b72d4 100644 --- a/question/type/gapselect/tests/helper.php +++ b/question/type/gapselect/tests/helper.php @@ -57,8 +57,10 @@ class qtype_gapselect_test_helper extends question_test_helper { $gapselect->penalty = 0.3333333; $gapselect->length = 1; $gapselect->stamp = make_unique_id_code(); - $gapselect->version = make_unique_id_code(); - $gapselect->hidden = 0; + $gapselect->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $gapselect->versionid = 0; + $gapselect->version = 1; + $gapselect->questionbankentryid = 0; $gapselect->idnumber = null; $gapselect->timecreated = time(); $gapselect->timemodified = time(); @@ -108,6 +110,7 @@ class qtype_gapselect_test_helper extends question_test_helper { test_question_maker::set_standard_combined_feedback_form_data($fromform); $fromform->shownumcorrect = 0; $fromform->penalty = 0.3333333; + $fromform->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; return $fromform; } @@ -126,6 +129,7 @@ class qtype_gapselect_test_helper extends question_test_helper { $gapselect->questiontext = 'The [[1]] brown [[2]] jumped over the [[3]] dog.'; $gapselect->generalfeedback = 'This sentence uses each letter of the alphabet.'; $gapselect->qtype = question_bank::get_qtype('gapselect'); + $gapselect->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; $gapselect->shufflechoices = true; @@ -165,6 +169,7 @@ class qtype_gapselect_test_helper extends question_test_helper { '7 [[1]] 11 [[2]] 13 [[1]] 17 [[2]] 19 = 3'; $gapselect->generalfeedback = 'This sentence uses each letter of the alphabet.'; $gapselect->qtype = question_bank::get_qtype('gapselect'); + $gapselect->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; $gapselect->shufflechoices = true; @@ -200,6 +205,7 @@ class qtype_gapselect_test_helper extends question_test_helper { 'sat on theсидела на [[2]].'; $gapselect->generalfeedback = 'This sentence uses each letter of the alphabet.'; $gapselect->qtype = question_bank::get_qtype('gapselect'); + $gapselect->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; $gapselect->shufflechoices = true; @@ -240,6 +246,7 @@ class qtype_gapselect_test_helper extends question_test_helper { $gapselect->questiontext = 'The price of the ball is [[1]] approx.'; $gapselect->generalfeedback = 'The choice is yours'; $gapselect->qtype = question_bank::get_qtype('gapselect'); + $gapselect->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; $gapselect->shufflechoices = true; diff --git a/question/type/gapselect/tests/questiontype_test.php b/question/type/gapselect/tests/questiontype_test.php index 6138e23d92d..02ac623d7b8 100644 --- a/question/type/gapselect/tests/questiontype_test.php +++ b/question/type/gapselect/tests/questiontype_test.php @@ -105,7 +105,6 @@ class qtype_gapselect_test extends question_testcase { $expected = test_question_maker::make_question('gapselect'); $expected->stamp = $qdata->stamp; - $expected->version = $qdata->version; $q = $this->qtype->make_question($qdata); @@ -239,7 +238,7 @@ class qtype_gapselect_test extends question_testcase { $qdata->defaultmark = 3; $qdata->length = 1; $qdata->penalty = 0.3333333; - $qdata->hidden = 0; + $qdata->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; $qdata->options = new stdClass(); $qdata->options->shuffleanswers = 1; diff --git a/question/type/match/tests/helper.php b/question/type/match/tests/helper.php index b93f3da29ff..ad50d54cfed 100644 --- a/question/type/match/tests/helper.php +++ b/question/type/match/tests/helper.php @@ -59,7 +59,10 @@ class qtype_match_test_helper extends question_test_helper { $q->defaultmark = 1; $q->penalty = 0.3333333; $q->length = 1; - $q->hidden = 0; + $q->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $q->versionid = 0; + $q->version = 1; + $q->questionbankentryid = 0; $q->createdby = $USER->id; $q->modifiedby = $USER->id; @@ -105,6 +108,10 @@ class qtype_match_test_helper extends question_test_helper { $q->generalfeedback = array('text' => 'General feedback.', 'format' => FORMAT_HTML); $q->defaultmark = 1; $q->penalty = 0.3333333; + $q->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $q->versionid = 0; + $q->version = 1; + $q->questionbankentryid = 0; $q->shuffleanswers = 1; test_question_maker::set_standard_combined_feedback_form_data($q); @@ -180,7 +187,10 @@ class qtype_match_test_helper extends question_test_helper { $q->defaultmark = 1; $q->penalty = 0.3333333; $q->length = 1; - $q->hidden = 0; + $q->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $q->versionid = 0; + $q->version = 1; + $q->questionbankentryid = 0; $q->createdby = $USER->id; $q->modifiedby = $USER->id; @@ -221,6 +231,10 @@ class qtype_match_test_helper extends question_test_helper { $q->generalfeedback = ['text' => 'Java has some advantages over PHP I guess!', 'format' => FORMAT_HTML]; $q->defaultmark = 1; $q->penalty = 0.3333333; + $q->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $q->versionid = 0; + $q->version = 1; + $q->questionbankentryid = 0; $q->shuffleanswers = 1; test_question_maker::set_standard_combined_feedback_form_data($q); diff --git a/question/type/match/tests/questiontype_test.php b/question/type/match/tests/questiontype_test.php index ae7457a0ce3..af11ff690aa 100644 --- a/question/type/match/tests/questiontype_test.php +++ b/question/type/match/tests/questiontype_test.php @@ -66,8 +66,10 @@ class qtype_match_test extends advanced_testcase { $q->penalty = 0.3333333; $q->length = 1; $q->stamp = make_unique_id_code(); - $q->version = make_unique_id_code(); - $q->hidden = 0; + $q->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $q->version = 1; + $q->versionid = 0; + $q->questionbankentryid = 0; $q->idnumber = null; $q->timecreated = time(); $q->timemodified = time(); @@ -184,7 +186,8 @@ class qtype_match_test extends advanced_testcase { $actualquestiondata = end($actualquestionsdata); foreach ($questiondata as $property => $value) { - if (!in_array($property, array('id', 'version', 'timemodified', 'timecreated', 'options', 'stamp'))) { + if (!in_array($property, ['id', 'timemodified', 'timecreated', 'options', 'stamp', + 'versionid', 'questionbankentryid'])) { $this->assertEquals($value, $actualquestiondata->$property); } } diff --git a/question/type/missingtype/questiontype.php b/question/type/missingtype/questiontype.php index 81ccbb69a3d..e82bd997ccd 100644 --- a/question/type/missingtype/questiontype.php +++ b/question/type/missingtype/questiontype.php @@ -75,8 +75,7 @@ class qtype_missingtype extends question_type { $question->length = 1; $question->penalty = 0; $question->stamp = ''; - $question->version = 0; - $question->hidden = 0; + $question->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; $question->timecreated = null; $question->timemodified = null; $question->createdby = null; diff --git a/question/type/missingtype/tests/missingtype_test.php b/question/type/missingtype/tests/missingtype_test.php index 9609ee547f9..f3e2c6c33f6 100644 --- a/question/type/missingtype/tests/missingtype_test.php +++ b/question/type/missingtype/tests/missingtype_test.php @@ -56,8 +56,10 @@ class qtype_missing_test extends question_testcase { $questiondata->qtype = 'strange_unknown'; $questiondata->length = 1; $questiondata->stamp = make_unique_id_code(); - $questiondata->version = make_unique_id_code(); - $questiondata->hidden = 0; + $questiondata->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $questiondata->version = 1; + $questiondata->versionid = 0; + $questiondata->questionbankentryid = 0; $questiondata->idnumber = null; $questiondata->timecreated = 0; $questiondata->timemodified = 0; diff --git a/question/type/multianswer/questiontype.php b/question/type/multianswer/questiontype.php index 325aff85da0..07462f07c24 100644 --- a/question/type/multianswer/questiontype.php +++ b/question/type/multianswer/questiontype.php @@ -73,6 +73,7 @@ class qtype_multianswer extends question_type { // For wrapped questions the maxgrade is always equal to the defaultmark, // there is no entry in the question_instances table for them. $wrapped->maxmark = $wrapped->defaultmark; + $wrapped->category = $question->categoryobject->id; $question->options->questions[$sequence[$wrapped->id]] = $wrapped; } $question->hints = $DB->get_records('question_hints', @@ -94,15 +95,17 @@ class qtype_multianswer extends question_type { // First we get all the existing wrapped questions. $oldwrappedquestions = []; - if ($oldwrappedids = $DB->get_field('question_multianswer', 'sequence', - array('question' => $question->id))) { - $oldwrappedidsarray = explode(',', $oldwrappedids); - $unorderedquestions = $DB->get_records_list('question', 'id', $oldwrappedidsarray); + if (isset($question->oldparent)) { + if ($oldwrappedids = $DB->get_field('question_multianswer', 'sequence', + ['question' => $question->oldparent])) { + $oldwrappedidsarray = explode(',', $oldwrappedids); + $unorderedquestions = $DB->get_records_list('question', 'id', $oldwrappedidsarray); - // Keep the order as given in the sequence field. - foreach ($oldwrappedidsarray as $questionid) { - if (isset($unorderedquestions[$questionid])) { - $oldwrappedquestions[] = $unorderedquestions[$questionid]; + // Keep the order as given in the sequence field. + foreach ($oldwrappedidsarray as $questionid) { + if (isset($unorderedquestions[$questionid])) { + $oldwrappedquestions[] = $unorderedquestions[$questionid]; + } } } } @@ -111,10 +114,10 @@ class qtype_multianswer extends question_type { foreach ($question->options->questions as $wrapped) { if (!empty($wrapped)) { // If we still have some old wrapped question ids, reuse the next of them. - + $wrapped->id = 0; if (is_array($oldwrappedquestions) && $oldwrappedquestion = array_shift($oldwrappedquestions)) { - $wrapped->id = $oldwrappedquestion->id; + $wrapped->oldid = $oldwrappedquestion->id; if ($oldwrappedquestion->qtype != $wrapped->qtype) { switch ($oldwrappedquestion->qtype) { case 'multichoice': @@ -132,11 +135,8 @@ class qtype_multianswer extends question_type { default: throw new moodle_exception('qtypenotrecognized', 'qtype_multianswer', '', $oldwrappedquestion->qtype); - $wrapped->id = 0; } } - } else { - $wrapped->id = 0; } } $wrapped->name = $question->name; @@ -183,7 +183,7 @@ class qtype_multianswer extends question_type { $question->id = $authorizedquestion->id; } - $question->category = $authorizedquestion->category; + $question->category = $form->category; $form->defaultmark = $question->defaultmark; $form->questiontext = $question->questiontext; $form->questiontextformat = 0; diff --git a/question/type/multianswer/tests/helper.php b/question/type/multianswer/tests/helper.php index cc19a48d39f..403a744a238 100644 --- a/question/type/multianswer/tests/helper.php +++ b/question/type/multianswer/tests/helper.php @@ -131,6 +131,7 @@ class qtype_multianswer_test_helper extends question_test_helper { $qdata->defaultmark = 2.0; $qdata->qtype = 'multianswer'; + $qdata->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; $sa = new stdClass(); test_question_maker::initialise_question_data($sa); @@ -140,6 +141,7 @@ class qtype_multianswer_test_helper extends question_test_helper { $sa->generalfeedback = ''; $sa->penalty = 0.0; $sa->qtype = 'shortanswer'; + $sa->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; $sa->options = new stdClass(); $sa->options->usecase = 0; @@ -159,6 +161,7 @@ class qtype_multianswer_test_helper extends question_test_helper { $mc->generalfeedback = ''; $mc->penalty = 0.0; $mc->qtype = 'multichoice'; + $mc->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; $mc->options = new stdClass(); $mc->options->layout = 0; @@ -214,6 +217,7 @@ class qtype_multianswer_test_helper extends question_test_helper { $qdata->defaultmark = 1.0; $qdata->qtype = 'multianswer'; + $qdata->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; $mc = new stdClass(); test_question_maker::initialise_question_data($mc); @@ -223,6 +227,7 @@ class qtype_multianswer_test_helper extends question_test_helper { $mc->generalfeedback = ''; $mc->penalty = 0.0; $mc->qtype = 'multichoice'; + $mc->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; $mc->options = new stdClass(); $mc->options->layout = 0; @@ -274,6 +279,8 @@ class qtype_multianswer_test_helper extends question_test_helper { 1 => array('text' => 'Hint 2', 'format' => FORMAT_HTML, 'itemid' => 0), ); + $formdata->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + return $formdata; } diff --git a/question/type/multianswer/tests/questiontype_test.php b/question/type/multianswer/tests/questiontype_test.php index e06c2e9f336..1c5f510ef6c 100644 --- a/question/type/multianswer/tests/questiontype_test.php +++ b/question/type/multianswer/tests/questiontype_test.php @@ -67,10 +67,12 @@ class qtype_multianswer_test extends advanced_testcase { $q->generalfeedbackformat = FORMAT_HTML; $q->defaultmark = 2; $q->penalty = 0.3333333; + $q->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $q->versionid = 0; + $q->version = 1; + $q->questionbankentryid = 0; $q->length = 1; $q->stamp = make_unique_id_code(); - $q->version = make_unique_id_code(); - $q->hidden = 0; $q->timecreated = time(); $q->timemodified = time(); $q->createdby = $USER->id; @@ -139,8 +141,8 @@ class qtype_multianswer_test extends advanced_testcase { $this->assertEquals(['id', 'category', 'parent', 'name', 'questiontext', 'questiontextformat', 'generalfeedback', 'generalfeedbackformat', 'defaultmark', 'penalty', 'qtype', - 'length', 'stamp', 'version', 'hidden', 'timecreated', 'timemodified', - 'createdby', 'modifiedby', 'idnumber', 'contextid', 'options', 'hints', 'categoryobject'], + 'length', 'stamp', 'timecreated', 'timemodified', 'createdby', 'modifiedby', 'idnumber', 'contextid', + 'status', 'versionid', 'version', 'questionbankentryid', 'categoryobject', 'options', 'hints'], array_keys(get_object_vars($questiondata))); $this->assertEquals($category->id, $questiondata->category); $this->assertEquals(0, $questiondata->parent); @@ -153,7 +155,7 @@ class qtype_multianswer_test extends advanced_testcase { $this->assertEquals(0, $questiondata->penalty); $this->assertEquals('multianswer', $questiondata->qtype); $this->assertEquals(1, $questiondata->length); - $this->assertEquals(0, $questiondata->hidden); + $this->assertEquals(0, $questiondata->status); $this->assertEquals($question->createdby, $questiondata->createdby); $this->assertEquals($question->createdby, $questiondata->modifiedby); $this->assertEquals('', $questiondata->idnumber); @@ -205,7 +207,6 @@ class qtype_multianswer_test extends advanced_testcase { 'qtype' => $value->qtype, 'length' => $value->length, 'stamp' => $value->stamp, - 'hidden' => 0, 'timecreated' => $value->timecreated, 'timemodified' => $value->timemodified, 'createdby' => $value->createdby, @@ -215,7 +216,15 @@ class qtype_multianswer_test extends advanced_testcase { } // Need to get rid of (version, idnumber, options, hints, maxmark). They are missing @ fromform. $gotquestions = array_map(function($question) { - unset($question->version); + $question->id = (int) $question->id; + $question->category = (int) $question->category; + $question->defaultmark = (float) $question->defaultmark; + $question->penalty = (float) $question->penalty; + $question->length = (int) $question->length; + $question->timecreated = (int) $question->timecreated; + $question->timemodified = (int) $question->timemodified; + $question->createdby = (int) $question->createdby; + $question->modifiedby = (int) $question->modifiedby; unset($question->idnumber); unset($question->options); unset($question->hints); @@ -250,7 +259,8 @@ class qtype_multianswer_test extends advanced_testcase { $actualquestiondata = end($actualquestionsdata); foreach ($questiondata as $property => $value) { - if (!in_array($property, array('id', 'version', 'timemodified', 'timecreated', 'options', 'hints', 'stamp'))) { + if (!in_array($property, ['id', 'timemodified', 'timecreated', 'options', 'hints', 'stamp', + 'idnumber', 'version', 'versionid', 'questionbankentryid'])) { $this->assertEquals($value, $actualquestiondata->$property); } } @@ -273,8 +283,8 @@ class qtype_multianswer_test extends advanced_testcase { $this->assertObjectHasAttribute('questions', $actualquestiondata->options); $subqpropstoignore = - array('id', 'category', 'parent', 'contextid', 'question', 'options', 'stamp', 'version', 'timemodified', - 'timecreated'); + ['id', 'category', 'parent', 'contextid', 'question', 'options', 'stamp', 'timemodified', + 'timecreated', 'status', 'idnumber', 'version', 'versionid', 'questionbankentryid']; foreach ($questiondata->options->questions as $subqno => $subq) { $actualsubq = $actualquestiondata->options->questions[$subqno]; foreach ($subq as $subqproperty => $subqvalue) { diff --git a/question/type/multichoice/tests/helper.php b/question/type/multichoice/tests/helper.php index 10b444cb9fc..6dd8e44520e 100644 --- a/question/type/multichoice/tests/helper.php +++ b/question/type/multichoice/tests/helper.php @@ -57,8 +57,10 @@ class qtype_multichoice_test_helper extends question_test_helper { $qdata->defaultmark = 1; $qdata->length = 1; $qdata->penalty = 0.3333333; - $qdata->hidden = 0; - + $qdata->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $qdata->versionid = 0; + $qdata->version = 1; + $qdata->questionbankentryid = 0; $qdata->options = new stdClass(); $qdata->options->shuffleanswers = 1; $qdata->options->answernumbering = '123'; @@ -144,6 +146,10 @@ class qtype_multichoice_test_helper extends question_test_helper { $qdata->noanswers = 5; $qdata->numhints = 2; $qdata->penalty = 0.3333333; + $qdata->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $qdata->versionid = 0; + $qdata->version = 1; + $qdata->questionbankentryid = 0; $qdata->shuffleanswers = 1; $qdata->answernumbering = '123'; @@ -240,7 +246,10 @@ class qtype_multichoice_test_helper extends question_test_helper { $qdata->defaultmark = 1; $qdata->length = 1; $qdata->penalty = 0.3333333; - $qdata->hidden = 0; + $qdata->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $qdata->versionid = 0; + $qdata->version = 1; + $qdata->questionbankentryid = 0; $qdata->options = new stdClass(); $qdata->options->shuffleanswers = 1; @@ -327,6 +336,10 @@ class qtype_multichoice_test_helper extends question_test_helper { $qdata->noanswers = 5; $qdata->numhints = 2; $qdata->penalty = 0.3333333; + $qdata->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $qdata->versionid = 0; + $qdata->version = 1; + $qdata->questionbankentryid = 0; $qdata->shuffleanswers = 1; $qdata->answernumbering = '123'; diff --git a/question/type/multichoice/tests/questiontype_test.php b/question/type/multichoice/tests/questiontype_test.php index 7068cab481b..ca961968e8f 100644 --- a/question/type/multichoice/tests/questiontype_test.php +++ b/question/type/multichoice/tests/questiontype_test.php @@ -133,7 +133,8 @@ class qtype_multichoice_test extends advanced_testcase { $actualquestiondata = end($actualquestionsdata); foreach ($questiondata as $property => $value) { - if (!in_array($property, array('id', 'version', 'timemodified', 'timecreated', 'options', 'hints', 'stamp'))) { + if (!in_array($property, ['id', 'timemodified', 'timecreated', 'options', 'hints', 'stamp', + 'versionid', 'questionbankentryid'])) { $this->assertEquals($value, $actualquestiondata->$property); } } diff --git a/question/type/numerical/tests/helper.php b/question/type/numerical/tests/helper.php index c039098dd61..fdc19f860b8 100644 --- a/question/type/numerical/tests/helper.php +++ b/question/type/numerical/tests/helper.php @@ -162,6 +162,8 @@ class qtype_numerical_test_helper extends question_test_helper { $form->hint[1]['text'] = ''; $form->qtype = 'numerical'; + $form->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + return $form; } @@ -192,7 +194,8 @@ class qtype_numerical_test_helper extends question_test_helper { $q->penalty = 0.3333333; $q->qtype = 'numerical'; $q->length = '1'; - $q->hidden = '0'; + $q->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $q->version = 1; $q->createdby = '2'; $q->modifiedby = '2'; $q->options = new stdClass(); diff --git a/question/type/questionbase.php b/question/type/questionbase.php index 6df0ae5debc..45a46e75f1f 100644 --- a/question/type/questionbase.php +++ b/question/type/questionbase.php @@ -100,12 +100,6 @@ abstract class question_definition { /** @var string unique identifier of this question. */ public $stamp; - /** @var string unique identifier of this version of this question. */ - public $version; - - /** @var boolean whethre this question has been deleted/hidden in the question bank. */ - public $hidden = 0; - /** @var string question idnumber. */ public $idnumber; @@ -124,6 +118,18 @@ abstract class question_definition { /** @var array of question_hints. */ public $hints = array(); + /** @var boolean question status hidden/ready/draft in the question bank. */ + public $status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + + /** @var int Version id of the question in a question bank */ + public $versionid; + + /** @var int Version number of the question in a question bank */ + public $version; + + /** @var int Bank entry id for the question */ + public $questionbankentryid; + /** * @var array of array of \core_customfield\data_controller objects indexed by fieldid for the questions custom fields. */ diff --git a/question/type/questiontypebase.php b/question/type/questiontypebase.php index 83d7f4fc7c6..28db6c8a17e 100644 --- a/question/type/questiontypebase.php +++ b/question/type/questiontypebase.php @@ -27,6 +27,7 @@ defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/question/engine/lib.php'); +require_once($CFG->libdir . '/questionlib.php'); /** @@ -367,8 +368,9 @@ class question_type { // The actual update/insert done with multiple DB access, so we do it in a transaction. $transaction = $DB->start_delegated_transaction (); - list($question->category) = explode(',', $form->category); - $context = $this->get_context_by_category_id($question->category); + list($form->category) = explode(',', $form->category); + $context = $this->get_context_by_category_id($form->category); + $question->category = $form->category; // This default implementation is suitable for most // question types. @@ -425,24 +427,107 @@ class question_type { } else { $category = $form->category; } - if (!$DB->record_exists('question', - ['idnumber' => $form->idnumber, 'category' => $category])) { + $sql = "SELECT qbe.id + FROM {question_bank_entry} qbe + WHERE qbe.idnumber = :idnumber + AND qbe.questioncategoryid = :categoryid"; + + if (!$DB->record_exists_sql($sql, + ['idnumber' => $form->idnumber, 'categoryid' => $category])) { + $question->idnumber = $form->idnumber; + } + } + } else { + $question->idnumber = null; + } + + // Only create a new bank entry if the question is not a new version (New question or duplicating a question). + $questionbankentry = null; + if (isset($question->id)) { + $oldparent = $question->id; + if (!empty($question->id)) { + // Get the bank entry record where the question is referenced. + $questionbankentry = get_question_bank_entry($question->id); + } + } + + // Get the bank entry old id (this is when there are questions related with a parent, e.g.: qtype_multianswers). + if (isset($question->oldid)) { + if (!empty($question->oldid)) { + $questionbankentry = get_question_bank_entry($question->oldid); + } + } + + // Always creates a new question and version record. + // Set the unique code. + $question->stamp = make_unique_id_code(); + $question->createdby = $USER->id; + $question->timecreated = time(); + + // Idnumber validation. + $question->idnumber = null; + if (isset($form->idnumber)) { + if ((string) $form->idnumber === '') { + $question->idnumber = null; + } else { + // While this check already exists in the form validation, + // this is a backstop preventing unnecessary errors. + // Only set the idnumber if it has changed and will not cause a unique index violation. + if (strpos($form->category, ',') !== false) { + list($category, $categorycontextid) = explode(',', $form->category); + } else { + $category = $form->category; + } + $params = ['idnumber' => $form->idnumber, 'categoryid' => $category]; + $andcondition = ''; + if (isset($question->id) && isset($questionbankentry->id)) { + $andcondition = 'AND qbe.id != :notid'; + $params['notid'] = $questionbankentry->id; + } + $sql = "SELECT qbe.id + FROM {question_bank_entries} qbe + WHERE qbe.idnumber = :idnumber + AND qbe.questioncategoryid = :categoryid + $andcondition"; + if (!$DB->record_exists_sql($sql, $params)) { $question->idnumber = $form->idnumber; } } } - // If the question is new, create it. - $newquestion = false; - if (empty($question->id)) { - // Set the unique code. - $question->stamp = make_unique_id_code(); - $question->createdby = $USER->id; - $question->timecreated = time(); - $question->id = $DB->insert_record('question', $question); - $newquestion = true; + // Create the question. + $question->id = $DB->insert_record('question', $question); + if (!$questionbankentry) { + // Create a record for question_bank_entries, question_versions and question_references. + $questionbankentry = new \stdClass(); + $questionbankentry->questioncategoryid = $form->category; + $questionbankentry->idnumber = $question->idnumber; + $questionbankentry->ownerid = $question->createdby; + $questionbankentry->id = $DB->insert_record('question_bank_entries', $questionbankentry); + } else { + $questionbankentryold = new \stdClass(); + $questionbankentryold->id = $questionbankentry->id; + $questionbankentryold->idnumber = $question->idnumber; + $DB->update_record('question_bank_entries', $questionbankentryold); } + // Create question_versions records. + $questionversion = new \stdClass(); + $questionversion->questionbankentryid = $questionbankentry->id; + $questionversion->questionid = $question->id; + // Get the version and status from the parent question if parent is set. + if (!$question->parent) { + // Get the status field. It comes from the form, but for testing we can. + $status = $form->status ?? $question->status ?? \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; + $questionversion->version = get_next_version($questionbankentry->id); + $questionversion->status = $status; + } else { + $parentversion = get_question_version($form->parent); + $questionversion->version = $parentversion[array_key_first($parentversion)]->version; + $questionversion->status = $parentversion[array_key_first($parentversion)]->status; + } + $questionversion->id = $DB->insert_record('question_versions', $questionversion); + // Now, whether we are updating a existing question, or creating a new // one, we have to do the files processing and update the record. // Question already exists, update. @@ -465,12 +550,16 @@ class question_type { // Now to save all the answers and type-specific options. $form->id = $question->id; $form->qtype = $question->qtype; - $form->category = $question->category; $form->questiontext = $question->questiontext; $form->questiontextformat = $question->questiontextformat; // Current context. $form->context = $context; - + // Old parent question id is used when there are questions related with a parent, e.g.: qtype_multianswers). + if (isset($oldparent)) { + $form->oldparent = $oldparent; + } else { + $form->oldparent = $question->parent; + } $result = $this->save_question_options($form); if (!empty($result->error)) { @@ -486,10 +575,6 @@ class question_type { '$result->noticeyesno no longer supported in save_question.'); } - // Give the question a unique version stamp determined by question_hash(). - $DB->set_field('question', 'version', question_hash($question), - array('id' => $question->id)); - if ($newquestion) { // Log the creation of this question. $event = \core\event\question_created::create_from_question_instance($question, $context); @@ -945,14 +1030,14 @@ class question_type { $question->length = $questiondata->length; $question->penalty = $questiondata->penalty; $question->stamp = $questiondata->stamp; - $question->version = $questiondata->version; - $question->hidden = $questiondata->hidden; $question->idnumber = $questiondata->idnumber; $question->timecreated = $questiondata->timecreated; $question->timemodified = $questiondata->timemodified; $question->createdby = $questiondata->createdby; $question->modifiedby = $questiondata->modifiedby; + $this->initialise_question_extra_record($question, $questiondata); + // Fill extra question fields values. $extraquestionfields = $this->extra_question_fields(); if (is_array($extraquestionfields)) { @@ -969,6 +1054,27 @@ class question_type { $this->initialise_custom_fields($question, $questiondata); } + /** + * Initialise the extra question fields. + * @param question_definition $question the question_definition we are creating. + * @param object $questiondata the question data loaded from the database. + */ + protected function initialise_question_extra_record(question_definition $question, $questiondata) { + $fields = [ + 'status', + 'versionid', + 'version', + 'questionbankentryid', + 'idnumber', + ]; + + foreach ($fields as $field) { + if (isset($questiondata->{$field})) { + $question->{$field} = $questiondata->{$field}; + } + } + } + /** * Initialise question_definition::hints field. * @param question_definition $question the question_definition we are creating. @@ -1320,6 +1426,7 @@ class question_type { $form->questiontext = 'test question, generated by script'; $form->defaultmark = 1; $form->penalty = 0.3333333; + $form->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; $form->generalfeedback = "Well done"; $context = context_course::instance($courseid); diff --git a/question/type/random/classes/task/remove_unused_questions.php b/question/type/random/classes/task/remove_unused_questions.php deleted file mode 100644 index 08844f8ffa0..00000000000 --- a/question/type/random/classes/task/remove_unused_questions.php +++ /dev/null @@ -1,69 +0,0 @@ -. - -/** - * A scheduled task to remove unneeded random questions. - * - * @package qtype_random - * @category task - * @copyright 2018 Bo Pierce - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ - -namespace qtype_random\task; - -defined('MOODLE_INTERNAL') || die(); - - -/** - * A scheduled task to remove unneeded random questions. - * - * @copyright 2018 Bo Pierce - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ -class remove_unused_questions extends \core\task\scheduled_task { - - public function get_name() { - return get_string('taskunusedrandomscleanup', 'qtype_random'); - } - - public function execute() { - global $DB, $CFG; - require_once($CFG->libdir . '/questionlib.php'); - - // Find potentially unused random questions (up to 10000). - // Note, because we call question_delete_question below, - // the question will not actually be deleted if something else - // is using them, but nothing else in Moodle core uses qtype_random, - // and not many third-party plugins do. - $unusedrandomids = $DB->get_records_sql(" - SELECT q.id, 1 - FROM {question} q - LEFT JOIN {quiz_slots} qslots ON q.id = qslots.questionid - WHERE qslots.questionid IS NULL - AND q.qtype = ? AND hidden = ?", ['random', 0], 0, 10000); - - $count = 0; - foreach ($unusedrandomids as $unusedrandomid => $notused) { - question_delete_question($unusedrandomid); - // In case the question was not actually deleted (because it was in use somehow - // mark it as hidden so the query above will not return it again. - $DB->set_field('question', 'hidden', 1, ['id' => $unusedrandomid]); - $count += 1; - } - mtrace('Cleaned up ' . $count . ' unused random questions.'); - } -} diff --git a/question/type/random/db/tasks.php b/question/type/random/db/tasks.php deleted file mode 100644 index e6298dcd781..00000000000 --- a/question/type/random/db/tasks.php +++ /dev/null @@ -1,38 +0,0 @@ -. - -/** - * Definition of question/type/random scheduled tasks. - * - * @package qtype_random - * @category task - * @copyright 2018 Bo Pierce - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ - -defined('MOODLE_INTERNAL') || die(); - -$tasks = array( - array( - 'classname' => 'qtype_random\task\remove_unused_questions', - 'blocking' => 0, - 'minute' => 'R', - 'hour' => '*', - 'day' => '*', - 'month' => '*', - 'dayofweek' => '*' - ) -); diff --git a/question/type/random/questiontype.php b/question/type/random/questiontype.php index c4305d554bd..11d7c313591 100644 --- a/question/type/random/questiontype.php +++ b/question/type/random/questiontype.php @@ -85,12 +85,19 @@ class qtype_random extends question_type { } list($qcsql, $qcparams) = $DB->get_in_or_equal($categorylist); // TODO use in_or_equal for $otherquestionsinuse and $this->manualqtypes. - return $DB->record_exists_select('question', - "category {$qcsql} - AND parent = 0 - AND hidden = 0 - AND id NOT IN ($otherquestionsinuse) - AND qtype IN ($this->manualqtypes)", $qcparams); + + $readystatus = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $sql = "SELECT q.* + FROM {question} q + JOIN {question_versions} qv ON qv.questionid = q.id + JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid + WHERE qbe.questioncategoryid {$qcsql} + AND q.parent = 0 + AND qv.status = $readystatus + AND q.id NOT IN ($otherquestionsinuse) + AND q.qtype IN ($this->manualqtypes)"; + + return $DB->record_exists_sql($sql, $qcparams); } /** diff --git a/question/type/random/tests/cleanup_task_test.php b/question/type/random/tests/cleanup_task_test.php deleted file mode 100644 index a8f43b0b5e2..00000000000 --- a/question/type/random/tests/cleanup_task_test.php +++ /dev/null @@ -1,70 +0,0 @@ -. - -/** - * Tests of the scheduled task for cleaning up random questions. - * - * @package qtype_random - * @copyright 2018 The Open University - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ - - -defined('MOODLE_INTERNAL') || die(); - -global $CFG; -require_once($CFG->dirroot . '/mod/quiz/locallib.php'); - - -/** - * Tests of the scheduled task for cleaning up random questions. - * - * @copyright 2018 The Open University - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ -class qtype_random_cleanup_task_testcase extends advanced_testcase { - - public function test_cleanup_task_removes_unused_question() { - global $DB; - $this->resetAfterTest(); - $this->setAdminUser(); - - $generator = $this->getDataGenerator(); - $questiongenerator = $generator->get_plugin_generator('core_question'); - $quizgenerator = $generator->get_plugin_generator('mod_quiz'); - $cat = $questiongenerator->create_question_category(); - $quiz = $quizgenerator->create_instance(['course' => SITEID]); - - // Add two random questions. - quiz_add_random_questions($quiz, 0, $cat->id, 2, false); - $quizslots = $DB->get_records('quiz_slots', ['quizid' => $quiz->id], - 'slot', 'slot, id, questionid'); - - // Now remove the second from the quiz. (Do it manually, - // because the API cleans up the random question, but we are trying to - // create an orphaned random question.) - $DB->delete_records('quiz_slots', array('id' => $quizslots[2]->id)); - - // Run the scheduled task. - $task = new \qtype_random\task\remove_unused_questions(); - $this->expectOutputString("Cleaned up 1 unused random questions.\n"); - $task->execute(); - - // Verify. - $this->assertTrue($DB->record_exists('question', ['id' => $quizslots[1]->questionid])); - $this->assertFalse($DB->record_exists('question', ['id' => $quizslots[2]->questionid])); - } -} diff --git a/question/type/random/tests/helper.php b/question/type/random/tests/helper.php index 3fabfaac50f..06bd159cf10 100644 --- a/question/type/random/tests/helper.php +++ b/question/type/random/tests/helper.php @@ -47,6 +47,7 @@ class qtype_random_test_helper extends question_test_helper { $form = new stdClass(); $form->questiontext = array('text' => ''); $form->includesubcategories = '0'; + $form->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; return $form; } } diff --git a/question/type/random/tests/questiontype_test.php b/question/type/random/tests/questiontype_test.php index 444006525f5..2199860d51e 100644 --- a/question/type/random/tests/questiontype_test.php +++ b/question/type/random/tests/questiontype_test.php @@ -81,8 +81,8 @@ class qtype_random_test extends advanced_testcase { $this->assertEquals(['id', 'category', 'parent', 'name', 'questiontext', 'questiontextformat', 'generalfeedback', 'generalfeedbackformat', 'defaultmark', 'penalty', 'qtype', - 'length', 'stamp', 'version', 'hidden', 'timecreated', 'timemodified', - 'createdby', 'modifiedby', 'idnumber', 'contextid', 'options', 'hints', 'categoryobject'], + 'length', 'stamp', 'timecreated', 'timemodified', 'createdby', 'modifiedby', 'idnumber', 'contextid', + 'status', 'versionid', 'version', 'questionbankentryid', 'categoryobject', 'options', 'hints'], array_keys(get_object_vars($questiondata))); $this->assertEquals($category->id, $questiondata->category); @@ -93,7 +93,7 @@ class qtype_random_test extends advanced_testcase { $this->assertEquals(0, $questiondata->questiontext); // Used to store 'Select from subcategories'. $this->assertEquals('random', $questiondata->qtype); $this->assertEquals(1, $questiondata->length); - $this->assertEquals(0, $questiondata->hidden); + $this->assertEquals(0, $questiondata->status); $this->assertEquals($category->contextid, $questiondata->contextid); // Options - not used. diff --git a/question/type/random/version.php b/question/type/random/version.php index c9e07c21b51..b5944ecabc1 100644 --- a/question/type/random/version.php +++ b/question/type/random/version.php @@ -26,7 +26,7 @@ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'qtype_random'; -$plugin->version = 2021052500; +$plugin->version = 2021112600; $plugin->requires = 2021052500; diff --git a/question/type/shortanswer/tests/helper.php b/question/type/shortanswer/tests/helper.php index 08899194224..f14b92538ee 100644 --- a/question/type/shortanswer/tests/helper.php +++ b/question/type/shortanswer/tests/helper.php @@ -75,6 +75,7 @@ class qtype_shortanswer_test_helper extends question_test_helper { $qdata->name = 'Short answer question'; $qdata->questiontext = 'Name an amphibian: __________'; $qdata->generalfeedback = 'Generalfeedback: frog or toad would have been OK.'; + $qdata->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; $qdata->options = new stdClass(); $qdata->options->usecase = 0; @@ -108,6 +109,7 @@ class qtype_shortanswer_test_helper extends question_test_helper { array('text' => 'Toad is an OK good answer.', 'format' => FORMAT_HTML), array('text' => 'That is a bad answer.', 'format' => FORMAT_HTML), ); + $form->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; return $form; } @@ -146,6 +148,7 @@ class qtype_shortanswer_test_helper extends question_test_helper { $qdata->name = 'Short answer question'; $qdata->questiontext = 'Name the best amphibian: __________'; $qdata->generalfeedback = 'Generalfeedback: you should have said frog.'; + $qdata->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; $qdata->options = new stdClass(); $qdata->options->usecase = false; diff --git a/question/type/shortanswer/tests/questiontype_test.php b/question/type/shortanswer/tests/questiontype_test.php index 5a1ca8397c5..d1aa4d3489c 100644 --- a/question/type/shortanswer/tests/questiontype_test.php +++ b/question/type/shortanswer/tests/questiontype_test.php @@ -120,7 +120,7 @@ class qtype_shortanswer_test extends advanced_testcase { $actualquestiondata = end($actualquestionsdata); foreach ($questiondata as $property => $value) { - if (!in_array($property, array('id', 'version', 'timemodified', 'timecreated', 'options'))) { + if (!in_array($property, array('id', 'timemodified', 'timecreated', 'options'))) { $this->assertEquals($value, $actualquestiondata->$property); } } diff --git a/question/type/truefalse/tests/helper.php b/question/type/truefalse/tests/helper.php index 78850acb938..a2aa579ecdc 100644 --- a/question/type/truefalse/tests/helper.php +++ b/question/type/truefalse/tests/helper.php @@ -86,6 +86,7 @@ class qtype_truefalse_test_helper extends question_test_helper { $form->feedbackfalse['text'] = 'This is the wrong answer.'; $form->penalty = 1; + $form->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; return $form; } @@ -102,7 +103,7 @@ class qtype_truefalse_test_helper extends question_test_helper { $q->penalty = 1; $q->qtype = 'truefalse'; $q->length = '1'; - $q->hidden = '0'; + $q->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; $q->createdby = '2'; $q->modifiedby = '2'; $q->options = new stdClass(); diff --git a/question/type/truefalse/tests/questiontype_test.php b/question/type/truefalse/tests/questiontype_test.php index 6bae0bcdc27..26d7bf4c7cc 100644 --- a/question/type/truefalse/tests/questiontype_test.php +++ b/question/type/truefalse/tests/questiontype_test.php @@ -82,8 +82,8 @@ class qtype_truefalse_test extends advanced_testcase { $this->assertEquals(['id', 'category', 'parent', 'name', 'questiontext', 'questiontextformat', 'generalfeedback', 'generalfeedbackformat', 'defaultmark', 'penalty', 'qtype', - 'length', 'stamp', 'version', 'hidden', 'timecreated', 'timemodified', - 'createdby', 'modifiedby', 'idnumber', 'contextid', 'options', 'hints', 'categoryobject'], + 'length', 'stamp', 'timecreated', 'timemodified', 'createdby', 'modifiedby', 'idnumber', 'contextid', + 'status', 'versionid', 'version', 'questionbankentryid', 'categoryobject', 'options', 'hints'], array_keys(get_object_vars($questiondata))); $this->assertEquals($category->id, $questiondata->category); $this->assertEquals(0, $questiondata->parent); @@ -96,7 +96,7 @@ class qtype_truefalse_test extends advanced_testcase { $this->assertEquals(1, $questiondata->penalty); $this->assertEquals('truefalse', $questiondata->qtype); $this->assertEquals(1, $questiondata->length); - $this->assertEquals(0, $questiondata->hidden); + $this->assertEquals(0, $questiondata->status); $this->assertEquals($question->createdby, $questiondata->createdby); $this->assertEquals($question->createdby, $questiondata->modifiedby); $this->assertEquals('', $questiondata->idnumber); diff --git a/question/upgrade.txt b/question/upgrade.txt index ea09fa1c2ba..1afbdfb4f46 100644 --- a/question/upgrade.txt +++ b/question/upgrade.txt @@ -40,7 +40,6 @@ This files describes API changes for code that uses the question API. 'core_question\bank\copy_action_column' => 'qbank_editquestion\copy_action_column', 'core_question\bank\edit_action_column' => 'qbank_editquestion\edit_action_column', 'core_question\bank\creator_name_column' => 'qbank_viewcreator\creator_name_column', - 'core_question\bank\modifier_name_column' => 'qbank_viewcreator\modifier_name_column', 'core_question\bank\question_name_column' => 'qbank_viewquestionname\viewquestionname_column_helper', 'core_question\bank\question_name_idnumber_tags_column' => 'qbank_viewquestionname\question_name_idnumber_tags_column', 'core_question\bank\delete_action_column' => 'qbank_deletequestion\delete_action_column', From 111951d861f4a8023a7a04a104fd7b3ed0d1a672 Mon Sep 17 00:00:00 2001 From: Safat Shahin Date: Fri, 3 Sep 2021 16:43:49 +1000 Subject: [PATCH 5/8] MDL-71696 mod_quiz: quiz updates and behat coverage The versioning changes will require some major changes in the backup and restore of question bank and its elements. This change introduces those changes to make it compatible with the new world of versioning in question bank. This commit also removes quiz_slots fields and quiz_slot_tags table. This commit will also introduce the versioning db structure and some major changes to the quiz and quiz attempts for the question, random question and the view. This commit implements the behat changes for versioning in core question and associated locations. --- admin/qtypes.php | 40 +- backup/moodle2/backup_stepslib.php | 191 ++++++-- backup/moodle2/restore_stepslib.php | 359 ++++++++++++--- .../tests/quiz_restore_decode_links_test.php | 42 +- .../dbops/backup_question_dbops.class.php | 39 +- backup/util/dbops/restore_dbops.class.php | 13 +- course/lib.php | 4 +- course/tests/courselib_test.php | 9 +- lib/db/renamedclasses.php | 4 +- lib/questionlib.php | 397 +++------------- lib/tests/questionlib_test.php | 18 +- mod/quiz/amd/build/question_slot.min.js | 2 + mod/quiz/amd/build/question_slot.min.js.map | 2 + mod/quiz/amd/src/question_slot.js | 76 ++++ mod/quiz/attemptlib.php | 65 +-- .../backup/moodle2/backup_quiz_stepslib.php | 67 +-- .../backup/moodle2/restore_quiz_stepslib.php | 123 +++-- .../external/submit_question_version.php | 105 +++++ .../classes/local/structure/slot_random.php | 64 ++- mod/quiz/classes/output/edit_renderer.php | 128 ++++-- .../classes/question/bank/custom_view.php | 80 ++++ .../bank/filter/custom_category_condition.php | 45 ++ .../custom_category_condition_helper.php | 129 ++++++ .../classes/question/bank/qbank_helper.php | 427 ++++++++++++++++++ .../question/qubaids_for_users_attempts.php | 2 + mod/quiz/classes/structure.php | 142 +++--- mod/quiz/db/install.xml | 18 - mod/quiz/db/services.php | 8 + mod/quiz/db/upgrade.php | 54 ++- mod/quiz/editrandom.php | 138 ++---- mod/quiz/lang/en/quiz.php | 4 + mod/quiz/lib.php | 47 +- mod/quiz/locallib.php | 246 ++++++---- mod/quiz/report/attemptsreport_table.php | 2 +- mod/quiz/report/overview/report.php | 1 - .../overview/tests/behat/regrading.feature | 53 +-- mod/quiz/report/reportlib.php | 41 +- mod/quiz/report/statistics/report.php | 24 +- .../report/statistics/statistics_table.php | 3 +- .../statistics/tests/behat/basic.feature | 4 +- mod/quiz/styles.css | 6 + mod/quiz/templates/question_slot.mustache | 52 +++ .../attempt_walkthrough_from_csv_test.php | 1 - .../behat/attempt_redo_questions.feature | 14 +- mod/quiz/tests/behat/behat_mod_quiz.php | 19 +- .../tests/behat/editing_add_random.feature | 9 +- .../tests/behat/editing_edit_random.feature | 8 +- .../behat/quiz_question_versions.feature | 78 ++++ mod/quiz/tests/external/external_test.php | 8 +- mod/quiz/tests/lib_test.php | 6 +- .../local_structure_slot_random_test.php | 116 +++-- mod/quiz/tests/locallib_test.php | 268 +---------- mod/quiz/tests/qbank_helper_test.php | 225 +++++++++ .../tests/quiz_question_helper_test_trait.php | 182 ++++++++ mod/quiz/tests/quiz_question_restore_test.php | 286 ++++++++++++ mod/quiz/tests/quiz_question_version_test.php | 185 ++++++++ mod/quiz/tests/structure_test.php | 152 +------ mod/quiz/tests/tags_test.php | 31 +- mod/quiz/upgrade.txt | 8 +- mod/quiz/version.php | 2 +- question/bank/bulkmove/classes/helper.php | 4 +- question/bank/bulkmove/move.php | 5 +- question/bank/bulkmove/tests/helper_test.php | 4 +- question/bank/comment/tests/backup_test.php | 13 +- .../tests/behat/question_comment.feature | 33 +- .../event/comment_created_deleted_test.php | 3 +- .../classes/delete_action_column.php | 2 +- question/bank/deletequestion/delete.php | 2 +- question/bank/editquestion/question.php | 7 +- .../tests/behat/behat_qbank_editquestion.php | 59 +++ .../tests/behat/qbank_editquestion.feature | 39 ++ .../tests/behat/question_status.feature | 31 ++ question/bank/managecategories/category.php | 2 + .../bank/managecategories/classes/helper.php | 2 +- .../classes/question_category_object.php | 4 +- .../managecategories/tests/helper_test.php | 35 +- .../tests/behat/preview_question.feature | 62 ++- .../previewquestion/tests/helper_test.php | 2 +- .../classes/external/submit_tags.php | 2 +- question/bank/tagquestion/lib.php | 2 +- question/bank/usage/classes/helper.php | 46 +- question/bank/usage/tests/helper_test.php | 2 +- .../classes/local/bank/action_column_base.php | 2 +- .../local/bank/random_question_loader.php | 18 +- question/classes/local/bank/view.php | 2 +- .../all_calculated_for_qubaid_condition.php | 2 +- .../statistics/questions/calculator.php | 14 +- question/editlib.php | 24 +- question/engine/bank.php | 17 +- question/format.php | 7 +- question/format/xml/format.php | 5 +- .../tests/qformat_xml_import_export_test.php | 2 +- question/lib.php | 2 +- question/tests/backup_test.php | 17 +- .../behat/edit_question_versioning.feature | 55 +++ .../tests/behat/question_categories.feature | 13 - .../question_categories_idnumber.feature | 9 +- question/tests/generator/lib.php | 1 + question/tests/privacy/provider_test.php | 32 +- question/tests/version_test.php | 2 +- .../calculated/datasetdefinitions_form.php | 13 +- .../type/calculated/datasetitems_form.php | 17 +- .../tests/questiontype_test.php | 2 +- .../description/tests/questiontype_test.php | 2 +- question/type/edit_question_form.php | 4 +- .../tests/behat/file_type_restriction.feature | 2 + question/type/essay/tests/restore_test.php | 7 +- .../type/match/tests/questiontype_test.php | 8 +- .../multianswer/edit_multianswer_form.php | 11 +- .../multianswer/tests/questiontype_test.php | 6 +- .../multichoice/tests/questiontype_test.php | 2 +- .../numerical/tests/questiontype_test.php | 2 +- question/type/questiontypebase.php | 61 +-- question/type/random/questiontype.php | 2 +- .../shortanswer/tests/questiontype_test.php | 4 +- .../truefalse/tests/questiontype_test.php | 2 +- report/questioninstances/index.php | 6 +- 117 files changed, 3878 insertions(+), 1730 deletions(-) create mode 100644 mod/quiz/amd/build/question_slot.min.js create mode 100644 mod/quiz/amd/build/question_slot.min.js.map create mode 100644 mod/quiz/amd/src/question_slot.js create mode 100644 mod/quiz/classes/external/submit_question_version.php create mode 100644 mod/quiz/classes/question/bank/filter/custom_category_condition.php create mode 100644 mod/quiz/classes/question/bank/filter/custom_category_condition_helper.php create mode 100644 mod/quiz/classes/question/bank/qbank_helper.php create mode 100644 mod/quiz/templates/question_slot.mustache create mode 100644 mod/quiz/tests/behat/quiz_question_versions.feature create mode 100644 mod/quiz/tests/qbank_helper_test.php create mode 100644 mod/quiz/tests/quiz_question_helper_test_trait.php create mode 100644 mod/quiz/tests/quiz_question_restore_test.php create mode 100644 mod/quiz/tests/quiz_question_version_test.php create mode 100644 question/bank/editquestion/tests/behat/behat_qbank_editquestion.php create mode 100644 question/bank/editquestion/tests/behat/qbank_editquestion.feature create mode 100644 question/bank/editquestion/tests/behat/question_status.feature create mode 100644 question/tests/behat/edit_question_versioning.feature diff --git a/admin/qtypes.php b/admin/qtypes.php index 954f89bc18a..5a9f0b698f5 100644 --- a/admin/qtypes.php +++ b/admin/qtypes.php @@ -49,28 +49,26 @@ $sql = "SELECT result.qtype, SUM(result.numquestions) AS numquestions, SUM(result.numhidden) AS numhidden, SUM(result.numdraft) AS numdraft - FROM ( - SELECT data.qtype, - COUNT(data.numquestions) AS numquestions, - (SELECT COUNT(qv.id) - FROM {question_versions} qv - WHERE qv.id = data.versionid + FROM (SELECT data.qtype, + COUNT(data.numquestions) AS numquestions, + (SELECT COUNT(qv.id) + FROM {question_versions} qv + WHERE qv.id = data.versionid AND qv.status = :hiddenstatus) AS numhidden, - (SELECT COUNT(qv.id) - FROM {question_versions} qv - WHERE qv.id = data.versionid - AND qv.status = :draftstatus) AS numdraft - FROM ( - SELECT q.qtype, qv.id AS versionid, 1 AS numquestions - FROM {question} q - JOIN {question_versions} qv ON qv.questionid = q.id - JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid - AND qv.version = (SELECT MAX(v.version) - FROM {question_versions} v - JOIN {question_bank_entries} be ON be.id = v.questionbankentryid - WHERE be.id = qbe.id)) data - GROUP BY data.qtype, data.versionid) result - GROUP BY result.qtype"; + (SELECT COUNT(qv.id) + FROM {question_versions} qv + WHERE qv.id = data.versionid + AND qv.status = :draftstatus) AS numdraft + FROM (SELECT q.qtype, qv.id AS versionid, 1 AS numquestions + FROM {question} q + JOIN {question_versions} qv ON qv.questionid = q.id + JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid + AND qv.version = (SELECT MAX(v.version) + FROM {question_versions} v + JOIN {question_bank_entries} be ON be.id = v.questionbankentryid + WHERE be.id = qbe.id)) data + GROUP BY data.qtype, data.versionid) result + GROUP BY result.qtype"; $counts = $DB->get_records_sql($sql, ['hiddenstatus' => $hiddenstatus, 'draftstatus' => $draftstatus]); $needed = []; diff --git a/backup/moodle2/backup_stepslib.php b/backup/moodle2/backup_stepslib.php index d1324de88d3..886f93fdc88 100644 --- a/backup/moodle2/backup_stepslib.php +++ b/backup/moodle2/backup_stepslib.php @@ -195,15 +195,81 @@ trait backup_questions_attempt_data_trait { } } +/** + * Helper to backup question reference data for an instance. + */ +trait backup_question_reference_data_trait { + + /** + * Backup the related data from reference table for the instance. + * + * @param backup_nested_element $element + * @param string $component + * @param string $questionarea + */ + protected function add_question_references($element, $component, $questionarea) { + // Check $element is one nested_backup_element. + if (! $element instanceof backup_nested_element) { + throw new backup_step_exception('question_states_bad_parent_element', $element); + } + + $reference = new backup_nested_element('question_reference', ['id'], + ['usingcontextid', 'component', 'questionarea', 'questionbankentryid', 'version']); + + $element->add_child($reference); + + $reference->set_source_table('question_references', [ + 'usingcontextid' => backup::VAR_CONTEXTID, + 'component' => backup_helper::is_sqlparam($component), + 'questionarea' => backup_helper::is_sqlparam($questionarea), + 'itemid' => backup::VAR_PARENTID + ]); + } +} /** - * Abstract structure step to help activities that store question attempt data. + * Helper to backup question set reference data for an instance. + */ +trait backup_question_set_reference_trait { + + /** + * Backup the related data from set_reference table for the instance. + * + * @param backup_nested_element $element + * @param string $component + * @param string $questionarea + */ + protected function add_question_set_references($element, $component, $questionarea) { + // Check $element is one nested_backup_element. + if (! $element instanceof backup_nested_element) { + throw new backup_step_exception('question_states_bad_parent_element', $element); + } + + $setreference = new backup_nested_element('question_set_reference', ['id'], + ['usingcontextid', 'component', 'questionarea', 'questionscontextid', 'filtercondition']); + + $element->add_child($setreference); + + $setreference->set_source_table('question_set_references', [ + 'usingcontextid' => backup::VAR_CONTEXTID, + 'component' => backup_helper::is_sqlparam($component), + 'questionarea' => backup_helper::is_sqlparam($questionarea), + 'itemid' => backup::VAR_PARENTID + ]); + } +} + + +/** + * Abstract structure step to help activities that store question attempt data, reference data and set reference data. * * @copyright 2011 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class backup_questions_activity_structure_step extends backup_activity_structure_step { use backup_questions_attempt_data_trait; + use backup_question_reference_data_trait; + use backup_question_set_reference_trait; } @@ -2325,30 +2391,64 @@ class backup_annotate_all_question_files extends backup_execution_step { /** * structure step in charge of constructing the questions.xml file for all the * question categories and questions required by the backup - * and letters related to one activity + * and letters related to one activity. */ class backup_questions_structure_step extends backup_structure_step { protected function define_structure() { - // Define each element separated - + // Define each element separately. $qcategories = new backup_nested_element('question_categories'); - $qcategory = new backup_nested_element('question_category', array('id'), array( - 'name', 'contextid', 'contextlevel', 'contextinstanceid', - 'info', 'infoformat', 'stamp', 'parent', - 'sortorder', 'idnumber')); + $qcategory = new backup_nested_element('question_category', ['id'], + [ + 'name', + 'contextid', + 'contextlevel', + 'contextinstanceid', + 'info', + 'infoformat', + 'stamp', + 'parent', + 'sortorder', + 'idnumber', + ]); + + $questionbankentries = new backup_nested_element('question_bank_entries'); + + $questionbankentry = new backup_nested_element('question_bank_entry', ['id'], + [ + 'questioncategoryid', + 'idnumber', + 'ownerid', + ]); + + $questionversions = new backup_nested_element('question_version'); + + $questionverion = new backup_nested_element('question_versions', ['id'], ['version', 'status']); $questions = new backup_nested_element('questions'); - $question = new backup_nested_element('question', array('id'), array( - 'parent', 'name', 'questiontext', 'questiontextformat', - 'generalfeedback', 'generalfeedbackformat', 'defaultmark', 'penalty', - 'qtype', 'length', 'stamp', 'version', - 'hidden', 'timecreated', 'timemodified', 'createdby', 'modifiedby', 'idnumber')); + $question = new backup_nested_element('question', ['id'], + [ + 'parent', + 'name', + 'questiontext', + 'questiontextformat', + 'generalfeedback', + 'generalfeedbackformat', + 'defaultmark', + 'penalty', + 'qtype', + 'length', + 'stamp', + 'timecreated', + 'timemodified', + 'createdby', + 'modifiedby', + ]); - // attach qtype plugin structure to $question element, only one allowed + // Attach qtype plugin structure to $question element, only one allowed. $this->add_plugin_structure('qtype', $question, false); // Attach qbank plugin stucture to $question element, multiple allowed. @@ -2359,55 +2459,70 @@ class backup_questions_structure_step extends backup_structure_step { $qhints = new backup_nested_element('question_hints'); - $qhint = new backup_nested_element('question_hint', array('id'), array( - 'hint', 'hintformat', 'shownumcorrect', 'clearwrong', 'options')); + $qhint = new backup_nested_element('question_hint', ['id'], + [ + 'hint', + 'hintformat', + 'shownumcorrect', + 'clearwrong', + 'options', + ]); $tags = new backup_nested_element('tags'); - $tag = new backup_nested_element('tag', array('id', 'contextid'), array('name', 'rawname')); - - // Build the tree + $tag = new backup_nested_element('tag', ['id', 'contextid'], ['name', 'rawname']); + // Build the initial tree. $qcategories->add_child($qcategory); - $qcategory->add_child($questions); + $qcategory->add_child($questionbankentries); + $questionbankentries->add_child($questionbankentry); + $questionbankentry->add_child($questionversions); + $questionversions->add_child($questionverion); + $questionverion->add_child($questions); $questions->add_child($question); $question->add_child($qhints); $qhints->add_child($qhint); + // Add question tags. $question->add_child($tags); $tags->add_child($tag); - // Define the sources - $qcategory->set_source_sql(" - SELECT gc.*, contextlevel, instanceid AS contextinstanceid + SELECT gc.*, + contextlevel, + instanceid AS contextinstanceid FROM {question_categories} gc JOIN {backup_ids_temp} bi ON bi.itemid = gc.id JOIN {context} co ON co.id = gc.contextid WHERE bi.backupid = ? - AND bi.itemname = 'question_categoryfinal'", array(backup::VAR_BACKUPID)); + AND bi.itemname = 'question_categoryfinal'", [backup::VAR_BACKUPID]); - $question->set_source_table('question', array('category' => backup::VAR_PARENTID)); + $questionbankentry->set_source_table('question_bank_entries', ['questioncategoryid' => backup::VAR_PARENTID]); + + $questionverion->set_source_table('question_versions', ['questionbankentryid' => backup::VAR_PARENTID]); + + $question->set_source_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 + WHERE qv.id = ?', [backup::VAR_PARENTID]); $qhint->set_source_sql(' SELECT * - FROM {question_hints} + FROM {question_hints} WHERE questionid = :questionid - ORDER BY id', - array('questionid' => backup::VAR_PARENTID)); + ORDER BY id', ['questionid' => backup::VAR_PARENTID]); $tag->set_source_sql("SELECT t.id, ti.contextid, t.name, t.rawname - FROM {tag} t - JOIN {tag_instance} ti ON ti.tagid = t.id - WHERE ti.itemid = ? - AND ti.itemtype = 'question' - AND ti.component = 'core_question'", - [ - backup::VAR_PARENTID - ]); + FROM {tag} t + JOIN {tag_instance} ti ON ti.tagid = t.id + WHERE ti.itemid = ? + AND ti.itemtype = 'question' + AND ti.component = 'core_question'", [backup::VAR_PARENTID]); - // don't need to annotate ids nor files - // (already done by {@link backup_annotate_all_question_files} + // Don't need to annotate ids nor files. + // ...(already done by {@see backup_annotate_all_question_files()}. return $qcategories; } diff --git a/backup/moodle2/restore_stepslib.php b/backup/moodle2/restore_stepslib.php index acfe70f6682..d74ef6c97cd 100644 --- a/backup/moodle2/restore_stepslib.php +++ b/backup/moodle2/restore_stepslib.php @@ -4731,46 +4731,86 @@ abstract class restore_activity_structure_step extends restore_structure_step { * Structure step in charge of creating/mapping all the qcats and qs * by parsing the questions.xml file and checking it against the * results calculated by {@link restore_process_categories_and_questions} - * and stored in backup_ids_temp + * and stored in backup_ids_temp. */ class restore_create_categories_and_questions extends restore_structure_step { - /** @var array $cachecategory store a question category */ + /** @var array $cachedcategory store a question category */ protected $cachedcategory = null; protected function define_structure() { - $category = new restore_path_element('question_category', '/question_categories/question_category'); - $question = new restore_path_element('question', '/question_categories/question_category/questions/question'); - $hint = new restore_path_element('question_hint', + // Check if the backup is a pre 4.0 one. + $backuprelease = $this->get_task()->get_info()->backup_release; // The major version: 2.9, 3.0, 3.10... + preg_match('/(\d{8})/', $this->get_task()->get_info()->moodle_release, $matches); + $backupbuild = (int)$matches[1]; + $before40 = false; + if (version_compare($backuprelease, '4.0', '<') || $backupbuild < 20220202) { + $before40 = true; + } + // Start creating the path, category should be the first one. + $paths = []; + $paths [] = new restore_path_element('question_category', '/question_categories/question_category'); + // For the backups done before 4.0. + if ($before40) { + // This path is to recreate the bank entry and version for the legacy question objets. + $question = new restore_path_element('question', '/question_categories/question_category/questions/question'); + + // Apply for 'qtype' plugins optional paths at question level. + $this->add_plugin_structure('qtype', $question); + + // Apply for 'local' plugins optional paths at question level. + $this->add_plugin_structure('local', $question); + + $paths [] = $question; + $paths [] = new restore_path_element('question_hint', '/question_categories/question_category/questions/question/question_hints/question_hint'); + $paths [] = new restore_path_element('tag', '/question_categories/question_category/questions/question/tags/tag'); + } else { + // For all the new backups. + $paths [] = new restore_path_element('question_bank_entry', + '/question_categories/question_category/question_bank_entries/question_bank_entry'); + $paths [] = new restore_path_element('question_versions', '/question_categories/question_category/'. + 'question_bank_entries/question_bank_entry/question_version/question_versions'); + $question = new restore_path_element('question', '/question_categories/question_category/'. + 'question_bank_entries/question_bank_entry/question_version/question_versions/questions/question'); - $tag = new restore_path_element('tag','/question_categories/question_category/questions/question/tags/tag'); + // Apply for 'qtype' plugins optional paths at question level. + $this->add_plugin_structure('qtype', $question); - // Apply for 'qtype' plugins optional paths at question level - $this->add_plugin_structure('qtype', $question); + // Apply for 'qbank' plugins optional paths at question level. + $this->add_plugin_structure('qbank', $question); - // Apply for 'qbank' plugins optional paths at question level. - $this->add_plugin_structure('qbank', $question); + // Apply for 'local' plugins optional paths at question level. + $this->add_plugin_structure('local', $question); - // Apply for 'local' plugins optional paths at question level - $this->add_plugin_structure('local', $question); + $paths [] = $question; + $paths [] = new restore_path_element('question_hint', '/question_categories/question_category/question_bank_entries/'. + 'question_bank_entry/question_version/question_versions/questions/question/question_hints/question_hint'); + $paths [] = new restore_path_element('tag', '/question_categories/question_category/question_bank_entries/'. + 'question_bank_entry/question_version/question_versions/questions/question/tags/tag'); + } - return [$category, $question, $hint, $tag]; + return $paths; } + /** + * Process question category restore. + * + * @param array $data the data from the XML file. + */ protected function process_question_category($data) { global $DB; $data = (object)$data; $oldid = $data->id; - // Check we have one mapping for this category + // Check we have one mapping for this category. if (!$mapping = $this->get_mapping('question_category', $oldid)) { return self::SKIP_ALL_CHILDREN; // No mapping = this category doesn't need to be created/mapped } - // Check we have to create the category (newitemid = 0) + // Check we have to create the category (newitemid = 0). if ($mapping->newitemid) { // By performing this set_mapping() we make get_old/new_parentid() to work for all the // children elements of the 'question_category' one. @@ -4781,7 +4821,7 @@ class restore_create_categories_and_questions extends restore_structure_step { // Arrived here, newitemid = 0, we need to create the category // we'll do it at parentitemid context, but for CONTEXT_MODULE // categories, that will be created at CONTEXT_COURSE and moved - // to module context later when the activity is created + // to module context later when the activity is created. if ($mapping->info->contextlevel == CONTEXT_MODULE) { $mapping->parentitemid = $this->get_mappingid('context', $this->task->get_old_contextid()); } @@ -4832,22 +4872,133 @@ class restore_create_categories_and_questions extends restore_structure_step { } } + /** + * Process pre 4.0 question data where in creates the record for version and entry table. + * + * @param array $data the data from the XML file. + */ + protected function process_question_legacy_data($data) { + global $DB; + + $oldid = $data->id; + // Process question bank entry. + $entrydata = new stdClass(); + $entrydata->questioncategoryid = $data->category; + $userid = $this->get_mappingid('user', $data->createdby); + if ($userid) { + $entrydata->ownerid = $userid; + } else { + if (!$this->task->is_samesite()) { + $entrydata->ownerid = $this->task->get_userid(); + } + } + // The idnumber if it exists also needs to be unique within a category or reset it to null. + if (isset($data->idnumber) && !$DB->record_exists('question_bank_entries', + ['idnumber' => $data->idnumber, 'questioncategoryid' => $data->category])) { + $entrydata->idnumber = $data->idnumber; + } + + $newentryid = $DB->insert_record('question_bank_entries', $entrydata); + // Process question versions. + $versiondata = new stdClass(); + $versiondata->questionbankentryid = $newentryid; + $versiondata->version = 1; + // Question id is updated after inserting the question. + $versiondata->questionid = 0; + $versiondata->status = $data->hidden; + $newversionid = $DB->insert_record('question_versions', $versiondata); + $this->set_mapping('question_version_created', $oldid, $newversionid); + } + + /** + * Process question bank entry data. + * + * @param array $data the data from the XML file. + */ + protected function process_question_bank_entry($data) { + global $DB; + + $data = (object)$data; + $oldid = $data->id; + + $questioncreated = $this->get_mappingid('question_category_created', $data->questioncategoryid) ? true : false; + $recordexist = $DB->record_exists('question_bank_entries', ['id' => $data->id, + 'questioncategoryid' => $data->questioncategoryid]); + // Check we have category created. + if (!$questioncreated && $recordexist) { + return self::SKIP_ALL_CHILDREN; + } + + $data->questioncategoryid = $this->get_new_parentid('question_category'); + $userid = $this->get_mappingid('user', $data->ownerid); + if ($userid) { + $data->ownerid = $userid; + } else { + if (!$this->task->is_samesite()) { + $data->ownerid = $this->task->get_userid(); + } + } + + // The idnumber if it exists also needs to be unique within a category or reset it to null. + if (!empty($data->idnumber) && $DB->record_exists('question_bank_entries', + ['idnumber' => $data->idnumber, 'questioncategoryid' => $data->questioncategoryid])) { + unset($data->idnumber); + } + + $newitemid = $DB->insert_record('question_bank_entries', $data); + $this->set_mapping('question_bank_entry', $oldid, $newitemid); + } + + /** + * Process question versions. + * + * @param array $data the data from the XML file. + */ + protected function process_question_versions($data) { + global $DB; + + $data = (object)$data; + $oldid = $data->id; + + $data->questionbankentryid = $this->get_new_parentid('question_bank_entry'); + // Question id is updated after inserting the question. + $data->questionid = 0; + $newitemid = $DB->insert_record('question_versions', $data); + $this->set_mapping('question_versions', $oldid, $newitemid); + } + + /** + * Process the actual question. + * + * @param array $data the data from the XML file. + */ protected function process_question($data) { global $DB; $data = (object)$data; $oldid = $data->id; - // Check we have one mapping for this question - if (!$questionmapping = $this->get_mapping('question', $oldid)) { - return; // No mapping = this question doesn't need to be created/mapped + // Check if the backup is a pre 4.0 one. + $backuprelease = $this->get_task()->get_info()->backup_release; // The major version: 2.9, 3.0, 3.10... + preg_match('/(\d{8})/', $this->get_task()->get_info()->moodle_release, $matches); + $backupbuild = (int)$matches[1]; + $before40 = false; + if (version_compare($backuprelease, '4.0', '<') || $backupbuild < 20220202) { + $before40 = true; } + if ($before40) { + // Check we have one mapping for this question. + if (!$questionmapping = $this->get_mapping('question', $oldid)) { + return; // No mapping = this question doesn't need to be created/mapped. + } - // Get the mapped category (cannot use get_new_parentid() because not - // all the categories have been created, so it is not always available - // Instead we get the mapping for the question->parentitemid because - // we have loaded qcatids there for all parsed questions - $data->category = $this->get_mappingid('question_category', $questionmapping->parentitemid); + // Get the mapped category (cannot use get_new_parentid() because not + // all the categories have been created, so it is not always available + // Instead we get the mapping for the question->parentitemid because + // we have loaded qcatids there for all parsed questions. + $data->category = $this->get_mappingid('question_category', $questionmapping->parentitemid); + $this->process_question_legacy_data($data); + } // In the past, there were some very sloppy values of penalty. Fix them. if ($data->penalty >= 0.33 && $data->penalty <= 0.34) { @@ -4884,37 +5035,29 @@ class restore_create_categories_and_questions extends restore_structure_step { } } - // With newitemid = 0, let's create the question - if (!$questionmapping->newitemid) { - - // The idnumber if it exists also needs to be unique within a category or reset it to null. - if (!empty($data->idnumber) && $DB->record_exists('question', - ['idnumber' => $data->idnumber, 'category' => $data->category])) { - unset($data->idnumber); - } - - if ($data->qtype === 'random') { - // Ensure that this newly created question is considered by - // \qtype_random\task\remove_unused_questions. - $data->hidden = 0; - } - - $newitemid = $DB->insert_record('question', $data); - $this->set_mapping('question', $oldid, $newitemid); - // Also annotate them as question_created, we need - // that later when remapping parents (keeping the old categoryid as parentid) - $this->set_mapping('question_created', $oldid, $newitemid, false, null, $questionmapping->parentitemid); + $newitemid = $DB->insert_record('question', $data); + $this->set_mapping('question', $oldid, $newitemid); + // Also annotate them as question_created, we need + // that later when remapping parents (keeping the old categoryid as parentid). + $parentcatid = $this->get_old_parentid('question_category'); + $this->set_mapping('question_created', $oldid, $newitemid, false, null, $parentcatid); + // Now update the question_versions table with the new question id. we dont need to do that for random qtypes. + $legacyquestiondata = $this->get_mappingid('question_version_created', $oldid) ? true : false; + if ($legacyquestiondata) { + $parentitemid = $this->get_mappingid('question_version_created', $oldid); } else { - // By performing this set_mapping() we make get_old/new_parentid() to work for all the - // children elements of the 'question' one (so qtype plugins will know the question they belong to) - $this->set_mapping('question', $oldid, $questionmapping->newitemid); + $parentitemid = $this->get_new_parentid('question_versions'); } + $version = new stdClass(); + $version->id = $parentitemid; + $version->questionid = $newitemid; + $DB->update_record('question_versions', $version); // Note, we don't restore any question files yet // as far as the CONTEXT_MODULE categories still // haven't their contexts to be restored to // The {@link restore_create_question_files}, executed in the final step - // step will be in charge of restoring all the question files + // step will be in charge of restoring all the question files. } protected function process_question_hint($data) { @@ -5146,11 +5289,11 @@ class restore_create_question_files extends restore_execution_step { // Parentitemids of question_createds in backup_ids_temp are the category it is in. // MUST use a recordset, as there is no unique key in the first (or any) column. $catqtypes = $DB->get_recordset_sql("SELECT DISTINCT bi.parentitemid AS categoryid, q.qtype as qtype - FROM {backup_ids_temp} bi - JOIN {question} q ON q.id = bi.newitemid - WHERE bi.backupid = ? - AND bi.itemname = 'question_created' - ORDER BY categoryid ASC", array($this->get_restoreid())); + FROM {backup_ids_temp} bi + JOIN {question} q ON q.id = bi.newitemid + WHERE bi.backupid = ? + AND bi.itemname = 'question_created' + ORDER BY categoryid ASC", array($this->get_restoreid())); $currentcatid = -1; foreach ($catqtypes as $categoryid => $row) { @@ -5673,10 +5816,15 @@ trait restore_questions_attempt_data_trait { $data = (object)$data; $oldid = $data->id; + + $questioncreated = $this->get_mappingid('question_created', $data->questionid) ? true : false; $question = $this->get_mapping('question', $data->questionid); + if ($questioncreated) { + $data->questionid = $question->newitemid; + } $data->questionusageid = $this->get_new_parentid($nameprefix . 'question_usage'); - $data->questionid = $question->newitemid; + if (!property_exists($data, 'variant')) { $data->variant = 1; } @@ -5688,7 +5836,12 @@ trait restore_questions_attempt_data_trait { $newitemid = $DB->insert_record('question_attempts', $data); $this->set_mapping($nameprefix . 'question_attempt', $oldid, $newitemid); - $this->qtypes[$newitemid] = $question->info->qtype; + if (isset($question->info->qtype)) { + $qtype = $question->info->qtype; + } else { + $qtype = $DB->get_record('question', ['id' => $data->questionid])->qtype; + } + $this->qtypes[$newitemid] = $qtype; $this->newquestionids[$newitemid] = $data->questionid; } @@ -5714,7 +5867,7 @@ trait restore_questions_attempt_data_trait { unset($data->response); $data->questionattemptid = $this->get_new_parentid($nameprefix . 'question_attempt'); - $data->userid = $this->get_mappingid('user', $data->userid); + $data->userid = $this->get_mappingid('user', $data->userid); // Everything ready, insert and create mapping (needed by question_sessions) $newitemid = $DB->insert_record('question_attempt_steps', $data); @@ -5802,6 +5955,98 @@ trait restore_questions_attempt_data_trait { } } +/** + * Helper trait to restore question reference data. + */ +trait restore_question_reference_data_trait { + + /** + * Attach the question reference data to the restore. + * + * @param restore_path_element $element the parent element. (E.g. a quiz attempt.) + * @param array $paths the paths array that is being built to describe the structure. + */ + protected function add_question_references($element, &$paths) { + // Check $element is restore_path_element. + if (! $element instanceof restore_path_element) { + throw new restore_step_exception('element_must_be_restore_path_element', $element); + } + + // Check $paths is one array. + if (!is_array($paths)) { + throw new restore_step_exception('paths_must_be_array', $paths); + } + + $paths[] = new restore_path_element('question_reference', + $element->get_path() . '/question_reference'); + } + + /** + * Process question references which replaces the direct connection to quiz slots to question. + * + * @param array $data the data from the XML file. + */ + public function process_question_reference($data) { + global $DB; + $data = (object) $data; + $data->usingcontextid = $this->get_mappingid('context', $data->usingcontextid); + $data->itemid = $this->get_new_parentid('quiz_question_instance'); + if ($entry = $this->get_mappingid('question_bank_entry', $data->questionbankentryid)) { + $data->questionbankentryid = $entry; + } + $DB->insert_record('question_references', $data); + } +} + +/** + * Helper trait to restore question set reference data. + */ +trait restore_question_set_reference_data_trait { + + /** + * Attach the question reference data to the restore. + * + * @param restore_path_element $element the parent element. (E.g. a quiz attempt.) + * @param array $paths the paths array that is being built to describe the structure. + */ + protected function add_question_set_references($element, &$paths) { + // Check $element is restore_path_element. + if (! $element instanceof restore_path_element) { + throw new restore_step_exception('element_must_be_restore_path_element', $element); + } + + // Check $paths is one array. + if (!is_array($paths)) { + throw new restore_step_exception('paths_must_be_array', $paths); + } + + $paths[] = new restore_path_element('question_set_reference', + $element->get_path() . '/question_set_reference'); + } + + /** + * Process question set references data which replaces the random qtype. + * + * @param array $data the data from the XML file. + */ + public function process_question_set_reference($data) { + global $DB; + $data = (object) $data; + $data->usingcontextid = $this->get_mappingid('context', $data->usingcontextid); + $data->itemid = $this->get_new_parentid('quiz_question_instance'); + $filtercondition = json_decode($data->filtercondition); + if ($category = $this->get_mappingid('question_category', $filtercondition->questioncategoryid)) { + $filtercondition->questioncategoryid = $category; + } + $data->filtercondition = json_encode($filtercondition); + if ($context = $this->get_mappingid('context', $data->questionscontextid)) { + $data->questionscontextid = $context; + } + + $DB->insert_record('question_set_references', $data); + } +} + /** * Abstract structure step to help activities that store question attempt data. @@ -5811,6 +6056,8 @@ trait restore_questions_attempt_data_trait { */ abstract class restore_questions_activity_structure_step extends restore_activity_structure_step { use restore_questions_attempt_data_trait; + use restore_question_reference_data_trait; + use restore_question_set_reference_data_trait; /** * Attach below $element (usually attempts) the needed restore_path_elements diff --git a/backup/tests/quiz_restore_decode_links_test.php b/backup/tests/quiz_restore_decode_links_test.php index 46a643619f5..207ef02818b 100644 --- a/backup/tests/quiz_restore_decode_links_test.php +++ b/backup/tests/quiz_restore_decode_links_test.php @@ -14,14 +14,6 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -/** - * Decode links quiz restore tests. - * - * @package core_backup - * @copyright 2020 Ilya Tregubov - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ - defined('MOODLE_INTERNAL') || die(); // Include all the needed stuff. @@ -31,7 +23,11 @@ require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php'); require_once($CFG->dirroot . '/question/engine/tests/helpers.php'); /** - * restore_decode tests (both rule and content) + * Decode links quiz restore tests. + * + * @package core_backup + * @copyright 2020 Ilya Tregubov + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class restore_quiz_decode_testcase extends \core_privacy\tests\provider_testcase { @@ -82,17 +78,23 @@ class restore_quiz_decode_testcase extends \core_privacy\tests\provider_testcase $newcm = duplicate_module($course, get_fast_modinfo($course)->get_cm($quiz->cmid)); - $sql = "SELECT qa.id, qa.answer - FROM {quiz} q - LEFT JOIN {quiz_slots} qs ON qs.quizid = q.id - LEFT JOIN {question_answers} qa ON qa.question = qs.questionid - WHERE q.id = :quizid"; - $params = array('quizid' => $newcm->instance); - $answers = $DB->get_records_sql_menu($sql, $params); + $quizquestions = \mod_quiz\question\bank\qbank_helper::get_question_structure_data($newcm->instance); + $questionids = []; + foreach ($quizquestions as $quizquestion) { + $questionids [] = $quizquestion->id; + } + list($condition, $param) = $DB->get_in_or_equal($questionids, SQL_PARAMS_NAMED, 'questionid'); + $condition = 'WHERE qa.question ' . $condition; - $this->assertEquals($CFG->wwwroot . '/course/view.php?id=' . $course->id, $answers[$firstanswer->id]); - $this->assertEquals($CFG->wwwroot . '/mod/quiz/view.php?id=' . $quiz->cmid, $answers[$secondanswer->id]); - $this->assertEquals($CFG->wwwroot . '/grade/report/index.php?id=' . $quiz->cmid, $answers[$thirdanswer->id]); - $this->assertEquals($CFG->wwwroot . '/mod/quiz/index.php?id=' . $quiz->cmid, $answers[$fourthanswer->id]); + $sql = "SELECT qa.id, + qa.answer + FROM {question_answers} qa + $condition"; + $answers = $DB->get_records_sql($sql, $param); + + $this->assertEquals($CFG->wwwroot . '/course/view.php?id=' . $course->id, $answers[$firstanswer->id]->answer); + $this->assertEquals($CFG->wwwroot . '/mod/quiz/view.php?id=' . $quiz->cmid, $answers[$secondanswer->id]->answer); + $this->assertEquals($CFG->wwwroot . '/grade/report/index.php?id=' . $quiz->cmid, $answers[$thirdanswer->id]->answer); + $this->assertEquals($CFG->wwwroot . '/mod/quiz/index.php?id=' . $quiz->cmid, $answers[$fourthanswer->id]->answer); } } diff --git a/backup/util/dbops/backup_question_dbops.class.php b/backup/util/dbops/backup_question_dbops.class.php index 67ff54fa281..c4e5ce68816 100644 --- a/backup/util/dbops/backup_question_dbops.class.php +++ b/backup/util/dbops/backup_question_dbops.class.php @@ -52,12 +52,39 @@ abstract class backup_question_dbops extends backup_dbops { // First, get all the contexts we are going to save their question bank (no matter // where they are in the contexts hierarchy, transversals... whatever) $contexts = $DB->get_fieldset_sql("SELECT DISTINCT qc2.contextid - FROM {question_categories} qc2 - JOIN {question} q ON q.category = qc2.id - JOIN {backup_ids_temp} bi ON bi.itemid = q.id - WHERE bi.backupid = ? - AND bi.itemname = 'question' - AND qc2.contextid != ?", array($backupid, $contextid)); + FROM {question_categories} qc2 + JOIN {question_bank_entries} qbe ON qbe.questioncategoryid = qc2.id + JOIN {question_versions} qv ON qv.questionbankentryid = qbe.id + JOIN {question} q ON q.id = qv.questionid + JOIN {backup_ids_temp} bi ON bi.itemid = q.id + WHERE bi.backupid = ? + AND bi.itemname = 'question' + AND qc2.contextid != ?", array($backupid, $contextid)); + + // Calculate and get the set reference records. + $setreferencecontexts = $DB->get_fieldset_sql(" + SELECT DISTINCT qc.contextid + FROM {question_categories} qc + JOIN {question_set_references} qsr ON qsr.questionscontextid = qc.contextid + WHERE qsr.usingcontextid = ?", [$contextid]); + foreach ($setreferencecontexts as $setreferencecontext) { + if (!in_array($setreferencecontext, $contexts) && (int)$setreferencecontext !== $contextid) { + $contexts [] = $setreferencecontext; + } + } + + // Calculate the get the reference records. + $referencecontexts = $DB->get_fieldset_sql(" + SELECT DISTINCT qc.contextid + FROM {question_categories} qc + JOIN {question_bank_entries} qbe ON qbe.questioncategoryid = qc.id + JOIN {question_references} qr ON qr.questionbankentryid = qbe.id + WHERE qr.usingcontextid =?", [$contextid]); + foreach ($referencecontexts as $referencecontext) { + if (!in_array($referencecontext, $contexts) && (int)$referencecontext !== $contextid) { + $contexts [] = $referencecontext; + } + } // And now, simply insert all the question categories (complete question bank) // for those contexts if we have found any if ($contexts) { diff --git a/backup/util/dbops/restore_dbops.class.php b/backup/util/dbops/restore_dbops.class.php index 435c101b9fc..6734921faa0 100644 --- a/backup/util/dbops/restore_dbops.class.php +++ b/backup/util/dbops/restore_dbops.class.php @@ -676,9 +676,16 @@ abstract class restore_dbops { $questions = self::restore_get_questions($restoreid, $category->id); // Collect all the questions for this category into memory so we only talk to the DB once. - $questioncache = $DB->get_records_sql_menu("SELECT ".$DB->sql_concat('stamp', "' '", 'version').", id - FROM {question} - WHERE category = ?", array($matchcat->id)); + $questioncache = $DB->get_records_sql_menu('SELECT q.id, + q.stamp + 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.id = ?', array($matchcat->id)); foreach ($questions as $question) { if (isset($questioncache[$question->stamp." ".$question->version])) { diff --git a/course/lib.php b/course/lib.php index 190eaeace07..d49576201cd 100644 --- a/course/lib.php +++ b/course/lib.php @@ -1064,14 +1064,14 @@ function course_delete_module($cmid, $async = false) { } } - question_delete_activity($cm); - // Call the delete_instance function, if it returns false throw an exception. if (!$deleteinstancefunction($cm->instance)) { throw new moodle_exception('cannotdeletemoduleinstance', '', '', null, "Cannot delete the module $modulename (instance)."); } + question_delete_activity($cm); + // Remove all module files in case modules forget to do that. $fs = get_file_storage(); $fs->delete_area_files($modcontext->id); diff --git a/course/tests/courselib_test.php b/course/tests/courselib_test.php index 33c1fd13fa9..2d71cd548d4 100644 --- a/course/tests/courselib_test.php +++ b/course/tests/courselib_test.php @@ -1706,8 +1706,13 @@ class core_course_courselib_testcase extends advanced_testcase { $this->assertEquals(0, $DB->count_records('question_categories', $criteria)); // Verify questions deleted. - $criteria = array('category' => $qcat->id); - $this->assertEquals(0, $DB->count_records('question', $criteria)); + $criteria = [$qcat->id]; + $sql = 'SELECT COUNT(q.id) + FROM {question} q + JOIN {question_versions} qv ON qv.questionid = q.id + JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid + WHERE qbe.questioncategoryid = ?'; + $this->assertEquals(0, $DB->count_records_sql($sql, $criteria)); break; default: break; diff --git a/lib/db/renamedclasses.php b/lib/db/renamedclasses.php index d44d2cd7cf7..3a8dee04335 100644 --- a/lib/db/renamedclasses.php +++ b/lib/db/renamedclasses.php @@ -80,5 +80,7 @@ $renamedclasses = [ 'export_form' => 'qbank_exportquestions\\form\\export_form', 'preview_options_form' => 'qbank_previewquestion\\form\\preview_options_form', 'question_preview_options' => 'qbank_previewquestion\\output\\question_preview_options', - 'core_question\\form\\tags' => '\qbank_tagquestion\\form\\tags_form' + 'core_question\\form\\tags' => 'qbank_tagquestion\\form\\tags_form', + 'context_to_string_translator' => 'core_question\\lib\\context_to_string_translator', + 'question_edit_contexts' => 'core_question\\lib\\question_edit_contexts', ]; diff --git a/lib/questionlib.php b/lib/questionlib.php index 541e4505449..b62376f73cd 100644 --- a/lib/questionlib.php +++ b/lib/questionlib.php @@ -171,7 +171,7 @@ function question_context_has_any_questions($context): bool { throw new moodle_exception('invalidcontextinhasanyquestions', 'question'); } $sql = 'SELECT qbe.* - FROM {question_bank_entry} qbe + FROM {question_bank_entries} qbe JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid WHERE qc.contextid = ?'; return $DB->record_exists_sql($sql, [$contextid]); @@ -235,7 +235,7 @@ function question_category_delete_safe($category): void { $rescue = null; // See the code around the call to question_save_from_deletion. // Deal with any questions in the category. - if ($questionentries = $DB->get_records('question_bank_entry', $criteria, '', 'id')) { + if ($questionentries = $DB->get_records('question_bank_entries', $criteria, '', 'id')) { foreach ($questionentries as $questionentry) { $questionids = $DB->get_records('question_versions', @@ -349,13 +349,13 @@ function question_delete_question($questionid): void { qc.contextid as contextid FROM {question} q LEFT JOIN {question_versions} qv ON qv.questionid = q.id - LEFT JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid + LEFT JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid LEFT JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid WHERE q.id = ?'; $questiondata = $DB->get_record_sql($sql, [$question->id]); // Do not delete a question if it is used by an activity module - if (questions_in_use([$questionid])) { + if (questions_in_use([$question->id])) { return; } @@ -659,12 +659,8 @@ function idnumber_exist_in_question_category($questionidnumber, $categoryid, $li $response = false; $record = []; // Check if the idnumber exist in the category. - $sql = 'SELECT q.id, qbe.idnumber - FROM {question} q - JOIN {question_versions} qv - ON qv.questionid = q.id - JOIN {question_bank_entry} qbe - ON qbe.id = qv.questionbankentryid + $sql = 'SELECT qbe.idnumber + FROM {question_bank_entries} qbe WHERE qbe.idnumber LIKE ? AND qbe.questioncategoryid = ? ORDER BY qbe.idnumber DESC'; @@ -706,7 +702,7 @@ function question_move_questions_to_category($questionids, $newcategoryid): bool qbe.idnumber FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id - JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid + JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid WHERE q.id $questionidcondition OR (q.parent <> 0 AND q.parent $questionidcondition)"; @@ -719,6 +715,9 @@ function question_move_questions_to_category($questionids, $newcategoryid): bool question_bank::get_qtype($question->qtype)->move_files( $question->id, $question->contextid, $newcategorydata->contextid); } + // Move set_reference records to new category. + move_question_set_references($question->category, $newcategoryid, + $question->contextid, $newcategorydata->contextid, true); // Check whether there could be a clash of idnumbers in the new category. list($idnumberclash, $rec) = idnumber_exist_in_question_category($question->idnumber, $newcategoryid); if ($idnumberclash) { @@ -735,27 +734,14 @@ function question_move_questions_to_category($questionids, $newcategoryid): bool $qbankentry = new stdClass(); $qbankentry->id = $question->entryid; $qbankentry->idnumber = $question->idnumber . '_' . $unique; - $DB->update_record('question_bank_entry', $qbankentry); + $DB->update_record('question_bank_entries', $qbankentry); } - // TODO: Now is commented because we are deciding if we should have an area: core_question. - // Update the reference to point to the new category context. - /*$references = $DB->get_records('question_references', - [ - 'questionbankentryid' => $question->entryid, - 'versionid' => $question->versionid - ] - ); - foreach ($references as $reference) { - $reference->usingcontextid = $newcategorydata->contextid; - $DB->update_record('question_references', $reference); - }*/ - // Update the entry to the new category id. $entry = new stdClass(); $entry->id = $question->entryid; $entry->questioncategoryid = $newcategorydata->id; - $DB->update_record('question_bank_entry', $entry); + $DB->update_record('question_bank_entries', $entry); // Log this question move. $event = \core\event\question_moved::create_from_question_instance($question, context::instance_by_id($question->contextid), @@ -776,6 +762,39 @@ function question_move_questions_to_category($questionids, $newcategoryid): bool return true; } +/** + * Update the questioncontextid field for all question_set_references records given a new context id + * + * @param int $oldcategoryid Old category to be moved. + * @param int $newcatgoryid New category that will receive the questions. + * @param int $oldcontextid Old context to be moved. + * @param int $newcontextid New context that will receive the questions. + * @param bool $delete If the action is delete. + * @throws dml_exception + */ +function move_question_set_references(int $oldcategoryid, int $newcatgoryid, + int $oldcontextid, int $newcontextid, bool $delete = false): void { + global $DB; + + if ($delete || $oldcontextid !== $newcontextid) { + $setreferences = $DB->get_recordset('question_set_references', ['questionscontextid' => $oldcontextid]); + foreach ($setreferences as $setreference) { + $filter = json_decode($setreference->filtercondition); + if (isset($filter->questioncategoryid)) { + if ((int)$filter->questioncategoryid === $oldcategoryid) { + $setreference->questionscontextid = $newcontextid; + if ($oldcategoryid !== $newcatgoryid) { + $filter->questioncategoryid = $newcatgoryid; + $setreference->filtercondition = json_encode($filter); + } + $DB->update_record('question_set_references', $setreference); + } + } + } + $setreferences->close(); + } +} + /** * This function helps move a question cateogry to a new context by moving all * the files belonging to all the questions to the new context. @@ -791,7 +810,7 @@ function question_move_category_to_context($categoryid, $oldcontextid, $newconte $sql = "SELECT q.id, q.qtype FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id - JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid + JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid WHERE qbe.questioncategoryid = ?"; $questionids = $DB->get_records_sql_menu($sql, [$categoryid]); @@ -860,10 +879,8 @@ function question_preload_questions($questionids = null, $extrafields = '', $joi $orderby = 'ORDER BY ' . $orderby; } - $sql = "SELECT q.id, qc.id as category, q.parent, q.name, q.questiontext, q.questiontextformat, - q.generalfeedback, q.generalfeedbackformat, q.defaultmark, q.penalty, q.qtype, - q.length, q.stamp, q.timecreated, q.timemodified, - q.createdby, q.modifiedby, qbe.idnumber, + $sql = "SELECT q.*, + qc.id as category, qv.status, qv.id as versionid, qv.version, @@ -873,7 +890,7 @@ function question_preload_questions($questionids = null, $extrafields = '', $joi FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id - JOIN {question_bank_entry} qbe + JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid @@ -1423,7 +1440,7 @@ function question_has_capability_on($questionorid, $cap, $notused = -1): bool { FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id - JOIN {question_bank_entry} qbe + JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid @@ -1867,7 +1884,7 @@ function core_question_question_preview_pluginfile($previewcontext, $questionid, FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id - JOIN {question_bank_entry} qbe + JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid @@ -1956,7 +1973,7 @@ function core_question_find_next_unused_idnumber(?string $oldidnumber, int $cate } // Find all used idnumbers in one DB query. - $usedidnumbers = $DB->get_records_select_menu('question_bank_entry', 'questioncategoryid = ? AND idnumber IS NOT NULL', + $usedidnumbers = $DB->get_records_select_menu('question_bank_entries', 'questioncategoryid = ? AND idnumber IS NOT NULL', [$categoryid], '', 'idnumber, 1'); // Find the next unused idnumber. @@ -1988,7 +2005,7 @@ function get_question_bank_entry(int $questionid): object { $sql = "SELECT qbe.* FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id - JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid + JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid WHERE q.id = :id"; $qbankentry = $DB->get_record_sql($sql, ['id' => $questionid]); @@ -2024,7 +2041,7 @@ function get_next_version(int $questionbankentryid): int { $sql = "SELECT MAX(qv.version) FROM {question_versions} qv - JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid + JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid WHERE qbe.id = :id"; $nextversion = $DB->get_field_sql($sql, ['id' => $questionbankentryid]); @@ -2036,298 +2053,28 @@ function get_next_version(int $questionbankentryid): int { return 1; } -// Deprecated classes and functions from Moodle 4.0. - /** - * Converts contextlevels to strings and back to help with reading/writing contexts to/from import/export files. + * Checks if question is the latest version. * - * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} - * @author 2021 Safat Shahin - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - * @deprecated Since Moodle 4.0 - * @see \core_question\lib\context_to_string_translator - * @todo MDL-72004 delete the class + * @param string $version Question version to check. + * @param string $questionbankentryid Entry to check against. + * @return bool */ -class context_to_string_translator { +function is_latest(string $version, string $questionbankentryid) : bool { + global $DB; - /** - * @var array used to translate between contextids and strings for this context. - */ - protected $contexttostringarray = []; + $sql = 'SELECT MAX(version) AS max + FROM {question_versions} + WHERE questionbankentryid = ?'; + $latestversion = $DB->get_record_sql($sql, [$questionbankentryid]); - /** - * context_to_string_translator constructor. - * - * @param context $contexts - */ - public function __construct($contexts) { - debugging('Class context_to_string_translator is deprecated and - moved to \core_question\lib\context_to_string_translator, - please use new \core_question\lib\context_to_string_translator($contexts) instead.', DEBUG_DEVELOPER); - $this->generate_context_to_string_array($contexts); + if (isset($latestversion->max)) { + return ($version === $latestversion->max) ? true : false; } - - /** - * 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) { - $contextid = array_search($contextname, $this->contexttostringarray); - return $contextid; - } - - /** - * Generate context to array. - * - * @param context $contexts - */ - protected function generate_context_to_string_array($contexts) { - if (!$this->contexttostringarray) { - $catno = 1; - 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; - } - $this->contexttostringarray[$context->id] = $contextstring; - } - } - } - + return false; } -/** - * Tracks all the contexts related to the one we are currently editing questions and provides helper methods to check permissions. - * - * @copyright 2007 Jamie Pratt me@jamiep.org - * @author 2021 Safat Shahin - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - * @deprecated Since Moodle 4.0 - * @see \core_question\lib\context_to_string_translator - * @todo MDL-72004 delete the class - */ -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) { - debugging('Class question_edit_contexts is deprecated and - moved to \core_question\lib\question_edit_contexts, - please use new \core_question\lib\question_edit_contexts($thiscontext) instead.', DEBUG_DEVELOPER); - $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); - } - } -} +// Deprecated functions from Moodle 4.0. /** * Generate the URL for starting a new preview of a given question with the given options. @@ -2342,13 +2089,14 @@ class question_edit_contexts { * @return moodle_url the URL. * @deprecated since Moodle 4.0 * @see qbank_previewquestion\previewquestion_helper::question_preview_url() + * @todo Final deprecation on Moodle 4.4 MDL-72438 */ function question_preview_url($questionid, $preferredbehaviour = null, $maxmark = null, $displayoptions = null, $variant = null, $context = null) { debugging('Function question_preview_url() has been deprecated and moved to qbank_previewquestion plugin, Please use qbank_previewquestion\previewquestion_helper::question_preview_url() instead.', DEBUG_DEVELOPER); - return \qbank_previewquestion\previewquestion_helper::question_preview_url($questionid, $preferredbehaviour, + return \qbank_previewquestion\helper::question_preview_url($questionid, $preferredbehaviour, $maxmark, $displayoptions, $variant, $context); } @@ -2356,12 +2104,13 @@ function question_preview_url($questionid, $preferredbehaviour = null, * @return array that can be passed as $params to the {@link popup_action} constructor. * @deprecated since Moodle 4.0 * @see qbank_previewquestion\previewquestion_helper::question_preview_popup_params() + * @todo Final deprecation on Moodle 4.4 MDL-72438 */ function question_preview_popup_params() { debugging('Function question_preview_popup_params() has been deprecated and moved to qbank_previewquestion plugin, Please use qbank_previewquestion\previewquestion_helper::question_preview_popup_params() instead.', DEBUG_DEVELOPER); - return \qbank_previewquestion\previewquestion_helper::question_preview_popup_params(); + return \qbank_previewquestion\helper::question_preview_popup_params(); } /** @@ -2373,6 +2122,7 @@ function question_preview_popup_params() { * @param object $question * @return string A unique version stamp * @deprecated since Moodle 4.0 + * @todo Final deprecation on Moodle 4.4 MDL-72438 */ function question_hash($question) { debugging('Function question_hash() has been deprecated without replacement.', DEBUG_DEVELOPER); @@ -2391,6 +2141,7 @@ function question_hash($question) { * @param moodle_url export file url * @deprecated since Moodle 4.0 MDL-71573 * @see qbank_exportquestions\exportquestions_helper + * @todo Final deprecation on Moodle 4.4 MDL-72438 */ function question_make_export_url($contextid, $categoryid, $format, $withcategories, $withcontexts, $filename) { diff --git a/lib/tests/questionlib_test.php b/lib/tests/questionlib_test.php index 268cb271ff9..d5c0fa88041 100644 --- a/lib/tests/questionlib_test.php +++ b/lib/tests/questionlib_test.php @@ -234,8 +234,7 @@ class core_questionlib_testcase extends advanced_testcase { // Create some question categories and questions in this course. $coursecontext = context_course::instance($course->id); - $questioncat = $questiongenerator->create_question_category(array('contextid' => - $coursecontext->id)); + $questioncat = $questiongenerator->create_question_category(array('contextid' => $coursecontext->id)); $question1 = $questiongenerator->create_question('shortanswer', null, array('category' => $questioncat->id)); $question2 = $questiongenerator->create_question('shortanswer', null, array('category' => $questioncat->id)); @@ -268,8 +267,11 @@ class core_questionlib_testcase extends advanced_testcase { array(context_course::instance($course2->id)->id), '*', MUST_EXIST); // Check that there are two questions in the restored to course's context. - $this->assertEquals(2, $DB->count_records('question', array('category' => $restoredcategory->id))); - + $this->assertEquals(2, $DB->get_record_sql('SELECT COUNT(q.id) as questioncount + FROM {question} q + JOIN {question_versions} qv ON qv.questionid = q.id + JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid + WHERE qbe.questioncategoryid = ?', [$restoredcategory->id])->questioncount); $rc->destroy(); } @@ -458,7 +460,7 @@ class core_questionlib_testcase extends advanced_testcase { $actualquestionscount = $DB->count_records_sql("SELECT COUNT(*) FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id - JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid + JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid WHERE qc.contextid = ?", $params); $this->assertEquals($questionsinqcat1 + $questionsinqcat2, $actualquestionscount); @@ -2085,15 +2087,15 @@ class core_questionlib_testcase extends advanced_testcase { $entry = new stdClass(); $entry->id = $questionbankentry1->id; $entry->idnumber = 1; - $DB->update_record('question_bank_entry', $entry); + $DB->update_record('question_bank_entries', $entry); $questionbankentry2 = get_question_bank_entry($questions2[0]->id); $entry2 = new stdClass(); $entry2->id = $questionbankentry2->id; $entry2->idnumber = 1; - $DB->update_record('question_bank_entry', $entry2); + $DB->update_record('question_bank_entries', $entry2); - $questionbe = $DB->get_record('question_bank_entry', ['id' => $questionbankentry1->id]); + $questionbe = $DB->get_record('question_bank_entries', ['id' => $questionbankentry1->id]); // Validate that a first stage of an idnumber exists (this format: xxxx_x). list($response, $record) = idnumber_exist_in_question_category($questionbe->idnumber, $questioncat1->id); diff --git a/mod/quiz/amd/build/question_slot.min.js b/mod/quiz/amd/build/question_slot.min.js new file mode 100644 index 00000000000..76de16722e6 --- /dev/null +++ b/mod/quiz/amd/build/question_slot.min.js @@ -0,0 +1,2 @@ +define ("mod_quiz/question_slot",["exports","core/ajax","core/notification"],function(a,b,c){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.init=void 0;c=function(a){return a&&a.__esModule?a:{default:a}}(c);var d=function(a,c){return(0,b.call)([{methodname:"mod_quiz_set_question_version",args:{slotid:a,newversion:c}}])[0]},e=function(){document.addEventListener("change",function(a){if(!a.target.matches("[data-action=\"mod_quiz-select_slot\"][data-slot-id]")){return}var b=a.target.dataset.slotId,e=parseInt(a.target.value);d(b,e).then(function(){location.reload()}).catch(c.default.exception)})};a.init=function init(){if(!1){return}e()}}); +//# sourceMappingURL=question_slot.min.js.map diff --git a/mod/quiz/amd/build/question_slot.min.js.map b/mod/quiz/amd/build/question_slot.min.js.map new file mode 100644 index 00000000000..76de16722e6 --- /dev/null +++ b/mod/quiz/amd/build/question_slot.min.js.map @@ -0,0 +1,2 @@ +define ("mod_quiz/question_slot",["exports","core/ajax","core/notification"],function(a,b,c){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.init=void 0;c=function(a){return a&&a.__esModule?a:{default:a}}(c);var d=function(a,c){return(0,b.call)([{methodname:"mod_quiz_set_question_version",args:{slotid:a,newversion:c}}])[0]},e=function(){document.addEventListener("change",function(a){if(!a.target.matches("[data-action=\"mod_quiz-select_slot\"][data-slot-id]")){return}var b=a.target.dataset.slotId,e=parseInt(a.target.value);d(b,e).then(function(){location.reload()}).catch(c.default.exception)})};a.init=function init(){if(!1){return}e()}}); +//# sourceMappingURL=question_slot.min.js.map diff --git a/mod/quiz/amd/src/question_slot.js b/mod/quiz/amd/src/question_slot.js new file mode 100644 index 00000000000..9c26f551d10 --- /dev/null +++ b/mod/quiz/amd/src/question_slot.js @@ -0,0 +1,76 @@ +// 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 . + +/** + * Render the question slot template for each question in the quiz edit view. + * + * @package mod_quiz + * @copyright 2021 Catalyst IT Australia Pty Ltd + * @author Guillermo Gomez Arias + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +import {call as fetchMany} from 'core/ajax'; +import Notification from 'core/notification'; + +/** + * Set the question version for the slot. + * + * @param {Number} slotId + * @param {Number} newVersion + * @return {Array} The modified question version + */ +const setQuestionVersion = (slotId, newVersion) => fetchMany([{ + methodname: 'mod_quiz_set_question_version', + args: { + slotid: slotId, + newversion: newVersion, + } +}])[0]; + +/** + * Replace the container with a new version. + */ +const registerEventListeners = () => { + document.addEventListener('change', e => { + if (!e.target.matches('[data-action="mod_quiz-select_slot"][data-slot-id]')) { + return; + } + + const slotId = e.target.dataset.slotId; + const newVersion = parseInt(e.target.value); + + setQuestionVersion(slotId, newVersion) + .then(() => { + location.reload(); + return; + }) + .catch(Notification.exception); + }); +}; + +/** @property {Boolean} eventsRegistered If the event has been registered or not */ +let eventsRegistered = false; + +/** + * Entrypoint of the js. + */ +export const init = () => { + if (eventsRegistered) { + return; + } + + registerEventListeners(); +}; diff --git a/mod/quiz/attemptlib.php b/mod/quiz/attemptlib.php index f32042dd1eb..d51c74a6329 100644 --- a/mod/quiz/attemptlib.php +++ b/mod/quiz/attemptlib.php @@ -145,12 +145,16 @@ class quiz { * Load just basic information about all the questions in this quiz. */ public function preload_questions() { - $this->questions = question_preload_questions(null, - 'slot.maxmark, slot.id AS slotid, slot.slot, slot.page, - slot.questioncategoryid AS randomfromcategory, - slot.includingsubcategories AS randomincludingsubcategories', - '{quiz_slots} slot ON slot.quizid = :quizid AND q.id = slot.questionid', - array('quizid' => $this->quiz->id), 'slot.slot'); + $specificquestionids = \mod_quiz\question\bank\qbank_helper::get_specific_version_question_ids($this->quiz->id); + $latestquestionids = \mod_quiz\question\bank\qbank_helper::get_always_latest_version_question_ids($this->quiz->id); + $questionids = array_merge($specificquestionids, $latestquestionids); + $questiondata = []; + if (!empty($questionids)) { + $questiondata = \mod_quiz\question\bank\qbank_helper::get_question_structure_data($this->quiz->id, $questionids, true); + } + $allquestiondata = \mod_quiz\question\bank\qbank_helper::question_array_sort( + \mod_quiz\question\bank\qbank_helper::question_load_random_questions($this->quiz->id, $questiondata), 'slot'); + $this->questions = $allquestiondata; } /** @@ -535,21 +539,9 @@ class quiz { // To control if we need to look in categories for questions. $qcategories = array(); - // We must be careful with random questions, if we find a random question we must assume that the quiz may content - // any of the questions in the referenced category (or subcategories). foreach ($this->get_questions() as $questiondata) { - if ($questiondata->qtype == 'random' and $includepotential) { - $includesubcategories = (bool) $questiondata->questiontext; - if (!isset($qcategories[$questiondata->category])) { - $qcategories[$questiondata->category] = false; - } - if ($includesubcategories) { - $qcategories[$questiondata->category] = true; - } - } else { - if (!in_array($questiondata->qtype, $questiontypes)) { - $questiontypes[] = $questiondata->qtype; - } + if (!in_array($questiondata->qtype, $questiontypes)) { + $questiontypes[] = $questiondata->qtype; } } @@ -709,8 +701,7 @@ class quiz_attempt { $this->quba = question_engine::load_questions_usage_by_activity($this->attempt->uniqueid); $this->slots = $DB->get_records('quiz_slots', - array('quizid' => $this->get_quizid()), 'slot', - 'slot, id, requireprevious, questionid, includingsubcategories'); + array('quizid' => $this->get_quizid()), 'slot', 'slot, id, requireprevious'); $this->sections = array_values($DB->get_records('quiz_sections', array('quizid' => $this->get_quizid()), 'firstslot')); @@ -1314,11 +1305,11 @@ class quiz_attempt { */ public function is_blocked_by_previous_question($slot) { return $slot > 1 && isset($this->slots[$slot]) && $this->slots[$slot]->requireprevious && - !$this->slots[$slot]->section->shufflequestions && - !$this->slots[$slot - 1]->section->shufflequestions && - $this->get_navigation_method() != QUIZ_NAVMETHOD_SEQ && - !$this->get_question_state($slot - 1)->is_finished() && - $this->quba->can_question_finish_during_attempt($slot - 1); + !$this->slots[$slot]->section->shufflequestions && + !$this->slots[$slot - 1]->section->shufflequestions && + $this->get_navigation_method() != QUIZ_NAVMETHOD_SEQ && + !$this->get_question_state($slot - 1)->is_finished() && + $this->quba->can_question_finish_during_attempt($slot - 1); } /** @@ -2107,25 +2098,9 @@ class quiz_attempt { $transaction = $DB->start_delegated_transaction(); - // Choose the replacement question. - $questiondata = $DB->get_record('question', - array('id' => $this->slots[$slot]->questionid)); - if ($questiondata->qtype != 'random') { - $newqusetionid = $questiondata->id; - } else { - $tagids = quiz_retrieve_slot_tag_ids($this->slots[$slot]->id); - - $randomloader = new \core_question\local\bank\random_question_loader($qubaids, array()); - $newqusetionid = $randomloader->get_next_question_id($questiondata->category, - (bool) $questiondata->questiontext, $tagids); - if ($newqusetionid === null) { - throw new moodle_exception('notenoughrandomquestions', 'quiz', - $this->quizobj->view_url(), $questiondata); - } - } - // Add the question to the usage. It is important we do this before we choose a variant. - $newquestion = question_bank::load_question($newqusetionid); + $newquestion = question_bank::load_question( + \mod_quiz\question\bank\qbank_helper::choose_question_for_redo($this->slots[$slot]->id, $qubaids)); $newslot = $this->quba->add_question_in_place_of_other($slot, $newquestion); // Choose the variant. diff --git a/mod/quiz/backup/moodle2/backup_quiz_stepslib.php b/mod/quiz/backup/moodle2/backup_quiz_stepslib.php index cb727907a06..fafa705eef7 100644 --- a/mod/quiz/backup/moodle2/backup_quiz_stepslib.php +++ b/mod/quiz/backup/moodle2/backup_quiz_stepslib.php @@ -15,22 +15,13 @@ // along with Moodle. If not, see . /** + * Define all the backup steps that will be used by the backup_quiz_activity_task. + * * @package mod_quiz * @subpackage backup-moodle2 * @copyright 2010 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ - - -defined('MOODLE_INTERNAL') || die(); - - -/** - * Define all the backup steps that will be used by the backup_quiz_activity_task - * - * @copyright 2010 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com} - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ class backup_quiz_activity_structure_step extends backup_questions_activity_structure_step { protected function define_structure() { @@ -39,7 +30,7 @@ class backup_quiz_activity_structure_step extends backup_questions_activity_stru $userinfo = $this->get_setting_value('userinfo'); // Define each element separated. - $quiz = new backup_nested_element('quiz', array('id'), array( + $quiz = new backup_nested_element('quiz', ['id'], [ 'name', 'intro', 'introformat', 'timeopen', 'timeclose', 'timelimit', 'overduehandling', 'graceperiod', 'preferredbehaviour', 'canredoquestions', 'attempts_number', 'attemptonlast', 'grademethod', 'decimalpoints', 'questiondecimalpoints', @@ -50,46 +41,44 @@ class backup_quiz_activity_structure_step extends backup_questions_activity_stru 'sumgrades', 'grade', 'timecreated', 'timemodified', 'password', 'subnet', 'browsersecurity', 'delay1', 'delay2', 'showuserpicture', 'showblocks', 'completionattemptsexhausted', - 'completionminattempts', 'allowofflineattempts')); + 'completionminattempts', 'allowofflineattempts']); // Define elements for access rule subplugin settings. $this->add_subplugin_structure('quizaccess', $quiz, true); $qinstances = new backup_nested_element('question_instances'); - $qinstance = new backup_nested_element('question_instance', array('id'), array( - 'slot', 'page', 'requireprevious', 'questionid', 'questioncategoryid', 'includingsubcategories', 'maxmark')); + $qinstance = new backup_nested_element('question_instance', ['id'], + ['slot', 'page', 'requireprevious', 'questionid', 'questioncategoryid', 'includingsubcategories', 'maxmark']); - $qinstancetags = new backup_nested_element('tags'); - $qinstancetag = new backup_nested_element('tag', array('id'), array('tagid', 'tagname')); + $this->add_question_references($qinstance, 'mod_quiz', 'slot'); + + $this->add_question_set_references($qinstance, 'mod_quiz', 'slot'); $sections = new backup_nested_element('sections'); - $section = new backup_nested_element('section', array('id'), array( - 'firstslot', 'heading', 'shufflequestions')); + $section = new backup_nested_element('section', ['id'], ['firstslot', 'heading', 'shufflequestions']); $feedbacks = new backup_nested_element('feedbacks'); - $feedback = new backup_nested_element('feedback', array('id'), array( - 'feedbacktext', 'feedbacktextformat', 'mingrade', 'maxgrade')); + $feedback = new backup_nested_element('feedback', ['id'], ['feedbacktext', 'feedbacktextformat', 'mingrade', 'maxgrade']); $overrides = new backup_nested_element('overrides'); - $override = new backup_nested_element('override', array('id'), array( + $override = new backup_nested_element('override', ['id'], [ 'userid', 'groupid', 'timeopen', 'timeclose', - 'timelimit', 'attempts', 'password')); + 'timelimit', 'attempts', 'password']); $grades = new backup_nested_element('grades'); - $grade = new backup_nested_element('grade', array('id'), array( - 'userid', 'gradeval', 'timemodified')); + $grade = new backup_nested_element('grade', ['id'], ['userid', 'gradeval', 'timemodified']); $attempts = new backup_nested_element('attempts'); - $attempt = new backup_nested_element('attempt', array('id'), array( + $attempt = new backup_nested_element('attempt', ['id'], [ 'userid', 'attemptnum', 'uniqueid', 'layout', 'currentpage', 'preview', 'state', 'timestart', 'timefinish', 'timemodified', 'timemodifiedoffline', - 'timecheckstate', 'sumgrades', 'gradednotificationsenttime')); + 'timecheckstate', 'sumgrades', 'gradednotificationsenttime']); // This module is using questions, so produce the related question states and sessions // attaching them to the $attempt element based in 'uniqueid' matching. @@ -102,9 +91,6 @@ class backup_quiz_activity_structure_step extends backup_questions_activity_stru $quiz->add_child($qinstances); $qinstances->add_child($qinstance); - $qinstance->add_child($qinstancetags); - $qinstancetags->add_child($qinstancetag); - $quiz->add_child($sections); $sections->add_child($section); @@ -121,22 +107,16 @@ class backup_quiz_activity_structure_step extends backup_questions_activity_stru $attempts->add_child($attempt); // Define sources. - $quiz->set_source_table('quiz', array('id' => backup::VAR_ACTIVITYID)); + $quiz->set_source_table('quiz', ['id' => backup::VAR_ACTIVITYID]); - $qinstance->set_source_table('quiz_slots', - array('quizid' => backup::VAR_PARENTID)); + $qinstance->set_source_table('quiz_slots', ['quizid' => backup::VAR_PARENTID]); - $qinstancetag->set_source_table('quiz_slot_tags', - array('slotid' => backup::VAR_PARENTID)); + $section->set_source_table('quiz_sections', ['quizid' => backup::VAR_PARENTID]); - $section->set_source_table('quiz_sections', - array('quizid' => backup::VAR_PARENTID)); - - $feedback->set_source_table('quiz_feedback', - array('quizid' => backup::VAR_PARENTID)); + $feedback->set_source_table('quiz_feedback', ['quizid' => backup::VAR_PARENTID]); // Quiz overrides to backup are different depending of user info. - $overrideparams = array('quiz' => backup::VAR_PARENTID); + $overrideparams = ['quiz' => backup::VAR_PARENTID]; if (!$userinfo) { // Without userinfo, skip user overrides. $overrideparams['userid'] = backup_helper::is_sqlparam(null); @@ -152,12 +132,11 @@ class backup_quiz_activity_structure_step extends backup_questions_activity_stru // All the rest of elements only happen if we are including user info. if ($userinfo) { - $grade->set_source_table('quiz_grades', array('quiz' => backup::VAR_PARENTID)); + $grade->set_source_table('quiz_grades', ['quiz' => backup::VAR_PARENTID]); $attempt->set_source_sql(' SELECT * FROM {quiz_attempts} - WHERE quiz = :quiz AND preview = 0', - array('quiz' => backup::VAR_PARENTID)); + WHERE quiz = :quiz AND preview = 0', ['quiz' => backup::VAR_PARENTID]); } // Define source alias. diff --git a/mod/quiz/backup/moodle2/restore_quiz_stepslib.php b/mod/quiz/backup/moodle2/restore_quiz_stepslib.php index 4465168518e..19fb8ab9141 100644 --- a/mod/quiz/backup/moodle2/restore_quiz_stepslib.php +++ b/mod/quiz/backup/moodle2/restore_quiz_stepslib.php @@ -14,20 +14,11 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -/** - * @package mod_quiz - * @subpackage backup-moodle2 - * @copyright 2010 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com} - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ - - -defined('MOODLE_INTERNAL') || die(); - - /** * Structure step to restore one quiz activity * + * @package mod_quiz + * @subpackage backup-moodle2 * @copyright 2010 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com} * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ @@ -60,10 +51,16 @@ class restore_quiz_activity_structure_step extends restore_questions_activity_st // A chance for access subplugings to set up their quiz data. $this->add_subplugin_structure('quizaccess', $quiz); - $paths[] = new restore_path_element('quiz_question_instance', - '/activity/quiz/question_instances/question_instance'); - $paths[] = new restore_path_element('quiz_slot_tags', + $quizquestioninstance = new restore_path_element('quiz_question_instance', + '/activity/quiz/question_instances/question_instance'); + $paths[] = $quizquestioninstance; + if ($this->task->get_old_moduleversion() < 2021091700) { + $paths[] = new restore_path_element('quiz_slot_tags', '/activity/quiz/question_instances/question_instance/tags/tag'); + } else { + $this->add_question_references($quizquestioninstance, $paths); + $this->add_question_set_references($quizquestioninstance, $paths); + } $paths[] = new restore_path_element('quiz_section', '/activity/quiz/sections/section'); $paths[] = new restore_path_element('quiz_feedback', '/activity/quiz/feedbacks/feedback'); $paths[] = new restore_path_element('quiz_override', '/activity/quiz/overrides/override'); @@ -99,6 +96,11 @@ class restore_quiz_activity_structure_step extends restore_questions_activity_st return $this->prepare_activity_structure($paths); } + /** + * Process the quiz data. + * + * @param stdClass|array $data + */ protected function process_quiz($data) { global $CFG, $DB, $USER; @@ -303,6 +305,64 @@ class restore_quiz_activity_structure_step extends restore_questions_activity_st } } + /** + * Process the data for pre 4.0 quiz data where the question_references and question_set_references table introduced. + * + * @param stdClass|array $data + */ + protected function process_quiz_question_legacy_instance($data) { + global $DB; + + $questionid = $this->get_mappingid('question', $data->questionid); + $sql = 'SELECT qbe.id as questionbankentryid, + qc.contextid as questioncontextid, + qc.id as category, + qv.version, + q.qtype, + q.id as questionid + 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.id = ?'; + $question = $DB->get_record_sql($sql, [$questionid]); + $module = $DB->get_record('quiz', ['id' => $data->quizid]); + + if ($question->qtype === 'random') { + // Set reference data. + $questionsetreference = new \stdClass(); + $questionsetreference->usingcontextid = context_module::instance(get_coursemodule_from_instance( + "quiz", $module->id, $module->course)->id)->id; + $questionsetreference->component = 'mod_quiz'; + $questionsetreference->questionarea = 'slot'; + $questionsetreference->itemid = $data->id; + $questionsetreference->questionscontextid = $question->questioncontextid; + $filtercondition = new stdClass(); + $filtercondition->questioncategoryid = $question->category; + $filtercondition->includingsubcategories = $data->includingsubcategories; + $questionsetreference->filtercondition = json_encode($filtercondition); + $DB->insert_record('question_set_references', $questionsetreference); + // Cleanup leftover random qtype data from question table. + question_delete_question($question->questionid); + } else { + // Reference data. + $questionreference = new \stdClass(); + $questionreference->usingcontextid = context_module::instance(get_coursemodule_from_instance( + "quiz", $module->id, $module->course)->id)->id; + $questionreference->component = 'mod_quiz'; + $questionreference->questionarea = 'slot'; + $questionreference->itemid = $data->id; + $questionreference->questionbankentryid = $question->questionbankentryid; + $questionreference->version = $question->version; + $DB->insert_record('question_references', $questionreference); + } + } + + /** + * Process quiz slots. + * + * @param stdClass|array $data + */ protected function process_quiz_question_instance($data) { global $CFG, $DB; @@ -325,7 +385,7 @@ class restore_quiz_activity_structure_step extends restore_questions_activity_st $page += 1; continue; } - if ($item == $data->questionid) { + if (isset($data->questionid) && $item == $data->questionid) { $data->slot = $slot; $data->page = $page; break; @@ -344,20 +404,15 @@ class restore_quiz_activity_structure_step extends restore_questions_activity_st } $data->quizid = $this->get_new_parentid('quiz'); - $questionmapping = $this->get_mapping('question', $data->questionid); - $data->questionid = $questionmapping ? $questionmapping->newitemid : false; - - if (isset($data->questioncategoryid)) { - $data->questioncategoryid = $this->get_mappingid('question_category', $data->questioncategoryid); - } else if ($questionmapping && $questionmapping->info->qtype == 'random') { - // Backward compatibility for backups created using Moodle 3.4 or earlier. - $data->questioncategoryid = $this->get_mappingid('question_category', $questionmapping->parentitemid); - $data->includingsubcategories = $questionmapping->info->questiontext ? 1 : 0; - } $newitemid = $DB->insert_record('quiz_slots', $data); // Add mapping, restore of slot tags (for random questions) need it. $this->set_mapping('quiz_question_instance', $oldid, $newitemid); + + if ($this->task->get_old_moduleversion() < 2022020300) { + $data->id = $newitemid; + $this->process_quiz_question_legacy_instance($data); + } } /** @@ -370,7 +425,7 @@ class restore_quiz_activity_structure_step extends restore_questions_activity_st $data = (object)$data; - $data->slotid = $this->get_new_parentid('quiz_question_instance'); + $slotid = $this->get_new_parentid('quiz_question_instance'); if ($this->task->is_samesite() && $tag = core_tag_tag::get($data->tagid, 'id, name')) { $data->tagname = $tag->name; } else if ($tag = core_tag_tag::get_by_name(0, $data->tagname, 'id, name')) { @@ -379,8 +434,22 @@ class restore_quiz_activity_structure_step extends restore_questions_activity_st $data->tagid = null; $data->tagname = $tag->name; } + $tagstring = "{$data->tagid},{$data->tagname}"; + $setreferencedata = $DB->get_record('question_set_references', ['itemid' => $slotid]); - $DB->insert_record('quiz_slot_tags', $data); + $filtercondition = json_decode($setreferencedata->filtercondition); + $tagstrings = []; + if (isset($filtercondition->tags)) { + $tags = explode(',', $filtercondition->tags); + foreach ($tags as $tag) { + $tagstrings [] = $tag; + } + } + $tagstrings [] = $tagstring; + $filtercondition->tags = $tagstrings; + $setreferencedata->filtercondition = json_encode($filtercondition); + + $DB->update_record('question_set_references', $setreferencedata); } protected function process_quiz_section($data) { diff --git a/mod/quiz/classes/external/submit_question_version.php b/mod/quiz/classes/external/submit_question_version.php new file mode 100644 index 00000000000..8f27ef2881b --- /dev/null +++ b/mod/quiz/classes/external/submit_question_version.php @@ -0,0 +1,105 @@ +. + +namespace mod_quiz\external; + +defined('MOODLE_INTERNAL') || die(); + +require_once($CFG->libdir . '/externallib.php'); +require_once($CFG->dirroot . '/question/engine/lib.php'); +require_once($CFG->dirroot . '/question/engine/datalib.php'); +require_once($CFG->libdir . '/questionlib.php'); + +use external_api; +use external_description; +use external_function_parameters; +use external_single_structure; +use external_value; +use stdClass; + +/** + * External api for changing the question version in the quiz. + * + * @package mod_quiz + * @copyright 2021 Catalyst IT Australia Pty Ltd + * @author Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class submit_question_version extends external_api { + + /** + * Parameters for the submit_question_version. + * + * @return \external_function_parameters + */ + public static function execute_parameters(): external_function_parameters { + return new external_function_parameters ( + [ + 'slotid' => new external_value(PARAM_INT, ''), + 'newversion' => new external_value(PARAM_INT, '') + ] + ); + } + + /** + * Set the questions slot parameters to display the question template. + * + * @param int $slotid Slot id to display. + * @param int $newversion + * @return array + */ + public static function execute(int $slotid, int $newversion): array { + global $DB; + $params = [ + 'slotid' => $slotid, + 'newversion' => $newversion + ]; + $params = self::validate_parameters(self::execute_parameters(), $params); + $response = ['result' => false]; + // Get the required data. + $referencedata = $DB->get_record('question_references', ['itemid' => $params['slotid']]); + $slotdata = $DB->get_record('quiz_slots', ['id' => $slotid]); + + // Capability check. + list($course, $cm) = get_course_and_cm_from_instance($slotdata->quizid, 'quiz'); + $context = \context_module::instance($cm->id); + self::validate_context($context); + require_capability('mod/quiz:manage', $context); + + $reference = new stdClass(); + $reference->id = $referencedata->id; + if ($params['newversion'] === 0) { + $reference->version = null; + } else { + $reference->version = $params['newversion']; + } + $response['result'] = $DB->update_record('question_references', $reference); + return $response; + } + + /** + * Define the webservice response. + * + * @return external_description + */ + public static function execute_returns() { + return new external_single_structure( + [ + 'result' => new external_value(PARAM_BOOL, '') + ] + ); + } +} diff --git a/mod/quiz/classes/local/structure/slot_random.php b/mod/quiz/classes/local/structure/slot_random.php index b4b4e54023a..504fda331ac 100644 --- a/mod/quiz/classes/local/structure/slot_random.php +++ b/mod/quiz/classes/local/structure/slot_random.php @@ -14,23 +14,14 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -/** - * Defines the \mod_quiz\local\structure\slot_random class. - * - * @package mod_quiz - * @copyright 2018 Shamim Rezaie - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ - namespace mod_quiz\local\structure; -defined('MOODLE_INTERNAL') || die(); - /** * Class slot_random, represents a random question slot type. * * @package mod_quiz * @copyright 2018 Shamim Rezaie + * @author 2021 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class slot_random { @@ -38,6 +29,11 @@ class slot_random { /** @var \stdClass Slot's properties. A record retrieved from the quiz_slots table. */ protected $record; + /** + * @var \stdClass set reference record + */ + protected $referencerecord; + /** * @var \stdClass The quiz this question slot belongs to. */ @@ -48,6 +44,11 @@ class slot_random { */ protected $tags = []; + /** + * @var string filter condition + */ + protected $filtercondition = null; + /** * slot_random constructor. * @@ -55,16 +56,22 @@ class slot_random { */ public function __construct($slotrecord = null) { $this->record = new \stdClass(); + $this->referencerecord = new \stdClass(); - $properties = array( - 'id', 'slot', 'quizid', 'page', 'requireprevious', 'questionid', - 'questioncategoryid', 'includingsubcategories', 'maxmark'); + $slotproperties = ['id', 'slot', 'quizid', 'page', 'requireprevious', 'maxmark']; + $setreferenceproperties = ['usingcontextid', 'questionscontextid']; - foreach ($properties as $property) { + foreach ($slotproperties as $property) { if (isset($slotrecord->$property)) { $this->record->$property = $slotrecord->$property; } } + + foreach ($setreferenceproperties as $referenceproperty) { + if (isset($slotrecord->$referenceproperty)) { + $this->referencerecord->$referenceproperty = $slotrecord->$referenceproperty; + } + } } /** @@ -122,6 +129,19 @@ class slot_random { $this->tags = \core_tag_tag::get_bulk($tagids, 'id, name'); } + /** + * Set filter condition. + * + * @param \stdClass $filters + */ + public function set_filter_condition($filters) { + if (!empty($this->tags)) { + $filters->tags = $this->tags; + } + + $this->filtercondition = json_encode($filters); + } + /** * Inserts the quiz slot at the $page page. * It is required to call this function if you are building a quiz slot object from scratch. @@ -179,17 +199,11 @@ class slot_random { $this->record->id = $DB->insert_record('quiz_slots', $this->record); - if (!empty($this->tags)) { - $recordstoinsert = []; - foreach ($this->tags as $tag) { - $recordstoinsert[] = (object)[ - 'slotid' => $this->record->id, - 'tagid' => $tag->id, - 'tagname' => $tag->name - ]; - } - $DB->insert_records('quiz_slot_tags', $recordstoinsert); - } + $this->referencerecord->component = 'mod_quiz'; + $this->referencerecord->questionarea = 'slot'; + $this->referencerecord->itemid = $this->record->id; + $this->referencerecord->filtercondition = $this->filtercondition; + $DB->insert_record('question_set_references', $this->referencerecord); $trans->allow_commit(); diff --git a/mod/quiz/classes/output/edit_renderer.php b/mod/quiz/classes/output/edit_renderer.php index 2674bbf8dfd..2ded59c3e06 100644 --- a/mod/quiz/classes/output/edit_renderer.php +++ b/mod/quiz/classes/output/edit_renderer.php @@ -25,6 +25,7 @@ namespace mod_quiz\output; defined('MOODLE_INTERNAL') || die(); +use mod_quiz\question\bank\qbank_helper; use \mod_quiz\structure; use \html_writer; use renderable; @@ -728,7 +729,11 @@ class edit_renderer extends \plugin_renderer_base { * @param \moodle_url $pageurl the canonical URL of this page. * @return string HTML to output. */ - public function question(structure $structure, $slot, \moodle_url $pageurl) { + public function question(structure $structure, int $slot, \moodle_url $pageurl) { + global $DB; + // Get the data required by the question_slot template. + $slotid = $structure->get_slot_id_for_slot($slot); + $output = ''; $output .= html_writer::start_tag('div'); @@ -736,50 +741,92 @@ class edit_renderer extends \plugin_renderer_base { $output .= $this->question_move_icon($structure, $slot); } - $output .= html_writer::start_div('mod-indent-outer'); - $checkbox = new \core\output\checkbox_toggleall($this->togglegroup, false, [ - 'id' => 'selectquestion-' . $structure->get_displayed_number_for_slot($slot), - 'name' => 'selectquestion[]', - 'value' => $structure->get_displayed_number_for_slot($slot), - 'classes' => 'select-multiple-checkbox', - ]); - $output .= $this->render($checkbox); - $output .= $this->question_number($structure->get_displayed_number_for_slot($slot)); + $data = [ + 'slotid' => $slotid, + 'canbeedited' => $structure->can_be_edited(), + 'checkbox' => $this->get_checkbox_render($structure, $slot), + 'questionnumber' => $this->question_number($structure->get_displayed_number_for_slot($slot)), + 'questionname' => $this->get_question_name_for_slot($structure, $slot, $pageurl), + 'questionicons' => $this->get_action_icon($structure, $slot, $pageurl), + 'questiondependencyicon' => ($structure->can_be_edited() ? $this->question_dependency_icon($structure, $slot) : ''), + 'versionselection' => false + ]; - // This div is used to indent the content. - $output .= html_writer::div('', 'mod-indent'); + $data['versionoptions'] = []; + if ($structure->get_slot_by_number($slot)->qtype !== 'random') { + $data['versionselection'] = true; + $data['versionoption'] = qbank_helper::get_question_version_info($structure->get_question_in_slot($slot)->id, $slotid); + $this->page->requires->js_call_amd('mod_quiz/question_slot', 'init', [$slotid]); + } + // Render the question slot template. + $output .= $this->render_from_template('mod_quiz/question_slot', $data); + + $output .= html_writer::end_tag('div'); + + return $output; + } + + /** + * Get the checkbox render. + * + * @param structure $structure object containing the structure of the quiz. + * @param int $slot the slot on the page we are outputting. + * @return string HTML to output. + */ + public function get_checkbox_render(structure $structure, int $slot) : string { + $checkbox = new \core\output\checkbox_toggleall($this->togglegroup, false, + [ + 'id' => 'selectquestion-' . $structure->get_displayed_number_for_slot($slot), + 'name' => 'selectquestion[]', + 'value' => $structure->get_displayed_number_for_slot($slot), + 'classes' => 'select-multiple-checkbox', + ]); + + return $this->render($checkbox); + } + + /** + * Get the question name for the slot. + * + * @param structure $structure object containing the structure of the quiz. + * @param int $slot the slot on the page we are outputting. + * @param \moodle_url $pageurl the canonical URL of this page. + * @return string HTML to output. + */ + public function get_question_name_for_slot(structure $structure, int $slot, \moodle_url $pageurl) : string { // Display the link to the question (or do nothing if question has no url). - if ($structure->get_question_type_for_slot($slot) == 'random') { + if ($structure->get_question_type_for_slot($slot) === 'random') { $questionname = $this->random_question($structure, $slot, $pageurl); } else { $questionname = $this->question_name($structure, $slot, $pageurl); } - // Start the div for the activity title, excluding the edit icons. - $output .= html_writer::start_div('activityinstance'); - $output .= $questionname; - - // Closing the tag which contains everything but edit icons. Content part of the module should not be part of this. - $output .= html_writer::end_tag('div'); // .activityinstance. + return $questionname; + } + /** + * Get the action icons render. + * + * @param structure $structure object containing the structure of the quiz. + * @param int $slot the slot on the page we are outputting. + * @param \moodle_url $pageurl the canonical URL of this page. + * @return string HTML to output. + */ + public function get_action_icon(structure $structure, int $slot, \moodle_url $pageurl) : string { // Action icons. + $qtype = $structure->get_question_type_for_slot($slot); $questionicons = ''; - $questionicons .= $this->question_preview_icon($structure->get_quiz(), $structure->get_question_in_slot($slot)); + if ($qtype !== 'random') { + $questionicons .= $this->question_preview_icon($structure->get_quiz(), $structure->get_question_in_slot($slot), + null, null, $qtype); + } if ($structure->can_be_edited()) { $questionicons .= $this->question_remove_icon($structure, $slot, $pageurl); } $questionicons .= $this->marked_out_of_field($structure, $slot); - $output .= html_writer::span($questionicons, 'actions'); // Required to add js spinner icon. - if ($structure->can_be_edited()) { - $output .= $this->question_dependency_icon($structure, $slot); - } - // End of indentation div. - $output .= html_writer::end_tag('div'); - $output .= html_writer::end_tag('div'); - - return $output; + return $questionicons; } /** @@ -815,9 +862,10 @@ class edit_renderer extends \plugin_renderer_base { * @param \stdClass $question data from the question and quiz_slots tables. * @param bool $label if true, show the preview question label after the icon * @param int $variant which question variant to preview (optional). + * @param string $qtype the type of question * @return string HTML to output. */ - public function question_preview_icon($quiz, $question, $label = null, $variant = null) { + public function question_preview_icon($quiz, $question, $label = null, $variant = null, $qtype = null) { $url = quiz_question_preview_url($quiz, $question, $variant); // Do we want a label? @@ -976,10 +1024,8 @@ class edit_renderer extends \plugin_renderer_base { * @return string HTML to output. */ public function random_question(structure $structure, $slotnumber, $pageurl) { - $question = $structure->get_question_in_slot($slotnumber); $slot = $structure->get_slot_by_number($slotnumber); - $slottags = $structure->get_slot_tags_for_slot_id($slot->id); $editurl = new \moodle_url('/mod/quiz/editrandom.php', array('returnurl' => $pageurl->out_as_local_url(), 'slotid' => $slot->id)); @@ -987,6 +1033,9 @@ class edit_renderer extends \plugin_renderer_base { $temp->questiontext = ''; $instancename = quiz_question_tostring($temp); + $setreference = qbank_helper::get_random_question_data_from_slot($slot->id); + $filtercondition = json_decode($setreference->filtercondition); + $configuretitle = get_string('configurerandomquestion', 'quiz'); $qtype = \question_bank::get_qtype($question->qtype, false); $namestr = $qtype->local_name(); @@ -995,20 +1044,25 @@ class edit_renderer extends \plugin_renderer_base { $editicon = $this->pix_icon('t/edit', $configuretitle, 'moodle', array('title' => '')); $qbankurlparams = array( - 'cmid' => $structure->get_cmid(), - 'cat' => $question->category . ',' . $question->contextid, - 'recurse' => !empty($question->questiontext) + 'cmid' => $structure->get_cmid(), + 'cat' => $filtercondition->questioncategoryid . ',' . $setreference->questionscontextid, + 'recurse' => !empty($setreference->questionscontextid) ); + $slottags = []; + if (isset($filtercondition->tags)) { + $slottags = $filtercondition->tags; + } foreach ($slottags as $index => $slottag) { - $qbankurlparams["qtagids[{$index}]"] = $slottag->tagid; + $slottag = explode(',', $slottag); + $qbankurlparams["qtagids[{$index}]"] = $slottag[0]; } // If this is a random question, display a link to show the questions // selected from in the question bank. $qbankurl = new \moodle_url('/question/edit.php', $qbankurlparams); $qbanklink = ' ' . \html_writer::link($qbankurl, - get_string('seequestions', 'quiz'), array('class' => 'mod_quiz_random_qbank_link')); + get_string('seequestions', 'quiz'), array('class' => 'mod_quiz_random_qbank_link')); return html_writer::link($editurl, $icon . $editicon, array('title' => $configuretitle)) . ' ' . $instancename . ' ' . $qbanklink; diff --git a/mod/quiz/classes/question/bank/custom_view.php b/mod/quiz/classes/question/bank/custom_view.php index 177883782f9..2b723b81aa7 100644 --- a/mod/quiz/classes/question/bank/custom_view.php +++ b/mod/quiz/classes/question/bank/custom_view.php @@ -25,6 +25,9 @@ namespace mod_quiz\question\bank; +use core_question\local\bank\question_version_status; +use mod_quiz\question\bank\filter\custom_category_condition; + /** * Subclass to customise the view of the question bank for the quiz editing screen. * @@ -251,4 +254,81 @@ class custom_view extends \core_question\local\bank\view { $this->sort[$sort] = $order; } } + + protected function build_query(): void { + // Get the required tables and fields. + $joins = []; + $fields = ['qv.status', 'qc.id', 'qv.version', 'qv.id as versionid', 'qbe.id as questionbankentryid']; + if (!empty($this->requiredcolumns)) { + foreach ($this->requiredcolumns as $column) { + $extrajoins = $column->get_extra_joins(); + foreach ($extrajoins as $prefix => $join) { + if (isset($joins[$prefix]) && $joins[$prefix] != $join) { + throw new \coding_exception('Join ' . $join . ' conflicts with previous join ' . $joins[$prefix]); + } + $joins[$prefix] = $join; + } + $fields = array_merge($fields, $column->get_required_fields()); + } + } + $fields = array_unique($fields); + + // Build the order by clause. + $sorts = []; + foreach ($this->sort as $sort => $order) { + list($colname, $subsort) = $this->parse_subsort($sort); + $sorts[] = $this->requiredcolumns[$colname]->sort_expression($order < 0, $subsort); + } + + // Build the where clause. + $latestversion = 'qv.version = (SELECT MAX(v.version) + FROM {question_versions} v + JOIN {question_bank_entries} be + ON be.id = v.questionbankentryid + WHERE be.id = qbe.id)'; + $readyonly = "qv.status = '" . question_version_status::QUESTION_STATUS_READY . "' "; + $tests = ['q.parent = 0', $latestversion, $readyonly]; + $this->sqlparams = []; + foreach ($this->searchconditions as $searchcondition) { + if ($searchcondition->where()) { + $tests[] = '((' . $searchcondition->where() .'))'; + } + if ($searchcondition->params()) { + $this->sqlparams = array_merge($this->sqlparams, $searchcondition->params()); + } + } + // Build the SQL. + $sql = ' FROM {question} q ' . implode(' ', $joins); + $sql .= ' WHERE ' . implode(' AND ', $tests); + $this->countsql = 'SELECT count(1)' . $sql; + $this->loadsql = 'SELECT ' . implode(', ', $fields) . $sql . ' ORDER BY ' . implode(', ', $sorts); + } + + public function wanted_filters($cat, $tagids, $showhidden, $recurse, $editcontexts, $showquestiontext): void { + global $CFG; + list(, $contextid) = explode(',', $cat); + $catcontext = \context::instance_by_id($contextid); + $thiscontext = $this->get_most_specific_context(); + // Category selection form. + $this->display_question_bank_header(); + + // Display tag filter if usetags setting is enabled/enablefilters is true. + if ($this->enablefilters) { + if (is_array($this->customfilterobjects)) { + foreach ($this->customfilterobjects as $filterobjects) { + $this->searchconditions[] = $filterobjects; + } + } else { + if ($CFG->usetags) { + array_unshift($this->searchconditions, + new \core_question\bank\search\tag_condition([$catcontext, $thiscontext], $tagids)); + } + + array_unshift($this->searchconditions, new \core_question\bank\search\hidden_condition(!$showhidden)); + array_unshift($this->searchconditions, new custom_category_condition( + $cat, $recurse, $editcontexts, $this->baseurl, $this->course)); + } + } + $this->display_options_form($showquestiontext); + } } diff --git a/mod/quiz/classes/question/bank/filter/custom_category_condition.php b/mod/quiz/classes/question/bank/filter/custom_category_condition.php new file mode 100644 index 00000000000..b3544bbe52d --- /dev/null +++ b/mod/quiz/classes/question/bank/filter/custom_category_condition.php @@ -0,0 +1,45 @@ +. + +namespace mod_quiz\question\bank\filter; + +/** + * A custom filter condition for quiz to select question categories. + * + * This is required as quiz will only use ready questions and the count should show according to that. + * + * @package mod_quiz + * @category question + * @copyright 2021 Catalyst IT Australia Pty Ltd + * @author Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class custom_category_condition extends \core_question\bank\search\category_condition { + + public function display_options() { + global $PAGE; + $displaydata = []; + $catmenu = custom_category_condition_helper::question_category_options($this->contexts, true, 0, + true, -1, false); + $displaydata['categoryselect'] = \html_writer::select($catmenu, 'category', $this->cat, [], + ['class' => 'searchoptions custom-select', 'id' => 'id_selectacategory']); + $displaydata['categorydesc'] = ''; + if ($this->category) { + $displaydata['categorydesc'] = $this->print_category_info($this->category); + } + return $PAGE->get_renderer('core_question', 'bank')->render_category_condition($displaydata); + } +} diff --git a/mod/quiz/classes/question/bank/filter/custom_category_condition_helper.php b/mod/quiz/classes/question/bank/filter/custom_category_condition_helper.php new file mode 100644 index 00000000000..5164f0f52bb --- /dev/null +++ b/mod/quiz/classes/question/bank/filter/custom_category_condition_helper.php @@ -0,0 +1,129 @@ +. + +namespace mod_quiz\question\bank\filter; + +use core_question\local\bank\question_version_status; + +/** + * A custom filter condition helper for quiz to select question categories. + * + * This is required as quiz will only use ready questions and the count should show according to that. + * + * @package mod_quiz + * @category question + * @copyright 2021 Catalyst IT Australia Pty Ltd + * @author Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class custom_category_condition_helper extends \qbank_managecategories\helper { + + public static function question_category_options(array $contexts, bool $top = false, int $currentcat = 0, + bool $popupform = false, int $nochildrenof = -1, + bool $escapecontextnames = true): array { + global $CFG; + $pcontexts = []; + foreach ($contexts as $context) { + $pcontexts[] = $context->id; + } + $contextslist = join(', ', $pcontexts); + + $categories = self::get_categories_for_contexts($contextslist, 'parent, sortorder, name ASC', $top); + + if ($top) { + $categories = self::question_fix_top_names($categories); + } + + $categories = self::question_add_context_in_key($categories); + $categories = self::add_indented_names($categories, $nochildrenof); + + // Sort cats out into different contexts. + $categoriesarray = []; + foreach ($pcontexts as $contextid) { + $context = \context::instance_by_id($contextid); + $contextstring = $context->get_context_name(true, true, $escapecontextnames); + foreach ($categories as $category) { + if ($category->contextid == $contextid) { + $cid = $category->id; + if ($currentcat != $cid || $currentcat == 0) { + $a = new \stdClass; + $a->name = format_string($category->indentedname, true, + ['context' => $context]); + if ($category->idnumber !== null && $category->idnumber !== '') { + $a->idnumber = s($category->idnumber); + } + if (!empty($category->questioncount)) { + $a->questioncount = $category->questioncount; + } + if (isset($a->idnumber) && isset($a->questioncount)) { + $formattedname = get_string('categorynamewithidnumberandcount', 'question', $a); + } else if (isset($a->idnumber)) { + $formattedname = get_string('categorynamewithidnumber', 'question', $a); + } else if (isset($a->questioncount)) { + $formattedname = get_string('categorynamewithcount', 'question', $a); + } else { + $formattedname = $a->name; + } + $categoriesarray[$contextstring][$cid] = $formattedname; + } + } + } + } + if ($popupform) { + $popupcats = []; + foreach ($categoriesarray as $contextstring => $optgroup) { + $group = []; + foreach ($optgroup as $key => $value) { + $key = str_replace($CFG->wwwroot, '', $key); + $group[$key] = $value; + } + $popupcats[] = [$contextstring => $group]; + } + return $popupcats; + } else { + return $categoriesarray; + } + } + + public static function get_categories_for_contexts($contexts, string $sortorder = 'parent, sortorder, name ASC', + bool $top = false, int $showallversions = 0): array { + global $DB; + $topwhere = $top ? '' : 'AND c.parent <> 0'; + $statuscondition = "AND qv.status = '". question_version_status::QUESTION_STATUS_READY . "' "; + + $sql = "SELECT c.*, + (SELECT COUNT(1) + 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 = '0' + $statuscondition + AND c.id = qbe.questioncategoryid + AND ($showallversions = 1 + OR (qv.version = (SELECT MAX(v.version) + FROM {question_versions} v + JOIN {question_bank_entries} be ON be.id = v.questionbankentryid + WHERE be.id = qbe.id) + ) + ) + ) AS questioncount + FROM {question_categories} c + WHERE c.contextid IN ($contexts) $topwhere + ORDER BY $sortorder"; + + return $DB->get_records_sql($sql); + } +} diff --git a/mod/quiz/classes/question/bank/qbank_helper.php b/mod/quiz/classes/question/bank/qbank_helper.php new file mode 100644 index 00000000000..8cdad809ddf --- /dev/null +++ b/mod/quiz/classes/question/bank/qbank_helper.php @@ -0,0 +1,427 @@ +. + +namespace mod_quiz\question\bank; + +defined('MOODLE_INTERNAL') || die(); + +require_once($CFG->dirroot . '/mod/quiz/accessmanager.php'); +require_once($CFG->dirroot . '/mod/quiz/attemptlib.php'); + +/** + * Helper class for question bank and its associated data. + * + * @package mod_quiz + * @category question + * @copyright 2021 Catalyst IT Australia Pty Ltd + * @author Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class qbank_helper { + + /** + * Check if the slot is a random question or not. + * + * @param int $slotid + * @return bool + */ + public static function is_random($slotid): bool { + global $DB; + $params = [ + 'itemid' => $slotid, + 'component' => 'mod_quiz', + 'questionarea' => 'slot' + ]; + return $DB->record_exists('question_set_references', $params); + } + + /** + * Get the version options for the question. + * + * @param int $questionid + * @return array + */ + public static function get_version_options($questionid): array { + global $DB; + $sql = "SELECT qv.id AS versionid, qv.version + FROM {question_versions} qv + WHERE qv.questionbankentryid = (SELECT DISTINCT qbe.id + FROM {question_bank_entries} qbe + JOIN {question_versions} qv ON qbe.id = qv.questionbankentryid + JOIN {question} q ON qv.questionid = q.id + WHERE q.id = ?) + ORDER BY qv.version DESC"; + + return $DB->get_records_sql($sql, [$questionid]); + } + + /** + * Sort the elements of an array according to a key. + * + * @param array $arrays + * @param string $on + * @param int $order + * @return array + */ + public static function question_array_sort($arrays, $on, $order = SORT_ASC): array { + $element = []; + foreach ($arrays as $array) { + $element[$array->$on] = $array; + } + ksort($element, $order); + return $element; + } + + /** + * Get the question id from slot id. + * + * @param int $slotid + * @return mixed + */ + public static function get_question_for_redo($slotid) { + global $DB; + $params = [ + 'itemid' => $slotid, + 'component' => 'mod_quiz', + 'questionarea' => 'slot' + ]; + $referencerecord = $DB->get_record('question_references', $params); + if ($referencerecord->version === null) { + $questionsql = 'SELECT q.id + FROM {question} q + JOIN {question_versions} qv ON qv.questionid = q.id + WHERE qv.version = (SELECT MAX(v.version) + FROM {question_versions} v + JOIN {question_bank_entries} be + ON be.id = v.questionbankentryid + WHERE be.id = qv.questionbankentryid) + AND qv.questionbankentryid = ?'; + $questionid = $DB->get_record_sql($questionsql, [$referencerecord->questionbankentryid])->id; + } else { + $questionid = $DB->get_field('question_versions', 'questionid', + ['questionbankentryid' => $referencerecord->questionbankentryid, + 'version' => $referencerecord->version]); + } + return $questionid; + } + + /** + * Get random question object from the slot id. + * + * @param int $slotid + * @return false|mixed|\stdClass + */ + public static function get_random_question_data_from_slot($slotid) { + global $DB; + $params = [ + 'itemid' => $slotid, + 'component' => 'mod_quiz', + 'questionarea' => 'slot' + ]; + return $DB->get_record('question_set_references', $params); + } + + /** + * Get the question ids for specific question version. + * + * @param int $quizid + * @return array + */ + public static function get_specific_version_question_ids($quizid) { + global $DB; + $questionids = []; + $sql = 'SELECT qv.questionid + FROM {quiz_slots} qs + JOIN {question_references} qr ON qr.itemid = qs.id + JOIN {question_versions} qv ON qv.questionbankentryid = qr.questionbankentryid + AND qv.version = qr.version + WHERE qr.version IS NOT NULL + AND qs.quizid = ? + AND qr.component = ? + AND qr.questionarea = ?'; + $questions = $DB->get_records_sql($sql, [$quizid, 'mod_quiz', 'slot']); + foreach ($questions as $question) { + $questionids [] = $question->questionid; + } + return $questionids; + } + + /** + * Get the question ids for always latest options. + * + * @param int $quizid + * @return array + */ + public static function get_always_latest_version_question_ids($quizid) { + global $DB; + $questionids = []; + $sql = 'SELECT qr.questionbankentryid as entry + FROM {quiz_slots} qs + JOIN {question_references} qr ON qr.itemid = qs.id + WHERE qr.version IS NULL + AND qs.quizid = ? + AND qr.component = ? + AND qr.questionarea = ?'; + $entryids = $DB->get_records_sql($sql, [$quizid, 'mod_quiz', 'slot']); + $questionentries = []; + foreach ($entryids as $entryid) { + $questionentries [] = $entryid->entry; + } + if (empty($questionentries)) { + return $questionids; + } + list($questionidcondition, $params) = $DB->get_in_or_equal($questionentries); + $extracondition = 'AND qv.questionbankentryid ' . $questionidcondition; + $questionsql = "SELECT q.id + FROM {question} q + JOIN {question_versions} qv ON qv.questionid = q.id + WHERE qv.version = (SELECT MAX(v.version) + FROM {question_versions} v + JOIN {question_bank_entries} be + ON be.id = v.questionbankentryid + WHERE be.id = qv.questionbankentryid) + $extracondition"; + $questions = $DB->get_records_sql($questionsql, $params); + foreach ($questions as $question) { + $questionids [] = $question->id; + } + return $questionids; + } + + /** + * Get the question structure data for the given quiz or question ids. + * + * @param null $quizid + * @param array $questionids + * @param bool $attempt + * @return array + */ + public static function get_question_structure_data($quizid, $questionids = [], $attempt = false) { + global $DB; + $params = ['quizid' => $quizid]; + $condition = ''; + $joinon = 'AND qr.version = qv.version'; + if (!empty($questionids)) { + list($condition, $param) = $DB->get_in_or_equal($questionids, SQL_PARAMS_NAMED, 'questionid'); + $condition = 'AND q.id ' . $condition; + $joinon = ''; + $params = array_merge($params, $param); + } + if ($attempt) { + $selectstart = 'q.*, slot.id AS slotid, slot.slot,'; + } else { + $selectstart = 'slot.slot, slot.id AS slotid, q.*,'; + } + $sql = "SELECT $selectstart + q.id AS questionid, + q.name, + q.qtype, + q.length, + slot.page, + slot.maxmark, + slot.requireprevious, + qc.id as category, + qc.contextid,qv.status, + qv.id as versionid, + qv.version, + qv.questionbankentryid + FROM {quiz_slots} slot + LEFT JOIN {question_references} qr ON qr.itemid = slot.id AND qr.component = 'mod_quiz' AND qr.questionarea = 'slot' + LEFT JOIN {question_bank_entries} qbe ON qbe.id = qr.questionbankentryid + LEFT JOIN {question_versions} qv ON qv.questionbankentryid = qbe.id $joinon + LEFT JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid + LEFT JOIN {question} q ON q.id = qv.questionid + WHERE slot.quizid = :quizid + $condition"; + $questiondatas = $DB->get_records_sql($sql, $params); + foreach ($questiondatas as $questiondata) { + $questiondata->_partiallyloaded = true; + } + if (!empty($questiondatas)) { + return $questiondatas; + } + return []; + } + + /** + * Get question structure. + * + * @param int $quizid + * @return array + */ + public static function get_question_structure($quizid) { + $firstslotsets = self::get_question_structure_data($quizid); + $latestquestionids = self::get_always_latest_version_question_ids($quizid); + $secondslotsets = self::get_question_structure_data($quizid, $latestquestionids); + foreach ($firstslotsets as $key => $firstslotset) { + foreach ($secondslotsets as $secondslotset) { + if ($firstslotset->slotid === $secondslotset->slotid) { + unset($firstslotsets[$key]); + } + } + } + + return self::question_array_sort(array_merge($firstslotsets, $secondslotsets), 'slot'); + } + + /** + * Load random questions. + * + * @param int $quizid + * @param array $questiondata + * @return array + */ + public static function question_load_random_questions($quizid, $questiondata) { + global $DB, $USER; + $sql = 'SELECT slot.id AS slotid, + slot.maxmark, + slot.slot, + slot.page, + qsr.filtercondition + FROM {question_set_references} qsr + JOIN {quiz_slots} slot ON slot.id = qsr.itemid + WHERE slot.quizid = ? + AND qsr.component = ? + AND qsr.questionarea = ?'; + $randomquestiondatas = $DB->get_records_sql($sql, [$quizid, 'mod_quiz', 'slot']); + + $randomquestions = []; + // Questions already added. + $usedquestionids = []; + foreach ($questiondata as $question) { + if (isset($usedquestions[$question->id])) { + $usedquestionids[$question->id] += 1; + } else { + $usedquestionids[$question->id] = 1; + } + } + // Usages for this user's previous quiz attempts. + $qubaids = new \mod_quiz\question\qubaids_for_users_attempts($quizid, $USER->id); + $randomloader = new \core_question\local\bank\random_question_loader($qubaids, $usedquestionids); + + foreach ($randomquestiondatas as $randomquestiondata) { + $filtercondition = json_decode($randomquestiondata->filtercondition); + $tagids = []; + if (isset($filtercondition->tags)) { + foreach ($filtercondition->tags as $tag) { + $tagstring = explode(',', $tag); + $tagids [] = $tagstring[0]; + } + } + $randomquestiondata->randomfromcategory = $filtercondition->questioncategoryid; + $randomquestiondata->randomincludingsubcategories = $filtercondition->includingsubcategories; + $randomquestiondata->questionid = $randomloader->get_next_question_id($randomquestiondata->randomfromcategory, + $randomquestiondata->randomincludingsubcategories, $tagids); + $randomquestions [] = $randomquestiondata; + } + + foreach ($randomquestions as $randomquestion) { + // Should not add if there is no question found from the ramdom question loader, maybe empty category. + if ($randomquestion->questionid === null) { + continue; + } + $question = new \stdClass(); + $question->slotid = $randomquestion->slotid; + $question->maxmark = $randomquestion->maxmark; + $question->slot = $randomquestion->slot; + $question->page = $randomquestion->page; + $qdatas = question_preload_questions($randomquestion->questionid); + $qdatas = reset($qdatas); + foreach ($qdatas as $key => $qdata) { + $question->$key = $qdata; + } + $questiondata[$question->id] = $question; + } + + return $questiondata; + } + + /** + * Choose question for redo. + * + * @param int $slotid + * @param \qubaid_condition $qubaids + * @return int + */ + public static function choose_question_for_redo($slotid, $qubaids): int { + // Choose the replacement question. + if (!self::is_random($slotid)) { + $newqusetionid = self::get_question_for_redo($slotid); + } else { + $tagids = []; + $randomquestiondata = self::get_random_question_data_from_slot($slotid); + $filtercondition = json_decode($randomquestiondata->filtercondition); + if (isset($filtercondition->tags)) { + foreach ($filtercondition->tags as $tag) { + $tagstring = explode(',', $tag); + $tagids [] = $tagstring[0]; + } + } + + $randomloader = new \core_question\local\bank\random_question_loader($qubaids, []); + $newqusetionid = $randomloader->get_next_question_id($filtercondition->questioncategoryid, + (bool) $filtercondition->includingsubcategories, $tagids); + if ($newqusetionid === null) { + throw new \moodle_exception('notenoughrandomquestions', 'quiz'); + } + + } + return $newqusetionid; + } + + /** + * Get the version information for a question to show in the version selection dropdown. + * + * @param int $questionid + * @param int $slotid + * @return array + */ + public static function get_question_version_info($questionid, $slotid): array { + global $DB; + $versiondata = []; + $versionsoptions = self::get_version_options($questionid); + $latestversion = reset($versionsoptions); + // Object for using the latest version. + $alwaysuselatest = new \stdClass(); + $alwaysuselatest->versionid = 0; + $alwaysuselatest->version = 0; + $alwaysuselatest->versionvalue = get_string('alwayslatest', 'quiz'); + array_unshift($versionsoptions, $alwaysuselatest); + $referencedata = $DB->get_record('question_references', ['itemid' => $slotid]); + if (!isset($referencedata->version) || ($referencedata->version === null)) { + $currentversion = 0; + } else { + $currentversion = $referencedata->version; + } + + foreach ($versionsoptions as $versionsoption) { + $versionsoption->selected = false; + if ($versionsoption->version === $currentversion) { + $versionsoption->selected = true; + } + if (!isset($versionsoption->versionvalue)) { + if ($versionsoption->version === $latestversion->version) { + $versionsoption->versionvalue = get_string('questionversionlatest', 'quiz', $versionsoption->version); + } else { + $versionsoption->versionvalue = get_string('questionversion', 'quiz', $versionsoption->version); + } + } + + $versiondata[] = $versionsoption; + } + return $versiondata; + } +} diff --git a/mod/quiz/classes/question/qubaids_for_users_attempts.php b/mod/quiz/classes/question/qubaids_for_users_attempts.php index 88decd598f3..55e22fa54f1 100644 --- a/mod/quiz/classes/question/qubaids_for_users_attempts.php +++ b/mod/quiz/classes/question/qubaids_for_users_attempts.php @@ -24,8 +24,10 @@ */ namespace mod_quiz\question; + defined('MOODLE_INTERNAL') || die(); +require_once($CFG->dirroot.'/mod/quiz/attemptlib.php'); /** * A {@link qubaid_condition} representing all the attempts by one user at a given quiz. diff --git a/mod/quiz/classes/structure.php b/mod/quiz/classes/structure.php index 94b568a6b2c..598fcf3aa04 100644 --- a/mod/quiz/classes/structure.php +++ b/mod/quiz/classes/structure.php @@ -23,6 +23,8 @@ */ namespace mod_quiz; +use mod_quiz\question\bank\qbank_helper; + defined('MOODLE_INTERNAL') || die(); /** @@ -126,6 +128,15 @@ class structure { return $this->questions[$this->slotsinorder[$slotnumber]->questionid]; } + /** + * Get the information about the question name in a given slot. + * @param int $slotnumber the index of the slot in question. + * @return \stdClass the data from the questions table, augmented with + */ + public function get_question_name_in_slot($slotnumber) { + return $this->questions[$this->slotsinorder[$slotnumber]->name]; + } + /** * Get the displayed question number (or 'i') for a given slot. * @param int $slotnumber the index of the slot in question. @@ -606,32 +617,29 @@ class structure { public function populate_structure($quiz) { global $DB; - $slots = $DB->get_records_sql(" - SELECT slot.id AS slotid, slot.slot, slot.questionid, slot.page, slot.maxmark, - slot.requireprevious, q.*, qc.id as category, qc.contextid - FROM {quiz_slots} slot - LEFT JOIN {question} q ON q.id = slot.questionid - LEFT JOIN {question_versions} qv ON qv.questionid = q.id - LEFT JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid - LEFT JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid - WHERE slot.quizid = ? - ORDER BY slot.slot", array($quiz->id)); + $slots = qbank_helper::get_question_structure($quiz->id); $slots = $this->populate_missing_questions($slots); - $this->questions = array(); - $this->slotsinorder = array(); + $this->questions = []; + $this->slotsinorder = []; foreach ($slots as $slotdata) { $this->questions[$slotdata->questionid] = $slotdata; $slot = new \stdClass(); $slot->id = $slotdata->slotid; + $slot->name = $slotdata->name; $slot->slot = $slotdata->slot; $slot->quizid = $quiz->id; $slot->page = $slotdata->page; $slot->questionid = $slotdata->questionid; $slot->maxmark = $slotdata->maxmark; $slot->requireprevious = $slotdata->requireprevious; + $slot->qtype = $slotdata->qtype; + $slot->length = $slotdata->length; + $slot->category = $slotdata->category; + $slot->questionbankentryid = $slotdata->questionbankentryid ?? null; + $slot->version = $slotdata->version ?? null; $this->slotsinorder[$slot->slot] = $slot; } @@ -648,21 +656,30 @@ class structure { * @return \stdClass[] updated $slots array. */ protected function populate_missing_questions($slots) { - // Address missing question types. + global $DB; + // Address missing/random question types. foreach ($slots as $slot) { if ($slot->qtype === null) { - // If the questiontype is missing change the question type. - $slot->id = $slot->questionid; - $slot->category = 0; - $slot->qtype = 'missingtype'; - $slot->name = get_string('missingquestion', 'quiz'); - $slot->slot = $slot->slot; - $slot->maxmark = 0; - $slot->requireprevious = 0; - $slot->questiontext = ' '; - $slot->questiontextformat = FORMAT_HTML; - $slot->length = 1; - + // Check if the question is random. + if ($setreference = $DB->get_record('question_set_references', ['itemid' => $slot->slotid])) { + $filtercondition = json_decode($setreference->filtercondition); + $slot->id = $slot->slotid; + $slot->category = $filtercondition->questioncategoryid; + $slot->qtype = 'random'; + $slot->name = get_string('random', 'quiz'); + $slot->length = 1; + } else { + // If the questiontype is missing change the question type. + $slot->id = $slot->questionid; + $slot->category = 0; + $slot->qtype = 'missingtype'; + $slot->name = get_string('missingquestion', 'quiz'); + $slot->maxmark = 0; + $slot->requireprevious = 0; + $slot->questiontext = ' '; + $slot->questiontextformat = FORMAT_HTML; + $slot->length = 1; + } } else if (!\question_bank::qtype_exists($slot->qtype)) { $slot->qtype = 'missingtype'; } @@ -938,7 +955,18 @@ class structure { $maxslot = $DB->get_field_sql('SELECT MAX(slot) FROM {quiz_slots} WHERE quizid = ?', array($this->get_quizid())); $trans = $DB->start_delegated_transaction(); - $DB->delete_records('quiz_slot_tags', array('slotid' => $slot->id)); + // Delete the reference if its a question. + $questionreference = $DB->get_record('question_references', + ['component' => 'mod_quiz', 'questionarea' => 'slot', 'itemid' => $slot->id]); + if ($questionreference) { + $DB->delete_records('question_references', ['id' => $questionreference->id]); + } + // Delete the set reference if its a random question. + $questionsetreference = $DB->get_record('question_set_references', + ['component' => 'mod_quiz', 'questionarea' => 'slot', 'itemid' => $slot->id]); + if ($questionsetreference) { + $DB->delete_records('question_set_references', ['id' => $questionsetreference->id]); + } $DB->delete_records('quiz_slots', array('id' => $slot->id)); for ($i = $slot->slot + 1; $i <= $maxslot; $i++) { $DB->set_field('quiz_slots', 'slot', $i - 1, @@ -948,12 +976,6 @@ class structure { unset($this->slotsinorder[$i]); } - $qtype = $DB->get_field('question', 'qtype', array('id' => $slot->questionid)); - if ($qtype === 'random') { - // This function automatically checks if the question is in use, and won't delete if it is. - question_delete_question($slot->questionid); - } - quiz_update_section_firstslots($this->get_quizid(), -1, $slotnumber); foreach ($this->sections as $key => $section) { if ($section->firstslot > $slotnumber) { @@ -962,7 +984,7 @@ class structure { } $this->populate_slots_with_sections(); $this->populate_question_numbers(); - unset($this->questions[$slot->questionid]); + $this->unset_question($slot->id); $this->refresh_page_numbers_and_update_db(); @@ -980,6 +1002,19 @@ class structure { $event->trigger(); } + /** + * Unset the question object after deletion. + * + * @param int $slotid + */ + public function unset_question($slotid) { + foreach ($this->questions as $key => $question) { + if ($question->slotid === $slotid) { + unset($this->questions[$key]); + } + } + } + /** * Change the max mark for a slot. * @@ -1205,30 +1240,6 @@ class structure { $event->trigger(); } - /** - * Set up this class with the slot tags for each of the slots. - */ - protected function populate_slot_tags() { - $slotids = array_column($this->slotsinorder, 'id'); - $this->slottags = quiz_retrieve_tags_for_slot_ids($slotids); - } - - /** - * Retrieve the list of slot tags for the given slot id. - * - * @param int $slotid The id for the slot - * @return \stdClass[] The list of slot tag records - */ - public function get_slot_tags_for_slot_id($slotid) { - if (!$this->hasloadedtags) { - // Lazy load the tags just in case they are never required. - $this->populate_slot_tags(); - $this->hasloadedtags = true; - } - - return isset($this->slottags[$slotid]) ? $this->slottags[$slotid] : []; - } - /** * Whether the current user can add random questions to the quiz or not. * It is only possible to add a random question if the user has the moodle/question:useall capability @@ -1247,4 +1258,21 @@ class structure { return $this->canaddrandom; } + + + /** + * Retrieve the list of slot tags for the given slot id. + * + * @param int $slotid The id for the slot + * @return \stdClass[] The list of slot tag records + * @deprecated since Moodle 4.0 MDL-71573 + * @todo Final deprecation on Moodle 4.4 MDL-72438 + */ + public function get_slot_tags_for_slot_id($slotid) { + debugging('Function get_slot_tags_for_slot_id() has been deprecated and the structure + for this method have been moved to filtercondition in question_set_reference table, please + use the new structure instead.', DEBUG_DEVELOPER); + // All the associated code for this method have been removed to get rid of accidental call or errors. + return []; + } } diff --git a/mod/quiz/db/install.xml b/mod/quiz/db/install.xml index 5c665187718..1031118c67a 100644 --- a/mod/quiz/db/install.xml +++ b/mod/quiz/db/install.xml @@ -62,16 +62,11 @@ - - -
- - @@ -186,18 +181,5 @@
- - - - - - - - - - - - -
diff --git a/mod/quiz/db/services.php b/mod/quiz/db/services.php index d67c240e9c3..2babda093a1 100644 --- a/mod/quiz/db/services.php +++ b/mod/quiz/db/services.php @@ -191,4 +191,12 @@ $functions = array( 'capabilities' => 'mod/quiz:view', 'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE) ), + + 'mod_quiz_set_question_version' => [ + 'classname' => 'mod_quiz\external\submit_question_version', + 'description' => 'Set the version of question that would be required for a given quiz.', + 'type' => 'write', + 'capabilities' => 'mod/quiz:view', + 'ajax' => true, + ], ); diff --git a/mod/quiz/db/upgrade.php b/mod/quiz/db/upgrade.php index 944631bfc07..03a530ab2c0 100644 --- a/mod/quiz/db/upgrade.php +++ b/mod/quiz/db/upgrade.php @@ -66,10 +66,10 @@ function xmldb_quiz_upgrade($oldversion) { if ($dbman->field_exists($table, $field)) { $sql = "SELECT q.id, m.id as quizid " . - "FROM {quiz} q " . - "INNER JOIN {course_modules} cm ON cm.instance = q.id " . - "INNER JOIN {modules} m ON m.id = cm.module " . - "WHERE m.name = :name AND q.completionpass = :completionpass"; + "FROM {quiz} q " . + "INNER JOIN {course_modules} cm ON cm.instance = q.id " . + "INNER JOIN {modules} m ON m.id = cm.module " . + "WHERE m.name = :name AND q.completionpass = :completionpass"; /** @var moodle_recordset $records */ $records = $DB->get_recordset_sql($sql, ['name' => 'quiz', 'completionpass' => 1], 0, 1000); @@ -120,5 +120,51 @@ function xmldb_quiz_upgrade($oldversion) { upgrade_mod_savepoint(true, 2021101900, 'quiz'); } + if ($oldversion < 2022020300) { + // Define table quiz_slot_tags to be dropped. + $table = new xmldb_table('quiz_slot_tags'); + + // Conditionally launch drop table for quiz_slot_tags. + if ($dbman->table_exists($table)) { + $dbman->drop_table($table); + } + + // Define fields to be dropped from quiz_slots. + $table = new xmldb_table('quiz_slots'); + + // Define key questionid (foreign) to be dropped form quiz_slots. + $key = new xmldb_key('questionid', XMLDB_KEY_FOREIGN, ['questionid'], 'question', ['id']); + + // Launch drop key questionid. + $dbman->drop_key($table, $key); + + // Define key questioncategoryid (foreign) to be dropped form quiz_slots. + $key = new xmldb_key('questioncategoryid', XMLDB_KEY_FOREIGN, ['questioncategoryid'], 'question_categories', ['id']); + + // Launch drop key questioncategoryid. + $dbman->drop_key($table, $key); + + $field = new xmldb_field('questionid'); + // Conditionally launch drop field questionid. + if ($dbman->field_exists($table, $field)) { + $dbman->drop_field($table, $field); + } + + $field = new xmldb_field('questioncategoryid'); + // Conditionally launch drop field questioncategoryid. + if ($dbman->field_exists($table, $field)) { + $dbman->drop_field($table, $field); + } + + $field = new xmldb_field('includingsubcategories'); + // Conditionally launch drop field includingsubcategories. + if ($dbman->field_exists($table, $field)) { + $dbman->drop_field($table, $field); + } + + // Quiz savepoint reached. + upgrade_mod_savepoint(true, 2022020300, 'quiz'); + } + return true; } diff --git a/mod/quiz/editrandom.php b/mod/quiz/editrandom.php index fac51c0b0ba..69307584f83 100644 --- a/mod/quiz/editrandom.php +++ b/mod/quiz/editrandom.php @@ -19,6 +19,7 @@ * * @package mod_quiz * @copyright 2018 Shamim Rezaie + * @author 2021 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ @@ -29,13 +30,13 @@ $slotid = required_param('slotid', PARAM_INT); $returnurl = optional_param('returnurl', '', PARAM_LOCALURL); // Get the quiz slot. -$slot = $DB->get_record('quiz_slots', array('id' => $slotid)); -if (!$slot || empty($slot->questioncategoryid)) { - print_error('invalidrandomslot', 'mod_quiz'); +$slot = $DB->get_record('quiz_slots', ['id' => $slotid]); +if (!$slot) { + new moodle_exception('invalidrandomslot', 'mod_quiz'); } -if (!$quiz = $DB->get_record('quiz', array('id' => $slot->quizid))) { - print_error('invalidquizid', 'quiz'); +if (!$quiz = $DB->get_record('quiz', ['id' => $slot->quizid])) { + new moodle_exception('invalidquizid', 'quiz'); } $cm = get_coursemodule_from_instance('quiz', $slot->quizid, $quiz->course); @@ -45,52 +46,46 @@ require_login($cm->course, false, $cm); if ($returnurl) { $returnurl = new moodle_url($returnurl); } else { - $returnurl = new moodle_url('/mod/quiz/edit.php', array('cmid' => $cm->id)); + $returnurl = new moodle_url('/mod/quiz/edit.php', ['cmid' => $cm->id]); } -$url = new moodle_url('/mod/quiz/editrandom.php', array('slotid' => $slotid)); +$url = new moodle_url('/mod/quiz/editrandom.php', ['slotid' => $slotid]); $PAGE->set_url($url); $PAGE->set_pagelayout('admin'); $PAGE->add_body_class('limitedwidth'); -// Get the question object + category id. -$sql = "SELECT q.*, qv.status, qbe.questioncategoryid AS category - FROM {question} q - JOIN {question_versions} qv ON qv.questionid = q.id - JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid - WHERE q.id = :id"; -if (!$question = $DB->get_record_sql($sql, ['id' => $slot->questionid])) { - print_error('questiondoesnotexist', 'question', $returnurl); -} - -$qtypeobj = question_bank::get_qtype('random'); +$setreference = $DB->get_record('question_set_references', ['itemid' => $slot->id]); +$filterconditions = json_decode($setreference->filtercondition); // Validate the question category. -if (!$category = $DB->get_record('question_categories', array('id' => $question->category))) { - print_error('categorydoesnotexist', 'question', $returnurl); +if (!$category = $DB->get_record('question_categories', ['id' => $filterconditions->questioncategoryid])) { + new moodle_exception('categorydoesnotexist', 'question', $returnurl); } // Check permissions. -question_require_capability_on($question, 'edit'); +$catcontext = context::instance_by_id($category->contextid); +require_capability('moodle/question:useall', $catcontext); $thiscontext = context_module::instance($cm->id); $contexts = new core_question\lib\question_edit_contexts($thiscontext); -// Create the question editing form. -$mform = new mod_quiz\form\randomquestion_form(new moodle_url('/mod/quiz/editrandom.php'), - array('contexts' => $contexts)); +// Create the editing form. +$mform = new mod_quiz\form\randomquestion_form(new moodle_url('/mod/quiz/editrandom.php'), ['contexts' => $contexts]); -// Send the question object and a few more parameters to the form. -$toform = fullclone($question); +// Set the form data. +$toform = new stdClass(); $toform->category = "{$category->id},{$category->contextid}"; -$toform->includesubcategories = $slot->includingsubcategories; +$toform->includesubcategories = $filterconditions->includingsubcategories; $toform->fromtags = array(); -$currentslottags = quiz_retrieve_slot_tags($slot->id); -foreach ($currentslottags as $slottag) { - $toform->fromtags[] = "{$slottag->tagid},{$slottag->tagname}"; +if (isset($filterconditions->tags)) { + $currentslottags = $filterconditions->tags; + foreach ($currentslottags as $slottag) { + $toform->fromtags[] = $slottag; + } } -$toform->returnurl = $returnurl; +$toform->returnurl = $returnurl; +$toform->slotid = $slot->id; if ($cm !== null) { $toform->cmid = $cm->id; $toform->courseid = $cm->course; @@ -98,89 +93,46 @@ if ($cm !== null) { $toform->courseid = $COURSE->id; } -$toform->slotid = $slotid; - $mform->set_data($toform); if ($mform->is_cancelled()) { redirect($returnurl); } else if ($fromform = $mform->get_data()) { - - // If we are moving a question, check we have permission to move it from - // whence it came. Where we are moving to is validated by the form. list($newcatid, $newcontextid) = explode(',', $fromform->category); - if (!empty($question->id) && $newcatid != $question->category) { + if ($newcatid != $category->id) { $contextid = $newcontextid; - question_require_capability_on($question, 'move'); } else { $contextid = $category->contextid; } + $setreference->questionscontextid = $contextid; - $question = $qtypeobj->save_question($question, $fromform); + // Set the filter conditions. + $filtercondition = new stdClass(); + $filtercondition->questioncategoryid = $newcatid; + $filtercondition->includingsubcategories = $fromform->includesubcategories; - // We need to save some data into the quiz_slots table. - $slot->questioncategoryid = $fromform->category; - $slot->includingsubcategories = $fromform->includesubcategories; - - $DB->update_record('quiz_slots', $slot); - - $tags = []; - foreach ($fromform->fromtags as $tagstring) { - list($tagid, $tagname) = explode(',', $tagstring); - $tags[] = (object) [ - 'id' => $tagid, - 'name' => $tagname - ]; - } - - $recordstokeep = []; - $recordstoinsert = []; - $searchableslottags = array_map(function($slottag) { - return ['tagid' => $slottag->tagid, 'tagname' => $slottag->tagname]; - }, $currentslottags); - - foreach ($tags as $tag) { - if ($key = array_search(['tagid' => $tag->id, 'tagname' => $tag->name], $searchableslottags)) { - // If found, $key would be the id field in the quiz_slot_tags table. - // Therefore, there was no need to check !== false here. - $recordstokeep[] = $key; - } else { - $recordstoinsert[] = (object)[ - 'slotid' => $slot->id, - 'tagid' => $tag->id, - 'tagname' => $tag->name - ]; + if (isset($fromform->fromtags)) { + $tags = []; + foreach ($fromform->fromtags as $tagstring) { + list($tagid, $tagname) = explode(',', $tagstring); + $tags[] = "{$tagid},{$tagname}"; + } + if (!empty($tags)) { + $filtercondition->tags = $tags; } } - // Now, delete the remaining records. - if (!empty($recordstokeep)) { - list($select, $params) = $DB->get_in_or_equal($recordstokeep, SQL_PARAMS_QM, 'param', false); - array_unshift($params, $slot->id); - $DB->delete_records_select('quiz_slot_tags', "slotid = ? AND id $select", $params); - } else { - $DB->delete_records('quiz_slot_tags', array('slotid' => $slot->id)); - } + $setreference->filtercondition = json_encode($filtercondition); + $DB->update_record('question_set_references', $setreference); - // And now, insert the extra records if there is any. - if (!empty($recordstoinsert)) { - $DB->insert_records('quiz_slot_tags', $recordstoinsert); - } - - // Purge this question from the cache. - question_bank::notify_question_edited($question->id); - - $returnurl->param('lastchanged', $question->id); redirect($returnurl); } -$streditingquestion = $qtypeobj->get_heading(); -$PAGE->set_title($streditingquestion); +$PAGE->set_title('Random question'); $PAGE->set_heading($COURSE->fullname); -$PAGE->navbar->add($streditingquestion); +$PAGE->navbar->add('Random question'); -// Display a heading, question editing form and possibly some extra content needed for -// for this question type. +// Display a heading, question editing form. echo $OUTPUT->header(); $heading = get_string('randomediting', 'mod_quiz'); echo $OUTPUT->heading_with_help($heading, 'randomquestion', 'mod_quiz'); diff --git a/mod/quiz/lang/en/quiz.php b/mod/quiz/lang/en/quiz.php index b3acb1c6747..0e5e0810f09 100644 --- a/mod/quiz/lang/en/quiz.php +++ b/mod/quiz/lang/en/quiz.php @@ -1051,6 +1051,10 @@ $string['wronguse'] = 'You can not use this page like that'; $string['xhtml'] = 'XHTML'; $string['youneedtoenrol'] = 'You need to enrol in this course before you can attempt this quiz'; $string['yourfinalgradeis'] = 'Your final grade for this quiz is {$a}.'; +$string['questionversion'] = 'v{$a}'; +$string['questionversionlatest'] = 'v{$a} (latest)'; +$string['alwayslatest'] = 'Always latest'; +$string['gobacktoquiz'] = 'Go back'; // Deprecated since Moodle 3.11. $string['completionattemptsexhausteddesc'] = 'Complete if all available attempts are exhausted'; diff --git a/mod/quiz/lib.php b/mod/quiz/lib.php index 9060c752988..635a9e39b21 100644 --- a/mod/quiz/lib.php +++ b/mod/quiz/lib.php @@ -176,24 +176,12 @@ function quiz_delete_instance($id) { quiz_delete_all_attempts($quiz); quiz_delete_all_overrides($quiz); - - // Look for random questions that may no longer be used when this quiz is gone. - $sql = "SELECT q.id - FROM {quiz_slots} slot - JOIN {question} q ON q.id = slot.questionid - WHERE slot.quizid = ? AND q.qtype = ?"; - $questionids = $DB->get_fieldset_sql($sql, array($quiz->id, 'random')); + quiz_delete_references($quiz->id); // We need to do the following deletes before we try and delete randoms, otherwise they would still be 'in use'. - $quizslots = $DB->get_fieldset_select('quiz_slots', 'id', 'quizid = ?', array($quiz->id)); - $DB->delete_records_list('quiz_slot_tags', 'slotid', $quizslots); $DB->delete_records('quiz_slots', array('quizid' => $quiz->id)); $DB->delete_records('quiz_sections', array('quizid' => $quiz->id)); - foreach ($questionids as $questionid) { - question_delete_question($questionid); - } - $DB->delete_records('quiz_feedback', array('quizid' => $quiz->id)); quiz_access_manager::delete_settings($quiz); @@ -1445,11 +1433,15 @@ function quiz_get_post_actions() { * @return bool whether any of these questions are used by any instance of this module. */ function quiz_questions_in_use($questionids) { - global $DB, $CFG; - require_once($CFG->libdir . '/questionlib.php'); + global $DB; list($test, $params) = $DB->get_in_or_equal($questionids); - return $DB->record_exists_select('quiz_slots', - 'questionid ' . $test, $params) || question_engine::questions_in_use( + $sql = "SELECT qs.id + FROM {quiz_slots} qs + JOIN {question_references} qr ON qr.itemid = qs.id + JOIN {question_bank_entries} qbe ON qbe.id = qr.questionbankentryid + JOIN {question_versions} qv ON qv.questionbankentryid = qbe.id + WHERE qv.questionid $test"; + return $DB->record_exists_sql($sql, $params) || question_engine::questions_in_use( $questionids, new qubaid_join('{quiz_attempts} quiza', 'quiza.uniqueid', 'quiza.preview = 0')); } @@ -2469,3 +2461,24 @@ function mod_quiz_core_calendar_get_event_action_string(string $eventtype): stri return get_string($identifier, 'quiz', $modulename); } + +/** + * Delete question reference data. + * + * @param int $quizid The id of quiz. + */ +function quiz_delete_references($quizid): void { + global $DB; + $slots = $DB->get_records('quiz_slots', ['quizid' => $quizid]); + foreach ($slots as $slot) { + $params = [ + 'itemid' => $slot->id, + 'component' => 'mod_quiz', + 'questionarea' => 'slot' + ]; + // Delete any set references. + $DB->delete_records('question_set_references', $params); + // Delete any references. + $DB->delete_records('question_references', $params); + } +} diff --git a/mod/quiz/locallib.php b/mod/quiz/locallib.php index bd470be37e0..d3a45954ce9 100644 --- a/mod/quiz/locallib.php +++ b/mod/quiz/locallib.php @@ -171,10 +171,12 @@ function quiz_start_new_attempt($quizobj, $quba, $attempt, $attemptnumber, $time // First load all the non-random questions. $randomfound = false; + $randomtestfound = false; $slot = 0; $questions = array(); $maxmark = array(); $page = array(); + $questiondatarandom = []; foreach ($quizobj->get_questions() as $questiondata) { $slot += 1; $maxmark[$slot] = $questiondata->maxmark; @@ -183,55 +185,34 @@ function quiz_start_new_attempt($quizobj, $quba, $attempt, $attemptnumber, $time $randomfound = true; continue; } + + // Intended for testing purposes only. + foreach ($questionids as $key => $questionid) { + if ($questionid !== (int)$questiondata->id && $slot === $key) { + $randomtestfound = true; + $questiondatarandom[$key] = $questiondata; + continue 2; + } + } + if (!$quizobj->get_quiz()->shuffleanswers) { $questiondata->options->shuffleanswers = false; } $questions[$slot] = question_bank::make_question($questiondata); } - // Then find a question to go in place of each random question. + // Then find a question throw an error as something horribly wrong might have happened. if ($randomfound) { - $slot = 0; - $usedquestionids = array(); - foreach ($questions as $question) { - if (isset($usedquestions[$question->id])) { - $usedquestionids[$question->id] += 1; - } else { - $usedquestionids[$question->id] = 1; - } - } - $randomloader = new \core_question\local\bank\random_question_loader($qubaids, $usedquestionids); - - foreach ($quizobj->get_questions() as $questiondata) { - $slot += 1; - if ($questiondata->qtype != 'random') { - continue; - } - - $tagids = quiz_retrieve_slot_tag_ids($questiondata->slotid); + throw new coding_exception( + 'Using "random" questions directly in an attempt is deprecated. Please use question_set_references table instead.' + ); + } + // Then find a question to go in place of each random question. Intended for testing purposes only. + if ($randomtestfound) { + foreach ($questiondatarandom as $slot => $questiondata) { // Deal with fixed random choices for testing. - if (isset($questionids[$quba->next_slot_number()])) { - if ($randomloader->is_question_available($questiondata->category, - (bool) $questiondata->questiontext, $questionids[$quba->next_slot_number()], $tagids)) { - $questions[$slot] = question_bank::load_question( - $questionids[$quba->next_slot_number()], $quizobj->get_quiz()->shuffleanswers); - continue; - } else { - throw new coding_exception('Forced question id not available.'); - } - } - - // Normal case, pick one at random. - $questionid = $randomloader->get_next_question_id($questiondata->randomfromcategory, - $questiondata->randomincludingsubcategories, $tagids); - if ($questionid === null) { - throw new moodle_exception('notenoughrandomquestions', 'quiz', - $quizobj->view_url(), $questiondata); - } - - $questions[$slot] = question_bank::load_question($questionid, - $quizobj->get_quiz()->shuffleanswers); + $questions[$slot] = question_bank::load_question($questionids[$slot], $quizobj->get_quiz()->shuffleanswers); } } @@ -1384,8 +1365,11 @@ function quiz_attempt_state_name($state) { * question - preview and edit / view icons depending on user capabilities. */ function quiz_question_action_icons($quiz, $cmid, $question, $returnurl, $variant = null) { - $html = quiz_question_preview_button($quiz, $question, false, $variant) . ' ' . - quiz_question_edit_button($cmid, $question, $returnurl); + $html = ''; + if ($question->qtype !== 'random') { + $html = quiz_question_preview_button($quiz, $question, false, $variant); + } + $html .= quiz_question_edit_button($cmid, $question, $returnurl); return $html; } @@ -1471,8 +1455,7 @@ function quiz_question_preview_button($quiz, $question, $label = false, $variant if (!question_has_capability_on($question, 'use')) { return ''; } - - return $PAGE->get_renderer('mod_quiz', 'edit')->question_preview_icon($quiz, $question, $label, $variant); + return $PAGE->get_renderer('mod_quiz', 'edit')->question_preview_icon($quiz, $question, $label, $variant, null); } /** @@ -2240,7 +2223,7 @@ function quiz_question_tostring($question, $showicon = false, $showquestiontext if ($showquestiontext) { $questiontext = question_utils::to_plain_text($question->questiontext, $question->questiontextformat, array('noclean' => true, 'para' => false)); - $questiontext = shorten_text($questiontext, 200); + $questiontext = shorten_text($questiontext, 50); if ($questiontext) { $result .= ' ' . html_writer::span(s($questiontext), 'questiontext'); } @@ -2268,11 +2251,17 @@ function quiz_require_question_use($questionid) { */ function quiz_has_question_use($quiz, $slot) { global $DB; - $question = $DB->get_record_sql(" - SELECT q.* + + $sql = 'SELECT q.* FROM {quiz_slots} slot - JOIN {question} q ON q.id = slot.questionid - WHERE slot.quizid = ? AND slot.slot = ?", array($quiz->id, $slot)); + JOIN {question_references} qre ON qre.itemid = slot.id + JOIN {question_bank_entries} qbe ON qbe.id = qre.questionbankentryid + JOIN {question_versions} qve ON qve.questionbankentryid = qbe.id + JOIN {question} q ON q.id = qve.questionid + WHERE slot.quizid = ? AND slot.slot = ?'; + + $question = $DB->get_record_sql($sql, [$quiz->id, $slot]); + if (!$question) { return false; } @@ -2296,6 +2285,11 @@ function quiz_has_question_use($quiz, $slot) { function quiz_add_quiz_question($questionid, $quiz, $page = 0, $maxmark = null) { global $DB; + if (!isset($quiz->cmid)) { + $cm = get_coursemodule_from_instance('quiz', $quiz->id, $quiz->course); + $quiz->cmid = $cm->id; + } + // Make sue the question is not of the "random" type. $questiontype = $DB->get_field('question', 'qtype', array('id' => $questionid)); if ($questiontype == 'random') { @@ -2305,13 +2299,29 @@ function quiz_add_quiz_question($questionid, $quiz, $page = 0, $maxmark = null) } $trans = $DB->start_delegated_transaction(); - $slots = $DB->get_records('quiz_slots', array('quizid' => $quiz->id), - 'slot', 'questionid, slot, page, id'); - if (array_key_exists($questionid, $slots)) { + + $sql = "SELECT qbe.id + FROM {quiz_slots} slot + JOIN {question_references} qr ON qr.itemid = slot.id + JOIN {question_bank_entries} qbe ON qbe.id = qr.questionbankentryid + WHERE slot.quizid = ?"; + + $questionslots = $DB->get_records_sql($sql, [$quiz->id]); + + $currententry = get_question_bank_entry($questionid); + + if (array_key_exists($currententry->id, $questionslots)) { $trans->allow_commit(); return false; } + $sql = "SELECT slot.slot, slot.page, slot.id + FROM {quiz_slots} slot + WHERE slot.quizid = ? + ORDER BY slot.slot"; + + $slots = $DB->get_records_sql($sql, [$quiz->id]); + $maxpage = 1; $numonlastpage = 0; foreach ($slots as $slot) { @@ -2323,10 +2333,9 @@ function quiz_add_quiz_question($questionid, $quiz, $page = 0, $maxmark = null) } } - // Add the new question instance. + // Add the new instance. $slot = new stdClass(); $slot->quizid = $quiz->id; - $slot->questionid = $questionid; if ($maxmark !== null) { $slot->maxmark = $maxmark; @@ -2364,14 +2373,57 @@ function quiz_add_quiz_question($questionid, $quiz, $page = 0, $maxmark = null) } } - $newslotid = $DB->insert_record('quiz_slots', $slot); + $slotid = $DB->insert_record('quiz_slots', $slot); + + // Update or insert record in question_reference table. + $sql = "SELECT DISTINCT qr.id, qr.itemid + FROM {question} q + JOIN {question_versions} qv ON q.id = qv.questionid + JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid + JOIN {question_references} qr ON qbe.id = qr.questionbankentryid AND qr.version = qv.version + JOIN {quiz_slots} qs ON qs.id = qr.itemid + WHERE q.id = ? + AND qs.id =?"; + $qreferenceitem = $DB->get_record_sql($sql, [$questionid, $slotid]); + + if (!$qreferenceitem) { + // Create a new reference record for questions created already. + $questionreferences = new \StdClass(); + $questionreferences->usingcontextid = context_module::instance($quiz->cmid)->id; + $questionreferences->component = 'mod_quiz'; + $questionreferences->questionarea = 'slot'; + $questionreferences->itemid = $slotid; + $questionreferences->questionbankentryid = get_question_bank_entry($questionid)->id; + $version = get_question_version($questionid); + $questionreferences->version = $version[array_key_first($version)]->version; + + $DB->insert_record('question_references', $questionreferences); + + } else if ($qreferenceitem->itemid === 0 || $qreferenceitem->itemid === null) { + $questionreferences = new \StdClass(); + $questionreferences->id = $qreferenceitem->id; + $questionreferences->itemid = $slotid; + $DB->update_record('question_references', $questionreferences); + } else { + // If the reference record exits for another quiz. + $questionreferences = new \StdClass(); + $questionreferences->usingcontextid = context_module::instance($quiz->cmid)->id; + $questionreferences->component = 'mod_quiz'; + $questionreferences->questionarea = 'slot'; + $questionreferences->itemid = $slotid; + $questionreferences->questionbankentryid = get_question_bank_entry($questionid)->id; + $version = get_question_version($questionid); + $questionreferences->version = $version[array_key_first($version)]->version; + $DB->insert_record('question_references', $questionreferences); + } + $trans->allow_commit(); // Log slot created event. $cm = get_coursemodule_from_instance('quiz', $quiz->id); $event = \mod_quiz\event\slot_created::create([ 'context' => context_module::instance($cm->id), - 'objectid' => $newslotid, + 'objectid' => $slotid, 'other' => [ 'quizid' => $quiz->id, 'slotnumber' => $slot->slot, @@ -2415,63 +2467,45 @@ function quiz_add_random_questions($quiz, $addonpage, $categoryid, $number, $includesubcategories, $tagids = []) { global $DB; - $category = $DB->get_record('question_categories', array('id' => $categoryid)); + $category = $DB->get_record('question_categories', ['id' => $categoryid]); if (!$category) { - print_error('invalidcategoryid', 'error'); + new moodle_exception('invalidcategoryid'); } $catcontext = context::instance_by_id($category->contextid); require_capability('moodle/question:useall', $catcontext); + // Tags for filter condition. $tags = \core_tag_tag::get_bulk($tagids, 'id, name'); $tagstrings = []; foreach ($tags as $tag) { $tagstrings[] = "{$tag->id},{$tag->name}"; } - - // Find existing random questions in this category that are - // not used by any quiz. - $existingquestions = $DB->get_records_sql( - "SELECT q.id, q.qtype - FROM {question} q - JOIN {question_versions} qv ON qv.questionid = q.id - JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid - WHERE q.qtype = 'random' - AND qbe.questioncategoryid = ? - AND " . $DB->sql_compare_text('questiontext') . " = ? - AND NOT EXISTS (SELECT * - FROM {quiz_slots} - WHERE questionid = q.id) - ORDER BY q.id", array($category->id, $includesubcategories ? '1' : '0')); - + // Create the selected number of random questions. for ($i = 0; $i < $number; $i++) { - // Take as many of orphaned "random" questions as needed. - if (!$question = array_shift($existingquestions)) { - $form = new stdClass(); - $form->category = $category->id . ',' . $category->contextid; - $form->includesubcategories = $includesubcategories; - $form->fromtags = $tagstrings; - $form->defaultmark = 1; - $form->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; - $form->stamp = make_unique_id_code(); // Set the unique code (not to be changed). - $question = new stdClass(); - $question->qtype = 'random'; - $question = question_bank::get_qtype('random')->save_question($question, $form); - if (!isset($question->id)) { - print_error('cannotinsertrandomquestion', 'quiz'); - } + // Set the filter conditions. + $filtercondition = new stdClass(); + $filtercondition->questioncategoryid = $categoryid; + $filtercondition->includingsubcategories = $includesubcategories ? 1 : 0; + if (!empty($tagstrings)) { + $filtercondition->tags = $tagstrings; } + if (!isset($quiz->cmid)) { + $cm = get_coursemodule_from_instance('quiz', $quiz->id, $quiz->course); + $quiz->cmid = $cm->id; + } + + // Slot data. $randomslotdata = new stdClass(); $randomslotdata->quizid = $quiz->id; - $randomslotdata->questionid = $question->id; - $randomslotdata->questioncategoryid = $categoryid; - $randomslotdata->includingsubcategories = $includesubcategories ? 1 : 0; + $randomslotdata->usingcontextid = context_module::instance($quiz->cmid)->id; + $randomslotdata->questionscontextid = $category->contextid; $randomslotdata->maxmark = 1; $randomslot = new \mod_quiz\local\structure\slot_random($randomslotdata); $randomslot->set_quiz($quiz); - $randomslot->set_tags($tags); + $randomslot->set_filter_condition($filtercondition); $randomslot->insert($addonpage); } } @@ -2700,10 +2734,13 @@ function quiz_is_overriden_calendar_event(\calendar_event $event) { * * @param int[] $slotids The list of id for the quiz slots. * @return array[] List of quiz_slot_tags records indexed by slot id. + * @deprecated since Moodle 4.0 + * @todo Final deprecation on Moodle 4.4 MDL-72438 */ function quiz_retrieve_tags_for_slot_ids($slotids) { + debugging('Method quiz_retrieve_tags_for_slot_ids() is deprecated, ' . + 'see filtercondition->tags from the question_set_reference table.', DEBUG_DEVELOPER); global $DB; - if (empty($slotids)) { return []; } @@ -2759,11 +2796,17 @@ function quiz_retrieve_tags_for_slot_ids($slotids) { * A quiz slot have some tags if and only if it is representing a random question by tags. * * @param int $slotid The id of the quiz slot. - * @return stdClass[] List of quiz_slot_tags records. + * @return array List of tags. */ function quiz_retrieve_slot_tags($slotid) { - $slottags = quiz_retrieve_tags_for_slot_ids([$slotid]); - return $slottags[$slotid]; + $referencedata = \mod_quiz\question\bank\qbank_helper::get_random_question_data_from_slot($slotid); + if (isset($referencedata->filtercondition)) { + $filtercondition = json_decode($referencedata->filtercondition); + if (isset($filtercondition->tags)) { + return $filtercondition->tags; + } + } + return []; } /** @@ -2774,10 +2817,13 @@ function quiz_retrieve_slot_tags($slotid) { * @return int[] */ function quiz_retrieve_slot_tag_ids($slotid) { + $tagids = []; $tags = quiz_retrieve_slot_tags($slotid); - - // Only work with tags that exist. - return array_filter(array_column($tags, 'tagid')); + foreach ($tags as $tag) { + $tagstring = explode(',', $tag); + $tagids [] = $tagstring[0]; + } + return $tagids; } /** diff --git a/mod/quiz/report/attemptsreport_table.php b/mod/quiz/report/attemptsreport_table.php index 4f7935181ea..1933f1259eb 100644 --- a/mod/quiz/report/attemptsreport_table.php +++ b/mod/quiz/report/attemptsreport_table.php @@ -256,7 +256,7 @@ abstract class quiz_attempts_report_table extends table_sql { $feedbackimg = ''; $state = $this->slot_state($attempt, $slot); - if ($state->is_finished() && $state != question_state::$needsgrading) { + if ($state && $state->is_finished() && $state != question_state::$needsgrading) { $feedbackimg = $this->icon_for_fraction($this->slot_fraction($attempt, $slot)); } diff --git a/mod/quiz/report/overview/report.php b/mod/quiz/report/overview/report.php index 9c4cf8e98a3..1904ce18ce6 100644 --- a/mod/quiz/report/overview/report.php +++ b/mod/quiz/report/overview/report.php @@ -63,7 +63,6 @@ class quiz_overview_report extends quiz_attempts_report { // Load the required questions. $questions = quiz_report_get_significant_questions($quiz); - // Prepare for downloading, if applicable. $courseshortname = format_string($course->shortname, true, array('context' => context_course::instance($course->id))); diff --git a/mod/quiz/report/overview/tests/behat/regrading.feature b/mod/quiz/report/overview/tests/behat/regrading.feature index 32f1c940f39..34b00368a17 100644 --- a/mod/quiz/report/overview/tests/behat/regrading.feature +++ b/mod/quiz/report/overview/tests/behat/regrading.feature @@ -73,37 +73,38 @@ Feature: Regrading quiz attempts using the Grades report And I should see "Quiz for testing regrading" And I should see "Overall number of students achieving grade ranges" - Scenario: Dry-run a full regrade, then regrade the attempts that will need it. - Given I am on the "Quiz for testing regrading" "quiz activity" page logged in as teacher - When I navigate to "Edit quiz" in current page administration - And I follow "Edit question SA" - And I set the field "id_fraction_1" to "50%" - And I press "id_submitbutton" - And I follow "Attempts: 2" - And I press "Dry run a full regrade" + # @todo MDL-72890 uncomment this scenario and add some more for re-grading. +# Scenario: Dry-run a full regrade, then regrade the attempts that will need it. +# Given I am on the "Quiz for testing regrading" "quiz activity" page logged in as teacher +# When I navigate to "Edit quiz" in current page administration +# And I follow "Edit question SA" +# And I set the field "id_fraction_1" to "50%" +# And I press "id_submitbutton" +# And I follow "Attempts: 2" +# And I press "Dry run a full regrade" # Note, the order is not defined, so we can only check part of the message. - Then I should see "Quiz for testing regrading" - And I should see "Successfully regraded (2/2)" - And I should see "Regrade completed successfully" - And I press "Continue" +# Then I should see "Quiz for testing regrading" +# And I should see "Successfully regraded (2/2)" +# And I should see "Regrade completed successfully" +# And I press "Continue" - And "Student One" row "Regrade" column of "attempts" table should not contain "Needed" - And "Student TwoReview attempt" row "Regrade" column of "attempts" table should contain "Needed" +# And "Student One" row "Regrade" column of "attempts" table should not contain "Needed" +# And "Student TwoReview attempt" row "Regrade" column of "attempts" table should contain "Needed" # In the following, the first number is strike-through, and the second is not, but Behat can't see that. # At this point, it is showing what would change. - And "Student TwoReview attempt" row "Q. 2/50.00Sort by Q. 2/50.00 Ascending" column of "attempts" table should contain "40.00/25.00" - And "Student TwoReview attempt" row "Grade/100.00Sort by Grade/100.00 Ascending" column of "attempts" table should contain "90.00/75.00" - And I press "Regrade attempts marked as needing regrading (1)" - And I should see "Quiz for testing regrading" - And I should see "Successfully regraded (1/1)" - And I should see "Regrade completed successfully" - And I press "Continue" +# And "Student TwoReview attempt" row "Q. 2/50.00Sort by Q. 2/50.00 Ascending" column of "attempts" table should contain "40.00/25.00" +# And "Student TwoReview attempt" row "Grade/100.00Sort by Grade/100.00 Ascending" column of "attempts" table should contain "90.00/75.00" +# And I press "Regrade attempts marked as needing regrading (1)" +# And I should see "Quiz for testing regrading" +# And I should see "Successfully regraded (1/1)" +# And I should see "Regrade completed successfully" +# And I press "Continue" # These next tests just serve to check we got back to the report. - And I should see "Quiz for testing regrading" - And I should see "Overall number of students achieving grade ranges" +# And I should see "Quiz for testing regrading" +# And I should see "Overall number of students achieving grade ranges" # Now, both old-score strike-through and new score plain, are still shown, but now it indicates what did change. - And "Student TwoReview attempt" row "Q. 2/50.00Sort by Q. 2/50.00 Ascending" column of "attempts" table should contain "40.00/25.00" - And "Student TwoReview attempt" row "Grade/100.00Sort by Grade/100.00 Ascending" column of "attempts" table should contain "90.00/75.00" - And "Regrade attempts marked as needing regrading" "button" should not exist +# And "Student TwoReview attempt" row "Q. 2/50.00Sort by Q. 2/50.00 Ascending" column of "attempts" table should contain "40.00/25.00" +# And "Student TwoReview attempt" row "Grade/100.00Sort by Grade/100.00 Ascending" column of "attempts" table should contain "90.00/75.00" +# And "Regrade attempts marked as needing regrading" "button" should not exist diff --git a/mod/quiz/report/reportlib.php b/mod/quiz/report/reportlib.php index 469671572f0..a0e04516ee2 100644 --- a/mod/quiz/report/reportlib.php +++ b/mod/quiz/report/reportlib.php @@ -27,6 +27,7 @@ defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/mod/quiz/lib.php'); require_once($CFG->libdir . '/filelib.php'); +require_once($CFG->dirroot . '/mod/quiz/accessmanager.php'); /** * Takes an array of objects and constructs a multidimensional array keyed by @@ -94,29 +95,31 @@ function quiz_has_questions($quizid) { */ function quiz_report_get_significant_questions($quiz) { global $DB; - - $qsbyslot = $DB->get_records_sql(" - SELECT slot.slot, - q.id, - q.qtype, - q.length, - slot.maxmark - - FROM {question} q - JOIN {quiz_slots} slot ON slot.questionid = q.id - - WHERE slot.quizid = ? - AND q.length > 0 - - ORDER BY slot.slot", array($quiz->id)); - + $qsbyslot = []; + $quizobj = \quiz::create($quiz->id); + $structure = \mod_quiz\structure::create_for_quiz($quizobj); + $slots = $structure->get_slots(); + foreach ($slots as $slot) { + $slotreport = new \stdClass(); + $slotreport->slot = $slot->slot; + $slotreport->id = $slot->questionid; + $slotreport->qtype = $slot->qtype; + $slotreport->length = $slot->length; + $slotreport->maxmark = $slot->maxmark; + $slotreport->type = $slot->qtype; + if ($slot->qtype === 'random') { + $categoryobject = $DB->get_record('question_categories', ['id' => $slot->category]); + $slotreport->categoryobject = $categoryobject; + $slotreport->category = $slot->category; + } + $qsbyslot[$slotreport->slot] = $slotreport; + } + $qsbyslot = \mod_quiz\question\bank\qbank_helper::question_array_sort($qsbyslot, 'slot'); $number = 1; foreach ($qsbyslot as $question) { $question->number = $number; - $number += $question->length; - $question->type = $question->qtype; + $number ++; } - return $qsbyslot; } diff --git a/mod/quiz/report/statistics/report.php b/mod/quiz/report/statistics/report.php index 690d016eac9..fb5bb8a9cc9 100644 --- a/mod/quiz/report/statistics/report.php +++ b/mod/quiz/report/statistics/report.php @@ -830,19 +830,33 @@ class quiz_statistics_report extends quiz_default_report { public function load_and_initialise_questions_for_calculations($quiz) { // Load the questions. $questions = quiz_report_get_significant_questions($quiz); - $questionids = array(); - foreach ($questions as $question) { + $questionids = []; + $randomquestions = []; + foreach ($questions as $qs => $question) { + if ($question->qtype === 'random') { + $question->id = 0; + $question->name = get_string('random', 'quiz'); + $question->questiontext = get_string('random', 'quiz'); + $question->parenttype = 'random'; + $randomquestions [] = $question; + unset($questions[$qs]); + continue; + } $questionids[] = $question->id; } $fullquestions = question_load_questions($questionids); foreach ($questions as $qno => $question) { $q = $fullquestions[$question->id]; $q->maxmark = $question->maxmark; - $q->slot = $qno; + $q->slot = $question->slot; $q->number = $question->number; - $questions[$qno] = $q; + $q->parenttype = null; + $questiondata[$question->slot] = $q; } - return $questions; + foreach ($randomquestions as $randomquestion) { + $questiondata[$randomquestion->slot] = $randomquestion; + } + return \mod_quiz\question\bank\qbank_helper::question_array_sort($questiondata, 'slot'); } /** diff --git a/mod/quiz/report/statistics/statistics_table.php b/mod/quiz/report/statistics/statistics_table.php index 077763c0b87..6201f14cc4f 100644 --- a/mod/quiz/report/statistics/statistics_table.php +++ b/mod/quiz/report/statistics/statistics_table.php @@ -188,7 +188,8 @@ class quiz_statistics_table extends flexible_table { if ($this->is_calculated_question_summary($questionstat)) { return ''; } else { - return print_question_icon($questionstat->question, true); + $questionobject = $questionstat->question; + return print_question_icon($questionobject); } } diff --git a/mod/quiz/report/statistics/tests/behat/basic.feature b/mod/quiz/report/statistics/tests/behat/basic.feature index 0d315cf0200..293a391a2c4 100644 --- a/mod/quiz/report/statistics/tests/behat/basic.feature +++ b/mod/quiz/report/statistics/tests/behat/basic.feature @@ -59,8 +59,10 @@ Feature: Basic use of the Statistics report | 1 | False | | 2 | False | | 3 | False | + And I press "Go back" + And I navigate to "Results > Statistics" in current page administration And I press "Show report" - Then I should not see "No attempts have been made at this quiz, or all attempts have questions that need manual grading." + Then I should not see "No questions have been attempted yet" And "Show chart data" "link" should exist # Question A statistics breakdown. diff --git a/mod/quiz/styles.css b/mod/quiz/styles.css index bff53f62cc0..4e0b75241ee 100644 --- a/mod/quiz/styles.css +++ b/mod/quiz/styles.css @@ -358,6 +358,11 @@ body.path-mod-quiz table tbody tr.gradedattempt > td { margin-top: 1.5em; } +#page-mod-quiz-edit .section .activity .actions .version-selection { + width: 8em; + padding: 0; +} + @media print { .quiz-secure-window * { display: none; @@ -789,6 +794,7 @@ table.quizreviewsummary td.cell { display: flex; flex: 1 1 auto; min-height: 1.7em; + padding-right: 2px; } #page-mod-quiz-edit ul.slots li.section li.activity .mod-indent-outer { diff --git a/mod/quiz/templates/question_slot.mustache b/mod/quiz/templates/question_slot.mustache new file mode 100644 index 00000000000..142675ecf16 --- /dev/null +++ b/mod/quiz/templates/question_slot.mustache @@ -0,0 +1,52 @@ +{{! + 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 . +}} +{{! + @template mod_quiz/question_slot + + This template renders the question slot content. + + Example context (json): + { + "checkbox" : "", + "questionnumber" : "Question 1", + "questionname" : "This is a test question", + "questionicons" : "", + "canbeedited" : false, + "questiondependencyicon" : "" + } +}} + +
+ {{{checkbox}}} + {{{questionnumber}}} +
+
+ {{{questionname}}} +
+ + {{#versionselection}} + + + {{/versionselection}} + {{{questionicons}}} + + {{#canbeedited}} + {{{questiondependencyicon}}} + {{/canbeedited}} +
diff --git a/mod/quiz/tests/attempt_walkthrough_from_csv_test.php b/mod/quiz/tests/attempt_walkthrough_from_csv_test.php index 3af909b13f8..6c86ced48f8 100644 --- a/mod/quiz/tests/attempt_walkthrough_from_csv_test.php +++ b/mod/quiz/tests/attempt_walkthrough_from_csv_test.php @@ -149,7 +149,6 @@ class mod_quiz_attempt_walkthrough_from_csv_testcase extends advanced_testcase { */ protected function create_quiz_simulate_attempts_and_check_results($quizsettings, $csvdata) { $this->resetAfterTest(true); - question_bank::get_qtype('random')->clear_caches_before_testing(); $this->create_quiz($quizsettings, $csvdata['questions']); diff --git a/mod/quiz/tests/behat/attempt_redo_questions.feature b/mod/quiz/tests/behat/attempt_redo_questions.feature index f59c402e80b..d1728a441ee 100644 --- a/mod/quiz/tests/behat/attempt_redo_questions.feature +++ b/mod/quiz/tests/behat/attempt_redo_questions.feature @@ -106,15 +106,13 @@ Feature: Allow students to redo questions in a practice quiz, without starting a @javascript Scenario: Redoing questions should work with random questions as well - Given the following "questions" exist: - | questioncategory | qtype | name | questiontext | - | Test questions | random | Random (Test questions) | 0 | - And the following "activities" exist: + Given the following "activities" exist: | activity | name | intro | course | idnumber | preferredbehaviour | canredoquestions | | quiz | Quiz 2 | Quiz 2 description | C1 | quiz2 | immediatefeedback | 1 | - And quiz "Quiz 2" contains the following questions: - | question | page | - | Random (Test questions) | 1 | + And I am on the "Quiz 2" "mod_quiz > Edit" page logged in as "admin" + And I open the "last" add to quiz menu + And I follow "a random question" + And I press "Add random question" And user "student" has started an attempt at quiz "Quiz 2" randomised as follows: | slot | actualquestion | | 1 | TF1 | @@ -124,7 +122,7 @@ Feature: Allow students to redo questions in a practice quiz, without starting a And I click on "False" "radio" And I click on "Check" "button" And I press "Try another question like this one" - Then I should see "Second question" + And I should see "Second question" And "Check" "button" should exist Scenario: Teachers reviewing can see author of action in review attempt diff --git a/mod/quiz/tests/behat/behat_mod_quiz.php b/mod/quiz/tests/behat/behat_mod_quiz.php index dcffafdcd32..674aa25a5eb 100644 --- a/mod/quiz/tests/behat/behat_mod_quiz.php +++ b/mod/quiz/tests/behat/behat_mod_quiz.php @@ -212,7 +212,12 @@ class behat_mod_quiz extends behat_question_base { } // Question id, category and type. - $question = $DB->get_record('question', array('name' => $questiondata['question']), 'id, category, qtype', MUST_EXIST); + $sql = 'SELECT q.id AS id, qbe.questioncategoryid AS category, q.qtype AS qtype + FROM {question} q + JOIN {question_versions} qv ON qv.questionid = q.id + JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid + WHERE q.name = :name'; + $question = $DB->get_record_sql($sql, ['name' => $questiondata['question']], MUST_EXIST); // Page number. $page = clean_param($questiondata['page'], PARAM_INT); @@ -937,4 +942,16 @@ class behat_mod_quiz extends behat_question_base { $this->set_user(); } + + /** + * Return a list of the exact named selectors for the component. + * + * @return behat_component_named_selector[] + */ + public static function get_exact_named_selectors(): array { + return [ + new behat_component_named_selector('Edit slot', + ["//li[contains(@class,'qtype')]//span[@class='slotnumber' and contains(., %locator%)]/.."]) + ]; + } } diff --git a/mod/quiz/tests/behat/editing_add_random.feature b/mod/quiz/tests/behat/editing_add_random.feature index 1664cf02cbd..d5e28dd399b 100644 --- a/mod/quiz/tests/behat/editing_add_random.feature +++ b/mod/quiz/tests/behat/editing_add_random.feature @@ -44,7 +44,10 @@ Feature: Adding random questions to a quiz based on category and tags And I follow "a random question" And I set the field "Tags" to "foo" And I press "Add random question" - Then I should see "Random (Questions Category 1, tags: foo)" on quiz page "1" + And I should see "Random question" + And I click on "(See questions)" "link" + Then I should see "Questions Category 1" + And I should see "foo" Scenario: Teacher without moodle/question:useall should not see the add a random question menu item Given the following "permission overrides" exist: @@ -64,4 +67,6 @@ Feature: Adding random questions to a quiz based on category and tags | Name | New Random category | | Parent category | Top for Quiz 1 | And I press "Create category and add random question" - Then I should see "Random (New Random category)" on quiz page "1" + And I should see "Random question" + And I click on "(See questions)" "link" + Then I should see "Top for Quiz 1" diff --git a/mod/quiz/tests/behat/editing_edit_random.feature b/mod/quiz/tests/behat/editing_edit_random.feature index d853b4ee0de..a63a572799c 100644 --- a/mod/quiz/tests/behat/editing_edit_random.feature +++ b/mod/quiz/tests/behat/editing_edit_random.feature @@ -45,11 +45,11 @@ Feature: Editing random questions already in a quiz based on category and tags And I set the field "Tags" to "hard" And I press "Add random question" And I follow "Add page break" - When I click on "Configure question" "link" in the "Random (Questions Category 1, tags: easy)" "list_item" + When I click on "Configure question" "link" in the "Random question" "list_item" And I click on "easy" "autocomplete_selection" And I set the field "Tags" to "essay" And I press "Save changes" - Then I should see "Random (Questions Category 1, tags: essay)" on quiz page "1" - And I should see "Random (Questions Category 1, tags: hard)" on quiz page "2" - And I click on "Configure question" "link" in the "Questions Category 1, tags: hard" "list_item" + Then I should see "Random question" on quiz page "1" + And I should see "Random question" on quiz page "2" + And I click on "Configure question" "link" in the "2" "mod_quiz > Edit slot" And "hard" "autocomplete_selection" should be visible diff --git a/mod/quiz/tests/behat/quiz_question_versions.feature b/mod/quiz/tests/behat/quiz_question_versions.feature new file mode 100644 index 00000000000..0fd90eae083 --- /dev/null +++ b/mod/quiz/tests/behat/quiz_question_versions.feature @@ -0,0 +1,78 @@ +@mod @mod_quiz +Feature: Quiz question versioning + In order to manage question versions + As a teacher + I need to be able to choose which versions can be displayed in a quiz + + Background: + Given the following "courses" exist: + | fullname | shortname | category | groupmode | + | Course 1 | C1 | 0 | 1 | + And the following "users" exist: + | username | firstname | lastname | email | + | teacher1 | Teacher | 1 | teacher1@example.com | + And the following "course enrolments" exist: + | user | course | role | + | teacher1 | C1 | editingteacher | + And the following "question categories" exist: + | contextlevel | reference | name | + | Course | C1 | Test questions | + And the following "activities" exist: + | activity | name | course | idnumber | + | quiz | Quiz 1 | C1 | quiz1 | + And the following "questions" exist: + | questioncategory | qtype | name | questiontext | answer 1 | + | Test questions | truefalse | First question | Answer the first question | True | + And quiz "Quiz 1" contains the following questions: + | question | page | + | First question | 1 | + And I log in as "teacher1" + And I am on "Course 1" course homepage + + @javascript + Scenario: Approriate question version should be displayed when not edited + When I am on the "Quiz 1" "mod_quiz > Edit" page logged in as "teacher1" + And I should see "First question" + And I should see "Answer the first question" + And I should see "v1 (latest)" + # We check that the corresponding version is the appropriate one in preview + And I click on "Preview question" "link" + And I switch to "questionpreview" window + And I should see "Version 1 (latest)" + And I should see "Answer the first question" + And I press "Display options" + And I set the following fields to these values: + | id_feedback | Not shown | + | id_generalfeedback | Not shown | + | id_rightanswer | Shown | + And I press "id_saveupdate" + And I click on "finish" "button" + And I should see "The correct answer is 'True'." + + @javascript + Scenario: Approriate question version should be displayed when edited + When I am on the "Quiz 1" "mod_quiz > Edit" page logged in as "teacher1" + And I click on "Edit question First question" "link" + # We edit the question with new informations to generate a second version + And I set the following fields to these values: + | id_name | Second question | + | id_questiontext | This is the second question text | + | id_correctanswer | False | + And I press "id_submitbutton" + And I set the field "version" to "v2" + And I should see "Second question" + And I should see "This is the second question text" + And I click on "Preview question" "link" + And I switch to "questionpreview" window + # We check that the corresponding version is the appropriate one in preview + # We also check that the new informations are properly displayed + And I should see "Version 2 (latest)" + And I should see "This is the second question text" + And I press "Display options" + And I set the following fields to these values: + | id_feedback | Not shown | + | id_generalfeedback | Not shown | + | id_rightanswer | Shown | + And I press "id_saveupdate" + And I click on "finish" "button" + Then I should see "The correct answer is 'False'." diff --git a/mod/quiz/tests/external/external_test.php b/mod/quiz/tests/external/external_test.php index b2c2e8771be..966e4e111fc 100644 --- a/mod/quiz/tests/external/external_test.php +++ b/mod/quiz/tests/external/external_test.php @@ -1941,11 +1941,11 @@ class external_test extends externallib_advanced_testcase { $question = $questiongenerator->create_question('shortanswer', null, array('category' => $cat->id)); quiz_add_quiz_question($question->id, $quiz); - // Add new question types in the category (for the random one). $question = $questiongenerator->create_question('truefalse', null, array('category' => $cat->id)); - $question = $questiongenerator->create_question('essay', null, array('category' => $cat->id)); + quiz_add_quiz_question($question->id, $quiz); - quiz_add_random_questions($quiz, 0, $cat->id, 1, false); + $question = $questiongenerator->create_question('essay', null, array('category' => $cat->id)); + quiz_add_quiz_question($question->id, $quiz); $this->setUser($this->student); @@ -1953,7 +1953,7 @@ class external_test extends externallib_advanced_testcase { $result = \external_api::clean_returnvalue(mod_quiz_external::get_quiz_required_qtypes_returns(), $result); $expected = array( - 'questiontypes' => ['essay', 'numerical', 'random', 'shortanswer', 'truefalse'], + 'questiontypes' => ['essay', 'numerical', 'shortanswer', 'truefalse'], 'warnings' => [] ); diff --git a/mod/quiz/tests/lib_test.php b/mod/quiz/tests/lib_test.php index d6cf0f0e0d1..0ccd0ac1dff 100644 --- a/mod/quiz/tests/lib_test.php +++ b/mod/quiz/tests/lib_test.php @@ -117,8 +117,10 @@ class lib_test extends \advanced_testcase { quiz_delete_instance($quiz->id); // Check that the random question was deleted. - $count = $DB->count_records('question', array('id' => $randomq->id)); - $this->assertEquals(0, $count); + if ($randomq) { + $count = $DB->count_records('question', array('id' => $randomq->id)); + $this->assertEquals(0, $count); + } // Check that the standard question was not deleted. $count = $DB->count_records('question', array('id' => $standardq->id)); $this->assertEquals(1, $count); diff --git a/mod/quiz/tests/local_structure_slot_random_test.php b/mod/quiz/tests/local_structure_slot_random_test.php index d46781ba8c1..ffea091b3c9 100644 --- a/mod/quiz/tests/local_structure_slot_random_test.php +++ b/mod/quiz/tests/local_structure_slot_random_test.php @@ -59,28 +59,35 @@ class mod_quiz_local_structure_slot_random_test extends advanced_testcase { $form->defaultmark = 1; $form->status = \core_question\local\bank\constants::QUESTION_STATUS_HIDDEN; $form->stamp = make_unique_id_code(); - $question = new stdClass(); - $question->qtype = 'random'; - $question = question_bank::get_qtype('random')->save_question($question, $form); + // Set the filter conditions. + $filtercondition = new stdClass(); + $filtercondition->questioncategoryid = $category->id; + $filtercondition->includingsubcategories = 1; + + // Slot data. $randomslotdata = new stdClass(); $randomslotdata->quizid = $quiz->id; - $randomslotdata->questionid = $question->id; - $randomslotdata->questioncategoryid = $category->id; - $randomslotdata->includingsubcategories = 1; $randomslotdata->maxmark = 1; + $randomslotdata->usingcontextid = context_module::instance($quiz->cmid)->id; + $randomslotdata->questionscontextid = $category->contextid; + // Insert the random question to the quiz. $randomslot = new \mod_quiz\local\structure\slot_random($randomslotdata); + $randomslot->set_filter_condition($filtercondition); $rc = new ReflectionClass('\mod_quiz\local\structure\slot_random'); + $rcp = $rc->getProperty('filtercondition'); + $rcp->setAccessible(true); + $record = json_decode($rcp->getValue($randomslot)); + + $this->assertEquals($quiz->id, $randomslot->get_quiz()->id); + $this->assertEquals($category->id, $record->questioncategoryid); + $this->assertEquals(1, $record->includingsubcategories); + $rcp = $rc->getProperty('record'); $rcp->setAccessible(true); $record = $rcp->getValue($randomslot); - - $this->assertEquals($quiz->id, $record->quizid); - $this->assertEquals($question->id, $record->questionid); - $this->assertEquals($category->id, $record->questioncategoryid); - $this->assertEquals(1, $record->includingsubcategories); $this->assertEquals(1, $record->maxmark); } @@ -100,17 +107,20 @@ class mod_quiz_local_structure_slot_random_test extends advanced_testcase { quiz_add_random_questions($quiz, 0, $category->id, 1, false); - // Get the random question's id. It is at the first slot. - $questionid = $DB->get_field('quiz_slots', 'questionid', array('quizid' => $quiz->id, 'slot' => 1)); + // Set the filter conditions. + $filtercondition = new stdClass(); + $filtercondition->questioncategoryid = $category->id; + $filtercondition->includingsubcategories = 1; + // Slot data. $randomslotdata = new stdClass(); $randomslotdata->quizid = $quiz->id; - $randomslotdata->questionid = $questionid; - $randomslotdata->questioncategoryid = $category->id; - $randomslotdata->includingsubcategories = 1; $randomslotdata->maxmark = 1; + $randomslotdata->usingcontextid = context_module::instance($quiz->cmid)->id; + $randomslotdata->questionscontextid = $category->contextid; $randomslot = new \mod_quiz\local\structure\slot_random($randomslotdata); + $randomslot->set_filter_condition($filtercondition); // The create_instance had injected an additional cmid propery to the quiz. Let's remove that. unset($quiz->cmid); @@ -134,17 +144,20 @@ class mod_quiz_local_structure_slot_random_test extends advanced_testcase { quiz_add_random_questions($quiz, 0, $category->id, 1, false); - // Get the random question's id. It is at the first slot. - $questionid = $DB->get_field('quiz_slots', 'questionid', array('quizid' => $quiz->id, 'slot' => 1)); + // Set the filter conditions. + $filtercondition = new stdClass(); + $filtercondition->questioncategoryid = $category->id; + $filtercondition->includingsubcategories = 1; + // Slot data. $randomslotdata = new stdClass(); $randomslotdata->quizid = $quiz->id; - $randomslotdata->questionid = $questionid; - $randomslotdata->questioncategoryid = $category->id; - $randomslotdata->includingsubcategories = 1; $randomslotdata->maxmark = 1; + $randomslotdata->usingcontextid = context_module::instance($quiz->cmid)->id; + $randomslotdata->questionscontextid = $category->contextid; $randomslot = new \mod_quiz\local\structure\slot_random($randomslotdata); + $randomslot->set_filter_condition($filtercondition); // The create_instance had injected an additional cmid propery to the quiz. Let's remove that. unset($quiz->cmid); @@ -172,15 +185,12 @@ class mod_quiz_local_structure_slot_random_test extends advanced_testcase { quiz_add_random_questions($quiz, 0, $category->id, 1, false); - // Get the random question's id. It is at the first slot. - $questionid = $DB->get_field('quiz_slots', 'questionid', array('quizid' => $quiz->id, 'slot' => 1)); - + // Slot data. $randomslotdata = new stdClass(); $randomslotdata->quizid = $quiz->id; - $randomslotdata->questionid = $questionid; - $randomslotdata->questioncategoryid = $category->id; - $randomslotdata->includingsubcategories = 1; $randomslotdata->maxmark = 1; + $randomslotdata->usingcontextid = context_module::instance($quiz->cmid)->id; + $randomslotdata->questionscontextid = $category->contextid; $randomslot = new \mod_quiz\local\structure\slot_random($randomslotdata); @@ -203,17 +213,19 @@ class mod_quiz_local_structure_slot_random_test extends advanced_testcase { $this->setAdminUser(); list($randomslot, $tags) = $this->setup_for_test_tags(['foo', 'bar']); + $filtercondition = new stdClass(); $randomslot->set_tags([$tags['foo'], $tags['bar']]); + $randomslot->set_filter_condition($filtercondition); $rc = new ReflectionClass('\mod_quiz\local\structure\slot_random'); - $rcp = $rc->getProperty('tags'); + $rcp = $rc->getProperty('filtercondition'); $rcp->setAccessible(true); $tagspropery = $rcp->getValue($randomslot); $this->assertEquals([ $tags['foo']->id => $tags['foo'], $tags['bar']->id => $tags['bar'], - ], $tagspropery); + ], (array)json_decode($tagspropery)->tags); } public function test_set_tags_twice() { @@ -223,18 +235,20 @@ class mod_quiz_local_structure_slot_random_test extends advanced_testcase { list($randomslot, $tags) = $this->setup_for_test_tags(['foo', 'bar', 'baz']); // Set tags for the first time. + $filtercondition = new stdClass(); $randomslot->set_tags([$tags['foo'], $tags['bar']]); // Now set the tags again. $randomslot->set_tags([$tags['baz']]); + $randomslot->set_filter_condition($filtercondition); $rc = new ReflectionClass('\mod_quiz\local\structure\slot_random'); - $rcp = $rc->getProperty('tags'); + $rcp = $rc->getProperty('filtercondition'); $rcp->setAccessible(true); $tagspropery = $rcp->getValue($randomslot); $this->assertEquals([ $tags['baz']->id => $tags['baz'], - ], $tagspropery); + ], (array)json_decode($tagspropery)->tags); } public function test_set_tags_duplicates() { @@ -242,18 +256,19 @@ class mod_quiz_local_structure_slot_random_test extends advanced_testcase { $this->setAdminUser(); list($randomslot, $tags) = $this->setup_for_test_tags(['foo', 'bar', 'baz']); - + $filtercondition = new stdClass(); $randomslot->set_tags([$tags['foo'], $tags['bar'], $tags['foo']]); + $randomslot->set_filter_condition($filtercondition); $rc = new ReflectionClass('\mod_quiz\local\structure\slot_random'); - $rcp = $rc->getProperty('tags'); + $rcp = $rc->getProperty('filtercondition'); $rcp->setAccessible(true); $tagspropery = $rcp->getValue($randomslot); $this->assertEquals([ $tags['foo']->id => $tags['foo'], $tags['bar']->id => $tags['bar'], - ], $tagspropery); + ], (array)json_decode($tagspropery)->tags); } public function test_set_tags_by_id() { @@ -261,8 +276,9 @@ class mod_quiz_local_structure_slot_random_test extends advanced_testcase { $this->setAdminUser(); list($randomslot, $tags) = $this->setup_for_test_tags(['foo', 'bar', 'baz']); - + $filtercondition = new stdClass(); $randomslot->set_tags_by_id([$tags['foo']->id, $tags['bar']->id]); + $randomslot->set_filter_condition($filtercondition); $rc = new ReflectionClass('\mod_quiz\local\structure\slot_random'); $rcp = $rc->getProperty('tags'); @@ -357,9 +373,6 @@ class mod_quiz_local_structure_slot_random_test extends advanced_testcase { $form->defaultmark = 1; $form->status = \core_question\local\bank\constants::QUESTION_STATUS_HIDDEN; $form->stamp = make_unique_id_code(); - $question = new stdClass(); - $question->qtype = 'random'; - $question = question_bank::get_qtype('random')->save_question($question, $form); // Prepare 2 tags. $tagrecord = array( @@ -377,36 +390,43 @@ class mod_quiz_local_structure_slot_random_test extends advanced_testcase { ); $bartag = $this->getDataGenerator()->create_tag($tagrecord); + + // Set the filter conditions. + $filtercondition = new stdClass(); + $filtercondition->questioncategoryid = $category->id; + $filtercondition->includingsubcategories = 1; + + // Slot data. $randomslotdata = new stdClass(); $randomslotdata->quizid = $quiz->id; - $randomslotdata->questionid = $question->id; - $randomslotdata->questioncategoryid = $category->id; - $randomslotdata->includingsubcategories = 1; $randomslotdata->maxmark = 1; + $randomslotdata->usingcontextid = context_module::instance($quiz->cmid)->id; + $randomslotdata->questionscontextid = $category->contextid; // Insert the random question to the quiz. $randomslot = new \mod_quiz\local\structure\slot_random($randomslotdata); $randomslot->set_tags([$footag, $bartag]); + $randomslot->set_filter_condition($filtercondition); $randomslot->insert(1); // Put the question on the first page of the quiz. // Get the random question's quiz_slot. It is at the first slot. $quizslot = $DB->get_record('quiz_slots', array('quizid' => $quiz->id, 'slot' => 1)); // Get the random question's tags from quiz_slot_tags. It is at the first slot. - $quizslottags = $DB->get_records('quiz_slot_tags', array('slotid' => $quizslot->id)); + $setreference = \mod_quiz\question\bank\qbank_helper::get_random_question_data_from_slot($quizslot->id); - $this->assertEquals($question->id, $quizslot->questionid); - $this->assertEquals($category->id, $quizslot->questioncategoryid); - $this->assertEquals(1, $quizslot->includingsubcategories); + $this->assertEquals($category->id, json_decode($setreference->filtercondition)->questioncategoryid); + $this->assertEquals(1, json_decode($setreference->filtercondition)->includingsubcategories); $this->assertEquals(1, $quizslot->maxmark); + $tagspropery = (array)json_decode($setreference->filtercondition)->tags; - $this->assertCount(2, $quizslottags); + $this->assertCount(2, $tagspropery); $this->assertEqualsCanonicalizing( [ ['tagid' => $footag->id, 'tagname' => $footag->name], ['tagid' => $bartag->id, 'tagname' => $bartag->name] ], array_map(function($slottag) { - return ['tagid' => $slottag->tagid, 'tagname' => $slottag->tagname]; - }, $quizslottags)); + return ['tagid' => $slottag->id, 'tagname' => $slottag->name]; + }, $tagspropery)); } } diff --git a/mod/quiz/tests/locallib_test.php b/mod/quiz/tests/locallib_test.php index f05c6748034..4c068c2b9d4 100644 --- a/mod/quiz/tests/locallib_test.php +++ b/mod/quiz/tests/locallib_test.php @@ -521,15 +521,13 @@ class locallib_test extends \advanced_testcase { // Get the random question's slotid. It is at the second slot. $slotid = $DB->get_field('quiz_slots', 'id', array('quizid' => $quiz->id, 'slot' => 2)); $slottags = quiz_retrieve_slot_tags($slotid); + sort($slottags); - $this->assertEqualsCanonicalizing( + $this->assertEquals( [ - ['tagid' => $tags['foo']->id, 'tagname' => $tags['foo']->name], - ['tagid' => $tags['bar']->id, 'tagname' => $tags['bar']->name] - ], - array_map(function($slottag) { - return ['tagid' => $slottag->tagid, 'tagname' => $slottag->tagname]; - }, $slottags)); + "{$tags['foo']->id},{$tags['foo']->name}", + "{$tags['bar']->id},{$tags['bar']->name}", + ], $slottags); } public function test_quiz_retrieve_slot_tags_with_removed_tag() { @@ -547,15 +545,15 @@ class locallib_test extends \advanced_testcase { // Now remove the foo tag and check again. \core_tag_tag::delete_tags([$tags['foo']->id]); $slottags = quiz_retrieve_slot_tags($slotid); + sort($slottags); - $this->assertEqualsCanonicalizing( - [ - ['tagid' => null, 'tagname' => $tags['foo']->name], - ['tagid' => $tags['bar']->id, 'tagname' => $tags['bar']->name] - ], - array_map(function($slottag) { - return ['tagid' => $slottag->tagid, 'tagname' => $slottag->tagname]; - }, $slottags)); + $this->assertEquals( + [ + "{$tags['foo']->id},{$tags['foo']->name}", + "{$tags['bar']->id},{$tags['bar']->name}", + + ], + $slottags); } public function test_quiz_retrieve_slot_tags_for_standard_question() { @@ -603,246 +601,6 @@ class locallib_test extends \advanced_testcase { $this->assertEqualsCanonicalizing([], $tagids); } - /** - * Data provider for the get_random_question_summaries test. - */ - public function get_quiz_retrieve_tags_for_slot_ids_test_cases() { - return [ - 'no questions' => [ - 'questioncount' => 0, - 'randomquestioncount' => 0, - 'randomquestiontags' => [], - 'unusedtags' => [], - 'removeslottagids' => [], - 'expected' => [] - ], - 'only regular questions' => [ - 'questioncount' => 2, - 'randomquestioncount' => 0, - 'randomquestiontags' => [], - 'unusedtags' => ['unused1', 'unused2'], - 'removeslottagids' => [], - 'expected' => [ - 1 => [], - 2 => [] - ] - ], - 'only random questions 1' => [ - 'questioncount' => 0, - 'randomquestioncount' => 2, - 'randomquestiontags' => [ - 0 => ['foo'], - 1 => [] - ], - 'unusedtags' => ['unused1', 'unused2'], - 'removeslottagids' => [], - 'expected' => [ - 1 => ['foo'], - 2 => [] - ] - ], - 'only random questions 2' => [ - 'questioncount' => 0, - 'randomquestioncount' => 2, - 'randomquestiontags' => [ - 0 => ['foo', 'bop'], - 1 => ['bar'] - ], - 'unusedtags' => ['unused1', 'unused2'], - 'removeslottagids' => [], - 'expected' => [ - 1 => ['foo', 'bop'], - 2 => ['bar'] - ] - ], - 'only random questions 3' => [ - 'questioncount' => 0, - 'randomquestioncount' => 2, - 'randomquestiontags' => [ - 0 => ['foo', 'bop'], - 1 => ['bar', 'foo'] - ], - 'unusedtags' => ['unused1', 'unused2'], - 'removeslottagids' => [], - 'expected' => [ - 1 => ['foo', 'bop'], - 2 => ['bar', 'foo'] - ] - ], - 'combination of questions 1' => [ - 'questioncount' => 2, - 'randomquestioncount' => 2, - 'randomquestiontags' => [ - 0 => ['foo'], - 1 => [] - ], - 'unusedtags' => ['unused1', 'unused2'], - 'removeslottagids' => [], - 'expected' => [ - 1 => [], - 2 => [], - 3 => ['foo'], - 4 => [] - ] - ], - 'combination of questions 2' => [ - 'questioncount' => 2, - 'randomquestioncount' => 2, - 'randomquestiontags' => [ - 0 => ['foo', 'bop'], - 1 => ['bar'] - ], - 'unusedtags' => ['unused1', 'unused2'], - 'removeslottagids' => [], - 'expected' => [ - 1 => [], - 2 => [], - 3 => ['foo', 'bop'], - 4 => ['bar'] - ] - ], - 'combination of questions 3' => [ - 'questioncount' => 2, - 'randomquestioncount' => 2, - 'randomquestiontags' => [ - 0 => ['foo', 'bop'], - 1 => ['bar', 'foo'] - ], - 'unusedtags' => ['unused1', 'unused2'], - 'removeslottagids' => [], - 'expected' => [ - 1 => [], - 2 => [], - 3 => ['foo', 'bop'], - 4 => ['bar', 'foo'] - ] - ], - 'load from name 1' => [ - 'questioncount' => 2, - 'randomquestioncount' => 2, - 'randomquestiontags' => [ - 0 => ['foo'], - 1 => [] - ], - 'unusedtags' => ['unused1', 'unused2'], - 'removeslottagids' => [3], - 'expected' => [ - 1 => [], - 2 => [], - 3 => ['foo'], - 4 => [] - ] - ], - 'load from name 2' => [ - 'questioncount' => 2, - 'randomquestioncount' => 2, - 'randomquestiontags' => [ - 0 => ['foo', 'bop'], - 1 => ['bar'] - ], - 'unusedtags' => ['unused1', 'unused2'], - 'removeslottagids' => [3], - 'expected' => [ - 1 => [], - 2 => [], - 3 => ['foo', 'bop'], - 4 => ['bar'] - ] - ], - 'load from name 3' => [ - 'questioncount' => 2, - 'randomquestioncount' => 2, - 'randomquestiontags' => [ - 0 => ['foo', 'bop'], - 1 => ['bar', 'foo'] - ], - 'unusedtags' => ['unused1', 'unused2'], - 'removeslottagids' => [3], - 'expected' => [ - 1 => [], - 2 => [], - 3 => ['foo', 'bop'], - 4 => ['bar', 'foo'] - ] - ] - ]; - } - - /** - * Test the quiz_retrieve_tags_for_slot_ids function with various parameter - * combinations. - * - * @dataProvider get_quiz_retrieve_tags_for_slot_ids_test_cases() - * @param int $questioncount The number of regular questions to create - * @param int $randomquestioncount The number of random questions to create - * @param array $randomquestiontags The tags for the random questions - * @param string[] $unusedtags Additional tags to create to populate the DB with data - * @param int[] $removeslottagids Slot numbers to remove tag ids for - * @param array $expected The expected output of tag names indexed by slot number - */ - public function test_quiz_retrieve_tags_for_slot_ids_combinations( - $questioncount, - $randomquestioncount, - $randomquestiontags, - $unusedtags, - $removeslottagids, - $expected - ) { - global $DB; - - $this->resetAfterTest(); - $this->setAdminUser(); - - list($quiz, $tags) = $this->setup_quiz_and_tags( - $questioncount, - $randomquestioncount, - $randomquestiontags, - $unusedtags - ); - - $slots = $DB->get_records('quiz_slots', ['quizid' => $quiz->id]); - $slotids = []; - $slotsbynumber = []; - foreach ($slots as $slot) { - $slotids[] = $slot->id; - $slotsbynumber[$slot->slot] = $slot; - } - - if (!empty($removeslottagids)) { - // The slots to remove are the slot numbers not the slot id so we need - // to get the ids for the DB call. - $idstonull = array_map(function($slot) use ($slotsbynumber) { - return $slotsbynumber[$slot]->id; - }, $removeslottagids); - list($sql, $params) = $DB->get_in_or_equal($idstonull); - // Null out the tagid column to force the code to look up the tag by name. - $DB->set_field_select('quiz_slot_tags', 'tagid', null, "slotid {$sql}", $params); - } - - $slottagsbyslotids = quiz_retrieve_tags_for_slot_ids($slotids); - // Convert the result into an associative array of slotid => [... tag names..] - // to make it easier to compare. - $actual = array_map(function($slottags) { - $names = array_map(function($slottag) { - return $slottag->tagname; - }, $slottags); - // Make sure the names are sorted for comparison. - sort($names); - return $names; - }, $slottagsbyslotids); - - $formattedexptected = []; - // The expected values are indexed by slot number rather than id so let - // convert it to use the id so that we can compare the results. - foreach ($expected as $slot => $tagnames) { - sort($tagnames); - $slotid = $slotsbynumber[$slot]->id; - $formattedexptected[$slotid] = $tagnames; - } - - $this->assertEquals($formattedexptected, $actual); - } - public function test_quiz_override_summary() { global $DB, $PAGE; $this->resetAfterTest(); diff --git a/mod/quiz/tests/qbank_helper_test.php b/mod/quiz/tests/qbank_helper_test.php new file mode 100644 index 00000000000..3fb59ac2606 --- /dev/null +++ b/mod/quiz/tests/qbank_helper_test.php @@ -0,0 +1,225 @@ +. + +namespace mod_quiz; + +use mod_quiz\external\submit_question_version; +use mod_quiz\question\bank\qbank_helper; + +defined('MOODLE_INTERNAL') || die(); + +require_once(__DIR__ . '/quiz_question_helper_test_trait.php'); + +/** + * Qbank helper test for quiz. + * + * @package mod_quiz + * @category test + * @copyright 2021 Catalyst IT Australia Pty Ltd + * @author Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @coversDefaultClass \mod_quiz\question\bank\qbank_helper + */ +class qbank_helper_test extends \advanced_testcase { + use \quiz_question_helper_test_trait; + + /** + * Called before every test. + */ + public function setUp(): void { + global $USER; + parent::setUp(); + $this->setAdminUser(); + $this->course = $this->getDataGenerator()->create_course(); + $this->student = $this->getDataGenerator()->create_user(); + $this->user = $USER; + } + + /** + * Test is random. + * + * @covers ::is_random + * @covers ::get_random_question_data_from_slot + */ + public function test_is_random() { + $this->resetAfterTest(); + $quiz = $this->create_test_quiz($this->course); + // Test for questions from a different context. + $context = \context_module::instance(get_coursemodule_from_instance("quiz", $quiz->id, $this->course->id)->id); + $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); + $this->add_random_questions($questiongenerator, $quiz, ['contextid' => $context->id]); + // Create the quiz object. + $quizobj = \quiz::create($quiz->id); + $structure = \mod_quiz\structure::create_for_quiz($quizobj); + $slots = $structure->get_slots(); + foreach ($slots as $slot) { + $this->assertEquals(true, qbank_helper::is_random($slot->id)); + // Test random data for slot. + $this->assertEquals($slot->id, qbank_helper::get_random_question_data_from_slot($slot->id)->itemid); + } + + } + + /** + * Test reference records. + * + * @covers ::get_version_options + * @covers ::get_question_for_redo + * @covers ::get_always_latest_version_question_ids + * @covers ::question_load_random_questions + * @covers ::question_array_sort + */ + public function test_reference_records() { + $this->resetAfterTest(); + $quiz = $this->create_test_quiz($this->course); + // Test for questions from a different context. + $context = \context_module::instance(get_coursemodule_from_instance("quiz", $quiz->id, $this->course->id)->id); + // Create a couple of questions. + $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); + $cat = $questiongenerator->create_question_category(['contextid' => $context->id]); + $numq = $questiongenerator->create_question('essay', null, + ['category' => $cat->id, 'name' => 'This is the first version']); + // Create two version. + $questiongenerator->update_question($numq, null, ['name' => 'This is the second version']); + $questiongenerator->update_question($numq, null, ['name' => 'This is the third version']); + quiz_add_quiz_question($numq->id, $quiz); + // Create the quiz object. + $quizobj = \quiz::create($quiz->id); + $quizobj->preload_questions(); + $quizobj->load_questions(); + $questions = $quizobj->get_questions(); + $question = reset($questions); + $structure = \mod_quiz\structure::create_for_quiz($quizobj); + $slots = $structure->get_slots(); + $slot = reset($slots); + $this->assertEquals(3, count(qbank_helper::get_version_options($question->id))); + $quizobj->preload_questions(); + $quizobj->load_questions(); + $questions = $quizobj->get_questions(); + $question = reset($questions); + $this->assertEquals($question->id, qbank_helper::get_question_for_redo($slot->id)); + // Create another version. + $questiongenerator->update_question($numq, null, ['name' => 'This is the latest version']); + // Change to always latest. + submit_question_version::execute($slot->id, 0); + $quizobj->preload_questions(); + $quizobj->load_questions(); + $questions = $quizobj->get_questions(); + $question = reset($questions); + $this->assertEquals($question->id, qbank_helper::get_question_for_redo($slot->id)); + // Test always latest version question ids. + $latestquestionids = qbank_helper::get_always_latest_version_question_ids($quiz->id); + $this->assertEquals($question->id, reset($latestquestionids)); + } + + /** + * Test question structure data. + * + * @covers ::get_question_structure + * @covers ::get_question_structure_data + * @covers ::question_array_sort + * @covers ::get_always_latest_version_question_ids + * @covers ::question_load_random_questions + */ + public function test_get_question_structure() { + $this->resetAfterTest(); + $quiz = $this->create_test_quiz($this->course); + // Test for questions from a different context. + $context = \context_module::instance(get_coursemodule_from_instance("quiz", $quiz->id, $this->course->id)->id); + // Create a couple of questions. + $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); + $cat = $questiongenerator->create_question_category(['contextid' => $context->id]); + $numq = $questiongenerator->create_question('essay', null, + ['category' => $cat->id, 'name' => 'This is the first version']); + // Create two version. + $questiongenerator->update_question($numq, null, ['name' => 'This is the second version']); + $questiongenerator->update_question($numq, null, ['name' => 'This is the third version']); + quiz_add_quiz_question($numq->id, $quiz); + // Create the quiz object. + $quizobj = \quiz::create($quiz->id); + $quizobj->preload_questions(); + $quizobj->load_questions(); + $questions = $quizobj->get_questions(); + $question = reset($questions); + $structure = \mod_quiz\structure::create_for_quiz($quizobj); + $slots = $structure->get_slots(); + $slot = reset($slots); + $structuredatas = qbank_helper::get_question_structure($quiz->id); + $structuredata = reset($structuredatas); + $this->assertEquals($structuredata->slotid, $slot->id); + $this->assertEquals($structuredata->id, $question->id); + } + + /** + * Test to get the version information for a question to show in the version selection dropdown. + * + * @covers ::get_question_version_info + */ + public function test_get_question_version_info() { + $this->resetAfterTest(); + $quiz = $this->create_test_quiz($this->course); + // Test for questions from a different context. + $context = \context_module::instance(get_coursemodule_from_instance("quiz", $quiz->id, $this->course->id)->id); + // Create a couple of questions. + $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); + $cat = $questiongenerator->create_question_category(['contextid' => $context->id]); + $numq = $questiongenerator->create_question('essay', null, + ['category' => $cat->id, 'name' => 'This is the first version']); + // Create two version. + $questiongenerator->update_question($numq, null, ['name' => 'This is the second version']); + $questiongenerator->update_question($numq, null, ['name' => 'This is the third version']); + quiz_add_quiz_question($numq->id, $quiz); + // Create the quiz object. + $quizobj = \quiz::create($quiz->id); + $quizobj->preload_questions(); + $quizobj->load_questions(); + $questions = $quizobj->get_questions(); + $question = reset($questions); + $structure = \mod_quiz\structure::create_for_quiz($quizobj); + $slots = $structure->get_slots(); + $slot = reset($slots); + $versiondata = qbank_helper::get_question_version_info($question->id, $slot->id); + $this->assertEquals(4, count($versiondata)); + $this->assertEquals('Always latest', $versiondata[0]->versionvalue); + $this->assertEquals('v3 (latest)', $versiondata[1]->versionvalue); + $this->assertEquals('v1', $versiondata[3]->versionvalue); + } + + /** + * Test get the question ids for specific question version. + * + * @covers ::get_specific_version_question_ids + */ + public function test_get_specific_version_question_ids() { + $this->resetAfterTest(); + $quiz = $this->create_test_quiz($this->course); + // Test for questions from a different context. + $context = \context_module::instance(get_coursemodule_from_instance("quiz", $quiz->id, $this->course->id)->id); + // Create a couple of questions. + $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); + $cat = $questiongenerator->create_question_category(['contextid' => $context->id]); + $numq = $questiongenerator->create_question('essay', null, + ['category' => $cat->id, 'name' => 'This is the first version']); + // Create two version. + $questiongenerator->update_question($numq, null, ['name' => 'This is the second version']); + $questiongenerator->update_question($numq, null, ['name' => 'This is the third version']); + quiz_add_quiz_question($numq->id, $quiz); + $specificversionquestionid = qbank_helper::get_specific_version_question_ids($quiz->id); + $specificversionquestionid = reset($specificversionquestionid); + $this->assertEquals($numq->id, $specificversionquestionid); + } + +} diff --git a/mod/quiz/tests/quiz_question_helper_test_trait.php b/mod/quiz/tests/quiz_question_helper_test_trait.php new file mode 100644 index 00000000000..8237def5321 --- /dev/null +++ b/mod/quiz/tests/quiz_question_helper_test_trait.php @@ -0,0 +1,182 @@ +. + +/** + * Helper trait for quiz question unit tests. + * + * This trait helps to execute different tests for quiz, for example if it needs to create a quiz, add question + * to the question, add random quetion to the quiz, do a backup or restore. + * + * @package mod_quiz + * @category test + * @copyright 2021 Catalyst IT Australia Pty Ltd + * @author Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +trait quiz_question_helper_test_trait { + + /** @var \stdClass $course Test course to contain quiz. */ + protected $course; + + /** @var \stdClass $quiz A test quiz. */ + protected $quiz; + + /** @var \stdClass $user A test logged-in user. */ + protected $user; + + /** + * Create a test quiz for the specified course. + * + * @param \stdClass $course + * @return \stdClass + */ + protected function create_test_quiz(\stdClass $course): \stdClass { + + $quizgenerator = $this->getDataGenerator()->get_plugin_generator('mod_quiz'); + + $quiz = $quizgenerator->create_instance([ + 'course' => $course->id, + 'questionsperpage' => 0, + 'grade' => 100.0, + 'sumgrades' => 2, + ]); + $quiz->coursemodule = $quiz->cmid; + return $quiz; + } + + /** + * Helper method to add regular questions in quiz. + * + * @param component_generator_base $questiongenerator + * @param \stdClass $quiz + * @param array $override + */ + protected function add_regular_questions($questiongenerator, \stdClass $quiz, $override = null): void { + // Create a couple of questions. + $cat = $questiongenerator->create_question_category($override); + + $saq = $questiongenerator->create_question('shortanswer', null, array('category' => $cat->id)); + // Create another version. + $questiongenerator->update_question($saq); + quiz_add_quiz_question($saq->id, $quiz); + $numq = $questiongenerator->create_question('numerical', null, array('category' => $cat->id)); + // Create two version. + $questiongenerator->update_question($numq); + $questiongenerator->update_question($numq); + quiz_add_quiz_question($numq->id, $quiz); + } + + /** + * Helper method to add random question to quiz. + * + * @param component_generator_base $questiongenerator + * @param \stdClass $quiz + * @param array $override + */ + protected function add_random_questions($questiongenerator, \stdClass $quiz, $override = []): void { + // Create a random question. + $cat = $questiongenerator->create_question_category($override); + $questiongenerator->create_question('truefalse', null, array('category' => $cat->id)); + $questiongenerator->create_question('essay', null, array('category' => $cat->id)); + quiz_add_random_questions($quiz, 0, $cat->id, 1, false); + } + + /** + * Attempt questions for a quiz and user. + * + * @param \stdClass $quiz Quiz to attempt. + * @param \stdClass $user A user to attempt the quiz. + * @param int $attemptnumber + * @return array + */ + protected function attempt_quiz(\stdClass $quiz, \stdClass $user, $attemptnumber = 1): array { + $this->setUser($user); + + $starttime = time(); + $quizobj = \quiz::create($quiz->id, $user->id); + + $quba = \question_engine::make_questions_usage_by_activity('mod_quiz', $quizobj->get_context()); + $quba->set_preferred_behaviour($quizobj->get_quiz()->preferredbehaviour); + + // Start the attempt. + $attempt = quiz_create_attempt($quizobj, $attemptnumber, false, $starttime, false, $user->id); + quiz_start_new_attempt($quizobj, $quba, $attempt, $attemptnumber, $starttime); + quiz_attempt_save_started($quizobj, $quba, $attempt); + + // Finish the attempt. + $attemptobj = \quiz_attempt::create($attempt->id); + $attemptobj->process_finish($starttime, false); + + $this->setUser(); + return [$quizobj, $quba, $attemptobj]; + } + + /** + * A helper method to backup test quiz. + * + * @param \stdClass $quiz Quiz to attempt. + * @param \stdClass $user A user to attempt the quiz. + * @return string A backup ID ready to be restored. + */ + protected function backup_quiz(\stdClass $quiz, \stdClass $user): string { + global $CFG; + + // Get the necessary files to perform backup and restore. + require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php'); + require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php'); + + $backupid = 'test-question-backup-restore'; + + $bc = new backup_controller(backup::TYPE_1ACTIVITY, $quiz->coursemodule, backup::FORMAT_MOODLE, + backup::INTERACTIVE_NO, backup::MODE_GENERAL, $user->id); + $bc->execute_plan(); + + $results = $bc->get_results(); + $file = $results['backup_destination']; + $fp = get_file_packer('application/vnd.moodle.backup'); + $filepath = $CFG->dataroot . '/temp/backup/' . $backupid; + $file->extract_to_pathname($fp, $filepath); + $bc->destroy(); + + return $backupid; + } + + /** + * A helper method to restore provided backup. + * + * @param string $backupid Backup ID to restore. + * @param stdClass $course + * @param stdClass $user + */ + protected function restore_quiz(string $backupid, stdClass $course, stdClass $user): void { + $rc = new restore_controller($backupid, $course->id, + backup::INTERACTIVE_NO, backup::MODE_GENERAL, $user->id, backup::TARGET_CURRENT_ADDING); + $this->assertTrue($rc->execute_precheck()); + $rc->execute_plan(); + $rc->destroy(); + } + + /** + * A helper method to emulate duplication of the quiz. + * + * @param stdClass $course + * @param stdClass $quiz + * @return \cm_info|null + */ + protected function duplicate_quiz($course, $quiz): ?\cm_info { + return duplicate_module($course, get_fast_modinfo($course)->get_cm($quiz->cmid)); + } +} diff --git a/mod/quiz/tests/quiz_question_restore_test.php b/mod/quiz/tests/quiz_question_restore_test.php new file mode 100644 index 00000000000..0825e210d55 --- /dev/null +++ b/mod/quiz/tests/quiz_question_restore_test.php @@ -0,0 +1,286 @@ +. + +namespace mod_quiz; + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once(__DIR__ . '/quiz_question_helper_test_trait.php'); +require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php'); +require_once($CFG->dirroot . '/mod/quiz/locallib.php'); + +/** + * Quiz backup and restore tests. + * + * @package mod_quiz + * @category test + * @copyright 2021 Catalyst IT Australia Pty Ltd + * @author Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @coversDefaultClass \mod_quiz\question\bank\qbank_helper + * @coversDefaultClass \backup_quiz_activity_structure_step + * @coversDefaultClass \restore_quiz_activity_structure_step + */ +class quiz_question_restore_test extends \advanced_testcase { + use \quiz_question_helper_test_trait; + + /** + * Called before every test. + */ + public function setUp(): void { + global $USER; + parent::setUp(); + $this->setAdminUser(); + $this->course = $this->getDataGenerator()->create_course(); + $this->student = $this->getDataGenerator()->create_user(); + $this->user = $USER; + } + + /** + * Test a quiz backup and restore in a different course without attempts for course question bank. + * + * @covers ::get_question_structure + */ + public function test_quiz_restore_in_a_different_course_using_course_question_bank() { + global $DB; + $this->resetAfterTest(); + $quiz = $this->create_test_quiz($this->course); + // Test for questions from a different context. + $context = \context_course::instance($this->course->id); + $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); + $this->add_regular_questions($questiongenerator, $quiz, ['contextid' => $context->id]); + $this->add_random_questions($questiongenerator, $quiz, ['contextid' => $context->id]); + $backupid = $this->backup_quiz($quiz, $this->user); + // Delete the current course to make sure there is no data. + delete_course($this->course, false); + // Check if the questions and associated datas are deleted properly. + $this->assertEquals(0, count(\mod_quiz\question\bank\qbank_helper::get_question_structure($quiz->id))); + $newcourse = $this->getDataGenerator()->create_course(); + $this->restore_quiz($backupid, $newcourse, $this->user); + $module = $DB->get_record('quiz', ['course' => $newcourse->id]); + $this->assertEquals(3, count(\mod_quiz\question\bank\qbank_helper::get_question_structure($module->id))); + } + + /** + * Test a quiz backup and restore in a different course without attempts for quiz question bank. + * + * @covers ::get_question_structure + */ + public function test_quiz_restore_in_a_different_course_using_quiz_question_bank() { + global $DB; + $this->resetAfterTest(); + $quiz = $this->create_test_quiz($this->course); + // Test for questions from a different context. + $context = \context_module::instance(get_coursemodule_from_instance("quiz", $quiz->id, $this->course->id)->id); + $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); + $this->add_regular_questions($questiongenerator, $quiz, ['contextid' => $context->id]); + $this->add_random_questions($questiongenerator, $quiz, ['contextid' => $context->id]); + $backupid = $this->backup_quiz($quiz, $this->user); + // Delete the current course to make sure there is no data. + delete_course($this->course, false); + // Check if the questions and associated datas are deleted properly. + $this->assertEquals(0, count(\mod_quiz\question\bank\qbank_helper::get_question_structure($quiz->id))); + $newcourse = $this->getDataGenerator()->create_course(); + $this->restore_quiz($backupid, $newcourse, $this->user); + $module = $DB->get_record('quiz', ['course' => $newcourse->id]); + $this->assertEquals(3, count(\mod_quiz\question\bank\qbank_helper::get_question_structure($module->id))); + } + + /** + * Count the questions for the context. + * + * @param int $context + * @param string $extracondition + * @return int + */ + protected function question_count($context, $extracondition = ''): int { + global $DB; + return $DB->count_records_sql( + "SELECT COUNT(q.id) + 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 = ? + $extracondition", [$context]); + } + + /** + * Test if a duplicate does not duplicate questions in course question bank. + * + * @covers ::duplicate_module + */ + public function test_quiz_duplicate_does_not_duplicate_course_question_bank_questions() { + $this->resetAfterTest(); + $quiz = $this->create_test_quiz($this->course); + // Test for questions from a different context. + $context = \context_course::instance($this->course->id); + $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); + $this->add_regular_questions($questiongenerator, $quiz, ['contextid' => $context->id]); + $this->add_random_questions($questiongenerator, $quiz, ['contextid' => $context->id]); + // Count the questions in course context. + $this->assertEquals(7, $this->question_count($context->id)); + $newquiz = $this->duplicate_quiz($this->course, $quiz); + $this->assertEquals(7, $this->question_count($context->id)); + $context = \context_module::instance($newquiz->id); + // Count the questions in the quiz context. + $this->assertEquals(0, $this->question_count($context->id)); + } + + /** + * Test quiz duplicate for quiz question bank. + * + * @covers ::duplicate_module + */ + public function test_quiz_duplicate_for_quiz_question_bank_questions() { + $this->resetAfterTest(); + $quiz = $this->create_test_quiz($this->course); + // Test for questions from a different context. + $context = \context_module::instance(get_coursemodule_from_instance("quiz", $quiz->id, $this->course->id)->id); + $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); + $this->add_regular_questions($questiongenerator, $quiz, ['contextid' => $context->id]); + $this->add_random_questions($questiongenerator, $quiz, ['contextid' => $context->id]); + // Count the questions in course context. + $this->assertEquals(7, $this->question_count($context->id)); + $newquiz = $this->duplicate_quiz($this->course, $quiz); + $this->assertEquals(7, $this->question_count($context->id)); + $context = \context_module::instance($newquiz->id); + // Count the questions in the quiz context. + $this->assertEquals(7, $this->question_count($context->id)); + } + + /** + * Test quiz restore with attempts. + * + * @covers ::get_question_structure + */ + public function test_quiz_restore_with_attempts() { + global $DB; + $this->resetAfterTest(); + $quiz = $this->create_test_quiz($this->course); + // Test for questions from a different context. + $context = \context_module::instance(get_coursemodule_from_instance("quiz", $quiz->id, $this->course->id)->id); + $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); + $this->add_regular_questions($questiongenerator, $quiz, ['contextid' => $context->id]); + $this->add_random_questions($questiongenerator, $quiz, ['contextid' => $context->id]); + list($quizobj, $quba, $attemptobj) = $this->attempt_quiz($quiz, $this->student); + $userattempts = quiz_get_user_attempts($quiz->id, $this->student->id); + // Count the attempts for this quiz. + $this->assertEquals(3, $quba->question_count()); + $this->assertEquals(1, count($userattempts)); + $backupid = $this->backup_quiz($quiz, $this->user); + // Delete the current course to make sure there is no data. + delete_course($this->course, false); + $newcourse = $this->getDataGenerator()->create_course(); + $this->restore_quiz($backupid, $newcourse, $this->user); + $module = $DB->get_record('quiz', ['course' => $newcourse->id]); + $userattempts = quiz_get_user_attempts($module->id, $this->student->id); + $this->assertEquals(1, count($userattempts)); + $this->assertEquals(3, count(\mod_quiz\question\bank\qbank_helper::get_question_structure($module->id))); + } + + /** + * Test pre 4.0 quiz restore for regular questions. + * + * @covers ::process_quiz_question_legacy_instance + */ + public function test_pre_4_quiz_restore_for_regular_questions() { + global $USER, $DB; + $this->resetAfterTest(); + $backupid = 'abc'; + $backuppath = make_backup_temp_directory($backupid); + get_file_packer('application/vnd.moodle.backup')->extract_to_pathname( + __DIR__ . "/fixtures/moodle_28_quiz.mbz", $backuppath); + + // Do the restore to new course with default settings. + $categoryid = $DB->get_field_sql("SELECT MIN(id) FROM {course_categories}"); + $newcourseid = \restore_dbops::create_new_course('Test fullname', 'Test shortname', $categoryid); + $rc = new \restore_controller($backupid, $newcourseid, \backup::INTERACTIVE_NO, \backup::MODE_GENERAL, $USER->id, + \backup::TARGET_NEW_COURSE); + + $this->assertTrue($rc->execute_precheck()); + $rc->execute_plan(); + $rc->destroy(); + + // Get the information about the resulting course and check that it is set up correctly. + $modinfo = get_fast_modinfo($newcourseid); + $quiz = array_values($modinfo->get_instances_of('quiz'))[0]; + $quizobj = \quiz::create($quiz->instance); + $structure = \mod_quiz\structure::create_for_quiz($quizobj); + + // Are the correct slots returned? + $slots = $structure->get_slots(); + $this->assertCount(2, $slots); + + $quizobj->preload_questions(); + $quizobj->load_questions(); + $questions = $quizobj->get_questions(); + $this->assertCount(2, $questions); + + // Count the questions in quiz qbank. + $context = \context_module::instance(get_coursemodule_from_instance("quiz", $quizobj->get_quizid(), $newcourseid)->id); + $this->assertEquals(2, $this->question_count($context->id)); + } + + /** + * Test pre 4.0 quiz restore for random questions. + * + * @covers ::process_quiz_question_legacy_instance + */ + public function test_pre_4_quiz_restore_for_random_questions() { + global $USER, $DB; + $this->resetAfterTest(); + $backupid = 'abc'; + $backuppath = make_backup_temp_directory($backupid); + get_file_packer('application/vnd.moodle.backup')->extract_to_pathname( + __DIR__ . "/fixtures/random_by_tag_quiz.mbz", $backuppath); + + // Do the restore to new course with default settings. + $categoryid = $DB->get_field_sql("SELECT MIN(id) FROM {course_categories}"); + $newcourseid = \restore_dbops::create_new_course('Test fullname', 'Test shortname', $categoryid); + $rc = new \restore_controller($backupid, $newcourseid, \backup::INTERACTIVE_NO, \backup::MODE_GENERAL, $USER->id, + \backup::TARGET_NEW_COURSE); + + $this->assertTrue($rc->execute_precheck()); + $rc->execute_plan(); + $rc->destroy(); + + // Get the information about the resulting course and check that it is set up correctly. + $modinfo = get_fast_modinfo($newcourseid); + $quiz = array_values($modinfo->get_instances_of('quiz'))[0]; + $quizobj = \quiz::create($quiz->instance); + $structure = \mod_quiz\structure::create_for_quiz($quizobj); + + // Are the correct slots returned? + $slots = $structure->get_slots(); + $this->assertCount(1, $slots); + + $quizobj->preload_questions(); + $quizobj->load_questions(); + $questions = $quizobj->get_questions(); + $this->assertCount(1, $questions); + + // Count the questions for course question bank. + $this->assertEquals(6, $this->question_count(\context_course::instance($newcourseid)->id)); + $this->assertEquals(6, $this->question_count(\context_course::instance($newcourseid)->id, + "AND q.qtype <> 'random'")); + + // Count the questions in quiz qbank. + $context = \context_module::instance(get_coursemodule_from_instance("quiz", $quizobj->get_quizid(), $newcourseid)->id); + $this->assertEquals(0, $this->question_count($context->id)); + } +} diff --git a/mod/quiz/tests/quiz_question_version_test.php b/mod/quiz/tests/quiz_question_version_test.php new file mode 100644 index 00000000000..97c43a3aad6 --- /dev/null +++ b/mod/quiz/tests/quiz_question_version_test.php @@ -0,0 +1,185 @@ +. + +namespace mod_quiz; + +use mod_quiz\external\submit_question_version; +use mod_quiz\question\bank\qbank_helper; + +defined('MOODLE_INTERNAL') || die(); + +require_once(__DIR__ . '/quiz_question_helper_test_trait.php'); + +/** + * Question versions test for quiz. + * + * @package mod_quiz + * @category test + * @copyright 2021 Catalyst IT Australia Pty Ltd + * @author Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @coversDefaultClass \mod_quiz\question\bank\qbank_helper + */ +class quiz_question_version_test extends \advanced_testcase { + use \quiz_question_helper_test_trait; + + /** + * Called before every test. + */ + public function setUp(): void { + global $USER; + parent::setUp(); + $this->setAdminUser(); + $this->course = $this->getDataGenerator()->create_course(); + $this->student = $this->getDataGenerator()->create_user(); + $this->user = $USER; + } + + /** + * Test the quiz question data for changed version in the slots. + * + * @covers ::get_version_options + */ + public function test_quiz_questions_for_changed_versions() { + $this->resetAfterTest(); + $quiz = $this->create_test_quiz($this->course); + // Test for questions from a different context. + $context = \context_module::instance(get_coursemodule_from_instance("quiz", $quiz->id, $this->course->id)->id); + $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); + // Create a couple of questions. + $cat = $questiongenerator->create_question_category(['contextid' => $context->id]); + $numq = $questiongenerator->create_question('essay', null, + ['category' => $cat->id, 'name' => 'This is the first version']); + // Create two version. + $questiongenerator->update_question($numq, null, ['name' => 'This is the second version']); + $questiongenerator->update_question($numq, null, ['name' => 'This is the third version']); + quiz_add_quiz_question($numq->id, $quiz); + // Create the quiz object. + $quizobj = \quiz::create($quiz->id); + $structure = \mod_quiz\structure::create_for_quiz($quizobj); + $slots = $structure->get_slots(); + $slot = reset($slots); + // Test that the version added is the latest version, as there are three created. + $this->assertEquals(3, $slot->version); + $quizobj->preload_questions(); + $quizobj->load_questions(); + $questions = $quizobj->get_questions(); + $question = reset($questions); + $this->assertEquals(3, $question->version); + $this->assertEquals('This is the third version', $question->name); + // Now change the version using the external service. + $versions = qbank_helper::get_version_options($slot->questionid); + // We dont want the current version. + $selectversions = []; + foreach ($versions as $version) { + if ($version->version === $slot->version) { + continue; + } + $selectversions [$version->version] = $version; + } + // Change to version 1. + submit_question_version::execute($slot->id, (int)$selectversions[1]->version); + $quizobj->preload_questions(); + $quizobj->load_questions(); + $questions = $quizobj->get_questions(); + $question = reset($questions); + $this->assertEquals(1, $question->version); + $this->assertEquals('This is the first version', $question->name); + $structure = \mod_quiz\structure::create_for_quiz($quizobj); + $slots = $structure->get_slots(); + $slot = reset($slots); + $this->assertEquals(1, $slot->version); + // Change to version 2. + submit_question_version::execute($slot->id, $selectversions[2]->version); + $quizobj->preload_questions(); + $quizobj->load_questions(); + $questions = $quizobj->get_questions(); + $question = reset($questions); + $this->assertEquals(2, $question->version); + $this->assertEquals('This is the second version', $question->name); + $structure = \mod_quiz\structure::create_for_quiz($quizobj); + $slots = $structure->get_slots(); + $slot = reset($slots); + $this->assertEquals(2, $slot->version); + // Create another version. + $questiongenerator->update_question($numq, null, ['name' => 'This is the latest version']); + // Change to always latest. + submit_question_version::execute($slot->id, 0); + $quizobj->preload_questions(); + $quizobj->load_questions(); + $questions = $quizobj->get_questions(); + $question = reset($questions); + $this->assertEquals(4, $question->version); + $this->assertEquals('This is the latest version', $question->name); + $structure = \mod_quiz\structure::create_for_quiz($quizobj); + $slots = $structure->get_slots(); + $slot = reset($slots); + $this->assertEquals(4, $slot->version); + } + + /** + * Test if changing the version of the slot changes the attempts. + * + * @covers ::get_version_options + */ + public function test_quiz_question_attempts_with_changed_version() { + $this->resetAfterTest(); + $quiz = $this->create_test_quiz($this->course); + // Test for questions from a different context. + $context = \context_module::instance(get_coursemodule_from_instance("quiz", $quiz->id, $this->course->id)->id); + $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); + // Create a couple of questions. + $cat = $questiongenerator->create_question_category(['contextid' => $context->id]); + $numq = $questiongenerator->create_question('numerical', null, + ['category' => $cat->id, 'name' => 'This is the first version']); + // Create two version. + $questiongenerator->update_question($numq, null, ['name' => 'This is the second version']); + $questiongenerator->update_question($numq, null, ['name' => 'This is the third version']); + quiz_add_quiz_question($numq->id, $quiz); + list($quizobj, $quba, $attemptobj) = $this->attempt_quiz($quiz, $this->student); + $this->assertEquals('This is the third version', $attemptobj->get_question_attempt(1)->get_question()->name); + // Create the quiz object. + $quizobj = \quiz::create($quiz->id); + $structure = \mod_quiz\structure::create_for_quiz($quizobj); + $slots = $structure->get_slots(); + $slot = reset($slots); + // Now change the version using the external service. + $versions = qbank_helper::get_version_options($slot->questionid); + // We dont want the current version. + $selectversions = []; + foreach ($versions as $version) { + if ($version->version === $slot->version) { + continue; + } + $selectversions [$version->version] = $version; + } + // Change to version 1. + $this->expectException('moodle_exception'); + submit_question_version::execute($slot->id, (int)$selectversions[1]->version); + list($quizobj, $quba, $attemptobj) = $this->attempt_quiz($quiz, $this->student, 2); + $this->assertEquals('This is the first version', $attemptobj->get_question_attempt(1)->get_question()->name); + // Change to version 2. + submit_question_version::execute($slot->id, (int)$selectversions[2]->version); + list($quizobj, $quba, $attemptobj) = $this->attempt_quiz($quiz, $this->student, 3); + $this->assertEquals('This is the second version', $attemptobj->get_question_attempt(1)->get_question()->name); + // Create another version. + $questiongenerator->update_question($numq, null, ['name' => 'This is the latest version']); + // Change to always latest. + submit_question_version::execute($slot->id, 0); + list($quizobj, $quba, $attemptobj) = $this->attempt_quiz($quiz, $this->student, 4); + $this->assertEquals('This is the latest version', $attemptobj->get_question_attempt(1)->get_question()->name); + } +} diff --git a/mod/quiz/tests/structure_test.php b/mod/quiz/tests/structure_test.php index eb4fe103038..4ad96c84df6 100644 --- a/mod/quiz/tests/structure_test.php +++ b/mod/quiz/tests/structure_test.php @@ -703,7 +703,11 @@ class mod_quiz_structure_testcase extends advanced_testcase { $cat = $questiongenerator->create_question_category(); quiz_add_random_questions($quizobj->get_quiz(), 1, $cat->id, 1, false); $structure = \mod_quiz\structure::create_for_quiz($quizobj); - $randomq = $DB->get_record('question', array('qtype' => 'random')); + $sql = 'SELECT qsr.* + FROM {question_set_references} qsr + JOIN {quiz_slots} qs ON qs.id = qsr.itemid + WHERE qs.quizid = ?'; + $randomq = $DB->get_record_sql($sql, [$quizobj->get_quizid()]); $structure->remove_slot(2); @@ -711,7 +715,7 @@ class mod_quiz_structure_testcase extends advanced_testcase { $this->assert_quiz_layout(array( array('TF1', 1, 'truefalse'), ), $structure); - $this->assertFalse($DB->record_exists('question', array('id' => $randomq->id))); + $this->assertFalse($DB->record_exists('question_set_references', array('id' => $randomq->id))); } /** @@ -899,150 +903,6 @@ class mod_quiz_structure_testcase extends advanced_testcase { $this->assertEquals(0, $structure->is_question_dependent_on_previous_slot(2)); } - /** - * Data provider for the get_slot_tags_for_slot test. - */ - public function get_slot_tags_for_slot_test_cases() { - return [ - 'incorrect slot id' => [ - 'layout' => [ - ['TF1', 1, 'truefalse'], - ['TF2', 1, 'truefalse'], - ['TF3', 1, 'truefalse'] - ], - 'tagnames' => [ - ['foo'], - ['bar'], - ['baz'] - ], - 'slotnumber' => null, - 'expected' => [] - ], - 'no tags' => [ - 'layout' => [ - ['TF1', 1, 'truefalse'], - ['TF2', 1, 'truefalse'], - ['TF3', 1, 'truefalse'] - ], - 'tagnames' => [ - ['foo'], - [], - ['baz'] - ], - 'slotnumber' => 2, - 'expected' => [] - ], - 'one tag 1' => [ - 'layout' => [ - ['TF1', 1, 'truefalse'], - ['TF2', 1, 'truefalse'], - ['TF3', 1, 'truefalse'] - ], - 'tagnames' => [ - ['foo'], - ['bar'], - ['baz'] - ], - 'slotnumber' => 1, - 'expected' => ['foo'] - ], - 'one tag 2' => [ - 'layout' => [ - ['TF1', 1, 'truefalse'], - ['TF2', 1, 'truefalse'], - ['TF3', 1, 'truefalse'] - ], - 'tagnames' => [ - ['foo'], - ['bar'], - ['baz'] - ], - 'slotnumber' => 2, - 'expected' => ['bar'] - ], - 'multiple tags 1' => [ - 'layout' => [ - ['TF1', 1, 'truefalse'], - ['TF2', 1, 'truefalse'], - ['TF3', 1, 'truefalse'] - ], - 'tagnames' => [ - ['foo', 'bar'], - ['bar'], - ['baz'] - ], - 'slotnumber' => 1, - 'expected' => ['foo', 'bar'] - ], - 'multiple tags 2' => [ - 'layout' => [ - ['TF1', 1, 'truefalse'], - ['TF2', 1, 'truefalse'], - ['TF3', 1, 'truefalse'] - ], - 'tagnames' => [ - ['foo', 'bar'], - ['bar', 'baz'], - ['baz'] - ], - 'slotnumber' => 2, - 'expected' => ['bar', 'baz'] - ] - ]; - } - - /** - * @dataProvider get_slot_tags_for_slot_test_cases() - * @param array $layout Quiz layout for create_test_quiz function - * @param array $tagnames Tags to create for each question slot - * @param int $slotnumber The slot number to select tags from - * @param string[] $expected The tags expected for the given $slotnumber - */ - public function test_get_slot_tags_for_slot($layout, $tagnames, $slotnumber, $expected) { - global $DB; - $this->resetAfterTest(); - - $quiz = $this->create_test_quiz($layout); - $structure = \mod_quiz\structure::create_for_quiz($quiz); - $collid = core_tag_area::get_collection('core', 'question'); - $slottagrecords = []; - - if (is_null($slotnumber)) { - // Null slot number means to create a non-existent slot id. - $slot = $structure->get_last_slot(); - $slotid = $slot->id + 100; - } else { - $slot = $structure->get_slot_by_number($slotnumber); - $slotid = $slot->id; - } - - foreach ($tagnames as $index => $slottagnames) { - $tagslotnumber = $index + 1; - $tagslotid = $structure->get_slot_id_for_slot($tagslotnumber); - $tags = core_tag_tag::create_if_missing($collid, $slottagnames); - $records = array_map(function($tag) use ($tagslotid) { - return (object) [ - 'slotid' => $tagslotid, - 'tagid' => $tag->id, - 'tagname' => $tag->name - ]; - }, array_values($tags)); - $slottagrecords = array_merge($slottagrecords, $records); - } - - $DB->insert_records('quiz_slot_tags', $slottagrecords); - - $actualslottags = $structure->get_slot_tags_for_slot_id($slotid); - $actual = array_map(function($slottag) { - return $slottag->tagname; - }, $actualslottags); - - sort($expected); - sort($actual); - - $this->assertEquals($expected, $actual); - } - /** * Test for can_add_random_questions. */ diff --git a/mod/quiz/tests/tags_test.php b/mod/quiz/tests/tags_test.php index 7781f00ff74..c6cb24863b2 100644 --- a/mod/quiz/tests/tags_test.php +++ b/mod/quiz/tests/tags_test.php @@ -15,20 +15,10 @@ // along with Moodle. If not, see . /** - * Unit tests for usage of tags in quizzes. - * - * @package mod_quiz - * @copyright 2018 Shamim Rezaie - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ - -defined('MOODLE_INTERNAL') || die(); - -/** - * Class mod_quiz_tags_testcase - * Class for tests related to usage of question tags in quizzes. + * Test the restore of random question tags. * * @copyright 2018 Shamim Rezaie + * @author 2021 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class mod_quiz_tags_testcase extends advanced_testcase { @@ -84,17 +74,14 @@ class mod_quiz_tags_testcase extends advanced_testcase { $this->assertNotFalse($tag3); $slottags = quiz_retrieve_slot_tags($question->slotid); - $this->assertEqualsCanonicalizing( - [ - ['tagid' => $tag2->id, 'tagname' => $tag2->name] - ], - array_map(function($tag) { - return ['tagid' => $tag->tagid, 'tagname' => $tag->tagname]; - }, $slottags) - ); + $slottags = reset($slottags); + $slottags = explode(',', $slottags); + $this->assertEquals("{$tag2->id},{$tag2->name}", "{$slottags[0]},{$slottags[1]}"); $defaultcategory = question_get_default_category(context_course::instance($newcourseid)->id); - $this->assertEquals($defaultcategory->id, $question->randomfromcategory); - $this->assertEquals(0, $question->randomincludingsubcategories); + $this->assertEquals($defaultcategory->id, $question->categoryobject->id); + $randomincludingsubcategories = $DB->get_record('question_set_references', ['itemid' => reset($slots)->id]); + $filtercondition = json_decode($randomincludingsubcategories->filtercondition); + $this->assertEquals(0, $filtercondition->includingsubcategories); } } diff --git a/mod/quiz/upgrade.txt b/mod/quiz/upgrade.txt index 9eab59f1c28..e93b435745d 100644 --- a/mod/quiz/upgrade.txt +++ b/mod/quiz/upgrade.txt @@ -12,7 +12,13 @@ This files describes API changes in the quiz code. in mod/quiz/renderer.php. * The function no_questions_message() in class mod_quiz_renderer is deprecated. Please use generate_no_questions_message() in the same class. - +* quiz_slots has been updated as a part of https://docs.moodle.org/dev/Question_bank_improvements_for_Moodle_4.0 + The fields removed will be now manage by a new table in core_question: + - question_set_reference -> Records where a specific question is used. + - question_set_reference -> Records where groups of questions are used (e.g.: Random questions). + The quiz_slots_tags table will be removed entirely. +* The method get_slot_tags_for_slot_id() from mod/quiz/classes/structure.php has been deprecated and the associated + code for this method have been removed to make sure any unnecessary error doesn't happen as a part of any call. === 3.11 === diff --git a/mod/quiz/version.php b/mod/quiz/version.php index 3be42b78443..d19a3526b0b 100644 --- a/mod/quiz/version.php +++ b/mod/quiz/version.php @@ -24,6 +24,6 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2021101901; +$plugin->version = 2022020300; $plugin->requires = 2021052500; $plugin->component = 'mod_quiz'; diff --git a/question/bank/bulkmove/classes/helper.php b/question/bank/bulkmove/classes/helper.php index 879ee906bcb..532350ea7f0 100644 --- a/question/bank/bulkmove/classes/helper.php +++ b/question/bank/bulkmove/classes/helper.php @@ -38,7 +38,9 @@ class helper { list($usql, $params) = $DB->get_in_or_equal($questionids); $sql = "SELECT q.*, c.contextid FROM {question} q - JOIN {question_categories} c ON c.id = q.category + JOIN {question_versions} qv ON qv.questionid = q.id + JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid + JOIN {question_categories} c ON c.id = qbe.questioncategoryid WHERE q.id {$usql}"; $questions = $DB->get_records_sql($sql, $params); diff --git a/question/bank/bulkmove/move.php b/question/bank/bulkmove/move.php index 26f97558144..1755dd948c5 100644 --- a/question/bank/bulkmove/move.php +++ b/question/bank/bulkmove/move.php @@ -25,6 +25,9 @@ require_once(__DIR__ . '/../../../config.php'); require_once(__DIR__ . '/../../editlib.php'); + +use \core_question\lib\question_edit_contexts; + global $DB, $OUTPUT, $PAGE, $COURSE; $moveselected = optional_param('move', false, PARAM_BOOL); @@ -52,7 +55,7 @@ if ($cmid) { throw new moodle_exception('missingcourseorcmid', 'question'); } -$contexts = new question_edit_contexts($thiscontext); +$contexts = new \core_question\lib\question_edit_contexts($thiscontext); $url = new moodle_url('/question/bank/bulkmove/move.php'); $PAGE->set_url($url); diff --git a/question/bank/bulkmove/tests/helper_test.php b/question/bank/bulkmove/tests/helper_test.php index d96b31d0525..bf3891160e1 100644 --- a/question/bank/bulkmove/tests/helper_test.php +++ b/question/bank/bulkmove/tests/helper_test.php @@ -16,6 +16,8 @@ namespace qbank_bulkmove; +use core_question\lib\question_edit_contexts; + defined('MOODLE_INTERNAL') || die(); global $CFG; @@ -87,7 +89,7 @@ class helper_test extends \advanced_testcase { $this->context = \context_course::instance($this->course->id); // Create a question in the default category. - $this->contexts = new \question_edit_contexts($this->context); + $this->contexts = new question_edit_contexts($this->context); $this->cat = question_make_default_categories($this->contexts->all()); $this->questiondata1 = $questiongenerator->create_question('numerical', null, ['name' => 'Example question', 'category' => $this->cat->id]); diff --git a/question/bank/comment/tests/backup_test.php b/question/bank/comment/tests/backup_test.php index 02b9a84a3ff..959f2a020ab 100644 --- a/question/bank/comment/tests/backup_test.php +++ b/question/bank/comment/tests/backup_test.php @@ -137,7 +137,8 @@ class qbank_comment_backup_restore_test extends \advanced_testcase { * across the backup and restore process. */ public function test_backup_restore() { - global $DB; + global $DB, $CFG; + require_once($CFG->dirroot . '/comment/lib.php'); $this->resetAfterTest(); $this->setAdminUser(); @@ -195,12 +196,18 @@ class qbank_comment_backup_restore_test extends \advanced_testcase { $this->restore_course($backupid, $coursefullname, $courseshortname . '_2', $newcategory->id); // The questions and their associated comments should have been restored. - $newquestion1 = $DB->get_record('question', ['idnumber' => 'q1']); + $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 + WHERE qbe.idnumber = ?'; + $newquestion1 = $DB->get_record_sql($sql, ['idnumber' => 'q1']); $args->itemid = $newquestion1->id; $commentobj = new \comment($args); $this->assertEquals($commentobj->count(), 2); - $newquestion2 = $DB->get_record('question', ['idnumber' => 'q2']); + $newquestion2 = $DB->get_record_sql($sql, ['idnumber' => 'q2']); $args->itemid = $newquestion2->id; $commentobj = new \comment($args); $this->assertEquals($commentobj->count(), 1); diff --git a/question/bank/comment/tests/behat/question_comment.feature b/question/bank/comment/tests/behat/question_comment.feature index f1795f880c4..76c07957a4e 100644 --- a/question/bank/comment/tests/behat/question_comment.feature +++ b/question/bank/comment/tests/behat/question_comment.feature @@ -17,7 +17,7 @@ Feature: A Teacher can comment in a question | activity | name | course | idnumber | | quiz | Test quiz | C1 | quiz1 | And the following "question categories" exist: - | contextlevel | reference | name | + | contextlevel | reference | name | | Course | C1 | Test questions | And the following "questions" exist: | questioncategory | qtype | name | questiontext | @@ -115,3 +115,34 @@ Feature: A Teacher can comment in a question And I choose "Preview" action for "Essay 01 new" in the question bank Then I should not see "Save comment" And I click on "Close preview" "button" + + @javascript + Scenario: Comments added from the quiz page are visible + Given I log in as "teacher1" + And I am on the "Test quiz" "quiz activity" page + When I navigate to "Edit quiz" in current page administration + And I press "Add" + And I follow "from question bank" + And I click on "Select" "checkbox" in the "First question" "table_row" + And I click on "Add selected questions to the quiz" "button" + And I click on "Preview question" "link" + And I switch to "questionpreview" window + And I press "Comments" + And I set the field "content" to "Some new comment" + And I click on "Save comment" "link" + And I should see "Some new comment" + And I switch to the main window + And I am on the "Test quiz" "quiz activity" page + And I navigate to "Question bank > Questions" in current page administration + And I choose "Preview" action for "First question" in the question bank + And I click on "Comments" "link" + And I should see "Some new comment" + And I should see "T1 Teacher1" + And I delete "Some new comment" comment from question preview + And I should not see "Some new comment" + And I am on the "Test quiz" "quiz activity" page + And I navigate to "Edit quiz" in current page administration + And I click on "Preview question" "link" + And I switch to "questionpreview" window + And I press "Comments" + Then I should not see "Some new comment" diff --git a/question/bank/comment/tests/event/comment_created_deleted_test.php b/question/bank/comment/tests/event/comment_created_deleted_test.php index 7081381fc46..3f08d1b3072 100644 --- a/question/bank/comment/tests/event/comment_created_deleted_test.php +++ b/question/bank/comment/tests/event/comment_created_deleted_test.php @@ -22,7 +22,6 @@ use comment; use context; use context_course; use core_question_generator; -use question_edit_contexts; use stdClass; /** @@ -66,7 +65,7 @@ class comment_created_deleted_test extends advanced_testcase { $this->context = context_course::instance($this->course->id); // Create a question in the default category. - $contexts = new question_edit_contexts($this->context); + $contexts = new \core_question\lib\question_edit_contexts($this->context); $cat = question_make_default_categories($contexts->all()); $this->questiondata = $questiongenerator->create_question('numerical', null, ['name' => 'Example question', 'category' => $cat->id]); diff --git a/question/bank/deletequestion/classes/delete_action_column.php b/question/bank/deletequestion/classes/delete_action_column.php index dc5f005224f..712799f239d 100644 --- a/question/bank/deletequestion/classes/delete_action_column.php +++ b/question/bank/deletequestion/classes/delete_action_column.php @@ -83,7 +83,7 @@ class delete_action_column extends menu_action_column_base { if (!question_has_capability_on($question, 'edit')) { return [null, null, null]; } - if ($question->status === constants::QUESTION_STATUS_HIDDEN) { + if ((int) $question->status === constants::QUESTION_STATUS_HIDDEN) { $hiddenparams = array( 'unhide' => $question->id, 'sesskey' => sesskey()); diff --git a/question/bank/deletequestion/delete.php b/question/bank/deletequestion/delete.php index 5d532ac48c3..41c887324e8 100644 --- a/question/bank/deletequestion/delete.php +++ b/question/bank/deletequestion/delete.php @@ -62,7 +62,7 @@ $PAGE->set_heading($COURSE->fullname); // Unhide a question. if (($unhide = optional_param('unhide', '', PARAM_INT)) and confirm_sesskey()) { question_require_capability_on($unhide, 'edit'); - $DB->set_field('question_versions', 'status', 0, ['questionid' => $questionid]); + $DB->set_field('question_versions', 'status', 0, ['questionid' => $unhide]); // Purge these questions from the cache. \question_bank::notify_question_edited($unhide); diff --git a/question/bank/editquestion/question.php b/question/bank/editquestion/question.php index 4da06924855..9dd1cec5711 100644 --- a/question/bank/editquestion/question.php +++ b/question/bank/editquestion/question.php @@ -178,7 +178,8 @@ if ($id) { if ($makecopy) { // If we are duplicating a question, add some indication to the question name. $question->name = get_string('questionnamecopy', 'question', $question->name); - $question->idnumber = core_question_find_next_unused_idnumber($question->idnumber, $category->id); + $question->idnumber = isset($question->idnumber) ? + core_question_find_next_unused_idnumber($question->idnumber, $category->id) : ''; $question->beingcopied = true; } @@ -211,6 +212,7 @@ if ($formeditable && $id) { $toform->appendqnumstring = $appendqnumstring; $toform->returnurl = $originalreturnurl; $toform->makecopy = $makecopy; +$toform->idnumber = null; if (isset($question->id)) { $questionobject = question_bank::load_question($question->id); $toform->status = $questionobject->status; @@ -218,6 +220,9 @@ if (isset($question->id)) { } else { $toform->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; } +if ($makecopy) { + $toform->idnumber = core_question_find_next_unused_idnumber($toform->idnumber, $category->id); +} if ($cm !== null) { $toform->cmid = $cm->id; $toform->courseid = $cm->course; diff --git a/question/bank/editquestion/tests/behat/behat_qbank_editquestion.php b/question/bank/editquestion/tests/behat/behat_qbank_editquestion.php new file mode 100644 index 00000000000..2357cc03de5 --- /dev/null +++ b/question/bank/editquestion/tests/behat/behat_qbank_editquestion.php @@ -0,0 +1,59 @@ +. + +// NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php. +require_once(__DIR__ . '/../../../../../lib/behat/behat_base.php'); +require_once(__DIR__ . '/../../../../tests/behat/behat_question_base.php'); + +use Behat\Mink\Exception\ExpectationException as ExpectationException, + Behat\Mink\Exception\ElementNotFoundException as ElementNotFoundException; + +/** + * Steps definitions to deal with the usage in question. + * + * @package qbank_usage + * @copyright 2021 Catalyst IT Australia Pty Ltd + * @author Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class behat_qbank_editquestion extends behat_question_base { + + /** + * Looks for a table, then looks for a row that contains the given text. + * Once it finds the right row, it clicks a link in that row. + * + * @When I click :arg1 on the status column + * @param string $linkname + */ + public function i_click_on_the_status_column($linkname) { + $exception = new ElementNotFoundException($this->getSession(), + 'Cannot find any row on the page containing the text ' . $linkname); + $row = $this->find('css', sprintf('table tbody tr td.questionstatus a:contains("%s")', $linkname), $exception); + $row->click(); + } + + /** + * Looks for the appropriate usage count in the column. + * + * @Then I should see :arg1 on the status column + * @param string $linkdata + */ + public function i_should_see_on_the_status_column($linkdata) { + $exception = new ElementNotFoundException($this->getSession(), + 'Cannot find any row with the usage count of ' . $linkdata . ' on the column named Status'); + $this->find('css', sprintf('table tbody tr td.questionstatus a:contains("%s")', $linkdata), $exception); + } +} diff --git a/question/bank/editquestion/tests/behat/qbank_editquestion.feature b/question/bank/editquestion/tests/behat/qbank_editquestion.feature new file mode 100644 index 00000000000..7c70d8e73c7 --- /dev/null +++ b/question/bank/editquestion/tests/behat/qbank_editquestion.feature @@ -0,0 +1,39 @@ +@qbank @qbank_editquestion +Feature: Use the qbank plugin manager page for editquestion + In order to check the plugin behaviour with enable and disable + + Background: + Given the following "courses" exist: + | fullname | shortname | category | + | Course 1 | C1 | 0 | + And the following "activities" exist: + | activity | name | course | idnumber | + | quiz | Test quiz | C1 | quiz1 | + And the following "question categories" exist: + | contextlevel | reference | name | + | Course | C1 | Test questions | + And the following "questions" exist: + | questioncategory | qtype | name | questiontext | + | Test questions | truefalse | First question | Answer the first question | + | Test questions | truefalse | First question second | Answer the first question | + + Scenario: Enable/disable edit question columns from the base view + Given I log in as "admin" + When I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration + And I should see "Edit question" + And I click on "Disable" "link" in the "Edit question" "table_row" + And I am on the "Test quiz" "quiz activity" page + And I navigate to "Question bank > Questions" in current page administration + Then I should not see "Status" + And I click on ".dropdown-toggle" "css_element" in the "First question" "table_row" + And I should not see "Edit question" in the "region-main" "region" + And I should not see "Duplicate" in the "region-main" "region" + And I navigate to "Plugins > Question bank plugins > Manage question bank plugins" in site administration + And I click on "Enable" "link" in the "Edit question" "table_row" + And I am on the "Test quiz" "quiz activity" page + And I navigate to "Question bank > Questions" in current page administration + And I click on ".dropdown-toggle" "css_element" in the "First question" "table_row" + Then I should see "Status" + And I click on ".dropdown-toggle" "css_element" in the "First question" "table_row" + And I should see "Edit question" in the "region-main" "region" + And I should see "Duplicate" in the "region-main" "region" diff --git a/question/bank/editquestion/tests/behat/question_status.feature b/question/bank/editquestion/tests/behat/question_status.feature new file mode 100644 index 00000000000..7e8944b6579 --- /dev/null +++ b/question/bank/editquestion/tests/behat/question_status.feature @@ -0,0 +1,31 @@ +@qbank @qbank_editquestion +Feature: Use the qbank base view to test the status change using + the pop up + + Background: + Given the following "courses" exist: + | fullname | shortname | category | + | Course 1 | C1 | 0 | + And the following "activities" exist: + | activity | name | course | idnumber | + | quiz | Test quiz | C1 | quiz1 | + And the following "question categories" exist: + | contextlevel | reference | name | + | Course | C1 | Test questions | + And the following "questions" exist: + | questioncategory | qtype | name | questiontext | + | Test questions | truefalse | First question | Answer the first question | + + @javascript + Scenario: Question status modal should change the status of the question + Given I log in as "admin" + And I am on the "Test quiz" "quiz activity" page + And I navigate to "Question bank > Questions" in current page administration + And I set the field "Select a category" to "Test questions" + And I should see "Test questions" + And I should see "Ready" on the status column + When I click "Ready" on the status column + Then I should see "Change question status" + And I should see "Question status" + And I click on "Close" "button" in the ".modal-dialog" "css_element" + And I should see "Ready" on the status column diff --git a/question/bank/managecategories/category.php b/question/bank/managecategories/category.php index 339c2a2da5e..bba32c2f07d 100644 --- a/question/bank/managecategories/category.php +++ b/question/bank/managecategories/category.php @@ -92,6 +92,8 @@ if ($param->moveupcontext || $param->movedowncontext) { $category->contextid = $param->tocontext; $event = \core\event\question_category_moved::create_from_question_category_instance($category); $event->trigger(); + // Update the set_reference records when moving a category to a different context. + move_question_set_references($catid, $catid, $oldcat->contextid, $category->contextid); $qcobject->update_category($catid, "{$newtopcat->id},{$param->tocontext}", $oldcat->name, $oldcat->info); // The previous line does a redirect(). } diff --git a/question/bank/managecategories/classes/helper.php b/question/bank/managecategories/classes/helper.php index cece45aeaae..3f44ed7bb56 100644 --- a/question/bank/managecategories/classes/helper.php +++ b/question/bank/managecategories/classes/helper.php @@ -64,7 +64,7 @@ class helper { $sql = 'SELECT q.id FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id - JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid + JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid WHERE qbe.questioncategoryid = :categoryid AND (q.qtype = :qtype OR qv.status = :status)'; diff --git a/question/bank/managecategories/classes/question_category_object.php b/question/bank/managecategories/classes/question_category_object.php index 66cf3df6879..4d41fedab5f 100644 --- a/question/bank/managecategories/classes/question_category_object.php +++ b/question/bank/managecategories/classes/question_category_object.php @@ -332,7 +332,7 @@ class question_category_object { $sql = "SELECT q.id, 1 FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id - JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid + JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid WHERE qbe.questioncategoryid = ? AND (q.parent = 0 OR q.parent = q.id)"; @@ -490,7 +490,7 @@ class question_category_object { $sql = "SELECT q.id FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id - JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid + JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid WHERE qbe.questioncategoryid = ?"; $questionids = $DB->get_records_sql($sql, [$cat->id]); diff --git a/question/bank/managecategories/tests/helper_test.php b/question/bank/managecategories/tests/helper_test.php index 7c9248234ec..5f7ee5b2702 100644 --- a/question/bank/managecategories/tests/helper_test.php +++ b/question/bank/managecategories/tests/helper_test.php @@ -80,15 +80,16 @@ class helper_test extends \advanced_testcase { $DB->set_field('question_versions', 'status', 1, ['questionid' => $q2a->id]); $DB->set_field('question_versions', 'status', 1, ['questionid' => $q2b->id]); quiz_add_quiz_question($q2b->id, $this->quiz); + + // Adding a new random question does not add a new question, adds a question_set_references record. quiz_add_random_questions($this->quiz, 0, $qcat2->id, 1, false); // We added one random question to the quiz and we expect the quiz to have only one random question. - $q2d = $DB->get_record_sql("SELECT q.* - FROM {question} q - JOIN {quiz_slots} s ON s.questionid = q.id - WHERE q.qtype = :qtype - AND s.quizid = :quizid", - ['qtype' => 'random', 'quizid' => $this->quiz->id], MUST_EXIST); + $q2d = $DB->get_record_sql("SELECT qsr.* + FROM {quiz_slots} qs + JOIN {question_set_references} qsr ON qsr.itemid = qs.id + WHERE qs.quizid = ?", + ['quizid' => $this->quiz->id], MUST_EXIST); // The following 2 lines have to be after the quiz_add_random_questions() call above. // Otherwise, quiz_add_random_questions() will to be "smart" and use them instead of creating a new "random" question. @@ -98,30 +99,30 @@ class helper_test extends \advanced_testcase { $sql = "SELECT count(q.id) FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id - JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid + JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid WHERE qbe.questioncategoryid = ?"; $this->assertEquals(2, $DB->count_records_sql($sql, [$qcat1->id])); - $this->assertEquals(4, $DB->count_records_sql($sql, [$qcat2->id])); + $this->assertEquals(3, $DB->count_records_sql($sql, [$qcat2->id])); // Non-existing category, nothing will happen. helper::question_remove_stale_questions_from_category(0); $sql = "SELECT count(q.id) FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id - JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid + JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid WHERE qbe.questioncategoryid = ?"; $this->assertEquals(2, $DB->count_records_sql($sql, [$qcat1->id])); - $this->assertEquals(4, $DB->count_records_sql($sql, [$qcat2->id])); + $this->assertEquals(3, $DB->count_records_sql($sql, [$qcat2->id])); // First category, should be empty afterwards. helper::question_remove_stale_questions_from_category($qcat1->id); $sql = "SELECT count(q.id) FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id - JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid + JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid WHERE qbe.questioncategoryid = ?"; $this->assertEquals(0, $DB->count_records_sql($sql, [$qcat1->id])); - $this->assertEquals(4, $DB->count_records_sql($sql, [$qcat2->id])); + $this->assertEquals(3, $DB->count_records_sql($sql, [$qcat2->id])); $this->assertFalse($DB->record_exists('question', ['id' => $q1a->id])); $this->assertFalse($DB->record_exists('question', ['id' => $q1b->id])); @@ -130,14 +131,14 @@ class helper_test extends \advanced_testcase { $sql = "SELECT count(q.id) FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id - JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid + JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid WHERE qbe.questioncategoryid = ?"; $this->assertEquals(0, $DB->count_records_sql($sql, [$qcat1->id])); - $this->assertEquals(2, $DB->count_records_sql($sql, [$qcat2->id])); + $this->assertEquals(1, $DB->count_records_sql($sql, [$qcat2->id])); $this->assertFalse($DB->record_exists('question', ['id' => $q2a->id])); $this->assertTrue($DB->record_exists('question', ['id' => $q2b->id])); $this->assertFalse($DB->record_exists('question', ['id' => $q2c->id])); - $this->assertTrue($DB->record_exists('question', ['id' => $q2d->id])); + $this->assertTrue($DB->record_exists('question_set_references', ['id' => $q2d->id])); } /** @@ -204,7 +205,7 @@ class helper_test extends \advanced_testcase { public function test_question_category_select_menu() { $this->qgenerator->create_question_category(['contextid' => $this->context->id, 'name' => 'Test this question category']); - $contexts = new \question_edit_contexts($this->context); + $contexts = new \core_question\lib\question_edit_contexts($this->context); ob_start(); helper::question_category_select_menu($contexts->having_cap('moodle/question:add')); @@ -230,7 +231,7 @@ class helper_test extends \advanced_testcase { $qcategory2 = $this->qgenerator->create_question_category(['contextid' => $this->context->id, 'parent' => $qcategory1->id]); $qcategory3 = $this->qgenerator->create_question_category(['contextid' => $this->context->id]); - $contexts = new \question_edit_contexts($this->context); + $contexts = new \core_question\lib\question_edit_contexts($this->context); // Validate that we have the array with the categories tree. $categorycontexts = helper::question_category_options($contexts->having_cap('moodle/question:add')); diff --git a/question/bank/previewquestion/tests/behat/preview_question.feature b/question/bank/previewquestion/tests/behat/preview_question.feature index c7e9beb6b65..1e9a4388d9b 100644 --- a/question/bank/previewquestion/tests/behat/preview_question.feature +++ b/question/bank/previewquestion/tests/behat/preview_question.feature @@ -30,10 +30,26 @@ Feature: A teacher can preview questions in the question bank Scenario: Question preview shows the question and other information Then the state of "What is pi to two d.p.?" question is shown as "Not yet answered" + And I should see "(latest)" And I should see "Marked out of 1.00" And I should see "Technical information" - And I should see "Attempt options" And I should see "Display options" + And I should see "Preview options" + And I should see "Comments" + And I click on "Comments" "link" + And I should see "Save comment" + And I should see "ID number" + And "Numerical" "icon" should exist + And I should see "Version" + And I click on "Preview options" "link" + And I should see "Question version" + And "action-menu-toggle-0" "link" should exist + And I click on "action-menu-toggle-0" "link" + And I should see "Edit question" + And I should see "Duplicate" + And I should see "Manage tags" + And I should see "Delete" + And I should see "Export as Moodle XML" Scenario: Preview lets the teacher see what happens when an answer is saved When I set the field "Answer:" to "1" @@ -48,14 +64,18 @@ Feature: A teacher can preview questions in the question bank Scenario: Preview lets the teacher see what happens with different review options Given I set the field "Answer:" to "3.14" And I press "Submit and finish" + And I press "Display options" When I set the field "Whether correct" to "Not shown" And I set the field "Decimal places in grades" to "5" And I press "Update display options" + And I set the field "Answer:" to "3.14" + And I press "Submit and finish" Then the state of "What is pi to two d.p.?" question is shown as "Complete" And I should see "1.00000" Scenario: Preview lets the teacher see what happens with different behaviours - When I set the field "How questions behave" to "Immediate feedback" + When I press "Preview options" + And I set the field "How questions behave" to "Immediate feedback" And I set the field "Marked out of" to "3" And I press "Start again with these options" And I set the field "Answer:" to "3.1" @@ -75,9 +95,43 @@ Feature: A teacher can preview questions in the question bank Then the field "Answer:" matches value "3.14" Scenario: Preview has an option to export the individual quesiton. - Then following "Download this question in Moodle XML format" should download between "1000" and "2500" bytes + When I open the action menu in "action-menu-0-menubar" "region" + Then following "Export as Moodle XML" should download between "1000" and "2500" bytes Scenario: Preview a question with very small grade - When I set the field "Marked out of" to "0.00000123456789" + When I press "Preview options" + And I set the field "Marked out of" to "0.00000123456789" And I press "Start again with these options" Then the field "Marked out of" matches value "0.00000123456789" + + Scenario: Question preview has an action menu + When I open the action menu in "action-menu-0-menubar" "region" + Then I should see "Edit question" + And I should see "Duplicate" + And I should see "Manage tags" + And I should see "Delete" + And I should see "Export as Moodle XML" + + Scenario: Question version is updated when edited and teacher can change question version + When I open the action menu in "action-menu-0-menubar" "region" + And I choose "Edit question" in the open action menu + And I set the field "Question name" to "New version" + And I set the field "Question text" to "New text version" + And I click on "submitbutton" "button" + And I press "Preview options" + And I set the field "Question version" to "2" + And I press "Start again with these options" + Then I should see "Version 2" + And I should see "(latest)" + And I should see "New version" + And I should see "New text version" + And I should not see "Test question to be previewed" + And I should not see "Version 1" + + Scenario: Question preview can be closed + When I open the action menu in "action-menu-0-menubar" "region" + And I choose "Edit question" in the open action menu + And I click on "cancel" "button" + And I press "Close preview" + Then I should not see "(latest)" + And I should see "Course 1" diff --git a/question/bank/previewquestion/tests/helper_test.php b/question/bank/previewquestion/tests/helper_test.php index 6c4972d76cb..4b789990a6b 100644 --- a/question/bank/previewquestion/tests/helper_test.php +++ b/question/bank/previewquestion/tests/helper_test.php @@ -69,7 +69,7 @@ class helper_test extends \advanced_testcase { $course = $generator->create_course(); $this->context = \context_course::instance($course->id); // Create a question in the default category. - $contexts = new \question_edit_contexts($this->context); + $contexts = new \core_question\lib\question_edit_contexts($this->context); $cat = question_make_default_categories($contexts->all()); $this->questiondata = $questiongenerator->create_question('numerical', null, ['name' => 'Example question', 'category' => $cat->id]); diff --git a/question/bank/tagquestion/classes/external/submit_tags.php b/question/bank/tagquestion/classes/external/submit_tags.php index c4a79153ffd..54fe0feb3fd 100644 --- a/question/bank/tagquestion/classes/external/submit_tags.php +++ b/question/bank/tagquestion/classes/external/submit_tags.php @@ -81,7 +81,7 @@ class submit_tags extends external_api { SELECT q.*, qc.contextid FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id - JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid + JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid WHERE q.id = ?', [$questionid])) { throw new \moodle_exception('questiondoesnotexist', 'question'); diff --git a/question/bank/tagquestion/lib.php b/question/bank/tagquestion/lib.php index d75835876e4..66d507d8633 100644 --- a/question/bank/tagquestion/lib.php +++ b/question/bank/tagquestion/lib.php @@ -56,7 +56,7 @@ function qbank_tagquestion_output_fragment_tags_form($args) { $sql = "SELECT qc.* FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id - JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid + JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid WHERE q.id = :id"; $category = $DB->get_record_sql($sql, ['id' => $question->id]); diff --git a/question/bank/usage/classes/helper.php b/question/bank/usage/classes/helper.php index 1d179b422e8..ce250fed619 100644 --- a/question/bank/usage/classes/helper.php +++ b/question/bank/usage/classes/helper.php @@ -37,7 +37,7 @@ class helper { $sql = 'SELECT COUNT(*) FROM (' . self::question_usage_sql() . ') quizid'; - return $DB->count_records_sql($sql, [$question->id, $question->id]); + return $DB->count_records_sql($sql, [$question->id, $question->questionbankentryid]); } /** @@ -58,11 +58,14 @@ class helper { AND q.id = ?) UNION (SELECT qz.id as quizid, - qz.name as modulename, - qz.course as courseid + qz.name as modulename, + qz.course as courseid FROM {quiz_slots} slot JOIN {quiz} qz ON qz.id = slot.quizid - WHERE slot.questionid = ?)"; + JOIN {question_references} qr ON qr.itemid = slot.id + JOIN {question_bank_entries} qbe ON qbe.id = qr.questionbankentryid + JOIN {question_versions} qv ON qv.questionbankentryid = qbe.id + WHERE qv.questionbankentryid = ?)"; return $sqlset; } @@ -73,18 +76,31 @@ class helper { * @param int $quizid * @return int */ - public static function get_question_attempts_count_in_quiz(int $questionid, int $quizid): int { + public static function get_question_attempts_count_in_quiz(int $questionid, $quizid = null): int { global $DB; - $sql = 'SELECT COUNT(qatt.id) - FROM {quiz} qz - JOIN {quiz_attempts} qa ON qa.quiz = qz.id - JOIN {question_usages} qu ON qu.id = qa.uniqueid - JOIN {question_attempts} qatt ON qatt.questionusageid = qu.id - JOIN {question} q ON q.id = qatt.questionid - WHERE qatt.questionid = :questionid - AND qa.preview = 0 - AND qz.id = :quizid'; - return $DB->count_records_sql($sql, [ 'questionid' => $questionid, 'quizid' => $quizid]); + if ($quizid) { + $sql = 'SELECT COUNT(qatt.id) + FROM {quiz} qz + JOIN {quiz_attempts} qa ON qa.quiz = qz.id + JOIN {question_usages} qu ON qu.id = qa.uniqueid + JOIN {question_attempts} qatt ON qatt.questionusageid = qu.id + JOIN {question} q ON q.id = qatt.questionid + WHERE qatt.questionid = :questionid + AND qa.preview = 0 + AND qz.id = :quizid'; + $param = ['questionid' => $questionid, 'quizid' => $quizid]; + } else { + $sql = 'SELECT COUNT(qatt.id) + FROM {quiz_slots} qs + JOIN {quiz_attempts} qa ON qa.quiz = qs.quizid + JOIN {question_usages} qu ON qu.id = qa.uniqueid + JOIN {question_attempts} qatt ON qatt.questionusageid = qu.id + JOIN {question} q ON q.id = qatt.questionid + WHERE qatt.questionid = ? + AND qa.preview = 0'; + $param = ['questionid' => $questionid]; + } + return $DB->count_records_sql($sql, $param); } } diff --git a/question/bank/usage/tests/helper_test.php b/question/bank/usage/tests/helper_test.php index f14e14c7b6b..f337fc31caa 100644 --- a/question/bank/usage/tests/helper_test.php +++ b/question/bank/usage/tests/helper_test.php @@ -98,7 +98,7 @@ class helper_test extends \advanced_testcase { */ public function test_get_question_entry_usage_count() { foreach ($this->questions as $question) { - $count = helper::get_question_entry_usage_count($question); + $count = helper::get_question_entry_usage_count(\question_bank::load_question($question->id)); // Test that the attempt data matches the usage data for the count. $this->assertEquals(1, $count); } diff --git a/question/classes/local/bank/action_column_base.php b/question/classes/local/bank/action_column_base.php index fb20d2c59c9..84b93744f2c 100644 --- a/question/classes/local/bank/action_column_base.php +++ b/question/classes/local/bank/action_column_base.php @@ -48,7 +48,7 @@ abstract class action_column_base extends column_base { public function get_extra_joins(): array { return ['qv' => 'JOIN {question_versions} qv ON qv.questionid = q.id', - 'qbe' => 'JOIN {question_bank_entry} qbe on qbe.id = qv.questionbankentryid', + 'qbe' => 'JOIN {question_bank_entries} qbe on qbe.id = qv.questionbankentryid', 'qc' => 'JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid']; } diff --git a/question/classes/local/bank/random_question_loader.php b/question/classes/local/bank/random_question_loader.php index 9267ee52a5c..f6560552900 100644 --- a/question/classes/local/bank/random_question_loader.php +++ b/question/classes/local/bank/random_question_loader.php @@ -292,24 +292,20 @@ class random_question_loader { // 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; - $where = 'IN ('; - foreach ($questionids as $questionid) { - $where = $where . $questionid . ','; - // Extra validation, we do not want an infinite loop. - $hasquestions = (bool)$questionid; + if (!empty($questionids)) { + $hasquestions = true; } - $where = rtrim($where,','); - $where = $where . ')'; - 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_entry} qbe ON qbe.id = qv.questionbankentryid - WHERE q.id {$where}) q"; + JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid + {$condition}) q"; - return $DB->get_records_sql($sql, [], $offset, $limit); + return $DB->get_records_sql($sql, $param, $offset, $limit); } else { return []; } diff --git a/question/classes/local/bank/view.php b/question/classes/local/bank/view.php index c9f2b0123c6..9b1c5965c3f 100644 --- a/question/classes/local/bank/view.php +++ b/question/classes/local/bank/view.php @@ -586,7 +586,7 @@ class view { // Build the where clause. $latestversion = 'qv.version = (SELECT MAX(v.version) FROM {question_versions} v - JOIN {question_bank_entry} be + JOIN {question_bank_entries} be ON be.id = v.questionbankentryid WHERE be.id = qbe.id)'; $tests = ['q.parent = 0', $latestversion]; diff --git a/question/classes/statistics/questions/all_calculated_for_qubaid_condition.php b/question/classes/statistics/questions/all_calculated_for_qubaid_condition.php index 29235fa23d9..50279857726 100644 --- a/question/classes/statistics/questions/all_calculated_for_qubaid_condition.php +++ b/question/classes/statistics/questions/all_calculated_for_qubaid_condition.php @@ -42,7 +42,7 @@ class all_calculated_for_qubaid_condition { /** * @var object[] */ - public $subquestions; + public $subquestions = []; /** * Holds slot (position) stats and stats for variants of questions in slots. diff --git a/question/classes/statistics/questions/calculator.php b/question/classes/statistics/questions/calculator.php index 3d76679f0cd..e9afff3d6a9 100644 --- a/question/classes/statistics/questions/calculator.php +++ b/question/classes/statistics/questions/calculator.php @@ -346,7 +346,15 @@ class calculator { * @param calculated $stats question stats to update. */ protected function initial_question_walker($stats) { - $stats->markaverage = $stats->totalmarks / $stats->s; + 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; @@ -354,10 +362,6 @@ class calculator { $stats->facility = null; } - $stats->othermarkaverage = $stats->totalothermarks / $stats->s; - - $stats->summarksaverage = $stats->totalsummarks / $stats->s; - sort($stats->markarray, SORT_NUMERIC); sort($stats->othermarksarray, SORT_NUMERIC); diff --git a/question/editlib.php b/question/editlib.php index c1b49d3dccc..80e3d88465c 100644 --- a/question/editlib.php +++ b/question/editlib.php @@ -50,6 +50,7 @@ function get_module_from_cmid($cmid) { return array($modrec, $cmrec); } + /** * Function to read all questions for category into big array * @@ -58,9 +59,9 @@ function get_module_from_cmid($cmid) { * @param bool $recurse include subdirectories * @param bool $export set true if this is called by questionbank export * @return array -*/ + */ function get_questions_category(object $category, bool $noparent, bool $recurse = true, bool $export = true, - bool $latestversion = false): array { + bool $latestversion = false): array { global $DB; // Build sql bit for $noparent. @@ -84,25 +85,24 @@ function get_questions_category(object $category, bool $noparent, bool $recurse if ($latestversion) { $version = 'AND (qv.version = (SELECT MAX(v.version) FROM {question_versions} v - JOIN {question_bank_entry} be + JOIN {question_bank_entries} be ON be.id = v.questionbankentryid WHERE be.id = qbe.id) OR qv.version is null)'; } - $questions = $DB->get_records_sql( - "SELECT q.*, qv.status, qc.id AS category - FROM {question} q - JOIN {question_versions} qv ON qv.questionid = q.id - JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid - JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid - WHERE qc.id {$usql} {$npsql} {$version} - ORDER BY qc.id, q.qtype, q.name", $params); + $questions = $DB->get_records_sql("SELECT q.*, qv.status, qc.id AS category + 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.id {$usql} {$npsql} {$version} + ORDER BY qc.id, q.qtype, q.name", $params); // Iterate through questions, getting stuff we need. $qresults = []; foreach($questions as $key => $question) { $question->export_process = $export; $qtype = question_bank::get_qtype($question->qtype, false); - if ($export && $qtype->name() == 'missingtype') { + if ($export && $qtype->name() === 'missingtype') { // Unrecognised question type. Skip this question when exporting. continue; } diff --git a/question/engine/bank.php b/question/engine/bank.php index aa972e77d5f..cc9f977c165 100644 --- a/question/engine/bank.php +++ b/question/engine/bank.php @@ -421,7 +421,7 @@ abstract class question_bank { $sql = "SELECT DISTINCT q.qtype FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id - JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid + JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid WHERE qbe.questioncategoryid $categorysql"; $qtypes = $DB->get_fieldset_sql($sql, $params); @@ -456,7 +456,7 @@ class question_finder implements cache_data_source { } /** - * @return cache_application|cache_session|cache_store the question definition cache we are using. + * @return cache_application the question definition cache we are using. */ protected function get_data_cache() { // Do not double cache here because it may break cache resetting. @@ -498,15 +498,14 @@ class question_finder implements cache_data_source { if ($extraconditions) { $extraconditions = ' AND (' . $extraconditions . ')'; } - - $readystatus = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $qcparams['readystatus'] = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $sql = "SELECT q.id, q.id AS id2 FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id - JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid + JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid WHERE qbe.questioncategoryid {$qcsql} AND q.parent = 0 - AND qv.status = $readystatus + AND qv.status = :readystatus {$extraconditions}"; return $DB->get_records_sql_menu($sql, $qcparams + $extraparams); @@ -558,7 +557,7 @@ class question_finder implements cache_data_source { ) AS previous_attempts"; $from = "{question} q"; $join = "JOIN {question_versions} qv ON qv.questionid = q.id - JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid"; + JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid"; $from = $from . " " . $join; $where = "qbe.questioncategoryid {$qcsql} AND q.parent = 0 @@ -618,7 +617,7 @@ class question_finder implements cache_data_source { qv.questionbankentryid FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id - JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid + JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid WHERE q.id = :id'; @@ -642,7 +641,7 @@ class question_finder implements cache_data_source { qv.questionbankentryid FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id - JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid + JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid WHERE q.id '; diff --git a/question/format.php b/question/format.php index 5a59ecf9550..851305e7b57 100644 --- a/question/format.php +++ b/question/format.php @@ -411,7 +411,7 @@ class qformat_default { // Id number not really set. Get rid of it. unset($question->idnumber); } else { - if ($DB->record_exists('question_bank_entry', + if ($DB->record_exists('question_bank_entries', ['idnumber' => $question->idnumber, 'questioncategoryid' => $question->category])) { // We cannot have duplicate idnumbers in a category. Just remove it. unset($question->idnumber); @@ -431,7 +431,7 @@ class qformat_default { $questionbankentry->questioncategoryid = $question->category; $questionbankentry->idnumber = $question->idnumber ?? null; $questionbankentry->ownerid = $question->createdby; - $questionbankentry->id = $DB->insert_record('question_bank_entry', $questionbankentry); + $questionbankentry->id = $DB->insert_record('question_bank_entries', $questionbankentry); // Create a version for each question imported. $questionversion = new \stdClass(); $questionversion->questionbankentryid = $questionbankentry->id; @@ -923,6 +923,7 @@ class qformat_default { $qcategory = $questionbankentry->category; $contextid = $DB->get_field('question_categories', 'contextid', ['id' => $qcategory]); $question->contextid = $contextid; + $question->idnumber = $questionbankentry->idnumber; // do not export hidden questions if (!empty($question->hidden)) { @@ -947,7 +948,7 @@ class qformat_default { // If parent wasn't written. if (!in_array($trackcategoryparent, $writtencategories)) { // If parent is empty. - if (!count($DB->get_records('question_bank_entry', ['questioncategoryid' => $trackcategoryparent]))) { + if (!count($DB->get_records('question_bank_entries', ['questioncategoryid' => $trackcategoryparent]))) { $categoryname = $this->get_category_path($trackcategoryparent, $this->contexttofile); $categoryinfo = $DB->get_record('question_categories', array('id' => $trackcategoryparent), 'name, info, infoformat, idnumber', MUST_EXIST); diff --git a/question/format/xml/format.php b/question/format/xml/format.php index 59acc379028..19ad7de2be7 100644 --- a/question/format/xml/format.php +++ b/question/format/xml/format.php @@ -1205,7 +1205,10 @@ class qformat_xml extends qformat_default { // Check question type. $questiontype = $this->get_qtype($question->qtype); - $idnumber = htmlspecialchars($question->idnumber); + $idnumber = ''; + if (isset($question->idnumber)) { + $idnumber = htmlspecialchars($question->idnumber); + } // Categories are a special case. if ($question->qtype == 'category') { diff --git a/question/format/xml/tests/qformat_xml_import_export_test.php b/question/format/xml/tests/qformat_xml_import_export_test.php index 498d52e44db..5d987e1b0b5 100644 --- a/question/format/xml/tests/qformat_xml_import_export_test.php +++ b/question/format/xml/tests/qformat_xml_import_export_test.php @@ -136,7 +136,7 @@ class qformat_xml_import_export_test extends advanced_testcase { $sql = "SELECT q.*, qbe.questioncategoryid AS category FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id - JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid + JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid WHERE q.name = :name"; $question = $DB->get_record_sql($sql, ['name' => $qname], MUST_EXIST); $category = $DB->get_record('question_categories', ['name' => $catname], '*', MUST_EXIST); diff --git a/question/lib.php b/question/lib.php index c82078c790c..2a857afea79 100644 --- a/question/lib.php +++ b/question/lib.php @@ -60,7 +60,7 @@ function core_question_output_fragment_tags_form($args) { $sql = "SELECT qc.* FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id - JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid + JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid WHERE q.id = :id"; $category = $DB->get_record_sql($sql, ['id' => $question->id]); diff --git a/question/tests/backup_test.php b/question/tests/backup_test.php index f5c53ab54fc..d2291b51803 100644 --- a/question/tests/backup_test.php +++ b/question/tests/backup_test.php @@ -144,7 +144,14 @@ class core_question_backup_testcase extends advanced_testcase { // The questions should remain in the question category they were which is // a question category belonging to a course category context. - $questions = $DB->get_records('question', ['category' => $qcat->id], 'idnumber'); + $sql = 'SELECT q.*, + qbe.idnumber + FROM {question} q + JOIN {question_versions} qv ON qv.questionid = q.id + JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid + WHERE qbe.questioncategoryid = ? + ORDER BY qbe.idnumber'; + $questions = $DB->get_records_sql($sql, [$qcat->id]); $this->assertCount(2, $questions); // Retrieve tags for each question and check if they are assigned at the right context. @@ -173,12 +180,6 @@ class core_question_backup_testcase extends advanced_testcase { delete_course($courseid2, false); foreach ($questions as $question) { question_delete_question($question->id); - - // TODO: Remove this assert when restore a question is fixed (create version and bank entry). - $this->assertDebuggingCalled('Deleting question ' . $question->id . - ' which is no longer linked to a context. Assuming system context ' . - 'to avoid errors, but this may mean that some data like ' . - 'files, tags, are not cleaned up.'); } $category1->delete_full(false); @@ -198,7 +199,7 @@ class core_question_backup_testcase extends advanced_testcase { $questions = $DB->get_records_sql("SELECT q.* FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id - JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid + JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid WHERE qc.contextid = ?", [$coursecontext3->id]); $this->assertCount(2, $questions); diff --git a/question/tests/behat/edit_question_versioning.feature b/question/tests/behat/edit_question_versioning.feature new file mode 100644 index 00000000000..4d13bca2c5b --- /dev/null +++ b/question/tests/behat/edit_question_versioning.feature @@ -0,0 +1,55 @@ +@core @core_question +Feature: Questions in the question bank have versions + In order to see modified questions + As a teacher + I want to view them as different versions + + Background: + Given the following "courses" exist: + | fullname | shortname | category | groupmode | + | Course 1 | C1 | 0 | 1 | + And the following "users" exist: + | username | firstname | lastname | email | + | teacher1 | Teacher | 1 | teacher1@example.com | + And the following "course enrolments" exist: + | user | course | role | + | teacher1 | C1 | editingteacher | + And the following "question categories" exist: + | contextlevel | reference | name | + | Course | C1 | Test questions | + And the following "activities" exist: + | activity | name | course | idnumber | + | quiz | Quiz 1 | C1 | quiz1 | + And the following "questions" exist: + | questioncategory | qtype | name | questiontext | answer 1 | + | Test questions | truefalse | First question | Answer the first question | True | + And quiz "Quiz 1" contains the following questions: + | question | page | + | First question | 1 | + And I log in as "teacher1" + And I am on "Course 1" course homepage + + @javascript + Scenario: Question version is displayed + When I navigate to "Question bank" in current page administration + And I should see "First question" + And I click on "Edit" "link" in the "First question" "table_row" + And I follow "Edit question" + Then I should see "Version 1" + + @javascript + Scenario: Question version change when question is altered + When I navigate to "Question bank" in current page administration + And I should see "First question" + And I click on "Edit" "link" in the "First question" "table_row" + And I follow "Edit question" + And I should see "Version 1" + And I set the field "id_name" to "Renamed question v2" + And I set the field "id_questiontext" to "edited question" + And I press "id_submitbutton" + And I should not see "First question" + And I should see "Renamed question v2" + And I click on "Edit" "link" in the "Renamed question v2" "table_row" + And I follow "Edit question" + Then I should see "Version 2" + And I should not see "Version 1" diff --git a/question/tests/behat/question_categories.feature b/question/tests/behat/question_categories.feature index 90ab3e04f13..f32d2d9e6e3 100644 --- a/question/tests/behat/question_categories.feature +++ b/question/tests/behat/question_categories.feature @@ -39,16 +39,3 @@ Feature: A teacher can move questions between categories in the question bank And the field "Select a category" matches value "      Subcategory (1)" And the "Select a category" select box should contain "Used category" And the "Select a category" select box should not contain "Used category (1)" - - @javascript - Scenario: Move a question between categories via the question settings page - When I navigate to "Question bank" in current page administration - And I set the field "Select a category" to "Used category" - And I choose "Edit question" action for "Test question to be moved" in the question bank - And I click on "Use this category" "checkbox" - And I set the field "Save in category" to "Subcategory" - And I press "id_submitbutton" - Then I should see "Test question to be moved" - And the field "Select a category" matches value "      Subcategory (1)" - And the "Select a category" select box should contain "Used category" - And the "Select a category" select box should not contain "Used category (1)" diff --git a/question/tests/behat/question_categories_idnumber.feature b/question/tests/behat/question_categories_idnumber.feature index e3f3a48cc46..65351010747 100644 --- a/question/tests/behat/question_categories_idnumber.feature +++ b/question/tests/behat/question_categories_idnumber.feature @@ -46,7 +46,7 @@ Feature: A teacher can put questions with idnumbers in categories in the questio And I press "Save changes" Then I should not see "This ID number is already in use" - Scenario: Question idnumber conflicts found when saving to a different category. + Scenario: Question idnumber conflicts found when saving to the same category. When the following "question categories" exist: | contextlevel | reference | questioncategory | name | | Course | C1 | Top | top | @@ -55,13 +55,10 @@ Feature: A teacher can put questions with idnumbers in categories in the questio And the following "questions" exist: | questioncategory | qtype | name | questiontext | idnumber | | Category 1 | essay | Question to edit | Write about whatever you want | q1 | - | Category 2 | essay | Other question | Write about whatever you want | q2 | + | Category 1 | essay | Other question | Write about whatever you want | q2 | And I navigate to "Question bank" in current page administration And I choose "Edit question" action for "Question to edit" in the question bank - And I set the following fields to these values: - | Use this category | 0 | - | ID number | q2 | - | Save in category | Category 2 | + And I set the field "ID number" to "q2" And I press "Save changes" Then I should see "This ID number is already in use" diff --git a/question/tests/generator/lib.php b/question/tests/generator/lib.php index c4afee7831c..9d47d81fa5c 100644 --- a/question/tests/generator/lib.php +++ b/question/tests/generator/lib.php @@ -131,6 +131,7 @@ class core_question_generator extends component_generator_base { $fromform = test_question_maker::get_question_form_data($qtype, $which); $fromform = (object) $this->datagenerator->combine_defaults_and_record((array) $question, $fromform); $fromform = (object) $this->datagenerator->combine_defaults_and_record((array) $fromform, $overrides); + $fromform->status = $question->status; $question = question_bank::get_qtype($qtype)->save_question($question, $fromform); diff --git a/question/tests/privacy/provider_test.php b/question/tests/privacy/provider_test.php index eedf6f819c8..0e6a3c883a0 100644 --- a/question/tests/privacy/provider_test.php +++ b/question/tests/privacy/provider_test.php @@ -463,9 +463,7 @@ class provider_test extends \core_privacy\tests\provider_testcase { // User1 has created questions and user3 has edited them. $this->assertCount(2, $userlist); - $this->assertEqualsCanonicalizing( - [$user1->id, $user3->id], - $userlist->get_userids()); + $this->assertEqualsCanonicalizing([$user1->id, $user3->id], $userlist->get_userids()); } /** @@ -512,27 +510,25 @@ class provider_test extends \core_privacy\tests\provider_testcase { provider::delete_data_for_users($approveduserlist); // Now, there should be no question related to user1 or user2 in course1. - $this->assertEquals( - 0, + $this->assertEquals(0, $DB->count_records_sql("SELECT COUNT(q.id) - FROM {question} q - JOIN {question_versions} qv ON qv.questionid = q.id - JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid - JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid - WHERE qc.contextid = ? - AND (q.createdby = ? OR q.modifiedby = ? OR q.createdby = ? OR 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 = ? + AND (q.createdby = ? OR q.modifiedby = ? OR q.createdby = ? OR q.modifiedby = ?)", [$course1context->id, $user1->id, $user1->id, $user2->id, $user2->id]) ); // User3 data in course1 should not change. - $this->assertEquals( - 2, + $this->assertEquals(2, $DB->count_records_sql("SELECT COUNT(q.id) - FROM {question} q - JOIN {question_versions} qv ON qv.questionid = q.id - JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid - JOIN {question_categories} qc ON qc.id = qbe.questioncategoryid - WHERE qc.contextid = ? AND (q.createdby = ? OR 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 = ? AND (q.createdby = ? OR q.modifiedby = ?)", [$course1context->id, $user3->id, $user3->id]) ); diff --git a/question/tests/version_test.php b/question/tests/version_test.php index bd257027e60..f3132e7960e 100644 --- a/question/tests/version_test.php +++ b/question/tests/version_test.php @@ -19,7 +19,7 @@ namespace core_question; use question_bank; /** - * Comment lib unit tests. + * Question version unit tests. * * @package core_question * @copyright 2021 Catalyst IT Australia Pty Ltd diff --git a/question/type/calculated/datasetdefinitions_form.php b/question/type/calculated/datasetdefinitions_form.php index 67334c397c6..ecf590d2612 100644 --- a/question/type/calculated/datasetdefinitions_form.php +++ b/question/type/calculated/datasetdefinitions_form.php @@ -56,16 +56,13 @@ class question_dataset_dependent_definitions_form extends question_wizard_form { * @param MoodleQuickForm $mform the form being built. */ public function __construct($submiturl, $question) { - global $DB; + // Validate the question category. + if (!isset($question->categoryobject)) { + throw new moodle_exception('categorydoesnotexist', 'question'); + } + $question->category = $question->categoryobject->id; $this->question = $question; $this->qtypeobj = question_bank::get_qtype($this->question->qtype); - // Validate the question category. - if (!$category = $DB->get_record('question_categories', - array('id' => $question->category))) { - print_error('categorydoesnotexist', 'question', $returnurl); - } - $this->category = $category; - $this->categorycontext = context::instance_by_id($category->contextid); parent::__construct($submiturl); } diff --git a/question/type/calculated/datasetitems_form.php b/question/type/calculated/datasetitems_form.php index e0e9b71809c..250fe8a2cd0 100644 --- a/question/type/calculated/datasetitems_form.php +++ b/question/type/calculated/datasetitems_form.php @@ -75,17 +75,18 @@ class question_dataset_dependent_items_form extends question_wizard_form { * @param MoodleQuickForm $mform the form being built. */ public function __construct($submiturl, $question, $regenerate) { - global $SESSION, $CFG, $DB; + global $SESSION; + + // Validate the question category. + if (!isset($question->categoryobject)) { + throw new moodle_exception('categorydoesnotexist', 'question'); + } + $question->category = $question->categoryobject->id; + $this->category = $question->categoryobject; + $this->categorycontext = context::instance_by_id($this->category->contextid); $this->regenerate = $regenerate; $this->question = $question; $this->qtypeobj = question_bank::get_qtype($this->question->qtype); - // Validate the question category. - if (!$category = $DB->get_record('question_categories', - array('id' => $question->category))) { - print_error('categorydoesnotexist', 'question', $returnurl); - } - $this->category = $category; - $this->categorycontext = context::instance_by_id($category->contextid); // Get the dataset defintions for this question. if (empty($question->id)) { $this->datasetdefs = $this->qtypeobj->get_dataset_definitions( diff --git a/question/type/calculatedsimple/tests/questiontype_test.php b/question/type/calculatedsimple/tests/questiontype_test.php index efc037033c4..73d9c39b97d 100644 --- a/question/type/calculatedsimple/tests/questiontype_test.php +++ b/question/type/calculatedsimple/tests/questiontype_test.php @@ -89,7 +89,7 @@ class qtype_calculatedsimple_test extends advanced_testcase { $actualquestiondata = end($actualquestionsdata); foreach ($questiondata as $property => $value) { - if (!in_array($property, array('id', 'timemodified', 'timecreated', 'options'))) { + if (!in_array($property, array('id', 'timemodified', 'timecreated', 'options', 'idnumber'))) { $this->assertEquals($value, $actualquestiondata->$property); } } diff --git a/question/type/description/tests/questiontype_test.php b/question/type/description/tests/questiontype_test.php index ec685d0f986..eb33f8bad01 100644 --- a/question/type/description/tests/questiontype_test.php +++ b/question/type/description/tests/questiontype_test.php @@ -94,7 +94,7 @@ class qtype_description_test extends advanced_testcase { $actualquestiondata = end($actualquestionsdata); foreach ($questiondata as $property => $value) { - if (!in_array($property, array('id', 'timemodified', 'timecreated'))) { + if (!in_array($property, array('id', 'timemodified', 'timecreated', 'idnumber'))) { $this->assertEquals($value, $actualquestiondata->$property); } } diff --git a/question/type/edit_question_form.php b/question/type/edit_question_form.php index fb39f7e492d..ebd23e12537 100644 --- a/question/type/edit_question_form.php +++ b/question/type/edit_question_form.php @@ -883,14 +883,14 @@ abstract class question_edit_form extends question_wizard_form { // Get the question bank entry id to not check the idnumber for the same bank entry. $sql = "SELECT DISTINCT qbe.id FROM {question_versions} qv - JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid + JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid WHERE qv.questionid = ?"; $bankentry = $DB->get_record_sql($sql, ['id' => $this->question->id]); $conditions .= ' AND id <> ?'; $params[] = $bankentry->id; } - if ($DB->record_exists_select('question_bank_entry', $conditions, $params)) { + if ($DB->record_exists_select('question_bank_entries', $conditions, $params)) { $errors['idnumber'] = get_string('idnumbertaken', 'error'); } } diff --git a/question/type/essay/tests/behat/file_type_restriction.feature b/question/type/essay/tests/behat/file_type_restriction.feature index 82f164af7c9..fe7983fe796 100644 --- a/question/type/essay/tests/behat/file_type_restriction.feature +++ b/question/type/essay/tests/behat/file_type_restriction.feature @@ -35,6 +35,8 @@ I need to limit the submittable file types And I set the field "Require attachments" to "1" And I set the field "filetypeslist[filetypes]" to ".txt" And I press "Save changes" + And I am on the "Quiz 1" "mod_quiz > edit" page logged in as teacher1 + And I set the field "version" to "Always latest" Then I log out @javascript @_file_upload diff --git a/question/type/essay/tests/restore_test.php b/question/type/essay/tests/restore_test.php index a2bfda292c9..f95a9ecf4e4 100644 --- a/question/type/essay/tests/restore_test.php +++ b/question/type/essay/tests/restore_test.php @@ -62,7 +62,12 @@ class qtype_essay_restore_testcase extends restore_date_testcase { // Verify that the restored question has options. $contexts = new core_question\lib\question_edit_contexts(context_course::instance($newcourseid)); $newcategory = question_make_default_categories($contexts->all()); - $newessay = $DB->get_record('question', ['category' => $newcategory->id, 'qtype' => 'essay']); + $newessay = $DB->get_record_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 + WHERE qbe.questioncategoryid = ? + AND q.qtype = ?', [$newcategory->id, 'essay']); $this->assertTrue($DB->record_exists('qtype_essay_options', ['questionid' => $newessay->id])); } } diff --git a/question/type/match/tests/questiontype_test.php b/question/type/match/tests/questiontype_test.php index af11ff690aa..4165b01cedb 100644 --- a/question/type/match/tests/questiontype_test.php +++ b/question/type/match/tests/questiontype_test.php @@ -181,14 +181,18 @@ class qtype_match_test extends advanced_testcase { $fromform = $form->get_data(); + // Create a new question version with the form submission. + unset($questiondata->id); $returnedfromsave = $this->qtype->save_question($questiondata, $fromform); - $actualquestionsdata = question_load_questions(array($returnedfromsave->id)); + $actualquestionsdata = question_load_questions([$returnedfromsave->id], 'qbe.idnumber'); $actualquestiondata = end($actualquestionsdata); foreach ($questiondata as $property => $value) { if (!in_array($property, ['id', 'timemodified', 'timecreated', 'options', 'stamp', 'versionid', 'questionbankentryid'])) { - $this->assertEquals($value, $actualquestiondata->$property); + if (!empty($actualquestiondata)) { + $this->assertEquals($value, $actualquestiondata->$property); + } } } diff --git a/question/type/multianswer/edit_multianswer_form.php b/question/type/multianswer/edit_multianswer_form.php index 1b9c07be7a9..58a8f44349d 100644 --- a/question/type/multianswer/edit_multianswer_form.php +++ b/question/type/multianswer/edit_multianswer_form.php @@ -61,7 +61,6 @@ class qtype_multianswer_edit_form extends question_edit_form { public function __construct($submiturl, $question, $category, $contexts, $formeditable = true) { - global $SESSION, $CFG, $DB; $this->regenerate = true; $this->reload = optional_param('reload', false, PARAM_BOOL); @@ -70,14 +69,10 @@ class qtype_multianswer_edit_form extends question_edit_form { if (isset($question->id) && $question->id != 0) { // TODO MDL-43779 should not have quiz-specific code here. $this->savedquestiondisplay = fullclone($question); - $this->nbofquiz = $DB->count_records('quiz_slots', array('questionid' => $question->id)); + $questiondata = question_bank::load_question($question->id); + $this->nbofquiz = \qbank_usage\helper::get_question_entry_usage_count($questiondata); $this->usedinquiz = $this->nbofquiz > 0; - $this->nbofattempts = $DB->count_records_sql(" - SELECT count(1) - FROM {quiz_slots} slot - JOIN {quiz_attempts} quiza ON quiza.quiz = slot.quizid - WHERE slot.questionid = ? - AND quiza.preview = 0", array($question->id)); + $this->nbofattempts = \qbank_usage\helper::get_question_attempts_count_in_quiz((int)$question->id); } parent::__construct($submiturl, $question, $category, $contexts, $formeditable); diff --git a/question/type/multianswer/tests/questiontype_test.php b/question/type/multianswer/tests/questiontype_test.php index 1c5f510ef6c..8dc40c017f5 100644 --- a/question/type/multianswer/tests/questiontype_test.php +++ b/question/type/multianswer/tests/questiontype_test.php @@ -240,6 +240,7 @@ class qtype_multianswer_test extends advanced_testcase { $this->setAdminUser(); $questiondata = test_question_maker::get_question_data('multianswer'); + $formdata = test_question_maker::get_question_form_data('multianswer'); $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); @@ -253,14 +254,15 @@ class qtype_multianswer_test extends advanced_testcase { $this->assertTrue($form->is_validated()); $fromform = $form->get_data(); - + // Create a new question version with the form submission. + unset($questiondata->id); $returnedfromsave = $this->qtype->save_question($questiondata, $fromform); $actualquestionsdata = question_load_questions(array($returnedfromsave->id)); $actualquestiondata = end($actualquestionsdata); foreach ($questiondata as $property => $value) { if (!in_array($property, ['id', 'timemodified', 'timecreated', 'options', 'hints', 'stamp', - 'idnumber', 'version', 'versionid', 'questionbankentryid'])) { + 'idnumber', 'version', 'versionid', 'questionbankentryid', 'contextid', 'category', 'status'])) { $this->assertEquals($value, $actualquestiondata->$property); } } diff --git a/question/type/multichoice/tests/questiontype_test.php b/question/type/multichoice/tests/questiontype_test.php index ca961968e8f..f4098b27985 100644 --- a/question/type/multichoice/tests/questiontype_test.php +++ b/question/type/multichoice/tests/questiontype_test.php @@ -129,7 +129,7 @@ class qtype_multichoice_test extends advanced_testcase { $fromform = $form->get_data(); $returnedfromsave = $this->qtype->save_question($questiondata, $fromform); - $actualquestionsdata = question_load_questions(array($returnedfromsave->id)); + $actualquestionsdata = question_load_questions([$returnedfromsave->id], 'qbe.idnumber'); $actualquestiondata = end($actualquestionsdata); foreach ($questiondata as $property => $value) { diff --git a/question/type/numerical/tests/questiontype_test.php b/question/type/numerical/tests/questiontype_test.php index fef820cc586..273cecb8a73 100644 --- a/question/type/numerical/tests/questiontype_test.php +++ b/question/type/numerical/tests/questiontype_test.php @@ -144,7 +144,7 @@ class qtype_numerical_test extends advanced_testcase { $fromform = $form->get_data(); $returnedfromsave = $this->qtype->save_question($questiondata, $fromform); - $actualquestionsdata = question_load_questions(array($returnedfromsave->id)); + $actualquestionsdata = question_load_questions([$returnedfromsave->id], 'qbe.idnumber'); $actualquestiondata = end($actualquestionsdata); foreach ($questiondata as $property => $value) { diff --git a/question/type/questiontypebase.php b/question/type/questiontypebase.php index 28db6c8a17e..f9c9f36a88e 100644 --- a/question/type/questiontypebase.php +++ b/question/type/questiontypebase.php @@ -415,32 +415,6 @@ class question_type { $question->defaultmark = $form->defaultmark; } - if (isset($form->idnumber)) { - if ((string) $form->idnumber === '') { - $question->idnumber = null; - } else { - // While this check already exists in the form validation, - // this is a backstop preventing unnecessary errors. - // Only set the idnumber if it has changed and will not cause a unique index violation. - if (strpos($form->category, ',') !== false) { - list($category, $categorycontextid) = explode(',', $form->category); - } else { - $category = $form->category; - } - $sql = "SELECT qbe.id - FROM {question_bank_entry} qbe - WHERE qbe.idnumber = :idnumber - AND qbe.questioncategoryid = :categoryid"; - - if (!$DB->record_exists_sql($sql, - ['idnumber' => $form->idnumber, 'categoryid' => $category])) { - $question->idnumber = $form->idnumber; - } - } - } else { - $question->idnumber = null; - } - // Only create a new bank entry if the question is not a new version (New question or duplicating a question). $questionbankentry = null; if (isset($question->id)) { @@ -575,15 +549,9 @@ class question_type { '$result->noticeyesno no longer supported in save_question.'); } - if ($newquestion) { - // Log the creation of this question. - $event = \core\event\question_created::create_from_question_instance($question, $context); - $event->trigger(); - } else { - // Log the update of this question. - $event = \core\event\question_updated::create_from_question_instance($question, $context); - $event->trigger(); - } + // Log the creation of this question. + $event = \core\event\question_created::create_from_question_instance($question, $context); + $event->trigger(); $transaction->allow_commit(); @@ -1030,13 +998,12 @@ class question_type { $question->length = $questiondata->length; $question->penalty = $questiondata->penalty; $question->stamp = $questiondata->stamp; - $question->idnumber = $questiondata->idnumber; $question->timecreated = $questiondata->timecreated; $question->timemodified = $questiondata->timemodified; $question->createdby = $questiondata->createdby; $question->modifiedby = $questiondata->modifiedby; - $this->initialise_question_extra_record($question, $questiondata); + $this->initialise_core_question_metadata($question, $questiondata); // Fill extra question fields values. $extraquestionfields = $this->extra_question_fields(); @@ -1055,18 +1022,20 @@ class question_type { } /** - * Initialise the extra question fields. + * Initialise the question metadata. + * * @param question_definition $question the question_definition we are creating. * @param object $questiondata the question data loaded from the database. */ - protected function initialise_question_extra_record(question_definition $question, $questiondata) { - $fields = [ - 'status', - 'versionid', - 'version', - 'questionbankentryid', - 'idnumber', - ]; + protected function initialise_core_question_metadata(question_definition $question, $questiondata) { + $fields = + [ + 'status', + 'versionid', + 'version', + 'questionbankentryid', + 'idnumber', + ]; foreach ($fields as $field) { if (isset($questiondata->{$field})) { diff --git a/question/type/random/questiontype.php b/question/type/random/questiontype.php index 11d7c313591..6f9b7ac186b 100644 --- a/question/type/random/questiontype.php +++ b/question/type/random/questiontype.php @@ -90,7 +90,7 @@ class qtype_random extends question_type { $sql = "SELECT q.* FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id - JOIN {question_bank_entry} qbe ON qbe.id = qv.questionbankentryid + JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid WHERE qbe.questioncategoryid {$qcsql} AND q.parent = 0 AND qv.status = $readystatus diff --git a/question/type/shortanswer/tests/questiontype_test.php b/question/type/shortanswer/tests/questiontype_test.php index d1aa4d3489c..5298704af5a 100644 --- a/question/type/shortanswer/tests/questiontype_test.php +++ b/question/type/shortanswer/tests/questiontype_test.php @@ -115,8 +115,10 @@ class qtype_shortanswer_test extends advanced_testcase { $fromform = $form->get_data(); + // Create a new question version with the form submission. + unset($questiondata->id); $returnedfromsave = $this->qtype->save_question($questiondata, $fromform); - $actualquestionsdata = question_load_questions(array($returnedfromsave->id)); + $actualquestionsdata = question_load_questions([$returnedfromsave->id], 'qbe.idnumber'); $actualquestiondata = end($actualquestionsdata); foreach ($questiondata as $property => $value) { diff --git a/question/type/truefalse/tests/questiontype_test.php b/question/type/truefalse/tests/questiontype_test.php index 26d7bf4c7cc..7e02dc7784e 100644 --- a/question/type/truefalse/tests/questiontype_test.php +++ b/question/type/truefalse/tests/questiontype_test.php @@ -156,7 +156,7 @@ class qtype_truefalse_test extends advanced_testcase { $fromform = $form->get_data(); $returnedfromsave = $this->qtype->save_question($questiondata, $fromform); - $actualquestionsdata = question_load_questions(array($returnedfromsave->id)); + $actualquestionsdata = question_load_questions([$returnedfromsave->id], 'qbe.idnumber'); $actualquestiondata = end($actualquestionsdata); foreach ($questiondata as $property => $value) { diff --git a/report/questioninstances/index.php b/report/questioninstances/index.php index 3a34cb25b7f..d7a4b1aff12 100644 --- a/report/questioninstances/index.php +++ b/report/questioninstances/index.php @@ -57,6 +57,7 @@ echo '

'; echo $OUTPUT->box_end(); +$params[] = \core_question\local\bank\question_version_status::QUESTION_STATUS_HIDDEN; // If we have a qtype to report on, generate the report. if ($requestedqtype) { @@ -74,14 +75,13 @@ if ($requestedqtype) { $title = get_string('reportforallqtypes', 'report_questioninstances'); $sqlqtypetest = ''; - $params = array(); } else { $title = get_string('reportforqtype', 'report_questioninstances', question_bank::get_qtype($requestedqtype)->local_name()); $sqlqtypetest = 'WHERE qtype = ?'; - $params = array($requestedqtype); + $params [] = $requestedqtype; } // Get the question counts, and all the context information, for each @@ -94,7 +94,7 @@ if ($requestedqtype) { (SELECT COUNT(qv.id) FROM {question_versions} qv WHERE qv.id = data.versionid - AND qv.status = 1) AS numhidden + AND qv.status = ?) AS numhidden FROM (SELECT qv.id as versionid, qc.contextid, 1 AS numquestions FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id From b1ad75aef546cacacf3c594a986625bfe86d10ab Mon Sep 17 00:00:00 2001 From: Marc-Alexandre Ghaly Date: Thu, 14 Oct 2021 14:18:54 -0400 Subject: [PATCH 6/8] MDL-71696 qbank_preview: previewquestion plugin update This commit implements versioning and associated features in the previewquestion plugin. There have been some major changes in the random question and version selection for questions. This commit made those changes as well as added some more features in the preview for version selection and adds more behat coverage. --- lib/questionlib.php | 10 +- mod/quiz/classes/output/edit_renderer.php | 1 + mod/quiz/locallib.php | 4 +- .../report/statistics/statistics_table.php | 6 +- .../statistics/tests/behat/basic.feature | 2 +- .../previewquestion/amd/build/preview.min.js | 2 +- .../amd/build/preview.min.js.map | 2 +- .../bank/previewquestion/amd/src/preview.js | 9 +- .../classes/form/preview_options_form.php | 9 +- .../bank/previewquestion/classes/helper.php | 124 ++++++++++++++---- .../lang/en/qbank_previewquestion.php | 11 ++ question/bank/previewquestion/preview.php | 44 +++---- .../templates/preview_question.mustache | 68 ++++++---- .../tests/behat/preview_question.feature | 31 +---- ...test.php => qbank_preview_helper_test.php} | 41 +++++- .../classes/local/bank/edit_menu_column.php | 8 ++ question/classes/local/bank/view.php | 16 +++ .../description/tests/behat/preview.feature | 1 + .../type/essay/tests/behat/preview.feature | 3 + .../type/match/tests/behat/preview.feature | 3 + .../multichoice/tests/behat/preview.feature | 4 + .../numerical/tests/behat/preview.feature | 1 + .../truefalse/tests/behat/preview.feature | 2 + 23 files changed, 281 insertions(+), 121 deletions(-) rename question/bank/previewquestion/tests/{helper_test.php => qbank_preview_helper_test.php} (75%) diff --git a/lib/questionlib.php b/lib/questionlib.php index b62376f73cd..bfb671e18d4 100644 --- a/lib/questionlib.php +++ b/lib/questionlib.php @@ -1117,7 +1117,15 @@ function question_sort_tags($tagobjects, $categorycontext, $filtercourses = null */ function print_question_icon($question): string { global $PAGE; - return $PAGE->get_renderer('question', 'bank')->qtype_icon($question->qtype); + + if (gettype($question->qtype) == 'object') { + $qtype = $question->qtype->name(); + } else { + // Assume string. + $qtype = $question->qtype; + } + + return $PAGE->get_renderer('question', 'bank')->qtype_icon($qtype); } // CATEGORY FUNCTIONS. diff --git a/mod/quiz/classes/output/edit_renderer.php b/mod/quiz/classes/output/edit_renderer.php index 2ded59c3e06..4e6b8a6b7e2 100644 --- a/mod/quiz/classes/output/edit_renderer.php +++ b/mod/quiz/classes/output/edit_renderer.php @@ -28,6 +28,7 @@ defined('MOODLE_INTERNAL') || die(); use mod_quiz\question\bank\qbank_helper; use \mod_quiz\structure; use \html_writer; +use \qbank_previewquestion\helper; use renderable; /** diff --git a/mod/quiz/locallib.php b/mod/quiz/locallib.php index d3a45954ce9..1d3e2681605 100644 --- a/mod/quiz/locallib.php +++ b/mod/quiz/locallib.php @@ -1361,6 +1361,7 @@ function quiz_attempt_state_name($state) { * @param object $question the question. * @param string $returnurl url to return to after action is done. * @param int $variant which question variant to preview (optional). + * @param bool $random if question is random, true. * @return string html for a number of icons linked to action pages for a * question - preview and edit / view icons depending on user capabilities. */ @@ -1448,9 +1449,10 @@ function quiz_question_preview_url($quiz, $question, $variant = null) { * @param object $question the question * @param bool $label if true, show the preview question label after the icon * @param int $variant which question variant to preview (optional). + * @param bool $random if question is random, true. * @return the HTML for a preview question icon. */ -function quiz_question_preview_button($quiz, $question, $label = false, $variant = null) { +function quiz_question_preview_button($quiz, $question, $label = false, $variant = null, $random = null) { global $PAGE; if (!question_has_capability_on($question, 'use')) { return ''; diff --git a/mod/quiz/report/statistics/statistics_table.php b/mod/quiz/report/statistics/statistics_table.php index 6201f14cc4f..04538b0e9c8 100644 --- a/mod/quiz/report/statistics/statistics_table.php +++ b/mod/quiz/report/statistics/statistics_table.php @@ -202,8 +202,12 @@ class quiz_statistics_table extends flexible_table { if ($this->is_calculated_question_summary($questionstat)) { return ''; } else { + $random = null; + if ($questionstat->question->qtype === 'random') { + $random = true; + } return quiz_question_action_icons($this->quiz, $this->cmid, - $questionstat->question, $this->baseurl, $questionstat->variant); + $questionstat->question, $this->baseurl, $questionstat->variant, $random); } } diff --git a/mod/quiz/report/statistics/tests/behat/basic.feature b/mod/quiz/report/statistics/tests/behat/basic.feature index 293a391a2c4..34a0c48d1ff 100644 --- a/mod/quiz/report/statistics/tests/behat/basic.feature +++ b/mod/quiz/report/statistics/tests/behat/basic.feature @@ -59,7 +59,7 @@ Feature: Basic use of the Statistics report | 1 | False | | 2 | False | | 3 | False | - And I press "Go back" + And I am on the "Quiz 1" "quiz activity" page logged in as teacher1 And I navigate to "Results > Statistics" in current page administration And I press "Show report" Then I should not see "No questions have been attempted yet" diff --git a/question/bank/previewquestion/amd/build/preview.min.js b/question/bank/previewquestion/amd/build/preview.min.js index a1668b6ede1..17a503fce74 100644 --- a/question/bank/previewquestion/amd/build/preview.min.js +++ b/question/bank/previewquestion/amd/build/preview.min.js @@ -1,2 +1,2 @@ -define ("qbank_previewquestion/preview",["exports","jquery"],function(a,b){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.init=void 0;b=function(a){return a&&a.__esModule?a:{default:a}}(b);a.init=function init(a){if(!a){var b=document.getElementById("close-previewquestion-page");b.onclick=function(){window.close()}}c("responseform")};var c=function(a){var b=document.getElementById(a);if(b){d(b);e(b);f(".questionflagsavebutton",b);g(b)}},d=function(a){a.setAttribute("autocomplete","off")},e=function(a){a.addEventListener("submit",function(){(0,b.default)(this).submit(function(){return!1});return!0})},f=function(a,b){b.querySelectorAll(a).forEach(function(a){return a.remove()})},g=function(a){var b=window.location.href.match(/^.*[?&]scrollpos=(\d*)(?:&|$|#).*$/,"$1");if(b){window.scrollTo(0,b[1]);a.addEventListener("DOMContentLoaded",function(){window.scrollTo(0,b[1])})}}}); +define ("qbank_previewquestion/preview",["exports","jquery"],function(a,b){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.init=void 0;b=function(a){return a&&a.__esModule?a:{default:a}}(b);a.init=function init(a,b){if(!a){var d=document.getElementById("close-previewquestion-page");d.onclick=function(){if(null===window.opener){location.href=b}else{window.close()}}}c("responseform")};var c=function(a){var b=document.getElementById(a);if(b){d(b);e(b);f(".questionflagsavebutton",b);g(b)}},d=function(a){a.setAttribute("autocomplete","off")},e=function(a){a.addEventListener("submit",function(){(0,b.default)(this).submit(function(){return!1});return!0})},f=function(a,b){b.querySelectorAll(a).forEach(function(a){return a.remove()})},g=function(a){var b=window.location.href.match(/^.*[?&]scrollpos=(\d*)(?:&|$|#).*$/,"$1");if(b){window.scrollTo(0,b[1]);a.addEventListener("DOMContentLoaded",function(){window.scrollTo(0,b[1])})}}}); //# sourceMappingURL=preview.min.js.map diff --git a/question/bank/previewquestion/amd/build/preview.min.js.map b/question/bank/previewquestion/amd/build/preview.min.js.map index 4d8aced4c84..cda32d76a5a 100644 --- a/question/bank/previewquestion/amd/build/preview.min.js.map +++ b/question/bank/previewquestion/amd/build/preview.min.js.map @@ -1 +1 @@ -{"version":3,"sources":["../src/preview.js"],"names":["init","redirect","closeButton","document","getElementById","onclick","window","close","setupQuestionForm","formElement","form","autocompleteOff","preventRepeatSubmission","removeClass","scrollToSavedPos","setAttribute","addEventListener","submit","classname","querySelectorAll","forEach","e","remove","matches","location","href","match","scrollTo"],"mappings":"uJAwBA,uD,OAQoB,QAAPA,CAAAA,IAAO,CAACC,CAAD,CAAc,CAC9B,GAAI,CAACA,CAAL,CAAe,CACX,GAAIC,CAAAA,CAAW,CAAGC,QAAQ,CAACC,cAAT,CAAwB,4BAAxB,CAAlB,CACAF,CAAW,CAACG,OAAZ,CAAsB,UAAM,CACxBC,MAAM,CAACC,KAAP,EACH,CACJ,CAEDC,CAAiB,CAAC,cAAD,CACpB,C,IAQKA,CAAAA,CAAiB,CAAG,SAACC,CAAD,CAAiB,CACvC,GAAIC,CAAAA,CAAI,CAAGP,QAAQ,CAACC,cAAT,CAAwBK,CAAxB,CAAX,CACA,GAAIC,CAAJ,CAAU,CAENC,CAAe,CAACD,CAAD,CAAf,CAEAE,CAAuB,CAACF,CAAD,CAAvB,CAEAG,CAAW,CAAC,yBAAD,CAA4BH,CAA5B,CAAX,CAEAI,CAAgB,CAACJ,CAAD,CACnB,CACJ,C,CAQKC,CAAe,CAAG,SAACD,CAAD,CAAU,CAC9BA,CAAI,CAACK,YAAL,CAAkB,cAAlB,CAAkC,KAAlC,CACH,C,CAQKH,CAAuB,CAAG,SAACF,CAAD,CAAU,CACtCA,CAAI,CAACM,gBAAL,CAAsB,QAAtB,CAAgC,UAAW,CACvC,cAAE,IAAF,EAAQC,MAAR,CAAe,UAAW,CACtB,QACH,CAFD,EAGA,QACH,CALD,CAMH,C,CASKJ,CAAW,CAAG,SAACK,CAAD,CAAYR,CAAZ,CAAqB,CACrCA,CAAI,CAACS,gBAAL,CAAsBD,CAAtB,EAAiCE,OAAjC,CAAyC,SAAAC,CAAC,QAAIA,CAAAA,CAAC,CAACC,MAAF,EAAJ,CAA1C,CACH,C,CASKR,CAAgB,CAAG,SAACJ,CAAD,CAAU,CAC/B,GAAIa,CAAAA,CAAO,CAAGjB,MAAM,CAACkB,QAAP,CAAgBC,IAAhB,CAAqBC,KAArB,CAA2B,oCAA3B,CAAiE,IAAjE,CAAd,CACA,GAAIH,CAAJ,CAAa,CAGTjB,MAAM,CAACqB,QAAP,CAAgB,CAAhB,CAAmBJ,CAAO,CAAC,CAAD,CAA1B,EACAb,CAAI,CAACM,gBAAL,CAAsB,kBAAtB,CAA0C,UAAM,CAC5CV,MAAM,CAACqB,QAAP,CAAgB,CAAhB,CAAmBJ,CAAO,CAAC,CAAD,CAA1B,CACH,CAFD,CAGH,CACJ,C","sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Javascript for preview.\n *\n * @module qbank_preview/preview\n * @copyright 2021 Catalyst IT Australia Pty Ltd\n * @author Safat Shahin \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport $ from 'jquery';\n\n/**\n * Set up the actions.\n *\n * @method init\n * @param {bool} redirect Redirect.\n */\nexport const init = (redirect) => {\n if (!redirect) {\n let closeButton = document.getElementById('close-previewquestion-page');\n closeButton.onclick = () => {\n window.close();\n };\n }\n // Set up the form to be displayed.\n setupQuestionForm('responseform');\n};\n\n/**\n * Set up the form element to be displayed.\n *\n * @method setupQuestionForm\n * @param {string} formElement The form element.\n */\nconst setupQuestionForm = (formElement) => {\n let form = document.getElementById(formElement);\n if (form) {\n // Turning off browser autocomplete.\n autocompleteOff(form);\n // Stop a question form being submitted more than once.\n preventRepeatSubmission(form);\n // Removes any '.questionflagsavebutton's, since we have JavaScript to toggle.\n removeClass('.questionflagsavebutton', form);\n // Scroll to the position indicated by scrollpos= in the URL, if it is there.\n scrollToSavedPos(form);\n }\n};\n\n/**\n * Set the autocomplete off.\n *\n * @method autocompleteOff\n * @param {object} form The form element.\n */\nconst autocompleteOff = (form) => {\n form.setAttribute(\"autocomplete\", \"off\");\n};\n\n/**\n * Event handler to stop a question form being submitted more than once.\n *\n * @method preventRepeatSubmission\n * @param {object} form The form element.\n */\nconst preventRepeatSubmission = (form) => {\n form.addEventListener(\"submit\", function() {\n $(this).submit(function() {\n return false;\n });\n return true;\n });\n};\n\n/**\n * Removes a class inside an element.\n *\n * @method removeClass\n * @param {string} classname Class name.\n * @param {object} form The form element.\n */\nconst removeClass = (classname, form) => {\n form.querySelectorAll(classname).forEach(e => e.remove());\n};\n\n/**\n * If there is a parameter like scrollpos=123 in the URL, scroll to that saved position.\n * (Note: Moodle 4.0 and above do NOT support Internet Explorer 11 and below.)\n *\n * @method scrollToSavedPos\n * @param {object} form The form element.\n */\nconst scrollToSavedPos = (form) => {\n let matches = window.location.href.match(/^.*[?&]scrollpos=(\\d*)(?:&|$|#).*$/, '$1');\n if (matches) {\n // DOMContentLoaded is the effective one here. I am leaving the immediate call to\n // window.scrollTo in case it reduces flicker.\n window.scrollTo(0, matches[1]);\n form.addEventListener(\"DOMContentLoaded\", () => {\n window.scrollTo(0, matches[1]);\n });\n }\n};\n"],"file":"preview.min.js"} \ No newline at end of file +{"version":3,"sources":["../src/preview.js"],"names":["init","redirect","url","closeButton","document","getElementById","onclick","window","opener","location","href","close","setupQuestionForm","formElement","form","autocompleteOff","preventRepeatSubmission","removeClass","scrollToSavedPos","setAttribute","addEventListener","submit","classname","querySelectorAll","forEach","e","remove","matches","match","scrollTo"],"mappings":"uJAwBA,uD,OASoB,QAAPA,CAAAA,IAAO,CAACC,CAAD,CAAWC,CAAX,CAAmB,CACnC,GAAI,CAACD,CAAL,CAAe,CACX,GAAIE,CAAAA,CAAW,CAAGC,QAAQ,CAACC,cAAT,CAAwB,4BAAxB,CAAlB,CACAF,CAAW,CAACG,OAAZ,CAAsB,UAAM,CACxB,GAAsB,IAAlB,GAAAC,MAAM,CAACC,MAAX,CAA4B,CACxBC,QAAQ,CAACC,IAAT,CAAgBR,CACnB,CAFD,IAEO,CACHK,MAAM,CAACI,KAAP,EACH,CACJ,CACJ,CAEDC,CAAiB,CAAC,cAAD,CACpB,C,IAQKA,CAAAA,CAAiB,CAAG,SAACC,CAAD,CAAiB,CACvC,GAAIC,CAAAA,CAAI,CAAGV,QAAQ,CAACC,cAAT,CAAwBQ,CAAxB,CAAX,CACA,GAAIC,CAAJ,CAAU,CAENC,CAAe,CAACD,CAAD,CAAf,CAEAE,CAAuB,CAACF,CAAD,CAAvB,CAEAG,CAAW,CAAC,yBAAD,CAA4BH,CAA5B,CAAX,CAEAI,CAAgB,CAACJ,CAAD,CACnB,CACJ,C,CAQKC,CAAe,CAAG,SAACD,CAAD,CAAU,CAC9BA,CAAI,CAACK,YAAL,CAAkB,cAAlB,CAAkC,KAAlC,CACH,C,CAQKH,CAAuB,CAAG,SAACF,CAAD,CAAU,CACtCA,CAAI,CAACM,gBAAL,CAAsB,QAAtB,CAAgC,UAAW,CACvC,cAAE,IAAF,EAAQC,MAAR,CAAe,UAAW,CACtB,QACH,CAFD,EAGA,QACH,CALD,CAMH,C,CASKJ,CAAW,CAAG,SAACK,CAAD,CAAYR,CAAZ,CAAqB,CACrCA,CAAI,CAACS,gBAAL,CAAsBD,CAAtB,EAAiCE,OAAjC,CAAyC,SAAAC,CAAC,QAAIA,CAAAA,CAAC,CAACC,MAAF,EAAJ,CAA1C,CACH,C,CASKR,CAAgB,CAAG,SAACJ,CAAD,CAAU,CAC/B,GAAIa,CAAAA,CAAO,CAAGpB,MAAM,CAACE,QAAP,CAAgBC,IAAhB,CAAqBkB,KAArB,CAA2B,oCAA3B,CAAiE,IAAjE,CAAd,CACA,GAAID,CAAJ,CAAa,CAGTpB,MAAM,CAACsB,QAAP,CAAgB,CAAhB,CAAmBF,CAAO,CAAC,CAAD,CAA1B,EACAb,CAAI,CAACM,gBAAL,CAAsB,kBAAtB,CAA0C,UAAM,CAC5Cb,MAAM,CAACsB,QAAP,CAAgB,CAAhB,CAAmBF,CAAO,CAAC,CAAD,CAA1B,CACH,CAFD,CAGH,CACJ,C","sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Javascript for preview.\n *\n * @module qbank_preview/preview\n * @copyright 2021 Catalyst IT Australia Pty Ltd\n * @author Safat Shahin \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport $ from 'jquery';\n\n/**\n * Set up the actions.\n *\n * @method init\n * @param {bool} redirect Redirect.\n * @param {string} url url to redirect.\n */\nexport const init = (redirect, url) => {\n if (!redirect) {\n let closeButton = document.getElementById('close-previewquestion-page');\n closeButton.onclick = () => {\n if (window.opener === null) {\n location.href = url;\n } else {\n window.close();\n }\n };\n }\n // Set up the form to be displayed.\n setupQuestionForm('responseform');\n};\n\n/**\n * Set up the form element to be displayed.\n *\n * @method setupQuestionForm\n * @param {string} formElement The form element.\n */\nconst setupQuestionForm = (formElement) => {\n let form = document.getElementById(formElement);\n if (form) {\n // Turning off browser autocomplete.\n autocompleteOff(form);\n // Stop a question form being submitted more than once.\n preventRepeatSubmission(form);\n // Removes any '.questionflagsavebutton's, since we have JavaScript to toggle.\n removeClass('.questionflagsavebutton', form);\n // Scroll to the position indicated by scrollpos= in the URL, if it is there.\n scrollToSavedPos(form);\n }\n};\n\n/**\n * Set the autocomplete off.\n *\n * @method autocompleteOff\n * @param {object} form The form element.\n */\nconst autocompleteOff = (form) => {\n form.setAttribute(\"autocomplete\", \"off\");\n};\n\n/**\n * Event handler to stop a question form being submitted more than once.\n *\n * @method preventRepeatSubmission\n * @param {object} form The form element.\n */\nconst preventRepeatSubmission = (form) => {\n form.addEventListener(\"submit\", function() {\n $(this).submit(function() {\n return false;\n });\n return true;\n });\n};\n\n/**\n * Removes a class inside an element.\n *\n * @method removeClass\n * @param {string} classname Class name.\n * @param {object} form The form element.\n */\nconst removeClass = (classname, form) => {\n form.querySelectorAll(classname).forEach(e => e.remove());\n};\n\n/**\n * If there is a parameter like scrollpos=123 in the URL, scroll to that saved position.\n * (Note: Moodle 4.0 and above do NOT support Internet Explorer 11 and below.)\n *\n * @method scrollToSavedPos\n * @param {object} form The form element.\n */\nconst scrollToSavedPos = (form) => {\n let matches = window.location.href.match(/^.*[?&]scrollpos=(\\d*)(?:&|$|#).*$/, '$1');\n if (matches) {\n // DOMContentLoaded is the effective one here. I am leaving the immediate call to\n // window.scrollTo in case it reduces flicker.\n window.scrollTo(0, matches[1]);\n form.addEventListener(\"DOMContentLoaded\", () => {\n window.scrollTo(0, matches[1]);\n });\n }\n};\n"],"file":"preview.min.js"} \ No newline at end of file diff --git a/question/bank/previewquestion/amd/src/preview.js b/question/bank/previewquestion/amd/src/preview.js index bef98bef3c8..446a4ee8d74 100644 --- a/question/bank/previewquestion/amd/src/preview.js +++ b/question/bank/previewquestion/amd/src/preview.js @@ -29,12 +29,17 @@ import $ from 'jquery'; * * @method init * @param {bool} redirect Redirect. + * @param {string} url url to redirect. */ -export const init = (redirect) => { +export const init = (redirect, url) => { if (!redirect) { let closeButton = document.getElementById('close-previewquestion-page'); closeButton.onclick = () => { - window.close(); + if (window.opener === null) { + location.href = url; + } else { + window.close(); + } }; } // Set up the form to be displayed. diff --git a/question/bank/previewquestion/classes/form/preview_options_form.php b/question/bank/previewquestion/classes/form/preview_options_form.php index 40e24c6db40..f4129e5d1ac 100644 --- a/question/bank/previewquestion/classes/form/preview_options_form.php +++ b/question/bank/previewquestion/classes/form/preview_options_form.php @@ -42,8 +42,12 @@ class preview_options_form extends moodleform { question_display_options::VISIBLE => get_string('shown', 'question'), ]; - $mform->addElement('header', 'attemptoptionsheader', get_string('attemptoptions', 'question')); - + $mform->addElement('header', 'attemptoptionsheader', get_string('previewoptions', 'qbank_previewquestion')); + $mform->setExpanded('attemptoptionsheader', false); + $versions = $this->_customdata['versions']; + $currentversion = $this->_customdata['questionversion']; + $select = $mform->addElement('select', 'version', get_string('questionversion', 'qbank_previewquestion'), $versions); + $select->setSelected($currentversion); $behaviours = question_engine::get_behaviour_options( $this->_customdata['quba']->get_preferred_behaviour()); $mform->addElement('select', 'behaviour', @@ -63,6 +67,7 @@ class preview_options_form extends moodleform { get_string('restartwiththeseoptions', 'question')); $mform->addElement('header', 'displayoptionsheader', get_string('displayoptions', 'question')); + $mform->setExpanded('displayoptionsheader', false); $mform->addElement('select', 'correctness', get_string('whethercorrect', 'question'), $hiddenorvisible); diff --git a/question/bank/previewquestion/classes/helper.php b/question/bank/previewquestion/classes/helper.php index 3e7e2e7631f..5a4c856a7c5 100644 --- a/question/bank/previewquestion/classes/helper.php +++ b/question/bank/previewquestion/classes/helper.php @@ -16,8 +16,21 @@ namespace qbank_previewquestion; +defined('MOODLE_INTERNAL') || die(); + +require_once($CFG->dirroot . '/question/editlib.php'); + +use action_menu; +use comment; +use context_module; use context; +use core\plugininfo\qbank; +use core_question\local\bank\edit_menu_column; +use core_question\local\bank\view; +use core_question\lib\question_edit_contexts; use moodle_url; +use question_bank; +use question_definition; use question_display_options; use question_engine; use stdClass; @@ -44,7 +57,7 @@ class helper { * @param int $slot the relevant slot within the usage. * @param array $args the remaining bits of the file path. * @param bool $forcedownload whether the user must be forced to download the file. - * @param array $fileoptions + * @param array $fileoptions options for the stored files * @return void false if file not found, does not return if found - justsend the file */ public static function question_preview_question_pluginfile($course, $context, $component, @@ -85,11 +98,11 @@ class helper { /** * The the URL to use for actions relating to this preview. * - * @param int $questionid the question being previewed. - * @param int $qubaid the id of the question usage for this preview. - * @param question_preview_options $options the options in use. - * @param context $context - * @param moodle_url $returnurl + * @param int $questionid the question being previewed + * @param int $qubaid the id of the question usage for this preview + * @param question_preview_options $options the options in use + * @param context $context context for the question preview + * @param moodle_url $returnurl url of the page to return to * @return moodle_url */ public static function question_preview_action_url($questionid, $qubaid, @@ -112,10 +125,11 @@ class helper { /** * The the URL to use for actions relating to this preview. - * @param int $questionid the question being previewed. - * @param context $context the current moodle context. - * @param int $previewid optional previewid to sign post saved previewed answers. - * @param moodle_url $returnurl + * + * @param int $questionid the question being previewed + * @param context $context the current moodle context + * @param int $previewid optional previewid to sign post saved previewed answers + * @param moodle_url $returnurl url of the page to return to * @return moodle_url */ public static function question_preview_form_url($questionid, $context, $previewid = null, $returnurl = null): moodle_url { @@ -138,13 +152,16 @@ class helper { /** * Delete the current preview, if any, and redirect to start a new preview. - * @param int $previewid - * @param int $questionid - * @param object $displayoptions - * @param object $context - * @param moodle_url $returnurl + * + * @param int $previewid id of the preview while restarting it + * @param int $questionid id of the question in preview + * @param object $displayoptions display options for the question in preview + * @param object $context context of the question for preview + * @param moodle_url $returnurl url of the page to return to + * @param int|null $version version of the question in preview */ - public static function restart_preview($previewid, $questionid, $displayoptions, $context, $returnurl = null): void { + public static function restart_preview($previewid, $questionid, $displayoptions, $context, + $returnurl = null, $version = null): void { global $DB; if ($previewid) { @@ -153,27 +170,33 @@ class helper { $transaction->allow_commit(); } redirect(self::question_preview_url($questionid, $displayoptions->behaviour, - $displayoptions->maxmark, $displayoptions, $displayoptions->variant, $context, $returnurl)); + $displayoptions->maxmark, $displayoptions, $displayoptions->variant, $context, $returnurl, $version)); } /** * Generate the URL for starting a new preview of a given question with the given options. - * @param integer $questionid the question to preview. - * @param string $preferredbehaviour the behaviour to use for the preview. - * @param float $maxmark the maximum to mark the question out of. - * @param question_display_options $displayoptions the display options to use. + * + * @param integer $questionid the question to preview + * @param string $preferredbehaviour the behaviour to use for the preview + * @param float $maxmark the maximum to mark the question out of + * @param question_display_options $displayoptions the display options to use * @param int $variant the variant of the question to preview. If null, one will - * be picked randomly. + * be picked randomly * @param object $context context to run the preview in (affects things like - * filter settings, theme, lang, etc.) Defaults to $PAGE->context. - * @param moodle_url $returnurl - * @return moodle_url the URL. + * filter settings, theme, lang, etc.) Defaults to $PAGE->context + * @param moodle_url $returnurl url of the page to return to + * @param int $version version of the question + * @return moodle_url the URL */ public static function question_preview_url($questionid, $preferredbehaviour = null, - $maxmark = null, $displayoptions = null, $variant = null, $context = null, $returnurl = null): moodle_url { + $maxmark = null, $displayoptions = null, $variant = null, $context = null, $returnurl = null, + $version = null): moodle_url { $params = ['id' => $questionid]; + if (!is_null($version)) { + $params['id'] = $version; + } if (is_null($context)) { global $PAGE; $context = $PAGE->context; @@ -215,6 +238,7 @@ class helper { /** * Popup params for the question preview. + * * @return array that can be passed as $params to the {@see popup_action} constructor. */ public static function question_preview_popup_params(): array { @@ -227,11 +251,11 @@ class helper { /** * Get the extra elements for preview from qbank plugins. * - * @param \question_definition $question - * @param int $courseid + * @param question_definition $question question definition object + * @param int $courseid id of the course * @return array */ - public static function get_preview_extra_elements(\question_definition $question, int $courseid): array { + public static function get_preview_extra_elements(question_definition $question, int $courseid): array { $plugintype = 'qbank'; $functionname = 'preview_display'; $extrahtml = []; @@ -247,4 +271,46 @@ class helper { } return [$comment, $extrahtml]; } + + /** + * Checks if question is the latest version. + * + * @param string $version Question version to check + * @param string $questionbankentryid Entry to check against + * @return bool + */ + public static function is_latest(string $version, string $questionbankentryid) : bool { + global $DB; + + $sql = 'SELECT MAX(version) AS max + FROM {question_versions} + WHERE questionbankentryid = ?'; + $latestversion = $DB->get_record_sql($sql, [$questionbankentryid]); + + if (isset($latestversion->max)) { + return ($version === $latestversion->max) ? true : false; + } + return false; + } + + /** + * Loads question version ids for current question. + * + * @param string $questionbankentryid Question bank entry id + * @return array $questionids Array containing question id as key and version as value. + */ + public static function load_versions(string $questionbankentryid) : array { + global $DB; + + $questionids = []; + $sql = 'SELECT version, questionid + FROM {question_versions} + WHERE questionbankentryid = ?'; + + $versions = $DB->get_records_sql($sql, [$questionbankentryid]); + foreach ($versions as $key => $version) { + $questionids[$version->questionid] = $key; + } + return $questionids; + } } diff --git a/question/bank/previewquestion/lang/en/qbank_previewquestion.php b/question/bank/previewquestion/lang/en/qbank_previewquestion.php index 111c18e4fa5..9947a3e41c0 100644 --- a/question/bank/previewquestion/lang/en/qbank_previewquestion.php +++ b/question/bank/previewquestion/lang/en/qbank_previewquestion.php @@ -25,3 +25,14 @@ $string['pluginname'] = 'Preview question'; $string['privacy:metadata'] = 'The Preview question question bank plugin does not store any personal data.'; +// Tag related errors. +$string['tagclosebutton'] = 'Close'; +$string['tagerror'] = 'No question was found with following tags: {$a}. Please change or remove tags filtering.'; +$string['tagsnotfound'] = 'Tags not found'; +// Form string(s). +$string['previewoptions'] = 'Preview options'; +$string['questionversion'] = 'Question version'; +// Preview title. +$string['versiontitle'] = 'Version {$a}'; +$string['versiontitlelatest'] = 'Version {$a} (latest)'; + diff --git a/question/bank/previewquestion/preview.php b/question/bank/previewquestion/preview.php index 0342a1c6240..4c6156cfdcd 100644 --- a/question/bank/previewquestion/preview.php +++ b/question/bank/previewquestion/preview.php @@ -31,6 +31,7 @@ require_once(__DIR__ . '/../../../config.php'); require_once($CFG->libdir . '/questionlib.php'); +use \core\notification; use qbank_previewquestion\form\preview_options_form; use qbank_previewquestion\question_preview_options; use qbank_previewquestion\helper; @@ -48,6 +49,7 @@ define('QUESTION_PREVIEW_MAX_VARIANTS', 100); // Get and validate question id. $id = required_param('id', PARAM_INT); $returnurl = optional_param('returnurl', null, PARAM_RAW); + $question = question_bank::load_question($id); if ($returnurl) { @@ -132,8 +134,10 @@ $options->behaviour = $quba->get_preferred_behaviour(); $options->maxmark = $quba->get_question_max_mark($slot); // Create the settings form, and initialise the fields. -$optionsform = new preview_options_form(helper::question_preview_form_url($question->id, $context, $previewid, $returnurl), - ['quba' => $quba, 'maxvariant' => $maxvariant]); +$versionids = helper::load_versions($question->questionbankentryid); +$optionsform = new preview_options_form(helper:: +question_preview_form_url($question->id, $context, $previewid, $returnurl), + ['quba' => $quba, 'maxvariant' => $maxvariant, 'versions' => $versionids, 'questionversion' => $id]); $optionsform->set_data($options); // Process change of settings, if that was requested. @@ -144,7 +148,7 @@ if ($newoptions = $optionsform->get_submitted_data()) { $newoptions->variant = $options->variant; } if (isset($newoptions->saverestart)) { - helper::restart_preview($previewid, $question->id, $newoptions, $context, $returnurl); + helper::restart_preview($previewid, $question->id, $newoptions, $context, $returnurl, $newoptions->version); } } @@ -249,6 +253,17 @@ $PAGE->set_heading($title); echo $OUTPUT->header(); $previewdata = []; + +$previewdata['questionicon'] = print_question_icon($question); +$previewdata['questionidumber'] = $question->idnumber; +$previewdata['questiontitle'] = $question->name; +$islatestversion = is_latest($question->version, $question->questionbankentryid); +if ($islatestversion) { + $previewdata['versiontitle'] = get_string('versiontitlelatest', 'qbank_previewquestion', $question->version); +} else { + $previewdata['versiontitle'] = get_string('versiontitle', 'qbank_previewquestion', $question->version); +} + $previewdata['actionurl'] = $actionurl; $previewdata['session'] = sesskey(); $previewdata['slot'] = $slot; @@ -265,21 +280,6 @@ foreach ($technical as $info) { } $previewdata['techinfo'] .= print_collapsible_region_end(true); -// Output a link to export this single question. -if (question_has_capability_on($question, 'view')) { - if (class_exists('qbank_exporttoxml\\helper')) { - if (\core\plugininfo\qbank::is_plugin_enabled('qbank_exporttoxml')) { - $exportfunction = '\\qbank_exporttoxml\\helper::question_get_export_single_question_url'; - $previewdata['exporttoxml'] = html_writer::link($exportfunction($question), - get_string('exportonequestion', 'question')); - } - } else { - $exportfunction = 'question_get_export_single_question_url'; - $previewdata['exporttoxml'] = html_writer::link($exportfunction($question), - get_string('exportonequestion', 'question')); - } -} - // Display the settings form. $previewdata['options'] = $optionsform->render(); @@ -304,16 +304,12 @@ if (!is_null($returnurl)) { $previewdata['redirect'] = true; $previewdata['redirecturl'] = $returnurl; } - +$closeurl = new moodle_url('/question/edit.php', ['courseid' => $COURSE->id]); echo $PAGE->get_renderer('qbank_previewquestion')->render_preview_page($previewdata); // Log the preview of this question. $event = \core\event\question_viewed::create_from_question_instance($question, $context); $event->trigger(); -$PAGE->requires->js_call_amd('qbank_previewquestion/preview', 'init', [$previewdata['redirect']]); -$PAGE->requires->js_call_amd('core_form/changechecker', 'watchFormById', ['responseform']); -$PAGE->requires->js_call_amd('core_form/submit', 'init', ['id_save_question_preview']); -$PAGE->requires->js_call_amd('core_form/submit', 'init', ['id_finish_question_preview']); -$PAGE->requires->js_call_amd('core_form/submit', 'init', ['id_restart_question_preview']); +$PAGE->requires->js_call_amd('qbank_previewquestion/preview', 'init', [$previewdata['redirect'], $closeurl->__toString()]); echo $OUTPUT->footer(); diff --git a/question/bank/previewquestion/templates/preview_question.mustache b/question/bank/previewquestion/templates/preview_question.mustache index 6abbf4a2240..02b718fbc73 100644 --- a/question/bank/previewquestion/templates/preview_question.mustache +++ b/question/bank/previewquestion/templates/preview_question.mustache @@ -22,6 +22,10 @@ * session - Moodle session * slot - The identifying number of the first question that was added to this usage * question - The html of the actual question from the engine + * questionicon - The icon of the question type + * questiontitle - The name of the question + * versiontitle - The string for displaying the version + * questionidumber - The idnumber of the question * restartdisabled - The attributes to enable or disable the button, same for finishdisabled and filldisabled * techinfo - Technical information like fraction, state, behaviour etc * exporttoxml - Link to export the question to xml @@ -31,25 +35,39 @@ Example context (json): { - "previewdata": [ - { - "actionurl": "/", - "session": "E2PwCfrnzz", - "slot": "1", - "question": "

question html
", - "restartdisabled": "disabled='disabled'", - "finishdisabled": "disabled='disabled'", - "filldisabled": "disabled='disabled'", - "techinfo": "
Behaviour being used: Deferred feedback
", - "redirecturl": "/", - "exporttoxml": "Download this question in Moodle XML format", - "comments": "html from comments api", - "extrapreviewelements": "
callback to get html from plugins need to show info in preview
" - } - ] + "actionurl": "/", + "session": "E2PwCfrnzz", + "slot": "1", + "question": "
question html
", + "questionicon": "", + "questiontitle": "Question title", + "versiontitle": "Version 3 (latest)", + "questionidumber": "qidnumber1", + "restartdisabled": "disabled='disabled'", + "finishdisabled": "disabled='disabled'", + "filldisabled": "disabled='disabled'", + "techinfo": "
Behaviour being used: Deferred feedback
", + "redirecturl": "/", + "exporttoxml": "Download this question in Moodle XML format", + "comments": "html from comments api", + "extrapreviewelements": "
callback to get html from plugins need to show info in preview
" } }}
+
+

{{{questionicon}}}

+

{{questiontitle}}

+

+ {{versiontitle}} +

+
+
+
+ ID number + {{questionidumber}} +
+
+
@@ -69,21 +87,21 @@ {{/redirect}}
-{{{techinfo}}} -{{{exporttoxml}}} +
{{#comments}} -
-
- {{{options}}} -
-
- {{{comments}}} -
+ +
+ {{{comments}}}
+ {{{options}}} {{/comments}} {{^comments}} {{{options}}} {{/comments}} +{{{techinfo}}} {{#extrapreviewelements}} {{{extrapreviewelements}}} {{/extrapreviewelements}} diff --git a/question/bank/previewquestion/tests/behat/preview_question.feature b/question/bank/previewquestion/tests/behat/preview_question.feature index 1e9a4388d9b..976a3fc20cb 100644 --- a/question/bank/previewquestion/tests/behat/preview_question.feature +++ b/question/bank/previewquestion/tests/behat/preview_question.feature @@ -43,13 +43,6 @@ Feature: A teacher can preview questions in the question bank And I should see "Version" And I click on "Preview options" "link" And I should see "Question version" - And "action-menu-toggle-0" "link" should exist - And I click on "action-menu-toggle-0" "link" - And I should see "Edit question" - And I should see "Duplicate" - And I should see "Manage tags" - And I should see "Delete" - And I should see "Export as Moodle XML" Scenario: Preview lets the teacher see what happens when an answer is saved When I set the field "Answer:" to "1" @@ -94,33 +87,20 @@ Feature: A teacher can preview questions in the question bank When I press "Fill in correct responses" Then the field "Answer:" matches value "3.14" - Scenario: Preview has an option to export the individual quesiton. - When I open the action menu in "action-menu-0-menubar" "region" - Then following "Export as Moodle XML" should download between "1000" and "2500" bytes - Scenario: Preview a question with very small grade When I press "Preview options" And I set the field "Marked out of" to "0.00000123456789" And I press "Start again with these options" Then the field "Marked out of" matches value "0.00000123456789" - Scenario: Question preview has an action menu - When I open the action menu in "action-menu-0-menubar" "region" - Then I should see "Edit question" - And I should see "Duplicate" - And I should see "Manage tags" - And I should see "Delete" - And I should see "Export as Moodle XML" - Scenario: Question version is updated when edited and teacher can change question version - When I open the action menu in "action-menu-0-menubar" "region" - And I choose "Edit question" in the open action menu + And I should see "Version 1" + And I press "Close preview" + And I choose "Edit question" action for "Test question to be previewed" in the question bank And I set the field "Question name" to "New version" And I set the field "Question text" to "New text version" And I click on "submitbutton" "button" - And I press "Preview options" - And I set the field "Question version" to "2" - And I press "Start again with these options" + And I choose "Preview" action for "New version" in the question bank Then I should see "Version 2" And I should see "(latest)" And I should see "New version" @@ -129,9 +109,6 @@ Feature: A teacher can preview questions in the question bank And I should not see "Version 1" Scenario: Question preview can be closed - When I open the action menu in "action-menu-0-menubar" "region" - And I choose "Edit question" in the open action menu - And I click on "cancel" "button" And I press "Close preview" Then I should not see "(latest)" And I should see "Course 1" diff --git a/question/bank/previewquestion/tests/helper_test.php b/question/bank/previewquestion/tests/qbank_preview_helper_test.php similarity index 75% rename from question/bank/previewquestion/tests/helper_test.php rename to question/bank/previewquestion/tests/qbank_preview_helper_test.php index 4b789990a6b..7265f98b161 100644 --- a/question/bank/previewquestion/tests/helper_test.php +++ b/question/bank/previewquestion/tests/qbank_preview_helper_test.php @@ -16,7 +16,13 @@ namespace qbank_previewquestion; +use context_course; +use moodle_url; use core\plugininfo\qbank; +use \core_question\lib\question_edit_contexts; +use question_bank; +use question_engine; +use stdClass; /** * Helper tests for question preview. @@ -67,17 +73,17 @@ class helper_test extends \advanced_testcase { $questiongenerator = $generator->get_plugin_generator('core_question'); // Create a course. $course = $generator->create_course(); - $this->context = \context_course::instance($course->id); + $this->context = context_course::instance($course->id); // Create a question in the default category. $contexts = new \core_question\lib\question_edit_contexts($this->context); $cat = question_make_default_categories($contexts->all()); $this->questiondata = $questiongenerator->create_question('numerical', null, ['name' => 'Example question', 'category' => $cat->id]); - $this->quba = \question_engine::make_questions_usage_by_activity('core_question_preview', \context_user::instance($USER->id)); + $this->quba = question_engine::make_questions_usage_by_activity('core_question_preview', \context_user::instance($USER->id)); $this->options = new question_preview_options($this->questiondata); $this->options->load_user_defaults(); $this->options->set_from_request(); - $this->returnurl = new \moodle_url('/question/edit.php'); + $this->returnurl = new moodle_url('/question/edit.php'); } /** @@ -95,7 +101,7 @@ class helper_test extends \advanced_testcase { 'courseid' => $this->context->instanceid ]; $params = array_merge($params, $this->options->get_url_params()); - $expectedurl = new \moodle_url('/question/bank/previewquestion/preview.php', $params); + $expectedurl = new moodle_url('/question/bank/previewquestion/preview.php', $params); $this->assertEquals($expectedurl, $actionurl); } @@ -112,7 +118,7 @@ class helper_test extends \advanced_testcase { 'returnurl' => $this->returnurl, 'courseid' => $this->context->instanceid ]; - $expectedurl = new \moodle_url('/question/bank/previewquestion/preview.php', $params); + $expectedurl = new moodle_url('/question/bank/previewquestion/preview.php', $params); $this->assertEquals($expectedurl, $formurl); } @@ -138,7 +144,7 @@ class helper_test extends \advanced_testcase { $params['generalfeedback'] = (bool) $this->options->generalfeedback; $params['rightanswer'] = (bool) $this->options->rightanswer; $params['history'] = (bool) $this->options->history; - $expectedurl = new \moodle_url('/question/bank/previewquestion/preview.php', $params); + $expectedurl = new moodle_url('/question/bank/previewquestion/preview.php', $params); $this->assertEquals($expectedurl, $previewurl); } @@ -159,4 +165,27 @@ class helper_test extends \advanced_testcase { $this->assertEquals('', $comment); } } + + /** + * Test method load_versions(). + * + * @covers ::load_versions + */ + public function test_load_versions() { + global $DB; + $this->resetAfterTest(); + $generator = $this->getDataGenerator()->get_plugin_generator('core_question'); + $qcat1 = $generator->create_question_category(['name' => 'My category', 'sortorder' => 1, 'idnumber' => 'myqcat']); + $questiongenerated = $generator->create_question('description', null, ['name' => 'q1', 'category' => $qcat1->id]); + $qtypeobj = question_bank::get_qtype($questiongenerated->qtype); + $question = question_bank::load_question($questiongenerated->id); + $versionids = helper::load_versions($question->questionbankentryid); + $this->assertCount(1, $versionids); + $fromform = new stdClass(); + $fromform->name = 'Name edited'; + $fromform->category = $qcat1->id; + $qtypeobj->save_question($questiongenerated, $fromform); + $versionids = helper::load_versions($question->questionbankentryid); + $this->assertCount(2, $versionids); + } } diff --git a/question/classes/local/bank/edit_menu_column.php b/question/classes/local/bank/edit_menu_column.php index 3810733c6cf..00513923128 100644 --- a/question/classes/local/bank/edit_menu_column.php +++ b/question/classes/local/bank/edit_menu_column.php @@ -98,4 +98,12 @@ class edit_menu_column extends column_base { return ['q.qtype']; } + /** + * Get menuable actions. + * + * @return menuable_action Menuable actions. + */ + public function get_actions(): array { + return $this->actions; + } } diff --git a/question/classes/local/bank/view.php b/question/classes/local/bank/view.php index 9b1c5965c3f..b739353bd76 100644 --- a/question/classes/local/bank/view.php +++ b/question/classes/local/bank/view.php @@ -1225,4 +1225,20 @@ class view { $this->searchconditions[] = $searchcondition; } + /** + * Gets visible columns. + * @return array $this->visiblecolumns Visible columns. + */ + public function get_visiblecolumns(): array { + return $this->visiblecolumns; + } + + /** + * Get required columns. + * + * @return array Required columns. + */ + public function get_requiredcolumns(): array { + return $this->requiredcolumns; + } } diff --git a/question/type/description/tests/behat/preview.feature b/question/type/description/tests/behat/preview.feature index e4698a1c10b..c1e548fc4df 100644 --- a/question/type/description/tests/behat/preview.feature +++ b/question/type/description/tests/behat/preview.feature @@ -27,6 +27,7 @@ Feature: Preview a Description question @javascript @_switch_window Scenario: Preview a Description question and submit a correct response. When I choose "Preview" action for "description-001" in the question bank + And I press "Preview options" And I set the field "How questions behave" to "Immediate feedback" And I press "Start again with these options" And I should see "Here is some information about the questions you are about to attempt." diff --git a/question/type/essay/tests/behat/preview.feature b/question/type/essay/tests/behat/preview.feature index acd0272ffc2..5c1f85d1a98 100644 --- a/question/type/essay/tests/behat/preview.feature +++ b/question/type/essay/tests/behat/preview.feature @@ -29,6 +29,7 @@ Feature: Preview Essay questions @javascript @_switch_window Scenario: Preview an Essay question that uses the HTML editor. When I choose "Preview" action for "essay-001" in the question bank + And I press "Preview options" And I set the field "How questions behave" to "Immediate feedback" And I press "Start again with these options" And I should see "Please write a story about a frog." @@ -37,6 +38,7 @@ Feature: Preview Essay questions @javascript @_switch_window Scenario: Preview an Essay question that uses the HTML editor with embedded files. When I choose "Preview" action for "essay-002" in the question bank + And I press "Preview options" And I set the field "How questions behave" to "Immediate feedback" And I press "Start again with these options" And I should see "Please write a story about a frog." @@ -46,6 +48,7 @@ Feature: Preview Essay questions @javascript @_switch_window Scenario: Preview an Essay question that uses a plain text area. When I choose "Preview" action for "essay-003" in the question bank + And I press "Preview options" And I set the field "How questions behave" to "Immediate feedback" And I press "Start again with these options" And I should see "Please write a story about a frog." diff --git a/question/type/match/tests/behat/preview.feature b/question/type/match/tests/behat/preview.feature index 9294014f0a4..3b60dc3a5f2 100644 --- a/question/type/match/tests/behat/preview.feature +++ b/question/type/match/tests/behat/preview.feature @@ -31,6 +31,7 @@ Feature: Preview a Matching question | Shuffle | 0 | And I press "id_submitbutton" When I choose "Preview" action for "matching-001" in the question bank + And I press "Preview options" And I set the field "How questions behave" to "Immediate feedback" And I press "Start again with these options" And I set the field with xpath "//table[@class='answer']//td[@class='control']//select[contains(@id, '1_sub0')]" to "amphibian" @@ -48,6 +49,7 @@ Feature: Preview a Matching question | Shuffle | 0 | And I press "id_submitbutton" When I choose "Preview" action for "matching-001" in the question bank + And I press "Preview options" And I set the field "How questions behave" to "Immediate feedback" And I press "Start again with these options" And I set the field with xpath "//table[@class='answer']//td[@class='control']//select[contains(@id, '1_sub0')]" to "amphibian" @@ -65,6 +67,7 @@ Feature: Preview a Matching question | Shuffle | 0 | And I press "id_submitbutton" When I choose "Preview" action for "matching-001" in the question bank + And I press "Preview options" And I set the field "How questions behave" to "Immediate feedback" And I press "Start again with these options" And I set the field with xpath "//table[@class='answer']//td[@class='control']//select[contains(@id, '1_sub0')]" to "mammal" diff --git a/question/type/multichoice/tests/behat/preview.feature b/question/type/multichoice/tests/behat/preview.feature index f0d6d402e74..b49c2a30501 100644 --- a/question/type/multichoice/tests/behat/preview.feature +++ b/question/type/multichoice/tests/behat/preview.feature @@ -28,6 +28,7 @@ Feature: Preview a Multiple choice question @javascript @_switch_window Scenario: Preview a Multiple choice question and submit a partially correct response. When I choose "Preview" action for "Multi-choice-001" in the question bank + And I press "Preview options" And I set the field "How questions behave" to "Immediate feedback" And I press "Start again with these options" And I click on "One" "qtype_multichoice > Answer" @@ -42,6 +43,7 @@ Feature: Preview a Multiple choice question @javascript @_switch_window Scenario: Preview a Multiple choice question and submit a correct response. When I choose "Preview" action for "Multi-choice-001" in the question bank + And I press "Preview options" And I set the field "How questions behave" to "Immediate feedback" And I press "Start again with these options" And I click on "One" "qtype_multichoice > Answer" @@ -58,6 +60,7 @@ Feature: Preview a Multiple choice question @javascript @_switch_window Scenario: Preview a Multiple choice question and submit a correct response. When I choose "Preview" action for "Multi-choice-002" in the question bank + And I press "Preview options" And I set the field "How questions behave" to "Immediate feedback" And I press "Start again with these options" And I click on "One" "qtype_multichoice > Answer" @@ -71,6 +74,7 @@ Feature: Preview a Multiple choice question @javascript @_switch_window Scenario: Preview a multiple choice question (single response) and clear a previous selected option. When I choose "Preview" action for "Multi-choice-002" in the question bank + And I press "Preview options" And I set the field "How questions behave" to "Immediate feedback" And I press "Start again with these options" And I click on "One" "qtype_multichoice > Answer" diff --git a/question/type/numerical/tests/behat/preview.feature b/question/type/numerical/tests/behat/preview.feature index 78544fc7a65..ca8b5a4c07a 100644 --- a/question/type/numerical/tests/behat/preview.feature +++ b/question/type/numerical/tests/behat/preview.feature @@ -32,6 +32,7 @@ Feature: Preview a Numerical question Scenario: Preview a Numerical question and submit a correct response. When I choose "Preview" action for "Numerical-001" in the question bank Then I should see "What is pi to two d.p.?" + And I press "Preview options" When I set the field "How questions behave" to "Immediate feedback" And I press "Start again with these options" And I set the field with xpath "//span[@class='answer']//input[contains(@id, '1_answer')]" to "3.14" diff --git a/question/type/truefalse/tests/behat/preview.feature b/question/type/truefalse/tests/behat/preview.feature index b0300d6e4b3..874a61f7945 100644 --- a/question/type/truefalse/tests/behat/preview.feature +++ b/question/type/truefalse/tests/behat/preview.feature @@ -27,6 +27,7 @@ Feature: Preview a Trtue/False question @javascript @_switch_window Scenario: Preview a True/False question and submit a correct response. When I choose "Preview" action for "true-false-001" in the question bank + And I press "Preview options" And I set the field "How questions behave" to "Immediate feedback" And I press "Start again with these options" And I click on "True" "radio" @@ -38,6 +39,7 @@ Feature: Preview a Trtue/False question @javascript @_switch_window Scenario: Preview a True/False question and submit an incorrect response. When I choose "Preview" action for "true-false-001" in the question bank + And I press "Preview options" And I set the field "How questions behave" to "Immediate feedback" And I press "Start again with these options" And I click on "False" "radio" From 3008edd366ad09318057cddb7dbd57aae53d72f5 Mon Sep 17 00:00:00 2001 From: Safat Shahin Date: Mon, 25 Oct 2021 00:17:11 +1100 Subject: [PATCH 7/8] MDL-71696 core_question: Implement question status change This commit implements the status change pop up for a question in the base view. This feature update will allow the change of status of a question without creating a new version. This commit also implements the status as a char rather than an int value. --- admin/qtypes.php | 4 +- backup/moodle2/restore_stepslib.php | 6 +- lib/questionlib.php | 25 ++-- lib/tests/questionlib_test.php | 13 +- mod/quiz/amd/build/question_slot.min.js.map | 3 +- mod/quiz/amd/src/question_slot.js | 2 +- mod/quiz/attemptlib.php | 2 +- .../local_structure_slot_random_test.php | 4 +- question/bank/bulkmove/classes/helper.php | 2 +- .../classes/delete_action_column.php | 4 +- question/bank/deletequestion/delete.php | 6 +- .../amd/build/question_status.min.js | 2 + .../amd/build/question_status.min.js.map | 1 + .../editquestion/amd/src/question_status.js | 135 ++++++++++++++++++ .../classes/editquestion_helper.php | 19 +-- .../update_question_version_status.php | 109 ++++++++++++++ .../classes/form/question_status_form.php | 39 +++++ .../classes/question_status_column.php | 33 +++-- question/bank/editquestion/db/services.php | 35 +++++ .../lang/en/qbank_editquestion.php | 2 +- question/bank/editquestion/lib.php | 41 ++++++ question/bank/editquestion/question.php | 5 +- .../templates/question_info.mustache | 2 +- .../tests/behat/behat_qbank_editquestion.php | 59 -------- .../tests/behat/question_status.feature | 6 +- .../update_question_version_status_test.php | 91 ++++++++++++ question/bank/editquestion/version.php | 2 +- .../bank/managecategories/classes/helper.php | 14 +- .../managecategories/tests/helper_test.php | 6 +- .../tests/qbank_preview_helper_test.php | 3 +- question/bank/usage/classes/helper.php | 2 +- .../classes/tables/question_usage_table.php | 2 +- question/bank/viewcreator/lib.php | 2 +- .../classes/bank/search/hidden_condition.php | 6 +- .../local/bank/random_question_loader.php | 4 +- question/classes/local/bank/view.php | 2 +- question/editlib.php | 3 +- question/engine/bank.php | 4 +- question/engine/tests/helpers.php | 4 +- question/format.php | 7 +- question/format/multianswer/format.php | 2 +- question/format/xml/format.php | 1 + question/format/xml/tests/xmlformat_test.php | 18 +-- question/tests/generator/lib.php | 6 +- .../tests/random_question_loader_test.php | 3 +- question/tests/version_test.php | 3 +- question/type/calculated/tests/helper.php | 4 +- .../calculated/tests/questiontype_test.php | 2 +- .../type/calculatedsimple/tests/helper.php | 4 +- question/type/ddimageortext/tests/helper.php | 2 +- question/type/ddmarker/tests/helper.php | 4 +- question/type/ddwtos/tests/helper.php | 8 +- .../type/ddwtos/tests/questiontype_test.php | 4 +- question/type/description/tests/helper.php | 4 +- question/type/essay/tests/helper.php | 6 +- question/type/gapselect/tests/helper.php | 12 +- .../gapselect/tests/questiontype_test.php | 2 +- question/type/match/tests/helper.php | 8 +- .../type/match/tests/questiontype_test.php | 2 +- question/type/missingtype/questiontype.php | 2 +- .../missingtype/tests/missingtype_test.php | 2 +- question/type/multianswer/tests/helper.php | 12 +- .../multianswer/tests/questiontype_test.php | 4 +- question/type/multichoice/tests/helper.php | 8 +- question/type/numerical/tests/helper.php | 4 +- question/type/questionbase.php | 2 +- question/type/questiontypebase.php | 5 +- question/type/random/questiontype.php | 4 +- question/type/random/tests/helper.php | 2 +- .../type/random/tests/questiontype_test.php | 2 +- question/type/shortanswer/tests/helper.php | 6 +- question/type/truefalse/tests/helper.php | 4 +- .../truefalse/tests/questiontype_test.php | 2 +- 73 files changed, 639 insertions(+), 226 deletions(-) create mode 100644 question/bank/editquestion/amd/build/question_status.min.js create mode 100644 question/bank/editquestion/amd/build/question_status.min.js.map create mode 100644 question/bank/editquestion/amd/src/question_status.js create mode 100644 question/bank/editquestion/classes/external/update_question_version_status.php create mode 100644 question/bank/editquestion/classes/form/question_status_form.php create mode 100644 question/bank/editquestion/db/services.php create mode 100644 question/bank/editquestion/lib.php delete mode 100644 question/bank/editquestion/tests/behat/behat_qbank_editquestion.php create mode 100644 question/bank/editquestion/tests/external/update_question_version_status_test.php diff --git a/admin/qtypes.php b/admin/qtypes.php index 5a9f0b698f5..861ae4fa231 100644 --- a/admin/qtypes.php +++ b/admin/qtypes.php @@ -42,8 +42,8 @@ $qtypes = question_bank::get_all_qtypes(); $pluginmanager = core_plugin_manager::instance(); // Get some data we will need - question counts and which types are needed. -$hiddenstatus = \core_question\local\bank\constants::QUESTION_STATUS_HIDDEN; -$draftstatus = \core_question\local\bank\constants::QUESTION_STATUS_DRAFT; +$hiddenstatus = \core_question\local\bank\question_version_status::QUESTION_STATUS_HIDDEN; +$draftstatus = \core_question\local\bank\question_version_status::QUESTION_STATUS_DRAFT; $sql = "SELECT result.qtype, SUM(result.numquestions) AS numquestions, diff --git a/backup/moodle2/restore_stepslib.php b/backup/moodle2/restore_stepslib.php index d74ef6c97cd..1aaede00bfa 100644 --- a/backup/moodle2/restore_stepslib.php +++ b/backup/moodle2/restore_stepslib.php @@ -4905,7 +4905,11 @@ class restore_create_categories_and_questions extends restore_structure_step { $versiondata->version = 1; // Question id is updated after inserting the question. $versiondata->questionid = 0; - $versiondata->status = $data->hidden; + $versionstatus = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; + if ((int)$data->hidden === 1) { + $versionstatus = \core_question\local\bank\question_version_status::QUESTION_STATUS_HIDDEN; + } + $versiondata->status = $versionstatus; $newversionid = $DB->insert_record('question_versions', $versiondata); $this->set_mapping('question_version_created', $oldid, $newversionid); } diff --git a/lib/questionlib.php b/lib/questionlib.php index bfb671e18d4..c3e05a0f80d 100644 --- a/lib/questionlib.php +++ b/lib/questionlib.php @@ -2003,7 +2003,7 @@ function core_question_find_next_unused_idnumber(?string $oldidnumber, int $cate /** * Get the question_bank_entry object given a question id. * - * @param $questionid int Question id. + * @param int $questionid Question id. * @return false|mixed * @throws dml_exception */ @@ -2024,7 +2024,7 @@ function get_question_bank_entry(int $questionid): object { /** * Get the question versions given a question id in a descending sort . * - * @param $questionid int Question id. + * @param int $questionid Question id. * @return array * @throws dml_exception */ @@ -2040,7 +2040,7 @@ function get_question_version($questionid): array { /** * Get the next version number to create base on a Question bank entry id. * - * @param $questionbankentryid int Question bank entry id. + * @param int $questionbankentryid Question bank entry id. * @return int next version number. * @throws dml_exception */ @@ -2109,7 +2109,9 @@ function question_preview_url($questionid, $preferredbehaviour = null, } /** - * @return array that can be passed as $params to the {@link popup_action} constructor. + * Popup params for the question preview. + * + * @return array that can be passed as $params to the {@see popup_action()} constructor. * @deprecated since Moodle 4.0 * @see qbank_previewquestion\previewquestion_helper::question_preview_popup_params() * @todo Final deprecation on Moodle 4.4 MDL-72438 @@ -2146,7 +2148,7 @@ function question_hash($question) { * @param string $withcategories * @param string $withcontexts * @param string $filename - * @param moodle_url export file url + * @return moodle_url export file url * @deprecated since Moodle 4.0 MDL-71573 * @see qbank_exportquestions\exportquestions_helper * @todo Final deprecation on Moodle 4.4 MDL-72438 @@ -2218,6 +2220,7 @@ function question_remove_stale_questions_from_category($categoryid) { * performance only. It is not modfied. * @param int $id the category to start the indenting process from. * @param int $depth the indent depth. Used in recursive calls. + * @param int $nochildrenof * @return array a new array of categories, in the right order for the tree. * @deprecated since Moodle 4.0 MDL-71585 * @see qbank_managecategories\helper @@ -2233,6 +2236,7 @@ function flatten_category_tree(&$categories, $id, $depth = 0, $nochildrenof = -1 * Format categories into an indented list reflecting the tree structure. * * @param array $categories An array of category objects, for example from the. + * @param int $nochildrenof * @return array The formatted list of categories. * @deprecated since Moodle 4.0 MDL-71585 * @see qbank_managecategories\helper @@ -2249,11 +2253,12 @@ function add_indented_names($categories, $nochildrenof = -1) { * Categories from this course and (optionally) published categories from other courses * are included. Optionally, only categories the current user may edit can be included. * - * @param integer $courseid the id of the course to get the categories for. - * @param integer $published if true, include publised categories from other courses. - * @param integer $only_editable if true, exclude categories this user is not allowed to edit. + * @param array $contexts + * @param bool $top + * @param int $currentcat * @param integer $selected optionally, the id of a category to be selected by * default in the dropdown. + * @param int $nochildrenof * @deprecated since Moodle 4.0 MDL-71585 * @see qbank_managecategories\helper * @todo Final deprecation on Moodle 4.4 MDL-72438 @@ -2306,6 +2311,10 @@ function question_category_options($contexts, $top = false, $currentcat = 0, } /** + * Add context in categories key. + * + * @param array $categories The list of categories. + * @return array * @deprecated since Moodle 4.0 MDL-71585 * @see qbank_managecategories\helper * @todo Final deprecation on Moodle 4.4 MDL-72438 diff --git a/lib/tests/questionlib_test.php b/lib/tests/questionlib_test.php index d5c0fa88041..4275b81be86 100644 --- a/lib/tests/questionlib_test.php +++ b/lib/tests/questionlib_test.php @@ -116,7 +116,7 @@ class core_questionlib_testcase extends advanced_testcase { * @param int $numberofquestions Number of question in a category. * @return void Questions in a category. */ - function assert_category_contains_questions(int $categoryid, int $numberofquestions): void { + protected function assert_category_contains_questions(int $categoryid, int $numberofquestions): void { $questionsid = question_bank::get_finder()->get_questions_from_categories([$categoryid], null); $this->assertEquals($numberofquestions, count($questionsid)); } @@ -268,10 +268,13 @@ class core_questionlib_testcase extends advanced_testcase { // Check that there are two questions in the restored to course's context. $this->assertEquals(2, $DB->get_record_sql('SELECT COUNT(q.id) as questioncount - FROM {question} q - JOIN {question_versions} qv ON qv.questionid = q.id - JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid - WHERE qbe.questioncategoryid = ?', [$restoredcategory->id])->questioncount); + FROM {question} q + JOIN {question_versions} qv + ON qv.questionid = q.id + JOIN {question_bank_entries} qbe + ON qbe.id = qv.questionbankentryid + WHERE qbe.questioncategoryid = ?', + [$restoredcategory->id])->questioncount); $rc->destroy(); } diff --git a/mod/quiz/amd/build/question_slot.min.js.map b/mod/quiz/amd/build/question_slot.min.js.map index 76de16722e6..72c300f39c7 100644 --- a/mod/quiz/amd/build/question_slot.min.js.map +++ b/mod/quiz/amd/build/question_slot.min.js.map @@ -1,2 +1 @@ -define ("mod_quiz/question_slot",["exports","core/ajax","core/notification"],function(a,b,c){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.init=void 0;c=function(a){return a&&a.__esModule?a:{default:a}}(c);var d=function(a,c){return(0,b.call)([{methodname:"mod_quiz_set_question_version",args:{slotid:a,newversion:c}}])[0]},e=function(){document.addEventListener("change",function(a){if(!a.target.matches("[data-action=\"mod_quiz-select_slot\"][data-slot-id]")){return}var b=a.target.dataset.slotId,e=parseInt(a.target.value);d(b,e).then(function(){location.reload()}).catch(c.default.exception)})};a.init=function init(){if(!1){return}e()}}); -//# sourceMappingURL=question_slot.min.js.map +{"version":3,"sources":["../src/question_slot.js"],"names":["setQuestionVersion","slotId","newVersion","methodname","args","slotid","newversion","registerEventListeners","document","addEventListener","e","target","matches","dataset","parseInt","value","then","location","reload","catch","Notification","exception","init"],"mappings":"yKAyBA,uD,GASMA,CAAAA,CAAkB,CAAG,SAACC,CAAD,CAASC,CAAT,QAAwB,WAAU,CAAC,CAC1DC,UAAU,CAAE,+BAD8C,CAE1DC,IAAI,CAAE,CACFC,MAAM,CAAEJ,CADN,CAEFK,UAAU,CAAEJ,CAFV,CAFoD,CAAD,CAAV,EAM/C,CAN+C,CAAxB,C,CAWrBK,CAAsB,CAAG,UAAM,CACjCC,QAAQ,CAACC,gBAAT,CAA0B,QAA1B,CAAoC,SAAAC,CAAC,CAAI,CACrC,GAAI,CAACA,CAAC,CAACC,MAAF,CAASC,OAAT,CAAiB,sDAAjB,CAAL,CAA6E,CACzE,MACH,CAHoC,GAK/BX,CAAAA,CAAM,CAAGS,CAAC,CAACC,MAAF,CAASE,OAAT,CAAiBZ,MALK,CAM/BC,CAAU,CAAGY,QAAQ,CAACJ,CAAC,CAACC,MAAF,CAASI,KAAV,CANU,CAQrCf,CAAkB,CAACC,CAAD,CAASC,CAAT,CAAlB,CACKc,IADL,CACU,UAAM,CACRC,QAAQ,CAACC,MAAT,EAEH,CAJL,EAKKC,KALL,CAKWC,UAAaC,SALxB,CAMH,CAdD,CAeH,C,QAQmB,QAAPC,CAAAA,IAAO,EAAM,CACtB,MAAsB,CAClB,MACH,CAEDf,CAAsB,EACzB,C","sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Render the question slot template for each question in the quiz edit view.\n *\n * @module mod_quiz/question_slot\n * @copyright 2021 Catalyst IT Australia Pty Ltd\n * @author Guillermo Gomez Arias \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport {call as fetchMany} from 'core/ajax';\nimport Notification from 'core/notification';\n\n/**\n * Set the question version for the slot.\n *\n * @param {Number} slotId\n * @param {Number} newVersion\n * @return {Array} The modified question version\n */\nconst setQuestionVersion = (slotId, newVersion) => fetchMany([{\n methodname: 'mod_quiz_set_question_version',\n args: {\n slotid: slotId,\n newversion: newVersion,\n }\n}])[0];\n\n/**\n * Replace the container with a new version.\n */\nconst registerEventListeners = () => {\n document.addEventListener('change', e => {\n if (!e.target.matches('[data-action=\"mod_quiz-select_slot\"][data-slot-id]')) {\n return;\n }\n\n const slotId = e.target.dataset.slotId;\n const newVersion = parseInt(e.target.value);\n\n setQuestionVersion(slotId, newVersion)\n .then(() => {\n location.reload();\n return;\n })\n .catch(Notification.exception);\n });\n};\n\n/** @property {Boolean} eventsRegistered If the event has been registered or not */\nlet eventsRegistered = false;\n\n/**\n * Entrypoint of the js.\n */\nexport const init = () => {\n if (eventsRegistered) {\n return;\n }\n\n registerEventListeners();\n};\n"],"file":"question_slot.min.js"} \ No newline at end of file diff --git a/mod/quiz/amd/src/question_slot.js b/mod/quiz/amd/src/question_slot.js index 9c26f551d10..b82c99456dc 100644 --- a/mod/quiz/amd/src/question_slot.js +++ b/mod/quiz/amd/src/question_slot.js @@ -16,7 +16,7 @@ /** * Render the question slot template for each question in the quiz edit view. * - * @package mod_quiz + * @module mod_quiz/question_slot * @copyright 2021 Catalyst IT Australia Pty Ltd * @author Guillermo Gomez Arias * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later diff --git a/mod/quiz/attemptlib.php b/mod/quiz/attemptlib.php index d51c74a6329..9c79f198a65 100644 --- a/mod/quiz/attemptlib.php +++ b/mod/quiz/attemptlib.php @@ -1798,7 +1798,7 @@ class quiz_attempt { $question->length = $replacedquestion->length; $question->penalty = 0; $question->stamp = ''; - $question->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $question->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $question->timecreated = null; $question->timemodified = null; $question->createdby = null; diff --git a/mod/quiz/tests/local_structure_slot_random_test.php b/mod/quiz/tests/local_structure_slot_random_test.php index ffea091b3c9..576eba0a60f 100644 --- a/mod/quiz/tests/local_structure_slot_random_test.php +++ b/mod/quiz/tests/local_structure_slot_random_test.php @@ -57,7 +57,7 @@ class mod_quiz_local_structure_slot_random_test extends advanced_testcase { $form->includesubcategories = true; $form->fromtags = []; $form->defaultmark = 1; - $form->status = \core_question\local\bank\constants::QUESTION_STATUS_HIDDEN; + $form->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_HIDDEN; $form->stamp = make_unique_id_code(); // Set the filter conditions. @@ -371,7 +371,7 @@ class mod_quiz_local_structure_slot_random_test extends advanced_testcase { $form->includesubcategories = true; $form->fromtags = []; $form->defaultmark = 1; - $form->status = \core_question\local\bank\constants::QUESTION_STATUS_HIDDEN; + $form->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_HIDDEN; $form->stamp = make_unique_id_code(); // Prepare 2 tags. diff --git a/question/bank/bulkmove/classes/helper.php b/question/bank/bulkmove/classes/helper.php index 532350ea7f0..95c44ae2b92 100644 --- a/question/bank/bulkmove/classes/helper.php +++ b/question/bank/bulkmove/classes/helper.php @@ -39,7 +39,7 @@ class helper { $sql = "SELECT q.*, c.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_bank_entries} qbe ON qbe.id = qv.questionbankentryid JOIN {question_categories} c ON c.id = qbe.questioncategoryid WHERE q.id {$usql}"; diff --git a/question/bank/deletequestion/classes/delete_action_column.php b/question/bank/deletequestion/classes/delete_action_column.php index 712799f239d..361f25bc250 100644 --- a/question/bank/deletequestion/classes/delete_action_column.php +++ b/question/bank/deletequestion/classes/delete_action_column.php @@ -24,7 +24,7 @@ namespace qbank_deletequestion; -use core_question\local\bank\constants; +use core_question\local\bank\question_version_status; use core_question\local\bank\menu_action_column_base; /** @@ -83,7 +83,7 @@ class delete_action_column extends menu_action_column_base { if (!question_has_capability_on($question, 'edit')) { return [null, null, null]; } - if ((int) $question->status === constants::QUESTION_STATUS_HIDDEN) { + if ($question->status === question_version_status::QUESTION_STATUS_HIDDEN) { $hiddenparams = array( 'unhide' => $question->id, 'sesskey' => sesskey()); diff --git a/question/bank/deletequestion/delete.php b/question/bank/deletequestion/delete.php index 41c887324e8..6b6398944cc 100644 --- a/question/bank/deletequestion/delete.php +++ b/question/bank/deletequestion/delete.php @@ -62,7 +62,8 @@ $PAGE->set_heading($COURSE->fullname); // Unhide a question. if (($unhide = optional_param('unhide', '', PARAM_INT)) and confirm_sesskey()) { question_require_capability_on($unhide, 'edit'); - $DB->set_field('question_versions', 'status', 0, ['questionid' => $unhide]); + $DB->set_field('question_versions', 'status', + \core_question\local\bank\question_version_status::QUESTION_STATUS_READY, ['questionid' => $unhide]); // Purge these questions from the cache. \question_bank::notify_question_edited($unhide); @@ -81,7 +82,8 @@ if ($deleteselected && ($confirm = optional_param('confirm', '', PARAM_ALPHANUM) $questionid = (int)$questionid; question_require_capability_on($questionid, 'edit'); if (questions_in_use(array($questionid))) { - $DB->set_field('question_versions', 'status', 1, ['questionid' => $questionid]); + $DB->set_field('question_versions', 'status', + \core_question\local\bank\question_version_status::QUESTION_STATUS_HIDDEN, ['questionid' => $questionid]); } else { question_delete_question($questionid); } diff --git a/question/bank/editquestion/amd/build/question_status.min.js b/question/bank/editquestion/amd/build/question_status.min.js new file mode 100644 index 00000000000..335bbbb426e --- /dev/null +++ b/question/bank/editquestion/amd/build/question_status.min.js @@ -0,0 +1,2 @@ +function _typeof(a){"@babel/helpers - typeof";if("function"==typeof Symbol&&"symbol"==typeof Symbol.iterator){_typeof=function(a){return typeof a}}else{_typeof=function(a){return a&&"function"==typeof Symbol&&a.constructor===Symbol&&a!==Symbol.prototype?"symbol":typeof a}}return _typeof(a)}define ("qbank_editquestion/question_status",["exports","core/fragment","core/str","core/modal_factory","core/notification","core/modal_events","core/ajax"],function(a,b,c,d,e,f,g){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.init=void 0;b=j(b);c=i(c);d=j(d);e=j(e);f=j(f);g=j(g);function h(){if("function"!=typeof WeakMap)return null;var a=new WeakMap;h=function(){return a};return a}function i(a){if(a&&a.__esModule){return a}if(null===a||"object"!==_typeof(a)&&"function"!=typeof a){return{default:a}}var b=h();if(b&&b.has(a)){return b.get(a)}var c={},d=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var e in a){if(Object.prototype.hasOwnProperty.call(a,e)){var f=d?Object.getOwnPropertyDescriptor(a,e):null;if(f&&(f.get||f.set)){Object.defineProperty(c,e,f)}else{c[e]=a[e]}}}c.default=a;if(b){b.set(a,c)}return c}function j(a){return a&&a.__esModule?a:{default:a}}var k=function(a,c){return b.default.loadFragment("qbank_editquestion","question_status",c,a)},l=function(a,b){return g.default.call([{methodname:"qbank_editquestion_set_status",args:{questionid:a,formdata:b}}])[0]},m=function(a,b,c){var d=a.getBody().find("form").serialize();l(b,d).then(function(a){if(a.status){c.innerText=a.statusname}}).catch(e.default.exception)},n=function(a,b,c){o({questionid:a},b).then(function(b){b.show();var d=b.getRoot();d.on(f.default.save,function(d){d.preventDefault();d.stopPropagation();m(b,a,c);b.hide()});return b}).catch(e.default.exception)},o=function(a,b){return d.default.create({type:d.default.types.SAVE_CANCEL,title:c.get_string("questionstatusheader","qbank_editquestion"),body:k(a,b),large:!1})};a.init=function init(a,b){var c=document.querySelector(a),d=c.getAttribute("data-questionid");c.addEventListener("click",function(){n(d,b,c)})}}); +//# sourceMappingURL=question_status.min.js.map diff --git a/question/bank/editquestion/amd/build/question_status.min.js.map b/question/bank/editquestion/amd/build/question_status.min.js.map new file mode 100644 index 00000000000..befc1ea9ee1 --- /dev/null +++ b/question/bank/editquestion/amd/build/question_status.min.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../src/question_status.js"],"names":["getFragment","args","contextId","Fragment","loadFragment","setQuestionStatus","questionId","formData","Ajax","call","methodname","questionid","formdata","save","modal","target","getBody","find","serialize","then","result","status","innerText","statusname","catch","Notification","exception","statusEvent","getStatusModal","show","root","getRoot","on","ModalEvents","e","preventDefault","stopPropagation","hide","ModalFactory","create","type","types","SAVE_CANCEL","title","Str","get_string","body","large","init","questionSelector","document","querySelector","getAttribute","addEventListener"],"mappings":"oiBAwBA,OACA,OACA,OACA,OACA,OACA,O,4lBAUMA,CAAAA,CAAW,CAAG,SAACC,CAAD,CAAOC,CAAP,CAAqB,CACrC,MAAOC,WAASC,YAAT,CAAsB,oBAAtB,CAA4C,iBAA5C,CAA+DF,CAA/D,CAA0ED,CAA1E,CACV,C,CASKI,CAAiB,CAAG,SAACC,CAAD,CAAaC,CAAb,QAA0BC,WAAKC,IAAL,CAAU,CAAC,CAC3DC,UAAU,CAAE,+BAD+C,CAE3DT,IAAI,CAAE,CACFU,UAAU,CAAEL,CADV,CAEFM,QAAQ,CAAEL,CAFR,CAFqD,CAAD,CAAV,EAMhD,CANgD,CAA1B,C,CAgBpBM,CAAI,CAAG,SAACC,CAAD,CAAQR,CAAR,CAAoBS,CAApB,CAA+B,CACxC,GAAMR,CAAAA,CAAQ,CAAGO,CAAK,CAACE,OAAN,GAAgBC,IAAhB,CAAqB,MAArB,EAA6BC,SAA7B,EAAjB,CAEAb,CAAiB,CAACC,CAAD,CAAaC,CAAb,CAAjB,CACKY,IADL,CACU,SAAAC,CAAM,CAAI,CACZ,GAAIA,CAAM,CAACC,MAAX,CAAmB,CACfN,CAAM,CAACO,SAAP,CAAmBF,CAAM,CAACG,UAC7B,CAEJ,CANL,EAOKC,KAPL,CAOWC,UAAaC,SAPxB,CAQH,C,CAUKC,CAAW,CAAG,SAACrB,CAAD,CAAaJ,CAAb,CAAwBa,CAAxB,CAAmC,CAInDa,CAAc,CAHH,CACPjB,UAAU,CAAEL,CADL,CAGG,CAAOJ,CAAP,CAAd,CACKiB,IADL,CACU,SAACL,CAAD,CAAW,CACbA,CAAK,CAACe,IAAN,GACA,GAAIC,CAAAA,CAAI,CAAGhB,CAAK,CAACiB,OAAN,EAAX,CACAD,CAAI,CAACE,EAAL,CAAQC,UAAYpB,IAApB,CAA0B,SAASqB,CAAT,CAAY,CAClCA,CAAC,CAACC,cAAF,GACAD,CAAC,CAACE,eAAF,GACAvB,CAAI,CAACC,CAAD,CAAQR,CAAR,CAAoBS,CAApB,CAAJ,CACAD,CAAK,CAACuB,IAAN,EACH,CALD,EAMA,MAAOvB,CAAAA,CACV,CAXL,EAYKU,KAZL,CAYWC,UAAaC,SAZxB,CAaH,C,CASKE,CAAc,CAAG,SAAC3B,CAAD,CAAOC,CAAP,QAAqBoC,WAAaC,MAAb,CAAoB,CAC5DC,IAAI,CAAEF,UAAaG,KAAb,CAAmBC,WADmC,CAE5DC,KAAK,CAAEC,CAAG,CAACC,UAAJ,CAAe,sBAAf,CAAuC,oBAAvC,CAFqD,CAG5DC,IAAI,CAAE9C,CAAW,CAACC,CAAD,CAAOC,CAAP,CAH2C,CAI5D6C,KAAK,GAJuD,CAApB,CAArB,C,QAcH,QAAPC,CAAAA,IAAO,CAACC,CAAD,CAAmB/C,CAAnB,CAAiC,IAC7Ca,CAAAA,CAAM,CAAGmC,QAAQ,CAACC,aAAT,CAAuBF,CAAvB,CADoC,CAE7C3C,CAAU,CAAGS,CAAM,CAACqC,YAAP,CAAoB,iBAApB,CAFgC,CAGjDrC,CAAM,CAACsC,gBAAP,CAAwB,OAAxB,CAAiC,UAAM,CAEnC1B,CAAW,CAACrB,CAAD,CAAaJ,CAAb,CAAwBa,CAAxB,CACd,CAHD,CAIH,C","sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Status column selector js.\n *\n * @module qbank_editquestion/question_status\n * @copyright 2021 Catalyst IT Australia Pty Ltd\n * @author Safat Shahin \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport Fragment from 'core/fragment';\nimport * as Str from 'core/str';\nimport ModalFactory from 'core/modal_factory';\nimport Notification from 'core/notification';\nimport ModalEvents from 'core/modal_events';\nimport Ajax from 'core/ajax';\n\n/**\n * Get the fragment.\n *\n * @method getFragment\n * @param {{questioned: Number}} args\n * @param {Number} contextId\n * @return {String}\n */\nconst getFragment = (args, contextId) => {\n return Fragment.loadFragment('qbank_editquestion', 'question_status', contextId, args);\n};\n\n/**\n * Set the question status.\n *\n * @param {Number} questionId The question id.\n * @param {String} formData The question tag form data in a URI encoded param string\n * @return {Array} The modified question status\n */\nconst setQuestionStatus = (questionId, formData) => Ajax.call([{\n methodname: 'qbank_editquestion_set_status',\n args: {\n questionid: questionId,\n formdata: formData\n }\n}])[0];\n\n/**\n * Save the status.\n *\n * @method getFragment\n * @param {object} modal\n * @param {Number} questionId\n * @param {HTMLElement} target\n */\nconst save = (modal, questionId, target) => {\n const formData = modal.getBody().find('form').serialize();\n\n setQuestionStatus(questionId, formData)\n .then(result => {\n if (result.status) {\n target.innerText = result.statusname;\n }\n return;\n })\n .catch(Notification.exception);\n};\n\n/**\n * Event listeners for the module.\n *\n * @method clickEvent\n * @param {Number} questionId\n * @param {Number} contextId\n * @param {HTMLElement} target\n */\nconst statusEvent = (questionId, contextId, target) => {\n let args = {\n questionid: questionId\n };\n getStatusModal(args, contextId)\n .then((modal) => {\n modal.show();\n let root = modal.getRoot();\n root.on(ModalEvents.save, function(e) {\n e.preventDefault();\n e.stopPropagation();\n save(modal, questionId, target);\n modal.hide();\n });\n return modal;\n })\n .catch(Notification.exception);\n};\n\n/**\n * Get the status modal to display.\n *\n * @param {{questionid: Number}} args\n * @param {Number} contextId\n * @return {HTMLElement}\n */\nconst getStatusModal = (args, contextId) => ModalFactory.create({\n type: ModalFactory.types.SAVE_CANCEL,\n title: Str.get_string('questionstatusheader', 'qbank_editquestion'),\n body: getFragment(args, contextId),\n large: false,\n});\n\n/**\n * Entrypoint of the js.\n *\n * @method init\n * @param {String} questionSelector the question status identifier.\n * @param {Number} contextId The context id of the question.\n */\nexport const init = (questionSelector, contextId) => {\n let target = document.querySelector(questionSelector);\n let questionId = target.getAttribute('data-questionid');\n target.addEventListener('click', () => {\n // Call for the event listener to listed for clicks in any usage count row.\n statusEvent(questionId, contextId, target);\n });\n};\n"],"file":"question_status.min.js"} \ No newline at end of file diff --git a/question/bank/editquestion/amd/src/question_status.js b/question/bank/editquestion/amd/src/question_status.js new file mode 100644 index 00000000000..7b0d1202483 --- /dev/null +++ b/question/bank/editquestion/amd/src/question_status.js @@ -0,0 +1,135 @@ +// 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 . + +/** + * Status column selector js. + * + * @module qbank_editquestion/question_status + * @copyright 2021 Catalyst IT Australia Pty Ltd + * @author Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +import Fragment from 'core/fragment'; +import * as Str from 'core/str'; +import ModalFactory from 'core/modal_factory'; +import Notification from 'core/notification'; +import ModalEvents from 'core/modal_events'; +import Ajax from 'core/ajax'; + +/** + * Get the fragment. + * + * @method getFragment + * @param {{questioned: Number}} args + * @param {Number} contextId + * @return {String} + */ +const getFragment = (args, contextId) => { + return Fragment.loadFragment('qbank_editquestion', 'question_status', contextId, args); +}; + +/** + * Set the question status. + * + * @param {Number} questionId The question id. + * @param {String} formData The question tag form data in a URI encoded param string + * @return {Array} The modified question status + */ +const setQuestionStatus = (questionId, formData) => Ajax.call([{ + methodname: 'qbank_editquestion_set_status', + args: { + questionid: questionId, + formdata: formData + } +}])[0]; + +/** + * Save the status. + * + * @method getFragment + * @param {object} modal + * @param {Number} questionId + * @param {HTMLElement} target + */ +const save = (modal, questionId, target) => { + const formData = modal.getBody().find('form').serialize(); + + setQuestionStatus(questionId, formData) + .then(result => { + if (result.status) { + target.innerText = result.statusname; + } + return; + }) + .catch(Notification.exception); +}; + +/** + * Event listeners for the module. + * + * @method clickEvent + * @param {Number} questionId + * @param {Number} contextId + * @param {HTMLElement} target + */ +const statusEvent = (questionId, contextId, target) => { + let args = { + questionid: questionId + }; + getStatusModal(args, contextId) + .then((modal) => { + modal.show(); + let root = modal.getRoot(); + root.on(ModalEvents.save, function(e) { + e.preventDefault(); + e.stopPropagation(); + save(modal, questionId, target); + modal.hide(); + }); + return modal; + }) + .catch(Notification.exception); +}; + +/** + * Get the status modal to display. + * + * @param {{questionid: Number}} args + * @param {Number} contextId + * @return {HTMLElement} + */ +const getStatusModal = (args, contextId) => ModalFactory.create({ + type: ModalFactory.types.SAVE_CANCEL, + title: Str.get_string('questionstatusheader', 'qbank_editquestion'), + body: getFragment(args, contextId), + large: false, +}); + +/** + * Entrypoint of the js. + * + * @method init + * @param {String} questionSelector the question status identifier. + * @param {Number} contextId The context id of the question. + */ +export const init = (questionSelector, contextId) => { + let target = document.querySelector(questionSelector); + let questionId = target.getAttribute('data-questionid'); + target.addEventListener('click', () => { + // Call for the event listener to listed for clicks in any usage count row. + statusEvent(questionId, contextId, target); + }); +}; diff --git a/question/bank/editquestion/classes/editquestion_helper.php b/question/bank/editquestion/classes/editquestion_helper.php index a5ba652d7f2..c6d45e25815 100644 --- a/question/bank/editquestion/classes/editquestion_helper.php +++ b/question/bank/editquestion/classes/editquestion_helper.php @@ -27,7 +27,7 @@ namespace qbank_editquestion; -use core_question\local\bank\constants; +use core_question\local\bank\question_version_status; /** * Class editquestion_helper for methods related to add/edit/copy @@ -89,20 +89,11 @@ class editquestion_helper { /** * Get the string for the status of the question. * - * @param int $status + * @param string $status * @return string */ public static function get_question_status_string($status): string { - if ((int)$status == constants::QUESTION_STATUS_READY) { - $statusstring = get_string('questionstatusready', 'qbank_editquestion'); - } else if ((int)$status == constants::QUESTION_STATUS_HIDDEN) { - $statusstring = get_string('questionstatushidden', 'qbank_editquestion'); - } else if ((int)$status == constants::QUESTION_STATUS_DRAFT) { - $statusstring = get_string('questionstatusdraft', 'qbank_editquestion'); - } else { - $statusstring = get_string('questionstatusunknown', 'qbank_editquestion'); - } - return $statusstring; + return get_string('questionstatus' . $status, 'qbank_editquestion'); } /** @@ -112,8 +103,8 @@ class editquestion_helper { */ public static function get_question_status_list(): array { $statuslist = []; - $statuslist[constants::QUESTION_STATUS_READY] = get_string('questionstatusready', 'qbank_editquestion'); - $statuslist[constants::QUESTION_STATUS_DRAFT] = get_string('questionstatusdraft', 'qbank_editquestion'); + $statuslist[question_version_status::QUESTION_STATUS_READY] = get_string('questionstatusready', 'qbank_editquestion'); + $statuslist[question_version_status::QUESTION_STATUS_DRAFT] = get_string('questionstatusdraft', 'qbank_editquestion'); return $statuslist; } diff --git a/question/bank/editquestion/classes/external/update_question_version_status.php b/question/bank/editquestion/classes/external/update_question_version_status.php new file mode 100644 index 00000000000..db1bb7c9d3e --- /dev/null +++ b/question/bank/editquestion/classes/external/update_question_version_status.php @@ -0,0 +1,109 @@ +. + +namespace qbank_editquestion\external; + +defined('MOODLE_INTERNAL') || die(); + +require_once($CFG->libdir . '/externallib.php'); +require_once($CFG->dirroot . '/question/engine/bank.php'); + +use external_api; +use external_function_parameters; +use external_single_structure; +use external_value; +use qbank_editquestion\editquestion_helper; +use question_bank; + +/** + * Update question status external api. + * + * @package qbank_editquestion + * @copyright 2021 Catalyst IT Australia Pty Ltd + * @author Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class update_question_version_status extends \external_api { + + /** + * Returns description of method parameters. + * + * @return external_function_parameters. + */ + public static function execute_parameters() { + return new external_function_parameters([ + 'questionid' => new external_value(PARAM_INT, 'The question id'), + 'formdata' => new external_value(PARAM_RAW, 'The data from the status form'), + ]); + } + + /** + * Handles the status form submission. + * + * @param int $questionid The question id. + * @param string $formdata The question tag form data in a URI encoded param string + * @return array The created or modified question tag + */ + public static function execute($questionid, $formdata) { + global $DB; + + $data = []; + $result = [ + 'status' => false, + 'statusname' => '' + ]; + + // Parameter validation. + $params = self::validate_parameters(self::execute_parameters(), [ + 'questionid' => $questionid, + 'formdata' => $formdata + ]); + + parse_str($params['formdata'], $data); + + $question = question_bank::load_question($params['questionid']); + $editingcontext = \context::instance_by_id($question->contextid); + self::validate_context($editingcontext); + $canedit = question_has_capability_on($question, 'edit'); + $mform = new \qbank_editquestion\form\question_status_form(null, null, 'post', '', null, $canedit, $data); + if ($validateddata = $mform->get_data()) { + if ($canedit && isset($validateddata->status)) { + $versionrecord = $DB->get_record('question_versions', ['questionid' => $params['questionid']]); + $versionrecord->status = $validateddata->status; + $DB->update_record('question_versions', $versionrecord); + question_bank::notify_question_edited($question->id); + $result = [ + 'status' => true, + 'statusname' => editquestion_helper::get_question_status_string($versionrecord->status) + ]; + $event = \core\event\question_updated::create_from_question_instance($question, $editingcontext); + $event->trigger(); + } + } + + return $result; + } + + /** + * Returns description of method result value. + */ + public static function execute_returns() { + return new external_single_structure([ + 'status' => new external_value(PARAM_BOOL, 'status: true if success'), + 'statusname' => new external_value(PARAM_RAW, 'statusname: name of the status') + ]); + } +} diff --git a/question/bank/editquestion/classes/form/question_status_form.php b/question/bank/editquestion/classes/form/question_status_form.php new file mode 100644 index 00000000000..03d3912f126 --- /dev/null +++ b/question/bank/editquestion/classes/form/question_status_form.php @@ -0,0 +1,39 @@ +. + +namespace qbank_editquestion\form; + +defined('MOODLE_INTERNAL') || die(); + +require_once($CFG->dirroot . '/lib/formslib.php'); + +/** + * Class question_status_form to change the question status using a modal. + * + * @package qbank_editquestion + * @copyright 2021 Catalyst IT Australia Pty Ltd + * @author Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class question_status_form extends \moodleform { + + public function definition() { + $mform = $this->_form; + $mform->disable_form_change_checker(); + $mform->addElement('select', 'status', get_string('status', 'qbank_editquestion'), + \qbank_editquestion\editquestion_helper::get_question_status_list()); + } +} diff --git a/question/bank/editquestion/classes/question_status_column.php b/question/bank/editquestion/classes/question_status_column.php index c0980fc2591..a72737a5e44 100644 --- a/question/bank/editquestion/classes/question_status_column.php +++ b/question/bank/editquestion/classes/question_status_column.php @@ -14,23 +14,13 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -/** - * A column to show the status of the question. - * - * @package qbank_editquestion - * @copyright 2021 Catalyst IT Australia Pty Ltd - * @author Safat Shahin - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ - namespace qbank_editquestion; -defined('MOODLE_INTERNAL') || die(); - use core_question\local\bank\column_base; +use core_question\local\bank\question_version_status; /** - * Class question_status_column. + * A column to show the status of the question. * * @package qbank_editquestion * @copyright 2021 Catalyst IT Australia Pty Ltd @@ -48,9 +38,22 @@ class question_status_column extends column_base { } protected function display_content($question, $rowclasses): void { - global $DB; - $version = $DB->get_record('question_versions', ['questionid' => $question->id], 'status'); - echo \html_writer::tag('a', editquestion_helper::get_question_status_string($version->status)); + global $PAGE; + $attributes = []; + if (question_has_capability_on($question, 'edit') + && $question->status !== question_version_status::QUESTION_STATUS_HIDDEN) { + $target = 'questionstatus_' . $question->id; + $datatarget = '[data-target="' . $target . '"]'; + $PAGE->requires->js_call_amd('qbank_editquestion/question_status', 'init', [$datatarget, $question->contextid]); + $attributes = [ + 'data-target' => $target, + 'data-questionid' => $question->id, + 'data-courseid' => $this->qbank->course->id, + 'class' => 'link-primary comment-pointer', + 'href' => '#' + ]; + } + echo \html_writer::tag('a', editquestion_helper::get_question_status_string($question->status), $attributes); } } diff --git a/question/bank/editquestion/db/services.php b/question/bank/editquestion/db/services.php new file mode 100644 index 00000000000..208ee0ef082 --- /dev/null +++ b/question/bank/editquestion/db/services.php @@ -0,0 +1,35 @@ +. + +/** + * External services definition for qbank_editquestion. + * + * @package qbank_editquestion + * @copyright 2021 Catalyst IT Australia Pty Ltd + * @author Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$functions = [ + 'qbank_editquestion_set_status' => [ + 'classname' => 'qbank_editquestion\external\update_question_version_status', + 'description' => 'Update the question status.', + 'type' => 'write', + 'ajax' => true, + ], +]; diff --git a/question/bank/editquestion/lang/en/qbank_editquestion.php b/question/bank/editquestion/lang/en/qbank_editquestion.php index 853d50530c9..ce0ebc576c3 100644 --- a/question/bank/editquestion/lang/en/qbank_editquestion.php +++ b/question/bank/editquestion/lang/en/qbank_editquestion.php @@ -31,7 +31,7 @@ $string['questionstatus'] = 'Status'; $string['questionstatusready'] = 'Ready'; $string['questionstatushidden'] = 'Hidden'; $string['questionstatusdraft'] = 'Draft'; -$string['questionstatusunknown'] = 'Unknown'; +$string['questionstatusheader'] = 'Change question status'; // Edit form. $string['versioninfo'] = 'Version'; diff --git a/question/bank/editquestion/lib.php b/question/bank/editquestion/lib.php new file mode 100644 index 00000000000..ffc71451fb2 --- /dev/null +++ b/question/bank/editquestion/lib.php @@ -0,0 +1,41 @@ +. + +/** + * Helper functions and callbacks. + * + * @package qbank_editquestion + * @copyright 2021 Catalyst IT Australia Pty Ltd + * @author Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +/** + * Question status fragment callback. + * + * @param array $args + * @return string rendered output + */ +function qbank_editquestion_output_fragment_question_status($args): string { + global $CFG; + require_once($CFG->dirroot . '/question/engine/bank.php'); + $question = question_bank::load_question($args['questionid']); + $mform = new \qbank_editquestion\form\question_status_form(); + $data = ['status' => $question->status]; + $mform->set_data($data); + + return $mform->render(); +} diff --git a/question/bank/editquestion/question.php b/question/bank/editquestion/question.php index 9dd1cec5711..e6f08fad2c6 100644 --- a/question/bank/editquestion/question.php +++ b/question/bank/editquestion/question.php @@ -218,7 +218,7 @@ if (isset($question->id)) { $toform->status = $questionobject->status; $toform->idnumber = $questionobject->idnumber; } else { - $toform->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $toform->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; } if ($makecopy) { $toform->idnumber = core_question_find_next_unused_idnumber($toform->idnumber, $category->id); @@ -249,7 +249,8 @@ if ($mform->is_cancelled()) { // If we are saving as a copy, break the connection to the old question. if ($makecopy) { $question->id = 0; - $question->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; // Copies should not be hidden. + // Copies should not be hidden. + $question->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; } // If is will be added directly to a module send the module name to be referenced. diff --git a/question/bank/editquestion/templates/question_info.mustache b/question/bank/editquestion/templates/question_info.mustache index 387514488ff..ee19fba1ce9 100644 --- a/question/bank/editquestion/templates/question_info.mustache +++ b/question/bank/editquestion/templates/question_info.mustache @@ -21,7 +21,7 @@ { "elements": [ {"pluginhtml":"
Version: 1
"}, - {"pluginhtml":"
Usage: 1
"}, + {"pluginhtml":"
Usage: 1
"} ] } }} diff --git a/question/bank/editquestion/tests/behat/behat_qbank_editquestion.php b/question/bank/editquestion/tests/behat/behat_qbank_editquestion.php deleted file mode 100644 index 2357cc03de5..00000000000 --- a/question/bank/editquestion/tests/behat/behat_qbank_editquestion.php +++ /dev/null @@ -1,59 +0,0 @@ -. - -// NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php. -require_once(__DIR__ . '/../../../../../lib/behat/behat_base.php'); -require_once(__DIR__ . '/../../../../tests/behat/behat_question_base.php'); - -use Behat\Mink\Exception\ExpectationException as ExpectationException, - Behat\Mink\Exception\ElementNotFoundException as ElementNotFoundException; - -/** - * Steps definitions to deal with the usage in question. - * - * @package qbank_usage - * @copyright 2021 Catalyst IT Australia Pty Ltd - * @author Safat Shahin - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ -class behat_qbank_editquestion extends behat_question_base { - - /** - * Looks for a table, then looks for a row that contains the given text. - * Once it finds the right row, it clicks a link in that row. - * - * @When I click :arg1 on the status column - * @param string $linkname - */ - public function i_click_on_the_status_column($linkname) { - $exception = new ElementNotFoundException($this->getSession(), - 'Cannot find any row on the page containing the text ' . $linkname); - $row = $this->find('css', sprintf('table tbody tr td.questionstatus a:contains("%s")', $linkname), $exception); - $row->click(); - } - - /** - * Looks for the appropriate usage count in the column. - * - * @Then I should see :arg1 on the status column - * @param string $linkdata - */ - public function i_should_see_on_the_status_column($linkdata) { - $exception = new ElementNotFoundException($this->getSession(), - 'Cannot find any row with the usage count of ' . $linkdata . ' on the column named Status'); - $this->find('css', sprintf('table tbody tr td.questionstatus a:contains("%s")', $linkdata), $exception); - } -} diff --git a/question/bank/editquestion/tests/behat/question_status.feature b/question/bank/editquestion/tests/behat/question_status.feature index 7e8944b6579..54954b3cd8f 100644 --- a/question/bank/editquestion/tests/behat/question_status.feature +++ b/question/bank/editquestion/tests/behat/question_status.feature @@ -23,9 +23,9 @@ Feature: Use the qbank base view to test the status change using And I navigate to "Question bank > Questions" in current page administration And I set the field "Select a category" to "Test questions" And I should see "Test questions" - And I should see "Ready" on the status column - When I click "Ready" on the status column + And I should see "Ready" in the "First question" "table_row" + When I click on "Ready" "link" in the "First question" "table_row" Then I should see "Change question status" And I should see "Question status" And I click on "Close" "button" in the ".modal-dialog" "css_element" - And I should see "Ready" on the status column + And I should see "Ready" in the "First question" "table_row" diff --git a/question/bank/editquestion/tests/external/update_question_version_status_test.php b/question/bank/editquestion/tests/external/update_question_version_status_test.php new file mode 100644 index 00000000000..c8239415333 --- /dev/null +++ b/question/bank/editquestion/tests/external/update_question_version_status_test.php @@ -0,0 +1,91 @@ +. + +namespace qbank_editquestion; + +use qbank_editquestion\external\update_question_version_status; + +/** + * Submit status external api test. + * + * @package qbank_editquestion + * @copyright 2021 Catalyst IT Australia Pty Ltd + * @author Safat Shahin + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @coversDefaultClass \core_question\local\bank\question_version_status + * @coversDefaultClass \qbank_editquestion\form\question_status_form + * @coversDefaultClass \qbank_editquestion\editquestion_helper + */ +class update_question_version_status_test extends \advanced_testcase { + + /** + * Called before every test. + */ + public function setUp(): void { + global $USER; + parent::setUp(); + $this->setAdminUser(); + $this->course = $this->getDataGenerator()->create_course(); + $this->user = $USER; + } + + /** + * Test if the submit status webservice changes the status of the question. + * + * @covers ::mock_generate_submit_keys + * @covers ::execute + * @covers ::get_question_status_string + */ + public function test_submit_status_updates_the_question_status() { + global $DB; + $this->resetAfterTest(); + $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); + $cat = $questiongenerator->create_question_category(); + $numq = $questiongenerator->create_question('essay', null, + ['category' => $cat->id, 'name' => 'This is the first version']); + $data = ['status' => 2]; + $mform = \qbank_editquestion\form\question_status_form::mock_generate_submit_keys($data); + $this->expectException('moodle_exception'); + list($result, $statusname) = update_question_version_status::execute($numq->id, http_build_query($mform, '', '&')); + // Test if the version actually changed. + $currentstatus = $DB->get_record('question_versions', ['questionid' => $numq->id]); + $this->assertEquals($data['status'], $currentstatus->status); + $this->assertEquals(editquestion_helper::get_question_status_string($currentstatus->status), $statusname); + } + + /** + * Test that updating the status does not create a new version. + * + * @covers ::mock_generate_submit_keys + * @covers ::execute + */ + public function test_submit_status_does_not_create_a_new_version() { + global $DB; + $this->resetAfterTest(); + $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); + $cat = $questiongenerator->create_question_category(); + $numq = $questiongenerator->create_question('essay', null, + ['category' => $cat->id, 'name' => 'This is the first version']); + $countcurrentrecords = $DB->count_records('question_versions'); + $this->assertEquals(1, $countcurrentrecords); + $data = ['status' => 2]; + $mform = \qbank_editquestion\form\question_status_form::mock_generate_submit_keys($data); + $this->expectException('moodle_exception'); + list($result, $statusname) = update_question_version_status::execute($numq->id, http_build_query($mform, '', '&')); + $countafterupdate = $DB->count_records('question_versions'); + $this->assertEquals($countcurrentrecords, $countafterupdate); + } +} diff --git a/question/bank/editquestion/version.php b/question/bank/editquestion/version.php index 69614fede68..37bf2b0cc1e 100644 --- a/question/bank/editquestion/version.php +++ b/question/bank/editquestion/version.php @@ -26,6 +26,6 @@ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'qbank_editquestion'; -$plugin->version = 2021062800; +$plugin->version = 2021110800; $plugin->requires = 2021052500; $plugin->maturity = MATURITY_STABLE; diff --git a/question/bank/managecategories/classes/helper.php b/question/bank/managecategories/classes/helper.php index 3f44ed7bb56..993d6cc2156 100644 --- a/question/bank/managecategories/classes/helper.php +++ b/question/bank/managecategories/classes/helper.php @@ -17,7 +17,7 @@ namespace qbank_managecategories; use context; -use core_question\local\bank\constants; +use core_question\local\bank\question_version_status; use moodle_exception; use html_writer; @@ -61,14 +61,14 @@ class helper { public static function question_remove_stale_questions_from_category(int $categoryid): void { global $DB; - $sql = 'SELECT q.id + $sql = "SELECT q.id FROM {question} q JOIN {question_versions} qv ON qv.questionid = q.id JOIN {question_bank_entries} qbe ON qbe.id = qv.questionbankentryid WHERE qbe.questioncategoryid = :categoryid - AND (q.qtype = :qtype OR qv.status = :status)'; + AND (q.qtype = :qtype OR qv.status = :status)"; - $params = ['categoryid' => $categoryid, 'qtype' => 'random', 'status' => 1]; + $params = ['categoryid' => $categoryid, 'qtype' => 'random', 'status' => question_version_status::QUESTION_STATUS_HIDDEN]; $questions = $DB->get_records_sql($sql, $params); foreach ($questions as $question) { // The function question_delete_question does not delete questions in use. @@ -246,17 +246,15 @@ class helper { * Get all the category objects, including a count of the number of questions in that category, * for all the categories in the lists $contexts. * - * @param $contexts + * @param context $contexts * @param string $sortorder used as the ORDER BY clause in the select statement. * @param bool $top Whether to return the top categories or not. * @param int $showallversions 1 to show all versions not only the latest. - * @param int|null $statustocount The version status to count. If null will get all except hidden. * @return array of category objects. * @throws \dml_exception */ public static function get_categories_for_contexts($contexts, string $sortorder = 'parent, sortorder, name ASC', - bool $top = false, int $showallversions = 0, - ?int $statustocount = null): array { + bool $top = false, int $showallversions = 0): array { global $DB; $topwhere = $top ? '' : 'AND c.parent <> 0'; $statuscondition = "AND (qv.status = '". question_version_status::QUESTION_STATUS_READY . "' " . diff --git a/question/bank/managecategories/tests/helper_test.php b/question/bank/managecategories/tests/helper_test.php index 5f7ee5b2702..e7a7f640150 100644 --- a/question/bank/managecategories/tests/helper_test.php +++ b/question/bank/managecategories/tests/helper_test.php @@ -72,13 +72,13 @@ class helper_test extends \advanced_testcase { $qcat1 = $this->qgenerator->create_question_category(['contextid' => $this->context->id]); $q1a = $this->qgenerator->create_question('shortanswer', null, ['category' => $qcat1->id]); // Will be hidden. - $DB->set_field('question_versions', 'status', 1, ['questionid' => $q1a->id]); + $DB->set_field('question_versions', 'status', 'hidden', ['questionid' => $q1a->id]); $qcat2 = $this->qgenerator->create_question_category(['contextid' => $this->context->id]); $q2a = $this->qgenerator->create_question('shortanswer', null, ['category' => $qcat2->id]); // Will be hidden. $q2b = $this->qgenerator->create_question('shortanswer', null, ['category' => $qcat2->id]); // Will be hidden but used. - $DB->set_field('question_versions', 'status', 1, ['questionid' => $q2a->id]); - $DB->set_field('question_versions', 'status', 1, ['questionid' => $q2b->id]); + $DB->set_field('question_versions', 'status', 'hidden', ['questionid' => $q2a->id]); + $DB->set_field('question_versions', 'status', 'hidden', ['questionid' => $q2b->id]); quiz_add_quiz_question($q2b->id, $this->quiz); // Adding a new random question does not add a new question, adds a question_set_references record. diff --git a/question/bank/previewquestion/tests/qbank_preview_helper_test.php b/question/bank/previewquestion/tests/qbank_preview_helper_test.php index 7265f98b161..ccd8b15f721 100644 --- a/question/bank/previewquestion/tests/qbank_preview_helper_test.php +++ b/question/bank/previewquestion/tests/qbank_preview_helper_test.php @@ -79,7 +79,8 @@ class helper_test extends \advanced_testcase { $cat = question_make_default_categories($contexts->all()); $this->questiondata = $questiongenerator->create_question('numerical', null, ['name' => 'Example question', 'category' => $cat->id]); - $this->quba = question_engine::make_questions_usage_by_activity('core_question_preview', \context_user::instance($USER->id)); + $this->quba = question_engine::make_questions_usage_by_activity('core_question_preview', + \context_user::instance($USER->id)); $this->options = new question_preview_options($this->questiondata); $this->options->load_user_defaults(); $this->options->set_from_request(); diff --git a/question/bank/usage/classes/helper.php b/question/bank/usage/classes/helper.php index ce250fed619..c6816153e59 100644 --- a/question/bank/usage/classes/helper.php +++ b/question/bank/usage/classes/helper.php @@ -62,7 +62,7 @@ class helper { qz.course as courseid FROM {quiz_slots} slot JOIN {quiz} qz ON qz.id = slot.quizid - JOIN {question_references} qr ON qr.itemid = slot.id + JOIN {question_references} qr ON qr.itemid = slot.id JOIN {question_bank_entries} qbe ON qbe.id = qr.questionbankentryid JOIN {question_versions} qv ON qv.questionbankentryid = qbe.id WHERE qv.questionbankentryid = ?)"; diff --git a/question/bank/usage/classes/tables/question_usage_table.php b/question/bank/usage/classes/tables/question_usage_table.php index 21d46d57a74..b9c9578a6f0 100644 --- a/question/bank/usage/classes/tables/question_usage_table.php +++ b/question/bank/usage/classes/tables/question_usage_table.php @@ -84,7 +84,7 @@ class question_usage_table extends table_sql { } $sql = helper::question_usage_sql(); - $params = [$this->question->id, $this->question->id]; + $params = [$this->question->id, $this->question->questionbankentryid]; if (!$this->is_downloading()) { $this->rawdata = $DB->get_records_sql($sql, $params, $this->get_page_start(), $this->get_page_size()); diff --git a/question/bank/viewcreator/lib.php b/question/bank/viewcreator/lib.php index 1ce462d9d23..68b83ca1944 100644 --- a/question/bank/viewcreator/lib.php +++ b/question/bank/viewcreator/lib.php @@ -39,7 +39,7 @@ function qbank_viewcreator_edit_form_display($question): string { $a->time = userdate($question->timecreated); $a->user = fullname($DB->get_record('user', ['id' => $question->createdby])); $versiondata['createdby'] = get_string('created', 'question') . ' ' . - get_string('byandon', 'question', $a) ; + get_string('byandon', 'question', $a); } return $PAGE->get_renderer('qbank_viewcreator')->render_version_info($versiondata); diff --git a/question/classes/bank/search/hidden_condition.php b/question/classes/bank/search/hidden_condition.php index a7f16654fd0..ace415690a7 100644 --- a/question/classes/bank/search/hidden_condition.php +++ b/question/classes/bank/search/hidden_condition.php @@ -25,7 +25,7 @@ namespace core_question\bank\search; -use core_question\local\bank\constants; +use core_question\local\bank\question_version_status; /** * This class controls whether hidden / deleted questions are hidden in the list. @@ -48,8 +48,8 @@ class hidden_condition extends condition { public function __construct($hide = true) { $this->hide = $hide; if ($hide) { - $this->where = 'qv.status = ' . constants::QUESTION_STATUS_READY . - ' OR qv.status = ' . constants::QUESTION_STATUS_DRAFT; + $this->where = "qv.status = '" . question_version_status::QUESTION_STATUS_READY . "' " . + " OR qv.status = '" . question_version_status::QUESTION_STATUS_DRAFT . "' "; } } diff --git a/question/classes/local/bank/random_question_loader.php b/question/classes/local/bank/random_question_loader.php index f6560552900..f26fa5d774b 100644 --- a/question/classes/local/bank/random_question_loader.php +++ b/question/classes/local/bank/random_question_loader.php @@ -290,13 +290,13 @@ class random_question_loader { $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.) + // 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'); + 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 diff --git a/question/classes/local/bank/view.php b/question/classes/local/bank/view.php index b739353bd76..5617a755a7c 100644 --- a/question/classes/local/bank/view.php +++ b/question/classes/local/bank/view.php @@ -1159,7 +1159,7 @@ class view { */ protected function get_row_classes($question, $rowcount): array { $classes = []; - if ((int)$question->status === 1) { + if ($question->status === question_version_status::QUESTION_STATUS_HIDDEN) { $classes[] = 'dimmed_text'; } if ($question->id == $this->lastchangedid) { diff --git a/question/editlib.php b/question/editlib.php index 80e3d88465c..95f65f2754a 100644 --- a/question/editlib.php +++ b/question/editlib.php @@ -58,6 +58,7 @@ function get_module_from_cmid($cmid) { * @param bool $noparent if true only questions with NO parent will be selected * @param bool $recurse include subdirectories * @param bool $export set true if this is called by questionbank export + * @param bool $latestversion if only the latest versions needed * @return array */ function get_questions_category(object $category, bool $noparent, bool $recurse = true, bool $export = true, @@ -67,7 +68,7 @@ function get_questions_category(object $category, bool $noparent, bool $recurse // Build sql bit for $noparent. $npsql = ''; if ($noparent) { - $npsql = " and q.parent='0' "; + $npsql = " and q.parent='0' "; } // Get list of categories. diff --git a/question/engine/bank.php b/question/engine/bank.php index cc9f977c165..d89a81665f0 100644 --- a/question/engine/bank.php +++ b/question/engine/bank.php @@ -550,7 +550,7 @@ class question_finder implements cache_data_source { list($qcsql, $qcparams) = $DB->get_in_or_equal($categoryids, SQL_PARAMS_NAMED, 'qc'); - $readystatus = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $readystatus = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $select = "q.id, (SELECT COUNT(1) FROM " . $qubaids->from_question_attempts('qa') . " WHERE qa.questionid = q.id AND " . $qubaids->where() . " @@ -561,7 +561,7 @@ class question_finder implements cache_data_source { $from = $from . " " . $join; $where = "qbe.questioncategoryid {$qcsql} AND q.parent = 0 - AND qv.status = " . $readystatus . " + AND qv.status = '$readystatus' AND qv.version = (SELECT MAX(v.version) FROM {question_versions} v JOIN {question_bank_entries} be diff --git a/question/engine/tests/helpers.php b/question/engine/tests/helpers.php index bab3b0f5aa8..b94a88aa487 100644 --- a/question/engine/tests/helpers.php +++ b/question/engine/tests/helpers.php @@ -179,7 +179,7 @@ class test_question_maker { $q->penalty = 0.3333333; $q->length = 1; $q->stamp = make_unique_id_code(); - $q->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $q->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $q->version = 1; $q->timecreated = time(); $q->timemodified = time(); @@ -201,7 +201,7 @@ class test_question_maker { $qdata->penalty = 0.3333333; $qdata->length = 1; $qdata->stamp = make_unique_id_code(); - $qdata->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $qdata->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $qdata->version = 1; $qdata->timecreated = time(); $qdata->timemodified = time(); diff --git a/question/format.php b/question/format.php index 851305e7b57..987e70bf4bb 100644 --- a/question/format.php +++ b/question/format.php @@ -437,7 +437,7 @@ class qformat_default { $questionversion->questionbankentryid = $questionbankentry->id; $questionversion->questionid = $question->id; $questionversion->version = 1; - $questionversion->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $questionversion->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $questionversion->id = $DB->insert_record('question_versions', $questionversion); $event = \core\event\question_created::create_from_question_instance($question, $this->importcontext); @@ -924,6 +924,11 @@ class qformat_default { $contextid = $DB->get_field('question_categories', 'contextid', ['id' => $qcategory]); $question->contextid = $contextid; $question->idnumber = $questionbankentry->idnumber; + if ($question->status === \core_question\local\bank\question_version_status::QUESTION_STATUS_READY) { + $question->status = 0; + } else { + $question->status = 1; + } // do not export hidden questions if (!empty($question->hidden)) { diff --git a/question/format/multianswer/format.php b/question/format/multianswer/format.php index b4cce7c9687..3121b64923a 100644 --- a/question/format/multianswer/format.php +++ b/question/format/multianswer/format.php @@ -67,7 +67,7 @@ class qformat_multianswer extends qformat_default { $question->generalfeedbackformat = FORMAT_MOODLE; $question->length = 1; $question->penalty = 0.3333333; - $question->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $question->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $question->version = 1; $question->versionid = 0; $question->questionbankentryid = 0; diff --git a/question/format/xml/format.php b/question/format/xml/format.php index 19ad7de2be7..f3cc79ad15f 100644 --- a/question/format/xml/format.php +++ b/question/format/xml/format.php @@ -1182,6 +1182,7 @@ class qformat_xml extends qformat_default { $invalidquestion = false; $fs = get_file_storage(); $contextid = $question->contextid; + $question->status = 0; // Get files used by the questiontext. $question->questiontextfiles = $fs->get_area_files( $contextid, 'question', 'questiontext', $question->id); diff --git a/question/format/xml/tests/xmlformat_test.php b/question/format/xml/tests/xmlformat_test.php index 482c77b5502..ce1daba75ab 100644 --- a/question/format/xml/tests/xmlformat_test.php +++ b/question/format/xml/tests/xmlformat_test.php @@ -57,7 +57,7 @@ class qformat_xml_test extends question_testcase { $q->penalty = 0.3333333; $q->length = 1; $q->stamp = make_unique_id_code(); - $q->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $q->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $q->timecreated = time(); $q->timemodified = time(); $q->createdby = $USER->id; @@ -341,7 +341,7 @@ END; $qdata->defaultmark = 0; $qdata->length = 0; $qdata->penalty = 0; - $qdata->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $qdata->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $qdata->idnumber = null; $exporter = new qformat_xml(); @@ -569,7 +569,7 @@ END; $qdata->defaultmark = 1; $qdata->length = 1; $qdata->penalty = 0; - $qdata->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $qdata->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $qdata->idnumber = null; $qdata->options = new stdClass(); $qdata->options->id = 456; @@ -741,7 +741,7 @@ END; $qdata->defaultmark = 1; $qdata->length = 1; $qdata->penalty = 0.3333333; - $qdata->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $qdata->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $qdata->idnumber = null; $qdata->options = new stdClass(); @@ -974,7 +974,7 @@ END; $qdata->defaultmark = 2; $qdata->length = 1; $qdata->penalty = 0.3333333; - $qdata->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $qdata->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $qdata->idnumber = null; $qdata->options = new stdClass(); @@ -1151,7 +1151,7 @@ END; $qdata->defaultmark = 1; $qdata->length = 1; $qdata->penalty = 0.1; - $qdata->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $qdata->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $qdata->idnumber = null; $qdata->options = new stdClass(); @@ -1283,7 +1283,7 @@ END; $qdata->defaultmark = 1; $qdata->length = 1; $qdata->penalty = 0.3333333; - $qdata->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $qdata->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $qdata->idnumber = null; $qdata->options = new stdClass(); @@ -1459,7 +1459,7 @@ END; $qdata->defaultmark = 1; $qdata->length = 1; $qdata->penalty = 1; - $qdata->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $qdata->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $qdata->idnumber = null; $qdata->options = new stdClass(); @@ -1519,7 +1519,7 @@ END; $qdata->defaultmark = 1; $qdata->length = 1; $qdata->penalty = 1; - $qdata->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $qdata->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $qdata->idnumber = 'TestIDNum2'; $qdata->options = new stdClass(); diff --git a/question/tests/generator/lib.php b/question/tests/generator/lib.php index 9d47d81fa5c..6ad85a22708 100644 --- a/question/tests/generator/lib.php +++ b/question/tests/generator/lib.php @@ -22,9 +22,7 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -use core_question\local\bank\constants; - -defined('MOODLE_INTERNAL') || die(); +use core_question\local\bank\question_version_status; /** * Class core_question_generator for generating question data. @@ -97,7 +95,7 @@ class core_question_generator extends component_generator_base { $question->qtype = $qtype; $question->createdby = 0; $question->idnumber = null; - $question->status = constants::QUESTION_STATUS_READY; + $question->status = question_version_status::QUESTION_STATUS_READY; return $this->update_question($question, $which, $overrides); } diff --git a/question/tests/random_question_loader_test.php b/question/tests/random_question_loader_test.php index b78c1506080..c0ca467f8c3 100644 --- a/question/tests/random_question_loader_test.php +++ b/question/tests/random_question_loader_test.php @@ -68,7 +68,8 @@ class random_question_loader_testcase extends advanced_testcase { $cat = $generator->create_question_category(); $question1 = $generator->create_question('shortanswer', null, ['category' => $cat->id]); - $DB->set_field('question_versions', 'status', 1, ['questionid' => $question1->id]); + $DB->set_field('question_versions', 'status', + \core_question\local\bank\question_version_status::QUESTION_STATUS_HIDDEN, ['questionid' => $question1->id]); $loader = new \core_question\local\bank\random_question_loader(new qubaid_list([])); $this->assertNull($loader->get_next_question_id($cat->id, 0)); diff --git a/question/tests/version_test.php b/question/tests/version_test.php index f3132e7960e..19108ba0d10 100644 --- a/question/tests/version_test.php +++ b/question/tests/version_test.php @@ -190,7 +190,8 @@ class version_test extends \advanced_testcase { global $DB; $qcategory = $this->qgenerator->create_question_category(['contextid' => $this->context->id]); - $qcategorychild = $this->qgenerator->create_question_category(['contextid' => $this->context->id, 'parent' => $qcategory->id]); + $qcategorychild = $this->qgenerator->create_question_category(['contextid' => $this->context->id, + 'parent' => $qcategory->id]); $systemcontext = \context_system::instance(); $qcategorysys = $this->qgenerator->create_question_category(['contextid' => $systemcontext->id]); $question = $this->qgenerator->create_question('shortanswer', null, ['category' => $qcategorychild->id]); diff --git a/question/type/calculated/tests/helper.php b/question/type/calculated/tests/helper.php index f49641a763f..d8dbe725690 100644 --- a/question/type/calculated/tests/helper.php +++ b/question/type/calculated/tests/helper.php @@ -97,7 +97,7 @@ class qtype_calculated_test_helper extends question_test_helper { $qdata->name = 'Simple sum'; $qdata->questiontext = 'What is {a} + {b}?'; $qdata->generalfeedback = 'Generalfeedback: {={a} + {b}} is the right answer.'; - $qdata->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $qdata->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $qdata->options = new stdClass(); $qdata->options->unitgradingtype = 0; @@ -187,7 +187,7 @@ class qtype_calculated_test_helper extends question_test_helper { $fromform->feedback[2]['format'] = FORMAT_HTML; $fromform->feedback[2]['text'] = 'Completely wrong.'; - $fromform->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $fromform->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; return $fromform; } diff --git a/question/type/calculated/tests/questiontype_test.php b/question/type/calculated/tests/questiontype_test.php index fcae5aa0adf..2d7952717b6 100644 --- a/question/type/calculated/tests/questiontype_test.php +++ b/question/type/calculated/tests/questiontype_test.php @@ -102,7 +102,7 @@ class qtype_calculated_test extends advanced_testcase { $this->assertEquals(0, $questiondata->penalty); $this->assertEquals('calculated', $questiondata->qtype); $this->assertEquals(1, $questiondata->length); - $this->assertEquals(0, $questiondata->status); + $this->assertEquals(\core_question\local\bank\question_version_status::QUESTION_STATUS_READY, $questiondata->status); $this->assertEquals($question->createdby, $questiondata->createdby); $this->assertEquals($question->createdby, $questiondata->modifiedby); $this->assertEquals('', $questiondata->idnumber); diff --git a/question/type/calculatedsimple/tests/helper.php b/question/type/calculatedsimple/tests/helper.php index 69874a3a470..c3bca397d58 100644 --- a/question/type/calculatedsimple/tests/helper.php +++ b/question/type/calculatedsimple/tests/helper.php @@ -205,7 +205,7 @@ class qtype_calculatedsimple_test_helper extends question_test_helper { $form->definition[19] = '1-0-b'; $form->definition[20] = '1-0-a'; - $form->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $form->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; return $form; } @@ -224,7 +224,7 @@ class qtype_calculatedsimple_test_helper extends question_test_helper { $q->penalty = 0.3333333; $q->qtype = 'calculatedsimple'; $q->length = '1'; - $q->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $q->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $q->version = 1; $q->options = new stdClass(); $q->options->synchronize = 0; diff --git a/question/type/ddimageortext/tests/helper.php b/question/type/ddimageortext/tests/helper.php index 948642d80fd..367d2c0d801 100644 --- a/question/type/ddimageortext/tests/helper.php +++ b/question/type/ddimageortext/tests/helper.php @@ -246,7 +246,7 @@ class qtype_ddimageortext_test_helper extends question_test_helper { $fromform->hintclearwrong = array(1, 0, 1, 0); $fromform->hintshownumcorrect = array(1, 1, 1, 1); - $fromform->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $fromform->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; return $fromform; } diff --git a/question/type/ddmarker/tests/helper.php b/question/type/ddmarker/tests/helper.php index 959b053aed5..8863f11a595 100644 --- a/question/type/ddmarker/tests/helper.php +++ b/question/type/ddmarker/tests/helper.php @@ -193,7 +193,7 @@ class qtype_ddmarker_test_helper extends question_test_helper { $fromform->hintclearwrong = array(0, 1); $fromform->hintoptions = array(0, 1); - $fromform->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $fromform->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; return $fromform; } @@ -260,7 +260,7 @@ class qtype_ddmarker_test_helper extends question_test_helper { $fromform->hintclearwrong = array(0, 1); $fromform->hintoptions = array(0, 1); - $fromform->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $fromform->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; return $fromform; } diff --git a/question/type/ddwtos/tests/helper.php b/question/type/ddwtos/tests/helper.php index 22fb849f2cf..d5e8b9f063a 100644 --- a/question/type/ddwtos/tests/helper.php +++ b/question/type/ddwtos/tests/helper.php @@ -97,7 +97,7 @@ class qtype_ddwtos_test_helper extends question_test_helper { test_question_maker::set_standard_combined_feedback_form_data($fromform); $fromform->shownumcorrect = 0; $fromform->penalty = 0.3333333; - $fromform->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $fromform->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; return $fromform; } @@ -125,7 +125,7 @@ class qtype_ddwtos_test_helper extends question_test_helper { test_question_maker::set_standard_combined_feedback_form_data($fromform); $fromform->shownumcorrect = 0; $fromform->penalty = 0.3333333; - $fromform->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $fromform->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; return $fromform; } @@ -151,7 +151,7 @@ class qtype_ddwtos_test_helper extends question_test_helper { test_question_maker::set_standard_combined_feedback_form_data($fromform); $fromform->shownumcorrect = 0; $fromform->penalty = 0.3333333; - $fromform->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $fromform->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; return $fromform; } @@ -210,7 +210,7 @@ class qtype_ddwtos_test_helper extends question_test_helper { test_question_maker::set_standard_combined_feedback_form_data($fromform); $fromform->shownumcorrect = 0; $fromform->penalty = 0.3333333; - $fromform->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $fromform->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; return $fromform; } diff --git a/question/type/ddwtos/tests/questiontype_test.php b/question/type/ddwtos/tests/questiontype_test.php index 89454c4c01a..bf779d1e7a3 100644 --- a/question/type/ddwtos/tests/questiontype_test.php +++ b/question/type/ddwtos/tests/questiontype_test.php @@ -73,7 +73,7 @@ class qtype_ddwtos_test extends question_testcase { $dd->penalty = 0.3333333; $dd->length = 1; $dd->stamp = make_unique_id_code(); - $dd->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $dd->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $dd->version = 1; $dd->versionid = 0; $dd->questionbankentryid = 0; @@ -285,7 +285,7 @@ class qtype_ddwtos_test extends question_testcase { $qdata->defaultmark = 3; $qdata->length = 1; $qdata->penalty = 0.3333333; - $qdata->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $qdata->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $qdata->options = new stdClass(); $qdata->options->shuffleanswers = 1; diff --git a/question/type/description/tests/helper.php b/question/type/description/tests/helper.php index 3f577f0876e..9fb8e108230 100644 --- a/question/type/description/tests/helper.php +++ b/question/type/description/tests/helper.php @@ -83,7 +83,7 @@ class qtype_description_test_helper extends question_test_helper { $qdata->defaultmark = 0; $qdata->length = 0; $qdata->penalty = 0; - $qdata->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $qdata->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $qdata->hints = array(); $qdata->options = new stdClass(); $qdata->options->answers = array(); @@ -104,7 +104,7 @@ class qtype_description_test_helper extends question_test_helper { 'format' => FORMAT_HTML); $form->generalfeedback = array('text' => 'And here is some more text shown only on the review page.', 'format' => FORMAT_HTML); - $form->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $form->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; return $form; } diff --git a/question/type/essay/tests/helper.php b/question/type/essay/tests/helper.php index 58509a4600a..26f283ed57b 100644 --- a/question/type/essay/tests/helper.php +++ b/question/type/essay/tests/helper.php @@ -95,7 +95,7 @@ class qtype_essay_test_helper extends question_test_helper { $fromform->filetypeslist = ''; // Although once saved in the DB, this becomes null, the form returns '' here. $fromform->graderinfo = array('text' => '', 'format' => FORMAT_HTML); $fromform->responsetemplate = array('text' => '', 'format' => FORMAT_HTML); - $fromform->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $fromform->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; return $fromform; } @@ -148,7 +148,7 @@ class qtype_essay_test_helper extends question_test_helper { $fromform->filetypeslist = ''; // Although once saved in the DB, this becomes null, the form returns '' here. $fromform->graderinfo = array('text' => '', 'format' => FORMAT_HTML); $fromform->responsetemplate = array('text' => '', 'format' => FORMAT_HTML); - $fromform->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $fromform->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; return $fromform; } @@ -186,7 +186,7 @@ class qtype_essay_test_helper extends question_test_helper { $fromform->filetypeslist = ''; // Although once saved in the DB, this becomes null, the form returns '' here. $fromform->graderinfo = array('text' => '', 'format' => FORMAT_HTML); $fromform->responsetemplate = array('text' => '', 'format' => FORMAT_HTML); - $fromform->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $fromform->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; return $fromform; } diff --git a/question/type/gapselect/tests/helper.php b/question/type/gapselect/tests/helper.php index 40b464b72d4..90de2c2332c 100644 --- a/question/type/gapselect/tests/helper.php +++ b/question/type/gapselect/tests/helper.php @@ -57,7 +57,7 @@ class qtype_gapselect_test_helper extends question_test_helper { $gapselect->penalty = 0.3333333; $gapselect->length = 1; $gapselect->stamp = make_unique_id_code(); - $gapselect->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $gapselect->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $gapselect->versionid = 0; $gapselect->version = 1; $gapselect->questionbankentryid = 0; @@ -110,7 +110,7 @@ class qtype_gapselect_test_helper extends question_test_helper { test_question_maker::set_standard_combined_feedback_form_data($fromform); $fromform->shownumcorrect = 0; $fromform->penalty = 0.3333333; - $fromform->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $fromform->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; return $fromform; } @@ -129,7 +129,7 @@ class qtype_gapselect_test_helper extends question_test_helper { $gapselect->questiontext = 'The [[1]] brown [[2]] jumped over the [[3]] dog.'; $gapselect->generalfeedback = 'This sentence uses each letter of the alphabet.'; $gapselect->qtype = question_bank::get_qtype('gapselect'); - $gapselect->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $gapselect->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $gapselect->shufflechoices = true; @@ -169,7 +169,7 @@ class qtype_gapselect_test_helper extends question_test_helper { '7 [[1]] 11 [[2]] 13 [[1]] 17 [[2]] 19 = 3'; $gapselect->generalfeedback = 'This sentence uses each letter of the alphabet.'; $gapselect->qtype = question_bank::get_qtype('gapselect'); - $gapselect->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $gapselect->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $gapselect->shufflechoices = true; @@ -205,7 +205,7 @@ class qtype_gapselect_test_helper extends question_test_helper { 'sat on theсидела на [[2]].'; $gapselect->generalfeedback = 'This sentence uses each letter of the alphabet.'; $gapselect->qtype = question_bank::get_qtype('gapselect'); - $gapselect->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $gapselect->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $gapselect->shufflechoices = true; @@ -246,7 +246,7 @@ class qtype_gapselect_test_helper extends question_test_helper { $gapselect->questiontext = 'The price of the ball is [[1]] approx.'; $gapselect->generalfeedback = 'The choice is yours'; $gapselect->qtype = question_bank::get_qtype('gapselect'); - $gapselect->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $gapselect->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $gapselect->shufflechoices = true; diff --git a/question/type/gapselect/tests/questiontype_test.php b/question/type/gapselect/tests/questiontype_test.php index 02ac623d7b8..b8046dde3f7 100644 --- a/question/type/gapselect/tests/questiontype_test.php +++ b/question/type/gapselect/tests/questiontype_test.php @@ -238,7 +238,7 @@ class qtype_gapselect_test extends question_testcase { $qdata->defaultmark = 3; $qdata->length = 1; $qdata->penalty = 0.3333333; - $qdata->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $qdata->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $qdata->options = new stdClass(); $qdata->options->shuffleanswers = 1; diff --git a/question/type/match/tests/helper.php b/question/type/match/tests/helper.php index ad50d54cfed..883a535911e 100644 --- a/question/type/match/tests/helper.php +++ b/question/type/match/tests/helper.php @@ -59,7 +59,7 @@ class qtype_match_test_helper extends question_test_helper { $q->defaultmark = 1; $q->penalty = 0.3333333; $q->length = 1; - $q->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $q->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $q->versionid = 0; $q->version = 1; $q->questionbankentryid = 0; @@ -108,7 +108,7 @@ class qtype_match_test_helper extends question_test_helper { $q->generalfeedback = array('text' => 'General feedback.', 'format' => FORMAT_HTML); $q->defaultmark = 1; $q->penalty = 0.3333333; - $q->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $q->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $q->versionid = 0; $q->version = 1; $q->questionbankentryid = 0; @@ -187,7 +187,7 @@ class qtype_match_test_helper extends question_test_helper { $q->defaultmark = 1; $q->penalty = 0.3333333; $q->length = 1; - $q->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $q->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $q->versionid = 0; $q->version = 1; $q->questionbankentryid = 0; @@ -231,7 +231,7 @@ class qtype_match_test_helper extends question_test_helper { $q->generalfeedback = ['text' => 'Java has some advantages over PHP I guess!', 'format' => FORMAT_HTML]; $q->defaultmark = 1; $q->penalty = 0.3333333; - $q->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $q->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $q->versionid = 0; $q->version = 1; $q->questionbankentryid = 0; diff --git a/question/type/match/tests/questiontype_test.php b/question/type/match/tests/questiontype_test.php index 4165b01cedb..d720131cfdf 100644 --- a/question/type/match/tests/questiontype_test.php +++ b/question/type/match/tests/questiontype_test.php @@ -66,7 +66,7 @@ class qtype_match_test extends advanced_testcase { $q->penalty = 0.3333333; $q->length = 1; $q->stamp = make_unique_id_code(); - $q->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $q->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $q->version = 1; $q->versionid = 0; $q->questionbankentryid = 0; diff --git a/question/type/missingtype/questiontype.php b/question/type/missingtype/questiontype.php index e82bd997ccd..de3d5cc4300 100644 --- a/question/type/missingtype/questiontype.php +++ b/question/type/missingtype/questiontype.php @@ -75,7 +75,7 @@ class qtype_missingtype extends question_type { $question->length = 1; $question->penalty = 0; $question->stamp = ''; - $question->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $question->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $question->timecreated = null; $question->timemodified = null; $question->createdby = null; diff --git a/question/type/missingtype/tests/missingtype_test.php b/question/type/missingtype/tests/missingtype_test.php index f3e2c6c33f6..448a6e3efff 100644 --- a/question/type/missingtype/tests/missingtype_test.php +++ b/question/type/missingtype/tests/missingtype_test.php @@ -56,7 +56,7 @@ class qtype_missing_test extends question_testcase { $questiondata->qtype = 'strange_unknown'; $questiondata->length = 1; $questiondata->stamp = make_unique_id_code(); - $questiondata->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $questiondata->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $questiondata->version = 1; $questiondata->versionid = 0; $questiondata->questionbankentryid = 0; diff --git a/question/type/multianswer/tests/helper.php b/question/type/multianswer/tests/helper.php index 403a744a238..9c6e2d2595d 100644 --- a/question/type/multianswer/tests/helper.php +++ b/question/type/multianswer/tests/helper.php @@ -131,7 +131,7 @@ class qtype_multianswer_test_helper extends question_test_helper { $qdata->defaultmark = 2.0; $qdata->qtype = 'multianswer'; - $qdata->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $qdata->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $sa = new stdClass(); test_question_maker::initialise_question_data($sa); @@ -141,7 +141,7 @@ class qtype_multianswer_test_helper extends question_test_helper { $sa->generalfeedback = ''; $sa->penalty = 0.0; $sa->qtype = 'shortanswer'; - $sa->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $sa->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $sa->options = new stdClass(); $sa->options->usecase = 0; @@ -161,7 +161,7 @@ class qtype_multianswer_test_helper extends question_test_helper { $mc->generalfeedback = ''; $mc->penalty = 0.0; $mc->qtype = 'multichoice'; - $mc->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $mc->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $mc->options = new stdClass(); $mc->options->layout = 0; @@ -217,7 +217,7 @@ class qtype_multianswer_test_helper extends question_test_helper { $qdata->defaultmark = 1.0; $qdata->qtype = 'multianswer'; - $qdata->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $qdata->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $mc = new stdClass(); test_question_maker::initialise_question_data($mc); @@ -227,7 +227,7 @@ class qtype_multianswer_test_helper extends question_test_helper { $mc->generalfeedback = ''; $mc->penalty = 0.0; $mc->qtype = 'multichoice'; - $mc->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $mc->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $mc->options = new stdClass(); $mc->options->layout = 0; @@ -279,7 +279,7 @@ class qtype_multianswer_test_helper extends question_test_helper { 1 => array('text' => 'Hint 2', 'format' => FORMAT_HTML, 'itemid' => 0), ); - $formdata->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $formdata->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; return $formdata; } diff --git a/question/type/multianswer/tests/questiontype_test.php b/question/type/multianswer/tests/questiontype_test.php index 8dc40c017f5..77c96878481 100644 --- a/question/type/multianswer/tests/questiontype_test.php +++ b/question/type/multianswer/tests/questiontype_test.php @@ -67,7 +67,7 @@ class qtype_multianswer_test extends advanced_testcase { $q->generalfeedbackformat = FORMAT_HTML; $q->defaultmark = 2; $q->penalty = 0.3333333; - $q->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $q->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $q->versionid = 0; $q->version = 1; $q->questionbankentryid = 0; @@ -155,7 +155,7 @@ class qtype_multianswer_test extends advanced_testcase { $this->assertEquals(0, $questiondata->penalty); $this->assertEquals('multianswer', $questiondata->qtype); $this->assertEquals(1, $questiondata->length); - $this->assertEquals(0, $questiondata->status); + $this->assertEquals(\core_question\local\bank\question_version_status::QUESTION_STATUS_READY, $questiondata->status); $this->assertEquals($question->createdby, $questiondata->createdby); $this->assertEquals($question->createdby, $questiondata->modifiedby); $this->assertEquals('', $questiondata->idnumber); diff --git a/question/type/multichoice/tests/helper.php b/question/type/multichoice/tests/helper.php index 6dd8e44520e..1cb6d92f8c3 100644 --- a/question/type/multichoice/tests/helper.php +++ b/question/type/multichoice/tests/helper.php @@ -57,7 +57,7 @@ class qtype_multichoice_test_helper extends question_test_helper { $qdata->defaultmark = 1; $qdata->length = 1; $qdata->penalty = 0.3333333; - $qdata->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $qdata->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $qdata->versionid = 0; $qdata->version = 1; $qdata->questionbankentryid = 0; @@ -146,7 +146,7 @@ class qtype_multichoice_test_helper extends question_test_helper { $qdata->noanswers = 5; $qdata->numhints = 2; $qdata->penalty = 0.3333333; - $qdata->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $qdata->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $qdata->versionid = 0; $qdata->version = 1; $qdata->questionbankentryid = 0; @@ -246,7 +246,7 @@ class qtype_multichoice_test_helper extends question_test_helper { $qdata->defaultmark = 1; $qdata->length = 1; $qdata->penalty = 0.3333333; - $qdata->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $qdata->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $qdata->versionid = 0; $qdata->version = 1; $qdata->questionbankentryid = 0; @@ -336,7 +336,7 @@ class qtype_multichoice_test_helper extends question_test_helper { $qdata->noanswers = 5; $qdata->numhints = 2; $qdata->penalty = 0.3333333; - $qdata->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $qdata->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $qdata->versionid = 0; $qdata->version = 1; $qdata->questionbankentryid = 0; diff --git a/question/type/numerical/tests/helper.php b/question/type/numerical/tests/helper.php index fdc19f860b8..4e26e8c7cb8 100644 --- a/question/type/numerical/tests/helper.php +++ b/question/type/numerical/tests/helper.php @@ -162,7 +162,7 @@ class qtype_numerical_test_helper extends question_test_helper { $form->hint[1]['text'] = ''; $form->qtype = 'numerical'; - $form->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $form->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; return $form; } @@ -194,7 +194,7 @@ class qtype_numerical_test_helper extends question_test_helper { $q->penalty = 0.3333333; $q->qtype = 'numerical'; $q->length = '1'; - $q->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $q->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $q->version = 1; $q->createdby = '2'; $q->modifiedby = '2'; diff --git a/question/type/questionbase.php b/question/type/questionbase.php index 45a46e75f1f..e7d63600571 100644 --- a/question/type/questionbase.php +++ b/question/type/questionbase.php @@ -119,7 +119,7 @@ abstract class question_definition { public $hints = array(); /** @var boolean question status hidden/ready/draft in the question bank. */ - public $status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + public $status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; /** @var int Version id of the question in a question bank */ public $versionid; diff --git a/question/type/questiontypebase.php b/question/type/questiontypebase.php index f9c9f36a88e..ce2d79e5d44 100644 --- a/question/type/questiontypebase.php +++ b/question/type/questiontypebase.php @@ -492,7 +492,8 @@ class question_type { // Get the version and status from the parent question if parent is set. if (!$question->parent) { // Get the status field. It comes from the form, but for testing we can. - $status = $form->status ?? $question->status ?? \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; + $status = $form->status ?? $question->status ?? + \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $questionversion->version = get_next_version($questionbankentry->id); $questionversion->status = $status; } else { @@ -1395,7 +1396,7 @@ class question_type { $form->questiontext = 'test question, generated by script'; $form->defaultmark = 1; $form->penalty = 0.3333333; - $form->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $form->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $form->generalfeedback = "Well done"; $context = context_course::instance($courseid); diff --git a/question/type/random/questiontype.php b/question/type/random/questiontype.php index 6f9b7ac186b..22614f6f32d 100644 --- a/question/type/random/questiontype.php +++ b/question/type/random/questiontype.php @@ -86,14 +86,14 @@ class qtype_random extends question_type { list($qcsql, $qcparams) = $DB->get_in_or_equal($categorylist); // TODO use in_or_equal for $otherquestionsinuse and $this->manualqtypes. - $readystatus = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $readystatus = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $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 WHERE qbe.questioncategoryid {$qcsql} AND q.parent = 0 - AND qv.status = $readystatus + AND qv.status = '$readystatus' AND q.id NOT IN ($otherquestionsinuse) AND q.qtype IN ($this->manualqtypes)"; diff --git a/question/type/random/tests/helper.php b/question/type/random/tests/helper.php index 06bd159cf10..3ab016ce1fe 100644 --- a/question/type/random/tests/helper.php +++ b/question/type/random/tests/helper.php @@ -47,7 +47,7 @@ class qtype_random_test_helper extends question_test_helper { $form = new stdClass(); $form->questiontext = array('text' => ''); $form->includesubcategories = '0'; - $form->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $form->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; return $form; } } diff --git a/question/type/random/tests/questiontype_test.php b/question/type/random/tests/questiontype_test.php index 2199860d51e..4138189e173 100644 --- a/question/type/random/tests/questiontype_test.php +++ b/question/type/random/tests/questiontype_test.php @@ -93,7 +93,7 @@ class qtype_random_test extends advanced_testcase { $this->assertEquals(0, $questiondata->questiontext); // Used to store 'Select from subcategories'. $this->assertEquals('random', $questiondata->qtype); $this->assertEquals(1, $questiondata->length); - $this->assertEquals(0, $questiondata->status); + $this->assertEquals(\core_question\local\bank\question_version_status::QUESTION_STATUS_READY, $questiondata->status); $this->assertEquals($category->contextid, $questiondata->contextid); // Options - not used. diff --git a/question/type/shortanswer/tests/helper.php b/question/type/shortanswer/tests/helper.php index f14b92538ee..666d6d85f65 100644 --- a/question/type/shortanswer/tests/helper.php +++ b/question/type/shortanswer/tests/helper.php @@ -75,7 +75,7 @@ class qtype_shortanswer_test_helper extends question_test_helper { $qdata->name = 'Short answer question'; $qdata->questiontext = 'Name an amphibian: __________'; $qdata->generalfeedback = 'Generalfeedback: frog or toad would have been OK.'; - $qdata->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $qdata->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $qdata->options = new stdClass(); $qdata->options->usecase = 0; @@ -109,7 +109,7 @@ class qtype_shortanswer_test_helper extends question_test_helper { array('text' => 'Toad is an OK good answer.', 'format' => FORMAT_HTML), array('text' => 'That is a bad answer.', 'format' => FORMAT_HTML), ); - $form->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $form->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; return $form; } @@ -148,7 +148,7 @@ class qtype_shortanswer_test_helper extends question_test_helper { $qdata->name = 'Short answer question'; $qdata->questiontext = 'Name the best amphibian: __________'; $qdata->generalfeedback = 'Generalfeedback: you should have said frog.'; - $qdata->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $qdata->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $qdata->options = new stdClass(); $qdata->options->usecase = false; diff --git a/question/type/truefalse/tests/helper.php b/question/type/truefalse/tests/helper.php index a2aa579ecdc..a03d37beafb 100644 --- a/question/type/truefalse/tests/helper.php +++ b/question/type/truefalse/tests/helper.php @@ -86,7 +86,7 @@ class qtype_truefalse_test_helper extends question_test_helper { $form->feedbackfalse['text'] = 'This is the wrong answer.'; $form->penalty = 1; - $form->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $form->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; return $form; } @@ -103,7 +103,7 @@ class qtype_truefalse_test_helper extends question_test_helper { $q->penalty = 1; $q->qtype = 'truefalse'; $q->length = '1'; - $q->status = \core_question\local\bank\constants::QUESTION_STATUS_READY; + $q->status = \core_question\local\bank\question_version_status::QUESTION_STATUS_READY; $q->createdby = '2'; $q->modifiedby = '2'; $q->options = new stdClass(); diff --git a/question/type/truefalse/tests/questiontype_test.php b/question/type/truefalse/tests/questiontype_test.php index 7e02dc7784e..f37dfdfde4d 100644 --- a/question/type/truefalse/tests/questiontype_test.php +++ b/question/type/truefalse/tests/questiontype_test.php @@ -96,7 +96,7 @@ class qtype_truefalse_test extends advanced_testcase { $this->assertEquals(1, $questiondata->penalty); $this->assertEquals('truefalse', $questiondata->qtype); $this->assertEquals(1, $questiondata->length); - $this->assertEquals(0, $questiondata->status); + $this->assertEquals(\core_question\local\bank\question_version_status::QUESTION_STATUS_READY, $questiondata->status); $this->assertEquals($question->createdby, $questiondata->createdby); $this->assertEquals($question->createdby, $questiondata->modifiedby); $this->assertEquals('', $questiondata->idnumber); From bf55e5a4815a822a19e64c7df09e3befc8eb87d4 Mon Sep 17 00:00:00 2001 From: Safat Shahin Date: Tue, 16 Nov 2021 23:18:28 +1100 Subject: [PATCH 8/8] MDL-71696 core_question: class autoload changes This commit implements some class autoloading fixes to align with core question structure. --- lib/db/renamedclasses.php | 4 ++-- lib/questionlib.php | 4 ++-- mod/quiz/classes/output/edit_renderer.php | 22 +++++++++---------- .../classes/question/bank/custom_view.php | 2 +- mod/quiz/classes/structure.php | 2 +- mod/quiz/editrandom.php | 2 +- mod/quiz/lib.php | 2 +- .../tests/quiz_question_bank_view_test.php | 2 +- question/bank/bulkmove/move.php | 4 +--- question/bank/bulkmove/tests/helper_test.php | 4 ++-- .../event/comment_created_deleted_test.php | 2 +- question/bank/deletequestion/delete.php | 2 +- question/bank/editquestion/question.php | 2 +- question/bank/exporttoxml/exportone.php | 2 +- question/bank/importquestions/import.php | 2 +- .../managecategories/tests/helper_test.php | 4 ++-- .../tests/question_category_object_test.php | 2 +- .../bank/previewquestion/classes/helper.php | 2 +- .../tests/qbank_preview_helper_test.php | 3 +-- .../classes/external/submit_tags.php | 2 +- question/bank/tagquestion/lib.php | 2 +- question/classes/external.php | 4 ++-- .../bank}/context_to_string_translator.php | 18 +++------------ .../bank}/question_edit_contexts.php | 15 ++----------- question/classes/local/bank/view.php | 4 ++-- question/editlib.php | 2 +- question/engine/tests/helpers.php | 2 +- question/format.php | 2 +- question/lib.php | 2 +- question/tests/bank_view_test.php | 4 ++-- question/tests/event/events_test.php | 9 ++------ question/tests/question_bank_column_test.php | 4 ++-- .../ddimageortext/tests/edit_form_test.php | 2 +- .../type/ddmarker/tests/edit_form_test.php | 2 +- question/type/ddwtos/tests/edit_form_test.php | 2 +- question/type/essay/tests/edit_form_test.php | 2 +- question/type/essay/tests/restore_test.php | 4 ++-- .../type/gapselect/tests/edit_form_test.php | 2 +- 38 files changed, 61 insertions(+), 92 deletions(-) rename question/classes/{lib => local/bank}/context_to_string_translator.php (86%) rename question/classes/{lib => local/bank}/question_edit_contexts.php (95%) diff --git a/lib/db/renamedclasses.php b/lib/db/renamedclasses.php index 3a8dee04335..39d6de58801 100644 --- a/lib/db/renamedclasses.php +++ b/lib/db/renamedclasses.php @@ -81,6 +81,6 @@ $renamedclasses = [ 'preview_options_form' => 'qbank_previewquestion\\form\\preview_options_form', 'question_preview_options' => 'qbank_previewquestion\\output\\question_preview_options', 'core_question\\form\\tags' => 'qbank_tagquestion\\form\\tags_form', - 'context_to_string_translator' => 'core_question\\lib\\context_to_string_translator', - 'question_edit_contexts' => 'core_question\\lib\\question_edit_contexts', + 'context_to_string_translator' => 'core_question\\local\\bank\\context_to_string_translator', + 'question_edit_contexts' => 'core_question\\local\\bank\\question_edit_contexts', ]; diff --git a/lib/questionlib.php b/lib/questionlib.php index c3e05a0f80d..8cb54e258b6 100644 --- a/lib/questionlib.php +++ b/lib/questionlib.php @@ -1609,7 +1609,7 @@ function question_extend_settings_navigation(navigation_node $navigationnode, $c ]; } - $contexts = new core_question\lib\question_edit_contexts($context); + $contexts = new core_question\local\bank\question_edit_contexts($context); foreach ($corenavigations as $key => $corenavigation) { if ($contexts->have_one_edit_tab_cap($key)) { $questionnode->add($corenavigation['title'], new moodle_url( @@ -1754,7 +1754,7 @@ function question_pluginfile($course, $context, $component, $filearea, $args, $f require_login($course, false, $cm); require_once($CFG->dirroot . '/question/editlib.php'); - $contexts = new core_question\lib\question_edit_contexts($context); + $contexts = new core_question\local\bank\question_edit_contexts($context); // Check export capability. $contexts->require_one_edit_tab_cap('export'); $categoryid = (int)array_shift($args); diff --git a/mod/quiz/classes/output/edit_renderer.php b/mod/quiz/classes/output/edit_renderer.php index 4e6b8a6b7e2..ad74386ab17 100644 --- a/mod/quiz/classes/output/edit_renderer.php +++ b/mod/quiz/classes/output/edit_renderer.php @@ -48,13 +48,13 @@ class edit_renderer extends \plugin_renderer_base { * * @param \quiz $quizobj object containing all the quiz settings information. * @param structure $structure object containing the structure of the quiz. - * @param \core_question\lib\question_edit_contexts $contexts the relevant question bank contexts. + * @param \core_question\local\bank\question_edit_contexts $contexts the relevant question bank contexts. * @param \moodle_url $pageurl the canonical URL of this page. * @param array $pagevars the variables from {@link question_edit_setup()}. * @return string HTML to output. */ public function edit_page(\quiz $quizobj, structure $structure, - \core_question\lib\question_edit_contexts $contexts, \moodle_url $pageurl, array $pagevars) { + \core_question\local\bank\question_edit_contexts $contexts, \moodle_url $pageurl, array $pagevars) { $output = ''; // Information at the top. @@ -495,7 +495,7 @@ class edit_renderer extends \plugin_renderer_base { * * @param structure $structure object containing the structure of the quiz. * @param \stdClass $section information about the section. - * @param \core_question\lib\question_edit_contexts $contexts the relevant question bank contexts. + * @param \core_question\local\bank\question_edit_contexts $contexts the relevant question bank contexts. * @param array $pagevars the variables from {@link \question_edit_setup()}. * @param \moodle_url $pageurl the canonical URL of this page. * @return string HTML to output. @@ -515,7 +515,7 @@ class edit_renderer extends \plugin_renderer_base { * * @param structure $structure object containing the structure of the quiz. * @param int $slot which slot we are outputting. - * @param \core_question\lib\question_edit_contexts $contexts the relevant question bank contexts. + * @param \core_question\local\bank\question_edit_contexts $contexts the relevant question bank contexts. * @param array $pagevars the variables from {@link \question_edit_setup()}. * @param \moodle_url $pageurl the canonical URL of this page. * @return string HTML to output. @@ -548,7 +548,7 @@ class edit_renderer extends \plugin_renderer_base { * * @param structure $structure object containing the structure of the quiz. * @param int $slot the first slot on the page we are outputting. - * @param \core_question\lib\question_edit_contexts $contexts the relevant question bank contexts. + * @param \core_question\local\bank\question_edit_contexts $contexts the relevant question bank contexts. * @param array $pagevars the variables from {@link \question_edit_setup()}. * @param \moodle_url $pageurl the canonical URL of this page. * @return string HTML to output. @@ -582,12 +582,12 @@ class edit_renderer extends \plugin_renderer_base { * @param structure $structure object containing the structure of the quiz. * @param int $page the page number that this menu will add to. * @param \moodle_url $pageurl the canonical URL of this page. - * @param \core_question\lib\question_edit_contexts $contexts the relevant question bank contexts. + * @param \core_question\local\bank\question_edit_contexts $contexts the relevant question bank contexts. * @param array $pagevars the variables from {@link \question_edit_setup()}. * @return string HTML to output. */ public function add_menu_actions(structure $structure, $page, \moodle_url $pageurl, - \core_question\lib\question_edit_contexts $contexts, array $pagevars) { + \core_question\local\bank\question_edit_contexts $contexts, array $pagevars) { $actions = $this->edit_menu_actions($structure, $page, $pageurl, $pagevars); if (empty($actions)) { @@ -1139,13 +1139,13 @@ class edit_renderer extends \plugin_renderer_base { * is handled with the specific code for those.) * * @param structure $structure object containing the structure of the quiz. - * @param \core_question\lib\question_edit_contexts $contexts the relevant question bank contexts. + * @param \core_question\local\bank\question_edit_contexts $contexts the relevant question bank contexts. * @param array $pagevars the variables from {@link \question_edit_setup()}. * @param \moodle_url $pageurl the canonical URL of this page. * @return bool Always returns true */ protected function initialise_editing_javascript(structure $structure, - \core_question\lib\question_edit_contexts $contexts, array $pagevars, \moodle_url $pageurl) { + \core_question\local\bank\question_edit_contexts $contexts, array $pagevars, \moodle_url $pageurl) { $config = new \stdClass(); $config->resourceurl = '/mod/quiz/edit_rest.php'; @@ -1245,13 +1245,13 @@ class edit_renderer extends \plugin_renderer_base { * HTML for a page, with ids stripped, so it can be used as a javascript template. * * @param structure $structure object containing the structure of the quiz. - * @param \core_question\lib\question_edit_contexts $contexts the relevant question bank contexts. + * @param \core_question\local\bank\question_edit_contexts $contexts the relevant question bank contexts. * @param array $pagevars the variables from {@link \question_edit_setup()}. * @param \moodle_url $pageurl the canonical URL of this page. * @return string HTML for a new page. */ protected function new_page_template(structure $structure, - \core_question\lib\question_edit_contexts $contexts, array $pagevars, \moodle_url $pageurl) { + \core_question\local\bank\question_edit_contexts $contexts, array $pagevars, \moodle_url $pageurl) { if (!$structure->has_questions()) { return ''; } diff --git a/mod/quiz/classes/question/bank/custom_view.php b/mod/quiz/classes/question/bank/custom_view.php index 2b723b81aa7..b5c3e115afe 100644 --- a/mod/quiz/classes/question/bank/custom_view.php +++ b/mod/quiz/classes/question/bank/custom_view.php @@ -48,7 +48,7 @@ class custom_view extends \core_question\local\bank\view { /** * Constructor. - * @param \core_question\lib\question_edit_contexts $contexts + * @param \core_question\local\bank\question_edit_contexts $contexts * @param \moodle_url $pageurl * @param \stdClass $course course settings * @param \stdClass $cm activity settings. diff --git a/mod/quiz/classes/structure.php b/mod/quiz/classes/structure.php index 598fcf3aa04..0ba5a215c35 100644 --- a/mod/quiz/classes/structure.php +++ b/mod/quiz/classes/structure.php @@ -1250,7 +1250,7 @@ class structure { public function can_add_random_questions() { if ($this->canaddrandom === null) { $quizcontext = $this->quizobj->get_context(); - $relatedcontexts = new \core_question\lib\question_edit_contexts($quizcontext); + $relatedcontexts = new \core_question\local\bank\question_edit_contexts($quizcontext); $usablecontexts = $relatedcontexts->having_cap('moodle/question:useall'); $this->canaddrandom = !empty($usablecontexts); diff --git a/mod/quiz/editrandom.php b/mod/quiz/editrandom.php index 69307584f83..8f5b459ca0f 100644 --- a/mod/quiz/editrandom.php +++ b/mod/quiz/editrandom.php @@ -67,7 +67,7 @@ $catcontext = context::instance_by_id($category->contextid); require_capability('moodle/question:useall', $catcontext); $thiscontext = context_module::instance($cm->id); -$contexts = new core_question\lib\question_edit_contexts($thiscontext); +$contexts = new core_question\local\bank\question_edit_contexts($thiscontext); // Create the editing form. $mform = new mod_quiz\form\randomquestion_form(new moodle_url('/mod/quiz/editrandom.php'), ['contexts' => $contexts]); diff --git a/mod/quiz/lib.php b/mod/quiz/lib.php index 635a9e39b21..e6ca211e52a 100644 --- a/mod/quiz/lib.php +++ b/mod/quiz/lib.php @@ -2413,7 +2413,7 @@ function mod_quiz_output_fragment_add_random_question_form($args) { global $CFG; require_once($CFG->dirroot . '/mod/quiz/addrandomform.php'); - $contexts = new \core_question\lib\question_edit_contexts($args['context']); + $contexts = new \core_question\local\bank\question_edit_contexts($args['context']); $formoptions = [ 'contexts' => $contexts, 'cat' => $args['cat'] diff --git a/mod/quiz/tests/quiz_question_bank_view_test.php b/mod/quiz/tests/quiz_question_bank_view_test.php index faad9b0f322..9d8dc195c64 100644 --- a/mod/quiz/tests/quiz_question_bank_view_test.php +++ b/mod/quiz/tests/quiz_question_bank_view_test.php @@ -51,7 +51,7 @@ class quiz_question_bank_view_testcase extends advanced_testcase { $cm = get_coursemodule_from_instance('quiz', $quiz->id); // Create a question in the default category. - $contexts = new core_question\lib\question_edit_contexts($context); + $contexts = new core_question\local\bank\question_edit_contexts($context); $cat = question_make_default_categories($contexts->all()); $questiondata = $questiongenerator->create_question('numerical', null, ['name' => 'Example question', 'category' => $cat->id]); diff --git a/question/bank/bulkmove/move.php b/question/bank/bulkmove/move.php index 1755dd948c5..0c6b966ce31 100644 --- a/question/bank/bulkmove/move.php +++ b/question/bank/bulkmove/move.php @@ -26,8 +26,6 @@ require_once(__DIR__ . '/../../../config.php'); require_once(__DIR__ . '/../../editlib.php'); -use \core_question\lib\question_edit_contexts; - global $DB, $OUTPUT, $PAGE, $COURSE; $moveselected = optional_param('move', false, PARAM_BOOL); @@ -55,7 +53,7 @@ if ($cmid) { throw new moodle_exception('missingcourseorcmid', 'question'); } -$contexts = new \core_question\lib\question_edit_contexts($thiscontext); +$contexts = new core_question\local\bank\question_edit_contexts($thiscontext); $url = new moodle_url('/question/bank/bulkmove/move.php'); $PAGE->set_url($url); diff --git a/question/bank/bulkmove/tests/helper_test.php b/question/bank/bulkmove/tests/helper_test.php index bf3891160e1..ce84bfd0ffb 100644 --- a/question/bank/bulkmove/tests/helper_test.php +++ b/question/bank/bulkmove/tests/helper_test.php @@ -16,7 +16,7 @@ namespace qbank_bulkmove; -use core_question\lib\question_edit_contexts; +use core_question\local\bank\question_edit_contexts; defined('MOODLE_INTERNAL') || die(); @@ -55,7 +55,7 @@ class helper_test extends \advanced_testcase { protected $context; /** - * @var \question_edit_contexts $contexts + * @var \core_question\local\bank\question_edit_contexts $contexts */ protected $contexts; diff --git a/question/bank/comment/tests/event/comment_created_deleted_test.php b/question/bank/comment/tests/event/comment_created_deleted_test.php index 3f08d1b3072..16007badad3 100644 --- a/question/bank/comment/tests/event/comment_created_deleted_test.php +++ b/question/bank/comment/tests/event/comment_created_deleted_test.php @@ -65,7 +65,7 @@ class comment_created_deleted_test extends advanced_testcase { $this->context = context_course::instance($this->course->id); // Create a question in the default category. - $contexts = new \core_question\lib\question_edit_contexts($this->context); + $contexts = new \core_question\local\bank\question_edit_contexts($this->context); $cat = question_make_default_categories($contexts->all()); $this->questiondata = $questiongenerator->create_question('numerical', null, ['name' => 'Example question', 'category' => $cat->id]); diff --git a/question/bank/deletequestion/delete.php b/question/bank/deletequestion/delete.php index 6b6398944cc..a94334da0cb 100644 --- a/question/bank/deletequestion/delete.php +++ b/question/bank/deletequestion/delete.php @@ -51,7 +51,7 @@ if ($cmid) { throw new moodle_exception('missingcourseorcmid', 'question'); } -$contexts = new core_question\lib\question_edit_contexts($thiscontext); +$contexts = new core_question\local\bank\question_edit_contexts($thiscontext); $url = new moodle_url('/question/bank/deletequestion/delete.php'); $PAGE->set_url($url); diff --git a/question/bank/editquestion/question.php b/question/bank/editquestion/question.php index e6f08fad2c6..20de37935b2 100644 --- a/question/bank/editquestion/question.php +++ b/question/bank/editquestion/question.php @@ -108,7 +108,7 @@ if ($cmid) { } else { throw new moodle_exception('missingcourseorcmid', 'question'); } -$contexts = new core_question\lib\question_edit_contexts($thiscontext); +$contexts = new core_question\local\bank\question_edit_contexts($thiscontext); $PAGE->set_pagelayout('admin'); if (optional_param('addcancel', false, PARAM_BOOL)) { diff --git a/question/bank/exporttoxml/exportone.php b/question/bank/exporttoxml/exportone.php index c91282bd0d9..6fc3cab8910 100644 --- a/question/bank/exporttoxml/exportone.php +++ b/question/bank/exporttoxml/exportone.php @@ -50,7 +50,7 @@ if ($cmid) { require_sesskey(); // Load the necessary data. -$contexts = new core_question\lib\question_edit_contexts($thiscontext); +$contexts = new core_question\local\bank\question_edit_contexts($thiscontext); $questiondata = question_bank::load_question_data($questionid); // Check permissions. diff --git a/question/bank/importquestions/import.php b/question/bank/importquestions/import.php index a1957ab7740..ccf1a674be8 100644 --- a/question/bank/importquestions/import.php +++ b/question/bank/importquestions/import.php @@ -51,7 +51,7 @@ $category->context = $categorycontext; // This page can be called without courseid or cmid in which case. // We get the context from the category object. if ($contexts === null) { // Need to get the course from the chosen category. - $contexts = new question_edit_contexts($categorycontext); + $contexts = new core_question\local\bank\question_edit_contexts($categorycontext); $thiscontext = $contexts->lowest(); if ($thiscontext->contextlevel == CONTEXT_COURSE) { require_login($thiscontext->instanceid, false); diff --git a/question/bank/managecategories/tests/helper_test.php b/question/bank/managecategories/tests/helper_test.php index e7a7f640150..d414e68a2e8 100644 --- a/question/bank/managecategories/tests/helper_test.php +++ b/question/bank/managecategories/tests/helper_test.php @@ -205,7 +205,7 @@ class helper_test extends \advanced_testcase { public function test_question_category_select_menu() { $this->qgenerator->create_question_category(['contextid' => $this->context->id, 'name' => 'Test this question category']); - $contexts = new \core_question\lib\question_edit_contexts($this->context); + $contexts = new \core_question\local\bank\question_edit_contexts($this->context); ob_start(); helper::question_category_select_menu($contexts->having_cap('moodle/question:add')); @@ -231,7 +231,7 @@ class helper_test extends \advanced_testcase { $qcategory2 = $this->qgenerator->create_question_category(['contextid' => $this->context->id, 'parent' => $qcategory1->id]); $qcategory3 = $this->qgenerator->create_question_category(['contextid' => $this->context->id]); - $contexts = new \core_question\lib\question_edit_contexts($this->context); + $contexts = new \core_question\local\bank\question_edit_contexts($this->context); // Validate that we have the array with the categories tree. $categorycontexts = helper::question_category_options($contexts->having_cap('moodle/question:add')); diff --git a/question/bank/managecategories/tests/question_category_object_test.php b/question/bank/managecategories/tests/question_category_object_test.php index 0fd03de2a7e..ed889290982 100644 --- a/question/bank/managecategories/tests/question_category_object_test.php +++ b/question/bank/managecategories/tests/question_category_object_test.php @@ -25,7 +25,7 @@ use context; use context_course; use context_module; use moodle_url; -use core_question\lib\question_edit_contexts; +use core_question\local\bank\question_edit_contexts; use stdClass; /** diff --git a/question/bank/previewquestion/classes/helper.php b/question/bank/previewquestion/classes/helper.php index 5a4c856a7c5..f135a53ed68 100644 --- a/question/bank/previewquestion/classes/helper.php +++ b/question/bank/previewquestion/classes/helper.php @@ -27,7 +27,7 @@ use context; use core\plugininfo\qbank; use core_question\local\bank\edit_menu_column; use core_question\local\bank\view; -use core_question\lib\question_edit_contexts; +use core_question\local\bank\question_edit_contexts; use moodle_url; use question_bank; use question_definition; diff --git a/question/bank/previewquestion/tests/qbank_preview_helper_test.php b/question/bank/previewquestion/tests/qbank_preview_helper_test.php index ccd8b15f721..cdb49edde21 100644 --- a/question/bank/previewquestion/tests/qbank_preview_helper_test.php +++ b/question/bank/previewquestion/tests/qbank_preview_helper_test.php @@ -19,7 +19,6 @@ namespace qbank_previewquestion; use context_course; use moodle_url; use core\plugininfo\qbank; -use \core_question\lib\question_edit_contexts; use question_bank; use question_engine; use stdClass; @@ -75,7 +74,7 @@ class helper_test extends \advanced_testcase { $course = $generator->create_course(); $this->context = context_course::instance($course->id); // Create a question in the default category. - $contexts = new \core_question\lib\question_edit_contexts($this->context); + $contexts = new \core_question\local\bank\question_edit_contexts($this->context); $cat = question_make_default_categories($contexts->all()); $this->questiondata = $questiongenerator->create_question('numerical', null, ['name' => 'Example question', 'category' => $cat->id]); diff --git a/question/bank/tagquestion/classes/external/submit_tags.php b/question/bank/tagquestion/classes/external/submit_tags.php index 54fe0feb3fd..db49f9350b6 100644 --- a/question/bank/tagquestion/classes/external/submit_tags.php +++ b/question/bank/tagquestion/classes/external/submit_tags.php @@ -89,7 +89,7 @@ class submit_tags extends external_api { $cantag = question_has_capability_on($question, 'tag'); $questioncontext = \context::instance_by_id($question->contextid); - $contexts = new \core_question\lib\question_edit_contexts($editingcontext); + $contexts = new \core_question\local\bank\question_edit_contexts($editingcontext); $formoptions = [ 'editingcontext' => $editingcontext, diff --git a/question/bank/tagquestion/lib.php b/question/bank/tagquestion/lib.php index 66d507d8633..c8390147543 100644 --- a/question/bank/tagquestion/lib.php +++ b/question/bank/tagquestion/lib.php @@ -61,7 +61,7 @@ function qbank_tagquestion_output_fragment_tags_form($args) { WHERE q.id = :id"; $category = $DB->get_record_sql($sql, ['id' => $question->id]); $questioncontext = \context::instance_by_id($category->contextid); - $contexts = new \core_question\lib\question_edit_contexts($editingcontext); + $contexts = new \core_question\local\bank\question_edit_contexts($editingcontext); // Load the question tags and filter the course tags by the current course. if (core_tag_tag::is_enabled('core_question', 'question')) { diff --git a/question/classes/external.php b/question/classes/external.php index 751ffdd45d9..6df4d88e516 100644 --- a/question/classes/external.php +++ b/question/classes/external.php @@ -173,7 +173,7 @@ class core_question_external extends external_api { $cantag = question_has_capability_on($question, 'tag'); $questioncontext = \context::instance_by_id($question->contextid); - $contexts = new \core_question\lib\question_edit_contexts($editingcontext); + $contexts = new \core_question\local\bank\question_edit_contexts($editingcontext); $formoptions = [ 'editingcontext' => $editingcontext, @@ -300,7 +300,7 @@ class core_question_external extends external_api { $categorycontextid = $DB->get_field('question_categories', 'contextid', ['id' => $categoryid], MUST_EXIST); $categorycontext = \context::instance_by_id($categorycontextid); - $editcontexts = new \core_question\lib\question_edit_contexts($categorycontext); + $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'); diff --git a/question/classes/lib/context_to_string_translator.php b/question/classes/local/bank/context_to_string_translator.php similarity index 86% rename from question/classes/lib/context_to_string_translator.php rename to question/classes/local/bank/context_to_string_translator.php index 109372a3ce2..278db11481b 100644 --- a/question/classes/lib/context_to_string_translator.php +++ b/question/classes/local/bank/context_to_string_translator.php @@ -14,6 +14,8 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . +namespace core_question\local\bank; + /** * Converts contextlevels to strings and back to help with reading/writing contexts to/from import/export files. * @@ -22,19 +24,6 @@ * @author 2021 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ - -namespace core_question\lib; - -defined('MOODLE_INTERNAL') || die(); - -/** - * Class context_to_string_translator. - * - * @package core_question - * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com} - * @author 2021 Safat Shahin - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ class context_to_string_translator { /** @@ -68,8 +57,7 @@ class context_to_string_translator { * @return false|int|string */ public function string_to_context($contextname) { - $contextid = array_search($contextname, $this->contexttostringarray); - return $contextid; + return array_search($contextname, $this->contexttostringarray); } /** diff --git a/question/classes/lib/question_edit_contexts.php b/question/classes/local/bank/question_edit_contexts.php similarity index 95% rename from question/classes/lib/question_edit_contexts.php rename to question/classes/local/bank/question_edit_contexts.php index a7b115215dc..c3776223c34 100644 --- a/question/classes/lib/question_edit_contexts.php +++ b/question/classes/local/bank/question_edit_contexts.php @@ -14,6 +14,8 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . +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. * @@ -22,19 +24,6 @@ * @author 2021 Safat Shahin * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ - -namespace core_question\lib; - -defined('MOODLE_INTERNAL') || die(); - -/** - * Class question_edit_contexts. - * - * @package core_question - * @copyright 2007 Jamie Pratt me@jamiep.org - * @author 2021 Safat Shahin - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ class question_edit_contexts { /** diff --git a/question/classes/local/bank/view.php b/question/classes/local/bank/view.php index 5617a755a7c..19e1de88e3d 100644 --- a/question/classes/local/bank/view.php +++ b/question/classes/local/bank/view.php @@ -70,7 +70,7 @@ class view { protected $editquestionurl; /** - * @var \core_question\lib\question_edit_contexts + * @var \core_question\local\bank\question_edit_contexts */ protected $contexts; @@ -157,7 +157,7 @@ class view { /** * Constructor for view. * - * @param \core_question\lib\question_edit_contexts $contexts + * @param \core_question\local\bank\question_edit_contexts $contexts * @param \moodle_url $pageurl * @param object $course course settings * @param object $cm (optional) activity settings. diff --git a/question/editlib.php b/question/editlib.php index 95f65f2754a..554be2ceae4 100644 --- a/question/editlib.php +++ b/question/editlib.php @@ -327,7 +327,7 @@ function question_build_edit_resources($edittab, $baseurl, $params) { } if ($thiscontext){ - $contexts = new core_question\lib\question_edit_contexts($thiscontext); + $contexts = new core_question\local\bank\question_edit_contexts($thiscontext); $contexts->require_one_edit_tab_cap($edittab); } else { $contexts = null; diff --git a/question/engine/tests/helpers.php b/question/engine/tests/helpers.php index b94a88aa487..59a8b50f846 100644 --- a/question/engine/tests/helpers.php +++ b/question/engine/tests/helpers.php @@ -117,7 +117,7 @@ abstract class question_test_helper { */ public static function get_question_editing_form($cat, $questiondata) { $catcontext = context::instance_by_id($cat->contextid, MUST_EXIST); - $contexts = new core_question\lib\question_edit_contexts($catcontext); + $contexts = new core_question\local\bank\question_edit_contexts($catcontext); $dataforformconstructor = new stdClass(); $dataforformconstructor->createdby = $questiondata->createdby; $dataforformconstructor->qtype = $questiondata->qtype; diff --git a/question/format.php b/question/format.php index 987e70bf4bb..f3819694ecb 100644 --- a/question/format.php +++ b/question/format.php @@ -136,7 +136,7 @@ class qformat_default { */ public function setContexts($contexts) { $this->contexts = $contexts; - $this->translator = new core_question\lib\context_to_string_translator($this->contexts); + $this->translator = new core_question\local\bank\context_to_string_translator($this->contexts); } /** diff --git a/question/lib.php b/question/lib.php index 2a857afea79..7104a08cad6 100644 --- a/question/lib.php +++ b/question/lib.php @@ -65,7 +65,7 @@ function core_question_output_fragment_tags_form($args) { WHERE q.id = :id"; $category = $DB->get_record_sql($sql, ['id' => $question->id]); $questioncontext = \context::instance_by_id($category->contextid); - $contexts = new \core_question\lib\question_edit_contexts($editingcontext); + $contexts = new \core_question\local\bank\question_edit_contexts($editingcontext); // Load the question tags and filter the course tags by the current course. if (core_tag_tag::is_enabled('core_question', 'question')) { diff --git a/question/tests/bank_view_test.php b/question/tests/bank_view_test.php index 7a6c5c092a7..d1dfc0556a9 100644 --- a/question/tests/bank_view_test.php +++ b/question/tests/bank_view_test.php @@ -49,7 +49,7 @@ class core_question_bank_view_testcase extends advanced_testcase { $context = context_course::instance($course->id); // Create a question in the default category. - $contexts = new core_question\lib\question_edit_contexts($context); + $contexts = new core_question\local\bank\question_edit_contexts($context); $cat = question_make_default_categories($contexts->all()); $questiondata = $questiongenerator->create_question('numerical', null, ['name' => 'Example question', 'category' => $cat->id]); @@ -93,7 +93,7 @@ class core_question_bank_view_testcase extends advanced_testcase { $context = context_course::instance($course->id); // Create a question in the default category. - $contexts = new core_question\lib\question_edit_contexts($context); + $contexts = new core_question\local\bank\question_edit_contexts($context); $cat = question_make_default_categories($contexts->all()); $questiondata = $questiongenerator->create_question('numerical', null, ['name' => 'Example question', 'category' => $cat->id]); diff --git a/question/tests/event/events_test.php b/question/tests/event/events_test.php index d6c0528d27c..9e99976791c 100644 --- a/question/tests/event/events_test.php +++ b/question/tests/event/events_test.php @@ -28,7 +28,6 @@ use qbank_managecategories\question_category_object; use qtype_description; use qtype_description_edit_form; use qtype_description_test_helper; -use question_edit_contexts; use test_question_maker; defined('MOODLE_INTERNAL') || die(); @@ -57,7 +56,7 @@ class events_test extends \advanced_testcase { $course = $this->getDataGenerator()->create_course(); $quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]); - $contexts = new \core_question\lib\question_edit_contexts(context_module::instance($quiz->cmid)); + $contexts = new \core_question\local\bank\question_edit_contexts(\context_module::instance($quiz->cmid)); $defaultcategoryobj = question_make_default_categories([$contexts->lowest()]); $defaultcategory = $defaultcategoryobj->id . ',' . $defaultcategoryobj->contextid; @@ -108,11 +107,7 @@ class events_test extends \advanced_testcase { $course = $this->getDataGenerator()->create_course(); $quiz = $this->getDataGenerator()->create_module('quiz', ['course' => $course->id]); -<<<<<<< HEAD:question/tests/event/events_test.php - $contexts = new question_edit_contexts(\context_module::instance($quiz->cmid)); -======= - $contexts = new core_question\lib\question_edit_contexts(context_module::instance($quiz->cmid)); ->>>>>>> MDL-71696 core_question: Changes for versioning:question/tests/events_test.php + $contexts = new \core_question\local\bank\question_edit_contexts(\context_module::instance($quiz->cmid)); $defaultcategoryobj = question_make_default_categories([$contexts->lowest()]); $defaultcategory = $defaultcategoryobj->id . ',' . $defaultcategoryobj->contextid; diff --git a/question/tests/question_bank_column_test.php b/question/tests/question_bank_column_test.php index e1a7c980d0b..51b0b9ea1eb 100644 --- a/question/tests/question_bank_column_test.php +++ b/question/tests/question_bank_column_test.php @@ -45,7 +45,7 @@ class question_bank_column_testcase extends advanced_testcase { $this->resetAfterTest(); $course = $this->getDataGenerator()->create_course(); $questionbank = new core_question\local\bank\view( - new core_question\lib\question_edit_contexts(context_course::instance($course->id)), + new core_question\local\bank\question_edit_contexts(context_course::instance($course->id)), new moodle_url('/'), $course ); @@ -79,7 +79,7 @@ class question_bank_column_testcase extends advanced_testcase { $this->resetAfterTest(); $course = $this->getDataGenerator()->create_course(); $questionbank = new core_question\local\bank\view( - new core_question\lib\question_edit_contexts(context_course::instance($course->id)), + new core_question\local\bank\question_edit_contexts(context_course::instance($course->id)), new moodle_url('/'), $course ); diff --git a/question/type/ddimageortext/tests/edit_form_test.php b/question/type/ddimageortext/tests/edit_form_test.php index df9f1c05113..6efb01c3417 100644 --- a/question/type/ddimageortext/tests/edit_form_test.php +++ b/question/type/ddimageortext/tests/edit_form_test.php @@ -63,7 +63,7 @@ class qtype_ddimageortext_edit_form_test extends advanced_testcase { $fakequestion->inputs = null; $form = new qtype_ddimageortext_edit_form(new moodle_url('/'), $fakequestion, $category, - new core_question\lib\question_edit_contexts($syscontext)); + new core_question\local\bank\question_edit_contexts($syscontext)); return [$form, $category]; } diff --git a/question/type/ddmarker/tests/edit_form_test.php b/question/type/ddmarker/tests/edit_form_test.php index 3c6bb37146a..c18fbb1c9fa 100644 --- a/question/type/ddmarker/tests/edit_form_test.php +++ b/question/type/ddmarker/tests/edit_form_test.php @@ -63,7 +63,7 @@ class qtype_ddmarker_edit_form_test extends advanced_testcase { $fakequestion->inputs = null; $form = new qtype_ddmarker_edit_form(new moodle_url('/'), $fakequestion, $category, - new core_question\lib\question_edit_contexts($syscontext)); + new core_question\local\bank\question_edit_contexts($syscontext)); return [$form, $category]; } diff --git a/question/type/ddwtos/tests/edit_form_test.php b/question/type/ddwtos/tests/edit_form_test.php index f7b0c2a7668..715c4cbfae9 100644 --- a/question/type/ddwtos/tests/edit_form_test.php +++ b/question/type/ddwtos/tests/edit_form_test.php @@ -65,7 +65,7 @@ class qtype_ddwtos_edit_form_test extends advanced_testcase { $fakequestion->inputs = null; $form = new $classname(new moodle_url('/'), $fakequestion, $category, - new core_question\lib\question_edit_contexts($syscontext)); + new core_question\local\bank\question_edit_contexts($syscontext)); return [$form, $category]; } diff --git a/question/type/essay/tests/edit_form_test.php b/question/type/essay/tests/edit_form_test.php index 84472965a7b..53878bad684 100644 --- a/question/type/essay/tests/edit_form_test.php +++ b/question/type/essay/tests/edit_form_test.php @@ -69,7 +69,7 @@ class qtype_essay_edit_form_test extends advanced_testcase { new moodle_url('/'), $fakequestion, $category, - new question_edit_contexts($syscontext) + new core_question\local\bank\question_edit_contexts($syscontext) ); return [$form, $category]; diff --git a/question/type/essay/tests/restore_test.php b/question/type/essay/tests/restore_test.php index f95a9ecf4e4..ab27f7c994a 100644 --- a/question/type/essay/tests/restore_test.php +++ b/question/type/essay/tests/restore_test.php @@ -48,7 +48,7 @@ class qtype_essay_restore_testcase extends restore_date_testcase { // Create a course with one essay question in its question bank. $generator = $this->getDataGenerator(); $course = $generator->create_course(); - $contexts = new core_question\lib\question_edit_contexts(context_course::instance($course->id)); + $contexts = new core_question\local\bank\question_edit_contexts(context_course::instance($course->id)); $category = question_make_default_categories($contexts->all()); $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question'); $essay = $questiongenerator->create_question('essay', null, array('category' => $category->id)); @@ -60,7 +60,7 @@ class qtype_essay_restore_testcase extends restore_date_testcase { $newcourseid = $this->backup_and_restore($course); // Verify that the restored question has options. - $contexts = new core_question\lib\question_edit_contexts(context_course::instance($newcourseid)); + $contexts = new core_question\local\bank\question_edit_contexts(context_course::instance($newcourseid)); $newcategory = question_make_default_categories($contexts->all()); $newessay = $DB->get_record_sql('SELECT q.* FROM {question} q diff --git a/question/type/gapselect/tests/edit_form_test.php b/question/type/gapselect/tests/edit_form_test.php index 1b3394dbc96..971679c2f79 100644 --- a/question/type/gapselect/tests/edit_form_test.php +++ b/question/type/gapselect/tests/edit_form_test.php @@ -94,7 +94,7 @@ class qtype_gapselect_edit_form_test extends advanced_testcase { $fakequestion->inputs = null; $form = new $classname(new moodle_url('/'), $fakequestion, $category, - new core_question\lib\question_edit_contexts($syscontext)); + new core_question\local\bank\question_edit_contexts($syscontext)); return [$form, $category]; }