Coverage for core/api/schema.py: 88%

232 statements  

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

1import copy 

2import re 

3import typing 

4from collections import OrderedDict 

5 

6from django.core.exceptions import ImproperlyConfigured 

7from django.utils.translation import gettext_lazy as _ 

8from drf_spectacular.contrib.django_filters import DjangoFilterExtension 

9from drf_spectacular.extensions import OpenApiSerializerExtension, OpenApiSerializerFieldExtension, _SchemaType 

10from drf_spectacular.openapi import AutoSchema 

11from drf_spectacular.plumbing import ( 

12 build_basic_type, 

13 build_choice_field, 

14 build_media_type_object, 

15 build_object_type, 

16 follow_field_source, 

17 get_doc, 

18) 

19from drf_spectacular.types import OpenApiTypes 

20from drf_spectacular.utils import Direction, OpenApiParameter, OpenApiResponse 

21from rest_framework.fields import ReadOnlyField 

22from rest_framework.utils import model_meta 

23 

24from netbox.api.fields import ChoiceField 

25from netbox.api.serializers import BulkOperationErrorSerializer, WritableNestedSerializer 

26from netbox.api.viewsets import NetBoxModelViewSet 

27 

28# see netbox.api.routers.NetBoxRouter 

29BULK_ACTIONS = ("bulk_destroy", "bulk_partial_update", "bulk_update") 

30WRITABLE_ACTIONS = ("PATCH", "POST", "PUT") 

31 

32 

33class NetBoxDjangoFilterExtension(DjangoFilterExtension): 

34 """ 

35 Overrides drf-spectacular's DjangoFilterExtension to fix a regression in v0.29.0 where 

36 _get_model_field() incorrectly double-appends to_field_name when field_name already ends 

37 with that value (e.g. field_name='tags__slug', to_field_name='slug' produces the invalid 

38 path ['tags', 'slug', 'slug']). This caused hundreds of spurious warnings during schema 

39 generation for filters such as TagFilter, TenancyFilterSet.tenant, and OwnerFilterMixin.owner. 

40 

41 See: https://github.com/netbox-community/netbox/issues/20787 

42 https://github.com/tfranzel/drf-spectacular/issues/1475 

43 """ 

44 priority = 1 

45 

46 def _get_model_field(self, filter_field, model): 

47 if not filter_field.field_name: 47 ↛ 48line 47 didn't jump to line 48 because the condition on line 47 was never true

48 return None 

49 path = filter_field.field_name.split('__') 

50 to_field_name = filter_field.extra.get('to_field_name') 

51 if to_field_name is not None and path[-1] != to_field_name: 

52 path.append(to_field_name) 

53 return follow_field_source(model, path, emit_warnings=False) 

54 

55 

56class FixTimeZoneSerializerField(OpenApiSerializerFieldExtension): 

57 target_class = 'timezone_field.rest_framework.TimeZoneSerializerField' 

58 

59 def map_serializer_field(self, auto_schema, direction): 

60 return build_basic_type(OpenApiTypes.STR) 

61 

62 

63class ChoiceFieldFix(OpenApiSerializerFieldExtension): 

64 target_class = 'netbox.api.fields.ChoiceField' 

65 

66 def map_serializer_field(self, auto_schema, direction): 

67 build_cf = build_choice_field(self.target) 

68 

69 if direction == 'request': 

70 return build_cf 

71 

72 if direction == "response": 72 ↛ 88line 72 didn't jump to line 88 because the condition on line 72 was always true

73 value = build_cf 

74 label = { 

75 **build_basic_type(OpenApiTypes.STR), 

76 "enum": list(OrderedDict.fromkeys(self.target.choices.values())) 

77 } 

78 

79 return build_object_type( 

80 properties={ 

81 "value": value, 

82 "label": label 

83 } 

84 ) 

85 

86 # TODO: This function should never implicitly/explicitly return `None` 

87 # The fallback should be well-defined (drf-spectacular expects request/response naming). 

88 return None 

89 

90 

91def viewset_handles_bulk_create(view): 

92 """Check if view automatically provides list-based bulk create""" 

93 return isinstance(view, NetBoxModelViewSet) 

94 

95 

96class NetBoxAutoSchema(AutoSchema): 

