Coverage for netbox/views/generic/bulk_views.py: 12%

724 statements  

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

1import logging 

2import re 

3from collections import Counter 

4from contextlib import contextmanager 

5from copy import deepcopy 

6from types import SimpleNamespace 

7 

8from django.conf import settings 

9from django.contrib import messages 

10from django.contrib.contenttypes.fields import GenericForeignKey, GenericRel 

11from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured, ObjectDoesNotExist, ValidationError 

12from django.db import IntegrityError, router, transaction 

13from django.db.models import ManyToManyField, ProtectedError, RestrictedError 

14from django.db.models.fields.reverse_related import ManyToManyRel 

15from django.forms import ModelMultipleChoiceField, MultipleHiddenInput 

16from django.http import HttpResponse 

17from django.shortcuts import get_object_or_404, redirect, render 

18from django.utils.http import content_disposition_header 

19from django.utils.safestring import mark_safe 

20from django.utils.translation import gettext as _ 

21 

22from core.exceptions import JobFailed 

23from core.models import ObjectType 

24from core.signals import clear_events 

25from extras.choices import CustomFieldUIEditableChoices 

26from extras.models import CustomField, ExportTemplate 

27from netbox.choices import ImportMethodChoices 

28from netbox.forms.bulk_rename import NetBoxModelBulkRenameForm 

29from netbox.models.features import ChangeLoggingMixin 

30from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport, BulkRename 

31from utilities.error_handlers import handle_protectederror 

32from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation 

33from utilities.export import TableExport, stream_table_csv_response 

34from utilities.forms import BulkDeleteForm, BulkRenameForm, restrict_form_fields 

35from utilities.forms.bulk_import import BulkImportForm 

36from utilities.htmx import htmx_partial 

37from utilities.jobs import is_background_request, process_request_as_job 

38from utilities.permissions import get_permission_for_model 

39from utilities.query import reapply_model_ordering 

40from utilities.querydict import normalize_querydict 

41from utilities.request import safe_for_redirect 

42from utilities.string import title 

43from utilities.tables import get_table_configs 

44from utilities.views import GetReturnURLMixin, get_action_url 

45 

46from .base import BaseMultiObjectView 

47from .mixins import ActionsMixin, TableMixin 

48from .utils import get_prerequisite_model 

49 

50__all__ = ( 

51 'BulkComponentCreateView', 

52 'BulkCreateView', 

53 'BulkDeleteView', 

54 'BulkEditView', 

55 'BulkImportView', 

56 'BulkRenameView', 

57 'ObjectListView', 

58) 

59 

60 

61# TODO: Remove in NetBox v5.0. 

62# MPTT support is retained only for plugins whose tree models still derive from the 

63# deprecated MPTT-backed bases. NetBox core uses netbox.models.ltree.LtreeModel, whose 

64# database triggers maintain the tree on every write, so ltree (and non-tree) models 

65# need no special bulk handling. These two helpers confine all MPTT-specific bulk 

66# bookkeeping so it can be deleted in one place; for non-MPTT models they are no-ops. 

67@contextmanager 

68def _delay_mptt_updates(model): 

69 """ 

70 Defer tree (lft/rght/tree_id) recomputation until the end of a bulk write for 

71 legacy MPTT models. A no-op context manager for ltree and non-tree models. 

72 """ 

73 from mptt.models import MPTTModel 

74 

75 if issubclass(model, MPTTModel): 

76 with model.objects.delay_mptt_updates(): 

77 yield 

78 else: 

79 yield 

80 

81 

82def _rebuild_mptt_tree(model): 

83 """Rebuild the MPTT tree after a bulk edit for legacy MPTT models; else a no-op.""" 

84 from mptt.models import MPTTModel 

85 

86 if issubclass(model, MPTTModel): 

87 model.objects.rebuild() 

88 

89 

90class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin): 

91 """ 

92 Display multiple objects, all the same type, as a table. 

93 

94 Attributes: 

95 filterset: A django-filter FilterSet that is applied to the queryset 

96 filterset_form: The form class used to render filter options 

97 actions: An iterable of ObjectAction subclasses (see ActionsMixin) 

98 """ 

99 template_name = 'generic/object_list.html' 

100 filterset = None 

101 filterset_form = None 

102 actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkRename, BulkDelete) 

103 

104 def get_required_permission(self): 

105 return get_permission_for_model(self.queryset.model, 'view') 

106 

107 # 

108 # Export methods 

109 # 

110 

111 def export_yaml(self): 

112 """ 

113 Export the queryset of objects as concatenated YAML documents. 

114 """ 

115 yaml_data = [obj.to_yaml() for obj in self.queryset] 

116 

117 return '---\n'.join(yaml_data) 

118 

119 def export_table(self, table, columns=None, filename=None, delimiter=None): 

120 """ 

121 Export all table data in CSV format. 

122 

123 Args: 

124 table: The Table instance to export 

125 columns: A list of specific columns to include. If None, all columns will be exported. 

126 filename: The name of the file attachment sent to the client. If None, will be determined automatically 

127 from the queryset model name. 

128 delimiter: The character used to separate columns (a comma is used by default) 

129 """ 

130 exclude_columns = {'pk', 'actions'} 

131 all_columns = [col_name for col_name, _ in table.selected_columns + table.available_columns] 

132 if columns: 

133 exclude_columns.update({ 

134 col for col in all_columns if col not in columns 

135 }) 

136 

137 # Ensure related objects are prefetched for every column that will be exported, not just 

138 # those currently visible in the configured table view. 

139 table._apply_prefetching(columns=[c for c in all_columns if c not in exclude_columns]) 

140 

141 filename = filename or f'netbox_{self.queryset.model._meta.verbose_name_plural}.csv' 

142 

143 if settings.STREAMING_EXPORTS: 

144 return stream_table_csv_response( 

145 table=table, 

146 exclude_columns=exclude_columns, 

147 filename=filename, 

148 delimiter=delimiter, 

149 ) 

150 

151 exporter = TableExport( 

152 export_format=TableExport.CSV, 

153 table=table, 

154 exclude_columns=exclude_columns, 

155 delimiter=delimiter, 

156 ) 

157 return exporter.response(filename=filename) 

158 

159 def export_template(self, template, request): 

160 """ 

161 Render an ExportTemplate using the current queryset. 

162 

163 Args: 

164 template: ExportTemplate instance 

165 request: The current request 

166 """ 

167 try: 

168 return template.render_to_response(queryset=self.queryset) 

169 except Exception as e: 

170 messages.error( 

171 request, 

172 _("There was an error rendering the selected export template ({template}): {error}").format( 

173 template=template.name, 

174 error=e 

175 ) 

176 ) 

177 # Strip the `export` param and redirect user to the filtered objects list 

178 query_params = request.GET.copy() 

179 query_params.pop('export') 

180 redirect_url = f'{request.path}?{query_params.urlencode()}' 

181 if safe_for_redirect(redirect_url): 

