Coverage for netbox/views/generic/object_views.py: 19%

290 statements  

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

1import logging 

2from collections import defaultdict 

3 

4from django.contrib import messages 

5from django.db import router, transaction 

6from django.db.models import ProtectedError, RestrictedError 

7from django.http import HttpResponse 

8from django.shortcuts import redirect, render 

9from django.urls import reverse 

10from django.utils.html import escape 

11from django.utils.safestring import mark_safe 

12from django.utils.translation import gettext as _ 

13 

14from core.signals import clear_events 

15from netbox.models.deletion import ConfirmCollector, CountOnly 

16from netbox.object_actions import BulkDelete, BulkEdit, CloneObject, DeleteObject, EditObject 

17from utilities.error_handlers import handle_protectederror 

18from utilities.exceptions import AbortRequest, PermissionsViolation 

19from utilities.forms import DeleteForm, restrict_form_fields 

20from utilities.htmx import htmx_partial 

21from utilities.permissions import get_permission_for_model 

22from utilities.querydict import normalize_querydict, prepare_cloned_fields 

23from utilities.request import safe_for_redirect 

24from utilities.tables import get_table_configs 

25from utilities.views import GetReturnURLMixin, get_action_url, get_default_template 

26 

27from .base import BaseObjectView 

28from .mixins import ActionsMixin, TableMixin 

29from .utils import get_prerequisite_model 

30 

31__all__ = ( 

32 'ComponentCreateView', 

33 'ObjectChildrenView', 

34 'ObjectDeleteView', 

35 'ObjectEditView', 

36 'ObjectView', 

37) 

38 

39 

40class ObjectView(ActionsMixin, BaseObjectView): 

41 """ 

42 Retrieve a single object for display. 

43 

44 Note: If `template_name` is not specified, it will be determined automatically based on the queryset model. 

45 

46 Attributes: 

47 layout: An instance of `netbox.ui.layout.Layout` which defines the page layout (overrides HTML template) 

48 tab: A ViewTab instance for the view 

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

50 """ 

51 layout = None 

52 tab = None 

53 actions = (CloneObject, EditObject, DeleteObject) 

54 

55 def get_required_permission(self): 

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

57 

58 def get_template_name(self): 

59 """ 

60 Return self.template_name if defined. Otherwise, dynamically resolve the template name using the queryset 

61 model's `app_label` and `model_name`. 

62 """ 

63 if self.template_name is not None: 

64 return self.template_name 

65 model_opts = self.queryset.model._meta 

66 return f'{model_opts.app_label}/{model_opts.model_name}.html' 

67 

68 # 

69 # Request handlers 

70 # 

71 

72 def get(self, request, **kwargs): 

73 """ 

74 GET request handler. `*args` and `**kwargs` are passed to identify the object being queried. 

75 

76 Args: 

77 request: The current request 

78 """ 

79 instance = self.get_object(**kwargs) 

80 actions = self.get_permitted_actions(request.user, model=instance) 

81 

82 return render(request, self.get_template_name(), { 

83 'object': instance, 

84 'actions': actions, 

85 'tab': self.tab, 

86 'layout': self.layout, 

87 **self.get_extra_context(request, instance), 

88 }) 

89 

90 

91class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin): 

92 """ 

93 Display a table of child objects associated with the parent object. For example, NetBox uses this to display 

94 the set of child IP addresses within a parent prefix. 

95 

96 Attributes: 

97 child_model: The model class which represents the child objects 

98 table: The django-tables2 Table class used to render the child objects list 

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

100 filterset_form: The form class used to render filter options 

101 filterset_instance: The bound FilterSet built for the current request (set during get()) 

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

103 """ 

104 child_model = None 

105 table = None 

106 filterset = None 

107 filterset_form = None 

108 filterset_instance = None 

109 actions = (CloneObject, EditObject, DeleteObject, BulkEdit, BulkDelete) 

110 template_name = 'generic/object_children.html' 

111 

112 def get_children(self, request, parent): 

113 """ 

114 Return a QuerySet of child objects. 

115 

116 Args: 

117 request: The current request 

118 parent: The parent object 

119 """ 

120 raise NotImplementedError(_('{class_name} must implement get_children()').format( 

121 class_name=self.__class__.__name__ 

122 )) 

123 

124 def prep_table_data(self, request, queryset, parent): 

125 """ 

126 Provides a hook for subclassed views to modify data before initializing the table. 

127 

128 Args: 

129 request: The current request 

130 queryset: The filtered queryset of child objects 

131 parent: The parent object 

132 """ 

