|
@@ -167,14 +167,14 @@ class SequentialBulkCreatesMixin:
|
|
|
|
|
|
|
|
# Create objects sequentially so each validation sees the state left by prior creates
|
|
# Create objects sequentially so each validation sees the state left by prior creates
|
|
|
# (e.g. rack space checks). Collect per-object errors instead of failing on the first.
|
|
# (e.g. rack space checks). Collect per-object errors instead of failing on the first.
|
|
|
- results = []
|
|
|
|
|
|
|
+ errors = []
|
|
|
return_data = []
|
|
return_data = []
|
|
|
- error_count = 0
|
|
|
|
|
with transaction.atomic(using=router.db_for_write(self.queryset.model)):
|
|
with transaction.atomic(using=router.db_for_write(self.queryset.model)):
|
|
|
if not isinstance(request.data, list):
|
|
if not isinstance(request.data, list):
|
|
|
# Creating a single object
|
|
# Creating a single object
|
|
|
return super().create(request, *args, **kwargs)
|
|
return super().create(request, *args, **kwargs)
|
|
|
|
|
|
|
|
|
|
+ total = len(request.data)
|
|
|
for i, data in enumerate(request.data):
|
|
for i, data in enumerate(request.data):
|
|
|
serializer = self.get_serializer(data=data)
|
|
serializer = self.get_serializer(data=data)
|
|
|
if serializer.is_valid():
|
|
if serializer.is_valid():
|
|
@@ -183,22 +183,20 @@ class SequentialBulkCreatesMixin:
|
|
|
# All creates are rolled back together if any item in the batch fails.
|
|
# All creates are rolled back together if any item in the batch fails.
|
|
|
self.perform_create(serializer)
|
|
self.perform_create(serializer)
|
|
|
return_data.append(serializer.data)
|
|
return_data.append(serializer.data)
|
|
|
- results.append({'index': i})
|
|
|
|
|
else:
|
|
else:
|
|
|
- results.append({'index': i, 'errors': serializer.errors})
|
|
|
|
|
- error_count += 1
|
|
|
|
|
|
|
+ errors.append({'index': i, 'errors': serializer.errors})
|
|
|
|
|
|
|
|
- if error_count:
|
|
|
|
|
|
|
+ if errors:
|
|
|
transaction.set_rollback(True)
|
|
transaction.set_rollback(True)
|
|
|
|
|
|
|
|
- if error_count:
|
|
|
|
|
|
|
+ if errors:
|
|
|
return Response(
|
|
return Response(
|
|
|
{
|
|
{
|
|
|
'detail': _('{failed_count} of {total} objects failed validation.').format(
|
|
'detail': _('{failed_count} of {total} objects failed validation.').format(
|
|
|
- failed_count=error_count,
|
|
|
|
|
- total=len(results),
|
|
|
|
|
|
|
+ failed_count=len(errors),
|
|
|
|
|
+ total=total,
|
|
|
),
|
|
),
|
|
|
- 'results': results,
|
|
|
|
|
|
|
+ 'errors': errors,
|
|
|
},
|
|
},
|
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
|
)
|
|
)
|
|
@@ -252,16 +250,16 @@ class BulkUpdateModelMixin:
|
|
|
obj.pop('id'): obj for obj in request.data
|
|
obj.pop('id'): obj for obj in request.data
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- object_pks, results, error_count = self.perform_bulk_update(qs, update_data, partial=partial)
|
|
|
|
|
|
|
+ object_pks, errors = self.perform_bulk_update(qs, update_data, partial=partial)
|
|
|
|
|
|
|
|
- if error_count:
|
|
|
|
|
|
|
+ if errors:
|
|
|
return Response(
|
|
return Response(
|
|
|
{
|
|
{
|
|
|
'detail': _('{failed_count} of {total} objects failed validation.').format(
|
|
'detail': _('{failed_count} of {total} objects failed validation.').format(
|
|
|
- failed_count=error_count,
|
|
|
|
|
- total=len(results),
|
|
|
|
|
|
|
+ failed_count=len(errors),
|
|
|
|
|
+ total=len(object_pks) + len(errors),
|
|
|
),
|
|
),
|
|
|
- 'results': results,
|
|
|
|
|
|
|
+ 'errors': errors,
|
|
|
},
|
|
},
|
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
|
)
|
|
)
|
|
@@ -274,8 +272,7 @@ class BulkUpdateModelMixin:
|
|
|
|
|
|
|
|
def perform_bulk_update(self, objects, update_data, partial):
|
|
def perform_bulk_update(self, objects, update_data, partial):
|
|
|
updated_pks = []
|
|
updated_pks = []
|
|
|
- results = []
|
|
|
|
|
- error_count = 0
|
|
|
|
|
|
|
+ errors = []
|
|
|
with transaction.atomic(using=router.db_for_write(self.queryset.model)):
|
|
with transaction.atomic(using=router.db_for_write(self.queryset.model)):
|
|
|
# Validate and save each object in turn so subsequent validations see the DB
|
|
# Validate and save each object in turn so subsequent validations see the DB
|
|
|
# state left by prior saves (e.g. two items renamed to the same name: the second
|
|
# state left by prior saves (e.g. two items renamed to the same name: the second
|
|
@@ -288,13 +285,11 @@ class BulkUpdateModelMixin:
|
|
|
if serializer.is_valid():
|
|
if serializer.is_valid():
|
|
|
self.perform_update(serializer)
|
|
self.perform_update(serializer)
|
|
|
updated_pks.append(obj.pk)
|
|
updated_pks.append(obj.pk)
|
|
|
- results.append({'id': obj.pk})
|
|
|
|
|
else:
|
|
else:
|
|
|
- results.append({'id': obj.pk, 'errors': serializer.errors})
|
|
|
|
|
- error_count += 1
|
|
|
|
|
- if error_count:
|
|
|
|
|
|
|
+ errors.append({'id': obj.pk, 'errors': serializer.errors})
|
|
|
|
|
+ if errors:
|
|
|
transaction.set_rollback(True)
|
|
transaction.set_rollback(True)
|
|
|
- return updated_pks, results, error_count
|
|
|
|
|
|
|
+ return updated_pks, errors
|
|
|
|
|
|
|
|
def get_bulk_update_serializer_class(self, *, partial=False):
|
|
def get_bulk_update_serializer_class(self, *, partial=False):
|
|
|
return get_bulk_update_serializer_class(
|
|
return get_bulk_update_serializer_class(
|
|
@@ -350,16 +345,16 @@ class BulkDestroyModelMixin:
|
|
|
o['id']: o.get('changelog_message') for o in serializer.validated_data
|
|
o['id']: o.get('changelog_message') for o in serializer.validated_data
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- results, error_count = self.perform_bulk_destroy(qs, changelog_messages)
|
|
|
|
|
|
|
+ errors, total = self.perform_bulk_destroy(qs, changelog_messages)
|
|
|
|
|
|
|
|
- if error_count:
|
|
|
|
|
|
|
+ if errors:
|
|
|
return Response(
|
|
return Response(
|
|
|
{
|
|
{
|
|
|
'detail': _('{failed_count} of {total} objects could not be deleted.').format(
|
|
'detail': _('{failed_count} of {total} objects could not be deleted.').format(
|
|
|
- failed_count=error_count,
|
|
|
|
|
- total=len(results),
|
|
|
|
|
|
|
+ failed_count=len(errors),
|
|
|
|
|
+ total=total,
|
|
|
),
|
|
),
|
|
|
- 'results': results,
|
|
|
|
|
|
|
+ 'errors': errors,
|
|
|
},
|
|
},
|
|
|
status=status.HTTP_409_CONFLICT,
|
|
status=status.HTTP_409_CONFLICT,
|
|
|
)
|
|
)
|
|
@@ -368,36 +363,38 @@ class BulkDestroyModelMixin:
|
|
|
|
|
|
|
|
def perform_bulk_destroy(self, objects, changelog_messages=None):
|
|
def perform_bulk_destroy(self, objects, changelog_messages=None):
|
|
|
changelog_messages = changelog_messages or {}
|
|
changelog_messages = changelog_messages or {}
|
|
|
- results = []
|
|
|
|
|
- error_count = 0
|
|
|
|
|
|
|
+ errors = []
|
|
|
|
|
+ total = 0
|
|
|
with transaction.atomic(using=router.db_for_write(self.queryset.model)):
|
|
with transaction.atomic(using=router.db_for_write(self.queryset.model)):
|
|
|
for obj in objects:
|
|
for obj in objects:
|
|
|
|
|
+ total += 1
|
|
|
if hasattr(obj, 'snapshot'):
|
|
if hasattr(obj, 'snapshot'):
|
|
|
obj.snapshot()
|
|
obj.snapshot()
|
|
|
obj._changelog_message = changelog_messages.get(obj.pk)
|
|
obj._changelog_message = changelog_messages.get(obj.pk)
|
|
|
pk = obj.pk # Django sets obj.pk = None after deletion; capture it first
|
|
pk = obj.pk # Django sets obj.pk = None after deletion; capture it first
|
|
|
try:
|
|
try:
|
|
|
self.perform_destroy(obj)
|
|
self.perform_destroy(obj)
|
|
|
- results.append({'id': pk})
|
|
|
|
|
except (ProtectedError, RestrictedError) as e:
|
|
except (ProtectedError, RestrictedError) as e:
|
|
|
protected = list(
|
|
protected = list(
|
|
|
e.protected_objects if isinstance(e, ProtectedError) else e.restricted_objects
|
|
e.protected_objects if isinstance(e, ProtectedError) else e.restricted_objects
|
|
|
)
|
|
)
|
|
|
n = len(protected)
|
|
n = len(protected)
|
|
|
- # Report only the count — not names or PKs — to avoid exposing objects
|
|
|
|
|
- # the caller may not have permission to view.
|
|
|
|
|
- results.append({
|
|
|
|
|
|
|
+ # Report only the count, not names or PKs, to keep each per-object error
|
|
|
|
|
+ # entry small in a batch response. Note: the single-object delete endpoint
|
|
|
|
|
+ # (NetBoxModelViewSet.dispatch()) does include names and PKs of dependent
|
|
|
|
|
+ # objects, so this is not a hard security boundary — just a narrower
|
|
|
|
|
+ # response shape for the bulk case.
|
|
|
|
|
+ errors.append({
|
|
|
'id': pk,
|
|
'id': pk,
|
|
|
'errors': {
|
|
'errors': {
|
|
|
- 'detail': _(
|
|
|
|
|
|
|
+ '__all__': _(
|
|
|
'Unable to delete: {n} dependent object(s) prevent deletion.'
|
|
'Unable to delete: {n} dependent object(s) prevent deletion.'
|
|
|
).format(n=n),
|
|
).format(n=n),
|
|
|
},
|
|
},
|
|
|
})
|
|
})
|
|
|
- error_count += 1
|
|
|
|
|
- if error_count:
|
|
|
|
|
|
|
+ if errors:
|
|
|
transaction.set_rollback(True)
|
|
transaction.set_rollback(True)
|
|
|
- return results, error_count
|
|
|
|
|
|
|
+ return errors, total
|
|
|
|
|
|
|
|
|
|
|
|
|
class ObjectValidationMixin:
|
|
class ObjectValidationMixin:
|