Переглянути джерело

Delete both valid and invalid basket lines marked for deletion

Currently, if a valid basket line is marked for deletion, any other
invalid basket lines on the formset prevent it from being deleted.
master
Joseph Wayodi 5 роки тому
джерело
коміт
d778f8f1dd

+ 4
- 0
docs/source/releases/v2.1.rst Переглянути файл

@@ -120,6 +120,10 @@ Bug fixes
120 120
   in a discount being applied for all items in the basket rather than the
121 121
   cheapest one.
122 122
 
123
+- Fixed a bug where a line could not be deleted from the basket, if the basket
124
+  had another line with a validation error - i.e. its product was out of stock
125
+  (see :issue:`2791` and :issue:`1654`).
126
+
123 127
 Removal of deprecated features
124 128
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
125 129
 

+ 57
- 2
src/oscar/apps/basket/views.py Переглянути файл

@@ -1,7 +1,7 @@
1 1
 from django import shortcuts
2 2
 from django.contrib import messages
3 3
 from django.core.exceptions import ObjectDoesNotExist
4
-from django.http import JsonResponse
4
+from django.http import JsonResponse, QueryDict
5 5
 from django.shortcuts import redirect
6 6
 from django.template.loader import render_to_string
7 7
 from django.urls import reverse
@@ -229,9 +229,17 @@ class BasketView(ModelFormSetView):
229 229
         saved_basket.merge_line(line)
230 230
 
231 231
     def formset_invalid(self, formset):
232
+        has_deletion = any(formset._should_delete_form(form) for form in formset.forms)
233
+        has_no_invalid_non_deletion = all(form.is_valid() or formset._should_delete_form(form)
234
+                                          for form in formset.forms)
235
+        if has_deletion:
236
+            self.remove_deleted_forms(formset)
237
+            if has_no_invalid_non_deletion:
238
+                return self.formset_valid(formset)
239
+
232 240
         flash_messages = ajax.FlashMessages()
233 241
         flash_messages.warning(_(
234
-            "Your basket couldn't be updated. "
242
+            "Your basket has got some issues. "
235 243
             "Please correct any validation errors below."))
236 244
 
237 245
         if self.request.is_ajax():
@@ -242,6 +250,53 @@ class BasketView(ModelFormSetView):
242 250
         flash_messages.apply_to_request(self.request)
243 251
         return super().formset_invalid(formset)
244 252
 
253
+    def remove_deleted_forms(self, formset):
254
+        """
255
+        Removes forms marked for deletion, from the formset, as well as deletes
256
+        their model instance objects; and modifies the formset's request data,
257
+        to match the state of the data in the database, for the remaining forms.
258
+
259
+        This is useful for redisplaying a formset containing other invalid
260
+        forms, after deleting one of the forms from it.
261
+        """
262
+        form_data = {}
263
+        form_index = 0
264
+        for form in formset.forms:
265
+            # Delete forms marked for deletion, and retain the request data
266
+            # for the other forms
267
+            if formset._should_delete_form(form):
268
+                if form.instance.id is not None:
269
+                    form.instance.delete()
270
+            else:
271
+                old_form_prefix = form.prefix
272
+                new_form_prefix = formset.add_prefix(form_index)
273
+                for field_name in form.fields:
274
+                    form.prefix = old_form_prefix
275
+                    old_prefixed_field_name = form.add_prefix(field_name)
276
+                    form.prefix = new_form_prefix
277
+                    new_prefixed_field_name = form.add_prefix(field_name)
278
+                    try:
279
+                        form_data[new_prefixed_field_name] = formset.data[old_prefixed_field_name]
280
+                    except KeyError:
281
+                        pass
282
+                form_index += 1
283
+        for field_name in formset.management_form.fields:
284
+            prefixed_field_name = formset.management_form.add_prefix(field_name)
285
+            if field_name in ['INITIAL_FORMS', 'TOTAL_FORMS']:
286
+                form_data[prefixed_field_name] = str(form_index)
287
+            else:
288
+                form_data[prefixed_field_name] = formset.data[prefixed_field_name]
289
+
290
+        query_dict = QueryDict(mutable=True)
291
+        query_dict.update(form_data)
292
+        formset.data = query_dict
293
+        # Clear cached values, so that they are recomputed using the modified
294
+        # request data
295
+        del formset.management_form
296
+        del formset.forms
297
+        # Clean the formset's modified request data
298
+        formset.full_clean()
299
+
245 300
 
246 301
 class BasketAddView(FormView):
