Browse Source

Merge branch 'feature/variants_dashboard'

master
Maik Hoepfel 11 years ago
parent
commit
1abd7b25a2

+ 16
- 0
docs/source/releases/v0.8.rst View File

82
 Some properties and method names have also been updated to the new naming. The
82
 Some properties and method names have also been updated to the new naming. The
83
 old ones will throw a deprecation warning.
83
 old ones will throw a deprecation warning.
84
 
84
 
85
+Better handling of child products in product dashboard
86
+------------------------------------------------------
87
+Together with the changes above, the dashboard experience for child products
88
+has been improved. The difference between a parent product and a stand-alone
89
+product is hidden from the user; a user can now add and remove child products
90
+on any suitable product. When the first child product is added, a stand-alone
91
+product becomes a parent product; and vice versa.
92
+In the front-end, the old name of "product variants" has been kept.
93
+
85
 Django 1.7 support
94
 Django 1.7 support
86
 ------------------
95
 ------------------
87
 
96
 
296
 * ``variants`` becomes ``children``
305
 * ``variants`` becomes ``children``
297
 * ``variant`` becomes ``child``
306
 * ``variant`` becomes ``child``
298
 
307
 
308
+Product editing
309
+---------------
310
+The dashboard improvements for child products meant slight changes to both
311
+``ProductCreateUpdateView`` and ``ProductForm``. Notably, ``ProductForm`` now
312
+gets a ``parent`` kwarg. Please review your customisations for compatibility
313
+with the updated code.
314
+
299
 .. _incompatible_shipping_changes_in_0.8:
315
 .. _incompatible_shipping_changes_in_0.8:
300
 
316
 
301
 Shipping
317
 Shipping

+ 54
- 19
oscar/apps/catalogue/abstract_models.py View File

292
     #: Determines if a product may be used in an offer. It is illegal to
292
     #: Determines if a product may be used in an offer. It is illegal to
293
     #: discount some types of product (e.g. ebooks) and this field helps
293
     #: discount some types of product (e.g. ebooks) and this field helps
294
     #: merchants from avoiding discounting such products
294
     #: merchants from avoiding discounting such products
295
+    #: Note that this flag is ignored for child products; they inherit from
296
+    #: the parent product.
295
     is_discountable = models.BooleanField(
297
     is_discountable = models.BooleanField(
296
         _("Is discountable?"), default=True, help_text=_(
298
         _("Is discountable?"), default=True, help_text=_(
297
             "This flag indicates if this product can be used in an offer "
299
             "This flag indicates if this product can be used in an offer "
327
         """
329
         """
328
         Validate a product. Those are the rules:
330
         Validate a product. Those are the rules:
329
 
331
 
330
-        +---------------+-------------+--------------+-----------+
331
-        |               | stand alone | parent       | child     |
332
-        +---------------+-------------+--------------+-----------+
333
-        | title         | required    | required     | optional  |
334
-        +---------------+-------------+--------------+-----------+
335
-        | product class | required    | must be None | required  |
336
-        +---------------+-------------+--------------+-----------+
337
-        | parent        | forbidden   | forbidden    | required  |
338
-        +---------------+-------------+--------------+-----------+
339
-        | stockrecords  | 0 or more   | forbidden    | required  |
340
-        +---------------+-------------+--------------+-----------+
341
-        | categories    | 1 or more   | 1 or more    | forbidden |
342
-        +---------------+-------------+--------------+-----------+
332
+        +---------------+-------------+--------------+--------------+
333
+        |               | stand alone | parent       | child        |
334
+        +---------------+-------------+--------------+--------------+
335
+        | title         | required    | required     | optional     |
336
+        +---------------+-------------+--------------+--------------+
337
+        | product class | required    | required     | must be None |
338
+        +---------------+-------------+--------------+--------------+
339
+        | parent        | forbidden   | forbidden    | required     |
340
+        +---------------+-------------+--------------+--------------+
341
+        | stockrecords  | 0 or more   | forbidden    | required     |
342
+        +---------------+-------------+--------------+--------------+
343
+        | categories    | 1 or more   | 1 or more    | forbidden    |
344
+        +---------------+-------------+--------------+--------------+
345
+        | attributes    | optional    | optional     | optional     |
346
+        +---------------+-------------+--------------+--------------+
347
+        | rec. products | optional    | optional     | unsupported  |
348
+        +---------------+-------------+--------------+--------------+
343
 
349
 
344
         Because the validation logic is quite complex, validation is delegated
350
         Because the validation logic is quite complex, validation is delegated
345
         to the sub method appropriate for the product's structure.
351
         to the sub method appropriate for the product's structure.
368
         if self.parent_id and not self.parent.is_parent:
374
         if self.parent_id and not self.parent.is_parent:
369
             raise ValidationError(
375
             raise ValidationError(
370
                 _("You can only assign child products to parent products."))
376
                 _("You can only assign child products to parent products."))
377
+        if self.product_class:
378
+            raise ValidationError(
379
+                _("A child product can't have a product class."))
371
 
380
 
372
     def _clean_parent(self):
381
     def _clean_parent(self):
373
         """
382
         """
398
     def is_child(self):
407
     def is_child(self):
399
         return self.structure == self.CHILD
408
         return self.structure == self.CHILD
400
 
409
 
410
+    def can_be_parent(self, give_reason=False):
411
+        """
412
+        Helps decide if a the product can be turned into a parent product.
413
+        """
414
+        reason = None
415
+        if self.is_child:
416
+            reason = _('The specified parent product is a child product.')
417
+        if self.has_stockrecords:
418
+            reason = _(
419
+                "One can't add a child product to a product with stock"
420
+                " records.")
421
+        is_valid = reason is None
422
+        if give_reason:
423
+            return is_valid, reason
424
+        else:
425
+            return is_valid
426
+
401
     @property
427
     @property
402
     def options(self):
428
     def options(self):
403
         pclass = self.get_product_class()
429
         pclass = self.get_product_class()
506
         """
532
         """
507
         return self._min_child_price('price_excl_tax')
533
         return self._min_child_price('price_excl_tax')
508
 
534
 
509
-    # Wrappers
535
+    # Wrappers for child products
510
 
536
 
511
     def get_title(self):
537
     def get_title(self):
512
         """
538
         """
520
 
546
 
521
     def get_product_class(self):
547
     def get_product_class(self):
522
         """
548
         """
523
-        Return a product's item class
549
+        Return a product's item class. Child products inherit their parent's.
524
         """
550
         """
525
-        if self.product_class_id or self.product_class:
526
-            return self.product_class
527
-        if self.parent and self.parent.product_class:
551
+        if self.is_child:
528
             return self.parent.product_class
552
             return self.parent.product_class
529
-        return None
553
+        else:
554
+            return self.product_class
530
     get_product_class.short_description = _("Product class")
555
     get_product_class.short_description = _("Product class")
531
 
556
 
557
+    def get_is_discountable(self):
558
+        """
559
+        At the moment, is_discountable can't be set individually for child
560
+        products; they inherit it from their parent.
561
+        """
562
+        if self.is_child:
563
+            return self.parent.is_discountable
564
+        else:
565
+            return self.is_discountable
566
+
532
     def get_categories(self):
567
     def get_categories(self):
533
         """
568
         """
534
         Return a product's categories or parent's if there is a parent product.
569
         Return a product's categories or parent's if there is a parent product.

+ 3
- 1
oscar/apps/dashboard/catalogue/app.py View File

64
             url(r'^products/create/(?P<product_class_slug>[\w-]+)/$',
64
             url(r'^products/create/(?P<product_class_slug>[\w-]+)/$',
65
                 self.product_createupdate_view.as_view(),
65
                 self.product_createupdate_view.as_view(),
66
                 name='catalogue-product-create'),
66
                 name='catalogue-product-create'),
67
+            url(r'^products/(?P<parent_pk>[-\d]+)/create-variant/$',
68
+                self.product_createupdate_view.as_view(),
69
+                name='catalogue-product-create-child'),
67
             url(r'^products/(?P<pk>\d+)/delete/$',
70
             url(r'^products/(?P<pk>\d+)/delete/$',
68
                 self.product_delete_view.as_view(),
71
                 self.product_delete_view.as_view(),
69
                 name='catalogue-product-delete'),
72
                 name='catalogue-product-delete'),
101
             url(r'^product-type/(?P<pk>\d+)/delete/$',
104
             url(r'^product-type/(?P<pk>\d+)/delete/$',
102
                 self.product_class_delete_view.as_view(),
105
                 self.product_class_delete_view.as_view(),
103
                 name='catalogue-class-delete'),
106
                 name='catalogue-class-delete'),
104
-
105
         ]
107
         ]
106
         return self.post_process_urls(urls)
108
         return self.post_process_urls(urls)
107
 
109
 

+ 62
- 49
oscar/apps/dashboard/catalogue/forms.py View File

1
 from django import forms
1
 from django import forms
2
-from django.core.exceptions import ValidationError, MultipleObjectsReturned
2
+from django.core import exceptions
3
 from django.forms.models import inlineformset_factory
3
 from django.forms.models import inlineformset_factory
4
 from django.utils.translation import ugettext_lazy as _
4
 from django.utils.translation import ugettext_lazy as _
5
 from treebeard.forms import MoveNodeForm, movenodeform_factory
5
 from treebeard.forms import MoveNodeForm, movenodeform_factory
12
 ProductClass = get_model('catalogue', 'ProductClass')
12
 ProductClass = get_model('catalogue', 'ProductClass')
13
 Category = get_model('catalogue', 'Category')
13
 Category = get_model('catalogue', 'Category')
14
 StockRecord = get_model('partner', 'StockRecord')
14
 StockRecord = get_model('partner', 'StockRecord')
15
-Partner = get_model('partner', 'Partner')
16
-ProductAttributeValue = get_model('catalogue', 'ProductAttributeValue')
17
 ProductCategory = get_model('catalogue', 'ProductCategory')
15
 ProductCategory = get_model('catalogue', 'ProductCategory')
18
 ProductImage = get_model('catalogue', 'ProductImage')
16
 ProductImage = get_model('catalogue', 'ProductImage')
19
 ProductRecommendation = get_model('catalogue', 'ProductRecommendation')
17
 ProductRecommendation = get_model('catalogue', 'ProductRecommendation')
147
         if self.require_user_stockrecord:
145
         if self.require_user_stockrecord:
148
             try:
146
             try:
149
                 user_partner = self.user.partners.get()
147
                 user_partner = self.user.partners.get()
150
-            except (Partner.DoesNotExist, MultipleObjectsReturned):
148
+            except (exceptions.ObjectDoesNotExist,
149
+                    exceptions.MultipleObjectsReturned):
151
                 pass
150
                 pass
152
             else:
151
             else:
153
                 partner_field = self.forms[0].fields.get('partner', None)
152
                 partner_field = self.forms[0].fields.get('partner', None)
172
                                         for form in self.forms])
