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: 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
|