Merge branch 'master' of https://github.com/edx/edx-platform into dgamanenko/fix_username_missing_field

This commit is contained in:
Dmitry Gamanenko
2018-05-18 09:46:24 +03:00
1058 changed files with 46128 additions and 13244 deletions

View File

@@ -20,13 +20,6 @@ test_root/staticfiles
common/static/xmodule
# Coffeescript directories (don't lint autogenerated files)
cms/static/coffee
lms/static/coffee
common/static/coffee
common/lib/capa/capa/tests/test_files/js
# Symlinks into common/lib/xmodule/xmodule/js
cms/static/xmodule_js
lms/static/xmodule_js
@@ -36,27 +29,40 @@ lms/static/xmodule_js
cms/djangoapps/pipeline_js/templates
# This directory is about half Coffee and half JS, things get messy here so just ignore all existing coffee paths
# These are es2015 spec files that used to be in an ignored path.
# Now they live with the rest of the code, but we want to ignore them
# until the surrounding code is es2015 and we have a chance to clean them.
# We need to ignore them here, because es2015 will cause a parse error
# even if we add an eslint-disable line to the file.
cms/static/js/spec/models/course_spec.js
cms/static/js/spec/models/metadata_spec.js
cms/static/js/spec/models/section_spec.js
cms/static/js/spec/models/settings_course_grader_spec.js
cms/static/js/spec/models/settings_grading_spec.js
cms/static/js/spec/models/textbook_spec.js
cms/static/js/spec/models/upload_spec.js
cms/static/js/spec/views/assets_squire_spec.js
cms/static/js/spec/views/course_info_spec.js
cms/static/js/spec/views/metadata_edit_spec.js
cms/static/js/spec/views/textbook_spec.js
cms/static/js/spec/views/upload_spec.js
common/lib/capa/capa/tests/test_files/js/test_problem_display.js
common/lib/capa/capa/tests/test_files/js/test_problem_generator.js
common/lib/capa/capa/tests/test_files/js/test_problem_grader.js
common/lib/capa/capa/tests/test_files/js/xproblem.js
common/lib/xmodule/xmodule/js/spec/annotatable/display_spec.js
common/lib/xmodule/xmodule/js/spec/capa/display_spec.js
common/lib/xmodule/xmodule/js/spec/html/edit_spec.js
common/lib/xmodule/xmodule/js/spec/problem/edit_spec.js
common/lib/xmodule/xmodule/js/spec/problem/edit_spec_hint.js
common/lib/xmodule/xmodule/js/spec/problem/edit_spec.js
common/lib/xmodule/xmodule/js/spec/tabs/edit.js
lms/static/js/spec/calculator_spec.js
lms/static/js/spec/courseware_spec.js
lms/static/js/spec/feedback_form_spec.js
lms/static/js/spec/helper.js
lms/static/js/spec/histogram_spec.js
lms/static/js/spec/modules/tab_spec.js
lms/static/js/spec/requirejs_spec.js
common/lib/xmodule/xmodule/js/src/annotatable/display.js
common/lib/xmodule/xmodule/js/src/conditional/display.js
common/lib/xmodule/xmodule/js/src/discussion/display.js
common/lib/xmodule/xmodule/js/src/html/display.js
common/lib/xmodule/xmodule/js/src/html/edit.js
common/lib/xmodule/xmodule/js/src/raw/edit/json.js
common/lib/xmodule/xmodule/js/src/raw/edit/metadata-only.js
common/lib/xmodule/xmodule/js/src/raw/edit/xml.js
common/lib/xmodule/xmodule/js/src/sequence/edit.js
common/lib/xmodule/xmodule/js/src/tabs/tabs-aggregator.js
common/lib/xmodule/xmodule/js/src/vertical/edit.js
# This file is responsible for almost half of the repo's total issues.
common/lib/xmodule/xmodule/js/src/capa/schematic.js
!**/.eslintrc.js

1
.gitignore vendored
View File

@@ -73,6 +73,7 @@ cover_html/
reports/
jscover.log
jscover.log.*
.pytest_cache/
.tddium*
common/test/data/test_unicode/static/
test_root/courses/

View File

@@ -178,12 +178,12 @@ Alasdair Swan <aswan@edx.org>
Paul Medlock-Walton <paulmw@mit.edu>
Henry Tareque <henry.tareque@gmail.com>
Eugeny Kolpakov <eugeny.kolpakov@gmail.com>
Omar Al-Ithawi <oithawi@qrf.org>
Omar Al-Ithawi <i@omardo.com>
Louis Pilfold <louis@lpil.uk>
Akiva Leffert <akiva@edx.org>
Mike Bifulco <mbifulco@aquent.com>
Jim Zheng <jimzheng@stanford.edu>
Afzal Wali <afzaledx@edx.org>
Afzal Wali <afzalwalinaushahi@gmail.com>
Julien Romagnoli <julien.romagnoli@fbmx.net>
Wenjie Wu <wuwenjie718@gmail.com>
Aamir <aamir.nu.206@gmail.com>

368
Jenkinsfile vendored Normal file
View File

@@ -0,0 +1,368 @@
def runPythonTests() {
ansiColor('gnome-terminal') {
sshagent(credentials: ['jenkins-worker'], ignoreMissing: true) {
checkout changelog: false, poll: false, scm: [$class: 'GitSCM', branches: [[name: '${sha1}']],
doGenerateSubmoduleConfigurations: false, extensions: [], submoduleCfg: [],
userRemoteConfigs: [[credentialsId: 'jenkins-worker',
refspec: '+refs/heads/*:refs/remotes/origin/* +refs/pull/*:refs/remotes/origin/pr/*',
url: 'git@github.com:edx/edx-platform.git']]]
console_output = sh(returnStdout: true, script: 'bash scripts/all-tests.sh').trim()
dir('stdout') {
writeFile file: "${TEST_SUITE}-${SHARD}-stdout.log", text: console_output
}
stash includes: 'reports/**/*coverage*', name: "${TEST_SUITE}-${SHARD}-reports"
}
}
}
def savePythonTestArtifacts() {
archiveArtifacts allowEmptyArchive: true, artifacts: 'reports/**/*,test_root/log/**/*.log,**/nosetests.xml,stdout/*.log,*.log'
junit '**/nosetests.xml'
}
pipeline {
agent { label "coverage-worker" }
options {
timestamps()
timeout(75)
}
stages {
stage('Run Tests') {
parallel {
stage('lms-unit-1') {
agent { label "jenkins-worker" }
environment {
SHARD = 1
TEST_SUITE = 'lms-unit'
}
steps {
script {
runPythonTests()
}
}
post {
always {
script {
savePythonTestArtifacts()
}
}
}
}
stage('lms-unit-2') {
agent { label "jenkins-worker" }
environment {
SHARD = 2
TEST_SUITE = 'lms-unit'
}
steps{
script {
runPythonTests()
}
}
post {
always {
script {
savePythonTestArtifacts()
}
}
}
}
stage('lms-unit-3') {
agent { label "jenkins-worker" }
environment {
SHARD = 3
TEST_SUITE = 'lms-unit'
}
steps {
script {
runPythonTests()
}
}
post {
always {
script {
savePythonTestArtifacts()
}
}
}
}
stage('lms-unit-4') {
agent { label "jenkins-worker" }
environment {
SHARD = 4
TEST_SUITE = 'lms-unit'
}
steps {
script {
runPythonTests()
}
}
post {
always {
script {
savePythonTestArtifacts()
}
}
}
}
stage('lms-unit-5') {
agent { label "jenkins-worker" }
environment {
SHARD = 5
TEST_SUITE = 'lms-unit'
}
steps {
script {
runPythonTests()
}
}
post {
always {
script {
savePythonTestArtifacts()
}
}
}
}
stage('lms-unit-6') {
agent { label "jenkins-worker" }
environment {
SHARD = 6
TEST_SUITE = 'lms-unit'
}
steps {
script {
runPythonTests()
}
}
post {
always {
script {
savePythonTestArtifacts()
}
}
}
}
stage('lms-unit-7') {
agent { label "jenkins-worker" }
environment {
SHARD = 7
TEST_SUITE = 'lms-unit'
}
steps {
script {
runPythonTests()
}
}
post {
always {
script {
savePythonTestArtifacts()
}
}
}
}
stage('lms-unit-8') {
agent { label "jenkins-worker" }
environment {
SHARD = 8
TEST_SUITE = 'lms-unit'
}
steps {
script {
runPythonTests()
}
}
post {
always {
script {
savePythonTestArtifacts()
}
}
}
}
stage('lms-unit-9') {
agent { label "jenkins-worker" }
environment {
SHARD = 9
TEST_SUITE = 'lms-unit'
}
steps {
script {
runPythonTests()
}
}
post {
always {
script {
savePythonTestArtifacts()
}
}
}
}
stage('lms-unit-10') {
agent { label "jenkins-worker" }
environment {
SHARD = 10
TEST_SUITE = 'lms-unit'
}
steps {
script {
runPythonTests()
}
}
post {
always {
script {
savePythonTestArtifacts()
}
}
}
}
stage('cms-unit-1') {
agent { label "jenkins-worker" }
environment {
SHARD = 1
TEST_SUITE = 'cms-unit'
}
steps {
script {
runPythonTests()
}
}
post {
always {
script {
savePythonTestArtifacts()
}
}
}
}
stage('cms-unit-2') {
agent { label "jenkins-worker" }
environment {
SHARD = 2
TEST_SUITE = 'cms-unit'
}
steps {
script {
runPythonTests()
}
}
post {
always {
script {
savePythonTestArtifacts()
}
}
}
}
stage('commonlib-unit-1') {
agent { label "jenkins-worker" }
environment {
SHARD = 1
TEST_SUITE = 'commonlib-unit'
}
steps {
script {
runPythonTests()
}
}
post {
always {
script {
savePythonTestArtifacts()
}
}
}
}
stage('commonlib-unit-2') {
agent { label "jenkins-worker" }
environment {
SHARD = 2
TEST_SUITE = 'commonlib-unit'
}
steps {
script {
runPythonTests()
}
}
post {
always {
script {
savePythonTestArtifacts()
}
}
}
}
stage('commonlib-unit-3') {
agent { label "jenkins-worker" }
environment {
SHARD = 3
TEST_SUITE = 'commonlib-unit'
}
steps {
script {
runPythonTests()
}
}
post {
always {
script {
savePythonTestArtifacts()
}
}
}
}
}
}
stage('Run coverage') {
environment {
CODE_COV_TOKEN = credentials('CODE_COV_TOKEN')
TARGET_BRANCH = "origin/master"
CI_BRANCH = "${ghprbSourceBranch}"
SUBSET_JOB = "null" // Keep this variable until we can remove the $SUBSET_JOB path from .coveragerc
}
steps {
ansiColor('gnome-terminal') {
sshagent(credentials: ['jenkins-worker'], ignoreMissing: true) {
checkout changelog: false, poll: false, scm: [$class: 'GitSCM', branches: [[name: '${sha1}']],
doGenerateSubmoduleConfigurations: false, extensions: [], submoduleCfg: [],
userRemoteConfigs: [[credentialsId: 'jenkins-worker',
refspec: '+refs/heads/*:refs/remotes/origin/* +refs/pull/*:refs/remotes/origin/pr/*',
url: 'git@github.com:edx/edx-platform.git']]]
unstash 'lms-unit-1-reports'
unstash 'lms-unit-2-reports'
unstash 'lms-unit-3-reports'
unstash 'lms-unit-4-reports'
unstash 'lms-unit-5-reports'
unstash 'lms-unit-6-reports'
unstash 'lms-unit-7-reports'
unstash 'lms-unit-8-reports'
unstash 'lms-unit-9-reports'
unstash 'lms-unit-10-reports'
unstash 'cms-unit-1-reports'
unstash 'cms-unit-2-reports'
unstash 'commonlib-unit-1-reports'
unstash 'commonlib-unit-2-reports'
unstash 'commonlib-unit-3-reports'
sh "./scripts/jenkins-report.sh"
}
}
}
post {
always {
publishHTML([allowMissing: false, alwaysLinkToLastBuild: false, keepAll: true,
reportDir: 'reports', reportFiles: 'diff_coverage_combined.html',
reportName: 'Diff Coverage Report', reportTitles: ''])
publishHTML([allowMissing: false, alwaysLinkToLastBuild: false, keepAll: true,
reportDir: 'reports/cover', reportFiles: 'index.html',
reportName: 'Coverage.py Report', reportTitles: ''])
}
}
}
}
}

View File

@@ -1,9 +1,14 @@
# Do things in edx-platform
.PHONY: clean extract_translations help pull_translations push_translations requirements upgrade
# Careful with mktemp syntax: it has to work on Mac and Ubuntu, which have differences.
PRIVATE_FILES := $(shell mktemp -u /tmp/private_files.XXXXXX)
clean:
help: ## display this help message
@echo "Please use \`make <target>' where <target> is one of"
@grep '^[a-zA-Z]' $(MAKEFILE_LIST) | sort | awk -F ':.*?## ' 'NF==2 {printf "\033[36m %-25s\033[0m %s\n", $$1, $$2}'
clean: ## archive and delete most git-ignored files
# Remove all the git-ignored stuff, but save and restore things marked
# by start-noclean/end-noclean. Include Makefile in the tarball so that
# there's always at least one file even if there are no private files.
@@ -13,16 +18,13 @@ clean:
tar xf $(PRIVATE_FILES)
rm $(PRIVATE_FILES)
extract_translations:
# Extract localizable strings from sources
extract_translations: ## extract localizable strings from sources
i18n_tool extract -vv
push_translations:
# Push source strings to Transifex for translation
push_translations: ## push source strings to Transifex for translation
i18n_tool transifex push
pull_translations:
## Pull translations from Transifex
pull_translations: ## pull translations from Transifex
git clean -fdX conf/locale
i18n_tool transifex pull
i18n_tool extract
@@ -32,3 +34,32 @@ pull_translations:
git clean -fdX conf/locale/rtl
git clean -fdX conf/locale/eo
i18n_tool validate
requirements: ## install development environment requirements
pip install -qr requirements/edx/development.txt --exists-action w
upgrade: ## update the pip requirements files to use the latest releases satisfying our constraints
pip install -qr requirements/edx/pip-tools.txt
# Make sure to compile files after any other files they include!
pip-compile --upgrade -o requirements/edx/pip-tools.txt requirements/edx/pip-tools.in
pip-compile --upgrade -o requirements/edx/coverage.txt requirements/edx/coverage.in
pip-compile --upgrade -o requirements/edx/paver.txt requirements/edx/paver.in
pip-compile --upgrade -o requirements/edx-sandbox/shared.txt requirements/edx-sandbox/shared.in
pip-compile --upgrade -o requirements/edx-sandbox/base.txt requirements/edx-sandbox/base.in
pip-compile --upgrade -o requirements/edx/base.txt requirements/edx/base.in
pip-compile --upgrade -o requirements/edx/testing.txt requirements/edx/testing.in
pip-compile --upgrade -o requirements/edx/development.txt requirements/edx/development.in
# Post process all of the files generated above to work around open pip-tools issues
scripts/post-pip-compile.sh \
requirements/edx/pip-tools.txt \
requirements/edx/coverage.txt \
requirements/edx/paver.txt \
requirements/edx-sandbox/shared.txt \
requirements/edx-sandbox/base.txt \
requirements/edx/base.txt \
requirements/edx/testing.txt \
requirements/edx/development.txt
# Let tox control the Django version for tests
grep "^django==" requirements/edx/base.txt > requirements/edx/django.txt
sed '/^[dD]jango==/d' requirements/edx/testing.txt > requirements/edx/testing.tmp
mv requirements/edx/testing.tmp requirements/edx/testing.txt

View File

@@ -12,26 +12,13 @@ dependencies:
- npm install
- pip install setuptools
- pip install --exists-action w -r requirements/edx/paver.txt
# Mirror what paver install_prereqs does.
# After a successful build, CircleCI will
# cache the virtualenv at that state, so that
# the next build will not need to install them
# from scratch again.
- pip install --exists-action w -r requirements/edx/pre.txt
- pip install --exists-action w -r requirements/edx/github.txt
- pip install --exists-action w -r requirements/edx/local.txt
# HACK: within base.txt stevedore had a
# dependency on a version range of pbr.
# Install a version which falls within that range.
- pip install --exists-action w pbr==0.9.0
- pip install --exists-action w -r requirements/edx/django.txt
- pip install --exists-action w -r requirements/edx/base.txt
- pip install --exists-action w -r requirements/edx/paver.txt
- pip install --exists-action w -r requirements/edx/testing.txt
- if [ -e requirements/edx/post.txt ]; then pip install --exists-action w -r requirements/edx/post.txt ; fi
- pip install coveralls==1.0

View File

@@ -35,7 +35,7 @@ class CourseImportExportViewMixin(DeveloperErrorViewMixin):
Ensures that the user is authenticated (e.g. not an AnonymousUser)
"""
super(CourseImportExportViewMixin, self).perform_authentication(request)
if request.user.is_anonymous():
if request.user.is_anonymous:
raise AuthenticationFailed
@@ -101,6 +101,11 @@ class CourseImportView(CourseImportExportViewMixin, GenericAPIView):
}
"""
# TODO: ARCH-91
# This view is excluded from Swagger doc generation because it
# does not specify a serializer class.
exclude_from_schema = True
def post(self, request, course_id):
"""
Kicks off an asynchronous course import and returns an ID to be used to check

View File

@@ -2,18 +2,32 @@
This module contains various configuration settings via
waffle switches for the contentstore app.
"""
from openedx.core.djangoapps.waffle_utils import WaffleSwitchNamespace
from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag, WaffleFlagNamespace, WaffleSwitchNamespace
# Namespace
WAFFLE_NAMESPACE = u'studio'
# Switches
ENABLE_ACCESSIBILITY_POLICY_PAGE = u'enable_policy_page'
ENABLE_ASSETS_SEARCH = u'enable_assets_search'
def waffle():
"""
Returns the namespaced, cached, audited Waffle class for Studio pages.
Returns the namespaced, cached, audited Waffle Switch class for Studio pages.
"""
return WaffleSwitchNamespace(name=WAFFLE_NAMESPACE, log_prefix=u'Studio: ')
def waffle_flags():
"""
Returns the namespaced, cached, audited Waffle Flag class for Studio pages.
"""
return WaffleFlagNamespace(name=WAFFLE_NAMESPACE, log_prefix=u'Studio: ')
# Flags
ENABLE_IN_CONTEXT_IMAGE_SELECTION = CourseWaffleFlag(
waffle_namespace=waffle_flags(),
flag_name=u'enable_in_context_image_selection',
flag_undefined_default=False
)

View File

@@ -1,37 +0,0 @@
@shard_2
Feature: CMS.Course updates
As a course author, I want to be able to provide updates to my students
# Internet explorer can't select all so the update appears weirdly
@skip_internetexplorer
Scenario: Users can change handouts
Given I have opened a new course in Studio
And I go to the course updates page
When I modify the handout to "<ol>Test</ol>"
Then I see the handout "Test"
And I see a "saving" notification
Scenario: Static links are rewritten when previewing handouts
Given I have opened a new course in Studio
And I go to the course updates page
When I modify the handout to "<ol><img src='/static/my_img.jpg'/></ol>"
# Can only do partial text matches because of the quotes with in quotes (and regexp step matching).
Then I see the handout image link "my_img.jpg"
And I change the handout from "/static/my_img.jpg" to "<img src='/static/modified.jpg'/>"
Then I see the handout image link "modified.jpg"
And when I reload the page
Then I see the handout image link "modified.jpg"
Scenario: Users cannot save handouts with bad html until edit or update it properly
Given I have opened a new course in Studio
And I go to the course updates page
When I modify the handout to "<p><a href=>[LINK TEXT]</a></p>"
Then I see the handout error text
And I see handout save button disabled
When I edit the handout to "<p><a href='https://www.google.com.pk/'>home</a></p>"
Then I see handout save button re-enabled
When I save handout edit
# Can only do partial text matches because of the quotes with in quotes (and regexp step matching).
Then I see the handout "https://www.google.com.pk/"
And when I reload the page
Then I see the handout "https://www.google.com.pk/"

View File

@@ -1,89 +0,0 @@
# pylint: disable=missing-docstring
from lettuce import step, world
from nose.tools import assert_in
from cms.djangoapps.contentstore.features.common import get_codemirror_value, type_in_codemirror
@step(u'I go to the course updates page')
def go_to_updates(_step):
menu_css = 'li.nav-course-courseware'
updates_css = 'li.nav-course-courseware-updates a'
world.css_click(menu_css)
world.css_click(updates_css)
world.wait_for_visible('#course-handouts-view')
@step(u'I change the handout from "([^"]*)" to "([^"]*)"$')
def change_existing_handout(_step, before, after):
verify_text_in_editor_and_update('div.course-handouts .edit-button', before, after)
@step(u'I modify the handout to "([^"]*)"$')
def edit_handouts(_step, text):
edit_css = 'div.course-handouts > .edit-button'
world.css_click(edit_css)
change_text(text)
@step(u'I see the handout "([^"]*)"$')
def check_handout(_step, handout):
handout_css = 'div.handouts-content'
assert_in(handout, world.css_html(handout_css))
@step(u'I see the handout image link "([^"]*)"$')
def check_handout_image_link(_step, image_file):
handout_css = 'div.handouts-content'
handout_html = world.css_html(handout_css)
asset_key = world.scenario_dict['COURSE'].id.make_asset_key(asset_type='asset', path=image_file)
assert_in(unicode(asset_key), handout_html)
@step(u'I see the handout error text')
def check_handout_error(_step):
handout_error_css = 'div#handout_error'
assert world.css_has_class(handout_error_css, 'is-shown')
@step(u'I see handout save button disabled')
def check_handout_error(_step):
handout_save_button = 'form.edit-handouts-form .save-button'
assert world.css_has_class(handout_save_button, 'is-disabled')
@step(u'I edit the handout to "([^"]*)"$')
def edit_handouts(_step, text):
type_in_codemirror(0, text)
@step(u'I see handout save button re-enabled')
def check_handout_error(_step):
handout_save_button = 'form.edit-handouts-form .save-button'
assert not world.css_has_class(handout_save_button, 'is-disabled')
@step(u'I save handout edit')
def check_handout_error(_step):
save_css = '.save-button'
world.css_click(save_css)
def change_text(text):
type_in_codemirror(0, text)
save_css = '.save-button'
world.css_click(save_css)
def verify_text_in_editor_and_update(button_css, before, after):
world.css_click(button_css)
text = get_codemirror_value()
assert_in(before, text)
change_text(after)
@step('I see a "(saving|deleting)" notification')
def i_see_a_mini_notification(_step, _type):
saving_css = '.wrapper-notification-mini'
assert world.is_css_present(saving_css)

View File

@@ -167,6 +167,10 @@ def check_toolbar_buttons(step):
'forecolor',
# This is our custom "code style" button, which uses an image instead of a class.
'none',
'alignleft',
'aligncenter',
'alignright',
'alignjustify',
'bullist',
'numlist',
'outdent',

View File

@@ -10,6 +10,7 @@ from django.core.management.base import BaseCommand, CommandError
from contentstore.management.commands.utils import user_from_str
from contentstore.views.course import create_new_course_in_store
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.exceptions import DuplicateCourseError
MODULESTORE_CHOICES = (ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split)
@@ -75,12 +76,16 @@ class Command(BaseCommand):
}
if name:
fields["display_name"] = name
new_course = create_new_course_in_store(
storetype,
user,
org,
number,
run,
fields
)
self.stdout.write(u"Created {}".format(text_type(new_course.id)))
try:
new_course = create_new_course_in_store(
storetype,
user,
org,
number,
run,
fields
)
self.stdout.write(u"Created {}".format(text_type(new_course.id)))
except DuplicateCourseError:
self.stdout.write(u"Course already exists")

View File

@@ -0,0 +1,123 @@
"""
Command to migrate transcripts to django storage.
"""
import logging
from django.core.management import BaseCommand, CommandError
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locator import CourseLocator
from cms.djangoapps.contentstore.tasks import (
DEFAULT_ALL_COURSES,
DEFAULT_FORCE_UPDATE,
DEFAULT_COMMIT,
enqueue_async_migrate_transcripts_tasks
)
from openedx.core.lib.command_utils import get_mutually_exclusive_required_option, parse_course_keys
from openedx.core.djangoapps.video_config.models import TranscriptMigrationSetting
from xmodule.modulestore.django import modulestore
log = logging.getLogger(__name__)
class Command(BaseCommand):
"""
Example usage:
$ ./manage.py cms migrate_transcripts --all-courses --force-update --commit
$ ./manage.py cms migrate_transcripts --course-id 'Course1' --course-id 'Course2' --commit
$ ./manage.py cms migrate_transcripts --from-settings
"""
help = 'Migrates transcripts to S3 for one or more courses.'
def add_arguments(self, parser):
"""
Add arguments to the command parser.
"""
parser.add_argument(
'--course-id', '--course_id',
dest='course_ids',
action='append',
help=u'Migrates transcripts for the list of courses.'
)
parser.add_argument(
'--all-courses', '--all', '--all_courses',
dest='all_courses',
action='store_true',
default=DEFAULT_ALL_COURSES,
help=u'Migrates transcripts to the configured django storage for all courses.'
)
parser.add_argument(
'--from-settings', '--from_settings',
dest='from_settings',
help='Migrate Transcripts with settings set via django admin',
action='store_true',
default=False,
)
parser.add_argument(
'--force-update', '--force_update',
dest='force_update',
action='store_true',
default=DEFAULT_FORCE_UPDATE,
help=u'Force migrate transcripts for the requested courses, overwrite if already present.'
)
parser.add_argument(
'--commit',
dest='commit',
action='store_true',
default=DEFAULT_COMMIT,
help=u'Commits the discovered video transcripts to django storage. '
u'Without this flag, the command will return the transcripts discovered for migration.'
)
def _parse_course_key(self, raw_value):
""" Parses course key from string """
try:
result = CourseKey.from_string(raw_value)
except InvalidKeyError:
raise CommandError("Invalid course_key: '%s'." % raw_value)
if not isinstance(result, CourseLocator):
raise CommandError(u"Argument {0} is not a course key".format(raw_value))
return result
def _get_migration_options(self, options):
"""
Returns the command arguments configured via django admin.
"""
force_update = options['force_update']
commit = options['commit']
courses_mode = get_mutually_exclusive_required_option(options, 'course_ids', 'all_courses', 'from_settings')
if courses_mode == 'all_courses':
course_keys = [course.id for course in modulestore().get_course_summaries()]
elif courses_mode == 'course_ids':
course_keys = map(self._parse_course_key, options['course_ids'])
else:
if self._latest_settings().all_courses:
course_keys = [course.id for course in modulestore().get_course_summaries()]
else:
course_keys = parse_course_keys(self._latest_settings().course_ids.split())
force_update = self._latest_settings().force_update
commit = self._latest_settings().commit
return course_keys, force_update, commit
def _latest_settings(self):
"""
Return the latest version of the TranscriptMigrationSetting
"""
return TranscriptMigrationSetting.current()
def handle(self, *args, **options):
"""
Invokes the migrate transcripts enqueue function.
"""
course_keys, force_update, commit = self._get_migration_options(options)
kwargs = {'force_update': force_update, 'commit': commit}
try:
enqueue_async_migrate_transcripts_tasks(
course_keys,
**kwargs
)
except InvalidKeyError as exc:
raise CommandError(u'Invalid course key: ' + unicode(exc))

View File

