Coverage for extras/api/views.py: 76%

221 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-10 18:35 +0000

1from django.core.exceptions import NON_FIELD_ERRORS 

2from django.core.exceptions import ValidationError as DjangoValidationError 

3from django.http import Http404 

4from django.shortcuts import get_object_or_404 

5from django.utils.translation import gettext_lazy as _ 

6from drf_spectacular.utils import OpenApiResponse, OpenApiTypes, extend_schema 

7from rest_framework.decorators import action 

8from rest_framework.exceptions import PermissionDenied, ValidationError 

9from rest_framework.generics import RetrieveUpdateDestroyAPIView 

10from rest_framework.mixins import CreateModelMixin, ListModelMixin, RetrieveModelMixin, UpdateModelMixin 

11from rest_framework.renderers import JSONRenderer 

12from rest_framework.response import Response 

13from rest_framework.routers import APIRootView 

14 

15from core.choices import ManagedFileRootPathChoices 

16from extras import filtersets 

17from extras.jobs import ScriptJob 

18from extras.models import * 

19from extras.scripts import EXEC_PARAM_FIELDS, prepare_script_form 

20from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired, TokenWritePermission 

21from netbox.api.features import SyncedDataMixin 

22from netbox.api.metadata import ContentTypeMetadata 

23from netbox.api.renderers import TextRenderer 

24from netbox.api.viewsets import BaseViewSet, NetBoxModelViewSet 

25from netbox.api.viewsets.mixins import ObjectValidationMixin 

26from users.models import Token 

27from utilities.exceptions import RQWorkerNotRunningException 

28from utilities.request import copy_safe_request 

29from utilities.rqworker import any_workers_for_queue 

30 

31from . import serializers 

32from .mixins import ConfigTemplateRenderMixin, SharedObjectQuerySetMixin 

33 

34 

35class ExtrasRootView(APIRootView): 

36 """ 

37 Extras API root view 

38 """ 

39 def get_view_name(self): 

40 return 'Extras' 

41 

42 

43# 

44# EventRules 

45# 

46 

47class EventRuleViewSet(NetBoxModelViewSet): 

48 metadata_class = ContentTypeMetadata 

49 queryset = EventRule.objects.all() 

50 serializer_class = serializers.EventRuleSerializer 

51 filterset_class = filtersets.EventRuleFilterSet 

52 

53 

54# 

55# Webhooks 

56# 

57 

58class WebhookViewSet(NetBoxModelViewSet): 

59 metadata_class = ContentTypeMetadata 

60 queryset = Webhook.objects.all() 

61 serializer_class = serializers.WebhookSerializer 

62 filterset_class = filtersets.WebhookFilterSet 

63 

64 

65# 

66# Custom fields 

67# 

68 

69class CustomFieldViewSet(NetBoxModelViewSet): 

70 metadata_class = ContentTypeMetadata 

71 queryset = CustomField.objects.select_related('choice_set') 

72 serializer_class = serializers.CustomFieldSerializer 

73 filterset_class = filtersets.CustomFieldFilterSet 

74 

75 

76class CustomFieldChoiceSetViewSet(NetBoxModelViewSet): 

77 queryset = CustomFieldChoiceSet.objects.all() 

78 serializer_class = serializers.CustomFieldChoiceSetSerializer 

79 filterset_class = filtersets.CustomFieldChoiceSetFilterSet 

80 

81 @action(detail=True) 

82 def choices(self, request, pk): 

83 """ 

84 Provides an endpoint to iterate through each choice in a set. 

85 """ 

86 choiceset = get_object_or_404(self.queryset, pk=pk) 

87 choices = choiceset.choices 

88 

89 # Enable filtering 

90 if q := request.GET.get('q'): 

91 q = q.lower() 

92 choices = [c for c in choices if q in c[0].lower() or q in c[1].lower()] 

93 

94 # Paginate data 

