Repository: django-oscar/django-oscar

Issue:
RelatedObjectDoesNotExist at /en-gb/basket/ Line has no basket.
### Issue Summary
`RelatedObjectDoesNotExist at /en-gb/basket/ Line has no basket.`
Occurs when updating a basket across multiple tabs. 

### Steps to Reproduce

The issue comes down to having the basket open in two tabs. To reproduce:

1. Add items to the basket and open the basket page in two tabs.
2. Remove an item from the basket in one of the tabs.
3. Try to update that same item in the other tab.

### Technical details

* Python version: 3.7.1
* Django version: 2.1
* Oscar version: 2.0dev0



Discussion:

Resolving patch:
diff --git a/src/oscar/apps/basket/forms.py b/src/oscar/apps/basket/forms.py
--- a/src/oscar/apps/basket/forms.py
+++ b/src/oscar/apps/basket/forms.py
@@ -1,6 +1,7 @@
 from django import forms
 from django.conf import settings
 from django.db.models import Sum
+from django.forms.utils import ErrorDict
 from django.utils.translation import gettext_lazy as _
 
 from oscar.core.loading import get_model
@@ -19,15 +20,31 @@ def __init__(self, strategy, *args, **kwargs):
         super().__init__(*args, **kwargs)
         self.instance.strategy = strategy
 
-        max_allowed_quantity = None
-        num_available = getattr(self.instance.purchase_info.availability, 'num_available', None)
-        basket_max_allowed_quantity = self.instance.basket.max_allowed_quantity()[0]
-        if all([num_available, basket_max_allowed_quantity]):
-            max_allowed_quantity = min(num_available, basket_max_allowed_quantity)
-        else:
-            max_allowed_quantity = num_available or basket_max_allowed_quantity
-        if max_allowed_quantity:
-            self.fields['quantity'].widget.attrs['max'] = max_allowed_quantity
+        # Evaluate max allowed quantity check only if line still exists, in
+        # order to avoid check run against missing instance -
+        # https://github.com/django-oscar/django-oscar/issues/2873.
+        if self.instance.id:
+            max_allowed_quantity = None
+            num_available = getattr(self.instance.purchase_info.availability, 'num_available', None)
+            basket_max_allowed_quantity = self.instance.basket.max_allowed_quantity()[0]
+            if all([num_available, basket_max_allowed_quantity]):
+                max_allowed_quantity = min(num_available, basket_max_allowed_quantity)
+            else:
+                max_allowed_quantity = num_available or basket_max_allowed_quantity
+            if max_allowed_quantity:
+                self.fields['quantity'].widget.attrs['max'] = max_allowed_quantity
+
+    def full_clean(self):
+        if not self.instance.id:
+            self.cleaned_data = {}
+            self._errors = ErrorDict()
+            return
+        return super().full_clean()
+
+    def has_changed(self):
+        if not self.instance.id:
+            return False
+        return super().has_changed()
 
     def clean_quantity(self):
         qty = self.cleaned_data['quantity']
diff --git a/src/oscar/apps/basket/formsets.py b/src/oscar/apps/basket/formsets.py
--- a/src/oscar/apps/basket/formsets.py
+++ b/src/oscar/apps/basket/formsets.py
@@ -1,4 +1,5 @@
 from django.forms.models import BaseModelFormSet, modelformset_factory
+from django.utils.functional import cached_property
 
 from oscar.core.loading import get_classes, get_model
 
