Coverage for netbox/api/viewsets/mixins.py: 69%

276 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-10 18:35 +0000

1from collections import Counter 

2from contextlib import contextmanager 

3 

4from django.core.exceptions import ObjectDoesNotExist, PermissionDenied 

5from django.db import router, transaction 

6from django.db.models import ProtectedError, RestrictedError 

7from django.http import Http404 

8from django.utils.translation import gettext_lazy as _ 

9from rest_framework import status 

10from rest_framework.exceptions import ValidationError 

11from rest_framework.response import Response 

12from rest_framework.reverse import reverse 

13from rest_framework.settings import api_settings 

14 

15from core.models import ObjectType 

16from core.signals import clear_events 

17from extras.models import ExportTemplate 

18from netbox.api.serializers import BulkOperationSerializer 

19from netbox.api.serializers.bulk import get_bulk_update_serializer_class 

20from netbox.jobs import AsyncAPIJob 

21from utilities.exceptions import AbortRequest, RQWorkerNotRunningException 

22from utilities.request import copy_safe_request 

23from utilities.rqworker import any_workers_for_queue 

24 

25__all__ = ( 

26 'BULK_ERROR_STATUSES', 

27 'BackgroundOperationMixin', 

28 'BulkCreateModelMixin', 

29 'BulkDestroyModelMixin', 

30 'BulkUpdateModelMixin', 

31 'CustomFieldsMixin', 

32 'ExportTemplatesMixin', 

33 'ObjectValidationMixin', 

34 'discard_events_on_rollback', 

35 'get_duplicate_objects_response', 

36 'get_invalid_entries_response', 

37 'get_missing_objects_response', 

38 'get_non_list_response', 

39 'resolve_bulk_error_status', 

40) 

41 

42# The status codes with which a failed bulk operation may be reported, in order of precedence: where 

43# the per-object failures within one batch imply more than one of these, the earliest applies, being 

44# the one which would still stand were the others corrected. An authorization failure thus outranks a 

45# conflict with the current state of the database, which in turn outranks a rejection of the request. 

46BULK_ERROR_STATUSES = ( 

47 status.HTTP_403_FORBIDDEN, 

48 status.HTTP_409_CONFLICT, 

49 status.HTTP_400_BAD_REQUEST, 

50) 

51 

52PERMISSION_DENIED_MESSAGE = _("You do not have permission to perform this action on this object.") 

53 

54 

55def resolve_bulk_error_status(error_statuses): 

56 """ 

57 Return the single status code with which to report a bulk operation whose per-object failures 

58 imply the given ones, or None if there were no failures. 

59 

60 :param error_statuses: The set of status codes implied by the failures within one batch, each 

61 drawn from BULK_ERROR_STATUSES (which documents how they are ranked). 

62 """ 

63 if not error_statuses: 

64 return None 

65 

66 for error_status in BULK_ERROR_STATUSES: 66 ↛ 72line 66 didn't jump to line 72 because the loop on line 66 didn't complete

67 if error_status in error_statuses: 

68 return error_status 

69 

70 # A code with no defined precedence (a subclass may report its own) is not silently ranked; 

71 # fall back to the generic client error. 

72 return status.HTTP_400_BAD_REQUEST 

73 

74 

75def get_non_list_response(data): 

76 """ 

77 Return an error Response if the given request body is not a list of objects, or None if it is. 

78 

79 A bulk operation always addresses a list. The body reaching one is not necessarily a list, 

80 however, as the router maps every PUT, PATCH, and DELETE on a list endpoint to a bulk action 

81 regardless of what was sent. Rejecting a non-list body here keeps the per-entry errors reported 

82 by the bulk actions correlated by position: those come from a serializer bound to a list, so 

83 they are only positional if the body was a list to begin with. 

84 

85 The response carries only a `detail`, with no `errors`, as there are no entries to report 

86 against. 

87 """ 

88 if isinstance(data, list): 

89 return None 

90 

91 if data is None or data == {} or data == '': 

92 detail = _('Expected a list of objects, but no data was submitted.') 

93 else: 

94 # A multipart body arrives as a QueryDict rather than as a plain dict, so report any mapping 

95 # by the type the client submitted rather than by the class which happens to carry it. 

96 datatype = 'dict' if isinstance(data, dict) else type(data).__name__ 

97 detail = _('Expected a list of objects, but got {datatype}.').format(datatype=datatype) 

98 

99 return Response({'detail': detail}, status=status.HTTP_400_BAD_REQUEST) 

100 

101 

102def _as_field_errors(item_errors): 

103 """ 

104 Return the errors reported for one entry of a bulk request as a mapping of field name to messages. 

105 """ 

