Skip to content

feat: serve container children from authoring v2 - #39317

Draft
Faraz32123 wants to merge 1 commit into
masterfrom
feat/standardize-container-children-api
Draft

Faraz32123 wants to merge 1 commit into
masterfrom
feat/standardize-container-children-api

Conversation

@Faraz32123

Copy link
Copy Markdown
Contributor

Summary

Standardizes the container children read from the [API] Containers tracking issue (#39063, part of the FC-0118 umbrella #38137): GET /api/contentstore/v1/container/{usage_key_string}/children is superseded by GET /api/authoring/v2/containers/{usage_key}/children/. This is the second half of the container pair: the container itself is #39274 (/api/authoring/v2/containers/{usage_key}/), and its PR body assigns this address to this PR.

Stacked. This branch sits on #39274 (which sits on #39229) and uses their CanViewCourse, the usage-key converter route and the view constants. The diff against origin/pr/39274 is what is described below.

Why a new version. The contract changes for callers, so it cannot be done in place:

  • v1 answers any signed-in caller. It has IsAuthenticated and nothing else: no course-access check anywhere on its path (course_index.py, get_xblock, ContainerHandlerMixin). v2 requires permission to view the course, the check container_handler and the course outline already make. This is a deliberate status change for callers without a role on the course (200 becomes 403).
  • Authentication follows the platform default: Bearer tokens and session-only inactive users are refused (v1 takes both).
  • Errors use the standard envelope. v1's missing-block answer is an unhandled error (HTML 500, measured), and its invalid get_upstream_info answer is a bare list.
  • The list is paginated (page, page_size, default 10, maximum 100) and shaped by view: minimal (default) is five fields per child; view=full is v1's child object with v1's field names.
  • A leaf block and the course root were 200 in v1 ({children: []} and the sections); they are 404 in v2 (the outline serves the course root).
  • render_error carries one fixed message instead of the exception text raised by the block's own render code; the text is logged with the child's key.
  • No block is created by a GET: v1 saved a course_info block when asked for the children of one that did not exist.

What leaves this response: is_published, can_paste_component, display_name and upstream_ready_to_sync_children_info (with the get_upstream_info parameter that fed the last). display_name is on the container detail; can_paste_component is always true for what v2 serves; is_published and the container's own upstream readiness have no v2 home yet (see Follow-ups).

Change table

Commit Endpoint Legacy behaviour kept New behaviour
feat: serve container children from authoring v2 GET /api/authoring/v2/containers/{usage_key}/children/ (name container_child_list) v1 address, URL names, bodies and statuses untouched; container/vertical/.../children still 301s to it Paginated, view=minimal|full, (IsAuthenticated, CanViewCourse), default authentication, standard error envelope
(same) cms/lib/spectacular.py v1 operation marked deprecated: true by one exact SUPERSEDED_PATHS entry
(same) test_containers.py, test_spectacular.py the assertions in #39274's tests that pinned the v1 children read as not deprecated are flipped here, because this PR is what deprecates it

ADRs applied

  • 0025 serializers — CourseContainerChild* serializers with help_text on every field, allow_null where real data is null, actions typed as an object, a Meta.ref_name on each
  • 0026 permissions — (IsAuthenticated, CanViewCourse); the one legacy check (IsAuthenticated) survives and one is added
  • 0027 schema / docs — @extend_schema with summary, description, examples and the error responses; SDK tag; the seven-key page envelope is published
  • 0028 viewsets — plain ViewSet with list, mounted with path() and the shared usage-key converter instead of a router (router routes are regexes and cannot use the converter)
  • 0029 errors — partial: every error the view and CanViewCourse produce is the envelope; a malformed or i4x:// key fails at URL resolution and gets the service's HTML 404, and 405/406 are typed internal because edx-drf-extensions 10.9.0 omits them (both pinned by tests that flip when fixed)
  • 0030 GET idempotence — no domain write, event or signal on GET or HEAD; course_info is no longer created
  • 0031 merged endpoints — n/a: one read operation; the only sibling is the frozen 301 alias, and container_handler is a different resource
  • 0032 pagination — flat paginated child sub-resource, the seven-key envelope documented by a minimal subclass
  • 0033 filtering — n/a: no filter and no sort; the order is the order the container stores; view selects a representation
  • 0034 authentication — platform default, no authentication_classes
  • 0035 MFE config — n/a: course content, not site or front-end configuration
  • 0036 nested JSON — flat paginated children sub-resource of a tree node; thin default, view=full opt-in
  • 0037 versioning — new v2 modules; v1 not edited; one recorded deviation: no deprecation notice in the superseded code
  • 0038 URL structure — /api/authoring/v2/containers/{usage_key}/children/, trailing slash, shared converter
  • OEP-66 queryset scoping — the mechanism is excluded: no ORM queryset to wrap; the layering is below
  • OEP-69 conventions — domain name authoring, plural collection, nesting of one level, per-resource version

Compliance matrix

Decision Status How it is met, or the concrete reason it is excluded Evidence
0025 met serializers in v2/serializers/container_children.py; nullable fields checked against real data (a broken library link, an unversioned link, a block with no display type) ContainerChildrenFullViewTest::test_a_link_to_a_library_that_cannot_be_followed_reports_why_and_nothing_else, ContainerChildrenSchemaTest::test_the_two_item_shapes, ::test_every_field_of_the_read_is_described
0026 met see Authorization ContainerChildrenAccessLegacyPermissionsTest, ContainerChildrenAccessAuthzTest
0027 met operation published at its full address, tagged, 200 and every 4xx documented, the page's seven keys around the two item shapes ContainerChildrenSchemaTest (8 tests); schema_diff.py compare: 0 BREAKING
0028 met, one departure plain ViewSet (the data is the modulestore, not rows); path() rather than a router for the converter ContainerChildrenRoutingTest::test_the_address
0029 partial see Error-format decision assert_error_envelope on every 400, 401, 403, 404 in the access, membership and page tests; ContainerChildrenRoutingTest::test_unparsable_or_deprecated_key_is_404 pins the gap
0030 met the service never calls get_xblock; reads only. Rendering a child for view=full can store state for the previewing user in the Django session, as v1 does; that row is the only write besides the permission library's first-lookup policy row, and the default view never touches the session ContainerChildren*SideEffects*Test (GET and HEAD, both views, AuthZ off and on); ContainerChildrenMembershipTest::test_a_course_info_block_that_does_not_exist_is_not_created
0031 excluded one read operation n/a
0032 met IterablePaginationMixin.paginate_iterable; CourseContainerChildPagination overrides only get_paginated_response_schema, which the library publishes with four of the seven keys ContainerChildrenDefaultViewTest (page size, maximum, links, out of range); ContainerChildrenSchemaTest::test_the_page_has_all_seven_keys_around_the_two_item_shapes
0033 excluded no filter or sort n/a
0034 met no authentication_classes line test_bearer_token_is_refused, test_an_inactive_session_is_not_authenticated, test_an_inactive_jwt_caller_*
0035 excluded not configuration n/a
0036 met view=minimal (default) / view=full, one level, no recursion ContainerChildrenDefaultViewTest, ContainerChildrenParityTest
0037 met new files only; the in-place edits are listed below versions gate
0038 met reverse("authoring_v2:container_child_list") is /api/authoring/v2/containers/<key>/children/; shared converter; routed_actions == {"get": "list"} ContainerChildrenRoutingTest; url_snapshot.py check
OEP-66 layering met 1 endpoint access: (IsAuthenticated, CanViewCourse). 2 record visibility: anyone who may view the course sees every child, including staff-only and group-restricted ones, exactly as v1 returned them. 3 user filtering: none; view, page and page_size shape the output and cannot widen visibility access and parity tests

Backward compatibility

  • Nothing changes for any v1 caller. v1/ is not edited; every v1 address, URL name, body and status stays, including the 301 alias container/vertical/{key}/children.

  • Versions gate (check_old_versions_untouched.sh --base origin/pr/39274): PASS. Two allowed edits (listed below), four added files.

  • URL gate (url_snapshot.py check): 2166 routes at head, 2165 at base, no address and no URL name lost, one new API route checked: PASS.

  • Schema diff: 0 BREAKING. Added: operation GET /api/authoring/v2/containers/{usage_key}/children/, operationId v2_containers_children_list, 8 components (CourseContainerChild, CourseContainerChildActions, CourseContainerChildFull, CourseContainerChildMessage, CourseContainerChildRead, CourseContainerChildUpstreamChild, CourseContainerChildUpstreamLink, PaginatedCourseContainerChildReadList). Zero operationIds and zero components.schemas names removed. The v1 operation is newly marked deprecated: true. The two WARN lines are drf-spectacular failing to resolve the platform's default authenticator classes, which every default-authentication view in this schema triggers.

  • Parity (ContainerChildrenParityTest, ContainerChildrenLibraryLinkedParityTest): every child of a section, subsection, unit, split test and group, with a library link to a block and to a container, a broken link, restricted groups, a validation error, tagging on and off, and a block that fails to render, is compared leaf by leaf with the v1 body. INTENTIONAL_DIFFERENCES, verbatim from the test:

    • is_published|can_paste_component|display_name|upstream_ready_to_sync_children_info(\[\d+\].*)? — v1 also describes the container itself; the page lists only its children
    • count|next|previous|num_pages|current_page|start — the page envelope
    • results\[\d+\]\.render_error — the cause of a render failure is logged and a fixed message returned

    The comparison fails on an undeclared difference and on a declared one that never occurs. It was mutation-checked: changing can_copy, returning the exception text, and always returning the fixed message each turn it red.

  • Legacy tests (v1/views/tests/test_vertical_block.py, including ContainerVerticalViewTest) are not edited and pass.

  • v1's measured behaviour (Django test client, same course): a missing block answers an HTML 500; a signed-in user with no role on the course gets the full children payload (200).

  • Query counts, a unit with N html children read by course staff (SQL statements / Mongo reads, second identical request):

    children v1 v2 default v2 view=full (page of 10)
    1 18 / 10 11 / 2 21 / 6
    5 22 / 18 11 / 2 25 / 14
    20 37 / 48 11 / 2 30 / 24

    With units in a subsection v1 is 19 / 10, 23 / 10, 38 / 10; v2 default 11 / 2; v2 full 22 / 6, 26 / 6, 31 / 6. view=full costs what the page holds, not what the container holds (page_size=5 over 20 children costs what 5 children cost), and its extra statements beyond v1 are the permission check's own (test_the_full_view_costs_no_more_than_v1_beyond_the_permission_check). Both with policy-based authoring permissions off and on.

In-place edits

File Category Evidence
cms/djangoapps/contentstore/rest_api/v2/authoring_urls.py strictly opt-in addition: one import and one path() url_snapshot.py check: no address and no URL name lost; the existing v2 route tests pass
cms/djangoapps/contentstore/rest_api/v2/views/tests/test_containers.py tests: drops the two assertions that pinned the v1 children read as not deprecated (V1_CHILDREN, test_the_v1_read_is_deprecated_and_the_children_read_is_not becomes test_the_v1_read_is_deprecated); nothing else changes the file's other tests are unchanged

Outside any version directory: cms/lib/spectacular.py gains one exact entry in SUPERSEDED_PATHS (no new constant, no redefined hook); cms/lib/tests/test_spectacular.py gains cases that include other resources' paths and lookalike paths and flips the matching #39274 pin. The superseded view module (v1/views/course_index.py) and v1/urls.py are not in the diff.

Error-format decision

Versioned. v1's error bodies on this route are not parseable shapes any client can rely on (an HTML 500 for a missing block, a bare list for an invalid parameter), and the permission and pagination changes already require a new version. The only readers found read children from a success body (the Authoring MFE's unit page and the end-to-end suite); neither reads an error body. Two known gaps, pinned by tests that flip when fixed: keys that fail to parse or are i4x:// get the service's HTML 404 at URL resolution (closing it needs a catch-all under containers/, which #39274 declined for its route; best done for both together), and 405 and 406 are typed internal by edx-drf-extensions 10.9.0.

Deprecations

GET /api/contentstore/v1/container/{usage_key_string}/children is superseded by GET /api/authoring/v2/containers/{usage_key}/children/?view=full and marked deprecated: true in the published schema, by one exact SUPERSEDED_PATHS entry. The superseded view is unchanged: no response header, no deprecation notes in code. The 301 alias container/vertical/{usage_key_string}/children is not a DRF view, has no schema operation, and is named on the DEPR entry.

Tracked on the FC-0118 umbrella DEPR ticket, #39128, which removes them one named release after the release this ships in. This area's entry is posted there once the PR is open.

Gap named, per the deprecation decision for this area: four things leave the response and two of them have no v2 home yet: is_published and the container's own upstream_ready_to_sync_children_info. The Authoring MFE's compare-containers widget reads the second. #39274 is asked to carry both on the container detail before it merges; until then the window could end before one exists, and the DEPR entry says so.

Migration notes for callers: call the v2 address with the trailing slash and ?view=full&page_size=100 for the Unit page; read results and follow next; errors use the envelope; Bearer is refused; a leaf or course-root key is 404; the caller must be able to view the course; render_error is a fixed message.

Authorization

Legacy check v1 v2
Signed in view_auth_classes(is_authenticated=True): IsAuthenticated IsAuthenticated
Course access none CanViewCourse: 404 for a missing course, a library key or a version-only key before the check; then courses.view_course / READ, with AuthZ roles when enabled