@@ -1,6 +1,7 @@
"""
Unittests for creating a course in an chosen modulestore
"""
from StringIO import StringIO
import ddt
from django.core.management import CommandError, call_command
from django.test import TestCase
@@ -59,6 +60,29 @@ class TestCreateCourse(ModuleStoreTestCase):
# pylint: disable=protected-access
self.assertEqual(store, modulestore()._get_modulestore_for_courselike(new_key).get_modulestore_type())
def test_duplicate_course(self):
"""
Test that creating a duplicate course exception is properly handled
"""
call_command(
"create_course",
"split",
str(self.user.email),
"org", "course", "run", "dummy-course-name"
)
# create the course again
out = StringIO()
call_command(
"create_course",
"split",
str(self.user.email),
"org", "course", "run", "dummy-course-name",
stderr=out
)
expected = u"Course already exists"
self.assertIn(out.getvalue().strip(), expected)
@ddt.data(ModuleStoreEnum.Type.split, ModuleStoreEnum.Type.mongo)
def test_get_course_with_different_case(self, default_store):
"""

View File

@@ -0,0 +1,280 @@
# -*- coding: utf-8 -*-
"""
Tests for course transcript migration management command.
"""
import logging
from datetime import datetime
import pytz
from django.test import TestCase
from django.core.management import call_command, CommandError
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from xmodule.video_module.transcripts_utils import save_to_store
from edxval import api as api
from testfixtures import LogCapture
LOGGER_NAME = "cms.djangoapps.contentstore.tasks"
SRT_FILEDATA = '''
0
00:00:00,270 --> 00:00:02,720
sprechen sie deutsch?
1
00:00:02,720 --> 00:00:05,430
Ja, ich spreche Deutsch
2
00:00:6,500 --> 00:00:08,600
可以用“我不太懂艺术 但我知道我喜欢什么”做比喻
'''
CRO_SRT_FILEDATA = '''
0
00:00:00,270 --> 00:00:02,720
Dobar dan!
1
00:00:02,720 --> 00:00:05,430
Kako ste danas?
2
00:00:6,500 --> 00:00:08,600
可以用“我不太懂艺术 但我知道我喜欢什么”做比喻
'''
VIDEO_DICT_STAR = dict(
client_video_id='TWINKLE TWINKLE',
duration=42.0,
edx_video_id='test_edx_video_id',
status='upload',
)
class TestArgParsing(TestCase):
"""
Tests for parsing arguments for the `migrate_transcripts` management command
"""
def test_no_args(self):
errstring = "Must specify exactly one of --course_ids, --all_courses, --from_settings"
with self.assertRaisesRegexp(CommandError, errstring):
call_command('migrate_transcripts')
def test_invalid_course(self):
errstring = "Invalid course_key: 'invalid-course'."
with self.assertRaisesRegexp(CommandError, errstring):
call_command('migrate_transcripts', '--course-id', 'invalid-course')
class TestMigrateTranscripts(ModuleStoreTestCase):
"""
Tests migrating video transcripts in courses from contentstore to django storage
"""
def setUp(self):
""" Common setup. """
super(TestMigrateTranscripts, self).setUp()
self.store = modulestore()
self.course = CourseFactory.create()
self.course_2 = CourseFactory.create()
video = {
'edx_video_id': 'test_edx_video_id',
'client_video_id': 'test1.mp4',
'duration': 42.0,
'status': 'upload',
'courses': [unicode(self.course.id)],
'encoded_videos': [],
'created': datetime.now(pytz.utc)
}
api.create_video(video)
video_sample_xml = '''
<video display_name="Test Video"
edx_video_id="test_edx_video_id"
youtube="1.0:p2Q6BrNhdh8,0.75:izygArpw-Qo,1.25:1EeWXzPdhSA,1.5:rABDYkeK0x8"
show_captions="false"
download_track="false"
start_time="00:00:01"
download_video="false"
end_time="00:01:00">
<source src="http://www.example.com/source.mp4"/>
<track src="http://www.example.com/track"/>
<handout src="http://www.example.com/handout"/>
<transcript language="ge" src="subs_grmtran1.srt" />
<transcript language="hr" src="subs_croatian1.srt" />
</video>
'''
video_sample_xml_2 = '''
<video display_name="Test Video 2"
edx_video_id="test_edx_video_id_2"
youtube="1.0:p2Q6BrNhdh8,0.75:izygArpw-Qo,1.25:1EeWXzPdhSA,1.5:rABDYkeK0x8"
show_captions="false"
download_track="false"
start_time="00:00:01"
download_video="false"
end_time="00:01:00">
<source src="http://www.example.com/source.mp4"/>
<track src="http://www.example.com/track"/>
<handout src="http://www.example.com/handout"/>
<transcript language="ge" src="not_found.srt" />
</video>
'''
self.video_descriptor = ItemFactory.create(
parent_location=self.course.location, category='video',
data={'data': video_sample_xml}
)
self.video_descriptor_2 = ItemFactory.create(
parent_location=self.course_2.location, category='video',
data={'data': video_sample_xml_2}
)
save_to_store(SRT_FILEDATA, 'subs_grmtran1.srt', 'text/srt', self.video_descriptor.location)
save_to_store(CRO_SRT_FILEDATA, 'subs_croatian1.srt', 'text/srt', self.video_descriptor.location)
def test_migrated_transcripts_count_with_commit(self):
"""
Test migrating transcripts with commit
"""
# check that transcript does not exist
languages = api.get_available_transcript_languages(self.video_descriptor.edx_video_id)
self.assertEqual(len(languages), 0)
self.assertFalse(api.is_transcript_available(self.video_descriptor.edx_video_id, 'hr'))
self.assertFalse(api.is_transcript_available(self.video_descriptor.edx_video_id, 'ge'))
# now call migrate_transcripts command and check the transcript availability
call_command('migrate_transcripts', '--course-id', unicode(self.course.id), '--commit')
languages = api.get_available_transcript_languages(self.video_descriptor.edx_video_id)
self.assertEqual(len(languages), 2)
self.assertTrue(api.is_transcript_available(self.video_descriptor.edx_video_id, 'hr'))
self.assertTrue(api.is_transcript_available(self.video_descriptor.edx_video_id, 'ge'))
def test_migrated_transcripts_without_commit(self):
"""
Test migrating transcripts as a dry-run
"""
# check that transcripts do not exist
languages = api.get_available_transcript_languages(self.video_descriptor.edx_video_id)
self.assertEqual(len(languages), 0)
self.assertFalse(api.is_transcript_available(self.video_descriptor.edx_video_id, 'hr'))
self.assertFalse(api.is_transcript_available(self.video_descriptor.edx_video_id, 'ge'))
# now call migrate_transcripts command and check the transcript availability
call_command('migrate_transcripts', '--course-id', unicode(self.course.id))
# check that transcripts still do not exist
languages = api.get_available_transcript_languages(self.video_descriptor.edx_video_id)
self.assertEqual(len(languages), 0)
self.assertFalse(api.is_transcript_available(self.video_descriptor.edx_video_id, 'hr'))
self.assertFalse(api.is_transcript_available(self.video_descriptor.edx_video_id, 'ge'))
def test_migrate_transcripts_availability(self):
"""
Test migrating transcripts
"""
translations = self.video_descriptor.available_translations(self.video_descriptor.get_transcripts_info())
self.assertItemsEqual(translations, ['hr', 'ge'])
self.assertFalse(api.is_transcript_available(self.video_descriptor.edx_video_id, 'hr'))
self.assertFalse(api.is_transcript_available(self.video_descriptor.edx_video_id, 'ge'))
# now call migrate_transcripts command and check the transcript availability
call_command('migrate_transcripts', '--course-id', unicode(self.course.id), '--commit')
self.assertTrue(api.is_transcript_available(self.video_descriptor.edx_video_id, 'hr'))
self.assertTrue(api.is_transcript_available(self.video_descriptor.edx_video_id, 'ge'))
def test_migrate_transcripts_idempotency(self):
"""
Test migrating transcripts multiple times
"""
translations = self.video_descriptor.available_translations(self.video_descriptor.get_transcripts_info())
self.assertItemsEqual(translations, ['hr', 'ge'])
self.assertFalse(api.is_transcript_available(self.video_descriptor.edx_video_id, 'hr'))
self.assertFalse(api.is_transcript_available(self.video_descriptor.edx_video_id, 'ge'))
# now call migrate_transcripts command and check the transcript availability
call_command('migrate_transcripts', '--course-id', unicode(self.course.id), '--commit')
self.assertTrue(api.is_transcript_available(self.video_descriptor.edx_video_id, 'hr'))
self.assertTrue(api.is_transcript_available(self.video_descriptor.edx_video_id, 'ge'))
# now call migrate_transcripts command again and check the transcript availability
call_command('migrate_transcripts', '--course-id', unicode(self.course.id), '--commit')
self.assertTrue(api.is_transcript_available(self.video_descriptor.edx_video_id, 'hr'))
self.assertTrue(api.is_transcript_available(self.video_descriptor.edx_video_id, 'ge'))
# now call migrate_transcripts command with --force-update and check the transcript availability
call_command('migrate_transcripts', '--course-id', unicode(self.course.id), '--force-update', '--commit')
self.assertTrue(api.is_transcript_available(self.video_descriptor.edx_video_id, 'hr'))
self.assertTrue(api.is_transcript_available(self.video_descriptor.edx_video_id, 'ge'))
def test_migrate_transcripts_logging(self):
"""
Test migrate transcripts logging and output
"""
expected_log = (
(LOGGER_NAME,
'INFO',
u'[Transcript migration] process for course {} started. Migrating 1 videos'.format(
unicode(self.course.id)
)),
(LOGGER_NAME,
'INFO',
'[Transcript migration] Migrating 2 transcripts'),
(LOGGER_NAME,
'INFO',
u'[Transcript migration] process for course {} ended. Processed 2 transcripts'.format(
unicode(self.course.id)
)),
(LOGGER_NAME,
'INFO',
'[Transcript migration] Result: Language hr transcript of video test_edx_video_id will be migrated'
'\nLanguage ge transcript of video test_edx_video_id will be migrated')
)
with LogCapture(LOGGER_NAME, level=logging.INFO) as logger:
call_command('migrate_transcripts', '--course-id', unicode(self.course.id))
logger.check(
*expected_log
)
def test_migrate_transcripts_exception_logging(self):
"""
Test migrate transcripts exception logging
"""
expected_log = (
(LOGGER_NAME,
'INFO',
u'[Transcript migration] process for course {} started. Migrating 1 videos'.format(
unicode(self.course_2.id)
)),
(LOGGER_NAME,
'INFO',
'[Transcript migration] Migrating 1 transcripts'),
(LOGGER_NAME,
'INFO',
u'[Transcript migration] process for ge transcript started'),
(LOGGER_NAME,
'ERROR',
"[Transcript migration] Exception: u'No transcript for `ge` language'"),
(LOGGER_NAME,
'INFO',
u'[Transcript migration] process for course {} ended. Processed 1 transcripts'.format(
unicode(self.course_2.id)
)),
(LOGGER_NAME,
'INFO',
"[Transcript migration] Result: Failed: language ge of video test_edx_video_id_2 with exception "
"No transcript for `ge` language")
)
with LogCapture(LOGGER_NAME, level=logging.INFO) as logger:
call_command('migrate_transcripts', '--course-id', unicode(self.course_2.id), '--commit')
logger.check(
*expected_log
)

View File

@@ -13,10 +13,14 @@ from tempfile import NamedTemporaryFile, mkdtemp
from celery.task import task
from celery.utils.log import get_task_logger
from celery_utils.chordable_django_backend import chord, chord_task
from celery_utils.persist_on_failure import LoggedPersistOnFailureTask
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import User
from django.core.exceptions import SuspiciousOperation
from django.core.files import File
from django.core.files.base import ContentFile
from django.test import RequestFactory
from django.utils.text import get_valid_filename
from django.utils.translation import ugettext as _
@@ -47,10 +51,243 @@ from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import DuplicateCourseError, ItemNotFoundError
from xmodule.modulestore.xml_exporter import export_course_to_xml, export_library_to_xml
from xmodule.modulestore.xml_importer import import_course_from_xml, import_library_from_xml
from xmodule.video_module.transcripts_utils import (
Transcript,
clean_video_id,
get_transcript_from_contentstore,
TranscriptsGenerationException
)
from xmodule.modulestore import ModuleStoreEnum
from xmodule.exceptions import NotFoundError
from edxval.api import (
ValCannotCreateError,
create_video_transcript,
is_transcript_available,
create_or_update_video_transcript,
create_external_video,
)
User = get_user_model()
LOGGER = get_task_logger(__name__)
FILE_READ_CHUNK = 1024 # bytes
FULL_COURSE_REINDEX_THRESHOLD = 1
DEFAULT_ALL_COURSES = False
DEFAULT_FORCE_UPDATE = False
DEFAULT_COMMIT = False
RETRY_DELAY_SECONDS = 30
COURSE_LEVEL_TIMEOUT_SECONDS = 1200
VIDEO_LEVEL_TIMEOUT_SECONDS = 300
def enqueue_async_migrate_transcripts_tasks(
course_keys,
force_update=DEFAULT_FORCE_UPDATE,
commit=DEFAULT_COMMIT
):
"""
Fires new Celery tasks for all the input courses or for all courses.
Arguments:
course_keys: Command line course ids as list of CourseKey objects,
force_update: Overwrite file in S3. Default is False,
commit: Update S3 or dry-run the command to see which transcripts will be affected. Default is False.
"""
kwargs = {
'force_update': force_update,
'commit': commit
}
tasks = [
async_migrate_transcript.s(
unicode(course_key),
**kwargs
) for course_key in course_keys
]
callback = task_status_callback.s()
status = chord(tasks)(callback)
for res in status.get():
LOGGER.info("[Transcript migration] Result: %s", '\n'.join(res))
@chord_task
def task_status_callback(results):
"""
Callback for collating the results of chord.
"""
return results
@chord_task(
bind=True,
base=LoggedPersistOnFailureTask,
default_retry_delay=RETRY_DELAY_SECONDS,
max_retries=1,
time_limit=COURSE_LEVEL_TIMEOUT_SECONDS
)
def async_migrate_transcript(self, course_key, **kwargs):
#pylint: disable=unused-argument
"""
Migrates the transcripts of all videos in a course as a new celery task.
"""
try:
if not modulestore().get_course(CourseKey.from_string(course_key)):
raise KeyError(u'Invalid course key: ' + unicode(course_key))
except KeyError as exc:
LOGGER.exception('[Transcript migration] Exception: %r', text_type(exc))
return 'Failed: course {course_key} with exception {exception}'.format(
course_key=course_key,
exception=text_type(exc)
)
force_update = kwargs['force_update']
sub_tasks = []
all_videos = get_videos_from_store(CourseKey.from_string(course_key))
LOGGER.info(
"[Transcript migration] process for course %s started. Migrating %s videos",
course_key,
len(all_videos)
)
for video in all_videos:
all_lang_transcripts = video.transcripts
english_transcript = video.sub
if english_transcript:
all_lang_transcripts.update({'en': video.sub})
for lang, _ in all_lang_transcripts.items():
transcript_already_present = is_transcript_available(
clean_video_id(video.edx_video_id),
lang
)
if transcript_already_present and force_update:
sub_tasks.append(async_migrate_transcript_subtask.s(
video, lang, True, **kwargs
))
elif not transcript_already_present:
sub_tasks.append(async_migrate_transcript_subtask.s(
video, lang, False, **kwargs
))
LOGGER.info("[Transcript migration] Migrating %s transcripts", len(sub_tasks))
callback = task_status_callback.s()
status = chord(sub_tasks)(callback)
LOGGER.info(
"[Transcript migration] process for course %s ended. Processed %s transcripts",
course_key,
len(status.get())
)
return status.get()
def get_videos_from_store(course_key):
"""
Returns all videos in a course as list.
Arguments:
course_key: CourseKey object
"""
store = modulestore()
all_videos = []
for video in store.get_items(course_key, qualifiers={'category': 'video'},
revision=ModuleStoreEnum.RevisionOption.published_only, include_orphans=False):
all_videos.append(video)
for video in store.get_items(course_key, qualifiers={'category': 'video'},
revision=ModuleStoreEnum.RevisionOption.draft_only, include_orphans=False):
all_videos.append(video)
return all_videos
@chord_task(
bind=True,
base=LoggedPersistOnFailureTask,
default_retry_delay=RETRY_DELAY_SECONDS,
max_retries=2,
time_limit=VIDEO_LEVEL_TIMEOUT_SECONDS
)
def async_migrate_transcript_subtask(self, *args, **kwargs):
#pylint: disable=unused-argument
"""
Migrates a transcript of a given video in a course as a new celery task.
"""
video, language_code, force_update = args
commit = kwargs['commit']
result = None
if commit is not True:
return 'Language {0} transcript of video {1} will be migrated'.format(
language_code,
video.edx_video_id
)
LOGGER.info("[Transcript migration] process for %s transcript started", language_code)
try:
transcript_info = video.get_transcripts_info()
transcript_content, _, _ = get_transcript_from_contentstore(
video, language_code, Transcript.SJSON, transcript_info)
edx_video_id = clean_video_id(video.edx_video_id)
if not edx_video_id:
video.edx_video_id = create_external_video('external-video')
video.save_with_metadata(user=User.objects.get(username='staff'))
if edx_video_id:
result = save_transcript_to_storage(
edx_video_id,
language_code,
transcript_content,
Transcript.SJSON,
force_update
)
except (NotFoundError, TranscriptsGenerationException, ValCannotCreateError) as exc:
LOGGER.exception('[Transcript migration] Exception: %r', text_type(exc))
return 'Failed: language {language} of video {video} with exception {exception}'.format(
language=language_code,
video=video.edx_video_id,
exception=text_type(exc)
)
LOGGER.info("[Transcript migration] process for %s transcript ended", language_code)
if result is not None:
return 'Success: language {0} of video {1}'.format(language_code, video.edx_video_id)
else:
return 'Failed: language {0} of video {1}'.format(language_code, video.edx_video_id)
def save_transcript_to_storage(
edx_video_id,
language_code,
transcript_content,
file_format=Transcript.SJSON,
force_update=False
):
"""
Pushes a given transcript's data to django storage.
"""
try:
result = None
edx_video_id = clean_video_id(edx_video_id)
if force_update:
result = create_or_update_video_transcript(
edx_video_id,
language_code,
dict({'file_format': file_format}),
ContentFile(transcript_content)
)
LOGGER.info("[Transcript migration] save_transcript_to_storage %s for %s with create_or_update method",
True if result else False, edx_video_id)
else:
result = create_video_transcript(
edx_video_id,
language_code,
file_format,
ContentFile(transcript_content)
)
LOGGER.info(
"[Transcript migration] save_transcript_to_storage %s for %s with create method",
result,
edx_video_id
)
return result
except ValCannotCreateError as err:
LOGGER.exception("[Transcript migration] save_transcript_to_storage_failed: %s", err)
raise
def clone_instance(instance, field_values):

View File

@@ -92,6 +92,8 @@ class ImportRequiredTestCases(ContentStoreTestCase):
"""
Tests which legitimately need to import a course
"""
shard = 1
def test_no_static_link_rewrites_on_import(self):
course_items = import_course_from_xml(
self.store, self.user.id, TEST_DATA_DIR, ['toy'], create_if_not_present=True
@@ -609,6 +611,8 @@ class MiscCourseTests(ContentStoreTestCase):
"""
Tests that rely on the toy courses.
"""
shard = 1
def setUp(self):
super(MiscCourseTests, self).setUp()
# save locs not items b/c the items won't have the subsequently created children in them until refetched
@@ -1157,6 +1161,7 @@ class ContentStoreTest(ContentStoreTestCase):
"""
Tests for the CMS ContentStore application.
"""
shard = 1
duplicate_course_error = ("There is already a course defined with the same organization and course number. "
"Please change either organization or course number to be unique.")
@@ -1805,6 +1810,7 @@ class ContentStoreTest(ContentStoreTestCase):
class MetadataSaveTestCase(ContentStoreTestCase):
"""Test that metadata is correctly cached and decached."""
shard = 1
def setUp(self):
super(MetadataSaveTestCase, self).setUp()
@@ -1866,6 +1872,8 @@ class RerunCourseTest(ContentStoreTestCase):
"""
Tests for Rerunning a course via the view handler
"""
shard = 1
def setUp(self):
super(RerunCourseTest, self).setUp()
self.destination_course_data = {
@@ -1950,6 +1958,23 @@ class RerunCourseTest(ContentStoreTestCase):
self.assertEqual(0, len(videos))
self.assertInCourseListing(destination_course_key)
def test_rerun_course_video_upload_token(self):
"""
Test when rerunning a course with video upload token, video upload token is not copied to new course.
"""
# Create a course with video upload token.
source_course = CourseFactory.create(video_upload_pipeline={"course_video_upload_token": 'test-token'})
destination_course_key = self.post_rerun_request(source_course.id)
self.verify_rerun_course(source_course.id, destination_course_key, self.destination_course_data['display_name'])
self.assertInCourseListing(destination_course_key)
# Verify video upload pipeline is empty.
source_course = self.store.get_course(source_course.id)
new_course = self.store.get_course(destination_course_key)
self.assertDictEqual(source_course.video_upload_pipeline, {"course_video_upload_token": 'test-token'})
self.assertEqual(new_course.video_upload_pipeline, {})
def test_rerun_course_success(self):
source_course = CourseFactory.create()
create_video(
@@ -1970,6 +1995,10 @@ class RerunCourseTest(ContentStoreTestCase):
self.assertEqual(1, len(source_videos))
self.assertEqual(source_videos, target_videos)
# Verify that video upload token is empty for rerun.
new_course = self.store.get_course(destination_course_key)
self.assertEqual(new_course.video_upload_pipeline, {})
def test_rerun_course_resets_advertised_date(self):
source_course = CourseFactory.create(advertised_start="01-12-2015")
destination_course_key = self.post_rerun_request(source_course.id)
@@ -2108,6 +2137,8 @@ class ContentLicenseTest(ContentStoreTestCase):
"""
Tests around content licenses
"""
shard = 1
def test_course_license_export(self):
content_store = contentstore()
root_dir = path(mkdtemp_clean())
@@ -2146,6 +2177,8 @@ class EntryPageTestCase(TestCase):
"""
Tests entry pages that aren't specific to a course.
"""
shard = 1
def setUp(self):
super(EntryPageTestCase, self).setUp()
self.client = AjaxEnabledTestClient()
@@ -2180,6 +2213,7 @@ class SigninPageTestCase(TestCase):
important to make sure that the script is functional independently of any
other script.
"""
shard = 1
def test_csrf_token_is_present_in_form(self):
# Expected html:

View File

@@ -6,7 +6,6 @@ import random
import ddt
from ccx_keys.locator import CCXLocator
from chrono import Timer
from django.conf import settings
from django.test import RequestFactory
from mock import Mock, patch

View File

@@ -41,6 +41,8 @@ class CourseSettingsEncoderTest(CourseTestCase):
"""
Tests for CourseSettingsEncoder.
"""
shard = 1
def test_encoder(self):
details = CourseDetails.fetch(self.course.id)
jsondetails = json.dumps(details, cls=CourseSettingsEncoder)
@@ -87,6 +89,8 @@ class CourseDetailsViewTest(CourseTestCase, MilestonesTestCaseMixin):
"""
Tests for modifying content on the first course settings page (course dates, overview, etc.).
"""
shard = 1
def alter_field(self, url, details, field, val):
"""
Change the one field to the given value and then invoke the update post to see if it worked.
@@ -435,6 +439,8 @@ class CourseGradingTest(CourseTestCase):
"""
Tests for the course settings grading page.
"""
shard = 1
def test_initial_grader(self):
test_grader = CourseGradingModel(self.course)
self.assertIsNotNone(test_grader.graders)
@@ -769,6 +775,8 @@ class CourseMetadataEditingTest(CourseTestCase):
"""
Tests for CourseMetadata.
"""
shard = 1
def setUp(self):
CourseTestCase.setUp(self)
self.fullcourse = CourseFactory.create()
@@ -1158,6 +1166,8 @@ class CourseGraderUpdatesTest(CourseTestCase):
"""
Test getting, deleting, adding, & updating graders
"""
shard = 1
def setUp(self):
"""Compute the url to use in tests"""
super(CourseGraderUpdatesTest, self).setUp()
@@ -1223,6 +1233,8 @@ class CourseEnrollmentEndFieldTest(CourseTestCase):
Base class to test the enrollment end fields in the course settings details view in Studio
when using marketing site flag and global vs non-global staff to access the page.
"""
shard = 1
NOT_EDITABLE_HELPER_MESSAGE = "Contact your edX partner manager to update these settings."
NOT_EDITABLE_DATE_WRAPPER = "<div class=\"field date is-not-editable\" id=\"field-enrollment-end-date\">"
NOT_EDITABLE_TIME_WRAPPER = "<div class=\"field time is-not-editable\" id=\"field-enrollment-end-time\">"

View File

@@ -185,6 +185,7 @@ class MixedWithOptionsTestCase(MixedSplitTestCase):
@ddt.ddt
class TestCoursewareSearchIndexer(MixedWithOptionsTestCase):
""" Tests the operation of the CoursewareSearchIndexer """
shard = 1
WORKS_WITH_STORES = (ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split)
@@ -605,6 +606,7 @@ class TestCoursewareSearchIndexer(MixedWithOptionsTestCase):
@ddt.ddt
class TestLargeCourseDeletions(MixedWithOptionsTestCase):
""" Tests to excerise deleting items from a course """
shard = 1
WORKS_WITH_STORES = (ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split)
@@ -688,6 +690,7 @@ class TestTaskExecution(SharedModuleStoreTestCase):
being present, which allows us to ensure that when the listener is
executed, it is done as expected.
"""
shard = 1
@classmethod
def setUpClass(cls):
@@ -782,6 +785,7 @@ class TestTaskExecution(SharedModuleStoreTestCase):
@ddt.ddt
class TestLibrarySearchIndexer(MixedWithOptionsTestCase):
""" Tests the operation of the CoursewareSearchIndexer """
shard = 1
# libraries work only with split, so do library indexer
WORKS_WITH_STORES = (ModuleStoreEnum.Type.split, )
@@ -955,6 +959,8 @@ class GroupConfigurationSearchMongo(CourseTestCase, MixedWithOptionsTestCase):
"""
Tests indexing of content groups on course modules using mongo modulestore.
"""
shard = 1
MODULESTORE = TEST_DATA_MONGO_MODULESTORE
INDEX_NAME = CoursewareSearchIndexer.INDEX_NAME

View File

@@ -36,6 +36,8 @@ class LibraryTestCase(ModuleStoreTestCase):
"""
Common functionality for content libraries tests
"""
shard = 1
def setUp(self):
super(LibraryTestCase, self).setUp()
@@ -148,6 +150,8 @@ class TestLibraries(LibraryTestCase):
"""
High-level tests for libraries
"""
shard = 1
@ddt.data(
(2, 1, 1),
(2, 2, 2),
@@ -480,6 +484,8 @@ class TestLibraryAccess(SignalDisconnectTestMixin, LibraryTestCase):
"""
Test Roles and Permissions related to Content Libraries
"""
shard = 1
def setUp(self):
""" Create a library, staff user, and non-staff user """
super(TestLibraryAccess, self).setUp()
@@ -813,6 +819,8 @@ class TestOverrides(LibraryTestCase):
"""
Test that overriding block Scope.settings fields from a library in a specific course works
"""
shard = 1
def setUp(self):
super(TestOverrides, self).setUp()
self.original_display_name = "A Problem Block"
@@ -997,6 +1005,8 @@ class TestIncompatibleModuleStore(LibraryTestCase):
"""
Tests for proper validation errors with an incompatible course modulestore.
"""
shard = 1
def setUp(self):
super(TestIncompatibleModuleStore, self).setUp()
# Create a course in an incompatible modulestore.

View File

@@ -191,7 +191,9 @@ class TestYoutubeSubsBase(SharedModuleStoreTestCase):
@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE)
class TestDownloadYoutubeSubs(TestYoutubeSubsBase):
"""Tests for `download_youtube_subs` function."""
"""
Tests for `download_youtube_subs` function.
"""
org = 'MITx'
number = '999'
@@ -238,13 +240,6 @@ class TestDownloadYoutubeSubs(TestYoutubeSubsBase):
mock_get.assert_any_call('http://video.google.com/timedtext', params={'lang': 'en', 'v': 'good_id_2'})
# Check asset status after import of transcript.
filename = 'subs_{0}.srt.sjson'.format(good_youtube_sub)
content_location = StaticContent.compute_location(self.course.id, filename)
self.assertTrue(contentstore().find(content_location))
self.clear_sub_content(good_youtube_sub)
def test_subs_for_html5_vid_with_periods(self):
"""
This is to verify a fix whereby subtitle files uploaded against
@@ -269,16 +264,6 @@ class TestDownloadYoutubeSubs(TestYoutubeSubsBase):
with self.assertRaises(transcripts_utils.GetTranscriptsFromYouTubeException):
transcripts_utils.download_youtube_subs(bad_youtube_sub, self.course, settings)
# Check asset status after import of transcript.
filename = 'subs_{0}.srt.sjson'.format(bad_youtube_sub)
content_location = StaticContent.compute_location(
self.course.id, filename
)
with self.assertRaises(NotFoundError):
contentstore().find(content_location)
self.clear_sub_content(bad_youtube_sub)
def test_success_downloading_chinese_transcripts(self):
# Disabled 11/14/13
@@ -367,13 +352,6 @@ class TestDownloadYoutubeSubs(TestYoutubeSubsBase):
params={'lang': 'en', 'v': 'good_id_2', 'name': 'Custom'}
)
# Check asset status after import of transcript.
filename = 'subs_{0}.srt.sjson'.format(good_youtube_sub)
content_location = StaticContent.compute_location(self.course.id, filename)
self.assertTrue(contentstore().find(content_location))
self.clear_sub_content(good_youtube_sub)
class TestGenerateSubsFromSource(TestDownloadYoutubeSubs):
"""Tests for `generate_subs_from_source` function."""
@@ -766,7 +744,7 @@ class TestGetTranscript(SharedModuleStoreTestCase):
edx_video_id=u'1234-5678-90'
)
def create_transcript(self, subs_id, language=u'en', filename='video.srt'):
def create_transcript(self, subs_id, language=u'en', filename='video.srt', youtube_id_1_0='', html5_sources=None):
"""
create transcript.
"""
@@ -774,21 +752,26 @@ class TestGetTranscript(SharedModuleStoreTestCase):
if language != u'en':
transcripts = {language: filename}
html5_sources = html5_sources or []
self.video = ItemFactory.create(
category='video',
parent_location=self.vertical.location,
sub=subs_id,
youtube_id_1_0=youtube_id_1_0,
transcripts=transcripts,
edx_video_id=u'1234-5678-90'
edx_video_id=u'1234-5678-90',
html5_sources=html5_sources
)
if subs_id:
transcripts_utils.save_subs_to_store(
self.subs_sjson,
subs_id,
self.video,
language=language,
)
possible_subs = [subs_id, youtube_id_1_0] + transcripts_utils.get_html5_ids(html5_sources)
for possible_sub in possible_subs:
if possible_sub:
transcripts_utils.save_subs_to_store(
self.subs_sjson,
possible_sub,
self.video,
language=language,
)
def create_srt_file(self, content):
"""
@@ -834,31 +817,69 @@ class TestGetTranscript(SharedModuleStoreTestCase):
)
@ddt.data(
# video.sub transcript
{
'language': u'en',
'subs_id': 'video_101',
'filename': 'en_video_101.srt',
'youtube_id_1_0': '',
'html5_sources': [],
'expected_filename': 'en_video_101.srt',
},
# if video.sub is present, rest will be skipped.
{
'language': u'en',
'subs_id': 'video_101',
'youtube_id_1_0': 'test_yt_id',
'html5_sources': ['www.abc.com/foo.mp4'],
'expected_filename': 'en_video_101.srt',
},
# video.youtube_id_1_0 transcript
{
'language': u'en',
'subs_id': '',
'youtube_id_1_0': 'test_yt_id',
'html5_sources': [],
'expected_filename': 'en_test_yt_id.srt',
},
# video.html5_sources transcript
{
'language': u'en',
'subs_id': '',
'youtube_id_1_0': '',
'html5_sources': ['www.abc.com/foo.mp4'],
'expected_filename': 'en_foo.srt',
},
# non-english transcript
{
'language': u'ur',
'subs_id': '',
'filename': 'ur_video_101.srt',
'youtube_id_1_0': '',
'html5_sources': [],
'expected_filename': 'ur_video_101.srt',
},
)
@ddt.unpack
def test_get_transcript_from_content_store(self, language, subs_id, filename):
def test_get_transcript_from_contentstore(
self,
language,
subs_id,
youtube_id_1_0,
html5_sources,
expected_filename
):
"""
Verify that `get_transcript` function returns correct data when transcript is in content store.
"""
self.upload_file(self.create_srt_file(self.subs_srt), self.video.location, filename)
self.create_transcript(subs_id, language, filename)
content, filename, mimetype = transcripts_utils.get_transcript(
base_filename = 'video_101.srt'
self.upload_file(self.create_srt_file(self.subs_srt), self.video.location, base_filename)
self.create_transcript(subs_id, language, base_filename, youtube_id_1_0, html5_sources)
content, file_name, mimetype = transcripts_utils.get_transcript(
self.video,
language
)
self.assertEqual(content, self.subs[language])
self.assertEqual(filename, filename)
self.assertEqual(file_name, expected_filename)
self.assertEqual(mimetype, self.srt_mime_type)
def test_get_transcript_from_content_store_for_ur(self):
@@ -938,3 +959,43 @@ class TestGetTranscript(SharedModuleStoreTestCase):
exception_message = text_type(no_en_transcript_exception.exception)
self.assertEqual(exception_message, 'No transcript for `en` language')
@ddt.data(
transcripts_utils.TranscriptsGenerationException,
UnicodeDecodeError('aliencodec', b'\x02\x01', 1, 2, 'alien codec found!')
)
@patch('xmodule.video_module.transcripts_utils.Transcript')
def test_get_transcript_val_exceptions(self, exception_to_raise, mock_Transcript):
"""
Verify that `get_transcript_from_val` function raises `NotFoundError` when specified exceptions raised.
"""
mock_Transcript.convert.side_effect = exception_to_raise
transcripts_info = self.video.get_transcripts_info()
lang = self.video.get_default_transcript_language(transcripts_info)
edx_video_id = transcripts_utils.clean_video_id(self.video.edx_video_id)
with self.assertRaises(NotFoundError):
transcripts_utils.get_transcript_from_val(
edx_video_id,
lang=lang,
output_format=transcripts_utils.Transcript.SRT
)
@ddt.data(
transcripts_utils.TranscriptsGenerationException,
UnicodeDecodeError('aliencodec', b'\x02\x01', 1, 2, 'alien codec found!')
)
@patch('xmodule.video_module.transcripts_utils.Transcript')
def test_get_transcript_content_store_exceptions(self, exception_to_raise, mock_Transcript):
"""
Verify that `get_transcript_from_contentstore` function raises `NotFoundError` when specified exceptions raised.
"""
mock_Transcript.asset.side_effect = exception_to_raise
transcripts_info = self.video.get_transcripts_info()
lang = self.video.get_default_transcript_language(transcripts_info)
with self.assertRaises(NotFoundError):
transcripts_utils.get_transcript_from_contentstore(
self.video,
language=lang,
output_format=transcripts_utils.Transcript.SRT,
transcripts_info=transcripts_info
)

View File

@@ -313,7 +313,12 @@ class CourseTestCase(ProceduralCourseTestMixin, ModuleStoreTestCase):
self.assertEqual(course1_item.data, course2_item.data)
# compare meta-data
self.assertEqual(own_metadata(course1_item), own_metadata(course2_item))
course1_metadata = own_metadata(course1_item)
course2_metadata = own_metadata(course2_item)
# Omit edx_video_id as it can be different in case of extrnal video imports.
course1_metadata.pop('edx_video_id', None)
course2_metadata.pop('edx_video_id', None)
self.assertEqual(course1_metadata, course2_metadata)
# compare children
self.assertEqual(course1_item.has_children, course2_item.has_children)

View File

@@ -150,6 +150,7 @@ def container_handler(request, usage_key_string):
index += 1
return render_to_response('container.html', {
'language_code': request.LANGUAGE_CODE,
'context_course': course, # Needed only for display of menus at top of page.
'action': action,
'xblock': xblock,

View File

@@ -5,6 +5,7 @@ import copy
import json
import logging
import random
import re
import string # pylint: disable=deprecated-module
import django.utils
@@ -56,7 +57,6 @@ from milestones import api as milestones_api
from models.settings.course_grading import CourseGradingModel
from models.settings.course_metadata import CourseMetadata
from models.settings.encoder import CourseSettingsEncoder
from openedx.core.djangoapps.content.course_structures.api.v0 import api, errors
from openedx.core.djangoapps.credit.api import get_credit_requirements, is_credit_course
from openedx.core.djangoapps.credit.tasks import update_credit_course_requirements
from openedx.core.djangoapps.models.course_details import CourseDetails
@@ -586,13 +586,18 @@ def _deprecated_blocks_info(course_module, deprecated_block_types):
'advance_settings_url': reverse_course_url('advanced_settings_handler', course_module.id)
}
try:
structure_data = api.course_structure(course_module.id, block_types=deprecated_block_types)
except errors.CourseStructureNotAvailableError:
return data
deprecated_blocks = modulestore().get_items(
course_module.id,
qualifiers={
'category': re.compile('^' + '$|^'.join(deprecated_block_types) + '$')
}
)
for block in structure_data['blocks'].values():
data['blocks'].append([reverse_usage_url('container_handler', block['parent']), block['display_name']])
for block in deprecated_blocks:
data['blocks'].append([
reverse_usage_url('container_handler', block.parent),
block.display_name
])
return data
@@ -906,6 +911,7 @@ def rerun_course(user, source_course_key, org, number, run, fields, async=True):
# Clear the fields that must be reset for the rerun
fields['advertised_start'] = None
fields['video_upload_pipeline'] = {}
json_fields = json.dumps(fields, cls=EdxJSONEncoder)
args = [unicode(source_course_key), unicode(destination_course_key), user.id, json_fields]

View File

@@ -24,7 +24,7 @@ def signup(request):
Display the signup form.
"""
csrf_token = csrf(request)['csrf_token']
if request.user.is_authenticated():
if request.user.is_authenticated:
return redirect('/course/')
if settings.FEATURES.get('AUTH_USE_CERTIFICATES_IMMEDIATE_SIGNUP'):
# Redirect to course to login to process their certificate if SSL is enabled
@@ -68,7 +68,7 @@ def login_page(request):
def howitworks(request):
"Proxy view"
if request.user.is_authenticated():
if request.user.is_authenticated:
return redirect('/home/')
else:
return render_to_response('howitworks.html', {})

View File

