Repository navigation
feat: serve course containers from authoring v2 - #39274
taimoor-ahmed-1 wants to merge 7 commits into
Conversation
The course index existed only at v1, with Bearer auth, a legacy error
body, a slashless address, the whole screen payload as its only shape,
and writes on GET (pre-1900 start dates re-saved, a course overview
built for the clipboard title).
v2 serves GET /api/authoring/v2/courses/{course_key}/outline/. By
default it returns a thin outline tree two levels deep, where every cut
node carries a followable children_url. ?view=full returns v1's screen
payload under v1's names, without the clipboard. Reads write nothing.
Authentication is the platform default, the course-view permission and
its legacy fallback are kept in CanViewCourse, and errors use the
standardized envelope. The v1 course index is marked deprecated in the
CMS schema.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Docstring-only markers naming the v2 successor and DEPR openedx#39128. With docstrings stripped the module matches the base. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mypy checks rest_api/v2/views, and pytz ships no type stubs, so the import failed make check-types. The standard library's UTC gives the same timezone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
build_course, count_mongo_calls, sql_writes, record_effects, FIRST_LOOKUP_WRITES and the schema conversion helper were defined in the course outline test module, and the container tests need them too. Importing one test module from another couples them, and one of the helpers was private. They move verbatim into v2/views/tests/helpers.py, the existing module for helpers shared by the v2 authoring endpoint tests, and the outline tests import them from there; _as_json_schema becomes as_json_schema. No test changes.
The v2 container resource is addressed by a usage key, not a course key. CanViewCourse now reads the course from the usage_key URL argument on a route without course_key, and makes the same courses.view_course check as before. A usage key whose context is not a course run, such as a content library block, is answered with 404 before any course lookup, because the existence lookup fails on such a key. Routes with a course_key behave exactly as before.
GET /api/contentstore/v1/container_handler/{usage_key_string} serves the
Studio Unit page. It accepts Bearer tokens and inactive users' sessions,
answers errors with {"detail"} bodies, a missing block with an empty 400
and a section, subsection or course key with a 500, always returns the
whole page payload, and reads the user's clipboard on GET, which can save
a CourseOverview row and send IMPORT_COURSE_DETAILS.
GET /api/authoring/v2/containers/{usage_key}/ is its successor. A
container is any block in the course outline with children: a section,
a subsection, a unit or a container inside a unit, as in the course
outline and content libraries APIs. The default view is a thin
representation whose reads depend on nesting depth, not course size.
view=full returns v1's Unit page payload, without user_clipboard and
action, under the same field names, for a unit or a container inside
one. Errors use the standard envelope; authentication is the platform
default; access is IsAuthenticated plus CanViewCourse, the same check v1
makes. Parity tests compare view=full with v1 field by field; the only
declared differences are the clipboard and the action echo.
…ntainer
GET /api/contentstore/v1/container_handler/{usage_key_string} now has a
conforming successor at /api/authoring/v2/containers/{usage_key}/. The
v1 operation is marked deprecated in the CMS schema through an exact
SUPERSEDED_PATHS entry, so the v1 children routes beside it stay current,
and its docstrings name the successor and the deprecation ticket. The
view itself is unchanged.
|
Thanks for the pull request, @taimoor-ahmed-1! This repository is currently maintained by Once you've gone through the following steps feel free to tag them in a comment and let them know that your changes are ready for engineering review. 🔘 Get product approvalIf you haven't already, check this list to see if your contribution needs to go through the product review process.
🔘 Provide contextTo help your reviewers and other members of the community understand the purpose and larger context of your changes, feel free to add as much of the following information to the PR description as you can:
🔘 Get a green buildIf one or more checks are failing, continue working on your changes until this is no longer the case and your build turns green. 🔘 Update the status of your PRYour PR is currently marked as a draft. After completing the steps above, update its status by clicking "Ready for Review", or removing "WIP" from the title, as appropriate. Where can I find more information?If you'd like to get more details on all aspects of the review process for open source pull requests (OSPRs), check out the following resources: When can I expect my changes to be merged?Our goal is to get community contributions seen and reviewed as efficiently as possible. However, the amount of time that it takes to review and merge a PR can vary significantly based on factors such as:
💡 As a result it may take up to several weeks or months to complete a review and merge your PR. |
Summary
Standardizes the Container Management area (#39064, part of #38137). The area has one endpoint, the Unit page read
GET /api/contentstore/v1/container_handler/{usage_key_string}. This PR adds its successor:GET /api/authoring/v2/containers/{usage_key}/(URL nameauthoring_v2:container_detail)v1 stays mounted and unchanged. It is marked
deprecated: truein the CMS schema and in its docstring.It applies ADRs 0025–0038 and OEP-69.
What a container is. A container is any block in the course outline that has children: a section, a subsection, a unit, or a container inside a unit (
split_test,library_content,conditional, a groupvertical). This is the same meaning as in the #39229 outline (?usage_key="a container in the course outline, such as a section, subsection or unit") and in/api/libraries/v2/containers/. The course itself, leaf components, missing blocks and blocks the course does not reach answer 404.Two representations:
view=minimal): a thin, bounded view of the container:id,category,display_name,display_type,course_key,is_unit,unit_id,positionandancestors. Its SQL and Mongo reads depend on how deeply the block is nested, not on how large the course is.unit_idandpositionare null for a section, a subsection, or a container placed directly in one.view=full: v1's Unit page payload, withoutuser_clipboardandaction. It uses v1's field names and shapes, including the Studio page links inprev_url/next_urlthat the authoring MFE decodes to move between units. It is available for a unit or a container inside a unit; a section or subsection withview=fullanswers 404.Why a new version and not a fix in place. Each of these changes the contract for v1's callers, so they ride v2 (ADR 0037):
CourseOverviewrow and sendIMPORT_COURSE_DETAILS.Changes
test: share the v2 authoring test helperstest_course_outline.pyinto the existingv2/views/tests/helpers.py, so the new tests do not import from another PR's test modulefeat: let CanViewCourse take the course from a usage key/api/authoring/v2/courses/{course_key}/outline/(unchanged)course_keybranch is untouchedusage_key, the course is the key's context. A library or other non-course context is 404 before any course lookupfeat: serve course containers from authoring v2GET /api/authoring/v2/containers/{usage_key}/(new)container_handlerunchangeddocs: mark the v1 container handler deprecated in favour of the v2 containerGET /api/contentstore/v1/container_handler/{usage_key_string}deprecated: truein the schema (one exactSUPERSEDED_PATHSentry). The class andgetdocstrings name the successor and #39128ADRs applied
help_texton every field and explicitCourseContainer*component names; theviewparameter validated by its own serializer(IsAuthenticated, CanViewCourse), the samecourses.view_coursecheck v1 makes inside_get_item_in_course; deny matrix with AuthZ off and on@extend_schemawith summary, description, theviewenum, both shapes as aoneOf, an example per shape, every status declared including 406; taggedopenedx-platform-sdkViewSetbacked by a service module, mounted withpath(...)and the sharedusage_keyconverter rather than a DRF Router, as every route inauthoring_urls.pyisi4x://key gets the CMS HTML 404 at routing, and 405/406 are typedinternalPolicyCacheControlrow on the first permission check against a database that lacks it, as on v1 and feat: serve the course outline from authoring v2 #39229view=fulllists are the opt-in Unit page payloadview, selects a representation and is validated with an enumauthentication_classes: the platform default (JWT and session). Bearer and session-only inactive callers are refused; inactive JWT callers are servedview=fullby explicit opt-in for the MFEdeprecated: truein the schema; the DEPR entry is a comment on [DEPR]: Contentstore and enrollment API versions superseded by FC-0118 #39128 until it is folded into the issue body/api/authoring/v2/containers/{usage_key}/: domain API name, plural snake_case collection, sharedusage_keyconverter, trailing slash, version-free URL name; the v1 namecontainer_handleris keptget_queryset(); the layering is in the matrixCompliance matrix
CourseContainerSerializer(default) andCourseContainerFullSerializer(full), with typed nested serializers for ancestors, children, component templates and support legend.help_texton every field. Nullable where the code returnsNone:display_type(both views),unit_id,position,xblock.display_type,draft_preview_link,published_preview_link,library_content_picker_url,prev_url,next_url,ancestor_xblocks[].children[].url,component_templates[].templates[].boilerplate_name.xblock_infostays a free-form object, as v1's is, because a deployment override can extend it; its keys are documented and pinned per block roleContainerSchemaTest::test_every_field_of_the_read_is_described,…::test_the_read_offers_the_default_and_the_full_shape;ContainerFullViewXBlockInfoKeysTest; hygiene H9CanViewCoursemakes the sameuser_has_course_permission(user, COURSES_VIEW_COURSE, course, READ)call v1 makes. Nobody gains accessContainerAccessLegacyPermissionsTest,ContainerAccessAuthzTest,CanViewCourseFromAUsageKeyTest; each case compared with v1's answerviewenum with defaultminimal,oneOfof both shapes, an example per shape (Container,Subsection,UnitPageData), 200/400/401/403/404/406 declared withErrorResponsefor every error. operationIdv2_containers_retrieve. Taggedopenedx-platform-sdkContainerSchemaTest::test_the_read_is_published_for_the_sdk,…::test_the_examples_are_valid_bodies_of_their_viewCourseContainerViewSet(StandardizedErrorMixin, viewsets.ViewSet), actionretrieve, overv2/container_service.py. A plain ViewSet because the data path is the modulestore. Deviation: mounted withpath("containers/<usage_key:usage_key>/", ...as_view({"get": "retrieve"})), because a router's regex routes cannot use the shared path converters; every route inauthoring_urls.pydoes the sameContainerRoutingTest::test_the_address;ContainerQueryCountTest,ContainerQueryCountAuthzTestStandardizedErrorMixinfirst in the MRO. 401authn, 403authz, 404not-found(unknown course, non-course context, missing block, non-member, full view above unit level), 400validationforviewwitherrors.view. Messages are fixed strings that never echo the key or the input. Gaps, both pinned by tests that flip when fixed: a malformed ori4x://key fails at routing and gets the CMS HTML 404; 405 and 406 are typedinternalbecause edx-drf-extensions 10.9.0 does not catalogueMethodNotAllowedandNotAcceptableContainerDefaultViewTest::test_an_unknown_view_is_refused_with_400;ContainerRoutingTest::test_unparsable_or_deprecated_key_is_404,…::test_unsupported_accept_is_refused_with_406;test_other_methods_are_refused_after_authenticationPolicyCacheControlrow, which inserts it (one INSERT, itspre_save/post_save). That write belongs to the permission library, happens on v1 and #39229 too, and is allow-listed by name. v1's clipboard write is pinned as still present on v1ContainerReadSideEffectsLegacyPermissionsTest,ContainerReadSideEffectsAuthzTest(test_reads_have_no_side_effects,test_reads_of_a_course_with_prerequisites_and_special_exams_have_no_side_effects,test_v1_still_builds_the_clipboard_overview)container/{key}/childrenis a different representation, owned by #39063; it should mount atcontainers/{usage_key}/children/under this memberview=full(ancestor_xblocks,course_sequence_ids, sibling units inxblock_info,component_templates) are the Unit page payload under ADR 0036's opt-in exception, not a list endpointviewis enum-validated; unknown parameters are ignoredContainerDefaultViewTest::test_unknown_parameters_are_ignoredauthentication_classes, soDefaultJwtAuthentication+DefaultSessionAuthentication. Frozen v1 keeps(JwtAuthentication, BearerAuthenticationAllowInactiveUser, SessionAuthenticationAllowInactiveUser). The sibling v2 views from #39157 (textbooks,video_settings) keepSessionAuthenticationAllowInactiveUser; this view follows #39229 instead. Its only consumer, the authoring MFE's Unit page, authenticates with the JWT cookie, which serves inactive users. Who is affected: an unactivated account (registration signs new accounts in before activation) or inactive global staff calling with a session cookie alone. Reviewers may prefer to align the siblings one way or the othertest_an_inactive_session_is_not_authenticated,test_an_inactive_session_with_a_role_is_not_authenticated,test_an_inactive_jwt_caller_who_is_global_staff_may_read,test_an_inactive_jwt_caller_with_a_role_may_read,test_bearer_token_is_refused,test_jwt_caller_may_read; hygiene H4view=fullonly by request. The default view's SQL and Mongo reads are constant across course shapes (walking up the tree still costs CPU in proportion to the course structure, as in split's parent lookup)ContainerDefaultViewTest,test_the_default_view_costs_the_same_on_every_course_shape,ContainerFullViewParityTestv2/. v1 frozen apart from a docstring-only marker (the syntax tree, docstrings stripped, equals the base).deprecated: truethrough one exactSUPERSEDED_PATHSentry. No runtime header (frozen route). DEPR: an entry comment on #39128, to be folded into the issue bodyCmsMarkSupersededPathsTest::test_the_container_read_is_marked_and_its_children_routes_are_not;ContainerSchemaTest::test_the_v1_read_is_deprecated_and_the_children_read_is_notauthoringAPI name, top-levelcontainerscollection (the key is its own opaque key),usage_keyconverter, trailing slash,container_detail. The slashless address redirects; the collection address without a key is not offeredContainerRoutingTest(test_the_address,test_the_v1_address_is_unchanged,test_the_url_name_is_unique_in_its_namespace,test_the_address_without_its_trailing_slash_redirects_to_it)viewcannot widen what is visible. Future seam: the modulestore lookup inv2/container_service.pyBackward compatibility
v1 is unchanged.
vertical_block.pygains docstring text only. Its syntax tree with docstrings stripped equals the base. The v1 route, view and URL namecms.djangoapps.contentstore:v1:container_handlerare untouched, and v1's error body is pinned (ContainerRoutingTest::test_v1_keeps_its_own_error_body).Legacy tests are not edited.
rest_api/v1/views/tests/test_vertical_block.pyis not in the diff.Parity.
view=fullis compared with v1 field by field, through the Django test client, for every member that has a Unit page, and for each of these cases:positionis counted as v1 counts it).The only declared differences, verbatim:
The comparison fails both on an undeclared difference and on a declared one that does not occur, and is itself mutation-tested (
test_the_comparison_fails_when_a_field_changes).Status changes from v1 are each pinned against v1's own answer in
ContainerMembershipTestand the access tests:view=fullQuery counts.
view=fullruns v1's SQL statements plus at most the course-existence lookup, and no more Mongo reads than v1. The default view's reads are equal across course shapes.Schema. One new operation and
deprecated: trueon the v1 operation.docs/cms-openapi.yamlis not edited; thegenerate_openapi_schemasworkflow regenerates it.In-place edits
cms/djangoapps/contentstore/rest_api/v1/views/vertical_block.pycms/djangoapps/contentstore/rest_api/v2/authoring_urls.pyContainerRoutingTest); outline, textbooks and video settings tests untouchedcms/djangoapps/contentstore/rest_api/v2/views/tests/helpers.pycms/djangoapps/contentstore/rest_api/v2/views/tests/test_course_outline.py(#39229)cms/djangoapps/contentstore/views/permissions.py(shared)CanViewCoursegains theusage_keybranchcourse_keybranch is unchanged;CanViewCourseFromAUsageKeyTestcms/lib/spectacular.py,cms/lib/tests/test_spectacular.pySUPERSEDED_PATHSentry and its testsNew files inside the pre-existing
v2/:container_service.py,serializers/containers.py,views/containers.py,views/tests/test_containers.py. v2 imports nothing fromrest_api/v1/, so removing v1 cannot break it.Error-format decision
Versioned. v1 keeps its
{"detail": ...}bodies, its empty 400 and its 500s, byte for byte. v2 uses the envelope. The only consumer, the authoring MFE, moves by switching address.Deprecations
GET /api/contentstore/v1/container_handler/{usage_key_string}is superseded byGET /api/authoring/v2/containers/{usage_key}/?view=full. It isdeprecated: truein the CMS schema and its docstrings name the successor. The view is otherwise unchanged: no runtime header. Tracked on the FC-0118 umbrella DEPR ticket, #39128; this area's entry is posted there with the PR. Window per #39128: removed one named release after the release this ships in.Migration notes for callers:
/api/authoring/v2/containers/{usage_key}/?view=full, with the trailing slash./api/content-staging/v1/clipboard/(the authoring MFE already does).actionis gone./api/authoring/v1/xblocks/{usage_key}/for component data. Library andi4x://keys are 404.Authorization
@view_auth_classes(is_authenticated=True)IsAuthenticatedtest_anonymous_caller_is_refused_with_401(both permission modes);test_anonymous_caller_of_an_unknown_course_is_refused_with_401@login_requiredon_get_item_in_courseIsAuthenticateduser_has_course_permission(user, COURSES_VIEW_COURSE, course_key, READ)in_get_item_in_courseCanViewCourse, same call, same keytest_user_without_a_role_is_refused_with_403,test_limited_staff_is_refused,test_staff_of_another_course_is_refused,test_a_role_on_another_course_is_refused,test_other_roles_are_refused,test_legacy_course_staff_without_a_policy_role_is_refused,test_a_custom_course_is_refused_to_everyone,test_a_custom_course_is_refused_to_staff_and_course_staffhas_studio_read_accesson the clipboard's source course/api/content-staging/v1/clipboard/test_a_key_outside_any_course_is_not_found_without_a_course_lookup,test_a_key_naming_only_a_course_version_is_not_found_without_a_course_lookup,test_keys_outside_any_course_are_not_foundOrder: 401, then 404 for an unknown course, then 403, then 404 for the block, so a caller without access cannot learn which blocks exist (
test_a_missing_block_is_404_for_a_reader_and_403_for_anyone_else). Allowed callers: course staff and instructor, org staff and instructor, global staff and superusers, and with policy-based permissions the roles that grantcourses.view_course.Gate report
Run on the host against #39229's head (
53ea5f44a1), which this branch stacks on. No platform environment was available, so four gates were skipped. The test module has not been run locally, and CI is its first run.v1/views/vertical_block.py,v2/authoring_urls.py,v2/views/tests/helpers.py,v2/views/tests/test_course_outline.py), 4 ADDED, nothing protected touched (17 shared legacy modules protected)Follow-ups
openedx-platform-sdk, so the next openedx-platform-sdk regeneration gainsv2_containers_retrieve.src/course-unit/data/api.tscan movegetVerticalDatato/api/authoring/v2/containers/{usage_key}/?view=full.containers/{usage_key}/children/under this member./api/content-staging/v1/clipboard/can save aCourseOverviewrow and sendIMPORT_COURSE_DETAILSon read, as v1container_handlerdid. Issue to be filed.PolicyCacheControl. The first permission check on a fresh database inserts the cache row on a read. Seeding it by migration would make reads write-free. Issue to be filed./api/addresses. A platform-level fix (CMShandler404answering with the envelope for/api/paths) instead of per-area catch-alls. Issue to be filed.MethodNotAllowedandNotAcceptableso 405/406 stop being typedinternal. Issue to be filed./api/authoring/v2/:textbooksandvideo_settingskeepSessionAuthenticationAllowInactiveUser; the outline and this view use the default. One policy for new versions of such endpoints should be agreed.