Quellcode durchsuchen

Add support for restricted offer combinations

Non-exclusive offers that only allow combination with specific other offers
master
Paul J Stevens vor 6 Jahren
Ursprung
Commit
1d0c704185

+ 4
- 0
docs/source/releases/v3.0.rst Datei anzeigen

@@ -32,6 +32,10 @@ What's new in Oscar 3.0?
32 32
   Projects should pay close attention to the data migration provided in
33 33
   ``catalogue/migrations/0019_option_required.py`` for this change.
34 34
 
35
+- Added support for restricted combinations of offers. When creating an offer in the dashboard,
36
+  administrators can now define a restricted set of other offers that offer can be used in combination with.
37
+  This changes introduces a new ``combinations`` field on the ``Offer`` model which requires a database migration.
38
+
35 39
 
36 40
 Backwards incompatible changes
37 41
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

+ 1
- 1
src/oscar/apps/basket/abstract_models.py Datei anzeigen

@@ -714,7 +714,7 @@ class AbstractLine(models.Model):
714 714
 
715 715
         Consumed items are no longer available to be used in offers.
716 716
         """
717
-        self.consumer.consume(quantity, offer=offer)
717
+        return self.consumer.consume(quantity, offer=offer)
718 718
 
719 719
     def get_price_breakdown(self):
720 720
         """

+ 30
- 17
src/oscar/apps/basket/utils.py Datei anzeigen

@@ -82,18 +82,19 @@ class LineOfferConsumer(object):
82 82
     """
83 83
 
84 84
     def __init__(self, line):
85
-        self.__line = line
86
-        self.__offers = dict()
87
-        self.__affected_quantity = 0
88
-        self.__consumptions = defaultdict(int)
85
+        self._line = line
86
+        self._offers = dict()
87
+        self._affected_quantity = 0
88
+        self._consumptions = defaultdict(int)
89 89
 
90
-    # private
91
-    def __cache(self, offer):
92
-        self.__offers[offer.pk] = offer
90
+    def _cache(self, offer):
91
+        self._offers[offer.pk] = offer
93 92
 
94
-    def __update_affected_quantity(self, quantity):
95
-        available = int(self.__line.quantity - self.__affected_quantity)
96
-        self.__affected_quantity += min(available, quantity)
93
+    def _update_affected_quantity(self, quantity):
94
+        available = int(self._line.quantity - self._affected_quantity)
95
+        num_consumed = min(available, quantity)
96
+        self._affected_quantity += num_consumed
97
+        return num_consumed
97 98
 
98 99
     # public
99 100
     def consume(self, quantity: int, offer=None):
@@ -103,16 +104,22 @@ class LineOfferConsumer(object):
103 104
         :param int quantity: the number of items on the line affected
104 105
         :param offer: the offer to mark the line
105 106
         :type offer: ConditionalOffer or None
107
+        :return: the number of items actually consumed
108
+        :rtype: int
106 109
 
107 110
         if offer is None, the specified quantity of items on this
108 111
         basket line is consumed for *any* offer, else only for the
109 112
         specified offer.
110 113
         """
111
-        self.__update_affected_quantity(quantity)
112 114
         if offer:
113
-            self.__cache(offer)
115
+            self._cache(offer)
114 116
             available = self.available(offer)
115
-            self.__consumptions[offer.pk] += min(available, quantity)
117
+
118
+        num_consumed = self._update_affected_quantity(quantity)
119
+        if offer:
120
+            num_consumed = min(available, quantity)
121
+            self._consumptions[offer.pk] += num_consumed
122
+        return num_consumed
116 123
 
117 124
     def consumed(self, offer=None):
118 125
         """
@@ -129,12 +136,12 @@ class LineOfferConsumer(object):
129 136
 
130 137
         """
131 138
         if not offer:
132
-            return self.__affected_quantity
133
-        return int(self.__consumptions[offer.pk])
139
+            return self._affected_quantity
140
+        return int(self._consumptions[offer.pk])
134 141
 
135 142
     @property
136 143
     def consumers(self):
137
-        return [x for x in self.__offers.values() if self.consumed(x)]
144
+        return [x for x in self._offers.values() if self.consumed(x)]
138 145
 
