""" Namespace that defines fields common to all blocks used in the LMS """ #from django.utils.translation import ugettext_noop as _ from lazy import lazy from xblock.core import XBlock, XBlockMixin from xblock.exceptions import JsonHandlerError from xblock.fields import Boolean, Dict, Scope, String from xblock.validation import ValidationMessage from lms.lib.utils import is_unit from xmodule.modulestore.inheritance import UserPartitionList from xmodule.partitions.partitions import NoSuchUserPartitionError, NoSuchUserPartitionGroupError # Please do not remove, this is a workaround for Django 1.8. # more information can be found here: https://openedx.atlassian.net/browse/PLAT-902 _ = lambda text: text INVALID_USER_PARTITION_VALIDATION_COMPONENT = _( "This component's access settings refer to deleted or invalid group configurations." ) INVALID_USER_PARTITION_VALIDATION_UNIT = _( "This unit's access settings refer to deleted or invalid group configurations." ) INVALID_USER_PARTITION_GROUP_VALIDATION_COMPONENT = _( "This component's access settings refer to deleted or invalid groups." ) INVALID_USER_PARTITION_GROUP_VALIDATION_UNIT = _("This unit's access settings refer to deleted or invalid groups.") NONSENSICAL_ACCESS_RESTRICTION = _("This component's access settings contradict its parent's access settings.") class GroupAccessDict(Dict): """ Special Dict class for serializing the group_access field. """ def from_json(self, value): if value is not None: return {int(k): value[k] for k in value} def to_json(self, value): if value is not None: return {str(k): value[k] for k in value} @XBlock.needs('partitions') @XBlock.needs('i18n') @XBlock.wants('completion') class LmsBlockMixin(XBlockMixin): """ Mixin that defines fields common to all blocks used in the LMS """ hide_from_toc = Boolean( help=_("Whether to display this module in the table of contents"), default=False, scope=Scope.settings ) format = String( # Translators: "TOC" stands for "Table of Contents" help=_("What format this module is in (used for deciding which " "grader to apply, and what to show in the TOC)"), scope=Scope.settings, ) chrome = String( display_name=_("Course Chrome"), # Translators: DO NOT translate the words in quotes here, they are # specific words for the acceptable values. help=_("Enter the chrome, or navigation tools, to use for the XBlock in the LMS. Valid values are: \n" "\"chromeless\" -- to not use tabs or the accordion; \n" "\"tabs\" -- to use tabs only; \n" "\"accordion\" -- to use the accordion only; or \n" "\"tabs,accordion\" -- to use tabs and the accordion."), scope=Scope.settings, default=None, ) default_tab = String( display_name=_("Default Tab"), help=_("Enter the tab that is selected in the XBlock. If not set, the Course tab is selected."), scope=Scope.settings, default=None, ) source_file = String( display_name=_("LaTeX Source File Name"), help=_("Enter the source file name for LaTeX."), scope=Scope.settings, deprecated=True ) visible_to_staff_only = Boolean( help=_("If true, can be seen only by course staff, regardless of start date."), default=False, scope=Scope.settings, ) group_access = GroupAccessDict( help=_( "A dictionary that maps which groups can be shown this block. The keys " "are group configuration ids and the values are a list of group IDs. " "If there is no key for a group configuration or if the set of group IDs " "is empty then the block is considered visible to all. Note that this " "field is ignored if the block is visible_to_staff_only." ), default={}, scope=Scope.settings, ) @lazy def merged_group_access(self): """ This computes access to a block's group_access rules in the context of its position within the courseware structure, in the form of a lazily-computed attribute. Each block's group_access rule is merged recursively with its parent's, guaranteeing that any rule in a parent block will be enforced on descendants, even if a descendant also defined its own access rules. The return value is always a dict, with the same structure as that of the group_access field. When merging access rules results in a case where all groups are denied access in a user partition (which effectively denies access to that block for all students), the special value False will be returned for that user partition key. """ parent = self.get_parent() if not parent: return self.group_access or {} merged_access = parent.merged_group_access.copy() if self.group_access is not None: for partition_id, group_ids in self.group_access.items(): # pylint: disable=no-member if group_ids: # skip if the "local" group_access for this partition is None or empty. if partition_id in merged_access: if merged_access[partition_id] is False: # special case - means somewhere up the hierarchy, merged access rules have eliminated # all group_ids from this partition, so there's no possible intersection. continue # otherwise, if the parent defines group access rules for this partition, # intersect with the local ones. merged_access[partition_id] = list( set(merged_access[partition_id]).intersection(group_ids) ) or False else: # add the group access rules for this partition to the merged set of rules. merged_access[partition_id] = group_ids return merged_access # Specified here so we can see what the value set at the course-level is. user_partitions = UserPartitionList( help=_("The list of group configurations for partitioning students in content experiments."), default=[], scope=Scope.settings ) def _get_user_partition(self, user_partition_id): """ Returns the user partition with the specified id. Note that this method can return an inactive user partition. Raises `NoSuchUserPartitionError` if the lookup fails. """ for user_partition in self.runtime.service(self, 'partitions').course_partitions: if user_partition.id == user_partition_id: return user_partition raise NoSuchUserPartitionError(f"could not find a UserPartition with ID [{user_partition_id}]") def _has_nonsensical_access_settings(self): """ Checks if a block's group access settings do not make sense. By nonsensical access settings, we mean a component's access settings which contradict its parent's access in that they restrict access to the component to a group that already will not be able to see that content. Note: This contradiction can occur when a component restricts access to the same partition but a different group than its parent, or when there is a parent access restriction but the component attempts to allow access to all learners. Returns: bool: True if the block's access settings contradict its parent's access settings. """ parent = self.get_parent() if not parent: return False parent_group_access = parent.group_access component_group_access = self.group_access for user_partition_id, parent_group_ids in parent_group_access.items(): component_group_ids = component_group_access.get(user_partition_id) # pylint: disable=no-member if component_group_ids: return parent_group_ids and not set(component_group_ids).issubset(set(parent_group_ids)) else: return not component_group_access return False def validate(self): """ Validates the state of this xblock instance. """ _ = self.runtime.service(self, "i18n").ugettext validation = super().validate() has_invalid_user_partitions = False has_invalid_groups = False block_is_unit = is_unit(self) for user_partition_id, group_ids in self.group_access.items(): # lint-amnesty, pylint:disable=no-member try: user_partition = self._get_user_partition(user_partition_id) except NoSuchUserPartitionError: has_invalid_user_partitions = True else: # Skip the validation check if the partition has been disabled if user_partition.active: for group_id in group_ids: try: user_partition.get_group(group_id) except NoSuchUserPartitionGroupError: has_invalid_groups = True if has_invalid_user_partitions: validation.add( ValidationMessage( ValidationMessage.ERROR, (INVALID_USER_PARTITION_VALIDATION_UNIT if block_is_unit else INVALID_USER_PARTITION_VALIDATION_COMPONENT) ) ) if has_invalid_groups: validation.add( ValidationMessage( ValidationMessage.ERROR, (INVALID_USER_PARTITION_GROUP_VALIDATION_UNIT if block_is_unit else INVALID_USER_PARTITION_GROUP_VALIDATION_COMPONENT) ) ) if self._has_nonsensical_access_settings(): validation.add( ValidationMessage( ValidationMessage.ERROR, NONSENSICAL_ACCESS_RESTRICTION ) ) return validation @XBlock.json_handler def publish_completion(self, data, suffix=''): # pylint: disable=unused-argument """ Publish completion data from the front end. """ completion_service = self.runtime.service(self, 'completion') if completion_service is None: # lint-amnesty, pylint: disable=no-else-raise raise JsonHandlerError(500, "No completion service found") elif not completion_service.completion_tracking_enabled(): raise JsonHandlerError(404, "Completion tracking is not enabled and API calls are unexpected") if not completion_service.can_mark_block_complete_on_view(self): raise JsonHandlerError(400, "Block not configured for completion on view.") self.runtime.publish(self, "completion", data) return {'result': 'ok'}