133 return queryset 

134 

135 # 

136 # Request handlers 

137 # 

138 

139 def get(self, request, *args, **kwargs): 

140 """ 

141 GET handler for rendering child objects. 

142 """ 

143 instance = self.get_object(**kwargs) 

144 child_objects = self.get_children(request, instance) 

145 

146 if self.filterset: 

147 # Retain the bound FilterSet so that prep_table_data() and get_extra_context() can 

148 # inspect the request's validated filter data without rebuilding it. 

149 self.filterset_instance = self.filterset(request.GET, child_objects, request=request) 

150 child_objects = self.filterset_instance.qs 

151 

152 # Determine the available actions 

153 actions = self.get_permitted_actions(request.user, model=self.child_model) 

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

155 

156 table_data = self.prep_table_data(request, child_objects, instance) 

157 table = self.get_table(table_data, request, has_table_actions) 

158 

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

160 if htmx_partial(request): 

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

162 'object': instance, 

163 'table': table, 

164 'model': self.child_model, 

165 }) 

166 

167 return render(request, self.get_template_name(), { 

168 'object': instance, 

169 'model': self.child_model, 

170 'child_model': self.child_model, 

171 'base_template': get_default_template(instance), 

172 'table': table, 

173 'table_config': f'{table.name}_config', 

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

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

176 'actions': actions, 

177 'tab': self.tab, 

178 'return_url': request.get_full_path(), 

179 **self.get_extra_context(request, instance), 

180 }) 

181 

182 

183class ObjectEditView(GetReturnURLMixin, BaseObjectView): 

184 """ 

185 Create or edit a single object. 

186 

187 Attributes: 

188 form: The form used to create or edit the object 

189 """ 

190 template_name = 'generic/object_edit.html' 

191 form = None 

192 htmx_template_name = 'htmx/form.html' 

193 

194 def dispatch(self, request, *args, **kwargs): 

195 # Determine required permission based on whether we are editing an existing object 

196 self._permission_action = 'change' if kwargs else 'add' 

197 

198 return super().dispatch(request, *args, **kwargs) 

199 

200 def get_required_permission(self): 

201 # self._permission_action is set by dispatch() to either "add" or "change" depending on whether 

202 # we are modifying an existing object or creating a new one. 

203 return get_permission_for_model(self.queryset.model, self._permission_action) 

204 

205 def get_object(self, **kwargs): 

206 """ 

207 Return an object for editing. If no keyword arguments have been specified, this will be a new instance. 

208 """ 

209 if not kwargs: 

210 # We're creating a new object 

211 return self.queryset.model() 

212 return super().get_object(**kwargs) 

213 

214 def alter_object(self, obj, request, url_args, url_kwargs): 

215 """ 

216 Provides a hook for views to modify an object before it is processed. For example, a parent object can be 

217 defined given some parameter from the request URL. 

218 

219 Args: 

220 obj: The object being edited 

221 request: The current request 

222 url_args: URL path args 

223 url_kwargs: URL path kwargs 

224 """ 

225 return obj 

226 

227 def get_extra_addanother_params(self, request): 

228 """ 

229 Return a dictionary of extra parameters to use on the Add Another button. 

230 """ 

231 return {} 

232 

233 # 

234 # Request handlers 

235 # 

236 

237 def get(self, request, *args, **kwargs): 

238 """ 

239 GET request handler. 

240 

241 Args: 

242 request: The current request 

243 """ 

244 obj = self.get_object(**kwargs) 

245 obj = self.alter_object(obj, request, args, kwargs) 

246 model = self.queryset.model 

247 

248 initial_data = normalize_querydict(request.GET) 

249 form_prefix = 'quickadd' if request.GET.get('_quickadd') else None 

250 form = self.form(instance=obj, initial=initial_data, prefix=form_prefix) 

251 restrict_form_fields(form, request.user) 

252 

253 context = { 

254 'model': model, 

255 'object': obj, 

256 'form': form, 

257 } 

258 

259 # If the form is being displayed within a "quick add" widget, 

260 # use the appropriate template 

261 if request.GET.get('_quickadd'): 

262 return render(request, 'htmx/quick_add.html', context) 

263 

264 # If this is an HTMX request, return only the rendered form HTML 

265 if htmx_partial(request): 

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

267 

