views.py 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218
  1. from django.contrib.auth.mixins import AccessMixin
  2. from django.core.exceptions import ImproperlyConfigured
  3. from django.urls import reverse
  4. from django.urls.exceptions import NoReverseMatch
  5. from django.utils.translation import gettext_lazy as _
  6. from netbox.registry import registry
  7. from .permissions import resolve_permission
  8. __all__ = (
  9. 'ContentTypePermissionRequiredMixin',
  10. 'GetReturnURLMixin',
  11. 'ObjectPermissionRequiredMixin',
  12. 'ViewTab',
  13. 'register_model_view',
  14. )
  15. #
  16. # View Mixins
  17. #
  18. class ContentTypePermissionRequiredMixin(AccessMixin):
  19. """
  20. Similar to Django's built-in PermissionRequiredMixin, but extended to check model-level permission assignments.
  21. This is related to ObjectPermissionRequiredMixin, except that is does not enforce object-level permissions,
  22. and fits within NetBox's custom permission enforcement system.
  23. additional_permissions: An optional iterable of statically declared permissions to evaluate in addition to those
  24. derived from the object type
  25. """
  26. additional_permissions = list()
  27. def get_required_permission(self):
  28. """
  29. Return the specific permission necessary to perform the requested action on an object.
  30. """
  31. raise NotImplementedError(_("{self.__class__.__name__} must implement get_required_permission()").format(
  32. class_name=self.__class__.__name__
  33. ))
  34. def has_permission(self):
  35. user = self.request.user
  36. permission_required = self.get_required_permission()
  37. # Check that the user has been granted the required permission(s).
  38. if user.has_perms((permission_required, *self.additional_permissions)):
  39. return True
  40. return False
  41. def dispatch(self, request, *args, **kwargs):
  42. if not self.has_permission():
  43. return self.handle_no_permission()
  44. return super().dispatch(request, *args, **kwargs)
  45. class ObjectPermissionRequiredMixin(AccessMixin):
  46. """
  47. Similar to Django's built-in PermissionRequiredMixin, but extended to check for both model-level and object-level
  48. permission assignments. If the user has only object-level permissions assigned, the view's queryset is filtered
  49. to return only those objects on which the user is permitted to perform the specified action.
  50. additional_permissions: An optional iterable of statically declared permissions to evaluate in addition to those
  51. derived from the object type
  52. """
  53. additional_permissions = list()
  54. def get_required_permission(self):
  55. """
  56. Return the specific permission necessary to perform the requested action on an object.
  57. """
  58. raise NotImplementedError(_("{class_name} must implement get_required_permission()").format(
  59. class_name=self.__class__.__name__
  60. ))
  61. def has_permission(self):
  62. user = self.request.user
  63. permission_required = self.get_required_permission()
  64. # Check that the user has been granted the required permission(s).
  65. if user.has_perms((permission_required, *self.additional_permissions)):
  66. # Update the view's QuerySet to filter only the permitted objects
  67. action = resolve_permission(permission_required)[1]
  68. self.queryset = self.queryset.restrict(user, action)
  69. return True
  70. return False
  71. def dispatch(self, request, *args, **kwargs):
  72. if not hasattr(self, 'queryset'):
  73. raise ImproperlyConfigured(
  74. _(
  75. '{class_name} has no queryset defined. ObjectPermissionRequiredMixin may only be used on views '
  76. 'which define a base queryset'
  77. ).format(class_name=self.__class__.__name__)
  78. )
  79. if not self.has_permission():
  80. return self.handle_no_permission()
  81. return super().dispatch(request, *args, **kwargs)
  82. class GetReturnURLMixin:
  83. """
  84. Provides logic for determining where a user should be redirected after processing a form.
  85. """
  86. default_return_url = None
  87. def get_return_url(self, request, obj=None):
  88. # First, see if `return_url` was specified as a query parameter or form data. Use this URL only if it's
  89. # considered safe.
  90. return_url = request.GET.get('return_url') or request.POST.get('return_url')
  91. if return_url and return_url.startswith('/'):
  92. return return_url
  93. # Next, check if the object being modified (if any) has an absolute URL.
  94. if obj is not None and obj.pk and hasattr(obj, 'get_absolute_url'):
  95. return obj.get_absolute_url()
  96. # Fall back to the default URL (if specified) for the view.
  97. if self.default_return_url is not None:
  98. return reverse(self.default_return_url)
  99. # Attempt to dynamically resolve the list view for the object
  100. if hasattr(self, 'queryset'):
  101. model_opts = self.queryset.model._meta
  102. try:
  103. return reverse(f'{model_opts.app_label}:{model_opts.model_name}_list')
  104. except NoReverseMatch:
  105. pass
  106. # If all else fails, return home. Ideally this should never happen.
  107. return reverse('home')
  108. class ViewTab:
  109. """
  110. ViewTabs are used for navigation among multiple object-specific views, such as the changelog or journal for
  111. a particular object.
  112. Args:
  113. label: Human-friendly text
  114. badge: A static value or callable to display alongside the label (optional). If a callable is used, it must
  115. accept a single argument representing the object being viewed.
  116. weight: Numeric weight to influence ordering among other tabs (default: 1000)
  117. permission: The permission required to display the tab (optional).
  118. hide_if_empty: If true, the tab will be displayed only if its badge has a meaningful value. (Tabs without a
  119. badge are always displayed.)
  120. """
  121. def __init__(self, label, badge=None, weight=1000, permission=None, hide_if_empty=False):
  122. self.label = label
  123. self.badge = badge
  124. self.weight = weight
  125. self.permission = permission
  126. self.hide_if_empty = hide_if_empty
  127. def render(self, instance):
  128. """Return the attributes needed to render a tab in HTML."""
  129. badge_value = self._get_badge_value(instance)
  130. if self.badge and self.hide_if_empty and not badge_value:
  131. return None
  132. return {
  133. 'label': self.label,
  134. 'badge': badge_value,
  135. 'weight': self.weight,
  136. }
  137. def _get_badge_value(self, instance):
  138. if not self.badge:
  139. return None
  140. if callable(self.badge):
  141. return self.badge(instance)
  142. return self.badge
  143. def register_model_view(model, name='', path=None, kwargs=None):
  144. """
  145. This decorator can be used to "attach" a view to any model in NetBox. This is typically used to inject
  146. additional tabs within a model's detail view. For example, to add a custom tab to NetBox's dcim.Site model:
  147. @register_model_view(Site, 'myview', path='my-custom-view')
  148. class MyView(ObjectView):
  149. ...
  150. This will automatically create a URL path for MyView at `/dcim/sites/<id>/my-custom-view/` which can be
  151. resolved using the view name `dcim:site_myview'.
  152. Args:
  153. model: The Django model class with which this view will be associated.
  154. name: The string used to form the view's name for URL resolution (e.g. via `reverse()`). This will be appended
  155. to the name of the base view for the model using an underscore. If blank, the model name will be used.
  156. path: The URL path by which the view can be reached (optional). If not provided, `name` will be used.
  157. kwargs: A dictionary of keyword arguments for the view to include when registering its URL path (optional).
  158. """
  159. def _wrapper(cls):
  160. app_label = model._meta.app_label
  161. model_name = model._meta.model_name
  162. if model_name not in registry['views'][app_label]:
  163. registry['views'][app_label][model_name] = []
  164. registry['views'][app_label][model_name].append({
  165. 'name': name,
  166. 'view': cls,
  167. 'path': path or name,
  168. 'kwargs': kwargs or {},
  169. })
  170. return cls
  171. return _wrapper