Skip to content

feat: serve course custom pages and tabs from authoring v1 - #39305

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

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

Conversation

@taimoor-ahmed-1

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

Copy link
Copy Markdown
Contributor

Stacked on #39303 (advanced settings, which adds IsExistingCourse and the shared test helpers). Until #39303 merges this branch also carries its commits; only the last 5 commits belong to this PR.

Summary

Standardizes the custom pages and tabs resources of the Course Management area (#39059, part of #38137). This is PR C of the eight resource-group PRs for the area; #39303 (advanced settings) is PR A.

Three conforming operations replace the three v0 tab endpoints:

v0 (unchanged, now deprecated: true) v1 URL name (authoring_v1:)
GET /api/contentstore/v0/tabs/{course_id} GET /api/authoring/v1/custom_pages/?course_key={course_key} custom_page_list
POST /api/contentstore/v0/tabs/{course_id}/reorder PUT /api/authoring/v1/courses/{course_key}/custom_page_order/ course_custom_page_order
POST /api/contentstore/v0/tabs/{course_id}/settings?tab_id= PATCH /api/authoring/v1/courses/{course_key}/tabs/{tab_id}/ course_tab_detail

It applies ADRs 0025–0038 and OEP-69.

Two resources, not one. v0's "tabs" endpoints act on two different things. The list and the reorder only ever see custom pages (static tabs): v0's GET keeps only static_tab entries, and its reorder only moves static tabs. The settings endpoint only ever succeeds on built-in tabs (progress, dates, wiki and the like), because a static tab is never hideable (StaticTab keeps CourseTab.is_hideable = False). So v1 names them separately:

  • Custom pages are a top-level collection with the course in the query string. A custom page is a static_tab block with its own usage key: its id in the list, the item of the reorder body, and /api/authoring/v1/xblocks/{usage_key}/ all address it by that key. ADR 0038 rule 8 puts such a resource at the top level with its parent in the query (the ADR's own units/?course_key= example). course_key is required.
  • The order of a course's custom pages is a one-per-course noun, courses/{course_key}/custom_page_order/, replaced with PUT. v0's reorder verb becomes the noun (rule 10).
  • Tabs are addressed by their course-scoped tab_id (progress, dates, wiki), so they stay under the course: courses/{course_key}/tabs/{tab_id}/, with PATCH {"is_hidden": …} returning the tab. Only the member is routed: listing every tab is not something v0 offered.

RISK, stated in the schema: a custom page is also one of the course's tabs (tab_id static_tab_<url_slug>), and the list shows that tab_id. PATCH …/tabs/static_tab_<url_slug>/ still answers 400 on is_hidden, as v0 does, because custom pages cannot be hidden. The PATCH description says so.

Why a new version and not a fix in place. Each of these changes the contract for v0's callers (the Pages & Resources custom pages screen calls the list and the reorder), so they ride v1 (ADR 0037):

  • the list becomes paginated (a bare array becomes the seven-key envelope);
  • the list moves to a new address, the reorder becomes PUT with a new body, and the tab change becomes PATCH on the member returning the tab;
  • errors use the standard envelope, with fixed messages instead of text that echoed the request;
  • Bearer tokens are refused;
  • addresses gain a trailing slash and a checked course key.

Changes

Commit Endpoint(s) Legacy behaviour kept New behaviour
feat: add a shared paginator that documents all seven envelope keys — DefaultPagination paging unchanged (page, page_size, 10 by default, at most 100) New unversioned cms/djangoapps/contentstore/rest_api/pagination.py: DocumentedDefaultPagination, which publishes all seven envelope keys, each with an example. For this PR and the later Course Management lists
feat: add the pages and resources management permission class — CanViewPagesAndResources unchanged CanManagePagesAndResources: the same user_has_course_permission(courses.manage_pages_and_resources, WRITE) call v0's reorder and settings views make inline
feat: serve course custom pages and tabs from authoring v1 the three v1 operations above (new) v0 routes, views and URL names unchanged Views, service, serializers, routes and tests. The service reuses the legacy helpers in views/tabs.py (get_course_tabs, reorder_tabs_handler, edit_tab_handler), which are not edited
docs: mark the v0 tabs endpoints deprecated in favour of authoring v1 the three v0 operations Routes, views and responses unchanged deprecated: true in the schema (three exact SUPERSEDED_PATHS entries); the docstrings name each successor and #39128
docs: describe what an inactive session and a superuser are allowed — — The authentication comment says what an inactive session gets instead of where the classes came from; the superuser test is renamed and gives the real reason a superuser is allowed (saving one sets is_staff)

ADRs applied

  • 0025 serializers — separate output (CourseTab.v1, CustomPage.v1), request (CustomPageOrder.v1, CourseTabUpdate.v1) and query (CustomPageListQuery.v1) serializers, help_text on every field, explicit .v1 component names
  • 0026 permissions — IsAuthenticated, the course key, IsExistingCourse, then the view or manage permission v0 checked inline; the list refuses any action it was not written for; deny matrix with policy permissions off and on
  • 0027 schema / docs — @extend_schema on all three with summary, description (every 400 message named), parameters, examples and every 4xx as ErrorResponse; all three tagged openedx-platform-sdk
  • 0028 viewsets — applied except router registration: three ViewSets over a service module, mounted with path(...) like every route in authoring_urls.py, because a router's regex routes cannot use the shared path converters
  • 0029 errors — partial: the envelope on every response these views produce, with fixed messages; malformed JSON, 405, 406 and 415 are typed internal and a malformed course key on the nested routes gets the CMS HTML 404 (library and routing gaps, pinned by tests); detail still carries the library's ErrorDetail(...) text for validation errors while errors is clean
  • 0030 GET idempotence — met for this code (no write, signal or event on GET or HEAD), with one inherited write outside it: openedx-authz inserts its PolicyCacheControl row on the first permission check against a database that lacks it, as on v0
  • 0031 merged endpoints — n/a: not merged, because the list and order act on custom pages and the visibility change on built-in tabs, which are different resources; the reorder verb became a noun under ADR 0038 instead
  • 0032 pagination — the list returns the seven-key envelope (page, page_size 10 by default, at most 100) through the new shared DocumentedDefaultPagination, and the schema publishes all seven keys
  • 0033 filtering — n/a: no filter or sort parameter; the order is the course's tab order, which is what learners see, and adding filters would be a new capability
  • 0034 authentication — (JwtAuthentication, SessionAuthenticationAllowInactiveUser) declared with its reason, as ADR 0034 requires for an endpoint that used SessionAuthenticationAllowInactiveUser; Bearer dropped
  • 0035 MFE config — n/a: per-course authoring data, not site or front-end configuration
  • 0036 nested JSON — n/a: a flat list of thin objects (nine scalar members and a small settings map each) and a single tab
  • 0037 versioning — partial: new v1 modules, v0 frozen (docstring-only markers) and deprecated: true in 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
  • 0038 URL structure — custom_pages/?course_key= (rule 8), courses/{course_key}/custom_page_order/ (rule 10), courses/{course_key}/tabs/{tab_id}/; shared converter, trailing slash, version-free URL names; v0 names kept
  • OEP-66 queryset scoping — n/a as a mechanism: the list reads one course's tab list from the modulestore, with no get_queryset(); the layering is shown in the matrix
  • OEP-69 conventions — the decisions above as recorded, with the 0028, 0029, 0030 and 0037 departures stated; no governance identifiers in code or schema text

Compliance matrix

Decision Status How it is met, or the concrete reason it is excluded Evidence (test / gate)
0025 met CourseTabSerializer (CourseTab.v1: type, title (null for custom pages), is_hideable, is_hidden, is_movable, course_staff_only, name, tab_id, settings) and CustomPageSerializer (CustomPage.v1, adds the usage key id) give the same item v0's list returns. Request serializers for the order and the tab change; a query serializer for course_key. Explicit ref_names, because CourseTab already collides platform-wide CustomPagesSchemaTest (test_the_tab_and_page_components, test_every_member_is_described, test_the_reorder_takes_json_naming_the_pages_and_answers_without_a_body, test_the_tab_change_takes_an_optional_is_hidden_and_returns_the_tab); CustomPagesListTest.test_the_body_matches_the_published_schema; hygiene H9
0026 met Every v0 check has a v1 home (Authorization below). The list reads course_key from the query after the login check and before the course checks, so the order is 401, 400 (missing or invalid key), 404, 403. IsExistingCourse is unchanged: a permission step sets the view's course_key argument it reads. The list refuses any action other than list and OPTIONS with a server error. The order and tab viewsets check the manage permission for every action CustomPagesAccessLegacyPermissionsTest, CustomPagesAccessAuthzTest (each access case asserts v0's answer too); CustomPagesPermissionWiringTest (test_the_listing_refuses_any_other_action, test_a_routed_action_the_listing_does_not_check_is_never_served); CustomPagesCourseKeyQueryTest (test_the_caller_is_authenticated_before_the_course_key_is_read, test_the_course_key_is_read_before_the_course_is_looked_up_or_the_caller_checked)
0027 met Operation IDs v1_custom_pages_list, v1_courses_custom_page_order_update, v1_courses_tabs_partial_update. course_key is a required query parameter. The PUT description names all four 400 messages; the PATCH description says custom pages cannot be hidden there. All three are tagged openedx-platform-sdk; the deprecated v0 operations lose the tag through untag_deprecated_operations CustomPagesSchemaTest (test_the_three_operations_are_published_for_the_sdk, test_the_list_parameters, test_the_reorder_documents_each_refusal_message, test_the_tab_change_says_custom_pages_cannot_be_hidden_there, test_the_examples_are_valid_bodies, test_the_v0_operations_are_deprecated_and_left_out_of_the_sdk)
0028 applied except router registration CourseCustomPagesViewSet, CourseCustomPageOrderViewSet, CourseTabsViewSet (each StandardizedErrorMixin first, then viewsets.ViewSet) over v1/custom_pages_service.py. They are plain ViewSets because the data is the course's tab list in the modulestore. Deviation: mounted with path(), not a router CustomPagesRoutingTest.test_the_addresses; CustomPagesListingCost{LegacyPermissions,Authz}Test
0029 partial StandardizedErrorMixin first. 401 authn, 403 authz, 404 not-found (unknown, version-only or library course; a course the modulestore does not return; a tab_id the course does not have; a page out of range), 400 validation with errors keyed by member. Every refusal uses a fixed message that never echoes the request: course_key; the four reorder messages (not a usage key, not a custom page of this course, listed twice, could not be saved); is_hidden on a tab that cannot be hidden. When the reorder helper fails, its own text is logged with the course key and not returned. Gaps pinned by tests that flip when fixed: malformed JSON (400), 405, 406 and 415 are typed internal (edx-drf-extensions 10.9.0 does not catalogue them); a malformed key on the nested routes gets the CMS HTML 404 at routing; detail renders the validation detail with the library's ErrorDetail(...) text, while errors carries the plain messages CustomPageOrderTest (test_an_item_that_is_not_a_string_holding_a_usage_key_is_refused_with_the_same_message, test_a_failure_saving_the_order_is_reported_without_its_text); CourseTabUpdateTest (test_a_tab_that_cannot_be_hidden_is_refused, test_a_tab_the_course_does_not_have_is_404); CustomPagesCourseKeyQueryTest.test_a_value_that_is_not_a_course_key_is_refused_without_echoing_it; CustomPagesRoutingTest (test_a_malformed_json_body_is_a_400_typed_internal, test_an_unsupported_media_type_is_a_415_typed_internal, test_other_methods_are_not_allowed, test_unsupported_accept_is_refused_with_406, test_an_unparsable_or_slash_form_key_is_an_html_404)
0030 met for this code; one inherited write GET and HEAD read the course's tab list only. With policy permissions off and on, and with the pages-and-resources page flag on and off (it changes which tabs Studio walks), no listing writes to SQL or Mongo, stores through the modulestore, or sends a Django signal or Open edX event, including callbacks run on commit. The exception is the first read on a database without openedx-authz's PolicyCacheControl row, which inserts it (one INSERT, its pre_save/post_save). That write belongs to the permission library, happens on v0 too, and is allow-listed by name. The recorder's own self-tests live with the shared helpers in #39303 CustomPagesListingSideEffects{LegacyPermissions,Authz}Test (test_listings_have_no_side_effects, test_the_v0_listing_makes_the_same_first_lookup_write)
0031 excluded Different resources (custom pages, built-in tabs); P18: a different resource is a reason not to merge —
0032 met IterablePaginationMixin with DocumentedDefaultPagination. Why a local subclass: edx-drf-extensions 10.9.0's DefaultPagination returns seven keys but publishes a response schema with four, so generated clients would drop num_pages, current_page and start. The subclass only adds get_paginated_response_schema, with an example per key so the generator can wrap a list example. It lives in an unversioned module so the later Course Management lists reuse it. The library fix is item L3 (to be filed); v2 textbooks' CourseTextbookPagination is a second copy, in a frozen file CustomPagesListTest (test_the_envelope_has_the_seven_keys, test_page_size_splits_the_pages, test_the_default_page_size_is_ten, test_the_page_size_is_at_most_one_hundred, test_a_page_out_of_range_is_404); SharedPageEnvelopeTest; CustomPagesSchemaTest.test_the_list_publishes_the_seven_envelope_keys; hygiene H3 WARN (explained below)
0033 excluded No filter or sort; fixed, documented order (the course's tab order). Unknown query parameters are ignored CustomPagesListTest (test_only_the_custom_pages_are_listed_in_course_order, test_other_query_parameters_are_ignored)
0034 met with declared deviation (JwtAuthentication, SessionAuthenticationAllowInactiveUser), commented at the declaration: an inactive account's browser session stays authenticated, so the course permission decides: staff status admits it, and its roles count only on courses where policy-based permissions are on. Bearer is dropped. Same as #39303 CustomPagesAuthenticationTest (test_a_jwt_caller_may_list_reorder_and_change_tabs, test_a_session_caller_may_list_reorder_and_change_tabs, test_an_inactive_session_is_authenticated, test_a_bearer_token_is_refused); hygiene H4
0035 excluded Not configuration; per-course, per-user —
0036 excluded Flat list of thin objects; a single tab —
0037 partial New modules in v1/. v0 frozen apart from docstring-only markers: the syntax tree with docstrings stripped equals the base. deprecated: true through three exact SUPERSEDED_PATHS entries; no runtime header (frozen routes). DEPR: an entry comment on #39128, to be folded into the issue body versions gate; CmsMarkSupersededPathsTest.test_the_tab_operations_are_marked_and_their_successors_are_not; CustomPagesSchemaTest (test_the_v0_operations_are_deprecated_and_left_out_of_the_sdk, test_only_superseded_or_migrated_addresses_are_deprecated)
0038 met Rule 8 for custom pages (own usage key → top level, course in the query); rule 10 for the order; tab_id as the tab's natural key; shared course_key converter on the nested routes; trailing slash; version-free URL names CustomPagesRoutingTest (test_the_addresses, test_the_v0_addresses_are_unchanged, test_the_url_names_are_unique_in_the_namespace, test_the_course_is_no_longer_part_of_the_list_address, test_the_list_address_without_its_trailing_slash_redirects_to_it, test_v0_still_takes_a_slash_separated_key_that_v1_does_not); CustomPagesCourseKeyQueryTest; url_snapshot rules check on a hand-built snapshot (the urls gate itself was skipped)
OEP-66 mechanism excluded, layering met (1) endpoint access: the permission classes; (2) record visibility: the course's own tab list through get_course_tabs, keeping custom pages only, as on v0; (3) user selection: none beyond page/page_size, which cannot widen what is listed access tests above; CustomPagesCourseKeyQueryTest.test_each_course_lists_its_own_pages
OEP-69 met The decisions above, with the departures stated in their rows. No ADR/OEP/PR identifiers or narration in code or schema text hygiene gate PASS (0 FAIL)

Backward compatibility

  • v0 is unchanged. v0/views/tabs.py gains docstring text only; its syntax tree with docstrings stripped equals the base. Its routes, views and URL names are untouched, and v0's own tests are not edited.

  • Parity. v1 is compared with v0 through the Django test client, with policy permissions off and on:

    • the list returns the same custom pages with identical members, on both sides of the pages-and-resources page flag;
    • a full reorder, and a reorder naming all pages but one, store the same tabs;
    • a visibility change stores the same tab.

    The only declared body differences, verbatim from the test module:

    INTENTIONAL_DIFFERENCES = [
        ("type", "errors use the standardized envelope"),
        ("title", "errors use the standardized envelope"),
        ("status", "errors use the standardized envelope"),
        ("detail", "errors use the standardized envelope"),
        ("errors", "a refused change names the refused member with a fixed message, where v0 returns the "
                   "helper's own text under developer_message"),
        ("developer_message", "the v0 error body carries developer_message; the envelope does not"),
        ("error_code", "the v0 not-found body carries error_code; the envelope does not"),
    ]

    The comparison fails both on an undeclared difference and on a declared difference that never occurs (_ParityMixin.test_every_declared_difference_occurs).

  • Status and request differences from v0. Each is listed in the test module and observed against v0's own answer:

    RESPONSE_DIFFERENCES = [
        ("listing", "a bare array of every custom page", "a page of them in the standard envelope",
         "test_the_list_is_paginated"),
        ("change of a tab's visibility", "204 without a body", "200 with the tab",
         "test_a_visibility_change_returns_the_tab"),
        ("change of a tab the course does not have", "400", "404", "test_an_unknown_tab"),
        ("change without is_hidden", "400", "200, nothing changed", "test_a_change_without_is_hidden"),
        ("reorder naming a custom page that does not exist", "500", "400", "test_a_reorder_naming_no_page"),
        ("reorder naming a page twice", "204, the page stored twice", "400", "test_a_reorder_naming_a_page_twice"),
        ("any request for a course the modulestore returns nothing for", "500", "404",
         "test_a_course_the_modulestore_returns_nothing_for"),
        ("reorder leaving out two or more pages", "the pages left out in no particular order",
         "the pages left out in their current order", "test_pages_left_out_of_a_reorder"),
        ("reorder naming a page of another course with the same url_slug", "204, this course's page moved", "400",
         "test_a_page_of_another_course_with_the_same_url_slug"),
        ("OPTIONS by a signed-in caller without access, or for an unknown course", "200", "403 or 404",
         "test_options"),
        ("any request with a Bearer token", "2xx", "401", "test_a_bearer_token"),
        ("listing for a slash-separated course key", "404 JSON", "400", "test_a_slash_separated_course_key"),
    ]

    As a table, with the request-shape changes that carry no status difference:

    Request v0 v1 Test
    list GET v0/tabs/{course_id}, bare array GET v1/custom_pages/?course_key=, 7-key envelope, 10 per page by default CustomPagesResponseDifferencesTest.test_the_list_is_paginated
    list for a slash-form course key 404 JSON 400 errors.course_key …test_a_slash_separated_course_key
    reorder POST …/reorder with [{tab_id}|{tab_locator}] PUT …/custom_page_order/ with {"custom_pages": [<usage key>, …]}; same stored tabs _ParityMixin.test_reorders_store_the_same_tabs
    reorder leaving out two or more pages left-out pages in no particular order left-out pages in their current order …test_pages_left_out_of_a_reorder
    reorder naming another course's page with the same url_slug 204, this course's page moved 400 …test_a_page_of_another_course_with_the_same_url_slug
    reorder naming a page that does not exist 500 400 …test_a_reorder_naming_no_page
    reorder naming a page twice 204, page stored twice 400 …test_a_reorder_naming_a_page_twice
    show or hide a tab POST …/settings?tab_id= → 204, no body PATCH …/tabs/{tab_id}/ → 200 with the tab …test_a_visibility_change_returns_the_tab
    tab the course does not have 400, echoing the tab id 404, fixed message …test_an_unknown_tab
    tab change without is_hidden ({}, or a misspelled member) 400 200, nothing changed …test_a_change_without_is_hidden; CourseTabUpdateTest.test_a_body_without_is_hidden_changes_nothing
    tab that cannot be hidden 400 with the helper's text 400, fixed message under is_hidden _ParityMixin.test_a_refused_visibility_change_names_is_hidden_with_a_fixed_message
    course the modulestore returns nothing for 500 404 …test_a_course_the_modulestore_returns_nothing_for
    OPTIONS by an outsider, or for an unknown course 200 403 / 404 …test_options
    Bearer token 2xx 401 …test_a_bearer_token
    address without the trailing slash served 301 to the slashed address, query kept CustomPagesRoutingTest.test_the_list_address_without_its_trailing_slash_redirects_to_it

    The tab_locator form of v0's settings endpoint has no successor. It can only name a custom page, which can never be hidden.

  • Query counts. A listing runs no more SQL queries or Mongo reads than v0's, measured in the same test on both sides of the pages-and-resources flag. Its SQL count does not grow with the number of pages. No literal baseline was measured, because no platform environment was available.

  • Schema. Three new operations, and deprecated: true on the three v0 operations. docs/cms-openapi.yaml is not edited; the generate_openapi_schemas workflow regenerates it.

In-place edits

File Category Evidence
cms/djangoapps/contentstore/rest_api/v0/views/tabs.py docstring-only deprecation marker syntax tree with docstrings stripped equals the base; versions gate ALLOWED
cms/djangoapps/contentstore/rest_api/v1/authoring_urls.py strictly opt-in addition: one import, three routes no line removed; CustomPagesRoutingTest
cms/djangoapps/contentstore/views/permissions.py (shared, unversioned) one class added; the existing openedx_authz.constants.permissions import gains one name; existing classes unchanged CustomPagesPermissionWiringTest and the access tests
cms/djangoapps/contentstore/rest_api/pagination.py (new, unversioned) new shared module SharedPageEnvelopeTest, CustomPagesSchemaTest.test_the_list_publishes_the_seven_envelope_keys
cms/lib/spectacular.py, cms/lib/tests/test_spectacular.py three exact SUPERSEDED_PATHS entries; the exact-set test updated and a test for the tab paths added CmsMarkSupersededPathsTest

New files inside the pre-existing v1/: custom_pages_service.py, serializers/custom_pages.py, views/custom_pages.py, views/tests/test_custom_pages.py. They import nothing from rest_api/v0/.

Error-format decision

Versioned. v0 keeps its developer_message/error_code bodies, its 500s and the helpers' text unchanged. v1 uses the envelope with fixed messages. The custom pages screen in frontend-app-authoring calls the v0 list and reorder (src/custom-pages/data/api.ts); the list's pagination already forces a new version, and the error format rides with it.

Deprecations

The three v0 operations are superseded by the three v1 operations in the Summary. They are deprecated: true in the CMS schema, and the docstrings name each successor. The views are otherwise unchanged: no runtime header. Tracked on the FC-0118 umbrella DEPR ticket, #39128; this resource's entry is posted there with the PR. Window per #39128: removed one named release after the release this ships in.

Migration notes:

  • List: call GET /api/authoring/v1/custom_pages/?course_key={course_key}. It is paginated: read results, and follow next or pass page/page_size (at most 100) to get every page. Items are unchanged, including id.
  • Reorder: PUT /api/authoring/v1/courses/{course_key}/custom_page_order/ with {"custom_pages": [<usage key>, …]}, the ids from the list (v0's tab_locator values). Pages left out follow the listed ones in their current order. Answers 204.
  • Show or hide a tab: PATCH /api/authoring/v1/courses/{course_key}/tabs/{tab_id}/ with {"is_hidden": true|false}. Answers 200 with the tab. A body without is_hidden changes nothing.
  • Refusals use fixed messages under the member they concern (course_key, custom_pages, is_hidden).
  • Errors use the standard envelope.
  • Bearer tokens are refused; use JWT or a session.
  • Every address ends with a slash.

Authorization

Legacy check Where it lives in v1 Deny-path tests
@view_auth_classes(is_authenticated=True) on all three views IsAuthenticated, before the course key is read test_anonymous_caller_is_refused_with_401 (both permission modes), incl. on an unknown course; CustomPagesCourseKeyQueryTest.test_the_caller_is_authenticated_before_the_course_key_is_read
@verify_course_exists() on all three methods IsExistingCourse (also 404 for version-only and library keys); for the list, after the query's course_key is validated unknown course, version-only key and library key → 404, before any role check (test_unknown_course_is_404_before_permission_is_checked, test_a_version_only_course_key_is_404, test_a_library_is_not_a_course, CustomPagesCourseKeyQueryTest.test_a_version_only_or_library_key_is_an_unknown_course)
user_has_course_permission(courses.view_pages_and_resources, READ) in the list CanViewPagesAndResources (existing), same call no role; staff/instructor of another course; limited staff; policy roles limited staff, data researcher, beta tester; an admin role on another course; legacy staff without a policy role when policy permissions are on
user_has_course_permission(courses.manage_pages_and_resources, WRITE) in reorder and settings CanManagePagesAndResources (new), same call as above, plus the view-only policy role (auditor) → 403 on reorder and tab change (test_a_role_that_may_only_view_may_list_but_not_reorder_or_change_tabs)

Order: 401, then 400 for a missing or invalid course_key (list only), then 404 for a missing course, then 403. Allowed: course staff and instructor, org staff and instructor, global staff, and superusers, as on v0: saving a superuser also sets is_staff, so every superuser is global staff (test_a_superuser_is_allowed). With policy permissions on, the roles that grant viewing (list) or managing (reorder, tab change) pages and resources are allowed: auditor views; editor, staff and admin manage.

Gate report

Run on the host against 9b4d60ea0d (the head of #39303, 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.

Gate Result
versions PASS: 2 ALLOWED (v0/views/tabs.py docstrings, v1/authoring_urls.py), 4 ADDED, nothing protected touched (23 shared legacy modules protected, including views/tabs.py, whose helpers v1 reuses)
hygiene PASS (0 FAIL, 1 WARN). The WARN is H3 on DocumentedDefaultPagination, a local subclass of a library paginator. It is kept on purpose: it adds the seven-key response schema the library lacks (see 0032) and says so in its docstring
ruff clean; no line over 120 characters
supplementary, not a gate: URL rules (url_snapshot.py check) on a hand-built snapshot of the new routes PASS (0 FAIL, 0 WARN); the real urls gate above is SKIPPED
schema SKIPPED (no platform environment)
urls SKIPPED (no platform environment)
old-tests SKIPPED (no platform environment)
tests SKIPPED (no platform environment)

Follow-ups

  • SDK: all three operations carry openedx-platform-sdk; the next openedx-platform-sdk regeneration gains them.
  • frontend-app-authoring: the custom pages screen (src/custom-pages/data/api.ts) can move to the v1 list (request page_size=100 or follow next) and the PUT order. No MFE caller uses the v0 settings endpoint today.
  • edx-drf-extensions:
    • L3: DefaultPagination.get_paginated_response_schema with all seven keys, after which DocumentedDefaultPagination can be deleted. Issue to be filed.
    • Catalogue ParseError, MethodNotAllowed, NotAcceptable and UnsupportedMediaType.
    • Render a validation detail without the ErrorDetail(...) text.
  • v2 textbooks: CourseTextbookPagination (frozen in v2/views/textbooks.py) duplicates the shared paginator's schema; point the next version of that resource at rest_api/pagination.py.
  • openedx-authz 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.
  • Authentication across /api/authoring/: v1 here, feat: serve course advanced settings from authoring v1 #39303 and the feat: apply FC-0118 ADRs to course content APIs #39157 v2 views keep SessionAuthenticationAllowInactiveUser; feat: serve the course outline from authoring v2 #39229 and feat: serve course containers from authoring v2 #39274 use the default. One policy for new versions of such endpoints should be agreed.
  • Sibling PR B (course optimizer) edits the same shared files: views/permissions.py, v1/authoring_urls.py, cms/lib/spectacular.py and its test. The conflicts are additive, resolved by rebasing in merge order. When both land, the merged openedx_authz.constants.permissions import must be wrapped to stay within 120 characters.

Taimoor Ahmed added 10 commits October 25, 2026 15:09
The v0 advanced settings view checks course existence with the
@verify_course_exists decorator and access with an inline
check_course_advanced_settings_access call in each handler. The
conforming views need these as permission classes.

IsExistingCourse answers 404 for a course key that names no course run:
an unknown course, a key that names only a version, or a library key.
CanViewAdvancedSettings and CanManageAdvancedSettings make the same
check_course_advanced_settings_access(read|write) call the v0 view
makes, so the policy-permission and legacy Studio-access branches are
unchanged. Existing classes in the module are untouched.
GET/PATCH /api/contentstore/v0/advanced_settings/{course_id} answers
errors with developer_message bodies and some failures with a 500,
accepts Bearer tokens, returns every settings field by default, and
reports a refused value with the field parser's own text, which can be
Python exception text.

GET/PATCH /api/authoring/v1/courses/{course_key}/advanced_settings/ is
its successor. Errors use the standard envelope; a refused value is
reported under its setting name with one fixed message and the original
text is logged. The default read is the set of settings course authors
edit (v0 with fetch_all=0); view=full returns every field and fields
narrows either view. Authentication keeps the session class that admits
inactive users, as the legacy endpoint did; Bearer is dropped. Access is
IsAuthenticated, IsExistingCourse and a per-action class making v0's
read or write check, and an action without a declared permission is
never served. Parity tests compare v1 with v0 through the test client;
the declared differences are the error envelope and the fixed message.
GET/PATCH /api/contentstore/v0/advanced_settings/{course_id} now has a
conforming successor at /api/authoring/v1/courses/{course_key}/advanced_settings/.
Both v0 operations are marked deprecated in the CMS schema through an
exact SUPERSEDED_PATHS entry, and the view's docstrings name the
successor, how each v0 read maps to a v1 read, and the deprecation
ticket. The view itself is unchanged. The schema hook test that used
this path as its example of an unrelated contentstore path now uses the
heartbeat, which is not deprecated.
Importing cms.djangoapps.contentstore.rest_api.v1.authoring_urls at
module level builds its routes before cms/urls.py registers the
course_key path converter, so collecting the module failed with an
ImproperlyConfigured error. The URL-name test now imports it when it
runs, as the youtube transcript tests do. Also wraps a line pylint
reported as too long.
The first CI run of the module showed three expectations that did not
match the platform:

- The legacy role checks consult the policy engine, which admits
  superusers, so a superuser who is not global staff is served by v0 and
  v1 alike. The test now expects that.
- The proctoring provider field keeps its default unless proctored exams
  are enabled, so the proctoring tests enable them.
- The JWT middleware appends NotJwtRestrictedApplication to the
  permissions of views that accept JWTs, so the wiring test compares only
  the classes the view declares.
The side-effect recorder, the body comparison helpers, the schema
helpers and the client helpers in the advanced settings tests are not
specific to advanced settings. They move unchanged, under public names,
into v1/views/tests/helpers.py so the other authoring v1 endpoint tests
reuse one copy instead of each carrying its own.
edx-drf-extensions' DefaultPagination returns seven envelope keys but
publishes a schema with four, so generated clients drop num_pages,
current_page and start. DocumentedDefaultPagination keeps the library's
pagination and publishes all seven keys, each with an example. It lives
in an unversioned module for every authoring list to reuse, and is to
be deleted once the library documents the envelope itself.
The v0 tab views check courses.manage_pages_and_resources inline with a
legacy write-access fallback before reordering custom pages or changing
a tab. CanManagePagesAndResources makes the same call, beside the
existing CanViewPagesAndResources. Existing classes are unchanged.
The v0 tabs endpoints return the custom pages as a bare array, reorder
them through a POST verb that answers a missing page with 500 and stores
a repeated page twice, change a tab's visibility through a POST verb that
echoes its input in errors, and accept Bearer tokens.

GET /api/authoring/v1/custom_pages/?course_key=... lists a course's
custom pages a page at a time in course order; a custom page is
addressed by its own usage key, so the list is top level with the course
in the query. PUT /api/authoring/v1/courses/{course_key}/custom_page_order/
replaces their order, with pages left out kept after the listed ones.
PATCH /api/authoring/v1/courses/{course_key}/tabs/{tab_id}/ shows or
hides a built-in tab and returns it. Errors use the standard envelope
with fixed messages. Access makes v0's view and manage checks. Parity
tests compare the stored tabs and each refusal with v0.
The three v0 tab operations now have conforming successors under
/api/authoring/v1/. They are marked deprecated in the CMS schema through
exact SUPERSEDED_PATHS entries, and the views' docstrings name the
successors and the deprecation ticket. The views themselves are
unchanged.
@openedx-webhooks openedx-webhooks added the open-source-contribution PR author is not from Axim or 2U label Oct 25, 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.

The authentication comment narrated where the classes came from; it now
says what an inactive session gets: staff status admits it, and its roles
count only on courses where policy-based permissions are on.

The superuser test claimed to cover a superuser who is not global staff.
Saving a superuser sets is_staff, so the factory user is global staff;
the test is renamed and its docstring gives that reason.

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