95 if page := self.paginate_queryset(choices): 

96 data = [ 

97 {'id': c[0], 'display': c[1]} for c in page 

98 ] 

99 else: 

100 data = [] 

101 

102 return self.get_paginated_response(data) 

103 

104 

105# 

106# Custom links 

107# 

108 

109class CustomLinkViewSet(NetBoxModelViewSet): 

110 metadata_class = ContentTypeMetadata 

111 queryset = CustomLink.objects.all() 

112 serializer_class = serializers.CustomLinkSerializer 

113 filterset_class = filtersets.CustomLinkFilterSet 

114 

115 

116# 

117# Export templates 

118# 

119 

120class ExportTemplateViewSet(SyncedDataMixin, NetBoxModelViewSet): 

121 metadata_class = ContentTypeMetadata 

122 queryset = ExportTemplate.objects.all() 

123 serializer_class = serializers.ExportTemplateSerializer 

124 filterset_class = filtersets.ExportTemplateFilterSet 

125 

126 

127# 

128# Saved filters 

129# 

130 

131class SavedFilterViewSet(SharedObjectQuerySetMixin, NetBoxModelViewSet): 

132 metadata_class = ContentTypeMetadata 

133 queryset = SavedFilter.objects.all() 

134 serializer_class = serializers.SavedFilterSerializer 

135 filterset_class = filtersets.SavedFilterFilterSet 

136 

137 

138# 

139# Table Configs 

140# 

141 

142class TableConfigViewSet(SharedObjectQuerySetMixin, NetBoxModelViewSet): 

143 metadata_class = ContentTypeMetadata 

144 queryset = TableConfig.objects.all() 

145 serializer_class = serializers.TableConfigSerializer 

146 filterset_class = filtersets.TableConfigFilterSet 

147 

148 

149# 

150# Bookmarks 

151# 

152 

153class BookmarkViewSet(NetBoxModelViewSet): 

154 metadata_class = ContentTypeMetadata 

155 queryset = Bookmark.objects.all() 

156 serializer_class = serializers.BookmarkSerializer 

157 filterset_class = filtersets.BookmarkFilterSet 

158 

159 

160# 

161# Notifications & subscriptions 

162# 

163 

164class NotificationViewSet(NetBoxModelViewSet): 

165 metadata_class = ContentTypeMetadata 

166 queryset = Notification.objects.all() 

167 serializer_class = serializers.NotificationSerializer 

168 

169 

170class NotificationGroupViewSet(NetBoxModelViewSet): 

171 queryset = NotificationGroup.objects.all() 

172 serializer_class = serializers.NotificationGroupSerializer 

173 

174 

175class SubscriptionViewSet(NetBoxModelViewSet): 

176 metadata_class = ContentTypeMetadata 

177 queryset = Subscription.objects.all() 

178 serializer_class = serializers.SubscriptionSerializer 

179 

180 

181# 

182# Tags 

183# 

184 

185class TagViewSet(NetBoxModelViewSet): 

186 queryset = Tag.objects.all() 

187 serializer_class = serializers.TagSerializer 

188 filterset_class = filtersets.TagFilterSet 

189 

190 

191class TaggedItemViewSet(RetrieveModelMixin, ListModelMixin, BaseViewSet): 

192 queryset = TaggedItem.objects.prefetch_related( 

193 'content_type', 'content_object', 'tag' 

194 ).order_by('tag__weight', 'tag__name') 

195 serializer_class = serializers.TaggedItemSerializer 

196 filterset_class = filtersets.TaggedItemFilterSet 

197 

198 

199# 

200# Image attachments 

201# 

202 

203class ImageAttachmentViewSet(NetBoxModelViewSet): 

204 metadata_class = ContentTypeMetadata 

205 queryset = ImageAttachment.objects.all() 

206 serializer_class = serializers.ImageAttachmentSerializer 

