Skip to content

feat: standardize Video Management API into authoring v2 - #39186

Open
Abdul-Muqadim-Arbisoft wants to merge 8 commits into
openedx:masterfrom
edly-io:feat/api-adrs-video-management
Open

Abdul-Muqadim-Arbisoft wants to merge 8 commits into
openedx:masterfrom
edly-io:feat/api-adrs-video-management

Conversation

@Abdul-Muqadim-Arbisoft

@Abdul-Muqadim-Arbisoft Abdul-Muqadim-Arbisoft commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

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 (also HEAD)
  • POST /api/authoring/v2/courses/{course_key}/video_archives/: a zip archive of chosen videos of a course

A thin service module checks that the course exists, then calls the existing get_video_usage_path and create_video_zip helpers 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 existing VideoDownloadThrottle.

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

  • every non-2xx body becomes the standard error envelope (type, title, status, detail, instance, errors) instead of {developer_message, error_code};
  • Bearer tokens and inactive users over a session are refused (platform-default authentication);
  • the download becomes a POST to a noun instead of a PUT to …/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);
  • deprecated Org/Course/Run keys are refused at routing;
  • a caller without Studio access to a course that does not exist gets 403 instead of a 404 that disclosed whether the course exists.

The usage body and the archive bytes are unchanged. The parity tests compare them byte for byte.

Departures and known limits

  1. A multipart archive request gets 400, not 415. The archive view parses JSON only (parser_classes = (JSONParser,)), so a form-encoded or text/plain body gets 415. A multipart/form-data body gets 400 (errors == {"files": ["This field is required."]}) for a different reason. Studio's tracking middleware reads request.POST before 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.
  2. HasFullCourseKey, a permission class that answers 404. The course_key converter 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@… and library-v1:version@<id>. Studio's role check raises InvalidKeyError on such a key, so every authenticated caller got a 500, global staff included. HasFullCourseKey runs between IsAuthenticated and HasStudioReadAccess. It raises NotFound when course_key.org is None, because such an address holds nothing. An anonymous caller still gets 401. It is a separate class so that HasStudioReadAccess stays 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).
  3. Four fallback routes, where the plan had two. Every slash-terminated address under the two collections that no route serves gets the JSON 404 envelope instead of Studio's HTML 404. That covers a refused course key, the usage collection without an id, an empty id, and any path below a usage or below the archives. A single pattern per collection would trip the URL gate (R6/R8). Each collection therefore has a path() for its own address and a re_path() for everything below it: the four …_unmatched routes on UnknownRouteView, 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 deeper video_usages/video_archives segment under a course (for example courses/<key>/textbooks/video_usages/) also gets this 404, unless an earlier route serves it.
  4. Studio's older /api-docs/ document (drf-yasg) is not corrected. That document lists the archive operation as 201 with no description and no request body. It also lists the two superseded v1 operations without a deprecated flag. The archive view declares no serializer because its response is binary. Fixing the entry would need drf-yasg's swagger_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

Commit Endpoint(s) Legacy behaviour kept (v1) New behaviour (authoring v2)
1. feat: publish full CMS schema paths and register path converters all CMS schema paths every route resolves to the same view. Each of the 77 base operations keeps its operationId, tags, parameters, request body and responses register_url_converters(). The filter admits /api/authoring/. Paths are published in full. Adds the cms_mark_migrated_paths hook, with SUPERSEDED_PATH_PREFIXES still empty
2. feat: add a v2 video usages endpoint to the authoring API GET …/video_usages/{edx_video_id}/ GET /api/contentstore/v1/videos/{course_id}/{edx_video_id}/usage unchanged same body. Error envelope, 403 before 404, JSON 404 under the collection. Its SUPERSEDED_PATH_PREFIXES entry flags the v1 operation deprecated: true
3. feat: add a v2 video archives endpoint to the authoring API POST …/video_archives/ PUT /api/contentstore/v1/videos/{course_id}/download unchanged same archive. Typed JSON body (400 instead of 500), error envelope, JSON 404 under the collection, and refused callers spend no throttle slot. Its entry flags the v1 operation deprecated: true

