Coverage for netbox/api/serializers/bulk.py: 96%

42 statements  

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

1import copy 

2import functools 

3 

4from django.utils.translation import gettext_lazy as _ 

5from rest_framework import serializers 

6 

7from .features import ChangeLogMessageSerializer 

8 

9__all__ = ( 

10 'BulkOperationEntryErrorSerializer', 

11 'BulkOperationErrorSerializer', 

12 'BulkOperationSerializer', 

13 'BulkPartialUpdateSchemaMixin', 

14 'BulkUpdateSchemaMixin', 

15 'get_bulk_update_serializer_class' 

16) 

17 

18 

19class BulkOperationSerializer(ChangeLogMessageSerializer): 

20 id = serializers.IntegerField() 

21 

22 

23# The two serializers below are schema-only: they are never used to validate or render data. The 

24# bulk actions in netbox.api.viewsets.mixins assemble these payloads directly; these exist so that 

25# their error responses are a documented part of the OpenAPI schema rather than an untyped body. 

26# Note that a class docstring becomes the component's description in the published schema, so keep 

27# it user-facing. 

28class BulkOperationEntryErrorSerializer(serializers.Serializer): 

29 """ 

30 The failure of a single object within a bulk operation. 

31 """ 

32 id = serializers.IntegerField( 

33 required=False, 

34 help_text=_( 

35 "The ID of the object which failed. Present once the entry has been matched to an " 

36 "object; mutually exclusive with `index`." 

37 ) 

38 ) 

39 index = serializers.IntegerField( 

40 required=False, 

41 help_text=_( 

42 "The zero-based position of the entry within the submitted list. Used where no object " 

43 "has been identified for the entry: always for creations, and for updates and deletions " 

44 "where the entry itself could not be interpreted (e.g. a missing or non-numeric `id`). " 

45 "Mutually exclusive with `id`." 

46 ) 

47 ) 

48 errors = serializers.DictField( 

49 help_text=_( 

50 "The errors for this entry, keyed by field name. Values are ordinarily arrays of " 

51 "messages. Errors which pertain to no particular field -- model validation, protection " 

52 "rules, restricted tags, object-level permissions, or the shape of the entry itself -- " 

53 "all appear under the single key `__all__`." 

54 ) 

55 ) 

56 

57 

58class BulkOperationErrorSerializer(serializers.Serializer): 

59 """ 

60 The body returned when a bulk operation fails, correlating each failure with the object 

61 responsible for it. 

62 """ 

63 detail = serializers.CharField( 

64 help_text=_('A summary of the failure, e.g. "1 of 3 objects could not be updated."') 

65 ) 

66 errors = BulkOperationEntryErrorSerializer( 

67 many=True, 

68 required=False, 

69 help_text=_( 

70 "One entry per object which failed; objects which would have succeeded are omitted, as " 

71 "a bulk operation is all-or-none. Absent where the request could not be attributed to " 

72 "individual entries at all (e.g. a request body which is not a list)." 

73 ) 

74 ) 

75 

76 

77class BulkUpdateSchemaMixin: 

78 def get_fields(self): 

79 fields = super().get_fields() 

80 # Reuse the runtime bulk-operation ID field so the schema stays in sync 

81 # with the validator that consumes `id` before model serialization. 

82 _id = copy.deepcopy(BulkOperationSerializer().fields['id']) 

83 _id.required = True 

84 fields['id'] = _id 

85 

86 return fields 

87 

88 

89class BulkPartialUpdateSchemaMixin(BulkUpdateSchemaMixin): 

90 def get_fields(self): 

91 fields = super().get_fields() 

92 

93 for name, field in fields.items(): 

94 if name != 'id': 

95 field.required = False 

96 

97 return fields 

98 

99 

100@functools.cache 

101def get_bulk_update_serializer_class(serializer_class, *, partial=False): 

102 """ 

103 Return a schema-only serializer for bulk PUT/PATCH requests. 

104 

105 Bulk update requests to a list endpoint require each object to include 

106 the target object's numeric ID, even though `id` is read-only on the 

107 normal model serializer. The runtime code consumes `id` before invoking 

108 the model serializer for each object. 

109 """ 

110 

111 meta = getattr(serializer_class, 'Meta') 

112 

113 if meta.fields == '__all__': 113 ↛ 114line 113 didn't jump to line 114 because the condition on line 113 was never true

114 fields = '__all__' 

115 else: 

116 fields = ('id', *[f for f in meta.fields if f != 'id']) 

117 

118 class Meta(meta): 

119 pass 

120 

121 # intentional; this is different than setting fields = fields within class Meta above 

122 Meta.fields = fields 

123 

124 bases = ( 

125 (BulkPartialUpdateSchemaMixin, serializer_class) 

126 if partial 

127 else (BulkUpdateSchemaMixin, serializer_class) 

128 ) 

129 

130 attrs = { 

131 'Meta': Meta, 

132 '__module__': serializer_class.__module__, 

133 } 

134 

135 prefix = 'PatchedBulk' if partial else 'Bulk' 

136 return type(f'{prefix}{serializer_class.__name__}', bases, attrs)