|
|
@@ -1,5 +1,6 @@
|
|
|
from django.core.exceptions import ObjectDoesNotExist
|
|
|
from django.db import router, transaction
|
|
|
+from django.db.models import ProtectedError, RestrictedError
|
|
|
from django.http import Http404
|
|
|
from django.utils.translation import gettext_lazy as _
|
|
|
from rest_framework import status
|
|
|
@@ -164,21 +165,39 @@ class SequentialBulkCreatesMixin:
|
|
|
if (response := handle_background(request, 'create')) is not None:
|
|
|
return response
|
|
|
|
|
|
- with transaction.atomic(using=router.db_for_write(self.queryset.model)):
|
|
|
- if not isinstance(request.data, list):
|
|
|
- # Creating a single object
|
|
|
- return super().create(request, *args, **kwargs)
|
|
|
+ if not isinstance(request.data, list):
|
|
|
+ # Creating a single object
|
|
|
+ return super().create(request, *args, **kwargs)
|
|
|
|
|
|
- return_data = []
|
|
|
- for data in request.data:
|
|
|
+ # 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.
|
|
|
+ results = []
|
|
|
+ return_data = []
|
|
|
+ with transaction.atomic(using=router.db_for_write(self.queryset.model)):
|
|
|
+ for i, data in enumerate(request.data):
|
|
|
serializer = self.get_serializer(data=data)
|
|
|
- serializer.is_valid(raise_exception=True)
|
|
|
- self.perform_create(serializer)
|
|
|
- return_data.append(serializer.data)
|
|
|
-
|
|
|
- headers = self.get_success_headers(serializer.data)
|
|
|
+ if serializer.is_valid():
|
|
|
+ self.perform_create(serializer)
|
|
|
+ return_data.append(serializer.data)
|
|
|
+ results.append({'index': i, 'status': 'ok'})
|
|
|
+ else:
|
|
|
+ results.append({'index': i, 'status': 'error', 'errors': serializer.errors})
|
|
|
+
|
|
|
+ if any(r['status'] == 'error' for r in results):
|
|
|
+ transaction.set_rollback(True)
|
|
|
+
|
|
|
+ if any(r['status'] == 'error' for r in results):
|
|
|
+ failed_count = sum(1 for r in results if r['status'] == 'error')
|
|
|
+ return Response(
|
|
|
+ {
|
|
|
+ 'detail': f'{failed_count} of {len(results)} objects failed validation.',
|
|
|
+ 'results': results,
|
|
|
+ },
|
|
|
+ status=status.HTTP_400_BAD_REQUEST,
|
|
|
+ )
|
|
|
|
|
|
- return Response(return_data, status=status.HTTP_201_CREATED, headers=headers)
|
|
|
+ headers = self.get_success_headers(return_data[-1]) if return_data else {}
|
|
|
+ return Response(return_data, status=status.HTTP_201_CREATED, headers=headers)
|
|
|
|
|
|
|
|
|
class BulkUpdateModelMixin:
|
|
|
@@ -226,7 +245,17 @@ class BulkUpdateModelMixin:
|
|
|
obj.pop('id'): obj for obj in request.data
|
|
|
}
|
|
|
|
|
|
- object_pks = self.perform_bulk_update(qs, update_data, partial=partial)
|
|
|
+ object_pks, results = self.perform_bulk_update(qs, update_data, partial=partial)
|
|
|
+
|
|
|
+ if results:
|
|
|
+ failed_count = sum(1 for r in results if r['status'] == 'error')
|
|
|
+ return Response(
|
|
|
+ {
|
|
|
+ 'detail': f'{failed_count} of {len(results)} objects failed validation.',
|
|
|
+ 'results': results,
|
|
|
+ },
|
|
|
+ status=status.HTTP_400_BAD_REQUEST,
|
|
|
+ )
|
|
|
|
|
|
# Prefetch related objects for all updated instances
|
|
|
qs = self.get_queryset().filter(pk__in=object_pks)
|
|
|
@@ -236,17 +265,31 @@ class BulkUpdateModelMixin:
|
|
|
|
|
|
def perform_bulk_update(self, objects, update_data, partial):
|
|
|
updated_pks = []
|
|
|
+ results = []
|
|
|
with transaction.atomic(using=router.db_for_write(self.queryset.model)):
|
|
|
+ # Pass 1: validate all objects without writing to the database
|
|
|
+ prepared = []
|
|
|
for obj in objects:
|
|
|
data = update_data.get(obj.id)
|
|
|
if hasattr(obj, 'snapshot'):
|
|
|
obj.snapshot()
|
|
|
serializer = self.get_serializer(obj, data=data, partial=partial)
|
|
|
- serializer.is_valid(raise_exception=True)
|
|
|
- self.perform_update(serializer)
|
|
|
- updated_pks.append(obj.pk)
|
|
|
-
|
|
|
- return updated_pks
|
|
|
+ prepared.append((obj, serializer, serializer.is_valid()))
|
|
|
+
|
|
|
+ if any(not valid for _, _, valid in prepared):
|
|
|
+ results = [
|
|
|
+ {'id': obj.pk, 'status': 'error', 'errors': ser.errors} if not valid
|
|
|
+ else {'id': obj.pk, 'status': 'ok'}
|
|
|
+ for obj, ser, valid in prepared
|
|
|
+ ]
|
|
|
+ transaction.set_rollback(True)
|
|
|
+ else:
|
|
|
+ # Pass 2: all objects are valid — perform updates
|
|
|
+ for obj, serializer, _ in prepared:
|
|
|
+ self.perform_update(serializer)
|
|
|
+ updated_pks.append(obj.pk)
|
|
|
+
|
|
|
+ return updated_pks, results
|
|
|
|
|
|
def get_bulk_update_serializer_class(self, *, partial=False):
|
|
|
return get_bulk_update_serializer_class(
|
|
|
@@ -302,18 +345,48 @@ class BulkDestroyModelMixin:
|
|
|
o['id']: o.get('changelog_message') for o in serializer.validated_data
|
|
|
}
|
|
|
|
|
|
- self.perform_bulk_destroy(qs, changelog_messages)
|
|
|
+ results = self.perform_bulk_destroy(qs, changelog_messages)
|
|
|
+
|
|
|
+ if results and any(r['status'] == 'error' for r in results):
|
|
|
+ failed_count = sum(1 for r in results if r['status'] == 'error')
|
|
|
+ return Response(
|
|
|
+ {
|
|
|
+ 'detail': f'{failed_count} of {len(results)} objects could not be deleted.',
|
|
|
+ 'results': results,
|
|
|
+ },
|
|
|
+ status=status.HTTP_409_CONFLICT,
|
|
|
+ )
|
|
|
|
|
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
|
|
|
|
|
def perform_bulk_destroy(self, objects, changelog_messages=None):
|
|
|
changelog_messages = changelog_messages or {}
|
|
|
+ results = []
|
|
|
with transaction.atomic(using=router.db_for_write(self.queryset.model)):
|
|
|
for obj in objects:
|
|
|
if hasattr(obj, 'snapshot'):
|
|
|
obj.snapshot()
|
|
|
obj._changelog_message = changelog_messages.get(obj.pk)
|
|
|
- self.perform_destroy(obj)
|
|
|
+ pk = obj.pk # Django sets obj.pk = None after deletion; capture it first
|
|
|
+ try:
|
|
|
+ self.perform_destroy(obj)
|
|
|
+ results.append({'id': pk, 'status': 'ok'})
|
|
|
+ except (ProtectedError, RestrictedError) as e:
|
|
|
+ protected = list(
|
|
|
+ e.protected_objects if isinstance(e, ProtectedError) else e.restricted_objects
|
|
|
+ )
|
|
|
+ n = len(protected)
|
|
|
+ objects_str = ', '.join(f'{o} ({o.pk})' for o in protected[:10])
|
|
|
+ if n > 10:
|
|
|
+ objects_str += f', and {n - 10} more'
|
|
|
+ results.append({
|
|
|
+ 'id': obj.pk,
|
|
|
+ 'status': 'error',
|
|
|
+ 'errors': {'detail': f'Unable to delete. {n} dependent object(s): {objects_str}'},
|
|
|
+ })
|
|
|
+ if any(r['status'] == 'error' for r in results):
|
|
|
+ transaction.set_rollback(True)
|
|
|
+ return results
|
|
|
|
|
|
|
|
|
class ObjectValidationMixin:
|