207 filterset_class = filtersets.ImageAttachmentFilterSet 

208 

209 

210# 

211# Journal entries 

212# 

213 

214class JournalEntryViewSet(NetBoxModelViewSet): 

215 metadata_class = ContentTypeMetadata 

216 queryset = JournalEntry.objects.all() 

217 serializer_class = serializers.JournalEntrySerializer 

218 filterset_class = filtersets.JournalEntryFilterSet 

219 

220 

221# 

222# Config contexts 

223# 

224 

225class ConfigContextProfileViewSet(SyncedDataMixin, NetBoxModelViewSet): 

226 queryset = ConfigContextProfile.objects.all() 

227 serializer_class = serializers.ConfigContextProfileSerializer 

228 filterset_class = filtersets.ConfigContextProfileFilterSet 

229 

230 

231class ConfigContextViewSet(SyncedDataMixin, NetBoxModelViewSet): 

232 queryset = ConfigContext.objects.all() 

233 serializer_class = serializers.ConfigContextSerializer 

234 filterset_class = filtersets.ConfigContextFilterSet 

235 

236 

237# 

238# Config templates 

239# 

240 

241class ConfigTemplateViewSet(SyncedDataMixin, ConfigTemplateRenderMixin, NetBoxModelViewSet): 

242 queryset = ConfigTemplate.objects.all() 

243 serializer_class = serializers.ConfigTemplateSerializer 

244 filterset_class = filtersets.ConfigTemplateFilterSet 

245 

246 def get_permissions(self): 

247 # For render action, check only token write ability (not model permissions) 

248 if self.action == 'render': 

249 return [TokenWritePermission()] 

250 return super().get_permissions() 

251 

252 @extend_schema( 

253 request=OpenApiTypes.OBJECT, 

254 responses={ 

255 200: OpenApiResponse( 

256 response=serializers.RenderedConfigSerializer, 

257 description=_( 

258 "The rendered config template. When the client requests `text/plain`, the raw " 

259 "rendered content is returned in place of the JSON object." 

260 ), 

261 ), 

262 500: OpenApiResponse( 

263 response=OpenApiTypes.OBJECT, 

264 description=_("An error occurred while rendering the config template."), 

265 ), 

266 }, 

267 ) 

268 @action(detail=True, methods=['post'], renderer_classes=[JSONRenderer, TextRenderer]) 

269 def render(self, request, pk): 

270 """ 

271 Render a ConfigTemplate using the context data provided (if any). The request body should be a 

272 mapping of context variables to make available to the template. If the client requests "text/plain" 

273 data, return the raw rendered content, rather than serialized JSON. 

274 """ 

275 # Override restrict() on the default queryset to enforce the render & view actions 

276 self.queryset = self.queryset.model.objects.restrict(request.user, 'render').restrict(request.user, 'view') 

277 configtemplate = self.get_object() 

278 

279 context = request.data 

280 

281 return self.render_configtemplate(request, configtemplate, context) 

282 

283 

284# 

285# Scripts 

286# 

287 

288class ScriptModuleViewSet(ObjectValidationMixin, CreateModelMixin, UpdateModelMixin, BaseViewSet): 

289 queryset = ScriptModule.objects.filter(file_root=ManagedFileRootPathChoices.SCRIPTS) 

290 serializer_class = serializers.ScriptModuleSerializer 

291 lookup_value_regex = '[^/]+' # Allow dots 

292 

293 def get_object(self): 

294 """ 

295 Retrieve a ScriptModule by numeric ID or by file name (e.g. my_script.py). 

296 """ 

297 queryset = self.filter_queryset(self.get_queryset()) 

298 lookup = self.kwargs.get(self.lookup_url_kwarg or self.lookup_field, '') 

299 

300 # Support lookup by numeric PK or by file_path. Treat all-decimal values as PKs 

301 # to preserve normal detail-route behavior; otherwise resolve the value as a 