182 return redirect(redirect_url) 

183 return redirect(get_action_url(self.queryset.model, action='list')) 

184 

185 # 

186 # Request handlers 

187 # 

188 

189 def get_queryset(self, request): 

190 qs = super().get_queryset(request) 

191 return reapply_model_ordering(qs) 

192 

193 def get(self, request): 

194 """ 

195 GET request handler. 

196 

197 Args: 

198 request: The current request 

199 """ 

200 model = self.queryset.model 

201 object_type = ObjectType.objects.get_for_model(model) 

202 

203 if self.filterset: 

204 self.queryset = self.filterset(request.GET, self.queryset, request=request).qs 

205 

206 # Determine the available actions 

207 actions = self.get_permitted_actions(request.user) 

208 has_table_actions = any(action.multi for action in actions) 

209 

210 if 'export' in request.GET: 

211 

212 # Export the current table view 

213 if request.GET['export'] == 'table': 

214 table = self.get_table(self.queryset, request, has_table_actions) 

215 columns = [name for name, _ in table.selected_columns] 

216 delimiter = request.user.config.get('csv_delimiter') if request.user.is_authenticated else None 

217 return self.export_table(table, columns, delimiter=delimiter) 

218 

219 # Render an ExportTemplate 

220 if request.GET['export']: 

221 template = get_object_or_404( 

222 ExportTemplate.objects.restrict(request.user, 'view'), 

223 object_types=object_type, 

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

225 ) 

226 return self.export_template(template, request) 

227 

228 # Check for YAML export support on the model 

229 if hasattr(model, 'to_yaml'): 

230 response = HttpResponse(self.export_yaml(), content_type='text/yaml') 

231 filename = 'netbox_{}.yaml'.format(self.queryset.model._meta.verbose_name_plural) 

232 response['Content-Disposition'] = content_disposition_header(as_attachment=True, filename=filename) 

233 return response 

234 

235 # Fall back to default table/YAML export 

236 table = self.get_table(self.queryset, request, has_table_actions) 

237 delimiter = request.user.config.get('csv_delimiter') if request.user.is_authenticated else None 

238 return self.export_table(table, delimiter=delimiter) 

239 

240 # Render the objects table 

241 table = self.get_table(self.queryset, request, has_table_actions) 

242 

243 # If this is an HTMX request, return only the rendered table HTML 

244 if htmx_partial(request): 

245 if request.GET.get('embedded', False): 

246 table.embedded = True 

247 # Hide selection checkboxes 

248 if 'pk' in table.base_columns: 

249 table.columns.hide('pk') 

250 return render(request, 'htmx/table.html', { 

251 'table': table, 

252 'model': model, 

253 'actions': actions, 

254 }) 

255 

256 context = { 

257 'model': model, 

258 'table': table, 

259 'table_configs': get_table_configs(table, request.user), 

260 'actions': actions, 

261 'filter_form': self.filterset_form(request.GET) if self.filterset_form else None, 

262 'prerequisite_model': get_prerequisite_model(self.queryset), 

263 **self.get_extra_context(request), 

264 } 

265 

266 return render(request, self.template_name, context) 

267 

268 

269class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView): 

270 """ 

271 Create new objects in bulk. 

272 

273 form: Form class which provides the `pattern` field 

274 model_form: The ModelForm used to create individual objects 

275 pattern_target: Name of the field to be evaluated as a pattern (if any) 

276 """ 

277 form = None 

278 model_form = None 

279 pattern_target = '' 

280 pattern_template_fields = () 

281 htmx_template_name = 'htmx/bulk_add_form.html' 

282 

283 def get_required_permission(self): 

284 return get_permission_for_model(self.queryset.model, 'add') 

285 

286 def get_pattern_context(self, value): 

287 """ 

288 Return a context mapping for substituting the generated pattern value into 

289 model form fields. 

290 

291 By default, the field named by ``pattern_target`` is supported as a 

292 placeholder, e.g. ``{vid}``. 

293 """ 

294 if not self.pattern_target: 

295 return {} 

296 

297 return { 

298 self.pattern_target: str(value), 

299 } 

300 

301 def render_pattern_template(self, template, value): 

302 """ 

303 Replace pattern placeholders in a single form field value. 

304 """ 

305 rendered = str(template) 

306 

307 for key, replacement in self.get_pattern_context(value).items(): 

308 rendered = rendered.replace(f'{{{key}}}', replacement) 

309 

310 return rendered 

311 

312 def apply_pattern_template_fields(self, data, value): 

313 """ 

314 Apply the generated pattern value to any configured template fields. 

315 """ 

316 for field_name in self.pattern_template_fields: 

317 if field_name not in data: 

318 continue 

319 

320 # QueryDict values may be multi-valued; preserve that behavior. 

321 if hasattr(data, 'getlist') and hasattr(data, 'setlist'): 

322 data.setlist(field_name, [ 

323 self.render_pattern_template(field_value, value) 

324 for field_value in data.getlist(field_name) 

325 ]) 

326 else: 

327 data[field_name] = self.render_pattern_template(data[field_name], value) 

328 

329 return data 

330 

331 def get_model_form_data(self, form, request, value): 

332 """ 

333 Return the submitted data to use when instantiating the model form for a 

334 single generated pattern value. 

335 """ 

336 data = request.POST.copy() 

337 data[self.pattern_target] = value 

338 

339 return self.apply_pattern_template_fields(data, value) 

340 

341 def add_model_form_errors(self, form, model_form, value): 

342 """ 

343 Copy validation errors from the generated object's model form back onto 

344 the pattern form for display. 

345 """ 

346 errors = model_form.errors.as_data() 

347 

348 if errors.get(self.pattern_target): 

349 form.add_error('pattern', errors.pop(self.pattern_target)) 

350 

351 for field_name, field_errors in errors.items(): 

352 if field_name == '__all__': 

353 field_label = _('General') 

354 elif field_name in model_form.fields: 

355 field_label = model_form.fields[field_name].label 

356 else: 

357 field_label = field_name 

358 

359 for error in field_errors: 

360 for message in error.messages: 

361 form.add_error( 

362 None, 

363 _('{value}: {field}: {error}').format( 

364 value=value, 

365 field=field_label, 

366 error=message, 

367 ) 

368 ) 

369 

370 def _create_objects(self, form, request): 

371 new_objects = [] 

372 

373 # Create objects from the expanded. Abort the transaction on the first validation error. 

374 for value in form.cleaned_data['pattern']: 

375 

376 # Reinstantiate the model form each time to avoid overwriting the same instance. Use a mutable 

377 # copy of the POST QueryDict so that we can update the target field value. 

378 model_form = self.model_form(self.get_model_form_data(form, request, value)) 

379 

380 # Validate each new object independently. 

381 if model_form.is_valid(): 

382 model_form.instance._changelog_message = model_form.cleaned_data.get('changelog_message', '') 

383 obj = model_form.save() 

