Преглед изворни кода

Merge pull request #22275 from jniec-js/main

Closes: #22245: Fix OpenAPI request schemas for bulk update endpoints
bctiemann пре 1 месец
родитељ
комит
c1d69ebae6
3 измењених фајлова са 102 додато и 3 уклоњено
  1. 19 3
      netbox/core/api/schema.py
  2. 68 0
      netbox/netbox/api/serializers/bulk.py
  3. 15 0
      netbox/netbox/api/viewsets/mixins.py

+ 19 - 3
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:

+ 68 - 0
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)

+ 15 - 0
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)