Merge branch 'release'
This commit is contained in:
@@ -179,3 +179,24 @@ class ContentStoreImportTest(ModuleStoreTestCase):
|
||||
u'i4x://testX/peergrading_copy/combinedopenended/SampleQuestion',
|
||||
peergrading_module.link_to_location
|
||||
)
|
||||
|
||||
def test_rewrite_reference_value_dict(self):
|
||||
module_store = modulestore('direct')
|
||||
target_location = Location(['i4x', 'testX', 'split_test_copy', 'course', 'copy_run'])
|
||||
import_from_xml(
|
||||
module_store,
|
||||
'common/test/data/',
|
||||
['split_test_module'],
|
||||
target_location_namespace=target_location
|
||||
)
|
||||
split_test_module = module_store.get_item(
|
||||
Location(['i4x', 'testX', 'split_test_copy', 'split_test', 'split1'])
|
||||
)
|
||||
self.assertIsNotNone(split_test_module)
|
||||
self.assertEqual(
|
||||
{
|
||||
"0": "i4x://testX/split_test_copy/vertical/sample_0",
|
||||
"2": "i4x://testX/split_test_copy/vertical/sample_2",
|
||||
},
|
||||
split_test_module.group_id_to_child,
|
||||
)
|
||||
|
||||
@@ -34,8 +34,7 @@ from path import path
|
||||
from lms.lib.xblock.mixin import LmsBlockMixin
|
||||
from cms.lib.xblock.mixin import CmsBlockMixin
|
||||
from xmodule.modulestore.inheritance import InheritanceMixin
|
||||
from xmodule.modulestore import only_xmodules
|
||||
from xmodule.x_module import XModuleMixin
|
||||
from xmodule.x_module import XModuleMixin, prefer_xmodules
|
||||
from dealer.git import git
|
||||
|
||||
############################ FEATURE CONFIGURATION #############################
|
||||
@@ -223,15 +222,10 @@ X_FRAME_OPTIONS = 'ALLOW'
|
||||
# once the responsibility of XBlock creation is moved out of modulestore - cpennington
|
||||
XBLOCK_MIXINS = (LmsBlockMixin, CmsBlockMixin, InheritanceMixin, XModuleMixin)
|
||||
|
||||
# Only allow XModules in Studio
|
||||
XBLOCK_SELECT_FUNCTION = only_xmodules
|
||||
|
||||
# Use the following lines to allow any xblock in Studio,
|
||||
# either by uncommenting them here, or adding them to your private.py
|
||||
# Allow any XBlock in Studio
|
||||
# You should also enable the ALLOW_ALL_ADVANCED_COMPONENTS feature flag, so that
|
||||
# xblocks can be added via advanced settings
|
||||
# from xmodule.modulestore import prefer_xmodules
|
||||
# XBLOCK_SELECT_FUNCTION = prefer_xmodules
|
||||
XBLOCK_SELECT_FUNCTION = prefer_xmodules
|
||||
|
||||
############################ SIGNAL HANDLERS ################################
|
||||
# This is imported to register the exception signal handling that logs exceptions
|
||||
@@ -473,6 +467,9 @@ INSTALLED_APPS = (
|
||||
# for course creator table
|
||||
'django.contrib.admin',
|
||||
|
||||
# XBlocks containing migrations
|
||||
'mentoring',
|
||||
|
||||
# for managing course modes
|
||||
'course_modes',
|
||||
|
||||
|
||||
50
common/djangoapps/user_api/middleware.py
Normal file
50
common/djangoapps/user_api/middleware.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
Middleware for user api.
|
||||
Adds user's tags to tracking event context.
|
||||
"""
|
||||
from track.contexts import COURSE_REGEX
|
||||
from eventtracking import tracker
|
||||
from user_api.models import UserCourseTag
|
||||
|
||||
|
||||
class UserTagsEventContextMiddleware(object):
|
||||
"""Middleware that adds a user's tags to tracking event context."""
|
||||
CONTEXT_NAME = 'user_tags_context'
|
||||
|
||||
def process_request(self, request):
|
||||
"""
|
||||
Add a user's tags to the tracking event context.
|
||||
"""
|
||||
match = COURSE_REGEX.match(request.build_absolute_uri())
|
||||
course_id = None
|
||||
if match:
|
||||
course_id = match.group('course_id')
|
||||
|
||||
context = {}
|
||||
|
||||
if course_id:
|
||||
context['course_id'] = course_id
|
||||
|
||||
if request.user.is_authenticated():
|
||||
context['course_user_tags'] = dict(
|
||||
UserCourseTag.objects.filter(
|
||||
user=request.user.pk,
|
||||
course_id=course_id
|
||||
).values_list('key', 'value')
|
||||
)
|
||||
else:
|
||||
context['course_user_tags'] = {}
|
||||
|
||||
tracker.get_tracker().enter_context(
|
||||
self.CONTEXT_NAME,
|
||||
context
|
||||
)
|
||||
|
||||
def process_response(self, request, response): # pylint: disable=unused-argument
|
||||
"""Exit the context if it exists."""
|
||||
try:
|
||||
tracker.get_tracker().exit_context(self.CONTEXT_NAME)
|
||||
except: # pylint: disable=bare-except
|
||||
pass
|
||||
|
||||
return response
|
||||
@@ -0,0 +1,87 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import datetime
|
||||
from south.db import db
|
||||
from south.v2 import SchemaMigration
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(SchemaMigration):
|
||||
|
||||
def forwards(self, orm):
|
||||
# Adding model 'UserCourseTags'
|
||||
db.create_table('user_api_usercoursetags', (
|
||||
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
|
||||
('user', self.gf('django.db.models.fields.related.ForeignKey')(related_name='+', to=orm['auth.User'])),
|
||||
('key', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)),
|
||||
('course_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)),
|
||||
('value', self.gf('django.db.models.fields.TextField')()),
|
||||
))
|
||||
db.send_create_signal('user_api', ['UserCourseTags'])
|
||||
|
||||
# Adding unique constraint on 'UserCourseTags', fields ['user', 'course_id', 'key']
|
||||
db.create_unique('user_api_usercoursetags', ['user_id', 'course_id', 'key'])
|
||||
|
||||
|
||||
def backwards(self, orm):
|
||||
# Removing unique constraint on 'UserCourseTags', fields ['user', 'course_id', 'key']
|
||||
db.delete_unique('user_api_usercoursetags', ['user_id', 'course_id', 'key'])
|
||||
|
||||
# Deleting model 'UserCourseTags'
|
||||
db.delete_table('user_api_usercoursetags')
|
||||
|
||||
|
||||
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'})
|
||||
},
|
||||
'user_api.usercoursetags': {
|
||||
'Meta': {'unique_together': "(('user', 'course_id', 'key'),)", 'object_name': 'UserCourseTags'},
|
||||
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'key': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'+'", 'to': "orm['auth.User']"}),
|
||||
'value': ('django.db.models.fields.TextField', [], {})
|
||||
},
|
||||
'user_api.userpreference': {
|
||||
'Meta': {'unique_together': "(('user', 'key'),)", 'object_name': 'UserPreference'},
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'key': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'+'", 'to': "orm['auth.User']"}),
|
||||
'value': ('django.db.models.fields.TextField', [], {})
|
||||
}
|
||||
}
|
||||
|
||||
complete_apps = ['user_api']
|
||||
@@ -0,0 +1,72 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import datetime
|
||||
from south.db import db
|
||||
from south.v2 import SchemaMigration
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(SchemaMigration):
|
||||
|
||||
def forwards(self, orm):
|
||||
db.rename_table('user_api_usercoursetags', 'user_api_usercoursetag')
|
||||
|
||||
|
||||
def backwards(self, orm):
|
||||
db.rename_table('user_api_usercoursetag', 'user_api_usercoursetags')
|
||||
|
||||
|
||||
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'})
|
||||
},
|
||||
'user_api.usercoursetag': {
|
||||
'Meta': {'unique_together': "(('user', 'course_id', 'key'),)", 'object_name': 'UserCourseTag'},
|
||||
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'key': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'+'", 'to': "orm['auth.User']"}),
|
||||
'value': ('django.db.models.fields.TextField', [], {})
|
||||
},
|
||||
'user_api.userpreference': {
|
||||
'Meta': {'unique_together': "(('user', 'key'),)", 'object_name': 'UserPreference'},
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'key': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'+'", 'to': "orm['auth.User']"}),
|
||||
'value': ('django.db.models.fields.TextField', [], {})
|
||||
}
|
||||
}
|
||||
|
||||
complete_apps = ['user_api']
|
||||
@@ -8,7 +8,7 @@ class UserPreference(models.Model):
|
||||
key = models.CharField(max_length=255, db_index=True)
|
||||
value = models.TextField()
|
||||
|
||||
class Meta:
|
||||
class Meta: # pylint: disable=missing-docstring
|
||||
unique_together = ("user", "key")
|
||||
|
||||
@classmethod
|
||||
@@ -33,3 +33,17 @@ class UserPreference(models.Model):
|
||||
return user_pref.value
|
||||
except cls.DoesNotExist:
|
||||
return default
|
||||
|
||||
|
||||
class UserCourseTag(models.Model):
|
||||
"""
|
||||
Per-course user tags, to be used by various things that want to store tags about
|
||||
the user. Added initially to store assignment to experimental groups.
|
||||
"""
|
||||
user = models.ForeignKey(User, db_index=True, related_name="+")
|
||||
key = models.CharField(max_length=255, db_index=True)
|
||||
course_id = models.CharField(max_length=255, db_index=True)
|
||||
value = models.TextField()
|
||||
|
||||
class Meta: # pylint: disable=missing-docstring
|
||||
unique_together = ("user", "course_id", "key")
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
"""Provides factories for User API models."""
|
||||
from factory.django import DjangoModelFactory
|
||||
from user_api.models import UserPreference
|
||||
|
||||
from factory import SubFactory
|
||||
from student.tests.factories import UserFactory
|
||||
from user_api.models import UserPreference, UserCourseTag
|
||||
|
||||
# Factories don't have __init__ methods, and are self documenting
|
||||
# pylint: disable=W0232, C0111
|
||||
class UserPreferenceFactory(DjangoModelFactory):
|
||||
FACTORY_FOR = UserPreference
|
||||
|
||||
user = None
|
||||
key = None
|
||||
value = "default test value"
|
||||
|
||||
|
||||
class UserCourseTagFactory(DjangoModelFactory):
|
||||
FACTORY_FOR = UserCourseTag
|
||||
|
||||
user = SubFactory(UserFactory)
|
||||
course_id = 'org/course/run'
|
||||
key = None
|
||||
value = None
|
||||
|
||||
119
common/djangoapps/user_api/tests/test_middleware.py
Normal file
119
common/djangoapps/user_api/tests/test_middleware.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""Tests for user API middleware"""
|
||||
from mock import Mock, patch
|
||||
from unittest import TestCase
|
||||
|
||||
from django.http import HttpResponse
|
||||
from django.test.client import RequestFactory
|
||||
|
||||
from student.tests.factories import UserFactory, AnonymousUserFactory
|
||||
from user_api.tests.factories import UserCourseTagFactory
|
||||
from user_api.middleware import UserTagsEventContextMiddleware
|
||||
|
||||
|
||||
class TagsMiddlewareTest(TestCase):
|
||||
"""
|
||||
Test the UserTagsEventContextMiddleware
|
||||
"""
|
||||
def setUp(self):
|
||||
self.middleware = UserTagsEventContextMiddleware()
|
||||
self.user = UserFactory.create()
|
||||
self.other_user = UserFactory.create()
|
||||
|
||||
self.course_id = 'mock/course/id'
|
||||
self.request_factory = RequestFactory()
|
||||
|
||||
# TODO: Make it so we can use reverse. Appears to fail depending on the order in which tests are run
|
||||
#self.request = RequestFactory().get(reverse('courseware', kwargs={'course_id': self.course_id}))
|
||||
self.request = RequestFactory().get('/courses/{}/courseware'.format(self.course_id))
|
||||
self.request.user = self.user
|
||||
|
||||
self.response = Mock(spec=HttpResponse)
|
||||
|
||||
patcher = patch('user_api.middleware.tracker')
|
||||
self.tracker = patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def process_request(self):
|
||||
"""
|
||||
Execute process request using the request, and verify that it returns None
|
||||
so that the request continues.
|
||||
"""
|
||||
# Middleware should pass request through
|
||||
self.assertEquals(self.middleware.process_request(self.request), None)
|
||||
|
||||
def assertContextSetTo(self, context):
|
||||
"""Asserts UserTagsEventContextMiddleware.CONTEXT_NAME matches ``context``"""
|
||||
self.tracker.get_tracker.return_value.enter_context.assert_called_with( # pylint: disable=maybe-no-member
|
||||
UserTagsEventContextMiddleware.CONTEXT_NAME,
|
||||
context
|
||||
)
|
||||
|
||||
def test_tag_context(self):
|
||||
for key, value in (('int_value', 1), ('str_value', "two")):
|
||||
UserCourseTagFactory.create(
|
||||
course_id=self.course_id,
|
||||
user=self.user,
|
||||
key=key,
|
||||
value=value,
|
||||
)
|
||||
|
||||
UserCourseTagFactory.create(
|
||||
course_id=self.course_id,
|
||||
user=self.other_user,
|
||||
key="other_user",
|
||||
value="other_user_value"
|
||||
)
|
||||
|
||||
UserCourseTagFactory.create(
|
||||
course_id='other/course/id',
|
||||
user=self.user,
|
||||
key="other_course",
|
||||
value="other_course_value"
|
||||
)
|
||||
|
||||
self.process_request()
|
||||
self.assertContextSetTo({
|
||||
'course_id': self.course_id,
|
||||
'course_user_tags': {
|
||||
'int_value': '1',
|
||||
'str_value': 'two',
|
||||
}
|
||||
})
|
||||
|
||||
def test_no_tags(self):
|
||||
self.process_request()
|
||||
self.assertContextSetTo({'course_id': self.course_id, 'course_user_tags': {}})
|
||||
|
||||
def test_not_course_url(self):
|
||||
self.request = self.request_factory.get('/not/a/course/url')
|
||||
self.request.user = self.user
|
||||
|
||||
self.process_request()
|
||||
|
||||
self.assertContextSetTo({})
|
||||
|
||||
def test_anonymous_user(self):
|
||||
self.request.user = AnonymousUserFactory()
|
||||
|
||||
self.process_request()
|
||||
|
||||
self.assertContextSetTo({'course_id': self.course_id, 'course_user_tags': {}})
|
||||
|
||||
def test_remove_context(self):
|
||||
get_tracker = self.tracker.get_tracker # pylint: disable=maybe-no-member
|
||||
exit_context = get_tracker.return_value.exit_context
|
||||
|
||||
# The middleware should clean up the context when the request is done
|
||||
self.assertEquals(
|
||||
self.middleware.process_response(self.request, self.response),
|
||||
self.response
|
||||
)
|
||||
exit_context.assert_called_with(UserTagsEventContextMiddleware.CONTEXT_NAME)
|
||||
exit_context.reset_mock()
|
||||
|
||||
# Even if the tracker blows up, the middleware should still return the response
|
||||
get_tracker.side_effect = Exception
|
||||
self.assertEquals(
|
||||
self.middleware.process_response(self.request, self.response),
|
||||
self.response
|
||||
)
|
||||
34
common/djangoapps/user_api/tests/test_user_service.py
Normal file
34
common/djangoapps/user_api/tests/test_user_service.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""
|
||||
Test the user service
|
||||
"""
|
||||
from django.test import TestCase
|
||||
|
||||
from student.tests.factories import UserFactory
|
||||
from user_api import user_service
|
||||
|
||||
|
||||
class TestUserService(TestCase):
|
||||
"""
|
||||
Test the user service
|
||||
"""
|
||||
def setUp(self):
|
||||
self.user = UserFactory.create()
|
||||
self.course_id = 'test_org/test_course_number/test_run'
|
||||
self.test_key = 'test_key'
|
||||
|
||||
def test_get_set_course_tag(self):
|
||||
# get a tag that doesn't exist
|
||||
tag = user_service.get_course_tag(self.user, self.course_id, self.test_key)
|
||||
self.assertIsNone(tag)
|
||||
|
||||
# test setting a new key
|
||||
test_value = 'value'
|
||||
user_service.set_course_tag(self.user, self.course_id, self.test_key, test_value)
|
||||
tag = user_service.get_course_tag(self.user, self.course_id, self.test_key)
|
||||
self.assertEqual(tag, test_value)
|
||||
|
||||
#test overwriting an existing key
|
||||
test_value = 'value2'
|
||||
user_service.set_course_tag(self.user, self.course_id, self.test_key, test_value)
|
||||
tag = user_service.get_course_tag(self.user, self.course_id, self.test_key)
|
||||
self.assertEqual(tag, test_value)
|
||||
65
common/djangoapps/user_api/user_service.py
Normal file
65
common/djangoapps/user_api/user_service.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
A service-like user_info interface. Could be made into an http API later, but for now
|
||||
just in-process. Exposes global and per-course key-value pairs for users.
|
||||
|
||||
Implementation note:
|
||||
Stores global metadata using the UserPreference model, and per-course metadata using the
|
||||
UserCourseTag model.
|
||||
"""
|
||||
|
||||
from user_api.models import UserCourseTag
|
||||
|
||||
# Scopes
|
||||
# (currently only allows per-course tags. Can be expanded to support
|
||||
# global tags (e.g. using the existing UserPreferences table))
|
||||
COURSE_SCOPE = 'course'
|
||||
|
||||
def get_course_tag(user, course_id, key):
|
||||
"""
|
||||
Gets the value of the user's course tag for the specified key in the specified
|
||||
course_id.
|
||||
|
||||
Args:
|
||||
user: the User object for the course tag
|
||||
course_id: course identifier (string)
|
||||
key: arbitrary (<=255 char string)
|
||||
|
||||
Returns:
|
||||
string value, or None if there is no value saved
|
||||
"""
|
||||
try:
|
||||
record = UserCourseTag.objects.get(
|
||||
user=user,
|
||||
course_id=course_id,
|
||||
key=key)
|
||||
|
||||
return record.value
|
||||
except UserCourseTag.DoesNotExist:
|
||||
return None
|
||||
|
||||
|
||||
def set_course_tag(user, course_id, key, value):
|
||||
"""
|
||||
Sets the value of the user's course tag for the specified key in the specified
|
||||
course_id. Overwrites any previous value.
|
||||
|
||||
The intention is that the values are fairly short, as they will be included in all
|
||||
analytics events about this user.
|
||||
|
||||
Args:
|
||||
user: the User object
|
||||
course_id: course identifier (string)
|
||||
key: arbitrary (<=255 char string)
|
||||
value: arbitrary string
|
||||
"""
|
||||
|
||||
record, _ = UserCourseTag.objects.get_or_create(
|
||||
user=user,
|
||||
course_id=course_id,
|
||||
key=key)
|
||||
|
||||
record.value = value
|
||||
record.save()
|
||||
|
||||
# TODO: There is a risk of IntegrityErrors being thrown here given
|
||||
# simultaneous calls from many processes. Handle by retrying after a short delay?
|
||||
@@ -46,6 +46,7 @@ import re
|
||||
import shlex # for splitting quoted strings
|
||||
import sys
|
||||
import pyparsing
|
||||
import html5lib
|
||||
|
||||
from .registry import TagRegistry
|
||||
from chem import chemcalc
|
||||
@@ -286,7 +287,18 @@ class InputTypeBase(object):
|
||||
context = self._get_render_context()
|
||||
|
||||
html = self.capa_system.render_template(self.template, context)
|
||||
return etree.XML(html)
|
||||
|
||||
try:
|
||||
output = etree.XML(html)
|
||||
except etree.XMLSyntaxError as ex:
|
||||
# If `html` contains attrs with no values, like `controls` in <audio controls src='smth'/>,
|
||||
# XML parser will raise exception, so wee fallback to html5parser, which will set empty "" values for such attrs.
|
||||
try:
|
||||
output = html5lib.parseFragment(html, treebuilder='lxml', namespaceHTMLElements=False)[0]
|
||||
except IndexError:
|
||||
raise ex
|
||||
|
||||
return output
|
||||
|
||||
def get_user_visible_answer(self, internal_answer):
|
||||
"""
|
||||
|
||||
@@ -14,6 +14,7 @@ import cgi
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import html5lib
|
||||
import numbers
|
||||
import numpy
|
||||
import os
|
||||
@@ -1761,17 +1762,22 @@ class CodeResponse(LoncapaResponse):
|
||||
" tags: 'correct', 'score', 'msg'")
|
||||
return fail
|
||||
|
||||
# Next, we need to check that the contents of the external grader message
|
||||
# is safe for the LMS.
|
||||
# Next, we need to check that the contents of the external grader message is safe for the LMS.
|
||||
# 1) Make sure that the message is valid XML (proper opening/closing tags)
|
||||
# 2) TODO: Is the message actually HTML?
|
||||
# 2) If it is not valid XML, make sure it is valid HTML. Note: html5lib parser will try to repair any broken HTML
|
||||
# For example: <aaa></bbb> will become <aaa/>.
|
||||
msg = score_result['msg']
|
||||
|
||||
try:
|
||||
etree.fromstring(msg)
|
||||
except etree.XMLSyntaxError as _err:
|
||||
log.error("Unable to parse external grader message as valid"
|
||||
# If `html` contains attrs with no values, like `controls` in <audio controls src='smth'/>,
|
||||
# XML parser will raise exception, so wee fallback to html5parser, which will set empty "" values for such attrs.
|
||||
parsed = html5lib.parseFragment(msg, treebuilder='lxml', namespaceHTMLElements=False)
|
||||
if not parsed:
|
||||
log.error("Unable to parse external grader message as valid"
|
||||
" XML: score_msg['msg']=%s", msg)
|
||||
return fail
|
||||
return fail
|
||||
|
||||
return (True, score_result['correct'], score_result['score'], msg)
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ TODO:
|
||||
import json
|
||||
from lxml import etree
|
||||
import unittest
|
||||
import textwrap
|
||||
import xml.sax.saxutils as saxutils
|
||||
|
||||
from . import test_capa_system
|
||||
@@ -583,6 +584,44 @@ class MatlabTest(unittest.TestCase):
|
||||
self.assertEqual(input_state['queuestate'], 'queued')
|
||||
self.assertFalse('queue_msg' in input_state)
|
||||
|
||||
def test_get_html(self):
|
||||
# usual output
|
||||
output = self.the_input.get_html()
|
||||
self.assertEqual(
|
||||
etree.tostring(output),
|
||||
"""<div>{\'status\': \'queued\', \'button_enabled\': True, \'rows\': \'10\', \'queue_len\': \'3\'\
|
||||
, \'mode\': \'\', \'cols\': \'80\', \'STATIC_URL\': \'/dummy-static/\', \'linenumbers\': \'true\'\
|
||||
, \'queue_msg\': \'\', \'value\': \'print "good evening"\', \'msg\': u\'Submitted\
|
||||
. As soon as a response is returned, this message will be replaced by that feedback.\', \'hidden\': \'\'\
|
||||
, \'id\': \'prob_1_2\', \'tabsize\': 4}</div>"""
|
||||
)
|
||||
|
||||
# test html, that is correct HTML5 html, but is not parsable by XML parser.
|
||||
old_render_template = self.the_input.capa_system.render_template
|
||||
self.the_input.capa_system.render_template = lambda *args: textwrap.dedent("""
|
||||
<div class='matlabResponse'><div id='mwAudioPlaceHolder'>
|
||||
<audio controls autobuffer autoplay src='data:audio/wav;base64='>Audio is not supported on this browser.</audio>
|
||||
<div>Right click <a href=https://endpoint.mss-mathworks.com/media/filename.wav>here</a> and click \"Save As\" to download the file</div></div>
|
||||
<div style='white-space:pre' class='commandWindowOutput'></div><ul></ul></div>
|
||||
""").replace('\n', '')
|
||||
output = self.the_input.get_html()
|
||||
self.assertEqual(
|
||||
etree.tostring(output),
|
||||
textwrap.dedent("""
|
||||
<div class='matlabResponse'><div id='mwAudioPlaceHolder'>
|
||||
<audio src='data:audio/wav;base64=' autobuffer="" controls="" autoplay="">Audio is not supported on this browser.</audio>
|
||||
<div>Right click <a href="https://endpoint.mss-mathworks.com/media/filename.wav">here</a> and click \"Save As\" to download the file</div></div>
|
||||
<div style='white-space:pre' class='commandWindowOutput'/><ul/></div>
|
||||
""").replace('\n', '').replace('\'', '\"')
|
||||
)
|
||||
|
||||
# check that exception is raised during parsing for html.
|
||||
self.the_input.capa_system.render_template = lambda *args: "<aaa"
|
||||
with self.assertRaises(etree.XMLSyntaxError):
|
||||
self.the_input.get_html()
|
||||
|
||||
self.the_input.capa_system.render_template = old_render_template
|
||||
|
||||
|
||||
class SchematicTest(unittest.TestCase):
|
||||
'''
|
||||
|
||||
@@ -998,6 +998,59 @@ class CodeResponseTest(ResponseTest):
|
||||
self.assertEquals(answers_converted['1_3_1'], ['answer1', 'answer2', 'answer3'])
|
||||
self.assertEquals(answers_converted['1_4_1'], [fp.name, fp.name])
|
||||
|
||||
def test_parse_score_msg_of_responder(self):
|
||||
"""
|
||||
Test whether LoncapaProblem._parse_score_msg correcly parses valid HTML5 html.
|
||||
"""
|
||||
valid_grader_msgs = [
|
||||
u'<span>MESSAGE</span>', # Valid XML
|
||||
textwrap.dedent("""
|
||||
<div class='matlabResponse'><div id='mwAudioPlaceHolder'>
|
||||
<audio controls autobuffer autoplay src='data:audio/wav;base64='>Audio is not supported on this browser.</audio>
|
||||
<div>Right click <a href=https://endpoint.mss-mathworks.com/media/filename.wav>here</a> and click \"Save As\" to download the file</div></div>
|
||||
<div style='white-space:pre' class='commandWindowOutput'></div><ul></ul></div>
|
||||
""").replace('\n', ''), # Valid HTML5 real case Matlab response, invalid XML
|
||||
'<aaa></bbb>' # Invalid XML, but will be parsed by html5lib to <aaa/>
|
||||
]
|
||||
|
||||
invalid_grader_msgs = [
|
||||
'<audio', # invalid XML and HTML5
|
||||
]
|
||||
|
||||
answer_ids = sorted(self.problem.get_question_answers())
|
||||
|
||||
# CodeResponse requires internal CorrectMap state. Build it now in the queued state
|
||||
old_cmap = CorrectMap()
|
||||
for i, answer_id in enumerate(answer_ids):
|
||||
queuekey = 1000 + i
|
||||
queuestate = CodeResponseTest.make_queuestate(queuekey, datetime.now(UTC))
|
||||
old_cmap.update(CorrectMap(answer_id=answer_ids[i], queuestate=queuestate))
|
||||
|
||||
for grader_msg in valid_grader_msgs:
|
||||
correct_score_msg = json.dumps({'correct': True, 'score': 1, 'msg': grader_msg})
|
||||
incorrect_score_msg = json.dumps({'correct': False, 'score': 0, 'msg': grader_msg})
|
||||
xserver_msgs = {'correct': correct_score_msg, 'incorrect': incorrect_score_msg, }
|
||||
|
||||
for i, answer_id in enumerate(answer_ids):
|
||||
self.problem.correct_map = CorrectMap()
|
||||
self.problem.correct_map.update(old_cmap)
|
||||
output = self.problem.update_score(xserver_msgs['correct'], queuekey=1000 + i)
|
||||
self.assertEquals(output[answer_id]['msg'], grader_msg)
|
||||
|
||||
for grader_msg in invalid_grader_msgs:
|
||||
correct_score_msg = json.dumps({'correct': True, 'score': 1, 'msg': grader_msg})
|
||||
incorrect_score_msg = json.dumps({'correct': False, 'score': 0, 'msg': grader_msg})
|
||||
xserver_msgs = {'correct': correct_score_msg, 'incorrect': incorrect_score_msg, }
|
||||
|
||||
for i, answer_id in enumerate(answer_ids):
|
||||
self.problem.correct_map = CorrectMap()
|
||||
self.problem.correct_map.update(old_cmap)
|
||||
|
||||
output = self.problem.update_score(xserver_msgs['correct'], queuekey=1000 + i)
|
||||
self.assertEquals(output[answer_id]['msg'], u'Invalid grader reply. Please contact the course staff.')
|
||||
|
||||
|
||||
|
||||
|
||||
class ChoiceResponseTest(ResponseTest):
|
||||
from capa.tests.response_xml_factory import ChoiceResponseXMLFactory
|
||||
|
||||
@@ -17,6 +17,7 @@ XMODULES = [
|
||||
"problem = xmodule.capa_module:CapaDescriptor",
|
||||
"problemset = xmodule.seq_module:SequenceDescriptor",
|
||||
"randomize = xmodule.randomize_module:RandomizeDescriptor",
|
||||
"split_test = xmodule.split_test_module:SplitTestDescriptor",
|
||||
"section = xmodule.backcompat_module:SemanticSectionDescriptor",
|
||||
"sequential = xmodule.seq_module:SequenceDescriptor",
|
||||
"slides = xmodule.backcompat_module:TranslateCustomTagDescriptor",
|
||||
|
||||
@@ -9,6 +9,7 @@ import dateutil.parser
|
||||
from lazy import lazy
|
||||
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.partitions.partitions import UserPartition
|
||||
from xmodule.seq_module import SequenceDescriptor, SequenceModule
|
||||
from xmodule.graders import grader_from_conf
|
||||
import json
|
||||
@@ -156,10 +157,29 @@ class TextbookList(List):
|
||||
return json_data
|
||||
|
||||
|
||||
class UserPartitionList(List):
|
||||
"""Special List class for listing UserPartitions"""
|
||||
def from_json(self, values):
|
||||
return [UserPartition.from_json(v) for v in values]
|
||||
|
||||
def to_json(self, values):
|
||||
return [user_partition.to_json()
|
||||
for user_partition in values]
|
||||
|
||||
|
||||
class CourseFields(object):
|
||||
lti_passports = List(help="LTI tools passports as id:client_key:client_secret", scope=Scope.settings)
|
||||
textbooks = TextbookList(help="List of pairs of (title, url) for textbooks used in this course",
|
||||
default=[], scope=Scope.content)
|
||||
|
||||
# This field is intended for Studio to update, not to be exposed directly via
|
||||
# advanced_settings.
|
||||
user_partitions = UserPartitionList(
|
||||
help="List of user partitions of this course into groups, used e.g. for experiments",
|
||||
default=[],
|
||||
scope=Scope.content
|
||||
)
|
||||
|
||||
wiki_slug = String(help="Slug that points to the wiki for this course", scope=Scope.content)
|
||||
enrollment_start = Date(help="Date that enrollment for this class is opened", scope=Scope.settings)
|
||||
enrollment_end = Date(help="Date that enrollment for this class is closed", scope=Scope.settings)
|
||||
@@ -354,7 +374,7 @@ class CourseFields(object):
|
||||
# Ensure that courses imported from XML keep their image
|
||||
default="images_course_image.jpg"
|
||||
)
|
||||
|
||||
|
||||
## Course level Certificate Name overrides.
|
||||
cert_name_short = String(
|
||||
help="Sitewide name of completion statements given to students (short).",
|
||||
|
||||
19
common/lib/xmodule/xmodule/js/fixtures/split_test_staff.html
Normal file
19
common/lib/xmodule/xmodule/js/fixtures/split_test_staff.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<div class="split-test-view" id="split-test">
|
||||
<select class="split-test-select">
|
||||
<option value="0">Group 0</option>
|
||||
<option value="1">Group 1</option>
|
||||
<option value="2">Group 2</option>
|
||||
</select>
|
||||
|
||||
<div class="split-test-child" data-group-id="0">
|
||||
<div class='condition-text'>condition 0</div>
|
||||
</div>
|
||||
<div class="split-test-child" data-group-id="1">
|
||||
<div class='condition-text'>condition 1</div>
|
||||
</div>
|
||||
<div class="split-test-child" data-group-id="2">
|
||||
<div class='condition-text'>condition 2</div>
|
||||
</div>
|
||||
|
||||
<div class='split-test-child-container'></div>
|
||||
</div>
|
||||
@@ -57,6 +57,7 @@ lib_paths:
|
||||
- common_static/js/vendor/analytics.js
|
||||
- common_static/js/test/add_ajax_prefix.js
|
||||
- common_static/js/src/utility.js
|
||||
- public/js/split_test_staff.js
|
||||
|
||||
# Paths to spec (test) JavaScript files
|
||||
spec_paths:
|
||||
|
||||
1
common/lib/xmodule/xmodule/js/public
Symbolic link
1
common/lib/xmodule/xmodule/js/public
Symbolic link
@@ -0,0 +1 @@
|
||||
../public/
|
||||
@@ -0,0 +1,37 @@
|
||||
describe('Tests for split_test staff view switching', function() {
|
||||
var ab_module;
|
||||
var elem;
|
||||
|
||||
beforeEach(function() {
|
||||
loadFixtures('split_test_staff.html');
|
||||
elem = $('#split-test');
|
||||
window.XBlock = jasmine.createSpyObj('XBlock', ['initializeBlocks']);
|
||||
ab_module = ABTestSelector(null, elem);
|
||||
});
|
||||
|
||||
afterEach(function() {
|
||||
delete window.XBlock;
|
||||
});
|
||||
|
||||
it("test that we have only one visible condition", function() {
|
||||
var containers = elem.find('.split-test-child-container').length;
|
||||
var conditions_shown = elem.find('.split-test-child-container .condition-text').length;
|
||||
expect(containers).toEqual(1);
|
||||
expect(conditions_shown).toEqual(1);
|
||||
expect(XBlock.initializeBlocks).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("test that the right child is visible when selected", function() {
|
||||
var groups = ['0', '1', '2'];
|
||||
|
||||
for(var i = 0; i < groups.length; i++) {
|
||||
var to_select = groups[i];
|
||||
elem.find('.split-test-select').val(to_select).change();
|
||||
var child_text = elem.find('.split-test-child-container .condition-text').text();
|
||||
expect(child_text).toContain(to_select);
|
||||
expect(XBlock.initializeBlocks).toHaveBeenCalled();
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
@@ -23,8 +23,8 @@ class @Sequence
|
||||
updatePageTitle: ->
|
||||
# update the page title to include the current section
|
||||
position_link = @link_for(@position)
|
||||
if position_link and position_link.attr('title')
|
||||
document.title = position_link.attr('title') + @base_page_title
|
||||
if position_link and position_link.data('page-title')
|
||||
document.title = position_link.data('page-title') + @base_page_title
|
||||
|
||||
hookUpProgressEvent: ->
|
||||
$('.problems-wrapper').bind 'progressChanged', @updateProgress
|
||||
@@ -98,10 +98,10 @@ class @Sequence
|
||||
# Added for aborting video bufferization, see ../video/10_main.js
|
||||
@el.trigger "sequence:change"
|
||||
@mark_active new_position
|
||||
|
||||
|
||||
current_tab = @contents.eq(new_position - 1)
|
||||
@content_container.html(current_tab.text()).attr("aria-labelledby", current_tab.attr("aria-labelledby"))
|
||||
|
||||
|
||||
XBlock.initializeBlocks(@content_container)
|
||||
|
||||
window.update_schematics() # For embedded circuit simulator exercises in 6.002x
|
||||
@@ -115,8 +115,8 @@ class @Sequence
|
||||
sequence_links.click @goto
|
||||
# Focus on the first available xblock.
|
||||
@content_container.find('.vert .xblock :first').focus()
|
||||
@$("a.active").blur()
|
||||
|
||||
@$("a.active").blur()
|
||||
|
||||
goto: (event) =>
|
||||
event.preventDefault()
|
||||
if $(event.target).hasClass 'seqnav' # Links from courseware <a class='seqnav' href='n'>...</a>
|
||||
|
||||
@@ -321,13 +321,13 @@ class MongoModuleStore(ModuleStoreWriteBase):
|
||||
'''
|
||||
TODO (cdodge) This method can be deleted when the 'split module store' work has been completed
|
||||
'''
|
||||
|
||||
# get all collections in the course, this query should not return any leaf nodes
|
||||
# note this is a bit ugly as when we add new categories of containers, we have to add it here
|
||||
|
||||
block_types_with_children = set(name for name, class_ in XBlock.load_classes() if getattr(class_, 'has_children', False))
|
||||
query = {'_id.org': location.org,
|
||||
'_id.course': location.course,
|
||||
'_id.category': {'$in': ['course', 'chapter', 'sequential', 'vertical', 'videosequence',
|
||||
'wrapper', 'problemset', 'conditional', 'randomize']}
|
||||
'_id.category': {'$in': list(block_types_with_children)}
|
||||
}
|
||||
# we just want the Location, children, and inheritable metadata
|
||||
record_filter = {'_id': 1, 'definition.children': 1}
|
||||
|
||||
@@ -6,7 +6,7 @@ import json
|
||||
|
||||
from .xml import XMLModuleStore, ImportSystem, ParentTracker
|
||||
from xmodule.modulestore import Location
|
||||
from xblock.fields import Scope, Reference, ReferenceList
|
||||
from xblock.fields import Scope, Reference, ReferenceList, ReferenceValueDict
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
from .inheritance import own_metadata
|
||||
from xmodule.errortracker import make_error_tracker
|
||||
@@ -547,15 +547,25 @@ def remap_namespace(module, target_location_namespace):
|
||||
).url()
|
||||
return new_ref
|
||||
|
||||
for field in all_fields:
|
||||
if isinstance(module.fields.get(field), Reference):
|
||||
new_ref = convert_ref(getattr(module, field))
|
||||
setattr(module, field, new_ref)
|
||||
for field_name in all_fields:
|
||||
field_object = module.fields.get(field_name)
|
||||
if isinstance(field_object, Reference):
|
||||
new_ref = convert_ref(getattr(module, field_name))
|
||||
setattr(module, field_name, new_ref)
|
||||
module.save()
|
||||
elif isinstance(module.fields.get(field), ReferenceList):
|
||||
references = getattr(module, field)
|
||||
elif isinstance(field_object, ReferenceList):
|
||||
references = getattr(module, field_name)
|
||||
new_references = [convert_ref(reference) for reference in references]
|
||||
setattr(module, field, new_references)
|
||||
setattr(module, field_name, new_references)
|
||||
module.save()
|
||||
elif isinstance(field_object, ReferenceValueDict):
|
||||
reference_dict = getattr(module, field_name)
|
||||
new_reference_dict = {
|
||||
key: convert_ref(reference)
|
||||
for key, reference
|
||||
in reference_dict.items()
|
||||
}
|
||||
setattr(module, field_name, new_reference_dict)
|
||||
module.save()
|
||||
|
||||
return module
|
||||
|
||||
0
common/lib/xmodule/xmodule/partitions/__init__.py
Normal file
0
common/lib/xmodule/xmodule/partitions/__init__.py
Normal file
116
common/lib/xmodule/xmodule/partitions/partitions.py
Normal file
116
common/lib/xmodule/xmodule/partitions/partitions.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""Defines ``Group`` and ``UserPartition`` models for partitioning"""
|
||||
|
||||
# We use ``id`` in this file as the IDs of our Groups and UserPartitions,
|
||||
# which Pylint disapproves of.
|
||||
# pylint: disable=invalid-name, redefined-builtin
|
||||
|
||||
|
||||
class Group(object):
|
||||
"""
|
||||
An id and name for a group of students. The id should be unique
|
||||
within the UserPartition this group appears in.
|
||||
"""
|
||||
# in case we want to add to this class, a version will be handy
|
||||
# for deserializing old versions. (This will be serialized in courses)
|
||||
VERSION = 1
|
||||
|
||||
def __init__(self, id, name):
|
||||
self.id = int(id)
|
||||
self.name = name
|
||||
|
||||
def to_json(self):
|
||||
"""
|
||||
'Serialize' to a json-serializable representation.
|
||||
|
||||
Returns:
|
||||
a dictionary with keys for the properties of the group.
|
||||
"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"version": Group.VERSION
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_json(value):
|
||||
"""
|
||||
Deserialize a Group from a json-like representation.
|
||||
|
||||
Args:
|
||||
value: a dictionary with keys for the properties of the group.
|
||||
|
||||
Raises TypeError if the value doesn't have the right keys.
|
||||
"""
|
||||
for key in ('id', 'name', 'version'):
|
||||
if key not in value:
|
||||
raise TypeError("Group dict {0} missing value key '{1}'".format(
|
||||
value, key))
|
||||
|
||||
if value["version"] != Group.VERSION:
|
||||
raise TypeError("Group dict {0} has unexpected version".format(
|
||||
value))
|
||||
|
||||
return Group(value["id"], value["name"])
|
||||
|
||||
|
||||
class UserPartition(object):
|
||||
"""
|
||||
A named way to partition users into groups, primarily intended for running
|
||||
experiments. It is expected that each user will be in at most one group in a
|
||||
partition.
|
||||
|
||||
A Partition has an id, name, description, and a list of groups.
|
||||
The id is intended to be unique within the context where these are used. (e.g. for
|
||||
partitions of users within a course, the ids should be unique per-course)
|
||||
"""
|
||||
VERSION = 1
|
||||
|
||||
def __init__(self, id, name, description, groups):
|
||||
|
||||
self.id = int(id)
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.groups = groups
|
||||
|
||||
def to_json(self):
|
||||
"""
|
||||
'Serialize' to a json-serializable representation.
|
||||
|
||||
Returns:
|
||||
a dictionary with keys for the properties of the partition.
|
||||
"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"groups": [g.to_json() for g in self.groups],
|
||||
"version": UserPartition.VERSION
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_json(value):
|
||||
"""
|
||||
Deserialize a Group from a json-like representation.
|
||||
|
||||
Args:
|
||||
value: a dictionary with keys for the properties of the group.
|
||||
|
||||
Raises TypeError if the value doesn't have the right keys.
|
||||
"""
|
||||
for key in ('id', 'name', 'description', 'version', 'groups'):
|
||||
if key not in value:
|
||||
raise TypeError("UserPartition dict {0} missing value key '{1}'"
|
||||
.format(value, key))
|
||||
|
||||
if value["version"] != UserPartition.VERSION:
|
||||
raise TypeError("UserPartition dict {0} has unexpected version"
|
||||
.format(value))
|
||||
|
||||
groups = [Group.from_json(g) for g in value["groups"]]
|
||||
|
||||
return UserPartition(
|
||||
value["id"],
|
||||
value["name"],
|
||||
value["description"],
|
||||
groups
|
||||
)
|
||||
138
common/lib/xmodule/xmodule/partitions/partitions_service.py
Normal file
138
common/lib/xmodule/xmodule/partitions/partitions_service.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""
|
||||
This is a service-like API that assigns tracks which groups users are in for various
|
||||
user partitions. It uses the user_service key/value store provided by the LMS runtime to
|
||||
persist the assignments.
|
||||
"""
|
||||
import random
|
||||
from abc import ABCMeta, abstractproperty
|
||||
|
||||
|
||||
class PartitionService(object):
|
||||
"""
|
||||
This is an XBlock service that assigns tracks which groups users are in for various
|
||||
user partitions. It uses the provided user_tags service object to
|
||||
persist the assignments.
|
||||
"""
|
||||
__metaclass__ = ABCMeta
|
||||
|
||||
@abstractproperty
|
||||
def course_partitions(self):
|
||||
"""
|
||||
Return the set of partitions assigned to self._course_id
|
||||
"""
|
||||
raise NotImplementedError('Subclasses must implement course_partition')
|
||||
|
||||
def __init__(self, user_tags_service, course_id, track_function):
|
||||
self.random = random.Random()
|
||||
self._user_tags_service = user_tags_service
|
||||
self._course_id = course_id
|
||||
self._track_function = track_function
|
||||
|
||||
def get_user_group_for_partition(self, user_partition_id):
|
||||
"""
|
||||
If the user is already assigned to a group in user_partition_id, return the
|
||||
group_id.
|
||||
|
||||
If not, assign them to one of the groups, persist that decision, and
|
||||
return the group_id.
|
||||
|
||||
If the group they are assigned to doesn't exist anymore, re-assign to one of
|
||||
the existing groups and return its id.
|
||||
|
||||
Args:
|
||||
user_partition_id -- an id of a partition that's hopefully in the
|
||||
runtime.user_partitions list.
|
||||
|
||||
Returns:
|
||||
The id of one of the groups in the specified user_partition_id (as a string).
|
||||
|
||||
Raises:
|
||||
ValueError if the user_partition_id isn't found.
|
||||
"""
|
||||
user_partition = self._get_user_partition(user_partition_id)
|
||||
if user_partition is None:
|
||||
raise ValueError(
|
||||
"Configuration problem! No user_partition with id {0} "
|
||||
"in course {1}".format(user_partition_id, self._course_id)
|
||||
)
|
||||
|
||||
group_id = self._get_group(user_partition)
|
||||
|
||||
return group_id
|
||||
|
||||
def _get_user_partition(self, user_partition_id):
|
||||
"""
|
||||
Look for a user partition with a matching id in
|
||||
in the course's partitions.
|
||||
|
||||
Returns:
|
||||
A UserPartition, or None if not found.
|
||||
"""
|
||||
for partition in self.course_partitions:
|
||||
if partition.id == user_partition_id:
|
||||
return partition
|
||||
|
||||
return None
|
||||
|
||||
def _key_for_partition(self, user_partition):
|
||||
"""
|
||||
Returns the key to use to look up and save the user's group for a particular
|
||||
condition. Always use this function rather than constructing the key directly.
|
||||
"""
|
||||
return 'xblock.partition_service.partition_{0}'.format(user_partition.id)
|
||||
|
||||
def _get_group(self, user_partition):
|
||||
"""
|
||||
Return the group of the current user in user_partition. If they don't already have
|
||||
one assigned, pick one and save it. Uses the runtime's user_service service to look up
|
||||
and persist the info.
|
||||
"""
|
||||
key = self._key_for_partition(user_partition)
|
||||
scope = self._user_tags_service.COURSE_SCOPE
|
||||
|
||||
group_id = self._user_tags_service.get_tag(scope, key)
|
||||
if group_id is not None:
|
||||
group_id = int(group_id)
|
||||
|
||||
partition_group_ids = [group.id for group in user_partition.groups]
|
||||
|
||||
# If a valid group id has been saved already, return it
|
||||
if group_id is not None and group_id in partition_group_ids:
|
||||
return group_id
|
||||
|
||||
# TODO: what's the atomicity of the get above and the save here? If it's not in a
|
||||
# single transaction, we could get a situation where the user sees one state in one
|
||||
# thread, but then that decision gets overwritten--low probability, but still bad.
|
||||
|
||||
# (If it is truly atomic, we should be fine--if one process is in the
|
||||
# process of finding no group and making one, the other should block till it
|
||||
# appears. HOWEVER, if we allow reads by the second one while the first
|
||||
# process runs the transaction, we have a problem again: could read empty,
|
||||
# have the first transaction finish, and pick a different group in a
|
||||
# different process.)
|
||||
|
||||
# If a group id hasn't yet been saved, or the saved group id is invalid,
|
||||
# we need to pick one, save it, then return it
|
||||
|
||||
# TODO: had a discussion in arch council about making randomization more
|
||||
# deterministic (e.g. some hash). Could do that, but need to be careful not
|
||||
# to introduce correlation between users or bias in generation.
|
||||
|
||||
# See note above for explanation of local_random()
|
||||
group = self.random.choice(user_partition.groups)
|
||||
self._user_tags_service.set_tag(scope, key, group.id)
|
||||
|
||||
# emit event for analytics
|
||||
# FYI - context is always user ID that is logged in, NOT the user id that is
|
||||
# being operated on. If instructor can move user explicitly, then we should
|
||||
# put in event_info the user id that is being operated on.
|
||||
event_info = {
|
||||
'group_id': group.id,
|
||||
'group_name': group.name,
|
||||
'partition_id': user_partition.id,
|
||||
'partition_name': user_partition.name
|
||||
}
|
||||
# TODO: Use the XBlock publish api instead
|
||||
self._track_function('xmodule.partitions.assigned_user_to_partition', event_info)
|
||||
|
||||
return group.id
|
||||
286
common/lib/xmodule/xmodule/partitions/test_partitions.py
Normal file
286
common/lib/xmodule/xmodule/partitions/test_partitions.py
Normal file
@@ -0,0 +1,286 @@
|
||||
"""
|
||||
Test the partitions and partitions service
|
||||
|
||||
"""
|
||||
|
||||
from collections import defaultdict
|
||||
from unittest import TestCase
|
||||
from mock import Mock, MagicMock
|
||||
|
||||
from xmodule.partitions.partitions import Group, UserPartition
|
||||
from xmodule.partitions.partitions_service import PartitionService
|
||||
|
||||
|
||||
class TestGroup(TestCase):
|
||||
"""Test constructing groups"""
|
||||
def test_construct(self):
|
||||
test_id = 10
|
||||
name = "Grendel"
|
||||
group = Group(test_id, name)
|
||||
self.assertEqual(group.id, test_id)
|
||||
self.assertEqual(group.name, name)
|
||||
|
||||
def test_string_id(self):
|
||||
test_id = "10"
|
||||
name = "Grendel"
|
||||
group = Group(test_id, name)
|
||||
self.assertEqual(group.id, 10)
|
||||
|
||||
def test_to_json(self):
|
||||
test_id = 10
|
||||
name = "Grendel"
|
||||
group = Group(test_id, name)
|
||||
jsonified = group.to_json()
|
||||
act_jsonified = {
|
||||
"id": test_id,
|
||||
"name": name,
|
||||
"version": group.VERSION
|
||||
}
|
||||
self.assertEqual(jsonified, act_jsonified)
|
||||
|
||||
def test_from_json(self):
|
||||
test_id = 5
|
||||
name = "Grendel"
|
||||
jsonified = {
|
||||
"id": test_id,
|
||||
"name": name,
|
||||
"version": Group.VERSION
|
||||
}
|
||||
group = Group.from_json(jsonified)
|
||||
self.assertEqual(group.id, test_id)
|
||||
self.assertEqual(group.name, name)
|
||||
|
||||
def test_from_json_broken(self):
|
||||
test_id = 5
|
||||
name = "Grendel"
|
||||
# Bad version
|
||||
jsonified = {
|
||||
"id": test_id,
|
||||
"name": name,
|
||||
"version": 9001
|
||||
}
|
||||
with self.assertRaisesRegexp(TypeError, "has unexpected version"):
|
||||
group = Group.from_json(jsonified)
|
||||
|
||||
# Missing key "id"
|
||||
jsonified = {
|
||||
"name": name,
|
||||
"version": Group.VERSION
|
||||
}
|
||||
with self.assertRaisesRegexp(TypeError, "missing value key 'id'"):
|
||||
group = Group.from_json(jsonified)
|
||||
|
||||
# Has extra key - should not be a problem
|
||||
jsonified = {
|
||||
"id": test_id,
|
||||
"name": name,
|
||||
"version": Group.VERSION,
|
||||
"programmer": "Cale"
|
||||
}
|
||||
group = Group.from_json(jsonified)
|
||||
self.assertNotIn("programmer", group.to_json())
|
||||
|
||||
|
||||
class TestUserPartition(TestCase):
|
||||
"""Test constructing UserPartitions"""
|
||||
def test_construct(self):
|
||||
groups = [Group(0, 'Group 1'), Group(1, 'Group 2')]
|
||||
user_partition = UserPartition(0, 'Test Partition', 'for testing purposes', groups)
|
||||
self.assertEqual(user_partition.id, 0)
|
||||
self.assertEqual(user_partition.name, "Test Partition")
|
||||
self.assertEqual(user_partition.description, "for testing purposes")
|
||||
self.assertEqual(user_partition.groups, groups)
|
||||
|
||||
def test_string_id(self):
|
||||
groups = [Group(0, 'Group 1'), Group(1, 'Group 2')]
|
||||
user_partition = UserPartition("70", 'Test Partition', 'for testing purposes', groups)
|
||||
self.assertEqual(user_partition.id, 70)
|
||||
|
||||
def test_to_json(self):
|
||||
groups = [Group(0, 'Group 1'), Group(1, 'Group 2')]
|
||||
upid = 0
|
||||
upname = "Test Partition"
|
||||
updesc = "for testing purposes"
|
||||
user_partition = UserPartition(upid, upname, updesc, groups)
|
||||
|
||||
jsonified = user_partition.to_json()
|
||||
act_jsonified = {
|
||||
"id": upid,
|
||||
"name": upname,
|
||||
"description": updesc,
|
||||
"groups": [group.to_json() for group in groups],
|
||||
"version": user_partition.VERSION
|
||||
}
|
||||
self.assertEqual(jsonified, act_jsonified)
|
||||
|
||||
def test_from_json(self):
|
||||
groups = [Group(0, 'Group 1'), Group(1, 'Group 2')]
|
||||
upid = 1
|
||||
upname = "Test Partition"
|
||||
updesc = "For Testing Purposes"
|
||||
|
||||
jsonified = {
|
||||
"id": upid,
|
||||
"name": upname,
|
||||
"description": updesc,
|
||||
"groups": [group.to_json() for group in groups],
|
||||
"version": UserPartition.VERSION
|
||||
}
|
||||
user_partition = UserPartition.from_json(jsonified)
|
||||
self.assertEqual(user_partition.id, upid)
|
||||
self.assertEqual(user_partition.name, upname)
|
||||
self.assertEqual(user_partition.description, updesc)
|
||||
for act_group in user_partition.groups:
|
||||
self.assertIn(act_group.id, [0, 1])
|
||||
exp_group = groups[act_group.id]
|
||||
self.assertEqual(exp_group.id, act_group.id)
|
||||
self.assertEqual(exp_group.name, act_group.name)
|
||||
|
||||
def test_from_json_broken(self):
|
||||
groups = [Group(0, 'Group 1'), Group(1, 'Group 2')]
|
||||
upid = 1
|
||||
upname = "Test Partition"
|
||||
updesc = "For Testing Purposes"
|
||||
|
||||
# Missing field
|
||||
jsonified = {
|
||||
"name": upname,
|
||||
"description": updesc,
|
||||
"groups": [group.to_json() for group in groups],
|
||||
"version": UserPartition.VERSION
|
||||
}
|
||||
with self.assertRaisesRegexp(TypeError, "missing value key 'id'"):
|
||||
user_partition = UserPartition.from_json(jsonified)
|
||||
|
||||
# Wrong version (it's over 9000!)
|
||||
jsonified = {
|
||||
'id': upid,
|
||||
"name": upname,
|
||||
"description": updesc,
|
||||
"groups": [group.to_json() for group in groups],
|
||||
"version": 9001
|
||||
}
|
||||
with self.assertRaisesRegexp(TypeError, "has unexpected version"):
|
||||
user_partition = UserPartition.from_json(jsonified)
|
||||
|
||||
# Has extra key - should not be a problem
|
||||
jsonified = {
|
||||
'id': upid,
|
||||
"name": upname,
|
||||
"description": updesc,
|
||||
"groups": [group.to_json() for group in groups],
|
||||
"version": UserPartition.VERSION,
|
||||
"programmer": "Cale"
|
||||
}
|
||||
user_partition = UserPartition.from_json(jsonified)
|
||||
self.assertNotIn("programmer", user_partition.to_json())
|
||||
|
||||
|
||||
class StaticPartitionService(PartitionService):
|
||||
"""
|
||||
Mock PartitionService for testing.
|
||||
"""
|
||||
def __init__(self, partitions, **kwargs):
|
||||
super(StaticPartitionService, self).__init__(**kwargs)
|
||||
self._partitions = partitions
|
||||
|
||||
@property
|
||||
def course_partitions(self):
|
||||
return self._partitions
|
||||
|
||||
|
||||
class MemoryUserTagsService(object):
|
||||
"""
|
||||
An implementation of a user_tags XBlock service that
|
||||
uses an in-memory dictionary for storage
|
||||
"""
|
||||
COURSE_SCOPE = 'course'
|
||||
|
||||
def __init__(self):
|
||||
self._tags = defaultdict(dict)
|
||||
|
||||
def get_tag(self, scope, key):
|
||||
"""Sets the value of ``key`` to ``value``"""
|
||||
print 'GETTING', scope, key, self._tags
|
||||
return self._tags[scope].get(key)
|
||||
|
||||
def set_tag(self, scope, key, value):
|
||||
"""Gets the value of ``key``"""
|
||||
self._tags[scope][key] = value
|
||||
print 'SET', scope, key, value, self._tags
|
||||
|
||||
|
||||
class TestPartitionsService(TestCase):
|
||||
"""
|
||||
Test getting a user's group out of a partition
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
groups = [Group(0, 'Group 1'), Group(1, 'Group 2')]
|
||||
self.partition_id = 0
|
||||
|
||||
self.user_tags_service = MemoryUserTagsService()
|
||||
|
||||
user_partition = UserPartition(self.partition_id, 'Test Partition', 'for testing purposes', groups)
|
||||
self.partitions_service = StaticPartitionService(
|
||||
[user_partition],
|
||||
user_tags_service=self.user_tags_service,
|
||||
course_id=Mock(),
|
||||
track_function=Mock()
|
||||
)
|
||||
|
||||
def test_get_user_group_for_partition(self):
|
||||
# get a group assigned to the user
|
||||
group1 = self.partitions_service.get_user_group_for_partition(self.partition_id)
|
||||
|
||||
# make sure we get the same group back out if we try a second time
|
||||
group2 = self.partitions_service.get_user_group_for_partition(self.partition_id)
|
||||
|
||||
self.assertEqual(group1, group2)
|
||||
|
||||
# test that we error if given an invalid partition id
|
||||
with self.assertRaises(ValueError):
|
||||
self.partitions_service.get_user_group_for_partition(3)
|
||||
|
||||
def test_user_in_deleted_group(self):
|
||||
# get a group assigned to the user - should be group 0 or 1
|
||||
old_group = self.partitions_service.get_user_group_for_partition(self.partition_id)
|
||||
self.assertIn(old_group, [0, 1])
|
||||
|
||||
# Change the group definitions! No more group 0 or 1
|
||||
groups = [Group(3, 'Group 3'), Group(4, 'Group 4')]
|
||||
user_partition = UserPartition(self.partition_id, 'Test Partition', 'for testing purposes', groups)
|
||||
self.partitions_service = StaticPartitionService(
|
||||
[user_partition],
|
||||
user_tags_service=self.user_tags_service,
|
||||
course_id=Mock(),
|
||||
track_function=Mock()
|
||||
)
|
||||
|
||||
# Now, get a new group using the same call - should be 3 or 4
|
||||
new_group = self.partitions_service.get_user_group_for_partition(self.partition_id)
|
||||
self.assertIn(new_group, [3, 4])
|
||||
|
||||
# We should get the same group over multiple calls
|
||||
new_group_2 = self.partitions_service.get_user_group_for_partition(self.partition_id)
|
||||
self.assertEqual(new_group, new_group_2)
|
||||
|
||||
def test_change_group_name(self):
|
||||
# Changing the name of the group shouldn't affect anything
|
||||
# get a group assigned to the user - should be group 0 or 1
|
||||
old_group = self.partitions_service.get_user_group_for_partition(self.partition_id)
|
||||
self.assertIn(old_group, [0, 1])
|
||||
|
||||
# Change the group names
|
||||
groups = [Group(0, 'Group 0'), Group(1, 'Group 1')]
|
||||
user_partition = UserPartition(self.partition_id, 'Test Partition', 'for testing purposes', groups)
|
||||
self.partitions_service = StaticPartitionService(
|
||||
[user_partition],
|
||||
user_tags_service=self.user_tags_service,
|
||||
course_id=Mock(),
|
||||
track_function=Mock()
|
||||
)
|
||||
|
||||
# Now, get a new group using the same call
|
||||
new_group = self.partitions_service.get_user_group_for_partition(self.partition_id)
|
||||
self.assertEqual(old_group, new_group)
|
||||
39
common/lib/xmodule/xmodule/public/js/split_test_staff.js
Normal file
39
common/lib/xmodule/xmodule/public/js/split_test_staff.js
Normal file
@@ -0,0 +1,39 @@
|
||||
|
||||
/**
|
||||
* Creates a new selector for managing toggling which child to show
|
||||
* @constructor
|
||||
*/
|
||||
|
||||
function ABTestSelector(runtime, elem) {
|
||||
var _this = this;
|
||||
_this.elem = $(elem);
|
||||
_this.children = _this.elem.find('.split-test-child');
|
||||
_this.content_container = _this.elem.find('.split-test-child-container');
|
||||
|
||||
function select_child(group_id) {
|
||||
// iterate over all the children and hide all the ones that haven't been selected
|
||||
// and show the one that was selected
|
||||
_this.children.each(function() {
|
||||
// force this id to remain a string, even if it looks like something else
|
||||
var child_group_id = $(this).data('group-id').toString();
|
||||
if(child_group_id === group_id) {
|
||||
_this.content_container.html($(this).text());
|
||||
XBlock.initializeBlocks(_this.content_container);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
select = _this.elem.find('.split-test-select');
|
||||
cur_group_id = select.val();
|
||||
select_child(cur_group_id);
|
||||
|
||||
// bind the change event to the dropdown
|
||||
select.change(function() {
|
||||
group_id = $(this).val()
|
||||
select_child(group_id);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/* Javascript for the Split Test XBlock. */
|
||||
function SplitTestStudentView(runtime, element) {
|
||||
$.post(runtime.handlerUrl(element, 'log_child_render'));
|
||||
return {};
|
||||
}
|
||||
@@ -88,20 +88,19 @@ class SequenceModule(SequenceFields, XModule):
|
||||
rendered_child = child.render('student_view', context)
|
||||
fragment.add_frag_resources(rendered_child)
|
||||
|
||||
for grand_child in child.get_children():
|
||||
title = grand_child.display_name
|
||||
if title:
|
||||
break
|
||||
else:
|
||||
title = child.display_name_with_default
|
||||
titles = child.get_content_titles()
|
||||
print titles
|
||||
childinfo = {
|
||||
'content': rendered_child.content,
|
||||
'title': title,
|
||||
'title': "\n".join(titles),
|
||||
'page_title': titles[0] if titles else '',
|
||||
'progress_status': Progress.to_js_status_str(progress),
|
||||
'progress_detail': Progress.to_js_detail_str(progress),
|
||||
'type': child.get_icon_class(),
|
||||
'id': child.id,
|
||||
}
|
||||
if childinfo['title'] == '':
|
||||
childinfo['title'] = child.display_name_with_default
|
||||
contents.append(childinfo)
|
||||
|
||||
params = {'items': contents,
|
||||
|
||||
226
common/lib/xmodule/xmodule/split_test_module.py
Normal file
226
common/lib/xmodule/xmodule/split_test_module.py
Normal file
@@ -0,0 +1,226 @@
|
||||
"""
|
||||
Module for running content split tests
|
||||
"""
|
||||
|
||||
import logging
|
||||
from webob import Response
|
||||
|
||||
from xmodule.progress import Progress
|
||||
from xmodule.seq_module import SequenceDescriptor
|
||||
from xmodule.x_module import XModule, module_attr
|
||||
|
||||
from lxml import etree
|
||||
|
||||
from xblock.core import XBlock
|
||||
from xblock.fields import Scope, Integer, ReferenceValueDict
|
||||
from xblock.fragment import Fragment
|
||||
|
||||
log = logging.getLogger('edx.' + __name__)
|
||||
|
||||
|
||||
class SplitTestFields(object):
|
||||
"""Fields needed for split test module"""
|
||||
user_partition_id = Integer(
|
||||
help="Which user partition is used for this test",
|
||||
scope=Scope.content
|
||||
)
|
||||
|
||||
# group_id is an int
|
||||
# child is a serialized UsageId (aka Location). This child
|
||||
# location needs to actually match one of the children of this
|
||||
# Block. (expected invariant that we'll need to test, and handle
|
||||
# authoring tools that mess this up)
|
||||
|
||||
# TODO: is there a way to add some validation around this, to
|
||||
# be run on course load or in studio or ....
|
||||
|
||||
group_id_to_child = ReferenceValueDict(
|
||||
help="Which child module students in a particular group_id should see",
|
||||
scope=Scope.content
|
||||
)
|
||||
|
||||
|
||||
@XBlock.needs('user_tags')
|
||||
@XBlock.needs('partitions')
|
||||
class SplitTestModule(SplitTestFields, XModule):
|
||||
"""
|
||||
Show the user the appropriate child. Uses the ExperimentState
|
||||
API to figure out which child to show.
|
||||
|
||||
Course staff still get put in an experimental condition, but have the option
|
||||
to see the other conditions. The only thing that counts toward their
|
||||
grade/progress is the condition they are actually in.
|
||||
|
||||
Technical notes:
|
||||
- There is more dark magic in this code than I'd like. The whole varying-children +
|
||||
grading interaction is a tangle between super and subclasses of descriptors and
|
||||
modules.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
super(SplitTestModule, self).__init__(*args, **kwargs)
|
||||
|
||||
self.child_descriptor = self.get_child_descriptors()[0]
|
||||
if self.child_descriptor is not None:
|
||||
self.child = self.system.get_module(self.child_descriptor)
|
||||
else:
|
||||
self.child = None
|
||||
|
||||
def get_child_descriptor_by_location(self, location):
|
||||
"""
|
||||
Look through the children and look for one with the given location.
|
||||
Returns the descriptor.
|
||||
If none match, return None
|
||||
"""
|
||||
# NOTE: calling self.get_children() creates a circular reference--
|
||||
# it calls get_child_descriptors() internally, but that doesn't work until
|
||||
# we've picked a choice. Use self.descriptor.get_children() instead.
|
||||
|
||||
for child in self.descriptor.get_children():
|
||||
if child.location.url() == location:
|
||||
return child
|
||||
|
||||
return None
|
||||
|
||||
def get_content_titles(self):
|
||||
"""
|
||||
Returns list of content titles for split_test's child.
|
||||
|
||||
This overwrites the get_content_titles method included in x_module by default.
|
||||
|
||||
WHY THIS OVERWRITE IS NECESSARY: If we fetch *all* of split_test's children,
|
||||
we'll end up getting all of the possible conditions users could ever see.
|
||||
Ex: If split_test shows a video to group A and HTML to group B, the
|
||||
regular get_content_titles in x_module will get the title of BOTH the video
|
||||
AND the HTML.
|
||||
|
||||
We only want the content titles that should actually be displayed to the user.
|
||||
|
||||
split_test's .child property contains *only* the child that should actually
|
||||
be shown to the user, so we call get_content_titles() on only that child.
|
||||
"""
|
||||
return self.child.get_content_titles()
|
||||
|
||||
def get_child_descriptors(self):
|
||||
"""
|
||||
For grading--return just the chosen child.
|
||||
"""
|
||||
group_id = self.runtime.service(self, 'partitions').get_user_group_for_partition(self.user_partition_id)
|
||||
|
||||
# group_id_to_child comes from json, so it has to have string keys
|
||||
str_group_id = str(group_id)
|
||||
if str_group_id in self.group_id_to_child:
|
||||
child_location = self.group_id_to_child[str_group_id]
|
||||
child_descriptor = self.get_child_descriptor_by_location(child_location)
|
||||
else:
|
||||
# Oops. Config error.
|
||||
log.debug("configuration error in split test module: invalid group_id %r (not one of %r). Showing error", str_group_id, self.group_id_to_child.keys())
|
||||
|
||||
if child_descriptor is None:
|
||||
# Peak confusion is great. Now that we set child_descriptor,
|
||||
# get_children() should return a list with one element--the
|
||||
# xmodule for the child
|
||||
log.debug("configuration error in split test module: no such child")
|
||||
return []
|
||||
|
||||
return [child_descriptor]
|
||||
|
||||
def _staff_view(self, context):
|
||||
"""
|
||||
Render the staff view for a split test module.
|
||||
"""
|
||||
fragment = Fragment()
|
||||
contents = []
|
||||
|
||||
for group_id in self.group_id_to_child:
|
||||
child_location = self.group_id_to_child[group_id]
|
||||
child_descriptor = self.get_child_descriptor_by_location(child_location)
|
||||
child = self.system.get_module(child_descriptor)
|
||||
rendered_child = child.render('student_view', context)
|
||||
fragment.add_frag_resources(rendered_child)
|
||||
|
||||
contents.append({
|
||||
'group_id': group_id,
|
||||
'id': child.id,
|
||||
'content': rendered_child.content
|
||||
})
|
||||
|
||||
# Use the new template
|
||||
fragment.add_content(self.system.render_template('split_test_staff_view.html', {
|
||||
'items': contents,
|
||||
}))
|
||||
fragment.add_css('.split-test-child { display: none; }')
|
||||
fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/split_test_staff.js'))
|
||||
fragment.initialize_js('ABTestSelector')
|
||||
return fragment
|
||||
|
||||
def student_view(self, context):
|
||||
"""
|
||||
Render the contents of the chosen condition for students, and all the
|
||||
conditions for staff.
|
||||
"""
|
||||
if self.child is None:
|
||||
# raise error instead? In fact, could complain on descriptor load...
|
||||
return Fragment(content=u"<div>Nothing here. Move along.</div>")
|
||||
|
||||
if self.system.user_is_staff:
|
||||
return self._staff_view(context)
|
||||
else:
|
||||
child_fragment = self.child.render('student_view', context)
|
||||
fragment = Fragment(self.system.render_template('split_test_student_view.html', {
|
||||
'child_content': child_fragment.content,
|
||||
'child_id': self.child.scope_ids.usage_id,
|
||||
}))
|
||||
fragment.add_frag_resources(child_fragment)
|
||||
fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/split_test_student.js'))
|
||||
fragment.initialize_js('SplitTestStudentView')
|
||||
return fragment
|
||||
|
||||
@XBlock.handler
|
||||
def log_child_render(self, request, suffix=''): # pylint: disable=unused-argument
|
||||
"""
|
||||
Record in the tracking logs which child was rendered
|
||||
"""
|
||||
# TODO: use publish instead, when publish is wired to the tracking logs
|
||||
self.system.track_function('xblock.split_test.child_render', {'child-id': self.child.scope_ids.usage_id})
|
||||
return Response()
|
||||
|
||||
def get_icon_class(self):
|
||||
return self.child.get_icon_class() if self.child else 'other'
|
||||
|
||||
def get_progress(self):
|
||||
children = self.get_children()
|
||||
progresses = [child.get_progress() for child in children]
|
||||
progress = reduce(Progress.add_counts, progresses, None)
|
||||
return progress
|
||||
|
||||
|
||||
@XBlock.needs('user_tags')
|
||||
@XBlock.needs('partitions')
|
||||
class SplitTestDescriptor(SplitTestFields, SequenceDescriptor):
|
||||
# the editing interface can be the same as for sequences -- just a container
|
||||
module_class = SplitTestModule
|
||||
|
||||
filename_extension = "xml"
|
||||
|
||||
child_descriptor = module_attr('child_descriptor')
|
||||
log_child_render = module_attr('log_child_render')
|
||||
get_content_titles = module_attr('get_content_titles')
|
||||
|
||||
def definition_to_xml(self, resource_fs):
|
||||
|
||||
xml_object = etree.Element('split_test')
|
||||
# TODO: also save the experiment id and the condition map
|
||||
for child in self.get_children():
|
||||
xml_object.append(
|
||||
etree.fromstring(child.export_to_xml(resource_fs)))
|
||||
return xml_object
|
||||
|
||||
def has_dynamic_children(self):
|
||||
"""
|
||||
Grading needs to know that only one of the children is actually "real". This
|
||||
makes it use module.get_child_descriptors().
|
||||
"""
|
||||
return True
|
||||
|
||||
122
common/lib/xmodule/xmodule/tests/test_split_module.py
Normal file
122
common/lib/xmodule/xmodule/tests/test_split_module.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""
|
||||
Tests for the Split Testing Module
|
||||
"""
|
||||
import ddt
|
||||
from mock import Mock
|
||||
|
||||
from xmodule.tests.xml import factories as xml
|
||||
from xmodule.tests.xml import XModuleXmlImportTest
|
||||
from xmodule.tests import get_test_system
|
||||
from xmodule.partitions.partitions import Group, UserPartition
|
||||
from xmodule.partitions.test_partitions import StaticPartitionService, MemoryUserTagsService
|
||||
|
||||
|
||||
class SplitTestModuleFactory(xml.XmlImportFactory):
|
||||
"""
|
||||
Factory for generating SplitTestModules for testing purposes
|
||||
"""
|
||||
tag = 'split_test'
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class SplitTestModuleTest(XModuleXmlImportTest):
|
||||
"""
|
||||
Test the split test module
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.course_id = 'test_org/test_course_number/test_run'
|
||||
# construct module
|
||||
course = xml.CourseFactory.build()
|
||||
sequence = xml.SequenceFactory.build(parent=course)
|
||||
split_test = SplitTestModuleFactory(
|
||||
parent=sequence,
|
||||
attribs={
|
||||
'user_partition_id': '0',
|
||||
'group_id_to_child': '{"0": "i4x://edX/xml_test_course/html/split_test_cond0", "1": "i4x://edX/xml_test_course/html/split_test_cond1"}'
|
||||
}
|
||||
)
|
||||
xml.HtmlFactory(parent=split_test, url_name='split_test_cond0', text='HTML FOR GROUP 0')
|
||||
xml.HtmlFactory(parent=split_test, url_name='split_test_cond1', text='HTML FOR GROUP 1')
|
||||
|
||||
self.course = self.process_xml(course)
|
||||
course_seq = self.course.get_children()[0]
|
||||
self.module_system = get_test_system()
|
||||
|
||||
def get_module(descriptor):
|
||||
module_system = get_test_system()
|
||||
module_system.get_module = get_module
|
||||
descriptor.bind_for_student(module_system, descriptor._field_data)
|
||||
return descriptor
|
||||
|
||||
self.module_system.get_module = get_module
|
||||
self.module_system.descriptor_system = self.course.runtime
|
||||
|
||||
self.tags_service = MemoryUserTagsService()
|
||||
self.module_system._services['user_tags'] = self.tags_service # pylint: disable=protected-access
|
||||
|
||||
self.partitions_service = StaticPartitionService(
|
||||
[
|
||||
UserPartition(0, 'first_partition', 'First Partition', [Group("0", 'alpha'), Group("1", 'beta')]),
|
||||
UserPartition(1, 'second_partition', 'Second Partition', [Group("0", 'abel'), Group("1", 'baker'), Group("2", 'charlie')])
|
||||
],
|
||||
user_tags_service=self.tags_service,
|
||||
course_id=self.course.id,
|
||||
track_function=Mock(name='track_function'),
|
||||
)
|
||||
self.module_system._services['partitions'] = self.partitions_service # pylint: disable=protected-access
|
||||
|
||||
self.split_test_module = course_seq.get_children()[0]
|
||||
self.split_test_module.bind_for_student(self.module_system, self.split_test_module._field_data)
|
||||
|
||||
|
||||
@ddt.data(('0', 'split_test_cond0'), ('1', 'split_test_cond1'))
|
||||
@ddt.unpack
|
||||
def test_child(self, user_tag, child_url_name):
|
||||
self.tags_service.set_tag(
|
||||
self.tags_service.COURSE_SCOPE,
|
||||
'xblock.partition_service.partition_0',
|
||||
user_tag
|
||||
)
|
||||
|
||||
self.assertEquals(self.split_test_module.child_descriptor.url_name, child_url_name)
|
||||
|
||||
@ddt.data(('0',), ('1',))
|
||||
@ddt.unpack
|
||||
def test_child_old_tag_value(self, user_tag):
|
||||
# If user_tag has a stale value, we should still get back a valid child url
|
||||
self.tags_service.set_tag(
|
||||
self.tags_service.COURSE_SCOPE,
|
||||
'xblock.partition_service.partition_0',
|
||||
'2'
|
||||
)
|
||||
|
||||
self.assertIn(self.split_test_module.child_descriptor.url_name, ['split_test_cond0', 'split_test_cond1'])
|
||||
|
||||
@ddt.data(('0', 'HTML FOR GROUP 0'), ('1', 'HTML FOR GROUP 1'))
|
||||
@ddt.unpack
|
||||
def test_get_html(self, user_tag, child_content):
|
||||
self.tags_service.set_tag(
|
||||
self.tags_service.COURSE_SCOPE,
|
||||
'xblock.partition_service.partition_0',
|
||||
user_tag
|
||||
)
|
||||
|
||||
self.assertIn(
|
||||
child_content,
|
||||
self.module_system.render(self.split_test_module, 'student_view').content
|
||||
)
|
||||
|
||||
@ddt.data(('0',), ('1',))
|
||||
@ddt.unpack
|
||||
def test_child_missing_tag_value(self, user_tag):
|
||||
# If user_tag has a missing value, we should still get back a valid child url
|
||||
self.assertIn(self.split_test_module.child_descriptor.url_name, ['split_test_cond0', 'split_test_cond1'])
|
||||
|
||||
@ddt.data(('100',), ('200',), ('300',), ('400',), ('500',), ('600',), ('700',), ('800',), ('900',), ('1000',))
|
||||
@ddt.unpack
|
||||
def test_child_persist_new_tag_value_when_tag_missing(self, user_tag):
|
||||
# If a user_tag has a missing value, a group should be saved/persisted for that user.
|
||||
# So, we check that we get the same url_name when we call on the url_name twice.
|
||||
# We run the test ten times so that, if our storage is failing, we'll be most likely to notice it.
|
||||
self.assertEquals(self.split_test_module.child_descriptor.url_name, self.split_test_module.child_descriptor.url_name)
|
||||
@@ -72,6 +72,7 @@ class XmlImportFactory(Factory):
|
||||
url_name = Sequence(str)
|
||||
attribs = {}
|
||||
policy = {}
|
||||
inline_xml = True
|
||||
tag = 'unknown'
|
||||
|
||||
@classmethod
|
||||
@@ -95,10 +96,26 @@ class XmlImportFactory(Factory):
|
||||
kwargs['xml_node'].text = kwargs.pop('text', None)
|
||||
|
||||
kwargs['xml_node'].attrib.update(kwargs.pop('attribs', {}))
|
||||
|
||||
# Make sure that the xml_module doesn't try and open a file to find the contents
|
||||
# of this node.
|
||||
inline_xml = kwargs.pop('inline_xml')
|
||||
|
||||
if inline_xml:
|
||||
kwargs['xml_node'].set('not_a_pointer', 'true')
|
||||
|
||||
for key in kwargs.keys():
|
||||
if key not in XML_IMPORT_ARGS:
|
||||
kwargs['xml_node'].set(key, kwargs.pop(key))
|
||||
|
||||
if not inline_xml:
|
||||
kwargs['xml_node'].write(
|
||||
kwargs['filesystem'].open(
|
||||
'{}/{}.xml'.format(kwargs['tag'], kwargs['url_name'])
|
||||
),
|
||||
encoding='utf-8'
|
||||
)
|
||||
|
||||
return kwargs
|
||||
|
||||
@lazy_attribute
|
||||
@@ -129,8 +146,17 @@ class SequenceFactory(XmlImportFactory):
|
||||
"""Factory for <sequential> nodes"""
|
||||
tag = 'sequential'
|
||||
|
||||
class VerticalFactory(XmlImportFactory):
|
||||
"""Factory for <vertical> nodes"""
|
||||
tag = 'vertical'
|
||||
|
||||
|
||||
class ProblemFactory(XmlImportFactory):
|
||||
"""Factory for <problem> nodes"""
|
||||
tag = 'problem'
|
||||
text = '<h1>Empty Problem!</h1>'
|
||||
|
||||
|
||||
class HtmlFactory(XmlImportFactory):
|
||||
"""Factory for <html> nodes"""
|
||||
tag = 'html'
|
||||
|
||||
@@ -218,6 +218,31 @@ class XModuleMixin(XBlockMixin):
|
||||
self.save()
|
||||
return self._field_data._kvs # pylint: disable=protected-access
|
||||
|
||||
def get_content_titles(self):
|
||||
"""
|
||||
Returns list of content titles for all of self's children.
|
||||
|
||||
SEQUENCE
|
||||
|
|
||||
VERTICAL
|
||||
/ \
|
||||
SPLIT_TEST DISCUSSION
|
||||
/ \
|
||||
VIDEO A VIDEO B
|
||||
|
||||
Essentially, this function returns a list of display_names (e.g. content titles)
|
||||
for all of the leaf nodes. In the diagram above, calling get_content_titles on
|
||||
SEQUENCE would return the display_names of `VIDEO A`, `VIDEO B`, and `DISCUSSION`.
|
||||
|
||||
This is most obviously useful for sequence_modules, which need this list to display
|
||||
tooltips to users, though in theory this should work for any tree that needs
|
||||
the display_names of all its leaf nodes.
|
||||
"""
|
||||
if self.has_children:
|
||||
return sum((child.get_content_titles() for child in self.get_children()), [])
|
||||
else:
|
||||
return [self.display_name_with_default]
|
||||
|
||||
def get_children(self):
|
||||
"""Returns a list of XBlock instances for the children of
|
||||
this module"""
|
||||
|
||||
@@ -63,23 +63,30 @@ describe 'ResponseCommentShowView', ->
|
||||
@comment.unflagAbuse()
|
||||
expect(@comment.get 'abuse_flaggers').toEqual []
|
||||
|
||||
describe 'comment deletion', ->
|
||||
describe '_delete', ->
|
||||
|
||||
it 'triggers the delete event when the delete icon is clicked', ->
|
||||
it 'triggers on the correct events', ->
|
||||
DiscussionUtil.loadRoles []
|
||||
@comment.updateInfo {ability: {'can_delete': true}}
|
||||
@view.render()
|
||||
DiscussionViewSpecHelper.checkButtonEvents(@view, "_delete", ".action-delete")
|
||||
|
||||
it 'triggers the delete event', ->
|
||||
triggerTarget = jasmine.createSpy()
|
||||
@view.bind "comment:_delete", triggerTarget
|
||||
@view.render()
|
||||
@view.$el.find('.action-delete').click()
|
||||
@view._delete()
|
||||
expect(triggerTarget).toHaveBeenCalled()
|
||||
|
||||
describe 'comment edit', ->
|
||||
describe 'edit', ->
|
||||
|
||||
it 'triggers comment:edit when the edit button is clicked', ->
|
||||
it 'triggers on the correct events', ->
|
||||
DiscussionUtil.loadRoles []
|
||||
@comment.updateInfo {ability: {'can_edit': true}}
|
||||
@view.render()
|
||||
DiscussionViewSpecHelper.checkButtonEvents(@view, "edit", ".action-edit")
|
||||
|
||||
it 'triggers comment:edit when the edit button is clicked', ->
|
||||
triggerTarget = jasmine.createSpy()
|
||||
@view.bind "comment:edit", triggerTarget
|
||||
@view.render()
|
||||
@view.$el.find(".action-edit").click()
|
||||
@view.edit()
|
||||
expect(triggerTarget).toHaveBeenCalled()
|
||||
|
||||
@@ -2,8 +2,14 @@ if Backbone?
|
||||
class @ResponseCommentShowView extends DiscussionContentView
|
||||
|
||||
events:
|
||||
"click .action-delete": "_delete"
|
||||
"click .action-edit": "edit"
|
||||
"click .action-delete":
|
||||
(event) -> @_delete(event)
|
||||
"keydown .action-delete":
|
||||
(event) -> DiscussionUtil.activateOnSpace(event, @_delete)
|
||||
"click .action-edit":
|
||||
(event) -> @edit(event)
|
||||
"keydown .action-edit":
|
||||
(event) -> DiscussionUtil.activateOnSpace(event, @edit)
|
||||
|
||||
tagName: "li"
|
||||
|
||||
@@ -52,7 +58,7 @@ if Backbone?
|
||||
else if DiscussionUtil.isTA(@model.get("user_id"))
|
||||
@$el.find("a.profile-link").after('<span class="community-ta-label">' + gettext('Community TA') + '</span>')
|
||||
|
||||
_delete: (event) ->
|
||||
_delete: (event) =>
|
||||
@trigger "comment:_delete", event
|
||||
|
||||
renderFlagged: =>
|
||||
|
||||
19
common/test/data/split_test_module/course.xml
Normal file
19
common/test/data/split_test_module/course.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<course url_name='split_test_course' org='split_test' course='split_test'>
|
||||
<chapter>
|
||||
<sequential>
|
||||
<vertical>
|
||||
<split_test url_name="split1" user_partition_id="0" group_id_to_child='{"0": "i4x://split_test/split_test/vertical/sample_0", "2": "i4x://split_test/split_test/vertical/sample_2"}'>
|
||||
<vertical url_name="sample_0">
|
||||
<html>Here is a prompt for group 0, please respond in the discussion.</html>
|
||||
<discussion for="split test discussion 0" id="split_test_d0" discussion_category="Lectures"/>
|
||||
</vertical>
|
||||
|
||||
<vertical url_name="sample_2">
|
||||
<html>Here is a prompt for group 2, please respond in the discussion.</html>
|
||||
<discussion for="split test discussion 2" id="split_test_d2" discussion_category="Lectures"/>
|
||||
</vertical>
|
||||
</split_test>
|
||||
</vertical>
|
||||
</sequential>
|
||||
</chapter>
|
||||
</course>
|
||||
76
docs/developers/source/experiments.rst
Normal file
76
docs/developers/source/experiments.rst
Normal file
@@ -0,0 +1,76 @@
|
||||
*******************************************
|
||||
Content experiments
|
||||
*******************************************
|
||||
|
||||
This is a brief overview of the support for content experiments in the platform.
|
||||
|
||||
For now, there is only one type of experiment: content split testing. This lets course authors define an experiment with several *experimental conditions*, add xblocks that reference that experiment in various places in the course, and specify what content students in each experimental condition should see. The LMS provides a way to randomly assign students to experimental conditions for each experiment, so that they see the right content at runtime.
|
||||
|
||||
Experimental conditions are essentially just a set of groups to partition users into. This may be useful to other non-experiment uses, so the implementation is done via a generic UserPartition interface. Copying the doc string, a UserPartition is:
|
||||
|
||||
A named way to partition users into groups, primarily intended for running
|
||||
experiments. It is expected that each user will be in at most one group in a
|
||||
partition.
|
||||
|
||||
A Partition has an id, name, description, and a list of groups.
|
||||
The id is intended to be unique within the context where these are used. (e.g. for
|
||||
partitions of users within a course, the ids should be unique per-course)
|
||||
|
||||
There is an XModule helper library ``partitions_service`` that helps manage user partitions from XBlocks (at the moment just from the split_test module). It provides an interface to store and retrieve the groups a user is in for particular partitions.
|
||||
|
||||
User assignments to particular groups within a partition must be persisted. This is done via a User Info service provided by the XBlock runtime, which exposes a generic user tagging interface, allowing storing key-value pairs for the user scoped to a particular course.
|
||||
|
||||
UserPartitions are configured at the course level (makes sense in Studio, for author context, and there's no XBlock scope to store per-course configuration state), and currently exposed via the LMS XBlock runtime as ``runtime.user_partitions``.
|
||||
|
||||
More details on the components below.
|
||||
|
||||
|
||||
User metadata service
|
||||
---------------------
|
||||
|
||||
Goals: provide a standard way to store information about users, to be used e.g. by XBlocks, and make that information easily accessible when looking at analytics.
|
||||
|
||||
When the course context is added to the analytics events, it should add the user's course-specific tags as well.
|
||||
When the users global context is added to analytics events, it should add the user's global tags.
|
||||
|
||||
We have a ``user_api`` app, which has REST interface to "User Preferences" for global preferences, and now a ``user_service.py`` interface that exposes per-course tags, with string keys (<=255 chars) and arbitrary string values. The intention is that the values are fairly short, as they will be included in all analytics events about this user.
|
||||
|
||||
The XBlock runtime includes a ``UserServiceInterface`` mixin that provides access to this interface, automatically filling in the current user and course context. This means that with the current design, an XBlock can't access tags for other users or from other courses.
|
||||
|
||||
To avoid name collisions in the keys, we rely on convention. e.g. the XBlock partition service uses ``'xblock.partition_service.partition_{0}'.format(user_partition.id)``.
|
||||
|
||||
|
||||
|
||||
Where the code is:
|
||||
----------------
|
||||
|
||||
|
||||
common:
|
||||
|
||||
- partitions library--defines UserPartitions, provides partitions_service API.
|
||||
- split_test_module -- a block that has one child per experimental condition (could be a vertical or other container with more blocks inside), and config specifying which child corresponds to which condition.
|
||||
- course_module -- a course has a list of UserPartitions, each of which specifies the set of groups to divide users into.
|
||||
|
||||
LMS:
|
||||
|
||||
- runtime--LmsUserPartitions, UserServiceMixin mixins. Provides a way for the partition_service to get the list of UserPartitions defined in a course, and get/set per-user tags within a course scope.
|
||||
- user_api app -- provides persistence for the user tags.
|
||||
|
||||
Things to watch out for (some not implemented yet):
|
||||
-------------------------------------------
|
||||
|
||||
- grade export needs to be smarter, because different students can see different graded things
|
||||
- grading needs to only grade the children that a particular student sees (so if there are problems in both conditions in a split_test, any student would see only one set)
|
||||
- ui -- icons in sequences need to be passed through
|
||||
- tooltips need to be passed through
|
||||
- author changes post-release: conditions can be added or deleted after an experiment is live. This is usually a bad idea, but can be useful, so it's allowed. Need to handle all the cases.
|
||||
- analytics logging needs to log all the user tags (if we really think it's a good idea). We'll probably want to cache the tags in memory for the duration of the request, being careful that they may change as the request is processed.
|
||||
- need to add a "hiding" interface to XBlocks that verticals, sequentials, and courses understand, to hide children that set it. Then give the split test module a way to say that particular condition should be empty and hidden, and pass that up.
|
||||
- staff view should show all the conditions, clearly marked
|
||||
|
||||
Things to test:
|
||||
- randomization
|
||||
- persistence
|
||||
- correlation between test that use the same groups
|
||||
- non-correlation between tests that use different groups
|
||||
|
||||
@@ -24,6 +24,7 @@ APIs
|
||||
|
||||
djangoapps.rst
|
||||
common-lib.rst
|
||||
experiments.rst
|
||||
|
||||
Internationalization
|
||||
---------------------
|
||||
|
||||
244
lms/djangoapps/courseware/tests/test_split_module.py
Normal file
244
lms/djangoapps/courseware/tests/test_split_module.py
Normal file
@@ -0,0 +1,244 @@
|
||||
"""
|
||||
Test for split test XModule
|
||||
"""
|
||||
import ddt
|
||||
from mock import MagicMock, patch, Mock
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.test.utils import override_settings
|
||||
|
||||
from student.tests.factories import UserFactory, CourseEnrollmentFactory, AdminFactory
|
||||
from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE
|
||||
from xmodule.modulestore.tests.factories import ItemFactory, CourseFactory
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
|
||||
from xmodule.partitions.partitions import Group, UserPartition
|
||||
from xmodule.partitions.test_partitions import StaticPartitionService
|
||||
from user_api.tests.factories import UserCourseTagFactory
|
||||
from xmodule.partitions.partitions import Group, UserPartition
|
||||
|
||||
|
||||
@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
|
||||
class SplitTestBase(ModuleStoreTestCase):
|
||||
__test__ = False
|
||||
|
||||
def setUp(self):
|
||||
self.partition = UserPartition(
|
||||
0,
|
||||
'first_partition',
|
||||
'First Partition',
|
||||
[
|
||||
Group(0, 'alpha'),
|
||||
Group(1, 'beta')
|
||||
]
|
||||
)
|
||||
|
||||
self.course = CourseFactory.create(
|
||||
number=self.COURSE_NUMBER,
|
||||
user_partitions=[self.partition]
|
||||
)
|
||||
|
||||
self.chapter = ItemFactory.create(
|
||||
parent_location=self.course.location,
|
||||
category="chapter",
|
||||
display_name="test chapter",
|
||||
)
|
||||
self.sequential = ItemFactory.create(
|
||||
parent_location=self.chapter.location,
|
||||
category="sequential",
|
||||
display_name="Split Test Tests",
|
||||
)
|
||||
|
||||
self.student = UserFactory.create()
|
||||
CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id)
|
||||
self.client.login(username=self.student.username, password='test')
|
||||
|
||||
def _video(self, parent, group):
|
||||
return ItemFactory.create(
|
||||
parent_location=parent.location,
|
||||
category="video",
|
||||
display_name="Group {} Sees This Video".format(group),
|
||||
)
|
||||
|
||||
def _problem(self, parent, group):
|
||||
return ItemFactory.create(
|
||||
parent_location=parent.location,
|
||||
category="problem",
|
||||
display_name="Group {} Sees This Problem".format(group),
|
||||
data="<h1>No Problem Defined Yet!</h1>",
|
||||
)
|
||||
|
||||
def _html(self, parent, group):
|
||||
return ItemFactory.create(
|
||||
parent_location=parent.location,
|
||||
category="html",
|
||||
display_name="Group {} Sees This HTML".format(group),
|
||||
data="Some HTML for group {}".format(group),
|
||||
)
|
||||
|
||||
def test_split_test_0(self):
|
||||
self._check_split_test(0)
|
||||
|
||||
def test_split_test_1(self):
|
||||
self._check_split_test(1)
|
||||
|
||||
def _check_split_test(self, user_tag):
|
||||
tag_factory = UserCourseTagFactory(
|
||||
user=self.student,
|
||||
course_id=self.course.id,
|
||||
key='xblock.partition_service.partition_{0}'.format(self.partition.id),
|
||||
value=str(user_tag)
|
||||
)
|
||||
|
||||
resp = self.client.get(reverse('courseware_section',
|
||||
kwargs={'course_id': self.course.id,
|
||||
'chapter': self.chapter.url_name,
|
||||
'section': self.sequential.url_name}
|
||||
))
|
||||
|
||||
content = resp.content
|
||||
print content
|
||||
|
||||
# Assert we see the proper icon in the top display
|
||||
self.assertIn('<a class="{} inactive progress-0"'.format(self.ICON_CLASSES[user_tag]), content)
|
||||
# And proper tooltips
|
||||
for tooltip in self.TOOLTIPS[user_tag]:
|
||||
self.assertIn(tooltip, content)
|
||||
|
||||
for hidden in self.HIDDEN_CONTENT[user_tag]:
|
||||
self.assertNotIn(hidden, content)
|
||||
|
||||
# Assert that we can see the data from the appropriate test condition
|
||||
for visible in self.VISIBLE_CONTENT[user_tag]:
|
||||
self.assertIn(visible, content)
|
||||
|
||||
|
||||
class TestVertSplitTestVert(SplitTestBase):
|
||||
"""
|
||||
Tests related to xmodule/split_test_module
|
||||
"""
|
||||
__test__ = True
|
||||
|
||||
COURSE_NUMBER='vert-split-vert'
|
||||
|
||||
ICON_CLASSES = [
|
||||
'seq_problem',
|
||||
'seq_video',
|
||||
]
|
||||
TOOLTIPS = [
|
||||
['Group 0 Sees This Video', "Group 0 Sees This Problem"],
|
||||
['Group 1 Sees This Video', 'Group 1 Sees This HTML'],
|
||||
]
|
||||
HIDDEN_CONTENT = [
|
||||
['Condition 0 vertical'],
|
||||
['Condition 1 vertical'],
|
||||
]
|
||||
|
||||
# Data is html encoded, because it's inactive inside the
|
||||
# sequence until javascript is executed
|
||||
VISIBLE_CONTENT = [
|
||||
['class="problems-wrapper'],
|
||||
['Some HTML for group 1']
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
super(TestVertSplitTestVert, self).setUp()
|
||||
|
||||
# vert <- split_test
|
||||
# split_test cond 0 = vert <- {video, problem}
|
||||
# split_test cond 1 = vert <- {video, html}
|
||||
vert1 = ItemFactory.create(
|
||||
parent_location=self.sequential.location,
|
||||
category="vertical",
|
||||
display_name="Split test vertical",
|
||||
)
|
||||
c0_url = self.course.location._replace(category="vertical", name="split_test_cond0")
|
||||
c1_url = self.course.location._replace(category="vertical", name="split_test_cond1")
|
||||
|
||||
split_test = ItemFactory.create(
|
||||
parent_location=vert1.location,
|
||||
category="split_test",
|
||||
display_name="Split test",
|
||||
user_partition_id='0',
|
||||
group_id_to_child={"0": c0_url.url(), "1": c1_url.url()},
|
||||
)
|
||||
|
||||
cond0vert = ItemFactory.create(
|
||||
parent_location=split_test.location,
|
||||
category="vertical",
|
||||
display_name="Condition 0 vertical",
|
||||
location=c0_url,
|
||||
)
|
||||
video0 = self._video(cond0vert, 0)
|
||||
problem0 = self._problem(cond0vert, 0)
|
||||
|
||||
cond1vert = ItemFactory.create(
|
||||
parent_location=split_test.location,
|
||||
category="vertical",
|
||||
display_name="Condition 1 vertical",
|
||||
location=c1_url,
|
||||
)
|
||||
video1 = self._video(cond1vert, 1)
|
||||
html1 = self._html(cond1vert, 1)
|
||||
|
||||
|
||||
class TestSplitTestVert(SplitTestBase):
|
||||
"""
|
||||
Tests related to xmodule/split_test_module
|
||||
"""
|
||||
__test__ = True
|
||||
|
||||
COURSE_NUMBER = 'split-vert'
|
||||
|
||||
ICON_CLASSES = [
|
||||
'seq_problem',
|
||||
'seq_video',
|
||||
]
|
||||
TOOLTIPS = [
|
||||
['Group 0 Sees This Video', "Group 0 Sees This Problem"],
|
||||
['Group 1 Sees This Video', 'Group 1 Sees This HTML'],
|
||||
]
|
||||
HIDDEN_CONTENT = [
|
||||
['Condition 0 vertical'],
|
||||
['Condition 1 vertical'],
|
||||
]
|
||||
|
||||
# Data is html encoded, because it's inactive inside the
|
||||
# sequence until javascript is executed
|
||||
VISIBLE_CONTENT = [
|
||||
['class="problems-wrapper'],
|
||||
['Some HTML for group 1']
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
super(TestSplitTestVert, self).setUp()
|
||||
|
||||
# split_test cond 0 = vert <- {video, problem}
|
||||
# split_test cond 1 = vert <- {video, html}
|
||||
c0_url = self.course.location._replace(category="vertical", name="split_test_cond0")
|
||||
c1_url = self.course.location._replace(category="vertical", name="split_test_cond1")
|
||||
|
||||
split_test = ItemFactory.create(
|
||||
parent_location=self.sequential.location,
|
||||
category="split_test",
|
||||
display_name="Split test",
|
||||
user_partition_id='0',
|
||||
group_id_to_child={"0": c0_url.url(), "1": c1_url.url()},
|
||||
)
|
||||
|
||||
cond0vert = ItemFactory.create(
|
||||
parent_location=split_test.location,
|
||||
category="vertical",
|
||||
display_name="Condition 0 vertical",
|
||||
location=c0_url,
|
||||
)
|
||||
video0 = self._video(cond0vert, 0)
|
||||
problem0 = self._problem(cond0vert, 0)
|
||||
|
||||
cond1vert = ItemFactory.create(
|
||||
parent_location=split_test.location,
|
||||
category="vertical",
|
||||
display_name="Condition 1 vertical",
|
||||
location=c1_url,
|
||||
)
|
||||
video1 = self._video(cond1vert, 1)
|
||||
html1 = self._html(cond1vert, 1)
|
||||
@@ -16,6 +16,7 @@ from django.views.decorators.cache import cache_control
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden
|
||||
from django.utils.html import strip_tags
|
||||
from util.json_request import JsonResponse
|
||||
|
||||
from courseware.access import has_access
|
||||
@@ -248,7 +249,9 @@ def students_update_enrollment(request, course_id):
|
||||
elif action == 'unenroll':
|
||||
before, after = unenroll_email(course_id, email, email_students, email_params)
|
||||
else:
|
||||
return HttpResponseBadRequest("Unrecognized action '{}'".format(action))
|
||||
return HttpResponseBadRequest(strip_tags(
|
||||
"Unrecognized action '{}'".format(action)
|
||||
))
|
||||
|
||||
results.append({
|
||||
'email': email,
|
||||
@@ -303,9 +306,9 @@ def modify_access(request, course_id):
|
||||
action = request.GET.get('action')
|
||||
|
||||
if not rolename in ['instructor', 'staff', 'beta']:
|
||||
return HttpResponseBadRequest(
|
||||
return HttpResponseBadRequest(strip_tags(
|
||||
"unknown rolename '{}'".format(rolename)
|
||||
)
|
||||
))
|
||||
|
||||
user = User.objects.get(email=email)
|
||||
|
||||
@@ -320,7 +323,9 @@ def modify_access(request, course_id):
|
||||
elif action == 'revoke':
|
||||
revoke_access(course, user, rolename)
|
||||
else:
|
||||
return HttpResponseBadRequest("unrecognized action '{}'".format(action))
|
||||
return HttpResponseBadRequest(strip_tags(
|
||||
"unrecognized action '{}'".format(action)
|
||||
))
|
||||
|
||||
response_payload = {
|
||||
'email': email,
|
||||
@@ -486,9 +491,9 @@ def get_distribution(request, course_id):
|
||||
available_features = analytics.distributions.AVAILABLE_PROFILE_FEATURES
|
||||
# allow None so that requests for no feature can list available features
|
||||
if not feature in available_features + (None,):
|
||||
return HttpResponseBadRequest(
|
||||
return HttpResponseBadRequest(strip_tags(
|
||||
"feature '{}' not available.".format(feature)
|
||||
)
|
||||
))
|
||||
|
||||
response_payload = {
|
||||
'course_id': course_id,
|
||||
@@ -833,7 +838,9 @@ def list_forum_members(request, course_id):
|
||||
|
||||
# filter out unsupported for roles
|
||||
if not rolename in [FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA]:
|
||||
return HttpResponseBadRequest("Unrecognized rolename '{}'.".format(rolename))
|
||||
return HttpResponseBadRequest(strip_tags(
|
||||
"Unrecognized rolename '{}'.".format(rolename)
|
||||
))
|
||||
|
||||
try:
|
||||
role = Role.objects.get(name=rolename, course_id=course_id)
|
||||
@@ -931,7 +938,9 @@ def update_forum_role_membership(request, course_id):
|
||||
return HttpResponseBadRequest("Operation requires instructor access.")
|
||||
|
||||
if not rolename in [FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA]:
|
||||
return HttpResponseBadRequest("Unrecognized rolename '{}'.".format(rolename))
|
||||
return HttpResponseBadRequest(strip_tags(
|
||||
"Unrecognized rolename '{}'.".format(rolename)
|
||||
))
|
||||
|
||||
user = User.objects.get(email=email)
|
||||
target_is_instructor = has_access(user, course, 'instructor')
|
||||
|
||||
@@ -34,8 +34,7 @@ from .discussionsettings import *
|
||||
|
||||
from lms.lib.xblock.mixin import LmsBlockMixin
|
||||
from xmodule.modulestore.inheritance import InheritanceMixin
|
||||
from xmodule.x_module import XModuleMixin
|
||||
from xmodule.modulestore import only_xmodules
|
||||
from xmodule.x_module import XModuleMixin, prefer_xmodules
|
||||
|
||||
################################### FEATURES ###################################
|
||||
# The display name of the platform to be used in templates/emails/etc.
|
||||
@@ -233,9 +232,6 @@ FEATURES = {
|
||||
|
||||
# Turn on/off Microsites feature
|
||||
'USE_MICROSITES': False,
|
||||
|
||||
# Turn on/off the Metrics tab for the Instructor dashboard
|
||||
'CLASS_DASHBOARD': False,
|
||||
}
|
||||
|
||||
# Used for A/B testing
|
||||
@@ -439,13 +435,8 @@ DOC_STORE_CONFIG = {
|
||||
# once the responsibility of XBlock creation is moved out of modulestore - cpennington
|
||||
XBLOCK_MIXINS = (LmsBlockMixin, InheritanceMixin, XModuleMixin)
|
||||
|
||||
# Only allow XModules in the LMS
|
||||
XBLOCK_SELECT_FUNCTION = only_xmodules
|
||||
|
||||
# Use the following lines to allow any xblock in the LMS,
|
||||
# either by uncommenting them here, or adding them to your private.py
|
||||
# from xmodule.modulestore import prefer_xmodules
|
||||
# XBLOCK_SELECT_FUNCTION = prefer_xmodules
|
||||
# Allow any XBlock in the LMS
|
||||
XBLOCK_SELECT_FUNCTION = prefer_xmodules
|
||||
|
||||
#################### Python sandbox ############################################
|
||||
|
||||
@@ -712,6 +703,10 @@ MIDDLEWARE_CLASSES = (
|
||||
'contentserver.middleware.StaticContentServer',
|
||||
'crum.CurrentRequestUserMiddleware',
|
||||
|
||||
# Adds user tags to tracking events
|
||||
# Must go before TrackMiddleware, to get the context set up
|
||||
'user_api.middleware.UserTagsEventContextMiddleware',
|
||||
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'track.middleware.TrackMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
@@ -842,7 +837,7 @@ PIPELINE_CSS = {
|
||||
},
|
||||
'style-course-vendor': {
|
||||
'source_filenames': [
|
||||
'js/vendor/CodeMirror/codemirror-3.21.0.css',
|
||||
'js/vendor/CodeMirror/codemirror.css',
|
||||
'css/vendor/jquery.treeview.css',
|
||||
'css/vendor/ui-lightness/jquery-ui-1.8.22.custom.css',
|
||||
],
|
||||
@@ -1168,6 +1163,9 @@ INSTALLED_APPS = (
|
||||
'reverification',
|
||||
|
||||
'embargo',
|
||||
|
||||
# XBlocks containing migrations
|
||||
'mentoring',
|
||||
)
|
||||
|
||||
######################### MARKETING SITE ###############################
|
||||
@@ -1195,7 +1193,8 @@ VERIFY_STUDENT = {
|
||||
"DAYS_GOOD_FOR": 365, # How many days is a verficiation good for?
|
||||
}
|
||||
|
||||
######## Metrics tab/Instructor Dashboard #############
|
||||
### This enables the Metrics tab for the Instructor dashboard ###########
|
||||
FEATURES['CLASS_DASHBOARD'] = False
|
||||
if FEATURES.get('CLASS_DASHBOARD'):
|
||||
INSTALLED_APPS += ('class_dashboard',)
|
||||
|
||||
|
||||
@@ -6,7 +6,10 @@ import re
|
||||
|
||||
from django.core.urlresolvers import reverse
|
||||
|
||||
from user_api import user_service
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.x_module import ModuleSystem
|
||||
from xmodule.partitions.partitions_service import PartitionService
|
||||
|
||||
|
||||
def _quote_slashes(match):
|
||||
@@ -109,8 +112,76 @@ class LmsHandlerUrls(object):
|
||||
})
|
||||
|
||||
|
||||
class LmsPartitionService(PartitionService):
|
||||
"""
|
||||
Another runtime mixin that provides access to the student partitions defined on the
|
||||
course.
|
||||
|
||||
(If and when XBlock directly provides access from one block (e.g. a split_test_module)
|
||||
to another (e.g. a course_module), this won't be neccessary, but for now it seems like
|
||||
the least messy way to hook things through)
|
||||
|
||||
"""
|
||||
@property
|
||||
def course_partitions(self):
|
||||
course = modulestore().get_course(self._course_id)
|
||||
return course.user_partitions
|
||||
|
||||
|
||||
class UserTagsService(object):
|
||||
"""
|
||||
A runtime class that provides an interface to the user service. It handles filling in
|
||||
the current course id and current user.
|
||||
"""
|
||||
|
||||
COURSE_SCOPE = user_service.COURSE_SCOPE
|
||||
|
||||
def __init__(self, runtime):
|
||||
self.runtime = runtime
|
||||
|
||||
def _get_current_user(self):
|
||||
"""Returns the real, not anonymized, current user."""
|
||||
real_user = self.runtime.get_real_user(self.runtime.anonymous_student_id)
|
||||
return real_user
|
||||
|
||||
def get_tag(self, scope, key):
|
||||
"""
|
||||
Get a user tag for the current course and the current user for a given key
|
||||
|
||||
scope: the current scope of the runtime
|
||||
key: the key for the value we want
|
||||
"""
|
||||
if scope != user_service.COURSE_SCOPE:
|
||||
raise ValueError("unexpected scope {0}".format(scope))
|
||||
|
||||
return user_service.get_course_tag(self._get_current_user(),
|
||||
self.runtime.course_id, key)
|
||||
|
||||
def set_tag(self, scope, key, value):
|
||||
"""
|
||||
Set the user tag for the current course and the current user for a given key
|
||||
|
||||
scope: the current scope of the runtime
|
||||
key: the key that to the value to be set
|
||||
value: the value to set
|
||||
"""
|
||||
if scope != user_service.COURSE_SCOPE:
|
||||
raise ValueError("unexpected scope {0}".format(scope))
|
||||
|
||||
return user_service.set_course_tag(self._get_current_user(),
|
||||
self.runtime.course_id, key, value)
|
||||
|
||||
|
||||
class LmsModuleSystem(LmsHandlerUrls, ModuleSystem): # pylint: disable=abstract-method
|
||||
"""
|
||||
ModuleSystem specialized to the LMS
|
||||
"""
|
||||
pass
|
||||
def __init__(self, **kwargs):
|
||||
services = kwargs.setdefault('services', {})
|
||||
services['user_tags'] = UserTagsService(self)
|
||||
services['partitions'] = LmsPartitionService(
|
||||
user_tags_service=services['user_tags'],
|
||||
course_id=kwargs.get('course_id', None),
|
||||
track_function=kwargs.get('track_function', None),
|
||||
)
|
||||
super(LmsModuleSystem, self).__init__(**kwargs)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
Tests of the LMS XBlock Runtime and associated utilities
|
||||
"""
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from ddt import ddt, data
|
||||
from mock import Mock
|
||||
from unittest import TestCase
|
||||
@@ -85,3 +86,53 @@ class TestHandlerUrl(TestCase):
|
||||
def test_handler_name(self):
|
||||
self.assertIn('handler1', self._parsed_path('handler1'))
|
||||
self.assertIn('handler_a', self._parsed_path('handler_a'))
|
||||
|
||||
|
||||
class TestUserServiceAPI(TestCase):
|
||||
"""Test the user service interface"""
|
||||
|
||||
def setUp(self):
|
||||
self.course_id = "org/course/run"
|
||||
|
||||
self.user = User(username='runtime_robot', email='runtime_robot@edx.org', password='test', first_name='Robot')
|
||||
self.user.save()
|
||||
|
||||
def mock_get_real_user(_anon_id):
|
||||
"""Just returns the test user"""
|
||||
return self.user
|
||||
|
||||
self.runtime = LmsModuleSystem(
|
||||
static_url='/static',
|
||||
track_function=Mock(),
|
||||
get_module=Mock(),
|
||||
render_template=Mock(),
|
||||
replace_urls=str,
|
||||
course_id=self.course_id,
|
||||
get_real_user=mock_get_real_user,
|
||||
descriptor_runtime=Mock(),
|
||||
)
|
||||
self.scope = 'course'
|
||||
self.key = 'key1'
|
||||
|
||||
self.mock_block = Mock()
|
||||
self.mock_block.service_declaration.return_value = 'needs'
|
||||
|
||||
def test_get_set_tag(self):
|
||||
# test for when we haven't set the tag yet
|
||||
tag = self.runtime.service(self.mock_block, 'user_tags').get_tag(self.scope, self.key)
|
||||
self.assertIsNone(tag)
|
||||
|
||||
# set the tag
|
||||
set_value = 'value'
|
||||
self.runtime.service(self.mock_block, 'user_tags').set_tag(self.scope, self.key, set_value)
|
||||
tag = self.runtime.service(self.mock_block, 'user_tags').get_tag(self.scope, self.key)
|
||||
|
||||
self.assertEqual(tag, set_value)
|
||||
|
||||
# Try to set tag in wrong scope
|
||||
with self.assertRaises(ValueError):
|
||||
self.runtime.service(self.mock_block, 'user_tags').set_tag('fake_scope', self.key, set_value)
|
||||
|
||||
# Try to get tag in wrong scope
|
||||
with self.assertRaises(ValueError):
|
||||
self.runtime.service(self.mock_block, 'user_tags').get_tag('fake_scope', self.key)
|
||||
|
||||
@@ -234,9 +234,10 @@
|
||||
|
||||
input[type="submit"] {
|
||||
display: block;
|
||||
height: 45px;
|
||||
height: auto;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
data-element="${idx+1}"
|
||||
href="javascript:void(0);"
|
||||
title="${item['title']|h}"
|
||||
data-page-title="${item['page_title']|h}"
|
||||
aria-controls="seq_contents_${idx}"
|
||||
id="tab_${idx}"
|
||||
tabindex="0"
|
||||
@@ -36,7 +37,7 @@
|
||||
</nav>
|
||||
|
||||
% for idx, item in enumerate(items):
|
||||
<div id="seq_contents_${idx}"
|
||||
<div id="seq_contents_${idx}"
|
||||
aria-labelledby="tab_${idx}"
|
||||
aria-hidden="true"
|
||||
class="seq_contents tex2jax_ignore asciimath2jax_ignore">
|
||||
|
||||
18
lms/templates/split_test_staff_view.html
Normal file
18
lms/templates/split_test_staff_view.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
|
||||
<div class="split-test-view">
|
||||
<select class="split-test-select">
|
||||
% for idx, item in enumerate(items):
|
||||
## Translators: The 'Group' here refers to the group of users that has been sorted into group_id
|
||||
<option value="${item['group_id']}">${_("Group {group_id}").format(group_id=item['group_id'])}</option>
|
||||
%endfor
|
||||
</select>
|
||||
|
||||
% for idx, item in enumerate(items):
|
||||
<div class="split-test-child" data-group-id="${item['group_id']}" data-id="${item['id']}">
|
||||
${item['content'] | h}
|
||||
</div>
|
||||
% endfor
|
||||
|
||||
<div class='split-test-child-container'></div>
|
||||
</div>
|
||||
3
lms/templates/split_test_student_view.html
Normal file
3
lms/templates/split_test_student_view.html
Normal file
@@ -0,0 +1,3 @@
|
||||
<div class='split-test-view' data-child-id='${child_id}'>
|
||||
${child_content}
|
||||
</div>
|
||||
@@ -106,7 +106,7 @@ django-debug-toolbar-mongo
|
||||
# Used for testing
|
||||
chrono==1.0.2
|
||||
coverage==3.7
|
||||
ddt==0.7.0
|
||||
ddt==0.7.1
|
||||
django-crum==0.5
|
||||
django_nose==1.1
|
||||
factory_boy==2.2.1
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
<<<<<<< HEAD
|
||||
# Requirements for edx.org that aren't necessarily needed for Open edX.
|
||||
|
||||
-e git+ssh://git@github.com/jazkarta/edX-jsdraw.git@df9d048e331a642193e5aa2e03650fb84a9d715f#egg=edx-jsdraw
|
||||
-e git+https://github.com/gsehub/xblock-mentoring.git@69a546eadeb4d038f6851bb54286c6c6fdbe8c87#egg=xblock-mentoring
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
-e git+https://github.com/appliedsec/pygeoip.git@95e69341cebf5a6a9fbf7c4f5439d458898bdc3b#egg=pygeoip
|
||||
|
||||
# Our libraries:
|
||||
-e git+https://github.com/edx/XBlock.git@893cd83dfb24405ce81b07f49c1c2e3053cdc865#egg=XBlock
|
||||
-e git+https://github.com/edx/XBlock.git@6dd8a9223cae34184ba5e2e1a186f36c4df1e080#egg=XBlock
|
||||
-e git+https://github.com/edx/codejail.git@e3d98f9455#egg=codejail
|
||||
-e git+https://github.com/edx/diff-cover.git@v0.2.9#egg=diff_cover
|
||||
-e git+https://github.com/edx/js-test-tool.git@v0.1.5#egg=js_test_tool
|
||||
|
||||
Reference in New Issue
Block a user