Browse Source

Add new type field for product.Option and enhanced form rendering for options based on their type.

master
PlayDay 6 years ago
parent
commit
dd5a151af2

+ 11
- 0
docs/source/releases/v3.0.rst View File

21
 What's new in Oscar 3.0?
21
 What's new in Oscar 3.0?
22
 ~~~~~~~~~~~~~~~~~~~~~~~~
22
 ~~~~~~~~~~~~~~~~~~~~~~~~
23
 
23
 
24
+- Support has been added to the ``catalogue.Option`` model to define the
25
+  type of option. This is used by the ``AddToBasketForm`` to determine the appropriate form field to display for that
26
+  option in the add-to-cart form. Currently supported types are: text, integer, float, boolean, and date.
27
+
28
+  The ``type`` field on the ``Option`` model (previously used to denote whether the option is required)
29
+  has been repurposed to store the type of the option, and a new ``required`` field
30
+  has been added to denote whether the option is required. Projects that have forked the catalogue app will
31
+  need to generate custom migrations for these model field changes.
32
+  Projects should pay close attention to the data migration provided in
33
+  ``catalogue/migrations/0019_option_required.py`` for this change.
34
+
24
 
35
 
25
 Backwards incompatible changes
36
 Backwards incompatible changes
26
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
37
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

+ 33
- 2
src/oscar/apps/basket/forms.py View File

9
 
9
 
10
 Line = get_model('basket', 'line')
10
 Line = get_model('basket', 'line')
11
 Basket = get_model('basket', 'basket')
11
 Basket = get_model('basket', 'basket')
12
+Option = get_model('catalogue', 'option')
12
 Product = get_model('catalogue', 'product')
13
 Product = get_model('catalogue', 'product')
13
 
14
 
14
 
15
 
16
+def _option_text_field(option):
17
+    return forms.CharField(label=option.name, required=option.required)
18
+
19
+
20
+def _option_integer_field(option):
21
+    return forms.IntegerField(label=option.name, required=option.required)
22
+
23
+
24
+def _option_boolean_field(option):
25
+    return forms.BooleanField(label=option.name, required=option.required)
26
+
27
+
28
+def _option_float_field(option):
29
+    return forms.FloatField(label=option.name, required=option.required)
30
+
31
+
32
+def _option_date_field(option):
33
+    return forms.DateField(label=option.name, required=option.required, widget=forms.widgets.DateInput)
34
+
35
+
15
 class BasketLineForm(forms.ModelForm):
36
 class BasketLineForm(forms.ModelForm):
37
+
16
     save_for_later = forms.BooleanField(
38
     save_for_later = forms.BooleanField(
17
         initial=False, required=False, label=_('Save for Later'))
39
         initial=False, required=False, label=_('Save for Later'))
18
 
40
 
120
 
142
 
121
 
143
 
122
 class AddToBasketForm(forms.Form):
144
 class AddToBasketForm(forms.Form):
145
+
146
+    OPTION_FIELD_FACTORIES = {
147
+        Option.TEXT: _option_text_field,
148
+        Option.INTEGER: _option_integer_field,
149
+        Option.BOOLEAN: _option_boolean_field,
150
+        Option.FLOAT: _option_float_field,
151
+        Option.DATE: _option_date_field,
152
+    }
153
+
123
     quantity = forms.IntegerField(initial=1, min_value=1, label=_('Quantity'))
154
     quantity = forms.IntegerField(initial=1, min_value=1, label=_('Quantity'))
124
 
155
 
125
     def __init__(self, basket, product, *args, **kwargs):
156
     def __init__(self, basket, product, *args, **kwargs):
182
         This is designed to be overridden so that specific widgets can be used
213
         This is designed to be overridden so that specific widgets can be used
183
         for certain types of options.
214
         for certain types of options.
184
         """
215
         """
185
-        self.fields[option.code] = forms.CharField(
186
-            label=option.name, required=option.is_required)
216
+        option_field = self.OPTION_FIELD_FACTORIES.get(option.type, Option.TEXT)(option)
217
+        self.fields[option.code] = option_field
187
 
218
 
188
     # Cleaning
219
     # Cleaning
189
 
220
 

+ 24
- 9
src/oscar/apps/catalogue/abstract_models.py View File

23
 from django.utils.translation import pgettext_lazy
23
 from django.utils.translation import pgettext_lazy
24
 from treebeard.mp_tree import MP_Node
