|
|
@@ -11,7 +11,9 @@ from django.core.exceptions import ImproperlyConfigured, ValidationError
|
|
11
|
11
|
from django.core.files.base import File
|
|
12
|
12
|
from django.core.validators import RegexValidator
|
|
13
|
13
|
from django.db import models
|
|
14
|
|
-from django.db.models import Count, Sum
|
|
|
14
|
+from django.db.models import Count, Exists, OuterRef, Sum
|
|
|
15
|
+from django.db.models.fields import Field
|
|
|
16
|
+from django.db.models.lookups import StartsWith
|
|
15
|
17
|
from django.urls import reverse
|
|
16
|
18
|
from django.utils.functional import cached_property
|
|
17
|
19
|
from django.utils.html import strip_tags
|
|
|
@@ -21,7 +23,7 @@ from django.utils.translation import gettext_lazy as _
|
|
21
|
23
|
from django.utils.translation import pgettext_lazy
|
|
22
|
24
|
from treebeard.mp_tree import MP_Node
|
|
23
|
25
|
|
|
24
|
|
-from oscar.core.loading import get_class, get_model
|
|
|
26
|
+from oscar.core.loading import get_class, get_classes, get_model
|
|
25
|
27
|
from oscar.core.utils import slugify
|
|
26
|
28
|
from oscar.core.validators import non_python_keyword
|
|
27
|
29
|
from oscar.models.fields import AutoSlugField, NullCharField
|
|
|
@@ -29,11 +31,44 @@ from oscar.models.fields.slugfield import SlugField
|
|
29
|
31
|
from oscar.utils.models import get_image_upload_path
|
|
30
|
32
|
|
|
31
|
33
|
BrowsableProductManager = get_class('catalogue.managers', 'BrowsableProductManager')
|
|
32
|
|
-ProductQuerySet = get_class('catalogue.managers', 'ProductQuerySet')
|
|
|
34
|
+CategoryQuerySet, ProductQuerySet = get_classes(
|
|
|
35
|
+ 'catalogue.managers', ['CategoryQuerySet', 'ProductQuerySet'])
|
|
33
|
36
|
ProductAttributesContainer = get_class(
|
|
34
|
37
|
'catalogue.product_attributes', 'ProductAttributesContainer')
|
|
35
|
38
|
|
|
36
|
39
|
|
|
|
40
|
+class ReverseStartsWith(StartsWith):
|
|
|
41
|
+ """
|
|
|
42
|
+ Adds a new lookup method to the django query language, that allows the
|
|
|
43
|
+ following syntax::
|
|
|
44
|
+
|
|
|
45
|
+ henk__rstartswith="koe"
|
|
|
46
|
+
|
|
|
47
|
+ The regular version of startswith::
|
|
|
48
|
+
|
|
|
49
|
+ henk__startswith="koe"
|
|
|
50
|
+
|
|
|
51
|
+ Would be about the same as the python statement::
|
|
|
52
|
+
|
|
|
53
|
+ henk.startswith("koe")
|
|
|
54
|
+
|
|
|
55
|
+ ReverseStartsWith will flip the right and left hand side of the expression,
|
|
|
56
|
+ effectively making this the same query as::
|
|
|
57
|
+
|
|
|
58
|
+ "koe".startswith(henk)
|
|
|
59
|
+ """
|
|
|
60
|
+ def process_rhs(self, compiler, connection):
|
|
|
61
|
+ return super().process_lhs(compiler, connection)
|
|
|
62
|
+
|
|
|
63
|
+ def process_lhs(self, compiler, connection, lhs=None):
|
|
|
64
|
+ if lhs is not None:
|
|
|
65
|
+ raise Exception("Flipped process_lhs does not accept lhs argument")
|
|
|
66
|
+ return super().process_rhs(compiler, connection)
|
|
|
67
|
+
|
|
|
68
|
+
|
|
|
69
|
+Field.register_lookup(ReverseStartsWith, "rstartswith")
|
|
|
70
|
+
|
|
|
71
|
+
|
|
37
|
72
|
class AbstractProductClass(models.Model):
|
|
38
|
73
|
"""
|
|
39
|
74
|
Used for defining options and attributes for a subset of products.
|
|
|
@@ -97,9 +132,23 @@ class AbstractCategory(MP_Node):
|
|
97
|
132
|
null=True, max_length=255)
|
|
98
|
133
|
slug = SlugField(_('Slug'), max_length=255, db_index=True)
|
|
99
|
134
|
|
|
|
135
|
+ is_public = models.BooleanField(
|
|
|
136
|
+ _('Is public'),
|
|
|
137
|
+ default=True,
|
|
|
138
|
+ db_index=True,
|
|
|
139
|
+ help_text=_("Show this category in search results and catalogue listings."))
|
|
|
140
|
+
|
|
|
141
|
+ ancestors_are_public = models.BooleanField(
|
|
|
142
|
+ _('Ancestor categories are public'),
|
|
|
143
|
+ default=True,
|
|
|
144
|
+ db_index=True,
|
|
|
145
|
+ help_text=_("The ancestors of this category are public"))
|
|
|
146
|
+
|
|
100
|
147
|
_slug_separator = '/'
|
|
101
|
148
|
_full_name_separator = ' > '
|
|
102
|
149
|
|
|
|
150
|
+ objects = CategoryQuerySet.as_manager()
|
|
|
151
|
+
|
|
103
|
152
|
def __str__(self):
|
|
104
|
153
|
return self.full_name
|
|
105
|
154
|
|
|
|
@@ -158,9 +207,34 @@ class AbstractCategory(MP_Node):
|
|
158
|
207
|
"""
|
|
159
|
208
|
if not self.slug:
|
|
160
|
209
|
self.slug = self.generate_slug()
|
|
161
|
|
-
|
|
162
|
210
|
super().save(*args, **kwargs)
|
|
163
|
211
|
|
|
|
212
|
+ def set_ancestors_are_public(self):
|
|
|
213
|
+ # Update ancestors_are_public for the sub tree.
|
|
|
214
|
+ # note: This doesn't trigger a new save for each instance, rather
|
|
|
215
|
+ # just a SQL update.
|
|
|
216
|
+ included_in_non_public_subtree = self.__class__.objects.filter(
|
|
|
217
|
+ is_public=False, path__rstartswith=OuterRef("path"), depth__lt=OuterRef("depth")
|
|
|
218
|
+ )
|
|
|
219
|
+ self.get_descendants_and_self().update(
|
|
|
220
|
+ ancestors_are_public=Exists(
|
|
|
221
|
+ included_in_non_public_subtree.values("id"), negated=True))
|
|
|
222
|
+
|
|
|
223
|
+ # Correctly populate ancestors_are_public
|
|
|
224
|
+ self.refresh_from_db()
|
|
|
225
|
+
|
|
|
226
|
+ @classmethod
|
|
|
227
|
+ def fix_tree(cls, destructive=False):
|
|
|
228
|
+ super().fix_tree(destructive)
|
|
|
229
|
+ for node in cls.get_root_nodes():
|
|
|
230
|
+ # ancestors_are_public *must* be True for root nodes, or all trees
|
|
|
231
|
+ # will become non-public
|
|
|
232
|
+ if not node.ancestors_are_public:
|
|
|
233
|
+ node.ancestors_are_public = True
|
|
|
234
|
+ node.save()
|
|
|
235
|
+ else:
|
|
|
236
|
+ node.set_ancestors_are_public()
|
|
|
237
|
+
|
|
164
|
238
|
def get_ancestors_and_self(self):
|
|
165
|
239
|
"""
|
|
166
|
240
|
Gets ancestors and includes itself. Use treebeard's get_ancestors
|
|
|
@@ -268,6 +342,7 @@ class AbstractProduct(models.Model):
|
|
268
|
342
|
is_public = models.BooleanField(
|
|
269
|
343
|
_('Is public'),
|
|
270
|
344
|
default=True,
|
|
|
345
|
+ db_index=True,
|
|
271
|
346
|
help_text=_("Show this product in search results and catalogue listings."))
|
|
272
|
347
|
|
|
273
|
348
|
upc = NullCharField(
|