From 515ad0e3c1220801f026e636913e34cc19d14362 Mon Sep 17 00:00:00 2001 From: Muhammad Faraz Maqsood Date: Fri, 3 Jul 2026 16:04:53 +0500 Subject: [PATCH 1/3] feat: fix OpenAPI schemas for SDK compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes driven by integration testing with openedx-platform-sdk: 1. authoring_grading serializer — expose grade_cutoffs, grace_period, and minimum_grade_credit as proper typed schema fields. CourseGradingModel.update_from_json requires all three, but they were absent from the serializer so the generated SDK had no typed fields for them, forcing callers to smuggle values in via additional_properties. Adds GracePeriodSerializer (hours / minutes / seconds) and documents that grade_cutoffs and grace_period must be present in every PATCH. 2. course_details serializer — mark certificate_available_date as allow_null=True. The field is legitimately null for many courses, but the missing flag caused the SDK client to crash with fromisoformat(None) when deserialising the response. 3. v4 HomeCoursesViewSet — add _HomeCoursesAutoSchema (same pattern as the existing v3 _HomeAutoSchema). The viewset builds its paginator manually inside list() rather than via pagination_class, so drf-spectacular's _is_list_view() returns True and generates an array schema. Overriding _is_list_view() for the list action and using an inline_serializer makes the schema reflect the actual paginated object shape (count, num_pages, current_page, start, next, previous, results). Co-Authored-By: Claude Sonnet 4.6 --- .../v0/serializers/authoring_grading.py | 28 +++++++++++++++++++ .../rest_api/v1/serializers/course_details.py | 2 +- .../contentstore/rest_api/v4/views/home.py | 27 ++++++++++++++++-- 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/cms/djangoapps/contentstore/rest_api/v0/serializers/authoring_grading.py b/cms/djangoapps/contentstore/rest_api/v0/serializers/authoring_grading.py index e42c3e2ee397..09799263798a 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/serializers/authoring_grading.py +++ b/cms/djangoapps/contentstore/rest_api/v0/serializers/authoring_grading.py @@ -18,9 +18,37 @@ class Meta: ref_name = "authoring_grading.Graders.v0" +class GracePeriodSerializer(serializers.Serializer): + """Serializer for grace period (hours / minutes / seconds).""" + hours = serializers.IntegerField(default=0) + minutes = serializers.IntegerField(default=0) + seconds = serializers.IntegerField(default=0, required=False) + + class Meta: + ref_name = "authoring_grading.GracePeriod.v0" + + class CourseGradingModelSerializer(serializers.Serializer): """ Serializer for course grading model data """ graders = GradersSerializer(many=True, allow_null=True, allow_empty=True) + grade_cutoffs = serializers.DictField( + child=serializers.FloatField(), + required=False, + help_text=( + "Mapping of letter grade to minimum score (0.0–1.0). " + "Required by CourseGradingModel.update_from_json — must be included in every PATCH." + ), + ) + grace_period = GracePeriodSerializer( + allow_null=True, + required=False, + help_text="Grace period duration. Pass null to clear the grace period.", + ) + minimum_grade_credit = serializers.FloatField( + required=False, + allow_null=True, + help_text="Minimum passing score for credit eligibility (0.0–1.0).", + ) class Meta: ref_name = "authoring_grading.CourseGrading.v0" diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/course_details.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/course_details.py index eec4dc11f38a..69dfd04c3c67 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/serializers/course_details.py +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/course_details.py @@ -29,7 +29,7 @@ class CourseDetailsSerializer(serializers.Serializer): about_sidebar_html = serializers.CharField(allow_null=True, allow_blank=True) banner_image_name = serializers.CharField(allow_blank=True) banner_image_asset_path = serializers.CharField() - certificate_available_date = serializers.DateTimeField() + certificate_available_date = serializers.DateTimeField(allow_null=True) certificates_display_behavior = serializers.CharField(allow_null=True) course_id = serializers.CharField() course_image_asset_path = serializers.CharField(allow_blank=True) diff --git a/cms/djangoapps/contentstore/rest_api/v4/views/home.py b/cms/djangoapps/contentstore/rest_api/v4/views/home.py index 1769edcd173c..aabec96268bb 100644 --- a/cms/djangoapps/contentstore/rest_api/v4/views/home.py +++ b/cms/djangoapps/contentstore/rest_api/v4/views/home.py @@ -1,6 +1,8 @@ """HomeCoursesViewSet for getting courses available to the logged-in user (v4).""" -from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema +from drf_spectacular.openapi import AutoSchema +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema, inline_serializer +from rest_framework import serializers as _serializers from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.auth.session.authentication import ( SessionAuthenticationAllowInactiveUser, @@ -18,6 +20,15 @@ from openedx.core.lib.api.mixins import StandardizedErrorMixin +class _HomeCoursesAutoSchema(AutoSchema): + """Custom AutoSchema that treats the 'list' action as a single-object response.""" + + def _is_list_view(self, serializer=None): + if self.view.action == 'list': + return False + return super()._is_list_view(serializer) + + class HomePageCoursesPaginator(DefaultPagination): """ ADR 0032 - standard pagination for the Studio home courses list (v4). @@ -136,6 +147,7 @@ class HomeCoursesViewSet(StandardizedErrorMixin, viewsets.ViewSet): is used instead of the default ``SessionAuthentication``. """ + schema = _HomeCoursesAutoSchema() authentication_classes = (JwtAuthentication, SessionAuthenticationAllowInactiveUser) permission_classes = (IsAuthenticated,) serializer_class = CourseHomeTabSerializerV4 @@ -154,7 +166,18 @@ def get_serializer(self, *args, **kwargs): parameters=_HOME_COURSES_QUERY_PARAMETERS, responses={ 200: OpenApiResponse( - response=CourseHomeTabSerializerV4, + response=inline_serializer( + name="PaginatedV4HomeCoursesResponse", + fields={ + "count": _serializers.IntegerField(help_text="Total number of courses."), + "num_pages": _serializers.IntegerField(help_text="Total number of pages."), + "current_page": _serializers.IntegerField(help_text="Current page number."), + "start": _serializers.IntegerField(help_text="Zero-based index of the first item on this page."), + "next": _serializers.CharField(allow_null=True, help_text="URL for the next page, or null."), + "previous": _serializers.CharField(allow_null=True, help_text="URL for the previous page, or null."), + "results": CourseHomeTabSerializerV4(), + }, + ), description="Paginated course list retrieved successfully.", ), 401: _UNAUTHENTICATED_RESPONSE, From c5b652b1f25ac88a51b8446a157afb7e0e0ca0e2 Mon Sep 17 00:00:00 2001 From: Muhammad Faraz Maqsood Date: Fri, 3 Jul 2026 16:36:03 +0500 Subject: [PATCH 2/3] fix: sort imports in v4 home view to satisfy ruff I001 Co-Authored-By: Claude Sonnet 4.6 --- cms/djangoapps/contentstore/rest_api/v4/views/home.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cms/djangoapps/contentstore/rest_api/v4/views/home.py b/cms/djangoapps/contentstore/rest_api/v4/views/home.py index aabec96268bb..d4258177678a 100644 --- a/cms/djangoapps/contentstore/rest_api/v4/views/home.py +++ b/cms/djangoapps/contentstore/rest_api/v4/views/home.py @@ -2,12 +2,12 @@ from drf_spectacular.openapi import AutoSchema from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema, inline_serializer -from rest_framework import serializers as _serializers 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.paginators import DefaultPagination +from rest_framework import serializers as _serializers from rest_framework import viewsets from rest_framework.permissions import IsAuthenticated from rest_framework.request import Request From e41f43c148fa4ce4e1b22e1632c74e57ee1ad49a Mon Sep 17 00:00:00 2001 From: Muhammad Faraz Maqsood Date: Fri, 3 Jul 2026 16:42:39 +0500 Subject: [PATCH 3/3] fix: wrap long lines in v4 home view inline_serializer fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes pylint C0301 (line-too-long) — lines 175 and 177 exceeded the 120-character limit. Co-Authored-By: Claude Sonnet 4.6 --- .../contentstore/rest_api/v4/views/home.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/cms/djangoapps/contentstore/rest_api/v4/views/home.py b/cms/djangoapps/contentstore/rest_api/v4/views/home.py index d4258177678a..6fd7fc2a01ef 100644 --- a/cms/djangoapps/contentstore/rest_api/v4/views/home.py +++ b/cms/djangoapps/contentstore/rest_api/v4/views/home.py @@ -172,9 +172,15 @@ def get_serializer(self, *args, **kwargs): "count": _serializers.IntegerField(help_text="Total number of courses."), "num_pages": _serializers.IntegerField(help_text="Total number of pages."), "current_page": _serializers.IntegerField(help_text="Current page number."), - "start": _serializers.IntegerField(help_text="Zero-based index of the first item on this page."), - "next": _serializers.CharField(allow_null=True, help_text="URL for the next page, or null."), - "previous": _serializers.CharField(allow_null=True, help_text="URL for the previous page, or null."), + "start": _serializers.IntegerField( + help_text="Zero-based index of the first item on this page." + ), + "next": _serializers.CharField( + allow_null=True, help_text="URL for the next page, or null." + ), + "previous": _serializers.CharField( + allow_null=True, help_text="URL for the previous page, or null." + ), "results": CourseHomeTabSerializerV4(), }, ),