Skip to content

Commit 148eb3c

Browse files
taimoor-ahmed-1Muhammad Faraz  Maqsood
authored andcommitted
feat: apply ADR standardization to AuthorGrading apis (#38726)
1 parent adbd373 commit 148eb3c

7 files changed

Lines changed: 523 additions & 33 deletions

File tree

cms/djangoapps/contentstore/rest_api/v3/urls.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@
22

33
from rest_framework.routers import DefaultRouter
44

5-
from cms.djangoapps.contentstore.rest_api.v3.views import CourseDetailsViewSet, HomeViewSet
5+
from cms.djangoapps.contentstore.rest_api.v3.views import AuthoringGradingViewSet, CourseDetailsViewSet, HomeViewSet
66

77
app_name = "v3"
88

99
router = DefaultRouter()
1010
router.register(r'home', HomeViewSet, basename='home')
1111
router.register(r'course_details', CourseDetailsViewSet, basename='course_details')
12+
router.register(r'authoring_grading', AuthoringGradingViewSet, basename='authoring_grading')
1213

1314
urlpatterns = router.urls
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""
2+
Shared utilities for v3 contentstore API viewsets.
3+
4+
Houses the small helpers and OpenAPI constants that more than one v3 viewset
5+
needs, so the per-viewset modules stay focused on action bodies and don't
6+
drift apart over time.
7+
8+
Currently provides:
9+
* :func:`resolve_course_key` – parse-and-verify a course key string,
10+
raising ``NotFound`` for unparseable keys or missing courses (replaces
11+
the legacy ``@verify_course_exists()`` decorator from v1 and avoids
12+
relying on ``DeveloperErrorViewMixin``).
13+
* :data:`COMMON_ERROR_RESPONSES` – the shared ``@extend_schema(responses=...)``
14+
fragment for the 401 / 403 / 404 cases every v3 course-scoped viewset
15+
can raise.
16+
"""
17+
18+
from drf_spectacular.utils import OpenApiResponse
19+
from opaque_keys import InvalidKeyError
20+
from opaque_keys.edx.keys import CourseKey
21+
from rest_framework.exceptions import NotFound
22+
23+
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
24+
25+
26+
def resolve_course_key(course_key: str) -> CourseKey:
27+
"""
28+
Parse ``course_key`` (string) into a :class:`CourseKey` and verify the
29+
course exists.
30+
31+
Raises:
32+
rest_framework.exceptions.NotFound: if the string is unparseable
33+
*or* the course does not exist. The ADR 0029 envelope (wired in
34+
by :class:`openedx.core.lib.api.mixins.StandardizedErrorMixin`)
35+
renders both as a structured 404.
36+
37+
OEP-68: the parameter name is ``course_key`` rather than the legacy
38+
``course_id``. The function is intentionally agnostic to which URL kwarg
39+
name the caller used — callers may pass the value of either kwarg as a
40+
positional argument.
41+
"""
42+
try:
43+
parsed = CourseKey.from_string(course_key)
44+
except InvalidKeyError as exc:
45+
raise NotFound("The provided course key cannot be parsed.") from exc
46+
if not CourseOverview.course_exists(parsed):
47+
raise NotFound(f"Course {course_key} not found.")
48+
return parsed
49+
50+
51+
COMMON_ERROR_RESPONSES = {
52+
401: OpenApiResponse(description="The requester is not authenticated."),
53+
403: OpenApiResponse(description="The requester cannot access the specified course."),
54+
404: OpenApiResponse(description="The requested course does not exist."),
55+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""Views for v3 contentstore API."""
22

3+
from .authoring_grading import AuthoringGradingViewSet # noqa: F401
34
from .course_details import CourseDetailsViewSet # noqa: F401
45
from .home import HomeViewSet # noqa: F401
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
"""
2+
API Views for course grading settings — v3.
3+
4+
This module is the v3 incarnation of the v0 ``AuthoringGradingView`` endpoint,
5+
restructured to apply the FC-0118 ADRs from the start:
6+
7+
* ADR 0025 – ``serializer_class`` on the viewset
8+
* ADR 0026 – explicit ``authentication_classes`` + ``permission_classes``
9+
* ADR 0027 – ``drf_spectacular`` for OpenAPI schema generation
10+
* ADR 0028 – consolidated into a single DRF ``ViewSet`` registered via
11+
``DefaultRouter`` (replaces ``AuthoringGradingView`` ``APIView``)
12+
* ADR 0029 – standardized error envelope via :class:`StandardizedErrorMixin`
13+
(v3-scoped — does not change the project-wide DRF ``EXCEPTION_HANDLER``
14+
setting)
15+
* ADR 0033 / OEP-68 – the URL kwarg, action parameter, and OpenAPI parameter
16+
are named ``course_key`` (the OEP-68-standardized name) rather than the
17+
legacy ``course_id``. Since this is a brand-new versioned API, no
18+
deprecated alias is needed — clients on the v0 endpoint continue to use
19+
``course_id`` there.
20+
21+
Permission model note:
22+
PR #38363 proposed a class-level ``HasStudioReadAccess`` permission. The
23+
current v0 view has since evolved to use the ``openedx_authz`` permission
24+
framework (``COURSES_EDIT_GRADING_SETTINGS``), which is more specific to
25+
grading and aligns with the platform-wide authz direction.
26+
27+
The v3 viewset preserves the openedx_authz model via an *inline*
28+
``user_has_course_permission`` check inside the action body (rather than
29+
the ``@authz_permission_required`` decorator). The decorator raises
30+
``DeveloperErrorResponseException`` — a plain ``Exception`` subclass that
31+
does not flow through DRF's exception handler, so it would bypass
32+
:class:`StandardizedErrorMixin` and surface as an unstructured 500.
33+
Raising ``rest_framework.exceptions.PermissionDenied`` directly keeps the
34+
ADR 0029 envelope intact.
35+
"""
36+
37+
from drf_spectacular.utils import OpenApiParameter, OpenApiRequest, OpenApiResponse, extend_schema
38+
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
39+
from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser
40+
from openedx_authz.constants.permissions import COURSES_EDIT_GRADING_SETTINGS
41+
from rest_framework import viewsets
42+
from rest_framework.exceptions import PermissionDenied
43+
from rest_framework.permissions import IsAuthenticated
44+
from rest_framework.request import Request
45+
from rest_framework.response import Response
46+
47+
from cms.djangoapps.contentstore.rest_api.v0.serializers import CourseGradingModelSerializer
48+
from cms.djangoapps.contentstore.rest_api.v3.utils import COMMON_ERROR_RESPONSES, resolve_course_key
49+
from cms.djangoapps.models.settings.course_grading import CourseGradingModel
50+
from openedx.core.djangoapps.authz.constants import LegacyAuthoringPermission
51+
from openedx.core.djangoapps.authz.decorators import user_has_course_permission
52+
from openedx.core.djangoapps.credit.tasks import update_credit_course_requirements
53+
from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser
54+
from openedx.core.lib.api.mixins import StandardizedErrorMixin
55+
56+
_COURSE_KEY_PARAMETER = OpenApiParameter(
57+
name="course_key",
58+
description="OEP-68 course key (e.g. course-v1:org+course+run).",
59+
required=True,
60+
type=str,
61+
location=OpenApiParameter.PATH,
62+
)
63+
64+
65+
class AuthoringGradingViewSet(StandardizedErrorMixin, viewsets.ViewSet):
66+
"""
67+
ViewSet for course grading settings (v3). Registered via DefaultRouter
68+
(basename ``authoring_grading``).
69+
70+
Router-generated URL::
71+
72+
PATCH /api/contentstore/v3/authoring_grading/{course_key}/ → partial_update
73+
74+
Supersedes ``AuthoringGradingView`` at ``POST /api/contentstore/v0/grading/{course_id}``.
75+
"""
76+
77+
authentication_classes = (
78+
JwtAuthentication,
79+
BearerAuthenticationAllowInactiveUser,
80+
SessionAuthenticationAllowInactiveUser,
81+
)
82+
permission_classes = (IsAuthenticated,)
83+
serializer_class = CourseGradingModelSerializer
84+
85+
# DefaultRouter lookup: matches course-v1:org+course+run (+ or / separators).
86+
# OEP-68: the kwarg name is ``course_key`` (not the legacy ``course_id``).
87+
lookup_field = "course_key"
88+
lookup_value_regex = r"[^/+]+(?:/|\+)[^/+]+(?:/|\+)[^/?]+"
89+
90+
def get_serializer(self, *args, **kwargs):
91+
"""Instantiate and return the configured serializer class."""
92+
return self.serializer_class(*args, **kwargs)
93+
94+
@extend_schema(
95+
summary="Update a course's grading settings",
96+
description="Partially update the grading settings for the specified course.",
97+
request=OpenApiRequest(request=CourseGradingModelSerializer),
98+
parameters=[_COURSE_KEY_PARAMETER],
99+
responses={
100+
200: OpenApiResponse(
101+
response=CourseGradingModelSerializer,
102+
description="Grading settings updated successfully.",
103+
),
104+
**COMMON_ERROR_RESPONSES,
105+
},
106+
)
107+
def partial_update(self, request: Request, course_key: str):
108+
"""
109+
Update a course's grading settings.
110+
111+
**Example Request**
112+
113+
PATCH /api/contentstore/v3/authoring_grading/{course_key}/
114+
115+
**PATCH Parameters**
116+
117+
The request body should follow the ``CourseGradingModelSerializer``
118+
schema. Example::
119+
120+
{
121+
"graders": [
122+
{
123+
"type": "Homework",
124+
"min_count": 1,
125+
"drop_count": 0,
126+
"short_label": "",
127+
"weight": 100,
128+
"id": 0
129+
}
130+
],
131+
"grade_cutoffs": {"A": 0.75, "B": 0.63, "C": 0.57, "D": 0.5},
132+
"grace_period": {"hours": 12, "minutes": 0},
133+
"minimum_grade_credit": 0.7,
134+
"is_credit_course": true
135+
}
136+
137+
**Response Values**
138+
139+
If the request is successful, an HTTP 200 "OK" response is returned
140+
with the updated grading data serialized via
141+
:class:`CourseGradingModelSerializer`.
142+
"""
143+
parsed_course_key = resolve_course_key(course_key)
144+
145+
# Per-action authorization (ADR 0026): kept inline rather than
146+
# behind ``@authz_permission_required`` because that decorator
147+
# raises ``DeveloperErrorResponseException`` (not a DRF exception),
148+
# which bypasses :class:`StandardizedErrorMixin`. Raising
149+
# ``PermissionDenied`` directly flows through the ADR 0029 envelope.
150+
if not user_has_course_permission(
151+
request.user,
152+
COURSES_EDIT_GRADING_SETTINGS.identifier,
153+
parsed_course_key,
154+
LegacyAuthoringPermission.READ,
155+
):
156+
raise PermissionDenied("You do not have permission to perform this action.")
157+
158+
if "minimum_grade_credit" in request.data:
159+
update_credit_course_requirements.delay(str(parsed_course_key))
160+
161+
updated_data = CourseGradingModel.update_from_json(parsed_course_key, request.data, request.user)
162+
serializer = self.get_serializer(updated_data)
163+
return Response(serializer.data)

cms/djangoapps/contentstore/rest_api/v3/views/course_details.py

Lines changed: 5 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -26,26 +26,23 @@
2626
from drf_spectacular.utils import OpenApiParameter, OpenApiRequest, OpenApiResponse, extend_schema
2727
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
2828
from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser
29-
from opaque_keys import InvalidKeyError
30-
from opaque_keys.edx.keys import CourseKey
3129
from openedx_authz.constants.permissions import (
3230
COURSES_EDIT_DETAILS,
3331
COURSES_EDIT_SCHEDULE,
3432
COURSES_VIEW_SCHEDULE_AND_DETAILS,
3533
)
3634
from rest_framework import viewsets
37-
from rest_framework.exceptions import NotFound
3835
from rest_framework.exceptions import ValidationError as DRFValidationError
3936
from rest_framework.permissions import IsAuthenticated
4037
from rest_framework.request import Request
4138
from rest_framework.response import Response
4239

4340
from cms.djangoapps.contentstore.rest_api.v1.serializers import CourseDetailsSerializer
4441
from cms.djangoapps.contentstore.rest_api.v1.views.course_details import _classify_update
42+
from cms.djangoapps.contentstore.rest_api.v3.utils import COMMON_ERROR_RESPONSES, resolve_course_key
4543
from cms.djangoapps.contentstore.utils import update_course_details
4644
from openedx.core.djangoapps.authz.constants import LegacyAuthoringPermission
4745
from openedx.core.djangoapps.authz.decorators import user_has_course_permission
48-
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
4946
from openedx.core.djangoapps.models.course_details import CourseDetails
5047
from openedx.core.lib.api.mixins import StandardizedErrorMixin
5148
from xmodule.modulestore.django import modulestore
@@ -57,29 +54,6 @@
5754
type=str,
5855
location=OpenApiParameter.PATH,
5956
)
60-
_COMMON_ERROR_RESPONSES = {
61-
401: OpenApiResponse(description="The requester is not authenticated."),
62-
403: OpenApiResponse(description="The requester cannot access the specified course."),
63-
404: OpenApiResponse(description="The requested course does not exist."),
64-
}
65-
66-
67-
def _resolve_course_key(course_id: str) -> CourseKey:
68-
"""
69-
Parse ``course_id`` into a ``CourseKey`` and verify the course exists.
70-
71-
Raises ``NotFound`` for both unparseable keys and missing courses, which
72-
the ADR 0029 envelope renders as a structured 404 response. This replaces
73-
the legacy ``@verify_course_exists()`` decorator from v1 and avoids
74-
relying on ``DeveloperErrorViewMixin``.
75-
"""
76-
try:
77-
course_key = CourseKey.from_string(course_id)
78-
except InvalidKeyError as exc:
79-
raise NotFound("The provided course key cannot be parsed.") from exc
80-
if not CourseOverview.course_exists(course_key):
81-
raise NotFound(f"Course {course_id} not found.")
82-
return course_key
8357

