Skip to content

Commit a82e2d1

Browse files
feat: move commonly used attributes from XModuleMixin to XBlock (#906)
* feat: add attrs from xmodulemixin * fix: quality issues * fix: display_name_with_default docstring Co-authored-by: Kyle McCormick <kyle@axim.org> * chore: bump version --------- Co-authored-by: Kyle McCormick <kyle@axim.org>
1 parent cb579f7 commit a82e2d1

3 files changed

Lines changed: 255 additions & 1 deletion

File tree

xblock/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
XBlock Courseware Components
33
"""
44

5-
__version__ = '6.0.0'
5+
__version__ = '6.1.0'

xblock/core.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -568,6 +568,32 @@ def _reset_dirty_field(self, field):
568568
self._field_data_cache[field.name]
569569
)
570570

571+
def get_explicitly_set_fields_by_scope(self, scope=Scope.content):
572+
"""
573+
Get a dictionary of the fields for the given scope which are set
574+
explicitly on this xblock. (Including any set to None.)
575+
576+
Arguments:
577+
scope: The :class:`~xblock.fields.Scope` to filter by.
578+
Defaults to ``Scope.content``.
579+
580+
Returns:
581+
dict: A dictionary mapping field names to their JSON-serialized
582+
values, for all fields of the given scope that have been
583+
explicitly set on this block.
584+
"""
585+
result = {}
586+
for field in self.fields.values():
587+
if field.scope == scope and field.is_set_on(self):
588+
try:
589+
result[field.name] = field.read_json(self)
590+
except TypeError as exception:
591+
exception_message = (
592+
f"{exception}, Block={self.usage_key}, Field-name={field.name}"
593+
)
594+
raise TypeError(exception_message) from exception
595+
return result
596+
571597
def add_xml_to_node(self, node):
572598
"""
573599
For exporting, set data on `node` from ourselves.
@@ -938,6 +964,30 @@ def has_support(self, view, functionality):
938964
"""
939965
return hasattr(view, "_supports") and functionality in view._supports # pylint: disable=protected-access
940966

967+
def get_icon_class(self):
968+
"""
969+
Return a css class identifying this XBlock in the context of an icon
970+
"""
971+
return getattr(self, "icon_class", "other")
972+
973+
@property
974+
def display_name_with_default(self):
975+
"""
976+
Return a display name for this block.
977+
978+
Uses ``display_name`` if it is set and not None. Otherwise, falls back
979+
to a name derived from the block's ``usage_key.block_id`` by replacing
980+
underscores with spaces.
981+
Note:
982+
This method does not perform any escaping. Callers are responsible
983+
for ensuring the returned value is properly escaped where required.
984+
985+
Returns:
986+
str: The resolved display name, or an empty string if no suitable
987+
value is available.
988+
"""
989+
return getattr(self, "display_name", None) or self.usage_key.block_id.replace("_", " ")
990+
941991

942992
class XBlockAside(Plugin, Blocklike):
943993
"""

xblock/test/test_core.py

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1163,3 +1163,207 @@ def test_key_properties_when_usage_is_not_an_opaque_key(self):
11631163
block = XBlock(Mock(spec=Runtime), scope_ids=scope_ids)
11641164
self.assertEqual(block.usage_key, "myWeirdOldUsageId")
11651165
self.assertIsNone(block.context_key)
1166+
1167+
1168+
class TestGetExplicitlySetFieldsByScope(unittest.TestCase):
1169+
"""
1170+
Tests for ``Blocklike.get_explicitly_set_fields_by_scope``.
1171+
"""
1172+
1173+
class FieldBlock(XBlock):
1174+
"""XBlock with fields across multiple scopes for testing."""
1175+
content_field = String(scope=Scope.content, default="default_content")
1176+
settings_field = String(scope=Scope.settings, default="default_settings")
1177+
mutable_content = List(scope=Scope.content)
1178+
mutable_settings = List(scope=Scope.settings)
1179+
1180+
def _make_block(self, field_data_dict=None):
1181+
field_data = DictFieldData(field_data_dict or {})
1182+
runtime = TestRuntime(services={'field-data': field_data})
1183+
return self.FieldBlock(runtime, scope_ids=Mock(spec=ScopeIds))
1184+
1185+
def test_no_explicitly_set_fields(self):
1186+
"""Fields not explicitly set should not appear in the result."""
1187+
block = self._make_block()
1188+
assert not block.get_explicitly_set_fields_by_scope(Scope.content)
1189+
assert not block.get_explicitly_set_fields_by_scope(Scope.settings)
1190+
1191+
def test_explicitly_set_via_field_data(self):
1192+
"""Fields present in the field data store are considered explicitly set."""
1193+
block = self._make_block({
1194+
'content_field': 'custom_content',
1195+
'settings_field': 'custom_settings',
1196+
})
1197+
content = block.get_explicitly_set_fields_by_scope(Scope.content)
1198+
settings = block.get_explicitly_set_fields_by_scope(Scope.settings)
1199+
1200+
assert content == {'content_field': 'custom_content'}
1201+
assert settings == {'settings_field': 'custom_settings'}
1202+
1203+
def test_explicitly_set_via_assignment(self):
1204+
"""Fields set by attribute assignment should appear after save."""
1205+
block = self._make_block()
1206+
block.content_field = 'new_content'
1207+
block.settings_field = 'new_settings'
1208+
block.save()
1209+
1210+
content = block.get_explicitly_set_fields_by_scope(Scope.content)
1211+
settings = block.get_explicitly_set_fields_by_scope(Scope.settings)
1212+
1213+
assert content == {'content_field': 'new_content'}
1214+
assert settings == {'settings_field': 'new_settings'}
1215+
1216+
def test_scope_filtering(self):
1217+
"""Only fields of the requested scope should be returned."""
1218+
block = self._make_block({
1219+
'content_field': 'some_content',
1220+
'settings_field': 'some_settings',
1221+
})
1222+
content = block.get_explicitly_set_fields_by_scope(Scope.content)
1223+
assert 'content_field' in content
1224+
assert 'settings_field' not in content
1225+
1226+
def test_mutable_fields(self):
1227+
"""Mutable field types (List, Dict) should work correctly."""
1228+
block = self._make_block({
1229+
'mutable_content': [1, 2, 3],
1230+
'mutable_settings': ['a', 'b'],
1231+
})
1232+
content = block.get_explicitly_set_fields_by_scope(Scope.content)
1233+
settings = block.get_explicitly_set_fields_by_scope(Scope.settings)
1234+
1235+
assert content == {'mutable_content': [1, 2, 3]}
1236+
assert settings == {'mutable_settings': ['a', 'b']}
1237+
1238+
def test_field_set_to_none(self):
1239+
"""Fields explicitly set to None should still appear in the result."""
1240+
block = self._make_block({'content_field': None})
1241+
content = block.get_explicitly_set_fields_by_scope(Scope.content)
1242+
assert 'content_field' in content
1243+
assert content['content_field'] is None
1244+
1245+
def test_default_scope_is_content(self):
1246+
"""The default scope parameter should be Scope.content."""
1247+
block = self._make_block({
1248+
'content_field': 'value',
1249+
'settings_field': 'value',
1250+
})
1251+
result = block.get_explicitly_set_fields_by_scope()
1252+
assert 'content_field' in result
1253+
assert 'settings_field' not in result
1254+
1255+
def test_deleted_field_not_returned(self):
1256+
"""A field that was set and then deleted should no longer appear."""
1257+
block = self._make_block({'content_field': 'will_delete'})
1258+
assert 'content_field' in block.get_explicitly_set_fields_by_scope(Scope.content)
1259+
1260+
del block.content_field
1261+
assert 'content_field' not in block.get_explicitly_set_fields_by_scope(Scope.content)
1262+
1263+
1264+
class TestGetIconClass(unittest.TestCase):
1265+
"""
1266+
Tests for ``XBlock.get_icon_class``.
1267+
"""
1268+
1269+
def test_default_icon_class(self):
1270+
"""Block without icon_class attribute should return 'other'."""
1271+
class PlainBlock(XBlock):
1272+
pass
1273+
1274+
runtime = TestRuntime(services={'field-data': DictFieldData({})})
1275+
block = PlainBlock(runtime, scope_ids=Mock(spec=ScopeIds))
1276+
assert block.get_icon_class() == 'other'
1277+
1278+
def test_custom_icon_class(self):
1279+
"""Block with icon_class attribute should return that value."""
1280+
class VideoLikeBlock(XBlock):
1281+
icon_class = 'video'
1282+
1283+
runtime = TestRuntime(services={'field-data': DictFieldData({})})
1284+
block = VideoLikeBlock(runtime, scope_ids=Mock(spec=ScopeIds))
1285+
assert block.get_icon_class() == 'video'
1286+
1287+
def test_problem_icon_class(self):
1288+
"""Verify the 'problem' icon class."""
1289+
class ProblemLikeBlock(XBlock):
1290+
icon_class = 'problem'
1291+
1292+
runtime = TestRuntime(services={'field-data': DictFieldData({})})
1293+
block = ProblemLikeBlock(runtime, scope_ids=Mock(spec=ScopeIds))
1294+
assert block.get_icon_class() == 'problem'
1295+
1296+
1297+
@ddt.ddt
1298+
class TestDisplayNameWithDefault(unittest.TestCase):
1299+
"""
1300+
Tests for ``XBlock.display_name_with_default``.
1301+
"""
1302+
1303+
class BlockWithDisplayName(XBlock):
1304+
display_name = String(default="Default Name", scope=Scope.settings)
1305+
1306+
def test_explicit_display_name(self):
1307+
"""When display_name is explicitly set, it should be returned."""
1308+
runtime = TestRuntime(services={'field-data': DictFieldData({
1309+
'display_name': 'My Custom Name',
1310+
})})
1311+
block = self.BlockWithDisplayName(runtime, scope_ids=Mock(spec=ScopeIds))
1312+
assert block.display_name_with_default == 'My Custom Name'
1313+
1314+
def test_field_default_used_when_not_set(self):
1315+
"""When display_name is not explicitly set, the field default is used."""
1316+
runtime = TestRuntime(services={'field-data': DictFieldData({})})
1317+
block = self.BlockWithDisplayName(runtime, scope_ids=Mock(spec=ScopeIds))
1318+
# Field has default="Default Name", so that's returned
1319+
assert block.display_name_with_default == "Default Name"
1320+
1321+
def test_empty_string_falls_back_to_usage_key(self):
1322+
"""When display_name is empty string (falsy), fall back to usage_key.block_id."""
1323+
usage_key = Mock()
1324+
usage_key.block_id = "my_block_id"
1325+
scope_ids = Mock(spec=ScopeIds)
1326+
scope_ids.usage_id = usage_key
1327+
1328+
runtime = TestRuntime(services={'field-data': DictFieldData({'display_name': ''})})
1329+
block = self.BlockWithDisplayName(runtime, scope_ids=scope_ids)
1330+
assert block.display_name_with_default == "my block id"
1331+
1332+
def test_none_display_name_falls_back_to_usage_key(self):
1333+
"""When display_name is explicitly None (falsy), fall back to usage_key.block_id."""
1334+
usage_key = Mock()
1335+
usage_key.block_id = "my_block_id"
1336+
scope_ids = Mock(spec=ScopeIds)
1337+
scope_ids.usage_id = usage_key
1338+
1339+
runtime = TestRuntime(services={'field-data': DictFieldData({'display_name': None})})
1340+
block = self.BlockWithDisplayName(runtime, scope_ids=scope_ids)
1341+
assert block.display_name_with_default == "my block id"
1342+
1343+
def test_no_display_name_field_falls_back_to_usage_key(self):
1344+
"""Block without display_name field at all should fall back to usage_key.block_id."""
1345+
class NoDisplayNameBlock(XBlock):
1346+
pass
1347+
1348+
usage_key = Mock()
1349+
usage_key.block_id = "some_block"
1350+
scope_ids = Mock(spec=ScopeIds)
1351+
scope_ids.usage_id = usage_key
1352+
1353+
runtime = TestRuntime(services={'field-data': DictFieldData({})})
1354+
block = NoDisplayNameBlock(runtime, scope_ids=scope_ids)
1355+
assert block.display_name_with_default == "some block"
1356+
1357+
def test_underscores_replaced_with_spaces(self):
1358+
"""The fallback name should replace underscores with spaces."""
1359+
class NoDisplayNameBlock(XBlock):
1360+
pass
1361+
1362+
usage_key = Mock()
1363+
usage_key.block_id = "intro_to_python_101"
1364+
scope_ids = Mock(spec=ScopeIds)
1365+
scope_ids.usage_id = usage_key
1366+
1367+
runtime = TestRuntime(services={'field-data': DictFieldData({})})
1368+
block = NoDisplayNameBlock(runtime, scope_ids=scope_ids)
1369+
assert block.display_name_with_default == "intro to python 101"

0 commit comments

Comments
 (0)