split testing support in the LMS.

Adds a split_test_module XModule, that can choose one of its children
to display, based on a get_condition_for_user API added to the runtime.

To test, add something like this to an xml course, or make equivalent
 tweaks in mongo.

 <vertical url_name="split_test_vert">
    <split_test url_name="split1" experiment_id="0" condition_id_to_child='{"0": "i4x://MITx/6.00x/html/split_test_cond0", "1": "i4x://MITx/6.00x/html/split_test_cond1"}'>
       <html url_name="split_test_cond0">condition 0</html>
       <html url_name="split_test_cond1">condition 1</html>
    </split_test>
  </vertical>

Also needs an experiment configured in the course policy json: e.g.

        "user_partitions": [{"id": 0,
                                      "name": "Experiment 0",
                                      "description": "Unicorns?",
                                      "version": 1,
                                      "groups": [{"id": 0,
                                                        "name": "group 0",
                                                        "version": 1},
                                                        {"id": 1,
                                                        "name": "group 1",
                                                        "version": 1}]}]

(This particular snippet will work inside a course with org MITx
 and course name 6.00x)

Co-Author: Sarina Canelake <sarina@edx.org>
Co-Author: Julia Hansbrough <julia@edx.org>
Co-Author: Diana Huang <diana@edx.org>
Co-Author: Calen Pennington <cale@edx.org>

[LMS-2095]
This commit is contained in:
Victor Shnayder
2013-12-31 10:44:34 -05:00
committed by Calen Pennington
parent 72e876fe40
commit 281ad63d2b
22 changed files with 1229 additions and 6 deletions

View File

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

View File

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

View File

@@ -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:
unique_together = ("user", "course_id", "key")

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

View File

@@ -0,0 +1,61 @@
"""
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
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?