384 new_objects.append(obj) 

385 else: 

386 self.add_model_form_errors(form, model_form, value) 

387 

388 # Abort the transaction and break out of the loop. 

389 raise AbortTransaction() 

390 

391 return new_objects 

392 

393 def _get_context(self, request, form, model_form): 

394 model = self.queryset.model 

395 return { 

396 'object': None, 

397 'obj_type': model._meta.verbose_name, 

398 'obj_type_plural': model._meta.verbose_name_plural, 

399 'form': form, 

400 'model_form': model_form, 

401 'return_url': self.get_return_url(request), 

402 'add_url': get_action_url(model, 'add'), 

403 **self.get_extra_context(request), 

404 } 

405 

406 # 

407 # Request handlers 

408 # 

409 

410 def get(self, request): 

411 # Set initial values for visible form fields from query args 

412 initial = {} 

413 for field in getattr(self.model_form._meta, 'fields', []): 

414 if request.GET.get(field): 

415 initial[field] = request.GET[field] 

416 

417 form = self.form() 

418 model_form = self.model_form(initial=initial) 

419 

420 # HTMX partial: only re-render the model form fields 

421 if htmx_partial(request): 

422 return render(request, self.htmx_template_name, { 

423 'model_form': model_form, 

424 }) 

425 

426 return render(request, self.template_name, self._get_context(request, form, model_form)) 

427 

428 def post(self, request): 

429 logger = logging.getLogger('netbox.views.BulkCreateView') 

430 model = self.queryset.model 

431 form = self.form(request.POST) 

432 model_form = self.model_form(request.POST) 

433 

434 # HTMX partial: only re-render the model form fields 

435 if htmx_partial(request): 

436 return render(request, self.htmx_template_name, { 

437 'model_form': model_form, 

438 }) 

439 

440 if form.is_valid(): 

441 logger.debug("Form validation was successful") 

442 

443 try: 

444 with transaction.atomic(using=router.db_for_write(model)): 

445 new_objs = self._create_objects(form, request) 

446 

447 # Enforce object-level permissions 

448 if self.queryset.filter(pk__in=[obj.pk for obj in new_objs]).count() != len(new_objs): 

449 raise PermissionsViolation 

450 

451 # If we make it to this point, validation has succeeded on all new objects. 

452 msg = f"Added {len(new_objs)} {model._meta.verbose_name_plural}" 

453 logger.info(msg) 

454 messages.success(request, msg) 

455 

456 if '_addanother' in request.POST and safe_for_redirect(request.path): 

457 return redirect(request.path) 

458 return redirect(self.get_return_url(request)) 

459 

460 except (AbortTransaction, IntegrityError): 

461 clear_events.send(sender=self) 

462 

463 except (AbortRequest, PermissionsViolation) as e: 

464 logger.debug(e.message) 

465 form.add_error(None, e.message) 

466 clear_events.send(sender=self) 

467 

468 else: 

469 logger.debug("Form validation failed") 

470 

471 return render(request, self.template_name, self._get_context(request, form, model_form)) 

472 

473 

474class BulkImportView(GetReturnURLMixin, BaseMultiObjectView): 

475 """ 

476 Import objects in bulk (CSV/JSON/YAML format). 

477 

478 Attributes: 

479 model_form: The form used to create each imported object 

480 """ 

481 template_name = 'generic/bulk_import.html' 

482 model_form = None 

483 related_object_forms = dict() 

484 

485 def get_required_permission(self): 

486 return get_permission_for_model(self.queryset.model, 'add') 

487 

488 def prep_related_object_data(self, parent, data): 

489 """ 

490 Hook to modify the data for related objects before it's passed to the related object form (for example, to 

491 assign a parent object). 

492 """ 

493 return data 

494 

495 def _get_form_fields(self): 

496 form = self.model_form() 

497 required_fields = {} 

498 optional_fields = {} 

499 

500 # Return only visible fields, with required fields listed first 

501 for field in form.visible_fields(): 

502 if field.is_hidden: 

503 continue 

504 if field.field.required: 

505 required_fields[field.name] = field.field 

506 else: 

507 optional_fields[field.name] = field.field 

508 

509 return {**required_fields, **optional_fields} 

510 

511 def _compile_form_errors(self, errors, index, prefix=None): 

512 error_messages = [] 

513 for field_name, errors in errors.items(): 

514 prefix = f'{prefix}.' if prefix else '' 

515 if field_name == '__all__': 

516 field_name = '' 

517 for err in errors: 

518 error_messages.append(f"Record {index} {prefix}{field_name}: {err}") 

519 return error_messages 

520 

521 def _save_object(self, model_form, request, parent_idx): 

522 _action = 'Updated' if model_form.instance.pk else 'Created' 

523 

524 # Save the primary object. Object-level permissions are enforced in aggregate by 

525 # create_and_update_objects() once all records have been processed. 

526 obj = self.save_object(model_form, request) 

527 

528 # Iterate through the related object forms (if any), validating and saving each instance. 

529 for field_name, related_object_form in self.related_object_forms.items(): 

530 

531 related_objects = model_form.data.get(field_name, list()) 

532 if not isinstance(related_objects, list): 

533 raise ValidationError( 

534 self._compile_form_errors( 

535 {field_name: [_("Must be a list.")]}, 

536 index=parent_idx 

537 ) 

538 ) 

539 

540 related_obj_pks = [] 

541 for i, rel_obj_data in enumerate(related_objects, start=1): 

542 if not isinstance(rel_obj_data, dict): 

543 raise ValidationError( 

544 self._compile_form_errors( 

545 {f'{field_name}[{i}]': [_("Must be a dictionary.")]}, 

546 index=parent_idx, 

547 ) 

548 ) 

549 

550 rel_obj_data = self.prep_related_object_data(obj, rel_obj_data) 

551 f = related_object_form(rel_obj_data) 

552 

553 for subfield_name, field in f.fields.items(): 

554 if subfield_name not in rel_obj_data and hasattr(field, 'initial'): 

555 f.data[subfield_name] = field.initial 

556 

557 if f.is_valid(): 

558 related_obj = f.save() 

559 related_obj_pks.append(related_obj.pk) 

560 else: 

561 # Replicate errors on the related object form to the import form for display and abort 

562 raise ValidationError( 

563 self._compile_form_errors(f.errors, index=parent_idx, prefix=f'{field_name}[{i}]') 

564 ) 

565 

566 # Enforce object-level permissions on related objects 

567 model = related_object_form.Meta.model 

568 if model.objects.filter(pk__in=related_obj_pks).count() != len(related_obj_pks): 

569 raise ObjectDoesNotExist 

570 

571 if is_background_request(request): 

572 request.job.logger.info(f'{_action} {obj}') 

573 

574 return obj 

575 

576 def save_object(self, object_form, request): 

577 """ 

578 Provide a hook to modify the object immediately before saving it (e.g. to encrypt secret data). 

579 

580 Args: 

581 object_form: The model form instance 

582 request: The current request 

583 """ 