302 # script module filename, e.g. "myscript.py". 

303 if lookup.isdecimal(): 

304 obj = get_object_or_404(queryset, pk=int(lookup)) 

305 else: 

306 obj = get_object_or_404(queryset, file_path=lookup) 

307 

308 self.check_object_permissions(self.request, obj) 

309 return obj 

310 

311 

312class ScriptViewSet(ListModelMixin, RetrieveModelMixin, BaseViewSet): 

313 # Individual scripts are created, modified, and deleted through their module (see ScriptModuleViewSet), 

314 # so the standard write actions are intentionally omitted here. Only listing/retrieving a script (GET) 

315 # and running one (POST to the detail route) are supported. 

316 permission_classes = [IsAuthenticatedOrLoginNotRequired] 

317 queryset = Script.objects.all() 

318 serializer_class = serializers.ScriptSerializer 

319 filterset_class = filtersets.ScriptFilterSet 

320 

321 lookup_value_regex = '[^/]+' # Allow dots 

322 

323 def get_serializer(self, *args, **kwargs): 

324 # A POST to the detail route runs the script, taking ScriptInputSerializer as its request body. 

325 # (This is keyed on the request method rather than on self.action, which is unset when generating 

326 # OPTIONS metadata.) ScriptInputSerializer is instantiated directly rather than via BaseViewSet, 

327 # which would pass it the fields/omit kwargs supported only by BaseModelSerializer. 

328 if getattr(self.request, 'method', None) == 'POST': 

329 kwargs.setdefault('context', self.get_serializer_context()) 

330 return serializers.ScriptInputSerializer(*args, **kwargs) 

331 return super().get_serializer(*args, **kwargs) 

332 

333 def get_serializer_context(self): 

334 context = super().get_serializer_context() 

335 

336 # ScriptInputSerializer resolves its field defaults and validates scheduling against the script 

337 # being run (set by run() below). 

338 context['script'] = getattr(self, 'script', None) 

339 

340 return context 

341 

342 def _get_script(self, pk): 

343 # Retrieve the script by ID if the PK is all decimal digits. (isdecimal() rather than isnumeric(), 

344 # as the latter also matches characters which cannot be cast to an integer.) 

345 if pk.isdecimal(): 

346 try: 

347 pk = int(pk) 

348 except ValueError: 

349 raise Http404 

350 return get_object_or_404(self.queryset, pk=pk) 

351 

352 # Default to retrieval by module & name 

353 try: 

354 module_name, script_name = pk.split('.', maxsplit=1) 

355 except ValueError: 

356 raise Http404 

357 

358 return get_object_or_404(self.queryset, module__file_path=f'{module_name}.py', name=script_name) 

359 

360 def retrieve(self, request, pk, **kwargs): 

361 script = self._get_script(pk) 

362 serializer = serializers.ScriptDetailSerializer(script, context={'request': request}) 

363 

364 return Response(serializer.data) 

365 

366 @extend_schema( 

367 operation_id='extras_scripts_run', 

368 request=serializers.ScriptInputSerializer, 

369 responses={ 

370 200: OpenApiResponse( 

371 response=serializers.ScriptDetailSerializer, 

372 description=_("The script has been enqueued for execution."), 

373 ), 

374 }, 

375 ) 

376 def run(self, request, pk, **kwargs): 

377 """ 

378 Run a Script identified by its numeric PK or module & name and return the pending Job as the result 

379 """ 

380 # Bound to POST on the detail route by ScriptRouter 

381 

382 # Reject read-only tokens before resolving the script, so that an insufficient token is always 

383 # reported as such. (Not via TokenWritePermission, which permits token auth only.) 

384 if isinstance(request.auth, Token) and not request.auth.write_enabled: 384 ↛ 385line 384 didn't jump to line 385 because the condition on line 384 was never true

385 raise PermissionDenied(_("This token does not permit write operations (running a script).")) 

386 

387 # An unauthenticated user can never run a script; report that explicitly, as restrict() below would 

388 # match no scripts and yield a misleading 404. 

389 if not request.user.is_authenticated: 389 ↛ 390line 389 didn't jump to line 390 because the condition on line 389 was never true

390 raise PermissionDenied(_("This user does not have permission to run this script.")) 

391 

392 # Running a script is a 'run' operation (not the 'add' that BaseViewSet maps to POST), so restrict 

393 # the QuerySet on 'run' before resolving the script. A script the user cannot run yields a 404. 

394 self.queryset = self.queryset.model.objects.restrict(request.user, 'run') 

395 self.script = script = self._get_script(pk) 

396 

397 # A script whose Python class cannot be resolved (e.g. its module has been modified or the script has 

398 # been deleted, retaining the record for its jobs) cannot be run 

399 if not script.is_executable or script.python_class is None: 

400 raise ValidationError(_("This script is not currently executable.")) 

401 

402 input_serializer = self.get_serializer(data=request.data) 

403 

404 # Check that at least one RQ worker is running 

405 if not any_workers_for_queue('default'): 

406 raise RQWorkerNotRunningException() 

407 

408 input_serializer.is_valid(raise_exception=True) 

409 

410 validated = input_serializer.validated_data 

411 

412 payload = validated['data'] 

413 

414 # Guaranteed non-None by the is_executable check above 

415 script_class = script.python_class 

416 script_instance = script_class() 

417 

418 form = prepare_script_form(script_instance, payload, files=request.FILES) 

419 if not form.is_valid(): 

420 # Exec params are validated separately via ScriptInputSerializer. Excluded by name 

421 # rather than by '_' prefix, which would also strip Django's NON_FIELD_ERRORS 

422 # key ('__all__'). 

423 errors = {k: v for k, v in form.errors.items() if k not in EXEC_PARAM_FIELDS} 

424 if not errors: 

425 # Every error was on an exec-param field, which a client can bind by naming one 

426 # in 'data' (e.g. {"_interval": "abc"}). NON_FIELD_ERRORS is never among them -- 

427 # the filter above retains '__all__' -- so there is nothing to re-surface here; 

428 # report a generic message rather than an empty body. 

429 errors = {NON_FIELD_ERRORS: [_('Invalid script input.')]} 

430 # Nest under 'data' so script-variable errors can't collide with the 

431 # serializer's own top-level fields (commit, schedule_at, interval, ...). 

432 raise ValidationError({'data': errors}) 

433 

434 data = form.cleaned_data.copy() 

435 for k in EXEC_PARAM_FIELDS: 

436 data.pop(k, None) 

437 

438 try: 

439 ScriptJob.enqueue( 

440 instance=script, 

441 user=request.user, 

442 data=data, 

443 request=copy_safe_request(request), 

444 commit=validated.get('commit'), 

445 job_timeout=script_class.job_timeout, 

446 schedule_at=validated.get('schedule_at'), 

447 interval=validated.get('interval'), 

448 notifications=validated.get('notifications'), 

449 ) 

450 except DjangoValidationError as e: 

451 # The script's execution configuration is invalid (see #22872). Surface it as a 400 rather than 

452 # allowing the exception to bubble up as an HTTP 500. These are script-level config errors, not 

453 # request-field errors, so report them under the non-field "detail" key. 

454 raise ValidationError({'detail': e.messages}) from e 

455 

456 serializer = serializers.ScriptDetailSerializer(script, context={'request': request}) 

457 return Response(serializer.data) 

458 

459 

460# 

461# User dashboard 

462# 

463 

464class DashboardView(RetrieveUpdateDestroyAPIView): 

465 queryset = Dashboard.objects.all() 

466 serializer_class = serializers.DashboardSerializer 

467 

468 def get_object(self): 

469 return Dashboard.objects.filter(user=self.request.user).first()