Commit f24f01d2 by jsa Committed by Andy Armstrong

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.
parent 356b2335
......@@ -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
......
......@@ -575,7 +575,7 @@ INSTALLED_APPS = (
'contentstore',
'course_creators',
'student', # misleading name due to sharing with lms
'course_groups', # not used in cms (yet), but tests run
'openedx.core.djangoapps.course_groups', # not used in cms (yet), but tests run
# Tracking
'track',
......
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -6,11 +6,7 @@ from django.http import Http404
from django.test.client import Client, RequestFactory
from django.test.utils import override_settings
from edxmako.tests import mako_middleware_process_request
from mock import patch, Mock, ANY, call
from nose.tools import assert_true # pylint: disable=no-name-in-module
from course_groups.models import CourseUserGroup
from courseware.courses import UserNotEnrolled
from django_comment_client.forum import views
from django_comment_client.tests.group_id import (
CohortedTopicGroupIdTestMixin,
......@@ -25,6 +21,15 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.django_utils import 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
......
......@@ -11,7 +11,12 @@ import newrelic.agent
from edxmako.shortcuts import render_to_response
from courseware.courses import get_course_with_access
from course_groups.cohorts import is_course_cohorted, get_cohort_id, get_course_cohorts, is_commentable_cohorted
from openedx.core.djangoapps.course_groups.cohorts import (
is_course_cohorted,
get_cohort_id,
get_course_cohorts,
is_commentable_cohorted
)
from courseware.access import has_access
from django_comment_client.permissions import cached_has_permission
......
import json
import re
from course_groups.models import CourseUserGroup
class GroupIdAssertionMixin(object):
def _data_or_params_cs_request(self, mock_request):
......
from django.test.utils import override_settings
from mock import patch
from course_groups.models import CourseUserGroup
from xmodule.modulestore.tests.django_utils import TEST_DATA_MOCK_MODULESTORE
from openedx.core.djangoapps.course_groups.models import CourseUserGroup
from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE
from django_comment_common.models import Role
from django_comment_common.utils import seed_permissions_roles
from student.tests.factories import CourseEnrollmentFactory, UserFactory
......
......@@ -17,8 +17,8 @@ from django_comment_client.permissions import check_permissions_by_view, cached_
from edxmako import lookup_template
import pystache_custom as pystache
from course_groups.cohorts import get_cohort_by_id, get_cohort_id, is_commentable_cohorted, is_course_cohorted
from course_groups.models import CourseUserGroup
from openedx.core.djangoapps.course_groups.cohorts import get_cohort_by_id, get_cohort_id, is_commentable_cohorted, is_course_cohorted
from openedx.core.djangoapps.course_groups.models import CourseUserGroup
from opaque_keys.edx.locations import i4xEncoder
from opaque_keys.edx.keys import CourseKey
from xmodule.modulestore.django import modulestore
......
......@@ -17,8 +17,8 @@ from instructor_analytics.basic import (
sale_record_features, sale_order_record_features, enrolled_students_features, course_registration_features,
coupon_codes_features, AVAILABLE_FEATURES, STUDENT_FEATURES, PROFILE_FEATURES
)
from course_groups.tests.helpers import CohortFactory
from course_groups.models import CourseUserGroup
from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory
from 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
......
......@@ -2,7 +2,7 @@ from django.contrib.auth.models import User
from django.http import Http404
from rest_framework import serializers
from course_groups.cohorts import is_course_cohorted
from openedx.core.djangoapps.course_groups.cohorts import is_course_cohorted
from notification_prefs import NOTIFICATION_PREF_KEY
from lang_pref import LANGUAGE_KEY
......
......@@ -5,7 +5,7 @@ from django.conf import settings
from django.test.client import RequestFactory
from django.test.utils import override_settings
from course_groups.models import CourseUserGroup
from openedx.core.djangoapps.course_groups.models import CourseUserGroup
from django_comment_common.models import Role, Permission
from lang_pref import LANGUAGE_KEY
from notification_prefs import NOTIFICATION_PREF_KEY
......
......@@ -1438,7 +1438,7 @@ INSTALLED_APPS = (
'open_ended_grading',
'psychometrics',
'licenses',
'course_groups',
'openedx.core.djangoapps.course_groups',
'bulk_email',
# External auth (OpenID, shib)
......
......@@ -355,21 +355,21 @@ if settings.COURSEWARE_ENABLED:
# Cohorts management
url(r'^courses/{}/cohorts$'.format(settings.COURSE_KEY_PATTERN),
'course_groups.views.list_cohorts', name="cohorts"),
'openedx.core.djangoapps.course_groups.views.list_cohorts', name="cohorts"),
url(r'^courses/{}/cohorts/add$'.format(settings.COURSE_KEY_PATTERN),
'course_groups.views.add_cohort',
'openedx.core.djangoapps.course_groups.views.add_cohort',
name="add_cohort"),
url(r'^courses/{}/cohorts/(?P<cohort_id>[0-9]+)$'.format(settings.COURSE_KEY_PATTERN),
'course_groups.views.users_in_cohort',
'openedx.core.djangoapps.course_groups.views.users_in_cohort',
name="list_cohort"),
url(r'^courses/{}/cohorts/(?P<cohort_id>[0-9]+)/add$'.format(settings.COURSE_KEY_PATTERN),
'course_groups.views.add_users_to_cohort',
'openedx.core.djangoapps.course_groups.views.add_users_to_cohort',
name="add_to_cohort"),
url(r'^courses/{}/cohorts/(?P<cohort_id>[0-9]+)/delete$'.format(settings.COURSE_KEY_PATTERN),
'course_groups.views.remove_user_from_cohort',
'openedx.core.djangoapps.course_groups.views.remove_user_from_cohort',
name="remove_from_cohort"),
url(r'^courses/{}/cohorts/debug$'.format(settings.COURSE_KEY_PATTERN),
'course_groups.views.debug_cohort_mgmt',
'openedx.core.djangoapps.course_groups.views.debug_cohort_mgmt',
name="debug_cohort_mgmt"),
# Open Ended Notifications
......
......@@ -14,7 +14,7 @@ from django.utils.translation import ugettext as _
from courseware import courses
from eventtracking import tracker
from student.models import get_user_by_username_or_email
from .models import CourseUserGroup
from .models import CourseUserGroup, CourseUserGroupPartitionGroup
log = logging.getLogger(__name__)
......@@ -373,3 +373,17 @@ def add_user_to_cohort(cohort, username_or_email):
)
cohort.users.add(user)
return (user, previous_cohort_name)
def get_partition_group_id_for_cohort(cohort):
"""
Get the ids of the partition and group to which this cohort has been linked
as a tuple of (int, int).
If the cohort has not been linked to any partition/group, both values in the
tuple will be None.
"""
res = CourseUserGroupPartitionGroup.objects.filter(course_user_group=cohort)
if len(res):
return res[0].partition_id, res[0].group_id
return None, None
# -*- 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']
# -*- coding: utf-8 -*-
import datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models
class Migration(SchemaMigration):
def forwards(self, orm):
# Adding model 'CourseUserGroupPartitionGroup'
db.create_table('course_groups_courseusergrouppartitiongroup', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('course_user_group', self.gf('django.db.models.fields.related.OneToOneField')(to=orm['course_groups.CourseUserGroup'], unique=True)),
('partition_id', self.gf('django.db.models.fields.IntegerField')()),
('group_id', self.gf('django.db.models.fields.IntegerField')()),
('created_at', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)),
('updated_at', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, blank=True)),
))
db.send_create_signal('course_groups', ['CourseUserGroupPartitionGroup'])
def backwards(self, orm):
# Deleting model 'CourseUserGroupPartitionGroup'
db.delete_table('course_groups_courseusergrouppartitiongroup')
models = {
'auth.group': {
'Meta': {'object_name': 'Group'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
},
'auth.permission': {
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
},
'auth.user': {
'Meta': {'object_name': 'User'},
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
},
'contenttypes.contenttype': {
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
},
'course_groups.courseusergroup': {
'Meta': {'unique_together': "(('name', 'course_id'),)", 'object_name': 'CourseUserGroup'},
'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
'group_type': ('django.db.models.fields.CharField', [], {'max_length': '20'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
'users': ('django.db.models.fields.related.ManyToManyField', [], {'db_index': 'True', 'related_name': "'course_groups'", 'symmetrical': 'False', 'to': "orm['auth.User']"})
},
'course_groups.courseusergrouppartitiongroup': {
'Meta': {'object_name': 'CourseUserGroupPartitionGroup'},
'course_user_group': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['course_groups.CourseUserGroup']", 'unique': 'True'}),
'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
'group_id': ('django.db.models.fields.IntegerField', [], {}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'partition_id': ('django.db.models.fields.IntegerField', [], {}),
'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'})
}
}
complete_apps = ['course_groups']
......@@ -35,3 +35,17 @@ class CourseUserGroup(models.Model):
COHORT = 'cohort'
GROUP_TYPE_CHOICES = ((COHORT, 'Cohort'),)
group_type = models.CharField(max_length=20, choices=GROUP_TYPE_CHOICES)
class CourseUserGroupPartitionGroup(models.Model):
"""
"""
course_user_group = models.OneToOneField(CourseUserGroup)
partition_id = models.IntegerField(
help_text="contains the id of a cohorted partition in this course"
)
group_id = models.IntegerField(
help_text="contains the id of a specific group within the cohorted partition"
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
"""
Provides a UserPartition driver for cohorts.
"""
import logging
from .cohorts import get_cohort, get_partition_group_id_for_cohort
log = logging.getLogger(__name__)
class CohortPartitionScheme(object):
"""
This scheme uses lms cohorts (CourseUserGroups) and cohort-partition
mappings (CourseUserGroupPartitionGroup) to map lms users into Partition
Groups.
"""
@classmethod
def get_group_for_user(cls, course_id, user, user_partition, track_function=None):
"""
Returns the Group from the specified user partition to which the user
is assigned, via their cohort membership and any mappings from cohorts
to partitions / groups that might exist.
If the user has not yet been assigned to a cohort, an assignment *might*
be created on-the-fly, as determined by the course's cohort config.
Any such side-effects will be triggered inside the call to
cohorts.get_cohort().
If the user has no cohort mapping, or there is no (valid) cohort ->
partition group mapping found, the function returns None.
"""
cohort = get_cohort(user, course_id)
if cohort is None:
# student doesn't have a cohort
return None
partition_id, group_id = get_partition_group_id_for_cohort(cohort)
if partition_id is None:
# cohort isn't mapped to any partition group.
return None
if partition_id != user_partition.id:
# if we have a match but the partition doesn't match the requested
# one it means the mapping is invalid. the previous state of the
# partition configuration may have been modified.
log.warn(
"partition mismatch in CohortPartitionScheme: %r",
{
"requested_partition_id": user_partition.id,
"found_partition_id": partition_id,
"found_group_id": group_id,
"cohort_id": cohort.id,
}
)
# fail silently
return None
group = user_partition.get_group(group_id)
if group is None:
# if we have a match but the group doesn't exist in the partition,
# it means the mapping is invalid. the previous state of the
# partition configuration may have been modified.
log.warn(
"group not found in CohortPartitionScheme: %r",
{
"requested_partition_id": user_partition.id,
"requested_group_id": group_id,
"cohort_id": cohort.id,
}
)
# fail silently
return None
return group
......@@ -3,11 +3,12 @@ Helper methods for testing cohorts.
"""
from factory import post_generation, Sequence
from factory.django import DjangoModelFactory
from course_groups.models import CourseUserGroup
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from xmodule.modulestore.django import modulestore
from xmodule.modulestore import ModuleStoreEnum
from ..models import CourseUserGroup
class CohortFactory(DjangoModelFactory):
"""
......
from django.conf import settings
from django.contrib.auth.models import User
from django.db import IntegrityError
from django.http import Http404
from django.test import TestCase
from django.test.utils import override_settings
from mock import call, patch
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from course_groups import cohorts
from course_groups.models import CourseUserGroup
from course_groups.tests.helpers import topic_name_to_id, config_course_cohorts, CohortFactory
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from student.models import CourseEnrollment
from student.tests.factories import UserFactory
from xmodule.modulestore.django import modulestore, clear_existing_modulestores
from xmodule.modulestore.tests.django_utils import TEST_DATA_MIXED_TOY_MODULESTORE
from xmodule.modulestore.tests.django_utils import TEST_DATA_MIXED_TOY_MODULESTORE, mixed_store_config
from ..models import CourseUserGroup, CourseUserGroupPartitionGroup
from .. import cohorts
from ..tests.helpers import topic_name_to_id, config_course_cohorts, CohortFactory
# NOTE: running this with the lms.envs.test config works without
# manually overriding the modulestore. However, running with
# cms.envs.test doesn't.
@patch("course_groups.cohorts.tracker")
TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT
TEST_MAPPING = {'edX/toy/2012_Fall': 'xml'}
TEST_DATA_MIXED_MODULESTORE = mixed_store_config(TEST_DATA_DIR, TEST_MAPPING)
@patch("openedx.core.djangoapps.course_groups.cohorts.tracker")
class TestCohortSignals(TestCase):
def setUp(self):
self.course_key = SlashSeparatedCourseKey("dummy", "dummy", "dummy")
......@@ -446,7 +453,7 @@ class TestCohorts(TestCase):
lambda: cohorts.get_cohort_by_id(course.id, cohort.id)
)
@patch("course_groups.cohorts.tracker")
@patch("openedx.core.djangoapps.course_groups.cohorts.tracker")
def test_add_cohort(self, mock_tracker):
"""
Make sure cohorts.add_cohort() properly adds a cohort to a course and handles
......@@ -469,7 +476,7 @@ class TestCohorts(TestCase):
lambda: cohorts.add_cohort(SlashSeparatedCourseKey("course", "does_not", "exist"), "My Cohort")
)
@patch("course_groups.cohorts.tracker")
@patch("openedx.core.djangoapps.course_groups.cohorts.tracker")
def test_add_user_to_cohort(self, mock_tracker):
"""
Make sure cohorts.add_user_to_cohort() properly adds a user to a cohort and
......@@ -525,3 +532,127 @@ class TestCohorts(TestCase):
User.DoesNotExist,
lambda: cohorts.add_user_to_cohort(first_cohort, "non_existent_username")
)
@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
class TestCohortsAndPartitionGroups(TestCase):
def setUp(self):
"""
Regenerate a test course and cohorts for each test
"""
self.test_course_key = SlashSeparatedCourseKey("edX", "toy", "2012_Fall")
self.course = modulestore().get_course(self.test_course_key)
self.first_cohort = CohortFactory(course_id=self.course.id, name="FirstCohort")
self.second_cohort = CohortFactory(course_id=self.course.id, name="SecondCohort")
self.partition_id = 1
self.group1_id = 10
self.group2_id = 20
def _link_cohort_partition_group(self, cohort, partition_id, group_id):
"""
Utility to create cohort -> partition group assignments in the database.
"""
link = CourseUserGroupPartitionGroup(
course_user_group=cohort,
partition_id=partition_id,
group_id=group_id,
)
link.save()
return link
def test_get_partition_group_id_for_cohort(self):
"""
Basic test of the partition_group_id accessor function
"""
# api should return nothing for an unmapped cohort
self.assertEqual(
cohorts.get_partition_group_id_for_cohort(self.first_cohort),
(None, None),
)
# create a link for the cohort in the db
link = self._link_cohort_partition_group(
self.first_cohort,
self.partition_id,
self.group1_id
)
# api should return the specified partition and group
self.assertEqual(
cohorts.get_partition_group_id_for_cohort(self.first_cohort),
(self.partition_id, self.group1_id)
)
# delete the link in the db
link.delete()
# api should return nothing again
self.assertEqual(
cohorts.get_partition_group_id_for_cohort(self.first_cohort),
(None, None),
)
def test_multiple_cohorts(self):
"""
Test that multiple cohorts can be linked to the same partition group
"""
self._link_cohort_partition_group(
self.first_cohort,
self.partition_id,
self.group1_id,
)
self._link_cohort_partition_group(
self.second_cohort,
self.partition_id,
self.group1_id,
)
self.assertEqual(
cohorts.get_partition_group_id_for_cohort(self.first_cohort),
(self.partition_id, self.group1_id),
)
self.assertEqual(
cohorts.get_partition_group_id_for_cohort(self.second_cohort),
cohorts.get_partition_group_id_for_cohort(self.first_cohort),
)
def test_multiple_partition_groups(self):
"""
Test that a cohort cannot be mapped to more than one partition group
"""
self._link_cohort_partition_group(
self.first_cohort,
self.partition_id,
self.group1_id,
)
with self.assertRaisesRegexp(IntegrityError, 'not unique'):
self._link_cohort_partition_group(
self.first_cohort,
self.partition_id,
self.group2_id,
)
def test_delete_cascade(self):
"""
Test that cohort -> partition group links are automatically deleted
when their parent cohort is deleted.
"""
self._link_cohort_partition_group(
self.first_cohort,
self.partition_id,
self.group1_id
)
self.assertEqual(
cohorts.get_partition_group_id_for_cohort(self.first_cohort),
(self.partition_id, self.group1_id)
)
# delete the link
self.first_cohort.delete()
# api should return nothing at that point
self.assertEqual(
cohorts.get_partition_group_id_for_cohort(self.first_cohort),
(None, None),
)
# link should no longer exist because of delete cascade
with self.assertRaises(CourseUserGroupPartitionGroup.DoesNotExist):
CourseUserGroupPartitionGroup.objects.get(
course_user_group_id=self.first_cohort.id
)
"""
Test the partitions and partitions service
"""
from django.conf import settings
import django.test
from django.test.utils import override_settings
from mock import patch
from student.tests.factories import UserFactory
from xmodule.partitions.partitions import Group, UserPartition, UserPartitionError
from xmodule.modulestore.django import modulestore, clear_existing_modulestores
from xmodule.modulestore.tests.django_utils import mixed_store_config
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from ..partition_scheme import CohortPartitionScheme
from ..models import CourseUserGroupPartitionGroup
from ..cohorts import add_user_to_cohort
from .helpers import CohortFactory, config_course_cohorts
TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT
TEST_MAPPING = {'edX/toy/2012_Fall': 'xml'}
TEST_DATA_MIXED_MODULESTORE = mixed_store_config(TEST_DATA_DIR, TEST_MAPPING)
@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE)
class TestCohortPartitionScheme(django.test.TestCase):
"""
Test the logic for linking a user to a partition group based on their cohort.
"""
def setUp(self):
"""
Regenerate a course with cohort configuration, partition and groups,
and a student for each test.
"""
self.course_key = SlashSeparatedCourseKey("edX", "toy", "2012_Fall")
config_course_cohorts(modulestore().get_course(self.course_key), [], cohorted=True)
self.groups = [Group(10, 'Group 10'), Group(20, 'Group 20')]
self.user_partition = UserPartition(
0,
'Test Partition',
'for testing purposes',
self.groups,
scheme=CohortPartitionScheme
)
self.student = UserFactory.create()
def link_cohort_partition_group(self, cohort, partition, group):
"""
Utility for creating cohort -> partition group links
"""
CourseUserGroupPartitionGroup(
course_user_group=cohort,
partition_id=partition.id,
group_id=group.id,
).save()
def unlink_cohort_partition_group(self, cohort):
"""
Utility for removing cohort -> partition group links
"""
CourseUserGroupPartitionGroup.objects.filter(course_user_group=cohort).delete()
def assert_student_in_group(self, group, partition=None):
"""
Utility for checking that our test student comes up as assigned to the
specified partition (or, if None, no partition at all)
"""
self.assertEqual(
CohortPartitionScheme.get_group_for_user(
self.course_key,
self.student,
partition or self.user_partition,
),
group
)
def test_student_cohort_assignment(self):
"""
Test that the CohortPartitionScheme continues to return the correct
group for a student as the student is moved in and out of different
cohorts.
"""
first_cohort, second_cohort = [
CohortFactory(course_id=self.course_key) for _ in range(2)
]
# place student 0 into first cohort
add_user_to_cohort(first_cohort, self.student.username)
self.assert_student_in_group(None)
# link first cohort to group 0 in the partition
self.link_cohort_partition_group(
first_cohort,
self.user_partition,
self.groups[0],
)
# link second cohort to to group 1 in the partition
self.link_cohort_partition_group(
second_cohort,
self.user_partition,
self.groups[1],
)
self.assert_student_in_group(self.groups[0])
# move student from first cohort to second cohort
add_user_to_cohort(second_cohort, self.student.username)
self.assert_student_in_group(self.groups[1])
# move the student out of the cohort
second_cohort.users.remove(self.student)
self.assert_student_in_group(None)
def test_cohort_partition_group_assignment(self):
"""
Test that the CohortPartitionScheme returns the correct group for a
student in a cohort when the cohort link is created / moved / deleted.
"""
test_cohort = CohortFactory(course_id=self.course_key)
# assign user to cohort (but cohort isn't linked to a partition group yet)
add_user_to_cohort(test_cohort, self.student.username)
# scheme should not yet find any link
self.assert_student_in_group(None)
# link cohort to group 0
self.link_cohort_partition_group(
test_cohort,
self.user_partition,
self.groups[0],
)
# now the scheme should find a link
self.assert_student_in_group(self.groups[0])
# link cohort to group 1 (first unlink it from group 0)
self.unlink_cohort_partition_group(test_cohort)
self.link_cohort_partition_group(
test_cohort,
self.user_partition,
self.groups[1],
)
# scheme should pick up the link
self.assert_student_in_group(self.groups[1])
# unlink cohort from anywhere
self.unlink_cohort_partition_group(
test_cohort,
)
# scheme should now return nothing
self.assert_student_in_group(None)
def setup_student_in_group_0(self):
"""
Utility to set up a cohort, add our student to the cohort, and link
the cohort to self.groups[0]
"""
test_cohort = CohortFactory(course_id=self.course_key)
# link cohort to group 0
self.link_cohort_partition_group(
test_cohort,
self.user_partition,
self.groups[0],
)
# place student into cohort
add_user_to_cohort(test_cohort, self.student.username)
# check link is correct
self.assert_student_in_group(self.groups[0])
def test_partition_changes_nondestructive(self):
"""
If the name of a user partition is changed, or a group is added to the
partition, links from cohorts do not break.
If the name of a group is changed, links from cohorts do not break.
"""
self.setup_student_in_group_0()
# to simulate a non-destructive configuration change on the course, create
# a new partition with the same id and scheme but with groups renamed and
# a group added
new_groups = [Group(10, 'New Group 10'), Group(20, 'New Group 20'), Group(30, 'New Group 30')]
new_user_partition = UserPartition(
0, # same id
'Different Partition',
'dummy',
new_groups,
scheme=CohortPartitionScheme,
)
# the link should still work
self.assert_student_in_group(new_groups[0], new_user_partition)
def test_missing_group(self):
"""
If the group is deleted (or its id is changed), there's no referential
integrity enforced, so any references from cohorts to that group will be
lost. A warning should be logged when links are found from cohorts to
groups that no longer exist.
"""
self.setup_student_in_group_0()
# to simulate a destructive change on the course, create a new partition
# with the same id, but different group ids.
new_user_partition = UserPartition(
0, # same id
'Another Partition',
'dummy',
[Group(11, 'Not Group 10'), Group(21, 'Not Group 20')], # different ids
scheme=CohortPartitionScheme,
)
# the partition will be found since it has the same id, but the group
# ids aren't present anymore, so the scheme returns None (and logs a
# warning)
with patch('openedx.core.djangoapps.course_groups.partition_scheme.log') as mock_log:
self.assert_student_in_group(None, new_user_partition)
self.assertTrue(mock_log.warn.called)
self.assertRegexpMatches(mock_log.warn.call_args[0][0], 'group not found')
def test_missing_partition(self):
"""
If the user partition is deleted (or its id is changed), there's no
referential integrity enforced, so any references from cohorts to that
partition's groups will be lost. A warning should be logged when links
are found from cohorts to partitions that do not exist.
"""
self.setup_student_in_group_0()
# to simulate another destructive change on the course, create a new
# partition with a different id, but using the same groups.
new_user_partition = UserPartition(
1, # different id
'Moved Partition',
'dummy',
[Group(10, 'Group 10'), Group(20, 'Group 20')], # same ids
scheme=CohortPartitionScheme,
)
# the partition will not be found even though the group ids match, so the
# scheme returns None (and logs a warning).
with patch('openedx.core.djangoapps.course_groups.partition_scheme.log') as mock_log:
self.assert_student_in_group(None, new_user_partition)
self.assertTrue(mock_log.warn.called)
self.assertRegexpMatches(mock_log.warn.call_args[0][0], 'partition mismatch')
class TestExtension(django.test.TestCase):
"""
Ensure that the scheme extension is correctly plugged in (via entry point
in setup.py)
"""
def test_get_scheme(self):
self.assertEqual(UserPartition.get_scheme('cohort'), CohortPartitionScheme)
with self.assertRaisesRegexp(UserPartitionError, 'Unrecognized scheme'):
UserPartition.get_scheme('other')
......@@ -4,25 +4,23 @@ Tests for course group views
from collections import namedtuple
import json
from collections import namedtuple
from django.contrib.auth.models import User
from django.http import Http404
from django.test.client import RequestFactory
from django.test.utils import override_settings
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from course_groups.cohorts import (
get_cohort, CohortAssignmentType, get_cohort_by_name, DEFAULT_COHORT_NAME
)
from course_groups.models import CourseUserGroup
from course_groups.tests.helpers import config_course_cohorts, CohortFactory
from course_groups.views import (
list_cohorts, add_cohort, users_in_cohort, add_users_to_cohort, remove_user_from_cohort
)
from xmodule.modulestore.tests.django_utils import TEST_DATA_MOCK_MODULESTORE
from student.models import CourseEnrollment
from student.tests.factories import UserFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from ..models import CourseUserGroup
from ..views import list_cohorts, add_cohort, users_in_cohort, add_users_to_cohort, remove_user_from_cohort
from ..cohorts import get_cohort, CohortAssignmentType, get_cohort_by_name, DEFAULT_COHORT_NAME
from .helpers import config_course_cohorts, CohortFactory
@override_settings(MODULESTORE=TEST_DATA_MOCK_MODULESTORE)
......
......@@ -7,7 +7,7 @@ Stores global metadata using the UserPreference model, and per-course metadata u
UserCourseTag model.
"""
from user_api.models import UserCourseTag
from ..models import UserCourseTag
# Scopes
# (currently only allows per-course tags. Can be expanded to support
......
......@@ -3,6 +3,7 @@ Test the user api's partition extensions.
"""
from collections import defaultdict
from mock import patch
from unittest import TestCase
from openedx.core.djangoapps.user_api.partition_schemes import RandomUserPartitionScheme, UserPartitionError
from student.tests.factories import UserFactory
......@@ -105,3 +106,15 @@ class TestRandomUserPartitionScheme(PartitionTestCase):
# Now, get a new group using the same call
new_group = RandomUserPartitionScheme.get_group_for_user(self.MOCK_COURSE_ID, self.user, user_partition)
self.assertEqual(old_group.id, new_group.id)
class TestExtension(TestCase):
"""
Ensure that the scheme extension is correctly plugged in (via entry point
in setup.py)
"""
def test_get_scheme(self):
self.assertEqual(UserPartition.get_scheme('random'), RandomUserPartitionScheme)
with self.assertRaisesRegexp(UserPartitionError, 'Unrecognized scheme'):
UserPartition.get_scheme('other')
......@@ -888,7 +888,7 @@ class RegistrationViewTest(ApiTestCase):
no_extra_fields_setting = {}
with simulate_running_pipeline(
"user_api.views.third_party_auth.pipeline",
"openedx.core.djangoapps.user_api.views.third_party_auth.pipeline",
"google-oauth2", email="bob@example.com",
fullname="Bob", username="Bob123"
):
......
......@@ -27,7 +27,7 @@ from edxmako.shortcuts import marketing_link
from util.authentication import SessionAuthenticationAllowInactiveUser
from .api import account as account_api, profile as profile_api
from .helpers import FormDescription, shim_student_view, require_post_params
from .models import UserPreference
from .models import UserPreference, UserProfile
from .serializers import UserSerializer, UserPreferenceSerializer
......
......@@ -13,12 +13,14 @@ setup(
# be reorganized to be a more conventional Python tree.
packages=[
"openedx.core.djangoapps.user_api",
"openedx.core.djangoapps.course_groups",
"lms",
"cms",
],
entry_points={
'openedx.user_partition_scheme': [
'random = openedx.core.djangoapps.user_api.partition_schemes:RandomUserPartitionScheme',
'cohort = openedx.core.djangoapps.course_groups.partition_scheme:CohortPartitionScheme',
],
}
)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment