Flag duplicate object IDs in bulk operations

This commit is contained in:
Jeremy Stretch 2026-08-10 16:15:55 -04:00
parent e6dfad94c5
commit 7de5a62451
3 changed files with 186 additions and 2 deletions

View File

@ -467,6 +467,51 @@ class SiteTestCase(APIViewTestCases.APIViewTestCase):
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
def test_bulk_update_objects_duplicate_id_invalid_entry(self):
"""
PATCH a set of objects in which one object is named twice, once with invalid data and once
with valid data. The invalid entry must not be discarded in favor of the valid one.
"""
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))
site = Site.objects.get(slug='site-1')
data = [
{'id': site.pk, 'name': ''}, # Invalid: name is required
{'id': site.pk, 'name': 'Renamed Site'},
]
response = self.client.patch(self._get_list_url(), data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
self.assertEqual([e['id'] for e in response.data['errors']], [site.pk])
# The valid entry must not have been applied
site.refresh_from_db()
self.assertEqual(site.name, 'Site 1')
def test_bulk_delete_objects_duplicate_id_changelog_message(self):
"""
DELETE a set of objects in which one object is named twice with differing changelog
messages. The request must be rejected rather than recording only one of the messages.
"""
obj_perm = ObjectPermission(name='Test permission', actions=['delete'])
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
site = Site.objects.get(slug='site-1')
data = [
{'id': site.pk, 'changelog_message': 'First message'},
{'id': site.pk, 'changelog_message': 'Second message'},
]
response = self.client.delete(self._get_list_url(), data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
self.assertEqual([e['id'] for e in response.data['errors']], [site.pk])
self.assertTrue(Site.objects.filter(pk=site.pk).exists())
def test_bulk_create_objects_conflicting(self):
"""
POST a set of objects in which two conflict with one another. Objects are created one at a

View File

@ -1,4 +1,5 @@
import warnings
from collections import Counter
from contextlib import contextmanager
from django.core.exceptions import ObjectDoesNotExist
@ -32,10 +33,53 @@ __all__ = (
'ObjectValidationMixin',
'SequentialBulkCreatesMixin',
'discard_events_on_rollback',
'get_duplicate_objects_response',
'get_missing_objects_response',
)
def get_duplicate_objects_response(object_ids):
"""
Return a structured error Response naming each of the given object IDs which appears more than
once, or None if they are all distinct.
A bulk operation identifies its objects by ID, so listing one twice is ambiguous. For an update,
only one of the two sets of attributes can be applied, and the discarded entry is never even
validated: a request pairing an invalid entry with a valid one for the same object would
otherwise report success while silently ignoring the invalid data. For a delete, the repetition
is meaningless, but it likewise causes the response to report on fewer objects than were named.
Rather than guess at the intent, such a request is rejected.
"""
errors = [
{
'id': object_id,
'errors': {
'id': [
_("Each object may be specified only once; ID {id} is listed {count} times").format(
id=object_id, count=count
),
],
},
}
# Counter preserves the order in which each ID was first seen
for object_id, count in Counter(object_ids).items()
if count > 1
]
if not errors:
return None
return Response(
{
'detail': _('{failed_count} of {total} objects are listed more than once.').format(
failed_count=len(errors),
total=len(object_ids),
),
'errors': errors,
},
status=status.HTTP_400_BAD_REQUEST,
)
def get_missing_objects_response(object_ids, queryset):
"""
Return a structured error Response naming each of the given object IDs which the queryset does
@ -60,8 +104,10 @@ def get_missing_objects_response(object_ids, queryset):
'id': [_("Object with ID {id} does not exist").format(id=object_id)],
},
}
# dict.fromkeys() de-duplicates while preserving the order of first appearance, so an ID
# repeated in the request is reported once rather than once per occurrence.
# NetBox's bulk actions reject a repeated ID before reaching this point (see
# get_duplicate_objects_response), but de-duplicate anyway so that any other caller reports
# such an ID once rather than once per occurrence. dict.fromkeys() preserves the order of
# first appearance.
for object_id in dict.fromkeys(object_ids)
if object_id not in found_pks
]
@ -376,6 +422,12 @@ class BulkUpdateModelMixin:
serializer.is_valid(raise_exception=True)
object_ids = [o['id'] for o in serializer.validated_data]
# Reject the batch if any object is named more than once, rather than applying only one of
# the entries given for it.
if (response := get_duplicate_objects_response(object_ids)) is not None:
return response
qs = self.get_bulk_update_queryset().filter(pk__in=object_ids)
# Reject the batch if any of the objects to be updated could not be found, rather than
@ -485,6 +537,12 @@ class BulkDestroyModelMixin:
serializer.is_valid(raise_exception=True)
object_ids = [o['id'] for o in serializer.validated_data]
# Reject the batch if any object is named more than once, rather than ignoring the
# repetition (and any changelog message attached to it) and reporting success.
if (response := get_duplicate_objects_response(object_ids)) is not None:
return response
qs = self.get_bulk_destroy_queryset().filter(pk__in=object_ids)
# Reject the batch if any of the objects to be deleted could not be found, rather than

