diff --git a/openedx_tagging/core/tagging/models/base.py b/openedx_tagging/core/tagging/models/base.py index 7900e440..ba54abbd 100644 --- a/openedx_tagging/core/tagging/models/base.py +++ b/openedx_tagging/core/tagging/models/base.py @@ -18,6 +18,7 @@ from openedx_learning.lib.fields import MultiCollationTextField, case_insensitive_char_field, case_sensitive_char_field from ..data import TagData +from .utils import ConcatNull log = logging.getLogger(__name__) @@ -462,9 +463,12 @@ def _get_filtered_tags_deep( qs = Tag.annotate_depth(qs) # Add the "lineage" field to sort them in order correctly: qs = qs.annotate(sort_key=Concat( - Coalesce(F("parent__parent__parent__value"), Value("")), - Coalesce(F("parent__parent__value"), Value("")), - Coalesce(F("parent__value"), Value("")), + # For a root tag, we want sort_key="RootValue" and for a depth=1 tag + # we want sort_key="RootValue\tValue". The following does that, since + # ConcatNull(...) returns NULL if any argument is NULL. + Coalesce(ConcatNull(F("parent__parent__parent__value"), Value("\t")), Value("")), + Coalesce(ConcatNull(F("parent__parent__value"), Value("\t")), Value("")), + Coalesce(ConcatNull(F("parent__value"), Value("\t")), Value("")), F("value"), output_field=models.CharField(), )) diff --git a/openedx_tagging/core/tagging/models/utils.py b/openedx_tagging/core/tagging/models/utils.py new file mode 100644 index 00000000..dd029c77 --- /dev/null +++ b/openedx_tagging/core/tagging/models/utils.py @@ -0,0 +1,24 @@ +""" +Utilities for tagging and taxonomy models +""" + +from django.db.models.expressions import Func + + +class ConcatNull(Func): + """ + Concatenate two arguments together. Like normal SQL but unlike Django's + "Concat", if either argument is NULL, the result will be NULL. + """ + + function = "CONCAT" + + def as_sqlite(self, compiler, connection, **extra_context): + """ SQLite doesn't have CONCAT() but has a concatenation operator """ + return super().as_sql( + compiler, + connection, + template="%(expressions)s", + arg_joiner=" || ", + **extra_context, + ) diff --git a/openedx_tagging/core/tagging/rest_api/v1/serializers.py b/openedx_tagging/core/tagging/rest_api/v1/serializers.py index 30412065..704c82cf 100644 --- a/openedx_tagging/core/tagging/rest_api/v1/serializers.py +++ b/openedx_tagging/core/tagging/rest_api/v1/serializers.py @@ -6,7 +6,7 @@ from rest_framework.reverse import reverse from openedx_tagging.core.tagging.data import TagData -from openedx_tagging.core.tagging.models import ObjectTag, Tag, Taxonomy +from openedx_tagging.core.tagging.models import ObjectTag, Taxonomy class TaxonomyListQueryParamsSerializer(serializers.Serializer): # pylint: disable=abstract-method @@ -126,104 +126,3 @@ def get_sub_tags_url(self, obj: TagData): ) return request.build_absolute_uri(url) return None - - -class TagsSerializer(serializers.ModelSerializer): - """ - Serializer for Tags - - Adds a link to get the sub tags - """ - - sub_tags_link = serializers.SerializerMethodField() - children_count = serializers.SerializerMethodField() - - class Meta: - model = Tag - fields = ( - "id", - "value", - "taxonomy_id", - "parent_id", - "sub_tags_link", - "children_count", - ) - - def get_sub_tags_link(self, obj): - """ - Returns URL for the list of child tags of the current tag. - """ - if obj.children.count(): - query_params = f"?parent_tag_id={obj.id}" - url = ( - reverse("oel_tagging:taxonomy-tags", args=[str(obj.taxonomy_id)]) - + query_params - ) - request = self.context.get("request") - return request.build_absolute_uri(url) - return None - - def get_children_count(self, obj): - """ - Returns the number of child tags of the given tag. - """ - return obj.children.count() - - -class TagsWithSubTagsSerializer(serializers.ModelSerializer): - """ - Serializer for Tags. - - Represents a tree with a list of sub tags - """ - - sub_tags = serializers.SerializerMethodField() - children_count = serializers.SerializerMethodField() - - class Meta: - model = Tag - fields = ( - "id", - "value", - "taxonomy_id", - "sub_tags", - "children_count", - ) - - def get_sub_tags(self, obj): - """ - Returns a serialized list of child tags for the given tag. - """ - serializer = TagsWithSubTagsSerializer( - obj.children.all().order_by("value", "id"), - many=True, - read_only=True, - ) - return serializer.data - - def get_children_count(self, obj): - """ - Returns the number of child tags of the given tag. - """ - return obj.children.count() - - -class TagsForSearchSerializer(TagsWithSubTagsSerializer): - """ - Serializer for Tags - - Used to filter sub tags of a given tag - """ - - def get_sub_tags(self, obj): - """ - Returns a serialized list of child tags for the given tag. - """ - serializer = TagsWithSubTagsSerializer(obj.sub_tags, many=True, read_only=True) - return serializer.data - - def get_children_count(self, obj): - """ - Returns the number of child tags of the given tag. - """ - return len(obj.sub_tags) diff --git a/openedx_tagging/core/tagging/rest_api/v1/views.py b/openedx_tagging/core/tagging/rest_api/v1/views.py index 6a949529..c4f6ae5b 100644 --- a/openedx_tagging/core/tagging/rest_api/v1/views.py +++ b/openedx_tagging/core/tagging/rest_api/v1/views.py @@ -14,22 +14,13 @@ from openedx_tagging.core.tagging.models.base import Tag -from ...api import ( - create_taxonomy, - get_children_tags, - get_object_tags, - get_root_tags, - get_taxonomies, - get_taxonomy, - search_tags, - tag_object, -) +from ...api import create_taxonomy, get_object_tags, get_taxonomies, get_taxonomy, tag_object +from ...data import TagData from ...import_export.api import export_tags from ...import_export.parsers import ParserFormat from ...models import Taxonomy -from ...data import TagData from ...rules import ObjectTagPermissionItem -from ..paginators import SEARCH_TAGS_THRESHOLD, TAGS_THRESHOLD, DisabledTagsPagination, TagsPagination +from ..paginators import TAGS_THRESHOLD, DisabledTagsPagination, TagsPagination from .permissions import ObjectTagObjectPermissions, TagListPermissions, TaxonomyObjectPermissions from .serializers import ( ObjectTagListQueryParamsSerializer, @@ -37,9 +28,6 @@ ObjectTagUpdateBodySerializer, ObjectTagUpdateQueryParamsSerializer, TagDataSerializer, - TagsForSearchSerializer, - TagsSerializer, - TagsWithSubTagsSerializer, TaxonomyExportQueryParamsSerializer, TaxonomyListQueryParamsSerializer, TaxonomySerializer, @@ -414,17 +402,22 @@ class TaxonomyTagsView(ListAPIView): hierachy will be returned. Otherwise, several levels will be returned, in tree order, up to the maximum supported depth. Additional levels/depth can be retrieved by using ?parent_tag_value to load more data. + + Note: If the taxonomy is particularly large (> 1,000 tags), ?root_only is + automatically set true by default and cannot be disabled. This way, users + can more easily select which tags they want to expand in the tree, and load + just that subset of the tree as needed. This may be changed in the future. **List Query Parameters** - * pk (required) - The pk of the taxonomy to retrieve tags. - * parent_tag_value (optional) - Id of the tag to retrieve children tags. + * id (required) - The ID of the taxonomy to retrieve tags. + * parent_tag (optional) - Retrieve children of the tag with this value. * root_only (optional) - If specified, only root tags are returned. * page (optional) - Page number (default: 1) * page_size (optional) - Number of items per page (default: 10) **List Example Requests** - GET api/tagging/v1/taxonomy/:pk/tags - Get tags of taxonomy - GET api/tagging/v1/taxonomy/:pk/tags?parent_tag_id=30 - Get children tags of tag + GET api/tagging/v1/taxonomy/:id/tags - Get tags of taxonomy + GET api/tagging/v1/taxonomy/:id/tags?parent_tag=Physics - Get child tags of tag **List Query Returns** * 200 - Success @@ -467,13 +460,17 @@ def get_queryset(self) -> models.QuerySet[TagData]: if parent_tag_value: # Fetching tags below a certain parent is always paginated and only returns the direct children - self.pagination_enabled = True depth = 1 if root_only: - raise ValidationError("?root_only and ?parent_tag_value cannot be used together") + raise ValidationError("?root_only and ?parent_tag cannot be used together") else: - if root_only or taxonomy.tag_set.count() > TAGS_THRESHOLD: - depth = 1 # Load only the root tags for now + if root_only: + depth = 1 # User Explicitly requested to load only the root tags for now + elif search_term: + depth = None # For search, default to maximum depth but use normal pagination + elif taxonomy.tag_set.count() > TAGS_THRESHOLD: + # This is a very large taxonomy. Only load the root tags at first, so users can choose what to load. + depth = 1 else: # We can load and display all the tags in the taxonomy at once: self.pagination_class = DisabledTagsPagination diff --git a/tests/openedx_tagging/core/tagging/import_export/test_template.py b/tests/openedx_tagging/core/tagging/import_export/test_template.py index c25c4b28..dc987c1c 100644 --- a/tests/openedx_tagging/core/tagging/import_export/test_template.py +++ b/tests/openedx_tagging/core/tagging/import_export/test_template.py @@ -12,8 +12,8 @@ from openedx_tagging.core.tagging.import_export import ParserFormat from openedx_tagging.core.tagging.import_export import api as import_api -from .mixins import TestImportExportMixin from ..utils import pretty_format_tags +from .mixins import TestImportExportMixin @ddt.ddt diff --git a/tests/openedx_tagging/core/tagging/test_api.py b/tests/openedx_tagging/core/tagging/test_api.py index b488c81c..1e1a39a9 100644 --- a/tests/openedx_tagging/core/tagging/test_api.py +++ b/tests/openedx_tagging/core/tagging/test_api.py @@ -644,22 +644,6 @@ def test_autocomplete_tags(self, search: str, expected_values: list[str], expect _value=value, ).save() - # Test for open taxonomy - # self._validate_autocomplete_tags( - # open_taxonomy, - # search, - # expected_values, - # [None] * len(expected_ids), - # ) - - # # Test for closed taxonomy - # self._validate_autocomplete_tags( - # closed_taxonomy, - # search, - # expected_values, - # expected_ids, - # ) - @ddt.data( ("ChA", [ "Archaea (used: 1, children: 3)", @@ -756,37 +740,3 @@ def _get_tag_ids(self, tags) -> list[int]: Get tag ids from tagging_api.autocomplete_tags() result """ return [tag.get("tag_id") for tag in tags] - - def _validate_autocomplete_tags( - self, - taxonomy: Taxonomy, - search: str, - expected: list[str], - ) -> None: - """ - Validate autocomplete tags - """ - - # Normal search - result = tagging_api.search_tags(taxonomy, search) - - assert pretty_format_tags(result, parent=False) == expected - - # Create ObjectTag to simulate the content tagging - first_value = next(t for t in result if search.lower() in t["value"].lower()) - tag_model = None - if not taxonomy.allow_free_text: - tag_model = get_tag(first_value) - - object_id = 'new_object_id' - ObjectTag( - object_id=object_id, - taxonomy=taxonomy, - tag=tag_model, - _value=first_value, - ).save() - - # Search with object - result = tagging_api.search_tags(taxonomy, search, object_id) - assert self._get_tag_values(result) == expected_values[1:] - assert self._get_tag_ids(result) == expected_ids[1:] diff --git a/tests/openedx_tagging/core/tagging/test_models.py b/tests/openedx_tagging/core/tagging/test_models.py index a47ec5f1..f59fbc03 100644 --- a/tests/openedx_tagging/core/tagging/test_models.py +++ b/tests/openedx_tagging/core/tagging/test_models.py @@ -13,6 +13,7 @@ from openedx_tagging.core.tagging import api from openedx_tagging.core.tagging.models import LanguageTaxonomy, ObjectTag, Tag, Taxonomy + from .utils import pretty_format_tags @@ -443,6 +444,33 @@ def test_usage_count(self) -> None: "Bacteria (None) (used: 3, children: 2)", ] + def test_pathological_tree_sort(self) -> None: + """ + Check for bugs in how tree sorting happens, if the tag names are very + similar. + """ + taxonomy = api.create_taxonomy("Sort Test") + root1 = Tag.objects.create(taxonomy=taxonomy, value="1") + child1_1 = Tag.objects.create(taxonomy=taxonomy, value="11", parent=root1) + child1_2 = Tag.objects.create(taxonomy=taxonomy, value="2", parent=root1) + child1_3 = Tag.objects.create(taxonomy=taxonomy, value="1 A", parent=root1) + child1_4 = Tag.objects.create(taxonomy=taxonomy, value="11111", parent=root1) + grandchild1_4_1 = Tag.objects.create(taxonomy=taxonomy, value="1111-grandchild", parent=child1_4) + root2 = Tag.objects.create(taxonomy=taxonomy, value="111") + child2_1 = Tag.objects.create(taxonomy=taxonomy, value="11111111", parent=root2) + child2_2 = Tag.objects.create(taxonomy=taxonomy, value="123", parent=root2) + result = pretty_format_tags(taxonomy.get_filtered_tags()) + assert result == [ + "1 (None) (used: 0, children: 4)", + " 1 A (1) (used: 0, children: 0)", + " 11 (1) (used: 0, children: 0)", + " 11111 (1) (used: 0, children: 1)", + " 1111-grandchild (11111) (used: 0, children: 0)", + " 2 (1) (used: 0, children: 0)", + "111 (None) (used: 0, children: 2)", + " 11111111 (111) (used: 0, children: 0)", + " 123 (111) (used: 0, children: 0)", + ] class TestFilteredTagsFreeTextTaxonomy(TestCase): """ diff --git a/tests/openedx_tagging/core/tagging/test_views.py b/tests/openedx_tagging/core/tagging/test_views.py index ac0b71a8..8ffc4e94 100644 --- a/tests/openedx_tagging/core/tagging/test_views.py +++ b/tests/openedx_tagging/core/tagging/test_views.py @@ -3,7 +3,7 @@ """ from __future__ import annotations -from urllib.parse import parse_qs, urlparse +from urllib.parse import parse_qs, quote, quote_plus, urlparse import ddt # type: ignore[import] # typing support in rules depends on https://github.com/dfunckt/django-rules/pull/177 @@ -1075,6 +1075,9 @@ def test_small_taxonomy_paged(self): def test_small_search(self): + """ + Test performing a search + """ search_term = 'eU' url = f"{self.small_taxonomy_url}?search_term={search_term}" self.client.force_authenticate(user=self.staff) @@ -1082,39 +1085,59 @@ def test_small_search(self): assert response.status_code == status.HTTP_200_OK data = response.data - results = data.get("results", []) - - assert len(results) == 3 + assert pretty_format_tags(data["results"], parent=False, usage_count=False) == [ + "Archaea (children: 3)", # No match in this tag, but a child matches so it's included + " Euryarchaeida (children: 0)", + "Bacteria (children: 2)", # No match in this tag, but a child matches so it's included + " Eubacteria (children: 0)", + "Eukaryota (children: 5)", + ] # Checking pagination values assert data.get("next") is None assert data.get("previous") is None - assert data.get("count") == 3 + assert data.get("count") == 5 assert data.get("num_pages") == 1 assert data.get("current_page") == 1 def test_large_taxonomy(self): + """ + Test listing the tags in a large taxonomy (~7,000 tags). + """ self._build_large_taxonomy() self.client.force_authenticate(user=self.staff) response = self.client.get(self.large_taxonomy_url) assert response.status_code == status.HTTP_200_OK data = response.data - results = data.get("results", []) + results = data["results"] + + # Even though we didn't specify root_only, only the root tags will have + # been returned, because of the taxonomy's size. + assert pretty_format_tags(results) == [ + "Tag 0 (None) (used: 0, children: 12)", + "Tag 1099 (None) (used: 0, children: 12)", + "Tag 1256 (None) (used: 0, children: 12)", + "Tag 1413 (None) (used: 0, children: 12)", + "Tag 157 (None) (used: 0, children: 12)", + "Tag 1570 (None) (used: 0, children: 12)", + "Tag 1727 (None) (used: 0, children: 12)", + "Tag 1884 (None) (used: 0, children: 12)", + "Tag 2041 (None) (used: 0, children: 12)", + "Tag 2198 (None) (used: 0, children: 12)", + # ... there are 41 more root tags but they're excluded from this first result page. + ] # Count of paginated root tags assert len(results) == self.page_size - # Checking tag fields - root_tag = self.large_taxonomy.tag_set.get(id=results[0].get("id")) - assert results[0].get("value") == root_tag.value - assert results[0].get("taxonomy_id") == self.large_taxonomy.id - assert results[0].get("parent_id") == root_tag.parent_id - assert results[0].get("children_count") == root_tag.children.count() - assert results[0].get("sub_tags_link") == ( + # Checking some other tag fields not covered by the pretty-formatted string above: + root_tag = self.large_taxonomy.tag_set.get(value=results[0].get("value")) + assert results[0].get("_id") == root_tag.id + assert results[0].get("sub_tags_url") == ( "http://testserver/tagging/" f"rest_api/v1/taxonomies/{self.large_taxonomy.id}" - f"/tags/?parent_tag_id={root_tag.id}" + f"/tags/?parent_tag={quote(results[0]['value'])}" ) # Checking pagination values @@ -1154,62 +1177,48 @@ def test_next_page_large_taxonomy(self): assert data.get("current_page") == 2 def test_large_search(self): + """ + Test searching in a large taxonomy + """ self._build_large_taxonomy() - search_term = '1' + search_term = '11' url = f"{self.large_taxonomy_url}?search_term={search_term}" self.client.force_authenticate(user=self.staff) response = self.client.get(url) assert response.status_code == status.HTTP_200_OK data = response.data - results = data.get("results", []) - - # Count of paginated root tags - assert len(results) == self.page_size - - # Checking pagination values - assert data.get("next") == ( - "http://testserver/tagging/" - f"rest_api/v1/taxonomies/{self.large_taxonomy.id}" - f"/tags/?page=2&search_term={search_term}" - ) - assert data.get("previous") is None - assert data.get("count") == 51 - assert data.get("num_pages") == 6 + results = data["results"] + assert pretty_format_tags(results, usage_count=None) == [ + "Tag 0 (None) (children: 12)", # First 3 results don't match but have children that match + " Tag 1 (Tag 0) (children: 12)", + " Tag 11 (Tag 1) (children: 0)", + " Tag 105 (Tag 0) (children: 12)", + " Tag 110 (Tag 105) (children: 0)", + " Tag 111 (Tag 105) (children: 0)", + " Tag 112 (Tag 105) (children: 0)", + " Tag 113 (Tag 105) (children: 0)", + " Tag 114 (Tag 105) (children: 0)", + " Tag 115 (Tag 105) (children: 0)", + ] + assert data.get("count") == 362 + assert data.get("num_pages") == 37 assert data.get("current_page") == 1 - - def test_next_large_search(self): - self._build_large_taxonomy() - search_term = '1' - url = f"{self.large_taxonomy_url}?search_term={search_term}" - - # Get first page of the search - self.client.force_authenticate(user=self.staff) - response = self.client.get(url) - - # Get next page - response = self.client.get(response.data.get("next")) - - data = response.data - results = data.get("results", []) - - # Count of paginated root tags - assert len(results) == self.page_size - - # Checking pagination values - assert data.get("next") == ( - "http://testserver/tagging/" - f"rest_api/v1/taxonomies/{self.large_taxonomy.id}" - f"/tags/?page=3&search_term={search_term}" - ) - assert data.get("previous") == ( - "http://testserver/tagging/" - f"rest_api/v1/taxonomies/{self.large_taxonomy.id}" - f"/tags/?search_term={search_term}" - ) - assert data.get("count") == 51 - assert data.get("num_pages") == 6 - assert data.get("current_page") == 2 + # Get the next page: + next_response = self.client.get(response.data.get("next")) + assert next_response.status_code == status.HTTP_200_OK + assert pretty_format_tags(next_response.data["results"], usage_count=None) == [ + " Tag 116 (Tag 105) (children: 0)", + " Tag 117 (Tag 105) (children: 0)", + " Tag 118 (Tag 0) (children: 12)", + " Tag 119 (Tag 118) (children: 0)", + "Tag 1099 (None) (children: 12)", + " Tag 1100 (Tag 1099) (children: 12)", + " Tag 1101 (Tag 1100) (children: 0)", + " Tag 1102 (Tag 1100) (children: 0)", + " Tag 1103 (Tag 1100) (children: 0)", + " Tag 1104 (Tag 1100) (children: 0)", + ] def test_get_children(self): self._build_large_taxonomy() @@ -1220,7 +1229,7 @@ def test_get_children(self): results = response.data.get("results", []) # Get children tags - response = self.client.get(results[0].get("sub_tags_link")) + response = self.client.get(results[0].get("sub_tags_url")) assert response.status_code == status.HTTP_200_OK data = response.data @@ -1230,22 +1239,21 @@ def test_get_children(self): assert len(results) == self.page_size # Checking tag fields - tag = self.large_taxonomy.tag_set.get(id=results[0].get("id")) + tag = self.large_taxonomy.tag_set.get(id=results[0].get("_id")) assert results[0].get("value") == tag.value - assert results[0].get("taxonomy_id") == self.large_taxonomy.id - assert results[0].get("parent_id") == tag.parent_id - assert results[0].get("children_count") == tag.children.count() - assert results[0].get("sub_tags_link") == ( + assert results[0].get("parent_value") == tag.parent.value + assert results[0].get("child_count") == tag.children.count() + assert results[0].get("sub_tags_url") == ( "http://testserver/tagging/" f"rest_api/v1/taxonomies/{self.large_taxonomy.id}" - f"/tags/?parent_tag_id={tag.id}" + f"/tags/?parent_tag={quote(tag.value)}" ) # Checking pagination values assert data.get("next") == ( "http://testserver/tagging/" f"rest_api/v1/taxonomies/{self.large_taxonomy.id}" - f"/tags/?page=2&parent_tag_id={tag.parent_id}" + f"/tags/?page=2&parent_tag={quote_plus(tag.parent.value)}" ) assert data.get("previous") is None assert data.get("count") == self.children_tags_count[0] @@ -1258,17 +1266,21 @@ def test_get_leaves(self): parent_tag = Tag.objects.get(value="Animalia") # Build url to get tags depth=2 - url = f"{self.small_taxonomy_url}?parent_tag_id={parent_tag.id}" + url = f"{self.small_taxonomy_url}?parent_tag={parent_tag.value}" response = self.client.get(url) - results = response.data.get("results", []) + results = response.data["results"] - # Checking tag fields - tag = self.small_taxonomy.tag_set.get(id=results[0].get("id")) - assert results[0].get("value") == tag.value - assert results[0].get("taxonomy_id") == self.small_taxonomy.id - assert results[0].get("parent_id") == tag.parent_id - assert results[0].get("children_count") == tag.children.count() - assert results[0].get("sub_tags_link") is None + # Even though we didn't specify root_only, only the root tags will have + # been returned, because of the taxonomy's size. + assert pretty_format_tags(results) == [ + " Arthropoda (Animalia) (used: 0, children: 0)", + " Chordata (Animalia) (used: 0, children: 1)", + " Cnidaria (Animalia) (used: 0, children: 0)", + " Ctenophora (Animalia) (used: 0, children: 0)", + " Gastrotrich (Animalia) (used: 0, children: 0)", + " Placozoa (Animalia) (used: 0, children: 0)", + " Porifera (Animalia) (used: 0, children: 0)", + ] def test_next_children(self): self._build_large_taxonomy() @@ -1278,22 +1290,27 @@ def test_next_children(self): response = self.client.get(self.large_taxonomy_url) results = response.data.get("results", []) - # Get children to obtain next link - response = self.client.get(results[0].get("sub_tags_link")) + # Get the URL that gives us the children of the first root tag + first_root_tag = results[0] + response = self.client.get(first_root_tag.get("sub_tags_url")) - # Get next children + # Get next page of children response = self.client.get(response.data.get("next")) assert response.status_code == status.HTTP_200_OK data = response.data - results = data.get("results", []) - tag = self.large_taxonomy.tag_set.get(id=results[0].get("id")) + results = data["results"] + assert pretty_format_tags(results) == [ + # There are 12 child tags total, so on this second page, we see only 2 (10 were on the first page): + " Tag 79 (Tag 0) (used: 0, children: 12)", + " Tag 92 (Tag 0) (used: 0, children: 12)", + ] # Checking pagination values assert data.get("next") is None assert data.get("previous") == ( "http://testserver/tagging/" - f"rest_api/v1/taxonomies/{self.large_taxonomy.id}/tags/?parent_tag_id={tag.parent_id}" + f"rest_api/v1/taxonomies/{self.large_taxonomy.id}/tags/?parent_tag={quote_plus(first_root_tag['value'])}" ) assert data.get("count") == self.children_tags_count[0] assert data.get("num_pages") == 2 diff --git a/tests/openedx_tagging/core/tagging/utils.py b/tests/openedx_tagging/core/tagging/utils.py index fb24187d..3d6890e1 100644 --- a/tests/openedx_tagging/core/tagging/utils.py +++ b/tests/openedx_tagging/core/tagging/utils.py @@ -1,6 +1,7 @@ """ Useful utilities for testing tagging and taxonomy code. """ +from __future__ import annotations def pretty_format_tags(result, parent=True, external_id=False, usage_count=True) -> list[str]: