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
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
1import copy
2import functools
4from django.utils.translation import gettext_lazy as _
5from rest_framework import serializers
7from .features import ChangeLogMessageSerializer
9__all__ = (
10 'BulkOperationEntryErrorSerializer',
11 'BulkOperationErrorSerializer',
12 'BulkOperationSerializer',
13 'BulkPartialUpdateSchemaMixin',
14 'BulkUpdateSchemaMixin',
15 'get_bulk_update_serializer_class'
16)
19class BulkOperationSerializer(ChangeLogMessageSerializer):
20 id = serializers.IntegerField()
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 )
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 )
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
86 return fields
89class BulkPartialUpdateSchemaMixin(BulkUpdateSchemaMixin):
90 def get_fields(self):
91 fields = super().get_fields()
93 for name, field in fields.items():
94 if name != 'id':
95 field.required = False
97 return fields
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.
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 """
111 meta = getattr(serializer_class, 'Meta')
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'])
118 class Meta(meta):
119 pass
121 # intentional; this is different than setting fields = fields within class Meta above
122 Meta.fields = fields
124 bases = (
125 (BulkPartialUpdateSchemaMixin, serializer_class)
126 if partial
127 else (BulkUpdateSchemaMixin, serializer_class)
128 )
130 attrs = {
131 'Meta': Meta,
132 '__module__': serializer_class.__module__,
133 }
135 prefix = 'PatchedBulk' if partial else 'Bulk'
136 return type(f'{prefix}{serializer_class.__name__}', bases, attrs)