171
                                         for form in self.forms])
173
             user_partners = set(self.user.partners.all())
172
             user_partners = set(self.user.partners.all())
174
             if not user_partners & stockrecord_partners:
173
             if not user_partners & stockrecord_partners:
175
-                raise ValidationError(_("At least one stock record must be set"
176
-                                        " to a partner that you're associated"
177
-                                        " with."))
174
+                raise exceptions.ValidationError(
175
+                    _("At least one stock record must be set to a partner that"
176
+                      "you're associated with."))
178
 
177
 
179
 
178
 
180
 def _attr_text_field(attribute):
179
 def _attr_text_field(attribute):
265
     class Meta:
264
     class Meta:
266
         model = Product
265
         model = Product
267
         fields = [
266
         fields = [
268
-            'title', 'upc', 'description',
269
-            'is_discountable',
270
-            'structure', 'parent']
267
+            'title', 'upc', 'description', 'is_discountable', 'structure']
271
         widgets = {
268
         widgets = {
272
-            'parent': ProductSelect,
269
+            'structure': forms.HiddenInput()
273
         }
270
         }
274
 
271
 
275
-    def __init__(self, product_class, data=None, *args, **kwargs):
276
-        self.set_initial_attribute_values(product_class, kwargs)
272
+    def __init__(self, product_class, data=None, parent=None, *args, **kwargs):
273
+        self.set_initial(product_class, parent, kwargs)
277
         super(ProductForm, self).__init__(data, *args, **kwargs)
274
         super(ProductForm, self).__init__(data, *args, **kwargs)
278
-        self.instance.product_class = product_class
279
-
280
-        # This is quite nasty.  We use the raw posted data to determine if the
281
-        # product is a parent product, as this changes the validation rules we
282
-        # want to apply.
283
-        is_parent = data and data.get('structure', '') == 'parent'
284
-        self.add_attribute_fields(is_parent)
285
-
286
-        parent = self.fields.get('parent', None)
287
-        if parent is not None:
288
-            parent.queryset = self.get_parent_products_queryset()
275
+        if parent:
276
+            self.instance.parent = parent
277
+            # We need to set the correct product structures explicitly to pass
278
+            # attribute validation and child product validation. Note that
279
+            # those changes are not persisted.
280
+            self.instance.structure = Product.CHILD
281
+            self.instance.parent.structure = Product.PARENT
282
+
283
+            self.delete_non_child_fields()
284
+        else:
285
+            # Only set product class for non-child products
286
+            self.instance.product_class = product_class
287
+        self.add_attribute_fields(product_class, self.instance.is_parent)
289
 
288
 
290
         if 'title' in self.fields:
289
         if 'title' in self.fields:
291
             self.fields['title'].widget = forms.TextInput(
290
             self.fields['title'].widget = forms.TextInput(
292
                 attrs={'autocomplete': 'off'})
291
                 attrs={'autocomplete': 'off'})
293
 
292
 
293
+    def set_initial(self, product_class, parent, kwargs):
294
+        """
295
+        Set initial data for the form. Sets the correct product structure
296
+        and fetches initial values for the dynamically constructed attribute
297
+        fields.
298
+        """
299
+        if 'initial' not in kwargs:
300
+            kwargs['initial'] = {}
301
+        self.set_initial_attribute_values(product_class, kwargs)
302
+        if parent:
303
+            kwargs['initial']['structure'] = Product.CHILD
304
+
294
     def set_initial_attribute_values(self, product_class, kwargs):
305
     def set_initial_attribute_values(self, product_class, kwargs):
295
         """
306
         """
296
         Update the kwargs['initial'] value to have the initial values based on
307
         Update the kwargs['initial'] value to have the initial values based on
297
         the product instance's attributes
308
         the product instance's attributes
298
         """
309
         """
299
-        if kwargs.get('instance', None) is None:
310
+        instance = kwargs.get('instance')
311
+        if instance is None:
300
             return
312
             return
301
-        if 'initial' not in kwargs:
302
-            kwargs['initial'] = {}
303
         for attribute in product_class.attributes.all():
313
         for attribute in product_class.attributes.all():
304
             try:
314
             try:
305
-                value = kwargs['instance'].attribute_values.get(
315
+                value = instance.attribute_values.get(
306
                     attribute=attribute).value
316
                     attribute=attribute).value
307
-            except ProductAttributeValue.DoesNotExist:
317
+            except exceptions.ObjectDoesNotExist:
308
                 pass
318
                 pass
309
             else:
319
             else:
310
                 kwargs['initial']['attr_%s' % attribute.code] = value
320
                 kwargs['initial']['attr_%s' % attribute.code] = value
311
 
321
 
312
-    def add_attribute_fields(self, is_parent=False):
313
-        for attribute in self.instance.product_class.attributes.all():
322
+    def add_attribute_fields(self, product_class, is_parent=False):
323
+        """
324
+        For each attribute specified by the product class, this method
325
+        dynamically adds form fields to the product form.
326
+        """
327
+        for attribute in product_class.attributes.all():
314
             field = self.get_attribute_field(attribute)
328
             field = self.get_attribute_field(attribute)
315
             if field:
329
             if field:
316
                 self.fields['attr_%s' % attribute.code] = field
330
                 self.fields['attr_%s' % attribute.code] = field
319
                     self.fields['attr_%s' % attribute.code].required = False
333
                     self.fields['attr_%s' % attribute.code].required = False
320
 
334
 
321
     def get_attribute_field(self, attribute):
335
     def get_attribute_field(self, attribute):
336
+        """
337
+        Gets the correct form field for a given attribute type.
338
+        """
322
         return self.FIELD_FACTORIES[attribute.type](attribute)
339
         return self.FIELD_FACTORIES[attribute.type](attribute)
323
 
340
 
324
-    def get_parent_products_queryset(self):
341
+    def delete_non_child_fields(self):
325
         """
342
         """
326
-        :return: Parent products, minus this product
343
+        Deletes any fields not needed for child products. Override this if
344
+        you want to e.g. keep the description field.
327
         """
345
         """
