Repository: django-oscar/django-oscar

Issue:
Add a multi_option value type. This will create a new table for the m…
Fixes https://github.com/django-oscar/django-oscar/issues/1739

I took inspiration from https://github.com/django-oscar/django-oscar/pull/879 but tried to implement it in such a way that it would be backwards compatible.

It's not a particularly elegant solution. There's now both a `ForeignKey` and `ManyToManyField` to `catalogue.AttributeOption`, but `ProductAttributeValue` isn't a particularly elegant model to begin with.

I initially thought I would add a `ManyToManyField` and then make the option field actually use it, but doing so would make backwards compatibility really difficult.
## To do

I am willing to do these, but I would appreciate some input before proceeding.
- Update the initial migration.
- Documentation. 
- Tests

Thanks,

John



Discussion:

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
@@ -714,6 +714,7 @@ class AbstractProductAttribute(models.Model):
     RICHTEXT = "richtext"
     DATE = "date"
     OPTION = "option"
+    MULTI_OPTION = "multi_option"
     ENTITY = "entity"
     FILE = "file"
     IMAGE = "image"
@@ -725,6 +726,7 @@ class AbstractProductAttribute(models.Model):
         (RICHTEXT, _("Rich Text")),
         (DATE, _("Date")),
         (OPTION, _("Option")),
+        (MULTI_OPTION, _("Multi Option")),
         (ENTITY, _("Entity")),
         (FILE, _("File")),
         (IMAGE, _("Image")),
@@ -739,7 +741,7 @@ class AbstractProductAttribute(models.Model):
         null=True,
         on_delete=models.CASCADE,
         verbose_name=_("Option Group"),
-        help_text=_('Select an option group if using type "Option"'))
+        help_text=_('Select an option group if using type "Option" or "Multi Option"'))
     required = models.BooleanField(_('Required'), default=False)
 
     class Meta:
@@ -753,6 +755,10 @@ class Meta:
     def is_option(self):
         return self.type == self.OPTION
 
+    @property
+    def is_multi_option(self):
+        return self.type == self.MULTI_OPTION
+
     @property
     def is_file(self):
         return self.type in [self.FILE, self.IMAGE]
@@ -786,6 +792,20 @@ def save_value(self, product, value):
                 # New uploaded file
                 value_obj.value = value
                 value_obj.save()
+        elif self.is_multi_option:
+            # ManyToMany fields are handled separately
+            if value is None:
+                value_obj.delete()
+                return
+            try:
+                count = value.count()
+            except (AttributeError, TypeError):
+                count = len(value)
+            if count == 0:
+                value_obj.delete()
+            else:
+                value_obj.value = value
+                value_obj.save()
         else:
             if value is None or value == '':
                 value_obj.delete()
@@ -829,14 +849,28 @@ def _validate_entity(self, value):
         if not isinstance(value, models.Model):
             raise ValidationError(_("Must be a model instance"))
 
-    def _validate_option(self, value):
+    def _validate_multi_option(self, value):
+        try:
+            values = iter(value)
+        except TypeError:
+            raise ValidationError(
+                _("Must be a list or AttributeOption queryset"))
+        # Validate each value as if it were an option
+        # Pass in valid_values so that the DB isn't hit multiple times per iteration
+        valid_values = self.option_group.options.values_list(
+            'option', flat=True)
+        for value in values:
+            self._validate_option(value, valid_values=valid_values)
+
+    def _validate_option(self, value, valid_values=None):
         if not isinstance(value, get_model('catalogue', 'AttributeOption')):
             raise ValidationError(
                 _("Must be an AttributeOption model object instance"))
         if not value.pk:
             raise ValidationError(_("AttributeOption has not been saved yet"))
