""" User Partitions Transformer """ from lms.djangoapps.courseware.access import has_access from openedx.core.djangoapps.content.block_structure.transformer import ( # lint-amnesty, pylint: disable=unused-import BlockStructureTransformer ) from xmodule.partitions.partitions_service import ( get_all_partitions_for_course, get_partition_from_id, get_user_partition_groups ) from .split_test import SplitTestTransformer from .utils import get_field_on_block class UserPartitionTransformer(BlockStructureTransformer): """ A transformer that enforces the group access rules on course blocks, by honoring their user_partitions and group_access fields, and removing all blocks in the block structure to which the user does not have group access. Staff users are *not* exempted from user partition pathways. """ WRITE_VERSION = 1 READ_VERSION = 1 @classmethod def name(cls): """ Unique identifier for the transformer's class; same identifier used in setup.py. """ return "user_partitions" @classmethod def collect(cls, block_structure): """ Computes any information for each XBlock that's necessary to execute this transformer's transform method. Arguments: block_structure (BlockStructureCollectedData) """ # First have the split test transformer setup its group access # data for each block. SplitTestTransformer.collect(block_structure) # Because user partitions are course-wide, only store data for # them on the root block. root_block = block_structure.get_xblock(block_structure.root_block_usage_key) user_partitions = get_all_partitions_for_course(root_block, active_only=True) block_structure.set_transformer_data(cls, 'user_partitions', user_partitions) # If there are no user partitions, this transformation is a # no-op, so there is nothing to collect. if not user_partitions: return # For each block, compute merged group access. Because this is a # topological sort, we know a block's parents are guaranteed to # already have merged group access computed before the block # itself. for block_key in block_structure.topological_traversal(): xblock = block_structure.get_xblock(block_key) parent_keys = block_structure.get_parents(block_key) merged_parent_access_list = [ block_structure.get_transformer_block_field(parent_key, cls, 'merged_group_access') for parent_key in parent_keys ] merged_group_access = _MergedGroupAccess(user_partitions, xblock, merged_parent_access_list) block_structure.set_transformer_block_field(block_key, cls, 'merged_group_access', merged_group_access) def transform(self, usage_info, block_structure): user = usage_info.user SplitTestTransformer().transform(usage_info, block_structure) staff_access = has_access(user, 'staff', usage_info.course_key) # If you have staff access, you are allowed access to the entire result list if staff_access: return user_partitions = block_structure.get_transformer_data(self, 'user_partitions') if not user_partitions: return user_groups = get_user_partition_groups(usage_info.course_key, user_partitions, user, 'id') for block_key in block_structure.topological_traversal(): transformer_block_field = block_structure.get_transformer_block_field( block_key, self, 'merged_group_access' ) if transformer_block_field is None: continue access_denying_partition_ids = transformer_block_field.get_access_denying_partitions( user_groups ) access_denying_reasons = [] access_denying_messages = [] for partition_id in access_denying_partition_ids: access_denying_partition = get_partition_from_id(user_partitions, partition_id) if access_denying_partition: user_group = user_groups.get(access_denying_partition.id) allowed_groups = transformer_block_field.get_allowed_groups()[access_denying_partition.id] access_denied_message = access_denying_partition.access_denied_message( block_key, user, user_group, allowed_groups ) access_denying_reasons.append(access_denying_partition.name) if access_denied_message: access_denying_messages.append(access_denied_message) if access_denying_reasons and not access_denying_messages: block_structure.remove_block(block_key, keep_descendants=False) else: if access_denying_reasons: block_structure.override_xblock_field( block_key, 'authorization_denial_reason', access_denying_reasons[0] ) if access_denying_messages: block_structure.override_xblock_field( block_key, 'authorization_denial_message', access_denying_messages[0] ) class _MergedGroupAccess: """ A class object to represent the computed access value for a block, merged from the inherited values from its ancestors. Note: The implementation assumes that the block structure is topologically traversed so that all parents' merged accesses are computed before a block's. How group access restrictions are represented within an XBlock: - group_access not defined => No group access restrictions. - For each partition: - partition.id not in group_access => All groups have access for this partition - group_access[partition_id] is None => All groups have access for this partition - group_access[partition_id] == [] => All groups have access for this partition - group_access[partition_id] == [group1..groupN] => groups 1..N have access for this partition We internally represent the restrictions in a simplified way: - self._access == {} => No group access restrictions. - For each partition: - partition.id not in _access => All groups have access for this partition - _access[partition_id] == set() => No groups have access for this partition - _access[partition_id] == set(group1..groupN) => groups 1..N have access for this partition Note that a user must have access to all partitions in group_access or _access in order to access a block. """ def __init__(self, user_partitions, xblock, merged_parent_access_list): """ Arguments: user_partitions (list[UserPartition]) xblock (XBlock) merged_parent_access_list (list[_MergedGroupAccess]) """ # { partition.id: set(IDs of groups that can access partition) } # If partition id is absent in this dict, no group access # restrictions exist for that partition. self._access = {} # Get the group_access value that is directly set on the xblock. # Do not get the inherited value since field inheritance doesn't # take a union of them for DAGs. xblock_group_access = get_field_on_block(xblock, 'group_access', default_value={}) for partition in user_partitions: # Running list of all groups that have access to this # block, computed as a "union" from all parent chains. # # Set the default to universal access, for the case when # there are no parents. merged_parent_group_ids = None if merged_parent_access_list: # Set the default to most restrictive as we iterate # through all the parent chains. merged_parent_group_ids = set() # Loop through parent_access from each parent-chain for merged_parent_access in merged_parent_access_list: # pylint: disable=protected-access if partition.id in merged_parent_access._access: # Since this parent has group access # restrictions, merge it with the running list # of parent-introduced restrictions. merged_parent_group_ids.update(merged_parent_access._access[partition.id]) else: # Since this parent chain has no group access # restrictions for this partition, allow # unfettered group access for this partition # and don't bother checking the rest of the # parents. merged_parent_group_ids = None break # Group access for this partition as stored on the xblock xblock_partition_access = set(xblock_group_access.get(partition.id) or []) or None # Compute this block's access by intersecting the block's # own access with the merged access from its parent chains. merged_group_ids = _MergedGroupAccess._intersection(xblock_partition_access, merged_parent_group_ids) # Add this partition's access only if group restrictions # exist. if merged_group_ids is not None: self._access[partition.id] = merged_group_ids def get_allowed_groups(self): return self._access @staticmethod def _intersection(*sets): """ Compute an intersection of sets, interpreting None as the Universe set. This makes __init__ a bit more elegant. Arguments: sets (list[set or None]), where None represents the Universe set. Returns: set or None, where None represents the Universe set. """ non_universe_sets = [set_ for set_ in sets if set_ is not None] if non_universe_sets: first, rest = non_universe_sets[0], non_universe_sets[1:] return first.intersection(*rest) else: return None def get_access_denying_partitions(self, user_groups): """ Arguments: dict[int: Group]: Given a user, a mapping from user partition IDs to the group to which the user belongs in each partition. Returns: list of ints: Which partition is denying access """ denied_access = [] for partition_id, allowed_group_ids in self.get_allowed_groups().items(): # If the user is not assigned to a group for this partition, # return partition as one that would deny access. if partition_id not in user_groups: denied_access.append(partition_id) # If the user does not belong to one of the allowed groups for this # partition, then return this partition as one that would deny access elif user_groups[partition_id].id not in allowed_group_ids: denied_access.append(partition_id) return denied_access def check_group_access(self, user_groups): """ Arguments: dict[int: Group]: Given a user, a mapping from user partition IDs to the group to which the user belongs in each partition. Returns: bool: Whether said user has group access. """ return not self.get_access_denying_partitions(user_groups)