Coverage for utilities/views.py: 46%
162 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
1from collections.abc import Iterable
2from dataclasses import dataclass
4from django.conf import settings
5from django.contrib.auth.mixins import AccessMixin
6from django.core.exceptions import ImproperlyConfigured
7from django.db.models import QuerySet
8from django.http import HttpResponseForbidden
9from django.template import TemplateDoesNotExist
10from django.template.loader import get_template
11from django.urls import reverse
12from django.urls.exceptions import NoReverseMatch
13from django.utils.translation import gettext_lazy as _
14from rest_framework.exceptions import AuthenticationFailed
16from netbox.api.authentication import TokenAuthentication
17from netbox.plugins import PluginConfig
18from netbox.registry import registry
19from utilities.relations import get_related_models
20from utilities.request import safe_for_redirect
21from utilities.string import title
23from .permissions import resolve_permission
25__all__ = (
26 'ConditionalLoginRequiredMixin',
27 'ContentTypePermissionRequiredMixin',
28 'GetRelatedModelsMixin',
29 'GetReturnURLMixin',
30 'ObjectPermissionRequiredMixin',
31 'TokenConditionalLoginRequiredMixin',
32 'ViewTab',
33 'get_action_url',
34 'get_default_template',
35 'get_view',
36 'get_viewname',
37 'register_model_view',
38)
41#
42# View Mixins
43#
45class ConditionalLoginRequiredMixin(AccessMixin):
46 """
47 Similar to Django's LoginRequiredMixin, but enforces authentication only if LOGIN_REQUIRED is True.
48 """
49 def dispatch(self, request, *args, **kwargs):
50 if settings.LOGIN_REQUIRED and not request.user.is_authenticated: 50 ↛ 52line 50 didn't jump to line 52 because the condition on line 50 was always true
51 return self.handle_no_permission()
52 return super().dispatch(request, *args, **kwargs)
55class TokenConditionalLoginRequiredMixin(ConditionalLoginRequiredMixin):
56 def dispatch(self, request, *args, **kwargs):
57 # Attempt to authenticate the user using a DRF token, if provided
58 if settings.LOGIN_REQUIRED and not request.user.is_authenticated:
59 authenticator = TokenAuthentication()
60 try:
61 if (auth_info := authenticator.authenticate(request)) is not None:
62 request.user = auth_info[0] # User object
63 request.auth = auth_info[1]
64 except AuthenticationFailed:
65 return HttpResponseForbidden("Invalid token")
67 return super().dispatch(request, *args, **kwargs)
70class ContentTypePermissionRequiredMixin(ConditionalLoginRequiredMixin):
71 """
72 Similar to Django's built-in PermissionRequiredMixin, but extended to check model-level permission assignments.
73 This is related to ObjectPermissionRequiredMixin, except that it does not enforce object-level permissions,
74 and fits within NetBox's custom permission enforcement system.
76 additional_permissions: An optional iterable of statically declared permissions to evaluate in addition to those
77 derived from the object type
78 """
79 additional_permissions = list()
81 def get_required_permission(self):
82 """
83 Return the specific permission necessary to perform the requested action on an object.
84 """
85 raise NotImplementedError(_("{self.__class__.__name__} must implement get_required_permission()").format(
86 class_name=self.__class__.__name__
87 ))
89 def has_permission(self):
90 user = self.request.user
91 permission_required = self.get_required_permission()
93 # Check that the user has been granted the required permission(s).
94 if user.has_perms((permission_required, *self.additional_permissions)):
95 return True
97 return False
99 def dispatch(self, request, *args, **kwargs):
100 if not self.has_permission():
101 return self.handle_no_permission()
103 return super().dispatch(request, *args, **kwargs)
106class ObjectPermissionRequiredMixin(ConditionalLoginRequiredMixin):
107 """
108 Similar to Django's built-in PermissionRequiredMixin, but extended to check for both model-level and object-level
109 permission assignments. If the user has only object-level permissions assigned, the view's queryset is filtered
110 to return only those objects on which the user is permitted to perform the specified action.
112 additional_permissions: An optional iterable of statically declared permissions to evaluate in addition to those
113 derived from the object type
114 """
115 additional_permissions = list()
117 def get_required_permission(self):
118 """
119 Return the specific permission necessary to perform the requested action on an object.
120 """
121 raise NotImplementedError(_("{class_name} must implement get_required_permission()").format(
122 class_name=self.__class__.__name__
123 ))
125 def has_permission(self):
126 user = self.request.user
127 permission_required = self.get_required_permission()
129 # Check that the user has been granted the required permission(s).
130 if user.has_perms((permission_required, *self.additional_permissions)):
132 # Update the view's QuerySet to filter only the permitted objects
133 action = resolve_permission(permission_required)[1]
134 self.queryset = self.queryset.restrict(user, action)
136 return True
138 return False
140 def dispatch(self, request, *args, **kwargs):
142 if not hasattr(self, 'queryset'):
143 raise ImproperlyConfigured(
144 _(
145 '{class_name} has no queryset defined. ObjectPermissionRequiredMixin may only be used on views '
146 'which define a base queryset'
147 ).format(class_name=self.__class__.__name__)
148 )
150 if not self.has_permission():
151 return self.handle_no_permission()
153 return super().dispatch(request, *args, **kwargs)
156class GetReturnURLMixin:
157 """
158 Provides logic for determining where a user should be redirected after processing a form.
159 """
160 default_return_url = None
162 def get_return_url(self, request, obj=None):
164 # First, see if `return_url` was specified as a query parameter or form data. Use this URL only if it's
165 # considered safe.
166 return_url = request.GET.get('return_url') or request.POST.get('return_url')
167 if return_url and safe_for_redirect(return_url):
168 return return_url
170 # Next, check if the object being modified (if any) has an absolute URL.
171 if obj is not None and obj.pk and hasattr(obj, 'get_absolute_url'):
172 return obj.get_absolute_url()
174 # Fall back to the default URL (if specified) for the view.
175 if self.default_return_url is not None:
176 return reverse(self.default_return_url)
178 # Attempt to dynamically resolve the list view for the object
179 if hasattr(self, 'queryset'):
180 try:
181 return get_action_url(self.queryset.model, action='list')
182 except NoReverseMatch:
183 pass
185 # If all else fails, return home. Ideally this should never happen.
186 return reverse('home')
189class GetRelatedModelsMixin:
190 """
191 Provides logic for collecting all related models for the currently viewed model.
192 """
193 @dataclass
194 class RelatedObjectCount:
195 queryset: QuerySet
196 filter_param: str
197 label: str = ''
199 @property
200 def name(self):
201 return self.label or title(_(self.queryset.model._meta.verbose_name_plural))
203 def get_related_models(self, request, instance, omit=None, extra=None, include_hidden=False):
204 """
205 Get related models of the view's `queryset` model without those listed in `omit`. Will be sorted alphabetical.
207 Args:
208 request: Current request being processed.
209 instance: The instance related models should be looked up for. A list of instances can be passed to match
210 related objects in this list (e.g. to find sites of a region including child regions).
211 omit: Remove relationships to these models from the result. Needs to be passed, if related models don't
212 provide a `_list` view.
213 extra: Add extra models to the list of automatically determined related models. Can be used to add indirect
214 relationships.
215 include_hidden: Also match relationships declared with `related_name='+'`.
216 """
217 omit = omit or []
218 model = self.queryset.model
219 related = filter(
220 lambda m: m[0] is not model and m[0] not in omit,
221 get_related_models(model, ordered=False, include_hidden=include_hidden)
222 )
224 related_models = [
225 self.RelatedObjectCount(
226 model.objects.restrict(request.user, 'view').filter(**(
227 {f'{field}__in': instance}
228 if isinstance(instance, Iterable)
229 else {field: instance}
230 )),
231 f'{field}_id'
232 )
233 for model, field in related
234 ]
235 if extra is not None:
236 related_models.extend([
237 self.RelatedObjectCount(*attrs) for attrs in extra
238 ])
240 return sorted(
241 filter(lambda roc: roc.queryset.exists(), related_models),
242 key=lambda roc: roc.name,
243 )
246class ViewTab:
247 """
248 ViewTabs are used for navigation among multiple object-specific views, such as the changelog or journal for
249 a particular object.
251 Args:
252 label: Human-friendly text
253 visible: A callable which determines whether the tab should be displayed. This callable must accept exactly one
254 argument: the object instance. If a callable is not specified, the tab's visibility will be determined by
255 its badge (if any) and the value of `hide_if_empty`.
256 badge: A static value or callable to display alongside the label (optional). If a callable is used, it must
257 accept a single argument representing the object being viewed.
258 weight: Numeric weight to influence ordering among other tabs (default: 1000)
259 permission: The permission required to display the tab (optional).
260 hide_if_empty: If true, the tab will be displayed only if its badge has a meaningful value. (This parameter is
261 evaluated only if the tab is permitted to be displayed according to the `visible` parameter.)
262 """
263 def __init__(self, label, visible=None, badge=None, weight=1000, permission=None, hide_if_empty=False):
264 self.label = label
265 self.visible = visible
266 self.badge = badge
267 self.weight = weight
268 self.permission = permission
269 self.hide_if_empty = hide_if_empty
271 def render(self, instance):
272 """
273 Return the attributes needed to render a tab in HTML if the tab should be displayed. Otherwise, return None.
274 """
275 if self.visible is not None and not self.visible(instance):
276 return None
277 badge_value = self._get_badge_value(instance)
278 if self.badge and self.hide_if_empty and not badge_value:
279 return None
280 return {
281 'label': self.label,
282 'badge': badge_value,
283 'weight': self.weight,
284 }
286 def _get_badge_value(self, instance):
287 if not self.badge:
288 return None
289 if callable(self.badge):
290 return self.badge(instance)
291 return self.badge
294#
295# Utility functions
296#
298def get_viewname(model, action=None, rest_api=False):
299 """
300 Return the view name for the given model and action, if valid.
302 :param model: The model or instance to which the view applies
303 :param action: A string indicating the desired action (if any); e.g. "add" or "list"
304 :param rest_api: A boolean indicating whether this is a REST API view
305 """
306 is_plugin = isinstance(model._meta.app_config, PluginConfig)
307 app_label = model._meta.app_label
308 model_name = model._meta.model_name
310 if rest_api:
311 viewname = f'{app_label}-api:{model_name}'
312 if is_plugin: 312 ↛ 313line 312 didn't jump to line 313 because the condition on line 312 was never true
313 viewname = f'plugins-api:{viewname}'
314 if action: 314 ↛ 324line 314 didn't jump to line 324 because the condition on line 314 was always true
315 viewname = f'{viewname}-{action}'
317 else:
318 viewname = f'{app_label}:{model_name}'
319 if is_plugin: 319 ↛ 320line 319 didn't jump to line 320 because the condition on line 319 was never true
320 viewname = f'plugins:{viewname}'
321 if action:
322 viewname = f'{viewname}_{action}'
324 return viewname
327def get_action_url(model, action=None, rest_api=False, kwargs=None):
328 """
329 Return the URL for the given model and action, if valid; otherwise raise NoReverseMatch.
330 Will defer to _get_action_url() on the model if it exists.
332 :param model: The model or instance to which the URL belongs
333 :param action: A string indicating the desired action (if any); e.g. "add" or "list"
334 :param rest_api: A boolean indicating whether this is a REST API action
335 :param kwargs: A dictionary of keyword arguments for the view to include when resolving its URL path (optional)
336 """
337 if hasattr(model, '_get_action_url'): 337 ↛ 338line 337 didn't jump to line 338 because the condition on line 337 was never true
338 return model._get_action_url(action, rest_api, kwargs)
340 return reverse(get_viewname(model, action, rest_api), kwargs=kwargs)
343def get_default_template(model):
344 """
345 Return the base template for the given model. If the presumed "{app}/{model}.html" template
346 does not exist, fall back to "generic/object.html".
347 """
348 template_name = f'{model._meta.app_label}/{model._meta.model_name}.html'
349 try:
350 get_template(template_name)
351 return template_name
352 except TemplateDoesNotExist:
353 return 'generic/object.html'
356def register_model_view(model, name='', path=None, detail=True, kwargs=None):
357 """
358 This decorator can be used to "attach" a view to any model in NetBox. This is typically used to inject
359 additional tabs within a model's detail view. For example, to add a custom tab to NetBox's dcim.Site model:
361 @register_model_view(Site, 'myview', path='my-custom-view')
362 class MyView(ObjectView):
363 ...
365 This will automatically create a URL path for MyView at `/dcim/sites/<id>/my-custom-view/` which can be
366 resolved using the view name `dcim:site_myview'.
368 Args:
369 model: The Django model class with which this view will be associated.
370 name: The string used to form the view's name for URL resolution (e.g. via `reverse()`). This will be appended
371 to the name of the base view for the model using an underscore. If blank, the model name will be used.
372 path: The URL path by which the view can be reached (optional). If not provided, `name` will be used.
373 detail: True if the path applied to an individual object; False if it attaches to the base (list) path.
374 kwargs: A dictionary of keyword arguments for the view to include when registering its URL path (optional).
375 """
376 def _wrapper(cls):
377 app_label = model._meta.app_label
378 model_name = model._meta.model_name
380 if model_name not in registry['views'][app_label]:
381 registry['views'][app_label][model_name] = []
383 registry['views'][app_label][model_name].append({
384 'name': name,
385 'view': cls,
386 'path': path if path is not None else name,
387 'detail': detail,
388 'kwargs': kwargs or {},
389 })
391 return cls
393 return _wrapper
396def get_view(model, name=''):
397 """
398 Return the view class registered for a model under the given name, or None if no matching view is registered.
400 Args:
401 model: A model class or instance whose registered view should be returned.
402 name: The name under which the view was registered (see `register_model_view()`). Defaults to the
403 model's base (detail) view.
404 """
405 app_label = model._meta.app_label
406 model_name = model._meta.model_name
407 views = registry['views'].get(app_label, {}).get(model_name, [])
408 return next((v['view'] for v in views if v['name'] == name), None)