Skip to content
Open
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
28 changes: 28 additions & 0 deletions cms/djangoapps/contentstore/rest_api/v1/authoring_urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""Authoring API v1 URLs (ADR 0038 conforming mount for Contentstore v1)."""

from django.urls import path

from cms.djangoapps.contentstore.rest_api.v1.views import XblockViewSet

app_name = "authoring_v1"

urlpatterns = [
# The viewset has no ``list`` action, so the collection accepts POST only.
path(
"xblocks/",
XblockViewSet.as_view({"post": "create"}),
name="xblock_list",
),
path(
"xblocks/<usage_key:usage_key_string>/",
XblockViewSet.as_view(
{
"get": "retrieve",
"put": "update",
"patch": "partial_update",
"delete": "destroy",
}
),
name="xblock_detail",
),
]
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from unittest.mock import patch

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

Expand Down Expand Up @@ -216,3 +216,66 @@ def test_minimal_view_is_noop_for_non_json_payload(self, mock_retrieve):
response = self.client.get(_detail_url(), {"view": "minimal", "fields": "graderType"})
assert response.status_code == status.HTTP_200_OK
assert response.json() == "notgraded"


# ---------------------------------------------------------------------------
# ADR 0038 — URL-structure tests
# ---------------------------------------------------------------------------


def _authoring_list_url():
return reverse("authoring_v1:xblock_list")


def _authoring_detail_url():
return reverse(
"authoring_v1:xblock_detail",
kwargs={"usage_key_string": TEST_LOCATOR},
)


class XblockViewSetUrlStructureTest(ModuleStoreTestCase, APITestCase):
"""The conforming /api/authoring/v1/xblocks/ routes serve the same view as the legacy ones."""

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

def test_conforming_urls_reverse_to_expected_paths(self):
assert _authoring_list_url() == "/api/authoring/v1/xblocks/"
assert _authoring_detail_url() == f"/api/authoring/v1/xblocks/{TEST_LOCATOR}/"

def test_conforming_and_legacy_routes_share_view(self):
legacy_cls = resolve(_detail_url()).func.cls
conforming_cls = resolve(_authoring_detail_url()).func.cls
assert conforming_cls is legacy_cls

def test_invalid_usage_key_is_404_on_conforming_route(self):
# The shared usage_key converter rejects unparseable keys with a
# routing-level 404.
response = self.client.get("/api/authoring/v1/xblocks/not-a-usage-key/")
assert response.status_code == status.HTTP_404_NOT_FOUND

@patch(f"{_VIEW_MODULE}.retrieve_xblock_response", return_value=_MOCK_RESPONSE)
def test_get_on_conforming_route_calls_retrieve(self, mock_fn):
# Also exercises the UsageKey→str coercion in XblockViewSet.initial().
response = self.client.get(_authoring_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}.create_xblock_response", return_value=_MOCK_RESPONSE)
def test_post_on_conforming_route_calls_create(self, mock_fn):
data = {"parent_locator": PARENT_LOCATOR, "category": "html"}
response = self.client.post(_authoring_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}.delete_xblock_response", return_value=_MOCK_RESPONSE)
def test_delete_on_conforming_route_calls_destroy(self, mock_fn):
response = self.client.delete(_authoring_detail_url())
assert response.status_code == status.HTTP_200_OK
mock_fn.assert_called_once()
assert mock_fn.call_args[0][0].method == "DELETE"
7 changes: 6 additions & 1 deletion cms/djangoapps/contentstore/rest_api/v1/views/xblock.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,12 @@ def initial(self, request, *args, **kwargs):
bytes) rather than request.data to avoid consuming the WSGI stream
before @expect_json_in_class_view runs.
"""
usage_key_string = kwargs.get("usage_key_string")
# The conforming route passes a parsed UsageKey; the legacy route
# passes the raw string. Coerce to the string the actions expect —
# ``self.kwargs`` is the dict ``dispatch()`` unpacks into the handler.
if isinstance(self.kwargs.get("usage_key_string"), UsageKey):
self.kwargs["usage_key_string"] = str(self.kwargs["usage_key_string"])
usage_key_string = self.kwargs.get("usage_key_string")
if usage_key_string:
try:
self.course_key = UsageKey.from_string(usage_key_string).course_key
Expand Down
35 changes: 35 additions & 0 deletions cms/djangoapps/contentstore/rest_api/v3/authoring_urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Authoring API v3 URLs (ADR 0038 conforming mount for Contentstore v3)."""

from django.urls import path

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

app_name = "authoring_v3"

urlpatterns = [
path(
"home/",
HomeViewSet.as_view({"get": "list"}),
name="home",
),
path(
"home/courses/",
HomeViewSet.as_view({"get": "courses"}),
name="home_courses",
),
path(
"home/libraries/",
HomeViewSet.as_view({"get": "libraries"}),
name="home_libraries",
),
path(
"courses/<course_key:course_id>/details/",
CourseDetailsViewSet.as_view({"get": "retrieve", "put": "update"}),
name="course_details",
),
path(
"courses/<course_key:course_key>/grading/",
AuthoringGradingViewSet.as_view({"patch": "partial_update"}),
name="course_grading",
),
]
29 changes: 28 additions & 1 deletion cms/djangoapps/contentstore/rest_api/v3/tests/test_home.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
``EXCEPTION_HANDLER`` setting is unchanged, so v0/v1/v2 endpoints continue
to return the legacy error shape.
"""
from django.urls import reverse
from django.urls import resolve, reverse
from rest_framework import status
from rest_framework.test import APIClient, APITestCase

