diff --git a/openedx_tagging/core/tagging/api.py b/openedx_tagging/core/tagging/api.py index 2c78b10c..a8a4e7bc 100644 --- a/openedx_tagging/core/tagging/api.py +++ b/openedx_tagging/core/tagging/api.py @@ -129,13 +129,7 @@ def resync_object_tags(object_tags: QuerySet | None = None) -> int: if not object_tags: object_tags = ObjectTag.objects.select_related("tag", "taxonomy") - num_changed = 0 - for object_tag in object_tags: - changed = object_tag.resync() - if changed: - object_tag.save() - num_changed += 1 - return num_changed + return ObjectTag.resync_object_tags(object_tags) def get_object_tags( @@ -329,3 +323,13 @@ def add_tag_to_taxonomy( Tag is returned """ return taxonomy.cast().add_tag(tag, parent_tag_id, external_id) + + +def update_tag_in_taxonomy(taxonomy: Taxonomy, tag: int, tag_value: str): + """ + Update a Tag that belongs to a Taxonomy. The related ObjectTags are + updated accordingly. + + Currently only support updates the Tag value. + """ + return taxonomy.cast().update_tag(tag, tag_value) diff --git a/openedx_tagging/core/tagging/models/base.py b/openedx_tagging/core/tagging/models/base.py index ffcef13e..5e973c51 100644 --- a/openedx_tagging/core/tagging/models/base.py +++ b/openedx_tagging/core/tagging/models/base.py @@ -366,14 +366,12 @@ def add_tag( "add_tag() doesn't work for system defined taxonomies. They cannot be modified." ) - current_tags = self.get_tags() - 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_id: - parent = Tag.objects.get(id=parent_tag_id) + parent = self.tag_set.get(id=parent_tag_id) tag = Tag.objects.create( taxonomy=self, value=tag_value, parent=parent, external_id=external_id @@ -381,6 +379,33 @@ def add_tag( return tag + def update_tag(self, tag_id: int, tag_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 + tag = self.tag_set.get(id=tag_id) + tag.value = tag_value + tag.save() + + # Resync all related ObjectTags to update to the new Tag value + object_tags = self.objecttag_set.all() + ObjectTag.resync_object_tags(object_tags) + return tag + def validate_value(self, value: str) -> bool: """ Check if 'value' is part of this Taxonomy. @@ -648,3 +673,18 @@ def copy(self, object_tag: ObjectTag) -> Self: self._value = object_tag._value # pylint: disable=protected-access self._name = object_tag._name # pylint: disable=protected-access return self + + @classmethod + def resync_object_tags(cls, object_tags: models.QuerySet[ObjectTag]) -> int: + """ + Reconciles ObjectTag entries with any changes made to their associated + taxonomies and tags. Return the number of changes made. + """ + num_changed = 0 + for object_tag in object_tags: + changed = object_tag.resync() + if changed: + object_tag.save() + num_changed += 1 + + return num_changed diff --git a/openedx_tagging/core/tagging/rest_api/v1/serializers.py b/openedx_tagging/core/tagging/rest_api/v1/serializers.py index b6912839..fb6d6c84 100644 --- a/openedx_tagging/core/tagging/rest_api/v1/serializers.py +++ b/openedx_tagging/core/tagging/rest_api/v1/serializers.py @@ -205,3 +205,14 @@ class TaxonomyTagCreateBodySerializer(serializers.Serializer): # pylint: disabl queryset=Tag.objects.all(), 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 view + """ + + tag = serializers.PrimaryKeyRelatedField( + queryset=Tag.objects.all(), required=True + ) + tag_value = serializers.CharField(required=True) diff --git a/openedx_tagging/core/tagging/rest_api/v1/views.py b/openedx_tagging/core/tagging/rest_api/v1/views.py index 733eefad..8392d35e 100644 --- a/openedx_tagging/core/tagging/rest_api/v1/views.py +++ b/openedx_tagging/core/tagging/rest_api/v1/views.py @@ -3,7 +3,7 @@ """ from __future__ import annotations -from django.db import model +from django.db import models from django.http import Http404, HttpResponse from rest_framework import mixins, status from rest_framework.decorators import action @@ -15,6 +15,7 @@ from openedx_tagging.core.tagging.models.base import Tag from ...api import ( + add_tag_to_taxonomy, create_taxonomy, get_children_tags, get_object_tags, @@ -23,7 +24,7 @@ get_taxonomy, search_tags, tag_object, - add_tag_to_taxonomy, + update_tag_in_taxonomy, ) from ...import_export.api import export_tags from ...import_export.parsers import ParserFormat @@ -43,6 +44,7 @@ TaxonomyListQueryParamsSerializer, TaxonomySerializer, TaxonomyTagCreateBodySerializer, + TaxonomyTagUpdateBodySerializer, ) from .utils import view_auth_classes @@ -602,9 +604,40 @@ def post(self, request, *args, **kwargs): new_tag = add_tag_to_taxonomy( taxonomy, tag, parent_tag_id, external_id ) + except Tag.DoesNotExist as e: + raise Http404("Parent Tag not found") from e + except ValueError as e: + raise ValidationError from e + + serializer_context = self.get_serializer_context() + return Response( + self.serializer_class(new_tag, context=serializer_context).data, + status=status.HTTP_201_CREATED + ) + + def update(self, request, *args, **kwargs): + """ + Updates a Tag that belongs to the Taxonomy and returns it. + Currently only updating the Tag value is supported. + """ + pk = self.kwargs.get("pk") + taxonomy = self.get_taxonomy(pk) + + body = TaxonomyTagUpdateBodySerializer(data=request.data) + body.is_valid(raise_exception=True) + + tag = body.data.get("tag") + tag_value = body.data.get("tag_value") + + try: + updated_tag = update_tag_in_taxonomy(taxonomy, tag, tag_value) + except Tag.DoesNotExist as e: + raise Http404("Tag not found") from e except ValueError as e: raise ValidationError from e + serializer_context = self.get_serializer_context() return Response( - TagsSerializer(new_tag).data, status=status.HTTP_201_CREATED + self.serializer_class(updated_tag, context=serializer_context).data, + 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 b137bd37..66ea85bb 100644 --- a/tests/openedx_tagging/core/tagging/test_views.py +++ b/tests/openedx_tagging/core/tagging/test_views.py @@ -1310,6 +1310,22 @@ def test_create_tag_in_taxonomy_with_invalid_parent_tag_id(self): assert response.status_code == status.HTTP_400_BAD_REQUEST + def test_create_tag_in_taxonomy_with_parent_tag_id_in_other_taxonomy(self): + self.client.force_authenticate(user=self.user) + invalid_parent_tag_id = 1 + new_tag_value = "New Child Tag" + + create_data = { + "tag": new_tag_value, + "parent_tag_id": invalid_parent_tag_id, + } + + response = self.client.post( + self.large_taxonomy_url, create_data, format="json" + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + def test_create_tag_in_taxonomy_with_already_existing_value(self): self.client.force_authenticate(user=self.user) new_tag_value = "New Tag" @@ -1330,3 +1346,165 @@ def test_create_tag_in_taxonomy_with_already_existing_value(self): ) assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_update_tag_in_taxonomy_with_different_methods(self): + self.client.force_authenticate(user=self.user) + updated_tag_value = "Updated Tag" + updated_tag_value_2 = "Updated Tag 2" + + # Existing Tag that will be updated + existing_tag = self.small_taxonomy.tag_set.filter(parent=None).first() + + update_data = { + "tag": existing_tag.id, + "tag_value": updated_tag_value + } + + # Test updating using the PUT method + response = self.client.put( + self.small_taxonomy_url, update_data, format="json" + ) + + assert response.status_code == status.HTTP_200_OK + + data = response.data + + # Check that Tag value got updated + self.assertEqual(data.get("id"), existing_tag.id) + self.assertEqual(data.get("value"), updated_tag_value) + self.assertEqual(data.get("taxonomy_id"), self.small_taxonomy.pk) + self.assertEqual(data.get("parent_id"), existing_tag.parent) + self.assertEqual(data.get("external_id"), existing_tag.external_id) + + # Test updating using the PATCH method + update_data["tag_value"] = updated_tag_value_2 + response = self.client.patch( + self.small_taxonomy_url, update_data, format="json" + ) + + assert response.status_code == status.HTTP_200_OK + + data = response.data + + # Check the Tag value got updated again + self.assertEqual(data.get("id"), existing_tag.id) + self.assertEqual(data.get("value"), updated_tag_value_2) + self.assertEqual(data.get("taxonomy_id"), self.small_taxonomy.pk) + self.assertEqual(data.get("parent_id"), existing_tag.parent) + self.assertEqual(data.get("external_id"), existing_tag.external_id) + + def test_update_tag_in_taxonomy_reflects_changes_in_object_tags(self): + self.client.force_authenticate(user=self.user) + + existing_tag = self.small_taxonomy.tag_set.filter(parent=None).first() + + # Setup ObjectTags + # _value=existing_tag.value + object_tag_1 = ObjectTag.objects.create( + object_id="abc", taxonomy=self.small_taxonomy, tag=existing_tag + ) + object_tag_2 = ObjectTag.objects.create( + object_id="def", taxonomy=self.small_taxonomy, tag=existing_tag + ) + object_tag_3 = ObjectTag.objects.create( + object_id="ghi", taxonomy=self.small_taxonomy, tag=existing_tag + ) + + assert object_tag_1.value == existing_tag.value + assert object_tag_2.value == existing_tag.value + assert object_tag_3.value == existing_tag.value + + updated_tag_value = "Updated Tag" + update_data = { + "tag": existing_tag.id, + "tag_value": updated_tag_value + } + + # Test updating using the PUT method + response = self.client.put( + self.small_taxonomy_url, update_data, format="json" + ) + + assert response.status_code == status.HTTP_200_OK + + data = response.data + + # Check that Tag value got updated + self.assertEqual(data.get("id"), existing_tag.id) + self.assertEqual(data.get("value"), updated_tag_value) + self.assertEqual(data.get("taxonomy_id"), self.small_taxonomy.pk) + self.assertEqual(data.get("parent_id"), existing_tag.parent) + self.assertEqual(data.get("external_id"), existing_tag.external_id) + + # Check that the ObjectTags got updated as well + object_tag_1.refresh_from_db() + self.assertEqual(object_tag_1.value, updated_tag_value) + object_tag_2.refresh_from_db() + self.assertEqual(object_tag_2.value, updated_tag_value) + object_tag_3.refresh_from_db() + self.assertEqual(object_tag_3.value, updated_tag_value) + + def test_update_tag_in_taxonomy_with_invalid_tag_id(self): + self.client.force_authenticate(user=self.user) + updated_tag_value = "Updated Tag" + + update_data = { + "tag": 919191, + "tag_value": updated_tag_value + } + + response = self.client.put( + self.small_taxonomy_url, update_data, format="json" + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_update_tag_in_taxonomy_with_tag_id_in_other_taxonomy(self): + self.client.force_authenticate(user=self.user) + updated_tag_value = "Updated Tag" + + update_data = { + "tag": 1, + "tag_value": updated_tag_value + } + + response = self.client.put( + self.large_taxonomy_url, update_data, format="json" + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_update_tag_in_taxonomy_with_no_tag_value_provided(self): + self.client.force_authenticate(user=self.user) + + # Existing Tag that will be updated + existing_tag = self.small_taxonomy.tag_set.filter(parent=None).first() + + update_data = { + "tag": existing_tag.id + } + + response = self.client.put( + self.small_taxonomy_url, update_data, format="json" + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_update_tag_in_invalid_taxonomy(self): + self.client.force_authenticate(user=self.user) + + # Existing Tag that will be updated + existing_tag = self.small_taxonomy.tag_set.filter(parent=None).first() + + updated_tag_value = "Updated Tag" + update_data = { + "tag": existing_tag.id, + "tag_value": updated_tag_value + } + + invalid_taxonomy_url = TAXONOMY_TAGS_URL.format(pk=919191) + response = self.client.put( + invalid_taxonomy_url, update_data, format="json" + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND