Skip to content

feat: serve the course outline from authoring v2 - #39229

Draft
taimoor-ahmed-1 wants to merge 3 commits into
openedx:masterfrom
edly-io:feat/standardize-course-index-api
Draft

taimoor-ahmed-1 wants to merge 3 commits into
openedx:masterfrom
edly-io:feat/standardize-course-index-api

Conversation

@taimoor-ahmed-1

@taimoor-ahmed-1 taimoor-ahmed-1 commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Summary

This PR standardizes the Course Index functional area (#39066, umbrella #38137) as a new resource, GET /api/authoring/v2/courses/{course_key}/outline/, following the API standardization ADRs docs/decisions/0025 to 0038 and OEP-69. GET /api/contentstore/v1/course_index/{course_id} stays mounted and unchanged, and is marked deprecated.

Why a new version. The course index exists only at v1, and each change the ADRs require would break v1 callers:

  • the error envelope;
  • authentication: Bearer is dropped and the platform default is used;
  • a conforming address with a required trailing slash;
  • a thin default representation;
  • no writes on GET.

So v1 stays frozen, and v2 is added under the authoring mount #39078 created. The precedent is #39157, which moved textbooks and video settings from v1 to authoring v2.

Change table

Endpoint Before After
GET /api/authoring/v2/courses/{course_key}/outline/ did not exist Default: a thin outline tree of {id, category, display_name, children[] | children_url}, two levels deep by default (depth 1 to 3). A node cut at the depth limit carries children_url, which re-roots the same resource with ?usage_key=<block>&depth=1. usage_key accepts any container in the course outline, so every children_url can be followed. ?view=full: v1's screen payload under v1's field names, minus initial_user_clipboard, which has its own endpoint. show sets the initial expanded state, as in v1.
GET /api/contentstore/v1/course_index/{course_id} current Behaviour unchanged. deprecated: true in the CMS schema, plus a docstring naming the successor.

ADRs applied

  • 0025 serializers - query, node, full and read serializers with help_text on every field (including v2-local proctoring-error serializers); nullable and required fields match real responses
  • 0026 permissions - IsAuthenticated + CanViewCourse (courses.view_course with the legacy fallback); 401, then 404, then 403; the reindex_link gate kept, with authz off and on
  • 0027 schema / docs - @extend_schema with machine-readable query parameter limits, examples of both read shapes, 4xx referencing ErrorResponse, openedx-platform-sdk tag
  • 0028 viewsets - plain ViewSet over a service module; query counts pinned, flat with course size
  • 0029 errors - envelope on every 400/401/403/404 this view raises, and no input crashes it. Open and pinned by tests that flip when fixed: (1) a malformed or slash-form course key gets the routing-level HTML 404 (no authoring catch-all yet); (2) 405 and 406 are typed internal until edx-drf-extensions catalogues them.
  • 0030 GET idempotence - v2 never re-saves blocks with pre-1900 start dates and never reads the clipboard, so no modulestore write, XBLOCK_UPDATED or CourseOverview save happens on GET. v1 still does both, pinned.
  • 0031 merged endpoints - n/a: a single read operation
  • 0032 pagination - n/a: one tree-shaped resource; depth is capped instead (ADR 0036 Pattern A)
  • 0033 filtering - n/a: no list; the query parameters choose a representation and a subtree, and are serializer-validated
  • 0034 authentication - platform default; no authentication_classes declared
  • 0035 MFE config - n/a: authenticated and per-course, not configuration
  • 0036 nested JSON - Pattern A: depth 2 by default, 3 at most, children_url to go deeper; the screen payload is opt-in
  • 0037 versioning - v2 added; v1 frozen (docstring only) and deprecated in the schema; DEPR entry on [DEPR]: Contentstore and enrollment API versions superseded by FC-0118 #39128 (comment) with the window per [DEPR]: Contentstore and enrollment API versions superseded by FC-0118 #39128
  • 0038 URL structure - courses/{course_key}/outline/, URL name course_outline, mounted in v2/authoring_urls.py
  • OEP-66 queryset scoping - n/a: not a list endpoint
  • OEP-69 conventions - no governance identifiers or history narration in code or schema text

Compliance matrix

Decision Status How Evidence
0025 met serializers/course_outline.py: CourseOutlineQuery, CourseOutline (recursive node), CourseOutlineFull, CourseOutlineInitialState, CourseOutlineDeprecatedBlocks, CourseOutlineProctoringError/Setting (byte-identical to the shared ones, documented). Six nullable fields. OutlineSchemaTest (descriptions on every reachable property, nullable and required sets); every test response validated against the published schema
0026 met views/permissions.py CanViewCourse, reindex gate per field OutlineAccessLegacyPermissionsTest, OutlineAccessAuthzTest (each case compared with v1, including a role on another course), OutlineFullViewReindexLinkAuthzTest
0027 met Query parameters with minimum/maximum/default, view enum and default; two examples; SDK tag OutlineSchemaTest
0028 met Thin view, course_outline_service.py, one bulk_operations walk OutlineQueryCountTest, OutlineQueryCountAuthzTest, OutlineRerootQueryCountTest
0029 partial StandardizedErrorMixin; fixed messages that echo no keys refusal tests in OutlineDefaultViewTest; open items pinned in OutlineRoutingTest
0030 met No re-save, no clipboard read, discussion settings copied OutlineReadSideEffectsLegacyPermissionsTest, OutlineReadSideEffectsAuthzTest (SQL and Mongo writes, signals, first request on a fresh database, HEAD, both views, role-holder reader)
0034 met Platform default inactive-session 401, inactive-JWT 200 (as v1), Bearer 401 tests in both access classes
0036 met Pattern A OutlineDefaultViewTest, OutlineOtherContainersTest (follows every children_url, including conditional, library_content and split_test)
0037 met v2; v1 docstring-only; SUPERSEDED_PATHS; DEPR entry on #39128 versions gate; cms/lib/tests/test_spectacular.py; DEPR entry
0038 met Route and name OutlineRoutingTest; urls gate

Backward compatibility

  • Versions gate: PASS. v1 course_index.py changes only in docstring lines; with docstrings stripped, its AST equals master. v2/authoring_urls.py gains one route. Protected, untouched: utils.py, views/course.py, xblock_storage_handlers/view_handlers.py (the shared outline builder), rest_api/serializers/common.py, the content-staging API and models, view_utils.py, the authz decorators, the CourseOverview model, docs/cms-openapi.yaml.
  • URLs gate: PASS. One route added; every base route still resolves.
  • Schema gate: PASS, 0 breaking. Two additions: the v2 GET, and the v1 GET newly deprecated: true. Warnings:
    • two new ones: the platform-default authenticator warnings that every default-auth view emits;
    • three that look new: ContainerChildrenView's existing warnings, moved by the docstring lines.
  • v1 tests: untouched and green.
  • Parity (?view=full vs v1, through the test client). The comparison is typed, keeps empty containers, and fails on a difference that is not declared or a declared one that does not occur. Declared differences:
    • initial_user_clipboard: always absent in v2.
    • For courses whose blocks have pre-1900 start dates: course_release_date, start, release_date, released_to_students and visibility_state (v1 repairs these dates on read and reports "Set Date"/unscheduled; v2 reports them as stored).
  • course_structure stays a free-form object, as in v1. The shared create_xblock_info builds it, and OVERRIDE_CREATE_XBLOCK_INFO plugins can extend it. Its node keys are documented in the schema. OutlineFullViewNodeKeysTest pins the key set per block role, so a change to the shared builder fails loudly.

In-place edits

File Category (ADR 0037) Why
rest_api/v1/views/course_index.py 4 docstring-only deprecation marker names the v2 successor and #39128
rest_api/v2/authoring_urls.py 2 strictly opt-in addition one route
rest_api/v2/course_outline_service.py, serializers/course_outline.py, views/course_outline.py, views/tests/test_course_outline.py added inside v2 the new resource's modules
views/permissions.py (shared, unversioned) additive CanViewCourse and COURSE_NOT_FOUND_MESSAGE appended; existing classes byte-identical; the textbooks, video_settings and certificates tests stay green
cms/lib/spectacular.py, cms/lib/tests/test_spectacular.py schema hook one SUPERSEDED_PATHS entry; an existing unmarked-paths test is rewritten as a loop with its assertion kept

Migration (v1 to v2)

  • Call /api/authoring/v2/courses/{course_key}/outline/?view=full. Note the trailing slash; a slashless GET is redirected (301), other methods are not.
  • The view=full body keeps v1's top-level keys and per-node shape, tag_counts_by_block included, minus initial_user_clipboard. Read the clipboard from /api/content-staging/v1/clipboard/.
  • Six fields are now declared nullable: initial_state, lms_link, notification_dismiss_url, reindex_link, rerun_notification_id, created_on.
  • ?format=concise has no v2 equivalent. v1's concise form also listed the components inside units; both v2 views stop at units.
  • Blocks with pre-1900 start dates are reported as stored (see Parity). v1 repaired them on read.
  • Authentication:
    • an inactive account's browser session is refused (401);
    • JWT does not check activation, as on v1;
    • Bearer tokens are refused; use JWT or a session.
  • Slash-form (Old Mongo) course keys are served only by v1; v2 answers them with a 404.
  • A version- or branch-pinned course or block key resolves to the current course, as on v1.
  • Errors use the standardized envelope.

Error-format decision

Versioned: v2 uses the standardized envelope, and v1 keeps its legacy developer_message/error_code body.

Deprecations

GET /api/contentstore/v1/course_index/{course_id} is deprecated: true in the CMS schema, and its docstrings name the v2 successor. No runtime header (frozen route). The DEPR record is an entry on #39128: this comment. Folding this entry, and the ones for #39102, #39186 and #39227, into the issue body is pending. Window, per #39128: the marking ships in the next named release after Verawood, and v1 stays mounted until one named release after that.

Authorization

v1 check v2
IsAuthenticated IsAuthenticated, first
verify_course_exists the course_key converter (malformed keys), then CanViewCourse: version-only key or unknown course gives 404 before any role check
inline user_has_course_permission(courses.view_course, READ) CanViewCourse, the same call
user_can_reindex_course for reindex_link the same call (authz off: legacy staff; authz on: courses.publish_course_content)

Nobody gains access. Bearer callers and inactive browser sessions lose it.

Gate report

run_gates.sh --base origin/master --service cms --prefix /api/authoring/v2/, run in a container with the branch's pinned requirements (Python 3.12, edx-drf-extensions 10.9.0, openedx-authz 1.24.0). No gate was skipped.

versions   PASS  v1 docstring-only; 2 allowed files, 4 new v2 modules; protected files untouched
hygiene    PASS  0 FAIL, 0 WARN
schema     PASS  0 BREAKING; added v2 GET, v1 GET newly deprecated; 5 WARN as explained above
urls       PASS  0 FAIL, 0 WARN; one route added
old-tests  PASS  37 passed, 7 skipped (v1 course_index API and Studio view tests, unmodified)
tests      PASS  340 passed: v2 outline 164, v2 textbooks 67, v2 video settings 47,
                 certificates 40 (importers of views/permissions.py), CMS schema hooks 22

Query counts (warm request; SQL / Mongo reads / Mongo writes)

Read authz off authz on
v1 37 / 6 / 0 38 / 6 / 0
v2 view=full 34 / 6 / 0 35 / 6 / 0
v2 default, and re-rooted 11 / 4 / 0 11 / 4 / 0

These counts are the same for a 1-unit and a 36-unit course.

Coordination

Follow-ups

  • Authoring v2: a JSON catch-all for malformed keys.
  • edx-drf-extensions: catalogue 405 and 406.
  • content_staging: the clipboard GET can save a CourseOverview row.
  • Pre-1900 start dates: a non-GET home for the date repair.
  • frontend-app-authoring: migrate the outline and competency-management screens to v2 ?view=full.

🤖 Generated with Claude Code

Taimoor Ahmed and others added 2 commits October 14, 2026 13:43
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>
@openedx-webhooks openedx-webhooks added the open-source-contribution PR author is not from Axim or 2U label Oct 14, 2026
@openedx-webhooks

Copy link
Copy Markdown

Thanks for the pull request, @taimoor-ahmed-1!

This repository is currently maintained by @openedx/wg-maintenance-openedx-platform-oncall.

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 approval

If you haven't already, check this list to see if your contribution needs to go through the product review process.

  • If it does, you'll need to submit a product proposal for your contribution, and have it reviewed by the Product Working Group.
    • This process (including the steps you'll need to take) is documented here.
  • If it doesn't, simply proceed with the next step.
🔘 Provide context

To 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:

  • Dependencies

    This PR must be merged before / after / at the same time as ...

  • Blockers

    This PR is waiting for OEP-1234 to be accepted.

  • Timeline information

    This PR must be merged by XX date because ...

  • Partner information

    This is for a course on edx.org.

  • Supporting documentation
  • Relevant Open edX discussion forum threads
🔘 Get a green build

If 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 PR

Your 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:

  • The size and impact of the changes that it introduces
  • The need for product review
  • Maintenance status of the parent repository

💡 As a result it may take up to several weeks or months to complete a review and merge your PR.

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>

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

open-source-contribution PR author is not from Axim or 2U

Projects

Status: Needs Triage

Development

Successfully merging this pull request may close these issues.

2 participants