Commit 356b2335 by Andy Armstrong

Add base support for cohorted group configurations

TNL-649
parent 699b3d08
......@@ -5,6 +5,8 @@ These are notable changes in edx-platform. This is a rolling list of changes,
in roughly chronological order, most recent first. Add your entries at or near
the top. Include a label indicating the component affected.
Platform: Add base support for cohorted group configurations. TNL-649
Common: Add configurable reset button to units
Studio: Add support xblock validation messages on Studio unit/container page. TNL-683
......
......@@ -22,7 +22,7 @@ from xmodule.error_module import ErrorDescriptor
from xmodule.modulestore.django import modulestore
from xmodule.contentstore.content import StaticContent
from xmodule.tabs import PDFTextbookTabs
from xmodule.partitions.partitions import UserPartition, Group
from xmodule.partitions.partitions import UserPartition
from xmodule.modulestore import EdxJSONEncoder
from xmodule.modulestore.exceptions import ItemNotFoundError, DuplicateCourseError
from opaque_keys import InvalidKeyError
......@@ -1173,7 +1173,7 @@ class GroupConfiguration(object):
configuration = json.loads(json_string)
except ValueError:
raise GroupConfigurationsValidationError(_("invalid JSON"))
configuration["version"] = UserPartition.VERSION
return configuration
def validate(self):
......@@ -1224,14 +1224,7 @@ class GroupConfiguration(object):
"""
Get user partition for saving in course.
"""
groups = [Group(g["id"], g["name"]) for g in self.configuration["groups"]]
return UserPartition(
self.configuration["id"],
self.configuration["name"],
self.configuration["description"],
groups
)
return UserPartition.from_json(self.configuration)
@staticmethod
def get_usage_info(course, store):
......@@ -1345,15 +1338,12 @@ def group_configurations_list_handler(request, course_key_string):
if 'text/html' in request.META.get('HTTP_ACCEPT', 'text/html'):
group_configuration_url = reverse_course_url('group_configurations_list_handler', course_key)
course_outline_url = reverse_course_url('course_handler', course_key)
split_test_enabled = SPLIT_TEST_COMPONENT_TYPE in ADVANCED_COMPONENT_TYPES and SPLIT_TEST_COMPONENT_TYPE in course.advanced_modules
configurations = GroupConfiguration.add_usage_info(course, store)
return render_to_response('group_configurations.html', {
'context_course': course,
'group_configuration_url': group_configuration_url,
'course_outline_url': course_outline_url,
'configurations': configurations if split_test_enabled else None,
'configurations': configurations if should_show_group_configurations_page(course) else None,
})
elif "application/json" in request.META.get('HTTP_ACCEPT'):
if request.method == 'POST':
......@@ -1432,6 +1422,16 @@ def group_configurations_detail_handler(request, course_key_string, group_config
return JsonResponse(status=204)
def should_show_group_configurations_page(course):
"""
Returns true if Studio should show the "Group Configurations" page for the specified course.
"""
return (
SPLIT_TEST_COMPONENT_TYPE in ADVANCED_COMPONENT_TYPES and
SPLIT_TEST_COMPONENT_TYPE in course.advanced_modules
)
def _get_course_creator_status(user):
"""
Helper method for returning the course creator status for a particular user,
......
......@@ -15,10 +15,17 @@ from xmodule.modulestore import ModuleStoreEnum
GROUP_CONFIGURATION_JSON = {
u'name': u'Test name',
u'scheme': u'random',
u'description': u'Test description',
u'version': UserPartition.VERSION,
u'groups': [
{u'name': u'Group A'},
{u'name': u'Group B'},
{
u'name': u'Group A',
u'version': 1,
}, {
u'name': u'Group B',
u'version': 1,
},
],
}
......@@ -229,7 +236,8 @@ class GroupConfigurationsListHandlerTestCase(CourseTestCase, GroupConfigurations
expected = {
u'description': u'Test description',
u'name': u'Test name',
u'version': 1,
u'scheme': u'random',
u'version': UserPartition.VERSION,
u'groups': [
{u'name': u'Group A', u'version': 1},
{u'name': u'Group B', u'version': 1},
......@@ -279,15 +287,16 @@ class GroupConfigurationsDetailHandlerTestCase(CourseTestCase, GroupConfiguratio
kwargs={'group_configuration_id': cid},
)
def test_can_create_new_group_configuration_if_it_is_not_exist(self):
def test_can_create_new_group_configuration_if_it_does_not_exist(self):
"""
PUT new group configuration when no configurations exist in the course.
"""
expected = {
u'id': 999,
u'name': u'Test name',
u'scheme': u'random',
u'description': u'Test description',
u'version': 1,
u'version': UserPartition.VERSION,
u'groups': [
{u'id': 0, u'name': u'Group A', u'version': 1},
{u'id': 1, u'name': u'Group B', u'version': 1},
......@@ -306,12 +315,12 @@ class GroupConfigurationsDetailHandlerTestCase(CourseTestCase, GroupConfiguratio
self.assertEqual(content, expected)
self.reload_course()
# Verify that user_partitions in the course contains the new group configuration.
user_partititons = self.course.user_partitions
self.assertEqual(len(user_partititons), 1)
self.assertEqual(user_partititons[0].name, u'Test name')
self.assertEqual(len(user_partititons[0].groups), 2)
self.assertEqual(user_partititons[0].groups[0].name, u'Group A')
self.assertEqual(user_partititons[0].groups[1].name, u'Group B')
user_partitions = self.course.user_partitions
self.assertEqual(len(user_partitions), 1)
self.assertEqual(user_partitions[0].name, u'Test name')
self.assertEqual(len(user_partitions[0].groups), 2)
self.assertEqual(user_partitions[0].groups[0].name, u'Group A')
self.assertEqual(user_partitions[0].groups[1].name, u'Group B')
def test_can_edit_group_configuration(self):
"""
......@@ -323,8 +332,9 @@ class GroupConfigurationsDetailHandlerTestCase(CourseTestCase, GroupConfiguratio
expected = {
u'id': self.ID,
u'name': u'New Test name',
u'scheme': u'random',
u'description': u'New Test description',
u'version': 1,
u'version': UserPartition.VERSION,
u'groups': [
{u'id': 0, u'name': u'New Group Name', u'version': 1},
{u'id': 2, u'name': u'Group C', u'version': 1},
......@@ -430,8 +440,9 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods):
expected = [{
'id': 0,
'name': 'Name 0',
'scheme': 'random',
'description': 'Description 0',
'version': 1,
'version': UserPartition.VERSION,
'groups': [
{'id': 0, 'name': 'Group A', 'version': 1},
{'id': 1, 'name': 'Group B', 'version': 1},
......@@ -454,8 +465,9 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods):
expected = [{
'id': 0,
'name': 'Name 0',
'scheme': 'random',
'description': 'Description 0',
'version': 1,
'version': UserPartition.VERSION,
'groups': [
{'id': 0, 'name': 'Group A', 'version': 1},
{'id': 1, 'name': 'Group B', 'version': 1},
......@@ -469,8 +481,9 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods):
}, {
'id': 1,
'name': 'Name 1',
'scheme': 'random',
'description': 'Description 1',
'version': 1,
'version': UserPartition.VERSION,
'groups': [
{'id': 0, 'name': 'Group A', 'version': 1},
{'id': 1, 'name': 'Group B', 'version': 1},
......@@ -495,8 +508,9 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods):
expected = [{
'id': 0,
'name': 'Name 0',
'scheme': 'random',
'description': 'Description 0',
'version': 1,
'version': UserPartition.VERSION,
'groups': [
{'id': 0, 'name': 'Group A', 'version': 1},
{'id': 1, 'name': 'Group B', 'version': 1},
......
......@@ -223,8 +223,9 @@ class GetItemTest(ItemTest):
GROUP_CONFIGURATION_JSON = {
u'id': 0,
u'name': u'first_partition',
u'scheme': u'random',
u'description': u'First Partition',
u'version': 1,
u'version': UserPartition.VERSION,
u'groups': [
{u'id': 0, u'name': u'New_NAME_A', u'version': 1},
{u'id': 1, u'name': u'New_NAME_B', u'version': 1},
......
......@@ -28,7 +28,6 @@ class CourseMetadata(object):
'graded',
'hide_from_toc',
'pdf_textbooks',
'user_partitions',
'name', # from xblock
'tags', # from xblock
'visible_to_staff_only',
......
......@@ -607,7 +607,7 @@ INSTALLED_APPS = (
'reverification',
# User preferences
'user_api',
'openedx.core.djangoapps.user_api',
'django_openid_auth',
'embargo',
......
......@@ -7,7 +7,7 @@ define([
defaults: function() {
return {
name: '',
version: null,
version: 1,
order: null
};
},
......
......@@ -9,8 +9,9 @@ function(Backbone, _, str, gettext, GroupModel, GroupCollection) {
defaults: function() {
return {
name: '',
scheme: 'random',
description: '',
version: null,
version: 2,
groups: new GroupCollection([
{
name: gettext('Group A'),
......@@ -71,6 +72,7 @@ function(Backbone, _, str, gettext, GroupModel, GroupCollection) {
return {
id: this.get('id'),
name: this.get('name'),
scheme: this.get('scheme'),
description: this.get('description'),
version: this.get('version'),
groups: this.get('groups').toJSON()
......
......@@ -99,7 +99,8 @@ define([
'id': 10,
'name': 'My Group Configuration',
'description': 'Some description',
'version': 1,
'version': 2,
'scheme': 'random',
'groups': [
{
'version': 1,
......@@ -114,9 +115,10 @@ define([
'id': 10,
'name': 'My Group Configuration',
'description': 'Some description',
'scheme': 'random',
'showGroups': false,
'editing': false,
'version': 1,
'version': 2,
'groups': [
{
'version': 1,
......
......@@ -55,7 +55,7 @@
</div>
% else:
<div class="ui-loading">
<p><span class="spin"><i class="icon-refresh"></i></span> <span class="copy">${_("Loading...")}</span></p>
<p><span class="spin"><i class="icon-refresh"></i></span> <span class="copy">${_("Loading")}</span></p>
</div>
% endif
</article>
......
......@@ -7,6 +7,7 @@
<%!
from django.utils.translation import ugettext as _
from contentstore import utils
from contentstore.views.course import should_show_group_configurations_page
import urllib
%>
......@@ -312,10 +313,10 @@ CMS.URL.UPLOAD_ASSET = '${upload_asset_url}';
<ul>
<li class="nav-item"><a href="${grading_config_url}">${_("Grading")}</a></li>
<li class="nav-item"><a href="${course_team_url}">${_("Course Team")}</a></li>
% if should_show_group_configurations_page(context_course):
<li class="nav-item"><a href="${utils.reverse_course_url('group_configurations_list_handler', context_course.id)}">${_("Group Configurations")}</a></li>
% endif
<li class="nav-item"><a href="${advanced_config_url}">${_("Advanced Settings")}</a></li>
% if "split_test" in context_course.advanced_modules:
<li class="nav-item"><a href="${utils.reverse_course_url('group_configurations_list_handler', context_course.id)}">${_("Group Configurations")}</a></li>
% endif
</ul>
</nav>
% endif
......
......@@ -4,6 +4,7 @@
<%!
from django.utils.translation import ugettext as _
from contentstore import utils
from contentstore.views.course import should_show_group_configurations_page
from django.utils.html import escapejs
%>
<%block name="title">${_("Advanced Settings")}</%block>
......@@ -91,9 +92,9 @@
<li class="nav-item"><a href="${details_url}">${_("Details &amp; Schedule")}</a></li>
<li class="nav-item"><a href="${grading_url}">${_("Grading")}</a></li>
<li class="nav-item"><a href="${course_team_url}">${_("Course Team")}</a></li>
% if "split_test" in context_course.advanced_modules:
<li class="nav-item"><a href="${utils.reverse_course_url('group_configurations_list_handler', context_course.id)}">${_("Group Configurations")}</a></li>
% endif
% if should_show_group_configurations_page(context_course):
<li class="nav-item"><a href="${utils.reverse_course_url('group_configurations_list_handler', context_course.id)}">${_("Group Configurations")}</a></li>
% endif
</ul>
</nav>
% endif
......
......@@ -6,6 +6,7 @@
<%namespace name='static' file='static_content.html'/>
<%!
from contentstore import utils
from contentstore.views.course import should_show_group_configurations_page
from django.utils.translation import ugettext as _
%>
......@@ -134,10 +135,10 @@
<ul>
<li class="nav-item"><a href="${detailed_settings_url}">${_("Details &amp; Schedule")}</a></li>
<li class="nav-item"><a href="${course_team_url}">${_("Course Team")}</a></li>
% if should_show_group_configurations_page(context_course):
<li class="nav-item"><a href="${utils.reverse_course_url('group_configurations_list_handler', context_course.id)}">${_("Group Configurations")}</a></li>
% endif
<li class="nav-item"><a href="${advanced_settings_url}">${_("Advanced Settings")}</a></li>
% if "split_test" in context_course.advanced_modules:
<li class="nav-item"><a href="${utils.reverse_course_url('group_configurations_list_handler', context_course.id)}">${_("Group Configurations")}</a></li>
% endif
</ul>
</nav>
% endif
......
......@@ -3,6 +3,7 @@
from django.core.urlresolvers import reverse
from django.utils.translation import ugettext as _
from contentstore.context_processors import doc_url
from contentstore.views.course import should_show_group_configurations_page
%>
<%page args="online_help_token"/>
......@@ -81,14 +82,14 @@
<li class="nav-item nav-course-settings-team">
<a href="${course_team_url}">${_("Course Team")}</a>
</li>
% if should_show_group_configurations_page(context_course):
<li class="nav-item nav-course-settings-group-configurations">
<a href="${reverse('contentstore.views.group_configurations_list_handler', kwargs={'course_key_string': unicode(course_key)})}">${_("Group Configurations")}</a>
</li>
% endif
<li class="nav-item nav-course-settings-advanced">
<a href="${advanced_settings_url}">${_("Advanced Settings")}</a>
</li>
% if "split_test" in context_course.advanced_modules:
<li class="nav-item nav-course-settings-group-configurations">
<a href="${reverse('contentstore.views.group_configurations_list_handler', kwargs={'course_key_string': unicode(course_key)})}">${_("Group Configurations")}</a>
</li>
% endif
</ul>
</div>
</div>
......
......@@ -39,7 +39,7 @@ urlpatterns = patterns('', # nopep8
url(r'^xmodule/', include('pipeline_js.urls')),
url(r'^heartbeat$', include('heartbeat.urls')),
url(r'^user_api/', include('user_api.urls')),
url(r'^user_api/', include('openedx.core.djangoapps.user_api.urls')),
url(r'^lang_pref/', include('lang_pref.urls')),
)
......
......@@ -2,7 +2,7 @@
Middleware for Language Preferences
"""
from user_api.models import UserPreference
from openedx.core.djangoapps.user_api.models import UserPreference
from lang_pref import LANGUAGE_KEY
......
......@@ -3,7 +3,7 @@ from django.test.client import RequestFactory
from django.contrib.sessions.middleware import SessionMiddleware
from lang_pref.middleware import LanguagePreferenceMiddleware
from user_api.models import UserPreference
from openedx.core.djangoapps.user_api.models import UserPreference
from lang_pref import LANGUAGE_KEY
from student.tests.factories import UserFactory
......
......@@ -4,7 +4,7 @@ Tests for the language setting view
from django.core.urlresolvers import reverse
from django.test import TestCase
from student.tests.factories import UserFactory
from user_api.models import UserPreference
from openedx.core.djangoapps.user_api.models import UserPreference
from lang_pref import LANGUAGE_KEY
......
......@@ -4,7 +4,7 @@ Views for accessing language preferences
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse, HttpResponseBadRequest
from user_api.models import UserPreference
from openedx.core.djangoapps.user_api.models import UserPreference
from lang_pref import LANGUAGE_KEY
......
......@@ -12,7 +12,7 @@ from django.test import TestCase, TransactionTestCase
import mock
from user_api.models import UserPreference
from openedx.core.djangoapps.user_api.models import UserPreference
from lang_pref import LANGUAGE_KEY
from edxmako.tests import mako_middleware_process_request
......
......@@ -83,7 +83,7 @@ from external_auth.login_and_register import (
from bulk_email.models import Optout, CourseAuthorization
import shoppingcart
from shoppingcart.models import DonationConfiguration
from user_api.models import UserPreference
from openedx.core.djangoapps.user_api.models import UserPreference
from lang_pref import LANGUAGE_KEY
import track.views
......
......@@ -142,8 +142,8 @@ class InheritanceMixin(XBlockMixin):
# This is should be scoped to content, but since it's defined in the policy
# file, it is currently scoped to settings.
user_partitions = UserPartitionList(
display_name=_("Experiment Group Configurations"),
help=_("Enter the configurations that govern how students are grouped for content experiments."),
display_name=_("Group Configurations"),
help=_("Enter the configurations that govern how students are grouped together."),
default=[],
scope=Scope.settings
)
......
......@@ -31,6 +31,7 @@ from xmodule.modulestore.xml_exporter import export_to_xml
from xmodule.modulestore.split_mongo.split_draft import DraftVersioningModuleStore
from xmodule.modulestore.tests.mongo_connection import MONGO_PORT_NUM, MONGO_HOST
from xmodule.modulestore.inheritance import InheritanceMixin
from xmodule.partitions.tests.test_partitions import PartitionTestCase
from xmodule.x_module import XModuleMixin
from xmodule.modulestore.xml import XMLModuleStore
......@@ -291,7 +292,7 @@ COURSE_DATA_NAMES = (
@ddt.ddt
@attr('mongo')
class CrossStoreXMLRoundtrip(CourseComparisonTest):
class CrossStoreXMLRoundtrip(CourseComparisonTest, PartitionTestCase):
"""
This class exists to test XML import and export between different modulestore
classes.
......
"""Defines ``Group`` and ``UserPartition`` models for partitioning"""
from collections import namedtuple
from stevedore.extension import ExtensionManager
# 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 UserPartitionError(Exception):
"""
An error was found regarding user partitions.
"""
pass
class Group(namedtuple("Group", "id name")):
"""
An id and name for a group of students. The id should be unique
......@@ -45,7 +55,7 @@ class Group(namedtuple("Group", "id name")):
if isinstance(value, Group):
return value
for key in ('id', 'name', 'version'):
for key in ("id", "name", "version"):
if key not in value:
raise TypeError("Group dict {0} missing value key '{1}'".format(
value, key))
......@@ -57,21 +67,50 @@ class Group(namedtuple("Group", "id name")):
return Group(value["id"], value["name"])
class UserPartition(namedtuple("UserPartition", "id name description groups")):
# The Stevedore extension point namespace for user partition scheme plugins.
USER_PARTITION_SCHEME_NAMESPACE = 'openedx.user_partition_scheme'
class UserPartition(namedtuple("UserPartition", "id name description groups scheme")):
"""
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.
A Partition has an id, name, scheme, 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)
partitions of users within a course, the ids should be unique per-course).
The scheme is used to assign users into groups.
"""
VERSION = 1
VERSION = 2
# The collection of user partition scheme extensions.
scheme_extensions = None
def __new__(cls, id, name, description, groups):
# The default scheme to be used when upgrading version 1 partitions.
VERSION_1_SCHEME = "random"
def __new__(cls, id, name, description, groups, scheme=None, scheme_id=VERSION_1_SCHEME):
# pylint: disable=super-on-old-class
return super(UserPartition, cls).__new__(cls, int(id), name, description, groups)
if not scheme:
scheme = UserPartition.get_scheme(scheme_id)
return super(UserPartition, cls).__new__(cls, int(id), name, description, groups, scheme)
@staticmethod
def get_scheme(name):
"""
Returns the user partition scheme with the given name.
"""
# Note: we're creating the extension manager lazily to ensure that the Python path
# has been correctly set up. Trying to create this statically will fail, unfortunately.
if not UserPartition.scheme_extensions:
UserPartition.scheme_extensions = ExtensionManager(namespace=USER_PARTITION_SCHEME_NAMESPACE)
try:
scheme = UserPartition.scheme_extensions[name].plugin
except KeyError:
raise UserPartitionError("Unrecognized scheme {0}".format(name))
scheme.name = name
return scheme
def to_json(self):
"""
......@@ -84,6 +123,7 @@ class UserPartition(namedtuple("UserPartition", "id name description groups")):
return {
"id": self.id,
"name": self.name,
"scheme": self.scheme.name,
"description": self.description,
"groups": [g.to_json() for g in self.groups],
"version": UserPartition.VERSION
......@@ -102,20 +142,38 @@ class UserPartition(namedtuple("UserPartition", "id name description groups")):
if isinstance(value, UserPartition):
return value
for key in ('id', 'name', 'description', 'version', 'groups'):
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))
raise TypeError("UserPartition dict {0} missing value key '{1}'".format(value, key))
if value["version"] == 1:
# If no scheme was provided, set it to the default ('random')
scheme_id = UserPartition.VERSION_1_SCHEME
elif value["version"] == UserPartition.VERSION:
if not "scheme" in value:
raise TypeError("UserPartition dict {0} missing value key 'scheme'".format(value))
scheme_id = value["scheme"]
else:
raise TypeError("UserPartition dict {0} has unexpected version".format(value))
groups = [Group.from_json(g) for g in value["groups"]]
scheme = UserPartition.get_scheme(scheme_id)
if not scheme:
raise TypeError("UserPartition dict {0} has unrecognized scheme {1}".format(value, scheme_id))
return UserPartition(
value["id"],
value["name"],
value["description"],
groups
groups,
scheme,
)
def get_group(self, group_id):
"""
Returns the group with the specified id.
"""
for group in self.groups: # pylint: disable=no-member
if group.id == group_id:
return group
return None
......@@ -3,7 +3,6 @@ This is a service-like API that assigns tracks which groups users are in for var
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
......@@ -22,13 +21,11 @@ class PartitionService(object):
"""
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
def __init__(self, runtime, track_function):
self.runtime = runtime
self._track_function = track_function
def get_user_group_for_partition(self, user_partition_id):
def get_user_group_id_for_partition(self, user_partition_id):
"""
If the user is already assigned to a group in user_partition_id, return the
group_id.
......@@ -53,17 +50,15 @@ class PartitionService(object):
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)
"in course {1}".format(user_partition_id, self.runtime.course_id)
)
group_id = self._get_group(user_partition)
return group_id
group = self._get_group(user_partition)
return group.id if group else None
def _get_user_partition(self, user_partition_id):
"""
Look for a user partition with a matching id in
in the course's partitions.
Look for a user partition with a matching id in the course's partitions.
Returns:
A UserPartition, or None if not found.
......@@ -74,65 +69,13 @@ class PartitionService(object):
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.
Returns the group from the specified user partition to which the user is assigned.
If the user has not yet been assigned, a group will be chosen for them based upon
the partition's scheme.
"""
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
user = self.runtime.get_real_user(self.runtime.anonymous_student_id)
return user_partition.scheme.get_group_for_user(
self.runtime.course_id, user, user_partition, track_function=self._track_function
)
"""
Test the partitions and partitions service
"""
from collections import defaultdict
from unittest import TestCase
from mock import Mock
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)
"""
Test the partitions and partitions service
"""
from unittest import TestCase
from mock import Mock
from stevedore.extension import Extension, ExtensionManager
from xmodule.partitions.partitions import Group, UserPartition, UserPartitionError, USER_PARTITION_SCHEME_NAMESPACE
from xmodule.partitions.partitions_service import PartitionService
from xmodule.tests import get_test_system
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) # pylint: disable=no-member
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) # pylint: disable=no-member
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) # pylint: disable=no-member
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.from_json(jsonified)
# Missing key "id"
jsonified = {
"name": name,
"version": Group.VERSION
}
with self.assertRaisesRegexp(TypeError, "missing value key 'id'"):
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 MockUserPartitionScheme(object):
"""
Mock user partition scheme
"""
def __init__(self, name="mock", current_group=None, **kwargs):
super(MockUserPartitionScheme, self).__init__(**kwargs)
self.name = name
self.current_group = current_group
def get_group_for_user(self, course_id, user, user_partition, track_function=None): # pylint: disable=unused-argument
"""
Returns the current group if set, else the first group from the specified user partition.
"""
if self.current_group:
return self.current_group
groups = user_partition.groups
if not groups or len(groups) == 0:
return None
return groups[0]
class PartitionTestCase(TestCase):
"""Base class for test cases that require partitions"""
TEST_ID = 0
TEST_NAME = "Mock Partition"
TEST_DESCRIPTION = "for testing purposes"
TEST_GROUPS = [Group(0, 'Group 1'), Group(1, 'Group 2')]
TEST_SCHEME_NAME = "mock"
def setUp(self):
# Set up two user partition schemes: mock and random
extensions = [
Extension(
self.TEST_SCHEME_NAME, USER_PARTITION_SCHEME_NAMESPACE,
MockUserPartitionScheme(self.TEST_SCHEME_NAME), None
),
Extension(
"random", USER_PARTITION_SCHEME_NAMESPACE, MockUserPartitionScheme("random"), None
),
]
UserPartition.scheme_extensions = ExtensionManager.make_test_instance(
extensions, namespace=USER_PARTITION_SCHEME_NAMESPACE
)
# Create a test partition
self.user_partition = UserPartition(
self.TEST_ID,
self.TEST_NAME,
self.TEST_DESCRIPTION,
self.TEST_GROUPS,
extensions[0].plugin
)
class TestUserPartition(PartitionTestCase):
"""Test constructing UserPartitions"""
def test_construct(self):
user_partition = UserPartition(
self.TEST_ID, self.TEST_NAME, self.TEST_DESCRIPTION, self.TEST_GROUPS, MockUserPartitionScheme()
)
self.assertEqual(user_partition.id, self.TEST_ID) # pylint: disable=no-member
self.assertEqual(user_partition.name, self.TEST_NAME)
self.assertEqual(user_partition.description, self.TEST_DESCRIPTION) # pylint: disable=no-member
self.assertEqual(user_partition.groups, self.TEST_GROUPS) # pylint: disable=no-member
self.assertEquals(user_partition.scheme.name, self.TEST_SCHEME_NAME) # pylint: disable=no-member
def test_string_id(self):
user_partition = UserPartition(
"70", self.TEST_NAME, self.TEST_DESCRIPTION, self.TEST_GROUPS
)
self.assertEqual(user_partition.id, 70) # pylint: disable=no-member
def test_to_json(self):
jsonified = self.user_partition.to_json()
act_jsonified = {
"id": self.TEST_ID,
"name": self.TEST_NAME,
"description": self.TEST_DESCRIPTION,
"groups": [group.to_json() for group in self.TEST_GROUPS],
"version": self.user_partition.VERSION,
"scheme": self.TEST_SCHEME_NAME
}
self.assertEqual(jsonified, act_jsonified)
def test_from_json(self):
jsonified = {
"id": self.TEST_ID,
"name": self.TEST_NAME,
"description": self.TEST_DESCRIPTION,
"groups": [group.to_json() for group in self.TEST_GROUPS],
"version": UserPartition.VERSION,
"scheme": "mock",
}
user_partition = UserPartition.from_json(jsonified)
self.assertEqual(user_partition.id, self.TEST_ID) # pylint: disable=no-member
self.assertEqual(user_partition.name, self.TEST_NAME) # pylint: disable=no-member
self.assertEqual(user_partition.description, self.TEST_DESCRIPTION) # pylint: disable=no-member
for act_group in user_partition.groups: # pylint: disable=no-member
self.assertIn(act_group.id, [0, 1])
exp_group = self.TEST_GROUPS[act_group.id]
self.assertEqual(exp_group.id, act_group.id)
self.assertEqual(exp_group.name, act_group.name)
def test_version_upgrade(self):
# Version 1 partitions did not have a scheme specified
jsonified = {
"id": self.TEST_ID,
"name": self.TEST_NAME,
"description": self.TEST_DESCRIPTION,
"groups": [group.to_json() for group in self.TEST_GROUPS],
"version": 1,
}
user_partition = UserPartition.from_json(jsonified)
self.assertEqual(user_partition.scheme.name, "random") # pylint: disable=no-member
def test_from_json_broken(self):
# Missing field
jsonified = {
"name": self.TEST_NAME,
"description": self.TEST_DESCRIPTION,
"groups": [group.to_json() for group in self.TEST_GROUPS],
"version": UserPartition.VERSION,
"scheme": self.TEST_SCHEME_NAME,
}
with self.assertRaisesRegexp(TypeError, "missing value key 'id'"):
UserPartition.from_json(jsonified)
# Missing scheme
jsonified = {
'id': self.TEST_ID,
"name": self.TEST_NAME,
"description": self.TEST_DESCRIPTION,
"groups": [group.to_json() for group in self.TEST_GROUPS],
"version": UserPartition.VERSION,
}
with self.assertRaisesRegexp(TypeError, "missing value key 'scheme'"):
UserPartition.from_json(jsonified)
# Invalid scheme
jsonified = {
'id': self.TEST_ID,
"name": self.TEST_NAME,
"description": self.TEST_DESCRIPTION,
"groups": [group.to_json() for group in self.TEST_GROUPS],
"version": UserPartition.VERSION,
"scheme": "no_such_scheme",
}
with self.assertRaisesRegexp(UserPartitionError, "Unrecognized scheme"):
UserPartition.from_json(jsonified)
# Wrong version (it's over 9000!)
# Wrong version (it's over 9000!)
jsonified = {
'id': self.TEST_ID,
"name": self.TEST_NAME,
"description": self.TEST_DESCRIPTION,
"groups": [group.to_json() for group in self.TEST_GROUPS],
"version": 9001,
"scheme": self.TEST_SCHEME_NAME,
}
with self.assertRaisesRegexp(TypeError, "has unexpected version"):
UserPartition.from_json(jsonified)
# Has extra key - should not be a problem
jsonified = {
'id': self.TEST_ID,
"name": self.TEST_NAME,
"description": self.TEST_DESCRIPTION,
"groups": [group.to_json() for group in self.TEST_GROUPS],
"version": UserPartition.VERSION,
"scheme": "mock",
"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 TestPartitionService(PartitionTestCase):
"""
Test getting a user's group out of a partition
"""
def setUp(self):
super(TestPartitionService, self).setUp()
self.partition_service = StaticPartitionService(
[self.user_partition],
runtime=get_test_system(),
track_function=Mock()
)
def test_get_user_group_id_for_partition(self):
# assign the first group to be returned
user_partition_id = self.user_partition.id # pylint: disable=no-member
groups = self.user_partition.groups # pylint: disable=no-member
self.user_partition.scheme.current_group = groups[0] # pylint: disable=no-member
# get a group assigned to the user
group1_id = self.partition_service.get_user_group_id_for_partition(user_partition_id)
self.assertEqual(group1_id, groups[0].id) # pylint: disable=no-member
# switch to the second group and verify that it is returned for the user
self.user_partition.scheme.current_group = groups[1] # pylint: disable=no-member
group2_id = self.partition_service.get_user_group_id_for_partition(user_partition_id)
self.assertEqual(group2_id, groups[1].id) # pylint: disable=no-member
......@@ -188,7 +188,7 @@ class SplitTestModule(SplitTestFields, XModule, StudioEditableModule):
partitions_service = self.runtime.service(self, 'partitions')
if not partitions_service:
return None
return partitions_service.get_user_group_for_partition(self.user_partition_id)
return partitions_service.get_user_group_id_for_partition(self.user_partition_id)
@property
def is_configured(self):
......
......@@ -79,13 +79,15 @@ def get_test_system(course_id=SlashSeparatedCourseKey('org', 'course', 'run')):
where `my_render_func` is a function of the form my_render_func(template, context).
"""
user = Mock(is_staff=False)
return TestModuleSystem(
static_url='/static',
track_function=Mock(),
get_module=Mock(),
render_template=mock_render_template,
replace_urls=str,
user=Mock(is_staff=False),
user=user,
get_real_user=lambda(__): user,
filestore=Mock(),
debug=True,
hostname="edx.org",
......
......@@ -6,6 +6,7 @@ import lxml
from mock import Mock, patch
from fs.memoryfs import MemoryFS
from xmodule.partitions.tests.test_partitions import StaticPartitionService, PartitionTestCase, MockUserPartitionScheme
from xmodule.tests.xml import factories as xml
from xmodule.tests.xml import XModuleXmlImportTest
from xmodule.tests import get_test_system
......@@ -13,7 +14,6 @@ from xmodule.x_module import AUTHOR_VIEW, STUDENT_VIEW
from xmodule.validation import StudioValidationMessage
from xmodule.split_test_module import SplitTestDescriptor, SplitTestFields
from xmodule.partitions.partitions import Group, UserPartition
from xmodule.partitions.test_partitions import StaticPartitionService, MemoryUserTagsService
class SplitTestModuleFactory(xml.XmlImportFactory):
......@@ -23,11 +23,12 @@ class SplitTestModuleFactory(xml.XmlImportFactory):
tag = 'split_test'
class SplitTestModuleTest(XModuleXmlImportTest):
class SplitTestModuleTest(XModuleXmlImportTest, PartitionTestCase):
"""
Base class for all split_module tests.
"""
def setUp(self):
super(SplitTestModuleTest, self).setUp()
self.course_id = 'test_org/test_course_number/test_run'
# construct module
course = xml.CourseFactory.build()
......@@ -57,16 +58,16 @@ class SplitTestModuleTest(XModuleXmlImportTest):
self.module_system.descriptor_system = self.course.runtime
self.course.runtime.export_fs = MemoryFS()
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')])
self.user_partition,
UserPartition(
1, 'second_partition', 'Second Partition',
[Group("0", 'abel'), Group("1", 'baker'), Group("2", 'charlie')],
MockUserPartitionScheme()
)
],
user_tags_service=self.tags_service,
course_id=self.course.id,
runtime=self.module_system,
track_function=Mock(name='track_function'),
)
self.module_system._services['partitions'] = self.partitions_service # pylint: disable=protected-access
......@@ -81,50 +82,28 @@ class SplitTestModuleLMSTest(SplitTestModuleTest):
Test the split test module
"""
@ddt.data(('0', 'split_test_cond0'), ('1', 'split_test_cond1'))
@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.user_partition.scheme.current_group = self.user_partition.groups[user_tag] # pylint: disable=no-member
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.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.user_partition.scheme.current_group = self.user_partition.groups[user_tag] # pylint: disable=no-member
self.assertIn(
child_content,
self.module_system.render(self.split_test_module, STUDENT_VIEW).content
)
@ddt.data(('0',), ('1',))
@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.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.
......
[run]
data_file = reports/bok_choy/.coverage
source = lms, cms, common/djangoapps, common/lib
omit = lms/envs/*, cms/envs/*, common/djangoapps/terrain/*, common/djangoapps/*/migrations/*, */test*, */management/*, */urls*, */wsgi*
omit = lms/envs/*, cms/envs/*, common/djangoapps/terrain/*, common/djangoapps/*/migrations/*, openedx/core/djangoapps/*/migrations/*, */test*, */management/*, */urls*, */wsgi*
parallel = True
[report]
......
......@@ -9,6 +9,7 @@ from nose.plugins.attrib import attr
from selenium.webdriver.support.ui import Select
from xmodule.partitions.partitions import Group, UserPartition
from xmodule.partitions.tests.test_partitions import MockUserPartitionScheme
from bok_choy.promise import Promise, EmptyPromise
from ...fixtures.course import XBlockFixtureDesc
......@@ -30,6 +31,16 @@ class SplitTestMixin(object):
"""
Mixin that contains useful methods for split_test module testing.
"""
@staticmethod
def create_user_partition_json(partition_id, name, description, groups):
"""
Helper method to create user partition JSON.
"""
return UserPartition(
partition_id, name, description, groups, MockUserPartitionScheme("random")
).to_json()
def verify_groups(self, container, active_groups, inactive_groups, verify_missing_groups_not_present=True):
"""
Check that the groups appear and are correctly categorized as to active and inactive.
......@@ -80,8 +91,18 @@ class SplitTest(ContainerBase, SplitTestMixin):
self.course_fixture._update_xblock(self.course_fixture._course_location, {
"metadata": {
u"user_partitions": [
UserPartition(0, 'Configuration alpha,beta', 'first', [Group("0", 'alpha'), Group("1", 'beta')]).to_json(),
UserPartition(1, 'Configuration 0,1,2', 'second', [Group("0", 'Group 0'), Group("1", 'Group 1'), Group("2", 'Group 2')]).to_json()
self.create_user_partition_json(
0,
'Configuration alpha,beta',
'first',
[Group("0", 'alpha'), Group("1", 'beta')]
),
self.create_user_partition_json(
1,
'Configuration 0,1,2',
'second',
[Group("0", 'Group 0'), Group("1", 'Group 1'), Group("2", 'Group 2')]
),
],
},
})
......@@ -124,8 +145,12 @@ class SplitTest(ContainerBase, SplitTestMixin):
self.course_fixture._update_xblock(self.course_fixture._course_location, {
"metadata": {
u"user_partitions": [
UserPartition(0, 'Configuration alpha,beta', 'first',
[Group("0", 'alpha'), Group("2", 'gamma')]).to_json()
self.create_user_partition_json(
0,
'Configuration alpha,beta',
'first',
[Group("0", 'alpha'), Group("2", 'gamma')]
)
],
},
})
......@@ -189,7 +214,7 @@ class SplitTest(ContainerBase, SplitTestMixin):
@attr('shard_1')
class SettingsMenuTest(StudioCourseTest):
"""
Tests that Setting menu is rendered correctly in Studio
Tests that Settings menu is rendered correctly in Studio
"""
def setUp(self):
......@@ -324,7 +349,7 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
self.course_fixture._update_xblock(self.course_fixture._course_location, {
"metadata": {
u"user_partitions": [
UserPartition(0, "Name", "Description.", groups).to_json(),
self.create_user_partition_json(0, "Name", "Description.", groups),
],
},
})
......@@ -396,8 +421,18 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
self.course_fixture._update_xblock(self.course_fixture._course_location, {
"metadata": {
u"user_partitions": [
UserPartition(0, 'Name of the Group Configuration', 'Description of the group configuration.', [Group("0", 'Group 0'), Group("1", 'Group 1')]).to_json(),
UserPartition(1, 'Name of second Group Configuration', 'Second group configuration.', [Group("0", 'Alpha'), Group("1", 'Beta'), Group("2", 'Gamma')]).to_json(),
self.create_user_partition_json(
0,
'Name of the Group Configuration',
'Description of the group configuration.',
[Group("0", 'Group 0'), Group("1", 'Group 1')]
),
self.create_user_partition_json(
1,
'Name of second Group Configuration',
'Second group configuration.',
[Group("0", 'Alpha'), Group("1", 'Beta'), Group("2", 'Gamma')]
),
],
},
})
......@@ -531,7 +566,12 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
self.course_fixture._update_xblock(self.course_fixture._course_location, {
"metadata": {
u"user_partitions": [
UserPartition(0, 'Name of the Group Configuration', 'Description of the group configuration.', [Group("0", 'Group A'), Group("1", 'Group B'), Group("2", 'Group C')]).to_json(),
self.create_user_partition_json(
0,
'Name of the Group Configuration',
'Description of the group configuration.',
[Group("0", 'Group A'), Group("1", 'Group B'), Group("2", 'Group C')]
),
],
},
})
......@@ -610,8 +650,18 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
self.course_fixture._update_xblock(self.course_fixture._course_location, {
"metadata": {
u"user_partitions": [
UserPartition(0, 'Name of the Group Configuration', 'Description of the group configuration.', [Group("0", 'Group 0'), Group("1", 'Group 1')]).to_json(),
UserPartition(1, 'Name of second Group Configuration', 'Second group configuration.', [Group("0", 'Alpha'), Group("1", 'Beta'), Group("2", 'Gamma')]).to_json(),
self.create_user_partition_json(
0,
'Name of the Group Configuration',
'Description of the group configuration.',
[Group("0", 'Group 0'), Group("1", 'Group 1')]
),
self.create_user_partition_json(
1,
'Name of second Group Configuration',
'Second group configuration.',
[Group("0", 'Alpha'), Group("1", 'Beta'), Group("2", 'Gamma')]
),
],
},
})
......@@ -696,7 +746,12 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
self.course_fixture._update_xblock(self.course_fixture._course_location, {
"metadata": {
u"user_partitions": [
UserPartition(0, "Name", "Description.", [Group("0", "Group A"), Group("1", "Group B")]).to_json(),
self.create_user_partition_json(
0,
"Name",
"Description.",
[Group("0", "Group A"), Group("1", "Group B")]
),
],
},
})
......@@ -728,7 +783,12 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
self.course_fixture._update_xblock(self.course_fixture._course_location, {
"metadata": {
u"user_partitions": [
UserPartition(0, "Name", "Description.", [Group("0", "Group A"), Group("1", "Group B")]).to_json(),
self.create_user_partition_json(
0,
"Name",
"Description.",
[Group("0", "Group A"), Group("1", "Group B")]
),
],
},
})
......@@ -771,8 +831,18 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
self.course_fixture._update_xblock(self.course_fixture._course_location, {
"metadata": {
u"user_partitions": [
UserPartition(0, 'Configuration 1', 'Description of the group configuration.', [Group("0", 'Group 0'), Group("1", 'Group 1')]).to_json(),
UserPartition(1, 'Configuration 2', 'Second group configuration.', [Group("0", 'Alpha'), Group("1", 'Beta'), Group("2", 'Gamma')]).to_json()
self.create_user_partition_json(
0,
'Configuration 1',
'Description of the group configuration.',
[Group("0", 'Group 0'), Group("1", 'Group 1')]
),
self.create_user_partition_json(
1,
'Configuration 2',
'Second group configuration.',
[Group("0", 'Alpha'), Group("1", 'Beta'), Group("2", 'Gamma')]
)
],
},
})
......@@ -804,7 +874,12 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
self.course_fixture._update_xblock(self.course_fixture._course_location, {
"metadata": {
u"user_partitions": [
UserPartition(0, "Name", "Description.", [Group("0", "Group A"), Group("1", "Group B")]).to_json()
self.create_user_partition_json(
0,
"Name",
"Description.",
[Group("0", "Group A"), Group("1", "Group B")]
)
],
},
})
......@@ -840,8 +915,18 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
self.course_fixture._update_xblock(self.course_fixture._course_location, {
"metadata": {
u"user_partitions": [
UserPartition(0, "Name", "Description.", [Group("0", "Group A"), Group("1", "Group B")]).to_json(),
UserPartition(1, 'Name of second Group Configuration', 'Second group configuration.', [Group("0", 'Alpha'), Group("1", 'Beta'), Group("2", 'Gamma')]).to_json(),
self.create_user_partition_json(
0,
"Name",
"Description.",
[Group("0", "Group A"), Group("1", "Group B")]
),
self.create_user_partition_json(
1,
'Name of second Group Configuration',
'Second group configuration.',
[Group("0", 'Alpha'), Group("1", 'Beta'), Group("2", 'Gamma')]
),
],
},
})
......
......@@ -2,7 +2,7 @@
[run]
data_file = reports/lms/.coverage
source = lms,common/djangoapps
omit = lms/envs/*, common/djangoapps/terrain/*, common/djangoapps/*/migrations/*
omit = lms/envs/*, common/djangoapps/terrain/*, common/djangoapps/*/migrations/*, openedx/core/djangoapps/*/migrations/*
[report]
ignore_errors = True
......
......@@ -12,7 +12,7 @@ from student.tests.factories import UserFactory, CourseEnrollmentFactory
from xmodule.modulestore.tests.factories import ItemFactory, CourseFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.partitions.partitions import Group, UserPartition
from user_api.tests.factories import UserCourseTagFactory
from openedx.core.djangoapps.user_api.tests.factories import UserCourseTagFactory
@override_settings(MODULESTORE=TEST_DATA_MOCK_MODULESTORE)
......
......@@ -27,7 +27,7 @@ from student.models import anonymous_id_for_user
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from xmodule.partitions.partitions import Group, UserPartition
from user_api.tests.factories import UserCourseTagFactory
from openedx.core.djangoapps.user_api.tests.factories import UserCourseTagFactory
@override_settings(MODULESTORE=TEST_DATA_MOCK_MODULESTORE)
......
......@@ -62,7 +62,7 @@ import instructor_analytics.basic
import instructor_analytics.distributions
import instructor_analytics.csvs
import csv
from user_api.models import UserPreference
from openedx.core.djangoapps.user_api.models import UserPreference
from instructor.views import INVOICE_KEY
from submissions import api as sub_api # installed from the edx-submissions repository
......
......@@ -17,7 +17,7 @@ from django.core.urlresolvers import reverse
from capa.tests.response_xml_factory import (CodeResponseXMLFactory,
CustomResponseXMLFactory)
from user_api.tests.factories import UserCourseTagFactory
from openedx.core.djangoapps.user_api.tests.factories import UserCourseTagFactory
from xmodule.modulestore.tests.factories import ItemFactory
from xmodule.modulestore import ModuleStoreEnum
from xmodule.partitions.partitions import Group, UserPartition
......
from django.contrib.auth.models import User
from lettuce import step, world
from notification_prefs import NOTIFICATION_PREF_KEY
from user_api.models import UserPreference
from openedx.core.djangoapps.user_api.models import UserPreference
USERNAME = "robot"
......
......@@ -12,7 +12,7 @@ from notification_prefs import NOTIFICATION_PREF_KEY
from notification_prefs.views import ajax_enable, ajax_disable, ajax_status, set_subscription, UsernameCipher
from student.tests.factories import UserFactory
from edxmako.tests import mako_middleware_process_request
from user_api.models import UserPreference
from openedx.core.djangoapps.user_api.models import UserPreference
from util.testing import UrlResetMixin
......
......@@ -12,7 +12,7 @@ from django.views.decorators.http import require_GET, require_POST
from edxmako.shortcuts import render_to_response
from notification_prefs import NOTIFICATION_PREF_KEY
from user_api.models import UserPreference
from openedx.core.djangoapps.user_api.models import UserPreference
class UsernameDecryptionException(Exception):
......
......@@ -13,8 +13,8 @@ from notifier_api.views import NotifierUsersViewSet
from opaque_keys.edx.locator import CourseLocator
from student.models import CourseEnrollment
from student.tests.factories import UserFactory, CourseEnrollmentFactory
from user_api.models import UserPreference
from user_api.tests.factories import UserPreferenceFactory
from openedx.core.djangoapps.user_api.models import UserPreference
from openedx.core.djangoapps.user_api.tests.factories import UserPreferenceFactory
from util.testing import UrlResetMixin
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
......
......@@ -3,7 +3,7 @@ from rest_framework.viewsets import ReadOnlyModelViewSet
from notification_prefs import NOTIFICATION_PREF_KEY
from notifier_api.serializers import NotifierUserSerializer
from user_api.views import ApiKeyHeaderPermission
from openedx.core.djangoapps.user_api.views import ApiKeyHeaderPermission
class NotifierUsersViewSet(ReadOnlyModelViewSet):
......
......@@ -6,7 +6,7 @@ from django.core.cache import cache
from courseware.access import has_access
from student.models import anonymous_id_for_user
from student.models import UserProfile
from user_api.models import UserPreference
from openedx.core.djangoapps.user_api.models import UserPreference
from lang_pref import LANGUAGE_KEY
from xmodule.modulestore.django import modulestore
from xmodule.course_module import CourseDescriptor
......
......@@ -9,7 +9,7 @@ from student.models import anonymous_id_for_user
from student.models import UserProfile
from student.roles import CourseStaffRole, CourseInstructorRole
from student.tests.factories import UserFactory, UserProfileFactory
from user_api.models import UserPreference
from openedx.core.djangoapps.user_api.models import UserPreference
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
# Will also run default tests for IDTokens and UserInfo
......
......@@ -16,9 +16,8 @@ from django.test.utils import override_settings
from util.testing import UrlResetMixin
from third_party_auth.tests.testutil import simulate_running_pipeline
from user_api.api import account as account_api
from user_api.api import profile as profile_api
from util.bad_request_rate_limiter import BadRequestRateLimiter
from openedx.core.djangoapps.user_api.api import account as account_api
from openedx.core.djangoapps.user_api.api import profile as profile_api
from xmodule.modulestore.tests.django_utils import (
ModuleStoreTestCase, mixed_store_config
)
......
......@@ -24,8 +24,8 @@ from student.views import (
register_user as old_register_view
)
from user_api.api import account as account_api
from user_api.api import profile as profile_api
from openedx.core.djangoapps.user_api.api import account as account_api
from openedx.core.djangoapps.user_api.api import profile as profile_api
from util.bad_request_rate_limiter import BadRequestRateLimiter
from student_account.helpers import auth_pipeline_urls
......
......@@ -11,8 +11,8 @@ from django.conf import settings
from django.core.urlresolvers import reverse
from util.testing import UrlResetMixin
from user_api.api import account as account_api
from user_api.api import profile as profile_api
from openedx.core.djangoapps.user_api.api import account as account_api
from openedx.core.djangoapps.user_api.api import profile as profile_api
from lang_pref import LANGUAGE_KEY, api as language_api
......
......@@ -11,7 +11,7 @@ from django.views.decorators.http import require_http_methods
from django_future.csrf import ensure_csrf_cookie
from django.contrib.auth.decorators import login_required
from edxmako.shortcuts import render_to_response
from user_api.api import profile as profile_api
from openedx.core.djangoapps.user_api.api import profile as profile_api
from lang_pref import LANGUAGE_KEY, api as language_api
import third_party_auth
......
......@@ -931,7 +931,7 @@ MIDDLEWARE_CLASSES = (
# Adds user tags to tracking events
# Must go before TrackMiddleware, to get the context set up
'user_api.middleware.UserTagsEventContextMiddleware',
'openedx.core.djangoapps.user_api.middleware.UserTagsEventContextMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'track.middleware.TrackMiddleware',
......@@ -1482,7 +1482,7 @@ INSTALLED_APPS = (
# User API
'rest_framework',
'user_api',
'openedx.core.djangoapps.user_api',
# Shopping cart
'shoppingcart',
......
......@@ -7,7 +7,7 @@ import xblock.reference.plugins
from django.core.urlresolvers import reverse
from django.conf import settings
from user_api.api import course_tag as user_course_tag_api
from openedx.core.djangoapps.user_api.api import course_tag as user_course_tag_api
from xmodule.modulestore.django import modulestore
from xmodule.x_module import ModuleSystem
from xmodule.partitions.partitions_service import PartitionService
......@@ -128,13 +128,13 @@ class LmsPartitionService(PartitionService):
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
to another (e.g. a course_module), this won't be necessary, 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)
course = modulestore().get_course(self.runtime.course_id)
return course.user_partitions
......@@ -194,8 +194,7 @@ class LmsModuleSystem(LmsHandlerUrls, ModuleSystem): # pylint: disable=abstract
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),
runtime=self,
track_function=kwargs.get('track_function', None),
)
services['fs'] = xblock.reference.plugins.FSService()
......
......@@ -57,7 +57,7 @@ urlpatterns = ('', # nopep8
url(r'^heartbeat$', include('heartbeat.urls')),
url(r'^user_api/', include('user_api.urls')),
url(r'^user_api/', include('openedx.core.djangoapps.user_api.urls')),
url(r'^notifier_api/', include('notifier_api.urls')),
......
......@@ -11,8 +11,8 @@ from django.db import transaction, IntegrityError
from django.core.validators import validate_email, validate_slug, ValidationError
from django.contrib.auth.forms import PasswordResetForm
from user_api.models import User, UserProfile, Registration, PendingEmailChange
from user_api.helpers import intercept_errors
from ..models import User, UserProfile, Registration, PendingEmailChange
from ..helpers import intercept_errors
USERNAME_MIN_LENGTH = 2
......
......@@ -13,9 +13,9 @@ from django.db import IntegrityError
from pytz import UTC
import analytics
from user_api.models import User, UserProfile, UserPreference, UserOrgTag
from user_api.helpers import intercept_errors
from eventtracking import tracker
from ..models import User, UserProfile, UserPreference, UserOrgTag
from ..helpers import intercept_errors
log = logging.getLogger(__name__)
......@@ -34,6 +34,7 @@ class ProfileInvalidField(ProfileRequestError):
""" The proposed value for a field is not in a valid format. """
def __init__(self, field, value):
super(ProfileInvalidField, self).__init__()
self.field = field
self.value = value
......
......@@ -12,7 +12,7 @@ from django.http import HttpResponseBadRequest
LOGGER = logging.getLogger(__name__)
def intercept_errors(api_error, ignore_errors=[]):
def intercept_errors(api_error, ignore_errors=None):
"""
Function decorator that intercepts exceptions
and translates them into API-specific errors (usually an "internal" error).
......@@ -33,13 +33,20 @@ def intercept_errors(api_error, ignore_errors=[]):
"""
def _decorator(func):
"""
Function decorator that intercepts exceptions and translates them into API-specific errors.
"""
@wraps(func)
def _wrapped(*args, **kwargs):
"""
Wrapper that evaluates a function, intercepting exceptions and translating them into
API-specific errors.
"""
try:
return func(*args, **kwargs)
except Exception as ex:
# Raise the original exception if it's in our list of "ignored" errors
for ignored in ignore_errors:
for ignored in ignore_errors or []:
if isinstance(ex, ignored):
raise
......
"""Generate a list indicating whether users have opted in or out of receiving email from an org.
Email opt-in is stored as an org-level preference.
When reports are generated, we need to handle:
1) Org aliases: some organizations might have multiple course key "org" values.
We choose the most recently set preference among all org aliases.
Since this information isn't stored anywhere in edx-platform,
the caller needs to pass in the list of orgs and aliases.
2) No preference set: Some users may not have an opt-in preference set
if they enrolled before the preference was introduced.
These users are opted in by default.
3) Restricting to a subset of courses in an org: Some orgs have courses
that we don't want to include in the results (e.g. EdX-created test courses).
Allow the caller to explicitly specify the list of courses in the org.
The command will always use the read replica database if one is configured.
"""
import os.path
import csv
import time
import contextlib
import logging
from django.core.management.base import BaseCommand, CommandError
from django.conf import settings
from django.db import connections
from opaque_keys.edx.keys import CourseKey
from xmodule.modulestore.django import modulestore
LOGGER = logging.getLogger(__name__)
class Command(BaseCommand):
"""Generate a list of email opt-in values for user enrollments. """
args = "<OUTPUT_FILENAME> <ORG_ALIASES> --courses=COURSE_ID_LIST"
help = "Generate a list of email opt-in values for user enrollments."
# Fields output in the CSV
OUTPUT_FIELD_NAMES = [
"email",
"full_name",
"course_id",
"is_opted_in_for_email",
"preference_set_date"
]
# Number of records to read at a time when making
# multiple queries over a potentially large dataset.
QUERY_INTERVAL = 1000
def handle(self, *args, **options):
"""Execute the command.
Arguments:
file_path (str): Path to the output file.
*org_list (unicode): List of organization aliases.
Keyword Arguments:
courses (unicode): Comma-separated list of course keys. If provided,
include only these courses in the results.
Raises:
CommandError
"""
file_path, org_list = self._parse_args(args)
# Retrieve all the courses for the org.
# If we were given a specific list of courses to include,
# filter out anything not in that list.
courses = self._get_courses_for_org(org_list)
only_courses = options.get("courses")
if only_courses is not None:
only_courses = [
CourseKey.from_string(course_key.strip())
for course_key in only_courses.split(",")
]
courses = list(set(courses) & set(only_courses))
# Add in organizations from the course keys, to ensure
# we're including orgs with different capitalizations
org_list = list(set(org_list) | set(course.org for course in courses))
# If no courses are found, abort
if not courses:
raise CommandError(
u"No courses found for orgs: {orgs}".format(
orgs=", ".join(org_list)
)
)
# Let the user know what's about to happen
LOGGER.info(
u"Retrieving data for courses: {courses}".format(
courses=", ".join([unicode(course) for course in courses])
)
)
# Open the output file and generate the report.
with open(file_path, "w") as file_handle:
with self._log_execution_time():
self._write_email_opt_in_prefs(file_handle, org_list, courses)
# Remind the user where the output file is
LOGGER.info(u"Output file: {file_path}".format(file_path=file_path))
def _parse_args(self, args):
"""Check and parse arguments.
Validates that the right number of args were provided
and that the output file doesn't already exist.
Arguments:
args (list): List of arguments given at the command line.
Returns:
Tuple of (file_path, org_list)
Raises:
CommandError
"""
if len(args) < 2:
raise CommandError(u"Usage: {args}".format(args=self.args))
file_path = args[0]
org_list = args[1:]
if os.path.exists(file_path):
raise CommandError("File already exists at '{path}'".format(path=file_path))
return file_path, org_list
def _get_courses_for_org(self, org_aliases):
"""Retrieve all course keys for a particular org.
Arguments:
org_aliases (list): List of aliases for the org.
Returns:
List of `CourseKey`s
"""
all_courses = modulestore().get_courses()
orgs_lowercase = [org.lower() for org in org_aliases]
return [
course.id
for course in all_courses
if course.id.org.lower() in orgs_lowercase
]
@contextlib.contextmanager
def _log_execution_time(self):
"""Context manager for measuring execution time. """
start_time = time.time()
yield
execution_time = time.time() - start_time
LOGGER.info(u"Execution time: {time} seconds".format(time=execution_time))
def _write_email_opt_in_prefs(self, file_handle, org_aliases, courses):
"""Write email opt-in preferences to the output file.
This will generate a CSV with one row for each enrollment.
This means that the user's "opt in" preference will be specified
multiple times if the user has enrolled in multiple courses
within the org. However, the values should always be the same:
if the user is listed as "opted out" for course A, she will
also be listed as "opted out" for courses B, C, and D.
Arguments:
file_handle (file): Handle to the output file.
org_aliases (list): List of aliases for the org.
courses (list): List of course keys in the org.
Returns:
None
"""
writer = csv.DictWriter(file_handle, fieldnames=self.OUTPUT_FIELD_NAMES)
cursor = self._db_cursor()
query = (
u"""
SELECT
user.`email` AS `email`,
profile.`name` AS `full_name`,
enrollment.`course_id` AS `course_id`,
(
SELECT value
FROM user_api_userorgtag
WHERE org IN ( {org_list} )
AND `key`=\"email-optin\"
AND `user_id`=user.`id`
ORDER BY modified DESC
LIMIT 1
) AS `is_opted_in_for_email`,
(
SELECT modified
FROM user_api_userorgtag
WHERE org IN ( {org_list} )
AND `key`=\"email-optin\"
AND `user_id`=user.`id`
ORDER BY modified DESC
LIMIT 1
) AS `preference_set_date`
FROM
student_courseenrollment AS enrollment
LEFT JOIN auth_user AS user ON user.id=enrollment.user_id
LEFT JOIN auth_userprofile AS profile ON profile.user_id=user.id
WHERE enrollment.course_id IN ( {course_id_list} )
"""
).format(
course_id_list=self._sql_list(courses),
org_list=self._sql_list(org_aliases)
)
cursor.execute(query)
row_count = 0
for row in self._iterate_results(cursor):
email, full_name, course_id, is_opted_in, pref_set_date = row
writer.writerow({
"email": email.encode('utf-8'),
"full_name": full_name.encode('utf-8'),
"course_id": course_id.encode('utf-8'),
"is_opted_in_for_email": is_opted_in if is_opted_in else "True",
"preference_set_date": pref_set_date,
})
row_count += 1
# Log the number of rows we processed
LOGGER.info(u"Retrieved {num_rows} records.".format(num_rows=row_count))
def _iterate_results(self, cursor):
"""Iterate through the results of a database query, fetching in chunks.
Arguments:
cursor: The database cursor
Yields:
tuple of row values from the query
"""
while True:
rows = cursor.fetchmany(self.QUERY_INTERVAL)
if not rows:
break
for row in rows:
yield row
def _sql_list(self, values):
"""Serialize a list of values for including in a SQL "IN" statement. """
return u",".join([u'"{}"'.format(val) for val in values])
def _db_cursor(self):
"""Return a database cursor to the read replica if one is available. """
# Use the read replica if one has been configured
db_alias = (
'read_replica'
if 'read_replica' in settings.DATABASES
else 'default'
)
return connections[db_alias].cursor()
# -*- coding: utf-8 -*-
"""Tests for the email opt-in list management command. """
import os.path
import tempfile
import shutil
import csv
from collections import defaultdict
from unittest import skipUnless
import ddt
from django.conf import settings
from django.test.utils import override_settings
from django.core.management.base import CommandError
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, mixed_store_config
from xmodule.modulestore.tests.factories import CourseFactory
from student.tests.factories import UserFactory, CourseEnrollmentFactory
from student.models import CourseEnrollment
import openedx.core.djangoapps.user_api.api.profile as profile_api
from openedx.core.djangoapps.user_api.models import UserOrgTag
from openedx.core.djangoapps.user_api.management.commands import email_opt_in_list
MODULESTORE_CONFIG = mixed_store_config(settings.COMMON_TEST_DATA_ROOT, {}, include_xml=False)
@ddt.ddt
@skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
@override_settings(MODULESTORE=MODULESTORE_CONFIG)
class EmailOptInListTest(ModuleStoreTestCase):
"""Tests for the email opt-in list management command. """
USER_USERNAME = "test_user"
USER_FIRST_NAME = u"Ṫëṡẗ"
USER_LAST_NAME = u"Űśéŕ"
TEST_ORG = u"téśt_őŕǵ"
OUTPUT_FILE_NAME = "test_org_email_opt_in.csv"
OUTPUT_FIELD_NAMES = [
"email",
"full_name",
"course_id",
"is_opted_in_for_email",
"preference_set_date"
]
def setUp(self):
self.user = UserFactory.create(
username=self.USER_USERNAME,
first_name=self.USER_FIRST_NAME,
last_name=self.USER_LAST_NAME
)
self.courses = []
self.enrollments = defaultdict(list)
def test_not_enrolled(self):
self._create_courses_and_enrollments((self.TEST_ORG, False))
output = self._run_command(self.TEST_ORG)
# The user isn't enrolled in the course, so the output should be empty
self._assert_output(output)
def test_enrolled_no_pref(self):
self._create_courses_and_enrollments((self.TEST_ORG, True))
output = self._run_command(self.TEST_ORG)
# By default, if no preference is set by the user is enrolled, opt in
self._assert_output(output, (self.user, self.courses[0].id, True))
def test_enrolled_pref_opted_in(self):
self._create_courses_and_enrollments((self.TEST_ORG, True))
self._set_opt_in_pref(self.user, self.TEST_ORG, True)
output = self._run_command(self.TEST_ORG)
self._assert_output(output, (self.user, self.courses[0].id, True))
def test_enrolled_pref_opted_out(self):
self._create_courses_and_enrollments((self.TEST_ORG, True))
self._set_opt_in_pref(self.user, self.TEST_ORG, False)
output = self._run_command(self.TEST_ORG)
self._assert_output(output, (self.user, self.courses[0].id, False))
def test_opt_in_then_opt_out(self):
self._create_courses_and_enrollments((self.TEST_ORG, True))
self._set_opt_in_pref(self.user, self.TEST_ORG, True)
self._set_opt_in_pref(self.user, self.TEST_ORG, False)
output = self._run_command(self.TEST_ORG)
self._assert_output(output, (self.user, self.courses[0].id, False))
def test_exclude_non_org_courses(self):
# Enroll in a course that's not in the org
self._create_courses_and_enrollments(
(self.TEST_ORG, True),
("other_org", True)
)
# Opt out of the other course
self._set_opt_in_pref(self.user, "other_org", False)
# The first course is included in the results,
# but the second course is excluded,
# so the user should be opted in by default.
output = self._run_command(self.TEST_ORG)
self._assert_output(
output,
(self.user, self.courses[0].id, True),
expect_pref_datetime=False
)
def test_enrolled_conflicting_prefs(self):
# Enroll in two courses, both in the org
self._create_courses_and_enrollments(
(self.TEST_ORG, True),
("org_alias", True)
)
# Opt into the first course, then opt out of the second course
self._set_opt_in_pref(self.user, self.TEST_ORG, True)
self._set_opt_in_pref(self.user, "org_alias", False)
# The second preference change should take precedence
# Note that *both* courses are included in the list,
# but they should have the same value.
output = self._run_command(self.TEST_ORG, other_names=["org_alias"])
self._assert_output(
output,
(self.user, self.courses[0].id, False),
(self.user, self.courses[1].id, False)
)
# Opt into the first course
# Even though the other course still has a preference set to false,
# the newest preference takes precedence
self._set_opt_in_pref(self.user, self.TEST_ORG, True)
output = self._run_command(self.TEST_ORG, other_names=["org_alias"])
self._assert_output(
output,
(self.user, self.courses[0].id, True),
(self.user, self.courses[1].id, True)
)
@ddt.data(True, False)
def test_unenrolled_from_all_courses(self, opt_in_pref):
# Enroll in the course and set a preference
self._create_courses_and_enrollments((self.TEST_ORG, True))
self._set_opt_in_pref(self.user, self.TEST_ORG, opt_in_pref)
# Unenroll from the course
CourseEnrollment.unenroll(self.user, self.courses[0].id, skip_refund=True)
# Enrollments should still appear in the outpu
output = self._run_command(self.TEST_ORG)
self._assert_output(output, (self.user, self.courses[0].id, opt_in_pref))
def test_unenrolled_from_some_courses(self):
# Enroll in several courses in the org
self._create_courses_and_enrollments(
(self.TEST_ORG, True),
(self.TEST_ORG, True),
(self.TEST_ORG, True),
("org_alias", True)
)
# Set a preference for the aliased course
self._set_opt_in_pref(self.user, "org_alias", False)
# Unenroll from the aliased course
CourseEnrollment.unenroll(self.user, self.courses[3].id, skip_refund=True)
# Expect that the preference still applies,
# and all the enrollments should appear in the list
output = self._run_command(self.TEST_ORG, other_names=["org_alias"])
self._assert_output(
output,
(self.user, self.courses[0].id, False),
(self.user, self.courses[1].id, False),
(self.user, self.courses[2].id, False),
(self.user, self.courses[3].id, False)
)
def test_no_courses_for_org_name(self):
self._create_courses_and_enrollments((self.TEST_ORG, True))
self._set_opt_in_pref(self.user, self.TEST_ORG, True)
# No course available for this particular org
with self.assertRaisesRegexp(CommandError, "^No courses found for orgs:"):
self._run_command("other_org")
def test_specify_subset_of_courses(self):
# Create several courses in the same org
self._create_courses_and_enrollments(
(self.TEST_ORG, True),
(self.TEST_ORG, True),
(self.TEST_ORG, True),
)
# Execute the command, but exclude the second course from the list
only_courses = [self.courses[0].id, self.courses[1].id]
self._run_command(self.TEST_ORG, only_courses=only_courses)
# Choose numbers before and after the query interval boundary
@ddt.data(2, 3, 4, 5, 6, 7, 8, 9)
def test_many_users(self, num_users):
# Create many users and enroll them in the test course
course = CourseFactory.create(org=self.TEST_ORG)
usernames = []
for _ in range(num_users):
user = UserFactory.create()
usernames.append(user.username)
CourseEnrollmentFactory.create(course_id=course.id, user=user)
# Generate the report
output = self._run_command(self.TEST_ORG, query_interval=4)
# Expect that every enrollment shows up in the report
output_emails = [row["email"] for row in output]
for email in output_emails:
self.assertIn(email, output_emails)
def test_org_capitalization(self):
# Lowercase some of the org names in the course IDs
self._create_courses_and_enrollments(
("MyOrg", True),
("myorg", True)
)
# Set preferences for both courses
self._set_opt_in_pref(self.user, "MyOrg", True)
self._set_opt_in_pref(self.user, "myorg", False)
# Execute the command, expecting both enrollments to show up
# We're passing in the uppercase org, but we set the lowercase
# version more recently, so we expect the lowercase org
# preference to apply.
output = self._run_command("MyOrg")
self._assert_output(
output,
(self.user, self.courses[0].id, False),
(self.user, self.courses[1].id, False)
)
@ddt.data(0, 1)
def test_not_enough_args(self, num_args):
args = ["dummy"] * num_args
expected_msg_regex = "^Usage: <OUTPUT_FILENAME> <ORG_ALIASES> --courses=COURSE_ID_LIST$"
with self.assertRaisesRegexp(CommandError, expected_msg_regex):
email_opt_in_list.Command().handle(*args)
def test_file_already_exists(self):
temp_file = tempfile.NamedTemporaryFile(delete=True)
def _cleanup(): # pylint: disable=missing-docstring
temp_file.close()
with self.assertRaisesRegexp(CommandError, "^File already exists"):
email_opt_in_list.Command().handle(temp_file.name, self.TEST_ORG)
def _create_courses_and_enrollments(self, *args):
"""Create courses and enrollments.
Created courses and enrollments are stored in instance variables
so tests can refer to them later.
Arguments:
*args: Tuples of (course_org, should_enroll), where
course_org is the name of the org in the course key
and should_enroll is a boolean indicating whether to enroll
the user in the course.
Returns:
None
"""
for course_number, (course_org, should_enroll) in enumerate(args):
course = CourseFactory.create(org=course_org, number=str(course_number))
if should_enroll:
enrollment = CourseEnrollmentFactory.create(
is_active=True,
course_id=course.id,
user=self.user
)
self.enrollments[course.id].append(enrollment)
self.courses.append(course)
def _set_opt_in_pref(self, user, org, is_opted_in):
"""Set the email opt-in preference.
Arguments:
user (User): The user model.
org (unicode): The org in the course key.
is_opted_in (bool): Whether the user is opted in or out of emails.
Returns:
None
"""
profile_api.update_email_opt_in(user.username, org, is_opted_in)
def _latest_pref_set_date(self, user):
"""Retrieve the latest opt-in preference for the user,
across all orgs and preference keys.
Arguments:
user (User): The user whos preference was set.
Returns:
ISO-formatted date string or empty string
"""
pref = UserOrgTag.objects.filter(user=user).order_by("-modified")
return pref[0].modified.isoformat(' ') if len(pref) > 0 else ""
def _run_command(self, org, other_names=None, only_courses=None, query_interval=None):
"""Execute the management command to generate the email opt-in list.
Arguments:
org (unicode): The org to generate the report for.
Keyword Arguments:
other_names (list): List of other aliases for the org.
only_courses (list): If provided, include only these course IDs in the report.
query_interval (int): If provided, override the default query interval.
Returns:
list: The rows of the generated CSV report. Each item is a dictionary.
"""
# Create a temporary directory for the output
# Delete it when we're finished
temp_dir_path = tempfile.mkdtemp()
def _cleanup(): # pylint: disable=missing-docstring
shutil.rmtree(temp_dir_path)
self.addCleanup(_cleanup)
# Sanitize the arguments
if other_names is None:
other_names = []
output_path = os.path.join(temp_dir_path, self.OUTPUT_FILE_NAME)
org_list = [org] + other_names
if only_courses is not None:
only_courses = ",".join(unicode(course_id) for course_id in only_courses)
command = email_opt_in_list.Command()
# Override the query interval to speed up the tests
if query_interval is not None:
command.QUERY_INTERVAL = query_interval
# Execute the command
command.handle(output_path, *org_list, courses=only_courses)
# Retrieve the output from the file
try:
with open(output_path) as output_file:
reader = csv.DictReader(output_file, fieldnames=self.OUTPUT_FIELD_NAMES)
rows = [row for row in reader]
except IOError:
self.fail("Could not find or open output file at '{path}'".format(path=output_path))
# Return the output as a list of dictionaries
return rows
def _assert_output(self, output, *args, **kwargs):
"""Check the output of the report.
Arguments:
output (list): List of rows in the output CSV file.
*args: Tuples of (user, course_id, opt_in_pref)
Keyword Arguments:
expect_pref_datetime (bool): If false, expect an empty
string for the preference.
Returns:
None
Raises:
AssertionError
"""
self.assertEqual(len(output), len(args))
for user, course_id, opt_in_pref in args:
self.assertIn({
"email": user.email.encode('utf-8'),
"full_name": user.profile.name.encode('utf-8'),
"course_id": unicode(course_id).encode('utf-8'),
"is_opted_in_for_email": unicode(opt_in_pref),
"preference_set_date": (
self._latest_pref_set_date(self.user)
if kwargs.get("expect_pref_datetime", True)
else ""
)
}, output)
......@@ -8,7 +8,8 @@ from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from track.contexts import COURSE_REGEX
from user_api.models import UserCourseTag
from .models import UserCourseTag
class UserTagsEventContextMiddleware(object):
......
"""
Provides partition support to the user service.
"""
import random
import api.course_tag as course_tag_api
from xmodule.partitions.partitions import UserPartitionError
class RandomUserPartitionScheme(object):
"""
This scheme randomly assigns users into the partition's groups.
"""
RANDOM = random.Random()
@classmethod
def get_group_for_user(cls, course_id, user, user_partition, track_function=None):
"""
Returns the group from the specified user position to which the user is assigned.
If the user has not yet been assigned, a group will be randomly chosen for them.
"""
partition_key = cls._key_for_partition(user_partition)
group_id = course_tag_api.get_course_tag(user, course_id, partition_key)
group = user_partition.get_group(int(group_id)) if not group_id is None else None
if group is None:
if not user_partition.groups:
raise UserPartitionError('Cannot assign user to an empty user partition')
# pylint: disable=fixme
# 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.
group = cls.RANDOM.choice(user_partition.groups)
# persist the value as a course tag
course_tag_api.set_course_tag(user, course_id, partition_key, group.id)
if track_function:
# 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
}
# pylint: disable=fixme
# TODO: Use the XBlock publish api instead
track_function('xmodule.partitions.assigned_user_to_partition', event_info)
return group
@classmethod
def _key_for_partition(cls, user_partition):
"""
Returns the key to use to look up and save the user's group for a given user partition.
"""
return 'xblock.partition_service.partition_{0}'.format(user_partition.id)
from django.contrib.auth.models import User
from rest_framework import serializers
from student.models import UserProfile
from user_api.models import UserPreference
from .models import UserPreference
class UserSerializer(serializers.HyperlinkedModelSerializer):
......
......@@ -2,9 +2,10 @@
from factory.django import DjangoModelFactory
from factory import SubFactory
from student.tests.factories import UserFactory
from user_api.models import UserPreference, UserCourseTag, UserOrgTag
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from ..models import UserPreference, UserCourseTag, UserOrgTag
# Factories are self documenting
# pylint: disable=missing-docstring
......
......@@ -12,8 +12,8 @@ from django.core import mail
from django.test import TestCase
from django.conf import settings
from user_api.api import account as account_api
from user_api.models import UserProfile
from ..api import account as account_api
from ..models import UserProfile
@ddt.ddt
......
......@@ -4,11 +4,11 @@ Test the user course tag API.
from django.test import TestCase
from student.tests.factories import UserFactory
from user_api.api import course_tag as course_tag_api
from openedx.core.djangoapps.user_api.api import course_tag as course_tag_api
from opaque_keys.edx.locations import SlashSeparatedCourseKey
class TestUserService(TestCase):
class TestCourseTagAPI(TestCase):
"""
Test the user service
"""
......
......@@ -4,10 +4,10 @@ Tests for helper functions.
import json
import mock
import ddt
from django.http import HttpRequest, HttpResponse
from django.test import TestCase
from nose.tools import raises
from django.http import HttpRequest, HttpResponse
from user_api.helpers import (
from ..helpers import (
intercept_errors, shim_student_view,
FormDescription, InvalidFieldError
)
......@@ -49,12 +49,12 @@ class InterceptErrorsTest(TestCase):
def test_ignores_expected_errors(self):
intercepted_function(raise_error=ValueError)
@mock.patch('user_api.helpers.LOGGER')
@mock.patch('openedx.core.djangoapps.user_api.helpers.LOGGER')
def test_logs_errors(self, mock_logger):
expected_log_msg = (
u"An unexpected error occurred when calling 'intercepted_function' "
u"with arguments '()' and "
u"keyword arguments '{'raise_error': <class 'user_api.tests.test_helpers.FakeInputException'>}': "
u"keyword arguments '{'raise_error': <class 'openedx.core.djangoapps.user_api.tests.test_helpers.FakeInputException'>}': "
u"FakeInputException()"
)
......
......@@ -6,8 +6,9 @@ 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
from ..tests.factories import UserCourseTagFactory
from ..middleware import UserTagsEventContextMiddleware
class TagsMiddlewareTest(TestCase):
......@@ -29,7 +30,7 @@ class TagsMiddlewareTest(TestCase):
self.response = Mock(spec=HttpResponse)
patcher = patch('user_api.middleware.tracker')
patcher = patch('openedx.core.djangoapps.user_api.middleware.tracker')
self.tracker = patcher.start()
self.addCleanup(patcher.stop)
......
......@@ -2,8 +2,9 @@ from django.db import IntegrityError
from django.test import TestCase
from xmodule.modulestore.tests.factories import CourseFactory
from student.tests.factories import UserFactory
from user_api.tests.factories import UserPreferenceFactory, UserCourseTagFactory, UserOrgTagFactory
from user_api.models import UserPreference
from ..tests.factories import UserPreferenceFactory, UserCourseTagFactory, UserOrgTagFactory
from ..models import UserPreference
class UserPreferenceModelTest(TestCase):
......
"""
Test the user api's partition extensions.
"""
from collections import defaultdict
from mock import patch
from openedx.core.djangoapps.user_api.partition_schemes import RandomUserPartitionScheme, UserPartitionError
from student.tests.factories import UserFactory
from xmodule.partitions.partitions import Group, UserPartition
from xmodule.partitions.tests.test_partitions import PartitionTestCase
class MemoryCourseTagAPI(object):
"""
An implementation of a user service that uses an in-memory dictionary for storage
"""
def __init__(self):
self._tags = defaultdict(dict)
def get_course_tag(self, __, course_id, key):
"""Sets the value of ``key`` to ``value``"""
return self._tags[course_id].get(key)
def set_course_tag(self, __, course_id, key, value):
"""Gets the value of ``key``"""
self._tags[course_id][key] = value
class TestRandomUserPartitionScheme(PartitionTestCase):
"""
Test getting a user's group out of a partition
"""
MOCK_COURSE_ID = "mock-course-id"
def setUp(self):
super(TestRandomUserPartitionScheme, self).setUp()
# Patch in a memory-based user service instead of using the persistent version
course_tag_api = MemoryCourseTagAPI()
self.user_service_patcher = patch(
'openedx.core.djangoapps.user_api.partition_schemes.course_tag_api', course_tag_api
)
self.user_service_patcher.start()
# Create a test user
self.user = UserFactory.create()
def tearDown(self):
self.user_service_patcher.stop()
def test_get_group_for_user(self):
# get a group assigned to the user
group1_id = RandomUserPartitionScheme.get_group_for_user(self.MOCK_COURSE_ID, self.user, self.user_partition)
# make sure we get the same group back out every time
for __ in range(0, 10):
group2_id = RandomUserPartitionScheme.get_group_for_user(self.MOCK_COURSE_ID, self.user, self.user_partition)
self.assertEqual(group1_id, group2_id)
def test_empty_partition(self):
empty_partition = UserPartition(
self.TEST_ID,
'Test Partition',
'for testing purposes',
[],
scheme=RandomUserPartitionScheme
)
# get a group assigned to the user
with self.assertRaisesRegexp(UserPartitionError, "Cannot assign user to an empty user partition"):
RandomUserPartitionScheme.get_group_for_user(self.MOCK_COURSE_ID, self.user, empty_partition)
def test_user_in_deleted_group(self):
# get a group assigned to the user - should be group 0 or 1
old_group = RandomUserPartitionScheme.get_group_for_user(self.MOCK_COURSE_ID, self.user, self.user_partition)
self.assertIn(old_group.id, [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.TEST_ID, 'Test Partition', 'for testing purposes', groups)
# Now, get a new group using the same call - should be 3 or 4
new_group = RandomUserPartitionScheme.get_group_for_user(self.MOCK_COURSE_ID, self.user, user_partition)
self.assertIn(new_group.id, [3, 4])
# We should get the same group over multiple calls
new_group_2 = RandomUserPartitionScheme.get_group_for_user(self.MOCK_COURSE_ID, self.user, user_partition)
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 = RandomUserPartitionScheme.get_group_for_user(self.MOCK_COURSE_ID, self.user, self.user_partition)
self.assertIn(old_group.id, [0, 1])
# Change the group names
groups = [Group(0, 'Group 0'), Group(1, 'Group 1')]
user_partition = UserPartition(
self.TEST_ID,
'Test Partition',
'for testing purposes',
groups,
scheme=RandomUserPartitionScheme
)
# Now, get a new group using the same call
new_group = RandomUserPartitionScheme.get_group_for_user(self.MOCK_COURSE_ID, self.user, user_partition)
self.assertEqual(old_group.id, new_group.id)
......@@ -10,9 +10,9 @@ from dateutil.parser import parse as parse_datetime
from xmodule.modulestore.tests.factories import CourseFactory
import datetime
from user_api.api import account as account_api
from user_api.api import profile as profile_api
from user_api.models import UserProfile, UserOrgTag
from ..api import account as account_api
from ..api import profile as profile_api
from ..models import UserProfile, UserOrgTag
@ddt.ddt
......
......@@ -16,16 +16,16 @@ from pytz import UTC
import mock
from xmodule.modulestore.tests.factories import CourseFactory
from user_api.api import account as account_api, profile as profile_api
from student.tests.factories import UserFactory
from user_api.models import UserOrgTag
from user_api.tests.factories import UserPreferenceFactory
from unittest import SkipTest
from django_comment_common import models
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from third_party_auth.tests.testutil import simulate_running_pipeline
from user_api.tests.test_constants import SORTED_COUNTRIES
from ..api import account as account_api, profile as profile_api
from ..models import UserOrgTag
from ..tests.factories import UserPreferenceFactory
from ..tests.test_constants import SORTED_COUNTRIES
TEST_API_KEY = "test_api_key"
......
# pylint: disable=missing-docstring
"""
Defines the URL routes for this app.
"""
from django.conf import settings
from django.conf.urls import include, patterns, url
from rest_framework import routers
from user_api import views as user_api_views
from user_api.models import UserPreference
from . import views as user_api_views
from .models import UserPreference
user_api_router = routers.DefaultRouter()
user_api_router.register(r'users', user_api_views.UserViewSet)
user_api_router.register(r'user_prefs', user_api_views.UserPreferenceViewSet)
USER_API_ROUTER = routers.DefaultRouter()
USER_API_ROUTER.register(r'users', user_api_views.UserViewSet)
USER_API_ROUTER.register(r'user_prefs', user_api_views.UserPreferenceViewSet)
urlpatterns = patterns(
'',
url(r'^v1/', include(user_api_router.urls)),
url(r'^v1/', include(USER_API_ROUTER.urls)),
url(
r'^v1/preferences/(?P<pref_key>{})/users/$'.format(UserPreference.KEY_REGEX),
user_api_views.PreferenceUsersListView.as_view()
......
"""HTTP end-points for the User API. """
import copy
import third_party_auth
from django.conf import settings
from django.contrib.auth.models import User
......@@ -19,16 +20,15 @@ from rest_framework import viewsets
from rest_framework.views import APIView
from rest_framework.exceptions import ParseError
from django_countries import countries
from user_api.serializers import UserSerializer, UserPreferenceSerializer
from user_api.models import UserPreference, UserProfile
from django_comment_common.models import Role
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from edxmako.shortcuts import marketing_link
import third_party_auth
from util.authentication import SessionAuthenticationAllowInactiveUser
from user_api.api import account as account_api, profile as profile_api
from user_api.helpers import FormDescription, shim_student_view, require_post_params
from .api import account as account_api, profile as profile_api
from .helpers import FormDescription, shim_student_view, require_post_params
from .models import UserPreference
from .serializers import UserSerializer, UserPreferenceSerializer
class ApiKeyHeaderPermission(permissions.BasePermission):
......
......@@ -48,7 +48,7 @@ def test_system(options):
if test_id:
if not system:
system = test_id.split('/')[0]
if system == 'common':
if system in ['common', 'openedx']:
system = 'lms'
opts['test_id'] = test_id
......
......@@ -128,7 +128,7 @@ class SystemTestSuite(NoseTestSuite):
# django-nose will import them early in the test process,
# thereby making sure that we load any django models that are
# only defined in test files.
default_test_id = "{system}/djangoapps/* common/djangoapps/*".format(
default_test_id = "{system}/djangoapps/* common/djangoapps/* openedx/core/djangoapps/*".format(
system=self.root
)
......
from setuptools import setup, find_packages
"""
Setup script for the Open edX package.
"""
from setuptools import setup
setup(
name="Open edX",
version="0.1",
version="0.2",
install_requires=['distribute'],
requires=[],
# NOTE: These are not the names we should be installing. This tree should
# be reorgnized to be a more conventional Python tree.
# be reorganized to be a more conventional Python tree.
packages=[
"openedx.core.djangoapps.user_api",
"lms",
"cms",
],
entry_points={
'openedx.user_partition_scheme': [
'random = openedx.core.djangoapps.user_api.partition_schemes:RandomUserPartitionScheme',
],
}
)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment