From 6189fda47ffefba8255dfff16c68d1a8528f3532 Mon Sep 17 00:00:00 2001
From: John Beedell
Date: Wed, 4 Jul 2018 12:21:44 +0100
Subject: [PATCH] MDL-62708 question: Add idnumbers to question and question
category
---
backup/moodle2/backup_stepslib.php | 4 +-
backup/moodle2/restore_stepslib.php | 13 ++
lang/en/question.php | 2 +
lib/db/install.xml | 6 +-
lib/db/upgrade.php | 39 ++++++
lib/questionlib.php | 23 +++-
question/category.php | 4 +-
question/category_class.php | 28 +++-
question/category_form.php | 33 +++++
question/engine/tests/helpers.php | 2 +
question/format.php | 7 +
question/format/xml/format.php | 3 +
question/format/xml/tests/xmlformat_test.php | 130 ++++++++++++++++++
question/tests/backup_test.php | 13 +-
.../question_categories_idnumber.feature | 122 ++++++++++++++++
question/tests/generator/lib.php | 2 +
question/tests/generator_test.php | 29 ++++
.../type/ddwtos/tests/questiontype_test.php | 4 +
question/type/edit_question_form.php | 30 ++++
.../gapselect/tests/questiontype_test.php | 3 +
.../type/match/tests/questiontype_test.php | 1 +
.../missingtype/tests/missingtype_test.php | 1 +
question/type/questiontypebase.php | 14 ++
version.php | 2 +-
24 files changed, 502 insertions(+), 13 deletions(-)
create mode 100644 question/tests/behat/question_categories_idnumber.feature
diff --git a/backup/moodle2/backup_stepslib.php b/backup/moodle2/backup_stepslib.php
index 3793d9b7ce7..b90490ac420 100644
--- a/backup/moodle2/backup_stepslib.php
+++ b/backup/moodle2/backup_stepslib.php
@@ -2211,7 +2211,7 @@ class backup_questions_structure_step extends backup_structure_step {
$qcategory = new backup_nested_element('question_category', array('id'), array(
'name', 'contextid', 'contextlevel', 'contextinstanceid',
'info', 'infoformat', 'stamp', 'parent',
- 'sortorder'));
+ 'sortorder', 'idnumber'));
$questions = new backup_nested_element('questions');
@@ -2219,7 +2219,7 @@ class backup_questions_structure_step extends backup_structure_step {
'parent', 'name', 'questiontext', 'questiontextformat',
'generalfeedback', 'generalfeedbackformat', 'defaultmark', 'penalty',
'qtype', 'length', 'stamp', 'version',
- 'hidden', 'timecreated', 'timemodified', 'createdby', 'modifiedby'));
+ 'hidden', 'timecreated', 'timemodified', 'createdby', 'modifiedby', 'idnumber'));
// attach qtype plugin structure to $question element, only one allowed
$this->add_plugin_structure('qtype', $question, false);
diff --git a/backup/moodle2/restore_stepslib.php b/backup/moodle2/restore_stepslib.php
index 844317424c7..250aeb23fb4 100644
--- a/backup/moodle2/restore_stepslib.php
+++ b/backup/moodle2/restore_stepslib.php
@@ -4434,6 +4434,12 @@ class restore_create_categories_and_questions extends restore_structure_step {
$data->stamp = make_unique_id_code();
}
+ // The idnumber if it exists also needs to be unique within a context or reset it to null.
+ if (!empty($data->idnumber) && $DB->record_exists('question_categories',
+ ['idnumber' => $data->idnumber, 'contextid' => $data->contextid])) {
+ unset($data->idnumber);
+ }
+
// Let's create the question_category and save mapping.
$newitemid = $DB->insert_record('question_categories', $data);
$this->set_mapping('question_category', $oldid, $newitemid);
@@ -4479,6 +4485,13 @@ 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);
+ }
+
$newitemid = $DB->insert_record('question', $data);
$this->set_mapping('question', $oldid, $newitemid);
// Also annotate them as question_created, we need
diff --git a/lang/en/question.php b/lang/en/question.php
index fb8fe82ff33..b853063a13c 100644
--- a/lang/en/question.php
+++ b/lang/en/question.php
@@ -170,6 +170,8 @@ $string['getcontextfromfile'] = 'Get context from file';
$string['changepublishstatuscat'] = 'Category "{$a->name}" in course "{$a->coursename}" will have it\'s sharing status changed from {$a->changefrom} to {$a->changeto}.';
$string['chooseqtypetoadd'] = 'Choose a question type to add';
$string['editquestions'] = 'Edit questions';
+$string['idnumber'] = 'ID number';
+$string['idnumber_help'] = 'If used, the ID number must be unique within each question category. It provides another way of identifying a question which is sometimes useful, but can usually be left blank.';
$string['ignorebroken'] = 'Ignore broken links';
$string['impossiblechar'] = 'Impossible character {$a} detected as parenthesis character';
$string['import'] = 'Import';
diff --git a/lib/db/install.xml b/lib/db/install.xml
index c7d1a8191d6..2101c71de19 100644
--- a/lib/db/install.xml
+++ b/lib/db/install.xml
@@ -1333,6 +1333,7 @@
+
@@ -1341,6 +1342,7 @@
+
@@ -1364,6 +1366,7 @@
+
@@ -1374,6 +1377,7 @@
+
-
\ No newline at end of file
+
diff --git a/lib/db/upgrade.php b/lib/db/upgrade.php
index fc5e7773f2c..7f6a1f11cb3 100644
--- a/lib/db/upgrade.php
+++ b/lib/db/upgrade.php
@@ -2360,5 +2360,44 @@ function xmldb_main_upgrade($oldversion) {
upgrade_main_savepoint(true, 2018091700.01);
}
+ // Add idnumber fields to question and question_category tables.
+ // This is done in four parts to aid error recovery during upgrade, should that occur.
+ if ($oldversion < 2018092100.01) {
+ $table = new xmldb_table('question');
+ $field = new xmldb_field('idnumber', XMLDB_TYPE_CHAR, '100', null, null, null, null, 'modifiedby');
+ if (!$dbman->field_exists($table, $field)) {
+ $dbman->add_field($table, $field);
+ }
+ upgrade_main_savepoint(true, 2018092100.01);
+ }
+
+ if ($oldversion < 2018092100.02) {
+ $table = new xmldb_table('question');
+ $index = new xmldb_index('categoryidnumber', XMLDB_INDEX_UNIQUE, array('category, idnumber'));
+ if (!$dbman->index_exists($table, $index)) {
+ $dbman->add_index($table, $index);
+ }
+ upgrade_main_savepoint(true, 2018092100.02);
+ }
+
+ if ($oldversion < 2018092100.03) {
+ $table = new xmldb_table('question_categories');
+ $field = new xmldb_field('idnumber', XMLDB_TYPE_CHAR, '100', null, null, null, null, 'sortorder');
+ if (!$dbman->field_exists($table, $field)) {
+ $dbman->add_field($table, $field);
+ }
+ upgrade_main_savepoint(true, 2018092100.03);
+ }
+
+ if ($oldversion < 2018092100.04) {
+ $table = new xmldb_table('question_categories');
+ $index = new xmldb_index('contextididnumber', XMLDB_INDEX_UNIQUE, array('contextid, idnumber'));
+ if (!$dbman->index_exists($table, $index)) {
+ $dbman->add_index($table, $index);
+ }
+ // Main savepoint reached.
+ upgrade_main_savepoint(true, 2018092100.04);
+ }
+
return true;
}
diff --git a/lib/questionlib.php b/lib/questionlib.php
index 268c066ed4b..1439ea87364 100644
--- a/lib/questionlib.php
+++ b/lib/questionlib.php
@@ -673,7 +673,7 @@ function question_move_questions_to_category($questionids, $newcategoryid) {
array('id' => $newcategoryid));
list($questionidcondition, $params) = $DB->get_in_or_equal($questionids);
$questions = $DB->get_records_sql("
- SELECT q.id, q.qtype, qc.contextid
+ SELECT q.id, q.qtype, qc.contextid, q.idnumber
FROM {question} q
JOIN {question_categories} qc ON q.category = qc.id
WHERE q.id $questionidcondition", $params);
@@ -682,6 +682,27 @@ function question_move_questions_to_category($questionids, $newcategoryid) {
question_bank::get_qtype($question->qtype)->move_files(
$question->id, $question->contextid, $newcontextid);
}
+ // 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);
+ $unique = 1;
+ if (count($rec)) {
+ $rec = reset($rec);
+ $idnumber = $rec->idnumber;
+ if (strpos($idnumber, '_') !== false) {
+ $unique = substr($idnumber, strpos($idnumber, '_') + 1) + 1;
+ }
+ }
+ // 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);
+ }
}
// Move the questions themselves.
diff --git a/question/category.php b/question/category.php
index 77ffa39bd58..8e4ae0e7af7 100644
--- a/question/category.php
+++ b/question/category.php
@@ -118,10 +118,10 @@ if ($qcobject->catform->is_cancelled()) {
$catformdata->info = $catformdata->info['text'];
if (!$catformdata->id) {//new category
$qcobject->add_category($catformdata->parent, $catformdata->name,
- $catformdata->info, false, $catformdata->infoformat);
+ $catformdata->info, false, $catformdata->infoformat, $catformdata->idnumber);
} else {
$qcobject->update_category($catformdata->id, $catformdata->parent,
- $catformdata->name, $catformdata->info, $catformdata->infoformat);
+ $catformdata->name, $catformdata->info, $catformdata->infoformat, $catformdata->idnumber);
}
redirect($thispageurl);
} else if ((!empty($param->delete) and (!$questionstomove) and confirm_sesskey())) {
diff --git a/question/category_class.php b/question/category_class.php
index e7f6ccb6a56..3bccfc9b08e 100644
--- a/question/category_class.php
+++ b/question/category_class.php
@@ -404,7 +404,8 @@ class question_category_object {
/**
* Creates a new category with given params
*/
- public function add_category($newparent, $newcategory, $newinfo, $return = false, $newinfoformat = FORMAT_HTML) {
+ public function add_category($newparent, $newcategory, $newinfo, $return = false, $newinfoformat = FORMAT_HTML,
+ $idnumber = null) {
global $DB;
if (empty($newcategory)) {
print_error('categorynamecantbeblank', 'question');
@@ -419,6 +420,14 @@ class question_category_object {
}
}
+ if (((string) $idnumber !== '') && !empty($contextid)) {
+ // While this check already exists in the form validation, this is a backstop preventing unnecessary errors.
+ if ($DB->record_exists('question_categories',
+ ['idnumber' => $idnumber, 'contextid' => $contextid])) {
+ $idnumber = null;
+ }
+ }
+
$cat = new stdClass();
$cat->parent = $parentid;
$cat->contextid = $contextid;
@@ -427,6 +436,9 @@ class question_category_object {
$cat->infoformat = $newinfoformat;
$cat->sortorder = 999;
$cat->stamp = make_unique_id_code();
+ if ($idnumber) {
+ $cat->idnumber = $idnumber;
+ }
$categoryid = $DB->insert_record("question_categories", $cat);
// Log the creation of this category.
@@ -447,7 +459,8 @@ class question_category_object {
/**
* Updates an existing category with given params
*/
- public function update_category($updateid, $newparent, $newname, $newinfo, $newinfoformat = FORMAT_HTML) {
+ public function update_category($updateid, $newparent, $newname, $newinfo, $newinfoformat = FORMAT_HTML,
+ $idnumber = null) {
global $CFG, $DB;
if (empty($newname)) {
print_error('categorynamecantbeblank', 'question');
@@ -480,6 +493,14 @@ class question_category_object {
}
}
+ if (((string) $idnumber !== '') && !empty($tocontextid)) {
+ // While this check already exists in the form validation, this is a backstop preventing unnecessary errors.
+ if ($DB->record_exists('question_categories',
+ ['idnumber' => $idnumber, 'contextid' => $tocontextid])) {
+ $idnumber = null;
+ }
+ }
+
// Update the category record.
$cat = new stdClass();
$cat->id = $updateid;
@@ -488,6 +509,9 @@ class question_category_object {
$cat->infoformat = $newinfoformat;
$cat->parent = $parentid;
$cat->contextid = $tocontextid;
+ if ($idnumber) {
+ $cat->idnumber = $idnumber;
+ }
if ($newstamprequired) {
$cat->stamp = make_unique_id_code();
}
diff --git a/question/category_form.php b/question/category_form.php
index 14ccf19a9f0..c0a36aa56f8 100644
--- a/question/category_form.php
+++ b/question/category_form.php
@@ -63,6 +63,10 @@ class question_category_edit_form extends moodleform {
$mform->setDefault('info', '');
$mform->setType('info', PARAM_RAW);
+ $mform->addElement('text', 'idnumber', get_string('idnumber', 'question'), 'maxlength="100" size="10"');
+ $mform->addHelpButton('idnumber', 'idnumber', 'question');
+ $mform->setType('idnumber', PARAM_RAW);
+
$this->add_action_buttons(false, get_string('addcategory', 'question'));
$mform->addElement('hidden', 'id', 0);
@@ -81,4 +85,33 @@ class question_category_edit_form extends moodleform {
}
parent::set_data($current);
}
+
+ /**
+ * Validation.
+ *
+ * @param array $data
+ * @param array $files
+ * @return array the errors that were found
+ */
+ public function validation($data, $files) {
+ global $DB;
+
+ $errors = parent::validation($data, $files);
+
+ // Add field validation check for duplicate idnumber.
+ list($parentid, $contextid) = explode(',', $data['parent']);
+ if (((string) $data['idnumber'] !== '') && !empty($contextid)) {
+ $conditions = 'contextid = ? AND idnumber = ?';
+ $params = [$contextid, $data['idnumber']];
+ if (!empty($data['id'])) {
+ $conditions .= ' AND id <> ?';
+ $params[] = $data['id'];
+ }
+ if ($DB->record_exists_select('question_categories', $conditions, $params)) {
+ $errors['idnumber'] = get_string('idnumbertaken', 'error');
+ }
+ }
+
+ return $errors;
+ }
}
diff --git a/question/engine/tests/helpers.php b/question/engine/tests/helpers.php
index 6877d454de8..93d3ecb9a52 100644
--- a/question/engine/tests/helpers.php
+++ b/question/engine/tests/helpers.php
@@ -170,6 +170,7 @@ class test_question_maker {
$q->id = 0;
$q->category = 0;
+ $q->idnumber = null;
$q->parent = 0;
$q->questiontextformat = FORMAT_HTML;
$q->generalfeedbackformat = FORMAT_HTML;
@@ -190,6 +191,7 @@ class test_question_maker {
$qdata->id = 0;
$qdata->category = 0;
+ $qdata->idnumber = null;
$qdata->contextid = 0;
$qdata->parent = 0;
$qdata->questiontextformat = FORMAT_HTML;
diff --git a/question/format.php b/question/format.php
index da31797397f..0ba280ef8f0 100644
--- a/question/format.php
+++ b/question/format.php
@@ -406,6 +406,13 @@ class qformat_default {
$question->timecreated = time();
$question->modifiedby = $USER->id;
$question->timemodified = time();
+ if (isset($question->idnumber) && (string) $question->idnumber !== '') {
+ if ($DB->record_exists('question', ['idnumber' => $question->idnumber, 'category' => $question->category])) {
+ // We cannot have duplicate idnumbers in a category.
+ unset($question->idnumber);
+ }
+ }
+
$fileoptions = array(
'subdirs' => true,
'maxfiles' => -1,
diff --git a/question/format/xml/format.php b/question/format/xml/format.php
index 93ab228df88..6ea5f5f290b 100644
--- a/question/format/xml/format.php
+++ b/question/format/xml/format.php
@@ -236,6 +236,8 @@ class qformat_xml extends qformat_default {
$qo->questiontext .= '
';
}
+ $qo->idnumber = $this->getpath($question, ['#', 'idnumber', 0, '#'], null);
+
// Restore files in generalfeedback.
$generalfeedback = $this->import_text_with_files($question,
array('#', 'generalfeedback', 0), $qo->generalfeedback, $this->get_format($qo->questiontextformat));
@@ -1217,6 +1219,7 @@ class qformat_xml extends qformat_default {
}
$expout .= " {$question->penalty}\n";
$expout .= " {$question->hidden}\n";
+ $expout .= " {$question->idnumber}\n";
// The rest of the output depends on question type.
switch($question->qtype) {
diff --git a/question/format/xml/tests/xmlformat_test.php b/question/format/xml/tests/xmlformat_test.php
index 6732cbc9545..c85fff02b26 100644
--- a/question/format/xml/tests/xmlformat_test.php
+++ b/question/format/xml/tests/xmlformat_test.php
@@ -48,6 +48,7 @@ class qformat_xml_test extends question_testcase {
$q = new stdClass();
$q->id = 0;
$q->contextid = 0;
+ $q->idnumber = null;
$q->category = 0;
$q->parent = 0;
$q->questiontextformat = FORMAT_HTML;
@@ -342,6 +343,7 @@ END;
$qdata->length = 0;
$qdata->penalty = 0;
$qdata->hidden = 0;
+ $qdata->idnumber = null;
$exporter = new qformat_xml();
$xml = $exporter->writequestion($qdata);
@@ -360,6 +362,7 @@ END;
0
0
0
+
';
@@ -487,6 +490,7 @@ END;
$qdata->length = 1;
$qdata->penalty = 0;
$qdata->hidden = 0;
+ $qdata->idnumber = null;
$qdata->options = new stdClass();
$qdata->options->id = 456;
$qdata->options->questionid = 123;
@@ -516,6 +520,7 @@ END;
1
0
0
+
monospaced
0
42
@@ -649,6 +654,7 @@ END;
$qdata->length = 1;
$qdata->penalty = 0.3333333;
$qdata->hidden = 0;
+ $qdata->idnumber = null;
$qdata->options = new stdClass();
$qdata->options->shuffleanswers = 1;
@@ -709,6 +715,7 @@ END;
1
0.3333333
0
+
true
Well done.
@@ -880,6 +887,7 @@ END;
$qdata->length = 1;
$qdata->penalty = 0.3333333;
$qdata->hidden = 0;
+ $qdata->idnumber = null;
$qdata->options = new stdClass();
$qdata->options->single = 0;
@@ -922,6 +930,7 @@ END;
2
0.3333333
0
+
false
false
abc
@@ -1053,6 +1062,7 @@ END;
$qdata->length = 1;
$qdata->penalty = 0.1;
$qdata->hidden = 0;
+ $qdata->idnumber = null;
$qdata->options = new stdClass();
$qdata->options->answers = array(
@@ -1083,6 +1093,7 @@ END;
1
0.1
0
+
42
@@ -1183,6 +1194,7 @@ END;
$qdata->length = 1;
$qdata->penalty = 0.3333333;
$qdata->hidden = 0;
+ $qdata->idnumber = null;
$qdata->options = new stdClass();
$qdata->options->usecase = 0;
@@ -1214,6 +1226,7 @@ END;
1
0.3333333
0
+
0
Beta
@@ -1290,6 +1303,59 @@ END;
$this->assert(new question_check_specified_fields_expectation($expectedq), $q);
}
+ public function test_import_truefalse_with_idnumber() {
+ $xml = '
+
+ True false question
+
+
+ The answer is true.
+
+
+ General feedback: You should have chosen true.
+
+ 1
+ 1
+ 0
+ TestIdNum1
+
+ true
+
+ Well done!
+
+
+
+ false
+
+ Doh!
+
+
+ ';
+ $xmldata = xmlize($xml);
+
+ $importer = new qformat_xml();
+ $q = $importer->import_truefalse($xmldata['question']);
+
+ $expectedq = new stdClass();
+ $expectedq->qtype = 'truefalse';
+ $expectedq->name = 'True false question';
+ $expectedq->questiontext = 'The answer is true.';
+ $expectedq->questiontextformat = FORMAT_HTML;
+ $expectedq->generalfeedback = 'General feedback: You should have chosen true.';
+ $expectedq->defaultmark = 1;
+ $expectedq->length = 1;
+ $expectedq->penalty = 1;
+ $expectedq->idnumber = 'TestIdNum1';
+
+ $expectedq->feedbacktrue = array('text' => 'Well done!',
+ 'format' => FORMAT_HTML);
+ $expectedq->feedbackfalse = array('text' => 'Doh!',
+ 'format' => FORMAT_HTML);
+ $expectedq->correctanswer = true;
+
+ $this->assert(new question_check_specified_fields_expectation($expectedq), $q);
+ }
+
public function test_export_truefalse() {
$qdata = new stdClass();
$qdata->id = 12;
@@ -1304,6 +1370,7 @@ END;
$qdata->length = 1;
$qdata->penalty = 1;
$qdata->hidden = 0;
+ $qdata->idnumber = null;
$qdata->options = new stdClass();
$qdata->options->answers = array(
@@ -1330,6 +1397,67 @@ END;
1
1
0
+
+
+ true
+
+ Well done!
+
+
+
+ false
+
+ Doh!
+
+
+
+';
+
+ $this->assert_same_xml($expectedxml, $xml);
+ }
+
+ public function test_export_truefalse_with_idnumber() {
+ $qdata = new stdClass();
+ $qdata->id = 12;
+ $qdata->contextid = \context_system::instance()->id;
+ $qdata->qtype = 'truefalse';
+ $qdata->name = 'True false question';
+ $qdata->questiontext = 'The answer is true.';
+ $qdata->questiontextformat = FORMAT_HTML;
+ $qdata->generalfeedback = 'General feedback: You should have chosen true.';
+ $qdata->generalfeedbackformat = FORMAT_HTML;
+ $qdata->defaultmark = 1;
+ $qdata->length = 1;
+ $qdata->penalty = 1;
+ $qdata->hidden = 0;
+ $qdata->idnumber = 'TestIDNum2';
+
+ $qdata->options = new stdClass();
+ $qdata->options->answers = array(
+ 1 => new question_answer(1, 'True', 1, 'Well done!', FORMAT_HTML),
+ 2 => new question_answer(2, 'False', 0, 'Doh!', FORMAT_HTML),
+ );
+ $qdata->options->trueanswer = 1;
+ $qdata->options->falseanswer = 2;
+
+ $exporter = new qformat_xml();
+ $xml = $exporter->writequestion($qdata);
+
+ $expectedxml = '
+
+
+ True false question
+
+
+ The answer is true.
+
+
+ General feedback: You should have chosen true.
+
+ 1
+ 1
+ 0
+ TestIDNum2
true
@@ -1474,6 +1602,7 @@ END;
0.3333333
0
+
Hint 1
@@ -1505,6 +1634,7 @@ END;
0.3333333
0
+
';
diff --git a/question/tests/backup_test.php b/question/tests/backup_test.php
index 9695571ce6f..389880cfd0f 100644
--- a/question/tests/backup_test.php
+++ b/question/tests/backup_test.php
@@ -115,8 +115,8 @@ class core_question_backup_testcase extends advanced_testcase {
$qgen = $this->getDataGenerator()->get_plugin_generator('core_question');
$context = context_coursecat::instance($category1->id);
$qcat = $qgen->create_question_category(array('contextid' => $context->id));
- $question1 = $qgen->create_question('shortanswer', null, array('category' => $qcat->id));
- $question2 = $qgen->create_question('shortanswer', null, array('category' => $qcat->id));
+ $question1 = $qgen->create_question('shortanswer', null, array('category' => $qcat->id, 'idnumber' => 'q1'));
+ $question2 = $qgen->create_question('shortanswer', null, array('category' => $qcat->id, 'idnumber' => 'q2'));
// Tag the questions with 2 question tags and 2 course level question tags.
$qcontext = context::instance_by_id($qcat->contextid);
@@ -144,10 +144,11 @@ 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', array('category' => $qcat->id));
+ $questions = $DB->get_records('question', array('category' => $qcat->id), 'idnumber');
$this->assertCount(2, $questions);
// Retrieve tags for each question and check if they are assigned at the right context.
+ $qcount = 1;
foreach ($questions as $question) {
$tags = core_tag_tag::get_item_tags('core_question', 'question', $question->id);
@@ -162,6 +163,10 @@ class core_question_backup_testcase extends advanced_testcase {
}
$this->assertEquals($expected, $tag->taginstancecontextid);
}
+
+ // Also check idnumbers have been backed up and restored.
+ $this->assertEquals('q' . $qcount, $question->idnumber);
+ $qcount++;
}
// Now, again, delete everything including the course category.
@@ -203,4 +208,4 @@ class core_question_backup_testcase extends advanced_testcase {
}
}
-}
\ No newline at end of file
+}
diff --git a/question/tests/behat/question_categories_idnumber.feature b/question/tests/behat/question_categories_idnumber.feature
new file mode 100644
index 00000000000..f5717be9dae
--- /dev/null
+++ b/question/tests/behat/question_categories_idnumber.feature
@@ -0,0 +1,122 @@
+@core @core_question
+Feature: A teacher can put questions with idnumbers in categories with idnumbers in the question bank
+ In order to organize my questions
+ As a teacher
+ I create and edit categories and move questions between them (now with idnumbers)
+
+ 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 "course enrolments" exist:
+ | user | course | role |
+ | teacher1 | C1 | editingteacher |
+ And I log in as "teacher1"
+ And I am on "Course 1" course homepage
+
+ Scenario: A new question category can only be created with a unique idnumber for a context
+ # Note need to create the top category each time.
+ When the following "question categories" exist:
+ | contextlevel | reference | questioncategory | name | idnumber |
+ | Course | C1 | Top | top | |
+ | Course | C1 | top | Used category | c1used |
+ And I navigate to "Question bank > Categories" in current page administration
+ And I set the following fields to these values:
+ | Name | Sub used category |
+ | Parent category | Used category |
+ | Category info | Created as a test |
+ | ID number | c1used |
+ And I press "Add category"
+ # Standard warning.
+ Then I should see "This ID number is already in use"
+ # Correction to a unique idnumber for the context.
+ And I set the field "ID number" to "c1unused"
+ And I press "Add category"
+ Then I should see "Sub used category (0)"
+ And I should see "Created as a test" in the "Sub used category" "list_item"
+
+ Scenario: A question category can be edited and saved without changing the idnumber
+ When the following "question categories" exist:
+ | contextlevel | reference | questioncategory | name | idnumber |
+ | Course | C1 | Top | top | |
+ | Course | C1 | top | Used category | c1used |
+ And I navigate to "Question bank > Categories" in current page administration
+ And I click on "Edit" "link" in the "Used category" "list_item"
+ And I press "Save changes"
+ Then I should not see "This ID number is already in use"
+
+ Scenario: A question can only have a unique idnumber within a category
+ When the following "question categories" exist:
+ | contextlevel | reference | questioncategory | name | idnumber |
+ | Course | C1 | Top | top | |
+ | Course | C1 | top | Used category | c1used |
+ And the following "questions" exist:
+ | questioncategory | qtype | name | questiontext | idnumber |
+ | Used category | essay | Test question 1 | Write about whatever you want | q1 |
+ | Used category | essay | Test question 2 | Write about whatever you want | q2 |
+ And I navigate to "Question bank > Questions" in current page administration
+ And I click on "Edit" "link" in the "Test question 2" "table_row"
+ And I set the field "ID number" to "q1"
+ And I press "submitbutton"
+ # This is the standard form warning reminding the user that the idnumber needs to be unique for a category.
+ Then I should see "This ID number is already in use"
+
+ Scenario: A question can be edited and saved without changing the idnumber
+ When the following "question categories" exist:
+ | contextlevel | reference | questioncategory | name | idnumber |
+ | Course | C1 | Top | top | |
+ | Course | C1 | top | Used category | c1used |
+ And the following "questions" exist:
+ | questioncategory | qtype | name | questiontext | idnumber |
+ | Used category | essay | Test question 1 | Write about whatever you want | q1 |
+ And I navigate to "Question bank > Questions" in current page administration
+ And I click on "Edit" "link" in the "Test question 1" "table_row"
+ And I press "Save changes"
+ Then I should not see "This ID number is already in use"
+
+ Scenario: Question idnumber conficts found when saving to a different category.
+ When the following "question categories" exist:
+ | contextlevel | reference | questioncategory | name |
+ | Course | C1 | Top | top |
+ | Course | C1 | top | Category 1 |
+ | Course | C1 | top | Category 2 |
+ 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 |
+ And I navigate to "Question bank > Questions" in current page administration
+ And I click on "Edit" "link" in the "Question to edit" "table_row"
+ And I set the following fields to these values:
+ | Use this category | 0 |
+ | ID number | q2 |
+ | Save in category | Category 2 |
+ And I press "Save changes"
+ Then I should see "This ID number is already in use"
+
+ @javascript
+ Scenario: Moving a question between categories can force a change to the idnumber
+ And the following "question categories" exist:
+ | contextlevel | reference | questioncategory | name | idnumber |
+ | Course | C1 | Top | top | |
+ | Course | C1 | top | Subcategory | c1sub |
+ | Course | C1 | top | Used category | c1used |
+ And the following "questions" exist:
+ | questioncategory | qtype | name | questiontext | idnumber |
+ | Used category | essay | Test question 1 | Write about whatever you want | q1 |
+ | Used category | essay | Test question 2 | Write about whatever you want | q2 |
+ | Subcategory | essay | Test question 3 | Write about whatever you want | q3 |
+ When I navigate to "Question bank > Questions" in current page administration
+ And I click on "Edit" "link" in the "Test question 3" "table_row"
+ # The q1 idnumber is allowed for this question while it is in the Subcategory.
+ And I set the field "ID number" to "q1"
+ And I press "submitbutton"
+ # Javascript is required for the next step.
+ And I click on "Test question 3" "checkbox" in the "Test question 3" "table_row"
+ And I set the field "Question category" to "Used category"
+ And I press "Move to >>"
+ And I click on "Edit" "link" in the "Test question 3" "table_row"
+ # The question just moved into this category needs to have a unique idnumber, so a number is appended.
+ Then the field "ID number" matches value "q1_1"
diff --git a/question/tests/generator/lib.php b/question/tests/generator/lib.php
index f807c93416e..bdbcca596fc 100644
--- a/question/tests/generator/lib.php
+++ b/question/tests/generator/lib.php
@@ -51,6 +51,7 @@ class core_question_generator extends component_generator_base {
'infoformat' => FORMAT_HTML,
'stamp' => make_unique_id_code(),
'sortorder' => 999,
+ 'idnumber' => null
);
$record = $this->datagenerator->combine_defaults_and_record($defaults, $record);
@@ -87,6 +88,7 @@ class core_question_generator extends component_generator_base {
$question->category = $fromform->category;
$question->qtype = $qtype;
$question->createdby = 0;
+ $question->idnumber = null;
return $this->update_question($question, $which, $overrides);
}
diff --git a/question/tests/generator_test.php b/question/tests/generator_test.php
index 50f7ce9b770..945c9839aa8 100644
--- a/question/tests/generator_test.php
+++ b/question/tests/generator_test.php
@@ -51,4 +51,33 @@ class core_question_generator_testcase extends advanced_testcase {
$this->assertSame('My category', $cat->name);
$this->assertSame(1, $cat->sortorder);
}
+
+ public function test_idnumbers_in_categories_and_questions() {
+ $this->resetAfterTest();
+ $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
+ list($category, $course, $qcat, $questions) = $generator->setup_course_and_questions();
+ // Check default idnumbers in question_category and questions.
+ $this->assertNull($qcat->idnumber);
+ $this->assertNull($questions[0]->idnumber);
+ $this->assertNull($questions[1]->idnumber);
+ // Check created idnumbers.
+ $qcat1 = $generator->create_question_category(array(
+ 'name' => 'My category', 'sortorder' => 1, 'idnumber' => 'myqcat'));
+ $this->assertSame('myqcat', $qcat1->idnumber);
+ $quest1 = $generator->update_question($questions[0], null, ['idnumber' => 'myquest']);
+ $this->assertSame('myquest', $quest1->idnumber);
+ $quest3 = $generator->create_question('shortanswer', null,
+ ['name' => 'sa1', 'category' => $qcat1->id, 'idnumber' => 'myquest_3']);
+ $this->assertSame('myquest_3', $quest3->idnumber);
+ // Check idnumbers of questions moved. Note have to use load_question_data or we only get to see old cached data.
+ 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']);
+ 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.
+ $quest4 = $generator->create_question('shortanswer', null, ['name' => 'sa1', 'category' => $qcat1->id, 'idnumber' => '0']);
+ $this->assertSame('0', $quest4->idnumber);
+ }
}
diff --git a/question/type/ddwtos/tests/questiontype_test.php b/question/type/ddwtos/tests/questiontype_test.php
index 222c1869116..ded8ebef094 100644
--- a/question/type/ddwtos/tests/questiontype_test.php
+++ b/question/type/ddwtos/tests/questiontype_test.php
@@ -75,6 +75,7 @@ class qtype_ddwtos_test extends question_testcase {
$dd->stamp = make_unique_id_code();
$dd->version = make_unique_id_code();
$dd->hidden = 0;
+ $dd->idnumber = null;
$dd->timecreated = time();
$dd->timemodified = time();
$dd->createdby = $USER->id;
@@ -122,6 +123,7 @@ 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);
@@ -247,6 +249,7 @@ class qtype_ddwtos_test extends question_testcase {
$qdata = new stdClass();
$qdata->id = 123;
$qdata->contextid = \context_system::instance()->id;
+ $qdata->idnumber = null;
$qdata->qtype = 'ddwtos';
$qdata->name = 'A drag-and-drop question';
$qdata->questiontext = 'Put these in order: [[1]], [[2]], [[3]].';
@@ -304,6 +307,7 @@ class qtype_ddwtos_test extends question_testcase {
3
0.3333333
0
+
1
Your answer is correct.
]]>
diff --git a/question/type/edit_question_form.php b/question/type/edit_question_form.php
index 44302ed038a..7b5fb9cde0f 100644
--- a/question/type/edit_question_form.php
+++ b/question/type/edit_question_form.php
@@ -198,6 +198,10 @@ abstract class question_edit_form extends question_wizard_form {
$mform->setType('generalfeedback', PARAM_RAW);
$mform->addHelpButton('generalfeedback', 'generalfeedback', 'question');
+ $mform->addElement('text', 'idnumber', get_string('idnumber', 'question'), 'maxlength="100" size="10"');
+ $mform->addHelpButton('idnumber', 'idnumber', 'question');
+ $mform->setType('idnumber', PARAM_RAW);
+
// Any questiontype specific fields.
$this->definition_inner($mform);
@@ -791,6 +795,8 @@ abstract class question_edit_form extends question_wizard_form {
}
public function validation($fromform, $files) {
+ global $DB;
+
$errors = parent::validation($fromform, $files);
if (empty($fromform['makecopy']) && isset($this->question->id)
&& ($this->question->formoptions->canedit ||
@@ -810,6 +816,30 @@ abstract class question_edit_form extends question_wizard_form {
$errors['defaultmark'] = get_string('defaultmarkmustbepositive', 'question');
}
+ // Can only have one idnumber per category.
+ if (strpos($fromform['category'], ',') !== false) {
+ list($category, $categorycontextid) = explode(',', $fromform['category']);
+ } else {
+ $category = $fromform['category'];
+ }
+ if (isset($fromform['idnumber']) && ((string) $fromform['idnumber'] !== '')) {
+ if (empty($fromform['usecurrentcat']) && !empty($fromform['categorymoveto'])) {
+ $categoryinfo = $fromform['categorymoveto'];
+ } else {
+ $categoryinfo = $fromform['category'];
+ }
+ list($categoryid, $notused) = explode(',', $categoryinfo);
+ $conditions = 'category = ? AND idnumber = ?';
+ $params = [$categoryid, $fromform['idnumber']];
+ if (!empty($this->question->id)) {
+ $conditions .= ' AND id <> ?';
+ $params[] = $this->question->id;
+ }
+ if ($DB->record_exists_select('question', $conditions, $params)) {
+ $errors['idnumber'] = get_string('idnumbertaken', 'error');
+ }
+ }
+
return $errors;
}
diff --git a/question/type/gapselect/tests/questiontype_test.php b/question/type/gapselect/tests/questiontype_test.php
index 0a85673b8f4..6eea6cbdfa3 100644
--- a/question/type/gapselect/tests/questiontype_test.php
+++ b/question/type/gapselect/tests/questiontype_test.php
@@ -79,6 +79,7 @@ class qtype_gapselect_test extends question_testcase {
$gapselect->stamp = make_unique_id_code();
$gapselect->version = make_unique_id_code();
$gapselect->hidden = 0;
+ $gapselect->idnumber = null;
$gapselect->timecreated = time();
$gapselect->timemodified = time();
$gapselect->createdby = $USER->id;
@@ -243,6 +244,7 @@ class qtype_gapselect_test extends question_testcase {
$qdata = new stdClass();
$qdata->id = 123;
$qdata->contextid = \context_system::instance()->id;
+ $qdata->idnumber = null;
$qdata->qtype = 'gapselect';
$qdata->name = 'A select missing words question';
$qdata->questiontext = 'Put these in order: [[1]], [[2]], [[3]].';
@@ -294,6 +296,7 @@ class qtype_gapselect_test extends question_testcase {
3
0.3333333
0
+
1
Your answer is correct.]]>
diff --git a/question/type/match/tests/questiontype_test.php b/question/type/match/tests/questiontype_test.php
index 2a8af385245..d60a0b4fb8d 100644
--- a/question/type/match/tests/questiontype_test.php
+++ b/question/type/match/tests/questiontype_test.php
@@ -68,6 +68,7 @@ class qtype_match_test extends advanced_testcase {
$q->stamp = make_unique_id_code();
$q->version = make_unique_id_code();
$q->hidden = 0;
+ $q->idnumber = null;
$q->timecreated = time();
$q->timemodified = time();
$q->createdby = $USER->id;
diff --git a/question/type/missingtype/tests/missingtype_test.php b/question/type/missingtype/tests/missingtype_test.php
index 76b289f243a..cc2a922db37 100644
--- a/question/type/missingtype/tests/missingtype_test.php
+++ b/question/type/missingtype/tests/missingtype_test.php
@@ -58,6 +58,7 @@ class qtype_missing_test extends question_testcase {
$questiondata->stamp = make_unique_id_code();
$questiondata->version = make_unique_id_code();
$questiondata->hidden = 0;
+ $questiondata->idnumber = null;
$questiondata->timecreated = 0;
$questiondata->timemodified = 0;
$questiondata->createdby = 0;
diff --git a/question/type/questiontypebase.php b/question/type/questiontypebase.php
index c812900adde..294975d1ee9 100644
--- a/question/type/questiontypebase.php
+++ b/question/type/questiontypebase.php
@@ -350,6 +350,19 @@ class question_type {
$question->defaultmark = $form->defaultmark;
}
+ if (isset($form->idnumber) && ((string) $form->idnumber !== '')) {
+ // While this check already exists in the form validation, this is a backstop preventing unnecessary errors.
+ if (strpos($form->category, ',') !== false) {
+ list($category, $categorycontextid) = explode(',', $form->category);
+ } else {
+ $category = $form->category;
+ }
+ if (!$DB->record_exists('question',
+ ['idnumber' => $form->idnumber, 'category' => $category])) {
+ $question->idnumber = $form->idnumber;
+ }
+ }
+
// If the question is new, create it.
if (empty($question->id)) {
// Set the unique code.
@@ -851,6 +864,7 @@ class question_type {
$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;
diff --git a/version.php b/version.php
index b33f98b9864..a6e8909f8b4 100644
--- a/version.php
+++ b/version.php
@@ -29,7 +29,7 @@
defined('MOODLE_INTERNAL') || die();
-$version = 2018092000.00; // YYYYMMDD = weekly release date of this DEV branch.
+$version = 2018092100.04; // YYYYMMDD = weekly release date of this DEV branch.
// RR = release increments - 00 in DEV branches.
// .XX = incremental changes.