584 return object_form.save() 

585 

586 def _process_import_records(self, form, request, records, prefetched_objects): 

587 """ 

588 Process CSV import records and save objects. 

589 """ 

590 saved_objects = [] 

591 

592 for i, record in enumerate(records, start=1): 

593 object_id = int(record.pop('id')) if record.get('id') else None 

594 

595 # Determine whether this object is being created or updated 

596 if object_id: 

597 try: 

598 instance = prefetched_objects[object_id] 

599 except KeyError: 

600 raise ValidationError( 

601 self._compile_form_errors( 

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

603 index=i 

604 ) 

605 ) 

606 

607 # Take a snapshot for change logging 

608 if instance.pk and hasattr(instance, 'snapshot'): 

609 instance.snapshot() 

610 

611 else: 

612 instance = self.queryset.model() 

613 

614 # For newly created objects, apply any default values for custom fields 

615 for cf in CustomField.objects.get_for_model(self.queryset.model): 

616 if cf.ui_editable != CustomFieldUIEditableChoices.YES: 

617 # Skip custom fields which are not editable via the UI 

618 continue 

619 field_name = f'cf_{cf.name}' 

620 if field_name not in record: 

621 record[field_name] = cf.default 

622 

623 # Record changelog message (if any) 

624 instance._changelog_message = form.cleaned_data.get('changelog_message', '') 

625 

626 # Instantiate the model form for the object 

627 model_form_kwargs = { 

628 'data': record, 

629 'instance': instance, 

630 } 

631 if hasattr(form, '_csv_headers'): 

632 model_form_kwargs['headers'] = form._csv_headers # Add CSV headers 

633 model_form = self.model_form(**model_form_kwargs) 

634 

635 # When updating, omit all form fields other than those specified in the record. (No 

636 # fields are required when modifying an existing object.) 

637 if object_id: 

638 unused_fields = [f for f in model_form.fields if f not in record] 

639 for field_name in unused_fields: 

640 del model_form.fields[field_name] 

641 

642 restrict_form_fields(model_form, request.user) 

643 

644 if model_form.is_valid(): 

645 obj = self._save_object(model_form, request, i) 

646 saved_objects.append(obj) 

647 else: 

648 # Raise model form errors 

649 raise ValidationError( 

650 self._compile_form_errors(model_form.errors, index=i) 

651 ) 

652 

653 return saved_objects 

654 

655 def create_and_update_objects(self, form, request): 

656 records = list(form.cleaned_data['data']) 

657 

658 # Prefetch objects to be updated, if any 

659 prefetch_ids = [int(record['id']) for record in records if record.get('id')] 

660 

661 # check for duplicate IDs 

662 duplicate_pks = [pk for pk, count in Counter(prefetch_ids).items() if count > 1] 

663 if duplicate_pks: 

664 error_msg = _( 

665 "Duplicate objects found: {model} with ID(s) {ids} appears multiple times" 

666 ).format( 

667 model=title(self.queryset.model._meta.verbose_name), 

668 ids=', '.join(str(pk) for pk in sorted(duplicate_pks)) 

669 ) 

670 raise ValidationError(error_msg) 

671 

672 # A record which references an existing object by ID performs an update rather than a creation. The bulk 

673 # import view is gated only on the 'add' permission, but updating an existing object requires 'change' (as 

674 # enforced by the REST API). Require the 'change' permission at the model level before permitting any updates, 

675 # and restrict the prefetched objects to those the user is permitted to change (object-level enforcement). 

676 update_pks = set(prefetch_ids) 

677 if prefetch_ids: 

678 change_permission = get_permission_for_model(self.queryset.model, 'change') 

679 if not request.user.has_perm(change_permission): 

680 raise ValidationError( 

681 _( 

682 "This import includes {count} record(s) that reference an existing object by ID and would " 

683 "update it, which requires the {permission} permission. Remove the ID column to create new " 

684 "objects instead." 

685 ).format(count=len(prefetch_ids), permission=change_permission) 

686 ) 

687 change_queryset = self.queryset.model.objects.restrict(request.user, 'change') 

688 else: 

689 change_queryset = self.queryset.model.objects 

690 

691 prefetched_objects = { 

692 obj.pk: obj 

693 for obj in change_queryset.filter(id__in=prefetch_ids) 

694 } if prefetch_ids else {} 

695 

696 # Delay tree updates until all saves are complete (MPTT plugin models only; 

697 # no-op for ltree). TODO: Remove the wrapper in v5.0 (see _delay_mptt_updates). 

698 with _delay_mptt_updates(self.queryset.model): 

699 saved_objects = self._process_import_records(form, request, records, prefetched_objects) 

700 

701 # Enforce object-level permissions in aggregate. Newly created objects are constrained by the 'add' 

702 # permission (self.queryset is already restricted to 'add'); updated objects by 'change' (reusing the 

703 # queryset built above, so no additional per-record work). This runs inside the caller's atomic 

704 # transaction, so any violation rolls back the entire import. 

705 created_pks = [obj.pk for obj in saved_objects if obj.pk not in update_pks] 

706 if self.queryset.filter(pk__in=created_pks).count() != len(created_pks): 

707 raise PermissionsViolation() 

708 updated_pks = [obj.pk for obj in saved_objects if obj.pk in update_pks] 

709 if updated_pks and change_queryset.filter(pk__in=updated_pks).count() != len(updated_pks): 

710 raise PermissionsViolation() 

711 

712 return saved_objects 

713 

714 # 

715 # Request handlers 

716 # 

717 

718 def get(self, request): 

719 model = self.model_form._meta.model 

720 form = BulkImportForm() 

721 if not issubclass(model, ChangeLoggingMixin): 

722 form.fields.pop('changelog_message') 

723 

724 return render(request, self.template_name, { 

725 'model': model, 

726 'form': form, 

727 'fields': self._get_form_fields(), 

728 'import_method': ImportMethodChoices.DIRECT, 

729 'return_url': self.get_return_url(request), 

730 **self.get_extra_context(request), 

731 }) 

732 

733 def post(self, request): 

734 logger = logging.getLogger('netbox.views.BulkImportView') 

735 model = self.model_form._meta.model 

736 form = BulkImportForm(request.POST, request.FILES) 

737 if not issubclass(model, ChangeLoggingMixin): 

738 form.fields.pop('changelog_message') 

739 

740 if form.is_valid(): 

741 logger.debug("Import form validation was successful") 

742 redirect_url = get_action_url(model, action='list') 

743 

744 # If indicated, defer this request to a background job & redirect the user 

745 if form.cleaned_data['background_job']: 

746 job_name = _('Bulk import {count} {object_type}').format( 

747 count=len(form.cleaned_data['data']), 

748 object_type=model._meta.verbose_name_plural, 

749 ) 

750 if process_request_as_job(self.__class__, request, name=job_name): 

751 return redirect(redirect_url) 

752 