328
-        # Not using Product.browsable because a deployment might override
329
-        # that manager to respect a status field or such like
330
-        queryset = Product._default_manager.filter(structure=Product.PARENT)
331
-        if self.instance.pk is not None:
332
-            # Prevent selecting itself as parent
333
-            queryset = queryset.exclude(pk=self.instance.pk)
334
-        return queryset
335
-
336
-    def save(self):
346
+        for field_name in ['description', 'is_discountable']:
347
+            if field_name in self.fields:
348
+                del self.fields[field_name]
349
+
350
+    def _post_clean(self):
337
         """
351
         """
338
-        Set product class and attributes before saving
352
+        Set attributes before ModelForm calls the product's clean method
353
+        (which it does in _post_clean), which in turn validates attributes.
339
         """
354
         """
340
-        product = super(ProductForm, self).save(commit=False)
341
-        for attribute in self.instance.product_class.attributes.all():
355
+        product_class = self.instance.get_product_class()
356
+        for attribute in product_class.attributes.all():
342
             value = self.cleaned_data['attr_%s' % attribute.code]
357
             value = self.cleaned_data['attr_%s' % attribute.code]
343
-            setattr(product.attr, attribute.code, value)
344
-        product.save()
345
-        self.save_m2m()
346
-        return product
358
+            setattr(self.instance.attr, attribute.code, value)
359
+        super(ProductForm, self)._post_clean()
347
 
360
 
348
 
361
 
349
 class StockAlertSearchForm(forms.Form):
362
 class StockAlertSearchForm(forms.Form):

+ 19
- 11
oscar/apps/dashboard/catalogue/tables.py View File

12
 
12
 
13
 class ProductTable(Table):
13
 class ProductTable(Table):
14
     title = TemplateColumn(
14
     title = TemplateColumn(
15
+        verbose_name=_('Title'),
15
         template_name='dashboard/catalogue/product_row_title.html',
16
         template_name='dashboard/catalogue/product_row_title.html',
16
-        order_by='title', accessor=A('get_title'))
17
+        order_by='title', accessor=A('title'))
17
     image = TemplateColumn(
18
     image = TemplateColumn(
19
+        verbose_name=_('Image'),
18
         template_name='dashboard/catalogue/product_row_image.html',
20
         template_name='dashboard/catalogue/product_row_image.html',
19
         orderable=False)
21
         orderable=False)
20
-    product_class = Column(verbose_name=_("Type"),
21
-                           accessor=A('get_product_class.name'),
22
-                           order_by=('product_class__name'))
23
-    parent = LinkColumn('dashboard:catalogue-product',
24
-                        verbose_name=_("Parent"), args=[A('parent.pk')],
25
-                        accessor=A('parent.title'))
26
-    children = Column(accessor=A('children.count'), orderable=False)
27
-    stock_records = Column(accessor=A('stockrecords.count'), orderable=False)
22
+    product_class = Column(
23
+        verbose_name=_('Product type'),
24
+        accessor=A('product_class'),
25
+        order_by='product_class__name')
26
+    variants = TemplateColumn(
27
+        verbose_name=_("Variants"),
28
+        template_name='dashboard/catalogue/product_row_variants.html',
29
+        orderable=False
30
+    )
31
+    stock_records = Column(
32
+        verbose_name=_('Stock records'),
33
+        accessor=A('stockrecords.count'),
34
+        orderable=False)
28
     actions = TemplateColumn(
35
     actions = TemplateColumn(
36
+        verbose_name=_('Actions'),
29
         template_name='dashboard/catalogue/product_row_actions.html',
37
         template_name='dashboard/catalogue/product_row_actions.html',
30
         orderable=False)
38
         orderable=False)
31
 
39
 
32
     class Meta(DashboardTable.Meta):
40
     class Meta(DashboardTable.Meta):
33
         model = Product
41
         model = Product
34
         fields = ('upc', 'date_created')
42
         fields = ('upc', 'date_created')
35
-        sequence = ('title', 'upc', 'image', 'product_class',
36
-                    'parent', 'children', 'stock_records', '...', 'date_created', 'actions')
43
+        sequence = ('title', 'upc', 'image', 'product_class', 'variants',
44
+                    'stock_records', '...', 'date_created', 'actions')
37
         order_by = '-date_created'
45
         order_by = '-date_created'
38
 
46
 
39
 
47
 

+ 161
- 27
oscar/apps/dashboard/catalogue/views.py View File

1
-from django.core.exceptions import ObjectDoesNotExist
2
 from django.views import generic
1
 from django.views import generic
3
 from django.db.models import Q
2
 from django.db.models import Q
4
-from django.http import HttpResponseRedirect, Http404
3
+from django.http import HttpResponseRedirect
5
 from django.contrib import messages
4
 from django.contrib import messages
6
 from django.core.urlresolvers import reverse
5
 from django.core.urlresolvers import reverse
7
 from django.utils.translation import ugettext_lazy as _
6
 from django.utils.translation import ugettext_lazy as _
7
+from django.shortcuts import get_object_or_404, redirect
8
 from django.template.loader import render_to_string
8
 from django.template.loader import render_to_string
9
 
9
 
10
-from oscar.core.loading import get_class, get_classes, get_model
10
+from oscar.core.loading import get_classes, get_model
11
 
11
 
12
 from django_tables2 import SingleTableMixin
12
 from django_tables2 import SingleTableMixin
13
 
13
 
106
         """
106
         """
107
         Build the queryset for this list
107
         Build the queryset for this list
108
         """
108
         """
109
-        queryset = Product.objects.base_queryset()
109
+        queryset = Product.browsable.base_queryset()
110
         queryset = self.filter_queryset(queryset)
110
         queryset = self.filter_queryset(queryset)
111
         queryset = self.apply_search(queryset)
111
         queryset = self.apply_search(queryset)
112
         queryset = self.apply_ordering(queryset)
112
         queryset = self.apply_ordering(queryset)
187
 
187
 
188
 class ProductCreateUpdateView(generic.UpdateView):
188
 class ProductCreateUpdateView(generic.UpdateView):
189
     """
189
     """
190
-    Dashboard view that bundles both creating and updating single products.
190
+    Dashboard view that is can both create and update products of all kinds.
191
+    It can be used in three different ways, each of them with a unique URL
192
+    pattern:
193
+    - When creating a new standalone product, this view is called with the
194
+      desired product class
195
+    - When editing an existing product, this view is called with the product's
196
+      primary key. If the product is a child product, the template considerably
197
+      reduces the available form fields.
198
+    - When creating a new child product, this view is called with the parent's
199
+      primary key.
200
+
191
     Supports the permission-based dashboard.
201
     Supports the permission-based dashboard.
192
     """
202
     """
193
 
203
 
219
         This parts allows generic.UpdateView to handle creating products as
229
         This parts allows generic.UpdateView to handle creating products as
220
         well. The only distinction between an UpdateView and a CreateView
230
         well. The only distinction between an UpdateView and a CreateView
221
         is that self.object is None. We emulate this behavior.
231
         is that self.object is None. We emulate this behavior.
222
-        Additionally, self.product_class is set.
232
+
233
+        This method is also responsible for setting self.product_class and
234
+        self.parent.
223
         """
235
         """
224
         self.creating = 'pk' not in self.kwargs
236
         self.creating = 'pk' not in self.kwargs
225
         if self.creating:
237
         if self.creating:
226
-            try:
227
-                product_class_slug = self.kwargs.get('product_class_slug',
228
-                                                     None)
229
-                self.product_class = ProductClass.objects.get(
230
-                    slug=product_class_slug)
231
-            except ObjectDoesNotExist:
232
-                raise Http404
238
+            # Specifying a parent product is only done when creating a child
239
+            # product.
240
+            parent_pk = self.kwargs.get('parent_pk')
241
+            if parent_pk is None:
242
+                self.parent = None
243
+                # A product class needs to be specified when creating a
244
+                # standalone product.
245
+                product_class_slug = self.kwargs.get('product_class_slug')
246
+                self.product_class = get_object_or_404(
247
+                    ProductClass, slug=product_class_slug)
233
             else:
248
             else:
234
-                return None  # success
249
+                self.parent = self.get_and_check_parent(parent_pk)
250
+                self.product_class = self.parent.product_class
251
+
252
+            return None  # success
235
         else:
253
         else:
236
             product = super(ProductCreateUpdateView, self).get_object(queryset)
254
             product = super(ProductCreateUpdateView, self).get_object(queryset)
237
             self.product_class = product.get_product_class()
255
             self.product_class = product.get_product_class()