24
 from treebeard.mp_tree import MP_Node
25
 
25
 
26
+from oscar.core.decorators import deprecated
26
 from oscar.core.loading import get_class, get_classes, get_model
27
 from oscar.core.loading import get_class, get_classes, get_model
27
 from oscar.core.utils import slugify
28
 from oscar.core.utils import slugify
28
 from oscar.core.validators import non_python_keyword
29
 from oscar.core.validators import non_python_keyword
1180
     This is not the same as an 'attribute' as options do not have a fixed value
1181
     This is not the same as an 'attribute' as options do not have a fixed value
1181
     for a particular item.  Instead, option need to be specified by a customer
1182
     for a particular item.  Instead, option need to be specified by a customer
1182
     when they add the item to their basket.
1183
     when they add the item to their basket.
1184
+
1185
+    The `type` of the option determines the form input that will be used to
1186
+    collect the information from the customer, and the `required` attribute
1187
+    determines whether a value must be supplied in order to add the item to the basket.
1183
     """
1188
     """
1184
-    name = models.CharField(_("Name"), max_length=128)
1185
-    code = AutoSlugField(_("Code"), max_length=128, unique=True,
1186
-                         populate_from='name')
1187
 
1189
 
1188
-    REQUIRED, OPTIONAL = ('Required', 'Optional')
1190
+    # Option types
1191
+    TEXT = "text"
1192
+    INTEGER = "integer"
1193
+    FLOAT = "float"
1194
+    BOOLEAN = "boolean"
1195
+    DATE = "date"
1196
+
1189
     TYPE_CHOICES = (
1197
     TYPE_CHOICES = (
1190
-        (REQUIRED, _("Required - a value for this option must be specified")),
1191
-        (OPTIONAL, _("Optional - a value for this option can be omitted")),
1198
+        (TEXT, _("Text")),
1199
+        (INTEGER, _("Integer")),
1200
+        (BOOLEAN, _("True / False")),
1201
+        (FLOAT, _("Float")),
1202
+        (DATE, _("Date")),
1192
     )
1203
     )
1193
-    type = models.CharField(_("Status"), max_length=128, default=REQUIRED,
1194
-                            choices=TYPE_CHOICES)
1204
+
1205
+    name = models.CharField(_("Name"), max_length=128)
1206
+    code = AutoSlugField(_("Code"), max_length=128, unique=True, populate_from='name')
1207
+    type = models.CharField(_("Type"), max_length=255, default=TEXT, choices=TYPE_CHOICES)
1208
+    required = models.BooleanField(_("Is this option required?"), default=False)
1195
 
1209
 
1196
     class Meta:
1210
     class Meta:
1197
         abstract = True
1211
         abstract = True
1203
         return self.name
1217
         return self.name
1204
 
1218
 
1205
     @property
1219
     @property
1220
+    @deprecated
1206
     def is_required(self):
1221
     def is_required(self):
1207
-        return self.type == self.REQUIRED
1222
+        return self.required
1208
 
1223
 
1209
 
1224
 
1210
 class MissingProductImage(object):
1225
 class MissingProductImage(object):

+ 37
- 0
src/oscar/apps/catalogue/migrations/0019_option_required.py View File

1
+# Generated by Django 2.1 on 2019-06-17 17:42
2
+
3
+from django.db import migrations, models
4
+
5
+
6
+def migrate_product_options(apps, schema_editor):
7
+    """
8
+    Migrate product Option.type field to required
9
+    Set Option.type='text'
10
+    """
11
+    Option = apps.get_model('catalogue', 'Option')
12
+    for option in Option.objects.all():
13
+        if option.type == "Required":
14
+            option.required = True
15
+        option.type = 'text'
16
+        option.save()
17
+
18
+
19
+class Migration(migrations.Migration):
20
+
21
+    dependencies = [
22
+        ('catalogue', '0018_auto_20191220_0920'),
23
+    ]
24
+
25
+    operations = [
26
+        migrations.AddField(
27
+            model_name='option',
28
+            name='required',
29
+            field=models.BooleanField(default=False, verbose_name='Is option required?'),
30
+        ),
31
+        migrations.RunPython(migrate_product_options, migrations.RunPython.noop),
32
+        migrations.AlterField(
33
+            model_name='option',
34
+            name='type',
35
+            field=models.CharField(choices=[('text', 'Text'), ('integer', 'Integer'), ('boolean', 'True / False'), ('float', 'Float'), ('date', 'Date')], default='text', max_length=255, verbose_name='Type'),
36
+        ),
37
+    ]