View File

@ -661,6 +661,52 @@ class APIViewTestCases:
f'sibling ID',
)
def test_bulk_update_objects_duplicate_id(self):
"""
PATCH a set of objects in which the same object is named twice. The request must be
rejected rather than applying only one of the entries given for that object.
"""
if self.bulk_update_data is None:
self.skipTest('Bulk update data not set')
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))
id_list = list(self._get_queryset().values_list('id', flat=True)[:2])
self.assertEqual(len(id_list), 2, 'Insufficient number of objects to test bulk update')
# Repeat the first ID at the end of the request
data = [{'id': id, **self.bulk_update_data} for id in (*id_list, id_list[0])]
# Snapshot the objects which would otherwise have been updated
instances_before = list(self._get_queryset().filter(pk__in=id_list))
response = self.client.patch(self._get_list_url(), data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
self.assertIn('detail', response.data)
self.assertIn('errors', response.data)
# The repeated ID must be reported once, not once per occurrence
self.assertEqual(len(response.data['errors']), 1)
self.assertEqual(response.data['errors'][0]['id'], id_list[0])
self.assertIn('id', response.data['errors'][0]['errors'])
# No object named in the request may have been updated, including the one named only once
for instance_before in instances_before:
instance_after = self._get_queryset().get(pk=instance_before.pk)
for field in self.bulk_update_data:
if field in ('changelog_message', 'add_tags', 'remove_tags'):
continue
self.assertEqual(
getattr(instance_after, field, None),
getattr(instance_before, field, None),
f'Field {field!r} of object {instance_before.pk} was modified despite a duplicated '
f'sibling ID',
)
class DeleteObjectViewTestCase(APITestCase):
def test_delete_object_without_permission(self):
@ -782,6 +828,41 @@ class APIViewTestCases:
# The objects named alongside the missing one must not have been deleted
self.assertEqual(self._get_queryset().count(), initial_count)
def test_bulk_delete_objects_duplicate_id(self):
"""
DELETE a set of objects in which the same object is named twice. The request must be
rejected rather than reporting success for a batch it only partly acted on.
"""
obj_perm = ObjectPermission(
name='Test permission',
actions=['delete']
)
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
# Target the most recently created objects to avoid triggering recursive deletions
id_list = list(self._get_queryset().order_by('-id').values_list('id', flat=True)[:2])
self.assertEqual(len(id_list), 2, 'Insufficient number of objects to test bulk deletion')
# Repeat the first ID at the end of the request
data = [{'id': id} for id in (*id_list, id_list[0])]
initial_count = self._get_queryset().count()
response = self.client.delete(self._get_list_url(), data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
self.assertIn('detail', response.data)
self.assertIn('errors', response.data)
# The repeated ID must be reported once, not once per occurrence
self.assertEqual(len(response.data['errors']), 1)
self.assertEqual(response.data['errors'][0]['id'], id_list[0])
self.assertIn('id', response.data['errors'][0]['errors'])
# No object named in the request may have been deleted
self.assertEqual(self._get_queryset().count(), initial_count)
class GraphQLTestCase(APITestCase):
graphql_auto_filter_tests = True
graphql_auto_filter_exclude = ()