diff --git a/mod/quiz/backup/moodle2/backup_quiz_stepslib.php b/mod/quiz/backup/moodle2/backup_quiz_stepslib.php index 59b3b67013a..97d867c1f20 100644 --- a/mod/quiz/backup/moodle2/backup_quiz_stepslib.php +++ b/mod/quiz/backup/moodle2/backup_quiz_stepslib.php @@ -58,7 +58,7 @@ class backup_quiz_activity_structure_step extends backup_questions_activity_stru $qinstances = new backup_nested_element('question_instances'); $qinstance = new backup_nested_element('question_instance', array('id'), array( - 'slot', 'page', 'requireprevious', 'questionid', 'maxmark')); + 'slot', 'page', 'requireprevious', 'questionid', 'questioncategoryid', 'includingsubcategories', 'tags', 'maxmark')); $sections = new backup_nested_element('sections'); diff --git a/mod/quiz/backup/moodle2/restore_quiz_stepslib.php b/mod/quiz/backup/moodle2/restore_quiz_stepslib.php index d695ca15138..5f76fa56fbd 100644 --- a/mod/quiz/backup/moodle2/restore_quiz_stepslib.php +++ b/mod/quiz/backup/moodle2/restore_quiz_stepslib.php @@ -290,7 +290,21 @@ class restore_quiz_activity_structure_step extends restore_questions_activity_st } $data->quizid = $this->get_new_parentid('quiz'); - $data->questionid = $this->get_mappingid('question', $data->questionid); + $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; + } + + if (isset($data->tags)) { + $tags = quiz_extract_random_question_tags($data->tags, $this->task->is_samesite()); + $data->tags = quiz_build_random_question_tag_json($tags); + } $DB->insert_record('quiz_slots', $data); } diff --git a/mod/quiz/classes/form/randomquestion_form.php b/mod/quiz/classes/form/randomquestion_form.php index ef138166a47..140f71810f4 100644 --- a/mod/quiz/classes/form/randomquestion_form.php +++ b/mod/quiz/classes/form/randomquestion_form.php @@ -81,4 +81,20 @@ class randomquestion_form extends \moodleform { $mform->addGroup($buttonarray, 'buttonar', '', array(' '), false); $mform->closeHeaderBefore('buttonar'); } + + public function set_data($defaultvalues) { + $mform = $this->_form; + + if ($defaultvalues->fromtags) { + $fromtagselement = $mform->getElement('fromtags'); + foreach ($defaultvalues->fromtags as $fromtag) { + if (!$fromtagselement->optionExists($fromtag)) { + $optionname = get_string('randomfromunavailabletag', 'mod_quiz', explode(',', $fromtag)[1]); + $fromtagselement->addOption($optionname, $fromtag); + } + } + } + + parent::set_data($defaultvalues); + } } diff --git a/mod/quiz/lang/en/quiz.php b/mod/quiz/lang/en/quiz.php index 9171ba1a672..6f88c803e08 100644 --- a/mod/quiz/lang/en/quiz.php +++ b/mod/quiz/lang/en/quiz.php @@ -700,6 +700,7 @@ $string['randomcreate'] = 'Create random questions'; $string['randomediting'] = 'Editing a random question'; $string['randomfromcategory'] = 'Random question from category:'; $string['randomfromexistingcategory'] = 'Random question from an existing category'; +$string['randomfromunavailabletag'] = '{$a} (unavailable)'; $string['randomnumber'] = 'Number of random questions'; $string['randomnosubcat'] = 'Questions from this category only, not its subcategories.'; $string['randomquestion'] = 'Random question'; diff --git a/mod/quiz/locallib.php b/mod/quiz/locallib.php index 9956fad8c6c..9350ec95985 100644 --- a/mod/quiz/locallib.php +++ b/mod/quiz/locallib.php @@ -2449,14 +2449,14 @@ function quiz_is_overriden_calendar_event(\calendar_event $event) { function quiz_build_random_question_tag_json($tagrecords) { $tags = []; foreach ($tagrecords as $tagrecord) { - if ($tag = core_tag_tag::get($tagrecord->id, 'id, name')) { + if ($tagrecord->id && $tag = core_tag_tag::get($tagrecord->id, 'id, name')) { $tags[] = [ 'id' => (int)$tagrecord->id, 'name' => $tag->name ]; } else if ($tag = core_tag_tag::get_by_name(0, $tagrecord->name, 'id, name')) { $tags[] = [ - 'id' => $tag->id, + 'id' => (int)$tag->id, 'name' => $tagrecord->name ]; } else { @@ -2475,20 +2475,25 @@ function quiz_build_random_question_tag_json($tagrecords) { * @param string $tagsjson The JSON string representing an array of tags in the [{"id":tagid,"name":"tagname"}] format. * E.g. [{"id":1,"name":"tag1"},{"id":2,"name":"tag2"}] * Usually equal to the value of the tags field retrieved from the quiz_slots table. + * @param bool $matchbyid If set to true, then the function tries to find tags by their id. + * If no tag is found by the tag id or if $matchbyid is set to false, then the function tries to find the tag by its name. * @return array An array of tags containing the id and name properties, indexed by tag ids. */ -function quiz_extract_random_question_tags($tagsjson) { +function quiz_extract_random_question_tags($tagsjson, $matchbyid = true) { $tagrecords = []; if (!empty($tagsjson)) { $tags = json_decode($tagsjson); - // Only work with tags that exist. + foreach ($tags as $tagdata) { - if (!array_key_exists($tagdata->id, $tagrecords)) { - if ($tag = core_tag_tag::get($tagdata->id, 'id, name')) { - $tagrecords[$tag->id] = $tag->to_object(); - } else if ($tag = core_tag_tag::get_by_name(0, $tagdata->name, 'id, name')) { - $tagrecords[$tag->id] = $tag->to_object(); - } + if ($matchbyid && $tag = core_tag_tag::get($tagdata->id, 'id, name')) { + $tagrecords[] = $tag->to_object(); + } else if ($tag = core_tag_tag::get_by_name(0, $tagdata->name, 'id, name')) { + $tagrecords[] = $tag->to_object(); + } else { + $tagrecords[] = (object)[ + 'id' => null, + 'name' => $tagdata->name + ]; } } } @@ -2502,9 +2507,13 @@ function quiz_extract_random_question_tags($tagsjson) { * @param string $tagsjson The JSON string representing an array of tags in the [{"id":tagid,"name":"tagname"}] format. * E.g. [{"id":1,"name":"tag1"},{"id":2,"name":"tag2"}] * Usually equal to the value of the tags field retrieved from the {quiz_slots} table. + * @param bool $matchbyid If set to true, then this function relies on the tag ids that are stored in $tagsjson to find tags. + * If no tag is found by the tag id or if $matchbyid is set to false, then this function tries to find the tag by its name. * @return int[] List of tag ids. */ -function quiz_extract_random_question_tag_ids($tagsjson) { - $tags = quiz_extract_random_question_tags($tagsjson); - return array_keys($tags); +function quiz_extract_random_question_tag_ids($tagsjson, $matchbyid = true) { + $tags = quiz_extract_random_question_tags($tagsjson, $matchbyid); + + // Only work with tags that exist. + return array_filter(array_column($tags, 'id')); } \ No newline at end of file diff --git a/mod/quiz/tests/fixtures/random_by_tag_quiz.mbz b/mod/quiz/tests/fixtures/random_by_tag_quiz.mbz new file mode 100644 index 00000000000..643b0c17319 Binary files /dev/null and b/mod/quiz/tests/fixtures/random_by_tag_quiz.mbz differ diff --git a/mod/quiz/tests/locallib_test.php b/mod/quiz/tests/locallib_test.php index 7da393e90e8..3ef0ad13d21 100644 --- a/mod/quiz/tests/locallib_test.php +++ b/mod/quiz/tests/locallib_test.php @@ -420,4 +420,224 @@ class mod_quiz_locallib_testcase extends advanced_testcase { $this->assertEquals($comparearray, quiz_get_user_timeclose($course->id)); } + + public function test_quiz_build_random_question_tag_json() { + $this->resetAfterTest(); + + // Setup test data. + $footagrecord = array( + 'isstandard' => 1, + 'flag' => 0, + 'rawname' => 'foo', + 'description' => 'foo desc' + ); + $footag = $this->getDataGenerator()->create_tag($footagrecord); + $bartagrecord = array( + 'isstandard' => 1, + 'flag' => 0, + 'rawname' => 'bar', + 'description' => 'bar desc' + ); + $bartag = $this->getDataGenerator()->create_tag($bartagrecord); + $baztagrecord = array( + 'isstandard' => 1, + 'flag' => 0, + 'rawname' => 'baz', + 'description' => 'baz desc' + ); + $baztag = $this->getDataGenerator()->create_tag($baztagrecord); + $quxtagrecord = array( + 'isstandard' => 1, + 'flag' => 0, + 'rawname' => 'qux', + 'description' => 'qux desc' + ); + $quxtag = $this->getDataGenerator()->create_tag($quxtagrecord); + $quuxtagrecord = array( + 'isstandard' => 1, + 'flag' => 0, + 'rawname' => 'quux', + 'description' => 'quux desc' + ); + $quuxtag = $this->getDataGenerator()->create_tag($quuxtagrecord); + + $tagrecords = array( + (object)[ + 'id' => $footag->id, + 'name' => 'foo' + ], + (object)[ + 'id' => 999, // An invalid tag id. + 'name' => 'bar' + ], + (object)[ + 'id' => null, + 'name' => 'baz' + ], + (object)[ + 'id' => $quxtag->id, + 'name' => 'invalidqux' // An invalid tag name. + ], + (object)[ + 'id' => 999, // An invalid tag id. + 'name' => 'invalidquux' // An invalid tag name. + ], + ); + + $expectedjson = json_encode(array( + ['id' => (int)$footag->id, 'name' => $footag->name], + ['id' => (int)$bartag->id, 'name' => $bartag->name], + ['id' => (int)$baztag->id, 'name' => $baztag->name], + ['id' => (int)$quxtag->id, 'name' => $quxtag->name], + ['id' => null, 'name' => 'invalidquux'], + )); + $this->assertEquals($expectedjson, quiz_build_random_question_tag_json($tagrecords)); + } + + public function test_quiz_extract_random_question_tags() { + $this->resetAfterTest(); + + // Setup test data. + $footagrecord = array( + 'isstandard' => 1, + 'flag' => 0, + 'rawname' => 'foo', + 'description' => 'foo desc' + ); + $footag = $this->getDataGenerator()->create_tag($footagrecord); + $bartagrecord = array( + 'isstandard' => 1, + 'flag' => 0, + 'rawname' => 'bar', + 'description' => 'bar desc' + ); + $bartag = $this->getDataGenerator()->create_tag($bartagrecord); + $baztagrecord = array( + 'isstandard' => 1, + 'flag' => 0, + 'rawname' => 'baz', + 'description' => 'baz desc' + ); + $baztag = $this->getDataGenerator()->create_tag($baztagrecord); + $quxtagrecord = array( + 'isstandard' => 1, + 'flag' => 0, + 'rawname' => 'qux', + 'description' => 'qux desc' + ); + $quxtag = $this->getDataGenerator()->create_tag($quxtagrecord); + $quuxtagrecord = array( + 'isstandard' => 1, + 'flag' => 0, + 'rawname' => 'quux', + 'description' => 'quux desc' + ); + $quuxtag = $this->getDataGenerator()->create_tag($quuxtagrecord); + + $tagjson = json_encode(array( + [ + 'id' => $footag->id, + 'name' => 'foo' + ], + [ + 'id' => 999, // An invalid tag id. + 'name' => 'bar' + ], + [ + 'id' => null, + 'name' => 'baz' + ], + [ + 'id' => $quxtag->id, + 'name' => 'invalidqux' // An invalid tag name. + ], + [ + 'id' => 999, // An invalid tag id. + 'name' => 'invalidquux' // An invalid tag name. + ], + )); + + $expectedrecords = array( + (object)['id' => $footag->id, 'name' => $footag->name], + (object)['id' => $bartag->id, 'name' => $bartag->name], + (object)['id' => $baztag->id, 'name' => $baztag->name], + (object)['id' => $quxtag->id, 'name' => $quxtag->name], + (object)['id' => null, 'name' => 'invalidquux'], + ); + + $this->assertEquals($expectedrecords, quiz_extract_random_question_tags($tagjson)); + } + + public function test_quiz_extract_random_question_tag_ids() { + $this->resetAfterTest(); + + // Setup test data. + $footagrecord = array( + 'isstandard' => 1, + 'flag' => 0, + 'rawname' => 'foo', + 'description' => 'foo desc' + ); + $footag = $this->getDataGenerator()->create_tag($footagrecord); + $bartagrecord = array( + 'isstandard' => 1, + 'flag' => 0, + 'rawname' => 'bar', + 'description' => 'bar desc' + ); + $bartag = $this->getDataGenerator()->create_tag($bartagrecord); + $baztagrecord = array( + 'isstandard' => 1, + 'flag' => 0, + 'rawname' => 'baz', + 'description' => 'baz desc' + ); + $baztag = $this->getDataGenerator()->create_tag($baztagrecord); + $quxtagrecord = array( + 'isstandard' => 1, + 'flag' => 0, + 'rawname' => 'qux', + 'description' => 'qux desc' + ); + $quxtag = $this->getDataGenerator()->create_tag($quxtagrecord); + $quuxtagrecord = array( + 'isstandard' => 1, + 'flag' => 0, + 'rawname' => 'quux', + 'description' => 'quux desc' + ); + $quuxtag = $this->getDataGenerator()->create_tag($quuxtagrecord); + + $tagjson = json_encode(array( + [ + 'id' => $footag->id, + 'name' => 'foo' + ], + [ + 'id' => 999, // An invalid tag id. + 'name' => 'bar' + ], + [ + 'id' => null, + 'name' => 'baz' + ], + [ + 'id' => $quxtag->id, + 'name' => 'invalidqux' // An invalid tag name. + ], + [ + 'id' => 999, // An invalid tag id. + 'name' => 'invalidquux' // An invalid tag name. + ], + )); + + $expectedrecords = array( + $footag->id, + $bartag->id, + $baztag->id, + $quxtag->id, + ); + + $this->assertEquals($expectedrecords, quiz_extract_random_question_tag_ids($tagjson)); + } } diff --git a/mod/quiz/tests/tags_test.php b/mod/quiz/tests/tags_test.php new file mode 100644 index 00000000000..c5fbbcd7b19 --- /dev/null +++ b/mod/quiz/tests/tags_test.php @@ -0,0 +1,95 @@ +. + +/** + * 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(); +global $CFG; +require_once($CFG->dirroot . '/mod/quiz/locallib.php'); + +/** + * Class mod_quiz_tags_testcase + * Class for tests related to usage of question tags in quizzes. + * + * @copyright 2018 Shamim Rezaie + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class mod_quiz_tags_testcase extends advanced_testcase { + public function test_restore_random_question_by_tag() { + global $CFG, $USER, $DB; + + require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php'); + + $this->resetAfterTest(); + $this->setAdminUser(); + + $backupid = 'abc'; + $backuppath = $CFG->tempdir . '/backup/' . $backupid; + check_dir_exists($backuppath); + 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); + + $question = array_values($questions)[0]; + + $tag1 = core_tag_tag::get_by_name(0, 't1', 'id, name'); + $this->assertNotFalse($tag1); + + $tag2 = core_tag_tag::get_by_name(0, 't2', 'id, name'); + $this->assertNotFalse($tag2); + + $tag3 = core_tag_tag::get_by_name(0, 't3', 'id, name'); + $this->assertNotFalse($tag3); + + $tagrecords = array($tag2->to_object()); + $this->assertEquals(quiz_build_random_question_tag_json($tagrecords), $question->randomfromtags); + + $defaultcategory = question_get_default_category(context_course::instance($newcourseid)->id); + $this->assertEquals($defaultcategory->id, $question->randomfromcategory); + $this->assertEquals(0, $question->randomincludingsubcategories); + } +}