diff --git a/rest_framework/schemas/inspectors.py b/rest_framework/schemas/inspectors.py index e027b46a70..56a857394e 100644 --- a/rest_framework/schemas/inspectors.py +++ b/rest_framework/schemas/inspectors.py @@ -3,6 +3,7 @@ See schemas.__init__.py for package overview. """ +import copy import re from weakref import WeakKeyDictionary @@ -48,9 +49,15 @@ def __get__(self, instance, owner): return self def __set__(self, instance, other): - self.instance_schemas[instance] = other if other is not None: + # A single `ViewInspector` instance may be shared by multiple + # views, e.g. when `@action(schema=AutoSchema())` is used on a + # method defined in a mixin and reused across several ViewSets. + # Store an independent copy per view so that `other.view` isn't + # silently overwritten by whichever view is set last. + other = copy.copy(other) other.view = instance + self.instance_schemas[instance] = other @property def view(self): diff --git a/tests/schemas/test_view_inspector.py b/tests/schemas/test_view_inspector.py new file mode 100644 index 0000000000..7ef0cf42de --- /dev/null +++ b/tests/schemas/test_view_inspector.py @@ -0,0 +1,52 @@ +import pytest +from django.test import TestCase + +from rest_framework.compat import uritemplate +from rest_framework.decorators import action +from rest_framework.response import Response +from rest_framework.routers import DefaultRouter +from rest_framework.schemas.openapi import AutoSchema, SchemaGenerator +from rest_framework.serializers import Serializer +from rest_framework.viewsets import GenericViewSet + + +class TestViewInspectorDescriptor(TestCase): + """ + Regression tests for #6877: a `ViewInspector` (e.g. `AutoSchema`) + instance shared across multiple views, such as one assigned via + `@action(schema=AutoSchema())` on a method defined in a mixin, must + not leak the `view` it was last accessed with onto other views. + """ + @pytest.mark.skipif(uritemplate is None, reason='uritemplate not installed.') + def test_schema_shared_via_mixin_action_is_not_shared_between_viewsets(self): + shared_schema = AutoSchema() + + class CancelViewSetMixin: + @action(methods=['post'], detail=True, schema=shared_schema) + def cancel(self, request, pk): + return Response() + + class ASerializer(Serializer): + pass + + class BSerializer(Serializer): + pass + + class ViewSetA(CancelViewSetMixin, GenericViewSet): + serializer_class = ASerializer + + class ViewSetB(CancelViewSetMixin, GenericViewSet): + serializer_class = BSerializer + + router = DefaultRouter() + router.register(r'view-set-a', ViewSetA, basename='viewseta') + router.register(r'view-set-b', ViewSetB, basename='viewsetb') + + generator = SchemaGenerator(title='Test', patterns=router.urls) + schema = generator.get_schema(request=None, public=True) + + operation_id_a = schema['paths']['/view-set-a/{id}/cancel/']['post']['operationId'] + operation_id_b = schema['paths']['/view-set-b/{id}/cancel/']['post']['operationId'] + + assert operation_id_a == 'cancelA' + assert operation_id_b == 'cancelB'