Skip to content

feat: standardize the Grades API as v2 at /api/grade/v2/ - #39172

Draft
taimoor-ahmed-1 wants to merge 14 commits into
openedx:masterfrom
edly-io:feat/standardize-grades-api
Draft

taimoor-ahmed-1 wants to merge 14 commits into
openedx:masterfrom
edly-io:feat/standardize-grades-api

Conversation

@taimoor-ahmed-1

@taimoor-ahmed-1 taimoor-ahmed-1 commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Summary

This PR standardizes the Grades functional area (#39057, umbrella #38137) as a new version, /api/grade/v2/, following the API standardization ADRs docs/decisions/0025 to 0038, OEP-66 and OEP-69. /api/grades/v1/ stays mounted, unchanged in behaviour, and marked deprecated.

Why a new version. Nothing in v1 was standardized, and every endpoint needs contract changes that existing callers would notice:

  • errors use the standard envelope;
  • cursor pagination becomes page pagination with the seven-field envelope;
  • Bearer authentication is dropped;
  • addresses change: a singular API name, plural collections, username and opaque keys instead of numeric ids, and no verbs in paths;
  • 200 {"success": false} becomes a real 404;
  • bulk overrides are validated as a whole batch.

Changes like these go in a new version, not the current one.

Stacked on #39078 (URL structure). This branch uses its register_url_converters(), its schema path settings and its LMS deprecation hook, and adds no parallel ones. Until #39078 merges, this branch also carries #39078's commits (rebased onto current master). Only the last three commits belong to this PR: feat: publish the Grades API in the LMS schema, feat: add the Grades API v2 at /api/grade/v2/ and docs: mark Grades API v1 deprecated in favour of v2. The branch rebases onto master once #39078 merges, and this PR stays a draft until then.

Change table

Commit v2 endpoint(s) v1 source New behaviour
shared infrastructure mount, schema filter, pagination schema, domain error types, permissions, JSON 404 catch-alls n/a /api/grade/v2/ and /api/grades/v1/ are admitted to the LMS schema; all nine v1 operations are marked deprecated
course grades GET course_grades/, GET course_grades/{username},{course_key}/ courses/, courses/{course_id}/, section_grades_breakdown/ The breakdown is ?view=full, for global staff only (as E8). Rows are scoped: staff see all, a restricted token sees its org and user filters, everyone else sees their own
gradebook entries GET courses/{course_key}/gradebook_entries/[{username}/] gradebook/{course_id}/ Access is checked before existence and the flag. ?view=minimal. Invalid filter values give 400 (v1 gave 500)
overrides POST courses/{course_key}/subsection_grade_overrides/ gradebook/{course_id}/bulk-update JSON only, at most 100 items. If any item is invalid, nothing is stored and errors are keyed like overrides[1].username
subsection grades GET subsection_grades/{username},{usage_key}/, .../override_history_records/ subsection/{subsection_id}/ CCX coaches admitted (v1 refuses them); the learner must be enrolled; an unavailable subsection gives 404
grading policy GET courses/{course_key}/grading_policy/ policy/courses/{course_id}/, gradebook/{course_id}/grading-info One resource. The default view keeps grading-info's access; ?view=minimal keeps the policy endpoint's access and content. A read never creates a course overview
submission histories GET courses/{course_key}/submission_histories/ submission_history/{course_id}/ Page pagination; problem data only with ?view=full; an unknown course gives 404
v1 deprecation markers none all nine Docstring lines only

ADRs applied

  • 0025 serializers - separate request and response serializers, help_text on every field, nullable and view-dependent fields marked, and a grade_v2.* ref_name on each
  • 0026 permissions - every v1 check is mapped to a permission class, checked per view, and compared against v1 caller by caller
  • 0027 schema / docs - @extend_schema and an explicit operation id on all nine operations, with examples for the batch override. The only v2 generator warning is "could not resolve authenticator" for the platform's default authentication classes, the same as Enrollment v2 today (library follow-up)
  • 0028 viewsets - views, services and serializers are separated, with GenericViewSets or ViewSets on explicit path() routes, but not router-registered. A DRF router names routes course_grade-list, cannot carry the {username},{course_key} composite or the key converters, and publishes an extra {id} operation. The per-row query growth is pinned below v1's
  • 0029 errors - the standard envelope everywhere; v1's 200 success:false becomes 404; no exception text in any body. Until the library catalogues them, a malformed JSON body (400), 405, 406 and 415 report type internal (library follow-up); tests pin this and flip when the pin moves
  • 0030 GET idempotence - met for N7, N8, N9 and the N3/N4 row reads. Three first-read writes are kept from v1 and pinned by tests: gradebook entries copy the course's cohort settings; a computed subsection grade creates the learner's anonymous-ID row; a computed grade (N6, and staff-only ?view=full) can assign an uncohorted learner to a cohort, writing two cohort tables and sending COHORT_MEMBERSHIP_CHANGED and two tracking events. The tracking issue is not filed yet
  • 0031 merged endpoints - the area has no action family; bulk-update becomes a POST to a noun. The two consolidations (breakdown into course grades, grading-info with policy) are view variants that keep each legacy check for the data it guarded, as caller-by-caller comparison tests show
  • 0032 pagination - the seven-field envelope, 10 per page, 100 max, on every list
  • 0033 filtering - FilterSets with typed, documented parameters, an ordering allow-list with an id tie-break, and no parameter aliases in v2
  • 0034 authentication - the platform default on every v2 view; Bearer and allow-inactive dropped
  • 0035 MFE config - n/a, no Grades endpoint serves front-end or site configuration
  • 0036 nested JSON - ?view=full or ?view=minimal where a representation is heavy; view-dependent fields are optional in the schema; other values give 400
  • 0037 versioning - v2 is added, and v1 is frozen (docstring markers only) and deprecated in the schema. Still owed before merge: the DEPR issue (not filed yet; the eight v1 markers link a placeholder that will be replaced), a named removal release, and the migration guide below published there
  • 0038 URL structure - singular API name, plural collections, comma composites, opaque-key converters, required trailing slashes, unique snake_case names, and JSON 404s for every unmatched address under the mount
  • OEP-66 queryset scoping - a real policy on course grades, implementing the library's ScopingPolicy protocol; FullScopePolicy on the other lists; filters only narrow
  • OEP-69 conventions - no governance identifiers in code or schema text, and no default-auth declarations. Two local stopgaps have library follow-ups: the pagination response schema and the JSON 404 catch-all view

Compliance matrix

Decision Status How it is met, or why it is excluded Evidence
0025 met Request and response serializers differ on N5; help_text on every field; allow_null where values can be null (for example display_name on an unnamed subsection); fields dropped by a view are required=False test_schema.py::test_override_request_body_differs_from_its_response, test_gradebook_list_is_the_page_envelope (required {username, percent}), test_grading_policy_operation, GradingPolicyUnnamedSubsectionTest
0026 met Every v1 check maps to a class (Authorization below); access runs before existence, flag and freeze checks CourseGradeFullViewAccessTest, CourseGradeFullViewParityTest, RestrictedTokenUserFilterParityTest, GradingPolicyAccessTest, GradingPolicyCcxAccessTest, HasGradebookAccess* in test_infrastructure.py, deny tests in every endpoint module
0027 met Full extend_schema; explicit operation ids; request and error examples on N5; the default-authenticator warning is the only v2 warning test_every_operation_id_is_the_declared_one, test_override_examples, test_v2_generates_no_warning_but_the_default_authentication_one, schema gate
0028 partial Layers separated; ViewSet types match the data path; not router-registered (reason above) query-count tests: test_full_list_grows_by_a_fixed_count_per_row, test_list_grows_by_one_query_per_row, *QueryCountTest
0029 met, with library caveat Envelope everywhere; domain types grades/writable-gradebook-disabled, grades/grades-frozen, grades/subsection-unavailable; no exception text assert_error_envelope across modules; test_failure_while_storing_records_nothing; test_failure_while_recomputing_keeps_every_override_and_answers_500; UnmatchedAddressTest; the 400/405/406/415 internal pins
0030 not met for three declared writes Pinned as v1 behaviour; every other read path proven write-free, HEAD included, on both databases *ReadOnlyTest, GradebookColdCohortSettingsTest, ComputedGradeAnonymousIdTest, CourseGradeCohortAssignmentTest, the cohort no-write tests on N3/N4/N7/N8/N9, test_course_without_an_overview_is_not_created_one
0031 met No action family; consolidations keep each legacy check per view CourseGradeFullViewParityTest, assert_views_follow_v1 (N8, with fields per view)
0032 met GradePagination(DefaultPagination) adds only the response schema CourseGradePagingTest, GradebookRowTest.test_paging, schema tests
0033 met FilterSets; excluded_course_roles published as a repeatable array and cohort_id as an integer; unknown ordering gives 400 CourseGradeFilterTest, GradebookFilterTest, test_gradebook_filter_parameter_types
0034 met No authentication_classes on any v2 view test_deactivated_account_*, restricted-token tests on N5 and N9, hygiene H4 clean
0035 n/a No configuration endpoint none
0036 met ?view=full on N1/N2 (staff) and N9; ?view=minimal on N3/N4 and N8 *RepresentationTest, test_minimal_view_reads_the_course_one_level_deep
0037 partial v1 docstring-only; nine v1 operations deprecated: true; DEPR issue, release and guide owed versions gate; syntax-tree comparison (docstrings blanked) identical; test_every_legacy_operation_is_deprecated
0038 met Plan URL table as built; 15 routes checked GradeV2UrlNameTest, GradeV2ResolutionTest, SupersededAddressTest, test_no_route_is_shadowed_by_a_not_found_route, UnmatchedAddressTest, reverse() literal tests, urls gate
OEP-66 met CourseGradeScopingPolicy (staff all, restricted token by org and first user filter as v1, else own rows); FullScopePolicy elsewhere CourseGradeVisibilityTest, RestrictedTokenListTest, test_filters_do_not_widen_a_learners_view
OEP-69 met See the box; hygiene H3 flags CourseGradeScopingPolicy by name, but it implements the library's protocol rather than copying a library class hygiene gate (explained below)

Backward compatibility

  • Versions gate: PASS. v1 changes are class-docstring lines only, and the syntax trees match the base with docstrings blanked. The unversioned legacy files (rest_api/urls.py, rest_api/serializers.py, apps.py, the shared view utils, and the enrollment form and throttle) are protected.
  • URLs gate: PASS. Every base address and URL name still resolves to the same view.
  • Schema gate: PASS, 0 breaking changes. v1 was not in the LMS schema before: its nine operations are newly published, deprecated, and bring 30 generator warnings plus one operation-id collision (api_grades_v1_courses_retrieve, _2), listed under Gate report. So the schema gate is not evidence that v1 is unchanged; the syntax-tree comparison and the untouched v1 tests are.
  • v1 tests: 193, untouched and green.
  • Parity tests compare v1 and v2 on one fixture and fail on any undeclared difference, or on a declared one that doesn't occur. The declared differences are the migration guide below.

Migration guide (v1 → v2)

Everywhere

  • JWT or session instead of Bearer.
  • Page pagination (count, num_pages, current_page, start, next, previous, results; page, page_size up to 100) instead of the cursor.
  • Errors use the standard envelope.
  • Database ids are not published (user_id, id, grade_id, history_user_id); learners are named by username.
  • Parameters: course_id → course_key, module_id/subsection_id → usage_key, assignment → assignment_usage_key, history_record_limit → page_size.

Course grades (E1, E2, E8 → N1, N2)

  • E8's current_grade (0 to 100) becomes percent (0 to 1). This is a unit change, not only a rename.
  • section_breakdown[].sequential_id becomes usage_key, and is null on entries that name no subsection.
  • The breakdown needs ?view=full, for global staff only. Rows carry email and letter_grade with or without it.
  • A single-learner read returns an object, not a one-element array.
  • A single-learner read no longer hides a master's-track learner's email.
  • Row order is by enrollment id; E8 grouped a page by course.
  • A restricted token needs grades:read and an org filter to list rows; a restricted token for a staff service user no longer gets E8's breakdown.
  • Statuses:
    • reading another learner gives 404 (v1: 403);
    • listing with no course gives your own rows (v1: 403);
    • an unknown course gives an empty page (v1: 404).
  • A row whose grade cannot be computed is left out of results but still counted in count, as in v1.

Gradebook entries (E4 → N3, N4)

  • total_users_count and filtered_users_count are removed; count is the filtered total. For "N of M learners", make a second call without filters and page_size=1.
  • A cohort_id naming another course's cohort, or no cohort, gives an empty page (v1: 404).
  • cohort_id=1.5 and other invalid filter values give 400 (v1: 500 or ignored).
  • A missing course gives 403 to callers without access and 404 to callers with it.
  • A deactivated session gives 401 (v1: 403).

Overrides (E5 → N5)

  • Body {"overrides": [{username, usage_key, earned_all_override, possible_all_override, earned_graded_override, possible_graded_override, comment}]}, JSON only, at most 100 items.
  • If any item is invalid, nothing is stored: 400, with errors keyed like overrides[1].username. v1 applied the valid items and returned 202 or 422.
  • The usage key must belong to the course in the address. comment is at most 300 characters.
  • If grade recalculation fails after the overrides are stored, the response is 500 and the overrides stay stored; sending the same batch again recalculates them.

Subsection grades (E7 → N6, N7)

  • The history moves to override_history_records/, paginated, newest first.
  • An unavailable subsection gives 404 grades/subsection-unavailable (v1: 200 success:false).
  • The learner must be enrolled. CCX coaches are admitted.

Grading policy (E3, E6 → N8)

  • E6's can_see_bulk_management becomes bulk_management_enabled, and assignment_types is a list, not a dict.
  • E3's content is ?view=minimal: assignment_type/count/dropped become type/min_count/drop_count, and it adds grade_cutoffs and each type's short_label.
  • E6's all parameter is dropped: it changed load depth, not output.
  • A course with no overview is still served, and no overview is created.

Submission histories (E9 → N9)

  • user → username, location → usage_key, name → display_name, submission_history → submissions.
  • Per-row course_id and course_name are removed; the course is the address.
  • Problem data only with ?view=full.
  • An unknown course gives 404.
  • The nested problems[] and submissions[] are not bounded, as in v1; the page bounds learners only.

In-place edits

File Category Evidence
lms/djangoapps/grades/rest_api/v1/views.py docstring-only deprecation marker syntax trees identical with docstrings blanked; v1 tests untouched
lms/djangoapps/grades/rest_api/v1/gradebook_views.py docstring-only deprecation marker same

Shared service files, not frozen:

  • lms/urls.py: the mount.
  • lms/lib/spectacular.py: the filter admits grades, and the existing hook marks v1 deprecated.
  • lms/lib/tests/test_spectacular.py.

drf-spectacular prefers method docstrings, so the successor text reaches the schema description of one v1 operation; all nine carry deprecated: true.

Error-format decision

Versioned. v1 keeps its bodies, and v2 uses the envelope. v1's main caller is frontend-app-gradebook, which migrates on its own schedule.

Deprecations

All nine /api/grades/v1/ operations are deprecated: true in the LMS schema, and each v1 view's docstring names its successor. The DEPR issue is not filed yet; the v1 docstring markers link a placeholder that is replaced once it exists. Both mounts stay for at least one named release, to be named in the DEPR issue.

Authorization

v1 check v2
E1/E2 JWT_RESTRICTED_APPLICATION_OR_USER_ACCESS, scope grades:read COURSE_GRADE_READ_ACCESS (endpoint), plus CourseGradeScopingPolicy (rows: staff all, restricted token by org and first user filter, else own)
E8 IsStaff, plus the middleware's NotJwtRestrictedApplication HasGradeBreakdownAccess on ?view=full: IsAdminUser & NotJwtRestrictedApplication
E4/E5/E6 course_author_access_required (with #39145's CCX branch) HasGradebookAccess (imports v1's CCX check)
E4/E5 verify_course_exists, verify_writable_gradebook_enabled CourseExists, WritableGradebookEnabled, after access
E5 are_grades_frozen GradesNotFrozen, last
E7 inline has_course_author_access (no CCX branch) HasGradebookAccess, which gains CCX, plus enrollment of the named learner
E3 inline has_access('staff') COURSE_GRADING_POLICY_READ_ACCESS = IsAuthenticated & (HasCourseStaffAccess | HasGradebookAccess), plus HasGradebookAccessUnlessMinimal so the default view keeps E6's access
E9 IsStaff, throttle, can_disable_rate_limit IsAdminUser, same throttle and switch

Deny-path tests cover anonymous callers, learners, staff of another course, CCX outsiders, restricted tokens with and without scope, deactivated sessions, and, for callers without access, whether a course exists or which flags are set.

Gate report

run_gates.sh --base <#39078 rebased onto master> --service lms --prefix /api/grade/v2/, run in a Tutor dev LMS container (Python 3.12, edx-drf-extensions 10.9.0). No gate was skipped.

versions   PASS  v1 edits are docstring-only; the protected legacy files are untouched
hygiene    FAIL  4 H1 on Tutor's container-only envs/tutor settings (not in this diff);
                 1 H3 on CourseGradeScopingPolicy (see notes); 1 H3 WARN on GradePagination
schema     PASS  0 BREAKING, 46 WARN, 18 INFO
urls       PASS  0 FAIL, 0 WARN
old-tests  PASS  193 passed (grades v1, unmodified)
tests      PASS  539 passed (grades v2, LMS spectacular, enrollments v2), -Wd

The schema gate's 46 warnings:

  • 14 are the default-authenticator warning on the nine v2 operations;
  • 30 are v1's generator warnings (no serializers on frozen v1 views);
  • 1 is the v1 operation-id collision;
  • 1 is v1's bulk-update having no documented request body.

Hygiene notes.

  • H3 flags CourseGradeScopingPolicy by name. It implements the library's ScopingPolicy protocol through ScopedQuerysetMixin, which is the intended use.
  • H3 warns on GradePagination, the documented stopgap.
  • In a Tutor container, H1 also reports Tutor's bind-mounted envs/tutor settings. Those are untracked and not in this diff.

Query counts (v1 → v2, same fixture)

Read v1 v2
N1 list 17 13
N1 ?view=full at 5 / 10 rows 50 / 85 (+7 per row) 46 / 76 (+6 per row)
N2 11 9
N3 at 5 / 10 rows 33 / 43 (+2 per row) 27 / 32 (+1 per row, the authorization policy-version read)
N4 17 15
N5 (one item) 48 49
N6 / N7 12 9 / 10
N8 default / minimal 12 / 9 14 / 9
N9 223 14

v1 cached the gradebook's unfiltered user count for an hour. v2 counts the filtered page on every request, and the staff cross-course N1 list counts all active enrollments.

Follow-ups

  • edx-drf-extensions:
    • a pagination response schema;
    • cataloguing the DRF built-in errors (parse, 405, 406, 415);
    • schema extensions for the default authentication classes;
    • the error handler logging tracebacks and rolling back ATOMIC_REQUESTS when it converts an exception to a 500;
    • nested per-item validation errors;
    • a JSON 404 catch-all view;
    • turning restricted-token org and user filters into a queryset filter.
  • Issues:
    • read-path writes (cohort settings, anonymous ID, cohort assignment), not filed yet;
    • the v1 subsection endpoint's CCX gap (cc feat: change in endpoint to support CCX id #39145's author);
    • frontend-app-gradebook calling bulk-update/history/, which matches no route.
  • SDK: course grades and grading policy carry the openedx-platform-sdk tag, so the SDK needs regenerating.
  • v1 removal: first move what v2 imports from v1: the CCX predicate, the problem-listing helper, the test mixin, and the patch targets in v2 tests.

…ons (ADR 0038)

Register the CourseKeyConverter / UsageKeyConverter path converters — added to edx-drf-extensions 10.8.0 (openedx/edx-drf-extensions#573) per ADR 0038's 'Code examples' section — once per service in lms/urls.py and cms/urls.py, as <course_key:...> / <usage_key:...>. ADR 0038 rule 9: conforming routes resolve opaque keys in the URLconf, views receive parsed keys, and malformed or deprecated (Org/Course/Run, i4x://) keys become routing-level 404s.

Bumps edx-drf-extensions 10.7.0 -> 10.8.0, the release that adds the converters (plus the ADR 0029/0032/0036 building blocks this API series already consumes). Converter unit tests live in the library; the per-API URL tests in the following commits cover resolve/reverse integration through the real routes.
Mount the conforming routes beside the legacy /api/contentstore/v1/xblock/ ones (OEP-21), serving the same XblockViewSet: the collection becomes plural (rule 2), the API name describes the domain rather than the implementing Django app (rule 3), the usage key is resolved by the shared usage_key converter, which turns malformed and deprecated i4x:// keys into routing-level 404s (rule 9), and URL names are snake_case, version-free, and unique (rule 11). The viewset's initial() coerces a parsed UsageKey back to the string form the action methods expect, so both mounts share one contract.

The legacy routes stay live for their deprecation window and are marked deprecated: true in the OpenAPI schema via the new cms_mark_migrated_paths post-processing hook; cms_api_filter now also admits /api/authoring/ paths. Tests pin reverse() literals, same-view resolution for both mounts, the routing-level 404, and handler parity on the conforming routes.

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

Mount the conforming home/, home/courses/, and home/libraries/ routes at /api/authoring/v3/ beside the legacy /api/contentstore/v3/home/ ones (OEP-21), serving the same HomeViewSet, with snake_case version-free URL names (rule 11) and the domain-named api_name (rule 3).

home is a BFF aggregate for the Studio home screen. Rule 4 disfavors screen names as resources, but the ADR's BFF provision applies: the surface keeps the /api/ prefix and one canonical conforming mount, and is marked x-internal in the OpenAPI schema — on both mounts — so clients can tell it apart from a stable resource contract. The legacy routes are additionally marked deprecated: true. Tests pin reverse() literals, same-view resolution for all three action pairs, and the ADR 0029 envelope on the conforming mount.
Mount the conforming courses/ collection at /api/authoring/v4/ beside the legacy /api/contentstore/v4/home/courses/ route (OEP-21), serving the same HomeCoursesViewSet: the screen-shaped home/courses/ address becomes the concrete plural collection of authorable courses (rule 4), filtered, sorted, and paginated in the query string, under the domain-named api_name (rule 3) with a snake_case version-free URL name (rule 11).

The legacy route stays live for its deprecation window and is marked deprecated: true in the OpenAPI schema. Tests pin the reverse() literal, same-view resolution for both mounts, and 401/200 contract parity on the conforming mount.
Mount the conforming /api/authoring/v3/courses/{course_key}/details/ route beside the legacy /api/contentstore/v3/course_details/{course_id}/ one (OEP-21), serving the same CourseDetailsViewSet: the screen-shaped collection becomes a sub-resource of the plural courses/ collection, one level deep — the ADR's own target for these endpoints (rules 4 and 8) — with the course key resolved by the shared course_key converter, which turns malformed and deprecated Org/Course/Run keys into routing-level 404s (rule 9).

resolve_course_key() now also accepts an already-parsed CourseKey, so both mounts funnel through one code path and share one contract. The legacy route stays live for its deprecation window and is marked deprecated: true in the OpenAPI schema. Tests pin the reverse() literal, same-view resolution, the routing-level 404, and 401/403 parity on the conforming mount.
Mount the conforming /api/authoring/v3/courses/{course_key}/grading/ route beside the legacy /api/contentstore/v3/authoring_grading/{course_key}/ one (OEP-21), serving the same AuthoringGradingViewSet: the app-flavored authoring_grading collection becomes the grading sub-resource of the plural courses/ collection, one level deep (rules 3, 4 and 8) — the authoring_ prefix is dropped because the namespace already says it — with the course key resolved by the shared course_key converter (rule 9). Both mounts funnel through resolve_course_key(), which already accepts parsed keys, so they share one contract.

The legacy route stays live for its deprecation window and is marked deprecated: true in the OpenAPI schema. Tests pin the reverse() literal, same-view resolution, the routing-level 404, and 401/200 PATCH parity on the conforming mount.
…nforming URL names)

/api/enrollment/v2/ already conforms in API name and version position; this fixes the remaining rule 6 and rule 11 violations. Conforming routes are dual-mounted (OEP-21) beside the legacy slashless ones, serving the same views: GET /enrollments/ (the admin list's optional-slash pattern — the ADR's own rule 6 example — is split into an exact slashed route plus a slashless legacy route, so every address that resolved before still resolves), GET /enrollments/{username},{course_key}/ under the plural collection (rule 2), GET /courses/{course_key}/ (plural, slashed), and roles/ renamed from the versioned kebab-case enrollment-v2-roles to user_roles (rule 11; path unchanged). Conforming member routes resolve course keys with the shared course_key converter (rule 9); the views coerce a parsed CourseKey back to the string form their bodies expect, so both mounts share one contract.

The two legacy retrieve forms no longer share one URL name — Django resolved that only by argument signature, the fragility rule 11 calls out — and the slashless legacy addresses are marked deprecated: true in the OpenAPI schema via a post-processing hook scoped to /v2/ (deprecating v1 is its own DEPR decision). Deeper ADR 0038 targets — collapsing the singular enrollment/ collection into enrollments/, replacing the unenroll verb (rule 10) with DELETE on the member address, and addressing the requesting user as me (rule 9) — are contract changes and belong to a future v3 per ADR 0037.

Tests pin the reverse() literals, same-view resolution for every legacy/conforming pair, the optional-slash coverage split, the unique legacy names, the routing-level 404, and 401 parity on both admin-list addresses.
…igration

Review feedback: the new authoring_urls.py module docstrings restated ADR 0038's
rules rather than describing the module, and several comments repeated what the
commits already say. The three urls.py modules now carry a one-line docstring
matching their siblings in the same package, the per-rule conformance lists and
the "same view, same contract" notes are gone, and the test-class docstrings,
Enrollment v2 docstring, and spectacular helpers are trimmed; comments that
prevent a mistake are kept but shortened (conforming routes pass a parsed
CourseKey/UsageKey where legacy routes pass the raw string; POSTPROCESSING_HOOKS
replaces rather than extends drf-spectacular's default list). Prose only — with
docstrings stripped, all 19 files parse to ASTs identical to the previous
revision, ruff passes, and no view or serializer docstring is touched.
Review feedback on openedx#39037: the code should follow the standards without naming
the documents. The three authoring urls.py modules now read "Authoring API vN
URLs.", the URL-structure test banners and the Enrollment v2 module docstring
lose their rule references, and the remaining comments say what the code does
instead — deprecation window rather than OEP-21 window, "whose course_key path
converter hands views a parsed key" rather than a rule number. Only lines this
branch adds are touched; the pre-existing ADR references elsewhere in these
files are left alone, the one exception being the ADR 0028 line in the
Enrollment v2 module docstring, which this branch was already rewriting.

Prose only apart from one assert message in the new URL-structure test, which
now reads "missing error-envelope field". ruff passes.
…refix

SCHEMA_PATH_PREFIX_TRIM is a boolean that we had set to a string in three
places; it only worked because the string was truthy. More importantly the
trimming itself produced wrong URLs: the LMS spec advertised /v2/enrollments/
against a bare LMS_ROOT_URL server, and adding /api/authoring/ to the CMS spec
left those paths untrimmed beside trimmed contentstore ones. Widening the
prefix regex would collapse /api/contentstore/v3/home/ and
/api/authoring/v3/home/ onto one key. Drop the trim, drop the CMS-contentstore
server that existed only to compensate, keep SCHEMA_PATH_PREFIX for tag
extraction, and update both post-processing hooks to full paths. Adds tests
for the hooks, which had none.
…iew actions

Review feedback on the dual mounts. A legacy mount and its conforming mount
serve the same view and tokenize to the same operationId, so drf-spectacular
was breaking the tie with a numeral suffix in registration order: the CMS
schema put _2 on the legacy path and the LMS schema put it on the conforming
one, so regenerating the SDK would have renamed modules by accident. Each
service now has an AutoSchema subclass wired in as DEFAULT_SCHEMA_CLASS that
suffixes the legacy address with _legacy and leaves the conforming address
with the clean id, so a regenerated SDK follows the same function name onto
the non-deprecated address. The two Studio home viewsets carry their own
schema instances, which bypass the default, so those are rebased onto the CMS
class as well; tests pin the ids for each pair, assert the default wiring,
and assert the per-view schemas chain the CMS class. The LMS legacy predicate
is shared between the schema class and the deprecation hook.

Two URL names had been dropped although every address was kept:
enrollment-v2-roles and the course-only form of enrollment-v2-retrieve. Both
are registered again on their paths after the new names, so reverse() keeps
working for out-of-tree callers and resolution stays on the first entry.

The route-sharing tests compared .func.cls, which is the same object for a
ViewSet whatever actions a mount wires up; they now compare .func.actions as
well, and the v1 test covers the list route too.
Admit /api/grade/v2/ and /api/grades/v1/ to the LMS OpenAPI document,
and mark every /api/grades/v1/ operation deprecated through the
existing post-processing hook instead of adding a second one.
Grades v1 had no standardized endpoint: bare error bodies, cursor
pagination, Bearer auth, numeric-id addresses, verb paths and
200 {"success": false}. Fixing any of it changes the contract, so v2
is a new version and v1 stays mounted as it is.

Nine operations replace the nine v1 routes: course grades (with the
staff-only section breakdown as ?view=full), gradebook entries,
all-or-nothing subsection grade overrides, subsection grades and their
override history, the grading policy, and submission histories. Every
v1 permission check maps to a permission class, and tests compare v2
with v1 caller by caller, CCX included. Parity tests compare v1 and v2
responses and fail on any undeclared difference.
Docstring-only markers on the nine v1 views, naming each successor.
No statement, decorator or import changes; the syntax trees match
the base with docstrings blanked.
@openedx-webhooks openedx-webhooks added the open-source-contribution PR author is not from Axim or 2U label Sep 30, 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.

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.

3 participants