Repository: django-oscar/django-oscar

Issue:
Sortable on Range products_view broken
JQuery UI was removed here: https://github.com/django-oscar/django-oscar/commit/1af6521ab5a6a1891e09f78f6cfd90a55f5e8020

If you go to `products_view` (`/dashboard/ranges/3/products/`) you get a javascript error: `$(...).sortable is not a function`



Discussion:
@john-parton thanks for the report. I'll prepare a fix.


Resolving patch:
diff --git a/src/oscar/apps/dashboard/ranges/views.py b/src/oscar/apps/dashboard/ranges/views.py
--- a/src/oscar/apps/dashboard/ranges/views.py
+++ b/src/oscar/apps/dashboard/ranges/views.py
@@ -200,7 +200,7 @@ def create_upload_object(self, request, range):
 
 class RangeReorderView(View):
     def post(self, request, pk):
-        order = dict(request.POST).get('product[]')
+        order = dict(request.POST).get('product')
         self._save_page_order(order)
         return HttpResponse(status=200)
 
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
@@ -847,6 +847,12 @@ def remove_product(self, product):
         """
         RangeProduct = get_model('offer', 'RangeProduct')
         RangeProduct.objects.filter(range=self, product=product).delete()
+        # Making sure product will be excluded from range products list by adding to
+        # respective field. Otherwise, it could be included as a product from included
+        # category or etc.
+        self.excluded_products.add(product)
+        # Invalidating cached property value with list of IDs of already excluded products.
+        self.invalidate_cached_ids()
 
     def contains_product(self, product):  # noqa (too complex (12))
         """
@@ -930,6 +936,11 @@ def _category_ids(self):
 
         return self.__category_ids
 
+    def invalidate_cached_ids(self):
+        self.__category_ids = None
+        self.__included_product_ids = None
+        self.__excluded_product_ids = None
+
     def num_products(self):
         # Delegate to a proxy class if one is provided
         if self.proxy:
@@ -963,10 +974,17 @@ def all_products(self):
     @property
     def is_editable(self):
         """
-        Test whether this product can be edited in the dashboard
+        Test whether this range can be edited in the dashboard.
         """
         return not self.proxy_class
 
+    @property
+    def is_reorderable(self):
+        """
+        Test whether products for the range can be re-ordered.
+        """
+        return len(self._class_ids()) == 0 and len(self._category_ids()) == 0
+
 
 class AbstractRangeProduct(models.Model):
     """


Test patch:
diff --git a/tests/integration/offer/range_tests.py b/tests/integration/offer/range_tests.py
--- a/tests/integration/offer/range_tests.py
+++ b/tests/integration/offer/range_tests.py
@@ -155,6 +155,59 @@ def test_descendant_categories_in_all_products(self):
 
         self.assertEqual(self.range.num_products(), 2)
 
+    def test_product_duplicated_in_all_products(self):
+        """Making sure product is not duplicated in range products if it has multiple categories assigned."""
+
+        included_category1 = catalogue_models.Category.add_root(name="cat1")
+        included_category2 = catalogue_models.Category.add_root(name="cat2")
+        product = create_product()
+        catalogue_models.ProductCategory.objects.create(
+            product=product, category=included_category1)
+        catalogue_models.ProductCategory.objects.create(
+            product=product, category=included_category2)
+
+        self.range.included_categories.add(included_category1)
+        self.range.included_categories.add(included_category2)
+        self.range.add_product(product)
+
+        all_product_ids = list(self.range.all_products().values_list('id', flat=True))
+        product_occurances_in_range = all_product_ids.count(product.id)
+        self.assertEqual(product_occurances_in_range, 1)
+
+    def test_product_remove_from_range(self):
+        included_category = catalogue_models.Category.add_root(name="root")
+        product = create_product()
+        catalogue_models.ProductCategory.objects.create(
+            product=product, category=included_category)
+
+        self.range.included_categories.add(included_category)
+        self.range.add_product(product)
+
+        all_products = self.range.all_products()
+        self.assertTrue(product in all_products)
+
+        self.range.remove_product(product)
+
+        all_products = self.range.all_products()
+        self.assertFalse(product in all_products)
+
+    def test_range_is_reordable(self):
+        product = create_product()
+        self.range.add_product(product)
+        self.assertTrue(self.range.is_reorderable)
+
+        included_category = catalogue_models.Category.add_root(name="root")
+        catalogue_models.ProductCategory.objects.create(
+            product=product, category=included_category)
+        self.range.included_categories.add(included_category)
+
+        self.range.invalidate_cached_ids()
+        self.assertFalse(self.range.is_reorderable)
+
+        self.range.included_categories.remove(included_category)
+        self.range.invalidate_cached_ids()
+        self.assertTrue(self.range.is_reorderable)
+
 
 class TestRangeModel(TestCase):