Repository navigation
feat: standardize the Grades API as v2 at /api/grade/v2/ - #39172
taimoor-ahmed-1 wants to merge 6 commits into
Conversation
|
Thanks for the pull request, @taimoor-ahmed-1! This repository is currently maintained by Once you've gone through the following steps feel free to tag them in a comment and let them know that your changes are ready for engineering review. 🔘 Get product approvalIf you haven't already, check this list to see if your contribution needs to go through the product review process.
🔘 Provide contextTo help your reviewers and other members of the community understand the purpose and larger context of your changes, feel free to add as much of the following information to the PR description as you can:
🔘 Get a green buildIf one or more checks are failing, continue working on your changes until this is no longer the case and your build turns green. DetailsWhere can I find more information?If you'd like to get more details on all aspects of the review process for open source pull requests (OSPRs), check out the following resources: When can I expect my changes to be merged?Our goal is to get community contributions seen and reviewed as efficiently as possible. However, the amount of time that it takes to review and merge a PR can vary significantly based on factors such as:
💡 As a result it may take up to several weeks or months to complete a review and merge your PR. |
d08b9e0 to
2f62d22
Compare
feanil
left a comment
There was a problem hiding this comment.
This PR is pretty hard to read with all the other changes from the other PR in the file changes list, can you make the other PR the base or use PR stacks to make it easier to review?
| # The one legacy address that differs from its conforming twin only by the | ||
| # trailing slash, so both tokenize to the same operationId. The other legacy | ||
| # addresses already have distinct ids and keep them. | ||
| COLLIDING_LEGACY_PATH = "/api/enrollment/v2/enrollments" |
There was a problem hiding this comment.
The nine v1 operations are newly published here, and GET /api/grades/v1/courses/ and GET /api/grades/v1/courses/{course_id}/ tokenize to the same operationId. drf-spectacular breaks the tie in registration order, so the second one is published as api_grades_v1_courses_retrieve_2. That is the same collision the Enrollment v2 pair has.
Add /api/grades/v1/courses/{course_id}/ to the paths LmsAutoSchema.get_operation_id suffixes with _legacy, and update the comment above it. I generated the LMS schema with that change and the ids come out as api_grades_v1_courses_retrieve and api_grades_v1_courses_retrieve_legacy.
There was a problem hiding this comment.
Thanks — fixed in 5b83e8d. /api/grades/v1/courses/{course_id}/ is now in COLLIDING_LEGACY_PATHS, so LmsAutoSchema suffixes it _legacy, and the comment above it covers both collisions. A new test in lms/lib/tests/test_spectacular.py generates the schema for the two routes and pins api_grades_v1_courses_retrieve / api_grades_v1_courses_retrieve_legacy; against the previous hook it reproduces api_grades_v1_courses_retrieve_2.
| from lms.djangoapps.courseware.access import has_access | ||
| from lms.djangoapps.grades.api import is_writable_gradebook_enabled | ||
| from lms.djangoapps.grades.grade_utils import are_grades_frozen | ||
| from lms.djangoapps.grades.rest_api.v1.gradebook_views import _has_ccx_gradebook_access |
There was a problem hiding this comment.
nit: _has_ccx_gradebook_access is a private name in the v1 views, and services.py::submission_history_rows also calls SubmissionHistoryView.get_problem_blocks. Not a blocker but while you're here, you could move both to a shared module now so v1 can be removed without touching v2.
There was a problem hiding this comment.
Done in 16a7bbb: both now live in an unversioned lms/djangoapps/grades/rest_api/helpers.py (has_ccx_gradebook_access, get_problem_blocks), and v2 code imports nothing from v1. I left v1's own copies in place rather than making v1 import the shared ones, because v1 is frozen and its tests patch v1.gradebook_views.get_course_by_id; a true move would have meant editing a v1 test. The duplication ends when v1 is removed. The v2 tests that compare v1 and v2 on a CCX course now patch both lookups.
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.
pylint's missing-function-docstring check flagged three helpers in the v2 grade tests.
GET /api/grades/v1/courses/ and GET /api/grades/v1/courses/{course_id}/
tokenize to the same operationId, so drf-spectacular published the second
as api_grades_v1_courses_retrieve_2, depending on registration order. The
detail address is now suffixed _legacy, as the colliding Enrollment v2
address already is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ades versions Grades v2 imported _has_ccx_gradebook_access from the v1 gradebook views and called SubmissionHistoryView.get_problem_blocks, so removing v1 would have meant editing v2. Both now live in lms/djangoapps/grades/rest_api/helpers.py, which v2 imports. v1 keeps its own copies, unchanged, because it is frozen until it is removed. The tests that compare v1 with v2 on a CCX course patch both lookups of the master course. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
e66d32d to
16a7bbb
Compare
|
@feanil thanks for the review. #39078 has merged, so I rebased this branch onto master: the diff now holds only the Grades changes (the four original commits, plus two for your inline comments, 5b83e8d and 16a7bbb). The description is updated to match, including a note that the gate results predate the rebase. Could you take another look? |
Summary
This PR standardizes the Grades functional area (#39057, umbrella #38137) as a new version,
/api/grade/v2/, following the API standardization ADRsdocs/decisions/0025to0038, 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:
usernameand opaque keys instead of numeric ids, and no verbs in paths;200 {"success": false}becomes a real 404;Changes like these go in a new version, not the current one.
Builds on #39078 (URL structure), which has merged. This branch is rebased onto master and uses #39078's
register_url_converters(), schema path settings and LMS deprecation hook, adding no parallel ones. Every commit on the branch belongs to this PR.Change table
/api/grade/v2/and/api/grades/v1/are admitted to the LMS schema; all nine v1 operations are marked deprecatedGET course_grades/,GET course_grades/{username},{course_key}/courses/,courses/{course_id}/,section_grades_breakdown/?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 ownGET courses/{course_key}/gradebook_entries/[{username}/]gradebook/{course_id}/?view=minimal. Invalid filter values give 400 (v1 gave 500)POST courses/{course_key}/subsection_grade_overrides/gradebook/{course_id}/bulk-updateoverrides[1].usernameGET subsection_grades/{username},{usage_key}/,.../override_history_records/subsection/{subsection_id}/GET courses/{course_key}/grading_policy/policy/courses/{course_id}/,gradebook/{course_id}/grading-info?view=minimalkeeps the policy endpoint's access and content. A read never creates a course overviewGET courses/{course_key}/submission_histories/submission_history/{course_id}/?view=full; an unknown course gives 404ADRs applied
help_texton every field, nullable and view-dependent fields marked, and agrade_v2.*ref_nameon each@extend_schemaand 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)path()routes, but not router-registered. A DRF router names routescourse_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's200 success:falsebecomes 404; no exception text in any body. Until the library catalogues them, a malformed JSON body (400), 405, 406 and 415 report typeinternal(library follow-up); tests pin this and flip when the pin moves?view=full) can assign an uncohorted learner to a cohort, writing two cohort tables and sendingCOHORT_MEMBERSHIP_CHANGEDand two tracking events. The tracking issue is not filed yetbulk-updatebecomes 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 showorderingallow-list with an id tie-break, and no parameter aliases in v2?view=fullor?view=minimalwhere a representation is heavy; view-dependent fields are optional in the schema; other values give 400ScopingPolicyprotocol;FullScopePolicyon the other lists; filters only narrowCompliance matrix
help_texton every field;allow_nullwhere values can be null (for exampledisplay_nameon an unnamed subsection); fields dropped by a view arerequired=Falsetest_schema.py::test_override_request_body_differs_from_its_response,test_gradebook_list_is_the_page_envelope(required{username, percent}),test_grading_policy_operation,GradingPolicyUnnamedSubsectionTestCourseGradeFullViewAccessTest,CourseGradeFullViewParityTest,RestrictedTokenUserFilterParityTest,GradingPolicyAccessTest,GradingPolicyCcxAccessTest,HasGradebookAccess*intest_infrastructure.py, deny tests in every endpoint moduleextend_schema; explicit operation ids; request and error examples on N5; the default-authenticator warning is the only v2 warningtest_every_operation_id_is_the_declared_one,test_override_examples,test_v2_generates_no_warning_but_the_default_authentication_one, schema gatetest_full_list_grows_by_a_fixed_count_per_row,test_list_grows_by_one_query_per_row,*QueryCountTestgrades/writable-gradebook-disabled,grades/grades-frozen,grades/subsection-unavailable; no exception textassert_error_envelopeacross modules;test_failure_while_storing_records_nothing;test_failure_while_recomputing_keeps_every_override_and_answers_500;UnmatchedAddressTest; the 400/405/406/415internalpins*ReadOnlyTest,GradebookColdCohortSettingsTest,ComputedGradeAnonymousIdTest,CourseGradeCohortAssignmentTest, the cohort no-write tests on N3/N4/N7/N8/N9,test_course_without_an_overview_is_not_created_oneCourseGradeFullViewParityTest,assert_views_follow_v1(N8, with fields per view)GradePagination(DefaultPagination)adds only the response schemaCourseGradePagingTest,GradebookRowTest.test_paging, schema testsexcluded_course_rolespublished as a repeatable array andcohort_idas an integer; unknownorderinggives 400CourseGradeFilterTest,GradebookFilterTest,test_gradebook_filter_parameter_typesauthentication_classeson any v2 viewtest_deactivated_account_*, restricted-token tests on N5 and N9, hygiene H4 clean?view=fullon N1/N2 (staff) and N9;?view=minimalon N3/N4 and N8*RepresentationTest,test_minimal_view_reads_the_course_one_level_deepdeprecated: true; DEPR issue, release and guide owedtest_every_legacy_operation_is_deprecatedGradeV2UrlNameTest,GradeV2ResolutionTest,SupersededAddressTest,test_no_route_is_shadowed_by_a_not_found_route,UnmatchedAddressTest,reverse()literal tests, urls gateCourseGradeScopingPolicy(staff all, restricted token by org and first user filter as v1, else own rows);FullScopePolicyelsewhereCourseGradeVisibilityTest,RestrictedTokenListTest,test_filters_do_not_widen_a_learners_viewCourseGradeScopingPolicyby name, but it implements the library's protocol rather than copying a library classBackward compatibility
rest_api/urls.py,rest_api/serializers.py,apps.py, the shared view utils, and the enrollment form and throttle) are protected.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.Migration guide (v1 → v2)
Everywhere
count,num_pages,current_page,start,next,previous,results;page,page_sizeup to 100) instead of the cursor.user_id,id,grade_id,history_user_id); learners are named byusername.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)
current_grade(0 to 100) becomespercent(0 to 1). This is a unit change, not only a rename.section_breakdown[].sequential_idbecomesusage_key, and isnullon entries that name no subsection.?view=full, for global staff only. Rows carryemailandletter_gradewith or without it.grades:readand an org filter to list rows; a restricted token for a staff service user no longer gets E8's breakdown.resultsbut still counted incount, as in v1.Gradebook entries (E4 → N3, N4)
total_users_countandfiltered_users_countare removed;countis the filtered total. For "N of M learners", make a second call without filters andpage_size=1.cohort_idnaming another course's cohort, or no cohort, gives an empty page (v1: 404).cohort_id=1.5and other invalid filter values give 400 (v1: 500 or ignored).Overrides (E5 → N5)
{"overrides": [{username, usage_key, earned_all_override, possible_all_override, earned_graded_override, possible_graded_override, comment}]}, JSON only, at most 100 items.errorskeyed likeoverrides[1].username. v1 applied the valid items and returned 202 or 422.commentis at most 300 characters.Subsection grades (E7 → N6, N7)
override_history_records/, paginated, newest first.grades/subsection-unavailable(v1: 200success:false).Grading policy (E3, E6 → N8)
can_see_bulk_managementbecomesbulk_management_enabled, andassignment_typesis a list, not a dict.?view=minimal:assignment_type/count/droppedbecometype/min_count/drop_count, and it addsgrade_cutoffsand each type'sshort_label.allparameter is dropped: it changed load depth, not output.Submission histories (E9 → N9)
user→username,location→usage_key,name→display_name,submission_history→submissions.course_idandcourse_nameare removed; the course is the address.dataonly with?view=full.problems[]andsubmissions[]are not bounded, as in v1; the page bounds learners only.In-place edits
lms/djangoapps/grades/rest_api/v1/views.pylms/djangoapps/grades/rest_api/v1/gradebook_views.pyShared 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 aredeprecated: truein 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
JWT_RESTRICTED_APPLICATION_OR_USER_ACCESS, scopegrades:readCOURSE_GRADE_READ_ACCESS(endpoint), plusCourseGradeScopingPolicy(rows: staff all, restricted token by org and first user filter, else own)IsStaff, plus the middleware'sNotJwtRestrictedApplicationHasGradeBreakdownAccesson?view=full:IsAdminUser & NotJwtRestrictedApplicationcourse_author_access_required(with #39145's CCX branch)HasGradebookAccess(the CCX check in the sharedlms/djangoapps/grades/rest_api/helpers.py; v1 keeps its own copy until it is removed)verify_course_exists,verify_writable_gradebook_enabledCourseExists,WritableGradebookEnabled, after accessare_grades_frozenGradesNotFrozen, lasthas_course_author_access(no CCX branch)HasGradebookAccess, which gains CCX, plus enrollment of the named learnerhas_access('staff')COURSE_GRADING_POLICY_READ_ACCESS = IsAuthenticated & (HasCourseStaffAccess | HasGradebookAccess), plusHasGradebookAccessUnlessMinimalso the default view keeps E6's accessIsStaff, throttle,can_disable_rate_limitIsAdminUser, same throttle and switchDeny-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.These results are from before the rebase onto master and the review fixes, and have not been re-run since. After them, on the host: the versions gate (base
master) gives PASS with only the two docstring-only v1 files allowed, hygiene gives the same 1 H3 FAIL and 1 H3 WARN explained below,ruffis clean on the changed files, andlms/lib/tests/test_spectacular.pypasses (8 tests). The Grades v2 and v1 suites need the full LMS test environment and are left to CI.The schema gate's 46 warnings:
/api/grades/v1/courses/{course_id}/is now suffixed_legacy, like the colliding Enrollment v2 address;Hygiene notes.
CourseGradeScopingPolicyby name. It implements the library'sScopingPolicyprotocol throughScopedQuerysetMixin, which is the intended use.GradePagination, the documented stopgap.envs/tutorsettings. Those are untracked and not in this diff.Query counts (v1 → v2, same fixture)
?view=fullat 5 / 10 rowsv1 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
ATOMIC_REQUESTSwhen it converts an exception to a 500;bulk-update/history/, which matches no route.openedx-platform-sdktag, so the SDK needs regenerating.rest_api/helpers.py), so v2 code imports nothing from v1. What remains is in tests: the test mixin, and the v1 patch targets in the tests that compare v1 with v2.