views.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441
  1. from django.core.exceptions import ValidationError as DjangoValidationError
  2. from django.http import Http404
  3. from django.shortcuts import get_object_or_404
  4. from django.utils.translation import gettext_lazy as _
  5. from drf_spectacular.utils import OpenApiResponse, OpenApiTypes, extend_schema
  6. from rest_framework import status
  7. from rest_framework.decorators import action
  8. from rest_framework.exceptions import PermissionDenied, ValidationError
  9. from rest_framework.generics import RetrieveUpdateDestroyAPIView
  10. from rest_framework.mixins import CreateModelMixin, ListModelMixin, RetrieveModelMixin, UpdateModelMixin
  11. from rest_framework.renderers import JSONRenderer
  12. from rest_framework.response import Response
  13. from rest_framework.routers import APIRootView
  14. from core.choices import ManagedFileRootPathChoices
  15. from extras import filtersets
  16. from extras.jobs import ScriptJob
  17. from extras.models import *
  18. from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired, TokenWritePermission
  19. from netbox.api.features import SyncedDataMixin
  20. from netbox.api.metadata import ContentTypeMetadata
  21. from netbox.api.renderers import TextRenderer
  22. from netbox.api.viewsets import BaseViewSet, NetBoxModelViewSet
  23. from netbox.api.viewsets.mixins import ObjectValidationMixin
  24. from users.models import Token
  25. from utilities.exceptions import RQWorkerNotRunningException
  26. from utilities.request import copy_safe_request
  27. from utilities.rqworker import any_workers_for_queue
  28. from . import serializers
  29. from .mixins import ConfigTemplateRenderMixin, SharedObjectQuerySetMixin
  30. class ExtrasRootView(APIRootView):
  31. """
  32. Extras API root view
  33. """
  34. def get_view_name(self):
  35. return 'Extras'
  36. #
  37. # EventRules
  38. #
  39. class EventRuleViewSet(NetBoxModelViewSet):
  40. metadata_class = ContentTypeMetadata
  41. queryset = EventRule.objects.all()
  42. serializer_class = serializers.EventRuleSerializer
  43. filterset_class = filtersets.EventRuleFilterSet
  44. #
  45. # Webhooks
  46. #
  47. class WebhookViewSet(NetBoxModelViewSet):
  48. metadata_class = ContentTypeMetadata
  49. queryset = Webhook.objects.all()
  50. serializer_class = serializers.WebhookSerializer
  51. filterset_class = filtersets.WebhookFilterSet
  52. #
  53. # Custom fields
  54. #
  55. class CustomFieldViewSet(NetBoxModelViewSet):
  56. metadata_class = ContentTypeMetadata
  57. queryset = CustomField.objects.select_related('choice_set')
  58. serializer_class = serializers.CustomFieldSerializer
  59. filterset_class = filtersets.CustomFieldFilterSet
  60. class CustomFieldChoiceSetViewSet(NetBoxModelViewSet):
  61. queryset = CustomFieldChoiceSet.objects.all()
  62. serializer_class = serializers.CustomFieldChoiceSetSerializer
  63. filterset_class = filtersets.CustomFieldChoiceSetFilterSet
  64. @action(detail=True)
  65. def choices(self, request, pk):
  66. """
  67. Provides an endpoint to iterate through each choice in a set.
  68. """
  69. choiceset = get_object_or_404(self.queryset, pk=pk)
  70. choices = choiceset.choices
  71. # Enable filtering
  72. if q := request.GET.get('q'):
  73. q = q.lower()
  74. choices = [c for c in choices if q in c[0].lower() or q in c[1].lower()]
  75. # Paginate data
  76. if page := self.paginate_queryset(choices):
  77. data = [
  78. {'id': c[0], 'display': c[1]} for c in page
  79. ]
  80. else:
  81. data = []
  82. return self.get_paginated_response(data)
  83. #
  84. # Custom links
  85. #
  86. class CustomLinkViewSet(NetBoxModelViewSet):
  87. metadata_class = ContentTypeMetadata
  88. queryset = CustomLink.objects.all()
  89. serializer_class = serializers.CustomLinkSerializer
  90. filterset_class = filtersets.CustomLinkFilterSet
  91. #
  92. # Export templates
  93. #
  94. class ExportTemplateViewSet(SyncedDataMixin, NetBoxModelViewSet):
  95. metadata_class = ContentTypeMetadata
  96. queryset = ExportTemplate.objects.all()
  97. serializer_class = serializers.ExportTemplateSerializer
  98. filterset_class = filtersets.ExportTemplateFilterSet
  99. #
  100. # Saved filters
  101. #
  102. class SavedFilterViewSet(SharedObjectQuerySetMixin, NetBoxModelViewSet):
  103. metadata_class = ContentTypeMetadata
  104. queryset = SavedFilter.objects.all()
  105. serializer_class = serializers.SavedFilterSerializer
  106. filterset_class = filtersets.SavedFilterFilterSet
  107. #
  108. # Table Configs
  109. #
  110. class TableConfigViewSet(SharedObjectQuerySetMixin, NetBoxModelViewSet):
  111. metadata_class = ContentTypeMetadata
  112. queryset = TableConfig.objects.all()
  113. serializer_class = serializers.TableConfigSerializer
  114. filterset_class = filtersets.TableConfigFilterSet
  115. #
  116. # Bookmarks
  117. #
  118. class BookmarkViewSet(NetBoxModelViewSet):
  119. metadata_class = ContentTypeMetadata
  120. queryset = Bookmark.objects.all()
  121. serializer_class = serializers.BookmarkSerializer
  122. filterset_class = filtersets.BookmarkFilterSet
  123. #
  124. # Notifications & subscriptions
  125. #
  126. class NotificationViewSet(NetBoxModelViewSet):
  127. metadata_class = ContentTypeMetadata
  128. queryset = Notification.objects.all()
  129. serializer_class = serializers.NotificationSerializer
  130. class NotificationGroupViewSet(NetBoxModelViewSet):
  131. queryset = NotificationGroup.objects.all()
  132. serializer_class = serializers.NotificationGroupSerializer
  133. class SubscriptionViewSet(NetBoxModelViewSet):
  134. metadata_class = ContentTypeMetadata
  135. queryset = Subscription.objects.all()
  136. serializer_class = serializers.SubscriptionSerializer
  137. #
  138. # Tags
  139. #
  140. class TagViewSet(NetBoxModelViewSet):
  141. queryset = Tag.objects.all()
  142. serializer_class = serializers.TagSerializer
  143. filterset_class = filtersets.TagFilterSet
  144. class TaggedItemViewSet(RetrieveModelMixin, ListModelMixin, BaseViewSet):
  145. queryset = TaggedItem.objects.prefetch_related(
  146. 'content_type', 'content_object', 'tag'
  147. ).order_by('tag__weight', 'tag__name')
  148. serializer_class = serializers.TaggedItemSerializer
  149. filterset_class = filtersets.TaggedItemFilterSet
  150. #
  151. # Image attachments
  152. #
  153. class ImageAttachmentViewSet(NetBoxModelViewSet):
  154. metadata_class = ContentTypeMetadata
  155. queryset = ImageAttachment.objects.all()
  156. serializer_class = serializers.ImageAttachmentSerializer
  157. filterset_class = filtersets.ImageAttachmentFilterSet
  158. #
  159. # Journal entries
  160. #
  161. class JournalEntryViewSet(NetBoxModelViewSet):
  162. metadata_class = ContentTypeMetadata
  163. queryset = JournalEntry.objects.all()
  164. serializer_class = serializers.JournalEntrySerializer
  165. filterset_class = filtersets.JournalEntryFilterSet
  166. #
  167. # Config contexts
  168. #
  169. class ConfigContextProfileViewSet(SyncedDataMixin, NetBoxModelViewSet):
  170. queryset = ConfigContextProfile.objects.all()
  171. serializer_class = serializers.ConfigContextProfileSerializer
  172. filterset_class = filtersets.ConfigContextProfileFilterSet
  173. class ConfigContextViewSet(SyncedDataMixin, NetBoxModelViewSet):
  174. queryset = ConfigContext.objects.all()
  175. serializer_class = serializers.ConfigContextSerializer
  176. filterset_class = filtersets.ConfigContextFilterSet
  177. #
  178. # Config templates
  179. #
  180. class ConfigTemplateViewSet(SyncedDataMixin, ConfigTemplateRenderMixin, NetBoxModelViewSet):
  181. queryset = ConfigTemplate.objects.all()
  182. serializer_class = serializers.ConfigTemplateSerializer
  183. filterset_class = filtersets.ConfigTemplateFilterSet
  184. def get_permissions(self):
  185. # For render action, check only token write ability (not model permissions)
  186. if self.action == 'render':
  187. return [TokenWritePermission()]
  188. return super().get_permissions()
  189. @extend_schema(
  190. request=OpenApiTypes.OBJECT,
  191. responses={
  192. 200: OpenApiResponse(
  193. response=serializers.RenderedConfigSerializer,
  194. description=_(
  195. "The rendered config template. When the client requests `text/plain`, the raw "
  196. "rendered content is returned in place of the JSON object."
  197. ),
  198. ),
  199. 500: OpenApiResponse(
  200. response=OpenApiTypes.OBJECT,
  201. description=_("An error occurred while rendering the config template."),
  202. ),
  203. },
  204. )
  205. @action(detail=True, methods=['post'], renderer_classes=[JSONRenderer, TextRenderer])
  206. def render(self, request, pk):
  207. """
  208. Render a ConfigTemplate using the context data provided (if any). The request body should be a
  209. mapping of context variables to make available to the template. If the client requests "text/plain"
  210. data, return the raw rendered content, rather than serialized JSON.
  211. """
  212. # Override restrict() on the default queryset to enforce the render & view actions
  213. self.queryset = self.queryset.model.objects.restrict(request.user, 'render').restrict(request.user, 'view')
  214. configtemplate = self.get_object()
  215. context = request.data
  216. return self.render_configtemplate(request, configtemplate, context)
  217. #
  218. # Scripts
  219. #
  220. class ScriptModuleViewSet(ObjectValidationMixin, CreateModelMixin, UpdateModelMixin, BaseViewSet):
  221. queryset = ScriptModule.objects.filter(file_root=ManagedFileRootPathChoices.SCRIPTS)
  222. serializer_class = serializers.ScriptModuleSerializer
  223. lookup_value_regex = '[^/]+' # Allow dots
  224. def get_object(self):
  225. """
  226. Retrieve a ScriptModule by numeric ID or by file name (e.g. my_script.py).
  227. """
  228. queryset = self.filter_queryset(self.get_queryset())
  229. lookup = self.kwargs.get(self.lookup_url_kwarg or self.lookup_field, '')
  230. # Support lookup by numeric PK or by file_path. Treat all-decimal values as PKs
  231. # to preserve normal detail-route behavior; otherwise resolve the value as a
  232. # script module filename, e.g. "myscript.py".
  233. if lookup.isdecimal():
  234. obj = get_object_or_404(queryset, pk=int(lookup))
  235. else:
  236. obj = get_object_or_404(queryset, file_path=lookup)
  237. self.check_object_permissions(self.request, obj)
  238. return obj
  239. class ScriptViewSet(ListModelMixin, RetrieveModelMixin, BaseViewSet):
  240. # Individual scripts are created, modified, and deleted through their module (see ScriptModuleViewSet),
  241. # so the standard write actions are intentionally omitted here. Only listing/retrieving a script (GET)
  242. # and running one (POST to the detail route) are supported.
  243. permission_classes = [IsAuthenticatedOrLoginNotRequired]
  244. queryset = Script.objects.all()
  245. serializer_class = serializers.ScriptSerializer
  246. filterset_class = filtersets.ScriptFilterSet
  247. lookup_value_regex = '[^/]+' # Allow dots
  248. def get_serializer(self, *args, **kwargs):
  249. # A POST to the detail route runs the script, taking ScriptInputSerializer as its request body.
  250. # (This is keyed on the request method rather than on self.action, which is unset when generating
  251. # OPTIONS metadata.) ScriptInputSerializer is instantiated directly rather than via BaseViewSet,
  252. # which would pass it the fields/omit kwargs supported only by BaseModelSerializer.
  253. if getattr(self.request, 'method', None) == 'POST':
  254. kwargs.setdefault('context', self.get_serializer_context())
  255. return serializers.ScriptInputSerializer(*args, **kwargs)
  256. return super().get_serializer(*args, **kwargs)
  257. def get_serializer_context(self):
  258. context = super().get_serializer_context()
  259. # ScriptInputSerializer resolves its field defaults and validates scheduling against the script
  260. # being run (set by run() below).
  261. context['script'] = getattr(self, 'script', None)
  262. return context
  263. def _get_script(self, pk):
  264. # Retrieve the script by ID if the PK is all decimal digits. (isdecimal() rather than isnumeric(),
  265. # as the latter also matches characters which cannot be cast to an integer.)
  266. if pk.isdecimal():
  267. try:
  268. pk = int(pk)
  269. except ValueError:
  270. raise Http404
  271. return get_object_or_404(self.queryset, pk=pk)
  272. # Default to retrieval by module & name
  273. try:
  274. module_name, script_name = pk.split('.', maxsplit=1)
  275. except ValueError:
  276. raise Http404
  277. return get_object_or_404(self.queryset, module__file_path=f'{module_name}.py', name=script_name)
  278. def retrieve(self, request, pk, **kwargs):
  279. script = self._get_script(pk)
  280. serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
  281. return Response(serializer.data)
  282. @extend_schema(
  283. operation_id='extras_scripts_run',
  284. request=serializers.ScriptInputSerializer,
  285. responses={
  286. 200: OpenApiResponse(
  287. response=serializers.ScriptDetailSerializer,
  288. description=_("The script has been enqueued for execution."),
  289. ),
  290. },
  291. )
  292. def run(self, request, pk, **kwargs):
  293. """
  294. Run a Script identified by its numeric PK or module & name and return the pending Job as the result
  295. """
  296. # Bound to POST on the detail route by ScriptRouter
  297. # Reject read-only tokens before resolving the script, so that an insufficient token is always
  298. # reported as such. (Not via TokenWritePermission, which permits token auth only.)
  299. if isinstance(request.auth, Token) and not request.auth.write_enabled:
  300. raise PermissionDenied(_("This token does not permit write operations (running a script)."))
  301. # An unauthenticated user can never run a script; report that explicitly, as restrict() below would
  302. # match no scripts and yield a misleading 404.
  303. if not request.user.is_authenticated:
  304. raise PermissionDenied(_("This user does not have permission to run this script."))
  305. # Running a script is a 'run' operation (not the 'add' that BaseViewSet maps to POST), so restrict
  306. # the QuerySet on 'run' before resolving the script. A script the user cannot run yields a 404.
  307. self.queryset = self.queryset.model.objects.restrict(request.user, 'run')
  308. self.script = script = self._get_script(pk)
  309. # A script whose Python class cannot be resolved (e.g. its module has been modified or the script has
  310. # been deleted, retaining the record for its jobs) cannot be run
  311. if not script.is_executable or script.python_class is None:
  312. raise ValidationError(_("This script is not currently executable."))
  313. input_serializer = self.get_serializer(data=request.data)
  314. # Check that at least one RQ worker is running
  315. if not any_workers_for_queue('default'):
  316. raise RQWorkerNotRunningException()
  317. if input_serializer.is_valid():
  318. try:
  319. ScriptJob.enqueue(
  320. instance=script,
  321. user=request.user,
  322. data=input_serializer.data['data'],
  323. request=copy_safe_request(request),
  324. commit=input_serializer.data['commit'],
  325. job_timeout=script.python_class.job_timeout,
  326. schedule_at=input_serializer.validated_data.get('schedule_at'),
  327. interval=input_serializer.validated_data.get('interval'),
  328. notifications=input_serializer.validated_data.get('notifications'),
  329. )
  330. except DjangoValidationError as e:
  331. # The script's execution configuration is invalid (see #22872). Surface it as a 400 rather than
  332. # allowing the exception to bubble up as an HTTP 500. These are script-level config errors, not
  333. # request-field errors, so report them under the non-field "detail" key.
  334. raise ValidationError({'detail': e.messages}) from e
  335. serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
  336. return Response(serializer.data)
  337. return Response(input_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
  338. #
  339. # User dashboard
  340. #
  341. class DashboardView(RetrieveUpdateDestroyAPIView):
  342. queryset = Dashboard.objects.all()
  343. serializer_class = serializers.DashboardSerializer
  344. def get_object(self):
  345. return Dashboard.objects.filter(user=self.request.user).first()