浏览代码

Add is_public to Category and hide non-public categories from frontend views.

master
Roel Bruggink 6 年前
父节点
当前提交
810eaa9a9d

+ 12
- 0
docs/source/releases/v2.1.rst 查看文件

31
   Django doesn't generate migrations if a project modifies the ``OSCAR_IMAGE_FOLDER``
31
   Django doesn't generate migrations if a project modifies the ``OSCAR_IMAGE_FOLDER``
32
   to specify a custom directory structure for uploaded images.
32
   to specify a custom directory structure for uploaded images.
33
 
33
 
34
+- ``catalogue.Category`` now has an ``is_public`` boolean field that serves a
35
+  similar purpose to ``catalogue.Product.is_public`` - i.e., hides them from
36
+  public views. The ``Category`` model also now has a custom manager
37
+  that provides a ``browsable()`` queryset method that excludes non-public
38
+  categories. This changes requires a database migration.
39
+
40
+  Category hierarchy implies that the children of any non-public category are
41
+  also non-public. This is enforced through an ``ancestors_are_public`` field
42
+  on the ``Category`` model.
43
+
34
 Communications app
44
 Communications app
35
 ------------------
45
 ------------------
36
 
46
 
125
   in Django's form, but it was possible to send a password reset email to
135
   in Django's form, but it was possible to send a password reset email to
126
   unintended recipients because of unicode character collision.
136
   unintended recipients because of unicode character collision.
127
 
137
 
138
+- ``catalogue.Product.is_public`` is now an indexed field. This change requires
139
+  a database migration.
128
 
140
 
129
 Dependency changes
141
 Dependency changes
130
 ~~~~~~~~~~~~~~~~~~
142
 ~~~~~~~~~~~~~~~~~~

+ 79
- 4
src/oscar/apps/catalogue/abstract_models.py 查看文件

11
 from django.core.files.base import File
11
 from django.core.files.base import File
12
 from django.core.validators import RegexValidator
12
 from django.core.validators import RegexValidator
13
 from django.db import models
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
 from django.urls import reverse
17
 from django.urls import reverse
16
 from django.utils.functional import cached_property
18
 from django.utils.functional import cached_property
17
 from django.utils.html import strip_tags
19
 from django.utils.html import strip_tags
21
 from django.utils.translation import pgettext_lazy
23
 from django.utils.translation import pgettext_lazy
22
 from treebeard.mp_tree import MP_Node
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
 from oscar.core.utils import slugify
27
 from oscar.core.utils import slugify
26
 from oscar.core.validators import non_python_keyword
28
 from oscar.core.validators import non_python_keyword
27
 from oscar.models.fields import AutoSlugField, NullCharField
29
 from oscar.models.fields import AutoSlugField, NullCharField
29
 from oscar.utils.models import get_image_upload_path
31
 from oscar.utils.models import get_image_upload_path
30
 
32
 
31
 BrowsableProductManager = get_class('catalogue.managers', 'BrowsableProductManager')
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
 ProductAttributesContainer = get_class(
36
 ProductAttributesContainer = get_class(
34
     'catalogue.product_attributes', 'ProductAttributesContainer')
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
 class AbstractProductClass(models.Model):
72
 class AbstractProductClass(models.Model):
38
     """
73
     """
39
     Used for defining options and attributes for a subset of products.
74
     Used for defining options and attributes for a subset of products.
97
                               null=True, max_length=255)
132
                               null=True, max_length=255)
98
     slug = SlugField(_('Slug'), max_length=255, db_index=True)
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
     _slug_separator = '/'
147
     _slug_separator = '/'
101
     _full_name_separator = ' > '
148
     _full_name_separator = ' > '
102
 
149
 
150
+    objects = CategoryQuerySet.as_manager()
151
+
103
     def __str__(self):
152
     def __str__(self):
104
         return self.full_name
153
         return self.full_name
105
 
154
 
158
         """
207
         """
159
         if not self.slug:
208
         if not self.slug:
160
             self.slug = self.generate_slug()
209
             self.slug = self.generate_slug()
161
-
162
         super().save(*args, **kwargs)
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
     def get_ancestors_and_self(self):
238
     def get_ancestors_and_self(self):
165
         """
239
         """
166
         Gets ancestors and includes itself. Use treebeard's get_ancestors