106 if isinstance(item_errors, dict): 106 ↛ 109line 106 didn't jump to line 109 because the condition on line 106 was always true

107 return item_errors 

108 

109 return {api_settings.NON_FIELD_ERRORS_KEY: item_errors} 

110 

111 

112def get_invalid_entries_response(entry_errors, total): 

113 """ 

114 Return a structured error Response for the entries of a bulk request which could not be 

115 interpreted, or None if every entry was interpretable. 

116 

117 The bulk update and delete actions first check that each entry identifies an object, before any 

118 entry has been matched to one. A failure at that stage -- a missing or non-numeric `id`, or an 

119 entry which is not an object at all -- is reported against the entry's position in the request 

120 rather than against an object ID, since no object has been identified yet. This is the same 

121 correlation bulk create uses throughout, for the same reason. 

122 

123 Passing this stage is what allows every later error to be correlated by `id` instead. 

124 

125 :param entry_errors: The `errors` of a BulkOperationSerializer bound to a list. These are 

126 reported as a mapping of the position of each uninterpretable entry in the request to that 

127 entry's errors; the entries which were interpretable are omitted. 

128 :param total: The number of entries in the request, for the summary message. 

129 """ 

130 # Ignore any error not correlated to a position, as it does not pertain to a single entry 

131 indexed_errors = { 

132 index: item_errors 

133 for index, item_errors in entry_errors.items() 

134 if isinstance(index, int) 

135 } 

136 errors = [ 

137 {'index': index, 'errors': _as_field_errors(item_errors)} 

138 for index, item_errors in sorted(indexed_errors.items()) 

139 ] 

140 if not errors: 140 ↛ 141line 140 didn't jump to line 141 because the condition on line 140 was never true

141 return None 

142 

143 return Response( 

144 { 

145 'detail': _('{failed_count} of {total} objects failed validation.').format( 

146 failed_count=len(errors), 

147 total=total, 

148 ), 

149 'errors': errors, 

150 }, 

151 status=status.HTTP_400_BAD_REQUEST, 

152 ) 

153 

154 

155def get_duplicate_objects_response(object_ids): 

156 """ 

157 Return a structured error Response naming each of the given object IDs which appears more than 

158 once, or None if they are all distinct. 

159 

160 A bulk operation identifies its objects by ID, so listing one twice is ambiguous. For an update, 

161 only one of the two sets of attributes can be applied, and the discarded entry is never even 

162 validated: a request pairing an invalid entry with a valid one for the same object would 

163 otherwise report success while silently ignoring the invalid data. For a delete, the repetition 

164 is meaningless, but it likewise causes the response to report on fewer objects than were named. 

165 Rather than guess at the intent, such a request is rejected. 

166 """ 

167 errors = [ 

168 { 

169 'id': object_id, 

170 'errors': { 

171 'id': [ 

172 _("Each object may be specified only once; ID {id} is listed {count} times").format( 

173 id=object_id, count=count 

174 ), 

175 ], 

176 }, 

177 } 

178 # Counter preserves the order in which each ID was first seen 

179 for object_id, count in Counter(object_ids).items() 

180 if count > 1 

181 ] 

182 if not errors: 

183 return None 

184 

185 return Response( 

186 { 

187 'detail': _('{failed_count} of {total} objects are listed more than once.').format( 

188 failed_count=len(errors), 

189 total=len(object_ids), 

190 ), 

191 'errors': errors, 

192 }, 

193 status=status.HTTP_400_BAD_REQUEST, 

194 ) 

195 

196 

197def get_missing_objects_response(object_ids, queryset): 

198 """ 

199 Return a structured error Response naming each of the given object IDs which the queryset does 

200 not match, or None if it matches them all. 

201 

202 An ID goes unmatched either because no such object exists or because the requesting user's 

203 object-level permissions exclude it. The two are deliberately not distinguished, consistent with 

204 the single-object endpoints, which return a 404 in both cases. 

205 

206 Bulk operations call this before performing any work: an unresolvable ID means the request names 

207 an object the client cannot act on, so there is nothing to be gained by attempting the batch (it 

208 would only be rolled back). Note that the status is 400 rather than the 409 a bulk delete 

209 returns for a dependency conflict, as this is a problem with the request itself rather than with 

210 the current state of the database. 

211 """ 

212 found_pks = set(queryset.values_list('pk', flat=True)) 

213 

