From d5ef9378098486730348cc1b5309c7072b253e97 Mon Sep 17 00:00:00 2001 From: Muhammad Faraz Maqsood Date: Wed, 16 Sep 2026 10:17:11 +0500 Subject: [PATCH 01/12] refactor: migrate direct drf-yasg usage to drf-spectacular Converts @swagger_auto_schema to @extend_schema in the five modules that import drf_yasg directly, following the drf-spectacular migration guide: https://drf-spectacular.readthedocs.io/en/latest/drf_yasg.html Structured openapi.Schema objects become inline_serializer so they get named components in the generated schema; untyped ones become OpenApiTypes.OBJECT. openapi.Parameter becomes OpenApiParameter. The other four drf_yasg users go through edx-api-doc-tools and will be migrated with it. --- lms/djangoapps/support/rest_api/v1/views.py | 29 +++---- .../content_libraries/rest_api/containers.py | 87 ++++++++----------- .../djangoapps/user_api/accounts/views.py | 17 ++-- openedx/core/djangoapps/user_api/views.py | 24 +++-- .../core/djangoapps/user_authn/views/login.py | 35 ++++---- 5 files changed, 87 insertions(+), 105 deletions(-) diff --git a/lms/djangoapps/support/rest_api/v1/views.py b/lms/djangoapps/support/rest_api/v1/views.py index ef0d1f68bd0b..4469adec0bea 100644 --- a/lms/djangoapps/support/rest_api/v1/views.py +++ b/lms/djangoapps/support/rest_api/v1/views.py @@ -5,6 +5,8 @@ from django.contrib.auth import get_user_model from django.core.exceptions import ObjectDoesNotExist from django.db.models import Q +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, extend_schema from opaque_keys.edx.keys import CourseKey from rest_framework import status from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError @@ -163,30 +165,27 @@ def list(self, request, *args, **kwargs): serializer = self.get_serializer(queryset, many=True) return Response(serializer.data) - from drf_yasg import openapi - from drf_yasg.utils import swagger_auto_schema - - @swagger_auto_schema( - manual_parameters=[ - openapi.Parameter( + @extend_schema( + parameters=[ + OpenApiParameter( "email", - openapi.IN_QUERY, + OpenApiTypes.STR, + OpenApiParameter.QUERY, description="User's email address", - type=openapi.TYPE_STRING, ), - openapi.Parameter( + OpenApiParameter( "username", - openapi.IN_QUERY, + OpenApiTypes.STR, + OpenApiParameter.QUERY, description="User's username", - type=openapi.TYPE_STRING, ), - openapi.Parameter( + OpenApiParameter( "user_id", - openapi.IN_QUERY, + OpenApiTypes.INT, + OpenApiParameter.QUERY, description="User's ID", - type=openapi.TYPE_INTEGER, ), - ] + ], ) def get(self, request, *args, **kwargs): """ diff --git a/openedx/core/djangoapps/content_libraries/rest_api/containers.py b/openedx/core/djangoapps/content_libraries/rest_api/containers.py index 6a63a723ed3a..e415620e59fd 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/containers.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/containers.py @@ -8,12 +8,13 @@ from django.contrib.auth import get_user_model from django.db.transaction import non_atomic_requests from django.utils.decorators import method_decorator -from drf_yasg import openapi -from drf_yasg.utils import swagger_auto_schema +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiResponse, extend_schema, inline_serializer from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2 from openedx_authz.constants import permissions as authz_permissions from openedx_content import api as content_api from openedx_content import models_api as content_models +from rest_framework import serializers as drf_serializers from rest_framework.generics import GenericAPIView from rest_framework.response import Response from rest_framework.status import HTTP_200_OK, HTTP_204_NO_CONTENT @@ -39,9 +40,9 @@ class LibraryContainersView(GenericAPIView): serializer_class = serializers.LibraryContainerMetadataSerializer @convert_exceptions - @swagger_auto_schema( - request_body=serializers.LibraryContainerMetadataSerializer, - responses={200: serializers.LibraryContainerMetadataSerializer} + @extend_schema( + request=serializers.LibraryContainerMetadataSerializer, + responses={200: serializers.LibraryContainerMetadataSerializer}, ) def post(self, request, lib_key_str): """ @@ -73,8 +74,8 @@ class LibraryContainerView(GenericAPIView): serializer_class = serializers.LibraryContainerMetadataSerializer @convert_exceptions - @swagger_auto_schema( - responses={200: serializers.LibraryContainerMetadataSerializer} + @extend_schema( + responses={200: serializers.LibraryContainerMetadataSerializer}, ) def get(self, request, container_key: LibraryContainerLocator): """ @@ -89,9 +90,9 @@ def get(self, request, container_key: LibraryContainerLocator): return Response(serializers.LibraryContainerMetadataSerializer(container).data) @convert_exceptions - @swagger_auto_schema( - request_body=serializers.LibraryContainerUpdateSerializer, - responses={200: serializers.LibraryContainerMetadataSerializer} + @extend_schema( + request=serializers.LibraryContainerUpdateSerializer, + responses={200: serializers.LibraryContainerMetadataSerializer}, ) def patch(self, request, container_key: LibraryContainerLocator): """ @@ -140,10 +141,10 @@ class LibraryContainerChildrenView(GenericAPIView): serializer_class = serializers.LibraryXBlockMetadataSerializer @convert_exceptions - @swagger_auto_schema( + @extend_schema( responses={ HTTP_200_OK: serializers.UnionLibraryMetadataSerializer() - } + }, ) def get(self, request, container_key: LibraryContainerLocator): """ @@ -223,9 +224,9 @@ def _update_component_children( return Response(serializers.LibraryContainerMetadataSerializer(container).data) @convert_exceptions - @swagger_auto_schema( - request_body=serializers.ContentLibraryItemContainerKeysSerializer, - responses={200: serializers.LibraryContainerMetadataSerializer} + @extend_schema( + request=serializers.ContentLibraryItemContainerKeysSerializer, + responses={200: serializers.LibraryContainerMetadataSerializer}, ) def post(self, request, container_key: LibraryContainerLocator): """ @@ -242,9 +243,9 @@ def post(self, request, container_key: LibraryContainerLocator): ) @convert_exceptions - @swagger_auto_schema( - request_body=serializers.ContentLibraryItemContainerKeysSerializer, - responses={200: serializers.LibraryContainerMetadataSerializer} + @extend_schema( + request=serializers.ContentLibraryItemContainerKeysSerializer, + responses={200: serializers.LibraryContainerMetadataSerializer}, ) def delete(self, request, container_key: LibraryContainerLocator): """ @@ -261,9 +262,9 @@ def delete(self, request, container_key: LibraryContainerLocator): ) @convert_exceptions - @swagger_auto_schema( - request_body=serializers.ContentLibraryItemContainerKeysSerializer, - responses={200: serializers.LibraryContainerMetadataSerializer} + @extend_schema( + request=serializers.ContentLibraryItemContainerKeysSerializer, + responses={200: serializers.LibraryContainerMetadataSerializer}, ) def patch(self, request, container_key: LibraryContainerLocator): """ @@ -288,13 +289,11 @@ class LibraryContainerRestore(GenericAPIView): """ @convert_exceptions - @swagger_auto_schema( - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - ), + @extend_schema( + request=OpenApiTypes.OBJECT, responses={ - HTTP_204_NO_CONTENT: "No content" - } + HTTP_204_NO_CONTENT: OpenApiResponse(description="No content"), + }, ) def post(self, request, container_key: LibraryContainerLocator) -> Response: """ @@ -317,18 +316,14 @@ class LibraryContainerCollectionsView(GenericAPIView): """ @convert_exceptions - @swagger_auto_schema( - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - ), + @extend_schema( + request=OpenApiTypes.OBJECT, responses={ - HTTP_200_OK: openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - 'count': openapi.Schema(type=openapi.TYPE_INTEGER) - } - ) - } + HTTP_200_OK: inline_serializer( + name="ContainerCollectionsUpdateCount", + fields={"count": drf_serializers.IntegerField()}, + ), + }, ) def patch(self, request: RestRequest, container_key: LibraryContainerLocator) -> Response: """ @@ -364,15 +359,9 @@ class LibraryContainerPublishView(GenericAPIView): """ @convert_exceptions - @swagger_auto_schema( - request_body=openapi.Schema( - type=openapi.TYPE_OBJECT, - ), - responses={ - HTTP_200_OK: openapi.Schema( - type=openapi.TYPE_OBJECT, - ) - } + @extend_schema( + request=OpenApiTypes.OBJECT, + responses={HTTP_200_OK: OpenApiTypes.OBJECT}, ) def post(self, request: RestRequest, container_key: LibraryContainerLocator) -> Response: """ @@ -423,8 +412,8 @@ class LibraryContainerHierarchy(GenericAPIView): serializer_class = serializers.ContainerHierarchySerializer @convert_exceptions - @swagger_auto_schema( - responses={200: serializers.ContainerHierarchySerializer} + @extend_schema( + responses={200: serializers.ContainerHierarchySerializer}, ) def get(self, request: RestRequest, container_key: LibraryContainerLocator) -> Response: """ diff --git a/openedx/core/djangoapps/user_api/accounts/views.py b/openedx/core/djangoapps/user_api/accounts/views.py index 958a4a587550..61c7fb3c4b47 100644 --- a/openedx/core/djangoapps/user_api/accounts/views.py +++ b/openedx/core/djangoapps/user_api/accounts/views.py @@ -18,13 +18,12 @@ from django.db import transaction from django.utils.translation import gettext as _ from django_ratelimit.core import is_ratelimited -from drf_yasg import openapi -from drf_yasg.utils import swagger_auto_schema +from drf_spectacular.utils import OpenApiResponse, extend_schema, inline_serializer from edx_ace import ace from edx_ace.recipient import Recipient from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser -from rest_framework import permissions, status +from rest_framework import permissions, serializers, status from rest_framework.authentication import SessionAuthentication from rest_framework.exceptions import UnsupportedMediaType from rest_framework.parsers import JSONParser @@ -130,10 +129,10 @@ def wrapper(self, request): # pylint: disable=missing-docstring return wrapper -account_get_me_return_schema = openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - "username": openapi.Schema(type=openapi.TYPE_STRING), +account_get_me_return_schema = inline_serializer( + name="AccountGetMe", + fields={ + "username": serializers.CharField(), }, ) @@ -154,10 +153,10 @@ class AccountViewSet(ViewSet): ) account_user_get_responses = { status.HTTP_200_OK: account_get_me_return_schema, - status.HTTP_401_UNAUTHORIZED: "", + status.HTTP_401_UNAUTHORIZED: OpenApiResponse(description=""), } - @swagger_auto_schema( + @extend_schema( responses=account_user_get_responses, ) def get(self, request): diff --git a/openedx/core/djangoapps/user_api/views.py b/openedx/core/djangoapps/user_api/views.py index 343441fc0d67..e72b5789d334 100644 --- a/openedx/core/djangoapps/user_api/views.py +++ b/openedx/core/djangoapps/user_api/views.py @@ -6,13 +6,12 @@ from django.utils.decorators import method_decorator from django.views.decorators.csrf import ensure_csrf_cookie from django_filters.rest_framework import DjangoFilterBackend -from drf_yasg import openapi -from drf_yasg.utils import swagger_auto_schema +from drf_spectacular.utils import OpenApiResponse, extend_schema, inline_serializer from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser from opaque_keys import InvalidKeyError from opaque_keys.edx import locator from opaque_keys.edx.keys import CourseKey -from rest_framework import generics, status, viewsets +from rest_framework import generics, serializers, status, viewsets from rest_framework.exceptions import ParseError from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response @@ -167,13 +166,12 @@ def get_queryset(self): return get_country_time_zones(country_code) -third_party_auth_error_message_schema = openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - "user_message": openapi.Schema( - type=openapi.TYPE_STRING, - x_nullable=True, - description=( +third_party_auth_error_message_schema = inline_serializer( + name="ThirdPartyAuthErrorMessage", + fields={ + "user_message": serializers.CharField( + allow_null=True, + help_text=( "Human-readable, translated message describing the pending " "third-party-auth error, or null if there is none pending." ), @@ -200,11 +198,11 @@ class ThirdPartyAuthErrorMessageView(APIView): authentication_classes = (SessionAuthenticationAllowInactiveUser,) permission_classes = (IsAuthenticated,) - @swagger_auto_schema( + @extend_schema( responses={ status.HTTP_200_OK: third_party_auth_error_message_schema, - status.HTTP_401_UNAUTHORIZED: "", - status.HTTP_403_FORBIDDEN: "", + status.HTTP_401_UNAUTHORIZED: OpenApiResponse(description=""), + status.HTTP_403_FORBIDDEN: OpenApiResponse(description=""), }, ) def get(self, request): diff --git a/openedx/core/djangoapps/user_authn/views/login.py b/openedx/core/djangoapps/user_authn/views/login.py index 31655f57fb54..9792e75b579d 100644 --- a/openedx/core/djangoapps/user_authn/views/login.py +++ b/openedx/core/djangoapps/user_authn/views/login.py @@ -21,15 +21,14 @@ from django.views.decorators.debug import sensitive_post_parameters from django.views.decorators.http import require_http_methods from django_ratelimit.decorators import ratelimit -from drf_yasg import openapi -from drf_yasg.utils import swagger_auto_schema +from drf_spectacular.utils import extend_schema, inline_serializer from edx_django_utils.monitoring import set_custom_attribute from eventtracking import tracker from openedx_events.learning.data import UserData, UserPersonalData from openedx_events.learning.signals import SESSION_LOGIN_COMPLETED from openedx_filters.authentication.filters import LoginAltRedirectURLRequested from openedx_filters.learning.filters import StudentLoginRequested -from rest_framework import status +from rest_framework import serializers, status from rest_framework.views import APIView from common.djangoapps import third_party_auth @@ -733,20 +732,20 @@ def redirect_to_lms_login(request): return redirect("/login?next=/admin") -login_user_schema = openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - "email": openapi.Schema(type=openapi.TYPE_STRING), - "password": openapi.Schema(type=openapi.TYPE_STRING), +login_user_schema = inline_serializer( + name="LoginUserRequest", + fields={ + "email": serializers.CharField(), + "password": serializers.CharField(), }, ) -login_user_return_schema = openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - "success": openapi.Schema(type=openapi.TYPE_BOOLEAN), - "value": openapi.Schema(type=openapi.TYPE_STRING), - "error_code": openapi.Schema(type=openapi.TYPE_STRING), +login_user_return_schema = inline_serializer( + name="LoginUserResponse", + fields={ + "success": serializers.BooleanField(), + "value": serializers.CharField(), + "error_code": serializers.CharField(), }, ) @@ -768,12 +767,10 @@ class LoginSessionView(APIView): def get(self, request, *args, **kwargs): return HttpResponse(get_login_session_form(request).to_json(), content_type="application/json") # pylint: disable=http-response-with-content-type-json - @swagger_auto_schema( - request_body=login_user_schema, + @extend_schema( + request=login_user_schema, responses=login_user_responses, - security=[ - {"csrf": []}, - ], + auth=[{"csrf": []}], ) @method_decorator(csrf_protect) def post(self, request, api_version): From 79386473188e54219cc21212bec63bb54208eaa9 Mon Sep 17 00:00:00 2001 From: Muhammad Faraz Maqsood Date: Wed, 16 Sep 2026 10:49:46 +0500 Subject: [PATCH 02/12] refactor: migrate openedx/core from edx-api-doc-tools to drf-spectacular Converts @apidocs.schema to @extend_schema across openedx/core, replacing the parameter helpers with OpenApiParameter and string response descriptions with OpenApiResponse. bookmarks/serializers.py inlines is_schema_request, which has no drf-spectacular equivalent, and extends it to recognise drf-spectacular's swagger_fake_view alongside drf-yasg's format=openapi. --- openedx/core/djangoapps/agreements/views.py | 77 +++++++--------- .../core/djangoapps/bookmarks/serializers.py | 15 +++- openedx/core/djangoapps/bookmarks/views.py | 19 ++-- .../content_libraries/rest_api/blocks.py | 22 ++--- .../content_libraries/rest_api/libraries.py | 88 +++++++++++-------- .../core/djangoapps/content_staging/views.py | 14 +-- .../course_apps/rest_api/v1/views.py | 23 ++--- .../course_groups/rest_api/views.py | 42 +++++---- openedx/core/djangoapps/course_live/views.py | 57 ++++++------ openedx/core/djangoapps/discussions/views.py | 36 ++++---- 10 files changed, 210 insertions(+), 183 deletions(-) diff --git a/openedx/core/djangoapps/agreements/views.py b/openedx/core/djangoapps/agreements/views.py index 694d424b5326..aeee03d25ec0 100644 --- a/openedx/core/djangoapps/agreements/views.py +++ b/openedx/core/djangoapps/agreements/views.py @@ -2,9 +2,9 @@ Views served by the Agreements app """ -import edx_api_doc_tools as apidocs from django.conf import settings -from drf_yasg import openapi +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from opaque_keys.edx.keys import CourseKey from rest_framework import status, viewsets from rest_framework.decorators import action @@ -30,6 +30,13 @@ ) from openedx.core.lib.api.view_utils import view_auth_classes +AGREEMENT_TYPE_PATH_PARAMETER = OpenApiParameter( + "agreement_type", + OpenApiTypes.STR, + OpenApiParameter.PATH, + description="Agreement ID/Type", +) + def is_user_course_or_global_staff(user, course_id): """ @@ -179,18 +186,12 @@ class UserAgreementRecordsView(APIView): Endpoint for the user agreement records API. """ - @apidocs.schema( - parameters=[ - apidocs.string_parameter( - "agreement_type", - apidocs.ParameterLocation.PATH, - description="Agreement ID/Type", - ), - ], + @extend_schema( + parameters=[AGREEMENT_TYPE_PATH_PARAMETER], responses={ 200: UserAgreementRecordSerializer, - 400: "Bad Request", - 404: "Not Found", + 400: OpenApiResponse(description="Bad Request"), + 404: OpenApiResponse(description="Not Found"), }, ) def get(self, request, agreement_type): @@ -201,17 +202,11 @@ def get(self, request, agreement_type): serializer = UserAgreementRecordSerializer(record) return Response(serializer.data, status=status.HTTP_200_OK) - @apidocs.schema( - parameters=[ - apidocs.string_parameter( - "agreement_type", - apidocs.ParameterLocation.PATH, - description="Agreement ID/Type", - ), - ], + @extend_schema( + parameters=[AGREEMENT_TYPE_PATH_PARAMETER], responses={ 200: UserAgreementRecordSerializer, - 400: "Bad Request", + 400: OpenApiResponse(description="Bad Request"), }, ) def post(self, request, agreement_type): @@ -233,18 +228,12 @@ class UserAgreementsViewSet(viewsets.GenericViewSet): lookup_field = "type" lookup_url_kwarg = "agreement_type" - @apidocs.schema( - parameters=[ - apidocs.string_parameter( - "agreement_type", - apidocs.ParameterLocation.PATH, - description="Agreement ID/Type", - ), - ], + @extend_schema( + parameters=[AGREEMENT_TYPE_PATH_PARAMETER], responses={ 200: UserAgreementSerializer, - 400: "Bad Request", - 404: "Not Found", + 400: OpenApiResponse(description="Bad Request"), + 404: OpenApiResponse(description="Not Found"), }, ) def retrieve(self, request, agreement_type=None, **kwargs): @@ -258,18 +247,12 @@ def retrieve(self, request, agreement_type=None, **kwargs): serializer = UserAgreementSerializer(agreement) return Response(serializer.data, status=status.HTTP_200_OK) - @apidocs.schema( - parameters=[ - apidocs.string_parameter( - "agreement_type", - apidocs.ParameterLocation.PATH, - description="Agreement ID/Type", - ), - ], + @extend_schema( + parameters=[AGREEMENT_TYPE_PATH_PARAMETER], responses={ 200: UserAgreementSerializer, - 400: "Bad Request", - 404: "Not Found", + 400: OpenApiResponse(description="Bad Request"), + 404: OpenApiResponse(description="Not Found"), }, ) @action(methods=["get"], detail=True) @@ -283,20 +266,20 @@ def text(self, request, agreement_type=None): return Response(status=status.HTTP_404_NOT_FOUND) return Response(agreement.text, status=status.HTTP_200_OK) - @apidocs.schema( + @extend_schema( parameters=[ - openapi.Parameter( + OpenApiParameter( "agreement_type", - apidocs.ParameterLocation.QUERY, + OpenApiTypes.STR, + OpenApiParameter.QUERY, required=False, - type=openapi.TYPE_ARRAY, - items=openapi.Items(type=openapi.TYPE_STRING), + many=True, description="Agreement ID/Type", ), ], responses={ 200: UserAgreementSerializer, - 400: "Bad Request", + 400: OpenApiResponse(description="Bad Request"), }, ) def list(self, request): diff --git a/openedx/core/djangoapps/bookmarks/serializers.py b/openedx/core/djangoapps/bookmarks/serializers.py index 56d5b7e01770..a8462890c41b 100644 --- a/openedx/core/djangoapps/bookmarks/serializers.py +++ b/openedx/core/djangoapps/bookmarks/serializers.py @@ -3,7 +3,6 @@ """ -from edx_api_doc_tools import is_schema_request from rest_framework import serializers from openedx.core.lib.api.serializers import CourseKeyField, UsageKeyField @@ -12,6 +11,20 @@ from .models import Bookmark +def is_schema_request(request): + """ + Return whether this request is serving an OpenAPI schema. + + Schema generators set a swagger_fake_view attribute on the view; that is + the drf-spectacular-compatible signal. ``format=openapi`` is drf-yasg's + convention, kept while it still serves ``/api-docs``. + """ + view = (getattr(request, 'parser_context', None) or {}).get('view') + if getattr(view, 'swagger_fake_view', False): + return True + return request.query_params.get('format') == 'openapi' + + class BookmarkSerializer(serializers.ModelSerializer): """ Serializer for the Bookmark model. diff --git a/openedx/core/djangoapps/bookmarks/views.py b/openedx/core/djangoapps/bookmarks/views.py index 3fd25ae06fbd..7764d7255eb1 100644 --- a/openedx/core/djangoapps/bookmarks/views.py +++ b/openedx/core/djangoapps/bookmarks/views.py @@ -8,12 +8,13 @@ import logging -import edx_api_doc_tools as apidocs import eventtracking from django.conf import settings from django.core.exceptions import ObjectDoesNotExist from django.utils.translation import gettext as _ from django.utils.translation import gettext_noop +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, extend_schema from edx_rest_framework_extensions.paginators import DefaultPagination from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey, UsageKey @@ -105,16 +106,18 @@ class BookmarksListView(ListCreateAPIView, BookmarksViewMixin): permission_classes = (permissions.IsAuthenticated,) serializer_class = BookmarkSerializer - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter( + OpenApiParameter( 'course_id', - apidocs.ParameterLocation.QUERY, + OpenApiTypes.STR, + OpenApiParameter.QUERY, description="The id of the course to limit the list", ), - apidocs.string_parameter( + OpenApiParameter( 'fields', - apidocs.ParameterLocation.QUERY, + OpenApiTypes.STR, + OpenApiParameter.QUERY, description="The fields to return: display_name, path.", ), ], @@ -201,7 +204,7 @@ def paginate_queryset(self, queryset): return page - @apidocs.schema() + @extend_schema() def post(self, request, *unused_args, **unused_kwargs): # pylint: disable=unused-argument """Create a new bookmark for a user. @@ -311,7 +314,7 @@ def get_usage_key_or_error_response(self, usage_id): log.error(error_message) return self.error_response(error_message, error_status=status.HTTP_404_NOT_FOUND) - @apidocs.schema() + @extend_schema() def get(self, request, username=None, usage_id=None): # pylint: disable=unused-argument """ Get a specific bookmark for a user. diff --git a/openedx/core/djangoapps/content_libraries/rest_api/blocks.py b/openedx/core/djangoapps/content_libraries/rest_api/blocks.py index 73c0f574ca50..c440488c38e5 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/blocks.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/blocks.py @@ -3,13 +3,13 @@ """ from uuid import UUID -import edx_api_doc_tools as apidocs from django.conf import settings from django.core.exceptions import ObjectDoesNotExist from django.db.transaction import non_atomic_requests from django.http import Http404, HttpResponse, StreamingHttpResponse from django.utils.decorators import method_decorator -from drf_yasg.utils import swagger_auto_schema +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, extend_schema from opaque_keys import InvalidKeyError from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2, LibraryUsageLocatorV2 from openedx_authz.constants import permissions as authz_permissions @@ -40,17 +40,19 @@ class LibraryBlocksView(GenericAPIView): """ serializer_class = serializers.LibraryXBlockMetadataSerializer - @apidocs.schema( + @extend_schema( parameters=[ *LibraryApiPaginationDocs.apidoc_params, - apidocs.query_parameter( + OpenApiParameter( 'text_search', - str, + OpenApiTypes.STR, + OpenApiParameter.QUERY, description="The string used to filter libraries by searching in title, id, org, or description", ), - apidocs.query_parameter( + OpenApiParameter( 'block_type', - str, + OpenApiTypes.STR, + OpenApiParameter.QUERY, description="The block type to search for. If omitted or blank, searches for all types. " "May be specified multiple times to match multiple types." ) @@ -76,9 +78,9 @@ def get(self, request, lib_key_str): return self.get_paginated_response(serializer.data) @convert_exceptions - @swagger_auto_schema( - request_body=serializers.LibraryXBlockCreationSerializer, - responses={200: serializers.LibraryXBlockMetadataSerializer} + @extend_schema( + request=serializers.LibraryXBlockCreationSerializer, + responses={200: serializers.LibraryXBlockMetadataSerializer}, ) def post(self, request, lib_key_str): """ diff --git a/openedx/core/djangoapps/content_libraries/rest_api/libraries.py b/openedx/core/djangoapps/content_libraries/rest_api/libraries.py index 3db1213604f4..0dfcd57cde5a 100644 --- a/openedx/core/djangoapps/content_libraries/rest_api/libraries.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/libraries.py @@ -65,14 +65,14 @@ import logging import warnings -import edx_api_doc_tools as apidocs from django.contrib.auth import get_user_model from django.contrib.auth.models import Group from django.db.transaction import atomic, non_atomic_requests from django.shortcuts import get_object_or_404 from django.utils.decorators import method_decorator from django.utils.translation import gettext as _ -from drf_yasg.utils import swagger_auto_schema +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, extend_schema from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2 from openedx_authz.constants import permissions as authz_permissions from organizations.api import ensure_organization @@ -132,19 +132,22 @@ class LibraryApiPaginationDocs: API docs for query params related to paginating ContentLibraryMetadata objects. """ apidoc_params = [ - apidocs.query_parameter( + OpenApiParameter( 'pagination', - bool, + OpenApiTypes.BOOL, + OpenApiParameter.QUERY, description="Enables paginated schema", ), - apidocs.query_parameter( + OpenApiParameter( 'page', - int, + OpenApiTypes.INT, + OpenApiParameter.QUERY, description="Page number of result. Defaults to 1", ), - apidocs.query_parameter( + OpenApiParameter( 'page_size', - int, + OpenApiTypes.INT, + OpenApiParameter.QUERY, description="Page size of the result. Defaults to 50", ), ] @@ -158,23 +161,26 @@ class LibraryRootView(GenericAPIView): """ serializer_class = ContentLibraryMetadataSerializer - @apidocs.schema( + @extend_schema( responses={200: ContentLibraryMetadataSerializer(many=True)}, parameters=[ *LibraryApiPaginationDocs.apidoc_params, - apidocs.query_parameter( + OpenApiParameter( 'org', - str, + OpenApiTypes.STR, + OpenApiParameter.QUERY, description="The organization short-name used to filter libraries", ), - apidocs.query_parameter( + OpenApiParameter( 'text_search', - str, + OpenApiTypes.STR, + OpenApiParameter.QUERY, description="The string used to filter libraries by searching in title, id, org, or description", ), - apidocs.query_parameter( + OpenApiParameter( 'order', - str, + OpenApiTypes.STR, + OpenApiParameter.QUERY, description=( "Name of the content library field to sort the results by. Prefix with a '-' to sort descending." ), @@ -548,8 +554,8 @@ class LibraryPasteClipboardView(GenericAPIView): serializer_class = PublishableItemSerializer @convert_exceptions - @swagger_auto_schema( - responses={200: PublishableItemSerializer} + @extend_schema( + responses={200: PublishableItemSerializer}, ) def post(self, request, lib_key_str): """ @@ -574,17 +580,19 @@ class LibraryBlocksView(GenericAPIView): """ serializer_class = LibraryXBlockMetadataSerializer - @apidocs.schema( + @extend_schema( parameters=[ *LibraryApiPaginationDocs.apidoc_params, - apidocs.query_parameter( + OpenApiParameter( 'text_search', - str, + OpenApiTypes.STR, + OpenApiParameter.QUERY, description="The string used to filter libraries by searching in title, id, org, or description", ), - apidocs.query_parameter( + OpenApiParameter( 'block_type', - str, + OpenApiTypes.STR, + OpenApiParameter.QUERY, description="The block type to search for. If omitted or blank, searches for all types. " "May be specified multiple times to match multiple types." ) @@ -610,9 +618,9 @@ def get(self, request, lib_key_str): return self.get_paginated_response(serializer.data) @convert_exceptions - @swagger_auto_schema( - request_body=LibraryXBlockCreationSerializer, - responses={200: LibraryXBlockMetadataSerializer} + @extend_schema( + request=LibraryXBlockCreationSerializer, + responses={200: LibraryXBlockMetadataSerializer}, ) def post(self, request, lib_key_str): """ @@ -731,9 +739,9 @@ class LibraryBackupView(APIView): """ - @apidocs.schema( - body=None, - responses={200: LibraryBackupResponseSerializer} + @extend_schema( + request=None, + responses={200: LibraryBackupResponseSerializer}, ) @convert_exceptions def post(self, request, lib_key_str): @@ -749,15 +757,16 @@ def post(self, request, lib_key_str): return Response(LibraryBackupResponseSerializer(result).data) - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.query_parameter( + OpenApiParameter( 'task_id', - str, + OpenApiTypes.STR, + OpenApiParameter.QUERY, description="The ID of the backup task to retrieve." ), ], - responses={200: LibraryBackupTaskStatusSerializer} + responses={200: LibraryBackupTaskStatusSerializer}, ) @convert_exceptions def get(self, request, lib_key_str): @@ -805,9 +814,9 @@ class LibraryRestoreView(APIView): * task_id: (required) The UUID of a restore task. """ - @apidocs.schema( - body=LibraryRestoreFileSerializer, - responses={200: LibraryRestoreFileSerializer} + @extend_schema( + request=LibraryRestoreFileSerializer, + responses={200: LibraryRestoreFileSerializer}, ) def post(self, request): """ @@ -828,15 +837,16 @@ def post(self, request): return Response(LibraryRestoreFileSerializer({'task_id': async_result.task_id}).data) - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.query_parameter( + OpenApiParameter( 'task_id', - str, + OpenApiTypes.STR, + OpenApiParameter.QUERY, description="The ID of the restore library task to retrieve." ), ], - responses={200: LibraryRestoreTaskResultSerializer} + responses={200: LibraryRestoreTaskResultSerializer}, ) def get(self, request): """ diff --git a/openedx/core/djangoapps/content_staging/views.py b/openedx/core/djangoapps/content_staging/views.py index 6bdab4198f26..514412b1eae5 100644 --- a/openedx/core/djangoapps/content_staging/views.py +++ b/openedx/core/djangoapps/content_staging/views.py @@ -3,11 +3,11 @@ """ from __future__ import annotations -import edx_api_doc_tools as apidocs from django.db import transaction from django.http import HttpResponse from django.shortcuts import get_object_or_404 from django.utils.decorators import method_decorator +from drf_spectacular.utils import OpenApiResponse, extend_schema from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import UsageKey from opaque_keys.edx.locator import CourseLocator, LibraryLocatorV2 @@ -61,10 +61,10 @@ class ClipboardEndpoint(APIView): clipboard or to POST some content to the clipboard. """ - @apidocs.schema( + @extend_schema( responses={ 200: UserClipboardSerializer, - } + }, ) def get(self, request): """ @@ -72,12 +72,12 @@ def get(self, request): """ return Response(api.get_user_clipboard_json(request.user.id, request)) - @apidocs.schema( - body=PostToClipboardSerializer, + @extend_schema( + request=PostToClipboardSerializer, responses={ 200: UserClipboardSerializer, - 403: "You do not have permission to read the specified usage key.", - 404: "The requested usage key does not exist.", + 403: OpenApiResponse(description="You do not have permission to read the specified usage key."), + 404: OpenApiResponse(description="The requested usage key does not exist."), }, ) def post(self, request): diff --git a/openedx/core/djangoapps/course_apps/rest_api/v1/views.py b/openedx/core/djangoapps/course_apps/rest_api/v1/views.py index 6c34376cdcaf..65da798b4db8 100644 --- a/openedx/core/djangoapps/course_apps/rest_api/v1/views.py +++ b/openedx/core/djangoapps/course_apps/rest_api/v1/views.py @@ -3,7 +3,8 @@ from typing import Dict # noqa: UP035 from django.contrib.auth import get_user_model -from edx_api_doc_tools import path_parameter, schema +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from edx_django_utils.plugins import PluginError from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser @@ -100,15 +101,15 @@ class CourseAppsView(DeveloperErrorViewMixin, views.APIView): ) permission_classes = (HasPagesAndResourcesAccess,) - @schema( + @extend_schema( parameters=[ - path_parameter("course_id", str, description="Course Key"), + OpenApiParameter("course_id", OpenApiTypes.STR, OpenApiParameter.PATH, description="Course Key"), ], responses={ 200: CourseAppSerializer, - 401: "The requester is not authenticated.", - 403: "The requester does not have staff access access to the specified course", - 404: "The requested course does not exist.", + 401: OpenApiResponse(description="The requester is not authenticated."), + 403: OpenApiResponse(description="The requester does not have staff access access to the specified course"), + 404: OpenApiResponse(description="The requested course does not exist."), }, ) @verify_course_exists("Requested apps for unknown course {course}") @@ -160,15 +161,15 @@ def get(self, request: Request, course_id: str): ) return Response(serializer.data) - @schema( + @extend_schema( parameters=[ - path_parameter("course_id", str, description="Course Key"), + OpenApiParameter("course_id", OpenApiTypes.STR, OpenApiParameter.PATH, description="Course Key"), ], responses={ 200: CourseAppSerializer, - 401: "The requester is not authenticated.", - 403: "The requester does not have staff access access to the specified course", - 404: "The requested course does not exist.", + 401: OpenApiResponse(description="The requester is not authenticated."), + 403: OpenApiResponse(description="The requester does not have staff access access to the specified course"), + 404: OpenApiResponse(description="The requested course does not exist."), }, ) @verify_course_exists("Requested apps for unknown course {course}") diff --git a/openedx/core/djangoapps/course_groups/rest_api/views.py b/openedx/core/djangoapps/course_groups/rest_api/views.py index 9bf884b6a22d..3882e3b3b3a0 100644 --- a/openedx/core/djangoapps/course_groups/rest_api/views.py +++ b/openedx/core/djangoapps/course_groups/rest_api/views.py @@ -1,8 +1,9 @@ """ REST API views for content group configurations. """ -import edx_api_doc_tools as apidocs from django.conf import settings +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey from rest_framework import status @@ -30,20 +31,21 @@ class GroupConfigurationsListView(DeveloperErrorViewMixin, APIView): permission_classes = (IsAuthenticated, permissions.InstructorPermission) permission_name = permissions.VIEW_DASHBOARD - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter( + OpenApiParameter( "course_id", - apidocs.ParameterLocation.PATH, + OpenApiTypes.STR, + OpenApiParameter.PATH, description="The course key (e.g., course-v1:org+course+run)", ), ], responses={ - 200: "Successfully retrieved content groups", - 400: "Invalid course key", - 401: "Authentication required", - 403: "User does not have permission to access this course", - 404: "Course not found", + 200: OpenApiResponse(description="Successfully retrieved content groups"), + 400: OpenApiResponse(description="Invalid course key"), + 401: OpenApiResponse(description="Authentication required"), + 403: OpenApiResponse(description="User does not have permission to access this course"), + 404: OpenApiResponse(description="Course not found"), }, ) def get(self, request, course_id): @@ -98,25 +100,27 @@ class GroupConfigurationDetailView(DeveloperErrorViewMixin, APIView): permission_classes = (IsAuthenticated, permissions.InstructorPermission) permission_name = permissions.VIEW_DASHBOARD - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter( + OpenApiParameter( "course_id", - apidocs.ParameterLocation.PATH, + OpenApiTypes.STR, + OpenApiParameter.PATH, description="The course key", ), - apidocs.path_parameter( + OpenApiParameter( "configuration_id", - int, + OpenApiTypes.INT, + OpenApiParameter.PATH, description="The ID of the content group configuration", ), ], responses={ - 200: "Content group configuration details", - 400: "Invalid course key", - 401: "Authentication required", - 403: "User does not have permission to access this course", - 404: "Content group configuration not found", + 200: OpenApiResponse(description="Content group configuration details"), + 400: OpenApiResponse(description="Invalid course key"), + 401: OpenApiResponse(description="Authentication required"), + 403: OpenApiResponse(description="User does not have permission to access this course"), + 404: OpenApiResponse(description="Content group configuration not found"), }, ) def get(self, request, course_id, configuration_id): diff --git a/openedx/core/djangoapps/course_live/views.py b/openedx/core/djangoapps/course_live/views.py index a25358571f4c..36c9f615976e 100644 --- a/openedx/core/djangoapps/course_live/views.py +++ b/openedx/core/djangoapps/course_live/views.py @@ -3,7 +3,8 @@ """ from typing import Dict # noqa: UP035 -import edx_api_doc_tools as apidocs +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser from lti_consumer.api import get_lti_pii_sharing_state_for_course @@ -37,19 +38,20 @@ class CourseLiveConfigurationView(APIView): ) permission_classes = (IsStaffOrInstructor,) - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.path_parameter( + OpenApiParameter( 'course_id', - str, + OpenApiTypes.STR, + OpenApiParameter.PATH, description="The course for which to get provider list", ) ], responses={ 200: CourseLiveConfigurationSerializer, - 401: "The requester is not authenticated.", - 403: "The requester cannot access the specified course.", - 404: "The requested course does not exist.", + 401: OpenApiResponse(description="The requester is not authenticated."), + 403: OpenApiResponse(description="The requester cannot access the specified course."), + 404: OpenApiResponse(description="The requested course does not exist."), }, ) @ensure_valid_course_key @@ -66,46 +68,51 @@ def get(self, request: Request, course_id: str) -> Response: return Response(serializer.data) - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.path_parameter( + OpenApiParameter( 'course_id', - str, + OpenApiTypes.STR, + OpenApiParameter.PATH, description="The course for which to get provider list", ), - apidocs.path_parameter( + OpenApiParameter( 'lti_1p1_client_key', - str, + OpenApiTypes.STR, + OpenApiParameter.PATH, description="The LTI provider's client key", ), - apidocs.path_parameter( + OpenApiParameter( 'lti_1p1_client_secret', - str, + OpenApiTypes.STR, + OpenApiParameter.PATH, description="The LTI provider's client secretL", ), - apidocs.path_parameter( + OpenApiParameter( 'lti_1p1_launch_url', - str, + OpenApiTypes.STR, + OpenApiParameter.PATH, description="The LTI provider's launch URL", ), - apidocs.path_parameter( + OpenApiParameter( 'provider_type', - str, + OpenApiTypes.STR, + OpenApiParameter.PATH, description="The LTI provider's launch URL", ), - apidocs.parameter( + OpenApiParameter( 'lti_config', - apidocs.ParameterLocation.QUERY, - object, + OpenApiTypes.OBJECT, + OpenApiParameter.QUERY, description="The lti_config object with required additional parameters ", ), ], responses={ 200: CourseLiveConfigurationSerializer, - 400: "Required parameters are missing.", - 401: "The requester is not authenticated.", - 403: "The requester cannot access the specified course.", - 404: "The requested course does not exist.", + 400: OpenApiResponse(description="Required parameters are missing."), + 401: OpenApiResponse(description="The requester is not authenticated."), + 403: OpenApiResponse(description="The requester cannot access the specified course."), + 404: OpenApiResponse(description="The requested course does not exist."), }, ) @ensure_valid_course_key diff --git a/openedx/core/djangoapps/discussions/views.py b/openedx/core/djangoapps/discussions/views.py index cafa78415aef..bdaf7d998063 100644 --- a/openedx/core/djangoapps/discussions/views.py +++ b/openedx/core/djangoapps/discussions/views.py @@ -3,7 +3,8 @@ """ from typing import Dict # noqa: UP035 -import edx_api_doc_tools as apidocs +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser from rest_framework.exceptions import ValidationError @@ -34,25 +35,27 @@ class DiscussionsConfigurationSettingsView(APIView): ) permission_classes = (HasPagesAndResourcesAccess,) - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter( + OpenApiParameter( 'course_id', - apidocs.ParameterLocation.PATH, + OpenApiTypes.STR, + OpenApiParameter.PATH, description="The course for which to get provider list", ), - apidocs.string_parameter( + OpenApiParameter( 'provider_id', - apidocs.ParameterLocation.QUERY, + OpenApiTypes.STR, + OpenApiParameter.QUERY, description="The provider_id to fetch data for" ) ], responses={ 200: DiscussionsConfigurationSerializer, - 400: "Invalid provider ID", - 401: "The requester is not authenticated.", - 403: "The requester cannot access the specified course.", - 404: "The requested course does not exist.", + 400: OpenApiResponse(description="Invalid provider ID"), + 401: OpenApiResponse(description="The requester is not authenticated."), + 403: OpenApiResponse(description="The requester cannot access the specified course."), + 404: OpenApiResponse(description="The requested course does not exist."), }, ) def get(self, request: Request, course_key_string: str, **_kwargs) -> Response: @@ -136,19 +139,20 @@ class DiscussionsProvidersView(APIView): ) permission_classes = (HasPagesAndResourcesAccess,) - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter( + OpenApiParameter( 'course_id', - apidocs.ParameterLocation.PATH, + OpenApiTypes.STR, + OpenApiParameter.PATH, description="The course for which to get provider list", ) ], responses={ 200: DiscussionsProvidersSerializer, - 401: "The requester is not authenticated.", - 403: "The requester cannot access the specified course.", - 404: "The requested course does not exist.", + 401: OpenApiResponse(description="The requester is not authenticated."), + 403: OpenApiResponse(description="The requester cannot access the specified course."), + 404: OpenApiResponse(description="The requested course does not exist."), }, ) def get(self, request, course_key_string: str, **_kwargs) -> Response: From 3d9425c90124f0e7c3f3943c413e17aa8331b8ff Mon Sep 17 00:00:00 2001 From: Muhammad Faraz Maqsood Date: Wed, 16 Sep 2026 10:56:56 +0500 Subject: [PATCH 03/12] refactor: migrate lms from edx-api-doc-tools to drf-spectacular Converts @apidocs.schema and @schema to @extend_schema across the lms app, excluding instructor. Parameter helpers become OpenApiParameter and string response descriptions become OpenApiResponse. discussion/rest_api/views.py also drops its remaining direct drf_yasg import, which was interleaved with the apidocs decorators. --- lms/djangoapps/certificates/apis/v0/views.py | 10 ++-- lms/djangoapps/discussion/rest_api/views.py | 48 ++++++++++--------- lms/djangoapps/mfe_config_api/views.py | 10 ++-- .../program_enrollments/rest_api/v1/views.py | 19 ++++---- 4 files changed, 47 insertions(+), 40 deletions(-) diff --git a/lms/djangoapps/certificates/apis/v0/views.py b/lms/djangoapps/certificates/apis/v0/views.py index d5b64a4a724a..5097a169b537 100644 --- a/lms/djangoapps/certificates/apis/v0/views.py +++ b/lms/djangoapps/certificates/apis/v0/views.py @@ -3,8 +3,9 @@ import logging -import edx_api_doc_tools as apidocs from django.contrib.auth import get_user_model +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, extend_schema from edx_rest_framework_extensions import permissions from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser @@ -170,10 +171,11 @@ class CertificatesListView(APIView): required_scopes = ['certificates:read'] - @apidocs.schema(parameters=[ - apidocs.string_parameter( + @extend_schema(parameters=[ + OpenApiParameter( 'username', - apidocs.ParameterLocation.PATH, + OpenApiTypes.STR, + OpenApiParameter.PATH, description="The users to get certificates for", ) ]) diff --git a/lms/djangoapps/discussion/rest_api/views.py b/lms/djangoapps/discussion/rest_api/views.py index 4276fc5e52cd..570de684b3b5 100644 --- a/lms/djangoapps/discussion/rest_api/views.py +++ b/lms/djangoapps/discussion/rest_api/views.py @@ -4,11 +4,11 @@ import logging import uuid -import edx_api_doc_tools as apidocs from django.contrib.auth import get_user_model from django.core.exceptions import BadRequest, ValidationError from django.shortcuts import get_object_or_404 -from drf_yasg import openapi +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser from forum import api as forum_api @@ -102,15 +102,15 @@ class CourseView(DeveloperErrorViewMixin, APIView): General discussion metadata API. """ - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID") + OpenApiParameter("course_id", OpenApiTypes.STR, OpenApiParameter.PATH, description="Course ID") ], responses={ 200: CourseMetadataSerailizer(read_only=True, required=False), - 401: "The requester is not authenticated.", - 403: "The requester cannot access the specified course.", - 404: "The requested course does not exist.", + 401: OpenApiResponse(description="The requester is not authenticated."), + 403: OpenApiResponse(description="The requester cannot access the specified course."), + 404: OpenApiResponse(description="The requested course does not exist."), } ) def get(self, request, course_id): @@ -133,15 +133,15 @@ class CourseViewV2(DeveloperErrorViewMixin, APIView): General discussion metadata API v2. """ - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID") + OpenApiParameter("course_id", OpenApiTypes.STR, OpenApiParameter.PATH, description="Course ID") ], responses={ 200: CourseMetadataSerailizer(read_only=True, required=False), - 401: "The requester is not authenticated.", - 403: "The requester cannot access the specified course.", - 404: "The requested course does not exist.", + 401: OpenApiResponse(description="The requester is not authenticated."), + 403: OpenApiResponse(description="The requester cannot access the specified course."), + 404: OpenApiResponse(description="The requested course does not exist."), } ) def get(self, request, course_id): @@ -298,32 +298,34 @@ class CourseTopicsViewV2(DeveloperErrorViewMixin, APIView): [API Documentation](/api-docs/?filter=discussion#/discussion/discussion_v2_course_topics_read) """ - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter( + OpenApiParameter( 'course_id', - apidocs.ParameterLocation.PATH, + OpenApiTypes.STR, + OpenApiParameter.PATH, description="Course ID", ), - apidocs.string_parameter( + OpenApiParameter( 'topic_id', - apidocs.ParameterLocation.QUERY, + OpenApiTypes.STR, + OpenApiParameter.QUERY, description="Comma-separated list of topic ids to filter", ), - openapi.Parameter( + OpenApiParameter( 'order_by', - apidocs.ParameterLocation.QUERY, + OpenApiTypes.STR, + OpenApiParameter.QUERY, required=False, - type=openapi.TYPE_STRING, enum=list(TopicOrdering), description="Sort ordering for topics", ), ], responses={ 200: DiscussionTopicSerializerV2(read_only=True, required=False), - 401: "The requester is not authenticated.", - 403: "The requester cannot access the specified course.", - 404: "The requested course does not exist.", + 401: OpenApiResponse(description="The requester is not authenticated."), + 403: OpenApiResponse(description="The requester cannot access the specified course."), + 404: OpenApiResponse(description="The requested course does not exist."), } ) def get(self, request, course_id): diff --git a/lms/djangoapps/mfe_config_api/views.py b/lms/djangoapps/mfe_config_api/views.py index eb98b224ed8e..470f882e1df4 100644 --- a/lms/djangoapps/mfe_config_api/views.py +++ b/lms/djangoapps/mfe_config_api/views.py @@ -4,11 +4,12 @@ from configparser import Error as ConfigParserError -import edx_api_doc_tools as apidocs from django.conf import settings from django.http import JsonResponse from django.utils.decorators import method_decorator from django.views.decorators.cache import cache_page +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, extend_schema from help_tokens.core import HelpUrlExpert from rest_framework import status from rest_framework.permissions import AllowAny @@ -248,11 +249,12 @@ class MFEConfigView(APIView): """ @method_decorator(cache_page(settings.MFE_CONFIG_API_CACHE_TIMEOUT)) - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.query_parameter( + OpenApiParameter( "mfe", - str, + OpenApiTypes.STR, + OpenApiParameter.QUERY, description="Name of an MFE (a.k.a. an APP_ID).", ), ], diff --git a/lms/djangoapps/program_enrollments/rest_api/v1/views.py b/lms/djangoapps/program_enrollments/rest_api/v1/views.py index e6ec3b2231d9..eaa086689532 100644 --- a/lms/djangoapps/program_enrollments/rest_api/v1/views.py +++ b/lms/djangoapps/program_enrollments/rest_api/v1/views.py @@ -5,7 +5,8 @@ from django.conf import settings from django.core.management import call_command from django.db import transaction -from edx_api_doc_tools import path_parameter, query_parameter, schema +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from edx_rest_framework_extensions import permissions from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser @@ -794,30 +795,30 @@ class UserProgramCourseEnrollmentView( serializer_class = CourseRunOverviewSerializer pagination_class = UserProgramCourseEnrollmentPagination - @schema( + @extend_schema( parameters=[ - path_parameter('username', str, description=( + OpenApiParameter('username', OpenApiTypes.STR, OpenApiParameter.PATH, description=( 'The username of the user for which enrollment overviews will be fetched. ' 'For now, this must be the requesting user; otherwise, 403 will be returned. ' 'In the future, global staff users may be able to supply other usernames.' )), - path_parameter('program_uuid', str, description=( + OpenApiParameter('program_uuid', OpenApiTypes.STR, OpenApiParameter.PATH, description=( 'UUID of a program. ' 'Enrollments will be returned for course runs in this program.' )), - query_parameter('page_size', int, description=( + OpenApiParameter('page_size', OpenApiTypes.INT, OpenApiParameter.QUERY, description=( 'Number of results to return per page. ' 'Defaults to 10. Maximum is 25.' )), ], responses={ 200: cursor_paginate_serializer(CourseRunOverviewSerializer), - 401: 'The requester is not authenticated.', - 403: ( + 401: OpenApiResponse(description='The requester is not authenticated.'), + 403: OpenApiResponse(description=( 'The requester cannot access the specified program and/or ' 'the requester may not retrieve this data for the specified user.' - ), - 404: 'The requested program does not exist.' + )), + 404: OpenApiResponse(description='The requested program does not exist.'), }, ) @verify_program_exists From 1fc1a2735a9f05979977ee3b9e85244839f43a18 Mon Sep 17 00:00:00 2001 From: Muhammad Faraz Maqsood Date: Wed, 16 Sep 2026 11:31:20 +0500 Subject: [PATCH 04/12] refactor: migrate instructor from edx-api-doc-tools to drf-spectacular Converts the 26 @apidocs.schema decorators in the instructor v1 and v2 APIs to @extend_schema. The course_id, problem, and exam_id path parameters were repeated verbatim across 29 decorators; those are now module-level constants. --- lms/djangoapps/instructor/views/api.py | 61 +-- lms/djangoapps/instructor/views/api_v2.py | 491 +++++++++------------- 2 files changed, 238 insertions(+), 314 deletions(-) diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 774f0e0eae47..767d8f996833 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -15,7 +15,6 @@ import string import dateutil -import edx_api_doc_tools as apidocs import pytz from django.conf import settings from django.contrib.auth.models import User # pylint: disable=imported-auth-user @@ -32,6 +31,8 @@ from django.views.decorators.cache import cache_control from django.views.decorators.csrf import ensure_csrf_cookie from django.views.decorators.http import require_POST +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser from edx_when.api import get_date_for_block @@ -1284,23 +1285,24 @@ class ProblemResponseReportInitiate(DeveloperErrorViewMixin, APIView): to a given problem. """ - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.path_parameter( + OpenApiParameter( 'course_id', - str, + OpenApiTypes.STR, + OpenApiParameter.PATH, description="ID of the course for which report is to be generate.", ), ], - body=ProblemResponseReportPostParamsSerializer, + request=ProblemResponseReportPostParamsSerializer, responses={ 200: ProblemResponsesReportStatusSerializer, - 400: _( + 400: OpenApiResponse(description=_( "The provided parameters were invalid. Make sure you've provided at least " "one valid usage key for `problem_locations`." - ), - 401: _("The requesting user is not authenticated."), - 403: _("The requesting user lacks access to the course."), + )), + 401: OpenApiResponse(description=_("The requesting user is not authenticated.")), + 403: OpenApiResponse(description=_("The requesting user lacks access to the course.")), } ) @transaction.non_atomic_requests @@ -2608,30 +2610,33 @@ class InstructorTasks(DeveloperErrorViewMixin, APIView): } """ - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter( + OpenApiParameter( 'course_id', - apidocs.ParameterLocation.PATH, + OpenApiTypes.STR, + OpenApiParameter.PATH, description="ID for the course whose tasks need to be listed.", ), - apidocs.string_parameter( + OpenApiParameter( 'problem_location_str', - apidocs.ParameterLocation.QUERY, + OpenApiTypes.STR, + OpenApiParameter.QUERY, description="Filter instructor tasks to this problem location.", ), - apidocs.string_parameter( + OpenApiParameter( 'unique_student_identifier', - apidocs.ParameterLocation.QUERY, + OpenApiTypes.STR, + OpenApiParameter.QUERY, description="Filter tasks to a singe problem and a single student. " "Must be used in combination with `problem_location_str`.", ), ], responses={ 200: InstructorTasksListSerializer, - 401: _("The requesting user is not authenticated."), - 403: _("The requesting user lacks access to the course."), - 404: _("The requested course does not exist."), + 401: OpenApiResponse(description=_("The requesting user is not authenticated.")), + 403: OpenApiResponse(description=_("The requesting user lacks access to the course.")), + 404: OpenApiResponse(description=_("The requested course does not exist.")), } ) def get(self, request, course_id): @@ -2824,24 +2829,26 @@ class ReportDownloads(DeveloperErrorViewMixin, APIView): API view to list report downloads for a course. """ - @apidocs.schema(parameters=[ - apidocs.string_parameter( + @extend_schema(parameters=[ + OpenApiParameter( 'course_id', - apidocs.ParameterLocation.PATH, + OpenApiTypes.STR, + OpenApiParameter.PATH, description=_("ID for the course whose reports need to be listed."), ), - apidocs.string_parameter( + OpenApiParameter( 'report_name', - apidocs.ParameterLocation.QUERY, + OpenApiTypes.STR, + OpenApiParameter.QUERY, description=_( "Filter results to only return details of for the report with the specified name." ), ), ], responses={ 200: ReportDownloadsListSerializer, - 401: _("The requesting user is not authenticated."), - 403: _("The requesting user lacks access to the course."), - 404: _("The requested course does not exist."), + 401: OpenApiResponse(description=_("The requesting user is not authenticated.")), + 403: OpenApiResponse(description=_("The requesting user lacks access to the course.")), + 404: OpenApiResponse(description=_("The requested course does not exist.")), }) def get(self, request, course_id): """ diff --git a/lms/djangoapps/instructor/views/api_v2.py b/lms/djangoapps/instructor/views/api_v2.py index 871aa90cf961..ff5d345e2d72 100644 --- a/lms/djangoapps/instructor/views/api_v2.py +++ b/lms/djangoapps/instructor/views/api_v2.py @@ -14,7 +14,6 @@ from datetime import datetime from typing import Optional, Tuple # noqa: UP035 -import edx_api_doc_tools as apidocs from django.conf import settings from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError as DjangoValidationError @@ -28,6 +27,8 @@ from django.utils.translation import gettext as _ from django.views.decorators.cache import cache_control from django_filters.rest_framework import DjangoFilterBackend +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from edx_proctoring.api import ( add_allowance_for_user, does_backend_support_onboarding, @@ -174,6 +175,26 @@ VALID_TEAM_ROLES = frozenset(ROLES.keys()) | frozenset(FORUM_ROLES) +# Shared OpenAPI parameters, each used identically by several endpoints below. +COURSE_ID_PATH_PARAMETER = OpenApiParameter( + 'course_id', + OpenApiTypes.STR, + OpenApiParameter.PATH, + description="Course key for the course.", +) +PROBLEM_PATH_PARAMETER = OpenApiParameter( + 'problem', + OpenApiTypes.STR, + OpenApiParameter.PATH, + description="Problem block usage key.", +) +EXAM_ID_PATH_PARAMETER = OpenApiParameter( + 'exam_id', + OpenApiTypes.STR, + OpenApiParameter.PATH, + description="Exam identifier.", +) + class CourseMetadataView(DeveloperErrorViewMixin, APIView): """ @@ -186,19 +207,15 @@ class CourseMetadataView(DeveloperErrorViewMixin, APIView): permission_classes = (IsAuthenticated, permissions.InstructorPermission) permission_name = permissions.VIEW_DASHBOARD - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter( - 'course_id', - apidocs.ParameterLocation.PATH, - description="Course key for the course.", - ), + COURSE_ID_PATH_PARAMETER, ], responses={ 200: CourseInformationSerializerV2, - 401: "The requesting user is not authenticated.", - 403: "The requesting user lacks instructor access to the course.", - 404: "The requested course does not exist.", + 401: OpenApiResponse(description="The requesting user is not authenticated."), + 403: OpenApiResponse(description="The requesting user lacks instructor access to the course."), + 404: OpenApiResponse(description="The requested course does not exist."), }, ) def get(self, request, course_id): @@ -354,30 +371,28 @@ class InstructorTaskListView(DeveloperErrorViewMixin, APIView): permission_classes = (IsAuthenticated, permissions.InstructorPermission) permission_name = permissions.SHOW_TASKS - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter( - 'course_id', - apidocs.ParameterLocation.PATH, - description="Course key for the course.", - ), - apidocs.string_parameter( + COURSE_ID_PATH_PARAMETER, + OpenApiParameter( 'problem_location_str', - apidocs.ParameterLocation.QUERY, + OpenApiTypes.STR, + OpenApiParameter.QUERY, description="Optional: Filter tasks to a specific problem location.", ), - apidocs.string_parameter( + OpenApiParameter( 'unique_student_identifier', - apidocs.ParameterLocation.QUERY, + OpenApiTypes.STR, + OpenApiParameter.QUERY, description="Optional: Filter tasks to a specific student (requires problem_location_str).", ), ], responses={ 200: InstructorTaskListSerializer, - 400: "Invalid parameters provided.", - 401: "The requesting user is not authenticated.", - 403: "The requesting user lacks instructor access to the course.", - 404: "The requested course does not exist.", + 400: OpenApiResponse(description="Invalid parameters provided."), + 401: OpenApiResponse(description="The requesting user is not authenticated."), + 403: OpenApiResponse(description="The requesting user lacks instructor access to the course."), + 404: OpenApiResponse(description="The requested course does not exist."), }, ) def get(self, request, course_id): @@ -767,19 +782,15 @@ class ReportDownloadsView(DeveloperErrorViewMixin, APIView): # to view generated reports, aligning with the intended audience of instructors/course staff permission_name = permissions.ENROLLMENT_REPORT - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter( - 'course_id', - apidocs.ParameterLocation.PATH, - description="Course key for the course.", - ), + COURSE_ID_PATH_PARAMETER, ], responses={ - 200: "Returns list of available report downloads.", - 401: "The requesting user is not authenticated.", - 403: "The requesting user lacks instructor access to the course.", - 404: "The requested course does not exist.", + 200: OpenApiResponse(description="Returns list of available report downloads."), + 401: OpenApiResponse(description="The requesting user is not authenticated."), + 403: OpenApiResponse(description="The requesting user lacks instructor access to the course."), + 404: OpenApiResponse(description="The requested course does not exist."), }, ) def get(self, request, course_id): @@ -933,16 +944,13 @@ def permission_name(self): return permissions.VIEW_ISSUED_CERTIFICATES return permissions.CAN_RESEARCH - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter( - 'course_id', - apidocs.ParameterLocation.PATH, - description="Course key for the course.", - ), - apidocs.string_parameter( + COURSE_ID_PATH_PARAMETER, + OpenApiParameter( 'report_type', - apidocs.ParameterLocation.PATH, + OpenApiTypes.STR, + OpenApiParameter.PATH, description=( "Type of report to generate. Valid values: " "enrolled_students, pending_enrollments, pending_activations, " @@ -952,11 +960,11 @@ def permission_name(self): ), ], responses={ - 200: "Report generation task has been submitted successfully.", - 400: "The requested task is already running or invalid report type.", - 401: "The requesting user is not authenticated.", - 403: "The requesting user lacks instructor access to the course.", - 404: "The requested course does not exist.", + 200: OpenApiResponse(description="Report generation task has been submitted successfully."), + 400: OpenApiResponse(description="The requested task is already running or invalid report type."), + 401: OpenApiResponse(description="The requesting user is not authenticated."), + 403: OpenApiResponse(description="The requesting user lacks instructor access to the course."), + 404: OpenApiResponse(description="The requested course does not exist."), }, ) def post(self, request, course_id, report_type): @@ -1658,21 +1666,17 @@ class RegenerateCertificatesView(DeveloperErrorViewMixin, APIView): permission_classes = (IsAuthenticated, permissions.InstructorPermission) permission_name = permissions.START_CERTIFICATE_REGENERATION - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter( - 'course_id', - apidocs.ParameterLocation.PATH, - description="Course key for the course.", - ), + COURSE_ID_PATH_PARAMETER, ], - body=RegenerateCertificatesSerializer, + request=RegenerateCertificatesSerializer, responses={ - 200: "Certificate regeneration task started successfully", - 400: "Invalid parameters provided.", - 401: "The requesting user is not authenticated.", - 403: "The requesting user lacks instructor access to the course.", - 404: "The requested course does not exist.", + 200: OpenApiResponse(description="Certificate regeneration task started successfully"), + 400: OpenApiResponse(description="Invalid parameters provided."), + 401: OpenApiResponse(description="The requesting user is not authenticated."), + 403: OpenApiResponse(description="The requesting user lacks instructor access to the course."), + 404: OpenApiResponse(description="The requested course does not exist."), }, ) def post(self, request, course_id): @@ -1763,19 +1767,15 @@ class CertificateConfigView(DeveloperErrorViewMixin, APIView): permission_classes = (IsAuthenticated, permissions.InstructorPermission) permission_name = permissions.VIEW_ISSUED_CERTIFICATES - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter( - 'course_id', - apidocs.ParameterLocation.PATH, - description="Course key for the course.", - ), + COURSE_ID_PATH_PARAMETER, ], responses={ - 200: "Returns certificate configuration.", - 401: "The requesting user is not authenticated.", - 403: "The requesting user lacks instructor access to the course.", - 404: "The requested course does not exist.", + 200: OpenApiResponse(description="Returns certificate configuration."), + 401: OpenApiResponse(description="The requesting user is not authenticated."), + 403: OpenApiResponse(description="The requesting user lacks instructor access to the course."), + 404: OpenApiResponse(description="The requested course does not exist."), }, ) def get(self, request, course_id): @@ -2519,25 +2519,22 @@ class LearnerView(DeveloperErrorViewMixin, APIView): permission_classes = (IsAuthenticated, permissions.InstructorPermission) permission_name = permissions.VIEW_DASHBOARD - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter( - 'course_id', - apidocs.ParameterLocation.PATH, - description="Course key for the course.", - ), - apidocs.string_parameter( + COURSE_ID_PATH_PARAMETER, + OpenApiParameter( 'email_or_username', - apidocs.ParameterLocation.PATH, + OpenApiTypes.STR, + OpenApiParameter.PATH, description="Learner's username or email address", ), ], responses={ - 200: 'Learner information retrieved successfully', - 400: "Invalid parameters provided.", - 401: "The requesting user is not authenticated.", - 403: "The requesting user lacks instructor access to the course.", - 404: "Learner not found or course does not exist.", + 200: OpenApiResponse(description='Learner information retrieved successfully'), + 400: OpenApiResponse(description="Invalid parameters provided."), + 401: OpenApiResponse(description="The requesting user is not authenticated."), + 403: OpenApiResponse(description="The requesting user lacks instructor access to the course."), + 404: OpenApiResponse(description="Learner not found or course does not exist."), }, ) def get(self, request, course_id, email_or_username): @@ -2620,25 +2617,22 @@ class ProblemView(DeveloperErrorViewMixin, APIView): permission_classes = (IsAuthenticated, permissions.InstructorPermission) permission_name = permissions.VIEW_DASHBOARD - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter( - 'course_id', - apidocs.ParameterLocation.PATH, - description="Course key for the course.", - ), - apidocs.string_parameter( + COURSE_ID_PATH_PARAMETER, + OpenApiParameter( 'location', - apidocs.ParameterLocation.PATH, + OpenApiTypes.STR, + OpenApiParameter.PATH, description="Problem block usage key", ), ], responses={ - 200: 'Problem information retrieved successfully', - 400: "Invalid parameters provided.", - 401: "The requesting user is not authenticated.", - 403: "The requesting user lacks instructor access to the course.", - 404: "Problem not found or course does not exist.", + 200: OpenApiResponse(description='Problem information retrieved successfully'), + 400: OpenApiResponse(description="Invalid parameters provided."), + 401: OpenApiResponse(description="The requesting user is not authenticated."), + 403: OpenApiResponse(description="The requesting user lacks instructor access to the course."), + 404: OpenApiResponse(description="Problem not found or course does not exist."), }, ) def get(self, request, course_id, location): @@ -2749,25 +2743,22 @@ class TaskStatusView(DeveloperErrorViewMixin, APIView): permission_classes = (IsAuthenticated, permissions.InstructorPermission) permission_name = permissions.SHOW_TASKS - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter( - 'course_id', - apidocs.ParameterLocation.PATH, - description="Course key for the course.", - ), - apidocs.string_parameter( + COURSE_ID_PATH_PARAMETER, + OpenApiParameter( 'task_id', - apidocs.ParameterLocation.PATH, + OpenApiTypes.STR, + OpenApiParameter.PATH, description="Task identifier returned from async operation", ), ], responses={ - 200: 'Task status retrieved successfully', - 400: "Invalid parameters provided.", - 401: "The requesting user is not authenticated.", - 403: "The requesting user lacks instructor access to the course.", - 404: "Task not found.", + 200: OpenApiResponse(description='Task status retrieved successfully'), + 400: OpenApiResponse(description="Invalid parameters provided."), + 401: OpenApiResponse(description="The requesting user is not authenticated."), + 403: OpenApiResponse(description="The requesting user lacks instructor access to the course."), + 404: OpenApiResponse(description="Task not found."), }, ) def get(self, request, course_id, task_id): @@ -2859,20 +2850,16 @@ class GradingConfigView(DeveloperErrorViewMixin, APIView): permission_classes = (IsAuthenticated, permissions.InstructorPermission) permission_name = permissions.VIEW_DASHBOARD - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter( - 'course_id', - apidocs.ParameterLocation.PATH, - description="Course key for the course.", - ), + COURSE_ID_PATH_PARAMETER, ], responses={ - 200: 'HTML-formatted grading configuration summary', - 400: "Invalid parameters provided.", - 401: "The requesting user is not authenticated.", - 403: "The requesting user lacks instructor access to the course.", - 404: "Course does not exist.", + 200: OpenApiResponse(description='HTML-formatted grading configuration summary'), + 400: OpenApiResponse(description="Invalid parameters provided."), + 401: OpenApiResponse(description="The requesting user is not authenticated."), + 403: OpenApiResponse(description="The requesting user lacks instructor access to the course."), + 404: OpenApiResponse(description="Course does not exist."), }, ) def get(self, request, course_id): @@ -3040,12 +3027,12 @@ def _unenroll_one(self, course_key, course, identifier, email_students, reason, 'after': after.to_dict(), } - @apidocs.schema( - body=EnrollmentModifyRequestSerializerV2, + @extend_schema( + request=EnrollmentModifyRequestSerializerV2, responses={ 200: EnrollmentModifyResponseSerializerV2, - 400: "Invalid parameters", - 403: "User does not have permission", + 400: OpenApiResponse(description="Invalid parameters"), + 403: OpenApiResponse(description="User does not have permission"), }, ) def post(self, request, course_id): @@ -3157,12 +3144,12 @@ def _modify_one(self, action, course, course_key, identifier, email_students, au 'is_active': user_active, } - @apidocs.schema( - body=BetaTesterModifyRequestSerializerV2, + @extend_schema( + request=BetaTesterModifyRequestSerializerV2, responses={ 200: BetaTesterModifyResponseSerializerV2, - 400: "Invalid parameters", - 403: "User does not have permission", + 400: OpenApiResponse(description="Invalid parameters"), + 403: OpenApiResponse(description="User does not have permission"), }, ) def post(self, request, course_id): @@ -3702,31 +3689,24 @@ class ResetAttemptsView(DeveloperErrorViewMixin, APIView): permission_classes = (IsAuthenticated, permissions.InstructorPermission) permission_name = permissions.GIVE_STUDENT_EXTENSION - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter( - 'course_id', - apidocs.ParameterLocation.PATH, - description="Course key for the course.", - ), - apidocs.string_parameter( - 'problem', - apidocs.ParameterLocation.PATH, - description="Problem block usage key.", - ), - apidocs.string_parameter( + COURSE_ID_PATH_PARAMETER, + PROBLEM_PATH_PARAMETER, + OpenApiParameter( 'learner', - apidocs.ParameterLocation.QUERY, + OpenApiTypes.STR, + OpenApiParameter.QUERY, description="Optional: Learner username or email. If omitted, resets all learners (async).", ), ], responses={ 200: SyncOperationResultSerializer, 202: AsyncOperationResultSerializer, - 400: "Invalid parameters provided.", - 401: "The requesting user is not authenticated.", - 403: "The requesting user lacks permission.", - 404: "Learner not found.", + 400: OpenApiResponse(description="Invalid parameters provided."), + 401: OpenApiResponse(description="The requesting user is not authenticated."), + 403: OpenApiResponse(description="The requesting user lacks permission."), + 404: OpenApiResponse(description="Learner not found."), }, ) def post(self, request, course_id, problem): @@ -3808,30 +3788,23 @@ class DeleteStateView(DeveloperErrorViewMixin, APIView): permission_classes = (IsAuthenticated, permissions.InstructorPermission) permission_name = permissions.GIVE_STUDENT_EXTENSION - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter( - 'course_id', - apidocs.ParameterLocation.PATH, - description="Course key for the course.", - ), - apidocs.string_parameter( - 'problem', - apidocs.ParameterLocation.PATH, - description="Problem block usage key.", - ), - apidocs.string_parameter( + COURSE_ID_PATH_PARAMETER, + PROBLEM_PATH_PARAMETER, + OpenApiParameter( 'learner', - apidocs.ParameterLocation.QUERY, + OpenApiTypes.STR, + OpenApiParameter.QUERY, description="Learner username or email (required).", ), ], responses={ 200: SyncOperationResultSerializer, - 400: "Invalid parameters or missing learner.", - 401: "The requesting user is not authenticated.", - 403: "The requesting user lacks permission.", - 404: "Learner not found.", + 400: OpenApiResponse(description="Invalid parameters or missing learner."), + 401: OpenApiResponse(description="The requesting user is not authenticated."), + 403: OpenApiResponse(description="The requesting user lacks permission."), + 404: OpenApiResponse(description="Learner not found."), }, ) def delete(self, request, course_id, problem): @@ -3895,26 +3868,20 @@ class RescoreView(DeveloperErrorViewMixin, APIView): permission_classes = (IsAuthenticated, permissions.InstructorPermission) permission_name = permissions.OVERRIDE_GRADES - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter( - 'course_id', - apidocs.ParameterLocation.PATH, - description="Course key for the course.", - ), - apidocs.string_parameter( - 'problem', - apidocs.ParameterLocation.PATH, - description="Problem block usage key.", - ), - apidocs.string_parameter( + COURSE_ID_PATH_PARAMETER, + PROBLEM_PATH_PARAMETER, + OpenApiParameter( 'learner', - apidocs.ParameterLocation.QUERY, + OpenApiTypes.STR, + OpenApiParameter.QUERY, description="Optional: Learner username or email. If omitted, rescores all learners.", ), - apidocs.string_parameter( + OpenApiParameter( 'only_if_higher', - apidocs.ParameterLocation.QUERY, + OpenApiTypes.STR, + OpenApiParameter.QUERY, description="Optional: If 'true', only update scores that are higher than current. " "May be provided as a query parameter or in the request body " "(JSON boolean or string).", @@ -3922,10 +3889,10 @@ class RescoreView(DeveloperErrorViewMixin, APIView): ], responses={ 202: AsyncOperationResultSerializer, - 400: "Invalid parameters provided.", - 401: "The requesting user is not authenticated.", - 403: "The requesting user lacks permission.", - 404: "Learner not found.", + 400: OpenApiResponse(description="Invalid parameters provided."), + 401: OpenApiResponse(description="The requesting user is not authenticated."), + 403: OpenApiResponse(description="The requesting user lacks permission."), + 404: OpenApiResponse(description="Learner not found."), }, ) def post(self, request, course_id, problem): @@ -4008,30 +3975,23 @@ class ScoreOverrideView(DeveloperErrorViewMixin, APIView): permission_classes = (IsAuthenticated, permissions.InstructorPermission) permission_name = permissions.OVERRIDE_GRADES - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter( - 'course_id', - apidocs.ParameterLocation.PATH, - description="Course key for the course.", - ), - apidocs.string_parameter( - 'problem', - apidocs.ParameterLocation.PATH, - description="Problem block usage key.", - ), - apidocs.string_parameter( + COURSE_ID_PATH_PARAMETER, + PROBLEM_PATH_PARAMETER, + OpenApiParameter( 'learner', - apidocs.ParameterLocation.QUERY, + OpenApiTypes.STR, + OpenApiParameter.QUERY, description="Learner username or email (required).", ), ], responses={ 202: AsyncOperationResultSerializer, - 400: "Invalid parameters or invalid score.", - 401: "The requesting user is not authenticated.", - 403: "The requesting user lacks permission.", - 404: "Learner not found.", + 400: OpenApiResponse(description="Invalid parameters or invalid score."), + 401: OpenApiResponse(description="The requesting user is not authenticated."), + 403: OpenApiResponse(description="The requesting user lacks permission."), + 404: OpenApiResponse(description="Learner not found."), }, ) def put(self, request, course_id, problem): @@ -4120,18 +4080,14 @@ class SpecialExamsListView(DeveloperErrorViewMixin, APIView): permission_classes = (IsAuthenticated, permissions.InstructorPermission) permission_name = permissions.EXAM_RESULTS - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter( - 'course_id', - apidocs.ParameterLocation.PATH, - description="Course key for the course.", - ), + COURSE_ID_PATH_PARAMETER, ], responses={ 200: SpecialExamSerializer(many=True), - 401: "The requesting user is not authenticated.", - 403: "The requesting user lacks access.", + 401: OpenApiResponse(description="The requesting user is not authenticated."), + 403: OpenApiResponse(description="The requesting user lacks access."), }, ) def get(self, request, course_id): @@ -4161,24 +4117,16 @@ class SpecialExamDetailView(DeveloperErrorViewMixin, APIView): permission_classes = (IsAuthenticated, permissions.InstructorPermission) permission_name = permissions.EXAM_RESULTS - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter( - 'course_id', - apidocs.ParameterLocation.PATH, - description="Course key for the course.", - ), - apidocs.string_parameter( - 'exam_id', - apidocs.ParameterLocation.PATH, - description="Exam identifier.", - ), + COURSE_ID_PATH_PARAMETER, + EXAM_ID_PATH_PARAMETER, ], responses={ 200: SpecialExamSerializer, - 401: "The requesting user is not authenticated.", - 403: "The requesting user lacks access.", - 404: "Exam not found.", + 401: OpenApiResponse(description="The requesting user is not authenticated."), + 403: OpenApiResponse(description="The requesting user lacks access."), + 404: OpenApiResponse(description="Exam not found."), }, ) def get(self, request, course_id, exam_id): @@ -4211,29 +4159,22 @@ class SpecialExamResetView(DeveloperErrorViewMixin, APIView): permission_classes = (IsAuthenticated, permissions.InstructorPermission) permission_name = permissions.EXAM_RESULTS - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter( - 'course_id', - apidocs.ParameterLocation.PATH, - description="Course key for the course.", - ), - apidocs.string_parameter( - 'exam_id', - apidocs.ParameterLocation.PATH, - description="Exam identifier.", - ), - apidocs.string_parameter( + COURSE_ID_PATH_PARAMETER, + EXAM_ID_PATH_PARAMETER, + OpenApiParameter( 'username', - apidocs.ParameterLocation.PATH, + OpenApiTypes.STR, + OpenApiParameter.PATH, description="Student's username.", ), ], responses={ - 200: "Attempt reset successfully.", - 401: "The requesting user is not authenticated.", - 403: "The requesting user lacks access.", - 404: "Exam or user not found.", + 200: OpenApiResponse(description="Attempt reset successfully."), + 401: OpenApiResponse(description="The requesting user is not authenticated."), + 403: OpenApiResponse(description="The requesting user lacks access."), + 404: OpenApiResponse(description="Exam or user not found."), }, ) def post(self, request, course_id, exam_id, username): @@ -4336,19 +4277,15 @@ class ProctoringSettingsView(DeveloperErrorViewMixin, APIView): permission_classes = (IsAuthenticated, permissions.InstructorPermission) permission_name = permissions.VIEW_DASHBOARD - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter( - 'course_id', - apidocs.ParameterLocation.PATH, - description="Course key for the course.", - ), + COURSE_ID_PATH_PARAMETER, ], responses={ 200: ProctoringSettingsSerializer, - 401: "The requesting user is not authenticated.", - 403: "The requesting user lacks access.", - 404: "Course not found.", + 401: OpenApiResponse(description="The requesting user is not authenticated."), + 403: OpenApiResponse(description="The requesting user lacks access."), + 404: OpenApiResponse(description="Course not found."), }, ) def get(self, request, course_id): @@ -4359,20 +4296,16 @@ def get(self, request, course_id): serializer = ProctoringSettingsSerializer(settings_data) return Response(serializer.data, status=status.HTTP_200_OK) - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter( - 'course_id', - apidocs.ParameterLocation.PATH, - description="Course key for the course.", - ), + COURSE_ID_PATH_PARAMETER, ], responses={ 200: ProctoringSettingsSerializer, - 400: "Invalid parameters.", - 401: "The requesting user is not authenticated.", - 403: "The requesting user lacks access.", - 404: "Course not found.", + 400: OpenApiResponse(description="Invalid parameters."), + 401: OpenApiResponse(description="The requesting user is not authenticated."), + 403: OpenApiResponse(description="The requesting user lacks access."), + 404: OpenApiResponse(description="Course not found."), }, ) def patch(self, request, course_id): @@ -4429,25 +4362,17 @@ class ExamAllowanceView(DeveloperErrorViewMixin, APIView): permission_classes = (IsAuthenticated, permissions.InstructorPermission) permission_name = permissions.EXAM_RESULTS - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter( - 'course_id', - apidocs.ParameterLocation.PATH, - description="Course key for the course.", - ), - apidocs.string_parameter( - 'exam_id', - apidocs.ParameterLocation.PATH, - description="Exam identifier.", - ), + COURSE_ID_PATH_PARAMETER, + EXAM_ID_PATH_PARAMETER, ], responses={ - 200: "Allowance granted successfully.", - 400: "Invalid parameters.", - 401: "The requesting user is not authenticated.", - 403: "The requesting user lacks access.", - 404: "Exam not found.", + 200: OpenApiResponse(description="Allowance granted successfully."), + 400: OpenApiResponse(description="Invalid parameters."), + 401: OpenApiResponse(description="The requesting user is not authenticated."), + 403: OpenApiResponse(description="The requesting user lacks access."), + 404: OpenApiResponse(description="Exam not found."), }, ) def post(self, request, course_id, exam_id): @@ -4489,25 +4414,17 @@ def post(self, request, course_id, exam_id): status=status.HTTP_200_OK, ) - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter( - 'course_id', - apidocs.ParameterLocation.PATH, - description="Course key for the course.", - ), - apidocs.string_parameter( - 'exam_id', - apidocs.ParameterLocation.PATH, - description="Exam identifier.", - ), + COURSE_ID_PATH_PARAMETER, + EXAM_ID_PATH_PARAMETER, ], responses={ - 200: "Allowance removed successfully.", - 400: "Invalid parameters.", - 401: "The requesting user is not authenticated.", - 403: "The requesting user lacks access.", - 404: "Exam not found.", + 200: OpenApiResponse(description="Allowance removed successfully."), + 400: OpenApiResponse(description="Invalid parameters."), + 401: OpenApiResponse(description="The requesting user is not authenticated."), + 403: OpenApiResponse(description="The requesting user lacks access."), + 404: OpenApiResponse(description="Exam not found."), }, ) def delete(self, request, course_id, exam_id): From 27f5edf5fa19d61b6d7b3a9d6f914425178cb401 Mon Sep 17 00:00:00 2001 From: Muhammad Faraz Maqsood Date: Wed, 16 Sep 2026 11:42:30 +0500 Subject: [PATCH 05/12] refactor: migrate cms from edx-api-doc-tools to drf-spectacular Converts the remaining @apidocs.schema decorators across contentstore and modulestore_migrator to @extend_schema. The three class-level @apidocs.schema_for decorators become @extend_schema_view, splitting each docstring into summary and description as schema_for did. Files that already imported drf-spectacular for the FC-0118 work have their import lines merged rather than duplicated. --- .../api/views/course_validation.py | 12 +- .../rest_api/v0/views/advanced_settings.py | 35 ++--- .../rest_api/v0/views/api_heartbeat.py | 10 +- .../rest_api/v0/views/authoring_grading.py | 15 +- .../rest_api/v0/views/course_optimizer.py | 59 ++++---- .../contentstore/rest_api/v0/views/tabs.py | 45 +++--- .../rest_api/v1/views/certificates.py | 15 +- .../rest_api/v1/views/course_details.py | 31 +++-- .../rest_api/v1/views/course_index.py | 34 +++-- .../rest_api/v1/views/course_rerun.py | 13 +- .../rest_api/v1/views/course_team.py | 13 +- .../contentstore/rest_api/v1/views/grading.py | 25 ++-- .../rest_api/v1/views/group_configurations.py | 15 +- .../contentstore/rest_api/v1/views/home.py | 26 ++-- .../rest_api/v1/views/proctoring.py | 13 +- .../rest_api/v1/views/settings.py | 13 +- .../rest_api/v1/views/textbooks.py | 15 +- .../rest_api/v1/views/vertical_block.py | 14 +- .../contentstore/rest_api/v1/views/videos.py | 41 +++--- .../contentstore/rest_api/v2/views/home.py | 52 ++++--- .../contentstore/rest_api/v3/views/home.py | 27 ++-- .../modulestore_migrator/rest_api/v1/views.py | 128 ++++++++++-------- 22 files changed, 349 insertions(+), 302 deletions(-) diff --git a/cms/djangoapps/contentstore/api/views/course_validation.py b/cms/djangoapps/contentstore/api/views/course_validation.py index 668431fa0e50..747e5564e312 100644 --- a/cms/djangoapps/contentstore/api/views/course_validation.py +++ b/cms/djangoapps/contentstore/api/views/course_validation.py @@ -2,7 +2,7 @@ import logging import dateutil -import edx_api_doc_tools as apidocs +from drf_spectacular.utils import OpenApiResponse, extend_schema from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser from openedx_authz.constants.permissions import COURSES_VIEW_COURSE @@ -378,10 +378,10 @@ class CourseLegacyLibraryContentMigratorView(DeveloperErrorViewMixin, StatusView ) serializer_class = StatusSerializerWithUuid - @apidocs.schema( + @extend_schema( responses={ 200: CourseLegacyLibraryContentSerializer(many=True), - 401: "The requester is not authenticated.", + 401: OpenApiResponse(description="The requester is not authenticated."), }, ) @authz_permission_required(COURSES_VIEW_COURSE.identifier, LegacyAuthoringPermission.WRITE) @@ -393,10 +393,10 @@ def list(self, _, course_key): # pylint: disable=arguments-differ serializer = CourseLegacyLibraryContentSerializer(blocks, many=True) return Response(serializer.data) - @apidocs.schema( + @extend_schema( responses={ - 200: "In case of success, a 200.", - 401: "The requester is not authenticated.", + 200: OpenApiResponse(description="In case of success, a 200."), + 401: OpenApiResponse(description="The requester is not authenticated."), }, ) @course_author_access_required diff --git a/cms/djangoapps/contentstore/rest_api/v0/views/advanced_settings.py b/cms/djangoapps/contentstore/rest_api/v0/views/advanced_settings.py index e7baf54b375d..c66663cd208d 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/views/advanced_settings.py +++ b/cms/djangoapps/contentstore/rest_api/v0/views/advanced_settings.py @@ -1,7 +1,8 @@ """ API Views for course advanced settings """ -import edx_api_doc_tools as apidocs from django import forms +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from opaque_keys.edx.keys import CourseKey from rest_framework.exceptions import ValidationError from rest_framework.request import Request @@ -36,25 +37,27 @@ def clean_filter_fields(self): return set(self.cleaned_data['filter_fields'].split(',')) return None - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID"), - apidocs.string_parameter( + OpenApiParameter("course_id", OpenApiTypes.STR, OpenApiParameter.PATH, description="Course ID"), + OpenApiParameter( "filter_fields", - apidocs.ParameterLocation.QUERY, + OpenApiTypes.STR, + OpenApiParameter.QUERY, description="Comma separated list of fields to filter", ), - apidocs.string_parameter( + OpenApiParameter( "fetch_all", - apidocs.ParameterLocation.QUERY, + OpenApiTypes.STR, + OpenApiParameter.QUERY, description="Specifies whether to fetch all settings or only enabled ones", ), ], responses={ 200: CourseAdvancedSettingsSerializer, - 401: "The requester is not authenticated.", - 403: "The requester cannot access the specified course.", - 404: "The requested course does not exist.", + 401: OpenApiResponse(description="The requester is not authenticated."), + 403: OpenApiResponse(description="The requester cannot access the specified course."), + 404: OpenApiResponse(description="The requested course does not exist."), }, ) @verify_course_exists() @@ -130,14 +133,14 @@ def get(self, request: Request, course_id: str): filter_fields=filter_query_data.cleaned_data['filter_fields'], )) - @apidocs.schema( - body=CourseAdvancedSettingsSerializer, - parameters=[apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID")], + @extend_schema( + request=CourseAdvancedSettingsSerializer, + parameters=[OpenApiParameter("course_id", OpenApiTypes.STR, OpenApiParameter.PATH, description="Course ID")], responses={ 200: CourseAdvancedSettingsSerializer, - 401: "The requester is not authenticated.", - 403: "The requester cannot access the specified course.", - 404: "The requested course does not exist.", + 401: OpenApiResponse(description="The requester is not authenticated."), + 403: OpenApiResponse(description="The requester cannot access the specified course."), + 404: OpenApiResponse(description="The requested course does not exist."), }, ) @verify_course_exists() diff --git a/cms/djangoapps/contentstore/rest_api/v0/views/api_heartbeat.py b/cms/djangoapps/contentstore/rest_api/v0/views/api_heartbeat.py index 78aa655f652a..04eac9dfb232 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/views/api_heartbeat.py +++ b/cms/djangoapps/contentstore/rest_api/v0/views/api_heartbeat.py @@ -1,5 +1,5 @@ """ View For Getting the Status of The Authoring API """ -import edx_api_doc_tools as apidocs +from drf_spectacular.utils import OpenApiResponse, extend_schema from rest_framework import status from rest_framework.request import Request from rest_framework.response import Response @@ -13,12 +13,12 @@ class APIHeartBeatView(DeveloperErrorViewMixin, APIView): View for getting the Authoring API's status """ - @apidocs.schema( + @extend_schema( parameters=[], responses={ - 200: "The API is online", - 401: "The requester is not authenticated.", - 403: "The API is not availible", + 200: OpenApiResponse(description="The API is online"), + 401: OpenApiResponse(description="The requester is not authenticated."), + 403: OpenApiResponse(description="The API is not availible"), }, ) @view_auth_classes(is_authenticated=True) diff --git a/cms/djangoapps/contentstore/rest_api/v0/views/authoring_grading.py b/cms/djangoapps/contentstore/rest_api/v0/views/authoring_grading.py index db3fffd17fa7..7aabd8eea6b1 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/views/authoring_grading.py +++ b/cms/djangoapps/contentstore/rest_api/v0/views/authoring_grading.py @@ -1,6 +1,7 @@ """ API Views for course advanced settings """ -import edx_api_doc_tools as apidocs +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from opaque_keys.edx.keys import CourseKey from openedx_authz.constants.permissions import COURSES_EDIT_GRADING_SETTINGS from rest_framework.request import Request @@ -21,16 +22,16 @@ class AuthoringGradingView(DeveloperErrorViewMixin, APIView): """ View for getting and setting the advanced settings for a course. """ - @apidocs.schema( - body=CourseGradingModelSerializer, + @extend_schema( + request=CourseGradingModelSerializer, parameters=[ - apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID"), + OpenApiParameter("course_id", OpenApiTypes.STR, OpenApiParameter.PATH, description="Course ID"), ], responses={ 200: CourseGradingModelSerializer, - 401: "The requester is not authenticated.", - 403: "The requester cannot access the specified course.", - 404: "The requested course does not exist.", + 401: OpenApiResponse(description="The requester is not authenticated."), + 403: OpenApiResponse(description="The requester cannot access the specified course."), + 404: OpenApiResponse(description="The requested course does not exist."), }, ) @verify_course_exists() diff --git a/cms/djangoapps/contentstore/rest_api/v0/views/course_optimizer.py b/cms/djangoapps/contentstore/rest_api/v0/views/course_optimizer.py index 9b012d918e06..591f4ea81c54 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/views/course_optimizer.py +++ b/cms/djangoapps/contentstore/rest_api/v0/views/course_optimizer.py @@ -1,6 +1,7 @@ """API Views for Course Optimizer.""" -import edx_api_doc_tools as apidocs +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey from rest_framework import status @@ -31,15 +32,15 @@ class LinkCheckView(DeveloperErrorViewMixin, APIView): """ View for queueing a celery task to scan a course for broken links. """ - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID"), + OpenApiParameter("course_id", OpenApiTypes.STR, OpenApiParameter.PATH, description="Course ID"), ], responses={ - 200: "Celery task queued.", - 401: "The requester is not authenticated.", - 403: "The requester cannot access the specified course.", - 404: "The requested course does not exist.", + 200: OpenApiResponse(description="Celery task queued."), + 401: OpenApiResponse(description="The requester is not authenticated."), + 403: OpenApiResponse(description="The requester cannot access the specified course."), + 404: OpenApiResponse(description="The requested course does not exist."), }, ) @verify_course_exists() @@ -70,15 +71,15 @@ class LinkCheckStatusView(DeveloperErrorViewMixin, APIView): """ View for checking the status of the celery task and returning the results. """ - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID"), + OpenApiParameter("course_id", OpenApiTypes.STR, OpenApiParameter.PATH, description="Course ID"), ], responses={ - 200: "OK", - 401: "The requester is not authenticated.", - 403: "The requester cannot access the specified course.", - 404: "The requested course does not exist.", + 200: OpenApiResponse(description="OK"), + 401: OpenApiResponse(description="The requester is not authenticated."), + 403: OpenApiResponse(description="The requester cannot access the specified course."), + 404: OpenApiResponse(description="The requested course does not exist."), }, ) def get(self, request: Request, course_id: str): @@ -222,19 +223,19 @@ class RerunLinkUpdateView(DeveloperErrorViewMixin, APIView): View for queueing a celery task to update course links to the latest re-run. """ - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter( - "course_id", apidocs.ParameterLocation.PATH, description="Course ID" + OpenApiParameter( + "course_id", OpenApiTypes.STR, OpenApiParameter.PATH, description="Course ID" ) ], - body=CourseRerunLinkUpdateRequestSerializer, + request=CourseRerunLinkUpdateRequestSerializer, responses={ - 200: "Celery task queued.", - 400: "Bad request - invalid action or missing data.", - 401: "The requester is not authenticated.", - 403: "The requester cannot access the specified course.", - 404: "The requested course does not exist.", + 200: OpenApiResponse(description="Celery task queued."), + 400: OpenApiResponse(description="Bad request - invalid action or missing data."), + 401: OpenApiResponse(description="The requester is not authenticated."), + 403: OpenApiResponse(description="The requester cannot access the specified course."), + 404: OpenApiResponse(description="The requested course does not exist."), }, ) @verify_course_exists() @@ -326,17 +327,17 @@ class RerunLinkUpdateStatusView(DeveloperErrorViewMixin, APIView): View for checking the status of the course link update task and returning the results. """ - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter( - "course_id", apidocs.ParameterLocation.PATH, description="Course ID" + OpenApiParameter( + "course_id", OpenApiTypes.STR, OpenApiParameter.PATH, description="Course ID" ), ], responses={ - 200: "OK", - 401: "The requester is not authenticated.", - 403: "The requester cannot access the specified course.", - 404: "The requested course does not exist.", + 200: OpenApiResponse(description="OK"), + 401: OpenApiResponse(description="The requester is not authenticated."), + 403: OpenApiResponse(description="The requester cannot access the specified course."), + 404: OpenApiResponse(description="The requested course does not exist."), }, ) def get(self, request: Request, course_id: str): diff --git a/cms/djangoapps/contentstore/rest_api/v0/views/tabs.py b/cms/djangoapps/contentstore/rest_api/v0/views/tabs.py index e1b9401cd0f9..57664fdf44c8 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/views/tabs.py +++ b/cms/djangoapps/contentstore/rest_api/v0/views/tabs.py @@ -1,7 +1,8 @@ """ API Views for course tabs """ -import edx_api_doc_tools as apidocs from django.utils.translation import gettext_lazy as _ +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from opaque_keys.edx.keys import CourseKey from openedx_authz.constants.permissions import ( COURSES_MANAGE_PAGES_AND_RESOURCES, @@ -28,13 +29,13 @@ class CourseTabListView(DeveloperErrorViewMixin, APIView): API view to list course tabs. """ - @apidocs.schema( - parameters=[apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID")], + @extend_schema( + parameters=[OpenApiParameter("course_id", OpenApiTypes.STR, OpenApiParameter.PATH, description="Course ID")], responses={ 200: CourseTabSerializer, - 401: "The requester is not authenticated.", - 403: "The requester cannot access the specified course.", - 404: "The requested course does not exist.", + 401: OpenApiResponse(description="The requester is not authenticated."), + 403: OpenApiResponse(description="The requester cannot access the specified course."), + 404: OpenApiResponse(description="The requested course does not exist."), }, ) @verify_course_exists() @@ -119,18 +120,18 @@ def handle_exception(self, exc): return self._make_error_response(400, str(exc)) return super().handle_exception(exc) - @apidocs.schema( - body=CourseTabUpdateSerializer(help_text=_("Change the visibility of tabs in a course.")), + @extend_schema( + request=CourseTabUpdateSerializer(help_text=_("Change the visibility of tabs in a course.")), parameters=[ - apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID"), - apidocs.string_parameter("tab_id", apidocs.ParameterLocation.QUERY, description="Tab ID"), - apidocs.string_parameter("tab_location", apidocs.ParameterLocation.QUERY, description="Tab usage key"), + OpenApiParameter("course_id", OpenApiTypes.STR, OpenApiParameter.PATH, description="Course ID"), + OpenApiParameter("tab_id", OpenApiTypes.STR, OpenApiParameter.QUERY, description="Tab ID"), + OpenApiParameter("tab_location", OpenApiTypes.STR, OpenApiParameter.QUERY, description="Tab usage key"), ], responses={ - 204: "In case of success, a 204 is returned with no content.", - 401: "The requester is not authenticated.", - 403: "The requester cannot access the specified course.", - 404: "The requested course does not exist.", + 204: OpenApiResponse(description="In case of success, a 204 is returned with no content."), + 401: OpenApiResponse(description="The requester is not authenticated."), + 403: OpenApiResponse(description="The requester cannot access the specified course."), + 404: OpenApiResponse(description="The requested course does not exist."), }, ) @verify_course_exists() @@ -200,16 +201,16 @@ def handle_exception(self, exc: Exception) -> Response: return self._make_error_response(400, str(exc)) return super().handle_exception(exc) - @apidocs.schema( - body=TabIDLocatorSerializer(many=True), + @extend_schema( + request=TabIDLocatorSerializer(many=True), parameters=[ - apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID"), + OpenApiParameter("course_id", OpenApiTypes.STR, OpenApiParameter.PATH, description="Course ID"), ], responses={ - 204: "In case of success, a 204 is returned with no content.", - 401: "The requester is not authenticated.", - 403: "The requester cannot access the specified course.", - 404: "The requested course does not exist.", + 204: OpenApiResponse(description="In case of success, a 204 is returned with no content."), + 401: OpenApiResponse(description="The requester is not authenticated."), + 403: OpenApiResponse(description="The requester cannot access the specified course."), + 404: OpenApiResponse(description="The requested course does not exist."), }, ) @verify_course_exists() diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/certificates.py b/cms/djangoapps/contentstore/rest_api/v1/views/certificates.py index adb835d30c2c..5729f9542a5c 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/certificates.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/certificates.py @@ -1,6 +1,7 @@ """ API Views for course certificates """ -import edx_api_doc_tools as apidocs +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from opaque_keys.edx.keys import CourseKey from openedx_authz.constants.permissions import COURSES_MANAGE_CERTIFICATES, COURSES_VIEW_CERTIFICATES from rest_framework.request import Request @@ -21,17 +22,17 @@ class CourseCertificatesView(DeveloperErrorViewMixin, APIView): View for course certificate page. """ - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter( - "course_id", apidocs.ParameterLocation.PATH, description="Course ID" + OpenApiParameter( + "course_id", OpenApiTypes.STR, OpenApiParameter.PATH, description="Course ID" ), ], responses={ 200: CourseCertificatesSerializer, - 401: "The requester is not authenticated.", - 403: "The requester cannot access the specified course.", - 404: "The requested course does not exist.", + 401: OpenApiResponse(description="The requester is not authenticated."), + 403: OpenApiResponse(description="The requester cannot access the specified course."), + 404: OpenApiResponse(description="The requested course does not exist."), }, ) @verify_course_exists() diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/course_details.py b/cms/djangoapps/contentstore/rest_api/v1/views/course_details.py index f795924e67cb..f21eec48db83 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/course_details.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/course_details.py @@ -1,12 +1,13 @@ """ API Views for course details """ -import edx_api_doc_tools as apidocs from django.core.exceptions import ValidationError +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from opaque_keys.edx.keys import CourseKey from openedx_authz.constants.permissions import ( - COURSES_EDIT_DETAILS, - COURSES_EDIT_SCHEDULE, - COURSES_VIEW_SCHEDULE_AND_DETAILS, + COURSES_EDIT_DETAILS, + COURSES_EDIT_SCHEDULE, + COURSES_VIEW_SCHEDULE_AND_DETAILS, ) from rest_framework.request import Request from rest_framework.response import Response @@ -101,15 +102,15 @@ class CourseDetailsView(DeveloperErrorViewMixin, APIView): """ View for getting and setting the course details. """ - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID"), + OpenApiParameter("course_id", OpenApiTypes.STR, OpenApiParameter.PATH, description="Course ID"), ], responses={ 200: CourseDetailsSerializer, - 401: "The requester is not authenticated.", - 403: "The requester cannot access the specified course.", - 404: "The requested course does not exist.", + 401: OpenApiResponse(description="The requester is not authenticated."), + 403: OpenApiResponse(description="The requester cannot access the specified course."), + 404: OpenApiResponse(description="The requested course does not exist."), }, ) @verify_course_exists() @@ -190,16 +191,16 @@ def get(self, request: Request, course_id: str): serializer = CourseDetailsSerializer(course_details) return Response(serializer.data) - @apidocs.schema( - body=CourseDetailsSerializer, + @extend_schema( + request=CourseDetailsSerializer, parameters=[ - apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID"), + OpenApiParameter("course_id", OpenApiTypes.STR, OpenApiParameter.PATH, description="Course ID"), ], responses={ 200: CourseDetailsSerializer, - 401: "The requester is not authenticated.", - 403: "The requester cannot access the specified course.", - 404: "The requested course does not exist.", + 401: OpenApiResponse(description="The requester is not authenticated."), + 403: OpenApiResponse(description="The requester cannot access the specified course."), + 404: OpenApiResponse(description="The requested course does not exist."), }, ) @verify_course_exists() diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/course_index.py b/cms/djangoapps/contentstore/rest_api/v1/views/course_index.py index f95c4e23e895..aa39f5371329 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/course_index.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/course_index.py @@ -2,8 +2,9 @@ import logging -import edx_api_doc_tools as apidocs from django.conf import settings +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from opaque_keys.edx.keys import CourseKey from openedx_authz.constants.permissions import COURSES_VIEW_COURSE from rest_framework.fields import BooleanField @@ -33,19 +34,20 @@ class CourseIndexView(DeveloperErrorViewMixin, APIView): """View for Course Index""" - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID"), - apidocs.string_parameter( + OpenApiParameter("course_id", OpenApiTypes.STR, OpenApiParameter.PATH, description="Course ID"), + OpenApiParameter( "show", - apidocs.ParameterLocation.QUERY, + OpenApiTypes.STR, + OpenApiParameter.QUERY, description="Query param to set initial state which fully expanded to see the item", )], responses={ 200: CourseIndexSerializer, - 401: "The requester is not authenticated.", - 403: "The requester cannot access the specified course.", - 404: "The requested course does not exist.", + 401: OpenApiResponse(description="The requester is not authenticated."), + 403: OpenApiResponse(description="The requester cannot access the specified course."), + 404: OpenApiResponse(description="The requested course does not exist."), }, ) @verify_course_exists() @@ -126,23 +128,25 @@ class ContainerChildrenView(APIView, ContainerHandlerMixin): View for container xblock requests to get state and children data. """ - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter( + OpenApiParameter( "usage_key_string", - apidocs.ParameterLocation.PATH, + OpenApiTypes.STR, + OpenApiParameter.PATH, description="Container usage key", ), - apidocs.string_parameter( + OpenApiParameter( "get_upstream_info", - apidocs.ParameterLocation.QUERY, + OpenApiTypes.STR, + OpenApiParameter.QUERY, description="Gets the info of all ready to sync children", ), ], responses={ 200: ContainerChildrenSerializer, - 401: "The requester is not authenticated.", - 404: "The requested locator does not exist.", + 401: OpenApiResponse(description="The requester is not authenticated."), + 404: OpenApiResponse(description="The requested locator does not exist."), }, ) def get(self, request: Request, usage_key_string: str): diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/course_rerun.py b/cms/djangoapps/contentstore/rest_api/v1/views/course_rerun.py index dbec4b0b8441..8e2a9cddeca8 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/course_rerun.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/course_rerun.py @@ -1,6 +1,7 @@ """ API Views for course rerun """ -import edx_api_doc_tools as apidocs +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from opaque_keys.edx.keys import CourseKey from rest_framework.request import Request from rest_framework.response import Response @@ -19,15 +20,15 @@ class CourseRerunView(DeveloperErrorViewMixin, APIView): View for course rerun. """ - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID"), + OpenApiParameter("course_id", OpenApiTypes.STR, OpenApiParameter.PATH, description="Course ID"), ], responses={ 200: CourseRerunSerializer, - 401: "The requester is not authenticated.", - 403: "The requester cannot access the specified course.", - 404: "The requested course does not exist.", + 401: OpenApiResponse(description="The requester is not authenticated."), + 403: OpenApiResponse(description="The requester cannot access the specified course."), + 404: OpenApiResponse(description="The requested course does not exist."), }, ) @verify_course_exists() diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/course_team.py b/cms/djangoapps/contentstore/rest_api/v1/views/course_team.py index 5b8f7d200a49..ef96ade1cf35 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/course_team.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/course_team.py @@ -1,6 +1,7 @@ """ API Views for course team """ -import edx_api_doc_tools as apidocs +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from opaque_keys.edx.keys import CourseKey from rest_framework.request import Request from rest_framework.response import Response @@ -18,15 +19,15 @@ class CourseTeamView(DeveloperErrorViewMixin, APIView): """ View for getting data for course team. """ - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID"), + OpenApiParameter("course_id", OpenApiTypes.STR, OpenApiParameter.PATH, description="Course ID"), ], responses={ 200: CourseTeamSerializer, - 401: "The requester is not authenticated.", - 403: "The requester cannot access the specified course.", - 404: "The requested course does not exist.", + 401: OpenApiResponse(description="The requester is not authenticated."), + 403: OpenApiResponse(description="The requester cannot access the specified course."), + 404: OpenApiResponse(description="The requested course does not exist."), }, ) @verify_course_exists() diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/grading.py b/cms/djangoapps/contentstore/rest_api/v1/views/grading.py index 275d2063e315..a460ee07f2c2 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/grading.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/grading.py @@ -1,7 +1,8 @@ """ API Views for course grading """ -import edx_api_doc_tools as apidocs from django.conf import settings +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from opaque_keys.edx.keys import CourseKey from openedx_authz.constants.permissions import COURSES_EDIT_GRADING_SETTINGS, COURSES_VIEW_GRADING_SETTINGS from rest_framework.request import Request @@ -26,15 +27,15 @@ class CourseGradingView(DeveloperErrorViewMixin, APIView): View for Course Grading policy configuration. """ - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID"), + OpenApiParameter("course_id", OpenApiTypes.STR, OpenApiParameter.PATH, description="Course ID"), ], responses={ 200: CourseGradingSerializer, - 401: "The requester is not authenticated.", - 403: "The requester cannot access the specified course.", - 404: "The requested course does not exist.", + 401: OpenApiResponse(description="The requester is not authenticated."), + 403: OpenApiResponse(description="The requester cannot access the specified course."), + 404: OpenApiResponse(description="The requested course does not exist."), }, ) @verify_course_exists() @@ -103,16 +104,16 @@ def get(self, request: Request, course_key: CourseKey): serializer = CourseGradingSerializer(grading_context) return Response(serializer.data) - @apidocs.schema( - body=CourseGradingModelSerializer, + @extend_schema( + request=CourseGradingModelSerializer, parameters=[ - apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID"), + OpenApiParameter("course_id", OpenApiTypes.STR, OpenApiParameter.PATH, description="Course ID"), ], responses={ 200: CourseGradingModelSerializer, - 401: "The requester is not authenticated.", - 403: "The requester cannot access the specified course.", - 404: "The requested course does not exist.", + 401: OpenApiResponse(description="The requester is not authenticated."), + 403: OpenApiResponse(description="The requester cannot access the specified course."), + 404: OpenApiResponse(description="The requested course does not exist."), }, ) @verify_course_exists() diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/group_configurations.py b/cms/djangoapps/contentstore/rest_api/v1/views/group_configurations.py index f6373d3febaa..78c03981f087 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/group_configurations.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/group_configurations.py @@ -1,6 +1,7 @@ """ API Views for course's settings group configurations """ -import edx_api_doc_tools as apidocs +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from opaque_keys.edx.keys import CourseKey from openedx_authz.constants.permissions import COURSES_MANAGE_GROUP_CONFIGURATIONS, COURSES_VIEW_GROUP_CONFIGURATIONS from rest_framework.request import Request @@ -21,17 +22,17 @@ class CourseGroupConfigurationsView(DeveloperErrorViewMixin, APIView): View for course's settings group configurations. """ - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter( - "course_id", apidocs.ParameterLocation.PATH, description="Course ID" + OpenApiParameter( + "course_id", OpenApiTypes.STR, OpenApiParameter.PATH, description="Course ID" ), ], responses={ 200: CourseGroupConfigurationsSerializer, - 401: "The requester is not authenticated.", - 403: "The requester cannot access the specified course.", - 404: "The requested course does not exist.", + 401: OpenApiResponse(description="The requester is not authenticated."), + 403: OpenApiResponse(description="The requester cannot access the specified course."), + 404: OpenApiResponse(description="The requested course does not exist."), }, ) @verify_course_exists() diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/home.py b/cms/djangoapps/contentstore/rest_api/v1/views/home.py index 88fd915e5d11..af35f2ab3bcb 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/home.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/home.py @@ -1,7 +1,8 @@ """ API Views for course home """ -import edx_api_doc_tools as apidocs from django.conf import settings +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from organizations import api as org_api from rest_framework.request import Request from rest_framework.response import Response @@ -18,16 +19,17 @@ class HomePageView(APIView): """ View for getting all courses and libraries available to the logged in user. """ - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter( + OpenApiParameter( "org", - apidocs.ParameterLocation.QUERY, + OpenApiTypes.STR, + OpenApiParameter.QUERY, description="Query param to filter by course org", )], responses={ 200: StudioHomeSerializer, - 401: "The requester is not authenticated.", + 401: OpenApiResponse(description="The requester is not authenticated."), }, ) def get(self, request: Request): @@ -104,16 +106,18 @@ class HomePageLibrariesView(APIView): """ View for getting all courses and libraries available to the logged in user. """ - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter( + OpenApiParameter( "org", - apidocs.ParameterLocation.QUERY, + OpenApiTypes.STR, + OpenApiParameter.QUERY, description="Query param to filter by course org", ), - apidocs.query_parameter( + OpenApiParameter( "is_migrated", - bool, + OpenApiTypes.BOOL, + OpenApiParameter.QUERY, description=( "Query param to filter by migrated status of library." " If present (true or false), it will filter by migration status" @@ -123,7 +127,7 @@ class HomePageLibrariesView(APIView): ], responses={ 200: LibraryTabSerializer, - 401: "The requester is not authenticated.", + 401: OpenApiResponse(description="The requester is not authenticated."), }, ) def get(self, request: Request): diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/proctoring.py b/cms/djangoapps/contentstore/rest_api/v1/views/proctoring.py index a129faa1b869..6aa4aa3c17f1 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/proctoring.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/proctoring.py @@ -1,8 +1,9 @@ """ API Views for proctored exam settings and proctoring error """ import copy -import edx_api_doc_tools as apidocs from django.conf import settings +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from opaque_keys.edx.keys import CourseKey from rest_framework import status from rest_framework.exceptions import NotFound @@ -207,15 +208,15 @@ class ProctoringErrorsView(DeveloperErrorViewMixin, APIView): """ View for getting the proctoring errors for a course with url to proctored exam settings. """ - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID"), + OpenApiParameter("course_id", OpenApiTypes.STR, OpenApiParameter.PATH, description="Course ID"), ], responses={ 200: ProctoringErrorsSerializer, - 401: "The requester is not authenticated.", - 403: "The requester cannot access the specified course.", - 404: "The requested course does not exist.", + 401: OpenApiResponse(description="The requester is not authenticated."), + 403: OpenApiResponse(description="The requester cannot access the specified course."), + 404: OpenApiResponse(description="The requested course does not exist."), }, ) @verify_course_exists() diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/settings.py b/cms/djangoapps/contentstore/rest_api/v1/views/settings.py index 0f7337b81f48..a8258dd94f74 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/settings.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/settings.py @@ -1,7 +1,8 @@ """ API Views for course settings """ -import edx_api_doc_tools as apidocs from django.conf import settings +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from opaque_keys.edx.keys import CourseKey from openedx_authz.constants.permissions import COURSES_VIEW_COURSE from rest_framework.request import Request @@ -24,15 +25,15 @@ class CourseSettingsView(DeveloperErrorViewMixin, APIView): View for getting the settings for a course. """ - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID"), + OpenApiParameter("course_id", OpenApiTypes.STR, OpenApiParameter.PATH, description="Course ID"), ], responses={ 200: CourseSettingsSerializer, - 401: "The requester is not authenticated.", - 403: "The requester cannot access the specified course.", - 404: "The requested course does not exist.", + 401: OpenApiResponse(description="The requester is not authenticated."), + 403: OpenApiResponse(description="The requester cannot access the specified course."), + 404: OpenApiResponse(description="The requested course does not exist."), }, ) @verify_course_exists() diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/textbooks.py b/cms/djangoapps/contentstore/rest_api/v1/views/textbooks.py index 6fe358a1b80e..ba0485a5debd 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/textbooks.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/textbooks.py @@ -1,6 +1,7 @@ """ API Views for course textbooks """ -import edx_api_doc_tools as apidocs +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from opaque_keys.edx.keys import CourseKey from openedx_authz.constants.permissions import COURSES_VIEW_PAGES_AND_RESOURCES from rest_framework.request import Request @@ -21,17 +22,17 @@ class CourseTextbooksView(DeveloperErrorViewMixin, APIView): View for course textbooks page. """ - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter( - "course_id", apidocs.ParameterLocation.PATH, description="Course ID" + OpenApiParameter( + "course_id", OpenApiTypes.STR, OpenApiParameter.PATH, description="Course ID" ), ], responses={ 200: CourseTextbooksSerializer, - 401: "The requester is not authenticated.", - 403: "The requester cannot access the specified course.", - 404: "The requested course does not exist.", + 401: OpenApiResponse(description="The requester is not authenticated."), + 403: OpenApiResponse(description="The requester cannot access the specified course."), + 404: OpenApiResponse(description="The requested course does not exist."), }, ) @verify_course_exists() diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/vertical_block.py b/cms/djangoapps/contentstore/rest_api/v1/views/vertical_block.py index 034f57ba31ed..02538a7d1e99 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/vertical_block.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/vertical_block.py @@ -2,9 +2,10 @@ import logging -import edx_api_doc_tools as apidocs from django.http import HttpResponseBadRequest, HttpResponsePermanentRedirect from django.urls import reverse +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView @@ -26,18 +27,19 @@ class ContainerHandlerView(APIView, ContainerHandlerMixin): View for container xblock requests to get vertical data. """ - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter( + OpenApiParameter( "usage_key_string", - apidocs.ParameterLocation.PATH, + OpenApiTypes.STR, + OpenApiParameter.PATH, description="Container usage key", ), ], responses={ 200: ContainerHandlerSerializer, - 401: "The requester is not authenticated.", - 404: "The requested locator does not exist.", + 401: OpenApiResponse(description="The requester is not authenticated."), + 404: OpenApiResponse(description="The requested locator does not exist."), }, ) def get(self, request: Request, usage_key_string: str): diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/videos.py b/cms/djangoapps/contentstore/rest_api/v1/views/videos.py index f648fa70d1ab..49ae6b3e77cf 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/videos.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/videos.py @@ -3,8 +3,9 @@ """ import logging -import edx_api_doc_tools as apidocs from django.conf import settings +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from opaque_keys.edx.keys import CourseKey from rest_framework.request import Request from rest_framework.response import Response @@ -32,15 +33,15 @@ class CourseVideosView(DeveloperErrorViewMixin, APIView): """ View for course videos. """ - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID"), + OpenApiParameter("course_id", OpenApiTypes.STR, OpenApiParameter.PATH, description="Course ID"), ], responses={ 200: CourseVideosSerializer, - 401: "The requester is not authenticated", - 403: "The requester cannot access the specified course", - 404: "The requested course does not exist", + 401: OpenApiResponse(description="The requester is not authenticated"), + 403: OpenApiResponse(description="The requester cannot access the specified course"), + 404: OpenApiResponse(description="The requested course does not exist"), }, ) @verify_course_exists() @@ -143,16 +144,16 @@ class VideoUsageView(DeveloperErrorViewMixin, APIView): """ View for course video usage locations. """ - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID"), - apidocs.string_parameter("edx_video_id", apidocs.ParameterLocation.PATH, description="edX Video ID"), + OpenApiParameter("course_id", OpenApiTypes.STR, OpenApiParameter.PATH, description="Course ID"), + OpenApiParameter("edx_video_id", OpenApiTypes.STR, OpenApiParameter.PATH, description="edX Video ID"), ], responses={ 200: VideoUsageSerializer, - 401: "The requester is not authenticated", - 403: "The requester cannot access the specified course", - 404: "The requested course does not exist", + 401: OpenApiResponse(description="The requester is not authenticated"), + 403: OpenApiResponse(description="The requester cannot access the specified course"), + 404: OpenApiResponse(description="The requested course does not exist"), }, ) @verify_course_exists() @@ -200,17 +201,17 @@ class VideoDownloadView(DeveloperErrorViewMixin, APIView): """ throttle_classes = (VideoDownloadThrottle,) - @apidocs.schema( - body=VideoDownloadSerializer, + @extend_schema( + request=VideoDownloadSerializer, parameters=[ - apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID"), + OpenApiParameter("course_id", OpenApiTypes.STR, OpenApiParameter.PATH, description="Course ID"), ], responses={ - 200: "In case of success, a 200.", - 401: "The requester is not authenticated", - 403: "The requester cannot access the specified course", - 404: "The requested course does not exist", - 429: "The requester has exceeded the per-user rate limit", + 200: OpenApiResponse(description="In case of success, a 200."), + 401: OpenApiResponse(description="The requester is not authenticated"), + 403: OpenApiResponse(description="The requester cannot access the specified course"), + 404: OpenApiResponse(description="The requested course does not exist"), + 429: OpenApiResponse(description="The requester has exceeded the per-user rate limit"), }, ) @verify_course_exists() diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/home.py b/cms/djangoapps/contentstore/rest_api/v2/views/home.py index 79bb127a8727..d711a4a52d1f 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/views/home.py +++ b/cms/djangoapps/contentstore/rest_api/v2/views/home.py @@ -2,7 +2,8 @@ from collections import OrderedDict -import edx_api_doc_tools as apidocs +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from rest_framework.pagination import PageNumberPagination from rest_framework.request import Request from rest_framework.response import Response @@ -46,57 +47,66 @@ def paginate_queryset(self, queryset, request, view=None): class HomePageCoursesViewV2(APIView): """View for getting all courses available to the logged in user.""" - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter( + OpenApiParameter( "org", - apidocs.ParameterLocation.QUERY, + OpenApiTypes.STR, + OpenApiParameter.QUERY, description="Query param to filter by course org", ), - apidocs.string_parameter( + OpenApiParameter( "search", - apidocs.ParameterLocation.QUERY, + OpenApiTypes.STR, + OpenApiParameter.QUERY, description="Query param to filter by course name, org, or number", ), - apidocs.string_parameter( + OpenApiParameter( "order", - apidocs.ParameterLocation.QUERY, + OpenApiTypes.STR, + OpenApiParameter.QUERY, description="Query param to order by course name, org, or number", ), - apidocs.string_parameter( + OpenApiParameter( "active_only", - apidocs.ParameterLocation.QUERY, + OpenApiTypes.STR, + OpenApiParameter.QUERY, description="Query param to filter by active courses only", ), - apidocs.string_parameter( + OpenApiParameter( "archived_only", - apidocs.ParameterLocation.QUERY, + OpenApiTypes.STR, + OpenApiParameter.QUERY, description="Query param to filter by archived courses only", ), - apidocs.string_parameter( + OpenApiParameter( "page", - apidocs.ParameterLocation.QUERY, + OpenApiTypes.STR, + OpenApiParameter.QUERY, description="Query param to paginate the courses", ), - apidocs.string_parameter( + OpenApiParameter( "page_size", - apidocs.ParameterLocation.QUERY, + OpenApiTypes.STR, + OpenApiParameter.QUERY, description="Query param to set page size", ), - apidocs.string_parameter( + OpenApiParameter( "start_date_on_or_after", - apidocs.ParameterLocation.QUERY, + OpenApiTypes.STR, + OpenApiParameter.QUERY, description="Query param to filter courses with a start date on or after this date (YYYY-MM-DD).", ), - apidocs.string_parameter( + OpenApiParameter( "start_date_on_or_before", - apidocs.ParameterLocation.QUERY, + OpenApiTypes.STR, + OpenApiParameter.QUERY, description="Query param to filter courses with a start date on or before this date (YYYY-MM-DD).", ), ], responses={ 200: CourseHomeTabSerializerV2, - 401: "The requester is not authenticated.", + 401: OpenApiResponse(description="The requester is not authenticated."), }, ) def get(self, request: Request): diff --git a/cms/djangoapps/contentstore/rest_api/v3/views/home.py b/cms/djangoapps/contentstore/rest_api/v3/views/home.py index 6ae636504abc..2b923d9dd2c2 100644 --- a/cms/djangoapps/contentstore/rest_api/v3/views/home.py +++ b/cms/djangoapps/contentstore/rest_api/v3/views/home.py @@ -26,10 +26,10 @@ instead of the default ``SessionAuthentication``. """ -import edx_api_doc_tools as apidocs from django.conf import settings from drf_spectacular.openapi import AutoSchema -from drf_spectacular.utils import OpenApiParameter, extend_schema +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser from edx_rest_framework_extensions.mixins import StandardizedErrorMixin @@ -131,16 +131,17 @@ def list(self, request: Request): # ADR 0036 — drop top-level keys not requested via ?fields=. return Response(project(serializer.data, request.query_params.get("fields"))) - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter( + OpenApiParameter( "org", - apidocs.ParameterLocation.QUERY, + OpenApiTypes.STR, + OpenApiParameter.QUERY, description="Query param to filter by course org", )], responses={ 200: CourseHomeTabSerializer, - 401: "The requester is not authenticated.", + 401: OpenApiResponse(description="The requester is not authenticated."), }, ) @action(detail=False, methods=['get'], url_path='courses', url_name='courses') @@ -161,16 +162,18 @@ def courses(self, request: Request): serializer = self.get_serializer(courses_context) return Response(serializer.data) - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter( + OpenApiParameter( "org", - apidocs.ParameterLocation.QUERY, + OpenApiTypes.STR, + OpenApiParameter.QUERY, description="Query param to filter by course org", ), - apidocs.query_parameter( + OpenApiParameter( "is_migrated", - bool, + OpenApiTypes.BOOL, + OpenApiParameter.QUERY, description=( "Query param to filter by migrated status of library." " If present (true or false), it will filter by migration status" @@ -180,7 +183,7 @@ def courses(self, request: Request): ], responses={ 200: LibraryTabSerializer, - 401: "The requester is not authenticated.", + 401: OpenApiResponse(description="The requester is not authenticated."), }, ) @action(detail=False, methods=['get'], url_path='libraries', url_name='libraries') diff --git a/cms/djangoapps/modulestore_migrator/rest_api/v1/views.py b/cms/djangoapps/modulestore_migrator/rest_api/v1/views.py index 594c9518a2ae..75b07fc4b8cb 100644 --- a/cms/djangoapps/modulestore_migrator/rest_api/v1/views.py +++ b/cms/djangoapps/modulestore_migrator/rest_api/v1/views.py @@ -4,7 +4,8 @@ import logging from uuid import UUID -import edx_api_doc_tools as apidocs +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema, extend_schema_view from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser from opaque_keys import InvalidKeyError @@ -51,30 +52,28 @@ _error_responses = { - 400: "Request malformed.", - 401: "Requester is not authenticated.", - 403: "Permission denied", + 400: OpenApiResponse(description="Request malformed."), + 401: OpenApiResponse(description="Requester is not authenticated."), + 403: OpenApiResponse(description="Permission denied"), } -@apidocs.schema_for( - "list", - """ - List all migration and bulk-migration tasks started by the current user. - - The response is a paginated series of migration task status objects, ordered - by the time at which the migration was started, newest first. - See `POST /api/modulestore_migrator/v1/migrations` for details of each object's schema. - """, -) -@apidocs.schema_for( - "retrieve", - """ - Get the status of particular migration or bulk-migration task by its UUID. - - The response is a migration task status object. - See `POST /api/modulestore_migrator/v1/migrations` for details on its schema. - """, +@extend_schema_view( + list=extend_schema( + summary="List all migration and bulk-migration tasks started by the current user.", + description=( + "The response is a paginated series of migration task status objects, ordered\n" + "by the time at which the migration was started, newest first.\n" + "See `POST /api/modulestore_migrator/v1/migrations` for details of each object's schema." + ), + ), + retrieve=extend_schema( + summary="Get the status of particular migration or bulk-migration task by its UUID.", + description=( + "The response is a migration task status object.\n" + "See `POST /api/modulestore_migrator/v1/migrations` for details on its schema." + ), + ), ) class MigrationViewSet(StatusViewSet): """ @@ -107,7 +106,7 @@ def get_queryset(self): user=self.request.user ).distinct().order_by("-created") - @apidocs.schema() + @extend_schema() @action(detail=True, methods=['post']) def cancel(self, request, *args, **kwargs): """ @@ -125,8 +124,8 @@ def cancel(self, request, *args, **kwargs): raise PermissionDenied("Only site administrators can cancel migration tasks.") return super().cancel(request, *args, **kwargs) - @apidocs.schema( - body=ModulestoreMigrationSerializer, + @extend_schema( + request=ModulestoreMigrationSerializer, responses={ 201: StatusWithModulestoreMigrationsSerializer, **_error_responses, @@ -279,8 +278,8 @@ class BulkMigrationViewSet(StatusViewSet): # That just leaves us with POST. http_method_names = ["post"] - @apidocs.schema( - body=BulkModulestoreMigrationSerializer, + @extend_schema( + request=BulkModulestoreMigrationSerializer, responses={ 201: StatusWithModulestoreMigrationsSerializer, **_error_responses, @@ -448,18 +447,19 @@ class MigrationInfoViewSet(APIView): SessionAuthenticationAllowInactiveUser, ) - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter( + OpenApiParameter( "source_keys", - apidocs.ParameterLocation.QUERY, + OpenApiTypes.STR, + OpenApiParameter.QUERY, description="List of source keys to consult", ), ], responses={ 200: MigrationInfoResponseSerializer, - 400: "Missing required parameter: source_keys", - 401: "The requester is not authenticated.", + 400: OpenApiResponse(description="Missing required parameter: source_keys"), + 401: OpenApiResponse(description="The requester is not authenticated."), }, ) def get(self, request): @@ -493,14 +493,15 @@ def get(self, request): return Response(serializer.data) -@apidocs.schema_for( - "list", - "List all course migrations to a library.", - responses={ - 201: LibraryMigrationCourseSerializer, - 401: "The requester is not authenticated.", - 403: "The requester does not have permission to access the library.", - }, +@extend_schema_view( + list=extend_schema( + summary="List all course migrations to a library.", + responses={ + 201: LibraryMigrationCourseSerializer, + 401: OpenApiResponse(description="The requester is not authenticated."), + 403: OpenApiResponse(description="The requester does not have permission to access the library."), + }, + ), ) class LibraryCourseMigrationViewSet(GenericViewSet, ListModelMixin): """ @@ -579,38 +580,43 @@ class BlockMigrationInfo(APIView): SessionAuthenticationAllowInactiveUser, ) - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter( + OpenApiParameter( "target_key", - apidocs.ParameterLocation.QUERY, + OpenApiTypes.STR, + OpenApiParameter.QUERY, description="Filter blocks by target key", ), - apidocs.string_parameter( + OpenApiParameter( "source_key", - apidocs.ParameterLocation.QUERY, + OpenApiTypes.STR, + OpenApiParameter.QUERY, description="Filter blocks by source key", ), - apidocs.string_parameter( + OpenApiParameter( "target_collection_key", - apidocs.ParameterLocation.QUERY, + OpenApiTypes.STR, + OpenApiParameter.QUERY, description="Filter blocks by target_collection_key", ), - apidocs.string_parameter( + OpenApiParameter( "task_uuid", - apidocs.ParameterLocation.QUERY, + OpenApiTypes.STR, + OpenApiParameter.QUERY, description="Filter blocks by task_uuid", ), - apidocs.string_parameter( + OpenApiParameter( "is_failed", - apidocs.ParameterLocation.QUERY, + OpenApiTypes.STR, + OpenApiParameter.QUERY, description="Filter blocks based on its migration status", ), ], responses={ 200: MigrationInfoResponseSerializer, - 400: "Missing required parameter: target_key", - 401: "The requester is not authenticated.", + 400: OpenApiResponse(description="Missing required parameter: target_key"), + 401: OpenApiResponse(description="The requester is not authenticated."), }, ) def get(self, request: Request): @@ -704,23 +710,25 @@ class PreviewMigration(APIView): SessionAuthenticationAllowInactiveUser, ) - @apidocs.schema( + @extend_schema( parameters=[ - apidocs.string_parameter( + OpenApiParameter( "target_key", - apidocs.ParameterLocation.QUERY, + OpenApiTypes.STR, + OpenApiParameter.QUERY, description="Target key of the migration", ), - apidocs.string_parameter( + OpenApiParameter( "source_key", - apidocs.ParameterLocation.QUERY, + OpenApiTypes.STR, + OpenApiParameter.QUERY, description="Source key of the migration", ), ], responses={ 200: PreviewMigrationSerializer, - 400: "Missing required parameter: target_key/source_key", - 401: "The requester is not authenticated.", + 400: OpenApiResponse(description="Missing required parameter: target_key/source_key"), + 401: OpenApiResponse(description="The requester is not authenticated."), }, ) def get(self, request: Request): From 18c05788e6fa480a448bdbab77a6c5a9fb02b2d7 Mon Sep 17 00:00:00 2001 From: Muhammad Faraz Maqsood Date: Wed, 16 Sep 2026 11:54:32 +0500 Subject: [PATCH 06/12] feat: add drf-spectacular extensions for BaseSerializer subclasses Six serializers subclass BaseSerializer, which has no `fields` attribute, so drf-spectacular raises AttributeError when generating a schema that covers them. Each extension declares the type its serializer produces. Registered from CommonInitializationConfig.ready() so they load in both services regardless of which schema is being generated. --- .../djangoapps/common_initialization/apps.py | 4 ++ openedx/core/lib/api/schema_extensions.py | 68 +++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 openedx/core/lib/api/schema_extensions.py diff --git a/openedx/core/djangoapps/common_initialization/apps.py b/openedx/core/djangoapps/common_initialization/apps.py index 1b2c1f795f45..6df84851469a 100644 --- a/openedx/core/djangoapps/common_initialization/apps.py +++ b/openedx/core/djangoapps/common_initialization/apps.py @@ -11,6 +11,10 @@ class CommonInitializationConfig(AppConfig): # pylint: disable=missing-class-do verbose_name = 'Common Initialization' def ready(self): + # Registers the drf-spectacular extensions for serializers the schema + # generator cannot introspect on its own. + from openedx.core.lib.api import schema_extensions # pylint: disable=unused-import # noqa: F401 + # Common settings validations for the LMS and CMS. from . import checks # pylint: disable=unused-import # noqa: F401 self._add_mimetypes() diff --git a/openedx/core/lib/api/schema_extensions.py b/openedx/core/lib/api/schema_extensions.py new file mode 100644 index 000000000000..2a057904c9e7 --- /dev/null +++ b/openedx/core/lib/api/schema_extensions.py @@ -0,0 +1,68 @@ +""" +drf-spectacular extensions for serializers it cannot introspect on its own. + +``BaseSerializer`` subclasses have no ``fields``, so drf-spectacular raises +``AttributeError`` when it tries to walk them. Each extension below declares +the type its serializer actually produces. + +The extensions self-register on import; ``lms.lib.spectacular`` and +``cms.lib.spectacular`` import this module so that they are loaded whenever a +schema is generated. +""" + +from drf_spectacular.extensions import OpenApiSerializerExtension +from drf_spectacular.plumbing import build_basic_type +from drf_spectacular.types import OpenApiTypes + + +class _StringSerializerExtension(OpenApiSerializerExtension): + """Base for serializers whose ``to_representation`` returns ``str``.""" + + def map_serializer(self, auto_schema, direction): + return build_basic_type(OpenApiTypes.STR) + + +class _ObjectSerializerExtension(OpenApiSerializerExtension): + """Base for serializers whose ``to_representation`` returns a dict.""" + + def map_serializer(self, auto_schema, direction): + return build_basic_type(OpenApiTypes.OBJECT) + + +class CourseKeySerializerExtension(_StringSerializerExtension): + """``CourseKeySerializer`` serializes a CourseKey to its string form.""" + + target_class = 'lms.djangoapps.course_api.serializers.CourseKeySerializer' + + +class PhoneNumberSerializerExtension(_StringSerializerExtension): + """``PhoneNumberSerializer`` serializes a phone number to a digit string.""" + + target_class = 'openedx.core.djangoapps.user_api.accounts.serializers.PhoneNumberSerializer' + + +class UsageKeyV2SerializerExtension(_StringSerializerExtension): + """``UsageKeyV2Serializer`` serializes a LibraryUsageLocatorV2 to a string.""" + + target_class = 'openedx.core.djangoapps.content_libraries.rest_api.serializers.UsageKeyV2Serializer' + + +class OpaqueKeySerializerExtension(_StringSerializerExtension): + """``OpaqueKeySerializer`` serializes an OpaqueKey to a string.""" + + target_class = 'openedx.core.djangoapps.content_libraries.rest_api.serializers.OpaqueKeySerializer' + + +class LegacySettingsSerializerExtension(_ObjectSerializerExtension): + """``LegacySettingsSerializer`` serializes legacy discussion settings to a dict.""" + + target_class = 'openedx.core.djangoapps.discussions.serializers.LegacySettingsSerializer' + + +class UserCourseOutlineDataSerializerExtension(_ObjectSerializerExtension): + """``UserCourseOutlineDataSerializer`` serializes a course outline to a dict.""" + + target_class = ( + 'openedx.core.djangoapps.content.learning_sequences.views.' + 'CourseOutlineView.UserCourseOutlineDataSerializer' + ) From d2d503aaf617d759bb4336aab100399dcf4464c1 Mon Sep 17 00:00:00 2001 From: Muhammad Faraz Maqsood Date: Wed, 16 Sep 2026 13:44:55 +0500 Subject: [PATCH 07/12] refactor: serve /api-docs with drf-spectacular Replaces make_docs_urls with SpectacularAPIView, SpectacularSwaggerView and SpectacularRedocView, preserving the swagger.json, swagger.yaml, api-docs/ and swagger/ routes and their URL names. The UI views reverse their schema URL without arguments, so api-docs/schema/ is registered alongside the format-suffixed routes. /api-docs serves the full API surface via custom_settings, leaving SPECTACULAR_SETTINGS to the narrower Authoring and Enrollment schemas the SDK consumes. Also removes drf_yasg from INSTALLED_APPS, drops SWAGGER_SETTINGS, and converts the docs security definitions to OpenAPI 3 form. `make swagger` now runs `manage.py lms spectacular`, since generate_swagger came from drf_yasg; docs_settings applies the same unfiltered configuration as /api-docs so the generated file still covers the whole surface. edx-api-doc-tools and drf-yasg remain installed as transitive dependencies of openedx-authz and django-user-tasks respectively. --- Makefile | 4 +-- cms/envs/common.py | 3 -- cms/urls.py | 51 ++++++++++++++++++++++++++++---- docs/docs_settings.py | 43 +++++++++++++++++---------- lms/envs/common.py | 5 ---- lms/urls.py | 47 +++++++++++++++++++++++++---- openedx/core/apidocs.py | 46 ++++++++++++++++++++++------ pyproject.toml | 1 - requirements/edx/base.txt | 4 +-- requirements/edx/development.txt | 4 +-- uv.lock | 2 -- 11 files changed, 156 insertions(+), 54 deletions(-) diff --git a/Makefile b/Makefile index dea4fe742f6e..2e83bf8fc03c 100644 --- a/Makefile +++ b/Makefile @@ -29,8 +29,8 @@ SWAGGER = docs/lms-openapi.yaml docs: swagger guides technical-docs ## build the documentation for this repository $(MAKE) -C docs html -swagger: ## generate the swagger.yaml file - DJANGO_SETTINGS_MODULE=docs.docs_settings uv run python manage.py lms generate_swagger --generator-class=edx_api_doc_tools.ApiSchemaGenerator -o $(SWAGGER) +swagger: ## generate the OpenAPI schema file + DJANGO_SETTINGS_MODULE=docs.docs_settings uv run python manage.py lms spectacular --file $(SWAGGER) extract_translations: ## extract localizable strings from sources uv run i18n_tool extract --no-segment -v diff --git a/cms/envs/common.py b/cms/envs/common.py index e2f324cdc2e7..0e4600d726a6 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -881,9 +881,6 @@ def make_lms_template_path(settings): # Asset management for mako templates 'common.djangoapps.pipeline_mako', - # API Documentation - 'drf_yasg', - # Tagging 'openedx_tagging', 'openedx.core.djangoapps.content_tagging', diff --git a/cms/urls.py b/cms/urls.py index c0f96f489bb8..bf2da8aed132 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -11,8 +11,7 @@ from django.urls import include, path, re_path from django.utils.translation import gettext_lazy as _ from django.views.generic import RedirectView -from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView -from edx_api_doc_tools import make_docs_urls +from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView import openedx.core.djangoapps.common_views.xblock import openedx.core.djangoapps.debug.views @@ -22,7 +21,7 @@ from cms.djangoapps.contentstore.views.block import xblock_edit_view from cms.djangoapps.contentstore.views.organization import OrganizationListView from openedx.core import toggles as core_toggles -from openedx.core.apidocs import api_info +from openedx.core.apidocs import get_api_docs_settings from openedx.core.djangoapps.password_policy import compliance as password_policy_compliance from openedx.core.djangoapps.password_policy.forms import PasswordPolicyAwareAdminAuthForm @@ -331,8 +330,50 @@ path('500', handler500), ] -# API docs. -urlpatterns += make_docs_urls(api_info) +# API docs, served by drf-spectacular. +# +# Route names and paths are preserved from the edx-api-doc-tools implementation +# these replaced, since reverse() calls and existing links depend on them. +# +# ``swagger.json`` / ``swagger.yaml`` serve the schema. drf-spectacular picks +# the renderer by content negotiation, so the extension is passed through as +# DRF's standard ``format`` suffix kwarg (without the leading dot) to select +# JSON or YAML explicitly. +# +# These use ``custom_settings`` for the full API surface. SPECTACULAR_SETTINGS +# is reserved for the narrower Authoring API schema at ``/authoring-api/`` +# registered below. +# +# The Swagger and ReDoc views reverse their schema URL with no arguments, so +# ``api-docs/schema/`` exists alongside the format-suffixed routes for them to +# point at. +urlpatterns += [ + re_path( + r'^swagger\.(?Pjson|yaml)$', + SpectacularAPIView.as_view(custom_settings=get_api_docs_settings()), + name='apidocs-data', + ), + path( + 'api-docs/schema/', + SpectacularAPIView.as_view(custom_settings=get_api_docs_settings()), + name='apidocs-schema', + ), + path( + 'api-docs/', + SpectacularSwaggerView.as_view(url_name='apidocs-schema'), + name='apidocs-ui', + ), + path( + 'api-docs/redoc/', + SpectacularRedocView.as_view(url_name='apidocs-schema'), + name='apidocs-ui-redoc', + ), + path( + 'swagger/', + RedirectView.as_view(pattern_name='apidocs-ui', permanent=False), + name='apidocs-ui-swagger', + ), +] # edx-drf-extensions csrf app urlpatterns += [ diff --git a/docs/docs_settings.py b/docs/docs_settings.py index 6b08a744193d..48ec4a5cc63e 100644 --- a/docs/docs_settings.py +++ b/docs/docs_settings.py @@ -18,6 +18,7 @@ VIDEO_TRANSCRIPT_MIGRATIONS_JOB_QUEUE, # noqa: F401 ) from lms.envs.common import * # pylint: disable=wildcard-import # noqa: F403 +from openedx.core.apidocs import get_api_docs_settings from openedx.core.lib.derived import derive_settings # Turn on all the boolean feature flags, so that conditionally included @@ -74,22 +75,32 @@ openapi_security_info_csrf = ( "Obtain by making a `GET` request to `/csrf/api/v1/token`. The token will be in the response cookie `csrftoken`." ) -SWAGGER_SETTINGS["SECURITY_DEFINITIONS"] = { # noqa: F405 - "Basic": { - "type": "basic", - "description": openapi_security_info_basic, - }, - "jwt": { - "type": "apiKey", - "name": "Authorization", - "in": "header", - "description": openapi_security_info_jwt, - }, - "csrf": { - "type": "apiKey", - "name": "X-CSRFToken", - "in": "header", - "description": openapi_security_info_csrf, +# The docs build publishes the whole API surface, so use the same unfiltered +# configuration that /api-docs does rather than the narrow, SDK-facing schema +# that SPECTACULAR_SETTINGS carries by default. +SPECTACULAR_SETTINGS.update(get_api_docs_settings()) # noqa: F405 + +# OpenAPI 3 security schemes, consumed by drf-spectacular. "Basic" uses the +# OpenAPI 3 http/basic form rather than Swagger 2.0's type: basic. +SPECTACULAR_SETTINGS["APPEND_COMPONENTS"] = { # noqa: F405 + "securitySchemes": { + "Basic": { + "type": "http", + "scheme": "basic", + "description": openapi_security_info_basic, + }, + "jwt": { + "type": "apiKey", + "name": "Authorization", + "in": "header", + "description": openapi_security_info_jwt, + }, + "csrf": { + "type": "apiKey", + "name": "X-CSRFToken", + "in": "header", + "description": openapi_security_info_csrf, + }, }, } diff --git a/lms/envs/common.py b/lms/envs/common.py index ecfa2f2fc512..ca9ac83c8d11 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -2074,7 +2074,6 @@ 'django_filters', # API Documentation - 'drf_yasg', 'drf_spectacular', # edx-drf-extensions @@ -2159,10 +2158,6 @@ ######################### Django Rest Framework ######################## -SWAGGER_SETTINGS = { - 'DEFAULT_INFO': 'openedx.core.apidocs.api_info', - 'DEEP_LINKING': True, -} ###################### drf-spectacular (LMS enrollment schema) ###################### SPECTACULAR_SETTINGS = { diff --git a/lms/urls.py b/lms/urls.py index 512a21647f66..a09aae638587 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -10,8 +10,7 @@ from django.urls import include, path, re_path from django.utils.translation import gettext_lazy as _ from django.views.generic.base import RedirectView -from drf_spectacular.views import SpectacularAPIView -from edx_api_doc_tools import make_docs_urls +from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView from edx_django_utils.plugins import get_plugin_url_patterns from submissions import urls as submissions_urls @@ -37,7 +36,7 @@ from lms.djangoapps.mfe_config_api.urls import frontend_site_config_urls, mfe_config_urls from lms.djangoapps.static_template_view import views as static_template_view_views from lms.djangoapps.staticbook import views as staticbook_views -from openedx.core.apidocs import api_info +from openedx.core.apidocs import get_api_docs_settings from openedx.core.djangoapps.auth_exchange.views import LoginWithAccessTokenView from openedx.core.djangoapps.catalog.models import CatalogIntegration from openedx.core.djangoapps.common_views.xblock import xblock_resource @@ -987,8 +986,46 @@ ) ] -# API docs. -urlpatterns += make_docs_urls(api_info) +# API docs, served by drf-spectacular. +# +# Route names and paths are preserved from the edx-api-doc-tools implementation +# these replaced, since reverse() calls and existing links depend on them. +# +# ``swagger.json`` / ``swagger.yaml`` serve the schema. drf-spectacular picks +# the renderer by content negotiation, so the extension is passed through as +# DRF's standard ``format`` suffix kwarg (without the leading dot) to select +# JSON or YAML explicitly. +# +# The Swagger and ReDoc views reverse their schema URL with no arguments, so +# ``api-docs/schema/`` exists alongside the format-suffixed routes for them to +# point at. +urlpatterns += [ + re_path( + r'^swagger\.(?Pjson|yaml)$', + SpectacularAPIView.as_view(custom_settings=get_api_docs_settings()), + name='apidocs-data', + ), + path( + 'api-docs/schema/', + SpectacularAPIView.as_view(custom_settings=get_api_docs_settings()), + name='apidocs-schema', + ), + path( + 'api-docs/', + SpectacularSwaggerView.as_view(url_name='apidocs-schema'), + name='apidocs-ui', + ), + path( + 'api-docs/redoc/', + SpectacularRedocView.as_view(url_name='apidocs-schema'), + name='apidocs-ui-redoc', + ), + path( + 'swagger/', + RedirectView.as_view(pattern_name='apidocs-ui', permanent=False), + name='apidocs-ui-swagger', + ), +] # edx-drf-extensions csrf app urlpatterns += [ diff --git a/openedx/core/apidocs.py b/openedx/core/apidocs.py index 0d96864c3d52..39cee09e5edf 100644 --- a/openedx/core/apidocs.py +++ b/openedx/core/apidocs.py @@ -3,17 +3,45 @@ """ from django.conf import settings -from edx_api_doc_tools import make_api_info from rest_framework import serializers -api_info = make_api_info( - title="Open edX API", - version="v1", - description="APIs for access to Open edX information", - #terms_of_service="https://www.google.com/policies/terms/", # TODO: Do we have these? - email=settings.API_ACCESS_MANAGER_EMAIL, - #license=openapi.License(name="BSD License"), # TODO: What does this mean? -) +# Settings for the service-wide ``/api-docs`` schema, served by drf-spectacular. +# +# These are passed as ``custom_settings`` to SpectacularAPIView rather than +# living in ``SPECTACULAR_SETTINGS``, because that global is already claimed by +# a deliberately narrow schema in each service -- the Authoring API +# (``/authoring-api/``) in CMS and the Enrollment API (``/lms-api/``) in LMS. +# Both filter the surface down via ``PREPROCESSING_HOOKS`` and trim a path +# prefix. ``/api-docs`` is the opposite: the full, untrimmed API surface, so it +# must switch that filtering off explicitly. +# Note: ``SERVE_*`` settings cannot be overridden through ``custom_settings`` +# (drf-spectacular raises AttributeError); SpectacularAPIView takes dedicated +# constructor arguments for those instead. +API_DOCS_SETTINGS = { + 'TITLE': 'Open edX API', + 'DESCRIPTION': 'APIs for access to Open edX information', + 'VERSION': 'v1', + # Document every endpoint, without the per-service filtering and prefix + # trimming that SPECTACULAR_SETTINGS applies. + 'PREPROCESSING_HOOKS': [], + 'SCHEMA_PATH_PREFIX': None, + 'SCHEMA_PATH_PREFIX_TRIM': False, + 'SERVERS': [], +} + + +def get_api_docs_settings(): + """ + Build the ``/api-docs`` schema settings, adding contact details if available. + + ``API_ACCESS_MANAGER_EMAIL`` is an LMS-only setting, so it is included only + where it is defined. + """ + api_docs_settings = dict(API_DOCS_SETTINGS) + contact_email = getattr(settings, 'API_ACCESS_MANAGER_EMAIL', None) + if contact_email: + api_docs_settings['CONTACT'] = {'email': contact_email} + return api_docs_settings def cursor_paginate_serializer(inner_serializer_class): diff --git a/pyproject.toml b/pyproject.toml index 2f775d6cd759..8c2fc90a7809 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,6 @@ dependencies = [ "djangorestframework", "drf-spectacular", "edx-ace", - "edx-api-doc-tools", "edx-auth-backends", # Allow Studio to use LMS SSO "edx-bulk-grades", # LMS REST API for managing bulk grading operations "edx-ccx-keys", diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index d83a8bf2d4de..0ac159a30931 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -415,9 +415,7 @@ drf-yasg==1.21.15 edx-ace==1.15.0 # via openedx-platform edx-api-doc-tools==3.0.0 - # via - # openedx-authz - # openedx-platform + # via openedx-authz edx-auth-backends==5.0.0 # via openedx-platform edx-bulk-grades==2.0.0 diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 5ce243eb90a8..f592c8d980b9 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -468,9 +468,7 @@ drf-yasg==1.21.15 edx-ace==1.15.0 # via openedx-platform edx-api-doc-tools==3.0.0 - # via - # openedx-authz - # openedx-platform + # via openedx-authz edx-auth-backends==5.0.0 # via openedx-platform edx-bulk-grades==2.0.0 diff --git a/uv.lock b/uv.lock index 90c1003510b7..39ccf6b5727d 100644 --- a/uv.lock +++ b/uv.lock @@ -4510,7 +4510,6 @@ dependencies = [ { name = "djangorestframework" }, { name = "drf-spectacular" }, { name = "edx-ace" }, - { name = "edx-api-doc-tools" }, { name = "edx-auth-backends" }, { name = "edx-bulk-grades" }, { name = "edx-ccx-keys" }, @@ -4917,7 +4916,6 @@ requires-dist = [ { name = "djangorestframework" }, { name = "drf-spectacular" }, { name = "edx-ace" }, - { name = "edx-api-doc-tools" }, { name = "edx-auth-backends" }, { name = "edx-bulk-grades" }, { name = "edx-ccx-keys" }, From ed0c4e9ecca7bfa05026c7ddb80c811f1666e459 Mon Sep 17 00:00:00 2001 From: Muhammad Faraz Maqsood Date: Thu, 17 Sep 2026 19:54:33 +0500 Subject: [PATCH 08/12] fix: resolve review comments - restore server-side caching on the OpenAPI schema endpoints, which edx-api-doc-tools provided via SchemaView.as_cached_view - point the api-docs test at /api-docs/schema/ so it exercises schema generation again, and add the CMS equivalent - serve Swagger UI and ReDoc assets from drf-spectacular-sidecar instead of drf-spectacular's unpinned jsdelivr CDN defaults - correct the /api-docs comment: edx-api-doc-tools was /api/-only, so this widens the documented surface rather than being "the opposite" - drop the LMS-only claim about API_ACCESS_MANAGER_EMAIL, which lives in openedx/envs/common.py and is shared by both services - point the schema_extensions docstring at CommonInitializationConfig, the actual registration site - remove the dead format=openapi branch from is_schema_request, since nothing in the platform serves drf-yasg any more - delete the now-empty Django Rest Framework banner in lms/envs/common.py --- .github/workflows/unit-test-shards.json | 3 ++- cms/envs/common.py | 1 + cms/envs/devstack.py | 5 ++++ cms/envs/production.py | 5 ++++ cms/tests.py | 26 +++++++++++++++++++ cms/urls.py | 16 ++++++++++-- lms/envs/common.py | 10 ++++--- lms/tests.py | 10 +++++++ lms/urls.py | 16 ++++++++++-- openedx/core/apidocs.py | 12 ++++++--- .../core/djangoapps/bookmarks/serializers.py | 9 +++---- openedx/core/lib/api/schema_extensions.py | 6 ++--- pyproject.toml | 1 + requirements/edx/base.txt | 3 +++ requirements/edx/development.txt | 3 +++ uv.lock | 15 +++++++++++ 16 files changed, 120 insertions(+), 21 deletions(-) create mode 100644 cms/tests.py diff --git a/.github/workflows/unit-test-shards.json b/.github/workflows/unit-test-shards.json index 0f26c1be11b0..58c8fff85f81 100644 --- a/.github/workflows/unit-test-shards.json +++ b/.github/workflows/unit-test-shards.json @@ -242,7 +242,8 @@ "cms/djangoapps/pipeline_js/", "cms/djangoapps/xblock_config/", "cms/envs/", - "cms/lib/" + "cms/lib/", + "cms/tests.py" ] }, "cms-2": { diff --git a/cms/envs/common.py b/cms/envs/common.py index 0e4600d726a6..fe3a3926fa1d 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -928,6 +928,7 @@ def make_lms_template_path(settings): # alternative swagger generator for CMS API 'drf_spectacular', + 'drf_spectacular_sidecar', # Authz 'openedx.core.djangoapps.authz', diff --git a/cms/envs/devstack.py b/cms/envs/devstack.py index 0576a35272af..53077de04ddf 100644 --- a/cms/envs/devstack.py +++ b/cms/envs/devstack.py @@ -359,6 +359,11 @@ def should_show_debug_toolbar(request): # pylint: disable=missing-function-docs # remove the default schema path prefix to replace it with server-specific base paths: 'SCHEMA_PATH_PREFIX': '/api/contentstore', 'SCHEMA_PATH_PREFIX_TRIM': '/api/contentstore', + # Serve the Swagger UI and ReDoc assets from drf-spectacular-sidecar rather + # than drf-spectacular's default unpinned jsdelivr CDN URLs. + 'SWAGGER_UI_DIST': 'SIDECAR', + 'SWAGGER_UI_FAVICON_HREF': 'SIDECAR', + 'REDOC_DIST': 'SIDECAR', 'SERVERS': [ {'url': AUTHORING_API_URL, 'description': 'Public'}, # noqa: F405 {'url': f'http://{CMS_BASE}', 'description': 'Local'}, diff --git a/cms/envs/production.py b/cms/envs/production.py index b15caa2d1c14..5c8f818e95f7 100644 --- a/cms/envs/production.py +++ b/cms/envs/production.py @@ -418,6 +418,11 @@ def get_env_setting(setting): # remove the default schema path prefix to replace it with server-specific base paths: 'SCHEMA_PATH_PREFIX': '/api/contentstore', 'SCHEMA_PATH_PREFIX_TRIM': '/api/contentstore', + # Serve the Swagger UI and ReDoc assets from drf-spectacular-sidecar rather + # than drf-spectacular's default unpinned jsdelivr CDN URLs. + 'SWAGGER_UI_DIST': 'SIDECAR', + 'SWAGGER_UI_FAVICON_HREF': 'SIDECAR', + 'REDOC_DIST': 'SIDECAR', 'SERVERS': [ {'url': AUTHORING_API_URL, 'description': 'Public'}, # noqa: F405 {'url': f'https://{CMS_BASE}', 'description': 'Local'}, # noqa: F405 diff --git a/cms/tests.py b/cms/tests.py new file mode 100644 index 000000000000..4481e1abdaef --- /dev/null +++ b/cms/tests.py @@ -0,0 +1,26 @@ +"""Tests for the cms module itself.""" + +from django.test import TestCase + + +class CmsModuleTests(TestCase): + """ + Tests for cms module itself. + """ + + def test_api_docs(self): + """ + Tests that requests to the `/api-docs/` endpoint do not raise an exception. + """ + response = self.client.get('/api-docs/') + assert response.status_code == 200 + + def test_api_docs_schema(self): + """ + Tests that the OpenAPI schema generates without raising an exception. + + The `/api-docs/` view above only renders the Swagger UI shell, so this + is what actually exercises schema generation across the whole service. + """ + response = self.client.get('/api-docs/schema/') + assert response.status_code == 200 diff --git a/cms/urls.py b/cms/urls.py index bf2da8aed132..cc200ffd4dfa 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -10,6 +10,8 @@ from django.shortcuts import redirect from django.urls import include, path, re_path from django.utils.translation import gettext_lazy as _ +from django.views.decorators.cache import cache_page +from django.views.decorators.vary import vary_on_headers from django.views.generic import RedirectView from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView @@ -347,15 +349,25 @@ # The Swagger and ReDoc views reverse their schema URL with no arguments, so # ``api-docs/schema/`` exists alongside the format-suffixed routes for them to # point at. +# +# Schema generation is expensive and these endpoints are public, so both schema +# routes are cached for OPENAPI_CACHE_TIMEOUT, as edx-api-doc-tools did via +# SchemaView.as_cached_view. +_apidocs_schema_view = cache_page(settings.OPENAPI_CACHE_TIMEOUT)( + vary_on_headers("Cookie", "Authorization")( + SpectacularAPIView.as_view(custom_settings=get_api_docs_settings()) + ) +) + urlpatterns += [ re_path( r'^swagger\.(?Pjson|yaml)$', - SpectacularAPIView.as_view(custom_settings=get_api_docs_settings()), + _apidocs_schema_view, name='apidocs-data', ), path( 'api-docs/schema/', - SpectacularAPIView.as_view(custom_settings=get_api_docs_settings()), + _apidocs_schema_view, name='apidocs-schema', ), path( diff --git a/lms/envs/common.py b/lms/envs/common.py index ca9ac83c8d11..b67e27d46c82 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -2075,6 +2075,7 @@ # API Documentation 'drf_spectacular', + 'drf_spectacular_sidecar', # edx-drf-extensions 'csrf.apps.CsrfAppConfig', # Enables frontend apps to retrieve CSRF tokens. @@ -2156,9 +2157,6 @@ add_optional_apps(OPTIONAL_APPS, INSTALLED_APPS) # noqa: F405 -######################### Django Rest Framework ######################## - - ###################### drf-spectacular (LMS enrollment schema) ###################### SPECTACULAR_SETTINGS = { 'TITLE': 'LMS Enrollment API', @@ -2167,6 +2165,12 @@ 'PREPROCESSING_HOOKS': ['lms.lib.spectacular.lms_api_filter'], 'SCHEMA_PATH_PREFIX': '/api/enrollment', 'SCHEMA_PATH_PREFIX_TRIM': '/api/enrollment', + # Serve the Swagger UI and ReDoc assets from drf-spectacular-sidecar rather + # than drf-spectacular's default unpinned jsdelivr CDN URLs, keeping them + # self-hosted and version-pinned as the drf-yasg bundles were. + 'SWAGGER_UI_DIST': 'SIDECAR', + 'SWAGGER_UI_FAVICON_HREF': 'SIDECAR', + 'REDOC_DIST': 'SIDECAR', # SERVERS is environment-specific (LMS_ROOT_URL differs per env) and is # set in devstack.py / production.py. } diff --git a/lms/tests.py b/lms/tests.py index 67c5ee2f9f7a..04436d98b03d 100644 --- a/lms/tests.py +++ b/lms/tests.py @@ -27,3 +27,13 @@ def test_api_docs(self): """ response = self.client.get('/api-docs/') assert response.status_code == 200 + + def test_api_docs_schema(self): + """ + Tests that the OpenAPI schema generates without raising an exception. + + The `/api-docs/` view above only renders the Swagger UI shell, so this + is what actually exercises schema generation across the whole service. + """ + response = self.client.get('/api-docs/schema/') + assert response.status_code == 200 diff --git a/lms/urls.py b/lms/urls.py index a09aae638587..4f7930dd4d01 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -9,6 +9,8 @@ from django.contrib.admin import autodiscover as django_autodiscover from django.urls import include, path, re_path from django.utils.translation import gettext_lazy as _ +from django.views.decorators.cache import cache_page +from django.views.decorators.vary import vary_on_headers from django.views.generic.base import RedirectView from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView from edx_django_utils.plugins import get_plugin_url_patterns @@ -999,15 +1001,25 @@ # The Swagger and ReDoc views reverse their schema URL with no arguments, so # ``api-docs/schema/`` exists alongside the format-suffixed routes for them to # point at. +# +# Schema generation is expensive and these endpoints are public, so both schema +# routes are cached for OPENAPI_CACHE_TIMEOUT, as edx-api-doc-tools did via +# SchemaView.as_cached_view. +_apidocs_schema_view = cache_page(settings.OPENAPI_CACHE_TIMEOUT)( + vary_on_headers("Cookie", "Authorization")( + SpectacularAPIView.as_view(custom_settings=get_api_docs_settings()) + ) +) + urlpatterns += [ re_path( r'^swagger\.(?Pjson|yaml)$', - SpectacularAPIView.as_view(custom_settings=get_api_docs_settings()), + _apidocs_schema_view, name='apidocs-data', ), path( 'api-docs/schema/', - SpectacularAPIView.as_view(custom_settings=get_api_docs_settings()), + _apidocs_schema_view, name='apidocs-schema', ), path( diff --git a/openedx/core/apidocs.py b/openedx/core/apidocs.py index 39cee09e5edf..724963b3caf1 100644 --- a/openedx/core/apidocs.py +++ b/openedx/core/apidocs.py @@ -12,8 +12,13 @@ # a deliberately narrow schema in each service -- the Authoring API # (``/authoring-api/``) in CMS and the Enrollment API (``/lms-api/``) in LMS. # Both filter the surface down via ``PREPROCESSING_HOOKS`` and trim a path -# prefix. ``/api-docs`` is the opposite: the full, untrimmed API surface, so it -# must switch that filtering off explicitly. +# prefix, so ``/api-docs`` must switch that filtering off explicitly to cover +# the whole service. +# +# Note this is wider than what edx-api-doc-tools produced: its +# ``ApiSchemaGenerator`` kept only paths under ``/api/`` and pinned the path +# prefix there, so ``/api-docs`` now documents every DRF endpoint in the +# service rather than just the versioned ``/api/*`` surface. # Note: ``SERVE_*`` settings cannot be overridden through ``custom_settings`` # (drf-spectacular raises AttributeError); SpectacularAPIView takes dedicated # constructor arguments for those instead. @@ -34,8 +39,7 @@ def get_api_docs_settings(): """ Build the ``/api-docs`` schema settings, adding contact details if available. - ``API_ACCESS_MANAGER_EMAIL`` is an LMS-only setting, so it is included only - where it is defined. + The contact email is included when ``API_ACCESS_MANAGER_EMAIL`` is set. """ api_docs_settings = dict(API_DOCS_SETTINGS) contact_email = getattr(settings, 'API_ACCESS_MANAGER_EMAIL', None) diff --git a/openedx/core/djangoapps/bookmarks/serializers.py b/openedx/core/djangoapps/bookmarks/serializers.py index a8462890c41b..b8c7333d6a3a 100644 --- a/openedx/core/djangoapps/bookmarks/serializers.py +++ b/openedx/core/djangoapps/bookmarks/serializers.py @@ -15,14 +15,11 @@ def is_schema_request(request): """ Return whether this request is serving an OpenAPI schema. - Schema generators set a swagger_fake_view attribute on the view; that is - the drf-spectacular-compatible signal. ``format=openapi`` is drf-yasg's - convention, kept while it still serves ``/api-docs``. + drf-spectacular sets ``swagger_fake_view`` on the view before building the + mock request it introspects with, so that attribute is the signal here. """ view = (getattr(request, 'parser_context', None) or {}).get('view') - if getattr(view, 'swagger_fake_view', False): - return True - return request.query_params.get('format') == 'openapi' + return getattr(view, 'swagger_fake_view', False) class BookmarkSerializer(serializers.ModelSerializer): diff --git a/openedx/core/lib/api/schema_extensions.py b/openedx/core/lib/api/schema_extensions.py index 2a057904c9e7..765bb5d344be 100644 --- a/openedx/core/lib/api/schema_extensions.py +++ b/openedx/core/lib/api/schema_extensions.py @@ -5,9 +5,9 @@ ``AttributeError`` when it tries to walk them. Each extension below declares the type its serializer actually produces. -The extensions self-register on import; ``lms.lib.spectacular`` and -``cms.lib.spectacular`` import this module so that they are loaded whenever a -schema is generated. +The extensions self-register on import. ``CommonInitializationConfig.ready()`` +(``openedx.core.djangoapps.common_initialization.apps``) imports this module, +so they are registered at startup in both the LMS and the CMS. """ from drf_spectacular.extensions import OpenApiSerializerExtension diff --git a/pyproject.toml b/pyproject.toml index 8c2fc90a7809..a3e0679c85db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ dependencies = [ "django-webpack-loader", # Used to wire webpack bundles into the django asset pipeline "djangorestframework", "drf-spectacular", + "drf-spectacular-sidecar", "edx-ace", "edx-auth-backends", # Allow Studio to use LMS SSO "edx-bulk-grades", # LMS REST API for managing bulk grading operations diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 0ac159a30931..a18d8393507a 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -206,6 +206,7 @@ django==5.2.17 # done-xblock # drf-jwt # drf-spectacular + # drf-spectacular-sidecar # drf-yasg # edx-ace # edx-api-doc-tools @@ -408,6 +409,8 @@ drf-jwt==1.19.2 # via edx-drf-extensions drf-spectacular==0.30.0 # via openedx-platform +drf-spectacular-sidecar==2026.9.1 + # via openedx-platform drf-yasg==1.21.15 # via # django-user-tasks diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index f592c8d980b9..d904da55a976 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -248,6 +248,7 @@ django==5.2.17 # done-xblock # drf-jwt # drf-spectacular + # drf-spectacular-sidecar # drf-yasg # edx-ace # edx-api-doc-tools @@ -461,6 +462,8 @@ drf-jwt==1.19.2 # via edx-drf-extensions drf-spectacular==0.30.0 # via openedx-platform +drf-spectacular-sidecar==2026.9.1 + # via openedx-platform drf-yasg==1.21.15 # via # django-user-tasks diff --git a/uv.lock b/uv.lock index 39ccf6b5727d..4bb3ef123981 100644 --- a/uv.lock +++ b/uv.lock @@ -1797,6 +1797,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c3/56/74dd7b45bbde6d24494220b98d6961cb1200b63a1800332b430daa2c4551/drf_spectacular-0.30.0-py3-none-any.whl", hash = "sha256:006cf5921ebe20a9bd24f7c846261ebbf78780be5961b0d6e87afaa82afd62ff", size = 111150, upload-time = "2026-07-06T11:29:45.12Z" }, ] +[[package]] +name = "drf-spectacular-sidecar" +version = "2026.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django", version = "4.2.30", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-16-openedx-platform-django42'" }, + { name = "django", version = "5.2.17", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-16-openedx-platform-django52' or extra != 'group-16-openedx-platform-django42'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/87/ef9f1693499ac2c1bf3c33ce57e72c423aed51ab51f7ebd5d3a38f20c606/drf_spectacular_sidecar-2026.9.1.tar.gz", hash = "sha256:99c2b845d0a52b01a7a8e96736a48ef2099dc3dedea103012dfea41a9e4e9ecc", size = 2605997, upload-time = "2026-09-01T15:27:00.127Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/3b/c4866e6304d09d8813883774dadc5ff70a355279fc2527af3a4004fdb5f0/drf_spectacular_sidecar-2026.9.1-py3-none-any.whl", hash = "sha256:1e521d0ff78c7ab1a873423fc6e6b82ef6bae863f170762e7410ac63b2c7b72f", size = 2628532, upload-time = "2026-09-01T15:26:58.472Z" }, +] + [[package]] name = "drf-yasg" version = "1.21.15" @@ -4509,6 +4522,7 @@ dependencies = [ { name = "django-webpack-loader" }, { name = "djangorestframework" }, { name = "drf-spectacular" }, + { name = "drf-spectacular-sidecar" }, { name = "edx-ace" }, { name = "edx-auth-backends" }, { name = "edx-bulk-grades" }, @@ -4915,6 +4929,7 @@ requires-dist = [ { name = "django-webpack-loader" }, { name = "djangorestframework" }, { name = "drf-spectacular" }, + { name = "drf-spectacular-sidecar" }, { name = "edx-ace" }, { name = "edx-auth-backends" }, { name = "edx-bulk-grades" }, From 957db3197cc34213fe2ac16682a98fd263645595 Mon Sep 17 00:00:00 2001 From: Muhammad Faraz Maqsood Date: Tue, 22 Sep 2026 21:20:03 +0500 Subject: [PATCH 09/12] fix: cache the api-docs schema compressed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cache_page pickles the whole Response, and this schema is over memcached's 1MB item limit. CACHES['default'] has ignore_exc set, so the oversized set failed silently and Django dropped the key — the endpoint looked cached while regenerating on every request. cached_schema_view() stores the zlib-compressed body instead, keyed on the path and the negotiated representation, which brings it to about a tenth of the limit. add_never_cache_headers restores the no-store that drf-yasg sent, and a falsy OPENAPI_CACHE_TIMEOUT still goes straight to the view so devstack is unchanged. --- cms/urls.py | 13 +++---- lms/urls.py | 13 +++---- openedx/core/apidocs.py | 79 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 18 deletions(-) diff --git a/cms/urls.py b/cms/urls.py index cc200ffd4dfa..92f25e3bddea 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -10,8 +10,6 @@ from django.shortcuts import redirect from django.urls import include, path, re_path from django.utils.translation import gettext_lazy as _ -from django.views.decorators.cache import cache_page -from django.views.decorators.vary import vary_on_headers from django.views.generic import RedirectView from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView @@ -23,7 +21,7 @@ from cms.djangoapps.contentstore.views.block import xblock_edit_view from cms.djangoapps.contentstore.views.organization import OrganizationListView from openedx.core import toggles as core_toggles -from openedx.core.apidocs import get_api_docs_settings +from openedx.core.apidocs import cached_schema_view from openedx.core.djangoapps.password_policy import compliance as password_policy_compliance from openedx.core.djangoapps.password_policy.forms import PasswordPolicyAwareAdminAuthForm @@ -352,12 +350,9 @@ # # Schema generation is expensive and these endpoints are public, so both schema # routes are cached for OPENAPI_CACHE_TIMEOUT, as edx-api-doc-tools did via -# SchemaView.as_cached_view. -_apidocs_schema_view = cache_page(settings.OPENAPI_CACHE_TIMEOUT)( - vary_on_headers("Cookie", "Authorization")( - SpectacularAPIView.as_view(custom_settings=get_api_docs_settings()) - ) -) +# SchemaView.as_cached_view. The document is too large for cache_page to store +# -- see cached_schema_view() for why it caches the compressed body instead. +_apidocs_schema_view = cached_schema_view() urlpatterns += [ re_path( diff --git a/lms/urls.py b/lms/urls.py index 4f7930dd4d01..bf26e1b705f0 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -9,8 +9,6 @@ from django.contrib.admin import autodiscover as django_autodiscover from django.urls import include, path, re_path from django.utils.translation import gettext_lazy as _ -from django.views.decorators.cache import cache_page -from django.views.decorators.vary import vary_on_headers from django.views.generic.base import RedirectView from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView from edx_django_utils.plugins import get_plugin_url_patterns @@ -38,7 +36,7 @@ from lms.djangoapps.mfe_config_api.urls import frontend_site_config_urls, mfe_config_urls from lms.djangoapps.static_template_view import views as static_template_view_views from lms.djangoapps.staticbook import views as staticbook_views -from openedx.core.apidocs import get_api_docs_settings +from openedx.core.apidocs import cached_schema_view from openedx.core.djangoapps.auth_exchange.views import LoginWithAccessTokenView from openedx.core.djangoapps.catalog.models import CatalogIntegration from openedx.core.djangoapps.common_views.xblock import xblock_resource @@ -1004,12 +1002,9 @@ # # Schema generation is expensive and these endpoints are public, so both schema # routes are cached for OPENAPI_CACHE_TIMEOUT, as edx-api-doc-tools did via -# SchemaView.as_cached_view. -_apidocs_schema_view = cache_page(settings.OPENAPI_CACHE_TIMEOUT)( - vary_on_headers("Cookie", "Authorization")( - SpectacularAPIView.as_view(custom_settings=get_api_docs_settings()) - ) -) +# SchemaView.as_cached_view. The document is too large for cache_page to store +# -- see cached_schema_view() for why it caches the compressed body instead. +_apidocs_schema_view = cached_schema_view() urlpatterns += [ re_path( diff --git a/openedx/core/apidocs.py b/openedx/core/apidocs.py index 724963b3caf1..8ec811b32b8c 100644 --- a/openedx/core/apidocs.py +++ b/openedx/core/apidocs.py @@ -2,9 +2,19 @@ Open API support. """ +import logging + from django.conf import settings +from django.core.cache import cache +from django.http import HttpResponse +from django.utils.cache import add_never_cache_headers +from edx_django_utils.cache import get_cache_key from rest_framework import serializers +from openedx.core.lib.cache_utils import zpickle, zunpickle + +log = logging.getLogger(__name__) + # Settings for the service-wide ``/api-docs`` schema, served by drf-spectacular. # # These are passed as ``custom_settings`` to SpectacularAPIView rather than @@ -48,6 +58,75 @@ def get_api_docs_settings(): return api_docs_settings +def cached_schema_view(): + """ + Build the ``/api-docs`` schema view, caching the rendered document compressed. + + ``cache_page`` stores the pickled ``Response``, which for this schema is over + memcached's default 1MB item limit. ``CACHES['default']`` sets + ``ignore_exc: True``, so that oversized ``set`` fails silently and Django + deletes the key -- the endpoint would look cached while regenerating the + whole schema on every request. Storing the zlib-compressed body instead + brings it to roughly a tenth of the limit. + + The document is identical for every requester, so the key covers only the + path and the negotiated representation. Nothing in it varies by user: + ``SERVE_PUBLIC`` defaults to ``True``, ``SERVE_*`` cannot be set through + ``custom_settings``, and ``API_DOCS_SETTINGS`` sets ``'SERVERS': []`` so no + ``servers`` block is emitted. + + ``drf_spectacular.views`` is imported inside the function rather than at + module scope: this module is imported from the settings, and importing DRF + views that early freezes ``api_settings`` before ``DEFAULT_SCHEMA_CLASS`` is + set, which makes schema generation fail with ``Incompatible AutoSchema used + on View``. + """ + from drf_spectacular.views import SpectacularAPIView # pylint: disable=import-outside-toplevel + + view = SpectacularAPIView.as_view(custom_settings=get_api_docs_settings()) + + def _response(content_type, body): + """Build the response, marking it uncacheable downstream as drf-yasg did.""" + response = HttpResponse(body, content_type=content_type) + add_never_cache_headers(response) + return response + + def schema_view(request, *args, **kwargs): + """Serve the schema, from the cache when a fresh copy is stored.""" + if not settings.OPENAPI_CACHE_TIMEOUT: + return view(request, *args, **kwargs) + + cache_key = get_cache_key( + resource='apidocs-schema', + path=request.get_full_path(), + accept=request.META.get('HTTP_ACCEPT', ''), + ) + '.zpickled' + + cached = cache.get(cache_key) + if cached: + try: + content_type, body = zunpickle(cached) + except Exception: # pylint: disable=broad-except + log.warning("Data for cache is corrupt for cache key %s", cache_key) + cache.delete(cache_key) + else: + return _response(content_type, body) + + response = view(request, *args, **kwargs).render() + if response.status_code != 200: + return response + + content_type = response['Content-Type'] + cache.set( + cache_key, + zpickle((content_type, response.content)), + settings.OPENAPI_CACHE_TIMEOUT, + ) + return _response(content_type, response.content) + + return schema_view + + def cursor_paginate_serializer(inner_serializer_class): """ Create a cursor-paginated version of a serializer. From f7499b5182b0b2c43371f7c168c331f26e38ef5a Mon Sep 17 00:00:00 2001 From: Muhammad Faraz Maqsood Date: Wed, 23 Sep 2026 15:24:49 +0500 Subject: [PATCH 10/12] fix: key the api-docs schema cache on method and language The cached branch stored anything that came back 200, so DRF's OPTIONS metadata document landed under the GET key and was served to the next GET. cache_page only ever stored GET and HEAD; do the same. drf-spectacular renders the document under whatever language is active, and LocaleMiddleware sets that from the request before the view runs, so one locale's document could be served to another's. Add the active language to the key. Adds a test to both suites for the two cases. The suite runs with OPENAPI_CACHE_TIMEOUT = 0 and a DummyCache, so it overrides both to reach the cached branch at all. --- cms/tests.py | 26 +++++++++++++++++++++++++- lms/tests.py | 26 +++++++++++++++++++++++++- openedx/core/apidocs.py | 15 ++++++++++++--- 3 files changed, 62 insertions(+), 5 deletions(-) diff --git a/cms/tests.py b/cms/tests.py index 4481e1abdaef..015df12aec31 100644 --- a/cms/tests.py +++ b/cms/tests.py @@ -1,6 +1,7 @@ """Tests for the cms module itself.""" -from django.test import TestCase +from django.core.cache import cache +from django.test import TestCase, override_settings class CmsModuleTests(TestCase): @@ -24,3 +25,26 @@ def test_api_docs_schema(self): """ response = self.client.get('/api-docs/schema/') assert response.status_code == 200 + + @override_settings( + OPENAPI_CACHE_TIMEOUT=60, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}, + ) + def test_api_docs_schema_cache(self): + """ + The schema cache is keyed on method and language, so neither an OPTIONS + response nor another language's document is served to a later GET. + + The suite runs with OPENAPI_CACHE_TIMEOUT = 0 and a DummyCache, so the + cached branch is only reachable with both overridden. + """ + cache.clear() + options = self.client.options('/api-docs/schema/') + spanish = self.client.get('/api-docs/schema/', HTTP_ACCEPT_LANGUAGE='es-419') + english = self.client.get('/api-docs/schema/', HTTP_ACCEPT_LANGUAGE='en') + cached = self.client.get('/api-docs/schema/', HTTP_ACCEPT_LANGUAGE='en') + assert options.status_code == 200 + assert english.status_code == 200 + assert english.content.startswith(b'openapi:') + assert english.content != spanish.content + assert english.content == cached.content diff --git a/lms/tests.py b/lms/tests.py index 04436d98b03d..2fe677a73010 100644 --- a/lms/tests.py +++ b/lms/tests.py @@ -5,7 +5,8 @@ import mimetypes from django.conf import settings # pylint: disable=unused-import # noqa: F401 -from django.test import TestCase +from django.core.cache import cache +from django.test import TestCase, override_settings log = logging.getLogger(__name__) @@ -37,3 +38,26 @@ def test_api_docs_schema(self): """ response = self.client.get('/api-docs/schema/') assert response.status_code == 200 + + @override_settings( + OPENAPI_CACHE_TIMEOUT=60, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}, + ) + def test_api_docs_schema_cache(self): + """ + The schema cache is keyed on method and language, so neither an OPTIONS + response nor another language's document is served to a later GET. + + The suite runs with OPENAPI_CACHE_TIMEOUT = 0 and a DummyCache, so the + cached branch is only reachable with both overridden. + """ + cache.clear() + options = self.client.options('/api-docs/schema/') + spanish = self.client.get('/api-docs/schema/', HTTP_ACCEPT_LANGUAGE='es-419') + english = self.client.get('/api-docs/schema/', HTTP_ACCEPT_LANGUAGE='en') + cached = self.client.get('/api-docs/schema/', HTTP_ACCEPT_LANGUAGE='en') + assert options.status_code == 200 + assert english.status_code == 200 + assert english.content.startswith(b'openapi:') + assert english.content != spanish.content + assert english.content == cached.content diff --git a/openedx/core/apidocs.py b/openedx/core/apidocs.py index 8ec811b32b8c..b2d264430bd3 100644 --- a/openedx/core/apidocs.py +++ b/openedx/core/apidocs.py @@ -7,6 +7,7 @@ from django.conf import settings from django.core.cache import cache from django.http import HttpResponse +from django.utils import translation from django.utils.cache import add_never_cache_headers from edx_django_utils.cache import get_cache_key from rest_framework import serializers @@ -69,12 +70,19 @@ def cached_schema_view(): whole schema on every request. Storing the zlib-compressed body instead brings it to roughly a tenth of the limit. - The document is identical for every requester, so the key covers only the - path and the negotiated representation. Nothing in it varies by user: + The key covers the path, the negotiated representation and the active + language. The document is not identical for every requester: drf-spectacular + renders it under whatever language is active, and ``LocaleMiddleware`` sets + that from the request's cookie or ``Accept-Language`` before this runs, so + two locales produce different documents. Nothing else in it varies by user -- ``SERVE_PUBLIC`` defaults to ``True``, ``SERVE_*`` cannot be set through ``custom_settings``, and ``API_DOCS_SETTINGS`` sets ``'SERVERS': []`` so no ``servers`` block is emitted. + Only GET and HEAD are cached, as ``cache_page`` did: DRF answers OPTIONS on + this view with a 200 metadata document, which would otherwise be stored and + served to the next GET. + ``drf_spectacular.views`` is imported inside the function rather than at module scope: this module is imported from the settings, and importing DRF views that early freezes ``api_settings`` before ``DEFAULT_SCHEMA_CLASS`` is @@ -93,13 +101,14 @@ def _response(content_type, body): def schema_view(request, *args, **kwargs): """Serve the schema, from the cache when a fresh copy is stored.""" - if not settings.OPENAPI_CACHE_TIMEOUT: + if not settings.OPENAPI_CACHE_TIMEOUT or request.method not in ('GET', 'HEAD'): return view(request, *args, **kwargs) cache_key = get_cache_key( resource='apidocs-schema', path=request.get_full_path(), accept=request.META.get('HTTP_ACCEPT', ''), + language=translation.get_language(), ) + '.zpickled' cached = cache.get(cache_key) From 133d1588370a241c7c80368eb7710a958528e9e5 Mon Sep 17 00:00:00 2001 From: Muhammad Faraz Maqsood Date: Tue, 29 Sep 2026 14:04:16 +0500 Subject: [PATCH 11/12] fix: key the api-docs schema cache per service The LMS and the CMS serve this view at the same path, under the same Accept and language, and share one cache by default, so both landed on the same key and whichever generated first served its document at the other's URL. cache_page avoided this by hashing build_absolute_uri(), host included. Add ROOT_URLCONF to the key. --- openedx/core/apidocs.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/openedx/core/apidocs.py b/openedx/core/apidocs.py index b2d264430bd3..4c96c8cce109 100644 --- a/openedx/core/apidocs.py +++ b/openedx/core/apidocs.py @@ -70,14 +70,16 @@ def cached_schema_view(): whole schema on every request. Storing the zlib-compressed body instead brings it to roughly a tenth of the limit. - The key covers the path, the negotiated representation and the active - language. The document is not identical for every requester: drf-spectacular - renders it under whatever language is active, and ``LocaleMiddleware`` sets - that from the request's cookie or ``Accept-Language`` before this runs, so - two locales produce different documents. Nothing else in it varies by user -- - ``SERVE_PUBLIC`` defaults to ``True``, ``SERVE_*`` cannot be set through - ``custom_settings``, and ``API_DOCS_SETTINGS`` sets ``'SERVERS': []`` so no - ``servers`` block is emitted. + The key covers the URLconf, the path, the negotiated representation and the + active language. The URLconf because the LMS and the CMS serve this view at + the same path and share one cache by default, so without it whichever + service generated first would serve its document at the other's URL. The + language because drf-spectacular renders the document under whatever + language is active, and ``LocaleMiddleware`` sets that from the request's + cookie or ``Accept-Language`` before this runs. Nothing else in it varies by + requester -- ``SERVE_PUBLIC`` defaults to ``True``, ``SERVE_*`` cannot be set + through ``custom_settings``, and ``API_DOCS_SETTINGS`` sets ``'SERVERS': []`` + so no ``servers`` block is emitted. Only GET and HEAD are cached, as ``cache_page`` did: DRF answers OPTIONS on this view with a 200 metadata document, which would otherwise be stored and @@ -106,6 +108,7 @@ def schema_view(request, *args, **kwargs): cache_key = get_cache_key( resource='apidocs-schema', + urlconf=settings.ROOT_URLCONF, path=request.get_full_path(), accept=request.META.get('HTTP_ACCEPT', ''), language=translation.get_language(), From f9092250fc274a0aaef3d7b38c537c0291145228 Mon Sep 17 00:00:00 2001 From: Muhammad Faraz Maqsood Date: Fri, 2 Oct 2026 22:14:44 +0500 Subject: [PATCH 12/12] test: cover the URLconf in the api-docs schema cache key LMS and CMS serve /api-docs/schema/ at the same path and share one cache, so the key must include ROOT_URLCONF. The existing cache test runs under a single URLconf and could not detect that line being dropped; this test serves the view from a second URLconf and fails without it. --- cms/tests.py | 26 ++++++++++++++++++++++++++ lms/tests.py | 26 ++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/cms/tests.py b/cms/tests.py index 015df12aec31..6bebd0f5f52d 100644 --- a/cms/tests.py +++ b/cms/tests.py @@ -2,6 +2,9 @@ from django.core.cache import cache from django.test import TestCase, override_settings +from django.urls import path + +from openedx.core.apidocs import cached_schema_view class CmsModuleTests(TestCase): @@ -48,3 +51,26 @@ def test_api_docs_schema_cache(self): assert english.content.startswith(b'openapi:') assert english.content != spanish.content assert english.content == cached.content + + @override_settings( + OPENAPI_CACHE_TIMEOUT=60, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}, + ) + def test_api_docs_schema_cache_per_urlconf(self): + """ + The key covers the URLconf: LMS and CMS serve this at the same path and + share one cache, so a document generated under one must not come back + under the other. + """ + cache.clear() + full = self.client.get('/api-docs/schema/') + with override_settings(ROOT_URLCONF='cms.tests'): + other = self.client.get('/api-docs/schema/') + again = self.client.get('/api-docs/schema/') + assert full.status_code == 200 + assert other.status_code == 200 + assert full.content != other.content + assert again.content == full.content + + +urlpatterns = [path('api-docs/schema/', cached_schema_view())] diff --git a/lms/tests.py b/lms/tests.py index 2fe677a73010..4a45b86a2699 100644 --- a/lms/tests.py +++ b/lms/tests.py @@ -7,6 +7,9 @@ from django.conf import settings # pylint: disable=unused-import # noqa: F401 from django.core.cache import cache from django.test import TestCase, override_settings +from django.urls import path + +from openedx.core.apidocs import cached_schema_view log = logging.getLogger(__name__) @@ -61,3 +64,26 @@ def test_api_docs_schema_cache(self): assert english.content.startswith(b'openapi:') assert english.content != spanish.content assert english.content == cached.content + + @override_settings( + OPENAPI_CACHE_TIMEOUT=60, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}, + ) + def test_api_docs_schema_cache_per_urlconf(self): + """ + The key covers the URLconf: LMS and CMS serve this at the same path and + share one cache, so a document generated under one must not come back + under the other. + """ + cache.clear() + full = self.client.get('/api-docs/schema/') + with override_settings(ROOT_URLCONF='lms.tests'): + other = self.client.get('/api-docs/schema/') + again = self.client.get('/api-docs/schema/') + assert full.status_code == 200 + assert other.status_code == 200 + assert full.content != other.content + assert again.content == full.content + + +urlpatterns = [path('api-docs/schema/', cached_schema_view())]