1313from openedx_tagging .data import TagData
1414from openedx_tagging .import_export .parsers import ParserFormat
1515from openedx_tagging .models import ObjectTag , Tag , TagImportTask , Taxonomy
16+ from openedx_tagging .models .utils import RESERVED_TAG_CHARS
1617from openedx_tagging .rules import ObjectTagPermissionItem
1718
1819from ..utils import UserPermissionsHelper
@@ -59,7 +60,7 @@ def _model(self) -> Type:
5960 @property
6061 def _request (self ) -> Request :
6162 """
62- Returns the current request from the serialize context.
63+ Returns the current request from the serializer context.
6364 """
6465 return self .context .get ('request' ) # type: ignore[attr-defined]
6566
@@ -217,6 +218,27 @@ class ObjectTagUpdateBodySerializer(serializers.Serializer): # pylint: disable=
217218 tagsData = serializers .ListField (child = ObjectTagUpdateByTaxonomySerializer (), required = True )
218219
219220
221+ def validate_tag_value (value , context ):
222+ """
223+ Validate this tag value is unique within the current taxonomy context and
224+ does not contain forbidden characters.
225+ """
226+ taxonomy_id = context .get ("taxonomy_id" )
227+ if taxonomy_id is not None :
228+ # Check if tag value already exists within this taxonomy. If so, raise a validation error.
229+ queryset = Tag .objects .filter (taxonomy_id = taxonomy_id , value = value )
230+ if queryset .exists ():
231+ raise serializers .ValidationError (
232+ f'Tag value "{ value } " already exists in this taxonomy.' , code = 'unique'
233+ )
234+
235+ # validator checks there are no forbidden characters ">" or ";":
236+ for char in value :
237+ if char in RESERVED_TAG_CHARS :
238+ raise serializers .ValidationError ('Tag values cannot contain "\t " or ">" or ";" characters.' )
239+ return value
240+
241+
220242class TagDataSerializer (UserPermissionsSerializerMixin , serializers .Serializer ): # pylint: disable=abstract-method
221243 """
222244 Serializer for TagData dicts. Also can serialize Tag instances.
@@ -237,6 +259,12 @@ class TagDataSerializer(UserPermissionsSerializerMixin, serializers.Serializer):
237259 can_change_tag = serializers .SerializerMethodField ()
238260 can_delete_tag = serializers .SerializerMethodField ()
239261
262+ def validate_value (self , value ):
263+ """
264+ Runs validations for the tag value.
265+ """
266+ return validate_tag_value (value , self .context )
267+
240268 def get_sub_tags_url (self , obj : TagData | Tag ):
241269 """
242270 Returns URL for the list of child tags of the current tag.
@@ -303,6 +331,12 @@ class TaxonomyTagCreateBodySerializer(serializers.Serializer): # pylint: disabl
303331 parent_tag_value = serializers .CharField (required = False )
304332 external_id = serializers .CharField (required = False )
305333
334+ def validate_tag (self , value ):
335+ """
336+ Run validations for the tag value.
337+ """
338+ return validate_tag_value (value , self .context )
339+
306340
307341class TaxonomyTagUpdateBodySerializer (serializers .Serializer ): # pylint: disable=abstract-method
308342 """
@@ -312,6 +346,12 @@ class TaxonomyTagUpdateBodySerializer(serializers.Serializer): # pylint: disabl
312346 tag = serializers .CharField (required = True )
313347 updated_tag_value = serializers .CharField (required = True )
314348
349+ def validate_updated_tag_value (self , value ):
350+ """
351+ Run validations for the updated tag value.
352+ """
353+ return validate_tag_value (value , self .context )
354+
315355
316356class TaxonomyTagDeleteBodySerializer (serializers .Serializer ): # pylint: disable=abstract-method
317357 """
@@ -323,6 +363,25 @@ class TaxonomyTagDeleteBodySerializer(serializers.Serializer): # pylint: disabl
323363 )
324364 with_subtags = serializers .BooleanField (required = False )
325365
366+ def validate_tags (self , tags_list ):
367+ """
368+ Make sure all tags are valid and exist before attempting deletion, to avoid partial deletes.
369+ """
370+ # Iterate through the list and make one bulk request that checks whether every tag.value exists
371+ taxonomy_id = self .context .get ("taxonomy_id" )
372+ existing_tags = set (
373+ Tag .objects .filter (taxonomy_id = taxonomy_id , value__in = tags_list )
374+ .values_list ("value" , flat = True )
375+ )
376+ missing_tags = set (tags_list ) - existing_tags
377+
378+ if missing_tags :
379+ raise serializers .ValidationError (
380+ f"Deletion aborted. The following tags do not exist and cannot be deleted:"
381+ f" { ', ' .join (missing_tags )} "
382+ )
383+ return tags_list
384+
326385
327386class TaxonomyImportBodySerializer (serializers .Serializer ): # pylint: disable=abstract-method
328387 """
0 commit comments