247 302
     """

+ 146
- 1
tests/functional/test_basket.py Переглянути файл

@@ -318,6 +318,7 @@ class BasketFormSetTests(WebTestCase):
318 318
         management_form = formset.management_form
319 319
         data = {
320 320
             formset.add_prefix('INITIAL_FORMS'): management_form.initial['INITIAL_FORMS'],
321
+            formset.add_prefix('MIN_NUM_FORMS'): management_form.initial['MIN_NUM_FORMS'],
321 322
             formset.add_prefix('MAX_NUM_FORMS'): management_form.initial['MAX_NUM_FORMS'],
322 323
             formset.add_prefix('TOTAL_FORMS'): management_form.initial['TOTAL_FORMS'],
323 324
             'form-0-quantity': 1,
@@ -330,7 +331,151 @@ class BasketFormSetTests(WebTestCase):
330 331
         response = self.post(reverse('basket:summary'), params=data)
331 332
         self.assertEqual(response.status_code, 200)
332 333
         formset = response.context['formset']
333
-        self.assertEqual(len(formset.forms), 3)
334
+        self.assertEqual(len(formset.forms), 2)
334 335
         self.assertEqual(len(formset.forms_with_instances), 2)
335 336
         self.assertEqual(basket.lines.all()[0].quantity, 1)
336 337
         self.assertEqual(basket.lines.all()[1].quantity, 1)
338
+
339
+    def test_deleting_valid_line_with_other_valid_line(self):
340
+        product_1 = create_product()
341
+        product_2 = create_product()
342
+
343
+        basket = factories.create_basket(empty=True)
344
+        basket.owner = self.user
345
+        basket.save()
346
+        add_product(basket, product=product_1)
347
+        add_product(basket, product=product_2)
348
+
349
+        response = self.get(reverse('basket:summary'))
350
+        formset = response.context['formset']
351
+        self.assertEqual(len(formset.forms), 2)
352
+
353
+        data = {
354
+            formset.add_prefix('TOTAL_FORMS'): formset.management_form.initial['TOTAL_FORMS'],
355
+            formset.add_prefix('INITIAL_FORMS'): formset.management_form.initial['INITIAL_FORMS'],
356
+            formset.add_prefix('MIN_NUM_FORMS'): formset.management_form.initial['MIN_NUM_FORMS'],
357
+            formset.add_prefix('MAX_NUM_FORMS'): formset.management_form.initial['MAX_NUM_FORMS'],
358
+            formset.forms[0].add_prefix('id'): formset.forms[0].instance.pk,
359
+            formset.forms[0].add_prefix('quantity'): formset.forms[0].instance.quantity,
360
+            formset.forms[0].add_prefix('DELETE'): 'on',
361
+            formset.forms[1].add_prefix('id'): formset.forms[1].instance.pk,
362
+            formset.forms[1].add_prefix('quantity'): formset.forms[1].instance.quantity,
363
+        }
364
+        response = self.post(reverse('basket:summary'), params=data, xhr=True)
365
+
366
+        self.assertEqual(response.status_code, 200)
367
+        self.assertEqual(len(response.context['formset'].forms), 1)
368
+        self.assertFalse(response.context['formset'].is_bound)  # new formset is rendered
369
+        self.assertEqual(basket.lines.count(), 1)
370
+        self.assertEqual(basket.lines.all()[0].quantity, 1)
371
+
372
+    def test_deleting_valid_line_with_other_invalid_line(self):
373
+        product_1 = create_product()
374
+        product_2 = create_product()
375
+
376
+        basket = factories.create_basket(empty=True)
377
+        basket.owner = self.user
378
+        basket.save()
379
+        add_product(basket, product=product_1)
380
+        add_product(basket, product=product_2)
381
+
382
+        response = self.get(reverse('basket:summary'))
383
+        formset = response.context['formset']
384
+        self.assertEqual(len(formset.forms), 2)
385
+
386
+        # Render product for other line out of stock
387
+        product_2.stockrecords.update(num_in_stock=0)
388
+
389
+        data = {
390
+            formset.add_prefix('TOTAL_FORMS'): formset.management_form.initial['TOTAL_FORMS'],
391
+            formset.add_prefix('INITIAL_FORMS'): formset.management_form.initial['INITIAL_FORMS'],
392
+            formset.add_prefix('MIN_NUM_FORMS'): formset.management_form.initial['MIN_NUM_FORMS'],
393
+            formset.add_prefix('MAX_NUM_FORMS'): formset.management_form.initial['MAX_NUM_FORMS'],
394
+            formset.forms[0].add_prefix('id'): formset.forms[0].instance.pk,
395
+            formset.forms[0].add_prefix('quantity'): formset.forms[0].instance.quantity,
396
+            formset.forms[0].add_prefix('DELETE'): 'on',
397
+            formset.forms[1].add_prefix('id'): formset.forms[1].instance.pk,
398
+            formset.forms[1].add_prefix('quantity'): formset.forms[1].instance.quantity,
399
+        }
400
+        response = self.post(reverse('basket:summary'), params=data, xhr=True)
401
+
402
+        self.assertEqual(response.status_code, 200)
403
+        self.assertEqual(len(response.context['formset'].forms), 1)
404
+        self.assertTrue(response.context['formset'].is_bound)  # formset with errors is rendered
405
+        self.assertFalse(response.context['formset'].forms[0].is_valid())
406
+        self.assertEqual(basket.lines.count(), 1)
407
+        self.assertEqual(basket.lines.all()[0].quantity, 1)
408
+
409
+    def test_deleting_invalid_line_with_other_valid_line(self):
410
+        product_1 = create_product()
411
+        product_2 = create_product()
412
+
413
+        basket = factories.create_basket(empty=True)
414
+        basket.owner = self.user
415
+        basket.save()
416
+        add_product(basket, product=product_1)
417
+        add_product(basket, product=product_2)
418
+
419
+        response = self.get(reverse('basket:summary'))
420
+        formset = response.context['formset']
421
+        self.assertEqual(len(formset.forms), 2)
422
+
423
+        # Render product for to-be-deleted line out of stock
424
+        product_1.stockrecords.update(num_in_stock=0)
425
+
426
+        data = {
427
+            formset.add_prefix('TOTAL_FORMS'): formset.management_form.initial['TOTAL_FORMS'],
428
+            formset.add_prefix('INITIAL_FORMS'): formset.management_form.initial['INITIAL_FORMS'],
429
+            formset.add_prefix('MIN_NUM_FORMS'): formset.management_form.initial['MIN_NUM_FORMS'],
430
+            formset.add_prefix('MAX_NUM_FORMS'): formset.management_form.initial['MAX_NUM_FORMS'],
431
+            formset.forms[0].add_prefix('id'): formset.forms[0].instance.pk,
432
+            formset.forms[0].add_prefix('quantity'): formset.forms[0].instance.quantity,
433
+            formset.forms[0].add_prefix('DELETE'): 'on',
434
+            formset.forms[1].add_prefix('id'): formset.forms[1].instance.pk,
435
+            formset.forms[1].add_prefix('quantity'): formset.forms[1].instance.quantity,
436
+        }
437
+        response = self.post(reverse('basket:summary'), params=data, xhr=True)
438
+
439
+        self.assertEqual(response.status_code, 200)
440
+        self.assertEqual(len(response.context['formset'].forms), 1)
441
+        self.assertFalse(response.context['formset'].is_bound)  # new formset is rendered
442
+        self.assertEqual(basket.lines.count(), 1)
443
+        self.assertEqual(basket.lines.all()[0].quantity, 1)
444
+
445
+    def test_deleting_invalid_line_with_other_invalid_line(self):
446
+        product_1 = create_product()
447
+        product_2 = create_product()
448
+
449
+        basket = factories.create_basket(empty=True)
450
+        basket.owner = self.user
451
+        basket.save()
452
+        add_product(basket, product=product_1)
453
+        add_product(basket, product=product_2)
454
+
455
+        response = self.get(reverse('basket:summary'))
456
+        formset = response.context['formset']
457
+        self.assertEqual(len(formset.forms), 2)
458
+
459
+        # Render products for both lines out of stock
460
+        product_1.stockrecords.update(num_in_stock=0)
461
+        product_2.stockrecords.update(num_in_stock=0)
462
+
463
+        data = {
464
+            formset.add_prefix('TOTAL_FORMS'): formset.management_form.initial['TOTAL_FORMS'],
465
+            formset.add_prefix('INITIAL_FORMS'): formset.management_form.initial['INITIAL_FORMS'],
466
+            formset.add_prefix('MIN_NUM_FORMS'): formset.management_form.initial['MIN_NUM_FORMS'],
467
+            formset.add_prefix('MAX_NUM_FORMS'): formset.management_form.initial['MAX_NUM_FORMS'],
468
+            formset.forms[0].add_prefix('id'): formset.forms[0].instance.pk,
469
+            formset.forms[0].add_prefix('quantity'): formset.forms[0].instance.quantity,
470
+            formset.forms[0].add_prefix('DELETE'): 'on',
471
+            formset.forms[1].add_prefix('id'): formset.forms[1].instance.pk,
472
+            formset.forms[1].add_prefix('quantity'): formset.forms[1].instance.quantity,
473
+        }
474
+        response = self.post(reverse('basket:summary'), params=data, xhr=True)
475
+
476
+        self.assertEqual(response.status_code, 200)
477
+        self.assertEqual(len(response.context['formset'].forms), 1)
478
+        self.assertTrue(response.context['formset'].is_bound)  # formset with errors is rendered
479
+        self.assertFalse(response.context['formset'].forms[0].is_valid())
480
+        self.assertEqual(basket.lines.count(), 1)
481
+        self.assertEqual(basket.lines.all()[0].quantity, 1)

Завантаження…
Відмінити
Зберегти