Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions cms/djangoapps/contentstore/rest_api/v2/authoring_urls.py
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 cms/djangoapps/contentstore/rest_api/v2/serializers/textbooks.py
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 cms/djangoapps/contentstore/rest_api/v2/serializers/video_settings.py
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.")
Comment thread
Faraz32123 marked this conversation as resolved.
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 cms/djangoapps/contentstore/rest_api/v2/textbooks_service.py
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 cms/djangoapps/contentstore/rest_api/v2/video_settings_service.py
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 cms/djangoapps/contentstore/rest_api/v2/views/tests/helpers.py
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]]
Loading
Loading