Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions common/lib/xmodule/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
21 changes: 21 additions & 0 deletions common/lib/xmodule/xmodule/course_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions common/lib/xmodule/xmodule/partitions/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

112 changes: 112 additions & 0 deletions common/lib/xmodule/xmodule/partitions/partitions.py
Original file line number Diff line number Diff line change
@@ -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)
135 changes: 135 additions & 0 deletions common/lib/xmodule/xmodule/partitions/partitions_service.py
Original file line number Diff line number Diff line change
@@ -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

33 changes: 33 additions & 0 deletions common/lib/xmodule/xmodule/partitions/test_partitions.py
Original file line number Diff line number Diff line change
@@ -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)

Loading