97 """ 

98 Overrides to drf_spectacular.openapi.AutoSchema to fix following issues: 

99 1. bulk serializers cause operation_id conflicts with non-bulk ones 

100 2. bulk operations should specify a list 

101 3. bulk operations don't have filter params 

102 4. bulk operations don't have pagination 

103 5. bulk delete should specify input 

104 """ 

105 

106 writable_serializers = {} 

107 

108 @property 

109 def is_bulk_action(self): 

110 if hasattr(self.view, "action") and self.view.action in BULK_ACTIONS: 

111 return True 

112 return False 

113 

114 def get_operation_id(self): 

115 """ 

116 bulk serializers cause operation_id conflicts with non-bulk ones 

117 bulk operations cause id conflicts in spectacular resulting in numerous: 

118 Warning: operationId "xxx" has collisions [xxx]. "resolving with numeral suffixes" 

119 code is modified from drf_spectacular.openapi.AutoSchema.get_operation_id 

120 """ 

121 if self.is_bulk_action: 

122 tokenized_path = self._tokenize_path() 

123 # replace dashes as they can be problematic later in code generation 

124 tokenized_path = [t.replace('-', '_') for t in tokenized_path] 

125 

126 if self.method == 'GET' and self._is_list_view(): 126 ↛ 128line 126 didn't jump to line 128 because the condition on line 126 was never true

127 # this shouldn't happen, but keeping it here to follow base code 

128 action = 'list' 

129 else: 

130 # action = self.method_mapping[self.method.lower()] 

131 # use bulk name so partial_update -> bulk_partial_update 

132 action = self.view.action.lower() 

133 

134 if not tokenized_path: 134 ↛ 135line 134 didn't jump to line 135 because the condition on line 134 was never true

135 tokenized_path.append('root') 

136 

137 if re.search(r'<drf_format_suffix\w*:\w+>', self.path_regex): 137 ↛ 138line 137 didn't jump to line 138 because the condition on line 137 was never true

138 tokenized_path.append('formatted') 

139 

140 return '_'.join(tokenized_path + [action]) 

141 

142 # if not bulk - just return normal id 

143 return super().get_operation_id() 

144 

145 def get_request_serializer(self) -> typing.Any: 

146 serializer = super().get_request_serializer() 

147 

148 # Bulk update/partial-update has a special request shape: a list of 

149 # writable objects plus a required `id` field. The normal writable 

150 # serializer omits `id` because it is read-only, so don't use the generic 

151 # bulk handling for these actions. 

152 action = getattr(self.view, 'action', None) 

153 if action in ('bulk_update', 'bulk_partial_update'): 

154 get_bulk_update_request_serializer = getattr( 

155 self.view, 

156 'get_bulk_update_request_serializer', 

157 None, 

158 ) 

159 if get_bulk_update_request_serializer is not None: 159 ↛ 165line 159 didn't jump to line 165 because the condition on line 159 was always true

160 return get_bulk_update_request_serializer( 

161 partial=(action == 'bulk_partial_update' or self.method == 'PATCH') 

162 ) 

163 

164 # Bulk creates/deletes should specify a list. 

165 if self.is_bulk_action: 

166 return type(serializer)(many=True) 

167 

168 # handle mapping for Writable serializers - adapted from dansheps original 

169 # code for drf-yasg. 

170 if serializer is not None and self.method in WRITABLE_ACTIONS: 170 ↛ 179line 170 didn't jump to line 179 because the condition on line 170 was always true

171 writable_class = self.get_writable_class(serializer) 

172 if writable_class is not None: 

173 if hasattr(serializer, "child"): 173 ↛ 174line 173 didn't jump to line 174 because the condition on line 173 was never true

174 child_serializer = self.get_writable_class(serializer.child) 

175 serializer = writable_class(context=serializer.context, child=child_serializer) 

176 else: 

177 serializer = writable_class(context=serializer.context) 

178 

179 return serializer 

180 

181 def get_response_serializers(self) -> typing.Any: 

182 # bulk operations should specify a list 

183 response_serializers = super().get_response_serializers() 

184 

185 if self.is_bulk_action: 

186 return type(response_serializers)(many=True) 

187 

