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.
424 lines
13 KiB
Python
424 lines
13 KiB
Python
"""
|
|
Safe configuration wrapper for Course Teams feature.
|
|
"""
|
|
import logging
|
|
import re
|
|
from enum import Enum
|
|
|
|
from django.utils.functional import cached_property
|
|
from django.utils.translation import gettext_lazy as _
|
|
from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag
|
|
|
|
from xmodule.partitions.partitions import UserPartition, UserPartitionError
|
|
log = logging.getLogger(__name__)
|
|
|
|
# "Arbitrarily large" but still limited
|
|
MANAGED_TEAM_MAX_TEAM_SIZE = 200
|
|
# Arbitrarily arbitrary
|
|
DEFAULT_COURSE_RUN_MAX_TEAM_SIZE = 50
|
|
TEAM_SCHEME = "team"
|
|
TEAMS_NAMESPACE = "teams"
|
|
|
|
# .. toggle_name: course_teams.content_groups_for_teams
|
|
# .. toggle_implementation: CourseWaffleFlag
|
|
# .. toggle_default: False
|
|
# .. toggle_description: This flag enables content groups for teams. Content groups are virtual groupings of learners
|
|
# who will see a particular set of course content. When this flag is enabled, course authors can create teams and
|
|
# assign content to each of them. Then, when a learner joins a team, they will see the content that is assigned to
|
|
# that team's content group. This flag is only relevant for courses that have teams enabled.
|
|
# .. toggle_use_cases: temporary, opt_in
|
|
# .. toggle_target_removal_date: Teak
|
|
# .. toggle_creation_date: 2024-04-01
|
|
CONTENT_GROUPS_FOR_TEAMS = CourseWaffleFlag(
|
|
f"{TEAMS_NAMESPACE}.content_groups_for_teams", __name__
|
|
)
|
|
|
|
|
|
class TeamsConfig:
|
|
"""
|
|
Configuration for the Course Teams feature on a course run.
|
|
|
|
Takes in a configuration from a JSON-friendly dictionary,
|
|
and exposes cleaned data from it.
|
|
"""
|
|
def __init__(self, data):
|
|
"""
|
|
Initialize a TeamsConfig object with a dictionary.
|
|
"""
|
|
self._data = data if isinstance(data, dict) else {}
|
|
|
|
def __str__(self):
|
|
"""
|
|
Return user-friendly string.
|
|
"""
|
|
return f"Teams configuration for {len(self.teamsets)} team-sets"
|
|
|
|
def __repr__(self):
|
|
"""
|
|
Return developer-helpful string.
|
|
"""
|
|
return "<{} default_max_team_size={} teamsets=[{}] enabled={}>".format(
|
|
self.__class__.__name__,
|
|
self.default_max_team_size,
|
|
", ".join(repr(teamset) for teamset in self.teamsets),
|
|
self.is_enabled,
|
|
)
|
|
|
|
def __eq__(self, other):
|
|
"""
|
|
Define equality based on cleaned data.
|
|
"""
|
|
return isinstance(other, self.__class__) and self.cleaned_data == other.cleaned_data
|
|
|
|
def __ne__(self, other):
|
|
"""
|
|
Overrides default inequality to be the inverse of our custom equality.
|
|
Safe to remove once we're in Python 3 -- Py3 does this for us.
|
|
"""
|
|
return not self.__eq__(other)
|
|
|
|
@property
|
|
def source_data(self):
|
|
"""
|
|
Dictionary containing the data from which this TeamsConfig was built.
|
|
"""
|
|
return self._data
|
|
|
|
@cached_property
|
|
def cleaned_data(self):
|
|
"""
|
|
JSON-friendly dictionary containing cleaned data from this TeamsConfig.
|
|
"""
|
|
return {
|
|
'enabled': self.is_enabled,
|
|
'max_team_size': self.default_max_team_size,
|
|
'team_sets': [
|
|
teamset.cleaned_data for teamset in self.teamsets
|
|
]
|
|
}
|
|
|
|
@property
|
|
def is_enabled(self):
|
|
"""
|
|
Whether the Course Teams feature is enabled for this course run.
|
|
"""
|
|
has_teamsets = bool(self.teamsets)
|
|
enabled = self._data.get('enabled')
|
|
|
|
# The `enabled` field is a relatively recent addition and thus is not present in all team configurations.
|
|
# If it is present in the data, it controls whether the feature is turned on or off.
|
|
# If it isn't present, the feature is considered enabled if there are teamsets defined.
|
|
if enabled is not None:
|
|
return enabled
|
|
else:
|
|
return has_teamsets
|
|
|
|
@is_enabled.setter
|
|
def is_enabled(self, value):
|
|
"""
|
|
Setter to set value of enabled value
|
|
"""
|
|
self._data['enabled'] = value
|
|
|
|
@cached_property
|
|
def teamsets(self):
|
|
"""
|
|
List of configurations for team-sets.
|
|
|
|
A team-set is a logical collection of teams, generally centered around a
|
|
discussion topic or assignment.
|
|
|
|
A learner should be able to join one team per team-set
|
|
(TODO MST-12... currently, a learner may join one team per course).
|
|
"""
|
|
all_teamsets_data = self._data.get(
|
|
'team_sets',
|
|
# For backwards compatibility, also check "topics" key.
|
|
self._data.get('topics', [])
|
|
)
|
|
if not isinstance(all_teamsets_data, list):
|
|
return []
|
|
all_teamsets = [
|
|
TeamsetConfig(teamset_data)
|
|
for teamset_data in all_teamsets_data
|
|
]
|
|
good_teamsets = []
|
|
seen_ids = set()
|
|
for teamset in all_teamsets:
|
|
if teamset.teamset_id and teamset.teamset_id not in seen_ids:
|
|
good_teamsets.append(teamset)
|
|
seen_ids.add(teamset.teamset_id)
|
|
return good_teamsets
|
|
|
|
@cached_property
|
|
def teamsets_by_id(self):
|
|
return {teamset.teamset_id: teamset for teamset in self.teamsets}
|
|
|
|
@cached_property
|
|
def default_max_team_size(self):
|
|
"""
|
|
The default maximum size for teams in this course.
|
|
|
|
Can be overriden by individual team sets; see `calc_max_team_size`.
|
|
"""
|
|
return _clean_max_team_size(self._data.get('max_team_size')) or DEFAULT_COURSE_RUN_MAX_TEAM_SIZE
|
|
|
|
def calc_max_team_size(self, teamset_id):
|
|
"""
|
|
Given a team-set's ID, return the maximum allowed size of teams within it.
|
|
|
|
For 'open' team-sets, first regards the team-set's `max_team_size`,
|
|
then falls back to the course's `max_team_size`.
|
|
For managed team-sets, returns `MANAGED_TEAM_MAX_TEAM_SIZE`
|
|
(a number that is big enough to never really be a limit, but does put an upper limit for safety's sake)
|
|
|
|
Return value of None should be regarded as "no maximum size" (TODO MST-33).
|
|
"""
|
|
try:
|
|
teamset = self.teamsets_by_id[teamset_id]
|
|
except KeyError:
|
|
raise ValueError(f"Team-set {teamset_id!r} does not exist.") # lint-amnesty, pylint: disable=raise-missing-from
|
|
if teamset.teamset_type != TeamsetType.open:
|
|
return MANAGED_TEAM_MAX_TEAM_SIZE
|
|
if teamset.max_team_size:
|
|
return teamset.max_team_size
|
|
return self.default_max_team_size
|
|
|
|
|
|
class TeamsetConfig:
|
|
"""
|
|
Configuration for a team-set within a course run.
|
|
|
|
Takes in a configuration from a JSON-friendly dictionary,
|
|
and exposes cleaned data from it.
|
|
"""
|
|
teamset_id_regex = re.compile(r'^[A-Za-z0-9_. -]+$')
|
|
|
|
def __init__(self, data):
|
|
"""
|
|
Initialize a TeamsConfig object with a dictionary.
|
|
"""
|
|
self._data = data if isinstance(data, dict) else {}
|
|
|
|
def __str__(self):
|
|
"""
|
|
Return user-friendly string.
|
|
"""
|
|
return self.name
|
|
|
|
def __repr__(self):
|
|
"""
|
|
Return developer-helpful string.
|
|
"""
|
|
attrs = ['teamset_id', 'name', 'description', 'max_team_size', 'teamset_type', 'user_partition_id']
|
|
return "<{} {}>".format(
|
|
self.__class__.__name__,
|
|
" ".join(
|
|
attr + "=" + repr(getattr(self, attr))
|
|
for attr in attrs if hasattr(self, attr)
|
|
),
|
|
)
|
|
|
|
def __eq__(self, other):
|
|
"""
|
|
Define equality based on cleaned data.
|
|
"""
|
|
return isinstance(other, self.__class__) and self.cleaned_data == other.cleaned_data
|
|
|
|
def __ne__(self, other):
|
|
"""
|
|
Overrides default inequality to be the inverse of our custom equality.
|
|
Safe to remove once we're in Python 3 -- Py3 does this for us.
|
|
"""
|
|
return not self.__eq__(other)
|
|
|
|
@property
|
|
def source_data(self):
|
|
"""
|
|
Dictionary containing the data from which this TeamsConfig was built.
|
|
"""
|
|
return self._data
|
|
|
|
@cached_property
|
|
def cleaned_data(self):
|
|
"""
|
|
JSON-friendly dictionary containing cleaned data from this TeamsConfig.
|
|
"""
|
|
return {
|
|
'id': self.teamset_id,
|
|
'name': self.name,
|
|
'description': self.description,
|
|
'max_team_size': self.max_team_size,
|
|
'type': self.teamset_type.value,
|
|
'user_partition_id': self.user_partition_id,
|
|
}
|
|
|
|
@cached_property
|
|
def teamset_id(self):
|
|
"""
|
|
An identifier for this team-set.
|
|
|
|
Should be a URL-slug friendly string,
|
|
but for a historical reasons may contain periods and spaces.
|
|
"""
|
|
teamset_id = _clean_string(self._data.get('id'))
|
|
if not self.teamset_id_regex.match(teamset_id):
|
|
return ""
|
|
return teamset_id
|
|
|
|
@cached_property
|
|
def name(self):
|
|
"""
|
|
A human-friendly name of the team-set,
|
|
falling back to `teamset_id`.
|
|
"""
|
|
return _clean_string(self._data.get('name')) or self.teamset_id
|
|
|
|
@cached_property
|
|
def description(self):
|
|
"""
|
|
A brief description of the team-set,
|
|
falling back to empty string.
|
|
"""
|
|
return _clean_string(self._data.get('description'))
|
|
|
|
@cached_property
|
|
def max_team_size(self):
|
|
"""
|
|
Configured maximum team size override for this team-set,
|
|
falling back to None.
|
|
"""
|
|
return _clean_max_team_size(self._data.get('max_team_size'))
|
|
|
|
@cached_property
|
|
def teamset_type(self):
|
|
"""
|
|
Configured TeamsetType,
|
|
falling back to default TeamsetType.
|
|
"""
|
|
try:
|
|
return TeamsetType(self._data['type'])
|
|
except (KeyError, ValueError):
|
|
return TeamsetType.get_default()
|
|
|
|
@cached_property
|
|
def is_private_managed(self):
|
|
"""
|
|
Returns true if teamsettype is private_managed
|
|
"""
|
|
return self.teamset_type == TeamsetType.private_managed
|
|
|
|
@cached_property
|
|
def user_partition_id(self):
|
|
"""
|
|
The ID of the dynamic user partition for this team-set,
|
|
falling back to None.
|
|
"""
|
|
return self._data.get('user_partition_id')
|
|
|
|
|
|
class TeamsetType(Enum):
|
|
"""
|
|
Management and privacy scheme for teams within a team-set.
|
|
|
|
"open" team-sets allow learners to freely join, leave, and create teams.
|
|
|
|
"public_managed" team-sets forbid learners from modifying teams' membership.
|
|
Instead, instructors manage membership (TODO MST-9).
|
|
|
|
"private_managed" is like public_managed, except for that team names,
|
|
team memberships, and team discussions are all private to the members
|
|
of the teams (TODO MST-10).
|
|
"""
|
|
open = "open"
|
|
public_managed = "public_managed"
|
|
private_managed = "private_managed"
|
|
open_managed = "open_managed"
|
|
|
|
@classmethod
|
|
def get_default(cls):
|
|
"""
|
|
Return default TeamsetType.
|
|
"""
|
|
return cls.open
|
|
|
|
|
|
def _clean_string(value):
|
|
"""
|
|
Return `str(value)` if it's a string or int, otherwise "".
|
|
"""
|
|
if isinstance(value, (int,) + (str,)):
|
|
return str(value)
|
|
return ""
|
|
|
|
|
|
def _clean_max_team_size(value):
|
|
"""
|
|
Return `value` if it's a positive int, otherwise None.
|
|
"""
|
|
if not isinstance(value, int):
|
|
return None
|
|
if value < 0:
|
|
return None
|
|
return value
|
|
|
|
|
|
def create_team_set_partitions_with_course_id(course_id, team_sets=None):
|
|
"""
|
|
Create and return the team-set user partitions based only on course_id.
|
|
If they cannot be created, None is returned.
|
|
"""
|
|
if not team_sets:
|
|
team_sets = _get_team_sets(course_id) or {}
|
|
|
|
try:
|
|
team_scheme = UserPartition.get_scheme(TEAM_SCHEME)
|
|
except UserPartitionError:
|
|
log.warning(f"No {TEAM_SCHEME} scheme registered, TeamUserPartition will not be created.")
|
|
return None
|
|
|
|
# Get team-sets from course and create user partitions for each team-set
|
|
# Then get teams from each team-set and create user groups for each team
|
|
partitions = []
|
|
for team_set in team_sets:
|
|
partition = team_scheme.create_user_partition(
|
|
id=team_set.user_partition_id,
|
|
name=_("Team Group: {team_set_name}").format(team_set_name=team_set.name),
|
|
description=_("Partition for segmenting users by team-set"),
|
|
parameters={
|
|
"course_id": str(course_id),
|
|
"team_set_id": team_set.teamset_id,
|
|
}
|
|
)
|
|
if partition:
|
|
partitions.append(partition)
|
|
|
|
return partitions
|
|
|
|
|
|
def create_team_set_partition(course):
|
|
"""
|
|
Get the dynamic enrollment track user partition based on the team-sets of the course.
|
|
"""
|
|
if not CONTENT_GROUPS_FOR_TEAMS.is_enabled(course.id):
|
|
return []
|
|
return create_team_set_partitions_with_course_id(
|
|
course.id,
|
|
_get_team_sets(course.id),
|
|
)
|
|
|
|
|
|
def _get_team_sets(course_key):
|
|
"""
|
|
Get team-sets of the course.
|
|
"""
|
|
# Avoid ImportError import by importing at this level:
|
|
# TeamsConfigurationService -> is_masquerading_as_specific_student -> CourseMode -> CourseOverview
|
|
# Raises: ImportError: cannot import name 'CourseOverview' from partially initialized module
|
|
from xmodule.services import TeamsConfigurationService
|
|
team_sets = TeamsConfigurationService().get_teams_configuration(course_key).teamsets
|
|
if not team_sets:
|
|
return None
|
|
|
|
return team_sets
|