214 errors = [ 

215 { 

216 'id': object_id, 

217 'errors': { 

218 'id': [_("Object with ID {id} does not exist").format(id=object_id)], 

219 }, 

220 } 

221 # NetBox's bulk actions reject a repeated ID before reaching this point (see 

222 # get_duplicate_objects_response), but de-duplicate anyway so that any other caller reports 

223 # such an ID once rather than once per occurrence. dict.fromkeys() preserves the order of 

224 # first appearance. 

225 for object_id in dict.fromkeys(object_ids) 

226 if object_id not in found_pks 

227 ] 

228 if not errors: 

229 return None 

230 

231 return Response( 

232 { 

233 'detail': _('{failed_count} of {total} objects could not be found.').format( 

234 failed_count=len(errors), 

235 total=len(object_ids), 

236 ), 

237 'errors': errors, 

238 }, 

239 status=status.HTTP_400_BAD_REQUEST, 

240 ) 

241 

242 

243@contextmanager 

244def discard_events_on_rollback(sender, using=None): 

245 """ 

246 Discard any queued events if the transaction wrapping this block is rolled back. 

247 

248 The change logging signal receivers queue events eagerly, as the payload for a deleted object 

249 must be captured while that object and its related rows are still reachable. The queue is not 

250 flushed to the events pipeline until after the response has been rendered, however, so events 

251 queued for writes which were subsequently rolled back would otherwise still be dispatched, 

252 firing webhooks and event rules for changes that were never committed. 

253 

254 Bulk operations need this because they provisionally write every valid object in a batch and 

255 then roll the entire batch back if any one object failed. Single-object writes need it because 

256 a write can be undone after it has been saved (for instance by the object-level permission 

257 check in perform_create()/perform_update(), or by a signal receiver raising AbortRequest). The 

258 UI's views send the same signal when they abandon a transaction. 

259 

260 Must be entered *inside* the transaction whose rollback it guards, so that the rollback flag is 

261 still set when this block exits. 

262 

263 Note that this discards the entire request's queue, not only the events queued within the 

264 guarded block. Nesting is therefore safe only because every rollback guarded here aborts the 

265 whole request, making the two equivalent: the bulk actions guard the whole batch while the 

266 per-object perform_*() calls they make guard each write, and a failure in either case abandons 

267 the request. Do not use this in a loop which catches a per-object failure and continues, as 

268 the events for objects which were successfully written would be discarded as well. 

269 """ 

270 try: 

271 yield 

272 except Exception: 

273 # An exception escaping the block (e.g. AbortRequest raised by a signal receiver) rolls 

274 # the transaction back just as an explicit set_rollback() does. 

275 clear_events.send(sender=sender) 

276 raise 

277 if transaction.get_connection(using).needs_rollback: 

278 clear_events.send(sender=sender) 

279 

280 

281class BackgroundOperationMixin: 

282 """ 

283 Enable optional background processing of REST API bulk write operations. When a write 

284 request to a list endpoint includes ``?background=true``, the bulk action enqueues an 

285 ``AsyncAPIJob`` to perform the work and immediately returns ``202 Accepted`` with the 

286 job's ID and polling URL. The actual write (including validation) runs in a worker via 

287 the same action method, so behavior is identical to the synchronous path. 

288 

289 This mixin overrides no framework methods; the bulk action methods call its helpers. 

290 """ 

291 

292 # False where the response carries a write-once secret: a 202 can only return it via the job record. 

293 background_enabled = True 

294 

295 def check_background_enabled(self): 

296 """Raise a ValidationError if this endpoint has opted out of background processing.""" 

297 if not self.background_enabled: 

298 raise ValidationError({ 

299 'detail': _("Background processing is not supported for this endpoint.") 

300 }) 

301 

302 def _background_requested(self, request): 

303 """Return True if background processing was requested for this write.""" 

304 if request.method not in ('POST', 'PUT', 'PATCH', 'DELETE'): 304 ↛ 305line 304 didn't jump to line 305 because the condition on line 304 was never true

305 return False 

306 return request.query_params.get('background', '').lower() == 'true' 

307 

308 def _handle_background_request(self, request, action, action_kwargs=None): 

309 """ 

310 Shared entry point for the bulk write actions. If background processing was requested 

311 for a bulk (list) operation, enqueue an AsyncAPIJob and return a 202 Response; otherwise 

312 return None so the caller proceeds synchronously. 

313 

314 Validation is intentionally deferred to the worker (which runs the same action method), 

315 so it is not performed twice and the request returns promptly regardless of batch size. 

316 """ 

317 if not (isinstance(request.data, list) and self._background_requested(request)): 317 ↛ 320line 317 didn't jump to line 320 because the condition on line 317 was always true

318 return None 

319 

320 return self._enqueue_bulk_job(request, action, payload=list(request.data), action_kwargs=action_kwargs) 