+ 1
- 1
src/oscar/apps/dashboard/catalogue/forms.py View File

393
 
393
 
394
     class Meta:
394
     class Meta:
395
         model = Option
395
         model = Option
396
-        fields = ['name', 'type']
396
+        fields = ['name', 'type', 'required']

+ 2
- 2
src/oscar/apps/dashboard/catalogue/tables.py View File

113
 
113
 
114
     class Meta(DashboardTable.Meta):
114
     class Meta(DashboardTable.Meta):
115
         model = Option
115
         model = Option
116
-        fields = ('name', 'type')
117
-        sequence = ('name', 'type', 'actions')
116
+        fields = ('name', 'type', 'required')
117
+        sequence = ('name', 'type', 'required', 'actions')
118
         per_page = settings.OSCAR_DASHBOARD_ITEMS_PER_PAGE
118
         per_page = settings.OSCAR_DASHBOARD_ITEMS_PER_PAGE

+ 4
- 4
src/oscar/templates/oscar/partials/form_field.html View File

22
             {% endblock %}
22
             {% endblock %}
23
 
23
 
24
             {% block controls %}
24
             {% block controls %}
25
-                <div class="{% if style|default:"stacked" != 'stacked' %}col-sm-7{% endif %}">
25
+                <div class="{% if style|default:"stacked" != 'stacked' %}col-sm-7{% endif %}{% if field.widget_type == 'CheckboxInput' %} checkbox{% endif %}">
26
                     {% block widget %}
26
                     {% block widget %}
27
                         {% if field.widget_type == 'CheckboxInput' %}
27
                         {% if field.widget_type == 'CheckboxInput' %}
28
-                            <label for="{{ field.auto_id }}" class="checkbox {% if field.field.required %}required{% endif %}">
29
-                                {{ field.label|safe }}
30
-                                {% render_field field class+="form-control" %}
28
+                            <label for="{{ field.auto_id }}" {% if field.field.required %}class="required"{% endif %}>
29
+                                {% render_field field %}
30
+                                {{ field.label|safe }}{% if field.field.required %} <span>*</span>{% endif %}
31
                             </label>
31
                             </label>
32
                         {% else %}
32
                         {% else %}
33
                             {% render_field field class+="form-control" %}
33
                             {% render_field field class+="form-control" %}

+ 2
- 1
src/oscar/test/factories/catalogue.py View File

70
 
70
 
71
     name = 'example option'
71
     name = 'example option'
72
     code = 'example'
72
     code = 'example'
73
-    type = Meta.model.OPTIONAL
73
+    type = Meta.model.TEXT
74
+    required = False
74
 
75
 
75
 
76
 
76
 class AttributeOptionFactory(factory.DjangoModelFactory):
77
 class AttributeOptionFactory(factory.DjangoModelFactory):

+ 37
- 0
tests/_site/apps/catalogue/migrations/0019_option_required.py View File

1
+# Generated by Django 2.1 on 2019-06-17 17:42
2
+
3
+from django.db import migrations, models
4
+
5
+
6
+def migrate_product_options(apps, schema_editor):
7
+    """
8
+    Migrate product Option.type field to required
9
+    Set Option.type='text'
10
+    """
11
+    Option = apps.get_model('catalogue', 'Option')
12
+    for option in Option.objects.all():
13
+        if option.type == "Required":
14
+            option.required = True
15
+        option.type = 'text'
16
+        option.save()
17
+
18
+
19
+class Migration(migrations.Migration):
20
+
21
+    dependencies = [
22
+        ('catalogue', '0018_auto_20191220_0920'),
23
+    ]
24
+
25
+    operations = [
26
+        migrations.AddField(
27
+            model_name='option',
28
+            name='required',
29
+            field=models.BooleanField(default=False, verbose_name='Is option required?'),
30
+        ),
31
+        migrations.RunPython(migrate_product_options, migrations.RunPython.noop),
32
+        migrations.AlterField(
33
+            model_name='option',
34
+            name='type',
35
+            field=models.CharField(choices=[('text', 'Text'), ('integer', 'Integer'), ('boolean', 'True / False'), ('float', 'Float'), ('date', 'Date')], default='text', max_length=255, verbose_name='Type'),
36
+        ),
37
+    ]