753 try: 

754 # Iterate through data and bind each record to a new model form instance. Object-level 

755 # permissions are enforced within create_and_update_objects(). 

756 with transaction.atomic(using=router.db_for_write(model)): 

757 new_objects = self.create_and_update_objects(form, request) 

758 

759 msg = _('Imported {count} {object_type}').format( 

760 count=len(new_objects), 

761 object_type=model._meta.verbose_name_plural 

762 ) 

763 logger.info(msg) 

764 

765 # Handle background job 

766 if is_background_request(request): 

767 request.job.logger.info(msg) 

768 return None 

769 

770 messages.success(request, msg) 

771 return redirect(f"{redirect_url}?modified_by_request={request.id}") 

772 

773 except (AbortRequest, PermissionsViolation, ValidationError) as e: 

774 err_messages = e.messages if type(e) is ValidationError else [e.message] 

775 for msg in err_messages: 

776 logger.debug(msg) 

777 form.add_error(None, msg) 

778 if is_background_request(request): 

779 request.job.logger.error(msg) 

780 request.job.logger.warning("Bulk import aborted") 

781 clear_events.send(sender=self) 

782 if is_background_request(request): 

783 raise JobFailed 

784 

785 else: 

786 logger.debug("Form validation failed") 

787 

788 return render(request, self.template_name, { 

789 'model': model, 

790 'form': form, 

791 'fields': self._get_form_fields(), 

792 # Return the user to the tab they submitted, so a rejected import shows its own error 

793 'import_method': form.cleaned_data.get('import_method') or ImportMethodChoices.DIRECT, 

794 'return_url': self.get_return_url(request), 

795 **self.get_extra_context(request), 

796 }) 

797 

798 

799class BulkEditView(GetReturnURLMixin, BaseMultiObjectView): 

800 """ 

801 Edit objects in bulk. 

802 

803 Attributes: 

804 filterset: FilterSet to apply when deleting by QuerySet 

805 form: The form class used to edit objects in bulk 

806 """ 

807 template_name = 'generic/bulk_edit.html' 

808 filterset = None 

809 form = None 

810 

811 def get_required_permission(self): 

812 return get_permission_for_model(self.queryset.model, 'change') 

813 

814 def pre_save_operations(self, form, obj): 

815 """ 

816 This method is called for each object in _update_objects immediately before full_clean() and 

817 save(). Override to modify the object from form fields that don't map directly to a model field 

818 (e.g. add/remove-style deltas), so the change is validated and persisted within the single 

819 bulk-edit save. No-op by default. 

820 """ 

821 pass 

822 

823 def post_save_operations(self, form, obj): 

824 """ 

825 This method is called for each object in _update_objects. Override to perform additional object-level 

826 operations that are specific to a particular ModelForm. 

827 """ 

828 # Add/remove tags 

829 if form.cleaned_data.get('add_tags', None): 

830 obj.tags.add(*form.cleaned_data['add_tags']) 

831 if form.cleaned_data.get('remove_tags', None): 

832 obj.tags.remove(*form.cleaned_data['remove_tags']) 

833 

834 def _update_objects(self, form, request): 

835 custom_fields = getattr(form, 'custom_fields', {}) 

836 standard_fields = [ 

837 field for field in form.fields if field not in list(custom_fields) + ['pk'] 

838 ] 

839 nullified_fields = request.POST.getlist('_nullify') 

840 updated_objects = [] 

841 model_fields = {} 

842 m2m_fields = {} 

843 

844 # Build list of model fields and m2m fields for later iteration 

845 for name in standard_fields: 

846 try: 

847 model_field = self.queryset.model._meta.get_field(name) 

848 if isinstance(model_field, (ManyToManyField, ManyToManyRel)): 

849 m2m_fields[name] = model_field 

850 elif isinstance(model_field, GenericRel): 

851 # Ignore generic relations (these may be used for other purposes in the form) 

852 continue 

853 else: 

854 model_fields[name] = model_field 

855 except FieldDoesNotExist: 

856 # This form field is used to modify a field rather than set its value directly 

857 model_fields[name] = None 

858 

859 for obj in self.queryset.filter(pk__in=form.cleaned_data['pk']): 

860 

861 # Take a snapshot of change-logged models 

862 if hasattr(obj, 'snapshot'): 

863 obj.snapshot() 

864 

865 # Attach the changelog message (if any) to the object 

866 obj._changelog_message = form.cleaned_data.get('changelog_message') 

867 

868 # Update standard fields. If a field is listed in _nullify, delete its value. 

869 for name, model_field in model_fields.items(): 

870 # Handle nullification 

871 if name in form.nullable_fields and name in nullified_fields: 

872 if type(model_field) is GenericForeignKey: 

873 setattr(obj, name, None) 

874 else: 

875 setattr(obj, name, None if model_field.null else '') 

876 # Normal fields 

877 elif name in form.changed_data: 

878 setattr(obj, name, form.cleaned_data[name]) 

879 

880 # Update custom fields 

881 for name, customfield in custom_fields.items(): 

882 if not name.startswith('cf_'): 

883 raise ImproperlyConfigured( 

884 _("Custom field form field name must begin with 'cf_': {name}").format(name=name) 

885 ) 

886 cf_name = name[3:] # Strip cf_ prefix 

887 if name in form.nullable_fields and name in nullified_fields: 

888 obj.custom_field_data[cf_name] = None 

889 elif name in form.changed_data: 

890 obj.custom_field_data[cf_name] = customfield.serialize(form.cleaned_data[name]) 

891 

892 # Store M2M values for validation 

893 obj._m2m_values = {} 

894 for field in obj._meta.local_many_to_many: 

895 if value := form.cleaned_data.get(field.name): 

896 obj._m2m_values[field.name] = list(value) 

897 elif field.name in nullified_fields: 

898 obj._m2m_values[field.name] = [] 

899 

900 # Apply any form-driven modifications that don't map directly to a model field (e.g. 

901 # add/remove deltas) before validation, so they're part of this single save. 

902 self.pre_save_operations(form, obj) 

903 

904 obj.full_clean() 

905 obj.save() 

906 updated_objects.append(obj) 

907 

908 # Handle M2M fields after save 

909 for name, m2m_field in m2m_fields.items(): 

910 if name in form.nullable_fields and name in nullified_fields: 

911 getattr(obj, name).clear() 

912 elif form.cleaned_data[name]: 

913 getattr(obj, name).set(form.cleaned_data[name]) 

914 

915 self.post_save_operations(form, obj) 

916 

917 if is_background_request(request): 

918 request.job.logger.info(f"Updated {obj}") 

919 

920 # Rebuild the tree for MPTT plugin models (no-op for ltree; its triggers keep 

921 # the tree current). TODO: Remove in v5.0 (see _rebuild_mptt_tree). 

922 _rebuild_mptt_tree(self.queryset.model) 

923 

924 return updated_objects 

925 

926 # 

927 # Request handlers 

