diff --git a/openedx_tagging/core/tagging/api.py b/openedx_tagging/core/tagging/api.py index a8a4e7bc..19d4f11e 100644 --- a/openedx_tagging/core/tagging/api.py +++ b/openedx_tagging/core/tagging/api.py @@ -333,3 +333,16 @@ def update_tag_in_taxonomy(taxonomy: Taxonomy, tag: int, tag_value: str): Currently only support updates the Tag value. """ return taxonomy.cast().update_tag(tag, tag_value) + + +def delete_tags_from_taxonomy( + taxonomy: Taxonomy, + tag_ids: list[Tag], + 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. + """ + return taxonomy.cast().delete_tags(tag_ids, with_subtags) diff --git a/openedx_tagging/core/tagging/models/base.py b/openedx_tagging/core/tagging/models/base.py index 5e973c51..5fcdcb33 100644 --- a/openedx_tagging/core/tagging/models/base.py +++ b/openedx_tagging/core/tagging/models/base.py @@ -406,6 +406,43 @@ def update_tag(self, tag_id: int, tag_value: str) -> Tag: ObjectTag.resync_object_tags(object_tags) return tag + def delete_tags(self, tag_ids: List[int], 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 = self.tag_set.filter(id__in=tag_ids) + + if tags.count() != len(tag_ids): + # If they do not match that means there is a Tag ID in the provided + # list that is either invalid or does 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.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.delete() + def validate_value(self, value: str) -> bool: """ Check if 'value' is part of this Taxonomy. diff --git a/openedx_tagging/core/tagging/rest_api/v1/serializers.py b/openedx_tagging/core/tagging/rest_api/v1/serializers.py index fb6d6c84..0a1ef3cb 100644 --- a/openedx_tagging/core/tagging/rest_api/v1/serializers.py +++ b/openedx_tagging/core/tagging/rest_api/v1/serializers.py @@ -216,3 +216,16 @@ class TaxonomyTagUpdateBodySerializer(serializers.Serializer): # pylint: disabl queryset=Tag.objects.all(), required=True ) tag_value = serializers.CharField(required=True) + + +class TaxonomyTagDeleteBodySerializer(serializers.Serializer): # pylint: disable=abstract-method + """ + Serializer of the body for the Taxonomy Tags DELETE view + """ + + tag_ids = serializers.PrimaryKeyRelatedField( + queryset=Tag.objects.all(), + many=True, + required=True + ) + with_subtags = serializers.BooleanField(required=False) diff --git a/openedx_tagging/core/tagging/rest_api/v1/views.py b/openedx_tagging/core/tagging/rest_api/v1/views.py index 8392d35e..d800c8a7 100644 --- a/openedx_tagging/core/tagging/rest_api/v1/views.py +++ b/openedx_tagging/core/tagging/rest_api/v1/views.py @@ -17,6 +17,7 @@ from ...api import ( add_tag_to_taxonomy, create_taxonomy, + delete_tags_from_taxonomy, get_children_tags, get_object_tags, get_root_tags, @@ -44,6 +45,7 @@ TaxonomyListQueryParamsSerializer, TaxonomySerializer, TaxonomyTagCreateBodySerializer, + TaxonomyTagDeleteBodySerializer, TaxonomyTagUpdateBodySerializer, ) from .utils import view_auth_classes @@ -428,6 +430,53 @@ class TaxonomyTagsView(ListAPIView, RetrieveUpdateDestroyAPIView): * 403 - Permission denied * 404 - Taxonomy not found + **Update Query Parameters** + * pk (required) - The pk of the taxonomy to update a Tag in + + **Update Request Body** + * tag (required): The ID of the Tag that should be updated + * tag_value (required): The updated value of the Tag + + **Update Example Requests** + PUT api/tagging/v1/taxonomy/:pk/tags - Update a Tag in Taxonomy + { + "tag": 1, + "tag_value": "Updated Tag Value" + } + PATCH api/tagging/v1/taxonomy/:pk/tags - Update a Tag in Taxonomy + { + "tag": 1, + "tag_value": "Updated Tag Value" + } + + **Update Query Returns** + * 200 - Success + * 400 - Invalid parameters provided + * 403 - Permission denied + * 404 - Taxonomy, Tag or Parent Tag not found + + **Delete Query Parameters** + * pk (required) - The pk of the taxonomy to Delete Tag(s) in + + **Delete Request Body** + * tag_ids (required): The IDs of Tags that should be deleted from Taxonomy + * with_subtags (optional): If a Tag in the provided ids contains + children (subtags), deletion will fail unless + set to `True`. Defaults to `False`. + + **Delete Example Requests** + DELETE api/tagging/v1/taxonomy/:pk/tags - Delete Tag(s) in Taxonomy + { + "tag_ids": [1,2,3], + "with_subtags": True + } + + **Delete Query Returns** + * 200 - Success + * 400 - Invalid parameters provided + * 403 - Permission denied + * 404 - Taxonomy not found + """ permission_classes = [TagListPermissions] @@ -607,7 +656,7 @@ def post(self, request, *args, **kwargs): except Tag.DoesNotExist as e: raise Http404("Parent Tag not found") from e except ValueError as e: - raise ValidationError from e + raise ValidationError(e) from e serializer_context = self.get_serializer_context() return Response( @@ -634,10 +683,32 @@ def update(self, request, *args, **kwargs): except Tag.DoesNotExist as e: raise Http404("Tag not found") from e except ValueError as e: - raise ValidationError from e + raise ValidationError(e) from e serializer_context = self.get_serializer_context() return Response( self.serializer_class(updated_tag, context=serializer_context).data, status=status.HTTP_200_OK ) + + def delete(self, request, *args, **kwargs): + """ + Deletes Tag(s) in 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. + """ + pk = self.kwargs.get("pk") + taxonomy = self.get_taxonomy(pk) + + body = TaxonomyTagDeleteBodySerializer(data=request.data) + body.is_valid(raise_exception=True) + + tag_ids = body.data.get("tag_ids") + with_subtags = body.data.get("with_subtags") + + try: + delete_tags_from_taxonomy(taxonomy, tag_ids, with_subtags) + except ValueError as e: + raise ValidationError(e) from e + + return Response(status=status.HTTP_200_OK) diff --git a/tests/openedx_tagging/core/tagging/test_views.py b/tests/openedx_tagging/core/tagging/test_views.py index 66ea85bb..1595af14 100644 --- a/tests/openedx_tagging/core/tagging/test_views.py +++ b/tests/openedx_tagging/core/tagging/test_views.py @@ -1508,3 +1508,128 @@ def test_update_tag_in_invalid_taxonomy(self): ) assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_delete_single_tag_from_taxonomy(self): + self.client.force_authenticate(user=self.user) + + # Get Tag that will be deleted + existing_tag = self.small_taxonomy.tag_set.filter(parent=None).first() + + delete_data = { + "tag_ids": [existing_tag.id], + "with_subtags": True + } + + response = self.client.delete( + self.small_taxonomy_url, delete_data, format="json" + ) + + assert response.status_code == status.HTTP_200_OK + + # Check that Tag no longer exists + with self.assertRaises(Tag.DoesNotExist) as exc: + existing_tag.refresh_from_db() + + def test_delete_multiple_tags_from_taxonomy(self): + self.client.force_authenticate(user=self.user) + + # Get Tags that will be deleted + existing_tags = self.small_taxonomy.tag_set.filter(parent=None)[:3] + + delete_data = { + "tag_ids": [existing_tag.id for existing_tag in existing_tags], + "with_subtags": True + } + + response = self.client.delete( + self.small_taxonomy_url, delete_data, format="json" + ) + + assert response.status_code == status.HTTP_200_OK + + # Check that Tags no longer exists + for existing_tag in existing_tags: + with self.assertRaises(Tag.DoesNotExist) as exc: + existing_tag.refresh_from_db() + + def test_delete_tag_with_subtags_should_fail_without_flag_passed(self): + self.client.force_authenticate(user=self.user) + + # Get Tag that will be deleted + existing_tag = self.small_taxonomy.tag_set.filter(parent=None).first() + + delete_data = { + "tag_ids": [existing_tag.id] + } + + response = self.client.delete( + self.small_taxonomy_url, delete_data, format="json" + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_delete_tag_in_invalid_taxonomy(self): + self.client.force_authenticate(user=self.user) + + # Get Tag that will be deleted + existing_tag = self.small_taxonomy.tag_set.filter(parent=None).first() + + delete_data = { + "tag_ids": [existing_tag.id] + } + + invalid_taxonomy_url = TAXONOMY_TAGS_URL.format(pk=919191) + response = self.client.delete( + invalid_taxonomy_url, delete_data, format="json" + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_delete_tag_in_taxonomy_with_invalid_tag_id(self): + self.client.force_authenticate(user=self.user) + + delete_data = { + "tag_ids": [91919] + } + + response = self.client.delete( + self.small_taxonomy_url, delete_data, format="json" + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_delete_tag_with_tag_id_in_other_taxonomy(self): + self.client.force_authenticate(user=self.user) + + # Get Tag in other Taxonomy + tag_in_other_taxonomy = self.small_taxonomy.tag_set.filter(parent=None).first() + + delete_data = { + "tag_ids": [tag_in_other_taxonomy.id] + } + + response = self.client.delete( + self.large_taxonomy_url, delete_data, format="json" + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_delete_tag_in_taxonomy_without_subtags(self): + self.client.force_authenticate(user=self.user) + + # Get Tag that will be deleted + existing_tag = self.small_taxonomy.tag_set.filter(children__isnull=True).first() + + delete_data = { + "tag_ids": [existing_tag.id] + } + + response = self.client.delete( + self.small_taxonomy_url, delete_data, format="json" + ) + + assert response.status_code == status.HTTP_200_OK + + # Check that Tag no longer exists + with self.assertRaises(Tag.DoesNotExist) as exc: + existing_tag.refresh_from_db()