Coverage for netbox/api/viewsets/__init__.py: 79%

202 statements  

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

1import logging 

2import warnings 

3from functools import cached_property 

4 

5from django.core.exceptions import ObjectDoesNotExist, PermissionDenied 

6from django.db import router, transaction 

7from django.db.models import ProtectedError, RestrictedError 

8from rest_framework import mixins as drf_mixins 

9from rest_framework import status 

10from rest_framework.exceptions import MethodNotAllowed 

11from rest_framework.response import Response 

12from rest_framework.viewsets import GenericViewSet 

13 

14from netbox.api.serializers.features import ChangeLogMessageSerializer 

15from utilities.api import get_annotations_for_serializer, get_prefetches_for_serializer 

16from utilities.exceptions import AbortRequest, PreconditionFailed 

17from utilities.query import reapply_model_ordering 

18 

19from . import mixins 

20 

21__all__ = ( 

22 'MPTTLockedMixin', 

23 'NetBoxModelViewSet', 

24 'NetBoxReadOnlyModelViewSet', 

25) 

26 

27HTTP_ACTIONS = { 

28 'GET': 'view', 

29 'OPTIONS': None, 

30 'HEAD': 'view', 

31 'POST': 'add', 

32 'PUT': 'change', 

33 'PATCH': 'change', 

34 'DELETE': 'delete', 

35} 

36 

37 

38class ETagMixin: 

39 """ 

40 Adds ETag header support to ViewSets. Generates weak ETags (W/ prefix per 

41 RFC 7232 §2.1) from `last_updated` (or `created` if unavailable). Weak ETags 

42 are appropriate here because the tag is derived from a modification timestamp 

43 rather than a hash of the serialized payload. 

44 """ 

45 

46 @staticmethod 

47 def _get_etag(obj): 

48 """Return a weak ETag string for the given object, or None.""" 

49 if ts := getattr(obj, 'last_updated', None) or getattr(obj, 'created', None): 

50 return f'W/"{ts.isoformat()}"' 

51 return None 

52 

53 @staticmethod 

54 def _get_if_match(request): 

55 """Return the list of If-Match header values (if specified).""" 

56 if (if_match := request.META.get('HTTP_IF_MATCH')) and if_match != '*': 56 ↛ 57line 56 didn't jump to line 57 because the condition on line 56 was never true

57 return [e.strip() for e in if_match.split(',')] 

58 return [] 

59 

60 def _validate_etag(self, request, instance): 

61 """Validate the request's ETag""" 

62 if provided := self._get_if_match(request): 62 ↛ 63line 62 didn't jump to line 63 because the condition on line 62 was never true

63 current_etag = self._get_etag(instance) 

64 if current_etag and current_etag not in provided: 

65 raise PreconditionFailed(etag=current_etag) 

66 

67 def handle_exception(self, exc): 

68 response = super().handle_exception(exc) 

69 if isinstance(exc, PreconditionFailed) and exc.etag: 69 ↛ 70line 69 didn't jump to line 70 because the condition on line 69 was never true

70 response['ETag'] = exc.etag 

71 return response 

72 

73 def retrieve(self, request, *args, **kwargs): 

74 instance = self.get_object() 

75 serializer = self.get_serializer(instance) 

76 response = Response(serializer.data) 

77 if etag := self._get_etag(instance): 

78 response['ETag'] = etag 

79 return response 

80 

81 

82class BaseViewSet(GenericViewSet): 

83 """ 

84 Base class for all API ViewSets. This is responsible for the enforcement of object-based permissions. 

85 """ 

86 brief = False 

87 

88 def initial(self, request, *args, **kwargs): 

89 super().initial(request, *args, **kwargs) 

90 

91 # Reject any method for which no action has been declared, rather than proceeding against an 

92 # unrestricted QuerySet. (A method mapped to None, e.g. OPTIONS, is permitted: it needs no 

93 # restriction.) This is the same 405 DRF would return when resolving the handler for an unmapped 

94 # method, but it also covers a handler bound to such a method (e.g. @action(methods=['trace'])). 

95 if request.method not in HTTP_ACTIONS: 95 ↛ 96line 95 didn't jump to line 96 because the condition on line 95 was never true

96 raise MethodNotAllowed(request.method) 

97 

98 # Restrict the view's QuerySet to allow only the permitted objects 