256
+            self.parent = product.parent
238
             return product
257
             return product
239
 
258
 
259
+    def get_and_check_parent(self, parent_pk):
260
+        """
261
+        Fetches the specified "parent" product and ensures that it can be
262
+        indeed be turned into a parent product if needed.
263
+        """
264
+        parent = get_object_or_404(Product, pk=parent_pk)
265
+        is_valid, reason = parent.can_be_parent(give_reason=True)
266
+        if is_valid:
267
+            return parent
268
+        else:
269
+            messages.error(self.request, reason)
270
+            return redirect('dashboard:catalogue-product-list')
271
+
240
     def get_context_data(self, **kwargs):
272
     def get_context_data(self, **kwargs):
241
         ctx = super(ProductCreateUpdateView, self).get_context_data(**kwargs)
273
         ctx = super(ProductCreateUpdateView, self).get_context_data(**kwargs)
242
         ctx['product_class'] = self.product_class
274
         ctx['product_class'] = self.product_class
275
+        ctx['parent'] = self.parent
276
+        ctx['title'] = self.get_page_title()
243
 
277
 
244
         for ctx_name, formset_class in self.formsets.items():
278
         for ctx_name, formset_class in self.formsets.items():
245
             if ctx_name not in ctx:
279
             if ctx_name not in ctx:
246
                 ctx[ctx_name] = formset_class(self.product_class,
280
                 ctx[ctx_name] = formset_class(self.product_class,
247
                                               self.request.user,
281
                                               self.request.user,
248
                                               instance=self.object)
282
                                               instance=self.object)
283
+        return ctx
249
 
284
 
250
-        if self.object is None:
251
-            ctx['title'] = _('Create new %s product') % self.product_class.name
285
+    def get_page_title(self):
286
+        if self.creating:
287
+            if self.parent is None:
288
+                return _('Create new %(product_class)s product') % {
289
+                    'product_class': self.product_class.name}
290
+            else:
291
+                return _('Create new variant of %(parent_product)s') % {
292
+                    'parent_product': self.parent.title}
252
         else:
293
         else:
253
-            ctx['title'] = ctx['product'].get_title()
254
-        return ctx
294
+            if self.object.title:
295
+                return self.object.title
296
+            else:
297
+                return _('Editing variant of %(parent_product)s') % {
298
+                    'parent_product': self.parent.title}
255
 
299
 
256
     def get_form_kwargs(self):
300
     def get_form_kwargs(self):
257
         kwargs = super(ProductCreateUpdateView, self).get_form_kwargs()
301
         kwargs = super(ProductCreateUpdateView, self).get_form_kwargs()
258
         kwargs['product_class'] = self.product_class
302
         kwargs['product_class'] = self.product_class
303
+        kwargs['parent'] = self.parent
259
         return kwargs
304
         return kwargs
260
 
305
 
261
     def process_all_forms(self, form):
306
     def process_all_forms(self, form):
305
     def forms_valid(self, form, formsets):
350
     def forms_valid(self, form, formsets):
306
         """
351
         """
307
         Save all changes and display a success url.
352
         Save all changes and display a success url.
353
+        When creating the first child product, this method also sets the new
354
+        parent's structure accordingly.
308
         """
355
         """
309
-        if not self.creating:
356
+        if self.creating:
357
+            self.handle_adding_child(self.parent)
358
+        else:
310
             # a just created product was already saved in process_all_forms()
359
             # a just created product was already saved in process_all_forms()
311
             self.object = form.save()
360
             self.object = form.save()
312
 
361
 
316
 
365
 
317
         return HttpResponseRedirect(self.get_success_url())
366
         return HttpResponseRedirect(self.get_success_url())
318
 
367
 
368
+    def handle_adding_child(self, parent):
369
+        """
370
+        When creating the first child product, the parent product needs
371
+        to be implicitly converted from a standalone product to a
372
+        parent product.
373
+        """
374
+        # ProductForm eagerly sets the future parent's structure to PARENT to
375
+        # pass validation, but it's not persisted in the database. We ensure
376
+        # it's persisted by calling save()
377
+        if parent is not None:
378
+            parent.structure = Product.PARENT
379
+            parent.save()
380
+
319
     def forms_invalid(self, form, formsets):
381
     def forms_invalid(self, form, formsets):
320
         # delete the temporary product again
382
         # delete the temporary product again
321
         if self.creating and self.object and self.object.pk is not None:
383
         if self.creating and self.object and self.object.pk is not None:
335
         return "?".join(url_parts)
397
         return "?".join(url_parts)
336
 
398
 
337
     def get_success_url(self):
399
     def get_success_url(self):
400
+        """
401
+        Renders a success message and redirects depending on the button:
402
+        - Standard case is pressing "Save"; redirects to the product list
403
+        - When "Save and continue" is pressed, we stay on the same page
404
+        - When "Create (another) child product" is pressed, it redirects
405
+          to a new product creation page
406
+        """
338
         msg = render_to_string(
407
         msg = render_to_string(
339
             'dashboard/catalogue/messages/product_saved.html',
408
             'dashboard/catalogue/messages/product_saved.html',
340
             {
409
             {
343
                 'request': self.request
412
                 'request': self.request
344
             })
413
             })
345
         messages.success(self.request, msg, extra_tags="safe noicon")
414
         messages.success(self.request, msg, extra_tags="safe noicon")
346
-        url = reverse('dashboard:catalogue-product-list')
347
-        if self.request.POST.get('action') == 'continue':
348
-            url = reverse('dashboard:catalogue-product',
349
-                          kwargs={"pk": self.object.id})
415
+
416
+        action = self.request.POST.get('action')
417
+        if action == 'continue':
418
+            url = reverse(
419
+                'dashboard:catalogue-product', kwargs={"pk": self.object.id})
420
+        elif action == 'create-another-child' and self.parent:
421
+            url = reverse(
422
+                'dashboard:catalogue-product-create-child',
423
+                kwargs={'parent_pk': self.parent.pk})
424
+        elif action == 'create-child':
425
+            url = reverse(
426
+                'dashboard:catalogue-product-create-child',
427
+                kwargs={'parent_pk': self.object.pk})
428
+        else:
429
+            url = reverse('dashboard:catalogue-product-list')
350
         return self.get_url_with_querystring(url)
430
         return self.get_url_with_querystring(url)
351
 
431
 
352
 
432
 
353
 class ProductDeleteView(generic.DeleteView):
433
 class ProductDeleteView(generic.DeleteView):
354
     """
434
     """
355
-    Dashboard view to delete a product.
435
+    Dashboard view to delete a product. Has special logic for deleting the
436
+    last child product.
356
     Supports the permission-based dashboard.
437
     Supports the permission-based dashboard.
357
     """
438
     """
358
     template_name = 'dashboard/catalogue/product_delete.html'
439
     template_name = 'dashboard/catalogue/product_delete.html'
365
         """
446
         """
366
         return filter_products(Product.objects.all(), self.request.user)
447
         return filter_products(Product.objects.all(), self.request.user)
367
 
448
 
449
+    def get_context_data(self, **kwargs):
450
+        ctx = super(ProductDeleteView, self).get_context_data(**kwargs)
451
+        if self.object.is_child:
452
+            ctx['title'] = _("Delete product variant?")
453
+        else:
454
+            ctx['title'] = _("Delete product?")
455
+        return ctx
456
+
457
+    def delete(self, request, *args, **kwargs):
458
+        # We override the core delete method and don't call super in order to
459
+        # apply more sophisticated logic around handling child products.
460
+        # Calling super makes it difficult to test if the product being deleted
461
+        # is the last child.
462
+
463
+        self.object = self.get_object()
464
+
465
+        # Before performing the delete, record whether this product is the last
466
+        # child.
467
+        is_last_child = False
468
+        if self.object.is_child:
469
+            parent = self.object.parent
470
+            is_last_child = parent.children.count() == 1
471
+
472
+        self.object.delete()
473
+
474
+        # If the product being deleted is the last child, then pass control
475
+        # to a method than can adjust the parent itself.
476
+        if is_last_child:
477
+            self.handle_deleting_last_child(parent)
478
+
479
+        return HttpResponseRedirect(self.get_success_url())
480
+
481
+    def handle_deleting_last_child(self, parent):
482
+        # If the last child product is deleted, this view defaults to turning
483
+        # the parent product into a standalone product. While this is
484
+        # appropriate for many scenarios, it is intentionally easily
485
+        # overridable and not automatically done in e.g. a Product's delete()
486
+        # method as it is more a UX helper than hard business logic.
487
+        parent.structure = parent.STANDALONE
488
+        parent.save()
489
+
368
     def get_success_url(self):
