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
« 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
4from extras.api.customfields import CustomFieldDefaultValues, CustomFieldListSerializer, CustomFieldsDataField
6from .base import ValidatedModelSerializer
7from .nested import NestedTagSerializer
9__all__ = (
10 'ChangeLogMessageSerializer',
11 'CustomFieldModelSerializer',
12 'NetBoxModelSerializer',
13 'TaggableModelSerializer',
14)
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 )
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)
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)
59 def to_internal_value(self, data):
60 ret = super().to_internal_value(data)
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])
69 return ret
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)
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 })
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 })
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 })
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)
102 data = super().validate(data)
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
110 return data
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)
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
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)
129 # Cache tags on instance for change logging
130 instance._tags = tags or []
132 instance = super().update(instance, validated_data)
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()
143 return instance
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()
154 return instance
157class ChangeLogMessageSerializer(serializers.Serializer):
158 changelog_message = serializers.CharField(
159 write_only=True,
160 required=False,
161 )
163 def to_internal_value(self, data):
164 ret = super().to_internal_value(data)
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']
170 return ret
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)
178class NetBoxModelSerializer(
179 ChangeLogMessageSerializer,
180 TaggableModelSerializer,
181 CustomFieldModelSerializer,
182 ValidatedModelSerializer
183):
184 """
185 Adds support for custom fields and tags.
186 """
187 pass