Commits:

  1. Settings (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.
  2. HasStudioReadAccess/HasFullCourseKey, UnknownRouteView, the api/authoring/v2/ mount, and the usage service, serializer, view, schema module and routes, with the usage tests and the usage superseded entry.
  3. The archive service, serializer, view and routes, with the archive tests and the download 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

  • 0025 serializers — request and response serializers, help_text on every field; the archive body keeps the legacy shape, is validated (400 instead of 500) and is JSON only; no serializer_class on the archive view (binary response)
  • 0026 permissions — IsAuthenticated + HasStudioReadAccess replace view_auth_classes and the inline has_studio_read_access check. HasFullCourseKey sits between them and answers 404 for version-only keys. The archive keeps a read check, as legacy
  • 0027 schema / docs — @extend_schema per operation, ErrorResponse on every 4xx, JSON-only request body, binary application/zip with its 406, both tagged openedx-platform-sdk; two library-side gaps: the ErrorResponse description, and no security schemes, so each 401 names its credentials
  • 0028 viewsets — ViewSet for the usage member and APIView for the archive operation (nothing is stored). One recorded deviation: path() + .as_view() instead of a router
  • 0029 errors — met with library gaps: the envelope on every non-2xx, including a JSON 404 for refused or version-only keys and unrouted addresses under the two collections; gaps: Python-text validation errors, request-level errors typed internal, an unexpected 500 neither logged nor signalled
  • 0030 GET idempotence — no write and no extra signal or Open edX event on GET/HEAD at either address (runtime SQL capture and signal spy); one disclosed write outside this code: the authorization engine's one-row cache marker
  • 0031 merged endpoints — nothing merged: a usage record and a generated archive are different resources, not an action-URL family on one resource
  • 0032 pagination — n/a: the usage endpoint is a member retrieve whose list is bounded by one video's uses in one course, and the archive is binary
  • 0033 filtering — n/a: neither endpoint takes a filter or sort parameter. The video id is a path identifier
  • 0034 authentication — no authentication_classes declaration. Bearer and inactive-session access are dropped; JWT and session are kept
  • 0035 MFE config — n/a: no endpoint in this area serves front-end or site configuration
  • 0036 nested JSON — n/a: the usage body is flat and thin (two strings per entry), and the archive is not JSON
  • 0037 versioning — new v2 modules, no pre-existing version file edited, the superseded operations flagged deprecated: 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)
  • 0038 URL structure — /api/authoring/v2/courses/{course_key}/video_usages/{edx_video_id}/ and …/video_archives/ (the download verb becomes a POST to a noun); four fallback routes return the JSON 404; legacy route names untouched
  • OEP-66 queryset scoping — out of scope as a mechanism, applied as a layering. Neither view has a get_queryset(): data comes from the content store and edxval. The future ORM seam is edxval's get_videos_for_course, which runs Video.objects.filter(courses__course_id=…, courses__is_hidden=False)
  • OEP-69 conventions — breaking changes ship as a new version with the old operations deprecated; request, response and every error declared in the schema; authentication named in each 401 description (no security schemes)

Compliance matrix

"Seen red" means the test was observed failing when the named behaviour was reverted.

