| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320 |
- from typing import Iterable
- from django.conf import settings
- from django.contrib.auth.mixins import AccessMixin
- from django.core.exceptions import ImproperlyConfigured
- from django.urls import reverse
- from django.urls.exceptions import NoReverseMatch
- from django.utils.http import url_has_allowed_host_and_scheme
- from django.utils.translation import gettext_lazy as _
- from netbox.plugins import PluginConfig
- from netbox.registry import registry
- from utilities.relations import get_related_models
- from .permissions import resolve_permission
- __all__ = (
- 'ConditionalLoginRequiredMixin',
- 'ContentTypePermissionRequiredMixin',
- 'GetRelatedModelsMixin',
- 'GetReturnURLMixin',
- 'ObjectPermissionRequiredMixin',
- 'ViewTab',
- 'get_viewname',
- 'register_model_view',
- )
- #
- # View Mixins
- #
- class ConditionalLoginRequiredMixin(AccessMixin):
- """
- Similar to Django's LoginRequiredMixin, but enforces authentication only if LOGIN_REQUIRED is True.
- """
- def dispatch(self, request, *args, **kwargs):
- if settings.LOGIN_REQUIRED and not request.user.is_authenticated:
- return self.handle_no_permission()
- return super().dispatch(request, *args, **kwargs)
- class ContentTypePermissionRequiredMixin(ConditionalLoginRequiredMixin):
- """
- Similar to Django's built-in PermissionRequiredMixin, but extended to check model-level permission assignments.
- This is related to ObjectPermissionRequiredMixin, except that it does not enforce object-level permissions,
- and fits within NetBox's custom permission enforcement system.
- additional_permissions: An optional iterable of statically declared permissions to evaluate in addition to those
- derived from the object type
- """
- additional_permissions = list()
- def get_required_permission(self):
- """
- Return the specific permission necessary to perform the requested action on an object.
- """
- raise NotImplementedError(_("{self.__class__.__name__} must implement get_required_permission()").format(
- class_name=self.__class__.__name__
- ))
- def has_permission(self):
- user = self.request.user
- permission_required = self.get_required_permission()
- # Check that the user has been granted the required permission(s).
- if user.has_perms((permission_required, *self.additional_permissions)):
- return True
- return False
- def dispatch(self, request, *args, **kwargs):
- if not self.has_permission():
- return self.handle_no_permission()
- return super().dispatch(request, *args, **kwargs)
- class ObjectPermissionRequiredMixin(ConditionalLoginRequiredMixin):
- """
- Similar to Django's built-in PermissionRequiredMixin, but extended to check for both model-level and object-level
- permission assignments. If the user has only object-level permissions assigned, the view's queryset is filtered
- to return only those objects on which the user is permitted to perform the specified action.
- additional_permissions: An optional iterable of statically declared permissions to evaluate in addition to those
- derived from the object type
- """
- additional_permissions = list()
- def get_required_permission(self):
- """
- Return the specific permission necessary to perform the requested action on an object.
- """
- raise NotImplementedError(_("{class_name} must implement get_required_permission()").format(
- class_name=self.__class__.__name__
- ))
- def has_permission(self):
- user = self.request.user
- permission_required = self.get_required_permission()
- # Check that the user has been granted the required permission(s).
- if user.has_perms((permission_required, *self.additional_permissions)):
- # Update the view's QuerySet to filter only the permitted objects
- action = resolve_permission(permission_required)[1]
- self.queryset = self.queryset.restrict(user, action)
- return True
- return False
- def dispatch(self, request, *args, **kwargs):
- if not hasattr(self, 'queryset'):
- raise ImproperlyConfigured(
- _(
- '{class_name} has no queryset defined. ObjectPermissionRequiredMixin may only be used on views '
- 'which define a base queryset'
- ).format(class_name=self.__class__.__name__)
- )
- if not self.has_permission():
- return self.handle_no_permission()
- return super().dispatch(request, *args, **kwargs)
- class GetReturnURLMixin:
- """
- Provides logic for determining where a user should be redirected after processing a form.
- """
- default_return_url = None
- def get_return_url(self, request, obj=None):
- # First, see if `return_url` was specified as a query parameter or form data. Use this URL only if it's
- # considered safe.
- return_url = request.GET.get('return_url') or request.POST.get('return_url')
- if return_url and url_has_allowed_host_and_scheme(return_url, allowed_hosts=None):
- return return_url
- # Next, check if the object being modified (if any) has an absolute URL.
- if obj is not None and obj.pk and hasattr(obj, 'get_absolute_url'):
- return obj.get_absolute_url()
- # Fall back to the default URL (if specified) for the view.
- if self.default_return_url is not None:
- return reverse(self.default_return_url)
- # Attempt to dynamically resolve the list view for the object
- if hasattr(self, 'queryset'):
- model_opts = self.queryset.model._meta
- try:
- return reverse(f'{model_opts.app_label}:{model_opts.model_name}_list')
- except NoReverseMatch:
- pass
- # If all else fails, return home. Ideally this should never happen.
- return reverse('home')
- class GetRelatedModelsMixin:
- """
- Provides logic for collecting all related models for the currently viewed model.
- """
- def get_related_models(self, request, instance, omit=[], extra=[]):
- """
- Get related models of the view's `queryset` model without those listed in `omit`. Will be sorted alphabetical.
- Args:
- request: Current request being processed.
- instance: The instance related models should be looked up for. A list of instances can be passed to match
- related objects in this list (e.g. to find sites of a region including child regions).
- omit: Remove relationships to these models from the result. Needs to be passed, if related models don't
- provide a `_list` view.
- extra: Add extra models to the list of automatically determined related models. Can be used to add indirect
- relationships.
- """
- model = self.queryset.model
- related = filter(
- lambda m: m[0] is not model and m[0] not in omit,
- get_related_models(model, False)
- )
- related_models = [
- (
- model.objects.restrict(request.user, 'view').filter(**(
- {f'{field}__in': instance}
- if isinstance(instance, Iterable)
- else {field: instance}
- )),
- f'{field}_id'
- )
- for model, field in related
- ]
- related_models.extend(extra)
- return sorted(related_models, key=lambda x: x[0].model._meta.verbose_name.lower())
- class ViewTab:
- """
- ViewTabs are used for navigation among multiple object-specific views, such as the changelog or journal for
- a particular object.
- Args:
- label: Human-friendly text
- visible: A callable which determines whether the tab should be displayed. This callable must accept exactly one
- argument: the object instance. If a callable is not specified, the tab's visibility will be determined by
- its badge (if any) and the value of `hide_if_empty`.
- badge: A static value or callable to display alongside the label (optional). If a callable is used, it must
- accept a single argument representing the object being viewed.
- weight: Numeric weight to influence ordering among other tabs (default: 1000)
- permission: The permission required to display the tab (optional).
- hide_if_empty: If true, the tab will be displayed only if its badge has a meaningful value. (This parameter is
- evaluated only if the tab is permitted to be displayed according to the `visible` parameter.)
- """
- def __init__(self, label, visible=None, badge=None, weight=1000, permission=None, hide_if_empty=False):
- self.label = label
- self.visible = visible
- self.badge = badge
- self.weight = weight
- self.permission = permission
- self.hide_if_empty = hide_if_empty
- def render(self, instance):
- """
- Return the attributes needed to render a tab in HTML if the tab should be displayed. Otherwise, return None.
- """
- if self.visible is not None and not self.visible(instance):
- return None
- badge_value = self._get_badge_value(instance)
- if self.badge and self.hide_if_empty and not badge_value:
- return None
- return {
- 'label': self.label,
- 'badge': badge_value,
- 'weight': self.weight,
- }
- def _get_badge_value(self, instance):
- if not self.badge:
- return None
- if callable(self.badge):
- return self.badge(instance)
- return self.badge
- #
- # Utility functions
- #
- def get_viewname(model, action=None, rest_api=False):
- """
- Return the view name for the given model and action, if valid.
- :param model: The model or instance to which the view applies
- :param action: A string indicating the desired action (if any); e.g. "add" or "list"
- :param rest_api: A boolean indicating whether this is a REST API view
- """
- is_plugin = isinstance(model._meta.app_config, PluginConfig)
- app_label = model._meta.app_label
- model_name = model._meta.model_name
- if rest_api:
- viewname = f'{app_label}-api:{model_name}'
- if is_plugin:
- viewname = f'plugins-api:{viewname}'
- if action:
- viewname = f'{viewname}-{action}'
- else:
- viewname = f'{app_label}:{model_name}'
- if is_plugin:
- viewname = f'plugins:{viewname}'
- if action:
- viewname = f'{viewname}_{action}'
- return viewname
- def register_model_view(model, name='', path=None, detail=True, kwargs=None):
- """
- This decorator can be used to "attach" a view to any model in NetBox. This is typically used to inject
- additional tabs within a model's detail view. For example, to add a custom tab to NetBox's dcim.Site model:
- @register_model_view(Site, 'myview', path='my-custom-view')
- class MyView(ObjectView):
- ...
- This will automatically create a URL path for MyView at `/dcim/sites/<id>/my-custom-view/` which can be
- resolved using the view name `dcim:site_myview'.
- Args:
- model: The Django model class with which this view will be associated.
- name: The string used to form the view's name for URL resolution (e.g. via `reverse()`). This will be appended
- to the name of the base view for the model using an underscore. If blank, the model name will be used.
- path: The URL path by which the view can be reached (optional). If not provided, `name` will be used.
- detail: True if the path applied to an individual object; False if it attaches to the base (list) path.
- kwargs: A dictionary of keyword arguments for the view to include when registering its URL path (optional).
- """
- def _wrapper(cls):
- app_label = model._meta.app_label
- model_name = model._meta.model_name
- if model_name not in registry['views'][app_label]:
- registry['views'][app_label][model_name] = []
- registry['views'][app_label][model_name].append({
- 'name': name,
- 'view': cls,
- 'path': path if path is not None else name,
- 'detail': detail,
- 'kwargs': kwargs or {},
- })
- return cls
- return _wrapper
|