Repository navigation
feat: standardize Video Management API into authoring v2 - #39186
Open
Abdul-Muqadim-Arbisoft wants to merge 8 commits into
Open
Abdul-Muqadim-Arbisoft wants to merge 8 commits into
Abdul-Muqadim-Arbisoft wants to merge 8 commits into
Conversation
The Studio schema trimmed /api/contentstore from every path and offered a server that re-added it, so an operation under any other API name could not be published at an address a client can call. Publish every path in full against the service-root servers and drop the contentstore server. SCHEMA_PATH_PREFIX stays, so every existing operationId and tag is unchanged. Admit /api/authoring/ in the pre-processing filter, and add a post-processing hook that marks the operations listed in SUPERSEDED_PATH_PREFIXES deprecated. The list starts empty; each successor adds its entry in the change that mounts it. Register the shared opaque-key path converters before autodiscovery, so routes can parse course keys in the path rather than in the view. Part of openedx#39071.
GET /api/authoring/v2/courses/{course_key}/video_usages/{edx_video_id}/
returns where a video is used in a course, with the same body as
GET /api/contentstore/v1/videos/{course_id}/{edx_video_id}/usage. The
v1 endpoint answers errors as {developer_message, error_code}, accepts
Bearer tokens and inactive session users, and checks access inline
after looking up the course; changing any of that in place would break
its callers.
The new endpoint uses the standard error envelope, the platform's
default authentication, and view-level permissions (IsAuthenticated,
HasFullCourseKey, HasStudioReadAccess) that run before the course
lookup, so a refusal no longer reveals whether a course exists.
Addresses under the collection that no route serves, including course
keys the converter refuses, answer with the JSON error envelope rather
than Studio's HTML 404. The v1 operation is marked deprecated in the
schema; its view is unchanged.
Part of openedx#39071.
POST /api/authoring/v2/courses/{course_key}/video_archives/ streams a
zip of chosen videos of a course. It replaces
PUT /api/contentstore/v1/videos/{course_id}/download, whose verb path,
unvalidated body (a malformed request got a 500) and error format
cannot change in place without breaking its callers.
The request body is validated by a serializer and accepted as JSON
only. Every requested URL must still be one of the course's own
encoded-video URLs before anything is fetched, through the existing
helper, and the existing per-user throttle is shared with v1, so both
addresses draw on one budget. The v1 operation is marked deprecated in
the schema; its view is unchanged.
Part of openedx#39071.
This was referenced Oct 4, 2026
The platform's type check runs mypy over this module, and pytz ships no type stubs, so its import failed `make check-types`. datetime.UTC gives the same aware timestamp without the third-party import. Part of openedx#39071.
Resolve the collisions with the landed URL-structure work: - cms/lib/spectacular.py: keep master's four constants, two hooks and CmsAutoSchema; fold the two superseded video operations into SUPERSEDED_PATHS, which matches exact paths, instead of the branch's own startswith prefix constant. - cms/urls.py: drop the duplicate register_url_converters import and call and the duplicate api/authoring/v2/ mount, all of which master already has. - cms/envs/devstack.py, cms/envs/production.py: take master's SPECTACULAR_SETTINGS, which keeps cms_mark_superseded_paths in POSTPROCESSING_HOOKS and already drops SCHEMA_PATH_PREFIX_TRIM and the CMS-contentstore server entry. - cms/djangoapps/contentstore/views/permissions.py: keep master's CanViewPagesAndResources and HasStudioReadAccess; add only HasFullCourseKey. - cms/djangoapps/contentstore/rest_api/v2/authoring_urls.py: register the video usage and archive routes beside master's textbooks and video_settings routes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two superseded video operations now live in SUPERSEDED_PATHS and are marked by cms_mark_superseded_paths, so the hook tests exercise that hook and the settings test requires all three registered hooks rather than two of them. The class a viewset address resolves to says nothing about which actions that mount wired up, so the method-to-action map is pinned with the shared routed_actions helper. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…cument The schema-hook suite pins the whole SUPERSEDED_PATHS set, so the two video addresses are listed there with the youtube pair. The service-wide documentation site is now generated by drf-spectacular: its schema view takes the format without a leading dot, it publishes paths in full rather than stripped of their API prefix, and an operation carries requestBody and per-status content instead of body parameters and a bare schema. The archive assertions read that shape, and check the operation is offered only as a zip. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The video settings suite asserted that the usage and download operations beside the legacy videos listing carry no deprecation mark, which held while neither had a successor. Both have one now, so the suite checks that they are marked and keeps the assertion that the listing is not -- that is what catches a change marking the whole legacy videos prefix instead of the two exact paths. The archive endpoint draws on the video-download throttle, so the rate limit setting names both endpoints and says they share one budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Abdul-Muqadim-Arbisoft
marked this pull request as ready for review
October 14, 2026 08:16
This was referenced Oct 14, 2026
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Resolves #39071 Standardizes the Video Management API (#39071, umbrella #38137) onto two conforming addresses in the authoring API. The existing
/api/contentstore/v1/videos/…endpoints keep working exactly as they do today.GET /api/authoring/v2/courses/{course_key}/video_usages/{edx_video_id}/: where a video is used in a course (alsoHEAD)POST /api/authoring/v2/courses/{course_key}/video_archives/: a zip archive of chosen videos of a courseA thin service module checks that the course exists, then calls the existing
get_video_usage_pathandcreate_video_ziphelpers by import. Neither helper is edited, so the server-side request forgery allowlist on the archive stays in one place. Both addresses share one rate-limit budget through the existingVideoDownloadThrottle.ADRs: 0025, 0026, 0027, 0028, 0029, 0030, 0031, 0032, 0033, 0034, 0035, 0036, 0037, 0038; OEP-69.
Why a new version rather than in place. Each change below breaks a current caller. ADR 0037 therefore puts them on a new version, and the v1 views stay frozen (their module is not in this diff):
type,title,status,detail,instance,errors) instead of{developer_message, error_code};Bearertokens and inactive users over a session are refused (platform-default authentication);POSTto a noun instead of aPUTto…/download. A malformed JSON body gets 400 instead of 500. A body that is not JSON is refused before anything is fetched (Departures, item 1);Org/Course/Runkeys are refused at routing;The usage body and the archive bytes are unchanged. The parity tests compare them byte for byte.
Departures and known limits
parser_classes = (JSONParser,)), so a form-encoded ortext/plainbody gets 415. Amultipart/form-databody gets 400 (errors == {"files": ["This field is required."]}) for a different reason. Studio's tracking middleware readsrequest.POSTbefore the view runs (common/djangoapps/track/middleware.py:66), which consumes the multipart stream, so DRF then parses an empty body. Nothing is fetched in any of these cases. Rather than write media-type negotiation into the view, the 400 is pinned (VideoArchiveMediaTypeTest::test_a_multipart_body_is_refused); it goes red if the middleware stops reading the stream.HasFullCourseKey, a permission class that answers 404. Thecourse_keyconverter in edx-drf-extensions 10.9.0 admits four key forms that name no organization, course or run:course-v1:version@<id>,course-v1:branch@…+version@<id>,ccx-v1:version@<id>+ccx@…andlibrary-v1:version@<id>. Studio's role check raisesInvalidKeyErroron such a key, so every authenticated caller got a 500, global staff included.HasFullCourseKeyruns betweenIsAuthenticatedandHasStudioReadAccess. It raisesNotFoundwhencourse_key.org is None, because such an address holds nothing. An anonymous caller still gets 401. It is a separate class so thatHasStudioReadAccessstays byte-identical to feat: apply FC-0118 ADRs to course content APIs #39157's. It can be removed once the converter refuses these keys (Follow-ups).path()for its own address and are_path()for everything below it: the four…_unmatchedroutes onUnknownRouteView, each listed after the route it backs. An address without the trailing slash is still redirected first. Nothing outside the two collections is claimed (test_the_fallbacks_claim_nothing_outside_the_two_collections). One side effect of<path:course_key>: a deepervideo_usages/video_archivessegment under a course (for examplecourses/<key>/textbooks/video_usages/) also gets this 404, unless an earlier route serves it./api-docs/document (drf-yasg) is not corrected. That document lists the archive operation as201with no description and no request body. It also lists the two superseded v1 operations without adeprecatedflag. The archive view declares no serializer because its response is binary. Fixing the entry would need drf-yasg'sswagger_auto_schema, which ADR 0027 deprecates. Flagging the v1 operations would need a change to the frozen v1 views or to that generator platform-wide. The Authoring API schema (/authoring-api/schema/) carries the correct status, request body and flags.Change table
feat: publish full CMS schema paths and register path convertersregister_url_converters(). The filter admits/api/authoring/. Paths are published in full. Adds thecms_mark_migrated_pathshook, withSUPERSEDED_PATH_PREFIXESstill emptyfeat: add a v2 video usages endpoint to the authoring APIGET …/video_usages/{edx_video_id}/GET /api/contentstore/v1/videos/{course_id}/{edx_video_id}/usageunchangedSUPERSEDED_PATH_PREFIXESentry flags the v1 operationdeprecated: truefeat: add a v2 video archives endpoint to the authoring APIPOST …/video_archives/PUT /api/contentstore/v1/videos/{course_id}/downloadunchangeddeprecated: trueCommits:
cms/envs/{devstack,production}.py), filter and hook (cms/lib/spectacular.py), and the converter registration (cms/urls.py), with the contract tests that need no new route. These include the guard that nothing is flagged before its successor exists.HasStudioReadAccess/HasFullCourseKey,UnknownRouteView, theapi/authoring/v2/mount, and the usage service, serializer, view, schema module and routes, with the usage tests and the usage superseded entry.Commit 3 is the tree the gates ran on. Each commit was also checked on its own: the v2 test modules present at that commit plus the 13 v1 video tests pass (26, 177 and 289), and hygiene, pylint and ruff are clean. Commit 1 carries a guard that no operation is flagged before its successor is mounted.
ADRs applied
help_texton every field; the archive body keeps the legacy shape, is validated (400 instead of 500) and is JSON only; noserializer_classon the archive view (binary response)IsAuthenticated+HasStudioReadAccessreplaceview_auth_classesand the inlinehas_studio_read_accesscheck.HasFullCourseKeysits between them and answers 404 for version-only keys. The archive keeps a read check, as legacy@extend_schemaper operation,ErrorResponseon every 4xx, JSON-only request body, binaryapplication/zipwith its 406, both taggedopenedx-platform-sdk; two library-side gaps: theErrorResponsedescription, and no security schemes, so each 401 names its credentialsViewSetfor the usage member andAPIViewfor the archive operation (nothing is stored). One recorded deviation:path()+.as_view()instead of a routerinternal, an unexpected 500 neither logged nor signalledauthentication_classesdeclaration.Bearerand inactive-session access are dropped; JWT and session are keptdeprecated: true, and this area's entry posted on the umbrella DEPR ticket [DEPR]: Contentstore and enrollment API versions superseded by FC-0118 #39128 (comment). One recorded deviation: no docstring marker (FC-0118 review, OEP-21 timing)/api/authoring/v2/courses/{course_key}/video_usages/{edx_video_id}/and…/video_archives/(thedownloadverb becomes a POST to a noun); four fallback routes return the JSON 404; legacy route names untouchedget_queryset(): data comes from the content store and edxval. The future ORM seam is edxval'sget_videos_for_course, which runsVideo.objects.filter(courses__course_id=…, courses__is_hidden=False)Compliance matrix
"Seen red" means the test was observed failing when the named behaviour was reverted.
CourseVideoUsageSerializer/VideoUsageLocationSerializer. Request:VideoArchiveRequestSerializer/VideoArchiveFileSerializer.help_textstates what the shared helper renders for content without a display name. Archive values are used exactly as sent, and a blank name is accepted as on v1. A missing or non-listfiles, an entry withouturl/name, or anullgets 400 (v1: 500). The archive body is JSON only (Departures, item 1). The archive view has noserializer_classbecause its response is binary; the request serializer reaches the schema only through@extend_schema(request=…)(Departures, item 4)VideoArchiveRequestValidationTest;VideoArchiveMediaTypeTest(seen red whenparser_classesis removed);test_an_address_is_matched_exactly;test_the_name_is_used_exactly_as_sent;test_names_the_content_does_not_set_appear_as_their_field_defaults;CmsSchemaDocumentTest::test_the_archive_request_body_is_declared,test_the_usage_response_is_declared;StudioApiDocsTest::test_the_archive_operation_is_given_no_json_body(seen red whenserializer_classis restored)permission_classes = (IsAuthenticated, HasFullCourseKey, HasStudioReadAccess)on both views, with course existence checked after them.HasFullCourseKeyanswers 404 for the four version-only key forms (Departures, item 2).HasStudioReadAccessis identical to #39157'sVideoUsageAuthorizationTest,VideoArchiveAuthorizationTest;HasStudioReadAccessTest(allow ×3, deny ×6);HasFullCourseKeyTest(404 ×4, pass ×4). Seen red when either class is removed, and whenHasFullCourseKeyis made non-raising or moved after the role check@extend_schema. Binary(200, "application/zip"). Every 4xx$refsErrorResponse(archive: 400, 401, 403, 404, 406, 415, 429). JSON-only request body. The course-key parameter and the 401/403/404 responses come from one module (views/video_schema.py). The filter is widened to the authoring prefix only. The archive's 406 says to sendAccept: application/zip, application/json. The usage operation declares no 406 (its response is JSON; the 406 is pinned in its tests). Gaps (Follow-ups): (a) this diff is the first to publishErrorResponsein the Studio schema, and its description is the library's class docstring; (b) there are nosecuritySchemes, so each 401 names the accepted credentials (JWT <token>or an active session cookie, withX-CSRFTokenfor the archive) and saysBeareris refusedCmsSchemaDocumentTest(test_every_error_response_is_the_error_envelope,test_the_accepted_credentials_are_named,test_each_refusal_names_its_reasons,test_only_the_studio_apis_are_published);VideoArchiveAcceptHeaderTest;CmsSchemaHookTest;CmsSchemaSettingsTestViewSet(retrieve), which walks the course in the content store. Archive:APIView(post); the archive is streamed and never stored. Deviation:path()+.as_view({...})instead of a router, as in ADR 0038's course-nested exampleVideoUsageQueryCountTest,VideoArchiveQueryCountTest(literal counts at both addresses);VideoRoutesTestStandardizedErrorMixincomes first in the MRO of both views and ofUnknownRouteView. Malformed, deprecated and version-only keys, and every unrouted address under the two collections, get the JSON envelope (Departures, item 3). No error type is registered. Library gaps (Follow-ups): (1) a list entry's error is published as Python text ("{'url': [ErrorDetail(…)]}"); (2) every validationdetailis the Python text of the error dict; (3) 405, 406, 415 and malformed JSON are typedinternal; (4) an unexpected failure gets the 500 envelope with nogot_request_exceptionand no logged traceback (v1 does both)assert_error_envelope;VideoArchiveRequestValidationTest(gaps 1–2 pinned exactly);VideoUsageUnexpectedFailureTest,VideoArchiveUnexpectedFailureTest(gap 4);VideoArchiveMediaTypeTest,VideoArchiveAcceptHeaderTest;test_unauthenticated_error_bodies,test_forbidden_error_bodies,test_missing_course_error_bodies,VideoArchiveParityTest::test_error_bodies;test_other_addresses_under_the_collection_are_answered_with_the_error_envelope,test_an_address_below_the_archives_is_answered_with_the_error_envelope,test_the_fallbacks_claim_nothing_outside_the_two_collectionsCourseOverview.course_exists(read only). On GET or HEAD at either address there is no INSERT/UPDATE/DELETE and no signal or Open edX event beyond those every request sends. This holds for every data branch, global staff, a missing course and a refused caller. Celery dispatch is not covered. Disclosed: openedx-authz inserts its one-rowPolicyCacheControlmarker on the first role check against a database that lacks it, once per database, at any Studio endpoint that checks roles (legacy included). The test creates the row insetUp. H8 cannot see the service module, so it is not citedVideoUsageReadOnlyTest(CaptureQueriesContext+ signal spy, 27 cases). Seen red when GET callsget_from_id. Also seen red when it sends an unreceived signal, usessend_robust, sendscourse_published, or publishes an Open edX event; the SQL capture alone misses those four. The bootstrap insert was observed at both addresses in a manual check, not a testauthentication_classeson either view or on the fallback viewtest_the_viewset_does_not_declare_authentication_classes,test_the_view_does_not_declare_authentication_classes;test_bearer_token_is_refused,test_inactive_global_staff_is_refused_over_a_session,test_jwt_caller_succeedson both endpointsrest_api/v2/, and nothing under a pre-existing version edited. The CMS post-processing hook flags the two superseded operations, and only those,deprecated: truein the Authoring API schema./api-docs/is not marked (Deprecations). DEPR: this area's entry is on #39128 (comment), including the removal step forVideoDownloadThrottle. Deviation: no docstring marker (FC-0118 review on #39100/#39102, OEP-21 timing). NoWarningheader, which would change frozen v1test_the_two_legacy_operations_are_deprecated,test_other_operations_are_left_unmarked,test_post_processing_marks_the_two_superseded_operations_deprecated,test_post_processing_leaves_other_resources_alone; old-tests gate PASS (108)authoringAPI name. Plural snake_case collections nested once under the course.course_keyconverter, trailing slash. Route namescourse_video_usage_detail,course_video_archive_listand the four…_unmatchedfallbacksVideoUsageUrlContractTest,VideoArchiveUrlContractTest,VideoRoutesTest; urls gate PASS (2144 → 2150 routes, 6 new checked, 0 FAIL, 0 WARN)get_queryset(). (1) Endpoint access: the three permission classes. (2) Record visibility is the authorized course. Usage: modulestoreget_items(course_key, qualifiers={'category': 'video'}). Archive: the allowlist from edxval'sget_videos_for_course(course_key). (3) User filtering: none.edx_video_idand everyfiles[].urlare matched inside layer 2's set. Future ORM seam: edxval'sget_videos_for_course(Video.objects.filter(courses__course_id=…, courses__is_hidden=False))test_staff_of_another_course_is_refused,HasStudioReadAccessTest,HasFullCourseKeyTest. Layer 2:test_a_video_removed_from_the_course_is_refused,test_the_legacy_address_refuses_a_video_removed_from_the_course(seen red withoutis_hidden), controltest_a_video_removed_from_one_course_is_still_archived_by_a_course_that_keeps_it. Layer 3:test_a_video_of_another_course_is_refused,test_an_address_outside_the_course_is_refused,test_an_address_is_matched_exactly,test_an_unreferenced_video_answers_with_an_empty_listtest_the_two_legacy_operations_are_deprecated;CmsSchemaDocumentTest; 13 v1 tests untouchedBackward compatibility
rest_api/v1/views/videos.pyis not in the diff. Versions gate PASS: 11 files added inside pre-existing version directories (see In-place edits), 0 modified, no allowlist.video_usagestill reverses to…/downloadwithout a video id and to…/usagewith one (VideoRoutesTest). DeprecatedOrg/Course/Runkeys still reach v1.rest_api/v1/views/tests/test_videos.pyand 95 inviews/tests/test_videos.py. The wholecontentstore/rest_api/suite passes (772). The tests gate passes the 276 new tests.comparefails because removing the path trim renames all 56 trimmed base path keys. With the base paths re-prefixed the result is 0 BREAKING / 4 WARN / 4 INFO (Gate report).compareignores operationIds, tags and servers, so each of the 77 base operations was also compared whole with its re-prefixed head counterpart. The only difference isdeprecated: trueon the two superseded operations, and no head operationId is duplicated.componentsgainsCourseVideoUsage,ErrorResponse,VideoArchiveFile,VideoArchiveRequestandVideoUsageLocation, and changes or drops none.test_no_authoring_operation_is_given_the_id_of_anotherchecks operationIds before the generator renames collisions (seen red with a colliding route).INTENTIONAL_DIFFERENCES = []). Usage bodies are byte-identical at both addresses for these cases: one use, three uses, an unpublished unit, a parentless component, an unused id ({"usage_locations": []}), unset display names ("None - None / Video"), and a unit in no subsection. Archives are byte-identical, with the sameContent-TypeandContent-Disposition, for these cases: a named extension, an extension from the upstream content type, an unknown type, several files, and an empty list. A fetch that fails part way stops both archives at the same byte (test_a_fetch_failing_part_way_stops_the_archive). The harness was seen red when the usage result or the file list was altered.ROLLBACK TO SAVEPOINTfrom the standard error handling, not a read.One client- and deployment-facing change: the schema's servers. The CMS schema now publishes every path in full (
SCHEMA_PATH_PREFIX_TRIMis removed). It lists two servers,PublicandLocal. TheCMS-contentstoreserver (<CMS_BASE>/api/contentstore) is dropped. Each server joined with each path, resolved against Studio's URLconf:Local(<CMS_BASE>)CMS-contentstore(<CMS_BASE>/api/contentstore)Public(AUTHORING_API_URL)/api/contentstore(#33694;cms/envs/mock.yml); if empty, same asLocalLocal<CMS_BASE>/api/contentstorekeeps working until it is regenerated. Once regenerated from the head document with that same base URL, it composes/api/contentstore/api/contentstore/…and gets 404 on every call. Its base URL must become<CMS_BASE>.AUTHORING_API_URLto a gateway base must re-point it at the Studio root./api-docs/caveat. Studio's drf-yasg document does not flag the v1 operations and shows an empty201for the archive (Departures, item 4).Differences a caller migrating to v2 will see
developer_message/error_codeabsentBearertokens refused (401); JWT and session acceptedcourse_does_not_exist); global staff still get 404ccx-v1:keys → 403 (v1: 404 for a missing CCX)Org/Course/Runand malformed keys → JSON 404 at routing…/usagegets 404invalid_course_keyand…/downloadgets the HTML 404POSTtovideo_archives/(v1:PUTto…/download). An invalid body gets 400 (v1: 500, or a truncated 200 for a missingname). A form-encoded body gets 415 (v1: 500); a multipart body gets 400WWW-Authenticate; 429 carriesRetry-Aftergot_request_exceptionor a logged traceback (v1 does both)In-place edits
None inside a pre-existing version directory. The versions gate lists these additions inside pre-existing directories (paths under
cms/djangoapps/contentstore/):rest_api/v2/authoring_urls.py,video_management_service.py,views/{video_usages,video_archives,video_schema}.py,serializers/{video_usages,video_archives}.pyv2/already holds unrelated resourcesrest_api/v2/views/tests/test_video_{usages,archives,management_contract}.pyrest_api/v1/views/unknown_route.pyShared files outside any version directory:
cms/urls.pyregister_url_converters()before autodiscovery, byte-identical to #39078/#39102 (a URLconf reload re-registers; Follow-ups), and theapi/authoring/v2/mountcms/lib/spectacular.py^/api/(contentstore|authoring)/v\d+/;SUPERSEDED_PATH_PREFIXESwith exactly the two superseded paths;cms_mark_migrated_pathsCmsSchemaHookTest: superseded operations become exactly{"deprecated": true}, a named fixture of other resources is left as given, and other APIs' paths are refusedcms/envs/production.py,cms/envs/devstack.pyPOSTPROCESSING_HOOKS(enum hook restated), trim removed, servers reduced. TheSCHEMA_PATH_PREFIXcomment says the prefix also derives operationIds (Sequencing)CmsSchemaSettingsTest;CmsSchemaDocumentTest::test_operation_ids;test_enum_components_survive_the_registered_post_processing. Whether the deployment'sAUTHORING_API_URLserves full paths is not testedviews/permissions.pyHasStudioReadAccess(identical to #39157's) andHasFullCourseKeyHasStudioReadAccessTest,HasFullCourseKeyTestError-format decision
Versioned, not in place. v1 keeps
{developer_message, error_code}byte for byte, so no in-place client evidence is owed. The tests list exactly which keys each address returns for 400, 401, 403 and 404.Deprecations
The two operations
GET /api/contentstore/v1/videos/{course_id}/{edx_video_id}/usageandPUT /api/contentstore/v1/videos/{course_id}/downloadare superseded by the addresses above. They are markeddeprecated: truein the Authoring API schema (/authoring-api/schema/, browsable at/authoring-api/ui/). The superseded views are unchanged: no response header and no deprecation notes in code.GET /api/contentstore/v1/videos/{course_id}(course videos) is a different resource and is not marked. The older/api-docs/document does not flag them (Departures, item 4). Readers of that document learn of the deprecation from #39128.Tracked on the FC-0118 umbrella DEPR ticket, #39128, which removes them one named release after the release this ships in. This area's entry, with its removal step: #39128 comment.
ADR 0037's docstring marker is deliberately omitted, following review on #39100 and #39102. If one is wanted later, it is the single OEP-21 line
Deprecated https://github.com/openedx/openedx-platform/issues/39128. The umbrella's step-1Warningheader is not emitted either, because the v1 route is frozen.Authorization
@view_auth_classes(is_authenticated=True)IsAuthenticatedhas_studio_read_access→permission_denied(both views)HasStudioReadAccess, view level. The archive keeps a read check on its POST, as v1 does@verify_course_exists()ensure_course_exists→ 404, after the permission check. Malformed or deprecated keys get a JSON 404 at routingHasFullCourseKey, betweenIsAuthenticatedandHasStudioReadAccess→ 404create_video_zipVideoDownloadThrottle(VIDEO_DOWNLOAD_RATE_LIMIT)courses__course_id,is_hidden=FalseDeny-path tests on both endpoints:
WWW-AuthenticateBearertoken → 401CourseLimitedStaffRole→ 403ccx-v1:key as global staff → 403On the archive, each refusal also asserts that nothing is fetched, and a session POST without a CSRF token gets 403. Allowed callers: global staff, course staff and course instructor. Every 4xx is asserted with
assert_error_envelope.Gate report
run_gates.sh --base origin/master --service cms --prefix /api/authoring/v2/, with the plan's six--protectpaths and no--allow, run in the Studio container on this tree. No gate was skipped. Summary line:GATES FAILED: 1(schema, reconciled below).video_storage_handlers.py,contentstore/rest_api/urls.py,openedx/core/lib/api/view_utils.py,common/djangoapps/student/auth.py,openedx/core/constants.py,course_overviews/models.py# RemovedInDjango5xWarningcomment in{cms,lms}/envs/tutor/{development,production}.py. Tutor writes those files into the container's checkout (untracked); they are not in this diffcheck-present: PASS, 2 paths under/api/authoring/v2/. Rawcompare: FAIL, 56 BREAKING, 16 WARN, 78 INFO. Every BREAKING is a renamed path key (/v0/…→/api/contentstore/v0/…), read bycompareas a removal; 12 of the 16 WARN are pre-existing write operations under renamed keys. Reconciled by re-prefixing the base paths with/api/contentstoreand re-runningcomparewith the base's warnings file alongside. Result: no base path missing; head-only paths are the two new addresses. INFO: the two operations added and the two v1 operations newly deprecated. WARN: "could not resolve authenticator" forDefaultJwtAuthentication/DefaultSessionAuthenticationon the two new views. The base already emits this warning for the same classes on other views.x-internal: none. Servers:CMS-contentstoredroppedThe schema gate stays FAIL as a raw result and is reported as reconciled, not PASS: the reconciliation is the evidence that no operation was removed.
Also run on the same tree: the whole
cms/djangoapps/contentstore/rest_api/suite (772 passed).pylintran on the changed Python files exceptcms/urls.pyandunknown_route.py, with no messages.ruff checkpassed onrest_api/v2/views/and both settings files. CI's other jobs (xsslint, pii_check, import linting, CodeQL) have not run.Follow-ups
v2_courses_video_usages_retrieveandv2_courses_video_archives_create(the v1 operations were never tagged). Three things for that PR. It needscontent_type_overrides: {application/zip: application/octet-stream}so the archive call returnsFile; this was checked on an earlier schema of this branch, not the final one. It must regenerate from a document with this PR's settings shape (feat: add GH workflow to generate openapi schema #39025). Its base URL is<CMS_BASE>.ErrorResponseschema description is the serializer's maintainer docstring. Issue to be filed (text ready).detail(normalize_validation_errors,flatten_detail). The tests here pin today's text. Issue to be filed (text ready).exc_infoand sendgot_request_exception. Issue to be filed (text ready).JwtAuthentication/DefaultSessionAuthentication. Once these exist, the 401 descriptions can be shortened. Issue to be filed (text ready, with the platform half).course_keyconverter should refuse version-only keys. ThenHasFullCourseKeycan go. Issue to be filed (text ready).internal. Issue to be filed (text ready).register_url_converters()idempotent. Django 5.2 warns on each URLconf reload and Django 6.0 raises. Issue to be filed (text ready).Accept: application/zipalone stops getting 406. Issue to be filed (text ready).Content-Dispositionfilename, no upstream fetch timeout, truncated 200 on a failed fetch, and unsanitized or duplicate zip entry names.courses/{course_key}/video_usages/would let the Videos page fetch every video's usage in one call. It is not added here, to keep to the issue's table.VideoDownloadThrottleinto v2 beforerest_api/v1/views/videos.pyis removed, because the v2 archive view imports it from there. It is listed in the [DEPR]: Contentstore and enrollment API versions superseded by FC-0118 #39128 entry as a removal step.Sequencing
The contract tests assert only this PR's own entries, routes and operations, plus a named list of operations that nothing supersedes. Under a simulated union with each sibling below, alone and all together, the 37 contract cases the module then held passed (whole-registry assertions failed 6); the 20 cases added since were not re-run under it.
unknown_route.pyis byte-identical.cms/urls.pyandcms/lib/spectacular.pytake the union (both mounts, both superseded entries). Settings match except one comment line.LEGACY_MIGRATED_PATH_PREFIXESstays separate. Both PRs definecms_mark_migrated_paths, so merging them into one hook is a hand merge.v2/authoring_urls.pyroutes and the mount.permissions.pyis a hand merge, withHasStudioReadAccessbyte-identical. Two semantic conflicts and one note:test_the_legacy_video_operations_are_unchangedgoes red once both PRs land. The fix, in feat: apply FC-0118 ADRs to course content APIs #39157, is to narrow that test to its ownGET /api/contentstore/v1/videos/{course_id}.register_error_types()retypes 405/406 process-wide. The tests here stay green, and 0029 gap 3 would then cover only 415 and malformed JSON.video_settingsview usesHasStudioReadAccesson the same converter, so it would answer 500 for version-only keys (found by reading the code, not run).cms/urls.pyandcms/lib/spectacular.py.SPECTACULAR_SETTINGSincms/envs/common.pywith this shape:SCHEMA_PATH_PREFIXr'/api/(contentstore|authoring)', no trim, andPOSTPROCESSING_HOOKSwith the enum hook andcms_mark_migrated_paths. Otherwise the committeddocs/cms-openapi.yamlmixes short and full paths and has no deprecation flags.When the settings files merge, keep this PR's
SCHEMA_PATH_PREFIXcomment ("…derive its tags and operationId…") over the "Used for tag extraction only" wording in #39078, #39100, #39102 and #39157. The setting itself is identical in all five.