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
 import logging
1
 import logging
2
 import os
2
 import os
3
 from datetime import date, datetime
3
 from datetime import date, datetime
4
-
5
 from django.conf import settings
4
 from django.conf import settings
6
 from django.contrib.contenttypes.fields import GenericForeignKey
5
 from django.contrib.contenttypes.fields import GenericForeignKey
7
 from django.contrib.contenttypes.models import ContentType
6
 from django.contrib.contenttypes.models import ContentType
8
 from django.contrib.staticfiles.finders import find
7
 from django.contrib.staticfiles.finders import find
9
 from django.core.cache import cache
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
 from django.core.files.base import File
14
 from django.core.files.base import File
12
 from django.core.validators import RegexValidator
15
 from django.core.validators import RegexValidator
13
 from django.db import models
16
 from django.db import models
590
         super().save(*args, **kwargs)
593
         super().save(*args, **kwargs)
591
         self.attr.save()
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
     # Properties
600
     # Properties
594
 
601
 
595
     @property
602
     @property
968
         if self.type == self.BOOLEAN and self.required:
975
         if self.type == self.BOOLEAN and self.required:
969
             raise ValidationError(_("Boolean attribute should not be required."))
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
         if value is None:
990
         if value is None:
975
             # No change
991
             # No change
976
-            return
992
+            return value_obj
977
         elif value is False:
993
         elif value is False:
978
-            # Delete file
979
-            value_obj.delete()
994
+            return None
980
         else:
995
         else:
981
             # New uploaded file
996
             # New uploaded file
982
             value_obj.value = value
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
         # ManyToMany fields are handled separately
1001
         # ManyToMany fields are handled separately
987
         if value is None:
1002
         if value is None:
988
-            value_obj.delete()
989
-            return
1003
+            return None
990
         try:
1004
         try:
991
             count = value.count()
1005
             count = value.count()
992
         except (AttributeError, TypeError):
1006
         except (AttributeError, TypeError):
993
             count = len(value)
1007
             count = len(value)
994
         if count == 0:
1008
         if count == 0:
995
-            value_obj.delete()
1009
+            return None
996
         else:
1010
         else:
997
             value_obj.value = value
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
         if value is None or value == "":
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
         if self.is_file:
1025
         if self.is_file:
1023
-            self._save_file(value_obj, value)
1026
+            return self._bind_value_file(value_obj, value)
1024
         elif self.is_multi_option:
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
         else:
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
     def validate_value(self, value):
1046
     def validate_value(self, value):
1030
         validator = getattr(self, "_validate_%s" % self.type)
1047
         validator = getattr(self, "_validate_%s" % self.type)
