Merge branch 'master' into jkarni/fix/descriptorsystemruntime

This commit is contained in:
Julian Arni
2013-08-12 15:00:26 -04:00
233 changed files with 4786 additions and 1634 deletions

View File

@@ -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):

View File

@@ -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):
"""

View File

@@ -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"

View File

@@ -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)

View File

@@ -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'}),

View File

@@ -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'}),

View File

@@ -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'}),

View File

@@ -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']

View File

@@ -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)

View File

@@ -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)

View File

@@ -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}

View File

@@ -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))

View File

@@ -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()

View File

@@ -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

View File

@@ -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'), [])

View File

@@ -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):

View File

@@ -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)

View File

@@ -0,0 +1,4 @@
.supertestclass{
color: red;
}

View File

@@ -0,0 +1,4 @@
.supertestclass{
color: red;
}

View File

@@ -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):
"""

View File

@@ -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:

View File

@@ -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

View 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%;
}
}

View 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;
}
}

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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>

View File

@@ -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=""

View File

@@ -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=""

View File

@@ -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"

View File

@@ -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"

View File

@@ -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=""

View File

@@ -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

View File

@@ -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')

View File

@@ -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'

View File

@@ -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'

View File

@@ -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'

View File

@@ -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);
});
});
});

View File

@@ -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".

View File

@@ -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');
});

View File

@@ -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');
});
});

View File

@@ -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);

View File

@@ -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() {

View File

@@ -39,7 +39,6 @@
range: "min",
min: 0,
max: 100,
/* value: 100, */
value: videoVolumeControl.currentVolume,
change: videoVolumeControl.onChange,
slide: videoVolumeControl.onChange

View File

@@ -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

View 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 {}

View File

@@ -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

View File

@@ -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) {

View File

@@ -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);
}
}

View File

@@ -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)

View File

@@ -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)) {

View File

@@ -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');
});
}
// ***************************************************************

View File

@@ -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.
}
});

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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):

View File

@@ -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

View File

@@ -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):

View File

@@ -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)

View 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)

View File

@@ -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.

View File

@@ -28,6 +28,7 @@ class PeerGradingModuleTest(unittest.TestCase, DummyModulestore):
'feedback': "",
'rubric_scores[]': [0, 1],
'submission_flagged': False,
'answer_unknown' : False,
})
def setUp(self):

View File

@@ -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)

View File

@@ -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()

View File

@@ -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

View File

@@ -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]])

View File

@@ -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).'''

View File

@@ -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;
}
};

View File

@@ -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"/>

View File

@@ -0,0 +1 @@
<a href="/c4x/edX/toy/asset/foo.jpg">link</a>

View File

@@ -0,0 +1 @@
<html filename="nonportable.html"/>

View File

@@ -0,0 +1,2 @@
<a href="/courses/edX/toy/2012_Fall/jump_to/i4x://edX/toy/html/nonportable_link">link</a>

View File

@@ -0,0 +1 @@
<html filename="nonportable_link.html"/>

View File

@@ -0,0 +1 @@
<a href='/static/handouts/sample_handout.txt'>Sample</a>

View File

@@ -0,0 +1 @@
<html filename="toyhtml.html"/>

View File

@@ -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" />