Files
edx-platform/xmodule/tests/test_xml_block.py
2026-01-07 16:39:11 +05:00

769 lines
29 KiB
Python

"""Unit tests for XBlock field serialization, deserialization, and XML attributes."""
import unittest
from unittest.mock import Mock
import dateutil.parser
from lxml import etree
from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator
from xblock.field_data import DictFieldData
from xblock.fields import Any, Boolean, Date, Dict, Float, Integer, List, RelativeTime, Scope, String, Timedelta
from xblock.runtime import DictKeyValueStore, KvsFieldData
from xmodule.course_block import CourseBlock
from xmodule.modulestore.inheritance import InheritanceKeyValueStore, InheritanceMixin, InheritingFieldData
from xmodule.modulestore.split_mongo.split_mongo_kvs import SplitMongoKVS
from xmodule.seq_block import SequenceBlock
from xmodule.tests import get_test_descriptor_system
from xmodule.tests.xml import XModuleXmlImportTest
from xmodule.tests.xml.factories import CourseFactory, ProblemFactory, SequenceFactory
from xmodule.x_module import XModuleMixin
from xmodule.xml_block import XmlMixin, deserialize_field, serialize_field
class CrazyJsonString(String): # pylint: disable=too-few-public-methods
"""String field that appends ' JSON' when serialized."""
def to_json(self, value):
"""Return the string value appended with ' JSON'."""
return value + " JSON"
class TestFields: # pylint: disable=too-few-public-methods
"""XBlock fields for testing editable and inherited behavior."""
# Will be returned by editable_metadata_fields.
max_attempts = Integer(scope=Scope.settings, default=1000, values={"min": 1, "max": 10})
# Will not be returned by editable_metadata_fields because filtered out by non_editable_metadata_fields.
due = Date(scope=Scope.settings)
# Will not be returned by editable_metadata_fields because is not Scope.settings.
student_answers = Dict(scope=Scope.user_state)
# Will be returned, and can override the inherited value from XModule.
display_name = String(
scope=Scope.settings, default="local default", display_name="Local Display Name", help="local help"
)
# Used for testing select type, effect of to_json method
string_select = CrazyJsonString(
scope=Scope.settings,
default="default value",
values=[{"display_name": "first", "value": "value a"}, {"display_name": "second", "value": "value b"}],
)
showanswer = InheritanceMixin.showanswer
# Used for testing select type
float_select = Float(scope=Scope.settings, default=0.999, values=[1.23, 0.98])
# Used for testing float type
float_non_select = Float(scope=Scope.settings, default=0.999, values={"min": 0, "step": 0.3})
# Used for testing that Booleans get mapped to select type
boolean_select = Boolean(scope=Scope.settings)
# Used for testing Lists
list_field = List(scope=Scope.settings, default=[])
class InheritingFieldDataTest(unittest.TestCase):
"""
Tests of InheritingFieldData.
"""
class TestableInheritingXBlock(XmlMixin):
"""
An XBlock we can use in these tests.
"""
inherited = String(scope=Scope.settings, default="the default")
not_inherited = String(scope=Scope.settings, default="nothing")
@classmethod
def definition_from_xml(cls, xml_object, system):
return {}, []
def definition_to_xml(self, resource_fs):
return etree.Element("test_block")
def setUp(self):
super().setUp()
self.dummy_course_key = CourseLocator("test_org", "test_123", "test_run")
self.system = get_test_descriptor_system()
self.all_blocks = {}
self.system.get_block = self.all_blocks.get
self.field_data = InheritingFieldData(
inheritable_names=["inherited"],
kvs=DictKeyValueStore({}),
)
def get_block_using_split_kvs(self, block_type, block_id, fields, defaults):
"""
Construct an Xblock with split mongo kvs.
"""
kvs = SplitMongoKVS(definition=Mock(), initial_values=fields, default_values=defaults, parent=None)
self.field_data = InheritingFieldData(
inheritable_names=["inherited"],
kvs=kvs,
)
block = self.get_a_block(usage_id=self.get_usage_id(block_type, block_id))
return block
def get_a_block(self, usage_id=None):
"""
Construct an XBlock for testing with.
"""
scope_ids = Mock()
if usage_id is None:
block_id = f"_auto{len(self.all_blocks)}"
usage_id = self.get_usage_id("course", block_id)
scope_ids.usage_id = usage_id
block = self.system.construct_xblock_from_class(
self.TestableInheritingXBlock,
field_data=self.field_data,
scope_ids=scope_ids,
)
self.all_blocks[usage_id] = block
return block
def get_usage_id(self, block_type, block_id):
"""
Constructs usage id using 'block_type' and 'block_id'
"""
return BlockUsageLocator(self.dummy_course_key, block_type=block_type, block_id=block_id)
def test_default_value(self):
"""
Test that the Blocks with nothing set with return the fields' defaults.
"""
block = self.get_a_block()
assert block.inherited == "the default"
assert block.not_inherited == "nothing"
def test_set_value(self):
"""
Test that If you set a value, that's what you get back.
"""
block = self.get_a_block()
block.inherited = "Changed!"
block.not_inherited = "New Value!"
assert block.inherited == "Changed!"
assert block.not_inherited == "New Value!"
def test_inherited(self):
"""
Test that a child with get a value inherited from the parent.
"""
parent_block = self.get_a_block(usage_id=self.get_usage_id("course", "parent"))
parent_block.inherited = "Changed!"
assert parent_block.inherited == "Changed!"
child = self.get_a_block(usage_id=self.get_usage_id("vertical", "child"))
child.parent = parent_block.location
assert child.inherited == "Changed!"
def test_inherited_across_generations(self):
"""
Test that a child with get a value inherited from a great-grandparent.
"""
parent = self.get_a_block(usage_id=self.get_usage_id("course", "parent"))
parent.inherited = "Changed!"
assert parent.inherited == "Changed!"
for child_num in range(10):
usage_id = self.get_usage_id("vertical", f"child_{child_num}")
child = self.get_a_block(usage_id=usage_id)
child.parent = parent.location
assert child.inherited == "Changed!"
def test_not_inherited(self):
"""
Test that the fields not in the inherited_names list won't be inherited.
"""
parent = self.get_a_block(usage_id=self.get_usage_id("course", "parent"))
parent.not_inherited = "Changed!"
assert parent.not_inherited == "Changed!"
child = self.get_a_block(usage_id=self.get_usage_id("vertical", "child"))
child.parent = parent.location
assert child.not_inherited == "nothing"
def test_non_defaults_inherited_across_lib(self):
"""
Test that a child inheriting from library_content block, inherits fields
from parent if these fields are not in its defaults.
"""
parent_block = self.get_block_using_split_kvs(
block_type="library_content",
block_id="parent",
fields={"inherited": "changed!"},
defaults={"inherited": "parent's default"},
)
assert parent_block.inherited == "changed!"
child = self.get_block_using_split_kvs(
block_type="problem",
block_id="child",
fields={},
defaults={},
)
child.parent = parent_block.location
assert child.inherited == "changed!"
def test_defaults_not_inherited_across_lib(self):
"""
Test that a child inheriting from library_content block, does not inherit
fields from parent if these fields are in its defaults already.
"""
parent_block = self.get_block_using_split_kvs(
block_type="library_content",
block_id="parent",
fields={"inherited": "changed!"},
defaults={"inherited": "parent's default"},
)
assert parent_block.inherited == "changed!"
child = self.get_block_using_split_kvs(
block_type="library_content",
block_id="parent",
fields={},
defaults={"inherited": "child's default"},
)
child.parent = parent_block.location
assert child.inherited == "child's default"
class EditableMetadataFieldsTest(unittest.TestCase):
"""Tests editable metadata fields and their serialization."""
class TestableXmlXBlock(XmlMixin, XModuleMixin):
"""
This is subclassing `XModuleMixin` to use metadata fields in the unmixed class.
"""
@classmethod
def definition_from_xml(cls, xml_object, system):
return {}, []
def definition_to_xml(self, resource_fs):
return etree.Element("test_block")
def test_display_name_field(self):
"""Test filtering and values of display_name editable metadata field."""
editable_fields = self.get_xml_editable_fields(DictFieldData({}))
# Tests that the xblock fields (currently tags and name) get filtered out.
# Also tests that xml_attributes is filtered out of XmlMixin.
assert 1 == len(editable_fields), editable_fields
self.assert_field_values(
editable_fields,
"display_name",
XModuleMixin.display_name,
explicitly_set=False,
value=None,
default_value=None,
)
def test_override_default(self):
"""Test explicitly set values override defaults for editable fields."""
# Tests that explicitly_set is correct when a value overrides the default (not inheritable).
editable_fields = self.get_xml_editable_fields(DictFieldData({"display_name": "foo"}))
self.assert_field_values(
editable_fields,
"display_name",
XModuleMixin.display_name,
explicitly_set=True,
value="foo",
default_value=None,
)
def test_integer_field(self):
"""Test serialization and options of Integer metadata fields."""
block = self.get_block(DictFieldData({"max_attempts": "7"}))
editable_fields = block.editable_metadata_fields
assert 8 == len(editable_fields)
self.assert_field_values(
editable_fields,
"max_attempts",
TestFields.max_attempts,
explicitly_set=True,
value=7,
default_value=1000,
type="Integer",
options=TestFields.max_attempts.values,
)
self.assert_field_values(
editable_fields,
"display_name",
TestFields.display_name,
explicitly_set=False,
value="local default",
default_value="local default",
)
editable_fields = self.get_block(DictFieldData({})).editable_metadata_fields
self.assert_field_values(
editable_fields,
"max_attempts",
TestFields.max_attempts,
explicitly_set=False,
value=1000,
default_value=1000,
type="Integer",
options=TestFields.max_attempts.values,
)
def test_inherited_field(self):
"""Test inheritance behavior of editable metadata fields."""
kvs = InheritanceKeyValueStore(initial_values={}, inherited_settings={"showanswer": "inherited"})
model_data = KvsFieldData(kvs)
block = self.get_block(model_data)
editable_fields = block.editable_metadata_fields
self.assert_field_values(
editable_fields,
"showanswer",
InheritanceMixin.showanswer,
explicitly_set=False,
value="inherited",
default_value="inherited",
)
# Mimic the case where display_name WOULD have been inherited, except we explicitly set it.
kvs = InheritanceKeyValueStore(
initial_values={"showanswer": "explicit"}, inherited_settings={"showanswer": "inheritable value"}
)
model_data = KvsFieldData(kvs)
block = self.get_block(model_data)
editable_fields = block.editable_metadata_fields
self.assert_field_values(
editable_fields,
"showanswer",
InheritanceMixin.showanswer,
explicitly_set=True,
value="explicit",
default_value="inheritable value",
)
def test_type_and_options(self):
"""Test type and options representation of various editable metadata fields."""
# test_display_name_field verifies that a String field is of type "Generic".
# test_integer_field verifies that a Integer field is of type "Integer".
block = self.get_block(DictFieldData({}))
editable_fields = block.editable_metadata_fields
# Tests for select
self.assert_field_values(
editable_fields,
"string_select",
TestFields.string_select,
explicitly_set=False,
value="default value",
default_value="default value",
type="Select",
options=[
{"display_name": "first", "value": "value a JSON"},
{"display_name": "second", "value": "value b JSON"},
],
)
self.assert_field_values(
editable_fields,
"float_select",
TestFields.float_select,
explicitly_set=False,
value=0.999,
default_value=0.999,
type="Select",
options=[1.23, 0.98],
)
self.assert_field_values(
editable_fields,
"boolean_select",
TestFields.boolean_select,
explicitly_set=False,
value=None,
default_value=None,
type="Select",
options=[{"display_name": "True", "value": True}, {"display_name": "False", "value": False}],
)
# Test for float
self.assert_field_values(
editable_fields,
"float_non_select",
TestFields.float_non_select,
explicitly_set=False,
value=0.999,
default_value=0.999,
type="Float",
options={"min": 0, "step": 0.3},
)
self.assert_field_values(
editable_fields,
"list_field",
TestFields.list_field,
explicitly_set=False,
value=[],
default_value=[],
type="List",
)
# Start of helper methods
def get_xml_editable_fields(self, field_data):
"""Return editable fields from a test XML XBlock with given field data."""
runtime = get_test_descriptor_system()
return runtime.construct_xblock_from_class(
self.TestableXmlXBlock,
scope_ids=Mock(),
field_data=field_data,
).editable_metadata_fields
def get_block(self, field_data):
"""Construct a test XBlock combining test fields and XML behavior."""
class TestModuleBlock(TestFields, self.TestableXmlXBlock): # pylint: disable=too-many-ancestors
"""Test XBlock class combining test fields and XML behavior, overriding non-editable fields."""
@property
def non_editable_metadata_fields(self):
non_editable_fields = super().non_editable_metadata_fields
non_editable_fields.append(TestModuleBlock.due)
return non_editable_fields
system = get_test_descriptor_system(render_template=Mock())
return system.construct_xblock_from_class(TestModuleBlock, field_data=field_data, scope_ids=Mock())
def assert_field_values( # pylint: disable=dangerous-default-value,too-many-arguments,too-many-positional-arguments
self,
editable_fields,
name,
field,
explicitly_set,
value,
default_value,
type="Generic", # pylint: disable=redefined-builtin
options=[],
):
"""Assert correctness of field values, type, options, and explicitness."""
test_field = editable_fields[name]
assert field.name == test_field["field_name"]
assert field.display_name == test_field["display_name"]
assert field.help == test_field["help"]
assert field.to_json(value) == test_field["value"]
assert field.to_json(default_value) == test_field["default_value"]
assert options == test_field["options"]
assert type == test_field["type"]
assert explicitly_set == test_field["explicitly_set"]
class TestSerialize(unittest.TestCase):
"""Tests the serialize, method, which is not dependent on type."""
def test_serialize(self):
"""Test serialization of various field types to JSON-compatible strings."""
assert serialize_field(None) == "null"
assert serialize_field(-2) == "-2"
assert serialize_field("2") == "2"
assert serialize_field(-3.41) == "-3.41"
assert serialize_field("2.589") == "2.589"
assert serialize_field(False) == "false"
assert serialize_field("false") == "false"
assert serialize_field("fAlse") == "fAlse"
assert serialize_field("hat box") == "hat box"
serialized_dict = serialize_field({"bar": "hat", "frog": "green"})
assert serialized_dict in ('{"bar": "hat", "frog": "green"}', '{"frog": "green", "bar": "hat"}')
assert serialize_field([3.5, 5.6]) == "[3.5, 5.6]"
assert serialize_field(["foo", "bar"]) == '["foo", "bar"]'
assert serialize_field("2012-12-31T23:59:59Z") == "2012-12-31T23:59:59Z"
assert serialize_field("1 day 12 hours 59 minutes 59 seconds") == "1 day 12 hours 59 minutes 59 seconds"
assert serialize_field(dateutil.parser.parse("2012-12-31T23:59:59Z")) == "2012-12-31T23:59:59+00:00"
class TestDeserialize(unittest.TestCase):
"""Base class for testing deserialization of field values."""
def assert_deserialize_equal(self, expected, arg):
"""
Asserts the result of deserialize_field.
"""
assert deserialize_field(self.field_type(), arg) == expected # pylint: disable=no-member
def assert_deserialize_non_string(self):
"""
Asserts input value is returned for None or something that is not a string.
For all types, 'null' is also always returned as None.
"""
self.assert_deserialize_equal(None, None)
self.assert_deserialize_equal(3.14, 3.14)
self.assert_deserialize_equal(True, True)
self.assert_deserialize_equal([10], [10])
self.assert_deserialize_equal({}, {})
self.assert_deserialize_equal([], [])
self.assert_deserialize_equal(None, "null")
class TestDeserializeInteger(TestDeserialize):
"""Tests deserialize as related to Integer type."""
field_type = Integer
def test_deserialize(self):
"""Test deserialization of Integer field values."""
self.assert_deserialize_equal(-2, "-2")
self.assert_deserialize_equal("450", '"450"')
# False can be parsed as a int (converts to 0)
self.assert_deserialize_equal(False, "false")
# True can be parsed as a int (converts to 1)
self.assert_deserialize_equal(True, "true")
# 2.78 can be converted to int, so the string will be deserialized
self.assert_deserialize_equal(-2.78, "-2.78")
def test_deserialize_unsupported_types(self):
"""Test deserialization handles unsupported Integer inputs gracefully."""
self.assert_deserialize_equal("[3]", "[3]")
# '2.78' cannot be converted to int, so input value is returned
self.assert_deserialize_equal('"-2.78"', '"-2.78"')
# 'false' cannot be converted to int, so input value is returned
self.assert_deserialize_equal('"false"', '"false"')
self.assert_deserialize_non_string()
class TestDeserializeFloat(TestDeserialize):
"""Tests deserialize as related to Float type."""
field_type = Float
def test_deserialize(self):
"""Test deserialization of Float field values."""
self.assert_deserialize_equal(-2, "-2")
self.assert_deserialize_equal("450", '"450"')
self.assert_deserialize_equal(-2.78, "-2.78")
self.assert_deserialize_equal("0.45", '"0.45"')
# False can be parsed as a float (converts to 0)
self.assert_deserialize_equal(False, "false")
# True can be parsed as a float (converts to 1)
self.assert_deserialize_equal(True, "true")
def test_deserialize_unsupported_types(self):
"""Test deserialization handles unsupported Float inputs gracefully."""
self.assert_deserialize_equal("[3]", "[3]")
# 'false' cannot be converted to float, so input value is returned
self.assert_deserialize_equal('"false"', '"false"')
self.assert_deserialize_non_string()
class TestDeserializeBoolean(TestDeserialize):
"""Tests deserialize as related to Boolean type."""
field_type = Boolean
def test_deserialize(self):
"""Test deserialization of Boolean field values."""
# json.loads converts the value to Python bool
self.assert_deserialize_equal(False, "false")
self.assert_deserialize_equal(True, "true")
# json.loads fails, string value is returned.
self.assert_deserialize_equal("False", "False")
self.assert_deserialize_equal("True", "True")
# json.loads deserializes as a string
self.assert_deserialize_equal("false", '"false"')
self.assert_deserialize_equal("fAlse", '"fAlse"')
self.assert_deserialize_equal("TruE", '"TruE"')
# 2.78 can be converted to a bool, so the string will be deserialized
self.assert_deserialize_equal(-2.78, "-2.78")
self.assert_deserialize_non_string()
class TestDeserializeString(TestDeserialize):
"""Tests deserialize as related to String type."""
field_type = String
def test_deserialize(self):
"""Test deserialization of String field values."""
self.assert_deserialize_equal("hAlf", '"hAlf"')
self.assert_deserialize_equal("false", '"false"')
self.assert_deserialize_equal("single quote", "single quote")
def test_deserialize_unsupported_types(self):
"""Test deserialization handles unsupported String inputs gracefully."""
self.assert_deserialize_equal("3.4", "3.4")
self.assert_deserialize_equal("false", "false")
self.assert_deserialize_equal("2", "2")
self.assert_deserialize_equal("[3]", "[3]")
self.assert_deserialize_non_string()
class TestDeserializeAny(TestDeserialize):
"""Tests deserialize as related to Any type."""
field_type = Any
def test_deserialize(self):
"""Test deserialization of Any-type field values."""
self.assert_deserialize_equal("hAlf", '"hAlf"')
self.assert_deserialize_equal("false", '"false"')
self.assert_deserialize_equal({"bar": "hat", "frog": "green"}, '{"bar": "hat", "frog": "green"}')
self.assert_deserialize_equal([3.5, 5.6], "[3.5, 5.6]")
self.assert_deserialize_equal("[", "[")
self.assert_deserialize_equal(False, "false")
self.assert_deserialize_equal(3.4, "3.4")
self.assert_deserialize_non_string()
class TestDeserializeList(TestDeserialize):
"""Tests deserialize as related to List type."""
field_type = List
def test_deserialize(self):
"""Test deserialization of List field values."""
self.assert_deserialize_equal(["foo", "bar"], '["foo", "bar"]')
self.assert_deserialize_equal([3.5, 5.6], "[3.5, 5.6]")
self.assert_deserialize_equal([], "[]")
def test_deserialize_unsupported_types(self):
"""Test deserialization handles unsupported List inputs gracefully."""
self.assert_deserialize_equal("3.4", "3.4")
self.assert_deserialize_equal("false", "false")
self.assert_deserialize_equal("2", "2")
self.assert_deserialize_non_string()
class TestDeserializeDate(TestDeserialize):
"""Test deserialization of Date field values."""
field_type = Date
def test_deserialize(self):
"""Test deserialization of Timedelta field values."""
self.assert_deserialize_equal("2012-12-31T23:59:59Z", "2012-12-31T23:59:59Z")
self.assert_deserialize_equal("2012-12-31T23:59:59Z", '"2012-12-31T23:59:59Z"')
self.assert_deserialize_non_string()
class TestDeserializeTimedelta(TestDeserialize):
"""Tests deserialize as related to Timedelta type."""
field_type = Timedelta
def test_deserialize(self):
"""Test deserialization of RelativeTime field values."""
self.assert_deserialize_equal("1 day 12 hours 59 minutes 59 seconds", "1 day 12 hours 59 minutes 59 seconds")
self.assert_deserialize_equal("1 day 12 hours 59 minutes 59 seconds", '"1 day 12 hours 59 minutes 59 seconds"')
self.assert_deserialize_non_string()
class TestDeserializeRelativeTime(TestDeserialize):
"""Tests deserialize as related to Timedelta type."""
field_type = RelativeTime
def test_deserialize(self):
"""
There is no check for
self.assert_deserialize_equal('10:20:30', '10:20:30')
self.assert_deserialize_non_string()
because these two tests work only because json.loads fires exception,
and xml_module.deserialized_field catches it and returns same value,
so there is nothing field-specific here.
But other modules do it, so I'm leaving this comment for PR reviewers.
"""
# test that from_json produces no exceptions
self.assert_deserialize_equal("10:20:30", '"10:20:30"')
class TestXmlAttributes(XModuleXmlImportTest):
"""Tests XML import/export of XBlock attributes, including known, unknown, and inheritable attributes."""
def test_unknown_attribute(self):
"""Test processing and retention of unknown XML attributes."""
assert not hasattr(CourseBlock, "unknown_attr")
course = self.process_xml(CourseFactory.build(unknown_attr="value"))
assert not hasattr(course, "unknown_attr")
assert course.xml_attributes["unknown_attr"] == "value"
def test_known_attribute(self):
"""Test that known XML attributes are correctly assigned to XBlock fields."""
assert hasattr(CourseBlock, "show_calculator")
course = self.process_xml(CourseFactory.build(show_calculator="true"))
assert course.show_calculator
assert "show_calculator" not in course.xml_attributes
def test_rerandomize_in_policy(self):
"""Test that rerandomize attribute from policy is correctly processed."""
# Rerandomize isn't a basic attribute of Sequence
assert not hasattr(SequenceBlock, "rerandomize")
root = SequenceFactory.build(policy={"rerandomize": "never"})
ProblemFactory.build(parent=root)
seq = self.process_xml(root)
# Rerandomize is added to the constructed sequence via the InheritanceMixin
assert seq.rerandomize == "never"
# Rerandomize is a known value coming from policy, and shouldn't appear
# in xml_attributes
assert "rerandomize" not in seq.xml_attributes
def test_attempts_in_policy(self):
"""Test that attempts attribute from policy is correctly handled in XML import."""
# attempts isn't a basic attribute of Sequence
assert not hasattr(SequenceBlock, "attempts")
root = SequenceFactory.build(policy={"attempts": "1"})
ProblemFactory.build(parent=root)
seq = self.process_xml(root)
# attempts isn't added to the constructed sequence, because
# it's not in the InheritanceMixin
assert not hasattr(seq, "attempts")
# attempts is an unknown attribute, so we should include it
# in xml_attributes so that it gets written out (despite the misleading
# name)
assert "attempts" in seq.xml_attributes
def check_inheritable_attribute(self, attribute, value):
"""Check that an inheritable attribute is correctly processed and excluded from XML attributes."""
# `attribute` isn't a basic attribute of Sequence
assert not hasattr(SequenceBlock, attribute)
# `attribute` is added by InheritanceMixin
assert hasattr(InheritanceMixin, attribute)
root = SequenceFactory.build(policy={attribute: str(value)})
ProblemFactory.build(parent=root)
# InheritanceMixin will be used when processing the XML
assert InheritanceMixin in root.xblock_mixins
seq = self.process_xml(root)
assert seq.unmixed_class == SequenceBlock
assert not seq.__class__ == SequenceBlock
# `attribute` is added to the constructed sequence, because
# it's in the InheritanceMixin
assert getattr(seq, attribute) == value
# `attribute` is a known attribute, so we shouldn't include it
# in xml_attributes
assert attribute not in seq.xml_attributes
def test_inheritable_attributes(self):
"""Check multiple inheritable attributes are processed correctly from XML."""
self.check_inheritable_attribute("days_early_for_beta", 2)
self.check_inheritable_attribute("max_attempts", 5)
self.check_inheritable_attribute("visible_to_staff_only", True)