Files
edx-platform/openedx/core/lib/teams_config.py
Maria Grimaldi 809ffc3743 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.
2024-04-25 13:02:49 -04:00

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