Selaa lähdekoodia

Add support for restricted offer combinations

Non-exclusive offers that only allow combination with specific other offers
master
Paul J Stevens 6 vuotta sitten
vanhempi
commit
1d0c704185

+ 4
- 0
docs/source/releases/v3.0.rst Näytä tiedosto

32
   Projects should pay close attention to the data migration provided in
32
   Projects should pay close attention to the data migration provided in
33
   ``catalogue/migrations/0019_option_required.py`` for this change.
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
 Backwards incompatible changes
40
 Backwards incompatible changes
37
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
41
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

+ 1
- 1
src/oscar/apps/basket/abstract_models.py Näytä tiedosto

714
 
714
 
715
         Consumed items are no longer available to be used in offers.
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
     def get_price_breakdown(self):
719
     def get_price_breakdown(self):
720
         """
720
         """

+ 30
- 17
src/oscar/apps/basket/utils.py Näytä tiedosto

82
     """
82
     """
83
 
83
 
84
     def __init__(self, line):
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
     # public
99
     # public
99
     def consume(self, quantity: int, offer=None):
100
     def consume(self, quantity: int, offer=None):
103
         :param int quantity: the number of items on the line affected
104
         :param int quantity: the number of items on the line affected
104
         :param offer: the offer to mark the line
105
         :param offer: the offer to mark the line
105
         :type offer: ConditionalOffer or None
106
         :type offer: ConditionalOffer or None
107
+        :return: the number of items actually consumed
108
+        :rtype: int
106
 
109
 
107
         if offer is None, the specified quantity of items on this
110
         if offer is None, the specified quantity of items on this
108
         basket line is consumed for *any* offer, else only for the
111
         basket line is consumed for *any* offer, else only for the
109
         specified offer.
112
         specified offer.
110
         """
113
         """
111
-        self.__update_affected_quantity(quantity)
112
         if offer:
114
         if offer:
113
-            self.__cache(offer)
115
+            self._cache(offer)
114
             available = self.available(offer)
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
     def consumed(self, offer=None):
124
     def consumed(self, offer=None):
118
         """
125
         """
129
 
136
 
130
         """
137
         """
131
         if not offer:
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
     @property
142
     @property
136
     def consumers(self):
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
     def available(self, offer=None) -> int:
146
     def available(self, offer=None) -> int:
140
         """
147
         """
145
         :return: the number of items available for offer
152
         :return: the number of items available for offer
146
         :rtype: int
153
         :rtype: int
147
         """
154
         """
148
-        max_affected_items = self.__line.quantity
155
+        max_affected_items = self._line.quantity
149
 
156
 
150
         if offer and isinstance(offer, ConditionalOffer):
157
         if offer and isinstance(offer, ConditionalOffer):
151
 
158
 
160
             if offer.exclusive and len(applied):
167
             if offer.exclusive and len(applied):
161
                 return 0
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
             # respect max_affected_items
176
             # respect max_affected_items
164
             if offer.benefit.max_affected_items:
177
             if offer.benefit.max_affected_items:
165
                 max_affected_items = min(offer.benefit.max_affected_items, max_affected_items)
178
                 max_affected_items = min(offer.benefit.max_affected_items, max_affected_items)

+ 28
- 1
src/oscar/apps/dashboard/offers/forms.py Näytä tiedosto

36
         fields = ('start_datetime', 'end_datetime',
36
         fields = ('start_datetime', 'end_datetime',
37
                   'max_basket_applications', 'max_user_applications',
37
                   'max_basket_applications', 'max_user_applications',
38
                   'max_global_applications', 'max_discount',
38
                   'max_global_applications', 'max_discount',
39
-                  'priority', 'exclusive')
39
+                  'priority', 'exclusive', 'combinations')
40
 
40
 
41
     def clean(self):
41
     def clean(self):
42
         cleaned_data = super().clean()
42
         cleaned_data = super().clean()
45
         if start and end and end < start:
45
         if start and end and end < start:
