Browse Source

Bulk attributes save (#4167)

* history should use settings.SESSION_COOKIE_SAMESITE directions

* Optimize product attributes

* Cache attribute lookups

* simplify QuerysetCache

* Perform bulk update for product attributes

* Added tests for uncovered features.

* added 1 more test

* fixes test

* Added more tests

* multi_option can not be save in bulk

* it should be possible to skip validate_identifier because it will be very hard to import data with oscarapi if this is always required.

* misnamed test

* fixes test

* added test for attribte validation errors

* added test that validates that required attributes are not valid

* more tests
master
Voxin Muyli 2 years ago
parent
commit
858962dc10
No account linked to committer's email address

+ 77
- 42
src/oscar/apps/catalogue/abstract_models.py View File

@@ -1,13 +1,16 @@
1 1
 import logging
2 2
 import os
3 3
 from datetime import date, datetime
4
-
5 4
 from django.conf import settings
6 5
 from django.contrib.contenttypes.fields import GenericForeignKey
7 6
 from django.contrib.contenttypes.models import ContentType
8 7
 from django.contrib.staticfiles.finders import find
9 8
 from django.core.cache import cache
10
-from django.core.exceptions import ImproperlyConfigured, ValidationError
9
+from django.core.exceptions import (
10
+    ImproperlyConfigured,
11
+    ValidationError,
12
+    ObjectDoesNotExist,
13
+)
11 14
 from django.core.files.base import File
12 15
 from django.core.validators import RegexValidator
13 16
 from django.db import models
@@ -590,6 +593,10 @@ class AbstractProduct(models.Model):
590 593
         super().save(*args, **kwargs)
591 594
         self.attr.save()
592 595
 
596
+    def refresh_from_db(self, using=None, fields=None):
597
+        super().refresh_from_db(using, fields)
598
+        self.attr.invalidate()
599
+
593 600
     # Properties
594 601
 
595 602
     @property
@@ -968,63 +975,73 @@ class AbstractProductAttribute(models.Model):
968 975
         if self.type == self.BOOLEAN and self.required:
969 976
             raise ValidationError(_("Boolean attribute should not be required."))
970 977
 
971
-    def _save_file(self, value_obj, value):
972
-        # File fields in Django are treated differently, see
973
-        # django.db.models.fields.FileField and method save_form_data
978
+    def _get_value_obj(self, product, value):
979
+        try:
980
+            return product.attribute_values.get(attribute=self)
981
+        except ObjectDoesNotExist:
982
+            # FileField uses False for announcing deletion of the file
983
+            # not creating a new value
984
+            delete_file = self.is_file and value is False
985
+            if value is None or value == "" or delete_file:
986
+                return None
987
+            return product.attribute_values.create(attribute=self)
988
+
989
+    def _bind_value_file(self, value_obj, value):
974 990
         if value is None:
975 991
             # No change
976
-            return
992
+            return value_obj
977 993
         elif value is False:
978
-            # Delete file
979
-            value_obj.delete()
994
+            return None
980 995
         else:
981 996
             # New uploaded file
982 997
             value_obj.value = value
983
-            value_obj.save()
998
+            return value_obj
984 999
 
985
-    def _save_multi_option(self, value_obj, value):
1000
+    def _bind_value_multi_option(self, value_obj, value):
986 1001
         # ManyToMany fields are handled separately
987 1002
         if value is None:
988
-            value_obj.delete()
989
-            return
1003
+            return None
990 1004
         try:
991 1005
             count = value.count()
992 1006
         except (AttributeError, TypeError):
993 1007
             count = len(value)
994 1008
         if count == 0:
995
-            value_obj.delete()
1009
+            return None
996 1010
         else:
997 1011
             value_obj.value = value
998
-            value_obj.save()
1012
+            return value_obj
999 1013
 
1000
-    def _save_value(self, value_obj, value):
1014
+    def _bind_value(self, value_obj, value):
1001 1015
         if value is None or value == "":
1002
-            value_obj.delete()
1003
-            return
1004
-        if value != value_obj.value:
1005
-            value_obj.value = value
1006
-            value_obj.save()
1007
-
1008
-    def save_value(self, product, value):
1009
-        ProductAttributeValue = get_model("catalogue", "ProductAttributeValue")
1010
-        try:
1011
-            value_obj = product.attribute_values.get(attribute=self)
1012
-        except ProductAttributeValue.DoesNotExist:
1013
-            # FileField uses False for announcing deletion of the file
1014
-            # not creating a new value
1015
-            delete_file = self.is_file and value is False
1016
-            if value is None or value == "" or delete_file:
1017
-                return
1018
-            value_obj = ProductAttributeValue.objects.create(
1019
-                product=product, attribute=self
1020
-            )
1016
+            return None
1017
+        value_obj.value = value
1018
+        return value_obj
1021 1019
 
1020
+    def bind_value(self, value_obj, value):
1021
+        """
1022
+        bind_value will bind the value passed to the value_obj, if the bind_value
1023
+        return None, that means the value_obj is supposed to be deleted.
1024
+        """
1022 1025
         if self.is_file:
1023
-            self._save_file(value_obj, value)
1026
+            return self._bind_value_file(value_obj, value)
1024 1027
         elif self.is_multi_option:
1025
-            self._save_multi_option(value_obj, value)
1028
+            return self._bind_value_multi_option(value_obj, value)
1026 1029
         else:
1027
-            self._save_value(value_obj, value)
1030
+            return self._bind_value(value_obj, value)
1031
+
1032
+    def save_value(self, product, value):
1033
+        value_obj = self._get_value_obj(product, value)
1034
+
1035
+        if value_obj is None:
1036
+            return None
1037
+
1038
+        updated_value_obj = self.bind_value(value_obj, value)
1039
+        if updated_value_obj is None:
1040
+            value_obj.delete()
1041
+        elif updated_value_obj.is_dirty:
1042
+            updated_value_obj.save()
1043
+
1044
+        return updated_value_obj
1028 1045
 
1029 1046
     def validate_value(self, value):
1030 1047
         validator = getattr(self, "_validate_%s" % self.type)
@@ -1158,28 +1175,43 @@ class AbstractProductAttributeValue(models.Model):
1158 1175
     entity_object_id = models.PositiveIntegerField(
1159 1176
         null=True, blank=True, editable=False
1160 1177
     )
1178
+    _dirty = False
1179
+
1180
+    @cached_property
1181
+    def type(self):
1182
+        return self.attribute.type
1183
+
1184
+    @property
1185
+    def value_field_name(self):
1186
+        return "value_%s" % self.type
1161 1187
 
1162 1188
     def _get_value(self):
1163
-        value = getattr(self, "value_%s" % self.attribute.type)
1189
+        value = getattr(self, self.value_field_name)
1164 1190
         if hasattr(value, "all"):
1165 1191
             value = value.all()
1166 1192
         return value
1167 1193
 
1168 1194
     def _set_value(self, new_value):
1169
-        attr_name = "value_%s" % self.attribute.type
1195
+        attr_name = self.value_field_name
1170 1196
 
1171 1197
         if self.attribute.is_option and isinstance(new_value, str):
1172 1198
             # Need to look up instance of AttributeOption
1173 1199
             new_value = self.attribute.option_group.options.get(option=new_value)
1174 1200
         elif self.attribute.is_multi_option:
1175 1201
             getattr(self, attr_name).set(new_value)
1202
+            self._dirty = True
1176 1203
             return
1177 1204
 
1178 1205
         setattr(self, attr_name, new_value)
1206
+        self._dirty = True
1179 1207
         return
1180 1208
 
1181 1209
     value = property(_get_value, _set_value)
1182 1210
 
1211
+    @property
1212
+    def is_dirty(self):
1213
+        return self._dirty
1214
+
1183 1215
     class Meta:
1184 1216
         abstract = True
1185 1217
         app_label = "catalogue"
@@ -1204,8 +1236,11 @@ class AbstractProductAttributeValue(models.Model):
1204 1236
         e.g. image attribute values, declare a _image_as_text property and
1205 1237
         return something appropriate.
1206 1238
         """
1207
-        property_name = "_%s_as_text" % self.attribute.type
1208
-        return getattr(self, property_name, self.value)
1239
+        try:
1240
+            property_name = "_%s_as_text" % self.type
1241
+            return getattr(self, property_name, self.value)
1242
+        except ValueError:
1243
+            return ""
1209 1244
 
1210 1245
     @property
1211 1246
     def _multi_option_as_text(self):
@@ -1241,7 +1276,7 @@ class AbstractProductAttributeValue(models.Model):
1241 1276
         return e.g. an ``<img>`` tag.  Defaults to the ``_as_text``
1242 1277
         representation.
1243 1278
         """
1244
-        property_name = "_%s_as_html" % self.attribute.type
1279
+        property_name = "_%s_as_html" % self.type
1245 1280
         return getattr(self, property_name, self.value_as_text)
1246 1281
 
1247 1282
     @property

+ 4
- 4
src/oscar/apps/catalogue/managers.py View File

@@ -101,6 +101,10 @@ class ProductQuerySet(models.query.QuerySet):
101 101
         product_options = Option.objects.filter(product=OuterRef("pk"))
102 102
         return (
103 103
             self.select_related("product_class")
104
+            .annotate(
105
+                has_product_class_options=Exists(product_class_options),
106
+                has_product_options=Exists(product_options),
107
+            )
104 108
             .prefetch_related(
105 109
                 "children",
106 110
                 "product_options",
@@ -108,10 +112,6 @@ class ProductQuerySet(models.query.QuerySet):
108 112
                 "stockrecords",
109 113
                 "images",
110 114
             )
111
-            .annotate(
112
-                has_product_class_options=Exists(product_class_options),
113
-                has_product_options=Exists(product_options),
114
-            )
115 115
         )
116 116
 
117 117
     def browsable(self):

+ 174
- 21
src/oscar/apps/catalogue/product_attributes.py View File

@@ -1,5 +1,46 @@
1
+from copy import deepcopy
2
+from django.db import models
1 3
 from django.core.exceptions import ObjectDoesNotExist, ValidationError
2 4
 from django.utils.translation import gettext_lazy as _
5
+from django.utils.functional import cached_property
6
+
7
+
8
+class QuerysetCache(dict):
9
+    def __init__(self, queryset):
10
+        self._queryset = queryset
11
+        self._queryset_iterator = queryset.iterator()
12
+
13
+    def queryset(self):
14
+        return self._queryset
15
+
16
+    def get(self, code, default=None):
17
+        try:
18
+            return self[code]
19
+        except KeyError:
20
+            for instance in self._queryset_iterator:
21
+                instance_code = instance.code
22
+                self[instance_code] = instance
23
+                if instance_code == code:
24
+                    return instance
25
+
26
+        return default
27
+
28
+
29
+class AttributesQuerysetCache:
30
+    def __init__(self, product):
31
+        self.product = product
32
+
33
+    @cached_property
34
+    def attributes(self):
35
+        return QuerysetCache(self.product.get_product_class().attributes.all())
36
+
37
+    @cached_property
38
+    def attribute_values(self):
39
+        return QuerysetCache(
40
+            self.product.get_attribute_values()
41
+            .select_related("attribute")
42
+            .annotate(code=models.F("attribute__code"))
43
+        )
3 44
 
4 45
 
5 46
 class ProductAttributesContainer:
@@ -15,20 +56,48 @@ class ProductAttributesContainer:
15 56
         product.attr.refresh()
16 57
     """
17 58
 
18
-    # pylint: disable=access-member-before-definition
19
-    def __setstate__(self, state):
20
-        self.__dict__.setdefault("_product", None)
21
-        self.__dict__.setdefault("_initialized", False)
22
-        self.__dict__.setdefault("_dirty", set())
23
-        self.__dict__ = state
59
+    RESERVED_ATTRIBUTES = {
60
+        "_cache",
61
+        "_dirty",
62
+        "initialized",
63
+        "_initialized",
64
+        "_product",
65
+        "get_all_attributes",
66
+        "get_attribute_by_code",
67
+        "get_value_by_attribute",
68
+        "get_values",
69
+        "initialize",
70
+        "product",
71
+        "refresh",
72
+        "save",
73
+        "set",
74
+        "update",
75
+        "validate_attributes",
76
+    }
24 77
 
25 78
     def __init__(self, product):
26 79
         # use __dict__ directly to avoid triggering __setattr__, which would
27 80
         # cause a recursion error on _initialized.
28 81
         self.__dict__.update(
29
-            {"_product": product, "_initialized": False, "_dirty": set()}
82
+            {
83
+                "_product": product,
84
+                "_initialized": False,
85
+                "_dirty": set(),
86
+                "_cache": None,
87
+            }
30 88
         )
31 89
 
90
+    def __deepcopy__(self, memo):
91
+        cpy = ProductAttributesContainer(self.product)
92
+        memo[id(self)] = cpy
93
+        if self.initialized:
94
+            # Only copy attributes for initialized containers
95
+            for key, value in self.__dict__.items():
96
+                if key != "_cache":
97
+                    cpy.__dict__[key] = deepcopy(value, memo)
98
+
99
+        return cpy
100
+
32 101
     @property
33 102
     def product(self):
34 103
         return self._product
@@ -37,21 +106,28 @@ class ProductAttributesContainer:
37 106
     def initialized(self):
38 107
         return self._initialized
39 108
 
40
-    @initialized.setter
41
-    def initialized(self, value):
42
-        # use __dict__ directly to avoid triggering __setattr__, which would
43
-        # cause a recursion error.
44
-        self.__dict__["_initialized"] = value
109
+    @property
110
+    def cache(self):
111
+        if self.__dict__["_cache"] is None:
112
+            self.__dict__["_cache"] = AttributesQuerysetCache(self.product)
113
+        return self.__dict__["_cache"]
45 114
 
46 115
     def initialize(self):
47
-        self.initialized = True
116
+        self.__dict__["_initialized"] = True
48 117
         # initialize should not overwrite any values that have allready been set
49 118
         attrs = self.__dict__
50
-        for v in self.get_values().select_related("attribute"):
119
+        for v in self.get_values():
51 120
             attrs.setdefault(v.attribute.code, v.value)
52 121
 
122
+    def invalidate(self):
123
+        "Invalidate any stored data, queried from the database"
124
+        self.__dict__["_cache"] = None
125
+        self.__dict__["_initialized"] = False
126
+
53 127
     def refresh(self):
54
-        for v in self.get_values().select_related("attribute"):
128
+        "Reload any queried data from the database, discarding local changes"
129
+        self.__dict__["_cache"] = None
130
+        for v in self.get_values():
55 131
             setattr(self, v.attribute.code, v.value)
56 132
 
57 133
     def __getattribute__(self, name):
@@ -72,9 +148,29 @@ class ProductAttributesContainer:
72 148
         )
73 149
 
74 150
     def __setattr__(self, name, value):
151
+        if name in self.RESERVED_ATTRIBUTES:
152
+            raise ValidationError(
153
+                "%s is a reserved name and cannot be used as an attribute"
154
+            )
155
+
75 156
         self._dirty.add(name)
76 157
         super().__setattr__(name, value)
77 158
 
159
+    def set(self, name, value, validate_identifier=True):
160
+        if not validate_identifier or name.isidentifier():
161
+            self.__setattr__(name, value)
162
+        else:
163
+            raise ValidationError(
164
+                _(
165
+                    "%s is not a valid identifier, but attribute codes must be valid python identifiers"
166
+                    % name
167
+                )
168
+            )
169
+
170
+    def update(self, adict):
171
+        self._dirty.update(adict.keys())
172
+        self.__dict__.update(adict)
173
+
78 174
     def validate_attributes(self):
79 175
         for attribute in self.get_all_attributes():
80 176
             value = getattr(self, attribute.code, None)
@@ -94,21 +190,30 @@ class ProductAttributesContainer:
94 190
                     )
95 191
 
96 192
     def get_values(self):
97
-        return self.product.get_attribute_values()
193
+        return self.cache.attribute_values.queryset()
98 194
 
99 195
     def get_value_by_attribute(self, attribute):
100
-        return self.get_values().get(attribute=attribute)
196
+        return self.cache.attribute_values.get(attribute.code)
101 197
 
102 198
     def get_all_attributes(self):
103
-        return self.product.get_product_class().attributes.all()
199
+        return self.cache.attributes.queryset()
104 200
 
105 201
     def get_attribute_by_code(self, code):
106
-        return self.get_all_attributes().get(code=code)
202
+        return self.cache.attributes.get(code)
107 203
 
108 204
     def __iter__(self):
109 205
         return iter(self.get_values())
110 206
 
111 207
     def save(self):
208
+        if not self.initialized and not self._dirty:
209
+            return  # no need to save untouched attr lists
210
+
211
+        ProductAttributeValue = self.product.attribute_values.model
212
+
213
+        to_be_updated = []
214
+        update_fields = set()
215
+        to_be_deleted = []
216
+        to_be_created = []
112 217
         for attribute in self.get_all_attributes():
113 218
             if hasattr(self, attribute.code):
114 219
                 value = getattr(self, attribute.code)
@@ -121,9 +226,57 @@ class ProductAttributesContainer:
121 226
                     # needed and should always be saved.
122 227
                     try:
123 228
                         attribute_value_current = self.get_value_by_attribute(attribute)
124
-                        if attribute_value_current.value == value:
229
+                        if (
230
+                            attribute_value_current is not None
231
+                            and attribute_value_current.value == value
232
+                        ):
125 233
                             continue  # no new value needs to be saved
126 234
                     except ObjectDoesNotExist:
127 235
                         pass  # there is no existing value, so a value needs to be saved.
128 236
 
129
-                attribute.save_value(self.product, value)
237
+                if attribute.is_multi_option:  # multi_option can not be bulk saved
238
+                    attribute.save_value(self.product, value)
239
+                else:
240
+                    value_obj = self.get_value_by_attribute(attribute)
241
+                    if (
242
+                        value_obj is None or value_obj.product != self.product
243
+                    ):  # it doesn't exist yet so should be created
244
+                        new_value_obj = ProductAttributeValue(
245
+                            attribute=attribute, product=self.product
246
+                        )
247
+
248
+                        bound_value_obj = attribute.bind_value(new_value_obj, value)
249
+                        if bound_value_obj is not None:
250
+                            assert not bound_value_obj.pk
251
+                            to_be_created.append(bound_value_obj)
252
+                    else:
253
+                        bound_value_obj = attribute.bind_value(value_obj, value)
254
+                        if bound_value_obj is None:
255
+                            to_be_deleted.append(value_obj.pk)
256
+                        else:
257
+                            if bound_value_obj.attribute.is_file:
258
+                                # with bulk_create the file is save just fine, but
259
+                                # with buld_update, it's not, so we have to performa
260
+                                # that manually
261
+                                bound_value_obj._meta.get_field(
262
+                                    bound_value_obj.value_field_name
263
+                                ).pre_save(bound_value_obj, False)
264
+
265
+                            to_be_updated.append(bound_value_obj)
266
+                            update_fields.add(bound_value_obj.value_field_name)
267
+
268
+        # now save all the attributes in bulk
269
+        if to_be_deleted:
270
+            self.product.attribute_values.filter(pk__in=to_be_deleted).delete()
271
+        if to_be_updated:
272
+            self.product.attribute_values.bulk_update(
273
+                to_be_updated, update_fields, batch_size=500
274
+            )
275
+        if to_be_created:
276
+            self.product.attribute_values.bulk_create(
277
+                to_be_created, batch_size=500, ignore_conflicts=False
278
+            )
279
+
280
+        # after this the current data is nolonger valid and should be refetched
281
+        # from the database
282
+        self.invalidate()

+ 1
- 0
src/oscar/apps/customer/history.py View File

@@ -13,6 +13,7 @@ class CustomerHistoryManager:
13 13
         "max_age": settings.OSCAR_RECENTLY_VIEWED_COOKIE_LIFETIME,
14 14
         "secure": settings.OSCAR_RECENTLY_VIEWED_COOKIE_SECURE,
15 15
         "httponly": True,
16
+        "samesite": settings.SESSION_COOKIE_SAMESITE,
16 17
     }
17 18
     max_products = settings.OSCAR_RECENTLY_VIEWED_PRODUCTS
18 19
 

+ 45
- 1
tests/integration/catalogue/test_attributes.py View File

@@ -235,8 +235,52 @@ class TestTextAttributes(TestCase):
235 235
 
236 236
 class TestFileAttributes(TestCase):
237 237
     def setUp(self):
238
-        self.attr = factories.ProductAttributeFactory(type="file")
238
+        self.attr = factories.ProductAttributeFactory(type="file", code="file")
239 239
 
240 240
     def test_validate_file_values(self):
241 241
         file_field = SimpleUploadedFile("test_file.txt", b"Test")
242 242
         self.assertIsNone(self.attr.validate_value(file_field))
243
+
244
+    def test_erase_file(self):
245
+        product = factories.ProductFactory()
246
+        product.product_class.attributes.add(self.attr)
247
+
248
+        # save file attribute
249
+        file_field = SimpleUploadedFile("test_file.txt", b"Test")
250
+        self.assertIsNone(self.attr.validate_value(file_field))
251
+        self.attr.save_value(product, file_field)
252
+        self.assertTrue(self.attr.is_file)
253
+
254
+        product = Product.objects.get(pk=product.pk)
255
+
256
+        self.assertIsNotNone(
257
+            product.attr.file, "There should be something saved into the file attribute"
258
+        )
259
+        self.assertIn(
260
+            file_field.name,
261
+            product.attr.file.name,
262
+            "The save file should have the correct filename",
263
+        )
264
+
265
+        # set file attribute to None, which does nothing
266
+        product.attr.file = None
267
+        product.attr.save()
268
+
269
+        product = Product.objects.get(pk=product.pk)
270
+        self.assertIsNotNone(
271
+            product.attr.file,
272
+            "There file should not be None, even though we set it to that",
273
+        )
274
+        self.assertIn(
275
+            file_field.name,
276
+            product.attr.file.name,
277
+            "The save file should still have the correct filename",
278
+        )
279
+
280
+        # set file attribute to False, which will erase it
281
+        product.attr.file = False
282
+        product.attr.save()
283
+
284
+        product = Product.objects.get(pk=product.pk)
285
+        with self.assertRaises(AttributeError):
286
+            self.assertIn(file_field.name, product.attr.file.name)

+ 14
- 0
tests/integration/catalogue/test_category.py View File

@@ -90,6 +90,20 @@ class TestMovingACategory(TestCase):
90 90
         gothic = Category.objects.get(name="Gothic")
91 91
         self.assertEqual("Books > Non-fiction > Horror > Gothic", gothic.full_name)
92 92
 
93
+    def test_fix_tree(self):
94
+        "fix_tree should rearrange the incorrect nodes and not cause any errors"
95
+        cat = Category.objects.get(path="00010002")
96
+        pk = cat.pk
97
+        self.assertEqual(cat.path, "00010002")
98
+
99
+        Category.objects.filter(pk=pk).update(path="00010050")
100
+        cat.refresh_from_db()
101
+        self.assertEqual(cat.path, "00010050")
102
+
103
+        Category.fix_tree(fix_paths=True)
104
+        cat.refresh_from_db()
105
+        self.assertEqual(cat.path, "00010003")
106
+
93 107
 
94 108
 class TestCategoryFactory(TestCase):
95 109
     def test_can_create_single_level_category(self):

+ 140
- 1
tests/unit/catalogue/test_product_attributes.py View File

@@ -1,3 +1,4 @@
1
+import unittest
1 2
 from copy import deepcopy
2 3
 
3 4
 from django.core.exceptions import ValidationError
@@ -29,11 +30,19 @@ class ProductAttributeTest(TestCase):
29 30
             name="name",
30 31
             code="name",
31 32
         )
32
-        self.weight_attrs = ProductAttributeFactory(
33
+        self.weight_attr = ProductAttributeFactory(
33 34
             type=ProductAttribute.INTEGER,
34 35
             name="weight",
35 36
             code="weight",
36 37
             product_class=product_class,
38
+            required=True,
39
+        )
40
+        self.richtext_attr = ProductAttributeFactory(
41
+            type=ProductAttribute.RICHTEXT,
42
+            name="html",
43
+            code="html",
44
+            product_class=product_class,
45
+            required=False,
37 46
         )
38 47
 
39 48
         # create the parent product
@@ -202,11 +211,141 @@ class ProductAttributeTest(TestCase):
202 211
             "so it saved, even when the parent has the same value",
203 212
         )
204 213
 
214
+    def test_delete_attribute_value(self):
215
+        "Attributes should be deleted when they are nulled"
216
+        self.assertEqual(self.product.attr.weight, 3)
217
+        self.product.attr.weight = None
218
+        self.product.save()
219
+
220
+        p = Product.objects.get(pk=self.product.pk)
221
+        with self.assertRaises(AttributeError):
222
+            p.attr.weight  # pylint: disable=pointless-statement
223
+
224
+    def test_validate_attribute_value(self):
225
+        self.test_delete_attribute_value()
226
+        with self.assertRaises(ValidationError):
227
+            self.product.attr.validate_attributes()
228
+
205 229
     def test_deepcopy(self):
206 230
         "Deepcopy should not cause a recursion error"
207 231
         deepcopy(self.product)
208 232
         deepcopy(self.child_product)
209 233
 
234
+    def test_set(self):
235
+        "Attributes should be settable from a string key"
236
+        self.product.attr.set("weight", 101)
237
+        self.assertEqual(self.product.attr._dirty, {"weight"})
238
+        self.product.attr.save()
239
+
240
+        p = Product.objects.get(pk=self.product.pk)
241
+
242
+        self.assertEqual(p.attr.weight, 101)
243
+
244
+    def test_set_error(self):
245
+        "set should only accept attributes which are valid python identifiers"
246
+        with self.assertRaises(ValidationError):
247
+            self.product.attr.set("bina-weight", 101)
248
+
249
+        with self.assertRaises(ValidationError):
250
+            self.product.attr.set("8_oepla", "oegaranos")
251
+
252
+        with self.assertRaises(ValidationError):
253
+            self.product.attr.set("set", "validate_identifier=True")
254
+
255
+        with self.assertRaises(ValidationError):
256
+            self.product.attr.set("save", "raise=True")
257
+
258
+    def test_update(self):
259
+        "Attributes should be updateble from a dictionary"
260
+        self.product.attr.update({"weight": 808, "name": "a banana"})
261
+        self.assertEqual(self.product.attr._dirty, {"weight", "name"})
262
+        self.product.attr.save()
263
+
264
+        p = Product.objects.get(pk=self.product.pk)
265
+
266
+        self.assertEqual(p.attr.weight, 808)
267
+        self.assertEqual(p.attr.name, "a banana")
268
+
269
+    def test_validate_attributes(self):
270
+        "validate_attributes should raise ValidationError on erroneous inputs"
271
+        self.product.attr.validate_attributes()
272
+        self.product.attr.weight = "koe"
273
+        with self.assertRaises(ValidationError):
274
+            self.product.attr.validate_attributes()
275
+
276
+    def test_get_attribute_by_code(self):
277
+        at = self.product.attr.get_attribute_by_code("weight")
278
+        self.assertEqual(at.code, "weight")
279
+        self.assertEqual(at.product_class, self.product.get_product_class())
280
+
281
+        self.assertIsNone(self.product.attr.get_attribute_by_code("stoubafluppie"))
282
+
283
+    def test_attribute_html(self):
284
+        self.product.attr.html = "<h1>Hi</h1>"
285
+        self.product.save()
286
+
287
+        value = self.product.attr.get_value_by_attribute(self.richtext_attr)
288
+        html = value.value_as_html
289
+        self.assertEqual(html, "<h1>Hi</h1>")
290
+        self.assertTrue(hasattr(html, "__html__"))
291
+
292
+
293
+class MultiOptionTest(TestCase):
294
+    fixtures = ["productattributes"]
295
+    maxDiff = None
296
+
297
+    def test_multi_option_recursion_error(self):
298
+        product = Product.objects.get(pk=4)
299
+        with self.assertRaises(ValueError):
300
+            product.attr.set("subkinds", "harrie")
301
+            product.save()
302
+
303
+    def test_value_as_html(self):
304
+        product = Product.objects.get(pk=4)
305
+        # pylint: disable=unused-variable
306
+        (
307
+            additional_info,
308
+            available,
309
+            facets,
310
+            hypothenusa,
311
+            kind,
312
+            releasedate,
313
+            starttime,
314
+            subkinds,
315
+            subtitle,
316
+        ) = product.attr.get_values().order_by("id")
317
+
318
+        self.assertTrue(
319
+            additional_info.value_as_html.startswith(
320
+                '<p style="margin: 0px; font-stretch: normal; font-size: 12px;'
321
+            )
322
+        )
323
+        self.assertEqual(available.value_as_html, "Yes")
324
+        self.assertEqual(kind.value_as_html, "bombastic")
325
+        self.assertEqual(subkinds.value_as_html, "grand, verocious, megalomane")
326
+        self.assertEqual(subtitle.value_as_html, "kekjo")
327
+
328
+    @unittest.skip("The implementation is wrong, which makes these tests fail")
329
+    def test_broken_value_as_html(self):
330
+        product = Product.objects.get(pk=4)
331
+        # pylint: disable=unused-variable
332
+        (
333
+            additional_info,
334
+            available,
335
+            facets,
336
+            hypothenusa,
337
+            kind,
338
+            releasedate,
339
+            starttime,
340
+            subkinds,
341
+            subtitle,
342
+        ) = product.attr.get_values().order_by("id")
343
+
344
+        self.assertEqual(starttime.value_as_html, "2018-11-16T09:15:00+00:00")
345
+        self.assertEqual(facets.value_as_html, "4")
346
+        self.assertEqual(releasedate.value_as_html, "2018-11-16")
347
+        self.assertEqual(hypothenusa.value_as_html, "2.4567")
348
+
210 349
 
211 350
 class ProductAttributeQuerysetTest(TestCase):
212 351
     fixtures = ["productattributes"]

Loading…
Cancel
Save