240
         Gets ancestors and includes itself. Use treebeard's get_ancestors
268
     is_public = models.BooleanField(
342
     is_public = models.BooleanField(
269
         _('Is public'),
343
         _('Is public'),
270
         default=True,
344
         default=True,
345
+        db_index=True,
271
         help_text=_("Show this product in search results and catalogue listings."))
346
         help_text=_("Show this product in search results and catalogue listings."))
272
 
347
 
273
     upc = NullCharField(
348
     upc = NullCharField(

+ 10
- 0
src/oscar/apps/catalogue/managers.py 查看文件

3
 from django.db import models
3
 from django.db import models
4
 from django.db.models import Exists, OuterRef
4
 from django.db.models import Exists, OuterRef
5
 from django.db.models.constants import LOOKUP_SEP
5
 from django.db.models.constants import LOOKUP_SEP
6
+from treebeard.mp_tree import MP_NodeQuerySet
6
 
7
 
7
 from oscar.core.decorators import deprecated
8
 from oscar.core.decorators import deprecated
8
 from oscar.core.loading import get_model
9
 from oscar.core.loading import get_model
144
     @deprecated
145
     @deprecated
145
     def get_queryset(self):
146
     def get_queryset(self):
146
         return super().get_queryset().browsable()
147
         return super().get_queryset().browsable()
148
+
149
+
150
+class CategoryQuerySet(MP_NodeQuerySet):
151
+
152
+    def browsable(self):
153
+        """
154
+        Excludes non-public categories
155
+        """
156
+        return self.filter(is_public=True, ancestors_are_public=True)

+ 28
- 0
src/oscar/apps/catalogue/migrations/0018_auto_20191220_0920.py 查看文件

1
+# Generated by Django 2.2.9 on 2019-12-20 09:20
2
+
3
+from django.db import migrations, models
4
+
5
+
6
+class Migration(migrations.Migration):
7
+
8
+    dependencies = [
9
+        ('catalogue', '0017_auto_20190816_0938'),
10
+    ]
11
+
12
+    operations = [
13
+        migrations.AddField(
14
+            model_name='category',
15
+            name='ancestors_are_public',
16
+            field=models.BooleanField(db_index=True, default=True, help_text='The ancestors of this category are public', verbose_name='Ancestor categories are public'),
17
+        ),
18
+        migrations.AddField(
19
+            model_name='category',
20
+            name='is_public',
21
+            field=models.BooleanField(db_index=True, default=True, help_text='Show this category in search results and catalogue listings.', verbose_name='Is public'),
22
+        ),
23
+        migrations.AlterField(
24
+            model_name='product',
25
+            name='is_public',
26
+            field=models.BooleanField(db_index=True, default=True, help_text='Show this product in search results and catalogue listings.', verbose_name='Is public'),
27
+        ),
28
+    ]

+ 12
- 5
src/oscar/apps/catalogue/receivers.py 查看文件

1
 # -*- coding: utf-8 -*-
1
 # -*- coding: utf-8 -*-
2
-
3
 from django.conf import settings
2
 from django.conf import settings
3
+from django.db.models.signals import post_delete, post_save
4
+from django.dispatch import receiver
5
+
6
+from oscar.core.loading import get_model
7
+
8
+Category = get_model("catalogue", "Category")
9
+
4
 
10
 
5
 if settings.OSCAR_DELETE_IMAGE_FILES:
11
 if settings.OSCAR_DELETE_IMAGE_FILES:
6
     from django.db import models
12
     from django.db import models
7
-    from django.db.models.signals import post_delete
8
-
9
-    from oscar.core.loading import get_model
10
     from oscar.core.thumbnails import get_thumbnailer
13
     from oscar.core.thumbnails import get_thumbnailer
11
 
14
 
12
     ProductImage = get_model('catalogue', 'ProductImage')
15
     ProductImage = get_model('catalogue', 'ProductImage')
13
-    Category = get_model('catalogue', 'Category')
14
 
16
 
15
     def delete_image_files(sender, instance, **kwargs):
17
     def delete_image_files(sender, instance, **kwargs):
16
         """
18
         """
28
     models_with_images = [ProductImage, Category]
30
     models_with_images = [ProductImage, Category]
29
     for sender in models_with_images:
31
     for sender in models_with_images:
30
         post_delete.connect(delete_image_files, sender=sender)
32
         post_delete.connect(delete_image_files, sender=sender)
33
+
34
+
35
+@receiver(post_save, sender=Category, dispatch_uid='set_ancestors_are_public')
36
+def post_save_set_ancestors_are_public(sender, instance, **kwargs):
37
+    instance.set_ancestors_are_public()

+ 8
- 0
src/oscar/apps/catalogue/views.py 查看文件

158
     def get(self, request, *args, **kwargs):
158
     def get(self, request, *args, **kwargs):
159
         # Fetch the category; return 404 or redirect as needed
159
         # Fetch the category; return 404 or redirect as needed
160
         self.category = self.get_category()
160
         self.category = self.get_category()
161
+
162
+        # Allow staff members so they can test layout etc.
163
+        if not self.is_viewable(self.category, request):
164
+            raise Http404()
165
+
161
         potential_redirect = self.redirect_if_necessary(
166
         potential_redirect = self.redirect_if_necessary(
162
             request.path, self.category)
167
             request.path, self.category)
163
         if potential_redirect is not None:
168
         if potential_redirect is not None:
172
 
177
 
173
         return super().get(request, *args, **kwargs)
178
         return super().get(request, *args, **kwargs)
174
 
179
 
180
+    def is_viewable(self, category, request):
181
+        return category.is_public or request.user.is_staff
182
+
175
     def get_category(self):
183
     def get_category(self):
176
         return get_object_or_404(Category, pk=self.kwargs['pk'])
184
         return get_object_or_404(Category, pk=self.kwargs['pk'])
177
 
185
 

+ 2
- 1
src/oscar/apps/dashboard/catalogue/forms.py 查看文件

26
 
26
 
27
 CategoryForm = movenodeform_factory(
27
 CategoryForm = movenodeform_factory(
28
     Category,
28
     Category,
29
-    fields=['name', 'description', 'image'])
29
+    fields=['name', 'description', 'image', 'is_public'],
30
+    exclude=['ancestors_are_public'])
30
 
31
 
31
 
32
 
32
 class ProductClassSelectForm(forms.Form):
33
 class ProductClassSelectForm(forms.Form):

+ 0
- 37
src/oscar/apps/offer/abstract_models.py 查看文件

7
 from django.core import exceptions
7
 from django.core import exceptions
8
 from django.db import models
8
 from django.db import models
9
 from django.db.models import Exists, OuterRef
9
 from django.db.models import Exists, OuterRef
10
-from django.db.models.fields import Field
11
-from django.db.models.lookups import StartsWith
12
 from django.db.models.query import Q
10
 from django.db.models.query import Q
13
 from django.template.defaultfilters import date as date_filter
11
 from django.template.defaultfilters import date as date_filter
14
 from django.urls import reverse
12
 from django.urls import reverse
29
 load_proxy, unit_price = get_classes('offer.utils', ['load_proxy', 'unit_price'])
27
 load_proxy, unit_price = get_classes('offer.utils', ['load_proxy', 'unit_price'])
30
 
28
 
31
 
29
 
32
-class ReverseStartsWith(StartsWith):
33
-    """
34
-    Adds a new lookup method to the django query language, that allows the
35
-    following syntax::
36
-
37
-        henk_rstartswith="koe"
38
-
39
-    The regular version of startswith::
40
-
41
-        henk_startswith="koe"
42
-
43
-     Would be about the same as the python statement::
44
-
45
-        henk.startswith("koe")
46
-
47
-    ReverseStartsWith will flip the right and left hand side of the expression,
48
-    effectively making this the same query as::
49
-
50
-    "koe".startswith(henk)
51
-
52
-    This is used by the range query below, where we need to flip select children
53
-    based on that their depth starts with the depth string of the parent.
54
-    """
55
-    def process_rhs(self, compiler, connection):
56
-        return super().process_lhs(compiler, connection)
57
-
58
-    def process_lhs(self, compiler, connection, lhs=None):
59
-        if lhs is not None:
60
-            raise Exception("Flipped process_lhs does not accept lhs argument")
61
-        return super().process_rhs(compiler, connection)
62
-
63
-
64
-Field.register_lookup(ReverseStartsWith, "rstartswith")
65
-
66
-
67
 class BaseOfferMixin(models.Model):
30
 class BaseOfferMixin(models.Model):
68
     class Meta:
31
     class Meta:
69
         abstract = True
32
         abstract = True

+ 2
- 0
src/oscar/templatetags/category_tags.py 查看文件

94
     if max_depth is not None:
94
     if max_depth is not None:
95
         categories = categories.filter(depth__lte=max_depth)
95
         categories = categories.filter(depth__lte=max_depth)
96
 
96
 
97
+    categories = categories.browsable()
98
+
97
     info = CheapCategoryInfo(parent, url="")
99
     info = CheapCategoryInfo(parent, url="")
98
 
100
 
99
     for node in categories:
101
     for node in categories:

+ 28
- 0
tests/_site/apps/catalogue/migrations/0018_auto_20191220_0920.py 查看文件

1
+# Generated by Django 2.2.9 on 2019-12-20 09:20
2
+
3
+from django.db import migrations, models
4
+
5
+
6
+class Migration(migrations.Migration):
7
+
8
+    dependencies = [
9
+        ('catalogue', '0017_auto_20190816_0938'),
10
+    ]
11
+
12
+    operations = [
13
+        migrations.AddField(
14
+            model_name='category',
15
+            name='ancestors_are_public',
16
+            field=models.BooleanField(db_index=True, default=True, help_text='The ancestors of this category are public', verbose_name='Ancestor categories are public'),
17
+        ),
18
+        migrations.AddField(
19
+            model_name='category',
20
+            name='is_public',
21
+            field=models.BooleanField(db_index=True, default=True, help_text='Show this category in search results and catalogue listings.', verbose_name='Is public'),
22
+        ),
23
+        migrations.AlterField(
24
+            model_name='product',
25
+            name='is_public',
26
+            field=models.BooleanField(db_index=True, default=True, help_text='Show this product in search results and catalogue listings.', verbose_name='Is public'),
27
+        ),
28
+    ]

+ 39
- 0
tests/functional/catalogue/test_catalogue.py 查看文件

122
         response = self.app.get(wrong_url)
122
         response = self.app.get(wrong_url)
123
         self.assertEqual(http_client.MOVED_PERMANENTLY, response.status_code)
123
         self.assertEqual(http_client.MOVED_PERMANENTLY, response.status_code)
124
         self.assertTrue(self.category.get_absolute_url() in response.location)
124
         self.assertTrue(self.category.get_absolute_url() in response.location)
125
+
126
+    def test_is_public_off(self):
127
+        category = Category.add_root(name="Foobars", is_public=False)
128
+        response = self.app.get(category.get_absolute_url(), expect_errors=True)
129
+        self.assertEqual(http_client.NOT_FOUND, response.status_code)
130
+        return category
131
+
132
+    def test_is_public_on(self):
133
+        category = Category.add_root(name="Barfoos", is_public=True)
134
+        response = self.app.get(category.get_absolute_url())
135
+        self.assertEqual(http_client.OK, response.status_code)
136
+        return category
137
+
138
+    def test_browsable_contains_public_child(self):
139
+        "If the parent is public the child should be in browsable if it is public as well"
140
+        cat = self.test_is_public_on()
141
+        child = cat.add_child(name="Koe", is_public=True)
142
+        self.assertTrue(child in Category.objects.all().browsable())
143
+
144
+        child.is_public = False
145
+        child.save()
146
+        self.assertTrue(child not in Category.objects.all().browsable())
147
+
148
+    def test_browsable_hides_public_child(self):
149
+        "If the parent is not public the child should not be in browsable evn if it is public"
150
+        cat = self.test_is_public_off()
151
+        child = cat.add_child(name="Koe", is_public=True)
152
+        self.assertTrue(child not in Category.objects.all().browsable())
153
+
154
+    def test_is_public_child(self):
155
+        cat = self.test_is_public_off()
156
+        child = cat.add_child(name="Koe", is_public=True)
157
+        response = self.app.get(child.get_absolute_url())
158
+        self.assertEqual(http_client.OK, response.status_code)
159
+
160
+        child.is_public = False
161
+        child.save()
162
+        response = self.app.get(child.get_absolute_url(), expect_errors=True)
163
+        self.assertEqual(http_client.NOT_FOUND, response.status_code)

正在加载...
取消
保存