-        valid_values = self.option_group.options.values_list(
-            'option', flat=True)
+        if valid_values is None:
+            valid_values = self.option_group.options.values_list(
+                'option', flat=True)
         if value.option not in valid_values:
             raise ValidationError(
                 _("%(enum)s is not a valid choice for %(attr)s") %
@@ -873,6 +907,10 @@ class AbstractProductAttributeValue(models.Model):
     value_float = models.FloatField(_('Float'), blank=True, null=True)
     value_richtext = models.TextField(_('Richtext'), blank=True, null=True)
     value_date = models.DateField(_('Date'), blank=True, null=True)
+    value_multi_option = models.ManyToManyField(
+        'catalogue.AttributeOption', blank=True,
+        related_name='multi_valued_attribute_values',
+        verbose_name=_("Value multi option"))
     value_option = models.ForeignKey(
         'catalogue.AttributeOption',
         blank=True,
@@ -898,7 +936,10 @@ class AbstractProductAttributeValue(models.Model):
         null=True, blank=True, editable=False)
 
     def _get_value(self):
-        return getattr(self, 'value_%s' % self.attribute.type)
+        value = getattr(self, 'value_%s' % self.attribute.type)
+        if hasattr(value, 'all'):
+            value = value.all()
+        return value
 
     def _set_value(self, new_value):
         if self.attribute.is_option and isinstance(new_value, six.string_types):
@@ -936,6 +977,10 @@ def value_as_text(self):
         property_name = '_%s_as_text' % self.attribute.type
         return getattr(self, property_name, self.value)
 
+    @property
+    def _multi_option_as_text(self):
+        return ', '.join(unicode(option) for option in self.value_multi_option.all())
+
     @property
     def _richtext_as_text(self):
         return strip_tags(self.value)
diff --git a/src/oscar/apps/catalogue/migrations/0010_auto_20170420_0439.py b/src/oscar/apps/catalogue/migrations/0010_auto_20170420_0439.py
new file mode 100644
--- /dev/null
+++ b/src/oscar/apps/catalogue/migrations/0010_auto_20170420_0439.py
@@ -0,0 +1,31 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11 on 2017-04-20 05:51
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('catalogue', '0009_slugfield_noop'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='productattributevalue',
+            name='value_multi_option',
+            field=models.ManyToManyField(blank=True, related_name='multi_valued_attribute_values', to='catalogue.AttributeOption', verbose_name='Value multi option'),
+        ),
+        migrations.AlterField(
+            model_name='productattribute',
+            name='option_group',
+            field=models.ForeignKey(blank=True, help_text='Select an option group if using type "Option" or "Multi Option"', null=True, on_delete=django.db.models.deletion.CASCADE, to='catalogue.AttributeOptionGroup', verbose_name='Option Group'),
+        ),
+        migrations.AlterField(
+            model_name='productattribute',
+            name='type',
+            field=models.CharField(choices=[('text', 'Text'), ('integer', 'Integer'), ('boolean', 'True / False'), ('float', 'Float'), ('richtext', 'Rich Text'), ('date', 'Date'), ('option', 'Option'), ('multi_option', 'Multi Option'), ('entity', 'Entity'), ('file', 'File'), ('image', 'Image')], default='text', max_length=20, verbose_name='Type'),
+        ),
+    ]


Test patch:
diff --git a/tests/functional/dashboard/test_product.py b/tests/functional/dashboard/test_product.py
--- a/tests/functional/dashboard/test_product.py
+++ b/tests/functional/dashboard/test_product.py
@@ -163,6 +163,10 @@ def setUp(self):
                 option_group = factories.AttributeOptionGroupFactory()
                 self.option = factories.AttributeOptionFactory(group=option_group)
                 values['option_group'] = option_group
+            elif attribute_type == ProductAttribute.MULTI_OPTION:
+                option_group = factories.AttributeOptionGroupFactory()
+                self.multi_option = factories.AttributeOptionFactory(group=option_group)
+                values['option_group'] = option_group
             ProductAttributeFactory(**values)
         self.product = factories.ProductFactory(product_class=self.pclass)
         self.url = reverse('dashboard:catalogue-product',
diff --git a/tests/integration/catalogue/test_attributes.py b/tests/integration/catalogue/test_attributes.py
--- a/tests/integration/catalogue/test_attributes.py
+++ b/tests/integration/catalogue/test_attributes.py
@@ -1,12 +1,62 @@
 from django.test import TestCase
 from django.core.exceptions import ValidationError
 
-from oscar.apps.catalogue import models
+from oscar.test import factories
 
 
-class TestProductAttributes(TestCase):
+class TestBooleanAttributes(TestCase):
+
+    def setUp(self):
+        self.attr = factories.ProductAttributeFactory(type="boolean")
 
     def test_validate_boolean_values(self):
-        attr = models.ProductAttribute(type="boolean")
+        self.assertIsNone(self.attr.validate_value(True))
+
+    def test_validate_invalid_boolean_values(self):
         with self.assertRaises(ValidationError):
-                attr.validate_value(1)
+            self.attr.validate_value(1)
+
+
+class TestMultiOptionAttributes(TestCase):
+
+    def setUp(self):
+        self.option_group = factories.AttributeOptionGroupFactory()
+        self.attr = factories.ProductAttributeFactory(
+            type='multi_option',
+            name='Sizes',
+            code='sizes',
+            option_group=self.option_group,
+        )
+
+        # Add some options to the group
+        self.options = factories.AttributeOptionFactory.create_batch(
+            3, group=self.option_group)
+
+    def test_validate_multi_option_values(self):
+        self.assertIsNone(self.attr.validate_value([
+            self.options[0], self.options[1]]))
+
+    def test_validate_invalid_multi_option_values(self):
+        with self.assertRaises(ValidationError):
+            # value must be an iterable
+            self.attr.validate_value('foobar')
+
+        with self.assertRaises(ValidationError):
+            # Items must all be AttributeOption objects
+            self.attr.validate_value([self.options[0], 'notanOption'])
+
+    def test_save_multi_option_value(self):
+        product = factories.ProductFactory()
+        # We'll save two out of the three available options
+        self.attr.save_value(product, [self.options[0], self.options[2]])
+        product.refresh_from_db()
+        self.assertEqual(list(product.attr.sizes),
+            [self.options[0], self.options[2]])
+
+    def test_delete_multi_option_value(self):
+        product = factories.ProductFactory()
+        self.attr.save_value(product, [self.options[0], self.options[1]])
+        # Now delete these values
+        self.attr.save_value(product, None)
+        product.refresh_from_db()
+        self.assertFalse(hasattr(product.attr, 'sizes'))