1158
     entity_object_id = models.PositiveIntegerField(
1175
     entity_object_id = models.PositiveIntegerField(
1159
         null=True, blank=True, editable=False
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
     def _get_value(self):
1188
     def _get_value(self):
1163
-        value = getattr(self, "value_%s" % self.attribute.type)
1189
+        value = getattr(self, self.value_field_name)
1164
         if hasattr(value, "all"):
1190
         if hasattr(value, "all"):
1165
             value = value.all()
1191
             value = value.all()
1166
         return value
1192
         return value
1167
 
1193
 
1168
     def _set_value(self, new_value):
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
         if self.attribute.is_option and isinstance(new_value, str):
1197
         if self.attribute.is_option and isinstance(new_value, str):
1172
             # Need to look up instance of AttributeOption
1198
             # Need to look up instance of AttributeOption
1173
             new_value = self.attribute.option_group.options.get(option=new_value)
1199
             new_value = self.attribute.option_group.options.get(option=new_value)
1174
         elif self.attribute.is_multi_option:
1200
         elif self.attribute.is_multi_option:
1175
             getattr(self, attr_name).set(new_value)
1201
             getattr(self, attr_name).set(new_value)
1202
+            self._dirty = True
1176
             return
1203
             return
1177
 
1204
 
1178
         setattr(self, attr_name, new_value)
1205
         setattr(self, attr_name, new_value)
1206
+        self._dirty = True
1179
         return
1207
         return
1180
 
1208
 
1181
     value = property(_get_value, _set_value)
1209
     value = property(_get_value, _set_value)
1182
 
1210
 
1211
+    @property
1212
+    def is_dirty(self):
1213
+        return self._dirty
1214
+
1183
     class Meta:
1215
     class Meta:
1184
         abstract = True
1216
         abstract = True
1185
         app_label = "catalogue"
1217
         app_label = "catalogue"
1204
         e.g. image attribute values, declare a _image_as_text property and
1236
         e.g. image attribute values, declare a _image_as_text property and
1205
         return something appropriate.
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
     @property
1245
     @property
1211
     def _multi_option_as_text(self):
1246
     def _multi_option_as_text(self):
1241
         return e.g. an ``<img>`` tag.  Defaults to the ``_as_text``
1276
         return e.g. an ``<img>`` tag.  Defaults to the ``_as_text``
1242
         representation.
1277
         representation.
1243
         """
1278
         """
1244
-        property_name = "_%s_as_html" % self.attribute.type
1279
+        property_name = "_%s_as_html" % self.type
1245
         return getattr(self, property_name, self.value_as_text)
1280
         return getattr(self, property_name, self.value_as_text)
1246
 
1281
 
1247
     @property
1282
     @property

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

101
         product_options = Option.objects.filter(product=OuterRef("pk"))
101
         product_options = Option.objects.filter(product=OuterRef("pk"))
102
         return (
102
         return (
103
             self.select_related("product_class")
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
             .prefetch_related(
108
             .prefetch_related(
105
                 "children",
109
                 "children",
106
                 "product_options",
110
                 "product_options",
108
                 "stockrecords",
112
                 "stockrecords",
109
                 "images",
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
     def browsable(self):
117
     def browsable(self):

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

1
+from copy import deepcopy
2
+from django.db import models
1
 from django.core.exceptions import ObjectDoesNotExist, ValidationError
3
 from django.core.exceptions import ObjectDoesNotExist, ValidationError
2
 from django.utils.translation import gettext_lazy as _
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
 class ProductAttributesContainer:
46
 class ProductAttributesContainer:
15
         product.attr.refresh()
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
     def __init__(self, product):
78
     def __init__(self, product):
26
         # use __dict__ directly to avoid triggering __setattr__, which would
79
         # use __dict__ directly to avoid triggering __setattr__, which would
27
         # cause a recursion error on _initialized.
80
         # cause a recursion error on _initialized.
28
         self.__dict__.update(
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
     @property
101
     @property
33
     def product(self):
102
     def product(self):
34
         return self._product
103
         return self._product
37
     def initialized(self):
106
     def initialized(self):
38
         return self._initialized
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
     def initialize(self):
115
     def initialize(self):
47
-        self.initialized = True
116
+        self.__dict__["_initialized"] = True
48
         # initialize should not overwrite any values that have allready been set
117
         # initialize should not overwrite any values that have allready been set
49
         attrs = self.__dict__
118
         attrs = self.__dict__
50
-        for v in self.get_values().select_related("attribute"):
119
+        for v in self.get_values():
51
             attrs.setdefault(v.attribute.code, v.value)
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
     def refresh(self):
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
             setattr(self, v.attribute.code, v.value)
131
             setattr(self, v.attribute.code, v.value)
56
 
132
 
57
     def __getattribute__(self, name):
133
     def __getattribute__(self, name):
72
         )
148
         )
73
 
149
 
74
     def __setattr__(self, name, value):
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
         self._dirty.add(name)
156
         self._dirty.add(name)
76
         super().__setattr__(name, value)
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
     def validate_attributes(self):
174
     def validate_attributes(self):
79
         for attribute in self.get_all_attributes():
175
         for attribute in self.get_all_attributes():
80
             value = getattr(self, attribute.code, None)
176
             value = getattr(self, attribute.code, None)
94
                     )
190
                     )
95
 
191
 
96
     def get_values(self):
192
     def get_values(self):
97
-        return self.product.get_attribute_values()
193
+        return self.cache.attribute_values.queryset()
98
 
194
 
99
     def get_value_by_attribute(self, attribute):
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
     def get_all_attributes(self):
198
     def get_all_attributes(self):
103
-        return self.product.get_product_class().attributes.all()
199
+        return self.cache.attributes.queryset()
104
 
200
 
105
     def get_attribute_by_code(self, code):
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
     def __iter__(self):
204
     def __iter__(self):
109
         return iter(self.get_values())
205
         return iter(self.get_values())
110
 
206
 
111
     def save(self):
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
         for attribute in self.get_all_attributes():
217
         for attribute in self.get_all_attributes():
113
             if hasattr(self, attribute.code):
218
             if hasattr(self, attribute.code):
114
                 value = getattr(self, attribute.code)
219
                 value = getattr(self, attribute.code)
121
                     # needed and should always be saved.
226
                     # needed and should always be saved.
122
                     try:
227
                     try:
123
                         attribute_value_current = self.get_value_by_attribute(attribute)
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
                             continue  # no new value needs to be saved
233
                             continue  # no new value needs to be saved
126
                     except ObjectDoesNotExist:
234
                     except ObjectDoesNotExist:
127
                         pass  # there is no existing value, so a value needs to be saved.
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
         "max_age": settings.OSCAR_RECENTLY_VIEWED_COOKIE_LIFETIME,
13
         "max_age": settings.OSCAR_RECENTLY_VIEWED_COOKIE_LIFETIME,
14
         "secure": settings.OSCAR_RECENTLY_VIEWED_COOKIE_SECURE,
14
         "secure": settings.OSCAR_RECENTLY_VIEWED_COOKIE_SECURE,
15
         "httponly": True,
15
         "httponly": True,
16
+        "samesite": settings.SESSION_COOKIE_SAMESITE,
16
     }
17
     }
17
     max_products = settings.OSCAR_RECENTLY_VIEWED_PRODUCTS
18
     max_products = settings.OSCAR_RECENTLY_VIEWED_PRODUCTS
18
 
19
 

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

235
 
235
 
236
 class TestFileAttributes(TestCase):
236
 class TestFileAttributes(TestCase):
237
     def setUp(self):
237
     def setUp(self):
238
-        self.attr = factories.ProductAttributeFactory(type="file")
238
+        self.attr = factories.ProductAttributeFactory(type="file", code="file")
239
 
239
 
240
     def test_validate_file_values(self):
240
     def test_validate_file_values(self):
241
         file_field = SimpleUploadedFile("test_file.txt", b"Test")
241
         file_field = SimpleUploadedFile("test_file.txt", b"Test")
242
         self.assertIsNone(self.attr.validate_value(file_field))
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
         gothic = Category.objects.get(name="Gothic")
90
         gothic = Category.objects.get(name="Gothic")
91
         self.assertEqual("Books > Non-fiction > Horror > Gothic", gothic.full_name)
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
 class TestCategoryFactory(TestCase):
108
 class TestCategoryFactory(TestCase):
95
     def test_can_create_single_level_category(self):
109
     def test_can_create_single_level_category(self):

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

1
+import unittest
1
 from copy import deepcopy
2
 from copy import deepcopy
2
 
3
 
3
 from django.core.exceptions import ValidationError
4
 from django.core.exceptions import ValidationError
29
             name="name",
30
             name="name",
30
             code="name",
31
             code="name",
31
         )
32
         )
32
-        self.weight_attrs = ProductAttributeFactory(
33
+        self.weight_attr = ProductAttributeFactory(
33
             type=ProductAttribute.INTEGER,
34
             type=ProductAttribute.INTEGER,
34
             name="weight",
35
             name="weight",
35
             code="weight",
36
             code="weight",
36
             product_class=product_class,
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
         # create the parent product
48
         # create the parent product
202
             "so it saved, even when the parent has the same value",
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
     def test_deepcopy(self):
229
     def test_deepcopy(self):
206
         "Deepcopy should not cause a recursion error"
230
         "Deepcopy should not cause a recursion error"
207
         deepcopy(self.product)
231
         deepcopy(self.product)
208
         deepcopy(self.child_product)
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
 class ProductAttributeQuerysetTest(TestCase):
350
 class ProductAttributeQuerysetTest(TestCase):
212
     fixtures = ["productattributes"]
351
     fixtures = ["productattributes"]

Loading…
Cancel
Save