@@ -23,9 +24,24 @@ def _should_delete_form(self, form):
         """
         if super()._should_delete_form(form):
             return True
+
+        # If related form instance already removed, let's remove this form
+        # as well.
+        if not form.instance.id:
+            return True
         if self.can_delete and 'quantity' in form.cleaned_data:
             return form.cleaned_data['quantity'] == 0
 
+    @cached_property
+    def forms_with_instances(self):
+        return [f for f in self.forms if f.instance.id]
+
+    def __iter__(self):
+        """
+        Skip forms with removed lines when iterating through the formset.
+        """
+        return iter(self.forms_with_instances)
+
 
 BasketLineFormSet = modelformset_factory(
     Line, form=BasketLineForm, formset=BaseBasketLineFormSet, extra=0,
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
@@ -152,7 +152,7 @@ def formset_valid(self, formset):
 
         for form in formset:
             if (hasattr(form, 'cleaned_data')
-                    and form.cleaned_data['save_for_later']):
+                    and getattr(form.cleaned_data, '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
@@ -200,3 +200,81 @@ def test_moving_from_saved_basket_more_than_stocklevel_raises(self):
         # we can't add more than stock level into basket
         self.assertEqual(Basket.open.get(id=basket.id).lines.get(product=product).quantity, 1)
         self.assertRedirects(response, reverse('basket:summary'))
+
+
+class BasketFormSetTests(WebTestCase):
+    csrf_checks = False
+
+    def test_formset_with_removed_line(self):
+        products = [create_product() for i in range(3)]
+        basket = factories.create_basket(empty=True)
+        basket.owner = self.user
+        basket.save()
+
+        add_product(basket, product=products[0])
+        add_product(basket, product=products[1])
+        add_product(basket, product=products[2])
+        response = self.get(reverse('basket:summary'))
+        formset = response.context['formset']
+        self.assertEqual(len(formset.forms), 3)
+
+        basket.lines.filter(product=products[0]).delete()
+
+        management_form = formset.management_form
+        data = {
+            formset.add_prefix('INITIAL_FORMS'): management_form.initial['INITIAL_FORMS'],
+            formset.add_prefix('MAX_NUM_FORMS'): management_form.initial['MAX_NUM_FORMS'],
+            formset.add_prefix('TOTAL_FORMS'): management_form.initial['TOTAL_FORMS'],
+            'form-0-quantity': 1,
+            'form-0-id': formset.forms[0].instance.id,
+            'form-1-quantity': 2,
+            'form-1-id': formset.forms[1].instance.id,
+            'form-2-quantity': 2,
+            'form-2-id': formset.forms[2].instance.id,
+        }
+        response = self.post(reverse('basket:summary'), params=data)
+        self.assertEqual(response.status_code, 302)
+        formset = response.follow().context['formset']
+        self.assertEqual(len(formset.forms), 2)
+        self.assertEqual(len(formset.forms_with_instances), 2)
+        self.assertEqual(basket.lines.all()[0].quantity, 2)
+        self.assertEqual(basket.lines.all()[1].quantity, 2)
+
+    def test_invalid_formset_with_removed_line(self):
+        products = [create_product() for i in range(3)]
+        basket = factories.create_basket(empty=True)
+        basket.owner = self.user
+        basket.save()
+
+        add_product(basket, product=products[0])
+        add_product(basket, product=products[1])
+        add_product(basket, product=products[2])
+        response = self.get(reverse('basket:summary'))
+        formset = response.context['formset']
+        self.assertEqual(len(formset.forms), 3)
+
+        basket.lines.filter(product=products[0]).delete()
+
+        stockrecord = products[1].stockrecords.first()
+        stockrecord.num_in_stock = 0
+        stockrecord.save()
+
+        management_form = formset.management_form
+        data = {
+            formset.add_prefix('INITIAL_FORMS'): management_form.initial['INITIAL_FORMS'],
+            formset.add_prefix('MAX_NUM_FORMS'): management_form.initial['MAX_NUM_FORMS'],
+            formset.add_prefix('TOTAL_FORMS'): management_form.initial['TOTAL_FORMS'],
+            'form-0-quantity': 1,
+            'form-0-id': formset.forms[0].instance.id,
+            'form-1-quantity': 2,
+            'form-1-id': formset.forms[1].instance.id,
+            'form-2-quantity': 2,
+            'form-2-id': formset.forms[2].instance.id,
+        }
+        response = self.post(reverse('basket:summary'), params=data)
+        self.assertEqual(response.status_code, 200)
+        formset = response.context['formset']
+        self.assertEqual(len(formset.forms), 3)
+        self.assertEqual(len(formset.forms_with_instances), 2)
+        self.assertEqual(basket.lines.all()[0].quantity, 1)
+        self.assertEqual(basket.lines.all()[1].quantity, 1)