From 238623b99407c7bd64f7c69422ba295e64aeea9c Mon Sep 17 00:00:00 2001 From: Victor Shnayder Date: Tue, 31 Dec 2013 10:44:34 -0500 Subject: [PATCH] WIP: 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. condition 0 condition 1 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) TODO: * arch review. Is this a reasonable way to do it? * analytics -- actually add the user tags to all events * add a "hidden" attribute to blocks, have verticals and ab tests pass it up the tree, have sequentials and the accordion actually hide things. (There is already a way to hide certain things in the course accordion. Ideally this would be made consistent with that method.) * test all the things --- common/lib/xmodule/setup.py | 1 + common/lib/xmodule/xmodule/course_module.py | 21 ++ .../xmodule/xmodule/partitions/__init__.py | 1 + .../xmodule/xmodule/partitions/partitions.py | 112 ++++++++++ .../xmodule/partitions/partitions_service.py | 135 ++++++++++++ .../xmodule/partitions/test_partitions.py | 33 +++ .../lib/xmodule/xmodule/split_test_module.py | 206 ++++++++++++++++++ docs/developers/source/experiments.rst | 76 +++++++ docs/developers/source/index.rst | 1 + ...nique_usercoursetags_user_course_id_key.py | 87 ++++++++ lms/djangoapps/user_api/models.py | 15 +- lms/djangoapps/user_api/user_service.py | 68 ++++++ lms/lib/xblock/runtime.py | 69 +++++- 13 files changed, 823 insertions(+), 2 deletions(-) create mode 100644 common/lib/xmodule/xmodule/partitions/__init__.py create mode 100644 common/lib/xmodule/xmodule/partitions/partitions.py create mode 100644 common/lib/xmodule/xmodule/partitions/partitions_service.py create mode 100644 common/lib/xmodule/xmodule/partitions/test_partitions.py create mode 100644 common/lib/xmodule/xmodule/split_test_module.py create mode 100644 docs/developers/source/experiments.rst create mode 100644 lms/djangoapps/user_api/migrations/0002_auto__add_usercoursetags__add_unique_usercoursetags_user_course_id_key.py create mode 100644 lms/djangoapps/user_api/user_service.py diff --git a/common/lib/xmodule/setup.py b/common/lib/xmodule/setup.py index 558668c9fae8..0a483596bc17 100644 --- a/common/lib/xmodule/setup.py +++ b/common/lib/xmodule/setup.py @@ -17,6 +17,7 @@ "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", diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 0ddd27d20251..ac7956bfc594 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -9,6 +9,7 @@ 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 @@ -157,10 +158,30 @@ def to_json(self, values): return json_data +class UserPartitionList(List): + 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) diff --git a/common/lib/xmodule/xmodule/partitions/__init__.py b/common/lib/xmodule/xmodule/partitions/__init__.py new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/common/lib/xmodule/xmodule/partitions/__init__.py @@ -0,0 +1 @@ + diff --git a/common/lib/xmodule/xmodule/partitions/partitions.py b/common/lib/xmodule/xmodule/partitions/partitions.py new file mode 100644 index 000000000000..105ef8d1c709 --- /dev/null +++ b/common/lib/xmodule/xmodule/partitions/partitions.py @@ -0,0 +1,112 @@ + +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 = 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. + """ + def check(key): + if key not in value: + raise TypeError("Group dict {0} missing value key '{1}'".format( + value, key)) + check("id") + check("name") + check("version") + 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 = 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. + """ + def check(key): + if key not in value: + raise TypeError("UserPartition dict {0} missing value key '{1}'" + .format(value, key)) + check("id") + check("name") + check("description") + check("version") + if value["version"] != UserPartition.VERSION: + raise TypeError("UserPartition dict {0} has unexpected version" + .format(value)) + + check("groups") + groups = [Group.from_json(g) for g in value["groups"]] + + return UserPartition(value["id"], + value["name"], + value["description"], + groups) diff --git a/common/lib/xmodule/xmodule/partitions/partitions_service.py b/common/lib/xmodule/xmodule/partitions/partitions_service.py new file mode 100644 index 000000000000..b209c186e17c --- /dev/null +++ b/common/lib/xmodule/xmodule/partitions/partitions_service.py @@ -0,0 +1,135 @@ +""" +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 + +# tl;dr: global state is bad. The capa library reseeds random every time a problem is +# loaded. Even if and when that's fixed, it's a good idea to have a local generator to +# avoid any other code that messes with the global random module. +_local_random = None + +def local_random(): + """ + Get the local random number generator. In a function so that we don't run + random.Random() at import time. + """ + # ironic, isn't it? + global _local_random + + if _local_random is None: + _local_random = random.Random() + + return _local_random + + + +def get_user_group_for_partition(runtime, 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: + runtime: a runtime object. Expected to have keys + course_id -- the current course id + user_service -- the User service + user_partitions -- the list of partition.UserPartition objects defined in + this course + 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 = _get_user_partition(runtime, 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, runtime.course_id)) + + group_id = _get_group(runtime, user_partition) + + return group_id + + +def _get_user_partition(runtime, 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 runtime.user_partitions: + if partition.id == user_partition_id: + return partition + + return None + + +def _key_for_partition(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(runtime, 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 = _key_for_partition(user_partition) + scope = runtime.user_service.COURSE + + group_id = runtime.user_service.get_tag(scope, key) + + if group_id != None: + # TODO: check whether this id is valid. If not, create a new one. + 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.) + + + # otherwise, 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 = local_random().choice(user_partition.groups) + runtime.user_service.set_tag(scope, key, group.id) + + # emit event for analytics: + # TODO: is this all the needed info? + event_info = {'group_id': group.id, + 'group_name': group.name, + 'partition_id': user_partition.id, + 'partition_name': user_partition.name} + runtime.track_function('assigned_user_to_partition', event_info) + + return group.id + diff --git a/common/lib/xmodule/xmodule/partitions/test_partitions.py b/common/lib/xmodule/xmodule/partitions/test_partitions.py new file mode 100644 index 000000000000..89d8952a2987 --- /dev/null +++ b/common/lib/xmodule/xmodule/partitions/test_partitions.py @@ -0,0 +1,33 @@ +import unittest + +from partitions import Group, UserPartition + + +class Test_Group(unittest.TestCase): + + def test_construct(self): + id = "an_id" + name = "Grendel" + g = Group(id, name) + self.assertEqual(g.id, id) + self.assertEqual(g.name, name) + + def test_to_json(self): + id = "an_id" + name = "Grendel" + g = Group(id, name) + jsonified = g.to_json() + self.assertEqual(jsonified, {"id": id, + "name": name, + "version": g.VERSION}) + + def test_from_json(self): + id = "an_id" + name = "Grendel" + jsonified = {"id": id, + "name": name, + "version": Group.VERSION} + g = Group.from_json(jsonified) + self.assertEqual(g.id, id) + self.assertEqual(g.name, name) + diff --git a/common/lib/xmodule/xmodule/split_test_module.py b/common/lib/xmodule/xmodule/split_test_module.py new file mode 100644 index 000000000000..60ab2077da60 --- /dev/null +++ b/common/lib/xmodule/xmodule/split_test_module.py @@ -0,0 +1,206 @@ +import logging +import random + +from xmodule.partitions.partitions import UserPartition, Group +from xmodule.partitions.partitions_service import get_user_group_for_partition +from xmodule.progress import Progress +from xmodule.seq_module import SequenceDescriptor +from xmodule.x_module import XModule + +from lxml import etree + +from xblock.fields import Scope, Integer, Dict, List +from xblock.fragment import Fragment + +log = logging.getLogger('edx.' + __name__) + + +class SplitTestFields(object): + 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 = Dict(help="Which child module students in a particular " + "group_id should see", + scope=Scope.content) + + + +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(SplitTestFields, self).__init__(*args, **kwargs) + + group_id = get_user_group_for_partition(self.runtime, 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] + self.child_descriptor = self.get_child_descriptor_by_location(child_location) + else: + # Oops. Config error. + # TODO: better error message + log.debug("split test config error: invalid group_id. Showing error") + self.child_descriptor = None + + if self.child_descriptor is not 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 + self.child = self.get_children()[0] + else: + # TODO: better error message + log.debug("split test config error: no such child") + 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_child_descriptors(self): + """ + For grading--return just the chosen child. + """ + if self.child_descriptor is None: + return [] + + return [self.child_descriptor] + + + def _actually_get_all_children(self): + """ + Actually get all the child blocks of this block, instead of + get_children(), which will only get the ones we want to expose to + progress/grading/etc for this user. Used to show staff all the options + of a split test, even while they are technically only in one of the + buckets. + + (Aside: this feels like too much magic) + """ + # Note: this deliberately uses system.get_module() because we're using + # XModule children for now (also see comment in + # x_module.py:get_children()) + return [self.system.get_module(descriptor) + for descriptor in self.descriptor.get_children()] + + + def _get_experiment_definition(): + """ + TODO: what interface should this actually use? + """ + + def _staff_view(self, context): + """ + Render the staff view for a split test module. + """ + # TODO (architectural): To give children proper context (e.g. which + # conditions are which), this block will need access to the actual + # UserPartition definition, not just the user's condition. + # + # This seems to require either: + # a way to get to the Course object, or exposing the UserPartitionList + # in the runtime. + fragment = Fragment() + contents = [] + + for child in self._actually_get_all_children(): + rendered_child = child.render('student_view', context) + fragment.add_frag_resources(rendered_child) + + contents.append({ + 'id': child.id, + 'content': rendered_child.content + }) + + # Use the existing vertical template for now. + # TODO: replace this with a dropdown, defaulting to user's condition + fragment.add_content(self.system.render_template('vert_module.html', { + 'items': contents + })) + 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"
Nothing here. Move along.
") + + if self.system.user_is_staff: + return self._staff_view(context) + else: + return self.child.render('student_view', context) + + + 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 + + + +class SplitTestDescriptor(SplitTestFields, SequenceDescriptor): + # the editing interface can be the same as for sequences -- just a container + module_class = SplitTestModule + + filename_extension = "xml" + + 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 diff --git a/docs/developers/source/experiments.rst b/docs/developers/source/experiments.rst new file mode 100644 index 000000000000..8ac2caefe955 --- /dev/null +++ b/docs/developers/source/experiments.rst @@ -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 + diff --git a/docs/developers/source/index.rst b/docs/developers/source/index.rst index 780bc55049fa..95558ceacf91 100644 --- a/docs/developers/source/index.rst +++ b/docs/developers/source/index.rst @@ -14,6 +14,7 @@ Contents: overview.rst common-lib.rst djangoapps.rst + experiments.rst Indices and tables ================== diff --git a/lms/djangoapps/user_api/migrations/0002_auto__add_usercoursetags__add_unique_usercoursetags_user_course_id_key.py b/lms/djangoapps/user_api/migrations/0002_auto__add_usercoursetags__add_unique_usercoursetags_user_course_id_key.py new file mode 100644 index 000000000000..cc7e27cc4d16 --- /dev/null +++ b/lms/djangoapps/user_api/migrations/0002_auto__add_usercoursetags__add_unique_usercoursetags_user_course_id_key.py @@ -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'] \ No newline at end of file diff --git a/lms/djangoapps/user_api/models.py b/lms/djangoapps/user_api/models.py index 3450c03aa313..456df2715745 100644 --- a/lms/djangoapps/user_api/models.py +++ b/lms/djangoapps/user_api/models.py @@ -1,7 +1,6 @@ from django.contrib.auth.models import User from django.db import models - class UserPreference(models.Model): """A user's preference, stored as generic text to be processed by client""" user = models.ForeignKey(User, db_index=True, related_name="+") @@ -10,3 +9,17 @@ class UserPreference(models.Model): class Meta: unique_together = ("user", "key") + + +class UserCourseTags(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") diff --git a/lms/djangoapps/user_api/user_service.py b/lms/djangoapps/user_api/user_service.py new file mode 100644 index 000000000000..d2fb8ad29264 --- /dev/null +++ b/lms/djangoapps/user_api/user_service.py @@ -0,0 +1,68 @@ +""" +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 +UserCourseMetadata model. +""" + +from django.contrib.auth.models import User +from user_api.models import UserCourseTags + + +def get_course_tag(user_id, course_id, key): + """ + Gets the value of the user's course tag for the specified key in the specified + course_id. + + Args: + user_id: an id into the User table + course_id: course identifier (string) + key: arbitrary (<=255 char string) + + Returns: + string value, or None if there is no value saved + """ + try: + record = UserCourseTags.objects.get( + user__id=user_id, + course_id=course_id, + key=key) + + return record.value + except UserCourseTags.DoesNotExist: + return None + + +def set_course_tag(user_id, 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_id: an id into the User table + course_id: course identifier (string) + key: arbitrary (<=255 char string) + value: arbitrary string + """ + + record, created = UserCourseTags.objects.get_or_create( + user__id=user_id, + course_id=course_id, + key=key, + defaults={'value': value, + # Have to include this here, because get_or_create does not + # automatically pass through params with '__' in them + 'user_id': user_id}) + + if not created: + # need to update the value + 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? diff --git a/lms/lib/xblock/runtime.py b/lms/lib/xblock/runtime.py index cb2c0330251b..f10984d894d2 100644 --- a/lms/lib/xblock/runtime.py +++ b/lms/lib/xblock/runtime.py @@ -6,6 +6,8 @@ from django.core.urlresolvers import reverse +from user_api import user_service +from xmodule.modulestore.django import modulestore from xmodule.x_module import ModuleSystem @@ -129,8 +131,73 @@ def handler_url(self, block, handler_name, suffix='', query='', thirdparty=False """See :method:`xblock.runtime:Runtime.handler_url`""" return handler_url(self.course_id, block, handler_name, suffix='', query='', thirdparty=thirdparty) +class LmsUserPartitions(object): + """ + 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 user_partitions(self): + course = modulestore().get_course(self.course_id) + return course.user_partitions + + +class UserServiceInterface(object): + """ + A runtime class that provides an interface to the user service. It handles filling in + the current course id and current user. + """ + # Scopes + # (currently only allows per-course tags. Can be expanded to support + # global tags (e.g. using the existing UserPreferences table)) + COURSE = 'course' + + def __init__(self, runtime): + self.runtime = runtime + + + def _get_current_user_id(self): + real_user = self.runtime.get_real_user(self.runtime.anonymous_student_id) + return real_user.id + + + def get_tag(self, scope, key): + """ + """ + if scope != self.COURSE: + raise ValueError("unexpected scope {0}".format(scope)) + + return user_service.get_course_tag(self._get_current_user_id(), + self.runtime.course_id, key) + + + def set_tag(self, scope, key, value): + """ + """ + if scope != self.COURSE: + raise ValueError("unexpected scope {0}".format(scope)) + + return user_service.set_course_tag(self._get_current_user_id(), + self.runtime.course_id, key, value) + + +class UserServiceMixin(object): + """ + Mix in a UserServiceInterface as self.user_service... + """ + + @property + def user_service(self): + return UserServiceInterface(self) + -class LmsModuleSystem(LmsHandlerUrls, ModuleSystem): # pylint: disable=abstract-method +class LmsModuleSystem(LmsHandlerUrls, LmsUserPartitions, + UserServiceMixin, ModuleSystem): # pylint: disable=abstract-method """ ModuleSystem specialized to the LMS """