Coverage for netbox/api/serializers/features.py: 76%

98 statements  

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

1from rest_framework import serializers 

2from rest_framework.fields import CreateOnlyDefault 

3 

4from extras.api.customfields import CustomFieldDefaultValues, CustomFieldListSerializer, CustomFieldsDataField 

5 

6from .base import ValidatedModelSerializer 

7from .nested import NestedTagSerializer 

8 

9__all__ = ( 

10 'ChangeLogMessageSerializer', 

11 'CustomFieldModelSerializer', 

12 'NetBoxModelSerializer', 

13 'TaggableModelSerializer', 

14) 

15 

16 

17class CustomFieldModelSerializer(serializers.Serializer): 

18 """ 

19 Introduces support for custom field assignment and representation. 

20 """ 

21 custom_fields = CustomFieldsDataField( 

22 source='custom_field_data', 

23 default=CreateOnlyDefault(CustomFieldDefaultValues()) 

24 ) 

25 

26 @classmethod 

27 def many_init(cls, *args, **kwargs): 

28 """ 

29 We can't call super().many_init() and change the outcome because by the time it returns, 

30 the plain ListSerializer is already instantiated. 

31 Because every NetBox serializer defines its own Meta which doesn't inherit from a parent Meta, 

32 this would silently not apply to any real serializer. 

33 Thats why this method replicates many_init from parent and changed the default value for list_serializer_class. 

34 """ 

35 list_kwargs = {} 

36 for key in serializers.LIST_SERIALIZER_KWARGS_REMOVE: 

37 value = kwargs.pop(key, None) 

38 if value is not None: 38 ↛ 39line 38 didn't jump to line 39 because the condition on line 38 was never true

39 list_kwargs[key] = value 

40 list_kwargs['child'] = cls(*args, **kwargs) 

41 list_kwargs.update({ 

42 key: value for key, value in kwargs.items() 

43 if key in serializers.LIST_SERIALIZER_KWARGS 

44 }) 

45 meta = getattr(cls, 'Meta', None) 

46 list_serializer_class = getattr(meta, 'list_serializer_class', CustomFieldListSerializer) 

47 return list_serializer_class(*args, **list_kwargs) 

48 

49 

50class TaggableModelSerializer(serializers.Serializer): 

51 """ 

52 Introduces support for Tag assignment. Adds `tags` serialization, and handles tag assignment 

53 on create() and update(). 

54 """ 

55 tags = NestedTagSerializer(many=True, required=False) 

56 add_tags = NestedTagSerializer(many=True, required=False, write_only=True) 

57 remove_tags = NestedTagSerializer(many=True, required=False, write_only=True) 

58 

59 def to_internal_value(self, data): 

60 ret = super().to_internal_value(data) 

61 

62 # Workaround to bypass requirement to include add_tags/remove_tags in Meta.fields on every serializer 

63 if type(data) is dict: 

64 tag_serializer = NestedTagSerializer(many=True) 

65 for field_name in ('add_tags', 'remove_tags'): 

66 if field_name in data: 66 ↛ 67line 66 didn't jump to line 67 because the condition on line 66 was never true

67 ret[field_name] = tag_serializer.to_internal_value(data[field_name]) 

68 

69 return ret 

70 

71 def validate(self, data): 

72 # Skip validation for nested serializer representations (e.g. when used as a related field) 

73 if type(data) is not dict: 

74 return super().validate(data) 

75 

76 if data.get('tags') and (data.get('add_tags') or data.get('remove_tags')): 76 ↛ 77line 76 didn't jump to line 77 because the condition on line 76 was never true

77 raise serializers.ValidationError({ 

78 'tags': 'Cannot specify "tags" together with "add_tags" or "remove_tags".' 

79 }) 

80 

81 if self.instance is None and data.get('remove_tags'): 81 ↛ 82line 81 didn't jump to line 82 because the condition on line 81 was never true

82 raise serializers.ValidationError({ 

83 'remove_tags': 'Cannot use "remove_tags" when creating a new object.' 

84 }) 

85 

86 if data.get('add_tags') and data.get('remove_tags'): 86 ↛ 87line 86 didn't jump to line 87 because the condition on line 86 was never true

87 add_pks = {t.pk for t in data['add_tags']} 

