Repository: django-oscar/django-oscar

Issue:
Offer Combination Removal Issue
### Issue Summary

Removing an offer combination does not remove the offer combination from both the current offer being saved and the offer that was combined. 


### Steps to Reproduce

1. Create 2 non-exclusive Offers (Offer A and Offer B)
2. Add Offer A as a Combination on Offer B, save
3. Go to Offer A, can see that Offer B is a "Combined Offer", remove Offer B from Offer A combinations
4. Go to Offer B, can see that Offer A is still a Combined Offer 

### Examples on Oscar sandbox
**Setup**
![image](https://user-images.githubusercontent.com/674282/143189491-c4faaafa-3fac-4920-a556-9fba8c66c4a4.png)

![image](https://user-images.githubusercontent.com/674282/143189552-16171e97-0389-43d4-86b1-1988544aaf83.png)

**Remove the Offer B Combination from Offer A**

![image](https://user-images.githubusercontent.com/674282/143189663-56da3f38-551f-4dc2-b62c-469527fce17f.png)
![image](https://user-images.githubusercontent.com/674282/143189732-17f30d37-60c6-499e-966b-133757efc431.png)




Discussion:
Is anyone working on this issue? If not could I take this up? @solarissmoke
@SakshiUppoor  please do!
Thanks!

I think adding a loop to find any removed offers and detaching the instance from combination offer fields of these offers should fix this issue.

https://github.com/django-oscar/django-oscar/blob/dd200b8ea31078b953bdcc49d8f1a91d7733548a/src/oscar/apps/dashboard/offers/forms.py#L76-L89

Will get started with a PR

Fix patch:
diff --git a/src/oscar/apps/dashboard/offers/forms.py b/src/oscar/apps/dashboard/offers/forms.py
--- a/src/oscar/apps/dashboard/offers/forms.py
+++ b/src/oscar/apps/dashboard/offers/forms.py
@@ -74,6 +74,9 @@ def save(self, *args, **kwargs):
         """
         instance = super().save(*args, **kwargs)
         if instance.id:
+            for offer in instance.combinations.all():
+                if offer not in self.cleaned_data['combinations']:
+                    offer.combinations.remove(instance)
             instance.combinations.clear()
             for offer in self.cleaned_data['combinations']:
                 if offer != instance:


Test patch:
diff --git a/tests/functional/dashboard/test_offer.py b/tests/functional/dashboard/test_offer.py
--- a/tests/functional/dashboard/test_offer.py
+++ b/tests/functional/dashboard/test_offer.py
@@ -227,6 +227,27 @@ def test_jump_to_condition_step_for_existing_offer(self):
         self.assertFalse('range' in condition_page.errors)
         self.assertEqual(len(condition_page.errors), 0)
 
+    def test_remove_offer_from_combinations(self):
+        offer_a = factories.create_offer("Offer A")
+        offer_b = factories.create_offer("Offer B")
+        offer_b.exclusive = False
+        offer_b.save()
+
+        restrictions_page = self.get(reverse(
+            'dashboard:offer-restrictions', kwargs={'pk': offer_a.pk}))
+        restrictions_page.form['exclusive'] = False
+        restrictions_page.form['combinations'] = [offer_b.id]
+        restrictions_page.form.submit()
+
+        self.assertIn(offer_a, offer_b.combinations.all())
+
+        restrictions_page = self.get(reverse(
+            'dashboard:offer-restrictions', kwargs={'pk': offer_a.pk}))
+        restrictions_page.form['combinations'] = []
+        restrictions_page.form.submit()
+
+        self.assertNotIn(offer_a, offer_b.combinations.all())
+
 
 class TestOfferListSearch(testcases.WebTestCase):
     is_staff = True