Skip to content
6 changes: 5 additions & 1 deletion cms/djangoapps/contentstore/rest_api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,15 @@
from .v0 import urls as v0_urls
from .v1 import urls as v1_urls
from .v2 import urls as v2_urls
from .v3 import urls as v3_urls
from .v4 import urls as v4_urls

app_name = 'cms.djangoapps.contentstore'

urlpatterns = [
path('v0/', include(v0_urls)),
path('v1/', include(v1_urls)),
path('v2/', include(v2_urls))
path('v2/', include(v2_urls)),
path('v3/', include(v3_urls)),
path('v4/', include(v4_urls)),
]
23 changes: 22 additions & 1 deletion cms/djangoapps/contentstore/rest_api/v0/views/xblock.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
"""
Public rest API endpoints for the CMS API.
Public rest API endpoints for the CMS API — v0 xblock (DEPRECATED).

.. deprecated::
These views are superseded by ``XblockViewSet`` in
``cms.djangoapps.contentstore.rest_api.v1.views.xblock``.
Use ``/api/contentstore/v1/xblock/`` going forward.
These v0 endpoints will be removed in a future release.
"""
import logging
import warnings

from django.views.decorators.csrf import csrf_exempt
from rest_framework.generics import CreateAPIView, RetrieveUpdateDestroyAPIView
Expand All @@ -17,10 +24,17 @@
log = logging.getLogger(__name__)
handle_xblock = view_handlers.handle_xblock

_DEPRECATION_MSG = (
"The v0 xblock API (/api/contentstore/v0/xblock/) is deprecated. "
"Use /api/contentstore/v1/xblock/ instead."
)


@view_auth_classes()
class XblockView(DeveloperErrorViewMixin, RetrieveUpdateDestroyAPIView):
"""
**DEPRECATED** — use ``/api/contentstore/v1/xblock/{usage_key_string}/`` instead.

Public rest API endpoints for the CMS API.
course_key: required argument, needed to authorize course authors.
usage_key_string (optional):
Expand All @@ -32,29 +46,35 @@ class XblockView(DeveloperErrorViewMixin, RetrieveUpdateDestroyAPIView):
@course_author_access_required
@expect_json_in_class_view
def retrieve(self, request, course_key, usage_key_string=None):
warnings.warn(_DEPRECATION_MSG, DeprecationWarning, stacklevel=2)
return handle_xblock(request, usage_key_string)

@course_author_access_required
@expect_json_in_class_view
@validate_request_with_serializer
def update(self, request, course_key, usage_key_string=None):
warnings.warn(_DEPRECATION_MSG, DeprecationWarning, stacklevel=2)
return handle_xblock(request, usage_key_string)

@course_author_access_required
@expect_json_in_class_view
@validate_request_with_serializer
def partial_update(self, request, course_key, usage_key_string=None):
warnings.warn(_DEPRECATION_MSG, DeprecationWarning, stacklevel=2)
return handle_xblock(request, usage_key_string)

@course_author_access_required
@expect_json_in_class_view
def destroy(self, request, course_key, usage_key_string=None):
warnings.warn(_DEPRECATION_MSG, DeprecationWarning, stacklevel=2)
return handle_xblock(request, usage_key_string)


@view_auth_classes()
class XblockCreateView(DeveloperErrorViewMixin, CreateAPIView):
"""
**DEPRECATED** — use ``POST /api/contentstore/v1/xblock/`` instead.

Public rest API endpoints for the CMS API.
course_key: required argument, needed to authorize course authors.
usage_key_string (optional):
Expand All @@ -68,4 +88,5 @@ class XblockCreateView(DeveloperErrorViewMixin, CreateAPIView):
@expect_json_in_class_view
@validate_request_with_serializer
def create(self, request, course_key, usage_key_string=None):
warnings.warn(_DEPRECATION_MSG, DeprecationWarning, stacklevel=2)
return handle_xblock(request, usage_key_string)
7 changes: 6 additions & 1 deletion cms/djangoapps/contentstore/rest_api/v1/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from django.conf import settings
from django.urls import path, re_path
from rest_framework.routers import DefaultRouter

