Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion cms/djangoapps/contentstore/rest_api/v3/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@

from rest_framework.routers import DefaultRouter

from cms.djangoapps.contentstore.rest_api.v3.views import CourseDetailsViewSet, HomeViewSet
from cms.djangoapps.contentstore.rest_api.v3.views import AuthoringGradingViewSet, CourseDetailsViewSet, HomeViewSet

app_name = "v3"

router = DefaultRouter()
router.register(r'home', HomeViewSet, basename='home')
router.register(r'course_details', CourseDetailsViewSet, basename='course_details')
router.register(r'authoring_grading', AuthoringGradingViewSet, basename='authoring_grading')

urlpatterns = router.urls
55 changes: 55 additions & 0 deletions cms/djangoapps/contentstore/rest_api/v3/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""
Shared utilities for v3 contentstore API viewsets.

Houses the small helpers and OpenAPI constants that more than one v3 viewset
needs, so the per-viewset modules stay focused on action bodies and don't
drift apart over time.

Currently provides:
* :func:`resolve_course_key` – parse-and-verify a course key string,
raising ``NotFound`` for unparseable keys or missing courses (replaces
the legacy ``@verify_course_exists()`` decorator from v1 and avoids
relying on ``DeveloperErrorViewMixin``).
* :data:`COMMON_ERROR_RESPONSES` – the shared ``@extend_schema(responses=...)``
fragment for the 401 / 403 / 404 cases every v3 course-scoped viewset
can raise.
"""

from drf_spectacular.utils import OpenApiResponse
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from rest_framework.exceptions import NotFound

from openedx.core.djangoapps.content.course_overviews.models import CourseOverview


def resolve_course_key(course_key: str) -> CourseKey:
"""
Parse ``course_key`` (string) into a :class:`CourseKey` and verify the
course exists.

Raises:
rest_framework.exceptions.NotFound: if the string is unparseable
*or* the course does not exist. The ADR 0029 envelope (wired in
by :class:`openedx.core.lib.api.mixins.StandardizedErrorMixin`)
renders both as a structured 404.

OEP-68: the parameter name is ``course_key`` rather than the legacy
``course_id``. The function is intentionally agnostic to which URL kwarg
name the caller used — callers may pass the value of either kwarg as a
positional argument.
"""
try:
parsed = CourseKey.from_string(course_key)
except InvalidKeyError as exc:
raise NotFound("The provided course key cannot be parsed.") from exc
if not CourseOverview.course_exists(parsed):
raise NotFound(f"Course {course_key} not found.")
return parsed


COMMON_ERROR_RESPONSES = {
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."),
}
1 change: 1 addition & 0 deletions cms/djangoapps/contentstore/rest_api/v3/views/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Views for v3 contentstore API."""

from .authoring_grading import AuthoringGradingViewSet # noqa: F401
from .course_details import CourseDetailsViewSet # noqa: F401
from .home import HomeViewSet # noqa: F401
163 changes: 163 additions & 0 deletions cms/djangoapps/contentstore/rest_api/v3/views/authoring_grading.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
"""
API Views for course grading settings — v3.

This module is the v3 incarnation of the v0 ``AuthoringGradingView`` endpoint,
restructured to apply the FC-0118 ADRs from the start:

* ADR 0025 – ``serializer_class`` on the viewset
* ADR 0026 – explicit ``authentication_classes`` + ``permission_classes``
* ADR 0027 – ``drf_spectacular`` for OpenAPI schema generation
* ADR 0028 – consolidated into a single DRF ``ViewSet`` registered via
``DefaultRouter`` (replaces ``AuthoringGradingView`` ``APIView``)
* ADR 0029 – standardized error envelope via :class:`StandardizedErrorMixin`
(v3-scoped — does not change the project-wide DRF ``EXCEPTION_HANDLER``
setting)
* ADR 0033 / OEP-68 – the URL kwarg, action parameter, and OpenAPI parameter
are named ``course_key`` (the OEP-68-standardized name) rather than the
legacy ``course_id``. Since this is a brand-new versioned API, no
deprecated alias is needed — clients on the v0 endpoint continue to use
``course_id`` there.

Permission model note:
PR #38363 proposed a class-level ``HasStudioReadAccess`` permission. The
current v0 view has since evolved to use the ``openedx_authz`` permission
framework (``COURSES_EDIT_GRADING_SETTINGS``), which is more specific to
grading and aligns with the platform-wide authz direction.

The v3 viewset preserves the openedx_authz model via an *inline*
``user_has_course_permission`` check inside the action body (rather than
the ``@authz_permission_required`` decorator). The decorator raises
``DeveloperErrorResponseException`` — a plain ``Exception`` subclass that
does not flow through DRF's exception handler, so it would bypass
:class:`StandardizedErrorMixin` and surface as an unstructured 500.
Raising ``rest_framework.exceptions.PermissionDenied`` directly keeps the
ADR 0029 envelope intact.
"""

