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