Skip to content

Commit

Permalink
feat: Single taxonomy view API for tags (#78)
Browse files Browse the repository at this point in the history
  • Loading branch information
ChrisChV authored Sep 20, 2023
1 parent ea311e1 commit 69519f4
Show file tree
Hide file tree
Showing 17 changed files with 953 additions and 48 deletions.
13 changes: 11 additions & 2 deletions docs/decisions/0014-single-taxonomy-view-api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,18 @@ We will use the same view to perform a search with the same logic:

**get_matching_tags(parent_tag_id: str = None, search_term: str = None)**

We can use ``search_term`` to perferom a search on root tags or children tags depending of ``parent_tag_id``.
We can use ``search_term`` to perform a search on all taxonomy tags or children tags depending of ``parent_tag_id``.
The result will be a pruned tree with the necessary tags to be able to reach the results from a root tag.
Ex. if in the result there may be a child tag of ``depth=2``, but the parents are not found in the result.
In this case, it is necessary to add the parent and the parent of the parent (root tag) to be able to show
the child tag that is in the result.

For the search, ``SEARCH_TAGS_THRESHOLD`` will be used. (It is recommended that it be 20% of ``TAGS_THRESHOLD``).
It will work in the following way:

- If ``search_result.count() < SEARCH_TAGS_THRESHOLD``, then it will return all tags on the result tree without pagination.
- Otherwise, it will return the roots of the result tree with pagination. Each root will have the entire pruned branch.

It will work in the same way of ``TAGS_THRESHOLD`` (see Views & Pagination)

**Pros**
Expand Down Expand Up @@ -190,4 +199,4 @@ can return all the tags in one page. So we can perform the tag search on the fro
**Cons:**

- It is not scalable.
- Sets limits of tags that can be created in the taxonomy.
- Sets limits of tags that can be created in the taxonomy.
50 changes: 44 additions & 6 deletions openedx_tagging/core/tagging/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,47 @@ def get_tags(taxonomy: Taxonomy) -> list[Tag]:
return taxonomy.cast().get_tags()


def get_root_tags(taxonomy: Taxonomy) -> list[Tag]:
"""
Returns a list of the root tags for the given taxonomy.
Note that if the taxonomy allows free-text tags, then the returned list will be empty.
"""
return list(taxonomy.cast().get_filtered_tags())


def search_tags(taxonomy: Taxonomy, search_term: str) -> list[Tag]:
"""
Returns a list of all tags that contains `search_term` of the given taxonomy.
Note that if the taxonomy allows free-text tags, then the returned list will be empty.
"""
return list(
taxonomy.cast().get_filtered_tags(
search_term=search_term,
search_in_all=True,
)
)


def get_children_tags(
taxonomy: Taxonomy,
parent_tag_id: int,
search_term: str | None = None,
) -> list[Tag]:
"""
Returns a list of children tags for the given parent tag.
Note that if the taxonomy allows free-text tags, then the returned list will be empty.
"""
return list(
taxonomy.cast().get_filtered_tags(
parent_tag_id=parent_tag_id,
search_term=search_term,
)
)


def resync_object_tags(object_tags: QuerySet | None = None) -> int:
"""
Reconciles ObjectTag entries with any changes made to their associated taxonomies and tags.
Expand All @@ -98,8 +139,7 @@ def resync_object_tags(object_tags: QuerySet | None = None) -> int:


def get_object_tags(
object_id: str,
taxonomy_id: str | None = None
object_id: str, taxonomy_id: str | None = None
) -> QuerySet[ObjectTag]:
"""
Returns a Queryset of object tags for a given object.
Expand All @@ -124,10 +164,8 @@ def delete_object_tags(object_id: str):
"""
Delete all ObjectTag entries for a given object.
"""
tags = (
ObjectTag.objects.filter(
object_id=object_id,
)
tags = ObjectTag.objects.filter(
object_id=object_id,
)

tags.delete()
Expand Down
1 change: 1 addition & 0 deletions openedx_tagging/core/tagging/import_export/import_plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ class TagItem:
"""
Tag representation on the tag import plan
"""

id: str
value: str
index: int | None = 0
Expand Down
4 changes: 3 additions & 1 deletion openedx_tagging/core/tagging/import_export/parsers.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,9 @@ def _export_data(cls, tags: list[dict], taxonomy: Taxonomy) -> str:
raise NotImplementedError

@classmethod
def _parse_tags(cls, tags_data: list[dict]) -> tuple[list[TagItem], list[TagParserError]]:
def _parse_tags(
cls, tags_data: list[dict]
) -> tuple[list[TagItem], list[TagParserError]]:
"""
Validate the required fields of each tag.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,16 @@


class Migration(migrations.Migration):

dependencies = [
('oel_tagging', '0006_auto_20230802_1631'),
("oel_tagging", "0006_auto_20230802_1631"),
]

operations = [
migrations.AlterField(
model_name='tagimporttask',
name='log',
field=models.TextField(blank=True, default=None, help_text='Action execution logs'),
model_name="tagimporttask",
name="log",
field=models.TextField(
blank=True, default=None, help_text="Action execution logs"
),
),
]
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,19 @@


class Migration(migrations.Migration):

dependencies = [
('oel_tagging', '0007_tag_import_task_log_null_fix'),
("oel_tagging", "0007_tag_import_task_log_null_fix"),
]

operations = [
migrations.AlterField(
model_name='taxonomy',
name='description',
field=openedx_learning.lib.fields.MultiCollationTextField(blank=True, default='', help_text='Provides extra information for the user when applying tags from this taxonomy to an object.'),
model_name="taxonomy",
name="description",
field=openedx_learning.lib.fields.MultiCollationTextField(
blank=True,
default="",
help_text="Provides extra information for the user when applying tags from this taxonomy to an object.",
),
preserve_default=False,
),
]
54 changes: 49 additions & 5 deletions openedx_tagging/core/tagging/models/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,10 @@ def copy(self, taxonomy: Taxonomy) -> Taxonomy:
self._taxonomy_class = taxonomy._taxonomy_class
return self

def get_tags(self, tag_set: models.QuerySet | None = None) -> list[Tag]:
def get_tags(
self,
tag_set: models.QuerySet[Tag] | None = None,
) -> 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.
Expand All @@ -289,6 +292,7 @@ def get_tags(self, tag_set: models.QuerySet | None = None) -> list[Tag]:
tag_set = self.tag_set.all()

parents = None

for depth in range(TAXONOMY_MAX_DEPTH):
filtered_tags = tag_set.prefetch_related("parent")
if parents is None:
Expand All @@ -310,6 +314,42 @@ def get_tags(self, tag_set: models.QuerySet | None = None) -> list[Tag]:
break
return tags

def get_filtered_tags(
self,
tag_set: models.QuerySet[Tag] | None = None,
parent_tag_id: int | None = None,
search_term: str | None = None,
search_in_all: bool = False,
) -> models.QuerySet[Tag]:
"""
Returns a filtered QuerySet of tags.
By default returns the root tags of the given taxonomy
Use `parent_tag_id` to return the children of a tag.
Use `search_term` to filter the results by values that contains `search_term`.
Set `search_in_all` to True to make the search in all tags on the given taxonomy.
Note: This is mostly an 'internal' API and generally code outside of openedx_tagging
should use the APIs in openedx_tagging.api which in turn use this.
"""
if tag_set is None:
tag_set = self.tag_set.all()

if self.allow_free_text:
return tag_set.none()

if not search_in_all:
# If not search in all taxonomy, then apply parent filter.
tag_set = tag_set.filter(parent=parent_tag_id)

if search_term:
# Apply search filter
tag_set = tag_set.filter(value__icontains=search_term)

return tag_set.order_by("value", "id")

def validate_object_tag(
self,
object_tag: "ObjectTag",
Expand Down Expand Up @@ -348,7 +388,9 @@ def _check_taxonomy(
Subclasses can override this method to perform their own taxonomy validation checks.
"""
# Must be linked to this taxonomy
return (object_tag.taxonomy_id is not None) and object_tag.taxonomy_id == self.id
return (
object_tag.taxonomy_id is not None
) and object_tag.taxonomy_id == self.id

def _check_tag(
self,
Expand Down Expand Up @@ -481,9 +523,11 @@ def autocomplete_tags(
# Fetch tags that the object already has to exclude them from the result
excluded_tags: list[str] = []
if object_id:
excluded_tags = list(self.objecttag_set.filter(object_id=object_id).values_list(
"_value", flat=True
))
excluded_tags = list(
self.objecttag_set.filter(object_id=object_id).values_list(
"_value", flat=True
)
)
return (
# Fetch object tags from this taxonomy whose value contains the search
self.objecttag_set.filter(_value__icontains=search)
Expand Down
33 changes: 31 additions & 2 deletions openedx_tagging/core/tagging/models/system_defined.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from django.contrib.auth import get_user_model
from django.db import models

from openedx_tagging.core.tagging.models.base import ObjectTag
from openedx_tagging.core.tagging.models.base import ObjectTag, Tag

from .base import ObjectTag, Tag, Taxonomy

Expand Down Expand Up @@ -241,14 +241,43 @@ class LanguageTaxonomy(SystemDefinedTaxonomy):
class Meta:
proxy = True

def get_tags(self, tag_set: models.QuerySet | None = None) -> list[Tag]:
def get_tags(
self,
tag_set: models.QuerySet[Tag] | None = None,
) -> list[Tag]:
"""
Returns a list of all the available Language Tags, annotated with ``depth`` = 0.
"""
available_langs = self._get_available_languages()
tag_set = self.tag_set.filter(external_id__in=available_langs)
return super().get_tags(tag_set=tag_set)

def get_filtered_tags(
self,
tag_set: models.QuerySet[Tag] | None = None,
parent_tag_id: int | None = None,
search_term: str | None = None,
search_in_all: bool = False,
) -> models.QuerySet[Tag]:
"""
Returns a filtered QuerySet of available Language Tags.
By default returns all the available Language Tags.
`parent_tag_id` returns an empty result because all Language tags are root tags.
Use `search_term` to filter the results by values that contains `search_term`.
"""
if parent_tag_id:
return self.tag_set.none()

available_langs = self._get_available_languages()
tag_set = self.tag_set.filter(external_id__in=available_langs)
return super().get_filtered_tags(
tag_set=tag_set,
search_term=search_term,
search_in_all=search_in_all,
)

def _get_available_languages(cls) -> set[str]:
"""
Get available languages from Django LANGUAGE.
Expand Down
29 changes: 29 additions & 0 deletions openedx_tagging/core/tagging/rest_api/paginators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from edx_rest_framework_extensions.paginators import DefaultPagination # type: ignore[import]

# From this point, the tags begin to be paginated
TAGS_THRESHOLD = 1000

# From this point, search tags begin to be paginated
SEARCH_TAGS_THRESHOLD = 200


class TagsPagination(DefaultPagination):
"""
Custom pagination configuration for taxonomies
with a large number of tags. Used on the get tags API view.
"""
page_size = 10
max_page_size = 300


class DisabledTagsPagination(DefaultPagination):
"""
Custom pagination configuration for taxonomies
with a small number of tags. Used on the get tags API view
This class allows to bring all the tags of the taxonomy.
It should be used if the number of tags within
the taxonomy does not exceed `TAGS_THRESHOLD`.
"""
page_size = TAGS_THRESHOLD
max_page_size = TAGS_THRESHOLD + 1
14 changes: 13 additions & 1 deletion openedx_tagging/core/tagging/rest_api/v1/permissions.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""
Tagging permissions
"""

import rules # type: ignore[import]
from rest_framework.permissions import DjangoObjectPermissions


Expand All @@ -27,3 +27,15 @@ class ObjectTagObjectPermissions(DjangoObjectPermissions):
"PATCH": ["%(app_label)s.change_%(model_name)s"],
"DELETE": ["%(app_label)s.delete_%(model_name)s"],
}


class TagListPermissions(DjangoObjectPermissions):
def has_permission(self, request, view):
if not request.user or (
not request.user.is_authenticated and self.authenticated_users_only
):
return False
return True

def has_object_permission(self, request, view, obj):
return rules.has_perm("oel_tagging.list_tag", request.user, obj)
Loading

0 comments on commit 69519f4

Please sign in to comment.