321 

322 def _enqueue_bulk_job(self, request, action, payload, action_kwargs=None): 

323 """ 

324 Enqueue an AsyncAPIJob to perform the given bulk action in the background and return 

325 a 202 response containing the job ID and polling URL. 

326 """ 

327 self.check_background_enabled() 

328 

329 # Reject conditional requests: an If-Match precondition cannot be meaningfully 

330 # honored when the write is deferred to a worker (the TOCTOU window is unbounded). 

331 if request.META.get('HTTP_IF_MATCH'): 

332 raise ValidationError( 

333 _("The If-Match header is not supported with background processing.") 

334 ) 

335 

336 # Don't accept work that no worker can perform (mirrors the scripts API; AsyncAPIJob 

337 # is enqueued without an instance, so it always lands on the default queue). 

338 if not any_workers_for_queue('default'): 

339 raise RQWorkerNotRunningException() 

340 

341 model = self.queryset.model 

342 verb = { 

343 'create': _("create"), 

344 'bulk_create': _("create"), 

345 'bulk_destroy': _("delete"), 

346 }.get(action, _("update")) 

347 job_name = _("Bulk {verb} {object_type}").format( 

348 verb=verb, 

349 object_type=model._meta.verbose_name_plural, 

350 ) 

351 # Carry a serializable snapshot of the request so the worker can reconstruct it (method, 

352 # request ID, and host metadata for absolute URLs in the captured result). The scheme is 

353 # passed separately, as copy_safe_request() does not capture it. The worker re-fetches the 

354 # user by PK and bypasses authentication entirely, so it reads neither the copied user nor 

355 # cookies; drop both so no User instance or session data is pickled into the job payload 

356 # for the lifetime of the job. 

357 request_copy = copy_safe_request(request, include_files=False) 

358 request_copy.user = None 

359 request_copy.COOKIES = {} 

360 

361 job = AsyncAPIJob.enqueue( 

362 name=job_name, 

363 user=request.user, 

364 viewset_class=f'{type(self).__module__}.{type(self).__qualname__}', 

365 action=action, 

366 payload=payload, 

367 user_pk=request.user.pk, 

368 action_kwargs=action_kwargs or {}, 

369 request=request_copy, 

370 scheme=request.scheme, 

371 ) 

372 

373 job_url = reverse('core-api:job-detail', kwargs={'pk': job.pk}, request=request) 

374 response = Response( 

375 {'job': {'id': job.pk, 'url': job_url, 'status': job.status}}, 

376 status=status.HTTP_202_ACCEPTED, 

377 ) 

378 response['Location'] = job_url 

379 return response 

380 

381 

382class CustomFieldsMixin: 

383 """ 

384 For models which support custom fields, populate the `custom_fields` context. 

385 """ 

386 def get_serializer_context(self): 

387 context = super().get_serializer_context() 

388 

389 if hasattr(self.queryset.model, 'custom_fields'): 

390 object_type = ObjectType.objects.get_for_model(self.queryset.model) 

391 context.update({ 

392 'custom_fields': object_type.custom_fields.all(), 

393 }) 

394 

395 return context 

396 

397 

398class ExportTemplatesMixin: 

399 """ 

400 Enable ExportTemplate support for list views. 

401 """ 

402 def list(self, request, *args, **kwargs): 

403 if 'export' in request.GET: 403 ↛ 404line 403 didn't jump to line 404 because the condition on line 403 was never true

404 object_type = ObjectType.objects.get_for_model(self.get_serializer_class().Meta.model) 

405 et = ExportTemplate.objects.restrict(request.user, 'view').filter( 

406 object_types=object_type, 

407 name=request.GET['export'], 

408 ).first() 

409 if et is None: 

410 raise Http404 

411 queryset = self.filter_queryset(self.get_queryset()) 

412 return et.render_to_response(queryset=queryset) 

413 

414 return super().list(request, *args, **kwargs) 

415 

416 

417class BulkCreateModelMixin: 

418 """ 

419 Support the creation of multiple objects using the list endpoint for a model. Accepts a POST action with a list 

420 of one or more JSON objects, each specifying the attributes of an object to be created. For example: 

421 

422 POST /api/dcim/sites/ 

423 [ 

424 {"name": "Site 1", "slug": "site-1"}, 

425 {"name": "Site 2", "slug": "site-2"} 

426 ] 

427 """ 

428 def bulk_create(self, request, *args, **kwargs): 

429 # If background processing was requested, enqueue a job and return immediately (before 

430 # any validation, which is deferred to the worker). 

431 handle_background = getattr(self, '_handle_background_request', lambda *a, **kw: None) 