Expand Down Expand Up @@ -84,3 +84,30 @@ def test_v1_endpoint_unaffected_by_v3_envelope(self):
# v1 still uses the project-default handler → ADR 0029 fields absent.
assert "type" not in response.data
assert "instance" not in response.data


class TestHomeViewSetUrlStructure(APITestCase):
"""The conforming /api/authoring/v3/home/ routes serve the same view as the legacy ones."""

def test_conforming_urls_reverse_to_expected_paths(self):
assert reverse("authoring_v3:home") == "/api/authoring/v3/home/"
assert reverse("authoring_v3:home_courses") == "/api/authoring/v3/home/courses/"
assert reverse("authoring_v3:home_libraries") == "/api/authoring/v3/home/libraries/"

def test_conforming_and_legacy_routes_share_view(self):
pairs = (
("cms.djangoapps.contentstore:v3:home-list", "authoring_v3:home"),
("cms.djangoapps.contentstore:v3:home-courses", "authoring_v3:home_courses"),
("cms.djangoapps.contentstore:v3:home-libraries", "authoring_v3:home_libraries"),
)
for legacy_name, conforming_name in pairs:
legacy_cls = resolve(reverse(legacy_name)).func.cls
conforming_cls = resolve(reverse(conforming_name)).func.cls
assert conforming_cls is legacy_cls, f"{conforming_name} must serve the same view as {legacy_name}"

def test_unauthenticated_conforming_route_returns_standardized_401(self):
"""The conforming mount carries the same contract — ADR 0029 envelope included."""
response = APIClient().get(reverse("authoring_v3:home"))
assert response.status_code == status.HTTP_401_UNAUTHORIZED
for field in _REQUIRED_ERROR_FIELDS:
assert field in response.data, f"ADR 0029: missing field '{field}'"
13 changes: 9 additions & 4 deletions cms/djangoapps/contentstore/rest_api/v3/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,15 @@
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview


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

Accepts either the raw string (the legacy ``/api/contentstore/v3/``
routes) or an already-parsed :class:`CourseKey` (the conforming
``/api/authoring/v3/`` routes, whose ``course_key`` path converter —
ADR 0038 rule 9 — hands views a parsed key).

