Merge branch 'master' into jkarni/fix/descriptorsystemruntime
This commit is contained in:
@@ -3,7 +3,7 @@ django admin pages for courseware model
|
||||
'''
|
||||
|
||||
from external_auth.models import *
|
||||
from django.contrib import admin
|
||||
from ratelimitbackend import admin
|
||||
|
||||
|
||||
class ExternalAuthMapAdmin(admin.ModelAdmin):
|
||||
|
||||
@@ -9,12 +9,15 @@ from urlparse import parse_qs
|
||||
|
||||
from django.conf import settings
|
||||
from django.test import TestCase, LiveServerTestCase
|
||||
from django.core.cache import cache
|
||||
from django.test.utils import override_settings
|
||||
# from django.contrib.auth.models import User
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.test.client import RequestFactory
|
||||
from unittest import skipUnless
|
||||
|
||||
from student.tests.factories import UserFactory
|
||||
from external_auth.views import provider_login
|
||||
|
||||
|
||||
class MyFetcher(HTTPFetcher):
|
||||
"""A fetcher that uses server-internal calls for performing HTTP
|
||||
@@ -199,6 +202,49 @@ class OpenIdProviderTest(TestCase):
|
||||
""" Test for 403 error code when the url"""
|
||||
self.attempt_login(403, return_to="http://apps.cs50.edx.or")
|
||||
|
||||
def _send_bad_redirection_login(self):
|
||||
"""
|
||||
Attempt to log in to the provider with setup parameters
|
||||
|
||||
Intentionally fail the login to force a redirect
|
||||
"""
|
||||
user = UserFactory()
|
||||
|
||||
factory = RequestFactory()
|
||||
post_params = {'email': user.email, 'password': 'password'}
|
||||
fake_url = 'fake url'
|
||||
request = factory.post(reverse('openid-provider-login'), post_params)
|
||||
openid_setup = {
|
||||
'request': factory.request(),
|
||||
'url': fake_url
|
||||
}
|
||||
request.session = {
|
||||
'openid_setup': openid_setup
|
||||
}
|
||||
response = provider_login(request)
|
||||
return response
|
||||
|
||||
@skipUnless(settings.MITX_FEATURES.get('AUTH_USE_OPENID') or
|
||||
settings.MITX_FEATURES.get('AUTH_USE_OPENID_PROVIDER'), True)
|
||||
def test_login_openid_handle_redirection(self):
|
||||
""" Test to see that we can handle login redirection properly"""
|
||||
response = self._send_bad_redirection_login()
|
||||
self.assertEquals(response.status_code, 302)
|
||||
|
||||
@skipUnless(settings.MITX_FEATURES.get('AUTH_USE_OPENID') or
|
||||
settings.MITX_FEATURES.get('AUTH_USE_OPENID_PROVIDER'), True)
|
||||
def test_login_openid_handle_redirection_ratelimited(self):
|
||||
# try logging in 30 times, the default limit in the number of failed
|
||||
# log in attempts before the rate gets limited
|
||||
for _ in xrange(30):
|
||||
self._send_bad_redirection_login()
|
||||
|
||||
response = self._send_bad_redirection_login()
|
||||
# verify that we are not returning the default 403
|
||||
self.assertEquals(response.status_code, 302)
|
||||
# clear the ratelimit cache so that we don't fail other logins
|
||||
cache.clear()
|
||||
|
||||
|
||||
class OpenIdProviderLiveServerTest(LiveServerTestCase):
|
||||
"""
|
||||
|
||||
@@ -39,6 +39,7 @@ from openid.consumer.consumer import SUCCESS
|
||||
from openid.server.server import Server, ProtocolError, UntrustedReturnURL
|
||||
from openid.server.trustroot import TrustRoot
|
||||
from openid.extensions import ax, sreg
|
||||
from ratelimitbackend.exceptions import RateLimitException
|
||||
|
||||
import student.views as student_views
|
||||
# Required for Pearson
|
||||
@@ -191,7 +192,7 @@ def _external_login_or_signup(request,
|
||||
user.backend = auth_backend
|
||||
AUDIT_LOG.info('Linked user "%s" logged in via Shibboleth', user.email)
|
||||
else:
|
||||
user = authenticate(username=uname, password=eamap.internal_password)
|
||||
user = authenticate(username=uname, password=eamap.internal_password, request=request)
|
||||
if user is None:
|
||||
# we want to log the failure, but don't want to log the password attempted:
|
||||
AUDIT_LOG.warning('External Auth Login failed for "%s"', uname)
|
||||
@@ -718,7 +719,12 @@ def provider_login(request):
|
||||
# Failure is again redirected to the login dialog.
|
||||
username = user.username
|
||||
password = request.POST.get('password', None)
|
||||
user = authenticate(username=username, password=password)
|
||||
try:
|
||||
user = authenticate(username=username, password=password, request=request)
|
||||
except RateLimitException:
|
||||
AUDIT_LOG.warning('OpenID - Too many failed login attempts.')
|
||||
return HttpResponseRedirect(openid_request_url)
|
||||
|
||||
if user is None:
|
||||
request.session['openid_error'] = True
|
||||
msg = "OpenID login failed - password for %s is invalid"
|
||||
|
||||
@@ -4,7 +4,7 @@ django admin pages for courseware model
|
||||
|
||||
from student.models import UserProfile, UserTestGroup, CourseEnrollmentAllowed
|
||||
from student.models import CourseEnrollment, Registration, PendingNameChange
|
||||
from django.contrib import admin
|
||||
from ratelimitbackend import admin
|
||||
|
||||
admin.site.register(UserProfile)
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ class Migration(SchemaMigration):
|
||||
('eligibility_appointment_date_first', self.gf('django.db.models.fields.DateField')(db_index=True)),
|
||||
('eligibility_appointment_date_last', self.gf('django.db.models.fields.DateField')(db_index=True)),
|
||||
('accommodation_code', self.gf('django.db.models.fields.CharField')(max_length=64, blank=True)),
|
||||
('accommodation_request', self.gf('django.db.models.fields.CharField')(db_index=True, max_length=1024, blank=True)),
|
||||
('accommodation_request', self.gf('django.db.models.fields.CharField')(db_index=False, max_length=1024, blank=True)),
|
||||
('uploaded_at', self.gf('django.db.models.fields.DateTimeField')(null=True, db_index=True)),
|
||||
('processed_at', self.gf('django.db.models.fields.DateTimeField')(null=True, db_index=True)),
|
||||
('upload_status', self.gf('django.db.models.fields.CharField')(db_index=True, max_length=20, blank=True)),
|
||||
@@ -163,7 +163,7 @@ class Migration(SchemaMigration):
|
||||
'student.testcenterregistration': {
|
||||
'Meta': {'object_name': 'TestCenterRegistration'},
|
||||
'accommodation_code': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}),
|
||||
'accommodation_request': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '1024', 'blank': 'True'}),
|
||||
'accommodation_request': ('django.db.models.fields.CharField', [], {'db_index': 'False', 'max_length': '1024', 'blank': 'True'}),
|
||||
'authorization_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}),
|
||||
'client_authorization_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '20', 'db_index': 'True'}),
|
||||
'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
|
||||
|
||||
@@ -93,7 +93,7 @@ class Migration(SchemaMigration):
|
||||
'student.testcenterregistration': {
|
||||
'Meta': {'object_name': 'TestCenterRegistration'},
|
||||
'accommodation_code': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}),
|
||||
'accommodation_request': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '1024', 'blank': 'True'}),
|
||||
'accommodation_request': ('django.db.models.fields.CharField', [], {'db_index': 'False', 'max_length': '1024', 'blank': 'True'}),
|
||||
'authorization_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}),
|
||||
'client_authorization_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '20', 'db_index': 'True'}),
|
||||
'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
|
||||
|
||||
@@ -94,7 +94,7 @@ class Migration(SchemaMigration):
|
||||
'student.testcenterregistration': {
|
||||
'Meta': {'object_name': 'TestCenterRegistration'},
|
||||
'accommodation_code': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}),
|
||||
'accommodation_request': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '1024', 'blank': 'True'}),
|
||||
'accommodation_request': ('django.db.models.fields.CharField', [], {'db_index': 'False', 'max_length': '1024', 'blank': 'True'}),
|
||||
'authorization_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}),
|
||||
'client_authorization_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '20', 'db_index': 'True'}),
|
||||
'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import datetime
|
||||
from south.db import db
|
||||
from south.v2 import SchemaMigration
|
||||
from django.db import models
|
||||
from django.db.utils import DatabaseError
|
||||
|
||||
|
||||
class Migration(SchemaMigration):
|
||||
"""
|
||||
Remove an unwanted index from environments that have it.
|
||||
This is a one-way migration in that backwards is a no-op and will not undo the removal.
|
||||
This migration is only relevant to dev environments that existed before a migration rewrite
|
||||
which removed the creation of this index.
|
||||
"""
|
||||
|
||||
def forwards(self, orm):
|
||||
try:
|
||||
# Removing index on 'TestCenterRegistration', fields ['accommodation_request']
|
||||
db.delete_index('student_testcenterregistration', ['accommodation_request'])
|
||||
except DatabaseError:
|
||||
print "-- skipping delete_index of student_testcenterregistration.accommodation_request (index does not exist)"
|
||||
|
||||
|
||||
def backwards(self, orm):
|
||||
pass
|
||||
|
||||
|
||||
models = {
|
||||
'auth.group': {
|
||||
'Meta': {'object_name': 'Group'},
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
|
||||
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
|
||||
},
|
||||
'auth.permission': {
|
||||
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
|
||||
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
|
||||
},
|
||||
'auth.user': {
|
||||
'Meta': {'object_name': 'User'},
|
||||
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
|
||||
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
|
||||
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
|
||||
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
|
||||
},
|
||||
'contenttypes.contenttype': {
|
||||
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
|
||||
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
|
||||
},
|
||||
'student.courseenrollment': {
|
||||
'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'},
|
||||
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
|
||||
},
|
||||
'student.courseenrollmentallowed': {
|
||||
'Meta': {'unique_together': "(('email', 'course_id'),)", 'object_name': 'CourseEnrollmentAllowed'},
|
||||
'auto_enroll': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
|
||||
},
|
||||
'student.pendingemailchange': {
|
||||
'Meta': {'object_name': 'PendingEmailChange'},
|
||||
'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'new_email': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
|
||||
'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'})
|
||||
},
|
||||
'student.pendingnamechange': {
|
||||
'Meta': {'object_name': 'PendingNameChange'},
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'new_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
|
||||
'rationale': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}),
|
||||
'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'})
|
||||
},
|
||||
'student.registration': {
|
||||
'Meta': {'object_name': 'Registration', 'db_table': "'auth_registration'"},
|
||||
'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'unique': 'True'})
|
||||
},
|
||||
'student.testcenterregistration': {
|
||||
'Meta': {'object_name': 'TestCenterRegistration'},
|
||||
'accommodation_code': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}),
|
||||
'accommodation_request': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}),
|
||||
'authorization_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}),
|
||||
'client_authorization_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '20', 'db_index': 'True'}),
|
||||
'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
|
||||
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}),
|
||||
'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'eligibility_appointment_date_first': ('django.db.models.fields.DateField', [], {'db_index': 'True'}),
|
||||
'eligibility_appointment_date_last': ('django.db.models.fields.DateField', [], {'db_index': 'True'}),
|
||||
'exam_series_code': ('django.db.models.fields.CharField', [], {'max_length': '15', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'processed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
|
||||
'testcenter_user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['student.TestCenterUser']"}),
|
||||
'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'upload_error_message': ('django.db.models.fields.CharField', [], {'max_length': '512', 'blank': 'True'}),
|
||||
'upload_status': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}),
|
||||
'uploaded_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
|
||||
'user_updated_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'})
|
||||
},
|
||||
'student.testcenteruser': {
|
||||
'Meta': {'object_name': 'TestCenterUser'},
|
||||
'address_1': ('django.db.models.fields.CharField', [], {'max_length': '40'}),
|
||||
'address_2': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}),
|
||||
'address_3': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}),
|
||||
'candidate_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}),
|
||||
'city': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}),
|
||||
'client_candidate_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '50', 'db_index': 'True'}),
|
||||
'company_name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '50', 'blank': 'True'}),
|
||||
'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
|
||||
'country': ('django.db.models.fields.CharField', [], {'max_length': '3', 'db_index': 'True'}),
|
||||
'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'extension': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '8', 'blank': 'True'}),
|
||||
'fax': ('django.db.models.fields.CharField', [], {'max_length': '35', 'blank': 'True'}),
|
||||
'fax_country_code': ('django.db.models.fields.CharField', [], {'max_length': '3', 'blank': 'True'}),
|
||||
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}),
|
||||
'middle_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'phone': ('django.db.models.fields.CharField', [], {'max_length': '35'}),
|
||||
'phone_country_code': ('django.db.models.fields.CharField', [], {'max_length': '3', 'db_index': 'True'}),
|
||||
'postal_code': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '16', 'blank': 'True'}),
|
||||
'processed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
|
||||
'salutation': ('django.db.models.fields.CharField', [], {'max_length': '50', 'blank': 'True'}),
|
||||
'state': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}),
|
||||
'suffix': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
|
||||
'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'upload_error_message': ('django.db.models.fields.CharField', [], {'max_length': '512', 'blank': 'True'}),
|
||||
'upload_status': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}),
|
||||
'uploaded_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}),
|
||||
'user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['auth.User']", 'unique': 'True'}),
|
||||
'user_updated_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'})
|
||||
},
|
||||
'student.userprofile': {
|
||||
'Meta': {'object_name': 'UserProfile', 'db_table': "'auth_userprofile'"},
|
||||
'allow_certificate': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
|
||||
'courseware': ('django.db.models.fields.CharField', [], {'default': "'course.xml'", 'max_length': '255', 'blank': 'True'}),
|
||||
'gender': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}),
|
||||
'goals': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'language': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
|
||||
'level_of_education': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}),
|
||||
'location': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
|
||||
'mailing_address': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'meta': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
|
||||
'user': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'profile'", 'unique': 'True', 'to': "orm['auth.User']"}),
|
||||
'year_of_birth': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'})
|
||||
},
|
||||
'student.usertestgroup': {
|
||||
'Meta': {'object_name': 'UserTestGroup'},
|
||||
'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}),
|
||||
'users': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'db_index': 'True', 'symmetrical': 'False'})
|
||||
}
|
||||
}
|
||||
|
||||
complete_apps = ['student']
|
||||
@@ -370,7 +370,7 @@ class TestCenterRegistration(models.Model):
|
||||
accommodation_code = models.CharField(max_length=64, blank=True)
|
||||
|
||||
# store the original text of the accommodation request.
|
||||
accommodation_request = models.CharField(max_length=1024, blank=True, db_index=True)
|
||||
accommodation_request = models.CharField(max_length=1024, blank=True, db_index=False)
|
||||
|
||||
# time at which edX sent the registration to the test center
|
||||
uploaded_at = models.DateTimeField(null=True, db_index=True)
|
||||
|
||||
@@ -11,9 +11,9 @@ class AutoAuthEnabledTestCase(UrlResetMixin, TestCase):
|
||||
Tests for the Auto auth view that we have for load testing.
|
||||
"""
|
||||
|
||||
@patch.dict("django.conf.settings.MITX_FEATURES", {"AUTOMATIC_AUTH_FOR_LOAD_TESTING": True})
|
||||
@patch.dict("django.conf.settings.MITX_FEATURES", {"AUTOMATIC_AUTH_FOR_TESTING": True})
|
||||
def setUp(self):
|
||||
# Patching the settings.MITX_FEATURES['AUTOMATIC_AUTH_FOR_LOAD_TESTING']
|
||||
# Patching the settings.MITX_FEATURES['AUTOMATIC_AUTH_FOR_TESTING']
|
||||
# value affects the contents of urls.py,
|
||||
# so we need to call super.setUp() which reloads urls.py (because
|
||||
# of the UrlResetMixin)
|
||||
@@ -37,6 +37,26 @@ class AutoAuthEnabledTestCase(UrlResetMixin, TestCase):
|
||||
user = qset[0]
|
||||
assert user.is_active
|
||||
|
||||
def test_create_defined_user(self):
|
||||
"""
|
||||
Test that the user gets created with the correct attributes
|
||||
when they are passed as parameters on the auto-auth page.
|
||||
"""
|
||||
|
||||
self.client.get(
|
||||
self.url,
|
||||
{'username': 'robot', 'password': 'test', 'email': 'robot@edx.org'}
|
||||
)
|
||||
|
||||
qset = User.objects.all()
|
||||
|
||||
# assert user was created with the correct username and password
|
||||
self.assertEqual(qset.count(), 1)
|
||||
user = qset[0]
|
||||
self.assertEqual(user.username, 'robot')
|
||||
self.assertTrue(user.check_password('test'))
|
||||
self.assertEqual(user.email, 'robot@edx.org')
|
||||
|
||||
@patch('student.views.random.randint')
|
||||
def test_create_multiple_users(self, randint):
|
||||
"""
|
||||
@@ -50,8 +70,13 @@ class AutoAuthEnabledTestCase(UrlResetMixin, TestCase):
|
||||
|
||||
qset = User.objects.all()
|
||||
|
||||
# make sure that USER_1 and USER_2 were created
|
||||
# make sure that USER_1 and USER_2 were created correctly
|
||||
self.assertEqual(qset.count(), 2)
|
||||
user1 = qset[0]
|
||||
self.assertEqual(user1.username, 'USER_1')
|
||||
self.assertTrue(user1.check_password('PASS_1'))
|
||||
self.assertEqual(user1.email, 'USER_1_dummy_test@mitx.mit.edu')
|
||||
self.assertEqual(qset[1].username, 'USER_2')
|
||||
|
||||
@patch.dict("django.conf.settings.MITX_FEATURES", {"MAX_AUTO_AUTH_USERS": 1})
|
||||
def test_login_already_created_user(self):
|
||||
@@ -77,9 +102,9 @@ class AutoAuthDisabledTestCase(UrlResetMixin, TestCase):
|
||||
Test that the page is inaccessible with default settings
|
||||
"""
|
||||
|
||||
@patch.dict("django.conf.settings.MITX_FEATURES", {"AUTOMATIC_AUTH_FOR_LOAD_TESTING": False})
|
||||
@patch.dict("django.conf.settings.MITX_FEATURES", {"AUTOMATIC_AUTH_FOR_TESTING": False})
|
||||
def setUp(self):
|
||||
# Patching the settings.MITX_FEATURES['AUTOMATIC_AUTH_FOR_LOAD_TESTING']
|
||||
# Patching the settings.MITX_FEATURES['AUTOMATIC_AUTH_FOR_TESTING']
|
||||
# value affects the contents of urls.py,
|
||||
# so we need to call super.setUp() which reloads urls.py (because
|
||||
# of the UrlResetMixin)
|
||||
|
||||
@@ -6,6 +6,7 @@ from mock import patch
|
||||
|
||||
from django.test import TestCase
|
||||
from django.test.client import Client
|
||||
from django.core.cache import cache
|
||||
from django.core.urlresolvers import reverse, NoReverseMatch
|
||||
from student.tests.factories import UserFactory, RegistrationFactory, UserProfileFactory
|
||||
|
||||
@@ -29,6 +30,7 @@ class LoginTest(TestCase):
|
||||
|
||||
# Create the test client
|
||||
self.client = Client()
|
||||
cache.clear()
|
||||
|
||||
# Store the login url
|
||||
try:
|
||||
@@ -95,6 +97,27 @@ class LoginTest(TestCase):
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self._assert_audit_log(mock_audit_log, 'info', [u'Logout', u'test'])
|
||||
|
||||
def test_login_ratelimited_success(self):
|
||||
# Try (and fail) logging in with fewer attempts than the limit of 30
|
||||
# and verify that you can still successfully log in afterwards.
|
||||
for i in xrange(20):
|
||||
password = u'test_password{0}'.format(i)
|
||||
response, _audit_log = self._login_response('test@edx.org', password)
|
||||
self._assert_response(response, success=False)
|
||||
# now try logging in with a valid password
|
||||
response, _audit_log = self._login_response('test@edx.org', 'test_password')
|
||||
self._assert_response(response, success=True)
|
||||
|
||||
def test_login_ratelimited(self):
|
||||
# try logging in 30 times, the default limit in the number of failed
|
||||
# login attempts in one 5 minute period before the rate gets limited
|
||||
for i in xrange(30):
|
||||
password = u'test_password{0}'.format(i)
|
||||
self._login_response('test@edx.org', password)
|
||||
# check to see if this response indicates that this was ratelimited
|
||||
response, _audit_log = self._login_response('test@edx.org', 'wrong_password')
|
||||
self._assert_response(response, success=False, value='Too many failed login attempts')
|
||||
|
||||
def _login_response(self, email, password, patched_audit_log='student.views.AUDIT_LOG'):
|
||||
''' Post the login info '''
|
||||
post_params = {'email': email, 'password': password}
|
||||
|
||||
@@ -23,6 +23,7 @@ from textwrap import dedent
|
||||
|
||||
from student.models import unique_id_for_user
|
||||
from student.views import process_survey_link, _cert_info, password_reset, password_reset_confirm_wrapper
|
||||
from student.views import enroll_in_course, is_enrolled_in_course
|
||||
from student.tests.factories import UserFactory
|
||||
from student.tests.test_email import mock_render_to_string
|
||||
COURSE_1 = 'edX/toy/2012_Fall'
|
||||
@@ -205,3 +206,15 @@ class CourseEndingTest(TestCase):
|
||||
'show_survey_button': False,
|
||||
'grade': '67'
|
||||
})
|
||||
|
||||
|
||||
class EnrollInCourseTest(TestCase):
|
||||
""" Tests the helper method for enrolling a user in a class """
|
||||
|
||||
def test_enroll_in_course(self):
|
||||
user = User.objects.create_user("joe", "joe@joe.com", "password")
|
||||
user.save()
|
||||
course_id = "course_id"
|
||||
self.assertFalse(is_enrolled_in_course(user, course_id))
|
||||
enroll_in_course(user, course_id)
|
||||
self.assertTrue(is_enrolled_in_course(user, course_id))
|
||||
|
||||
@@ -28,6 +28,8 @@ from django.utils.http import cookie_date
|
||||
from django.utils.http import base36_to_int
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
from ratelimitbackend.exceptions import RateLimitException
|
||||
|
||||
from mitxmako.shortcuts import render_to_response, render_to_string
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
@@ -376,7 +378,7 @@ def change_enrollment(request):
|
||||
"run:{0}".format(run)])
|
||||
|
||||
try:
|
||||
enrollment, _created = CourseEnrollment.objects.get_or_create(user=user, course_id=course.id)
|
||||
enroll_in_course(user, course.id)
|
||||
except IntegrityError:
|
||||
# If we've already created this enrollment in a separate transaction,
|
||||
# then just continue
|
||||
@@ -401,6 +403,23 @@ def change_enrollment(request):
|
||||
return HttpResponseBadRequest(_("Enrollment action is invalid"))
|
||||
|
||||
|
||||
def enroll_in_course(user, course_id):
|
||||
"""
|
||||
Helper method to enroll a user in a particular class.
|
||||
|
||||
It is expected that this method is called from a method which has already
|
||||
verified the user authentication and access.
|
||||
"""
|
||||
CourseEnrollment.objects.get_or_create(user=user, course_id=course_id)
|
||||
|
||||
|
||||
def is_enrolled_in_course(user, course_id):
|
||||
"""
|
||||
Helper method that returns whether or not the user is enrolled in a particular course.
|
||||
"""
|
||||
return CourseEnrollment.objects.filter(user=user, course_id=course_id).count() > 0
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
def accounts_login(request, error=""):
|
||||
|
||||
@@ -421,13 +440,23 @@ def login_user(request, error=""):
|
||||
user = User.objects.get(email=email)
|
||||
except User.DoesNotExist:
|
||||
AUDIT_LOG.warning(u"Login failed - Unknown user email: {0}".format(email))
|
||||
return HttpResponse(json.dumps({'success': False,
|
||||
'value': _('Email or password is incorrect.')})) # TODO: User error message
|
||||
user = None
|
||||
|
||||
username = user.username
|
||||
user = authenticate(username=username, password=password)
|
||||
# if the user doesn't exist, we want to set the username to an invalid
|
||||
# username so that authentication is guaranteed to fail and we can take
|
||||
# advantage of the ratelimited backend
|
||||
username = user.username if user else ""
|
||||
try:
|
||||
user = authenticate(username=username, password=password, request=request)
|
||||
# this occurs when there are too many attempts from the same IP address
|
||||
except RateLimitException:
|
||||
return HttpResponse(json.dumps({'success': False,
|
||||
'value': _('Too many failed login attempts. Try again later.')}))
|
||||
if user is None:
|
||||
AUDIT_LOG.warning(u"Login failed - password for {0} is invalid".format(email))
|
||||
# if we didn't find this username earlier, the account for this email
|
||||
# doesn't exist, and doesn't have a corresponding password
|
||||
if username != "":
|
||||
AUDIT_LOG.warning(u"Login failed - password for {0} is invalid".format(email))
|
||||
return HttpResponse(json.dumps({'success': False,
|
||||
'value': _('Email or password is incorrect.')}))
|
||||
|
||||
@@ -674,7 +703,7 @@ def create_account(request, post_override=None):
|
||||
message = render_to_string('emails/activation_email.txt', d)
|
||||
|
||||
# dont send email if we are doing load testing or random user generation for some reason
|
||||
if not (settings.MITX_FEATURES.get('AUTOMATIC_AUTH_FOR_LOAD_TESTING')):
|
||||
if not (settings.MITX_FEATURES.get('AUTOMATIC_AUTH_FOR_TESTING')):
|
||||
try:
|
||||
if settings.MITX_FEATURES.get('REROUTE_ACTIVATION_EMAIL'):
|
||||
dest_addr = settings.MITX_FEATURES['REROUTE_ACTIVATION_EMAIL']
|
||||
@@ -913,41 +942,46 @@ def auto_auth(request):
|
||||
"""
|
||||
Automatically logs the user in with a generated random credentials
|
||||
This view is only accessible when
|
||||
settings.MITX_SETTINGS['AUTOMATIC_AUTH_FOR_LOAD_TESTING'] is true.
|
||||
settings.MITX_SETTINGS['AUTOMATIC_AUTH_FOR_TESTING'] is true.
|
||||
"""
|
||||
|
||||
def get_dummy_post_data(username, password):
|
||||
def get_dummy_post_data(username, password, email, name):
|
||||
"""
|
||||
Return a dictionary suitable for passing to post_vars of _do_create_account or post_override
|
||||
of create_account, with specified username and password.
|
||||
of create_account, with specified values.
|
||||
"""
|
||||
|
||||
return {'username': username,
|
||||
'email': username + "_dummy_test@mitx.mit.edu",
|
||||
'email': email,
|
||||
'password': password,
|
||||
'name': username + " " + username,
|
||||
'name': name,
|
||||
'honor_code': u'true',
|
||||
'terms_of_service': u'true', }
|
||||
|
||||
# generate random user ceredentials from a small name space (determined by settings)
|
||||
# generate random user credentials from a small name space (determined by settings)
|
||||
name_base = 'USER_'
|
||||
pass_base = 'PASS_'
|
||||
|
||||
max_users = settings.MITX_FEATURES.get('MAX_AUTO_AUTH_USERS', 200)
|
||||
number = random.randint(1, max_users)
|
||||
|
||||
username = name_base + str(number)
|
||||
password = pass_base + str(number)
|
||||
# Get the params from the request to override default user attributes if specified
|
||||
qdict = request.GET
|
||||
|
||||
# Use the params from the request, otherwise use these defaults
|
||||
username = qdict.get('username', name_base + str(number))
|
||||
password = qdict.get('password', pass_base + str(number))
|
||||
email = qdict.get('email', '%s_dummy_test@mitx.mit.edu' % username)
|
||||
name = qdict.get('name', '%s Test' % username)
|
||||
|
||||
# if they already are a user, log in
|
||||
try:
|
||||
user = User.objects.get(username=username)
|
||||
user = authenticate(username=username, password=password)
|
||||
user = authenticate(username=username, password=password, request=request)
|
||||
login(request, user)
|
||||
|
||||
# else create and activate account info
|
||||
except ObjectDoesNotExist:
|
||||
post_override = get_dummy_post_data(username, password)
|
||||
post_override = get_dummy_post_data(username, password, email, name)
|
||||
create_account(request, post_override=post_override)
|
||||
request.user.is_active = True
|
||||
request.user.save()
|
||||
|
||||
@@ -34,33 +34,17 @@ def create_user(uname, password):
|
||||
|
||||
|
||||
@world.absorb
|
||||
def log_in(username, password):
|
||||
def log_in(username='robot', password='test', email='robot@edx.org', name='Robot'):
|
||||
"""
|
||||
Log the user in programatically.
|
||||
This will delete any existing cookies to ensure that the user
|
||||
logs in to the correct session.
|
||||
Use the auto_auth feature to programmatically log the user in
|
||||
"""
|
||||
url = '/auto_auth?username=%s&password=%s&name=%s&email=%s' % (username,
|
||||
password, name, email)
|
||||
world.visit(url)
|
||||
|
||||
# Authenticate the user
|
||||
world.scenario_dict['USER'] = authenticate(username=username, password=password)
|
||||
assert(world.scenario_dict['USER'] is not None and world.scenario_dict['USER'].is_active)
|
||||
|
||||
# Send a fake HttpRequest to log the user in
|
||||
# We need to process the request using
|
||||
# Session middleware and Authentication middleware
|
||||
# to ensure that session state can be stored
|
||||
request = HttpRequest()
|
||||
SessionMiddleware().process_request(request)
|
||||
AuthenticationMiddleware().process_request(request)
|
||||
login(request, world.scenario_dict['USER'])
|
||||
|
||||
# Save the session
|
||||
request.session.save()
|
||||
|
||||
# Retrieve the sessionid and add it to the browser's cookies
|
||||
cookie_dict = {settings.SESSION_COOKIE_NAME: request.session.session_key}
|
||||
world.browser.cookies.delete()
|
||||
world.browser.cookies.add(cookie_dict)
|
||||
# Save the user info in the world scenario_dict for use in the tests
|
||||
user = User.objects.get(username=username)
|
||||
world.scenario_dict['USER'] = user
|
||||
|
||||
|
||||
@world.absorb
|
||||
|
||||
@@ -88,13 +88,13 @@ def the_page_title_should_contain(step, title):
|
||||
|
||||
@step('I log in$')
|
||||
def i_log_in(step):
|
||||
world.log_in('robot', 'test')
|
||||
world.log_in(username='robot', password='test')
|
||||
|
||||
|
||||
@step('I am a logged in user$')
|
||||
def i_am_logged_in_user(step):
|
||||
world.create_user('robot', 'test')
|
||||
world.log_in('robot', 'test')
|
||||
world.log_in(username='robot', password='test')
|
||||
|
||||
|
||||
@step('I am not logged in$')
|
||||
@@ -147,7 +147,7 @@ def should_see_in_the_page(step, doesnt_appear, text):
|
||||
@step('I am logged in$')
|
||||
def i_am_logged_in(step):
|
||||
world.create_user('robot', 'test')
|
||||
world.log_in('robot', 'test')
|
||||
world.log_in(username='robot', password='test')
|
||||
world.browser.visit(django_url('/'))
|
||||
# You should not see the login link
|
||||
assert_equals(world.browser.find_by_css('a#login'), [])
|
||||
|
||||
@@ -44,8 +44,8 @@ def is_css_not_present(css_selector, wait_time=5):
|
||||
|
||||
|
||||
@world.absorb
|
||||
def css_has_text(css_selector, text):
|
||||
return world.css_text(css_selector) == text
|
||||
def css_has_text(css_selector, text, index=0, max_attempts=5):
|
||||
return world.css_text(css_selector, index=index, max_attempts=max_attempts) == text
|
||||
|
||||
|
||||
@world.absorb
|
||||
@@ -235,6 +235,13 @@ def click_tools():
|
||||
def is_mac():
|
||||
return platform.mac_ver()[0] is not ''
|
||||
|
||||
@world.absorb
|
||||
def is_firefox():
|
||||
return world.browser.driver_name is 'Firefox'
|
||||
|
||||
@world.absorb
|
||||
def trigger_event(css_selector, event='change', index=0):
|
||||
world.browser.execute_script("$('{}:eq({})').trigger('{}')".format(css_selector, index, event))
|
||||
|
||||
@world.absorb
|
||||
def retry_on_exception(func, max_attempts=5):
|
||||
|
||||
@@ -3,6 +3,6 @@ django admin pages for courseware model
|
||||
'''
|
||||
|
||||
from track.models import TrackingLog
|
||||
from django.contrib import admin
|
||||
from ratelimitbackend import admin
|
||||
|
||||
admin.site.register(TrackingLog)
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
.supertestclass{
|
||||
color: red;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
.supertestclass{
|
||||
color: red;
|
||||
}
|
||||
|
||||
@@ -534,8 +534,16 @@ class CapaModule(CapaFields, XModule):
|
||||
id=self.location.html_id(), ajax_url=self.system.ajax_url
|
||||
) + html + "</div>"
|
||||
|
||||
# now do the substitutions which are filesystem based, e.g. '/static/' prefixes
|
||||
return self.system.replace_urls(html)
|
||||
# now do all the substitutions which the LMS module_render normally does, but
|
||||
# we need to do here explicitly since we can get called for our HTML via AJAX
|
||||
html = self.system.replace_urls(html)
|
||||
if self.system.replace_course_urls:
|
||||
html = self.system.replace_course_urls(html)
|
||||
|
||||
if self.system.replace_jump_to_id_urls:
|
||||
html = self.system.replace_jump_to_id_urls(html)
|
||||
|
||||
return html
|
||||
|
||||
def handle_ajax(self, dispatch, data):
|
||||
"""
|
||||
|
||||
@@ -58,6 +58,20 @@ class StaticContent(object):
|
||||
else:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_static_path_from_location(location):
|
||||
"""
|
||||
This utility static method will take a location identifier and create a 'durable' /static/.. URL representation of it.
|
||||
This link is 'durable' as it can maintain integrity across cloning of courseware across course-ids, e.g. reruns of
|
||||
courses.
|
||||
In the LMS/CMS, we have runtime link-rewriting, so at render time, this /static/... format will get translated into
|
||||
the actual /c4x/... path which the client needs to reference static content
|
||||
"""
|
||||
if location is not None:
|
||||
return "/static/{name}".format(**location.dict())
|
||||
else:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_base_url_path_for_course_assets(loc):
|
||||
if loc is not None:
|
||||
|
||||
@@ -362,6 +362,11 @@ class CourseFields(object):
|
||||
# Explicit comparison to True because we always want to return a bool.
|
||||
hide_progress_tab = Boolean(help="DO NOT USE THIS", scope=Scope.settings)
|
||||
|
||||
display_organization = String(help="An optional display string for the course organization that will get rendered in the LMS",
|
||||
scope=Scope.settings)
|
||||
|
||||
display_coursenumber = String(help="An optional display string for the course number that will get rendered in the LMS",
|
||||
scope=Scope.settings)
|
||||
|
||||
class CourseDescriptor(CourseFields, SequenceDescriptor):
|
||||
module_class = SequenceModule
|
||||
@@ -933,6 +938,26 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
|
||||
def number(self):
|
||||
return self.location.course
|
||||
|
||||
@property
|
||||
def display_number_with_default(self):
|
||||
"""
|
||||
Return a display course number if it has been specified, otherwise return the 'course' that is in the location
|
||||
"""
|
||||
if self.display_coursenumber:
|
||||
return self.display_coursenumber
|
||||
|
||||
return self.number
|
||||
|
||||
@property
|
||||
def org(self):
|
||||
return self.location.org
|
||||
|
||||
@property
|
||||
def display_org_with_default(self):
|
||||
"""
|
||||
Return a display organization if it has been specified, otherwise return the 'org' that is in the location
|
||||
"""
|
||||
if self.display_organization:
|
||||
return self.display_organization
|
||||
|
||||
return self.org
|
||||
|
||||
19
common/lib/xmodule/xmodule/css/tabs/codemirror.scss
Normal file
19
common/lib/xmodule/xmodule/css/tabs/codemirror.scss
Normal file
@@ -0,0 +1,19 @@
|
||||
.editor{
|
||||
@include clearfix();
|
||||
|
||||
.CodeMirror {
|
||||
@include box-sizing(border-box);
|
||||
width: 100%;
|
||||
position: relative;
|
||||
height: 379px;
|
||||
border: 1px solid #3c3c3c;
|
||||
border-top: 1px solid #8891a1;
|
||||
background: $white;
|
||||
color: #3c3c3c;
|
||||
}
|
||||
|
||||
.CodeMirror-scroll {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
137
common/lib/xmodule/xmodule/css/tabs/tabs.scss
Normal file
137
common/lib/xmodule/xmodule/css/tabs/tabs.scss
Normal file
@@ -0,0 +1,137 @@
|
||||
// styles duped from _unit.scss - Edit Header (Component Name, Mode-Editor, Mode-Settings)
|
||||
|
||||
|
||||
.tabs-wrapper{
|
||||
padding-top: 0;
|
||||
position: relative;
|
||||
|
||||
.wrapper-comp-settings {
|
||||
// set visibility to metadata editor
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.editor-single-tab-name {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
.editor-with-tabs {
|
||||
@include clearfix();
|
||||
position: relative;
|
||||
|
||||
|
||||
.edit-header {
|
||||
@include box-sizing(border-box);
|
||||
padding: 18px 0 18px $baseline;
|
||||
top: 0 !important; // ugly override for second level tab override
|
||||
right: 0;
|
||||
background-color: $blue;
|
||||
border-bottom: 1px solid $blue-d2;
|
||||
color: $white;
|
||||
|
||||
//Component Name
|
||||
.component-name {
|
||||
@extend .t-copy-sub1;
|
||||
position: relative;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 50%;
|
||||
color: $white;
|
||||
font-weight: 600;
|
||||
|
||||
|
||||
|
||||
em {
|
||||
display: inline-block;
|
||||
margin-right: ($baseline/4);
|
||||
font-weight: 400;
|
||||
color: $white;
|
||||
}
|
||||
}
|
||||
|
||||
//Nav-Edit Modes
|
||||
.editor-tabs {
|
||||
list-style: none;
|
||||
right: 0;
|
||||
top: ($baseline/4);
|
||||
position: absolute;
|
||||
padding: 12px ($baseline*0.75);
|
||||
|
||||
.inner_tab_wrap {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
|
||||
a.tab {
|
||||
@include font-size(14);
|
||||
@include linear-gradient(top, rgba(255, 255, 255, .3), rgba(255, 255, 255, 0));
|
||||
border: 1px solid $blue-d1;
|
||||
border-radius: 3px;
|
||||
padding: ($baseline/4) ($baseline);
|
||||
background-color: $blue;
|
||||
font-weight: bold;
|
||||
color: $white;
|
||||
|
||||
&.current {
|
||||
@include linear-gradient($blue, $blue);
|
||||
color: $blue-d1;
|
||||
box-shadow: inset 0 1px 2px 1px $shadow-l1;
|
||||
background-color: $blue-d4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
box-shadow: inset 0 1px 2px 1px $shadow;
|
||||
background-image: linear-gradient(#009FE6, #009FE6) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.is-inactive {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.comp-subtitles-entry {
|
||||
text-align: center;
|
||||
|
||||
.file-upload {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.comp-subtitles-import-list {
|
||||
> li {
|
||||
display: block;
|
||||
margin: $baseline/2 0px $baseline/2 0;
|
||||
}
|
||||
|
||||
.blue-button {
|
||||
font-size: 1em;
|
||||
display: block;
|
||||
width: 70%;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
.component-tab {
|
||||
background: $white;
|
||||
position: relative;
|
||||
border-top: 1px solid #8891a1;
|
||||
|
||||
&#advanced {
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.blue-button {
|
||||
@include blue-button;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -133,7 +133,6 @@ div.videoalpha {
|
||||
line-height: 46px;
|
||||
padding: 0 lh(.75);
|
||||
text-indent: -9999px;
|
||||
@include transition(background-color 0.75s linear 0s, opacity 0.75s linear 0s);
|
||||
width: 14px;
|
||||
background: url('../images/vcr.png') 15px 15px no-repeat;
|
||||
outline: 0;
|
||||
@@ -150,7 +149,7 @@ div.videoalpha {
|
||||
&.play {
|
||||
background-position: 17px -114px;
|
||||
|
||||
&:hover {
|
||||
&:hover, &:focus {
|
||||
background-color: #444;
|
||||
}
|
||||
}
|
||||
@@ -158,7 +157,7 @@ div.videoalpha {
|
||||
&.pause {
|
||||
background-position: 16px -50px;
|
||||
|
||||
&:hover {
|
||||
&:hover, &:focus {
|
||||
background-color: #444;
|
||||
}
|
||||
}
|
||||
@@ -300,12 +299,15 @@ div.videoalpha {
|
||||
|
||||
&.muted {
|
||||
&>a {
|
||||
background: url('../images/mute.png') 10px center no-repeat;
|
||||
background-image: url('../images/mute.png');
|
||||
}
|
||||
}
|
||||
|
||||
> a {
|
||||
background: url('../images/volume.png') 10px center no-repeat;
|
||||
background-image: url('../images/volume.png');
|
||||
background-position: 10px center;
|
||||
background-repeat: no-repeat;
|
||||
|
||||
border-right: 1px solid #000;
|
||||
box-shadow: 1px 0 0 #555, inset 1px 0 0 #555;
|
||||
@include clearfix();
|
||||
@@ -382,7 +384,7 @@ div.videoalpha {
|
||||
@include transition(none);
|
||||
width: 30px;
|
||||
|
||||
&:hover {
|
||||
&:hover, &:active, &:focus {
|
||||
background-color: #444;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
@@ -403,7 +405,7 @@ div.videoalpha {
|
||||
@include transition(none);
|
||||
width: 30px;
|
||||
|
||||
&:hover {
|
||||
&:hover, &:focus {
|
||||
background-color: #444;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
@@ -419,7 +421,6 @@ div.videoalpha {
|
||||
|
||||
a.hide-subtitles {
|
||||
background: url('../images/cc.png') center no-repeat;
|
||||
display: block;
|
||||
float: left;
|
||||
font-weight: 800;
|
||||
line-height: 46px; //height of play pause buttons
|
||||
@@ -432,7 +433,7 @@ div.videoalpha {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
width: 30px;
|
||||
|
||||
&:hover {
|
||||
&:hover, &:focus {
|
||||
background-color: #444;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
@@ -442,9 +443,7 @@ div.videoalpha {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
background-color: #444;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
color: #797979;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -513,12 +512,6 @@ div.videoalpha {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
article.video-wrapper section.video-controls div.secondary-controls a.hide-subtitles {
|
||||
background-color: inherit;
|
||||
color: #797979;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
|
||||
article.video-wrapper div.video-player-pre, article.video-wrapper div.video-player-post {
|
||||
height: 0px;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"""Descriptors for XBlocks/Xmodules, that provide editing of atrributes"""
|
||||
|
||||
from pkg_resources import resource_string
|
||||
from xmodule.mako_module import MakoModuleDescriptor
|
||||
from xblock.core import Scope, String
|
||||
@@ -7,6 +9,7 @@ log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EditingFields(object):
|
||||
"""Contains specific template information (the raw data body)"""
|
||||
data = String(scope=Scope.content, default='')
|
||||
|
||||
|
||||
@@ -29,6 +32,46 @@ class EditingDescriptor(EditingFields, MakoModuleDescriptor):
|
||||
return _context
|
||||
|
||||
|
||||
class TabsEditingDescriptor(EditingFields, MakoModuleDescriptor):
|
||||
"""
|
||||
Module that provides a raw editing view of its data and children. It does not
|
||||
perform any validation on its definition---just passes it along to the browser.
|
||||
|
||||
This class is intended to be used as a mixin.
|
||||
|
||||
Engine (module_edit.js) wants for metadata editor
|
||||
template to be always loaded, so don't forget to include
|
||||
settings tab in your module descriptor.
|
||||
"""
|
||||
mako_template = "widgets/tabs-aggregator.html"
|
||||
css = {'scss': [resource_string(__name__, 'css/tabs/tabs.scss')]}
|
||||
js = {'coffee': [resource_string(
|
||||
__name__, 'js/src/tabs/tabs-aggregator.coffee')]}
|
||||
js_module_name = "TabsEditingDescriptor"
|
||||
tabs = []
|
||||
|
||||
def get_context(self):
|
||||
_context = super(TabsEditingDescriptor, self).get_context()
|
||||
_context.update({
|
||||
'tabs': self.tabs,
|
||||
'html_id': self.location.html_id(), # element_id
|
||||
'data': self.data,
|
||||
})
|
||||
return _context
|
||||
|
||||
@classmethod
|
||||
def get_css(cls):
|
||||
# load every tab's css
|
||||
for tab in cls.tabs:
|
||||
tab_styles = tab.get('css', {})
|
||||
for css_type, css_content in tab_styles.items():
|
||||
if css_type in cls.css:
|
||||
cls.css[css_type].extend(css_content)
|
||||
else:
|
||||
cls.css[css_type] = css_content
|
||||
return cls.css
|
||||
|
||||
|
||||
class XMLEditingDescriptor(EditingDescriptor):
|
||||
"""
|
||||
Module that provides a raw editing view of its data as XML. It does not perform
|
||||
|
||||
@@ -13,6 +13,7 @@ class ProcessingError(Exception):
|
||||
'''
|
||||
pass
|
||||
|
||||
|
||||
class InvalidVersionError(Exception):
|
||||
"""
|
||||
Tried to save an item with a location that a store cannot support (e.g., draft version
|
||||
@@ -21,3 +22,12 @@ class InvalidVersionError(Exception):
|
||||
def __init__(self, location):
|
||||
super(InvalidVersionError, self).__init__()
|
||||
self.location = location
|
||||
|
||||
|
||||
class SerializationError(Exception):
|
||||
"""
|
||||
Thrown when a module cannot be exported to XML
|
||||
"""
|
||||
def __init__(self, location, msg):
|
||||
super(SerializationError, self).__init__(msg)
|
||||
self.location = location
|
||||
|
||||
@@ -14,6 +14,7 @@ from xmodule.stringify import stringify_children
|
||||
from xmodule.x_module import XModule
|
||||
from xmodule.xml_module import XmlDescriptor, name_to_pathname
|
||||
import textwrap
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
|
||||
log = logging.getLogger("mitx.courseware")
|
||||
|
||||
@@ -79,6 +80,17 @@ class HtmlDescriptor(HtmlFields, XmlDescriptor, EditingDescriptor):
|
||||
nc.append(candidate[:-4] + '.html')
|
||||
return candidates + nc
|
||||
|
||||
def get_context(self):
|
||||
"""
|
||||
an override to add in specific rendering context, in this case we need to
|
||||
add in a base path to our c4x content addressing scheme
|
||||
"""
|
||||
_context = EditingDescriptor.get_context(self)
|
||||
# Add some specific HTML rendering context when editing HTML modules where we pass
|
||||
# the root /c4x/ url for assets. This allows client-side substitutions to occur.
|
||||
_context.update({'base_asset_url': StaticContent.get_base_url_path_for_course_assets(self.location) + '/'})
|
||||
return _context
|
||||
|
||||
# NOTE: html descriptors are special. We do not want to parse and
|
||||
# export them ourselves, because that can break things (e.g. lxml
|
||||
# adds body tags when it exports, but they should just be html
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<section class="html-edit">
|
||||
<ul class="editor-tabs">
|
||||
<li><a href="#" class="visual-tab tab current" data-tab="visual">Visual</a></li>
|
||||
<li><a href="#" class="html-tab tab" data-tab="advanced">HTML</a></li>
|
||||
</ul>
|
||||
<div class="row">
|
||||
<textarea class="tiny-mce">dummy text</textarea>
|
||||
<textarea name="" class="edit-box">Advanced Editor Text with link /static/dummy.jpg</textarea>
|
||||
</div>
|
||||
</section>
|
||||
@@ -2,8 +2,8 @@
|
||||
<div id="video_example">
|
||||
<div id="example">
|
||||
<div id="video_id" class="video"
|
||||
data-youtube-id-0-75="slowerSpeedYoutubeId"
|
||||
data-youtube-id-1-0="normalSpeedYoutubeId"
|
||||
data-youtube-id-0-75="7tqY6eQzVhE"
|
||||
data-youtube-id-1-0="cogebirgzzM"
|
||||
data-show-captions="true"
|
||||
data-start=""
|
||||
data-end=""
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div
|
||||
id="video_id"
|
||||
class="videoalpha"
|
||||
data-streams="0.75:slowerSpeedYoutubeId,1.0:normalSpeedYoutubeId"
|
||||
data-streams="0.75:7tqY6eQzVhE,1.0:cogebirgzzM"
|
||||
data-show-captions="true"
|
||||
data-start=""
|
||||
data-end=""
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
data-start=""
|
||||
data-end=""
|
||||
data-caption-asset-path="/static/subs/"
|
||||
data-sub="test_name_of_the_subtitles"
|
||||
data-sub="Z5KLxerq05Y"
|
||||
data-mp4-source="test_files/test.mp4"
|
||||
data-webm-source="test_files/test.webm"
|
||||
data-ogg-source="test_files/test.ogv"
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
data-start=""
|
||||
data-end=""
|
||||
data-caption-asset-path="/static/subs/"
|
||||
data-sub="test_name_of_the_subtitles"
|
||||
data-sub="Z5KLxerq05Y"
|
||||
data-mp4-source="test_files/test.mp4"
|
||||
data-webm-source="test_files/test.webm"
|
||||
data-ogg-source="test_files/test.ogv"
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div
|
||||
id="video_id"
|
||||
class="videoalpha"
|
||||
data-streams="0.75:slowerSpeedYoutubeId,1.0:normalSpeedYoutubeId"
|
||||
data-streams="0.75:7tqY6eQzVhE,1.0:cogebirgzzM"
|
||||
data-show-captions="false"
|
||||
data-start=""
|
||||
data-end=""
|
||||
|
||||
@@ -12,6 +12,9 @@ window.STATUS = window.YT.PlayerState
|
||||
|
||||
oldAjaxWithPrefix = window.jQuery.ajaxWithPrefix
|
||||
|
||||
window.onTouchBasedDevice = ->
|
||||
navigator.userAgent.match /iPhone|iPod|iPad/i
|
||||
|
||||
jasmine.stubbedCaption =
|
||||
end: [3120, 6270, 8490, 21620, 24920, 25750, 27900, 34380, 35550, 40250]
|
||||
start: [1180, 3120, 6270, 14910, 21620, 24920, 25750, 27900, 34380, 35550]
|
||||
@@ -36,7 +39,7 @@ jasmine.stubbedCaption =
|
||||
#
|
||||
# We will replace it with a function that does:
|
||||
#
|
||||
# 1.) Return a hard coded captions object if the file name contains 'test_name_of_the_subtitles'.
|
||||
# 1.) Return a hard coded captions object if the file name contains 'Z5KLxerq05Y'.
|
||||
# 2.) Behaves the same a as the origianl in all other cases.
|
||||
|
||||
window.jQuery.ajaxWithPrefix = (url, settings) ->
|
||||
@@ -46,7 +49,7 @@ window.jQuery.ajaxWithPrefix = (url, settings) ->
|
||||
success = settings.success
|
||||
data = settings.data
|
||||
|
||||
if url.match(/test_name_of_the_subtitles/g) isnt null or url.match(/slowerSpeedYoutubeId/g) isnt null or url.match(/normalSpeedYoutubeId/g) isnt null
|
||||
if url.match(/Z5KLxerq05Y/g) isnt null or url.match(/7tqY6eQzVhE/g) isnt null or url.match(/cogebirgzzM/g) isnt null
|
||||
if window.jQuery.isFunction(success) is true
|
||||
success jasmine.stubbedCaption
|
||||
else if window.jQuery.isFunction(data) is true
|
||||
@@ -60,11 +63,11 @@ window.WAIT_TIMEOUT = 1000
|
||||
jasmine.getFixtures().fixturesPath = 'xmodule/js/fixtures'
|
||||
|
||||
jasmine.stubbedMetadata =
|
||||
slowerSpeedYoutubeId:
|
||||
id: 'slowerSpeedYoutubeId'
|
||||
'7tqY6eQzVhE':
|
||||
id: '7tqY6eQzVhE'
|
||||
duration: 300
|
||||
normalSpeedYoutubeId:
|
||||
id: 'normalSpeedYoutubeId'
|
||||
'cogebirgzzM':
|
||||
id: 'cogebirgzzM'
|
||||
duration: 200
|
||||
bogus:
|
||||
duration: 100
|
||||
@@ -117,7 +120,7 @@ jasmine.stubVideoPlayer = (context, enableParts, createPlayer=true) ->
|
||||
loadFixtures 'video.html'
|
||||
jasmine.stubRequests()
|
||||
YT.Player = undefined
|
||||
videosDefinition = '0.75:slowerSpeedYoutubeId,1.0:normalSpeedYoutubeId'
|
||||
videosDefinition = '0.75:7tqY6eQzVhE,1.0:cogebirgzzM'
|
||||
context.video = new Video '#example', videosDefinition
|
||||
jasmine.stubYoutubePlayer()
|
||||
if createPlayer
|
||||
@@ -135,7 +138,7 @@ jasmine.stubVideoPlayerAlpha = (context, enableParts, html5=false) ->
|
||||
YT.Player = undefined
|
||||
window.OldVideoPlayerAlpha = undefined
|
||||
jasmine.stubYoutubePlayer()
|
||||
return new VideoAlpha '#example', '.75:slowerSpeedYoutubeId,1.0:normalSpeedYoutubeId'
|
||||
return new VideoAlpha '#example', '.75:7tqY6eQzVhE,1.0:cogebirgzzM'
|
||||
|
||||
|
||||
# Stub jQuery.cookie
|
||||
|
||||
@@ -48,6 +48,16 @@ describe 'HTMLEditingDescriptor', ->
|
||||
expect(@descriptor.showingVisualEditor).toEqual(true)
|
||||
data = @descriptor.save().data
|
||||
expect(data).toEqual('from visual editor')
|
||||
it 'Performs link rewriting for static assets when saving', ->
|
||||
visualEditorStub =
|
||||
isDirty: () -> true
|
||||
getContent: () -> 'from visual editor with /c4x/foo/bar/asset/image.jpg'
|
||||
spyOn(@descriptor, 'getVisualEditor').andCallFake () ->
|
||||
visualEditorStub
|
||||
expect(@descriptor.showingVisualEditor).toEqual(true)
|
||||
@descriptor.base_asset_url = '/c4x/foo/bar/asset/'
|
||||
data = @descriptor.save().data
|
||||
expect(data).toEqual('from visual editor with /static/image.jpg')
|
||||
describe 'Can switch to Advanced Editor', ->
|
||||
beforeEach ->
|
||||
loadFixtures 'html-edit.html'
|
||||
@@ -88,3 +98,23 @@ describe 'HTMLEditingDescriptor', ->
|
||||
expect(visualEditorStub.isDirty()).toEqual(false)
|
||||
expect(visualEditorStub.getContent()).toEqual('Advanced Editor Text')
|
||||
expect(visualEditorStub.startContent).toEqual('Advanced Editor Text')
|
||||
it 'When switching to visual editor links are rewritten to c4x format', ->
|
||||
loadFixtures 'html-edit-with-links.html'
|
||||
@descriptor = new HTMLEditingDescriptor($('.html-edit'))
|
||||
@descriptor.base_asset_url = '/c4x/foo/bar/asset/'
|
||||
@descriptor.showingVisualEditor = false
|
||||
|
||||
visualEditorStub =
|
||||
isNotDirty: false
|
||||
content: 'not set'
|
||||
startContent: 'not set',
|
||||
focus: () -> true
|
||||
isDirty: () -> not @isNotDirty
|
||||
setContent: (x) -> @content = x
|
||||
getContent: -> @content
|
||||
|
||||
@descriptor.showVisualEditor(visualEditorStub)
|
||||
expect(@descriptor.showingVisualEditor).toEqual(true)
|
||||
expect(visualEditorStub.isDirty()).toEqual(false)
|
||||
expect(visualEditorStub.getContent()).toEqual('Advanced Editor Text with link /c4x/foo/bar/asset/dummy.jpg')
|
||||
expect(visualEditorStub.startContent).toEqual('Advanced Editor Text with link /c4x/foo/bar/asset/dummy.jpg')
|
||||
@@ -19,7 +19,7 @@ describe 'VideoCaption', ->
|
||||
@caption = @player.caption
|
||||
|
||||
it 'set the youtube id', ->
|
||||
expect(@caption.youtubeId).toEqual 'normalSpeedYoutubeId'
|
||||
expect(@caption.youtubeId).toEqual 'cogebirgzzM'
|
||||
|
||||
it 'create the caption element', ->
|
||||
expect($('.video')).toContain 'ol.subtitles'
|
||||
|
||||
@@ -35,7 +35,7 @@ describe 'VideoPlayer', ->
|
||||
expect(window.VideoCaption.prototype.initialize).toHaveBeenCalled()
|
||||
expect(@player.caption).toBeDefined()
|
||||
expect(@player.caption.el).toBe @player.el
|
||||
expect(@player.caption.youtubeId).toEqual 'normalSpeedYoutubeId'
|
||||
expect(@player.caption.youtubeId).toEqual 'cogebirgzzM'
|
||||
expect(@player.caption.currentSpeed).toEqual '1.0'
|
||||
expect(@player.caption.captionAssetPath).toEqual '/static/subs/'
|
||||
|
||||
@@ -60,7 +60,7 @@ describe 'VideoPlayer', ->
|
||||
showinfo: 0
|
||||
enablejsapi: 1
|
||||
modestbranding: 1
|
||||
videoId: 'normalSpeedYoutubeId'
|
||||
videoId: 'cogebirgzzM'
|
||||
events:
|
||||
onReady: @player.onReady
|
||||
onStateChange: @player.onStateChange
|
||||
@@ -290,7 +290,7 @@ describe 'VideoPlayer', ->
|
||||
@player.onSpeedChange {}, '0.75'
|
||||
|
||||
it 'load the video', ->
|
||||
expect(@player.player.loadVideoById).toHaveBeenCalledWith 'slowerSpeedYoutubeId', '80.000'
|
||||
expect(@player.player.loadVideoById).toHaveBeenCalledWith '7tqY6eQzVhE', '80.000'
|
||||
|
||||
it 'trigger updatePlayTime event', ->
|
||||
expect(@player.updatePlayTime).toHaveBeenCalledWith '80.000'
|
||||
@@ -301,7 +301,7 @@ describe 'VideoPlayer', ->
|
||||
@player.onSpeedChange {}, '0.75'
|
||||
|
||||
it 'cue the video', ->
|
||||
expect(@player.player.cueVideoById).toHaveBeenCalledWith 'slowerSpeedYoutubeId', '80.000'
|
||||
expect(@player.player.cueVideoById).toHaveBeenCalledWith '7tqY6eQzVhE', '80.000'
|
||||
|
||||
it 'trigger updatePlayTime event', ->
|
||||
expect(@player.updatePlayTime).toHaveBeenCalledWith '80.000'
|
||||
|
||||
@@ -5,14 +5,14 @@ describe 'Video', ->
|
||||
loadFixtures 'video.html'
|
||||
jasmine.stubRequests()
|
||||
|
||||
@slowerSpeedYoutubeId = 'slowerSpeedYoutubeId'
|
||||
@normalSpeedYoutubeId = 'normalSpeedYoutubeId'
|
||||
@['7tqY6eQzVhE'] = '7tqY6eQzVhE'
|
||||
@['cogebirgzzM'] = 'cogebirgzzM'
|
||||
metadata =
|
||||
slowerSpeedYoutubeId:
|
||||
id: @slowerSpeedYoutubeId
|
||||
'7tqY6eQzVhE':
|
||||
id: @['7tqY6eQzVhE']
|
||||
duration: 300
|
||||
normalSpeedYoutubeId:
|
||||
id: @normalSpeedYoutubeId
|
||||
'cogebirgzzM':
|
||||
id: @['cogebirgzzM']
|
||||
duration: 200
|
||||
|
||||
afterEach ->
|
||||
@@ -38,8 +38,8 @@ describe 'Video', ->
|
||||
|
||||
it 'parse the videos', ->
|
||||
expect(@video.videos).toEqual
|
||||
'0.75': @slowerSpeedYoutubeId
|
||||
'1.0': @normalSpeedYoutubeId
|
||||
'0.75': @['7tqY6eQzVhE']
|
||||
'1.0': @['cogebirgzzM']
|
||||
|
||||
it 'fetch the video metadata', ->
|
||||
expect(@video.fetchMetadata).toHaveBeenCalled
|
||||
@@ -102,12 +102,12 @@ describe 'Video', ->
|
||||
|
||||
describe 'with speed', ->
|
||||
it 'return the video id for given speed', ->
|
||||
expect(@video.youtubeId('0.75')).toEqual @slowerSpeedYoutubeId
|
||||
expect(@video.youtubeId('1.0')).toEqual @normalSpeedYoutubeId
|
||||
expect(@video.youtubeId('0.75')).toEqual @['7tqY6eQzVhE']
|
||||
expect(@video.youtubeId('1.0')).toEqual @['cogebirgzzM']
|
||||
|
||||
describe 'without speed', ->
|
||||
it 'return the video id for current speed', ->
|
||||
expect(@video.youtubeId()).toEqual @normalSpeedYoutubeId
|
||||
expect(@video.youtubeId()).toEqual @cogebirgzzM
|
||||
|
||||
describe 'setSpeed', ->
|
||||
beforeEach ->
|
||||
@@ -148,6 +148,6 @@ describe 'Video', ->
|
||||
it 'call the logger with valid parameters', ->
|
||||
expect(Logger.log).toHaveBeenCalledWith 'someEvent',
|
||||
id: 'id'
|
||||
code: @normalSpeedYoutubeId
|
||||
code: @cogebirgzzM
|
||||
currentTime: 25
|
||||
speed: '1.0'
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
jasmine.stubRequests();
|
||||
oldOTBD = window.onTouchBasedDevice;
|
||||
window.onTouchBasedDevice = jasmine.createSpy('onTouchBasedDevice').andReturn(false);
|
||||
this.videosDefinition = '0.75:slowerSpeedYoutubeId,1.0:normalSpeedYoutubeId';
|
||||
this.slowerSpeedYoutubeId = 'slowerSpeedYoutubeId';
|
||||
this.normalSpeedYoutubeId = 'normalSpeedYoutubeId';
|
||||
this.videosDefinition = '0.75:7tqY6eQzVhE,1.0:cogebirgzzM';
|
||||
this['7tqY6eQzVhE'] = '7tqY6eQzVhE';
|
||||
this['cogebirgzzM'] = 'cogebirgzzM';
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
@@ -45,8 +45,8 @@
|
||||
|
||||
it('parse the videos', function () {
|
||||
expect(this.state.videos).toEqual({
|
||||
'0.75': this.slowerSpeedYoutubeId,
|
||||
'1.0': this.normalSpeedYoutubeId
|
||||
'0.75': this['7tqY6eQzVhE'],
|
||||
'1.0': this['cogebirgzzM']
|
||||
});
|
||||
});
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
});
|
||||
|
||||
it('parse the videos if subtitles exist', function () {
|
||||
var sub = 'test_name_of_the_subtitles';
|
||||
var sub = 'Z5KLxerq05Y';
|
||||
|
||||
expect(state.videos).toEqual({
|
||||
'0.75': sub,
|
||||
@@ -165,14 +165,14 @@
|
||||
|
||||
describe('with speed', function () {
|
||||
it('return the video id for given speed', function () {
|
||||
expect(state.youtubeId('0.75')).toEqual(this.slowerSpeedYoutubeId);
|
||||
expect(state.youtubeId('1.0')).toEqual(this.normalSpeedYoutubeId);
|
||||
expect(state.youtubeId('0.75')).toEqual(this['7tqY6eQzVhE']);
|
||||
expect(state.youtubeId('1.0')).toEqual(this['cogebirgzzM']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('without speed', function () {
|
||||
it('return the video id for current speed', function () {
|
||||
expect(state.youtubeId()).toEqual(this.normalSpeedYoutubeId);
|
||||
expect(state.youtubeId()).toEqual(this.cogebirgzzM);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
Jasmine JavaScript tests status
|
||||
-------------------------------
|
||||
|
||||
As of 22.07.2013, all the tests in this directory pass. To disable each of them, change the top level "describe(" to "xdescribe(".
|
||||
As of 22.07.2013, all the tests in this directory pass. To enable a test file, change
|
||||
the top level "xdescribe(" to "describe(".
|
||||
|
||||
PS: When you are running the tests in chrome locally, make sure that chrome is started
|
||||
with the option "--allow-file-access-from-files".
|
||||
|
||||
@@ -130,7 +130,6 @@
|
||||
|
||||
describe('mouse movement', function() {
|
||||
beforeEach(function() {
|
||||
//initialize();
|
||||
window.setTimeout.andReturn(100);
|
||||
spyOn(window, 'clearTimeout');
|
||||
});
|
||||
@@ -221,10 +220,6 @@
|
||||
});
|
||||
|
||||
describe('search', function() {
|
||||
beforeEach(function() {
|
||||
//initialize();
|
||||
});
|
||||
|
||||
it('return a correct caption index', function() {
|
||||
expect(videoCaption.search(0)).toEqual(0);
|
||||
expect(videoCaption.search(3120)).toEqual(1);
|
||||
@@ -277,7 +272,6 @@
|
||||
|
||||
describe('pause', function() {
|
||||
beforeEach(function() {
|
||||
//initialize();
|
||||
videoCaption.playing = true;
|
||||
videoCaption.pause();
|
||||
});
|
||||
@@ -288,10 +282,6 @@
|
||||
});
|
||||
|
||||
describe('updatePlayTime', function() {
|
||||
/*beforeEach(function() {
|
||||
initialize();
|
||||
});*/
|
||||
|
||||
describe('when the video speed is 1.0x', function() {
|
||||
beforeEach(function() {
|
||||
videoSpeedControl.currentSpeed = '1.0';
|
||||
@@ -369,16 +359,21 @@
|
||||
});
|
||||
|
||||
it('when CC button is disabled ', function() {
|
||||
var realHeight = parseInt($('.subtitles').css('maxHeight'), 10),
|
||||
videoWrapperHeight = $('.video-wrapper').height(),
|
||||
controlsHeight = videoControl.el.height(),
|
||||
progressSliderHeight = videoControl.sliderEl.height(),
|
||||
shouldBeHeight = videoWrapperHeight - controlsHeight \
|
||||
- 0.5 * controlsHeight;
|
||||
var realHeight, videoWrapperHeight, progressSliderHeight,
|
||||
controlHeight, shouldBeHeight;
|
||||
|
||||
state.captionsHidden = true;
|
||||
videoCaption.setSubtitlesHeight();
|
||||
expect(realHeight).toBeCloseTo($('.video-wrapper').height(shouldBeHeight, 2));
|
||||
|
||||
realHeight = parseInt($('.subtitles').css('maxHeight'), 10);
|
||||
videoWrapperHeight = $('.video-wrapper').height();
|
||||
progressSliderHeight = videoControl.sliderEl.height();
|
||||
controlHeight = videoControl.el.height();
|
||||
shouldBeHeight = videoWrapperHeight -
|
||||
0.5 * progressSliderHeight -
|
||||
controlHeight;
|
||||
|
||||
expect(realHeight).toBe(shouldBeHeight);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -434,17 +429,6 @@
|
||||
});
|
||||
|
||||
it('scroll to current caption', function() {
|
||||
// Check for calledWith(parameters) for some reason fails...
|
||||
//
|
||||
// var offset = -0.5 * ($('.video-wrapper').height() - $('.subtitles .current:first').height());
|
||||
//
|
||||
// expect($.fn.scrollTo).toHaveBeenCalledWith(
|
||||
// $('.subtitles .current:first', videoCaption.el),
|
||||
// {
|
||||
// offset: offset
|
||||
// }
|
||||
// );
|
||||
|
||||
expect($.fn.scrollTo).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -454,7 +438,6 @@
|
||||
describe('seekPlayer', function() {
|
||||
describe('when the video speed is 1.0x', function() {
|
||||
beforeEach(function() {
|
||||
//initialize();
|
||||
videoSpeedControl.currentSpeed = '1.0';
|
||||
$('.subtitles li[data-start="14910"]').trigger('click');
|
||||
});
|
||||
|
||||
@@ -34,12 +34,6 @@
|
||||
});
|
||||
|
||||
describe('constructor', function() {
|
||||
beforeEach(function() {
|
||||
$.fn.qtip.andCallFake(function() {
|
||||
$(this).data('qtip', true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('always', function() {
|
||||
beforeEach(function() {
|
||||
initialize();
|
||||
@@ -60,7 +54,7 @@
|
||||
|
||||
it('create video caption', function() {
|
||||
expect(videoCaption).toBeDefined();
|
||||
expect(state.youtubeId()).toEqual('test_name_of_the_subtitles');
|
||||
expect(state.youtubeId()).toEqual('Z5KLxerq05Y');
|
||||
expect(state.speed).toEqual('1.0');
|
||||
expect(state.config.caption_asset_path).toEqual('/static/subs/');
|
||||
});
|
||||
@@ -80,38 +74,6 @@
|
||||
// All the toHandleWith() expect tests are not necessary for this version of Video Alpha.
|
||||
// jQuery event system is not used to trigger and invoke methods. This is an artifact from
|
||||
// previous version of Video Alpha.
|
||||
//
|
||||
// xit('bind to video control play event', function() {
|
||||
// expect($(videoControl)).toHandleWith('play', player.play);
|
||||
// });
|
||||
//
|
||||
// xit('bind to video control pause event', function() {
|
||||
// expect($(videoControl)).toHandleWith('pause', player.pause);
|
||||
// });
|
||||
//
|
||||
// xit('bind to video caption seek event', function() {
|
||||
// expect($(videoCaption)).toHandleWith('caption_seek', player.onSeek);
|
||||
// });
|
||||
//
|
||||
// xit('bind to video speed control speedChange event', function() {
|
||||
// expect($(videoSpeedControl)).toHandleWith('speedChange', player.onSpeedChange);
|
||||
// });
|
||||
//
|
||||
// xit('bind to video progress slider seek event', function() {
|
||||
// expect($(videoProgressSlider)).toHandleWith('slide_seek', player.onSeek);
|
||||
// });
|
||||
//
|
||||
// xit('bind to video volume control volumeChange event', function() {
|
||||
// expect($(videoVolumeControl)).toHandleWith('volumeChange', player.onVolumeChange);
|
||||
// });
|
||||
//
|
||||
// xit('bind to key press', function() {
|
||||
// expect($(document.documentElement)).toHandleWith('keyup', player.bindExitFullScreen);
|
||||
// });
|
||||
//
|
||||
// xit('bind to fullscreen switching button', function() {
|
||||
// expect($('.add-fullscreen')).toHandleWith('click', player.toggleFullScreen);
|
||||
// });
|
||||
});
|
||||
|
||||
it('create Youtube player', function() {
|
||||
@@ -136,7 +98,7 @@
|
||||
modestbranding: 1,
|
||||
html5: 1
|
||||
},
|
||||
videoId: 'normalSpeedYoutubeId',
|
||||
videoId: 'cogebirgzzM',
|
||||
events: {
|
||||
onReady: videoPlayer.onReady,
|
||||
onStateChange: videoPlayer.onStateChange,
|
||||
@@ -149,20 +111,6 @@
|
||||
|
||||
// We can't test the invocation of HTML5Video because it is not available
|
||||
// globally. It is defined within the scope of Require JS.
|
||||
//
|
||||
// xit('create HTML5 player', function() {
|
||||
// spyOn(state.HTML5Video, 'Player').andCallThrough();
|
||||
// initialize();
|
||||
//
|
||||
// expect(window.HTML5Video.Player).toHaveBeenCalledWith(this.video.el, {
|
||||
// playerVars: playerVars,
|
||||
// videoSources: this.video.html5Sources,
|
||||
// events: {
|
||||
// onReady: player.onReady,
|
||||
// onStateChange: player.onStateChange
|
||||
// }
|
||||
// });
|
||||
// });
|
||||
|
||||
describe('when not on a touch based device', function() {
|
||||
beforeEach(function() {
|
||||
@@ -170,10 +118,6 @@
|
||||
initialize();
|
||||
});
|
||||
|
||||
it('does not add the tooltip to fullscreen button', function() {
|
||||
expect($('.add-fullscreen')).not.toHaveData('qtip');
|
||||
});
|
||||
|
||||
it('create video volume control', function() {
|
||||
expect(videoVolumeControl).toBeDefined();
|
||||
expect(videoVolumeControl.el).toHaveClass('volume');
|
||||
@@ -187,10 +131,6 @@
|
||||
initialize();
|
||||
});
|
||||
|
||||
it('add the tooltip to fullscreen button', function() {
|
||||
expect($('.add-fullscreen')).toHaveData('qtip');
|
||||
});
|
||||
|
||||
it('controls are in paused state', function() {
|
||||
expect(videoControl.isPlaying).toBe(false);
|
||||
});
|
||||
@@ -433,11 +373,9 @@
|
||||
expect(state.setSpeed).toHaveBeenCalledWith('0.75', false);
|
||||
});
|
||||
|
||||
// Not relevant any more.
|
||||
// Not relevant any more:
|
||||
//
|
||||
// it('tell video caption that the speed has changed', function() {
|
||||
// expect(this.player.caption.currentSpeed).toEqual('0.75');
|
||||
// });
|
||||
// expect( "tell video caption that the speed has changed" ) ...
|
||||
});
|
||||
|
||||
describe('when the video is playing', function() {
|
||||
@@ -548,8 +486,9 @@
|
||||
expect(true).toBe(false);
|
||||
}
|
||||
|
||||
// The below test has been replaced by above trickery.
|
||||
// expect($('.vidtime')).toHaveHtml('1:00 / 1:01');
|
||||
// The below test has been replaced by above trickery:
|
||||
//
|
||||
// expect($('.vidtime')).toHaveHtml('1:00 / 1:01');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -39,21 +39,6 @@
|
||||
|
||||
it('build the seek handle', function() {
|
||||
expect(videoProgressSlider.handle).toBe('.slider .ui-slider-handle');
|
||||
expect($.fn.qtip).toHaveBeenCalledWith({
|
||||
content: "0:00",
|
||||
position: {
|
||||
my: 'bottom center',
|
||||
at: 'top center',
|
||||
container: videoProgressSlider.handle
|
||||
},
|
||||
hide: {
|
||||
delay: 700
|
||||
},
|
||||
style: {
|
||||
classes: 'ui-tooltip-slider',
|
||||
widget: true
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,7 +54,6 @@
|
||||
|
||||
// We can't expect $.fn.slider not to have been called,
|
||||
// because sliders are used in other parts of VideoAlpha.
|
||||
// expect($.fn.slider).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -94,43 +78,6 @@
|
||||
});
|
||||
|
||||
// Currently, the slider is not rebuilt if it does not exist.
|
||||
//
|
||||
// describe('when the slider was not already built', function() {
|
||||
// beforeEach(function() {
|
||||
// spyOn($.fn, 'slider').andCallThrough();
|
||||
// videoProgressSlider.slider = null;
|
||||
// videoPlayer.play();
|
||||
// });
|
||||
//
|
||||
// it('build the slider', function() {
|
||||
// expect(videoProgressSlider.slider).toBe('.slider');
|
||||
// expect($.fn.slider).toHaveBeenCalledWith({
|
||||
// range: 'min',
|
||||
// change: videoProgressSlider.onChange,
|
||||
// slide: videoProgressSlider.onSlide,
|
||||
// stop: videoProgressSlider.onStop
|
||||
// });
|
||||
// });
|
||||
//
|
||||
// it('build the seek handle', function() {
|
||||
// expect(videoProgressSlider.handle).toBe('.ui-slider-handle');
|
||||
// expect($.fn.qtip).toHaveBeenCalledWith({
|
||||
// content: "0:00",
|
||||
// position: {
|
||||
// my: 'bottom center',
|
||||
// at: 'top center',
|
||||
// container: videoProgressSlider.handle
|
||||
// },
|
||||
// hide: {
|
||||
// delay: 700
|
||||
// },
|
||||
// style: {
|
||||
// classes: 'ui-tooltip-slider',
|
||||
// widget: true
|
||||
// }
|
||||
// });
|
||||
// });
|
||||
// });
|
||||
});
|
||||
|
||||
describe('updatePlayTime', function() {
|
||||
@@ -181,10 +128,6 @@
|
||||
expect(videoProgressSlider.frozen).toBeTruthy();
|
||||
});
|
||||
|
||||
it('update the tooltip', function() {
|
||||
expect($.fn.qtip).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('trigger seek event', function() {
|
||||
expect(videoPlayer.onSlideSeek).toHaveBeenCalled();
|
||||
expect(videoPlayer.currentTime).toEqual(20);
|
||||
@@ -199,9 +142,6 @@
|
||||
value: 20
|
||||
});
|
||||
});
|
||||
it('update the tooltip', function() {
|
||||
expect($.fn.qtip).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onStop', function() {
|
||||
@@ -228,18 +168,6 @@
|
||||
expect(videoProgressSlider.frozen).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateTooltip', function() {
|
||||
beforeEach(function() {
|
||||
initialize();
|
||||
spyOn($.fn, 'slider').andCallThrough();
|
||||
videoProgressSlider.updateTooltip(90);
|
||||
});
|
||||
|
||||
it('set the tooltip value', function() {
|
||||
expect($.fn.qtip).toHaveBeenCalledWith('option', 'content.text', '1:30');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
}).call(this);
|
||||
|
||||
@@ -90,19 +90,7 @@
|
||||
// detect (and do not do anything) if there is a request for a speed that
|
||||
// is already set.
|
||||
//
|
||||
// describe('when new speed is the same', function() {
|
||||
// beforeEach(function() {
|
||||
// initialize();
|
||||
// videoSpeedControl.setSpeed(1.0);
|
||||
// spyOn(videoPlayer, 'onSpeedChange').andCallThrough();
|
||||
//
|
||||
// $('li[data-speed="1.0"] a').click();
|
||||
// });
|
||||
//
|
||||
// it('does not trigger speedChange event', function() {
|
||||
// expect(videoPlayer.onSpeedChange).not.toHaveBeenCalled();
|
||||
// });
|
||||
// });
|
||||
// describe("when new speed is the same") ...
|
||||
|
||||
describe('when new speed is not the same', function() {
|
||||
beforeEach(function() {
|
||||
|
||||
@@ -39,7 +39,6 @@
|
||||
range: "min",
|
||||
min: 0,
|
||||
max: 100,
|
||||
/* value: 100, */
|
||||
value: videoVolumeControl.currentVolume,
|
||||
change: videoVolumeControl.onChange,
|
||||
slide: videoVolumeControl.onChange
|
||||
|
||||
@@ -3,6 +3,9 @@ class @HTMLEditingDescriptor
|
||||
|
||||
constructor: (element) ->
|
||||
@element = element;
|
||||
@base_asset_url = @element.find("#editor-tab").data('base-asset-url')
|
||||
if @base_asset_url == undefined
|
||||
@base_asset_url = null
|
||||
|
||||
@advanced_editor = CodeMirror.fromTextArea($(".edit-box", @element)[0], {
|
||||
mode: "text/html"
|
||||
@@ -25,6 +28,9 @@ class @HTMLEditingDescriptor
|
||||
convert_urls : false,
|
||||
# TODO: we should share this CSS with studio (and LMS)
|
||||
content_css : "/static/css/tiny-mce.css",
|
||||
# The default popup_css path uses an absolute path referencing page in which tinyMCE is being hosted.
|
||||
# Supply the correct relative path instead.
|
||||
popup_css: '/static/js/vendor/tiny_mce/themes/advanced/skins/default/dialog.css',
|
||||
formats : {
|
||||
# Disable h4, h5, and h6 styles as we don't have CSS for them.
|
||||
h4: {},
|
||||
@@ -47,7 +53,7 @@ class @HTMLEditingDescriptor
|
||||
setup : @setupTinyMCE,
|
||||
# Cannot get access to tinyMCE Editor instance (for focusing) until after it is rendered.
|
||||
# The tinyMCE callback passes in the editor as a paramter.
|
||||
init_instance_callback: @focusVisualEditor
|
||||
init_instance_callback: @initInstanceCallback
|
||||
})
|
||||
|
||||
@showingVisualEditor = true
|
||||
@@ -95,21 +101,34 @@ class @HTMLEditingDescriptor
|
||||
# Show the Advanced (codemirror) Editor. Pulled out as a helper method for unit testing.
|
||||
showAdvancedEditor: (visualEditor) ->
|
||||
if visualEditor.isDirty()
|
||||
@advanced_editor.setValue(visualEditor.getContent({no_events: 1}))
|
||||
content = @rewriteStaticLinks(visualEditor.getContent({no_events: 1}), @base_asset_url, '/static/')
|
||||
@advanced_editor.setValue(content)
|
||||
@advanced_editor.setCursor(0)
|
||||
@advanced_editor.refresh()
|
||||
@advanced_editor.focus()
|
||||
@showingVisualEditor = false
|
||||
|
||||
rewriteStaticLinks: (content, from, to) ->
|
||||
if from == null || to == null
|
||||
return content
|
||||
|
||||
regex = new RegExp(from, 'g')
|
||||
return content.replace(regex, to)
|
||||
|
||||
# Show the Visual (tinyMCE) Editor. Pulled out as a helper method for unit testing.
|
||||
showVisualEditor: (visualEditor) ->
|
||||
visualEditor.setContent(@advanced_editor.getValue())
|
||||
# In order for isDirty() to return true ONLY if edits have been made after setting the text,
|
||||
# both the startContent must be sync'ed up and the dirty flag set to false.
|
||||
visualEditor.startContent = visualEditor.getContent({format: "raw", no_events: 1});
|
||||
content = @rewriteStaticLinks(@advanced_editor.getValue(), '/static/', @base_asset_url)
|
||||
visualEditor.setContent(content)
|
||||
visualEditor.startContent = content
|
||||
@focusVisualEditor(visualEditor)
|
||||
@showingVisualEditor = true
|
||||
|
||||
initInstanceCallback: (visualEditor) =>
|
||||
visualEditor.setContent(@rewriteStaticLinks(@advanced_editor.getValue(), '/static/', @base_asset_url))
|
||||
@focusVisualEditor(visualEditor)
|
||||
|
||||
focusVisualEditor: (visualEditor) =>
|
||||
visualEditor.focus()
|
||||
# Need to mark editor as not dirty both when it is initially created and when we switch back to it.
|
||||
@@ -131,5 +150,5 @@ class @HTMLEditingDescriptor
|
||||
text = @advanced_editor.getValue()
|
||||
visualEditor = @getVisualEditor()
|
||||
if @showingVisualEditor and visualEditor.isDirty()
|
||||
text = visualEditor.getContent({no_events: 1})
|
||||
text = @rewriteStaticLinks(visualEditor.getContent({no_events: 1}), @base_asset_url, '/static/')
|
||||
data: text
|
||||
|
||||
125
common/lib/xmodule/xmodule/js/src/tabs/tabs-aggregator.coffee
Normal file
125
common/lib/xmodule/xmodule/js/src/tabs/tabs-aggregator.coffee
Normal file
@@ -0,0 +1,125 @@
|
||||
class @TabsEditingDescriptor
|
||||
@isInactiveClass : "is-inactive"
|
||||
|
||||
constructor: (element) ->
|
||||
@element = element;
|
||||
###
|
||||
Not tested on syncing of multiple editors of same type in tabs
|
||||
(Like many CodeMirrors).
|
||||
###
|
||||
|
||||
# hide editor/settings bar
|
||||
$('.component-edit-header').hide()
|
||||
|
||||
@$tabs = $(".tab", @element)
|
||||
@$content = $(".component-tab", @element)
|
||||
|
||||
@element.find('.editor-tabs .tab').each (index, value) =>
|
||||
$(value).on('click', @onSwitchEditor)
|
||||
|
||||
# If default visible tab is not setted or if were marked as current
|
||||
# more than 1 tab just first tab will be shown
|
||||
currentTab = @$tabs.filter('.current')
|
||||
currentTab = @$tabs.first() if currentTab.length isnt 1
|
||||
@html_id = @$tabs.closest('.wrapper-comp-editor').data('html_id')
|
||||
currentTab.trigger("click", [true, @html_id])
|
||||
|
||||
onSwitchEditor: (e, firstTime, html_id) =>
|
||||
e.preventDefault();
|
||||
|
||||
isInactiveClass = TabsEditingDescriptor.isInactiveClass
|
||||
$currentTarget = $(e.currentTarget)
|
||||
|
||||
if not $currentTarget.hasClass('current') or firstTime is true
|
||||
previousTab = null
|
||||
|
||||
@$tabs.each( (index, value) ->
|
||||
if $(value).hasClass('current')
|
||||
previousTab = $(value).html()
|
||||
)
|
||||
|
||||
# init and save data from previous tab
|
||||
TabsEditingDescriptor.Model.updateValue(@html_id, previousTab)
|
||||
|
||||
# Save data from editor in previous tab to editor in current tab here.
|
||||
# (to be implemented when there is a use case for this functionality)
|
||||
|
||||
# call onswitch
|
||||
onSwitchFunction = TabsEditingDescriptor.Model.modules[@html_id].tabSwitch[$currentTarget.text()]
|
||||
onSwitchFunction() if $.isFunction(onSwitchFunction)
|
||||
|
||||
@$tabs.removeClass('current')
|
||||
$currentTarget.addClass('current')
|
||||
|
||||
# Tabs are implemeted like anchors. Therefore we can use hash to find
|
||||
# corresponding content
|
||||
content_id = $currentTarget.attr('href')
|
||||
|
||||
@$content
|
||||
.addClass(isInactiveClass)
|
||||
.filter(content_id)
|
||||
.removeClass(isInactiveClass)
|
||||
|
||||
save: ->
|
||||
@element.off('click', '.editor-tabs .tab', @onSwitchEditor)
|
||||
current_tab = @$tabs.filter('.current').html()
|
||||
data: TabsEditingDescriptor.Model.getValue(@html_id, current_tab)
|
||||
|
||||
@Model :
|
||||
addModelUpdate : (id, tabName, modelUpdateFunction) ->
|
||||
###
|
||||
Function that registers 'modelUpdate' functions of every tab.
|
||||
These functions are used to update value, which will be returned
|
||||
by calling save on component.
|
||||
###
|
||||
@initialize(id)
|
||||
@modules[id].modelUpdate[tabName] = modelUpdateFunction
|
||||
|
||||
addOnSwitch : (id, tabName, onSwitchFunction) ->
|
||||
###
|
||||
Function that registers functions invoked when switching
|
||||
to particular tab.
|
||||
###
|
||||
@initialize(id)
|
||||
@modules[id].tabSwitch[tabName] = onSwitchFunction
|
||||
|
||||
updateValue : (id, tabName) ->
|
||||
###
|
||||
Function that invokes when switching tabs.
|
||||
It ensures that data from previous tab is stored.
|
||||
If new tab need this data, it should retrieve it from
|
||||
stored value.
|
||||
###
|
||||
@initialize(id)
|
||||
modelUpdateFunction = @modules[id]['modelUpdate'][tabName]
|
||||
@modules[id]['value'] = modelUpdateFunction() if $.isFunction(modelUpdateFunction)
|
||||
|
||||
getValue : (id, tabName) ->
|
||||
###
|
||||
Retrieves stored data on component save.
|
||||
1. When we switching tabs - previous tab data is always saved to @[id].value
|
||||
2. If current tab have registered 'modelUpdate' method, it should be invoked 1st.
|
||||
(If we have edited in 1st tab, then switched to 2nd, 2nd tab should
|
||||
care about getting data from @[id].value in onSwitch.)
|
||||
###
|
||||
if not @modules[id]
|
||||
return null
|
||||
if $.isFunction(@modules[id]['modelUpdate'][tabName])
|
||||
return @modules[id]['modelUpdate'][tabName]()
|
||||
else
|
||||
if typeof @modules[id]['value'] is 'undefined'
|
||||
return null
|
||||
else
|
||||
return @modules[id]['value']
|
||||
|
||||
# html_id's of descriptors will be stored in modules variable as
|
||||
# containers for callbacks.
|
||||
modules: {}
|
||||
|
||||
initialize : (id) ->
|
||||
###
|
||||
Initialize objects per id. Id is html_id of descriptor.
|
||||
###
|
||||
@modules[id] = @modules[id] or {}
|
||||
@modules[id].tabSwitch = @modules[id]['tabSwitch'] or {}
|
||||
@modules[id].modelUpdate = @modules[id]['modelUpdate'] or {}
|
||||
@@ -93,14 +93,7 @@ function (VideoPlayer) {
|
||||
|
||||
fadeOutTimeout: 1400,
|
||||
|
||||
availableQualities: ['hd720', 'hd1080', 'highres'],
|
||||
|
||||
qTipConfig: {
|
||||
position: {
|
||||
my: 'top right',
|
||||
at: 'top center'
|
||||
}
|
||||
}
|
||||
availableQualities: ['hd720', 'hd1080', 'highres']
|
||||
};
|
||||
|
||||
if (!(_parseYouTubeIDs(state))) {
|
||||
@@ -148,7 +141,7 @@ function (VideoPlayer) {
|
||||
// Option
|
||||
// this.config.show_captions = true | false
|
||||
//
|
||||
// defines whether to turn off/on the captions altogether. User will not have the ability to turn them on/off.
|
||||
// Defines whether or not captions are shown on first viewing.
|
||||
//
|
||||
// Option
|
||||
// this.hide_captions = true | false
|
||||
|
||||
@@ -318,7 +318,14 @@ function (HTML5Video) {
|
||||
availablePlaybackRates = this.videoPlayer.player.getAvailablePlaybackRates();
|
||||
if ((this.currentPlayerMode === 'html5') && (this.videoType === 'youtube')) {
|
||||
if (availablePlaybackRates.length === 1) {
|
||||
restartUsingFlash(this);
|
||||
// This condition is needed in cases when Firefox version is less than 20. In those versions
|
||||
// HTML5 playback could only happen at 1 speed (no speed changing). Therefore, in this case,
|
||||
// we need to switch back to Flash.
|
||||
//
|
||||
// This might also happen in other browsers, therefore when we have 1 speed available, we fall
|
||||
// back to Flash.
|
||||
|
||||
_restartUsingFlash(this);
|
||||
|
||||
return;
|
||||
} else if (availablePlaybackRates.length > 1) {
|
||||
|
||||
@@ -53,9 +53,6 @@ function () {
|
||||
|
||||
if (!onTouchBasedDevice()) {
|
||||
state.videoControl.pause();
|
||||
|
||||
state.videoControl.playPauseEl.qtip(state.config.qTipConfig);
|
||||
state.videoControl.fullScreenEl.qtip(state.config.qTipConfig);
|
||||
} else {
|
||||
state.videoControl.play();
|
||||
}
|
||||
@@ -77,7 +74,8 @@ function () {
|
||||
$(document).on('keyup', state.videoControl.exitFullScreen);
|
||||
|
||||
if (state.videoType === 'html5') {
|
||||
state.el.on('mousemove', state.videoControl.showControls)
|
||||
state.el.on('mousemove', state.videoControl.showControls);
|
||||
state.el.on('keydown', state.videoControl.showControls);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,10 +43,6 @@ function () {
|
||||
|
||||
state.videoQualityControl.el.show();
|
||||
state.videoQualityControl.quality = null;
|
||||
|
||||
if (!onTouchBasedDevice()) {
|
||||
state.videoQualityControl.el.qtip(state.config.qTipConfig);
|
||||
}
|
||||
}
|
||||
|
||||
// function _bindHandlers(state)
|
||||
|
||||
@@ -32,9 +32,7 @@ function () {
|
||||
// get the 'state' object as a context.
|
||||
function _makeFunctionsPublic(state) {
|
||||
state.videoProgressSlider.onSlide = _.bind(onSlide, state);
|
||||
state.videoProgressSlider.onChange = _.bind(onChange, state);
|
||||
state.videoProgressSlider.onStop = _.bind(onStop, state);
|
||||
state.videoProgressSlider.updateTooltip = _.bind(updateTooltip, state);
|
||||
state.videoProgressSlider.updatePlayTime = _.bind(updatePlayTime, state);
|
||||
//Added for tests -- JM
|
||||
state.videoProgressSlider.buildSlider = _.bind(buildSlider, state);
|
||||
@@ -56,22 +54,6 @@ function () {
|
||||
|
||||
function _buildHandle(state) {
|
||||
state.videoProgressSlider.handle = state.videoProgressSlider.el.find('.ui-slider-handle');
|
||||
|
||||
state.videoProgressSlider.handle.qtip({
|
||||
content: '' + Time.format(state.videoProgressSlider.slider.slider('value')),
|
||||
position: {
|
||||
my: 'bottom center',
|
||||
at: 'top center',
|
||||
container: state.videoProgressSlider.handle
|
||||
},
|
||||
hide: {
|
||||
delay: 700
|
||||
},
|
||||
style: {
|
||||
classes: 'ui-tooltip-slider',
|
||||
widget: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ***************************************************************
|
||||
@@ -83,7 +65,6 @@ function () {
|
||||
function buildSlider(state) {
|
||||
state.videoProgressSlider.slider = state.videoProgressSlider.el.slider({
|
||||
range: 'min',
|
||||
change: state.videoProgressSlider.onChange,
|
||||
slide: state.videoProgressSlider.onSlide,
|
||||
stop: state.videoProgressSlider.onStop
|
||||
});
|
||||
@@ -91,15 +72,10 @@ function () {
|
||||
|
||||
function onSlide(event, ui) {
|
||||
this.videoProgressSlider.frozen = true;
|
||||
this.videoProgressSlider.updateTooltip(ui.value);
|
||||
|
||||
this.trigger('videoPlayer.onSlideSeek', {'type': 'onSlideSeek', 'time': ui.value});
|
||||
}
|
||||
|
||||
function onChange(event, ui) {
|
||||
this.videoProgressSlider.updateTooltip(ui.value);
|
||||
}
|
||||
|
||||
function onStop(event, ui) {
|
||||
var _this = this;
|
||||
|
||||
@@ -112,10 +88,6 @@ function () {
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function updateTooltip(value) {
|
||||
this.videoProgressSlider.handle.qtip('option', 'content.text', '' + Time.format(value));
|
||||
}
|
||||
|
||||
//Changed for tests -- JM: Check if it is the cause of Chrome Bug Valera noticed
|
||||
function updatePlayTime(params) {
|
||||
if ((this.videoProgressSlider.slider) && (!this.videoProgressSlider.frozen)) {
|
||||
|
||||
@@ -61,6 +61,9 @@ function () {
|
||||
slide: state.videoVolumeControl.onChange
|
||||
});
|
||||
|
||||
// Make sure that we can focus the actual volume slider while Tabing.
|
||||
state.videoVolumeControl.volumeSliderEl.find('a').attr('tabindex', '0');
|
||||
|
||||
state.videoVolumeControl.el.toggleClass('muted', state.videoVolumeControl.currentVolume === 0);
|
||||
}
|
||||
|
||||
@@ -74,9 +77,21 @@ function () {
|
||||
$(this).addClass('open');
|
||||
});
|
||||
|
||||
state.videoVolumeControl.buttonEl.on('focus', function() {
|
||||
$(this).parent().addClass('open');
|
||||
});
|
||||
|
||||
state.videoVolumeControl.el.on('mouseleave', function() {
|
||||
$(this).removeClass('open');
|
||||
});
|
||||
|
||||
state.videoVolumeControl.buttonEl.on('blur', function() {
|
||||
state.videoVolumeControl.volumeSliderEl.find('a').focus();
|
||||
});
|
||||
|
||||
state.videoVolumeControl.volumeSliderEl.find('a').on('blur', function () {
|
||||
state.videoVolumeControl.el.removeClass('open');
|
||||
});
|
||||
}
|
||||
|
||||
// ***************************************************************
|
||||
|
||||
@@ -21,34 +21,41 @@ function () {
|
||||
|
||||
// function _makeFunctionsPublic(state)
|
||||
//
|
||||
// Functions which will be accessible via 'state' object. When called, these functions will
|
||||
// get the 'state' object as a context.
|
||||
// Functions which will be accessible via 'state' object. When called,
|
||||
// these functions will get the 'state' object as a context.
|
||||
function _makeFunctionsPublic(state) {
|
||||
state.videoSpeedControl.changeVideoSpeed = _.bind(changeVideoSpeed, state);
|
||||
state.videoSpeedControl.changeVideoSpeed = _.bind(
|
||||
changeVideoSpeed, state
|
||||
);
|
||||
state.videoSpeedControl.setSpeed = _.bind(setSpeed, state);
|
||||
state.videoSpeedControl.reRender = _.bind(reRender, state);
|
||||
}
|
||||
|
||||
// function _renderElements(state)
|
||||
//
|
||||
// Create any necessary DOM elements, attach them, and set their initial configuration. Also
|
||||
// make the created DOM elements available via the 'state' object. Much easier to work this
|
||||
// way - you don't have to do repeated jQuery element selects.
|
||||
// Create any necessary DOM elements, attach them, and set their
|
||||
// initial configuration. Also make the created DOM elements available
|
||||
// via the 'state' object. Much easier to work this way - you don't
|
||||
// have to do repeated jQuery element selects.
|
||||
function _renderElements(state) {
|
||||
state.videoSpeedControl.speeds = state.speeds;
|
||||
|
||||
state.videoSpeedControl.el = state.el.find('div.speeds');
|
||||
|
||||
state.videoSpeedControl.videoSpeedsEl = state.videoSpeedControl.el.find('.video_speeds');
|
||||
state.videoSpeedControl.videoSpeedsEl = state.videoSpeedControl.el
|
||||
.find('.video_speeds');
|
||||
|
||||
state.videoControl.secondaryControlsEl.prepend(state.videoSpeedControl.el);
|
||||
state.videoControl.secondaryControlsEl.prepend(
|
||||
state.videoSpeedControl.el
|
||||
);
|
||||
|
||||
$.each(state.videoSpeedControl.speeds, function(index, speed) {
|
||||
var link = '<a class="speed_link" href="#">' + speed + 'x</a>';
|
||||
|
||||
//var link = $('<a href="#">' + speed + 'x</a>');
|
||||
var link = '<a href="#">' + speed + 'x</a>';
|
||||
|
||||
state.videoSpeedControl.videoSpeedsEl.prepend($('<li data-speed="' + speed + '">' + link + '</li>'));
|
||||
state.videoSpeedControl.videoSpeedsEl
|
||||
.prepend(
|
||||
$('<li data-speed="' + speed + '">' + link + '</li>')
|
||||
);
|
||||
});
|
||||
|
||||
state.videoSpeedControl.setSpeed(state.speed);
|
||||
@@ -56,9 +63,11 @@ function () {
|
||||
|
||||
// function _bindHandlers(state)
|
||||
//
|
||||
// Bind any necessary function callbacks to DOM events (click, mousemove, etc.).
|
||||
// Bind any necessary function callbacks to DOM events (click,
|
||||
// mousemove, etc.).
|
||||
function _bindHandlers(state) {
|
||||
state.videoSpeedControl.videoSpeedsEl.find('a').on('click', state.videoSpeedControl.changeVideoSpeed);
|
||||
state.videoSpeedControl.videoSpeedsEl.find('a')
|
||||
.on('click', state.videoSpeedControl.changeVideoSpeed);
|
||||
|
||||
if (onTouchBasedDevice()) {
|
||||
state.videoSpeedControl.el.on('click', function(event) {
|
||||
@@ -77,18 +86,36 @@ function () {
|
||||
event.preventDefault();
|
||||
$(this).removeClass('open');
|
||||
});
|
||||
|
||||
state.videoSpeedControl.el.children('a')
|
||||
.on('focus', function () {
|
||||
$(this).parent().addClass('open');
|
||||
})
|
||||
.on('blur', function () {
|
||||
state.videoSpeedControl.videoSpeedsEl
|
||||
.find('a.speed_link:first')
|
||||
.focus();
|
||||
});
|
||||
|
||||
state.videoSpeedControl.videoSpeedsEl.find('a.speed_link:last')
|
||||
.on('blur', function () {
|
||||
state.videoSpeedControl.el.removeClass('open');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ***************************************************************
|
||||
// Public functions start here.
|
||||
// These are available via the 'state' object. Their context ('this' keyword) is the 'state' object.
|
||||
// The magic private function that makes them available and sets up their context is makeFunctionsPublic().
|
||||
// These are available via the 'state' object. Their context ('this'
|
||||
// keyword) is the 'state' object. The magic private function that makes
|
||||
// them available and sets up their context is makeFunctionsPublic().
|
||||
// ***************************************************************
|
||||
|
||||
function setSpeed(speed) {
|
||||
this.videoSpeedControl.videoSpeedsEl.find('li').removeClass('active');
|
||||
this.videoSpeedControl.videoSpeedsEl.find("li[data-speed='" + speed + "']").addClass('active');
|
||||
this.videoSpeedControl.videoSpeedsEl
|
||||
.find("li[data-speed='" + speed + "']")
|
||||
.addClass('active');
|
||||
this.videoSpeedControl.el.find('p.active').html('' + speed + 'x');
|
||||
}
|
||||
|
||||
@@ -102,10 +129,15 @@ function () {
|
||||
|
||||
this.videoSpeedControl.setSpeed(
|
||||
// To meet the API expected format.
|
||||
parseFloat(this.videoSpeedControl.currentSpeed).toFixed(2).replace(/\.00$/, '.0')
|
||||
parseFloat(this.videoSpeedControl.currentSpeed)
|
||||
.toFixed(2)
|
||||
.replace(/\.00$/, '.0')
|
||||
);
|
||||
|
||||
this.trigger('videoPlayer.onSpeedChange', this.videoSpeedControl.currentSpeed);
|
||||
this.trigger(
|
||||
'videoPlayer.onSpeedChange',
|
||||
this.videoSpeedControl.currentSpeed
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +151,6 @@ function () {
|
||||
$.each(this.videoSpeedControl.speeds, function(index, speed) {
|
||||
var link, listItem;
|
||||
|
||||
//link = $('<a href="#">' + speed + 'x</a>');
|
||||
link = '<a href="#">' + speed + 'x</a>';
|
||||
|
||||
listItem = $('<li data-speed="' + speed + '">' + link + '</li>');
|
||||
@@ -131,7 +162,11 @@ function () {
|
||||
_this.videoSpeedControl.videoSpeedsEl.prepend(listItem);
|
||||
});
|
||||
|
||||
this.videoSpeedControl.videoSpeedsEl.find('a').on('click', this.videoSpeedControl.changeVideoSpeed);
|
||||
this.videoSpeedControl.videoSpeedsEl.find('a')
|
||||
.on('click', this.videoSpeedControl.changeVideoSpeed);
|
||||
|
||||
// TODO: After the control was re-rendered, we should attach 'focus'
|
||||
// and 'blur' events once more.
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
@@ -109,6 +109,7 @@ function () {
|
||||
|
||||
if (this.videoType === 'html5') {
|
||||
this.el.on('mousemove', this.videoCaption.autoShowCaptions);
|
||||
this.el.on('keydown', this.videoCaption.autoShowCaptions);
|
||||
|
||||
// Moving slider on subtitles is not a mouse move,
|
||||
// but captions and controls should be showed.
|
||||
@@ -122,6 +123,10 @@ function () {
|
||||
|
||||
this.videoCaption.hideCaptions(this.hide_captions);
|
||||
|
||||
if (!this.youtubeId('1.0')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$.ajaxWithPrefix({
|
||||
url: _this.videoCaption.captionURL(),
|
||||
notifyOnError: false,
|
||||
|
||||
@@ -60,10 +60,7 @@ function (
|
||||
VideoProgressSlider(state);
|
||||
VideoVolumeControl(state);
|
||||
VideoSpeedControl(state);
|
||||
|
||||
if (state.config.show_captions) {
|
||||
VideoCaption(state);
|
||||
}
|
||||
VideoCaption(state);
|
||||
|
||||
// Because the 'state' object is only available inside this closure, we will also make
|
||||
// it available to the caller by returning it. This is necessary so that we can test
|
||||
|
||||
@@ -1,10 +1,150 @@
|
||||
import re
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.mongo import MongoModuleStore
|
||||
from xmodule.modulestore.inheritance import own_metadata
|
||||
|
||||
import logging
|
||||
|
||||
|
||||
def _prefix_only_url_replace_regex(prefix):
|
||||
"""
|
||||
Match static urls in quotes that don't end in '?raw'.
|
||||
|
||||
To anyone contemplating making this more complicated:
|
||||
http://xkcd.com/1171/
|
||||
"""
|
||||
return r"""
|
||||
(?x) # flags=re.VERBOSE
|
||||
(?P<quote>\\?['"]) # the opening quotes
|
||||
(?P<prefix>{prefix}) # the prefix
|
||||
(?P<rest>.*?) # everything else in the url
|
||||
(?P=quote) # the first matching closing quote
|
||||
""".format(prefix=re.escape(prefix))
|
||||
|
||||
|
||||
def _prefix_and_category_url_replace_regex(prefix):
|
||||
"""
|
||||
Match static urls in quotes that don't end in '?raw'.
|
||||
|
||||
To anyone contemplating making this more complicated:
|
||||
http://xkcd.com/1171/
|
||||
"""
|
||||
return r"""
|
||||
(?x) # flags=re.VERBOSE
|
||||
(?P<quote>\\?['"]) # the opening quotes
|
||||
(?P<prefix>{prefix}) # the prefix
|
||||
(?P<category>[^/]+)/
|
||||
(?P<rest>.*?) # everything else in the url
|
||||
(?P=quote) # the first matching closing quote
|
||||
""".format(prefix=re.escape(prefix))
|
||||
|
||||
|
||||
def rewrite_nonportable_content_links(source_course_id, dest_course_id, text):
|
||||
"""
|
||||
Does a regex replace on non-portable links:
|
||||
/c4x/<org>/<course>/asset/<name> -> /static/<name>
|
||||
/jump_to/i4x://<org>/<course>/<category>/<name> -> /jump_to_id/<id>
|
||||
|
||||
"""
|
||||
|
||||
org, course, run = source_course_id.split("/")
|
||||
dest_org, dest_course, dest_run = dest_course_id.split("/")
|
||||
|
||||
def portable_asset_link_subtitution(match):
|
||||
quote = match.group('quote')
|
||||
rest = match.group('rest')
|
||||
return quote + '/static/' + rest + quote
|
||||
|
||||
def portable_jump_to_link_substitution(match):
|
||||
quote = match.group('quote')
|
||||
rest = match.group('rest')
|
||||
return quote + '/jump_to_id/' + rest + quote
|
||||
|
||||
def generic_courseware_link_substitution(match):
|
||||
quote = match.group('quote')
|
||||
rest = match.group('rest')
|
||||
dest_generic_courseware_lik_base = '/courses/{org}/{course}/{run}/'.format(
|
||||
org=dest_org, course=dest_course, run=dest_run
|
||||
)
|
||||
return quote + dest_generic_courseware_lik_base + rest + quote
|
||||
|
||||
course_location = Location(['i4x', org, course, 'course', run])
|
||||
|
||||
# NOTE: ultimately link updating is not a hard requirement, so if something blows up with
|
||||
# the regex subsitution, log the error and continue
|
||||
try:
|
||||
c4x_link_base = '{0}/'.format(StaticContent.get_base_url_path_for_course_assets(course_location))
|
||||
text = re.sub(_prefix_only_url_replace_regex(c4x_link_base), portable_asset_link_subtitution, text)
|
||||
except Exception, e:
|
||||
logging.warning("Error going regex subtituion %r on text = %r.\n\nError msg = %s", c4x_link_base, text, str(e))
|
||||
|
||||
try:
|
||||
jump_to_link_base = '/courses/{org}/{course}/{run}/jump_to/i4x://{org}/{course}/'.format(
|
||||
org=org, course=course, run=run
|
||||
)
|
||||
text = re.sub(_prefix_and_category_url_replace_regex(jump_to_link_base), portable_jump_to_link_substitution, text)
|
||||
except Exception, e:
|
||||
logging.warning("Error going regex subtituion %r on text = %r.\n\nError msg = %s", jump_to_link_base, text, str(e))
|
||||
|
||||
# Also, there commonly is a set of link URL's used in the format:
|
||||
# /courses/<org>/<course>/<run> which will be broken if migrated to a different course_id
|
||||
# so let's rewrite those, but the target will also be non-portable,
|
||||
#
|
||||
# Note: we only need to do this if we are changing course-id's
|
||||
#
|
||||
if source_course_id != dest_course_id:
|
||||
try:
|
||||
generic_courseware_link_base = '/courses/{org}/{course}/{run}/'.format(
|
||||
org=org, course=course, run=run
|
||||
)
|
||||
text = re.sub(_prefix_only_url_replace_regex(generic_courseware_link_base), portable_asset_link_subtitution, text)
|
||||
except Exception, e:
|
||||
logging.warning("Error going regex subtituion %r on text = %r.\n\nError msg = %s", generic_courseware_link_base, text, str(e))
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def _clone_modules(modulestore, modules, source_location, dest_location):
|
||||
for module in modules:
|
||||
original_loc = Location(module.location)
|
||||
|
||||
if original_loc.category != 'course':
|
||||
module.location = module.location._replace(
|
||||
tag=dest_location.tag, org=dest_location.org, course=dest_location.course)
|
||||
else:
|
||||
# on the course module we also have to update the module name
|
||||
module.location = module.location._replace(
|
||||
tag=dest_location.tag, org=dest_location.org, course=dest_location.course, name=dest_location.name)
|
||||
|
||||
print "Cloning module {0} to {1}....".format(original_loc, module.location)
|
||||
|
||||
# NOTE: usage of the the internal module.xblock_kvs._data does not include any 'default' values for the fields
|
||||
data = module.xblock_kvs._data
|
||||
if isinstance(data, basestring):
|
||||
data = rewrite_nonportable_content_links(
|
||||
source_location.course_id, dest_location.course_id, data)
|
||||
|
||||
modulestore.update_item(module.location, data)
|
||||
|
||||
# repoint children
|
||||
if module.has_children:
|
||||
new_children = []
|
||||
for child_loc_url in module.children:
|
||||
child_loc = Location(child_loc_url)
|
||||
child_loc = child_loc._replace(
|
||||
tag=dest_location.tag,
|
||||
org=dest_location.org,
|
||||
course=dest_location.course
|
||||
)
|
||||
new_children.append(child_loc.url())
|
||||
|
||||
modulestore.update_children(module.location, new_children)
|
||||
|
||||
# save metadata
|
||||
modulestore.update_metadata(module.location, own_metadata(module))
|
||||
|
||||
|
||||
def clone_course(modulestore, contentstore, source_location, dest_location, delete_original=False):
|
||||
# first check to see if the modulestore is Mongo backed
|
||||
if not isinstance(modulestore, MongoModuleStore):
|
||||
@@ -37,38 +177,10 @@ def clone_course(modulestore, contentstore, source_location, dest_location, dele
|
||||
# Get all modules under this namespace which is (tag, org, course) tuple
|
||||
|
||||
modules = modulestore.get_items([source_location.tag, source_location.org, source_location.course, None, None, None])
|
||||
_clone_modules(modulestore, modules, source_location, dest_location)
|
||||
|
||||
for module in modules:
|
||||
original_loc = Location(module.location)
|
||||
|
||||
if original_loc.category != 'course':
|
||||
module.location = module.location._replace(tag=dest_location.tag, org=dest_location.org,
|
||||
course=dest_location.course)
|
||||
else:
|
||||
# on the course module we also have to update the module name
|
||||
module.location = module.location._replace(tag=dest_location.tag, org=dest_location.org,
|
||||
course=dest_location.course, name=dest_location.name)
|
||||
|
||||
print "Cloning module {0} to {1}....".format(original_loc, module.location)
|
||||
|
||||
modulestore.update_item(module.location, module._model_data._kvs._data)
|
||||
|
||||
# repoint children
|
||||
if module.has_children:
|
||||
new_children = []
|
||||
for child_loc_url in module.children:
|
||||
child_loc = Location(child_loc_url)
|
||||
child_loc = child_loc._replace(
|
||||
tag=dest_location.tag,
|
||||
org=dest_location.org,
|
||||
course=dest_location.course
|
||||
)
|
||||
new_children.append(child_loc.url())
|
||||
|
||||
modulestore.update_children(module.location, new_children)
|
||||
|
||||
# save metadata
|
||||
modulestore.update_metadata(module.location, module._model_data._kvs._metadata)
|
||||
modules = modulestore.get_items([source_location.tag, source_location.org, source_location.course, None, None, 'draft'])
|
||||
_clone_modules(modulestore, modules, source_location, dest_location)
|
||||
|
||||
# now iterate through all of the assets and clone them
|
||||
# first the thumbnails
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import logging
|
||||
import os
|
||||
import mimetypes
|
||||
from lxml.html import rewrite_links as lxml_rewrite_links
|
||||
from path import path
|
||||
|
||||
from xblock.core import Scope
|
||||
@@ -11,6 +10,7 @@ from xmodule.modulestore import Location
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
from .inheritance import own_metadata
|
||||
from xmodule.errortracker import make_error_tracker
|
||||
from .store_utilities import rewrite_nonportable_content_links
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -61,117 +61,6 @@ def import_static_content(modules, course_loc, course_data_path, static_content_
|
||||
return remap_dict
|
||||
|
||||
|
||||
def verify_content_links(module, base_dir, static_content_store, link, remap_dict=None):
|
||||
if link.startswith('/static/'):
|
||||
# yes, then parse out the name
|
||||
path = link[len('/static/'):]
|
||||
|
||||
static_pathname = base_dir / path
|
||||
|
||||
if os.path.exists(static_pathname):
|
||||
try:
|
||||
content_loc = StaticContent.compute_location(module.location.org, module.location.course, path)
|
||||
filename = os.path.basename(path)
|
||||
mime_type = mimetypes.guess_type(filename)[0]
|
||||
|
||||
with open(static_pathname, 'rb') as f:
|
||||
data = f.read()
|
||||
|
||||
content = StaticContent(content_loc, filename, mime_type, data, import_path=path)
|
||||
|
||||
# first let's save a thumbnail so we can get back a thumbnail location
|
||||
(thumbnail_content, thumbnail_location) = static_content_store.generate_thumbnail(content)
|
||||
|
||||
if thumbnail_content is not None:
|
||||
content.thumbnail_location = thumbnail_location
|
||||
|
||||
#then commit the content
|
||||
static_content_store.save(content)
|
||||
|
||||
new_link = StaticContent.get_url_path_from_location(content_loc)
|
||||
|
||||
if remap_dict is not None:
|
||||
remap_dict[link] = new_link
|
||||
|
||||
return new_link
|
||||
except Exception, e:
|
||||
logging.exception('Skipping failed content load from {0}. Exception: {1}'.format(path, e))
|
||||
|
||||
return link
|
||||
|
||||
|
||||
def import_module_from_xml(modulestore, static_content_store, course_data_path, module, target_location_namespace=None, verbose=False):
|
||||
# remap module to the new namespace
|
||||
if target_location_namespace is not None:
|
||||
# This looks a bit wonky as we need to also change the 'name' of the imported course to be what
|
||||
# the caller passed in
|
||||
if module.location.category != 'course':
|
||||
module.location = module.location._replace(tag=target_location_namespace.tag, org=target_location_namespace.org,
|
||||
course=target_location_namespace.course)
|
||||
else:
|
||||
module.location = module.location._replace(tag=target_location_namespace.tag, org=target_location_namespace.org,
|
||||
course=target_location_namespace.course, name=target_location_namespace.name)
|
||||
|
||||
# then remap children pointers since they too will be re-namespaced
|
||||
if module.has_children:
|
||||
children_locs = module.children
|
||||
new_locs = []
|
||||
for child in children_locs:
|
||||
child_loc = Location(child)
|
||||
new_child_loc = child_loc._replace(tag=target_location_namespace.tag, org=target_location_namespace.org,
|
||||
course=target_location_namespace.course)
|
||||
|
||||
new_locs.append(new_child_loc.url())
|
||||
|
||||
module.children = new_locs
|
||||
|
||||
if hasattr(module, 'data'):
|
||||
# cdodge: now go through any link references to '/static/' and make sure we've imported
|
||||
# it as a StaticContent asset
|
||||
try:
|
||||
remap_dict = {}
|
||||
|
||||
# use the rewrite_links as a utility means to enumerate through all links
|
||||
# in the module data. We use that to load that reference into our asset store
|
||||
# IMPORTANT: There appears to be a bug in lxml.rewrite_link which makes us not be able to
|
||||
# do the rewrites natively in that code.
|
||||
# For example, what I'm seeing is <img src='foo.jpg' /> -> <img src='bar.jpg'>
|
||||
# Note the dropped element closing tag. This causes the LMS to fail when rendering modules - that's
|
||||
# no good, so we have to do this kludge
|
||||
if isinstance(module.data, str) or isinstance(module.data, unicode): # some module 'data' fields are non strings which blows up the link traversal code
|
||||
lxml_rewrite_links(module.data, lambda link: verify_content_links(module, course_data_path, static_content_store, link, remap_dict))
|
||||
|
||||
for key in remap_dict.keys():
|
||||
module.data = module.data.replace(key, remap_dict[key])
|
||||
|
||||
except Exception:
|
||||
logging.exception("failed to rewrite links on {0}. Continuing...".format(module.location))
|
||||
|
||||
modulestore.update_item(module.location, module.data)
|
||||
|
||||
if module.has_children:
|
||||
modulestore.update_children(module.location, module.children)
|
||||
|
||||
modulestore.update_metadata(module.location, own_metadata(module))
|
||||
|
||||
|
||||
def import_course_from_xml(modulestore, static_content_store, course_data_path, module, target_location_namespace=None, verbose=False):
|
||||
# cdodge: more hacks (what else). Seems like we have a problem when importing a course (like 6.002) which
|
||||
# does not have any tabs defined in the policy file. The import goes fine and then displays fine in LMS,
|
||||
# but if someone tries to add a new tab in the CMS, then the LMS barfs because it expects that -
|
||||
# if there is *any* tabs - then there at least needs to be some predefined ones
|
||||
if module.tabs is None or len(module.tabs) == 0:
|
||||
module.tabs = [{"type": "courseware"},
|
||||
{"type": "course_info", "name": "Course Info"},
|
||||
{"type": "discussion", "name": "Discussion"},
|
||||
{"type": "wiki", "name": "Wiki"}] # note, add 'progress' when we can support it on Edge
|
||||
|
||||
# a bit of a hack, but typically the "course image" which is shown on marketing pages is hard coded to /images/course_image.jpg
|
||||
# so let's make sure we import in case there are no other references to it in the modules
|
||||
verify_content_links(module, course_data_path, static_content_store, '/static/images/course_image.jpg')
|
||||
import_module_from_xml(modulestore, static_content_store, course_data_path, module, target_location_namespace, verbose=verbose)
|
||||
|
||||
|
||||
def import_from_xml(store, data_dir, course_dirs=None,
|
||||
default_class='xmodule.raw_module.RawDescriptor',
|
||||
load_error_modules=True, static_content_store=None, target_location_namespace=None,
|
||||
@@ -239,11 +128,8 @@ def import_from_xml(store, data_dir, course_dirs=None,
|
||||
{"type": "discussion", "name": "Discussion"},
|
||||
{"type": "wiki", "name": "Wiki"}] # note, add 'progress' when we can support it on Edge
|
||||
|
||||
import_module(module, store, course_data_path, static_content_store)
|
||||
|
||||
# a bit of a hack, but typically the "course image" which is shown on marketing pages is hard coded to /images/course_image.jpg
|
||||
# so let's make sure we import in case there are no other references to it in the modules
|
||||
verify_content_links(module, course_data_path, static_content_store, '/static/images/course_image.jpg')
|
||||
import_module(module, store, course_data_path, static_content_store, course_location,
|
||||
target_location_namespace or course_location)
|
||||
|
||||
course_items.append(module)
|
||||
|
||||
@@ -257,7 +143,6 @@ def import_from_xml(store, data_dir, course_dirs=None,
|
||||
|
||||
# finally loop through all the modules
|
||||
for module in xml_module_store.modules[course_id].itervalues():
|
||||
|
||||
if module.category == 'course':
|
||||
# we've already saved the course module up at the top of the loop
|
||||
# so just skip over it in the inner loop
|
||||
@@ -270,25 +155,31 @@ def import_from_xml(store, data_dir, course_dirs=None,
|
||||
if verbose:
|
||||
log.debug('importing module location {0}'.format(module.location))
|
||||
|
||||
import_module(module, store, course_data_path, static_content_store)
|
||||
import_module(module, store, course_data_path, static_content_store, course_location,
|
||||
target_location_namespace if target_location_namespace else course_location)
|
||||
|
||||
# now import any 'draft' items
|
||||
if draft_store is not None:
|
||||
import_course_draft(xml_module_store, store, draft_store, course_data_path,
|
||||
static_content_store, target_location_namespace if target_location_namespace is not None
|
||||
static_content_store, course_location, target_location_namespace if target_location_namespace
|
||||
else course_location)
|
||||
|
||||
finally:
|
||||
# turn back on all write signalling
|
||||
if pseudo_course_id in store.ignore_write_events_on_courses:
|
||||
store.ignore_write_events_on_courses.remove(pseudo_course_id)
|
||||
store.refresh_cached_metadata_inheritance_tree(target_location_namespace if
|
||||
target_location_namespace is not None else course_location)
|
||||
store.refresh_cached_metadata_inheritance_tree(
|
||||
target_location_namespace if target_location_namespace is not None else course_location
|
||||
)
|
||||
|
||||
return xml_module_store, course_items
|
||||
|
||||
|
||||
def import_module(module, store, course_data_path, static_content_store, allow_not_found=False):
|
||||
def import_module(module, store, course_data_path, static_content_store,
|
||||
source_course_location, dest_course_location, allow_not_found=False):
|
||||
|
||||
logging.debug('processing import of module {0}...'.format(module.location.url()))
|
||||
|
||||
content = {}
|
||||
for field in module.fields:
|
||||
if field.scope != Scope.content:
|
||||
@@ -302,30 +193,15 @@ def import_module(module, store, course_data_path, static_content_store, allow_n
|
||||
module_data = {}
|
||||
if 'data' in content:
|
||||
module_data = content['data']
|
||||
|
||||
# cdodge: now go through any link references to '/static/' and make sure we've imported
|
||||
# it as a StaticContent asset
|
||||
try:
|
||||
remap_dict = {}
|
||||
|
||||
# use the rewrite_links as a utility means to enumerate through all links
|
||||
# in the module data. We use that to load that reference into our asset store
|
||||
# IMPORTANT: There appears to be a bug in lxml.rewrite_link which makes us not be able to
|
||||
# do the rewrites natively in that code.
|
||||
# For example, what I'm seeing is <img src='foo.jpg' /> -> <img src='bar.jpg'>
|
||||
# Note the dropped element closing tag. This causes the LMS to fail when rendering modules - that's
|
||||
# no good, so we have to do this kludge
|
||||
if isinstance(module_data, str) or isinstance(module_data, unicode): # some module 'data' fields are non strings which blows up the link traversal code
|
||||
lxml_rewrite_links(module_data, lambda link: verify_content_links(module, course_data_path, static_content_store, link, remap_dict))
|
||||
|
||||
for key in remap_dict.keys():
|
||||
module_data = module_data.replace(key, remap_dict[key])
|
||||
|
||||
except Exception:
|
||||
logging.exception("failed to rewrite links on {0}. Continuing...".format(module.location))
|
||||
else:
|
||||
module_data = content
|
||||
|
||||
if isinstance(module_data, basestring):
|
||||
# we want to convert all 'non-portable' links in the module_data (if it is a string) to
|
||||
# portable strings (e.g. /static/)
|
||||
module_data = rewrite_nonportable_content_links(
|
||||
source_course_location.course_id, dest_course_location.course_id, module_data)
|
||||
|
||||
if allow_not_found:
|
||||
store.update_item(module.location, module_data, allow_not_found=allow_not_found)
|
||||
else:
|
||||
@@ -339,7 +215,7 @@ def import_module(module, store, course_data_path, static_content_store, allow_n
|
||||
store.update_metadata(module.location, dict(own_metadata(module)))
|
||||
|
||||
|
||||
def import_course_draft(xml_module_store, store, draft_store, course_data_path, static_content_store, target_location_namespace):
|
||||
def import_course_draft(xml_module_store, store, draft_store, course_data_path, static_content_store, source_location_namespace, target_location_namespace):
|
||||
'''
|
||||
This will import all the content inside of the 'drafts' folder, if it exists
|
||||
NOTE: This is not a full course import, basically in our current application only verticals (and downwards)
|
||||
@@ -396,7 +272,8 @@ def import_course_draft(xml_module_store, store, draft_store, course_data_path,
|
||||
del module.xml_attributes['parent_sequential_url']
|
||||
del module.xml_attributes['index_in_children_list']
|
||||
|
||||
import_module(module, draft_store, course_data_path, static_content_store, allow_not_found=True)
|
||||
import_module(module, draft_store, course_data_path, static_content_store,
|
||||
source_location_namespace, target_location_namespace, allow_not_found=True)
|
||||
for child in module.get_children():
|
||||
_import_module(child)
|
||||
|
||||
@@ -613,3 +490,57 @@ def perform_xlint(data_dir, course_dirs,
|
||||
print "This course can be imported successfully."
|
||||
|
||||
return err_cnt
|
||||
|
||||
|
||||
#
|
||||
# UNSURE IF THIS IS UNUSED CODE - IF SO NEEDS TO BE PRUNED. TO BE INVESTIGATED.
|
||||
#
|
||||
def import_module_from_xml(modulestore, static_content_store, course_data_path, module, target_location_namespace=None, verbose=False):
|
||||
# remap module to the new namespace
|
||||
if target_location_namespace is not None:
|
||||
# This looks a bit wonky as we need to also change the 'name' of the imported course to be what
|
||||
# the caller passed in
|
||||
if module.location.category != 'course':
|
||||
module.location = module.location._replace(tag=target_location_namespace.tag, org=target_location_namespace.org,
|
||||
course=target_location_namespace.course)
|
||||
else:
|
||||
module.location = module.location._replace(tag=target_location_namespace.tag, org=target_location_namespace.org,
|
||||
course=target_location_namespace.course, name=target_location_namespace.name)
|
||||
|
||||
# then remap children pointers since they too will be re-namespaced
|
||||
if module.has_children:
|
||||
children_locs = module.children
|
||||
new_locs = []
|
||||
for child in children_locs:
|
||||
child_loc = Location(child)
|
||||
new_child_loc = child_loc._replace(tag=target_location_namespace.tag, org=target_location_namespace.org,
|
||||
course=target_location_namespace.course)
|
||||
|
||||
new_locs.append(new_child_loc.url())
|
||||
|
||||
module.children = new_locs
|
||||
|
||||
if hasattr(module, 'data'):
|
||||
modulestore.update_item(module.location, module.data)
|
||||
|
||||
if module.has_children:
|
||||
modulestore.update_children(module.location, module.children)
|
||||
|
||||
modulestore.update_metadata(module.location, own_metadata(module))
|
||||
|
||||
|
||||
def import_course_from_xml(modulestore, static_content_store, course_data_path, module, target_location_namespace=None, verbose=False):
|
||||
# CDODGE: Is this unused code (along with import_module_from_xml)? I can't find any references to it. If so, then
|
||||
# we need to delete this apparently duplicate code.
|
||||
|
||||
# cdodge: more hacks (what else). Seems like we have a problem when importing a course (like 6.002) which
|
||||
# does not have any tabs defined in the policy file. The import goes fine and then displays fine in LMS,
|
||||
# but if someone tries to add a new tab in the CMS, then the LMS barfs because it expects that -
|
||||
# if there is *any* tabs - then there at least needs to be some predefined ones
|
||||
if module.tabs is None or len(module.tabs) == 0:
|
||||
module.tabs = [{"type": "courseware"},
|
||||
{"type": "course_info", "name": "Course Info"},
|
||||
{"type": "discussion", "name": "Discussion"},
|
||||
{"type": "wiki", "name": "Wiki"}] # note, add 'progress' when we can support it on Edge
|
||||
|
||||
import_module_from_xml(modulestore, static_content_store, course_data_path, module, target_location_namespace, verbose=verbose)
|
||||
|
||||
@@ -11,7 +11,7 @@ from .peer_grading_service import PeerGradingService, MockPeerGradingService
|
||||
import controller_query_service
|
||||
|
||||
from datetime import datetime
|
||||
from django.utils.timezone import UTC
|
||||
from pytz import UTC
|
||||
|
||||
log = logging.getLogger("mitx.courseware")
|
||||
|
||||
@@ -126,7 +126,7 @@ class OpenEndedChild(object):
|
||||
pass
|
||||
|
||||
def closed(self):
|
||||
if self.close_date is not None and datetime.now(UTC()) > self.close_date:
|
||||
if self.close_date is not None and datetime.now(UTC) > self.close_date:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@@ -36,21 +36,18 @@ class PeerGradingService(GradingService):
|
||||
return self.try_to_decode(response)
|
||||
|
||||
def get_next_submission(self, problem_location, grader_id):
|
||||
response = self.get(self.get_next_submission_url,
|
||||
{'location': problem_location, 'grader_id': grader_id})
|
||||
response = self.get(
|
||||
self.get_next_submission_url,
|
||||
{
|
||||
'location': problem_location,
|
||||
'grader_id': grader_id
|
||||
}
|
||||
)
|
||||
return self.try_to_decode(self._render_rubric(response))
|
||||
|
||||
def save_grade(self, location, grader_id, submission_id, score, feedback, submission_key, rubric_scores,
|
||||
submission_flagged):
|
||||
data = {'grader_id': grader_id,
|
||||
'submission_id': submission_id,
|
||||
'score': score,
|
||||
'feedback': feedback,
|
||||
'submission_key': submission_key,
|
||||
'location': location,
|
||||
'rubric_scores': rubric_scores,
|
||||
'rubric_scores_complete': True,
|
||||
'submission_flagged': submission_flagged}
|
||||
def save_grade(self, **kwargs):
|
||||
data = kwargs
|
||||
data.update({'rubric_scores_complete': True})
|
||||
return self.try_to_decode(self.post(self.save_grade_url, data))
|
||||
|
||||
def is_student_calibrated(self, problem_location, grader_id):
|
||||
@@ -62,16 +59,9 @@ class PeerGradingService(GradingService):
|
||||
response = self.get(self.show_calibration_essay_url, params)
|
||||
return self.try_to_decode(self._render_rubric(response))
|
||||
|
||||
def save_calibration_essay(self, problem_location, grader_id, calibration_essay_id, submission_key,
|
||||
score, feedback, rubric_scores):
|
||||
data = {'location': problem_location,
|
||||
'student_id': grader_id,
|
||||
'calibration_essay_id': calibration_essay_id,
|
||||
'submission_key': submission_key,
|
||||
'score': score,
|
||||
'feedback': feedback,
|
||||
'rubric_scores[]': rubric_scores,
|
||||
'rubric_scores_complete': True}
|
||||
def save_calibration_essay(self, **kwargs):
|
||||
data = kwargs
|
||||
data.update({'rubric_scores_complete': True})
|
||||
return self.try_to_decode(self.post(self.save_calibration_essay_url, data))
|
||||
|
||||
def get_problem_list(self, course_id, grader_id):
|
||||
@@ -100,16 +90,17 @@ without making actual service calls to the grading controller
|
||||
|
||||
class MockPeerGradingService(object):
|
||||
def get_next_submission(self, problem_location, grader_id):
|
||||
return {'success': True,
|
||||
'submission_id': 1,
|
||||
'submission_key': "",
|
||||
'student_response': 'fake student response',
|
||||
'prompt': 'fake submission prompt',
|
||||
'rubric': 'fake rubric',
|
||||
'max_score': 4}
|
||||
return {
|
||||
'success': True,
|
||||
'submission_id': 1,
|
||||
'submission_key': "",
|
||||
'student_response': 'fake student response',
|
||||
'prompt': 'fake submission prompt',
|
||||
'rubric': 'fake rubric',
|
||||
'max_score': 4
|
||||
}
|
||||
|
||||
def save_grade(self, location, grader_id, submission_id,
|
||||
score, feedback, submission_key, rubric_scores, submission_flagged):
|
||||
def save_grade(self, **kwargs):
|
||||
return {'success': True}
|
||||
|
||||
def is_student_calibrated(self, problem_location, grader_id):
|
||||
@@ -124,9 +115,7 @@ class MockPeerGradingService(object):
|
||||
'rubric': 'fake rubric',
|
||||
'max_score': 4}
|
||||
|
||||
def save_calibration_essay(self, problem_location, grader_id,
|
||||
calibration_essay_id, submission_key, score,
|
||||
feedback, rubric_scores):
|
||||
def save_calibration_essay(self, **kwargs):
|
||||
return {'success': True, 'actual_score': 2}
|
||||
|
||||
def get_problem_list(self, course_id, grader_id):
|
||||
|
||||
@@ -12,7 +12,7 @@ from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from .timeinfo import TimeInfo
|
||||
from xblock.core import Dict, String, Scope, Boolean, Integer, Float
|
||||
from xmodule.fields import Date
|
||||
from xmodule.fields import Date, Timedelta
|
||||
|
||||
from xmodule.open_ended_grading_classes.peer_grading_service import PeerGradingService, GradingServiceError, MockPeerGradingService
|
||||
from open_ended_grading_classes import combined_open_ended_rubric
|
||||
@@ -23,6 +23,7 @@ log = logging.getLogger(__name__)
|
||||
|
||||
EXTERNAL_GRADER_NO_CONTACT_ERROR = "Failed to contact external graders. Please notify course staff."
|
||||
|
||||
|
||||
class PeerGradingFields(object):
|
||||
use_for_single_location = Boolean(
|
||||
display_name="Show Single Problem",
|
||||
@@ -47,9 +48,8 @@ class PeerGradingFields(object):
|
||||
help="Due date that should be displayed.",
|
||||
default=None,
|
||||
scope=Scope.settings)
|
||||
grace_period_string = String(
|
||||
graceperiod = Timedelta(
|
||||
help="Amount of grace to give on the due date.",
|
||||
default=None,
|
||||
scope=Scope.settings
|
||||
)
|
||||
student_data_for_location = Dict(
|
||||
@@ -68,9 +68,11 @@ class PeerGradingFields(object):
|
||||
scope=Scope.settings,
|
||||
default="Peer Grading Interface"
|
||||
)
|
||||
data = String(help="Html contents to display for this module",
|
||||
data = String(
|
||||
help="Html contents to display for this module",
|
||||
default='<peergrading></peergrading>',
|
||||
scope=Scope.content)
|
||||
scope=Scope.content
|
||||
)
|
||||
|
||||
|
||||
class PeerGradingModule(PeerGradingFields, XModule):
|
||||
@@ -79,11 +81,14 @@ class PeerGradingModule(PeerGradingFields, XModule):
|
||||
"""
|
||||
_VERSION = 1
|
||||
|
||||
js = {'coffee': [resource_string(__name__, 'js/src/peergrading/peer_grading.coffee'),
|
||||
resource_string(__name__, 'js/src/peergrading/peer_grading_problem.coffee'),
|
||||
resource_string(__name__, 'js/src/collapsible.coffee'),
|
||||
resource_string(__name__, 'js/src/javascript_loader.coffee'),
|
||||
]}
|
||||
js = {
|
||||
'coffee': [
|
||||
resource_string(__name__, 'js/src/peergrading/peer_grading.coffee'),
|
||||
resource_string(__name__, 'js/src/peergrading/peer_grading_problem.coffee'),
|
||||
resource_string(__name__, 'js/src/collapsible.coffee'),
|
||||
resource_string(__name__, 'js/src/javascript_loader.coffee'),
|
||||
]
|
||||
}
|
||||
js_module_name = "PeerGrading"
|
||||
|
||||
css = {'scss': [resource_string(__name__, 'css/combinedopenended/display.scss')]}
|
||||
@@ -105,12 +110,12 @@ class PeerGradingModule(PeerGradingFields, XModule):
|
||||
log.error("Linked location {0} for peer grading module {1} does not exist".format(
|
||||
self.link_to_location, self.location))
|
||||
raise
|
||||
due_date = self.linked_problem._model_data.get('due', None)
|
||||
due_date = self.linked_problem.lms.due
|
||||
if due_date:
|
||||
self._model_data['due'] = due_date
|
||||
self.lms.due = due_date
|
||||
|
||||
try:
|
||||
self.timeinfo = TimeInfo(self.due, self.grace_period_string)
|
||||
self.timeinfo = TimeInfo(self.due, self.graceperiod)
|
||||
except Exception:
|
||||
log.error("Error parsing due date information in location {0}".format(self.location))
|
||||
raise
|
||||
@@ -134,7 +139,6 @@ class PeerGradingModule(PeerGradingFields, XModule):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _err_response(self, msg):
|
||||
"""
|
||||
Return a HttpResponse with a json dump with success=False, and the given error message.
|
||||
@@ -308,31 +312,22 @@ class PeerGradingModule(PeerGradingFields, XModule):
|
||||
error: if there was an error in the submission, this is the error message
|
||||
"""
|
||||
|
||||
required = set(['location', 'submission_id', 'submission_key', 'score', 'feedback', 'rubric_scores[]',
|
||||
'submission_flagged'])
|
||||
required = set(['location', 'submission_id', 'submission_key', 'score', 'feedback', 'rubric_scores[]', 'submission_flagged', 'answer_unknown'])
|
||||
success, message = self._check_required(data, required)
|
||||
if not success:
|
||||
return self._err_response(message)
|
||||
grader_id = self.system.anonymous_student_id
|
||||
|
||||
location = data.get('location')
|
||||
submission_id = data.get('submission_id')
|
||||
score = data.get('score')
|
||||
feedback = data.get('feedback')
|
||||
submission_key = data.get('submission_key')
|
||||
rubric_scores = data.getlist('rubric_scores[]')
|
||||
submission_flagged = data.get('submission_flagged')
|
||||
data_dict = {k:data.get(k) for k in required}
|
||||
data_dict['rubric_scores'] = data.getlist('rubric_scores[]')
|
||||
data_dict['grader_id'] = self.system.anonymous_student_id
|
||||
|
||||
try:
|
||||
response = self.peer_gs.save_grade(location, grader_id, submission_id,
|
||||
score, feedback, submission_key, rubric_scores, submission_flagged)
|
||||
response = self.peer_gs.save_grade(**data_dict)
|
||||
return response
|
||||
except GradingServiceError:
|
||||
# This is a dev_facing_error
|
||||
log.exception("""Error saving grade to open ended grading service. server url: {0}, location: {1}, submission_id:{2},
|
||||
submission_key: {3}, score: {4}"""
|
||||
.format(self.peer_gs.url,
|
||||
location, submission_id, submission_key, score)
|
||||
log.exception("""Error saving grade to open ended grading service. server url: {0}"""
|
||||
.format(self.peer_gs.url)
|
||||
)
|
||||
# This is a student_facing_error
|
||||
return {
|
||||
@@ -451,27 +446,21 @@ class PeerGradingModule(PeerGradingFields, XModule):
|
||||
success, message = self._check_required(data, required)
|
||||
if not success:
|
||||
return self._err_response(message)
|
||||
grader_id = self.system.anonymous_student_id
|
||||
|
||||
location = data.get('location')
|
||||
calibration_essay_id = data.get('submission_id')
|
||||
submission_key = data.get('submission_key')
|
||||
score = data.get('score')
|
||||
feedback = data.get('feedback')
|
||||
rubric_scores = data.getlist('rubric_scores[]')
|
||||
data_dict = {k:data.get(k) for k in required}
|
||||
data_dict['rubric_scores'] = data.getlist('rubric_scores[]')
|
||||
data_dict['student_id'] = self.system.anonymous_student_id
|
||||
data_dict['calibration_essay_id'] = data_dict['submission_id']
|
||||
|
||||
try:
|
||||
response = self.peer_gs.save_calibration_essay(location, grader_id, calibration_essay_id,
|
||||
submission_key, score, feedback, rubric_scores)
|
||||
response = self.peer_gs.save_calibration_essay(**data_dict)
|
||||
if 'actual_rubric' in response:
|
||||
rubric_renderer = combined_open_ended_rubric.CombinedOpenEndedRubric(self.system, True)
|
||||
response['actual_rubric'] = rubric_renderer.render_rubric(response['actual_rubric'])['html']
|
||||
return response
|
||||
except GradingServiceError:
|
||||
# This is a dev_facing_error
|
||||
log.exception(
|
||||
"Error saving calibration grade, location: {0}, submission_key: {1}, grader_id: {2}".format(
|
||||
location, submission_key, grader_id))
|
||||
log.exception("Error saving calibration grade")
|
||||
# This is a student_facing_error
|
||||
return self._err_response('There was an error saving your score. Please notify course staff.')
|
||||
|
||||
@@ -533,10 +522,10 @@ class PeerGradingModule(PeerGradingFields, XModule):
|
||||
problem_location = problem['location']
|
||||
descriptor = _find_corresponding_module_for_location(problem_location)
|
||||
if descriptor:
|
||||
problem['due'] = descriptor._model_data.get('due', None)
|
||||
grace_period_string = descriptor._model_data.get('graceperiod', None)
|
||||
problem['due'] = descriptor.lms.due
|
||||
grace_period = descriptor.lms.graceperiod
|
||||
try:
|
||||
problem_timeinfo = TimeInfo(problem['due'], grace_period_string)
|
||||
problem_timeinfo = TimeInfo(problem['due'], grace_period)
|
||||
except:
|
||||
log.error("Malformed due date or grace period string for location {0}".format(problem_location))
|
||||
raise
|
||||
@@ -629,5 +618,5 @@ class PeerGradingDescriptor(PeerGradingFields, RawDescriptor):
|
||||
@property
|
||||
def non_editable_metadata_fields(self):
|
||||
non_editable_fields = super(PeerGradingDescriptor, self).non_editable_metadata_fields
|
||||
non_editable_fields.extend([PeerGradingFields.due, PeerGradingFields.grace_period_string])
|
||||
non_editable_fields.extend([PeerGradingFields.due, PeerGradingFields.graceperiod])
|
||||
return non_editable_fields
|
||||
|
||||
@@ -4,6 +4,7 @@ from xmodule.xml_module import XmlDescriptor
|
||||
import logging
|
||||
import sys
|
||||
from xblock.core import String, Scope
|
||||
from exceptions import SerializationError
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -27,11 +28,11 @@ class RawDescriptor(XmlDescriptor, XMLEditingDescriptor):
|
||||
# re-raise
|
||||
lines = self.data.split('\n')
|
||||
line, offset = err.position
|
||||
msg = ("Unable to create xml for problem {loc}. "
|
||||
msg = ("Unable to create xml for module {loc}. "
|
||||
"Context: '{context}'".format(
|
||||
context=lines[line - 1][offset - 40:offset + 40],
|
||||
loc=self.location))
|
||||
raise Exception, msg, sys.exc_info()[2]
|
||||
raise SerializationError(self.location, msg)
|
||||
|
||||
|
||||
class EmptyDataRawDescriptor(XmlDescriptor, XMLEditingDescriptor):
|
||||
|
||||
@@ -53,7 +53,7 @@ def get_dummy_course(start, announcement=None, is_new=None, advertised_start=Non
|
||||
end = to_attrb('end', end)
|
||||
|
||||
start_xml = '''
|
||||
<course org="{org}" course="{course}"
|
||||
<course org="{org}" course="{course}" display_organization="{org}_display" display_coursenumber="{course}_display"
|
||||
graceperiod="1 day" url_name="test"
|
||||
start="{start}"
|
||||
{announcement}
|
||||
@@ -141,6 +141,16 @@ class IsNewCourseTestCase(unittest.TestCase):
|
||||
print "Checking start=%s advertised=%s" % (s[0], s[1])
|
||||
self.assertEqual(d.start_date_text, s[2])
|
||||
|
||||
def test_display_organization(self):
|
||||
descriptor = get_dummy_course(start='2012-12-02T12:00', is_new=True)
|
||||
self.assertNotEqual(descriptor.location.org, descriptor.display_org_with_default)
|
||||
self.assertEqual(descriptor.display_org_with_default, "{0}_display".format(ORG))
|
||||
|
||||
def test_display_coursenumber(self):
|
||||
descriptor = get_dummy_course(start='2012-12-02T12:00', is_new=True)
|
||||
self.assertNotEqual(descriptor.location.course, descriptor.display_number_with_default)
|
||||
self.assertEqual(descriptor.display_number_with_default, "{0}_display".format(COURSE))
|
||||
|
||||
def test_is_newish(self):
|
||||
descriptor = get_dummy_course(start='2012-12-02T12:00', is_new=True)
|
||||
assert(descriptor.is_newish is True)
|
||||
|
||||
63
common/lib/xmodule/xmodule/tests/test_editing_module.py
Normal file
63
common/lib/xmodule/xmodule/tests/test_editing_module.py
Normal file
@@ -0,0 +1,63 @@
|
||||
""" Tests for editing descriptors"""
|
||||
import unittest
|
||||
import os
|
||||
import logging
|
||||
|
||||
from mock import Mock
|
||||
from pkg_resources import resource_string
|
||||
from xmodule.editing_module import TabsEditingDescriptor
|
||||
|
||||
from .import get_test_system
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TabsEditingDescriptorTestCase(unittest.TestCase):
|
||||
""" Testing TabsEditingDescriptor"""
|
||||
|
||||
def setUp(self):
|
||||
super(TabsEditingDescriptorTestCase, self).setUp()
|
||||
system = get_test_system()
|
||||
system.render_template = Mock(return_value="<div>Test Template HTML</div>")
|
||||
self.tabs = [
|
||||
{
|
||||
'name': "Test_css",
|
||||
'template': "tabs/codemirror-edit.html",
|
||||
'current': True,
|
||||
'css': {
|
||||
'scss': [resource_string(__name__,
|
||||
'../../test_files/test_tabseditingdescriptor.scss')],
|
||||
'css': [resource_string(__name__,
|
||||
'../../test_files/test_tabseditingdescriptor.css')]
|
||||
}
|
||||
},
|
||||
{
|
||||
'name': "Subtitles",
|
||||
'template': "videoalpha/subtitles.html",
|
||||
},
|
||||
{
|
||||
'name': "Settings",
|
||||
'template': "tabs/video-metadata-edit-tab.html"
|
||||
}
|
||||
]
|
||||
|
||||
TabsEditingDescriptor.tabs = self.tabs
|
||||
self.descriptor = TabsEditingDescriptor(
|
||||
runtime=system,
|
||||
model_data={})
|
||||
|
||||
def test_get_css(self):
|
||||
"""test get_css"""
|
||||
css = self.descriptor.get_css()
|
||||
test_files_dir = os.path.dirname(__file__).replace('xmodule/tests', 'test_files')
|
||||
test_css_file = os.path.join(test_files_dir, 'test_tabseditingdescriptor.scss')
|
||||
with open(test_css_file) as new_css:
|
||||
added_css = new_css.read()
|
||||
self.assertEqual(css['scss'].pop(), added_css)
|
||||
self.assertEqual(css['css'].pop(), added_css)
|
||||
|
||||
def test_get_context(self):
|
||||
""""test get_context"""
|
||||
rendered_context = self.descriptor.get_context()
|
||||
self.assertListEqual(rendered_context['tabs'], self.tabs)
|
||||
|
||||
@@ -40,6 +40,8 @@ def strip_filenames(descriptor):
|
||||
for d in descriptor.get_children():
|
||||
strip_filenames(d)
|
||||
|
||||
descriptor.save()
|
||||
|
||||
|
||||
class RoundTripTestCase(unittest.TestCase):
|
||||
''' Check that our test courses roundtrip properly.
|
||||
|
||||
@@ -28,6 +28,7 @@ class PeerGradingModuleTest(unittest.TestCase, DummyModulestore):
|
||||
'feedback': "",
|
||||
'rubric_scores[]': [0, 1],
|
||||
'submission_flagged': False,
|
||||
'answer_unknown' : False,
|
||||
})
|
||||
|
||||
def setUp(self):
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#pylint: disable=W0212
|
||||
"""Test for Video Alpha Xmodule functional logic.
|
||||
These tests data readed from xml, not from mongo.
|
||||
These test data read from xml, not from mongo.
|
||||
|
||||
we have a ModuleStoreTestCase class defined in
|
||||
We have a ModuleStoreTestCase class defined in
|
||||
common/lib/xmodule/xmodule/modulestore/tests/django_utils.py. You can
|
||||
search for usages of this in the cms and lms tests for examples. You use
|
||||
this so that it will do things like point the modulestore setting to mongo,
|
||||
@@ -12,9 +13,15 @@ in common/lib/xmodule/xmodule/modulestore/tests/factories.py to create
|
||||
the course, section, subsection, unit, etc.
|
||||
"""
|
||||
|
||||
from xmodule.videoalpha_module import VideoAlphaDescriptor
|
||||
import unittest
|
||||
from . import LogicTest
|
||||
from lxml import etree
|
||||
from .import get_test_system
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.videoalpha_module import VideoAlphaDescriptor, _create_youtube_string
|
||||
from xmodule.video_module import VideoDescriptor
|
||||
from .test_import import DummySystem
|
||||
|
||||
from textwrap import dedent
|
||||
|
||||
|
||||
class VideoAlphaModuleTest(LogicTest):
|
||||
@@ -25,27 +32,338 @@ class VideoAlphaModuleTest(LogicTest):
|
||||
'data': '<videoalpha />'
|
||||
}
|
||||
|
||||
def test_get_timeframe_no_parameters(self):
|
||||
"Make sure that timeframe() works correctly w/o parameters"
|
||||
xmltree = etree.fromstring('<videoalpha>test</videoalpha>')
|
||||
output = self.xmodule.get_timeframe(xmltree)
|
||||
self.assertEqual(output, ('', ''))
|
||||
def test_parse_time_empty(self):
|
||||
"""Ensure parse_time returns correctly with None or empty string."""
|
||||
expected = ''
|
||||
self.assertEqual(VideoAlphaDescriptor._parse_time(None), expected)
|
||||
self.assertEqual(VideoAlphaDescriptor._parse_time(''), expected)
|
||||
|
||||
def test_get_timeframe_with_one_parameter(self):
|
||||
"Make sure that timeframe() works correctly with one parameter"
|
||||
xmltree = etree.fromstring(
|
||||
'<videoalpha start_time="00:04:07">test</videoalpha>'
|
||||
)
|
||||
output = self.xmodule.get_timeframe(xmltree)
|
||||
self.assertEqual(output, (247, ''))
|
||||
def test_parse_time(self):
|
||||
"""Ensure that times are parsed correctly into seconds."""
|
||||
expected = 247
|
||||
output = VideoAlphaDescriptor._parse_time('00:04:07')
|
||||
self.assertEqual(output, expected)
|
||||
|
||||
def test_get_timeframe_with_two_parameters(self):
|
||||
"Make sure that timeframe() works correctly with two parameters"
|
||||
xmltree = etree.fromstring(
|
||||
'''<videoalpha
|
||||
start_time="00:04:07"
|
||||
end_time="13:04:39"
|
||||
>test</videoalpha>'''
|
||||
def test_parse_youtube(self):
|
||||
"""Test parsing old-style Youtube ID strings into a dict."""
|
||||
youtube_str = '0.75:jNCf2gIqpeE,1.00:ZwkTiUPN0mg,1.25:rsq9auxASqI,1.50:kMyNdzVHHgg'
|
||||
output = VideoAlphaDescriptor._parse_youtube(youtube_str)
|
||||
self.assertEqual(output, {'0.75': 'jNCf2gIqpeE',
|
||||
'1.00': 'ZwkTiUPN0mg',
|
||||
'1.25': 'rsq9auxASqI',
|
||||
'1.50': 'kMyNdzVHHgg'})
|
||||
|
||||
def test_parse_youtube_one_video(self):
|
||||
"""
|
||||
Ensure that all keys are present and missing speeds map to the
|
||||
empty string.
|
||||
"""
|
||||
youtube_str = '0.75:jNCf2gIqpeE'
|
||||
output = VideoAlphaDescriptor._parse_youtube(youtube_str)
|
||||
self.assertEqual(output, {'0.75': 'jNCf2gIqpeE',
|
||||
'1.00': '',
|
||||
'1.25': '',
|
||||
'1.50': ''})
|
||||
|
||||
def test_parse_youtube_key_format(self):
|
||||
"""
|
||||
Make sure that inconsistent speed keys are parsed correctly.
|
||||
"""
|
||||
youtube_str = '1.00:p2Q6BrNhdh8'
|
||||
youtube_str_hack = '1.0:p2Q6BrNhdh8'
|
||||
self.assertEqual(
|
||||
VideoAlphaDescriptor._parse_youtube(youtube_str),
|
||||
VideoAlphaDescriptor._parse_youtube(youtube_str_hack)
|
||||
)
|
||||
output = self.xmodule.get_timeframe(xmltree)
|
||||
self.assertEqual(output, (247, 47079))
|
||||
|
||||
def test_parse_youtube_empty(self):
|
||||
"""
|
||||
Some courses have empty youtube attributes, so we should handle
|
||||
that well.
|
||||
"""
|
||||
self.assertEqual(
|
||||
VideoAlphaDescriptor._parse_youtube(''),
|
||||
{'0.75': '',
|
||||
'1.00': '',
|
||||
'1.25': '',
|
||||
'1.50': ''}
|
||||
)
|
||||
|
||||
|
||||
class VideoAlphaDescriptorTest(unittest.TestCase):
|
||||
"""Test for VideoAlphaDescriptor"""
|
||||
|
||||
def setUp(self):
|
||||
system = get_test_system()
|
||||
self.descriptor = VideoAlphaDescriptor(
|
||||
runtime=system,
|
||||
model_data={})
|
||||
|
||||
def test_get_context(self):
|
||||
""""test get_context"""
|
||||
correct_tabs = [
|
||||
{
|
||||
'name': "Settings",
|
||||
'template': "tabs/metadata-edit-tab.html",
|
||||
'current': True
|
||||
}
|
||||
]
|
||||
rendered_context = self.descriptor.get_context()
|
||||
self.assertListEqual(rendered_context['tabs'], correct_tabs)
|
||||
|
||||
def test_create_youtube_string(self):
|
||||
"""
|
||||
Test that Youtube ID strings are correctly created when writing
|
||||
back out to XML.
|
||||
"""
|
||||
system = DummySystem(load_error_modules=True)
|
||||
location = Location(["i4x", "edX", "videoalpha", "default", "SampleProblem1"])
|
||||
model_data = {'location': location}
|
||||
descriptor = VideoAlphaDescriptor(system, model_data)
|
||||
descriptor.youtube_id_0_75 = 'izygArpw-Qo'
|
||||
descriptor.youtube_id_1_0 = 'p2Q6BrNhdh8'
|
||||
descriptor.youtube_id_1_25 = '1EeWXzPdhSA'
|
||||
descriptor.youtube_id_1_5 = 'rABDYkeK0x8'
|
||||
expected = "0.75:izygArpw-Qo,1.00:p2Q6BrNhdh8,1.25:1EeWXzPdhSA,1.50:rABDYkeK0x8"
|
||||
self.assertEqual(_create_youtube_string(descriptor), expected)
|
||||
|
||||
def test_create_youtube_string_missing(self):
|
||||
"""
|
||||
Test that Youtube IDs which aren't explicitly set aren't included
|
||||
in the output string.
|
||||
"""
|
||||
system = DummySystem(load_error_modules=True)
|
||||
location = Location(["i4x", "edX", "videoalpha", "default", "SampleProblem1"])
|
||||
model_data = {'location': location}
|
||||
descriptor = VideoAlphaDescriptor(system, model_data)
|
||||
descriptor.youtube_id_0_75 = 'izygArpw-Qo'
|
||||
descriptor.youtube_id_1_0 = 'p2Q6BrNhdh8'
|
||||
descriptor.youtube_id_1_25 = '1EeWXzPdhSA'
|
||||
expected = "0.75:izygArpw-Qo,1.00:p2Q6BrNhdh8,1.25:1EeWXzPdhSA"
|
||||
self.assertEqual(_create_youtube_string(descriptor), expected)
|
||||
|
||||
|
||||
class VideoAlphaDescriptorImportTestCase(unittest.TestCase):
|
||||
"""
|
||||
Make sure that VideoAlphaDescriptor can import an old XML-based video correctly.
|
||||
"""
|
||||
|
||||
def assert_attributes_equal(self, video, attrs):
|
||||
"""
|
||||
Assert that `video` has the correct attributes. `attrs` is a map
|
||||
of {metadata_field: value}.
|
||||
"""
|
||||
for key, value in attrs.items():
|
||||
self.assertEquals(getattr(video, key), value)
|
||||
|
||||
def test_constructor(self):
|
||||
sample_xml = '''
|
||||
<videoalpha display_name="Test Video"
|
||||
youtube="1.0:p2Q6BrNhdh8,0.75:izygArpw-Qo,1.25:1EeWXzPdhSA,1.5:rABDYkeK0x8"
|
||||
show_captions="false"
|
||||
start_time="00:00:01"
|
||||
end_time="00:01:00">
|
||||
<source src="http://www.example.com/source.mp4"/>
|
||||
<source src="http://www.example.com/source.ogg"/>
|
||||
<track src="http://www.example.com/track"/>
|
||||
</videoalpha>
|
||||
'''
|
||||
location = Location(["i4x", "edX", "videoalpha", "default",
|
||||
"SampleProblem1"])
|
||||
model_data = {'data': sample_xml,
|
||||
'location': location}
|
||||
system = DummySystem(load_error_modules=True)
|
||||
descriptor = VideoAlphaDescriptor(system, model_data)
|
||||
self.assert_attributes_equal(descriptor, {
|
||||
'youtube_id_0_75': 'izygArpw-Qo',
|
||||
'youtube_id_1_0': 'p2Q6BrNhdh8',
|
||||
'youtube_id_1_25': '1EeWXzPdhSA',
|
||||
'youtube_id_1_5': 'rABDYkeK0x8',
|
||||
'show_captions': False,
|
||||
'start_time': 1.0,
|
||||
'end_time': 60,
|
||||
'track': 'http://www.example.com/track',
|
||||
'html5_sources': ['http://www.example.com/source.mp4', 'http://www.example.com/source.ogg'],
|
||||
'data': ''
|
||||
})
|
||||
|
||||
def test_from_xml(self):
|
||||
module_system = DummySystem(load_error_modules=True)
|
||||
xml_data = '''
|
||||
<videoalpha display_name="Test Video"
|
||||
youtube="1.0:p2Q6BrNhdh8,0.75:izygArpw-Qo,1.25:1EeWXzPdhSA,1.5:rABDYkeK0x8"
|
||||
show_captions="false"
|
||||
start_time="00:00:01"
|
||||
end_time="00:01:00">
|
||||
<source src="http://www.example.com/source.mp4"/>
|
||||
<track src="http://www.example.com/track"/>
|
||||
</videoalpha>
|
||||
'''
|
||||
output = VideoAlphaDescriptor.from_xml(xml_data, module_system)
|
||||
self.assert_attributes_equal(output, {
|
||||
'youtube_id_0_75': 'izygArpw-Qo',
|
||||
'youtube_id_1_0': 'p2Q6BrNhdh8',
|
||||
'youtube_id_1_25': '1EeWXzPdhSA',
|
||||
'youtube_id_1_5': 'rABDYkeK0x8',
|
||||
'show_captions': False,
|
||||
'start_time': 1.0,
|
||||
'end_time': 60,
|
||||
'track': 'http://www.example.com/track',
|
||||
'source': 'http://www.example.com/source.mp4',
|
||||
'html5_sources': ['http://www.example.com/source.mp4'],
|
||||
'data': ''
|
||||
})
|
||||
|
||||
def test_from_xml_missing_attributes(self):
|
||||
"""
|
||||
Ensure that attributes have the right values if they aren't
|
||||
explicitly set in XML.
|
||||
"""
|
||||
module_system = DummySystem(load_error_modules=True)
|
||||
xml_data = '''
|
||||
<videoalpha display_name="Test Video"
|
||||
youtube="1.0:p2Q6BrNhdh8,1.25:1EeWXzPdhSA"
|
||||
show_captions="true">
|
||||
<source src="http://www.example.com/source.mp4"/>
|
||||
<track src="http://www.example.com/track"/>
|
||||
</videoalpha>
|
||||
'''
|
||||
output = VideoAlphaDescriptor.from_xml(xml_data, module_system)
|
||||
self.assert_attributes_equal(output, {
|
||||
'youtube_id_0_75': '',
|
||||
'youtube_id_1_0': 'p2Q6BrNhdh8',
|
||||
'youtube_id_1_25': '1EeWXzPdhSA',
|
||||
'youtube_id_1_5': '',
|
||||
'show_captions': True,
|
||||
'start_time': 0.0,
|
||||
'end_time': 0.0,
|
||||
'track': 'http://www.example.com/track',
|
||||
'source': 'http://www.example.com/source.mp4',
|
||||
'html5_sources': ['http://www.example.com/source.mp4'],
|
||||
'data': ''
|
||||
})
|
||||
|
||||
def test_from_xml_no_attributes(self):
|
||||
"""
|
||||
Make sure settings are correct if none are explicitly set in XML.
|
||||
"""
|
||||
module_system = DummySystem(load_error_modules=True)
|
||||
xml_data = '<videoalpha></videoalpha>'
|
||||
output = VideoAlphaDescriptor.from_xml(xml_data, module_system)
|
||||
self.assert_attributes_equal(output, {
|
||||
'youtube_id_0_75': '',
|
||||
'youtube_id_1_0': 'OEoXaMPEzfM',
|
||||
'youtube_id_1_25': '',
|
||||
'youtube_id_1_5': '',
|
||||
'show_captions': True,
|
||||
'start_time': 0.0,
|
||||
'end_time': 0.0,
|
||||
'track': '',
|
||||
'source': '',
|
||||
'html5_sources': [],
|
||||
'data': ''
|
||||
})
|
||||
|
||||
def test_old_video_format(self):
|
||||
"""
|
||||
Test backwards compatibility with VideoModule's XML format.
|
||||
"""
|
||||
module_system = DummySystem(load_error_modules=True)
|
||||
xml_data = """
|
||||
<videoalpha display_name="Test Video"
|
||||
youtube="1.0:p2Q6BrNhdh8,0.75:izygArpw-Qo,1.25:1EeWXzPdhSA,1.5:rABDYkeK0x8"
|
||||
show_captions="false"
|
||||
from="00:00:01"
|
||||
to="00:01:00">
|
||||
<source src="http://www.example.com/source.mp4"/>
|
||||
<track src="http://www.example.com/track"/>
|
||||
</videoalpha>
|
||||
"""
|
||||
output = VideoAlphaDescriptor.from_xml(xml_data, module_system)
|
||||
self.assert_attributes_equal(output, {
|
||||
'youtube_id_0_75': 'izygArpw-Qo',
|
||||
'youtube_id_1_0': 'p2Q6BrNhdh8',
|
||||
'youtube_id_1_25': '1EeWXzPdhSA',
|
||||
'youtube_id_1_5': 'rABDYkeK0x8',
|
||||
'show_captions': False,
|
||||
'start_time': 1.0,
|
||||
'end_time': 60,
|
||||
'track': 'http://www.example.com/track',
|
||||
'html5_sources': ['http://www.example.com/source.mp4'],
|
||||
'data': ''
|
||||
})
|
||||
|
||||
def test_old_video_data(self):
|
||||
"""
|
||||
Ensure that Video Alpha is able to read VideoModule's model data.
|
||||
"""
|
||||
module_system = DummySystem(load_error_modules=True)
|
||||
xml_data = """
|
||||
<video display_name="Test Video"
|
||||
youtube="1.0:p2Q6BrNhdh8,0.75:izygArpw-Qo,1.25:1EeWXzPdhSA,1.5:rABDYkeK0x8"
|
||||
show_captions="false"
|
||||
from="00:00:01"
|
||||
to="00:01:00">
|
||||
<source src="http://www.example.com/source.mp4"/>
|
||||
<track src="http://www.example.com/track"/>
|
||||
</video>
|
||||
"""
|
||||
video = VideoDescriptor.from_xml(xml_data, module_system)
|
||||
video_alpha = VideoAlphaDescriptor(module_system, video._model_data)
|
||||
self.assert_attributes_equal(video_alpha, {
|
||||
'youtube_id_0_75': 'izygArpw-Qo',
|
||||
'youtube_id_1_0': 'p2Q6BrNhdh8',
|
||||
'youtube_id_1_25': '1EeWXzPdhSA',
|
||||
'youtube_id_1_5': 'rABDYkeK0x8',
|
||||
'show_captions': False,
|
||||
'start_time': 1.0,
|
||||
'end_time': 60,
|
||||
'track': 'http://www.example.com/track',
|
||||
'html5_sources': ['http://www.example.com/source.mp4'],
|
||||
'data': ''
|
||||
})
|
||||
|
||||
|
||||
class VideoAlphaExportTestCase(unittest.TestCase):
|
||||
"""
|
||||
Make sure that VideoAlphaDescriptor can export itself to XML
|
||||
correctly.
|
||||
"""
|
||||
|
||||
def test_export_to_xml(self):
|
||||
"""Test that we write the correct XML on export."""
|
||||
module_system = DummySystem(load_error_modules=True)
|
||||
location = Location(["i4x", "edX", "videoalpha", "default", "SampleProblem1"])
|
||||
desc = VideoAlphaDescriptor(module_system, {'location': location})
|
||||
|
||||
desc.youtube_id_0_75 = 'izygArpw-Qo'
|
||||
desc.youtube_id_1_0 = 'p2Q6BrNhdh8'
|
||||
desc.youtube_id_1_25 = '1EeWXzPdhSA'
|
||||
desc.youtube_id_1_5 = 'rABDYkeK0x8'
|
||||
desc.show_captions = False
|
||||
desc.start_time = 1.0
|
||||
desc.end_time = 60
|
||||
desc.track = 'http://www.example.com/track'
|
||||
desc.html5_sources = ['http://www.example.com/source.mp4', 'http://www.example.com/source.ogg']
|
||||
|
||||
xml = desc.export_to_xml(None) # We don't use the `resource_fs` parameter
|
||||
expected = dedent('''\
|
||||
<videoalpha display_name="Video Alpha" start_time="0:00:01" youtube="0.75:izygArpw-Qo,1.00:p2Q6BrNhdh8,1.25:1EeWXzPdhSA,1.50:rABDYkeK0x8" show_captions="false" end_time="0:01:00">
|
||||
<source src="http://www.example.com/source.mp4"/>
|
||||
<source src="http://www.example.com/source.ogg"/>
|
||||
<track src="http://www.example.com/track"/>
|
||||
</videoalpha>
|
||||
''')
|
||||
|
||||
self.assertEquals(expected, xml)
|
||||
|
||||
def test_export_to_xml_empty_parameters(self):
|
||||
"""Test XML export with defaults."""
|
||||
module_system = DummySystem(load_error_modules=True)
|
||||
location = Location(["i4x", "edX", "videoalpha", "default", "SampleProblem1"])
|
||||
desc = VideoAlphaDescriptor(module_system, {'location': location})
|
||||
|
||||
xml = desc.export_to_xml(None)
|
||||
expected = '<videoalpha display_name="Video Alpha" youtube="1.00:OEoXaMPEzfM" show_captions="true"/>\n'
|
||||
|
||||
self.assertEquals(expected, xml)
|
||||
|
||||
@@ -39,7 +39,8 @@ class TestFields(object):
|
||||
float_non_select = Float(scope=Scope.settings, default=.999, values={'min': 0, 'step': .3})
|
||||
# Used for testing that Booleans get mapped to select type
|
||||
boolean_select = Boolean(scope=Scope.settings)
|
||||
|
||||
# Used for testing Lists
|
||||
list_field = List(scope=Scope.settings, default=[])
|
||||
|
||||
class EditableMetadataFieldsTest(unittest.TestCase):
|
||||
def test_display_name_field(self):
|
||||
@@ -63,7 +64,7 @@ class EditableMetadataFieldsTest(unittest.TestCase):
|
||||
def test_integer_field(self):
|
||||
descriptor = self.get_descriptor({'max_attempts': '7'})
|
||||
editable_fields = descriptor.editable_metadata_fields
|
||||
self.assertEqual(6, len(editable_fields))
|
||||
self.assertEqual(7, len(editable_fields))
|
||||
self.assert_field_values(
|
||||
editable_fields, 'max_attempts', TestFields.max_attempts,
|
||||
explicitly_set=True, inheritable=False, value=7, default_value=1000, type='Integer',
|
||||
@@ -137,6 +138,12 @@ class EditableMetadataFieldsTest(unittest.TestCase):
|
||||
type='Float', options={'min': 0, 'step': .3}
|
||||
)
|
||||
|
||||
self.assert_field_values(
|
||||
editable_fields, 'list_field', TestFields.list_field,
|
||||
explicitly_set=False, inheritable=False, value=[], default_value=[],
|
||||
type='List'
|
||||
)
|
||||
|
||||
# Start of helper methods
|
||||
def get_xml_editable_fields(self, model_data):
|
||||
system = get_test_system()
|
||||
|
||||
@@ -14,20 +14,23 @@ class TimeInfo(object):
|
||||
|
||||
"""
|
||||
_delta_standin = Timedelta()
|
||||
def __init__(self, due_date, grace_period_string):
|
||||
def __init__(self, due_date, grace_period_string_or_timedelta):
|
||||
if due_date is not None:
|
||||
self.display_due_date = due_date
|
||||
|
||||
else:
|
||||
self.display_due_date = None
|
||||
|
||||
if grace_period_string is not None and self.display_due_date:
|
||||
try:
|
||||
self.grace_period = TimeInfo._delta_standin.from_json(grace_period_string)
|
||||
self.close_date = self.display_due_date + self.grace_period
|
||||
except:
|
||||
log.error("Error parsing the grace period {0}".format(grace_period_string))
|
||||
raise
|
||||
if grace_period_string_or_timedelta is not None and self.display_due_date:
|
||||
if isinstance(grace_period_string_or_timedelta, basestring):
|
||||
try:
|
||||
self.grace_period = TimeInfo._delta_standin.from_json(grace_period_string_or_timedelta)
|
||||
except:
|
||||
log.error("Error parsing the grace period {0}".format(grace_period_string_or_timedelta))
|
||||
raise
|
||||
else:
|
||||
self.grace_period = grace_period_string_or_timedelta
|
||||
self.close_date = self.display_due_date + self.grace_period
|
||||
else:
|
||||
self.grace_period = None
|
||||
self.close_date = self.display_due_date
|
||||
|
||||
@@ -14,41 +14,105 @@ import json
|
||||
import logging
|
||||
|
||||
from lxml import etree
|
||||
from pkg_resources import resource_string, resource_listdir
|
||||
from pkg_resources import resource_string
|
||||
|
||||
from django.http import Http404
|
||||
from django.conf import settings
|
||||
|
||||
from xmodule.x_module import XModule
|
||||
from xmodule.raw_module import RawDescriptor
|
||||
from xmodule.editing_module import TabsEditingDescriptor
|
||||
from xmodule.raw_module import EmptyDataRawDescriptor
|
||||
from xmodule.modulestore.mongo import MongoModuleStore
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
from xblock.core import Integer, Scope, String
|
||||
from xblock.core import Scope, String, Boolean, Float, List, Integer
|
||||
|
||||
import datetime
|
||||
import time
|
||||
import textwrap
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VideoAlphaFields(object):
|
||||
"""Fields for `VideoAlphaModule` and `VideoAlphaDescriptor`."""
|
||||
data = String(help="XML data for the problem",
|
||||
default=textwrap.dedent('''\
|
||||
<videoalpha show_captions="true" sub="name_of_file" youtube="0.75:JMD_ifUUfsU,1.0:OEoXaMPEzfM,1.25:AKqURZnYqpk,1.50:DYpADpL7jAY" >
|
||||
<source src="https://s3.amazonaws.com/edx-course-videos/edx-intro/edX-FA12-cware-1_100.mp4"/>
|
||||
<source src="https://s3.amazonaws.com/edx-course-videos/edx-intro/edX-FA12-cware-1_100.webm"/>
|
||||
<source src="https://s3.amazonaws.com/edx-course-videos/edx-intro/edX-FA12-cware-1_100.ogv"/>
|
||||
</videoalpha>'''),
|
||||
scope=Scope.content)
|
||||
position = Integer(help="Current position in the video", scope=Scope.user_state, default=0)
|
||||
display_name = String(
|
||||
display_name="Display Name", help="Display name for this module",
|
||||
display_name="Display Name", help="Display name for this module.",
|
||||
default="Video Alpha",
|
||||
scope=Scope.settings
|
||||
)
|
||||
position = Integer(
|
||||
help="Current position in the video",
|
||||
scope=Scope.user_state,
|
||||
default=0
|
||||
)
|
||||
show_captions = Boolean(
|
||||
help="This controls whether or not captions are shown by default.",
|
||||
display_name="Show Captions",
|
||||
scope=Scope.settings,
|
||||
default=True
|
||||
)
|
||||
# TODO: This should be moved to Scope.content, but this will
|
||||
# require data migration to support the old video module.
|
||||
youtube_id_1_0 = String(
|
||||
help="This is the Youtube ID reference for the normal speed video.",
|
||||
display_name="Youtube ID",
|
||||
scope=Scope.settings,
|
||||
default="OEoXaMPEzfM"
|
||||
)
|
||||
youtube_id_0_75 = String(
|
||||
help="The Youtube ID for the .75x speed video.",
|
||||
display_name="Youtube ID for .75x speed",
|
||||
scope=Scope.settings,
|
||||
default=""
|
||||
)
|
||||
youtube_id_1_25 = String(
|
||||
help="The Youtube ID for the 1.25x speed video.",
|
||||
display_name="Youtube ID for 1.25x speed",
|
||||
scope=Scope.settings,
|
||||
default=""
|
||||
)
|
||||
youtube_id_1_5 = String(
|
||||
help="The Youtube ID for the 1.5x speed video.",
|
||||
display_name="Youtube ID for 1.5x speed",
|
||||
scope=Scope.settings,
|
||||
default=""
|
||||
)
|
||||
start_time = Float(
|
||||
help="Start time for the video.",
|
||||
display_name="Start Time",
|
||||
scope=Scope.settings,
|
||||
default=0.0
|
||||
)
|
||||
end_time = Float(
|
||||
help="End time for the video.",
|
||||
display_name="End Time",
|
||||
scope=Scope.settings,
|
||||
default=0.0
|
||||
)
|
||||
source = String(
|
||||
help="The external URL to download the video. This appears as a link beneath the video.",
|
||||
display_name="Download Video",
|
||||
scope=Scope.settings,
|
||||
default=""
|
||||
)
|
||||
html5_sources = List(
|
||||
help="A list of filenames to be used with HTML5 video. The first supported filetype will be displayed.",
|
||||
display_name="Video Sources",
|
||||
scope=Scope.settings,
|
||||
default=[]
|
||||
)
|
||||
track = String(
|
||||
help="The external URL to download the subtitle track. This appears as a link beneath the video.",
|
||||
display_name="Download Track",
|
||||
scope=Scope.settings,
|
||||
default=""
|
||||
)
|
||||
sub = String(
|
||||
help="The name of the subtitle track (for non-Youtube videos).",
|
||||
display_name="HTML5 Subtitles",
|
||||
scope=Scope.settings,
|
||||
default=""
|
||||
)
|
||||
|
||||
|
||||
class VideoAlphaModule(VideoAlphaFields, XModule):
|
||||
@@ -84,72 +148,6 @@ class VideoAlphaModule(VideoAlphaFields, XModule):
|
||||
css = {'scss': [resource_string(__name__, 'css/videoalpha/display.scss')]}
|
||||
js_module_name = "VideoAlpha"
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
XModule.__init__(self, *args, **kwargs)
|
||||
xmltree = etree.fromstring(self.data)
|
||||
|
||||
# Front-end expects an empty string, or a properly formatted string with YouTube IDs.
|
||||
self.youtube_streams = xmltree.get('youtube', '')
|
||||
|
||||
self.sub = xmltree.get('sub')
|
||||
|
||||
self.autoplay = xmltree.get('autoplay') or ''
|
||||
if self.autoplay.lower() not in ['true', 'false']:
|
||||
self.autoplay = 'true'
|
||||
|
||||
self.position = 0
|
||||
self.show_captions = xmltree.get('show_captions', 'true')
|
||||
self.sources = {
|
||||
'main': self._get_source(xmltree),
|
||||
'mp4': self._get_source(xmltree, ['mp4']),
|
||||
'webm': self._get_source(xmltree, ['webm']),
|
||||
'ogv': self._get_source(xmltree, ['ogv']),
|
||||
}
|
||||
self.track = self._get_track(xmltree)
|
||||
self.start_time, self.end_time = self.get_timeframe(xmltree)
|
||||
|
||||
def _get_source(self, xmltree, exts=None):
|
||||
"""Find the first valid source, which ends with one of `exts`."""
|
||||
exts = ['mp4', 'ogv', 'avi', 'webm'] if exts is None else exts
|
||||
condition = lambda src: any([src.endswith(ext) for ext in exts])
|
||||
return self._get_first_external(xmltree, 'source', condition)
|
||||
|
||||
def _get_track(self, xmltree):
|
||||
"""Find the first valid track."""
|
||||
return self._get_first_external(xmltree, 'track')
|
||||
|
||||
def _get_first_external(self, xmltree, tag, condition=bool):
|
||||
"""Will return the first 'valid' element of the given tag.
|
||||
'valid' means that `condition('src' attribute) == True`
|
||||
"""
|
||||
result = None
|
||||
|
||||
for element in xmltree.findall(tag):
|
||||
src = element.get('src')
|
||||
if condition(src):
|
||||
result = src
|
||||
break
|
||||
return result
|
||||
|
||||
def get_timeframe(self, xmltree):
|
||||
""" Converts 'start_time' and 'end_time' parameters in video tag to seconds.
|
||||
If there are no parameters, returns empty string. """
|
||||
|
||||
def parse_time(str_time):
|
||||
"""Converts s in '12:34:45' format to seconds. If s is
|
||||
None, returns empty string"""
|
||||
if str_time is None:
|
||||
return ''
|
||||
else:
|
||||
obj_time = time.strptime(str_time, '%H:%M:%S')
|
||||
return datetime.timedelta(
|
||||
hours=obj_time.tm_hour,
|
||||
minutes=obj_time.tm_min,
|
||||
seconds=obj_time.tm_sec
|
||||
).total_seconds()
|
||||
|
||||
return parse_time(xmltree.get('start_time')), parse_time(xmltree.get('end_time'))
|
||||
|
||||
def handle_ajax(self, dispatch, data):
|
||||
"""This is not being called right now and we raise 404 error."""
|
||||
log.debug(u"GET {0}".format(data))
|
||||
@@ -168,25 +166,202 @@ class VideoAlphaModule(VideoAlphaFields, XModule):
|
||||
# cdodge: filesystem static content support.
|
||||
caption_asset_path = "/static/subs/"
|
||||
|
||||
get_ext = lambda filename: filename.rpartition('.')[-1]
|
||||
sources = {get_ext(src): src for src in self.html5_sources}
|
||||
sources['main'] = self.source
|
||||
|
||||
return self.system.render_template('videoalpha.html', {
|
||||
'youtube_streams': self.youtube_streams,
|
||||
'youtube_streams': _create_youtube_string(self),
|
||||
'id': self.location.html_id(),
|
||||
'sub': self.sub,
|
||||
'autoplay': self.autoplay,
|
||||
'sources': self.sources,
|
||||
'sources': sources,
|
||||
'track': self.track,
|
||||
'display_name': self.display_name_with_default,
|
||||
# This won't work when we move to data that
|
||||
# isn't on the filesystem
|
||||
'data_dir': getattr(self, 'data_dir', None),
|
||||
'caption_asset_path': caption_asset_path,
|
||||
'show_captions': self.show_captions,
|
||||
'show_captions': json.dumps(self.show_captions),
|
||||
'start': self.start_time,
|
||||
'end': self.end_time,
|
||||
'autoplay': settings.MITX_FEATURES.get('AUTOPLAY_VIDEOS', True)
|
||||
})
|
||||
|
||||
|
||||
class VideoAlphaDescriptor(VideoAlphaFields, RawDescriptor):
|
||||
class VideoAlphaDescriptor(VideoAlphaFields, TabsEditingDescriptor, EmptyDataRawDescriptor):
|
||||
"""Descriptor for `VideoAlphaModule`."""
|
||||
module_class = VideoAlphaModule
|
||||
|
||||
tabs = [
|
||||
# {
|
||||
# 'name': "Subtitles",
|
||||
# 'template': "videoalpha/subtitles.html",
|
||||
# },
|
||||
{
|
||||
'name': "Settings",
|
||||
'template': "tabs/metadata-edit-tab.html",
|
||||
'current': True
|
||||
}
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(VideoAlphaDescriptor, self).__init__(*args, **kwargs)
|
||||
# For backwards compatibility -- if we've got XML data, parse
|
||||
# it out and set the metadata fields
|
||||
if self.data:
|
||||
model_data = VideoAlphaDescriptor._parse_video_xml(self.data)
|
||||
self._model_data.update(model_data)
|
||||
del self.data
|
||||
|
||||
@classmethod
|
||||
def from_xml(cls, xml_data, system, org=None, course=None):
|
||||
"""
|
||||
Creates an instance of this descriptor from the supplied xml_data.
|
||||
This may be overridden by subclasses
|
||||
|
||||
xml_data: A string of xml that will be translated into data and children for
|
||||
this module
|
||||
system: A DescriptorSystem for interacting with external resources
|
||||
org and course are optional strings that will be used in the generated modules
|
||||
url identifiers
|
||||
"""
|
||||
# Calling from_xml of XmlDescritor, to get right Location, when importing from XML
|
||||
video = super(VideoAlphaDescriptor, cls).from_xml(xml_data, system, org, course)
|
||||
return video
|
||||
|
||||
def export_to_xml(self, resource_fs):
|
||||
"""
|
||||
Returns an xml string representing this module.
|
||||
"""
|
||||
xml = etree.Element('videoalpha')
|
||||
attrs = {
|
||||
'display_name': self.display_name,
|
||||
'show_captions': json.dumps(self.show_captions),
|
||||
'youtube': _create_youtube_string(self),
|
||||
'start_time': datetime.timedelta(seconds=self.start_time),
|
||||
'end_time': datetime.timedelta(seconds=self.end_time),
|
||||
'sub': self.sub
|
||||
}
|
||||
for key, value in attrs.items():
|
||||
if value:
|
||||
xml.set(key, str(value))
|
||||
|
||||
for source in self.html5_sources:
|
||||
ele = etree.Element('source')
|
||||
ele.set('src', source)
|
||||
xml.append(ele)
|
||||
|
||||
if self.track:
|
||||
ele = etree.Element('track')
|
||||
ele.set('src', self.track)
|
||||
xml.append(ele)
|
||||
|
||||
return etree.tostring(xml, pretty_print=True)
|
||||
|
||||
@staticmethod
|
||||
def _parse_youtube(data):
|
||||
"""
|
||||
Parses a string of Youtube IDs such as "1.0:AXdE34_U,1.5:VO3SxfeD"
|
||||
into a dictionary. Necessary for backwards compatibility with
|
||||
XML-based courses.
|
||||
"""
|
||||
ret = {'0.75': '', '1.00': '', '1.25': '', '1.50': ''}
|
||||
if data == '':
|
||||
return ret
|
||||
videos = data.split(',')
|
||||
for video in videos:
|
||||
pieces = video.split(':')
|
||||
# HACK
|
||||
# To elaborate somewhat: in many LMS tests, the keys for
|
||||
# Youtube IDs are inconsistent. Sometimes a particular
|
||||
# speed isn't present, and formatting is also inconsistent
|
||||
# ('1.0' versus '1.00'). So it's necessary to either do
|
||||
# something like this or update all the tests to work
|
||||
# properly.
|
||||
ret['%.2f' % float(pieces[0])] = pieces[1]
|
||||
return ret
|
||||
|
||||
@staticmethod
|
||||
def _parse_video_xml(xml_data):
|
||||
"""
|
||||
Parse video fields out of xml_data. The fields are set if they are
|
||||
present in the XML.
|
||||
"""
|
||||
xml = etree.fromstring(xml_data)
|
||||
model_data = {}
|
||||
|
||||
conversions = {
|
||||
'show_captions': json.loads,
|
||||
'start_time': VideoAlphaDescriptor._parse_time,
|
||||
'end_time': VideoAlphaDescriptor._parse_time
|
||||
}
|
||||
|
||||
# VideoModule and VideoAlphaModule use different names for
|
||||
# these attributes -- need to convert between them
|
||||
video_compat = {
|
||||
'from': 'start_time',
|
||||
'to': 'end_time'
|
||||
}
|
||||
|
||||
sources = xml.findall('source')
|
||||
if sources:
|
||||
model_data['html5_sources'] = [ele.get('src') for ele in sources]
|
||||
model_data['source'] = model_data['html5_sources'][0]
|
||||
|
||||
track = xml.find('track')
|
||||
if track is not None:
|
||||
model_data['track'] = track.get('src')
|
||||
|
||||
for attr, value in xml.items():
|
||||
if attr in video_compat:
|
||||
attr = video_compat[attr]
|
||||
if attr == 'youtube':
|
||||
speeds = VideoAlphaDescriptor._parse_youtube(value)
|
||||
for speed, youtube_id in speeds.items():
|
||||
# should have made these youtube_id_1_00 for
|
||||
# cleanliness, but hindsight doesn't need glasses
|
||||
normalized_speed = speed[:-1] if speed.endswith('0') else speed
|
||||
# If the user has specified html5 sources, make sure we don't use the default video
|
||||
if youtube_id != '' or 'html5_sources' in model_data:
|
||||
model_data['youtube_id_{0}'.format(normalized_speed.replace('.', '_'))] = youtube_id
|
||||
else:
|
||||
# Convert XML attrs into Python values.
|
||||
if attr in conversions:
|
||||
value = conversions[attr](value)
|
||||
model_data[attr] = value
|
||||
|
||||
return model_data
|
||||
|
||||
@staticmethod
|
||||
def _parse_time(str_time):
|
||||
"""Converts s in '12:34:45' format to seconds. If s is
|
||||
None, returns empty string"""
|
||||
if not str_time:
|
||||
return ''
|
||||
else:
|
||||
obj_time = time.strptime(str_time, '%H:%M:%S')
|
||||
return datetime.timedelta(
|
||||
hours=obj_time.tm_hour,
|
||||
minutes=obj_time.tm_min,
|
||||
seconds=obj_time.tm_sec
|
||||
).total_seconds()
|
||||
|
||||
|
||||
def _create_youtube_string(module):
|
||||
"""
|
||||
Create a string of Youtube IDs from `module`'s metadata
|
||||
attributes. Only writes a speed if an ID is present in the
|
||||
module. Necessary for backwards compatibility with XML-based
|
||||
courses.
|
||||
"""
|
||||
youtube_ids = [
|
||||
module.youtube_id_0_75,
|
||||
module.youtube_id_1_0,
|
||||
module.youtube_id_1_25,
|
||||
module.youtube_id_1_5
|
||||
]
|
||||
youtube_speeds = ['0.75', '1.00', '1.25', '1.50']
|
||||
return ','.join([':'.join(pair)
|
||||
for pair
|
||||
in zip(youtube_speeds, youtube_ids)
|
||||
if pair[1]])
|
||||
|
||||
@@ -10,7 +10,7 @@ from pkg_resources import resource_listdir, resource_string, resource_isdir
|
||||
from xmodule.modulestore import inheritance, Location
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError, InsufficientSpecificationError, InvalidLocationError
|
||||
|
||||
from xblock.core import XBlock, Scope, String, Integer, Float, ModelType
|
||||
from xblock.core import XBlock, Scope, String, Integer, Float, List, ModelType
|
||||
from xblock.fragment import Fragment
|
||||
from xblock.runtime import Runtime
|
||||
from xmodule.modulestore.locator import BlockUsageLocator
|
||||
@@ -766,7 +766,7 @@ class XModuleDescriptor(XModuleFields, HTMLSnippet, ResourceTemplates, XBlock):
|
||||
# 2. Number editors for integers and floats.
|
||||
# 3. A generic string editor for anything else (editing JSON representation of the value).
|
||||
editor_type = "Generic"
|
||||
values = [] if field.values is None else copy.deepcopy(field.values)
|
||||
values = copy.deepcopy(field.values)
|
||||
if isinstance(values, tuple):
|
||||
values = list(values)
|
||||
if isinstance(values, list):
|
||||
@@ -783,11 +783,13 @@ class XModuleDescriptor(XModuleFields, HTMLSnippet, ResourceTemplates, XBlock):
|
||||
editor_type = "Integer"
|
||||
elif isinstance(field, Float):
|
||||
editor_type = "Float"
|
||||
elif isinstance(field, List):
|
||||
editor_type = "List"
|
||||
metadata_fields[field.name] = {'field_name': field.name,
|
||||
'type': editor_type,
|
||||
'display_name': field.display_name,
|
||||
'value': field.to_json(value),
|
||||
'options': values,
|
||||
'options': [] if values is None else values,
|
||||
'default_value': field.to_json(default_value),
|
||||
'inheritable': inheritable,
|
||||
'explicitly_set': explicitly_set,
|
||||
@@ -902,6 +904,8 @@ class ModuleSystem(Runtime):
|
||||
s3_interface=None,
|
||||
cache=None,
|
||||
can_execute_unsafe_code=None,
|
||||
replace_course_urls=None,
|
||||
replace_jump_to_id_urls=None
|
||||
):
|
||||
'''
|
||||
Create a closure around the system environment.
|
||||
@@ -978,6 +982,8 @@ class ModuleSystem(Runtime):
|
||||
|
||||
self.cache = cache or DoNothingCache()
|
||||
self.can_execute_unsafe_code = can_execute_unsafe_code or (lambda: False)
|
||||
self.replace_course_urls = replace_course_urls
|
||||
self.replace_jump_to_id_urls = replace_jump_to_id_urls
|
||||
|
||||
def get(self, attr):
|
||||
''' provide uniform access to attributes (like etree).'''
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
// checks whether or not the url is external to the local site.
|
||||
// generously provided by StackOverflow: http://stackoverflow.com/questions/6238351/fastest-way-to-detect-external-urls
|
||||
function isExternal(url) {
|
||||
window.isExternal = function (url) {
|
||||
// parse the url into protocol, host, path, query, and fragment. More information can be found here: http://tools.ietf.org/html/rfc3986#appendix-B
|
||||
var match = url.match(/^([^:\/?#]+:)?(?:\/\/([^\/?#]*))?([^?#]+)?(\?[^#]*)?(#.*)?/);
|
||||
// match[1] matches a protocol if one exists in the url
|
||||
// if the protocol in the url does not match the protocol in the window's location, this url is considered external
|
||||
if (typeof match[1] === "string" &&
|
||||
match[1].length > 0
|
||||
&& match[1].toLowerCase() !== location.protocol)
|
||||
if (typeof match[1] === "string" &&
|
||||
match[1].length > 0 &&
|
||||
match[1].toLowerCase() !== location.protocol)
|
||||
return true;
|
||||
// match[2] matches the host if one exists in the url
|
||||
// if the host in the url does not match the host of the window location, this url is considered external
|
||||
if (typeof match[2] === "string" &&
|
||||
match[2].length > 0 &&
|
||||
if (typeof match[2] === "string" &&
|
||||
match[2].length > 0 &&
|
||||
// this regex removes the port number if it patches the current location's protocol
|
||||
match[2].replace(new RegExp(":("+{"http:":80,"https:":443}[location.protocol]+")?$"), "") !== location.host)
|
||||
match[2].replace(new RegExp(":("+{"http:":80,"https:":443}[location.protocol]+")?$"), "") !== location.host)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
<videosequence url_name="Toy_Videos">
|
||||
<html url_name="secret:toylab"/>
|
||||
<html url_name="toyjumpto"/>
|
||||
<html url_name="toyhtml"/>
|
||||
<html url_name="nonportable"/>
|
||||
<html url_name="nonportable_link"/>
|
||||
<video url_name="Video_Resources" youtube_id_1_0="1bK-WdDi6Qw" display_name="Video Resources"/>
|
||||
</videosequence>
|
||||
<video url_name="Welcome" youtube_id_1_0="p2Q6BrNhdh8" display_name="Welcome"/>
|
||||
|
||||
1
common/test/data/toy/html/nonportable.html
Normal file
1
common/test/data/toy/html/nonportable.html
Normal file
@@ -0,0 +1 @@
|
||||
<a href="/c4x/edX/toy/asset/foo.jpg">link</a>
|
||||
1
common/test/data/toy/html/nonportable.xml
Normal file
1
common/test/data/toy/html/nonportable.xml
Normal file
@@ -0,0 +1 @@
|
||||
<html filename="nonportable.html"/>
|
||||
2
common/test/data/toy/html/nonportable_link.html
Normal file
2
common/test/data/toy/html/nonportable_link.html
Normal file
@@ -0,0 +1,2 @@
|
||||
<a href="/courses/edX/toy/2012_Fall/jump_to/i4x://edX/toy/html/nonportable_link">link</a>
|
||||
|
||||
1
common/test/data/toy/html/nonportable_link.xml
Normal file
1
common/test/data/toy/html/nonportable_link.xml
Normal file
@@ -0,0 +1 @@
|
||||
<html filename="nonportable_link.html"/>
|
||||
1
common/test/data/toy/html/toyhtml.html
Normal file
1
common/test/data/toy/html/toyhtml.html
Normal file
@@ -0,0 +1 @@
|
||||
<a href='/static/handouts/sample_handout.txt'>Sample</a>
|
||||
1
common/test/data/toy/html/toyhtml.xml
Normal file
1
common/test/data/toy/html/toyhtml.xml
Normal file
@@ -0,0 +1 @@
|
||||
<html filename="toyhtml.html"/>
|
||||
@@ -1 +1 @@
|
||||
<course org="edX" course="toy" url_name="2012_Fall"/>
|
||||
<course org="edX" course="toy" url_name="2012_Fall" display_organization="edX_display" display_coursenum="2012_Fall_Display" />
|
||||
Reference in New Issue
Block a user