diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 788bc1c5a..0c59cf31a 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -389,6 +389,29 @@ class SiteTest(APIViewTestCases.APIViewTestCase): response = self.client.post(self._get_list_url(), data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + def test_add_and_remove_same_tag_error(self): + """ + Including the same tag in both add_tags and remove_tags should raise a validation error. + """ + site = Site.objects.first() + Tag.objects.bulk_create(( + Tag(name='Alpha', slug='alpha'), + Tag(name='Bravo', slug='bravo'), + )) + + obj_perm = ObjectPermission(name='Test permission', actions=['change']) + obj_perm.save() + obj_perm.users.add(self.user) + obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model)) + + url = self._get_detail_url(site) + data = { + 'add_tags': [{'name': 'Alpha'}, {'name': 'Bravo'}], + 'remove_tags': [{'name': 'Alpha'}], + } + response = self.client.patch(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + class LocationTest(APIViewTestCases.APIViewTestCase): model = Location diff --git a/netbox/netbox/api/serializers/features.py b/netbox/netbox/api/serializers/features.py index 382ba7891..45e7bce17 100644 --- a/netbox/netbox/api/serializers/features.py +++ b/netbox/netbox/api/serializers/features.py @@ -60,6 +60,17 @@ class TaggableModelSerializer(serializers.Serializer): 'remove_tags': 'Cannot use "remove_tags" when creating a new object.' }) + if data.get('add_tags') and data.get('remove_tags'): + add_pks = {t.pk for t in data['add_tags']} + remove_pks = {t.pk for t in data['remove_tags']} + overlap = [t for t in data['add_tags'] if t.pk in (add_pks & remove_pks)] + if overlap: + raise serializers.ValidationError({ + 'remove_tags': + f'Tags may not be present in both "add_tags" and "remove_tags": ' + f'{", ".join(t.name for t in overlap)}' + }) + # Pop add_tags/remove_tags before calling super() to prevent them from being passed # to the model constructor during ValidatedModelSerializer validation add_tags = data.pop('add_tags', None)