diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index ba227ecbec0e..d22f515bcf13 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -770,6 +770,9 @@ def get_visibility_partition_info(xblock, course=None): if len(partition["groups"]) > 1 or any(group["selected"] for group in partition["groups"]): selectable_partitions.append(partition) + team_user_partitions = get_user_partition_info(xblock, schemes=["team"], course=course) + selectable_partitions += team_user_partitions + course_key = xblock.scope_ids.usage_id.course_key is_library = isinstance(course_key, LibraryLocator) if not is_library and ContentTypeGatingConfig.current(course_key=course_key).studio_override_enabled: diff --git a/cms/djangoapps/models/settings/course_metadata.py b/cms/djangoapps/models/settings/course_metadata.py index e5a0ab6a5b8f..ae72d81230f3 100644 --- a/cms/djangoapps/models/settings/course_metadata.py +++ b/cms/djangoapps/models/settings/course_metadata.py @@ -15,12 +15,15 @@ from cms.djangoapps.contentstore import toggles from common.djangoapps.xblock_django.models import XBlockStudioConfigurationFlag from openedx.core.djangoapps.course_apps.toggles import exams_ida_enabled +from openedx.core.djangoapps.course_groups.partition_generator import MINIMUM_DYNAMIC_TEAM_PARTITION_ID +from openedx.core.djangoapps.course_groups.partition_scheme import CONTENT_GROUPS_FOR_TEAMS from openedx.core.djangoapps.discussions.config.waffle_utils import legacy_discussion_experience_enabled from openedx.core.lib.teams_config import TeamsetType from openedx.features.course_experience import COURSE_ENABLE_UNENROLLED_ACCESS_FLAG from xmodule.course_block import get_available_providers # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.exceptions import InvalidProctoringProvider # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore.exceptions import InvalidProctoringProvider # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.partitions.partitions import MINIMUM_STATIC_PARTITION_ID LOGGER = logging.getLogger(__name__) @@ -267,6 +270,7 @@ def validate_and_update_from_json(cls, block, jsondict, user, filter_tabs=True): did_validate = False errors.append({'key': key, 'message': err_message, 'model': model}) + cls.fill_teams_user_partitions_ids(block.id, filtered_dict) team_setting_errors = cls.validate_team_settings(filtered_dict) if team_setting_errors: errors = errors + team_setting_errors @@ -283,6 +287,37 @@ def validate_and_update_from_json(cls, block, jsondict, user, filter_tabs=True): return did_validate, errors, updated_data + @classmethod + def fill_teams_user_partitions_ids(cls, course_key, settings_dict): + """ + Fill the `dynamic_user_partition_id` in the team settings if it is not set. + + This is used by the Dynamic Team Partition Generator to create the dynamic user partitions + based on the team-sets defined in the course. + """ + if not CONTENT_GROUPS_FOR_TEAMS.is_enabled(course_key): + return + + teams_configuration_model = settings_dict.get('teams_configuration', {}) + if teams_configuration_model == {}: + return + json_value = teams_configuration_model.get('value') + if json_value == '': + return + + proposed_topics = json_value.get('topics') + + if proposed_topics is None: + proposed_teamsets = json_value.get('team_sets') + if proposed_teamsets is None: + return + else: + proposed_topics = proposed_teamsets + + for index, proposed_topic in enumerate(proposed_topics): + if not proposed_topic.get('dynamic_user_partition_id'): + proposed_topic['dynamic_user_partition_id'] = MINIMUM_DYNAMIC_TEAM_PARTITION_ID + index + @classmethod def update_from_dict(cls, key_values, block, user, save=True): """ @@ -342,6 +377,15 @@ def validate_team_settings(cls, settings_dict): topic_validation_errors['model'] = teams_configuration_model errors.append(topic_validation_errors) + for proposed_topic in proposed_topics: + dynamic_user_partition_id = proposed_topic.get('dynamic_user_partition_id') + if dynamic_user_partition_id is None: + continue + if dynamic_user_partition_id > MINIMUM_STATIC_PARTITION_ID or dynamic_user_partition_id < MINIMUM_DYNAMIC_TEAM_PARTITION_ID: + message = 'dynamic_user_partition_id must be greater than ' + str(MINIMUM_DYNAMIC_TEAM_PARTITION_ID) + \ + ' and less than ' + str(MINIMUM_STATIC_PARTITION_ID) + errors.append({'key': 'teams_configuration', 'message': message, 'model': teams_configuration_model}) + return errors @classmethod @@ -359,7 +403,14 @@ def validate_single_topic(cls, topic_settings): error_list = [] valid_teamset_types = [TeamsetType.open.value, TeamsetType.public_managed.value, TeamsetType.private_managed.value] - valid_keys = {'id', 'name', 'description', 'max_team_size', 'type'} + valid_keys = { + 'id', + 'name', + 'description', + 'max_team_size', + 'type', + 'dynamic_user_partition_id', + } teamset_type = topic_settings.get('type', {}) if teamset_type: if teamset_type not in valid_teamset_types: diff --git a/openedx/core/djangoapps/content/learning_sequences/api/outlines.py b/openedx/core/djangoapps/content/learning_sequences/api/outlines.py index 9af14d6140ef..3dff1d5f4d73 100644 --- a/openedx/core/djangoapps/content/learning_sequences/api/outlines.py +++ b/openedx/core/djangoapps/content/learning_sequences/api/outlines.py @@ -16,6 +16,7 @@ from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locator import LibraryLocator from openedx.core import types +from openedx.core.djangoapps.content.learning_sequences.api.processors.team_partition_groups import TeamPartitionGroupsOutlineProcessor from ..data import ( ContentErrorData, @@ -330,6 +331,7 @@ def _get_user_course_outline_and_processors(course_key: CourseKey, # lint-amnes ('enrollment', EnrollmentOutlineProcessor), ('enrollment_track_partitions', EnrollmentTrackPartitionGroupsOutlineProcessor), ('cohorts_partitions', CohortPartitionGroupsOutlineProcessor), + ('teams_partitions', TeamPartitionGroupsOutlineProcessor), ] # Run each OutlineProcessor in order to figure out what items we have to diff --git a/openedx/core/djangoapps/content/learning_sequences/api/processors/team_partition_groups.py b/openedx/core/djangoapps/content/learning_sequences/api/processors/team_partition_groups.py new file mode 100644 index 000000000000..02ac247e2039 --- /dev/null +++ b/openedx/core/djangoapps/content/learning_sequences/api/processors/team_partition_groups.py @@ -0,0 +1,92 @@ +""" +Outline processors for applying team user partition groups. +""" +import logging +from datetime import datetime +from typing import Dict + +from opaque_keys.edx.keys import CourseKey + +from openedx.core import types +from openedx.core.djangoapps.content.learning_sequences.api.processors.base import OutlineProcessor +from openedx.core.djangoapps.course_groups.partition_generator import create_team_set_partition_with_course_id +from openedx.core.djangoapps.course_groups.partition_scheme import CONTENT_GROUPS_FOR_TEAMS +from xmodule.partitions.partitions import Group +from xmodule.partitions.partitions_service import get_user_partition_groups + +log = logging.getLogger(__name__) + + +class TeamPartitionGroupsOutlineProcessor(OutlineProcessor): + """ + Processor for applying all team user partition groups. + + This processor is used to remove content from the course outline based on + the user's team membership. It is used in the courseware API to remove + content from the course outline before it is returned to the client. + """ + def __init__(self, course_key: CourseKey, user: types.User, at_time: datetime): + super().__init__(course_key, user, at_time) + self.team_groups: Dict[str, Group] = {} + self.user_group = None + + def load_data(self, _) -> None: + """ + Pull team groups for this course and which group the user is in. + """ + user_partitions = create_team_set_partition_with_course_id(self.course_key) + self.team_groups = get_user_partition_groups( + self.course_key, + user_partitions, + self.user, + partition_dict_key="id", + ) + self.user_groups = [] + for _, group in self.team_groups.items(): + self.user_groups.append(group.id) + + def _is_user_excluded_by_partition_group(self, user_partition_groups): + """ + Is the user part of the group to which the block is restricting content? + + The user is excluded if the block is in a partition group, but the user + is not in that group. + """ + if not CONTENT_GROUPS_FOR_TEAMS.is_enabled(self.course_key): + return False + + if not user_partition_groups: + return False + + if not self.user_groups: + return False + + for partition_id, groups in user_partition_groups.items(): + if partition_id not in self.team_groups: + continue + if self.team_groups[partition_id].id in groups: + return False + + return True + + def usage_keys_to_remove(self, full_course_outline): + """ + Content group exclusions remove the content entirely. + + This method returns the usage keys of all content that should be + removed from the course outline based on the user's team membership. + """ + removed_usage_keys = set() + for section in full_course_outline.sections: + remove_all_children = False + if self._is_user_excluded_by_partition_group( + section.user_partition_groups + ): + removed_usage_keys.add(section.usage_key) + remove_all_children = True + for seq in section.sequences: + if remove_all_children or self._is_user_excluded_by_partition_group( + seq.user_partition_groups + ): + removed_usage_keys.add(seq.usage_key) + return removed_usage_keys diff --git a/openedx/core/djangoapps/course_groups/partition_generator.py b/openedx/core/djangoapps/course_groups/partition_generator.py new file mode 100644 index 000000000000..8d02ebf26788 --- /dev/null +++ b/openedx/core/djangoapps/course_groups/partition_generator.py @@ -0,0 +1,74 @@ +""" +The team dynamic partition generation to be part of the +openedx.dynamic_partition plugin. +""" +import logging + +from django.conf import settings +from django.utils.translation import gettext_lazy as _ +from openedx.core.djangoapps.course_groups.partition_scheme import CONTENT_GROUPS_FOR_TEAMS + +from xmodule.partitions.partitions import UserPartition, UserPartitionError +from xmodule.services import TeamsConfigurationService + +log = logging.getLogger(__name__) + +FEATURES = getattr(settings, 'FEATURES', {}) +MINIMUM_DYNAMIC_TEAM_PARTITION_ID = 51 +TEAM_SCHEME = "team" + + +def create_team_set_partition_with_course_id(course_id, team_sets=None): + """ + Create and return the dynamic enrollment track user partition based only on course_id. + If it 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("No 'team' 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.dynamic_user_partition_id, + name=f"Team set {team_set.name} groups", + 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_partition_with_course_id( + course.id, + get_team_sets(course.id), + ) + + +def get_team_sets(course_key): + """ + Get team-sets of the course. + """ + team_sets = TeamsConfigurationService().get_teams_configuration(course_key).teamsets + if not team_sets: + return None + + return team_sets diff --git a/openedx/core/djangoapps/course_groups/partition_scheme.py b/openedx/core/djangoapps/course_groups/partition_scheme.py index 60e7c5f915c2..9b9b5726c36b 100644 --- a/openedx/core/djangoapps/course_groups/partition_scheme.py +++ b/openedx/core/djangoapps/course_groups/partition_scheme.py @@ -5,16 +5,37 @@ import logging +from opaque_keys.edx.keys import CourseKey + from lms.djangoapps.courseware.masquerade import ( get_course_masquerade, get_masquerading_user_group, is_masquerading_as_specific_student ) -from xmodule.partitions.partitions import NoSuchUserPartitionGroupError # lint-amnesty, pylint: disable=wrong-import-order +from lms.djangoapps.teams.api import get_teams_in_teamset +from lms.djangoapps.teams.models import CourseTeamMembership +from xmodule.partitions.partitions import ( # lint-amnesty, pylint: disable=wrong-import-order + Group, + NoSuchUserPartitionGroupError, + UserPartition +) +from xmodule.services import TeamsConfigurationService +from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag from .cohorts import get_cohort, get_group_info_for_cohort log = logging.getLogger(__name__) +COURSE_GROUPS_NAMESPACE = "course_groups" + +# .. toggle_name: course_groups.content_groups_for_teams +# .. toggle_implementation: CourseWaffleFlag +# .. toggle_default: False +# .. toggle_description: This flag enables the use of content groups for teams. +# .. toggle_use_cases: open_edx +# .. toggle_creation_date: 2023-11-23 +CONTENT_GROUPS_FOR_TEAMS = CourseWaffleFlag( + f"{COURSE_GROUPS_NAMESPACE}.content_groups_for_teams", __name__ +) class CohortPartitionScheme: @@ -103,3 +124,89 @@ def get_cohorted_user_partition(course): return user_partition return None + + +class TeamUserPartition(UserPartition): + """ + Extends UserPartition to support dynamic groups pulled from the current course teams. + """ + + team_sets_mapping = {} + + @property + def groups(self): + """ + Return the groups (based on CourseModes) for the course associated with this + EnrollmentTrackUserPartition instance. Note that only groups based on selectable + CourseModes are returned (which means that Credit will never be returned). + """ + course_key = CourseKey.from_string(self.parameters["course_id"]) + if not CONTENT_GROUPS_FOR_TEAMS.is_enabled(course_key): + return [] + + team_sets = TeamsConfigurationService().get_teams_configuration(course_key).teamsets + team_set_id = self.team_sets_mapping[self.id] + team_set = next((team_set for team_set in team_sets if team_set.teamset_id == team_set_id), None) + teams = get_teams_in_teamset(str(course_key), team_set.teamset_id) + return [ + Group(team.id, str(team.name)) for team in teams + ] + + +class TeamPartitionScheme: + + @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 team membership and any mappings from teams to + partitions / groups that might exist. + """ + if not CONTENT_GROUPS_FOR_TEAMS.is_enabled(course_key): + return None + + teams = get_teams_in_teamset(str(course_key), user_partition.parameters["team_set_id"]) + team_ids = [team.team_id for team in teams] + user_team = CourseTeamMembership.get_memberships(user.username, [str(course_key)], team_ids).first() + if not user_team: + return None + + return Group(user_team.team.id, str(user_team.team.name)) + + @classmethod + def create_user_partition(self, id, name, description, groups=None, parameters=None, active=True): + """ + Create a custom UserPartition to support dynamic groups. + + A Partition has an id, name, scheme, description, parameters, and a list + of groups. The id is intended to be unique within the context where these + are used. (e.g., for partitions of users within a course, the ids should + be unique per-course). The scheme is used to assign users into groups. + The parameters field is used to save extra parameters e.g., location of + the course ID for this partition scheme. + + Partitions can be marked as inactive by setting the "active" flag to False. + Any group access rule referencing inactive partitions will be ignored + when performing access checks. + """ + course_key = CourseKey.from_string(parameters["course_id"]) + if not CONTENT_GROUPS_FOR_TEAMS.is_enabled(course_key): + return None + + # Team-set used to create partition was created before this feature was + # introduced. In that case, we need to create a new partition with a + # new team-set id. + if not id: + return + + team_set_partition = TeamUserPartition( + id, + str(name), + str(description), + groups, + self, + parameters, + active=True, + ) + TeamUserPartition.team_sets_mapping[id] = parameters["team_set_id"] + return team_set_partition diff --git a/openedx/core/lib/teams_config.py b/openedx/core/lib/teams_config.py index 636c5073f825..2291feb65c71 100644 --- a/openedx/core/lib/teams_config.py +++ b/openedx/core/lib/teams_config.py @@ -190,7 +190,7 @@ def __repr__(self): """ Return developer-helpful string. """ - attrs = ['teamset_id', 'name', 'description', 'max_team_size', 'teamset_type'] + attrs = ['teamset_id', 'name', 'description', 'max_team_size', 'teamset_type', 'dynamic_user_partition_id'] return "<{} {}>".format( self.__class__.__name__, " ".join( @@ -230,6 +230,7 @@ def cleaned_data(self): 'description': self.description, 'max_team_size': self.max_team_size, 'type': self.teamset_type.value, + 'dynamic_user_partition_id': self.dynamic_user_partition_id, } @cached_property @@ -287,6 +288,13 @@ def is_private_managed(self): """ return self.teamset_type == TeamsetType.private_managed + @cached_property + def dynamic_user_partition_id(self): + """ + The ID of the dynamic user partition for this team-set, + falling back to None. + """ + return self._data.get('dynamic_user_partition_id') class TeamsetType(Enum): """ diff --git a/setup.py b/setup.py index f405f92a95b4..d430c085bf47 100644 --- a/setup.py +++ b/setup.py @@ -107,6 +107,7 @@ "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", + "team = openedx.core.djangoapps.course_groups.partition_scheme:TeamPartitionScheme", ], "openedx.block_structure_transformer": [ "library_content = lms.djangoapps.course_blocks.transformers.library_content:ContentLibraryTransformer", @@ -181,7 +182,8 @@ ], 'openedx.dynamic_partition_generator': [ 'enrollment_track = xmodule.partitions.enrollment_track_partition_generator:create_enrollment_track_partition', # lint-amnesty, pylint: disable=line-too-long - 'content_type_gating = openedx.features.content_type_gating.partitions:create_content_gating_partition' + 'content_type_gating = openedx.features.content_type_gating.partitions:create_content_gating_partition', + 'team = openedx.core.djangoapps.course_groups.partition_generator:create_team_set_partition', ], 'xblock.v1': XBLOCKS, 'xblock_asides.v1': XBLOCKS_ASIDES, diff --git a/xmodule/partitions/partitions_service.py b/xmodule/partitions/partitions_service.py index e0d976dad7ad..6935733c86d0 100644 --- a/xmodule/partitions/partitions_service.py +++ b/xmodule/partitions/partitions_service.py @@ -82,7 +82,10 @@ def _get_dynamic_partitions(course): for generator in dynamic_partition_generators: generated_partition = generator(course) if generated_partition: - generated_partitions.append(generated_partition) + if isinstance(generated_partition, list): + generated_partitions.extend(generated_partition) + else: + generated_partitions.append(generated_partition) return generated_partitions