Repository: django-oscar/django-oscar

Issue:
When choosing a custom condition for your offer, it requires a range while it shouldn't
When you are choosing a custom condition on your offer, it will throw an error if you do not select a range as well.

<img width="752" alt="image" src="https://user-images.githubusercontent.com/7702200/156327295-b915ed75-48ea-4172-b1fb-98d8e67c674c.png">

However, the range is saved on the custom condition model so it should not be required.

### Steps to Reproduce
1. Create a custom condition, see [here](https://django-oscar.readthedocs.io/en/3.0.2/howto/how_to_create_a_custom_condition.html) how.
2. Create an offer and choose your custom condition without choosing a range, type and value.
3. Error should occur.

The logic in the the clean method of the ConditionForm seems wrong (line 127):
https://github.com/django-oscar/django-oscar/blob/23013a61a1f84517522c071bbeddb3fd3e841ee6/src/oscar/apps/dashboard/offers/forms.py#L126-L133

I think it should be like the BenefitForm does it:
https://github.com/django-oscar/django-oscar/blob/23013a61a1f84517522c071bbeddb3fd3e841ee6/src/oscar/apps/dashboard/offers/forms.py#L178-L191

Or is there any reason this is a requirement even when you choose a custom condition?

### Technical details

* Python version: Python 3.9.9
* Django version: Version: 3.2.9
* Oscar version: Version: 3.1



Discussion:
I think you are right that the validation is wrong, and should be like the one on the `BenefitForm`.
Alright, I'll work on a fix then.

Resolving patch:
diff --git a/src/oscar/apps/dashboard/offers/forms.py b/src/oscar/apps/dashboard/offers/forms.py
--- a/src/oscar/apps/dashboard/offers/forms.py
+++ b/src/oscar/apps/dashboard/offers/forms.py
@@ -123,11 +123,17 @@ def clean(self):
 
         # Check that either a condition has been entered or a custom condition
         # has been chosen
-        if data.get('custom_condition'):
-            if not data.get('range', None):
+        if not any(data.values()):
+            raise forms.ValidationError(
+                _("Please either choose a range, type and value OR "
+                  "select a custom condition"))
+
+        if data['custom_condition']:
+            if data.get('range') or data.get('type') or data.get('value'):
                 raise forms.ValidationError(
-                    _("A range is required"))
-        elif not all([data.get('range'), data.get('type'), data.get('value')]):
+                    _("No other options can be set if you are using a "
+                      "custom condition"))
+        elif not data.get('type'):
             raise forms.ValidationError(
                 _("Please either choose a range, type and value OR "
                   "select a custom condition"))
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
@@ -732,6 +732,50 @@ def proxy_map(self):
                 'offer.conditions', 'CoverageCondition'),
         }
 
+    def clean(self):
+        # The form will validate whether this is ok or not.
+        if not self.type:
+            return
+        method_name = 'clean_%s' % self.type.lower()
+        if hasattr(self, method_name):
+            getattr(self, method_name)()
+
+    def clean_count(self):
+        errors = []
+
+        if not self.range:
+            errors.append(_("Count conditions require a product range"))
+
+        if not self.value:
+            errors.append(_("Count conditions require a value"))
+
+        if errors:
+            raise exceptions.ValidationError(errors)
+
+    def clean_value(self):
+        errors = []
+
+        if not self.range:
+            errors.append(_("Value conditions require a product range"))
+
+        if not self.value:
+            errors.append(_("Value conditions require a value"))
+
+        if errors:
+            raise exceptions.ValidationError(errors)
+
+    def clean_coverage(self):
+        errors = []
+
+        if not self.range:
+            errors.append(_("Coverage conditions require a product range"))
+
+        if not self.value:
+            errors.append(_("Coverage conditions require a value"))
+
+        if errors:
+            raise exceptions.ValidationError(errors)
+
     def consume_items(self, offer, basket, affected_lines):
         pass
 


Test patch:
diff --git a/tests/integration/dashboard/test_offer_forms.py b/tests/integration/dashboard/test_offer_forms.py
--- a/tests/integration/dashboard/test_offer_forms.py
+++ b/tests/integration/dashboard/test_offer_forms.py
@@ -202,14 +202,34 @@ def test_clean_no_value(self):
 
     def test_clean_custom_condition(self):
         """
-        If a custom condition exists, and the data for range and custom condition is supplied,
-        the form should be valid.
+        If a custom condition is selected, the form should be valid.
         """
         custom_condition = create_condition(CustomConditionModel)
         form = forms.ConditionForm(data={
-            'range': self.range.id,
+            'range': '',
             'type': '',
             'value': '',
             'custom_condition': custom_condition.id
         })
         self.assertTrue(form.is_valid())
+        self.assertEqual({
+            'range': None,
+            'type': '',
+            'value': None,
+            'custom_condition': str(custom_condition.id)
+        }, form.clean())
+
+    def test_clean_custom_condition_with_range_type_and_value(self):
+        """
+        If a custom condition is selected, but a range, type and value is selected as well,
+        it should throw a ValidationError as you may only have a custom condition.
+        """
+        custom_condition = create_condition(CustomConditionModel)
+        form = forms.ConditionForm(data={
+            'range': self.range.id,
+            'type': 'Count',
+            'value': '5',
+            'custom_condition': custom_condition.id
+        })
+        self.assertFalse(form.is_valid())
+        self.assertRaises(ValidationError, form.clean)
diff --git a/tests/integration/dashboard/test_offer_views.py b/tests/integration/dashboard/test_offer_views.py
--- a/tests/integration/dashboard/test_offer_views.py
+++ b/tests/integration/dashboard/test_offer_views.py
@@ -356,11 +356,9 @@ def test_offer_condition_view_with_built_in_condition_type(self):
         }])
 
     def test_offer_condition_view_with_custom_condition_type(self):
-        range_ = RangeFactory()
         condition = create_condition(CustomConditionModel)
 
         request = RequestFactory().post('/', data={
-            'range': range_.pk,
             'custom_condition': condition.pk,
         })
         request.session['offer_wizard'] = {
@@ -379,7 +377,7 @@ def test_offer_condition_view_with_custom_condition_type(self):
         self.assertJSONEqual(request.session['offer_wizard']['benefit_obj'], self.benefit_obj_session_data)
         self.assertJSONEqual(request.session['offer_wizard']['condition'], {
             'data': {
-                'range': range_.pk,
+                'range': None,
                 'type': '',
                 'value': None,
                 'custom_condition': str(condition.pk),
@@ -669,11 +667,9 @@ def test_offer_condition_view_with_built_in_condition_type(self):
         }])
 
     def test_offer_condition_view_with_custom_condition_type(self):
-        range_ = RangeFactory()
         condition = create_condition(CustomConditionModel)
 
         request = RequestFactory().post('/', data={
-            'range': range_.pk,
             'custom_condition': condition.pk,
         })
         request.session['offer_wizard'] = {
@@ -694,7 +690,7 @@ def test_offer_condition_view_with_custom_condition_type(self):
         self.assertJSONEqual(request.session['offer_wizard'][self.benefit_obj_key], self.benefit_obj_session_data)
         self.assertJSONEqual(request.session['offer_wizard'][self.condition_form_kwargs_key], {
             'data': {
-                'range': range_.pk,
+                'range': None,
                 'type': '',
                 'value': None,
                 'custom_condition': str(condition.pk),