188 return response_serializers 

189 

190 def _get_bulk_error_responses(self, direction) -> typing.Any: 

191 """ 

192 Return the error responses of the current bulk write action, keyed by status code, or an 

193 empty dict if this action is not a bulk write. 

194 

195 A failed bulk write returns a structured body correlating each failure with the object (or, 

196 where no object could be identified, the request position) responsible for it. This is a 

197 documented part of the API contract, but drf-spectacular cannot infer it: responses are 

198 derived from the request/response serializer alone, which describes only the success case. 

199 """ 

200 action = getattr(self.view, 'action', None) 

201 

202 if action in ('bulk_update', 'bulk_partial_update'): 

203 return { 

204 '400': OpenApiResponse( 

205 response=BulkOperationErrorSerializer, 

206 description=_( 

207 "One or more of the objects specified could not be updated. No objects were " 

208 "modified: a bulk update is an all-or-none operation." 

209 ), 

210 ), 

211 '403': OpenApiResponse( 

212 response=BulkOperationErrorSerializer, 

213 description=_( 

214 "The requesting user is not permitted to apply one or more of the " 

215 "modifications specified. No objects were modified." 

216 ), 

217 ), 

218 } 

219 

220 if action == 'bulk_destroy': 

221 return { 

222 '400': OpenApiResponse( 

223 response=BulkOperationErrorSerializer, 

224 description=_( 

225 "The request was malformed, one or more of the objects specified could not " 

226 "be found, or the deletion of one of them was prevented by a protection " 

227 "rule. No objects were deleted." 

228 ), 

229 ), 

230 '403': OpenApiResponse( 

231 response=BulkOperationErrorSerializer, 

232 description=_( 

233 "The requesting user is not permitted to delete one or more of the objects " 

234 "specified. No objects were deleted." 

235 ), 

236 ), 

237 '409': OpenApiResponse( 

238 response=BulkOperationErrorSerializer, 

239 description=_( 

240 "One or more of the objects specified could not be deleted, because a " 

241 "dependent object prevents it. No objects were deleted: a bulk deletion is " 

242 "an all-or-none operation." 

243 ), 

244 ), 

245 } 

246 

247 if action == 'create' and viewset_handles_bulk_create(self.view): 

248 # A POST to a list endpoint accepts either a single object or a list of them (see 

249 # _get_request_for_media_type()), so its error body takes one of two shapes 

250 # accordingly: field-keyed errors for a single object, or the bulk envelope for a list. 

251 component = self.resolve_serializer(BulkOperationErrorSerializer, direction) 

252 return { 

253 '400': OpenApiResponse( 

254 response={ 

255 'oneOf': [ 

256 build_basic_type(OpenApiTypes.OBJECT), 

257 component.ref if component else build_basic_type(OpenApiTypes.OBJECT), 

258 ], 

259 }, 

260 description=_( 

261 "The object could not be created. Where a list was submitted, no objects " 

262 "were created: a bulk creation is an all-or-none operation." 

263 ), 

264 ), 

265 # A 403 always carries a `detail`, and BulkOperationError's `errors` is optional, so 

266 # the one component covers both the single-object and the bulk shape here. 

267 '403': OpenApiResponse( 

268 response=BulkOperationErrorSerializer, 

269 description=_( 

270 "The requesting user is not permitted to create one or more of the objects " 

271 "specified. No objects were created." 

272 ), 

273 ), 

274 } 

275 

276 return {} 

277 

278 def _get_response_bodies(self, direction='response') -> typing.Any: 

279 responses = super()._get_response_bodies(direction=direction) 

280 

281 # Document the error responses of the bulk write actions, which cannot be inferred (see 

282 # _get_bulk_error_responses). A status code already present -- for instance one declared 

283 # via @extend_schema on a custom action -- is left as it is. 

284 for code, response in self._get_bulk_error_responses(direction).items(): 

285 if code not in responses: 285 ↛ 284line 285 didn't jump to line 284 because the condition on line 285 was always true

286 responses[code] = self._get_response_for_code(response, code, direction=direction) 

287 

288 return responses 

289 

290 def _get_request_for_media_type(self, serializer, direction='request'): 