from drf_spectacular.utils import OpenApiParameter, OpenApiRequest, 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_EDIT_GRADING_SETTINGS
from rest_framework import viewsets
from rest_framework.exceptions import PermissionDenied
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response

from cms.djangoapps.contentstore.rest_api.v0.serializers import CourseGradingModelSerializer
from cms.djangoapps.contentstore.rest_api.v3.utils import COMMON_ERROR_RESPONSES, resolve_course_key
from cms.djangoapps.models.settings.course_grading import CourseGradingModel
from openedx.core.djangoapps.authz.constants import LegacyAuthoringPermission
from openedx.core.djangoapps.authz.decorators import user_has_course_permission
from openedx.core.djangoapps.credit.tasks import update_credit_course_requirements
from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser
from openedx.core.lib.api.mixins import StandardizedErrorMixin

_COURSE_KEY_PARAMETER = OpenApiParameter(
name="course_key",
description="OEP-68 course key (e.g. course-v1:org+course+run).",
required=True,
type=str,
location=OpenApiParameter.PATH,
)


class AuthoringGradingViewSet(StandardizedErrorMixin, viewsets.ViewSet):
"""
ViewSet for course grading settings (v3). Registered via DefaultRouter
(basename ``authoring_grading``).

Router-generated URL::

PATCH /api/contentstore/v3/authoring_grading/{course_key}/ → partial_update

Supersedes ``AuthoringGradingView`` at ``POST /api/contentstore/v0/grading/{course_id}``.
"""

authentication_classes = (
JwtAuthentication,
BearerAuthenticationAllowInactiveUser,
SessionAuthenticationAllowInactiveUser,
)
permission_classes = (IsAuthenticated,)
serializer_class = CourseGradingModelSerializer

# DefaultRouter lookup: matches course-v1:org+course+run (+ or / separators).
# OEP-68: the kwarg name is ``course_key`` (not the legacy ``course_id``).
lookup_field = "course_key"
lookup_value_regex = r"[^/+]+(?:/|\+)[^/+]+(?:/|\+)[^/?]+"

def get_serializer(self, *args, **kwargs):
"""Instantiate and return the configured serializer class."""
return self.serializer_class(*args, **kwargs)

@extend_schema(
summary="Update a course's grading settings",
description="Partially update the grading settings for the specified course.",
request=OpenApiRequest(request=CourseGradingModelSerializer),
parameters=[_COURSE_KEY_PARAMETER],
responses={
200: OpenApiResponse(
response=CourseGradingModelSerializer,
description="Grading settings updated successfully.",
),
**COMMON_ERROR_RESPONSES,
},
)
def partial_update(self, request: Request, course_key: str):
"""
Update a course's grading settings.

**Example Request**

PATCH /api/contentstore/v3/authoring_grading/{course_key}/

**PATCH Parameters**

The request body should follow the ``CourseGradingModelSerializer``
schema. Example::

{
"graders": [
{
"type": "Homework",
"min_count": 1,
"drop_count": 0,
"short_label": "",
"weight": 100,
"id": 0
}
],
"grade_cutoffs": {"A": 0.75, "B": 0.63, "C": 0.57, "D": 0.5},
"grace_period": {"hours": 12, "minutes": 0},
"minimum_grade_credit": 0.7,
"is_credit_course": true
}

**Response Values**

If the request is successful, an HTTP 200 "OK" response is returned
with the updated grading data serialized via
:class:`CourseGradingModelSerializer`.
"""
parsed_course_key = resolve_course_key(course_key)

# Per-action authorization (ADR 0026): kept inline rather than
# behind ``@authz_permission_required`` because that decorator
# raises ``DeveloperErrorResponseException`` (not a DRF exception),
# which bypasses :class:`StandardizedErrorMixin`. Raising
# ``PermissionDenied`` directly flows through the ADR 0029 envelope.
if not user_has_course_permission(
request.user,
COURSES_EDIT_GRADING_SETTINGS.identifier,
parsed_course_key,
LegacyAuthoringPermission.READ,
):
raise PermissionDenied("You do not have permission to perform this action.")

