Skip to content
Open
52 changes: 52 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,52 @@
"""
Conforming (ADR 0038) URLs for the authoring API, v1.

Mounted at ``api/authoring/v1/`` from ``cms/urls.py``, beside the legacy
``/api/contentstore/v1/xblock/`` routes, which stay live for their OEP-21
deprecation window and are marked ``deprecated: true`` in the OpenAPI schema
(see ``cms/lib/spectacular.py``).

ADR 0038 conformance relative to the legacy mount:

* rule 2 — the collection is plural (``xblocks/``), the API name singular;
* rule 3 — the API name describes the domain (``authoring``), not the
implementing Django app (``contentstore``);
* rule 9 — the identifier is resolved by the shared ``usage_key`` path
converter (``edx_rest_framework_extensions.url_converters``), which rejects
deprecated ``i4x://`` keys with a 404;
* rule 11 — URL names are ``snake_case``, version-free, and unique.

Note: ADR 0038 (implementation note 4) asks that ``/api/authoring/v1/xblocks/``
be reconciled with the existing Learning Core ``/api/xblock/v2/xblocks/``
rather than leaving two names for what looks like one API. That reconciliation
is an API-owner decision tracked with the DEPR work, not part of this
mechanical migration.
"""

from django.urls import path

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

app_name = "authoring_v1"

urlpatterns = [
# No ``list`` action exists on the viewset, so the collection URL accepts
# POST only — the same surface the legacy router-generated route exposes.
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,70 @@ 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):
"""
ADR 0038 — the conforming /api/authoring/v1/xblocks/ routes are mounted
beside the legacy /api/contentstore/v1/xblock/ routes and serve the same
view.
"""

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"
10 changes: 9 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,15 @@ 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")
# ADR 0038: the conforming /api/authoring/v1/xblocks/<usage_key:…>/
# route passes a parsed UsageKey, while the legacy
# /api/contentstore/v1/xblock/ route passes the raw string. Coerce to
# the string form the action methods expect; ``self.kwargs`` is the
# same dict ``dispatch()`` unpacks into the handler, so the handler
# receives the coerced value as well.
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
67 changes: 67 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,67 @@
"""
Conforming (ADR 0038) URLs for the authoring API, v3.

Mounted at ``api/authoring/v3/`` from ``cms/urls.py``, beside the legacy
``/api/contentstore/v3/`` routes, which stay live for their OEP-21
deprecation window and are marked ``deprecated: true`` in the OpenAPI schema
(see ``cms/lib/spectacular.py``).

ADR 0038 conformance relative to the legacy mount:

* rule 3 — the API name describes the domain (``authoring``), not the
implementing Django app (``contentstore``);
* rule 4 / 8 — the screen-shaped ``course_details`` and ``authoring_grading``
collections become sub-resources of the plural ``courses/`` collection,
one level deep, per the ADR's own target for these endpoints
(``/api/authoring/…/courses/{course_key}/details/`` "and siblings");
* rule 9 — course keys are resolved by the shared ``course_key`` path
converter (``edx_rest_framework_extensions.url_converters``), which rejects
deprecated ``Org/Course/Run`` keys with a 404;
* rule 11 — URL names are ``snake_case``, version-free, and unique.

``home/`` is a BFF aggregate for the Studio home screen. Rule 4 disfavors
screen names, but ADR 0038's BFF provision applies: the surface keeps its
``/api/`` prefix and a single canonical conforming mount, and is marked
``x-internal`` in the OpenAPI schema (``cms/lib/spectacular.py``) so clients
can tell it apart from a stable resource contract.
"""

from django.urls import path

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

app_name = "authoring_v3"

urlpatterns = [
# Studio home BFF (x-internal — see module docstring).
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",
),
# Course details — /api/contentstore/v3/course_details/{course_id}/
# renamed per the ADR's target shape; same view, same contract.
path(
"courses/<course_key:course_id>/details/",
CourseDetailsViewSet.as_view({"get": "retrieve", "put": "update"}),
name="course_details",
),
# Course grading — /api/contentstore/v3/authoring_grading/{course_key}/
# renamed per the ADR's target shape; same view, same contract. The
# ``authoring_`` prefix is dropped because the namespace already says it.
path(
"courses/<course_key:course_key>/grading/",
AuthoringGradingViewSet.as_view({"patch": "partial_update"}),
name="course_grading",
),
]
36 changes: 35 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,37 @@ 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


# ===========================================================================
# ADR 0038 — URL-structure tests
# ===========================================================================
class TestHomeViewSetUrlStructure(APITestCase):
"""
ADR 0038 — the conforming /api/authoring/v3/home/ routes are mounted
beside the legacy /api/contentstore/v3/home/ routes and serve the same
view.
"""

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,66 @@ 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


# ===========================================================================
# ADR 0038 — URL-structure tests
# ===========================================================================
class TestAuthoringGradingViewSetUrlStructure(APITestCase):
"""
ADR 0038 — the conforming /api/authoring/v3/courses/{course_key}/grading/
route is mounted beside the legacy
/api/contentstore/v3/authoring_grading/{course_key}/ route and serves
the same view.
"""

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()
Loading
Loading