291 """ 

292 Override to generate oneOf schema for serializers that support both 

293 single object and array input (NetBoxModelViewSet POST operations). 

294 

295 Refs: #20638 

296 """ 

297 # Get the standard schema first 

298 schema, required = super()._get_request_for_media_type(serializer, direction) 

299 

300 # If this serializer supports arrays (marked in get_request_serializer), 

301 # wrap the schema in oneOf to allow single object OR array 

302 if ( 

303 direction == 'request' and 

304 schema is not None and 

305 getattr(self.view, 'action', None) == 'create' and 

306 viewset_handles_bulk_create(self.view) 

307 ): 

308 return { 

309 'oneOf': [ 

310 schema, # Single object 

311 { 

312 'type': 'array', 

313 'items': schema, # Array of objects 

314 } 

315 ] 

316 }, required 

317 

318 return schema, required 

319 

320 def _get_serializer_name(self, serializer, direction, bypass_extensions=False) -> str: 

321 name = super()._get_serializer_name(serializer, direction, bypass_extensions) 

322 

323 # If this serializer is nested, prepend its name with "Brief". Serializers which declare an explicit 

324 # Meta.ref_name are exempt: those are brief by design and have no complete form in the schema, so the 

325 # prefix would only rename an existing component to no purpose. See #22989. 

326 if getattr(serializer, 'nested', False) and not getattr(getattr(serializer, 'Meta', None), 'ref_name', None): 

327 name = f'Brief{name}' 

328 

329 return name 

330 

331 def get_serializer_ref_name(self, serializer): 

332 # from drf-yasg.utils 

333 """Get serializer's ref_name 

334 :param serializer: Serializer instance 

335 :return: Serializer's ``ref_name`` or ``None`` for inline serializer 

336 :rtype: str or None 

337 """ 

338 serializer_meta = getattr(serializer, 'Meta', None) 

339 serializer_name = type(serializer).__name__ 

340 if hasattr(serializer_meta, 'ref_name'): 340 ↛ 341line 340 didn't jump to line 341 because the condition on line 340 was never true

341 ref_name = serializer_meta.ref_name 

342 else: 

343 ref_name = serializer_name 

344 if ref_name.endswith('Serializer'): 344 ↛ 346line 344 didn't jump to line 346 because the condition on line 344 was always true

345 ref_name = ref_name[: -len('Serializer')] 

346 return ref_name 

347 

348 @staticmethod 

349 def _rebuilds_as_writable(serializer, field_name): 

350 """ 

351 Return True if DRF would rebuild the named field in writable form if the field declared on 

352 the serializer class were removed (see get_writable_class()). 

353 

354 This defers to ModelSerializer.build_field(), which is what get_fields() itself calls for 

355 any field not explicitly declared on the class -- rather than testing the model for a field 

356 of that name, which is a weaker condition. A name backed only by a model property, by a 

357 non-editable model field, or by a generic foreign key (which lives in Meta.private_fields 

358 and so is absent from DRF's field info) is rebuilt read-only, and is then dropped from the 

359 request body altogether. 

360 """ 

361 model = getattr(getattr(serializer, 'Meta', None), 'model', None) 

362 if model is None or not hasattr(serializer, 'build_field'): 362 ↛ 363line 362 didn't jump to line 363 because the condition on line 362 was never true

363 return False 

364 

365 depth = getattr(serializer.Meta, 'depth', 0) 

366 try: 

367 field_class, field_kwargs = serializer.build_field( 

368 field_name, model_meta.get_field_info(model), model, depth 

369 ) 

370 except ImproperlyConfigured: 

371 # build_unknown_field(): the model has nothing of this name at all 

372 return False 

373 

374 if isinstance(field_class, type) and issubclass(field_class, ReadOnlyField): 

375 return False 

376 return not field_kwargs.get('read_only', False) 

377 

378 def get_writable_class(self, serializer): 

379 properties = {} 

380 fields = {} if hasattr(serializer, 'child') else serializer.fields 

381 remove_fields = [] 

382 

383 # If you get a failure here for "AttributeError: 'cached_property' object has no attribute 'items'" 

384 # it is probably because you are using a viewsets.ViewSet for the API View and are defining a 

385 # serializer_class. You will also need to define a get_serializer() method like for GenericAPIView. 

