From 356b2335e9c9101cd04865ecf6d92ea2e9b0912c Mon Sep 17 00:00:00 2001 From: Andy Armstrong Date: Thu, 23 Oct 2014 18:35:42 -0400 Subject: [PATCH 1/5] Add base support for cohorted group configurations TNL-649 --- CHANGELOG.rst | 2 + cms/djangoapps/contentstore/views/course.py | 28 +- .../views/tests/test_group_configurations.py | 46 +- .../contentstore/views/tests/test_item.py | 3 +- .../models/settings/course_metadata.py | 1 - cms/envs/common.py | 2 +- cms/static/js/models/group.js | 2 +- cms/static/js/models/group_configuration.js | 4 +- .../spec/models/group_configuration_spec.js | 6 +- cms/templates/group_configurations.html | 2 +- cms/templates/settings.html | 7 +- cms/templates/settings_advanced.html | 7 +- cms/templates/settings_graders.html | 7 +- cms/templates/widgets/header.html | 11 +- cms/urls.py | 2 +- common/djangoapps/lang_pref/middleware.py | 2 +- .../lang_pref/tests/test_middleware.py | 2 +- .../djangoapps/lang_pref/tests/test_views.py | 2 +- common/djangoapps/lang_pref/views.py | 2 +- .../student/tests/test_create_account.py | 2 +- common/djangoapps/student/views.py | 2 +- .../xmodule/modulestore/inheritance.py | 4 +- .../test_cross_modulestore_import_export.py | 3 +- .../xmodule/xmodule/partitions/partitions.py | 86 +++- .../xmodule/partitions/partitions_service.py | 85 +--- .../xmodule/partitions/test_partitions.py | 286 ------------- .../xmodule/partitions/tests}/__init__.py | 0 .../partitions/tests/test_partitions.py | 302 +++++++++++++ .../lib/xmodule/xmodule/split_test_module.py | 2 +- common/lib/xmodule/xmodule/tests/__init__.py | 4 +- .../xmodule/tests/test_split_test_module.py | 53 +-- common/test/acceptance/.coveragerc | 2 +- .../tests/studio/test_studio_split_test.py | 121 +++++- lms/.coveragerc | 2 +- .../courseware/tests/test_split_module.py | 2 +- .../tests/test_submitting_problems.py | 2 +- lms/djangoapps/instructor/views/api.py | 2 +- .../instructor_task/tests/test_integration.py | 2 +- .../features/unsubscribe.py | 2 +- lms/djangoapps/notification_prefs/tests.py | 2 +- lms/djangoapps/notification_prefs/views.py | 2 +- lms/djangoapps/notifier_api/tests.py | 4 +- lms/djangoapps/notifier_api/views.py | 2 +- lms/djangoapps/oauth2_handler/handlers.py | 2 +- lms/djangoapps/oauth2_handler/tests.py | 2 +- .../student_account/test/test_views.py | 5 +- lms/djangoapps/student_account/views.py | 4 +- .../student_profile/test/test_views.py | 4 +- lms/djangoapps/student_profile/views.py | 2 +- lms/envs/common.py | 4 +- lms/lib/xblock/runtime.py | 9 +- lms/urls.py | 2 +- .../user_api/api => openedx}/__init__.py | 0 .../migrations => openedx/core}/__init__.py | 0 .../core/djangoapps}/__init__.py | 0 openedx/core/djangoapps/user_api/__init__.py | 0 .../core/djangoapps/user_api/api/__init__.py | 0 .../core}/djangoapps/user_api/api/account.py | 4 +- .../djangoapps/user_api/api/course_tag.py | 0 .../core}/djangoapps/user_api/api/profile.py | 5 +- .../core}/djangoapps/user_api/helpers.py | 11 +- .../user_api/management/__init__.py | 0 .../user_api/management/commands/__init__.py | 0 .../management/commands/email_opt_in_list.py | 267 ++++++++++++ .../user_api/management/tests/__init__.py | 0 .../tests/test_email_opt_in_list.py | 399 ++++++++++++++++++ .../core}/djangoapps/user_api/middleware.py | 3 +- .../user_api/migrations/0001_initial.py | 0 ...nique_usercoursetags_user_course_id_key.py | 0 .../migrations/0003_rename_usercoursetags.py | 0 .../user_api/migrations/__init__.py | 0 .../core}/djangoapps/user_api/models.py | 0 .../djangoapps/user_api/partition_schemes.py | 61 +++ .../core}/djangoapps/user_api/serializers.py | 3 +- .../djangoapps/user_api/tests/__init__.py | 0 .../djangoapps/user_api/tests/factories.py | 3 +- .../user_api/tests/test_account_api.py | 4 +- .../user_api/tests/test_constants.py | 0 .../user_api/tests/test_course_tag_api.py | 4 +- .../djangoapps/user_api/tests/test_helpers.py | 8 +- .../user_api/tests/test_middleware.py | 7 +- .../djangoapps/user_api/tests/test_models.py | 5 +- .../user_api/tests/test_partition_schemes.py | 107 +++++ .../user_api/tests/test_profile_api.py | 6 +- .../djangoapps/user_api/tests/test_views.py | 10 +- .../core}/djangoapps/user_api/urls.py | 18 +- .../core}/djangoapps/user_api/views.py | 10 +- pavelib/tests.py | 2 +- pavelib/utils/test/suites/nose_suite.py | 2 +- setup.py | 16 +- 90 files changed, 1534 insertions(+), 567 deletions(-) delete mode 100644 common/lib/xmodule/xmodule/partitions/test_partitions.py rename common/{djangoapps/user_api => lib/xmodule/xmodule/partitions/tests}/__init__.py (100%) create mode 100644 common/lib/xmodule/xmodule/partitions/tests/test_partitions.py rename {common/djangoapps/user_api/api => openedx}/__init__.py (100%) rename {common/djangoapps/user_api/migrations => openedx/core}/__init__.py (100%) rename {common/djangoapps/user_api/tests => openedx/core/djangoapps}/__init__.py (100%) create mode 100644 openedx/core/djangoapps/user_api/__init__.py create mode 100644 openedx/core/djangoapps/user_api/api/__init__.py rename {common => openedx/core}/djangoapps/user_api/api/account.py (99%) rename {common => openedx/core}/djangoapps/user_api/api/course_tag.py (100%) rename {common => openedx/core}/djangoapps/user_api/api/profile.py (97%) rename {common => openedx/core}/djangoapps/user_api/helpers.py (97%) create mode 100644 openedx/core/djangoapps/user_api/management/__init__.py create mode 100644 openedx/core/djangoapps/user_api/management/commands/__init__.py create mode 100644 openedx/core/djangoapps/user_api/management/commands/email_opt_in_list.py create mode 100644 openedx/core/djangoapps/user_api/management/tests/__init__.py create mode 100644 openedx/core/djangoapps/user_api/management/tests/test_email_opt_in_list.py rename {common => openedx/core}/djangoapps/user_api/middleware.py (97%) rename {common => openedx/core}/djangoapps/user_api/migrations/0001_initial.py (100%) rename {common => openedx/core}/djangoapps/user_api/migrations/0002_auto__add_usercoursetags__add_unique_usercoursetags_user_course_id_key.py (100%) rename {common => openedx/core}/djangoapps/user_api/migrations/0003_rename_usercoursetags.py (100%) create mode 100644 openedx/core/djangoapps/user_api/migrations/__init__.py rename {common => openedx/core}/djangoapps/user_api/models.py (100%) create mode 100644 openedx/core/djangoapps/user_api/partition_schemes.py rename {common => openedx/core}/djangoapps/user_api/serializers.py (95%) create mode 100644 openedx/core/djangoapps/user_api/tests/__init__.py rename {common => openedx/core}/djangoapps/user_api/tests/factories.py (92%) rename {common => openedx/core}/djangoapps/user_api/tests/test_account_api.py (99%) rename {common => openedx/core}/djangoapps/user_api/tests/test_constants.py (100%) rename {common => openedx/core}/djangoapps/user_api/tests/test_course_tag_api.py (91%) rename {common => openedx/core}/djangoapps/user_api/tests/test_helpers.py (96%) rename {common => openedx/core}/djangoapps/user_api/tests/test_middleware.py (95%) rename {common => openedx/core}/djangoapps/user_api/tests/test_models.py (95%) create mode 100644 openedx/core/djangoapps/user_api/tests/test_partition_schemes.py rename {common => openedx/core}/djangoapps/user_api/tests/test_profile_api.py (98%) rename {common => openedx/core}/djangoapps/user_api/tests/test_views.py (99%) rename {common => openedx/core}/djangoapps/user_api/urls.py (76%) rename {common => openedx/core}/djangoapps/user_api/views.py (99%) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index fb5aa94e5c..a044ecc910 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,8 @@ These are notable changes in edx-platform. This is a rolling list of changes, in roughly chronological order, most recent first. Add your entries at or near the top. Include a label indicating the component affected. +Platform: Add base support for cohorted group configurations. TNL-649 + Common: Add configurable reset button to units Studio: Add support xblock validation messages on Studio unit/container page. TNL-683 diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 318e3f9f3f..ca9887b4d5 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -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, diff --git a/cms/djangoapps/contentstore/views/tests/test_group_configurations.py b/cms/djangoapps/contentstore/views/tests/test_group_configurations.py index 7d03c23c0f..5c0e5130d1 100644 --- a/cms/djangoapps/contentstore/views/tests/test_group_configurations.py +++ b/cms/djangoapps/contentstore/views/tests/test_group_configurations.py @@ -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}, diff --git a/cms/djangoapps/contentstore/views/tests/test_item.py b/cms/djangoapps/contentstore/views/tests/test_item.py index 496eee0c52..b496a7ffc4 100644 --- a/cms/djangoapps/contentstore/views/tests/test_item.py +++ b/cms/djangoapps/contentstore/views/tests/test_item.py @@ -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}, diff --git a/cms/djangoapps/models/settings/course_metadata.py b/cms/djangoapps/models/settings/course_metadata.py index d1b0ebacf1..236451eb4f 100644 --- a/cms/djangoapps/models/settings/course_metadata.py +++ b/cms/djangoapps/models/settings/course_metadata.py @@ -28,7 +28,6 @@ class CourseMetadata(object): 'graded', 'hide_from_toc', 'pdf_textbooks', - 'user_partitions', 'name', # from xblock 'tags', # from xblock 'visible_to_staff_only', diff --git a/cms/envs/common.py b/cms/envs/common.py index 4055c5c944..4feb741582 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -607,7 +607,7 @@ INSTALLED_APPS = ( 'reverification', # User preferences - 'user_api', + 'openedx.core.djangoapps.user_api', 'django_openid_auth', 'embargo', diff --git a/cms/static/js/models/group.js b/cms/static/js/models/group.js index 96548f7e8e..9456c2c56a 100644 --- a/cms/static/js/models/group.js +++ b/cms/static/js/models/group.js @@ -7,7 +7,7 @@ define([ defaults: function() { return { name: '', - version: null, + version: 1, order: null }; }, diff --git a/cms/static/js/models/group_configuration.js b/cms/static/js/models/group_configuration.js index 95c97ee3d5..0ef7a27c6e 100644 --- a/cms/static/js/models/group_configuration.js +++ b/cms/static/js/models/group_configuration.js @@ -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() diff --git a/cms/static/js/spec/models/group_configuration_spec.js b/cms/static/js/spec/models/group_configuration_spec.js index 4a1b183f19..b58532d2a5 100644 --- a/cms/static/js/spec/models/group_configuration_spec.js +++ b/cms/static/js/spec/models/group_configuration_spec.js @@ -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, diff --git a/cms/templates/group_configurations.html b/cms/templates/group_configurations.html index d4d8650cef..954b1509ff 100644 --- a/cms/templates/group_configurations.html +++ b/cms/templates/group_configurations.html @@ -55,7 +55,7 @@ % else:
-

${_("Loading...")}

+

${_("Loading")}

% endif diff --git a/cms/templates/settings.html b/cms/templates/settings.html index 4746f6bb91..ea12f2453a 100644 --- a/cms/templates/settings.html +++ b/cms/templates/settings.html @@ -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}'; % endif diff --git a/cms/templates/settings_advanced.html b/cms/templates/settings_advanced.html index f4467945ff..af021a31d3 100644 --- a/cms/templates/settings_advanced.html +++ b/cms/templates/settings_advanced.html @@ -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")} @@ -91,9 +92,9 @@ - % if "split_test" in context_course.advanced_modules: - - % endif + % if should_show_group_configurations_page(context_course): + + % endif % endif diff --git a/cms/templates/settings_graders.html b/cms/templates/settings_graders.html index 641b06fc77..69c15d4a24 100644 --- a/cms/templates/settings_graders.html +++ b/cms/templates/settings_graders.html @@ -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 @@ % endif diff --git a/cms/templates/widgets/header.html b/cms/templates/widgets/header.html index 266c656b17..6ff87f6d4b 100644 --- a/cms/templates/widgets/header.html +++ b/cms/templates/widgets/header.html @@ -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 @@ + % if should_show_group_configurations_page(context_course): + + % endif - % if "split_test" in context_course.advanced_modules: - - % endif diff --git a/cms/urls.py b/cms/urls.py index afdec0eec8..7e06f50339 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -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')), ) diff --git a/common/djangoapps/lang_pref/middleware.py b/common/djangoapps/lang_pref/middleware.py index b14ea33690..da3a64ddef 100644 --- a/common/djangoapps/lang_pref/middleware.py +++ b/common/djangoapps/lang_pref/middleware.py @@ -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 diff --git a/common/djangoapps/lang_pref/tests/test_middleware.py b/common/djangoapps/lang_pref/tests/test_middleware.py index 68d39265b6..042e86a712 100644 --- a/common/djangoapps/lang_pref/tests/test_middleware.py +++ b/common/djangoapps/lang_pref/tests/test_middleware.py @@ -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 diff --git a/common/djangoapps/lang_pref/tests/test_views.py b/common/djangoapps/lang_pref/tests/test_views.py index 7d6bffd2a9..daa8e89127 100644 --- a/common/djangoapps/lang_pref/tests/test_views.py +++ b/common/djangoapps/lang_pref/tests/test_views.py @@ -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 diff --git a/common/djangoapps/lang_pref/views.py b/common/djangoapps/lang_pref/views.py index 78f99b73e0..df75166392 100644 --- a/common/djangoapps/lang_pref/views.py +++ b/common/djangoapps/lang_pref/views.py @@ -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 diff --git a/common/djangoapps/student/tests/test_create_account.py b/common/djangoapps/student/tests/test_create_account.py index de5770f298..e72dd971eb 100644 --- a/common/djangoapps/student/tests/test_create_account.py +++ b/common/djangoapps/student/tests/test_create_account.py @@ -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 diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 43986e320f..d61b0e719e 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -83,7 +83,7 @@ from external_auth.login_and_register import ( from bulk_email.models import Optout, CourseAuthorization import shoppingcart from shoppingcart.models import DonationConfiguration -from user_api.models import UserPreference +from openedx.core.djangoapps.user_api.models import UserPreference from lang_pref import LANGUAGE_KEY import track.views diff --git a/common/lib/xmodule/xmodule/modulestore/inheritance.py b/common/lib/xmodule/xmodule/modulestore/inheritance.py index 97391130ef..8b8623e2dd 100644 --- a/common/lib/xmodule/xmodule/modulestore/inheritance.py +++ b/common/lib/xmodule/xmodule/modulestore/inheritance.py @@ -142,8 +142,8 @@ class InheritanceMixin(XBlockMixin): # This is should be scoped to content, but since it's defined in the policy # file, it is currently scoped to settings. user_partitions = UserPartitionList( - display_name=_("Experiment Group Configurations"), - help=_("Enter the configurations that govern how students are grouped for content experiments."), + display_name=_("Group Configurations"), + help=_("Enter the configurations that govern how students are grouped together."), default=[], scope=Scope.settings ) diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_cross_modulestore_import_export.py b/common/lib/xmodule/xmodule/modulestore/tests/test_cross_modulestore_import_export.py index c8f5b5f0ea..ca3a10a3b1 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_cross_modulestore_import_export.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_cross_modulestore_import_export.py @@ -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. diff --git a/common/lib/xmodule/xmodule/partitions/partitions.py b/common/lib/xmodule/xmodule/partitions/partitions.py index c2752b951f..27b6f7a75f 100644 --- a/common/lib/xmodule/xmodule/partitions/partitions.py +++ b/common/lib/xmodule/xmodule/partitions/partitions.py @@ -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 diff --git a/common/lib/xmodule/xmodule/partitions/partitions_service.py b/common/lib/xmodule/xmodule/partitions/partitions_service.py index 82b283167e..bb23ca8225 100644 --- a/common/lib/xmodule/xmodule/partitions/partitions_service.py +++ b/common/lib/xmodule/xmodule/partitions/partitions_service.py @@ -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 + ) diff --git a/common/lib/xmodule/xmodule/partitions/test_partitions.py b/common/lib/xmodule/xmodule/partitions/test_partitions.py deleted file mode 100644 index 0ff667d34e..0000000000 --- a/common/lib/xmodule/xmodule/partitions/test_partitions.py +++ /dev/null @@ -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) diff --git a/common/djangoapps/user_api/__init__.py b/common/lib/xmodule/xmodule/partitions/tests/__init__.py similarity index 100% rename from common/djangoapps/user_api/__init__.py rename to common/lib/xmodule/xmodule/partitions/tests/__init__.py diff --git a/common/lib/xmodule/xmodule/partitions/tests/test_partitions.py b/common/lib/xmodule/xmodule/partitions/tests/test_partitions.py new file mode 100644 index 0000000000..6b13b7cb4e --- /dev/null +++ b/common/lib/xmodule/xmodule/partitions/tests/test_partitions.py @@ -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 diff --git a/common/lib/xmodule/xmodule/split_test_module.py b/common/lib/xmodule/xmodule/split_test_module.py index 3dd59bfd68..a56b1e26bf 100644 --- a/common/lib/xmodule/xmodule/split_test_module.py +++ b/common/lib/xmodule/xmodule/split_test_module.py @@ -188,7 +188,7 @@ class SplitTestModule(SplitTestFields, XModule, StudioEditableModule): partitions_service = self.runtime.service(self, 'partitions') if not partitions_service: return None - return partitions_service.get_user_group_for_partition(self.user_partition_id) + return partitions_service.get_user_group_id_for_partition(self.user_partition_id) @property def is_configured(self): diff --git a/common/lib/xmodule/xmodule/tests/__init__.py b/common/lib/xmodule/xmodule/tests/__init__.py index 4c8ff94f76..44d9fc0123 100644 --- a/common/lib/xmodule/xmodule/tests/__init__.py +++ b/common/lib/xmodule/xmodule/tests/__init__.py @@ -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", diff --git a/common/lib/xmodule/xmodule/tests/test_split_test_module.py b/common/lib/xmodule/xmodule/tests/test_split_test_module.py index b82310c845..36755a0308 100644 --- a/common/lib/xmodule/xmodule/tests/test_split_test_module.py +++ b/common/lib/xmodule/xmodule/tests/test_split_test_module.py @@ -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. diff --git a/common/test/acceptance/.coveragerc b/common/test/acceptance/.coveragerc index 87fe2964fe..32a237f3bb 100644 --- a/common/test/acceptance/.coveragerc +++ b/common/test/acceptance/.coveragerc @@ -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] diff --git a/common/test/acceptance/tests/studio/test_studio_split_test.py b/common/test/acceptance/tests/studio/test_studio_split_test.py index 1fabdff540..e3a0861b78 100644 --- a/common/test/acceptance/tests/studio/test_studio_split_test.py +++ b/common/test/acceptance/tests/studio/test_studio_split_test.py @@ -9,6 +9,7 @@ from nose.plugins.attrib import attr from selenium.webdriver.support.ui import Select from xmodule.partitions.partitions import Group, UserPartition +from xmodule.partitions.tests.test_partitions import MockUserPartitionScheme from bok_choy.promise import Promise, EmptyPromise from ...fixtures.course import XBlockFixtureDesc @@ -30,6 +31,16 @@ class SplitTestMixin(object): """ Mixin that contains useful methods for split_test module testing. """ + @staticmethod + def create_user_partition_json(partition_id, name, description, groups): + """ + Helper method to create user partition JSON. + """ + return UserPartition( + partition_id, name, description, groups, MockUserPartitionScheme("random") + ).to_json() + + def verify_groups(self, container, active_groups, inactive_groups, verify_missing_groups_not_present=True): """ Check that the groups appear and are correctly categorized as to active and inactive. @@ -80,8 +91,18 @@ class SplitTest(ContainerBase, SplitTestMixin): self.course_fixture._update_xblock(self.course_fixture._course_location, { "metadata": { u"user_partitions": [ - UserPartition(0, 'Configuration alpha,beta', 'first', [Group("0", 'alpha'), Group("1", 'beta')]).to_json(), - UserPartition(1, 'Configuration 0,1,2', 'second', [Group("0", 'Group 0'), Group("1", 'Group 1'), Group("2", 'Group 2')]).to_json() + self.create_user_partition_json( + 0, + 'Configuration alpha,beta', + 'first', + [Group("0", 'alpha'), Group("1", 'beta')] + ), + self.create_user_partition_json( + 1, + 'Configuration 0,1,2', + 'second', + [Group("0", 'Group 0'), Group("1", 'Group 1'), Group("2", 'Group 2')] + ), ], }, }) @@ -124,8 +145,12 @@ class SplitTest(ContainerBase, SplitTestMixin): self.course_fixture._update_xblock(self.course_fixture._course_location, { "metadata": { u"user_partitions": [ - UserPartition(0, 'Configuration alpha,beta', 'first', - [Group("0", 'alpha'), Group("2", 'gamma')]).to_json() + self.create_user_partition_json( + 0, + 'Configuration alpha,beta', + 'first', + [Group("0", 'alpha'), Group("2", 'gamma')] + ) ], }, }) @@ -189,7 +214,7 @@ class SplitTest(ContainerBase, SplitTestMixin): @attr('shard_1') class SettingsMenuTest(StudioCourseTest): """ - Tests that Setting menu is rendered correctly in Studio + Tests that Settings menu is rendered correctly in Studio """ def setUp(self): @@ -324,7 +349,7 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin): self.course_fixture._update_xblock(self.course_fixture._course_location, { "metadata": { u"user_partitions": [ - UserPartition(0, "Name", "Description.", groups).to_json(), + self.create_user_partition_json(0, "Name", "Description.", groups), ], }, }) @@ -396,8 +421,18 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin): self.course_fixture._update_xblock(self.course_fixture._course_location, { "metadata": { u"user_partitions": [ - UserPartition(0, 'Name of the Group Configuration', 'Description of the group configuration.', [Group("0", 'Group 0'), Group("1", 'Group 1')]).to_json(), - UserPartition(1, 'Name of second Group Configuration', 'Second group configuration.', [Group("0", 'Alpha'), Group("1", 'Beta'), Group("2", 'Gamma')]).to_json(), + self.create_user_partition_json( + 0, + 'Name of the Group Configuration', + 'Description of the group configuration.', + [Group("0", 'Group 0'), Group("1", 'Group 1')] + ), + self.create_user_partition_json( + 1, + 'Name of second Group Configuration', + 'Second group configuration.', + [Group("0", 'Alpha'), Group("1", 'Beta'), Group("2", 'Gamma')] + ), ], }, }) @@ -531,7 +566,12 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin): self.course_fixture._update_xblock(self.course_fixture._course_location, { "metadata": { u"user_partitions": [ - UserPartition(0, 'Name of the Group Configuration', 'Description of the group configuration.', [Group("0", 'Group A'), Group("1", 'Group B'), Group("2", 'Group C')]).to_json(), + self.create_user_partition_json( + 0, + 'Name of the Group Configuration', + 'Description of the group configuration.', + [Group("0", 'Group A'), Group("1", 'Group B'), Group("2", 'Group C')] + ), ], }, }) @@ -610,8 +650,18 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin): self.course_fixture._update_xblock(self.course_fixture._course_location, { "metadata": { u"user_partitions": [ - UserPartition(0, 'Name of the Group Configuration', 'Description of the group configuration.', [Group("0", 'Group 0'), Group("1", 'Group 1')]).to_json(), - UserPartition(1, 'Name of second Group Configuration', 'Second group configuration.', [Group("0", 'Alpha'), Group("1", 'Beta'), Group("2", 'Gamma')]).to_json(), + self.create_user_partition_json( + 0, + 'Name of the Group Configuration', + 'Description of the group configuration.', + [Group("0", 'Group 0'), Group("1", 'Group 1')] + ), + self.create_user_partition_json( + 1, + 'Name of second Group Configuration', + 'Second group configuration.', + [Group("0", 'Alpha'), Group("1", 'Beta'), Group("2", 'Gamma')] + ), ], }, }) @@ -696,7 +746,12 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin): self.course_fixture._update_xblock(self.course_fixture._course_location, { "metadata": { u"user_partitions": [ - UserPartition(0, "Name", "Description.", [Group("0", "Group A"), Group("1", "Group B")]).to_json(), + self.create_user_partition_json( + 0, + "Name", + "Description.", + [Group("0", "Group A"), Group("1", "Group B")] + ), ], }, }) @@ -728,7 +783,12 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin): self.course_fixture._update_xblock(self.course_fixture._course_location, { "metadata": { u"user_partitions": [ - UserPartition(0, "Name", "Description.", [Group("0", "Group A"), Group("1", "Group B")]).to_json(), + self.create_user_partition_json( + 0, + "Name", + "Description.", + [Group("0", "Group A"), Group("1", "Group B")] + ), ], }, }) @@ -771,8 +831,18 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin): self.course_fixture._update_xblock(self.course_fixture._course_location, { "metadata": { u"user_partitions": [ - UserPartition(0, 'Configuration 1', 'Description of the group configuration.', [Group("0", 'Group 0'), Group("1", 'Group 1')]).to_json(), - UserPartition(1, 'Configuration 2', 'Second group configuration.', [Group("0", 'Alpha'), Group("1", 'Beta'), Group("2", 'Gamma')]).to_json() + self.create_user_partition_json( + 0, + 'Configuration 1', + 'Description of the group configuration.', + [Group("0", 'Group 0'), Group("1", 'Group 1')] + ), + self.create_user_partition_json( + 1, + 'Configuration 2', + 'Second group configuration.', + [Group("0", 'Alpha'), Group("1", 'Beta'), Group("2", 'Gamma')] + ) ], }, }) @@ -804,7 +874,12 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin): self.course_fixture._update_xblock(self.course_fixture._course_location, { "metadata": { u"user_partitions": [ - UserPartition(0, "Name", "Description.", [Group("0", "Group A"), Group("1", "Group B")]).to_json() + self.create_user_partition_json( + 0, + "Name", + "Description.", + [Group("0", "Group A"), Group("1", "Group B")] + ) ], }, }) @@ -840,8 +915,18 @@ class GroupConfigurationsTest(ContainerBase, SplitTestMixin): self.course_fixture._update_xblock(self.course_fixture._course_location, { "metadata": { u"user_partitions": [ - UserPartition(0, "Name", "Description.", [Group("0", "Group A"), Group("1", "Group B")]).to_json(), - UserPartition(1, 'Name of second Group Configuration', 'Second group configuration.', [Group("0", 'Alpha'), Group("1", 'Beta'), Group("2", 'Gamma')]).to_json(), + self.create_user_partition_json( + 0, + "Name", + "Description.", + [Group("0", "Group A"), Group("1", "Group B")] + ), + self.create_user_partition_json( + 1, + 'Name of second Group Configuration', + 'Second group configuration.', + [Group("0", 'Alpha'), Group("1", 'Beta'), Group("2", 'Gamma')] + ), ], }, }) diff --git a/lms/.coveragerc b/lms/.coveragerc index 72b7b037ef..3d5d9fd0af 100644 --- a/lms/.coveragerc +++ b/lms/.coveragerc @@ -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 diff --git a/lms/djangoapps/courseware/tests/test_split_module.py b/lms/djangoapps/courseware/tests/test_split_module.py index 2fe280c12c..ee72589459 100644 --- a/lms/djangoapps/courseware/tests/test_split_module.py +++ b/lms/djangoapps/courseware/tests/test_split_module.py @@ -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) diff --git a/lms/djangoapps/courseware/tests/test_submitting_problems.py b/lms/djangoapps/courseware/tests/test_submitting_problems.py index a51aaa6ad5..904204764b 100644 --- a/lms/djangoapps/courseware/tests/test_submitting_problems.py +++ b/lms/djangoapps/courseware/tests/test_submitting_problems.py @@ -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) diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 60caded8b5..a5efa2b738 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -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 diff --git a/lms/djangoapps/instructor_task/tests/test_integration.py b/lms/djangoapps/instructor_task/tests/test_integration.py index 5d386ddb92..eee8f124d3 100644 --- a/lms/djangoapps/instructor_task/tests/test_integration.py +++ b/lms/djangoapps/instructor_task/tests/test_integration.py @@ -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 diff --git a/lms/djangoapps/notification_prefs/features/unsubscribe.py b/lms/djangoapps/notification_prefs/features/unsubscribe.py index 1eaab95177..987211b0cd 100644 --- a/lms/djangoapps/notification_prefs/features/unsubscribe.py +++ b/lms/djangoapps/notification_prefs/features/unsubscribe.py @@ -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" diff --git a/lms/djangoapps/notification_prefs/tests.py b/lms/djangoapps/notification_prefs/tests.py index 3206dfaabc..91e84b063c 100644 --- a/lms/djangoapps/notification_prefs/tests.py +++ b/lms/djangoapps/notification_prefs/tests.py @@ -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 diff --git a/lms/djangoapps/notification_prefs/views.py b/lms/djangoapps/notification_prefs/views.py index eef24c5ee4..3cada57beb 100644 --- a/lms/djangoapps/notification_prefs/views.py +++ b/lms/djangoapps/notification_prefs/views.py @@ -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): diff --git a/lms/djangoapps/notifier_api/tests.py b/lms/djangoapps/notifier_api/tests.py index 5005781342..545adaea10 100644 --- a/lms/djangoapps/notifier_api/tests.py +++ b/lms/djangoapps/notifier_api/tests.py @@ -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 diff --git a/lms/djangoapps/notifier_api/views.py b/lms/djangoapps/notifier_api/views.py index 84a38639a9..9be79b0717 100644 --- a/lms/djangoapps/notifier_api/views.py +++ b/lms/djangoapps/notifier_api/views.py @@ -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): diff --git a/lms/djangoapps/oauth2_handler/handlers.py b/lms/djangoapps/oauth2_handler/handlers.py index b44ab7403f..fe1957bbee 100644 --- a/lms/djangoapps/oauth2_handler/handlers.py +++ b/lms/djangoapps/oauth2_handler/handlers.py @@ -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 diff --git a/lms/djangoapps/oauth2_handler/tests.py b/lms/djangoapps/oauth2_handler/tests.py index 36d7fbfd49..b1676e948f 100644 --- a/lms/djangoapps/oauth2_handler/tests.py +++ b/lms/djangoapps/oauth2_handler/tests.py @@ -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 diff --git a/lms/djangoapps/student_account/test/test_views.py b/lms/djangoapps/student_account/test/test_views.py index 6fe8456bc4..2f223a934a 100644 --- a/lms/djangoapps/student_account/test/test_views.py +++ b/lms/djangoapps/student_account/test/test_views.py @@ -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 ) diff --git a/lms/djangoapps/student_account/views.py b/lms/djangoapps/student_account/views.py index 647427a27a..9251eee900 100644 --- a/lms/djangoapps/student_account/views.py +++ b/lms/djangoapps/student_account/views.py @@ -24,8 +24,8 @@ from student.views import ( register_user as old_register_view ) -from user_api.api import account as account_api -from user_api.api import profile as profile_api +from openedx.core.djangoapps.user_api.api import account as account_api +from openedx.core.djangoapps.user_api.api import profile as profile_api from util.bad_request_rate_limiter import BadRequestRateLimiter from student_account.helpers import auth_pipeline_urls diff --git a/lms/djangoapps/student_profile/test/test_views.py b/lms/djangoapps/student_profile/test/test_views.py index c24bd0ea0a..6785796ded 100644 --- a/lms/djangoapps/student_profile/test/test_views.py +++ b/lms/djangoapps/student_profile/test/test_views.py @@ -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 diff --git a/lms/djangoapps/student_profile/views.py b/lms/djangoapps/student_profile/views.py index a7a6a3c0d0..51fac70c3e 100644 --- a/lms/djangoapps/student_profile/views.py +++ b/lms/djangoapps/student_profile/views.py @@ -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 diff --git a/lms/envs/common.py b/lms/envs/common.py index 6b260e44c0..e84d2d059b 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -931,7 +931,7 @@ MIDDLEWARE_CLASSES = ( # Adds user tags to tracking events # Must go before TrackMiddleware, to get the context set up - 'user_api.middleware.UserTagsEventContextMiddleware', + 'openedx.core.djangoapps.user_api.middleware.UserTagsEventContextMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'track.middleware.TrackMiddleware', @@ -1482,7 +1482,7 @@ INSTALLED_APPS = ( # User API 'rest_framework', - 'user_api', + 'openedx.core.djangoapps.user_api', # Shopping cart 'shoppingcart', diff --git a/lms/lib/xblock/runtime.py b/lms/lib/xblock/runtime.py index 2c1729ec49..f96a157acf 100644 --- a/lms/lib/xblock/runtime.py +++ b/lms/lib/xblock/runtime.py @@ -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() diff --git a/lms/urls.py b/lms/urls.py index 4730514479..edb02028e1 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -57,7 +57,7 @@ urlpatterns = ('', # nopep8 url(r'^heartbeat$', include('heartbeat.urls')), - url(r'^user_api/', include('user_api.urls')), + url(r'^user_api/', include('openedx.core.djangoapps.user_api.urls')), url(r'^notifier_api/', include('notifier_api.urls')), diff --git a/common/djangoapps/user_api/api/__init__.py b/openedx/__init__.py similarity index 100% rename from common/djangoapps/user_api/api/__init__.py rename to openedx/__init__.py diff --git a/common/djangoapps/user_api/migrations/__init__.py b/openedx/core/__init__.py similarity index 100% rename from common/djangoapps/user_api/migrations/__init__.py rename to openedx/core/__init__.py diff --git a/common/djangoapps/user_api/tests/__init__.py b/openedx/core/djangoapps/__init__.py similarity index 100% rename from common/djangoapps/user_api/tests/__init__.py rename to openedx/core/djangoapps/__init__.py diff --git a/openedx/core/djangoapps/user_api/__init__.py b/openedx/core/djangoapps/user_api/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/core/djangoapps/user_api/api/__init__.py b/openedx/core/djangoapps/user_api/api/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/djangoapps/user_api/api/account.py b/openedx/core/djangoapps/user_api/api/account.py similarity index 99% rename from common/djangoapps/user_api/api/account.py rename to openedx/core/djangoapps/user_api/api/account.py index 118838c7b4..e79969f1b7 100644 --- a/common/djangoapps/user_api/api/account.py +++ b/openedx/core/djangoapps/user_api/api/account.py @@ -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 diff --git a/common/djangoapps/user_api/api/course_tag.py b/openedx/core/djangoapps/user_api/api/course_tag.py similarity index 100% rename from common/djangoapps/user_api/api/course_tag.py rename to openedx/core/djangoapps/user_api/api/course_tag.py diff --git a/common/djangoapps/user_api/api/profile.py b/openedx/core/djangoapps/user_api/api/profile.py similarity index 97% rename from common/djangoapps/user_api/api/profile.py rename to openedx/core/djangoapps/user_api/api/profile.py index 8bc2f01f9c..f0033cc919 100644 --- a/common/djangoapps/user_api/api/profile.py +++ b/openedx/core/djangoapps/user_api/api/profile.py @@ -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 diff --git a/common/djangoapps/user_api/helpers.py b/openedx/core/djangoapps/user_api/helpers.py similarity index 97% rename from common/djangoapps/user_api/helpers.py rename to openedx/core/djangoapps/user_api/helpers.py index 04bd7065e0..bb85be6fa9 100644 --- a/common/djangoapps/user_api/helpers.py +++ b/openedx/core/djangoapps/user_api/helpers.py @@ -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 diff --git a/openedx/core/djangoapps/user_api/management/__init__.py b/openedx/core/djangoapps/user_api/management/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/core/djangoapps/user_api/management/commands/__init__.py b/openedx/core/djangoapps/user_api/management/commands/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/core/djangoapps/user_api/management/commands/email_opt_in_list.py b/openedx/core/djangoapps/user_api/management/commands/email_opt_in_list.py new file mode 100644 index 0000000000..b71f72ef91 --- /dev/null +++ b/openedx/core/djangoapps/user_api/management/commands/email_opt_in_list.py @@ -0,0 +1,267 @@ +"""Generate a list indicating whether users have opted in or out of receiving email from an org. + +Email opt-in is stored as an org-level preference. +When reports are generated, we need to handle: + +1) Org aliases: some organizations might have multiple course key "org" values. + We choose the most recently set preference among all org aliases. + Since this information isn't stored anywhere in edx-platform, + the caller needs to pass in the list of orgs and aliases. + +2) No preference set: Some users may not have an opt-in preference set + if they enrolled before the preference was introduced. + These users are opted in by default. + +3) Restricting to a subset of courses in an org: Some orgs have courses + that we don't want to include in the results (e.g. EdX-created test courses). + Allow the caller to explicitly specify the list of courses in the org. + +The command will always use the read replica database if one is configured. + +""" +import os.path +import csv +import time +import contextlib +import logging + +from django.core.management.base import BaseCommand, CommandError +from django.conf import settings +from django.db import connections + +from opaque_keys.edx.keys import CourseKey +from xmodule.modulestore.django import modulestore + + +LOGGER = logging.getLogger(__name__) + + +class Command(BaseCommand): + """Generate a list of email opt-in values for user enrollments. """ + + args = " --courses=COURSE_ID_LIST" + help = "Generate a list of email opt-in values for user enrollments." + + # Fields output in the CSV + OUTPUT_FIELD_NAMES = [ + "email", + "full_name", + "course_id", + "is_opted_in_for_email", + "preference_set_date" + ] + + # Number of records to read at a time when making + # multiple queries over a potentially large dataset. + QUERY_INTERVAL = 1000 + + def handle(self, *args, **options): + """Execute the command. + + Arguments: + file_path (str): Path to the output file. + *org_list (unicode): List of organization aliases. + + Keyword Arguments: + courses (unicode): Comma-separated list of course keys. If provided, + include only these courses in the results. + + Raises: + CommandError + + """ + file_path, org_list = self._parse_args(args) + + # Retrieve all the courses for the org. + # If we were given a specific list of courses to include, + # filter out anything not in that list. + courses = self._get_courses_for_org(org_list) + only_courses = options.get("courses") + if only_courses is not None: + only_courses = [ + CourseKey.from_string(course_key.strip()) + for course_key in only_courses.split(",") + ] + courses = list(set(courses) & set(only_courses)) + + # Add in organizations from the course keys, to ensure + # we're including orgs with different capitalizations + org_list = list(set(org_list) | set(course.org for course in courses)) + + # If no courses are found, abort + if not courses: + raise CommandError( + u"No courses found for orgs: {orgs}".format( + orgs=", ".join(org_list) + ) + ) + + # Let the user know what's about to happen + LOGGER.info( + u"Retrieving data for courses: {courses}".format( + courses=", ".join([unicode(course) for course in courses]) + ) + ) + + # Open the output file and generate the report. + with open(file_path, "w") as file_handle: + with self._log_execution_time(): + self._write_email_opt_in_prefs(file_handle, org_list, courses) + + # Remind the user where the output file is + LOGGER.info(u"Output file: {file_path}".format(file_path=file_path)) + + def _parse_args(self, args): + """Check and parse arguments. + + Validates that the right number of args were provided + and that the output file doesn't already exist. + + Arguments: + args (list): List of arguments given at the command line. + + Returns: + Tuple of (file_path, org_list) + + Raises: + CommandError + + """ + if len(args) < 2: + raise CommandError(u"Usage: {args}".format(args=self.args)) + + file_path = args[0] + org_list = args[1:] + if os.path.exists(file_path): + raise CommandError("File already exists at '{path}'".format(path=file_path)) + + return file_path, org_list + + def _get_courses_for_org(self, org_aliases): + """Retrieve all course keys for a particular org. + + Arguments: + org_aliases (list): List of aliases for the org. + + Returns: + List of `CourseKey`s + + """ + all_courses = modulestore().get_courses() + orgs_lowercase = [org.lower() for org in org_aliases] + return [ + course.id + for course in all_courses + if course.id.org.lower() in orgs_lowercase + ] + + @contextlib.contextmanager + def _log_execution_time(self): + """Context manager for measuring execution time. """ + start_time = time.time() + yield + execution_time = time.time() - start_time + LOGGER.info(u"Execution time: {time} seconds".format(time=execution_time)) + + def _write_email_opt_in_prefs(self, file_handle, org_aliases, courses): + """Write email opt-in preferences to the output file. + + This will generate a CSV with one row for each enrollment. + This means that the user's "opt in" preference will be specified + multiple times if the user has enrolled in multiple courses + within the org. However, the values should always be the same: + if the user is listed as "opted out" for course A, she will + also be listed as "opted out" for courses B, C, and D. + + Arguments: + file_handle (file): Handle to the output file. + org_aliases (list): List of aliases for the org. + courses (list): List of course keys in the org. + + Returns: + None + + """ + writer = csv.DictWriter(file_handle, fieldnames=self.OUTPUT_FIELD_NAMES) + cursor = self._db_cursor() + query = ( + u""" + SELECT + user.`email` AS `email`, + profile.`name` AS `full_name`, + enrollment.`course_id` AS `course_id`, + ( + SELECT value + FROM user_api_userorgtag + WHERE org IN ( {org_list} ) + AND `key`=\"email-optin\" + AND `user_id`=user.`id` + ORDER BY modified DESC + LIMIT 1 + ) AS `is_opted_in_for_email`, + ( + SELECT modified + FROM user_api_userorgtag + WHERE org IN ( {org_list} ) + AND `key`=\"email-optin\" + AND `user_id`=user.`id` + ORDER BY modified DESC + LIMIT 1 + ) AS `preference_set_date` + FROM + student_courseenrollment AS enrollment + LEFT JOIN auth_user AS user ON user.id=enrollment.user_id + LEFT JOIN auth_userprofile AS profile ON profile.user_id=user.id + WHERE enrollment.course_id IN ( {course_id_list} ) + """ + ).format( + course_id_list=self._sql_list(courses), + org_list=self._sql_list(org_aliases) + ) + + cursor.execute(query) + row_count = 0 + for row in self._iterate_results(cursor): + email, full_name, course_id, is_opted_in, pref_set_date = row + writer.writerow({ + "email": email.encode('utf-8'), + "full_name": full_name.encode('utf-8'), + "course_id": course_id.encode('utf-8'), + "is_opted_in_for_email": is_opted_in if is_opted_in else "True", + "preference_set_date": pref_set_date, + }) + row_count += 1 + + # Log the number of rows we processed + LOGGER.info(u"Retrieved {num_rows} records.".format(num_rows=row_count)) + + def _iterate_results(self, cursor): + """Iterate through the results of a database query, fetching in chunks. + + Arguments: + cursor: The database cursor + + Yields: + tuple of row values from the query + + """ + while True: + rows = cursor.fetchmany(self.QUERY_INTERVAL) + if not rows: + break + for row in rows: + yield row + + def _sql_list(self, values): + """Serialize a list of values for including in a SQL "IN" statement. """ + return u",".join([u'"{}"'.format(val) for val in values]) + + def _db_cursor(self): + """Return a database cursor to the read replica if one is available. """ + # Use the read replica if one has been configured + db_alias = ( + 'read_replica' + if 'read_replica' in settings.DATABASES + else 'default' + ) + return connections[db_alias].cursor() diff --git a/openedx/core/djangoapps/user_api/management/tests/__init__.py b/openedx/core/djangoapps/user_api/management/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/core/djangoapps/user_api/management/tests/test_email_opt_in_list.py b/openedx/core/djangoapps/user_api/management/tests/test_email_opt_in_list.py new file mode 100644 index 0000000000..58d60f0a0d --- /dev/null +++ b/openedx/core/djangoapps/user_api/management/tests/test_email_opt_in_list.py @@ -0,0 +1,399 @@ +# -*- coding: utf-8 -*- +"""Tests for the email opt-in list management command. """ +import os.path +import tempfile +import shutil +import csv +from collections import defaultdict +from unittest import skipUnless + + +import ddt +from django.conf import settings +from django.test.utils import override_settings +from django.core.management.base import CommandError + + +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, mixed_store_config +from xmodule.modulestore.tests.factories import CourseFactory +from student.tests.factories import UserFactory, CourseEnrollmentFactory +from student.models import CourseEnrollment + +import openedx.core.djangoapps.user_api.api.profile as profile_api +from openedx.core.djangoapps.user_api.models import UserOrgTag +from openedx.core.djangoapps.user_api.management.commands import email_opt_in_list + + +MODULESTORE_CONFIG = mixed_store_config(settings.COMMON_TEST_DATA_ROOT, {}, include_xml=False) + + +@ddt.ddt +@skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') +@override_settings(MODULESTORE=MODULESTORE_CONFIG) +class EmailOptInListTest(ModuleStoreTestCase): + """Tests for the email opt-in list management command. """ + + USER_USERNAME = "test_user" + USER_FIRST_NAME = u"Ṫëṡẗ" + USER_LAST_NAME = u"Űśéŕ" + + TEST_ORG = u"téśt_őŕǵ" + + OUTPUT_FILE_NAME = "test_org_email_opt_in.csv" + OUTPUT_FIELD_NAMES = [ + "email", + "full_name", + "course_id", + "is_opted_in_for_email", + "preference_set_date" + ] + + def setUp(self): + self.user = UserFactory.create( + username=self.USER_USERNAME, + first_name=self.USER_FIRST_NAME, + last_name=self.USER_LAST_NAME + ) + self.courses = [] + self.enrollments = defaultdict(list) + + def test_not_enrolled(self): + self._create_courses_and_enrollments((self.TEST_ORG, False)) + output = self._run_command(self.TEST_ORG) + + # The user isn't enrolled in the course, so the output should be empty + self._assert_output(output) + + def test_enrolled_no_pref(self): + self._create_courses_and_enrollments((self.TEST_ORG, True)) + output = self._run_command(self.TEST_ORG) + + # By default, if no preference is set by the user is enrolled, opt in + self._assert_output(output, (self.user, self.courses[0].id, True)) + + def test_enrolled_pref_opted_in(self): + self._create_courses_and_enrollments((self.TEST_ORG, True)) + self._set_opt_in_pref(self.user, self.TEST_ORG, True) + output = self._run_command(self.TEST_ORG) + self._assert_output(output, (self.user, self.courses[0].id, True)) + + def test_enrolled_pref_opted_out(self): + self._create_courses_and_enrollments((self.TEST_ORG, True)) + self._set_opt_in_pref(self.user, self.TEST_ORG, False) + output = self._run_command(self.TEST_ORG) + self._assert_output(output, (self.user, self.courses[0].id, False)) + + def test_opt_in_then_opt_out(self): + self._create_courses_and_enrollments((self.TEST_ORG, True)) + self._set_opt_in_pref(self.user, self.TEST_ORG, True) + self._set_opt_in_pref(self.user, self.TEST_ORG, False) + output = self._run_command(self.TEST_ORG) + self._assert_output(output, (self.user, self.courses[0].id, False)) + + def test_exclude_non_org_courses(self): + # Enroll in a course that's not in the org + self._create_courses_and_enrollments( + (self.TEST_ORG, True), + ("other_org", True) + ) + + # Opt out of the other course + self._set_opt_in_pref(self.user, "other_org", False) + + # The first course is included in the results, + # but the second course is excluded, + # so the user should be opted in by default. + output = self._run_command(self.TEST_ORG) + self._assert_output( + output, + (self.user, self.courses[0].id, True), + expect_pref_datetime=False + ) + + def test_enrolled_conflicting_prefs(self): + # Enroll in two courses, both in the org + self._create_courses_and_enrollments( + (self.TEST_ORG, True), + ("org_alias", True) + ) + + # Opt into the first course, then opt out of the second course + self._set_opt_in_pref(self.user, self.TEST_ORG, True) + self._set_opt_in_pref(self.user, "org_alias", False) + + # The second preference change should take precedence + # Note that *both* courses are included in the list, + # but they should have the same value. + output = self._run_command(self.TEST_ORG, other_names=["org_alias"]) + self._assert_output( + output, + (self.user, self.courses[0].id, False), + (self.user, self.courses[1].id, False) + ) + + # Opt into the first course + # Even though the other course still has a preference set to false, + # the newest preference takes precedence + self._set_opt_in_pref(self.user, self.TEST_ORG, True) + output = self._run_command(self.TEST_ORG, other_names=["org_alias"]) + self._assert_output( + output, + (self.user, self.courses[0].id, True), + (self.user, self.courses[1].id, True) + ) + + @ddt.data(True, False) + def test_unenrolled_from_all_courses(self, opt_in_pref): + # Enroll in the course and set a preference + self._create_courses_and_enrollments((self.TEST_ORG, True)) + self._set_opt_in_pref(self.user, self.TEST_ORG, opt_in_pref) + + # Unenroll from the course + CourseEnrollment.unenroll(self.user, self.courses[0].id, skip_refund=True) + + # Enrollments should still appear in the outpu + output = self._run_command(self.TEST_ORG) + self._assert_output(output, (self.user, self.courses[0].id, opt_in_pref)) + + def test_unenrolled_from_some_courses(self): + # Enroll in several courses in the org + self._create_courses_and_enrollments( + (self.TEST_ORG, True), + (self.TEST_ORG, True), + (self.TEST_ORG, True), + ("org_alias", True) + ) + + # Set a preference for the aliased course + self._set_opt_in_pref(self.user, "org_alias", False) + + # Unenroll from the aliased course + CourseEnrollment.unenroll(self.user, self.courses[3].id, skip_refund=True) + + # Expect that the preference still applies, + # and all the enrollments should appear in the list + output = self._run_command(self.TEST_ORG, other_names=["org_alias"]) + self._assert_output( + output, + (self.user, self.courses[0].id, False), + (self.user, self.courses[1].id, False), + (self.user, self.courses[2].id, False), + (self.user, self.courses[3].id, False) + ) + + def test_no_courses_for_org_name(self): + self._create_courses_and_enrollments((self.TEST_ORG, True)) + self._set_opt_in_pref(self.user, self.TEST_ORG, True) + + # No course available for this particular org + with self.assertRaisesRegexp(CommandError, "^No courses found for orgs:"): + self._run_command("other_org") + + def test_specify_subset_of_courses(self): + # Create several courses in the same org + self._create_courses_and_enrollments( + (self.TEST_ORG, True), + (self.TEST_ORG, True), + (self.TEST_ORG, True), + ) + + # Execute the command, but exclude the second course from the list + only_courses = [self.courses[0].id, self.courses[1].id] + self._run_command(self.TEST_ORG, only_courses=only_courses) + + # Choose numbers before and after the query interval boundary + @ddt.data(2, 3, 4, 5, 6, 7, 8, 9) + def test_many_users(self, num_users): + # Create many users and enroll them in the test course + course = CourseFactory.create(org=self.TEST_ORG) + usernames = [] + for _ in range(num_users): + user = UserFactory.create() + usernames.append(user.username) + CourseEnrollmentFactory.create(course_id=course.id, user=user) + + # Generate the report + output = self._run_command(self.TEST_ORG, query_interval=4) + + # Expect that every enrollment shows up in the report + output_emails = [row["email"] for row in output] + for email in output_emails: + self.assertIn(email, output_emails) + + def test_org_capitalization(self): + # Lowercase some of the org names in the course IDs + self._create_courses_and_enrollments( + ("MyOrg", True), + ("myorg", True) + ) + + # Set preferences for both courses + self._set_opt_in_pref(self.user, "MyOrg", True) + self._set_opt_in_pref(self.user, "myorg", False) + + # Execute the command, expecting both enrollments to show up + # We're passing in the uppercase org, but we set the lowercase + # version more recently, so we expect the lowercase org + # preference to apply. + output = self._run_command("MyOrg") + self._assert_output( + output, + (self.user, self.courses[0].id, False), + (self.user, self.courses[1].id, False) + ) + + @ddt.data(0, 1) + def test_not_enough_args(self, num_args): + args = ["dummy"] * num_args + expected_msg_regex = "^Usage: --courses=COURSE_ID_LIST$" + with self.assertRaisesRegexp(CommandError, expected_msg_regex): + email_opt_in_list.Command().handle(*args) + + def test_file_already_exists(self): + temp_file = tempfile.NamedTemporaryFile(delete=True) + + def _cleanup(): # pylint: disable=missing-docstring + temp_file.close() + + with self.assertRaisesRegexp(CommandError, "^File already exists"): + email_opt_in_list.Command().handle(temp_file.name, self.TEST_ORG) + + def _create_courses_and_enrollments(self, *args): + """Create courses and enrollments. + + Created courses and enrollments are stored in instance variables + so tests can refer to them later. + + Arguments: + *args: Tuples of (course_org, should_enroll), where + course_org is the name of the org in the course key + and should_enroll is a boolean indicating whether to enroll + the user in the course. + + Returns: + None + + """ + for course_number, (course_org, should_enroll) in enumerate(args): + course = CourseFactory.create(org=course_org, number=str(course_number)) + if should_enroll: + enrollment = CourseEnrollmentFactory.create( + is_active=True, + course_id=course.id, + user=self.user + ) + self.enrollments[course.id].append(enrollment) + self.courses.append(course) + + def _set_opt_in_pref(self, user, org, is_opted_in): + """Set the email opt-in preference. + + Arguments: + user (User): The user model. + org (unicode): The org in the course key. + is_opted_in (bool): Whether the user is opted in or out of emails. + + Returns: + None + + """ + profile_api.update_email_opt_in(user.username, org, is_opted_in) + + def _latest_pref_set_date(self, user): + """Retrieve the latest opt-in preference for the user, + across all orgs and preference keys. + + Arguments: + user (User): The user whos preference was set. + + Returns: + ISO-formatted date string or empty string + + """ + pref = UserOrgTag.objects.filter(user=user).order_by("-modified") + return pref[0].modified.isoformat(' ') if len(pref) > 0 else "" + + def _run_command(self, org, other_names=None, only_courses=None, query_interval=None): + """Execute the management command to generate the email opt-in list. + + Arguments: + org (unicode): The org to generate the report for. + + Keyword Arguments: + other_names (list): List of other aliases for the org. + only_courses (list): If provided, include only these course IDs in the report. + query_interval (int): If provided, override the default query interval. + + Returns: + list: The rows of the generated CSV report. Each item is a dictionary. + + """ + # Create a temporary directory for the output + # Delete it when we're finished + temp_dir_path = tempfile.mkdtemp() + + def _cleanup(): # pylint: disable=missing-docstring + shutil.rmtree(temp_dir_path) + + self.addCleanup(_cleanup) + + # Sanitize the arguments + if other_names is None: + other_names = [] + + output_path = os.path.join(temp_dir_path, self.OUTPUT_FILE_NAME) + org_list = [org] + other_names + if only_courses is not None: + only_courses = ",".join(unicode(course_id) for course_id in only_courses) + + command = email_opt_in_list.Command() + + # Override the query interval to speed up the tests + if query_interval is not None: + command.QUERY_INTERVAL = query_interval + + # Execute the command + command.handle(output_path, *org_list, courses=only_courses) + + # Retrieve the output from the file + try: + with open(output_path) as output_file: + reader = csv.DictReader(output_file, fieldnames=self.OUTPUT_FIELD_NAMES) + rows = [row for row in reader] + except IOError: + self.fail("Could not find or open output file at '{path}'".format(path=output_path)) + + # Return the output as a list of dictionaries + return rows + + def _assert_output(self, output, *args, **kwargs): + """Check the output of the report. + + Arguments: + output (list): List of rows in the output CSV file. + *args: Tuples of (user, course_id, opt_in_pref) + + Keyword Arguments: + expect_pref_datetime (bool): If false, expect an empty + string for the preference. + + Returns: + None + + Raises: + AssertionError + + """ + self.assertEqual(len(output), len(args)) + for user, course_id, opt_in_pref in args: + self.assertIn({ + "email": user.email.encode('utf-8'), + "full_name": user.profile.name.encode('utf-8'), + "course_id": unicode(course_id).encode('utf-8'), + "is_opted_in_for_email": unicode(opt_in_pref), + "preference_set_date": ( + self._latest_pref_set_date(self.user) + if kwargs.get("expect_pref_datetime", True) + else "" + ) + }, output) diff --git a/common/djangoapps/user_api/middleware.py b/openedx/core/djangoapps/user_api/middleware.py similarity index 97% rename from common/djangoapps/user_api/middleware.py rename to openedx/core/djangoapps/user_api/middleware.py index 1f4d4aa1cc..144ab8220e 100644 --- a/common/djangoapps/user_api/middleware.py +++ b/openedx/core/djangoapps/user_api/middleware.py @@ -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): diff --git a/common/djangoapps/user_api/migrations/0001_initial.py b/openedx/core/djangoapps/user_api/migrations/0001_initial.py similarity index 100% rename from common/djangoapps/user_api/migrations/0001_initial.py rename to openedx/core/djangoapps/user_api/migrations/0001_initial.py diff --git a/common/djangoapps/user_api/migrations/0002_auto__add_usercoursetags__add_unique_usercoursetags_user_course_id_key.py b/openedx/core/djangoapps/user_api/migrations/0002_auto__add_usercoursetags__add_unique_usercoursetags_user_course_id_key.py similarity index 100% rename from common/djangoapps/user_api/migrations/0002_auto__add_usercoursetags__add_unique_usercoursetags_user_course_id_key.py rename to openedx/core/djangoapps/user_api/migrations/0002_auto__add_usercoursetags__add_unique_usercoursetags_user_course_id_key.py diff --git a/common/djangoapps/user_api/migrations/0003_rename_usercoursetags.py b/openedx/core/djangoapps/user_api/migrations/0003_rename_usercoursetags.py similarity index 100% rename from common/djangoapps/user_api/migrations/0003_rename_usercoursetags.py rename to openedx/core/djangoapps/user_api/migrations/0003_rename_usercoursetags.py diff --git a/openedx/core/djangoapps/user_api/migrations/__init__.py b/openedx/core/djangoapps/user_api/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/djangoapps/user_api/models.py b/openedx/core/djangoapps/user_api/models.py similarity index 100% rename from common/djangoapps/user_api/models.py rename to openedx/core/djangoapps/user_api/models.py diff --git a/openedx/core/djangoapps/user_api/partition_schemes.py b/openedx/core/djangoapps/user_api/partition_schemes.py new file mode 100644 index 0000000000..b3b40212db --- /dev/null +++ b/openedx/core/djangoapps/user_api/partition_schemes.py @@ -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) diff --git a/common/djangoapps/user_api/serializers.py b/openedx/core/djangoapps/user_api/serializers.py similarity index 95% rename from common/djangoapps/user_api/serializers.py rename to openedx/core/djangoapps/user_api/serializers.py index 09aa25e939..7c7227fff6 100644 --- a/common/djangoapps/user_api/serializers.py +++ b/openedx/core/djangoapps/user_api/serializers.py @@ -1,7 +1,8 @@ from django.contrib.auth.models import User from rest_framework import serializers from student.models import UserProfile -from user_api.models import UserPreference + +from .models import UserPreference class UserSerializer(serializers.HyperlinkedModelSerializer): diff --git a/openedx/core/djangoapps/user_api/tests/__init__.py b/openedx/core/djangoapps/user_api/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/djangoapps/user_api/tests/factories.py b/openedx/core/djangoapps/user_api/tests/factories.py similarity index 92% rename from common/djangoapps/user_api/tests/factories.py rename to openedx/core/djangoapps/user_api/tests/factories.py index 60ee7c2ddc..8b1198bef5 100644 --- a/common/djangoapps/user_api/tests/factories.py +++ b/openedx/core/djangoapps/user_api/tests/factories.py @@ -2,9 +2,10 @@ from factory.django import DjangoModelFactory from factory import SubFactory from student.tests.factories import UserFactory -from user_api.models import UserPreference, UserCourseTag, UserOrgTag from opaque_keys.edx.locations import SlashSeparatedCourseKey +from ..models import UserPreference, UserCourseTag, UserOrgTag + # Factories are self documenting # pylint: disable=missing-docstring diff --git a/common/djangoapps/user_api/tests/test_account_api.py b/openedx/core/djangoapps/user_api/tests/test_account_api.py similarity index 99% rename from common/djangoapps/user_api/tests/test_account_api.py rename to openedx/core/djangoapps/user_api/tests/test_account_api.py index 3ab1aac029..27b708abce 100644 --- a/common/djangoapps/user_api/tests/test_account_api.py +++ b/openedx/core/djangoapps/user_api/tests/test_account_api.py @@ -12,8 +12,8 @@ from django.core import mail from django.test import TestCase from django.conf import settings -from user_api.api import account as account_api -from user_api.models import UserProfile +from ..api import account as account_api +from ..models import UserProfile @ddt.ddt diff --git a/common/djangoapps/user_api/tests/test_constants.py b/openedx/core/djangoapps/user_api/tests/test_constants.py similarity index 100% rename from common/djangoapps/user_api/tests/test_constants.py rename to openedx/core/djangoapps/user_api/tests/test_constants.py diff --git a/common/djangoapps/user_api/tests/test_course_tag_api.py b/openedx/core/djangoapps/user_api/tests/test_course_tag_api.py similarity index 91% rename from common/djangoapps/user_api/tests/test_course_tag_api.py rename to openedx/core/djangoapps/user_api/tests/test_course_tag_api.py index 51b48a56a4..a329df2a3a 100644 --- a/common/djangoapps/user_api/tests/test_course_tag_api.py +++ b/openedx/core/djangoapps/user_api/tests/test_course_tag_api.py @@ -4,11 +4,11 @@ Test the user course tag API. from django.test import TestCase from student.tests.factories import UserFactory -from user_api.api import course_tag as course_tag_api +from openedx.core.djangoapps.user_api.api import course_tag as course_tag_api from opaque_keys.edx.locations import SlashSeparatedCourseKey -class TestUserService(TestCase): +class TestCourseTagAPI(TestCase): """ Test the user service """ diff --git a/common/djangoapps/user_api/tests/test_helpers.py b/openedx/core/djangoapps/user_api/tests/test_helpers.py similarity index 96% rename from common/djangoapps/user_api/tests/test_helpers.py rename to openedx/core/djangoapps/user_api/tests/test_helpers.py index 76fcce7ddf..713ff41206 100644 --- a/common/djangoapps/user_api/tests/test_helpers.py +++ b/openedx/core/djangoapps/user_api/tests/test_helpers.py @@ -4,10 +4,10 @@ Tests for helper functions. import json import mock import ddt +from django.http import HttpRequest, HttpResponse from django.test import TestCase from nose.tools import raises -from django.http import HttpRequest, HttpResponse -from user_api.helpers import ( +from ..helpers import ( intercept_errors, shim_student_view, FormDescription, InvalidFieldError ) @@ -49,12 +49,12 @@ class InterceptErrorsTest(TestCase): def test_ignores_expected_errors(self): intercepted_function(raise_error=ValueError) - @mock.patch('user_api.helpers.LOGGER') + @mock.patch('openedx.core.djangoapps.user_api.helpers.LOGGER') def test_logs_errors(self, mock_logger): expected_log_msg = ( u"An unexpected error occurred when calling 'intercepted_function' " u"with arguments '()' and " - u"keyword arguments '{'raise_error': }': " + u"keyword arguments '{'raise_error': }': " u"FakeInputException()" ) diff --git a/common/djangoapps/user_api/tests/test_middleware.py b/openedx/core/djangoapps/user_api/tests/test_middleware.py similarity index 95% rename from common/djangoapps/user_api/tests/test_middleware.py rename to openedx/core/djangoapps/user_api/tests/test_middleware.py index d51ed2057e..297ad0a140 100644 --- a/common/djangoapps/user_api/tests/test_middleware.py +++ b/openedx/core/djangoapps/user_api/tests/test_middleware.py @@ -6,8 +6,9 @@ from django.http import HttpResponse from django.test.client import RequestFactory from student.tests.factories import UserFactory, AnonymousUserFactory -from user_api.tests.factories import UserCourseTagFactory -from user_api.middleware import UserTagsEventContextMiddleware + +from ..tests.factories import UserCourseTagFactory +from ..middleware import UserTagsEventContextMiddleware class TagsMiddlewareTest(TestCase): @@ -29,7 +30,7 @@ class TagsMiddlewareTest(TestCase): self.response = Mock(spec=HttpResponse) - patcher = patch('user_api.middleware.tracker') + patcher = patch('openedx.core.djangoapps.user_api.middleware.tracker') self.tracker = patcher.start() self.addCleanup(patcher.stop) diff --git a/common/djangoapps/user_api/tests/test_models.py b/openedx/core/djangoapps/user_api/tests/test_models.py similarity index 95% rename from common/djangoapps/user_api/tests/test_models.py rename to openedx/core/djangoapps/user_api/tests/test_models.py index fabcbec8d2..09606403ab 100644 --- a/common/djangoapps/user_api/tests/test_models.py +++ b/openedx/core/djangoapps/user_api/tests/test_models.py @@ -2,8 +2,9 @@ from django.db import IntegrityError from django.test import TestCase from xmodule.modulestore.tests.factories import CourseFactory from student.tests.factories import UserFactory -from user_api.tests.factories import UserPreferenceFactory, UserCourseTagFactory, UserOrgTagFactory -from user_api.models import UserPreference + +from ..tests.factories import UserPreferenceFactory, UserCourseTagFactory, UserOrgTagFactory +from ..models import UserPreference class UserPreferenceModelTest(TestCase): diff --git a/openedx/core/djangoapps/user_api/tests/test_partition_schemes.py b/openedx/core/djangoapps/user_api/tests/test_partition_schemes.py new file mode 100644 index 0000000000..f1b9ec9dc0 --- /dev/null +++ b/openedx/core/djangoapps/user_api/tests/test_partition_schemes.py @@ -0,0 +1,107 @@ +""" +Test the user api's partition extensions. +""" +from collections import defaultdict +from mock import patch + +from openedx.core.djangoapps.user_api.partition_schemes import RandomUserPartitionScheme, UserPartitionError +from student.tests.factories import UserFactory +from xmodule.partitions.partitions import Group, UserPartition +from xmodule.partitions.tests.test_partitions import PartitionTestCase + + +class MemoryCourseTagAPI(object): + """ + An implementation of a user service that uses an in-memory dictionary for storage + """ + def __init__(self): + self._tags = defaultdict(dict) + + def get_course_tag(self, __, course_id, key): + """Sets the value of ``key`` to ``value``""" + return self._tags[course_id].get(key) + + def set_course_tag(self, __, course_id, key, value): + """Gets the value of ``key``""" + self._tags[course_id][key] = value + + +class TestRandomUserPartitionScheme(PartitionTestCase): + """ + Test getting a user's group out of a partition + """ + + MOCK_COURSE_ID = "mock-course-id" + + def setUp(self): + super(TestRandomUserPartitionScheme, self).setUp() + # Patch in a memory-based user service instead of using the persistent version + course_tag_api = MemoryCourseTagAPI() + self.user_service_patcher = patch( + 'openedx.core.djangoapps.user_api.partition_schemes.course_tag_api', course_tag_api + ) + self.user_service_patcher.start() + + # Create a test user + self.user = UserFactory.create() + + def tearDown(self): + self.user_service_patcher.stop() + + def test_get_group_for_user(self): + # get a group assigned to the user + group1_id = RandomUserPartitionScheme.get_group_for_user(self.MOCK_COURSE_ID, self.user, self.user_partition) + + # make sure we get the same group back out every time + for __ in range(0, 10): + group2_id = RandomUserPartitionScheme.get_group_for_user(self.MOCK_COURSE_ID, self.user, self.user_partition) + self.assertEqual(group1_id, group2_id) + + def test_empty_partition(self): + empty_partition = UserPartition( + self.TEST_ID, + 'Test Partition', + 'for testing purposes', + [], + scheme=RandomUserPartitionScheme + ) + # get a group assigned to the user + with self.assertRaisesRegexp(UserPartitionError, "Cannot assign user to an empty user partition"): + RandomUserPartitionScheme.get_group_for_user(self.MOCK_COURSE_ID, self.user, empty_partition) + + def test_user_in_deleted_group(self): + # get a group assigned to the user - should be group 0 or 1 + old_group = RandomUserPartitionScheme.get_group_for_user(self.MOCK_COURSE_ID, self.user, self.user_partition) + self.assertIn(old_group.id, [0, 1]) + + # Change the group definitions! No more group 0 or 1 + groups = [Group(3, 'Group 3'), Group(4, 'Group 4')] + user_partition = UserPartition(self.TEST_ID, 'Test Partition', 'for testing purposes', groups) + + # Now, get a new group using the same call - should be 3 or 4 + new_group = RandomUserPartitionScheme.get_group_for_user(self.MOCK_COURSE_ID, self.user, user_partition) + self.assertIn(new_group.id, [3, 4]) + + # We should get the same group over multiple calls + new_group_2 = RandomUserPartitionScheme.get_group_for_user(self.MOCK_COURSE_ID, self.user, user_partition) + self.assertEqual(new_group, new_group_2) + + def test_change_group_name(self): + # Changing the name of the group shouldn't affect anything + # get a group assigned to the user - should be group 0 or 1 + old_group = RandomUserPartitionScheme.get_group_for_user(self.MOCK_COURSE_ID, self.user, self.user_partition) + self.assertIn(old_group.id, [0, 1]) + + # Change the group names + groups = [Group(0, 'Group 0'), Group(1, 'Group 1')] + user_partition = UserPartition( + self.TEST_ID, + 'Test Partition', + 'for testing purposes', + groups, + scheme=RandomUserPartitionScheme + ) + + # Now, get a new group using the same call + new_group = RandomUserPartitionScheme.get_group_for_user(self.MOCK_COURSE_ID, self.user, user_partition) + self.assertEqual(old_group.id, new_group.id) diff --git a/common/djangoapps/user_api/tests/test_profile_api.py b/openedx/core/djangoapps/user_api/tests/test_profile_api.py similarity index 98% rename from common/djangoapps/user_api/tests/test_profile_api.py rename to openedx/core/djangoapps/user_api/tests/test_profile_api.py index 9e0f7e554e..297b50c4f8 100644 --- a/common/djangoapps/user_api/tests/test_profile_api.py +++ b/openedx/core/djangoapps/user_api/tests/test_profile_api.py @@ -10,9 +10,9 @@ from dateutil.parser import parse as parse_datetime from xmodule.modulestore.tests.factories import CourseFactory import datetime -from user_api.api import account as account_api -from user_api.api import profile as profile_api -from user_api.models import UserProfile, UserOrgTag +from ..api import account as account_api +from ..api import profile as profile_api +from ..models import UserProfile, UserOrgTag @ddt.ddt diff --git a/common/djangoapps/user_api/tests/test_views.py b/openedx/core/djangoapps/user_api/tests/test_views.py similarity index 99% rename from common/djangoapps/user_api/tests/test_views.py rename to openedx/core/djangoapps/user_api/tests/test_views.py index 69380eb220..6a3f8259a4 100644 --- a/common/djangoapps/user_api/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/tests/test_views.py @@ -16,16 +16,16 @@ from pytz import UTC import mock from xmodule.modulestore.tests.factories import CourseFactory -from user_api.api import account as account_api, profile as profile_api - from student.tests.factories import UserFactory -from user_api.models import UserOrgTag -from user_api.tests.factories import UserPreferenceFactory +from unittest import SkipTest from django_comment_common import models from opaque_keys.edx.locations import SlashSeparatedCourseKey from third_party_auth.tests.testutil import simulate_running_pipeline -from user_api.tests.test_constants import SORTED_COUNTRIES +from ..api import account as account_api, profile as profile_api +from ..models import UserOrgTag +from ..tests.factories import UserPreferenceFactory +from ..tests.test_constants import SORTED_COUNTRIES TEST_API_KEY = "test_api_key" diff --git a/common/djangoapps/user_api/urls.py b/openedx/core/djangoapps/user_api/urls.py similarity index 76% rename from common/djangoapps/user_api/urls.py rename to openedx/core/djangoapps/user_api/urls.py index b6b903f350..3b840cc6b5 100644 --- a/common/djangoapps/user_api/urls.py +++ b/openedx/core/djangoapps/user_api/urls.py @@ -1,17 +1,21 @@ -# pylint: disable=missing-docstring +""" +Defines the URL routes for this app. +""" + from django.conf import settings from django.conf.urls import include, patterns, url from rest_framework import routers -from user_api import views as user_api_views -from user_api.models import UserPreference + +from . import views as user_api_views +from .models import UserPreference -user_api_router = routers.DefaultRouter() -user_api_router.register(r'users', user_api_views.UserViewSet) -user_api_router.register(r'user_prefs', user_api_views.UserPreferenceViewSet) +USER_API_ROUTER = routers.DefaultRouter() +USER_API_ROUTER.register(r'users', user_api_views.UserViewSet) +USER_API_ROUTER.register(r'user_prefs', user_api_views.UserPreferenceViewSet) urlpatterns = patterns( '', - url(r'^v1/', include(user_api_router.urls)), + url(r'^v1/', include(USER_API_ROUTER.urls)), url( r'^v1/preferences/(?P{})/users/$'.format(UserPreference.KEY_REGEX), user_api_views.PreferenceUsersListView.as_view() diff --git a/common/djangoapps/user_api/views.py b/openedx/core/djangoapps/user_api/views.py similarity index 99% rename from common/djangoapps/user_api/views.py rename to openedx/core/djangoapps/user_api/views.py index 41e69aa3f5..3ee7415290 100644 --- a/common/djangoapps/user_api/views.py +++ b/openedx/core/djangoapps/user_api/views.py @@ -1,5 +1,6 @@ """HTTP end-points for the User API. """ import copy +import third_party_auth from django.conf import settings from django.contrib.auth.models import User @@ -19,16 +20,15 @@ from rest_framework import viewsets from rest_framework.views import APIView from rest_framework.exceptions import ParseError from django_countries import countries -from user_api.serializers import UserSerializer, UserPreferenceSerializer -from user_api.models import UserPreference, UserProfile from django_comment_common.models import Role from opaque_keys.edx.locations import SlashSeparatedCourseKey from edxmako.shortcuts import marketing_link -import third_party_auth from util.authentication import SessionAuthenticationAllowInactiveUser -from user_api.api import account as account_api, profile as profile_api -from user_api.helpers import FormDescription, shim_student_view, require_post_params +from .api import account as account_api, profile as profile_api +from .helpers import FormDescription, shim_student_view, require_post_params +from .models import UserPreference +from .serializers import UserSerializer, UserPreferenceSerializer class ApiKeyHeaderPermission(permissions.BasePermission): diff --git a/pavelib/tests.py b/pavelib/tests.py index e8da51c6fc..9b1058b221 100644 --- a/pavelib/tests.py +++ b/pavelib/tests.py @@ -48,7 +48,7 @@ def test_system(options): if test_id: if not system: system = test_id.split('/')[0] - if system == 'common': + if system in ['common', 'openedx']: system = 'lms' opts['test_id'] = test_id diff --git a/pavelib/utils/test/suites/nose_suite.py b/pavelib/utils/test/suites/nose_suite.py index 4e0e0992d8..42787bc216 100644 --- a/pavelib/utils/test/suites/nose_suite.py +++ b/pavelib/utils/test/suites/nose_suite.py @@ -128,7 +128,7 @@ class SystemTestSuite(NoseTestSuite): # django-nose will import them early in the test process, # thereby making sure that we load any django models that are # only defined in test files. - default_test_id = "{system}/djangoapps/* common/djangoapps/*".format( + default_test_id = "{system}/djangoapps/* common/djangoapps/* openedx/core/djangoapps/*".format( system=self.root ) diff --git a/setup.py b/setup.py index cdd8f74211..90ee4ef273 100644 --- a/setup.py +++ b/setup.py @@ -1,14 +1,24 @@ -from setuptools import setup, find_packages +""" +Setup script for the Open edX package. +""" + +from setuptools import setup setup( name="Open edX", - version="0.1", + version="0.2", install_requires=['distribute'], requires=[], # NOTE: These are not the names we should be installing. This tree should - # be reorgnized to be a more conventional Python tree. + # be reorganized to be a more conventional Python tree. packages=[ + "openedx.core.djangoapps.user_api", "lms", "cms", ], + entry_points={ + 'openedx.user_partition_scheme': [ + 'random = openedx.core.djangoapps.user_api.partition_schemes:RandomUserPartitionScheme', + ], + } ) From f24f01d21751bd81b8808f740c63fc55c2db929c Mon Sep 17 00:00:00 2001 From: jsa Date: Wed, 5 Nov 2014 15:59:20 -0500 Subject: [PATCH 2/5] Add support for user partitioning based on cohort. JIRA: TNL-710 IMPORTANT: this commit converts the course_groups package to using migrations. When deploying to an existing openedx instance, migration 0001 may fail with an error indicating that the CourseUserGroup table already exists. If this happens, running the 0001 migration first, with the --fake option, is recommended. After performing this step, remaining migrations should work as expected. --- CHANGELOG.rst | 2 + cms/envs/common.py | 2 +- common/test/db_cache/bok_choy_data.json | 2 +- common/test/db_cache/bok_choy_schema.sql | 137 +++++++++- common/test/db_cache/lettuce.db | Bin 578560 -> 604160 bytes .../django_comment_client/forum/tests.py | 13 +- .../django_comment_client/forum/views.py | 7 +- .../django_comment_client/tests/group_id.py | 2 - .../django_comment_client/tests/utils.py | 4 +- lms/djangoapps/django_comment_client/utils.py | 4 +- .../instructor_analytics/tests/test_basic.py | 4 +- lms/djangoapps/notifier_api/serializers.py | 2 +- lms/djangoapps/notifier_api/tests.py | 2 +- lms/envs/common.py | 2 +- lms/urls.py | 12 +- .../djangoapps/course_groups/__init__.py | 0 .../core}/djangoapps/course_groups/cohorts.py | 16 +- .../course_groups/migrations/0001_initial.py | 88 ++++++ ...add_model_CourseUserGroupPartitionGroup.py | 82 ++++++ .../course_groups/migrations}/__init__.py | 0 .../core}/djangoapps/course_groups/models.py | 14 + .../course_groups/partition_scheme.py | 75 +++++ .../course_groups/tests/__init__.py | 0 .../djangoapps/course_groups/tests/helpers.py | 3 +- .../course_groups/tests/test_cohorts.py | 147 +++++++++- .../tests/test_partition_scheme.py | 257 ++++++++++++++++++ .../course_groups/tests/test_views.py | 16 +- .../core}/djangoapps/course_groups/views.py | 0 .../djangoapps/user_api/api/course_tag.py | 2 +- .../user_api/tests/test_partition_schemes.py | 13 + .../djangoapps/user_api/tests/test_views.py | 2 +- openedx/core/djangoapps/user_api/views.py | 2 +- setup.py | 2 + 33 files changed, 859 insertions(+), 55 deletions(-) rename {common => openedx/core}/djangoapps/course_groups/__init__.py (100%) rename {common => openedx/core}/djangoapps/course_groups/cohorts.py (95%) create mode 100644 openedx/core/djangoapps/course_groups/migrations/0001_initial.py create mode 100644 openedx/core/djangoapps/course_groups/migrations/0002_add_model_CourseUserGroupPartitionGroup.py rename {common/djangoapps/course_groups/tests => openedx/core/djangoapps/course_groups/migrations}/__init__.py (100%) rename {common => openedx/core}/djangoapps/course_groups/models.py (75%) create mode 100644 openedx/core/djangoapps/course_groups/partition_scheme.py create mode 100644 openedx/core/djangoapps/course_groups/tests/__init__.py rename {common => openedx/core}/djangoapps/course_groups/tests/helpers.py (98%) rename {common => openedx/core}/djangoapps/course_groups/tests/test_cohorts.py (79%) create mode 100644 openedx/core/djangoapps/course_groups/tests/test_partition_scheme.py rename {common => openedx/core}/djangoapps/course_groups/tests/test_views.py (98%) rename {common => openedx/core}/djangoapps/course_groups/views.py (100%) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a044ecc910..b548bb8dc6 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,8 @@ These are notable changes in edx-platform. This is a rolling list of changes, in roughly chronological order, most recent first. Add your entries at or near the top. Include a label indicating the component affected. +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 diff --git a/cms/envs/common.py b/cms/envs/common.py index 4feb741582..9243f3b514 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -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', diff --git a/common/test/db_cache/bok_choy_data.json b/common/test/db_cache/bok_choy_data.json index 22d4e181be..ac7cb2495c 100644 --- a/common/test/db_cache/bok_choy_data.json +++ b/common/test/db_cache/bok_choy_data.json @@ -1 +1 @@ -[{"pk": 60, "model": "contenttypes.contenttype", "fields": {"model": "accesstoken", "name": "access token", "app_label": "oauth2"}}, {"pk": 120, "model": "contenttypes.contenttype", "fields": {"model": "aiclassifier", "name": "ai classifier", "app_label": "assessment"}}, {"pk": 119, "model": "contenttypes.contenttype", "fields": {"model": "aiclassifierset", "name": "ai classifier set", "app_label": "assessment"}}, {"pk": 122, "model": "contenttypes.contenttype", "fields": {"model": "aigradingworkflow", "name": "ai grading workflow", "app_label": "assessment"}}, {"pk": 121, "model": "contenttypes.contenttype", "fields": {"model": "aitrainingworkflow", "name": "ai training workflow", "app_label": "assessment"}}, {"pk": 34, "model": "contenttypes.contenttype", "fields": {"model": "anonymoususerid", "name": "anonymous user id", "app_label": "student"}}, {"pk": 63, "model": "contenttypes.contenttype", "fields": {"model": "article", "name": "article", "app_label": "wiki"}}, {"pk": 64, "model": "contenttypes.contenttype", "fields": {"model": "articleforobject", "name": "Article for object", "app_label": "wiki"}}, {"pk": 67, "model": "contenttypes.contenttype", "fields": {"model": "articleplugin", "name": "article plugin", "app_label": "wiki"}}, {"pk": 65, "model": "contenttypes.contenttype", "fields": {"model": "articlerevision", "name": "article revision", "app_label": "wiki"}}, {"pk": 72, "model": "contenttypes.contenttype", "fields": {"model": "articlesubscription", "name": "article subscription", "app_label": "wiki"}}, {"pk": 110, "model": "contenttypes.contenttype", "fields": {"model": "assessment", "name": "assessment", "app_label": "assessment"}}, {"pk": 113, "model": "contenttypes.contenttype", "fields": {"model": "assessmentfeedback", "name": "assessment feedback", "app_label": "assessment"}}, {"pk": 112, "model": "contenttypes.contenttype", "fields": {"model": "assessmentfeedbackoption", "name": "assessment feedback option", "app_label": "assessment"}}, {"pk": 111, "model": "contenttypes.contenttype", "fields": {"model": "assessmentpart", "name": "assessment part", "app_label": "assessment"}}, {"pk": 123, "model": "contenttypes.contenttype", "fields": {"model": "assessmentworkflow", "name": "assessment workflow", "app_label": "workflow"}}, {"pk": 124, "model": "contenttypes.contenttype", "fields": {"model": "assessmentworkflowstep", "name": "assessment workflow step", "app_label": "workflow"}}, {"pk": 20, "model": "contenttypes.contenttype", "fields": {"model": "association", "name": "association", "app_label": "django_openid_auth"}}, {"pk": 25, "model": "contenttypes.contenttype", "fields": {"model": "association", "name": "association", "app_label": "default"}}, {"pk": 92, "model": "contenttypes.contenttype", "fields": {"model": "certificateitem", "name": "certificate item", "app_label": "shoppingcart"}}, {"pk": 48, "model": "contenttypes.contenttype", "fields": {"model": "certificatewhitelist", "name": "certificate whitelist", "app_label": "certificates"}}, {"pk": 58, "model": "contenttypes.contenttype", "fields": {"model": "client", "name": "client", "app_label": "oauth2"}}, {"pk": 26, "model": "contenttypes.contenttype", "fields": {"model": "code", "name": "code", "app_label": "default"}}, {"pk": 4, "model": "contenttypes.contenttype", "fields": {"model": "contenttype", "name": "content type", "app_label": "contenttypes"}}, {"pk": 88, "model": "contenttypes.contenttype", "fields": {"model": "coupon", "name": "coupon", "app_label": "shoppingcart"}}, {"pk": 89, "model": "contenttypes.contenttype", "fields": {"model": "couponredemption", "name": "coupon redemption", "app_label": "shoppingcart"}}, {"pk": 46, "model": "contenttypes.contenttype", "fields": {"model": "courseaccessrole", "name": "course access role", "app_label": "student"}}, {"pk": 56, "model": "contenttypes.contenttype", "fields": {"model": "courseauthorization", "name": "course authorization", "app_label": "bulk_email"}}, {"pk": 53, "model": "contenttypes.contenttype", "fields": {"model": "courseemail", "name": "course email", "app_label": "bulk_email"}}, {"pk": 55, "model": "contenttypes.contenttype", "fields": {"model": "courseemailtemplate", "name": "course email template", "app_label": "bulk_email"}}, {"pk": 44, "model": "contenttypes.contenttype", "fields": {"model": "courseenrollment", "name": "course enrollment", "app_label": "student"}}, {"pk": 45, "model": "contenttypes.contenttype", "fields": {"model": "courseenrollmentallowed", "name": "course enrollment allowed", "app_label": "student"}}, {"pk": 93, "model": "contenttypes.contenttype", "fields": {"model": "coursemode", "name": "course mode", "app_label": "course_modes"}}, {"pk": 94, "model": "contenttypes.contenttype", "fields": {"model": "coursemodesarchive", "name": "course modes archive", "app_label": "course_modes"}}, {"pk": 86, "model": "contenttypes.contenttype", "fields": {"model": "courseregistrationcode", "name": "course registration code", "app_label": "shoppingcart"}}, {"pk": 101, "model": "contenttypes.contenttype", "fields": {"model": "coursererunstate", "name": "course rerun state", "app_label": "course_action_state"}}, {"pk": 51, "model": "contenttypes.contenttype", "fields": {"model": "coursesoftware", "name": "course software", "app_label": "licenses"}}, {"pk": 18, "model": "contenttypes.contenttype", "fields": {"model": "courseusergroup", "name": "course user group", "app_label": "course_groups"}}, {"pk": 127, "model": "contenttypes.contenttype", "fields": {"model": "coursevideo", "name": "course video", "app_label": "edxval"}}, {"pk": 108, "model": "contenttypes.contenttype", "fields": {"model": "criterion", "name": "criterion", "app_label": "assessment"}}, {"pk": 109, "model": "contenttypes.contenttype", "fields": {"model": "criterionoption", "name": "criterion option", "app_label": "assessment"}}, {"pk": 10, "model": "contenttypes.contenttype", "fields": {"model": "crontabschedule", "name": "crontab", "app_label": "djcelery"}}, {"pk": 96, "model": "contenttypes.contenttype", "fields": {"model": "darklangconfig", "name": "dark lang config", "app_label": "dark_lang"}}, {"pk": 98, "model": "contenttypes.contenttype", "fields": {"model": "embargoedcourse", "name": "embargoed course", "app_label": "embargo"}}, {"pk": 99, "model": "contenttypes.contenttype", "fields": {"model": "embargoedstate", "name": "embargoed state", "app_label": "embargo"}}, {"pk": 128, "model": "contenttypes.contenttype", "fields": {"model": "encodedvideo", "name": "encoded video", "app_label": "edxval"}}, {"pk": 57, "model": "contenttypes.contenttype", "fields": {"model": "externalauthmap", "name": "external auth map", "app_label": "external_auth"}}, {"pk": 49, "model": "contenttypes.contenttype", "fields": {"model": "generatedcertificate", "name": "generated certificate", "app_label": "certificates"}}, {"pk": 59, "model": "contenttypes.contenttype", "fields": {"model": "grant", "name": "grant", "app_label": "oauth2"}}, {"pk": 2, "model": "contenttypes.contenttype", "fields": {"model": "group", "name": "group", "app_label": "auth"}}, {"pk": 50, "model": "contenttypes.contenttype", "fields": {"model": "instructortask", "name": "instructor task", "app_label": "instructor_task"}}, {"pk": 9, "model": "contenttypes.contenttype", "fields": {"model": "intervalschedule", "name": "interval", "app_label": "djcelery"}}, {"pk": 85, "model": "contenttypes.contenttype", "fields": {"model": "invoice", "name": "invoice", "app_label": "shoppingcart"}}, {"pk": 100, "model": "contenttypes.contenttype", "fields": {"model": "ipfilter", "name": "ip filter", "app_label": "embargo"}}, {"pk": 102, "model": "contenttypes.contenttype", "fields": {"model": "linkedin", "name": "linked in", "app_label": "linkedin"}}, {"pk": 22, "model": "contenttypes.contenttype", "fields": {"model": "logentry", "name": "log entry", "app_label": "admin"}}, {"pk": 43, "model": "contenttypes.contenttype", "fields": {"model": "loginfailures", "name": "login failures", "app_label": "student"}}, {"pk": 97, "model": "contenttypes.contenttype", "fields": {"model": "midcoursereverificationwindow", "name": "midcourse reverification window", "app_label": "reverification"}}, {"pk": 15, "model": "contenttypes.contenttype", "fields": {"model": "migrationhistory", "name": "migration history", "app_label": "south"}}, {"pk": 19, "model": "contenttypes.contenttype", "fields": {"model": "nonce", "name": "nonce", "app_label": "django_openid_auth"}}, {"pk": 24, "model": "contenttypes.contenttype", "fields": {"model": "nonce", "name": "nonce", "app_label": "default"}}, {"pk": 79, "model": "contenttypes.contenttype", "fields": {"model": "note", "name": "note", "app_label": "notes"}}, {"pk": 76, "model": "contenttypes.contenttype", "fields": {"model": "notification", "name": "notification", "app_label": "django_notify"}}, {"pk": 32, "model": "contenttypes.contenttype", "fields": {"model": "offlinecomputedgrade", "name": "offline computed grade", "app_label": "courseware"}}, {"pk": 33, "model": "contenttypes.contenttype", "fields": {"model": "offlinecomputedgradelog", "name": "offline computed grade log", "app_label": "courseware"}}, {"pk": 54, "model": "contenttypes.contenttype", "fields": {"model": "optout", "name": "optout", "app_label": "bulk_email"}}, {"pk": 83, "model": "contenttypes.contenttype", "fields": {"model": "order", "name": "order", "app_label": "shoppingcart"}}, {"pk": 84, "model": "contenttypes.contenttype", "fields": {"model": "orderitem", "name": "order item", "app_label": "shoppingcart"}}, {"pk": 90, "model": "contenttypes.contenttype", "fields": {"model": "paidcourseregistration", "name": "paid course registration", "app_label": "shoppingcart"}}, {"pk": 91, "model": "contenttypes.contenttype", "fields": {"model": "paidcourseregistrationannotation", "name": "paid course registration annotation", "app_label": "shoppingcart"}}, {"pk": 42, "model": "contenttypes.contenttype", "fields": {"model": "passwordhistory", "name": "password history", "app_label": "student"}}, {"pk": 114, "model": "contenttypes.contenttype", "fields": {"model": "peerworkflow", "name": "peer workflow", "app_label": "assessment"}}, {"pk": 115, "model": "contenttypes.contenttype", "fields": {"model": "peerworkflowitem", "name": "peer workflow item", "app_label": "assessment"}}, {"pk": 41, "model": "contenttypes.contenttype", "fields": {"model": "pendingemailchange", "name": "pending email change", "app_label": "student"}}, {"pk": 40, "model": "contenttypes.contenttype", "fields": {"model": "pendingnamechange", "name": "pending name change", "app_label": "student"}}, {"pk": 12, "model": "contenttypes.contenttype", "fields": {"model": "periodictask", "name": "periodic task", "app_label": "djcelery"}}, {"pk": 11, "model": "contenttypes.contenttype", "fields": {"model": "periodictasks", "name": "periodic tasks", "app_label": "djcelery"}}, {"pk": 1, "model": "contenttypes.contenttype", "fields": {"model": "permission", "name": "permission", "app_label": "auth"}}, {"pk": 125, "model": "contenttypes.contenttype", "fields": {"model": "profile", "name": "profile", "app_label": "edxval"}}, {"pk": 17, "model": "contenttypes.contenttype", "fields": {"model": "psychometricdata", "name": "psychometric data", "app_label": "psychometrics"}}, {"pk": 78, "model": "contenttypes.contenttype", "fields": {"model": "puzzlecomplete", "name": "puzzle complete", "app_label": "foldit"}}, {"pk": 61, "model": "contenttypes.contenttype", "fields": {"model": "refreshtoken", "name": "refresh token", "app_label": "oauth2"}}, {"pk": 39, "model": "contenttypes.contenttype", "fields": {"model": "registration", "name": "registration", "app_label": "student"}}, {"pk": 87, "model": "contenttypes.contenttype", "fields": {"model": "registrationcoderedemption", "name": "registration code redemption", "app_label": "shoppingcart"}}, {"pk": 68, "model": "contenttypes.contenttype", "fields": {"model": "reusableplugin", "name": "reusable plugin", "app_label": "wiki"}}, {"pk": 70, "model": "contenttypes.contenttype", "fields": {"model": "revisionplugin", "name": "revision plugin", "app_label": "wiki"}}, {"pk": 71, "model": "contenttypes.contenttype", "fields": {"model": "revisionpluginrevision", "name": "revision plugin revision", "app_label": "wiki"}}, {"pk": 107, "model": "contenttypes.contenttype", "fields": {"model": "rubric", "name": "rubric", "app_label": "assessment"}}, {"pk": 8, "model": "contenttypes.contenttype", "fields": {"model": "tasksetmeta", "name": "saved group result", "app_label": "djcelery"}}, {"pk": 77, "model": "contenttypes.contenttype", "fields": {"model": "score", "name": "score", "app_label": "foldit"}}, {"pk": 105, "model": "contenttypes.contenttype", "fields": {"model": "score", "name": "score", "app_label": "submissions"}}, {"pk": 106, "model": "contenttypes.contenttype", "fields": {"model": "scoresummary", "name": "score summary", "app_label": "submissions"}}, {"pk": 16, "model": "contenttypes.contenttype", "fields": {"model": "servercircuit", "name": "server circuit", "app_label": "circuit"}}, {"pk": 5, "model": "contenttypes.contenttype", "fields": {"model": "session", "name": "session", "app_label": "sessions"}}, {"pk": 74, "model": "contenttypes.contenttype", "fields": {"model": "settings", "name": "settings", "app_label": "django_notify"}}, {"pk": 69, "model": "contenttypes.contenttype", "fields": {"model": "simpleplugin", "name": "simple plugin", "app_label": "wiki"}}, {"pk": 6, "model": "contenttypes.contenttype", "fields": {"model": "site", "name": "site", "app_label": "sites"}}, {"pk": 95, "model": "contenttypes.contenttype", "fields": {"model": "softwaresecurephotoverification", "name": "software secure photo verification", "app_label": "verify_student"}}, {"pk": 80, "model": "contenttypes.contenttype", "fields": {"model": "splashconfig", "name": "splash config", "app_label": "splash"}}, {"pk": 103, "model": "contenttypes.contenttype", "fields": {"model": "studentitem", "name": "student item", "app_label": "submissions"}}, {"pk": 27, "model": "contenttypes.contenttype", "fields": {"model": "studentmodule", "name": "student module", "app_label": "courseware"}}, {"pk": 28, "model": "contenttypes.contenttype", "fields": {"model": "studentmodulehistory", "name": "student module history", "app_label": "courseware"}}, {"pk": 117, "model": "contenttypes.contenttype", "fields": {"model": "studenttrainingworkflow", "name": "student training workflow", "app_label": "assessment"}}, {"pk": 118, "model": "contenttypes.contenttype", "fields": {"model": "studenttrainingworkflowitem", "name": "student training workflow item", "app_label": "assessment"}}, {"pk": 104, "model": "contenttypes.contenttype", "fields": {"model": "submission", "name": "submission", "app_label": "submissions"}}, {"pk": 75, "model": "contenttypes.contenttype", "fields": {"model": "subscription", "name": "subscription", "app_label": "django_notify"}}, {"pk": 129, "model": "contenttypes.contenttype", "fields": {"model": "subtitle", "name": "subtitle", "app_label": "edxval"}}, {"pk": 14, "model": "contenttypes.contenttype", "fields": {"model": "taskstate", "name": "task", "app_label": "djcelery"}}, {"pk": 7, "model": "contenttypes.contenttype", "fields": {"model": "taskmeta", "name": "task state", "app_label": "djcelery"}}, {"pk": 47, "model": "contenttypes.contenttype", "fields": {"model": "trackinglog", "name": "tracking log", "app_label": "track"}}, {"pk": 116, "model": "contenttypes.contenttype", "fields": {"model": "trainingexample", "name": "training example", "app_label": "assessment"}}, {"pk": 62, "model": "contenttypes.contenttype", "fields": {"model": "trustedclient", "name": "trusted client", "app_label": "oauth2_provider"}}, {"pk": 73, "model": "contenttypes.contenttype", "fields": {"model": "notificationtype", "name": "type", "app_label": "django_notify"}}, {"pk": 66, "model": "contenttypes.contenttype", "fields": {"model": "urlpath", "name": "URL path", "app_label": "wiki"}}, {"pk": 3, "model": "contenttypes.contenttype", "fields": {"model": "user", "name": "user", "app_label": "auth"}}, {"pk": 82, "model": "contenttypes.contenttype", "fields": {"model": "usercoursetag", "name": "user course tag", "app_label": "user_api"}}, {"pk": 52, "model": "contenttypes.contenttype", "fields": {"model": "userlicense", "name": "user license", "app_label": "licenses"}}, {"pk": 21, "model": "contenttypes.contenttype", "fields": {"model": "useropenid", "name": "user open id", "app_label": "django_openid_auth"}}, {"pk": 81, "model": "contenttypes.contenttype", "fields": {"model": "userpreference", "name": "user preference", "app_label": "user_api"}}, {"pk": 36, "model": "contenttypes.contenttype", "fields": {"model": "userprofile", "name": "user profile", "app_label": "student"}}, {"pk": 37, "model": "contenttypes.contenttype", "fields": {"model": "usersignupsource", "name": "user signup source", "app_label": "student"}}, {"pk": 23, "model": "contenttypes.contenttype", "fields": {"model": "usersocialauth", "name": "user social auth", "app_label": "default"}}, {"pk": 35, "model": "contenttypes.contenttype", "fields": {"model": "userstanding", "name": "user standing", "app_label": "student"}}, {"pk": 38, "model": "contenttypes.contenttype", "fields": {"model": "usertestgroup", "name": "user test group", "app_label": "student"}}, {"pk": 126, "model": "contenttypes.contenttype", "fields": {"model": "video", "name": "video", "app_label": "edxval"}}, {"pk": 13, "model": "contenttypes.contenttype", "fields": {"model": "workerstate", "name": "worker", "app_label": "djcelery"}}, {"pk": 31, "model": "contenttypes.contenttype", "fields": {"model": "xmodulestudentinfofield", "name": "x module student info field", "app_label": "courseware"}}, {"pk": 30, "model": "contenttypes.contenttype", "fields": {"model": "xmodulestudentprefsfield", "name": "x module student prefs field", "app_label": "courseware"}}, {"pk": 29, "model": "contenttypes.contenttype", "fields": {"model": "xmoduleuserstatesummaryfield", "name": "x module user state summary field", "app_label": "courseware"}}, {"pk": 1, "model": "sites.site", "fields": {"domain": "example.com", "name": "example.com"}}, {"pk": 1, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:32Z", "app_name": "courseware", "migration": "0001_initial"}}, {"pk": 2, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:32Z", "app_name": "courseware", "migration": "0002_add_indexes"}}, {"pk": 3, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:32Z", "app_name": "courseware", "migration": "0003_done_grade_cache"}}, {"pk": 4, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:33Z", "app_name": "courseware", "migration": "0004_add_field_studentmodule_course_id"}}, {"pk": 5, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:33Z", "app_name": "courseware", "migration": "0005_auto__add_offlinecomputedgrade__add_unique_offlinecomputedgrade_user_c"}}, {"pk": 6, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:33Z", "app_name": "courseware", "migration": "0006_create_student_module_history"}}, {"pk": 7, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:33Z", "app_name": "courseware", "migration": "0007_allow_null_version_in_history"}}, {"pk": 8, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:33Z", "app_name": "courseware", "migration": "0008_add_xmodule_storage"}}, {"pk": 9, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:33Z", "app_name": "courseware", "migration": "0009_add_field_default"}}, {"pk": 10, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:33Z", "app_name": "courseware", "migration": "0010_rename_xblock_field_content_to_user_state_summary"}}, {"pk": 11, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:34Z", "app_name": "student", "migration": "0001_initial"}}, {"pk": 12, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:34Z", "app_name": "student", "migration": "0002_text_to_varchar_and_indexes"}}, {"pk": 13, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:34Z", "app_name": "student", "migration": "0003_auto__add_usertestgroup"}}, {"pk": 14, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:34Z", "app_name": "student", "migration": "0004_add_email_index"}}, {"pk": 15, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:34Z", "app_name": "student", "migration": "0005_name_change"}}, {"pk": 16, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:34Z", "app_name": "student", "migration": "0006_expand_meta_field"}}, {"pk": 17, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:34Z", "app_name": "student", "migration": "0007_convert_to_utf8"}}, {"pk": 18, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:34Z", "app_name": "student", "migration": "0008__auto__add_courseregistration"}}, {"pk": 19, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:34Z", "app_name": "student", "migration": "0009_auto__del_courseregistration__add_courseenrollment"}}, {"pk": 20, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:34Z", "app_name": "student", "migration": "0010_auto__chg_field_courseenrollment_course_id"}}, {"pk": 21, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:34Z", "app_name": "student", "migration": "0011_auto__chg_field_courseenrollment_user__del_unique_courseenrollment_use"}}, {"pk": 22, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:34Z", "app_name": "student", "migration": "0012_auto__add_field_userprofile_gender__add_field_userprofile_date_of_birt"}}, {"pk": 23, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:34Z", "app_name": "student", "migration": "0013_auto__chg_field_userprofile_meta"}}, {"pk": 24, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:34Z", "app_name": "student", "migration": "0014_auto__del_courseenrollment"}}, {"pk": 25, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:34Z", "app_name": "student", "migration": "0015_auto__add_courseenrollment__add_unique_courseenrollment_user_course_id"}}, {"pk": 26, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:34Z", "app_name": "student", "migration": "0016_auto__add_field_courseenrollment_date__chg_field_userprofile_country"}}, {"pk": 27, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:35Z", "app_name": "student", "migration": "0017_rename_date_to_created"}}, {"pk": 28, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:35Z", "app_name": "student", "migration": "0018_auto"}}, {"pk": 29, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:35Z", "app_name": "student", "migration": "0019_create_approved_demographic_fields_fall_2012"}}, {"pk": 30, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:35Z", "app_name": "student", "migration": "0020_add_test_center_user"}}, {"pk": 31, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:35Z", "app_name": "student", "migration": "0021_remove_askbot"}}, {"pk": 32, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:35Z", "app_name": "student", "migration": "0022_auto__add_courseenrollmentallowed__add_unique_courseenrollmentallowed_"}}, {"pk": 33, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:35Z", "app_name": "student", "migration": "0023_add_test_center_registration"}}, {"pk": 34, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:35Z", "app_name": "student", "migration": "0024_add_allow_certificate"}}, {"pk": 35, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:36Z", "app_name": "student", "migration": "0025_auto__add_field_courseenrollmentallowed_auto_enroll"}}, {"pk": 36, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:36Z", "app_name": "student", "migration": "0026_auto__remove_index_student_testcenterregistration_accommodation_request"}}, {"pk": 37, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:36Z", "app_name": "student", "migration": "0027_add_active_flag_and_mode_to_courseware_enrollment"}}, {"pk": 38, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:36Z", "app_name": "student", "migration": "0028_auto__add_userstanding"}}, {"pk": 39, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:36Z", "app_name": "student", "migration": "0029_add_lookup_table_between_user_and_anonymous_student_id"}}, {"pk": 40, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:36Z", "app_name": "student", "migration": "0029_remove_pearson"}}, {"pk": 41, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:36Z", "app_name": "student", "migration": "0030_auto__chg_field_anonymoususerid_anonymous_user_id"}}, {"pk": 42, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:36Z", "app_name": "student", "migration": "0031_drop_student_anonymoususerid_temp_archive"}}, {"pk": 43, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:36Z", "app_name": "student", "migration": "0032_add_field_UserProfile_country_add_field_UserProfile_city"}}, {"pk": 44, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:36Z", "app_name": "student", "migration": "0032_auto__add_loginfailures"}}, {"pk": 45, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:36Z", "app_name": "student", "migration": "0033_auto__add_passwordhistory"}}, {"pk": 46, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:36Z", "app_name": "student", "migration": "0034_auto__add_courseaccessrole"}}, {"pk": 47, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:37Z", "app_name": "student", "migration": "0035_access_roles"}}, {"pk": 48, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:37Z", "app_name": "student", "migration": "0036_access_roles_orgless"}}, {"pk": 49, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:37Z", "app_name": "student", "migration": "0037_auto__add_courseregistrationcode"}}, {"pk": 50, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:37Z", "app_name": "student", "migration": "0038_auto__add_usersignupsource"}}, {"pk": 51, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:37Z", "app_name": "student", "migration": "0039_auto__del_courseregistrationcode"}}, {"pk": 52, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:38Z", "app_name": "student", "migration": "0040_auto__del_field_usersignupsource_user_id__add_field_usersignupsource_u"}}, {"pk": 53, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:38Z", "app_name": "track", "migration": "0001_initial"}}, {"pk": 54, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:38Z", "app_name": "track", "migration": "0002_auto__add_field_trackinglog_host__chg_field_trackinglog_event_type__ch"}}, {"pk": 55, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:38Z", "app_name": "certificates", "migration": "0001_added_generatedcertificates"}}, {"pk": 56, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:38Z", "app_name": "certificates", "migration": "0002_auto__add_field_generatedcertificate_download_url"}}, {"pk": 57, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:38Z", "app_name": "certificates", "migration": "0003_auto__add_field_generatedcertificate_enabled"}}, {"pk": 58, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:38Z", "app_name": "certificates", "migration": "0004_auto__add_field_generatedcertificate_graded_certificate_id__add_field_"}}, {"pk": 59, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:38Z", "app_name": "certificates", "migration": "0005_auto__add_field_generatedcertificate_name"}}, {"pk": 60, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:38Z", "app_name": "certificates", "migration": "0006_auto__chg_field_generatedcertificate_certificate_id"}}, {"pk": 61, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:39Z", "app_name": "certificates", "migration": "0007_auto__add_revokedcertificate"}}, {"pk": 62, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:39Z", "app_name": "certificates", "migration": "0008_auto__del_revokedcertificate__del_field_generatedcertificate_name__add"}}, {"pk": 63, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:39Z", "app_name": "certificates", "migration": "0009_auto__del_field_generatedcertificate_graded_download_url__del_field_ge"}}, {"pk": 64, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:39Z", "app_name": "certificates", "migration": "0010_auto__del_field_generatedcertificate_enabled__add_field_generatedcerti"}}, {"pk": 65, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:39Z", "app_name": "certificates", "migration": "0011_auto__del_field_generatedcertificate_certificate_id__add_field_generat"}}, {"pk": 66, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:39Z", "app_name": "certificates", "migration": "0012_auto__add_field_generatedcertificate_name__add_field_generatedcertific"}}, {"pk": 67, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:39Z", "app_name": "certificates", "migration": "0013_auto__add_field_generatedcertificate_error_reason"}}, {"pk": 68, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:39Z", "app_name": "certificates", "migration": "0014_adding_whitelist"}}, {"pk": 69, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:39Z", "app_name": "certificates", "migration": "0015_adding_mode_for_verified_certs"}}, {"pk": 70, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:39Z", "app_name": "instructor_task", "migration": "0001_initial"}}, {"pk": 71, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:39Z", "app_name": "instructor_task", "migration": "0002_add_subtask_field"}}, {"pk": 72, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:40Z", "app_name": "licenses", "migration": "0001_initial"}}, {"pk": 73, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:40Z", "app_name": "bulk_email", "migration": "0001_initial"}}, {"pk": 74, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:40Z", "app_name": "bulk_email", "migration": "0002_change_field_names"}}, {"pk": 75, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:40Z", "app_name": "bulk_email", "migration": "0003_add_optout_user"}}, {"pk": 76, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:40Z", "app_name": "bulk_email", "migration": "0004_migrate_optout_user"}}, {"pk": 77, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:40Z", "app_name": "bulk_email", "migration": "0005_remove_optout_email"}}, {"pk": 78, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:40Z", "app_name": "bulk_email", "migration": "0006_add_course_email_template"}}, {"pk": 79, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:40Z", "app_name": "bulk_email", "migration": "0007_load_course_email_template"}}, {"pk": 80, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:40Z", "app_name": "bulk_email", "migration": "0008_add_course_authorizations"}}, {"pk": 81, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:40Z", "app_name": "bulk_email", "migration": "0009_force_unique_course_ids"}}, {"pk": 82, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:41Z", "app_name": "bulk_email", "migration": "0010_auto__chg_field_optout_course_id__add_field_courseemail_template_name_"}}, {"pk": 83, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:41Z", "app_name": "external_auth", "migration": "0001_initial"}}, {"pk": 84, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:41Z", "app_name": "oauth2", "migration": "0001_initial"}}, {"pk": 85, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:41Z", "app_name": "oauth2", "migration": "0002_auto__chg_field_client_user"}}, {"pk": 86, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:41Z", "app_name": "oauth2", "migration": "0003_auto__add_field_client_name"}}, {"pk": 87, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:41Z", "app_name": "oauth2", "migration": "0004_auto__add_index_accesstoken_token"}}, {"pk": 88, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:42Z", "app_name": "oauth2_provider", "migration": "0001_initial"}}, {"pk": 89, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:42Z", "app_name": "wiki", "migration": "0001_initial"}}, {"pk": 90, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:42Z", "app_name": "wiki", "migration": "0002_auto__add_field_articleplugin_created"}}, {"pk": 91, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:43Z", "app_name": "wiki", "migration": "0003_auto__add_field_urlpath_article"}}, {"pk": 92, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:43Z", "app_name": "wiki", "migration": "0004_populate_urlpath__article"}}, {"pk": 93, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:43Z", "app_name": "wiki", "migration": "0005_auto__chg_field_urlpath_article"}}, {"pk": 94, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:43Z", "app_name": "wiki", "migration": "0006_auto__add_attachmentrevision__add_image__add_attachment"}}, {"pk": 95, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:43Z", "app_name": "wiki", "migration": "0007_auto__add_articlesubscription"}}, {"pk": 96, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:43Z", "app_name": "wiki", "migration": "0008_auto__add_simpleplugin__add_revisionpluginrevision__add_imagerevision_"}}, {"pk": 97, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:43Z", "app_name": "wiki", "migration": "0009_auto__add_field_imagerevision_width__add_field_imagerevision_height"}}, {"pk": 98, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:43Z", "app_name": "wiki", "migration": "0010_auto__chg_field_imagerevision_image"}}, {"pk": 99, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:43Z", "app_name": "wiki", "migration": "0011_auto__chg_field_imagerevision_width__chg_field_imagerevision_height"}}, {"pk": 100, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:44Z", "app_name": "django_notify", "migration": "0001_initial"}}, {"pk": 101, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:44Z", "app_name": "notifications", "migration": "0001_initial"}}, {"pk": 102, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:44Z", "app_name": "foldit", "migration": "0001_initial"}}, {"pk": 103, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:45Z", "app_name": "django_comment_client", "migration": "0001_initial"}}, {"pk": 104, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:45Z", "app_name": "django_comment_common", "migration": "0001_initial"}}, {"pk": 105, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:45Z", "app_name": "notes", "migration": "0001_initial"}}, {"pk": 106, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:45Z", "app_name": "splash", "migration": "0001_initial"}}, {"pk": 107, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:45Z", "app_name": "splash", "migration": "0002_auto__add_field_splashconfig_unaffected_url_paths"}}, {"pk": 108, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:46Z", "app_name": "user_api", "migration": "0001_initial"}}, {"pk": 109, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:46Z", "app_name": "user_api", "migration": "0002_auto__add_usercoursetags__add_unique_usercoursetags_user_course_id_key"}}, {"pk": 110, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:46Z", "app_name": "user_api", "migration": "0003_rename_usercoursetags"}}, {"pk": 111, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:46Z", "app_name": "shoppingcart", "migration": "0001_initial"}}, {"pk": 112, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:46Z", "app_name": "shoppingcart", "migration": "0002_auto__add_field_paidcourseregistration_mode"}}, {"pk": 113, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:46Z", "app_name": "shoppingcart", "migration": "0003_auto__del_field_orderitem_line_cost"}}, {"pk": 114, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:46Z", "app_name": "shoppingcart", "migration": "0004_auto__add_field_orderitem_fulfilled_time"}}, {"pk": 115, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:47Z", "app_name": "shoppingcart", "migration": "0005_auto__add_paidcourseregistrationannotation__add_field_orderitem_report"}}, {"pk": 116, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:47Z", "app_name": "shoppingcart", "migration": "0006_auto__add_field_order_refunded_time__add_field_orderitem_refund_reques"}}, {"pk": 117, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:47Z", "app_name": "shoppingcart", "migration": "0007_auto__add_field_orderitem_service_fee"}}, {"pk": 118, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:47Z", "app_name": "shoppingcart", "migration": "0008_auto__add_coupons__add_couponredemption__chg_field_certificateitem_cou"}}, {"pk": 119, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:47Z", "app_name": "shoppingcart", "migration": "0009_auto__del_coupons__add_courseregistrationcode__add_coupon__chg_field_c"}}, {"pk": 120, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:47Z", "app_name": "shoppingcart", "migration": "0010_auto__add_registrationcoderedemption__del_field_courseregistrationcode"}}, {"pk": 121, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:47Z", "app_name": "shoppingcart", "migration": "0011_auto__add_invoice__add_field_courseregistrationcode_invoice"}}, {"pk": 122, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:48Z", "app_name": "shoppingcart", "migration": "0012_auto__del_field_courseregistrationcode_transaction_group_name__del_fie"}}, {"pk": 123, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:48Z", "app_name": "shoppingcart", "migration": "0013_auto__add_field_invoice_is_valid"}}, {"pk": 124, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:48Z", "app_name": "shoppingcart", "migration": "0014_auto__del_field_invoice_tax_id__add_field_invoice_address_line_1__add_"}}, {"pk": 125, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:48Z", "app_name": "shoppingcart", "migration": "0015_auto__del_field_invoice_purchase_order_number__del_field_invoice_compa"}}, {"pk": 126, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:48Z", "app_name": "shoppingcart", "migration": "0016_auto__del_field_invoice_company_email__del_field_invoice_company_refer"}}, {"pk": 127, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:48Z", "app_name": "shoppingcart", "migration": "0017_auto__add_field_courseregistrationcode_order__chg_field_registrationco"}}, {"pk": 128, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:49Z", "app_name": "course_modes", "migration": "0001_initial"}}, {"pk": 129, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:49Z", "app_name": "course_modes", "migration": "0002_auto__add_field_coursemode_currency"}}, {"pk": 130, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:49Z", "app_name": "course_modes", "migration": "0003_auto__add_unique_coursemode_course_id_currency_mode_slug"}}, {"pk": 131, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:49Z", "app_name": "course_modes", "migration": "0004_auto__add_field_coursemode_expiration_date"}}, {"pk": 132, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:49Z", "app_name": "course_modes", "migration": "0005_auto__add_field_coursemode_expiration_datetime"}}, {"pk": 133, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:49Z", "app_name": "course_modes", "migration": "0006_expiration_date_to_datetime"}}, {"pk": 134, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:49Z", "app_name": "course_modes", "migration": "0007_add_description"}}, {"pk": 135, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:49Z", "app_name": "course_modes", "migration": "0007_auto__add_coursemodesarchive__chg_field_coursemode_course_id"}}, {"pk": 136, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:49Z", "app_name": "verify_student", "migration": "0001_initial"}}, {"pk": 137, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:49Z", "app_name": "verify_student", "migration": "0002_auto__add_field_softwaresecurephotoverification_window"}}, {"pk": 138, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:49Z", "app_name": "verify_student", "migration": "0003_auto__add_field_softwaresecurephotoverification_display"}}, {"pk": 139, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:49Z", "app_name": "dark_lang", "migration": "0001_initial"}}, {"pk": 140, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:49Z", "app_name": "dark_lang", "migration": "0002_enable_on_install"}}, {"pk": 141, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:50Z", "app_name": "reverification", "migration": "0001_initial"}}, {"pk": 142, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:50Z", "app_name": "embargo", "migration": "0001_initial"}}, {"pk": 143, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:50Z", "app_name": "course_action_state", "migration": "0001_initial"}}, {"pk": 144, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:50Z", "app_name": "course_action_state", "migration": "0002_add_rerun_display_name"}}, {"pk": 145, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:51Z", "app_name": "linkedin", "migration": "0001_initial"}}, {"pk": 146, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:51Z", "app_name": "submissions", "migration": "0001_initial"}}, {"pk": 147, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:51Z", "app_name": "submissions", "migration": "0002_auto__add_scoresummary"}}, {"pk": 148, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:51Z", "app_name": "submissions", "migration": "0003_auto__del_field_submission_answer__add_field_submission_raw_answer"}}, {"pk": 149, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:51Z", "app_name": "submissions", "migration": "0004_auto__add_field_score_reset"}}, {"pk": 150, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:52Z", "app_name": "assessment", "migration": "0001_initial"}}, {"pk": 151, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:52Z", "app_name": "assessment", "migration": "0002_auto__add_assessmentfeedbackoption__del_field_assessmentfeedback_feedb"}}, {"pk": 152, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:52Z", "app_name": "assessment", "migration": "0003_add_index_pw_course_item_student"}}, {"pk": 153, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:52Z", "app_name": "assessment", "migration": "0004_auto__add_field_peerworkflow_graded_count"}}, {"pk": 154, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:52Z", "app_name": "assessment", "migration": "0005_auto__del_field_peerworkflow_graded_count__add_field_peerworkflow_grad"}}, {"pk": 155, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:52Z", "app_name": "assessment", "migration": "0006_auto__add_field_assessmentpart_feedback"}}, {"pk": 156, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:52Z", "app_name": "assessment", "migration": "0007_auto__chg_field_assessmentpart_feedback"}}, {"pk": 157, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:53Z", "app_name": "assessment", "migration": "0008_student_training"}}, {"pk": 158, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:53Z", "app_name": "assessment", "migration": "0009_auto__add_unique_studenttrainingworkflowitem_order_num_workflow"}}, {"pk": 159, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:53Z", "app_name": "assessment", "migration": "0010_auto__add_unique_studenttrainingworkflow_submission_uuid"}}, {"pk": 160, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:53Z", "app_name": "assessment", "migration": "0011_ai_training"}}, {"pk": 161, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:53Z", "app_name": "assessment", "migration": "0012_move_algorithm_id_to_classifier_set"}}, {"pk": 162, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:53Z", "app_name": "assessment", "migration": "0013_auto__add_field_aigradingworkflow_essay_text"}}, {"pk": 163, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:53Z", "app_name": "assessment", "migration": "0014_auto__add_field_aitrainingworkflow_item_id__add_field_aitrainingworkfl"}}, {"pk": 164, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:54Z", "app_name": "assessment", "migration": "0015_auto__add_unique_aitrainingworkflow_uuid__add_unique_aigradingworkflow"}}, {"pk": 165, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:54Z", "app_name": "assessment", "migration": "0016_auto__add_field_aiclassifierset_course_id__add_field_aiclassifierset_i"}}, {"pk": 166, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:54Z", "app_name": "assessment", "migration": "0016_auto__add_field_rubric_structure_hash"}}, {"pk": 167, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:54Z", "app_name": "assessment", "migration": "0017_rubric_structure_hash"}}, {"pk": 168, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:54Z", "app_name": "assessment", "migration": "0018_auto__add_field_assessmentpart_criterion"}}, {"pk": 169, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:54Z", "app_name": "assessment", "migration": "0019_assessmentpart_criterion_field"}}, {"pk": 170, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:54Z", "app_name": "assessment", "migration": "0020_assessmentpart_criterion_not_null"}}, {"pk": 171, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:54Z", "app_name": "assessment", "migration": "0021_assessmentpart_option_nullable"}}, {"pk": 172, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:54Z", "app_name": "assessment", "migration": "0022__add_label_fields"}}, {"pk": 173, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:54Z", "app_name": "assessment", "migration": "0023_assign_criteria_and_option_labels"}}, {"pk": 174, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:54Z", "app_name": "workflow", "migration": "0001_initial"}}, {"pk": 175, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:54Z", "app_name": "workflow", "migration": "0002_auto__add_field_assessmentworkflow_course_id__add_field_assessmentwork"}}, {"pk": 176, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:55Z", "app_name": "workflow", "migration": "0003_auto__add_assessmentworkflowstep"}}, {"pk": 177, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:55Z", "app_name": "edxval", "migration": "0001_initial"}}, {"pk": 178, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:55Z", "app_name": "edxval", "migration": "0002_default_profiles"}}, {"pk": 179, "model": "south.migrationhistory", "fields": {"applied": "2014-10-06T18:53:55Z", "app_name": "django_extensions", "migration": "0001_empty"}}, {"pk": 1, "model": "edxval.profile", "fields": {"width": 1280, "profile_name": "desktop_mp4", "extension": "mp4", "height": 720}}, {"pk": 2, "model": "edxval.profile", "fields": {"width": 1280, "profile_name": "desktop_webm", "extension": "webm", "height": 720}}, {"pk": 3, "model": "edxval.profile", "fields": {"width": 960, "profile_name": "mobile_high", "extension": "mp4", "height": 540}}, {"pk": 4, "model": "edxval.profile", "fields": {"width": 640, "profile_name": "mobile_low", "extension": "mp4", "height": 360}}, {"pk": 5, "model": "edxval.profile", "fields": {"width": 1920, "profile_name": "youtube", "extension": "mp4", "height": 1080}}, {"pk": 64, "model": "auth.permission", "fields": {"codename": "add_logentry", "name": "Can add log entry", "content_type": 22}}, {"pk": 65, "model": "auth.permission", "fields": {"codename": "change_logentry", "name": "Can change log entry", "content_type": 22}}, {"pk": 66, "model": "auth.permission", "fields": {"codename": "delete_logentry", "name": "Can delete log entry", "content_type": 22}}, {"pk": 361, "model": "auth.permission", "fields": {"codename": "add_aiclassifier", "name": "Can add ai classifier", "content_type": 120}}, {"pk": 362, "model": "auth.permission", "fields": {"codename": "change_aiclassifier", "name": "Can change ai classifier", "content_type": 120}}, {"pk": 363, "model": "auth.permission", "fields": {"codename": "delete_aiclassifier", "name": "Can delete ai classifier", "content_type": 120}}, {"pk": 358, "model": "auth.permission", "fields": {"codename": "add_aiclassifierset", "name": "Can add ai classifier set", "content_type": 119}}, {"pk": 359, "model": "auth.permission", "fields": {"codename": "change_aiclassifierset", "name": "Can change ai classifier set", "content_type": 119}}, {"pk": 360, "model": "auth.permission", "fields": {"codename": "delete_aiclassifierset", "name": "Can delete ai classifier set", "content_type": 119}}, {"pk": 367, "model": "auth.permission", "fields": {"codename": "add_aigradingworkflow", "name": "Can add ai grading workflow", "content_type": 122}}, {"pk": 368, "model": "auth.permission", "fields": {"codename": "change_aigradingworkflow", "name": "Can change ai grading workflow", "content_type": 122}}, {"pk": 369, "model": "auth.permission", "fields": {"codename": "delete_aigradingworkflow", "name": "Can delete ai grading workflow", "content_type": 122}}, {"pk": 364, "model": "auth.permission", "fields": {"codename": "add_aitrainingworkflow", "name": "Can add ai training workflow", "content_type": 121}}, {"pk": 365, "model": "auth.permission", "fields": {"codename": "change_aitrainingworkflow", "name": "Can change ai training workflow", "content_type": 121}}, {"pk": 366, "model": "auth.permission", "fields": {"codename": "delete_aitrainingworkflow", "name": "Can delete ai training workflow", "content_type": 121}}, {"pk": 331, "model": "auth.permission", "fields": {"codename": "add_assessment", "name": "Can add assessment", "content_type": 110}}, {"pk": 332, "model": "auth.permission", "fields": {"codename": "change_assessment", "name": "Can change assessment", "content_type": 110}}, {"pk": 333, "model": "auth.permission", "fields": {"codename": "delete_assessment", "name": "Can delete assessment", "content_type": 110}}, {"pk": 340, "model": "auth.permission", "fields": {"codename": "add_assessmentfeedback", "name": "Can add assessment feedback", "content_type": 113}}, {"pk": 341, "model": "auth.permission", "fields": {"codename": "change_assessmentfeedback", "name": "Can change assessment feedback", "content_type": 113}}, {"pk": 342, "model": "auth.permission", "fields": {"codename": "delete_assessmentfeedback", "name": "Can delete assessment feedback", "content_type": 113}}, {"pk": 337, "model": "auth.permission", "fields": {"codename": "add_assessmentfeedbackoption", "name": "Can add assessment feedback option", "content_type": 112}}, {"pk": 338, "model": "auth.permission", "fields": {"codename": "change_assessmentfeedbackoption", "name": "Can change assessment feedback option", "content_type": 112}}, {"pk": 339, "model": "auth.permission", "fields": {"codename": "delete_assessmentfeedbackoption", "name": "Can delete assessment feedback option", "content_type": 112}}, {"pk": 334, "model": "auth.permission", "fields": {"codename": "add_assessmentpart", "name": "Can add assessment part", "content_type": 111}}, {"pk": 335, "model": "auth.permission", "fields": {"codename": "change_assessmentpart", "name": "Can change assessment part", "content_type": 111}}, {"pk": 336, "model": "auth.permission", "fields": {"codename": "delete_assessmentpart", "name": "Can delete assessment part", "content_type": 111}}, {"pk": 325, "model": "auth.permission", "fields": {"codename": "add_criterion", "name": "Can add criterion", "content_type": 108}}, {"pk": 326, "model": "auth.permission", "fields": {"codename": "change_criterion", "name": "Can change criterion", "content_type": 108}}, {"pk": 327, "model": "auth.permission", "fields": {"codename": "delete_criterion", "name": "Can delete criterion", "content_type": 108}}, {"pk": 328, "model": "auth.permission", "fields": {"codename": "add_criterionoption", "name": "Can add criterion option", "content_type": 109}}, {"pk": 329, "model": "auth.permission", "fields": {"codename": "change_criterionoption", "name": "Can change criterion option", "content_type": 109}}, {"pk": 330, "model": "auth.permission", "fields": {"codename": "delete_criterionoption", "name": "Can delete criterion option", "content_type": 109}}, {"pk": 343, "model": "auth.permission", "fields": {"codename": "add_peerworkflow", "name": "Can add peer workflow", "content_type": 114}}, {"pk": 344, "model": "auth.permission", "fields": {"codename": "change_peerworkflow", "name": "Can change peer workflow", "content_type": 114}}, {"pk": 345, "model": "auth.permission", "fields": {"codename": "delete_peerworkflow", "name": "Can delete peer workflow", "content_type": 114}}, {"pk": 346, "model": "auth.permission", "fields": {"codename": "add_peerworkflowitem", "name": "Can add peer workflow item", "content_type": 115}}, {"pk": 347, "model": "auth.permission", "fields": {"codename": "change_peerworkflowitem", "name": "Can change peer workflow item", "content_type": 115}}, {"pk": 348, "model": "auth.permission", "fields": {"codename": "delete_peerworkflowitem", "name": "Can delete peer workflow item", "content_type": 115}}, {"pk": 322, "model": "auth.permission", "fields": {"codename": "add_rubric", "name": "Can add rubric", "content_type": 107}}, {"pk": 323, "model": "auth.permission", "fields": {"codename": "change_rubric", "name": "Can change rubric", "content_type": 107}}, {"pk": 324, "model": "auth.permission", "fields": {"codename": "delete_rubric", "name": "Can delete rubric", "content_type": 107}}, {"pk": 352, "model": "auth.permission", "fields": {"codename": "add_studenttrainingworkflow", "name": "Can add student training workflow", "content_type": 117}}, {"pk": 353, "model": "auth.permission", "fields": {"codename": "change_studenttrainingworkflow", "name": "Can change student training workflow", "content_type": 117}}, {"pk": 354, "model": "auth.permission", "fields": {"codename": "delete_studenttrainingworkflow", "name": "Can delete student training workflow", "content_type": 117}}, {"pk": 355, "model": "auth.permission", "fields": {"codename": "add_studenttrainingworkflowitem", "name": "Can add student training workflow item", "content_type": 118}}, {"pk": 356, "model": "auth.permission", "fields": {"codename": "change_studenttrainingworkflowitem", "name": "Can change student training workflow item", "content_type": 118}}, {"pk": 357, "model": "auth.permission", "fields": {"codename": "delete_studenttrainingworkflowitem", "name": "Can delete student training workflow item", "content_type": 118}}, {"pk": 349, "model": "auth.permission", "fields": {"codename": "add_trainingexample", "name": "Can add training example", "content_type": 116}}, {"pk": 350, "model": "auth.permission", "fields": {"codename": "change_trainingexample", "name": "Can change training example", "content_type": 116}}, {"pk": 351, "model": "auth.permission", "fields": {"codename": "delete_trainingexample", "name": "Can delete training example", "content_type": 116}}, {"pk": 4, "model": "auth.permission", "fields": {"codename": "add_group", "name": "Can add group", "content_type": 2}}, {"pk": 5, "model": "auth.permission", "fields": {"codename": "change_group", "name": "Can change group", "content_type": 2}}, {"pk": 6, "model": "auth.permission", "fields": {"codename": "delete_group", "name": "Can delete group", "content_type": 2}}, {"pk": 1, "model": "auth.permission", "fields": {"codename": "add_permission", "name": "Can add permission", "content_type": 1}}, {"pk": 2, "model": "auth.permission", "fields": {"codename": "change_permission", "name": "Can change permission", "content_type": 1}}, {"pk": 3, "model": "auth.permission", "fields": {"codename": "delete_permission", "name": "Can delete permission", "content_type": 1}}, {"pk": 7, "model": "auth.permission", "fields": {"codename": "add_user", "name": "Can add user", "content_type": 3}}, {"pk": 8, "model": "auth.permission", "fields": {"codename": "change_user", "name": "Can change user", "content_type": 3}}, {"pk": 9, "model": "auth.permission", "fields": {"codename": "delete_user", "name": "Can delete user", "content_type": 3}}, {"pk": 166, "model": "auth.permission", "fields": {"codename": "add_courseauthorization", "name": "Can add course authorization", "content_type": 56}}, {"pk": 167, "model": "auth.permission", "fields": {"codename": "change_courseauthorization", "name": "Can change course authorization", "content_type": 56}}, {"pk": 168, "model": "auth.permission", "fields": {"codename": "delete_courseauthorization", "name": "Can delete course authorization", "content_type": 56}}, {"pk": 157, "model": "auth.permission", "fields": {"codename": "add_courseemail", "name": "Can add course email", "content_type": 53}}, {"pk": 158, "model": "auth.permission", "fields": {"codename": "change_courseemail", "name": "Can change course email", "content_type": 53}}, {"pk": 159, "model": "auth.permission", "fields": {"codename": "delete_courseemail", "name": "Can delete course email", "content_type": 53}}, {"pk": 163, "model": "auth.permission", "fields": {"codename": "add_courseemailtemplate", "name": "Can add course email template", "content_type": 55}}, {"pk": 164, "model": "auth.permission", "fields": {"codename": "change_courseemailtemplate", "name": "Can change course email template", "content_type": 55}}, {"pk": 165, "model": "auth.permission", "fields": {"codename": "delete_courseemailtemplate", "name": "Can delete course email template", "content_type": 55}}, {"pk": 160, "model": "auth.permission", "fields": {"codename": "add_optout", "name": "Can add optout", "content_type": 54}}, {"pk": 161, "model": "auth.permission", "fields": {"codename": "change_optout", "name": "Can change optout", "content_type": 54}}, {"pk": 162, "model": "auth.permission", "fields": {"codename": "delete_optout", "name": "Can delete optout", "content_type": 54}}, {"pk": 142, "model": "auth.permission", "fields": {"codename": "add_certificatewhitelist", "name": "Can add certificate whitelist", "content_type": 48}}, {"pk": 143, "model": "auth.permission", "fields": {"codename": "change_certificatewhitelist", "name": "Can change certificate whitelist", "content_type": 48}}, {"pk": 144, "model": "auth.permission", "fields": {"codename": "delete_certificatewhitelist", "name": "Can delete certificate whitelist", "content_type": 48}}, {"pk": 145, "model": "auth.permission", "fields": {"codename": "add_generatedcertificate", "name": "Can add generated certificate", "content_type": 49}}, {"pk": 146, "model": "auth.permission", "fields": {"codename": "change_generatedcertificate", "name": "Can change generated certificate", "content_type": 49}}, {"pk": 147, "model": "auth.permission", "fields": {"codename": "delete_generatedcertificate", "name": "Can delete generated certificate", "content_type": 49}}, {"pk": 46, "model": "auth.permission", "fields": {"codename": "add_servercircuit", "name": "Can add server circuit", "content_type": 16}}, {"pk": 47, "model": "auth.permission", "fields": {"codename": "change_servercircuit", "name": "Can change server circuit", "content_type": 16}}, {"pk": 48, "model": "auth.permission", "fields": {"codename": "delete_servercircuit", "name": "Can delete server circuit", "content_type": 16}}, {"pk": 10, "model": "auth.permission", "fields": {"codename": "add_contenttype", "name": "Can add content type", "content_type": 4}}, {"pk": 11, "model": "auth.permission", "fields": {"codename": "change_contenttype", "name": "Can change content type", "content_type": 4}}, {"pk": 12, "model": "auth.permission", "fields": {"codename": "delete_contenttype", "name": "Can delete content type", "content_type": 4}}, {"pk": 94, "model": "auth.permission", "fields": {"codename": "add_offlinecomputedgrade", "name": "Can add offline computed grade", "content_type": 32}}, {"pk": 95, "model": "auth.permission", "fields": {"codename": "change_offlinecomputedgrade", "name": "Can change offline computed grade", "content_type": 32}}, {"pk": 96, "model": "auth.permission", "fields": {"codename": "delete_offlinecomputedgrade", "name": "Can delete offline computed grade", "content_type": 32}}, {"pk": 97, "model": "auth.permission", "fields": {"codename": "add_offlinecomputedgradelog", "name": "Can add offline computed grade log", "content_type": 33}}, {"pk": 98, "model": "auth.permission", "fields": {"codename": "change_offlinecomputedgradelog", "name": "Can change offline computed grade log", "content_type": 33}}, {"pk": 99, "model": "auth.permission", "fields": {"codename": "delete_offlinecomputedgradelog", "name": "Can delete offline computed grade log", "content_type": 33}}, {"pk": 79, "model": "auth.permission", "fields": {"codename": "add_studentmodule", "name": "Can add student module", "content_type": 27}}, {"pk": 80, "model": "auth.permission", "fields": {"codename": "change_studentmodule", "name": "Can change student module", "content_type": 27}}, {"pk": 81, "model": "auth.permission", "fields": {"codename": "delete_studentmodule", "name": "Can delete student module", "content_type": 27}}, {"pk": 82, "model": "auth.permission", "fields": {"codename": "add_studentmodulehistory", "name": "Can add student module history", "content_type": 28}}, {"pk": 83, "model": "auth.permission", "fields": {"codename": "change_studentmodulehistory", "name": "Can change student module history", "content_type": 28}}, {"pk": 84, "model": "auth.permission", "fields": {"codename": "delete_studentmodulehistory", "name": "Can delete student module history", "content_type": 28}}, {"pk": 91, "model": "auth.permission", "fields": {"codename": "add_xmodulestudentinfofield", "name": "Can add x module student info field", "content_type": 31}}, {"pk": 92, "model": "auth.permission", "fields": {"codename": "change_xmodulestudentinfofield", "name": "Can change x module student info field", "content_type": 31}}, {"pk": 93, "model": "auth.permission", "fields": {"codename": "delete_xmodulestudentinfofield", "name": "Can delete x module student info field", "content_type": 31}}, {"pk": 88, "model": "auth.permission", "fields": {"codename": "add_xmodulestudentprefsfield", "name": "Can add x module student prefs field", "content_type": 30}}, {"pk": 89, "model": "auth.permission", "fields": {"codename": "change_xmodulestudentprefsfield", "name": "Can change x module student prefs field", "content_type": 30}}, {"pk": 90, "model": "auth.permission", "fields": {"codename": "delete_xmodulestudentprefsfield", "name": "Can delete x module student prefs field", "content_type": 30}}, {"pk": 85, "model": "auth.permission", "fields": {"codename": "add_xmoduleuserstatesummaryfield", "name": "Can add x module user state summary field", "content_type": 29}}, {"pk": 86, "model": "auth.permission", "fields": {"codename": "change_xmoduleuserstatesummaryfield", "name": "Can change x module user state summary field", "content_type": 29}}, {"pk": 87, "model": "auth.permission", "fields": {"codename": "delete_xmoduleuserstatesummaryfield", "name": "Can delete x module user state summary field", "content_type": 29}}, {"pk": 304, "model": "auth.permission", "fields": {"codename": "add_coursererunstate", "name": "Can add course rerun state", "content_type": 101}}, {"pk": 305, "model": "auth.permission", "fields": {"codename": "change_coursererunstate", "name": "Can change course rerun state", "content_type": 101}}, {"pk": 306, "model": "auth.permission", "fields": {"codename": "delete_coursererunstate", "name": "Can delete course rerun state", "content_type": 101}}, {"pk": 52, "model": "auth.permission", "fields": {"codename": "add_courseusergroup", "name": "Can add course user group", "content_type": 18}}, {"pk": 53, "model": "auth.permission", "fields": {"codename": "change_courseusergroup", "name": "Can change course user group", "content_type": 18}}, {"pk": 54, "model": "auth.permission", "fields": {"codename": "delete_courseusergroup", "name": "Can delete course user group", "content_type": 18}}, {"pk": 280, "model": "auth.permission", "fields": {"codename": "add_coursemode", "name": "Can add course mode", "content_type": 93}}, {"pk": 281, "model": "auth.permission", "fields": {"codename": "change_coursemode", "name": "Can change course mode", "content_type": 93}}, {"pk": 282, "model": "auth.permission", "fields": {"codename": "delete_coursemode", "name": "Can delete course mode", "content_type": 93}}, {"pk": 283, "model": "auth.permission", "fields": {"codename": "add_coursemodesarchive", "name": "Can add course modes archive", "content_type": 94}}, {"pk": 284, "model": "auth.permission", "fields": {"codename": "change_coursemodesarchive", "name": "Can change course modes archive", "content_type": 94}}, {"pk": 285, "model": "auth.permission", "fields": {"codename": "delete_coursemodesarchive", "name": "Can delete course modes archive", "content_type": 94}}, {"pk": 289, "model": "auth.permission", "fields": {"codename": "add_darklangconfig", "name": "Can add dark lang config", "content_type": 96}}, {"pk": 290, "model": "auth.permission", "fields": {"codename": "change_darklangconfig", "name": "Can change dark lang config", "content_type": 96}}, {"pk": 291, "model": "auth.permission", "fields": {"codename": "delete_darklangconfig", "name": "Can delete dark lang config", "content_type": 96}}, {"pk": 73, "model": "auth.permission", "fields": {"codename": "add_association", "name": "Can add association", "content_type": 25}}, {"pk": 74, "model": "auth.permission", "fields": {"codename": "change_association", "name": "Can change association", "content_type": 25}}, {"pk": 75, "model": "auth.permission", "fields": {"codename": "delete_association", "name": "Can delete association", "content_type": 25}}, {"pk": 76, "model": "auth.permission", "fields": {"codename": "add_code", "name": "Can add code", "content_type": 26}}, {"pk": 77, "model": "auth.permission", "fields": {"codename": "change_code", "name": "Can change code", "content_type": 26}}, {"pk": 78, "model": "auth.permission", "fields": {"codename": "delete_code", "name": "Can delete code", "content_type": 26}}, {"pk": 70, "model": "auth.permission", "fields": {"codename": "add_nonce", "name": "Can add nonce", "content_type": 24}}, {"pk": 71, "model": "auth.permission", "fields": {"codename": "change_nonce", "name": "Can change nonce", "content_type": 24}}, {"pk": 72, "model": "auth.permission", "fields": {"codename": "delete_nonce", "name": "Can delete nonce", "content_type": 24}}, {"pk": 67, "model": "auth.permission", "fields": {"codename": "add_usersocialauth", "name": "Can add user social auth", "content_type": 23}}, {"pk": 68, "model": "auth.permission", "fields": {"codename": "change_usersocialauth", "name": "Can change user social auth", "content_type": 23}}, {"pk": 69, "model": "auth.permission", "fields": {"codename": "delete_usersocialauth", "name": "Can delete user social auth", "content_type": 23}}, {"pk": 229, "model": "auth.permission", "fields": {"codename": "add_notification", "name": "Can add notification", "content_type": 76}}, {"pk": 230, "model": "auth.permission", "fields": {"codename": "change_notification", "name": "Can change notification", "content_type": 76}}, {"pk": 231, "model": "auth.permission", "fields": {"codename": "delete_notification", "name": "Can delete notification", "content_type": 76}}, {"pk": 220, "model": "auth.permission", "fields": {"codename": "add_notificationtype", "name": "Can add type", "content_type": 73}}, {"pk": 221, "model": "auth.permission", "fields": {"codename": "change_notificationtype", "name": "Can change type", "content_type": 73}}, {"pk": 222, "model": "auth.permission", "fields": {"codename": "delete_notificationtype", "name": "Can delete type", "content_type": 73}}, {"pk": 223, "model": "auth.permission", "fields": {"codename": "add_settings", "name": "Can add settings", "content_type": 74}}, {"pk": 224, "model": "auth.permission", "fields": {"codename": "change_settings", "name": "Can change settings", "content_type": 74}}, {"pk": 225, "model": "auth.permission", "fields": {"codename": "delete_settings", "name": "Can delete settings", "content_type": 74}}, {"pk": 226, "model": "auth.permission", "fields": {"codename": "add_subscription", "name": "Can add subscription", "content_type": 75}}, {"pk": 227, "model": "auth.permission", "fields": {"codename": "change_subscription", "name": "Can change subscription", "content_type": 75}}, {"pk": 228, "model": "auth.permission", "fields": {"codename": "delete_subscription", "name": "Can delete subscription", "content_type": 75}}, {"pk": 58, "model": "auth.permission", "fields": {"codename": "add_association", "name": "Can add association", "content_type": 20}}, {"pk": 59, "model": "auth.permission", "fields": {"codename": "change_association", "name": "Can change association", "content_type": 20}}, {"pk": 60, "model": "auth.permission", "fields": {"codename": "delete_association", "name": "Can delete association", "content_type": 20}}, {"pk": 55, "model": "auth.permission", "fields": {"codename": "add_nonce", "name": "Can add nonce", "content_type": 19}}, {"pk": 56, "model": "auth.permission", "fields": {"codename": "change_nonce", "name": "Can change nonce", "content_type": 19}}, {"pk": 57, "model": "auth.permission", "fields": {"codename": "delete_nonce", "name": "Can delete nonce", "content_type": 19}}, {"pk": 61, "model": "auth.permission", "fields": {"codename": "add_useropenid", "name": "Can add user open id", "content_type": 21}}, {"pk": 62, "model": "auth.permission", "fields": {"codename": "change_useropenid", "name": "Can change user open id", "content_type": 21}}, {"pk": 63, "model": "auth.permission", "fields": {"codename": "delete_useropenid", "name": "Can delete user open id", "content_type": 21}}, {"pk": 28, "model": "auth.permission", "fields": {"codename": "add_crontabschedule", "name": "Can add crontab", "content_type": 10}}, {"pk": 29, "model": "auth.permission", "fields": {"codename": "change_crontabschedule", "name": "Can change crontab", "content_type": 10}}, {"pk": 30, "model": "auth.permission", "fields": {"codename": "delete_crontabschedule", "name": "Can delete crontab", "content_type": 10}}, {"pk": 25, "model": "auth.permission", "fields": {"codename": "add_intervalschedule", "name": "Can add interval", "content_type": 9}}, {"pk": 26, "model": "auth.permission", "fields": {"codename": "change_intervalschedule", "name": "Can change interval", "content_type": 9}}, {"pk": 27, "model": "auth.permission", "fields": {"codename": "delete_intervalschedule", "name": "Can delete interval", "content_type": 9}}, {"pk": 34, "model": "auth.permission", "fields": {"codename": "add_periodictask", "name": "Can add periodic task", "content_type": 12}}, {"pk": 35, "model": "auth.permission", "fields": {"codename": "change_periodictask", "name": "Can change periodic task", "content_type": 12}}, {"pk": 36, "model": "auth.permission", "fields": {"codename": "delete_periodictask", "name": "Can delete periodic task", "content_type": 12}}, {"pk": 31, "model": "auth.permission", "fields": {"codename": "add_periodictasks", "name": "Can add periodic tasks", "content_type": 11}}, {"pk": 32, "model": "auth.permission", "fields": {"codename": "change_periodictasks", "name": "Can change periodic tasks", "content_type": 11}}, {"pk": 33, "model": "auth.permission", "fields": {"codename": "delete_periodictasks", "name": "Can delete periodic tasks", "content_type": 11}}, {"pk": 19, "model": "auth.permission", "fields": {"codename": "add_taskmeta", "name": "Can add task state", "content_type": 7}}, {"pk": 20, "model": "auth.permission", "fields": {"codename": "change_taskmeta", "name": "Can change task state", "content_type": 7}}, {"pk": 21, "model": "auth.permission", "fields": {"codename": "delete_taskmeta", "name": "Can delete task state", "content_type": 7}}, {"pk": 22, "model": "auth.permission", "fields": {"codename": "add_tasksetmeta", "name": "Can add saved group result", "content_type": 8}}, {"pk": 23, "model": "auth.permission", "fields": {"codename": "change_tasksetmeta", "name": "Can change saved group result", "content_type": 8}}, {"pk": 24, "model": "auth.permission", "fields": {"codename": "delete_tasksetmeta", "name": "Can delete saved group result", "content_type": 8}}, {"pk": 40, "model": "auth.permission", "fields": {"codename": "add_taskstate", "name": "Can add task", "content_type": 14}}, {"pk": 41, "model": "auth.permission", "fields": {"codename": "change_taskstate", "name": "Can change task", "content_type": 14}}, {"pk": 42, "model": "auth.permission", "fields": {"codename": "delete_taskstate", "name": "Can delete task", "content_type": 14}}, {"pk": 37, "model": "auth.permission", "fields": {"codename": "add_workerstate", "name": "Can add worker", "content_type": 13}}, {"pk": 38, "model": "auth.permission", "fields": {"codename": "change_workerstate", "name": "Can change worker", "content_type": 13}}, {"pk": 39, "model": "auth.permission", "fields": {"codename": "delete_workerstate", "name": "Can delete worker", "content_type": 13}}, {"pk": 382, "model": "auth.permission", "fields": {"codename": "add_coursevideo", "name": "Can add course video", "content_type": 127}}, {"pk": 383, "model": "auth.permission", "fields": {"codename": "change_coursevideo", "name": "Can change course video", "content_type": 127}}, {"pk": 384, "model": "auth.permission", "fields": {"codename": "delete_coursevideo", "name": "Can delete course video", "content_type": 127}}, {"pk": 385, "model": "auth.permission", "fields": {"codename": "add_encodedvideo", "name": "Can add encoded video", "content_type": 128}}, {"pk": 386, "model": "auth.permission", "fields": {"codename": "change_encodedvideo", "name": "Can change encoded video", "content_type": 128}}, {"pk": 387, "model": "auth.permission", "fields": {"codename": "delete_encodedvideo", "name": "Can delete encoded video", "content_type": 128}}, {"pk": 376, "model": "auth.permission", "fields": {"codename": "add_profile", "name": "Can add profile", "content_type": 125}}, {"pk": 377, "model": "auth.permission", "fields": {"codename": "change_profile", "name": "Can change profile", "content_type": 125}}, {"pk": 378, "model": "auth.permission", "fields": {"codename": "delete_profile", "name": "Can delete profile", "content_type": 125}}, {"pk": 388, "model": "auth.permission", "fields": {"codename": "add_subtitle", "name": "Can add subtitle", "content_type": 129}}, {"pk": 389, "model": "auth.permission", "fields": {"codename": "change_subtitle", "name": "Can change subtitle", "content_type": 129}}, {"pk": 390, "model": "auth.permission", "fields": {"codename": "delete_subtitle", "name": "Can delete subtitle", "content_type": 129}}, {"pk": 379, "model": "auth.permission", "fields": {"codename": "add_video", "name": "Can add video", "content_type": 126}}, {"pk": 380, "model": "auth.permission", "fields": {"codename": "change_video", "name": "Can change video", "content_type": 126}}, {"pk": 381, "model": "auth.permission", "fields": {"codename": "delete_video", "name": "Can delete video", "content_type": 126}}, {"pk": 295, "model": "auth.permission", "fields": {"codename": "add_embargoedcourse", "name": "Can add embargoed course", "content_type": 98}}, {"pk": 296, "model": "auth.permission", "fields": {"codename": "change_embargoedcourse", "name": "Can change embargoed course", "content_type": 98}}, {"pk": 297, "model": "auth.permission", "fields": {"codename": "delete_embargoedcourse", "name": "Can delete embargoed course", "content_type": 98}}, {"pk": 298, "model": "auth.permission", "fields": {"codename": "add_embargoedstate", "name": "Can add embargoed state", "content_type": 99}}, {"pk": 299, "model": "auth.permission", "fields": {"codename": "change_embargoedstate", "name": "Can change embargoed state", "content_type": 99}}, {"pk": 300, "model": "auth.permission", "fields": {"codename": "delete_embargoedstate", "name": "Can delete embargoed state", "content_type": 99}}, {"pk": 301, "model": "auth.permission", "fields": {"codename": "add_ipfilter", "name": "Can add ip filter", "content_type": 100}}, {"pk": 302, "model": "auth.permission", "fields": {"codename": "change_ipfilter", "name": "Can change ip filter", "content_type": 100}}, {"pk": 303, "model": "auth.permission", "fields": {"codename": "delete_ipfilter", "name": "Can delete ip filter", "content_type": 100}}, {"pk": 169, "model": "auth.permission", "fields": {"codename": "add_externalauthmap", "name": "Can add external auth map", "content_type": 57}}, {"pk": 170, "model": "auth.permission", "fields": {"codename": "change_externalauthmap", "name": "Can change external auth map", "content_type": 57}}, {"pk": 171, "model": "auth.permission", "fields": {"codename": "delete_externalauthmap", "name": "Can delete external auth map", "content_type": 57}}, {"pk": 235, "model": "auth.permission", "fields": {"codename": "add_puzzlecomplete", "name": "Can add puzzle complete", "content_type": 78}}, {"pk": 236, "model": "auth.permission", "fields": {"codename": "change_puzzlecomplete", "name": "Can change puzzle complete", "content_type": 78}}, {"pk": 237, "model": "auth.permission", "fields": {"codename": "delete_puzzlecomplete", "name": "Can delete puzzle complete", "content_type": 78}}, {"pk": 232, "model": "auth.permission", "fields": {"codename": "add_score", "name": "Can add score", "content_type": 77}}, {"pk": 233, "model": "auth.permission", "fields": {"codename": "change_score", "name": "Can change score", "content_type": 77}}, {"pk": 234, "model": "auth.permission", "fields": {"codename": "delete_score", "name": "Can delete score", "content_type": 77}}, {"pk": 148, "model": "auth.permission", "fields": {"codename": "add_instructortask", "name": "Can add instructor task", "content_type": 50}}, {"pk": 149, "model": "auth.permission", "fields": {"codename": "change_instructortask", "name": "Can change instructor task", "content_type": 50}}, {"pk": 150, "model": "auth.permission", "fields": {"codename": "delete_instructortask", "name": "Can delete instructor task", "content_type": 50}}, {"pk": 151, "model": "auth.permission", "fields": {"codename": "add_coursesoftware", "name": "Can add course software", "content_type": 51}}, {"pk": 152, "model": "auth.permission", "fields": {"codename": "change_coursesoftware", "name": "Can change course software", "content_type": 51}}, {"pk": 153, "model": "auth.permission", "fields": {"codename": "delete_coursesoftware", "name": "Can delete course software", "content_type": 51}}, {"pk": 154, "model": "auth.permission", "fields": {"codename": "add_userlicense", "name": "Can add user license", "content_type": 52}}, {"pk": 155, "model": "auth.permission", "fields": {"codename": "change_userlicense", "name": "Can change user license", "content_type": 52}}, {"pk": 156, "model": "auth.permission", "fields": {"codename": "delete_userlicense", "name": "Can delete user license", "content_type": 52}}, {"pk": 307, "model": "auth.permission", "fields": {"codename": "add_linkedin", "name": "Can add linked in", "content_type": 102}}, {"pk": 308, "model": "auth.permission", "fields": {"codename": "change_linkedin", "name": "Can change linked in", "content_type": 102}}, {"pk": 309, "model": "auth.permission", "fields": {"codename": "delete_linkedin", "name": "Can delete linked in", "content_type": 102}}, {"pk": 238, "model": "auth.permission", "fields": {"codename": "add_note", "name": "Can add note", "content_type": 79}}, {"pk": 239, "model": "auth.permission", "fields": {"codename": "change_note", "name": "Can change note", "content_type": 79}}, {"pk": 240, "model": "auth.permission", "fields": {"codename": "delete_note", "name": "Can delete note", "content_type": 79}}, {"pk": 178, "model": "auth.permission", "fields": {"codename": "add_accesstoken", "name": "Can add access token", "content_type": 60}}, {"pk": 179, "model": "auth.permission", "fields": {"codename": "change_accesstoken", "name": "Can change access token", "content_type": 60}}, {"pk": 180, "model": "auth.permission", "fields": {"codename": "delete_accesstoken", "name": "Can delete access token", "content_type": 60}}, {"pk": 172, "model": "auth.permission", "fields": {"codename": "add_client", "name": "Can add client", "content_type": 58}}, {"pk": 173, "model": "auth.permission", "fields": {"codename": "change_client", "name": "Can change client", "content_type": 58}}, {"pk": 174, "model": "auth.permission", "fields": {"codename": "delete_client", "name": "Can delete client", "content_type": 58}}, {"pk": 175, "model": "auth.permission", "fields": {"codename": "add_grant", "name": "Can add grant", "content_type": 59}}, {"pk": 176, "model": "auth.permission", "fields": {"codename": "change_grant", "name": "Can change grant", "content_type": 59}}, {"pk": 177, "model": "auth.permission", "fields": {"codename": "delete_grant", "name": "Can delete grant", "content_type": 59}}, {"pk": 181, "model": "auth.permission", "fields": {"codename": "add_refreshtoken", "name": "Can add refresh token", "content_type": 61}}, {"pk": 182, "model": "auth.permission", "fields": {"codename": "change_refreshtoken", "name": "Can change refresh token", "content_type": 61}}, {"pk": 183, "model": "auth.permission", "fields": {"codename": "delete_refreshtoken", "name": "Can delete refresh token", "content_type": 61}}, {"pk": 184, "model": "auth.permission", "fields": {"codename": "add_trustedclient", "name": "Can add trusted client", "content_type": 62}}, {"pk": 185, "model": "auth.permission", "fields": {"codename": "change_trustedclient", "name": "Can change trusted client", "content_type": 62}}, {"pk": 186, "model": "auth.permission", "fields": {"codename": "delete_trustedclient", "name": "Can delete trusted client", "content_type": 62}}, {"pk": 49, "model": "auth.permission", "fields": {"codename": "add_psychometricdata", "name": "Can add psychometric data", "content_type": 17}}, {"pk": 50, "model": "auth.permission", "fields": {"codename": "change_psychometricdata", "name": "Can change psychometric data", "content_type": 17}}, {"pk": 51, "model": "auth.permission", "fields": {"codename": "delete_psychometricdata", "name": "Can delete psychometric data", "content_type": 17}}, {"pk": 292, "model": "auth.permission", "fields": {"codename": "add_midcoursereverificationwindow", "name": "Can add midcourse reverification window", "content_type": 97}}, {"pk": 293, "model": "auth.permission", "fields": {"codename": "change_midcoursereverificationwindow", "name": "Can change midcourse reverification window", "content_type": 97}}, {"pk": 294, "model": "auth.permission", "fields": {"codename": "delete_midcoursereverificationwindow", "name": "Can delete midcourse reverification window", "content_type": 97}}, {"pk": 13, "model": "auth.permission", "fields": {"codename": "add_session", "name": "Can add session", "content_type": 5}}, {"pk": 14, "model": "auth.permission", "fields": {"codename": "change_session", "name": "Can change session", "content_type": 5}}, {"pk": 15, "model": "auth.permission", "fields": {"codename": "delete_session", "name": "Can delete session", "content_type": 5}}, {"pk": 277, "model": "auth.permission", "fields": {"codename": "add_certificateitem", "name": "Can add certificate item", "content_type": 92}}, {"pk": 278, "model": "auth.permission", "fields": {"codename": "change_certificateitem", "name": "Can change certificate item", "content_type": 92}}, {"pk": 279, "model": "auth.permission", "fields": {"codename": "delete_certificateitem", "name": "Can delete certificate item", "content_type": 92}}, {"pk": 265, "model": "auth.permission", "fields": {"codename": "add_coupon", "name": "Can add coupon", "content_type": 88}}, {"pk": 266, "model": "auth.permission", "fields": {"codename": "change_coupon", "name": "Can change coupon", "content_type": 88}}, {"pk": 267, "model": "auth.permission", "fields": {"codename": "delete_coupon", "name": "Can delete coupon", "content_type": 88}}, {"pk": 268, "model": "auth.permission", "fields": {"codename": "add_couponredemption", "name": "Can add coupon redemption", "content_type": 89}}, {"pk": 269, "model": "auth.permission", "fields": {"codename": "change_couponredemption", "name": "Can change coupon redemption", "content_type": 89}}, {"pk": 270, "model": "auth.permission", "fields": {"codename": "delete_couponredemption", "name": "Can delete coupon redemption", "content_type": 89}}, {"pk": 259, "model": "auth.permission", "fields": {"codename": "add_courseregistrationcode", "name": "Can add course registration code", "content_type": 86}}, {"pk": 260, "model": "auth.permission", "fields": {"codename": "change_courseregistrationcode", "name": "Can change course registration code", "content_type": 86}}, {"pk": 261, "model": "auth.permission", "fields": {"codename": "delete_courseregistrationcode", "name": "Can delete course registration code", "content_type": 86}}, {"pk": 256, "model": "auth.permission", "fields": {"codename": "add_invoice", "name": "Can add invoice", "content_type": 85}}, {"pk": 257, "model": "auth.permission", "fields": {"codename": "change_invoice", "name": "Can change invoice", "content_type": 85}}, {"pk": 258, "model": "auth.permission", "fields": {"codename": "delete_invoice", "name": "Can delete invoice", "content_type": 85}}, {"pk": 250, "model": "auth.permission", "fields": {"codename": "add_order", "name": "Can add order", "content_type": 83}}, {"pk": 251, "model": "auth.permission", "fields": {"codename": "change_order", "name": "Can change order", "content_type": 83}}, {"pk": 252, "model": "auth.permission", "fields": {"codename": "delete_order", "name": "Can delete order", "content_type": 83}}, {"pk": 253, "model": "auth.permission", "fields": {"codename": "add_orderitem", "name": "Can add order item", "content_type": 84}}, {"pk": 254, "model": "auth.permission", "fields": {"codename": "change_orderitem", "name": "Can change order item", "content_type": 84}}, {"pk": 255, "model": "auth.permission", "fields": {"codename": "delete_orderitem", "name": "Can delete order item", "content_type": 84}}, {"pk": 271, "model": "auth.permission", "fields": {"codename": "add_paidcourseregistration", "name": "Can add paid course registration", "content_type": 90}}, {"pk": 272, "model": "auth.permission", "fields": {"codename": "change_paidcourseregistration", "name": "Can change paid course registration", "content_type": 90}}, {"pk": 273, "model": "auth.permission", "fields": {"codename": "delete_paidcourseregistration", "name": "Can delete paid course registration", "content_type": 90}}, {"pk": 274, "model": "auth.permission", "fields": {"codename": "add_paidcourseregistrationannotation", "name": "Can add paid course registration annotation", "content_type": 91}}, {"pk": 275, "model": "auth.permission", "fields": {"codename": "change_paidcourseregistrationannotation", "name": "Can change paid course registration annotation", "content_type": 91}}, {"pk": 276, "model": "auth.permission", "fields": {"codename": "delete_paidcourseregistrationannotation", "name": "Can delete paid course registration annotation", "content_type": 91}}, {"pk": 262, "model": "auth.permission", "fields": {"codename": "add_registrationcoderedemption", "name": "Can add registration code redemption", "content_type": 87}}, {"pk": 263, "model": "auth.permission", "fields": {"codename": "change_registrationcoderedemption", "name": "Can change registration code redemption", "content_type": 87}}, {"pk": 264, "model": "auth.permission", "fields": {"codename": "delete_registrationcoderedemption", "name": "Can delete registration code redemption", "content_type": 87}}, {"pk": 16, "model": "auth.permission", "fields": {"codename": "add_site", "name": "Can add site", "content_type": 6}}, {"pk": 17, "model": "auth.permission", "fields": {"codename": "change_site", "name": "Can change site", "content_type": 6}}, {"pk": 18, "model": "auth.permission", "fields": {"codename": "delete_site", "name": "Can delete site", "content_type": 6}}, {"pk": 43, "model": "auth.permission", "fields": {"codename": "add_migrationhistory", "name": "Can add migration history", "content_type": 15}}, {"pk": 44, "model": "auth.permission", "fields": {"codename": "change_migrationhistory", "name": "Can change migration history", "content_type": 15}}, {"pk": 45, "model": "auth.permission", "fields": {"codename": "delete_migrationhistory", "name": "Can delete migration history", "content_type": 15}}, {"pk": 241, "model": "auth.permission", "fields": {"codename": "add_splashconfig", "name": "Can add splash config", "content_type": 80}}, {"pk": 242, "model": "auth.permission", "fields": {"codename": "change_splashconfig", "name": "Can change splash config", "content_type": 80}}, {"pk": 243, "model": "auth.permission", "fields": {"codename": "delete_splashconfig", "name": "Can delete splash config", "content_type": 80}}, {"pk": 100, "model": "auth.permission", "fields": {"codename": "add_anonymoususerid", "name": "Can add anonymous user id", "content_type": 34}}, {"pk": 101, "model": "auth.permission", "fields": {"codename": "change_anonymoususerid", "name": "Can change anonymous user id", "content_type": 34}}, {"pk": 102, "model": "auth.permission", "fields": {"codename": "delete_anonymoususerid", "name": "Can delete anonymous user id", "content_type": 34}}, {"pk": 136, "model": "auth.permission", "fields": {"codename": "add_courseaccessrole", "name": "Can add course access role", "content_type": 46}}, {"pk": 137, "model": "auth.permission", "fields": {"codename": "change_courseaccessrole", "name": "Can change course access role", "content_type": 46}}, {"pk": 138, "model": "auth.permission", "fields": {"codename": "delete_courseaccessrole", "name": "Can delete course access role", "content_type": 46}}, {"pk": 130, "model": "auth.permission", "fields": {"codename": "add_courseenrollment", "name": "Can add course enrollment", "content_type": 44}}, {"pk": 131, "model": "auth.permission", "fields": {"codename": "change_courseenrollment", "name": "Can change course enrollment", "content_type": 44}}, {"pk": 132, "model": "auth.permission", "fields": {"codename": "delete_courseenrollment", "name": "Can delete course enrollment", "content_type": 44}}, {"pk": 133, "model": "auth.permission", "fields": {"codename": "add_courseenrollmentallowed", "name": "Can add course enrollment allowed", "content_type": 45}}, {"pk": 134, "model": "auth.permission", "fields": {"codename": "change_courseenrollmentallowed", "name": "Can change course enrollment allowed", "content_type": 45}}, {"pk": 135, "model": "auth.permission", "fields": {"codename": "delete_courseenrollmentallowed", "name": "Can delete course enrollment allowed", "content_type": 45}}, {"pk": 127, "model": "auth.permission", "fields": {"codename": "add_loginfailures", "name": "Can add login failures", "content_type": 43}}, {"pk": 128, "model": "auth.permission", "fields": {"codename": "change_loginfailures", "name": "Can change login failures", "content_type": 43}}, {"pk": 129, "model": "auth.permission", "fields": {"codename": "delete_loginfailures", "name": "Can delete login failures", "content_type": 43}}, {"pk": 124, "model": "auth.permission", "fields": {"codename": "add_passwordhistory", "name": "Can add password history", "content_type": 42}}, {"pk": 125, "model": "auth.permission", "fields": {"codename": "change_passwordhistory", "name": "Can change password history", "content_type": 42}}, {"pk": 126, "model": "auth.permission", "fields": {"codename": "delete_passwordhistory", "name": "Can delete password history", "content_type": 42}}, {"pk": 121, "model": "auth.permission", "fields": {"codename": "add_pendingemailchange", "name": "Can add pending email change", "content_type": 41}}, {"pk": 122, "model": "auth.permission", "fields": {"codename": "change_pendingemailchange", "name": "Can change pending email change", "content_type": 41}}, {"pk": 123, "model": "auth.permission", "fields": {"codename": "delete_pendingemailchange", "name": "Can delete pending email change", "content_type": 41}}, {"pk": 118, "model": "auth.permission", "fields": {"codename": "add_pendingnamechange", "name": "Can add pending name change", "content_type": 40}}, {"pk": 119, "model": "auth.permission", "fields": {"codename": "change_pendingnamechange", "name": "Can change pending name change", "content_type": 40}}, {"pk": 120, "model": "auth.permission", "fields": {"codename": "delete_pendingnamechange", "name": "Can delete pending name change", "content_type": 40}}, {"pk": 115, "model": "auth.permission", "fields": {"codename": "add_registration", "name": "Can add registration", "content_type": 39}}, {"pk": 116, "model": "auth.permission", "fields": {"codename": "change_registration", "name": "Can change registration", "content_type": 39}}, {"pk": 117, "model": "auth.permission", "fields": {"codename": "delete_registration", "name": "Can delete registration", "content_type": 39}}, {"pk": 106, "model": "auth.permission", "fields": {"codename": "add_userprofile", "name": "Can add user profile", "content_type": 36}}, {"pk": 107, "model": "auth.permission", "fields": {"codename": "change_userprofile", "name": "Can change user profile", "content_type": 36}}, {"pk": 108, "model": "auth.permission", "fields": {"codename": "delete_userprofile", "name": "Can delete user profile", "content_type": 36}}, {"pk": 109, "model": "auth.permission", "fields": {"codename": "add_usersignupsource", "name": "Can add user signup source", "content_type": 37}}, {"pk": 110, "model": "auth.permission", "fields": {"codename": "change_usersignupsource", "name": "Can change user signup source", "content_type": 37}}, {"pk": 111, "model": "auth.permission", "fields": {"codename": "delete_usersignupsource", "name": "Can delete user signup source", "content_type": 37}}, {"pk": 103, "model": "auth.permission", "fields": {"codename": "add_userstanding", "name": "Can add user standing", "content_type": 35}}, {"pk": 104, "model": "auth.permission", "fields": {"codename": "change_userstanding", "name": "Can change user standing", "content_type": 35}}, {"pk": 105, "model": "auth.permission", "fields": {"codename": "delete_userstanding", "name": "Can delete user standing", "content_type": 35}}, {"pk": 112, "model": "auth.permission", "fields": {"codename": "add_usertestgroup", "name": "Can add user test group", "content_type": 38}}, {"pk": 113, "model": "auth.permission", "fields": {"codename": "change_usertestgroup", "name": "Can change user test group", "content_type": 38}}, {"pk": 114, "model": "auth.permission", "fields": {"codename": "delete_usertestgroup", "name": "Can delete user test group", "content_type": 38}}, {"pk": 316, "model": "auth.permission", "fields": {"codename": "add_score", "name": "Can add score", "content_type": 105}}, {"pk": 317, "model": "auth.permission", "fields": {"codename": "change_score", "name": "Can change score", "content_type": 105}}, {"pk": 318, "model": "auth.permission", "fields": {"codename": "delete_score", "name": "Can delete score", "content_type": 105}}, {"pk": 319, "model": "auth.permission", "fields": {"codename": "add_scoresummary", "name": "Can add score summary", "content_type": 106}}, {"pk": 320, "model": "auth.permission", "fields": {"codename": "change_scoresummary", "name": "Can change score summary", "content_type": 106}}, {"pk": 321, "model": "auth.permission", "fields": {"codename": "delete_scoresummary", "name": "Can delete score summary", "content_type": 106}}, {"pk": 310, "model": "auth.permission", "fields": {"codename": "add_studentitem", "name": "Can add student item", "content_type": 103}}, {"pk": 311, "model": "auth.permission", "fields": {"codename": "change_studentitem", "name": "Can change student item", "content_type": 103}}, {"pk": 312, "model": "auth.permission", "fields": {"codename": "delete_studentitem", "name": "Can delete student item", "content_type": 103}}, {"pk": 313, "model": "auth.permission", "fields": {"codename": "add_submission", "name": "Can add submission", "content_type": 104}}, {"pk": 314, "model": "auth.permission", "fields": {"codename": "change_submission", "name": "Can change submission", "content_type": 104}}, {"pk": 315, "model": "auth.permission", "fields": {"codename": "delete_submission", "name": "Can delete submission", "content_type": 104}}, {"pk": 139, "model": "auth.permission", "fields": {"codename": "add_trackinglog", "name": "Can add tracking log", "content_type": 47}}, {"pk": 140, "model": "auth.permission", "fields": {"codename": "change_trackinglog", "name": "Can change tracking log", "content_type": 47}}, {"pk": 141, "model": "auth.permission", "fields": {"codename": "delete_trackinglog", "name": "Can delete tracking log", "content_type": 47}}, {"pk": 247, "model": "auth.permission", "fields": {"codename": "add_usercoursetag", "name": "Can add user course tag", "content_type": 82}}, {"pk": 248, "model": "auth.permission", "fields": {"codename": "change_usercoursetag", "name": "Can change user course tag", "content_type": 82}}, {"pk": 249, "model": "auth.permission", "fields": {"codename": "delete_usercoursetag", "name": "Can delete user course tag", "content_type": 82}}, {"pk": 244, "model": "auth.permission", "fields": {"codename": "add_userpreference", "name": "Can add user preference", "content_type": 81}}, {"pk": 245, "model": "auth.permission", "fields": {"codename": "change_userpreference", "name": "Can change user preference", "content_type": 81}}, {"pk": 246, "model": "auth.permission", "fields": {"codename": "delete_userpreference", "name": "Can delete user preference", "content_type": 81}}, {"pk": 286, "model": "auth.permission", "fields": {"codename": "add_softwaresecurephotoverification", "name": "Can add software secure photo verification", "content_type": 95}}, {"pk": 287, "model": "auth.permission", "fields": {"codename": "change_softwaresecurephotoverification", "name": "Can change software secure photo verification", "content_type": 95}}, {"pk": 288, "model": "auth.permission", "fields": {"codename": "delete_softwaresecurephotoverification", "name": "Can delete software secure photo verification", "content_type": 95}}, {"pk": 187, "model": "auth.permission", "fields": {"codename": "add_article", "name": "Can add article", "content_type": 63}}, {"pk": 191, "model": "auth.permission", "fields": {"codename": "assign", "name": "Can change ownership of any article", "content_type": 63}}, {"pk": 188, "model": "auth.permission", "fields": {"codename": "change_article", "name": "Can change article", "content_type": 63}}, {"pk": 189, "model": "auth.permission", "fields": {"codename": "delete_article", "name": "Can delete article", "content_type": 63}}, {"pk": 192, "model": "auth.permission", "fields": {"codename": "grant", "name": "Can assign permissions to other users", "content_type": 63}}, {"pk": 190, "model": "auth.permission", "fields": {"codename": "moderate", "name": "Can edit all articles and lock/unlock/restore", "content_type": 63}}, {"pk": 193, "model": "auth.permission", "fields": {"codename": "add_articleforobject", "name": "Can add Article for object", "content_type": 64}}, {"pk": 194, "model": "auth.permission", "fields": {"codename": "change_articleforobject", "name": "Can change Article for object", "content_type": 64}}, {"pk": 195, "model": "auth.permission", "fields": {"codename": "delete_articleforobject", "name": "Can delete Article for object", "content_type": 64}}, {"pk": 202, "model": "auth.permission", "fields": {"codename": "add_articleplugin", "name": "Can add article plugin", "content_type": 67}}, {"pk": 203, "model": "auth.permission", "fields": {"codename": "change_articleplugin", "name": "Can change article plugin", "content_type": 67}}, {"pk": 204, "model": "auth.permission", "fields": {"codename": "delete_articleplugin", "name": "Can delete article plugin", "content_type": 67}}, {"pk": 196, "model": "auth.permission", "fields": {"codename": "add_articlerevision", "name": "Can add article revision", "content_type": 65}}, {"pk": 197, "model": "auth.permission", "fields": {"codename": "change_articlerevision", "name": "Can change article revision", "content_type": 65}}, {"pk": 198, "model": "auth.permission", "fields": {"codename": "delete_articlerevision", "name": "Can delete article revision", "content_type": 65}}, {"pk": 217, "model": "auth.permission", "fields": {"codename": "add_articlesubscription", "name": "Can add article subscription", "content_type": 72}}, {"pk": 218, "model": "auth.permission", "fields": {"codename": "change_articlesubscription", "name": "Can change article subscription", "content_type": 72}}, {"pk": 219, "model": "auth.permission", "fields": {"codename": "delete_articlesubscription", "name": "Can delete article subscription", "content_type": 72}}, {"pk": 205, "model": "auth.permission", "fields": {"codename": "add_reusableplugin", "name": "Can add reusable plugin", "content_type": 68}}, {"pk": 206, "model": "auth.permission", "fields": {"codename": "change_reusableplugin", "name": "Can change reusable plugin", "content_type": 68}}, {"pk": 207, "model": "auth.permission", "fields": {"codename": "delete_reusableplugin", "name": "Can delete reusable plugin", "content_type": 68}}, {"pk": 211, "model": "auth.permission", "fields": {"codename": "add_revisionplugin", "name": "Can add revision plugin", "content_type": 70}}, {"pk": 212, "model": "auth.permission", "fields": {"codename": "change_revisionplugin", "name": "Can change revision plugin", "content_type": 70}}, {"pk": 213, "model": "auth.permission", "fields": {"codename": "delete_revisionplugin", "name": "Can delete revision plugin", "content_type": 70}}, {"pk": 214, "model": "auth.permission", "fields": {"codename": "add_revisionpluginrevision", "name": "Can add revision plugin revision", "content_type": 71}}, {"pk": 215, "model": "auth.permission", "fields": {"codename": "change_revisionpluginrevision", "name": "Can change revision plugin revision", "content_type": 71}}, {"pk": 216, "model": "auth.permission", "fields": {"codename": "delete_revisionpluginrevision", "name": "Can delete revision plugin revision", "content_type": 71}}, {"pk": 208, "model": "auth.permission", "fields": {"codename": "add_simpleplugin", "name": "Can add simple plugin", "content_type": 69}}, {"pk": 209, "model": "auth.permission", "fields": {"codename": "change_simpleplugin", "name": "Can change simple plugin", "content_type": 69}}, {"pk": 210, "model": "auth.permission", "fields": {"codename": "delete_simpleplugin", "name": "Can delete simple plugin", "content_type": 69}}, {"pk": 199, "model": "auth.permission", "fields": {"codename": "add_urlpath", "name": "Can add URL path", "content_type": 66}}, {"pk": 200, "model": "auth.permission", "fields": {"codename": "change_urlpath", "name": "Can change URL path", "content_type": 66}}, {"pk": 201, "model": "auth.permission", "fields": {"codename": "delete_urlpath", "name": "Can delete URL path", "content_type": 66}}, {"pk": 370, "model": "auth.permission", "fields": {"codename": "add_assessmentworkflow", "name": "Can add assessment workflow", "content_type": 123}}, {"pk": 371, "model": "auth.permission", "fields": {"codename": "change_assessmentworkflow", "name": "Can change assessment workflow", "content_type": 123}}, {"pk": 372, "model": "auth.permission", "fields": {"codename": "delete_assessmentworkflow", "name": "Can delete assessment workflow", "content_type": 123}}, {"pk": 373, "model": "auth.permission", "fields": {"codename": "add_assessmentworkflowstep", "name": "Can add assessment workflow step", "content_type": 124}}, {"pk": 374, "model": "auth.permission", "fields": {"codename": "change_assessmentworkflowstep", "name": "Can change assessment workflow step", "content_type": 124}}, {"pk": 375, "model": "auth.permission", "fields": {"codename": "delete_assessmentworkflowstep", "name": "Can delete assessment workflow step", "content_type": 124}}, {"pk": 1, "model": "dark_lang.darklangconfig", "fields": {"change_date": "2014-10-06T18:53:49Z", "changed_by": null, "enabled": true, "released_languages": ""}}] \ No newline at end of file +[{"pk": 62, "model": "contenttypes.contenttype", "fields": {"model": "accesstoken", "name": "access token", "app_label": "oauth2"}}, {"pk": 128, "model": "contenttypes.contenttype", "fields": {"model": "aiclassifier", "name": "ai classifier", "app_label": "assessment"}}, {"pk": 127, "model": "contenttypes.contenttype", "fields": {"model": "aiclassifierset", "name": "ai classifier set", "app_label": "assessment"}}, {"pk": 130, "model": "contenttypes.contenttype", "fields": {"model": "aigradingworkflow", "name": "ai grading workflow", "app_label": "assessment"}}, {"pk": 129, "model": "contenttypes.contenttype", "fields": {"model": "aitrainingworkflow", "name": "ai training workflow", "app_label": "assessment"}}, {"pk": 33, "model": "contenttypes.contenttype", "fields": {"model": "anonymoususerid", "name": "anonymous user id", "app_label": "student"}}, {"pk": 65, "model": "contenttypes.contenttype", "fields": {"model": "article", "name": "article", "app_label": "wiki"}}, {"pk": 66, "model": "contenttypes.contenttype", "fields": {"model": "articleforobject", "name": "Article for object", "app_label": "wiki"}}, {"pk": 69, "model": "contenttypes.contenttype", "fields": {"model": "articleplugin", "name": "article plugin", "app_label": "wiki"}}, {"pk": 67, "model": "contenttypes.contenttype", "fields": {"model": "articlerevision", "name": "article revision", "app_label": "wiki"}}, {"pk": 74, "model": "contenttypes.contenttype", "fields": {"model": "articlesubscription", "name": "article subscription", "app_label": "wiki"}}, {"pk": 118, "model": "contenttypes.contenttype", "fields": {"model": "assessment", "name": "assessment", "app_label": "assessment"}}, {"pk": 121, "model": "contenttypes.contenttype", "fields": {"model": "assessmentfeedback", "name": "assessment feedback", "app_label": "assessment"}}, {"pk": 120, "model": "contenttypes.contenttype", "fields": {"model": "assessmentfeedbackoption", "name": "assessment feedback option", "app_label": "assessment"}}, {"pk": 119, "model": "contenttypes.contenttype", "fields": {"model": "assessmentpart", "name": "assessment part", "app_label": "assessment"}}, {"pk": 131, "model": "contenttypes.contenttype", "fields": {"model": "assessmentworkflow", "name": "assessment workflow", "app_label": "workflow"}}, {"pk": 132, "model": "contenttypes.contenttype", "fields": {"model": "assessmentworkflowstep", "name": "assessment workflow step", "app_label": "workflow"}}, {"pk": 19, "model": "contenttypes.contenttype", "fields": {"model": "association", "name": "association", "app_label": "django_openid_auth"}}, {"pk": 24, "model": "contenttypes.contenttype", "fields": {"model": "association", "name": "association", "app_label": "default"}}, {"pk": 96, "model": "contenttypes.contenttype", "fields": {"model": "certificateitem", "name": "certificate item", "app_label": "shoppingcart"}}, {"pk": 48, "model": "contenttypes.contenttype", "fields": {"model": "certificatewhitelist", "name": "certificate whitelist", "app_label": "certificates"}}, {"pk": 60, "model": "contenttypes.contenttype", "fields": {"model": "client", "name": "client", "app_label": "oauth2"}}, {"pk": 25, "model": "contenttypes.contenttype", "fields": {"model": "code", "name": "code", "app_label": "default"}}, {"pk": 4, "model": "contenttypes.contenttype", "fields": {"model": "contenttype", "name": "content type", "app_label": "contenttypes"}}, {"pk": 90, "model": "contenttypes.contenttype", "fields": {"model": "coupon", "name": "coupon", "app_label": "shoppingcart"}}, {"pk": 91, "model": "contenttypes.contenttype", "fields": {"model": "couponredemption", "name": "coupon redemption", "app_label": "shoppingcart"}}, {"pk": 45, "model": "contenttypes.contenttype", "fields": {"model": "courseaccessrole", "name": "course access role", "app_label": "student"}}, {"pk": 58, "model": "contenttypes.contenttype", "fields": {"model": "courseauthorization", "name": "course authorization", "app_label": "bulk_email"}}, {"pk": 55, "model": "contenttypes.contenttype", "fields": {"model": "courseemail", "name": "course email", "app_label": "bulk_email"}}, {"pk": 57, "model": "contenttypes.contenttype", "fields": {"model": "courseemailtemplate", "name": "course email template", "app_label": "bulk_email"}}, {"pk": 43, "model": "contenttypes.contenttype", "fields": {"model": "courseenrollment", "name": "course enrollment", "app_label": "student"}}, {"pk": 44, "model": "contenttypes.contenttype", "fields": {"model": "courseenrollmentallowed", "name": "course enrollment allowed", "app_label": "student"}}, {"pk": 99, "model": "contenttypes.contenttype", "fields": {"model": "coursemode", "name": "course mode", "app_label": "course_modes"}}, {"pk": 100, "model": "contenttypes.contenttype", "fields": {"model": "coursemodesarchive", "name": "course modes archive", "app_label": "course_modes"}}, {"pk": 93, "model": "contenttypes.contenttype", "fields": {"model": "courseregcodeitem", "name": "course reg code item", "app_label": "shoppingcart"}}, {"pk": 94, "model": "contenttypes.contenttype", "fields": {"model": "courseregcodeitemannotation", "name": "course reg code item annotation", "app_label": "shoppingcart"}}, {"pk": 88, "model": "contenttypes.contenttype", "fields": {"model": "courseregistrationcode", "name": "course registration code", "app_label": "shoppingcart"}}, {"pk": 107, "model": "contenttypes.contenttype", "fields": {"model": "coursererunstate", "name": "course rerun state", "app_label": "course_action_state"}}, {"pk": 51, "model": "contenttypes.contenttype", "fields": {"model": "coursesoftware", "name": "course software", "app_label": "licenses"}}, {"pk": 53, "model": "contenttypes.contenttype", "fields": {"model": "courseusergroup", "name": "course user group", "app_label": "course_groups"}}, {"pk": 54, "model": "contenttypes.contenttype", "fields": {"model": "courseusergrouppartitiongroup", "name": "course user group partition group", "app_label": "course_groups"}}, {"pk": 135, "model": "contenttypes.contenttype", "fields": {"model": "coursevideo", "name": "course video", "app_label": "edxval"}}, {"pk": 116, "model": "contenttypes.contenttype", "fields": {"model": "criterion", "name": "criterion", "app_label": "assessment"}}, {"pk": 117, "model": "contenttypes.contenttype", "fields": {"model": "criterionoption", "name": "criterion option", "app_label": "assessment"}}, {"pk": 10, "model": "contenttypes.contenttype", "fields": {"model": "crontabschedule", "name": "crontab", "app_label": "djcelery"}}, {"pk": 102, "model": "contenttypes.contenttype", "fields": {"model": "darklangconfig", "name": "dark lang config", "app_label": "dark_lang"}}, {"pk": 46, "model": "contenttypes.contenttype", "fields": {"model": "dashboardconfiguration", "name": "dashboard configuration", "app_label": "student"}}, {"pk": 98, "model": "contenttypes.contenttype", "fields": {"model": "donation", "name": "donation", "app_label": "shoppingcart"}}, {"pk": 97, "model": "contenttypes.contenttype", "fields": {"model": "donationconfiguration", "name": "donation configuration", "app_label": "shoppingcart"}}, {"pk": 104, "model": "contenttypes.contenttype", "fields": {"model": "embargoedcourse", "name": "embargoed course", "app_label": "embargo"}}, {"pk": 105, "model": "contenttypes.contenttype", "fields": {"model": "embargoedstate", "name": "embargoed state", "app_label": "embargo"}}, {"pk": 136, "model": "contenttypes.contenttype", "fields": {"model": "encodedvideo", "name": "encoded video", "app_label": "edxval"}}, {"pk": 59, "model": "contenttypes.contenttype", "fields": {"model": "externalauthmap", "name": "external auth map", "app_label": "external_auth"}}, {"pk": 49, "model": "contenttypes.contenttype", "fields": {"model": "generatedcertificate", "name": "generated certificate", "app_label": "certificates"}}, {"pk": 61, "model": "contenttypes.contenttype", "fields": {"model": "grant", "name": "grant", "app_label": "oauth2"}}, {"pk": 2, "model": "contenttypes.contenttype", "fields": {"model": "group", "name": "group", "app_label": "auth"}}, {"pk": 50, "model": "contenttypes.contenttype", "fields": {"model": "instructortask", "name": "instructor task", "app_label": "instructor_task"}}, {"pk": 9, "model": "contenttypes.contenttype", "fields": {"model": "intervalschedule", "name": "interval", "app_label": "djcelery"}}, {"pk": 87, "model": "contenttypes.contenttype", "fields": {"model": "invoice", "name": "invoice", "app_label": "shoppingcart"}}, {"pk": 106, "model": "contenttypes.contenttype", "fields": {"model": "ipfilter", "name": "ip filter", "app_label": "embargo"}}, {"pk": 110, "model": "contenttypes.contenttype", "fields": {"model": "linkedin", "name": "linked in", "app_label": "linkedin"}}, {"pk": 21, "model": "contenttypes.contenttype", "fields": {"model": "logentry", "name": "log entry", "app_label": "admin"}}, {"pk": 42, "model": "contenttypes.contenttype", "fields": {"model": "loginfailures", "name": "login failures", "app_label": "student"}}, {"pk": 103, "model": "contenttypes.contenttype", "fields": {"model": "midcoursereverificationwindow", "name": "midcourse reverification window", "app_label": "reverification"}}, {"pk": 15, "model": "contenttypes.contenttype", "fields": {"model": "migrationhistory", "name": "migration history", "app_label": "south"}}, {"pk": 18, "model": "contenttypes.contenttype", "fields": {"model": "nonce", "name": "nonce", "app_label": "django_openid_auth"}}, {"pk": 23, "model": "contenttypes.contenttype", "fields": {"model": "nonce", "name": "nonce", "app_label": "default"}}, {"pk": 81, "model": "contenttypes.contenttype", "fields": {"model": "note", "name": "note", "app_label": "notes"}}, {"pk": 78, "model": "contenttypes.contenttype", "fields": {"model": "notification", "name": "notification", "app_label": "django_notify"}}, {"pk": 31, "model": "contenttypes.contenttype", "fields": {"model": "offlinecomputedgrade", "name": "offline computed grade", "app_label": "courseware"}}, {"pk": 32, "model": "contenttypes.contenttype", "fields": {"model": "offlinecomputedgradelog", "name": "offline computed grade log", "app_label": "courseware"}}, {"pk": 56, "model": "contenttypes.contenttype", "fields": {"model": "optout", "name": "optout", "app_label": "bulk_email"}}, {"pk": 85, "model": "contenttypes.contenttype", "fields": {"model": "order", "name": "order", "app_label": "shoppingcart"}}, {"pk": 86, "model": "contenttypes.contenttype", "fields": {"model": "orderitem", "name": "order item", "app_label": "shoppingcart"}}, {"pk": 92, "model": "contenttypes.contenttype", "fields": {"model": "paidcourseregistration", "name": "paid course registration", "app_label": "shoppingcart"}}, {"pk": 95, "model": "contenttypes.contenttype", "fields": {"model": "paidcourseregistrationannotation", "name": "paid course registration annotation", "app_label": "shoppingcart"}}, {"pk": 41, "model": "contenttypes.contenttype", "fields": {"model": "passwordhistory", "name": "password history", "app_label": "student"}}, {"pk": 122, "model": "contenttypes.contenttype", "fields": {"model": "peerworkflow", "name": "peer workflow", "app_label": "assessment"}}, {"pk": 123, "model": "contenttypes.contenttype", "fields": {"model": "peerworkflowitem", "name": "peer workflow item", "app_label": "assessment"}}, {"pk": 40, "model": "contenttypes.contenttype", "fields": {"model": "pendingemailchange", "name": "pending email change", "app_label": "student"}}, {"pk": 39, "model": "contenttypes.contenttype", "fields": {"model": "pendingnamechange", "name": "pending name change", "app_label": "student"}}, {"pk": 12, "model": "contenttypes.contenttype", "fields": {"model": "periodictask", "name": "periodic task", "app_label": "djcelery"}}, {"pk": 11, "model": "contenttypes.contenttype", "fields": {"model": "periodictasks", "name": "periodic tasks", "app_label": "djcelery"}}, {"pk": 1, "model": "contenttypes.contenttype", "fields": {"model": "permission", "name": "permission", "app_label": "auth"}}, {"pk": 133, "model": "contenttypes.contenttype", "fields": {"model": "profile", "name": "profile", "app_label": "edxval"}}, {"pk": 17, "model": "contenttypes.contenttype", "fields": {"model": "psychometricdata", "name": "psychometric data", "app_label": "psychometrics"}}, {"pk": 80, "model": "contenttypes.contenttype", "fields": {"model": "puzzlecomplete", "name": "puzzle complete", "app_label": "foldit"}}, {"pk": 63, "model": "contenttypes.contenttype", "fields": {"model": "refreshtoken", "name": "refresh token", "app_label": "oauth2"}}, {"pk": 38, "model": "contenttypes.contenttype", "fields": {"model": "registration", "name": "registration", "app_label": "student"}}, {"pk": 89, "model": "contenttypes.contenttype", "fields": {"model": "registrationcoderedemption", "name": "registration code redemption", "app_label": "shoppingcart"}}, {"pk": 70, "model": "contenttypes.contenttype", "fields": {"model": "reusableplugin", "name": "reusable plugin", "app_label": "wiki"}}, {"pk": 72, "model": "contenttypes.contenttype", "fields": {"model": "revisionplugin", "name": "revision plugin", "app_label": "wiki"}}, {"pk": 73, "model": "contenttypes.contenttype", "fields": {"model": "revisionpluginrevision", "name": "revision plugin revision", "app_label": "wiki"}}, {"pk": 115, "model": "contenttypes.contenttype", "fields": {"model": "rubric", "name": "rubric", "app_label": "assessment"}}, {"pk": 8, "model": "contenttypes.contenttype", "fields": {"model": "tasksetmeta", "name": "saved group result", "app_label": "djcelery"}}, {"pk": 79, "model": "contenttypes.contenttype", "fields": {"model": "score", "name": "score", "app_label": "foldit"}}, {"pk": 113, "model": "contenttypes.contenttype", "fields": {"model": "score", "name": "score", "app_label": "submissions"}}, {"pk": 114, "model": "contenttypes.contenttype", "fields": {"model": "scoresummary", "name": "score summary", "app_label": "submissions"}}, {"pk": 16, "model": "contenttypes.contenttype", "fields": {"model": "servercircuit", "name": "server circuit", "app_label": "circuit"}}, {"pk": 5, "model": "contenttypes.contenttype", "fields": {"model": "session", "name": "session", "app_label": "sessions"}}, {"pk": 76, "model": "contenttypes.contenttype", "fields": {"model": "settings", "name": "settings", "app_label": "django_notify"}}, {"pk": 71, "model": "contenttypes.contenttype", "fields": {"model": "simpleplugin", "name": "simple plugin", "app_label": "wiki"}}, {"pk": 6, "model": "contenttypes.contenttype", "fields": {"model": "site", "name": "site", "app_label": "sites"}}, {"pk": 101, "model": "contenttypes.contenttype", "fields": {"model": "softwaresecurephotoverification", "name": "software secure photo verification", "app_label": "verify_student"}}, {"pk": 82, "model": "contenttypes.contenttype", "fields": {"model": "splashconfig", "name": "splash config", "app_label": "splash"}}, {"pk": 111, "model": "contenttypes.contenttype", "fields": {"model": "studentitem", "name": "student item", "app_label": "submissions"}}, {"pk": 26, "model": "contenttypes.contenttype", "fields": {"model": "studentmodule", "name": "student module", "app_label": "courseware"}}, {"pk": 27, "model": "contenttypes.contenttype", "fields": {"model": "studentmodulehistory", "name": "student module history", "app_label": "courseware"}}, {"pk": 125, "model": "contenttypes.contenttype", "fields": {"model": "studenttrainingworkflow", "name": "student training workflow", "app_label": "assessment"}}, {"pk": 126, "model": "contenttypes.contenttype", "fields": {"model": "studenttrainingworkflowitem", "name": "student training workflow item", "app_label": "assessment"}}, {"pk": 112, "model": "contenttypes.contenttype", "fields": {"model": "submission", "name": "submission", "app_label": "submissions"}}, {"pk": 77, "model": "contenttypes.contenttype", "fields": {"model": "subscription", "name": "subscription", "app_label": "django_notify"}}, {"pk": 137, "model": "contenttypes.contenttype", "fields": {"model": "subtitle", "name": "subtitle", "app_label": "edxval"}}, {"pk": 109, "model": "contenttypes.contenttype", "fields": {"model": "surveyanswer", "name": "survey answer", "app_label": "survey"}}, {"pk": 108, "model": "contenttypes.contenttype", "fields": {"model": "surveyform", "name": "survey form", "app_label": "survey"}}, {"pk": 14, "model": "contenttypes.contenttype", "fields": {"model": "taskstate", "name": "task", "app_label": "djcelery"}}, {"pk": 7, "model": "contenttypes.contenttype", "fields": {"model": "taskmeta", "name": "task state", "app_label": "djcelery"}}, {"pk": 47, "model": "contenttypes.contenttype", "fields": {"model": "trackinglog", "name": "tracking log", "app_label": "track"}}, {"pk": 124, "model": "contenttypes.contenttype", "fields": {"model": "trainingexample", "name": "training example", "app_label": "assessment"}}, {"pk": 64, "model": "contenttypes.contenttype", "fields": {"model": "trustedclient", "name": "trusted client", "app_label": "oauth2_provider"}}, {"pk": 75, "model": "contenttypes.contenttype", "fields": {"model": "notificationtype", "name": "type", "app_label": "django_notify"}}, {"pk": 68, "model": "contenttypes.contenttype", "fields": {"model": "urlpath", "name": "URL path", "app_label": "wiki"}}, {"pk": 3, "model": "contenttypes.contenttype", "fields": {"model": "user", "name": "user", "app_label": "auth"}}, {"pk": 84, "model": "contenttypes.contenttype", "fields": {"model": "usercoursetag", "name": "user course tag", "app_label": "user_api"}}, {"pk": 52, "model": "contenttypes.contenttype", "fields": {"model": "userlicense", "name": "user license", "app_label": "licenses"}}, {"pk": 20, "model": "contenttypes.contenttype", "fields": {"model": "useropenid", "name": "user open id", "app_label": "django_openid_auth"}}, {"pk": 83, "model": "contenttypes.contenttype", "fields": {"model": "userpreference", "name": "user preference", "app_label": "user_api"}}, {"pk": 35, "model": "contenttypes.contenttype", "fields": {"model": "userprofile", "name": "user profile", "app_label": "student"}}, {"pk": 36, "model": "contenttypes.contenttype", "fields": {"model": "usersignupsource", "name": "user signup source", "app_label": "student"}}, {"pk": 22, "model": "contenttypes.contenttype", "fields": {"model": "usersocialauth", "name": "user social auth", "app_label": "default"}}, {"pk": 34, "model": "contenttypes.contenttype", "fields": {"model": "userstanding", "name": "user standing", "app_label": "student"}}, {"pk": 37, "model": "contenttypes.contenttype", "fields": {"model": "usertestgroup", "name": "user test group", "app_label": "student"}}, {"pk": 134, "model": "contenttypes.contenttype", "fields": {"model": "video", "name": "video", "app_label": "edxval"}}, {"pk": 13, "model": "contenttypes.contenttype", "fields": {"model": "workerstate", "name": "worker", "app_label": "djcelery"}}, {"pk": 30, "model": "contenttypes.contenttype", "fields": {"model": "xmodulestudentinfofield", "name": "x module student info field", "app_label": "courseware"}}, {"pk": 29, "model": "contenttypes.contenttype", "fields": {"model": "xmodulestudentprefsfield", "name": "x module student prefs field", "app_label": "courseware"}}, {"pk": 28, "model": "contenttypes.contenttype", "fields": {"model": "xmoduleuserstatesummaryfield", "name": "x module user state summary field", "app_label": "courseware"}}, {"pk": 1, "model": "sites.site", "fields": {"domain": "example.com", "name": "example.com"}}, {"pk": 1, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:27Z", "app_name": "courseware", "migration": "0001_initial"}}, {"pk": 2, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:27Z", "app_name": "courseware", "migration": "0002_add_indexes"}}, {"pk": 3, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:27Z", "app_name": "courseware", "migration": "0003_done_grade_cache"}}, {"pk": 4, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:27Z", "app_name": "courseware", "migration": "0004_add_field_studentmodule_course_id"}}, {"pk": 5, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:27Z", "app_name": "courseware", "migration": "0005_auto__add_offlinecomputedgrade__add_unique_offlinecomputedgrade_user_c"}}, {"pk": 6, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:27Z", "app_name": "courseware", "migration": "0006_create_student_module_history"}}, {"pk": 7, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:27Z", "app_name": "courseware", "migration": "0007_allow_null_version_in_history"}}, {"pk": 8, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:28Z", "app_name": "courseware", "migration": "0008_add_xmodule_storage"}}, {"pk": 9, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:28Z", "app_name": "courseware", "migration": "0009_add_field_default"}}, {"pk": 10, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:28Z", "app_name": "courseware", "migration": "0010_rename_xblock_field_content_to_user_state_summary"}}, {"pk": 11, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:28Z", "app_name": "student", "migration": "0001_initial"}}, {"pk": 12, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:28Z", "app_name": "student", "migration": "0002_text_to_varchar_and_indexes"}}, {"pk": 13, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:28Z", "app_name": "student", "migration": "0003_auto__add_usertestgroup"}}, {"pk": 14, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:28Z", "app_name": "student", "migration": "0004_add_email_index"}}, {"pk": 15, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:28Z", "app_name": "student", "migration": "0005_name_change"}}, {"pk": 16, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:28Z", "app_name": "student", "migration": "0006_expand_meta_field"}}, {"pk": 17, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:28Z", "app_name": "student", "migration": "0007_convert_to_utf8"}}, {"pk": 18, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:28Z", "app_name": "student", "migration": "0008__auto__add_courseregistration"}}, {"pk": 19, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:29Z", "app_name": "student", "migration": "0009_auto__del_courseregistration__add_courseenrollment"}}, {"pk": 20, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:29Z", "app_name": "student", "migration": "0010_auto__chg_field_courseenrollment_course_id"}}, {"pk": 21, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:29Z", "app_name": "student", "migration": "0011_auto__chg_field_courseenrollment_user__del_unique_courseenrollment_use"}}, {"pk": 22, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:29Z", "app_name": "student", "migration": "0012_auto__add_field_userprofile_gender__add_field_userprofile_date_of_birt"}}, {"pk": 23, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:29Z", "app_name": "student", "migration": "0013_auto__chg_field_userprofile_meta"}}, {"pk": 24, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:29Z", "app_name": "student", "migration": "0014_auto__del_courseenrollment"}}, {"pk": 25, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:29Z", "app_name": "student", "migration": "0015_auto__add_courseenrollment__add_unique_courseenrollment_user_course_id"}}, {"pk": 26, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:29Z", "app_name": "student", "migration": "0016_auto__add_field_courseenrollment_date__chg_field_userprofile_country"}}, {"pk": 27, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:29Z", "app_name": "student", "migration": "0017_rename_date_to_created"}}, {"pk": 28, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:29Z", "app_name": "student", "migration": "0018_auto"}}, {"pk": 29, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:29Z", "app_name": "student", "migration": "0019_create_approved_demographic_fields_fall_2012"}}, {"pk": 30, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:29Z", "app_name": "student", "migration": "0020_add_test_center_user"}}, {"pk": 31, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:29Z", "app_name": "student", "migration": "0021_remove_askbot"}}, {"pk": 32, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:29Z", "app_name": "student", "migration": "0022_auto__add_courseenrollmentallowed__add_unique_courseenrollmentallowed_"}}, {"pk": 33, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:30Z", "app_name": "student", "migration": "0023_add_test_center_registration"}}, {"pk": 34, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:30Z", "app_name": "student", "migration": "0024_add_allow_certificate"}}, {"pk": 35, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:30Z", "app_name": "student", "migration": "0025_auto__add_field_courseenrollmentallowed_auto_enroll"}}, {"pk": 36, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:30Z", "app_name": "student", "migration": "0026_auto__remove_index_student_testcenterregistration_accommodation_request"}}, {"pk": 37, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:30Z", "app_name": "student", "migration": "0027_add_active_flag_and_mode_to_courseware_enrollment"}}, {"pk": 38, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:30Z", "app_name": "student", "migration": "0028_auto__add_userstanding"}}, {"pk": 39, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:30Z", "app_name": "student", "migration": "0029_add_lookup_table_between_user_and_anonymous_student_id"}}, {"pk": 40, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:30Z", "app_name": "student", "migration": "0029_remove_pearson"}}, {"pk": 41, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:30Z", "app_name": "student", "migration": "0030_auto__chg_field_anonymoususerid_anonymous_user_id"}}, {"pk": 42, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:30Z", "app_name": "student", "migration": "0031_drop_student_anonymoususerid_temp_archive"}}, {"pk": 43, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:30Z", "app_name": "student", "migration": "0032_add_field_UserProfile_country_add_field_UserProfile_city"}}, {"pk": 44, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:30Z", "app_name": "student", "migration": "0032_auto__add_loginfailures"}}, {"pk": 45, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:30Z", "app_name": "student", "migration": "0033_auto__add_passwordhistory"}}, {"pk": 46, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:30Z", "app_name": "student", "migration": "0034_auto__add_courseaccessrole"}}, {"pk": 47, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:31Z", "app_name": "student", "migration": "0035_access_roles"}}, {"pk": 48, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:31Z", "app_name": "student", "migration": "0036_access_roles_orgless"}}, {"pk": 49, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:31Z", "app_name": "student", "migration": "0037_auto__add_courseregistrationcode"}}, {"pk": 50, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:31Z", "app_name": "student", "migration": "0038_auto__add_usersignupsource"}}, {"pk": 51, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:31Z", "app_name": "student", "migration": "0039_auto__del_courseregistrationcode"}}, {"pk": 52, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:31Z", "app_name": "student", "migration": "0040_auto__del_field_usersignupsource_user_id__add_field_usersignupsource_u"}}, {"pk": 53, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:31Z", "app_name": "student", "migration": "0041_add_dashboard_config"}}, {"pk": 54, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:32Z", "app_name": "track", "migration": "0001_initial"}}, {"pk": 55, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:32Z", "app_name": "track", "migration": "0002_auto__add_field_trackinglog_host__chg_field_trackinglog_event_type__ch"}}, {"pk": 56, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:32Z", "app_name": "certificates", "migration": "0001_added_generatedcertificates"}}, {"pk": 57, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:32Z", "app_name": "certificates", "migration": "0002_auto__add_field_generatedcertificate_download_url"}}, {"pk": 58, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:32Z", "app_name": "certificates", "migration": "0003_auto__add_field_generatedcertificate_enabled"}}, {"pk": 59, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:32Z", "app_name": "certificates", "migration": "0004_auto__add_field_generatedcertificate_graded_certificate_id__add_field_"}}, {"pk": 60, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:32Z", "app_name": "certificates", "migration": "0005_auto__add_field_generatedcertificate_name"}}, {"pk": 61, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:32Z", "app_name": "certificates", "migration": "0006_auto__chg_field_generatedcertificate_certificate_id"}}, {"pk": 62, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:32Z", "app_name": "certificates", "migration": "0007_auto__add_revokedcertificate"}}, {"pk": 63, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:32Z", "app_name": "certificates", "migration": "0008_auto__del_revokedcertificate__del_field_generatedcertificate_name__add"}}, {"pk": 64, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:32Z", "app_name": "certificates", "migration": "0009_auto__del_field_generatedcertificate_graded_download_url__del_field_ge"}}, {"pk": 65, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:32Z", "app_name": "certificates", "migration": "0010_auto__del_field_generatedcertificate_enabled__add_field_generatedcerti"}}, {"pk": 66, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:33Z", "app_name": "certificates", "migration": "0011_auto__del_field_generatedcertificate_certificate_id__add_field_generat"}}, {"pk": 67, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:33Z", "app_name": "certificates", "migration": "0012_auto__add_field_generatedcertificate_name__add_field_generatedcertific"}}, {"pk": 68, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:33Z", "app_name": "certificates", "migration": "0013_auto__add_field_generatedcertificate_error_reason"}}, {"pk": 69, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:33Z", "app_name": "certificates", "migration": "0014_adding_whitelist"}}, {"pk": 70, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:33Z", "app_name": "certificates", "migration": "0015_adding_mode_for_verified_certs"}}, {"pk": 71, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:33Z", "app_name": "instructor_task", "migration": "0001_initial"}}, {"pk": 72, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:33Z", "app_name": "instructor_task", "migration": "0002_add_subtask_field"}}, {"pk": 73, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:33Z", "app_name": "licenses", "migration": "0001_initial"}}, {"pk": 74, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:34Z", "app_name": "course_groups", "migration": "0001_initial"}}, {"pk": 75, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:34Z", "app_name": "course_groups", "migration": "0002_add_model_CourseUserGroupPartitionGroup"}}, {"pk": 76, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:34Z", "app_name": "bulk_email", "migration": "0001_initial"}}, {"pk": 77, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:34Z", "app_name": "bulk_email", "migration": "0002_change_field_names"}}, {"pk": 78, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:34Z", "app_name": "bulk_email", "migration": "0003_add_optout_user"}}, {"pk": 79, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:34Z", "app_name": "bulk_email", "migration": "0004_migrate_optout_user"}}, {"pk": 80, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:34Z", "app_name": "bulk_email", "migration": "0005_remove_optout_email"}}, {"pk": 81, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:34Z", "app_name": "bulk_email", "migration": "0006_add_course_email_template"}}, {"pk": 82, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:34Z", "app_name": "bulk_email", "migration": "0007_load_course_email_template"}}, {"pk": 83, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:34Z", "app_name": "bulk_email", "migration": "0008_add_course_authorizations"}}, {"pk": 84, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:34Z", "app_name": "bulk_email", "migration": "0009_force_unique_course_ids"}}, {"pk": 85, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:35Z", "app_name": "bulk_email", "migration": "0010_auto__chg_field_optout_course_id__add_field_courseemail_template_name_"}}, {"pk": 86, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:35Z", "app_name": "external_auth", "migration": "0001_initial"}}, {"pk": 87, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:35Z", "app_name": "oauth2", "migration": "0001_initial"}}, {"pk": 88, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:35Z", "app_name": "oauth2", "migration": "0002_auto__chg_field_client_user"}}, {"pk": 89, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:35Z", "app_name": "oauth2", "migration": "0003_auto__add_field_client_name"}}, {"pk": 90, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:35Z", "app_name": "oauth2", "migration": "0004_auto__add_index_accesstoken_token"}}, {"pk": 91, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:36Z", "app_name": "oauth2_provider", "migration": "0001_initial"}}, {"pk": 92, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:36Z", "app_name": "wiki", "migration": "0001_initial"}}, {"pk": 93, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:36Z", "app_name": "wiki", "migration": "0002_auto__add_field_articleplugin_created"}}, {"pk": 94, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:36Z", "app_name": "wiki", "migration": "0003_auto__add_field_urlpath_article"}}, {"pk": 95, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:36Z", "app_name": "wiki", "migration": "0004_populate_urlpath__article"}}, {"pk": 96, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:36Z", "app_name": "wiki", "migration": "0005_auto__chg_field_urlpath_article"}}, {"pk": 97, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:37Z", "app_name": "wiki", "migration": "0006_auto__add_attachmentrevision__add_image__add_attachment"}}, {"pk": 98, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:37Z", "app_name": "wiki", "migration": "0007_auto__add_articlesubscription"}}, {"pk": 99, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:37Z", "app_name": "wiki", "migration": "0008_auto__add_simpleplugin__add_revisionpluginrevision__add_imagerevision_"}}, {"pk": 100, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:37Z", "app_name": "wiki", "migration": "0009_auto__add_field_imagerevision_width__add_field_imagerevision_height"}}, {"pk": 101, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:37Z", "app_name": "wiki", "migration": "0010_auto__chg_field_imagerevision_image"}}, {"pk": 102, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:37Z", "app_name": "wiki", "migration": "0011_auto__chg_field_imagerevision_width__chg_field_imagerevision_height"}}, {"pk": 103, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:38Z", "app_name": "django_notify", "migration": "0001_initial"}}, {"pk": 104, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:38Z", "app_name": "notifications", "migration": "0001_initial"}}, {"pk": 105, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:38Z", "app_name": "foldit", "migration": "0001_initial"}}, {"pk": 106, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:39Z", "app_name": "django_comment_client", "migration": "0001_initial"}}, {"pk": 107, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:39Z", "app_name": "django_comment_common", "migration": "0001_initial"}}, {"pk": 108, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:39Z", "app_name": "notes", "migration": "0001_initial"}}, {"pk": 109, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:39Z", "app_name": "splash", "migration": "0001_initial"}}, {"pk": 110, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:39Z", "app_name": "splash", "migration": "0002_auto__add_field_splashconfig_unaffected_url_paths"}}, {"pk": 111, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:40Z", "app_name": "user_api", "migration": "0001_initial"}}, {"pk": 112, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:40Z", "app_name": "user_api", "migration": "0002_auto__add_usercoursetags__add_unique_usercoursetags_user_course_id_key"}}, {"pk": 113, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:40Z", "app_name": "user_api", "migration": "0003_rename_usercoursetags"}}, {"pk": 114, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:40Z", "app_name": "shoppingcart", "migration": "0001_initial"}}, {"pk": 115, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:40Z", "app_name": "shoppingcart", "migration": "0002_auto__add_field_paidcourseregistration_mode"}}, {"pk": 116, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:40Z", "app_name": "shoppingcart", "migration": "0003_auto__del_field_orderitem_line_cost"}}, {"pk": 117, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:40Z", "app_name": "shoppingcart", "migration": "0004_auto__add_field_orderitem_fulfilled_time"}}, {"pk": 118, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:40Z", "app_name": "shoppingcart", "migration": "0005_auto__add_paidcourseregistrationannotation__add_field_orderitem_report"}}, {"pk": 119, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:40Z", "app_name": "shoppingcart", "migration": "0006_auto__add_field_order_refunded_time__add_field_orderitem_refund_reques"}}, {"pk": 120, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:40Z", "app_name": "shoppingcart", "migration": "0007_auto__add_field_orderitem_service_fee"}}, {"pk": 121, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:41Z", "app_name": "shoppingcart", "migration": "0008_auto__add_coupons__add_couponredemption__chg_field_certificateitem_cou"}}, {"pk": 122, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:41Z", "app_name": "shoppingcart", "migration": "0009_auto__del_coupons__add_courseregistrationcode__add_coupon__chg_field_c"}}, {"pk": 123, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:41Z", "app_name": "shoppingcart", "migration": "0010_auto__add_registrationcoderedemption__del_field_courseregistrationcode"}}, {"pk": 124, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:41Z", "app_name": "shoppingcart", "migration": "0011_auto__add_invoice__add_field_courseregistrationcode_invoice"}}, {"pk": 125, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:41Z", "app_name": "shoppingcart", "migration": "0012_auto__del_field_courseregistrationcode_transaction_group_name__del_fie"}}, {"pk": 126, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:41Z", "app_name": "shoppingcart", "migration": "0013_auto__add_field_invoice_is_valid"}}, {"pk": 127, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:42Z", "app_name": "shoppingcart", "migration": "0014_auto__del_field_invoice_tax_id__add_field_invoice_address_line_1__add_"}}, {"pk": 128, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:42Z", "app_name": "shoppingcart", "migration": "0015_auto__del_field_invoice_purchase_order_number__del_field_invoice_compa"}}, {"pk": 129, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:42Z", "app_name": "shoppingcart", "migration": "0016_auto__del_field_invoice_company_email__del_field_invoice_company_refer"}}, {"pk": 130, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:42Z", "app_name": "shoppingcart", "migration": "0017_auto__add_field_courseregistrationcode_order__chg_field_registrationco"}}, {"pk": 131, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:42Z", "app_name": "shoppingcart", "migration": "0018_auto__add_donation"}}, {"pk": 132, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:42Z", "app_name": "shoppingcart", "migration": "0019_auto__add_donationconfiguration"}}, {"pk": 133, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:42Z", "app_name": "shoppingcart", "migration": "0020_auto__add_courseregcodeitem__add_courseregcodeitemannotation__add_fiel"}}, {"pk": 134, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:42Z", "app_name": "shoppingcart", "migration": "0021_auto__add_field_orderitem_created__add_field_orderitem_modified"}}, {"pk": 135, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:42Z", "app_name": "course_modes", "migration": "0001_initial"}}, {"pk": 136, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:42Z", "app_name": "course_modes", "migration": "0002_auto__add_field_coursemode_currency"}}, {"pk": 137, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:43Z", "app_name": "course_modes", "migration": "0003_auto__add_unique_coursemode_course_id_currency_mode_slug"}}, {"pk": 138, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:43Z", "app_name": "course_modes", "migration": "0004_auto__add_field_coursemode_expiration_date"}}, {"pk": 139, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:43Z", "app_name": "course_modes", "migration": "0005_auto__add_field_coursemode_expiration_datetime"}}, {"pk": 140, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:43Z", "app_name": "course_modes", "migration": "0006_expiration_date_to_datetime"}}, {"pk": 141, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:43Z", "app_name": "course_modes", "migration": "0007_add_description"}}, {"pk": 142, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:43Z", "app_name": "course_modes", "migration": "0007_auto__add_coursemodesarchive__chg_field_coursemode_course_id"}}, {"pk": 143, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:43Z", "app_name": "verify_student", "migration": "0001_initial"}}, {"pk": 144, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:43Z", "app_name": "verify_student", "migration": "0002_auto__add_field_softwaresecurephotoverification_window"}}, {"pk": 145, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:43Z", "app_name": "verify_student", "migration": "0003_auto__add_field_softwaresecurephotoverification_display"}}, {"pk": 146, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:43Z", "app_name": "dark_lang", "migration": "0001_initial"}}, {"pk": 147, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:43Z", "app_name": "dark_lang", "migration": "0002_enable_on_install"}}, {"pk": 148, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:44Z", "app_name": "reverification", "migration": "0001_initial"}}, {"pk": 149, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:44Z", "app_name": "embargo", "migration": "0001_initial"}}, {"pk": 150, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:44Z", "app_name": "course_action_state", "migration": "0001_initial"}}, {"pk": 151, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:44Z", "app_name": "course_action_state", "migration": "0002_add_rerun_display_name"}}, {"pk": 152, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:45Z", "app_name": "survey", "migration": "0001_initial"}}, {"pk": 153, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:45Z", "app_name": "linkedin", "migration": "0001_initial"}}, {"pk": 154, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:45Z", "app_name": "submissions", "migration": "0001_initial"}}, {"pk": 155, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:46Z", "app_name": "submissions", "migration": "0002_auto__add_scoresummary"}}, {"pk": 156, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:46Z", "app_name": "submissions", "migration": "0003_auto__del_field_submission_answer__add_field_submission_raw_answer"}}, {"pk": 157, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:46Z", "app_name": "submissions", "migration": "0004_auto__add_field_score_reset"}}, {"pk": 158, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:46Z", "app_name": "assessment", "migration": "0001_initial"}}, {"pk": 159, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:46Z", "app_name": "assessment", "migration": "0002_auto__add_assessmentfeedbackoption__del_field_assessmentfeedback_feedb"}}, {"pk": 160, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:46Z", "app_name": "assessment", "migration": "0003_add_index_pw_course_item_student"}}, {"pk": 161, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:46Z", "app_name": "assessment", "migration": "0004_auto__add_field_peerworkflow_graded_count"}}, {"pk": 162, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:47Z", "app_name": "assessment", "migration": "0005_auto__del_field_peerworkflow_graded_count__add_field_peerworkflow_grad"}}, {"pk": 163, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:47Z", "app_name": "assessment", "migration": "0006_auto__add_field_assessmentpart_feedback"}}, {"pk": 164, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:47Z", "app_name": "assessment", "migration": "0007_auto__chg_field_assessmentpart_feedback"}}, {"pk": 165, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:47Z", "app_name": "assessment", "migration": "0008_student_training"}}, {"pk": 166, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:47Z", "app_name": "assessment", "migration": "0009_auto__add_unique_studenttrainingworkflowitem_order_num_workflow"}}, {"pk": 167, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:47Z", "app_name": "assessment", "migration": "0010_auto__add_unique_studenttrainingworkflow_submission_uuid"}}, {"pk": 168, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:47Z", "app_name": "assessment", "migration": "0011_ai_training"}}, {"pk": 169, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:47Z", "app_name": "assessment", "migration": "0012_move_algorithm_id_to_classifier_set"}}, {"pk": 170, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:47Z", "app_name": "assessment", "migration": "0013_auto__add_field_aigradingworkflow_essay_text"}}, {"pk": 171, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:48Z", "app_name": "assessment", "migration": "0014_auto__add_field_aitrainingworkflow_item_id__add_field_aitrainingworkfl"}}, {"pk": 172, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:48Z", "app_name": "assessment", "migration": "0015_auto__add_unique_aitrainingworkflow_uuid__add_unique_aigradingworkflow"}}, {"pk": 173, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:48Z", "app_name": "assessment", "migration": "0016_auto__add_field_aiclassifierset_course_id__add_field_aiclassifierset_i"}}, {"pk": 174, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:48Z", "app_name": "assessment", "migration": "0016_auto__add_field_rubric_structure_hash"}}, {"pk": 175, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:48Z", "app_name": "assessment", "migration": "0017_rubric_structure_hash"}}, {"pk": 176, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:48Z", "app_name": "assessment", "migration": "0018_auto__add_field_assessmentpart_criterion"}}, {"pk": 177, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:48Z", "app_name": "assessment", "migration": "0019_assessmentpart_criterion_field"}}, {"pk": 178, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:48Z", "app_name": "assessment", "migration": "0020_assessmentpart_criterion_not_null"}}, {"pk": 179, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:48Z", "app_name": "assessment", "migration": "0021_assessmentpart_option_nullable"}}, {"pk": 180, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:48Z", "app_name": "assessment", "migration": "0022__add_label_fields"}}, {"pk": 181, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:48Z", "app_name": "assessment", "migration": "0023_assign_criteria_and_option_labels"}}, {"pk": 182, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:49Z", "app_name": "workflow", "migration": "0001_initial"}}, {"pk": 183, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:49Z", "app_name": "workflow", "migration": "0002_auto__add_field_assessmentworkflow_course_id__add_field_assessmentwork"}}, {"pk": 184, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:49Z", "app_name": "workflow", "migration": "0003_auto__add_assessmentworkflowstep"}}, {"pk": 185, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:49Z", "app_name": "edxval", "migration": "0001_initial"}}, {"pk": 186, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:49Z", "app_name": "edxval", "migration": "0002_default_profiles"}}, {"pk": 187, "model": "south.migrationhistory", "fields": {"applied": "2014-11-13T22:49:49Z", "app_name": "django_extensions", "migration": "0001_empty"}}, {"pk": 1, "model": "edxval.profile", "fields": {"width": 1280, "profile_name": "desktop_mp4", "extension": "mp4", "height": 720}}, {"pk": 2, "model": "edxval.profile", "fields": {"width": 1280, "profile_name": "desktop_webm", "extension": "webm", "height": 720}}, {"pk": 3, "model": "edxval.profile", "fields": {"width": 960, "profile_name": "mobile_high", "extension": "mp4", "height": 540}}, {"pk": 4, "model": "edxval.profile", "fields": {"width": 640, "profile_name": "mobile_low", "extension": "mp4", "height": 360}}, {"pk": 5, "model": "edxval.profile", "fields": {"width": 1920, "profile_name": "youtube", "extension": "mp4", "height": 1080}}, {"pk": 61, "model": "auth.permission", "fields": {"codename": "add_logentry", "name": "Can add log entry", "content_type": 21}}, {"pk": 62, "model": "auth.permission", "fields": {"codename": "change_logentry", "name": "Can change log entry", "content_type": 21}}, {"pk": 63, "model": "auth.permission", "fields": {"codename": "delete_logentry", "name": "Can delete log entry", "content_type": 21}}, {"pk": 385, "model": "auth.permission", "fields": {"codename": "add_aiclassifier", "name": "Can add ai classifier", "content_type": 128}}, {"pk": 386, "model": "auth.permission", "fields": {"codename": "change_aiclassifier", "name": "Can change ai classifier", "content_type": 128}}, {"pk": 387, "model": "auth.permission", "fields": {"codename": "delete_aiclassifier", "name": "Can delete ai classifier", "content_type": 128}}, {"pk": 382, "model": "auth.permission", "fields": {"codename": "add_aiclassifierset", "name": "Can add ai classifier set", "content_type": 127}}, {"pk": 383, "model": "auth.permission", "fields": {"codename": "change_aiclassifierset", "name": "Can change ai classifier set", "content_type": 127}}, {"pk": 384, "model": "auth.permission", "fields": {"codename": "delete_aiclassifierset", "name": "Can delete ai classifier set", "content_type": 127}}, {"pk": 391, "model": "auth.permission", "fields": {"codename": "add_aigradingworkflow", "name": "Can add ai grading workflow", "content_type": 130}}, {"pk": 392, "model": "auth.permission", "fields": {"codename": "change_aigradingworkflow", "name": "Can change ai grading workflow", "content_type": 130}}, {"pk": 393, "model": "auth.permission", "fields": {"codename": "delete_aigradingworkflow", "name": "Can delete ai grading workflow", "content_type": 130}}, {"pk": 388, "model": "auth.permission", "fields": {"codename": "add_aitrainingworkflow", "name": "Can add ai training workflow", "content_type": 129}}, {"pk": 389, "model": "auth.permission", "fields": {"codename": "change_aitrainingworkflow", "name": "Can change ai training workflow", "content_type": 129}}, {"pk": 390, "model": "auth.permission", "fields": {"codename": "delete_aitrainingworkflow", "name": "Can delete ai training workflow", "content_type": 129}}, {"pk": 355, "model": "auth.permission", "fields": {"codename": "add_assessment", "name": "Can add assessment", "content_type": 118}}, {"pk": 356, "model": "auth.permission", "fields": {"codename": "change_assessment", "name": "Can change assessment", "content_type": 118}}, {"pk": 357, "model": "auth.permission", "fields": {"codename": "delete_assessment", "name": "Can delete assessment", "content_type": 118}}, {"pk": 364, "model": "auth.permission", "fields": {"codename": "add_assessmentfeedback", "name": "Can add assessment feedback", "content_type": 121}}, {"pk": 365, "model": "auth.permission", "fields": {"codename": "change_assessmentfeedback", "name": "Can change assessment feedback", "content_type": 121}}, {"pk": 366, "model": "auth.permission", "fields": {"codename": "delete_assessmentfeedback", "name": "Can delete assessment feedback", "content_type": 121}}, {"pk": 361, "model": "auth.permission", "fields": {"codename": "add_assessmentfeedbackoption", "name": "Can add assessment feedback option", "content_type": 120}}, {"pk": 362, "model": "auth.permission", "fields": {"codename": "change_assessmentfeedbackoption", "name": "Can change assessment feedback option", "content_type": 120}}, {"pk": 363, "model": "auth.permission", "fields": {"codename": "delete_assessmentfeedbackoption", "name": "Can delete assessment feedback option", "content_type": 120}}, {"pk": 358, "model": "auth.permission", "fields": {"codename": "add_assessmentpart", "name": "Can add assessment part", "content_type": 119}}, {"pk": 359, "model": "auth.permission", "fields": {"codename": "change_assessmentpart", "name": "Can change assessment part", "content_type": 119}}, {"pk": 360, "model": "auth.permission", "fields": {"codename": "delete_assessmentpart", "name": "Can delete assessment part", "content_type": 119}}, {"pk": 349, "model": "auth.permission", "fields": {"codename": "add_criterion", "name": "Can add criterion", "content_type": 116}}, {"pk": 350, "model": "auth.permission", "fields": {"codename": "change_criterion", "name": "Can change criterion", "content_type": 116}}, {"pk": 351, "model": "auth.permission", "fields": {"codename": "delete_criterion", "name": "Can delete criterion", "content_type": 116}}, {"pk": 352, "model": "auth.permission", "fields": {"codename": "add_criterionoption", "name": "Can add criterion option", "content_type": 117}}, {"pk": 353, "model": "auth.permission", "fields": {"codename": "change_criterionoption", "name": "Can change criterion option", "content_type": 117}}, {"pk": 354, "model": "auth.permission", "fields": {"codename": "delete_criterionoption", "name": "Can delete criterion option", "content_type": 117}}, {"pk": 367, "model": "auth.permission", "fields": {"codename": "add_peerworkflow", "name": "Can add peer workflow", "content_type": 122}}, {"pk": 368, "model": "auth.permission", "fields": {"codename": "change_peerworkflow", "name": "Can change peer workflow", "content_type": 122}}, {"pk": 369, "model": "auth.permission", "fields": {"codename": "delete_peerworkflow", "name": "Can delete peer workflow", "content_type": 122}}, {"pk": 370, "model": "auth.permission", "fields": {"codename": "add_peerworkflowitem", "name": "Can add peer workflow item", "content_type": 123}}, {"pk": 371, "model": "auth.permission", "fields": {"codename": "change_peerworkflowitem", "name": "Can change peer workflow item", "content_type": 123}}, {"pk": 372, "model": "auth.permission", "fields": {"codename": "delete_peerworkflowitem", "name": "Can delete peer workflow item", "content_type": 123}}, {"pk": 346, "model": "auth.permission", "fields": {"codename": "add_rubric", "name": "Can add rubric", "content_type": 115}}, {"pk": 347, "model": "auth.permission", "fields": {"codename": "change_rubric", "name": "Can change rubric", "content_type": 115}}, {"pk": 348, "model": "auth.permission", "fields": {"codename": "delete_rubric", "name": "Can delete rubric", "content_type": 115}}, {"pk": 376, "model": "auth.permission", "fields": {"codename": "add_studenttrainingworkflow", "name": "Can add student training workflow", "content_type": 125}}, {"pk": 377, "model": "auth.permission", "fields": {"codename": "change_studenttrainingworkflow", "name": "Can change student training workflow", "content_type": 125}}, {"pk": 378, "model": "auth.permission", "fields": {"codename": "delete_studenttrainingworkflow", "name": "Can delete student training workflow", "content_type": 125}}, {"pk": 379, "model": "auth.permission", "fields": {"codename": "add_studenttrainingworkflowitem", "name": "Can add student training workflow item", "content_type": 126}}, {"pk": 380, "model": "auth.permission", "fields": {"codename": "change_studenttrainingworkflowitem", "name": "Can change student training workflow item", "content_type": 126}}, {"pk": 381, "model": "auth.permission", "fields": {"codename": "delete_studenttrainingworkflowitem", "name": "Can delete student training workflow item", "content_type": 126}}, {"pk": 373, "model": "auth.permission", "fields": {"codename": "add_trainingexample", "name": "Can add training example", "content_type": 124}}, {"pk": 374, "model": "auth.permission", "fields": {"codename": "change_trainingexample", "name": "Can change training example", "content_type": 124}}, {"pk": 375, "model": "auth.permission", "fields": {"codename": "delete_trainingexample", "name": "Can delete training example", "content_type": 124}}, {"pk": 4, "model": "auth.permission", "fields": {"codename": "add_group", "name": "Can add group", "content_type": 2}}, {"pk": 5, "model": "auth.permission", "fields": {"codename": "change_group", "name": "Can change group", "content_type": 2}}, {"pk": 6, "model": "auth.permission", "fields": {"codename": "delete_group", "name": "Can delete group", "content_type": 2}}, {"pk": 1, "model": "auth.permission", "fields": {"codename": "add_permission", "name": "Can add permission", "content_type": 1}}, {"pk": 2, "model": "auth.permission", "fields": {"codename": "change_permission", "name": "Can change permission", "content_type": 1}}, {"pk": 3, "model": "auth.permission", "fields": {"codename": "delete_permission", "name": "Can delete permission", "content_type": 1}}, {"pk": 7, "model": "auth.permission", "fields": {"codename": "add_user", "name": "Can add user", "content_type": 3}}, {"pk": 8, "model": "auth.permission", "fields": {"codename": "change_user", "name": "Can change user", "content_type": 3}}, {"pk": 9, "model": "auth.permission", "fields": {"codename": "delete_user", "name": "Can delete user", "content_type": 3}}, {"pk": 172, "model": "auth.permission", "fields": {"codename": "add_courseauthorization", "name": "Can add course authorization", "content_type": 58}}, {"pk": 173, "model": "auth.permission", "fields": {"codename": "change_courseauthorization", "name": "Can change course authorization", "content_type": 58}}, {"pk": 174, "model": "auth.permission", "fields": {"codename": "delete_courseauthorization", "name": "Can delete course authorization", "content_type": 58}}, {"pk": 163, "model": "auth.permission", "fields": {"codename": "add_courseemail", "name": "Can add course email", "content_type": 55}}, {"pk": 164, "model": "auth.permission", "fields": {"codename": "change_courseemail", "name": "Can change course email", "content_type": 55}}, {"pk": 165, "model": "auth.permission", "fields": {"codename": "delete_courseemail", "name": "Can delete course email", "content_type": 55}}, {"pk": 169, "model": "auth.permission", "fields": {"codename": "add_courseemailtemplate", "name": "Can add course email template", "content_type": 57}}, {"pk": 170, "model": "auth.permission", "fields": {"codename": "change_courseemailtemplate", "name": "Can change course email template", "content_type": 57}}, {"pk": 171, "model": "auth.permission", "fields": {"codename": "delete_courseemailtemplate", "name": "Can delete course email template", "content_type": 57}}, {"pk": 166, "model": "auth.permission", "fields": {"codename": "add_optout", "name": "Can add optout", "content_type": 56}}, {"pk": 167, "model": "auth.permission", "fields": {"codename": "change_optout", "name": "Can change optout", "content_type": 56}}, {"pk": 168, "model": "auth.permission", "fields": {"codename": "delete_optout", "name": "Can delete optout", "content_type": 56}}, {"pk": 142, "model": "auth.permission", "fields": {"codename": "add_certificatewhitelist", "name": "Can add certificate whitelist", "content_type": 48}}, {"pk": 143, "model": "auth.permission", "fields": {"codename": "change_certificatewhitelist", "name": "Can change certificate whitelist", "content_type": 48}}, {"pk": 144, "model": "auth.permission", "fields": {"codename": "delete_certificatewhitelist", "name": "Can delete certificate whitelist", "content_type": 48}}, {"pk": 145, "model": "auth.permission", "fields": {"codename": "add_generatedcertificate", "name": "Can add generated certificate", "content_type": 49}}, {"pk": 146, "model": "auth.permission", "fields": {"codename": "change_generatedcertificate", "name": "Can change generated certificate", "content_type": 49}}, {"pk": 147, "model": "auth.permission", "fields": {"codename": "delete_generatedcertificate", "name": "Can delete generated certificate", "content_type": 49}}, {"pk": 46, "model": "auth.permission", "fields": {"codename": "add_servercircuit", "name": "Can add server circuit", "content_type": 16}}, {"pk": 47, "model": "auth.permission", "fields": {"codename": "change_servercircuit", "name": "Can change server circuit", "content_type": 16}}, {"pk": 48, "model": "auth.permission", "fields": {"codename": "delete_servercircuit", "name": "Can delete server circuit", "content_type": 16}}, {"pk": 10, "model": "auth.permission", "fields": {"codename": "add_contenttype", "name": "Can add content type", "content_type": 4}}, {"pk": 11, "model": "auth.permission", "fields": {"codename": "change_contenttype", "name": "Can change content type", "content_type": 4}}, {"pk": 12, "model": "auth.permission", "fields": {"codename": "delete_contenttype", "name": "Can delete content type", "content_type": 4}}, {"pk": 91, "model": "auth.permission", "fields": {"codename": "add_offlinecomputedgrade", "name": "Can add offline computed grade", "content_type": 31}}, {"pk": 92, "model": "auth.permission", "fields": {"codename": "change_offlinecomputedgrade", "name": "Can change offline computed grade", "content_type": 31}}, {"pk": 93, "model": "auth.permission", "fields": {"codename": "delete_offlinecomputedgrade", "name": "Can delete offline computed grade", "content_type": 31}}, {"pk": 94, "model": "auth.permission", "fields": {"codename": "add_offlinecomputedgradelog", "name": "Can add offline computed grade log", "content_type": 32}}, {"pk": 95, "model": "auth.permission", "fields": {"codename": "change_offlinecomputedgradelog", "name": "Can change offline computed grade log", "content_type": 32}}, {"pk": 96, "model": "auth.permission", "fields": {"codename": "delete_offlinecomputedgradelog", "name": "Can delete offline computed grade log", "content_type": 32}}, {"pk": 76, "model": "auth.permission", "fields": {"codename": "add_studentmodule", "name": "Can add student module", "content_type": 26}}, {"pk": 77, "model": "auth.permission", "fields": {"codename": "change_studentmodule", "name": "Can change student module", "content_type": 26}}, {"pk": 78, "model": "auth.permission", "fields": {"codename": "delete_studentmodule", "name": "Can delete student module", "content_type": 26}}, {"pk": 79, "model": "auth.permission", "fields": {"codename": "add_studentmodulehistory", "name": "Can add student module history", "content_type": 27}}, {"pk": 80, "model": "auth.permission", "fields": {"codename": "change_studentmodulehistory", "name": "Can change student module history", "content_type": 27}}, {"pk": 81, "model": "auth.permission", "fields": {"codename": "delete_studentmodulehistory", "name": "Can delete student module history", "content_type": 27}}, {"pk": 88, "model": "auth.permission", "fields": {"codename": "add_xmodulestudentinfofield", "name": "Can add x module student info field", "content_type": 30}}, {"pk": 89, "model": "auth.permission", "fields": {"codename": "change_xmodulestudentinfofield", "name": "Can change x module student info field", "content_type": 30}}, {"pk": 90, "model": "auth.permission", "fields": {"codename": "delete_xmodulestudentinfofield", "name": "Can delete x module student info field", "content_type": 30}}, {"pk": 85, "model": "auth.permission", "fields": {"codename": "add_xmodulestudentprefsfield", "name": "Can add x module student prefs field", "content_type": 29}}, {"pk": 86, "model": "auth.permission", "fields": {"codename": "change_xmodulestudentprefsfield", "name": "Can change x module student prefs field", "content_type": 29}}, {"pk": 87, "model": "auth.permission", "fields": {"codename": "delete_xmodulestudentprefsfield", "name": "Can delete x module student prefs field", "content_type": 29}}, {"pk": 82, "model": "auth.permission", "fields": {"codename": "add_xmoduleuserstatesummaryfield", "name": "Can add x module user state summary field", "content_type": 28}}, {"pk": 83, "model": "auth.permission", "fields": {"codename": "change_xmoduleuserstatesummaryfield", "name": "Can change x module user state summary field", "content_type": 28}}, {"pk": 84, "model": "auth.permission", "fields": {"codename": "delete_xmoduleuserstatesummaryfield", "name": "Can delete x module user state summary field", "content_type": 28}}, {"pk": 322, "model": "auth.permission", "fields": {"codename": "add_coursererunstate", "name": "Can add course rerun state", "content_type": 107}}, {"pk": 323, "model": "auth.permission", "fields": {"codename": "change_coursererunstate", "name": "Can change course rerun state", "content_type": 107}}, {"pk": 324, "model": "auth.permission", "fields": {"codename": "delete_coursererunstate", "name": "Can delete course rerun state", "content_type": 107}}, {"pk": 157, "model": "auth.permission", "fields": {"codename": "add_courseusergroup", "name": "Can add course user group", "content_type": 53}}, {"pk": 158, "model": "auth.permission", "fields": {"codename": "change_courseusergroup", "name": "Can change course user group", "content_type": 53}}, {"pk": 159, "model": "auth.permission", "fields": {"codename": "delete_courseusergroup", "name": "Can delete course user group", "content_type": 53}}, {"pk": 160, "model": "auth.permission", "fields": {"codename": "add_courseusergrouppartitiongroup", "name": "Can add course user group partition group", "content_type": 54}}, {"pk": 161, "model": "auth.permission", "fields": {"codename": "change_courseusergrouppartitiongroup", "name": "Can change course user group partition group", "content_type": 54}}, {"pk": 162, "model": "auth.permission", "fields": {"codename": "delete_courseusergrouppartitiongroup", "name": "Can delete course user group partition group", "content_type": 54}}, {"pk": 298, "model": "auth.permission", "fields": {"codename": "add_coursemode", "name": "Can add course mode", "content_type": 99}}, {"pk": 299, "model": "auth.permission", "fields": {"codename": "change_coursemode", "name": "Can change course mode", "content_type": 99}}, {"pk": 300, "model": "auth.permission", "fields": {"codename": "delete_coursemode", "name": "Can delete course mode", "content_type": 99}}, {"pk": 301, "model": "auth.permission", "fields": {"codename": "add_coursemodesarchive", "name": "Can add course modes archive", "content_type": 100}}, {"pk": 302, "model": "auth.permission", "fields": {"codename": "change_coursemodesarchive", "name": "Can change course modes archive", "content_type": 100}}, {"pk": 303, "model": "auth.permission", "fields": {"codename": "delete_coursemodesarchive", "name": "Can delete course modes archive", "content_type": 100}}, {"pk": 307, "model": "auth.permission", "fields": {"codename": "add_darklangconfig", "name": "Can add dark lang config", "content_type": 102}}, {"pk": 308, "model": "auth.permission", "fields": {"codename": "change_darklangconfig", "name": "Can change dark lang config", "content_type": 102}}, {"pk": 309, "model": "auth.permission", "fields": {"codename": "delete_darklangconfig", "name": "Can delete dark lang config", "content_type": 102}}, {"pk": 70, "model": "auth.permission", "fields": {"codename": "add_association", "name": "Can add association", "content_type": 24}}, {"pk": 71, "model": "auth.permission", "fields": {"codename": "change_association", "name": "Can change association", "content_type": 24}}, {"pk": 72, "model": "auth.permission", "fields": {"codename": "delete_association", "name": "Can delete association", "content_type": 24}}, {"pk": 73, "model": "auth.permission", "fields": {"codename": "add_code", "name": "Can add code", "content_type": 25}}, {"pk": 74, "model": "auth.permission", "fields": {"codename": "change_code", "name": "Can change code", "content_type": 25}}, {"pk": 75, "model": "auth.permission", "fields": {"codename": "delete_code", "name": "Can delete code", "content_type": 25}}, {"pk": 67, "model": "auth.permission", "fields": {"codename": "add_nonce", "name": "Can add nonce", "content_type": 23}}, {"pk": 68, "model": "auth.permission", "fields": {"codename": "change_nonce", "name": "Can change nonce", "content_type": 23}}, {"pk": 69, "model": "auth.permission", "fields": {"codename": "delete_nonce", "name": "Can delete nonce", "content_type": 23}}, {"pk": 64, "model": "auth.permission", "fields": {"codename": "add_usersocialauth", "name": "Can add user social auth", "content_type": 22}}, {"pk": 65, "model": "auth.permission", "fields": {"codename": "change_usersocialauth", "name": "Can change user social auth", "content_type": 22}}, {"pk": 66, "model": "auth.permission", "fields": {"codename": "delete_usersocialauth", "name": "Can delete user social auth", "content_type": 22}}, {"pk": 235, "model": "auth.permission", "fields": {"codename": "add_notification", "name": "Can add notification", "content_type": 78}}, {"pk": 236, "model": "auth.permission", "fields": {"codename": "change_notification", "name": "Can change notification", "content_type": 78}}, {"pk": 237, "model": "auth.permission", "fields": {"codename": "delete_notification", "name": "Can delete notification", "content_type": 78}}, {"pk": 226, "model": "auth.permission", "fields": {"codename": "add_notificationtype", "name": "Can add type", "content_type": 75}}, {"pk": 227, "model": "auth.permission", "fields": {"codename": "change_notificationtype", "name": "Can change type", "content_type": 75}}, {"pk": 228, "model": "auth.permission", "fields": {"codename": "delete_notificationtype", "name": "Can delete type", "content_type": 75}}, {"pk": 229, "model": "auth.permission", "fields": {"codename": "add_settings", "name": "Can add settings", "content_type": 76}}, {"pk": 230, "model": "auth.permission", "fields": {"codename": "change_settings", "name": "Can change settings", "content_type": 76}}, {"pk": 231, "model": "auth.permission", "fields": {"codename": "delete_settings", "name": "Can delete settings", "content_type": 76}}, {"pk": 232, "model": "auth.permission", "fields": {"codename": "add_subscription", "name": "Can add subscription", "content_type": 77}}, {"pk": 233, "model": "auth.permission", "fields": {"codename": "change_subscription", "name": "Can change subscription", "content_type": 77}}, {"pk": 234, "model": "auth.permission", "fields": {"codename": "delete_subscription", "name": "Can delete subscription", "content_type": 77}}, {"pk": 55, "model": "auth.permission", "fields": {"codename": "add_association", "name": "Can add association", "content_type": 19}}, {"pk": 56, "model": "auth.permission", "fields": {"codename": "change_association", "name": "Can change association", "content_type": 19}}, {"pk": 57, "model": "auth.permission", "fields": {"codename": "delete_association", "name": "Can delete association", "content_type": 19}}, {"pk": 52, "model": "auth.permission", "fields": {"codename": "add_nonce", "name": "Can add nonce", "content_type": 18}}, {"pk": 53, "model": "auth.permission", "fields": {"codename": "change_nonce", "name": "Can change nonce", "content_type": 18}}, {"pk": 54, "model": "auth.permission", "fields": {"codename": "delete_nonce", "name": "Can delete nonce", "content_type": 18}}, {"pk": 58, "model": "auth.permission", "fields": {"codename": "add_useropenid", "name": "Can add user open id", "content_type": 20}}, {"pk": 59, "model": "auth.permission", "fields": {"codename": "change_useropenid", "name": "Can change user open id", "content_type": 20}}, {"pk": 60, "model": "auth.permission", "fields": {"codename": "delete_useropenid", "name": "Can delete user open id", "content_type": 20}}, {"pk": 28, "model": "auth.permission", "fields": {"codename": "add_crontabschedule", "name": "Can add crontab", "content_type": 10}}, {"pk": 29, "model": "auth.permission", "fields": {"codename": "change_crontabschedule", "name": "Can change crontab", "content_type": 10}}, {"pk": 30, "model": "auth.permission", "fields": {"codename": "delete_crontabschedule", "name": "Can delete crontab", "content_type": 10}}, {"pk": 25, "model": "auth.permission", "fields": {"codename": "add_intervalschedule", "name": "Can add interval", "content_type": 9}}, {"pk": 26, "model": "auth.permission", "fields": {"codename": "change_intervalschedule", "name": "Can change interval", "content_type": 9}}, {"pk": 27, "model": "auth.permission", "fields": {"codename": "delete_intervalschedule", "name": "Can delete interval", "content_type": 9}}, {"pk": 34, "model": "auth.permission", "fields": {"codename": "add_periodictask", "name": "Can add periodic task", "content_type": 12}}, {"pk": 35, "model": "auth.permission", "fields": {"codename": "change_periodictask", "name": "Can change periodic task", "content_type": 12}}, {"pk": 36, "model": "auth.permission", "fields": {"codename": "delete_periodictask", "name": "Can delete periodic task", "content_type": 12}}, {"pk": 31, "model": "auth.permission", "fields": {"codename": "add_periodictasks", "name": "Can add periodic tasks", "content_type": 11}}, {"pk": 32, "model": "auth.permission", "fields": {"codename": "change_periodictasks", "name": "Can change periodic tasks", "content_type": 11}}, {"pk": 33, "model": "auth.permission", "fields": {"codename": "delete_periodictasks", "name": "Can delete periodic tasks", "content_type": 11}}, {"pk": 19, "model": "auth.permission", "fields": {"codename": "add_taskmeta", "name": "Can add task state", "content_type": 7}}, {"pk": 20, "model": "auth.permission", "fields": {"codename": "change_taskmeta", "name": "Can change task state", "content_type": 7}}, {"pk": 21, "model": "auth.permission", "fields": {"codename": "delete_taskmeta", "name": "Can delete task state", "content_type": 7}}, {"pk": 22, "model": "auth.permission", "fields": {"codename": "add_tasksetmeta", "name": "Can add saved group result", "content_type": 8}}, {"pk": 23, "model": "auth.permission", "fields": {"codename": "change_tasksetmeta", "name": "Can change saved group result", "content_type": 8}}, {"pk": 24, "model": "auth.permission", "fields": {"codename": "delete_tasksetmeta", "name": "Can delete saved group result", "content_type": 8}}, {"pk": 40, "model": "auth.permission", "fields": {"codename": "add_taskstate", "name": "Can add task", "content_type": 14}}, {"pk": 41, "model": "auth.permission", "fields": {"codename": "change_taskstate", "name": "Can change task", "content_type": 14}}, {"pk": 42, "model": "auth.permission", "fields": {"codename": "delete_taskstate", "name": "Can delete task", "content_type": 14}}, {"pk": 37, "model": "auth.permission", "fields": {"codename": "add_workerstate", "name": "Can add worker", "content_type": 13}}, {"pk": 38, "model": "auth.permission", "fields": {"codename": "change_workerstate", "name": "Can change worker", "content_type": 13}}, {"pk": 39, "model": "auth.permission", "fields": {"codename": "delete_workerstate", "name": "Can delete worker", "content_type": 13}}, {"pk": 406, "model": "auth.permission", "fields": {"codename": "add_coursevideo", "name": "Can add course video", "content_type": 135}}, {"pk": 407, "model": "auth.permission", "fields": {"codename": "change_coursevideo", "name": "Can change course video", "content_type": 135}}, {"pk": 408, "model": "auth.permission", "fields": {"codename": "delete_coursevideo", "name": "Can delete course video", "content_type": 135}}, {"pk": 409, "model": "auth.permission", "fields": {"codename": "add_encodedvideo", "name": "Can add encoded video", "content_type": 136}}, {"pk": 410, "model": "auth.permission", "fields": {"codename": "change_encodedvideo", "name": "Can change encoded video", "content_type": 136}}, {"pk": 411, "model": "auth.permission", "fields": {"codename": "delete_encodedvideo", "name": "Can delete encoded video", "content_type": 136}}, {"pk": 400, "model": "auth.permission", "fields": {"codename": "add_profile", "name": "Can add profile", "content_type": 133}}, {"pk": 401, "model": "auth.permission", "fields": {"codename": "change_profile", "name": "Can change profile", "content_type": 133}}, {"pk": 402, "model": "auth.permission", "fields": {"codename": "delete_profile", "name": "Can delete profile", "content_type": 133}}, {"pk": 412, "model": "auth.permission", "fields": {"codename": "add_subtitle", "name": "Can add subtitle", "content_type": 137}}, {"pk": 413, "model": "auth.permission", "fields": {"codename": "change_subtitle", "name": "Can change subtitle", "content_type": 137}}, {"pk": 414, "model": "auth.permission", "fields": {"codename": "delete_subtitle", "name": "Can delete subtitle", "content_type": 137}}, {"pk": 403, "model": "auth.permission", "fields": {"codename": "add_video", "name": "Can add video", "content_type": 134}}, {"pk": 404, "model": "auth.permission", "fields": {"codename": "change_video", "name": "Can change video", "content_type": 134}}, {"pk": 405, "model": "auth.permission", "fields": {"codename": "delete_video", "name": "Can delete video", "content_type": 134}}, {"pk": 313, "model": "auth.permission", "fields": {"codename": "add_embargoedcourse", "name": "Can add embargoed course", "content_type": 104}}, {"pk": 314, "model": "auth.permission", "fields": {"codename": "change_embargoedcourse", "name": "Can change embargoed course", "content_type": 104}}, {"pk": 315, "model": "auth.permission", "fields": {"codename": "delete_embargoedcourse", "name": "Can delete embargoed course", "content_type": 104}}, {"pk": 316, "model": "auth.permission", "fields": {"codename": "add_embargoedstate", "name": "Can add embargoed state", "content_type": 105}}, {"pk": 317, "model": "auth.permission", "fields": {"codename": "change_embargoedstate", "name": "Can change embargoed state", "content_type": 105}}, {"pk": 318, "model": "auth.permission", "fields": {"codename": "delete_embargoedstate", "name": "Can delete embargoed state", "content_type": 105}}, {"pk": 319, "model": "auth.permission", "fields": {"codename": "add_ipfilter", "name": "Can add ip filter", "content_type": 106}}, {"pk": 320, "model": "auth.permission", "fields": {"codename": "change_ipfilter", "name": "Can change ip filter", "content_type": 106}}, {"pk": 321, "model": "auth.permission", "fields": {"codename": "delete_ipfilter", "name": "Can delete ip filter", "content_type": 106}}, {"pk": 175, "model": "auth.permission", "fields": {"codename": "add_externalauthmap", "name": "Can add external auth map", "content_type": 59}}, {"pk": 176, "model": "auth.permission", "fields": {"codename": "change_externalauthmap", "name": "Can change external auth map", "content_type": 59}}, {"pk": 177, "model": "auth.permission", "fields": {"codename": "delete_externalauthmap", "name": "Can delete external auth map", "content_type": 59}}, {"pk": 241, "model": "auth.permission", "fields": {"codename": "add_puzzlecomplete", "name": "Can add puzzle complete", "content_type": 80}}, {"pk": 242, "model": "auth.permission", "fields": {"codename": "change_puzzlecomplete", "name": "Can change puzzle complete", "content_type": 80}}, {"pk": 243, "model": "auth.permission", "fields": {"codename": "delete_puzzlecomplete", "name": "Can delete puzzle complete", "content_type": 80}}, {"pk": 238, "model": "auth.permission", "fields": {"codename": "add_score", "name": "Can add score", "content_type": 79}}, {"pk": 239, "model": "auth.permission", "fields": {"codename": "change_score", "name": "Can change score", "content_type": 79}}, {"pk": 240, "model": "auth.permission", "fields": {"codename": "delete_score", "name": "Can delete score", "content_type": 79}}, {"pk": 148, "model": "auth.permission", "fields": {"codename": "add_instructortask", "name": "Can add instructor task", "content_type": 50}}, {"pk": 149, "model": "auth.permission", "fields": {"codename": "change_instructortask", "name": "Can change instructor task", "content_type": 50}}, {"pk": 150, "model": "auth.permission", "fields": {"codename": "delete_instructortask", "name": "Can delete instructor task", "content_type": 50}}, {"pk": 151, "model": "auth.permission", "fields": {"codename": "add_coursesoftware", "name": "Can add course software", "content_type": 51}}, {"pk": 152, "model": "auth.permission", "fields": {"codename": "change_coursesoftware", "name": "Can change course software", "content_type": 51}}, {"pk": 153, "model": "auth.permission", "fields": {"codename": "delete_coursesoftware", "name": "Can delete course software", "content_type": 51}}, {"pk": 154, "model": "auth.permission", "fields": {"codename": "add_userlicense", "name": "Can add user license", "content_type": 52}}, {"pk": 155, "model": "auth.permission", "fields": {"codename": "change_userlicense", "name": "Can change user license", "content_type": 52}}, {"pk": 156, "model": "auth.permission", "fields": {"codename": "delete_userlicense", "name": "Can delete user license", "content_type": 52}}, {"pk": 331, "model": "auth.permission", "fields": {"codename": "add_linkedin", "name": "Can add linked in", "content_type": 110}}, {"pk": 332, "model": "auth.permission", "fields": {"codename": "change_linkedin", "name": "Can change linked in", "content_type": 110}}, {"pk": 333, "model": "auth.permission", "fields": {"codename": "delete_linkedin", "name": "Can delete linked in", "content_type": 110}}, {"pk": 244, "model": "auth.permission", "fields": {"codename": "add_note", "name": "Can add note", "content_type": 81}}, {"pk": 245, "model": "auth.permission", "fields": {"codename": "change_note", "name": "Can change note", "content_type": 81}}, {"pk": 246, "model": "auth.permission", "fields": {"codename": "delete_note", "name": "Can delete note", "content_type": 81}}, {"pk": 184, "model": "auth.permission", "fields": {"codename": "add_accesstoken", "name": "Can add access token", "content_type": 62}}, {"pk": 185, "model": "auth.permission", "fields": {"codename": "change_accesstoken", "name": "Can change access token", "content_type": 62}}, {"pk": 186, "model": "auth.permission", "fields": {"codename": "delete_accesstoken", "name": "Can delete access token", "content_type": 62}}, {"pk": 178, "model": "auth.permission", "fields": {"codename": "add_client", "name": "Can add client", "content_type": 60}}, {"pk": 179, "model": "auth.permission", "fields": {"codename": "change_client", "name": "Can change client", "content_type": 60}}, {"pk": 180, "model": "auth.permission", "fields": {"codename": "delete_client", "name": "Can delete client", "content_type": 60}}, {"pk": 181, "model": "auth.permission", "fields": {"codename": "add_grant", "name": "Can add grant", "content_type": 61}}, {"pk": 182, "model": "auth.permission", "fields": {"codename": "change_grant", "name": "Can change grant", "content_type": 61}}, {"pk": 183, "model": "auth.permission", "fields": {"codename": "delete_grant", "name": "Can delete grant", "content_type": 61}}, {"pk": 187, "model": "auth.permission", "fields": {"codename": "add_refreshtoken", "name": "Can add refresh token", "content_type": 63}}, {"pk": 188, "model": "auth.permission", "fields": {"codename": "change_refreshtoken", "name": "Can change refresh token", "content_type": 63}}, {"pk": 189, "model": "auth.permission", "fields": {"codename": "delete_refreshtoken", "name": "Can delete refresh token", "content_type": 63}}, {"pk": 190, "model": "auth.permission", "fields": {"codename": "add_trustedclient", "name": "Can add trusted client", "content_type": 64}}, {"pk": 191, "model": "auth.permission", "fields": {"codename": "change_trustedclient", "name": "Can change trusted client", "content_type": 64}}, {"pk": 192, "model": "auth.permission", "fields": {"codename": "delete_trustedclient", "name": "Can delete trusted client", "content_type": 64}}, {"pk": 49, "model": "auth.permission", "fields": {"codename": "add_psychometricdata", "name": "Can add psychometric data", "content_type": 17}}, {"pk": 50, "model": "auth.permission", "fields": {"codename": "change_psychometricdata", "name": "Can change psychometric data", "content_type": 17}}, {"pk": 51, "model": "auth.permission", "fields": {"codename": "delete_psychometricdata", "name": "Can delete psychometric data", "content_type": 17}}, {"pk": 310, "model": "auth.permission", "fields": {"codename": "add_midcoursereverificationwindow", "name": "Can add midcourse reverification window", "content_type": 103}}, {"pk": 311, "model": "auth.permission", "fields": {"codename": "change_midcoursereverificationwindow", "name": "Can change midcourse reverification window", "content_type": 103}}, {"pk": 312, "model": "auth.permission", "fields": {"codename": "delete_midcoursereverificationwindow", "name": "Can delete midcourse reverification window", "content_type": 103}}, {"pk": 13, "model": "auth.permission", "fields": {"codename": "add_session", "name": "Can add session", "content_type": 5}}, {"pk": 14, "model": "auth.permission", "fields": {"codename": "change_session", "name": "Can change session", "content_type": 5}}, {"pk": 15, "model": "auth.permission", "fields": {"codename": "delete_session", "name": "Can delete session", "content_type": 5}}, {"pk": 289, "model": "auth.permission", "fields": {"codename": "add_certificateitem", "name": "Can add certificate item", "content_type": 96}}, {"pk": 290, "model": "auth.permission", "fields": {"codename": "change_certificateitem", "name": "Can change certificate item", "content_type": 96}}, {"pk": 291, "model": "auth.permission", "fields": {"codename": "delete_certificateitem", "name": "Can delete certificate item", "content_type": 96}}, {"pk": 271, "model": "auth.permission", "fields": {"codename": "add_coupon", "name": "Can add coupon", "content_type": 90}}, {"pk": 272, "model": "auth.permission", "fields": {"codename": "change_coupon", "name": "Can change coupon", "content_type": 90}}, {"pk": 273, "model": "auth.permission", "fields": {"codename": "delete_coupon", "name": "Can delete coupon", "content_type": 90}}, {"pk": 274, "model": "auth.permission", "fields": {"codename": "add_couponredemption", "name": "Can add coupon redemption", "content_type": 91}}, {"pk": 275, "model": "auth.permission", "fields": {"codename": "change_couponredemption", "name": "Can change coupon redemption", "content_type": 91}}, {"pk": 276, "model": "auth.permission", "fields": {"codename": "delete_couponredemption", "name": "Can delete coupon redemption", "content_type": 91}}, {"pk": 280, "model": "auth.permission", "fields": {"codename": "add_courseregcodeitem", "name": "Can add course reg code item", "content_type": 93}}, {"pk": 281, "model": "auth.permission", "fields": {"codename": "change_courseregcodeitem", "name": "Can change course reg code item", "content_type": 93}}, {"pk": 282, "model": "auth.permission", "fields": {"codename": "delete_courseregcodeitem", "name": "Can delete course reg code item", "content_type": 93}}, {"pk": 283, "model": "auth.permission", "fields": {"codename": "add_courseregcodeitemannotation", "name": "Can add course reg code item annotation", "content_type": 94}}, {"pk": 284, "model": "auth.permission", "fields": {"codename": "change_courseregcodeitemannotation", "name": "Can change course reg code item annotation", "content_type": 94}}, {"pk": 285, "model": "auth.permission", "fields": {"codename": "delete_courseregcodeitemannotation", "name": "Can delete course reg code item annotation", "content_type": 94}}, {"pk": 265, "model": "auth.permission", "fields": {"codename": "add_courseregistrationcode", "name": "Can add course registration code", "content_type": 88}}, {"pk": 266, "model": "auth.permission", "fields": {"codename": "change_courseregistrationcode", "name": "Can change course registration code", "content_type": 88}}, {"pk": 267, "model": "auth.permission", "fields": {"codename": "delete_courseregistrationcode", "name": "Can delete course registration code", "content_type": 88}}, {"pk": 295, "model": "auth.permission", "fields": {"codename": "add_donation", "name": "Can add donation", "content_type": 98}}, {"pk": 296, "model": "auth.permission", "fields": {"codename": "change_donation", "name": "Can change donation", "content_type": 98}}, {"pk": 297, "model": "auth.permission", "fields": {"codename": "delete_donation", "name": "Can delete donation", "content_type": 98}}, {"pk": 292, "model": "auth.permission", "fields": {"codename": "add_donationconfiguration", "name": "Can add donation configuration", "content_type": 97}}, {"pk": 293, "model": "auth.permission", "fields": {"codename": "change_donationconfiguration", "name": "Can change donation configuration", "content_type": 97}}, {"pk": 294, "model": "auth.permission", "fields": {"codename": "delete_donationconfiguration", "name": "Can delete donation configuration", "content_type": 97}}, {"pk": 262, "model": "auth.permission", "fields": {"codename": "add_invoice", "name": "Can add invoice", "content_type": 87}}, {"pk": 263, "model": "auth.permission", "fields": {"codename": "change_invoice", "name": "Can change invoice", "content_type": 87}}, {"pk": 264, "model": "auth.permission", "fields": {"codename": "delete_invoice", "name": "Can delete invoice", "content_type": 87}}, {"pk": 256, "model": "auth.permission", "fields": {"codename": "add_order", "name": "Can add order", "content_type": 85}}, {"pk": 257, "model": "auth.permission", "fields": {"codename": "change_order", "name": "Can change order", "content_type": 85}}, {"pk": 258, "model": "auth.permission", "fields": {"codename": "delete_order", "name": "Can delete order", "content_type": 85}}, {"pk": 259, "model": "auth.permission", "fields": {"codename": "add_orderitem", "name": "Can add order item", "content_type": 86}}, {"pk": 260, "model": "auth.permission", "fields": {"codename": "change_orderitem", "name": "Can change order item", "content_type": 86}}, {"pk": 261, "model": "auth.permission", "fields": {"codename": "delete_orderitem", "name": "Can delete order item", "content_type": 86}}, {"pk": 277, "model": "auth.permission", "fields": {"codename": "add_paidcourseregistration", "name": "Can add paid course registration", "content_type": 92}}, {"pk": 278, "model": "auth.permission", "fields": {"codename": "change_paidcourseregistration", "name": "Can change paid course registration", "content_type": 92}}, {"pk": 279, "model": "auth.permission", "fields": {"codename": "delete_paidcourseregistration", "name": "Can delete paid course registration", "content_type": 92}}, {"pk": 286, "model": "auth.permission", "fields": {"codename": "add_paidcourseregistrationannotation", "name": "Can add paid course registration annotation", "content_type": 95}}, {"pk": 287, "model": "auth.permission", "fields": {"codename": "change_paidcourseregistrationannotation", "name": "Can change paid course registration annotation", "content_type": 95}}, {"pk": 288, "model": "auth.permission", "fields": {"codename": "delete_paidcourseregistrationannotation", "name": "Can delete paid course registration annotation", "content_type": 95}}, {"pk": 268, "model": "auth.permission", "fields": {"codename": "add_registrationcoderedemption", "name": "Can add registration code redemption", "content_type": 89}}, {"pk": 269, "model": "auth.permission", "fields": {"codename": "change_registrationcoderedemption", "name": "Can change registration code redemption", "content_type": 89}}, {"pk": 270, "model": "auth.permission", "fields": {"codename": "delete_registrationcoderedemption", "name": "Can delete registration code redemption", "content_type": 89}}, {"pk": 16, "model": "auth.permission", "fields": {"codename": "add_site", "name": "Can add site", "content_type": 6}}, {"pk": 17, "model": "auth.permission", "fields": {"codename": "change_site", "name": "Can change site", "content_type": 6}}, {"pk": 18, "model": "auth.permission", "fields": {"codename": "delete_site", "name": "Can delete site", "content_type": 6}}, {"pk": 43, "model": "auth.permission", "fields": {"codename": "add_migrationhistory", "name": "Can add migration history", "content_type": 15}}, {"pk": 44, "model": "auth.permission", "fields": {"codename": "change_migrationhistory", "name": "Can change migration history", "content_type": 15}}, {"pk": 45, "model": "auth.permission", "fields": {"codename": "delete_migrationhistory", "name": "Can delete migration history", "content_type": 15}}, {"pk": 247, "model": "auth.permission", "fields": {"codename": "add_splashconfig", "name": "Can add splash config", "content_type": 82}}, {"pk": 248, "model": "auth.permission", "fields": {"codename": "change_splashconfig", "name": "Can change splash config", "content_type": 82}}, {"pk": 249, "model": "auth.permission", "fields": {"codename": "delete_splashconfig", "name": "Can delete splash config", "content_type": 82}}, {"pk": 97, "model": "auth.permission", "fields": {"codename": "add_anonymoususerid", "name": "Can add anonymous user id", "content_type": 33}}, {"pk": 98, "model": "auth.permission", "fields": {"codename": "change_anonymoususerid", "name": "Can change anonymous user id", "content_type": 33}}, {"pk": 99, "model": "auth.permission", "fields": {"codename": "delete_anonymoususerid", "name": "Can delete anonymous user id", "content_type": 33}}, {"pk": 133, "model": "auth.permission", "fields": {"codename": "add_courseaccessrole", "name": "Can add course access role", "content_type": 45}}, {"pk": 134, "model": "auth.permission", "fields": {"codename": "change_courseaccessrole", "name": "Can change course access role", "content_type": 45}}, {"pk": 135, "model": "auth.permission", "fields": {"codename": "delete_courseaccessrole", "name": "Can delete course access role", "content_type": 45}}, {"pk": 127, "model": "auth.permission", "fields": {"codename": "add_courseenrollment", "name": "Can add course enrollment", "content_type": 43}}, {"pk": 128, "model": "auth.permission", "fields": {"codename": "change_courseenrollment", "name": "Can change course enrollment", "content_type": 43}}, {"pk": 129, "model": "auth.permission", "fields": {"codename": "delete_courseenrollment", "name": "Can delete course enrollment", "content_type": 43}}, {"pk": 130, "model": "auth.permission", "fields": {"codename": "add_courseenrollmentallowed", "name": "Can add course enrollment allowed", "content_type": 44}}, {"pk": 131, "model": "auth.permission", "fields": {"codename": "change_courseenrollmentallowed", "name": "Can change course enrollment allowed", "content_type": 44}}, {"pk": 132, "model": "auth.permission", "fields": {"codename": "delete_courseenrollmentallowed", "name": "Can delete course enrollment allowed", "content_type": 44}}, {"pk": 136, "model": "auth.permission", "fields": {"codename": "add_dashboardconfiguration", "name": "Can add dashboard configuration", "content_type": 46}}, {"pk": 137, "model": "auth.permission", "fields": {"codename": "change_dashboardconfiguration", "name": "Can change dashboard configuration", "content_type": 46}}, {"pk": 138, "model": "auth.permission", "fields": {"codename": "delete_dashboardconfiguration", "name": "Can delete dashboard configuration", "content_type": 46}}, {"pk": 124, "model": "auth.permission", "fields": {"codename": "add_loginfailures", "name": "Can add login failures", "content_type": 42}}, {"pk": 125, "model": "auth.permission", "fields": {"codename": "change_loginfailures", "name": "Can change login failures", "content_type": 42}}, {"pk": 126, "model": "auth.permission", "fields": {"codename": "delete_loginfailures", "name": "Can delete login failures", "content_type": 42}}, {"pk": 121, "model": "auth.permission", "fields": {"codename": "add_passwordhistory", "name": "Can add password history", "content_type": 41}}, {"pk": 122, "model": "auth.permission", "fields": {"codename": "change_passwordhistory", "name": "Can change password history", "content_type": 41}}, {"pk": 123, "model": "auth.permission", "fields": {"codename": "delete_passwordhistory", "name": "Can delete password history", "content_type": 41}}, {"pk": 118, "model": "auth.permission", "fields": {"codename": "add_pendingemailchange", "name": "Can add pending email change", "content_type": 40}}, {"pk": 119, "model": "auth.permission", "fields": {"codename": "change_pendingemailchange", "name": "Can change pending email change", "content_type": 40}}, {"pk": 120, "model": "auth.permission", "fields": {"codename": "delete_pendingemailchange", "name": "Can delete pending email change", "content_type": 40}}, {"pk": 115, "model": "auth.permission", "fields": {"codename": "add_pendingnamechange", "name": "Can add pending name change", "content_type": 39}}, {"pk": 116, "model": "auth.permission", "fields": {"codename": "change_pendingnamechange", "name": "Can change pending name change", "content_type": 39}}, {"pk": 117, "model": "auth.permission", "fields": {"codename": "delete_pendingnamechange", "name": "Can delete pending name change", "content_type": 39}}, {"pk": 112, "model": "auth.permission", "fields": {"codename": "add_registration", "name": "Can add registration", "content_type": 38}}, {"pk": 113, "model": "auth.permission", "fields": {"codename": "change_registration", "name": "Can change registration", "content_type": 38}}, {"pk": 114, "model": "auth.permission", "fields": {"codename": "delete_registration", "name": "Can delete registration", "content_type": 38}}, {"pk": 103, "model": "auth.permission", "fields": {"codename": "add_userprofile", "name": "Can add user profile", "content_type": 35}}, {"pk": 104, "model": "auth.permission", "fields": {"codename": "change_userprofile", "name": "Can change user profile", "content_type": 35}}, {"pk": 105, "model": "auth.permission", "fields": {"codename": "delete_userprofile", "name": "Can delete user profile", "content_type": 35}}, {"pk": 106, "model": "auth.permission", "fields": {"codename": "add_usersignupsource", "name": "Can add user signup source", "content_type": 36}}, {"pk": 107, "model": "auth.permission", "fields": {"codename": "change_usersignupsource", "name": "Can change user signup source", "content_type": 36}}, {"pk": 108, "model": "auth.permission", "fields": {"codename": "delete_usersignupsource", "name": "Can delete user signup source", "content_type": 36}}, {"pk": 100, "model": "auth.permission", "fields": {"codename": "add_userstanding", "name": "Can add user standing", "content_type": 34}}, {"pk": 101, "model": "auth.permission", "fields": {"codename": "change_userstanding", "name": "Can change user standing", "content_type": 34}}, {"pk": 102, "model": "auth.permission", "fields": {"codename": "delete_userstanding", "name": "Can delete user standing", "content_type": 34}}, {"pk": 109, "model": "auth.permission", "fields": {"codename": "add_usertestgroup", "name": "Can add user test group", "content_type": 37}}, {"pk": 110, "model": "auth.permission", "fields": {"codename": "change_usertestgroup", "name": "Can change user test group", "content_type": 37}}, {"pk": 111, "model": "auth.permission", "fields": {"codename": "delete_usertestgroup", "name": "Can delete user test group", "content_type": 37}}, {"pk": 340, "model": "auth.permission", "fields": {"codename": "add_score", "name": "Can add score", "content_type": 113}}, {"pk": 341, "model": "auth.permission", "fields": {"codename": "change_score", "name": "Can change score", "content_type": 113}}, {"pk": 342, "model": "auth.permission", "fields": {"codename": "delete_score", "name": "Can delete score", "content_type": 113}}, {"pk": 343, "model": "auth.permission", "fields": {"codename": "add_scoresummary", "name": "Can add score summary", "content_type": 114}}, {"pk": 344, "model": "auth.permission", "fields": {"codename": "change_scoresummary", "name": "Can change score summary", "content_type": 114}}, {"pk": 345, "model": "auth.permission", "fields": {"codename": "delete_scoresummary", "name": "Can delete score summary", "content_type": 114}}, {"pk": 334, "model": "auth.permission", "fields": {"codename": "add_studentitem", "name": "Can add student item", "content_type": 111}}, {"pk": 335, "model": "auth.permission", "fields": {"codename": "change_studentitem", "name": "Can change student item", "content_type": 111}}, {"pk": 336, "model": "auth.permission", "fields": {"codename": "delete_studentitem", "name": "Can delete student item", "content_type": 111}}, {"pk": 337, "model": "auth.permission", "fields": {"codename": "add_submission", "name": "Can add submission", "content_type": 112}}, {"pk": 338, "model": "auth.permission", "fields": {"codename": "change_submission", "name": "Can change submission", "content_type": 112}}, {"pk": 339, "model": "auth.permission", "fields": {"codename": "delete_submission", "name": "Can delete submission", "content_type": 112}}, {"pk": 328, "model": "auth.permission", "fields": {"codename": "add_surveyanswer", "name": "Can add survey answer", "content_type": 109}}, {"pk": 329, "model": "auth.permission", "fields": {"codename": "change_surveyanswer", "name": "Can change survey answer", "content_type": 109}}, {"pk": 330, "model": "auth.permission", "fields": {"codename": "delete_surveyanswer", "name": "Can delete survey answer", "content_type": 109}}, {"pk": 325, "model": "auth.permission", "fields": {"codename": "add_surveyform", "name": "Can add survey form", "content_type": 108}}, {"pk": 326, "model": "auth.permission", "fields": {"codename": "change_surveyform", "name": "Can change survey form", "content_type": 108}}, {"pk": 327, "model": "auth.permission", "fields": {"codename": "delete_surveyform", "name": "Can delete survey form", "content_type": 108}}, {"pk": 139, "model": "auth.permission", "fields": {"codename": "add_trackinglog", "name": "Can add tracking log", "content_type": 47}}, {"pk": 140, "model": "auth.permission", "fields": {"codename": "change_trackinglog", "name": "Can change tracking log", "content_type": 47}}, {"pk": 141, "model": "auth.permission", "fields": {"codename": "delete_trackinglog", "name": "Can delete tracking log", "content_type": 47}}, {"pk": 253, "model": "auth.permission", "fields": {"codename": "add_usercoursetag", "name": "Can add user course tag", "content_type": 84}}, {"pk": 254, "model": "auth.permission", "fields": {"codename": "change_usercoursetag", "name": "Can change user course tag", "content_type": 84}}, {"pk": 255, "model": "auth.permission", "fields": {"codename": "delete_usercoursetag", "name": "Can delete user course tag", "content_type": 84}}, {"pk": 250, "model": "auth.permission", "fields": {"codename": "add_userpreference", "name": "Can add user preference", "content_type": 83}}, {"pk": 251, "model": "auth.permission", "fields": {"codename": "change_userpreference", "name": "Can change user preference", "content_type": 83}}, {"pk": 252, "model": "auth.permission", "fields": {"codename": "delete_userpreference", "name": "Can delete user preference", "content_type": 83}}, {"pk": 304, "model": "auth.permission", "fields": {"codename": "add_softwaresecurephotoverification", "name": "Can add software secure photo verification", "content_type": 101}}, {"pk": 305, "model": "auth.permission", "fields": {"codename": "change_softwaresecurephotoverification", "name": "Can change software secure photo verification", "content_type": 101}}, {"pk": 306, "model": "auth.permission", "fields": {"codename": "delete_softwaresecurephotoverification", "name": "Can delete software secure photo verification", "content_type": 101}}, {"pk": 193, "model": "auth.permission", "fields": {"codename": "add_article", "name": "Can add article", "content_type": 65}}, {"pk": 197, "model": "auth.permission", "fields": {"codename": "assign", "name": "Can change ownership of any article", "content_type": 65}}, {"pk": 194, "model": "auth.permission", "fields": {"codename": "change_article", "name": "Can change article", "content_type": 65}}, {"pk": 195, "model": "auth.permission", "fields": {"codename": "delete_article", "name": "Can delete article", "content_type": 65}}, {"pk": 198, "model": "auth.permission", "fields": {"codename": "grant", "name": "Can assign permissions to other users", "content_type": 65}}, {"pk": 196, "model": "auth.permission", "fields": {"codename": "moderate", "name": "Can edit all articles and lock/unlock/restore", "content_type": 65}}, {"pk": 199, "model": "auth.permission", "fields": {"codename": "add_articleforobject", "name": "Can add Article for object", "content_type": 66}}, {"pk": 200, "model": "auth.permission", "fields": {"codename": "change_articleforobject", "name": "Can change Article for object", "content_type": 66}}, {"pk": 201, "model": "auth.permission", "fields": {"codename": "delete_articleforobject", "name": "Can delete Article for object", "content_type": 66}}, {"pk": 208, "model": "auth.permission", "fields": {"codename": "add_articleplugin", "name": "Can add article plugin", "content_type": 69}}, {"pk": 209, "model": "auth.permission", "fields": {"codename": "change_articleplugin", "name": "Can change article plugin", "content_type": 69}}, {"pk": 210, "model": "auth.permission", "fields": {"codename": "delete_articleplugin", "name": "Can delete article plugin", "content_type": 69}}, {"pk": 202, "model": "auth.permission", "fields": {"codename": "add_articlerevision", "name": "Can add article revision", "content_type": 67}}, {"pk": 203, "model": "auth.permission", "fields": {"codename": "change_articlerevision", "name": "Can change article revision", "content_type": 67}}, {"pk": 204, "model": "auth.permission", "fields": {"codename": "delete_articlerevision", "name": "Can delete article revision", "content_type": 67}}, {"pk": 223, "model": "auth.permission", "fields": {"codename": "add_articlesubscription", "name": "Can add article subscription", "content_type": 74}}, {"pk": 224, "model": "auth.permission", "fields": {"codename": "change_articlesubscription", "name": "Can change article subscription", "content_type": 74}}, {"pk": 225, "model": "auth.permission", "fields": {"codename": "delete_articlesubscription", "name": "Can delete article subscription", "content_type": 74}}, {"pk": 211, "model": "auth.permission", "fields": {"codename": "add_reusableplugin", "name": "Can add reusable plugin", "content_type": 70}}, {"pk": 212, "model": "auth.permission", "fields": {"codename": "change_reusableplugin", "name": "Can change reusable plugin", "content_type": 70}}, {"pk": 213, "model": "auth.permission", "fields": {"codename": "delete_reusableplugin", "name": "Can delete reusable plugin", "content_type": 70}}, {"pk": 217, "model": "auth.permission", "fields": {"codename": "add_revisionplugin", "name": "Can add revision plugin", "content_type": 72}}, {"pk": 218, "model": "auth.permission", "fields": {"codename": "change_revisionplugin", "name": "Can change revision plugin", "content_type": 72}}, {"pk": 219, "model": "auth.permission", "fields": {"codename": "delete_revisionplugin", "name": "Can delete revision plugin", "content_type": 72}}, {"pk": 220, "model": "auth.permission", "fields": {"codename": "add_revisionpluginrevision", "name": "Can add revision plugin revision", "content_type": 73}}, {"pk": 221, "model": "auth.permission", "fields": {"codename": "change_revisionpluginrevision", "name": "Can change revision plugin revision", "content_type": 73}}, {"pk": 222, "model": "auth.permission", "fields": {"codename": "delete_revisionpluginrevision", "name": "Can delete revision plugin revision", "content_type": 73}}, {"pk": 214, "model": "auth.permission", "fields": {"codename": "add_simpleplugin", "name": "Can add simple plugin", "content_type": 71}}, {"pk": 215, "model": "auth.permission", "fields": {"codename": "change_simpleplugin", "name": "Can change simple plugin", "content_type": 71}}, {"pk": 216, "model": "auth.permission", "fields": {"codename": "delete_simpleplugin", "name": "Can delete simple plugin", "content_type": 71}}, {"pk": 205, "model": "auth.permission", "fields": {"codename": "add_urlpath", "name": "Can add URL path", "content_type": 68}}, {"pk": 206, "model": "auth.permission", "fields": {"codename": "change_urlpath", "name": "Can change URL path", "content_type": 68}}, {"pk": 207, "model": "auth.permission", "fields": {"codename": "delete_urlpath", "name": "Can delete URL path", "content_type": 68}}, {"pk": 394, "model": "auth.permission", "fields": {"codename": "add_assessmentworkflow", "name": "Can add assessment workflow", "content_type": 131}}, {"pk": 395, "model": "auth.permission", "fields": {"codename": "change_assessmentworkflow", "name": "Can change assessment workflow", "content_type": 131}}, {"pk": 396, "model": "auth.permission", "fields": {"codename": "delete_assessmentworkflow", "name": "Can delete assessment workflow", "content_type": 131}}, {"pk": 397, "model": "auth.permission", "fields": {"codename": "add_assessmentworkflowstep", "name": "Can add assessment workflow step", "content_type": 132}}, {"pk": 398, "model": "auth.permission", "fields": {"codename": "change_assessmentworkflowstep", "name": "Can change assessment workflow step", "content_type": 132}}, {"pk": 399, "model": "auth.permission", "fields": {"codename": "delete_assessmentworkflowstep", "name": "Can delete assessment workflow step", "content_type": 132}}, {"pk": 1, "model": "dark_lang.darklangconfig", "fields": {"change_date": "2014-11-13T22:49:43Z", "changed_by": null, "enabled": true, "released_languages": ""}}] \ No newline at end of file diff --git a/common/test/db_cache/bok_choy_schema.sql b/common/test/db_cache/bok_choy_schema.sql index a155d8e1c0..94ceb08539 100644 --- a/common/test/db_cache/bok_choy_schema.sql +++ b/common/test/db_cache/bok_choy_schema.sql @@ -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 */; diff --git a/common/test/db_cache/lettuce.db b/common/test/db_cache/lettuce.db index adfdcd262b0563937f4f478641ad3b9abe0f0f0b..fc6081441e176bef35a035d80ef30c3fcd2cc0b6 100644 GIT binary patch delta 52655 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

L*Obsrv6_zoA(RqXVg!q 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[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[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[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 diff --git a/common/djangoapps/course_groups/__init__.py b/openedx/core/djangoapps/course_groups/__init__.py similarity index 100% rename from common/djangoapps/course_groups/__init__.py rename to openedx/core/djangoapps/course_groups/__init__.py diff --git a/common/djangoapps/course_groups/cohorts.py b/openedx/core/djangoapps/course_groups/cohorts.py similarity index 95% rename from common/djangoapps/course_groups/cohorts.py rename to openedx/core/djangoapps/course_groups/cohorts.py index d10c6309dc..958c81ee23 100644 --- a/common/djangoapps/course_groups/cohorts.py +++ b/openedx/core/djangoapps/course_groups/cohorts.py @@ -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 diff --git a/openedx/core/djangoapps/course_groups/migrations/0001_initial.py b/openedx/core/djangoapps/course_groups/migrations/0001_initial.py new file mode 100644 index 0000000000..1663cc902d --- /dev/null +++ b/openedx/core/djangoapps/course_groups/migrations/0001_initial.py @@ -0,0 +1,88 @@ +# -*- 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 'CourseUserGroup' + 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'] diff --git a/openedx/core/djangoapps/course_groups/migrations/0002_add_model_CourseUserGroupPartitionGroup.py b/openedx/core/djangoapps/course_groups/migrations/0002_add_model_CourseUserGroupPartitionGroup.py new file mode 100644 index 0000000000..735dbde78b --- /dev/null +++ b/openedx/core/djangoapps/course_groups/migrations/0002_add_model_CourseUserGroupPartitionGroup.py @@ -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'] diff --git a/common/djangoapps/course_groups/tests/__init__.py b/openedx/core/djangoapps/course_groups/migrations/__init__.py similarity index 100% rename from common/djangoapps/course_groups/tests/__init__.py rename to openedx/core/djangoapps/course_groups/migrations/__init__.py diff --git a/common/djangoapps/course_groups/models.py b/openedx/core/djangoapps/course_groups/models.py similarity index 75% rename from common/djangoapps/course_groups/models.py rename to openedx/core/djangoapps/course_groups/models.py index aae9f40604..da17f4c20c 100644 --- a/common/djangoapps/course_groups/models.py +++ b/openedx/core/djangoapps/course_groups/models.py @@ -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) diff --git a/openedx/core/djangoapps/course_groups/partition_scheme.py b/openedx/core/djangoapps/course_groups/partition_scheme.py new file mode 100644 index 0000000000..cc36c14373 --- /dev/null +++ b/openedx/core/djangoapps/course_groups/partition_scheme.py @@ -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 diff --git a/openedx/core/djangoapps/course_groups/tests/__init__.py b/openedx/core/djangoapps/course_groups/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/djangoapps/course_groups/tests/helpers.py b/openedx/core/djangoapps/course_groups/tests/helpers.py similarity index 98% rename from common/djangoapps/course_groups/tests/helpers.py rename to openedx/core/djangoapps/course_groups/tests/helpers.py index cd7b6c9eee..e23838a5bb 100644 --- a/common/djangoapps/course_groups/tests/helpers.py +++ b/openedx/core/djangoapps/course_groups/tests/helpers.py @@ -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): """ diff --git a/common/djangoapps/course_groups/tests/test_cohorts.py b/openedx/core/djangoapps/course_groups/tests/test_cohorts.py similarity index 79% rename from common/djangoapps/course_groups/tests/test_cohorts.py rename to openedx/core/djangoapps/course_groups/tests/test_cohorts.py index b4a16af27c..68fc445ecb 100644 --- a/common/djangoapps/course_groups/tests/test_cohorts.py +++ b/openedx/core/djangoapps/course_groups/tests/test_cohorts.py @@ -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 + ) diff --git a/openedx/core/djangoapps/course_groups/tests/test_partition_scheme.py b/openedx/core/djangoapps/course_groups/tests/test_partition_scheme.py new file mode 100644 index 0000000000..6e0f2e1a4b --- /dev/null +++ b/openedx/core/djangoapps/course_groups/tests/test_partition_scheme.py @@ -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') diff --git a/common/djangoapps/course_groups/tests/test_views.py b/openedx/core/djangoapps/course_groups/tests/test_views.py similarity index 98% rename from common/djangoapps/course_groups/tests/test_views.py rename to openedx/core/djangoapps/course_groups/tests/test_views.py index 580b469c88..9a50dda203 100644 --- a/common/djangoapps/course_groups/tests/test_views.py +++ b/openedx/core/djangoapps/course_groups/tests/test_views.py @@ -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) diff --git a/common/djangoapps/course_groups/views.py b/openedx/core/djangoapps/course_groups/views.py similarity index 100% rename from common/djangoapps/course_groups/views.py rename to openedx/core/djangoapps/course_groups/views.py diff --git a/openedx/core/djangoapps/user_api/api/course_tag.py b/openedx/core/djangoapps/user_api/api/course_tag.py index 89e7f49b85..6aa9d7d948 100644 --- a/openedx/core/djangoapps/user_api/api/course_tag.py +++ b/openedx/core/djangoapps/user_api/api/course_tag.py @@ -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 diff --git a/openedx/core/djangoapps/user_api/tests/test_partition_schemes.py b/openedx/core/djangoapps/user_api/tests/test_partition_schemes.py index f1b9ec9dc0..7d7ce3135d 100644 --- a/openedx/core/djangoapps/user_api/tests/test_partition_schemes.py +++ b/openedx/core/djangoapps/user_api/tests/test_partition_schemes.py @@ -3,6 +3,7 @@ Test the user api's partition extensions. """ from collections import defaultdict from mock import patch +from unittest import TestCase from openedx.core.djangoapps.user_api.partition_schemes import RandomUserPartitionScheme, UserPartitionError from student.tests.factories import UserFactory @@ -105,3 +106,15 @@ class TestRandomUserPartitionScheme(PartitionTestCase): # Now, get a new group using the same call new_group = RandomUserPartitionScheme.get_group_for_user(self.MOCK_COURSE_ID, self.user, user_partition) self.assertEqual(old_group.id, new_group.id) + + +class TestExtension(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('random'), RandomUserPartitionScheme) + with self.assertRaisesRegexp(UserPartitionError, 'Unrecognized scheme'): + UserPartition.get_scheme('other') diff --git a/openedx/core/djangoapps/user_api/tests/test_views.py b/openedx/core/djangoapps/user_api/tests/test_views.py index 6a3f8259a4..c1614b235d 100644 --- a/openedx/core/djangoapps/user_api/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/tests/test_views.py @@ -888,7 +888,7 @@ class RegistrationViewTest(ApiTestCase): no_extra_fields_setting = {} with simulate_running_pipeline( - "user_api.views.third_party_auth.pipeline", + "openedx.core.djangoapps.user_api.views.third_party_auth.pipeline", "google-oauth2", email="bob@example.com", fullname="Bob", username="Bob123" ): diff --git a/openedx/core/djangoapps/user_api/views.py b/openedx/core/djangoapps/user_api/views.py index 3ee7415290..24791613c4 100644 --- a/openedx/core/djangoapps/user_api/views.py +++ b/openedx/core/djangoapps/user_api/views.py @@ -27,7 +27,7 @@ from edxmako.shortcuts import marketing_link from util.authentication import SessionAuthenticationAllowInactiveUser from .api import account as account_api, profile as profile_api from .helpers import FormDescription, shim_student_view, require_post_params -from .models import UserPreference +from .models import UserPreference, UserProfile from .serializers import UserSerializer, UserPreferenceSerializer diff --git a/setup.py b/setup.py index 90ee4ef273..a3870e5750 100644 --- a/setup.py +++ b/setup.py @@ -13,12 +13,14 @@ setup( # be reorganized to be a more conventional Python tree. packages=[ "openedx.core.djangoapps.user_api", + "openedx.core.djangoapps.course_groups", "lms", "cms", ], entry_points={ 'openedx.user_partition_scheme': [ 'random = openedx.core.djangoapps.user_api.partition_schemes:RandomUserPartitionScheme', + 'cohort = openedx.core.djangoapps.course_groups.partition_scheme:CohortPartitionScheme', ], } ) From eced849db0926b3276077af4ef79461cca8a0adf Mon Sep 17 00:00:00 2001 From: Andy Armstrong Date: Wed, 12 Nov 2014 23:24:04 -0500 Subject: [PATCH 3/5] Add group_access field to all xblocks TNL-670 --- CHANGELOG.rst | 2 + .../models/settings/course_metadata.py | 2 + .../student/tests/test_enrollment.py | 2 +- common/djangoapps/student/views.py | 2 +- .../user_api/management/__init__.py | 0 .../user_api/management/commands/__init__.py | 0 .../management/commands/email_opt_in_list.py | 267 ------------ .../user_api/management/tests/__init__.py | 0 .../tests/test_email_opt_in_list.py | 399 ------------------ .../xmodule/modulestore/inheritance.py | 9 + .../lib/xmodule/xmodule/split_test_module.py | 4 - .../tests/studio/test_studio_split_test.py | 1 - .../django_comment_client/forum/tests.py | 6 +- .../django_comment_client/tests/utils.py | 2 +- .../instructor_analytics/tests/test_basic.py | 3 - lms/lib/xblock/mixin.py | 74 +++- lms/lib/xblock/test/test_mixin.py | 123 ++++++ ...e_userorgtag_user_org_key__chg_field_us.py | 0 18 files changed, 213 insertions(+), 683 deletions(-) delete mode 100644 common/djangoapps/user_api/management/__init__.py delete mode 100644 common/djangoapps/user_api/management/commands/__init__.py delete mode 100644 common/djangoapps/user_api/management/commands/email_opt_in_list.py delete mode 100644 common/djangoapps/user_api/management/tests/__init__.py delete mode 100644 common/djangoapps/user_api/management/tests/test_email_opt_in_list.py create mode 100644 lms/lib/xblock/test/test_mixin.py rename {common => openedx/core}/djangoapps/user_api/migrations/0004_auto__add_userorgtag__add_unique_userorgtag_user_org_key__chg_field_us.py (100%) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index b548bb8dc6..a0f53af981 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,8 @@ These are notable changes in edx-platform. This is a rolling list of changes, in roughly chronological order, most recent first. Add your entries at or near the top. Include a label indicating the component affected. +Platform: Add 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 diff --git a/cms/djangoapps/models/settings/course_metadata.py b/cms/djangoapps/models/settings/course_metadata.py index 236451eb4f..052ecfeb57 100644 --- a/cms/djangoapps/models/settings/course_metadata.py +++ b/cms/djangoapps/models/settings/course_metadata.py @@ -28,9 +28,11 @@ class CourseMetadata(object): 'graded', 'hide_from_toc', 'pdf_textbooks', + 'user_partitions', 'name', # from xblock 'tags', # from xblock 'visible_to_staff_only', + 'group_access', ] @classmethod diff --git a/common/djangoapps/student/tests/test_enrollment.py b/common/djangoapps/student/tests/test_enrollment.py index 9e92c046c4..b99126c494 100644 --- a/common/djangoapps/student/tests/test_enrollment.py +++ b/common/djangoapps/student/tests/test_enrollment.py @@ -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'), diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index d61b0e719e..8c7edb772c 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -109,7 +109,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 diff --git a/common/djangoapps/user_api/management/__init__.py b/common/djangoapps/user_api/management/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/common/djangoapps/user_api/management/commands/__init__.py b/common/djangoapps/user_api/management/commands/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/common/djangoapps/user_api/management/commands/email_opt_in_list.py b/common/djangoapps/user_api/management/commands/email_opt_in_list.py deleted file mode 100644 index b71f72ef91..0000000000 --- a/common/djangoapps/user_api/management/commands/email_opt_in_list.py +++ /dev/null @@ -1,267 +0,0 @@ -"""Generate a list indicating whether users have opted in or out of receiving email from an org. - -Email opt-in is stored as an org-level preference. -When reports are generated, we need to handle: - -1) Org aliases: some organizations might have multiple course key "org" values. - We choose the most recently set preference among all org aliases. - Since this information isn't stored anywhere in edx-platform, - the caller needs to pass in the list of orgs and aliases. - -2) No preference set: Some users may not have an opt-in preference set - if they enrolled before the preference was introduced. - These users are opted in by default. - -3) Restricting to a subset of courses in an org: Some orgs have courses - that we don't want to include in the results (e.g. EdX-created test courses). - Allow the caller to explicitly specify the list of courses in the org. - -The command will always use the read replica database if one is configured. - -""" -import os.path -import csv -import time -import contextlib -import logging - -from django.core.management.base import BaseCommand, CommandError -from django.conf import settings -from django.db import connections - -from opaque_keys.edx.keys import CourseKey -from xmodule.modulestore.django import modulestore - - -LOGGER = logging.getLogger(__name__) - - -class Command(BaseCommand): - """Generate a list of email opt-in values for user enrollments. """ - - args = " --courses=COURSE_ID_LIST" - help = "Generate a list of email opt-in values for user enrollments." - - # Fields output in the CSV - OUTPUT_FIELD_NAMES = [ - "email", - "full_name", - "course_id", - "is_opted_in_for_email", - "preference_set_date" - ] - - # Number of records to read at a time when making - # multiple queries over a potentially large dataset. - QUERY_INTERVAL = 1000 - - def handle(self, *args, **options): - """Execute the command. - - Arguments: - file_path (str): Path to the output file. - *org_list (unicode): List of organization aliases. - - Keyword Arguments: - courses (unicode): Comma-separated list of course keys. If provided, - include only these courses in the results. - - Raises: - CommandError - - """ - file_path, org_list = self._parse_args(args) - - # Retrieve all the courses for the org. - # If we were given a specific list of courses to include, - # filter out anything not in that list. - courses = self._get_courses_for_org(org_list) - only_courses = options.get("courses") - if only_courses is not None: - only_courses = [ - CourseKey.from_string(course_key.strip()) - for course_key in only_courses.split(",") - ] - courses = list(set(courses) & set(only_courses)) - - # Add in organizations from the course keys, to ensure - # we're including orgs with different capitalizations - org_list = list(set(org_list) | set(course.org for course in courses)) - - # If no courses are found, abort - if not courses: - raise CommandError( - u"No courses found for orgs: {orgs}".format( - orgs=", ".join(org_list) - ) - ) - - # Let the user know what's about to happen - LOGGER.info( - u"Retrieving data for courses: {courses}".format( - courses=", ".join([unicode(course) for course in courses]) - ) - ) - - # Open the output file and generate the report. - with open(file_path, "w") as file_handle: - with self._log_execution_time(): - self._write_email_opt_in_prefs(file_handle, org_list, courses) - - # Remind the user where the output file is - LOGGER.info(u"Output file: {file_path}".format(file_path=file_path)) - - def _parse_args(self, args): - """Check and parse arguments. - - Validates that the right number of args were provided - and that the output file doesn't already exist. - - Arguments: - args (list): List of arguments given at the command line. - - Returns: - Tuple of (file_path, org_list) - - Raises: - CommandError - - """ - if len(args) < 2: - raise CommandError(u"Usage: {args}".format(args=self.args)) - - file_path = args[0] - org_list = args[1:] - if os.path.exists(file_path): - raise CommandError("File already exists at '{path}'".format(path=file_path)) - - return file_path, org_list - - def _get_courses_for_org(self, org_aliases): - """Retrieve all course keys for a particular org. - - Arguments: - org_aliases (list): List of aliases for the org. - - Returns: - List of `CourseKey`s - - """ - all_courses = modulestore().get_courses() - orgs_lowercase = [org.lower() for org in org_aliases] - return [ - course.id - for course in all_courses - if course.id.org.lower() in orgs_lowercase - ] - - @contextlib.contextmanager - def _log_execution_time(self): - """Context manager for measuring execution time. """ - start_time = time.time() - yield - execution_time = time.time() - start_time - LOGGER.info(u"Execution time: {time} seconds".format(time=execution_time)) - - def _write_email_opt_in_prefs(self, file_handle, org_aliases, courses): - """Write email opt-in preferences to the output file. - - This will generate a CSV with one row for each enrollment. - This means that the user's "opt in" preference will be specified - multiple times if the user has enrolled in multiple courses - within the org. However, the values should always be the same: - if the user is listed as "opted out" for course A, she will - also be listed as "opted out" for courses B, C, and D. - - Arguments: - file_handle (file): Handle to the output file. - org_aliases (list): List of aliases for the org. - courses (list): List of course keys in the org. - - Returns: - None - - """ - writer = csv.DictWriter(file_handle, fieldnames=self.OUTPUT_FIELD_NAMES) - cursor = self._db_cursor() - query = ( - u""" - SELECT - user.`email` AS `email`, - profile.`name` AS `full_name`, - enrollment.`course_id` AS `course_id`, - ( - SELECT value - FROM user_api_userorgtag - WHERE org IN ( {org_list} ) - AND `key`=\"email-optin\" - AND `user_id`=user.`id` - ORDER BY modified DESC - LIMIT 1 - ) AS `is_opted_in_for_email`, - ( - SELECT modified - FROM user_api_userorgtag - WHERE org IN ( {org_list} ) - AND `key`=\"email-optin\" - AND `user_id`=user.`id` - ORDER BY modified DESC - LIMIT 1 - ) AS `preference_set_date` - FROM - student_courseenrollment AS enrollment - LEFT JOIN auth_user AS user ON user.id=enrollment.user_id - LEFT JOIN auth_userprofile AS profile ON profile.user_id=user.id - WHERE enrollment.course_id IN ( {course_id_list} ) - """ - ).format( - course_id_list=self._sql_list(courses), - org_list=self._sql_list(org_aliases) - ) - - cursor.execute(query) - row_count = 0 - for row in self._iterate_results(cursor): - email, full_name, course_id, is_opted_in, pref_set_date = row - writer.writerow({ - "email": email.encode('utf-8'), - "full_name": full_name.encode('utf-8'), - "course_id": course_id.encode('utf-8'), - "is_opted_in_for_email": is_opted_in if is_opted_in else "True", - "preference_set_date": pref_set_date, - }) - row_count += 1 - - # Log the number of rows we processed - LOGGER.info(u"Retrieved {num_rows} records.".format(num_rows=row_count)) - - def _iterate_results(self, cursor): - """Iterate through the results of a database query, fetching in chunks. - - Arguments: - cursor: The database cursor - - Yields: - tuple of row values from the query - - """ - while True: - rows = cursor.fetchmany(self.QUERY_INTERVAL) - if not rows: - break - for row in rows: - yield row - - def _sql_list(self, values): - """Serialize a list of values for including in a SQL "IN" statement. """ - return u",".join([u'"{}"'.format(val) for val in values]) - - def _db_cursor(self): - """Return a database cursor to the read replica if one is available. """ - # Use the read replica if one has been configured - db_alias = ( - 'read_replica' - if 'read_replica' in settings.DATABASES - else 'default' - ) - return connections[db_alias].cursor() diff --git a/common/djangoapps/user_api/management/tests/__init__.py b/common/djangoapps/user_api/management/tests/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/common/djangoapps/user_api/management/tests/test_email_opt_in_list.py b/common/djangoapps/user_api/management/tests/test_email_opt_in_list.py deleted file mode 100644 index f9b4389da3..0000000000 --- a/common/djangoapps/user_api/management/tests/test_email_opt_in_list.py +++ /dev/null @@ -1,399 +0,0 @@ -# -*- coding: utf-8 -*- -"""Tests for the email opt-in list management command. """ -import os.path -import tempfile -import shutil -import csv -from collections import defaultdict -from unittest import skipUnless - - -import ddt -from django.conf import settings -from django.test.utils import override_settings -from django.core.management.base import CommandError - - -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, mixed_store_config -from xmodule.modulestore.tests.factories import CourseFactory -from student.tests.factories import UserFactory, CourseEnrollmentFactory -from student.models import CourseEnrollment - -import user_api.api.profile as profile_api -from user_api.models import UserOrgTag -from user_api.management.commands import email_opt_in_list - - -MODULESTORE_CONFIG = mixed_store_config(settings.COMMON_TEST_DATA_ROOT, {}, include_xml=False) - - -@ddt.ddt -@skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') -@override_settings(MODULESTORE=MODULESTORE_CONFIG) -class EmailOptInListTest(ModuleStoreTestCase): - """Tests for the email opt-in list management command. """ - - USER_USERNAME = "test_user" - USER_FIRST_NAME = u"Ṫëṡẗ" - USER_LAST_NAME = u"Űśéŕ" - - TEST_ORG = u"téśt_őŕǵ" - - OUTPUT_FILE_NAME = "test_org_email_opt_in.csv" - OUTPUT_FIELD_NAMES = [ - "email", - "full_name", - "course_id", - "is_opted_in_for_email", - "preference_set_date" - ] - - def setUp(self): - self.user = UserFactory.create( - username=self.USER_USERNAME, - first_name=self.USER_FIRST_NAME, - last_name=self.USER_LAST_NAME - ) - self.courses = [] - self.enrollments = defaultdict(list) - - def test_not_enrolled(self): - self._create_courses_and_enrollments((self.TEST_ORG, False)) - output = self._run_command(self.TEST_ORG) - - # The user isn't enrolled in the course, so the output should be empty - self._assert_output(output) - - def test_enrolled_no_pref(self): - self._create_courses_and_enrollments((self.TEST_ORG, True)) - output = self._run_command(self.TEST_ORG) - - # By default, if no preference is set by the user is enrolled, opt in - self._assert_output(output, (self.user, self.courses[0].id, True)) - - def test_enrolled_pref_opted_in(self): - self._create_courses_and_enrollments((self.TEST_ORG, True)) - self._set_opt_in_pref(self.user, self.TEST_ORG, True) - output = self._run_command(self.TEST_ORG) - self._assert_output(output, (self.user, self.courses[0].id, True)) - - def test_enrolled_pref_opted_out(self): - self._create_courses_and_enrollments((self.TEST_ORG, True)) - self._set_opt_in_pref(self.user, self.TEST_ORG, False) - output = self._run_command(self.TEST_ORG) - self._assert_output(output, (self.user, self.courses[0].id, False)) - - def test_opt_in_then_opt_out(self): - self._create_courses_and_enrollments((self.TEST_ORG, True)) - self._set_opt_in_pref(self.user, self.TEST_ORG, True) - self._set_opt_in_pref(self.user, self.TEST_ORG, False) - output = self._run_command(self.TEST_ORG) - self._assert_output(output, (self.user, self.courses[0].id, False)) - - def test_exclude_non_org_courses(self): - # Enroll in a course that's not in the org - self._create_courses_and_enrollments( - (self.TEST_ORG, True), - ("other_org", True) - ) - - # Opt out of the other course - self._set_opt_in_pref(self.user, "other_org", False) - - # The first course is included in the results, - # but the second course is excluded, - # so the user should be opted in by default. - output = self._run_command(self.TEST_ORG) - self._assert_output( - output, - (self.user, self.courses[0].id, True), - expect_pref_datetime=False - ) - - def test_enrolled_conflicting_prefs(self): - # Enroll in two courses, both in the org - self._create_courses_and_enrollments( - (self.TEST_ORG, True), - ("org_alias", True) - ) - - # Opt into the first course, then opt out of the second course - self._set_opt_in_pref(self.user, self.TEST_ORG, True) - self._set_opt_in_pref(self.user, "org_alias", False) - - # The second preference change should take precedence - # Note that *both* courses are included in the list, - # but they should have the same value. - output = self._run_command(self.TEST_ORG, other_names=["org_alias"]) - self._assert_output( - output, - (self.user, self.courses[0].id, False), - (self.user, self.courses[1].id, False) - ) - - # Opt into the first course - # Even though the other course still has a preference set to false, - # the newest preference takes precedence - self._set_opt_in_pref(self.user, self.TEST_ORG, True) - output = self._run_command(self.TEST_ORG, other_names=["org_alias"]) - self._assert_output( - output, - (self.user, self.courses[0].id, True), - (self.user, self.courses[1].id, True) - ) - - @ddt.data(True, False) - def test_unenrolled_from_all_courses(self, opt_in_pref): - # Enroll in the course and set a preference - self._create_courses_and_enrollments((self.TEST_ORG, True)) - self._set_opt_in_pref(self.user, self.TEST_ORG, opt_in_pref) - - # Unenroll from the course - CourseEnrollment.unenroll(self.user, self.courses[0].id, skip_refund=True) - - # Enrollments should still appear in the outpu - output = self._run_command(self.TEST_ORG) - self._assert_output(output, (self.user, self.courses[0].id, opt_in_pref)) - - def test_unenrolled_from_some_courses(self): - # Enroll in several courses in the org - self._create_courses_and_enrollments( - (self.TEST_ORG, True), - (self.TEST_ORG, True), - (self.TEST_ORG, True), - ("org_alias", True) - ) - - # Set a preference for the aliased course - self._set_opt_in_pref(self.user, "org_alias", False) - - # Unenroll from the aliased course - CourseEnrollment.unenroll(self.user, self.courses[3].id, skip_refund=True) - - # Expect that the preference still applies, - # and all the enrollments should appear in the list - output = self._run_command(self.TEST_ORG, other_names=["org_alias"]) - self._assert_output( - output, - (self.user, self.courses[0].id, False), - (self.user, self.courses[1].id, False), - (self.user, self.courses[2].id, False), - (self.user, self.courses[3].id, False) - ) - - def test_no_courses_for_org_name(self): - self._create_courses_and_enrollments((self.TEST_ORG, True)) - self._set_opt_in_pref(self.user, self.TEST_ORG, True) - - # No course available for this particular org - with self.assertRaisesRegexp(CommandError, "^No courses found for orgs:"): - self._run_command("other_org") - - def test_specify_subset_of_courses(self): - # Create several courses in the same org - self._create_courses_and_enrollments( - (self.TEST_ORG, True), - (self.TEST_ORG, True), - (self.TEST_ORG, True), - ) - - # Execute the command, but exclude the second course from the list - only_courses = [self.courses[0].id, self.courses[1].id] - self._run_command(self.TEST_ORG, only_courses=only_courses) - - # Choose numbers before and after the query interval boundary - @ddt.data(2, 3, 4, 5, 6, 7, 8, 9) - def test_many_users(self, num_users): - # Create many users and enroll them in the test course - course = CourseFactory.create(org=self.TEST_ORG) - usernames = [] - for _ in range(num_users): - user = UserFactory.create() - usernames.append(user.username) - CourseEnrollmentFactory.create(course_id=course.id, user=user) - - # Generate the report - output = self._run_command(self.TEST_ORG, query_interval=4) - - # Expect that every enrollment shows up in the report - output_emails = [row["email"] for row in output] - for email in output_emails: - self.assertIn(email, output_emails) - - def test_org_capitalization(self): - # Lowercase some of the org names in the course IDs - self._create_courses_and_enrollments( - ("MyOrg", True), - ("myorg", True) - ) - - # Set preferences for both courses - self._set_opt_in_pref(self.user, "MyOrg", True) - self._set_opt_in_pref(self.user, "myorg", False) - - # Execute the command, expecting both enrollments to show up - # We're passing in the uppercase org, but we set the lowercase - # version more recently, so we expect the lowercase org - # preference to apply. - output = self._run_command("MyOrg") - self._assert_output( - output, - (self.user, self.courses[0].id, False), - (self.user, self.courses[1].id, False) - ) - - @ddt.data(0, 1) - def test_not_enough_args(self, num_args): - args = ["dummy"] * num_args - expected_msg_regex = "^Usage: --courses=COURSE_ID_LIST$" - with self.assertRaisesRegexp(CommandError, expected_msg_regex): - email_opt_in_list.Command().handle(*args) - - def test_file_already_exists(self): - temp_file = tempfile.NamedTemporaryFile(delete=True) - - def _cleanup(): # pylint: disable=missing-docstring - temp_file.close() - - with self.assertRaisesRegexp(CommandError, "^File already exists"): - email_opt_in_list.Command().handle(temp_file.name, self.TEST_ORG) - - def _create_courses_and_enrollments(self, *args): - """Create courses and enrollments. - - Created courses and enrollments are stored in instance variables - so tests can refer to them later. - - Arguments: - *args: Tuples of (course_org, should_enroll), where - course_org is the name of the org in the course key - and should_enroll is a boolean indicating whether to enroll - the user in the course. - - Returns: - None - - """ - for course_number, (course_org, should_enroll) in enumerate(args): - course = CourseFactory.create(org=course_org, number=str(course_number)) - if should_enroll: - enrollment = CourseEnrollmentFactory.create( - is_active=True, - course_id=course.id, - user=self.user - ) - self.enrollments[course.id].append(enrollment) - self.courses.append(course) - - def _set_opt_in_pref(self, user, org, is_opted_in): - """Set the email opt-in preference. - - Arguments: - user (User): The user model. - org (unicode): The org in the course key. - is_opted_in (bool): Whether the user is opted in or out of emails. - - Returns: - None - - """ - profile_api.update_email_opt_in(user.username, org, is_opted_in) - - def _latest_pref_set_date(self, user): - """Retrieve the latest opt-in preference for the user, - across all orgs and preference keys. - - Arguments: - user (User): The user whos preference was set. - - Returns: - ISO-formatted date string or empty string - - """ - pref = UserOrgTag.objects.filter(user=user).order_by("-modified") - return pref[0].modified.isoformat(' ') if len(pref) > 0 else "" - - def _run_command(self, org, other_names=None, only_courses=None, query_interval=None): - """Execute the management command to generate the email opt-in list. - - Arguments: - org (unicode): The org to generate the report for. - - Keyword Arguments: - other_names (list): List of other aliases for the org. - only_courses (list): If provided, include only these course IDs in the report. - query_interval (int): If provided, override the default query interval. - - Returns: - list: The rows of the generated CSV report. Each item is a dictionary. - - """ - # Create a temporary directory for the output - # Delete it when we're finished - temp_dir_path = tempfile.mkdtemp() - - def _cleanup(): # pylint: disable=missing-docstring - shutil.rmtree(temp_dir_path) - - self.addCleanup(_cleanup) - - # Sanitize the arguments - if other_names is None: - other_names = [] - - output_path = os.path.join(temp_dir_path, self.OUTPUT_FILE_NAME) - org_list = [org] + other_names - if only_courses is not None: - only_courses = ",".join(unicode(course_id) for course_id in only_courses) - - command = email_opt_in_list.Command() - - # Override the query interval to speed up the tests - if query_interval is not None: - command.QUERY_INTERVAL = query_interval - - # Execute the command - command.handle(output_path, *org_list, courses=only_courses) - - # Retrieve the output from the file - try: - with open(output_path) as output_file: - reader = csv.DictReader(output_file, fieldnames=self.OUTPUT_FIELD_NAMES) - rows = [row for row in reader] - except IOError: - self.fail("Could not find or open output file at '{path}'".format(path=output_path)) - - # Return the output as a list of dictionaries - return rows - - def _assert_output(self, output, *args, **kwargs): - """Check the output of the report. - - Arguments: - output (list): List of rows in the output CSV file. - *args: Tuples of (user, course_id, opt_in_pref) - - Keyword Arguments: - expect_pref_datetime (bool): If false, expect an empty - string for the preference. - - Returns: - None - - Raises: - AssertionError - - """ - self.assertEqual(len(output), len(args)) - for user, course_id, opt_in_pref in args: - self.assertIn({ - "email": user.email.encode('utf-8'), - "full_name": user.profile.name.encode('utf-8'), - "course_id": unicode(course_id).encode('utf-8'), - "is_opted_in_for_email": unicode(opt_in_pref), - "preference_set_date": ( - self._latest_pref_set_date(self.user) - if kwargs.get("expect_pref_datetime", True) - else "" - ) - }, output) diff --git a/common/lib/xmodule/xmodule/modulestore/inheritance.py b/common/lib/xmodule/xmodule/modulestore/inheritance.py index 8b8623e2dd..296fdb80ca 100644 --- a/common/lib/xmodule/xmodule/modulestore/inheritance.py +++ b/common/lib/xmodule/xmodule/modulestore/inheritance.py @@ -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\")."), diff --git a/common/lib/xmodule/xmodule/split_test_module.py b/common/lib/xmodule/xmodule/split_test_module.py index a56b1e26bf..bd392562fe 100644 --- a/common/lib/xmodule/xmodule/split_test_module.py +++ b/common/lib/xmodule/xmodule/split_test_module.py @@ -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 diff --git a/common/test/acceptance/tests/studio/test_studio_split_test.py b/common/test/acceptance/tests/studio/test_studio_split_test.py index e3a0861b78..defd8d879d 100644 --- a/common/test/acceptance/tests/studio/test_studio_split_test.py +++ b/common/test/acceptance/tests/studio/test_studio_split_test.py @@ -40,7 +40,6 @@ class SplitTestMixin(object): 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. diff --git a/lms/djangoapps/django_comment_client/forum/tests.py b/lms/djangoapps/django_comment_client/forum/tests.py index 26e5e01fbf..1647fb85c4 100644 --- a/lms/djangoapps/django_comment_client/forum/tests.py +++ b/lms/djangoapps/django_comment_client/forum/tests.py @@ -17,19 +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.tests.modulestore_config import TEST_DATA_DIR 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 -TEST_DATA_MONGO_MODULESTORE = mixed_store_config(TEST_DATA_DIR, {}, include_xml=False) - log = logging.getLogger(__name__) # pylint: disable=missing-docstring diff --git a/lms/djangoapps/django_comment_client/tests/utils.py b/lms/djangoapps/django_comment_client/tests/utils.py index 95852371f3..2d8d751eb3 100644 --- a/lms/djangoapps/django_comment_client/tests/utils.py +++ b/lms/djangoapps/django_comment_client/tests/utils.py @@ -2,7 +2,7 @@ from django.test.utils import override_settings from mock import patch from openedx.core.djangoapps.course_groups.models import CourseUserGroup -from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE +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 from student.tests.factories import CourseEnrollmentFactory, UserFactory diff --git a/lms/djangoapps/instructor_analytics/tests/test_basic.py b/lms/djangoapps/instructor_analytics/tests/test_basic.py index b4eeb9c543..c933e7e343 100644 --- a/lms/djangoapps/instructor_analytics/tests/test_basic.py +++ b/lms/djangoapps/instructor_analytics/tests/test_basic.py @@ -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 @@ -18,11 +17,9 @@ from instructor_analytics.basic import ( coupon_codes_features, AVAILABLE_FEATURES, STUDENT_FEATURES, PROFILE_FEATURES ) from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory -from openedx.core.djangoapps.course_groups.models import CourseUserGroup 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): diff --git a/lms/lib/xblock/mixin.py b/lms/lib/xblock/mixin.py index 08e965b922..a3055d0437 100644 --- a/lms/lib/xblock/mixin.py +++ b/lms/lib/xblock/mixin.py @@ -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 diff --git a/lms/lib/xblock/test/test_mixin.py b/lms/lib/xblock/test/test_mixin.py new file mode 100644 index 0000000000..eeb0d8e680 --- /dev/null +++ b/lms/lib/xblock/test/test_mixin.py @@ -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)) diff --git a/common/djangoapps/user_api/migrations/0004_auto__add_userorgtag__add_unique_userorgtag_user_org_key__chg_field_us.py b/openedx/core/djangoapps/user_api/migrations/0004_auto__add_userorgtag__add_unique_userorgtag_user_org_key__chg_field_us.py similarity index 100% rename from common/djangoapps/user_api/migrations/0004_auto__add_userorgtag__add_unique_userorgtag_user_org_key__chg_field_us.py rename to openedx/core/djangoapps/user_api/migrations/0004_auto__add_userorgtag__add_unique_userorgtag_user_org_key__chg_field_us.py From de6ca4a7a7f1be461dfc2a43aa812e363877a3e4 Mon Sep 17 00:00:00 2001 From: jsa Date: Fri, 5 Dec 2014 12:31:24 -0500 Subject: [PATCH 4/5] avoid errors when forward-migrating course_groups after south conversion JIRA: TNL-937 --- .../course_groups/migrations/0001_initial.py | 51 ++++++++++++------- 1 file changed, 34 insertions(+), 17 deletions(-) diff --git a/openedx/core/djangoapps/course_groups/migrations/0001_initial.py b/openedx/core/djangoapps/course_groups/migrations/0001_initial.py index 1663cc902d..ca68d6628f 100644 --- a/openedx/core/djangoapps/course_groups/migrations/0001_initial.py +++ b/openedx/core/djangoapps/course_groups/migrations/0001_initial.py @@ -2,31 +2,48 @@ import datetime from south.db import db from south.v2 import SchemaMigration -from django.db import models +from django.db import models, connection class Migration(SchemaMigration): def forwards(self, orm): # Adding model 'CourseUserGroup' - 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']) + def table_exists(name): + return name in connection.introspection.table_names() - # 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 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'] From 50e4416d002009089220b9b4aba93fab9467f986 Mon Sep 17 00:00:00 2001 From: Andy Armstrong Date: Mon, 8 Dec 2014 11:18:47 -0500 Subject: [PATCH 5/5] Document the Open edX package. --- openedx/__init__.py | 9 +++++++++ openedx/core/__init__.py | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/openedx/__init__.py b/openedx/__init__.py index e69de29bb2..f3d7c7b8a8 100644 --- a/openedx/__init__.py +++ b/openedx/__init__.py @@ -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. +""" diff --git a/openedx/core/__init__.py b/openedx/core/__init__.py index e69de29bb2..fa1c85b3ad 100644 --- a/openedx/core/__init__.py +++ b/openedx/core/__init__.py @@ -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. +"""