99 if request.user.is_authenticated: 99 ↛ exitline 99 didn't return from function 'initial' because the condition on line 99 was always true

100 if action := HTTP_ACTIONS[request.method]: 

101 self.queryset = self.queryset.restrict(request.user, action) 

102 

103 def initialize_request(self, request, *args, **kwargs): 

104 

105 # Annotate whether brief mode is active 

106 self.brief = request.method == 'GET' and request.GET.get('brief') 

107 

108 return super().initialize_request(request, *args, **kwargs) 

109 

110 def get_queryset(self): 

111 qs = super().get_queryset() 

112 serializer_class = self.get_serializer_class() 

113 

114 # Dynamically resolve prefetches for included serializer fields and attach them to the queryset 

115 if prefetch := get_prefetches_for_serializer(serializer_class, **self.field_kwargs): 

116 qs = qs.prefetch_related(*prefetch) 

117 

118 # Dynamically resolve annotations for RelatedObjectCountFields on the serializer and attach them to the queryset 

119 if annotations := get_annotations_for_serializer(serializer_class, **self.field_kwargs): 

120 qs = qs.annotate(**annotations) 

121 

122 return qs 

123 

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

125 # Pass the fields/omit kwargs (if specified by the request) to the serializer 

126 kwargs.update(**self.field_kwargs) 

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

128 

129 @cached_property 

130 def field_kwargs(self): 

131 """Return a dictionary of keyword arguments to be passed when instantiating the serializer.""" 

132 # An explicit list of fields was requested 

133 if requested_fields := self.request.query_params.get('fields'): 

134 return {'fields': requested_fields.split(',')} 

135 

136 # An explicit list of fields to omit was requested 

137 if omit_fields := self.request.query_params.get('omit'): 

138 return {'omit': omit_fields.split(',')} 

139 

140 # Brief mode has been enabled for this request 

141 if self.brief: 

142 serializer_class = self.get_serializer_class() 

143 if brief_fields := getattr(serializer_class.Meta, 'brief_fields', None): 

144 return {'fields': brief_fields} 

145 

146 return {} 

147 

148 

149class NetBoxReadOnlyModelViewSet( 

150 ETagMixin, 

151 mixins.CustomFieldsMixin, 

152 mixins.ExportTemplatesMixin, 

153 drf_mixins.RetrieveModelMixin, 

154 drf_mixins.ListModelMixin, 

155 BaseViewSet 

156): 

157 pass 

158 

159 

160class NetBoxModelViewSet( 

161 ETagMixin, 

162 mixins.BackgroundOperationMixin, 

163 mixins.BulkCreateModelMixin, 

164 mixins.BulkUpdateModelMixin, 

165 mixins.BulkDestroyModelMixin, 

166 mixins.ObjectValidationMixin, 

167 mixins.CustomFieldsMixin, 

168 mixins.ExportTemplatesMixin, 

169 drf_mixins.CreateModelMixin, 

170 drf_mixins.RetrieveModelMixin, 

171 drf_mixins.UpdateModelMixin, 

172 drf_mixins.DestroyModelMixin, 

173 drf_mixins.ListModelMixin, 

174 BaseViewSet 

175): 

176 """ 

177 Extend DRF's ModelViewSet to support bulk update and delete functions. 

178 """ 

179 def get_object_with_snapshot(self): 

180 """ 

181 Save a pre-change snapshot of the object immediately after retrieving it. This snapshot will be used to 

182 record the "before" data in the changelog. 

183 """ 

184 obj = super().get_object() 

185 if hasattr(obj, 'snapshot'): 

186 obj.snapshot() 

187 return obj 

188 

189 def get_queryset(self): 

190 qs = super().get_queryset() 

191 return reapply_model_ordering(qs) 

192 

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

194 # If a list of objects has been provided, initialize the serializer with many=True 

195 if isinstance(kwargs.get('data', {}), list): 195 ↛ 196line 195 didn't jump to line 196 because the condition on line 195 was never true

196 kwargs['many'] = True 

197 

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

199 

200 def dispatch(self, request, *args, **kwargs): 

201 logger = logging.getLogger(f'netbox.api.views.{self.__class__.__name__}') 

202 

203 try: 

204 return super().dispatch(request, *args, **kwargs) 

205 except (ProtectedError, RestrictedError) as e: 

