Exposes the hide_from_toc xblock attribute so course authors can configure it as a section visibility option in Studio. Before this change, the Hide from TOC functionality was mainly used by OLX components. Hence, it wasn't available for configuration through the Studio UI. Still, its implementation existed in the platform and could be used by setting the attribute: hide_from_toc=true as part of the OLX definition. Ref: https://openedx.atlassian.net/wiki/spaces/OEPM/pages/3853975595/Feature+Enhancement+Proposal+Hide+Sections+from+course+outline
452 lines
18 KiB
Python
452 lines
18 KiB
Python
"""
|
|
Support for inheritance of fields down an XBlock hierarchy.
|
|
"""
|
|
|
|
|
|
import warnings
|
|
from django.utils import timezone
|
|
from xblock.core import XBlockMixin
|
|
from xblock.fields import Boolean, Dict, Float, Integer, List, Scope, String
|
|
from xblock.runtime import KeyValueStore, KvsFieldData
|
|
|
|
from xmodule.error_block import ErrorBlock
|
|
from xmodule.fields import Date, Timedelta
|
|
from xmodule.partitions.partitions import UserPartition
|
|
|
|
from ..course_metadata_utils import DEFAULT_START_DATE
|
|
|
|
# Make '_' a no-op so we can scrape strings
|
|
# Using lambda instead of `django.utils.translation.ugettext_noop` because Django cannot be imported in this file
|
|
_ = lambda text: text
|
|
|
|
|
|
class UserPartitionList(List):
|
|
"""Special List class for listing UserPartitions"""
|
|
def from_json(self, values): # lint-amnesty, pylint: disable=arguments-differ
|
|
return [UserPartition.from_json(v) for v in values]
|
|
|
|
def to_json(self, values): # lint-amnesty, pylint: disable=arguments-differ
|
|
return [user_partition.to_json()
|
|
for user_partition in values]
|
|
|
|
|
|
class InheritanceMixin(XBlockMixin):
|
|
"""Field definitions for inheritable fields."""
|
|
|
|
graded = Boolean(
|
|
help="Whether this block contributes to the final course grade",
|
|
scope=Scope.settings,
|
|
default=False,
|
|
)
|
|
start = Date(
|
|
help="Start time when this block is visible",
|
|
default=DEFAULT_START_DATE,
|
|
scope=Scope.settings
|
|
)
|
|
due = Date(
|
|
display_name=_("Due Date"),
|
|
help=_("Enter the default date by which problems are due."),
|
|
scope=Scope.settings,
|
|
)
|
|
# This attribute is for custom pacing in self paced courses for Studio if CUSTOM_RELATIVE_DATES flag is active
|
|
relative_weeks_due = Integer(
|
|
display_name=_("Number of Relative Weeks Due By"),
|
|
help=_("Enter the number of weeks the problems are due by relative to the learner's enrollment date"),
|
|
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,
|
|
)
|
|
course_edit_method = String(
|
|
display_name=_("Course Editor"),
|
|
help=_("Enter the method by which this course is edited (\"XML\" or \"Studio\")."),
|
|
default="Studio",
|
|
scope=Scope.settings,
|
|
deprecated=True # Deprecated because user would not change away from Studio within Studio.
|
|
)
|
|
giturl = String(
|
|
display_name=_("GIT URL"),
|
|
help=_("Enter the URL for the course data GIT repository."),
|
|
scope=Scope.settings
|
|
)
|
|
xqa_key = String(
|
|
display_name=_("XQA Key"),
|
|
help=_("This setting is not currently supported."), scope=Scope.settings,
|
|
deprecated=True
|
|
)
|
|
graceperiod = Timedelta(
|
|
help="Amount of time after the due date that submissions will be accepted",
|
|
scope=Scope.settings,
|
|
)
|
|
group_access = Dict(
|
|
help=_("Enter the ids for the content groups this problem belongs to."),
|
|
scope=Scope.settings,
|
|
)
|
|
|
|
showanswer = String(
|
|
display_name=_("Show Answer"),
|
|
help=_(
|
|
# Translators: DO NOT translate the words in quotes here, they are
|
|
# specific words for the acceptable values.
|
|
'Specify when the Show Answer button appears for each problem. '
|
|
'Valid values are "always", "answered", "attempted", "closed", '
|
|
'"finished", "past_due", "correct_or_past_due", "after_all_attempts", '
|
|
'"after_all_attempts_or_correct", "attempted_no_past_due", and "never".'
|
|
),
|
|
scope=Scope.settings,
|
|
default="finished",
|
|
)
|
|
|
|
show_correctness = String(
|
|
display_name=_("Show Results"),
|
|
help=_(
|
|
# Translators: DO NOT translate the words in quotes here, they are
|
|
# specific words for the acceptable values.
|
|
'Specify when to show answer correctness and score to learners. '
|
|
'Valid values are "always", "never", and "past_due".'
|
|
),
|
|
scope=Scope.settings,
|
|
default="always",
|
|
)
|
|
|
|
rerandomize = String(
|
|
display_name=_("Randomization"),
|
|
help=_(
|
|
# Translators: DO NOT translate the words in quotes here, they are
|
|
# specific words for the acceptable values.
|
|
'Specify the default for how often variable values in a problem are randomized. '
|
|
'This setting should be set to "never" unless you plan to provide a Python '
|
|
'script to identify and randomize values in most of the problems in your course. '
|
|
'Valid values are "always", "onreset", "never", and "per_student".'
|
|
),
|
|
scope=Scope.settings,
|
|
default="never",
|
|
)
|
|
days_early_for_beta = Float(
|
|
display_name=_("Days Early for Beta Users"),
|
|
help=_("Enter the number of days before the start date that beta users can access the course."),
|
|
scope=Scope.settings,
|
|
default=None,
|
|
)
|
|
static_asset_path = String(
|
|
display_name=_("Static Asset Path"),
|
|
help=_("Enter the path to use for files on the Files & Uploads page. This value overrides the Studio default, c4x://."), # lint-amnesty, pylint: disable=line-too-long
|
|
scope=Scope.settings,
|
|
default='',
|
|
)
|
|
use_latex_compiler = Boolean(
|
|
display_name=_("Enable LaTeX Compiler"),
|
|
help=_("Enter true or false. If true, you can use the LaTeX templates for HTML components and advanced Problem components."), # lint-amnesty, pylint: disable=line-too-long
|
|
default=False,
|
|
scope=Scope.settings
|
|
)
|
|
max_attempts = Integer(
|
|
display_name=_("Maximum Attempts"),
|
|
help=_("Enter the maximum number of times a student can try to answer problems. By default, Maximum Attempts is set to null, meaning that students have an unlimited number of attempts for problems. You can override this course-wide setting for individual problems. However, if the course-wide setting is a specific number, you cannot set the Maximum Attempts for individual problems to unlimited."), # lint-amnesty, pylint: disable=line-too-long
|
|
values={"min": 0}, scope=Scope.settings
|
|
)
|
|
matlab_api_key = String(
|
|
display_name=_("Matlab API key"),
|
|
help=_("Enter the API key provided by MathWorks for accessing the MATLAB Hosted Service. "
|
|
"This key is granted for exclusive use in this course for the specified duration. "
|
|
"Do not share the API key with other courses. Notify MathWorks immediately "
|
|
"if you believe the key is exposed or compromised. To obtain a key for your course, "
|
|
"or to report an issue, please contact moocsupport@mathworks.com"),
|
|
scope=Scope.settings
|
|
)
|
|
# This is should be scoped to content, but since it's defined in the policy
|
|
# file, it is currently scoped to settings.
|
|
user_partitions = UserPartitionList(
|
|
display_name=_("Group Configurations"),
|
|
help=_("Enter the configurations that govern how students are grouped together."),
|
|
default=[],
|
|
scope=Scope.settings
|
|
)
|
|
video_speed_optimizations = Boolean(
|
|
display_name=_("Enable video caching system"),
|
|
help=_("Enter true or false. If true, video caching will be used for HTML5 videos."),
|
|
default=True,
|
|
scope=Scope.settings
|
|
)
|
|
video_auto_advance = Boolean(
|
|
display_name=_("Enable video auto-advance"),
|
|
help=_(
|
|
"Specify whether to show an auto-advance button in videos. If the student clicks it, when the last video in a unit finishes it will automatically move to the next unit and autoplay the first video." # lint-amnesty, pylint: disable=line-too-long
|
|
),
|
|
scope=Scope.settings,
|
|
default=False
|
|
)
|
|
video_bumper = Dict(
|
|
display_name=_("Video Pre-Roll"),
|
|
help=_(
|
|
"Identify a video, 5-10 seconds in length, to play before course videos. Enter the video ID from "
|
|
"the Video Uploads page and one or more transcript files in the following format: {format}. "
|
|
"For example, an entry for a video with two transcripts looks like this: {example}"
|
|
),
|
|
help_format_args=dict(
|
|
format='{"video_id": "ID", "transcripts": {"language": "/static/filename.srt"}}',
|
|
example=(
|
|
'{'
|
|
'"video_id": "77cef264-d6f5-4cf2-ad9d-0178ab8c77be", '
|
|
'"transcripts": {"en": "/static/DemoX-D01_1.srt", "uk": "/static/DemoX-D01_1_uk.srt"}'
|
|
'}'
|
|
),
|
|
),
|
|
scope=Scope.settings
|
|
)
|
|
|
|
show_reset_button = Boolean(
|
|
display_name=_("Show Reset Button for Problems"),
|
|
help=_(
|
|
"Enter true or false. If true, problems in the course default to always displaying a 'Reset' button. "
|
|
"You can override this in each problem's settings. All existing problems are affected when "
|
|
"this course-wide setting is changed."
|
|
),
|
|
scope=Scope.settings,
|
|
default=False
|
|
)
|
|
edxnotes = Boolean(
|
|
display_name=_("Enable Student Notes"),
|
|
help=_("Enter true or false. If true, students can use the Student Notes feature."),
|
|
default=False,
|
|
scope=Scope.settings
|
|
)
|
|
edxnotes_visibility = Boolean(
|
|
display_name="Student Notes Visibility",
|
|
help=_("Indicates whether Student Notes are visible in the course. "
|
|
"Students can also show or hide their notes in the courseware."),
|
|
default=True,
|
|
scope=Scope.user_info
|
|
)
|
|
|
|
in_entrance_exam = Boolean(
|
|
display_name=_("Tag this block as part of an Entrance Exam section"),
|
|
help=_("Enter true or false. If true, answer submissions for problem blocks will be "
|
|
"considered in the Entrance Exam scoring/gating algorithm."),
|
|
scope=Scope.settings,
|
|
default=False
|
|
)
|
|
|
|
self_paced = Boolean(
|
|
display_name=_('Self Paced'),
|
|
help=_(
|
|
'Set this to "true" to mark this course as self-paced. Self-paced courses do not have '
|
|
'due dates for assignments, and students can progress through the course at any rate before '
|
|
'the course ends.'
|
|
),
|
|
default=False,
|
|
scope=Scope.settings
|
|
)
|
|
|
|
hide_from_toc = Boolean(
|
|
display_name=_("Hide from Table of Contents"),
|
|
help=_("Enter true or false. If true, this block will be hidden from the Table of Contents."),
|
|
default=False,
|
|
scope=Scope.settings
|
|
)
|
|
|
|
@property
|
|
def close_date(self):
|
|
"""
|
|
Return the date submissions should be closed from.
|
|
|
|
If graceperiod is present for the course, all the submissions
|
|
can be submitted till due date and the graceperiod. If no
|
|
graceperiod, then the close date is same as the due date.
|
|
"""
|
|
due_date = self.due
|
|
|
|
if self.graceperiod is not None and due_date:
|
|
return due_date + self.graceperiod
|
|
return due_date
|
|
|
|
def is_past_due(self):
|
|
"""
|
|
Returns the boolean identifying if the submission due date has passed.
|
|
"""
|
|
return self.close_date is not None and timezone.now() > self.close_date
|
|
|
|
def has_deadline_passed(self):
|
|
"""
|
|
Returns a boolean indicating if the submission is past its deadline.
|
|
|
|
If the course is self-paced or no due date has been
|
|
specified, then the submission can be made. If none of these
|
|
cases exists, check if the submission due date has passed or not.
|
|
"""
|
|
if self.self_paced or self.close_date is None:
|
|
return False
|
|
return self.is_past_due()
|
|
|
|
|
|
def compute_inherited_metadata(block):
|
|
"""Given a block, traverse all of its descendants and do metadata
|
|
inheritance. Should be called on a CourseBlock after importing a
|
|
course.
|
|
|
|
NOTE: This means that there is no such thing as lazy loading at the
|
|
moment--this accesses all the children."""
|
|
if block.has_children:
|
|
if isinstance(block.xblock_kvs, InheritanceKeyValueStore):
|
|
parent_metadata = block.xblock_kvs.inherited_settings.copy()
|
|
else:
|
|
parent_metadata = {}
|
|
# add any of block's explicitly set fields to the inheriting list
|
|
for field in InheritanceMixin.fields.values(): # lint-amnesty, pylint: disable=no-member
|
|
if field.is_set_on(block):
|
|
# inherited_settings values are json repr
|
|
parent_metadata[field.name] = field.read_json(block)
|
|
|
|
for child in block.get_children():
|
|
inherit_metadata(child, parent_metadata)
|
|
compute_inherited_metadata(child)
|
|
|
|
|
|
def inherit_metadata(block, inherited_data):
|
|
"""
|
|
Updates this block with metadata inherited from a containing block.
|
|
Only metadata specified in self.inheritable_metadata will
|
|
be inherited
|
|
|
|
`inherited_data`: A dictionary mapping field names to the values that
|
|
they should inherit
|
|
"""
|
|
if isinstance(block, ErrorBlock):
|
|
return
|
|
|
|
block_type = block.scope_ids.block_type
|
|
if isinstance(block.xblock_kvs, InheritanceKeyValueStore):
|
|
# This XBlock's field_data is backed by InheritanceKeyValueStore, which supports pre-computed inherited fields
|
|
block.xblock_kvs.inherited_settings = inherited_data
|
|
else:
|
|
# We cannot apply pre-computed field data to this XBlock during import, but inheritance should still work
|
|
# normally when it's used in Studio/LMS, which use a different runtime.
|
|
# Though if someone ever needs a hacky temporary fix here, it's possible here to force it with:
|
|
# init_dict = {key: getattr(block, key) for key in block.fields.keys()}
|
|
# block._field_data = InheritanceKeyValueStore(init_dict)
|
|
warnings.warn(
|
|
f'Cannot inherit metadata to {block_type} block with KVS {block.xblock_kvs}',
|
|
stacklevel=2,
|
|
)
|
|
|
|
|
|
def own_metadata(block):
|
|
"""
|
|
Return a JSON-friendly dictionary that contains only non-inherited field
|
|
keys, mapped to their serialized values
|
|
"""
|
|
return block.get_explicitly_set_fields_by_scope(Scope.settings)
|
|
|
|
|
|
class InheritingFieldData(KvsFieldData):
|
|
"""
|
|
A `FieldData` implementation that can inherit value from parents to children.
|
|
|
|
This wraps a KeyValueStore, and will work fine with any KVS implementation.
|
|
Sometimes this wraps a subclass of InheritanceKeyValueStore, but that's not
|
|
a requirement.
|
|
|
|
This class is the way that inheritance "normally" works in modulestore.
|
|
During XML import/export, however, a different mechanism is used:
|
|
InheritanceKeyValueStore.
|
|
"""
|
|
|
|
def __init__(self, inheritable_names, **kwargs):
|
|
"""
|
|
`inheritable_names` is a list of names that can be inherited from
|
|
parents.
|
|
|
|
"""
|
|
super().__init__(**kwargs)
|
|
self.inheritable_names = set(inheritable_names)
|
|
|
|
def has_default_value(self, name):
|
|
"""
|
|
Return whether or not the field `name` has a default value
|
|
"""
|
|
has_default_value = getattr(self._kvs, 'has_default_value', False)
|
|
if callable(has_default_value):
|
|
return has_default_value(name)
|
|
|
|
return has_default_value
|
|
|
|
def default(self, block, name):
|
|
"""
|
|
The default for an inheritable name is found on a parent.
|
|
"""
|
|
if name in self.inheritable_names:
|
|
# Walk up the content tree to find the first ancestor
|
|
# that this field is set on. Use the field from the current
|
|
# block so that if it has a different default than the root
|
|
# node of the tree, the block's default will be used.
|
|
field = block.fields[name]
|
|
ancestor = block.get_parent()
|
|
# In case, if block's parent is of type 'library_content',
|
|
# bypass inheritance and use kvs' default instead of reusing
|
|
# from parent as '_copy_from_templates' puts fields into
|
|
# defaults.
|
|
if ancestor and \
|
|
ancestor.location.block_type == 'library_content' and \
|
|
self.has_default_value(name):
|
|
return super().default(block, name)
|
|
|
|
while ancestor is not None:
|
|
if field.is_set_on(ancestor):
|
|
return field.read_json(ancestor)
|
|
else:
|
|
ancestor = ancestor.get_parent()
|
|
return super().default(block, name)
|
|
|
|
|
|
def inheriting_field_data(kvs):
|
|
"""Create an InheritanceFieldData that inherits the names in InheritanceMixin."""
|
|
return InheritingFieldData(
|
|
inheritable_names=InheritanceMixin.fields.keys(), # lint-amnesty, pylint: disable=no-member
|
|
kvs=kvs,
|
|
)
|
|
|
|
|
|
class InheritanceKeyValueStore(KeyValueStore):
|
|
"""
|
|
Common superclass for kvs's which know about inheritance of settings. Offers simple
|
|
dict-based storage of fields and lookup of inherited values.
|
|
|
|
Note: inherited_settings is a dict of key to json values (internal xblock field repr)
|
|
|
|
Using this KVS is an alternative to using InheritingFieldData(). That one works with any KVS, like
|
|
DictKeyValueStore, and doesn't require any special behavior. On the other hand, this InheritanceKeyValueStore only
|
|
does inheritance properly if you first use compute_inherited_metadata() to walk the tree of XBlocks and pre-compute
|
|
the inherited metadata for the whole tree, storing it in the inherited_settings field of each instance of this KVS.
|
|
|
|
🟥 Warning: Unlike the base class, this KVS makes the assumption that you're using a completely separate KVS
|
|
instance for every XBlock, so that we only have to look at the "field_name" part of the key. You cannot use this
|
|
as a drop-in replacement for DictKeyValueStore for this reason.
|
|
"""
|
|
def __init__(self, initial_values=None, inherited_settings=None):
|
|
super().__init__()
|
|
self.inherited_settings = inherited_settings or {}
|
|
self._fields = initial_values or {}
|
|
|
|
def get(self, key):
|
|
return self._fields[key.field_name]
|
|
|
|
def set(self, key, value):
|
|
# xml backed courses are read-only, but they do have some computed fields
|
|
self._fields[key.field_name] = value
|
|
|
|
def delete(self, key):
|
|
del self._fields[key.field_name]
|
|
|
|
def has(self, key):
|
|
return key.field_name in self._fields
|
|
|
|
def default(self, key):
|
|
"""
|
|
Check to see if the default should be from inheritance. If not
|
|
inheriting, this will raise KeyError which will cause the caller to use
|
|
the field's global default.
|
|
"""
|
|
return self.inherited_settings[key.field_name]
|