928 # 

929 

930 def get(self, request): 

931 return redirect(self.get_return_url(request)) 

932 

933 def post(self, request, **kwargs): 

934 logger = logging.getLogger('netbox.views.BulkEditView') 

935 model = self.queryset.model 

936 

937 # If we are editing *all* objects in the queryset, replace the PK list with all matched objects. 

938 if request.POST.get('_all') and self.filterset is not None: 

939 pk_list = self.filterset(request.GET, self.queryset.values_list('pk', flat=True), request=request).qs 

940 else: 

941 pk_list = request.POST.getlist('pk') 

942 

943 # Include the PK list as initial data for the form 

944 initial_data = {'pk': pk_list} 

945 

946 # Check for other contextual data needed for the form. We avoid passing all of request.GET because the 

947 # filter values will conflict with the bulk edit form fields. 

948 # TODO: Find a better way to accomplish this 

949 if 'device' in request.GET: 

950 initial_data['device'] = request.GET.get('device') 

951 elif 'device_type' in request.GET: 

952 initial_data['device_type'] = request.GET.get('device_type') 

953 elif 'virtual_machine' in request.GET: 

954 initial_data['virtual_machine'] = request.GET.get('virtual_machine') 

955 

956 post_data = request.POST.copy() 

957 post_data.setlist('pk', pk_list) 

958 

959 # An HTMX request without "_apply" is a dependent-field refresh (e.g. changing a content type), not a 

960 # submission. Build the form unbound with the submitted state as initial data so fields reconfigure 

961 # without surfacing validation errors before the user clicks Apply. 

962 if htmx_partial(request) and '_apply' not in request.POST: 

963 initial_data.update(normalize_querydict(post_data)) 

964 initial_data['pk'] = pk_list 

965 form = self.form(initial=initial_data) 

966 else: 

967 form = self.form(post_data, initial=initial_data) 

968 restrict_form_fields(form, request.user) 

969 

970 if '_apply' in request.POST: 

971 if form.is_valid(): 

972 logger.debug("Form validation was successful") 

973 

974 # If indicated, defer this request to a background job & redirect the user 

975 if form.cleaned_data['background_job']: 

976 job_name = _('Bulk edit {count} {object_type}').format( 

977 count=len(form.cleaned_data['pk']), 

978 object_type=model._meta.verbose_name_plural, 

979 ) 

980 if process_request_as_job(self.__class__, request, name=job_name): 

981 return redirect(self.get_return_url(request)) 

982 

983 try: 

984 with transaction.atomic(using=router.db_for_write(model)): 

985 updated_objects = self._update_objects(form, request) 

986 

987 # Enforce object-level permissions 

988 object_count = self.queryset.filter(pk__in=[obj.pk for obj in updated_objects]).count() 

989 if object_count != len(updated_objects): 

990 raise PermissionsViolation 

991 

992 msg = _('Updated {count} {object_type}').format( 

993 count=len(updated_objects), 

994 object_type=model._meta.verbose_name_plural, 

995 ) 

996 logger.info(msg) 

997 

998 # Handle background job 

999 if is_background_request(request): 

1000 request.job.logger.info(msg) 

1001 return None 

1002 

1003 messages.success(self.request, msg) 

1004 return redirect(self.get_return_url(request)) 

1005 

1006 except (AbortRequest, PermissionsViolation, ValidationError) as e: 

1007 err_messages = e.messages if type(e) is ValidationError else [e.message] 

1008 for msg in err_messages: 

1009 logger.debug(msg) 

1010 form.add_error(None, msg) 

1011 if is_background_request(request): 

1012 request.job.logger.error(msg) 

1013 clear_events.send(sender=self) 

1014 if is_background_request(request): 

1015 raise JobFailed 

1016 

1017 else: 

1018 logger.debug("Form validation failed") 

1019 

1020 # Retrieve objects being edited 

1021 table = self.table(self.queryset.filter(pk__in=pk_list), orderable=False) 

1022 if not table.rows: 

1023 messages.warning( 

1024 request, 

1025 _("No {object_type} were selected.").format(object_type=model._meta.verbose_name_plural) 

1026 ) 

1027 return redirect(self.get_return_url(request)) 

1028 

1029 return render(request, self.template_name, { 

1030 'model': model, 

1031 'form': form, 

1032 'table': table, 

1033 'return_url': self.get_return_url(request), 

1034 **self.get_extra_context(request), 

1035 }) 

1036 

1037 

1038class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView): 

1039 """ 

1040 An extendable view for renaming objects in bulk. 

1041 

1042 Attributes: 

1043 field_name: The name of the object attribute to rename (defaults to "name"). Used when 

1044 rename_fields is not set; kept for backward compatibility with plugins. 

1045 rename_fields: Tuple of field names that can be selected for renaming. When two or more 

1046 fields are listed, the form renders a checkbox per field so the user can apply the 

1047 find/replace pattern to any combination of them simultaneously. 

1048 """ 

1049 field_name = 'name' 

1050 rename_fields = () 

1051 template_name = 'generic/bulk_rename.html' 

1052 # Match BulkEditView/BulkDeleteView behavior: allow passing a FilterSet 

1053 # so "Select all N matching query" can expand across the full queryset. 

1054 filterset = None 

1055 

1056 def __init__(self, *args, **kwargs): 

1057 super().__init__(*args, **kwargs) 

1058 

1059 # Use the changelog-aware form for models that support change logging 

1060 base_form = ( 

1061 NetBoxModelBulkRenameForm 

1062 if issubclass(self.queryset.model, ChangeLoggingMixin) 

1063 else BulkRenameForm 

1064 ) 

1065 

1066 self.form = type('_Form', (base_form,), { 

1067 'pk': ModelMultipleChoiceField( 

1068 queryset=self.queryset, 

1069 widget=MultipleHiddenInput(), 

1070 ), 

1071 }) 

1072 

1073 def get_required_permission(self): 

1074 return get_permission_for_model(self.queryset.model, 'change') 

1075 

1076 def _rename_objects(self, form, selected_objects, field_names=None): 

1077 if field_names is None: 

1078 field_names = [self.field_name] 

1079 

1080 find = form.cleaned_data['find'] 

1081 replace = form.cleaned_data['replace'] 

1082 use_regex = form.cleaned_data['use_regex'] 

1083 renamed_pks = [] 

1084 

1085 for obj in selected_objects: 

1086 # Take a snapshot of change-logged models 

1087 if hasattr(obj, 'snapshot'): 

1088 obj.snapshot() 

1089 

1090 new_values = {} 

1091 for field in field_names: 

1092 current = getattr(obj, field, '') or '' 

1093 if use_regex: 

1094 try: 

1095 new_values[field] = re.sub(find, replace, current) 

1096 # Catch regex group reference errors 

1097 except re.error: 

1098 new_values[field] = current 

1099 else: 

1100 new_values[field] = current.replace(find, replace) 

1101 

