Skip to content

feat: apply FC-0118 ADRs to course content APIs - #39157

Draft
Faraz32123 wants to merge 1 commit into
masterfrom
feat/apply_fc_0118_ADRs_to_course_content_endpoints
Draft

Faraz32123 wants to merge 1 commit into
masterfrom
feat/apply_fc_0118_ADRs_to_course_content_endpoints

Conversation

@Faraz32123

Copy link
Copy Markdown
Contributor

Related Issue: #39070

Standardizes the two v1 course-content endpoints (CourseTextbooksView, CourseVideosView in cms/djangoapps/contentstore/rest_api/v1/views/{textbooks,videos}.py) onto a new, additive v2 surface. v1 is completely untouched — both legacy files have a literal zero-line diff against master, and every existing address still resolves to the same view with the same body.

Only the two endpoints listed in #39070 are migrated.

New addresses

Legacy (unchanged) New
GET /api/contentstore/v1/textbooks/{course_id} GET /api/authoring/v2/courses/{course_key}/textbooks/
GET /api/contentstore/v1/videos/{course_id} GET /api/authoring/v2/courses/{course_key}/video_settings/

Note on the video address. CourseVideosView is not in issue #39061 (Video Management) or issue #39060 (Course Videos, PR #39102) — it belongs here, in #39070, per the issue's own table. PR #39102's description currently attributes this payload to #39061; that attribution is incorrect. #39061's approved plan explicitly excludes CourseVideosView from its scope for this reason. The new address, video_settings/, does not collide with #39102's videos/ collection or #39061's planned video_usages//video_archives/ collections.

No deprecation markers are added to the legacy views or the schema in this pass — the two v1 files are untouched, and no deprecated: true is set on their operations.

Two things worth calling out

The video endpoint fixes a runtime-verified write-on-GET bug. The legacy GET .../videos/{course_id} (and HEAD) can flip a stuck video upload's status from upload to upload_failed, via Video.save(), as a side effect of computing the display status — a genuine ADR 0030 violation, confirmed by a live probe (a 30-hour-old upload row flipped after a single HEAD request). The new endpoint's read path never invokes that reconciliation logic; a dedicated test proves zero writes (SQL-capture, signal-spy, and event-spy, not just a mock assertion), and the legacy route's behavior is left completely unchanged for existing callers.

The video endpoint is light by default. GET .../video_settings/ returns only the course's upload/transcript settings by default; ?view=full adds previous_uploads, matching ADR 0036's "the heavy payload is never the default" guidance. previous_uploads items are field-for-field identical to the legacy shape (including the non-ISO created string format), because a known consumer (enterprise-catalog) persists every field of every item as-is — the only corrections are dropping the always-empty pagination_context and fixing a typo'd field name that has no reader.

ADR compliance

ADR Status Notes
0025 Serializers Applied New serializers for both endpoints, help_text on every field, explicit Meta.ref_name. video_settings documents both its light and full (?view=full) response shapes.
0026 Permissions Applied Textbooks keeps its existing openedx-authz-with-legacy-fallback check, moved into a permission class. Videos keeps the legacy has_studio_read_access check — no upload-pipeline gate is added, since this is a read of already-uploaded state.
0027 Schema Applied @extend_schema on both actions, tags=["openedx-platform-sdk"] on both, every 4xx documented against the standardized envelope.
0028 Views Applied Plain ViewSets, registered via explicit path(), matching the pattern in-flight sibling PRs use for the same converter-based routing.
0029 Errors Applied StandardizedErrorMixin first in the MRO. A stored textbook missing id/chapters (an unhandled 500 today, reachable from ordinary imported courses) becomes a 200 with id: null/chapters: [] — a fix to bad stored data, not a shape change, since a 4xx would incorrectly blame the client.
0030 GET side effects Applied See above — the video read path is proven write-free; the write is not relocated, since nothing depends on it being reachable from a read.
0032 Pagination Applied Textbooks is paginated (7-key envelope).
0033 Filtering Not applicable Neither endpoint takes filter/sort parameters.
0034 Authentication Applied Bearer authentication is dropped on both; the remaining pair is unchanged from what each legacy view already declared.
0035 MFE config Not applicable Neither endpoint returns front-end configuration.
0036 Nested JSON Applied for video_settings; not applicable for textbooks previous_uploads is opt-in via ?view=full rather than the default. Textbooks' list is flat and thin.
0037 Versioning Applied New version (v2); v1 completely untouched (verified zero-line diff); one like-for-like extraction in contentstore/utils.py (category 1, byte-identical output verified across 16 cases).
0038 URLs Applied /api/authoring/v2/courses/{course_key}/..., course key via path converter, trailing slash, version-free URL names.
0031 Merges Not applicable The two endpoints are unrelated resources (textbooks vs. video settings); nothing to merge.
OEP-66 Scoping Applied as a layering Permission classes gate access; neither view has a get_queryset() for the mixin to wrap.

Compatibility evidence

  • URL gate: 0 FAIL / 0 WARN. Head 2183 routes vs base 2181 — the only difference is the two new routes. Both legacy addresses resolve to their original url names (textbooks, course_videos).
  • Legacy tests pass unmodified: 158 tests across rest_api/v1/views/tests/{test_textbooks,test_videos}.py and views/tests/{test_textbooks,test_textbooks_permissions,test_videos}.py — all five files untouched in this diff.
  • v1/views/textbooks.py and v1/views/videos.py are not modified at all (literal zero-line diff).
  • 116 new tests, including a full deny matrix per endpoint, a write-free proof for the video GET (SQL capture, signal spy, event spy), a query-count comparison against the legacy address, and 14 mutation checks that each turned a guarding test red.

