views.py 17 KB

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