490
     def get_success_url(self):
369
-        msg = _("Deleted product '%s'") % self.object.title
370
-        messages.success(self.request, msg)
371
-        return reverse('dashboard:catalogue-product-list')
491
+        """
492
+        When deleting child products, this view redirects to editing the
493
+        parent product. When deleting any other product, it redirects to the
494
+        product list view.
495
+        """
496
+        if self.object.is_child:
497
+            msg = _("Deleted product variant '%s'") % self.object.get_title()
498
+            messages.success(self.request, msg)
499
+            return reverse(
500
+                'dashboard:catalogue-product',
501
+                kwargs={'pk': self.object.parent_id})
502
+        else:
503
+            msg = _("Deleted product '%s'") % self.object.title
504
+            messages.success(self.request, msg)
505
+            return reverse('dashboard:catalogue-product-list')
372
 
506
 
373
 
507
 
374
 class StockAlertListView(generic.ListView):
508
 class StockAlertListView(generic.ListView):

+ 2
- 1
oscar/apps/offer/models.py View File

508
         if not line.stockrecord_id:
508
         if not line.stockrecord_id:
509
             return False
509
             return False
510
         product = line.product
510
         product = line.product
511
-        return self.range.contains_product(product) and product.is_discountable
511
+        return (self.range.contains_product(product)
512
+                and product.get_is_discountable())
512
 
513
 
513
     def get_applicable_lines(self, offer, basket, most_expensive_first=True):
514
     def get_applicable_lines(self, offer, basket, most_expensive_first=True):
514
         """
515
         """

+ 40
- 9
oscar/templates/oscar/dashboard/catalogue/messages/product_saved.html View File

2
 {% load django_tables2 %}
2
 {% load django_tables2 %}
3
 
3
 
4
 <p>
4
 <p>
5
-{% if creating %}
6
-    {% blocktrans with name=product.get_title %}
7
-        Created product '{{ name }}'.
8
-    {% endblocktrans %}
9
-{% else %}
10
-    {% blocktrans with name=product.get_title %}
11
-        Updated product '{{ name }}'.
12
-    {% endblocktrans %}
13
-{% endif %}
5
+{% with name=product.title parent_name=product.parent.title %}
6
+    {% if product.is_child %}
7
+
8
+        {% if creating %}
9
+            {% if product.title %}
10
+                {% blocktrans %}
11
+                    Created product variant '{{ name }}'.
12
+                {% endblocktrans %}
13
+            {% else %}
14
+                {% blocktrans %}
15
+                    Created variant of '{{ parent_name }}'.
16
+                {% endblocktrans %}
17
+            {% endif %}
18
+        {% else %}
19
+            {% if product.title %}
20
+                {% blocktrans %}
21
+                    Updated product variant '{{ name }}'.
22
+                {% endblocktrans %}
23
+            {% else %}
24
+                {% blocktrans %}
25
+                    Updated a variant of '{{ parent_name }}'.
26
+                {% endblocktrans %}
27
+            {% endif %}
28
+        {% endif %}
29
+
30
+    {% else %}
31
+
32
+        {% if creating %}
33
+            {% blocktrans %}
34
+                Created product '{{ name }}'.
35
+            {% endblocktrans %}
36
+        {% else %}
37
+            {% blocktrans %}
38
+                Updated product '{{ name }}'.
39
+            {% endblocktrans %}
40
+        {% endif %}
41
+
42
+    {% endif %}
43
+{% endwith %}
44
+
14
 </p>
45
 </p>
15
 
46
 
16
 <p>
47
 <p>

+ 19
- 17
oscar/templates/oscar/dashboard/catalogue/product_delete.html View File

3
 {% load i18n %}
3
 {% load i18n %}
4
 
4
 
5
 {% block title %}
5
 {% block title %}
6
-    {% blocktrans with title=product.get_title %}
7
-        Delete {{ title}}?
8
-    {% endblocktrans %} |
9
-    {{ block.super }}
6
+    {{ title }} | {{ block.super }}
10
 {% endblock %}
7
 {% endblock %}
11
 
8
 
12
 {% block body_class %}{{ block.super }} create-page{% endblock %}
9
 {% block body_class %}{{ block.super }} create-page{% endblock %}
22
             <span class="divider">/</span>
19
             <span class="divider">/</span>
23
         </li>
20
         </li>
24
         <li>
21
         <li>
25
-            <a href="{% url 'dashboard:catalogue-product' pk=product.pk %}">{{ product.title }}</a>
22
+            <a href="{% url 'dashboard:catalogue-product' pk=product.pk %}">{{ product.get_title }}</a>
26
             <span class="divider">/</span>
23
             <span class="divider">/</span>
27
         </li>
24
         </li>
28
-        <li class="active">{% trans "Delete?" %}</li>
25
+        <li class="active">{{ title }}</li>
29
     </ul>
26
     </ul>
30
 {% endblock %}
27
 {% endblock %}
31
 
28
 
32
-{% block header %}
33
-    <div class="page-header">
34
-        <h1>{% trans "Delete product?" %}</h1>
35
-    </div>
36
-{% endblock header%}
29
+{% block headertext %}{{ title }}{% endblock %}
37
 
30
 
38
 {% block dashboard_content %}
31
 {% block dashboard_content %}
39
     <div class="table-header">
32
     <div class="table-header">
40
-        <h2>{% trans "Delete product" %}</h2>
33
+        <h2>{{ title }}</h2>
41
     </div>
34
     </div>
42
     <form action="." method="post" class="well">
35
     <form action="." method="post" class="well">
43
         {% csrf_token %}
36
         {% csrf_token %}
44
 
37
 
45
-        {% blocktrans with title=product.title %}
46
-            <p>Delete product <strong>{{ title }}</strong> - are you sure?</p>
47
-        {% endblocktrans %}
38
+        <p>
39
+            {% if product.is_child %}
40
+                {% blocktrans with title=product.get_title %}
41
+                    Delete product variant <strong>{{ title }}</strong> - are you sure?
42
+                {% endblocktrans %}
43
+            {% else %}
44
+                {% blocktrans with title=product.get_title %}
45
+                    Delete product <strong>{{ title }}</strong> - are you sure?
46
+                {% endblocktrans %}
47
+            {% endif %}
48
+        </p>
48
 
49
 
49
         {% if product.is_parent %}
50
         {% if product.is_parent %}
50
-            <p> {% trans "This will also delete the following child products:" %}
51
+            <p>
52
+                {% trans "This will also delete the following child products:" %}
51
                 <ul>
53
                 <ul>
52
                     {% for child in product.children.all %}
54
                     {% for child in product.children.all %}
53
-                        <li><strong>{{ child.title }}</strong></li>
55
+                        <li><strong>{{ child.get_title }}</strong></li>
54
                     {% endfor %}
56
                     {% endfor %}
55
                 </ul>
57
                 </ul>
56
             </p>
58
             </p>

+ 5
- 0
oscar/templates/oscar/dashboard/catalogue/product_row_variants.html View File

1
+{% if record.is_standalone %}
2
+    -
3
+{% else %}
4
+    {{ record.children.count }}
5
+{% endif %}

+ 130
- 71
oscar/templates/oscar/dashboard/catalogue/product_update.html View File

21
             <a href="{% url 'dashboard:catalogue-product-list' %}">{% trans "Products" %}</a>
21
             <a href="{% url 'dashboard:catalogue-product-list' %}">{% trans "Products" %}</a>
22
             <span class="divider">/</span>
22
             <span class="divider">/</span>
23
         </li>
23
         </li>
24
+        {% if parent %}
25
+            <li>
26
+                <a href="{% url 'dashboard:catalogue-product' parent.id %}">
27
+                    {{ parent.title }}
28
+                </a>
29
+                <span class="divider">/</span>
30
+            </li>
31
+        {% endif %}
24
         <li class="active">{{ title }}</li>
32
         <li class="active">{{ title }}</li>
25
-
26
     </ul>
33
     </ul>
27
 {% endblock %}
34
 {% endblock %}
28
 
35
 
31
 {% block dashboard_content %}
38
 {% block dashboard_content %}
32
     <form action="{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}" method="post" class="form-stacked wysiwyg fixed-actions" enctype="multipart/form-data" data-behaviour="affix-nav-errors" autocomplete="off">