Deny-path tests, each with GET and HEAD, on both views, with policy-based authoring permissions off (ContainerChildrenAccessLegacyPermissionsTest) and on (ContainerChildrenAccessAuthzTest): anonymous (401, WWW-Authenticate), signed in with no role (403), enrolled learner (403), limited staff (403), a role on another course (403, and 200 on its own course), unknown course (404 before 403; anonymous 401 before 404), custom course key (403 for everyone), org and course staff / instructor and global staff (200), AuthZ auditor / editor / staff / admin (200), other AuthZ roles (403), legacy staff without a policy role under AuthZ (403), inactive session (401), inactive JWT (200 for a user with a role), Bearer (401), POST/PUT/PATCH/DELETE (405 after authentication). test_v1_lists_the_children_for_a_user_without_a_role records the status change: v1 200, v2 403.

Gate report

Run against origin/pr/39274 (stack base, SHA 3291339058). Gates run individually due to worktree environment (no uv / .venv; Tutor dev).

Gate Result Notes
versions (check_old_versions_untouched.sh) PASS 2 allowed, 4 added, 0 v0/v1/v3/v4 files changed, 0 protected files changed
hygiene (check_code_hygiene.py) PASS (1 WARN) WARN H3: CourseContainerChildPagination subclass kept for the seven-key schema; documented
schema (schema_diff.py compare) PASS 0 BREAKING, 2 WARN, 4 INFO; 1 operation added, 8 components added; v1 operation newly deprecated: true; WARNs are the platform's default-authenticator resolution
url (url_snapshot.py check) PASS head 2166, base 2165, 1 new API route, all v1 addresses resolve
old-tests (pytest v1/…/test_vertical_block.py) PASS all classes green, no v1 test edited
tests (pytest v2/…/test_container_children.py) PASS (144 cases) 86 methods, 144 parametrized; test_spectacular.py 25 cases PASS; full affected set 606 passed, 3 failed (pre-existing in #39274's test_containers.py, fail identically at base)
linting (ruff, pylint) PASS all checks passed

Re-run against origin/master after rebase (plan §12.4).

Follow-ups

  • Carry is_published and the container's own upstream readiness (ready_to_sync_children) on the container detail (feat: serve course containers from authoring v2 #39274), so the compare-containers widget has a v2 source; recorded in the DEPR entry.
  • A catch-all under containers/ so keys that fail to parse answer with the envelope instead of the HTML 404 (both container routes together).
  • The untyped user_partition_info and user_partitions objects are candidates for a typed shape in a next version; v1's declared shapes are kept as they are.
  • The UpstreamLink fields upstream_name, downstream_key, upstream_link and is_ready_to_sync_individually stay dropped, as in v1.
  • CourseContainerChildPagination is deleted for DefaultPagination once edx-drf-extensions documents all seven keys.
  • docs/cms-openapi.yaml is regenerated by the schema workflow, not edited here; the SDK gains a method for the new operation.
  • The end-to-end suite's containerChildrenPath calls the 301 alias; it moves to the v2 address in a separate change.

🤖 Generated with Claude Code

@Faraz32123 Faraz32123 linked an issue Oct 27, 2026 that may be closed by this pull request
@Faraz32123
Faraz32123 force-pushed the feat/standardize-container-children-api branch from 9216513 to 6ad329a Compare October 27, 2026 12:57
@Faraz32123

Copy link
Copy Markdown
Contributor Author

Note: This PR is rebased onto master so the diff is clean, but the code depends on:

Once those PRs merge, this branch will be rebased to use their shared definitions and the local copies removed. CI may flag missing imports until then.

@Faraz32123
Faraz32123 force-pushed the feat/standardize-container-children-api branch 7 times, most recently from 8f083e9 to bed8ede Compare October 28, 2026 06:31
Adds GET /api/authoring/v2/containers/{usage_key}/children/,
the ADR-conforming replacement for the v1 container children
endpoint.

Part of: #39063 — [API] Containers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@Faraz32123
Faraz32123 force-pushed the feat/standardize-container-children-api branch from bed8ede to 72eb701 Compare October 28, 2026 09:01

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[API] Containers

1 participant