diff --git a/xblock/fields.py b/xblock/fields.py index 3cad35a..a44afd3 100644 --- a/xblock/fields.py +++ b/xblock/fields.py @@ -25,7 +25,7 @@ __all__ = [ 'BlockScope', 'UserScope', 'Scope', 'ScopeIds', 'Field', - 'Boolean', 'Dict', 'Float', 'Integer', 'List', 'String', + 'Boolean', 'Dict', 'Float', 'Integer', 'List', 'Set', 'String', 'XBlockMixin', ] @@ -770,6 +770,34 @@ def from_json(self, value): enforce_type = from_json +class Set(JSONField): + """ + A field class for representing a set. + + The stored value can either be None or a set. + + """ + _default = set() + + def __init__(self, *args, **kwargs): + """ + Set class constructor. + + Redefined in order to convert default values to sets. + """ + super(Set, self).__init__(*args, **kwargs) + + self._default = set(self._default) + + def from_json(self, value): + if value is None or isinstance(value, set): + return value + else: + return set(value) + + enforce_type = from_json + + class String(JSONField): """ A field class for representing a string. diff --git a/xblock/test/test_core.py b/xblock/test/test_core.py index 08dc056..163ef84 100644 --- a/xblock/test/test_core.py +++ b/xblock/test/test_core.py @@ -23,7 +23,7 @@ DisallowedFileError, FieldDataDeprecationWarning, ) -from xblock.fields import Dict, Float, Integer, List, Field, Scope, ScopeIds +from xblock.fields import Dict, Float, Integer, List, Set, Field, Scope, ScopeIds from xblock.field_data import FieldData, DictFieldData from xblock.mixins import ScopedStorageMixin from xblock.runtime import Runtime @@ -145,6 +145,59 @@ class FieldTester(XBlock): assert_equals([1], field_data.get(field_tester, 'field_d')) +def test_set_field_access(): + # Check that sets are correctly saved when not directly set + class FieldTester(XBlock): + """Test XBlock for field access testing""" + field_a = Set(scope=Scope.settings) + field_b = Set(scope=Scope.content, default=[1, 2, 3]) + field_c = Set(scope=Scope.content, default=[4, 5, 6]) + field_d = Set(scope=Scope.settings) + + field_tester = FieldTester(MagicMock(), DictFieldData({'field_a': [200], 'field_b': [11, 12, 13]}), Mock()) + + # Check initial values have been set properly + assert_equals(set([200]), field_tester.field_a) + assert_equals(set([11, 12, 13]), field_tester.field_b) + assert_equals(set([4, 5, 6]), field_tester.field_c) + assert_equals(set(), field_tester.field_d) + + # Update the fields + field_tester.field_a.add(1) + field_tester.field_b.add(14) + field_tester.field_c.remove(5) + field_tester.field_d.add(1) + + # The fields should be update in the cache, but /not/ in the underlying kvstore. + assert_equals(set([200, 1]), field_tester.field_a) + assert_equals(set([11, 12, 13, 14]), field_tester.field_b) + assert_equals(set([4, 6]), field_tester.field_c) + assert_equals(set([1]), field_tester.field_d) + + # Examine model data directly + # Caveat: there's not a clean way to copy the originally provided values for `field_a` and `field_b` + # when we instantiate the XBlock. So, the values for those two in both `_field_data` and `_field_data_cache` + # point at the same object. Thus, `field_a` and `field_b` actually have the correct values in + # `_field_data` right now. `field_c` does not, because it has never been written to the `_field_data`. + assert_false(field_tester._field_data.has(field_tester, 'field_c')) + assert_false(field_tester._field_data.has(field_tester, 'field_d')) + + # save the XBlock + field_tester.save() + + # verify that the fields have been updated correctly + assert_equals(set([200, 1]), field_tester.field_a) + assert_equals(set([11, 12, 13, 14]), field_tester.field_b) + assert_equals(set([4, 6]), field_tester.field_c) + assert_equals(set([1]), field_tester.field_d) + # Now, the fields should be updated in the underlying kvstore + + assert_equals(set([200, 1]), field_tester._field_data.get(field_tester, 'field_a')) + assert_equals(set([11, 12, 13, 14]), field_tester._field_data.get(field_tester, 'field_b')) + assert_equals(set([4, 6]), field_tester._field_data.get(field_tester, 'field_c')) + assert_equals(set([1]), field_tester._field_data.get(field_tester, 'field_d')) + + def test_mutable_none_values(): # Check that fields with values intentionally set to None # save properly. diff --git a/xblock/test/test_fields.py b/xblock/test/test_fields.py index 794d592..67d0a46 100644 --- a/xblock/test/test_fields.py +++ b/xblock/test/test_fields.py @@ -22,7 +22,7 @@ from xblock.field_data import DictFieldData from xblock.fields import ( Any, Boolean, Dict, Field, Float, - Integer, List, String, DateTime, Reference, ReferenceList, Sentinel, + Integer, List, Set, String, DateTime, Reference, ReferenceList, Sentinel, UNIQUE_ID ) @@ -326,6 +326,33 @@ def test_error(self): self.assertJSONOrSetTypeError({}) +class SetTest(FieldTest): + """ + Tests the Set Field. + """ + FIELD_TO_TEST = Set + + def test_json_equals(self): + self.assertJSONOrSetEquals(set(), set()) + self.assertJSONOrSetEquals(set(['foo', 'bar']), set(['foo', 'bar'])) + self.assertJSONOrSetEquals(set(['bar', 'foo']), set(['foo', 'bar'])) + self.assertJSONOrSetEquals(set([1, 3.14]), set([1, 3.14])) + self.assertJSONOrSetEquals(set([1, 3.14]), set([1, 3.14, 1])) + + def test_hashable_converts(self): + self.assertJSONOrSetEquals(set([1, 3.4]), [1, 3.4]) + self.assertJSONOrSetEquals(set(['a', 'b']), 'ab') + self.assertJSONOrSetEquals(set(['k1', 'k2']), {'k1': 1, 'k2': '2'}) + + def test_none(self): + self.assertJSONOrSetEquals(None, None) + + def test_error(self): + self.assertJSONOrSetTypeError(42) + self.assertJSONOrSetTypeError(3.7) + self.assertJSONOrSetTypeError(True) + + class ReferenceTest(FieldTest): """ Tests the Reference Field.