浏览代码

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

master
PlayDay 6 年前
父节点
当前提交
dd5a151af2

+ 11
- 0
docs/source/releases/v3.0.rst 查看文件

@@ -21,6 +21,17 @@ Compatibility
21 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 36
 Backwards incompatible changes
26 37
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

+ 33
- 2
src/oscar/apps/basket/forms.py 查看文件

@@ -9,10 +9,32 @@ from oscar.forms import widgets
9 9
 
10 10
 Line = get_model('basket', 'line')
11 11
 Basket = get_model('basket', 'basket')
12
+Option = get_model('catalogue', 'option')
12 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 36
 class BasketLineForm(forms.ModelForm):
37
+
16 38
     save_for_later = forms.BooleanField(
17 39
         initial=False, required=False, label=_('Save for Later'))
18 40
 
@@ -120,6 +142,15 @@ class BasketVoucherForm(forms.Form):
120 142
 
121 143
 
122 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 154
     quantity = forms.IntegerField(initial=1, min_value=1, label=_('Quantity'))
124 155
 
125 156
     def __init__(self, basket, product, *args, **kwargs):
@@ -182,8 +213,8 @@ class AddToBasketForm(forms.Form):
182 213
         This is designed to be overridden so that specific widgets can be used
183 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 219
     # Cleaning
189 220
 

+ 24
- 9
src/oscar/apps/catalogue/abstract_models.py 查看文件

@@ -23,6 +23,7 @@ from django.utils.translation import gettext_lazy as _
23 23
 from django.utils.translation import pgettext_lazy
24 24
 from treebeard.mp_tree import MP_Node
25 25
 
26
+from oscar.core.decorators import deprecated
26 27
 from oscar.core.loading import get_class, get_classes, get_model
27 28
 from oscar.core.utils import slugify
28 29
 from oscar.core.validators import non_python_keyword
@@ -1180,18 +1181,31 @@ class AbstractOption(models.Model):
1180 1181
     This is not the same as an 'attribute' as options do not have a fixed value
1181 1182
     for a particular item.  Instead, option need to be specified by a customer
1182 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 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 1210
     class Meta:
1197 1211
         abstract = True
@@ -1203,8 +1217,9 @@ class AbstractOption(models.Model):
1203 1217
         return self.name
1204 1218
 
1205 1219
     @property
1220
+    @deprecated
1206 1221
     def is_required(self):
1207
-        return self.type == self.REQUIRED
1222
+        return self.required
1208 1223
 
1209 1224
 
1210 1225
 class MissingProductImage(object):

+ 37
- 0
src/oscar/apps/catalogue/migrations/0019_option_required.py 查看文件

@@ -0,0 +1,37 @@
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 查看文件

@@ -393,4 +393,4 @@ class OptionForm(forms.ModelForm):
393 393
 
394 394
     class Meta:
395 395
         model = Option
396
-        fields = ['name', 'type']
396
+        fields = ['name', 'type', 'required']

+ 2
- 2
src/oscar/apps/dashboard/catalogue/tables.py 查看文件

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

+ 4
- 4
src/oscar/templates/oscar/partials/form_field.html 查看文件

@@ -22,12 +22,12 @@
22 22
             {% endblock %}
23 23
 
24 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 26
                     {% block widget %}
27 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 31
                             </label>
32 32
                         {% else %}
33 33
                             {% render_field field class+="form-control" %}

+ 2
- 1
src/oscar/test/factories/catalogue.py 查看文件

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

+ 37
- 0
tests/_site/apps/catalogue/migrations/0019_option_required.py 查看文件

@@ -0,0 +1,37 @@
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 查看文件

@@ -10,9 +10,11 @@ from oscar.core.loading import get_model
10 10
 from oscar.test import factories
11 11
 from oscar.test.basket import add_product
12 12
 from oscar.test.factories import (
13
-    BenefitFactory, ConditionalOfferFactory, ConditionFactory, RangeFactory)
13
+    BenefitFactory, ConditionalOfferFactory, ConditionFactory,
14
+    OptionFactory, RangeFactory)
14 15
 
15 16
 Line = get_model('basket', 'Line')
17
+Option = get_model('catalogue', 'Option')
16 18
 
17 19
 
18 20
 class TestBasketLineForm(TestCase):
@@ -205,3 +207,80 @@ class TestAddToBasketForm(TestCase):
205 207
             'This product cannot be added to the basket because a price '
206 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
+        )

正在加载...
取消
保存