From 1e608035fa0d70f9ca0875f80952d4b9598dc48b Mon Sep 17 00:00:00 2001 From: Muhammad Faraz Maqsood Date: Wed, 16 Sep 2026 10:17:11 +0500 Subject: [PATCH 1/8] 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 e2bf14f6dadbd6beed4b0a41d4b8b4c8d87ca5a9 Mon Sep 17 00:00:00 2001 From: Muhammad Faraz Maqsood Date: Wed, 16 Sep 2026 10:49:46 +0500 Subject: [PATCH 2/8] 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 dfa1f54ba10fca55c50241b8b7e5edc3a5f2661d Mon Sep 17 00:00:00 2001 From: Muhammad Faraz Maqsood Date: Wed, 16 Sep 2026 10:56:56 +0500 Subject: [PATCH 3/8] 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 2644307f9f548d63f63ee49debe5e922b8943d21 Mon Sep 17 00:00:00 2001 From: Muhammad Faraz Maqsood Date: Wed, 16 Sep 2026 11:31:20 +0500 Subject: [PATCH 4/8] 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 6f6e34c6c210eec757edbf35c41c089e7d582c60 Mon Sep 17 00:00:00 2001 From: Muhammad Faraz Maqsood Date: Wed, 16 Sep 2026 11:42:30 +0500 Subject: [PATCH 5/8] 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 f4b9df3aa848ed7d33317935a0337523e64c0e6b Mon Sep 17 00:00:00 2001 From: Muhammad Faraz Maqsood Date: Wed, 16 Sep 2026 11:54:32 +0500 Subject: [PATCH 6/8] 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 b9f64672fff9fbe116f83b5da19a775b80feaf46 Mon Sep 17 00:00:00 2001 From: Muhammad Faraz Maqsood Date: Wed, 16 Sep 2026 13:44:55 +0500 Subject: [PATCH 7/8] 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 bd4a1ac78165..637bbaaf9b4d 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -874,9 +874,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 dcc51f9ffaad..449ee0a952e6 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -2064,7 +2064,6 @@ 'django_filters', # API Documentation - 'drf_yasg', 'drf_spectacular', # edx-drf-extensions @@ -2149,10 +2148,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 0765504d4080..4a38948b2c7e 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 @@ -983,8 +982,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 29b214f7ccd3..0c6e39ca3586 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 ee6a3a9a6f4b..6d7a7e91d8d4 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 1ca504d04363..f3c376b17d0e 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 cf69d22270bc..26dc72f7ac7b 100644 --- a/uv.lock +++ b/uv.lock @@ -4452,7 +4452,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" }, @@ -4859,7 +4858,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 4bf390f6127274ca46d10b7df0f0529aff79af65 Mon Sep 17 00:00:00 2001 From: Muhammad Faraz Maqsood Date: Thu, 17 Sep 2026 19:54:33 +0500 Subject: [PATCH 8/8] 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 637bbaaf9b4d..dd3ca112cdaa 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -921,6 +921,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 604d2753bccd..1d0c83a8f072 100644 --- a/cms/envs/production.py +++ b/cms/envs/production.py @@ -419,6 +419,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 449ee0a952e6..41f13d9eb36f 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -2065,6 +2065,7 @@ # API Documentation 'drf_spectacular', + 'drf_spectacular_sidecar', # edx-drf-extensions 'csrf.apps.CsrfAppConfig', # Enables frontend apps to retrieve CSRF tokens. @@ -2146,9 +2147,6 @@ add_optional_apps(OPTIONAL_APPS, INSTALLED_APPS) # noqa: F405 -######################### Django Rest Framework ######################## - - ###################### drf-spectacular (LMS enrollment schema) ###################### SPECTACULAR_SETTINGS = { 'TITLE': 'LMS Enrollment API', @@ -2157,6 +2155,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 4a38948b2c7e..7043e0c59bd3 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 @@ -995,15 +997,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 0c6e39ca3586..3b8475ff0616 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 6d7a7e91d8d4..d7a37c5d8155 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 f3c376b17d0e..913356156c43 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 26dc72f7ac7b..cd9ea7f11525 100644 --- a/uv.lock +++ b/uv.lock @@ -1817,6 +1817,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" @@ -4451,6 +4464,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" }, @@ -4857,6 +4871,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" },