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:
committed by
Calen Pennington
parent
72e876fe40
commit
281ad63d2b
@@ -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']
|
||||
@@ -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")
|
||||
|
||||
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)
|
||||
61
common/djangoapps/user_api/user_service.py
Normal file
61
common/djangoapps/user_api/user_service.py
Normal 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?
|
||||
Reference in New Issue
Block a user