Decision Status How it is met, or the concrete reason it is excluded Evidence (test / gate)
0025 serializers Met Response: CourseVideoUsageSerializer/VideoUsageLocationSerializer. Request: VideoArchiveRequestSerializer/VideoArchiveFileSerializer. help_text states 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-list files, an entry without url/name, or a null gets 400 (v1: 500). The archive body is JSON only (Departures, item 1). The archive view has no serializer_class because its response is binary; the request serializer reaches the schema only through @extend_schema(request=…) (Departures, item 4) VideoArchiveRequestValidationTest; VideoArchiveMediaTypeTest (seen red when parser_classes is 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 when serializer_class is restored)
0026 permissions Met permission_classes = (IsAuthenticated, HasFullCourseKey, HasStudioReadAccess) on both views, with course existence checked after them. HasFullCourseKey answers 404 for the four version-only key forms (Departures, item 2). HasStudioReadAccess is identical to #39157's VideoUsageAuthorizationTest, VideoArchiveAuthorizationTest; HasStudioReadAccessTest (allow ×3, deny ×6); HasFullCourseKeyTest (404 ×4, pass ×4). Seen red when either class is removed, and when HasFullCourseKey is made non-raising or moved after the role check
0027 schema / docs Met, two gaps outside this diff Per-operation @extend_schema. Binary (200, "application/zip"). Every 4xx $refs ErrorResponse (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 send Accept: 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 publish ErrorResponse in the Studio schema, and its description is the library's class docstring; (b) there are no securitySchemes, so each 401 names the accepted credentials (JWT <token> or an active session cookie, with X-CSRFToken for the archive) and says Bearer is refused CmsSchemaDocumentTest (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; CmsSchemaSettingsTest
0028 viewsets Met, one deviation Usage: ViewSet (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 example VideoUsageQueryCountTest, VideoArchiveQueryCountTest (literal counts at both addresses); VideoRoutesTest
0029 errors Met with library gaps (versioned) StandardizedErrorMixin comes first in the MRO of both views and of UnknownRouteView. 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 validation detail is the Python text of the error dict; (3) 405, 406, 415 and malformed JSON are typed internal; (4) an unexpected failure gets the 500 envelope with no got_request_exception and no logged traceback (v1 does both) every 4xx via 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_collections
0030 GET idempotence Met, one disclosed engine bootstrap The course check uses CourseOverview.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-row PolicyCacheControl marker 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 in setUp. H8 cannot see the service module, so it is not cited VideoUsageReadOnlyTest (CaptureQueriesContext + signal spy, 27 cases). Seen red when GET calls get_from_id. Also seen red when it sends an unreceived signal, uses send_robust, sends course_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 test
0031 merged endpoints Analysed, nothing merged different resources —
0032 pagination Excluded, with reason member retrieve and a binary archive —
0033 filtering Excluded, with reason no filter or sort parameters —
0034 authentication Met no authentication_classes on either view or on the fallback view test_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_succeeds on both endpoints
0035 MFE config Excluded, with reason no configuration endpoint —
0036 nested JSON Excluded, with reason flat and thin; binary —
0037 versioning Met, one recorded deviation New modules in rest_api/v2/, and nothing under a pre-existing version edited. The CMS post-processing hook flags the two superseded operations, and only those, deprecated: true in the Authoring API schema. /api-docs/ is not marked (Deprecations). DEPR: this area's entry is on #39128 (comment), including the removal step for VideoDownloadThrottle. Deviation: no docstring marker (FC-0118 review on #39100/#39102, OEP-21 timing). No Warning header, which would change frozen v1 versions gate PASS (11 added, 0 modified, 0 allowed, 6 protected paths untouched); schema gate FAIL, reconciled → 0 BREAKING / 4 WARN / 4 INFO; test_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)
0038 URL structure Met authoring API name. Plural snake_case collections nested once under the course. course_key converter, trailing slash. Route names course_video_usage_detail, course_video_archive_list and the four …_unmatched fallbacks VideoUsageUrlContractTest, VideoArchiveUrlContractTest, VideoRoutesTest; urls gate PASS (2144 → 2150 routes, 6 new checked, 0 FAIL, 0 WARN)
OEP-66 scoping Excluded as a mechanism, applied as a layering There is no view-level get_queryset(). (1) Endpoint access: the three permission classes. (2) Record visibility is the authorized course. Usage: modulestore get_items(course_key, qualifiers={'category': 'video'}). Archive: the allowlist from edxval's get_videos_for_course(course_key). (3) User filtering: none. edx_video_id and every files[].url are matched inside layer 2's set. Future ORM seam: edxval's get_videos_for_course (Video.objects.filter(courses__course_id=…, courses__is_hidden=False)) layer 1: 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 without is_hidden), control test_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_list
OEP-69 conventions Met Incremental migration: new version, old operations flagged deprecated, v1 frozen. Machine-first contracts: request, response and every error declared. Authentication is named in the 401 descriptions because the document has no security schemes test_the_two_legacy_operations_are_deprecated; CmsSchemaDocumentTest; 13 v1 tests untouched

Backward compatibility

  • No pre-existing version file is modified. rest_api/v1/views/videos.py is not in the diff. Versions gate PASS: 11 files added inside pre-existing version directories (see In-place edits), 0 modified, no allowlist.
  • Every v1 address still resolves to the same view. Urls gate PASS (2144 → 2150 routes). The shared URL name video_usage still reverses to …/download without a video id and to …/usage with one (VideoRoutesTest). Deprecated Org/Course/Run keys still reach v1.
  • Legacy tests untouched and green. The old-tests gate ran 108: 13 in rest_api/v1/views/tests/test_videos.py and 95 in views/tests/test_videos.py. The whole contentstore/rest_api/ suite passes (772). The tests gate passes the 276 new tests.
  • Schema: additions and deprecation flags only. The raw compare fails 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). compare ignores operationIds, tags and servers, so each of the 77 base operations was also compared whole with its re-prefixed head counterpart. The only difference is deprecated: true on the two superseded operations, and no head operationId is duplicated. components gains CourseVideoUsage, ErrorResponse, VideoArchiveFile, VideoArchiveRequest and VideoUsageLocation, and changes or drops none. test_no_authoring_operation_is_given_the_id_of_another checks operationIds before the generator renames collisions (seen red with a colliding route).
  • Parity (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 same Content-Type and Content-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.
  • Query counts (warm caches, legacy/new): usage 11/11 (staff), 13/13 (session), 11/11 (course staff), 8/7 (403), 8/9 (404), 5/6 (401); archive 11/11 (200), 11/12 (400), 8/7 (403), 8/9 (404), 5/6 (401). Every +1 is a ROLLBACK TO SAVEPOINT from 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_TRIM is removed). It lists two servers, Public and Local. The CMS-contentstore server (<CMS_BASE>/api/contentstore) is dropped. Each server joined with each path, resolved against Studio's URLconf:

Server Base: 56 shortened paths + 1 full Head: 59 full paths
Local (<CMS_BASE>) 1 of 57 routable 59 of 59 routable
CMS-contentstore (<CMS_BASE>/api/contentstore) 56 of 57 routable dropped
Public (AUTHORING_API_URL) routable only as a gateway base mapped to /api/contentstore (#33694; cms/envs/mock.yml); if empty, same as Local routable only when it is the Studio root; if empty, same as Local
  • A client generated from the base document against <CMS_BASE>/api/contentstore keeps 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>.
  • A deployment that sets AUTHORING_API_URL to 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 empty 201 for the archive (Departures, item 4).

Differences a caller migrating to v2 will see

Difference Why
error body is the envelope; developer_message / error_code absent ADR 0029
Bearer tokens refused (401); JWT and session accepted ADR 0034
inactive user over a session → 401 (v1: 200 for inactive global staff); over JWT unchanged (200) ADR 0034; pinned both ways
caller without access to a missing course → 403 (v1: 404 course_does_not_exist); global staff still get 404 ADR 0026; permissions run first
ccx-v1: keys → 403 (v1: 404 for a missing CCX) Studio grants no access to CCX courses, and that check runs first
deprecated Org/Course/Run and malformed keys → JSON 404 at routing ADR 0038
version-only course keys (four forms) → JSON 404 when authenticated, 401 when anonymous. On v1, …/usage gets 404 invalid_course_key and …/download gets the HTML 404 ADR 0026; the role check cannot evaluate such a key
archive: POST to video_archives/ (v1: PUT to …/download). An invalid body gets 400 (v1: 500, or a truncated 200 for a missing name). A form-encoded body gets 415 (v1: 500); a multipart body gets 400 ADR 0038, ADR 0025
archive: a caller refused with 403 no longer spends a throttle slot permissions run before throttles
401 carries WWW-Authenticate; 429 carries Retry-After DRF headers kept by the envelope handler
for operators: an unexpected failure gets the 500 envelope without got_request_exception or a logged traceback (v1 does both) edx-drf-extensions error handling (Follow-ups)

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/):

Added file Why it lives there
rest_api/v2/authoring_urls.py, video_management_service.py, views/{video_usages,video_archives,video_schema}.py, serializers/{video_usages,video_archives}.py the new version of these two resources, which are at v1 today. v2/ already holds unrelated resources
rest_api/v2/views/tests/test_video_{usages,archives,management_contract}.py new test modules
rest_api/v1/views/unknown_route.py the view behind the four fallback routes. It is byte-identical to #39102's file at the same path, so the two PRs merge cleanly in either order

Shared files outside any version directory:

File Change Evidence
cms/urls.py register_url_converters() before autodiscovery, byte-identical to #39078/#39102 (a URLconf reload re-registers; Follow-ups), and the api/authoring/v2/ mount urls gate PASS; converter tests
cms/lib/spectacular.py filter widened to ^/api/(contentstore|authoring)/v\d+/; SUPERSEDED_PATH_PREFIXES with exactly the two superseded paths; cms_mark_migrated_paths CmsSchemaHookTest: superseded operations become exactly {"deprecated": true}, a named fixture of other resources is left as given, and other APIs' paths are refused
cms/envs/production.py, cms/envs/devstack.py POSTPROCESSING_HOOKS (enum hook restated), trim removed, servers reduced. The SCHEMA_PATH_PREFIX comment says the prefix also derives operationIds (Sequencing) CmsSchemaSettingsTest; CmsSchemaDocumentTest::test_operation_ids; test_enum_components_survive_the_registered_post_processing. Whether the deployment's AUTHORING_API_URL serves full paths is not tested
views/permissions.py HasStudioReadAccess (identical to #39157's) and HasFullCourseKey HasStudioReadAccessTest, HasFullCourseKeyTest

Error-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}/usage and PUT /api/contentstore/v1/videos/{course_id}/download are superseded by the addresses above. They are marked deprecated: true in 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-1 Warning header is not emitted either, because the v1 route is frozen.

Authorization

Legacy check Where it lives now
@view_auth_classes(is_authenticated=True) IsAuthenticated
inline has_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 routing
— (legacy patterns never route version-only keys here) HasFullCourseKey, between IsAuthenticated and HasStudioReadAccess → 404
server-side request forgery allowlist in create_video_zip unchanged, called by import. Every URL is checked before any fetch
VideoDownloadThrottle (VIDEO_DOWNLOAD_RATE_LIMIT) the same class, so one per-user budget across both addresses
CSRF for session callers enforced by the default session authentication
row visibility: content-store items of the course; edxval courses__course_id, is_hidden=False unchanged, inside the helpers

Deny-path tests on both endpoints:

  • anonymous → 401 with WWW-Authenticate
  • Bearer token → 401
  • no role → 403
  • CourseLimitedStaffRole → 403
  • staff of another course → 403
  • ccx-v1: key as global staff → 403
  • inactive global staff over a session → 401
  • missing course without access → 403
  • version-only keys (four forms): global staff and no role → 404; anonymous → 401

On 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 --protect paths and no --allow, run in the Studio container on this tree. No gate was skipped. Summary line: GATES FAILED: 1 (schema, reconciled below).

Gate Result Detail
versions PASS 11 files added inside pre-existing version directories, 0 modified, allowed 0. Protected and untouched: 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
hygiene PASS (0 FAIL, 4 WARN) in the container; PASS (0 FAIL, 0 WARN) on the host The 4 WARN are H11 matches on a # RemovedInDjango5xWarning comment in {cms,lms}/envs/tutor/{development,production}.py. Tutor writes those files into the container's checkout (untracked); they are not in this diff
schema FAIL, reconciled → 0 BREAKING / 4 WARN / 4 INFO check-present: PASS, 2 paths under /api/authoring/v2/. Raw compare: FAIL, 56 BREAKING, 16 WARN, 78 INFO. Every BREAKING is a renamed path key (/v0/… → /api/contentstore/v0/…), read by compare as a removal; 12 of the 16 WARN are pre-existing write operations under renamed keys. Reconciled by re-prefixing the base paths with /api/contentstore and re-running compare with 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" for DefaultJwtAuthentication/DefaultSessionAuthentication on the two new views. The base already emits this warning for the same classes on other views. x-internal: none. Servers: CMS-contentstore dropped
urls PASS (0 FAIL, 0 WARN) 2144 base routes, 2150 head routes; 6 new API routes checked (two endpoints, four fallbacks)
old-tests PASS 108 passed (13 + 95), unedited
tests PASS 276 passed

The 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). pylint ran on the changed Python files except cms/urls.py and unknown_route.py, with no messages. ruff check passed on rest_api/v2/views/ and both settings files. CI's other jobs (xsslint, pii_check, import linting, CodeQL) have not run.

