feat: connect teams with content groups using dynamic partition generator (#33788)
Implements the connection from the teams feature to the content groups feature. This implementation uses the dynamic partition generator extension point to associate content groups with the users that belong to a Team. This implementation was heavily inspired by the enrollment tracks dynamic partitions.
This commit is contained in:
@@ -226,8 +226,8 @@ class TestGetBlocksQueryCounts(TestGetBlocksQueryCountsBase):
|
||||
)
|
||||
|
||||
@ddt.data(
|
||||
(ModuleStoreEnum.Type.split, 2, True, 23),
|
||||
(ModuleStoreEnum.Type.split, 2, False, 13),
|
||||
(ModuleStoreEnum.Type.split, 2, True, 24),
|
||||
(ModuleStoreEnum.Type.split, 2, False, 14),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_query_counts_uncached(self, store_type, expected_mongo_queries, with_storage_backing, num_sql_queries):
|
||||
|
||||
@@ -286,7 +286,7 @@ class TestGradeIteration(SharedModuleStoreTestCase):
|
||||
else mock_course_grade.return_value
|
||||
for student in self.students
|
||||
]
|
||||
with self.assertNumQueries(8):
|
||||
with self.assertNumQueries(11):
|
||||
all_course_grades, all_errors = self._course_grades_and_errors_for(self.course, self.students)
|
||||
assert {student: str(all_errors[student]) for student in all_errors} == {
|
||||
student3: 'Error for student3.',
|
||||
|
||||
@@ -406,7 +406,7 @@ class TestInstructorGradeReport(InstructorGradeReportTestCase):
|
||||
|
||||
with patch('lms.djangoapps.instructor_task.tasks_helper.runner._get_current_task'):
|
||||
with check_mongo_calls(2):
|
||||
with self.assertNumQueries(50):
|
||||
with self.assertNumQueries(53):
|
||||
CourseGradeReport.generate(None, None, course.id, {}, 'graded')
|
||||
|
||||
def test_inactive_enrollments(self):
|
||||
|
||||
147
lms/djangoapps/teams/team_partition_scheme.py
Normal file
147
lms/djangoapps/teams/team_partition_scheme.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""
|
||||
Provides a UserPartition driver for teams.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
|
||||
from lms.djangoapps.courseware.masquerade import (
|
||||
get_course_masquerade,
|
||||
get_masquerading_user_group,
|
||||
is_masquerading_as_specific_student
|
||||
)
|
||||
from lms.djangoapps.teams.api import get_teams_in_teamset
|
||||
from lms.djangoapps.teams.models import CourseTeamMembership
|
||||
from openedx.core.lib.teams_config import CONTENT_GROUPS_FOR_TEAMS
|
||||
|
||||
from xmodule.partitions.partitions import ( # lint-amnesty, pylint: disable=wrong-import-order
|
||||
Group,
|
||||
UserPartition
|
||||
)
|
||||
from xmodule.services import TeamsConfigurationService
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TeamUserPartition(UserPartition):
|
||||
"""Extends UserPartition to support dynamic groups pulled from the current
|
||||
course teams.
|
||||
"""
|
||||
|
||||
@property
|
||||
def groups(self):
|
||||
"""Dynamically generate groups (based on teams) for this partition.
|
||||
|
||||
Returns:
|
||||
list of Group: The groups in this partition.
|
||||
"""
|
||||
course_key = CourseKey.from_string(self.parameters["course_id"])
|
||||
if not CONTENT_GROUPS_FOR_TEAMS.is_enabled(course_key):
|
||||
return []
|
||||
|
||||
# Get the team-set for this partition via the partition parameters and then get the teams in that team-set
|
||||
# to create the groups for this partition.
|
||||
team_sets = TeamsConfigurationService().get_teams_configuration(course_key).teamsets
|
||||
team_set_id = self.parameters["team_set_id"]
|
||||
team_set = next((team_set for team_set in team_sets if team_set.teamset_id == team_set_id), None)
|
||||
teams = get_teams_in_teamset(str(course_key), team_set.teamset_id)
|
||||
return [
|
||||
Group(team.id, str(team.name)) for team in teams
|
||||
]
|
||||
|
||||
|
||||
class TeamPartitionScheme:
|
||||
"""Uses course team memberships to map learners into partition groups.
|
||||
|
||||
The scheme is only available if the CONTENT_GROUPS_FOR_TEAMS feature flag is enabled.
|
||||
|
||||
This is how it works:
|
||||
- A user partition is created for each team-set in the course with a unused partition ID generated in runtime
|
||||
by using generate_int_id() with min=MINIMUM_STATIC_PARTITION_ID and max=MYSQL_MAX_INT.
|
||||
- A (Content) group is created for each team in the team-set with the database team ID as the group ID,
|
||||
and the team name as the group name.
|
||||
- A user is assigned to a group if they are a member of the team.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def get_group_for_user(cls, course_key, user, user_partition):
|
||||
"""Get the (Content) Group from the specified user partition for the user.
|
||||
|
||||
A user is assigned to the group via their team membership and any mappings from teams to
|
||||
partitions / groups that might exist.
|
||||
|
||||
Args:
|
||||
course_key (CourseKey): The course key.
|
||||
user (User): The user.
|
||||
user_partition (UserPartition): The user partition.
|
||||
|
||||
Returns:
|
||||
Group: The group in the specified user partition
|
||||
"""
|
||||
if not CONTENT_GROUPS_FOR_TEAMS.is_enabled(course_key):
|
||||
return None
|
||||
|
||||
# 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):
|
||||
return get_masquerading_user_group(course_key, user, user_partition)
|
||||
|
||||
# A user cannot belong to more than one team in a team-set by definition, so we can just get the first team.
|
||||
teams = get_teams_in_teamset(str(course_key), user_partition.parameters["team_set_id"])
|
||||
team_ids = [team.team_id for team in teams]
|
||||
user_team = CourseTeamMembership.get_memberships(user.username, [str(course_key)], team_ids).first()
|
||||
if not user_team:
|
||||
return None
|
||||
|
||||
return Group(user_team.team.id, str(user_team.team.name))
|
||||
|
||||
@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 based on teams.
|
||||
|
||||
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.
|
||||
|
||||
Args:
|
||||
id (int): The id of the partition.
|
||||
name (str): The name of the partition.
|
||||
description (str): The description of the partition.
|
||||
groups (list of Group): The groups in the partition.
|
||||
parameters (dict): The parameters for the partition.
|
||||
active (bool): Whether the partition is active.
|
||||
|
||||
Returns:
|
||||
TeamUserPartition: The user partition.
|
||||
"""
|
||||
course_key = CourseKey.from_string(parameters["course_id"])
|
||||
if not CONTENT_GROUPS_FOR_TEAMS.is_enabled(course_key):
|
||||
return None
|
||||
|
||||
# Team-set used to create partition was created before this feature was
|
||||
# introduced. In that case, we need to create a new partition with a
|
||||
# new team-set id.
|
||||
if not id:
|
||||
return None
|
||||
|
||||
team_set_partition = TeamUserPartition(
|
||||
id,
|
||||
str(name),
|
||||
str(description),
|
||||
groups,
|
||||
cls,
|
||||
parameters,
|
||||
active=True,
|
||||
)
|
||||
return team_set_partition
|
||||
211
lms/djangoapps/teams/tests/test_partition_scheme.py
Normal file
211
lms/djangoapps/teams/tests/test_partition_scheme.py
Normal file
@@ -0,0 +1,211 @@
|
||||
"""
|
||||
Test the partitions and partitions services. The partitions tested
|
||||
in this file are the following:
|
||||
- TeamPartitionScheme
|
||||
"""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from common.djangoapps.student.tests.factories import UserFactory
|
||||
from lms.djangoapps.teams.tests.factories import CourseTeamFactory
|
||||
from lms.djangoapps.teams.team_partition_scheme import TeamPartitionScheme
|
||||
from openedx.core.lib.teams_config import create_team_set_partitions_with_course_id
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from xmodule.modulestore.tests.factories import ToyCourseFactory
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.partitions.partitions import Group
|
||||
|
||||
|
||||
@patch(
|
||||
"lms.djangoapps.teams.team_partition_scheme.CONTENT_GROUPS_FOR_TEAMS.is_enabled",
|
||||
lambda _: True
|
||||
)
|
||||
class TestTeamPartitionScheme(ModuleStoreTestCase):
|
||||
"""
|
||||
Test the TeamPartitionScheme partition scheme and its related functions.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Regenerate a course with teams configuration, partition and groups,
|
||||
and a student for each test.
|
||||
"""
|
||||
super().setUp()
|
||||
self.course_key = ToyCourseFactory.create().id
|
||||
self.course = modulestore().get_course(self.course_key)
|
||||
self.student = UserFactory.create()
|
||||
self.student.courseenrollment_set.create(course_id=self.course_key, is_active=True)
|
||||
self.team_sets = [
|
||||
MagicMock(name="1st TeamSet", teamset_id=1, user_partition_id=51),
|
||||
MagicMock(name="2nd TeamSet", teamset_id=2, user_partition_id=52),
|
||||
]
|
||||
|
||||
@patch("lms.djangoapps.teams.team_partition_scheme.TeamsConfigurationService")
|
||||
def test_create_user_partition_with_course_id(self, mock_teams_configuration_service):
|
||||
"""
|
||||
Test that create_user_partition returns the correct user partitions for the input data.
|
||||
|
||||
Expected result:
|
||||
- There's a user partition matching the ID given.
|
||||
"""
|
||||
mock_teams_configuration_service().get_teams_configuration.return_value.teamsets = self.team_sets
|
||||
|
||||
partition = TeamPartitionScheme.create_user_partition(
|
||||
id=self.team_sets[0].user_partition_id,
|
||||
name=f"Team Group: {self.team_sets[0].name}",
|
||||
description="Partition for segmenting users by team-set",
|
||||
parameters={
|
||||
"course_id": str(self.course_key),
|
||||
"team_set_id": self.team_sets[0].teamset_id,
|
||||
}
|
||||
)
|
||||
|
||||
assert partition.id == self.team_sets[0].user_partition_id
|
||||
|
||||
def test_team_partition_generator(self):
|
||||
"""
|
||||
Test that create_team_set_partition returns the correct user partitions for the input data.
|
||||
|
||||
Expected result:
|
||||
- The user partitions are created based on the team sets.
|
||||
"""
|
||||
partitions = create_team_set_partitions_with_course_id(self.course_key, self.team_sets)
|
||||
|
||||
assert partitions == [
|
||||
TeamPartitionScheme.create_user_partition(
|
||||
id=self.team_sets[0].user_partition_id,
|
||||
name=f"Team Group: {self.team_sets[0].name}",
|
||||
description="Partition for segmenting users by team-set",
|
||||
parameters={
|
||||
"course_id": str(self.course_key),
|
||||
"team_set_id": self.team_sets[0].teamset_id,
|
||||
}
|
||||
),
|
||||
TeamPartitionScheme.create_user_partition(
|
||||
id=self.team_sets[1].user_partition_id,
|
||||
name=f"Team Group: {self.team_sets[1].name}",
|
||||
description="Partition for segmenting users by team-set",
|
||||
parameters={
|
||||
"course_id": str(self.course_key),
|
||||
"team_set_id": self.team_sets[1].teamset_id,
|
||||
}
|
||||
),
|
||||
]
|
||||
|
||||
@patch("lms.djangoapps.teams.team_partition_scheme.TeamsConfigurationService")
|
||||
def test_get_partition_groups(self, mock_teams_configuration_service):
|
||||
"""
|
||||
Test that the TeamPartitionScheme returns the correct groups for a team set.
|
||||
|
||||
Expected result:
|
||||
- The groups in the partition match the teams in the team set.
|
||||
"""
|
||||
mock_teams_configuration_service().get_teams_configuration.return_value.teamsets = self.team_sets
|
||||
team_1 = CourseTeamFactory.create(
|
||||
name="Team 1 in TeamSet",
|
||||
course_id=self.course_key,
|
||||
topic_id=self.team_sets[0].teamset_id,
|
||||
)
|
||||
team_2 = CourseTeamFactory.create(
|
||||
name="Team 2 in TeamSet",
|
||||
course_id=self.course_key,
|
||||
topic_id=self.team_sets[0].teamset_id,
|
||||
)
|
||||
team_partition_scheme = TeamPartitionScheme.create_user_partition(
|
||||
id=self.team_sets[0].user_partition_id,
|
||||
name=f"Team Group: {self.team_sets[0].name}",
|
||||
description="Partition for segmenting users by team-set",
|
||||
parameters={
|
||||
"course_id": str(self.course_key),
|
||||
"team_set_id": self.team_sets[0].teamset_id,
|
||||
}
|
||||
)
|
||||
|
||||
assert team_partition_scheme.groups == [
|
||||
Group(team_1.id, str(team_1.name)),
|
||||
Group(team_2.id, str(team_2.name)),
|
||||
]
|
||||
|
||||
@patch("lms.djangoapps.teams.team_partition_scheme.TeamsConfigurationService")
|
||||
def test_get_group_for_user(self, mock_teams_configuration_service):
|
||||
"""
|
||||
Test that the TeamPartitionScheme returns the correct group for a
|
||||
student in a team when the team is linked to a partition group.
|
||||
|
||||
Expected result:
|
||||
- The group returned matches the team the student is in.
|
||||
"""
|
||||
mock_teams_configuration_service().get_teams_configuration.return_value.teamsets = self.team_sets
|
||||
team = CourseTeamFactory.create(
|
||||
name="Team in 1st TeamSet",
|
||||
course_id=self.course_key,
|
||||
topic_id=self.team_sets[0].teamset_id,
|
||||
)
|
||||
team.add_user(self.student)
|
||||
team_partition_scheme = TeamPartitionScheme.create_user_partition(
|
||||
id=self.team_sets[0].user_partition_id,
|
||||
name=f"Team Group: {self.team_sets[0].name}",
|
||||
description="Partition for segmenting users by team-set",
|
||||
parameters={
|
||||
"course_id": str(self.course_key),
|
||||
"team_set_id": self.team_sets[0].teamset_id,
|
||||
}
|
||||
)
|
||||
|
||||
assert TeamPartitionScheme.get_group_for_user(
|
||||
self.course_key, self.student, team_partition_scheme
|
||||
) == team_partition_scheme.groups[0]
|
||||
|
||||
def test_get_group_for_user_no_team(self):
|
||||
"""
|
||||
Test that the TeamPartitionScheme returns None for a student not in a team.
|
||||
|
||||
Expected result:
|
||||
- The group returned is None.
|
||||
"""
|
||||
team_partition_scheme = TeamPartitionScheme.create_user_partition(
|
||||
id=51,
|
||||
name="Team Group: 1st TeamSet",
|
||||
description="Partition for segmenting users by team-set",
|
||||
parameters={
|
||||
"course_id": str(self.course_key),
|
||||
"team_set_id": 1,
|
||||
}
|
||||
)
|
||||
|
||||
assert TeamPartitionScheme.get_group_for_user(
|
||||
self.course_key, self.student, team_partition_scheme
|
||||
) is None
|
||||
|
||||
@patch("lms.djangoapps.teams.team_partition_scheme.get_course_masquerade")
|
||||
@patch("lms.djangoapps.teams.team_partition_scheme.get_masquerading_user_group")
|
||||
@patch("lms.djangoapps.teams.team_partition_scheme.is_masquerading_as_specific_student")
|
||||
def test_group_for_user_masquerading(
|
||||
self,
|
||||
mock_is_masquerading_as_specific_student,
|
||||
mock_get_masquerading_user_group,
|
||||
mock_get_course_masquerade
|
||||
):
|
||||
"""
|
||||
Test that the TeamPartitionScheme calls the masquerading functions when
|
||||
the user is masquerading.
|
||||
|
||||
Expected result:
|
||||
- The masquerading functions are called.
|
||||
"""
|
||||
team_partition_scheme = TeamPartitionScheme.create_user_partition(
|
||||
id=51,
|
||||
name="Team Group: 1st TeamSet",
|
||||
description="Partition for segmenting users by team-set",
|
||||
parameters={
|
||||
"course_id": str(self.course_key),
|
||||
"team_set_id": 1,
|
||||
}
|
||||
)
|
||||
mock_get_course_masquerade.return_value = True
|
||||
mock_is_masquerading_as_specific_student.return_value = False
|
||||
|
||||
TeamPartitionScheme.get_group_for_user(
|
||||
self.course_key, self.student, team_partition_scheme
|
||||
)
|
||||
|
||||
mock_get_masquerading_user_group.assert_called_once_with(self.course_key, self.student, team_partition_scheme)
|
||||
Reference in New Issue
Block a user