+ 80
- 1
tests/integration/basket/test_forms.py View File

10
 from oscar.test import factories
10
 from oscar.test import factories
11
 from oscar.test.basket import add_product
11
 from oscar.test.basket import add_product
12
 from oscar.test.factories import (
12
 from oscar.test.factories import (
13
-    BenefitFactory, ConditionalOfferFactory, ConditionFactory, RangeFactory)
13
+    BenefitFactory, ConditionalOfferFactory, ConditionFactory,
14
+    OptionFactory, RangeFactory)
14
 
15
 
15
 Line = get_model('basket', 'Line')
16
 Line = get_model('basket', 'Line')
17
+Option = get_model('catalogue', 'Option')
16
 
18
 
17
 
19
 
18
 class TestBasketLineForm(TestCase):
20
 class TestBasketLineForm(TestCase):
205
             'This product cannot be added to the basket because a price '
207
             'This product cannot be added to the basket because a price '
206
             'could not be determined for it.',
208
             'could not be determined for it.',
207
         )
209
         )
210
+
211
+
212
+class TestAddToBasketWithOptionForm(TestCase):
213
+
214
+    def setUp(self):
215
+        self.basket = factories.create_basket(empty=True)
216
+        self.product = factories.create_product(num_in_stock=1)
217
+
218
+    def _get_basket_form(self, basket, product, data=None):
219
+        return forms.AddToBasketForm(basket=basket, product=product, data=data)
220
+
221
+    def test_basket_option_field_exists(self):
222
+        option = OptionFactory()
223
+        self.product.product_class.options.add(option)
224
+        form = self._get_basket_form(basket=self.basket, product=self.product)
225
+        self.assertIn(option.code, form.fields)
226
+
227
+    def test_add_to_basket_with_not_required_option(self):
228
+        option = OptionFactory(required=False)
229
+        self.product.product_class.options.add(option)
230
+        data = {'quantity': 1}
231
+        form = self._get_basket_form(
232
+            basket=self.basket, product=self.product, data=data,
233
+        )
234
+        self.assertTrue(form.is_valid())
235
+        self.assertFalse(form.fields[option.code].required)
236
+
237
+    def test_add_to_basket_with_required_option(self):
238
+        option = OptionFactory(required=True)
239
+        self.product.product_class.options.add(option)
240
+        data = {'quantity': 1}
241
+        invalid_form = self._get_basket_form(
242
+            basket=self.basket, product=self.product, data=data,
243
+        )
244
+        self.assertFalse(invalid_form.is_valid())
245
+        self.assertTrue(invalid_form.fields[option.code].required)
246
+        data[option.code] = 'Test value'
247
+        valid_form = self._get_basket_form(
248
+            basket=self.basket, product=self.product, data=data,
249
+        )
250
+        self.assertTrue(valid_form.is_valid())
251
+
252
+    def _test_add_to_basket_with_specific_option_type(
253
+            self, option_type, invalid_value, valid_value
254
+    ):
255
+        option = OptionFactory(required=True, type=option_type)
256
+        self.product.product_class.options.add(option)
257
+        data = {'quantity': 1, option.code: invalid_value}
258
+        invalid_form = self._get_basket_form(
259
+            basket=self.basket, product=self.product, data=data,
260
+        )
261
+        self.assertFalse(invalid_form.is_valid())
262
+        data[option.code] = valid_value
263
+        valid_form = self._get_basket_form(
264
+            basket=self.basket, product=self.product, data=data,
265
+        )
266
+        self.assertTrue(valid_form.is_valid())
267
+
268
+    def test_add_to_basket_with_integer_option(self):
269
+        self._test_add_to_basket_with_specific_option_type(
270
+            Option.INTEGER, 1.55, 1,
271
+        )
272
+
273
+    def test_add_to_basket_with_float_option(self):
274
+        self._test_add_to_basket_with_specific_option_type(
275
+            Option.FLOAT, 'invalid_float', 1,
276
+        )
277
+
278
+    def test_add_to_basket_with_bool_option(self):
279
+        self._test_add_to_basket_with_specific_option_type(
280
+            Option.BOOLEAN, None, True,
281
+        )
282
+
283
+    def test_add_to_basket_with_date_option(self):
284
+        self._test_add_to_basket_with_specific_option_type(
285
+            Option.DATE, 'invalid_date', '2019-03-03',
286
+        )

Loading…
Cancel
Save