39
     <form action="{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}" method="post" class="form-stacked wysiwyg fixed-actions" enctype="multipart/form-data" data-behaviour="affix-nav-errors" autocomplete="off">
33
         {% csrf_token %}
40
         {% csrf_token %}
41
+
42
+        {% if parent %}
43
+            <div class="row-fluid">
44
+                <div class="span12">
45
+                    <div class="alert alert-info">
46
+                        {% url 'dashboard:catalogue-product' pk=parent.id as parent_url %}
47
+                        {% blocktrans with title=parent.title %}
48
+                            You are currently editing a product variant of
49
+                            <a href="{{ parent_url }}">{{ title }}</a>.
50
+                        {% endblocktrans %}
51
+                    </div>
52
+                </div>
53
+            </div>
54
+        {% endif %}
55
+
34
         <div class="row-fluid">
56
         <div class="row-fluid">
35
 
57
 
36
             {% block tab_nav %}
58
             {% block tab_nav %}
40
                             <h3>{% trans "Sections" %}</h3>
62
                             <h3>{% trans "Sections" %}</h3>
41
                         </div>
63
                         </div>
42
                         <ul class="nav nav-list bs-docs-sidenav" id="product_update_tabs">
64
                         <ul class="nav nav-list bs-docs-sidenav" id="product_update_tabs">
43
-                            <li class="active"><a href="#product_details" data-toggle="tab">{% trans 'Product details' %}</a></li>
44
-                            <li><a href="#product_category" data-toggle="tab">{% trans 'Category' %}</a></li>
45
-                            {% if product_class.has_attributes %}
46
-                                <li><a href="#product_attributes" data-toggle="tab">{% trans 'Attributes' %}</a></li>
47
-                            {% endif %}
48
-                            <li><a href="#product_images" data-toggle="tab">{% trans 'Images' %}</a></li>
49
-                            <li><a href="#product_stock" data-toggle="tab">{% trans 'Stock and pricing' %}</a></li>
50
-                            {% if not product.is_child %}
51
-                                <li><a href="#child_products" data-toggle="tab">{% trans 'Variants' %}</a></li>
52
-                            {% endif %}
53
-                            <li><a href="#product_recommended" data-toggle="tab">{% trans 'Upselling' %}</a></li>
65
+                            {% comment %}
66
+                                The navigation below is filtered heavily on the product structure.
67
+                                This intentionally is the only place where filtering is done, as
68
+                                deployments are likely to want to override certain aspects of what
69
+                                data is stored on products of a certain structure. This means that
70
+                                only one template block (instead of all affected) has to be altered.
71
+                            {% endcomment %}
72
+                            {% block tabs %}
73
+                                <li class="active"><a href="#product_details" data-toggle="tab">{% trans 'Product details' %}</a></li>
74
+                                {% if not parent %}
75
+                                    <li><a href="#product_category" data-toggle="tab">{% trans 'Categories' %}</a></li>
76
+                                {% endif %}
77
+                                {% if product_class.has_attributes %}
78
+                                    <li><a href="#product_attributes" data-toggle="tab">{% trans 'Attributes' %}</a></li>
79
+                                {% endif %}
80
+                                {% if not parent %}
81
+                                    <li><a href="#product_images" data-toggle="tab">{% trans 'Images' %}</a></li>
82
+                                {% endif %}
83
+                                {% if not product.is_parent %}
84
+                                    <li><a href="#product_stock" data-toggle="tab">{% trans 'Stock and pricing' %}</a></li>
85
+                                {% endif %}
86
+                                {% if not parent %}
87
+                                    <li><a href="#child_products" data-toggle="tab">{% trans 'Variants' %}</a></li>
88
+                                {% endif %}
89
+                                {% if not parent %}
90
+                                    <li><a href="#product_recommended" data-toggle="tab">{% trans 'Upselling' %}</a></li>
91
+                                {% endif %}
92
+                            {% endblock tabs %}
54
                         </ul>
93
                         </ul>
55
                     </div>
94
                     </div>
56
                 </div>
95
                 </div>
75
                                             {% include 'partials/form_field.html' with field=field %}
114
                                             {% include 'partials/form_field.html' with field=field %}
76
                                         {% endif %}
115
                                         {% endif %}
77
                                     {% endfor %}
116
                                     {% endfor %}
78
-
79
-                                    {% with parent=product.parent %}
80
-                                        {% if parent %}
81
-                                            <div class="control-group">
82
-                                                <label class="control-label">{% trans "Parent" %}</label>
83
-                                                <div class="controls">
84
-                                                    <a href="{% url 'dashboard:catalogue-product' pk=parent.id %}" title="{% blocktrans with title=parent.title %}Edit {{ title }}{% endblocktrans %}">{{ parent.title }}</a>
85
-                                                </div>
86
-                                            </div>
87
-                                        {% endif %}
88
-                                    {% endwith %}
89
                                 {% endblock product_details_content %}
117
                                 {% endblock product_details_content %}
90
                             </div>
118
                             </div>
91
                         </div>
119
                         </div>
111
                     {% endblock product_categories %}
139
                     {% endblock product_categories %}
112
 
140
 
113
                     {% block product_attributes %}
141
                     {% block product_attributes %}
114
-                        {% if product_class.has_attributes %}
115
-                            <div class="tab-pane" id="product_attributes">
116
-                                {% block product_attributes_content %}
117
-                                    <table class="table table-striped table-bordered">
118
-                                        <caption>
119
-                                            {% trans "Attributes" %}
120
-                                            <span class="label label-success">
121
-                                                {% trans "Product Type:" %} {{ product_class }}
122
-                                            </span>
123
-                                        </caption>
124
-                                        {% for field in form %}
125
-                                            {% if 'attr' in field.id_for_label %}
126
-                                                <tr>
127
-                                                    <td>
128
-                                                        {% include "partials/form_field.html" %}
129
-                                                    </td>
130
-                                                </tr>
131
-                                            {% endif %}
132
-                                        {% endfor %}
133
-                                    </table>
134
-                                {% endblock product_attributes_content %}
135
-                            </div>
136
-                        {% endif %}
142
+                        <div class="tab-pane" id="product_attributes">
143
+                            {% block product_attributes_content %}
144
+                                <table class="table table-striped table-bordered">
145
+                                    <caption>
146
+                                        {% trans "Attributes" %}
147
+                                        <span class="label label-success">
148
+                                            {% trans "Product Type:" %} {{ product_class }}
149
+                                        </span>
150
+                                    </caption>
151
+                                    {% for field in form %}
152
+                                        {% if 'attr' in field.id_for_label %}
153
+                                            <tr>
154
+                                                <td>
155
+                                                    {% include "partials/form_field.html" %}
156
+                                                </td>
157
+                                            </tr>
158
+                                        {% endif %}
159
+                                    {% endfor %}
160
+                                </table>
161
+                            {% endblock product_attributes_content %}
162
+                        </div>
137
                     {% endblock product_attributes %}
163
                     {% endblock product_attributes %}
138
 
164
 
139
                     {% block product_images %}
165
                     {% block product_images %}
214
                     {% endblock stockrecords %}
240
                     {% endblock stockrecords %}
215
 
241
 
216
                     {% block child_products %}
242
                     {% block child_products %}
217
-                        {% if not product.is_child %}
218
-                            {% with children=product.children.all %}
219
-                                <div class="tab-pane" id="child_products">
220
-                                    {% block child_products_content %}
221
-                                        <table class='table table-striped table-bordered'>
222
-                                            <caption>{% trans "Variants" %}</caption>
243
+                        {% with children=product.children.all %}
244
+                            <div class="tab-pane" id="child_products">
245
+                                {% block child_products_content %}
246
+                                    <table class='table table-striped table-bordered'>
247
+                                        <caption>
248
+                                            {% trans "Variants" %}
249
+                                            <button class="btn btn-primary pull-right{% if not product.can_be_parent %} disabled{% endif %}" name="action" type="submit" value="create-child">
250
+                                                <i class="icon-plus"></i>
251
+                                                {% trans "Add variant" %}
252
+                                            </button>
253
+                                        </caption>
254
+                                        {% if children %}
255
+                                            <tr>
256
+                                                <th>{% trans "Title" %}</th>
257
+                                                <th>{% trans "Attributes" %}</th>
258
+                                                <th>{% trans "Stock records" %}</th>
259
+                                                <th>&nbsp;</th>
260
+                                            </tr>
223
                                             {% for child in children %}
261
                                             {% for child in children %}
224
                                                 <tr>
262
                                                 <tr>
225
                                                     <td>{{ child.get_title }}</td>