Raises:
rest_framework.exceptions.NotFound: if the string is unparseable
Expand All @@ -44,7 +49,7 @@ def resolve_course_key(course_key: str) -> CourseKey:
positional argument.
"""
try:
parsed = CourseKey.from_string(course_key)
parsed = course_key if isinstance(course_key, CourseKey) else 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):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from unittest.mock import patch

from django.test import TestCase
from django.urls import reverse
from django.urls import resolve, reverse
from rest_framework import status
from rest_framework.test import APIClient, APITestCase

Expand Down Expand Up @@ -291,3 +291,58 @@ def test_v0_endpoint_unaffected_by_v3_envelope(self):
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert "type" not in response.data
assert "instance" not in response.data


class TestAuthoringGradingViewSetUrlStructure(APITestCase):
"""The conforming courses/{course_key}/grading/ route serves the same view as the legacy one."""

def setUp(self):
super().setUp()
self.client = APIClient()
self.conforming_url = reverse(
"authoring_v3:course_grading",
kwargs={"course_key": COURSE_ID},
)
self.legacy_url = reverse(
"cms.djangoapps.contentstore:v3:authoring_grading-detail",
kwargs={"course_key": COURSE_ID},
)

def test_conforming_url_reverses_to_expected_path(self):
assert self.conforming_url == f"/api/authoring/v3/courses/{COURSE_ID}/grading/"

def test_conforming_and_legacy_routes_share_view(self):
legacy_cls = resolve(self.legacy_url).func.cls
conforming_cls = resolve(self.conforming_url).func.cls
assert conforming_cls is legacy_cls

def test_invalid_course_key_is_404_on_conforming_route(self):
# The shared course_key converter rejects unparseable keys with a
# routing-level 404.
response = self.client.patch(
"/api/authoring/v3/courses/not-a-course-key/grading/",
data={}, format="json",
)
assert response.status_code == status.HTTP_404_NOT_FOUND

def test_unauthenticated_patch_returns_401(self):
response = self.client.patch(self.conforming_url, data={}, format="json")
assert response.status_code == status.HTTP_401_UNAUTHORIZED

@patch(MOCK_CREDIT_TASK)
@patch(MOCK_UPDATE_FROM_JSON, return_value=_MOCK_GRADING_MODEL)
@patch(MOCK_HAS_PERMISSION, return_value=True)
@patch(MOCK_COURSE_EXISTS, return_value=True)
def test_patch_on_conforming_route_updates_grading(
self, mock_exists, mock_perm, mock_update, mock_credit, # noqa: ARG002
):
"""Same contract on the conforming mount as on the legacy one."""
user = UserFactory.create()
self.client.force_authenticate(user=user)
response = self.client.patch(
self.conforming_url,
data={"graders": _GRADERS_PAYLOAD},
format="json",
)
assert response.status_code == status.HTTP_200_OK
mock_update.assert_called_once()
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"""
from unittest.mock import MagicMock, patch

from django.urls import reverse
from django.urls import resolve, reverse
from rest_framework import status
from rest_framework.test import APIClient, APITestCase

Expand Down Expand Up @@ -378,3 +378,51 @@ def test_fields_csv_restricts_top_level_keys(

assert response.status_code == status.HTTP_200_OK
assert set(response.data.keys()) == {"course_id", "title"}


# ---------------------------------------------------------------------------
# ADR 0038 — URL-structure tests
# ---------------------------------------------------------------------------
class TestCourseDetailsViewSetUrlStructure(APITestCase):
"""The conforming courses/{course_key}/details/ route serves the same view as the legacy one."""

def _conforming_url(self):
return reverse(
"authoring_v3:course_details",
kwargs={"course_id": TEST_COURSE_ID},
)

def _legacy_url(self):
return reverse(
"cms.djangoapps.contentstore:v3:course_details-detail",
kwargs={"course_id": TEST_COURSE_ID},
)

def test_conforming_url_reverses_to_expected_path(self):
assert self._conforming_url() == (
f"/api/authoring/v3/courses/{TEST_COURSE_ID}/details/"
)

def test_conforming_and_legacy_routes_share_view(self):
legacy_cls = resolve(self._legacy_url()).func.cls
conforming_cls = resolve(self._conforming_url()).func.cls
assert conforming_cls is legacy_cls

def test_invalid_course_key_is_404_on_conforming_route(self):
# The shared course_key converter rejects unparseable keys with a
# routing-level 404.
response = self.client.get("/api/authoring/v3/courses/not-a-course-key/details/")
assert response.status_code == status.HTTP_404_NOT_FOUND

def test_unauthenticated_get_returns_401(self):
response = self.client.get(self._conforming_url())
assert response.status_code == status.HTTP_401_UNAUTHORIZED

@patch(MOCK_COURSE_EXISTS, return_value=True)
@patch(MOCK_HAS_PERMISSION, return_value=False)
def test_non_author_get_returns_403(self, mock_perm, mock_exists): # noqa: ARG002
"""The conforming mount enforces the same authorization as the legacy one."""
user = UserFactory.create()
self.client.force_authenticate(user=user)
response = self.client.get(self._conforming_url())
assert response.status_code == status.HTTP_403_FORBIDDEN
15 changes: 15 additions & 0 deletions cms/djangoapps/contentstore/rest_api/v4/authoring_urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"""Authoring API v4 URLs (ADR 0038 conforming mount for Contentstore v4)."""

from django.urls import path

from cms.djangoapps.contentstore.rest_api.v4.views import home

app_name = "authoring_v4"

urlpatterns = [
path(
"courses/",
home.HomeCoursesViewSet.as_view({"get": "list"}),
name="course_list",
),
]
Loading
Loading