views.py 8.0 KB

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