268 return render(request, self.template_name, { 

269 **context, 

270 'return_url': self.get_return_url(request, obj), 

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

272 **self.get_extra_context(request, obj), 

273 }) 

274 

275 def post(self, request, *args, **kwargs): 

276 """ 

277 POST request handler. 

278 

279 Args: 

280 request: The current request 

281 """ 

282 logger = logging.getLogger('netbox.views.ObjectEditView') 

283 obj = self.get_object(**kwargs) 

284 model = self.queryset.model 

285 

286 # Take a snapshot for change logging (if editing an existing object) 

287 if obj.pk and hasattr(obj, 'snapshot'): 

288 obj.snapshot() 

289 

290 obj = self.alter_object(obj, request, args, kwargs) 

291 

292 form_prefix = 'quickadd' if request.GET.get('_quickadd') else None 

293 form = self.form(data=request.POST, files=request.FILES, instance=obj, prefix=form_prefix) 

294 restrict_form_fields(form, request.user) 

295 

296 if form.is_valid(): 

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

298 

299 # Record changelog message (if any) 

300 obj._changelog_message = form.cleaned_data.pop('changelog_message', '') 

301 

302 try: 

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

304 object_created = form.instance.pk is None 

305 obj = form.save() 

306 

307 # Check that the new object conforms with any assigned object-level permissions 

308 if not self.queryset.filter(pk=obj.pk).exists(): 

309 raise PermissionsViolation() 

310 

311 msg = '{} {}'.format( 

312 'Created' if object_created else 'Modified', 

313 self.queryset.model._meta.verbose_name 

314 ) 

315 logger.info(f"{msg} {obj} (PK: {obj.pk})") 

316 if hasattr(obj, 'get_absolute_url'): 

317 msg = mark_safe(f'{msg} <a href="{obj.get_absolute_url()}">{escape(obj)}</a>') 

318 else: 

319 msg = f'{msg} {obj}' 

320 messages.success(request, msg) 

321 

322 # Object was created via "quick add" modal 

323 if '_quickadd' in request.POST: 

324 return render(request, 'htmx/quick_add_created.html', { 

325 'object': obj, 

326 }) 

327 

328 # If adding another object, redirect back to the edit form 

329 if '_addanother' in request.POST: 

330 redirect_url = request.path 

331 

332 # If cloning is supported, pre-populate a new instance of the form 

333 params = prepare_cloned_fields(obj) 

334 params.update(self.get_extra_addanother_params(request)) 

335 if params: 

336 if 'return_url' in request.GET: 

337 params['return_url'] = request.GET.get('return_url') 

338 redirect_url += f"?{params.urlencode()}" 

339 if not safe_for_redirect(redirect_url): 

340 redirect_url = reverse('home') 

341 

342 return redirect(redirect_url) 

343 

344 return_url = self.get_return_url(request, obj) 

345 

346 # If the object has been created or edited via HTMX, return an HTMX redirect to the object view 

347 if request.htmx: 

348 return HttpResponse(headers={ 

349 'HX-Location': return_url, 

350 }) 

351 

352 return redirect(return_url) 

353 

354 except (AbortRequest, PermissionsViolation) as e: 

355 logger.debug(e.message) 

356 form.add_error(None, e.message) 

357 clear_events.send(sender=self) 

358 

359 else: 

360 logger.debug("Form validation failed") 

361 

362 context = { 

363 'model': model, 

364 'object': obj, 

365 'form': form, 

366 'return_url': self.get_return_url(request, obj), 

367 **self.get_extra_context(request, obj), 

368 } 

369 

370 # Form was submitted via a "quick add" widget 

371 if '_quickadd' in request.POST: 

372 return render(request, 'htmx/quick_add.html', context) 

373 

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

375 

376 

377class ObjectDeleteView(GetReturnURLMixin, BaseObjectView): 

378 """ 

379 Delete a single object. 

380 """ 

381 template_name = 'generic/object_delete.html' 

382 

383 def get_required_permission(self): 

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

385 

386 def _get_dependent_objects(self, obj): 

387 """ 

388 Returns a dictionary mapping each dependent model to the objects (of that model) which will 

389 be deleted as a result of deleting the requested object. 

390 

391 Values are normally a list of instances. For high-cardinality relations that we do not 

392 materialize to avoid excessive memory use (currently a JobsMixin object's jobs, see 

393 #22812), the value is a `CountOnly` — a lenient empty iterable whose `len()` is the true 

394 row count, so it renders as a non-expandable row alongside the itemized relations. 

395 

396 Args: 

397 obj: The object to return dependent objects for 

398 """ 