206 if type(e) is ProtectedError: 206 ↛ 209line 206 didn't jump to line 209 because the condition on line 206 was always true

207 protected_objects = list(e.protected_objects) 

208 else: 

209 protected_objects = list(e.restricted_objects) 

210 msg = f'Unable to delete object. {len(protected_objects)} dependent objects were found: ' 

211 msg += ', '.join([f'{obj} ({obj.pk})' for obj in protected_objects]) 

212 logger.warning(msg) 

213 return self.finalize_response( 

214 request, 

215 Response({'detail': msg}, status=409), 

216 *args, 

217 **kwargs 

218 ) 

219 except AbortRequest as e: 

220 logger.debug(e.message) 

221 return self.finalize_response( 

222 request, 

223 Response({'detail': e.message}, status=400), 

224 *args, 

225 **kwargs 

226 ) 

227 

228 def exception_to_response(self, exc): 

229 """ 

230 Translate a NetBox/Django exception that is not a DRF APIException into the same 

231 Response that dispatch() would return for it. Returns None if the exception is not 

232 one this method handles (the caller should then re-raise or defer to DRF). 

233 

234 This mirrors the except clauses in dispatch(); it is also called by the background 

235 job runner (netbox.jobs.AsyncAPIJob), which executes action methods directly and so 

236 bypasses dispatch(). NOTE: dispatch() does not yet call this helper itself; the two 

237 should be consolidated into a single source of truth in a future change. 

238 """ 

239 logger = logging.getLogger(f'netbox.api.views.{self.__class__.__name__}') 

240 if isinstance(exc, (ProtectedError, RestrictedError)): 

241 if type(exc) is ProtectedError: 

242 protected_objects = list(exc.protected_objects) 

243 else: 

244 protected_objects = list(exc.restricted_objects) 

245 msg = f'Unable to delete object. {len(protected_objects)} dependent objects were found: ' 

246 msg += ', '.join([f'{obj} ({obj.pk})' for obj in protected_objects]) 

247 logger.warning(msg) 

248 return Response({'detail': msg}, status=409) 

249 if isinstance(exc, AbortRequest): 

250 logger.debug(exc.message) 

251 return Response({'detail': exc.message}, status=400) 

252 return None 

253 

254 # Creates 

255 

256 def create(self, request, *args, **kwargs): 

257 # If background processing was requested for a bulk (list) create, enqueue a job and 

258 # return immediately. Single-object creates always run synchronously. 

259 if (response := self._handle_background_request(request, 'create')) is not None: 259 ↛ 260line 259 didn't jump to line 260 because the condition on line 259 was never true

260 return response 

261 

262 # Creating multiple objects, which are validated and saved one at a time in order to 

263 # collect per-object errors (see BulkCreateModelMixin) 

264 if isinstance(request.data, list): 

265 return self.bulk_create(request, *args, **kwargs) 

266 

267 serializer = self.get_serializer(data=request.data) 

268 serializer.is_valid(raise_exception=True) 

269 self.perform_create(serializer) 

270 

271 # After creating the instance, re-initialize the serializer with a queryset 

272 # to ensure related objects are prefetched. 

273 qs = self.get_queryset().get(pk=serializer.instance.pk) 

274 

275 # Re-serialize the instance with prefetched data 

276 serializer = self.get_serializer(qs) 

277 

278 headers = self.get_success_headers(serializer.data) 

279 response = Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) 

280 

281 if etag := self._get_etag(qs): 

282 response['ETag'] = etag 

283 

284 return response 

285 

286 def perform_create(self, serializer): 

287 model = self.queryset.model 

288 logger = logging.getLogger(f'netbox.api.views.{self.__class__.__name__}') 

289 logger.info(f"Creating new {model._meta.verbose_name}") 

290 

291 # Enforce object-level permissions on save() 

292 using = router.db_for_write(model) 

293 try: 

294 with transaction.atomic(using=using), mixins.discard_events_on_rollback(self, using=using): 

295 instance = serializer.save() 

296 self._validate_objects(instance) 

297 except ObjectDoesNotExist: 

298 raise PermissionDenied() 

299 

300 # Updates 

301 

302 def update(self, request, *args, **kwargs): 

303 partial = kwargs.pop('partial', False) 

304 instance = self.get_object_with_snapshot() 

305 

306 # Enforce If-Match precondition (RFC 9110 §13.1.1) 