if "minimum_grade_credit" in request.data:
update_credit_course_requirements.delay(str(parsed_course_key))

updated_data = CourseGradingModel.update_from_json(parsed_course_key, request.data, request.user)
serializer = self.get_serializer(updated_data)
return Response(serializer.data)
36 changes: 5 additions & 31 deletions cms/djangoapps/contentstore/rest_api/v3/views/course_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,26 +26,23 @@
from drf_spectacular.utils import OpenApiParameter, OpenApiRequest, OpenApiResponse, extend_schema
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
from opaque_keys.edx.keys import CourseKey
from openedx_authz.constants.permissions import (
COURSES_EDIT_DETAILS,
COURSES_EDIT_SCHEDULE,
COURSES_VIEW_SCHEDULE_AND_DETAILS,
)
from rest_framework import viewsets
from rest_framework.exceptions import NotFound
from rest_framework.exceptions import ValidationError as DRFValidationError
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response

from cms.djangoapps.contentstore.rest_api.v1.serializers import CourseDetailsSerializer
from cms.djangoapps.contentstore.rest_api.v1.views.course_details import _classify_update
from cms.djangoapps.contentstore.rest_api.v3.utils import COMMON_ERROR_RESPONSES, resolve_course_key
from cms.djangoapps.contentstore.utils import update_course_details
from openedx.core.djangoapps.authz.constants import LegacyAuthoringPermission
from openedx.core.djangoapps.authz.decorators import user_has_course_permission
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.models.course_details import CourseDetails
from openedx.core.lib.api.mixins import StandardizedErrorMixin
from xmodule.modulestore.django import modulestore
Expand All @@ -57,29 +54,6 @@
type=str,
location=OpenApiParameter.PATH,
)
_COMMON_ERROR_RESPONSES = {
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 _resolve_course_key(course_id: str) -> CourseKey:
"""
Parse ``course_id`` into a ``CourseKey`` and verify the course exists.

Raises ``NotFound`` for both unparseable keys and missing courses, which
the ADR 0029 envelope renders as a structured 404 response. This replaces
the legacy ``@verify_course_exists()`` decorator from v1 and avoids
relying on ``DeveloperErrorViewMixin``.
"""
try:
course_key = CourseKey.from_string(course_id)
except InvalidKeyError as exc:
raise NotFound("The provided course key cannot be parsed.") from exc
if not CourseOverview.course_exists(course_key):
raise NotFound(f"Course {course_id} not found.")
return course_key


class CourseDetailsViewSet(StandardizedErrorMixin, viewsets.ViewSet):
Expand Down Expand Up @@ -111,7 +85,7 @@ class CourseDetailsViewSet(StandardizedErrorMixin, viewsets.ViewSet):
response=CourseDetailsSerializer,
description="Course details retrieved successfully.",
),
**_COMMON_ERROR_RESPONSES,
**COMMON_ERROR_RESPONSES,
},
)
def retrieve(self, request: Request, course_id: str):
Expand All @@ -122,7 +96,7 @@ def retrieve(self, request: Request, course_id: str):

GET /api/contentstore/v3/course_details/{course_id}/
"""
course_key = _resolve_course_key(course_id)
course_key = resolve_course_key(course_id)
if not user_has_course_permission(
request.user,
COURSES_VIEW_SCHEDULE_AND_DETAILS.identifier,
Expand All @@ -146,7 +120,7 @@ def retrieve(self, request: Request, course_id: str):
description="Course details updated successfully.",
),
400: OpenApiResponse(description="Bad request — invalid data."),
**_COMMON_ERROR_RESPONSES,
**COMMON_ERROR_RESPONSES,
},
)
def update(self, request: Request, course_id: str):
Expand All @@ -169,7 +143,7 @@ def update(self, request: Request, course_id: str):
If the request is successful, an HTTP 200 "OK" response is returned,
along with all the course's details similar to a ``GET`` request.
"""
course_key = _resolve_course_key(course_id)
course_key = resolve_course_key(course_id)
is_schedule_update, is_details_update = _classify_update(request.data, course_key)

if not is_schedule_update and not is_details_update:
Expand Down
Loading
Loading