Repository: django-oscar/django-oscar

Issue:
It is not possible to exclude specific child products from a product range that includes the parent product.
Because the range query selects all children AFTER applying the excludes it is not possible to exclude specific children.


Discussion:

Resolving patch:
diff --git a/src/oscar/apps/dashboard/ranges/forms.py b/src/oscar/apps/dashboard/ranges/forms.py
--- a/src/oscar/apps/dashboard/ranges/forms.py
+++ b/src/oscar/apps/dashboard/ranges/forms.py
@@ -9,6 +9,8 @@
 Product = get_model('catalogue', 'Product')
 Range = get_model('offer', 'Range')
 
+UPC_SET_REGEX = re.compile(r'[^,\s]+')
+
 
 class RangeForm(forms.ModelForm):
 
@@ -46,7 +48,7 @@ def clean_query(self):
             return raw
 
         # Check that the search matches some products
-        ids = set(re.compile(r'[\w-]+').findall(raw))
+        ids = set(UPC_SET_REGEX.findall(raw))
         products = self.range.all_products()
         existing_skus = set(products.values_list(
             'stockrecords__partner_sku', flat=True))
diff --git a/src/oscar/apps/offer/abstract_models.py b/src/oscar/apps/offer/abstract_models.py
--- a/src/oscar/apps/offer/abstract_models.py
+++ b/src/oscar/apps/offer/abstract_models.py
@@ -967,7 +967,7 @@ def product_queryset(self):
 
         # select all those product that are selected either by product class,
         # category, or explicitly by included_products.
-        all_parents = (
+        selected_parents = (
             Product.objects.filter(
                 Q(product_class_id__in=self.classes.values("id"))
                 | Q(categories__in=category_tree)
@@ -975,15 +975,15 @@ def product_queryset(self):
             | self.included_products.all()
         )
 
+        # select parents and their children
+        selected_products = (
+            selected_parents | Product.objects.filter(parent__in=selected_parents)
+        )
+
         # now go and exclude all explicitly excluded products
         excludes = self.excluded_products.values("id")
-        selected_parents = all_parents.exclude(
+        return selected_products.exclude(
             Q(parent_id__in=excludes) | Q(id__in=excludes)
-        )
-
-        # select parents and their children
-        return (
-            selected_parents | Product.objects.filter(parent__in=selected_parents)
         ).distinct()
 
     @property


Test patch:
diff --git a/tests/functional/dashboard/test_range.py b/tests/functional/dashboard/test_range.py
--- a/tests/functional/dashboard/test_range.py
+++ b/tests/functional/dashboard/test_range.py
@@ -77,6 +77,9 @@ def setUp(self):
         )
         self.product3 = create_product(partner_sku='456')
         self.product4 = create_product(partner_sku='789')
+        self.parent = create_product(upc="1234", structure="parent")
+        self.child1 = create_product(upc="1234.345", structure="child", parent=self.parent)
+        self.child2 = create_product(upc="1234-345", structure="child", parent=self.parent)
 
     def test_upload_file_with_skus(self):
         range_products_page = self.get(self.url)
@@ -162,3 +165,45 @@ def test_same_skus_within_different_products_warning_file_upload(self):
         self.assertEqual(
             messages[1].message, 'There are more than one product with SKU 123123'
         )
+
+    def test_adding_child_does_not_add_parent(self):
+        range_products_page = self.get(self.url)
+        form = range_products_page.forms[0]
+        form['query'] = '1234.345'
+        form.submit().follow()
+        all_products = self.range.all_products()
+        self.assertEqual(len(all_products), 1)
+        self.assertFalse(self.range.contains_product(self.parent))
+        self.assertTrue(self.range.contains_product(self.child1))
+        self.assertFalse(self.range.contains_product(self.child2))
+
+        form = range_products_page.forms[0]
+        form['query'] = '1234-345'
+        form.submit().follow()
+        all_products = self.range.all_products()
+        self.assertEqual(len(all_products), 1)
+        self.assertTrue(self.range.contains_product(self.child1))
+        self.assertTrue(self.range.contains_product(self.child2))
+        self.assertFalse(self.range.contains_product(self.parent))
+
+    def test_adding_multiple_children_does_not_add_parent(self):
+        range_products_page = self.get(self.url)
+        form = range_products_page.forms[0]
+        form['query'] = '1234.345 1234-345'
+        form.submit().follow()
+        all_products = self.range.all_products()
+        self.assertEqual(len(all_products), 2)
+        self.assertTrue(self.range.contains_product(self.child1))
+        self.assertTrue(self.range.contains_product(self.child2))
+        self.assertFalse(self.range.contains_product(self.parent))
+
+    def test_adding_multiple_comma_separated_children_does_not_add_parent(self):
+        range_products_page = self.get(self.url)
+        form = range_products_page.forms[0]
+        form['query'] = '1234.345, 1234-345'
+        form.submit().follow()
+        all_products = self.range.all_products()
+        self.assertEqual(len(all_products), 2)
+        self.assertTrue(self.range.contains_product(self.child1))
+        self.assertTrue(self.range.contains_product(self.child2))
+        self.assertFalse(self.range.contains_product(self.parent))
diff --git a/tests/integration/offer/test_range.py b/tests/integration/offer/test_range.py
--- a/tests/integration/offer/test_range.py
+++ b/tests/integration/offer/test_range.py
@@ -49,6 +49,13 @@ def test_does_not_include_parent(self):
     def test_does_not_include_sibling(self):
         self.assertFalse(self.range.contains_product(self.child2))
 
+    def test_parent_with_child_exception(self):
+        self.range.add_product(self.parent)
+        self.range.remove_product(self.child1)
+        self.assertTrue(self.range.contains_product(self.parent))
+        self.assertTrue(self.range.contains_product(self.child2))
+        self.assertFalse(self.range.contains_product(self.child1))
+
 
 class TestPartialRange(TestCase):