Skip to content

Commit

Permalink
feat: CRUD API for Taxonomy Tags [FC-0036] (#96)
Browse files Browse the repository at this point in the history
* Implements add, update, delete taxonomy tag api/rest + tests
* resyncs ObjectTags when tag added or updated.
* Updates Taxonomy Tags permissions to use Taxonomy permissions
* Bumps version to 0.2.6
  • Loading branch information
yusuf-musleh authored Oct 22, 2023
1 parent 9a63ca6 commit 1081ea7
Show file tree
Hide file tree
Showing 9 changed files with 950 additions and 38 deletions.
2 changes: 1 addition & 1 deletion openedx_learning/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""
Open edX Learning ("Learning Core").
"""
__version__ = "0.2.5"
__version__ = "0.2.6"
55 changes: 55 additions & 0 deletions openedx_tagging/core/tagging/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ def resync_object_tags(object_tags: QuerySet | None = None) -> int:
if changed:
object_tag.save()
num_changed += 1

return num_changed


Expand Down Expand Up @@ -315,3 +316,57 @@ def autocomplete_tags(
# remove repeats
.distinct()
)


def add_tag_to_taxonomy(
taxonomy: Taxonomy,
tag: str,
parent_tag_value: str | None = None,
external_id: str | None = None
) -> Tag:
"""
Adds a new Tag to provided Taxonomy. If a Tag already exists in the
Taxonomy, an exception is raised, otherwise the newly created
Tag is returned
"""
taxonomy = taxonomy.cast()
new_tag = taxonomy.add_tag(tag, parent_tag_value, external_id)

# Resync all related ObjectTags after creating new Tag to
# to ensure any existing ObjectTags with the same value will
# be linked to the new Tag
object_tags = taxonomy.objecttag_set.all()
resync_object_tags(object_tags)

return new_tag


def update_tag_in_taxonomy(taxonomy: Taxonomy, tag: str, new_value: str):
"""
Update a Tag that belongs to a Taxonomy. The related ObjectTags are
updated accordingly.
Currently only supports updating the Tag value.
"""
taxonomy = taxonomy.cast()
updated_tag = taxonomy.update_tag(tag, new_value)

# Resync all related ObjectTags to update to the new Tag value
object_tags = taxonomy.objecttag_set.all()
resync_object_tags(object_tags)

return updated_tag


def delete_tags_from_taxonomy(
taxonomy: Taxonomy,
tags: list[str],
with_subtags: bool
):
"""
Delete Tags that belong to a Taxonomy. If any of the Tags have children and
the `with_subtags` is not set to `True` it will fail, otherwise
the sub-tags will be deleted as well.
"""
taxonomy = taxonomy.cast()
taxonomy.delete_tags(tags, with_subtags)
99 changes: 99 additions & 0 deletions openedx_tagging/core/tagging/models/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,105 @@ def get_filtered_tags(

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

def add_tag(
self,
tag_value: str,
parent_tag_value: str | None = None,
external_id: str | None = None
) -> Tag:
"""
Add new Tag to Taxonomy. If an existing Tag with the `tag_value` already
exists in the Taxonomy, an exception is raised, otherwise the newly
created Tag is returned
"""
self.check_casted()

if self.allow_free_text:
raise ValueError(
"add_tag() doesn't work for free text taxonomies. They don't use Tag instances."
)

if self.system_defined:
raise ValueError(
"add_tag() doesn't work for system defined taxonomies. They cannot be modified."
)

if self.tag_set.filter(value__iexact=tag_value).exists():
raise ValueError(f"Tag with value '{tag_value}' already exists for taxonomy.")

parent = None
if parent_tag_value:
# Get parent tag from taxonomy, raises Tag.DoesNotExist if doesn't
# belong to taxonomy
parent = self.tag_set.get(value__iexact=parent_tag_value)

tag = Tag.objects.create(
taxonomy=self, value=tag_value, parent=parent, external_id=external_id
)

return tag

def update_tag(self, tag: str, new_value: str) -> Tag:
"""
Update an existing Tag in Taxonomy and return it. Currently only
supports updating the Tag's value.
"""
self.check_casted()

if self.allow_free_text:
raise ValueError(
"update_tag() doesn't work for free text taxonomies. They don't use Tag instances."
)

if self.system_defined:
raise ValueError(
"update_tag() doesn't work for system defined taxonomies. They cannot be modified."
)

# Update Tag instance with new value, raises Tag.DoesNotExist if
# tag doesn't belong to taxonomy
tag_to_update = self.tag_set.get(value__iexact=tag)
tag_to_update.value = new_value
tag_to_update.save()
return tag_to_update

def delete_tags(self, tags: List[str], with_subtags: bool = False):
"""
Delete the Taxonomy Tags provided. If any of them have children and
the `with_subtags` is not set to `True` it will fail, otherwise
the sub-tags will be deleted as well.
"""
self.check_casted()

if self.allow_free_text:
raise ValueError(
"delete_tags() doesn't work for free text taxonomies. They don't use Tag instances."
)

if self.system_defined:
raise ValueError(
"delete_tags() doesn't work for system defined taxonomies. They cannot be modified."
)

tags_to_delete = self.tag_set.filter(value__in=tags)

if tags_to_delete.count() != len(tags):
# If they do not match that means there is one or more Tag ID(s)
# provided that do not belong to this Taxonomy
raise ValueError("Invalid tag id provided or tag id does not belong to taxonomy")

# Check if any Tag contains subtags (children)
contains_children = tags_to_delete.filter(children__isnull=False).distinct().exists()

if contains_children and not with_subtags:
raise ValueError(
"Tag(s) contain children, `with_subtags` must be `True` for "
"all Tags and their subtags (children) to be deleted."
)

# Delete the Tags with their subtags if any
tags_to_delete.delete()

def validate_value(self, value: str) -> bool:
"""
Check if 'value' is part of this Taxonomy.
Expand Down
26 changes: 15 additions & 11 deletions openedx_tagging/core/tagging/rest_api/v1/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import rules # type: ignore[import]
from rest_framework.permissions import DjangoObjectPermissions

from ...models import Tag


class TaxonomyObjectPermissions(DjangoObjectPermissions):
"""
Expand Down Expand Up @@ -35,22 +37,24 @@ class ObjectTagObjectPermissions(DjangoObjectPermissions):
}


class TagListPermissions(DjangoObjectPermissions):
class TagObjectPermissions(DjangoObjectPermissions):
"""
Permissions for Tag object views.
Maps each REST API methods to its corresponding Tag permission.
"""
def has_permission(self, request, view):
"""
Returns True if the user on the given request is allowed the given view.
"""
if not request.user or (
not request.user.is_authenticated and self.authenticated_users_only
):
return False
return True
perms_map = {
"GET": ["%(app_label)s.view_%(model_name)s"],
"OPTIONS": [],
"HEAD": ["%(app_label)s.view_%(model_name)s"],
"POST": ["%(app_label)s.add_%(model_name)s"],
"PUT": ["%(app_label)s.change_%(model_name)s"],
"PATCH": ["%(app_label)s.change_%(model_name)s"],
"DELETE": ["%(app_label)s.delete_%(model_name)s"],
}

# This is to handle the special case for GET list of Taxonomy Tags
def has_object_permission(self, request, view, obj):
"""
Returns True if the user on the given request is allowed the given view for the given object.
"""
obj = obj.taxonomy if isinstance(obj, Tag) else obj
return rules.has_perm("oel_tagging.list_tag", request.user, obj)
37 changes: 34 additions & 3 deletions openedx_tagging/core/tagging/rest_api/v1/serializers.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
"""
API Serializers for taxonomies
"""

from rest_framework import serializers
from rest_framework.reverse import reverse

Expand Down Expand Up @@ -110,6 +109,7 @@ class Meta:
"value",
"taxonomy_id",
"parent_id",
"external_id",
"sub_tags_link",
"children_count",
)
Expand All @@ -120,11 +120,12 @@ def get_sub_tags_link(self, obj):
"""
if obj.children.count():
query_params = f"?parent_tag_id={obj.id}"
request = self.context.get("request")
url_namespace = request.resolver_match.namespace # get the namespace, usually "oel_tagging"
url = (
reverse("oel_tagging:taxonomy-tags", args=[str(obj.taxonomy_id)])
reverse(f"{url_namespace}:taxonomy-tags", args=[str(obj.taxonomy_id)])
+ query_params
)
request = self.context.get("request")
return request.build_absolute_uri(url)
return None

Expand Down Expand Up @@ -192,3 +193,33 @@ def get_children_count(self, obj):
Returns the number of child tags of the given tag.
"""
return len(obj.sub_tags)


class TaxonomyTagCreateBodySerializer(serializers.Serializer): # pylint: disable=abstract-method
"""
Serializer of the body for the Taxonomy Tags CREATE request
"""

tag = serializers.CharField(required=True)
parent_tag_value = serializers.CharField(required=False)
external_id = serializers.CharField(required=False)


class TaxonomyTagUpdateBodySerializer(serializers.Serializer): # pylint: disable=abstract-method
"""
Serializer of the body for the Taxonomy Tags UPDATE request
"""

tag = serializers.CharField(required=True)
updated_tag_value = serializers.CharField(required=True)


class TaxonomyTagDeleteBodySerializer(serializers.Serializer): # pylint: disable=abstract-method
"""
Serializer of the body for the Taxonomy Tags DELETE request
"""

tags = serializers.ListField(
child=serializers.CharField(), required=True
)
with_subtags = serializers.BooleanField(required=False)
Loading

0 comments on commit 1081ea7

Please sign in to comment.