Gate results

versions        PASS   0 files needed --allow (the one shared-code edit is unversioned)
hygiene         PASS   0 FAIL / 1 WARN (sanctioned pagination subclass, documented in code)
urls            PASS   0 FAIL / 0 WARN, 2181 base routes preserved
schema present  PASS   both endpoints published, no mixed-prefix warning
old tests       PASS   158 legacy tests green, all five files unmodified
new tests       PASS   116
ruff            PASS
schema compare  FAIL   — expected; see below

On the schema-compare failure. Removing SCHEMA_PATH_PREFIX_TRIM re-keys every existing path from its trimmed form to its full address, which the differ reports as breaking. Reconciled mechanically: base 57 paths → head 59; 0 removed paths fail to reappear at their full address; 2 genuinely new paths (this pass's two endpoints); zero operations differ in any key anywhere in the schema — because this pass adds no deprecation markers, every existing operation is completely untouched.

Known gaps

  • A routing-level 404 on the new mount (malformed course key) is answered with HTML, not JSON — the same known gap as the sibling standardized areas, pending a JSON catch-all route that an in-flight PR (feat: standardize Course Videos API into authoring v1 #39102) is adding elsewhere.
  • cms/djangoapps/contentstore/rest_api/v2/authoring_urls.py may be extended by a sibling in-flight area (Video Management, [API] Video Management #39071) once both land; the two additive route sets merge without conflict.

🤖 Generated with Claude Code

@Faraz32123
Faraz32123 marked this pull request as draft September 28, 2026 14:26
@Faraz32123 Faraz32123 linked an issue Sep 28, 2026 that may be closed by this pull request
@Faraz32123
Faraz32123 force-pushed the feat/apply_fc_0118_ADRs_to_course_content_endpoints branch from 2416efb to ac5b339 Compare September 28, 2026 14:34
Add a conforming v2 surface for the textbooks and video-settings
endpoints at /api/authoring/v2/courses/{course_key}/{textbooks,video_settings}/.
The video endpoint's read path no longer reconciles stale upload status,
fixing a runtime-verified write-on-GET bug; that reconciliation stays
on the unchanged legacy route. The v1 routes are untouched and keep
resolving exactly as they do today.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Comment thread cms/envs/devstack.py
'SCHEMA_PATH_PREFIX_TRIM': '/api/contentstore',
# Used for tag extraction only. Paths are emitted in full so they resolve
# against the service-root SERVERS below.
'SCHEMA_PATH_PREFIX': r'/api/(contentstore|authoring)',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cms/envs/common.py now has its own SPECTACULAR_SETTINGS (#39025), and that's what cms.envs.development uses to generate the committed docs/cms-openapi.yaml. It still has SCHEMA_PATH_PREFIX_TRIM, so after this merges that file mixes trimmed paths (/v1/textbooks/{course_id}) with full ones (/api/authoring/v2/courses/{course_key}/textbooks/) and has no servers. I merged this branch onto master and generated it to check. Please drop SCHEMA_PATH_PREFIX_TRIM and widen SCHEMA_PATH_PREFIX in common.py the same way when you rebase.

Without them, a wrong verb or an unsatisfiable ``Accept`` header would be
reported to the client as an internal server error at a 4xx status.
"""
register_error_type(MethodNotAllowed, "method-not-allowed", "Method Not Allowed")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The registry in edx_rest_framework_extensions.errors is process-wide, so this changes the error type of every standardized Studio endpoint, not only these two. On this branch POST /api/contentstore/v3/home/ answers 405 with .../errors/method-not-allowed and a GET with Accept: application/xml answers 406 with .../errors/not-acceptable. Master returns .../errors/internal for both. #39102 left these unregistered for that reason and lists the fix as an edx-drf-extensions follow-up. Do you want to do the same here?

class TextbookChapterSerializer(serializers.Serializer):
"""One chapter of a course PDF textbook."""

title = serializers.CharField(help_text="Chapter title shown in the textbook's table of contents.")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A chapter missing title or url still fails the whole page. I stored chapters: [{"title": "c"}] and got a 500 with the internal-error envelope, same as legacy, and a stored chapters: null comes back as null even though the schema says array. Is a malformed chapter out of scope here, or do you want the chapter fields to be as forgiving as id and tab_title?

return video_settings


def get_previous_uploads(course):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#39102 adds a paginated videos/ collection that lists the same uploads with the same write-free status computation, so the status logic exists twice in flight. What needs previous_uploads on this endpoint once that lands? If there is a reason to keep this capability, I think it should share one implementation with that PR, and ?view=full is unpaginated.

from cms.djangoapps.contentstore.views.permissions import CanViewPagesAndResources


class CourseTextbookPagination(DefaultPagination):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Add a note here that this subclass can go once edx-drf-extensions documents all seven keys in DefaultPagination.get_paginated_response_schema, with the issue link if there is one.

child=serializers.CharField(),
help_text="Accepted video file extensions.",
)
video_upload_max_file_size = serializers.IntegerField(help_text="Largest accepted video file, in gigabytes.")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: The description says the response differs from v1 only by pagination_context and the renamed transcript field. video_upload_max_file_size is also a number here where v1 sends a string, and status is always English. Worth listing for people moving off v1.

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] Course Content

2 participants