diff --git a/cms/djangoapps/contentstore/course_group_config.py b/cms/djangoapps/contentstore/course_group_config.py index 569a1f72b372..7c58810aa43c 100644 --- a/cms/djangoapps/contentstore/course_group_config.py +++ b/cms/djangoapps/contentstore/course_group_config.py @@ -12,7 +12,7 @@ from cms.djangoapps.contentstore.utils import reverse_usage_url from common.djangoapps.util.db import MYSQL_MAX_INT, generate_int_id from lms.lib.utils import get_parent_unit -from openedx.core.djangoapps.course_groups.partition_scheme import get_cohorted_user_partition +from openedx.core.djangoapps.course_groups.partition_scheme import get_cohorted_user_partition, get_teamed_user_partition # lint-amnesty, pylint: disable=wrong-import-order from xmodule.partitions.partitions import MINIMUM_STATIC_PARTITION_ID, ReadOnlyUserPartitionError, UserPartition # lint-amnesty, pylint: disable=wrong-import-order from xmodule.partitions.partitions_service import get_all_partitions_for_course # lint-amnesty, pylint: disable=wrong-import-order from xmodule.split_test_block import get_split_user_partitions # lint-amnesty, pylint: disable=wrong-import-order @@ -22,6 +22,7 @@ RANDOM_SCHEME = "random" COHORT_SCHEME = "cohort" ENROLLMENT_SCHEME = "enrollment_track" +TEAM_SCHEME = "team" CONTENT_GROUP_CONFIGURATION_DESCRIPTION = _( 'The groups in this configuration can be mapped to cohorts in the Instructor Dashboard.' @@ -114,7 +115,7 @@ def get_user_partition(self): raise GroupConfigurationsValidationError(_("unable to load this type of group configuration")) # lint-amnesty, pylint: disable=raise-missing-from @staticmethod - def _get_usage_dict(course, unit, block, scheme_name=None): + def _get_usage_dict(course, unit, block, scheme_name=COHORT_SCHEME): """ Get usage info for unit/block. """ @@ -347,7 +348,7 @@ def update_partition_usage_info(store, course, configuration): return partition_configuration @staticmethod - def get_or_create_content_group(store, course): + def get_or_create_content_group(store, course, scheme_name=COHORT_SCHEME): """ Returns the first user partition from the course which uses the CohortPartitionScheme, or generates one if no such partition is @@ -355,14 +356,17 @@ def get_or_create_content_group(store, course): the client explicitly creates a group within the partition and POSTs back. """ - content_group_configuration = get_cohorted_user_partition(course) + if scheme_name == COHORT_SCHEME: + content_group_configuration = get_cohorted_user_partition(course) + elif scheme_name == TEAM_SCHEME: + content_group_configuration = get_teamed_user_partition(course) if content_group_configuration is None: content_group_configuration = UserPartition( id=generate_int_id(MINIMUM_GROUP_ID, MYSQL_MAX_INT, GroupConfiguration.get_used_ids(course)), - name=CONTENT_GROUP_CONFIGURATION_NAME, + name=f"Content Groups for {scheme_name}", description=CONTENT_GROUP_CONFIGURATION_DESCRIPTION, groups=[], - scheme_id=COHORT_SCHEME + scheme_id=scheme_name, ) return content_group_configuration.to_json() diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index ba227ecbec0e..b73a6c6f7a08 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -763,6 +763,7 @@ def get_visibility_partition_info(xblock, course=None): selectable_partitions = [] # We wish to display enrollment partitions before cohort partitions. enrollment_user_partitions = get_user_partition_info(xblock, schemes=["enrollment_track"], course=course) + team_partitions = get_user_partition_info(xblock, schemes=["team"], course=course) # For enrollment partitions, we only show them if there is a selected group or # or if the number of groups > 1. @@ -776,7 +777,7 @@ def get_visibility_partition_info(xblock, course=None): selectable_partitions += get_user_partition_info(xblock, schemes=[CONTENT_TYPE_GATING_SCHEME], course=course) # Now add the cohort user partitions. - selectable_partitions = selectable_partitions + get_user_partition_info(xblock, schemes=["cohort"], course=course) + selectable_partitions = selectable_partitions + get_user_partition_info(xblock, schemes=["cohort"], course=course) + team_partitions # Find the first partition with a selected group. That will be the one initially enabled in the dialog # (if the course has only been added in Studio, only one partition should have a selected group). diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index b42c011c4b20..a603f5b84c52 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -81,6 +81,7 @@ COHORT_SCHEME, ENROLLMENT_SCHEME, RANDOM_SCHEME, + TEAM_SCHEME, GroupConfiguration, GroupConfigurationsValidationError ) @@ -1608,11 +1609,13 @@ def group_configurations_list_handler(request, course_key_string): # Add it to the front of the list if it should be shown. if should_show_enrollment_track: displayable_partitions.insert(0, partition) + elif partition['scheme'] == TEAM_SCHEME: + has_content_groups = True + displayable_partitions.append(partition) elif partition['scheme'] != RANDOM_SCHEME: # Experiment group configurations are handled explicitly above. We don't # want to display their groups twice. displayable_partitions.append(partition) - # Set the sort-order. Higher numbers sort earlier scheme_priority = defaultdict(lambda: -1, { ENROLLMENT_SCHEME: 1, @@ -1623,6 +1626,7 @@ def group_configurations_list_handler(request, course_key_string): # This will add ability to add new groups in the view. if not has_content_groups: displayable_partitions.append(GroupConfiguration.get_or_create_content_group(store, course)) + displayable_partitions.append(GroupConfiguration.get_or_create_content_group(store, course, scheme_name="team")) return render_to_response('group_configurations.html', { 'context_course': course, 'group_configuration_url': group_configuration_url, diff --git a/lms/djangoapps/teams/static/teams/js/models/team.js b/lms/djangoapps/teams/static/teams/js/models/team.js index 010116e3717e..3ccd17da12c7 100644 --- a/lms/djangoapps/teams/static/teams/js/models/team.js +++ b/lms/djangoapps/teams/static/teams/js/models/team.js @@ -16,7 +16,8 @@ country: '', language: '', membership: [], - last_activity_at: '' + last_activity_at: '', + content_group: '', }, initialize: function(options) { diff --git a/lms/djangoapps/teams/static/teams/js/views/edit_team.js b/lms/djangoapps/teams/static/teams/js/views/edit_team.js index 4413faf5de33..737449b62860 100644 --- a/lms/djangoapps/teams/static/teams/js/views/edit_team.js +++ b/lms/djangoapps/teams/static/teams/js/views/edit_team.js @@ -27,6 +27,9 @@ this.topic = options.topic; this.collection = options.collection; this.action = options.action; + this.contentGroupsNameMap = _.map(this.context.contentGroups, function(group) { + return [group.id, group.name] + }); if (this.action === 'create') { this.teamModel = new TeamModel({}); @@ -57,6 +60,17 @@ + 'goals or direction of the team (maximum 300 characters).') }); + this.teamContentGroupsField = new FieldViews.DropdownFieldView({ + model: this.teamModel, + title: gettext('Content Groups'), + valueAttribute: 'content_group', + required: false, + showMessages: false, + options: this.contentGroupsNameMap, + helpMessage: gettext( + 'The content groups that the team is associated with.') + }); + this.teamLanguageField = new FieldViews.DropdownFieldView({ model: this.teamModel, title: gettext('Language'), @@ -92,6 +106,7 @@ ); this.set(this.teamNameField, '.team-required-fields'); this.set(this.teamDescriptionField, '.team-required-fields'); + this.set(this.teamContentGroupsField, '.team-optional-fields'); this.set(this.teamLanguageField, '.team-optional-fields'); this.set(this.teamCountryField, '.team-optional-fields'); return this; @@ -105,17 +120,19 @@ this.$(selector).append(view.render().$el); } }, - createOrUpdateTeam: function(event) { event.preventDefault(); var view = this, // eslint-disable-line vars-on-top teamLanguage = this.teamLanguageField.fieldValue(), teamCountry = this.teamCountryField.fieldValue(), + teamContentGroup = this.teamContentGroupsField.fieldValue(), data = { name: this.teamNameField.fieldValue(), description: this.teamDescriptionField.fieldValue(), language: _.isNull(teamLanguage) ? '' : teamLanguage, - country: _.isNull(teamCountry) ? '' : teamCountry + country: _.isNull(teamCountry) ? '' : teamCountry, + content_group: _.isNull(teamContentGroup) ? '' : teamContentGroup, + user_partition_id: this.context.partitionID }, saveOptions = { wait: true diff --git a/lms/djangoapps/teams/templates/teams/teams.html b/lms/djangoapps/teams/templates/teams/teams.html index 3dfa5b51aeb6..29a5fca4d652 100644 --- a/lms/djangoapps/teams/templates/teams/teams.html +++ b/lms/djangoapps/teams/templates/teams/teams.html @@ -24,6 +24,10 @@ <%include file="/courseware/course_navigation.html" args="active_page='teams'" /> +<%! +from openedx.core.djangoapps.course_groups.partition_scheme import get_teamed_user_partition +%> +
@@ -38,7 +42,21 @@ <%include file="../discussion/_js_body_dependencies.html" /> <%static:require_module module_name="teams/js/teams_tab_factory" class_name="TeamsTabFactory"> - TeamsTabFactory({ + <% + teamed_user_partition = get_teamed_user_partition(course) + content_groups = teamed_user_partition.groups if teamed_user_partition else [] + %> + var groupUserPartitionId = ${teamed_user_partition.id if teamed_user_partition else None | n, dump_js_escaped_json}, + contentGroups = [ + % for content_group in content_groups: + { + id: ${content_group.id | n, dump_js_escaped_json}, + name: "${content_group.name | n, js_escaped_string}", + user_partition_id: groupUserPartitionId + }, + % endfor + ]; + TeamsTabFactory({ courseID: '${str(course.id) | n, js_escaped_string}', topics: ${topics | n, dump_js_escaped_json}, hasManagedTopic: ${has_managed_teamset | n, dump_js_escaped_json}, @@ -59,7 +77,9 @@ courseMaxTeamSize: ${course.teams_configuration.default_max_team_size | n, dump_js_escaped_json}, languages: ${languages | n, dump_js_escaped_json}, countries: ${countries | n, dump_js_escaped_json}, - teamsBaseUrl: '${teams_base_url | n, js_escaped_string}' + teamsBaseUrl: '${teams_base_url | n, js_escaped_string}', + contentGroups: contentGroups, + partitionID: groupUserPartitionId, }); diff --git a/lms/djangoapps/teams/views.py b/lms/djangoapps/teams/views.py index a5370ffa937f..3f6bc856894e 100644 --- a/lms/djangoapps/teams/views.py +++ b/lms/djangoapps/teams/views.py @@ -71,6 +71,8 @@ from .toggles import are_team_submissions_enabled from .utils import emit_team_event +from openedx.core.djangoapps.course_groups.api import * + TEAM_MEMBERSHIPS_PER_PAGE = 5 TOPICS_PER_PAGE = 12 MAXIMUM_SEARCH_SIZE = 10000 @@ -637,6 +639,13 @@ def post(self, request): ) data = CourseTeamSerializer(team, context={"request": request}).data + group = add_team_to_course( + team.name, + course_key, + ) + if request.data.get("user_partition_id") and request.data.get("content_group"): + link_team_to_partition_group(group, request.data["user_partition_id"], request.data["content_group"]) + return Response(data) def get_page(self): @@ -838,6 +847,27 @@ def delete(self, request, team_id): }) return Response(status=status.HTTP_204_NO_CONTENT) + def patch(self, request, team_id): + course_team = get_object_or_404(CourseTeam, team_id=team_id) + data = request.data + + content_group_id = data["content_group"] + if not content_group_id: + return super().patch(request, team_id) + + group = get_course_team_qs(course_team.course_id).filter(name=course_team.name).first() + partitioned_group_info = get_group_info_for_team(group) + if not partitioned_group_info: + link_team_to_partition_group(group, data["user_partition_id"], data["content_group"]) + return super().patch(request, team_id) + + existing_content_group_id, existing_partition_id = get_group_info_for_team(group) + if content_group_id != existing_content_group_id or data["user_partition_id"] != existing_partition_id: + unlink_team_partition_group(group) + link_team_to_partition_group(group, data["user_partition_id"], data["content_group"]) + + return super().patch(request, team_id) + class TeamsAssignmentsView(GenericAPIView): """ @@ -1479,6 +1509,8 @@ def post(self, request): try: membership = team.add_user(user) + group = get_course_team_qs(team.course_id).get(name=team.name) + add_user_to_team(group, user.username) emit_team_event( 'edx.team.learner_added', team.course_id, diff --git a/openedx/core/djangoapps/course_groups/api.py b/openedx/core/djangoapps/course_groups/api.py index a4dec915fbe5..929a17569987 100644 --- a/openedx/core/djangoapps/course_groups/api.py +++ b/openedx/core/djangoapps/course_groups/api.py @@ -1,11 +1,16 @@ """ course_groups API """ - - from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user +from django.core.exceptions import ValidationError +from django.core.validators import validate_email +from django.http import Http404 + +from common.djangoapps.student.models import get_user_by_username_or_email +from openedx.core.djangoapps.course_groups.models import CohortMembership, CourseUserGroup, TeamMembership +from openedx.core.lib.courses import get_course_by_id -from openedx.core.djangoapps.course_groups.models import CohortMembership +from .models import CourseUserGroupPartitionGroup, CourseTeamGroup def remove_user_from_cohort(course_key, username, cohort_id=None): @@ -27,3 +32,132 @@ def remove_user_from_cohort(course_key, username, cohort_id=None): pass else: membership.delete() + +def get_assignment_type(user_group): + """ + Get assignment type for cohort. + """ + course_cohort = user_group.cohort + return course_cohort.assignment_type + + +def get_team(user, course_key, assign=True, use_cached=False): + if user is None or user.is_anonymous: + return None + + try: + membership = TeamMembership.objects.get( + course_id=course_key, + user_id=user.id, + ) + return membership.course_user_group + except TeamMembership.DoesNotExist: + if not assign: + return None + +def get_team_by_id(course_key, group_id): + """ + Return the CourseUserGroup object for the given cohort. Raises DoesNotExist + it isn't present. Uses the course_key for extra validation. + """ + return CourseUserGroup.objects.get( + course_id=course_key, + group_type=CourseUserGroup.TEAM, + id=group_id + ) + + +def link_team_to_partition_group(group, partition_id, group_id): + """ + Create group to partition_id/group_id link. + """ + CourseUserGroupPartitionGroup( + course_user_group=group, + partition_id=partition_id, + group_id=group_id, + ).save() + + +def unlink_team_partition_group(group): + """ + Remove any existing group to partition_id/group_id link. + """ + CourseUserGroupPartitionGroup.objects.filter(course_user_group=group).delete() + + +def get_course_teams(course_id=None): + query_set = CourseUserGroup.objects.filter( + course_id=course_id, + group_type=CourseUserGroup.TEAM, + ) + return list(query_set) + +def get_course_team_qs(course_id=None): + query_set = CourseUserGroup.objects.filter( + course_id=course_id, + group_type=CourseUserGroup.TEAM, + ) + return query_set + +def is_team_exists(course_key, name): + """ + Check if a group already exists. + """ + return CourseUserGroup.objects.filter(course_id=course_key, group_type=CourseUserGroup.TEAM, name=name).exists() + + +def add_team_to_course(name, course_key, professor=None): + """ + Adds a group to a course. + """ + if is_team_exists(course_key, name): + raise ValueError("You cannot create two groups with the same name") + + try: + course = get_course_by_id(course_key) + except Http404: + raise ValueError("Invalid course_key") # lint-amnesty, pylint: disable=raise-missing-from + + return CourseTeamGroup.create( + group_name=name, + course_id=course.id, + professor=professor, + ).course_user_group + + +def add_user_to_team(group, username_or_email_or_user): + try: + if hasattr(username_or_email_or_user, 'email'): + user = username_or_email_or_user + else: + user = get_user_by_username_or_email(username_or_email_or_user) + + return TeamMembership.assign(group, user) + except User.DoesNotExist as ex: # Note to self: TOO COHORT SPECIFIC! + # If username_or_email is an email address, store in database. + try: + return (None, None, True) + except ValidationError as invalid: + if "@" in username_or_email_or_user: # lint-amnesty, pylint: disable=no-else-raise + raise invalid + else: + raise ex # lint-amnesty, pylint: disable=raise-missing-from + + +def get_group_info_for_team(team): + """ + Get the ids of the group and partition to which this cohort has been linked + as a tuple of (int, int). + + If the cohort has not been linked to any group/partition, both values in the + tuple will be None. + + The partition group info is cached for the duration of a request. Pass + use_cached=True to use the cached value instead of fetching from the + database. + """ + try: + partition_group = CourseUserGroupPartitionGroup.objects.get(course_user_group=team) + return partition_group.group_id, partition_group.partition_id + except CourseUserGroupPartitionGroup.DoesNotExist: + pass diff --git a/openedx/core/djangoapps/course_groups/migrations/0004_auto_20231116_2038.py b/openedx/core/djangoapps/course_groups/migrations/0004_auto_20231116_2038.py new file mode 100644 index 000000000000..d240041d10d4 --- /dev/null +++ b/openedx/core/djangoapps/course_groups/migrations/0004_auto_20231116_2038.py @@ -0,0 +1,38 @@ +# Generated by Django 3.2.22 on 2023-11-16 20:38 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import opaque_keys.edx.django.models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('course_groups', '0003_auto_20170609_1455'), + ] + + operations = [ + migrations.AlterField( + model_name='courseusergroup', + name='group_type', + field=models.CharField(choices=[('cohort', 'Cohort'), ('team', 'Team')], max_length=20), + ), + migrations.CreateModel( + name='TeamMembership', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('course_id', opaque_keys.edx.django.models.CourseKeyField(max_length=255)), + ('course_user_group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='course_groups.courseusergroup')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='CourseTeamGroup', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('course_user_group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='group', to='course_groups.courseusergroup')), + ], + ), + ] diff --git a/openedx/core/djangoapps/course_groups/models.py b/openedx/core/djangoapps/course_groups/models.py index 0b731cc6b608..cb48f561d99b 100644 --- a/openedx/core/djangoapps/course_groups/models.py +++ b/openedx/core/djangoapps/course_groups/models.py @@ -63,7 +63,8 @@ class Meta: # For now, only have group type 'cohort', but adding a type field to support # things like 'question_discussion', 'friends', 'off-line-class', etc COHORT = 'cohort' # If changing this string, update it in migration 0006.forwards() as well - GROUP_TYPE_CHOICES = ((COHORT, 'Cohort'),) + TEAM = 'team' + GROUP_TYPE_CHOICES = ((COHORT, 'Cohort'), (TEAM, 'Team'),) group_type = models.CharField(max_length=20, choices=GROUP_TYPE_CHOICES) @classmethod @@ -190,6 +191,33 @@ def save(self, force_insert=False, force_update=False, using=None, update_fields ) +class TeamMembership(models.Model): + """ + Used internally to enforce particular conditions. + + .. no_pii: + """ + course_user_group = models.ForeignKey(CourseUserGroup, on_delete=models.CASCADE) + user = models.ForeignKey(User, on_delete=models.CASCADE) + course_id = CourseKeyField(max_length=255) + + def clean_fields(self, *args, **kwargs): # lint-amnesty, pylint: disable=signature-differs + if self.course_id is None: + self.course_id = self.course_user_group.course_id + super().clean_fields(*args, **kwargs) + + @classmethod + def assign(cls, group, user): + with transaction.atomic(): + membership, created = cls.objects.select_for_update().get_or_create( + user=user, + course_id=group.course_id, + course_user_group=group, + ) + membership.course_user_group.users.add(user) + return membership + + # Needs to exist outside class definition in order to use 'sender=CohortMembership' @receiver(pre_delete, sender=CohortMembership) def remove_user_from_cohort(sender, instance, **kwargs): # pylint: disable=unused-argument @@ -295,6 +323,34 @@ def create(cls, cohort_name=None, course_id=None, course_user_group=None, assign return course_cohort +class CourseTeamGroup(models.Model): + """ + This model represents the new group-type related info. + + .. no_pii: + """ + course_user_group = models.OneToOneField( + CourseUserGroup, + unique=True, + related_name='group', + on_delete=models.CASCADE, + ) + + @classmethod + def create(cls, group_name=None, course_id=None, course_user_group=None, professor=None): + if course_user_group is None: + course_user_group, __ = CourseUserGroup.create( + group_name, + course_id, + group_type=CourseUserGroup.TEAM, + ) + + course_group, __ = cls.objects.get_or_create( + course_user_group=course_user_group, + ) + + return course_group + class UnregisteredLearnerCohortAssignments(DeletableByUserValue, models.Model): """ Tracks the assignment of an unregistered learner to a course's cohort. diff --git a/openedx/core/djangoapps/course_groups/partition_scheme.py b/openedx/core/djangoapps/course_groups/partition_scheme.py index 60e7c5f915c2..70e9c6ea135b 100644 --- a/openedx/core/djangoapps/course_groups/partition_scheme.py +++ b/openedx/core/djangoapps/course_groups/partition_scheme.py @@ -13,6 +13,7 @@ from xmodule.partitions.partitions import NoSuchUserPartitionGroupError # lint-amnesty, pylint: disable=wrong-import-order from .cohorts import get_cohort, get_group_info_for_cohort +from .api import get_team, get_group_info_for_team log = logging.getLogger(__name__) @@ -99,7 +100,57 @@ def get_cohorted_user_partition(course): one cohorted user partition. """ for user_partition in course.user_partitions: - if user_partition.scheme == CohortPartitionScheme: + if user_partition.scheme == CohortPartitionScheme: # Deberíamos poder añadir el otro chequeo acá return user_partition return None + + +def get_teamed_user_partition(course): + """ + Returns the first user partition from the specified course which uses the CohortPartitionScheme, + or None if one is not found. Note that it is currently recommended that each course have only + one cohorted user partition. + """ + for user_partition in course.user_partitions: + if user_partition.scheme == TeamPartitionScheme: + return user_partition + + return None + + +class TeamPartitionScheme: + + @classmethod + def get_teamed_user_partition(cls, course): + for user_partition in course.user_partitions: + if user_partition.scheme == cls: + return user_partition + + return None + + @classmethod + def get_group_for_user(cls, course_key, user, user_partition): + """ + Returns the (Content) Group from the specified user partition to which the user + is assigned, via their group-type membership and any mappings from groups + to partitions / (content) groups that might exist. + + If the user has no group-type mapping, or there is no (valid) group -> + partition group mapping found, the function returns None. + """ + team = get_team(user, course_key) + if team is None: + return None + + team_id, partition_id = get_group_info_for_team(team) + if partition_id is None: + return None + + if partition_id != user_partition.id: + return None + + try: + return user_partition.get_group(team_id) + except NoSuchUserPartitionGroupError: + return None diff --git a/setup.py b/setup.py index f405f92a95b4..e7756536773f 100644 --- a/setup.py +++ b/setup.py @@ -104,6 +104,7 @@ "openedx.user_partition_scheme": [ "random = openedx.core.djangoapps.user_api.partition_schemes:RandomUserPartitionScheme", "cohort = openedx.core.djangoapps.course_groups.partition_scheme:CohortPartitionScheme", + "team = openedx.core.djangoapps.course_groups.partition_scheme:TeamPartitionScheme", "verification = openedx.core.djangoapps.user_api.partition_schemes:ReturnGroup1PartitionScheme", "enrollment_track = openedx.core.djangoapps.verified_track_content.partition_scheme:EnrollmentTrackPartitionScheme", # lint-amnesty, pylint: disable=line-too-long "content_type_gate = openedx.features.content_type_gating.partitions:ContentTypeGatingPartitionScheme",