refactor: move xmodule folder to root

- Moving xmodule folder to root as we're dissolving sub-projects of common folder in edx-platform
    - More info: https://openedx.atlassian.net/browse/BOM-2579
- -e common/lib/xmodule has been removed from the requirements as xmodule has itself become the part of edx-platform and not being installed through requirements
- The test files common/lib/xmodule/test_files/ have been removed as they are not being used anymore
This commit is contained in:
M Umar Khan
2022-05-19 21:40:48 +05:00
parent 26c8ec5c2a
commit a91df0c40f
487 changed files with 216 additions and 339 deletions

View File

View File

@@ -0,0 +1,62 @@
"""
The enrollment_track dynamic partition generation to be part of the
openedx.dynamic_partition plugin.
"""
import logging
from django.conf import settings
from django.utils.translation import gettext_lazy as _
from xmodule.partitions.partitions import (
get_partition_from_id,
ENROLLMENT_TRACK_PARTITION_ID,
UserPartition,
UserPartitionError
)
log = logging.getLogger(__name__)
FEATURES = getattr(settings, 'FEATURES', {})
def create_enrollment_track_partition_with_course_id(course_id):
"""
Create and return the dynamic enrollment track user partition based only on course_id.
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
partition = enrollment_track_scheme.create_user_partition(
id=ENROLLMENT_TRACK_PARTITION_ID,
name=_("Enrollment Track Groups"),
description=_("Partition for segmenting users by enrollment track"),
parameters={"course_id": str(course_id)}
)
return partition
def create_enrollment_track_partition(course):
"""
Create and return the dynamic enrollment track user partition.
If it cannot be created, None is returned.
"""
used_ids = {p.id for p in course.user_partitions}
if ENROLLMENT_TRACK_PARTITION_ID in used_ids:
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=str(course.id)
)
)
return None
return create_enrollment_track_partition_with_course_id(course.id)

View File

@@ -0,0 +1,297 @@
"""Defines ``Group`` and ``UserPartition`` models for partitioning"""
from collections import namedtuple
from stevedore.extension import ExtensionManager
# We use ``id`` in this file as the IDs of our Groups and UserPartitions,
# which Pylint disapproves of.
# pylint: disable=redefined-builtin
# 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
class UserPartitionError(Exception):
"""
Base Exception for when an error was found regarding user partitions.
"""
pass # lint-amnesty, pylint: disable=unnecessary-pass
class NoSuchUserPartitionError(UserPartitionError):
"""
Exception to be raised when looking up a UserPartition by its ID fails.
"""
pass # lint-amnesty, pylint: disable=unnecessary-pass
class NoSuchUserPartitionGroupError(UserPartitionError):
"""
Exception to be raised when looking up a UserPartition Group by its ID fails.
"""
pass # lint-amnesty, pylint: disable=unnecessary-pass
class ReadOnlyUserPartitionError(UserPartitionError):
"""
Exception to be raised when attempting to modify a read only partition.
"""
pass # lint-amnesty, pylint: disable=unnecessary-pass
class Group(namedtuple("Group", "id name")):
"""
An id and name for a group of students. The id should be unique
within the UserPartition this group appears in.
"""
# in case we want to add to this class, a version will be handy
# for deserializing old versions. (This will be serialized in courses)
VERSION = 1
def __new__(cls, id, name):
return super().__new__(cls, int(id), name)
def to_json(self):
"""
'Serialize' to a json-serializable representation.
Returns:
a dictionary with keys for the properties of the group.
"""
return {
"id": self.id,
"name": self.name,
"version": Group.VERSION
}
@staticmethod
def from_json(value):
"""
Deserialize a Group from a json-like representation.
Args:
value: a dictionary with keys for the properties of the group.
Raises TypeError if the value doesn't have the right keys.
"""
if isinstance(value, Group):
return value
for key in ("id", "name", "version"):
if key not in value:
raise TypeError("Group dict {} missing value key '{}'".format(
value, key))
if value["version"] != Group.VERSION:
raise TypeError("Group dict {} has unexpected version".format(
value))
return Group(value["id"], value["name"])
# The Stevedore extension point namespace for user partition scheme plugins.
USER_PARTITION_SCHEME_NAMESPACE = 'openedx.user_partition_scheme'
class UserPartition(namedtuple("UserPartition", "id name description groups scheme parameters active")):
"""A named way to partition users into groups, primarily intended for
running experiments. It is expected that each user will be in at most one
group in a partition.
A Partition has an id, name, 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 block in case of VerificationPartitionScheme.
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.
"""
VERSION = 3
# The collection of user partition scheme extensions.
scheme_extensions = None
# The default scheme to be used when upgrading version 1 partitions.
VERSION_1_SCHEME = "random"
def __new__(cls, id, name, description, groups, scheme=None, parameters=None, active=True,
scheme_id=VERSION_1_SCHEME):
if not scheme:
scheme = UserPartition.get_scheme(scheme_id)
if parameters is None:
parameters = {}
return super().__new__(cls, int(id), name, description, groups, scheme, parameters, active)
@staticmethod
def get_scheme(name):
"""
Returns the user partition scheme with the given name.
"""
# Note: we're creating the extension manager lazily to ensure that the Python path
# has been correctly set up. Trying to create this statically will fail, unfortunately.
if not UserPartition.scheme_extensions:
UserPartition.scheme_extensions = ExtensionManager(namespace=USER_PARTITION_SCHEME_NAMESPACE)
try:
scheme = UserPartition.scheme_extensions[name].plugin # lint-amnesty, pylint: disable=unsubscriptable-object
except KeyError:
raise UserPartitionError(f"Unrecognized scheme '{name}'") # lint-amnesty, pylint: disable=raise-missing-from
scheme.name = name
return scheme
def to_json(self):
"""
'Serialize' to a json-serializable representation.
Returns:
a dictionary with keys for the properties of the partition.
"""
return {
"id": self.id,
"name": self.name,
"scheme": self.scheme.name,
"description": self.description,
"parameters": self.parameters,
"groups": [g.to_json() for g in self.groups],
"active": bool(self.active),
"version": UserPartition.VERSION
}
@staticmethod
def from_json(value):
"""
Deserialize a Group from a json-like representation.
Args:
value: a dictionary with keys for the properties of the group.
Raises TypeError if the value doesn't have the right keys.
"""
if isinstance(value, UserPartition):
return value
for key in ("id", "name", "description", "version", "groups"):
if key not in value:
raise TypeError(f"UserPartition dict {value} missing value key '{key}'")
if value["version"] == 1:
# If no scheme was provided, set it to the default ('random')
scheme_id = UserPartition.VERSION_1_SCHEME
# Version changes should be backwards compatible in case the code
# gets rolled back. If we see a version number greater than the current
# version, we should try to read it rather than raising an exception.
elif value["version"] >= 2:
if "scheme" not in value:
raise TypeError(f"UserPartition dict {value} missing value key 'scheme'")
scheme_id = value["scheme"]
else:
raise TypeError(f"UserPartition dict {value} has unexpected version")
parameters = value.get("parameters", {})
active = value.get("active", True)
groups = [Group.from_json(g) for g in value["groups"]]
scheme = UserPartition.get_scheme(scheme_id)
if not scheme:
raise TypeError(f"UserPartition dict {value} has unrecognized scheme {scheme_id}")
if getattr(scheme, 'read_only', False):
raise ReadOnlyUserPartitionError(f"UserPartition dict {value} uses scheme {scheme_id} which is read only") # lint-amnesty, pylint: disable=line-too-long
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"],
value["description"],
groups,
scheme,
parameters,
active,
)
def get_group(self, group_id):
"""
Returns the group with the specified id.
Arguments:
group_id (int): ID of the partition group.
Raises:
NoSuchUserPartitionGroupError: The specified group could not be found.
"""
for group in self.groups:
if group.id == group_id:
return group
raise NoSuchUserPartitionGroupError(
"Could not find a Group with ID [{group_id}] in UserPartition [{partition_id}].".format(
group_id=group_id, partition_id=self.id
)
)
def access_denied_message(self, block_key, user, user_group, allowed_groups): # lint-amnesty, pylint: disable=unused-argument
"""
Return a message that should be displayed to the user when they are not allowed to access
content managed by this partition, or None if there is no applicable message.
Arguments:
block_key (:class:`.BlockUsageLocator`): The content being managed
user (:class:`.User`): The user who was denied access
user_group (:class:`.Group`): The current Group the user is in
allowed_groups (list of :class:`.Group`): The groups who are allowed to see the content
Returns: str
"""
return None
def access_denied_fragment(self, block, user, user_group, allowed_groups): # lint-amnesty, pylint: disable=unused-argument
"""
Return an html fragment that should be displayed to the user when they are not allowed to access
content managed by this partition, or None if there is no applicable message.
Arguments:
block (:class:`.XBlock`): The content being managed
user (:class:`.User`): The user who was denied access
user_group (:class:`.Group`): The current Group the user is in
allowed_groups (list of :class:`.Group`): The groups who are allowed to see the content
Returns: :class:`.Fragment`
"""
return None
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

View File

@@ -0,0 +1,176 @@
"""
This is a service-like API that assigns tracks which groups users are in for various
user partitions. It uses the user_service key/value store provided by the LMS runtime to
persist the assignments.
"""
import logging
from typing import Dict
from django.conf import settings
from django.contrib.auth import get_user_model
from openedx.core.lib.cache_utils import request_cached
from openedx.core.lib.dynamic_partitions_generators import DynamicPartitionGeneratorsPluginManager
from xmodule.modulestore.django import modulestore
from xmodule.partitions.partitions import get_partition_from_id
from .partitions import Group
User = get_user_model()
log = logging.getLogger(__name__)
FEATURES = getattr(settings, 'FEATURES', {})
@request_cached()
def get_all_partitions_for_course(course, active_only=False):
"""
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.
"""
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
def get_user_partition_groups(course_key: str, user_partitions: list, user: User,
partition_dict_key: str = 'name') -> Dict[str, Group]:
"""
Collect group ID for each partition in this course for this user.
Arguments:
course_key (CourseKey)
user_partitions (list[UserPartition])
user (User)
partition_dict_key - i.e. 'id', 'name' depending on which partition attribute you want as a key.
Returns:
dict[partition_dict_key: Group]: Mapping from user partitions to the group to
which the user belongs in each partition. If the user isn't
in a group for a particular partition, then that partition's
ID will not be in the dict.
"""
partition_groups = {}
for partition in user_partitions:
group = partition.scheme.get_group_for_user(
course_key,
user,
partition,
)
if group is not None:
partition_groups[getattr(partition, partition_dict_key)] = group
return partition_groups
def _get_dynamic_partitions(course):
"""
Return the dynamic user partitions for this course.
If none exists, returns an empty array.
"""
dynamic_partition_generators = DynamicPartitionGeneratorsPluginManager.get_available_plugins().values()
generated_partitions = []
for generator in dynamic_partition_generators:
generated_partition = generator(course)
if generated_partition:
generated_partitions.append(generated_partition)
return generated_partitions
class PartitionService:
"""
This is an XBlock service that returns information about the user partitions associated
with a given course.
"""
def __init__(self, course_id, cache=None, course=None):
self._course_id = course_id
self._cache = cache
self.course = course
def get_course(self):
"""
Return the course instance associated with this PartitionService.
This default implementation looks up the course from the modulestore.
"""
return self.course or 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.
If not, assign them to one of the groups, persist that decision, and
return the group_id.
Args:
user_partition_id -- an id of a partition that's hopefully in the
runtime.user_partitions list.
Returns:
The id of one of the groups in the specified user_partition_id (as a string).
Raises:
ValueError if the user_partition_id isn't found.
"""
cache_key = "PartitionService.ugidfp.{}.{}.{}".format(
user.id, self._course_id, user_partition_id
)
if self._cache and (cache_key in self._cache):
return self._cache[cache_key]
user_partition = self.get_user_partition(user_partition_id)
if user_partition is None:
raise ValueError(
"Configuration problem! No user_partition with id {} "
"in course {}".format(user_partition_id, self._course_id)
)
group = self.get_group(user, user_partition)
group_id = group.id if group else None
if self._cache is not None:
self._cache[cache_key] = group_id
return group_id
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.
"""
return get_partition_from_id(self.course_partitions, user_partition_id)
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, user, user_partition, assign=assign,
)

View File

View File

@@ -0,0 +1,603 @@
"""
Test the partitions and partitions service
"""
from datetime import datetime
from unittest.mock import Mock
import pytest
from django.test import TestCase
from opaque_keys.edx.locator import CourseLocator
from stevedore.extension import Extension, ExtensionManager
from openedx.features.content_type_gating.models import ContentTypeGatingConfig
from xmodule.partitions.partitions import (
ENROLLMENT_TRACK_PARTITION_ID,
USER_PARTITION_SCHEME_NAMESPACE,
Group,
NoSuchUserPartitionGroupError,
UserPartition,
UserPartitionError
)
from xmodule.partitions.partitions_service import FEATURES, PartitionService, get_all_partitions_for_course
class TestGroup(TestCase):
"""Test constructing groups"""
def test_construct(self):
test_id = 10
name = "Grendel"
group = Group(test_id, name)
assert group.id == test_id
assert group.name == name
def test_string_id(self):
test_id = "10"
name = "Grendel"
group = Group(test_id, name)
assert group.id == 10
def test_to_json(self):
test_id = 10
name = "Grendel"
group = Group(test_id, name)
jsonified = group.to_json()
act_jsonified = {
"id": test_id,
"name": name,
"version": group.VERSION
}
assert jsonified == act_jsonified
def test_from_json(self):
test_id = 5
name = "Grendel"
jsonified = {
"id": test_id,
"name": name,
"version": Group.VERSION
}
group = Group.from_json(jsonified)
assert group.id == test_id
assert group.name == name
def test_from_json_broken(self):
test_id = 5
name = "Grendel"
# Bad version
jsonified = {
"id": test_id,
"name": name,
"version": -1,
}
with self.assertRaisesRegex(TypeError, "has unexpected version"):
Group.from_json(jsonified)
# Missing key "id"
jsonified = {
"name": name,
"version": Group.VERSION
}
with self.assertRaisesRegex(TypeError, "missing value key 'id'"):
Group.from_json(jsonified)
# Has extra key - should not be a problem
jsonified = {
"id": test_id,
"name": name,
"version": Group.VERSION,
"programmer": "Cale"
}
group = Group.from_json(jsonified)
assert 'programmer' not in group.to_json()
class MockUserPartitionScheme:
"""
Mock user partition scheme
"""
def __init__(self, name="mock", current_group=None, **kwargs):
super().__init__(**kwargs)
self.name = name
self.current_group = current_group
def get_group_for_user(self, course_id, user, user_partition, assign=True): # pylint: disable=unused-argument
"""
Returns the current group if set, else the first group from the specified user partition.
"""
if self.current_group:
return self.current_group
groups = user_partition.groups
if not groups or len(groups) == 0:
return None
return groups[0]
class MockEnrollmentTrackUserPartitionScheme(MockUserPartitionScheme): # lint-amnesty, pylint: disable=missing-class-docstring
def create_user_partition(self, id, name, description, groups=None, parameters=None, active=True): # pylint: disable=redefined-builtin, invalid-name
"""
The EnrollmentTrackPartitionScheme provides this method to return a subclass of UserPartition.
"""
return UserPartition(id, name, description, groups, self, parameters, active)
class PartitionTestCase(TestCase):
"""Base class for test cases that require partitions"""
TEST_ID = 0
TEST_NAME = "Mock Partition"
TEST_DESCRIPTION = "for testing purposes"
TEST_PARAMETERS = {"location": "block-v1:edX+DemoX+Demo+type@block@uuid"}
TEST_GROUPS = [Group(0, 'Group 1'), Group(1, 'Group 2')]
TEST_SCHEME_NAME = "mock"
ENROLLMENT_TRACK_SCHEME_NAME = "enrollment_track"
def setUp(self):
super().setUp()
# Set up two user partition schemes: mock and random
self.non_random_scheme = MockUserPartitionScheme(self.TEST_SCHEME_NAME)
self.random_scheme = MockUserPartitionScheme("random")
self.enrollment_track_scheme = MockEnrollmentTrackUserPartitionScheme(self.ENROLLMENT_TRACK_SCHEME_NAME)
extensions = [
Extension(
self.non_random_scheme.name, USER_PARTITION_SCHEME_NAMESPACE, self.non_random_scheme, None
),
Extension(
self.random_scheme.name, USER_PARTITION_SCHEME_NAMESPACE, self.random_scheme, None
),
Extension(
self.enrollment_track_scheme.name, USER_PARTITION_SCHEME_NAMESPACE, self.enrollment_track_scheme, None
),
]
UserPartition.scheme_extensions = ExtensionManager.make_test_instance(
extensions, namespace=USER_PARTITION_SCHEME_NAMESPACE
)
# Be sure to clean up the global scheme_extensions after the test.
self.addCleanup(self.cleanup_scheme_extensions)
# Create a test partition
self.user_partition = UserPartition(
self.TEST_ID,
self.TEST_NAME,
self.TEST_DESCRIPTION,
self.TEST_GROUPS,
extensions[0].plugin,
self.TEST_PARAMETERS,
)
# Make sure the names are set on the schemes (which happens normally in code, but may not happen in tests).
self.user_partition.get_scheme(self.non_random_scheme.name)
self.user_partition.get_scheme(self.random_scheme.name)
def cleanup_scheme_extensions(self):
"""
Unset the UserPartition.scheme_extensions cache.
"""
UserPartition.scheme_extensions = None
class TestUserPartition(PartitionTestCase):
"""Test constructing UserPartitions"""
def test_construct(self):
user_partition = UserPartition(
self.TEST_ID,
self.TEST_NAME,
self.TEST_DESCRIPTION,
self.TEST_GROUPS,
MockUserPartitionScheme(),
self.TEST_PARAMETERS,
)
assert user_partition.id == self.TEST_ID
assert user_partition.name == self.TEST_NAME
assert user_partition.description == self.TEST_DESCRIPTION
assert user_partition.groups == self.TEST_GROUPS
assert user_partition.scheme.name == self.TEST_SCHEME_NAME
assert user_partition.parameters == self.TEST_PARAMETERS
def test_string_id(self):
user_partition = UserPartition(
"70",
self.TEST_NAME,
self.TEST_DESCRIPTION,
self.TEST_GROUPS,
MockUserPartitionScheme(),
self.TEST_PARAMETERS,
)
assert user_partition.id == 70
def test_to_json(self):
jsonified = self.user_partition.to_json()
act_jsonified = {
"id": self.TEST_ID,
"name": self.TEST_NAME,
"description": self.TEST_DESCRIPTION,
"parameters": self.TEST_PARAMETERS,
"groups": [group.to_json() for group in self.TEST_GROUPS],
"version": self.user_partition.VERSION,
"scheme": self.TEST_SCHEME_NAME,
"active": True,
}
assert jsonified == act_jsonified
def test_from_json(self):
jsonified = {
"id": self.TEST_ID,
"name": self.TEST_NAME,
"description": self.TEST_DESCRIPTION,
"parameters": self.TEST_PARAMETERS,
"groups": [group.to_json() for group in self.TEST_GROUPS],
"version": UserPartition.VERSION,
"scheme": "mock",
}
user_partition = UserPartition.from_json(jsonified)
assert user_partition.id == self.TEST_ID
assert user_partition.name == self.TEST_NAME
assert user_partition.description == self.TEST_DESCRIPTION
assert user_partition.parameters == self.TEST_PARAMETERS
for act_group in user_partition.groups:
assert act_group.id in [0, 1]
exp_group = self.TEST_GROUPS[act_group.id]
assert exp_group.id == act_group.id
assert exp_group.name == act_group.name
def test_version_upgrade(self):
# Test that version 1 partitions did not have a scheme specified
# and have empty parameters
jsonified = {
"id": self.TEST_ID,
"name": self.TEST_NAME,
"description": self.TEST_DESCRIPTION,
"groups": [group.to_json() for group in self.TEST_GROUPS],
"version": 1,
}
user_partition = UserPartition.from_json(jsonified)
assert user_partition.scheme.name == 'random'
assert user_partition.parameters == {}
assert user_partition.active
def test_version_upgrade_2_to_3(self):
# Test that version 3 user partition raises error if 'scheme' field is
# not provided (same behavior as version 2)
jsonified = {
'id': self.TEST_ID,
"name": self.TEST_NAME,
"description": self.TEST_DESCRIPTION,
"parameters": self.TEST_PARAMETERS,
"groups": [group.to_json() for group in self.TEST_GROUPS],
"version": 2,
}
with self.assertRaisesRegex(TypeError, "missing value key 'scheme'"):
UserPartition.from_json(jsonified)
# Test that version 3 partitions have a scheme specified
# and a field 'parameters' (optional while setting user partition but
# always present in response)
jsonified = {
"id": self.TEST_ID,
"name": self.TEST_NAME,
"description": self.TEST_DESCRIPTION,
"groups": [group.to_json() for group in self.TEST_GROUPS],
"version": 2,
"scheme": self.TEST_SCHEME_NAME,
}
user_partition = UserPartition.from_json(jsonified)
assert user_partition.scheme.name == self.TEST_SCHEME_NAME
assert user_partition.parameters == {}
assert user_partition.active
# now test that parameters dict is present in response with same value
# as provided
jsonified = {
"id": self.TEST_ID,
"name": self.TEST_NAME,
"description": self.TEST_DESCRIPTION,
"groups": [group.to_json() for group in self.TEST_GROUPS],
"parameters": self.TEST_PARAMETERS,
"version": 3,
"scheme": self.TEST_SCHEME_NAME,
}
user_partition = UserPartition.from_json(jsonified)
assert user_partition.parameters == self.TEST_PARAMETERS
assert user_partition.active
def test_from_json_broken(self):
# Missing field
jsonified = {
"name": self.TEST_NAME,
"description": self.TEST_DESCRIPTION,
"parameters": self.TEST_PARAMETERS,
"groups": [group.to_json() for group in self.TEST_GROUPS],
"version": UserPartition.VERSION,
"scheme": self.TEST_SCHEME_NAME,
}
with self.assertRaisesRegex(TypeError, "missing value key 'id'"):
UserPartition.from_json(jsonified)
# Missing scheme
jsonified = {
'id': self.TEST_ID,
"name": self.TEST_NAME,
"description": self.TEST_DESCRIPTION,
"parameters": self.TEST_PARAMETERS,
"groups": [group.to_json() for group in self.TEST_GROUPS],
"version": UserPartition.VERSION,
}
with self.assertRaisesRegex(TypeError, "missing value key 'scheme'"):
UserPartition.from_json(jsonified)
# Invalid scheme
jsonified = {
'id': self.TEST_ID,
"name": self.TEST_NAME,
"description": self.TEST_DESCRIPTION,
"parameters": self.TEST_PARAMETERS,
"groups": [group.to_json() for group in self.TEST_GROUPS],
"version": UserPartition.VERSION,
"scheme": "no_such_scheme",
}
with self.assertRaisesRegex(UserPartitionError, "Unrecognized scheme"):
UserPartition.from_json(jsonified)
# Wrong version
jsonified = {
'id': self.TEST_ID,
"name": self.TEST_NAME,
"description": self.TEST_DESCRIPTION,
"parameters": self.TEST_PARAMETERS,
"groups": [group.to_json() for group in self.TEST_GROUPS],
"version": -1,
"scheme": self.TEST_SCHEME_NAME,
}
with self.assertRaisesRegex(TypeError, "has unexpected version"):
UserPartition.from_json(jsonified)
# Has extra key - should not be a problem
jsonified = {
'id': self.TEST_ID,
"name": self.TEST_NAME,
"description": self.TEST_DESCRIPTION,
"parameters": self.TEST_PARAMETERS,
"groups": [group.to_json() for group in self.TEST_GROUPS],
"version": UserPartition.VERSION,
"scheme": "mock",
"programmer": "Cale",
}
user_partition = UserPartition.from_json(jsonified)
assert 'programmer' not in user_partition.to_json()
# No error on missing parameters key (which is optional)
jsonified = {
'id': self.TEST_ID,
"name": self.TEST_NAME,
"description": self.TEST_DESCRIPTION,
"groups": [group.to_json() for group in self.TEST_GROUPS],
"version": UserPartition.VERSION,
"scheme": "mock",
}
user_partition = UserPartition.from_json(jsonified)
assert user_partition.parameters == {}
def test_get_group(self):
"""
UserPartition.get_group correctly returns the group referenced by the
`group_id` parameter, or raises NoSuchUserPartitionGroupError when
the lookup fails.
"""
assert self.user_partition.get_group(self.TEST_GROUPS[0].id) == self.TEST_GROUPS[0]
assert self.user_partition.get_group(self.TEST_GROUPS[1].id) == self.TEST_GROUPS[1]
with pytest.raises(NoSuchUserPartitionGroupError):
self.user_partition.get_group(3)
def test_forward_compatibility(self):
# If the user partition version is updated in a release,
# then the release is rolled back, courses might contain
# version numbers greater than the currently deployed
# version number.
newer_version_json = {
"id": self.TEST_ID,
"name": self.TEST_NAME,
"description": self.TEST_DESCRIPTION,
"groups": [group.to_json() for group in self.TEST_GROUPS],
"version": UserPartition.VERSION + 1,
"scheme": "mock",
"additional_new_field": "foo",
}
partition = UserPartition.from_json(newer_version_json)
assert partition.id == self.TEST_ID
assert partition.name == self.TEST_NAME
class MockPartitionService(PartitionService):
"""
Mock PartitionService for testing.
"""
def __init__(self, course, **kwargs):
super().__init__(**kwargs)
self._course = course
def get_course(self):
return self._course
class PartitionServiceBaseClass(PartitionTestCase):
"""
Base test class for testing the PartitionService.
"""
def setUp(self):
super().setUp()
ContentTypeGatingConfig.objects.create(
enabled=True,
enabled_as_of=datetime(2018, 1, 1),
studio_override_enabled=True
)
self.course = Mock(id=CourseLocator('org_0', 'course_0', 'run_0'))
self.partition_service = self._create_service("ma")
def _create_service(self, username, cache=None):
"""Convenience method to generate a MockPartitionService for a user."""
# Derive a "user_id" from the username, just so we don't have to add an
# extra param to this method. Just has to be unique per user.
user_id = abs(hash(username))
self.user = Mock(
username=username, email=f'{username}@edx.org', is_staff=False, is_active=True, id=user_id
)
self.course.user_partitions = [self.user_partition]
return MockPartitionService(
self.course,
course_id=self.course.id,
cache=cache
)
class TestPartitionService(PartitionServiceBaseClass):
"""
Test getting a user's group out of a partition
"""
def test_get_user_group_id_for_partition(self):
# assign the first group to be returned
user_partition_id = self.user_partition.id
groups = self.user_partition.groups
self.user_partition.scheme.current_group = groups[0]
# get a group assigned to the user
group1_id = self.partition_service.get_user_group_id_for_partition(self.user, user_partition_id)
assert group1_id == groups[0].id
# switch to the second group and verify that it is returned for the user
self.user_partition.scheme.current_group = groups[1]
group2_id = self.partition_service.get_user_group_id_for_partition(self.user, user_partition_id)
assert group2_id == groups[1].id
def test_caching(self):
username = "psvc_cache_user"
user_partition_id = self.user_partition.id
shared_cache = {}
# Two MockPartitionService objects that share the same cache:
ps_shared_cache_1 = self._create_service(username, shared_cache)
ps_shared_cache_2 = self._create_service(username, shared_cache)
# A MockPartitionService with its own local cache
ps_diff_cache = self._create_service(username, {})
# A MockPartitionService that never uses caching.
ps_uncached = self._create_service(username)
# Set the group we expect users to be placed into
first_group = self.user_partition.groups[0]
self.user_partition.scheme.current_group = first_group
# Make sure our partition services all return the right thing, but skip
# ps_shared_cache_2 so we can see if its cache got updated anyway.
for part_svc in [ps_shared_cache_1, ps_diff_cache, ps_uncached]:
assert first_group.id == part_svc.get_user_group_id_for_partition(self.user, user_partition_id)
# Now select a new target group
second_group = self.user_partition.groups[1]
self.user_partition.scheme.current_group = second_group
# Both of the shared cache entries should return the old value, even
# ps_shared_cache_2, which was never asked for the value the first time
# Likewise, our separately cached piece should return the original answer
for part_svc in [ps_shared_cache_1, ps_shared_cache_2, ps_diff_cache]:
assert first_group.id == part_svc.get_user_group_id_for_partition(self.user, user_partition_id)
# Our uncached service should be accurate.
assert second_group.id == ps_uncached.get_user_group_id_for_partition(self.user, user_partition_id)
# And a newly created service should see the right thing
ps_new_cache = self._create_service(username, {})
assert second_group.id == ps_new_cache.get_user_group_id_for_partition(self.user, user_partition_id)
def test_get_group(self):
"""
Test that a partition group is assigned to a user.
"""
groups = self.user_partition.groups
# assign first group and verify that it is returned for the user
self.user_partition.scheme.current_group = groups[0]
group1 = self.partition_service.get_group(self.user, self.user_partition)
assert group1 == groups[0]
# switch to the second group and verify that it is returned for the user
self.user_partition.scheme.current_group = groups[1]
group2 = self.partition_service.get_group(self.user, self.user_partition)
assert group2 == groups[1]
class TestGetCourseUserPartitions(PartitionServiceBaseClass):
"""
Test the helper method get_all_partitions_for_course.
"""
def setUp(self):
super().setUp()
TestGetCourseUserPartitions._enable_enrollment_track_partition(True)
@staticmethod
def _enable_enrollment_track_partition(enable):
"""
Enable or disable the feature flag for the enrollment track user partition.
"""
FEATURES['ENABLE_ENROLLMENT_TRACK_USER_PARTITION'] = enable
def test_enrollment_track_partition_not_added_if_conflict(self):
"""
Test that the dynamic enrollment track scheme is NOT added if a UserPartition exists with that ID.
"""
self.user_partition = UserPartition(
ENROLLMENT_TRACK_PARTITION_ID,
self.TEST_NAME,
self.TEST_DESCRIPTION,
self.TEST_GROUPS,
self.non_random_scheme,
self.TEST_PARAMETERS,
)
self.course.user_partitions = [self.user_partition]
all_partitions = get_all_partitions_for_course(self.course)
assert 1 == len(all_partitions)
assert self.TEST_SCHEME_NAME == all_partitions[0].scheme.name
def test_enrollment_track_partition_not_added_if_disabled(self):
"""
Test that the dynamic enrollment track scheme is NOT added if the settings FEATURE flag is disabled.
"""
TestGetCourseUserPartitions._enable_enrollment_track_partition(False)
all_partitions = get_all_partitions_for_course(self.course)
assert 1 == len(all_partitions)
assert self.TEST_SCHEME_NAME == all_partitions[0].scheme.name
def test_filter_inactive_user_partitions(self):
"""
Tests supplying the `active_only` parameter.
"""
self.user_partition = UserPartition(
self.TEST_ID,
self.TEST_NAME,
self.TEST_DESCRIPTION,
self.TEST_GROUPS,
self.non_random_scheme,
self.TEST_PARAMETERS,
active=False
)
self.course.user_partitions = [self.user_partition]
all_partitions = get_all_partitions_for_course(self.course, active_only=True)
assert 1 == len(all_partitions)
assert self.ENROLLMENT_TRACK_SCHEME_NAME == all_partitions[0].scheme.name
all_partitions = get_all_partitions_for_course(self.course, active_only=False)
assert 2 == len(all_partitions)
assert self.TEST_SCHEME_NAME == all_partitions[0].scheme.name
assert self.ENROLLMENT_TRACK_SCHEME_NAME == all_partitions[1].scheme.name