307 self._validate_etag(self.request, instance) 

308 

309 serializer = self.get_serializer(instance, data=request.data, partial=partial) 

310 serializer.is_valid(raise_exception=True) 

311 self.perform_update(serializer) 

312 

313 # After updating the instance, re-initialize the serializer with a queryset 

314 # to ensure related objects are prefetched. 

315 qs = self.get_queryset().get(pk=serializer.instance.pk) 

316 

317 # Re-serialize the instance(s) with prefetched data 

318 serializer = self.get_serializer(qs) 

319 response = Response(serializer.data) 

320 

321 if etag := self._get_etag(qs): 

322 response['ETag'] = etag 

323 

324 return response 

325 

326 def perform_update(self, serializer): 

327 model = self.queryset.model 

328 logger = logging.getLogger(f'netbox.api.views.{self.__class__.__name__}') 

329 logger.info(f"Updating {model._meta.verbose_name} {serializer.instance} (PK: {serializer.instance.pk})") 

330 

331 # Enforce object-level permissions on save() 

332 using = router.db_for_write(model) 

333 try: 

334 with transaction.atomic(using=using), mixins.discard_events_on_rollback(self, using=using): 

335 # Re-check the If-Match ETag under a row-level lock to close the TOCTOU window 

336 # between the initial check in update() and the actual write. 

337 if self._get_if_match(self.request): 337 ↛ 338line 337 didn't jump to line 338 because the condition on line 337 was never true

338 locked = model.objects.select_for_update().get(pk=serializer.instance.pk) 

339 self._validate_etag(self.request, locked) 

340 instance = serializer.save() 

341 self._validate_objects(instance) 

342 except ObjectDoesNotExist: 

343 raise PermissionDenied() 

344 

345 # Deletes 

346 

347 def destroy(self, request, *args, **kwargs): 

348 instance = self.get_object_with_snapshot() 

349 

350 # Enforce If-Match precondition (RFC 9110 §13.1.1) 

351 self._validate_etag(request, instance) 

352 

353 # Attach changelog message (if any) 

354 serializer = ChangeLogMessageSerializer(data=request.data) 

355 serializer.is_valid(raise_exception=True) 

356 instance._changelog_message = serializer.validated_data.get('changelog_message') 

357 

358 self.perform_destroy(instance) 

359 

360 return Response(status=status.HTTP_204_NO_CONTENT) 

361 

362 def perform_destroy(self, instance): 

363 model = self.queryset.model 

364 logger = logging.getLogger(f'netbox.api.views.{self.__class__.__name__}') 

365 logger.info(f"Deleting {model._meta.verbose_name} {instance} (PK: {instance.pk})") 

366 

367 using = router.db_for_write(model) 

368 try: 

369 with transaction.atomic(using=using), mixins.discard_events_on_rollback(self, using=using): 

370 # Re-check the If-Match ETag under a row-level lock to close the TOCTOU window 

371 # between the initial check in destroy() and the actual delete. 

372 if self._get_if_match(self.request): 372 ↛ 373line 372 didn't jump to line 373 because the condition on line 372 was never true

373 locked = model.objects.select_for_update().get(pk=instance.pk) 

374 self._validate_etag(self.request, locked) 

375 super().perform_destroy(instance) 

376 except ObjectDoesNotExist: 

377 raise PermissionDenied() 

378 

379 

380# TODO: Remove this in NetBox v5.0 

381class MPTTLockedMixin: 

382 """ 

383 Deprecated no-op mixin retained for backward compatibility. 

384 

385 Historically this acquired a pglock around create/update/destroy to serialize 

386 concurrent writes to MPTT-based tree models. NetBox no longer uses MPTT: tree 

387 integrity is now maintained by the PostgreSQL ltree triggers (see 

388 `utilities.ltree`), which take per-tree advisory locks at the database level. 

389 This mixin is therefore now a transparent pass-through and may be removed in a 

390 future release. Plugins should stop inheriting from it. 

391 """ 

392 

393 def __init_subclass__(cls, **kwargs): 

394 super().__init_subclass__(**kwargs) 

395 warnings.warn( 

396 "MPTTLockedMixin is deprecated and no longer does anything; tree write " 

397 "concurrency is now handled by ltree database triggers. Remove it from " 

398 f"{cls.__name__}.", 

399 DeprecationWarning, 

400 stacklevel=2, 

401 )