46
             raise forms.ValidationError(_(
46
             raise forms.ValidationError(_(
47
                 "The end date must be after the start date"))
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
         return cleaned_data
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
 class ConditionForm(forms.ModelForm):
78
 class ConditionForm(forms.ModelForm):
52
     custom_condition = forms.ChoiceField(
79
     custom_condition = forms.ChoiceField(

+ 24
- 8
src/oscar/apps/dashboard/offers/views.py Näytä tiedosto

111
 
111
 
112
         # Adjust kwargs to avoid trying to save the range instance
112
         # Adjust kwargs to avoid trying to save the range instance
113
         form_data = form.cleaned_data.copy()
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
         form_kwargs = {'data': form_data}
123
         form_kwargs = {'data': form_data}
118
         json_data = json.dumps(form_kwargs, cls=DjangoJSONEncoder)
124
         json_data = json.dumps(form_kwargs, cls=DjangoJSONEncoder)
119
 
125
 
136
         # We don't store the object instance as that is not JSON serialisable.
142
         # We don't store the object instance as that is not JSON serialisable.
137
         # Instead, we save an alternative form
143
         # Instead, we save an alternative form
138
         instance = form.save(commit=False)
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
         session_data[self._key(is_object=True)] = json_qs
157
         session_data[self._key(is_object=True)] = json_qs
142
         self.request.session.save()
158
         self.request.session.save()
152
             return deserialised_obj[0].object
168
             return deserialised_obj[0].object
153
 
169
 
154
     def _fetch_session_offer(self):
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
         offer = self._fetch_object('metadata')
176
         offer = self._fetch_object('metadata')
161
         if offer is None and self.update:
177
         if offer is None and self.update:

+ 15
- 0
src/oscar/apps/offer/abstract_models.py Näytä tiedosto

119
         help_text=_("Exclusive offers cannot be combined on the same items"),
119
         help_text=_("Exclusive offers cannot be combined on the same items"),
120
         default=True
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
     # We track a status variable so it's easier to load offers that are
130
     # We track a status variable so it's easier to load offers that are
124
     # 'available' in some sense.
131
     # 'available' in some sense.
443
         return queryset.filter(is_discountable=True).exclude(
450
         return queryset.filter(is_discountable=True).exclude(
444
             structure=Product.CHILD)
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
 class AbstractBenefit(BaseOfferMixin, models.Model):
462
 class AbstractBenefit(BaseOfferMixin, models.Model):
448
     range = models.ForeignKey(
463
     range = models.ForeignKey(

+ 3
- 7
src/oscar/apps/offer/conditions.py Näytä tiedosto

92
         if to_consume == 0:
92
         if to_consume == 0:
93
             return
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
             if to_consume == 0:
98
             if to_consume == 0:
103
                 break
99
                 break
104
 
100
 

+ 18
- 0
src/oscar/apps/offer/migrations/0010_conditionaloffer_combinations.py Näytä tiedosto

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 Näytä tiedosto

118
         line.consume(99)
118
         line.consume(99)
119
         assert line.quantity_with_discount == 10
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
         offer1 = ConditionalOfferFactory(name='offer1')
122
         offer1 = ConditionalOfferFactory(name='offer1')
123
         offer2 = ConditionalOfferFactory(name='offer2')
123
         offer2 = ConditionalOfferFactory(name='offer2')
124
         offer3 = ConditionalOfferFactory(name='offer3')
124
         offer3 = ConditionalOfferFactory(name='offer3')
132
 
132
 
133
         line1, line2 = list(filled_basket.all_lines())
133
         line1, line2 = list(filled_basket.all_lines())
134
 
134
 
135
+        # exclusive offer consumes one item on line1
135
         line1.consumer.consume(1, offer1)
136
         line1.consumer.consume(1, offer1)
137
+
136
         # offer1 is exclusive so that blocks other offers
138
         # offer1 is exclusive so that blocks other offers
137
         assert line1.is_available_for_offer_discount(offer2) is False
139
         assert line1.is_available_for_offer_discount(offer2) is False
138
 
140
 
153
         # but still room for offer3!
155
         # but still room for offer3!
154
         assert line2.is_available_for_offer_discount(offer3) is True
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
     def test_consumed_by_application(self, filled_basket, single_offer):
217
     def test_consumed_by_application(self, filled_basket, single_offer):
157
         basket = filled_basket
218
         basket = filled_basket
158
         Applicator().apply(basket)
219
         Applicator().apply(basket)
159
         assert len(basket.offer_applications.offer_discounts) == 1
220
         assert len(basket.offer_applications.offer_discounts) == 1
160
-
161
         assert [x.consumer.consumed() for x in basket.all_lines()] == [1, 0]
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

Loading…
Peruuta
Tallenna