Repository: django-oscar/django-oscar

Issue:
Basket Save For Later not validating correctly
Found a bug? Please fill out the sections below.

### Issue Summary

Working on an django-oscar project and I discovered that the "save for later" feature in the basket doesn't work as intended as far as I can tell. I was a registered user interacting with my cart and had 2 items in the cart. Pressing saved for later reloaded the page but the cart remained unchanged. A message was added that read "cart total is now X.XX." So it was trying to do something but failing.

Upon investigating line 159 of `oscar/apps/basket/views.py` uses `getattr` to check for 'save_for_later' which always returns false even if it should be true. 
```
if (hasattr(form, 'cleaned_data')
                    and getattr(form.cleaned_data, 'save_for_later', False)):
```
I changed it into `form.cleaned_data.get('save_for_later', False)` which works but then it creates a bug further down the line where there should only be 1 saved basket. Not sure what the intended behavior down the line is but could create a PR if someone weighs in on what the behavior of the whole save for later flow should be.

### Steps to Reproduce

1. Add multiple items to cart.
2. Click 'save for later' in basket.
3. Cart will not change, but message will be added.

### Technical details

* Python version: 3.6.2
django-oscar==2.0.2



Discussion:
Thanks for reporting this. This is a regression [introduced in this commit](https://github.com/django-oscar/django-oscar/commit/cca43aa4d1b41833fabfa8aaf432df28f8b1e498#diff-64ea6af857bdaaee13f3fbc770586f95), and your proposed change to fix that looks correct. Would be great if you are able to make a PR to fix this!

> but then it creates a bug further down the line where there should only be 1 saved basket

It's not clear to me why this should happen - a saved basket is created with a different status to the active one, and this shouldn't cause any problems. Can you elaborate on what issue you run into?

@solarissmoke I made the change locally and then I hit an issue where oscar does a .get() to grab the saved basket. Maybe it was something I had done during testing that wouldn't normally be hit, but I had multiple saved baskets after I changed the line described in my original post and was unable to load any pages as a result. My solution at the time was to delete all saved baskets via the shell. I don't know exactly which line it was at this point, however, line 128-129 of the same file might possibly be it. 
```                
saved_basket = self.basket_model.saved.get(
                    owner=self.request.user)
```
There is only one other call like this in the same file and it pertains to ajax which my use case wasn't touching, I don't believe. Either way both of these could potentially fail. I wasn't sure what the intention was further down the line. Should there be multiple saved baskets or only ever one?

There should only be one saved basket for any user - the [logic that creates/updates a saved basket](https://github.com/django-oscar/django-oscar/blob/a22f4991b036890b2d90839cd2c4a03e22258255/src/oscar/apps/basket/views.py#L226-L229) should enforce this (unless there's another bug elsewhere). Looking at the code, it's obviously not safe from race conditions (two simultaneous requests to create a new saved basket) and that may be what you ran into.

Resolving patch:
diff --git a/src/oscar/apps/basket/views.py b/src/oscar/apps/basket/views.py
--- a/src/oscar/apps/basket/views.py
+++ b/src/oscar/apps/basket/views.py
@@ -156,7 +156,7 @@ def formset_valid(self, formset):
 
         for form in formset:
             if (hasattr(form, 'cleaned_data')
-                    and getattr(form.cleaned_data, 'save_for_later', False)):
+                    and form.cleaned_data.get('save_for_later', False)):
                 line = form.instance
                 if self.request.user.is_authenticated:
                     self.move_line_to_saved_basket(line)


Test patch:
diff --git a/tests/functional/test_basket.py b/tests/functional/test_basket.py
--- a/tests/functional/test_basket.py
+++ b/tests/functional/test_basket.py
@@ -145,6 +145,63 @@ def test_submitted_report_doesnt_error(self):
 class SavedBasketTests(WebTestCase):
     csrf_checks = False
 
+    def test_moving_to_saved_basket_creates_new(self):
+        self.user = factories.UserFactory()
+        product = factories.ProductFactory()
+        basket = factories.BasketFactory(owner=self.user)
+        basket.add_product(product)
+
+        response = self.get(reverse('basket:summary'))
+        formset = response.context['formset']
+        form = formset.forms[0]
+
+        data = {
+            formset.add_prefix('INITIAL_FORMS'): 1,
+            formset.add_prefix('TOTAL_FORMS'): 1,
+            formset.add_prefix('MIN_FORMS'): 0,
+            formset.add_prefix('MAX_NUM_FORMS'): 1,
+            form.add_prefix('id'): form.instance.pk,
+            form.add_prefix('quantity'): form.initial['quantity'],
+            form.add_prefix('save_for_later'): True,
+        }
+        response = self.post(reverse('basket:summary'), params=data)
+
+        self.assertRedirects(response, reverse('basket:summary'))
+        self.assertFalse(Basket.open.get(pk=basket.pk).lines.exists())
+        self.assertEqual(Basket.saved.get(owner=self.user).lines.get(
+            product=product).quantity, 1)
+
+    def test_moving_to_saved_basket_updates_existing(self):
+        self.user = factories.UserFactory()
+        product = factories.ProductFactory()
+
+        basket = factories.BasketFactory(owner=self.user)
+        basket.add_product(product)
+
+        saved_basket = factories.BasketFactory(owner=self.user,
+                                               status=Basket.SAVED)
+        saved_basket.add_product(product)
+
+        response = self.get(reverse('basket:summary'))
+        formset = response.context['formset']
+        form = formset.forms[0]
+
+        data = {
+            formset.add_prefix('INITIAL_FORMS'): 1,
+            formset.add_prefix('TOTAL_FORMS'): 1,
+            formset.add_prefix('MIN_FORMS'): 0,
+            formset.add_prefix('MAX_NUM_FORMS'): 1,
+            form.add_prefix('id'): form.instance.pk,
+            form.add_prefix('quantity'): form.initial['quantity'],
+            form.add_prefix('save_for_later'): True,
+        }
+        response = self.post(reverse('basket:summary'), params=data)
+
+        self.assertRedirects(response, reverse('basket:summary'))
+        self.assertFalse(Basket.open.get(pk=basket.pk).lines.exists())
+        self.assertEqual(Basket.saved.get(pk=saved_basket.pk).lines.get(
+            product=product).quantity, 2)
+
     def test_moving_from_saved_basket(self):
         self.user = User.objects.create_user(username='test', password='pass',
                                              email='test@example.com')