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
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
1import logging
2from collections import defaultdict
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 _
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
27from .base import BaseObjectView
28from .mixins import ActionsMixin, TableMixin
29from .utils import get_prerequisite_model
31__all__ = (
32 'ComponentCreateView',
33 'ObjectChildrenView',
34 'ObjectDeleteView',
35 'ObjectEditView',
36 'ObjectView',
37)
40class ObjectView(ActionsMixin, BaseObjectView):
41 """
42 Retrieve a single object for display.
44 Note: If `template_name` is not specified, it will be determined automatically based on the queryset model.
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)
55 def get_required_permission(self):
56 return get_permission_for_model(self.queryset.model, 'view')
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'
68 #
69 # Request handlers
70 #
72 def get(self, request, **kwargs):
73 """
74 GET request handler. `*args` and `**kwargs` are passed to identify the object being queried.
76 Args:
77 request: The current request
78 """
79 instance = self.get_object(**kwargs)
80 actions = self.get_permitted_actions(request.user, model=instance)
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 })
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.
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'
112 def get_children(self, request, parent):
113 """
114 Return a QuerySet of child objects.
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 ))
124 def prep_table_data(self, request, queryset, parent):
125 """
126 Provides a hook for subclassed views to modify data before initializing the table.
128 Args:
129 request: The current request
130 queryset: The filtered queryset of child objects
131 parent: The parent object
132 """
133 return queryset
135 #
136 # Request handlers
137 #
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)
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
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)
156 table_data = self.prep_table_data(request, child_objects, instance)
157 table = self.get_table(table_data, request, has_table_actions)
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 })
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 })
183class ObjectEditView(GetReturnURLMixin, BaseObjectView):
184 """
185 Create or edit a single object.
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'
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'
198 return super().dispatch(request, *args, **kwargs)
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)
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)
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.
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
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 {}
233 #
234 # Request handlers
235 #
237 def get(self, request, *args, **kwargs):
238 """
239 GET request handler.
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
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)
253 context = {
254 'model': model,
255 'object': obj,
256 'form': form,
257 }
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)
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)
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 })
275 def post(self, request, *args, **kwargs):
276 """
277 POST request handler.
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
286 # Take a snapshot for change logging (if editing an existing object)
287 if obj.pk and hasattr(obj, 'snapshot'):
288 obj.snapshot()
290 obj = self.alter_object(obj, request, args, kwargs)
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)
296 if form.is_valid():
297 logger.debug("Form validation was successful")
299 # Record changelog message (if any)
300 obj._changelog_message = form.cleaned_data.pop('changelog_message', '')
302 try:
303 with transaction.atomic(using=router.db_for_write(model)):
304 object_created = form.instance.pk is None
305 obj = form.save()
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()
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)
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 })
328 # If adding another object, redirect back to the edit form
329 if '_addanother' in request.POST:
330 redirect_url = request.path
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')
342 return redirect(redirect_url)
344 return_url = self.get_return_url(request, obj)
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 })
352 return redirect(return_url)
354 except (AbortRequest, PermissionsViolation) as e:
355 logger.debug(e.message)
356 form.add_error(None, e.message)
357 clear_events.send(sender=self)
359 else:
360 logger.debug("Form validation failed")
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 }
370 # Form was submitted via a "quick add" widget
371 if '_quickadd' in request.POST:
372 return render(request, 'htmx/quick_add.html', context)
374 return render(request, self.template_name, context)
377class ObjectDeleteView(GetReturnURLMixin, BaseObjectView):
378 """
379 Delete a single object.
380 """
381 template_name = 'generic/object_delete.html'
383 def get_required_permission(self):
384 return get_permission_for_model(self.queryset.model, 'delete')
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.
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.
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])
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)
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)
420 return dependent_objects
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)
428 if request.htmx:
429 return HttpResponse(headers={
430 'HX-Redirect': obj.get_absolute_url(),
431 })
432 return redirect(obj.get_absolute_url())
434 #
435 # Request handlers
436 #
438 def get(self, request, *args, **kwargs):
439 """
440 GET request handler.
442 Args:
443 request: The current request
444 """
445 obj = self.get_object(**kwargs)
446 form = DeleteForm(instance=obj, initial=request.GET)
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)
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 })
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 })
475 def post(self, request, *args, **kwargs):
476 """
477 POST request handler.
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)
486 if form.is_valid():
487 logger.debug("Form validation was successful")
489 # Take a snapshot of change-logged models
490 if hasattr(obj, 'snapshot'):
491 obj.snapshot()
493 # Record changelog message (if any)
494 obj._changelog_message = form.cleaned_data.pop('changelog_message', '')
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())
511 msg = 'Deleted {} {}'.format(self.queryset.model._meta.verbose_name, obj)
512 logger.info(msg)
513 messages.success(request, msg)
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))
520 logger.debug("Form validation failed")
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 })
530#
531# Device/VirtualMachine components
532#
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
542 # Parent assignments carried into the "Add Another" redirect, which cloning cannot supply
543 parent_fields = ('device', 'module', 'device_type', 'module_type', 'virtual_machine')
545 def get_required_permission(self):
546 return get_permission_for_model(self.queryset.model, 'add')
548 def alter_object(self, instance, request):
549 return instance
551 def initialize_form(self, request):
552 data = request.POST if request.method == 'POST' else None
553 initial_data = normalize_querydict(request.GET)
555 form = self.form(data=data, initial=initial_data)
557 return form
559 def get(self, request):
560 form = self.initialize_form(request)
561 instance = self.alter_object(self.queryset.model(), request)
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 })
569 return render(request, self.template_name, {
570 'object': instance,
571 'form': form,
572 'return_url': self.get_return_url(request),
573 })
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)
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")
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]])
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]
595 if hasattr(form, 'get_iterative_data'):
596 for k, v in form.get_iterative_data(i).items():
597 data.setlist(k, v)
599 component_form = self.model_form(data)
601 if component_form.is_valid():
602 new_components.append(component_form)
603 else:
604 form.errors.update(component_form.errors)
605 break
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)
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
623 messages.success(request, "Added {} {}".format(
624 len(new_components), self.queryset.model._meta.verbose_name_plural
625 ))
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)
648 return render(request, self.template_name, {
649 'object': instance,
650 'form': form,
651 'return_url': self.get_return_url(request),
652 })