Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[POC] feat: flexible groups support proof of concept #32806

Closed
wants to merge 10 commits into from
16 changes: 10 additions & 6 deletions cms/djangoapps/contentstore/course_group_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.'
Expand Down Expand Up @@ -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.
"""
Expand Down Expand Up @@ -347,22 +348,25 @@ 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
found. The created partition is not saved to the course until
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()

Expand Down
3 changes: 2 additions & 1 deletion cms/djangoapps/contentstore/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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).
Expand Down
5 changes: 4 additions & 1 deletion cms/djangoapps/contentstore/views/course.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
140 changes: 137 additions & 3 deletions openedx/core/djangoapps/course_groups/api.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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
Original file line number Diff line number Diff line change
@@ -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),
),
]
Original file line number Diff line number Diff line change
@@ -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)),
],
),
]
Loading
Loading