refactor: use video block utils from xblocks-contrib package (#38088)
* refactor: use bumper_utils from xblocks-contrib package * refactor: use video_handlers from xblocks-contrib package * fix: fix test_video_handlers test cases
This commit is contained in:
committed by
GitHub
parent
68a53b8506
commit
9b6445cb7b
@@ -46,6 +46,7 @@ from openedx.core.djangoapps.video_config.services import VideoConfigService
|
||||
from openedx.core.djangoapps.discussions.services import DiscussionConfigService
|
||||
from openedx.core.lib.xblock_services.call_to_action import CallToActionService
|
||||
from xmodule.contentstore.django import contentstore
|
||||
from xblocks_contrib.video.exceptions import TranscriptNotFoundError
|
||||
from xmodule.exceptions import NotFoundError as XModuleNotFoundError
|
||||
from xmodule.library_tools import LegacyLibraryToolsService
|
||||
from xmodule.modulestore.django import XBlockI18nService, modulestore
|
||||
@@ -975,7 +976,7 @@ def _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, course
|
||||
raise Http404 # lint-amnesty, pylint: disable=raise-missing-from
|
||||
|
||||
# If we can't find the block, respond with a 404
|
||||
except (XModuleNotFoundError, NotFoundError):
|
||||
except (XModuleNotFoundError, NotFoundError, TranscriptNotFoundError):
|
||||
log.exception("Module indicating to user that request doesn't exist")
|
||||
raise Http404 # lint-amnesty, pylint: disable=raise-missing-from
|
||||
|
||||
|
||||
@@ -320,7 +320,7 @@ class TestTranscriptAvailableTranslationsDispatch(TestVideo): # lint-amnesty, p
|
||||
assert sorted(json.loads(response.body.decode('utf-8'))) == sorted(['en', 'uk'])
|
||||
|
||||
@patch('openedx.core.djangoapps.video_config.transcripts_utils.get_video_transcript_content')
|
||||
@patch('openedx.core.djangoapps.video_config.transcripts_utils.get_available_transcript_languages')
|
||||
@patch('edxval.api.get_available_transcript_languages')
|
||||
@ddt.data(
|
||||
(
|
||||
['en', 'uk', 'ro'],
|
||||
@@ -504,7 +504,7 @@ class TestTranscriptDownloadDispatch(TestVideo): # lint-amnesty, pylint: disabl
|
||||
assert response.status == '404 Not Found'
|
||||
|
||||
@patch(
|
||||
'xmodule.video_block.video_handlers.get_transcript',
|
||||
'xblocks_contrib.video.video_handlers.get_transcript',
|
||||
return_value=('Subs!', 'test_filename.srt', 'application/x-subrip; charset=utf-8')
|
||||
)
|
||||
def test_download_srt_exist(self, __):
|
||||
@@ -515,7 +515,7 @@ class TestTranscriptDownloadDispatch(TestVideo): # lint-amnesty, pylint: disabl
|
||||
assert response.headers['Content-Language'] == 'en'
|
||||
|
||||
@patch(
|
||||
'xmodule.video_block.video_handlers.get_transcript',
|
||||
'xblocks_contrib.video.video_handlers.get_transcript',
|
||||
return_value=('Subs!', 'txt', 'text/plain; charset=utf-8')
|
||||
)
|
||||
def test_download_txt_exist(self, __):
|
||||
@@ -545,7 +545,6 @@ class TestTranscriptDownloadDispatch(TestVideo): # lint-amnesty, pylint: disabl
|
||||
assert response.headers['Content-Disposition'] == 'attachment; filename="en_塞.srt"'
|
||||
|
||||
@patch('openedx.core.djangoapps.video_config.transcripts_utils.edxval_api.get_video_transcript_data')
|
||||
@patch('xmodule.video_block.get_transcript', Mock(side_effect=NotFoundError))
|
||||
def test_download_fallback_transcript(self, mock_get_video_transcript_data):
|
||||
"""
|
||||
Verify val transcript is returned as a fallback if it is not found in the content store.
|
||||
|
||||
@@ -49,7 +49,8 @@ from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE
|
||||
from xmodule.tests.helpers import mock_render_template, override_descriptor_system # pylint: disable=unused-import
|
||||
from xmodule.tests.test_import import DummyModuleStoreRuntime
|
||||
from xmodule.tests.test_video import VideoBlockTestBase
|
||||
from xmodule.video_block import VideoBlock, bumper_utils, video_utils
|
||||
from xmodule.video_block import VideoBlock, video_utils
|
||||
from xblocks_contrib.video import bumper_utils
|
||||
from openedx.core.djangoapps.video_config.transcripts_utils import Transcript, save_to_store, subs_filename
|
||||
from xmodule.video_block.video_block import EXPORT_IMPORT_COURSE_DIR, EXPORT_IMPORT_STATIC_DIR
|
||||
from xmodule.x_module import PUBLIC_VIEW, STUDENT_VIEW
|
||||
@@ -2323,7 +2324,7 @@ class TestVideoWithBumper(TestVideo): # pylint: disable=test-inherits-tests
|
||||
# Use temporary FEATURES in this test without affecting the original
|
||||
FEATURES = dict(settings.FEATURES)
|
||||
|
||||
@patch('xmodule.video_block.bumper_utils.get_bumper_settings')
|
||||
@patch('xblocks_contrib.video.bumper_utils.get_bumper_settings')
|
||||
def test_is_bumper_enabled(self, get_bumper_settings):
|
||||
"""
|
||||
Check that bumper is (not)shown if ENABLE_VIDEO_BUMPER is (False)True
|
||||
@@ -2348,8 +2349,8 @@ class TestVideoWithBumper(TestVideo): # pylint: disable=test-inherits-tests
|
||||
assert not bumper_utils.is_bumper_enabled(self.block)
|
||||
|
||||
@patch('xblock.utils.resources.ResourceLoader.render_django_template', side_effect=mock_render_template)
|
||||
@patch('xmodule.video_block.bumper_utils.is_bumper_enabled')
|
||||
@patch('xmodule.video_block.bumper_utils.get_bumper_settings')
|
||||
@patch('xblocks_contrib.video.bumper_utils.is_bumper_enabled')
|
||||
@patch('xblocks_contrib.video.bumper_utils.get_bumper_settings')
|
||||
@patch('edxval.api.get_urls_for_profiles')
|
||||
def test_bumper_metadata(
|
||||
self, get_url_for_profiles, get_bumper_settings, is_bumper_enabled, mock_render_django_template
|
||||
|
||||
@@ -29,6 +29,7 @@ from openedx.core.djangoapps.content_libraries.api import (
|
||||
add_library_block_static_asset_file,
|
||||
delete_library_block_static_asset_file,
|
||||
)
|
||||
from openedx.core.djangoapps.video_config.sharing_sites import sharing_sites_info_for_video
|
||||
from openedx.core.djangoapps.video_config.transcripts_utils import (
|
||||
Transcript,
|
||||
clean_video_id,
|
||||
@@ -93,7 +94,6 @@ class VideoConfigService:
|
||||
|
||||
organization = get_course_organization(course_key)
|
||||
|
||||
from openedx.core.djangoapps.video_config.sharing_sites import sharing_sites_info_for_video
|
||||
sharing_sites_info = sharing_sites_info_for_video(
|
||||
public_video_url,
|
||||
organization=organization
|
||||
|
||||
@@ -28,6 +28,7 @@ from xmodule.contentstore.content import StaticContent
|
||||
from xmodule.contentstore.django import contentstore
|
||||
from xmodule.exceptions import NotFoundError
|
||||
|
||||
from xblocks_contrib.video.bumper_utils import get_bumper_settings
|
||||
from xblocks_contrib.video.exceptions import TranscriptsGenerationException
|
||||
|
||||
|
||||
@@ -786,11 +787,6 @@ class VideoTranscriptsMixin:
|
||||
is_bumper(bool): If True, the request is for the bumper transcripts
|
||||
include_val_transcripts(bool): If True, include edx-val transcripts as well
|
||||
"""
|
||||
# TODO: This causes a circular import when imported at the top-level.
|
||||
# This import will be removed as part of the VideoBlock extraction.
|
||||
# https://github.com/openedx/edx-platform/issues/36282
|
||||
from xmodule.video_block.bumper_utils import get_bumper_settings
|
||||
|
||||
if is_bumper:
|
||||
transcripts = copy.deepcopy(get_bumper_settings(self).get('transcripts', {}))
|
||||
sub = transcripts.pop("en", "")
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
Container for video block and its utils.
|
||||
"""
|
||||
|
||||
from .bumper_utils import *
|
||||
from openedx.core.djangoapps.video_config.transcripts_utils import * # lint-amnesty, pylint: disable=redefined-builtin
|
||||
from .video_block import *
|
||||
from .video_utils import *
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
"""
|
||||
Utils for video bumper
|
||||
"""
|
||||
|
||||
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from .video_utils import set_query_parameter
|
||||
|
||||
try:
|
||||
import edxval.api as edxval_api
|
||||
except ImportError:
|
||||
edxval_api = None
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_bumper_settings(video):
|
||||
"""
|
||||
Get bumper settings from video instance.
|
||||
"""
|
||||
bumper_settings = copy.deepcopy(getattr(video, 'video_bumper', {}))
|
||||
|
||||
# clean up /static/ prefix from bumper transcripts
|
||||
for lang, transcript_url in bumper_settings.get('transcripts', {}).items():
|
||||
bumper_settings['transcripts'][lang] = transcript_url.replace("/static/", "")
|
||||
|
||||
return bumper_settings
|
||||
|
||||
|
||||
def is_bumper_enabled(video):
|
||||
"""
|
||||
Check if bumper enabled.
|
||||
|
||||
- Feature flag ENABLE_VIDEO_BUMPER should be set to True
|
||||
- Do not show again button should not be clicked by user.
|
||||
- Current time minus periodicity must be greater that last time viewed
|
||||
- edxval_api should be presented
|
||||
|
||||
Returns:
|
||||
bool.
|
||||
"""
|
||||
bumper_last_view_date = getattr(video, 'bumper_last_view_date', None)
|
||||
utc_now = datetime.now(ZoneInfo("UTC"))
|
||||
periodicity = settings.FEATURES.get('SHOW_BUMPER_PERIODICITY', 0)
|
||||
has_viewed = any([
|
||||
video.bumper_do_not_show_again,
|
||||
(bumper_last_view_date and bumper_last_view_date + timedelta(seconds=periodicity) > utc_now)
|
||||
])
|
||||
is_studio = getattr(video.runtime, "is_author_mode", False)
|
||||
return bool(
|
||||
not is_studio and
|
||||
settings.FEATURES.get('ENABLE_VIDEO_BUMPER') and
|
||||
get_bumper_settings(video) and
|
||||
edxval_api and
|
||||
not has_viewed
|
||||
)
|
||||
|
||||
|
||||
def bumperize(video):
|
||||
"""
|
||||
Populate video with bumper settings, if they are presented.
|
||||
"""
|
||||
video.bumper = {
|
||||
'enabled': False,
|
||||
'edx_video_id': "",
|
||||
'transcripts': {},
|
||||
'metadata': None,
|
||||
}
|
||||
|
||||
if not is_bumper_enabled(video):
|
||||
return
|
||||
|
||||
bumper_settings = get_bumper_settings(video)
|
||||
|
||||
try:
|
||||
video.bumper['edx_video_id'] = bumper_settings['video_id']
|
||||
video.bumper['transcripts'] = bumper_settings['transcripts']
|
||||
except (TypeError, KeyError):
|
||||
log.warning(
|
||||
"Could not retrieve video bumper information from course settings"
|
||||
)
|
||||
return
|
||||
|
||||
sources = get_bumper_sources(video)
|
||||
if not sources:
|
||||
return
|
||||
|
||||
video.bumper.update({
|
||||
'metadata': bumper_metadata(video, sources),
|
||||
'enabled': True, # Video poster needs this.
|
||||
})
|
||||
|
||||
|
||||
def get_bumper_sources(video):
|
||||
"""
|
||||
Get bumper sources from edxval.
|
||||
|
||||
Returns list of sources.
|
||||
"""
|
||||
try:
|
||||
val_profiles = ["desktop_webm", "desktop_mp4"]
|
||||
val_video_urls = edxval_api.get_urls_for_profiles(video.bumper['edx_video_id'], val_profiles)
|
||||
bumper_sources = [url for url in [val_video_urls[p] for p in val_profiles] if url]
|
||||
except edxval_api.ValInternalError:
|
||||
# if no bumper sources, nothing will be showed
|
||||
log.warning(
|
||||
"Could not retrieve information from VAL for Bumper edx Video ID: %s.", video.bumper['edx_video_id']
|
||||
)
|
||||
return []
|
||||
|
||||
return bumper_sources
|
||||
|
||||
|
||||
def bumper_metadata(video, sources):
|
||||
"""
|
||||
Generate bumper metadata.
|
||||
"""
|
||||
transcripts = video.get_transcripts_info(is_bumper=True)
|
||||
unused_track_url, bumper_transcript_language, bumper_languages = video.get_transcripts_for_student(transcripts)
|
||||
|
||||
metadata = OrderedDict({
|
||||
'saveStateUrl': video.ajax_url + '/save_user_state',
|
||||
'showCaptions': json.dumps(video.show_captions),
|
||||
'sources': sources,
|
||||
'streams': '',
|
||||
'transcriptLanguage': bumper_transcript_language,
|
||||
'transcriptLanguages': bumper_languages,
|
||||
'transcriptTranslationUrl': set_query_parameter(
|
||||
video.runtime.handler_url(video, 'transcript', 'translation/__lang__').rstrip('/?'), 'is_bumper', 1
|
||||
),
|
||||
'transcriptAvailableTranslationsUrl': set_query_parameter(
|
||||
video.runtime.handler_url(video, 'transcript', 'available_translations').rstrip('/?'), 'is_bumper', 1
|
||||
),
|
||||
'publishCompletionUrl': set_query_parameter(
|
||||
video.runtime.handler_url(video, 'publish_completion', '').rstrip('?'), 'is_bumper', 1
|
||||
),
|
||||
})
|
||||
|
||||
return metadata
|
||||
@@ -48,7 +48,7 @@ from xmodule.x_module import (
|
||||
XModuleMixin, XModuleToXBlockMixin,
|
||||
)
|
||||
from xmodule.xml_block import XmlMixin, deserialize_field, is_pointer_tag, name_to_pathname
|
||||
from .bumper_utils import bumperize
|
||||
from xblocks_contrib.video.bumper_utils import bumperize
|
||||
from openedx.core.djangoapps.video_config.transcripts_utils import (
|
||||
Transcript,
|
||||
VideoTranscriptsMixin,
|
||||
@@ -57,7 +57,7 @@ from openedx.core.djangoapps.video_config.transcripts_utils import (
|
||||
get_html5_ids,
|
||||
subs_filename
|
||||
)
|
||||
from .video_handlers import VideoStudentViewHandlers, VideoStudioViewHandlers
|
||||
from xblocks_contrib.video.video_handlers import VideoStudentViewHandlers, VideoStudioViewHandlers
|
||||
from .video_utils import create_youtube_string, format_xml_exception_message, get_poster, rewrite_video_url
|
||||
from .video_xfields import VideoFields
|
||||
|
||||
|
||||
@@ -1,551 +0,0 @@
|
||||
"""
|
||||
Handlers for video block.
|
||||
|
||||
StudentViewHandlers are handlers for video block instance.
|
||||
StudioViewHandlers are handlers for video descriptor instance.
|
||||
"""
|
||||
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
|
||||
from django.utils.timezone import now
|
||||
from opaque_keys.edx.locator import CourseLocator
|
||||
from webob import Response
|
||||
from xblock.core import XBlock
|
||||
from xblock.exceptions import JsonHandlerError
|
||||
from xblock.fields import RelativeTime
|
||||
|
||||
from xmodule.exceptions import NotFoundError
|
||||
|
||||
from openedx.core.djangoapps.video_config.transcripts_utils import (
|
||||
Transcript,
|
||||
clean_video_id,
|
||||
subs_filename,
|
||||
)
|
||||
from xblocks_contrib.video.exceptions import (
|
||||
TranscriptsGenerationException,
|
||||
TranscriptNotFoundError,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_transcript(
|
||||
video_block,
|
||||
lang: str | None = None,
|
||||
output_format: str = 'srt',
|
||||
youtube_id: str | None = None,
|
||||
is_bumper: bool = False,
|
||||
) -> tuple[bytes, str, str]:
|
||||
"""
|
||||
Retrieve a transcript using a video block's configuration service.
|
||||
|
||||
Returns:
|
||||
tuple(bytes, str, str): transcript content, filename, and mimetype.
|
||||
|
||||
Raises:
|
||||
Exception: If the video config service is not available or the transcript cannot be retrieved.
|
||||
"""
|
||||
video_config_service = video_block.runtime.service(video_block, 'video_config')
|
||||
if not video_config_service:
|
||||
raise Exception("Video config service not found")
|
||||
return video_config_service.get_transcript(video_block, lang, output_format, youtube_id, is_bumper)
|
||||
|
||||
|
||||
# Disable no-member warning:
|
||||
# pylint: disable=no-member
|
||||
|
||||
def to_boolean(value):
|
||||
"""
|
||||
Convert a value from a GET or POST request parameter to a bool
|
||||
"""
|
||||
if isinstance(value, bytes):
|
||||
value = value.decode('ascii', errors='replace')
|
||||
if isinstance(value, str):
|
||||
return value.lower() == 'true'
|
||||
else:
|
||||
return bool(value)
|
||||
|
||||
|
||||
class VideoStudentViewHandlers:
|
||||
"""
|
||||
Handlers for video block instance.
|
||||
"""
|
||||
global_speed = None
|
||||
transcript_language = None
|
||||
|
||||
def handle_ajax(self, dispatch, data):
|
||||
"""
|
||||
Update values of xfields, that were changed by student.
|
||||
"""
|
||||
accepted_keys = [
|
||||
'speed', 'auto_advance', 'saved_video_position', 'transcript_language',
|
||||
'transcript_download_format', 'youtube_is_available',
|
||||
'bumper_last_view_date', 'bumper_do_not_show_again'
|
||||
]
|
||||
|
||||
conversions = {
|
||||
'speed': json.loads,
|
||||
'auto_advance': json.loads,
|
||||
'saved_video_position': RelativeTime.isotime_to_timedelta,
|
||||
'youtube_is_available': json.loads,
|
||||
'bumper_last_view_date': to_boolean,
|
||||
'bumper_do_not_show_again': to_boolean,
|
||||
}
|
||||
|
||||
if dispatch == 'save_user_state':
|
||||
for key in data:
|
||||
if key in accepted_keys:
|
||||
if key in conversions:
|
||||
value = conversions[key](data[key])
|
||||
else:
|
||||
value = data[key]
|
||||
|
||||
if key == 'bumper_last_view_date':
|
||||
value = now()
|
||||
|
||||
if key == 'speed' and math.isnan(value):
|
||||
message = f"Invalid speed value {value}, must be a float."
|
||||
log.warning(message)
|
||||
return json.dumps({'success': False, 'error': message})
|
||||
|
||||
setattr(self, key, value)
|
||||
|
||||
if key == 'speed':
|
||||
self.global_speed = self.speed
|
||||
|
||||
return json.dumps({'success': True})
|
||||
|
||||
log.debug(f"GET {data}")
|
||||
log.debug(f"DISPATCH {dispatch}")
|
||||
|
||||
raise NotFoundError('Unexpected dispatch type')
|
||||
|
||||
def get_static_transcript(self, request, transcripts):
|
||||
"""
|
||||
Courses that are imported with the --nostatic flag do not show
|
||||
transcripts/captions properly even if those captions are stored inside
|
||||
their static folder. This adds a last resort method of redirecting to
|
||||
the static asset path of the course if the transcript can't be found
|
||||
inside the contentstore and the course has the static_asset_path field
|
||||
set.
|
||||
|
||||
transcripts (dict): A dict with all transcripts and a sub.
|
||||
"""
|
||||
response = Response(status=404)
|
||||
# Only do redirect for English
|
||||
if not self.transcript_language == 'en':
|
||||
return response
|
||||
|
||||
# If this video lives in library, the code below is not relevant and will error.
|
||||
if not isinstance(self.course_id, CourseLocator):
|
||||
return response
|
||||
|
||||
video_id = request.GET.get('videoId', None)
|
||||
if video_id:
|
||||
transcript_name = video_id
|
||||
else:
|
||||
transcript_name = transcripts["sub"]
|
||||
|
||||
if transcript_name:
|
||||
# Get the asset path for course
|
||||
asset_path = None
|
||||
course = self.runtime.modulestore.get_course(self.course_id)
|
||||
if course.static_asset_path:
|
||||
asset_path = course.static_asset_path
|
||||
else:
|
||||
# It seems static_asset_path is not set in any XMLModuleStore courses.
|
||||
asset_path = getattr(course, 'data_dir', '')
|
||||
|
||||
if asset_path:
|
||||
response = Response(
|
||||
status=307,
|
||||
location='/static/{}/{}'.format(
|
||||
asset_path,
|
||||
subs_filename(transcript_name, self.transcript_language)
|
||||
)
|
||||
)
|
||||
return response
|
||||
|
||||
@XBlock.json_handler
|
||||
def publish_completion(self, data, dispatch): # pylint: disable=unused-argument
|
||||
"""
|
||||
Entry point for completion for student_view.
|
||||
|
||||
Parameters:
|
||||
data: JSON dict:
|
||||
key: "completion"
|
||||
value: float in range [0.0, 1.0]
|
||||
|
||||
dispatch: Ignored.
|
||||
Return value: JSON response (200 on success, 400 for malformed data)
|
||||
"""
|
||||
completion_service = self.runtime.service(self, 'completion')
|
||||
if completion_service is None:
|
||||
raise JsonHandlerError(500, "No completion service found")
|
||||
if not completion_service.completion_tracking_enabled():
|
||||
raise JsonHandlerError(404, "Completion tracking is not enabled and API calls are unexpected")
|
||||
if not isinstance(data['completion'], (int, float)):
|
||||
message = "Invalid completion value {}. Must be a float in range [0.0, 1.0]"
|
||||
raise JsonHandlerError(400, message.format(data['completion']))
|
||||
if not 0.0 <= data['completion'] <= 1.0:
|
||||
message = "Invalid completion value {}. Must be in range [0.0, 1.0]"
|
||||
raise JsonHandlerError(400, message.format(data['completion']))
|
||||
self.runtime.publish(self, "completion", data)
|
||||
return {"result": "ok"}
|
||||
|
||||
@staticmethod
|
||||
def make_transcript_http_response(content, filename, language, content_type, add_attachment_header=True):
|
||||
"""
|
||||
Construct `Response` object.
|
||||
|
||||
Arguments:
|
||||
content (unicode): transcript content
|
||||
filename (unicode): transcript filename
|
||||
language (unicode): transcript language
|
||||
mimetype (unicode): transcript content type
|
||||
add_attachment_header (bool): whether to add attachment header or not
|
||||
"""
|
||||
headerlist = [
|
||||
('Content-Language', language),
|
||||
]
|
||||
|
||||
if add_attachment_header:
|
||||
headerlist.append(
|
||||
(
|
||||
'Content-Disposition',
|
||||
f'attachment; filename="{filename}"'
|
||||
)
|
||||
)
|
||||
|
||||
response = Response(
|
||||
content,
|
||||
headerlist=headerlist,
|
||||
charset='utf8'
|
||||
)
|
||||
response.content_type = content_type
|
||||
|
||||
return response
|
||||
|
||||
@XBlock.handler
|
||||
def transcript(self, request, dispatch):
|
||||
"""
|
||||
Entry point for transcript handlers for student_view.
|
||||
|
||||
Request GET contains:
|
||||
(optional) `videoId` for `translation` dispatch.
|
||||
`is_bumper=1` flag for bumper case.
|
||||
|
||||
Dispatches, (HTTP GET):
|
||||
/translation/[language_id]
|
||||
/download
|
||||
/available_translations/
|
||||
|
||||
Explanations:
|
||||
`download`: returns SRT or TXT file.
|
||||
`translation`: depends on HTTP methods:
|
||||
Provide translation for requested language, SJSON format is sent back on success,
|
||||
Proper language_id should be in url.
|
||||
`available_translations`:
|
||||
Returns list of languages, for which transcript files exist.
|
||||
For 'en' check if SJSON exists. For non-`en` check if SRT file exists.
|
||||
"""
|
||||
is_bumper = request.GET.get('is_bumper', False)
|
||||
transcripts = self.get_transcripts_info(is_bumper)
|
||||
|
||||
if dispatch.startswith('translation'):
|
||||
language = dispatch.replace('translation', '').strip('/')
|
||||
|
||||
# Because scrapers hit video blocks, verify that a user exists.
|
||||
# use the _request attr to get the django request object.
|
||||
if not request._request.user: # pylint: disable=protected-access
|
||||
log.info("Transcript: user must be logged or public view enabled to get transcript")
|
||||
return Response(status=403)
|
||||
|
||||
if not language:
|
||||
log.info("Invalid /translation request: no language.")
|
||||
return Response(status=400)
|
||||
|
||||
if language not in ['en'] + list(transcripts["transcripts"].keys()):
|
||||
log.info("Video: transcript facilities are not available for given language.")
|
||||
return Response(status=404)
|
||||
|
||||
if language != self.transcript_language:
|
||||
self.transcript_language = language
|
||||
|
||||
try:
|
||||
youtube_id = None if is_bumper else request.GET.get('videoId')
|
||||
content, filename, mimetype = get_transcript(
|
||||
self,
|
||||
lang=self.transcript_language,
|
||||
output_format=Transcript.SJSON,
|
||||
youtube_id=youtube_id,
|
||||
is_bumper=is_bumper
|
||||
)
|
||||
response = self.make_transcript_http_response(
|
||||
content,
|
||||
filename,
|
||||
self.transcript_language,
|
||||
mimetype,
|
||||
add_attachment_header=False
|
||||
)
|
||||
except TranscriptNotFoundError as exc:
|
||||
edx_video_id = clean_video_id(self.edx_video_id)
|
||||
log.warning(
|
||||
'[Translation Dispatch] %s: %s',
|
||||
self.location,
|
||||
exc if is_bumper else f'Transcript not found for {edx_video_id}, lang: {self.transcript_language}',
|
||||
)
|
||||
response = self.get_static_transcript(request, transcripts)
|
||||
|
||||
elif dispatch == 'download':
|
||||
lang = request.GET.get('lang', None)
|
||||
|
||||
try:
|
||||
content, filename, mimetype = get_transcript(self, lang, output_format=self.transcript_download_format)
|
||||
except TranscriptNotFoundError:
|
||||
return Response(status=404)
|
||||
|
||||
response = self.make_transcript_http_response(
|
||||
content,
|
||||
filename,
|
||||
self.transcript_language,
|
||||
mimetype
|
||||
)
|
||||
elif dispatch.startswith('available_translations'):
|
||||
video_config_service = self.runtime.service(self, 'video_config')
|
||||
if not video_config_service:
|
||||
return Response(status=404)
|
||||
available_translations = video_config_service.available_translations(
|
||||
self,
|
||||
transcripts,
|
||||
verify_assets=True,
|
||||
is_bumper=is_bumper
|
||||
)
|
||||
if available_translations:
|
||||
response = Response(json.dumps(available_translations))
|
||||
response.content_type = 'application/json'
|
||||
else:
|
||||
response = Response(status=404)
|
||||
else: # unknown dispatch
|
||||
log.debug("Dispatch is not allowed")
|
||||
response = Response(status=404)
|
||||
|
||||
return response
|
||||
|
||||
@XBlock.handler
|
||||
def student_view_user_state(self, request, suffix=''): # lint-amnesty, pylint: disable=unused-argument
|
||||
"""
|
||||
Endpoint to get user-specific state, like current position and playback speed,
|
||||
without rendering the full student_view HTML. This is similar to student_view_state,
|
||||
but that one cannot contain user-specific info.
|
||||
"""
|
||||
view_state = self.student_view_data()
|
||||
view_state.update({
|
||||
"saved_video_position": self.saved_video_position.total_seconds(),
|
||||
"speed": self.speed,
|
||||
})
|
||||
return Response(
|
||||
json.dumps(view_state),
|
||||
content_type='application/json',
|
||||
charset='UTF-8'
|
||||
)
|
||||
|
||||
@XBlock.handler
|
||||
def yt_video_metadata(self, request, suffix=''): # lint-amnesty, pylint: disable=unused-argument
|
||||
"""
|
||||
Endpoint to get YouTube metadata.
|
||||
This handler is only used in the openedx_content-based runtime. The old
|
||||
runtime uses a similar REST API that's not an XBlock handler.
|
||||
"""
|
||||
from lms.djangoapps.courseware.views.views import load_metadata_from_youtube
|
||||
if not self.youtube_id_1_0:
|
||||
# TODO: more informational response to explain that yt_video_metadata not supported for non-youtube videos.
|
||||
return Response('{}', status=400)
|
||||
|
||||
metadata, status_code = load_metadata_from_youtube(video_id=self.youtube_id_1_0, request=request)
|
||||
response = Response(json.dumps(metadata), status=status_code)
|
||||
response.content_type = 'application/json'
|
||||
return response
|
||||
|
||||
|
||||
class VideoStudioViewHandlers:
|
||||
"""
|
||||
Handlers for Studio view.
|
||||
"""
|
||||
def validate_transcript_upload_data(self, data):
|
||||
"""
|
||||
Validates video transcript file.
|
||||
Arguments:
|
||||
data: Transcript data to be validated.
|
||||
Returns:
|
||||
None or String
|
||||
If there is error returns error message otherwise None.
|
||||
"""
|
||||
error = None
|
||||
_ = self.runtime.service(self, "i18n").ugettext
|
||||
# Validate the must have attributes - this error is unlikely to be faced by common users.
|
||||
must_have_attrs = ['edx_video_id', 'language_code', 'new_language_code']
|
||||
missing = [attr for attr in must_have_attrs if attr not in data]
|
||||
|
||||
# Get available transcript languages.
|
||||
transcripts = self.get_transcripts_info()
|
||||
video_config_service = self.runtime.service(self, 'video_config')
|
||||
if not video_config_service:
|
||||
return error
|
||||
available_translations = video_config_service.available_translations(
|
||||
self,
|
||||
transcripts,
|
||||
verify_assets=True
|
||||
)
|
||||
|
||||
if missing:
|
||||
error = _('The following parameters are required: {missing}.').format(missing=', '.join(missing))
|
||||
elif (
|
||||
data['language_code'] != data['new_language_code'] and data['new_language_code'] in available_translations
|
||||
):
|
||||
error = _('A transcript with the "{language_code}" language code already exists.').format(
|
||||
language_code=data['new_language_code'],
|
||||
)
|
||||
elif 'file' not in data:
|
||||
error = _('A transcript file is required.')
|
||||
|
||||
return error
|
||||
|
||||
@XBlock.handler
|
||||
def studio_transcript(self, request, dispatch):
|
||||
"""
|
||||
Entry point for Studio transcript handlers.
|
||||
|
||||
Dispatches:
|
||||
/translation/[language_id] - language_id sould be in url.
|
||||
|
||||
`translation` dispatch support following HTTP methods:
|
||||
`POST`:
|
||||
Upload srt file. Check possibility of generation of proper sjson files.
|
||||
For now, it works only for self.transcripts, not for `en`.
|
||||
`GET:
|
||||
Return filename from storage. SRT format is sent back on success. Filename should be in GET dict.
|
||||
|
||||
We raise all exceptions right in Studio:
|
||||
NotFoundError:
|
||||
Video or asset was deleted from module/contentstore, but request came later.
|
||||
Seems impossible to be raised. block_render.py catches NotFoundErrors from here.
|
||||
|
||||
/translation POST:
|
||||
TypeError:
|
||||
Unjsonable filename or content.
|
||||
TranscriptsGenerationException, TranscriptException:
|
||||
no SRT extension or not parse-able by PySRT
|
||||
UnicodeDecodeError: non-UTF8 uploaded file content encoding.
|
||||
"""
|
||||
if dispatch.startswith('translation'):
|
||||
|
||||
if request.method == 'POST':
|
||||
response = self._studio_transcript_upload(request)
|
||||
elif request.method == 'DELETE':
|
||||
response = self._studio_transcript_delete(request)
|
||||
elif request.method == 'GET':
|
||||
response = self._studio_transcript_get(request)
|
||||
else:
|
||||
# Any other HTTP method is not allowed.
|
||||
response = Response(status=404)
|
||||
|
||||
else: # unknown dispatch
|
||||
log.debug("Dispatch is not allowed")
|
||||
response = Response(status=404)
|
||||
|
||||
return response
|
||||
|
||||
def _studio_transcript_upload(self, request):
|
||||
"""
|
||||
Upload transcript. Used in "POST" method in `studio_transcript`
|
||||
"""
|
||||
_ = self.runtime.service(self, "i18n").ugettext
|
||||
video_config_service = self.runtime.service(self, 'video_config')
|
||||
if not video_config_service:
|
||||
return Response(json={'error': _('Runtime does not support transcripts.')}, status=400)
|
||||
error = self.validate_transcript_upload_data(data=request.POST)
|
||||
if error:
|
||||
return Response(json={'error': error}, status=400)
|
||||
edx_video_id = (request.POST['edx_video_id'] or "").strip()
|
||||
language_code = request.POST['language_code']
|
||||
new_language_code = request.POST['new_language_code']
|
||||
try:
|
||||
video_config_service.upload_transcript(
|
||||
video_block=self, # NOTE: .edx_video_id and .transcripts may get mutated
|
||||
edx_video_id=edx_video_id,
|
||||
language_code=language_code,
|
||||
new_language_code=new_language_code,
|
||||
transcript_file=request.POST['file'].file,
|
||||
)
|
||||
return Response(
|
||||
json.dumps(
|
||||
{
|
||||
"edx_video_id": edx_video_id or self.edx_video_id,
|
||||
"language_code": new_language_code,
|
||||
}
|
||||
),
|
||||
status=201,
|
||||
)
|
||||
except (TranscriptsGenerationException, UnicodeDecodeError):
|
||||
return Response(
|
||||
json={
|
||||
'error': _(
|
||||
'There is a problem with this transcript file. Try to upload a different file.'
|
||||
)
|
||||
},
|
||||
status=400
|
||||
)
|
||||
|
||||
def _studio_transcript_delete(self, request):
|
||||
"""
|
||||
Delete transcript. Used in "DELETE" method in `studio_transcript`
|
||||
"""
|
||||
_ = self.runtime.service(self, "i18n").ugettext
|
||||
video_config_service = self.runtime.service(self, 'video_config')
|
||||
if not video_config_service:
|
||||
return Response(json={'error': _('Runtime does not support transcripts.')}, status=400)
|
||||
request_data = request.json
|
||||
if 'lang' not in request_data or 'edx_video_id' not in request_data:
|
||||
return Response(status=400)
|
||||
video_config_service.delete_transcript(
|
||||
video_block=self,
|
||||
edx_video_id=request_data['edx_video_id'],
|
||||
language_code=request_data['lang'],
|
||||
)
|
||||
return Response(status=200)
|
||||
|
||||
def _studio_transcript_get(self, request):
|
||||
"""
|
||||
Get transcript. Used in "GET" method in `studio_transcript`
|
||||
"""
|
||||
_ = self.runtime.service(self, "i18n").ugettext
|
||||
language = request.GET.get('language_code')
|
||||
if not language:
|
||||
return Response(json={'error': _('Language is required.')}, status=400)
|
||||
|
||||
try:
|
||||
video_config_service = self.runtime.service(self, 'video_config')
|
||||
if not video_config_service:
|
||||
return Response(status=404)
|
||||
transcript_content, transcript_name, mime_type = video_config_service.get_transcript(
|
||||
self, lang=language, output_format=Transcript.SRT
|
||||
)
|
||||
response = Response(transcript_content, headerlist=[
|
||||
(
|
||||
'Content-Disposition',
|
||||
f'attachment; filename="{transcript_name}"'
|
||||
),
|
||||
('Content-Language', language),
|
||||
('Content-Type', mime_type)
|
||||
])
|
||||
except (
|
||||
UnicodeDecodeError,
|
||||
TranscriptsGenerationException,
|
||||
TranscriptNotFoundError
|
||||
):
|
||||
response = Response(status=404)
|
||||
return response
|
||||
Reference in New Issue
Block a user