88 remove_pks = {t.pk for t in data['remove_tags']} 

89 overlap = [t for t in data['add_tags'] if t.pk in (add_pks & remove_pks)] 

90 if overlap: 

91 raise serializers.ValidationError({ 

92 'remove_tags': 

93 f'Tags may not be present in both "add_tags" and "remove_tags": ' 

94 f'{", ".join(t.name for t in overlap)}' 

95 }) 

96 

97 # Pop add_tags/remove_tags before calling super() to prevent them from being passed 

98 # to the model constructor during ValidatedModelSerializer validation 

99 add_tags = data.pop('add_tags', None) 

100 remove_tags = data.pop('remove_tags', None) 

101 

102 data = super().validate(data) 

103 

104 # Restore for use in create()/update() 

105 if add_tags is not None: 105 ↛ 106line 105 didn't jump to line 106 because the condition on line 105 was never true

106 data['add_tags'] = add_tags 

107 if remove_tags is not None: 107 ↛ 108line 107 didn't jump to line 108 because the condition on line 107 was never true

108 data['remove_tags'] = remove_tags 

109 

110 return data 

111 

112 def create(self, validated_data): 

113 tags = validated_data.pop('tags', None) 

114 add_tags = validated_data.pop('add_tags', None) 

115 validated_data.pop('remove_tags', None) 

116 instance = super().create(validated_data) 

117 

118 if tags is not None: 

119 return self._save_tags(instance, tags) 

120 if add_tags is not None: 120 ↛ 121line 120 didn't jump to line 121 because the condition on line 120 was never true

121 instance.tags.add(*[t.name for t in add_tags]) 

122 return instance 

123 

124 def update(self, instance, validated_data): 

125 tags = validated_data.pop('tags', None) 

126 add_tags = validated_data.pop('add_tags', None) 

127 remove_tags = validated_data.pop('remove_tags', None) 

128 

129 # Cache tags on instance for change logging 

130 instance._tags = tags or [] 

131 

132 instance = super().update(instance, validated_data) 

133 

134 if tags is not None: 

135 return self._save_tags(instance, tags) 

136 if add_tags is not None: 136 ↛ 137line 136 didn't jump to line 137 because the condition on line 136 was never true

137 instance.tags.add(*[t.name for t in add_tags]) 

138 if remove_tags is not None: 138 ↛ 139line 138 didn't jump to line 139 because the condition on line 138 was never true

139 instance.tags.remove(*[t.name for t in remove_tags]) 

140 if add_tags is not None or remove_tags is not None: 140 ↛ 141line 140 didn't jump to line 141 because the condition on line 140 was never true

141 instance._tags = instance.tags.all() 

142 

143 return instance 

144 

145 def _save_tags(self, instance, tags): 

146 if tags: 146 ↛ 148line 146 didn't jump to line 148 because the condition on line 146 was never true

147 # Cache tags on instance so serialize_object() can reuse them without a DB query 

148 instance._tags = tags 

149 instance.tags.set([t.name for t in tags]) 

150 else: 

151 instance._tags = [] 

152 instance.tags.clear() 

153 

154 return instance 

155 

156 

157class ChangeLogMessageSerializer(serializers.Serializer): 

158 changelog_message = serializers.CharField( 

159 write_only=True, 

160 required=False, 

161 ) 

162 

163 def to_internal_value(self, data): 

164 ret = super().to_internal_value(data) 

165 

166 # Workaround to bypass requirement to include changelog_message in Meta.fields on every serializer 

167 if type(data) is dict and 'changelog_message' in data: 167 ↛ 168line 167 didn't jump to line 168 because the condition on line 167 was never true

168 ret['changelog_message'] = data['changelog_message'] 

169 

170 return ret 

171 

172 def save(self, **kwargs): 

173 if self.instance is not None: 

174 self.instance._changelog_message = self.validated_data.get('changelog_message') 

175 return super().save(**kwargs) 

176 

177 

178class NetBoxModelSerializer( 

179 ChangeLogMessageSerializer, 

180 TaggableModelSerializer, 

181 CustomFieldModelSerializer, 

182 ValidatedModelSerializer 

183): 

184 """ 

185 Adds support for custom fields and tags. 

186 """ 

187 pass