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)