139 146
     def available(self, offer=None) -> int:
140 147
         """
@@ -145,7 +152,7 @@ class LineOfferConsumer(object):
145 152
         :return: the number of items available for offer
146 153
         :rtype: int
147 154
         """
148
-        max_affected_items = self.__line.quantity
155
+        max_affected_items = self._line.quantity
149 156
 
150 157
         if offer and isinstance(offer, ConditionalOffer):
151 158
 
@@ -160,6 +167,12 @@ class LineOfferConsumer(object):
160 167
             if offer.exclusive and len(applied):
161 168
                 return 0
162 169
 
170
+            # check for applied offers allowing restricted combinations
171
+            for x in applied:
172
+                check = offer.combinations.count() or x.combinations.count()
173
+                if check and offer not in x.combined_offers:
174
+                    return 0
175
+
163 176
             # respect max_affected_items
164 177
             if offer.benefit.max_affected_items:
165 178
                 max_affected_items = min(offer.benefit.max_affected_items, max_affected_items)

+ 28
- 1
src/oscar/apps/dashboard/offers/forms.py Datei anzeigen

@@ -36,7 +36,7 @@ class RestrictionsForm(forms.ModelForm):
36 36
         fields = ('start_datetime', 'end_datetime',
37 37
                   'max_basket_applications', 'max_user_applications',
38 38
                   'max_global_applications', 'max_discount',
39
-                  'priority', 'exclusive')
39
+                  'priority', 'exclusive', 'combinations')
40 40
 
41 41
     def clean(self):
42 42
         cleaned_data = super().clean()
@@ -45,8 +45,35 @@ class RestrictionsForm(forms.ModelForm):
45 45
         if start and end and end < start:
46 46
             raise forms.ValidationError(_(
47 47
                 "The end date must be after the start date"))
48
+        exclusive = cleaned_data['exclusive']
49
+        combinations = cleaned_data['combinations']
50
+        if exclusive and combinations:
51
+            raise forms.ValidationError(_('Exclusive offers cannot be combined'))
48 52
         return cleaned_data
49 53
 
54
+    def save(self, *args, **kwargs):
55
+        """Store the offer combinations.
56
+
57
+        Also, and make sure the combinations are stored on the combine-able
58
+        offers as well.
59
+        """
60
+        instance = super().save(*args, **kwargs)
61
+        if instance.id:
62
+            instance.combinations.clear()
63
+            for offer in self.cleaned_data['combinations']:
64
+                if offer != instance:
65
+                    instance.combinations.add(offer)
66
+
67
+            combined_offers = instance.combined_offers
68
+            for offer in combined_offers:
69
+                if offer == instance:
70
+                    continue
71
+                for otheroffer in combined_offers:
72
+                    if offer == otheroffer:
73
+                        continue
74
+                    offer.combinations.add(otheroffer)
75
+        return instance
76
+
50 77
 
51 78
 class ConditionForm(forms.ModelForm):