@@ -106,6 +106,7 @@ class CertificatesBaseTestCase(object):
"""
Mixin with base test cases for the certificates.
"""
shard = 1
def _remove_ids(self, content):
"""
@@ -199,6 +200,8 @@ class CertificatesListHandlerTestCase(
"""
Test cases for certificates_list_handler.
"""
shard = 1
def setUp(self):
"""
Set up CertificatesListHandlerTestCase.
@@ -425,6 +428,7 @@ class CertificatesDetailHandlerTestCase(
"""
Test cases for CertificatesDetailHandlerTestCase.
"""
shard = 1
_id = 0

View File

@@ -211,6 +211,7 @@ class ContainerPageTestCase(StudioPageTestCase, LibraryTestCase):
"""
request = RequestFactory().get('foo')
request.user = self.user
request.LANGUAGE_CODE = 'en'
# Check for invalid 'usage_key_strings'
self.assertRaises(

View File

@@ -42,6 +42,8 @@ class TestCourseIndex(CourseTestCase):
"""
Unit tests for getting the list of courses and the course outline.
"""
shard = 1
def setUp(self):
"""
Add a course with odd characters in the fields
@@ -315,6 +317,8 @@ class TestCourseIndexArchived(CourseTestCase):
"""
Unit tests for testing the course index list when there are archived courses.
"""
shard = 1
NOW = datetime.datetime.now(pytz.utc)
DAY = datetime.timedelta(days=1)
YESTERDAY = NOW - DAY
@@ -426,6 +430,7 @@ class TestCourseOutline(CourseTestCase):
"""
Unit tests for the course outline.
"""
shard = 1
ENABLED_SIGNALS = ['course_published']
def setUp(self):
@@ -628,79 +633,12 @@ class TestCourseOutline(CourseTestCase):
expected_block_types
)
@ddt.data(
{'delete_vertical': True},
{'delete_vertical': False},
)
@ddt.unpack
def test_deprecated_blocks_list_updated_correctly(self, delete_vertical):
"""
Verify that deprecated blocks list shown on banner is updated correctly.
Here is the scenario:
This list of deprecated blocks shown on banner contains published
and un-published blocks. That list should be updated when we delete
un-published block(s). This behavior should be same if we delete
unpublished vertical or problem.
"""
block_types = ['notes']
course_module = modulestore().get_item(self.course.location)
vertical1 = ItemFactory.create(
parent_location=self.sequential.location, category='vertical', display_name='Vert1 Subsection1'
)
problem1 = ItemFactory.create(
parent_location=vertical1.location,
category='notes',
display_name='notes problem in vert1',
publish_item=False
)
info = _deprecated_blocks_info(course_module, block_types)
# info['blocks'] should be empty here because there is nothing
# published or un-published present
self.assertEqual(info['blocks'], [])
vertical2 = ItemFactory.create(
parent_location=self.sequential.location, category='vertical', display_name='Vert2 Subsection1'
)
ItemFactory.create(
parent_location=vertical2.location,
category='notes',
display_name='notes problem in vert2',
pubish_item=True
)
# At this point CourseStructure will contain both the above
# published and un-published verticals
info = _deprecated_blocks_info(course_module, block_types)
self.assertItemsEqual(
info['blocks'],
[
[reverse_usage_url('container_handler', vertical1.location), 'notes problem in vert1'],
[reverse_usage_url('container_handler', vertical2.location), 'notes problem in vert2']
]
)
# Delete the un-published vertical or problem so that CourseStructure updates its data
if delete_vertical:
self.store.delete_item(vertical1.location, self.user.id)
else:
self.store.delete_item(problem1.location, self.user.id)
info = _deprecated_blocks_info(course_module, block_types)
# info['blocks'] should only contain the info about vertical2 which is published.
# There shouldn't be any info present about un-published vertical1
self.assertEqual(
info['blocks'],
[[reverse_usage_url('container_handler', vertical2.location), 'notes problem in vert2']]
)
class TestCourseReIndex(CourseTestCase):
"""
Unit tests for the course outline.
"""
shard = 1
SUCCESSFUL_RESPONSE = _("Course has been successfully reindexed.")
ENABLED_SIGNALS = ['course_published']

View File

