diff --git a/openedx_tagging/core/tagging/api.py b/openedx_tagging/core/tagging/api.py index 59438f183..b6e268123 100644 --- a/openedx_tagging/core/tagging/api.py +++ b/openedx_tagging/core/tagging/api.py @@ -10,7 +10,7 @@ Please look at the models.py file for more information about the kinds of data are stored in this app. """ -from typing import List, Type, Union +from typing import Generator, List, Type, Union from django.db.models import QuerySet from django.utils.translation import gettext_lazy as _ @@ -19,17 +19,18 @@ def create_taxonomy( - name, - description=None, + name: str, + description: str = None, enabled=True, required=False, allow_multiple=False, allow_free_text=False, + object_tag_class: Type = None, ) -> Taxonomy: """ Creates, saves, and returns a new Taxonomy with the given attributes. """ - return Taxonomy.objects.create( + taxonomy = Taxonomy( name=name, description=description, enabled=enabled, @@ -37,6 +38,10 @@ def create_taxonomy( allow_multiple=allow_multiple, allow_free_text=allow_free_text, ) + if object_tag_class: + taxonomy.object_tag_class = object_tag_class + taxonomy.save() + return taxonomy def get_taxonomies(enabled=True) -> QuerySet: @@ -86,18 +91,33 @@ def resync_object_tags(object_tags: QuerySet = None) -> int: def get_object_tags( - taxonomy: Taxonomy, object_id: str, object_type: str, valid_only=True -) -> List[ObjectTag]: + object_id: str, object_type: str = None, taxonomy: Taxonomy = None, valid_only=True +) -> Generator[ObjectTag, None, None]: """ - Returns a list of tags for a given taxonomy + content. + Generates a list of object tags for a given object. + + Pass taxonomy to limit the returned object_tags to a specific taxonomy. Pass valid_only=False when displaying tags to content authors, so they can see invalid tags too. - Invalid tags will likely be hidden from learners. + Invalid tags will (probably) be hidden from learners. """ - tags = taxonomy.objecttag_set.filter( - object_id=object_id, object_type=object_type + tags = ObjectTag.objects.filter( + object_id=object_id, ).order_by("id") - return [tag for tag in tags if not valid_only or taxonomy.validate_object_tag(tag)] + if object_type: + tags = tags.filter(object_type=object_type) + if taxonomy: + tags = tags.filter(_taxonomy=taxonomy) + + for tag in tags: + # We can only validate tags with taxonomies, because we need the object_tag_class + if valid_only: + if tag._taxonomy_id: + object_tag = tag.taxonomy.object_tag_class().copy(tag) + if object_tag.is_valid(): + yield object_tag + else: + yield tag def tag_object( diff --git a/openedx_tagging/core/tagging/migrations/0004_objecttagclass.py b/openedx_tagging/core/tagging/migrations/0004_objecttagclass.py new file mode 100644 index 000000000..21f4e38de --- /dev/null +++ b/openedx_tagging/core/tagging/migrations/0004_objecttagclass.py @@ -0,0 +1,41 @@ +# Generated by Django 3.2.19 on 2023-07-04 07:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("oel_tagging", "0003_objecttag__taxonomy"), + ] + + operations = [ + migrations.CreateModel( + name="OpenObjectTag", + fields=[], + options={ + "proxy": True, + "indexes": [], + "constraints": [], + }, + bases=("oel_tagging.objecttag",), + ), + migrations.AddField( + model_name="taxonomy", + name="_object_tag_class", + field=models.CharField( + help_text="Overrides the default BaseObjectTag subclass associated with this taxonomy.Must be a fully-qualified module and class name.", + max_length=255, + null=True, + ), + ), + migrations.CreateModel( + name="ClosedObjectTag", + fields=[], + options={ + "proxy": True, + "indexes": [], + "constraints": [], + }, + bases=("oel_tagging.openobjecttag",), + ), + ] diff --git a/openedx_tagging/core/tagging/models.py b/openedx_tagging/core/tagging/models.py index acee7fb3f..633d8558e 100644 --- a/openedx_tagging/core/tagging/models.py +++ b/openedx_tagging/core/tagging/models.py @@ -2,6 +2,7 @@ from typing import List, Type from django.db import models +from django.utils.module_loading import import_string from django.utils.translation import gettext_lazy as _ from model_utils.managers import InheritanceManager @@ -73,7 +74,7 @@ def __str__(self): """ User-facing string representation of a Tag. """ - return f"Tag ({self.id}) {self.value}" + return f"<{self.__class__.__name__}> ({self.id}) {self.value}" def get_lineage(self) -> Lineage: """ @@ -101,7 +102,7 @@ class TaxonomyManager(InheritanceManager): class Taxonomy(models.Model): """ - Represents a namespace and rules for a group of tags which can be applied to a particular Open edX object. + Represents a namespace and rules for a group of tags. """ objects = TaxonomyManager() @@ -144,6 +145,14 @@ class Taxonomy(models.Model): "Indicates that tags in this taxonomy need not be predefined; authors may enter their own tag values." ), ) + _object_tag_class = models.CharField( + null=True, + max_length=255, + help_text=_( + "Overrides the default BaseObjectTag subclass associated with this taxonomy." + "Must be a fully-qualified module and class name.", + ), + ) class Meta: verbose_name_plural = "Taxonomies" @@ -158,7 +167,7 @@ def __str__(self): """ User-facing string representation of a Taxonomy. """ - return f"<{self.__class__.__name__}> {self.name}" + return f"<{self.__class__.__name__}> ({self.id}) {self.name}" @property def system_defined(self) -> bool: @@ -171,6 +180,39 @@ def system_defined(self) -> bool: """ return False + @property + def object_tag_class(self) -> Type: + """ + Returns the BaseObjectTag subclass associated with this taxonomy. + + Can be overridden by setting this property. + """ + if self._object_tag_class: + ObjectTagClass = import_string(self._object_tag_class) + elif self.allow_free_text: + ObjectTagClass = OpenObjectTag + else: + ObjectTagClass = ClosedObjectTag + + return ObjectTagClass + + @object_tag_class.setter + def object_tag_class(self, object_tag_class: Type): + """ + Assigns the given object_tag_class's module path.class to the field. + + Raises ValueError if the given `object_tag_class` is a built-in class; it should be an ObjectTag-like class. + """ + if object_tag_class.__module__ == "builtins": + raise ValueError( + f"object_tag_class {object_tag_class} must be class like ObjectTag" + ) + + # ref: https://stackoverflow.com/a/2020083 + self._object_tag_class = ".".join( + [object_tag_class.__module__, object_tag_class.__qualname__] + ) + def get_tags(self) -> List[Tag]: """ Returns a list of all Tags in the current taxonomy, from the root(s) down to TAXONOMY_MAX_DEPTH tags, in tree order. @@ -205,39 +247,6 @@ def get_tags(self) -> List[Tag]: break return tags - def validate_object_tag( - self, - object_tag: "ObjectTag", - check_taxonomy=True, - check_tag=True, - check_object=True, - ) -> bool: - """ - Returns True if the given object tag is valid for the current Taxonomy. - - Subclasses can override this method to perform their own validation checks, e.g. against dynamically generated - tag lists. - - If `check_taxonomy` is False, then we skip validating the object tag's taxonomy reference. - If `check_tag` is False, then we skip validating the object tag's tag reference. - If `check_object` is False, then we skip validating the object ID/type. - """ - # Must be linked to this taxonomy - if check_taxonomy and ( - not object_tag.taxonomy or object_tag.taxonomy.id != self.id - ): - return False - - # Must be linked to a Tag unless its a free-text taxonomy - if check_tag and (not self.allow_free_text and not object_tag.tag_id): - return False - - # Must have a valid object id/type: - if check_object and (not object_tag.object_id or not object_tag.object_type): - return False - - return True - def tag_object( self, tags: List, object_id: str, object_type: str ) -> List["ObjectTag"]: @@ -259,6 +268,7 @@ def tag_object( _(f"Taxonomy ({self.id}) requires at least one tag per object.") ) + ObjectTagClass = self.object_tag_class current_tags = { tag.tag_ref: tag for tag in self.objecttag_set.filter( @@ -270,7 +280,7 @@ def tag_object( if tag_ref in current_tags: object_tag = current_tags.pop(tag_ref) else: - object_tag = ObjectTag( + object_tag = ObjectTagClass( taxonomy=self, object_id=object_id, object_type=object_type, @@ -286,7 +296,7 @@ def tag_object( object_tag.value = tag_ref object_tag.resync() - if not self.validate_object_tag(object_tag): + if not object_tag.is_valid(): raise ValueError( _(f"Invalid object tag for taxonomy ({self.id}): {tag_ref}") ) @@ -388,7 +398,7 @@ def __str__(self): """ User-facing string representation of an ObjectTag. """ - return f"{self.object_id} ({self.object_type}): {self.name}={self.value}" + return f"<{self.__class__.__name__}> {self.object_id} ({self.object_type}): {self.name}={self.value}" @property def taxonomy(self) -> str: @@ -419,6 +429,19 @@ def taxonomy(self, taxonomy: Taxonomy): self._taxonomy = taxonomy self._cached_taxonomy = taxonomy + def copy(self, object_tag: "ObjectTag") -> "ObjectTag": + """ + Copy the fields from the given ObjectTag into the current instance. + """ + self.id = object_tag.id + self.object_id = object_tag.object_id + self.object_type = object_tag.object_type + self._taxonomy_id = object_tag._taxonomy_id + self.tag_id = object_tag.tag_id + self._name = object_tag.name + self._value = object_tag.value + return self + @property def name(self) -> str: """ @@ -463,17 +486,6 @@ def tag_ref(self) -> str: """ return self.tag.id if self.tag_id else self._value - @property - def is_valid(self) -> bool: - """ - Returns True if this ObjectTag represents a valid taxonomy tag. - - A valid ObjectTag must be linked to a Taxonomy, and be a valid tag in that taxonomy. - """ - if self._taxonomy_id: - return self.taxonomy.validate_object_tag(self) - return False - def get_lineage(self) -> Lineage: """ Returns the lineage of the current tag as a list of value strings. @@ -483,6 +495,31 @@ def get_lineage(self) -> Lineage: """ return self.tag.get_lineage() if self.tag_id else [self._value] + def is_valid(self, check_taxonomy=True, check_tag=True, check_object=True) -> bool: + """ + Returns True if this ObjectTag is valid for the linked taxonomy and/or tag. + + Subclasses must override this method to perform the proper validation checks, e.g. closed vs open taxonomies, + dynamically generated tag lists or object definitions. + + If `check_taxonomy` is False, then we skip validating the object tag's taxonomy reference. + If `check_tag` is False, then we skip validating the object tag's tag reference. + If `check_object` is False, then we skip validating the object ID/type. + """ + # Must be linked to a taxonomy + if check_taxonomy and not self._taxonomy_id: + return False + + # Open taxonomies don't have an associated tag, but we need a value. + if check_tag and not self.value: + return False + + # Must have a valid object id/type: + if check_object and (not self.object_id or not self.object_type): + return False + + return True + def resync(self) -> bool: """ Reconciles the stored ObjectTag properties with any changes made to its associated taxonomy or tag. @@ -492,6 +529,8 @@ def resync(self) -> bool: It's also useful for a set of ObjectTags are imported from an external source prior to when a Taxonomy exists to validate or store its available Tags. + Subclasses must override this method to perform any additional syncing for the particular type of object tag. + Returns True if anything was changed, False otherwise. """ changed = False @@ -502,21 +541,25 @@ def resync(self) -> bool: name=self.name, enabled=True ).select_subclasses(): # Make sure this taxonomy will accept object tags like this. - self.taxonomy = taxonomy - if taxonomy.validate_object_tag(self, check_tag=False): + object_tag = taxonomy.object_tag_class( + id=self.id, + taxonomy=taxonomy, + _name=self._name, + _value=self._value, + ) + if object_tag.is_valid(check_tag=False, check_object=False): + self.taxonomy = taxonomy changed = True break # If not, try the next one - else: - self.taxonomy = None # Sync the stored _name with the taxonomy.name - elif self._name != self.taxonomy.name: + if self._taxonomy_id and self._name != self.taxonomy.name: self.name = self.taxonomy.name changed = True - # Locate a tag matching _value - if self.taxonomy and not self.tag_id and not self.taxonomy.allow_free_text: + # Closed taxonomies require a tag matching _value + if self.taxonomy and not self.tag_id: tag = self.taxonomy.tag_set.filter(value=self.value).first() if tag: self.tag = tag @@ -542,3 +585,41 @@ def system_defined(self) -> bool: This is a system-defined taxonomy. """ return True + + +class OpenObjectTag(ObjectTag): + """ + Free-text object tag. + + Only needs a taxonomy and a value to be valid. + """ + + class Meta: + proxy = True + + +class ClosedObjectTag(OpenObjectTag): + """ + Object tags linked to a closed taxonomy, where all the tag options are known. + """ + + class Meta: + proxy = True + + def is_valid(self, check_taxonomy=True, check_tag=True, check_object=True) -> bool: + """ + Returns True if this ObjectTag is linked to a known tag, and it's parent classes are valid. + + If `check_taxonomy` is False, then we skip validating the object tag's taxonomy reference. + If `check_tag` is False, then we skip validating the object tag's tag reference. + If `check_object` is False, then we skip validating the object ID/type. + """ + # Must be linked to a Tag + if check_tag and not self.tag_id: + return False + + return super().is_valid( + check_taxonomy=check_taxonomy, + check_tag=check_tag, + check_object=check_object, + ) diff --git a/tests/openedx_tagging/core/tagging/test_api.py b/tests/openedx_tagging/core/tagging/test_api.py index 2ba402ac3..d82106f13 100644 --- a/tests/openedx_tagging/core/tagging/test_api.py +++ b/tests/openedx_tagging/core/tagging/test_api.py @@ -1,6 +1,6 @@ """ Test the tagging APIs """ -from unittest.mock import patch +from unittest.mock import Mock, patch from django.test.testcases import TestCase @@ -28,19 +28,30 @@ def test_create_taxonomy(self): for param, value in params.items(): assert getattr(taxonomy, param) == value + def test_create_taxonomy_bad_object_tag_class(self): + with self.assertRaises(ValueError) as exc: + tagging_api.create_taxonomy( + name="invalid", + object_tag_class=str, + ) + assert "object_tag_class must be class like ObjectTag" in str( + exc.exception + ) + def test_get_taxonomies(self): tax1 = tagging_api.create_taxonomy("Enabled") tax2 = tagging_api.create_taxonomy("Disabled", enabled=False) with self.assertNumQueries(1): enabled = list(tagging_api.get_taxonomies()) assert enabled == [tax1, self.taxonomy, self.system_taxonomy] - assert str(enabled[0]) == " Enabled" - assert str(enabled[1]) == " Life on Earth" - assert str(enabled[2]) == " System Languages" + assert str(enabled[0]) == " (3) Enabled" + assert str(enabled[1]) == " (1) Life on Earth" + assert str(enabled[2]) == " (2) System Languages" with self.assertNumQueries(1): disabled = list(tagging_api.get_taxonomies(enabled=False)) assert disabled == [tax2] + assert str(disabled[0]) == " (4) Disabled" with self.assertNumQueries(1): both = list(tagging_api.get_taxonomies(enabled=None)) @@ -69,7 +80,7 @@ def test_resync_object_tags(self): missing_links.name = self.taxonomy.name missing_links.value = self.mammalia.value missing_links.save() - changed_links = ObjectTag( + changed_links = self.taxonomy.object_tag_class( object_id="def", object_type="alpha", taxonomy=self.taxonomy, @@ -78,8 +89,7 @@ def test_resync_object_tags(self): changed_links.name = "Life" changed_links.value = "Animals" changed_links.save() - - no_changes = ObjectTag( + no_changes = self.taxonomy.object_tag_class( object_id="ghi", object_type="beta", taxonomy=self.taxonomy, @@ -116,22 +126,31 @@ def test_resync_object_tags(self): changed = tagging_api.resync_object_tags() assert changed == 0 - # Recreate the taxonomy and resync some tags - first_taxonomy = tagging_api.create_taxonomy("Life on Earth") - second_taxonomy = tagging_api.create_taxonomy("Life on Earth") + # Recreate the taxonomy with a custom tag class and resync some tags + first_taxonomy = tagging_api.create_taxonomy( + "Life on Earth", object_tag_class=ObjectTag + ) + second_taxonomy = tagging_api.create_taxonomy( + "Life on Earth", object_tag_class=ObjectTag + ) new_tag = Tag.objects.create( value="Mammalia", taxonomy=second_taxonomy, ) + # Use our mock object tag class here + MockObjectClass = Mock() + MockObjectClass.return_value = Mock(spec=ObjectTag) + MockObjectClass.return_value.is_valid.side_effect = [False, True, False, True] with patch( - "openedx_tagging.core.tagging.models.Taxonomy.validate_object_tag", - side_effect=[False, True, False, True], + "openedx_tagging.core.tagging.models.import_string", + return_value=MockObjectClass, ): changed = tagging_api.resync_object_tags( ObjectTag.objects.filter(object_type="alpha") ) assert changed == 2 + for object_tag in (missing_links, changed_links): self.check_object_tag( object_tag, second_taxonomy, new_tag, "Life on Earth", "Mammalia" @@ -178,10 +197,12 @@ def test_tag_object(self): # Ensure the expected number of tags exist in the database assert ( - tagging_api.get_object_tags( - taxonomy=self.taxonomy, - object_id="biology101", - object_type="course", + list( + tagging_api.get_object_tags( + taxonomy=self.taxonomy, + object_id="biology101", + object_type="course", + ) ) == object_tags ) @@ -189,8 +210,73 @@ def test_tag_object(self): assert len(object_tags) == len(tag_list) for index, object_tag in enumerate(object_tags): assert object_tag.tag_id == tag_list[index] - assert object_tag.is_valid + assert object_tag.is_valid() assert object_tag.taxonomy == self.taxonomy assert object_tag.name == self.taxonomy.name assert object_tag.object_id == "biology101" assert object_tag.object_type == "course" + + def test_get_object_tags(self): + # Alpha tag has no taxonomy + alpha = ObjectTag(object_id="abc", object_type="alpha") + alpha.name = self.taxonomy.name + alpha.value = self.mammalia.value + alpha.save() + # Beta tag has a closed taxonomy + beta = self.taxonomy.object_tag_class.objects.create( + object_id="abc", + object_type="beta", + taxonomy=self.taxonomy, + ) + + # Fetch all the tags for a given object ID + assert list( + tagging_api.get_object_tags( + object_id="abc", + valid_only=False, + ) + ) == [ + alpha, + beta, + ] + + # No valid tags for this object yet.. + assert not list( + tagging_api.get_object_tags( + object_id="abc", + valid_only=True, + ) + ) + # ...until we make beta valid + beta.tag = self.mammalia + beta.save() + assert list( + tagging_api.get_object_tags( + object_id="abc", + valid_only=True, + ) + ) == [ + beta, + ] + + # Fetch all the tags for alpha-type objects + assert list( + tagging_api.get_object_tags( + object_id="abc", + object_type="alpha", + valid_only=False, + ) + ) == [ + alpha, + ] + + # Fetch all the tags for a given object ID + taxonomy + assert list( + tagging_api.get_object_tags( + object_id="abc", + taxonomy=self.taxonomy, + valid_only=False, + ) + ) == [ + beta, + ] diff --git a/tests/openedx_tagging/core/tagging/test_models.py b/tests/openedx_tagging/core/tagging/test_models.py index 39dd013b1..66518252c 100644 --- a/tests/openedx_tagging/core/tagging/test_models.py +++ b/tests/openedx_tagging/core/tagging/test_models.py @@ -90,13 +90,15 @@ def test_system_defined(self): assert self.system_taxonomy.system_defined def test_representations(self): - assert str(self.taxonomy) == repr(self.taxonomy) == " Life on Earth" + assert ( + str(self.taxonomy) == repr(self.taxonomy) == " (1) Life on Earth" + ) assert ( str(self.system_taxonomy) == repr(self.system_taxonomy) - == " System Languages" + == " (2) System Languages" ) - assert str(self.bacteria) == repr(self.bacteria) == "Tag (1) Bacteria" + assert str(self.bacteria) == repr(self.bacteria) == " (1) Bacteria" @ddt.data( # Root tags just return their own value @@ -151,13 +153,6 @@ def setUp(self): tag=self.tag, ) - def test_representations(self): - assert ( - str(self.object_tag) - == repr(self.object_tag) - == "object:id:1 (life): Life on Earth=Bacteria" - ) - def test_change_taxonomy(self): assert self.object_tag.taxonomy == self.taxonomy assert self.object_tag.taxonomy.id == self.taxonomy.id @@ -180,27 +175,36 @@ def test_change_taxonomy(self): # pylint: disable=protected-access self.object_tag._taxonomy_id == self.system_taxonomy.id + == 2 + ) + object_tag = self.object_tag.taxonomy.object_tag_class().copy(self.object_tag) + assert ( + str(object_tag) + == repr(object_tag) + == " object:id:1 (life): System Languages=Bacteria" + ) + object_tag.taxonomy.allow_free_text = True + object_tag = object_tag.taxonomy.object_tag_class().copy(object_tag) + assert ( + str(object_tag) + == repr(object_tag) + == " object:id:1 (life): System Languages=Bacteria" ) def test_object_tag_name(self): # ObjectTag's name defaults to its taxonomy's name - object_tag = ObjectTag.objects.create( - object_id="object:id", - object_type="any_old_object", - taxonomy=self.taxonomy, - ) - assert object_tag.name == self.taxonomy.name + assert self.object_tag.name == self.taxonomy.name # Even if we overwrite the name, it still uses the taxonomy's name - object_tag.name = "Another tag" - assert object_tag.name == self.taxonomy.name - object_tag.save() - assert object_tag.name == self.taxonomy.name + self.object_tag.name = "Another tag" + assert self.object_tag.name == self.taxonomy.name + self.object_tag.save() + assert self.object_tag.name == self.taxonomy.name # But if the taxonomy is deleted, then the object_tag's name reverts to our cached name self.taxonomy.delete() - object_tag.refresh_from_db() - assert object_tag.name == "Another tag" + self.object_tag.refresh_from_db() + assert self.object_tag.name == "Another tag" def test_object_tag_value(self): # ObjectTag's value defaults to its tag's value @@ -233,42 +237,34 @@ def test_object_tag_lineage(self): assert self.object_tag.get_lineage() == ["Another tag"] def test_object_tag_is_valid(self): - object_tag = ObjectTag( - object_id="object:id", - object_type="life", + # All valid object tags must have a taxonomy and an object + object_tag = ObjectTag() + assert not object_tag.is_valid( + check_taxonomy=True, check_tag=False, check_object=False ) - assert not object_tag.is_valid - - object_tag.taxonomy = self.taxonomy - assert not object_tag.is_valid - - object_tag.tag = self.tag - assert object_tag.is_valid - - # or, we can have no tag, and a free-text taxonomy - object_tag.tag = None - self.taxonomy.allow_free_text = True - self.taxonomy.save() - assert object_tag.is_valid - - def test_validate_object_tag_invalid(self): - taxonomy = Taxonomy.objects.create( - name="Another taxonomy", + assert not object_tag.is_valid( + check_taxonomy=False, check_tag=True, check_object=False + ) + assert not object_tag.is_valid( + check_taxonomy=False, check_tag=False, check_object=True ) + + # OpenObjectTags can be valid with only a value and no tag object_tag = ObjectTag( + object_id="object:id", + object_type="life", taxonomy=self.taxonomy, + _value="Any text we want", ) - assert not taxonomy.validate_object_tag(object_tag) - - object_tag.taxonomy = taxonomy - assert not taxonomy.validate_object_tag(object_tag) + object_tag.taxonomy.allow_free_text = True + assert object_tag.is_valid() - taxonomy.allow_free_text = True - assert not taxonomy.validate_object_tag(object_tag) - - object_tag.object_id = "object:id" - object_tag.object_type = "object:type" - assert taxonomy.validate_object_tag(object_tag) + # ClosedObjectTags require a tag + object_tag.taxonomy.allow_free_text = False + object_tag = self.taxonomy.object_tag_class().copy(object_tag) + assert not object_tag.is_valid() + object_tag.tag = self.tag + assert object_tag.is_valid() def test_tag_object(self): self.taxonomy.allow_multiple = True @@ -306,7 +302,7 @@ def test_tag_object(self): assert len(object_tags) == len(tag_list) for index, object_tag in enumerate(object_tags): assert object_tag.tag_id == tag_list[index] - assert object_tag.is_valid + assert object_tag.is_valid() assert object_tag.taxonomy == self.taxonomy assert object_tag.name == self.taxonomy.name assert object_tag.object_id == "biology101" @@ -322,7 +318,7 @@ def test_tag_object_free_text(self): ) assert len(object_tags) == 1 object_tag = object_tags[0] - assert object_tag.is_valid + assert object_tag.is_valid() assert object_tag.taxonomy == self.taxonomy assert object_tag.name == self.taxonomy.name assert object_tag.tag_ref == "Eukaryota Xenomorph"