263
                                                     <td>{{ child.get_title }}</td>
226
                                                     <td>{{ child.attribute_summary }}</td>
264
                                                     <td>{{ child.attribute_summary }}</td>
227
-                                                    <td><a href="{% url 'dashboard:catalogue-product' pk=child.id %}" class="btn btn-primary">{% trans "Edit" %}</a></td>
228
-                                                </tr>
229
-                                            {% empty %}
230
-                                                <tr>
231
-                                                    <td colspan="3">
232
-                                                        {% url 'dashboard:catalogue-product-create' product_class.slug as create_url %}
233
-                                                        {% blocktrans %}
234
-                                                            This product does not have any variants. To add variants, please ensure that the product structure is set to
235
-                                                            parent product and finish editing the current product. Then <a href="{{ create_url }}">create</a> one or more
236
-                                                            child products and select the current product as parent.
237
-                                                        {% endblocktrans %}
265
+                                                    <td>{{ child.stockrecords.count }}</td>
266
+                                                    <td>
267
+                                                        <a href="{% url 'dashboard:catalogue-product' pk=child.id %}" class="btn btn-primary">
268
+                                                            {% trans "Edit" %}
269
+                                                        </a>
270
+                                                        <a href="{% url 'dashboard:catalogue-product-delete' pk=child.id %}" class="btn btn-danger">
271
+                                                            {% trans "Delete" %}
272
+                                                        </a>
238
                                                     </td>
273
                                                     </td>
239
                                                 </tr>
274
                                                 </tr>
240
                                             {% endfor %}
275
                                             {% endfor %}
241
-                                        </table>
242
-                                    {% endblock child_products_content %}
243
-                                </div>
244
-                            {% endwith %}
245
-                        {% endif %}
276
+                                        {% else %}
277
+                                            <tr>
278
+                                                <td colspan="3">
279
+                                                    {% if product.can_be_parent %}
280
+                                                        {% trans 'This product does not have any variants.' %}
281
+                                                    {% else %}
282
+                                                        {% trans "One can't add variants to this product at this point." %}
283
+                                                        {% if product.has_stockrecords %}
284
+                                                            {% trans 'This is likely because this product still has stock records.' %}
285
+                                                        {% endif %}
286
+                                                    {% endif %}
287
+                                                </td>
288
+                                            </tr>
289
+                                        {% endif %}
290
+                                    </table>
291
+                                {% endblock child_products_content %}
292
+                            </div>
293
+                        {% endwith %}
246
                     {% endblock child_products %}
294
                     {% endblock child_products %}
247
 
295
 
248
                     {% block recommended_products %}
296
                     {% block recommended_products %}
271
             <div class="fixed-actions-group">
319
             <div class="fixed-actions-group">
272
                 <div class="form-actions">
320
                 <div class="form-actions">
273
                     <div class="pull-right">
321
                     <div class="pull-right">
274
-                        <a href="{% url 'dashboard:catalogue-product-list' %}">{% trans "Cancel" %}</a>
322
+                        <a href="{% url 'dashboard:catalogue-product-list' %}">
323
+                            {% trans "Cancel" %}
324
+                        </a>
275
                         {% trans "or" %}
325
                         {% trans "or" %}
276
-                        <button class="btn btn-secondary btn-large" name="action" type="submit" value="continue">{% trans "Save and continue editing" %}</button>
277
-                        <button class="btn btn-primary btn-large" name="action" type="submit" value="save">{% trans "Save" %}</button>
326
+                        {% if parent %}
327
+                            <button class="btn btn-secondary btn-large" name="action" type="submit" value="create-another-child">
328
+                                {% trans "Save and add another variant" %}
329
+                            </button>
330
+                        {% endif %}
331
+                        <button class="btn btn-secondary btn-large" name="action" type="submit" value="continue">
332
+                            {% trans "Save and continue editing" %}
333
+                        </button>
334
+                        <button class="btn btn-primary btn-large" name="action" type="submit" value="save">
335
+                            {% trans "Save" %}
336
+                        </button>
278
                     </div>
337
                     </div>
279
                     {% if product %}
338
                     {% if product %}
280
                         <a class="btn btn-success btn-large" href="{{ product.get_absolute_url }}">{% trans "View on site" %}</a>
339
                         <a class="btn btn-success btn-large" href="{{ product.get_absolute_url }}">{% trans "View on site" %}</a>

+ 1
- 1
oscar/templates/oscar/dashboard/ranges/range_product_list.html View File

108
                                 </td>
108
                                 </td>
109
                                 <td>{{ product.upc|default:"-" }}</td>
109
                                 <td>{{ product.upc|default:"-" }}</td>
110
                                 <td><a href="{% url 'dashboard:catalogue-product' pk=product.id %}">{{ product.get_title }}</a></td>
110
                                 <td><a href="{% url 'dashboard:catalogue-product' pk=product.id %}">{{ product.get_title }}</a></td>
111
-                                <td>{% if product.is_discountable %}{% trans "Yes" %}{% else %}{% trans "No" %}{% endif %}</td>
111
+                                <td>{% if product.get_is_discountable %}{% trans "Yes" %}{% else %}{% trans "No" %}{% endif %}</td>
112
                                 <td>
112
                                 <td>
113
                                     <a class="btn btn-danger" href="#" data-behaviours="remove">{% trans "Remove" %}</a>
113
                                     <a class="btn btn-danger" href="#" data-behaviours="remove">{% trans "Remove" %}</a>
114
                                     <a href="#" class="btn btn-info btn-handle"><i class="icon-move icon-large"></i> {% trans "Re-order" %}</a>
114
                                     <a href="#" class="btn btn-info btn-handle"><i class="icon-move icon-large"></i> {% trans "Re-order" %}</a>

+ 3
- 2
oscar/test/newfactories.py View File

20
 
20
 
21
 __all__ = ["UserFactory", "CountryFactory", "UserAddressFactory",
21
 __all__ = ["UserFactory", "CountryFactory", "UserAddressFactory",
22
            "BasketFactory", "VoucherFactory", "ProductFactory",
22
            "BasketFactory", "VoucherFactory", "ProductFactory",
23
-           "StockRecordFactory", "ProductAttributeFactory",
24
-           "ProductAttributeValueFactory", "AttributeOptionGroupFactory",
23
+           "ProductClassFactory", "StockRecordFactory",
24
+           "ProductAttributeFactory", "ProductAttributeValueFactory",
25
+           "AttributeOptionGroupFactory",
25
            "AttributeOptionFactory", "PartnerFactory",
26
            "AttributeOptionFactory", "PartnerFactory",
26
            "ProductCategoryFactory", "CategoryFactory", "RangeFactory",
27
            "ProductCategoryFactory", "CategoryFactory", "RangeFactory",
27
            "ProductClassFactory"]
28
            "ProductClassFactory"]

+ 8
- 8
sites/sandbox/fixtures/child_products.json View File

80
         "date_created": "2013-12-12T16:34:14.023Z", 
80
         "date_created": "2013-12-12T16:34:14.023Z", 
81
         "product_options": [], 
81
         "product_options": [], 
82
         "slug": "oscar-t-shirt", 
82
         "slug": "oscar-t-shirt", 
83
-        "product_class": 1,
83
+        "product_class": null,
84
         "structure": "child"
84
         "structure": "child"
85
     }
85
     }
86
 },
86
 },
98
         "date_created": "2013-12-12T16:34:32.170Z", 
98
         "date_created": "2013-12-12T16:34:32.170Z", 
99
         "product_options": [], 
99
         "product_options": [], 
100
         "slug": "oscar-t-shirt", 
100
         "slug": "oscar-t-shirt", 
101
-        "product_class": 1,
101
+        "product_class": null,
102
         "structure": "child"
102
         "structure": "child"
103
     }
103
     }
104
 },
104
 },
116
         "date_created": "2013-12-12T17:32:15.016Z", 
116
         "date_created": "2013-12-12T17:32:15.016Z", 
117
         "product_options": [], 
117
         "product_options": [], 
118
         "slug": "oscar-t-shirt", 
118
         "slug": "oscar-t-shirt", 
119
-        "product_class": 1,
119
+        "product_class": null,
120
         "structure": "child"
120
         "structure": "child"
121
     }
121
     }
122
 },
122
 },
152
         "date_created": "2013-12-13T11:37:08.138Z", 
152
         "date_created": "2013-12-13T11:37:08.138Z", 
153
         "product_options": [], 
153
         "product_options": [], 
154
         "slug": "commandlinefu-t-shirt", 