Follow-ups

  • edly-io/openedx-platform-sdk regeneration adds v2_courses_video_usages_retrieve and v2_courses_video_archives_create (the v1 operations were never tagged). Three things for that PR. It needs content_type_overrides: {application/zip: application/octet-stream} so the archive call returns File; 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>.
  • edx-drf-extensions: ErrorResponse schema description is the serializer's maintainer docstring. Issue to be filed (text ready).
  • edx-drf-extensions: nested validation errors and a stable detail (normalize_validation_errors, flatten_detail). The tests here pin today's text. Issue to be filed (text ready).
  • edx-drf-extensions: log unhandled exceptions with exc_info and send got_request_exception. Issue to be filed (text ready).
  • edx-drf-extensions + platform: security schemes for JwtAuthentication/DefaultSessionAuthentication. Once these exist, the 401 descriptions can be shortened. Issue to be filed (text ready, with the platform half).
  • edx-drf-extensions: the course_key converter should refuse version-only keys. Then HasFullCourseKey can go. Issue to be filed (text ready).
  • edx-drf-extensions: catalog 405/406/415/malformed JSON instead of typing them internal. Issue to be filed (text ready).
  • edx-drf-extensions: make register_url_converters() idempotent. Django 5.2 warns on each URLconf reload and Django 6.0 raises. Issue to be filed (text ready).
  • edx-drf-extensions: a pass-through renderer for binary responses, so Accept: application/zip alone stops getting 406. Issue to be filed (text ready).
  • The shared video helpers, fixed once for both addresses: unquoted Content-Disposition filename, no upstream fetch timeout, truncated 200 on a failed fetch, and unsanitized or duplicate zip entry names.
  • A collection form 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.
  • Move VideoDownloadThrottle into v2 before rest_api/v1/views/videos.py is 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.

  • feat: standardize Course Videos API into authoring v1 #39102 (Course Videos): unknown_route.py is byte-identical. cms/urls.py and cms/lib/spectacular.py take the union (both mounts, both superseded entries). Settings match except one comment line.
  • feat: apply ADR 0038 (URL structure standardization) across 6 standardized APIs #39078 (URL structure): same converter registration and settings shape. Its LEGACY_MIGRATED_PATH_PREFIXES stays separate. Both PRs define cms_mark_migrated_paths, so merging them into one hook is a hand merge.
  • feat: apply FC-0118 ADRs to course content APIs #39157 (Course Content): union of v2/authoring_urls.py routes and the mount. permissions.py is a hand merge, with HasStudioReadAccess byte-identical. Two semantic conflicts and one note:
    • Its test_the_legacy_video_operations_are_unchanged goes 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 own GET /api/contentstore/v1/videos/{course_id}.
    • Its 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.
    • Note: its video_settings view uses HasStudioReadAccess on the same converter, so it would answer 500 for version-only keys (found by reading the code, not run).
  • feat: apply FC-0118 ADRs to youtube transcripts APIs #39100 (YouTube transcripts): its mount and superseded paths take the union in cms/urls.py and cms/lib/spectacular.py.
  • feat: add GH workflow to generate openapi schema #39025 (OpenAPI schema workflow): no textual conflict. Whichever PR lands second aligns the base SPECTACULAR_SETTINGS in cms/envs/common.py with this shape: SCHEMA_PATH_PREFIX r'/api/(contentstore|authoring)', no trim, and POSTPROCESSING_HOOKS with the enum hook and cms_mark_migrated_paths. Otherwise the committed docs/cms-openapi.yaml mixes short and full paths and has no deprecation flags.

When the settings files merge, keep this PR's SCHEMA_PATH_PREFIX comment ("…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.

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.
Abdul-Muqadim-Arbisoft and others added 5 commits October 5, 2026 01:06
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
Abdul-Muqadim-Arbisoft marked this pull request as ready for review October 14, 2026 08:16

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[API] Video Management

1 participant