Commit e5035746 by cahrens

Introduce EnrollmentTrackUserPartition.

TNL-6674
parent b6ba57ee
......@@ -9,10 +9,11 @@ from util.db import generate_int_id, MYSQL_MAX_INT
from django.utils.translation import ugettext as _
from contentstore.utils import reverse_usage_url
from xmodule.partitions.partitions import UserPartition
from xmodule.partitions.partitions_service import get_all_partitions_for_course, MINIMUM_STATIC_PARTITION_ID
from xmodule.split_test_module import get_split_user_partitions
from openedx.core.djangoapps.course_groups.partition_scheme import get_cohorted_user_partition
MINIMUM_GROUP_ID = 100
MINIMUM_GROUP_ID = MINIMUM_STATIC_PARTITION_ID
RANDOM_SCHEME = "random"
COHORT_SCHEME = "cohort"
......@@ -84,7 +85,7 @@ class GroupConfiguration(object):
"""
Assign ids for the group_configuration's groups.
"""
used_ids = [g.id for p in self.course.user_partitions for g in p.groups]
used_ids = [g.id for p in get_all_partitions_for_course(self.course) for g in p.groups]
# Assign ids to every group in configuration.
for group in self.configuration.get('groups', []):
if group.get('id') is None:
......@@ -96,7 +97,7 @@ class GroupConfiguration(object):
"""
Return a list of IDs that already in use.
"""
return set([p.id for p in course.user_partitions])
return set([p.id for p in get_all_partitions_for_course(course)])
def get_user_partition(self):
"""
......
......@@ -493,12 +493,12 @@ class GetUserPartitionInfoTest(ModuleStoreTestCase):
]
}
]
self.assertEqual(self._get_partition_info(), expected)
self.assertEqual(self._get_partition_info(schemes=["cohort", "random"]), expected)
# Update group access and expect that now one group is marked as selected.
self._set_group_access({0: [1]})
expected[0]["groups"][1]["selected"] = True
self.assertEqual(self._get_partition_info(), expected)
self.assertEqual(self._get_partition_info(schemes=["cohort", "random"]), expected)
def test_deleted_groups(self):
# Select a group that is not defined in the partition
......@@ -546,7 +546,7 @@ class GetUserPartitionInfoTest(ModuleStoreTestCase):
])
# Expect that the inactive scheme is excluded from the results
partitions = self._get_partition_info()
partitions = self._get_partition_info(schemes=["cohort", "verification"])
self.assertEqual(len(partitions), 1)
self.assertEqual(partitions[0]["scheme"], "cohort")
......@@ -572,7 +572,7 @@ class GetUserPartitionInfoTest(ModuleStoreTestCase):
])
# Expect that the partition with no groups is excluded from the results
partitions = self._get_partition_info()
partitions = self._get_partition_info(schemes=["cohort", "verification"])
self.assertEqual(len(partitions), 1)
self.assertEqual(partitions[0]["scheme"], "verification")
......
......@@ -14,6 +14,7 @@ from django_comment_common.utils import seed_permissions_roles
from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
from openedx.core.djangoapps.site_configuration.models import SiteConfiguration
from xmodule.partitions.partitions_service import get_all_partitions_for_course
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
......@@ -373,11 +374,11 @@ def get_user_partition_info(xblock, schemes=None, course=None):
schemes = set(schemes)
partitions = []
for p in sorted(course.user_partitions, key=lambda p: p.name):
for p in sorted(get_all_partitions_for_course(course, active_only=True), key=lambda p: p.name):
# Exclude disabled partitions, partitions with no groups defined
# Also filter by scheme name if there's a filter defined.
if p.active and p.groups and (schemes is None or p.scheme.name in schemes):
if p.groups and (schemes is None or p.scheme.name in schemes):
# First, add groups defined by the partition
groups = []
......@@ -408,7 +409,7 @@ def get_user_partition_info(xblock, schemes=None, course=None):
# Put together the entire partition dictionary
partitions.append({
"id": p.id,
"name": p.name,
"name": unicode(p.name), # Convert into a string in case ugettext_lazy was used
"scheme": p.scheme.name,
"groups": groups,
})
......
......@@ -16,6 +16,7 @@ from xmodule.x_module import PREVIEW_VIEWS, STUDENT_VIEW, AUTHOR_VIEW
from xmodule.contentstore.django import contentstore
from xmodule.error_module import ErrorDescriptor
from xmodule.exceptions import NotFoundError, ProcessingError
from xmodule.partitions.partitions_service import PartitionService
from xmodule.studio_editable import has_author_view
from xmodule.services import SettingsService
from xmodule.modulestore.django import modulestore, ModuleI18nService
......@@ -213,10 +214,24 @@ def _preview_module_system(request, descriptor, field_data):
"i18n": ModuleI18nService,
"settings": SettingsService(),
"user": DjangoXBlockUserService(request.user),
"partitions": StudioPartitionService(course_id=course_id)
},
)
class StudioPartitionService(PartitionService):
"""
A runtime mixin to allow the display and editing of component visibility based on user partitions.
"""
def get_user_group_id_for_partition(self, user, user_partition_id):
"""
Override this method to return None, as the split_test_module calls this
to determine which group a user should see, but is robust to getting a return
value of None meaning that all groups should be shown.
"""
return None
def _load_preview_module(request, descriptor):
"""
Return a preview XModule instantiated from the supplied descriptor. Will use mutable fields
......
......@@ -93,6 +93,8 @@ FEATURES['LICENSING'] = True
FEATURES['ENABLE_MOBILE_REST_API'] = True # Enable video bumper in Studio
FEATURES['ENABLE_VIDEO_BUMPER'] = True # Enable video bumper in Studio settings
FEATURES['ENABLE_ENROLLMENT_TRACK_USER_PARTITION'] = True
# Enable partner support link in Studio footer
PARTNER_SUPPORT_EMAIL = 'partner-support@example.com'
......
......@@ -88,6 +88,8 @@ from lms.envs.common import (
# File upload defaults
FILE_UPLOAD_STORAGE_BUCKET_NAME,
FILE_UPLOAD_STORAGE_PREFIX,
COURSE_ENROLLMENT_MODES
)
from path import Path as path
from warnings import simplefilter
......@@ -225,6 +227,9 @@ FEATURES = {
# Allow public account creation
'ALLOW_PUBLIC_ACCOUNT_CREATION': True,
# Whether or not the dynamic EnrollmentTrackUserPartition should be registered.
'ENABLE_ENROLLMENT_TRACK_USER_PARTITION': False,
}
ENABLE_JASMINE = False
......@@ -883,6 +888,9 @@ INSTALLED_APPS = (
# for managing course modes
'course_modes',
# Verified Track Content Cohorting (Beta feature that will hopefully be removed)
'openedx.core.djangoapps.verified_track_content',
# Dark-launching languages
'openedx.core.djangoapps.dark_lang',
......
......@@ -320,6 +320,7 @@ FEATURES['ENABLE_COURSEWARE_INDEX'] = True
FEATURES['ENABLE_LIBRARY_INDEX'] = True
SEARCH_ENGINE = "search.tests.mock_search_engine.MockSearchEngine"
FEATURES['ENABLE_ENROLLMENT_TRACK_USER_PARTITION'] = True
# teams feature
FEATURES['ENABLE_TEAMS'] = True
......
......@@ -28,22 +28,18 @@ from course_modes.models import CourseMode, CourseModeExpirationConfig
# the verification deadline table won't exist.
from lms.djangoapps.verify_student import models as verification_models
COURSE_MODE_SLUG_CHOICES = [(mode_slug, mode_slug) for mode_slug in settings.COURSE_ENROLLMENT_MODES]
class CourseModeForm(forms.ModelForm):
"""
Admin form for adding a course mode.
"""
class Meta(object):
model = CourseMode
fields = '__all__'
COURSE_MODE_SLUG_CHOICES = (
[(CourseMode.DEFAULT_MODE_SLUG, CourseMode.DEFAULT_MODE_SLUG)] +
[(mode_slug, mode_slug) for mode_slug in CourseMode.VERIFIED_MODES] +
[(CourseMode.NO_ID_PROFESSIONAL_MODE, CourseMode.NO_ID_PROFESSIONAL_MODE)] +
[(mode_slug, mode_slug) for mode_slug in CourseMode.CREDIT_MODES] +
# need to keep legacy modes around for awhile
[(CourseMode.DEFAULT_SHOPPINGCART_MODE_SLUG, CourseMode.DEFAULT_SHOPPINGCART_MODE_SLUG)]
)
mode_slug = forms.ChoiceField(choices=COURSE_MODE_SLUG_CHOICES, label=_("Mode"))
# The verification deadline is stored outside the course mode in the verify_student app.
......
......@@ -1341,23 +1341,6 @@ class CourseDescriptor(CourseFields, SequenceDescriptor, LicenseMixin):
"""
return self.teams_configuration.get('topics', None)
def get_user_partitions_for_scheme(self, scheme):
"""
Retrieve all user partitions defined in the course for a particular
partition scheme.
Arguments:
scheme (object): The user partition scheme.
Returns:
list of `UserPartition`
"""
return [
p for p in self.user_partitions
if p.scheme == scheme
]
def set_user_partitions_for_scheme(self, partitions, scheme):
"""
Set the user partitions for a particular scheme.
......
......@@ -47,6 +47,7 @@ from xmodule.modulestore.draft_and_published import ModuleStoreDraftAndPublished
from xmodule.modulestore.edit_info import EditInfoRuntimeMixin
from xmodule.modulestore.exceptions import ItemNotFoundError, DuplicateCourseError, ReferentialIntegrityError
from xmodule.modulestore.inheritance import InheritanceMixin, inherit_metadata, InheritanceKeyValueStore
from xmodule.partitions.partitions_service import PartitionService
from xmodule.modulestore.xml import CourseLocationManager
from xmodule.modulestore.store_utilities import DETACHED_XBLOCK_TYPES
from xmodule.services import SettingsService
......@@ -934,6 +935,8 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo
if self.request_cache:
services["request_cache"] = self.request_cache
services["partitions"] = PartitionService(course_key)
system = CachingDescriptorSystem(
modulestore=self,
course_key=course_key,
......@@ -1346,6 +1349,8 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo
if self.user_service:
services["user"] = self.user_service
services["partitions"] = PartitionService(course_key)
runtime = CachingDescriptorSystem(
modulestore=self,
module_data={},
......
......@@ -83,6 +83,7 @@ from xmodule.modulestore import (
from ..exceptions import ItemNotFoundError
from .caching_descriptor_system import CachingDescriptorSystem
from xmodule.partitions.partitions_service import PartitionService
from xmodule.modulestore.split_mongo.mongo_connection import MongoConnection, DuplicateKeyError
from xmodule.modulestore.split_mongo import BlockKey, CourseEnvelope
from xmodule.modulestore.store_utilities import DETACHED_XBLOCK_TYPES
......@@ -3359,6 +3360,9 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
"""
Create the proper runtime for this course
"""
services = self.services
services["partitions"] = PartitionService(course_entry.course_key)
return CachingDescriptorSystem(
modulestore=self,
course_entry=course_entry,
......@@ -3370,7 +3374,7 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
mixins=self.xblock_mixins,
select=self.xblock_select,
disabled_xblock_types=self.disabled_xblock_types,
services=self.services,
services=services,
)
def ensure_indexes(self):
......
......@@ -127,7 +127,7 @@ class UserPartition(namedtuple("UserPartition", "id name description groups sche
try:
scheme = UserPartition.scheme_extensions[name].plugin
except KeyError:
raise UserPartitionError("Unrecognized scheme {0}".format(name))
raise UserPartitionError("Unrecognized scheme '{0}'".format(name))
scheme.name = name
return scheme
......@@ -188,6 +188,16 @@ class UserPartition(namedtuple("UserPartition", "id name description groups sche
if not scheme:
raise TypeError("UserPartition dict {0} has unrecognized scheme {1}".format(value, scheme_id))
if hasattr(scheme, "create_user_partition"):
return scheme.create_user_partition(
value["id"],
value["name"],
value["description"],
groups,
parameters,
active,
)
else:
return UserPartition(
value["id"],
value["name"],
......@@ -214,5 +224,7 @@ class UserPartition(namedtuple("UserPartition", "id name description groups sche
return group
raise NoSuchUserPartitionGroupError(
"could not find a Group with ID [{}] in UserPartition [{}]".format(group_id, self.id)
"Could not find a Group with ID [{group_id}] in UserPartition [{partition_id}].".format(
group_id=group_id, partition_id=self.id
)
)
......@@ -3,31 +3,122 @@ This is a service-like API that assigns tracks which groups users are in for var
user partitions. It uses the user_service key/value store provided by the LMS runtime to
persist the assignments.
"""
from abc import ABCMeta, abstractproperty
from django.conf import settings
from django.utils.translation import ugettext_lazy as _
import logging
from xmodule.partitions.partitions import UserPartition, UserPartitionError
from xmodule.modulestore.django import modulestore
class PartitionService(object):
log = logging.getLogger(__name__)
# UserPartition IDs must be unique. The Cohort and Random UserPartitions (when they are
# created via Studio) choose an unused ID in the range of 100 (historical) to MAX_INT. Therefore the
# dynamic UserPartitionIDs must be under 100, and they have to be hard-coded to ensure
# they are always the same whenever the dynamic partition is added (since the UserPartition
# ID is stored in the xblock group_access dict).
ENROLLMENT_TRACK_PARTITION_ID = 50
MINIMUM_STATIC_PARTITION_ID = 100
# settings will not be available when running nosetests.
FEATURES = getattr(settings, 'FEATURES', {})
def get_all_partitions_for_course(course, active_only=False):
"""
This is an XBlock service that assigns tracks which groups users are in for various
user partitions. It uses the provided user_tags service object to
persist the assignments.
A method that returns all `UserPartitions` associated with a course, as a List.
This will include the ones defined in course.user_partitions, but it may also
include dynamically included partitions (such as the `EnrollmentTrackUserPartition`).
Args:
course: the course for which user partitions should be returned.
active_only: if `True`, only partitions with `active` set to True will be returned.
Returns:
A List of UserPartitions associated with the course.
"""
__metaclass__ = ABCMeta
all_partitions = course.user_partitions + _get_dynamic_partitions(course)
if active_only:
all_partitions = [partition for partition in all_partitions if partition.active]
return all_partitions
@abstractproperty
def course_partitions(self):
def _get_dynamic_partitions(course):
"""
Return the set of partitions assigned to self._course_id
Return the dynamic user partitions for this course.
If none exists, returns an empty array.
"""
raise NotImplementedError('Subclasses must implement course_partition')
enrollment_partition = _create_enrollment_track_partition(course)
return [enrollment_partition] if enrollment_partition else []
def _create_enrollment_track_partition(course):
"""
Create and return the dynamic enrollment track user partition.
If it cannot be created, None is returned.
"""
if not FEATURES.get('ENABLE_ENROLLMENT_TRACK_USER_PARTITION'):
return None
try:
enrollment_track_scheme = UserPartition.get_scheme("enrollment_track")
except UserPartitionError:
log.warning("No 'enrollment_track' scheme registered, EnrollmentTrackUserPartition will not be created.")
return None
used_ids = set(p.id for p in course.user_partitions)
if ENROLLMENT_TRACK_PARTITION_ID in used_ids:
# TODO: change to Exception after this has been in production for awhile, see TNL-6796.
log.warning(
"Can't add 'enrollment_track' partition, as ID {id} is assigned to {partition} in course {course}.".format(
id=ENROLLMENT_TRACK_PARTITION_ID,
partition=_get_partition_from_id(course.user_partitions, ENROLLMENT_TRACK_PARTITION_ID).name,
course=unicode(course.id)
)
)
return None
partition = enrollment_track_scheme.create_user_partition(
id=ENROLLMENT_TRACK_PARTITION_ID,
name=_(u"Enrollment Track Partition"),
description=_(u"Partition for segmenting users by enrollment track"),
parameters={"course_id": unicode(course.id)}
)
return partition
def __init__(self, user, course_id, track_function=None, cache=None):
self._user = user
class PartitionService(object):
"""
This is an XBlock service that returns information about the user partitions associated
with a given course.
"""
def __init__(self, course_id, track_function=None, cache=None):
self._course_id = course_id
self._track_function = track_function
self._cache = cache
def get_user_group_id_for_partition(self, user_partition_id):
def get_course(self):
"""
Return the course instance associated with this PartitionService.
This default implementation looks up the course from the modulestore.
"""
return modulestore().get_course(self._course_id)
@property
def course_partitions(self):
"""
Return the set of partitions assigned to self._course_id (both those set directly on the course
through course.user_partitions, and any dynamic partitions that exist). Note: this returns
both active and inactive partitions.
"""
return get_all_partitions_for_course(self.get_course())
def get_user_group_id_for_partition(self, user, user_partition_id):
"""
If the user is already assigned to a group in user_partition_id, return the
group_id.
......@@ -35,9 +126,6 @@ class PartitionService(object):
If not, assign them to one of the groups, persist that decision, and
return the group_id.
If the group they are assigned to doesn't exist anymore, re-assign to one of
the existing groups and return its id.
Args:
user_partition_id -- an id of a partition that's hopefully in the
runtime.user_partitions list.
......@@ -49,7 +137,7 @@ class PartitionService(object):
ValueError if the user_partition_id isn't found.
"""
cache_key = "PartitionService.ugidfp.{}.{}.{}".format(
self._user.id, self._course_id, user_partition_id
user.id, self._course_id, user_partition_id
)
if self._cache and (cache_key in self._cache):
......@@ -62,7 +150,7 @@ class PartitionService(object):
"in course {1}".format(user_partition_id, self._course_id)
)
group = self.get_group(user_partition)
group = self.get_group(user, user_partition)
group_id = group.id if group else None
if self._cache is not None:
......@@ -73,22 +161,33 @@ class PartitionService(object):
def _get_user_partition(self, user_partition_id):
"""
Look for a user partition with a matching id in the course's partitions.
Note that this method can return an inactive user partition.
Returns:
A UserPartition, or None if not found.
"""
for partition in self.course_partitions:
if partition.id == user_partition_id:
return partition
return None
return _get_partition_from_id(self.course_partitions, user_partition_id)
def get_group(self, user_partition, assign=True):
def get_group(self, user, user_partition, assign=True):
"""
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.
"""
return user_partition.scheme.get_group_for_user(
self._course_id, self._user, user_partition, assign=assign, track_function=self._track_function
self._course_id, user, user_partition, assign=assign, track_function=self._track_function
)
def _get_partition_from_id(partitions, user_partition_id):
"""
Look for a user partition with a matching id in the provided list of partitions.
Returns:
A UserPartition, or None if not found.
"""
for partition in partitions:
if partition.id == user_partition_id:
return partition
return None
......@@ -98,7 +98,8 @@ def get_split_user_partitions(user_partitions):
@XBlock.needs('user_tags') # pylint: disable=abstract-method
@XBlock.wants('partitions')
@XBlock.needs('partitions')
@XBlock.needs('user')
class SplitTestModule(SplitTestFields, XModule, StudioEditableModule):
"""
Show the user the appropriate child. Uses the ExperimentState
......@@ -193,9 +194,9 @@ class SplitTestModule(SplitTestFields, XModule, StudioEditableModule):
Returns the group ID, or None if none is available.
"""
partitions_service = self.runtime.service(self, 'partitions')
if not partitions_service:
return None
return partitions_service.get_user_group_id_for_partition(self.user_partition_id)
user_service = self.runtime.service(self, 'user')
user = user_service._django_user # pylint: disable=protected-access
return partitions_service.get_user_group_id_for_partition(user, self.user_partition_id)
@property
def is_configured(self):
......@@ -370,8 +371,8 @@ class SplitTestModule(SplitTestFields, XModule, StudioEditableModule):
@XBlock.needs('user_tags') # pylint: disable=abstract-method
@XBlock.wants('partitions')
@XBlock.wants('user')
@XBlock.needs('partitions')
@XBlock.needs('user')
class SplitTestDescriptor(SplitTestFields, SequenceDescriptor, StudioEditableDescriptor):
# the editing interface can be the same as for sequences -- just a container
module_class = SplitTestModule
......@@ -641,10 +642,6 @@ class SplitTestDescriptor(SplitTestFields, SequenceDescriptor, StudioEditableDes
Called from Studio view.
"""
user_service = self.runtime.service(self, 'user')
if user_service is None:
return Response()
user_partition = self.get_selected_partition()
changed = False
......
......@@ -6,7 +6,7 @@ import lxml
from mock import Mock, patch
from fs.memoryfs import MemoryFS
from xmodule.partitions.tests.test_partitions import StaticPartitionService, PartitionTestCase, MockUserPartitionScheme
from xmodule.partitions.tests.test_partitions import MockPartitionService, PartitionTestCase, MockUserPartitionScheme
from xmodule.tests.xml import factories as xml
from xmodule.tests.xml import XModuleXmlImportTest
from xmodule.tests import get_test_system
......@@ -14,6 +14,7 @@ from xmodule.x_module import AUTHOR_VIEW, STUDENT_VIEW
from xmodule.validation import StudioValidationMessage
from xmodule.split_test_module import SplitTestDescriptor, SplitTestFields, get_split_user_partitions
from xmodule.partitions.partitions import Group, UserPartition
from xmodule.partitions.partitions_service import MINIMUM_STATIC_PARTITION_ID
class SplitTestModuleFactory(xml.XmlImportFactory):
......@@ -81,21 +82,30 @@ class SplitTestModuleTest(XModuleXmlImportTest, PartitionTestCase):
self.module_system.descriptor_runtime = self.course._runtime # pylint: disable=protected-access
self.course.runtime.export_fs = MemoryFS()
user = Mock(username='ma', email='ma@edx.org', is_staff=False, is_active=True)
self.partitions_service = StaticPartitionService(
[
# Create mock partition service, as these tests are running with XML in-memory system.
self.course.user_partitions = [
self.user_partition,
UserPartition(
1, 'second_partition', 'Second Partition',
[Group("0", 'abel'), Group("1", 'baker'), Group("2", 'charlie')],
MINIMUM_STATIC_PARTITION_ID, 'second_partition', 'Second Partition',
[
Group(unicode(MINIMUM_STATIC_PARTITION_ID + 1), 'abel'),
Group(unicode(MINIMUM_STATIC_PARTITION_ID + 2), 'baker'), Group("103", 'charlie')
],
MockUserPartitionScheme()
)
],
user=user,
]
partitions_service = MockPartitionService(
self.course,
course_id=self.course.id,
track_function=Mock(name='track_function'),
)
self.module_system._services['partitions'] = self.partitions_service # pylint: disable=protected-access
self.module_system._services['partitions'] = partitions_service # pylint: disable=protected-access
# Mock user_service user
user_service = Mock()
user = Mock(username='ma', email='ma@edx.org', is_staff=False, is_active=True)
user_service._django_user = user
self.module_system._services['user'] = user_service # pylint: disable=protected-access
self.split_test_module = self.course_sequence.get_children()[0]
self.split_test_module.bind_for_student(
......@@ -103,6 +113,12 @@ class SplitTestModuleTest(XModuleXmlImportTest, PartitionTestCase):
user.id
)
# Create mock modulestore for getting the course. Needed for rendering the HTML
# view, since mock services exist and the rendering code will not short-circuit.
mocked_modulestore = Mock()
mocked_modulestore.get_course.return_value = self.course
self.split_test_module.system.modulestore = mocked_modulestore
@ddt.ddt
class SplitTestModuleLMSTest(SplitTestModuleTest):
......
......@@ -231,18 +231,18 @@ class TestFieldOverrideMongoPerformance(FieldOverridePerformanceTestCase):
# # of sql queries to default,
# # of mongo queries,
# )
('no_overrides', 1, True, False): (22, 6),
('no_overrides', 2, True, False): (22, 6),
('no_overrides', 3, True, False): (22, 6),
('ccx', 1, True, False): (22, 6),
('ccx', 2, True, False): (22, 6),
('ccx', 3, True, False): (22, 6),
('no_overrides', 1, False, False): (22, 6),
('no_overrides', 2, False, False): (22, 6),
('no_overrides', 3, False, False): (22, 6),
('ccx', 1, False, False): (22, 6),
('ccx', 2, False, False): (22, 6),
('ccx', 3, False, False): (22, 6),
('no_overrides', 1, True, False): (24, 6),
('no_overrides', 2, True, False): (24, 6),
('no_overrides', 3, True, False): (24, 6),
('ccx', 1, True, False): (24, 6),
('ccx', 2, True, False): (24, 6),
('ccx', 3, True, False): (24, 6),
('no_overrides', 1, False, False): (24, 6),
('no_overrides', 2, False, False): (24, 6),
('no_overrides', 3, False, False): (24, 6),
('ccx', 1, False, False): (24, 6),
('ccx', 2, False, False): (24, 6),
('ccx', 3, False, False): (24, 6),
}
......@@ -254,19 +254,19 @@ class TestFieldOverrideSplitPerformance(FieldOverridePerformanceTestCase):
__test__ = True
TEST_DATA = {
('no_overrides', 1, True, False): (22, 3),
('no_overrides', 2, True, False): (22, 3),
('no_overrides', 3, True, False): (22, 3),
('ccx', 1, True, False): (22, 3),
('ccx', 2, True, False): (22, 3),
('ccx', 3, True, False): (22, 3),
('ccx', 1, True, True): (23, 3),
('ccx', 2, True, True): (23, 3),
('ccx', 3, True, True): (23, 3),
('no_overrides', 1, False, False): (22, 3),
('no_overrides', 2, False, False): (22, 3),
('no_overrides', 3, False, False): (22, 3),
('ccx', 1, False, False): (22, 3),
('ccx', 2, False, False): (22, 3),
('ccx', 3, False, False): (22, 3),
('no_overrides', 1, True, False): (24, 3),
('no_overrides', 2, True, False): (24, 3),
('no_overrides', 3, True, False): (24, 3),
('ccx', 1, True, False): (24, 3),
('ccx', 2, True, False): (24, 3),
('ccx', 3, True, False): (24, 3),
('ccx', 1, True, True): (25, 3),
('ccx', 2, True, True): (25, 3),
('ccx', 3, True, True): (25, 3),
('no_overrides', 1, False, False): (24, 3),
('no_overrides', 2, False, False): (24, 3),
('no_overrides', 3, False, False): (24, 3),
('ccx', 1, False, False): (24, 3),
('ccx', 2, False, False): (24, 3),
('ccx', 3, False, False): (24, 3),
}
......@@ -146,7 +146,7 @@ class TestGetBlocksQueryCounts(SharedModuleStoreTestCase):
self._get_blocks(
course,
expected_mongo_queries=0,
expected_sql_queries=3 if with_storage_backing else 2,
expected_sql_queries=5 if with_storage_backing else 4,
)
@ddt.data(
......@@ -164,5 +164,5 @@ class TestGetBlocksQueryCounts(SharedModuleStoreTestCase):
self._get_blocks(
course,
expected_mongo_queries,
expected_sql_queries=11 if with_storage_backing else 3,
expected_sql_queries=13 if with_storage_backing else 5,
)
......@@ -5,6 +5,7 @@ from openedx.core.djangoapps.content.block_structure.transformer import (
BlockStructureTransformer,
FilteringTransformerMixin,
)
from xmodule.partitions.partitions_service import get_all_partitions_for_course
from .split_test import SplitTestTransformer
from .utils import get_field_on_block
......@@ -46,11 +47,7 @@ class UserPartitionTransformer(FilteringTransformerMixin, BlockStructureTransfor
# Because user partitions are course-wide, only store data for
# them on the root block.
root_block = block_structure.get_xblock(block_structure.root_block_usage_key)
user_partitions = [
user_partition
for user_partition in getattr(root_block, 'user_partitions', [])
if user_partition.active
]
user_partitions = get_all_partitions_for_course(root_block, active_only=True)
block_structure.set_transformer_data(cls, 'user_partitions', user_partitions)
# If there are no user partitions, this transformation is a
......
......@@ -30,7 +30,6 @@ from xmodule.course_module import (
)
from xmodule.error_module import ErrorDescriptor
from xmodule.x_module import XModule
from xmodule.split_test_module import get_split_user_partitions
from xmodule.partitions.partitions import NoSuchUserPartitionError, NoSuchUserPartitionGroupError
from courseware.access_response import (
......@@ -466,12 +465,6 @@ def _has_group_access(descriptor, user, course_key):
This function returns a boolean indicating whether or not `user` has
sufficient group memberships to "load" a block (the `descriptor`)
"""
if len(descriptor.user_partitions) == len(get_split_user_partitions(descriptor.user_partitions)):
# Short-circuit the process, since there are no defined user partitions that are not
# user_partitions used by the split_test module. The split_test module handles its own access
# via updating the children of the split_test module.
return ACCESS_GRANTED
# Allow staff and instructors roles group access, as they are not masquerading as a student.
if get_user_role(user, course_key) in ['staff', 'instructor']:
return ACCESS_GRANTED
......
......@@ -45,6 +45,7 @@ from xmodule.course_module import (
)
from xmodule.error_module import ErrorDescriptor
from xmodule.partitions.partitions import Group, UserPartition
from xmodule.partitions.partitions_service import MINIMUM_STATIC_PARTITION_ID
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
......@@ -301,9 +302,11 @@ class AccessTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase, MilestonesTes
"""
Test that a user masquerading as a member of a group sees appropriate content in preview mode.
"""
partition_id = 0
group_0_id = 0
group_1_id = 1
# Note about UserPartition and UserPartition Group IDs: these must not conflict with IDs used
# by dynamic user partitions.
partition_id = MINIMUM_STATIC_PARTITION_ID
group_0_id = MINIMUM_STATIC_PARTITION_ID + 1
group_1_id = MINIMUM_STATIC_PARTITION_ID + 2
user_partition = UserPartition(
partition_id, 'Test User Partition', '',
[Group(group_0_id, 'Group 1'), Group(group_1_id, 'Group 2')],
......@@ -314,7 +317,6 @@ class AccessTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase, MilestonesTes
chapter = ItemFactory.create(category="chapter", parent_location=self.course.location)
chapter.group_access = {partition_id: [group_0_id]}
chapter.user_partitions = self.course.user_partitions
modulestore().update_item(self.course, ModuleStoreEnum.UserID.test)
......@@ -431,6 +433,7 @@ class AccessTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase, MilestonesTes
user = Mock()
descriptor = Mock(user_partitions=[])
descriptor._class_tags = {}
descriptor.merged_group_access = {}
# Always returns true because DISABLE_START_DATES is set in test.py
self.assertTrue(access._has_access_descriptor(user, 'load', descriptor))
......@@ -457,6 +460,8 @@ class AccessTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase, MilestonesTes
mock_unit._class_tags = {} # Needed for detached check in _has_access_descriptor
mock_unit.visible_to_staff_only = visible_to_staff_only
mock_unit.start = start
mock_unit.merged_group_access = {}
self.verify_access(mock_unit, expected_access, expected_error_type)
def test__has_access_descriptor_beta_user(self):
......@@ -465,6 +470,7 @@ class AccessTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase, MilestonesTes
mock_unit.days_early_for_beta = 2
mock_unit.start = self.TOMORROW
mock_unit.visible_to_staff_only = False
mock_unit.merged_group_access = {}
self.assertTrue(bool(access._has_access_descriptor(
self.beta_user, 'load', mock_unit, course_key=self.course.id)))
......@@ -480,6 +486,8 @@ class AccessTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase, MilestonesTes
mock_unit._class_tags = {} # Needed for detached check in _has_access_descriptor
mock_unit.visible_to_staff_only = False
mock_unit.start = start
mock_unit.merged_group_access = {}
self.verify_access(mock_unit, True)
@ddt.data(
......@@ -499,6 +507,8 @@ class AccessTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase, MilestonesTes
mock_unit._class_tags = {} # Needed for detached check in _has_access_descriptor
mock_unit.visible_to_staff_only = False
mock_unit.start = start
mock_unit.merged_group_access = {}
self.verify_access(mock_unit, expected_access, expected_error_type)
def test__has_access_course_can_enroll(self):
......
......@@ -406,31 +406,3 @@ class GroupAccessTestCase(ModuleStoreTestCase):
self.check_access(self.blue_dog, block_accessed, False)
self.check_access(self.gray_worm, block_accessed, False)
self.ensure_staff_access(block_accessed)
def test_group_access_short_circuits(self):
"""
Test that the group_access check short-circuits if there are no user_partitions defined
except user_partitions in use by the split_test module.
"""
# Initially, "red_cat" user can't view the vertical.
self.set_group_access(self.chapter_location, {self.animal_partition.id: [self.dog_group.id]})
self.check_access(self.red_cat, self.vertical_location, False)
# Change the vertical's user_partitions value to the empty list. Now red_cat can view the vertical.
self.set_user_partitions(self.vertical_location, [])
self.check_access(self.red_cat, self.vertical_location, True)
# Change the vertical's user_partitions value to include only "split_test" partitions.
split_test_partition = UserPartition(
199,
'split_test partition',
'nothing to look at here',
[Group(2, 'random group')],
scheme=UserPartition.get_scheme("random"),
)
self.set_user_partitions(self.vertical_location, [split_test_partition])
self.check_access(self.red_cat, self.vertical_location, True)
# Finally, add back in a cohort user_partition
self.set_user_partitions(self.vertical_location, [split_test_partition, self.animal_partition])
self.check_access(self.red_cat, self.vertical_location, False)
......@@ -206,7 +206,7 @@ class IndexQueryTestCase(ModuleStoreTestCase):
NUM_PROBLEMS = 20
@ddt.data(
(ModuleStoreEnum.Type.mongo, 9),
(ModuleStoreEnum.Type.mongo, 10),
(ModuleStoreEnum.Type.split, 4),
)
@ddt.unpack
......@@ -1420,17 +1420,17 @@ class ProgressPageTests(ModuleStoreTestCase):
"""Test that query counts remain the same for self-paced and instructor-paced courses."""
SelfPacedConfiguration(enabled=self_paced_enabled).save()
self.setup_course(self_paced=self_paced)
with self.assertNumQueries(39), check_mongo_calls(4):
with self.assertNumQueries(41), check_mongo_calls(4):
self._get_progress_page()
def test_progress_queries(self):
self.setup_course()
with self.assertNumQueries(39), check_mongo_calls(4):
with self.assertNumQueries(41), check_mongo_calls(4):
self._get_progress_page()
# subsequent accesses to the progress page require fewer queries.
for _ in range(2):
with self.assertNumQueries(25), check_mongo_calls(4):
with self.assertNumQueries(27), check_mongo_calls(4):
self._get_progress_page()
@patch(
......
......@@ -178,6 +178,8 @@ class RenderXBlockTestMixin(object):
@ddt.unpack
def test_success_enrolled_staff(self, default_store, mongo_calls):
with self.store.default_store(default_store):
if default_store is ModuleStoreEnum.Type.mongo:
mongo_calls = self.get_success_enrolled_staff_mongo_count()
self.setup_course(default_store)
self.setup_user(admin=True, enroll=True, login=True)
......@@ -197,6 +199,13 @@ class RenderXBlockTestMixin(object):
with check_mongo_calls(mongo_calls):
self.verify_response()
def get_success_enrolled_staff_mongo_count(self):
"""
Helper method used by test_success_enrolled_staff because one test
class using this mixin has an increased number of mongo (only) queries.
"""
return 5
def test_success_unenrolled_staff(self):
self.setup_course()
self.setup_user(admin=True, enroll=False, login=True)
......
......@@ -154,10 +154,10 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest
self.assertEquals(mock_block_structure_create.call_count, 1)
@ddt.data(
(ModuleStoreEnum.Type.mongo, 1, 24, True),
(ModuleStoreEnum.Type.mongo, 1, 21, False),
(ModuleStoreEnum.Type.split, 3, 23, True),
(ModuleStoreEnum.Type.split, 3, 20, False),
(ModuleStoreEnum.Type.mongo, 1, 26, True),
(ModuleStoreEnum.Type.mongo, 1, 23, False),
(ModuleStoreEnum.Type.split, 3, 25, True),
(ModuleStoreEnum.Type.split, 3, 22, False),
)
@ddt.unpack
def test_query_counts(self, default_store, num_mongo_calls, num_sql_calls, create_multiple_subsections):
......@@ -169,8 +169,8 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest
self._apply_recalculate_subsection_grade()
@ddt.data(
(ModuleStoreEnum.Type.mongo, 1, 24),
(ModuleStoreEnum.Type.split, 3, 23),
(ModuleStoreEnum.Type.mongo, 1, 26),
(ModuleStoreEnum.Type.split, 3, 25),
)
@ddt.unpack
def test_query_counts_dont_change_with_more_content(self, default_store, num_mongo_calls, num_sql_calls):
......@@ -215,8 +215,8 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest
)
@ddt.data(
(ModuleStoreEnum.Type.mongo, 1, 9),
(ModuleStoreEnum.Type.split, 3, 8),
(ModuleStoreEnum.Type.mongo, 1, 11),
(ModuleStoreEnum.Type.split, 3, 10),
)
@ddt.unpack
def test_persistent_grades_not_enabled_on_course(self, default_store, num_mongo_queries, num_sql_queries):
......@@ -230,8 +230,8 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest
self.assertEqual(len(PersistentSubsectionGrade.bulk_read_grades(self.user.id, self.course.id)), 0)
@ddt.data(
(ModuleStoreEnum.Type.mongo, 1, 22),
(ModuleStoreEnum.Type.split, 3, 21),
(ModuleStoreEnum.Type.mongo, 1, 24),
(ModuleStoreEnum.Type.split, 3, 23),
)
@ddt.unpack
def test_persistent_grades_enabled_on_course(self, default_store, num_mongo_queries, num_sql_queries):
......@@ -409,8 +409,8 @@ class ComputeGradesForCourseTest(HasCourseWithProblemsMixin, ModuleStoreTestCase
@ddt.data(*xrange(1, 12, 3))
def test_database_calls(self, batch_size):
per_user_queries = 16 * min(batch_size, 6) # No more than 6 due to offset
with self.assertNumQueries(3 + 16 * min(batch_size, 6)):
per_user_queries = 18 * min(batch_size, 6) # No more than 6 due to offset
with self.assertNumQueries(3 + per_user_queries):
with check_mongo_calls(1):
compute_grades_for_course.delay(
course_key=six.text_type(self.course.id),
......
......@@ -54,6 +54,7 @@ from class_dashboard.dashboard_data import get_section_display_name, get_array_s
from .tools import get_units_with_due_date, title_or_url
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.djangoapps.verified_track_content.models import VerifiedTrackCohortedCourse
from openedx.core.djangolib.markup import HTML, Text
......@@ -640,7 +641,6 @@ def _section_send_email(course, access):
if is_course_cohorted(course_key):
cohorts = get_course_cohorts(course)
course_modes = []
from verified_track_content.models import VerifiedTrackCohortedCourse
if not VerifiedTrackCohortedCourse.is_verified_track_cohort_enabled(course_key):
course_modes = CourseMode.modes_for_course(course_key, include_expired=True, only_selectable=False)
email_editor = fragment.content
......
......@@ -30,6 +30,7 @@ from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification
from pytz import UTC
from track import contexts
from xmodule.modulestore.django import modulestore
from xmodule.partitions.partitions_service import PartitionService
from xmodule.split_test_module import get_split_user_partitions
from certificates.api import generate_user_certificates
......@@ -59,7 +60,6 @@ from shoppingcart.models import (
)
from openassessment.data import OraAggregateData
from lms.djangoapps.instructor_task.models import ReportStore, InstructorTask, PROGRESS
from lms.djangoapps.lms_xblock.runtime import LmsPartitionService
from openedx.core.djangoapps.course_groups.cohorts import get_cohort
from openedx.core.djangoapps.course_groups.models import CourseUserGroup
from opaque_keys.edx.keys import UsageKey
......@@ -806,7 +806,7 @@ def upload_grades_csv(_xmodule_instance_args, _entry_id, course_id, _task_input,
group_configs_group_names = []
for partition in experiment_partitions:
group = LmsPartitionService(student, course_id).get_group(partition, assign=False)
group = PartitionService(course_id).get_group(student, partition, assign=False)
group_configs_group_names.append(group.name if group else '')
team_name = []
......
......@@ -1775,7 +1775,7 @@ class TestCertificateGeneration(InstructorTaskModuleTestCase):
'failed': 3,
'skipped': 2
}
with self.assertNumQueries(168):
with self.assertNumQueries(184):
self.assertCertificatesGenerated(task_input, expected_results)
expected_results = {
......
......@@ -5,6 +5,7 @@ Namespace that defines fields common to all blocks used in the LMS
#from django.utils.translation import ugettext_noop as _
from lazy import lazy
from xblock.core import XBlock
from xblock.fields import Boolean, Scope, String, XBlockMixin, Dict
from xblock.validation import ValidationMessage
from xmodule.modulestore.inheritance import UserPartitionList
......@@ -26,6 +27,7 @@ class GroupAccessDict(Dict):
return {unicode(k): access_dict[k] for k in access_dict}
@XBlock.needs('partitions')
class LmsBlockMixin(XBlockMixin):
"""
Mixin that defines fields common to all blocks used in the LMS
......@@ -128,10 +130,10 @@ class LmsBlockMixin(XBlockMixin):
def _get_user_partition(self, user_partition_id):
"""
Returns the user partition with the specified id. Raises
`NoSuchUserPartitionError` if the lookup fails.
Returns the user partition with the specified id. Note that this method can return
an inactive user partition. Raises `NoSuchUserPartitionError` if the lookup fails.
"""
for user_partition in self.user_partitions:
for user_partition in self.runtime.service(self, 'partitions').course_partitions:
if user_partition.id == user_partition_id:
return user_partition
......
......@@ -80,22 +80,6 @@ def local_resource_url(block, uri):
return xblock_local_resource_url(block, uri)
class LmsPartitionService(PartitionService):
"""
Another runtime mixin that provides access to the student partitions defined on the
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 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)
return course.user_partitions
class UserTagsService(object):
"""
A runtime class that provides an interface to the user service. It handles filling in
......@@ -154,8 +138,7 @@ class LmsModuleSystem(ModuleSystem): # pylint: disable=abstract-method
services['fs'] = xblock.reference.plugins.FSService()
services['i18n'] = ModuleI18nService
services['library_tools'] = LibraryToolsService(modulestore())
services['partitions'] = LmsPartitionService(
user=kwargs.get('user'),
services['partitions'] = PartitionService(
course_id=kwargs.get('course_id'),
track_function=kwargs.get('track_function', None),
cache=request_cache_dict
......
......@@ -170,6 +170,8 @@ class LtiLaunchTestRender(LtiTestMixin, RenderXBlockTestMixin, ModuleStoreTestCa
This class overrides the get_response method, which is used by
the tests defined in RenderXBlockTestMixin.
"""
SUCCESS_ENROLLED_STAFF_MONGO_COUNT = 9
def setUp(self):
"""
Set up tests
......@@ -212,3 +214,21 @@ class LtiLaunchTestRender(LtiTestMixin, RenderXBlockTestMixin, ModuleStoreTestCa
self.setup_course()
self.setup_user(admin=False, enroll=True, login=False)
self.verify_response()
def get_success_enrolled_staff_mongo_count(self):
"""
Override because mongo queries are higher for this
particular test. This has not been investigated exhaustively
as mongo is no longer used much, and removing user_partitions
from inheritance fixes the problem.
# The 9 mongoDB calls include calls for
# Old Mongo:
# (1) fill_in_run
# (2) get_course in get_course_with_access
# (3) get_item for HTML block in get_module_by_usage_id
# (4) get_parent when loading HTML block
# (5)-(8) calls related to the inherited user_partitions field.
# (9) edx_notes descriptor call to get_course
"""
return 9
......@@ -146,6 +146,8 @@ FEATURES['AUTOMATIC_AUTH_FOR_TESTING'] = True
# Open up endpoint for faking Software Secure responses
FEATURES['ENABLE_SOFTWARE_SECURE_FAKE'] = True
FEATURES['ENABLE_ENROLLMENT_TRACK_USER_PARTITION'] = True
########################### Entrance Exams #################################
FEATURES['ENTRANCE_EXAMS'] = True
......
......@@ -371,6 +371,9 @@ FEATURES = {
# Enable footer banner for cookie consent.
# See https://cookieconsent.insites.com/ for more.
'ENABLE_COOKIE_CONSENT': False,
# Whether or not the dynamic EnrollmentTrackUserPartition should be registered.
'ENABLE_ENROLLMENT_TRACK_USER_PARTITION': False,
}
# Ignore static asset files on import which match this pattern
......@@ -2146,8 +2149,8 @@ INSTALLED_APPS = (
# API access administration
'openedx.core.djangoapps.api_admin',
# Verified Track Content Cohorting
'verified_track_content',
# Verified Track Content Cohorting (Beta feature that will hopefully be removed)
'openedx.core.djangoapps.verified_track_content',
# Learner's dashboard
'learner_dashboard',
......@@ -3068,3 +3071,13 @@ DOC_LINK_BASE_URL = None
ENTERPRISE_ENROLLMENT_API_URL = LMS_ROOT_URL + "/api/enrollment/v1/"
ENTERPRISE_PUBLIC_ENROLLMENT_API_URL = ENTERPRISE_ENROLLMENT_API_URL
ENTERPRISE_API_CACHE_TIMEOUT = 3600 # Value is in seconds
############## Settings for Course Enrollment Modes ######################
COURSE_ENROLLMENT_MODES = {
"audit": 1,
"verified": 2,
"professional": 3,
"no-id-professional": 4,
"credit": 5,
"honor": 6,
}
......@@ -78,6 +78,8 @@ FEATURES['ENABLE_COMBINED_LOGIN_REGISTRATION'] = True
# Enable the milestones app in tests to be consistent with it being enabled in production
FEATURES['MILESTONES_APP'] = True
FEATURES['ENABLE_ENROLLMENT_TRACK_USER_PARTITION'] = True
# Need wiki for courseware views to work. TODO (vshnayder): shouldn't need it.
WIKI_ENABLED = True
......
......@@ -553,7 +553,7 @@ urlpatterns += (
r'^courses/{}/verified_track_content/settings'.format(
settings.COURSE_KEY_PATTERN,
),
'verified_track_content.views.cohorting_settings',
'openedx.core.djangoapps.verified_track_content.views.cohorting_settings',
name='verified_track_cohorting',
),
url(
......
......@@ -141,7 +141,7 @@ def _set_verification_partitions(course_key, icrv_blocks):
log.error("Could not find course %s", course_key)
return []
verified_partitions = course.get_user_partitions_for_scheme(scheme)
verified_partitions = [p for p in course.user_partitions if p.scheme == scheme]
partition_id_for_location = {
p.parameters["location"]: p.id
for p in verified_partitions
......
......@@ -4,8 +4,8 @@ Django admin page for verified track configuration
from django.contrib import admin
from verified_track_content.forms import VerifiedTrackCourseForm
from verified_track_content.models import VerifiedTrackCohortedCourse
from openedx.core.djangoapps.verified_track_content.forms import VerifiedTrackCourseForm
from openedx.core.djangoapps.verified_track_content.models import VerifiedTrackCohortedCourse
@admin.register(VerifiedTrackCohortedCourse)
......
......@@ -9,7 +9,7 @@ from xmodule.modulestore.django import modulestore
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from verified_track_content.models import VerifiedTrackCohortedCourse
from openedx.core.djangoapps.verified_track_content.models import VerifiedTrackCohortedCourse
class VerifiedTrackCourseForm(forms.ModelForm):
......
......@@ -8,9 +8,9 @@ from django.db.models.signals import post_save, pre_save
from openedx.core.djangoapps.xmodule_django.models import CourseKeyField
from student.models import CourseEnrollment
from courseware.courses import get_course_by_id
from lms.djangoapps.courseware.courses import get_course_by_id
from verified_track_content.tasks import sync_cohort_with_mode
from openedx.core.djangoapps.verified_track_content.tasks import sync_cohort_with_mode
from openedx.core.djangoapps.course_groups.cohorts import (
get_course_cohorts, CourseCohort, is_course_cohorted, get_random_cohort
)
......
"""
UserPartitionScheme for enrollment tracks.
"""
from django.conf import settings
from courseware.masquerade import (
get_course_masquerade,
get_masquerading_group_info,
is_masquerading_as_specific_student,
)
from course_modes.models import CourseMode
from student.models import CourseEnrollment
from opaque_keys.edx.keys import CourseKey
from openedx.core.djangoapps.verified_track_content.models import VerifiedTrackCohortedCourse
from xmodule.partitions.partitions import NoSuchUserPartitionGroupError, Group, UserPartition
# These IDs must be less than 100 so that they do not overlap with Groups in
# CohortUserPartition or RandomUserPartitionScheme
# (CMS' course_group_config uses a minimum value of 100 for all generated IDs).
ENROLLMENT_GROUP_IDS = settings.COURSE_ENROLLMENT_MODES
class EnrollmentTrackUserPartition(UserPartition):
"""
Extends UserPartition to support dynamic groups pulled from the current course Enrollment tracks.
"""
@property
def groups(self):
"""
Return the groups (based on CourseModes) for the course associated with this
EnrollmentTrackUserPartition instance.
If a course is using the Verified Track Cohorting pilot feature, this method
returns an empty array regardless of registered CourseModes.
"""
course_key = CourseKey.from_string(self.parameters["course_id"])
if is_course_using_cohort_instead(course_key):
return []
return [
Group(ENROLLMENT_GROUP_IDS[mode.slug], unicode(mode.name))
for mode in CourseMode.modes_for_course(course_key, include_expired=True, only_selectable=False)
]
def to_json(self):
"""
Because this partition is dynamic, to_json and from_json are not supported.
Calling this method will raise a TypeError.
"""
raise TypeError("Because EnrollmentTrackUserPartition is a dynamic partition, 'to_json' is not supported.")
def from_json(self):
"""
Because this partition is dynamic, to_json and from_json are not supported.
Calling this method will raise a TypeError.
"""
raise TypeError("Because EnrollmentTrackUserPartition is a dynamic partition, 'from_json' is not supported.")
class EnrollmentTrackPartitionScheme(object):
"""
This scheme uses learner enrollment tracks to map learners into partition groups.
"""
@classmethod
def get_group_for_user(cls, course_key, user, user_partition, **kwargs): # pylint: disable=unused-argument
"""
Returns the Group from the specified user partition to which the user
is assigned, via enrollment mode.
If a course is using the Verified Track Cohorting pilot feature, this method
returns None regardless of the user's enrollment mode.
"""
if is_course_using_cohort_instead(course_key):
return None
# NOTE: masquerade code was copied from CohortPartitionScheme, and it may need
# some changes (or if not, code should be refactored out and shared).
# This work will be done in a future story TNL-6739.
# First, check if we have to deal with masquerading.
# If the current user is masquerading as a specific student, use the
# same logic as normal to return that student's group. If the current
# user is masquerading as a generic student in a specific group, then
# return that group.
if get_course_masquerade(user, course_key) and not is_masquerading_as_specific_student(user, course_key):
group_id, user_partition_id = get_masquerading_group_info(user, course_key)
if user_partition_id == user_partition.id and group_id is not None:
try:
return user_partition.get_group(group_id)
except NoSuchUserPartitionGroupError:
return None
# The user is masquerading as a generic student. We can't show any particular group.
return None
mode_slug, is_active = CourseEnrollment.enrollment_mode_for_user(user, course_key)
if mode_slug and is_active:
course_mode = CourseMode.mode_for_course(
course_key,
mode_slug,
modes=CourseMode.modes_for_course(course_key, include_expired=True, only_selectable=False),
)
if not course_mode:
course_mode = CourseMode.DEFAULT_MODE
return Group(ENROLLMENT_GROUP_IDS[course_mode.slug], unicode(course_mode.name))
else:
return None
@classmethod
def create_user_partition(cls, id, name, description, groups=None, parameters=None, active=True): # pylint: disable=redefined-builtin, invalid-name, unused-argument
"""
Create a custom UserPartition to support dynamic groups.
A Partition has an id, name, scheme, description, parameters, 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). The scheme is used to assign users into groups.
The parameters field is used to save extra parameters e.g., location of
the course ID for this partition scheme.
Partitions can be marked as inactive by setting the "active" flag to False.
Any group access rule referencing inactive partitions will be ignored
when performing access checks.
"""
return EnrollmentTrackUserPartition(id, name, description, [], cls, parameters, active)
def is_course_using_cohort_instead(course_key):
"""
Returns whether the given course_context is using verified-track cohorts
and therefore shouldn't use a track-based partition.
"""
return VerifiedTrackCohortedCourse.is_verified_track_cohort_enabled(course_key)
......@@ -4,7 +4,7 @@ Test for forms helpers.
from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from verified_track_content.forms import VerifiedTrackCourseForm
from openedx.core.djangoapps.verified_track_content.forms import VerifiedTrackCourseForm
class TestVerifiedTrackCourseForm(SharedModuleStoreTestCase):
......
"""
Tests for Verified Track Cohorting models
"""
# pylint: disable=attribute-defined-outside-init
# pylint: disable=no-member
from django.test import TestCase
import mock
from mock import patch
......@@ -12,11 +15,12 @@ from student.models import CourseMode
from student.tests.factories import UserFactory, CourseEnrollmentFactory
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from verified_track_content.models import VerifiedTrackCohortedCourse, DEFAULT_VERIFIED_COHORT_NAME
from verified_track_content.tasks import sync_cohort_with_mode
from ..models import VerifiedTrackCohortedCourse, DEFAULT_VERIFIED_COHORT_NAME
from ..tasks import sync_cohort_with_mode
from openedx.core.djangoapps.course_groups.cohorts import (
set_course_cohort_settings, add_cohort, CourseCohort, DEFAULT_COHORT_NAME
)
from openedx.core.djangolib.testing.utils import skip_unless_lms
class TestVerifiedTrackCohortedCourse(TestCase):
......@@ -48,13 +52,13 @@ class TestVerifiedTrackCohortedCourse(TestCase):
self.assertEqual(unicode(config), "Course: {}, enabled: True".format(self.SAMPLE_COURSE))
def test_verified_cohort_name(self):
COHORT_NAME = 'verified cohort'
cohort_name = 'verified cohort'
course_key = CourseKey.from_string(self.SAMPLE_COURSE)
config = VerifiedTrackCohortedCourse.objects.create(
course_key=course_key, enabled=True, verified_cohort_name=COHORT_NAME
course_key=course_key, enabled=True, verified_cohort_name=cohort_name
)
config.save()
self.assertEqual(VerifiedTrackCohortedCourse.verified_cohort_name_for_course(course_key), COHORT_NAME)
self.assertEqual(VerifiedTrackCohortedCourse.verified_cohort_name_for_course(course_key), cohort_name)
def test_unset_verified_cohort_name(self):
fake_course_id = 'fake/course/key'
......@@ -62,6 +66,7 @@ class TestVerifiedTrackCohortedCourse(TestCase):
self.assertEqual(VerifiedTrackCohortedCourse.verified_cohort_name_for_course(course_key), None)
@skip_unless_lms
class TestMoveToVerified(SharedModuleStoreTestCase):
""" Tests for the post-save listener. """
......@@ -82,12 +87,15 @@ class TestMoveToVerified(SharedModuleStoreTestCase):
self.addCleanup(celery_task_patcher.stop)
def _enable_cohorting(self):
""" Turn on cohorting in the course. """
set_course_cohort_settings(self.course.id, is_cohorted=True)
def _create_verified_cohort(self, name=DEFAULT_VERIFIED_COHORT_NAME):
""" Create a verified cohort. """
add_cohort(self.course.id, name, CourseCohort.MANUAL)
def _create_named_random_cohort(self, name):
""" Create a random cohort with the supplied name. """
return add_cohort(self.course.id, name, CourseCohort.RANDOM)
def _enable_verified_track_cohorting(self, cohort_name=None):
......@@ -101,6 +109,7 @@ class TestMoveToVerified(SharedModuleStoreTestCase):
config.save()
def _enroll_in_course(self):
""" Enroll self.user in self.course. """
self.enrollment = CourseEnrollmentFactory(course_id=self.course.id, user=self.user)
def _upgrade_to_verified(self):
......@@ -108,6 +117,7 @@ class TestMoveToVerified(SharedModuleStoreTestCase):
self.enrollment.update_enrollment(mode=CourseMode.VERIFIED)
def _verify_no_automatic_cohorting(self):
""" Check that upgrading self.user to verified does not move them into a cohort. """
self._enroll_in_course()
self.assertIsNone(get_cohort(self.user, self.course.id, assign=False))
self._upgrade_to_verified()
......@@ -115,13 +125,15 @@ class TestMoveToVerified(SharedModuleStoreTestCase):
self.assertEqual(0, self.mocked_celery_task.call_count)
def _unenroll(self):
""" Unenroll self.user from self.course. """
self.enrollment.unenroll(self.user, self.course.id)
def _reenroll(self):
""" Re-enroll the learner into mode AUDIT. """
self.enrollment.activate()
self.enrollment.change_mode(CourseMode.AUDIT)
@mock.patch('verified_track_content.models.log.error')
@mock.patch('openedx.core.djangoapps.verified_track_content.models.log.error')
def test_automatic_cohorting_disabled(self, error_logger):
"""
If the VerifiedTrackCohortedCourse feature is disabled for a course, enrollment mode changes do not move
......@@ -136,7 +148,7 @@ class TestMoveToVerified(SharedModuleStoreTestCase):
# No logging occurs if feature is disabled for course.
self.assertFalse(error_logger.called)
@mock.patch('verified_track_content.models.log.error')
@mock.patch('openedx.core.djangoapps.verified_track_content.models.log.error')
def test_cohorting_enabled_course_not_cohorted(self, error_logger):
"""
If the VerifiedTrackCohortedCourse feature is enabled for a course, but the course is not cohorted,
......@@ -149,7 +161,7 @@ class TestMoveToVerified(SharedModuleStoreTestCase):
self.assertTrue(error_logger.called)
self.assertIn("course is not cohorted", error_logger.call_args[0][0])
@mock.patch('verified_track_content.models.log.error')
@mock.patch('openedx.core.djangoapps.verified_track_content.models.log.error')
def test_cohorting_enabled_missing_verified_cohort(self, error_logger):
"""
If the VerifiedTrackCohortedCourse feature is enabled for a course and the course is cohorted,
......
"""
Tests for verified_track_content/partition_scheme.py.
"""
from datetime import datetime, timedelta
import pytz
from ..partition_scheme import EnrollmentTrackPartitionScheme, EnrollmentTrackUserPartition, ENROLLMENT_GROUP_IDS
from ..models import VerifiedTrackCohortedCourse
from course_modes.models import CourseMode
from student.models import CourseEnrollment
from student.tests.factories import UserFactory
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.partitions.partitions import UserPartition
from xmodule.partitions.partitions_service import MINIMUM_STATIC_PARTITION_ID
class EnrollmentTrackUserPartitionTest(SharedModuleStoreTestCase):
"""
Tests for the custom EnrollmentTrackUserPartition (dynamic groups).
"""
@classmethod
def setUpClass(cls):
super(EnrollmentTrackUserPartitionTest, cls).setUpClass()
cls.course = CourseFactory.create()
def test_only_default_mode(self):
partition = create_enrollment_track_partition(self.course)
groups = partition.groups
self.assertEqual(1, len(groups))
self.assertEqual("Audit", groups[0].name)
def test_using_verified_track_cohort(self):
VerifiedTrackCohortedCourse.objects.create(course_key=self.course.id, enabled=True).save()
partition = create_enrollment_track_partition(self.course)
self.assertEqual(0, len(partition.groups))
def test_multiple_groups(self):
create_mode(self.course, CourseMode.AUDIT, "Audit Enrollment Track", min_price=0)
# Note that the verified mode is expired-- this is intentional.
create_mode(
self.course, CourseMode.VERIFIED, "Verified Enrollment Track", min_price=1,
expiration_datetime=datetime.now(pytz.UTC) + timedelta(days=-1)
)
# Note that the credit mode is not selectable-- this is intentional.
create_mode(self.course, CourseMode.CREDIT_MODE, "Credit Mode", min_price=2)
partition = create_enrollment_track_partition(self.course)
groups = partition.groups
self.assertEqual(3, len(groups))
self.assertIsNotNone(self.get_group_by_name(partition, "Audit Enrollment Track"))
self.assertIsNotNone(self.get_group_by_name(partition, "Verified Enrollment Track"))
self.assertIsNotNone(self.get_group_by_name(partition, "Credit Mode"))
def test_to_json_not_supported(self):
user_partition = create_enrollment_track_partition(self.course)
with self.assertRaises(TypeError):
user_partition.to_json()
def test_from_json_not_supported(self):
with self.assertRaises(TypeError):
EnrollmentTrackUserPartition.from_json()
def test_group_ids(self):
"""
Test that group IDs are all less than MINIMUM_STATIC_PARTITION_ID (to avoid overlapping
with group IDs associated with cohort and random user partitions).
"""
for mode in ENROLLMENT_GROUP_IDS:
self.assertLess(ENROLLMENT_GROUP_IDS[mode], MINIMUM_STATIC_PARTITION_ID)
@staticmethod
def get_group_by_name(partition, name):
"""
Return the group in the EnrollmentTrackUserPartition with the given name.
If no such group exists, returns `None`.
"""
for group in partition.groups:
if group.name == name:
return group
return None
class EnrollmentTrackPartitionSchemeTest(SharedModuleStoreTestCase):
"""
Tests for EnrollmentTrackPartitionScheme.
"""
@classmethod
def setUpClass(cls):
super(EnrollmentTrackPartitionSchemeTest, cls).setUpClass()
cls.course = CourseFactory.create()
cls.student = UserFactory()
def test_get_scheme(self):
"""
Ensure that the scheme extension is correctly plugged in (via entry point in setup.py)
"""
self.assertEquals(UserPartition.get_scheme('enrollment_track'), EnrollmentTrackPartitionScheme)
def test_create_user_partition(self):
user_partition = UserPartition.get_scheme('enrollment_track').create_user_partition(
301, "partition", "test partition", parameters={"course_id": unicode(self.course.id)}
)
self.assertEqual(type(user_partition), EnrollmentTrackUserPartition)
self.assertEqual(user_partition.name, "partition")
groups = user_partition.groups
self.assertEqual(1, len(groups))
self.assertEqual("Audit", groups[0].name)
def test_not_enrolled(self):
self.assertIsNone(self._get_user_group())
def test_default_enrollment(self):
CourseEnrollment.enroll(self.student, self.course.id)
self.assertEqual("Audit", self._get_user_group().name)
def test_enrolled_in_nonexistent_mode(self):
CourseEnrollment.enroll(self.student, self.course.id, mode=CourseMode.VERIFIED)
self.assertEqual("Audit", self._get_user_group().name)
def test_enrolled_in_verified(self):
create_mode(self.course, CourseMode.VERIFIED, "Verified Enrollment Track", min_price=1)
CourseEnrollment.enroll(self.student, self.course.id, mode=CourseMode.VERIFIED)
self.assertEqual("Verified Enrollment Track", self._get_user_group().name)
def test_enrolled_in_expired(self):
create_mode(
self.course, CourseMode.VERIFIED, "Verified Enrollment Track",
min_price=1, expiration_datetime=datetime.now(pytz.UTC) + timedelta(days=-1)
)
CourseEnrollment.enroll(self.student, self.course.id, mode=CourseMode.VERIFIED)
self.assertEqual("Verified Enrollment Track", self._get_user_group().name)
def test_enrolled_in_non_selectable(self):
create_mode(self.course, CourseMode.CREDIT_MODE, "Credit Enrollment Track", min_price=1)
CourseEnrollment.enroll(self.student, self.course.id, mode=CourseMode.CREDIT_MODE)
self.assertEqual("Credit Enrollment Track", self._get_user_group().name)
def test_using_verified_track_cohort(self):
VerifiedTrackCohortedCourse.objects.create(course_key=self.course.id, enabled=True).save()
CourseEnrollment.enroll(self.student, self.course.id)
self.assertIsNone(self._get_user_group())
def _get_user_group(self):
"""
Gets the group the user is assigned to.
"""
user_partition = create_enrollment_track_partition(self.course)
return user_partition.scheme.get_group_for_user(self.course.id, self.student, user_partition)
def create_enrollment_track_partition(course):
"""
Create an EnrollmentTrackUserPartition instance for the given course.
"""
enrollment_track_scheme = UserPartition.get_scheme("enrollment_track")
partition = enrollment_track_scheme.create_user_partition(
id=1,
name="TestEnrollment Track Partition",
description="Test partition for segmenting users by enrollment track",
parameters={"course_id": unicode(course.id)}
)
return partition
def create_mode(course, mode_slug, mode_name, min_price=0, expiration_datetime=None):
"""
Create a new course mode for the given course.
"""
return CourseMode.objects.get_or_create(
course_id=course.id,
mode_display_name=mode_name,
mode_slug=mode_slug,
min_price=min_price,
suggested_prices='',
_expiration_datetime=expiration_datetime,
currency='usd'
)
......@@ -5,22 +5,21 @@ Tests for verified track content views.
import json
from nose.plugins.attrib import attr
from unittest import skipUnless
from django.http import Http404
from django.test.client import RequestFactory
from django.conf import settings
from openedx.core.djangolib.testing.utils import skip_unless_lms
from student.tests.factories import UserFactory, AdminFactory
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from verified_track_content.models import VerifiedTrackCohortedCourse
from verified_track_content.views import cohorting_settings
from ..models import VerifiedTrackCohortedCourse
from ..views import cohorting_settings
@attr(shard=2)
@skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Tests only valid in LMS')
@skip_unless_lms
class CohortingSettingsTestCase(SharedModuleStoreTestCase):
"""
Tests the `cohort_discussion_topics` view.
......@@ -65,6 +64,7 @@ class CohortingSettingsTestCase(SharedModuleStoreTestCase):
self._verify_cohort_settings_response(expected_response)
def _verify_cohort_settings_response(self, expected_response):
""" Verify that the response was successful and matches the expected JSON payload. """
request = RequestFactory().get("dummy_url")
request.user = AdminFactory()
response = cohorting_settings(request, unicode(self.course.id))
......
......@@ -6,9 +6,9 @@ from util.json_request import expect_json, JsonResponse
from django.contrib.auth.decorators import login_required
from opaque_keys.edx.keys import CourseKey
from courseware.courses import get_course_with_access
from lms.djangoapps.courseware.courses import get_course_with_access
from verified_track_content.models import VerifiedTrackCohortedCourse
from openedx.core.djangoapps.verified_track_content.models import VerifiedTrackCohortedCourse
@expect_json
......
......@@ -42,6 +42,7 @@ setup(
"random = openedx.core.djangoapps.user_api.partition_schemes:RandomUserPartitionScheme",
"cohort = openedx.core.djangoapps.course_groups.partition_scheme:CohortPartitionScheme",
"verification = openedx.core.djangoapps.credit.partition_schemes:VerificationPartitionScheme",
"enrollment_track = openedx.core.djangoapps.verified_track_content.partition_scheme:EnrollmentTrackPartitionScheme",
],
"openedx.block_structure_transformer": [
"library_content = lms.djangoapps.course_blocks.transformers.library_content:ContentLibraryTransformer",
......
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