154
         "slug": "commandlinefu-t-shirt", 
155
-        "product_class": 1,
155
+        "product_class": null,
156
         "structure": "child"
156
         "structure": "child"
157
     }
157
     }
158
 },
158
 },
170
         "date_created": "2013-12-13T11:37:45.834Z", 
170
         "date_created": "2013-12-13T11:37:45.834Z", 
171
         "product_options": [], 
171
         "product_options": [], 
172
         "slug": "commandlinefu-t-shirt", 
172
         "slug": "commandlinefu-t-shirt", 
173
-        "product_class": 1,
173
+        "product_class": null,
174
         "structure": "child"
174
         "structure": "child"
175
     }
175
     }
176
 },
176
 },
206
         "date_created": "2013-12-13T11:38:49.769Z", 
206
         "date_created": "2013-12-13T11:38:49.769Z", 
207
         "product_options": [], 
207
         "product_options": [], 
208
         "slug": "tangent-t-shirt", 
208
         "slug": "tangent-t-shirt", 
209
-        "product_class": 1,
209
+        "product_class": null,
210
         "structure": "child"
210
         "structure": "child"
211
     }
211
     }
212
 },
212
 },
224
         "date_created": "2013-12-13T11:39:12.859Z", 
224
         "date_created": "2013-12-13T11:39:12.859Z", 
225
         "product_options": [], 
225
         "product_options": [], 
226
         "slug": "tangent-t-shirt", 
226
         "slug": "tangent-t-shirt", 
227
-        "product_class": 1,
227
+        "product_class": null,
228
         "structure": "child"
228
         "structure": "child"
229
     }
229
     }
230
 },
230
 },
242
         "date_created": "2013-12-13T11:39:53.073Z", 
242
         "date_created": "2013-12-13T11:39:53.073Z", 
243
         "product_options": [], 
243
         "product_options": [], 
244
         "slug": "tangent-t-shirt", 
244
         "slug": "tangent-t-shirt", 
245
-        "product_class": 1,
245
+        "product_class": null,
246
         "structure": "child"
246
         "structure": "child"
247
     }
247
     }
248
 },
248
 },

+ 1
- 1
tests/functional/dashboard/catalogue_tests.py View File

58
         form['stockrecords-0-partner_sku'] = '14'
58
         form['stockrecords-0-partner_sku'] = '14'
59
         form['stockrecords-0-num_in_stock'] = '555'
59
         form['stockrecords-0-num_in_stock'] = '555'
60
         form['stockrecords-0-price_excl_tax'] = '13.99'
60
         form['stockrecords-0-price_excl_tax'] = '13.99'
61
-        page = form.submit('action', index=0)
61
+        page = form.submit(name='action', value='continue')
62
 
62
 
63
         self.assertEqual(Product.objects.count(), 1)
63
         self.assertEqual(Product.objects.count(), 1)
64
         product = Product.objects.all()[0]
64
         product = Product.objects.all()[0]

+ 2
- 4
tests/functional/dashboard/product_tests.py View File

113
         super(TestCreateChildProduct, self).setUp()
113
         super(TestCreateChildProduct, self).setUp()
114
 
114
 
115
     def test_categories_are_not_required(self):
115
     def test_categories_are_not_required(self):
116
-        url = reverse('dashboard:catalogue-product-create',
117
-                      kwargs={'product_class_slug': self.pclass.slug})
116
+        url = reverse('dashboard:catalogue-product-create-child',
117
+                      kwargs={'parent_pk': self.parent.pk})
118
         page = self.get(url)
118
         page = self.get(url)
119
 
119
 
120
         product_form = page.form
120
         product_form = page.form
121
-        product_form['structure'] = 'child'
122
         product_form['title'] = expected_title = 'Nice T-Shirt'
121
         product_form['title'] = expected_title = 'Nice T-Shirt'
123
-        product_form['parent'] = str(self.parent.id)
124
         product_form.submit()
122
         product_form.submit()
125
 
123
 
126
         try:
124
         try:

+ 12
- 13
tests/integration/catalogue/product_tests.py View File

70
         self.assertEqual(set([product]), set(Product.browsable.all()))
70
         self.assertEqual(set([product]), set(Product.browsable.all()))
71
 
71
 
72
 
72
 
73
-class VariantProductTests(ProductTests):
73
+class ChildProductTests(ProductTests):
74
 
74
 
75
     def setUp(self):
75
     def setUp(self):
76
-        super(VariantProductTests, self).setUp()
76
+        super(ChildProductTests, self).setUp()
77
         self.parent = Product.objects.create(
77
         self.parent = Product.objects.create(
78
-            title="Parent product", product_class=self.product_class,
79
-            structure=Product.PARENT)
78
+            title="Parent product",
79
+            product_class=self.product_class,
80
+            structure=Product.PARENT,
81
+            is_discountable=False)
80
 
82
 
81
     def test_child_products_dont_need_titles(self):
83
     def test_child_products_dont_need_titles(self):
82
         Product.objects.create(
84
         Product.objects.create(
84
             structure=Product.CHILD)
86
             structure=Product.CHILD)
85
 
87
 
86
     def test_child_products_dont_need_a_product_class(self):
88
     def test_child_products_dont_need_a_product_class(self):
87
-        Product.objects.create(
88
-            parent=self.parent, structure=Product.CHILD)
89
+        Product.objects.create(parent=self.parent, structure=Product.CHILD)
89
 
90
 
90
-    def test_child_products_inherit_parent_titles(self):
91
+    def test_child_products_inherit_fields(self):
91
         p = Product.objects.create(
92
         p = Product.objects.create(
92
-            parent=self.parent, product_class=self.product_class,
93
-            structure=Product.CHILD)
93
+            parent=self.parent,
94
+            structure=Product.CHILD,
95
+            is_discountable=True)
94
         self.assertEqual("Parent product", p.get_title())
96
         self.assertEqual("Parent product", p.get_title())
95
-
96
-    def test_child_products_inherit_product_class(self):
97
-        p = Product.objects.create(
98
-            parent=self.parent, structure=Product.CHILD)
99
         self.assertEqual("Clothing", p.get_product_class().name)
97
         self.assertEqual("Clothing", p.get_product_class().name)
98
+        self.assertEqual(False, p.get_is_discountable())
100
 
99
 
101
     def test_child_products_are_not_part_of_browsable_set(self):
100
     def test_child_products_are_not_part_of_browsable_set(self):
102
         Product.objects.create(
101
         Product.objects.create(

+ 9
- 8
tests/unit/dashboard/catalogue_form_tests.py View File

1
 from django.test import TestCase
1
 from django.test import TestCase
2
-from django_dynamic_fixture import G
3
 
2
 
4
-from oscar.apps.catalogue import models
5
 from oscar.apps.dashboard.catalogue import forms
3
 from oscar.apps.dashboard.catalogue import forms
4
+from oscar.test import factories
6
 
5
 
7
 
6
 
8
 class TestCreateProductForm(TestCase):
7
 class TestCreateProductForm(TestCase):
9
 
8
 
10
     def setUp(self):
9
     def setUp(self):
11
-        self.pclass = G(models.ProductClass)
10
+        self.product_class = factories.ProductClassFactory()
12
 
11
 
13
-    def submit(self, data):
14
-        return forms.ProductForm(self.pclass, data=data)
12
+    def submit(self, data, parent=None):
13
+        return forms.ProductForm(self.product_class, parent=parent, data=data)
15
 
14
 
16
     def test_validates_that_parent_products_must_have_title(self):
15
     def test_validates_that_parent_products_must_have_title(self):
17
         form = self.submit({'structure': 'parent'})
16
         form = self.submit({'structure': 'parent'})
18
         self.assertFalse(form.is_valid())
17
         self.assertFalse(form.is_valid())
18
+        form = self.submit({'structure': 'parent', 'title': 'foo'})
19
+        self.assertTrue(form.is_valid())
19
 
20
 
20
     def test_validates_that_child_products_dont_need_a_title(self):
21
     def test_validates_that_child_products_dont_need_a_title(self):
21
-        parent = G(
22
-            models.Product, product_class=self.pclass, structure='parent')
23
-        form = self.submit({'structure': 'child', 'parent': parent.id})
22
+        parent = factories.ProductFactory(
23
+            product_class=self.product_class, structure='parent')
24
+        form = self.submit({'structure': 'child'}, parent=parent)
24
         self.assertTrue(form.is_valid())
25
         self.assertTrue(form.is_valid())

Loading…
Cancel
Save