399 using = router.db_for_write(obj._meta.model) 

400 collector = ConfirmCollector(using=using) 

401 collector.collect([obj]) 

402 

403 # Compile a mapping of models to instances 

404 dependent_objects = defaultdict(list) 

405 for model, instances in collector.instances_with_model(): 

406 # Ignore relations to auto-created models (e.g. many-to-many mappings) 

407 if model._meta.auto_created: 

408 continue 

409 # Omit the root object 

410 if instances == obj: 

411 continue 

412 dependent_objects[model].append(instances) 

413 

414 # Add count-only entries for relations the collector enumerated by count rather than by 

415 # instance (e.g. jobs), so they render as non-expandable rows in the same mapping. 

416 dependent_objects = dict(dependent_objects) 

417 for model, count in collector.generic_relation_counts.items(): 

418 dependent_objects[model] = CountOnly(count) 

419 

420 return dependent_objects 

421 

422 def _handle_protected_objects(self, obj, protected_objects, request, exc): 

423 """ 

424 Handle a ProtectedError or RestrictedError exception raised while attempt to resolve dependent objects. 

425 """ 

426 handle_protectederror(protected_objects, request, exc) 

427 

428 if request.htmx: 

429 return HttpResponse(headers={ 

430 'HX-Redirect': obj.get_absolute_url(), 

431 }) 

432 return redirect(obj.get_absolute_url()) 

433 

434 # 

435 # Request handlers 

436 # 

437 

438 def get(self, request, *args, **kwargs): 

439 """ 

440 GET request handler. 

441 

442 Args: 

443 request: The current request 

444 """ 

445 obj = self.get_object(**kwargs) 

446 form = DeleteForm(instance=obj, initial=request.GET) 

447 

448 try: 

449 dependent_objects = self._get_dependent_objects(obj) 

450 except ProtectedError as e: 

451 return self._handle_protected_objects(obj, e.protected_objects, request, e) 

452 except RestrictedError as e: 

453 return self._handle_protected_objects(obj, e.restricted_objects, request, e) 

454 

455 # If this is an HTMX request, return only the rendered deletion form as modal content 

456 if htmx_partial(request): 

457 form_url = get_action_url(self.queryset.model, action='delete', kwargs={'pk': obj.pk}) 

458 return render(request, 'htmx/delete_form.html', { 

459 'object': obj, 

460 'object_type': self.queryset.model._meta.verbose_name, 

461 'form': form, 

462 'form_url': form_url, 

463 'dependent_objects': dependent_objects, 

464 **self.get_extra_context(request, obj), 

465 }) 

466 

467 return render(request, self.template_name, { 

468 'object': obj, 

469 'form': form, 

470 'return_url': self.get_return_url(request, obj), 

471 'dependent_objects': dependent_objects, 

472 **self.get_extra_context(request, obj), 

473 }) 

474 

475 def post(self, request, *args, **kwargs): 

476 """ 

477 POST request handler. 

478 

479 Args: 

480 request: The current request 

481 """ 

482 logger = logging.getLogger('netbox.views.ObjectDeleteView') 

483 obj = self.get_object(**kwargs) 

484 form = DeleteForm(request.POST, instance=obj) 

485 

486 if form.is_valid(): 

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

488 

489 # Take a snapshot of change-logged models 

490 if hasattr(obj, 'snapshot'): 

491 obj.snapshot() 

492 

493 # Record changelog message (if any) 

494 obj._changelog_message = form.cleaned_data.pop('changelog_message', '') 

495 

496 # Delete the object 

497 try: 

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

499 obj.delete() 

500 except (ProtectedError, RestrictedError) as e: 

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

502 clear_events.send(sender=self) 

503 handle_protectederror([obj], request, e) 

504 return redirect(obj.get_absolute_url()) 

505 except AbortRequest as e: 

506 logger.debug(e.message) 

507 clear_events.send(sender=self) 

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

509 return redirect(obj.get_absolute_url()) 

510 

511 msg = 'Deleted {} {}'.format(self.queryset.model._meta.verbose_name, obj) 

512 logger.info(msg) 

513 messages.success(request, msg) 

514 

515 return_url = form.cleaned_data.get('return_url') 

516 if return_url and return_url.startswith('/'): 

517 return redirect(return_url) 

518 return redirect(self.get_return_url(request, obj)) 

519 

520 logger.debug("Form validation failed") 

521 

