Repository: django-oscar/django-oscar

Issue:
Consolidate product validation
Currently there's validation in Products save() method which shouldn't live there. ProductForm's clean() method also seems to duplicate the same validation. It should be possible to move both into Product.clean()



Discussion:

Resolving patch:
diff --git a/oscar/apps/catalogue/abstract_models.py b/oscar/apps/catalogue/abstract_models.py
--- a/oscar/apps/catalogue/abstract_models.py
+++ b/oscar/apps/catalogue/abstract_models.py
@@ -302,22 +302,19 @@ def get_absolute_url(self):
             'product_slug': self.slug,
             'pk': self.id})
 
-    def save(self, *args, **kwargs):
+    def clean(self):
         if self.is_top_level and not self.title:
             raise ValidationError(_("Canonical products must have a title"))
-        if not self.slug:
-            self.slug = slugify(self.get_title())
-
-        # Allow attribute validation to be skipped.  This is required when
-        # saving a parent product which belongs to a product class with
-        # required attributes.
-        if not self.is_group and kwargs.pop('validate_attributes', True):
+        if self.is_top_level and not self.product_class:
+            raise ValidationError(
+                _("Canonical products must have a product class"))
+        if not self.is_group:
             self.attr.validate_attributes()
 
-        # Save product
+    def save(self, *args, **kwargs):
+        if not self.slug:
+            self.slug = slugify(self.get_title())
         super(AbstractProduct, self).save(*args, **kwargs)
-
-        # Finally, save attributes
         self.attr.save()
 
     # Properties
diff --git a/oscar/apps/dashboard/catalogue/forms.py b/oscar/apps/dashboard/catalogue/forms.py
--- a/oscar/apps/dashboard/catalogue/forms.py
+++ b/oscar/apps/dashboard/catalogue/forms.py
@@ -282,9 +282,9 @@ class Meta:
         }
 
     def __init__(self, product_class, data=None, *args, **kwargs):
-        self.product_class = product_class
-        self.set_initial_attribute_values(kwargs)
+        self.set_initial_attribute_values(product_class, kwargs)
         super(ProductForm, self).__init__(data, *args, **kwargs)
+        self.instance.product_class = product_class
 
         # Set the initial value of the is_group field.  This isn't watertight:
         # if the product is intended to be a parent product but doesn't have
@@ -312,12 +312,16 @@ def __init__(self, product_class, data=None, *args, **kwargs):
             self.fields['title'].widget = forms.TextInput(
                 attrs={'autocomplete': 'off'})
 
-    def set_initial_attribute_values(self, kwargs):
+    def set_initial_attribute_values(self, product_class, kwargs):
+        """
+        Update the kwargs['initial'] value to have the initial values based on
+        the product instance's attributes
+        """
         if kwargs.get('instance', None) is None:
             return
         if 'initial' not in kwargs:
             kwargs['initial'] = {}
-        for attribute in self.product_class.attributes.all():
+        for attribute in product_class.attributes.all():
             try:
                 value = kwargs['instance'].attribute_values.get(
                     attribute=attribute).value
@@ -327,7 +331,7 @@ def set_initial_attribute_values(self, kwargs):
                 kwargs['initial']['attr_%s' % attribute.code] = value
 
     def add_attribute_fields(self, is_parent=False):
-        for attribute in self.product_class.attributes.all():
+        for attribute in self.instance.product_class.attributes.all():
             self.fields['attr_%s' % attribute.code] \
                 = self.get_attribute_field(attribute)
             # Attributes are not required for a parent product
@@ -357,25 +361,13 @@ def save(self):
         Set product class and attributes before saving
         """
         product = super(ProductForm, self).save(commit=False)
-        product.product_class = self.product_class
-        for attribute in self.product_class.attributes.all():
+        for attribute in self.instance.product_class.attributes.all():
             value = self.cleaned_data['attr_%s' % attribute.code]
             setattr(product.attr, attribute.code, value)
         product.save()
         self.save_m2m()
         return product
 
-    def clean(self):
-        data = self.cleaned_data
-        if 'parent' not in data and not data['title']:
-            raise forms.ValidationError(_("This field is required"))
-        elif 'parent' in data and data['parent'] is None and not data['title']:
-            raise forms.ValidationError(_("Parent products must have a title"))
-        # Calling the clean() method of BaseForm here is required to apply
-        # checks for 'unique' field. This prevents e.g. the UPC field from
-        # raising a DatabaseError.
-        return super(ProductForm, self).clean()
-
 
 class StockAlertSearchForm(forms.Form):
     status = forms.CharField(label=_('Status'))


Test patch:
diff --git a/tests/functional/dashboard/product_tests.py b/tests/functional/dashboard/product_tests.py
--- a/tests/functional/dashboard/product_tests.py
+++ b/tests/functional/dashboard/product_tests.py
@@ -72,7 +72,7 @@ def submit(self, title=None, category=None, upc=None):
     def test_title_is_required(self):
         response = self.submit(title='')
 
-        self.assertContains(response, "Parent products must have a title")
+        self.assertContains(response, "Canonical products must have a title")
         self.assertEqual(Product.objects.count(), 0)
 
     def test_requires_a_category(self):
@@ -81,12 +81,11 @@ def test_requires_a_category(self):
             "A top-level product must have at least one category")
         self.assertEqual(Product.objects.count(), 0)
 
-    def test_doesnt_smoke(self):
+    def test_for_smoke(self):
         category = G(Category)
-        response = self.submit(category=category)
-
-        self.assertContains(response, "Parent products must have a title")
-        self.assertEqual(Product.objects.count(), 0)
+        response = self.submit(title='testing', category=category)
+        self.assertIsRedirect(response)
+        self.assertEqual(Product.objects.count(), 1)
 
     def test_doesnt_allow_duplicate_upc(self):
         G(Product, parent=None, upc="12345")
diff --git a/tests/integration/catalogue/product_tests.py b/tests/integration/catalogue/product_tests.py
--- a/tests/integration/catalogue/product_tests.py
+++ b/tests/integration/catalogue/product_tests.py
@@ -57,7 +57,12 @@ def test_allow_two_products_without_upc(self):
 class TopLevelProductTests(ProductTests):
 
     def test_top_level_products_must_have_titles(self):
-        self.assertRaises(ValidationError, Product.objects.create, product_class=self.product_class)
+        product = Product(product_class=self.product_class)
+        self.assertRaises(ValidationError, product.clean)
+
+    def test_top_level_products_must_have_product_class(self):
+        product = Product(title=u"Kopfhörer")
+        self.assertRaises(ValidationError, product.clean)
 
     def test_top_level_products_are_part_of_browsable_set(self):
         product = Product.objects.create(