Merge pull request #5942 from edx/cohorted-courseware
Cohorted courseware
This commit is contained in:
@@ -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
|
||||
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
|
||||
|
||||
Studio: Add support xblock validation messages on Studio unit/container page. TNL-683
|
||||
|
||||
@@ -22,7 +22,7 @@ from xmodule.error_module import ErrorDescriptor
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
from xmodule.tabs import PDFTextbookTabs
|
||||
from xmodule.partitions.partitions import UserPartition, Group
|
||||
from xmodule.partitions.partitions import UserPartition
|
||||
from xmodule.modulestore import EdxJSONEncoder
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError, DuplicateCourseError
|
||||
from opaque_keys import InvalidKeyError
|
||||
@@ -1173,7 +1173,7 @@ class GroupConfiguration(object):
|
||||
configuration = json.loads(json_string)
|
||||
except ValueError:
|
||||
raise GroupConfigurationsValidationError(_("invalid JSON"))
|
||||
|
||||
configuration["version"] = UserPartition.VERSION
|
||||
return configuration
|
||||
|
||||
def validate(self):
|
||||
@@ -1224,14 +1224,7 @@ class GroupConfiguration(object):
|
||||
"""
|
||||
Get user partition for saving in course.
|
||||
"""
|
||||
groups = [Group(g["id"], g["name"]) for g in self.configuration["groups"]]
|
||||
|
||||
return UserPartition(
|
||||
self.configuration["id"],
|
||||
self.configuration["name"],
|
||||
self.configuration["description"],
|
||||
groups
|
||||
)
|
||||
return UserPartition.from_json(self.configuration)
|
||||
|
||||
@staticmethod
|
||||
def get_usage_info(course, store):
|
||||
@@ -1345,15 +1338,12 @@ def group_configurations_list_handler(request, course_key_string):
|
||||
if 'text/html' in request.META.get('HTTP_ACCEPT', 'text/html'):
|
||||
group_configuration_url = reverse_course_url('group_configurations_list_handler', course_key)
|
||||
course_outline_url = reverse_course_url('course_handler', course_key)
|
||||
split_test_enabled = SPLIT_TEST_COMPONENT_TYPE in ADVANCED_COMPONENT_TYPES and SPLIT_TEST_COMPONENT_TYPE in course.advanced_modules
|
||||
|
||||
configurations = GroupConfiguration.add_usage_info(course, store)
|
||||
|
||||
return render_to_response('group_configurations.html', {
|
||||
'context_course': course,
|
||||
'group_configuration_url': group_configuration_url,
|
||||
'course_outline_url': course_outline_url,
|
||||
'configurations': configurations if split_test_enabled else None,
|
||||
'configurations': configurations if should_show_group_configurations_page(course) else None,
|
||||
})
|
||||
elif "application/json" in request.META.get('HTTP_ACCEPT'):
|
||||
if request.method == 'POST':
|
||||
@@ -1432,6 +1422,16 @@ def group_configurations_detail_handler(request, course_key_string, group_config
|
||||
return JsonResponse(status=204)
|
||||
|
||||
|
||||
def should_show_group_configurations_page(course):
|
||||
"""
|
||||
Returns true if Studio should show the "Group Configurations" page for the specified course.
|
||||
"""
|
||||
return (
|
||||
SPLIT_TEST_COMPONENT_TYPE in ADVANCED_COMPONENT_TYPES and
|
||||
SPLIT_TEST_COMPONENT_TYPE in course.advanced_modules
|
||||
)
|
||||
|
||||
|
||||
def _get_course_creator_status(user):
|
||||
"""
|
||||
Helper method for returning the course creator status for a particular user,
|
||||
|
||||
@@ -15,10 +15,17 @@ from xmodule.modulestore import ModuleStoreEnum
|
||||
|
||||
GROUP_CONFIGURATION_JSON = {
|
||||
u'name': u'Test name',
|
||||
u'scheme': u'random',
|
||||
u'description': u'Test description',
|
||||
u'version': UserPartition.VERSION,
|
||||
u'groups': [
|
||||
{u'name': u'Group A'},
|
||||
{u'name': u'Group B'},
|
||||
{
|
||||
u'name': u'Group A',
|
||||
u'version': 1,
|
||||
}, {
|
||||
u'name': u'Group B',
|
||||
u'version': 1,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -229,7 +236,8 @@ class GroupConfigurationsListHandlerTestCase(CourseTestCase, GroupConfigurations
|
||||
expected = {
|
||||
u'description': u'Test description',
|
||||
u'name': u'Test name',
|
||||
u'version': 1,
|
||||
u'scheme': u'random',
|
||||
u'version': UserPartition.VERSION,
|
||||
u'groups': [
|
||||
{u'name': u'Group A', u'version': 1},
|
||||
{u'name': u'Group B', u'version': 1},
|
||||
@@ -279,15 +287,16 @@ class GroupConfigurationsDetailHandlerTestCase(CourseTestCase, GroupConfiguratio
|
||||
kwargs={'group_configuration_id': cid},
|
||||
)
|
||||
|
||||
def test_can_create_new_group_configuration_if_it_is_not_exist(self):
|
||||
def test_can_create_new_group_configuration_if_it_does_not_exist(self):
|
||||
"""
|
||||
PUT new group configuration when no configurations exist in the course.
|
||||
"""
|
||||
expected = {
|
||||
u'id': 999,
|
||||
u'name': u'Test name',
|
||||
u'scheme': u'random',
|
||||
u'description': u'Test description',
|
||||
u'version': 1,
|
||||
u'version': UserPartition.VERSION,
|
||||
u'groups': [
|
||||
{u'id': 0, u'name': u'Group A', u'version': 1},
|
||||
{u'id': 1, u'name': u'Group B', u'version': 1},
|
||||
@@ -306,12 +315,12 @@ class GroupConfigurationsDetailHandlerTestCase(CourseTestCase, GroupConfiguratio
|
||||
self.assertEqual(content, expected)
|
||||
self.reload_course()
|
||||
# Verify that user_partitions in the course contains the new group configuration.
|
||||
user_partititons = self.course.user_partitions
|
||||
self.assertEqual(len(user_partititons), 1)
|
||||
self.assertEqual(user_partititons[0].name, u'Test name')
|
||||
self.assertEqual(len(user_partititons[0].groups), 2)
|
||||
self.assertEqual(user_partititons[0].groups[0].name, u'Group A')
|
||||
self.assertEqual(user_partititons[0].groups[1].name, u'Group B')
|
||||
user_partitions = self.course.user_partitions
|
||||
self.assertEqual(len(user_partitions), 1)
|
||||
self.assertEqual(user_partitions[0].name, u'Test name')
|
||||
self.assertEqual(len(user_partitions[0].groups), 2)
|
||||
self.assertEqual(user_partitions[0].groups[0].name, u'Group A')
|
||||
self.assertEqual(user_partitions[0].groups[1].name, u'Group B')
|
||||
|
||||
def test_can_edit_group_configuration(self):
|
||||
"""
|
||||
@@ -323,8 +332,9 @@ class GroupConfigurationsDetailHandlerTestCase(CourseTestCase, GroupConfiguratio
|
||||
expected = {
|
||||
u'id': self.ID,
|
||||
u'name': u'New Test name',
|
||||
u'scheme': u'random',
|
||||
u'description': u'New Test description',
|
||||
u'version': 1,
|
||||
u'version': UserPartition.VERSION,
|
||||
u'groups': [
|
||||
{u'id': 0, u'name': u'New Group Name', u'version': 1},
|
||||
{u'id': 2, u'name': u'Group C', u'version': 1},
|
||||
@@ -430,8 +440,9 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods):
|
||||
expected = [{
|
||||
'id': 0,
|
||||
'name': 'Name 0',
|
||||
'scheme': 'random',
|
||||
'description': 'Description 0',
|
||||
'version': 1,
|
||||
'version': UserPartition.VERSION,
|
||||
'groups': [
|
||||
{'id': 0, 'name': 'Group A', 'version': 1},
|
||||
{'id': 1, 'name': 'Group B', 'version': 1},
|
||||
@@ -454,8 +465,9 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods):
|
||||
expected = [{
|
||||
'id': 0,
|
||||
'name': 'Name 0',
|
||||
'scheme': 'random',
|
||||
'description': 'Description 0',
|
||||
'version': 1,
|
||||
'version': UserPartition.VERSION,
|
||||
'groups': [
|
||||
{'id': 0, 'name': 'Group A', 'version': 1},
|
||||
{'id': 1, 'name': 'Group B', 'version': 1},
|
||||
@@ -469,8 +481,9 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods):
|
||||
}, {
|
||||
'id': 1,
|
||||
'name': 'Name 1',
|
||||
'scheme': 'random',
|
||||
'description': 'Description 1',
|
||||
'version': 1,
|
||||
'version': UserPartition.VERSION,
|
||||
'groups': [
|
||||
{'id': 0, 'name': 'Group A', 'version': 1},
|
||||
{'id': 1, 'name': 'Group B', 'version': 1},
|
||||
@@ -495,8 +508,9 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods):
|
||||
expected = [{
|
||||
'id': 0,
|
||||
'name': 'Name 0',
|
||||
'scheme': 'random',
|
||||
'description': 'Description 0',
|
||||
'version': 1,
|
||||
'version': UserPartition.VERSION,
|
||||
'groups': [
|
||||
{'id': 0, 'name': 'Group A', 'version': 1},
|
||||
{'id': 1, 'name': 'Group B', 'version': 1},
|
||||
|
||||
@@ -223,8 +223,9 @@ class GetItemTest(ItemTest):
|
||||
GROUP_CONFIGURATION_JSON = {
|
||||
u'id': 0,
|
||||
u'name': u'first_partition',
|
||||
u'scheme': u'random',
|
||||
u'description': u'First Partition',
|
||||
u'version': 1,
|
||||
u'version': UserPartition.VERSION,
|
||||
u'groups': [
|
||||
{u'id': 0, u'name': u'New_NAME_A', u'version': 1},
|
||||
{u'id': 1, u'name': u'New_NAME_B', u'version': 1},
|
||||
|
||||
@@ -32,6 +32,7 @@ class CourseMetadata(object):
|
||||
'name', # from xblock
|
||||
'tags', # from xblock
|
||||
'visible_to_staff_only',
|
||||
'group_access',
|
||||
]
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -575,7 +575,7 @@ INSTALLED_APPS = (
|
||||
'contentstore',
|
||||
'course_creators',
|
||||
'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
|
||||
'track',
|
||||
@@ -607,7 +607,7 @@ INSTALLED_APPS = (
|
||||
'reverification',
|
||||
|
||||
# User preferences
|
||||
'user_api',
|
||||
'openedx.core.djangoapps.user_api',
|
||||
'django_openid_auth',
|
||||
|
||||
'embargo',
|
||||
|
||||
@@ -7,7 +7,7 @@ define([
|
||||
defaults: function() {
|
||||
return {
|
||||
name: '',
|
||||
version: null,
|
||||
version: 1,
|
||||
order: null
|
||||
};
|
||||
},
|
||||
|
||||
@@ -9,8 +9,9 @@ function(Backbone, _, str, gettext, GroupModel, GroupCollection) {
|
||||
defaults: function() {
|
||||
return {
|
||||
name: '',
|
||||
scheme: 'random',
|
||||
description: '',
|
||||
version: null,
|
||||
version: 2,
|
||||
groups: new GroupCollection([
|
||||
{
|
||||
name: gettext('Group A'),
|
||||
@@ -71,6 +72,7 @@ function(Backbone, _, str, gettext, GroupModel, GroupCollection) {
|
||||
return {
|
||||
id: this.get('id'),
|
||||
name: this.get('name'),
|
||||
scheme: this.get('scheme'),
|
||||
description: this.get('description'),
|
||||
version: this.get('version'),
|
||||
groups: this.get('groups').toJSON()
|
||||
|
||||
@@ -99,7 +99,8 @@ define([
|
||||
'id': 10,
|
||||
'name': 'My Group Configuration',
|
||||
'description': 'Some description',
|
||||
'version': 1,
|
||||
'version': 2,
|
||||
'scheme': 'random',
|
||||
'groups': [
|
||||
{
|
||||
'version': 1,
|
||||
@@ -114,9 +115,10 @@ define([
|
||||
'id': 10,
|
||||
'name': 'My Group Configuration',
|
||||
'description': 'Some description',
|
||||
'scheme': 'random',
|
||||
'showGroups': false,
|
||||
'editing': false,
|
||||
'version': 1,
|
||||
'version': 2,
|
||||
'groups': [
|
||||
{
|
||||
'version': 1,
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
</div>
|
||||
% else:
|
||||
<div class="ui-loading">
|
||||
<p><span class="spin"><i class="icon-refresh"></i></span> <span class="copy">${_("Loading...")}</span></p>
|
||||
<p><span class="spin"><i class="icon-refresh"></i></span> <span class="copy">${_("Loading")}</span></p>
|
||||
</div>
|
||||
% endif
|
||||
</article>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<%!
|
||||
from django.utils.translation import ugettext as _
|
||||
from contentstore import utils
|
||||
from contentstore.views.course import should_show_group_configurations_page
|
||||
import urllib
|
||||
%>
|
||||
|
||||
@@ -312,10 +313,10 @@ CMS.URL.UPLOAD_ASSET = '${upload_asset_url}';
|
||||
<ul>
|
||||
<li class="nav-item"><a href="${grading_config_url}">${_("Grading")}</a></li>
|
||||
<li class="nav-item"><a href="${course_team_url}">${_("Course Team")}</a></li>
|
||||
% if should_show_group_configurations_page(context_course):
|
||||
<li class="nav-item"><a href="${utils.reverse_course_url('group_configurations_list_handler', context_course.id)}">${_("Group Configurations")}</a></li>
|
||||
% endif
|
||||
<li class="nav-item"><a href="${advanced_config_url}">${_("Advanced Settings")}</a></li>
|
||||
% if "split_test" in context_course.advanced_modules:
|
||||
<li class="nav-item"><a href="${utils.reverse_course_url('group_configurations_list_handler', context_course.id)}">${_("Group Configurations")}</a></li>
|
||||
% endif
|
||||
</ul>
|
||||
</nav>
|
||||
% endif
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<%!
|
||||
from django.utils.translation import ugettext as _
|
||||
from contentstore import utils
|
||||
from contentstore.views.course import should_show_group_configurations_page
|
||||
from django.utils.html import escapejs
|
||||
%>
|
||||
<%block name="title">${_("Advanced Settings")}</%block>
|
||||
@@ -91,9 +92,9 @@
|
||||
<li class="nav-item"><a href="${details_url}">${_("Details & Schedule")}</a></li>
|
||||
<li class="nav-item"><a href="${grading_url}">${_("Grading")}</a></li>
|
||||
<li class="nav-item"><a href="${course_team_url}">${_("Course Team")}</a></li>
|
||||
% if "split_test" in context_course.advanced_modules:
|
||||
<li class="nav-item"><a href="${utils.reverse_course_url('group_configurations_list_handler', context_course.id)}">${_("Group Configurations")}</a></li>
|
||||
% endif
|
||||
% if should_show_group_configurations_page(context_course):
|
||||
<li class="nav-item"><a href="${utils.reverse_course_url('group_configurations_list_handler', context_course.id)}">${_("Group Configurations")}</a></li>
|
||||
% endif
|
||||
</ul>
|
||||
</nav>
|
||||
% endif
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<%namespace name='static' file='static_content.html'/>
|
||||
<%!
|
||||
from contentstore import utils
|
||||
from contentstore.views.course import should_show_group_configurations_page
|
||||
from django.utils.translation import ugettext as _
|
||||
%>
|
||||
|
||||
@@ -134,10 +135,10 @@
|
||||
<ul>
|
||||
<li class="nav-item"><a href="${detailed_settings_url}">${_("Details & Schedule")}</a></li>
|
||||
<li class="nav-item"><a href="${course_team_url}">${_("Course Team")}</a></li>
|
||||
% if should_show_group_configurations_page(context_course):
|
||||
<li class="nav-item"><a href="${utils.reverse_course_url('group_configurations_list_handler', context_course.id)}">${_("Group Configurations")}</a></li>
|
||||
% endif
|
||||
<li class="nav-item"><a href="${advanced_settings_url}">${_("Advanced Settings")}</a></li>
|
||||
% if "split_test" in context_course.advanced_modules:
|
||||
<li class="nav-item"><a href="${utils.reverse_course_url('group_configurations_list_handler', context_course.id)}">${_("Group Configurations")}</a></li>
|
||||
% endif
|
||||
</ul>
|
||||
</nav>
|
||||
% endif
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.utils.translation import ugettext as _
|
||||
from contentstore.context_processors import doc_url
|
||||
from contentstore.views.course import should_show_group_configurations_page
|
||||
%>
|
||||
<%page args="online_help_token"/>
|
||||
|
||||
@@ -81,14 +82,14 @@
|
||||
<li class="nav-item nav-course-settings-team">
|
||||
<a href="${course_team_url}">${_("Course Team")}</a>
|
||||
</li>
|
||||
% if should_show_group_configurations_page(context_course):
|
||||
<li class="nav-item nav-course-settings-group-configurations">
|
||||
<a href="${reverse('contentstore.views.group_configurations_list_handler', kwargs={'course_key_string': unicode(course_key)})}">${_("Group Configurations")}</a>
|
||||
</li>
|
||||
% endif
|
||||
<li class="nav-item nav-course-settings-advanced">
|
||||
<a href="${advanced_settings_url}">${_("Advanced Settings")}</a>
|
||||
</li>
|
||||
% if "split_test" in context_course.advanced_modules:
|
||||
<li class="nav-item nav-course-settings-group-configurations">
|
||||
<a href="${reverse('contentstore.views.group_configurations_list_handler', kwargs={'course_key_string': unicode(course_key)})}">${_("Group Configurations")}</a>
|
||||
</li>
|
||||
% endif
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -39,7 +39,7 @@ urlpatterns = patterns('', # nopep8
|
||||
url(r'^xmodule/', include('pipeline_js.urls')),
|
||||
url(r'^heartbeat$', include('heartbeat.urls')),
|
||||
|
||||
url(r'^user_api/', include('user_api.urls')),
|
||||
url(r'^user_api/', include('openedx.core.djangoapps.user_api.urls')),
|
||||
url(r'^lang_pref/', include('lang_pref.urls')),
|
||||
)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Middleware for Language Preferences
|
||||
"""
|
||||
|
||||
from user_api.models import UserPreference
|
||||
from openedx.core.djangoapps.user_api.models import UserPreference
|
||||
from lang_pref import LANGUAGE_KEY
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ from django.test.client import RequestFactory
|
||||
from django.contrib.sessions.middleware import SessionMiddleware
|
||||
|
||||
from lang_pref.middleware import LanguagePreferenceMiddleware
|
||||
from user_api.models import UserPreference
|
||||
from openedx.core.djangoapps.user_api.models import UserPreference
|
||||
from lang_pref import LANGUAGE_KEY
|
||||
from student.tests.factories import UserFactory
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ Tests for the language setting view
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.test import TestCase
|
||||
from student.tests.factories import UserFactory
|
||||
from user_api.models import UserPreference
|
||||
from openedx.core.djangoapps.user_api.models import UserPreference
|
||||
from lang_pref import LANGUAGE_KEY
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ Views for accessing language preferences
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpResponse, HttpResponseBadRequest
|
||||
|
||||
from user_api.models import UserPreference
|
||||
from openedx.core.djangoapps.user_api.models import UserPreference
|
||||
from lang_pref import LANGUAGE_KEY
|
||||
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ from django.test import TestCase, TransactionTestCase
|
||||
|
||||
import mock
|
||||
|
||||
from user_api.models import UserPreference
|
||||
from openedx.core.djangoapps.user_api.models import UserPreference
|
||||
from lang_pref import LANGUAGE_KEY
|
||||
|
||||
from edxmako.tests import mako_middleware_process_request
|
||||
|
||||
@@ -106,7 +106,7 @@ class EnrollmentTest(ModuleStoreTestCase):
|
||||
self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course.id))
|
||||
|
||||
@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(
|
||||
([], 'true'),
|
||||
([], 'false'),
|
||||
|
||||
@@ -79,7 +79,7 @@ import external_auth.views
|
||||
from bulk_email.models import Optout, CourseAuthorization
|
||||
import shoppingcart
|
||||
from shoppingcart.models import DonationConfiguration
|
||||
from user_api.models import UserPreference
|
||||
from openedx.core.djangoapps.user_api.models import UserPreference
|
||||
from lang_pref import LANGUAGE_KEY
|
||||
|
||||
import track.views
|
||||
@@ -105,7 +105,7 @@ from student.helpers import (
|
||||
)
|
||||
from xmodule.error_module import ErrorDescriptor
|
||||
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
|
||||
from eventtracking import tracker
|
||||
|
||||
@@ -57,6 +57,15 @@ class InheritanceMixin(XBlockMixin):
|
||||
default=False,
|
||||
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(
|
||||
display_name=_("Course Editor"),
|
||||
help=_("Enter the method by which this course is edited (\"XML\" or \"Studio\")."),
|
||||
@@ -142,8 +151,8 @@ class InheritanceMixin(XBlockMixin):
|
||||
# This is should be scoped to content, but since it's defined in the policy
|
||||
# file, it is currently scoped to settings.
|
||||
user_partitions = UserPartitionList(
|
||||
display_name=_("Experiment Group Configurations"),
|
||||
help=_("Enter the configurations that govern how students are grouped for content experiments."),
|
||||
display_name=_("Group Configurations"),
|
||||
help=_("Enter the configurations that govern how students are grouped together."),
|
||||
default=[],
|
||||
scope=Scope.settings
|
||||
)
|
||||
|
||||
@@ -31,6 +31,7 @@ from xmodule.modulestore.xml_exporter import export_to_xml
|
||||
from xmodule.modulestore.split_mongo.split_draft import DraftVersioningModuleStore
|
||||
from xmodule.modulestore.tests.mongo_connection import MONGO_PORT_NUM, MONGO_HOST
|
||||
from xmodule.modulestore.inheritance import InheritanceMixin
|
||||
from xmodule.partitions.tests.test_partitions import PartitionTestCase
|
||||
from xmodule.x_module import XModuleMixin
|
||||
from xmodule.modulestore.xml import XMLModuleStore
|
||||
|
||||
@@ -291,7 +292,7 @@ COURSE_DATA_NAMES = (
|
||||
|
||||
@ddt.ddt
|
||||
@attr('mongo')
|
||||
class CrossStoreXMLRoundtrip(CourseComparisonTest):
|
||||
class CrossStoreXMLRoundtrip(CourseComparisonTest, PartitionTestCase):
|
||||
"""
|
||||
This class exists to test XML import and export between different modulestore
|
||||
classes.
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
"""Defines ``Group`` and ``UserPartition`` models for partitioning"""
|
||||
|
||||
from collections import namedtuple
|
||||
from stevedore.extension import ExtensionManager
|
||||
|
||||
# We use ``id`` in this file as the IDs of our Groups and UserPartitions,
|
||||
# which Pylint disapproves of.
|
||||
# pylint: disable=invalid-name, redefined-builtin
|
||||
|
||||
|
||||
class UserPartitionError(Exception):
|
||||
"""
|
||||
An error was found regarding user partitions.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class Group(namedtuple("Group", "id name")):
|
||||
"""
|
||||
An id and name for a group of students. The id should be unique
|
||||
@@ -45,7 +55,7 @@ class Group(namedtuple("Group", "id name")):
|
||||
if isinstance(value, Group):
|
||||
return value
|
||||
|
||||
for key in ('id', 'name', 'version'):
|
||||
for key in ("id", "name", "version"):
|
||||
if key not in value:
|
||||
raise TypeError("Group dict {0} missing value key '{1}'".format(
|
||||
value, key))
|
||||
@@ -57,21 +67,50 @@ class Group(namedtuple("Group", "id name")):
|
||||
return Group(value["id"], value["name"])
|
||||
|
||||
|
||||
class UserPartition(namedtuple("UserPartition", "id name description groups")):
|
||||
# The Stevedore extension point namespace for user partition scheme plugins.
|
||||
USER_PARTITION_SCHEME_NAMESPACE = 'openedx.user_partition_scheme'
|
||||
|
||||
|
||||
class UserPartition(namedtuple("UserPartition", "id name description groups scheme")):
|
||||
"""
|
||||
A named way to partition users into groups, primarily intended for running
|
||||
experiments. It is expected that each user will be in at most one group in a
|
||||
partition.
|
||||
|
||||
A Partition has an id, name, description, and a list of groups.
|
||||
A Partition has an id, name, scheme, description, and a list of groups.
|
||||
The id is intended to be unique within the context where these are used. (e.g. for
|
||||
partitions of users within a course, the ids should be unique per-course)
|
||||
partitions of users within a course, the ids should be unique per-course).
|
||||
The scheme is used to assign users into groups.
|
||||
"""
|
||||
VERSION = 1
|
||||
VERSION = 2
|
||||
|
||||
def __new__(cls, id, name, description, groups):
|
||||
# The collection of user partition scheme extensions.
|
||||
scheme_extensions = None
|
||||
|
||||
# The default scheme to be used when upgrading version 1 partitions.
|
||||
VERSION_1_SCHEME = "random"
|
||||
|
||||
def __new__(cls, id, name, description, groups, scheme=None, scheme_id=VERSION_1_SCHEME):
|
||||
# pylint: disable=super-on-old-class
|
||||
return super(UserPartition, cls).__new__(cls, int(id), name, description, groups)
|
||||
if not scheme:
|
||||
scheme = UserPartition.get_scheme(scheme_id)
|
||||
return super(UserPartition, cls).__new__(cls, int(id), name, description, groups, scheme)
|
||||
|
||||
@staticmethod
|
||||
def get_scheme(name):
|
||||
"""
|
||||
Returns the user partition scheme with the given name.
|
||||
"""
|
||||
# Note: we're creating the extension manager lazily to ensure that the Python path
|
||||
# has been correctly set up. Trying to create this statically will fail, unfortunately.
|
||||
if not UserPartition.scheme_extensions:
|
||||
UserPartition.scheme_extensions = ExtensionManager(namespace=USER_PARTITION_SCHEME_NAMESPACE)
|
||||
try:
|
||||
scheme = UserPartition.scheme_extensions[name].plugin
|
||||
except KeyError:
|
||||
raise UserPartitionError("Unrecognized scheme {0}".format(name))
|
||||
scheme.name = name
|
||||
return scheme
|
||||
|
||||
def to_json(self):
|
||||
"""
|
||||
@@ -84,6 +123,7 @@ class UserPartition(namedtuple("UserPartition", "id name description groups")):
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"scheme": self.scheme.name,
|
||||
"description": self.description,
|
||||
"groups": [g.to_json() for g in self.groups],
|
||||
"version": UserPartition.VERSION
|
||||
@@ -102,20 +142,38 @@ class UserPartition(namedtuple("UserPartition", "id name description groups")):
|
||||
if isinstance(value, UserPartition):
|
||||
return value
|
||||
|
||||
for key in ('id', 'name', 'description', 'version', 'groups'):
|
||||
for key in ("id", "name", "description", "version", "groups"):
|
||||
if key not in value:
|
||||
raise TypeError("UserPartition dict {0} missing value key '{1}'"
|
||||
.format(value, key))
|
||||
raise TypeError("UserPartition dict {0} missing value key '{1}'".format(value, key))
|
||||
|
||||
if value["version"] != UserPartition.VERSION:
|
||||
raise TypeError("UserPartition dict {0} has unexpected version"
|
||||
.format(value))
|
||||
if value["version"] == 1:
|
||||
# If no scheme was provided, set it to the default ('random')
|
||||
scheme_id = UserPartition.VERSION_1_SCHEME
|
||||
elif value["version"] == UserPartition.VERSION:
|
||||
if not "scheme" in value:
|
||||
raise TypeError("UserPartition dict {0} missing value key 'scheme'".format(value))
|
||||
scheme_id = value["scheme"]
|
||||
else:
|
||||
raise TypeError("UserPartition dict {0} has unexpected version".format(value))
|
||||
|
||||
groups = [Group.from_json(g) for g in value["groups"]]
|
||||
scheme = UserPartition.get_scheme(scheme_id)
|
||||
if not scheme:
|
||||
raise TypeError("UserPartition dict {0} has unrecognized scheme {1}".format(value, scheme_id))
|
||||
|
||||
return UserPartition(
|
||||
value["id"],
|
||||
value["name"],
|
||||
value["description"],
|
||||
groups
|
||||
groups,
|
||||
scheme,
|
||||
)
|
||||
|
||||
def get_group(self, group_id):
|
||||
"""
|
||||
Returns the group with the specified id.
|
||||
"""
|
||||
for group in self.groups: # pylint: disable=no-member
|
||||
if group.id == group_id:
|
||||
return group
|
||||
return None
|
||||
|
||||
@@ -3,7 +3,6 @@ This is a service-like API that assigns tracks which groups users are in for var
|
||||
user partitions. It uses the user_service key/value store provided by the LMS runtime to
|
||||
persist the assignments.
|
||||
"""
|
||||
import random
|
||||
from abc import ABCMeta, abstractproperty
|
||||
|
||||
|
||||
@@ -22,13 +21,11 @@ class PartitionService(object):
|
||||
"""
|
||||
raise NotImplementedError('Subclasses must implement course_partition')
|
||||
|
||||
def __init__(self, user_tags_service, course_id, track_function):
|
||||
self.random = random.Random()
|
||||
self._user_tags_service = user_tags_service
|
||||
self._course_id = course_id
|
||||
def __init__(self, runtime, track_function):
|
||||
self.runtime = runtime
|
||||
self._track_function = track_function
|
||||
|
||||
def get_user_group_for_partition(self, user_partition_id):
|
||||
def get_user_group_id_for_partition(self, user_partition_id):
|
||||
"""
|
||||
If the user is already assigned to a group in user_partition_id, return the
|
||||
group_id.
|
||||
@@ -53,17 +50,15 @@ class PartitionService(object):
|
||||
if user_partition is None:
|
||||
raise ValueError(
|
||||
"Configuration problem! No user_partition with id {0} "
|
||||
"in course {1}".format(user_partition_id, self._course_id)
|
||||
"in course {1}".format(user_partition_id, self.runtime.course_id)
|
||||
)
|
||||
|
||||
group_id = self._get_group(user_partition)
|
||||
|
||||
return group_id
|
||||
group = self._get_group(user_partition)
|
||||
return group.id if group else None
|
||||
|
||||
def _get_user_partition(self, user_partition_id):
|
||||
"""
|
||||
Look for a user partition with a matching id in
|
||||
in the course's partitions.
|
||||
Look for a user partition with a matching id in the course's partitions.
|
||||
|
||||
Returns:
|
||||
A UserPartition, or None if not found.
|
||||
@@ -74,65 +69,13 @@ class PartitionService(object):
|
||||
|
||||
return None
|
||||
|
||||
def _key_for_partition(self, user_partition):
|
||||
"""
|
||||
Returns the key to use to look up and save the user's group for a particular
|
||||
condition. Always use this function rather than constructing the key directly.
|
||||
"""
|
||||
return 'xblock.partition_service.partition_{0}'.format(user_partition.id)
|
||||
|
||||
def _get_group(self, user_partition):
|
||||
"""
|
||||
Return the group of the current user in user_partition. If they don't already have
|
||||
one assigned, pick one and save it. Uses the runtime's user_service service to look up
|
||||
and persist the info.
|
||||
Returns the group from the specified user partition to which the user is assigned.
|
||||
If the user has not yet been assigned, a group will be chosen for them based upon
|
||||
the partition's scheme.
|
||||
"""
|
||||
key = self._key_for_partition(user_partition)
|
||||
scope = self._user_tags_service.COURSE_SCOPE
|
||||
|
||||
group_id = self._user_tags_service.get_tag(scope, key)
|
||||
if group_id is not None:
|
||||
group_id = int(group_id)
|
||||
|
||||
partition_group_ids = [group.id for group in user_partition.groups]
|
||||
|
||||
# If a valid group id has been saved already, return it
|
||||
if group_id is not None and group_id in partition_group_ids:
|
||||
return group_id
|
||||
|
||||
# TODO: what's the atomicity of the get above and the save here? If it's not in a
|
||||
# single transaction, we could get a situation where the user sees one state in one
|
||||
# thread, but then that decision gets overwritten--low probability, but still bad.
|
||||
|
||||
# (If it is truly atomic, we should be fine--if one process is in the
|
||||
# process of finding no group and making one, the other should block till it
|
||||
# appears. HOWEVER, if we allow reads by the second one while the first
|
||||
# process runs the transaction, we have a problem again: could read empty,
|
||||
# have the first transaction finish, and pick a different group in a
|
||||
# different process.)
|
||||
|
||||
# If a group id hasn't yet been saved, or the saved group id is invalid,
|
||||
# we need to pick one, save it, then return it
|
||||
|
||||
# TODO: had a discussion in arch council about making randomization more
|
||||
# deterministic (e.g. some hash). Could do that, but need to be careful not
|
||||
# to introduce correlation between users or bias in generation.
|
||||
|
||||
# See note above for explanation of local_random()
|
||||
group = self.random.choice(user_partition.groups)
|
||||
self._user_tags_service.set_tag(scope, key, group.id)
|
||||
|
||||
# emit event for analytics
|
||||
# FYI - context is always user ID that is logged in, NOT the user id that is
|
||||
# being operated on. If instructor can move user explicitly, then we should
|
||||
# put in event_info the user id that is being operated on.
|
||||
event_info = {
|
||||
'group_id': group.id,
|
||||
'group_name': group.name,
|
||||
'partition_id': user_partition.id,
|
||||
'partition_name': user_partition.name
|
||||
}
|
||||
# TODO: Use the XBlock publish api instead
|
||||
self._track_function('xmodule.partitions.assigned_user_to_partition', event_info)
|
||||
|
||||
return group.id
|
||||
user = self.runtime.get_real_user(self.runtime.anonymous_student_id)
|
||||
return user_partition.scheme.get_group_for_user(
|
||||
self.runtime.course_id, user, user_partition, track_function=self._track_function
|
||||
)
|
||||
|
||||
@@ -1,286 +0,0 @@
|
||||
"""
|
||||
Test the partitions and partitions service
|
||||
|
||||
"""
|
||||
|
||||
from collections import defaultdict
|
||||
from unittest import TestCase
|
||||
from mock import Mock
|
||||
|
||||
from xmodule.partitions.partitions import Group, UserPartition
|
||||
from xmodule.partitions.partitions_service import PartitionService
|
||||
|
||||
|
||||
class TestGroup(TestCase):
|
||||
"""Test constructing groups"""
|
||||
def test_construct(self):
|
||||
test_id = 10
|
||||
name = "Grendel"
|
||||
group = Group(test_id, name)
|
||||
self.assertEqual(group.id, test_id)
|
||||
self.assertEqual(group.name, name)
|
||||
|
||||
def test_string_id(self):
|
||||
test_id = "10"
|
||||
name = "Grendel"
|
||||
group = Group(test_id, name)
|
||||
self.assertEqual(group.id, 10)
|
||||
|
||||
def test_to_json(self):
|
||||
test_id = 10
|
||||
name = "Grendel"
|
||||
group = Group(test_id, name)
|
||||
jsonified = group.to_json()
|
||||
act_jsonified = {
|
||||
"id": test_id,
|
||||
"name": name,
|
||||
"version": group.VERSION
|
||||
}
|
||||
self.assertEqual(jsonified, act_jsonified)
|
||||
|
||||
def test_from_json(self):
|
||||
test_id = 5
|
||||
name = "Grendel"
|
||||
jsonified = {
|
||||
"id": test_id,
|
||||
"name": name,
|
||||
"version": Group.VERSION
|
||||
}
|
||||
group = Group.from_json(jsonified)
|
||||
self.assertEqual(group.id, test_id)
|
||||
self.assertEqual(group.name, name)
|
||||
|
||||
def test_from_json_broken(self):
|
||||
test_id = 5
|
||||
name = "Grendel"
|
||||
# Bad version
|
||||
jsonified = {
|
||||
"id": test_id,
|
||||
"name": name,
|
||||
"version": 9001
|
||||
}
|
||||
with self.assertRaisesRegexp(TypeError, "has unexpected version"):
|
||||
group = Group.from_json(jsonified)
|
||||
|
||||
# Missing key "id"
|
||||
jsonified = {
|
||||
"name": name,
|
||||
"version": Group.VERSION
|
||||
}
|
||||
with self.assertRaisesRegexp(TypeError, "missing value key 'id'"):
|
||||
group = Group.from_json(jsonified)
|
||||
|
||||
# Has extra key - should not be a problem
|
||||
jsonified = {
|
||||
"id": test_id,
|
||||
"name": name,
|
||||
"version": Group.VERSION,
|
||||
"programmer": "Cale"
|
||||
}
|
||||
group = Group.from_json(jsonified)
|
||||
self.assertNotIn("programmer", group.to_json())
|
||||
|
||||
|
||||
class TestUserPartition(TestCase):
|
||||
"""Test constructing UserPartitions"""
|
||||
def test_construct(self):
|
||||
groups = [Group(0, 'Group 1'), Group(1, 'Group 2')]
|
||||
user_partition = UserPartition(0, 'Test Partition', 'for testing purposes', groups)
|
||||
self.assertEqual(user_partition.id, 0)
|
||||
self.assertEqual(user_partition.name, "Test Partition")
|
||||
self.assertEqual(user_partition.description, "for testing purposes")
|
||||
self.assertEqual(user_partition.groups, groups)
|
||||
|
||||
def test_string_id(self):
|
||||
groups = [Group(0, 'Group 1'), Group(1, 'Group 2')]
|
||||
user_partition = UserPartition("70", 'Test Partition', 'for testing purposes', groups)
|
||||
self.assertEqual(user_partition.id, 70)
|
||||
|
||||
def test_to_json(self):
|
||||
groups = [Group(0, 'Group 1'), Group(1, 'Group 2')]
|
||||
upid = 0
|
||||
upname = "Test Partition"
|
||||
updesc = "for testing purposes"
|
||||
user_partition = UserPartition(upid, upname, updesc, groups)
|
||||
|
||||
jsonified = user_partition.to_json()
|
||||
act_jsonified = {
|
||||
"id": upid,
|
||||
"name": upname,
|
||||
"description": updesc,
|
||||
"groups": [group.to_json() for group in groups],
|
||||
"version": user_partition.VERSION
|
||||
}
|
||||
self.assertEqual(jsonified, act_jsonified)
|
||||
|
||||
def test_from_json(self):
|
||||
groups = [Group(0, 'Group 1'), Group(1, 'Group 2')]
|
||||
upid = 1
|
||||
upname = "Test Partition"
|
||||
updesc = "For Testing Purposes"
|
||||
|
||||
jsonified = {
|
||||
"id": upid,
|
||||
"name": upname,
|
||||
"description": updesc,
|
||||
"groups": [group.to_json() for group in groups],
|
||||
"version": UserPartition.VERSION
|
||||
}
|
||||
user_partition = UserPartition.from_json(jsonified)
|
||||
self.assertEqual(user_partition.id, upid)
|
||||
self.assertEqual(user_partition.name, upname)
|
||||
self.assertEqual(user_partition.description, updesc)
|
||||
for act_group in user_partition.groups:
|
||||
self.assertIn(act_group.id, [0, 1])
|
||||
exp_group = groups[act_group.id]
|
||||
self.assertEqual(exp_group.id, act_group.id)
|
||||
self.assertEqual(exp_group.name, act_group.name)
|
||||
|
||||
def test_from_json_broken(self):
|
||||
groups = [Group(0, 'Group 1'), Group(1, 'Group 2')]
|
||||
upid = 1
|
||||
upname = "Test Partition"
|
||||
updesc = "For Testing Purposes"
|
||||
|
||||
# Missing field
|
||||
jsonified = {
|
||||
"name": upname,
|
||||
"description": updesc,
|
||||
"groups": [group.to_json() for group in groups],
|
||||
"version": UserPartition.VERSION
|
||||
}
|
||||
with self.assertRaisesRegexp(TypeError, "missing value key 'id'"):
|
||||
user_partition = UserPartition.from_json(jsonified)
|
||||
|
||||
# Wrong version (it's over 9000!)
|
||||
jsonified = {
|
||||
'id': upid,
|
||||
"name": upname,
|
||||
"description": updesc,
|
||||
"groups": [group.to_json() for group in groups],
|
||||
"version": 9001
|
||||
}
|
||||
with self.assertRaisesRegexp(TypeError, "has unexpected version"):
|
||||
user_partition = UserPartition.from_json(jsonified)
|
||||
|
||||
# Has extra key - should not be a problem
|
||||
jsonified = {
|
||||
'id': upid,
|
||||
"name": upname,
|
||||
"description": updesc,
|
||||
"groups": [group.to_json() for group in groups],
|
||||
"version": UserPartition.VERSION,
|
||||
"programmer": "Cale"
|
||||
}
|
||||
user_partition = UserPartition.from_json(jsonified)
|
||||
self.assertNotIn("programmer", user_partition.to_json())
|
||||
|
||||
|
||||
class StaticPartitionService(PartitionService):
|
||||
"""
|
||||
Mock PartitionService for testing.
|
||||
"""
|
||||
def __init__(self, partitions, **kwargs):
|
||||
super(StaticPartitionService, self).__init__(**kwargs)
|
||||
self._partitions = partitions
|
||||
|
||||
@property
|
||||
def course_partitions(self):
|
||||
return self._partitions
|
||||
|
||||
|
||||
class MemoryUserTagsService(object):
|
||||
"""
|
||||
An implementation of a user_tags XBlock service that
|
||||
uses an in-memory dictionary for storage
|
||||
"""
|
||||
COURSE_SCOPE = 'course'
|
||||
|
||||
def __init__(self):
|
||||
self._tags = defaultdict(dict)
|
||||
|
||||
def get_tag(self, scope, key):
|
||||
"""Sets the value of ``key`` to ``value``"""
|
||||
print 'GETTING', scope, key, self._tags
|
||||
return self._tags[scope].get(key)
|
||||
|
||||
def set_tag(self, scope, key, value):
|
||||
"""Gets the value of ``key``"""
|
||||
self._tags[scope][key] = value
|
||||
print 'SET', scope, key, value, self._tags
|
||||
|
||||
|
||||
class TestPartitionsService(TestCase):
|
||||
"""
|
||||
Test getting a user's group out of a partition
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
groups = [Group(0, 'Group 1'), Group(1, 'Group 2')]
|
||||
self.partition_id = 0
|
||||
|
||||
self.user_tags_service = MemoryUserTagsService()
|
||||
|
||||
user_partition = UserPartition(self.partition_id, 'Test Partition', 'for testing purposes', groups)
|
||||
self.partitions_service = StaticPartitionService(
|
||||
[user_partition],
|
||||
user_tags_service=self.user_tags_service,
|
||||
course_id=Mock(),
|
||||
track_function=Mock()
|
||||
)
|
||||
|
||||
def test_get_user_group_for_partition(self):
|
||||
# get a group assigned to the user
|
||||
group1 = self.partitions_service.get_user_group_for_partition(self.partition_id)
|
||||
|
||||
# make sure we get the same group back out if we try a second time
|
||||
group2 = self.partitions_service.get_user_group_for_partition(self.partition_id)
|
||||
|
||||
self.assertEqual(group1, group2)
|
||||
|
||||
# test that we error if given an invalid partition id
|
||||
with self.assertRaises(ValueError):
|
||||
self.partitions_service.get_user_group_for_partition(3)
|
||||
|
||||
def test_user_in_deleted_group(self):
|
||||
# get a group assigned to the user - should be group 0 or 1
|
||||
old_group = self.partitions_service.get_user_group_for_partition(self.partition_id)
|
||||
self.assertIn(old_group, [0, 1])
|
||||
|
||||
# Change the group definitions! No more group 0 or 1
|
||||
groups = [Group(3, 'Group 3'), Group(4, 'Group 4')]
|
||||
user_partition = UserPartition(self.partition_id, 'Test Partition', 'for testing purposes', groups)
|
||||
self.partitions_service = StaticPartitionService(
|
||||
[user_partition],
|
||||
user_tags_service=self.user_tags_service,
|
||||
course_id=Mock(),
|
||||
track_function=Mock()
|
||||
)
|
||||
|
||||
# Now, get a new group using the same call - should be 3 or 4
|
||||
new_group = self.partitions_service.get_user_group_for_partition(self.partition_id)
|
||||
self.assertIn(new_group, [3, 4])
|
||||
|
||||
# We should get the same group over multiple calls
|
||||
new_group_2 = self.partitions_service.get_user_group_for_partition(self.partition_id)
|
||||
self.assertEqual(new_group, new_group_2)
|
||||
|
||||
def test_change_group_name(self):
|
||||
# Changing the name of the group shouldn't affect anything
|
||||
# get a group assigned to the user - should be group 0 or 1
|
||||
old_group = self.partitions_service.get_user_group_for_partition(self.partition_id)
|
||||
self.assertIn(old_group, [0, 1])
|
||||
|
||||
# Change the group names
|
||||
groups = [Group(0, 'Group 0'), Group(1, 'Group 1')]
|
||||
user_partition = UserPartition(self.partition_id, 'Test Partition', 'for testing purposes', groups)
|
||||
self.partitions_service = StaticPartitionService(
|
||||
[user_partition],
|
||||
user_tags_service=self.user_tags_service,
|
||||
course_id=Mock(),
|
||||
track_function=Mock()
|
||||
)
|
||||
|
||||
# Now, get a new group using the same call
|
||||
new_group = self.partitions_service.get_user_group_for_partition(self.partition_id)
|
||||
self.assertEqual(old_group, new_group)
|
||||
302
common/lib/xmodule/xmodule/partitions/tests/test_partitions.py
Normal file
302
common/lib/xmodule/xmodule/partitions/tests/test_partitions.py
Normal file
@@ -0,0 +1,302 @@
|
||||
"""
|
||||
Test the partitions and partitions service
|
||||
|
||||
"""
|
||||
|
||||
from unittest import TestCase
|
||||
from mock import Mock
|
||||
|
||||
from stevedore.extension import Extension, ExtensionManager
|
||||
from xmodule.partitions.partitions import Group, UserPartition, UserPartitionError, USER_PARTITION_SCHEME_NAMESPACE
|
||||
from xmodule.partitions.partitions_service import PartitionService
|
||||
from xmodule.tests import get_test_system
|
||||
|
||||
|
||||
class TestGroup(TestCase):
|
||||
"""Test constructing groups"""
|
||||
def test_construct(self):
|
||||
test_id = 10
|
||||
name = "Grendel"
|
||||
group = Group(test_id, name)
|
||||
self.assertEqual(group.id, test_id) # pylint: disable=no-member
|
||||
self.assertEqual(group.name, name)
|
||||
|
||||
def test_string_id(self):
|
||||
test_id = "10"
|
||||
name = "Grendel"
|
||||
group = Group(test_id, name)
|
||||
self.assertEqual(group.id, 10) # pylint: disable=no-member
|
||||
|
||||
def test_to_json(self):
|
||||
test_id = 10
|
||||
name = "Grendel"
|
||||
group = Group(test_id, name)
|
||||
jsonified = group.to_json()
|
||||
act_jsonified = {
|
||||
"id": test_id,
|
||||
"name": name,
|
||||
"version": group.VERSION
|
||||
}
|
||||
self.assertEqual(jsonified, act_jsonified)
|
||||
|
||||
def test_from_json(self):
|
||||
test_id = 5
|
||||
name = "Grendel"
|
||||
jsonified = {
|
||||
"id": test_id,
|
||||
"name": name,
|
||||
"version": Group.VERSION
|
||||
}
|
||||
group = Group.from_json(jsonified)
|
||||
self.assertEqual(group.id, test_id) # pylint: disable=no-member
|
||||
self.assertEqual(group.name, name)
|
||||
|
||||
def test_from_json_broken(self):
|
||||
test_id = 5
|
||||
name = "Grendel"
|
||||
# Bad version
|
||||
jsonified = {
|
||||
"id": test_id,
|
||||
"name": name,
|
||||
"version": 9001
|
||||
}
|
||||
with self.assertRaisesRegexp(TypeError, "has unexpected version"):
|
||||
Group.from_json(jsonified)
|
||||
|
||||
# Missing key "id"
|
||||
jsonified = {
|
||||
"name": name,
|
||||
"version": Group.VERSION
|
||||
}
|
||||
with self.assertRaisesRegexp(TypeError, "missing value key 'id'"):
|
||||
Group.from_json(jsonified)
|
||||
|
||||
# Has extra key - should not be a problem
|
||||
jsonified = {
|
||||
"id": test_id,
|
||||
"name": name,
|
||||
"version": Group.VERSION,
|
||||
"programmer": "Cale"
|
||||
}
|
||||
group = Group.from_json(jsonified)
|
||||
self.assertNotIn("programmer", group.to_json())
|
||||
|
||||
|
||||
class MockUserPartitionScheme(object):
|
||||
"""
|
||||
Mock user partition scheme
|
||||
"""
|
||||
def __init__(self, name="mock", current_group=None, **kwargs):
|
||||
super(MockUserPartitionScheme, self).__init__(**kwargs)
|
||||
self.name = name
|
||||
self.current_group = current_group
|
||||
|
||||
def get_group_for_user(self, course_id, user, user_partition, track_function=None): # pylint: disable=unused-argument
|
||||
"""
|
||||
Returns the current group if set, else the first group from the specified user partition.
|
||||
"""
|
||||
if self.current_group:
|
||||
return self.current_group
|
||||
groups = user_partition.groups
|
||||
if not groups or len(groups) == 0:
|
||||
return None
|
||||
return groups[0]
|
||||
|
||||
|
||||
class PartitionTestCase(TestCase):
|
||||
"""Base class for test cases that require partitions"""
|
||||
TEST_ID = 0
|
||||
TEST_NAME = "Mock Partition"
|
||||
TEST_DESCRIPTION = "for testing purposes"
|
||||
TEST_GROUPS = [Group(0, 'Group 1'), Group(1, 'Group 2')]
|
||||
TEST_SCHEME_NAME = "mock"
|
||||
|
||||
def setUp(self):
|
||||
# Set up two user partition schemes: mock and random
|
||||
extensions = [
|
||||
Extension(
|
||||
self.TEST_SCHEME_NAME, USER_PARTITION_SCHEME_NAMESPACE,
|
||||
MockUserPartitionScheme(self.TEST_SCHEME_NAME), None
|
||||
),
|
||||
Extension(
|
||||
"random", USER_PARTITION_SCHEME_NAMESPACE, MockUserPartitionScheme("random"), None
|
||||
),
|
||||
]
|
||||
UserPartition.scheme_extensions = ExtensionManager.make_test_instance(
|
||||
extensions, namespace=USER_PARTITION_SCHEME_NAMESPACE
|
||||
)
|
||||
|
||||
# Create a test partition
|
||||
self.user_partition = UserPartition(
|
||||
self.TEST_ID,
|
||||
self.TEST_NAME,
|
||||
self.TEST_DESCRIPTION,
|
||||
self.TEST_GROUPS,
|
||||
extensions[0].plugin
|
||||
)
|
||||
|
||||
|
||||
class TestUserPartition(PartitionTestCase):
|
||||
"""Test constructing UserPartitions"""
|
||||
|
||||
def test_construct(self):
|
||||
user_partition = UserPartition(
|
||||
self.TEST_ID, self.TEST_NAME, self.TEST_DESCRIPTION, self.TEST_GROUPS, MockUserPartitionScheme()
|
||||
)
|
||||
self.assertEqual(user_partition.id, self.TEST_ID) # pylint: disable=no-member
|
||||
self.assertEqual(user_partition.name, self.TEST_NAME)
|
||||
self.assertEqual(user_partition.description, self.TEST_DESCRIPTION) # pylint: disable=no-member
|
||||
self.assertEqual(user_partition.groups, self.TEST_GROUPS) # pylint: disable=no-member
|
||||
self.assertEquals(user_partition.scheme.name, self.TEST_SCHEME_NAME) # pylint: disable=no-member
|
||||
|
||||
def test_string_id(self):
|
||||
user_partition = UserPartition(
|
||||
"70", self.TEST_NAME, self.TEST_DESCRIPTION, self.TEST_GROUPS
|
||||
)
|
||||
self.assertEqual(user_partition.id, 70) # pylint: disable=no-member
|
||||
|
||||
def test_to_json(self):
|
||||
jsonified = self.user_partition.to_json()
|
||||
act_jsonified = {
|
||||
"id": self.TEST_ID,
|
||||
"name": self.TEST_NAME,
|
||||
"description": self.TEST_DESCRIPTION,
|
||||
"groups": [group.to_json() for group in self.TEST_GROUPS],
|
||||
"version": self.user_partition.VERSION,
|
||||
"scheme": self.TEST_SCHEME_NAME
|
||||
}
|
||||
self.assertEqual(jsonified, act_jsonified)
|
||||
|
||||
def test_from_json(self):
|
||||
jsonified = {
|
||||
"id": self.TEST_ID,
|
||||
"name": self.TEST_NAME,
|
||||
"description": self.TEST_DESCRIPTION,
|
||||
"groups": [group.to_json() for group in self.TEST_GROUPS],
|
||||
"version": UserPartition.VERSION,
|
||||
"scheme": "mock",
|
||||
}
|
||||
user_partition = UserPartition.from_json(jsonified)
|
||||
self.assertEqual(user_partition.id, self.TEST_ID) # pylint: disable=no-member
|
||||
self.assertEqual(user_partition.name, self.TEST_NAME) # pylint: disable=no-member
|
||||
self.assertEqual(user_partition.description, self.TEST_DESCRIPTION) # pylint: disable=no-member
|
||||
for act_group in user_partition.groups: # pylint: disable=no-member
|
||||
self.assertIn(act_group.id, [0, 1])
|
||||
exp_group = self.TEST_GROUPS[act_group.id]
|
||||
self.assertEqual(exp_group.id, act_group.id)
|
||||
self.assertEqual(exp_group.name, act_group.name)
|
||||
|
||||
def test_version_upgrade(self):
|
||||
# Version 1 partitions did not have a scheme specified
|
||||
jsonified = {
|
||||
"id": self.TEST_ID,
|
||||
"name": self.TEST_NAME,
|
||||
"description": self.TEST_DESCRIPTION,
|
||||
"groups": [group.to_json() for group in self.TEST_GROUPS],
|
||||
"version": 1,
|
||||
}
|
||||
user_partition = UserPartition.from_json(jsonified)
|
||||
self.assertEqual(user_partition.scheme.name, "random") # pylint: disable=no-member
|
||||
|
||||
def test_from_json_broken(self):
|
||||
# Missing field
|
||||
jsonified = {
|
||||
"name": self.TEST_NAME,
|
||||
"description": self.TEST_DESCRIPTION,
|
||||
"groups": [group.to_json() for group in self.TEST_GROUPS],
|
||||
"version": UserPartition.VERSION,
|
||||
"scheme": self.TEST_SCHEME_NAME,
|
||||
}
|
||||
with self.assertRaisesRegexp(TypeError, "missing value key 'id'"):
|
||||
UserPartition.from_json(jsonified)
|
||||
|
||||
# Missing scheme
|
||||
jsonified = {
|
||||
'id': self.TEST_ID,
|
||||
"name": self.TEST_NAME,
|
||||
"description": self.TEST_DESCRIPTION,
|
||||
"groups": [group.to_json() for group in self.TEST_GROUPS],
|
||||
"version": UserPartition.VERSION,
|
||||
}
|
||||
with self.assertRaisesRegexp(TypeError, "missing value key 'scheme'"):
|
||||
UserPartition.from_json(jsonified)
|
||||
|
||||
# Invalid scheme
|
||||
jsonified = {
|
||||
'id': self.TEST_ID,
|
||||
"name": self.TEST_NAME,
|
||||
"description": self.TEST_DESCRIPTION,
|
||||
"groups": [group.to_json() for group in self.TEST_GROUPS],
|
||||
"version": UserPartition.VERSION,
|
||||
"scheme": "no_such_scheme",
|
||||
}
|
||||
with self.assertRaisesRegexp(UserPartitionError, "Unrecognized scheme"):
|
||||
UserPartition.from_json(jsonified)
|
||||
|
||||
# Wrong version (it's over 9000!)
|
||||
# Wrong version (it's over 9000!)
|
||||
jsonified = {
|
||||
'id': self.TEST_ID,
|
||||
"name": self.TEST_NAME,
|
||||
"description": self.TEST_DESCRIPTION,
|
||||
"groups": [group.to_json() for group in self.TEST_GROUPS],
|
||||
"version": 9001,
|
||||
"scheme": self.TEST_SCHEME_NAME,
|
||||
}
|
||||
with self.assertRaisesRegexp(TypeError, "has unexpected version"):
|
||||
UserPartition.from_json(jsonified)
|
||||
|
||||
# Has extra key - should not be a problem
|
||||
jsonified = {
|
||||
'id': self.TEST_ID,
|
||||
"name": self.TEST_NAME,
|
||||
"description": self.TEST_DESCRIPTION,
|
||||
"groups": [group.to_json() for group in self.TEST_GROUPS],
|
||||
"version": UserPartition.VERSION,
|
||||
"scheme": "mock",
|
||||
"programmer": "Cale",
|
||||
}
|
||||
user_partition = UserPartition.from_json(jsonified)
|
||||
self.assertNotIn("programmer", user_partition.to_json())
|
||||
|
||||
|
||||
class StaticPartitionService(PartitionService):
|
||||
"""
|
||||
Mock PartitionService for testing.
|
||||
"""
|
||||
def __init__(self, partitions, **kwargs):
|
||||
super(StaticPartitionService, self).__init__(**kwargs)
|
||||
self._partitions = partitions
|
||||
|
||||
@property
|
||||
def course_partitions(self):
|
||||
return self._partitions
|
||||
|
||||
|
||||
class TestPartitionService(PartitionTestCase):
|
||||
"""
|
||||
Test getting a user's group out of a partition
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(TestPartitionService, self).setUp()
|
||||
self.partition_service = StaticPartitionService(
|
||||
[self.user_partition],
|
||||
runtime=get_test_system(),
|
||||
track_function=Mock()
|
||||
)
|
||||
|
||||
def test_get_user_group_id_for_partition(self):
|
||||
# assign the first group to be returned
|
||||
user_partition_id = self.user_partition.id # pylint: disable=no-member
|
||||
groups = self.user_partition.groups # pylint: disable=no-member
|
||||
self.user_partition.scheme.current_group = groups[0] # pylint: disable=no-member
|
||||
|
||||
# get a group assigned to the user
|
||||
group1_id = self.partition_service.get_user_group_id_for_partition(user_partition_id)
|
||||
self.assertEqual(group1_id, groups[0].id) # pylint: disable=no-member
|
||||
|
||||
# switch to the second group and verify that it is returned for the user
|
||||
self.user_partition.scheme.current_group = groups[1] # pylint: disable=no-member
|
||||
group2_id = self.partition_service.get_user_group_id_for_partition(user_partition_id)
|
||||
self.assertEqual(group2_id, groups[1].id) # pylint: disable=no-member
|
||||
@@ -80,10 +80,6 @@ class SplitTestFields(object):
|
||||
# location needs to actually match one of the children of this
|
||||
# Block. (expected invariant that we'll need to test, and handle
|
||||
# authoring tools that mess this up)
|
||||
|
||||
# TODO: is there a way to add some validation around this, to
|
||||
# be run on course load or in studio or ....
|
||||
|
||||
group_id_to_child = ReferenceValueDict(
|
||||
help=_("Which child module students in a particular group_id should see"),
|
||||
scope=Scope.content
|
||||
@@ -188,7 +184,7 @@ class SplitTestModule(SplitTestFields, XModule, StudioEditableModule):
|
||||
partitions_service = self.runtime.service(self, 'partitions')
|
||||
if not partitions_service:
|
||||
return None
|
||||
return partitions_service.get_user_group_for_partition(self.user_partition_id)
|
||||
return partitions_service.get_user_group_id_for_partition(self.user_partition_id)
|
||||
|
||||
@property
|
||||
def is_configured(self):
|
||||
|
||||
@@ -79,13 +79,15 @@ def get_test_system(course_id=SlashSeparatedCourseKey('org', 'course', 'run')):
|
||||
where `my_render_func` is a function of the form my_render_func(template, context).
|
||||
|
||||
"""
|
||||
user = Mock(is_staff=False)
|
||||
return TestModuleSystem(
|
||||
static_url='/static',
|
||||
track_function=Mock(),
|
||||
get_module=Mock(),
|
||||
render_template=mock_render_template,
|
||||
replace_urls=str,
|
||||
user=Mock(is_staff=False),
|
||||
user=user,
|
||||
get_real_user=lambda(__): user,
|
||||
filestore=Mock(),
|
||||
debug=True,
|
||||
hostname="edx.org",
|
||||
|
||||
@@ -6,6 +6,7 @@ import lxml
|
||||
from mock import Mock, patch
|
||||
from fs.memoryfs import MemoryFS
|
||||
|
||||
from xmodule.partitions.tests.test_partitions import StaticPartitionService, PartitionTestCase, MockUserPartitionScheme
|
||||
from xmodule.tests.xml import factories as xml
|
||||
from xmodule.tests.xml import XModuleXmlImportTest
|
||||
from xmodule.tests import get_test_system
|
||||
@@ -13,7 +14,6 @@ from xmodule.x_module import AUTHOR_VIEW, STUDENT_VIEW
|
||||
from xmodule.validation import StudioValidationMessage
|
||||
from xmodule.split_test_module import SplitTestDescriptor, SplitTestFields
|
||||
from xmodule.partitions.partitions import Group, UserPartition
|
||||
from xmodule.partitions.test_partitions import StaticPartitionService, MemoryUserTagsService
|
||||
|
||||
|
||||
class SplitTestModuleFactory(xml.XmlImportFactory):
|
||||
@@ -23,11 +23,12 @@ class SplitTestModuleFactory(xml.XmlImportFactory):
|
||||
tag = 'split_test'
|
||||
|
||||
|
||||
class SplitTestModuleTest(XModuleXmlImportTest):
|
||||
class SplitTestModuleTest(XModuleXmlImportTest, PartitionTestCase):
|
||||
"""
|
||||
Base class for all split_module tests.
|
||||
"""
|
||||
def setUp(self):
|
||||
super(SplitTestModuleTest, self).setUp()
|
||||
self.course_id = 'test_org/test_course_number/test_run'
|
||||
# construct module
|
||||
course = xml.CourseFactory.build()
|
||||
@@ -57,16 +58,16 @@ class SplitTestModuleTest(XModuleXmlImportTest):
|
||||
self.module_system.descriptor_system = self.course.runtime
|
||||
self.course.runtime.export_fs = MemoryFS()
|
||||
|
||||
self.tags_service = MemoryUserTagsService()
|
||||
self.module_system._services['user_tags'] = self.tags_service # pylint: disable=protected-access
|
||||
|
||||
self.partitions_service = StaticPartitionService(
|
||||
[
|
||||
UserPartition(0, 'first_partition', 'First Partition', [Group("0", 'alpha'), Group("1", 'beta')]),
|
||||
UserPartition(1, 'second_partition', 'Second Partition', [Group("0", 'abel'), Group("1", 'baker'), Group("2", 'charlie')])
|
||||
self.user_partition,
|
||||
UserPartition(
|
||||
1, 'second_partition', 'Second Partition',
|
||||
[Group("0", 'abel'), Group("1", 'baker'), Group("2", 'charlie')],
|
||||
MockUserPartitionScheme()
|
||||
)
|
||||
],
|
||||
user_tags_service=self.tags_service,
|
||||
course_id=self.course.id,
|
||||
runtime=self.module_system,
|
||||
track_function=Mock(name='track_function'),
|
||||
)
|
||||
self.module_system._services['partitions'] = self.partitions_service # pylint: disable=protected-access
|
||||
@@ -81,50 +82,28 @@ class SplitTestModuleLMSTest(SplitTestModuleTest):
|
||||
Test the split test module
|
||||
"""
|
||||
|
||||
@ddt.data(('0', 'split_test_cond0'), ('1', 'split_test_cond1'))
|
||||
@ddt.data((0, 'split_test_cond0'), (1, 'split_test_cond1'))
|
||||
@ddt.unpack
|
||||
def test_child(self, user_tag, child_url_name):
|
||||
self.tags_service.set_tag(
|
||||
self.tags_service.COURSE_SCOPE,
|
||||
'xblock.partition_service.partition_0',
|
||||
user_tag
|
||||
)
|
||||
|
||||
self.user_partition.scheme.current_group = self.user_partition.groups[user_tag] # pylint: disable=no-member
|
||||
self.assertEquals(self.split_test_module.child_descriptor.url_name, child_url_name)
|
||||
|
||||
@ddt.data(('0',), ('1',))
|
||||
@ddt.unpack
|
||||
def test_child_old_tag_value(self, _user_tag):
|
||||
# If user_tag has a stale value, we should still get back a valid child url
|
||||
self.tags_service.set_tag(
|
||||
self.tags_service.COURSE_SCOPE,
|
||||
'xblock.partition_service.partition_0',
|
||||
'2'
|
||||
)
|
||||
|
||||
self.assertIn(self.split_test_module.child_descriptor.url_name, ['split_test_cond0', 'split_test_cond1'])
|
||||
|
||||
@ddt.data(('0', 'HTML FOR GROUP 0'), ('1', 'HTML FOR GROUP 1'))
|
||||
@ddt.data((0, 'HTML FOR GROUP 0'), (1, 'HTML FOR GROUP 1'))
|
||||
@ddt.unpack
|
||||
def test_get_html(self, user_tag, child_content):
|
||||
self.tags_service.set_tag(
|
||||
self.tags_service.COURSE_SCOPE,
|
||||
'xblock.partition_service.partition_0',
|
||||
user_tag
|
||||
)
|
||||
|
||||
self.user_partition.scheme.current_group = self.user_partition.groups[user_tag] # pylint: disable=no-member
|
||||
self.assertIn(
|
||||
child_content,
|
||||
self.module_system.render(self.split_test_module, STUDENT_VIEW).content
|
||||
)
|
||||
|
||||
@ddt.data(('0',), ('1',))
|
||||
@ddt.data((0,), (1,))
|
||||
@ddt.unpack
|
||||
def test_child_missing_tag_value(self, _user_tag):
|
||||
# If user_tag has a missing value, we should still get back a valid child url
|
||||
self.assertIn(self.split_test_module.child_descriptor.url_name, ['split_test_cond0', 'split_test_cond1'])
|
||||
|
||||
@ddt.data(('100',), ('200',), ('300',), ('400',), ('500',), ('600',), ('700',), ('800',), ('900',), ('1000',))
|
||||
@ddt.data((100,), (200,), (300,), (400,), (500,), (600,), (700,), (800,), (900,), (1000,))
|
||||
@ddt.unpack
|
||||
def test_child_persist_new_tag_value_when_tag_missing(self, _user_tag):
|
||||
# If a user_tag has a missing value, a group should be saved/persisted for that user.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[run]
|
||||
data_file = reports/bok_choy/.coverage
|
||||
source = lms, cms, common/djangoapps, common/lib
|
||||
omit = lms/envs/*, cms/envs/*, common/djangoapps/terrain/*, common/djangoapps/*/migrations/*, */test*, */management/*, */urls*, */wsgi*
|
||||
omit = lms/envs/*, cms/envs/*, common/djangoapps/terrain/*, common/djangoapps/*/migrations/*, openedx/core/djangoapps/*/migrations/*, */test*, */management/*, */urls*, */wsgi*
|
||||
parallel = True
|
||||
|
||||
[report]
|
||||
|
||||
@@ -9,6 +9,7 @@ from nose.plugins.attrib import attr
|
||||
from selenium.webdriver.support.ui import Select
|
||||
|
||||
from xmodule.partitions.partitions import Group, UserPartition
|
||||
from xmodule.partitions.tests.test_partitions import MockUserPartitionScheme
|
||||
from bok_choy.promise import Promise, EmptyPromise
|
||||
|
||||
from ...fixtures.course import XBlockFixtureDesc
|
||||
@@ -30,6 +31,15 @@ class SplitTestMixin(object):
|
||||
"""
|
||||
Mixin that contains useful methods for split_test module testing.
|
||||
"""
|
||||
@staticmethod
|
||||
def create_user_partition_json(partition_id, name, description, groups):
|
||||
"""
|
||||
Helper method to create user partition JSON.
|
||||
"""
|
||||
return UserPartition(
|
||||
partition_id, name, description, groups, MockUserPartitionScheme("random")
|
||||
).to_json()
|
||||
|
||||
def verify_groups(self, container, active_groups, inactive_groups, verify_missing_groups_not_present=True):
|
||||
"""
|
||||
Check that the groups appear and are correctly categorized as to active and inactive.
|
||||
@@ -80,8 +90,18 @@ class SplitTest(ContainerBase, SplitTestMixin):
|
||||
self.course_fixture._update_xblock(self.course_fixture._course_location, {
|
||||
"metadata": {
|
||||
u"user_partitions": [
|
||||
UserPartition(0, 'Configuration alpha,beta', 'first', [Group("0", 'alpha'), Group("1", 'beta')]).to_json(),
|
||||
UserPartition(1, 'Configuration 0,1,2', 'second', [Group("0", 'Group 0'), Group("1", 'Group 1'), Group("2", 'Group 2')]).to_json()
|
||||
self.create_user_partition_json(
|
||||
0,
|
||||
'Configuration alpha,beta',
|
||||
'first',
|
||||
[Group("0", 'alpha'), Group("1", 'beta')]
|
||||
),
|
||||
self.create_user_partition_json(
|
||||
1,
|
||||
'Configuration 0,1,2',
|
||||
'second',
|
||||
[Group("0", 'Group 0'), Group("1", 'Group 1'), Group("2", 'Group 2')]
|
||||
),
|
||||
],
|
||||
},
|
||||
})
|
||||
@@ -124,8 +144,12 @@ class SplitTest(ContainerBase, SplitTestMixin):
|
||||
self.course_fixture._update_xblock(self.course_fixture._course_location, {
|
||||
"metadata": {
|
||||
u"user_partitions": [
|
||||
UserPartition(0, 'Configuration alpha,beta', 'first',
|
||||
[Group("0", 'alpha'), Group("2", 'gamma')]).to_json()
|
||||
self.create_user_partition_json(
|
||||
0,
|
||||
'Configuration alpha,beta',
|
||||
'first',
|
||||
[Group("0", 'alpha'), Group("2", 'gamma')]
|
||||
)
|
||||
],
|
||||
},
|
||||
})
|
||||
@@ -189,7 +213,7 @@ class SplitTest(ContainerBase, SplitTestMixin):
|
||||
@attr('shard_1')
|
||||
class SettingsMenuTest(StudioCourseTest):
|
||||
"""
|
||||
Tests that Setting menu is rendered correctly in Studio
|
||||
Tests that Settings menu is rendered correctly in Studio
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
@@ -324,7 +348,7 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
|
||||
self.course_fixture._update_xblock(self.course_fixture._course_location, {
|
||||
"metadata": {
|
||||
u"user_partitions": [
|
||||
UserPartition(0, "Name", "Description.", groups).to_json(),
|
||||
self.create_user_partition_json(0, "Name", "Description.", groups),
|
||||
],
|
||||
},
|
||||
})
|
||||
@@ -396,8 +420,18 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
|
||||
self.course_fixture._update_xblock(self.course_fixture._course_location, {
|
||||
"metadata": {
|
||||
u"user_partitions": [
|
||||
UserPartition(0, 'Name of the Group Configuration', 'Description of the group configuration.', [Group("0", 'Group 0'), Group("1", 'Group 1')]).to_json(),
|
||||
UserPartition(1, 'Name of second Group Configuration', 'Second group configuration.', [Group("0", 'Alpha'), Group("1", 'Beta'), Group("2", 'Gamma')]).to_json(),
|
||||
self.create_user_partition_json(
|
||||
0,
|
||||
'Name of the Group Configuration',
|
||||
'Description of the group configuration.',
|
||||
[Group("0", 'Group 0'), Group("1", 'Group 1')]
|
||||
),
|
||||
self.create_user_partition_json(
|
||||
1,
|
||||
'Name of second Group Configuration',
|
||||
'Second group configuration.',
|
||||
[Group("0", 'Alpha'), Group("1", 'Beta'), Group("2", 'Gamma')]
|
||||
),
|
||||
],
|
||||
},
|
||||
})
|
||||
@@ -531,7 +565,12 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
|
||||
self.course_fixture._update_xblock(self.course_fixture._course_location, {
|
||||
"metadata": {
|
||||
u"user_partitions": [
|
||||
UserPartition(0, 'Name of the Group Configuration', 'Description of the group configuration.', [Group("0", 'Group A'), Group("1", 'Group B'), Group("2", 'Group C')]).to_json(),
|
||||
self.create_user_partition_json(
|
||||
0,
|
||||
'Name of the Group Configuration',
|
||||
'Description of the group configuration.',
|
||||
[Group("0", 'Group A'), Group("1", 'Group B'), Group("2", 'Group C')]
|
||||
),
|
||||
],
|
||||
},
|
||||
})
|
||||
@@ -610,8 +649,18 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
|
||||
self.course_fixture._update_xblock(self.course_fixture._course_location, {
|
||||
"metadata": {
|
||||
u"user_partitions": [
|
||||
UserPartition(0, 'Name of the Group Configuration', 'Description of the group configuration.', [Group("0", 'Group 0'), Group("1", 'Group 1')]).to_json(),
|
||||
UserPartition(1, 'Name of second Group Configuration', 'Second group configuration.', [Group("0", 'Alpha'), Group("1", 'Beta'), Group("2", 'Gamma')]).to_json(),
|
||||
self.create_user_partition_json(
|
||||
0,
|
||||
'Name of the Group Configuration',
|
||||
'Description of the group configuration.',
|
||||
[Group("0", 'Group 0'), Group("1", 'Group 1')]
|
||||
),
|
||||
self.create_user_partition_json(
|
||||
1,
|
||||
'Name of second Group Configuration',
|
||||
'Second group configuration.',
|
||||
[Group("0", 'Alpha'), Group("1", 'Beta'), Group("2", 'Gamma')]
|
||||
),
|
||||
],
|
||||
},
|
||||
})
|
||||
@@ -696,7 +745,12 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
|
||||
self.course_fixture._update_xblock(self.course_fixture._course_location, {
|
||||
"metadata": {
|
||||
u"user_partitions": [
|
||||
UserPartition(0, "Name", "Description.", [Group("0", "Group A"), Group("1", "Group B")]).to_json(),
|
||||
self.create_user_partition_json(
|
||||
0,
|
||||
"Name",
|
||||
"Description.",
|
||||
[Group("0", "Group A"), Group("1", "Group B")]
|
||||
),
|
||||
],
|
||||
},
|
||||
})
|
||||
@@ -728,7 +782,12 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
|
||||
self.course_fixture._update_xblock(self.course_fixture._course_location, {
|
||||
"metadata": {
|
||||
u"user_partitions": [
|
||||
UserPartition(0, "Name", "Description.", [Group("0", "Group A"), Group("1", "Group B")]).to_json(),
|
||||
self.create_user_partition_json(
|
||||
0,
|
||||
"Name",
|
||||
"Description.",
|
||||
[Group("0", "Group A"), Group("1", "Group B")]
|
||||
),
|
||||
],
|
||||
},
|
||||
})
|
||||
@@ -771,8 +830,18 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
|
||||
self.course_fixture._update_xblock(self.course_fixture._course_location, {
|
||||
"metadata": {
|
||||
u"user_partitions": [
|
||||
UserPartition(0, 'Configuration 1', 'Description of the group configuration.', [Group("0", 'Group 0'), Group("1", 'Group 1')]).to_json(),
|
||||
UserPartition(1, 'Configuration 2', 'Second group configuration.', [Group("0", 'Alpha'), Group("1", 'Beta'), Group("2", 'Gamma')]).to_json()
|
||||
self.create_user_partition_json(
|
||||
0,
|
||||
'Configuration 1',
|
||||
'Description of the group configuration.',
|
||||
[Group("0", 'Group 0'), Group("1", 'Group 1')]
|
||||
),
|
||||
self.create_user_partition_json(
|
||||
1,
|
||||
'Configuration 2',
|
||||
'Second group configuration.',
|
||||
[Group("0", 'Alpha'), Group("1", 'Beta'), Group("2", 'Gamma')]
|
||||
)
|
||||
],
|
||||
},
|
||||
})
|
||||
@@ -804,7 +873,12 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
|
||||
self.course_fixture._update_xblock(self.course_fixture._course_location, {
|
||||
"metadata": {
|
||||
u"user_partitions": [
|
||||
UserPartition(0, "Name", "Description.", [Group("0", "Group A"), Group("1", "Group B")]).to_json()
|
||||
self.create_user_partition_json(
|
||||
0,
|
||||
"Name",
|
||||
"Description.",
|
||||
[Group("0", "Group A"), Group("1", "Group B")]
|
||||
)
|
||||
],
|
||||
},
|
||||
})
|
||||
@@ -840,8 +914,18 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin):
|
||||
self.course_fixture._update_xblock(self.course_fixture._course_location, {
|
||||
"metadata": {
|
||||
u"user_partitions": [
|
||||
UserPartition(0, "Name", "Description.", [Group("0", "Group A"), Group("1", "Group B")]).to_json(),
|
||||
UserPartition(1, 'Name of second Group Configuration', 'Second group configuration.', [Group("0", 'Alpha'), Group("1", 'Beta'), Group("2", 'Gamma')]).to_json(),
|
||||
self.create_user_partition_json(
|
||||
0,
|
||||
"Name",
|
||||
"Description.",
|
||||
[Group("0", "Group A"), Group("1", "Group B")]
|
||||
),
|
||||
self.create_user_partition_json(
|
||||
1,
|
||||
'Name of second Group Configuration',
|
||||
'Second group configuration.',
|
||||
[Group("0", 'Alpha'), Group("1", 'Beta'), Group("2", 'Gamma')]
|
||||
),
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -394,7 +394,7 @@ CREATE TABLE `auth_permission` (
|
||||
UNIQUE KEY `content_type_id` (`content_type_id`,`codename`),
|
||||
KEY `auth_permission_e4470c6e` (`content_type_id`),
|
||||
CONSTRAINT `content_type_id_refs_id_728de91f` FOREIGN KEY (`content_type_id`) REFERENCES `django_content_type` (`id`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=391 DEFAULT CHARSET=utf8;
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=415 DEFAULT CHARSET=utf8;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
DROP TABLE IF EXISTS `auth_registration`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
@@ -666,7 +666,7 @@ CREATE TABLE `course_groups_courseusergroup` (
|
||||
`course_id` varchar(255) NOT NULL,
|
||||
`group_type` varchar(20) NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `name` (`name`,`course_id`),
|
||||
UNIQUE KEY `course_groups_courseusergroup_name_63f7511804c52f38_uniq` (`name`,`course_id`),
|
||||
KEY `course_groups_courseusergroup_ff48d8e5` (`course_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
@@ -678,11 +678,26 @@ CREATE TABLE `course_groups_courseusergroup_users` (
|
||||
`courseusergroup_id` int(11) NOT NULL,
|
||||
`user_id` int(11) NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `courseusergroup_id` (`courseusergroup_id`,`user_id`),
|
||||
UNIQUE KEY `course_groups_courseus_courseusergroup_id_46691806058983eb_uniq` (`courseusergroup_id`,`user_id`),
|
||||
KEY `course_groups_courseusergroup_users_caee1c64` (`courseusergroup_id`),
|
||||
KEY `course_groups_courseusergroup_users_fbfc09f1` (`user_id`),
|
||||
CONSTRAINT `courseusergroup_id_refs_id_d26180aa` FOREIGN KEY (`courseusergroup_id`) REFERENCES `course_groups_courseusergroup` (`id`),
|
||||
CONSTRAINT `user_id_refs_id_bf33b47a` FOREIGN KEY (`user_id`) REFERENCES `auth_user` (`id`)
|
||||
CONSTRAINT `user_id_refs_id_779390fdbf33b47a` FOREIGN KEY (`user_id`) REFERENCES `auth_user` (`id`),
|
||||
CONSTRAINT `courseusergroup_id_refs_id_43167b76d26180aa` FOREIGN KEY (`courseusergroup_id`) REFERENCES `course_groups_courseusergroup` (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
DROP TABLE IF EXISTS `course_groups_courseusergrouppartitiongroup`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8 */;
|
||||
CREATE TABLE `course_groups_courseusergrouppartitiongroup` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`course_user_group_id` int(11) NOT NULL,
|
||||
`partition_id` int(11) NOT NULL,
|
||||
`group_id` int(11) NOT NULL,
|
||||
`created_at` datetime NOT NULL,
|
||||
`updated_at` datetime NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `course_user_group_id` (`course_user_group_id`),
|
||||
CONSTRAINT `course_user_group_id_refs_id_48edc507145383c4` FOREIGN KEY (`course_user_group_id`) REFERENCES `course_groups_courseusergroup` (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
DROP TABLE IF EXISTS `course_modes_coursemode`;
|
||||
@@ -887,8 +902,8 @@ CREATE TABLE `django_admin_log` (
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `django_admin_log_fbfc09f1` (`user_id`),
|
||||
KEY `django_admin_log_e4470c6e` (`content_type_id`),
|
||||
CONSTRAINT `content_type_id_refs_id_288599e6` FOREIGN KEY (`content_type_id`) REFERENCES `django_content_type` (`id`),
|
||||
CONSTRAINT `user_id_refs_id_c8665aa` FOREIGN KEY (`user_id`) REFERENCES `auth_user` (`id`)
|
||||
CONSTRAINT `user_id_refs_id_c8665aa` FOREIGN KEY (`user_id`) REFERENCES `auth_user` (`id`),
|
||||
CONSTRAINT `content_type_id_refs_id_288599e6` FOREIGN KEY (`content_type_id`) REFERENCES `django_content_type` (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
DROP TABLE IF EXISTS `django_comment_client_permission`;
|
||||
@@ -950,7 +965,7 @@ CREATE TABLE `django_content_type` (
|
||||
`model` varchar(100) NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `app_label` (`app_label`,`model`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=130 DEFAULT CHARSET=utf8;
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=138 DEFAULT CHARSET=utf8;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
DROP TABLE IF EXISTS `django_openid_auth_association`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
@@ -1609,6 +1624,30 @@ CREATE TABLE `shoppingcart_couponredemption` (
|
||||
CONSTRAINT `user_id_refs_id_c543b145e9b8167` FOREIGN KEY (`user_id`) REFERENCES `auth_user` (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
DROP TABLE IF EXISTS `shoppingcart_courseregcodeitem`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8 */;
|
||||
CREATE TABLE `shoppingcart_courseregcodeitem` (
|
||||
`orderitem_ptr_id` int(11) NOT NULL,
|
||||
`course_id` varchar(128) NOT NULL,
|
||||
`mode` varchar(50) NOT NULL,
|
||||
PRIMARY KEY (`orderitem_ptr_id`),
|
||||
KEY `shoppingcart_courseregcodeitem_ff48d8e5` (`course_id`),
|
||||
KEY `shoppingcart_courseregcodeitem_4160619e` (`mode`),
|
||||
CONSTRAINT `orderitem_ptr_id_refs_id_2ea4d9d5a466f07f` FOREIGN KEY (`orderitem_ptr_id`) REFERENCES `shoppingcart_orderitem` (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
DROP TABLE IF EXISTS `shoppingcart_courseregcodeitemannotation`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8 */;
|
||||
CREATE TABLE `shoppingcart_courseregcodeitemannotation` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`course_id` varchar(128) NOT NULL,
|
||||
`annotation` longtext,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `course_id` (`course_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
DROP TABLE IF EXISTS `shoppingcart_courseregistrationcode`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8 */;
|
||||
@@ -1632,6 +1671,31 @@ CREATE TABLE `shoppingcart_courseregistrationcode` (
|
||||
CONSTRAINT `invoice_id_refs_id_6e8c54da995f0ae8` FOREIGN KEY (`invoice_id`) REFERENCES `shoppingcart_invoice` (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
DROP TABLE IF EXISTS `shoppingcart_donation`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8 */;
|
||||
CREATE TABLE `shoppingcart_donation` (
|
||||
`orderitem_ptr_id` int(11) NOT NULL,
|
||||
`donation_type` varchar(32) NOT NULL,
|
||||
`course_id` varchar(255) NOT NULL,
|
||||
PRIMARY KEY (`orderitem_ptr_id`),
|
||||
KEY `shoppingcart_donation_ff48d8e5` (`course_id`),
|
||||
CONSTRAINT `orderitem_ptr_id_refs_id_28d47c0cb7138a4b` FOREIGN KEY (`orderitem_ptr_id`) REFERENCES `shoppingcart_orderitem` (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
DROP TABLE IF EXISTS `shoppingcart_donationconfiguration`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8 */;
|
||||
CREATE TABLE `shoppingcart_donationconfiguration` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`change_date` datetime NOT NULL,
|
||||
`changed_by_id` int(11) DEFAULT NULL,
|
||||
`enabled` tinyint(1) NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `shoppingcart_donationconfiguration_16905482` (`changed_by_id`),
|
||||
CONSTRAINT `changed_by_id_refs_id_28af5c44b4a26b7f` FOREIGN KEY (`changed_by_id`) REFERENCES `auth_user` (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
DROP TABLE IF EXISTS `shoppingcart_invoice`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8 */;
|
||||
@@ -1680,6 +1744,13 @@ CREATE TABLE `shoppingcart_order` (
|
||||
`bill_to_cardtype` varchar(32) NOT NULL,
|
||||
`processor_reply_dump` longtext NOT NULL,
|
||||
`refunded_time` datetime,
|
||||
`company_name` varchar(255),
|
||||
`company_contact_name` varchar(255),
|
||||
`company_contact_email` varchar(255),
|
||||
`recipient_name` varchar(255),
|
||||
`recipient_email` varchar(255),
|
||||
`customer_reference_number` varchar(63),
|
||||
`order_type` varchar(32) NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `shoppingcart_order_fbfc09f1` (`user_id`),
|
||||
CONSTRAINT `user_id_refs_id_a4b0342e1195673` FOREIGN KEY (`user_id`) REFERENCES `auth_user` (`id`)
|
||||
@@ -1702,6 +1773,8 @@ CREATE TABLE `shoppingcart_orderitem` (
|
||||
`refund_requested_time` datetime,
|
||||
`service_fee` decimal(30,2) NOT NULL,
|
||||
`list_price` decimal(30,2),
|
||||
`created` datetime NOT NULL,
|
||||
`modified` datetime NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `shoppingcart_orderitem_8337030b` (`order_id`),
|
||||
KEY `shoppingcart_orderitem_fbfc09f1` (`user_id`),
|
||||
@@ -1816,7 +1889,7 @@ CREATE TABLE `south_migrationhistory` (
|
||||
`migration` varchar(255) NOT NULL,
|
||||
`applied` datetime NOT NULL,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=180 DEFAULT CHARSET=utf8;
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=188 DEFAULT CHARSET=utf8;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
DROP TABLE IF EXISTS `splash_splashconfig`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
@@ -1903,6 +1976,20 @@ CREATE TABLE `student_courseenrollmentallowed` (
|
||||
KEY `student_courseenrollmentallowed_3216ff68` (`created`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
DROP TABLE IF EXISTS `student_dashboardconfiguration`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8 */;
|
||||
CREATE TABLE `student_dashboardconfiguration` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`change_date` datetime NOT NULL,
|
||||
`changed_by_id` int(11) DEFAULT NULL,
|
||||
`enabled` tinyint(1) NOT NULL,
|
||||
`recent_enrollment_time_delta` int(10) unsigned NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `student_dashboardconfiguration_16905482` (`changed_by_id`),
|
||||
CONSTRAINT `changed_by_id_refs_id_31b94d88eec78c18` FOREIGN KEY (`changed_by_id`) REFERENCES `auth_user` (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
DROP TABLE IF EXISTS `student_loginfailures`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8 */;
|
||||
@@ -2083,6 +2170,38 @@ CREATE TABLE `submissions_submission` (
|
||||
CONSTRAINT `student_item_id_refs_id_1df1d83e00b5cccc` FOREIGN KEY (`student_item_id`) REFERENCES `submissions_studentitem` (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
DROP TABLE IF EXISTS `survey_surveyanswer`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8 */;
|
||||
CREATE TABLE `survey_surveyanswer` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`created` datetime NOT NULL,
|
||||
`modified` datetime NOT NULL,
|
||||
`user_id` int(11) NOT NULL,
|
||||
`form_id` int(11) NOT NULL,
|
||||
`field_name` varchar(255) NOT NULL,
|
||||
`field_value` varchar(1024) NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `survey_surveyanswer_fbfc09f1` (`user_id`),
|
||||
KEY `survey_surveyanswer_1d0aabf2` (`form_id`),
|
||||
KEY `survey_surveyanswer_7e1499` (`field_name`),
|
||||
CONSTRAINT `form_id_refs_id_5a119e5cf4c79f29` FOREIGN KEY (`form_id`) REFERENCES `survey_surveyform` (`id`),
|
||||
CONSTRAINT `user_id_refs_id_74dcdfa0e0ad4b5e` FOREIGN KEY (`user_id`) REFERENCES `auth_user` (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
DROP TABLE IF EXISTS `survey_surveyform`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8 */;
|
||||
CREATE TABLE `survey_surveyform` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`created` datetime NOT NULL,
|
||||
`modified` datetime NOT NULL,
|
||||
`name` varchar(255) NOT NULL,
|
||||
`form` longtext NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `name` (`name`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
DROP TABLE IF EXISTS `track_trackinglog`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8 */;
|
||||
|
||||
Binary file not shown.
@@ -2,7 +2,7 @@
|
||||
[run]
|
||||
data_file = reports/lms/.coverage
|
||||
source = lms,common/djangoapps
|
||||
omit = lms/envs/*, common/djangoapps/terrain/*, common/djangoapps/*/migrations/*
|
||||
omit = lms/envs/*, common/djangoapps/terrain/*, common/djangoapps/*/migrations/*, openedx/core/djangoapps/*/migrations/*
|
||||
|
||||
[report]
|
||||
ignore_errors = True
|
||||
|
||||
@@ -12,7 +12,7 @@ from student.tests.factories import UserFactory, CourseEnrollmentFactory
|
||||
from xmodule.modulestore.tests.factories import ItemFactory, CourseFactory
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.partitions.partitions import Group, UserPartition
|
||||
from user_api.tests.factories import UserCourseTagFactory
|
||||
from openedx.core.djangoapps.user_api.tests.factories import UserCourseTagFactory
|
||||
|
||||
|
||||
@override_settings(MODULESTORE=TEST_DATA_MOCK_MODULESTORE)
|
||||
|
||||
@@ -27,7 +27,7 @@ from student.models import anonymous_id_for_user
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
|
||||
from xmodule.partitions.partitions import Group, UserPartition
|
||||
from user_api.tests.factories import UserCourseTagFactory
|
||||
from openedx.core.djangoapps.user_api.tests.factories import UserCourseTagFactory
|
||||
|
||||
|
||||
@override_settings(MODULESTORE=TEST_DATA_MOCK_MODULESTORE)
|
||||
|
||||
@@ -6,11 +6,7 @@ from django.http import Http404
|
||||
from django.test.client import Client, RequestFactory
|
||||
from django.test.utils import override_settings
|
||||
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.tests.group_id import (
|
||||
CohortedTopicGroupIdTestMixin,
|
||||
@@ -21,10 +17,15 @@ from django_comment_client.tests.utils import CohortedContentTestCase
|
||||
from django_comment_client.utils import strip_none
|
||||
from student.tests.factories import UserFactory, CourseEnrollmentFactory
|
||||
from util.testing import UrlResetMixin
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.django_utils import TEST_DATA_MOCK_MODULESTORE
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, TEST_DATA_MOCK_MODULESTORE
|
||||
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__)
|
||||
|
||||
# pylint: disable=missing-docstring
|
||||
|
||||
@@ -11,7 +11,12 @@ import newrelic.agent
|
||||
|
||||
from edxmako.shortcuts import render_to_response
|
||||
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 django_comment_client.permissions import cached_has_permission
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import json
|
||||
import re
|
||||
|
||||
from course_groups.models import CourseUserGroup
|
||||
|
||||
|
||||
class GroupIdAssertionMixin(object):
|
||||
def _data_or_params_cs_request(self, mock_request):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from django.test.utils import override_settings
|
||||
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 django_comment_common.models import Role
|
||||
from django_comment_common.utils import seed_permissions_roles
|
||||
|
||||
@@ -17,8 +17,8 @@ from django_comment_client.permissions import check_permissions_by_view, cached_
|
||||
from edxmako import lookup_template
|
||||
import pystache_custom as pystache
|
||||
|
||||
from 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.cohorts import get_cohort_by_id, get_cohort_id, is_commentable_cohorted, is_course_cohorted
|
||||
from openedx.core.djangoapps.course_groups.models import CourseUserGroup
|
||||
from opaque_keys.edx.locations import i4xEncoder
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
@@ -62,7 +62,7 @@ import instructor_analytics.basic
|
||||
import instructor_analytics.distributions
|
||||
import instructor_analytics.csvs
|
||||
import csv
|
||||
from user_api.models import UserPreference
|
||||
from openedx.core.djangoapps.user_api.models import UserPreference
|
||||
from instructor.views import INVOICE_KEY
|
||||
|
||||
from submissions import api as sub_api # installed from the edx-submissions repository
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
Tests for instructor.basic
|
||||
"""
|
||||
|
||||
from django.test import TestCase
|
||||
from student.models import CourseEnrollment
|
||||
from django.core.urlresolvers import reverse
|
||||
from mock import patch
|
||||
@@ -17,12 +16,10 @@ from instructor_analytics.basic import (
|
||||
sale_record_features, sale_order_record_features, enrolled_students_features, course_registration_features,
|
||||
coupon_codes_features, AVAILABLE_FEATURES, STUDENT_FEATURES, PROFILE_FEATURES
|
||||
)
|
||||
from course_groups.tests.helpers import CohortFactory
|
||||
from course_groups.models import CourseUserGroup
|
||||
from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory
|
||||
from courseware.tests.factories import InstructorFactory
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
|
||||
|
||||
class TestAnalyticsBasic(ModuleStoreTestCase):
|
||||
|
||||
@@ -17,7 +17,7 @@ from django.core.urlresolvers import reverse
|
||||
|
||||
from capa.tests.response_xml_factory import (CodeResponseXMLFactory,
|
||||
CustomResponseXMLFactory)
|
||||
from user_api.tests.factories import UserCourseTagFactory
|
||||
from openedx.core.djangoapps.user_api.tests.factories import UserCourseTagFactory
|
||||
from xmodule.modulestore.tests.factories import ItemFactory
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.partitions.partitions import Group, UserPartition
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from django.contrib.auth.models import User
|
||||
from lettuce import step, world
|
||||
from notification_prefs import NOTIFICATION_PREF_KEY
|
||||
from user_api.models import UserPreference
|
||||
from openedx.core.djangoapps.user_api.models import UserPreference
|
||||
|
||||
|
||||
USERNAME = "robot"
|
||||
|
||||
@@ -12,7 +12,7 @@ from notification_prefs import NOTIFICATION_PREF_KEY
|
||||
from notification_prefs.views import ajax_enable, ajax_disable, ajax_status, set_subscription, UsernameCipher
|
||||
from student.tests.factories import UserFactory
|
||||
from edxmako.tests import mako_middleware_process_request
|
||||
from user_api.models import UserPreference
|
||||
from openedx.core.djangoapps.user_api.models import UserPreference
|
||||
from util.testing import UrlResetMixin
|
||||
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ from django.views.decorators.http import require_GET, require_POST
|
||||
|
||||
from edxmako.shortcuts import render_to_response
|
||||
from notification_prefs import NOTIFICATION_PREF_KEY
|
||||
from user_api.models import UserPreference
|
||||
from openedx.core.djangoapps.user_api.models import UserPreference
|
||||
|
||||
|
||||
class UsernameDecryptionException(Exception):
|
||||
|
||||
@@ -2,7 +2,7 @@ from django.contrib.auth.models import User
|
||||
from django.http import Http404
|
||||
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 lang_pref import LANGUAGE_KEY
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from django.conf import settings
|
||||
from django.test.client import RequestFactory
|
||||
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 lang_pref import LANGUAGE_KEY
|
||||
from notification_prefs import NOTIFICATION_PREF_KEY
|
||||
@@ -13,8 +13,8 @@ from notifier_api.views import NotifierUsersViewSet
|
||||
from opaque_keys.edx.locator import CourseLocator
|
||||
from student.models import CourseEnrollment
|
||||
from student.tests.factories import UserFactory, CourseEnrollmentFactory
|
||||
from user_api.models import UserPreference
|
||||
from user_api.tests.factories import UserPreferenceFactory
|
||||
from openedx.core.djangoapps.user_api.models import UserPreference
|
||||
from openedx.core.djangoapps.user_api.tests.factories import UserPreferenceFactory
|
||||
from util.testing import UrlResetMixin
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
|
||||
@@ -3,7 +3,7 @@ from rest_framework.viewsets import ReadOnlyModelViewSet
|
||||
|
||||
from notification_prefs import NOTIFICATION_PREF_KEY
|
||||
from notifier_api.serializers import NotifierUserSerializer
|
||||
from user_api.views import ApiKeyHeaderPermission
|
||||
from openedx.core.djangoapps.user_api.views import ApiKeyHeaderPermission
|
||||
|
||||
|
||||
class NotifierUsersViewSet(ReadOnlyModelViewSet):
|
||||
|
||||
@@ -6,7 +6,7 @@ from django.core.cache import cache
|
||||
from courseware.access import has_access
|
||||
from student.models import anonymous_id_for_user
|
||||
from student.models import UserProfile
|
||||
from user_api.models import UserPreference
|
||||
from openedx.core.djangoapps.user_api.models import UserPreference
|
||||
from lang_pref import LANGUAGE_KEY
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
|
||||
@@ -9,7 +9,7 @@ from student.models import anonymous_id_for_user
|
||||
from student.models import UserProfile
|
||||
from student.roles import CourseStaffRole, CourseInstructorRole
|
||||
from student.tests.factories import UserFactory, UserProfileFactory
|
||||
from user_api.models import UserPreference
|
||||
from openedx.core.djangoapps.user_api.models import UserPreference
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
|
||||
# Will also run default tests for IDTokens and UserInfo
|
||||
|
||||
@@ -16,9 +16,8 @@ from django.test.utils import override_settings
|
||||
|
||||
from util.testing import UrlResetMixin
|
||||
from third_party_auth.tests.testutil import simulate_running_pipeline
|
||||
from user_api.api import account as account_api
|
||||
from user_api.api import profile as profile_api
|
||||
from util.bad_request_rate_limiter import BadRequestRateLimiter
|
||||
from openedx.core.djangoapps.user_api.api import account as account_api
|
||||
from openedx.core.djangoapps.user_api.api import profile as profile_api
|
||||
from xmodule.modulestore.tests.django_utils import (
|
||||
ModuleStoreTestCase, mixed_store_config
|
||||
)
|
||||
|
||||
@@ -16,8 +16,8 @@ from edxmako.shortcuts import render_to_response, render_to_string
|
||||
from microsite_configuration import microsite
|
||||
import third_party_auth
|
||||
|
||||
from user_api.api import account as account_api
|
||||
from user_api.api import profile as profile_api
|
||||
from openedx.core.djangoapps.user_api.api import account as account_api
|
||||
from openedx.core.djangoapps.user_api.api import profile as profile_api
|
||||
from util.bad_request_rate_limiter import BadRequestRateLimiter
|
||||
|
||||
from student_account.helpers import auth_pipeline_urls
|
||||
|
||||
@@ -11,8 +11,8 @@ from django.conf import settings
|
||||
from django.core.urlresolvers import reverse
|
||||
|
||||
from util.testing import UrlResetMixin
|
||||
from user_api.api import account as account_api
|
||||
from user_api.api import profile as profile_api
|
||||
from openedx.core.djangoapps.user_api.api import account as account_api
|
||||
from openedx.core.djangoapps.user_api.api import profile as profile_api
|
||||
from lang_pref import LANGUAGE_KEY, api as language_api
|
||||
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from django.views.decorators.http import require_http_methods
|
||||
from django_future.csrf import ensure_csrf_cookie
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from edxmako.shortcuts import render_to_response
|
||||
from user_api.api import profile as profile_api
|
||||
from openedx.core.djangoapps.user_api.api import profile as profile_api
|
||||
from lang_pref import LANGUAGE_KEY, api as language_api
|
||||
import third_party_auth
|
||||
|
||||
|
||||
@@ -931,7 +931,7 @@ MIDDLEWARE_CLASSES = (
|
||||
|
||||
# Adds user tags to tracking events
|
||||
# Must go before TrackMiddleware, to get the context set up
|
||||
'user_api.middleware.UserTagsEventContextMiddleware',
|
||||
'openedx.core.djangoapps.user_api.middleware.UserTagsEventContextMiddleware',
|
||||
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'track.middleware.TrackMiddleware',
|
||||
@@ -1438,7 +1438,7 @@ INSTALLED_APPS = (
|
||||
'open_ended_grading',
|
||||
'psychometrics',
|
||||
'licenses',
|
||||
'course_groups',
|
||||
'openedx.core.djangoapps.course_groups',
|
||||
'bulk_email',
|
||||
|
||||
# External auth (OpenID, shib)
|
||||
@@ -1482,7 +1482,7 @@ INSTALLED_APPS = (
|
||||
|
||||
# User API
|
||||
'rest_framework',
|
||||
'user_api',
|
||||
'openedx.core.djangoapps.user_api',
|
||||
|
||||
# Shopping cart
|
||||
'shoppingcart',
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""
|
||||
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
|
||||
_ = lambda text: text
|
||||
@@ -53,3 +55,73 @@ class LmsBlockMixin(XBlockMixin):
|
||||
default=False,
|
||||
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
|
||||
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.conf import settings
|
||||
from user_api.api import course_tag as user_course_tag_api
|
||||
from openedx.core.djangoapps.user_api.api import course_tag as user_course_tag_api
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.x_module import ModuleSystem
|
||||
from xmodule.partitions.partitions_service import PartitionService
|
||||
@@ -128,13 +128,13 @@ class LmsPartitionService(PartitionService):
|
||||
course.
|
||||
|
||||
(If and when XBlock directly provides access from one block (e.g. a split_test_module)
|
||||
to another (e.g. a course_module), this won't be neccessary, but for now it seems like
|
||||
to another (e.g. a course_module), this won't be necessary, but for now it seems like
|
||||
the least messy way to hook things through)
|
||||
|
||||
"""
|
||||
@property
|
||||
def course_partitions(self):
|
||||
course = modulestore().get_course(self._course_id)
|
||||
course = modulestore().get_course(self.runtime.course_id)
|
||||
return course.user_partitions
|
||||
|
||||
|
||||
@@ -194,8 +194,7 @@ class LmsModuleSystem(LmsHandlerUrls, ModuleSystem): # pylint: disable=abstract
|
||||
services = kwargs.setdefault('services', {})
|
||||
services['user_tags'] = UserTagsService(self)
|
||||
services['partitions'] = LmsPartitionService(
|
||||
user_tags_service=services['user_tags'],
|
||||
course_id=kwargs.get('course_id', None),
|
||||
runtime=self,
|
||||
track_function=kwargs.get('track_function', None),
|
||||
)
|
||||
services['fs'] = xblock.reference.plugins.FSService()
|
||||
|
||||
123
lms/lib/xblock/test/test_mixin.py
Normal file
123
lms/lib/xblock/test/test_mixin.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""
|
||||
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))
|
||||
14
lms/urls.py
14
lms/urls.py
@@ -60,7 +60,7 @@ urlpatterns = ('', # nopep8
|
||||
|
||||
url(r'^heartbeat$', include('heartbeat.urls')),
|
||||
|
||||
url(r'^user_api/', include('user_api.urls')),
|
||||
url(r'^user_api/', include('openedx.core.djangoapps.user_api.urls')),
|
||||
|
||||
url(r'^notifier_api/', include('notifier_api.urls')),
|
||||
|
||||
@@ -343,21 +343,21 @@ if settings.COURSEWARE_ENABLED:
|
||||
|
||||
# Cohorts management
|
||||
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),
|
||||
'course_groups.views.add_cohort',
|
||||
'openedx.core.djangoapps.course_groups.views.add_cohort',
|
||||
name="add_cohort"),
|
||||
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"),
|
||||
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"),
|
||||
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"),
|
||||
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"),
|
||||
|
||||
# Open Ended Notifications
|
||||
|
||||
9
openedx/__init__.py
Normal file
9
openedx/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
9
openedx/core/__init__.py
Normal file
9
openedx/core/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
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 _
|
||||
from courseware import courses
|
||||
from eventtracking import tracker
|
||||
from student.models import get_user_by_username_or_email
|
||||
from .models import CourseUserGroup
|
||||
from .models import CourseUserGroup, CourseUserGroupPartitionGroup
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -373,3 +373,17 @@ def add_user_to_cohort(cohort, username_or_email):
|
||||
)
|
||||
cohort.users.add(user)
|
||||
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
|
||||
105
openedx/core/djangoapps/course_groups/migrations/0001_initial.py
Normal file
105
openedx/core/djangoapps/course_groups/migrations/0001_initial.py
Normal file
@@ -0,0 +1,105 @@
|
||||
# -*- 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']
|
||||
@@ -0,0 +1,82 @@
|
||||
# -*- 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):
|
||||
COHORT = 'cohort'
|
||||
GROUP_TYPE_CHOICES = ((COHORT, 'Cohort'),)
|
||||
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)
|
||||
75
openedx/core/djangoapps/course_groups/partition_scheme.py
Normal file
75
openedx/core/djangoapps/course_groups/partition_scheme.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
from factory import post_generation, Sequence
|
||||
from factory.django import DjangoModelFactory
|
||||
from course_groups.models import CourseUserGroup
|
||||
from opaque_keys.edx.locations import SlashSeparatedCourseKey
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
|
||||
from ..models import CourseUserGroup
|
||||
|
||||
|
||||
class CohortFactory(DjangoModelFactory):
|
||||
"""
|
||||
@@ -1,24 +1,31 @@
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import IntegrityError
|
||||
from django.http import Http404
|
||||
from django.test import TestCase
|
||||
from django.test.utils import override_settings
|
||||
from mock import call, patch
|
||||
from opaque_keys.edx.locations import SlashSeparatedCourseKey
|
||||
|
||||
from course_groups import cohorts
|
||||
from course_groups.models import CourseUserGroup
|
||||
from course_groups.tests.helpers import topic_name_to_id, config_course_cohorts, CohortFactory
|
||||
from opaque_keys.edx.locations import SlashSeparatedCourseKey
|
||||
from student.models import CourseEnrollment
|
||||
from student.tests.factories import UserFactory
|
||||
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
|
||||
# manually overriding the modulestore. However, running with
|
||||
# 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):
|
||||
def setUp(self):
|
||||
self.course_key = SlashSeparatedCourseKey("dummy", "dummy", "dummy")
|
||||
@@ -446,7 +453,7 @@ class TestCohorts(TestCase):
|
||||
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):
|
||||
"""
|
||||
Make sure cohorts.add_cohort() properly adds a cohort to a course and handles
|
||||
@@ -469,7 +476,7 @@ class TestCohorts(TestCase):
|
||||
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):
|
||||
"""
|
||||
Make sure cohorts.add_user_to_cohort() properly adds a user to a cohort and
|
||||
@@ -525,3 +532,127 @@ class TestCohorts(TestCase):
|
||||
User.DoesNotExist,
|
||||
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
|
||||
)
|
||||
@@ -0,0 +1,257 @@
|
||||
"""
|
||||
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
|
||||
from collections import namedtuple
|
||||
import json
|
||||
|
||||
from collections import namedtuple
|
||||
from django.contrib.auth.models import User
|
||||
from django.http import Http404
|
||||
from django.test.client import RequestFactory
|
||||
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 student.models import CourseEnrollment
|
||||
from student.tests.factories import UserFactory
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
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)
|
||||
@@ -11,8 +11,8 @@ from django.db import transaction, IntegrityError
|
||||
from django.core.validators import validate_email, validate_slug, ValidationError
|
||||
from django.contrib.auth.forms import PasswordResetForm
|
||||
|
||||
from user_api.models import User, UserProfile, Registration, PendingEmailChange
|
||||
from user_api.helpers import intercept_errors
|
||||
from ..models import User, UserProfile, Registration, PendingEmailChange
|
||||
from ..helpers import intercept_errors
|
||||
|
||||
|
||||
USERNAME_MIN_LENGTH = 2
|
||||
@@ -7,7 +7,7 @@ Stores global metadata using the UserPreference model, and per-course metadata u
|
||||
UserCourseTag model.
|
||||
"""
|
||||
|
||||
from user_api.models import UserCourseTag
|
||||
from ..models import UserCourseTag
|
||||
|
||||
# Scopes
|
||||
# (currently only allows per-course tags. Can be expanded to support
|
||||
@@ -13,9 +13,9 @@ from django.db import IntegrityError
|
||||
from pytz import UTC
|
||||
import analytics
|
||||
|
||||
from user_api.models import User, UserProfile, UserPreference, UserOrgTag
|
||||
from user_api.helpers import intercept_errors
|
||||
from eventtracking import tracker
|
||||
from ..models import User, UserProfile, UserPreference, UserOrgTag
|
||||
from ..helpers import intercept_errors
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -34,6 +34,7 @@ class ProfileInvalidField(ProfileRequestError):
|
||||
""" The proposed value for a field is not in a valid format. """
|
||||
|
||||
def __init__(self, field, value):
|
||||
super(ProfileInvalidField, self).__init__()
|
||||
self.field = field
|
||||
self.value = value
|
||||
|
||||
@@ -12,7 +12,7 @@ from django.http import HttpResponseBadRequest
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def intercept_errors(api_error, ignore_errors=[]):
|
||||
def intercept_errors(api_error, ignore_errors=None):
|
||||
"""
|
||||
Function decorator that intercepts exceptions
|
||||
and translates them into API-specific errors (usually an "internal" error).
|
||||
@@ -33,13 +33,20 @@ def intercept_errors(api_error, ignore_errors=[]):
|
||||
|
||||
"""
|
||||
def _decorator(func):
|
||||
"""
|
||||
Function decorator that intercepts exceptions and translates them into API-specific errors.
|
||||
"""
|
||||
@wraps(func)
|
||||
def _wrapped(*args, **kwargs):
|
||||
"""
|
||||
Wrapper that evaluates a function, intercepting exceptions and translating them into
|
||||
API-specific errors.
|
||||
"""
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except Exception as ex:
|
||||
# Raise the original exception if it's in our list of "ignored" errors
|
||||
for ignored in ignore_errors:
|
||||
for ignored in ignore_errors or []:
|
||||
if isinstance(ex, ignored):
|
||||
raise
|
||||
|
||||
@@ -19,9 +19,9 @@ from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from student.tests.factories import UserFactory, CourseEnrollmentFactory
|
||||
from student.models import CourseEnrollment
|
||||
|
||||
import user_api.api.profile as profile_api
|
||||
from user_api.models import UserOrgTag
|
||||
from user_api.management.commands import email_opt_in_list
|
||||
import openedx.core.djangoapps.user_api.api.profile as profile_api
|
||||
from openedx.core.djangoapps.user_api.models import UserOrgTag
|
||||
from openedx.core.djangoapps.user_api.management.commands import email_opt_in_list
|
||||
|
||||
|
||||
MODULESTORE_CONFIG = mixed_store_config(settings.COMMON_TEST_DATA_ROOT, {}, include_xml=False)
|
||||
@@ -8,7 +8,8 @@ from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
|
||||
from track.contexts import COURSE_REGEX
|
||||
from user_api.models import UserCourseTag
|
||||
|
||||
from .models import UserCourseTag
|
||||
|
||||
|
||||
class UserTagsEventContextMiddleware(object):
|
||||
61
openedx/core/djangoapps/user_api/partition_schemes.py
Normal file
61
openedx/core/djangoapps/user_api/partition_schemes.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""
|
||||
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)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user