class SplitTestFields(object): """Fields needed for split test module""" has_children = True # All available user partitions (with value and display name). This is updated each time # editable_metadata_fields is called. user_partition_values = [] # Default value used for user_partition_id no_partition_selected = {'display_name': _("Not Selected"), 'value': -1} @staticmethod def build_partition_values(all_user_partitions, selected_user_partition): """ This helper method builds up the user_partition values that will be passed to the Studio editor """ SplitTestFields.user_partition_values = [] # Add "No selection" value if there is not a valid selected user partition. if not selected_user_partition: SplitTestFields.user_partition_values.append(SplitTestFields.no_partition_selected) for user_partition in all_user_partitions: SplitTestFields.user_partition_values.append({"display_name": user_partition.name, "value": user_partition.id}) return SplitTestFields.user_partition_values display_name = String( display_name=_("Display Name"), help=_("This name is used for organizing your course content, but is not shown to students."), scope=Scope.settings, default=_("Content Experiment") ) # 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 ) user_partition_id = Integer( help=_("The configuration defines how users are grouped for this content experiment. Caution: Changing the group configuration of a student-visible experiment will impact the experiment data."), scope=Scope.content, display_name=_("Group Configuration"), default=no_partition_selected["value"], values=lambda: SplitTestFields.user_partition_values # Will be populated before the Studio editor is shown. ) # group_id is an int # child is a serialized UsageId (aka Location). This child # location needs to actually match one of the children of this # Block. (expected invariant that we'll need to test, and handle # authoring tools that mess this up) # TODO: is there a way to add some validation around this, to # be run on course load or in studio or .... group_id_to_child = ReferenceValueDict( help=_("Which child module students in a particular group_id should see"), scope=Scope.content )
class SplitTestFields: """Fields needed for split test module""" has_children = True # Default value used for user_partition_id no_partition_selected = {'display_name': _("Not Selected"), 'value': -1} display_name = String( display_name=_("Display Name"), help=_("The display name for this component. (Not shown to learners)"), scope=Scope.settings, default=_("Content Experiment")) # 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) user_partition_id = Integer( help= _("The configuration defines how users are grouped for this content experiment. Caution: Changing the group configuration of a student-visible experiment will impact the experiment data." ), # lint-amnesty, pylint: disable=line-too-long scope=Scope.content, display_name=_("Group Configuration"), default=no_partition_selected["value"], values=lambda: user_partition_values. values # Will be populated before the Studio editor is shown. ) # group_id is an int # child is a serialized UsageId (aka Location). This child # location needs to actually match one of the children of this # Block. (expected invariant that we'll need to test, and handle # authoring tools that mess this up) group_id_to_child = ReferenceValueDict(help=_( "Which child module students in a particular group_id should see"), scope=Scope.content)
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(): 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( "could not find a UserPartition with ID [{}]".format( user_partition_id)) def validate(self): """ Validates the state of this xblock instance. """ _ = self.runtime.service(self, "i18n").ugettext validation = super(LmsBlockMixin, self).validate() has_invalid_user_partitions = False has_invalid_groups = False for user_partition_id, group_ids in self.group_access.iteritems(): 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)) if has_invalid_groups: validation.add( ValidationMessage(ValidationMessage.ERROR, INVALID_USER_PARTITION_GROUP_VALIDATION)) return validation
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( u"could not find a UserPartition with ID [{}]".format( 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 six.iteritems( parent_group_access): 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(LmsBlockMixin, self).validate() has_invalid_user_partitions = False has_invalid_groups = False block_is_unit = is_unit(self) for user_partition_id, group_ids in six.iteritems(self.group_access): 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: raise JsonHandlerError(500, u"No completion service found") elif not completion_service.completion_tracking_enabled(): raise JsonHandlerError( 404, u"Completion tracking is not enabled and API calls are unexpected" ) if not completion_service.can_mark_block_complete_on_view(self): raise JsonHandlerError( 400, u"Block not configured for completion on view.") self.runtime.publish(self, "completion", data) return {'result': 'ok'}
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( 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=_("Courseware Chrome"), 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 Courseware 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) ispublic = Boolean( display_name=_("Course Is Public"), help= _("Enter true or false. If true, the course is open to the public. If false, the course is open only to admins." ), scope=Scope.settings) 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 = Dict( 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 list 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, ) # 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, or None if there is no such partition. """ for user_partition in self.user_partitions: if user_partition.id == user_partition_id: return user_partition return None def is_visible_to_group(self, user_partition, group): """ Returns true if this xblock should be shown to a user in the specified user partition group. This method returns true if one of the following is true: - the xblock has no group_access dictionary specified - if the dictionary has no key for the user partition's id - if the value for the user partition's id is an empty list - if the value for the user partition's id contains the specified group's id """ if not self.group_access: return True group_ids = self.group_access.get(user_partition.id, []) if len(group_ids) == 0: return True return group.id in group_ids def validate(self): """ Validates the state of this xblock instance. """ _ = self.runtime.service(self, "i18n").ugettext # pylint: disable=redefined-outer-name validation = super(LmsBlockMixin, self).validate() for user_partition_id, group_ids in self.group_access.iteritems(): user_partition = self._get_user_partition(user_partition_id) if not user_partition: validation.add( ValidationMessage( ValidationMessage.ERROR, _(u"This xblock refers to a deleted or invalid content group configuration." ))) else: for group_id in group_ids: group = user_partition.get_group(group_id) if not group: validation.add( ValidationMessage( ValidationMessage.ERROR, _(u"This xblock refers to a deleted or invalid content group." ))) return validation