Repository navigation
feat: standardize Video Management API into authoring v2 - #39186
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.
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>
taimoor-ahmed-1
left a comment
There was a problem hiding this comment.
Thanks for the thorough work here. The new endpoints mostly line up with the ADRs. Most of what I found is in the PR description, which seems to predate the merge from master (df3842b45a) and the three commits after it. Inline comments cover the code. Description items:
The description doesn't match the current diff
cms/urls.py,cms/envs/production.pyandcms/envs/devstack.pyaren't in the diff (master already has those changes). The only settings change is a comment incms/envs/common.py, which isn't mentioned.SUPERSEDED_PATH_PREFIXES/ a newcms_mark_migrated_pathsdon't exist. The PR adds two entries to master'sSUPERSEDED_PATHS/cms_mark_superseded_paths.HasStudioReadAccessis already on master; onlyHasFullCourseKeyis new.- The "one client- and deployment-facing change: the schema's servers" section describes something master already did, not this PR.
- The versions check now fails on HEAD:
authoring_urls.pyandtest_video_settings.pyare existingv2/files that are edited (see inline). "0 modified" and "nothing under a pre-existing version edited" are no longer accurate. - Sequencing says #39157 should narrow
test_the_legacy_video_operations_are_unchanged, but #39157 is merged and this PR makes that edit itself. - Departures item 4 says
/api-docs/is drf-yasg with a bodiless 201./api-docs/is now drf-spectacular (cms/urls.py:374), and this PR's own contract test asserts a 200 zip there. - The 0025 evidence cites
StudioApiDocsTest::test_the_archive_operation_is_given_no_json_body, which isn't in the tree. - The test counts, route counts and schema reconciliation were measured on "commit 3". Could you re-run the gates on HEAD and refresh those figures?
ADR checkboxes
- 0029 is ticked, but this PR's tests pin three unmet requirements: list-item errors are Python text, 405/406/415 and malformed JSON are typed
internal, and an unexpected 500 isn't logged (the ADR says stack traces are still written to the server log). I'd mark it partial. The second gap could be closed here (see inline). - 0037 is ticked, but the ADR asks for the deprecation in the schema and the endpoint docstring, with a removal date. The docstring marker is left out on purpose (if there's an FC-0118 ruling on #39100/#39102, a link would help), there's no removal date, and the #39128 entry is a comment asking for rows to be added rather than rows on the ticket. I'd mark it partial until the DEPR rows land.
- The follow-ups all say "Issue to be filed (text ready)". Linking the actual issues would let the "met with library gaps" claims be tracked.
| video store does not hold. | ||
| """ | ||
|
|
||
| permission_classes = (IsAuthenticated, HasFullCourseKey, HasStudioReadAccess) |
There was a problem hiding this comment.
ADR 0034 consistency: CourseVideoSettingsViewSet on the same Videos page (v2/views/video_settings.py:34-36) keeps (JwtAuthentication, SessionAuthenticationAllowInactiveUser), following the ADR's note that existing endpoints using SessionAuthenticationAllowInactiveUser keep that class when migrating. These two views treat v2 as a new endpoint and take the defaults. As a result, an inactive global-staff user on a session cookie gets 200 from video_settings and 401 from video_usages/video_archives, while over JWT all three return 200.
Either choice can be argued, but the two should agree. Could we align with video_settings, or note in the description why this mount differs? If refusing inactive users is the intent, ADR 0034 suggests a permission class rather than relying on the session class. Same applies to video_archives.py:93.
| return has_studio_read_access(request.user, course_key) | ||
|
|
||
|
|
||
| class HasFullCourseKey(BasePermission): |
There was a problem hiding this comment.
ADR 0026 (consistent patterns across similar endpoints): this protects the two new views, but CourseVideoSettingsViewSet uses HasStudioReadAccess on the same course_key converter without it. Per HasStudioReadAccessTest::test_a_key_without_an_organization_cannot_be_evaluated, GET …/courses/course-v1:version@<id>/video_settings/ should still return a 500 for an authenticated caller. That view is on master now, so could this PR add the guard there too, rather than leaving it as a #39157 note?
Also, this class validates the key rather than access, and exists because of a converter defect. A linked edx-drf-extensions issue would give it a removal path. Until then every new view on this converter has to remember to list it.
| """ | ||
|
|
||
| permission_classes = (IsAuthenticated, HasFullCourseKey, HasStudioReadAccess) | ||
| parser_classes = (JSONParser,) |
There was a problem hiding this comment.
ADR 0029: with JSONParser, a malformed body raises ParseError and a non-JSON body raises UnsupportedMediaType. Both currently come back typed …/errors/internal. That contradicts this operation's own 400 description ("The request body is malformed"), and a client error gets reported as a server fault. register_error_type is available in the pinned edx-drf-extensions, and #39157 already registers types this way. Could we do the same here instead of listing it as a library gap?
There was a problem hiding this comment.
Correction: I was wrong about #39157 above. As merged, it does not register error types. It deliberately stopped, because register_error_type is process-wide and "changed the error type of every standardized Studio endpoint". So please don't register types from this area. The 405/406/415 and malformed-JSON typing belongs in the edx-drf-extensions follow-up you already list.
The rest still stands: a malformed body is typed …/errors/internal, which contradicts this operation's own 400 description. Until the library change lands, I'd mark 0029 as partial and link the library issue.
| # The view declares no serializer_class: documentation generators read | ||
| # it as the response body as well, and the response is a binary archive. | ||
| body = VideoArchiveRequestSerializer(data=request.data) | ||
| body.is_valid(raise_exception=True) |
There was a problem hiding this comment.
ADR 0025: the URL allowlist check runs inside create_video_zip after is_valid(), so a refused URL comes back as errors == {"non_field_errors": [...]} rather than against files[i].url. Keeping the helper untouched makes sense for the SSRF guard. A validate_files on VideoArchiveRequestSerializer that checks against get_course_video_download_urls would point clients at the offending entry, while the helper's check stays as defence in depth. If that's out of scope, could it be recorded as a deviation?
| ), | ||
| 401: unauthenticated_response(requires_csrf_token=False), | ||
| 403: forbidden_response(requires_csrf_token=False), | ||
| 404: COURSE_NOT_FOUND_RESPONSE, |
There was a problem hiding this comment.
ADR 0027 / OEP-69: the usage tests pin a 406 for this operation (test_video_usages.py:847), but responses declares only 401/403/404. The OEP-69 row in the description says "request, response and every error declared". Could we either declare the 406 here or soften that claim?
| from rest_framework.permissions import IsAuthenticated | ||
| from rest_framework.views import APIView | ||
|
|
||
| from cms.djangoapps.contentstore.rest_api.v1.views.videos import VideoDownloadThrottle |
There was a problem hiding this comment.
ADR 0037 placement: v2 now depends on frozen v1 in two places, this throttle import from v1/views/videos.py and UnknownRouteView from v1/views/unknown_route.py. The throttle move is in the #39128 removal steps, but unknown_route isn't. Since the throttle is the only v2 use, could it live in a shared, non-versioned module now (e.g. next to video_storage_handlers.py), with v1 importing from there? Then removing v1 won't break v2.
|
|
||
|
|
||
| @extend_schema(exclude=True) | ||
| class UnknownRouteView(StandardizedErrorMixin, APIView): |
There was a problem hiding this comment.
This is a new module inside the frozen v1/ directory, used only by v2 routes. A neutral location (e.g. contentstore/rest_api/views/ or openedx/core/lib/api/) would fit it better, especially as #39102 carries a byte-identical copy and could share one module.
Small one: the docstring says it answers "a video address whose course key the conforming routes refuse", but it also answers valid keys (e.g. video_usages/ with no id, or sub-paths). Something like "an address under a collection that no route serves" would be more accurate.
| # the redirect to the slash-terminated form still happens first. A path | ||
| # converter cannot match an empty segment, hence the patterns for the | ||
| # addresses below each collection. | ||
| path( |
There was a problem hiding this comment.
ADR 0038: two points on the fallback routes.
<path:course_key>and(?P<course_key>.+)also take deeper prefixes, socourses/<key>/textbooks/video_usages/(orcourses/<key>/<anything>/video_archives/…) gets this 404, and so would any route appended to this file later. The description says "nothing outside the two collections is claimed", andtest_the_fallbacks_claim_nothing_outside_the_two_collectionsliststextbooks/video_usages_report/but nottextbooks/video_usages/, which is the address that is caught. Constraining the key segment (e.g.[^/]+(/[^/]+){0,2}for the deprecated form, or the converter's regex) would keep the fallbacks to the two collections.- Naming these routes makes the names part of the
reverse()contract (rule 11). The follow-up "collection formcourses/{course_key}/video_usages/" would replacecourse_video_usage_list_unmatchedand leave a name that has to be kept. Leaving the fallbacks unnamed avoids that, if tests don't need to reverse them.
Nit: the 10-line comment is long for this URLconf; reviewers asked for this kind of comment to be removed on #39078.
| # successors of their own, so they carry the mark. The listing does not, | ||
| # which is what the assertion above guards: a change that marked the whole | ||
| # legacy videos prefix rather than the two exact paths would sweep it up. | ||
| def test_the_legacy_siblings_of_the_listing_are_deprecated(self): |
There was a problem hiding this comment.
This renames an existing test from #39157 and flips its assertion from "not deprecated" to "deprecated is True". The change is correct given the new SUPERSEDED_PATHS entries, but it's an in-place edit of an existing test inside a pre-existing version directory, and the description says the opposite ("#39157 should narrow that test", "0 modified"). Could it be listed under In-place edits with the reason?
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.