432 if (response := handle_background(request, 'bulk_create')) is not None: 432 ↛ 433line 432 didn't jump to line 433 because the condition on line 432 was never true

433 return response 

434 

435 created_pks, errors, error_status = self.perform_bulk_create(request.data) 

436 

437 if errors: 

438 return Response( 

439 { 

440 'detail': _('{failed_count} of {total} objects could not be created.').format( 

441 failed_count=len(errors), 

442 total=len(request.data), 

443 ), 

444 'errors': errors, 

445 }, 

446 status=error_status, 

447 ) 

448 

449 # Re-fetch the new objects to serialize them with their related objects prefetched. Order by PK 

450 # to ensure that the ordering of objects in the response matches the ordering of those in the 

451 # request (the objects were created in the order given, so PK order is request order). 

452 qs = self.get_queryset().filter(pk__in=created_pks).order_by('pk') 

453 serializer = self.get_serializer(qs, many=True) 

454 

455 return Response(serializer.data, status=status.HTTP_201_CREATED) 

456 

457 def perform_bulk_create(self, data): 

458 """ 

459 Validate and create each of the given objects, rolling the entire batch back if any one of 

460 them could not be created. 

461 

462 Returns the PKs of the objects created, the per-object errors (if any), and the status code 

463 with which to report them (None if there were none). 

464 """ 

465 created_pks = [] 

466 errors = [] 

467 error_statuses = set() 

468 using = router.db_for_write(self.queryset.model) 

469 with transaction.atomic(using=using), discard_events_on_rollback(self, using=using): 

470 # Validate and save each object in turn, rather than validating the entire batch up front, so that 

471 # validation which depends on the state left by prior saves is evaluated correctly. 

472 for i, item in enumerate(data): 

473 if not isinstance(item, dict): 

474 # Checked explicitly because get_serializer() infers many=True from a list, so a nested list would 

475 # otherwise be validated as a batch of its own. 

476 errors.append({ 

477 'index': i, 

478 'errors': { 

479 api_settings.NON_FIELD_ERRORS_KEY: [ 

480 _('Invalid data. Expected a dictionary, but got {datatype}.').format( 

481 datatype=type(item).__name__ 

482 ), 

483 ], 

484 }, 

485 }) 

486 error_statuses.add(status.HTTP_400_BAD_REQUEST) 

487 continue 

488 serializer = self.get_serializer(data=item) 

489 if not serializer.is_valid(): 

490 errors.append({'index': i, 'errors': serializer.errors}) 

491 error_statuses.add(status.HTTP_400_BAD_REQUEST) 

492 continue 

493 try: 

494 # Provisionally create even when a prior item failed, so subsequent cross-object validators see a 

495 # realistic state. All creates are rolled back together if any item in the batch fails. 

496 self.perform_create(serializer) 

497 except AbortRequest as e: 

498 # Raised by a signal receiver rather than by validation (e.g. assigning a tag which is restricted 

499 # to other object types). 

500 errors.append({'index': i, 'errors': {'__all__': [str(e.message)]}}) 

501 error_statuses.add(status.HTTP_400_BAD_REQUEST) 

502 except PermissionDenied: 

503 # Raised by perform_create() when the object it saved falls outside the queryset permitted to the 

504 # requesting user. Reported per object so that the offending entry is named, but still as a 403, 

505 # which is what the single-object endpoint returns for the same rejection. 

506 errors.append({'index': i, 'errors': {'__all__': [PERMISSION_DENIED_MESSAGE]}}) 

507 error_statuses.add(status.HTTP_403_FORBIDDEN) 

508 else: 

509 created_pks.append(serializer.instance.pk) 

510 if errors: 

511 transaction.set_rollback(True) 

512 return created_pks, errors, resolve_bulk_error_status(error_statuses) 

513 

514 

515class BulkUpdateModelMixin: 

516 """ 

517 Support bulk modification of objects using the list endpoint for a model. Accepts a PATCH action with a list of one 

518 or more JSON objects, each specifying the numeric ID of an object to be updated as well as the attributes to be set. 

519 For example: 

520 

521 PATCH /api/dcim/sites/ 

522 [ 

523 { 

524 "id": 123, 

525 "name": "New name" 

526 }, 

527 { 

528 "id": 456, 

529 "status": "planned" 

530 } 

531 ] 

532 """ 

533 def get_bulk_update_queryset(self): 

534 return self.get_queryset() 

535 

536 def bulk_update(self, request, *args, **kwargs): 

537 partial = kwargs.pop('partial', False) 

538 

539 # If background processing was requested, enqueue a job and return immediately (before 

