Files
edx-platform/openedx/core/lib/teams_config.py
Kshitij Sobti ff61434893 feat: Add a new way to enable/disable teams [BD-38] [TNL-9175] [BB-5066] (#29099)
* feat: Add a new way to enable/disable teams

Adds a new mechanism for enabling/disabling the team feature in a course using an 'enabled' field to the teams config.
If this field is set to true, teams is enabled (team sets/groups) still need to be defined. If this is set to false then teams is disabled whether or not team sets are defined.

* fix: review feedback
2021-11-01 17:12:06 +05:00

327 lines
9.5 KiB
Python

"""
Safe configuration wrapper for Course Teams feature.
"""
import re
from enum import Enum
from django.utils.functional import cached_property
# "Arbitrarily large" but still limited
MANAGED_TEAM_MAX_TEAM_SIZE = 200
# Arbitrarily arbitrary
DEFAULT_COURSE_RUN_MAX_TEAM_SIZE = 50
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.
"""
# Check if the enabled field is set, and teamsets are defined
has_teamsets = bool(self.teamsets)
return self._data.get('enabled', True) and 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']
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,
}
@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
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"
@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