Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
05360ae
feat: standardize Course Videos API into authoring v1
Abdul-Muqadim-Arbisoft Sep 14, 2026
a973122
fix: publish CMS schema paths in full so every operation is reachable
Abdul-Muqadim-Arbisoft Sep 14, 2026
e5971c5
docs: mark the v0 video upload endpoints deprecated
Abdul-Muqadim-Arbisoft Sep 14, 2026
a1c46d5
refactor: align the authoring v1 schema plumbing with the ADR 0038 mi…
Abdul-Muqadim-Arbisoft Sep 18, 2026
2155ce9
fix: keep the v0 video upload deprecation out of the superseded code
Abdul-Muqadim-Arbisoft Sep 27, 2026
f74beac
fix: scope this change's schema, errors and routes to its own API
Abdul-Muqadim-Arbisoft Sep 27, 2026
c55fdec
test: document the hook fixture helper
Abdul-Muqadim-Arbisoft Sep 27, 2026
2072c84
Merge branch 'master' into feat/api-adrs-course-videos
Abdul-Muqadim-Arbisoft Oct 14, 2026
1d945f3
fix: answer 404 for version-only course keys, and realign the schema …
Abdul-Muqadim-Arbisoft Oct 14, 2026
800d10b
refactor: hand the video upload course lookup the parsed course key
Abdul-Muqadim-Arbisoft Oct 14, 2026
a6393f7
fix: keep the existing content_type enum on its published name
claude Oct 14, 2026
b72ca7a
test: assert the pinned name of this API's content_type enum
claude Oct 14, 2026
a46d8cd
Merge branch 'master' into feat/api-adrs-course-videos
Abdul-Muqadim-Arbisoft Oct 21, 2026
e07db66
refactor: reuse the legacy video status logic in the v1 service
Abdul-Muqadim-Arbisoft Oct 21, 2026
e2c27d9
feat: add a command that marks stuck video uploads as failed
Abdul-Muqadim-Arbisoft Oct 21, 2026
be52ded
fix: answer unroutable video course keys like the sibling resources
Abdul-Muqadim-Arbisoft Oct 21, 2026
1b54e40
test: pin the version-only course key 404 on the other v1 viewsets
Abdul-Muqadim-Arbisoft Oct 21, 2026
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""
Mark video uploads that never finished as failed.
"""
import logging
from datetime import datetime, timedelta

from django.core.management.base import BaseCommand
from edxval.models import Video
from pytz import UTC

from cms.djangoapps.contentstore.video_storage_handlers import MAX_UPLOAD_HOURS, convert_video_status

log = logging.getLogger(__name__)


class Command(BaseCommand):
"""
Mark every video still in the `upload` status after MAX_UPLOAD_HOURS as `upload_failed`.

Studio's video list does the same for the videos of the course it shows; this command does it
for all of them, without anyone having to open that list.

Example:
./manage.py cms fail_stuck_video_uploads --dry-run
./manage.py cms fail_stuck_video_uploads
"""
help = f'Mark videos still uploading after {MAX_UPLOAD_HOURS} hours as failed.'

def add_arguments(self, parser):
parser.add_argument(
'--dry-run',
action='store_true',
help='Report the stuck uploads without changing them.',
)

def handle(self, *args, **options):
cutoff = datetime.now(UTC) - timedelta(hours=MAX_UPLOAD_HOURS)
stuck = list(
Video.objects.filter(status='upload', created__lt=cutoff)
.values('edx_video_id', 'status', 'created')
)
for video in stuck:
if options['dry_run']:
log.info('Video [%s] is still in upload since [%s]', video['edx_video_id'], video['created'])
else:
convert_video_status(video)
log.info(
'%s %d video upload(s) stuck for more than %d hours',
'Found' if options['dry_run'] else 'Marked as failed',
len(stuck),
MAX_UPLOAD_HOURS,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""
Tests for the `fail_stuck_video_uploads` management command.
"""
from datetime import datetime, timedelta

import pytz
from django.core.management import call_command
from django.test import TestCase
from edxval.api import create_video
from edxval.models import Video


class FailStuckVideoUploadsTest(TestCase):
"""
Tests for the `fail_stuck_video_uploads` management command.
"""

def create_video(self, edx_video_id, status, age):
"""Create a video in `status` whose upload started `age` ago."""
create_video({
'edx_video_id': edx_video_id,
'client_video_id': f'{edx_video_id}.mp4',
'duration': 42.0,
'status': status,
'courses': ['course-v1:edX+DemoX+Demo_Course'],
'encoded_videos': [],
})
Video.objects.filter(edx_video_id=edx_video_id).update(created=datetime.now(pytz.utc) - age)