8458

8559
class CourseDetailsViewSet(StandardizedErrorMixin, viewsets.ViewSet):
@@ -111,7 +85,7 @@ class CourseDetailsViewSet(StandardizedErrorMixin, viewsets.ViewSet):
11185
response=CourseDetailsSerializer,
11286
description="Course details retrieved successfully.",
11387
),
114-
**_COMMON_ERROR_RESPONSES,
88+
**COMMON_ERROR_RESPONSES,
11589
},
11690
)
11791
def retrieve(self, request: Request, course_id: str):
@@ -122,7 +96,7 @@ def retrieve(self, request: Request, course_id: str):
12296
12397
GET /api/contentstore/v3/course_details/{course_id}/
12498
"""
125-
course_key = _resolve_course_key(course_id)
99+
course_key = resolve_course_key(course_id)
126100
if not user_has_course_permission(
127101
request.user,
128102
COURSES_VIEW_SCHEDULE_AND_DETAILS.identifier,
@@ -146,7 +120,7 @@ def retrieve(self, request: Request, course_id: str):
146120
description="Course details updated successfully.",
147121
),
148122
400: OpenApiResponse(description="Bad request — invalid data."),
149-
**_COMMON_ERROR_RESPONSES,
123+
**COMMON_ERROR_RESPONSES,
150124
},
151125
)
152126
def update(self, request: Request, course_id: str):
@@ -169,7 +143,7 @@ def update(self, request: Request, course_id: str):
169143
If the request is successful, an HTTP 200 "OK" response is returned,
170144
along with all the course's details similar to a ``GET`` request.
171145
"""
172-
course_key = _resolve_course_key(course_id)
146+
course_key = resolve_course_key(course_id)
173147
is_schedule_update, is_details_update = _classify_update(request.data, course_key)
174148

175149
if not is_schedule_update and not is_details_update:

0 commit comments

Comments
 (0)