Commit 2b0f959b by Andy Armstrong

Merge pull request #5942 from edx/cohorted-courseware

Cohorted courseware
parents 99b55f88 50e4416d
...@@ -5,6 +5,12 @@ These are notable changes in edx-platform. This is a rolling list of changes, ...@@ -5,6 +5,12 @@ 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 in roughly chronological order, most recent first. Add your entries at or near
the top. Include a label indicating the component affected. the top. Include a label indicating the component affected.
Platform: Add group_access field to all xblocks. TNL-670
LMS: Add support for user partitioning based on cohort. TNL-710
Platform: Add base support for cohorted group configurations. TNL-649
Common: Add configurable reset button to units Common: Add configurable reset button to units
Studio: Add support xblock validation messages on Studio unit/container page. TNL-683 Studio: Add support xblock validation messages on Studio unit/container page. TNL-683
......
...@@ -22,7 +22,7 @@ from xmodule.error_module import ErrorDescriptor ...@@ -22,7 +22,7 @@ from xmodule.error_module import ErrorDescriptor
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.contentstore.content import StaticContent from xmodule.contentstore.content import StaticContent
from xmodule.tabs import PDFTextbookTabs 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 import EdxJSONEncoder
from xmodule.modulestore.exceptions import ItemNotFoundError, DuplicateCourseError from xmodule.modulestore.exceptions import ItemNotFoundError, DuplicateCourseError
from opaque_keys import InvalidKeyError from opaque_keys import InvalidKeyError
...@@ -1173,7 +1173,7 @@ class GroupConfiguration(object): ...@@ -1173,7 +1173,7 @@ class GroupConfiguration(object):
configuration = json.loads(json_string) configuration = json.loads(json_string)
except ValueError: except ValueError:
raise GroupConfigurationsValidationError(_("invalid JSON")) raise GroupConfigurationsValidationError(_("invalid JSON"))
configuration["version"] = UserPartition.VERSION
return configuration return configuration
def validate(self): def validate(self):
...@@ -1224,14 +1224,7 @@ class GroupConfiguration(object): ...@@ -1224,14 +1224,7 @@ class GroupConfiguration(object):
""" """
Get user partition for saving in course. Get user partition for saving in course.
""" """
groups = [Group(g["id"], g["name"]) for g in self.configuration["groups"]] return UserPartition.from_json(self.configuration)
return UserPartition(
self.configuration["id"],
self.configuration["name"],
self.configuration["description"],
groups
)
@staticmethod @staticmethod
def get_usage_info(course, store): def get_usage_info(course, store):
...@@ -1345,15 +1338,12 @@ def group_configurations_list_handler(request, course_key_string): ...@@ -1345,15 +1338,12 @@ def group_configurations_list_handler(request, course_key_string):
if 'text/html' in request.META.get('HTTP_ACCEPT', 'text/html'): if 'text/html' in request.META.get('HTTP_ACCEPT', 'text/html'):
group_configuration_url = reverse_course_url('group_configurations_list_handler', course_key) group_configuration_url = reverse_course_url('group_configurations_list_handler', course_key)
course_outline_url = reverse_course_url('course_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) configurations = GroupConfiguration.add_usage_info(course, store)
return render_to_response('group_configurations.html', { return render_to_response('group_configurations.html', {
'context_course': course, 'context_course': course,
'group_configuration_url': group_configuration_url, 'group_configuration_url': group_configuration_url,
'course_outline_url': course_outline_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'): elif "application/json" in request.META.get('HTTP_ACCEPT'):
if request.method == 'POST': if request.method == 'POST':
...@@ -1432,6 +1422,16 @@ def group_configurations_detail_handler(request, course_key_string, group_config ...@@ -1432,6 +1422,16 @@ def group_configurations_detail_handler(request, course_key_string, group_config
return JsonResponse(status=204) 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): def _get_course_creator_status(user):
""" """
Helper method for returning the course creator status for a particular user, Helper method for returning the course creator status for a particular user,
......
...@@ -15,10 +15,17 @@ from xmodule.modulestore import ModuleStoreEnum ...@@ -15,10 +15,17 @@ from xmodule.modulestore import ModuleStoreEnum
GROUP_CONFIGURATION_JSON = { GROUP_CONFIGURATION_JSON = {
u'name': u'Test name', u'name': u'Test name',
u'scheme': u'random',
u'description': u'Test description', u'description': u'Test description',
u'version': UserPartition.VERSION,
u'groups': [ 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 ...@@ -229,7 +236,8 @@ class GroupConfigurationsListHandlerTestCase(CourseTestCase, GroupConfigurations
expected = { expected = {
u'description': u'Test description', u'description': u'Test description',
u'name': u'Test name', u'name': u'Test name',
u'version': 1, u'scheme': u'random',
u'version': UserPartition.VERSION,
u'groups': [ u'groups': [
{u'name': u'Group A', u'version': 1}, {u'name': u'Group A', u'version': 1},
{u'name': u'Group B', u'version': 1}, {u'name': u'Group B', u'version': 1},
...@@ -279,15 +287,16 @@ class GroupConfigurationsDetailHandlerTestCase(CourseTestCase, GroupConfiguratio ...@@ -279,15 +287,16 @@ class GroupConfigurationsDetailHandlerTestCase(CourseTestCase, GroupConfiguratio
kwargs={'group_configuration_id': cid}, 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. PUT new group configuration when no configurations exist in the course.
""" """
expected = { expected = {
u'id': 999, u'id': 999,
u'name': u'Test name', u'name': u'Test name',
u'scheme': u'random',
u'description': u'Test description', u'description': u'Test description',
u'version': 1, u'version': UserPartition.VERSION,
u'groups': [ u'groups': [
{u'id': 0, u'name': u'Group A', u'version': 1}, {u'id': 0, u'name': u'Group A', u'version': 1},
{u'id': 1, u'name': u'Group B', u'version': 1}, {u'id': 1, u'name': u'Group B', u'version': 1},
...@@ -306,12 +315,12 @@ class GroupConfigurationsDetailHandlerTestCase(CourseTestCase, GroupConfiguratio ...@@ -306,12 +315,12 @@ class GroupConfigurationsDetailHandlerTestCase(CourseTestCase, GroupConfiguratio
self.assertEqual(content, expected) self.assertEqual(content, expected)
self.reload_course() self.reload_course()
# Verify that user_partitions in the course contains the new group configuration. # Verify that user_partitions in the course contains the new group configuration.
user_partititons = self.course.user_partitions user_partitions = self.course.user_partitions
self.assertEqual(len(user_partititons), 1) self.assertEqual(len(user_partitions), 1)
self.assertEqual(user_partititons[0].name, u'Test name') self.assertEqual(user_partitions[0].name, u'Test name')
self.assertEqual(len(user_partititons[0].groups), 2) self.assertEqual(len(user_partitions[0].groups), 2)
self.assertEqual(user_partititons[0].groups[0].name, u'Group A') self.assertEqual(user_partitions[0].groups[0].name, u'Group A')
self.assertEqual(user_partititons[0].groups[1].name, u'Group B') self.assertEqual(user_partitions[0].groups[1].name, u'Group B')
def test_can_edit_group_configuration(self): def test_can_edit_group_configuration(self):
""" """
...@@ -323,8 +332,9 @@ class GroupConfigurationsDetailHandlerTestCase(CourseTestCase, GroupConfiguratio ...@@ -323,8 +332,9 @@ class GroupConfigurationsDetailHandlerTestCase(CourseTestCase, GroupConfiguratio
expected = { expected = {
u'id': self.ID, u'id': self.ID,
u'name': u'New Test name', u'name': u'New Test name',
u'scheme': u'random',
u'description': u'New Test description', u'description': u'New Test description',
u'version': 1, u'version': UserPartition.VERSION,
u'groups': [ u'groups': [
{u'id': 0, u'name': u'New Group Name', u'version': 1}, {u'id': 0, u'name': u'New Group Name', u'version': 1},
{u'id': 2, u'name': u'Group C', u'version': 1}, {u'id': 2, u'name': u'Group C', u'version': 1},
...@@ -430,8 +440,9 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods): ...@@ -430,8 +440,9 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods):
expected = [{ expected = [{
'id': 0, 'id': 0,
'name': 'Name 0', 'name': 'Name 0',
'scheme': 'random',
'description': 'Description 0', 'description': 'Description 0',
'version': 1, 'version': UserPartition.VERSION,
'groups': [ 'groups': [
{'id': 0, 'name': 'Group A', 'version': 1}, {'id': 0, 'name': 'Group A', 'version': 1},
{'id': 1, 'name': 'Group B', 'version': 1}, {'id': 1, 'name': 'Group B', 'version': 1},
...@@ -454,8 +465,9 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods): ...@@ -454,8 +465,9 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods):
expected = [{ expected = [{
'id': 0, 'id': 0,
'name': 'Name 0', 'name': 'Name 0',
'scheme': 'random',
'description': 'Description 0', 'description': 'Description 0',
'version': 1, 'version': UserPartition.VERSION,
'groups': [ 'groups': [
{'id': 0, 'name': 'Group A', 'version': 1}, {'id': 0, 'name': 'Group A', 'version': 1},
{'id': 1, 'name': 'Group B', 'version': 1}, {'id': 1, 'name': 'Group B', 'version': 1},
...@@ -469,8 +481,9 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods): ...@@ -469,8 +481,9 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods):
}, { }, {
'id': 1, 'id': 1,
'name': 'Name 1', 'name': 'Name 1',
'scheme': 'random',
'description': 'Description 1', 'description': 'Description 1',
'version': 1, 'version': UserPartition.VERSION,
'groups': [ 'groups': [
{'id': 0, 'name': 'Group A', 'version': 1}, {'id': 0, 'name': 'Group A', 'version': 1},
{'id': 1, 'name': 'Group B', 'version': 1}, {'id': 1, 'name': 'Group B', 'version': 1},
...@@ -495,8 +508,9 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods): ...@@ -495,8 +508,9 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods):
expected = [{ expected = [{
'id': 0, 'id': 0,
'name': 'Name 0', 'name': 'Name 0',
'scheme': 'random',
'description': 'Description 0', 'description': 'Description 0',
'version': 1, 'version': UserPartition.VERSION,
'groups': [ 'groups': [
{'id': 0, 'name': 'Group A', 'version': 1}, {'id': 0, 'name': 'Group A', 'version': 1},
{'id': 1, 'name': 'Group B', 'version': 1}, {'id': 1, 'name': 'Group B', 'version': 1},
......
...@@ -223,8 +223,9 @@ class GetItemTest(ItemTest): ...@@ -223,8 +223,9 @@ class GetItemTest(ItemTest):
GROUP_CONFIGURATION_JSON = { GROUP_CONFIGURATION_JSON = {
u'id': 0, u'id': 0,
u'name': u'first_partition', u'name': u'first_partition',
u'scheme': u'random',
u'description': u'First Partition', u'description': u'First Partition',
u'version': 1, u'version': UserPartition.VERSION,
u'groups': [ u'groups': [
{u'id': 0, u'name': u'New_NAME_A', u'version': 1}, {u'id': 0, u'name': u'New_NAME_A', u'version': 1},
{u'id': 1, u'name': u'New_NAME_B', u'version': 1}, {u'id': 1, u'name': u'New_NAME_B', u'version': 1},
......
...@@ -32,6 +32,7 @@ class CourseMetadata(object): ...@@ -32,6 +32,7 @@ class CourseMetadata(object):
'name', # from xblock 'name', # from xblock
'tags', # from xblock 'tags', # from xblock
'visible_to_staff_only', 'visible_to_staff_only',
'group_access',
] ]
@classmethod @classmethod
......
...@@ -575,7 +575,7 @@ INSTALLED_APPS = ( ...@@ -575,7 +575,7 @@ INSTALLED_APPS = (
'contentstore', 'contentstore',
'course_creators', 'course_creators',
'student', # misleading name due to sharing with lms 'student', # misleading name due to sharing with lms
'course_groups', # not used in cms (yet), but tests run 'openedx.core.djangoapps.course_groups', # not used in cms (yet), but tests run
# Tracking # Tracking
'track', 'track',
...@@ -607,7 +607,7 @@ INSTALLED_APPS = ( ...@@ -607,7 +607,7 @@ INSTALLED_APPS = (
'reverification', 'reverification',
# User preferences # User preferences
'user_api', 'openedx.core.djangoapps.user_api',
'django_openid_auth', 'django_openid_auth',
'embargo', 'embargo',
......
...@@ -7,7 +7,7 @@ define([ ...@@ -7,7 +7,7 @@ define([
defaults: function() { defaults: function() {
return { return {
name: '', name: '',
version: null, version: 1,
order: null order: null
}; };
}, },
......
...@@ -9,8 +9,9 @@ function(Backbone, _, str, gettext, GroupModel, GroupCollection) { ...@@ -9,8 +9,9 @@ function(Backbone, _, str, gettext, GroupModel, GroupCollection) {
defaults: function() { defaults: function() {
return { return {
name: '', name: '',
scheme: 'random',
description: '', description: '',
version: null, version: 2,
groups: new GroupCollection([ groups: new GroupCollection([
{ {
name: gettext('Group A'), name: gettext('Group A'),
...@@ -71,6 +72,7 @@ function(Backbone, _, str, gettext, GroupModel, GroupCollection) { ...@@ -71,6 +72,7 @@ function(Backbone, _, str, gettext, GroupModel, GroupCollection) {
return { return {
id: this.get('id'), id: this.get('id'),
name: this.get('name'), name: this.get('name'),
scheme: this.get('scheme'),
description: this.get('description'), description: this.get('description'),
version: this.get('version'), version: this.get('version'),
groups: this.get('groups').toJSON() groups: this.get('groups').toJSON()
......
...@@ -99,7 +99,8 @@ define([ ...@@ -99,7 +99,8 @@ define([
'id': 10, 'id': 10,
'name': 'My Group Configuration', 'name': 'My Group Configuration',
'description': 'Some description', 'description': 'Some description',
'version': 1, 'version': 2,
'scheme': 'random',
'groups': [ 'groups': [
{ {
'version': 1, 'version': 1,
...@@ -114,9 +115,10 @@ define([ ...@@ -114,9 +115,10 @@ define([
'id': 10, 'id': 10,
'name': 'My Group Configuration', 'name': 'My Group Configuration',
'description': 'Some description', 'description': 'Some description',
'scheme': 'random',
'showGroups': false, 'showGroups': false,
'editing': false, 'editing': false,
'version': 1, 'version': 2,
'groups': [ 'groups': [
{ {
'version': 1, 'version': 1,
......
...@@ -55,7 +55,7 @@ ...@@ -55,7 +55,7 @@
</div> </div>
% else: % else:
<div class="ui-loading"> <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> </div>
% endif % endif
</article> </article>
......
...@@ -7,6 +7,7 @@ ...@@ -7,6 +7,7 @@
<%! <%!
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from contentstore import utils from contentstore import utils
from contentstore.views.course import should_show_group_configurations_page
import urllib import urllib
%> %>
...@@ -312,10 +313,10 @@ CMS.URL.UPLOAD_ASSET = '${upload_asset_url}'; ...@@ -312,10 +313,10 @@ CMS.URL.UPLOAD_ASSET = '${upload_asset_url}';
<ul> <ul>
<li class="nav-item"><a href="${grading_config_url}">${_("Grading")}</a></li> <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> <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> <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> </ul>
</nav> </nav>
% endif % endif
......
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
<%! <%!
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from contentstore import utils from contentstore import utils
from contentstore.views.course import should_show_group_configurations_page
from django.utils.html import escapejs from django.utils.html import escapejs
%> %>
<%block name="title">${_("Advanced Settings")}</%block> <%block name="title">${_("Advanced Settings")}</%block>
...@@ -91,9 +92,9 @@ ...@@ -91,9 +92,9 @@
<li class="nav-item"><a href="${details_url}">${_("Details &amp; Schedule")}</a></li> <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="${grading_url}">${_("Grading")}</a></li>
<li class="nav-item"><a href="${course_team_url}">${_("Course Team")}</a></li> <li class="nav-item"><a href="${course_team_url}">${_("Course Team")}</a></li>
% if "split_test" in context_course.advanced_modules: % 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> <li class="nav-item"><a href="${utils.reverse_course_url('group_configurations_list_handler', context_course.id)}">${_("Group Configurations")}</a></li>
% endif % endif
</ul> </ul>
</nav> </nav>
% endif % endif
......
...@@ -6,6 +6,7 @@ ...@@ -6,6 +6,7 @@
<%namespace name='static' file='static_content.html'/> <%namespace name='static' file='static_content.html'/>
<%! <%!
from contentstore import utils from contentstore import utils
from contentstore.views.course import should_show_group_configurations_page
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
%> %>
...@@ -134,10 +135,10 @@ ...@@ -134,10 +135,10 @@
<ul> <ul>
<li class="nav-item"><a href="${detailed_settings_url}">${_("Details &amp; Schedule")}</a></li> <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> <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> <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> </ul>
</nav> </nav>
% endif % endif
......
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from contentstore.context_processors import doc_url from contentstore.context_processors import doc_url
from contentstore.views.course import should_show_group_configurations_page
%> %>
<%page args="online_help_token"/> <%page args="online_help_token"/>
...@@ -81,14 +82,14 @@ ...@@ -81,14 +82,14 @@
<li class="nav-item nav-course-settings-team"> <li class="nav-item nav-course-settings-team">
<a href="${course_team_url}">${_("Course Team")}</a> <a href="${course_team_url}">${_("Course Team")}</a>
</li> </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"> <li class="nav-item nav-course-settings-advanced">
<a href="${advanced_settings_url}">${_("Advanced Settings")}</a> <a href="${advanced_settings_url}">${_("Advanced Settings")}</a>
</li> </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> </ul>
</div> </div>
</div> </div>
......
...@@ -39,7 +39,7 @@ urlpatterns = patterns('', # nopep8 ...@@ -39,7 +39,7 @@ urlpatterns = patterns('', # nopep8
url(r'^xmodule/', include('pipeline_js.urls')), url(r'^xmodule/', include('pipeline_js.urls')),
url(r'^heartbeat$', include('heartbeat.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')), url(r'^lang_pref/', include('lang_pref.urls')),
) )
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
Middleware for Language Preferences 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 from lang_pref import LANGUAGE_KEY
......
...@@ -3,7 +3,7 @@ from django.test.client import RequestFactory ...@@ -3,7 +3,7 @@ from django.test.client import RequestFactory
from django.contrib.sessions.middleware import SessionMiddleware from django.contrib.sessions.middleware import SessionMiddleware
from lang_pref.middleware import LanguagePreferenceMiddleware 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 lang_pref import LANGUAGE_KEY
from student.tests.factories import UserFactory from student.tests.factories import UserFactory
......
...@@ -4,7 +4,7 @@ Tests for the language setting view ...@@ -4,7 +4,7 @@ Tests for the language setting view
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.test import TestCase from django.test import TestCase
from student.tests.factories import UserFactory 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 from lang_pref import LANGUAGE_KEY
......
...@@ -4,7 +4,7 @@ Views for accessing language preferences ...@@ -4,7 +4,7 @@ Views for accessing language preferences
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.http import HttpResponse, HttpResponseBadRequest 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 from lang_pref import LANGUAGE_KEY
......
...@@ -12,7 +12,7 @@ from django.test import TestCase, TransactionTestCase ...@@ -12,7 +12,7 @@ from django.test import TestCase, TransactionTestCase
import mock import mock
from user_api.models import UserPreference from openedx.core.djangoapps.user_api.models import UserPreference
from lang_pref import LANGUAGE_KEY from lang_pref import LANGUAGE_KEY
from edxmako.tests import mako_middleware_process_request from edxmako.tests import mako_middleware_process_request
......
...@@ -106,7 +106,7 @@ class EnrollmentTest(ModuleStoreTestCase): ...@@ -106,7 +106,7 @@ class EnrollmentTest(ModuleStoreTestCase):
self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course.id)) self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course.id))
@patch.dict(settings.FEATURES, {'ENABLE_MKTG_EMAIL_OPT_IN': True}) @patch.dict(settings.FEATURES, {'ENABLE_MKTG_EMAIL_OPT_IN': True})
@patch('user_api.api.profile.update_email_opt_in') @patch('openedx.core.djangoapps.user_api.api.profile.update_email_opt_in')
@ddt.data( @ddt.data(
([], 'true'), ([], 'true'),
([], 'false'), ([], 'false'),
......
...@@ -79,7 +79,7 @@ import external_auth.views ...@@ -79,7 +79,7 @@ import external_auth.views
from bulk_email.models import Optout, CourseAuthorization from bulk_email.models import Optout, CourseAuthorization
import shoppingcart import shoppingcart
from shoppingcart.models import DonationConfiguration 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 from lang_pref import LANGUAGE_KEY
import track.views import track.views
...@@ -105,7 +105,7 @@ from student.helpers import ( ...@@ -105,7 +105,7 @@ from student.helpers import (
) )
from xmodule.error_module import ErrorDescriptor from xmodule.error_module import ErrorDescriptor
from shoppingcart.models import CourseRegistrationCode from shoppingcart.models import CourseRegistrationCode
from user_api.api import profile as profile_api from openedx.core.djangoapps.user_api.api import profile as profile_api
import analytics import analytics
from eventtracking import tracker from eventtracking import tracker
......
...@@ -57,6 +57,15 @@ class InheritanceMixin(XBlockMixin): ...@@ -57,6 +57,15 @@ class InheritanceMixin(XBlockMixin):
default=False, default=False,
scope=Scope.settings, scope=Scope.settings,
) )
group_access = Dict(
help="A dictionary that maps which groups can be shown this block. The keys "
"are group configuration ids and the values are a list of group IDs. "
"If there is no key for a group configuration or if the list of group IDs "
"is empty then the block is considered visible to all. Note that this "
"field is ignored if the block is visible_to_staff_only.",
default={},
scope=Scope.settings,
)
course_edit_method = String( course_edit_method = String(
display_name=_("Course Editor"), display_name=_("Course Editor"),
help=_("Enter the method by which this course is edited (\"XML\" or \"Studio\")."), help=_("Enter the method by which this course is edited (\"XML\" or \"Studio\")."),
...@@ -142,8 +151,8 @@ class InheritanceMixin(XBlockMixin): ...@@ -142,8 +151,8 @@ class InheritanceMixin(XBlockMixin):
# This is should be scoped to content, but since it's defined in the policy # This is should be scoped to content, but since it's defined in the policy
# file, it is currently scoped to settings. # file, it is currently scoped to settings.
user_partitions = UserPartitionList( user_partitions = UserPartitionList(
display_name=_("Experiment Group Configurations"), display_name=_("Group Configurations"),
help=_("Enter the configurations that govern how students are grouped for content experiments."), help=_("Enter the configurations that govern how students are grouped together."),
default=[], default=[],
scope=Scope.settings scope=Scope.settings
) )
......
...@@ -31,6 +31,7 @@ from xmodule.modulestore.xml_exporter import export_to_xml ...@@ -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.split_mongo.split_draft import DraftVersioningModuleStore
from xmodule.modulestore.tests.mongo_connection import MONGO_PORT_NUM, MONGO_HOST from xmodule.modulestore.tests.mongo_connection import MONGO_PORT_NUM, MONGO_HOST
from xmodule.modulestore.inheritance import InheritanceMixin from xmodule.modulestore.inheritance import InheritanceMixin
from xmodule.partitions.tests.test_partitions import PartitionTestCase
from xmodule.x_module import XModuleMixin from xmodule.x_module import XModuleMixin
from xmodule.modulestore.xml import XMLModuleStore from xmodule.modulestore.xml import XMLModuleStore
...@@ -291,7 +292,7 @@ COURSE_DATA_NAMES = ( ...@@ -291,7 +292,7 @@ COURSE_DATA_NAMES = (
@ddt.ddt @ddt.ddt
@attr('mongo') @attr('mongo')
class CrossStoreXMLRoundtrip(CourseComparisonTest): class CrossStoreXMLRoundtrip(CourseComparisonTest, PartitionTestCase):
""" """
This class exists to test XML import and export between different modulestore This class exists to test XML import and export between different modulestore
classes. classes.
......
"""Defines ``Group`` and ``UserPartition`` models for partitioning""" """Defines ``Group`` and ``UserPartition`` models for partitioning"""
from collections import namedtuple from collections import namedtuple
from stevedore.extension import ExtensionManager
# We use ``id`` in this file as the IDs of our Groups and UserPartitions, # We use ``id`` in this file as the IDs of our Groups and UserPartitions,
# which Pylint disapproves of. # which Pylint disapproves of.
# pylint: disable=invalid-name, redefined-builtin # pylint: disable=invalid-name, redefined-builtin
class UserPartitionError(Exception):
"""
An error was found regarding user partitions.
"""
pass
class Group(namedtuple("Group", "id name")): class Group(namedtuple("Group", "id name")):
""" """
An id and name for a group of students. The id should be unique An id and name for a group of students. The id should be unique
...@@ -45,7 +55,7 @@ class Group(namedtuple("Group", "id name")): ...@@ -45,7 +55,7 @@ class Group(namedtuple("Group", "id name")):
if isinstance(value, Group): if isinstance(value, Group):
return value return value
for key in ('id', 'name', 'version'): for key in ("id", "name", "version"):
if key not in value: if key not in value:
raise TypeError("Group dict {0} missing value key '{1}'".format( raise TypeError("Group dict {0} missing value key '{1}'".format(
value, key)) value, key))
...@@ -57,21 +67,50 @@ class Group(namedtuple("Group", "id name")): ...@@ -57,21 +67,50 @@ class Group(namedtuple("Group", "id name")):
return Group(value["id"], value["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 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 experiments. It is expected that each user will be in at most one group in a
partition. 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 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 # 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): def to_json(self):
""" """
...@@ -84,6 +123,7 @@ class UserPartition(namedtuple("UserPartition", "id name description groups")): ...@@ -84,6 +123,7 @@ class UserPartition(namedtuple("UserPartition", "id name description groups")):
return { return {
"id": self.id, "id": self.id,
"name": self.name, "name": self.name,
"scheme": self.scheme.name,
"description": self.description, "description": self.description,
"groups": [g.to_json() for g in self.groups], "groups": [g.to_json() for g in self.groups],
"version": UserPartition.VERSION "version": UserPartition.VERSION
...@@ -102,20 +142,38 @@ class UserPartition(namedtuple("UserPartition", "id name description groups")): ...@@ -102,20 +142,38 @@ class UserPartition(namedtuple("UserPartition", "id name description groups")):
if isinstance(value, UserPartition): if isinstance(value, UserPartition):
return value return value
for key in ('id', 'name', 'description', 'version', 'groups'): for key in ("id", "name", "description", "version", "groups"):
if key not in value: if key not in value:
raise TypeError("UserPartition dict {0} missing value key '{1}'" raise TypeError("UserPartition dict {0} missing value key '{1}'".format(value, key))
.format(value, key))
if value["version"] == 1:
if value["version"] != UserPartition.VERSION: # If no scheme was provided, set it to the default ('random')
raise TypeError("UserPartition dict {0} has unexpected version" scheme_id = UserPartition.VERSION_1_SCHEME
.format(value)) 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"]] 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( return UserPartition(
value["id"], value["id"],
value["name"], value["name"],
value["description"], 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 ...@@ -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 user partitions. It uses the user_service key/value store provided by the LMS runtime to
persist the assignments. persist the assignments.
""" """
import random
from abc import ABCMeta, abstractproperty from abc import ABCMeta, abstractproperty
...@@ -22,13 +21,11 @@ class PartitionService(object): ...@@ -22,13 +21,11 @@ class PartitionService(object):
""" """
raise NotImplementedError('Subclasses must implement course_partition') raise NotImplementedError('Subclasses must implement course_partition')
def __init__(self, user_tags_service, course_id, track_function): def __init__(self, runtime, track_function):
self.random = random.Random() self.runtime = runtime
self._user_tags_service = user_tags_service
self._course_id = course_id
self._track_function = track_function 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 If the user is already assigned to a group in user_partition_id, return the
group_id. group_id.
...@@ -53,17 +50,15 @@ class PartitionService(object): ...@@ -53,17 +50,15 @@ class PartitionService(object):
if user_partition is None: if user_partition is None:
raise ValueError( raise ValueError(
"Configuration problem! No user_partition with id {0} " "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) group = self._get_group(user_partition)
return group.id if group else None
return group_id
def _get_user_partition(self, user_partition_id): def _get_user_partition(self, user_partition_id):
""" """
Look for a user partition with a matching id in Look for a user partition with a matching id in the course's partitions.
in the course's partitions.
Returns: Returns:
A UserPartition, or None if not found. A UserPartition, or None if not found.
...@@ -74,65 +69,13 @@ class PartitionService(object): ...@@ -74,65 +69,13 @@ class PartitionService(object):
return None 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): def _get_group(self, user_partition):
""" """
Return the group of the current user in user_partition. If they don't already have Returns the group from the specified user partition to which the user is assigned.
one assigned, pick one and save it. Uses the runtime's user_service service to look up If the user has not yet been assigned, a group will be chosen for them based upon
and persist the info. the partition's scheme.
""" """
key = self._key_for_partition(user_partition) user = self.runtime.get_real_user(self.runtime.anonymous_student_id)
scope = self._user_tags_service.COURSE_SCOPE return user_partition.scheme.get_group_for_user(
self.runtime.course_id, user, user_partition, track_function=self._track_function
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
...@@ -80,10 +80,6 @@ class SplitTestFields(object): ...@@ -80,10 +80,6 @@ class SplitTestFields(object):
# location needs to actually match one of the children of this # location needs to actually match one of the children of this
# Block. (expected invariant that we'll need to test, and handle # Block. (expected invariant that we'll need to test, and handle
# authoring tools that mess this up) # authoring tools that mess this up)
# TODO: is there a way to add some validation around this, to
# be run on course load or in studio or ....
group_id_to_child = ReferenceValueDict( group_id_to_child = ReferenceValueDict(
help=_("Which child module students in a particular group_id should see"), help=_("Which child module students in a particular group_id should see"),
scope=Scope.content scope=Scope.content
...@@ -188,7 +184,7 @@ class SplitTestModule(SplitTestFields, XModule, StudioEditableModule): ...@@ -188,7 +184,7 @@ class SplitTestModule(SplitTestFields, XModule, StudioEditableModule):
partitions_service = self.runtime.service(self, 'partitions') partitions_service = self.runtime.service(self, 'partitions')
if not partitions_service: if not partitions_service:
return None 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 @property
def is_configured(self): def is_configured(self):
......
...@@ -79,13 +79,15 @@ def get_test_system(course_id=SlashSeparatedCourseKey('org', 'course', 'run')): ...@@ -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). where `my_render_func` is a function of the form my_render_func(template, context).
""" """
user = Mock(is_staff=False)
return TestModuleSystem( return TestModuleSystem(
static_url='/static', static_url='/static',
track_function=Mock(), track_function=Mock(),
get_module=Mock(), get_module=Mock(),
render_template=mock_render_template, render_template=mock_render_template,
replace_urls=str, replace_urls=str,
user=Mock(is_staff=False), user=user,
get_real_user=lambda(__): user,
filestore=Mock(), filestore=Mock(),
debug=True, debug=True,
hostname="edx.org", hostname="edx.org",
......
...@@ -6,6 +6,7 @@ import lxml ...@@ -6,6 +6,7 @@ import lxml
from mock import Mock, patch from mock import Mock, patch
from fs.memoryfs import MemoryFS 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 factories as xml
from xmodule.tests.xml import XModuleXmlImportTest from xmodule.tests.xml import XModuleXmlImportTest
from xmodule.tests import get_test_system from xmodule.tests import get_test_system
...@@ -13,7 +14,6 @@ from xmodule.x_module import AUTHOR_VIEW, STUDENT_VIEW ...@@ -13,7 +14,6 @@ from xmodule.x_module import AUTHOR_VIEW, STUDENT_VIEW
from xmodule.validation import StudioValidationMessage from xmodule.validation import StudioValidationMessage
from xmodule.split_test_module import SplitTestDescriptor, SplitTestFields from xmodule.split_test_module import SplitTestDescriptor, SplitTestFields
from xmodule.partitions.partitions import Group, UserPartition from xmodule.partitions.partitions import Group, UserPartition
from xmodule.partitions.test_partitions import StaticPartitionService, MemoryUserTagsService
class SplitTestModuleFactory(xml.XmlImportFactory): class SplitTestModuleFactory(xml.XmlImportFactory):
...@@ -23,11 +23,12 @@ class SplitTestModuleFactory(xml.XmlImportFactory): ...@@ -23,11 +23,12 @@ class SplitTestModuleFactory(xml.XmlImportFactory):
tag = 'split_test' tag = 'split_test'
class SplitTestModuleTest(XModuleXmlImportTest): class SplitTestModuleTest(XModuleXmlImportTest, PartitionTestCase):
""" """
Base class for all split_module tests. Base class for all split_module tests.
""" """
def setUp(self): def setUp(self):
super(SplitTestModuleTest, self).setUp()
self.course_id = 'test_org/test_course_number/test_run' self.course_id = 'test_org/test_course_number/test_run'
# construct module # construct module
course = xml.CourseFactory.build() course = xml.CourseFactory.build()
...@@ -57,16 +58,16 @@ class SplitTestModuleTest(XModuleXmlImportTest): ...@@ -57,16 +58,16 @@ class SplitTestModuleTest(XModuleXmlImportTest):
self.module_system.descriptor_system = self.course.runtime self.module_system.descriptor_system = self.course.runtime
self.course.runtime.export_fs = MemoryFS() 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( self.partitions_service = StaticPartitionService(
[ [
UserPartition(0, 'first_partition', 'First Partition', [Group("0", 'alpha'), Group("1", 'beta')]), self.user_partition,
UserPartition(1, 'second_partition', 'Second Partition', [Group("0", 'abel'), Group("1", 'baker'), Group("2", 'charlie')]) UserPartition(
1, 'second_partition', 'Second Partition',
[Group("0", 'abel'), Group("1", 'baker'), Group("2", 'charlie')],
MockUserPartitionScheme()
)
], ],
user_tags_service=self.tags_service, runtime=self.module_system,
course_id=self.course.id,
track_function=Mock(name='track_function'), track_function=Mock(name='track_function'),
) )
self.module_system._services['partitions'] = self.partitions_service # pylint: disable=protected-access self.module_system._services['partitions'] = self.partitions_service # pylint: disable=protected-access
...@@ -81,50 +82,28 @@ class SplitTestModuleLMSTest(SplitTestModuleTest): ...@@ -81,50 +82,28 @@ class SplitTestModuleLMSTest(SplitTestModuleTest):
Test the split test module 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 @ddt.unpack
def test_child(self, user_tag, child_url_name): def test_child(self, user_tag, child_url_name):
self.tags_service.set_tag( self.user_partition.scheme.current_group = self.user_partition.groups[user_tag] # pylint: disable=no-member
self.tags_service.COURSE_SCOPE,
'xblock.partition_service.partition_0',
user_tag
)
self.assertEquals(self.split_test_module.child_descriptor.url_name, child_url_name) self.assertEquals(self.split_test_module.child_descriptor.url_name, child_url_name)
@ddt.data(('0',), ('1',)) @ddt.data((0, 'HTML FOR GROUP 0'), (1, 'HTML FOR GROUP 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.unpack @ddt.unpack
def test_get_html(self, user_tag, child_content): def test_get_html(self, user_tag, child_content):
self.tags_service.set_tag( self.user_partition.scheme.current_group = self.user_partition.groups[user_tag] # pylint: disable=no-member
self.tags_service.COURSE_SCOPE,
'xblock.partition_service.partition_0',
user_tag
)
self.assertIn( self.assertIn(
child_content, child_content,
self.module_system.render(self.split_test_module, STUDENT_VIEW).content self.module_system.render(self.split_test_module, STUDENT_VIEW).content
) )
@ddt.data(('0',), ('1',)) @ddt.data((0,), (1,))
@ddt.unpack @ddt.unpack
def test_child_missing_tag_value(self, _user_tag): 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 # 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']) 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 @ddt.unpack
def test_child_persist_new_tag_value_when_tag_missing(self, _user_tag): 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. # If a user_tag has a missing value, a group should be saved/persisted for that user.
......
[run] [run]
data_file = reports/bok_choy/.coverage data_file = reports/bok_choy/.coverage
source = lms, cms, common/djangoapps, common/lib 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 parallel = True
[report] [report]
......
This source diff could not be displayed because it is too large. You can view the blob instead.
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
[run] [run]
data_file = reports/lms/.coverage data_file = reports/lms/.coverage
source = lms,common/djangoapps 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] [report]
ignore_errors = True ignore_errors = True
......
...@@ -12,7 +12,7 @@ from student.tests.factories import UserFactory, CourseEnrollmentFactory ...@@ -12,7 +12,7 @@ from student.tests.factories import UserFactory, CourseEnrollmentFactory
from xmodule.modulestore.tests.factories import ItemFactory, CourseFactory from xmodule.modulestore.tests.factories import ItemFactory, CourseFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.partitions.partitions import Group, UserPartition 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) @override_settings(MODULESTORE=TEST_DATA_MOCK_MODULESTORE)
......
...@@ -27,7 +27,7 @@ from student.models import anonymous_id_for_user ...@@ -27,7 +27,7 @@ from student.models import anonymous_id_for_user
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from xmodule.partitions.partitions import Group, UserPartition 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) @override_settings(MODULESTORE=TEST_DATA_MOCK_MODULESTORE)
......
...@@ -6,11 +6,7 @@ from django.http import Http404 ...@@ -6,11 +6,7 @@ from django.http import Http404
from django.test.client import Client, RequestFactory from django.test.client import Client, RequestFactory
from django.test.utils import override_settings from django.test.utils import override_settings
from edxmako.tests import mako_middleware_process_request from edxmako.tests import mako_middleware_process_request
from mock import patch, Mock, ANY, call
from nose.tools import assert_true # pylint: disable=no-name-in-module
from course_groups.models import CourseUserGroup
from courseware.courses import UserNotEnrolled
from django_comment_client.forum import views from django_comment_client.forum import views
from django_comment_client.tests.group_id import ( from django_comment_client.tests.group_id import (
CohortedTopicGroupIdTestMixin, CohortedTopicGroupIdTestMixin,
...@@ -21,10 +17,15 @@ from django_comment_client.tests.utils import CohortedContentTestCase ...@@ -21,10 +17,15 @@ from django_comment_client.tests.utils import CohortedContentTestCase
from django_comment_client.utils import strip_none from django_comment_client.utils import strip_none
from student.tests.factories import UserFactory, CourseEnrollmentFactory from student.tests.factories import UserFactory, CourseEnrollmentFactory
from util.testing import UrlResetMixin from util.testing import UrlResetMixin
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, TEST_DATA_MOCK_MODULESTORE
from xmodule.modulestore.tests.django_utils import TEST_DATA_MOCK_MODULESTORE
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from courseware.courses import UserNotEnrolled
from nose.tools import assert_true # pylint: disable=E0611
from mock import patch, Mock, ANY, call
from openedx.core.djangoapps.course_groups.models import CourseUserGroup
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# pylint: disable=missing-docstring # pylint: disable=missing-docstring
......
...@@ -11,7 +11,12 @@ import newrelic.agent ...@@ -11,7 +11,12 @@ import newrelic.agent
from edxmako.shortcuts import render_to_response from edxmako.shortcuts import render_to_response
from courseware.courses import get_course_with_access from courseware.courses import get_course_with_access
from course_groups.cohorts import is_course_cohorted, get_cohort_id, get_course_cohorts, is_commentable_cohorted from openedx.core.djangoapps.course_groups.cohorts import (
is_course_cohorted,
get_cohort_id,
get_course_cohorts,
is_commentable_cohorted
)
from courseware.access import has_access from courseware.access import has_access
from django_comment_client.permissions import cached_has_permission from django_comment_client.permissions import cached_has_permission
......
import json import json
import re import re
from course_groups.models import CourseUserGroup
class GroupIdAssertionMixin(object): class GroupIdAssertionMixin(object):
def _data_or_params_cs_request(self, mock_request): def _data_or_params_cs_request(self, mock_request):
......
from django.test.utils import override_settings from django.test.utils import override_settings
from mock import patch from mock import patch
from course_groups.models import CourseUserGroup from openedx.core.djangoapps.course_groups.models import CourseUserGroup
from xmodule.modulestore.tests.django_utils import TEST_DATA_MOCK_MODULESTORE from xmodule.modulestore.tests.django_utils import TEST_DATA_MOCK_MODULESTORE
from django_comment_common.models import Role from django_comment_common.models import Role
from django_comment_common.utils import seed_permissions_roles from django_comment_common.utils import seed_permissions_roles
......
...@@ -17,8 +17,8 @@ from django_comment_client.permissions import check_permissions_by_view, cached_ ...@@ -17,8 +17,8 @@ from django_comment_client.permissions import check_permissions_by_view, cached_
from edxmako import lookup_template from edxmako import lookup_template
import pystache_custom as pystache import pystache_custom as pystache
from course_groups.cohorts import get_cohort_by_id, get_cohort_id, is_commentable_cohorted, is_course_cohorted from openedx.core.djangoapps.course_groups.cohorts import get_cohort_by_id, get_cohort_id, is_commentable_cohorted, is_course_cohorted
from course_groups.models import CourseUserGroup from openedx.core.djangoapps.course_groups.models import CourseUserGroup
from opaque_keys.edx.locations import i4xEncoder from opaque_keys.edx.locations import i4xEncoder
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
......
...@@ -62,7 +62,7 @@ import instructor_analytics.basic ...@@ -62,7 +62,7 @@ import instructor_analytics.basic
import instructor_analytics.distributions import instructor_analytics.distributions
import instructor_analytics.csvs import instructor_analytics.csvs
import csv import csv
from user_api.models import UserPreference from openedx.core.djangoapps.user_api.models import UserPreference
from instructor.views import INVOICE_KEY from instructor.views import INVOICE_KEY
from submissions import api as sub_api # installed from the edx-submissions repository from submissions import api as sub_api # installed from the edx-submissions repository
......
...@@ -2,7 +2,6 @@ ...@@ -2,7 +2,6 @@
Tests for instructor.basic Tests for instructor.basic
""" """
from django.test import TestCase
from student.models import CourseEnrollment from student.models import CourseEnrollment
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from mock import patch from mock import patch
...@@ -17,12 +16,10 @@ from instructor_analytics.basic import ( ...@@ -17,12 +16,10 @@ from instructor_analytics.basic import (
sale_record_features, sale_order_record_features, enrolled_students_features, course_registration_features, sale_record_features, sale_order_record_features, enrolled_students_features, course_registration_features,
coupon_codes_features, AVAILABLE_FEATURES, STUDENT_FEATURES, PROFILE_FEATURES coupon_codes_features, AVAILABLE_FEATURES, STUDENT_FEATURES, PROFILE_FEATURES
) )
from course_groups.tests.helpers import CohortFactory from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory
from course_groups.models import CourseUserGroup
from courseware.tests.factories import InstructorFactory from courseware.tests.factories import InstructorFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
class TestAnalyticsBasic(ModuleStoreTestCase): class TestAnalyticsBasic(ModuleStoreTestCase):
......
...@@ -17,7 +17,7 @@ from django.core.urlresolvers import reverse ...@@ -17,7 +17,7 @@ from django.core.urlresolvers import reverse
from capa.tests.response_xml_factory import (CodeResponseXMLFactory, from capa.tests.response_xml_factory import (CodeResponseXMLFactory,
CustomResponseXMLFactory) 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.tests.factories import ItemFactory
from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore import ModuleStoreEnum
from xmodule.partitions.partitions import Group, UserPartition from xmodule.partitions.partitions import Group, UserPartition
......
from django.contrib.auth.models import User from django.contrib.auth.models import User
from lettuce import step, world from lettuce import step, world
from notification_prefs import NOTIFICATION_PREF_KEY from notification_prefs import NOTIFICATION_PREF_KEY
from user_api.models import UserPreference from openedx.core.djangoapps.user_api.models import UserPreference
USERNAME = "robot" USERNAME = "robot"
......
...@@ -12,7 +12,7 @@ from notification_prefs import NOTIFICATION_PREF_KEY ...@@ -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 notification_prefs.views import ajax_enable, ajax_disable, ajax_status, set_subscription, UsernameCipher
from student.tests.factories import UserFactory from student.tests.factories import UserFactory
from edxmako.tests import mako_middleware_process_request 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 from util.testing import UrlResetMixin
......
...@@ -12,7 +12,7 @@ from django.views.decorators.http import require_GET, require_POST ...@@ -12,7 +12,7 @@ from django.views.decorators.http import require_GET, require_POST
from edxmako.shortcuts import render_to_response from edxmako.shortcuts import render_to_response
from notification_prefs import NOTIFICATION_PREF_KEY 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): class UsernameDecryptionException(Exception):
......
...@@ -2,7 +2,7 @@ from django.contrib.auth.models import User ...@@ -2,7 +2,7 @@ from django.contrib.auth.models import User
from django.http import Http404 from django.http import Http404
from rest_framework import serializers from rest_framework import serializers
from course_groups.cohorts import is_course_cohorted from openedx.core.djangoapps.course_groups.cohorts import is_course_cohorted
from notification_prefs import NOTIFICATION_PREF_KEY from notification_prefs import NOTIFICATION_PREF_KEY
from lang_pref import LANGUAGE_KEY from lang_pref import LANGUAGE_KEY
......
...@@ -5,7 +5,7 @@ from django.conf import settings ...@@ -5,7 +5,7 @@ from django.conf import settings
from django.test.client import RequestFactory from django.test.client import RequestFactory
from django.test.utils import override_settings from django.test.utils import override_settings
from course_groups.models import CourseUserGroup from openedx.core.djangoapps.course_groups.models import CourseUserGroup
from django_comment_common.models import Role, Permission from django_comment_common.models import Role, Permission
from lang_pref import LANGUAGE_KEY from lang_pref import LANGUAGE_KEY
from notification_prefs import NOTIFICATION_PREF_KEY from notification_prefs import NOTIFICATION_PREF_KEY
...@@ -13,8 +13,8 @@ from notifier_api.views import NotifierUsersViewSet ...@@ -13,8 +13,8 @@ from notifier_api.views import NotifierUsersViewSet
from opaque_keys.edx.locator import CourseLocator from opaque_keys.edx.locator import CourseLocator
from student.models import CourseEnrollment from student.models import CourseEnrollment
from student.tests.factories import UserFactory, CourseEnrollmentFactory from student.tests.factories import UserFactory, CourseEnrollmentFactory
from user_api.models import UserPreference from openedx.core.djangoapps.user_api.models import UserPreference
from user_api.tests.factories import UserPreferenceFactory from openedx.core.djangoapps.user_api.tests.factories import UserPreferenceFactory
from util.testing import UrlResetMixin from util.testing import UrlResetMixin
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
......
...@@ -3,7 +3,7 @@ from rest_framework.viewsets import ReadOnlyModelViewSet ...@@ -3,7 +3,7 @@ from rest_framework.viewsets import ReadOnlyModelViewSet
from notification_prefs import NOTIFICATION_PREF_KEY from notification_prefs import NOTIFICATION_PREF_KEY
from notifier_api.serializers import NotifierUserSerializer from notifier_api.serializers import NotifierUserSerializer
from user_api.views import ApiKeyHeaderPermission from openedx.core.djangoapps.user_api.views import ApiKeyHeaderPermission
class NotifierUsersViewSet(ReadOnlyModelViewSet): class NotifierUsersViewSet(ReadOnlyModelViewSet):
......
...@@ -6,7 +6,7 @@ from django.core.cache import cache ...@@ -6,7 +6,7 @@ from django.core.cache import cache
from courseware.access import has_access from courseware.access import has_access
from student.models import anonymous_id_for_user from student.models import anonymous_id_for_user
from student.models import UserProfile 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 lang_pref import LANGUAGE_KEY
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.course_module import CourseDescriptor from xmodule.course_module import CourseDescriptor
......
...@@ -9,7 +9,7 @@ from student.models import anonymous_id_for_user ...@@ -9,7 +9,7 @@ from student.models import anonymous_id_for_user
from student.models import UserProfile from student.models import UserProfile
from student.roles import CourseStaffRole, CourseInstructorRole from student.roles import CourseStaffRole, CourseInstructorRole
from student.tests.factories import UserFactory, UserProfileFactory 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 from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
# Will also run default tests for IDTokens and UserInfo # Will also run default tests for IDTokens and UserInfo
......
...@@ -16,9 +16,8 @@ from django.test.utils import override_settings ...@@ -16,9 +16,8 @@ from django.test.utils import override_settings
from util.testing import UrlResetMixin from util.testing import UrlResetMixin
from third_party_auth.tests.testutil import simulate_running_pipeline from third_party_auth.tests.testutil import simulate_running_pipeline
from user_api.api import account as account_api from openedx.core.djangoapps.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 profile as profile_api
from util.bad_request_rate_limiter import BadRequestRateLimiter
from xmodule.modulestore.tests.django_utils import ( from xmodule.modulestore.tests.django_utils import (
ModuleStoreTestCase, mixed_store_config ModuleStoreTestCase, mixed_store_config
) )
......
...@@ -16,8 +16,8 @@ from edxmako.shortcuts import render_to_response, render_to_string ...@@ -16,8 +16,8 @@ from edxmako.shortcuts import render_to_response, render_to_string
from microsite_configuration import microsite from microsite_configuration import microsite
import third_party_auth import third_party_auth
from user_api.api import account as account_api from openedx.core.djangoapps.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 profile as profile_api
from util.bad_request_rate_limiter import BadRequestRateLimiter from util.bad_request_rate_limiter import BadRequestRateLimiter
from student_account.helpers import auth_pipeline_urls from student_account.helpers import auth_pipeline_urls
......
...@@ -11,8 +11,8 @@ from django.conf import settings ...@@ -11,8 +11,8 @@ from django.conf import settings
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from util.testing import UrlResetMixin from util.testing import UrlResetMixin
from user_api.api import account as account_api from openedx.core.djangoapps.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 profile as profile_api
from lang_pref import LANGUAGE_KEY, api as language_api from lang_pref import LANGUAGE_KEY, api as language_api
......
...@@ -11,7 +11,7 @@ from django.views.decorators.http import require_http_methods ...@@ -11,7 +11,7 @@ from django.views.decorators.http import require_http_methods
from django_future.csrf import ensure_csrf_cookie from django_future.csrf import ensure_csrf_cookie
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from edxmako.shortcuts import render_to_response 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 from lang_pref import LANGUAGE_KEY, api as language_api
import third_party_auth import third_party_auth
......
...@@ -931,7 +931,7 @@ MIDDLEWARE_CLASSES = ( ...@@ -931,7 +931,7 @@ MIDDLEWARE_CLASSES = (
# Adds user tags to tracking events # Adds user tags to tracking events
# Must go before TrackMiddleware, to get the context set up # 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', 'django.contrib.messages.middleware.MessageMiddleware',
'track.middleware.TrackMiddleware', 'track.middleware.TrackMiddleware',
...@@ -1438,7 +1438,7 @@ INSTALLED_APPS = ( ...@@ -1438,7 +1438,7 @@ INSTALLED_APPS = (
'open_ended_grading', 'open_ended_grading',
'psychometrics', 'psychometrics',
'licenses', 'licenses',
'course_groups', 'openedx.core.djangoapps.course_groups',
'bulk_email', 'bulk_email',
# External auth (OpenID, shib) # External auth (OpenID, shib)
...@@ -1482,7 +1482,7 @@ INSTALLED_APPS = ( ...@@ -1482,7 +1482,7 @@ INSTALLED_APPS = (
# User API # User API
'rest_framework', 'rest_framework',
'user_api', 'openedx.core.djangoapps.user_api',
# Shopping cart # Shopping cart
'shoppingcart', 'shoppingcart',
......
""" """
Namespace that defines fields common to all blocks used in the LMS Namespace that defines fields common to all blocks used in the LMS
""" """
from xblock.fields import Boolean, Scope, String, XBlockMixin from xblock.fields import Boolean, Scope, String, XBlockMixin, Dict
from xblock.validation import ValidationMessage
from xmodule.modulestore.inheritance import UserPartitionList
# Make '_' a no-op so we can scrape strings # Make '_' a no-op so we can scrape strings
_ = lambda text: text _ = lambda text: text
...@@ -53,3 +55,73 @@ class LmsBlockMixin(XBlockMixin): ...@@ -53,3 +55,73 @@ class LmsBlockMixin(XBlockMixin):
default=False, default=False,
scope=Scope.settings, scope=Scope.settings,
) )
group_access = Dict(
help="A dictionary that maps which groups can be shown this block. The keys "
"are group configuration ids and the values are a list of group IDs. "
"If there is no key for a group configuration or if the list of group IDs "
"is empty then the block is considered visible to all. Note that this "
"field is ignored if the block is visible_to_staff_only.",
default={},
scope=Scope.settings,
)
# Specified here so we can see what the value set at the course-level is.
user_partitions = UserPartitionList(
help=_("The list of group configurations for partitioning students in content experiments."),
default=[],
scope=Scope.settings
)
def _get_user_partition(self, user_partition_id):
"""
Returns the user partition with the specified id, or None if there is no such partition.
"""
for user_partition in self.user_partitions:
if user_partition.id == user_partition_id:
return user_partition
return None
def is_visible_to_group(self, user_partition, group):
"""
Returns true if this xblock should be shown to a user in the specified user partition group.
This method returns true if one of the following is true:
- the xblock has no group_access dictionary specified
- if the dictionary has no key for the user partition's id
- if the value for the user partition's id is an empty list
- if the value for the user partition's id contains the specified group's id
"""
if not self.group_access:
return True
group_ids = self.group_access.get(user_partition.id, [])
if len(group_ids) == 0:
return True
return group.id in group_ids
def validate(self):
"""
Validates the state of this xblock instance.
"""
_ = self.runtime.service(self, "i18n").ugettext # pylint: disable=redefined-outer-name
validation = super(LmsBlockMixin, self).validate()
for user_partition_id, group_ids in self.group_access.iteritems():
user_partition = self._get_user_partition(user_partition_id)
if not user_partition:
validation.add(
ValidationMessage(
ValidationMessage.ERROR,
_(u"This xblock refers to a deleted or invalid content group configuration.")
)
)
else:
for group_id in group_ids:
group = user_partition.get_group(group_id)
if not group:
validation.add(
ValidationMessage(
ValidationMessage.ERROR,
_(u"This xblock refers to a deleted or invalid content group.")
)
)
return validation
...@@ -7,7 +7,7 @@ import xblock.reference.plugins ...@@ -7,7 +7,7 @@ import xblock.reference.plugins
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.conf import settings 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.modulestore.django import modulestore
from xmodule.x_module import ModuleSystem from xmodule.x_module import ModuleSystem
from xmodule.partitions.partitions_service import PartitionService from xmodule.partitions.partitions_service import PartitionService
...@@ -128,13 +128,13 @@ class LmsPartitionService(PartitionService): ...@@ -128,13 +128,13 @@ class LmsPartitionService(PartitionService):
course. course.
(If and when XBlock directly provides access from one block (e.g. a split_test_module) (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) the least messy way to hook things through)
""" """
@property @property
def course_partitions(self): def course_partitions(self):
course = modulestore().get_course(self._course_id) course = modulestore().get_course(self.runtime.course_id)
return course.user_partitions return course.user_partitions
...@@ -194,8 +194,7 @@ class LmsModuleSystem(LmsHandlerUrls, ModuleSystem): # pylint: disable=abstract ...@@ -194,8 +194,7 @@ class LmsModuleSystem(LmsHandlerUrls, ModuleSystem): # pylint: disable=abstract
services = kwargs.setdefault('services', {}) services = kwargs.setdefault('services', {})
services['user_tags'] = UserTagsService(self) services['user_tags'] = UserTagsService(self)
services['partitions'] = LmsPartitionService( services['partitions'] = LmsPartitionService(
user_tags_service=services['user_tags'], runtime=self,
course_id=kwargs.get('course_id', None),
track_function=kwargs.get('track_function', None), track_function=kwargs.get('track_function', None),
) )
services['fs'] = xblock.reference.plugins.FSService() services['fs'] = xblock.reference.plugins.FSService()
......
"""
Tests of the LMS XBlock Mixin
"""
from xblock.validation import ValidationMessage
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.partitions.partitions import Group, UserPartition
class LmsXBlockMixinTestCase(ModuleStoreTestCase):
"""
Base class for XBlock mixin tests cases. A simple course with a single user partition is created
in setUp for all subclasses to use.
"""
def setUp(self):
super(LmsXBlockMixinTestCase, self).setUp()
self.user_partition = UserPartition(
0,
'first_partition',
'First Partition',
[
Group(0, 'alpha'),
Group(1, 'beta')
]
)
self.group1 = self.user_partition.groups[0] # pylint: disable=no-member
self.group2 = self.user_partition.groups[1] # pylint: disable=no-member
self.course = CourseFactory.create(user_partitions=[self.user_partition])
self.section = ItemFactory.create(parent=self.course, category='chapter', display_name='Test Section')
self.subsection = ItemFactory.create(parent=self.section, category='sequential', display_name='Test Subsection')
self.vertical = ItemFactory.create(parent=self.subsection, category='vertical', display_name='Test Unit')
self.video = ItemFactory.create(parent=self.subsection, category='video', display_name='Test Video')
class XBlockValidationTest(LmsXBlockMixinTestCase):
"""
Unit tests for XBlock validation
"""
def verify_validation_message(self, message, expected_message, expected_message_type):
"""
Verify that the validation message has the expected validation message and type.
"""
self.assertEqual(message.text, expected_message)
self.assertEqual(message.type, expected_message_type)
def test_validate_full_group_access(self):
"""
Test the validation messages produced for an xblock with full group access.
"""
validation = self.video.validate()
self.assertEqual(len(validation.messages), 0)
def test_validate_restricted_group_access(self):
"""
Test the validation messages produced for an xblock with a valid group access restriction
"""
self.video.group_access[self.user_partition.id] = [self.group1.id, self.group2.id] # pylint: disable=no-member
validation = self.video.validate()
self.assertEqual(len(validation.messages), 0)
def test_validate_invalid_user_partition(self):
"""
Test the validation messages produced for an xblock referring to a non-existent user partition.
"""
self.video.group_access[999] = [self.group1.id]
validation = self.video.validate()
self.assertEqual(len(validation.messages), 1)
self.verify_validation_message(
validation.messages[0],
u"This xblock refers to a deleted or invalid content group configuration.",
ValidationMessage.ERROR,
)
def test_validate_invalid_group(self):
"""
Test the validation messages produced for an xblock referring to a non-existent group.
"""
self.video.group_access[self.user_partition.id] = [self.group1.id, 999] # pylint: disable=no-member
validation = self.video.validate()
self.assertEqual(len(validation.messages), 1)
self.verify_validation_message(
validation.messages[0],
u"This xblock refers to a deleted or invalid content group.",
ValidationMessage.ERROR,
)
class XBlockGroupAccessTest(LmsXBlockMixinTestCase):
"""
Unit tests for XBlock group access.
"""
def test_is_visible_to_group(self):
"""
Test the behavior of is_visible_to_group.
"""
# All groups are visible for an unrestricted xblock
self.assertTrue(self.video.is_visible_to_group(self.user_partition, self.group1))
self.assertTrue(self.video.is_visible_to_group(self.user_partition, self.group2))
# Verify that all groups are visible if the set of group ids is empty
self.video.group_access[self.user_partition.id] = [] # pylint: disable=no-member
self.assertTrue(self.video.is_visible_to_group(self.user_partition, self.group1))
self.assertTrue(self.video.is_visible_to_group(self.user_partition, self.group2))
# Verify that only specified groups are visible
self.video.group_access[self.user_partition.id] = [self.group1.id] # pylint: disable=no-member
self.assertTrue(self.video.is_visible_to_group(self.user_partition, self.group1))
self.assertFalse(self.video.is_visible_to_group(self.user_partition, self.group2))
# Verify that having an invalid user partition does not affect group visibility of other partitions
self.video.group_access[999] = [self.group1.id]
self.assertTrue(self.video.is_visible_to_group(self.user_partition, self.group1))
self.assertFalse(self.video.is_visible_to_group(self.user_partition, self.group2))
# Verify that group access is still correct even with invalid group ids
self.video.group_access.clear()
self.video.group_access[self.user_partition.id] = [self.group2.id, 999] # pylint: disable=no-member
self.assertFalse(self.video.is_visible_to_group(self.user_partition, self.group1))
self.assertTrue(self.video.is_visible_to_group(self.user_partition, self.group2))
...@@ -60,7 +60,7 @@ urlpatterns = ('', # nopep8 ...@@ -60,7 +60,7 @@ urlpatterns = ('', # nopep8
url(r'^heartbeat$', include('heartbeat.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'^notifier_api/', include('notifier_api.urls')), url(r'^notifier_api/', include('notifier_api.urls')),
...@@ -343,21 +343,21 @@ if settings.COURSEWARE_ENABLED: ...@@ -343,21 +343,21 @@ if settings.COURSEWARE_ENABLED:
# Cohorts management # Cohorts management
url(r'^courses/{}/cohorts$'.format(settings.COURSE_KEY_PATTERN), url(r'^courses/{}/cohorts$'.format(settings.COURSE_KEY_PATTERN),
'course_groups.views.list_cohorts', name="cohorts"), 'openedx.core.djangoapps.course_groups.views.list_cohorts', name="cohorts"),
url(r'^courses/{}/cohorts/add$'.format(settings.COURSE_KEY_PATTERN), url(r'^courses/{}/cohorts/add$'.format(settings.COURSE_KEY_PATTERN),
'course_groups.views.add_cohort', 'openedx.core.djangoapps.course_groups.views.add_cohort',
name="add_cohort"), name="add_cohort"),
url(r'^courses/{}/cohorts/(?P<cohort_id>[0-9]+)$'.format(settings.COURSE_KEY_PATTERN), url(r'^courses/{}/cohorts/(?P<cohort_id>[0-9]+)$'.format(settings.COURSE_KEY_PATTERN),
'course_groups.views.users_in_cohort', 'openedx.core.djangoapps.course_groups.views.users_in_cohort',
name="list_cohort"), name="list_cohort"),
url(r'^courses/{}/cohorts/(?P<cohort_id>[0-9]+)/add$'.format(settings.COURSE_KEY_PATTERN), url(r'^courses/{}/cohorts/(?P<cohort_id>[0-9]+)/add$'.format(settings.COURSE_KEY_PATTERN),
'course_groups.views.add_users_to_cohort', 'openedx.core.djangoapps.course_groups.views.add_users_to_cohort',
name="add_to_cohort"), name="add_to_cohort"),
url(r'^courses/{}/cohorts/(?P<cohort_id>[0-9]+)/delete$'.format(settings.COURSE_KEY_PATTERN), url(r'^courses/{}/cohorts/(?P<cohort_id>[0-9]+)/delete$'.format(settings.COURSE_KEY_PATTERN),
'course_groups.views.remove_user_from_cohort', 'openedx.core.djangoapps.course_groups.views.remove_user_from_cohort',
name="remove_from_cohort"), name="remove_from_cohort"),
url(r'^courses/{}/cohorts/debug$'.format(settings.COURSE_KEY_PATTERN), url(r'^courses/{}/cohorts/debug$'.format(settings.COURSE_KEY_PATTERN),
'course_groups.views.debug_cohort_mgmt', 'openedx.core.djangoapps.course_groups.views.debug_cohort_mgmt',
name="debug_cohort_mgmt"), name="debug_cohort_mgmt"),
# Open Ended Notifications # Open Ended Notifications
......
"""
This is the root package for Open edX. The intent is that all importable code
from Open edX will eventually live here, including the code in the lms, cms,
and common directories.
Note: for now the code is not structured like this, and hence legacy code will
continue to live in a number of different packages. All new code should be
created in this package, and the legacy code will be moved here gradually.
"""
"""
This is the root package for all core Open edX functionality. In particular,
the djangoapps subpackage is the location for all Django apps that are shared
between LMS and CMS.
Note: the majority of the core functionality currently lives in the root
common directory. All new Django apps should be created here instead, and
the pre-existing apps will be moved here over time.
"""
...@@ -14,7 +14,7 @@ from django.utils.translation import ugettext as _ ...@@ -14,7 +14,7 @@ from django.utils.translation import ugettext as _
from courseware import courses from courseware import courses
from eventtracking import tracker from eventtracking import tracker
from student.models import get_user_by_username_or_email from student.models import get_user_by_username_or_email
from .models import CourseUserGroup from .models import CourseUserGroup, CourseUserGroupPartitionGroup
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -373,3 +373,17 @@ def add_user_to_cohort(cohort, username_or_email): ...@@ -373,3 +373,17 @@ def add_user_to_cohort(cohort, username_or_email):
) )
cohort.users.add(user) cohort.users.add(user)
return (user, previous_cohort_name) return (user, previous_cohort_name)
def get_partition_group_id_for_cohort(cohort):
"""
Get the ids of the partition and group to which this cohort has been linked
as a tuple of (int, int).
If the cohort has not been linked to any partition/group, both values in the
tuple will be None.
"""
res = CourseUserGroupPartitionGroup.objects.filter(course_user_group=cohort)
if len(res):
return res[0].partition_id, res[0].group_id
return None, None
# -*- coding: utf-8 -*-
import datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models, connection
class Migration(SchemaMigration):
def forwards(self, orm):
# Adding model 'CourseUserGroup'
def table_exists(name):
return name in connection.introspection.table_names()
def index_exists(table_name, column_name):
return column_name in connection.introspection.get_indexes(connection.cursor(), table_name)
# Since this djangoapp has been converted to south migrations after-the-fact,
# these tables/indexes should already exist when migrating an existing installation.
if not (
table_exists('course_groups_courseusergroup') and
index_exists('course_groups_courseusergroup', 'name') and
index_exists('course_groups_courseusergroup', 'course_id') and
table_exists('course_groups_courseusergroup_users') and
index_exists('course_groups_courseusergroup_users', 'courseusergroup_id') and
index_exists('course_groups_courseusergroup_users', 'user_id')
):
db.create_table('course_groups_courseusergroup', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('name', self.gf('django.db.models.fields.CharField')(max_length=255)),
('course_id', self.gf('xmodule_django.models.CourseKeyField')(max_length=255, db_index=True)),
('group_type', self.gf('django.db.models.fields.CharField')(max_length=20)),
))
db.send_create_signal('course_groups', ['CourseUserGroup'])
# Adding unique constraint on 'CourseUserGroup', fields ['name', 'course_id']
db.create_unique('course_groups_courseusergroup', ['name', 'course_id'])
# Adding M2M table for field users on 'CourseUserGroup'
db.create_table('course_groups_courseusergroup_users', (
('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)),
('courseusergroup', models.ForeignKey(orm['course_groups.courseusergroup'], null=False)),
('user', models.ForeignKey(orm['auth.user'], null=False))
))
db.create_unique('course_groups_courseusergroup_users', ['courseusergroup_id', 'user_id'])
def backwards(self, orm):
# Removing unique constraint on 'CourseUserGroup', fields ['name', 'course_id']
db.delete_unique('course_groups_courseusergroup', ['name', 'course_id'])
# Deleting model 'CourseUserGroup'
db.delete_table('course_groups_courseusergroup')
# Removing M2M table for field users on 'CourseUserGroup'
db.delete_table('course_groups_courseusergroup_users')
models = {
'auth.group': {
'Meta': {'object_name': 'Group'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
},
'auth.permission': {
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
},
'auth.user': {
'Meta': {'object_name': 'User'},
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
},
'contenttypes.contenttype': {
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
},
'course_groups.courseusergroup': {
'Meta': {'unique_together': "(('name', 'course_id'),)", 'object_name': 'CourseUserGroup'},
'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
'group_type': ('django.db.models.fields.CharField', [], {'max_length': '20'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'users': ('django.db.models.fields.related.ManyToManyField', [], {'db_index': 'True', 'related_name': "'course_groups'", 'symmetrical': 'False', 'to': "orm['auth.User']"})
}
}
complete_apps = ['course_groups']
# -*- coding: utf-8 -*-
import datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models
class Migration(SchemaMigration):
def forwards(self, orm):
# Adding model 'CourseUserGroupPartitionGroup'
db.create_table('course_groups_courseusergrouppartitiongroup', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('course_user_group', self.gf('django.db.models.fields.related.OneToOneField')(to=orm['course_groups.CourseUserGroup'], unique=True)),
('partition_id', self.gf('django.db.models.fields.IntegerField')()),
('group_id', self.gf('django.db.models.fields.IntegerField')()),
('created_at', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)),
('updated_at', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, blank=True)),
))
db.send_create_signal('course_groups', ['CourseUserGroupPartitionGroup'])
def backwards(self, orm):
# Deleting model 'CourseUserGroupPartitionGroup'
db.delete_table('course_groups_courseusergrouppartitiongroup')
models = {
'auth.group': {
'Meta': {'object_name': 'Group'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
},
'auth.permission': {
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
},
'auth.user': {
'Meta': {'object_name': 'User'},
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
},
'contenttypes.contenttype': {
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
},
'course_groups.courseusergroup': {
'Meta': {'unique_together': "(('name', 'course_id'),)", 'object_name': 'CourseUserGroup'},
'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
'group_type': ('django.db.models.fields.CharField', [], {'max_length': '20'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'users': ('django.db.models.fields.related.ManyToManyField', [], {'db_index': 'True', 'related_name': "'course_groups'", 'symmetrical': 'False', 'to': "orm['auth.User']"})
},
'course_groups.courseusergrouppartitiongroup': {
'Meta': {'object_name': 'CourseUserGroupPartitionGroup'},
'course_user_group': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['course_groups.CourseUserGroup']", 'unique': 'True'}),
'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
'group_id': ('django.db.models.fields.IntegerField', [], {}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'partition_id': ('django.db.models.fields.IntegerField', [], {}),
'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'})
}
}
complete_apps = ['course_groups']
...@@ -35,3 +35,17 @@ class CourseUserGroup(models.Model): ...@@ -35,3 +35,17 @@ class CourseUserGroup(models.Model):
COHORT = 'cohort' COHORT = 'cohort'
GROUP_TYPE_CHOICES = ((COHORT, 'Cohort'),) GROUP_TYPE_CHOICES = ((COHORT, 'Cohort'),)
group_type = models.CharField(max_length=20, choices=GROUP_TYPE_CHOICES) group_type = models.CharField(max_length=20, choices=GROUP_TYPE_CHOICES)
class CourseUserGroupPartitionGroup(models.Model):
"""
"""
course_user_group = models.OneToOneField(CourseUserGroup)
partition_id = models.IntegerField(
help_text="contains the id of a cohorted partition in this course"
)
group_id = models.IntegerField(
help_text="contains the id of a specific group within the cohorted partition"
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
"""
Provides a UserPartition driver for cohorts.
"""
import logging
from .cohorts import get_cohort, get_partition_group_id_for_cohort
log = logging.getLogger(__name__)
class CohortPartitionScheme(object):
"""
This scheme uses lms cohorts (CourseUserGroups) and cohort-partition
mappings (CourseUserGroupPartitionGroup) to map lms users into Partition
Groups.
"""
@classmethod
def get_group_for_user(cls, course_id, user, user_partition, track_function=None):
"""
Returns the Group from the specified user partition to which the user
is assigned, via their cohort membership and any mappings from cohorts
to partitions / groups that might exist.
If the user has not yet been assigned to a cohort, an assignment *might*
be created on-the-fly, as determined by the course's cohort config.
Any such side-effects will be triggered inside the call to
cohorts.get_cohort().
If the user has no cohort mapping, or there is no (valid) cohort ->
partition group mapping found, the function returns None.
"""
cohort = get_cohort(user, course_id)
if cohort is None:
# student doesn't have a cohort
return None
partition_id, group_id = get_partition_group_id_for_cohort(cohort)
if partition_id is None:
# cohort isn't mapped to any partition group.
return None
if partition_id != user_partition.id:
# if we have a match but the partition doesn't match the requested
# one it means the mapping is invalid. the previous state of the
# partition configuration may have been modified.
log.warn(
"partition mismatch in CohortPartitionScheme: %r",
{
"requested_partition_id": user_partition.id,
"found_partition_id": partition_id,
"found_group_id": group_id,
"cohort_id": cohort.id,
}
)
# fail silently
return None
group = user_partition.get_group(group_id)
if group is None:
# if we have a match but the group doesn't exist in the partition,
# it means the mapping is invalid. the previous state of the
# partition configuration may have been modified.
log.warn(
"group not found in CohortPartitionScheme: %r",
{
"requested_partition_id": user_partition.id,
"requested_group_id": group_id,
"cohort_id": cohort.id,
}
)
# fail silently
return None
return group
...@@ -3,11 +3,12 @@ Helper methods for testing cohorts. ...@@ -3,11 +3,12 @@ Helper methods for testing cohorts.
""" """
from factory import post_generation, Sequence from factory import post_generation, Sequence
from factory.django import DjangoModelFactory from factory.django import DjangoModelFactory
from course_groups.models import CourseUserGroup
from opaque_keys.edx.locations import SlashSeparatedCourseKey from opaque_keys.edx.locations import SlashSeparatedCourseKey
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore import ModuleStoreEnum
from ..models import CourseUserGroup
class CohortFactory(DjangoModelFactory): class CohortFactory(DjangoModelFactory):
""" """
......
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db import IntegrityError
from django.http import Http404 from django.http import Http404
from django.test import TestCase from django.test import TestCase
from django.test.utils import override_settings from django.test.utils import override_settings
from mock import call, patch from mock import call, patch
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from course_groups import cohorts from opaque_keys.edx.locations import SlashSeparatedCourseKey
from course_groups.models import CourseUserGroup
from course_groups.tests.helpers import topic_name_to_id, config_course_cohorts, CohortFactory
from student.models import CourseEnrollment from student.models import CourseEnrollment
from student.tests.factories import UserFactory from student.tests.factories import UserFactory
from xmodule.modulestore.django import modulestore, clear_existing_modulestores from xmodule.modulestore.django import modulestore, clear_existing_modulestores
from xmodule.modulestore.tests.django_utils import TEST_DATA_MIXED_TOY_MODULESTORE from xmodule.modulestore.tests.django_utils import TEST_DATA_MIXED_TOY_MODULESTORE, mixed_store_config
from ..models import CourseUserGroup, CourseUserGroupPartitionGroup
from .. import cohorts
from ..tests.helpers import topic_name_to_id, config_course_cohorts, CohortFactory
# NOTE: running this with the lms.envs.test config works without # NOTE: running this with the lms.envs.test config works without
# manually overriding the modulestore. However, running with # manually overriding the modulestore. However, running with
# cms.envs.test doesn't. # cms.envs.test doesn't.
@patch("course_groups.cohorts.tracker")
TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT
TEST_MAPPING = {'edX/toy/2012_Fall': 'xml'}
TEST_DATA_MIXED_MODULESTORE = mixed_store_config(TEST_DATA_DIR, TEST_MAPPING)
@patch("openedx.core.djangoapps.course_groups.cohorts.tracker")
class TestCohortSignals(TestCase): class TestCohortSignals(TestCase):
def setUp(self): def setUp(self):
self.course_key = SlashSeparatedCourseKey("dummy", "dummy", "dummy") self.course_key = SlashSeparatedCourseKey("dummy", "dummy", "dummy")
...@@ -446,7 +453,7 @@ class TestCohorts(TestCase): ...@@ -446,7 +453,7 @@ class TestCohorts(TestCase):
lambda: cohorts.get_cohort_by_id(course.id, cohort.id) lambda: cohorts.get_cohort_by_id(course.id, cohort.id)
) )
@patch("course_groups.cohorts.tracker") @patch("openedx.core.djangoapps.course_groups.cohorts.tracker")
def test_add_cohort(self, mock_tracker): def test_add_cohort(self, mock_tracker):
""" """
Make sure cohorts.add_cohort() properly adds a cohort to a course and handles Make sure cohorts.add_cohort() properly adds a cohort to a course and handles
...@@ -469,7 +476,7 @@ class TestCohorts(TestCase): ...@@ -469,7 +476,7 @@ class TestCohorts(TestCase):
lambda: cohorts.add_cohort(SlashSeparatedCourseKey("course", "does_not", "exist"), "My Cohort") lambda: cohorts.add_cohort(SlashSeparatedCourseKey("course", "does_not", "exist"), "My Cohort")
) )
@patch("course_groups.cohorts.tracker") @patch("openedx.core.djangoapps.course_groups.cohorts.tracker")
def test_add_user_to_cohort(self, mock_tracker): def test_add_user_to_cohort(self, mock_tracker):
""" """
Make sure cohorts.add_user_to_cohort() properly adds a user to a cohort and Make sure cohorts.add_user_to_cohort() properly adds a user to a cohort and
...@@ -525,3 +532,127 @@ class TestCohorts(TestCase): ...@@ -525,3 +532,127 @@ class TestCohorts(TestCase):
User.DoesNotExist, User.DoesNotExist,
lambda: cohorts.add_user_to_cohort(first_cohort, "non_existent_username") lambda: cohorts.add_user_to_cohort(first_cohort, "non_existent_username")
) )
@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
class TestCohortsAndPartitionGroups(TestCase):
def setUp(self):
"""
Regenerate a test course and cohorts for each test
"""
self.test_course_key = SlashSeparatedCourseKey("edX", "toy", "2012_Fall")
self.course = modulestore().get_course(self.test_course_key)
self.first_cohort = CohortFactory(course_id=self.course.id, name="FirstCohort")
self.second_cohort = CohortFactory(course_id=self.course.id, name="SecondCohort")
self.partition_id = 1
self.group1_id = 10
self.group2_id = 20
def _link_cohort_partition_group(self, cohort, partition_id, group_id):
"""
Utility to create cohort -> partition group assignments in the database.
"""
link = CourseUserGroupPartitionGroup(
course_user_group=cohort,
partition_id=partition_id,
group_id=group_id,
)
link.save()
return link
def test_get_partition_group_id_for_cohort(self):
"""
Basic test of the partition_group_id accessor function
"""
# api should return nothing for an unmapped cohort
self.assertEqual(
cohorts.get_partition_group_id_for_cohort(self.first_cohort),
(None, None),
)
# create a link for the cohort in the db
link = self._link_cohort_partition_group(
self.first_cohort,
self.partition_id,
self.group1_id
)
# api should return the specified partition and group
self.assertEqual(
cohorts.get_partition_group_id_for_cohort(self.first_cohort),
(self.partition_id, self.group1_id)
)
# delete the link in the db
link.delete()
# api should return nothing again
self.assertEqual(
cohorts.get_partition_group_id_for_cohort(self.first_cohort),
(None, None),
)
def test_multiple_cohorts(self):
"""
Test that multiple cohorts can be linked to the same partition group
"""
self._link_cohort_partition_group(
self.first_cohort,
self.partition_id,
self.group1_id,
)
self._link_cohort_partition_group(
self.second_cohort,
self.partition_id,
self.group1_id,
)
self.assertEqual(
cohorts.get_partition_group_id_for_cohort(self.first_cohort),
(self.partition_id, self.group1_id),
)
self.assertEqual(
cohorts.get_partition_group_id_for_cohort(self.second_cohort),
cohorts.get_partition_group_id_for_cohort(self.first_cohort),
)
def test_multiple_partition_groups(self):
"""
Test that a cohort cannot be mapped to more than one partition group
"""
self._link_cohort_partition_group(
self.first_cohort,
self.partition_id,
self.group1_id,
)
with self.assertRaisesRegexp(IntegrityError, 'not unique'):
self._link_cohort_partition_group(
self.first_cohort,
self.partition_id,
self.group2_id,
)
def test_delete_cascade(self):
"""
Test that cohort -> partition group links are automatically deleted
when their parent cohort is deleted.
"""
self._link_cohort_partition_group(
self.first_cohort,
self.partition_id,
self.group1_id
)
self.assertEqual(
cohorts.get_partition_group_id_for_cohort(self.first_cohort),
(self.partition_id, self.group1_id)
)
# delete the link
self.first_cohort.delete()
# api should return nothing at that point
self.assertEqual(
cohorts.get_partition_group_id_for_cohort(self.first_cohort),
(None, None),
)
# link should no longer exist because of delete cascade
with self.assertRaises(CourseUserGroupPartitionGroup.DoesNotExist):
CourseUserGroupPartitionGroup.objects.get(
course_user_group_id=self.first_cohort.id
)
"""
Test the partitions and partitions service
"""
from django.conf import settings
import django.test
from django.test.utils import override_settings
from mock import patch
from student.tests.factories import UserFactory
from xmodule.partitions.partitions import Group, UserPartition, UserPartitionError
from xmodule.modulestore.django import modulestore, clear_existing_modulestores
from xmodule.modulestore.tests.django_utils import mixed_store_config
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from ..partition_scheme import CohortPartitionScheme
from ..models import CourseUserGroupPartitionGroup
from ..cohorts import add_user_to_cohort
from .helpers import CohortFactory, config_course_cohorts
TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT
TEST_MAPPING = {'edX/toy/2012_Fall': 'xml'}
TEST_DATA_MIXED_MODULESTORE = mixed_store_config(TEST_DATA_DIR, TEST_MAPPING)
@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
class TestCohortPartitionScheme(django.test.TestCase):
"""
Test the logic for linking a user to a partition group based on their cohort.
"""
def setUp(self):
"""
Regenerate a course with cohort configuration, partition and groups,
and a student for each test.
"""
self.course_key = SlashSeparatedCourseKey("edX", "toy", "2012_Fall")
config_course_cohorts(modulestore().get_course(self.course_key), [], cohorted=True)
self.groups = [Group(10, 'Group 10'), Group(20, 'Group 20')]
self.user_partition = UserPartition(
0,
'Test Partition',
'for testing purposes',
self.groups,
scheme=CohortPartitionScheme
)
self.student = UserFactory.create()
def link_cohort_partition_group(self, cohort, partition, group):
"""
Utility for creating cohort -> partition group links
"""
CourseUserGroupPartitionGroup(
course_user_group=cohort,
partition_id=partition.id,
group_id=group.id,
).save()
def unlink_cohort_partition_group(self, cohort):
"""
Utility for removing cohort -> partition group links
"""
CourseUserGroupPartitionGroup.objects.filter(course_user_group=cohort).delete()
def assert_student_in_group(self, group, partition=None):
"""
Utility for checking that our test student comes up as assigned to the
specified partition (or, if None, no partition at all)
"""
self.assertEqual(
CohortPartitionScheme.get_group_for_user(
self.course_key,
self.student,
partition or self.user_partition,
),
group
)
def test_student_cohort_assignment(self):
"""
Test that the CohortPartitionScheme continues to return the correct
group for a student as the student is moved in and out of different
cohorts.
"""
first_cohort, second_cohort = [
CohortFactory(course_id=self.course_key) for _ in range(2)
]
# place student 0 into first cohort
add_user_to_cohort(first_cohort, self.student.username)
self.assert_student_in_group(None)
# link first cohort to group 0 in the partition
self.link_cohort_partition_group(
first_cohort,
self.user_partition,
self.groups[0],
)
# link second cohort to to group 1 in the partition
self.link_cohort_partition_group(
second_cohort,
self.user_partition,
self.groups[1],
)
self.assert_student_in_group(self.groups[0])
# move student from first cohort to second cohort
add_user_to_cohort(second_cohort, self.student.username)
self.assert_student_in_group(self.groups[1])
# move the student out of the cohort
second_cohort.users.remove(self.student)
self.assert_student_in_group(None)
def test_cohort_partition_group_assignment(self):
"""
Test that the CohortPartitionScheme returns the correct group for a
student in a cohort when the cohort link is created / moved / deleted.
"""
test_cohort = CohortFactory(course_id=self.course_key)
# assign user to cohort (but cohort isn't linked to a partition group yet)
add_user_to_cohort(test_cohort, self.student.username)
# scheme should not yet find any link
self.assert_student_in_group(None)
# link cohort to group 0
self.link_cohort_partition_group(
test_cohort,
self.user_partition,
self.groups[0],
)
# now the scheme should find a link
self.assert_student_in_group(self.groups[0])
# link cohort to group 1 (first unlink it from group 0)
self.unlink_cohort_partition_group(test_cohort)
self.link_cohort_partition_group(
test_cohort,
self.user_partition,
self.groups[1],
)
# scheme should pick up the link
self.assert_student_in_group(self.groups[1])
# unlink cohort from anywhere
self.unlink_cohort_partition_group(
test_cohort,
)
# scheme should now return nothing
self.assert_student_in_group(None)
def setup_student_in_group_0(self):
"""
Utility to set up a cohort, add our student to the cohort, and link
the cohort to self.groups[0]
"""
test_cohort = CohortFactory(course_id=self.course_key)
# link cohort to group 0
self.link_cohort_partition_group(
test_cohort,
self.user_partition,
self.groups[0],
)
# place student into cohort
add_user_to_cohort(test_cohort, self.student.username)
# check link is correct
self.assert_student_in_group(self.groups[0])
def test_partition_changes_nondestructive(self):
"""
If the name of a user partition is changed, or a group is added to the
partition, links from cohorts do not break.
If the name of a group is changed, links from cohorts do not break.
"""
self.setup_student_in_group_0()
# to simulate a non-destructive configuration change on the course, create
# a new partition with the same id and scheme but with groups renamed and
# a group added
new_groups = [Group(10, 'New Group 10'), Group(20, 'New Group 20'), Group(30, 'New Group 30')]
new_user_partition = UserPartition(
0, # same id
'Different Partition',
'dummy',
new_groups,
scheme=CohortPartitionScheme,
)
# the link should still work
self.assert_student_in_group(new_groups[0], new_user_partition)
def test_missing_group(self):
"""
If the group is deleted (or its id is changed), there's no referential
integrity enforced, so any references from cohorts to that group will be
lost. A warning should be logged when links are found from cohorts to
groups that no longer exist.
"""
self.setup_student_in_group_0()
# to simulate a destructive change on the course, create a new partition
# with the same id, but different group ids.
new_user_partition = UserPartition(
0, # same id
'Another Partition',
'dummy',
[Group(11, 'Not Group 10'), Group(21, 'Not Group 20')], # different ids
scheme=CohortPartitionScheme,
)
# the partition will be found since it has the same id, but the group
# ids aren't present anymore, so the scheme returns None (and logs a
# warning)
with patch('openedx.core.djangoapps.course_groups.partition_scheme.log') as mock_log:
self.assert_student_in_group(None, new_user_partition)
self.assertTrue(mock_log.warn.called)
self.assertRegexpMatches(mock_log.warn.call_args[0][0], 'group not found')
def test_missing_partition(self):
"""
If the user partition is deleted (or its id is changed), there's no
referential integrity enforced, so any references from cohorts to that
partition's groups will be lost. A warning should be logged when links
are found from cohorts to partitions that do not exist.
"""
self.setup_student_in_group_0()
# to simulate another destructive change on the course, create a new
# partition with a different id, but using the same groups.
new_user_partition = UserPartition(
1, # different id
'Moved Partition',
'dummy',
[Group(10, 'Group 10'), Group(20, 'Group 20')], # same ids
scheme=CohortPartitionScheme,
)
# the partition will not be found even though the group ids match, so the
# scheme returns None (and logs a warning).
with patch('openedx.core.djangoapps.course_groups.partition_scheme.log') as mock_log:
self.assert_student_in_group(None, new_user_partition)
self.assertTrue(mock_log.warn.called)
self.assertRegexpMatches(mock_log.warn.call_args[0][0], 'partition mismatch')
class TestExtension(django.test.TestCase):
"""
Ensure that the scheme extension is correctly plugged in (via entry point
in setup.py)
"""
def test_get_scheme(self):
self.assertEqual(UserPartition.get_scheme('cohort'), CohortPartitionScheme)
with self.assertRaisesRegexp(UserPartitionError, 'Unrecognized scheme'):
UserPartition.get_scheme('other')
...@@ -4,25 +4,23 @@ Tests for course group views ...@@ -4,25 +4,23 @@ Tests for course group views
from collections import namedtuple from collections import namedtuple
import json import json
from collections import namedtuple
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.http import Http404 from django.http import Http404
from django.test.client import RequestFactory from django.test.client import RequestFactory
from django.test.utils import override_settings from django.test.utils import override_settings
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from course_groups.cohorts import (
get_cohort, CohortAssignmentType, get_cohort_by_name, DEFAULT_COHORT_NAME
)
from course_groups.models import CourseUserGroup
from course_groups.tests.helpers import config_course_cohorts, CohortFactory
from course_groups.views import (
list_cohorts, add_cohort, users_in_cohort, add_users_to_cohort, remove_user_from_cohort
)
from xmodule.modulestore.tests.django_utils import TEST_DATA_MOCK_MODULESTORE from xmodule.modulestore.tests.django_utils import TEST_DATA_MOCK_MODULESTORE
from student.models import CourseEnrollment from student.models import CourseEnrollment
from student.tests.factories import UserFactory from student.tests.factories import UserFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from ..models import CourseUserGroup
from ..views import list_cohorts, add_cohort, users_in_cohort, add_users_to_cohort, remove_user_from_cohort
from ..cohorts import get_cohort, CohortAssignmentType, get_cohort_by_name, DEFAULT_COHORT_NAME
from .helpers import config_course_cohorts, CohortFactory
@override_settings(MODULESTORE=TEST_DATA_MOCK_MODULESTORE) @override_settings(MODULESTORE=TEST_DATA_MOCK_MODULESTORE)
......
...@@ -11,8 +11,8 @@ from django.db import transaction, IntegrityError ...@@ -11,8 +11,8 @@ from django.db import transaction, IntegrityError
from django.core.validators import validate_email, validate_slug, ValidationError from django.core.validators import validate_email, validate_slug, ValidationError
from django.contrib.auth.forms import PasswordResetForm from django.contrib.auth.forms import PasswordResetForm
from user_api.models import User, UserProfile, Registration, PendingEmailChange from ..models import User, UserProfile, Registration, PendingEmailChange
from user_api.helpers import intercept_errors from ..helpers import intercept_errors
USERNAME_MIN_LENGTH = 2 USERNAME_MIN_LENGTH = 2
......
...@@ -7,7 +7,7 @@ Stores global metadata using the UserPreference model, and per-course metadata u ...@@ -7,7 +7,7 @@ Stores global metadata using the UserPreference model, and per-course metadata u
UserCourseTag model. UserCourseTag model.
""" """
from user_api.models import UserCourseTag from ..models import UserCourseTag
# Scopes # Scopes
# (currently only allows per-course tags. Can be expanded to support # (currently only allows per-course tags. Can be expanded to support
......
...@@ -13,9 +13,9 @@ from django.db import IntegrityError ...@@ -13,9 +13,9 @@ from django.db import IntegrityError
from pytz import UTC from pytz import UTC
import analytics import analytics
from user_api.models import User, UserProfile, UserPreference, UserOrgTag
from user_api.helpers import intercept_errors
from eventtracking import tracker from eventtracking import tracker
from ..models import User, UserProfile, UserPreference, UserOrgTag
from ..helpers import intercept_errors
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -34,6 +34,7 @@ class ProfileInvalidField(ProfileRequestError): ...@@ -34,6 +34,7 @@ class ProfileInvalidField(ProfileRequestError):
""" The proposed value for a field is not in a valid format. """ """ The proposed value for a field is not in a valid format. """
def __init__(self, field, value): def __init__(self, field, value):
super(ProfileInvalidField, self).__init__()
self.field = field self.field = field
self.value = value self.value = value
......
...@@ -12,7 +12,7 @@ from django.http import HttpResponseBadRequest ...@@ -12,7 +12,7 @@ from django.http import HttpResponseBadRequest
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
def intercept_errors(api_error, ignore_errors=[]): def intercept_errors(api_error, ignore_errors=None):
""" """
Function decorator that intercepts exceptions Function decorator that intercepts exceptions
and translates them into API-specific errors (usually an "internal" error). and translates them into API-specific errors (usually an "internal" error).
...@@ -33,13 +33,20 @@ def intercept_errors(api_error, ignore_errors=[]): ...@@ -33,13 +33,20 @@ def intercept_errors(api_error, ignore_errors=[]):
""" """
def _decorator(func): def _decorator(func):
"""
Function decorator that intercepts exceptions and translates them into API-specific errors.
"""
@wraps(func) @wraps(func)
def _wrapped(*args, **kwargs): def _wrapped(*args, **kwargs):
"""
Wrapper that evaluates a function, intercepting exceptions and translating them into
API-specific errors.
"""
try: try:
return func(*args, **kwargs) return func(*args, **kwargs)
except Exception as ex: except Exception as ex:
# Raise the original exception if it's in our list of "ignored" errors # 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): if isinstance(ex, ignored):
raise raise
......
...@@ -19,9 +19,9 @@ from xmodule.modulestore.tests.factories import CourseFactory ...@@ -19,9 +19,9 @@ from xmodule.modulestore.tests.factories import CourseFactory
from student.tests.factories import UserFactory, CourseEnrollmentFactory from student.tests.factories import UserFactory, CourseEnrollmentFactory
from student.models import CourseEnrollment from student.models import CourseEnrollment
import user_api.api.profile as profile_api import openedx.core.djangoapps.user_api.api.profile as profile_api
from user_api.models import UserOrgTag from openedx.core.djangoapps.user_api.models import UserOrgTag
from user_api.management.commands import email_opt_in_list 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) MODULESTORE_CONFIG = mixed_store_config(settings.COMMON_TEST_DATA_ROOT, {}, include_xml=False)
......
...@@ -8,7 +8,8 @@ from opaque_keys import InvalidKeyError ...@@ -8,7 +8,8 @@ from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from track.contexts import COURSE_REGEX from track.contexts import COURSE_REGEX
from user_api.models import UserCourseTag
from .models import UserCourseTag
class UserTagsEventContextMiddleware(object): 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)
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