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:
0
xmodule/partitions/__init__.py
Normal file
0
xmodule/partitions/__init__.py
Normal file
62
xmodule/partitions/enrollment_track_partition_generator.py
Normal file
62
xmodule/partitions/enrollment_track_partition_generator.py
Normal 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)
|
||||
297
xmodule/partitions/partitions.py
Normal file
297
xmodule/partitions/partitions.py
Normal 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
|
||||
176
xmodule/partitions/partitions_service.py
Normal file
176
xmodule/partitions/partitions_service.py
Normal 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,
|
||||
)
|
||||
0
xmodule/partitions/tests/__init__.py
Normal file
0
xmodule/partitions/tests/__init__.py
Normal file
603
xmodule/partitions/tests/test_partitions.py
Normal file
603
xmodule/partitions/tests/test_partitions.py
Normal 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
|
||||
Reference in New Issue
Block a user