540 # any validation, which is deferred to the worker). _handle_background_request() comes 

541 # from BackgroundOperationMixin; fall back to "no background" so this mixin remains 

542 # usable on its own (e.g. in custom viewsets). 

543 handle_background = getattr(self, '_handle_background_request', lambda *a, **kw: None) 

544 action = 'bulk_partial_update' if partial else 'bulk_update' 

545 if (response := handle_background(request, action)) is not None: 545 ↛ 546line 545 didn't jump to line 546 because the condition on line 545 was never true

546 return response 

547 

548 if (response := get_non_list_response(request.data)) is not None: 

549 return response 

550 

551 # Check that every entry identifies an object before matching any of them to one, so that 

552 # a malformed entry is reported in the same form as every other bulk error 

553 serializer = BulkOperationSerializer(data=request.data, many=True) 

554 if not serializer.is_valid(): 

555 return get_invalid_entries_response(serializer.errors, len(request.data)) 

556 

557 object_ids = [o['id'] for o in serializer.validated_data] 

558 

559 # Reject the batch if any object is named more than once, rather than applying only one of 

560 # the entries given for it. 

561 if (response := get_duplicate_objects_response(object_ids)) is not None: 

562 return response 

563 

564 qs = self.get_bulk_update_queryset().filter(pk__in=object_ids) 

565 

566 # Reject the batch if any of the objects to be updated could not be found, rather than 

567 # silently omitting them from the response. 

568 if (response := get_missing_objects_response(object_ids, qs)) is not None: 

569 return response 

570 

571 # Map the attributes to be set for each object by its ID, taking the IDs from the validated 

572 # data rather than from the request body: the body's values have not been coerced, so an ID 

573 # submitted as a string ("123") would key this map by a value which never matches the 

574 # integer PK it identifies, silently discarding that entry's attributes. Each `id` is 

575 # excluded here rather than popped, leaving the request data as the client sent it. zip() is 

576 # strict as the two sequences necessarily correspond, every entry having been validated. 

577 update_data = { 

578 object_id: {k: v for k, v in item.items() if k != 'id'} 

579 for object_id, item in zip(object_ids, request.data, strict=True) 

580 } 

581 

582 object_pks, errors, error_status = self.perform_bulk_update(qs, update_data, partial=partial) 

583 

584 if errors: 584 ↛ 585line 584 didn't jump to line 585 because the condition on line 584 was never true

585 return Response( 

586 { 

587 'detail': _('{failed_count} of {total} objects could not be updated.').format( 

588 failed_count=len(errors), 

589 total=len(object_pks) + len(errors), 

590 ), 

591 'errors': errors, 

592 }, 

593 status=error_status, 

594 ) 

595 

596 # Prefetch related objects for all updated instances 

597 qs = self.get_queryset().filter(pk__in=object_pks) 

598 serializer = self.get_serializer(qs, many=True) 

599 

600 return Response(serializer.data, status=status.HTTP_200_OK) 

601 

602 def perform_bulk_update(self, objects, update_data, partial): 

603 """ 

604 Validate and apply the given attributes to each of the given objects, rolling the entire 

605 batch back if any one of them could not be updated. 

606 

607 Returns the PKs of the objects updated, the per-object errors, and the status code with 

608 which to report them (None if there were none). See resolve_bulk_error_status(). 

609 """ 

610 updated_pks = [] 

611 errors = [] 

612 error_statuses = set() 

613 using = router.db_for_write(self.queryset.model) 

614 with transaction.atomic(using=using), discard_events_on_rollback(self, using=using): 

615 # Validate and save each object in turn so subsequent validations see the DB 

616 # state left by prior saves (e.g. two items renamed to the same name: the second 

617 # will fail validation rather than raising an integrity error on save). 

618 for obj in objects: 618 ↛ 619line 618 didn't jump to line 619 because the loop on line 618 never started

619 data = update_data.get(obj.id) 

620 if hasattr(obj, 'snapshot'): 

621 obj.snapshot() 

622 serializer = self.get_serializer(obj, data=data, partial=partial) 

623 if not serializer.is_valid(): 

624 errors.append({'id': obj.pk, 'errors': serializer.errors}) 

625 error_statuses.add(status.HTTP_400_BAD_REQUEST) 

626 continue 

627 try: 

628 self.perform_update(serializer) 

629 except AbortRequest as e: 

630 # Raised by a signal receiver rather than by validation (e.g. assigning a tag 

631 # which is restricted to other object types). perform_update() wraps its write 

632 # in its own atomic block, so the connection is rolled back to that savepoint 