522 return render(request, self.template_name, { 

523 'object': obj, 

524 'form': form, 

525 'return_url': self.get_return_url(request, obj), 

526 **self.get_extra_context(request, obj), 

527 }) 

528 

529 

530# 

531# Device/VirtualMachine components 

532# 

533 

534class ComponentCreateView(GetReturnURLMixin, BaseObjectView): 

535 """ 

536 Add one or more components (e.g. interfaces, console ports, etc.) to a Device or VirtualMachine. 

537 """ 

538 template_name = 'generic/object_edit.html' 

539 form = None 

540 model_form = None 

541 

542 # Parent assignments carried into the "Add Another" redirect, which cloning cannot supply 

543 parent_fields = ('device', 'module', 'device_type', 'module_type', 'virtual_machine') 

544 

545 def get_required_permission(self): 

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

547 

548 def alter_object(self, instance, request): 

549 return instance 

550 

551 def initialize_form(self, request): 

552 data = request.POST if request.method == 'POST' else None 

553 initial_data = normalize_querydict(request.GET) 

554 

555 form = self.form(data=data, initial=initial_data) 

556 

557 return form 

558 

559 def get(self, request): 

560 form = self.initialize_form(request) 

561 instance = self.alter_object(self.queryset.model(), request) 

562 

563 # If this is an HTMX request, return only the rendered form HTML 

564 if htmx_partial(request): 

565 return render(request, 'htmx/form.html', { 

566 'form': form, 

567 }) 

568 

569 return render(request, self.template_name, { 

570 'object': instance, 

571 'form': form, 

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

573 }) 

574 

575 def post(self, request): 

576 logger = logging.getLogger('netbox.views.ComponentCreateView') 

577 form = self.initialize_form(request) 

578 instance = self.alter_object(self.queryset.model(), request) 

579 

580 # Note that the form instance is a replicated field base 

581 # This is needed to avoid running custom validators multiple times 

582 form.instance._replicated_base = hasattr(self.form, "replication_fields") 

583 

584 if form.is_valid(): 

585 changelog_message = form.cleaned_data.pop('changelog_message', '') 

586 new_components = [] 

587 data = request.POST.copy() 

588 pattern_count = len(form.cleaned_data[self.form.replication_fields[0]]) 

589 

590 for i in range(pattern_count): 

591 for field_name in self.form.replication_fields: 

592 if form.cleaned_data.get(field_name): 

593 data[field_name] = form.cleaned_data[field_name][i] 

594 

595 if hasattr(form, 'get_iterative_data'): 

596 for k, v in form.get_iterative_data(i).items(): 

597 data.setlist(k, v) 

598 

599 component_form = self.model_form(data) 

600 

601 if component_form.is_valid(): 

602 new_components.append(component_form) 

603 else: 

604 form.errors.update(component_form.errors) 

605 break 

606 

607 if not form.errors and not component_form.errors: 

608 try: 

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

610 # Create the new components 

611 new_objs = [] 

612 for component_form in new_components: 

613 # Record changelog message (if any) 

614 if changelog_message: 

615 component_form.instance._changelog_message = changelog_message 

616 obj = component_form.save() 

617 new_objs.append(obj) 

618 

619 # Enforce object-level permissions 

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

621 raise PermissionsViolation 

622 

623 messages.success(request, "Added {} {}".format( 

624 len(new_components), self.queryset.model._meta.verbose_name_plural 

625 )) 

626 

627 # Redirect user on success 

628 if '_addanother' in request.POST: 

629 # A name pattern may create several components, so follow the last one 

630 new_obj = new_objs[-1] 

631 redirect_url = request.path 

632 params = prepare_cloned_fields(new_obj) 

633 for field_name in self.parent_fields: 

634 if (parent_id := getattr(new_obj, f'{field_name}_id', None)) is not None: 

635 params[field_name] = parent_id 

636 if 'return_url' in request.GET: 

637 params['return_url'] = request.GET.get('return_url') 

638 if params: 

639 redirect_url += f"?{params.urlencode()}" 

640 if safe_for_redirect(redirect_url): 

641 return redirect(redirect_url) 

642 return redirect(self.get_return_url(request)) 

643 except (AbortRequest, PermissionsViolation) as e: 

644 logger.debug(e.message) 

645 form.add_error(None, e.message) 

646 clear_events.send(sender=self) 

647 

648 return render(request, self.template_name, { 

649 'object': instance, 

650 'form': form, 

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

652 })