From 396a9a6ebeda4926d5a26757843a0e009b89bfb7 Mon Sep 17 00:00:00 2001 From: Josh Niec Date: Fri, 22 May 2026 13:09:22 -0400 Subject: [PATCH 1/7] fix: make id field required in bulk patch/put open api schema --- netbox/core/api/schema.py | 22 ++++++++++-- netbox/netbox/api/serializers/bulk.py | 50 ++++++++++++++++++++++++++- netbox/netbox/api/viewsets/mixins.py | 15 ++++++++ 3 files changed, 83 insertions(+), 4 deletions(-) diff --git a/netbox/core/api/schema.py b/netbox/core/api/schema.py index 5ceb3c61d..1105ca11a 100644 --- a/netbox/core/api/schema.py +++ b/netbox/core/api/schema.py @@ -138,14 +138,30 @@ class NetBoxAutoSchema(AutoSchema): return super().get_operation_id() def get_request_serializer(self) -> typing.Any: - # bulk operations should specify a list serializer = super().get_request_serializer() + # Bulk update/partial-update has a special request shape: a list of + # writable objects plus a required `id` field. The normal writable + # serializer omits `id` because it is read-only, so don't use the generic + # bulk handling for these actions. + action = getattr(self.view, 'action', None) + if action in ('bulk_update', 'bulk_partial_update'): + get_bulk_update_request_serializer = getattr( + self.view, + 'get_bulk_update_request_serializer', + None, + ) + if get_bulk_update_request_serializer is not None: + return get_bulk_update_request_serializer( + partial=(action == 'bulk_partial_update' or self.method == 'PATCH') + ) + + # Bulk creates/deletes should specify a list. if self.is_bulk_action: return type(serializer)(many=True) - # handle mapping for Writable serializers - adapted from dansheps original code - # for drf-yasg + # handle mapping for Writable serializers - adapted from dansheps original + # code for drf-yasg. if serializer is not None and self.method in WRITABLE_ACTIONS: writable_class = self.get_writable_class(serializer) if writable_class is not None: diff --git a/netbox/netbox/api/serializers/bulk.py b/netbox/netbox/api/serializers/bulk.py index c9fd28534..e8c46be1f 100644 --- a/netbox/netbox/api/serializers/bulk.py +++ b/netbox/netbox/api/serializers/bulk.py @@ -1,11 +1,59 @@ +from functools import lru_cache + +from rest_framework import serializers from rest_framework import serializers from .features import ChangeLogMessageSerializer __all__ = ( - 'BulkOperationSerializer', + 'BulkOperationSerializer', + 'BulkPartialUpdateSchemaMixin', + 'get_bulk_update_serializer_class' ) +class BulkPartialUpdateSchemaMixin: + def get_fields(self): + fields = super().get_fields() + fields['id'] = serializers.IntegerField(required=True) + + for name, field in fields.items(): + if name != 'id': + field.required = False + + return fields + + +@lru_cache +def get_bulk_update_serializer_class(serializer_class, *, partial=False): + """ + Return a schema-only serializer for bulk PUT/PATCH requests. + + Bulk update requests to a list endpoint require each object to include + the target object's numeric ID, even though `id` is read-only on the + normal model serializer. The runtime code consumes `id` before invoking + the model serializer for each object. + """ + meta = getattr(serializer_class, 'Meta') + + class Meta(meta): + fields = ('id', *[f for f in meta.fields if f != 'id']) + + bases = ( + (BulkPartialUpdateSchemaMixin, serializer_class) + if partial + else (serializer_class,) + ) + + attrs = { + 'id': serializers.IntegerField(required=True), + 'Meta': Meta, + '__module__': serializer_class.__module__, + } + + prefix = 'PatchedBulk' if partial else 'Bulk' + return type(f'{prefix}{serializer_class.__name__}', bases, attrs) + + class BulkOperationSerializer(ChangeLogMessageSerializer): id = serializers.IntegerField() diff --git a/netbox/netbox/api/viewsets/mixins.py b/netbox/netbox/api/viewsets/mixins.py index fd49bd7af..7caf38480 100644 --- a/netbox/netbox/api/viewsets/mixins.py +++ b/netbox/netbox/api/viewsets/mixins.py @@ -7,6 +7,7 @@ from rest_framework.response import Response from core.models import ObjectType from extras.models import ExportTemplate from netbox.api.serializers import BulkOperationSerializer +from netbox.api.serializers.bulk import get_bulk_update_serializer_class __all__ = ( 'BulkDestroyModelMixin', @@ -133,6 +134,20 @@ class BulkUpdateModelMixin: return updated_pks + def get_bulk_update_serializer_class(self, *, partial=False): + return get_bulk_update_serializer_class( + self.get_serializer_class(), + partial=partial, + ) + + def get_bulk_update_request_serializer(self, *, partial=False): + serializer_class = self.get_bulk_update_serializer_class(partial=partial) + + # Important: do NOT pass partial=True here. The partial schema class already + # makes non-id fields optional, and passing partial=True would also make id + # appear optional in OpenAPI. + return serializer_class(many=True) + def bulk_partial_update(self, request, *args, **kwargs): kwargs['partial'] = True return self.bulk_update(request, *args, **kwargs) From cfdf22fc185820726f23cb218911288a60757bba Mon Sep 17 00:00:00 2001 From: Josh Niec Date: Fri, 22 May 2026 14:08:05 -0400 Subject: [PATCH 2/7] fix: linting --- netbox/netbox/api/serializers/bulk.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/netbox/netbox/api/serializers/bulk.py b/netbox/netbox/api/serializers/bulk.py index e8c46be1f..5817155e7 100644 --- a/netbox/netbox/api/serializers/bulk.py +++ b/netbox/netbox/api/serializers/bulk.py @@ -1,12 +1,11 @@ from functools import lru_cache -from rest_framework import serializers from rest_framework import serializers from .features import ChangeLogMessageSerializer __all__ = ( - 'BulkOperationSerializer', + 'BulkOperationSerializer', 'BulkPartialUpdateSchemaMixin', 'get_bulk_update_serializer_class' ) From 208dd9b05bc6cc4da4a7bc446ba0529dfd3a661b Mon Sep 17 00:00:00 2001 From: Josh Niec Date: Tue, 2 Jun 2026 18:28:45 -0400 Subject: [PATCH 3/7] fix: address pr comments --- netbox/netbox/api/serializers/bulk.py | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/netbox/netbox/api/serializers/bulk.py b/netbox/netbox/api/serializers/bulk.py index 5817155e7..829d741c5 100644 --- a/netbox/netbox/api/serializers/bulk.py +++ b/netbox/netbox/api/serializers/bulk.py @@ -1,3 +1,4 @@ +import copy from functools import lru_cache from rest_framework import serializers @@ -11,10 +12,25 @@ __all__ = ( ) -class BulkPartialUpdateSchemaMixin: +class BulkOperationSerializer(ChangeLogMessageSerializer): + id = serializers.IntegerField() + + +class BulkUpdateSchemaMixin: + def get_fields(self): + fields = super().get_fields() + # Reuse the runtime bulk-operation ID field so the schema stays in sync + # with the validator that consumes `id` before model serialization. + _id = copy.deepcopy(BulkOperationSerializer().fields['id']) + _id.required = True + fields['id'] = _id + + return fields + + +class BulkPartialUpdateSchemaMixin(BulkUpdateSchemaMixin): def get_fields(self): fields = super().get_fields() - fields['id'] = serializers.IntegerField(required=True) for name, field in fields.items(): if name != 'id': @@ -33,6 +49,7 @@ def get_bulk_update_serializer_class(serializer_class, *, partial=False): normal model serializer. The runtime code consumes `id` before invoking the model serializer for each object. """ + meta = getattr(serializer_class, 'Meta') class Meta(meta): @@ -41,11 +58,10 @@ def get_bulk_update_serializer_class(serializer_class, *, partial=False): bases = ( (BulkPartialUpdateSchemaMixin, serializer_class) if partial - else (serializer_class,) + else (BulkUpdateSchemaMixin, serializer_class) ) attrs = { - 'id': serializers.IntegerField(required=True), 'Meta': Meta, '__module__': serializer_class.__module__, } @@ -54,5 +70,4 @@ def get_bulk_update_serializer_class(serializer_class, *, partial=False): return type(f'{prefix}{serializer_class.__name__}', bases, attrs) -class BulkOperationSerializer(ChangeLogMessageSerializer): - id = serializers.IntegerField() + From 1597f1bd7dc4e573b90af6ed0a087b565c4dd03d Mon Sep 17 00:00:00 2001 From: Josh Niec Date: Tue, 2 Jun 2026 18:30:10 -0400 Subject: [PATCH 4/7] fix: linting --- netbox/netbox/api/serializers/bulk.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/netbox/netbox/api/serializers/bulk.py b/netbox/netbox/api/serializers/bulk.py index 829d741c5..d6c1dda4b 100644 --- a/netbox/netbox/api/serializers/bulk.py +++ b/netbox/netbox/api/serializers/bulk.py @@ -68,6 +68,3 @@ def get_bulk_update_serializer_class(serializer_class, *, partial=False): prefix = 'PatchedBulk' if partial else 'Bulk' return type(f'{prefix}{serializer_class.__name__}', bases, attrs) - - - From c264b42abcf9b2d22b0d954e0dd24ca5f31692a5 Mon Sep 17 00:00:00 2001 From: Josh Niec Date: Tue, 2 Jun 2026 18:37:24 -0400 Subject: [PATCH 5/7] fix: avoid problem when fields is set to '__all__' --- netbox/netbox/api/serializers/bulk.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/netbox/netbox/api/serializers/bulk.py b/netbox/netbox/api/serializers/bulk.py index d6c1dda4b..1138eee9f 100644 --- a/netbox/netbox/api/serializers/bulk.py +++ b/netbox/netbox/api/serializers/bulk.py @@ -52,9 +52,17 @@ def get_bulk_update_serializer_class(serializer_class, *, partial=False): meta = getattr(serializer_class, 'Meta') - class Meta(meta): + if meta.fields == '__all__': + fields = '__all__' + else: fields = ('id', *[f for f in meta.fields if f != 'id']) + class Meta(meta): + pass + + # intentional; this is different than setting fields = fields within class Meta above + Meta.fields = fields + bases = ( (BulkPartialUpdateSchemaMixin, serializer_class) if partial From 56ac8030b81bfbfb8c100b9bfa610b7a7d17bd54 Mon Sep 17 00:00:00 2001 From: Josh Niec Date: Tue, 2 Jun 2026 19:48:37 -0400 Subject: [PATCH 6/7] fix: address pr comments --- netbox/netbox/api/serializers/bulk.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/netbox/netbox/api/serializers/bulk.py b/netbox/netbox/api/serializers/bulk.py index 1138eee9f..706df0e97 100644 --- a/netbox/netbox/api/serializers/bulk.py +++ b/netbox/netbox/api/serializers/bulk.py @@ -7,6 +7,7 @@ from .features import ChangeLogMessageSerializer __all__ = ( 'BulkOperationSerializer', + 'BulkUpdateSchemaMixin', 'BulkPartialUpdateSchemaMixin', 'get_bulk_update_serializer_class' ) @@ -39,7 +40,7 @@ class BulkPartialUpdateSchemaMixin(BulkUpdateSchemaMixin): return fields -@lru_cache +@lru_cache(maxsize=None) def get_bulk_update_serializer_class(serializer_class, *, partial=False): """ Return a schema-only serializer for bulk PUT/PATCH requests. From c3d8b14a3d1332847f25fc6898f698304cffaa14 Mon Sep 17 00:00:00 2001 From: Josh Niec Date: Tue, 2 Jun 2026 19:51:42 -0400 Subject: [PATCH 7/7] fix: address pr comments --- netbox/netbox/api/serializers/bulk.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/netbox/netbox/api/serializers/bulk.py b/netbox/netbox/api/serializers/bulk.py index 706df0e97..fba4961b0 100644 --- a/netbox/netbox/api/serializers/bulk.py +++ b/netbox/netbox/api/serializers/bulk.py @@ -1,5 +1,5 @@ import copy -from functools import lru_cache +import functools from rest_framework import serializers @@ -7,8 +7,8 @@ from .features import ChangeLogMessageSerializer __all__ = ( 'BulkOperationSerializer', - 'BulkUpdateSchemaMixin', 'BulkPartialUpdateSchemaMixin', + 'BulkUpdateSchemaMixin', 'get_bulk_update_serializer_class' ) @@ -40,7 +40,7 @@ class BulkPartialUpdateSchemaMixin(BulkUpdateSchemaMixin): return fields -@lru_cache(maxsize=None) +@functools.cache def get_bulk_update_serializer_class(serializer_class, *, partial=False): """ Return a schema-only serializer for bulk PUT/PATCH requests.