Repository navigation
feat: apply FC-0118 ADRs to course content APIs #39157
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Faraz32123
merged 5 commits into
master
from
feat/apply_fc_0118_ADRs_to_course_content_endpoints
Oct 9, 2026
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
5bd8e9d
feat: apply FC-0118 ADRs to course content APIs
Faraz32123 67d1c70
Merge remote-tracking branch 'origin/master' into feat/apply_fc_0118_…
Faraz32123 daa691b
fix: address review feedback on the course content APIs
Faraz32123 4a8daee
fix: address second round of review feedback on the course content APIs
Faraz32123 b135ba0
Merge remote-tracking branch 'origin/master' into feat/apply_fc_0118_…
Faraz32123 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| """Contentstore API v2 authoring URLs.""" | ||
|
|
||
| from django.urls import path | ||
|
|
||
| from cms.djangoapps.contentstore.rest_api.v2.views.textbooks import CourseTextbooksViewSet | ||
| from cms.djangoapps.contentstore.rest_api.v2.views.video_settings import CourseVideoSettingsViewSet | ||
|
|
||
| app_name = "authoring_v2" | ||
|
|
||
| urlpatterns = [ | ||
| path( | ||
| "courses/<course_key:course_key>/textbooks/", | ||
| CourseTextbooksViewSet.as_view({"get": "list"}), | ||
| name="course_textbook_list", | ||
| ), | ||
| path( | ||
| "courses/<course_key:course_key>/video_settings/", | ||
| CourseVideoSettingsViewSet.as_view({"get": "retrieve"}), | ||
| name="course_video_settings", | ||
| ), | ||
| ] |
69 changes: 69 additions & 0 deletions
69
cms/djangoapps/contentstore/rest_api/v2/serializers/textbooks.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| """Serializers for course PDF textbooks.""" | ||
|
|
||
| from rest_framework import serializers | ||
|
|
||
|
|
||
| class TextbookChapterSerializer(serializers.Serializer): | ||
| """ | ||
| One chapter of a course PDF textbook. | ||
|
|
||
| A stored chapter missing ``title`` or ``url``, or holding null for either, | ||
| is returned with an empty string in its place rather than failing the page. | ||
| """ | ||
|
|
||
| title = serializers.CharField( | ||
| default="", | ||
| help_text="Chapter title shown in the textbook's table of contents. Empty when the stored chapter has none.", | ||
| ) | ||
| url = serializers.CharField( | ||
| default="", | ||
| help_text=( | ||
| "Address of the chapter's PDF file, usually a course asset path. " | ||
| "Empty when the stored chapter has none." | ||
| ), | ||
| ) | ||
|
|
||
| class Meta: | ||
| ref_name = "TextbookChapter" | ||
|
|
||
| def to_representation(self, instance): | ||
| data = super().to_representation(instance) | ||
| return {key: "" if value is None else value for key, value in data.items()} | ||
|
|
||
|
|
||
| class CourseTextbookSerializer(serializers.Serializer): | ||
| """ | ||
| One PDF textbook of a course, as stored in the course's settings. | ||
|
|
||
| A stored textbook can lack ``id``, ``chapters`` or ``tab_title``: imported | ||
| courses may carry textbooks written by other tools, such as the single-file | ||
| form that has a ``url`` and no chapters. Such a textbook is returned with | ||
| ``null`` for the missing scalar and an empty chapter list, rather than | ||
| failing the whole page. A stored ``null`` chapter list is returned empty | ||
| too. Stored keys not declared here are not returned. | ||
| """ | ||
|
|
||
| id = serializers.CharField( | ||
| allow_null=True, | ||
| default=None, | ||
| help_text="Identifier of the textbook, unique within its course. Null when the stored textbook has none.", | ||
| ) | ||
| chapters = TextbookChapterSerializer( | ||
| many=True, | ||
| default=list, | ||
| help_text="Chapters in reading order. Empty when the stored textbook has no chapter list.", | ||
| ) | ||
| tab_title = serializers.CharField( | ||
| allow_null=True, | ||
| default=None, | ||
| help_text="Title of the course tab that opens the textbook. Null when the stored textbook has none.", | ||
| ) | ||
|
|
||
| class Meta: | ||
| ref_name = "CourseTextbook" | ||
|
|
||
| def to_representation(self, instance): | ||
| data = super().to_representation(instance) | ||
| if data.get("chapters") is None: | ||
| data["chapters"] = [] | ||
| return data |
145 changes: 145 additions & 0 deletions
145
cms/djangoapps/contentstore/rest_api/v2/serializers/video_settings.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,145 @@ | ||
| """Serializers for a course's video upload and transcript settings.""" | ||
|
|
||
| from rest_framework import serializers | ||
|
|
||
|
|
||
| class VideoSettingsImageSerializer(serializers.Serializer): | ||
| """Limits that apply to video thumbnail images uploaded to the course.""" | ||
|
|
||
| video_image_upload_enabled = serializers.BooleanField( | ||
| help_text="Whether thumbnail images may be uploaded for the course's videos.", | ||
| ) | ||
| max_size = serializers.IntegerField(help_text="Largest accepted image, in bytes.") | ||
| min_size = serializers.IntegerField(help_text="Smallest accepted image, in bytes.") | ||
| max_width = serializers.IntegerField(help_text="Largest accepted image width, in pixels.") | ||
| max_height = serializers.IntegerField(help_text="Largest accepted image height, in pixels.") | ||
| supported_file_formats = serializers.DictField( | ||
| child=serializers.CharField(), | ||
| help_text="Accepted image file extensions, each mapped to its MIME type.", | ||
| ) | ||
|
|
||
| class Meta: | ||
| ref_name = "VideoSettingsImage" | ||
|
|
||
|
|
||
| class VideoSettingsTranscriptSerializer(serializers.Serializer): | ||
| """Studio handler addresses and options for the course's video transcripts.""" | ||
|
|
||
| transcript_download_handler_url = serializers.CharField( | ||
| help_text="Studio address that downloads a transcript.", | ||
| ) | ||
| transcript_upload_handler_url = serializers.CharField( | ||
| help_text="Studio address that uploads a transcript.", | ||
| ) | ||
| transcript_delete_handler_url = serializers.CharField( | ||
| help_text="Studio address that deletes a transcript of this course.", | ||
| ) | ||
| transcript_download_file_format = serializers.CharField( | ||
| source="trancript_download_file_format", | ||
| help_text="File format transcripts are downloaded in.", | ||
| ) | ||
| transcript_preferences_handler_url = serializers.CharField( | ||
| allow_null=True, | ||
| help_text="Studio address that saves the course's transcript preferences. Null when transcripts are disabled.", | ||
| ) | ||
| transcript_credentials_handler_url = serializers.CharField( | ||
| allow_null=True, | ||
| help_text="Studio address that saves transcript provider credentials. Null when transcripts are disabled.", | ||
| ) | ||
| transcription_plans = serializers.DictField( | ||
| child=serializers.DictField(), | ||
| allow_null=True, | ||
| help_text=( | ||
| "Third-party transcription plans, keyed by provider name, with each plan's options as the " | ||
| "provider defines them. Null when transcripts are disabled." | ||
| ), | ||
| ) | ||
|
|
||
| class Meta: | ||
| ref_name = "VideoSettingsTranscript" | ||
|
|
||
|
|
||
| class VideoSettingsTranscriptPreferencesSerializer(serializers.Serializer): | ||
| """The course's saved preferences for third-party transcription.""" | ||
|
|
||
| course_id = serializers.CharField(help_text="Key of the course the preferences belong to.") | ||
| provider = serializers.CharField(help_text="Transcription provider.") | ||
| cielo24_fidelity = serializers.CharField(allow_null=True, help_text="Cielo24 fidelity level, when Cielo24 is used.") | ||
| cielo24_turnaround = serializers.CharField( | ||
| allow_null=True, | ||
| help_text="Cielo24 turnaround, when Cielo24 is used.", | ||
| ) | ||
| three_play_turnaround = serializers.CharField( | ||
| allow_null=True, | ||
| help_text="3Play Media turnaround, when 3Play Media is used.", | ||
| ) | ||
| preferred_languages = serializers.ListField( | ||
| child=serializers.CharField(), | ||
| help_text="Language codes transcripts are requested in.", | ||
| ) | ||
| video_source_language = serializers.CharField( | ||
| allow_null=True, | ||
| help_text="Language code of the videos' spoken language.", | ||
| ) | ||
| modified = serializers.CharField(help_text="When the preferences were last changed.") | ||
|
|
||
| class Meta: | ||
| ref_name = "VideoSettingsTranscriptPreferences" | ||
|
|
||
|
|
||
| class TranscriptLanguageSerializer(serializers.Serializer): | ||
| """A language a transcript may be written in.""" | ||
|
|
||
| language_code = serializers.CharField(help_text="Language code.") | ||
| language_text = serializers.CharField(help_text="Display name of the language.") | ||
|
|
||
| class Meta: | ||
| ref_name = "TranscriptLanguage" | ||
|
|
||
|
|
||
| class CourseVideoSettingsSerializer(serializers.Serializer): | ||
| """The course's video upload and transcript settings.""" | ||
|
|
||
| image_upload_url = serializers.CharField(help_text="Studio address that uploads a video's thumbnail image.") | ||
| video_handler_url = serializers.CharField(help_text="Studio address that manages the course's video uploads.") | ||
| encodings_download_url = serializers.CharField( | ||
| help_text="Studio address that downloads the course's video encodings as CSV.", | ||
| ) | ||
| default_video_image_url = serializers.CharField(help_text="Address of the image shown for videos without one.") | ||
| concurrent_upload_limit = serializers.IntegerField( | ||
| help_text="How many videos a client may upload at the same time.", | ||
| ) | ||
| video_supported_file_formats = serializers.ListField( | ||
| child=serializers.CharField(), | ||
| help_text="Accepted video file extensions.", | ||
| ) | ||
| video_upload_max_file_size = serializers.IntegerField(help_text="Largest accepted video file, in gigabytes.") | ||
| video_image_settings = VideoSettingsImageSerializer(help_text="Limits for video thumbnail images.") | ||
| is_video_transcript_enabled = serializers.BooleanField( | ||
| help_text="Whether third-party transcription is enabled for the course.", | ||
| ) | ||
| is_ai_translations_enabled = serializers.BooleanField( | ||
| help_text="Whether AI translation of transcripts is enabled for the course.", | ||
| ) | ||
| active_transcript_preferences = VideoSettingsTranscriptPreferencesSerializer( | ||
| allow_null=True, | ||
| help_text="The course's transcription preferences. Null when transcripts are disabled or none are saved.", | ||
| ) | ||
| transcript_credentials = serializers.DictField( | ||
| child=serializers.BooleanField(), | ||
| allow_null=True, | ||
| help_text=( | ||
| "Whether the course's organization has credentials for each transcription provider, " | ||
| "keyed by provider name. Null when transcripts are disabled." | ||
| ), | ||
| ) | ||
| transcript_available_languages = TranscriptLanguageSerializer( | ||
| many=True, | ||
| help_text="Every language a transcript may be written in, ordered by display name.", | ||
| ) | ||
| video_transcript_settings = VideoSettingsTranscriptSerializer( | ||
| help_text="Studio handler addresses and options for transcripts.", | ||
| ) | ||
|
|
||
| class Meta: | ||
| ref_name = "CourseVideoSettings" | ||
27 changes: 27 additions & 0 deletions
27
cms/djangoapps/contentstore/rest_api/v2/textbooks_service.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| """Service layer for the v2 course textbooks endpoint.""" | ||
|
|
||
| import logging | ||
|
|
||
| from rest_framework.exceptions import NotFound | ||
|
|
||
| from cms.djangoapps.contentstore.utils import get_textbooks_context | ||
| from xmodule.modulestore.django import modulestore | ||
|
|
||
| log = logging.getLogger(__name__) | ||
|
|
||
| COURSE_NOT_FOUND_MESSAGE = "The requested course does not exist." | ||
|
|
||
|
|
||
| def get_course_textbooks(course_key): | ||
| """ | ||
| Return the stored PDF textbooks of the course ``course_key``, in stored order. | ||
|
|
||
| Raises ``NotFound`` when the course cannot be loaded from the modulestore. | ||
| """ | ||
| store = modulestore() | ||
| with store.bulk_operations(course_key): | ||
| course = store.get_course(course_key) | ||
| if course is None: | ||
| log.info("Textbooks requested for course %s, which is not in the modulestore.", course_key) | ||
| raise NotFound(COURSE_NOT_FOUND_MESSAGE) | ||
| return get_textbooks_context(course)["textbooks"] |
29 changes: 29 additions & 0 deletions
29
cms/djangoapps/contentstore/rest_api/v2/video_settings_service.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| """Service layer for the v2 course video settings endpoint.""" | ||
|
|
||
| import logging | ||
|
|
||
| from rest_framework.exceptions import NotFound | ||
|
|
||
| from cms.djangoapps.contentstore.utils import get_course_video_settings_context | ||
| from xmodule.modulestore.django import modulestore | ||
|
|
||
| log = logging.getLogger(__name__) | ||
|
|
||
| COURSE_NOT_FOUND_MESSAGE = "The requested course does not exist." | ||
|
|
||
|
|
||
| def get_course_video_settings(course_key): | ||
| """ | ||
| Return the video settings of the course ``course_key``. | ||
|
|
||
| Raises ``NotFound`` when the course cannot be loaded from the modulestore. | ||
| Nothing is written. | ||
| """ | ||
| store = modulestore() | ||
| with store.bulk_operations(course_key): | ||
| course = store.get_course(course_key) | ||
| if course is None: | ||
| log.info("Video settings requested for course %s, which is not in the modulestore.", course_key) | ||
| raise NotFound(COURSE_NOT_FOUND_MESSAGE) | ||
|
|
||
| return get_course_video_settings_context(course) |
97 changes: 97 additions & 0 deletions
97
cms/djangoapps/contentstore/rest_api/v2/views/tests/helpers.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,97 @@ | ||
| """Shared helpers for the v2 authoring endpoint tests.""" | ||
|
|
||
| from unittest.mock import patch | ||
|
|
||
| from crum import signals as crum_signals | ||
| from django.core import signals as core_signals | ||
| from django.db.models import signals as model_signals | ||
| from django.dispatch import Signal | ||
| from drf_spectacular.generators import SchemaGenerator | ||
| from drf_spectacular.settings import patched_settings | ||
| from openedx_events.tooling import OpenEdxPublicSignal | ||
| from rest_framework.test import APIClient | ||
|
|
||
| PASSWORD = "test" | ||
|
|
||
| # The schema settings the CMS publishes its authoring schema with. The test | ||
| # settings module configures none. | ||
| SCHEMA_SETTINGS = { | ||
| "PREPROCESSING_HOOKS": ["cms.lib.spectacular.cms_api_filter"], | ||
| "SCHEMA_PATH_PREFIX": r"/api/(contentstore|authoring)", | ||
| } | ||
|
|
||
|
|
||
| def logged_in_client(user): | ||
| """Return a client holding a real session for ``user``, created with ``PASSWORD``.""" | ||
| client = APIClient() | ||
| assert client.login(username=user.username, password=PASSWORD) | ||
| return client | ||
|
|
||
|
|
||
| def writes(queries): | ||
| """Return the SQL statements in ``queries`` that insert, update or delete rows.""" | ||
| return [q["sql"] for q in queries if q["sql"].lstrip().upper().startswith(("INSERT", "UPDATE", "DELETE"))] | ||
|
|
||
|
|
||
| class SignalRecorder: | ||
| """ | ||
| Record every Django signal and openedx-event sent while active. | ||
|
|
||
| ``unexpected()`` leaves out only the signals any request sends without | ||
| writing anything: the request boundaries, model instance construction on | ||
| every row read, and django-crum's current-user bookkeeping. | ||
| """ | ||
|
|
||
| NEUTRAL = ( | ||
| core_signals.request_started, | ||
| core_signals.request_finished, | ||
| model_signals.pre_init, | ||
| model_signals.post_init, | ||
| crum_signals.current_user_getter, | ||
| crum_signals.current_user_setter, | ||
| ) | ||
|
|
||
| def __init__(self): | ||
| self.sent = [] | ||
| self._patches = [] | ||
|
|
||
| def _wrap(self, owner, name): | ||
| """Prepare a patch of ``owner.name`` that records each send before performing it.""" | ||
| original = getattr(owner, name) | ||
| recorder = self | ||
|
|
||
| def wrapper(signal, *args, **kwargs): | ||
| recorder.sent.append((name, signal)) | ||
| return original(signal, *args, **kwargs) | ||
|
|
||
| self._patches.append(patch.object(owner, name, wrapper)) | ||
|
|
||
| def __enter__(self): | ||
| self._wrap(Signal, "send") | ||
| self._wrap(Signal, "send_robust") | ||
| self._wrap(OpenEdxPublicSignal, "send_event") | ||
| for active in self._patches: | ||
| active.start() | ||
| return self | ||
|
|
||
| def __exit__(self, *exc): | ||
| for active in self._patches: | ||
| active.stop() | ||
|
|
||
| def unexpected(self): | ||
| """Return the recorded ``(method, signal)`` pairs other than the neutral signals.""" | ||
| return [(name, signal) for name, signal in self.sent if not any(signal is n for n in self.NEUTRAL)] | ||
|
|
||
|
|
||
| def generate_cms_schema(): | ||
| """Generate the CMS authoring schema with the settings the service publishes it with.""" | ||
| with patched_settings(SCHEMA_SETTINGS): | ||
| return SchemaGenerator().get_schema(request=None, public=True) | ||
|
|
||
|
|
||
| def resolve_ref(schema, node): | ||
| """Return the component ``node`` points at, or ``node`` itself when it is inline.""" | ||
| ref = node.get("$ref") | ||
| if ref is None: | ||
| return node | ||
| return schema["components"]["schemas"][ref.rsplit("/", 1)[1]] |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.