1102 obj.new_names = SimpleNamespace(**new_values) 

1103 obj.has_changes = any( 

1104 new_values[f] != (getattr(obj, f, '') or '') for f in field_names 

1105 ) 

1106 renamed_pks.append(obj.pk) 

1107 

1108 return renamed_pks 

1109 

1110 def post(self, request): 

1111 logger = logging.getLogger('netbox.views.BulkRenameView') 

1112 # Default field list: either all rename_fields or the single legacy field_name 

1113 field_names = list(self.rename_fields) if self.rename_fields else [self.field_name] 

1114 

1115 # If we are editing *all* objects in the queryset, replace the PK list with all matched objects. 

1116 if request.POST.get('_all') and self.filterset is not None: 

1117 pk_list = self.filterset(request.GET, self.queryset.values_list('pk', flat=True), request=request).qs 

1118 else: 

1119 pk_list = request.POST.getlist('pk') 

1120 

1121 selected_objects = self.queryset.filter(pk__in=pk_list) 

1122 

1123 if '_preview' in request.POST or '_apply' in request.POST: 

1124 form = self.form(request.POST, initial={'pk': pk_list}) 

1125 

1126 if form.is_valid(): 

1127 submitted = [ 

1128 f for f in request.POST.getlist('field_names') 

1129 if self.rename_fields and f in self.rename_fields 

1130 ] 

1131 if self.rename_fields and not submitted: 

1132 form.add_error(None, _("Select at least one field to rename.")) 

1133 elif submitted: 

1134 field_names = submitted 

1135 if not form.errors: 

1136 try: 

1137 with transaction.atomic(using=router.db_for_write(self.queryset.model)): 

1138 renamed_pks = self._rename_objects(form, selected_objects, field_names) 

1139 

1140 if '_apply' in request.POST: 

1141 # Delay tree updates until all saves are complete (MPTT 

1142 # plugin models only; no-op for ltree). 

1143 # TODO: Remove the wrapper in v5.0 (see _delay_mptt_updates). 

1144 with _delay_mptt_updates(self.queryset.model): 

1145 for obj in selected_objects: 

1146 for field in field_names: 

1147 setattr(obj, field, getattr(obj.new_names, field)) 

1148 obj._changelog_message = form.cleaned_data.get('changelog_message', '') 

1149 obj.save() 

1150 

1151 # Enforce constrained permissions 

1152 if self.queryset.filter(pk__in=renamed_pks).count() != len(selected_objects): 

1153 raise PermissionsViolation 

1154 

1155 messages.success( 

1156 request, 

1157 _("Renamed {count} {object_type}").format( 

1158 count=len(selected_objects), 

1159 object_type=self.queryset.model._meta.verbose_name_plural 

1160 ) 

1161 ) 

1162 return redirect(self.get_return_url(request)) 

1163 

1164 except IntegrityError as e: 

1165 messages.error(self.request, ", ".join(e.args)) 

1166 clear_events.send(sender=self) 

1167 

1168 except (AbortRequest, PermissionsViolation) as e: 

1169 logger.debug(e.message) 

1170 form.add_error(None, e.message) 

1171 clear_events.send(sender=self) 

1172 

1173 else: 

1174 form = self.form(initial={'pk': pk_list}) 

1175 

1176 return render(request, self.template_name, { 

1177 'rename_fields': self.rename_fields, 

1178 'selected_field_names': field_names, 

1179 'form': form, 

1180 'obj_type_plural': self.queryset.model._meta.verbose_name_plural, 

1181 'selected_objects': selected_objects, 

1182 'return_url': self.get_return_url(request), 

1183 }) 

1184 

1185 

1186class BulkDeleteView(GetReturnURLMixin, BaseMultiObjectView): 

1187 """ 

1188 Delete objects in bulk. 

1189 

1190 Attributes: 

1191 filterset: FilterSet to apply when deleting by QuerySet 

1192 table: The table used to display devices being deleted 

1193 """ 

1194 template_name = 'generic/bulk_delete.html' 

1195 filterset = None 

1196 table = None 

1197 

1198 def get_required_permission(self): 

1199 return get_permission_for_model(self.queryset.model, 'delete') 

1200 

1201 # 

1202 # Request handlers 

1203 # 

1204 

1205 def get(self, request): 

1206 return redirect(self.get_return_url(request)) 

1207 

1208 def post(self, request, **kwargs): 

1209 logger = logging.getLogger('netbox.views.BulkDeleteView') 

1210 model = self.queryset.model 

1211 

1212 # Are we deleting *all* objects in the queryset or just a selected subset? 

1213 if request.POST.get('_all'): 

1214 qs = model.objects.all() 

1215 if self.filterset is not None: 

1216 qs = self.filterset(request.GET, qs, request=request).qs 

1217 pk_list = qs.only('pk').values_list('pk', flat=True) 

1218 else: 

1219 pk_list = [int(pk) for pk in request.POST.getlist('pk')] 

1220 

1221 if '_confirm' in request.POST: 

1222 form = BulkDeleteForm(model, request.POST) 

1223 if form.is_valid(): 

1224 logger.debug("Form validation was successful") 

1225 

1226 # If indicated, defer this request to a background job & redirect the user 

1227 if form.cleaned_data['background_job']: 

1228 job_name = _('Bulk delete {count} {object_type}').format( 

1229 count=len(form.cleaned_data['pk']), 

1230 object_type=model._meta.verbose_name_plural, 

1231 ) 

1232 if process_request_as_job(self.__class__, request, name=job_name): 

1233 return redirect(self.get_return_url(request)) 

1234 

1235 # Delete objects 

1236 queryset = self.queryset.filter(pk__in=pk_list) 

1237 deleted_count = queryset.count() 

1238 try: 

1239 with transaction.atomic(using=router.db_for_write(model)): 

1240 for obj in queryset: 

1241 

1242 # Take a snapshot of change-logged models 

1243 if hasattr(obj, 'snapshot'): 

1244 obj.snapshot() 

1245 

1246 # Attach the changelog message (if any) to the object 

1247 obj._changelog_message = form.cleaned_data.get('changelog_message') 

1248 

1249 # Delete the object 

1250 obj.delete() 

1251 

1252 if is_background_request(request): 

1253 request.job.logger.info(f"Deleted {obj}") 

1254 

1255 msg = _('Deleted {count} {object_type}').format( 

1256 count=deleted_count, 

1257 object_type=model._meta.verbose_name_plural 

1258 ) 

1259 logger.info(msg) 

1260 

1261 # Handle background job 

1262 if is_background_request(request): 

1263 request.job.logger.info(msg) 

1264 return None 

1265 

1266 messages.success(request, msg) 

1267 

1268 except (ProtectedError, RestrictedError) as e: 

1269 logger.warning(f"Caught {type(e)} while attempting to delete objects") 

1270 clear_events.send(sender=self) 

1271 if is_background_request(request): 

1272 request.job.logger.error( 

1273 _("Deletion failed due to the presence of one or more dependent objects.") 

1274 ) 