633 # and the remaining objects in the batch can still be evaluated. The message is 

634 # coerced to a string because a few receivers pass an exception rather than text. 

635 errors.append({'id': obj.pk, 'errors': {'__all__': [str(e.message)]}}) 

636 error_statuses.add(status.HTTP_400_BAD_REQUEST) 

637 except PermissionDenied: 

638 # Raised by perform_update() when the object, as modified, falls outside the 

639 # queryset permitted to the requesting user -- so unlike the check made before 

640 # the batch begins (see get_missing_objects_response), this depends on the 

641 # attributes submitted. Reported per object so that the offending entry is 

642 # named, but still as a 403, as the single-object endpoint returns. 

643 errors.append({'id': obj.pk, 'errors': {'__all__': [PERMISSION_DENIED_MESSAGE]}}) 

644 error_statuses.add(status.HTTP_403_FORBIDDEN) 

645 else: 

646 updated_pks.append(obj.pk) 

647 if errors: 647 ↛ 648line 647 didn't jump to line 648 because the condition on line 647 was never true

648 transaction.set_rollback(True) 

649 return updated_pks, errors, resolve_bulk_error_status(error_statuses) 

650 

651 def get_bulk_update_serializer_class(self, *, partial=False): 

652 return get_bulk_update_serializer_class( 

653 self.get_serializer_class(), 

654 partial=partial, 

655 ) 

656 

657 def get_bulk_update_request_serializer(self, *, partial=False): 

658 serializer_class = self.get_bulk_update_serializer_class(partial=partial) 

659 

660 # Important: do NOT pass partial=True here. The partial schema class already 

661 # makes non-id fields optional, and passing partial=True would also make id 

662 # appear optional in OpenAPI. 

663 return serializer_class(many=True) 

664 

665 def bulk_partial_update(self, request, *args, **kwargs): 

666 kwargs['partial'] = True 

667 return self.bulk_update(request, *args, **kwargs) 

668 

669 

670class BulkDestroyModelMixin: 

671 """ 

672 Support bulk deletion of objects using the list endpoint for a model. Accepts a DELETE action with a list of one 

673 or more JSON objects, each specifying the numeric ID of an object to be deleted. For example: 

674 

675 DELETE /api/dcim/sites/ 

676 [ 

677 {"id": 123}, 

678 {"id": 456} 

679 ] 

680 """ 

681 def get_bulk_destroy_queryset(self): 

682 return self.get_queryset() 

683 

684 def bulk_destroy(self, request, *args, **kwargs): 

685 # If background processing was requested, enqueue a job and return immediately (before 

686 # any validation, which is deferred to the worker). _handle_background_request() comes 

687 # from BackgroundOperationMixin; fall back to "no background" so this mixin remains 

688 # usable on its own (e.g. in custom viewsets). 

689 handle_background = getattr(self, '_handle_background_request', lambda *a, **kw: None) 

690 if (response := handle_background(request, 'bulk_destroy')) is not None: 690 ↛ 691line 690 didn't jump to line 691 because the condition on line 690 was never true

691 return response 

692 

693 if (response := get_non_list_response(request.data)) is not None: 

694 return response 

695 

696 # Check that every entry identifies an object before matching any of them to one, so that 

697 # a malformed entry is reported in the same form as every other bulk error 

698 serializer = BulkOperationSerializer(data=request.data, many=True) 

699 if not serializer.is_valid(): 

700 return get_invalid_entries_response(serializer.errors, len(request.data)) 

701 

702 object_ids = [o['id'] for o in serializer.validated_data] 

703 

704 # Reject the batch if any object is named more than once, rather than ignoring the 

705 # repetition (and any changelog message attached to it) and reporting success. 

706 if (response := get_duplicate_objects_response(object_ids)) is not None: 706 ↛ 707line 706 didn't jump to line 707 because the condition on line 706 was never true

707 return response 

708 

709 qs = self.get_bulk_destroy_queryset().filter(pk__in=object_ids) 

710 

711 # Reject the batch if any of the objects to be deleted could not be found, rather than 

712 # silently omitting them and reporting success. 

713 if (response := get_missing_objects_response(object_ids, qs)) is not None: 713 ↛ 714line 713 didn't jump to line 714 because the condition on line 713 was never true

714 return response 

715 

716 # Compile any changelog messages to be recorded on the objects being deleted 

717 changelog_messages = { 

718 o['id']: o.get('changelog_message') for o in serializer.validated_data 

719 } 

720 

721 errors, total, error_status = self.perform_bulk_destroy(qs, changelog_messages) 

722 

723 if errors: 723 ↛ 724line 723 didn't jump to line 724 because the condition on line 723 was never true

