From 5f4afca0f3d205ec5134801404cd36103d61a472 Mon Sep 17 00:00:00 2001 From: Samar Hassan Date: Fri, 22 Mar 2024 19:24:28 +0000 Subject: [PATCH] feat :star: add excluded categories in range --- src/oscar/apps/dashboard/ranges/forms.py | 1 + src/oscar/apps/offer/abstract_models.py | 33 +++++++++++++++++-- .../0013_range_excluded_categories.py | 24 ++++++++++++++ src/oscar/apps/offer/queryset.py | 16 ++++++++- tests/integration/offer/test_range.py | 12 +++++++ 5 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 src/oscar/apps/offer/migrations/0013_range_excluded_categories.py diff --git a/src/oscar/apps/dashboard/ranges/forms.py b/src/oscar/apps/dashboard/ranges/forms.py index b2d7657a41..08673cebba 100644 --- a/src/oscar/apps/dashboard/ranges/forms.py +++ b/src/oscar/apps/dashboard/ranges/forms.py @@ -22,6 +22,7 @@ class Meta: "is_public", "includes_all_products", "included_categories", + "excluded_categories", ] diff --git a/src/oscar/apps/offer/abstract_models.py b/src/oscar/apps/offer/abstract_models.py index 9c284a4c74..f12c92532c 100644 --- a/src/oscar/apps/offer/abstract_models.py +++ b/src/oscar/apps/offer/abstract_models.py @@ -1003,6 +1003,16 @@ class AbstractRange(models.Model): blank=True, verbose_name=_("Included Categories"), ) + excluded_categories = models.ManyToManyField( + "catalogue.Category", + related_name="excludes", + blank=True, + verbose_name=_("Excluded Categories"), + help_text=_( + "Products with these categories are excluded from the range when " + "Includes all products is checked" + ), + ) # Allow a custom range instance to be specified proxy_class = fields.NullCharField( @@ -1112,8 +1122,23 @@ def product_queryset(self): Product = self.included_products.model if self.includes_all_products: + _filter = Q(id__in=self.excluded_products.values("id")) + # extend filter if excluded_categories exist + if self.excluded_categories.exists(): + expanded_range_categories = ExpandDownwardsCategoryQueryset( + self.excluded_categories.values("id") + ) + _filter |= Q(categories__in=expanded_range_categories) + # extend filter for parent categories, exclude parent = None + if ( + Product.objects.exclude(parent=None) + .filter(parent__categories__in=expanded_range_categories) + .exists() + ): + _filter |= Q(parent__categories__in=expanded_range_categories) + # Filter out blacklisted - return Product.objects.exclude(id__in=self.excluded_products.values("id")) + return Product.objects.exclude(_filter) # start with filter clause that always applies _filter = Q(includes=self) @@ -1162,7 +1187,11 @@ def is_reorderable(self): """ Test whether products for the range can be re-ordered. """ - return not (self.included_categories.exists() or self.classes.exists()) + return not ( + self.included_categories.exists() + or self.classes.exists() + or (self.excluded_categories.exists() and self.includes_all_products) + ) class AbstractRangeProduct(models.Model): diff --git a/src/oscar/apps/offer/migrations/0013_range_excluded_categories.py b/src/oscar/apps/offer/migrations/0013_range_excluded_categories.py new file mode 100644 index 0000000000..092834c52b --- /dev/null +++ b/src/oscar/apps/offer/migrations/0013_range_excluded_categories.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.9 on 2024-03-22 19:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("catalogue", "0027_attributeoption_code_attributeoptiongroup_code_and_more"), + ("offer", "0012_fixedunitdiscountbenefit_alter_benefit_type"), + ] + + operations = [ + migrations.AddField( + model_name="range", + name="excluded_categories", + field=models.ManyToManyField( + blank=True, + help_text="Products with these categories are excluded from the range when Includes all products is checked", + related_name="excludes", + to="catalogue.category", + verbose_name="Excluded Categories", + ), + ), + ] diff --git a/src/oscar/apps/offer/queryset.py b/src/oscar/apps/offer/queryset.py index a7bcf6e856..1dc9dd4e09 100644 --- a/src/oscar/apps/offer/queryset.py +++ b/src/oscar/apps/offer/queryset.py @@ -23,6 +23,17 @@ def _excluded_products_clause(self, product): ) return ~models.Q(excluded_products=product) + def _excluded_categories_clause(self, product): + if product.structure == product.CHILD: + # child products are excluded from a range if their parent contains + # category that is excluded + return ~( + models.Q( + excluded_categories__id__in=product.parent.categories.values("id") + ) + ) + return ~models.Q(excluded_categories__id__in=product.categories.values("id")) + def _included_products_clause(self, product): if product.structure == product.CHILD: # child products are included in a range if either they are @@ -56,10 +67,13 @@ def contains_product(self, product): # turned on, we only need to look at explicit exclusions, the other # mechanism for adding a product to a range don't need to be checked wide = self.filter( - self._excluded_products_clause(product), includes_all_products=True + self._excluded_products_clause(product), + self._excluded_categories_clause(product), + includes_all_products=True, ) narrow = self.filter( self._excluded_products_clause(product), + self._excluded_categories_clause(product), self._included_products_clause(product) | models.Q( included_categories__in=ExpandUpwardsCategoryQueryset( diff --git a/tests/integration/offer/test_range.py b/tests/integration/offer/test_range.py index bb06a7b14f..e365aed53a 100644 --- a/tests/integration/offer/test_range.py +++ b/tests/integration/offer/test_range.py @@ -11,6 +11,9 @@ def setUp(self): name="All products", includes_all_products=True ) self.prod = create_product() + self.child = create_product(structure="child", parent=self.prod) + self.category = catalogue_models.Category.add_root(name="root") + self.prod.categories.add(self.category) def test_all_products_range(self): self.assertTrue(self.range.contains_product(self.prod)) @@ -30,6 +33,15 @@ def test_blacklisting(self): self.assertFalse(self.range.contains_product(self.prod)) self.assertNotIn(self.prod, self.range.all_products()) + def test_category_blacklisting(self): + self.range.excluded_categories.add(self.category) + self.assertNotIn(self.range, models.Range.objects.contains_product(self.prod)) + self.assertNotIn(self.range, models.Range.objects.contains_product(self.child)) + self.assertFalse(self.range.contains_product(self.prod)) + self.assertFalse(self.range.contains_product(self.child)) + self.assertNotIn(self.prod, self.range.all_products()) + self.assertNotIn(self.child, self.range.all_products()) + class TestChildRange(TestCase): def setUp(self):