views.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370
  1. from dataclasses import dataclass
  2. from typing import Iterable
  3. from django.conf import settings
  4. from django.contrib.auth.mixins import AccessMixin
  5. from django.core.exceptions import ImproperlyConfigured
  6. from django.db.models import QuerySet
  7. from django.urls import reverse
  8. from django.urls.exceptions import NoReverseMatch
  9. from django.utils.translation import gettext_lazy as _
  10. from netbox.api.authentication import TokenAuthentication
  11. from netbox.plugins import PluginConfig
  12. from netbox.registry import registry
  13. from utilities.relations import get_related_models
  14. from utilities.request import safe_for_redirect
  15. from utilities.string import title
  16. from .permissions import resolve_permission
  17. __all__ = (
  18. 'ConditionalLoginRequiredMixin',
  19. 'ContentTypePermissionRequiredMixin',
  20. 'GetRelatedModelsMixin',
  21. 'GetReturnURLMixin',
  22. 'ObjectPermissionRequiredMixin',
  23. 'TokenConditionalLoginRequiredMixin',
  24. 'ViewTab',
  25. 'get_action_url',
  26. 'get_viewname',
  27. 'register_model_view',
  28. )
  29. #
  30. # View Mixins
  31. #
  32. class ConditionalLoginRequiredMixin(AccessMixin):
  33. """
  34. Similar to Django's LoginRequiredMixin, but enforces authentication only if LOGIN_REQUIRED is True.
  35. """
  36. def dispatch(self, request, *args, **kwargs):
  37. if settings.LOGIN_REQUIRED and not request.user.is_authenticated:
  38. return self.handle_no_permission()
  39. return super().dispatch(request, *args, **kwargs)
  40. class TokenConditionalLoginRequiredMixin(ConditionalLoginRequiredMixin):
  41. def dispatch(self, request, *args, **kwargs):
  42. # Attempt to authenticate the user using a DRF token, if provided
  43. if settings.LOGIN_REQUIRED and not request.user.is_authenticated:
  44. authenticator = TokenAuthentication()
  45. auth_info = authenticator.authenticate(request)
  46. if auth_info is not None:
  47. request.user = auth_info[0] # User object
  48. request.auth = auth_info[1]
  49. return super().dispatch(request, *args, **kwargs)
  50. class ContentTypePermissionRequiredMixin(ConditionalLoginRequiredMixin):
  51. """
  52. Similar to Django's built-in PermissionRequiredMixin, but extended to check model-level permission assignments.
  53. This is related to ObjectPermissionRequiredMixin, except that it does not enforce object-level permissions,
  54. and fits within NetBox's custom permission enforcement system.
  55. additional_permissions: An optional iterable of statically declared permissions to evaluate in addition to those
  56. derived from the object type
  57. """
  58. additional_permissions = list()
  59. def get_required_permission(self):
  60. """
  61. Return the specific permission necessary to perform the requested action on an object.
  62. """
  63. raise NotImplementedError(_("{self.__class__.__name__} must implement get_required_permission()").format(
  64. class_name=self.__class__.__name__
  65. ))
  66. def has_permission(self):
  67. user = self.request.user
  68. permission_required = self.get_required_permission()
  69. # Check that the user has been granted the required permission(s).
  70. if user.has_perms((permission_required, *self.additional_permissions)):
  71. return True
  72. return False
  73. def dispatch(self, request, *args, **kwargs):
  74. if not self.has_permission():
  75. return self.handle_no_permission()
  76. return super().dispatch(request, *args, **kwargs)
  77. class ObjectPermissionRequiredMixin(ConditionalLoginRequiredMixin):
  78. """
  79. Similar to Django's built-in PermissionRequiredMixin, but extended to check for both model-level and object-level
  80. permission assignments. If the user has only object-level permissions assigned, the view's queryset is filtered
  81. to return only those objects on which the user is permitted to perform the specified action.
  82. additional_permissions: An optional iterable of statically declared permissions to evaluate in addition to those
  83. derived from the object type
  84. """
  85. additional_permissions = list()
  86. def get_required_permission(self):
  87. """
  88. Return the specific permission necessary to perform the requested action on an object.
  89. """
  90. raise NotImplementedError(_("{class_name} must implement get_required_permission()").format(
  91. class_name=self.__class__.__name__
  92. ))
  93. def has_permission(self):
  94. user = self.request.user
  95. permission_required = self.get_required_permission()
  96. # Check that the user has been granted the required permission(s).
  97. if user.has_perms((permission_required, *self.additional_permissions)):
  98. # Update the view's QuerySet to filter only the permitted objects
  99. action = resolve_permission(permission_required)[1]
  100. self.queryset = self.queryset.restrict(user, action)
  101. return True
  102. return False
  103. def dispatch(self, request, *args, **kwargs):
  104. if not hasattr(self, 'queryset'):
  105. raise ImproperlyConfigured(
  106. _(
  107. '{class_name} has no queryset defined. ObjectPermissionRequiredMixin may only be used on views '
  108. 'which define a base queryset'
  109. ).format(class_name=self.__class__.__name__)
  110. )
  111. if not self.has_permission():
  112. return self.handle_no_permission()
  113. return super().dispatch(request, *args, **kwargs)
  114. class GetReturnURLMixin:
  115. """
  116. Provides logic for determining where a user should be redirected after processing a form.
  117. """
  118. default_return_url = None
  119. def get_return_url(self, request, obj=None):
  120. # First, see if `return_url` was specified as a query parameter or form data. Use this URL only if it's
  121. # considered safe.
  122. return_url = request.GET.get('return_url') or request.POST.get('return_url')
  123. if return_url and safe_for_redirect(return_url):
  124. return return_url
  125. # Next, check if the object being modified (if any) has an absolute URL.
  126. if obj is not None and obj.pk and hasattr(obj, 'get_absolute_url'):
  127. return obj.get_absolute_url()
  128. # Fall back to the default URL (if specified) for the view.
  129. if self.default_return_url is not None:
  130. return reverse(self.default_return_url)
  131. # Attempt to dynamically resolve the list view for the object
  132. if hasattr(self, 'queryset'):
  133. try:
  134. return get_action_url(self.queryset.model, action='list')
  135. except NoReverseMatch:
  136. pass
  137. # If all else fails, return home. Ideally this should never happen.
  138. return reverse('home')
  139. class GetRelatedModelsMixin:
  140. """
  141. Provides logic for collecting all related models for the currently viewed model.
  142. """
  143. @dataclass
  144. class RelatedObjectCount:
  145. queryset: QuerySet
  146. filter_param: str
  147. label: str = ''
  148. @property
  149. def name(self):
  150. return self.label or title(_(self.queryset.model._meta.verbose_name_plural))
  151. def get_related_models(self, request, instance, omit=None, extra=None):
  152. """
  153. Get related models of the view's `queryset` model without those listed in `omit`. Will be sorted alphabetical.
  154. Args:
  155. request: Current request being processed.
  156. instance: The instance related models should be looked up for. A list of instances can be passed to match
  157. related objects in this list (e.g. to find sites of a region including child regions).
  158. omit: Remove relationships to these models from the result. Needs to be passed, if related models don't
  159. provide a `_list` view.
  160. extra: Add extra models to the list of automatically determined related models. Can be used to add indirect
  161. relationships.
  162. """
  163. omit = omit or []
  164. model = self.queryset.model
  165. related = filter(
  166. lambda m: m[0] is not model and m[0] not in omit,
  167. get_related_models(model, False)
  168. )
  169. related_models = [
  170. self.RelatedObjectCount(
  171. model.objects.restrict(request.user, 'view').filter(**(
  172. {f'{field}__in': instance}
  173. if isinstance(instance, Iterable)
  174. else {field: instance}
  175. )),
  176. f'{field}_id'
  177. )
  178. for model, field in related
  179. ]
  180. if extra is not None:
  181. related_models.extend([
  182. self.RelatedObjectCount(*attrs) for attrs in extra
  183. ])
  184. return sorted(
  185. filter(lambda roc: roc.queryset.exists(), related_models),
  186. key=lambda roc: roc.name,
  187. )
  188. class ViewTab:
  189. """
  190. ViewTabs are used for navigation among multiple object-specific views, such as the changelog or journal for
  191. a particular object.
  192. Args:
  193. label: Human-friendly text
  194. visible: A callable which determines whether the tab should be displayed. This callable must accept exactly one
  195. argument: the object instance. If a callable is not specified, the tab's visibility will be determined by
  196. its badge (if any) and the value of `hide_if_empty`.
  197. badge: A static value or callable to display alongside the label (optional). If a callable is used, it must
  198. accept a single argument representing the object being viewed.
  199. weight: Numeric weight to influence ordering among other tabs (default: 1000)
  200. permission: The permission required to display the tab (optional).
  201. hide_if_empty: If true, the tab will be displayed only if its badge has a meaningful value. (This parameter is
  202. evaluated only if the tab is permitted to be displayed according to the `visible` parameter.)
  203. """
  204. def __init__(self, label, visible=None, badge=None, weight=1000, permission=None, hide_if_empty=False):
  205. self.label = label
  206. self.visible = visible
  207. self.badge = badge
  208. self.weight = weight
  209. self.permission = permission
  210. self.hide_if_empty = hide_if_empty
  211. def render(self, instance):
  212. """
  213. Return the attributes needed to render a tab in HTML if the tab should be displayed. Otherwise, return None.
  214. """
  215. if self.visible is not None and not self.visible(instance):
  216. return None
  217. badge_value = self._get_badge_value(instance)
  218. if self.badge and self.hide_if_empty and not badge_value:
  219. return None
  220. return {
  221. 'label': self.label,
  222. 'badge': badge_value,
  223. 'weight': self.weight,
  224. }
  225. def _get_badge_value(self, instance):
  226. if not self.badge:
  227. return None
  228. if callable(self.badge):
  229. return self.badge(instance)
  230. return self.badge
  231. #
  232. # Utility functions
  233. #
  234. def get_viewname(model, action=None, rest_api=False):
  235. """
  236. Return the view name for the given model and action, if valid.
  237. :param model: The model or instance to which the view applies
  238. :param action: A string indicating the desired action (if any); e.g. "add" or "list"
  239. :param rest_api: A boolean indicating whether this is a REST API view
  240. """
  241. is_plugin = isinstance(model._meta.app_config, PluginConfig)
  242. app_label = model._meta.app_label
  243. model_name = model._meta.model_name
  244. if rest_api:
  245. viewname = f'{app_label}-api:{model_name}'
  246. if is_plugin:
  247. viewname = f'plugins-api:{viewname}'
  248. if action:
  249. viewname = f'{viewname}-{action}'
  250. else:
  251. viewname = f'{app_label}:{model_name}'
  252. if is_plugin:
  253. viewname = f'plugins:{viewname}'
  254. if action:
  255. viewname = f'{viewname}_{action}'
  256. return viewname
  257. def get_action_url(model, action=None, rest_api=False, kwargs=None):
  258. """
  259. Return the URL for the given model and action, if valid; otherwise raise NoReverseMatch.
  260. Will defer to _get_action_url() on the model if it exists.
  261. :param model: The model or instance to which the URL belongs
  262. :param action: A string indicating the desired action (if any); e.g. "add" or "list"
  263. :param rest_api: A boolean indicating whether this is a REST API action
  264. :param kwargs: A dictionary of keyword arguments for the view to include when resolving its URL path (optional)
  265. """
  266. if hasattr(model, '_get_action_url'):
  267. return model._get_action_url(action, rest_api, kwargs)
  268. return reverse(get_viewname(model, action, rest_api), kwargs=kwargs)
  269. def register_model_view(model, name='', path=None, detail=True, kwargs=None):
  270. """
  271. This decorator can be used to "attach" a view to any model in NetBox. This is typically used to inject
  272. additional tabs within a model's detail view. For example, to add a custom tab to NetBox's dcim.Site model:
  273. @register_model_view(Site, 'myview', path='my-custom-view')
  274. class MyView(ObjectView):
  275. ...
  276. This will automatically create a URL path for MyView at `/dcim/sites/<id>/my-custom-view/` which can be
  277. resolved using the view name `dcim:site_myview'.
  278. Args:
  279. model: The Django model class with which this view will be associated.
  280. name: The string used to form the view's name for URL resolution (e.g. via `reverse()`). This will be appended
  281. to the name of the base view for the model using an underscore. If blank, the model name will be used.
  282. path: The URL path by which the view can be reached (optional). If not provided, `name` will be used.
  283. detail: True if the path applied to an individual object; False if it attaches to the base (list) path.
  284. kwargs: A dictionary of keyword arguments for the view to include when registering its URL path (optional).
  285. """
  286. def _wrapper(cls):
  287. app_label = model._meta.app_label
  288. model_name = model._meta.model_name
  289. if model_name not in registry['views'][app_label]:
  290. registry['views'][app_label][model_name] = []
  291. registry['views'][app_label][model_name].append({
  292. 'name': name,
  293. 'view': cls,
  294. 'path': path if path is not None else name,
  295. 'detail': detail,
  296. 'kwargs': kwargs or {},
  297. })
  298. return cls
  299. return _wrapper