386 for child_name, child in fields.items(): 

387 # read_only fields don't need to be in writable (write only) serializers 

388 if 'read_only' in dir(child) and child.read_only: 

389 remove_fields.append(child_name) 

390 if isinstance(child, (ChoiceField, WritableNestedSerializer)): 

391 if child.read_only or self._rebuilds_as_writable(serializer, child_name): 

392 properties[child_name] = None 

393 else: 

394 # DRF cannot rebuild this one writably: it is backed by a read-only property 

395 # (e.g. Service.protocol, derived from port_mappings). Nulling it would leave 

396 # DRF to rebuild it as a ReadOnlyField, which is then omitted from the request 

397 # body altogether -- silently dropping a field the serializer does accept on 

398 # write. Keep the declared field instead; ChoiceFieldFix already renders it 

399 # correctly for the request direction. The copy leaves the bound original 

400 # untouched (Field.__deepcopy__ returns an unbound field built from the same 

401 # arguments), and keeps `properties` non-empty so the writable variant is still 

402 # generated rather than collapsing to None below. 

403 properties[child_name] = copy.deepcopy(child) 

404 

405 if not properties: 

406 return None 

407 

408 if type(serializer) not in self.writable_serializers: 

409 writable_name = 'Writable' + type(serializer).__name__ 

410 meta_class = getattr(type(serializer), 'Meta', None) 

411 if meta_class: 411 ↛ 421line 411 didn't jump to line 421 because the condition on line 411 was always true

412 ref_name = 'Writable' + self.get_serializer_ref_name(serializer) 

413 # remove read_only fields from write-only serializers 

414 fields = list(meta_class.fields) 

415 for field in remove_fields: 

416 fields.remove(field) 

417 writable_meta = type('Meta', (meta_class,), {'ref_name': ref_name, 'fields': fields}) 

418 

419 properties['Meta'] = writable_meta 

420 

421 self.writable_serializers[type(serializer)] = type(writable_name, (type(serializer),), properties) 

422 

423 writable_class = self.writable_serializers[type(serializer)] 

424 return writable_class 

425 

426 def get_override_parameters(self): 

427 params = super().get_override_parameters() 

428 # Expose the ?fields, ?omit, and ?brief query parameters supported by NetBoxModelViewSet 

429 # for all non-bulk GET operations (both list and detail). 

430 if not self.is_bulk_action and self.method == 'GET': 

431 params = list(params) + [ 

432 OpenApiParameter( 

433 name='fields', 

434 location=OpenApiParameter.QUERY, 

435 required=False, 

436 type=OpenApiTypes.STR, 

437 description='Comma-separated list of fields to include in the response. Example: `fields=id,name`.', 

438 ), 

439 OpenApiParameter( 

440 name='omit', 

441 location=OpenApiParameter.QUERY, 

442 required=False, 

443 type=OpenApiTypes.STR, 

444 description='Comma-separated list of fields to exclude from the response. ' 

445 'Example: `omit=description,tags`.', 

446 ), 

447 OpenApiParameter( 

448 name='brief', 

449 location=OpenApiParameter.QUERY, 

450 required=False, 

451 type=OpenApiTypes.BOOL, 

452 description='Return only brief fields for each object.', 

453 ), 

454 ] 

455 return params 

456 

457 def get_filter_backends(self): 

458 # bulk operations don't have filter params 

459 if self.is_bulk_action: 

460 return [] 

461 return super().get_filter_backends() 

462 

463 def _get_paginator(self): 

464 # bulk operations don't have pagination 

465 if self.is_bulk_action: 

466 return None 

467 return super()._get_paginator() 

468 

469 def _get_request_body(self, direction='request'): 

470 # bulk delete should specify input 

471 if (not self.is_bulk_action) or (self.method != 'DELETE'): 

472 return super()._get_request_body(direction) 

473 

474 # rest from drf_spectacular.openapi.AutoSchema._get_request_body 

475 # but remove the unsafe method check 

476 

477 request_serializer = self.get_request_serializer() 

478 

479 if isinstance(request_serializer, dict): 479 ↛ 480line 479 didn't jump to line 480 because the condition on line 479 was never true

480 content = [] 

