Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: CRUD API for Taxonomy Tags [FC-0036] #96

Merged
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
111 changes: 111 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,117 @@ 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:
# Check if parent tag is valid
if not Tag.objects.filter(value__iexact=parent_tag_value).exists():
raise ValueError("Invalid `parent_tag_value` provided")
bradenmacdonald marked this conversation as resolved.
Show resolved Hide resolved

# 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."
)

# Check if tag is valid
if not Tag.objects.filter(value__iexact=tag).exists():
raise ValueError("Invalid `tag` provided")
bradenmacdonald marked this conversation as resolved.
Show resolved Hide resolved

# 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."
)

# Check if tags provided are valid
if not Tag.objects.filter(value__in=tags).count() == len(tags):
raise ValueError("One or more tag in `tags` is invalid")
bradenmacdonald marked this conversation as resolved.
Show resolved Hide resolved

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.
yusuf-musleh marked this conversation as resolved.
Show resolved Hide resolved
"""
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
Loading