def statuses(self):
return dict(Video.objects.values_list('edx_video_id', 'status'))

def setUp(self):
super().setUp()
self.create_video('stuck', 'upload', timedelta(hours=25))
self.create_video('uploading', 'upload', timedelta(hours=23))
self.create_video('old-and-ready', 'file_complete', timedelta(hours=48))

def test_marks_only_uploads_older_than_the_upload_window_as_failed(self):
call_command('fail_stuck_video_uploads')

assert self.statuses() == {
'stuck': 'upload_failed',
'uploading': 'upload',
'old-and-ready': 'file_complete',
}

def test_dry_run_changes_nothing(self):
before = self.statuses()

call_command('fail_stuck_video_uploads', '--dry-run')

assert self.statuses() == before
11 changes: 11 additions & 0 deletions cms/djangoapps/contentstore/rest_api/v1/authoring_urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
YoutubeTranscriptChecksViewSet,
YoutubeTranscriptImportsViewSet,
)
from cms.djangoapps.contentstore.rest_api.v1.views.video_uploads import CourseVideoUploadsViewSet

app_name = "authoring_v1"

Expand All @@ -21,6 +22,16 @@
YoutubeTranscriptImportsViewSet.as_view({"post": "create"}),
name="youtube_transcript_import_list",
),
path(
"courses/<course_key:course_key>/videos/",
CourseVideoUploadsViewSet.as_view({"get": "list", "post": "create"}),
name="course_video_list",
),
path(
"courses/<course_key:course_key>/videos/<str:edx_video_id>/",
CourseVideoUploadsViewSet.as_view({"get": "retrieve", "delete": "destroy"}),
name="course_video_detail",
),
# The viewset has no ``list`` action, so the collection accepts POST only.
path(
"xblocks/",
Expand Down
204 changes: 204 additions & 0 deletions cms/djangoapps/contentstore/rest_api/v1/serializers/video_uploads.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
"""API Serializers for course video uploads."""

from edx_rest_framework_extensions.shaping import MinimalViewMixin
from rest_framework import serializers
from rest_framework.settings import api_settings

from cms.djangoapps.contentstore.rest_api.serializers.common import StrictSerializer
from cms.djangoapps.contentstore.rest_api.v1.video_uploads_service import VIDEO_ORDERING_CHOICES
from cms.djangoapps.contentstore.video_storage_handlers import VIDEO_SUPPORTED_FILE_FORMATS

SUPPORTED_UPLOAD_CONTENT_TYPES = sorted(set(VIDEO_SUPPORTED_FILE_FORMATS.values()))

NON_FIELD_ERRORS_KEY = api_settings.NON_FIELD_ERRORS_KEY

#: The fields one video is reduced to under the minimal response preset.
MINIMAL_VIDEO_FIELDS = ("edx_video_id", "client_video_id", "status", "created", "duration")


def _flat_messages(detail, path=""):
"""
Return validation ``detail`` as a map of field path to messages.

Errors reported for the entries of a list of objects are nested one or two
levels below the field they belong to. Flattening them to
``{"files[0].content_type": ["..."]}`` gives a client the message of every
invalid field without walking the containers it is reported inside.
"""
if isinstance(detail, dict):
flat = {}
for field, value in detail.items():
nested = path if field == NON_FIELD_ERRORS_KEY and path else _join(path, field)
flat.update(_flat_messages(value, nested))
return flat
if isinstance(detail, list) and any(isinstance(entry, (dict, list)) for entry in detail):
flat = {}
for index, entry in enumerate(detail):
if entry:
flat.update(_flat_messages(entry, f"{path}[{index}]"))
return flat
messages = detail if isinstance(detail, list) else [detail]
return {path or NON_FIELD_ERRORS_KEY: [str(message) for message in messages]}


def _join(path, field):
"""Return the path of ``field`` inside ``path``."""
return f"{path}.{field}" if path else str(field)


class VideoUploadFileSerializer(StrictSerializer):
"""One requested upload slot: the file the caller intends to PUT."""

file_name = serializers.CharField(
help_text="Name of the video file being uploaded. ASCII characters only.",
)
content_type = serializers.ChoiceField(
choices=SUPPORTED_UPLOAD_CONTENT_TYPES,
help_text="MIME type of the video file. Only these types can be stored.",
)

def validate_file_name(self, value):
"""Reject names the storage backend cannot carry in its object metadata."""
try:
value.encode("ascii")
except UnicodeEncodeError as error:
raise serializers.ValidationError(
"The file name must contain only ASCII characters."
) from error
return value


class VideoUploadRequestSerializer(StrictSerializer):
"""Request body for creating upload slots."""

files = VideoUploadFileSerializer(
many=True,
allow_empty=False,
help_text="Files to create upload slots for. One slot is returned per entry, in order.",
)

def run_validation(self, data=serializers.empty):
"""Validate the body, reporting every invalid field as a list of messages."""
try:
return super().run_validation(data)
except serializers.ValidationError as error:
raise serializers.ValidationError(_flat_messages(error.detail)) from error


class VideoUploadLinkSerializer(serializers.Serializer): # pylint: disable=abstract-method
"""One created upload slot."""

file_name = serializers.CharField(
help_text="Name of the video file, echoed from the request.",
)
upload_url = serializers.CharField(
help_text="Short-lived pre-signed URL to PUT the video file to. Expires after 24 hours.",
)
edx_video_id = serializers.CharField(
help_text="Identifier assigned to the video. Use it to address the video afterwards.",
)


class VideoUploadResponseSerializer(serializers.Serializer): # pylint: disable=abstract-method
"""Response body for creating upload slots."""

files = VideoUploadLinkSerializer(
many=True,
help_text="Created upload slots, one per requested file, in request order.",
)


class CourseVideoSerializer(serializers.Serializer): # pylint: disable=abstract-method
"""One video asset attached to a course."""

edx_video_id = serializers.CharField(
help_text="Identifier of the video.",
)
client_video_id = serializers.CharField(
allow_blank=True,
help_text="Original file name the video was uploaded under.",
)
created = serializers.DateTimeField(
allow_null=True,
help_text="Time the video record was created.",
)
duration = serializers.FloatField(
allow_null=True,
help_text="Length of the video in seconds; 0 until the video has been processed.",
)
status = serializers.CharField(
help_text=(
"Processing state of the video, in stable English: for example Uploading, "
"In Progress, Ready, Failed, YouTube Duplicate."
),
)
error_description = serializers.CharField(
allow_null=True,
allow_blank=True,
help_text="Details of the processing failure, when the video failed to process.",
)
course_video_image_url = serializers.CharField(
allow_null=True,
help_text="Thumbnail image URL for this video in this course, or null when none is set.",
)
download_link = serializers.CharField(
allow_blank=True,
help_text="URL of the desktop MP4 encoding, or an empty string when it is not ready.",
)
file_size = serializers.IntegerField(
help_text="Size of the desktop MP4 encoding in bytes; 0 when it is not ready.",
)
transcripts = serializers.ListField(
child=serializers.CharField(),
help_text="Language codes the video has transcripts for.",
)
transcription_status = serializers.CharField(
allow_blank=True,
help_text=(
"Transcription state for courses on the current video workflow, in stable English; "
"empty for courses still using a course video upload token."
),
)
transcript_urls = serializers.DictField(
child=serializers.CharField(),
help_text="Transcript download URL keyed by language code.",
)


class CourseVideoMinimalSerializer(CourseVideoSerializer):
"""One video asset, reduced to the fields that identify it."""

def get_fields(self):
"""Return only the fields the minimal preset keeps."""
fields = super().get_fields()
return {name: field for name, field in fields.items() if name in MINIMAL_VIDEO_FIELDS}


class CourseVideoQuerySerializer(serializers.Serializer): # pylint: disable=abstract-method
"""Query parameters accepted by every course video address."""

view = serializers.ChoiceField(
choices=[MinimalViewMixin.minimal_view_value],
required=False,
help_text="Response preset to return. Omit it for the full representation.",
)


class CourseVideoListQuerySerializer(CourseVideoQuerySerializer):
"""Query parameters accepted by the course video collection."""

ordering = serializers.ChoiceField(
choices=VIDEO_ORDERING_CHOICES,
required=False,
help_text="Field to sort by. Prefix the field name with '-' to sort descending.",
)
page = serializers.IntegerField(
required=False,
min_value=1,
help_text="Number of the page to return.",
)
page_size = serializers.IntegerField(
required=False,
min_value=1,
help_text="Number of videos per page; values above the maximum are reduced to it.",
)
Loading
Loading