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 | 88 +++- .../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, 1535 insertions(+), 568 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 fb5aa94e5cb5..a044ecc910b1 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 318e3f9f3f52..ca9887b4d5f2 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -22,7 +22,7 @@ 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 @@ def parse(json_string): configuration = json.loads(json_string) except ValueError: raise GroupConfigurationsValidationError(_("invalid JSON")) - + configuration["version"] = UserPartition.VERSION return configuration def validate(self): @@ -1224,14 +1224,7 @@ def get_user_partition(self): """ 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 7d03c23c0fae..5c0e5130d179 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 @@ 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 @@ def test_can_create_group_configuration(self): 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 @@ def _url(self, cid=-1): 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 @@ def test_can_create_new_group_configuration_if_it_is_not_exist(self): 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 @@ def test_can_edit_group_configuration(self): 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 @@ def test_group_configuration_not_used(self): 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 @@ def test_can_get_correct_usage_info(self): 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 @@ def test_can_get_correct_usage_info(self): }, { '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 @@ def test_can_use_one_configuration_in_multiple_experiments(self): 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 496eee0c5236..b496a7ffc4f9 100644 --- a/cms/djangoapps/contentstore/views/tests/test_item.py +++ b/cms/djangoapps/contentstore/views/tests/test_item.py @@ -223,8 +223,9 @@ def test_split_test_edited(self): 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 d1b0ebacf137..236451eb4f75 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 4055c5c944f1..4feb741582a4 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -607,7 +607,7 @@ '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 96548f7e8e65..9456c2c56af2 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 95c97ee3d53e..0ef7a27c6ee6 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 4a1b183f1995..b58532d2a5bd 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 d4d8650cefff..954b1509ff54 100644 --- a/cms/templates/group_configurations.html +++ b/cms/templates/group_configurations.html @@ -55,7 +55,7 @@

${_("Page Actions")}

% else:
-

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

+

${_("Loading")}

% endif diff --git a/cms/templates/settings.html b/cms/templates/settings.html index 4746f6bb91ff..ea12f2453a7c 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 @@

${_("Other Course Settings")}

% endif diff --git a/cms/templates/settings_advanced.html b/cms/templates/settings_advanced.html index f4467945ffe1..af021a31d3f6 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 @@

${_("Other Course Settings")}

- % 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 641b06fc77fb..69c15d4a2479 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 @@

${_("Other Course Settings")}

% endif diff --git a/cms/templates/widgets/header.html b/cms/templates/widgets/header.html index 266c656b17f6..6ff87f6d4b39 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 @@

${_("Course" + % 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 afdec0eec890..7e06f5033909 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -39,7 +39,7 @@ 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 b14ea336906a..da3a64ddeff6 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 68d39265b668..042e86a71267 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.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 7d6bffd2a962..daa8e891274b 100644 --- a/common/djangoapps/lang_pref/tests/test_views.py +++ b/common/djangoapps/lang_pref/tests/test_views.py @@ -4,7 +4,7 @@ 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 78f99b73e03a..df7516639215 100644 --- a/common/djangoapps/lang_pref/views.py +++ b/common/djangoapps/lang_pref/views.py @@ -4,7 +4,7 @@ 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 de5770f298c5..e72dd971eb3d 100644 --- a/common/djangoapps/student/tests/test_create_account.py +++ b/common/djangoapps/student/tests/test_create_account.py @@ -12,7 +12,7 @@ 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 43986e320fd4..d61b0e719ece 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -83,7 +83,7 @@ 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 97391130ef6f..8b8623e2ddf4 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 c8f5b5f0eaff..ca3a10a3b19f 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.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 @@ def __repr__(self): @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 c2752b951f29..27b6f7a75f0c 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 @@ def from_json(value): 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 @@ def from_json(value): return Group(value["id"], value["name"]) -class UserPartition(namedtuple("UserPartition", "id name description groups")): +# The Stevedore extension point namespace for user partition scheme plugins. +USER_PARTITION_SCHEME_NAMESPACE = 'openedx.user_partition_scheme' + + +class UserPartition(namedtuple("UserPartition", "id name description groups scheme")): """ A named way to partition users into groups, primarily intended for running experiments. It is expected that each user will be in at most one group in a partition. - A Partition has an id, name, description, and a list of groups. + A Partition has an id, name, scheme, description, and a list of groups. The id is intended to be unique within the context where these are used. (e.g. for - partitions of users within a course, the ids should be unique per-course) + partitions of users within a course, the ids should be unique per-course). + The scheme is used to assign users into groups. """ - VERSION = 1 + VERSION = 2 + + # The collection of user partition scheme extensions. + scheme_extensions = None - def __new__(cls, id, name, description, groups): + # The default scheme to be used when upgrading version 1 partitions. + VERSION_1_SCHEME = "random" + + def __new__(cls, id, name, description, groups, scheme=None, scheme_id=VERSION_1_SCHEME): # pylint: disable=super-on-old-class - return super(UserPartition, cls).__new__(cls, int(id), name, description, groups) + if not scheme: + scheme = UserPartition.get_scheme(scheme_id) + return super(UserPartition, cls).__new__(cls, int(id), name, description, groups, scheme) + + @staticmethod + def get_scheme(name): + """ + Returns the user partition scheme with the given name. + """ + # Note: we're creating the extension manager lazily to ensure that the Python path + # has been correctly set up. Trying to create this statically will fail, unfortunately. + if not UserPartition.scheme_extensions: + UserPartition.scheme_extensions = ExtensionManager(namespace=USER_PARTITION_SCHEME_NAMESPACE) + try: + scheme = UserPartition.scheme_extensions[name].plugin + except KeyError: + raise UserPartitionError("Unrecognized scheme {0}".format(name)) + scheme.name = name + return scheme def to_json(self): """ @@ -84,6 +123,7 @@ def to_json(self): 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 @@ def from_json(value): if isinstance(value, UserPartition): return value - for key in ('id', 'name', 'description', 'version', 'groups'): + for key in ("id", "name", "description", "version", "groups"): if key not in value: - raise TypeError("UserPartition dict {0} missing value key '{1}'" - .format(value, key)) - - if value["version"] != UserPartition.VERSION: - raise TypeError("UserPartition dict {0} has unexpected version" - .format(value)) + raise TypeError("UserPartition dict {0} missing value key '{1}'".format(value, key)) + + if value["version"] == 1: + # If no scheme was provided, set it to the default ('random') + scheme_id = UserPartition.VERSION_1_SCHEME + elif value["version"] == UserPartition.VERSION: + if not "scheme" in value: + raise TypeError("UserPartition dict {0} missing value key 'scheme'".format(value)) + scheme_id = value["scheme"] + else: + raise TypeError("UserPartition dict {0} has unexpected version".format(value)) groups = [Group.from_json(g) for g in value["groups"]] + scheme = UserPartition.get_scheme(scheme_id) + if not scheme: + raise TypeError("UserPartition dict {0} has unrecognized scheme {1}".format(value, scheme_id)) return UserPartition( value["id"], value["name"], value["description"], - groups + groups, + scheme, ) + + def get_group(self, group_id): + """ + Returns the group with the specified id. + """ + for group in self.groups: # pylint: disable=no-member + if group.id == group_id: + return group + return None diff --git a/common/lib/xmodule/xmodule/partitions/partitions_service.py b/common/lib/xmodule/xmodule/partitions/partitions_service.py index 82b283167ea5..bb23ca82254e 100644 --- a/common/lib/xmodule/xmodule/partitions/partitions_service.py +++ b/common/lib/xmodule/xmodule/partitions/partitions_service.py @@ -3,7 +3,6 @@ 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 @@ def course_partitions(self): """ 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 @@ def get_user_group_for_partition(self, user_partition_id): if user_partition is None: raise ValueError( "Configuration problem! No user_partition with id {0} " - "in course {1}".format(user_partition_id, 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 @@ def _get_user_partition(self, user_partition_id): 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 0ff667d34e3f..000000000000 --- 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 000000000000..6b13b7cb4e12 --- /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 3dd59bfd6868..a56b1e26bf9f 100644 --- a/common/lib/xmodule/xmodule/split_test_module.py +++ b/common/lib/xmodule/xmodule/split_test_module.py @@ -188,7 +188,7 @@ def get_group_id(self): 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 4c8ff94f76ca..44d9fc012327 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 b82310c845a5..36755a0308c0 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 @@ 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.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 @@ def get_module(descriptor): 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 87fe2964feb3..32a237f3bb97 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 1fabdff540d4..e3a0861b7897 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 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 @@ def setUp(self): 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 @@ def create_poorly_configured_split_instance(self): 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 @@ def test_delete_inactive_group(self): @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 @@ def create_group_configuration_experiment(self, groups, associate_experiment): 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 @@ def test_group_configurations_have_correct_data(self): 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 @@ def test_container_page_active_verticals_names_are_synced(self): 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 @@ def test_can_cancel_editing_of_group_configuration(self): 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 @@ def test_group_configuration_empty_usage(self): 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 @@ def test_group_configuration_non_empty_usage(self): 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 @@ def test_can_delete_unused_group_configuration(self): 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 @@ def test_cannot_delete_used_group_configuration(self): 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 @@ def test_easy_access_from_experiment(self): 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 72b7b037ef9c..3d5d9fd0af51 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 2fe280c12cdb..ee7258945924 100644 --- a/lms/djangoapps/courseware/tests/test_split_module.py +++ b/lms/djangoapps/courseware/tests/test_split_module.py @@ -12,7 +12,7 @@ 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 a51aaa6ad555..904204764b1e 100644 --- a/lms/djangoapps/courseware/tests/test_submitting_problems.py +++ b/lms/djangoapps/courseware/tests/test_submitting_problems.py @@ -27,7 +27,7 @@ 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 60caded8b52f..a5efa2b738aa 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -62,7 +62,7 @@ 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 5d386ddb92cc..eee8f124d342 100644 --- a/lms/djangoapps/instructor_task/tests/test_integration.py +++ b/lms/djangoapps/instructor_task/tests/test_integration.py @@ -17,7 +17,7 @@ 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 1eaab9517700..987211b0cd6b 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 3206dfaabc8a..91e84b063cbe 100644 --- a/lms/djangoapps/notification_prefs/tests.py +++ b/lms/djangoapps/notification_prefs/tests.py @@ -12,7 +12,7 @@ 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 eef24c5ee4a6..3cada57beb80 100644 --- a/lms/djangoapps/notification_prefs/views.py +++ b/lms/djangoapps/notification_prefs/views.py @@ -12,7 +12,7 @@ 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 50057813428c..545adaea1092 100644 --- a/lms/djangoapps/notifier_api/tests.py +++ b/lms/djangoapps/notifier_api/tests.py @@ -13,8 +13,8 @@ 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 84a38639a98d..9be79b07179c 100644 --- a/lms/djangoapps/notifier_api/views.py +++ b/lms/djangoapps/notifier_api/views.py @@ -3,7 +3,7 @@ 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 b44ab7403fb4..fe1957bbee10 100644 --- a/lms/djangoapps/oauth2_handler/handlers.py +++ b/lms/djangoapps/oauth2_handler/handlers.py @@ -6,7 +6,7 @@ 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 36d7fbfd4910..b1676e948f4d 100644 --- a/lms/djangoapps/oauth2_handler/tests.py +++ b/lms/djangoapps/oauth2_handler/tests.py @@ -9,7 +9,7 @@ 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 6fe8456bc4b5..2f223a934a0f 100644 --- a/lms/djangoapps/student_account/test/test_views.py +++ b/lms/djangoapps/student_account/test/test_views.py @@ -16,9 +16,8 @@ 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 647427a27a9c..9251eee900c4 100644 --- a/lms/djangoapps/student_account/views.py +++ b/lms/djangoapps/student_account/views.py @@ -24,8 +24,8 @@ 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 c24bd0ea0a31..6785796dedf8 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.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 a7a6a3c0d03c..51fac70c3e9d 100644 --- a/lms/djangoapps/student_profile/views.py +++ b/lms/djangoapps/student_profile/views.py @@ -11,7 +11,7 @@ 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 6b260e44c022..e84d2d059b5d 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -931,7 +931,7 @@ # 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 @@ # 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 2c1729ec4906..f96a157acf34 100644 --- a/lms/lib/xblock/runtime.py +++ b/lms/lib/xblock/runtime.py @@ -7,7 +7,7 @@ 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 @@ def __init__(self, **kwargs): 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 4730514479d1..edb02028e126 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -57,7 +57,7 @@ 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 000000000000..e69de29bb2d1 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 000000000000..e69de29bb2d1 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 118838c7b46e..e79969f1b712 100644 --- a/common/djangoapps/user_api/api/account.py +++ b/openedx/core/djangoapps/user_api/api/account.py @@ -11,8 +11,8 @@ 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 8bc2f01f9c56..f0033cc9197d 100644 --- a/common/djangoapps/user_api/api/profile.py +++ b/openedx/core/djangoapps/user_api/api/profile.py @@ -13,9 +13,9 @@ 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 04bd7065e0f0..bb85be6fa901 100644 --- a/common/djangoapps/user_api/helpers.py +++ b/openedx/core/djangoapps/user_api/helpers.py @@ -12,7 +12,7 @@ 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 000000000000..e69de29bb2d1 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 000000000000..e69de29bb2d1 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 000000000000..b71f72ef910d --- /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 000000000000..e69de29bb2d1 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 000000000000..58d60f0a0d1a --- /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 1f4d4aa1cc45..144ab8220e29 100644 --- a/common/djangoapps/user_api/middleware.py +++ b/openedx/core/djangoapps/user_api/middleware.py @@ -8,7 +8,8 @@ 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 000000000000..e69de29bb2d1 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 000000000000..b3b40212db87 --- /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 09aa25e9395f..7c7227fff6b6 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 000000000000..e69de29bb2d1 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 60ee7c2ddcf4..8b1198bef5de 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 3ab1aac02947..27b708abcea0 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.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 51b48a56a421..a329df2a3a7d 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 @@ 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 76fcce7ddf4d..713ff4120673 100644 --- a/common/djangoapps/user_api/tests/test_helpers.py +++ b/openedx/core/djangoapps/user_api/tests/test_helpers.py @@ -4,10 +4,10 @@ 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 @@ def test_ignores_no_error(self): 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 d51ed2057e36..297ad0a140e7 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.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 @@ def setUp(self): 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 fabcbec8d206..09606403ab33 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.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 000000000000..f1b9ec9dc064 --- /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 9e0f7e554ec3..297b50c4f8ac 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 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 69380eb22073..6a3f8259a414 100644 --- a/common/djangoapps/user_api/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/tests/test_views.py @@ -16,16 +16,16 @@ 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 b6b903f350f5..3b840cc6b508 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 41e69aa3f5d7..3ee741529047 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.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 e8da51c6fc87..9b1058b2216a 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 4e0e0992d86f..42787bc216e6 100644 --- a/pavelib/utils/test/suites/nose_suite.py +++ b/pavelib/utils/test/suites/nose_suite.py @@ -128,7 +128,7 @@ def _default_test_id(self): # 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 cdd8f74211ca..90ee4ef27336 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 a044ecc910b1..b548bb8dc674 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 4feb741582a4..9243f3b5145c 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -575,7 +575,7 @@ '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 22d4e181be69..ac7cb2495cac 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 a155d8e1c054..94ceb085394e 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 d10c6309dc39..958c81ee23a8 100644 --- a/common/djangoapps/course_groups/cohorts.py +++ b/openedx/core/djangoapps/course_groups/cohorts.py @@ -14,7 +14,7 @@ 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 000000000000..1663cc902d58 --- /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 000000000000..735dbde78ba7 --- /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 aae9f406041f..da17f4c20c43 100644 --- a/common/djangoapps/course_groups/models.py +++ b/openedx/core/djangoapps/course_groups/models.py @@ -35,3 +35,17 @@ class Meta: 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 000000000000..cc36c14373f8 --- /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 000000000000..e69de29bb2d1 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 cd7b6c9eeed7..e23838a5bb8f 100644 --- a/common/djangoapps/course_groups/tests/helpers.py +++ b/openedx/core/djangoapps/course_groups/tests/helpers.py @@ -3,11 +3,12 @@ """ 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 b4a16af27c92..68fc445ecb28 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 @@ def test_get_cohort_by_id(self): 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 @@ def test_add_cohort(self, mock_tracker): 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 @@ def test_add_user_to_cohort(self, mock_tracker): 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 000000000000..6e0f2e1a4b1b --- /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 580b469c8868..9a50dda20376 100644 --- a/common/djangoapps/course_groups/tests/test_views.py +++ b/openedx/core/djangoapps/course_groups/tests/test_views.py @@ -4,25 +4,23 @@ 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 89e7f49b85e3..6aa9d7d9489e 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 @@ 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 f1b9ec9dc064..7d7ce3135d66 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 @@ """ 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 @@ def test_change_group_name(self): # 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 6a3f8259a414..c1614b235db9 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 @@ def test_register_form_third_party_auth_running(self): 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 3ee741529047..24791613c4a7 100644 --- a/openedx/core/djangoapps/user_api/views.py +++ b/openedx/core/djangoapps/user_api/views.py @@ -27,7 +27,7 @@ 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 90ee4ef27336..a3870e5750cf 100644 --- a/setup.py +++ b/setup.py @@ -13,12 +13,14 @@ # 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 b548bb8dc674..a0f53af981e6 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 236451eb4f75..052ecfeb5700 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 9e92c046c4fb..b99126c49438 100644 --- a/common/djangoapps/student/tests/test_enrollment.py +++ b/common/djangoapps/student/tests/test_enrollment.py @@ -106,7 +106,7 @@ def test_unenroll(self): 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 d61b0e719ece..8c7edb772ceb 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -109,7 +109,7 @@ ) 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 e69de29bb2d1..000000000000 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 e69de29bb2d1..000000000000 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 b71f72ef910d..000000000000 --- 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 e69de29bb2d1..000000000000 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 f9b4389da309..000000000000 --- 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 8b8623e2ddf4..296fdb80caa6 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 a56b1e26bf9f..bd392562fe3b 100644 --- a/common/lib/xmodule/xmodule/split_test_module.py +++ b/common/lib/xmodule/xmodule/split_test_module.py @@ -80,10 +80,6 @@ def build_partition_values(all_user_partitions, selected_user_partition): # 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 e3a0861b7897..defd8d879da3 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 @@ def create_user_partition_json(partition_id, name, description, groups): 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 26e5e01fbf27..1647fb85c476 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.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 95852371f394..2d8d751eb320 100644 --- a/lms/djangoapps/django_comment_client/tests/utils.py +++ b/lms/djangoapps/django_comment_client/tests/utils.py @@ -2,7 +2,7 @@ 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 b4eeb9c5434b..c933e7e34359 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 @@ 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 08e965b9223b..a3055d0437f2 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 000000000000..eeb0d8e680e6 --- /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 1663cc902d58..ca68d6628f14 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 e69de29bb2d1..f3d7c7b8a885 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 e69de29bb2d1..fa1c85b3ad10 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. +"""