Repository: django-oscar/django-oscar

Issue:
List display for product with options is broken in sandbox
![image](https://cloud.githubusercontent.com/assets/509427/12413594/55e4d064-be90-11e5-8e83-2034bf6168cb.png)

For a shop I'm doing, I have products with options. In [product.html](https://github.com/django-oscar/django-oscar/blob/master/src/oscar/templates/oscar/catalogue/partials/product.html), the `AddToBasketForm` only gets included when the product is not a parent product. But we should also exclude it if the product has options. Or maybe the `basket_form` template tag shouldn't return a form if the product has options and `simple` was specified. 

We also need to be careful to not incur too many new database queries; we want to avoid querying for options for each product. 



Discussion:
To avoid additional queries, we can add `has_options` field to the product model and update it with the `post_save` signal. Hence, in the template, similar to parent products, we won't show basket form:
```django
{% if not product.is_parent or not product.has_options %}
    {% include "catalogue/partials/add_to_basket_form_compact.html" %}
{% endif %}
```
If we deprecate product options in favor of attribute options in terms of #1776, we can slightly change this code and still indicate presence or absence of option attributes in this field.

WDYT @mvantellingen @solarissmoke ?
> We can add `has_options` field to the product model.

Sounds OK to me. The template logic would be `and not` rather than `or not`.

As per my comments in #1776 I think there are separate use cases for attributes vs options.

Resolving patch:
diff --git a/src/oscar/apps/catalogue/abstract_models.py b/src/oscar/apps/catalogue/abstract_models.py
--- a/src/oscar/apps/catalogue/abstract_models.py
+++ b/src/oscar/apps/catalogue/abstract_models.py
@@ -495,6 +495,16 @@ def options(self):
         pclass_options = self.get_product_class().options.all()
         return set(pclass_options) or set(self.product_options.all())
 
+    @cached_property
+    def has_options(self):
+        # Extracting annotated value with number of product class options
+        # from product list queryset.
+        num_product_class_options = getattr(self, 'num_product_class_options', None)
+        num_product_options = getattr(self, 'num_product_options', None)
+        if num_product_class_options is not None and num_product_options is not None:
+            return num_product_class_options > 0 or num_product_options > 0
+        return self.get_product_class().options.exists() or self.product_options.exists()
+
     @property
     def is_shipping_required(self):
         return self.get_product_class().requires_shipping
diff --git a/src/oscar/apps/catalogue/managers.py b/src/oscar/apps/catalogue/managers.py
--- a/src/oscar/apps/catalogue/managers.py
+++ b/src/oscar/apps/catalogue/managers.py
@@ -1,4 +1,5 @@
 from django.db import models
+from django.db.models import Count
 
 
 class ProductQuerySet(models.query.QuerySet):
@@ -9,12 +10,9 @@ def base_queryset(self):
         models to save on queries
         """
         return self.select_related('product_class')\
-            .prefetch_related('children',
-                              'product_options',
-                              'product_class__options',
-                              'stockrecords',
-                              'images',
-                              )
+            .prefetch_related('children', 'product_options', 'product_class__options', 'stockrecords', 'images') \
+            .annotate(num_product_class_options=Count('product_class__options'),
+                      num_product_options=Count('product_options'))
 
     def browsable(self):
         """


Test patch:
diff --git a/tests/integration/catalogue/test_options.py b/tests/integration/catalogue/test_options.py
new file mode 100644
--- /dev/null
+++ b/tests/integration/catalogue/test_options.py
@@ -0,0 +1,36 @@
+from django.test import TestCase
+
+from oscar.test import factories
+from oscar.core.loading import get_model
+
+
+Product = get_model('catalogue', 'Product')
+
+
+class ProductOptionTests(TestCase):
+    def setUp(self):
+        self.product_class = factories.ProductClassFactory()
+        self.product = factories.create_product(product_class=self.product_class)
+        self.option = factories.OptionFactory()
+
+    def test_product_has_options_per_product_class(self):
+        self.product_class.options.add(self.option)
+        self.assertTrue(self.product.has_options)
+
+    def test_product_has_options_per_product(self):
+        self.product.product_options.add(self.option)
+        self.assertTrue(self.product.has_options)
+
+    def test_queryset_per_product_class(self):
+        self.product_class.options.add(self.option)
+        qs = Product.browsable.base_queryset().filter(id=self.product.id)
+        product = qs.first()
+        self.assertTrue(product.has_options)
+        self.assertEquals(product.num_product_class_options, 1)
+
+    def test_queryset_per_product(self):
+        self.product.product_options.add(self.option)
+        qs = Product.browsable.base_queryset().filter(id=self.product.id)
+        product = qs.first()
+        self.assertTrue(product.has_options)
+        self.assertEquals(product.num_product_options, 1)