724 return Response( 

725 { 

726 'detail': _('{failed_count} of {total} objects could not be deleted.').format( 

727 failed_count=len(errors), 

728 total=total, 

729 ), 

730 'errors': errors, 

731 }, 

732 status=error_status, 

733 ) 

734 

735 return Response(status=status.HTTP_204_NO_CONTENT) 

736 

737 def perform_bulk_destroy(self, objects, changelog_messages=None): 

738 """ 

739 Attempt to delete each of the given objects, rolling the entire batch back if any one of 

740 them could not be deleted. 

741 

742 Returns the per-object errors, the number of objects processed, and the status code with 

743 which to report the errors (None if there were none). A dependency conflict yields a 409, as 

744 it is a conflict with the current state of the database, whereas a protection rule (or any 

745 other signal receiver raising AbortRequest) yields a 400, being a rejection of the request: 

746 this matches the single-object endpoint, where dispatch() maps the same exception classes to 

747 the same status codes. See resolve_bulk_error_status() for how a batch hitting more than one 

748 of these is resolved. 

749 """ 

750 changelog_messages = changelog_messages or {} 

751 errors = [] 

752 total = 0 

753 error_statuses = set() 

754 using = router.db_for_write(self.queryset.model) 

755 with transaction.atomic(using=using), discard_events_on_rollback(self, using=using): 

756 for obj in objects: 756 ↛ 757line 756 didn't jump to line 757 because the loop on line 756 never started

757 total += 1 

758 if hasattr(obj, 'snapshot'): 

759 obj.snapshot() 

760 obj._changelog_message = changelog_messages.get(obj.pk) 

761 pk = obj.pk # Django sets obj.pk = None after deletion; capture it first 

762 try: 

763 self.perform_destroy(obj) 

764 except (ProtectedError, RestrictedError) as e: 

765 error_statuses.add(status.HTTP_409_CONFLICT) 

766 protected = list( 

767 e.protected_objects if isinstance(e, ProtectedError) else e.restricted_objects 

768 ) 

769 # Report only the count, not names or PKs, to keep each per-object error 

770 # entry small in a batch response. Note: the single-object delete endpoint 

771 # (NetBoxModelViewSet.dispatch()) does include names and PKs of dependent 

772 # objects, so this is not a hard security boundary — just a narrower 

773 # response shape for the bulk case. 

774 errors.append({ 

775 'id': pk, 

776 'errors': { 

777 '__all__': [ 

778 _('Unable to delete: {n} dependent object(s) prevent deletion.').format( 

779 n=len(protected) 

780 ), 

781 ], 

782 }, 

783 }) 

784 except AbortRequest as e: 

785 # Raised by a signal receiver rather than by a database constraint (e.g. a 

786 # PROTECTION_RULES violation caught in core.signals.handle_deleted_object). 

787 # perform_destroy() wraps its delete in its own atomic block, so the connection 

788 # is rolled back to that savepoint and the remaining objects in the batch can 

789 # still be evaluated. 

790 errors.append({'id': pk, 'errors': {'__all__': [str(e.message)]}}) 

791 error_statuses.add(status.HTTP_400_BAD_REQUEST) 

792 except PermissionDenied: 

793 # Raised by perform_destroy() when the object falls outside the queryset 

794 # permitted to the requesting user (reachable via the If-Match re-check). 

795 errors.append({'id': pk, 'errors': {'__all__': [PERMISSION_DENIED_MESSAGE]}}) 

796 error_statuses.add(status.HTTP_403_FORBIDDEN) 

797 if errors: 797 ↛ 798line 797 didn't jump to line 798 because the condition on line 797 was never true

798 transaction.set_rollback(True) 

799 return errors, total, resolve_bulk_error_status(error_statuses) 

800 

801 

802class ObjectValidationMixin: 

803 

804 def _validate_objects(self, instance): 

805 """ 

806 Check that the provided instance or list of instances are matched by the current queryset. This confirms that 

807 any newly created or modified objects abide by the attributes granted by any applicable ObjectPermissions. 

808 """ 

809 if type(instance) is list: 

810 # Check that all instances are still included in the view's queryset 

811 conforming_count = self.queryset.filter(pk__in=[obj.pk for obj in instance]).count() 

812 if conforming_count != len(instance): 812 ↛ 813line 812 didn't jump to line 813 because the condition on line 812 was never true

813 raise ObjectDoesNotExist 

814 elif not self.queryset.filter(pk=instance.pk).exists(): 814 ↛ 815line 814 didn't jump to line 815 because the condition on line 814 was never true

815 raise ObjectDoesNotExist