from openedx.core.constants import COURSE_ID_PATTERN

Expand All @@ -27,14 +28,18 @@
ProctoringErrorsView,
VideoDownloadView,
VideoUsageView,
XblockViewSet,
vertical_container_children_redirect_view,
)

app_name = 'v1'

VIDEO_ID_PATTERN = r'(?P<edx_video_id>[-\w]+)'

urlpatterns = [
_router = DefaultRouter()
_router.register(r'xblock', XblockViewSet, basename='xblock')

urlpatterns = _router.urls + [
path(
'home',
HomePageView.as_view(),
Expand Down
1 change: 1 addition & 0 deletions cms/djangoapps/contentstore/rest_api/v1/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@
from .textbooks import CourseTextbooksView # noqa: F401
from .vertical_block import ContainerHandlerView, vertical_container_children_redirect_view # noqa: F401
from .videos import CourseVideosView, VideoDownloadView, VideoUsageView # noqa: F401
from .xblock import XblockViewSet # noqa: F401
27 changes: 27 additions & 0 deletions cms/djangoapps/contentstore/rest_api/v1/views/permissions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""
Permission classes for v1 contentstore API views (ADR 0026).
"""
import logging

from rest_framework.permissions import BasePermission

from common.djangoapps.student.auth import has_course_author_access

log = logging.getLogger(__name__)


class HasCourseAuthorAccess(BasePermission):
"""
ADR 0026: replaces the @course_author_access_required decorator.

Reads ``view.kwargs["course_key"]`` (a CourseKey instance) that is
injected by XblockViewSet.initial() before DRF runs permission checks.
Returns 403 if the authenticated user lacks authoring rights on that
course, or if no course key could be derived.
"""

def has_permission(self, request, view):
course_key = getattr(view, "course_key", None)
if not course_key:
return False
return has_course_author_access(request.user, course_key)
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"""
Tests for XblockViewSet (v1 — ADR 0028, 0026, 0029).

Verifies:
* Each HTTP method routes to the correct per-verb handler (ADR 0028)
* Unauthenticated requests return standardized 401 (ADR 0029)
* Authenticated non-authors return standardized 403 (ADR 0029)
* ADR 0029 error envelope fields are present and correctly typed
"""
from unittest.mock import patch

from django.http import JsonResponse
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase

from common.djangoapps.student.tests.factories import GlobalStaffFactory, UserFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase

TEST_LOCATOR = "block-v1:edX+ToyX+Toy_Course+type@problem+block@ba6327f840da49289fb27a9243913478"
PARENT_LOCATOR = "block-v1:edX+ToyX+Toy_Course+type@vertical+block@vert1"

_REQUIRED_ERROR_FIELDS = ("type", "title", "status", "detail", "instance")

_MOCK_RESPONSE = JsonResponse({"locator": TEST_LOCATOR})

_VIEW_MODULE = "cms.djangoapps.contentstore.rest_api.v1.views.xblock"


def _list_url():
return reverse("cms.djangoapps.contentstore:v1:xblock-list")


def _detail_url():
return reverse(
"cms.djangoapps.contentstore:v1:xblock-detail",
kwargs={"usage_key_string": TEST_LOCATOR},
)


# ---------------------------------------------------------------------------
# Routing tests
# ---------------------------------------------------------------------------


class XblockViewSetRoutingTest(ModuleStoreTestCase, APITestCase):
"""Verify each HTTP method routes to the correct per-verb handler (ADR 0028)."""

def setUp(self):
super().setUp()
self.staff = GlobalStaffFactory(password='password')
self.client.force_authenticate(user=self.staff)

@patch(f"{_VIEW_MODULE}.create_xblock_response", return_value=_MOCK_RESPONSE)
def test_post_calls_create_xblock_response(self, mock_fn):
data = {"parent_locator": PARENT_LOCATOR, "category": "html"}
response = self.client.post(_list_url(), data=data, format="json")
assert response.status_code == status.HTTP_200_OK
mock_fn.assert_called_once()
assert mock_fn.call_args[0][0].method == "POST"

@patch(f"{_VIEW_MODULE}.retrieve_xblock_response", return_value=_MOCK_RESPONSE)
def test_get_calls_retrieve_xblock_response(self, mock_fn):
response = self.client.get(_detail_url())
assert response.status_code == status.HTTP_200_OK
mock_fn.assert_called_once()
assert mock_fn.call_args[0][0].method == "GET"

@patch(f"{_VIEW_MODULE}.update_xblock_response", return_value=_MOCK_RESPONSE)
def test_put_calls_update_xblock_response(self, mock_fn):
data = {"id": TEST_LOCATOR, "data": "<p>Updated</p>"}
response = self.client.put(_detail_url(), data=data, format="json")
assert response.status_code == status.HTTP_200_OK
mock_fn.assert_called_once()
assert mock_fn.call_args[0][0].method == "PUT"

@patch(f"{_VIEW_MODULE}.update_xblock_response", return_value=_MOCK_RESPONSE)
def test_patch_calls_update_xblock_response(self, mock_fn):
data = {"id": TEST_LOCATOR, "display_name": "New Name"}
response = self.client.patch(_detail_url(), data=data, format="json")
assert response.status_code == status.HTTP_200_OK
mock_fn.assert_called_once()
assert mock_fn.call_args[0][0].method == "PATCH"

@patch(f"{_VIEW_MODULE}.delete_xblock_response", return_value=_MOCK_RESPONSE)
def test_delete_calls_delete_xblock_response(self, mock_fn):
response = self.client.delete(_detail_url())
assert response.status_code == status.HTTP_200_OK
mock_fn.assert_called_once()
assert mock_fn.call_args[0][0].method == "DELETE"


# ---------------------------------------------------------------------------
# ADR 0029 error-shape tests
# ---------------------------------------------------------------------------


class XblockViewSetErrorShapeTest(ModuleStoreTestCase, APITestCase):
"""Verify ADR 0029 standardized error envelope for auth failures."""

def setUp(self):
super().setUp()
self.non_author = UserFactory.create(password='password')

def test_unauthenticated_returns_401(self):
response = self.client.get(_detail_url())
assert response.status_code == status.HTTP_401_UNAUTHORIZED

def test_unauthenticated_401_has_required_fields(self):
response = self.client.get(_detail_url())
data = response.json()
for field in _REQUIRED_ERROR_FIELDS:
assert field in data, f"Missing ADR 0029 field: {field}"

def test_unauthenticated_401_type_uri(self):
response = self.client.get(_detail_url())
assert response.json()["type"] == "https://docs.openedx.org/errors/authn"

def test_non_author_returns_403(self):
self.client.force_authenticate(user=self.non_author)
response = self.client.get(_detail_url())
assert response.status_code == status.HTTP_403_FORBIDDEN

def test_non_author_403_has_required_fields(self):
self.client.force_authenticate(user=self.non_author)
response = self.client.get(_detail_url())
data = response.json()
for field in _REQUIRED_ERROR_FIELDS:
assert field in data, f"Missing ADR 0029 field: {field}"

def test_non_author_403_type_uri(self):
self.client.force_authenticate(user=self.non_author)
response = self.client.get(_detail_url())
assert response.json()["type"] == "https://docs.openedx.org/errors/authz"

def test_error_body_has_no_developer_message(self):
response = self.client.get(_detail_url())
data = response.json()
assert "developer_message" not in data
assert "error_code" not in data

def test_instance_field_is_request_path(self):
response = self.client.get(_detail_url())
assert response.json()["instance"] == _detail_url()
Loading
Loading