diff --git a/lang/en/question.php b/lang/en/question.php index 67a520d50d9..ead22db12b3 100644 --- a/lang/en/question.php +++ b/lang/en/question.php @@ -391,6 +391,7 @@ $string['questionbehavioursdisabledexplained'] = 'Enter a comma separated list o $string['questionbehavioursorder'] = 'Question behaviours order'; $string['questionbehavioursorderexplained'] = 'Enter a comma separated list of behaviours in the order you want them to appear in dropdown menu'; $string['questionidmismatch'] = 'Question ids mismatch'; +$string['questionformtagheader'] = '{$a} tags'; $string['questionname'] = 'Question name'; $string['questionnamecopy'] = '{$a} (copy)'; $string['questionpreviewdefaults'] = 'Question preview defaults'; diff --git a/lib/db/install.xml b/lib/db/install.xml old mode 100644 new mode 100755 index 593ff37a451..97df4857b38 --- a/lib/db/install.xml +++ b/lib/db/install.xml @@ -1,5 +1,5 @@ - @@ -2019,6 +2019,7 @@ + @@ -2081,7 +2082,7 @@ - + diff --git a/lib/db/tag.php b/lib/db/tag.php index 2dc6b314ba3..ed5cf19b95d 100644 --- a/lib/db/tag.php +++ b/lib/db/tag.php @@ -69,6 +69,7 @@ $tagareas = array( array( 'itemtype' => 'question', // Questions. 'component' => 'core_question', + 'multiplecontexts' => true, ), array( 'itemtype' => 'post', // Blog posts. diff --git a/lib/db/upgrade.php b/lib/db/upgrade.php index 7e68ae117c8..78ebbee0dd0 100644 --- a/lib/db/upgrade.php +++ b/lib/db/upgrade.php @@ -1974,7 +1974,6 @@ function xmldb_main_upgrade($oldversion) { } if ($oldversion < 2018022800.01) { - // Fix old block configurations that use the deprecated (and now removed) object class. upgrade_fix_block_instance_configuration(); @@ -1982,5 +1981,44 @@ function xmldb_main_upgrade($oldversion) { upgrade_main_savepoint(true, 2018022800.01); } + if ($oldversion < 2018022800.02) { + // Define index taggeditem (unique) to be dropped form tag_instance. + $table = new xmldb_table('tag_instance'); + $index = new xmldb_index('taggeditem', XMLDB_INDEX_UNIQUE, array('component', + 'itemtype', 'itemid', 'tiuserid', 'tagid')); + + // Conditionally launch drop index taggeditem. + if ($dbman->index_exists($table, $index)) { + $dbman->drop_index($table, $index); + } + + $index = new xmldb_index('taggeditem', XMLDB_INDEX_UNIQUE, array('component', + 'itemtype', 'itemid', 'contextid', 'tiuserid', 'tagid')); + + // Conditionally launch add index taggeditem. + if (!$dbman->index_exists($table, $index)) { + $dbman->add_index($table, $index); + } + + // Main savepoint reached. + upgrade_main_savepoint(true, 2018022800.02); + } + + if ($oldversion < 2018022800.03) { + + // Define field multiplecontexts to be added to tag_area. + $table = new xmldb_table('tag_area'); + $field = new xmldb_field('multiplecontexts', XMLDB_TYPE_INTEGER, '1', null, + XMLDB_NOTNULL, null, '0', 'showstandard'); + + // Conditionally launch add field multiplecontexts. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // Main savepoint reached. + upgrade_main_savepoint(true, 2018022800.03); + } + return true; } diff --git a/lib/questionlib.php b/lib/questionlib.php index 98ef3c4320f..eb99d4f383a 100644 --- a/lib/questionlib.php +++ b/lib/questionlib.php @@ -537,6 +537,120 @@ function question_delete_activity($cm, $feedback=true) { return true; } +/** + * This function will handle moving all tag instances to a new context for a + * given list of questions. + * + * Questions can be tagged in up to two contexts: + * 1.) The context the question exists in. + * 2.) The course context (if the question context is a higher context. + * E.g. course category context or system context. + * + * This means a question that exists in a higher context (e.g. course cat or + * system context) may have multiple groups of tags in any number of child + * course contexts. + * + * Questions in the course category context can be move "down" a context level + * into one of their child course contexts or activity contexts which affects the + * availability of that question in other courses / activities. + * + * In this case it makes the questions no longer available in the other course or + * activity contexts so we need to make sure that the tag instances in those other + * contexts are removed. + * + * @param stdClass[] $questions The list of question being moved (must include + * 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) { + // 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 + // tagging questions across courses). + $instancestodelete = []; + $instancesfornewcontext = []; + $newcontextparentids = $newcontext->get_parent_context_ids(); + $questionids = array_map(function($question) { + return $question->id; + }, $questions); + $questionstagobjects = core_tag_tag::get_items_tags('core_question', 'question', $questionids); + + foreach ($questions as $question) { + $tagobjects = $questionstagobjects[$question->id]; + + foreach ($tagobjects as $tagobject) { + $tagid = $tagobject->taginstanceid; + $tagcontextid = $tagobject->taginstancecontextid; + $istaginnewcontext = $tagcontextid == $newcontext->id; + $istaginquestioncontext = $tagcontextid == $question->contextid; + + if ($istaginnewcontext) { + // This tag instance is already in the correct context so we can + // ignore it. + continue; + } + + if ($istaginquestioncontext) { + // This tag instance is in the question context so it needs to be + // updated. + $instancesfornewcontext[] = $tagid; + continue; + } + + // These tag instances are in neither the new context nor the + // question context so we need to determine what to do based on + // the context they are in and the new question context. + $tagcontext = context::instance_by_id($tagcontextid); + $tagcoursecontext = $tagcontext->get_course_context(false); + // The tag is in a course context if get_course_context() returns + // itself. + $istaginstancecontextcourse = !empty($tagcoursecontext) + && $tagcontext->id == $tagcoursecontext->id; + + if ($istaginstancecontextcourse) { + // If the tag instance is in a course context we need to add some + // special handling. + $tagcontextparentids = $tagcontext->get_parent_context_ids(); + $isnewcontextaparent = in_array($newcontext->id, $tagcontextparentids); + $isnewcontextachild = in_array($tagcontext->id, $newcontextparentids); + + if ($isnewcontextaparent) { + // If the tag instance is a course context tag and the new + // context is still a parent context to the tag context then + // we can leave this tag where it is. + continue; + } else if ($isnewcontextachild) { + // If the new context is a child context (e.g. activity) of this + // tag instance then we should move all of this tag instance + // down into the activity context along with the question. + $instancesfornewcontext[] = $tagid; + } else { + // If the tag is in a course context that is no longer a parent + // or child of the new context then this tag instance should be + // removed. + $instancestodelete[] = $tagid; + } + } else { + // This is a catch all for any tag instances not in the question + // context or a course context. These tag instances should be + // updated to the new context id. This will clean up old invalid + // data. + $instancesfornewcontext[] = $tagid; + } + } + } + + if (!empty($instancestodelete)) { + // Delete any course context tags that may no longer be valid. + core_tag_tag::delete_instances_by_id($instancestodelete); + } + + if (!empty($instancesfornewcontext)) { + // Update the tag instances to the new context id. + core_tag_tag::change_instances_context($instancesfornewcontext, $newcontext); + } +} + /** * 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 @@ -573,8 +687,8 @@ function question_move_questions_to_category($questionids, $newcategoryid) { $DB->set_field_select('question', 'category', $newcategoryid, "parent $questionidcondition", $params); - // Update the contextid for any tag instances that may exist for these questions. - core_tag_tag::change_items_context('core_question', 'question', $questionids, $newcontextid); + $newcontext = context::instance_by_id($newcontextid); + question_move_question_tags_to_new_context($questions, $newcontext); // TODO Deal with datasets. @@ -597,6 +711,7 @@ function question_move_questions_to_category($questionids, $newcategoryid) { function question_move_category_to_context($categoryid, $oldcontextid, $newcontextid) { global $DB; + $questions = []; $questionids = $DB->get_records_menu('question', array('category' => $categoryid), '', 'id,qtype'); foreach ($questionids as $questionid => $qtype) { @@ -604,10 +719,15 @@ function question_move_category_to_context($categoryid, $oldcontextid, $newconte $questionid, $oldcontextid, $newcontextid); // Purge this question from the cache. question_bank::notify_question_edited($questionid); + + $questions[] = (object) [ + 'id' => $questionid, + 'contextid' => $oldcontextid + ]; } - core_tag_tag::change_items_context('core_question', 'question', - array_keys($questionids), $newcontextid); + $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'); @@ -770,9 +890,11 @@ function question_load_questions($questionids, $extrafields = '', $join = '') { * Private function to factor common code out of get_question_options(). * * @param object $question the question to tidy. - * @param boolean $loadtags load the question tags from the tags table. Optional, default false. + * @param stdClass $category The question_categories record for the given $question. + * @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, $loadtags = false) { +function _tidy_question($question, $category, array $tagobjects = null, array $filtercourses = null) { global $CFG; // Load question-type specific fields. @@ -790,8 +912,76 @@ function _tidy_question($question, $loadtags = false) { unset($question->_partiallyloaded); } - if ($loadtags && core_tag_tag::is_enabled('core_question', 'question')) { - $question->tags = core_tag_tag::get_item_tags_array('core_question', 'question', $question->id); + $question->categoryobject = $category; + + if (!is_null($tagobjects)) { + $categorycontext = context::instance_by_id($category->contextid); + + // Questions can have two sets of tag instances. One set at the + // course context level and another at the context the question + // belongs to (e.g. course category, system etc). + $question->coursetagobjects = []; + $question->coursetags = []; + $question->tagobjects = []; + $question->tags = []; + $taginstanceidstonormalise = []; + $filtercoursecontextids = []; + $hasfiltercourses = !empty($filtercourses); + + if ($hasfiltercourses) { + // If we're being asked to filter the course tags by a set of courses + // then get the context ids to filter below. + $filtercoursecontextids = array_map(function($course) { + $coursecontext = context_course::instance($course->id); + return $coursecontext->id; + }, $filtercourses); + } + + foreach ($tagobjects as $tagobject) { + $tagcontextid = $tagobject->taginstancecontextid; + $tagcontext = context::instance_by_id($tagcontextid); + $tagcoursecontext = $tagcontext->get_course_context(false); + // This is a course tag if the tag context is a course context which + // doesn't match the question's context. Any tag in the question context + // is not considered a course tag, it belongs to the question. + $iscoursetag = $tagcoursecontext + && $tagcontext->id == $tagcoursecontext->id + && $tagcontext->id != $categorycontext->id; + + if ($iscoursetag) { + // Any tag instance in a course context level is considered a course tag. + if (!$hasfiltercourses || in_array($tagcontextid, $filtercoursecontextids)) { + // Add the tag to the list of course tags if we aren't being + // asked to filter or if this tag is in the list of courses + // we're being asked to filter by. + $question->coursetagobjects[] = $tagobject; + $question->coursetags[$tagobject->id] = $tagobject->get_display_name(); + } + } else { + // All non course context level tag instances or tags in the question + // context belong to the context that the question was created in. + $question->tagobjects[] = $tagobject; + $question->tags[$tagobject->id] = $tagobject->get_display_name(); + + // Due to legacy tag implementations that don't force the recording + // of a context id, some tag instances may have context ids that don't + // match either a course context or the question context. In this case + // we should take the opportunity to fix up the data and set the correct + // context id. + if ($tagcontext->id != $categorycontext->id) { + $taginstanceidstonormalise[] = $tagobject->taginstanceid; + // Update the object properties to reflect the DB update that will + // happen below. + $tagobject->taginstancecontextid = $categorycontext->id; + } + } + } + + if (!empty($taginstanceidstonormalise)) { + // If we found any tag instances with incorrect context id data then we can + // correct those values now by setting them to the question context id. + core_tag_tag::change_instances_context($taginstanceidstonormalise, $categorycontext); + } } } @@ -804,17 +994,47 @@ function _tidy_question($question, $loadtags = false) { * * @param mixed $questions Either an array of question objects to be updated * or just a single question object - * @param boolean $loadtags load the question tags from the tags table. Optional, default false. + * @param bool $loadtags load the question tags from the tags table. Optional, default false. + * @param stdClass[] $filtercourses The courses to filter the course tags by. * @return bool Indicates success or failure. */ -function get_question_options(&$questions, $loadtags = false) { - if (is_array($questions)) { // deal with an array of questions - foreach ($questions as $i => $notused) { - _tidy_question($questions[$i], $loadtags); - } - } else { // deal with single question - _tidy_question($questions, $loadtags); +function get_question_options(&$questions, $loadtags = false, $filtercourses = null) { + global $DB; + + $questionlist = is_array($questions) ? $questions : [$questions]; + $categoryids = []; + $questionids = []; + + if (empty($questionlist)) { + return true; } + + foreach ($questionlist as $question) { + $questionids[] = $question->id; + + if (!in_array($question->category, $categoryids)) { + $categoryids[] = $question->category; + } + } + + $categories = $DB->get_records_list('question_categories', 'id', $categoryids); + + if ($loadtags && core_tag_tag::is_enabled('core_question', 'question')) { + $tagobjectsbyquestion = core_tag_tag::get_items_tags('core_question', 'question', $questionids); + } else { + $tagobjectsbyquestion = null; + } + + foreach ($questionlist as $question) { + if (is_null($tagobjectsbyquestion)) { + $tagobjects = null; + } else { + $tagobjects = $tagobjectsbyquestion[$question->id]; + } + + _tidy_question($question, $categories[$question->category], $tagobjects, $filtercourses); + } + return true; } diff --git a/lib/tests/questionlib_test.php b/lib/tests/questionlib_test.php index bc5874630ea..ee793d90435 100644 --- a/lib/tests/questionlib_test.php +++ b/lib/tests/questionlib_test.php @@ -22,6 +22,8 @@ * @copyright 2006 The Open University * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +use core_tag\output\tag; + defined('MOODLE_INTERNAL') || die(); @@ -74,7 +76,10 @@ class core_questionlib_testcase extends advanced_testcase { $category = $this->getDataGenerator()->create_category(); // Create course. - $course = $this->getDataGenerator()->create_course(array('numsections' => 5)); + $course = $this->getDataGenerator()->create_course(array( + 'numsections' => 5, + 'category' => $category->id + )); $options = array( 'course' => $course->id, @@ -86,12 +91,22 @@ class core_questionlib_testcase extends advanced_testcase { $qgen = $this->getDataGenerator()->get_plugin_generator('core_question'); - if ('course' == $type) { - $context = context_course::instance($course->id); - } else if ('category' == $type) { - $context = context_coursecat::instance($category->id); - } else { - $context = context_module::instance($quiz->cmid); + switch ($type) { + case 'course': + $context = context_course::instance($course->id); + break; + + case 'category': + $context = context_coursecat::instance($category->id); + break; + + case 'system': + $context = context_system::instance(); + break; + + default: + $context = context_module::instance($quiz->cmid); + break; } $qcat = $qgen->create_question_category(array('contextid' => $context->id)); @@ -456,4 +471,837 @@ class core_questionlib_testcase extends advanced_testcase { $this->assertFalse($DB->record_exists('question', ['id' => $q2c->id])); $this->assertTrue($DB->record_exists('question', ['id' => $q2d->id])); } + + /** + * get_question_options should add the category object to the given question. + */ + public function test_get_question_options_includes_category_object_single_question() { + list($category, $course, $quiz, $qcat, $questions) = $this->setup_quiz_and_questions('category'); + $question = array_shift($questions); + + get_question_options($question); + + $this->assertEquals($qcat, $question->categoryobject); + } + + /** + * get_question_options should add the category object to all of the questions in + * the given list. + */ + public function test_get_question_options_includes_category_object_multiple_questions() { + list($category, $course, $quiz, $qcat, $questions) = $this->setup_quiz_and_questions('category'); + + get_question_options($questions); + + foreach ($questions as $question) { + $this->assertEquals($qcat, $question->categoryobject); + } + } + + /** + * get_question_options includes the tags for all questions in the list. + */ + public function test_get_question_options_includes_question_tags() { + list($category, $course, $quiz, $qcat, $questions) = $this->setup_quiz_and_questions('category'); + $question1 = $questions[0]; + $question2 = $questions[1]; + $qcontext = context::instance_by_id($qcat->contextid); + + core_tag_tag::set_item_tags('core_question', 'question', $question1->id, $qcontext, ['foo', 'bar']); + core_tag_tag::set_item_tags('core_question', 'question', $question2->id, $qcontext, ['baz', 'bop']); + + get_question_options($questions, true); + + foreach ($questions as $question) { + $tags = core_tag_tag::get_item_tags('core_question', 'question', $question->id); + $expectedtags = []; + $actualtags = $question->tags; + foreach ($tags as $tag) { + $expectedtags[$tag->id] = $tag->get_display_name(); + } + + // The question should have a tags property populated with each tag id + // and display name as a key vale pair. + $this->assertEquals($expectedtags, $actualtags); + + $actualtagobjects = $question->tagobjects; + sort($tags); + sort($actualtagobjects); + + // The question should have a full set of each tag object. + $this->assertEquals($tags, $actualtagobjects); + // The question should not have any course tags. + $this->assertEmpty($question->coursetagobjects); + } + } + + /** + * get_question_options includes the course tags for all questions in the list. + */ + public function test_get_question_options_includes_course_tags() { + list($category, $course, $quiz, $qcat, $questions) = $this->setup_quiz_and_questions('category'); + $question1 = $questions[0]; + $question2 = $questions[1]; + $coursecontext = context_course::instance($course->id); + + core_tag_tag::set_item_tags('core_question', 'question', $question1->id, $coursecontext, ['foo', 'bar']); + core_tag_tag::set_item_tags('core_question', 'question', $question2->id, $coursecontext, ['baz', 'bop']); + + get_question_options($questions, true); + + foreach ($questions as $question) { + $tags = core_tag_tag::get_item_tags('core_question', 'question', $question->id); + $expectedcoursetags = []; + $actualcoursetags = $question->coursetags; + foreach ($tags as $tag) { + $expectedcoursetags[$tag->id] = $tag->get_display_name(); + } + + // The question should have a coursetags property populated with each tag id + // and display name as a key vale pair. + $this->assertEquals($expectedcoursetags, $actualcoursetags); + + $actualcoursetagobjects = $question->coursetagobjects; + sort($tags); + sort($actualcoursetagobjects); + + // The question should have a full set of the course tag objects. + $this->assertEquals($tags, $actualcoursetagobjects); + // The question should not have any other tags. + $this->assertEmpty($question->tagobjects); + $this->assertEmpty($question->tags); + } + } + + /** + * get_question_options only categorises a tag as a course tag if it is in a + * course context that is different from the question context. + */ + public function test_get_question_options_course_tags_in_course_question_context() { + list($category, $course, $quiz, $qcat, $questions) = $this->setup_quiz_and_questions('course'); + $question1 = $questions[0]; + $question2 = $questions[1]; + $coursecontext = context_course::instance($course->id); + + // Create course level tags in the course context that matches the question + // course context. + core_tag_tag::set_item_tags('core_question', 'question', $question1->id, $coursecontext, ['foo', 'bar']); + core_tag_tag::set_item_tags('core_question', 'question', $question2->id, $coursecontext, ['baz', 'bop']); + + get_question_options($questions, true); + + foreach ($questions as $question) { + $tags = core_tag_tag::get_item_tags('core_question', 'question', $question->id); + + $actualtagobjects = $question->tagobjects; + sort($tags); + sort($actualtagobjects); + + // The tags should not be considered course tags because they are in + // the same context as the question. That makes them question tags. + $this->assertEmpty($question->coursetagobjects); + // The course context tags should be returned in the regular tag object + // list. + $this->assertEquals($tags, $actualtagobjects); + } + } + + /** + * get_question_options includes the tags and course tags for all questions in the list + * if each question has course and question level tags. + */ + public function test_get_question_options_includes_question_and_course_tags() { + list($category, $course, $quiz, $qcat, $questions) = $this->setup_quiz_and_questions('category'); + $question1 = $questions[0]; + $question2 = $questions[1]; + $qcontext = context::instance_by_id($qcat->contextid); + $coursecontext = context_course::instance($course->id); + + core_tag_tag::set_item_tags('core_question', 'question', $question1->id, $qcontext, ['foo', 'bar']); + core_tag_tag::set_item_tags('core_question', 'question', $question1->id, $coursecontext, ['cfoo', 'cbar']); + core_tag_tag::set_item_tags('core_question', 'question', $question2->id, $qcontext, ['baz', 'bop']); + core_tag_tag::set_item_tags('core_question', 'question', $question2->id, $coursecontext, ['cbaz', 'cbop']); + + get_question_options($questions, true); + + foreach ($questions as $question) { + $alltags = core_tag_tag::get_item_tags('core_question', 'question', $question->id); + $tags = array_filter($alltags, function($tag) use ($qcontext) { + return $tag->taginstancecontextid == $qcontext->id; + }); + $coursetags = array_filter($alltags, function($tag) use ($coursecontext) { + return $tag->taginstancecontextid == $coursecontext->id; + }); + + $expectedtags = []; + $actualtags = $question->tags; + foreach ($tags as $tag) { + $expectedtags[$tag->id] = $tag->get_display_name(); + } + + // The question should have a tags property populated with each tag id + // and display name as a key vale pair. + $this->assertEquals($expectedtags, $actualtags); + + $actualtagobjects = $question->tagobjects; + sort($tags); + sort($actualtagobjects); + // The question should have a full set of each tag object. + $this->assertEquals($tags, $actualtagobjects); + + $actualcoursetagobjects = $question->coursetagobjects; + sort($coursetags); + sort($actualcoursetagobjects); + // The question should have a full set of course tag objects. + $this->assertEquals($coursetags, $actualcoursetagobjects); + } + } + + /** + * get_question_options should update the context id to the question category + * context id for any non-course context tag that isn't in the question category + * context. + */ + public function test_get_question_options_normalises_question_tags() { + list($category, $course, $quiz, $qcat, $questions) = $this->setup_quiz_and_questions('category'); + $question1 = $questions[0]; + $question2 = $questions[1]; + $qcontext = context::instance_by_id($qcat->contextid); + $systemcontext = context_system::instance(); + + core_tag_tag::set_item_tags('core_question', 'question', $question1->id, $qcontext, ['foo', 'bar']); + core_tag_tag::set_item_tags('core_question', 'question', $question2->id, $qcontext, ['baz', 'bop']); + + $q1tags = core_tag_tag::get_item_tags('core_question', 'question', $question1->id); + $q2tags = core_tag_tag::get_item_tags('core_question', 'question', $question2->id); + $q1tag = array_shift($q1tags); + $q2tag = array_shift($q2tags); + + // Change two of the tag instances to be a different (non-course) context to the + // question tag context. These tags should then be normalised back to the question + // tag context. + core_tag_tag::change_instances_context([$q1tag->taginstanceid, $q2tag->taginstanceid], $systemcontext); + + get_question_options($questions, true); + + foreach ($questions as $question) { + $tags = core_tag_tag::get_item_tags('core_question', 'question', $question->id); + + // The database should have been updated with the correct context id. + foreach ($tags as $tag) { + $this->assertEquals($qcontext->id, $tag->taginstancecontextid); + } + + // The tag objects on the question should have been updated with the + // correct context id. + foreach ($question->tagobjects as $tag) { + $this->assertEquals($qcontext->id, $tag->taginstancecontextid); + } + } + } + + /** + * get_question_options if the question is a course level question then tags + * in that context should not be consdered course tags, they are question tags. + */ + public function test_get_question_options_includes_course_context_question_tags() { + list($category, $course, $quiz, $qcat, $questions) = $this->setup_quiz_and_questions('course'); + $question1 = $questions[0]; + $question2 = $questions[1]; + $coursecontext = context_course::instance($course->id); + + core_tag_tag::set_item_tags('core_question', 'question', $question1->id, $coursecontext, ['foo', 'bar']); + core_tag_tag::set_item_tags('core_question', 'question', $question2->id, $coursecontext, ['baz', 'bop']); + + get_question_options($questions, true); + + foreach ($questions as $question) { + $tags = core_tag_tag::get_item_tags('core_question', 'question', $question->id); + // Tags in a course context that matches the question context should + // not be considered course tags. + $this->assertEmpty($question->coursetagobjects); + $this->assertEmpty($question->coursetags); + + $actualtagobjects = $question->tagobjects; + sort($tags); + sort($actualtagobjects); + // The tags should be considered question tags not course tags. + $this->assertEquals($tags, $actualtagobjects); + } + } + + /** + * get_question_options should return tags from all course contexts by default. + */ + public function test_get_question_options_includes_multiple_courses_tags() { + list($category, $course, $quiz, $qcat, $questions) = $this->setup_quiz_and_questions('category'); + $question1 = $questions[0]; + $question2 = $questions[1]; + $coursecontext = context_course::instance($course->id); + // Create a sibling course. + $siblingcourse = $this->getDataGenerator()->create_course(['category' => $course->category]); + $siblingcoursecontext = context_course::instance($siblingcourse->id); + + // Create course tags. + core_tag_tag::set_item_tags('core_question', 'question', $question1->id, $coursecontext, ['c1']); + core_tag_tag::set_item_tags('core_question', 'question', $question2->id, $coursecontext, ['c1']); + core_tag_tag::set_item_tags('core_question', 'question', $question1->id, $siblingcoursecontext, ['c2']); + core_tag_tag::set_item_tags('core_question', 'question', $question2->id, $siblingcoursecontext, ['c2']); + + get_question_options($questions, true); + + foreach ($questions as $question) { + $this->assertCount(2, $question->coursetagobjects); + + foreach ($question->coursetagobjects as $tag) { + if ($tag->name == 'c1') { + $this->assertEquals($coursecontext->id, $tag->taginstancecontextid); + } else { + $this->assertEquals($siblingcoursecontext->id, $tag->taginstancecontextid); + } + } + } + } + + /** + * get_question_options should filter the course tags by the given list of courses. + */ + public function test_get_question_options_includes_filter_course_tags() { + list($category, $course, $quiz, $qcat, $questions) = $this->setup_quiz_and_questions('category'); + $question1 = $questions[0]; + $question2 = $questions[1]; + $coursecontext = context_course::instance($course->id); + // Create a sibling course. + $siblingcourse = $this->getDataGenerator()->create_course(['category' => $course->category]); + $siblingcoursecontext = context_course::instance($siblingcourse->id); + + // Create course tags. + core_tag_tag::set_item_tags('core_question', 'question', $question1->id, $coursecontext, ['foo']); + core_tag_tag::set_item_tags('core_question', 'question', $question2->id, $coursecontext, ['bar']); + // Create sibling course tags. These should be filtered out. + core_tag_tag::set_item_tags('core_question', 'question', $question1->id, $siblingcoursecontext, ['filtered1']); + core_tag_tag::set_item_tags('core_question', 'question', $question2->id, $siblingcoursecontext, ['filtered2']); + + // Ask to only receive course tags from $course (ignoring $siblingcourse tags). + get_question_options($questions, true, [$course]); + + foreach ($questions as $question) { + foreach ($question->coursetagobjects as $tag) { + // We should only be seeing course tags from $course. The tags from + // $siblingcourse should have been filtered out. + $this->assertEquals($coursecontext->id, $tag->taginstancecontextid); + } + } + } + + /** + * question_move_question_tags_to_new_context should update all of the + * question tags contexts when they are moving down (from system to course + * category context). + */ + public function test_question_move_question_tags_to_new_context_system_to_course_cat_qtags() { + list($category, $course, $quiz, $qcat, $questions) = $this->setup_quiz_and_questions('system'); + $question1 = $questions[0]; + $question2 = $questions[1]; + $qcontext = context::instance_by_id($qcat->contextid); + $newcontext = context_coursecat::instance($category->id); + + foreach ($questions as $question) { + $question->contextid = $qcat->contextid; + } + + // Create tags in the system context. + core_tag_tag::set_item_tags('core_question', 'question', $question1->id, $qcontext, ['foo', 'bar']); + core_tag_tag::set_item_tags('core_question', 'question', $question2->id, $qcontext, ['foo', 'bar']); + + question_move_question_tags_to_new_context($questions, $newcontext); + + foreach ($questions as $question) { + $tags = core_tag_tag::get_item_tags('core_question', 'question', $question->id); + + // All of the tags should have their context id set to the new context. + foreach ($tags as $tag) { + $this->assertEquals($newcontext->id, $tag->taginstancecontextid); + } + } + } + + /** + * question_move_question_tags_to_new_context should update all of the question tags + * contexts when they are moving down (from system to course category context) + * but leave any tags in the course context where they are. + */ + public function test_question_move_question_tags_to_new_context_system_to_course_cat_qtags_and_course_tags() { + list($category, $course, $quiz, $qcat, $questions) = $this->setup_quiz_and_questions('system'); + $question1 = $questions[0]; + $question2 = $questions[1]; + $qcontext = context::instance_by_id($qcat->contextid); + $coursecontext = context_course::instance($course->id); + $newcontext = context_coursecat::instance($category->id); + + foreach ($questions as $question) { + $question->contextid = $qcat->contextid; + } + + // Create tags in the system context. + core_tag_tag::set_item_tags('core_question', 'question', $question1->id, $qcontext, ['foo']); + core_tag_tag::set_item_tags('core_question', 'question', $question2->id, $qcontext, ['foo']); + // Create tags in the course context. + core_tag_tag::set_item_tags('core_question', 'question', $question1->id, $coursecontext, ['ctag']); + core_tag_tag::set_item_tags('core_question', 'question', $question2->id, $coursecontext, ['ctag']); + + question_move_question_tags_to_new_context($questions, $newcontext); + + foreach ($questions as $question) { + $tags = core_tag_tag::get_item_tags('core_question', 'question', $question->id); + + foreach ($tags as $tag) { + if ($tag->name == 'ctag') { + // Course tags should remain in the course context. + $this->assertEquals($coursecontext->id, $tag->taginstancecontextid); + } else { + // Other tags should be updated. + $this->assertEquals($newcontext->id, $tag->taginstancecontextid); + } + } + } + } + + /** + * question_move_question_tags_to_new_context should update all of the question + * contexts tags when they are moving up (from course category to system context). + */ + public function test_question_move_question_tags_to_new_context_course_cat_to_system_qtags() { + list($category, $course, $quiz, $qcat, $questions) = $this->setup_quiz_and_questions('category'); + $question1 = $questions[0]; + $question2 = $questions[1]; + $qcontext = context::instance_by_id($qcat->contextid); + $newcontext = context_system::instance(); + + foreach ($questions as $question) { + $question->contextid = $qcat->contextid; + } + + // Create tags in the course category context. + core_tag_tag::set_item_tags('core_question', 'question', $question1->id, $qcontext, ['foo', 'bar']); + core_tag_tag::set_item_tags('core_question', 'question', $question2->id, $qcontext, ['foo', 'bar']); + + question_move_question_tags_to_new_context($questions, $newcontext); + + foreach ($questions as $question) { + $tags = core_tag_tag::get_item_tags('core_question', 'question', $question->id); + + // All of the tags should have their context id set to the new context. + foreach ($tags as $tag) { + $this->assertEquals($newcontext->id, $tag->taginstancecontextid); + } + } + } + + /** + * question_move_question_tags_to_new_context should update all of the question + * tags contexts when they are moving up (from course category context to system + * context) but leave any tags in the course context where they are. + */ + public function test_question_move_question_tags_to_new_context_course_cat_to_system_qtags_and_course_tags() { + list($category, $course, $quiz, $qcat, $questions) = $this->setup_quiz_and_questions('category'); + $question1 = $questions[0]; + $question2 = $questions[1]; + $qcontext = context::instance_by_id($qcat->contextid); + $coursecontext = context_course::instance($course->id); + $newcontext = context_system::instance(); + + foreach ($questions as $question) { + $question->contextid = $qcat->contextid; + } + + // Create tags in the system context. + core_tag_tag::set_item_tags('core_question', 'question', $question1->id, $qcontext, ['foo']); + core_tag_tag::set_item_tags('core_question', 'question', $question2->id, $qcontext, ['foo']); + // Create tags in the course context. + core_tag_tag::set_item_tags('core_question', 'question', $question1->id, $coursecontext, ['ctag']); + core_tag_tag::set_item_tags('core_question', 'question', $question2->id, $coursecontext, ['ctag']); + + question_move_question_tags_to_new_context($questions, $newcontext); + + foreach ($questions as $question) { + $tags = core_tag_tag::get_item_tags('core_question', 'question', $question->id); + + foreach ($tags as $tag) { + if ($tag->name == 'ctag') { + // Course tags should remain in the course context. + $this->assertEquals($coursecontext->id, $tag->taginstancecontextid); + } else { + // Other tags should be updated. + $this->assertEquals($newcontext->id, $tag->taginstancecontextid); + } + } + } + } + + /** + * question_move_question_tags_to_new_context should merge all tags into the course + * context when moving down from course category context into course context. + */ + public function test_question_move_question_tags_to_new_context_course_cat_to_coures_qtags_and_course_tags() { + list($category, $course, $quiz, $qcat, $questions) = $this->setup_quiz_and_questions('category'); + $question1 = $questions[0]; + $question2 = $questions[1]; + $qcontext = context::instance_by_id($qcat->contextid); + $coursecontext = context_course::instance($course->id); + $newcontext = $coursecontext; + + foreach ($questions as $question) { + $question->contextid = $qcat->contextid; + } + + // Create tags in the system context. + core_tag_tag::set_item_tags('core_question', 'question', $question1->id, $qcontext, ['foo']); + core_tag_tag::set_item_tags('core_question', 'question', $question2->id, $qcontext, ['foo']); + // Create tags in the course context. + core_tag_tag::set_item_tags('core_question', 'question', $question1->id, $coursecontext, ['ctag']); + core_tag_tag::set_item_tags('core_question', 'question', $question2->id, $coursecontext, ['ctag']); + + question_move_question_tags_to_new_context($questions, $newcontext); + + foreach ($questions as $question) { + $tags = core_tag_tag::get_item_tags('core_question', 'question', $question->id); + // Each question should have 2 tags. + $this->assertCount(2, $tags); + + foreach ($tags as $tag) { + // All tags should be updated to the course context and merged in. + $this->assertEquals($newcontext->id, $tag->taginstancecontextid); + } + } + } + + /** + * question_move_question_tags_to_new_context should delete all of the tag + * instances from sibling courses when moving the context of a question down + * from a course category into a course context because the other courses will + * no longer have access to the question. + */ + public function test_question_move_question_tags_to_new_context_remove_other_course_tags() { + list($category, $course, $quiz, $qcat, $questions) = $this->setup_quiz_and_questions('category'); + // Create a sibling course. + $siblingcourse = $this->getDataGenerator()->create_course(['category' => $course->category]); + $question1 = $questions[0]; + $question2 = $questions[1]; + $qcontext = context::instance_by_id($qcat->contextid); + $coursecontext = context_course::instance($course->id); + $siblingcoursecontext = context_course::instance($siblingcourse->id); + $newcontext = $coursecontext; + + foreach ($questions as $question) { + $question->contextid = $qcat->contextid; + } + + // Create tags in the system context. + core_tag_tag::set_item_tags('core_question', 'question', $question1->id, $qcontext, ['foo']); + core_tag_tag::set_item_tags('core_question', 'question', $question2->id, $qcontext, ['foo']); + // Create tags in the target course context. + core_tag_tag::set_item_tags('core_question', 'question', $question1->id, $coursecontext, ['ctag']); + core_tag_tag::set_item_tags('core_question', 'question', $question2->id, $coursecontext, ['ctag']); + // Create tags in the sibling course context. These should be deleted as + // part of the move. + core_tag_tag::set_item_tags('core_question', 'question', $question1->id, $siblingcoursecontext, ['stag']); + core_tag_tag::set_item_tags('core_question', 'question', $question2->id, $siblingcoursecontext, ['stag']); + + question_move_question_tags_to_new_context($questions, $newcontext); + + foreach ($questions as $question) { + $tags = core_tag_tag::get_item_tags('core_question', 'question', $question->id); + // Each question should have 2 tags, 'foo' and 'ctag'. + $this->assertCount(2, $tags); + + foreach ($tags as $tag) { + $tagname = $tag->name; + // The 'stag' should have been deleted because it's in a sibling + // course context. + $this->assertContains($tagname, ['foo', 'ctag']); + // All tags should be in the course context now. + $this->assertEquals($coursecontext->id, $tag->taginstancecontextid); + } + } + } + + /** + * question_move_question_tags_to_new_context should update all of the question + * tags to be the course category context when moving the tags from a course + * context to a course category context. + */ + public function test_question_move_question_tags_to_new_context_course_to_course_cat() { + list($category, $course, $quiz, $qcat, $questions) = $this->setup_quiz_and_questions('course'); + $question1 = $questions[0]; + $question2 = $questions[1]; + $qcontext = context::instance_by_id($qcat->contextid); + // Moving up into the course category context. + $newcontext = context_coursecat::instance($category->id); + + foreach ($questions as $question) { + $question->contextid = $qcat->contextid; + } + + // Create tags in the course context. + core_tag_tag::set_item_tags('core_question', 'question', $question1->id, $qcontext, ['foo']); + core_tag_tag::set_item_tags('core_question', 'question', $question2->id, $qcontext, ['foo']); + + question_move_question_tags_to_new_context($questions, $newcontext); + + foreach ($questions as $question) { + $tags = core_tag_tag::get_item_tags('core_question', 'question', $question->id); + + // All of the tags should have their context id set to the new context. + foreach ($tags as $tag) { + $this->assertEquals($newcontext->id, $tag->taginstancecontextid); + } + } + } + + /** + * question_move_question_tags_to_new_context should update all of the + * question tags contexts when they are moving down (from system to course + * category context). + */ + public function test_question_move_question_tags_to_new_context_orphaned_tag_contexts() { + list($category, $course, $quiz, $qcat, $questions) = $this->setup_quiz_and_questions('system'); + $question1 = $questions[0]; + $question2 = $questions[1]; + $othercategory = $this->getDataGenerator()->create_category(); + $qcontext = context::instance_by_id($qcat->contextid); + $newcontext = context_coursecat::instance($category->id); + $othercategorycontext = context_coursecat::instance($othercategory->id); + + foreach ($questions as $question) { + $question->contextid = $qcat->contextid; + } + + // Create tags in the system context. + core_tag_tag::set_item_tags('core_question', 'question', $question1->id, $qcontext, ['foo']); + core_tag_tag::set_item_tags('core_question', 'question', $question2->id, $qcontext, ['foo']); + // Create tags in the other course category context. These should be + // update to the next context id because they represent erroneous data + // from a time before context id was mandatory in the tag API. + core_tag_tag::set_item_tags('core_question', 'question', $question1->id, $othercategorycontext, ['bar']); + core_tag_tag::set_item_tags('core_question', 'question', $question2->id, $othercategorycontext, ['bar']); + + question_move_question_tags_to_new_context($questions, $newcontext); + + foreach ($questions as $question) { + $tags = core_tag_tag::get_item_tags('core_question', 'question', $question->id); + // Each question should have two tags, 'foo' and 'bar'. + $this->assertCount(2, $tags); + + // All of the tags should have their context id set to the new context + // (course category context). + foreach ($tags as $tag) { + $this->assertEquals($newcontext->id, $tag->taginstancecontextid); + } + } + } + + /** + * When moving from a course category context down into an activity context + * all question context tags and course tags (where the course is a parent of + * the activity) should move into the new context. + */ + public function test_question_move_question_tags_to_new_context_course_cat_to_activity_qtags_and_course_tags() { + list($category, $course, $quiz, $qcat, $questions) = $this->setup_quiz_and_questions('category'); + $question1 = $questions[0]; + $question2 = $questions[1]; + $qcontext = context::instance_by_id($qcat->contextid); + $coursecontext = context_course::instance($course->id); + $newcontext = context_module::instance($quiz->cmid); + + foreach ($questions as $question) { + $question->contextid = $qcat->contextid; + } + + // Create tags in the course category context. + core_tag_tag::set_item_tags('core_question', 'question', $question1->id, $qcontext, ['foo']); + core_tag_tag::set_item_tags('core_question', 'question', $question2->id, $qcontext, ['foo']); + // Move the questions to the activity context which is a child context of + // $coursecontext. + core_tag_tag::set_item_tags('core_question', 'question', $question1->id, $coursecontext, ['ctag']); + core_tag_tag::set_item_tags('core_question', 'question', $question2->id, $coursecontext, ['ctag']); + + question_move_question_tags_to_new_context($questions, $newcontext); + + foreach ($questions as $question) { + $tags = core_tag_tag::get_item_tags('core_question', 'question', $question->id); + // Each question should have 2 tags. + $this->assertCount(2, $tags); + + foreach ($tags as $tag) { + $this->assertEquals($newcontext->id, $tag->taginstancecontextid); + } + } + } + + /** + * When moving from a course category context down into an activity context + * all question context tags and course tags (where the course is a parent of + * the activity) should move into the new context. Tags in course contexts + * that are not a parent of the activity context should be deleted. + */ + public function test_question_move_question_tags_to_new_context_course_cat_to_activity_orphaned_tags() { + list($category, $course, $quiz, $qcat, $questions) = $this->setup_quiz_and_questions('category'); + $question1 = $questions[0]; + $question2 = $questions[1]; + $qcontext = context::instance_by_id($qcat->contextid); + $coursecontext = context_course::instance($course->id); + $newcontext = context_module::instance($quiz->cmid); + $othercourse = $this->getDataGenerator()->create_course(); + $othercoursecontext = context_course::instance($othercourse->id); + + foreach ($questions as $question) { + $question->contextid = $qcat->contextid; + } + + // Create tags in the course category context. + core_tag_tag::set_item_tags('core_question', 'question', $question1->id, $qcontext, ['foo']); + core_tag_tag::set_item_tags('core_question', 'question', $question2->id, $qcontext, ['foo']); + // Create tags in the course context. + core_tag_tag::set_item_tags('core_question', 'question', $question1->id, $coursecontext, ['ctag']); + core_tag_tag::set_item_tags('core_question', 'question', $question2->id, $coursecontext, ['ctag']); + // Create tags in the other course context. These should be deleted. + core_tag_tag::set_item_tags('core_question', 'question', $question1->id, $othercoursecontext, ['delete']); + core_tag_tag::set_item_tags('core_question', 'question', $question2->id, $othercoursecontext, ['delete']); + + // Move the questions to the activity context which is a child context of + // $coursecontext. + question_move_question_tags_to_new_context($questions, $newcontext); + + foreach ($questions as $question) { + $tags = core_tag_tag::get_item_tags('core_question', 'question', $question->id); + // Each question should have 2 tags. + $this->assertCount(2, $tags); + + foreach ($tags as $tag) { + // Make sure we don't have any 'delete' tags. + $this->assertContains($tag->name, ['foo', 'ctag']); + $this->assertEquals($newcontext->id, $tag->taginstancecontextid); + } + } + } + + /** + * When moving from a course context down into an activity context all of the + * course tags should move into the activity context. + */ + public function test_question_move_question_tags_to_new_context_course_to_activity_qtags() { + list($category, $course, $quiz, $qcat, $questions) = $this->setup_quiz_and_questions('course'); + $question1 = $questions[0]; + $question2 = $questions[1]; + $qcontext = context::instance_by_id($qcat->contextid); + $newcontext = context_module::instance($quiz->cmid); + + foreach ($questions as $question) { + $question->contextid = $qcat->contextid; + } + + // Create tags in the course context. + core_tag_tag::set_item_tags('core_question', 'question', $question1->id, $qcontext, ['foo']); + core_tag_tag::set_item_tags('core_question', 'question', $question2->id, $qcontext, ['foo']); + + question_move_question_tags_to_new_context($questions, $newcontext); + + foreach ($questions as $question) { + $tags = core_tag_tag::get_item_tags('core_question', 'question', $question->id); + + foreach ($tags as $tag) { + $this->assertEquals($newcontext->id, $tag->taginstancecontextid); + } + } + } + + /** + * When moving from a course context down into an activity context all of the + * course tags should move into the activity context. + */ + public function test_question_move_question_tags_to_new_context_activity_to_course_qtags() { + list($category, $course, $quiz, $qcat, $questions) = $this->setup_quiz_and_questions(); + $question1 = $questions[0]; + $question2 = $questions[1]; + $qcontext = context::instance_by_id($qcat->contextid); + $newcontext = context_course::instance($course->id); + + foreach ($questions as $question) { + $question->contextid = $qcat->contextid; + } + + // Create tags in the activity context. + core_tag_tag::set_item_tags('core_question', 'question', $question1->id, $qcontext, ['foo']); + core_tag_tag::set_item_tags('core_question', 'question', $question2->id, $qcontext, ['foo']); + + question_move_question_tags_to_new_context($questions, $newcontext); + + foreach ($questions as $question) { + $tags = core_tag_tag::get_item_tags('core_question', 'question', $question->id); + + foreach ($tags as $tag) { + $this->assertEquals($newcontext->id, $tag->taginstancecontextid); + } + } + } + + /** + * question_move_question_tags_to_new_context should update all of the + * question tags contexts when they are moving down (from system to course + * category context). + * + * Course tags within the new category context should remain while any course + * tags in course contexts that can no longer access the question should be + * deleted. + */ + public function test_question_move_question_tags_to_new_context_system_to_course_cat_with_orphaned_tags() { + list($category, $course, $quiz, $qcat, $questions) = $this->setup_quiz_and_questions('system'); + $question1 = $questions[0]; + $question2 = $questions[1]; + $othercategory = $this->getDataGenerator()->create_category(); + $othercourse = $this->getDataGenerator()->create_course(['category' => $othercategory->id]); + $qcontext = context::instance_by_id($qcat->contextid); + $newcontext = context_coursecat::instance($category->id); + $othercategorycontext = context_coursecat::instance($othercategory->id); + $coursecontext = context_course::instance($course->id); + $othercoursecontext = context_course::instance($othercourse->id); + + foreach ($questions as $question) { + $question->contextid = $qcat->contextid; + } + + // Create tags in the system context. + core_tag_tag::set_item_tags('core_question', 'question', $question1->id, $qcontext, ['foo']); + core_tag_tag::set_item_tags('core_question', 'question', $question2->id, $qcontext, ['foo']); + // Create tags in the child course context of the new context. + core_tag_tag::set_item_tags('core_question', 'question', $question1->id, $coursecontext, ['bar']); + core_tag_tag::set_item_tags('core_question', 'question', $question2->id, $coursecontext, ['bar']); + // Create tags in the other course context. These should be deleted when + // the question moves to the new course category context because this + // course belongs to a different category, which means it will no longer + // have access to the question. + core_tag_tag::set_item_tags('core_question', 'question', $question1->id, $othercoursecontext, ['delete']); + core_tag_tag::set_item_tags('core_question', 'question', $question2->id, $othercoursecontext, ['delete']); + + question_move_question_tags_to_new_context($questions, $newcontext); + + foreach ($questions as $question) { + $tags = core_tag_tag::get_item_tags('core_question', 'question', $question->id); + // Each question should have two tags, 'foo' and 'bar'. + $this->assertCount(2, $tags); + + // All of the tags should have their context id set to the new context + // (course category context). + foreach ($tags as $tag) { + $this->assertContains($tag->name, ['foo', 'bar']); + + if ($tag->name == 'foo') { + $this->assertEquals($newcontext->id, $tag->taginstancecontextid); + } else { + $this->assertEquals($coursecontext->id, $tag->taginstancecontextid); + } + } + } + } } diff --git a/question/question.php b/question/question.php index 984bc6cc58a..acbabcec399 100644 --- a/question/question.php +++ b/question/question.php @@ -120,7 +120,10 @@ if ($id) { if (!$question = $DB->get_record('question', array('id' => $id))) { print_error('questiondoesnotexist', 'question', $returnurl); } - get_question_options($question, true); + // We can use $COURSE here because it's been initialised as part of the + // require_login above. Passing it as the third parameter tells the function + // to filter the course tags by that course. + get_question_options($question, true, [$COURSE]); } else if ($categoryid && $qtype) { // only for creating new questions $question = new stdClass(); @@ -146,9 +149,13 @@ if ($id) { $qtypeobj = question_bank::get_qtype($question->qtype); -// Validate the question category. -if (!$category = $DB->get_record('question_categories', array('id' => $question->category))) { - print_error('categorydoesnotexist', 'question', $returnurl); +if (isset($question->categoryobject)) { + $category = $question->categoryobject; +} else { + // Validate the question category. + if (!$category = $DB->get_record('question_categories', array('id' => $question->category))) { + print_error('categorydoesnotexist', 'question', $returnurl); + } } // Check permissions @@ -261,10 +268,18 @@ if ($mform->is_cancelled()) { print_error('nopermissions', '', '', 'edit'); } } + $question = $qtypeobj->save_question($question, $fromform); if (isset($fromform->tags)) { + // If we have any question context level tags then set those tags now. core_tag_tag::set_item_tags('core_question', 'question', $question->id, - context::instance_by_id($contextid), $fromform->tags); + context::instance_by_id($contextid), $fromform->tags, 0); + } + + if (isset($fromform->coursetags)) { + // If we have and course context level tags then set those now. + core_tag_tag::set_item_tags('core_question', 'question', $question->id, + context_course::instance($fromform->courseid), $fromform->coursetags, 0); } // Purge this question from the cache. diff --git a/question/type/edit_question_form.php b/question/type/edit_question_form.php index 48f35dae6ea..c1e1666b28d 100644 --- a/question/type/edit_question_form.php +++ b/question/type/edit_question_form.php @@ -202,13 +202,7 @@ abstract class question_edit_form extends question_wizard_form { $this->definition_inner($mform); if (core_tag_tag::is_enabled('core_question', 'question')) { - $mform->addElement('header', 'tagsheader', get_string('tags')); - $mform->addElement('tags', 'tags', get_string('tags'), - array('itemtype' => 'question', 'component' => 'core_question')); - - if (!question_has_capability_on($this->question, 'tag')) { - $mform->hardFreeze('tags'); - } + $this->add_tag_fields($mform); } if (!empty($this->question->id)) { @@ -309,6 +303,53 @@ abstract class question_edit_form extends question_wizard_form { return $repeated; } + /** + * Add the tag and course tag fields to the mform. + * + * If the form is being built in a course context then add the field + * for course tags. + * + * If the question category doesn't belong to a course context or we + * aren't editing in a course context then add the tags element to allow + * tags to be added to the question category context. + * + * @param object $mform The form being built + */ + protected function add_tag_fields($mform) { + $hastagcapability = question_has_capability_on($this->question, 'tag'); + // Is the question category in a course context? + $qcontext = $this->categorycontext; + $qcoursecontext = $qcontext->get_course_context(false); + $iscourseoractivityquestion = !empty($qcoursecontext); + // Is the current context we're editing in a course context? + $editingcontext = $this->contexts->lowest(); + $editingcoursecontext = $editingcontext->get_course_context(false); + $iseditingcontextcourseoractivity = !empty($editingcoursecontext); + + $mform->addElement('header', 'tagsheader', get_string('tags')); + $mform->addElement('tags', 'tags', get_string('tags'), + array('itemtype' => 'question', 'component' => 'core_question')); + + if (!$hastagcapability) { + $mform->hardFreeze('tags'); + } + + if ($iseditingcontextcourseoractivity && !$iscourseoractivityquestion) { + // If the question is being edited in a course or activity context + // and the question isn't a course or activity level question then + // allow course tags to be added to the course. + $coursetagheader = get_string('questionformtagheader', 'core_question', + $editingcoursecontext->get_context_name(true)); + $mform->addElement('header', 'coursetagsheader', $coursetagheader); + $mform->addElement('tags', 'coursetags', get_string('tags'), + array('itemtype' => 'question', 'component' => 'core_question')); + + if (!$hastagcapability) { + $mform->hardFreeze('coursetags'); + } + } + } + /** * Add a set of form fields, obtained from get_per_answer_fields, to the form, * one for each existing answer, with some blanks for some new ones. diff --git a/tag/classes/area.php b/tag/classes/area.php index a51b82541d8..9e79fac970f 100644 --- a/tag/classes/area.php +++ b/tag/classes/area.php @@ -122,6 +122,32 @@ class core_tag_area { return null; } + /** + * Checks if the tag area allows items to be tagged in multiple different contexts. + * + * If true then it indicates that not all tag instance contexts must match the + * context of the item they are tagging. If false then all tag instance should + * match the context of the item they are tagging. + * + * Example use case for multi-context tagging: + * A question that exists in a course category context may be used by multiple + * child courses. The question tag area can allow tag instances to be created in + * multiple contexts which allows the tag API to tag the question at the course + * category context and then seperately in each of the child course contexts. + * + * @param string $component component responsible for tagging + * @param string $itemtype what is being tagged, for example, 'post', 'course', 'user', etc. + * @return bool + */ + public static function allows_tagging_in_multiple_contexts($component, $itemtype) { + $itemtypes = self::get_areas(); + if (isset($itemtypes[$itemtype][$component])) { + $config = $itemtypes[$itemtype][$component]; + return isset($config->multiplecontexts) ? $config->multiplecontexts : false; + } + return false; + } + /** * Returns the id of the tag collection that should be used for storing tags of this itemtype * @@ -217,7 +243,8 @@ class core_tag_area { 'tagcollid' => $record->tagcollid, 'callback' => $record->callback, 'callbackfile' => $record->callbackfile, - 'showstandard' => isset($record->showstandard) ? $record->showstandard : core_tag_tag::BOTH_STANDARD_AND_NOT)); + 'showstandard' => isset($record->showstandard) ? $record->showstandard : core_tag_tag::BOTH_STANDARD_AND_NOT, + 'multiplecontexts' => isset($record->multiplecontexts) ? $record->multiplecontexts : 0)); // Reset cache. cache::make('core', 'tags')->delete('tag_area'); @@ -233,7 +260,8 @@ class core_tag_area { global $DB; $data = array_intersect_key((array)$data, array('enabled' => 1, 'tagcollid' => 1, - 'callback' => 1, 'callbackfile' => 1, 'showstandard' => 1)); + 'callback' => 1, 'callbackfile' => 1, 'showstandard' => 1, + 'multiplecontexts' => 1)); foreach ($data as $key => $value) { if ($existing->$key == $value) { unset($data[$key]); @@ -310,6 +338,9 @@ class core_tag_area { if (!isset($record->callbackfile)) { $record->callbackfile = null; } + if (!isset($record->multiplecontexts)) { + $record->multiplecontexts = false; + } $itemtypes[$record->itemtype . ':' . $record->component] = $record; } } diff --git a/tag/classes/tag.php b/tag/classes/tag.php index bbbe35c3d4d..0c933161058 100644 --- a/tag/classes/tag.php +++ b/tag/classes/tag.php @@ -499,6 +499,64 @@ class core_tag_tag { $this->delete_instance_as_record($taginstance, true); } + /** + * Bulk delete all tag instances. + * + * @param stdClass[] $taginstances A list of tag_instance records to delete. Each + * record must also contain the name and rawname + * columns from the related tag record. + */ + public static function delete_instances_as_record(array $taginstances) { + global $DB; + + if (empty($taginstances)) { + return; + } + + $taginstanceids = array_map(function($taginstance) { + return $taginstance->id; + }, $taginstances); + // Now remove all the tag instances. + $DB->delete_records_list('tag_instance', 'id', $taginstanceids); + // Save the system context in case the 'contextid' column in the 'tag_instance' table is null. + $syscontextid = context_system::instance()->id; + // Loop through the tag instances and fire an 'tag_removed' event. + foreach ($taginstances as $taginstance) { + // We can not fire an event with 'null' as the contextid. + if (is_null($taginstance->contextid)) { + $taginstance->contextid = $syscontextid; + } + + // Trigger tag removed event. + \core\event\tag_removed::create_from_tag_instance($taginstance, $taginstance->name, + $taginstance->rawname, true)->trigger(); + } + } + + /** + * Bulk delete all tag instances by tag id. + * + * @param int[] $taginstanceids List of tag instance ids to be deleted. + */ + public static function delete_instances_by_id(array $taginstanceids) { + global $DB; + + if (empty($taginstanceids)) { + return; + } + + list($idsql, $params) = $DB->get_in_or_equal($taginstanceids); + $sql = "SELECT ti.*, t.name, t.rawname, t.isstandard + FROM {tag_instance} ti + JOIN {tag} t + ON ti.tagid = t.id + WHERE ti.id {$idsql}"; + + if ($taginstances = $DB->get_records_sql($sql, $params)) { + static::delete_instances_as_record($taginstances); + } + } + /** * Bulk delete all tag instances for a component or tag area * @@ -523,22 +581,9 @@ class core_tag_tag { $sql .= " AND ti.itemtype = :itemtype"; $params['itemtype'] = $itemtype; } - if ($taginstances = $DB->get_records_sql($sql, $params)) { - // Now remove all the tag instances. - $DB->delete_records('tag_instance', $params); - // Save the system context in case the 'contextid' column in the 'tag_instance' table is null. - $syscontextid = context_system::instance()->id; - // Loop through the tag instances and fire an 'tag_removed' event. - foreach ($taginstances as $taginstance) { - // We can not fire an event with 'null' as the contextid. - if (is_null($taginstance->contextid)) { - $taginstance->contextid = $syscontextid; - } - // Trigger tag removed event. - \core\event\tag_removed::create_from_tag_instance($taginstance, $taginstance->name, - $taginstance->rawname, true)->trigger(); - } + if ($taginstances = $DB->get_records_sql($sql, $params)) { + static::delete_instances_as_record($taginstances); } } @@ -557,7 +602,7 @@ class core_tag_tag { global $DB; $this->ensure_fields_exist(array('name', 'rawname'), 'add_instance'); - $taginstance = new StdClass; + $taginstance = new stdClass; $taginstance->tagid = $this->id; $taginstance->component = $component ? $component : ''; $taginstance->itemid = $itemid; @@ -592,6 +637,62 @@ class core_tag_tag { $DB->update_record('tag_instance', $data); } + /** + * Get the array of core_tag_tag objects associated with a list of items. + * + * Use {@link core_tag_tag::get_item_tags_array()} if you wish to get the same data as simple array. + * + * @param string $component component responsible for tagging. For BC it can be empty but in this case the + * query will be slow because DB index will not be used. + * @param string $itemtype type of the tagged item + * @param int[] $itemids + * @param int $standardonly wether to return only standard tags or any + * @param int $tiuserid tag instance user id, only needed for tag areas with user tagging + * @return core_tag_tag[] each object contains additional fields taginstanceid, taginstancecontextid and ordering + */ + public static function get_items_tags($component, $itemtype, $itemids, $standardonly = self::BOTH_STANDARD_AND_NOT, + $tiuserid = 0) { + global $DB; + + if (static::is_enabled($component, $itemtype) === false) { + // Tagging area is properly defined but not enabled - return empty array. + return array(); + } + + if (empty($itemids)) { + return array(); + } + + $standardonly = (int)$standardonly; // In case somebody passed bool. + + list($idsql, $params) = $DB->get_in_or_equal($itemids, SQL_PARAMS_NAMED); + // Note: if the fields in this query are changed, you need to do the same changes in core_tag_tag::get_correlated_tags(). + $sql = "SELECT ti.id AS taginstanceid, tg.id, tg.isstandard, tg.name, tg.rawname, tg.flag, + tg.tagcollid, ti.ordering, ti.contextid AS taginstancecontextid, ti.itemid + FROM {tag_instance} ti + JOIN {tag} tg ON tg.id = ti.tagid + WHERE ti.itemtype = :itemtype AND ti.itemid $idsql ". + ($component ? "AND ti.component = :component " : ""). + ($tiuserid ? "AND ti.tiuserid = :tiuserid " : ""). + (($standardonly == self::STANDARD_ONLY) ? "AND tg.isstandard = 1 " : ""). + (($standardonly == self::NOT_STANDARD_ONLY) ? "AND tg.isstandard = 0 " : ""). + "ORDER BY ti.ordering ASC, ti.id"; + + $params['itemtype'] = $itemtype; + $params['component'] = $component; + $params['tiuserid'] = $tiuserid; + + $records = $DB->get_records_sql($sql, $params); + $result = array(); + foreach ($itemids as $itemid) { + $result[$itemid] = []; + } + foreach ($records as $id => $record) { + $result[$record->itemid][$id] = new static($record); + } + return $result; + } + /** * Get the array of core_tag_tag objects associated with an item (instances). * @@ -607,39 +708,8 @@ class core_tag_tag { */ public static function get_item_tags($component, $itemtype, $itemid, $standardonly = self::BOTH_STANDARD_AND_NOT, $tiuserid = 0) { - global $DB; - - if (static::is_enabled($component, $itemtype) === false) { - // Tagging area is properly defined but not enabled - return empty array. - return array(); - } - - $standardonly = (int)$standardonly; // In case somebody passed bool. - - // Note: if the fields in this query are changed, you need to do the same changes in core_tag_tag::get_correlated_tags(). - $sql = "SELECT ti.id AS taginstanceid, tg.id, tg.isstandard, tg.name, tg.rawname, tg.flag, - tg.tagcollid, ti.ordering, ti.contextid AS taginstancecontextid - FROM {tag_instance} ti - JOIN {tag} tg ON tg.id = ti.tagid - WHERE ti.itemtype = :itemtype AND ti.itemid = :itemid ". - ($component ? "AND ti.component = :component " : ""). - ($tiuserid ? "AND ti.tiuserid = :tiuserid " : ""). - (($standardonly == self::STANDARD_ONLY) ? "AND tg.isstandard = 1 " : ""). - (($standardonly == self::NOT_STANDARD_ONLY) ? "AND tg.isstandard = 0 " : ""). - "ORDER BY ti.ordering ASC, ti.id"; - - $params = array(); - $params['itemtype'] = $itemtype; - $params['itemid'] = $itemid; - $params['component'] = $component; - $params['tiuserid'] = $tiuserid; - - $records = $DB->get_records_sql($sql, $params); - $result = array(); - foreach ($records as $id => $record) { - $result[$id] = new static($record); - } - return $result; + $tagobjects = static::get_items_tags($component, $itemtype, [$itemid], $standardonly, $tiuserid); + return empty($tagobjects) ? [] : $tagobjects[$itemid]; } /** @@ -703,12 +773,35 @@ class core_tag_tag { $tagobjects = array(); } + $allowmultiplecontexts = core_tag_area::allows_tagging_in_multiple_contexts($component, $itemtype); $currenttags = static::get_item_tags($component, $itemtype, $itemid, self::BOTH_STANDARD_AND_NOT, $tiuserid); + $taginstanceidstomovecontext = []; // For data coherence reasons, it's better to remove deleted tags // before adding new data: ordering could be duplicated. foreach ($currenttags as $currenttag) { - if (!array_key_exists($currenttag->name, $tagobjects)) { + $hasbeenrequested = array_key_exists($currenttag->name, $tagobjects); + $issamecontext = $currenttag->taginstancecontextid == $context->id; + + if ($allowmultiplecontexts) { + // If the tag area allows multiple contexts then we should only be + // managing tags in the given $context. All other tags can be ignored. + $shoulddelete = $issamecontext && !$hasbeenrequested; + } else { + // If the tag area only allows tag instances in a single context then + // all tags that aren't in the requested tags should be deleted, regardless + // of their context, if they are not part of the new set of tags. + $shoulddelete = !$hasbeenrequested; + // If the tag instance isn't in the correct context (legacy data) + // then we should take this opportunity to update it with the correct + // context id. + if (!$shoulddelete && !$issamecontext) { + $currenttag->taginstancecontextid = $context->id; + $taginstanceidstomovecontext[] = $currenttag->taginstanceid; + } + } + + if ($shoulddelete) { $taginstance = (object)array('id' => $currenttag->taginstanceid, 'itemtype' => $itemtype, 'itemid' => $itemid, 'contextid' => $currenttag->taginstancecontextid, 'tiuserid' => $tiuserid); @@ -716,11 +809,30 @@ class core_tag_tag { } } + if (!empty($taginstanceidstomovecontext)) { + static::change_instances_context($taginstanceidstomovecontext, $context); + } + $ordering = -1; foreach ($tagobjects as $name => $tag) { $ordering++; foreach ($currenttags as $currenttag) { - if (strval($currenttag->name) === strval($name)) { + $namesmatch = strval($currenttag->name) === strval($name); + + if ($allowmultiplecontexts) { + // If the tag area allows multiple contexts then we should only + // skip adding a new instance if the existing one is in the correct + // context. + $contextsmatch = $currenttag->taginstancecontextid == $context->id; + $shouldskipinstance = $namesmatch && $contextsmatch; + } else { + // The existing behaviour for single context tag areas is to + // skip adding a new instance regardless of whether the existing + // instance is in the same context as the provided $context. + $shouldskipinstance = $namesmatch; + } + + if ($shouldskipinstance) { if ($currenttag->ordering != $ordering) { $currenttag->update_instance_ordering($currenttag->taginstanceid, $ordering); } @@ -888,10 +1000,28 @@ class core_tag_tag { if ($newcontext instanceof context) { $newcontext = $newcontext->id; } + $DB->set_field_select('tag_instance', 'contextid', $newcontext, 'component = :component AND itemtype = :itemtype AND itemid ' . $sql, $params); } + /** + * Moves all of the specified tag instances into a new context. + * + * @param array $taginstanceids The list of tag instance ids that should be moved + * @param context $newcontext The context to move the tag instances into + */ + public static function change_instances_context(array $taginstanceids, context $newcontext) { + global $DB; + + if (empty($taginstanceids)) { + return; + } + + list($sql, $params) = $DB->get_in_or_equal($taginstanceids); + $DB->set_field_select('tag_instance', 'contextid', $newcontext->id, "id {$sql}", $params); + } + /** * Updates the information about the tag * @@ -1113,7 +1243,7 @@ class core_tag_tag { // This is (and has to) return the same fields as the query in core_tag_tag::get_item_tags(). $sql = "SELECT ti.id AS taginstanceid, tg.id, tg.isstandard, tg.name, tg.rawname, tg.flag, - tg.tagcollid, ti.ordering, ti.contextid AS taginstancecontextid + tg.tagcollid, ti.ordering, ti.contextid AS taginstancecontextid, ti.itemid FROM {tag} tg INNER JOIN {tag_instance} ti ON tg.id = ti.tagid WHERE tg.id $query AND tg.id <> ? AND tg.tagcollid = ? diff --git a/tag/tests/taglib_test.php b/tag/tests/taglib_test.php index 0a1a23cbde8..02d747a4969 100644 --- a/tag/tests/taglib_test.php +++ b/tag/tests/taglib_test.php @@ -1172,6 +1172,852 @@ class core_tag_taglib_testcase extends advanced_testcase { $this->assertEquals($expected, $actual); } + /** + * get_items_tags should return an empty array if the tag area is disabled. + */ + public function test_get_items_tags_disabled_component() { + global $CFG; + + $user1 = $this->getDataGenerator()->create_user(); + $context1 = context_user::instance($user1->id); + $component = 'core'; + $itemtype = 'user'; + $itemids = [$user1->id]; + + // User 1 tags: 'foo', 'bar'. + core_tag_tag::set_item_tags($component, $itemtype, $user1->id, $context1, ['foo']); + // This mimics disabling tags for a component. + $CFG->usetags = false; + $result = core_tag_tag::get_items_tags($component, $itemtype, $itemids); + $this->assertEmpty($result); + } + + /** + * get_items_tags should return an empty array if the tag item ids list + * is empty. + */ + public function test_get_items_tags_empty_itemids() { + $user1 = $this->getDataGenerator()->create_user(); + $context1 = context_user::instance($user1->id); + $component = 'core'; + $itemtype = 'user'; + + // User 1 tags: 'foo', 'bar'. + core_tag_tag::set_item_tags($component, $itemtype, $user1->id, $context1, ['foo']); + $result = core_tag_tag::get_items_tags($component, $itemtype, []); + $this->assertEmpty($result); + } + + /** + * get_items_tags should return an array indexed by the item ids with empty + * arrays as the values when the component or itemtype is unknown. + */ + public function test_get_items_tags_unknown_component_itemtype() { + $itemids = [1, 2, 3]; + $result = core_tag_tag::get_items_tags('someunknowncomponent', 'user', $itemids); + foreach ($itemids as $itemid) { + // Unknown component should return an array indexed by the item ids + // with empty arrays as the values. + $this->assertEmpty($result[$itemid]); + } + + $result = core_tag_tag::get_items_tags('core', 'someunknownitemtype', $itemids); + foreach ($itemids as $itemid) { + // Unknown item type should return an array indexed by the item ids + // with empty arrays as the values. + $this->assertEmpty($result[$itemid]); + } + } + + /** + * get_items_tags should return an array indexed by the item ids with empty + * arrays as the values for any item ids that don't have tag instances. + * + * Data setup: + * Users: 1, 2, 3 + * Tags: user 1 = ['foo', 'bar'] + * user 2 = ['baz', 'bop'] + * user 3 = [] + * + * Expected result: + * [ + * 1 => [ + * 1 => 'foo', + * 2 => 'bar' + * ], + * 2 => [ + * 3 => 'baz', + * 4 => 'bop' + * ], + * 3 => [] + * ] + */ + public function test_get_items_tags_missing_itemids() { + $user1 = $this->getDataGenerator()->create_user(); + $user2 = $this->getDataGenerator()->create_user(); + $user3 = $this->getDataGenerator()->create_user(); + $context1 = context_user::instance($user1->id); + $context2 = context_user::instance($user2->id); + $component = 'core'; + $itemtype = 'user'; + $itemids = [$user1->id, $user2->id, $user3->id]; + $expecteduser1tagnames = ['foo', 'bar']; + $expecteduser2tagnames = ['baz', 'bop']; + $expecteduser3tagnames = []; + + // User 1 tags: 'foo', 'bar'. + core_tag_tag::set_item_tags($component, $itemtype, $user1->id, $context1, $expecteduser1tagnames); + // User 2 tags: 'bar', 'baz'. + core_tag_tag::set_item_tags($component, $itemtype, $user2->id, $context2, $expecteduser2tagnames); + + $result = core_tag_tag::get_items_tags($component, $itemtype, $itemids); + $actualuser1tagnames = array_map(function($taginstance) { + return $taginstance->name; + }, $result[$user1->id]); + $actualuser2tagnames = array_map(function($taginstance) { + return $taginstance->name; + }, $result[$user2->id]); + $actualuser3tagnames = $result[$user3->id]; + + sort($expecteduser1tagnames); + sort($expecteduser2tagnames); + sort($actualuser1tagnames); + sort($actualuser2tagnames); + + $this->assertEquals($expecteduser1tagnames, $actualuser1tagnames); + $this->assertEquals($expecteduser2tagnames, $actualuser2tagnames); + $this->assertEquals($expecteduser3tagnames, $actualuser3tagnames); + } + + /** + * set_item_tags should remove any tags that aren't in the given list and should + * add any instances that are missing. + */ + public function test_set_item_tags_no_multiple_context_add_remove_instances() { + $tagnames = ['foo', 'bar', 'baz', 'bop']; + $collid = core_tag_collection::get_default(); + $tags = core_tag_tag::create_if_missing($collid, $tagnames); + $user1 = $this->getDataGenerator()->create_user(); + $context = context_user::instance($user1->id); + $component = 'core'; + $itemtype = 'user'; + $itemid = 1; + $tagareas = core_tag_area::get_areas(); + $tagarea = $tagareas[$itemtype][$component]; + $newtagnames = ['bar', 'baz', 'bop']; + + // Make sure the tag area doesn't allow multiple contexts. + core_tag_area::update($tagarea, ['multiplecontexts' => false]); + + // Create tag instances in separate contexts. + $this->add_tag_instance($tags['foo'], $component, $itemtype, $itemid, $context); + $this->add_tag_instance($tags['bar'], $component, $itemtype, $itemid, $context); + + core_tag_tag::set_item_tags($component, $itemtype, $itemid, $context, $newtagnames); + + $result = core_tag_tag::get_item_tags($component, $itemtype, $itemid); + $actualtagnames = array_map(function($record) { + return $record->name; + }, $result); + + sort($newtagnames); + sort($actualtagnames); + + // The list of tags should match the $newtagnames which means 'foo' + // should have been removed while 'baz' and 'bop' were added. 'bar' + // should remain as it was in the new list of tags. + $this->assertEquals($newtagnames, $actualtagnames); + } + + /** + * set_item_tags should set all of the tag instance context ids to the given + * context if the tag area for the items doesn't allow multiple contexts for + * the tag instances. + */ + public function test_set_item_tags_no_multiple_context_updates_context_of_instances() { + $tagnames = ['foo', 'bar']; + $collid = core_tag_collection::get_default(); + $tags = core_tag_tag::create_if_missing($collid, $tagnames); + $user1 = $this->getDataGenerator()->create_user(); + $user2 = $this->getDataGenerator()->create_user(); + $context1 = context_user::instance($user1->id); + $context2 = context_user::instance($user2->id); + $component = 'core'; + $itemtype = 'user'; + $itemid = 1; + $tagareas = core_tag_area::get_areas(); + $tagarea = $tagareas[$itemtype][$component]; + + // Make sure the tag area doesn't allow multiple contexts. + core_tag_area::update($tagarea, ['multiplecontexts' => false]); + + // Create tag instances in separate contexts. + $this->add_tag_instance($tags['foo'], $component, $itemtype, $itemid, $context1); + $this->add_tag_instance($tags['bar'], $component, $itemtype, $itemid, $context2); + + core_tag_tag::set_item_tags($component, $itemtype, $itemid, $context1, $tagnames); + + $result = core_tag_tag::get_item_tags($component, $itemtype, $itemid); + $this->assertCount(count($tagnames), $result); + + foreach ($result as $tag) { + // The core user tag area doesn't allow multiple contexts for tag instances + // so set_item_tags should have set all of the tag instance context ids + // to match $context1. + $this->assertEquals($context1->id, $tag->taginstancecontextid); + } + } + + /** + * set_item_tags should delete all of the tag instances that don't match + * the new set of tags, regardless of the context that the tag instance + * is in. + */ + public function test_set_item_tags_no_multiple_contex_deletes_old_instancest() { + $tagnames = ['foo', 'bar', 'baz', 'bop']; + $collid = core_tag_collection::get_default(); + $tags = core_tag_tag::create_if_missing($collid, $tagnames); + $user1 = $this->getDataGenerator()->create_user(); + $user2 = $this->getDataGenerator()->create_user(); + $context1 = context_user::instance($user1->id); + $context2 = context_user::instance($user2->id); + $component = 'core'; + $itemtype = 'user'; + $itemid = 1; + $expectedtagnames = ['foo', 'baz']; + $tagareas = core_tag_area::get_areas(); + $tagarea = $tagareas[$itemtype][$component]; + + // Make sure the tag area doesn't allow multiple contexts. + core_tag_area::update($tagarea, ['multiplecontexts' => false]); + + // Create tag instances in separate contexts. + $this->add_tag_instance($tags['foo'], $component, $itemtype, $itemid, $context1); + $this->add_tag_instance($tags['bar'], $component, $itemtype, $itemid, $context1); + $this->add_tag_instance($tags['baz'], $component, $itemtype, $itemid, $context2); + $this->add_tag_instance($tags['bop'], $component, $itemtype, $itemid, $context2); + + core_tag_tag::set_item_tags($component, $itemtype, $itemid, $context1, $expectedtagnames); + + $result = core_tag_tag::get_item_tags($component, $itemtype, $itemid); + $actualtagnames = array_map(function($record) { + return $record->name; + }, $result); + + sort($expectedtagnames); + sort($actualtagnames); + + // The list of tags should match the $expectedtagnames. + $this->assertEquals($expectedtagnames, $actualtagnames); + + foreach ($result as $tag) { + // The core user tag area doesn't allow multiple contexts for tag instances + // so set_item_tags should have set all of the tag instance context ids + // to match $context1. + $this->assertEquals($context1->id, $tag->taginstancecontextid); + } + } + + /** + * set_item_tags should not change tag instances in a different context to the one + * it's opertating on if the tag area allows instances from multiple contexts. + */ + public function test_set_item_tags_allow_multiple_context_doesnt_update_context() { + global $DB; + $tagnames = ['foo', 'bar', 'bop']; + $collid = core_tag_collection::get_default(); + $tags = core_tag_tag::create_if_missing($collid, $tagnames); + $user1 = $this->getDataGenerator()->create_user(); + $user2 = $this->getDataGenerator()->create_user(); + $context1 = context_user::instance($user1->id); + $context2 = context_user::instance($user2->id); + $component = 'core'; + $itemtype = 'user'; + $itemid = 1; + $tagareas = core_tag_area::get_areas(); + $tagarea = $tagareas[$itemtype][$component]; + + // Make sure the tag area allows multiple contexts. + core_tag_area::update($tagarea, ['multiplecontexts' => true]); + + // Create tag instances in separate contexts. + $this->add_tag_instance($tags['foo'], $component, $itemtype, $itemid, $context1); + $this->add_tag_instance($tags['bar'], $component, $itemtype, $itemid, $context2); + + // Set the list of tags for $context1. This includes a tag that already exists + // in that context and a new tag. There is another tag, 'bar', that exists in a + // different context ($context2) that should be ignored. + core_tag_tag::set_item_tags($component, $itemtype, $itemid, $context1, ['foo', 'bop']); + + $result = core_tag_tag::get_item_tags($component, $itemtype, $itemid); + $actualtagnames = array_map(function($record) { + return $record->name; + }, $result); + + sort($tagnames); + sort($actualtagnames); + // The list of tags should match the $tagnames. + $this->assertEquals($tagnames, $actualtagnames); + + foreach ($result as $tag) { + if ($tag->name == 'bar') { + // The tag instance for 'bar' should have been left untouched + // because it was in a different context. + $this->assertEquals($context2->id, $tag->taginstancecontextid); + } else { + $this->assertEquals($context1->id, $tag->taginstancecontextid); + } + } + } + + /** + * set_item_tags should delete all of the tag instances that don't match + * the new set of tags only in the same context if the tag area allows + * multiple contexts. + */ + public function test_set_item_tags_allow_multiple_context_deletes_instances_in_same_context() { + $tagnames = ['foo', 'bar', 'baz', 'bop']; + $collid = core_tag_collection::get_default(); + $tags = core_tag_tag::create_if_missing($collid, $tagnames); + $user1 = $this->getDataGenerator()->create_user(); + $user2 = $this->getDataGenerator()->create_user(); + $context1 = context_user::instance($user1->id); + $context2 = context_user::instance($user2->id); + $component = 'core'; + $itemtype = 'user'; + $itemid = 1; + $expectedtagnames = ['foo', 'bar', 'bop']; + $tagareas = core_tag_area::get_areas(); + $tagarea = $tagareas[$itemtype][$component]; + + // Make sure the tag area allows multiple contexts. + core_tag_area::update($tagarea, ['multiplecontexts' => true]); + + // Create tag instances in separate contexts. + $this->add_tag_instance($tags['foo'], $component, $itemtype, $itemid, $context1); + $this->add_tag_instance($tags['bar'], $component, $itemtype, $itemid, $context1); + $this->add_tag_instance($tags['baz'], $component, $itemtype, $itemid, $context1); + $this->add_tag_instance($tags['bop'], $component, $itemtype, $itemid, $context2); + + core_tag_tag::set_item_tags($component, $itemtype, $itemid, $context1, ['foo', 'bar']); + + $result = core_tag_tag::get_item_tags($component, $itemtype, $itemid); + $actualtagnames = array_map(function($record) { + return $record->name; + }, $result); + + sort($expectedtagnames); + sort($actualtagnames); + + // The list of tags should match the $expectedtagnames, which includes the + // tag 'bop' because it was in a different context to the one being set + // even though it wasn't in the new set of tags. + $this->assertEquals($expectedtagnames, $actualtagnames); + } + + /** + * set_item_tags should allow multiple instances of the same tag in different + * contexts if the tag area allows multiple contexts. + */ + public function test_set_item_tags_allow_multiple_context_same_tag_multiple_contexts() { + $tagnames = ['foo']; + $collid = core_tag_collection::get_default(); + $tags = core_tag_tag::create_if_missing($collid, $tagnames); + $user1 = $this->getDataGenerator()->create_user(); + $user2 = $this->getDataGenerator()->create_user(); + $context1 = context_user::instance($user1->id); + $context2 = context_user::instance($user2->id); + $component = 'core'; + $itemtype = 'user'; + $itemid = 1; + $expectedtagnames = ['foo', 'bar', 'bop']; + $tagareas = core_tag_area::get_areas(); + $tagarea = $tagareas[$itemtype][$component]; + + // Make sure the tag area allows multiple contexts. + core_tag_area::update($tagarea, ['multiplecontexts' => true]); + + // Create first instance of 'foo' in $context1. + $this->add_tag_instance($tags['foo'], $component, $itemtype, $itemid, $context1); + + core_tag_tag::set_item_tags($component, $itemtype, $itemid, $context2, ['foo']); + + $result = core_tag_tag::get_item_tags($component, $itemtype, $itemid); + $tagsbycontext = array_reduce($result, function($carry, $tag) { + $contextid = $tag->taginstancecontextid; + if (isset($carry[$contextid])) { + $carry[$contextid][] = $tag; + } else { + $carry[$contextid] = [$tag]; + } + return $carry; + }, []); + + // The result should be two tag instances of 'foo' in each of the + // two contexts, $context1 and $context2. + $this->assertCount(1, $tagsbycontext[$context1->id]); + $this->assertCount(1, $tagsbycontext[$context2->id]); + $this->assertEquals('foo', $tagsbycontext[$context1->id][0]->name); + $this->assertEquals('foo', $tagsbycontext[$context2->id][0]->name); + } + + /** + * delete_instances_as_record with an empty set of instances should do nothing. + */ + public function test_delete_instances_as_record_empty_set() { + $user = $this->getDataGenerator()->create_user(); + $context = context_user::instance($user->id); + $component = 'core'; + $itemtype = 'user'; + $itemid = 1; + + core_tag_tag::set_item_tags($component, $itemtype, $itemid, $context, ['foo']); + // This shouldn't error. + core_tag_tag::delete_instances_as_record([]); + + $tags = core_tag_tag::get_item_tags($component, $itemtype, $itemid); + // We should still have one tag. + $this->assertCount(1, $tags); + } + + /** + * delete_instances_as_record with an instance that doesn't exist should do + * nothing. + */ + public function test_delete_instances_as_record_missing_set() { + $tagnames = ['foo']; + $collid = core_tag_collection::get_default(); + $tags = core_tag_tag::create_if_missing($collid, $tagnames); + $user = $this->getDataGenerator()->create_user(); + $context = context_user::instance($user->id); + $component = 'core'; + $itemtype = 'user'; + $itemid = 1; + + $taginstance = $this->add_tag_instance($tags['foo'], $component, $itemtype, $itemid, $context); + $taginstance->id++; + + // Delete an instance that doesn't exist should do nothing. + core_tag_tag::delete_instances_as_record([$taginstance]); + + $tags = core_tag_tag::get_item_tags($component, $itemtype, $itemid); + // We should still have one tag. + $this->assertCount(1, $tags); + } + + /** + * delete_instances_as_record with a list of all tag instances should + * leave no tags left. + */ + public function test_delete_instances_as_record_whole_set() { + $tagnames = ['foo']; + $collid = core_tag_collection::get_default(); + $tags = core_tag_tag::create_if_missing($collid, $tagnames); + $user = $this->getDataGenerator()->create_user(); + $context = context_user::instance($user->id); + $component = 'core'; + $itemtype = 'user'; + $itemid = 1; + + $taginstance = $this->add_tag_instance($tags['foo'], $component, $itemtype, $itemid, $context); + + core_tag_tag::delete_instances_as_record([$taginstance]); + + $tags = core_tag_tag::get_item_tags($component, $itemtype, $itemid); + // There should be no tags left. + $this->assertEmpty($tags); + } + + /** + * delete_instances_as_record with a list of only some tag instances should + * delete only the given tag instances and leave other tag instances. + */ + public function test_delete_instances_as_record_partial_set() { + $tagnames = ['foo', 'bar']; + $collid = core_tag_collection::get_default(); + $tags = core_tag_tag::create_if_missing($collid, $tagnames); + $user = $this->getDataGenerator()->create_user(); + $context = context_user::instance($user->id); + $component = 'core'; + $itemtype = 'user'; + $itemid = 1; + + $taginstance = $this->add_tag_instance($tags['foo'], $component, $itemtype, $itemid, $context); + $this->add_tag_instance($tags['bar'], $component, $itemtype, $itemid, $context); + + core_tag_tag::delete_instances_as_record([$taginstance]); + + $tags = core_tag_tag::get_item_tags($component, $itemtype, $itemid); + // We should be left with a single tag, 'bar'. + $this->assertCount(1, $tags); + $tag = array_shift($tags); + $this->assertEquals('bar', $tag->name); + } + + /** + * delete_instances_by_id with an empty set of ids should do nothing. + */ + public function test_delete_instances_by_id_empty_set() { + $user = $this->getDataGenerator()->create_user(); + $context = context_user::instance($user->id); + $component = 'core'; + $itemtype = 'user'; + $itemid = 1; + + core_tag_tag::set_item_tags($component, $itemtype, $itemid, $context, ['foo']); + // This shouldn't error. + core_tag_tag::delete_instances_by_id([]); + + $tags = core_tag_tag::get_item_tags($component, $itemtype, $itemid); + // We should still have one tag. + $this->assertCount(1, $tags); + } + + /** + * delete_instances_by_id with an id that doesn't exist should do + * nothing. + */ + public function test_delete_instances_by_id_missing_set() { + $tagnames = ['foo']; + $collid = core_tag_collection::get_default(); + $tags = core_tag_tag::create_if_missing($collid, $tagnames); + $user = $this->getDataGenerator()->create_user(); + $context = context_user::instance($user->id); + $component = 'core'; + $itemtype = 'user'; + $itemid = 1; + + $taginstance = $this->add_tag_instance($tags['foo'], $component, $itemtype, $itemid, $context); + + // Delete an instance that doesn't exist should do nothing. + core_tag_tag::delete_instances_by_id([$taginstance->id + 1]); + + $tags = core_tag_tag::get_item_tags($component, $itemtype, $itemid); + // We should still have one tag. + $this->assertCount(1, $tags); + } + + /** + * delete_instances_by_id with a list of all tag instance ids should + * leave no tags left. + */ + public function test_delete_instances_by_id_whole_set() { + $tagnames = ['foo']; + $collid = core_tag_collection::get_default(); + $tags = core_tag_tag::create_if_missing($collid, $tagnames); + $user = $this->getDataGenerator()->create_user(); + $context = context_user::instance($user->id); + $component = 'core'; + $itemtype = 'user'; + $itemid = 1; + + $taginstance = $this->add_tag_instance($tags['foo'], $component, $itemtype, $itemid, $context); + + core_tag_tag::delete_instances_by_id([$taginstance->id]); + + $tags = core_tag_tag::get_item_tags($component, $itemtype, $itemid); + // There should be no tags left. + $this->assertEmpty($tags); + } + + /** + * delete_instances_by_id with a list of only some tag instance ids should + * delete only the given tag instance ids and leave other tag instances. + */ + public function test_delete_instances_by_id_partial_set() { + $tagnames = ['foo', 'bar']; + $collid = core_tag_collection::get_default(); + $tags = core_tag_tag::create_if_missing($collid, $tagnames); + $user = $this->getDataGenerator()->create_user(); + $context = context_user::instance($user->id); + $component = 'core'; + $itemtype = 'user'; + $itemid = 1; + + $taginstance = $this->add_tag_instance($tags['foo'], $component, $itemtype, $itemid, $context); + $this->add_tag_instance($tags['bar'], $component, $itemtype, $itemid, $context); + + core_tag_tag::delete_instances_by_id([$taginstance->id]); + + $tags = core_tag_tag::get_item_tags($component, $itemtype, $itemid); + // We should be left with a single tag, 'bar'. + $this->assertCount(1, $tags); + $tag = array_shift($tags); + $this->assertEquals('bar', $tag->name); + } + + /** + * delete_instances should delete all tag instances for a component if given + * only the component as a parameter. + */ + public function test_delete_instances_with_component() { + global $DB; + + $tagnames = ['foo', 'bar']; + $collid = core_tag_collection::get_default(); + $tags = core_tag_tag::create_if_missing($collid, $tagnames); + $user = $this->getDataGenerator()->create_user(); + $context = context_user::instance($user->id); + $component = 'core'; + $itemtype1 = 'user'; + $itemtype2 = 'course'; + $itemid = 1; + + // Add 2 tag instances in the same $component but with different item types. + $this->add_tag_instance($tags['foo'], $component, $itemtype1, $itemid, $context); + $this->add_tag_instance($tags['bar'], $component, $itemtype2, $itemid, $context); + + // Delete all tag instances for the component. + core_tag_tag::delete_instances($component); + + $taginstances = $DB->get_records_sql('SELECT * FROM {tag_instance} WHERE component = ?', [$component]); + // Both tag instances from the $component should have been deleted even though + // they are in different item types. + $this->assertEmpty($taginstances); + } + + /** + * delete_instances should delete all tag instances for a component if given + * only the component as a parameter. + */ + public function test_delete_instances_with_component_and_itemtype() { + global $DB; + + $tagnames = ['foo', 'bar']; + $collid = core_tag_collection::get_default(); + $tags = core_tag_tag::create_if_missing($collid, $tagnames); + $user = $this->getDataGenerator()->create_user(); + $context = context_user::instance($user->id); + $component = 'core'; + $itemtype1 = 'user'; + $itemtype2 = 'course'; + $itemid = 1; + + // Add 2 tag instances in the same $component but with different item types. + $this->add_tag_instance($tags['foo'], $component, $itemtype1, $itemid, $context); + $this->add_tag_instance($tags['bar'], $component, $itemtype2, $itemid, $context); + + // Delete all tag instances for the component and itemtype. + core_tag_tag::delete_instances($component, $itemtype1); + + $taginstances = $DB->get_records_sql('SELECT * FROM {tag_instance} WHERE component = ?', [$component]); + // Only the tag instances for $itemtype1 should have been deleted. We + // should still be left with the instance for 'bar'. + $this->assertCount(1, $taginstances); + $taginstance = array_shift($taginstances); + $this->assertEquals($itemtype2, $taginstance->itemtype); + $this->assertEquals($tags['bar']->id, $taginstance->tagid); + } + + /** + * delete_instances should delete all tag instances for a component in a context + * if given both the component and context id as parameters. + */ + public function test_delete_instances_with_component_and_context() { + global $DB; + + $tagnames = ['foo', 'bar', 'baz']; + $collid = core_tag_collection::get_default(); + $tags = core_tag_tag::create_if_missing($collid, $tagnames); + $user1 = $this->getDataGenerator()->create_user(); + $user2 = $this->getDataGenerator()->create_user(); + $context1 = context_user::instance($user1->id); + $context2 = context_user::instance($user2->id); + $component = 'core'; + $itemtype1 = 'user'; + $itemtype2 = 'course'; + $itemid = 1; + + // Add 3 tag instances in the same $component but with different contexts. + $this->add_tag_instance($tags['foo'], $component, $itemtype1, $itemid, $context1); + $this->add_tag_instance($tags['bar'], $component, $itemtype2, $itemid, $context1); + $this->add_tag_instance($tags['baz'], $component, $itemtype2, $itemid, $context2); + + // Delete all tag instances for the component and context. + core_tag_tag::delete_instances($component, null, $context1->id); + + $taginstances = $DB->get_records_sql('SELECT * FROM {tag_instance} WHERE component = ?', [$component]); + // Only the tag instances for $context1 should have been deleted. We + // should still be left with the instance for 'baz'. + $this->assertCount(1, $taginstances); + $taginstance = array_shift($taginstances); + $this->assertEquals($context2->id, $taginstance->contextid); + $this->assertEquals($tags['baz']->id, $taginstance->tagid); + } + + /** + * delete_instances should delete all tag instances for a component, item type + * and context if given the component, itemtype, and context id as parameters. + */ + public function test_delete_instances_with_component_and_itemtype_and_context() { + global $DB; + + $tagnames = ['foo', 'bar', 'baz']; + $collid = core_tag_collection::get_default(); + $tags = core_tag_tag::create_if_missing($collid, $tagnames); + $user1 = $this->getDataGenerator()->create_user(); + $user2 = $this->getDataGenerator()->create_user(); + $context1 = context_user::instance($user1->id); + $context2 = context_user::instance($user2->id); + $component = 'core'; + $itemtype1 = 'user'; + $itemtype2 = 'course'; + $itemid = 1; + + // Add 3 tag instances in the same $component but with different contexts. + $this->add_tag_instance($tags['foo'], $component, $itemtype1, $itemid, $context1); + $this->add_tag_instance($tags['bar'], $component, $itemtype2, $itemid, $context1); + $this->add_tag_instance($tags['baz'], $component, $itemtype2, $itemid, $context2); + + // Delete all tag instances for the component and context. + core_tag_tag::delete_instances($component, $itemtype2, $context1->id); + + $taginstances = $DB->get_records_sql('SELECT * FROM {tag_instance} WHERE component = ?', [$component]); + // Only the tag instances for $itemtype2 in $context1 should have been + // deleted. We should still be left with the instance for 'foo' and 'baz'. + $this->assertCount(2, $taginstances); + $fooinstances = array_filter($taginstances, function($instance) use ($tags) { + return $instance->tagid == $tags['foo']->id; + }); + $fooinstance = array_shift($fooinstances); + $bazinstances = array_filter($taginstances, function($instance) use ($tags) { + return $instance->tagid == $tags['baz']->id; + }); + $bazinstance = array_shift($bazinstances); + $this->assertNotEmpty($fooinstance); + $this->assertNotEmpty($bazinstance); + $this->assertEquals($context1->id, $fooinstance->contextid); + $this->assertEquals($context2->id, $bazinstance->contextid); + } + + /** + * change_instances_context should not change any existing instance contexts + * if not given any instance ids. + */ + public function test_change_instances_context_empty_set() { + global $DB; + + $tagnames = ['foo']; + $collid = core_tag_collection::get_default(); + $tags = core_tag_tag::create_if_missing($collid, $tagnames); + $user1 = $this->getDataGenerator()->create_user(); + $user2 = $this->getDataGenerator()->create_user(); + $context1 = context_user::instance($user1->id); + $context2 = context_user::instance($user2->id); + $component = 'core'; + $itemtype = 'user'; + $itemid = 1; + + $this->add_tag_instance($tags['foo'], $component, $itemtype, $itemid, $context1); + + core_tag_tag::change_instances_context([], $context2); + + $taginstances = $DB->get_records_sql('SELECT * FROM {tag_instance}'); + // The existing tag instance should not have changed. + $this->assertCount(1, $taginstances); + $taginstance = array_shift($taginstances); + $this->assertEquals($context1->id, $taginstance->contextid); + } + + /** + * change_instances_context should only change the context of the given ids. + */ + public function test_change_instances_context_partial_set() { + global $DB; + + $tagnames = ['foo', 'bar']; + $collid = core_tag_collection::get_default(); + $tags = core_tag_tag::create_if_missing($collid, $tagnames); + $user1 = $this->getDataGenerator()->create_user(); + $user2 = $this->getDataGenerator()->create_user(); + $context1 = context_user::instance($user1->id); + $context2 = context_user::instance($user2->id); + $component = 'core'; + $itemtype = 'user'; + $itemid = 1; + + $fooinstance = $this->add_tag_instance($tags['foo'], $component, $itemtype, $itemid, $context1); + $barinstance = $this->add_tag_instance($tags['bar'], $component, $itemtype, $itemid, $context1); + + core_tag_tag::change_instances_context([$fooinstance->id], $context2); + + // Reload the record. + $fooinstance = $DB->get_record('tag_instance', ['id' => $fooinstance->id]); + $barinstance = $DB->get_record('tag_instance', ['id' => $barinstance->id]); + // Tag 'foo' context should be updated. + $this->assertEquals($context2->id, $fooinstance->contextid); + // Tag 'bar' context should not be changed. + $this->assertEquals($context1->id, $barinstance->contextid); + } + + /** + * change_instances_context should change multiple items from multiple contexts. + */ + public function test_change_instances_context_multiple_contexts() { + global $DB; + + $tagnames = ['foo', 'bar']; + $collid = core_tag_collection::get_default(); + $tags = core_tag_tag::create_if_missing($collid, $tagnames); + $user1 = $this->getDataGenerator()->create_user(); + $user2 = $this->getDataGenerator()->create_user(); + $user3 = $this->getDataGenerator()->create_user(); + $context1 = context_user::instance($user1->id); + $context2 = context_user::instance($user2->id); + $context3 = context_user::instance($user3->id); + $component = 'core'; + $itemtype = 'user'; + $itemid = 1; + + // Two instances in different contexts. + $fooinstance = $this->add_tag_instance($tags['foo'], $component, $itemtype, $itemid, $context1); + $barinstance = $this->add_tag_instance($tags['bar'], $component, $itemtype, $itemid, $context2); + + core_tag_tag::change_instances_context([$fooinstance->id, $barinstance->id], $context3); + + // Reload the record. + $fooinstance = $DB->get_record('tag_instance', ['id' => $fooinstance->id]); + $barinstance = $DB->get_record('tag_instance', ['id' => $barinstance->id]); + // Tag 'foo' context should be updated. + $this->assertEquals($context3->id, $fooinstance->contextid); + // Tag 'bar' context should be updated. + $this->assertEquals($context3->id, $barinstance->contextid); + // There shouldn't be any tag instances left in $context1. + $context1records = $DB->get_records('tag_instance', ['contextid' => $context1->id]); + $this->assertEmpty($context1records); + // There shouldn't be any tag instances left in $context2. + $context2records = $DB->get_records('tag_instance', ['contextid' => $context2->id]); + $this->assertEmpty($context2records); + } + + /** + * change_instances_context moving an instance from one context into a context + * that already has an instance of that tag should throw an exception. + */ + public function test_change_instances_context_conflicting_instances() { + global $DB; + + $tagnames = ['foo']; + $collid = core_tag_collection::get_default(); + $tags = core_tag_tag::create_if_missing($collid, $tagnames); + $user1 = $this->getDataGenerator()->create_user(); + $user2 = $this->getDataGenerator()->create_user(); + $context1 = context_user::instance($user1->id); + $context2 = context_user::instance($user2->id); + $component = 'core'; + $itemtype = 'user'; + $itemid = 1; + + // Two instances of 'foo' in different contexts. + $fooinstance1 = $this->add_tag_instance($tags['foo'], $component, $itemtype, $itemid, $context1); + $fooinstance2 = $this->add_tag_instance($tags['foo'], $component, $itemtype, $itemid, $context2); + + // There is already an instance of 'foo' in $context2 so the code + // should throw an exception when we try to move another instance there. + $this->expectException('Exception'); + core_tag_tag::change_instances_context([$fooinstance1->id], $context2); + } + /** * Help method to return sorted array of names of correlated tags to use for assertions * @param core_tag $tag @@ -1184,4 +2030,29 @@ class core_tag_taglib_testcase extends advanced_testcase { sort($rv); return array_values($rv); } + + /** + * Add a tag instance. + * + * @param core_tag_tag $tag + * @param string $component + * @param string $itemtype + * @param int $itemid + * @param context $context + * @return stdClass + */ + protected function add_tag_instance(core_tag_tag $tag, $component, $itemtype, $itemid, $context) { + global $DB; + $record = (array) $tag->to_object(); + $record['tagid'] = $record['id']; + $record['component'] = $component; + $record['itemtype'] = $itemtype; + $record['itemid'] = $itemid; + $record['contextid'] = $context->id; + $record['tiuserid'] = 0; + $record['ordering'] = 0; + $record['timecreated'] = time(); + $record['id'] = $DB->insert_record('tag_instance', $record); + return (object) $record; + } } diff --git a/version.php b/version.php index 641e28593ec..e5fdaa815ab 100644 --- a/version.php +++ b/version.php @@ -29,7 +29,7 @@ defined('MOODLE_INTERNAL') || die(); -$version = 2018022800.01; // YYYYMMDD = weekly release date of this DEV branch. +$version = 2018022800.03; // YYYYMMDD = weekly release date of this DEV branch. // RR = release increments - 00 in DEV branches. // .XX = incremental changes.