1275 raise JobFailed 

1276 handle_protectederror(queryset, request, e) 

1277 

1278 except AbortRequest as e: 

1279 logger.debug(e.message) 

1280 clear_events.send(sender=self) 

1281 if is_background_request(request): 

1282 request.job.logger.error(e.message) 

1283 raise JobFailed 

1284 messages.error(request, mark_safe(e.message)) 

1285 

1286 return redirect(self.get_return_url(request)) 

1287 

1288 logger.debug("Form validation failed") 

1289 

1290 else: 

1291 form = BulkDeleteForm(model, initial={ 

1292 'pk': pk_list, 

1293 'return_url': self.get_return_url(request), 

1294 }) 

1295 

1296 # Retrieve objects being deleted 

1297 table = self.table(self.queryset.filter(pk__in=pk_list), orderable=False) 

1298 if not table.rows: 

1299 messages.warning( 

1300 request, 

1301 _("No {object_type} were selected.").format(object_type=model._meta.verbose_name_plural) 

1302 ) 

1303 return redirect(self.get_return_url(request)) 

1304 

1305 return render(request, self.template_name, { 

1306 'model': model, 

1307 'form': form, 

1308 'table': table, 

1309 'return_url': self.get_return_url(request), 

1310 **self.get_extra_context(request), 

1311 }) 

1312 

1313 

1314# 

1315# Device/VirtualMachine components 

1316# 

1317 

1318class BulkComponentCreateView(GetReturnURLMixin, BaseMultiObjectView): 

1319 """ 

1320 Add one or more components (e.g. interfaces, console ports, etc.) to a set of Devices or VirtualMachines. 

1321 """ 

1322 template_name = 'generic/bulk_add_component.html' 

1323 parent_model = None 

1324 parent_field = None 

1325 form = None 

1326 model_form = None 

1327 filterset = None 

1328 table = None 

1329 

1330 def get_required_permission(self): 

1331 return f'dcim.add_{self.queryset.model._meta.model_name}' 

1332 

1333 def post(self, request): 

1334 logger = logging.getLogger('netbox.views.BulkComponentCreateView') 

1335 parent_model_name = self.parent_model._meta.verbose_name_plural 

1336 model_name = self.queryset.model._meta.verbose_name_plural 

1337 

1338 # Are we editing *all* objects in the queryset or just a selected subset? 

1339 if request.POST.get('_all') and self.filterset is not None: 

1340 queryset = self.filterset(request.GET, self.parent_model.objects.only('pk'), request=request).qs 

1341 pk_list = [obj.pk for obj in queryset] 

1342 else: 

1343 pk_list = [int(pk) for pk in request.POST.getlist('pk')] 

1344 

1345 selected_objects = self.parent_model.objects.filter(pk__in=pk_list) 

1346 if not selected_objects: 

1347 messages.warning( 

1348 request, 

1349 _("No {object_type} were selected.").format(object_type=self.parent_model._meta.verbose_name_plural) 

1350 ) 

1351 return redirect(self.get_return_url(request)) 

1352 table = self.table(selected_objects, orderable=False) 

1353 

1354 if '_create' in request.POST: 

1355 form = self.form(request.POST) 

1356 

1357 if form.is_valid(): 

1358 logger.debug("Form validation was successful") 

1359 

1360 # If indicated, defer this request to a background job & redirect the user 

1361 if form.cleaned_data['background_job']: 

1362 job_name = _('Bulk add {count} {object_type}').format( 

1363 count=len(form.cleaned_data['pk']), 

1364 object_type=self.queryset.model._meta.verbose_name_plural, 

1365 ) 

1366 if process_request_as_job(self.__class__, request, name=job_name): 

1367 return redirect(self.get_return_url(request)) 

1368 

1369 new_components = [] 

1370 data = deepcopy(form.cleaned_data) 

1371 changelog_message = data.pop('changelog_message', '') 

1372 data.pop('background_job', None) 

1373 replication_data = { 

1374 field: data.pop(field) for field in form.replication_fields 

1375 } 

1376 

1377 try: 

1378 with transaction.atomic(using=router.db_for_write(self.queryset.model)): 

1379 

1380 for obj in data['pk']: 

1381 

1382 pattern_count = len(replication_data[form.replication_fields[0]]) 

1383 for i in range(pattern_count): 

1384 component_data = { 

1385 self.parent_field: obj.pk 

1386 } 

1387 component_data.update(data) 

1388 for field, values in replication_data.items(): 

1389 if values: 

1390 component_data[field] = values[i] 

1391 

1392 component_form = self.model_form(component_data) 

1393 if component_form.is_valid(): 

1394 if changelog_message: 

1395 component_form.instance._changelog_message = changelog_message 

1396 instance = component_form.save() 

1397 logger.debug(f"Created {instance} on {instance.parent_object}") 

1398 new_components.append(instance) 

1399 else: 

1400 for field, errors in component_form.errors.as_data().items(): 

1401 for e in errors: 

1402 err_msg = '{}: {}'.format(obj, ', '.join(e)) 

1403 form.add_error(field, err_msg) 

1404 if is_background_request(request): 

1405 request.job.logger.error(err_msg) 

1406 

1407 # Enforce object-level permissions 

1408 component_ids = [obj.pk for obj in new_components] 

1409 if self.queryset.filter(pk__in=component_ids).count() != len(new_components): 

1410 raise PermissionsViolation 

1411 

1412 except IntegrityError: 

1413 clear_events.send(sender=self) 

1414 if is_background_request(request): 

1415 request.job.logger.error(_("An integrity error occurred while creating components")) 

1416 raise JobFailed 

1417 

1418 except (AbortRequest, PermissionsViolation) as e: 

1419 logger.debug(e.message) 

1420 form.add_error(None, e.message) 

1421 clear_events.send(sender=self) 

1422 if is_background_request(request): 

1423 request.job.logger.error(e.message) 

1424 raise JobFailed 

1425 

1426 if not form.errors: 

1427 msg = _("Added {count} {component} to {parent_count} {parent}.").format( 

1428 count=len(new_components), 

1429 component=model_name, 

1430 parent_count=len(form.cleaned_data['pk']), 

1431 parent=parent_model_name, 

1432 ) 

1433 logger.info(msg) 

1434 

1435 # Handle background job 

1436 if is_background_request(request): 

1437 request.job.logger.info(msg) 

1438 return None 

1439 

1440 messages.success(request, msg) 

1441 

1442 return redirect(self.get_return_url(request)) 

1443 

1444 else: 

1445 logger.debug("Form validation failed") 

1446 

1447 else: 

1448 form = self.form(initial={'pk': pk_list}) 

1449 

1450 return render(request, self.template_name, { 

1451 'form': form, 

1452 'parent_model_name': parent_model_name, 

1453 'model_name': model_name, 

1454 'table': table, 

1455 'return_url': self.get_return_url(request), 

1456 })