481 request_body_required = True 

482 for media_type, serializer in request_serializer.items(): 

483 schema, partial_request_body_required = self._get_request_for_media_type(serializer, direction) 

484 examples = self._get_examples(serializer, direction, media_type) 

485 if schema is None: 

486 continue 

487 content.append((media_type, schema, examples)) 

488 request_body_required &= partial_request_body_required 

489 else: 

490 schema, request_body_required = self._get_request_for_media_type(request_serializer, direction) 

491 if schema is None: 491 ↛ 492line 491 didn't jump to line 492 because the condition on line 491 was never true

492 return None 

493 content = [ 

494 (media_type, schema, self._get_examples(request_serializer, direction, media_type)) 

495 for media_type in self.map_parsers() 

496 ] 

497 

498 request_body = { 

499 'content': { 

500 media_type: build_media_type_object(schema, examples) for media_type, schema, examples in content 

501 } 

502 } 

503 if request_body_required: 503 ↛ 505line 503 didn't jump to line 505 because the condition on line 503 was always true

504 request_body['required'] = request_body_required 

505 return request_body 

506 

507 def get_description(self): 

508 """ 

509 Return a string description for the ViewSet. 

510 """ 

511 

512 # If a docstring is provided, use it. 

513 if self.view.__doc__: 

514 return get_doc(self.view.__class__) 

515 

516 # When the action method is decorated with @action, use the docstring of the method. 

517 action_or_method = getattr(self.view, getattr(self.view, 'action', self.method.lower()), None) 

518 if action_or_method and action_or_method.__doc__: 

519 return get_doc(action_or_method) 

520 

521 # Else, generate a description from the class name. 

522 return self._generate_description() 

523 

524 def _generate_description(self): 

525 """ 

526 Generate a docstring for the method. It also takes into account whether the method is for list or detail. 

527 """ 

528 model_name = self.view.queryset.model._meta.verbose_name 

529 

530 # Determine if the method is for list or detail. 

531 if '{id}' in self.path: 

532 return f"{self.method.capitalize()} a {model_name} object." 

533 return f"{self.method.capitalize()} a list of {model_name} objects." 

534 

535 

536class FixSerializedPKRelatedField(OpenApiSerializerFieldExtension): 

537 target_class = 'netbox.api.fields.SerializedPKRelatedField' 

538 

539 def map_serializer_field(self, auto_schema, direction): 

540 if direction == "response": 

541 # Resolve an instance of the serializer carrying the field's nested setting, so that the brief 

542 # component is referenced wherever the field renders a brief representation. (The field's 

543 # to_representation() passes nested in the same manner.) See #22989. 

544 serializer = self.target.serializer(nested=self.target.nested) 

545 component = auto_schema.resolve_serializer(serializer, direction) 

546 return component.ref if component else None 

547 return build_basic_type(OpenApiTypes.INT) 

548 

549 

550class FixIntegerRangeSerializerSchema(OpenApiSerializerExtension): 

551 target_class = 'netbox.api.fields.IntegerRangeSerializer' 

552 match_subclasses = True 

553 

554 def map_serializer(self, auto_schema: 'AutoSchema', direction: Direction) -> _SchemaType: 

555 # One range = two integers; many=True will wrap this in an outer array 

556 return { 

557 'type': 'array', 

558 'items': { 

559 'type': 'integer', 

560 }, 

561 'minItems': 2, 

562 'maxItems': 2, 

563 'example': [10, 20], 

564 } 

565 

566 

567# Nested models can be passed by ID in requests 

568# The logic for this is handled in `BaseModelSerializer.to_internal_value` 

569class FixWritableNestedSerializerAllowPK(OpenApiSerializerFieldExtension): 

570 target_class = 'netbox.api.serializers.BaseModelSerializer' 

571 match_subclasses = True 

572 

573 def map_serializer_field(self, auto_schema, direction): 

574 schema = auto_schema._map_serializer_field(self.target, direction, bypass_extensions=True) 

575 if schema is None: 

576 return schema 

577 if direction == 'request' and self.target.nested: 

578 return { 

579 'oneOf': [ 

580 build_basic_type(OpenApiTypes.INT), 

581 schema, 

582 ] 

583 } 

584 return schema