@@ -168,6 +168,8 @@ class GroupConfigurationsBaseTestCase(object):
"""
Mixin with base test cases for the group configurations.
"""
shard = 1
def _remove_ids(self, content):
"""
Remove ids from the response. We cannot predict IDs, because they're
@@ -240,6 +242,8 @@ class GroupConfigurationsListHandlerTestCase(CourseTestCase, GroupConfigurations
"""
Test cases for group_configurations_list_handler.
"""
shard = 1
def _url(self):
"""
Return url for the handler.
@@ -331,6 +335,7 @@ class GroupConfigurationsDetailHandlerTestCase(CourseTestCase, GroupConfiguratio
Test cases for group_configurations_detail_handler.
"""
shard = 1
ID = 0
def _url(self, cid=-1):
@@ -634,6 +639,8 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods):
"""
Tests for usage information of configurations and content groups.
"""
shard = 1
def _get_user_partition(self, scheme):
"""
Returns the first user partition with the specified scheme.
@@ -1066,6 +1073,8 @@ class GroupConfigurationsValidationTestCase(CourseTestCase, HelperMethods):
"""
Tests for validation in Group Configurations.
"""
shard = 1
@patch('xmodule.split_test_module.SplitTestDescriptor.validate_split_test')
def verify_validation_add_usage_info(self, expected_result, mocked_message, mocked_validation_messages):
"""

View File

@@ -125,6 +125,7 @@ class ItemTest(CourseTestCase):
@ddt.ddt
class GetItemTest(ItemTest):
"""Tests for '/xblock' GET url."""
shard = 1
def _get_preview(self, usage_key, data=None):
""" Makes a request to xblock preview handler """
@@ -469,6 +470,8 @@ class GetItemTest(ItemTest):
@ddt.ddt
class DeleteItem(ItemTest):
"""Tests for '/xblock' DELETE url."""
shard = 1
@ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split)
def test_delete_static_page(self, store):
course = CourseFactory.create(default_store=store)
@@ -485,6 +488,8 @@ class TestCreateItem(ItemTest):
"""
Test the create_item handler thoroughly
"""
shard = 1
def test_create_nicely(self):
"""
Try the straightforward use cases
@@ -660,6 +665,8 @@ class TestDuplicateItem(ItemTest, DuplicateHelper):
"""
Test the duplicate method.
"""
shard = 1
def setUp(self):
""" Creates the test course structure and a few components to 'duplicate'. """
super(TestDuplicateItem, self).setUp()
@@ -766,6 +773,8 @@ class TestMoveItem(ItemTest):
"""
Tests for move item.
"""
shard = 1
def setUp(self):
"""
Creates the test course structure to build course outline tree.
@@ -1317,6 +1326,8 @@ class TestDuplicateItemWithAsides(ItemTest, DuplicateHelper):
"""
Test the duplicate method for blocks with asides.
"""
shard = 1
MODULESTORE = TEST_DATA_SPLIT_MODULESTORE
def setUp(self):
@@ -1379,6 +1390,8 @@ class TestEditItemSetup(ItemTest):
"""
Setup for xblock update tests.
"""
shard = 1
def setUp(self):
""" Creates the test course structure and a couple problems to 'edit'. """
super(TestEditItemSetup, self).setUp()
@@ -1409,6 +1422,8 @@ class TestEditItem(TestEditItemSetup):
"""
Test xblock update.
"""
shard = 1
def test_delete_field(self):
"""
Sending null in for a field 'deletes' it
@@ -1848,6 +1863,7 @@ class TestEditItemSplitMongo(TestEditItemSetup):
"""
Tests for EditItem running on top of the SplitMongoModuleStore.
"""
shard = 1
MODULESTORE = TEST_DATA_SPLIT_MODULESTORE
def test_editing_view_wrappers(self):
@@ -1869,6 +1885,8 @@ class TestEditSplitModule(ItemTest):
"""
Tests around editing instances of the split_test module.
"""
shard = 1
def setUp(self):
super(TestEditSplitModule, self).setUp()
self.user = UserFactory()
@@ -2090,6 +2108,8 @@ class TestEditSplitModule(ItemTest):
@ddt.ddt
class TestComponentHandler(TestCase):
shard = 1
def setUp(self):
super(TestComponentHandler, self).setUp()
@@ -2150,6 +2170,7 @@ class TestComponentTemplates(CourseTestCase):
"""
Unit tests for the generation of the component templates for a course.
"""
shard = 1
def setUp(self):
super(TestComponentTemplates, self).setUp()
@@ -2385,6 +2406,8 @@ class TestXBlockInfo(ItemTest):
"""
Unit tests for XBlock's outline handling.
"""
shard = 1
def setUp(self):
super(TestXBlockInfo, self).setUp()
user_id = self.user.id
@@ -2731,6 +2754,8 @@ class TestLibraryXBlockInfo(ModuleStoreTestCase):
"""
Unit tests for XBlock Info for XBlocks in a content library
"""
shard = 1
def setUp(self):
super(TestLibraryXBlockInfo, self).setUp()
user_id = self.user.id
@@ -2780,6 +2805,8 @@ class TestLibraryXBlockCreation(ItemTest):
"""
Tests the adding of XBlocks to Library
"""
shard = 1
def test_add_xblock(self):
"""
Verify we can add an XBlock to a Library.
@@ -2816,6 +2843,7 @@ class TestXBlockPublishingInfo(ItemTest):
"""
Unit tests for XBlock's outline handling.
"""
shard = 1
FIRST_SUBSECTION_PATH = [0]
FIRST_UNIT_PATH = [0, 0]
SECOND_UNIT_PATH = [0, 1]

View File

@@ -5,6 +5,7 @@ from io import BytesIO
from mock import Mock, patch, ANY
from django.test.testcases import TestCase
from django.core.urlresolvers import reverse
from edxval import api
from contentstore.tests.utils import CourseTestCase
@@ -177,26 +178,24 @@ class TranscriptCredentialsValidationTest(TestCase):
@ddt.ddt
@patch(
'openedx.core.djangoapps.video_config.models.VideoTranscriptEnabledFlag.feature_enabled',
Mock(return_value=True)
)
class TranscriptDownloadTest(CourseTestCase):
"""
Tests for transcript download handler.
"""
VIEW_NAME = 'transcript_download_handler'
def get_url_for_course_key(self, course_id):
return reverse_course_url(self.VIEW_NAME, course_id)
@property
def view_url(self):
"""
Returns url for this view
"""
return reverse('transcript_download_handler')
def test_302_with_anonymous_user(self):
"""
Verify that redirection happens in case of unauthorized request.
"""
self.client.logout()
transcript_download_url = self.get_url_for_course_key(self.course.id)
response = self.client.get(transcript_download_url, content_type='application/json')
response = self.client.get(self.view_url, content_type='application/json')
self.assertEqual(response.status_code, 302)
def test_405_with_not_allowed_request_method(self):
@@ -204,26 +203,14 @@ class TranscriptDownloadTest(CourseTestCase):
Verify that 405 is returned in case of not-allowed request methods.
Allowed request methods include GET.
"""
transcript_download_url = self.get_url_for_course_key(self.course.id)
response = self.client.post(transcript_download_url, content_type='application/json')
response = self.client.post(self.view_url, content_type='application/json')
self.assertEqual(response.status_code, 405)
def test_404_with_feature_disabled(self):
"""
Verify that 404 is returned if the corresponding feature is disabled.
"""
transcript_download_url = self.get_url_for_course_key(self.course.id)
with patch('openedx.core.djangoapps.video_config.models.VideoTranscriptEnabledFlag.feature_enabled') as feature:
feature.return_value = False
response = self.client.get(transcript_download_url, content_type='application/json')
self.assertEqual(response.status_code, 404)
@patch('contentstore.views.transcript_settings.get_video_transcript_data')
def test_transcript_download_handler(self, mock_get_video_transcript_data):
"""
Tests that transcript download handler works as expected.
"""
transcript_download_url = self.get_url_for_course_key(self.course.id)
mock_get_video_transcript_data.return_value = {
'content': json.dumps({
"start": [10],
@@ -235,7 +222,7 @@ class TranscriptDownloadTest(CourseTestCase):
# Make request to transcript download handler
response = self.client.get(
transcript_download_url,
self.view_url,
data={
'edx_video_id': '123',
'language_code': 'en'
@@ -277,34 +264,30 @@ class TranscriptDownloadTest(CourseTestCase):
Tests that transcript download handler with missing attributes.
"""
# Make request to transcript download handler
transcript_download_url = self.get_url_for_course_key(self.course.id)
response = self.client.get(transcript_download_url, data=request_payload)
response = self.client.get(self.view_url, data=request_payload)
# Assert the response
self.assertEqual(response.status_code, 400)
self.assertEqual(json.loads(response.content)['error'], expected_error_message)
@ddt.ddt
@patch(
'openedx.core.djangoapps.video_config.models.VideoTranscriptEnabledFlag.feature_enabled',
Mock(return_value=True)
)
class TranscriptUploadTest(CourseTestCase):
"""
Tests for transcript upload handler.
"""
VIEW_NAME = 'transcript_upload_handler'
def get_url_for_course_key(self, course_id):
return reverse_course_url(self.VIEW_NAME, course_id)
@property
def view_url(self):
"""
Returns url for this view
"""
return reverse('transcript_upload_handler')
def test_302_with_anonymous_user(self):
"""
Verify that redirection happens in case of unauthorized request.
"""
self.client.logout()
transcript_upload_url = self.get_url_for_course_key(self.course.id)
response = self.client.post(transcript_upload_url, content_type='application/json')
response = self.client.post(self.view_url, content_type='application/json')
self.assertEqual(response.status_code, 302)
def test_405_with_not_allowed_request_method(self):
@@ -312,31 +295,19 @@ class TranscriptUploadTest(CourseTestCase):
Verify that 405 is returned in case of not-allowed request methods.
Allowed request methods include POST.
"""
transcript_upload_url = self.get_url_for_course_key(self.course.id)
response = self.client.get(transcript_upload_url, content_type='application/json')
response = self.client.get(self.view_url, content_type='application/json')
self.assertEqual(response.status_code, 405)
def test_404_with_feature_disabled(self):
"""
Verify that 404 is returned if the corresponding feature is disabled.
"""
transcript_upload_url = self.get_url_for_course_key(self.course.id)
with patch('openedx.core.djangoapps.video_config.models.VideoTranscriptEnabledFlag.feature_enabled') as feature:
feature.return_value = False
response = self.client.post(transcript_upload_url, content_type='application/json')
self.assertEqual(response.status_code, 404)
@patch('contentstore.views.transcript_settings.create_or_update_video_transcript')
@patch('contentstore.views.transcript_settings.get_available_transcript_languages', Mock(return_value=['en']))
def test_transcript_upload_handler(self, mock_create_or_update_video_transcript):
"""
Tests that transcript upload handler works as expected.
"""
transcript_upload_url = self.get_url_for_course_key(self.course.id)
transcript_file_stream = BytesIO('0\n00:00:00,010 --> 00:00:00,100\nПривіт, edX вітає вас.\n\n')
# Make request to transcript upload handler
response = self.client.post(
transcript_upload_url,
self.view_url,
{
'edx_video_id': '123',
'language_code': 'en',
@@ -395,9 +366,8 @@ class TranscriptUploadTest(CourseTestCase):
"""
Tests the transcript upload handler when the required attributes are missing.
"""
transcript_upload_url = self.get_url_for_course_key(self.course.id)
# Make request to transcript upload handler
response = self.client.post(transcript_upload_url, request_payload, format='multipart')
response = self.client.post(self.view_url, request_payload, format='multipart')
self.assertEqual(response.status_code, 400)
self.assertEqual(json.loads(response.content)['error'], expected_error_message)
@@ -407,14 +377,13 @@ class TranscriptUploadTest(CourseTestCase):
Tests that upload handler do not update transcript's language if a transcript
with the same language already present for an edx_video_id.
"""
transcript_upload_url = self.get_url_for_course_key(self.course.id)
# Make request to transcript upload handler
request_payload = {
'edx_video_id': '1234',
'language_code': 'en',
'new_language_code': 'es'
}
response = self.client.post(transcript_upload_url, request_payload, format='multipart')
response = self.client.post(self.view_url, request_payload, format='multipart')
self.assertEqual(response.status_code, 400)
self.assertEqual(
json.loads(response.content)['error'],
@@ -427,10 +396,9 @@ class TranscriptUploadTest(CourseTestCase):
Tests the transcript upload handler with an image file.
"""
with make_image_file() as image_file:
transcript_upload_url = self.get_url_for_course_key(self.course.id)
# Make request to transcript upload handler
response = self.client.post(
transcript_upload_url,
self.view_url,
{
'edx_video_id': '123',
'language_code': 'en',
@@ -451,11 +419,10 @@ class TranscriptUploadTest(CourseTestCase):
"""
Tests the transcript upload handler with an invalid transcript file.
"""
transcript_upload_url = self.get_url_for_course_key(self.course.id)
transcript_file_stream = BytesIO('An invalid transcript SubRip file content')
# Make request to transcript upload handler
response = self.client.post(
transcript_upload_url,
self.view_url,
{
'edx_video_id': '123',
'language_code': 'en',
@@ -473,11 +440,7 @@ class TranscriptUploadTest(CourseTestCase):
@ddt.ddt
@patch(
'openedx.core.djangoapps.video_config.models.VideoTranscriptEnabledFlag.feature_enabled',
Mock(return_value=True)
)
class TranscriptUploadTest(CourseTestCase):
class TranscriptDeleteTest(CourseTestCase):
"""
Tests for transcript deletion handler.
"""
@@ -504,16 +467,6 @@ class TranscriptUploadTest(CourseTestCase):
response = self.client.post(transcript_delete_url)
self.assertEqual(response.status_code, 405)
def test_404_with_feature_disabled(self):
"""
Verify that 404 is returned if the corresponding feature is disabled.
"""
transcript_delete_url = self.get_url_for_course_key(self.course.id, edx_video_id='test_id', language_code='en')
with patch('openedx.core.djangoapps.video_config.models.VideoTranscriptEnabledFlag.feature_enabled') as feature:
feature.return_value = False
response = self.client.delete(transcript_delete_url)
self.assertEqual(response.status_code, 404)
def test_404_with_non_staff_user(self):
"""
Verify that 404 is returned if the user doesn't have studio write access.

View File

@@ -1,9 +1,10 @@
"""Tests for items views."""
import copy
from codecs import BOM_UTF8
import ddt
import json
import os
from mock import patch, Mock
import tempfile
import textwrap
from uuid import uuid4
@@ -11,7 +12,7 @@ from uuid import uuid4
from django.conf import settings
from django.core.urlresolvers import reverse
from django.test.utils import override_settings
from mock import patch, Mock
from edxval.api import create_video
from opaque_keys.edx.keys import UsageKey
from contentstore.tests.utils import CourseTestCase, mock_requests_get
@@ -20,11 +21,32 @@ from xmodule.contentstore.content import StaticContent
from xmodule.contentstore.django import contentstore
from xmodule.exceptions import NotFoundError
from xmodule.modulestore.django import modulestore
from xmodule.video_module import transcripts_utils
from xmodule.video_module.transcripts_utils import (
GetTranscriptsFromYouTubeException,
get_video_transcript_content,
remove_subs_from_store,
Transcript,
)
TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE)
TEST_DATA_CONTENTSTORE['DOC_STORE_CONFIG']['db'] = 'test_xcontent_%s' % uuid4().hex
SRT_TRANSCRIPT_CONTENT = """0
00:00:10,500 --> 00:00:13,000
Elephant's Dream
1
00:00:15,000 --> 00:00:18,000
At the left we can see...
"""
SJSON_TRANSCRIPT_CONTENT = Transcript.convert(
SRT_TRANSCRIPT_CONTENT,
Transcript.SRT,
Transcript.SJSON,
)
@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE)
class BaseTranscripts(CourseTestCase):
@@ -95,498 +117,716 @@ class BaseTranscripts(CourseTestCase):
1.5: item.youtube_id_1_5
}
class TestUploadTranscripts(BaseTranscripts):
"""
Tests for '/transcripts/upload' url.
"""
def setUp(self):
"""Create initial data."""
super(TestUploadTranscripts, self).setUp()
self.good_srt_file = tempfile.NamedTemporaryFile(suffix='.srt')
self.good_srt_file.write(textwrap.dedent("""
1
00:00:10,500 --> 00:00:13,000
Elephant's Dream
2
00:00:15,000 --> 00:00:18,000
At the left we can see...
"""))
self.good_srt_file.seek(0)
self.bad_data_srt_file = tempfile.NamedTemporaryFile(suffix='.srt')
self.bad_data_srt_file.write('Some BAD data')
self.bad_data_srt_file.seek(0)
self.bad_name_srt_file = tempfile.NamedTemporaryFile(suffix='.BAD')
self.bad_name_srt_file.write(textwrap.dedent("""
1
00:00:10,500 --> 00:00:13,000
Elephant's Dream
2
00:00:15,000 --> 00:00:18,000
At the left we can see...
"""))
self.bad_name_srt_file.seek(0)
self.ufeff_srt_file = tempfile.NamedTemporaryFile(suffix='.srt')
def test_success_video_module_source_subs_uploading(self):
self.item.data = textwrap.dedent("""
<video youtube="">
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.mp4"/>
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.webm"/>
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.ogv"/>
</video>
""")
modulestore().update_item(self.item, self.user.id)
link = reverse('upload_transcripts')
filename = os.path.splitext(os.path.basename(self.good_srt_file.name))[0]
resp = self.client.post(link, {
'locator': self.video_usage_key,
'transcript-file': self.good_srt_file,
'video_list': json.dumps([{
'type': 'html5',
'video': filename,
'mode': 'mp4',
}])
})
self.assertEqual(resp.status_code, 200)
self.assertEqual(json.loads(resp.content).get('status'), 'Success')
item = modulestore().get_item(self.video_usage_key)
self.assertEqual(item.sub, filename)
content_location = StaticContent.compute_location(
self.course.id, 'subs_{0}.srt.sjson'.format(filename))
self.assertTrue(contentstore().find(content_location))
def test_fail_data_without_id(self):
link = reverse('upload_transcripts')
resp = self.client.post(link, {'transcript-file': self.good_srt_file})
self.assertEqual(resp.status_code, 400)
self.assertEqual(json.loads(resp.content).get('status'), 'POST data without "locator" form data.')
def test_fail_data_without_file(self):
link = reverse('upload_transcripts')
resp = self.client.post(link, {'locator': self.video_usage_key})
self.assertEqual(resp.status_code, 400)
self.assertEqual(json.loads(resp.content).get('status'), 'POST data without "file" form data.')
def test_fail_data_with_bad_locator(self):
# Test for raising `InvalidLocationError` exception.
link = reverse('upload_transcripts')
filename = os.path.splitext(os.path.basename(self.good_srt_file.name))[0]
resp = self.client.post(link, {
'locator': 'BAD_LOCATOR',
'transcript-file': self.good_srt_file,
'video_list': json.dumps([{
'type': 'html5',
'video': filename,
'mode': 'mp4',
}])
})
self.assertEqual(resp.status_code, 400)
self.assertEqual(json.loads(resp.content).get('status'), "Can't find item by locator.")
# Test for raising `ItemNotFoundError` exception.
link = reverse('upload_transcripts')
filename = os.path.splitext(os.path.basename(self.good_srt_file.name))[0]
resp = self.client.post(link, {
'locator': '{0}_{1}'.format(self.video_usage_key, 'BAD_LOCATOR'),
'transcript-file': self.good_srt_file,
'video_list': json.dumps([{
'type': 'html5',
'video': filename,
'mode': 'mp4',
}])
})
self.assertEqual(resp.status_code, 400)
self.assertEqual(json.loads(resp.content).get('status'), "Can't find item by locator.")
def test_fail_for_non_video_module(self):
# non_video module: setup
def create_non_video_module(self):
"""
Setup non video module for tests.
"""
data = {
'parent_locator': unicode(self.course.location),
'category': 'non_video',
'type': 'non_video'
}
resp = self.client.ajax_post('/xblock/', data)
usage_key = self._get_usage_key(resp)
response = self.client.ajax_post('/xblock/', data)
usage_key = self._get_usage_key(response)
item = modulestore().get_item(usage_key)
item.data = '<non_video youtube="0.75:JMD_ifUUfsU,1.0:hI10vDNYz4M" />'
modulestore().update_item(item, self.user.id)
# non_video module: testing
return usage_key
link = reverse('upload_transcripts')
filename = os.path.splitext(os.path.basename(self.good_srt_file.name))[0]
resp = self.client.post(link, {
'locator': unicode(usage_key),
'transcript-file': self.good_srt_file,
'video_list': json.dumps([{
'type': 'html5',
'video': filename,
'mode': 'mp4',
}])
})
self.assertEqual(resp.status_code, 400)
self.assertEqual(json.loads(resp.content).get('status'), 'Transcripts are supported only for "video" modules.')
def assert_response(self, response, expected_status_code, expected_message):
response_content = json.loads(response.content)
self.assertEqual(response.status_code, expected_status_code)
self.assertEqual(response_content['status'], expected_message)
def test_fail_bad_xml(self):
self.item.data = '<<<video youtube="0.75:JMD_ifUUfsU,1.25:AKqURZnYqpk,1.50:DYpADpL7jAY" />'
modulestore().update_item(self.item, self.user.id)
link = reverse('upload_transcripts')
filename = os.path.splitext(os.path.basename(self.good_srt_file.name))[0]
resp = self.client.post(link, {
'locator': unicode(self.video_usage_key),
'transcript-file': self.good_srt_file,
'video_list': json.dumps([{
'type': 'html5',
'video': filename,
'mode': 'mp4',
}])
@ddt.ddt
class TestUploadTranscripts(BaseTranscripts):
"""
Tests for '/transcripts/upload' endpoint.
"""
def setUp(self):
super(TestUploadTranscripts, self).setUp()
self.contents = {
'good': SRT_TRANSCRIPT_CONTENT,
'bad': 'Some BAD data',
}
# Create temporary transcript files
self.good_srt_file = self.create_transcript_file(content=self.contents['good'], suffix='.srt')
self.bad_data_srt_file = self.create_transcript_file(content=self.contents['bad'], suffix='.srt')
self.bad_name_srt_file = self.create_transcript_file(content=self.contents['good'], suffix='.bad')
self.bom_srt_file = self.create_transcript_file(content=self.contents['good'], suffix='.srt', include_bom=True)
# Setup a VEDA produced video and persist `edx_video_id` in VAL.
create_video({
'edx_video_id': u'123-456-789',
'status': 'upload',
'client_video_id': u'Test Video',
'duration': 0,
'encoded_videos': [],
'courses': [unicode(self.course.id)]
})
self.assertEqual(resp.status_code, 400)
# incorrect xml produces incorrect item category error
self.assertEqual(json.loads(resp.content).get('status'), 'Transcripts are supported only for "video" modules.')
# Add clean up handler
self.addCleanup(self.clean_temporary_transcripts)
def test_fail_bad_data_srt_file(self):
link = reverse('upload_transcripts')
filename = os.path.splitext(os.path.basename(self.bad_data_srt_file.name))[0]
resp = self.client.post(link, {
'locator': unicode(self.video_usage_key),
'transcript-file': self.bad_data_srt_file,
'video_list': json.dumps([{
'type': 'html5',
'video': filename,
'mode': 'mp4',
}])
})
self.assertEqual(resp.status_code, 400)
self.assertEqual(json.loads(resp.content).get('status'), 'Something wrong with SubRip transcripts file during parsing.')
def test_fail_bad_name_srt_file(self):
link = reverse('upload_transcripts')
filename = os.path.splitext(os.path.basename(self.bad_name_srt_file.name))[0]
resp = self.client.post(link, {
'locator': unicode(self.video_usage_key),
'transcript-file': self.bad_name_srt_file,
'video_list': json.dumps([{
'type': 'html5',
'video': filename,
'mode': 'mp4',
}])
})
self.assertEqual(resp.status_code, 400)
self.assertEqual(json.loads(resp.content).get('status'), 'We support only SubRip (*.srt) transcripts format.')
def test_undefined_file_extension(self):
srt_file = tempfile.NamedTemporaryFile(suffix='')
srt_file.write(textwrap.dedent("""
1
00:00:10,500 --> 00:00:13,000
Elephant's Dream
2
00:00:15,000 --> 00:00:18,000
At the left we can see...
"""))
srt_file.seek(0)
link = reverse('upload_transcripts')
filename = os.path.splitext(os.path.basename(srt_file.name))[0]
resp = self.client.post(link, {
'locator': self.video_usage_key,
'transcript-file': srt_file,
'video_list': json.dumps([{
'type': 'html5',
'video': filename,
'mode': 'mp4',
}])
})
self.assertEqual(resp.status_code, 400)
self.assertEqual(json.loads(resp.content).get('status'), 'Undefined file extension.')
def test_subs_uploading_with_byte_order_mark(self):
def create_transcript_file(self, content, suffix, include_bom=False):
"""
Test uploading subs containing BOM(Byte Order Mark), e.g. U+FEFF
Setup a transcript file with suffix and content.
"""
filedata = textwrap.dedent("""
1
00:00:10,500 --> 00:00:13,000
Test ufeff characters
transcript_file = tempfile.NamedTemporaryFile(suffix=suffix)
wrapped_content = textwrap.dedent(content)
if include_bom:
wrapped_content = wrapped_content.encode('utf-8-sig')
# Verify that ufeff(BOM) character is in content.
self.assertIn(BOM_UTF8, wrapped_content)
2
00:00:15,000 --> 00:00:18,000
At the left we can see...
""").encode('utf-8-sig')
transcript_file.write(wrapped_content)
transcript_file.seek(0)
# Verify that ufeff character is in filedata.
self.assertIn("ufeff", filedata)
self.ufeff_srt_file.write(filedata)
self.ufeff_srt_file.seek(0)
link = reverse('upload_transcripts')
filename = os.path.splitext(os.path.basename(self.ufeff_srt_file.name))[0]
resp = self.client.post(link, {
'locator': self.video_usage_key,
'transcript-file': self.ufeff_srt_file,
'video_list': json.dumps([{
'type': 'html5',
'video': filename,
'mode': 'mp4',
}])
})
self.assertEqual(resp.status_code, 200)
content_location = StaticContent.compute_location(
self.course.id, 'subs_{0}.srt.sjson'.format(filename))
self.assertTrue(contentstore().find(content_location))
subs_text = json.loads(contentstore().find(content_location).data).get('text')
self.assertIn("Test ufeff characters", subs_text)
def tearDown(self):
super(TestUploadTranscripts, self).tearDown()
return transcript_file
def clean_temporary_transcripts(self):
"""
Close transcript files gracefully.
"""
self.good_srt_file.close()
self.bad_data_srt_file.close()
self.bad_name_srt_file.close()
self.ufeff_srt_file.close()
self.bom_srt_file.close()
def upload_transcript(self, locator, transcript_file, edx_video_id=None):
"""
Uploads a transcript for a video
"""
payload = {}
if locator:
payload.update({'locator': locator})
if edx_video_id is not None:
payload.update({'edx_video_id': edx_video_id})
if transcript_file:
payload.update({'transcript-file': transcript_file})
upload_url = reverse('upload_transcripts')
response = self.client.post(upload_url, payload)
return response
@ddt.data(
(u'123-456-789', False),
(u'', False),
(u'123-456-789', True)
)
@ddt.unpack
def test_transcript_upload_success(self, edx_video_id, include_bom):
"""
Tests transcript file upload to video component works as
expected in case of following:
1. External video component
2. VEDA produced video component
3. Transcript content containing BOM character
"""
# In case of an external video component, the `edx_video_id` must be empty
# and VEDA produced video component will have `edx_video_id` set to VAL video ID.
self.item.edx_video_id = edx_video_id
modulestore().update_item(self.item, self.user.id)
# Upload a transcript
transcript_file = self.bom_srt_file if include_bom else self.good_srt_file
response = self.upload_transcript(self.video_usage_key, transcript_file, '')
# Verify the response
self.assert_response(response, expected_status_code=200, expected_message='Success')
# Verify the `edx_video_id` on the video component
json_response = json.loads(response.content)
expected_edx_video_id = edx_video_id if edx_video_id else json_response['edx_video_id']
video = modulestore().get_item(self.video_usage_key)
self.assertEqual(video.edx_video_id, expected_edx_video_id)
# Verify transcript content
actual_transcript = get_video_transcript_content(video.edx_video_id, language_code=u'en')
actual_sjson_content = json.loads(actual_transcript['content'])
expected_sjson_content = json.loads(Transcript.convert(
self.contents['good'],
input_format=Transcript.SRT,
output_format=Transcript.SJSON
))
self.assertDictEqual(actual_sjson_content, expected_sjson_content)
def test_transcript_upload_without_locator(self):
"""
Test that transcript upload validation fails if the video locator is missing
"""
response = self.upload_transcript(locator=None, transcript_file=self.good_srt_file, edx_video_id='')
self.assert_response(
response,
expected_status_code=400,
expected_message=u'Video locator is required.'
)
def test_transcript_upload_without_file(self):
"""
Test that transcript upload validation fails if transcript file is missing
"""
response = self.upload_transcript(locator=self.video_usage_key, transcript_file=None, edx_video_id='')
self.assert_response(
response,
expected_status_code=400,
expected_message=u'A transcript file is required.'
)
def test_transcript_upload_bad_format(self):
"""
Test that transcript upload validation fails if transcript format is not SRT
"""
response = self.upload_transcript(
locator=self.video_usage_key,
transcript_file=self.bad_name_srt_file,
edx_video_id=''
)
self.assert_response(
response,
expected_status_code=400,
expected_message=u'This transcript file type is not supported.'
)
def test_transcript_upload_bad_content(self):
"""
Test that transcript upload validation fails in case of bad transcript content.
"""
# Request to upload transcript for the video
response = self.upload_transcript(
locator=self.video_usage_key,
transcript_file=self.bad_data_srt_file,
edx_video_id=''
)
self.assert_response(
response,
expected_status_code=400,
expected_message=u'There is a problem with this transcript file. Try to upload a different file.'
)
def test_transcript_upload_unknown_category(self):
"""
Test that transcript upload validation fails if item's category is other than video.
"""
# non_video module setup - i.e. an item whose category is not 'video'.
usage_key = self.create_non_video_module()
# Request to upload transcript for the item
response = self.upload_transcript(locator=usage_key, transcript_file=self.good_srt_file, edx_video_id='')
self.assert_response(
response,
expected_status_code=400,
expected_message=u'Transcripts are supported only for "video" modules.'
)
def test_transcript_upload_non_existent_item(self):
"""
Test that transcript upload validation fails in case of invalid item's locator.
"""
# Request to upload transcript for the item
response = self.upload_transcript(
locator='non_existent_locator',
transcript_file=self.good_srt_file,
edx_video_id=''
)
self.assert_response(
response,
expected_status_code=400,
expected_message=u'Cannot find item by locator.'
)
def test_transcript_upload_without_edx_video_id(self):
"""
Test that transcript upload validation fails if the `edx_video_id` is missing
"""
response = self.upload_transcript(locator=self.video_usage_key, transcript_file=self.good_srt_file)
self.assert_response(
response,
expected_status_code=400,
expected_message=u'Video ID is required.'
)
def test_transcript_upload_with_non_existant_edx_video_id(self):
"""
Test that transcript upload works as expected if `edx_video_id` set on
video descriptor is different from `edx_video_id` received in POST request.
"""
non_existant_edx_video_id = '1111-2222-3333-4444'
# Upload with non-existant `edx_video_id`
response = self.upload_transcript(
locator=self.video_usage_key,
transcript_file=self.good_srt_file,
edx_video_id=non_existant_edx_video_id
)
# Verify the response
self.assert_response(response, expected_status_code=400, expected_message='Invalid Video ID')
# Verify transcript does not exist for non-existant `edx_video_id`
self.assertIsNone(get_video_transcript_content(non_existant_edx_video_id, language_code=u'en'))
@ddt.ddt
class TestChooseTranscripts(BaseTranscripts):
"""
Tests for '/transcripts/choose' endpoint.
"""
def setUp(self):
super(TestChooseTranscripts, self).setUp()
# Create test transcript in contentstore
self.chosen_html5_id = 'test_html5_subs'
self.sjson_subs = Transcript.convert(SRT_TRANSCRIPT_CONTENT, Transcript.SRT, Transcript.SJSON)
self.save_subs_to_store(json.loads(self.sjson_subs), self.chosen_html5_id)
# Setup a VEDA produced video and persist `edx_video_id` in VAL.
create_video({
'edx_video_id': u'123-456-789',
'status': 'upload',
'client_video_id': u'Test Video',
'duration': 0,
'encoded_videos': [],
'courses': [unicode(self.course.id)]
})
def choose_transcript(self, locator, chosen_html5_id):
"""
Make an endpoint call to choose transcript
"""
payload = {}
if locator:
payload.update({'locator': unicode(locator)})
if chosen_html5_id:
payload.update({'html5_id': chosen_html5_id})
choose_transcript_url = reverse('choose_transcripts')
response = self.client.get(choose_transcript_url, {'data': json.dumps(payload)})
return response
@ddt.data(u'123-456-789', u'')
def test_choose_transcript_success(self, edx_video_id):
"""
Verify that choosing transcript file in video component basic tab works as
expected in case of following:
1. External video component
2. VEDA produced video component
"""
# In case of an external video component, the `edx_video_id` must be empty
# and VEDA produced video component will have `edx_video_id` set to VAL video ID.
self.item.edx_video_id = edx_video_id
modulestore().update_item(self.item, self.user.id)
# Make call to choose a transcript
response = self.choose_transcript(self.video_usage_key, self.chosen_html5_id)
# Verify the response
self.assert_response(response, expected_status_code=200, expected_message='Success')
# Verify the `edx_video_id` on the video component
json_response = json.loads(response.content)
expected_edx_video_id = edx_video_id if edx_video_id else json_response['edx_video_id']
video = modulestore().get_item(self.video_usage_key)
self.assertEqual(video.edx_video_id, expected_edx_video_id)
# Verify transcript content
actual_transcript = get_video_transcript_content(video.edx_video_id, language_code=u'en')
actual_sjson_content = json.loads(actual_transcript['content'])
expected_sjson_content = json.loads(self.sjson_subs)
self.assertDictEqual(actual_sjson_content, expected_sjson_content)
def test_choose_transcript_fails_without_data(self):
"""
Verify that choose transcript fails if we do not provide video data in request.
"""
response = self.choose_transcript(locator=None, chosen_html5_id=None)
self.assert_response(
response,
expected_status_code=400,
expected_message=u'Incoming video data is empty.'
)
def test_choose_transcript_fails_without_locator(self):
"""
Verify that choose transcript fails if video locator is missing in request.
"""
response = self.choose_transcript(locator=None, chosen_html5_id=self.chosen_html5_id)
self.assert_response(
response,
expected_status_code=400,
expected_message=u'Cannot find item by locator.'
)
def test_choose_transcript_with_no_html5_transcript(self):
"""
Verify that choose transcript fails if the chosen html5 ID don't
have any transcript associated in contentstore.
"""
response = self.choose_transcript(locator=self.video_usage_key, chosen_html5_id='non-existent-html5-id')
self.assert_response(
response,
expected_status_code=400,
expected_message=u"No such transcript."
)
def test_choose_transcript_fails_on_unknown_category(self):
"""
Test that transcript choose validation fails if item's category is other than video.
"""
# non_video module setup - i.e. an item whose category is not 'video'.
usage_key = self.create_non_video_module()
# Request to choose transcript for the item
response = self.choose_transcript(locator=usage_key, chosen_html5_id=self.chosen_html5_id)
self.assert_response(
response,
expected_status_code=400,
expected_message=u'Transcripts are supported only for "video" modules.'
)
@ddt.ddt
class TestRenameTranscripts(BaseTranscripts):
"""
Tests for '/transcripts/rename' endpoint.
"""
def setUp(self):
super(TestRenameTranscripts, self).setUp()
# Create test transcript in contentstore and update item's sub.
self.item.sub = 'test_video_subs'
self.sjson_subs = Transcript.convert(SRT_TRANSCRIPT_CONTENT, Transcript.SRT, Transcript.SJSON)
self.save_subs_to_store(json.loads(self.sjson_subs), self.item.sub)
modulestore().update_item(self.item, self.user.id)
# Setup a VEDA produced video and persist `edx_video_id` in VAL.
create_video({
'edx_video_id': u'123-456-789',
'status': 'upload',
'client_video_id': u'Test Video',
'duration': 0,
'encoded_videos': [],
'courses': [unicode(self.course.id)]
})
def rename_transcript(self, locator):
"""
Make an endpoint call to rename transcripts.
"""
payload = {}
if locator:
payload.update({'locator': unicode(locator)})
rename_transcript_url = reverse('rename_transcripts')
response = self.client.get(rename_transcript_url, {'data': json.dumps(payload)})
return response
@ddt.data(u'123-456-789', u'')
def test_rename_transcript_success(self, edx_video_id):
"""
Verify that "use current transcript" in video component basic tab works as
expected in case of following:
1. External video component
2. VEDA produced video component
"""
# In case of an external video component, the `edx_video_id` must be empty
# and VEDA produced video component will have `edx_video_id` set to VAL video ID.
self.item.edx_video_id = edx_video_id
modulestore().update_item(self.item, self.user.id)
# Make call to use current transcript from contentstore
response = self.rename_transcript(self.video_usage_key)
# Verify the response
self.assert_response(response, expected_status_code=200, expected_message='Success')
# Verify the `edx_video_id` on the video component
json_response = json.loads(response.content)
expected_edx_video_id = edx_video_id if edx_video_id else json_response['edx_video_id']
video = modulestore().get_item(self.video_usage_key)
self.assertEqual(video.edx_video_id, expected_edx_video_id)
# Verify transcript content
actual_transcript = get_video_transcript_content(video.edx_video_id, language_code=u'en')
actual_sjson_content = json.loads(actual_transcript['content'])
expected_sjson_content = json.loads(self.sjson_subs)
self.assertDictEqual(actual_sjson_content, expected_sjson_content)
def test_rename_transcript_fails_without_data(self):
"""
Verify that use current transcript fails if we do not provide video data in request.
"""
response = self.rename_transcript(locator=None)
self.assert_response(
response,
expected_status_code=400,
expected_message=u'Incoming video data is empty.'
)
def test_rename_transcript_fails_with_invalid_locator(self):
"""
Verify that use current transcript fails if video locator is missing in request.
"""
response = self.rename_transcript(locator='non-existent-locator')
self.assert_response(
response,
expected_status_code=400,
expected_message=u'Cannot find item by locator.'
)
def test_rename_transcript_with_non_existent_sub(self):
"""
Verify that rename transcript fails if the `item.sub` don't
have any transcript associated in contentstore.
"""
# Update item's sub to an id who does not have any
# transcript associated in contentstore.
self.item.sub = 'non-existent-sub'
modulestore().update_item(self.item, self.user.id)
response = self.rename_transcript(locator=self.video_usage_key)
self.assert_response(
response,
expected_status_code=400,
expected_message=u"No such transcript."
)
def test_rename_transcript_fails_on_unknown_category(self):
"""
Test that validation fails if item's category is other than video.
"""
# non_video module setup - i.e. an item whose category is not 'video'.
usage_key = self.create_non_video_module()
# Make call to use current transcript from contentstore.
response = self.rename_transcript(usage_key)
self.assert_response(
response,
expected_status_code=400,
expected_message=u'Transcripts are supported only for "video" modules.'
)
@ddt.ddt
@patch('contentstore.views.transcripts_ajax.download_youtube_subs', Mock(return_value=SJSON_TRANSCRIPT_CONTENT))
class TestReplaceTranscripts(BaseTranscripts):
"""
Tests for '/transcripts/replace' endpoint.
"""
def setUp(self):
super(TestReplaceTranscripts, self).setUp()
self.youtube_id = 'test_yt_id'
# Setup a VEDA produced video and persist `edx_video_id` in VAL.
create_video({
'edx_video_id': u'123-456-789',
'status': 'upload',
'client_video_id': u'Test Video',
'duration': 0,
'encoded_videos': [],
'courses': [unicode(self.course.id)]
})
def replace_transcript(self, locator, youtube_id):
"""
Make an endpoint call to replace transcripts with youtube ones.
"""
payload = {}
if locator:
payload.update({'locator': unicode(locator)})
if youtube_id:
payload.update({
'videos': [
{
'type': 'youtube',
'video': youtube_id
}
]
})
replace_transcript_url = reverse('replace_transcripts')
response = self.client.get(replace_transcript_url, {'data': json.dumps(payload)})
return response
@ddt.data(u'123-456-789', u'')
def test_replace_transcript_success(self, edx_video_id):
"""
Verify that "import from youtube" in video component basic tab works as
expected in case of following:
1. External video component
2. VEDA produced video component
"""
# In case of an external video component, the `edx_video_id` must be empty
# and VEDA produced video component will have `edx_video_id` set to VAL video ID.
self.item.edx_video_id = edx_video_id
modulestore().update_item(self.item, self.user.id)
# Make call to replace transcripts from youtube
response = self.replace_transcript(self.video_usage_key, self.youtube_id)
# Verify the response
self.assert_response(response, expected_status_code=200, expected_message='Success')
# Verify the `edx_video_id` on the video component
json_response = json.loads(response.content)
expected_edx_video_id = edx_video_id if edx_video_id else json_response['edx_video_id']
video = modulestore().get_item(self.video_usage_key)
self.assertEqual(video.edx_video_id, expected_edx_video_id)
# Verify transcript content
actual_transcript = get_video_transcript_content(video.edx_video_id, language_code=u'en')
actual_sjson_content = json.loads(actual_transcript['content'])
expected_sjson_content = json.loads(SJSON_TRANSCRIPT_CONTENT)
self.assertDictEqual(actual_sjson_content, expected_sjson_content)
def test_replace_transcript_fails_without_data(self):
"""
Verify that replace transcript fails if we do not provide video data in request.
"""
response = self.replace_transcript(locator=None, youtube_id=None)
self.assert_response(
response,
expected_status_code=400,
expected_message=u'Incoming video data is empty.'
)
def test_replace_transcript_fails_with_invalid_locator(self):
"""
Verify that replace transcript fails if a video locator does not exist.
"""
response = self.replace_transcript(locator='non-existent-locator', youtube_id=self.youtube_id)
self.assert_response(
response,
expected_status_code=400,
expected_message=u'Cannot find item by locator.'
)
def test_replace_transcript_fails_without_yt_id(self):
"""
Verify that replace transcript fails if youtube id is not provided.
"""
response = self.replace_transcript(locator=self.video_usage_key, youtube_id=None)
self.assert_response(
response,
expected_status_code=400,
expected_message=u'YouTube ID is required.'
)
def test_replace_transcript_no_transcript_on_yt(self):
"""
Verify that replace transcript fails if YouTube does not have transcript for the given youtube id.
"""
error_message = u'YT ID not found.'
with patch('contentstore.views.transcripts_ajax.download_youtube_subs') as mock_download_youtube_subs:
mock_download_youtube_subs.side_effect = GetTranscriptsFromYouTubeException(error_message)
response = self.replace_transcript(locator=self.video_usage_key, youtube_id='non-existent-yt-id')
self.assertContains(response, text=error_message, status_code=400)
def test_replace_transcript_fails_on_unknown_category(self):
"""
Test that validation fails if item's category is other than video.
"""
# non_video module setup - i.e. an item whose category is not 'video'.
usage_key = self.create_non_video_module()
response = self.replace_transcript(usage_key, youtube_id=self.youtube_id)
self.assert_response(
response,
expected_status_code=400,
expected_message=u'Transcripts are supported only for "video" modules.'
)
class TestDownloadTranscripts(BaseTranscripts):
"""
Tests for '/transcripts/download' url.
"""
def test_success_download_youtube(self):
self.item.data = '<video youtube="1:JMD_ifUUfsU" />'
modulestore().update_item(self.item, self.user.id)
subs = {
'start': [100, 200, 240],
'end': [200, 240, 380],
'text': [
'subs #1',
'subs #2',
'subs #3'
]
}
self.save_subs_to_store(subs, 'JMD_ifUUfsU')
link = reverse('download_transcripts')
resp = self.client.get(link, {'locator': self.video_usage_key, 'subs_id': "JMD_ifUUfsU"})
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.content, """0\n00:00:00,100 --> 00:00:00,200\nsubs #1\n\n1\n00:00:00,200 --> 00:00:00,240\nsubs #2\n\n2\n00:00:00,240 --> 00:00:00,380\nsubs #3\n\n""")
def test_success_download_nonyoutube(self):
subs_id = str(uuid4())
self.item.data = textwrap.dedent("""
<video youtube="" sub="{}">
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.mp4"/>
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.webm"/>
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.ogv"/>
</video>
""".format(subs_id))
modulestore().update_item(self.item, self.user.id)
subs = {
'start': [100, 200, 240],
'end': [200, 240, 380],
'text': [
'subs #1',
'subs #2',
'subs #3'
]
}
self.save_subs_to_store(subs, subs_id)
link = reverse('download_transcripts')
resp = self.client.get(link, {'locator': self.video_usage_key, 'subs_id': subs_id})
self.assertEqual(resp.status_code, 200)
self.assertEqual(
resp.content,
'0\n00:00:00,100 --> 00:00:00,200\nsubs #1\n\n1\n00:00:00,200 --> '
'00:00:00,240\nsubs #2\n\n2\n00:00:00,240 --> 00:00:00,380\nsubs #3\n\n'
)
transcripts_utils.remove_subs_from_store(subs_id, self.item)
def test_fail_data_without_file(self):
link = reverse('download_transcripts')
resp = self.client.get(link, {'locator': ''})
self.assertEqual(resp.status_code, 404)
resp = self.client.get(link, {})
self.assertEqual(resp.status_code, 404)
def test_fail_data_with_bad_locator(self):
# Test for raising `InvalidLocationError` exception.
link = reverse('download_transcripts')
resp = self.client.get(link, {'locator': 'BAD_LOCATOR'})
self.assertEqual(resp.status_code, 404)
# Test for raising `ItemNotFoundError` exception.
link = reverse('download_transcripts')
resp = self.client.get(link, {'locator': '{0}_{1}'.format(self.video_usage_key, 'BAD_LOCATOR')})
self.assertEqual(resp.status_code, 404)
def test_fail_for_non_video_module(self):
# Video module: setup
data = {
'parent_locator': unicode(self.course.location),
'category': 'videoalpha',
'type': 'videoalpha'
}
resp = self.client.ajax_post('/xblock/', data)
usage_key = self._get_usage_key(resp)
subs_id = str(uuid4())
item = modulestore().get_item(usage_key)
item.data = textwrap.dedent("""
<videoalpha youtube="" sub="{}">
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.mp4"/>
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.webm"/>
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.ogv"/>
</videoalpha>
""".format(subs_id))
modulestore().update_item(item, self.user.id)
subs = {
'start': [100, 200, 240],
'end': [200, 240, 380],
'text': [
'subs #1',
'subs #2',
'subs #3'
]
}
self.save_subs_to_store(subs, subs_id)
link = reverse('download_transcripts')
resp = self.client.get(link, {'locator': unicode(usage_key)})
self.assertEqual(resp.status_code, 404)
def test_fail_nonyoutube_subs_dont_exist(self):
self.item.data = textwrap.dedent("""
<video youtube="" sub="UNDEFINED">
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.mp4"/>
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.webm"/>
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.ogv"/>
</video>
""")
modulestore().update_item(self.item, self.user.id)
link = reverse('download_transcripts')
resp = self.client.get(link, {'locator': self.video_usage_key})
self.assertEqual(resp.status_code, 404)
def test_empty_youtube_attr_and_sub_attr(self):
self.item.data = textwrap.dedent("""
<video youtube="">
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.mp4"/>
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.webm"/>
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.ogv"/>
</video>
""")
modulestore().update_item(self.item, self.user.id)
link = reverse('download_transcripts')
resp = self.client.get(link, {'locator': self.video_usage_key})
self.assertEqual(resp.status_code, 404)
def test_fail_bad_sjson_subs(self):
subs_id = str(uuid4())
self.item.data = textwrap.dedent("""
<video youtube="" sub="{}">
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.mp4"/>
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.webm"/>
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.ogv"/>
</video>
""".format(subs_id))
modulestore().update_item(self.item, self.user.id)
subs = {
'start': [100, 200, 240],
'end': [200, 240, 380],
'text': [
'subs #1'
]
}
self.save_subs_to_store(subs, 'JMD_ifUUfsU')
link = reverse('download_transcripts')
resp = self.client.get(link, {'locator': self.video_usage_key})
self.assertEqual(resp.status_code, 404)
@patch('openedx.core.djangoapps.video_config.models.VideoTranscriptEnabledFlag.feature_enabled', Mock(return_value=True))
@patch('xmodule.video_module.transcripts_utils.edxval_api.get_video_transcript_data')
def test_download_fallback_transcript(self, mock_get_video_transcript_data):
def update_video_component(self, sub=None, youtube_id=None):
"""
Verify that the val transcript is returned if its not found in content-store.
Updates video component with `sub` and `youtube_id`.
"""
mock_get_video_transcript_data.return_value = {
'content': json.dumps({
"start": [10],
"end": [100],
"text": ["Hi, welcome to Edx."],
}),
'file_name': 'edx.sjson'
}
sjson_transcript = json.loads(SJSON_TRANSCRIPT_CONTENT)
self.item.sub = sub
if sub:
self.save_subs_to_store(sjson_transcript, sub)
self.item.youtube_id_1_0 = youtube_id
if youtube_id:
self.save_subs_to_store(sjson_transcript, youtube_id)
self.item.data = textwrap.dedent("""
<video youtube="" sub="" edx_video_id="123">
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.mp4"/>
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.webm"/>
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.ogv"/>
</video>
""")
modulestore().update_item(self.item, self.user.id)
download_transcripts_url = reverse('download_transcripts')
response = self.client.get(download_transcripts_url, {'locator': self.video_usage_key})
# Expected response
expected_content = u'0\n00:00:00,010 --> 00:00:00,100\nHi, welcome to Edx.\n\n'
expected_headers = {
'content-disposition': 'attachment; filename="edx.srt"',
'content-type': 'application/x-subrip; charset=utf-8'
}
# Assert the actual response
self.assertEqual(response.status_code, 200)
self.assertEqual(response.content, expected_content)
for attribute, value in expected_headers.iteritems():
self.assertEqual(response.get(attribute), value)
@patch(
'openedx.core.djangoapps.video_config.models.VideoTranscriptEnabledFlag.feature_enabled',
Mock(return_value=False),
)
def test_download_fallback_transcript_feature_disabled(self):
def download_transcript(self, locator):
"""
Verify the transcript download when feature is disabled.
Makes a call to download transcripts.
"""
self.item.data = textwrap.dedent("""
<video youtube="" sub="">
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.mp4"/>
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.webm"/>
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.ogv"/>
</video>
""")
modulestore().update_item(self.item, self.user.id)
payload = {}
if locator:
payload.update({'locator': unicode(locator)})
download_transcripts_url = reverse('download_transcripts')
response = self.client.get(download_transcripts_url, {'locator': self.video_usage_key})
# Assert the actual response
self.assertEqual(response.status_code, 404)
download_transcript_url = reverse('download_transcripts')
response = self.client.get(download_transcript_url, payload)
return response
def assert_download_response(self, response, expected_status_code, expected_content=None):
"""
Verify transcript download response.
"""
self.assertEqual(response.status_code, expected_status_code)
if expected_content:
self.assertEqual(response.content, expected_content)
def test_download_youtube_transcript_success(self):
"""
Verify that the transcript associated to YT id is downloaded successfully.
"""
self.update_video_component(youtube_id='JMD_ifUUfsU')
response = self.download_transcript(locator=self.video_usage_key)
self.assert_download_response(response, expected_content=SRT_TRANSCRIPT_CONTENT, expected_status_code=200)
def test_download_non_youtube_transcript_success(self):
"""
Verify that the transcript associated to item's `sub` is downloaded successfully.
"""
self.update_video_component(sub='test_subs')
response = self.download_transcript(locator=self.video_usage_key)
self.assert_download_response(response, expected_content=SRT_TRANSCRIPT_CONTENT, expected_status_code=200)
def test_download_transcript_404_without_locator(self):
"""
Verify that download transcript returns 404 without locator.
"""
response = self.download_transcript(locator=None)
self.assert_download_response(response, expected_status_code=404)
def test_download_transcript_404_with_bad_locator(self):
"""
Verify that download transcript returns 404 with invalid locator.
"""
response = self.download_transcript(locator='invalid-locator')
self.assert_download_response(response, expected_status_code=404)
def test_download_transcript_404_for_non_video_module(self):
"""
Verify that download transcript returns 404 for a non video module.
"""
usage_key = self.create_non_video_module()
response = self.download_transcript(locator=usage_key)
self.assert_download_response(response, expected_status_code=404)
def test_download_transcript_404_for_no_yt_and_no_sub(self):
"""
Verify that download transcript returns 404 when video component
does not have sub and youtube id.
"""
self.update_video_component(sub=None, youtube_id=None)
response = self.download_transcript(locator=self.video_usage_key)
self.assert_download_response(response, expected_status_code=404)
@ddt.ddt
@@ -631,7 +871,6 @@ class TestCheckTranscripts(BaseTranscripts):
json.loads(resp.content),
{
u'status': u'Success',
u'subs': unicode(subs_id),
u'youtube_local': False,
u'is_youtube_mode': False,
u'youtube_server': False,
@@ -643,7 +882,7 @@ class TestCheckTranscripts(BaseTranscripts):
}
)
transcripts_utils.remove_subs_from_store(subs_id, self.item)
remove_subs_from_store(subs_id, self.item)
def test_check_youtube(self):
self.item.data = '<video youtube="1:JMD_ifUUfsU" />'
@@ -668,13 +907,14 @@ class TestCheckTranscripts(BaseTranscripts):
'mode': 'youtube',
}]
}
resp = self.client.get(link, {'data': json.dumps(data)})
self.assertEqual(resp.status_code, 200)
self.assertDictEqual(
json.loads(resp.content),
{
u'status': u'Success',
u'subs': u'JMD_ifUUfsU',
u'youtube_local': True,
u'is_youtube_mode': True,
u'youtube_server': False,
@@ -726,7 +966,6 @@ class TestCheckTranscripts(BaseTranscripts):
json.loads(resp.content),
{
u'status': u'Success',
u'subs': u'good_id_2',
u'youtube_local': True,
u'is_youtube_mode': True,
u'youtube_server': True,
@@ -824,19 +1063,21 @@ class TestCheckTranscripts(BaseTranscripts):
self.assertEqual(resp.status_code, 400)
self.assertEqual(json.loads(resp.content).get('status'), 'Transcripts are supported only for "video" modules.')
@ddt.data(
(True, 'found'),
(False, 'not_found')
)
@ddt.unpack
@patch('openedx.core.djangoapps.video_config.models.VideoTranscriptEnabledFlag.feature_enabled')
@patch('xmodule.video_module.transcripts_utils.edxval_api.get_video_transcript_data', Mock(return_value=True))
def test_command_for_fallback_transcript(self, feature_enabled, expected_command, video_transcript_feature):
@patch('xmodule.video_module.transcripts_utils.get_video_transcript_content')
def test_command_for_fallback_transcript(self, mock_get_video_transcript_content):
"""
Verify the command if a transcript is not found in content-store but
its there in edx-val.
Verify the command if a transcript is there in edx-val.
"""
video_transcript_feature.return_value = feature_enabled
mock_get_video_transcript_content.return_value = {
'content': json.dumps({
"start": [10],
"end": [100],
"text": ["Hi, welcome to Edx."],
}),
'file_name': 'edx.sjson'
}
# video_transcript_feature.return_value = feature_enabled
self.item.data = textwrap.dedent("""
<video youtube="" sub="" edx_video_id="123">
<source src="http://www.quirksmode.org/html5/videos/big_buck_bunny.mp4"/>
@@ -864,74 +1105,13 @@ class TestCheckTranscripts(BaseTranscripts):
json.loads(response.content),
{
u'status': u'Success',
u'subs': u'',
u'youtube_local': False,
u'is_youtube_mode': False,
u'youtube_server': False,
u'command': expected_command,
u'command': 'found',
u'current_item_subs': None,
u'youtube_diff': True,
u'html5_local': [],
u'html5_equal': False,
}
)
class TestSaveTranscripts(BaseTranscripts):
"""
Tests for '/transcripts/save' url.
"""
def assert_current_subs(self, expected_subs):
"""
Asserts the current subtitles set on the video module.
Arguments:
expected_subs (String): Expected current subtitles for video.
"""
item = modulestore().get_item(self.video_usage_key)
self.assertEqual(item.sub, expected_subs)
def test_prioritize_youtube_sub_on_save(self):
"""
Test that the '/transcripts/save' endpoint prioritises youtube subtitles over html5 ones
while deciding the current subs for video module.
"""
# Update video module to contain 1 youtube and 2 html5 sources.
youtube_id = str(uuid4())
self.item.data = textwrap.dedent(
"""
<video youtube="1:{youtube_id}" sub="">
<source src="http://www.testvid.org/html5/videos/testvid.mp4"/>
<source src="http://www.testvid2.org/html5/videos/testvid2.webm"/>
</video>
""".format(youtube_id=youtube_id)
)
modulestore().update_item(self.item, self.user.id)
self.assert_current_subs(expected_subs='')
# Save new subs in the content store.
subs = {
'start': [100, 200, 240],
'end': [200, 240, 380],
'text': [
'subs #1',
'subs #2',
'subs #3'
]
}
self.save_subs_to_store(subs, youtube_id)
# Now, make request to /transcripts/save endpoint with new subs.
data = {
'locator': unicode(self.video_usage_key),
'metadata': {
'sub': youtube_id
}
}
resp = self.client.get(reverse('save_transcripts'), {'data': json.dumps(data)})
self.assertEqual(resp.status_code, 200)
self.assertEqual(json.loads(resp.content), {"status": "Success"})
# Now check item.sub, it should be same as youtube id because /transcripts/save prioritize
# youtube subs over html5 ones.
self.assert_current_subs(expected_subs=youtube_id)

View File

@@ -63,6 +63,8 @@ class VideoUploadTestBase(object):
"""
Test cases for the video upload feature
"""
shard = 1
def get_url_for_course_key(self, course_key, kwargs=None):
"""Return video handler URL for the given course"""
return reverse_course_url(self.VIEW_NAME, course_key, kwargs)
@@ -172,6 +174,8 @@ class VideoUploadTestMixin(VideoUploadTestBase):
"""
Test cases for the video upload feature
"""
shard = 1
def test_anon_user(self):
self.client.logout()
response = self.client.get(self.url)
@@ -211,6 +215,7 @@ class VideoUploadTestMixin(VideoUploadTestBase):
@override_settings(VIDEO_UPLOAD_PIPELINE={"BUCKET": "test_bucket", "ROOT_PATH": "test_root"})
class VideosHandlerTestCase(VideoUploadTestMixin, CourseTestCase):
"""Test cases for the main video upload endpoint"""
shard = 1
VIEW_NAME = 'videos_handler'
@@ -224,7 +229,15 @@ class VideosHandlerTestCase(VideoUploadTestMixin, CourseTestCase):
original_video = self.previous_uploads[-(i + 1)]
self.assertEqual(
set(response_video.keys()),
set(['edx_video_id', 'client_video_id', 'created', 'duration', 'status', 'course_video_image_url'])
set([
'edx_video_id',
'client_video_id',
'created',
'duration',
'status',
'course_video_image_url',
'transcripts'
])
)
dateutil.parser.parse(response_video['created'])
for field in ['edx_video_id', 'client_video_id', 'duration']:
@@ -236,13 +249,6 @@ class VideosHandlerTestCase(VideoUploadTestMixin, CourseTestCase):
@ddt.data(
(
False,
['edx_video_id', 'client_video_id', 'created', 'duration', 'status', 'course_video_image_url'],
[],
[]
),
(
True,
['edx_video_id', 'client_video_id', 'created', 'duration', 'status', 'course_video_image_url',
'transcripts'],
[
@@ -257,7 +263,6 @@ class VideosHandlerTestCase(VideoUploadTestMixin, CourseTestCase):
['en']
),
(
True,
['edx_video_id', 'client_video_id', 'created', 'duration', 'status', 'course_video_image_url',
'transcripts'],
[
@@ -280,14 +285,10 @@ class VideosHandlerTestCase(VideoUploadTestMixin, CourseTestCase):
)
)
@ddt.unpack
@patch('openedx.core.djangoapps.video_config.models.VideoTranscriptEnabledFlag.feature_enabled')
def test_get_json_transcripts(self, is_video_transcript_enabled, expected_video_keys, uploaded_transcripts,
expected_transcripts, video_transcript_feature):
def test_get_json_transcripts(self, expected_video_keys, uploaded_transcripts, expected_transcripts):
"""
Test that transcripts are attached based on whether the video transcript feature is enabled.
"""
video_transcript_feature.return_value = is_video_transcript_enabled
for transcript in uploaded_transcripts:
create_or_update_video_transcript(
transcript['video_id'],
@@ -658,6 +659,7 @@ class VideoImageTestCase(VideoUploadTestBase, CourseTestCase):
"""
Tests for video image.
"""
shard = 1
VIEW_NAME = "video_images_handler"
@@ -966,6 +968,7 @@ class TranscriptPreferencesTestCase(VideoUploadTestBase, CourseTestCase):
"""
Tests for video transcripts preferences.
"""
shard = 1
VIEW_NAME = 'transcript_preferences_handler'
@@ -1277,6 +1280,7 @@ class TranscriptPreferencesTestCase(VideoUploadTestBase, CourseTestCase):
@override_settings(VIDEO_UPLOAD_PIPELINE={"BUCKET": "test_bucket", "ROOT_PATH": "test_root"})
class VideoUrlsCsvTestCase(VideoUploadTestMixin, CourseTestCase):
"""Test cases for the CSV download endpoint for video uploads"""
shard = 1
VIEW_NAME = "video_encodings_download"

View File

@@ -130,23 +130,18 @@ def transcript_credentials_handler(request, course_key_string):
@login_required
@require_GET
def transcript_download_handler(request, course_key_string):
def transcript_download_handler(request):
"""
JSON view handler to download a transcript.
Arguments:
request: WSGI request object
course_key_string: course key
Returns:
- A 200 response with SRT transcript file attached.
- A 400 if there is a validation error.
- A 404 if there is no such transcript or feature flag is disabled.
- A 404 if there is no such transcript.
"""
course_key = CourseKey.from_string(course_key_string)
if not VideoTranscriptEnabledFlag.feature_enabled(course_key):
return HttpResponseNotFound()
missing = [attr for attr in ['edx_video_id', 'language_code'] if attr not in request.GET]
if missing:
return JsonResponse(
@@ -206,27 +201,20 @@ def validate_transcript_upload_data(data, files):
@login_required
@require_POST
def transcript_upload_handler(request, course_key_string):
def transcript_upload_handler(request):
"""
View to upload a transcript file.
Arguments:
request: A WSGI request object
course_key_string: Course key identifying a course
Transcript file, edx video id and transcript language are required.
Transcript file should be in SRT(SubRip) format.
Returns
- A 400 if any of the validation fails
- A 404 if the corresponding feature flag is disabled
- A 200 if transcript has been uploaded successfully
"""
# Check whether the feature is available for this course.
course_key = CourseKey.from_string(course_key_string)
if not VideoTranscriptEnabledFlag.feature_enabled(course_key):
return HttpResponseNotFound()
error = validate_transcript_upload_data(data=request.POST, files=request.FILES)
if error:
response = JsonResponse({'error': error}, status=400)
@@ -276,14 +264,13 @@ def transcript_delete_handler(request, course_key_string, edx_video_id, language
language_code: transcript's language code.
Returns
- A 404 if the corresponding feature flag is disabled or user does not have required permisions
- A 404 if the user does not have required permisions
- A 200 if transcript is deleted without any error(s)
"""
# Check whether the feature is available for this course.
course_key = CourseKey.from_string(course_key_string)
video_transcripts_enabled = VideoTranscriptEnabledFlag.feature_enabled(course_key)
# User needs to have studio write access for this course.
if not video_transcripts_enabled or not has_studio_write_access(request.user, course_key):
if not has_studio_write_access(request.user, course_key):
return HttpResponseNotFound()
delete_video_transcript(video_id=edx_video_id, language_code=language_code)

View File

@@ -14,12 +14,13 @@ import requests
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied
from django.core.files.base import ContentFile
from django.http import Http404, HttpResponse
from django.utils.translation import ugettext as _
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import UsageKey
from six import text_type
from edxval.api import create_or_update_video_transcript, create_external_video
from student.auth import has_course_author_access
from util.json_request import JsonResponse
from xmodule.contentstore.content import StaticContent
@@ -28,21 +29,24 @@ from xmodule.exceptions import NotFoundError
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.video_module.transcripts_utils import (
clean_video_id,
copy_or_rename_transcript,
download_youtube_subs,
GetTranscriptsFromYouTubeException,
get_video_transcript_content,
generate_subs_from_source,
get_transcript_for_video,
get_transcripts_from_youtube,
manage_video_subtitles_save,
remove_subs_from_store,
Transcript,
TranscriptsRequestValidationException,
TranscriptsGenerationException,
youtube_video_transcript_name,
get_transcript,
get_transcript_from_val,
)
from xmodule.video_module.transcripts_model_utils import (
is_val_transcript_feature_enabled_for_course
)
from cms.djangoapps.contentstore.views.videos import TranscriptProvider
__all__ = [
'upload_transcripts',
@@ -51,7 +55,6 @@ __all__ = [
'choose_transcripts',
'replace_transcripts',
'rename_transcripts',
'save_transcripts',
]
log = logging.getLogger(__name__)
@@ -68,6 +71,126 @@ def error_response(response, message, status_code=400):
return JsonResponse(response, status_code)
def link_video_to_component(video_component, user):
"""
Links a VAL video to the video component.
Arguments:
video_component: video descriptor item.
user: A requesting user.
Returns:
A cleaned Video ID.
"""
edx_video_id = clean_video_id(video_component.edx_video_id)
if not edx_video_id:
edx_video_id = create_external_video(display_name=u'external video')
video_component.edx_video_id = edx_video_id
video_component.save_with_metadata(user)
return edx_video_id
def save_video_transcript(edx_video_id, input_format, transcript_content, language_code):
"""
Saves a video transcript to the VAL and its content to the configured django storage(DS).
Arguments:
edx_video_id: A Video ID to associate the transcript.
input_format: Input transcript format for content being passed.
transcript_content: Content of the transcript file
language_code: transcript language code
Returns:
A boolean indicating whether the transcript was saved or not.
"""
try:
# Convert the transcript into the 'sjson' and upload it to
# configured transcript storage. For example, S3.
sjson_subs = Transcript.convert(
content=transcript_content,
input_format=input_format,
output_format=Transcript.SJSON
)
create_or_update_video_transcript(
video_id=edx_video_id,
language_code=language_code,
metadata={
'provider': TranscriptProvider.CUSTOM,
'file_format': Transcript.SJSON,
'language_code': language_code
},
file_data=ContentFile(sjson_subs),
)
result = True
except (TranscriptsGenerationException, UnicodeDecodeError):
result = False
return result
def validate_video_module(request, locator):
"""
Validates video module given its locator and request. Also, checks
if requesting user has course authoring access.
Arguments:
request: WSGI request.
locator: video locator.
Returns:
A tuple containing error(or None) and video descriptor(i.e. if validation succeeds).
Raises:
PermissionDenied: if requesting user does not have access to author the video component.
"""
error, item = None, None
try:
item = _get_item(request, {'locator': locator})
if item.category != 'video':
error = _(u'Transcripts are supported only for "video" modules.')
except (InvalidKeyError, ItemNotFoundError):
error = _(u'Cannot find item by locator.')
return error, item
def validate_transcript_upload_data(request):
"""
Validates video transcript file.
Arguments:
request: A WSGI request's data part.
Returns:
Tuple containing an error and validated data
If there is a validation error then, validated data will be empty.
"""
error, validated_data = None, {}
data, files = request.POST, request.FILES
video_locator = data.get('locator')
edx_video_id = data.get('edx_video_id')
if not video_locator:
error = _(u'Video locator is required.')
elif 'transcript-file' not in files:
error = _(u'A transcript file is required.')
elif os.path.splitext(files['transcript-file'].name)[1][1:] != Transcript.SRT:
error = _(u'This transcript file type is not supported.')
elif 'edx_video_id' not in data:
error = _(u'Video ID is required.')
if not error:
error, video = validate_video_module(request, video_locator)
if not error:
validated_data.update({
'video': video,
'edx_video_id': clean_video_id(edx_video_id) or clean_video_id(video.edx_video_id),
'transcript_file': files['transcript-file']
})
return error, validated_data
@login_required
def upload_transcripts(request):
"""
@@ -78,67 +201,51 @@ def upload_transcripts(request):
status: 'Success' and HTTP 200 or 'Error' and HTTP 400.
subs: Value of uploaded and saved html5 sub field in video item.
"""
response = {
'status': 'Unknown server error',
'subs': '',
}
locator = request.POST.get('locator')
if not locator:
return error_response(response, 'POST data without "locator" form data.')
try:
item = _get_item(request, request.POST)
except (InvalidKeyError, ItemNotFoundError):
return error_response(response, "Can't find item by locator.")
if 'transcript-file' not in request.FILES:
return error_response(response, 'POST data without "file" form data.')
video_list = request.POST.get('video_list')
if not video_list:
return error_response(response, 'POST data without video names.')
try:
video_list = json.loads(video_list)
except ValueError:
return error_response(response, 'Invalid video_list JSON.')
# Used utf-8-sig encoding type instead of utf-8 to remove BOM(Byte Order Mark), e.g. U+FEFF
source_subs_filedata = request.FILES['transcript-file'].read().decode('utf-8-sig')
source_subs_filename = request.FILES['transcript-file'].name
if '.' not in source_subs_filename:
return error_response(response, "Undefined file extension.")
basename = os.path.basename(source_subs_filename)
source_subs_name = os.path.splitext(basename)[0]
source_subs_ext = os.path.splitext(basename)[1][1:]
if item.category != 'video':
return error_response(response, 'Transcripts are supported only for "video" modules.')
# Allow upload only if any video link is presented
if video_list:
sub_attr = source_subs_name
try:
# Generate and save for 1.0 speed, will create subs_sub_attr.srt.sjson subtitles file in storage.
generate_subs_from_source({1: sub_attr}, source_subs_ext, source_subs_filedata, item)
for video_dict in video_list:
video_name = video_dict['video']
# We are creating transcripts for every video source, if in future some of video sources would be deleted.
# Updates item.sub with `video_name` on success.
copy_or_rename_transcript(video_name, sub_attr, item, user=request.user)
response['subs'] = item.sub
response['status'] = 'Success'
except Exception as ex:
return error_response(response, text_type(ex))
error, validated_data = validate_transcript_upload_data(request)
if error:
response = JsonResponse({'status': error}, status=400)
else:
return error_response(response, 'Empty video sources.')
video = validated_data['video']
edx_video_id = validated_data['edx_video_id']
transcript_file = validated_data['transcript_file']
# check if we need to create an external VAL video to associate the transcript
# and save its ID on the video component.
if not edx_video_id:
edx_video_id = create_external_video(display_name=u'external video')
video.edx_video_id = edx_video_id
video.save_with_metadata(request.user)
return JsonResponse(response)
response = JsonResponse({'edx_video_id': edx_video_id, 'status': 'Success'}, status=200)
try:
# Convert 'srt' transcript into the 'sjson' and upload it to
# configured transcript storage. For example, S3.
sjson_subs = Transcript.convert(
content=transcript_file.read(),
input_format=Transcript.SRT,
output_format=Transcript.SJSON
)
transcript_created = create_or_update_video_transcript(
video_id=edx_video_id,
language_code=u'en',
metadata={
'provider': TranscriptProvider.CUSTOM,
'file_format': Transcript.SJSON,
'language_code': u'en'
},
file_data=ContentFile(sjson_subs),
)
if transcript_created is None:
response = JsonResponse({'status': 'Invalid Video ID'}, status=400)
except (TranscriptsGenerationException, UnicodeDecodeError):
response = JsonResponse({
'status': _(u'There is a problem with this transcript file. Try to upload a different file.')
}, status=400)
return response
@login_required
@@ -148,54 +255,18 @@ def download_transcripts(request):
Raises Http404 if unsuccessful.
"""
locator = request.GET.get('locator')
subs_id = request.GET.get('subs_id')
if not locator:
log.debug('GET data without "locator" property.')
error, video = validate_video_module(request, locator=request.GET.get('locator'))
if error:
raise Http404
try:
item = _get_item(request, request.GET)
except (InvalidKeyError, ItemNotFoundError):
log.debug("Can't find item by locator.")
raise Http404
if item.category != 'video':
log.debug('transcripts are supported only for video" modules.')
raise Http404
try:
if not subs_id:
raise NotFoundError
filename = subs_id
content_location = StaticContent.compute_location(
item.location.course_key,
'subs_{filename}.srt.sjson'.format(filename=filename),
)
input_format = Transcript.SJSON
transcript_content = contentstore().find(content_location).data
content, filename, mimetype = get_transcript(video, lang=u'en')
except NotFoundError:
# Try searching in VAL for the transcript as a last resort
transcript = None
if is_val_transcript_feature_enabled_for_course(item.location.course_key):
transcript = get_video_transcript_content(edx_video_id=item.edx_video_id, language_code=u'en')
if not transcript:
raise Http404
name_and_extension = os.path.splitext(transcript['file_name'])
filename, input_format = name_and_extension[0], name_and_extension[1][1:]
transcript_content = transcript['content']
# convert sjson content into srt format.
transcript_content = Transcript.convert(transcript_content, input_format=input_format, output_format=Transcript.SRT)
if not transcript_content:
raise Http404
# Construct an HTTP response
response = HttpResponse(transcript_content, content_type='application/x-subrip; charset=utf-8')
response['Content-Disposition'] = 'attachment; filename="{filename}.srt"'.format(filename=filename)
response = HttpResponse(content, content_type=mimetype)
response['Content-Disposition'] = 'attachment; filename="{filename}"'.format(filename=filename.encode('utf-8'))
return response
@@ -237,6 +308,7 @@ def check_transcripts(request):
'current_item_subs': None,
'status': 'Error',
}
try:
__, videos, item = _validate_transcripts_data(request)
except TranscriptsRequestValidationException as e:
@@ -244,75 +316,72 @@ def check_transcripts(request):
transcripts_presence['status'] = 'Success'
filename = 'subs_{0}.srt.sjson'.format(item.sub)
content_location = StaticContent.compute_location(item.location.course_key, filename)
try:
local_transcripts = contentstore().find(content_location).data
transcripts_presence['current_item_subs'] = item.sub
edx_video_id = clean_video_id(videos.get('edx_video_id'))
get_transcript_from_val(edx_video_id=edx_video_id, lang=u'en')
command = 'found'
except NotFoundError:
pass
# Check for youtube transcripts presence
youtube_id = videos.get('youtube', None)
if youtube_id:
transcripts_presence['is_youtube_mode'] = True
# youtube local
filename = 'subs_{0}.srt.sjson'.format(youtube_id)
filename = 'subs_{0}.srt.sjson'.format(item.sub)
content_location = StaticContent.compute_location(item.location.course_key, filename)
try:
local_transcripts = contentstore().find(content_location).data
transcripts_presence['youtube_local'] = True
transcripts_presence['current_item_subs'] = item.sub
except NotFoundError:
log.debug("Can't find transcripts in storage for youtube id: %s", youtube_id)
pass
# youtube server
youtube_text_api = copy.deepcopy(settings.YOUTUBE['TEXT_API'])
youtube_text_api['params']['v'] = youtube_id
youtube_transcript_name = youtube_video_transcript_name(youtube_text_api)
if youtube_transcript_name:
youtube_text_api['params']['name'] = youtube_transcript_name
youtube_response = requests.get('http://' + youtube_text_api['url'], params=youtube_text_api['params'])
# Check for youtube transcripts presence
youtube_id = videos.get('youtube', None)
if youtube_id:
transcripts_presence['is_youtube_mode'] = True
if youtube_response.status_code == 200 and youtube_response.text:
transcripts_presence['youtube_server'] = True
#check youtube local and server transcripts for equality
if transcripts_presence['youtube_server'] and transcripts_presence['youtube_local']:
# youtube local
filename = 'subs_{0}.srt.sjson'.format(youtube_id)
content_location = StaticContent.compute_location(item.location.course_key, filename)
try:
youtube_server_subs = get_transcripts_from_youtube(
youtube_id,
settings,
item.runtime.service(item, "i18n")
)
if json.loads(local_transcripts) == youtube_server_subs: # check transcripts for equality
transcripts_presence['youtube_diff'] = False
except GetTranscriptsFromYouTubeException:
pass
local_transcripts = contentstore().find(content_location).data
transcripts_presence['youtube_local'] = True
except NotFoundError:
log.debug("Can't find transcripts in storage for youtube id: %s", youtube_id)
# Check for html5 local transcripts presence
html5_subs = []
for html5_id in videos['html5']:
filename = 'subs_{0}.srt.sjson'.format(html5_id)
content_location = StaticContent.compute_location(item.location.course_key, filename)
try:
html5_subs.append(contentstore().find(content_location).data)
transcripts_presence['html5_local'].append(html5_id)
except NotFoundError:
log.debug("Can't find transcripts in storage for non-youtube video_id: %s", html5_id)
if len(html5_subs) == 2: # check html5 transcripts for equality
transcripts_presence['html5_equal'] = json.loads(html5_subs[0]) == json.loads(html5_subs[1])
# youtube server
youtube_text_api = copy.deepcopy(settings.YOUTUBE['TEXT_API'])
youtube_text_api['params']['v'] = youtube_id
youtube_transcript_name = youtube_video_transcript_name(youtube_text_api)
if youtube_transcript_name:
youtube_text_api['params']['name'] = youtube_transcript_name
youtube_response = requests.get('http://' + youtube_text_api['url'], params=youtube_text_api['params'])
command, subs_to_use = _transcripts_logic(transcripts_presence, videos)
if command == 'not_found':
# Try searching in VAL for the transcript as a last resort
if is_val_transcript_feature_enabled_for_course(item.location.course_key):
video_transcript = get_video_transcript_content(edx_video_id=item.edx_video_id, language_code=u'en')
command = 'found' if video_transcript else command
if youtube_response.status_code == 200 and youtube_response.text:
transcripts_presence['youtube_server'] = True
#check youtube local and server transcripts for equality
if transcripts_presence['youtube_server'] and transcripts_presence['youtube_local']:
try:
youtube_server_subs = get_transcripts_from_youtube(
youtube_id,
settings,
item.runtime.service(item, "i18n")
)
if json.loads(local_transcripts) == youtube_server_subs: # check transcripts for equality
transcripts_presence['youtube_diff'] = False
except GetTranscriptsFromYouTubeException:
pass
transcripts_presence.update({
'command': command,
'subs': subs_to_use,
})
# Check for html5 local transcripts presence
html5_subs = []
for html5_id in videos['html5']:
filename = 'subs_{0}.srt.sjson'.format(html5_id)
content_location = StaticContent.compute_location(item.location.course_key, filename)
try:
html5_subs.append(contentstore().find(content_location).data)
transcripts_presence['html5_local'].append(html5_id)
except NotFoundError:
log.debug("Can't find transcripts in storage for non-youtube video_id: %s", html5_id)
if len(html5_subs) == 2: # check html5 transcripts for equality
transcripts_presence['html5_equal'] = json.loads(html5_subs[0]) == json.loads(html5_subs[1])
command, __ = _transcripts_logic(transcripts_presence, videos)
transcripts_presence.update({'command': command})
return JsonResponse(transcripts_presence)
@@ -374,78 +443,6 @@ def _transcripts_logic(transcripts_presence, videos):
return command, subs
@login_required
def choose_transcripts(request):
"""
Replaces html5 subtitles, presented for both html5 sources, with chosen one.
Code removes rejected html5 subtitles and updates sub attribute with chosen html5_id.
It does nothing with youtube id's.
Returns: status `Success` and resulted item.sub value or status `Error` and HTTP 400.
"""
response = {
'status': 'Error',
'subs': '',
}
try:
data, videos, item = _validate_transcripts_data(request)
except TranscriptsRequestValidationException as e:
return error_response(response, text_type(e))
html5_id = data.get('html5_id') # html5_id chosen by user
# find rejected html5_id and remove appropriate subs from store
html5_id_to_remove = [x for x in videos['html5'] if x != html5_id]
if html5_id_to_remove:
remove_subs_from_store(html5_id_to_remove, item)
if item.sub != html5_id: # update sub value
item.sub = html5_id
item.save_with_metadata(request.user)
response = {
'status': 'Success',
'subs': item.sub,
}
return JsonResponse(response)
@login_required
def replace_transcripts(request):
"""
Replaces all transcripts with youtube ones.
Downloads subtitles from youtube and replaces all transcripts with downloaded ones.
Returns: status `Success` and resulted item.sub value or status `Error` and HTTP 400.
"""
response = {'status': 'Error', 'subs': ''}
try:
__, videos, item = _validate_transcripts_data(request)
except TranscriptsRequestValidationException as e:
return error_response(response, text_type(e))
youtube_id = videos['youtube']
if not youtube_id:
return error_response(response, 'YouTube id {} is not presented in request data.'.format(youtube_id))
try:
download_youtube_subs(youtube_id, item, settings)
except GetTranscriptsFromYouTubeException as e:
return error_response(response, text_type(e))
item.sub = youtube_id
item.save_with_metadata(request.user)
response = {
'status': 'Success',
'subs': item.sub,
}
return JsonResponse(response)
def _validate_transcripts_data(request):
"""
Validates, that request contains all proper data for transcripts processing.
@@ -476,6 +473,9 @@ def _validate_transcripts_data(request):
for video_data in data.get('videos'):
if video_data['type'] == 'youtube':
videos['youtube'] = video_data['video']
elif video_data['type'] == 'edx_video_id':
if clean_video_id(video_data['video']):
videos['edx_video_id'] = video_data['video']
else: # do not add same html5 videos
if videos['html5'].get('video') != video_data['video']:
videos['html5'][video_data['video']] = video_data['mode']
@@ -483,78 +483,162 @@ def _validate_transcripts_data(request):
return data, videos, item
def validate_transcripts_request(request, include_yt=False, include_html5=False):
"""
Validates transcript handler's request.
NOTE: This is one central validation flow for `choose_transcripts`,
`check_transcripts` and `replace_transcripts` handlers.
Returns:
A tuple containing:
1. An error message in case of validation failure.
2. validated video data
"""
error = None
validated_data = {'video': None, 'youtube': '', 'html5': {}}
# Loads the request data
data = json.loads(request.GET.get('data', '{}'))
if not data:
error = _(u'Incoming video data is empty.')
else:
error, video = validate_video_module(request, locator=data.get('locator'))
if not error:
validated_data.update({'video': video})
videos = data.get('videos', [])
if include_yt:
validated_data.update({
video['type']: video['video']
for video in videos
if video['type'] == 'youtube'
})
if include_html5:
validated_data['chosen_html5_id'] = data.get('html5_id')
validated_data['html5'] = {
video['video']: video['mode']
for video in videos
if video['type'] != 'youtube'
}
return error, validated_data
@login_required
def choose_transcripts(request):
"""
Create/Update edx transcript in DS with chosen html5 subtitles from contentstore.
Returns:
status `Success` and resulted `edx_video_id` value
Or error in case of validation failures.
"""
error, validated_data = validate_transcripts_request(request, include_html5=True)
if error:
response = error_response({}, error)
else:
# 1. Retrieve transcript file for `chosen_html5_id` from contentstore.
try:
video = validated_data['video']
chosen_html5_id = validated_data['chosen_html5_id']
input_format, __, transcript_content = get_transcript_for_video(
video.location,
subs_id=chosen_html5_id,
file_name=chosen_html5_id,
language=u'en'
)
except NotFoundError:
return error_response({}, _('No such transcript.'))
# 2. Link a video to video component if its not already linked to one.
edx_video_id = link_video_to_component(video, request.user)
# 3. Upload the retrieved transcript to DS for the linked video ID.
success = save_video_transcript(edx_video_id, input_format, transcript_content, language_code=u'en')
if success:
response = JsonResponse({'edx_video_id': edx_video_id, 'status': 'Success'}, status=200)
else:
response = error_response({}, _('There is a problem with the chosen transcript file.'))
return response
@login_required
def rename_transcripts(request):
"""
Create copies of existing subtitles with new names of HTML5 sources.
Copies existing transcript on video component's `sub`(from contentstore) into the
DS for a video.
Old subtitles are not deleted now, because we do not have rollback functionality.
If succeed, Item.sub will be chosen randomly from html5 video sources provided by front-end.
Returns:
status `Success` and resulted `edx_video_id` value
Or error in case of validation failures.
"""
response = {'status': 'Error', 'subs': ''}
try:
__, videos, item = _validate_transcripts_data(request)
except TranscriptsRequestValidationException as e:
return error_response(response, text_type(e))
old_name = item.sub
for new_name in videos['html5'].keys(): # copy subtitles for every HTML5 source
error, validated_data = validate_transcripts_request(request)
if error:
response = error_response({}, error)
else:
# 1. Retrieve transcript file for `video.sub` from contentstore.
try:
# updates item.sub with new_name if it is successful.
copy_or_rename_transcript(new_name, old_name, item, user=request.user)
video = validated_data['video']
input_format, __, transcript_content = get_transcript_for_video(
video.location,
subs_id=video.sub,
file_name=video.sub,
language=u'en'
)
except NotFoundError:
# subtitles file `item.sub` is not presented in the system. Nothing to copy or rename.
error_response(response, "Can't find transcripts in storage for {}".format(old_name))
return error_response({}, _('No such transcript.'))
response['status'] = 'Success'
response['subs'] = item.sub # item.sub has been changed, it is not equal to old_name.
log.debug("Updated item.sub to %s", item.sub)
return JsonResponse(response)
# 2. Link a video to video component if its not already linked to one.
edx_video_id = link_video_to_component(video, request.user)
# 3. Upload the retrieved transcript to DS for the linked video ID.
success = save_video_transcript(edx_video_id, input_format, transcript_content, language_code=u'en')
if success:
response = JsonResponse({'edx_video_id': edx_video_id, 'status': 'Success'}, status=200)
else:
response = error_response(
{}, _('There is a problem with the existing transcript file. Please upload a different file.')
)
return response
@login_required
def save_transcripts(request):
def replace_transcripts(request):
"""
Saves video module with updated values of fields.
Downloads subtitles from youtube and replaces edx transcripts in DS with youtube ones.
Returns: status `Success` or status `Error` and HTTP 400.
Returns:
status `Success` and resulted `edx_video_id` value
Or error on validation failures.
"""
response = {'status': 'Error'}
error, validated_data = validate_transcripts_request(request, include_yt=True)
youtube_id = validated_data['youtube']
if error:
response = error_response({}, error)
elif not youtube_id:
response = error_response({}, _(u'YouTube ID is required.'))
else:
# 1. Download transcript from YouTube.
try:
video = validated_data['video']
transcript_content = download_youtube_subs(youtube_id, video, settings)
except GetTranscriptsFromYouTubeException as e:
return error_response({}, text_type(e))
data = json.loads(request.GET.get('data', '{}'))
if not data:
return error_response(response, 'Incoming video data is empty.')
# 2. Link a video to video component if its not already linked to one.
edx_video_id = link_video_to_component(video, request.user)
try:
item = _get_item(request, data)
except (InvalidKeyError, ItemNotFoundError):
return error_response(response, "Can't find item by locator.")
metadata = data.get('metadata')
if metadata is not None:
new_sub = metadata.get('sub')
for metadata_key, value in metadata.items():
setattr(item, metadata_key, value)
item.save_with_metadata(request.user) # item becomes updated with new values
if new_sub:
manage_video_subtitles_save(item, request.user)
# 3. Upload YT transcript to DS for the linked video ID.
success = save_video_transcript(edx_video_id, Transcript.SJSON, transcript_content, language_code=u'en')
if success:
response = JsonResponse({'edx_video_id': edx_video_id, 'status': 'Success'}, status=200)
else:
# If `new_sub` is empty, it means that user explicitly does not want to use
# transcripts for current video ids and we remove all transcripts from storage.
current_subs = data.get('current_subs')
if current_subs is not None:
for sub in current_subs:
remove_subs_from_store(sub, item)
response = error_response({}, _('There is a problem with the YouTube transcript file.'))
response['status'] = 'Success'
return JsonResponse(response)
return response
def _get_item(request, data):

View File

@@ -15,6 +15,7 @@ from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.contrib.staticfiles.storage import staticfiles_storage
from django.core.files.images import get_image_dimensions
from django.core.urlresolvers import reverse
from django.http import HttpResponse, HttpResponseNotFound
from django.utils.translation import ugettext as _
from django.utils.translation import ugettext_noop
@@ -533,15 +534,12 @@ def _get_videos(course):
"""
Retrieves the list of videos from VAL corresponding to this course.
"""
is_video_transcript_enabled = VideoTranscriptEnabledFlag.feature_enabled(course.id)
videos = list(get_videos_for_course(unicode(course.id), VideoSortField.created, SortDirection.desc))
# convert VAL's status to studio's Video Upload feature status.
for video in videos:
video["status"] = convert_video_status(video)
if is_video_transcript_enabled:
video['transcripts'] = get_available_transcript_languages(video_id=video['edx_video_id'])
video['transcripts'] = get_available_transcript_languages(video_id=video['edx_video_id'])
return videos
@@ -558,10 +556,7 @@ def _get_index_videos(course):
Returns the information about each video upload required for the video list
"""
course_id = unicode(course.id)
attrs = ['edx_video_id', 'client_video_id', 'created', 'duration', 'status', 'courses']
if VideoTranscriptEnabledFlag.feature_enabled(course.id):
attrs += ['transcripts']
attrs = ['edx_video_id', 'client_video_id', 'created', 'duration', 'status', 'courses', 'transcripts']
def _get_values(video):
"""
@@ -631,14 +626,19 @@ def videos_index_html(course):
'supported_file_formats': settings.VIDEO_IMAGE_SUPPORTED_FILE_FORMATS
},
'is_video_transcript_enabled': is_video_transcript_enabled,
'video_transcript_settings': None,
'active_transcript_preferences': None,
'transcript_credentials': None,
'transcript_available_languages': None
'transcript_available_languages': get_all_transcript_languages(),
'video_transcript_settings': {
'transcript_download_handler_url': reverse('transcript_download_handler'),
'transcript_upload_handler_url': reverse('transcript_upload_handler'),
'transcript_delete_handler_url': reverse_course_url('transcript_delete_handler', unicode(course.id)),
'trancript_download_file_format': Transcript.SRT
}
}
if is_video_transcript_enabled:
context['video_transcript_settings'] = {
context['video_transcript_settings'].update({
'transcript_preferences_handler_url': reverse_course_url(
'transcript_preferences_handler',
unicode(course.id)
@@ -647,25 +647,11 @@ def videos_index_html(course):
'transcript_credentials_handler',
unicode(course.id)
),
'transcript_download_handler_url': reverse_course_url(
'transcript_download_handler',
unicode(course.id)
),
'transcript_upload_handler_url': reverse_course_url(
'transcript_upload_handler',
unicode(course.id)
),
'transcript_delete_handler_url': reverse_course_url(
'transcript_delete_handler',
unicode(course.id)
),
'transcription_plans': get_3rd_party_transcription_plans(),
'trancript_download_file_format': Transcript.SRT
}
})
context['active_transcript_preferences'] = get_transcript_preferences(unicode(course.id))
# Cached state for transcript providers' credentials (org-specific)
context['transcript_credentials'] = get_transcript_credentials_state_for_org(course.id.org)
context['transcript_available_languages'] = get_all_transcript_languages()
return render_to_response('videos_index.html', context)

View File

@@ -96,6 +96,7 @@ DATABASES = {
# Use the auto_auth workflow for creating users and logging them in
FEATURES['AUTOMATIC_AUTH_FOR_TESTING'] = True
FEATURES['RESTRICT_AUTOMATIC_AUTH'] = False
# Forums are disabled in test.py to speed up unit tests, but we do not have
# per-test control for lettuce acceptance tests.

View File

@@ -205,6 +205,7 @@ DEFAULT_FEEDBACK_EMAIL = ENV_TOKENS.get('DEFAULT_FEEDBACK_EMAIL', DEFAULT_FEEDBA
ADMINS = ENV_TOKENS.get('ADMINS', ADMINS)
SERVER_EMAIL = ENV_TOKENS.get('SERVER_EMAIL', SERVER_EMAIL)
MKTG_URLS = ENV_TOKENS.get('MKTG_URLS', MKTG_URLS)
MKTG_URL_LINK_MAP.update(ENV_TOKENS.get('MKTG_URL_LINK_MAP', {}))
TECH_SUPPORT_EMAIL = ENV_TOKENS.get('TECH_SUPPORT_EMAIL', TECH_SUPPORT_EMAIL)
for name, value in ENV_TOKENS.get("CODE_JAIL", {}).items():
@@ -437,14 +438,15 @@ CELERY_QUEUES.update(
# Queue to use for updating grades due to grading policy change
POLICY_CHANGE_GRADES_ROUTING_KEY = ENV_TOKENS.get('POLICY_CHANGE_GRADES_ROUTING_KEY', LOW_PRIORITY_QUEUE)
# Rate limit for regrading tasks that a grading policy change can kick off
POLICY_CHANGE_TASK_RATE_LIMIT = ENV_TOKENS.get('POLICY_CHANGE_TASK_RATE_LIMIT', POLICY_CHANGE_TASK_RATE_LIMIT)
# Event tracking
TRACKING_BACKENDS.update(AUTH_TOKENS.get("TRACKING_BACKENDS", {}))
EVENT_TRACKING_BACKENDS['tracking_logs']['OPTIONS']['backends'].update(AUTH_TOKENS.get("EVENT_TRACKING_BACKENDS", {}))
EVENT_TRACKING_BACKENDS['segmentio']['OPTIONS']['processors'][0]['OPTIONS']['whitelist'].extend(
AUTH_TOKENS.get("EVENT_TRACKING_SEGMENTIO_EMIT_WHITELIST", []))
VIRTUAL_UNIVERSITIES = ENV_TOKENS.get('VIRTUAL_UNIVERSITIES', [])
##### ACCOUNT LOCKOUT DEFAULT PARAMETERS #####
MAX_FAILED_LOGIN_ATTEMPTS_ALLOWED = ENV_TOKENS.get("MAX_FAILED_LOGIN_ATTEMPTS_ALLOWED", 5)
MAX_FAILED_LOGIN_ATTEMPTS_LOCKOUT_PERIOD_SECS = ENV_TOKENS.get("MAX_FAILED_LOGIN_ATTEMPTS_LOCKOUT_PERIOD_SECS", 15 * 60)
@@ -575,12 +577,14 @@ ENTERPRISE_REPORTING_SECRET = AUTH_TOKENS.get(
)
############### Settings for Retirement #####################
RETIRED_USERNAME_FMT = ENV_TOKENS.get('RETIRED_USERNAME_FMT', RETIRED_USERNAME_FMT)
RETIRED_EMAIL_FMT = ENV_TOKENS.get('RETIRED_EMAIL_FMT', RETIRED_EMAIL_FMT)
RETIRED_USERNAME_PREFIX = ENV_TOKENS.get('RETIRED_USERNAME_PREFIX', RETIRED_USERNAME_PREFIX)
RETIRED_EMAIL_PREFIX = ENV_TOKENS.get('RETIRED_EMAIL_PREFIX', RETIRED_EMAIL_PREFIX)
RETIRED_EMAIL_DOMAIN = ENV_TOKENS.get('RETIRED_EMAIL_DOMAIN', RETIRED_EMAIL_DOMAIN)
RETIREMENT_SERVICE_WORKER_USERNAME = ENV_TOKENS.get(
'RETIREMENT_SERVICE_WORKER_USERNAME',
RETIREMENT_SERVICE_WORKER_USERNAME
)
RETIREMENT_STATES = ENV_TOKENS.get('RETIREMENT_STATES', RETIREMENT_STATES)
####################### Plugin Settings ##########################

View File

@@ -80,6 +80,7 @@ for log_name, log_level in LOG_OVERRIDES:
# Use the auto_auth workflow for creating users and logging them in
FEATURES['AUTOMATIC_AUTH_FOR_TESTING'] = True
FEATURES['RESTRICT_AUTOMATIC_AUTH'] = False
# Enable milestones app
FEATURES['MILESTONES_APP'] = True
@@ -145,6 +146,16 @@ if RELEASE_LINE == "master":
'course_author': 'http://edx.readthedocs.io/projects/edx-partner-course-staff',
}
########################## VIDEO TRANSCRIPTS STORAGE ############################
VIDEO_TRANSCRIPTS_SETTINGS = dict(
VIDEO_TRANSCRIPTS_MAX_BYTES=3 * 1024 * 1024, # 3 MB
STORAGE_KWARGS=dict(
location=MEDIA_ROOT,
base_url=MEDIA_URL,
),
DIRECTORY_PREFIX='video-transcripts/',
)
#####################################################################
# Lastly, see if the developer has any local overrides.
try:

View File

@@ -127,10 +127,14 @@ from lms.envs.common import (
VIDEO_IMAGE_SETTINGS,
VIDEO_TRANSCRIPTS_SETTINGS,
RETIRED_USERNAME_PREFIX,
RETIRED_USERNAME_FMT,
RETIRED_EMAIL_PREFIX,
RETIRED_EMAIL_DOMAIN,
RETIRED_EMAIL_FMT,
RETIRED_USER_SALTS,
RETIREMENT_SERVICE_WORKER_USERNAME,
RETIREMENT_STATES,
# Methods to derive settings
_make_mako_template_dirs,
@@ -166,6 +170,10 @@ FEATURES = {
'ENABLE_TEXTBOOK': True,
'ENABLE_STUDENT_NOTES': True,
# DO NOT SET TO True IN THIS FILE
# Doing so will cause all courses to be released on production
'DISABLE_START_DATES': False, # When True, all courses will be active, regardless of start date
'AUTH_USE_CERTIFICATES': False,
# email address for studio staff (eg to request course creation)
@@ -296,6 +304,15 @@ FEATURES = {
# Whether archived courses (courses with end dates in the past) should be
# shown in Studio in a separate list.
'ENABLE_SEPARATE_ARCHIVED_COURSES': True,
# For acceptance and load testing
'AUTOMATIC_AUTH_FOR_TESTING': False,
# Prevent auto auth from creating superusers or modifying existing users
'RESTRICT_AUTOMATIC_AUTH': True,
# Set this to true to make API docs available at /api-docs/.
'ENABLE_API_DOCS': False,
}
ENABLE_JASMINE = False
@@ -806,10 +823,7 @@ PIPELINE_JS = {
},
}
PIPELINE_COMPILERS = (
'pipeline.compilers.coffee.CoffeeScriptCompiler',
)
PIPELINE_COMPILERS = ()
PIPELINE_CSS_COMPRESSOR = None
PIPELINE_JS_COMPRESSOR = None
@@ -825,10 +839,6 @@ STATICFILES_IGNORE_PATTERNS = (
"sass/*/*.scss",
"sass/*/*/*.scss",
"sass/*/*/*/*.scss",
"coffee/*.coffee",
"coffee/*/*.coffee",
"coffee/*/*/*.coffee",
"coffee/*/*/*/*.coffee",
# Ignore tests
"spec",
@@ -1132,6 +1142,9 @@ INSTALLED_APPS = [
# Asset management for mako templates
'pipeline_mako',
# API Documentation
'rest_framework_swagger',
]
@@ -1244,6 +1257,9 @@ OPTIONAL_APPS = (
# Enterprise App (http://github.com/edx/edx-enterprise)
('enterprise', None),
('consent', None),
('integrated_channels.integrated_channel', None),
('integrated_channels.degreed', None),
('integrated_channels.sap_success_factors', None),
)
@@ -1469,6 +1485,9 @@ RECALCULATE_GRADES_ROUTING_KEY = LOW_PRIORITY_QUEUE
# Queue to use for updating grades due to grading policy change
POLICY_CHANGE_GRADES_ROUTING_KEY = LOW_PRIORITY_QUEUE
# Rate limit for regrading tasks that a grading policy change can kick off
POLICY_CHANGE_TASK_RATE_LIMIT = '300/h'
############## Settings for CourseGraph ############################
COURSEGRAPH_JOB_QUEUE = LOW_PRIORITY_QUEUE

View File

@@ -33,6 +33,7 @@ container = course_author:developing_course/course_components.html#components-th
video = course_author:video/index.html
certificates = course_author:set_up_course/studio_add_course_information/studio_creating_certificates.html
content_highlights = course_author:developing_course/course_sections.html#set-section-highlights-for-weekly-course-highlight-messages
image_accessibility = course_author:accessibility/best_practices_course_content_dev.html#use-best-practices-for-describing-images
# below are the language directory names for the different locales
[locales]

View File

@@ -17,6 +17,8 @@ SWIFT_NAME_PREFIX = FILE_UPLOAD_STORAGE_PREFIX
SWIFT_USE_TEMP_URLS = AUTH_TOKENS.get('SWIFT_USE_TEMP_URLS', False)
SWIFT_TEMP_URL_KEY = AUTH_TOKENS.get('SWIFT_TEMP_URL_KEY')
SWIFT_TEMP_URL_DURATION = AUTH_TOKENS.get('SWIFT_TEMP_URL_DURATION', 1800) # seconds
SWIFT_CONTENT_LENGTH_FROM_FD = AUTH_TOKENS.get('SWIFT_CONTENT_LENGTH_FROM_FD', False)
SWIFT_LAZY_CONNECT = AUTH_TOKENS.get('SWIFT_LAZY_CONNECT', True)
if AUTH_TOKENS.get('SWIFT_REGION_NAME'):
SWIFT_EXTRA_OPTIONS = {'region_name': AUTH_TOKENS['SWIFT_REGION_NAME']}

View File

@@ -10,4 +10,4 @@ filterwarnings =
ignore::xblock.exceptions.FieldDataDeprecationWarning
norecursedirs = envs
python_classes =
python_files = tests.py test_*.py *_tests.py
python_files = test.py tests.py test_*.py *_tests.py

View File

@@ -104,7 +104,7 @@
'jquery.fileupload-validate': 'js/vendor/jQuery-File-Upload/js/jquery.fileupload-validate',
'jquery.iframe-transport': 'js/vendor/jQuery-File-Upload/js/jquery.iframe-transport',
'jquery.inputnumber': 'js/vendor/html5-input-polyfills/number-polyfill',
'jquery.immediateDescendents': 'coffee/src/jquery.immediateDescendents',
'jquery.immediateDescendents': 'js/src/jquery.immediateDescendents',
'datepair': 'js/vendor/timepicker/datepair',
'date': 'js/vendor/date',
moment: 'common/js/vendor/moment-with-locales',
@@ -304,11 +304,11 @@
deps: ['xblock/core']
},
'cms/js/main': {
deps: ['coffee/src/ajax_prefix']
deps: ['js/src/ajax_prefix']
},
'js/src/logger': {
exports: 'Logger',
deps: ['coffee/src/ajax_prefix']
deps: ['js/src/ajax_prefix']
},
// the following are all needed for annotation tools

View File

@@ -44,7 +44,7 @@
'jquery.fileupload-validate': 'xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-validate', // eslint-disable-line max-len
'jquery.iframe-transport': 'xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.iframe-transport', // eslint-disable-line max-len
'jquery.inputnumber': 'xmodule_js/common_static/js/vendor/html5-input-polyfills/number-polyfill',
'jquery.immediateDescendents': 'xmodule_js/common_static/coffee/src/jquery.immediateDescendents',
'jquery.immediateDescendents': 'xmodule_js/common_static/js/src/jquery.immediateDescendents',
'jquery.simulate': 'xmodule_js/common_static/js/vendor/jquery.simulate',
'datepair': 'xmodule_js/common_static/js/vendor/timepicker/datepair',
'date': 'xmodule_js/common_static/js/vendor/date',
@@ -72,7 +72,7 @@
'mock-ajax': 'xmodule_js/common_static/js/vendor/mock-ajax',
mathjax: '//cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.1/MathJax.js?config=TeX-MML-AM_SVG&delayStartupUntil=configured', // eslint-disable-line max-len
'youtube': '//www.youtube.com/player_api?noext',
'coffee/src/ajax_prefix': 'xmodule_js/common_static/coffee/src/ajax_prefix',
'js/src/ajax_prefix': 'xmodule_js/common_static/js/src/ajax_prefix',
'js/spec/test_utils': 'js/spec/test_utils'
},
shim: {
@@ -218,9 +218,9 @@
deps: ['jquery']
},
'cms/js/main': {
deps: ['coffee/src/ajax_prefix']
deps: ['js/src/ajax_prefix']
},
'coffee/src/ajax_prefix': {
'js/src/ajax_prefix': {
deps: ['jquery']
}
}
@@ -231,21 +231,21 @@
testFiles = [
'cms/js/spec/main_spec',
'cms/js/spec/xblock/cms.runtime.v1_spec',
'coffee/spec/models/course_spec',
'coffee/spec/models/metadata_spec',
'coffee/spec/models/section_spec',
'coffee/spec/models/settings_course_grader_spec',
'coffee/spec/models/settings_grading_spec',
'coffee/spec/models/textbook_spec',
'coffee/spec/models/upload_spec',
'coffee/spec/views/course_info_spec',
'coffee/spec/views/metadata_edit_spec',
'coffee/spec/views/textbook_spec',
'coffee/spec/views/upload_spec',
'js/spec/models/course_spec',
'js/spec/models/metadata_spec',
'js/spec/models/section_spec',
'js/spec/models/settings_course_grader_spec',
'js/spec/models/settings_grading_spec',
'js/spec/models/textbook_spec',
'js/spec/models/upload_spec',
'js/spec/views/course_info_spec',
'js/spec/views/metadata_edit_spec',
'js/spec/views/textbook_spec',
'js/spec/views/upload_spec',
'js/spec/video/transcripts/message_manager_spec',
'js/spec/video/transcripts/utils_spec',
'js/spec/video/transcripts/editor_spec',
'js/spec/video/transcripts/videolist_spec',
'js/spec/video/transcripts/message_manager_spec',
'js/spec/video/transcripts/file_uploader_spec',
'js/spec/models/component_template_spec',
'js/spec/models/explicit_url_spec',

View File

@@ -27,7 +27,8 @@
'jquery.fileupload-validate': 'xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-validate', // eslint-disable-line max-len
'jquery.iframe-transport': 'xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.iframe-transport', // eslint-disable-line max-len
'jquery.inputnumber': 'xmodule_js/common_static/js/vendor/html5-input-polyfills/number-polyfill',
'jquery.immediateDescendents': 'xmodule_js/common_static/coffee/src/jquery.immediateDescendents',
'jquery.immediateDescendents': 'xmodule_js/common_static/js/src/jquery.immediateDescendents',
'jquery.ajaxQueue': 'xmodule_js/common_static/js/vendor/jquery.ajaxQueue',
'datepair': 'xmodule_js/common_static/js/vendor/timepicker/datepair',
'date': 'xmodule_js/common_static/js/vendor/date',
'text': 'xmodule_js/common_static/js/vendor/requirejs/text',
@@ -49,7 +50,7 @@
'URI': 'xmodule_js/common_static/js/vendor/URI.min',
mathjax: '//cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.1/MathJax.js?config=TeX-MML-AM_SVG&delayStartupUntil=configured', // eslint-disable-line max-len
'youtube': '//www.youtube.com/player_api?noext',
'coffee/src/ajax_prefix': 'xmodule_js/common_static/coffee/src/ajax_prefix'
'js/src/ajax_prefix': 'xmodule_js/common_static/js/src/ajax_prefix'
},
shim: {
'gettext': {
@@ -108,6 +109,10 @@
deps: ['jquery', 'tinymce'],
exports: 'jQuery.fn.tinymce'
},
'jquery.ajaxQueue': {
deps: ['jquery'],
exports: 'jQuery.fn.ajaxQueue'
},
'datepair': {
deps: ['jquery.ui', 'jquery.timepicker']
},
@@ -174,9 +179,9 @@
deps: ['xblock/core']
},
'cms/js/main': {
deps: ['coffee/src/ajax_prefix']
deps: ['js/src/ajax_prefix']
},
'coffee/src/ajax_prefix': {
'js/src/ajax_prefix': {
deps: ['jquery']
}
}
@@ -185,7 +190,7 @@
jasmine.getFixtures().fixturesPath = '/base/templates';
testFiles = [
'coffee/spec/views/assets_spec',
'js/spec/views/assets_squire_spec',
'js/spec/video/translations_editor_spec',
'js/spec/video/file_uploader_editor_spec',
'js/spec/models/group_configuration_spec'

View File

@@ -29,5 +29,31 @@ define(['jquery', 'jquery.cookie'], function($) {
}
});
});
$('input#password').blur(function() {
var $formErrors = $('#password_error'),
data = {
password: $('#password').val()
};
// Uninitialize the errors on blur
$formErrors.empty();
$formErrors.addClass('hidden');
$.ajax({
url: '/api/user/v1/validation/registration',
type: 'POST',
dataType: 'json',
data: data,
success: function(json) {
_.each(json.validation_decisions, function(value, key) {
if (key === 'password' && value) {
$formErrors.html(value);
$formErrors.removeClass('hidden');
}
});
}
});
});
};
});

View File

@@ -55,8 +55,7 @@ define([
videoImageSettings: videoImageSettings,
videoTranscriptSettings: videoTranscriptSettings,
transcriptAvailableLanguages: transcriptAvailableLanguages,
videoSupportedFileFormats: videoSupportedFileFormats,
isVideoTranscriptEnabled: isVideoTranscriptEnabled
videoSupportedFileFormats: videoSupportedFileFormats
});
$contentWrapper.find('.wrapper-assets').replaceWith(updatedView.render().$el);
});
@@ -71,8 +70,7 @@ define([
videoImageSettings: videoImageSettings,
videoTranscriptSettings: videoTranscriptSettings,
transcriptAvailableLanguages: transcriptAvailableLanguages,
videoSupportedFileFormats: videoSupportedFileFormats,
isVideoTranscriptEnabled: isVideoTranscriptEnabled
videoSupportedFileFormats: videoSupportedFileFormats
});
$contentWrapper.append(activeView.render().$el);
$contentWrapper.append(previousView.render().$el);

View File

@@ -1,7 +1,7 @@
define(['backbone'], function(Backbone) {
/**
* Model used for metadata setting editors. This model does not do its own saving,
* as that is done by module_edit.coffee.
* as that is done by module_edit.js.
*/
var Metadata = Backbone.Model.extend({
defaults: {
@@ -11,7 +11,8 @@ define(['backbone'], function(Backbone) {
explicitly_set: null,
default_value: null,
options: null,
type: null
type: null,
custom: false // Used only for non-metadata fields
},
initialize: function() {
@@ -24,6 +25,11 @@ define(['backbone'], function(Backbone) {
* property has changed.
*/
isModified: function() {
// A non-metadata field will handle itself
if (this.get('custom') === true) {
return false;
}
if (!this.get('explicitly_set') && !this.original_explicitly_set) {
return false;
}

View File

@@ -1,5 +1,5 @@
define(
['js/factories/textbooks', 'common/js/utils/page_factory', 'js/pages/course'],
['js/factories/textbooks', 'common/js/utils/page_factory', 'js/factories/base', 'js/pages/course'],
function(TextbooksFactory, invokePageFactory) {
'use strict';
invokePageFactory('TextbooksFactory', TextbooksFactory);

View File

@@ -38,7 +38,7 @@ function($, Backbone, _, Utils, Editor, MetadataView, MetadataModel, MetadataCol
field_name: 'edx_video_id',
help: 'Specifies the video ID.',
options: [],
type: MetadataModel.GENERIC_TYPE,
type: 'VideoID',
value: 'basic tab video id'
},
models = [DisplayNameEntry, VideoListEntry, VideoIDEntry],
@@ -51,7 +51,8 @@ function($, Backbone, _, Utils, Editor, MetadataView, MetadataModel, MetadataCol
object: testData,
string: JSON.stringify(testData)
},
transcripts, $container;
component_locator = 'component_locator',
transcripts, $container, waitForEvent, editor;
var waitsForDisplayName = function(collection) {
return jasmine.waitUntil(function() {
@@ -76,15 +77,109 @@ function($, Backbone, _, Utils, Editor, MetadataView, MetadataModel, MetadataCol
Utils.Storage.remove('sub');
});
describe('Events', function() {
beforeEach(function() {
Utils.command.and.callThrough();
spyOn(Backbone, 'trigger').and.callThrough();
spyOn(Editor.prototype, 'destroy').and.callThrough();
spyOn(Editor.prototype, 'handleFieldChanged').and.callThrough();
spyOn(Editor.prototype, 'getLocator').and.returnValue(component_locator);
appendSetFixtures(
sandbox({ // eslint-disable-line no-undef
class: 'wrapper-comp-settings basic_metadata_edit',
'data-metadata': JSON.stringify({video_url: VideoListEntry, edx_video_id: VideoIDEntry})
})
);
appendSetFixtures(
$('<script>',
{
id: 'metadata-videolist-entry',
type: 'text/template'
}
).text(readFixtures('video/transcripts/metadata-videolist-entry.underscore'))
);
appendSetFixtures(
$('<script>',
{
id: 'metadata-string-entry',
type: 'text/template'
}
).text(readFixtures('metadata-string-entry.underscore'))
);
editor = new Editor({
el: $('.basic_metadata_edit')
});
// reset the already triggered events
Backbone.trigger.calls.reset();
// reset the manual call to `handleFieldChanged` we made in the `editor.js::initialize`
Editor.prototype.handleFieldChanged.calls.reset();
});
waitForEvent = function(eventName) {
var triggerCallArgs;
return jasmine.waitUntil(function() {
triggerCallArgs = Backbone.trigger.calls.mostRecent().args;
return Backbone.trigger.calls.count() === 1 && triggerCallArgs[0] === eventName;
});
};
afterEach(function() {
Backbone.trigger.calls.reset();
Editor.prototype.destroy.calls.reset();
Editor.prototype.handleFieldChanged.calls.reset();
});
it('handles transcripts:basicTabFieldChanged', function(done) {
var event = 'transcripts:basicTabFieldChanged';
Backbone.trigger(event);
waitForEvent(event)
.then(function() {
expect(Editor.prototype.handleFieldChanged).toHaveBeenCalled();
expect(Utils.command).toHaveBeenCalledWith(
'check',
component_locator,
[
{ mode: 'youtube', video: '12345678901', type: 'youtube' },
{ mode: 'html5', video: 'video', type: 'mp4' },
{ mode: 'html5', video: 'video', type: 'webm' },
{ mode: 'edx_video_id', type: 'edx_video_id', video: 'basic tab video id' }
]
);
}).always(done);
});
it('handles xblock:editorModalHidden', function(done) {
var event = 'xblock:editorModalHidden';
Backbone.trigger(event);
waitForEvent(event)
.then(function() {
expect(Editor.prototype.destroy).toHaveBeenCalled();
}).always(done);
});
});
describe('Test initialization', function() {
beforeEach(function() {
spyOn(MetadataView, 'Editor');
spyOn(Editor.prototype, 'handleFieldChanged');
transcripts = new Editor({
el: $container
});
});
afterEach(function() {
MetadataView.Editor.calls.reset();
Editor.prototype.handleFieldChanged.calls.reset();
});
$.each(metadataDict, function(index, val) {
it('toModels with argument as ' + index, function() {
expect(transcripts.toModels(val)).toEqual(models);
@@ -159,6 +254,7 @@ function($, Backbone, _, Utils, Editor, MetadataView, MetadataModel, MetadataCol
beforeEach(function() {
spyOn(MetadataView, 'Editor');
spyOn(Editor.prototype, 'handleFieldChanged');
transcripts = new Editor({
el: $container
@@ -182,6 +278,11 @@ function($, Backbone, _, Utils, Editor, MetadataView, MetadataModel, MetadataCol
);
});
afterEach(function() {
MetadataView.Editor.calls.reset();
Editor.prototype.handleFieldChanged.calls.reset();
});
describe('Test Advanced to Basic synchronization', function() {
it('Correct data', function(done) {
transcripts.syncBasicTab(metadataCollection, metadataView);
@@ -362,31 +463,6 @@ function($, Backbone, _, Utils, Editor, MetadataView, MetadataModel, MetadataCol
}).getValue();
expect(youtubeValue).toEqual('');
});
it('Timed Transcript field is updated', function() {
Utils.Storage.set('sub', 'test_value');
transcripts.syncAdvancedTab(metadataCollection);
var collection = metadataCollection.models,
subValue = collection[1].getValue();
expect(subValue).toEqual('test_value');
});
it('Timed Transcript field is updated just once', function() {
Utils.Storage.set('sub', 'test_value');
var collection = metadataCollection.models,
subModel = collection[1];
spyOn(subModel, 'setValue');
transcripts.syncAdvancedTab(metadataCollection);
transcripts.syncAdvancedTab(metadataCollection);
transcripts.syncAdvancedTab(metadataCollection);
expect(subModel.setValue.calls.count()).toEqual(1);
});
});
});
});

View File

@@ -1,10 +1,10 @@
define(
[
'jquery', 'underscore',
'jquery', 'underscore', 'backbone',
'js/views/video/transcripts/utils', 'js/views/video/transcripts/file_uploader',
'xmodule', 'jquery.form'
],
function($, _, Utils, FileUploader) {
function($, _, Backbone, TranscriptUtils, FileUploader) {
'use strict';
describe('Transcripts.FileUploader', function() {
@@ -34,10 +34,6 @@ function($, _, Utils, FileUploader) {
'MessageManager',
['render', 'showError', 'hideError']
),
videoListObject = jasmine.createSpyObj(
'MetadataView.VideoList',
['render', 'getVideoObjectsList']
),
$container = $('.transcripts-status');
$container
@@ -49,7 +45,6 @@ function($, _, Utils, FileUploader) {
view = new FileUploader({
el: $container,
messenger: messenger,
videoListObject: videoListObject,
component_locator: 'component_locator'
});
});
@@ -100,6 +95,12 @@ function($, _, Utils, FileUploader) {
});
describe('Upload', function() {
var videoId = '123-456-789-0';
beforeEach(function() {
TranscriptUtils.Storage.set('edx_video_id', videoId);
});
it('File is not chosen', function() {
spyOn($.fn, 'ajaxSubmit');
view.upload();
@@ -114,6 +115,9 @@ function($, _, Utils, FileUploader) {
view.upload();
expect(view.$form.ajaxSubmit).toHaveBeenCalled();
expect(view.$form.ajaxSubmit).toHaveBeenCalledWith(jasmine.objectContaining({
data: {'edx_video_id': videoId}
}));
});
});
@@ -196,31 +200,25 @@ function($, _, Utils, FileUploader) {
status: 200,
responseText: JSON.stringify({
status: 'Success',
subs: 'test'
edx_video_id: 'test_video_id'
})
};
spyOn(Utils.Storage, 'set');
spyOn(Backbone, 'trigger');
view.xhrCompleteHandler(xhr);
expect(view.$progress).toHaveClass('is-invisible');
expect(view.options.messenger.render.calls.mostRecent().args[0])
.toEqual('uploaded');
expect(Utils.Storage.set)
.toHaveBeenCalledWith('sub', 'test');
expect(Backbone.trigger)
.toHaveBeenCalledWith('transcripts:basicTabUpdateEdxVideoId', 'test_video_id');
});
var assertAjaxError = function(xhr) {
spyOn(Utils.Storage, 'set');
view.xhrCompleteHandler(xhr);
expect(view.options.messenger.showError).toHaveBeenCalled();
expect(view.$progress).toHaveClass('is-invisible');
expect(view.options.messenger.render)
.not
.toHaveBeenCalled();
expect(Utils.Storage.set)
.not
.toHaveBeenCalledWith('sub', 'test');
expect(view.options.messenger.render).not.toHaveBeenCalled();
};
it('Ajax transport Error', function() {

View File

@@ -1,11 +1,11 @@
define(
[
'jquery', 'underscore',
'jquery', 'underscore', 'backbone',
'js/views/video/transcripts/utils', 'js/views/video/transcripts/message_manager',
'js/views/video/transcripts/file_uploader', 'sinon',
'xmodule'
],
function($, _, Utils, MessageManager, FileUploader, sinon) {
function($, _, Backbone, Utils, MessageManager, FileUploader, sinon) {
'use strict';
describe('Transcripts.MessageManager', function() {
@@ -61,8 +61,7 @@ function($, _, Utils, MessageManager, FileUploader, sinon) {
expect(fileUploader.initialize).toHaveBeenCalledWith({
el: view.$el,
messenger: view,
component_locator: view.component_locator,
videoListObject: view.options.parent
component_locator: view.component_locator
});
});
@@ -185,12 +184,15 @@ function($, _, Utils, MessageManager, FileUploader, sinon) {
};
it('Invoke without extraParamas', function(done) {
spyOn(Backbone, 'trigger');
sinonXhr.respondWith([
200,
{'Content-Type': 'application/json'},
JSON.stringify({
status: 'Success',
subs: 'video_id'
edx_video_id: 'video_id'
})
]);
@@ -203,20 +205,23 @@ function($, _, Utils, MessageManager, FileUploader, sinon) {
void(0)
);
expect(view.showError).not.toHaveBeenCalled();
expect(view.render.calls.mostRecent().args[0])
.toEqual('found');
expect(Utils.Storage.set).toHaveBeenCalled();
expect(view.render.calls.mostRecent().args[0]).toEqual('found');
expect(Backbone.trigger)
.toHaveBeenCalledWith('transcripts:basicTabUpdateEdxVideoId', 'video_id');
})
.always(done);
});
it('Invoke with extraParamas', function(done) {
spyOn(Backbone, 'trigger');
sinonXhr.respondWith([
200,
{'Content-Type': 'application/json'},
JSON.stringify({
status: 'Success',
subs: 'video_id'
edx_video_id: 'video_id'
})
]);
@@ -234,7 +239,8 @@ function($, _, Utils, MessageManager, FileUploader, sinon) {
);
expect(view.showError).not.toHaveBeenCalled();
expect(view.render.calls.mostRecent().args[0]).toEqual('found');
expect(Utils.Storage.set).toHaveBeenCalled();
expect(Backbone.trigger)
.toHaveBeenCalledWith('transcripts:basicTabUpdateEdxVideoId', 'video_id');
})
.always(done);
});

View File

@@ -1,13 +1,15 @@
define(
[
'jquery', 'underscore',
'jquery', 'underscore', 'backbone',
'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers',
'js/views/video/transcripts/utils',
'js/views/video/transcripts/editor',
'js/views/video/transcripts/metadata_videolist', 'js/models/metadata',
'js/views/abstract_editor',
'js/views/video/transcripts/message_manager',
'xmodule'
],
function($, _, AjaxHelpers, Utils, VideoList, MetadataModel, AbstractEditor) {
function($, _, Backbone, AjaxHelpers, Utils, Editor, VideoList, MetadataModel, AbstractEditor, MessageManager) {
'use strict';
describe('CMS.Views.Metadata.VideoList', function() {
var videoListEntryTemplate = readFixtures(
@@ -46,12 +48,23 @@ function($, _, AjaxHelpers, Utils, VideoList, MetadataModel, AbstractEditor) {
'video.webm'
]
},
videoIDStub = {
default_value: 'test default value',
display_name: 'Video ID',
explicitly_set: true,
field_name: 'edx_video_id',
help: 'Specifies the video ID.',
options: [],
type: 'VideoID',
value: 'advanced tab video id'
},
response = JSON.stringify({
command: 'found',
status: 'Success',
subs: 'video_id'
}),
MessageManager, messenger;
waitForEvent,
createVideoListView;
var createMockAjaxServer = function() {
@@ -67,7 +80,7 @@ function($, _, AjaxHelpers, Utils, VideoList, MetadataModel, AbstractEditor) {
};
beforeEach(function() {
var tpl = sandbox({
var tpl = sandbox({ // eslint-disable-line no-undef
class: 'component',
'data-locator': component_locator
});
@@ -86,24 +99,17 @@ function($, _, AjaxHelpers, Utils, VideoList, MetadataModel, AbstractEditor) {
// create mock server
this.mockServer = createMockAjaxServer();
spyOn($.fn, 'on').and.callThrough();
spyOn(Backbone, 'trigger').and.callThrough();
spyOn(Utils, 'command').and.callThrough();
spyOn(abstractEditor, 'initialize').and.callThrough();
spyOn(abstractEditor, 'render').and.callThrough();
spyOn(console, 'error');
messenger = jasmine.createSpyObj('MessageManager', [
'initialize', 'render', 'showError', 'hideError'
]);
$.each(messenger, function(index, method) {
method.and.returnValue(messenger);
});
MessageManager = function() {
messenger.initialize();
return messenger;
};
spyOn(MessageManager.prototype, 'initialize').and.callThrough();
spyOn(MessageManager.prototype, 'render').and.callThrough();
spyOn(MessageManager.prototype, 'showError').and.callThrough();
spyOn(MessageManager.prototype, 'hideError').and.callThrough();
jasmine.addMatchers({
assertValueInView: function() {
@@ -154,13 +160,49 @@ function($, _, AjaxHelpers, Utils, VideoList, MetadataModel, AbstractEditor) {
this.mockServer.restore();
});
var createVideoListView = function() {
var model = new MetadataModel(modelStub);
return new VideoList({
waitForEvent = function() {
var triggerCallArgs;
return jasmine.waitUntil(function() {
triggerCallArgs = Backbone.trigger.calls.mostRecent().args;
return Backbone.trigger.calls.count() === 1 &&
triggerCallArgs[0] === 'transcripts:basicTabFieldChanged';
});
};
createVideoListView = function(mockServer) {
var $container, editor, model, videoListView;
appendSetFixtures(
sandbox({ // eslint-disable-line no-undef
class: 'wrapper-comp-settings basic_metadata_edit',
'data-metadata': JSON.stringify({video_url: modelStub, edx_video_id: videoIDStub})
})
);
$container = $('.basic_metadata_edit');
editor = new Editor({
el: $container
});
spyOn(editor, 'getLocator').and.returnValue(component_locator);
// reset
Backbone.trigger.calls.reset();
mockServer.requests.length = 0;
model = new MetadataModel(modelStub);
videoListView = new VideoList({
el: $('.component'),
model: model,
MessageManager: MessageManager
});
waitForEvent()
.then(function() {
return true;
});
return videoListView;
};
var waitsForResponse = function(mockServer) {
@@ -174,36 +216,46 @@ function($, _, AjaxHelpers, Utils, VideoList, MetadataModel, AbstractEditor) {
it('Initialize', function(done) {
var view = createVideoListView();
var view = createVideoListView(this.mockServer), callArgs;
waitsForResponse(this.mockServer)
.then(function() {
expect(abstractEditor.initialize).toHaveBeenCalled();
expect(messenger.initialize).toHaveBeenCalled();
expect(view.component_locator).toBe(component_locator);
expect(view.$el).toHandle('input');
}).always(done);
.then(function() {
expect(abstractEditor.initialize).toHaveBeenCalled();
expect(MessageManager.prototype.initialize).toHaveBeenCalled();
expect(view.component_locator).toBe(component_locator);
expect(view.$el).toHandle('input');
callArgs = view.$el.on.calls.mostRecent().args;
expect(callArgs[0]).toEqual('input');
expect(callArgs[1]).toEqual('.videolist-settings-item input');
}).always(done);
});
describe('Render', function() {
var assertToHaveBeenRendered = function(videoList) {
expect(abstractEditor.render).toHaveBeenCalled();
expect(Utils.command).toHaveBeenCalledWith(
'check',
component_locator,
videoList
);
var assertToHaveBeenRendered = function(expectedVideoList) {
var commandCallArgs = Utils.command.calls.mostRecent().args,
actualVideoList = commandCallArgs[2].slice(0, expectedVideoList.length);
expect(messenger.render).toHaveBeenCalled();
expect(commandCallArgs[0]).toEqual('check');
expect(commandCallArgs[1]).toEqual(component_locator);
_.each([0, 1, 2], function(index) {
expect(_.isEqual(expectedVideoList[index], actualVideoList[index])).toBeTruthy();
});
expect(abstractEditor.render).toHaveBeenCalled();
expect(MessageManager.prototype.render).toHaveBeenCalled();
},
resetSpies = function(mockServer) {
abstractEditor.render.calls.reset();
Utils.command.calls.reset();
messenger.render.calls.reset();
mockServer.requests.length = 0;
MessageManager.prototype.render.calls.reset();
mockServer.requests.length = 0; // eslint-disable-line no-param-reassign
};
afterEach(function() {
Backbone.trigger('xblock:editorModalHidden');
});
it('is rendered in correct way', function(done) {
createVideoListView();
var view = createVideoListView(this.mockServer);
waitsForResponse(this.mockServer)
.then(function() {
assertToHaveBeenRendered(videoList);
@@ -212,7 +264,7 @@ function($, _, AjaxHelpers, Utils, VideoList, MetadataModel, AbstractEditor) {
});
it('is rendered with opened extra videos bar', function(done) {
var view = createVideoListView();
var view = createVideoListView(this.mockServer);
var videoListLength = [
{
mode: 'youtube',
@@ -233,8 +285,8 @@ function($, _, AjaxHelpers, Utils, VideoList, MetadataModel, AbstractEditor) {
}
];
spyOn(view, 'getVideoObjectsList').and.returnValue(videoListLength);
spyOn(view, 'openExtraVideosBar');
spyOn(VideoList.prototype, 'getVideoObjectsList').and.returnValue(videoListLength);
spyOn(VideoList.prototype, 'openExtraVideosBar');
resetSpies(this.mockServer);
view.render();
@@ -260,7 +312,7 @@ function($, _, AjaxHelpers, Utils, VideoList, MetadataModel, AbstractEditor) {
});
it('is rendered without opened extra videos bar', function(done) {
var view = createVideoListView(),
var view = createVideoListView(this.mockServer),
videoList = [
{
mode: 'youtube',
@@ -269,8 +321,8 @@ function($, _, AjaxHelpers, Utils, VideoList, MetadataModel, AbstractEditor) {
}
];
spyOn(view, 'getVideoObjectsList').and.returnValue(videoList);
spyOn(view, 'closeExtraVideosBar');
spyOn(VideoList.prototype, 'getVideoObjectsList').and.returnValue(videoList);
spyOn(VideoList.prototype, 'closeExtraVideosBar');
resetSpies(this.mockServer);
view.render();
@@ -286,7 +338,7 @@ function($, _, AjaxHelpers, Utils, VideoList, MetadataModel, AbstractEditor) {
describe('isUniqOtherVideos', function() {
it('Unique data - return true', function(done) {
var view = createVideoListView(),
var view = createVideoListView(this.mockServer),
data = videoList.concat([{
mode: 'html5',
type: 'other',
@@ -302,7 +354,7 @@ function($, _, AjaxHelpers, Utils, VideoList, MetadataModel, AbstractEditor) {
});
it('Not Unique data - return false', function(done) {
var view = createVideoListView(),
var view = createVideoListView(this.mockServer),
data = [
{
mode: 'html5',
@@ -342,7 +394,7 @@ function($, _, AjaxHelpers, Utils, VideoList, MetadataModel, AbstractEditor) {
describe('isUniqVideoTypes', function() {
it('Unique data - return true', function(done) {
var view = createVideoListView(),
var view = createVideoListView(this.mockServer),
data = videoList;
waitsForResponse(this.mockServer)
@@ -354,7 +406,7 @@ function($, _, AjaxHelpers, Utils, VideoList, MetadataModel, AbstractEditor) {
});
it('Not Unique data - return false', function(done) {
var view = createVideoListView(),
var view = createVideoListView(this.mockServer),
data = [
{
mode: 'html5',
@@ -389,7 +441,7 @@ function($, _, AjaxHelpers, Utils, VideoList, MetadataModel, AbstractEditor) {
describe('checkIsUniqVideoTypes', function() {
it('Error is shown', function(done) {
var view = createVideoListView(),
var view = createVideoListView(this.mockServer),
data = [
{
mode: 'html5',
@@ -417,14 +469,14 @@ function($, _, AjaxHelpers, Utils, VideoList, MetadataModel, AbstractEditor) {
.then(function() {
var result = view.checkIsUniqVideoTypes(data);
expect(messenger.showError).toHaveBeenCalled();
expect(MessageManager.prototype.showError).toHaveBeenCalled();
expect(result).toBe(false);
})
.always(done);
});
it('All works okay if arguments are not passed', function(done) {
var view = createVideoListView();
var view = createVideoListView(this.mockServer);
spyOn(view, 'getVideoObjectsList').and.returnValue(videoList);
waitsForResponse(this.mockServer)
@@ -432,7 +484,7 @@ function($, _, AjaxHelpers, Utils, VideoList, MetadataModel, AbstractEditor) {
var result = view.checkIsUniqVideoTypes();
expect(view.getVideoObjectsList).toHaveBeenCalled();
expect(messenger.showError).not.toHaveBeenCalled();
expect(MessageManager.prototype.showError).not.toHaveBeenCalled();
expect(result).toBe(true);
})
.always(done);
@@ -441,7 +493,7 @@ function($, _, AjaxHelpers, Utils, VideoList, MetadataModel, AbstractEditor) {
describe('checkValidity', function() {
it('Error message is shown', function(done) {
var view = createVideoListView();
var view = createVideoListView(this.mockServer);
spyOn(view, 'checkIsUniqVideoTypes').and.returnValue(true);
waitsForResponse(this.mockServer)
@@ -449,7 +501,7 @@ function($, _, AjaxHelpers, Utils, VideoList, MetadataModel, AbstractEditor) {
var data = {mode: 'incorrect'},
result = view.checkValidity(data, true);
expect(messenger.showError).toHaveBeenCalled();
expect(MessageManager.prototype.showError).toHaveBeenCalled();
expect(view.checkIsUniqVideoTypes).toHaveBeenCalled();
expect(result).toBe(false);
})
@@ -457,7 +509,7 @@ function($, _, AjaxHelpers, Utils, VideoList, MetadataModel, AbstractEditor) {
});
it('Error message is shown when flag is not passed', function(done) {
var view = createVideoListView();
var view = createVideoListView(this.mockServer);
spyOn(view, 'checkIsUniqVideoTypes').and.returnValue(true);
waitsForResponse(this.mockServer)
@@ -465,14 +517,14 @@ function($, _, AjaxHelpers, Utils, VideoList, MetadataModel, AbstractEditor) {
var data = {mode: 'incorrect'},
result = view.checkValidity(data);
expect(messenger.showError).not.toHaveBeenCalled();
expect(MessageManager.prototype.showError).not.toHaveBeenCalled();
expect(view.checkIsUniqVideoTypes).toHaveBeenCalled();
expect(result).toBe(true);
}).always(done);
});
it('All works okay if correct data is passed', function(done) {
var view = createVideoListView();
var view = createVideoListView(this.mockServer);
spyOn(view, 'checkIsUniqVideoTypes').and.returnValue(true);
waitsForResponse(this.mockServer)
@@ -480,7 +532,7 @@ function($, _, AjaxHelpers, Utils, VideoList, MetadataModel, AbstractEditor) {
var data = videoList,
result = view.checkValidity(data);
expect(messenger.showError).not.toHaveBeenCalled();
expect(MessageManager.prototype.showError).not.toHaveBeenCalled();
expect(view.checkIsUniqVideoTypes).toHaveBeenCalled();
expect(result).toBe(true);
})
@@ -489,7 +541,7 @@ function($, _, AjaxHelpers, Utils, VideoList, MetadataModel, AbstractEditor) {
});
it('openExtraVideosBar', function(done) {
var view = createVideoListView();
var view = createVideoListView(this.mockServer);
waitsForResponse(this.mockServer)
.then(function() {
view.$extraVideosBar.removeClass('is-visible');
@@ -500,7 +552,7 @@ function($, _, AjaxHelpers, Utils, VideoList, MetadataModel, AbstractEditor) {
});
it('closeExtraVideosBar', function(done) {
var view = createVideoListView();
var view = createVideoListView(this.mockServer);
waitsForResponse(this.mockServer)
.then(function() {
view.$extraVideosBar.addClass('is-visible');
@@ -512,7 +564,7 @@ function($, _, AjaxHelpers, Utils, VideoList, MetadataModel, AbstractEditor) {
});
it('toggleExtraVideosBar', function(done) {
var view = createVideoListView();
var view = createVideoListView(this.mockServer);
waitsForResponse(this.mockServer)
.then(function() {
view.$extraVideosBar.addClass('is-visible');
@@ -525,7 +577,7 @@ function($, _, AjaxHelpers, Utils, VideoList, MetadataModel, AbstractEditor) {
});
it('getValueFromEditor', function(done) {
var view = createVideoListView();
var view = createVideoListView(this.mockServer);
waitsForResponse(this.mockServer)
.then(function() {
expect(view).assertValueInView(modelStub.value);
@@ -534,7 +586,7 @@ function($, _, AjaxHelpers, Utils, VideoList, MetadataModel, AbstractEditor) {
});
it('setValueInEditor', function(done) {
var view = createVideoListView();
var view = createVideoListView(this.mockServer);
waitsForResponse(this.mockServer)
.then(function() {
expect(view).assertCanUpdateView(['abc.mp4']);
@@ -543,7 +595,7 @@ function($, _, AjaxHelpers, Utils, VideoList, MetadataModel, AbstractEditor) {
});
it('getVideoObjectsList', function(done) {
var view = createVideoListView();
var view = createVideoListView(this.mockServer);
var value = [
{
mode: 'youtube',
@@ -577,7 +629,7 @@ function($, _, AjaxHelpers, Utils, VideoList, MetadataModel, AbstractEditor) {
describe('getPlaceholders', function() {
it('All works okay if empty values are passed', function(done) {
var view = createVideoListView(),
var view = createVideoListView(this.mockServer),
defaultPlaceholders = view.placeholders;
waitsForResponse(this.mockServer)
@@ -593,7 +645,7 @@ function($, _, AjaxHelpers, Utils, VideoList, MetadataModel, AbstractEditor) {
it('On filling less than 3 fields, remaining fields should have ' +
'placeholders for video types that were not filled yet',
function(done) {
var view = createVideoListView(),
var view = createVideoListView(this.mockServer),
defaultPlaceholders = view.placeholders;
var dataDict = {
youtube: {
@@ -640,7 +692,7 @@ function($, _, AjaxHelpers, Utils, VideoList, MetadataModel, AbstractEditor) {
var eventObject;
var resetSpies = function(view) {
messenger.hideError.calls.reset();
MessageManager.prototype.hideError.calls.reset();
view.updateModel.calls.reset();
view.closeExtraVideosBar.calls.reset();
};
@@ -660,100 +712,77 @@ function($, _, AjaxHelpers, Utils, VideoList, MetadataModel, AbstractEditor) {
resetSpies(view);
};
it('Field has invalid value - nothing should happen',
function(done) {
var view = createVideoListView();
setUp(view);
$.fn.hasClass.and.returnValue(false);
view.checkValidity.and.returnValue(false);
var videoListView = function() {
return new VideoList({
el: $('.component'),
model: new MetadataModel(modelStub),
MessageManager: MessageManager
});
};
waitsForResponse(this.mockServer)
.then(function() {
view.inputHandler(eventObject);
expect(messenger.hideError).not.toHaveBeenCalled();
expect(view.updateModel).not.toHaveBeenCalled();
expect(view.closeExtraVideosBar).not.toHaveBeenCalled();
expect($.fn.prop).toHaveBeenCalledWith(
'disabled', true
);
expect($.fn.addClass).toHaveBeenCalledWith(
'is-disabled'
);
})
.always(done);
}
);
beforeEach(function() {
MessageManager.prototype.render.and.callFake(function() { return true; });
});
it('Main field has invalid value - extra Videos Bar is closed',
function(done) {
var view = createVideoListView();
setUp(view);
$.fn.hasClass.and.returnValue(true);
view.checkValidity.and.returnValue(false);
afterEach(function() {
MessageManager.prototype.render.and.callThrough();
});
waitsForResponse(this.mockServer)
.then(function() {
view.inputHandler(eventObject);
expect(messenger.hideError).not.toHaveBeenCalled();
expect(view.updateModel).not.toHaveBeenCalled();
expect(view.closeExtraVideosBar).toHaveBeenCalled();
expect($.fn.prop).toHaveBeenCalledWith(
'disabled', true
);
expect($.fn.addClass).toHaveBeenCalledWith(
'is-disabled'
);
})
.always(done);
}
);
it('Field has invalid value - nothing should happen', function() {
var view = videoListView();
setUp(view);
$.fn.hasClass.and.returnValue(false);
view.checkValidity.and.returnValue(false);
it('Model is updated if value is valid',
function(done) {
var view = createVideoListView();
setUp(view);
view.checkValidity.and.returnValue(true);
_.isEqual.and.returnValue(false);
view.inputHandler(eventObject);
expect(MessageManager.prototype.hideError).not.toHaveBeenCalled();
expect(view.updateModel).not.toHaveBeenCalled();
expect(view.closeExtraVideosBar).not.toHaveBeenCalled();
expect($.fn.prop).toHaveBeenCalledWith('disabled', true);
expect($.fn.addClass).toHaveBeenCalledWith('is-disabled');
});
waitsForResponse(this.mockServer)
.then(function() {
view.inputHandler(eventObject);
expect(messenger.hideError).not.toHaveBeenCalled();
expect(view.updateModel).toHaveBeenCalled();
expect(view.closeExtraVideosBar).not.toHaveBeenCalled();
expect($.fn.prop).toHaveBeenCalledWith(
'disabled', false
);
expect($.fn.removeClass).toHaveBeenCalledWith(
'is-disabled'
);
})
.always(done);
}
);
it('Main field has invalid value - extra Videos Bar is closed', function() {
var view = videoListView();
setUp(view);
$.fn.hasClass.and.returnValue(true);
view.checkValidity.and.returnValue(false);
it('Corner case: Error is hided',
function(done) {
var view = createVideoListView();
setUp(view);
view.checkValidity.and.returnValue(true);
_.isEqual.and.returnValue(true);
waitsForResponse(this.mockServer)
.then(function() {
view.inputHandler(eventObject);
expect(messenger.hideError).toHaveBeenCalled();
expect(view.updateModel).not.toHaveBeenCalled();
expect(view.closeExtraVideosBar).not.toHaveBeenCalled();
expect($.fn.prop).toHaveBeenCalledWith(
'disabled', false
);
expect($.fn.removeClass).toHaveBeenCalledWith(
'is-disabled'
);
})
.always(done);
}
);
view.inputHandler(eventObject);
expect(MessageManager.prototype.hideError).not.toHaveBeenCalled();
expect(view.updateModel).not.toHaveBeenCalled();
expect(view.closeExtraVideosBar).toHaveBeenCalled();
expect($.fn.prop).toHaveBeenCalledWith('disabled', true);
expect($.fn.addClass).toHaveBeenCalledWith('is-disabled');
});
it('Model is updated if value is valid', function() {
var view = videoListView();
setUp(view);
view.checkValidity.and.returnValue(true);
_.isEqual.and.returnValue(false);
view.inputHandler(eventObject);
expect(MessageManager.prototype.hideError).not.toHaveBeenCalled();
expect(view.updateModel).toHaveBeenCalled();
expect(view.closeExtraVideosBar).not.toHaveBeenCalled();
expect($.fn.prop).toHaveBeenCalledWith('disabled', false);
expect($.fn.removeClass).toHaveBeenCalledWith('is-disabled');
});
it('Corner case: Error is hided', function() {
var view = videoListView();
setUp(view);
view.checkValidity.and.returnValue(true);
_.isEqual.and.returnValue(true);
view.inputHandler(eventObject);
expect(MessageManager.prototype.hideError).toHaveBeenCalled();
expect(view.updateModel).not.toHaveBeenCalled();
expect(view.closeExtraVideosBar).not.toHaveBeenCalled();
expect($.fn.prop).toHaveBeenCalledWith('disabled', false);
expect($.fn.removeClass).toHaveBeenCalledWith('is-disabled');
});
});
});
});

View File

@@ -124,13 +124,6 @@ define(['jquery', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', 'js/spec_
requests[actualIndex].respond(status);
};
it('does nothing if item not moved far enough', function() {
var requests = init(this);
// Drag the first component in Group A down very slightly but not enough to move it.
dragComponentVertically(groupAComponent1, 5);
verifyNumReorderCalls(requests, 0);
});
it('can reorder within a group', function() {
var requests = init(this);
// Drag the third component in Group A to be the first

View File

@@ -3,8 +3,9 @@
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
define(["js/models/metadata", "js/collections/metadata", "js/views/metadata", "cms/js/main"],
function(MetadataModel, MetadataCollection, MetadataView, main) {
define(["underscore", "js/models/metadata", "js/collections/metadata", "js/views/metadata", "cms/js/main",
"js/views/video/transcripts/utils", 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers'],
function(_, MetadataModel, MetadataCollection, MetadataView, main, TranscriptUtils, AjaxHelpers) {
const verifyInputType = function(input, expectedType) {
// Some browsers (e.g. FireFox) do not support the "number"
// input type. We can accept a "text" input instead
@@ -43,6 +44,8 @@ function(MetadataModel, MetadataCollection, MetadataView, main) {
value: "Word cloud"
};
const videoIDEntry = _.extend({}, genericEntry, {field_name: "edx_video_id", type: "VideoID"});
const selectEntry = {
default_value: "answered",
display_name: "Show Answer",
@@ -271,6 +274,51 @@ function(MetadataModel, MetadataCollection, MetadataView, main) {
});
});
describe("MetadataView.VideoID", function() {
var waitForMock;
waitForMock = function(mock) {
return jasmine.waitUntil(function() {
return mock.calls.count() === 1;
});
};
beforeEach(function() {
const model = new MetadataModel(videoIDEntry);
spyOn(TranscriptUtils.Storage, 'set');
this.view = new MetadataView.VideoID({model});
spyOn(Backbone, 'trigger');
expect(TranscriptUtils.Storage.set).toHaveBeenCalledWith('edx_video_id', this.view.getValueFromEditor());
});
it("triggers correct event on input change", function(done) {
// change value and trigger input event
this.view.$el.find('input').val("1234-5678-90").trigger('input');
waitForMock(Backbone.trigger)
.then(function() {
expect(Backbone.trigger).toHaveBeenCalledWith('transcripts:basicTabFieldChanged');
})
.always(done);
});
it("triggers correct event on clear", function(done) {
this.view.clear();
waitForMock(Backbone.trigger)
.then(function() {
expect(Backbone.trigger).toHaveBeenCalledWith('transcripts:basicTabFieldChanged');
})
.always(done);
});
it("constructs correct data", function() {
expect(
this.view.getData()
).toEqual(
[{mode: 'edx_video_id', type: 'edx_video_id', video: this.view.getValueFromEditor()}]
);
});
});
describe("MetadataView.Option is an option input type with clear functionality", function() {
beforeEach(function() {
const model = new MetadataModel(selectEntry);

View File

@@ -1,6 +1,7 @@
define(['jquery', 'underscore', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', 'js/spec_helpers/edit_helpers',
'js/views/modals/edit_xblock', 'js/models/xblock_info'],
function($, _, AjaxHelpers, EditHelpers, EditXBlockModal, XBlockInfo) {
define(['jquery', 'underscore', 'backbone', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers',
'js/spec_helpers/edit_helpers', 'js/views/modals/edit_xblock', 'js/models/xblock_info'],
function($, _, Backbone, AjaxHelpers, EditHelpers, EditXBlockModal, XBlockInfo) {
'use strict';
describe('EditXBlockModal', function() {
var model, modal, showModal;
@@ -30,6 +31,7 @@ define(['jquery', 'underscore', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpe
beforeEach(function() {
EditHelpers.installMockXBlock();
spyOn(Backbone, 'trigger').and.callThrough();
});
afterEach(function() {
@@ -74,6 +76,7 @@ define(['jquery', 'underscore', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpe
modal.editorView.notifyRuntime('save', {state: 'end'});
expect(EditHelpers.isShowingModal(modal)).toBeFalsy();
expect(refreshed).toBeTruthy();
expect(Backbone.trigger).toHaveBeenCalledWith('xblock:editorModalHidden');
});
it('hides itself and does not refresh after cancel notification', function() {
@@ -86,6 +89,7 @@ define(['jquery', 'underscore', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpe
modal.editorView.notifyRuntime('cancel');
expect(EditHelpers.isShowingModal(modal)).toBeFalsy();
expect(refreshed).toBeFalsy();
expect(Backbone.trigger).toHaveBeenCalledWith('xblock:editorModalHidden');
});
describe('Custom Buttons', function() {

View File

@@ -11,7 +11,8 @@ define(
duration: 42,
created: '2014-11-25T23:13:05',
edx_video_id: 'dummy_id',
status: 'uploading'
status: 'uploading',
transcripts: []
};
var collection = new Backbone.Collection(
_.map(
@@ -26,6 +27,9 @@ define(
var view = new PreviousVideoUploadListView({
collection: collection,
videoHandlerUrl: videoHandlerUrl,
transcriptAvailableLanguages: [],
videoSupportedFileFormats: [],
videoTranscriptSettings: {},
videoImageSettings: {}
});
return view.render().$el;

View File

@@ -10,11 +10,15 @@ define(
duration: 42,
created: '2014-11-25T23:13:05',
edx_video_id: 'dummy_id',
status: 'uploading'
status: 'uploading',
transcripts: []
},
view = new PreviousVideoUploadView({
model: new Backbone.Model($.extend({}, defaultData, modelData)),
videoHandlerUrl: '/videos/course-v1:org.0+course_0+Run_0',
transcriptAvailableLanguages: [],
videoSupportedFileFormats: [],
videoTranscriptSettings: {},
videoImageSettings: {}
});
return view.render().$el;

View File

@@ -1,9 +1,14 @@
define(["sinon", "js/models/uploads", "js/views/uploads", "js/models/chapter",
define(["underscore", "sinon", "js/models/uploads", "js/views/uploads", "js/models/chapter",
"edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers", "js/spec_helpers/modal_helpers"],
(sinon, FileUpload, UploadDialog, Chapter, AjaxHelpers, modal_helpers) =>
(_, sinon, FileUpload, UploadDialog, Chapter, AjaxHelpers, modal_helpers) =>
describe("UploadDialog", function() {
const tpl = readFixtures("upload-dialog.underscore");
const tpl = readFixtures("upload-dialog.underscore"),
uploadData = {
edx_video_id: '123-456-789-0',
language_code: 'en',
new_language_code: 'ur'
};
beforeEach(function() {
let dialogResponse;
@@ -27,7 +32,8 @@ define(["sinon", "js/models/uploads", "js/views/uploads", "js/models/chapter",
url: CMS.URL.UPLOAD_ASSET,
onSuccess: response => {
return test.dialogResponse.push(response.response);
}
},
uploadData: uploadData
});
spyOn(view, 'remove').and.callThrough();
@@ -37,6 +43,7 @@ define(["sinon", "js/models/uploads", "js/views/uploads", "js/models/chapter",
const jqMockFileInput = jasmine.createSpyObj('jqMockFileInput', ['get', 'replaceWith']);
jqMockFileInput.get.and.returnValue(mockFileInput);
const originalView$ = view.$;
spyOn($.fn, 'ajaxSubmit').and.callThrough();
spyOn(view, "$").and.callFake(function(selector) {
if (selector === "input[type=file]") {
return jqMockFileInput;
@@ -126,6 +133,9 @@ define(["sinon", "js/models/uploads", "js/views/uploads", "js/models/chapter",
view.upload();
expect(this.model.get("uploading")).toBeTruthy();
AjaxHelpers.expectRequest(requests, "POST", "/upload");
expect($.fn.ajaxSubmit.calls.mostRecent().args[0].data).toEqual(
_.extend({}, uploadData, {notifyOnError: false})
);
AjaxHelpers.respondWithJson(requests, { response: "dummy_response"});
expect(this.model.get("uploading")).toBeFalsy();
expect(this.model.get("finished")).toBeTruthy();

View File

@@ -43,7 +43,8 @@ define(
duration: 42,
created: '2014-11-25T23:13:05',
edx_video_id: 'dummy_id',
status: 'uploading'
status: 'uploading',
transcripts: []
},
collection = new Backbone.Collection(_.map(_.range(numVideos), function(num, index) {
return new Backbone.Model(
@@ -61,7 +62,10 @@ define(
max_height: VIDEO_IMAGE_MAX_HEIGHT,
supported_file_formats: VIDEO_IMAGE_SUPPORTED_FILE_FORMATS,
video_image_upload_enabled: videoImageUploadEnabled
}
},
transcriptAvailableLanguages: [],
videoSupportedFileFormats: [],
videoTranscriptSettings: {}
});
$videoListEl = videoListView.render().$el;

View File

@@ -93,9 +93,8 @@ define(
return new File([new Blob([Array(size).join('i')], {type: type})], transcriptFileName);
};
renderView = function(availableTranscripts, isVideoTranscriptEnabled) {
renderView = function(availableTranscripts) {
var videoViewIndex = 0,
isVideoTranscriptEnabled = isVideoTranscriptEnabled || _.isUndefined(isVideoTranscriptEnabled), // eslint-disable-line max-len, no-redeclare
videoData = {
client_video_id: clientVideoID,
edx_video_id: edxVideoID,
@@ -109,8 +108,7 @@ define(
videoImageSettings: {},
videoTranscriptSettings: videoTranscriptSettings,
transcriptAvailableLanguages: transcriptAvailableLanguages,
videoSupportedFileFormats: videoSupportedFileFormats,
isVideoTranscriptEnabled: isVideoTranscriptEnabled
videoSupportedFileFormats: videoSupportedFileFormats
});
videoListView.setElement($('.wrapper-assets'));
videoListView.render();
@@ -139,18 +137,6 @@ define(
expect(_.isUndefined(videoTranscriptsView)).toEqual(false);
});
it('does not render transcripts view if feature is disabled', function() {
renderView(transcripts, false);
// Verify transcript container is not present.
expect(videoListView.$el.find('.video-transcripts-header')).not.toExist();
// Veirfy transcript column header is not present.
expect(videoListView.$el.find('.js-table-head .video-head-col.transcripts-col')).not.toExist();
// Verify transcript data column is not present.
expect(videoListView.$el.find('.js-table-body .transcripts-col')).not.toExist();
// Verify view has not initiallized.
expect(_.isUndefined(videoTranscriptsView)).toEqual(true);
});
it('does not show list of transcripts initially', function() {
expect(
videoTranscriptsView.$el.find('.video-transcripts-wrapper').hasClass('hidden')

View File

@@ -1,16 +1,21 @@
define(
[
'backbone',
'js/views/baseview', 'underscore', 'js/models/metadata', 'js/views/abstract_editor',
'js/models/uploads', 'js/views/uploads',
'js/models/license', 'js/views/license',
'js/views/video/transcripts/utils',
'js/views/video/transcripts/metadata_videolist',
'js/views/video/translations_editor'
],
function(BaseView, _, MetadataModel, AbstractEditor, FileUpload, UploadDialog,
LicenseModel, LicenseView, VideoList, VideoTranslations) {
function(Backbone, BaseView, _, MetadataModel, AbstractEditor, FileUpload, UploadDialog,
LicenseModel, LicenseView, TranscriptUtils, VideoList, VideoTranslations) {
'use strict';
var Metadata = {};
Metadata.Editor = BaseView.extend({
// Store rendered view references
views: {},
// Model is CMS.Models.MetadataCollection,
initialize: function() {
@@ -42,10 +47,10 @@ function(BaseView, _, MetadataModel, AbstractEditor, FileUpload, UploadDialog,
}
if (_.isFunction(Metadata[type])) {
new Metadata[type](data);
self.views[data.model.getFieldName()] = new Metadata[type](data);
} else {
// Everything else is treated as GENERIC_TYPE, which uses String editor.
new Metadata.String(data);
self.views[data.model.getFieldName()] = new Metadata.String(data);
}
});
},
@@ -120,6 +125,40 @@ function(BaseView, _, MetadataModel, AbstractEditor, FileUpload, UploadDialog,
}
});
Metadata.VideoID = Metadata.String.extend({
// Delay between check_transcript requests
requestDelay: 300,
initialize: function() {
Metadata.String.prototype.initialize.apply(this, arguments);
this.$el.on(
'input',
'input',
_.debounce(_.bind(this.inputChange, this), this.requestDelay)
);
},
render: function() {
Metadata.String.prototype.render.apply(this, arguments);
TranscriptUtils.Storage.set('edx_video_id', this.getValueFromEditor());
},
clear: function() {
this.model.setValue('');
this.inputChange();
},
getData: function() {
return [{mode: 'edx_video_id', type: 'edx_video_id', video: this.getValueFromEditor()}];
},
inputChange: function() {
TranscriptUtils.Storage.set('edx_video_id', this.getValueFromEditor());
Backbone.trigger('transcripts:basicTabFieldChanged');
}
});
Metadata.Number = AbstractEditor.extend({
events: {

View File

@@ -3,9 +3,9 @@
* It is invoked using the edit method which is passed an existing rendered xblock,
* and upon save an optional refresh function can be invoked to update the display.
*/
define(['jquery', 'underscore', 'gettext', 'js/views/modals/base_modal', 'common/js/components/utils/view_utils',
'js/views/utils/xblock_utils', 'js/views/xblock_editor'],
function($, _, gettext, BaseModal, ViewUtils, XBlockViewUtils, XBlockEditorView) {
define(['jquery', 'underscore', 'backbone', 'gettext', 'js/views/modals/base_modal',
'common/js/components/utils/view_utils', 'js/views/utils/xblock_utils', 'js/views/xblock_editor'],
function($, _, Backbone, gettext, BaseModal, ViewUtils, XBlockViewUtils, XBlockEditorView) {
'use strict';
var EditXBlockModal = BaseModal.extend({
@@ -181,6 +181,9 @@ define(['jquery', 'underscore', 'gettext', 'js/views/modals/base_modal', 'common
},
hide: function() {
// Notify child views to stop listening events
Backbone.trigger('xblock:editorModalHidden');
BaseModal.prototype.hide.call(this);
// Notify the runtime that the modal has been hidden

View File

@@ -20,7 +20,6 @@ define(
this.template = HtmlUtils.template(previousVideoUploadTemplate);
this.videoHandlerUrl = options.videoHandlerUrl;
this.videoImageUploadEnabled = options.videoImageSettings.video_image_upload_enabled;
this.isVideoTranscriptEnabled = options.isVideoTranscriptEnabled;
if (this.videoImageUploadEnabled) {
this.videoThumbnailView = new VideoThumbnailView({
@@ -30,22 +29,19 @@ define(
videoImageSettings: options.videoImageSettings
});
}
if (this.isVideoTranscriptEnabled) {
this.videoTranscriptsView = new VideoTranscriptsView({
transcripts: this.model.get('transcripts'),
edxVideoID: this.model.get('edx_video_id'),
clientVideoID: this.model.get('client_video_id'),
transcriptAvailableLanguages: options.transcriptAvailableLanguages,
videoSupportedFileFormats: options.videoSupportedFileFormats,
videoTranscriptSettings: options.videoTranscriptSettings
});
}
this.videoTranscriptsView = new VideoTranscriptsView({
transcripts: this.model.get('transcripts'),
edxVideoID: this.model.get('edx_video_id'),
clientVideoID: this.model.get('client_video_id'),
transcriptAvailableLanguages: options.transcriptAvailableLanguages,
videoSupportedFileFormats: options.videoSupportedFileFormats,
videoTranscriptSettings: options.videoTranscriptSettings
});
},
render: function() {
var renderedAttributes = {
videoImageUploadEnabled: this.videoImageUploadEnabled,
isVideoTranscriptEnabled: this.isVideoTranscriptEnabled,
created: DateUtils.renderDate(this.model.get('created')),
status: this.model.get('status')
};
@@ -59,9 +55,7 @@ define(
if (this.videoImageUploadEnabled) {
this.videoThumbnailView.setElement(this.$('.thumbnail-col')).render();
}
if (this.isVideoTranscriptEnabled) {
this.videoTranscriptsView.setElement(this.$('.transcripts-col')).render();
}
this.videoTranscriptsView.setElement(this.$('.transcripts-col')).render();
return this;
},

View File

@@ -11,7 +11,6 @@ define(
this.template = HtmlUtils.template(previousVideoUploadListTemplate);
this.encodingsDownloadUrl = options.encodingsDownloadUrl;
this.videoImageUploadEnabled = options.videoImageSettings.video_image_upload_enabled;
this.isVideoTranscriptEnabled = options.isVideoTranscriptEnabled;
this.itemViews = this.collection.map(function(model) {
return new PreviousVideoUploadView({
videoImageUploadURL: options.videoImageUploadURL,
@@ -21,8 +20,7 @@ define(
videoTranscriptSettings: options.videoTranscriptSettings,
model: model,
transcriptAvailableLanguages: options.transcriptAvailableLanguages,
videoSupportedFileFormats: options.videoSupportedFileFormats,
isVideoTranscriptEnabled: options.isVideoTranscriptEnabled
videoSupportedFileFormats: options.videoSupportedFileFormats
});
});
},
@@ -35,8 +33,7 @@ define(
this.$el,
this.template({
encodingsDownloadUrl: this.encodingsDownloadUrl,
videoImageUploadEnabled: this.videoImageUploadEnabled,
isVideoTranscriptEnabled: this.isVideoTranscriptEnabled
videoImageUploadEnabled: this.videoImageUploadEnabled
})
);

View File

@@ -13,11 +13,14 @@ define(['jquery', 'underscore', 'gettext', 'js/views/modals/base_modal', 'jquery
viewSpecificClasses: 'confirm'
}),
initialize: function() {
initialize: function(options) {
BaseModal.prototype.initialize.call(this);
this.template = this.loadTemplate('upload-dialog');
this.listenTo(this.model, 'change', this.renderContents);
this.options.title = this.model.get('title');
// `uploadData` can contain extra data that
// can be POSTed along with the file.
this.uploadData = _.extend({}, options.uploadData);
},
addActionButtons: function() {
@@ -73,17 +76,19 @@ define(['jquery', 'underscore', 'gettext', 'js/views/modals/base_modal', 'jquery
},
upload: function(e) {
var uploadAjaxData = _.extend({}, this.uploadData);
// don't show the generic error notification; we're in a modal,
// and we're better off modifying it instead.
uploadAjaxData.notifyOnError = false;
if (e && e.preventDefault) { e.preventDefault(); }
this.model.set('uploading', true);
this.$('form').ajaxSubmit({
success: _.bind(this.success, this),
error: _.bind(this.error, this),
uploadProgress: _.bind(this.progress, this),
data: {
// don't show the generic error notification; we're in a modal,
// and we're better off modifying it instead.
notifyOnError: false
}
data: uploadAjaxData
});
},

View File

@@ -12,7 +12,6 @@ function($, Backbone, _, Utils, MetadataView, MetadataCollection) {
initialize: function() {
// prepare data for MetadataView.Editor
var metadata = this.$el.data('metadata'),
models = this.toModels(metadata);
@@ -23,6 +22,23 @@ function($, Backbone, _, Utils, MetadataView, MetadataCollection) {
el: this.$el,
collection: this.collection
});
// Listen to edx_video_id update
this.listenTo(Backbone, 'transcripts:basicTabUpdateEdxVideoId', this.handleUpdateEdxVideoId);
// Listen to `video_url` and `edx_video_id` updates
this.listenTo(Backbone, 'transcripts:basicTabFieldChanged', this.handleFieldChanged);
// Listen to modal hidden event
this.listenTo(Backbone, 'xblock:editorModalHidden', this.destroy);
// Now `video_url` and `edx_video_id` viwes are rendered so
// send a `check_transcript` request to get transctip status
// This is needed because we need to update the transcrript status
// when basic tabs renders. We trigger `basicTabFieldChanged` event
// in `video_url` field but that event triggers before event is
// actually binded
this.handleFieldChanged();
},
/**
@@ -72,7 +88,6 @@ function($, Backbone, _, Utils, MetadataView, MetadataCollection) {
var result = [],
getField = Utils.getField,
component_locator = this.$el.closest('[data-locator]').data('locator'),
subs = getField(metadataCollection, 'sub'),
values = {},
videoUrl, metadata, modifiedValues;
@@ -86,37 +101,6 @@ function($, Backbone, _, Utils, MetadataView, MetadataCollection) {
modifiedValues = metadataView.getModifiedMetadataValues();
var isSubsModified = (function(values) {
var isSubsChanged = subs.hasChanged('value');
return Boolean(
isSubsChanged &&
(
// If the user changes the field, `values.sub` contains
// string value;
// If the user clicks `clear` button, the field contains
// null value.
// Otherwise, undefined.
_.isString(values.sub) || _.isNull(subs.getValue())
)
);
}(modifiedValues));
// When we change value of `sub` field in the `Advanced`,
// we update data on backend. That provides possibility to remove
// transcripts.
if (isSubsModified) {
metadata = $.extend(true, {}, modifiedValues);
// Save module state
Utils.command('save', component_locator, null, {
metadata: metadata,
current_subs: _.pluck(
Utils.getVideoList(videoUrl.getDisplayValue()),
'video'
)
});
}
// Get values from `Advanced` tab fields (`html5_sources`,
// `youtube_id_1_0`) that should be synchronized.
var html5Sources = getField(metadataCollection, 'html5_sources').getDisplayValue();
@@ -148,17 +132,6 @@ function($, Backbone, _, Utils, MetadataView, MetadataCollection) {
// Synchronize other fields that has the same `field_name` property.
Utils.syncCollections(metadataCollection, this.collection);
if (isSubsModified) {
// When `sub` field is changed, clean Storage to avoid overwriting.
Utils.Storage.remove('sub');
// Trigger `change` event manually if `video_url` model
// isn't changed.
if (!videoUrl.hasChanged()) {
videoUrl.trigger('change');
}
}
},
/**
@@ -174,8 +147,6 @@ function($, Backbone, _, Utils, MetadataView, MetadataCollection) {
*/
syncAdvancedTab: function(metadataCollection, metadataView) {
var getField = Utils.getField,
subsValue = Utils.Storage.get('sub'),
subs = getField(metadataCollection, 'sub'),
html5Sources, youtube, videoUrlValue, result;
// if metadataCollection is not passed, just exit.
@@ -231,18 +202,45 @@ function($, Backbone, _, Utils, MetadataView, MetadataCollection) {
youtube.setValue(result);
}
// If Utils.Storage contain some subtitles, update them.
if (_.isString(subsValue)) {
subs.setValue(subsValue);
// After updating should be removed, because it might overwrite
// subtitles added by user manually.
Utils.Storage.remove('sub');
}
// Synchronize other fields that has the same `field_name` property.
Utils.syncCollections(this.collection, metadataCollection);
}
},
handleUpdateEdxVideoId: function(edxVideoId) {
var edxVideoIdField = Utils.getField(this.collection, 'edx_video_id');
edxVideoIdField.setValue(edxVideoId);
},
getLocator: function() {
return this.$el.closest('[data-locator]').data('locator');
},
/**
* Event handler for `transcripts:basicTabFieldChanged` event.
*/
handleFieldChanged: function() {
var views = this.settingsView.views,
videoURLSView = views.video_url,
edxVideoIdView = views.edx_video_id,
edxVideoIdData = edxVideoIdView.getData(),
videoURLsData = videoURLSView.getVideoObjectsList(),
data = videoURLsData.concat(edxVideoIdData),
locator = this.getLocator();
Utils.command('check', locator, data)
.done(function(response) {
videoURLSView.updateOnCheckTranscriptSuccess(videoURLsData, response);
})
.fail(function(response) {
videoURLSView.showServerError(response);
});
},
destroy: function() {
this.stopListening();
this.undelegateEvents();
this.$el.empty();
}
});
return Editor;

View File

@@ -3,7 +3,7 @@ define(
'jquery', 'backbone', 'underscore',
'js/views/video/transcripts/utils'
],
function($, Backbone, _, Utils) {
function($, Backbone, _, TranscriptUtils) {
var FileUploader = Backbone.View.extend({
invisibleClass: 'is-invisible',
@@ -29,8 +29,7 @@ function($, Backbone, _, Utils) {
render: function() {
var tpl = $(this.uploadTpl).text(),
tplContainer = this.$el.find('.transcripts-file-uploader'),
videoList = this.options.videoListObject.getVideoObjectsList();
tplContainer = this.$el.find('.transcripts-file-uploader');
if (tplContainer.length) {
if (!tpl) {
@@ -42,8 +41,7 @@ function($, Backbone, _, Utils) {
tplContainer.html(this.template({
ext: this.validFileExtensions,
component_locator: this.options.component_locator,
video_list: videoList
component_locator: this.options.component_locator
}));
this.$form = this.$el.find('.file-chooser');
@@ -59,6 +57,10 @@ function($, Backbone, _, Utils) {
*
*/
upload: function() {
var data = {
'edx_video_id': TranscriptUtils.Storage.get('edx_video_id') || ''
};
if (!this.file) {
return;
}
@@ -66,7 +68,8 @@ function($, Backbone, _, Utils) {
this.$form.ajaxSubmit({
beforeSend: this.xhrResetProgressBar,
uploadProgress: this.xhrProgressHandler,
complete: this.xhrCompleteHandler
complete: this.xhrCompleteHandler,
data: data
});
},
@@ -186,14 +189,14 @@ function($, Backbone, _, Utils) {
xhrCompleteHandler: function(xhr) {
var resp = JSON.parse(xhr.responseText),
err = resp.status || gettext('Error: Uploading failed.'),
sub = resp.subs;
edxVideoId = resp.edx_video_id;
this.$progress
.addClass(this.invisibleClass);
if (xhr.status === 200) {
this.options.messenger.render('uploaded', resp);
Utils.Storage.set('sub', sub);
Backbone.trigger('transcripts:basicTabUpdateEdxVideoId', edxVideoId);
} else {
this.options.messenger.showError(err);
}

View File

@@ -40,8 +40,7 @@ function($, Backbone, _, Utils, FileUploader, gettext) {
this.fileUploader = new FileUploader({
el: this.$el,
messenger: this,
component_locator: this.component_locator,
videoListObject: this.options.parent
component_locator: this.component_locator
});
},
@@ -218,10 +217,10 @@ function($, Backbone, _, Utils, FileUploader, gettext) {
xhr = Utils.command(action, component_locator, videoList, extraParam)
.done(function(resp) {
var sub = resp.subs;
var edxVideoID = resp.edx_video_id;
self.render('found', resp);
Utils.Storage.set('sub', sub);
Backbone.trigger('transcripts:basicTabUpdateEdxVideoId', edxVideoID);
})
.fail(function(resp) {
var message = resp.status || errorMessage;

View File

@@ -43,7 +43,7 @@ function($, Backbone, _, AbstractEditor, Utils, MessageManager) {
.apply(this, arguments);
this.$el.on(
'input', 'input',
'input', '.videolist-settings-item input',
_.debounce(_.bind(this.inputHandler, this), this.inputDelay)
);
@@ -56,57 +56,45 @@ function($, Backbone, _, AbstractEditor, Utils, MessageManager) {
AbstractEditor.prototype.render
.apply(this, arguments);
var self = this,
component_locator = this.$el.closest('[data-locator]')
.data('locator'),
videoList = this.getVideoObjectsList(),
showServerError = function(response) {
var errorMessage = response.status ||
gettext('Error: Connection with server failed.');
self.messenger
.render('not_found')
.showError(
errorMessage,
true // hide buttons
);
};
this.$extraVideosBar = this.$el.find('.videolist-extra-videos');
if (videoList.length === 0) {
this.messenger
.render('not_found')
.showError(
gettext('No sources'),
true // hide buttons
);
// Check current state of Timed Transcripts.
Backbone.trigger('transcripts:basicTabFieldChanged');
},
return void(0);
updateOnCheckTranscriptSuccess: function(videoList, response) {
var params = response,
len = videoList.length,
mode = (len === 1) ? videoList[0].mode : false;
// If there are more than 1 video or just html5 source is
// passed, video sources box should expand
if (len > 1 || mode === 'html5') {
this.openExtraVideosBar();
} else {
this.closeExtraVideosBar();
}
// Check current state of Timed Transcripts.
Utils.command('check', component_locator, videoList)
.done(function(resp) {
var params = resp,
len = videoList.length,
mode = (len === 1) ? videoList[0].mode : false;
this.messenger.render(response.command, params);
this.checkIsUniqVideoTypes();
},
// If there are more than 1 video or just html5 source is
// passed, video sources box should expand
if (len > 1 || mode === 'html5') {
self.openExtraVideosBar();
} else {
self.closeExtraVideosBar();
}
/**
* Updates the message with error.
*/
showServerError: function(response) {
var errorMessage = gettext('Error: Connection with server failed.');
self.messenger.render(resp.command, params);
self.checkIsUniqVideoTypes();
// Synchronize transcripts field in the `Advanced` tab.
Utils.Storage.set('sub', resp.subs);
})
.fail(showServerError);
if (response.responseJSON !== undefined) {
errorMessage = response.responseJSON.status;
}
this.messenger
.render('not_found')
.showError(
errorMessage,
true // hide buttons
);
},
/**

View File

@@ -1,10 +1,9 @@
define(
[
'jquery', 'underscore',
'js/views/abstract_editor', 'js/models/uploads', 'js/views/uploads'
'jquery', 'underscore', 'edx-ui-toolkit/js/utils/html-utils', 'js/views/video/transcripts/utils',
'js/views/abstract_editor', 'common/js/components/utils/view_utils', 'js/models/uploads', 'js/views/uploads'
],
function($, _, AbstractEditor, FileUpload, UploadDialog) {
function($, _, HtmlUtils, TranscriptUtils, AbstractEditor, ViewUtils, FileUpload, UploadDialog) {
'use strict';
var VideoUploadDialog = UploadDialog.extend({
@@ -19,7 +18,6 @@ function($, _, AbstractEditor, FileUpload, UploadDialog) {
var Translations = AbstractEditor.extend({
events: {
'click .setting-clear': 'clear',
'click .create-setting': 'addEntry',
'click .remove-setting': 'removeEntry',
'click .upload-setting': 'upload',
@@ -29,15 +27,29 @@ function($, _, AbstractEditor, FileUpload, UploadDialog) {
templateName: 'metadata-translations-entry',
templateItemName: 'metadata-translations-item',
validFileFormats: ['srt'],
initialize: function() {
var templateName = _.result(this, 'templateItemName'),
tpl = document.getElementById(templateName).text;
tpl = document.getElementById(templateName).text,
languageMap = {};
if (!tpl) {
console.error("Couldn't load template for item: " + templateName);
}
this.templateItem = _.template(tpl);
// Initialize language map. This maps original language to the newly selected language.
// Keys in this map represent language codes present on server, they don't change when
// user selects a language while values represent currently selected language.
// Initially, the map will look like {'ar': 'ar', 'zh': 'zh'} i.e {'original_lang': 'original_lang'}
// and corresponding dropdowns will show language names Arabic and Chinese. If user changes
// Chinese to Russian then map will become {'ar': 'ar', 'zh': 'ru'} i.e {'original_lang': 'new_lang'}
_.each(this.model.getDisplayValue(), function(value, lang) {
languageMap[lang] = lang;
});
TranscriptUtils.Storage.set('languageMap', languageMap);
AbstractEditor.prototype.initialize.apply(this, arguments);
},
@@ -111,14 +123,16 @@ function($, _, AbstractEditor, FileUpload, UploadDialog) {
setValueInEditor: function(values) {
var self = this,
frag = document.createDocumentFragment(),
dropdown = self.getDropdown(values);
dropdown = self.getDropdown(values),
languageMap = TranscriptUtils.Storage.get('languageMap');
_.each(values, function(value, key) {
_.each(values, function(value, newLang) {
var html = $(self.templateItem({
lang: key,
newLang: newLang,
originalLang: _.findKey(languageMap, function(lang) { return lang === newLang; }) || '',
value: value,
url: self.model.get('urlRoot') + '/' + key
})).prepend(dropdown.clone().val(key))[0];
url: self.model.get('urlRoot')
})).prepend(dropdown.clone().val(newLang))[0];
frag.appendChild(html);
});
@@ -130,63 +144,166 @@ function($, _, AbstractEditor, FileUpload, UploadDialog) {
event.preventDefault();
// We don't call updateModel here since it's bound to the
// change event
var dict = $.extend(true, {}, this.model.get('value'));
dict[''] = '';
this.setValueInEditor(dict);
this.setValueInEditor(this.getAllLanguageDropdownElementsData(true));
this.$el.find('.create-setting').addClass('is-disabled').attr('aria-disabled', true);
},
removeEntry: function(event) {
var self = this,
$currentListItemEl = $(event.currentTarget).parent(),
originalLang = $currentListItemEl.data('original-lang'),
selectedLang = $currentListItemEl.find('select option:selected').val(),
languageMap = TranscriptUtils.Storage.get('languageMap'),
edxVideoIdField = TranscriptUtils.getField(self.model.collection, 'edx_video_id');
event.preventDefault();
var entry = $(event.currentTarget).data('lang');
this.setValueInEditor(_.omit(this.model.get('value'), entry));
this.updateModel();
/*
There is a scenario when a user adds an empty video translation item and
removes it. In such cases, omitting will have no harm on the model
values or languages map.
*/
if (originalLang) {
ViewUtils.confirmThenRunOperation(
gettext('Are you sure you want to remove this transcript?'),
gettext('If you remove this transcript, the transcript will not be available for this component.'),
gettext('Remove Transcript'),
function() {
ViewUtils.runOperationShowingMessage(
gettext('Removing'),
function() {
return $.ajax({
url: self.model.get('urlRoot'),
type: 'DELETE',
data: JSON.stringify({lang: originalLang, edx_video_id: edxVideoIdField.getValue()})
}).done(function() {
self.setValueInEditor(self.getAllLanguageDropdownElementsData(false, selectedLang));
TranscriptUtils.Storage.set('languageMap', _.omit(languageMap, originalLang));
});
}
);
}
);
} else {
this.setValueInEditor(this.getAllLanguageDropdownElementsData(false, selectedLang));
}
this.$el.find('.create-setting').removeClass('is-disabled').attr('aria-disabled', false);
},
upload: function(event) {
event.preventDefault();
var self = this,
$target = $(event.currentTarget),
lang = $target.data('lang'),
model = new FileUpload({
title: gettext('Upload translation'),
fileFormats: ['srt']
}),
view = new VideoUploadDialog({
model: model,
url: self.model.get('urlRoot') + '/' + lang,
parentElement: $target.closest('.xblock-editor'),
onSuccess: function(response) {
if (!response.filename) { return; }
$listItem = $target.parents('li.list-settings-item'),
originalLang = $listItem.data('original-lang'),
newLang = $listItem.find(':selected').val(),
edxVideoIdField = TranscriptUtils.getField(self.model.collection, 'edx_video_id'),
fileUploadModel,
uploadData,
videoUploadDialog;
var dict = $.extend(true, {}, self.model.get('value'));
event.preventDefault();
dict[lang] = response.filename;
self.model.setValue(dict);
}
});
// That's the case when an author is
// uploading a new transcript.
if (!originalLang) {
originalLang = newLang;
}
view.show();
// Transcript data payload
uploadData = {
edx_video_id: edxVideoIdField.getValue(),
language_code: originalLang,
new_language_code: newLang
};
fileUploadModel = new FileUpload({
title: gettext('Upload translation'),
fileFormats: this.validFileFormats
});
videoUploadDialog = new VideoUploadDialog({
model: fileUploadModel,
url: this.model.get('urlRoot'),
parentElement: $target.closest('.xblock-editor'),
uploadData: uploadData,
onSuccess: function(response) {
var languageMap = TranscriptUtils.Storage.get('languageMap'),
newLangObject = {};
// new language entry to be added to languageMap
newLangObject[newLang] = newLang;
// Update edx-video-id
edxVideoIdField.setValue(response.edx_video_id);
// Update language map by omitting original lang and adding new lang
// if languageMap is empty then newLang will be added
// if an original lang is replaced with new lang then omit the original lang and the add new lang
languageMap = _.extend(_.omit(languageMap, originalLang), newLangObject);
TranscriptUtils.Storage.set('languageMap', languageMap);
// re-render the whole view
self.setValueInEditor(self.getAllLanguageDropdownElementsData());
}
});
videoUploadDialog.show();
},
enableAdd: function() {
this.$el.find('.create-setting').removeClass('is-disabled').attr('aria-disabled', false);
},
clear: function() {
AbstractEditor.prototype.clear.apply(this, arguments);
if (_.isNull(this.model.getValue())) {
this.$el.find('.create-setting').removeClass('is-disabled').attr('aria-disabled', false);
onChangeHandler: function(event) {
var $target = $(event.currentTarget),
$listItem = $target.parents('li.list-settings-item'),
originalLang = $listItem.data('original-lang'),
newLang = $listItem.find('select option:selected').val(),
languageMap = TranscriptUtils.Storage.get('languageMap');
// To protect against any new/unsaved language code in the map.
if (originalLang in languageMap) {
languageMap[originalLang] = newLang;
TranscriptUtils.Storage.set('languageMap', languageMap);
// an existing saved lang is changed, no need to re-render the whole view
return;
}
this.enableAdd();
this.setValueInEditor(this.getAllLanguageDropdownElementsData());
},
onChangeHandler: function(event) {
this.showClearButton();
this.enableAdd();
this.updateModel();
/**
* Constructs data extracted from each dropdown. This will be used to re-render the whole view.
*/
getAllLanguageDropdownElementsData: function(isNew, omittedLanguage) {
var data = {},
languageDropdownElements = this.$el.find('select'),
languageMap = TranscriptUtils.Storage.get('languageMap');
// data object will mirror the languageMap. `data` will contain lang to lang map as explained below
// {originalLang: originalLang}; original lang not changed
// {newLang: originalLang}; original lang changed to a new lang
// {selectedLang: ''}; new lang to be added, no entry in languageMap
_.each(languageDropdownElements, function(languageDropdown) {
var language = $(languageDropdown).find(':selected').val();
data[language] = _.findKey(languageMap, function(lang) { return lang === language; }) || '';
});
// This is needed to render an empty item that
// will be further used to upload a transcript.
if (isNew) {
data[''] = '';
}
// This Omits a language from the dropdown's data. It is
// needed when an item is going to be removed.
if (typeof(omittedLanguage) !== 'undefined') {
data = _.omit(data, omittedLanguage);
}
return data;
}
});

View File

@@ -22,13 +22,11 @@ var options = {
// Otherwise Istanbul which is used for coverage tracking will cause tests to not run.
sourceFiles: [
{pattern: 'cms/**/!(*spec|djangojs).js'},
{pattern: 'coffee/src/**/!(*spec).js'},
{pattern: 'js/**/!(*spec|djangojs).js'}
],
specFiles: [
{pattern: 'cms/**/*spec.js'},
{pattern: 'coffee/spec/**/*spec.js'},
{pattern: 'js/certificates/spec/**/*spec.js'},
{pattern: 'js/spec/**/*spec.js'}
],

View File

@@ -21,18 +21,15 @@ var options = {
// Make sure the patterns in sourceFiles and specFiles do not match the same file.
// Otherwise Istanbul which is used for coverage tracking will cause tests to not run.
sourceFiles: [
{pattern: 'coffee/src/**/!(*spec).js'},
{pattern: 'cms/js/**/!(*spec|djangojs).js'},
{pattern: 'js/**/!(*spec|djangojs).js'}
],
specFiles: [
{pattern: 'coffee/spec/**/*spec.js'},
{pattern: 'js/spec/**/*spec.js'}
],
fixtureFiles: [
{pattern: 'coffee/fixtures/**/*.*'},
{pattern: 'templates/**/*.*'}
],

View File

@@ -78,3 +78,8 @@
width: 100%;
background: $black;
}
// Mimic Bootstrap functionality which makes the background body unscrollable while the modal is open.
body.modal-open {
overflow: hidden;
}

View File

@@ -160,6 +160,9 @@
+ .tip {
color: $gray-d1;
}
+ .tip-error {
color: $red;
}
}
}
@@ -187,6 +190,10 @@
margin-top: ($baseline/4);
color: $gray-d1;
}
.tip-error {
color: $red;
}
}
.field-group {

View File

@@ -6,7 +6,6 @@
from django.utils.translation import ugettext as _
from openedx.core.djangolib.markup import HTML, Text
from openedx.core.djangolib.js_utils import js_escaped_string, dump_js_escaped_json
from cms.djangoapps.contentstore.config.waffle import waffle, ENABLE_ASSETS_SEARCH
%>
<%block name="title">${_("Files & Uploads")}</%block>
<%block name="bodyclass">is-signedin course uploads view-uploads</%block>
@@ -14,8 +13,10 @@
<%namespace name='static' file='static_content.html'/>
<%block name="header_extras">
<link rel="stylesheet" type="text/css" href="${static.url('common/css/vendor/common.min.css')}" />
<link rel="stylesheet" type="text/css" href="${static.url('common/css/vendor/assets.min.css')}" />
% if not settings.STUDIO_FRONTEND_CONTAINER_URL:
<link rel="stylesheet" type="text/css" href="${static.url('common/css/vendor/common.min.css')}" />
<link rel="stylesheet" type="text/css" href="${static.url('common/css/vendor/assets.min.css')}" />
% endif
</%block>
<%block name="content">
@@ -49,9 +50,6 @@
},
"upload_settings": {
"max_file_size_in_mbs": ${max_file_size_in_mbs|n, dump_js_escaped_json}
},
"search_settings": {
"enabled": ${waffle().is_enabled(ENABLE_ASSETS_SEARCH) | n, dump_js_escaped_json}
}
}
</%static:studiofrontend>

View File

@@ -15,6 +15,7 @@ from openedx.core.djangolib.js_utils import (
dump_js_escaped_json, js_escaped_string
)
from openedx.core.djangolib.markup import HTML
from openedx.core.release import RELEASE_LINE
%>
<%page expression_filter="h"/>
@@ -24,6 +25,7 @@ from openedx.core.djangolib.markup import HTML
<head dir="${static.dir_rtl()}">
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="openedx-release-line" content="${RELEASE_LINE}" />
<title>
<%block name="title"></%block> |
% if context_course:
@@ -108,7 +110,7 @@ from openedx.core.djangolib.markup import HTML
</div>
</main>
% if user.is_authenticated():
% if user.is_authenticated:
<%include file="widgets/sock.html" args="online_help_token=online_help_token" />
% endif
<%include file="widgets/footer.html" />
@@ -140,7 +142,7 @@ from openedx.core.djangolib.markup import HTML
}
</script>
% endif
% if user.is_authenticated():
% if user.is_authenticated:
<%static:invoke_page_bundle page_name='js/sock'/>
% endif
<%block name='page_bundle'>

View File

@@ -11,6 +11,7 @@ else:
<%!
from django.utils.translation import ugettext as _
from cms.djangoapps.contentstore.config.waffle import ENABLE_IN_CONTEXT_IMAGE_SELECTION
from contentstore.views.helpers import xblock_studio_url, xblock_type_display_name
from openedx.core.djangolib.js_utils import (
dump_js_escaped_json, js_escaped_string
@@ -33,6 +34,12 @@ from openedx.core.djangolib.markup import HTML, Text
<%static:include path="common/templates/image-modal.underscore" />
</script>
<link rel="stylesheet" type="text/css" href="${static.url('js/vendor/timepicker/jquery.timepicker.css')}" />
% if ENABLE_IN_CONTEXT_IMAGE_SELECTION.is_enabled(xblock_locator.course_key):
% if not settings.STUDIO_FRONTEND_CONTAINER_URL:
<link rel="stylesheet" type="text/css" href="${static.url('common/css/vendor/common.min.css')}" />
<link rel="stylesheet" type="text/css" href="${static.url('common/css/vendor/editImageModal.min.css')}" />
% endif
% endif
</%block>
<%block name="requirejs">
@@ -52,6 +59,12 @@ from openedx.core.djangolib.markup import HTML, Text
<%block name="content">
% if ENABLE_IN_CONTEXT_IMAGE_SELECTION.is_enabled(xblock_locator.course_key):
<script type="text/javascript">
window.STUDIO_FRONTEND_IN_CONTEXT_IMAGE_SELECTION = true;
</script>
% endif
<div class="wrapper-mast wrapper">
<header class="mast has-actions has-navigation has-subtitle">
@@ -159,6 +172,27 @@ from openedx.core.djangolib.markup import HTML, Text
% endif
</aside>
</section>
% if ENABLE_IN_CONTEXT_IMAGE_SELECTION.is_enabled(xblock_locator.course_key):
<div id="edit-image-modal">
<%static:studiofrontend entry="editImageModal">
{
"course": {
"id": "${context_course.id | n, js_escaped_string}",
"name": "${context_course.display_name_with_default | n, js_escaped_string}",
"url_name": "${context_course.location.name | n, js_escaped_string}",
"org": "${context_course.location.org | n, js_escaped_string}",
"num": "${context_course.location.course | n, js_escaped_string}",
"display_course_number": "${context_course.display_coursenumber | n, js_escaped_string}",
"revision": "${context_course.location.revision | n, js_escaped_string}"
},
"help_tokens": {
"image_accessibility": "${get_online_help_info('image_accessibility')['doc_url'] | n, js_escaped_string}"
},
"lang": "${language_code | n, js_escaped_string}"
}
</%static:studiofrontend>
</div>
% endif
</div>
</div>
</%block>

View File

@@ -14,9 +14,7 @@
<div class="video-head-col video-col name-col"><%- gettext("Name") %></div>
<div class="video-head-col video-col date-col"><%- gettext("Date Added") %></div>
<div class="video-head-col video-col video-id-col"><%- gettext("Video ID") %></div>
<% if (isVideoTranscriptEnabled) { %>
<div class="video-head-col video-col transcripts-col"><%- gettext("Transcripts") %></div>
<% } %>
<div class="video-head-col video-col status-col"><%- gettext("Status") %></div>
<div class="video-head-col video-col actions-col"><%- gettext("Action") %></div>
</div>

View File

@@ -5,9 +5,7 @@
<div class="video-col name-col"><%- client_video_id %></div>
<div class="video-col date-col"><%- created %></div>
<div class="video-col video-id-col"><%- edx_video_id %></div>
<% if (isVideoTranscriptEnabled) { %>
<div class="video-col transcripts-col"></div>
<% } %>
<div class="video-col status-col"><%- status %></div>
<div class="video-col actions-col">
<ul class="actions-list">

View File

@@ -1,14 +1,11 @@
<div class="wrapper-comp-setting metadata-video-translations">
<label class="label setting-label"><%= model.get('display_name')%></label>
<input class="upload-transcript-input is-hidden" type="file" name="file" accept=".srt"/>
<div class="wrapper-translations-settings">
<ol class="list-settings"></ol>
<a href="#" class="create-action create-setting">
<span class="icon fa fa-plus" aria-hidden="true"></span><%= gettext("Add") %> <span class="sr"><%= model.get('display_name')%></span>
</a>
</div>
<button class="action setting-clear inactive" type="button" name="setting-clear" value="<%= gettext("Clear") %>" data-tooltip="<%= gettext("Clear") %>">
<span class="icon fa fa-undo" aria-hidden="true"></span>
<span class="sr">"<%= gettext("Clear Value") %>"</span>
</button>
</div>
<span class="tip setting-help"><%= model.get('help') %></span>

View File

@@ -1,12 +1,13 @@
<li class="list-settings-item">
<a href="#" class="remove-action remove-setting" data-lang="<%= lang %>" data-value="<%= value %>"><span class="icon fa fa-times-circle" aria-hidden="true"></span><span class="sr"><%= gettext("Remove") %></span></a>
<li class="list-settings-item" data-original-lang="<%= originalLang %>">
<a href="#" class="remove-action remove-setting" data-value="<%= value %>"><span class="icon fa fa-times-circle" aria-hidden="true"></span><span class="sr"><%= gettext("Remove") %></span></a>
<input type="hidden" class="input" value="<%= value %>">
<div class="list-settings-buttons"><% if (lang) {
%><a href="#" class="upload-action upload-setting" data-lang="<%= lang %>" data-value="<%= value %>"><%= value ? gettext("Replace") : gettext("Upload") %>
<div class="list-settings-buttons"><% if (newLang) {
%><a href="#" class="upload-action upload-setting" data-value="<%= value %>"><%= value ? gettext("Replace") : gettext("Upload") %>
</a><%
} %><% if (value) {
%><a href="<%= url %>?filename=<%= value %>" class="download-action download-setting"><%= gettext("Download") %>
%><a href="<%= url %>?language_code=<%= originalLang %>" class="download-action download-setting"><%= gettext("Download") %>
</a><%
}
%><div>
%>
</div>
</li>

View File

@@ -6,5 +6,4 @@
<input type="file" class="file-input" name="transcript-file"
accept="<%= _.map(ext, function(val){ return '.' + val; }).join(', ') %>">
<input type="hidden" name="locator" value="<%= component_locator %>">
<input type="hidden" name="video_list" value='<%= JSON.stringify(video_list) %>'>
</form>

View File

@@ -10,7 +10,7 @@
<button class="action setting-upload" type="button" name="setting-upload" value="<%= gettext("Upload New Transcript") %>" data-tooltip="<%= gettext("Upload New .srt Transcript") %>">
<span><%= gettext("Upload New Transcript") %></span>
</button>
<a class="action setting-download" href="/transcripts/download?locator=<%= component_locator %>&subs_id=<%= subs_id %>" data-tooltip="<%= gettext("Download Transcript for Editing") %>">
<a class="action setting-download" href="/transcripts/download?locator=<%= component_locator %>" data-tooltip="<%= gettext("Download Transcript for Editing") %>">
<span><%= gettext("Download Transcript for Editing") %></span>
</a>
</div>

View File

@@ -10,7 +10,7 @@
<button class="action setting-upload" type="button" name="setting-upload" value="<%= gettext("Upload New Transcript") %>" data-tooltip="<%= gettext("Upload New Transcript") %>">
<span><%= gettext("Upload New Transcript") %></span>
</button>
<a class="action setting-download" href="/transcripts/download?locator=<%= component_locator %>&subs_id=<%= subs_id %>" data-tooltip="<%= gettext("Download Transcript for Editing") %>">
<a class="action setting-download" href="/transcripts/download?locator=<%= component_locator %>" data-tooltip="<%= gettext("Download Transcript for Editing") %>">
<span><%= gettext("Download Transcript for Editing") %></span>
</a>
</div>

View File

@@ -50,6 +50,7 @@ from django.core.urlresolvers import reverse
<li class="field text required" id="field-password">
<label for="password">${_("Password")}</label>
<input id="password" type="password" name="password" />
<span id="password_error" class="tip tip-error hidden" role="alert"></span>
</li>
<li class="field-group">

View File

@@ -190,7 +190,7 @@
<nav class="user-language-selector" aria-label="${_('Language preference')}">
<form action="/i18n/setlang/" method="post" class="settings-language-form" id="language-settings-form">
<input type="hidden" id="csrf_token" name="csrfmiddlewaretoken" value="${csrf_token}">
% if user.is_authenticated():
% if user.is_authenticated:
<input title="preference api" type="hidden" id="preference-api-url" class="url-endpoint" value="${reverse('preferences_api', kwargs={'username': user.username})}" data-user-is-authenticated="true">
% else:
<input title="session update url" type="hidden" id="update-session-url" class="url-endpoint" value="${reverse('session_language')}" data-user-is-authenticated="false">
@@ -210,7 +210,7 @@
</nav>
% endif
% endif
% if user.is_authenticated():
% if user.is_authenticated:
<nav class="nav-account nav-is-signedin nav-dd ui-right" aria-label="${_('Account')}">
<h2 class="sr-only">${_("Account Navigation")}</h2>
<ol>

View File

@@ -4,7 +4,7 @@
% if settings.CMS_SEGMENT_KEY:
<!-- begin segment footer -->
<script type="text/javascript">
% if user.is_authenticated():
% if user.is_authenticated:
// We can't use JQuery's on load method because it
// screws up RequireJS' JQuery initialization.
var onLoadCallback = function() {

Some files were not shown because too many files have changed in this diff Show More