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..fba4961b0 100644 --- a/netbox/netbox/api/serializers/bulk.py +++ b/netbox/netbox/api/serializers/bulk.py @@ -1,11 +1,79 @@ +import copy +import functools + from rest_framework import serializers from .features import ChangeLogMessageSerializer __all__ = ( 'BulkOperationSerializer', + 'BulkPartialUpdateSchemaMixin', + 'BulkUpdateSchemaMixin', + 'get_bulk_update_serializer_class' ) 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() + + for name, field in fields.items(): + if name != 'id': + field.required = False + + return fields + + +@functools.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') + + 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 + else (BulkUpdateSchemaMixin, serializer_class) + ) + + attrs = { + 'Meta': Meta, + '__module__': serializer_class.__module__, + } + + prefix = 'PatchedBulk' if partial else 'Bulk' + return type(f'{prefix}{serializer_class.__name__}', bases, attrs) 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)