diff --git a/cms/djangoapps/contentstore/course_group_config.py b/cms/djangoapps/contentstore/course_group_config.py index 569a1f72b372..544830d56583 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_grouped_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" +GROUP_SCHEME = "group" 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 == GROUP_SCHEME: + content_group_configuration = get_grouped_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 83c26197a4a7..9a0b4daf93ce 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -752,6 +752,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) + group_partitions = get_user_partition_info(xblock, schemes=["group"], course=course) # For enrollment partitions, we only show them if there is a selected group or # or if the number of groups > 1. @@ -765,7 +766,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) + group_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 a55bb3db9a53..14dfab68abf6 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -1606,11 +1606,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'] == "group": + 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, @@ -1621,6 +1623,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="group")) return render_to_response('group_configurations.html', { 'context_course': course, 'group_configuration_url': group_configuration_url, diff --git a/openedx/core/djangoapps/course_groups/api.py b/openedx/core/djangoapps/course_groups/api.py index a4dec915fbe5..94356b52b37d 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, CourseGroupsSettings, GroupMembership +from openedx.core.lib.courses import get_course_by_id -from openedx.core.djangoapps.course_groups.models import CohortMembership +from .models import CourseUserGroupPartitionGroup, CourseGroup 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_group(user, course_key, assign=True, use_cached=False): + if user is None or user.is_anonymous: + return None + + try: + membership = GroupMembership.objects.get( + course_id=course_key, + user_id=user.id, + ) + return membership.course_user_group + except GroupMembership.DoesNotExist: + if not assign: + return None + +def get_group_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.GROUPS, + id=group_id + ) + + +def link_group_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_group_partition_group(group): + """ + Remove any existing group to partition_id/group_id link. + """ + CourseUserGroupPartitionGroup.objects.filter(course_user_group=group).delete() + + +def get_course_groups(course_id=None): + query_set = CourseUserGroup.objects.filter( + course_id=course_id, + group_type=CourseUserGroup.GROUPS, + ) + return list(query_set) + +def get_course_groups_qs(course_id=None): + query_set = CourseUserGroup.objects.filter( + course_id=course_id, + group_type=CourseUserGroup.GROUPS, + ) + return query_set + +def is_group_exists(course_key, name): + """ + Check if a group already exists. + """ + return CourseUserGroup.objects.filter(course_id=course_key, group_type=CourseUserGroup.GROUPS, name=name).exists() + + +def add_group_to_course(name, course_key, professor=None): + """ + Adds a group to a course. + """ + if is_group_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 CourseGroup.create( + group_name=name, + course_id=course.id, + professor=professor, + ).course_user_group + + +def add_user_to_group(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 GroupMembership.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_group(group): + """ + 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=group) + return partition_group.group_id, partition_group.partition_id + except CourseUserGroupPartitionGroup.DoesNotExist: + pass diff --git a/openedx/core/djangoapps/course_groups/migrations/0004_alter_courseusergroup_group_type.py b/openedx/core/djangoapps/course_groups/migrations/0004_alter_courseusergroup_group_type.py new file mode 100644 index 000000000000..f9ffbca5e628 --- /dev/null +++ b/openedx/core/djangoapps/course_groups/migrations/0004_alter_courseusergroup_group_type.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.20 on 2023-07-18 22:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_groups', '0003_auto_20170609_1455'), + ] + + operations = [ + migrations.AlterField( + model_name='courseusergroup', + name='group_type', + field=models.CharField(choices=[('cohort', 'Cohort'), ('groups', 'Groups')], max_length=20), + ), + ] diff --git a/openedx/core/djangoapps/course_groups/migrations/0005_coursegroup_coursegroupssettings_groupmembership.py b/openedx/core/djangoapps/course_groups/migrations/0005_coursegroup_coursegroupssettings_groupmembership.py new file mode 100644 index 000000000000..9ce89dcfb96e --- /dev/null +++ b/openedx/core/djangoapps/course_groups/migrations/0005_coursegroup_coursegroupssettings_groupmembership.py @@ -0,0 +1,42 @@ +# Generated by Django 3.2.20 on 2023-07-19 23:52 + +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', '0004_alter_courseusergroup_group_type'), + ] + + operations = [ + migrations.CreateModel( + name='CourseGroupsSettings', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_grouped', models.BooleanField(default=False)), + ('course_id', opaque_keys.edx.django.models.CourseKeyField(db_index=True, help_text='Which course are these settings associated with?', max_length=255, unique=True)), + ], + ), + migrations.CreateModel( + name='GroupMembership', + 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='CourseGroup', + 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')), + ('professors', models.ManyToManyField(blank=True, db_index=True, related_name='course_groups_professors', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/openedx/core/djangoapps/course_groups/models.py b/openedx/core/djangoapps/course_groups/models.py index 0b731cc6b608..9863b2db8283 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'),) + GROUPS = 'groups' + GROUP_TYPE_CHOICES = ((COHORT, 'Cohort'), (GROUPS, 'Groups'),) 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 GroupMembership(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,51 @@ def create(cls, cohort_name=None, course_id=None, course_user_group=None, assign return course_cohort +class CourseGroup(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) + + professors = models.ManyToManyField(User, db_index=True, related_name='course_groups_professors', blank=True) + + @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.GROUPS, + ) + + course_group, __ = cls.objects.get_or_create( + course_user_group=course_user_group, + ) + if professor: + course_group.professors.add(professor) + + return course_group + + +class CourseGroupsSettings(models.Model): + """ + This model represents cohort settings for courses. + The only non-deprecated fields are `is_grouped` and `course_id`. + + .. no_pii: + """ + is_grouped = models.BooleanField(default=False) + + course_id = CourseKeyField( + unique=True, + max_length=255, + db_index=True, + help_text="Which course are these settings associated with?", + ) + 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..4df1b7257bd1 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_group, get_group_info_for_group log = logging.getLogger(__name__) @@ -99,7 +100,47 @@ 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_grouped_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. + """ + from platform_plugin_groups.scheme import GroupPartitionScheme + for user_partition in course.user_partitions: + if user_partition.scheme == GroupPartitionScheme: + return user_partition + + return None + + +class GroupPartitionScheme: + """ + This scheme uses lms cohorts (CourseUserGroups) and cohort-partition + mappings (CourseUserGroupPartitionGroup) to map lms users into Partition + Groups. + """ + + @classmethod + def get_group_for_user(cls, course_key, user, user_partition, use_cached=True): + group = get_group(user, course_key, use_cached=use_cached) + if group is None: + return None + + group_id, partition_id = get_group_info_for_group(group, use_cached=use_cached) + if partition_id is None: + return None + + if partition_id != user_partition.id: + return None + + try: + return user_partition.get_group(group_id) + except NoSuchUserPartitionGroupError: + return None \ No newline at end of file diff --git a/setup.py b/setup.py index f405f92a95b4..579c50c30e26 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", + "groups = openedx.core.djangoapps.course_groups.partition_scheme:GroupPartitionScheme", # Add in plugin!! "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",