52 79
     custom_condition = forms.ChoiceField(

+ 24
- 8
src/oscar/apps/dashboard/offers/views.py Datei anzeigen

@@ -111,9 +111,15 @@ class OfferWizardStepView(FormView):
111 111
 
112 112
         # Adjust kwargs to avoid trying to save the range instance
113 113
         form_data = form.cleaned_data.copy()
114
-        range = form_data.get('range', None)
115
-        if range is not None:
116
-            form_data['range'] = range.id
114
+        product_range = form_data.get('range')
115
+        if product_range is not None:
116
+            form_data['range'] = product_range.id
117
+
118
+        combinations = form_data.get('combinations')
119
+        if combinations is not None:
120
+            form_data['combination_ids'] = [x.id for x in combinations]
121
+            del form_data['combinations']
122
+
117 123
         form_kwargs = {'data': form_data}
118 124
         json_data = json.dumps(form_kwargs, cls=DjangoJSONEncoder)
119 125
 
@@ -136,7 +142,17 @@ class OfferWizardStepView(FormView):
136 142
         # We don't store the object instance as that is not JSON serialisable.
137 143
         # Instead, we save an alternative form
138 144
         instance = form.save(commit=False)
139
-        json_qs = serializers.serialize('json', [instance])
145
+        fields = form.fields.keys()
146
+        safe_fields = ['custom_benefit', 'custom_condition']
147
+        # remove fields that do not exist (yet) on the uncommitted instance, i.e. m2m fields
148
+        # unless they are 'virtual' fields as listed in 'safe_fields'
149
+        cleanfields = {x: hasattr(instance, x) for x in fields}
150
+        cleanfields.update({x: True for x in fields if x in safe_fields})
151
+        cleanfields = [
152
+            x[0] for x in cleanfields.items() if x[1]
153
+        ]
154
+
155
+        json_qs = serializers.serialize('json', [instance], fields=tuple(cleanfields))
140 156
 
141 157
         session_data[self._key(is_object=True)] = json_qs
142 158
         self.request.session.save()
@@ -152,10 +168,10 @@ class OfferWizardStepView(FormView):
152 168
             return deserialised_obj[0].object
153 169
 
154 170
     def _fetch_session_offer(self):
155
-        """
156
-        Return the offer instance loaded with the data stored in the
157
-        session.  When updating an offer, the updated fields are used with the
158
-        existing offer data.
171
+        """Return the offer instance loaded with the data stored in the session.
172
+
173
+        When updating an offer, the updated fields are used with the existing
174
+        offer data.
159 175
         """
160 176
         offer = self._fetch_object('metadata')
161 177
         if offer is None and self.update:

+ 15
- 0
src/oscar/apps/offer/abstract_models.py Datei anzeigen

@@ -119,6 +119,13 @@ class AbstractConditionalOffer(models.Model):
119 119
         help_text=_("Exclusive offers cannot be combined on the same items"),
120 120
         default=True
121 121
     )
122
+    combinations = models.ManyToManyField(
123
+        'offer.ConditionalOffer',
124
+        help_text=_('Select other non-exclusive offers that this offer can be combined with on the same items'),
125
+        related_name='in_combination',
126
+        limit_choices_to={'exclusive': False},
127
+        blank=True,
128
+    )
122 129
 
123 130
     # We track a status variable so it's easier to load offers that are
124 131
     # 'available' in some sense.
@@ -443,6 +450,14 @@ class AbstractConditionalOffer(models.Model):
443 450
         return queryset.filter(is_discountable=True).exclude(
444 451
             structure=Product.CHILD)
445 452
 
453
+    @cached_property
454
+    def combined_offers(self):
455
+        return self.__class__.objects.filter(
456
+            models.Q(pk=self.pk)
457
+            | models.Q(pk__in=self.combinations.values_list("pk", flat=True))
458
+            | models.Q(pk__in=self.in_combination.values_list("pk", flat=True))
459
+        ).distinct()
460
+
446 461
 
447 462
 class AbstractBenefit(BaseOfferMixin, models.Model):
448 463
     range = models.ForeignKey(

+ 3
- 7
src/oscar/apps/offer/conditions.py Datei anzeigen

@@ -92,13 +92,9 @@ class CountCondition(Condition):
92 92
         if to_consume == 0:
93 93
             return
94 94
 
95
-        for __, line in self.get_applicable_lines(offer, basket,
96
-                                                  most_expensive_first=True):
97
-            quantity_to_consume = min(
98
-                line.quantity_without_offer_discount(offer), to_consume
99
-            )
100
-            line.consume(quantity_to_consume, offer=offer)
101
-            to_consume -= quantity_to_consume
95
+        for __, line in self.get_applicable_lines(offer, basket, most_expensive_first=True):
96
+            num_consumed = line.consume(to_consume, offer=offer)
97
+            to_consume -= num_consumed
102 98
             if to_consume == 0:
103 99
                 break
104 100
 

+ 18
- 0
src/oscar/apps/offer/migrations/0010_conditionaloffer_combinations.py Datei anzeigen

@@ -0,0 +1,18 @@
1
+# Generated by Django 3.0.9 on 2020-08-28 09:03
2
+
3
+from django.db import migrations, models
4
+
5
+
6
+class Migration(migrations.Migration):
7
+
8
+    dependencies = [
9
+        ('offer', '0009_auto_20200801_0817'),
10
+    ]
11
+
12
+    operations = [
13
+        migrations.AddField(
14
+            model_name='conditionaloffer',
15
+            name='combinations',
16
+            field=models.ManyToManyField(blank=True, help_text='Select other non-exclusive offers that this offer can be combined with on the same items', limit_choices_to={'exclusive': False}, related_name='in_combination', to='offer.ConditionalOffer'),
17
+        ),
18
+    ]

+ 121
- 2
tests/integration/basket/test_utils.py Datei anzeigen

@@ -118,7 +118,7 @@ class TestLineOfferConsumer:
118 118
         line.consume(99)
119 119
         assert line.quantity_with_discount == 10
120 120
 
121
-    def test_consumed_with_exclusive_offer(self, filled_basket):
121
+    def test_consumed_with_exclusive_offer_1(self, filled_basket):
122 122
         offer1 = ConditionalOfferFactory(name='offer1')
123 123
         offer2 = ConditionalOfferFactory(name='offer2')
124 124
         offer3 = ConditionalOfferFactory(name='offer3')
@@ -132,7 +132,9 @@ class TestLineOfferConsumer:
132 132
 
133 133
         line1, line2 = list(filled_basket.all_lines())
134 134
 
135
+        # exclusive offer consumes one item on line1
135 136
         line1.consumer.consume(1, offer1)
137
+
136 138
         # offer1 is exclusive so that blocks other offers
137 139
         assert line1.is_available_for_offer_discount(offer2) is False
138 140
 
@@ -153,9 +155,126 @@ class TestLineOfferConsumer:
153 155
         # but still room for offer3!
154 156
         assert line2.is_available_for_offer_discount(offer3) is True
155 157
 
158
+    def test_consumed_with_exclusive_offer_2(self, filled_basket):
159
+        offer1 = ConditionalOfferFactory(name='offer1')
160
+        offer2 = ConditionalOfferFactory(name='offer2')
161
+        offer3 = ConditionalOfferFactory(name='offer3')
162
+        offer1.exclusive = True
163
+        offer2.exclusive = False
164
+        offer3.exclusive = False
165
+
166
+        for line in filled_basket.all_lines():
167
+            assert line.consumer.consumed(offer1) == 0
168
+            assert line.consumer.consumed(offer2) == 0
169
+
170
+        line1, line2 = list(filled_basket.all_lines())
171
+
172
+        # exclusive offer consumes one item on line1
173
+        line1.consumer.consume(1, offer1)
174
+        remaining1 = line1.quantity - 1
175
+
176
+        assert line1.quantity_with_offer_discount(offer1) == 1
177
+        assert line1.quantity_with_offer_discount(offer2) == 0
178
+        assert line1.quantity_with_offer_discount(offer3) == 0
179
+
180
+        assert line1.quantity_without_offer_discount(offer1) == remaining1
181
+        assert line1.quantity_without_offer_discount(offer2) == 0
182
+        assert line1.quantity_without_offer_discount(offer3) == 0
183
+
184
+        # exclusive offer consumes all items on line1
185
+        line1.consumer.consume(remaining1, offer1)
186
+        assert line1.quantity_with_offer_discount(offer1) == line1.quantity
187
+        assert line1.quantity_with_offer_discount(offer2) == 0
188
+        assert line1.quantity_with_offer_discount(offer3) == 0
189
+
190
+        assert line1.quantity_without_offer_discount(offer1) == 0
191
+        assert line1.quantity_without_offer_discount(offer2) == 0
192
+        assert line1.quantity_without_offer_discount(offer3) == 0
193
+
194
+        # non-exclusive offer consumes one item on line2
195
+        line2.consumer.consume(1, offer2)
196
+        remaining2 = line2.quantity - 1
197
+
198
+        assert line2.quantity_with_offer_discount(offer1) == 0
199
+        assert line2.quantity_with_offer_discount(offer2) == 1
200
+        assert line2.quantity_with_offer_discount(offer3) == 0
201
+
202
+        assert line2.quantity_without_offer_discount(offer1) == 0
203
+        assert line2.quantity_without_offer_discount(offer2) == remaining2
204
+        assert line2.quantity_without_offer_discount(offer3) == line2.quantity
205
+
206
+        # non-exclusive offer consumes all items on line2
207
+        line2.consumer.consume(remaining2, offer2)
208
+
209
+        assert line2.quantity_with_offer_discount(offer1) == 0
210
+        assert line2.quantity_with_offer_discount(offer2) == line2.quantity
211
+        assert line2.quantity_with_offer_discount(offer3) == 0
212
+
213
+        assert line2.quantity_without_offer_discount(offer1) == 0
214
+        assert line2.quantity_without_offer_discount(offer2) == 0
215
+        assert line2.quantity_without_offer_discount(offer3) == line2.quantity
216
+
156 217
     def test_consumed_by_application(self, filled_basket, single_offer):
157 218
         basket = filled_basket
158 219
         Applicator().apply(basket)
159 220
         assert len(basket.offer_applications.offer_discounts) == 1
160
-
161 221
         assert [x.consumer.consumed() for x in basket.all_lines()] == [1, 0]
222
+
223
+    def test_consumed_with_combined_offer(self, filled_basket):
224
+        offer1 = ConditionalOfferFactory(name='offer1')
225
+        offer2 = ConditionalOfferFactory(name='offer2')
226
+        offer3 = ConditionalOfferFactory(name='offer3')
227
+        offer4 = ConditionalOfferFactory(name='offer4')
228
+        offer1.exclusive = True
229
+        offer2.exclusive = False
230
+        offer3.exclusive = False
231
+        offer4.exclusive = False
232
+        offer2.combinations.add(offer3)
233
+        assert offer3 in offer2.combined_offers
234
+        assert offer2 in offer3.combined_offers
235
+
236
+        for line in filled_basket.all_lines():
237
+            assert line.consumer.consumed(offer1) == 0
238
+            assert line.consumer.consumed(offer2) == 0
239
+            assert line.consumer.consumed(offer3) == 0
240
+
241
+        line1 = filled_basket.all_lines()[0]
242
+
243
+        # combinable offer consumes one item of line1
244
+        line1.consumer.consume(1, offer2)
245
+        remaining1 = line1.quantity - 1
246
+
247
+        assert line1.quantity_with_offer_discount(offer1) == 0
248
+        assert line1.quantity_with_offer_discount(offer2) == 1
249
+        assert line1.quantity_with_offer_discount(offer3) == 0
250
+        assert line1.quantity_with_offer_discount(offer4) == 0
251
+
252
+        assert line1.quantity_without_offer_discount(offer1) == 0
253
+        assert line1.quantity_without_offer_discount(offer2) == remaining1
254
+        assert line1.quantity_without_offer_discount(offer3) == line1.quantity
255
+        assert line1.quantity_without_offer_discount(offer4) == 0
256
+
257
+        # combinable offer consumes one item of line1
258
+        line1.consumer.consume(1, offer3)
259
+        assert line1.quantity_with_offer_discount(offer1) == 0
260
+        assert line1.quantity_with_offer_discount(offer2) == 1
261
+        assert line1.quantity_with_offer_discount(offer3) == 1
262
+        assert line1.quantity_with_offer_discount(offer4) == 0
263
+
264
+        assert line1.quantity_without_offer_discount(offer1) == 0
265
+        assert line1.quantity_without_offer_discount(offer2) == remaining1
266
+        assert line1.quantity_without_offer_discount(offer3) == remaining1
267
+        assert line1.quantity_without_offer_discount(offer4) == 0
268
+
269
+        # combinable offer consumes all items of line1
270
+        line1.consumer.consume(remaining1, offer2)
271
+
272
+        assert line1.quantity_with_offer_discount(offer1) == 0
273
+        assert line1.quantity_with_offer_discount(offer2) == line1.quantity
274
+        assert line1.quantity_with_offer_discount(offer3) == 1
275
+        assert line1.quantity_with_offer_discount(offer4) == 0
276
+
277
+        assert line1.quantity_without_offer_discount(offer1) == 0
278
+        assert line1.quantity_without_offer_discount(offer2) == 0
279
+        assert line1.quantity_without_offer_discount(offer3) == remaining1
280
+        assert line1.quantity_without_offer_discount(offer4) == 0

Laden…
Abbrechen
Speichern