Skip to content

Feat/api adrs course videos - #39102

Draft
Abdul-Muqadim-Arbisoft wants to merge 3 commits into
openedx:masterfrom
edly-io:feat/api-adrs-course-videos
Draft

Abdul-Muqadim-Arbisoft wants to merge 3 commits into
openedx:masterfrom
edly-io:feat/api-adrs-course-videos

Conversation

@Abdul-Muqadim-Arbisoft

@Abdul-Muqadim-Arbisoft Abdul-Muqadim-Arbisoft commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Summary

Standardizes the Course Videos API (#39060, umbrella #38137) onto a conforming, versioned surface, and leaves the existing /api/contentstore/v0/videos/uploads/… endpoints working exactly as they are today.

New addresses:

  • GET | POST /api/authoring/v1/courses/{course_key}/videos/
  • GET | DELETE /api/authoring/v1/courses/{course_key}/videos/{edx_video_id}/

One viewsets.ViewSet with list, retrieve, create, destroy, backed by a service layer that composes the existing video_storage_handlers and edxval.api functions by import. No business logic is duplicated, and none of it is edited.

Why a new version rather than in place. Three of the four contracts had to change to conform, and each change is breaking for anyone parsing the current responses: the list gains a pagination envelope, every non-2xx gains a single error envelope in place of three incompatible shapes, and POST answers 201 instead of 200. ADR 0037 puts all three on a new version. The v0 surface is frozen and keeps its exact bytes.

Why the GET becomes two endpoints. GET /api/contentstore/v0/videos/uploads/{course_id}/{edx_video_id} declares a video id and ignores it, returning the whole course listing. The new version splits that: the collection endpoint succeeds the behaviour the legacy route really has, and the member endpoint finally honours the id.

Change table

Endpoint(s) Legacy behaviour kept (v0) New behaviour (authoring v1)
POST …/videos/ v0 POST unchanged: 200, same body 201, same files[] entries, typed request serializer
GET …/videos/ v0 GET unchanged: {"videos": […]}, unpaginated pagination envelope, ?ordering, ?view=minimal
GET …/videos/{edx_video_id}/ v0 GET unchanged: ignores the id returns that video; 404 when it is not in the course
DELETE …/videos/{edx_video_id}/ v0 DELETE unchanged: 204 204, same soft-delete, standardized 404

Three defects fixed, on the new addresses only

  1. A GET that writes. The legacy GET/HEAD flips every video stuck in upload for more than 24 hours to upload_failed (Video.save() from a GET). The new read path computes the same display status without the write. The legacy route still reconciles, and a test pins that it does.
  2. Three incompatible error shapes, including a 400 with an empty body whose payload rides in the HTTP reason phrase, and an empty-body 404.
  3. A 302/500 on the platform default. A non-JSON Accept header returns a redirect to the authoring MFE, or a TypeError 500 when COURSE_AUTHORING_MICROFRONTEND_URL is unset — which is the platform default.

ADRs applied

  • 0025 serializers — separate request / response / query serializers, help_text on every field, content_type choices derived from the handler's real set
  • 0026 permissions — IsAuthenticated + the existing HasCourseAuthorAccess; every legacy check keeps a home
  • 0027 schema / docs — @extend_schema per action, ErrorResponseSerializer on every documented 4xx, both response variants published
  • 0028 viewsets — one ViewSet, four actions, replacing a RetrieveAPIView+DestroyAPIView pair and a CreateAPIView; one recorded deviation (routing form, below)
  • 0029 errors — one envelope for every non-2xx, arriving with the new version
  • 0030 GET idempotence — the stuck-upload write is unreachable from the new GET/HEAD
  • 0031 merged endpoints — nothing merged: the three methods are CRUD on one resource, which is ADR 0028's instrument, not 0031's. POST /generate_video_upload_link/{course_key} is a genuine action-URL sibling hitting the same handler, but it is outside this issue's endpoint table and needs its own DEPR — recorded as a follow-up
  • 0032 pagination — DefaultPagination via the library's IterablePaginationMixin; not retrofitted to v0, where it would be breaking
  • 0033 filtering — the ADR's non-ORM branch: this view has no queryset, and the endpoint exposes no filters (legacy had none either). ordering is serializer-validated against edxval's own sort fields
  • 0034 authentication — no authentication_classes declaration anywhere; Bearer gone by construction
  • 0035 MFE config — n/a: no endpoint in this area returns front-end or site configuration. The legacy GET's 302 to COURSE_AUTHORING_MICROFRONTEND_URL is a redirect to a screen, not a configuration payload, and is not carried forward
  • 0036 nested JSON — flat-list form: ?view=minimal with both representations published; ?fields= deliberately omitted, one mechanism being enough here
  • 0037 versioning — applied except the OEP-21 artifacts. The new version, the frozen v0 surface and the docstring-only deprecation markers are all in place, but the three deprecated operations carry no DEPR issue link and no named removal release (see Deprecations)
  • 0038 URL structure — /api/authoring/v1/courses/{course_key}/videos/[{edx_video_id}/], the ADR's own contentstoreauthoring target
  • OEP-66 queryset scoping — out of scope as a mechanism, applied as a layering: ScopedQuerysetMixin wraps a view-level get_queryset(), and this ViewSet has none — the only real QuerySet is built inside edxval. Hand-writing a pass-through policy would be worse than not having one. The three visibility layers are enumerated below
  • OEP-69 conventions — machine-first contracts: one stable English status, no locale-dependent enum value

Compliance matrix

Decision Status How it is met, or the concrete reason it is excluded Evidence
0025 serializers Met Request/response/query serializers split; help_text on all fields; allow_null on the fields null in practice; content_type choices from VIDEO_SUPPORTED_FILE_FORMATS CourseVideoSchemaTest::test_the_declared_full_representation_is_the_one_returned; test_create_rejects_unsupported_content_type
0026 permissions Met IsAuthenticated + reused HasCourseAuthorAccess; Studio read check and the upload-pipeline gate preserved in the service and translated at the boundary CourseVideoAuthorizationTest — 8 allow rows, 32 deny rows
0027 schema / docs Met Per-action @extend_schema; every documented 4xx $refs ErrorResponse; 404 examples carry both causes; enum post-processing preserved CourseVideoSchemaTest (12 tests), CmsSchemaHookTest, CmsSchemaAddressTest
0028 viewsets Met, one deviation One ViewSet, four actions; handle_videos's method/Accept dispatch split. Deviation: path() + .as_view({…}) rather than a router registration — ADR 0038's own conforming-URLconf example (docs/decisions/0038-…rst:245-259) uses exactly this form for a course-nested resource keyed by an opaque id CourseVideoQueryCountTest (literal counts); CourseVideoUrlContractTest
0029 errors Met StandardizedErrorMixin first in the MRO; unresolvable addresses under a course answered as JSON; ParseError/MethodNotAllowed/NotAcceptable/UnsupportedMediaType cataloged; Django-native PermissionDenied/Http404 translated at the service boundary; errors values are message strings CourseVideoRequestErrorTest; CourseVideoUrlContractTest::assert_json_not_found; test_create_names_the_entry_each_message_belongs_to
0030 GET idempotence Met The new read path computes the display status with the same branches minus send_video_status_update; legacy keeps the write CourseVideoReadOnlyTest (incl. HEAD on both addresses and the legacy-route companion)
0031 merged endpoints Analysed, nothing merged CRUD on one resource is not an RPC action-URL family
0032 pagination Met DefaultPagination via IterablePaginationMixin; defaults, clamping and bounds pinned test_list_returns_ten_videos_a_page_by_default; test_list_accepts_the_maximum_page_size; test_list_reduces_an_oversized_page_size_to_the_maximum; test_the_declared_list_envelope_is_the_one_returned
0033 filtering Met (non-ORM form) No view-level queryset; ordering allow-list generated from edxval.api.VideoSortField, default -created, total order via edxval's edx_video_id tie-break so pagination is stable test_list_honours_ordering; test_list_rejects_unknown_ordering; test_list_defaults_to_newest_first
0034 authentication Met No authentication_classes line, including on the fallback view test_the_viewset_does_not_declare_authentication_classes (checks __dict__, so inheritance cannot satisfy it)
0035 MFE config Excluded, with reason No endpoint here returns configuration; the legacy 302 is a screen redirect test_the_legacy_accept_header_branch_is_not_carried_over
0036 nested JSON Met ?view=minimal via MinimalViewMixin; both variants published as a oneOf; default shape is the legacy row test_both_video_representations_are_declared; test_the_declared_minimal_representation_is_the_one_returned
0037 versioning Applied except the OEP-21 artifacts New modules in v1/ for this resource; v0 frozen; deprecation markers are docstring text only. Missing: DEPR issue link, named removal release versions gate PASS; AST proof that the v0 diff is docstring-only; 108 legacy tests untouched and green
0038 URL structure Met Conforming addresses, trailing slashes, course_key converter, opaque edx_video_id, snake_case version-free names urls gate PASS (0 FAIL, 0 WARN); CourseVideoUrlContractTest
OEP-66 scoping Excluded as a mechanism, applied as a layering No view-level get_queryset() to wrap. Layer 1 endpoint access = permission_classes; layer 2 record visibility = the service's pipeline gate + edxval's course_id and is_hidden=False filters; layer 3 = ordering/page/page_size/view, sort-and-shape only, provably unable to widen the row set test_list_excludes_other_courses; test_list_excludes_removed_videos; test_retrieve_404_for_video_of_another_course
OEP-69 conventions Met One stable English status; status_nontranslated not carried forward test_the_status_is_never_locale_translated

Backward compatibility

  • versions gate PASS — no pre-existing version directory modified in place.
  • urls gate PASS (0 FAIL, 0 WARN) — every base address still resolves to the same view: both v0 upload routes, the four sibling v0/videos/* routes, the three /api/contentstore/v1/videos/… routes, and all seven unversioned Studio routes.
  • 108 legacy tests untouched and green (contentstore/views/tests/test_videos.py + rest_api/v1/views/tests/test_videos.py).
  • 195 new tests green.
  • Zero known consumers. No reverse() caller and no path literal in-repo; frontend-app-authoring@master calls the legacy Studio /videos/{courseId} route and /api/contentstore/v1/videos/…, never videos/uploads. openedx/openedx-platform-sdk does not exist.

Intentional differences for a caller migrating v0 → v1

Each is asserted by the parity harness, which fails if a declared difference does not occur:

Difference Why
pagination envelope instead of {"videos": […]} ADR 0032 on a new list
status untranslated; status_nontranslated absent OEP-69 machine-first contracts
created keeps microsecond precision (legacy's encoder truncated to milliseconds) DRF's ISO-8601; precision only, same instant
POST 201 instead of 200 REST semantics on a new version
one error envelope instead of three shapes ADR 0029
no 302 / Accept-driven representation switching ADR 0038 r1
deprecated Org/Course/Run keys 404 at routing ADR 0038 r9
inactive users are refused (401) ADR 0034 rule 5 drops SessionAuthenticationAllowInactiveUser. A deactivated global-staff account gets 200 on v0 today and 401 on v1. Pinned in both directions by test_inactive_global_staff_is_refused / test_inactive_global_staff_still_reaches_the_legacy_address

In-place edits

File Sanctioned category Change Evidence
rest_api/v0/views/authoring_videos.py 4 — docstring-only deprecation marker Deprecation markers naming each successor address git diff --numstat = 28 insertions, 0 deletions; AST proof: deleting every docstring node from base and head yields byte-identical ASTs
cms/urls.py 3 — URL conformance register_url_converters() and the api/authoring/v1/ mount urls gate: 0 base routes lost. The call registers only course_key and usage_key; neither collides with any existing converter, and a full warning capture across setup and resolver walk produced no re-registration warning
cms/lib/spectacular.py 3 — schema plumbing Filter widened to `^/api/(contentstore authoring)/v\d+/`; deprecation post-processing hook added
cms/envs/production.py, cms/envs/devstack.py 3 — schema plumbing POSTPROCESSING_HOOKS (re-including the default enum hook); trimming turned off; SERVERS reduced CmsSchemaAddressTest; see below

Two new modules beside the ViewSet, both inside the pre-existing v1/: error_types.py (registers the four uncataloged DRF request errors) and views/unknown_route.py (answers an unmatched address under a course with the standard envelope instead of Studio's HTML 404 page).

Error-format decision

Versioned, not in place. Clients parsing v0's three shapes are unaffected because v0 is untouched, so no in-place client evidence is owed.

Deprecations

deprecated: true on the three v0 upload operations, set by the post-processing hook in cms/lib/spectacular.py (outside the frozen directory), plus docstring markers naming each successor. No runtime Deprecation header: that is a response change on a live frozen endpoint.

Not yet supplied, and owed before merge:

  • DEPR issue: not yet filed. ADR 0037's playbook asks for it at marking time, not at removal time.
  • Removal release: not yet named.

Both need someone who knows the current release train. The docstrings say "will be removed in a future release", matching the merged v0-xblock precedent (#38723), and that precedent carries no issue link or release either — but the ADR text is stricter than the precedent, which is why the 0037 box above is unticked.

The unversioned Studio routes (/videos/{course_key}, /generate_video_upload_link/{course_key}) are not deprecated here: they are what the MFE actually calls, and two of their operations have no conforming successor yet.

Authorization

Legacy check Where it lives now
IsAuthenticated (from @view_auth_classes()) permission_classes
has_course_author_access → 403 HasCourseAuthorAccess, view level, all four actions
has_studio_read_accessPermissionDenied still called inside _get_and_validate_course; now translated to a DRF exception at the service boundary so it yields a real authz envelope
upload-pipeline gate → 404 same call, 404 envelope with a registered domain type videos/uploads-not-configured
row visibility (courses__course_id, is_hidden=False) unchanged, inside edxval

Deny-path tests, each × all four operations: anonymous → 401; authenticated with no role → 403; CourseLimitedStaffRole → 403; author of another course → 403; ccx-v1: key → 403; plus "no domain record is touched when refused". Every 4xx asserted with assert_error_envelope.

Gate report

versions   PASS
hygiene    PASS   (1 sanctioned WARN)
schema     FAIL   (explained below — expected, not hidden)
urls       PASS
old-tests  PASS   (108 legacy, untouched)
tests      PASS   (195 new)

No gate was skipped.

Why schema fails. This PR stops the CMS schema trimming its path prefix, so every published path string changes from /v0/… to /api/contentstore/v0/…. The gate classifies a path-key rename as a removal and cannot see the compensating addition, so it reports 56 BREAKING. Verified mechanically on the gate's own artifacts: 56 removed, 0 of them without a full-path counterpart, and exactly 2 genuinely new paths (the two authoring addresses). No operation was deleted and no effective URL moved — 81 operations before, 85 after, key sets otherwise identical.

The trim was removed because admitting /api/authoring/ into the document while trimming only /api/contentstore produced a document where the new operations were unreachable from the advertised server: a generated client built https://{CMS_BASE}/api/contentstore/api/authoring/v1/courses/{key}/videos/. The SCHEMA_PATH_PREFIX_TRIM/SERVERS settings are not new — they have been on master since #33694 (2023) — so this is a latent defect this PR surfaces and fixes rather than one it introduces. schema_diff.py check-present now passes and the mixed-prefix warning is gone.

Sanctioned hygiene WARN. CourseVideoPagination(DefaultPagination) is a local subclass of a library primitive. edx-drf-extensions 10.8.0 contains no get_paginated_response_schema, so DefaultPagination describes only 4 of its own 7 envelope keys; the subclass adds that one method so the published schema matches the body actually returned.

Follow-ups

  • DEPR issue in openedx/public-engineering for the three v0 methods, and the named removal release (see Deprecations).
  • edx-drf-extensions: add get_paginated_response_schema to DefaultPagination — every Open edX list endpoint currently publishes a 4-key envelope schema for a 7-key body. This PR's subclass deletes itself when that lands.
  • edx-drf-extensions: add ParseError, MethodNotAllowed, NotAcceptable, UnsupportedMediaType to the builtin error catalog, and make flatten_detail stop stringifying the detail dict into detail. Registered locally here; the durable fix is upstream.
  • AUTHORING_API_URL changes meaning: it was joined to shortened paths and is now joined to full paths, so it must be the Studio service root rather than a gateway base. cms/envs/mock.yml carries the gateway-shaped example. Any deployment setting it to a gateway base should re-point it.
  • LMS carries the same latent prefix defect (lms/envs/common.py still trims /api/enrollment). No mix today because its filter admits only that prefix; the next prefix added there hits it.
  • POST /generate_video_upload_link/{course_key} is a genuine ADR 0031 / 0038 r10 candidate hitting the same handler; outside this issue's table, needs its own DEPR.
  • [API] Video Management #39071's videos-page payload needs its own screen-shaped address: this PR claims courses/{course_key}/videos/ for the video resource.
  • The stuck-upload reconciliation has no conforming successor operation; it must be a prerequisite for retiring the legacy Studio route.
  • SDK regeneration: the four new operations carry tags=["openedx-platform-sdk"].
  • An address under the mount but not under a course (e.g. /api/authoring/v1/no/such/thing/) still gets Studio's HTML 404. ADR 0038 r8 rejects any route whose first segment after the version is a placeholder, so a conforming route cannot claim it; this matches every other API mount in the platform.

Sequencing

PR #39078 will also create v1/authoring_urls.py, the api/authoring/v1/ mount, register_url_converters() in cms/urls.py and the widened filter. It has not landed, so this PR creates them in the same shape. On rebase: take #39078's version of each shared file and re-apply the two route additions. app_name = "authoring_v1" is assumed to match its namespace.

Adds a conforming REST surface for a course's video assets at
/api/authoring/v1/courses/{course_key}/videos/ and .../{edx_video_id}/,
as one ViewSet with list, retrieve, create and destroy.

The legacy GET at /api/contentstore/v0/videos/uploads/{course_id}/{edx_video_id}
declares a video id and ignores it, returning the whole course listing. The new
version splits that in two: the collection endpoint succeeds the behaviour the
legacy route really has, and the member endpoint finally honours the id.

Three defects are fixed on the new addresses only, because fixing them in place
would break anyone parsing the current responses:

- The legacy GET and HEAD write. They flip every video stuck in 'upload' for
  more than 24 hours to 'upload_failed'. The new read path computes the same
  display status without the write; the legacy route still reconciles.
- Three incompatible error shapes, including a 400 with an empty body whose
  payload rides in the HTTP reason phrase, become one error envelope.
- A non-JSON Accept header returns a redirect to the authoring MFE, or a 500
  when COURSE_AUTHORING_MICROFRONTEND_URL is unset, which is the platform
  default. The new addresses are JSON only.

Business logic is reused by importing the existing video_storage_handlers and
edxval.api functions; none of it is duplicated or edited. Authentication drops
to the platform default, which refuses inactive users on the new addresses
while the legacy addresses are unchanged.

Addresses under a course that no endpoint serves are answered with the error
envelope rather than the site's HTML error page, so a client can read them.

Part of openedx#39060.
The CMS schema trimmed '/api/contentstore' from every path and advertised a
matching server entry. Once the document also carries paths under another
prefix, that arrangement has no server that serves both: a client generated
from the advertised server builds
https://{CMS_BASE}/api/contentstore/api/authoring/v1/courses/{key}/videos/
and gets a 404.

Trimming is therefore off and the server list is reduced to the public host and
the Studio host, so one server URL serves every operation in the document. Path
strings change from /v0/... to /api/contentstore/v0/..., but no effective URL
moves and no operation is added or removed by this commit.

Two consequences worth knowing. The schema comparison gate reads a path-key
rename as a removal, so it reports a breaking change for each renamed path; the
compensating addition is invisible to it. And AUTHORING_API_URL now joins full
paths rather than shortened ones, so it must be the Studio service root rather
than a gateway base.

These settings predate this work; the defect is latent on master and is
surfaced, not introduced, by adding a second prefix to the document.
The three video upload operations under /api/contentstore/v0/videos/uploads/
now have conforming successors, so they are marked deprecated in the published
schema by a post-processing hook, and their docstrings name the successor
address for each operation.

The hook lives outside the frozen version directory, and the change inside it
is docstring text only: no import, no statement, no decorator, so the v0
endpoints behave exactly as before. No runtime Deprecation header is emitted,
since that would be a response change on a live endpoint.

The pre-processing filter is widened at the same time so the authoring paths
reach the document at all; without it they are dropped silently.

Still owed before the deprecation window can formally open: a DEPR issue in
openedx/public-engineering, and a named removal release. Both need someone who
knows the current release train.

Part of openedx#39060.
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.

1 participant