소스 검색

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,6 +31,16 @@ What's new in Oscar 2.1?
31 31
   Django doesn't generate migrations if a project modifies the ``OSCAR_IMAGE_FOLDER``
32 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 44
 Communications app
35 45
 ------------------
36 46
 
@@ -125,6 +135,8 @@ Minor changes
125 135
   in Django's form, but it was possible to send a password reset email to
126 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 141
 Dependency changes
130 142
 ~~~~~~~~~~~~~~~~~~

+ 79
- 4
src/oscar/apps/catalogue/abstract_models.py 파일 보기

@@ -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(

+ 10
- 0
src/oscar/apps/catalogue/managers.py 파일 보기

@@ -3,6 +3,7 @@ from collections import defaultdict
3 3
 from django.db import models
4 4
 from django.db.models import Exists, OuterRef
5 5
 from django.db.models.constants import LOOKUP_SEP
6
+from treebeard.mp_tree import MP_NodeQuerySet
6 7
 
7 8
 from oscar.core.decorators import deprecated
8 9
 from oscar.core.loading import get_model
@@ -144,3 +145,12 @@ class BrowsableProductManager(ProductManager):
144 145
     @deprecated
145 146
     def get_queryset(self):
146 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 파일 보기

@@ -0,0 +1,28 @@
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,16 +1,18 @@
1 1
 # -*- coding: utf-8 -*-
2
-
3 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 11
 if settings.OSCAR_DELETE_IMAGE_FILES:
6 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 13
     from oscar.core.thumbnails import get_thumbnailer
11 14
 
12 15
     ProductImage = get_model('catalogue', 'ProductImage')
13
-    Category = get_model('catalogue', 'Category')
14 16
 
15 17
     def delete_image_files(sender, instance, **kwargs):
16 18
         """
@@ -28,3 +30,8 @@ if settings.OSCAR_DELETE_IMAGE_FILES:
28 30
     models_with_images = [ProductImage, Category]
29 31
     for sender in models_with_images:
30 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,6 +158,11 @@ class ProductCategoryView(TemplateView):
158 158
     def get(self, request, *args, **kwargs):
159 159
         # Fetch the category; return 404 or redirect as needed
160 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 166
         potential_redirect = self.redirect_if_necessary(
162 167
             request.path, self.category)
163 168
         if potential_redirect is not None:
@@ -172,6 +177,9 @@ class ProductCategoryView(TemplateView):
172 177
 
173 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 183
     def get_category(self):
176 184
         return get_object_or_404(Category, pk=self.kwargs['pk'])
177 185
 

+ 2
- 1
src/oscar/apps/dashboard/catalogue/forms.py 파일 보기

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

+ 0
- 37
src/oscar/apps/offer/abstract_models.py 파일 보기

@@ -7,8 +7,6 @@ from django.conf import settings
7 7
 from django.core import exceptions
8 8
 from django.db import models
9 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 10
 from django.db.models.query import Q
13 11
 from django.template.defaultfilters import date as date_filter
14 12
 from django.urls import reverse
@@ -29,41 +27,6 @@ ZERO_DISCOUNT = get_class('offer.results', 'ZERO_DISCOUNT')
29 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 30
 class BaseOfferMixin(models.Model):
68 31
     class Meta:
69 32
         abstract = True

+ 2
- 0
src/oscar/templatetags/category_tags.py 파일 보기

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

+ 28
- 0
tests/_site/apps/catalogue/migrations/0018_auto_20191220_0920.py 파일 보기

@@ -0,0 +1,28 @@
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,3 +122,42 @@ class TestProductCategoryView(WebTestCase):
122 122
         response = self.app.get(wrong_url)
123 123
         self.assertEqual(http_client.MOVED_PERMANENTLY, response.status_code)
124 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)

Loading…
취소
저장