Browse Source

Feature/stacked offers (#2410)

* flesh-out initial opaque data-model

refactors the basket line consumption into a facade object.

the goals is accomodating combitions of various offers on the same basket-lines,
while remaining backward compatible with the current single offer per line
policy.

* add docstrings

* add exclusive flag to conditionaloffer model and continue refactoring

* test new offer consumption on basket view

* cleanups

* fix exclusivity

* code style

* add cast to pacify tests

* spelling

* add some documentation to the release notes

* move documentation to new 1.6 release docs
master
Paul J Stevens 8 years ago
parent
commit
78823ca044

+ 0
- 1
docs/source/releases/v1.5.rst View File

150
  - ``oscar.forms.fields.ExtendedURLField`` no longer accepts a ``verify_exists``
150
  - ``oscar.forms.fields.ExtendedURLField`` no longer accepts a ``verify_exists``
151
    argument.
151
    argument.
152
 
152
 
153
-
154
 Dependency changes
153
 Dependency changes
155
 ------------------
154
 ------------------
156
 
155
 

+ 60
- 0
docs/source/releases/v1.6.rst View File

1
+=======================
2
+Oscar 1.6 release notes
3
+=======================
4
+
5
+:release: tbd
6
+
7
+Welcome to Oscar 1.6
8
+
9
+
10
+Table of contents:
11
+
12
+.. contents::
13
+    :local:
14
+    :depth: 1
15
+
16
+
17
+.. _compatibility_of_1.6:
18
+
19
+Compatibility
20
+-------------
21
+
22
+Oscar 1.6 is compatible with Django 1.8, 1.10 and 1.11 as well as Python 2.7,
23
+3.3, 3.4, 3.5 and 3.6. Support for Django 1.9 is no longer officialy supported
24
+since it is longer supported by Django (end of life).
25
+
26
+
27
+.. _new_in_1.6:
28
+
29
+What's new in Oscar 1.6?
30
+------------------------
31
+
32
+
33
+Removal of deprecated features
34
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
35
+
36
+
37
+Minor changes
38
+~~~~~~~~~~~~~
39
+
40
+.. _incompatible_in_1.6:
41
+
42
+Backwards incompatible changes in Oscar 1.6
43
+-------------------------------------------
44
+
45
+ - ``oscar.apps.offer.models.ConditionalOffer`` now has a new flag
46
+   ``exclusive`` to denote that the offer involved can not be combined on the
47
+   same item on the same basket line with another offer.
48
+   This flag is used by ``oscar.apps.basket.utils.LineOfferConsumer``, a facade 
49
+   that supercedes the old ``oscar.apps.basket.models.Line._affected_items`` counter,
50
+   and replaces it with a more finegrained approach. This makes it possible to apply
51
+   two distinct non-exclusive offers on the same basketline items, for example
52
+   multiple distinct vouchers.
53
+   A couple of methods on the basketline model have been extended with an
54
+   optional ``offer`` argument, i.e. ``discount`` and ``consume``, so if you
55
+   are using a customized basketline model, you have to update your methods'
56
+   signatures.
57
+
58
+
59
+Dependency changes
60
+------------------

+ 31
- 16
src/oscar/apps/basket/abstract_models.py View File

18
 from oscar.templatetags.currency_filters import currency
18
 from oscar.templatetags.currency_filters import currency
19
 
19
 
20
 Unavailable = get_class('partner.availability', 'Unavailable')
20
 Unavailable = get_class('partner.availability', 'Unavailable')
21
+LineOfferConsumer = get_class('basket.utils', 'LineOfferConsumer')
21
 
22
 
22
 
23
 
23
 @python_2_unicode_compatible
24
 @python_2_unicode_compatible
634
         # Instance variables used to persist discount information
635
         # Instance variables used to persist discount information
635
         self._discount_excl_tax = D('0.00')
636
         self._discount_excl_tax = D('0.00')
636
         self._discount_incl_tax = D('0.00')
637
         self._discount_incl_tax = D('0.00')
637
-        self._affected_quantity = 0
638
+        self.consumer = LineOfferConsumer(self)
638
 
639
 
639
     class Meta:
640
     class Meta:
640
         abstract = True
641
         abstract = True
669
         """
670
         """
670
         self._discount_excl_tax = D('0.00')
671
         self._discount_excl_tax = D('0.00')
671
         self._discount_incl_tax = D('0.00')
672
         self._discount_incl_tax = D('0.00')
672
-        self._affected_quantity = 0
673
+        self.consumer = LineOfferConsumer(self)
673
 
674
 
674
-    def discount(self, discount_value, affected_quantity, incl_tax=True):
675
+    def discount(self, discount_value, affected_quantity, incl_tax=True,
676
+                 offer=None):
675
         """
677
         """
676
         Apply a discount to this line
678
         Apply a discount to this line
677
         """
679
         """
687
                     "Attempting to discount the tax-exclusive price of a line "
689
                     "Attempting to discount the tax-exclusive price of a line "
688
                     "when tax-inclusive discounts are already applied")
690
                     "when tax-inclusive discounts are already applied")
689
             self._discount_excl_tax += discount_value
691
             self._discount_excl_tax += discount_value
690
-        self._affected_quantity += int(affected_quantity)
692
+        self.consume(affected_quantity, offer=offer)
691
 
693
 
692
-    def consume(self, quantity):
694
+    def consume(self, quantity, offer=None):
693
         """
695
         """
694
         Mark all or part of the line as 'consumed'
696
         Mark all or part of the line as 'consumed'
695
 
697
 
696
         Consumed items are no longer available to be used in offers.
698
         Consumed items are no longer available to be used in offers.
697
         """
699
         """
698
-        if quantity > self.quantity - self._affected_quantity:
699
-            inc = self.quantity - self._affected_quantity
700
-        else:
701
-            inc = quantity
702
-        self._affected_quantity += int(inc)
700
+        self.consumer.consume(quantity, offer=offer)
703
 
701
 
704
     def get_price_breakdown(self):
702
     def get_price_breakdown(self):
705
         """
703
         """
719
             # Need to split the discount among the affected quantity
717
             # Need to split the discount among the affected quantity
720
             # of products.
718
             # of products.
721
             item_incl_tax_discount = (
719
             item_incl_tax_discount = (
722
-                self.discount_value / int(self._affected_quantity))
720
+                self.discount_value / int(self.consumer.consumed()))
723
             item_excl_tax_discount = item_incl_tax_discount * self._tax_ratio
721
             item_excl_tax_discount = item_incl_tax_discount * self._tax_ratio
724
             item_excl_tax_discount = item_excl_tax_discount.quantize(D('0.01'))
722
             item_excl_tax_discount = item_excl_tax_discount.quantize(D('0.01'))
725
             prices.append((self.unit_price_incl_tax - item_incl_tax_discount,
723
             prices.append((self.unit_price_incl_tax - item_incl_tax_discount,
726
                            self.unit_price_excl_tax - item_excl_tax_discount,
724
                            self.unit_price_excl_tax - item_excl_tax_discount,
727
-                           self._affected_quantity))
725
+                           self.consumer.consumed()))
728
             if self.quantity_without_discount:
726
             if self.quantity_without_discount:
729
                 prices.append((self.unit_price_incl_tax,
727
                 prices.append((self.unit_price_incl_tax,
730
                                self.unit_price_excl_tax,
728
                                self.unit_price_excl_tax,
741
             return 0
739
             return 0
742
         return self.unit_price_excl_tax / self.unit_price_incl_tax
740
         return self.unit_price_excl_tax / self.unit_price_incl_tax
743
 
741
 
742
+    # ===============
743
+    # Offer Discounts
744
+    # ===============
745
+
746
+    def has_offer_discount(self, offer):
747
+        return self.consumer.consumed(offer) > 0
748
+
749
+    def quantity_with_offer_discount(self, offer):
750
+        return self.consumer.consumed(offer)
751
+
752
+    def quantity_without_offer_discount(self, offer):
753
+        return self.consumer.available(offer)
754
+
755
+    def is_available_for_offer_discount(self, offer):
756
+        return self.consumer.available(offer) > 0
757
+
744
     # ==========
758
     # ==========
745
     # Properties
759
     # Properties
746
     # ==========
760
     # ==========
747
 
761
 
748
     @property
762
     @property
749
     def has_discount(self):
763
     def has_discount(self):
750
-        return self.quantity > self.quantity_without_discount
764
+        return bool(self.consumer.consumed())
751
 
765
 
752
     @property
766
     @property
753
     def quantity_with_discount(self):
767
     def quantity_with_discount(self):
754
-        return self._affected_quantity
768
+        return self.consumer.consumed()
755
 
769
 
756
     @property
770
     @property
757
     def quantity_without_discount(self):
771
     def quantity_without_discount(self):
758
-        return int(self.quantity - self._affected_quantity)
772
+        return self.consumer.available()
759
 
773
 
760
     @property
774
     @property
761
     def is_available_for_discount(self):
775
     def is_available_for_discount(self):
762
-        return self.quantity_without_discount > 0
776
+        # deprecated
777
+        return self.consumer.available() > 0
763
 
778
 
764
     @property
779
     @property
765
     def discount_value(self):
780
     def discount_value(self):

+ 89
- 1
src/oscar/apps/basket/utils.py View File

1
+from collections import defaultdict
2
+
1
 from django.contrib import messages
3
 from django.contrib import messages
2
 from django.template.loader import render_to_string
4
 from django.template.loader import render_to_string
3
-
4
 from oscar.core.loading import get_class
5
 from oscar.core.loading import get_class
5
 
6
 
6
 Applicator = get_class('offer.applicator', 'Applicator')
7
 Applicator = get_class('offer.applicator', 'Applicator')
62
 
63
 
63
         for level, msg in self.get_messages(request.basket, offers_before, offers_after):
64
         for level, msg in self.get_messages(request.basket, offers_before, offers_after):
64
             messages.add_message(request, level, msg, extra_tags='safe noicon')
65
             messages.add_message(request, level, msg, extra_tags='safe noicon')
66
+
67
+
68
+class LineOfferConsumer(object):
69
+    """
70
+    facade for marking basket lines as consumed by
71
+    any or a specific offering.
72
+
73
+    historically oscar marks a line as consumed if any
74
+    offer is applied to it, but more complicated scenarios
75
+    are possible if we mark the line as being consumed by
76
+    specific offers.
77
+
78
+    this allows combining i.e. multiple vouchers, vouchers
79
+    with special session discounts, etc.
80
+    """
81
+
82
+    def __init__(self, line):
83
+        self.__line = line
84
+        self.__offers = dict()
85
+        self.__affected_quantity = 0
86
+        self.__consumptions = defaultdict(int)
87
+
88
+    # private
89
+    def __cache(self, offer):
90
+        self.__offers[offer.pk] = offer
91
+
92
+    def __update_affected_quantity(self, quantity):
93
+        available = int(self.__line.quantity - self.__affected_quantity)
94
+        self.__affected_quantity += min(available, quantity)
95
+
96
+    # public
97
+    def consume(self, quantity, offer=None):
98
+        """
99
+        mark a basket line as consumed by an offer
100
+
101
+        :param int quantity: the number of items on the line affected
102
+        :param offer: the offer to mark the line
103
+        :type offer: ConditionalOffer or None
104
+
105
+        if offer is None, the specified quantity of items on this
106
+        basket line is consumed for *any* offer, else only for the
107
+        specified offer.
108
+        """
109
+        self.__update_affected_quantity(quantity)
110
+        if offer:
111
+            self.__cache(offer)
112
+            available = self.available(offer)
113
+            self.__consumptions[offer.pk] += min(available, quantity)
114
+
115
+    def consumed(self, offer=None):
116
+        """
117
+        check how many items on this line have been
118
+        consumed by an offer
119
+
120
+        :param offer: the offer to check
121
+        :type offer: ConditionalOffer or None
122
+        :return: the number of items marked as consumed
123
+        :rtype: int
124
+
125
+        if offer is not None, only the number of items marked
126
+        with the specified ConditionalOffer are returned
127
+
128
+        """
129
+        if not offer:
130
+            return self.__affected_quantity
131
+        return int(self.__consumptions[offer.pk])
132
+
133
+    def available(self, offer=None):
134
+        """
135
+        check how many items are available for offer
136
+
137
+        :param offer: the offer to check
138
+        :type offer: ConditionalOffer or None
139
+        :return: the number of items available for offer
140
+        :rtype: int
141
+        """
142
+        if offer:
143
+            exclusive = any([x.exclusive for x in self.__offers.values()])
144
+            exclusive |= bool(offer.exclusive)
145
+        else:
146
+            exclusive = True
147
+
148
+        if exclusive:
149
+            offer = None
150
+
151
+        consumed = self.consumed(offer)
152
+        return int(self.__line.quantity - consumed)

+ 2
- 1
src/oscar/apps/dashboard/offers/forms.py View File

36
         model = ConditionalOffer
36
         model = ConditionalOffer
37
         fields = ('start_datetime', 'end_datetime',
37
         fields = ('start_datetime', 'end_datetime',
38
                   'max_basket_applications', 'max_user_applications',
38
                   'max_basket_applications', 'max_user_applications',
39
-                  'max_global_applications', 'max_discount')
39
+                  'max_global_applications', 'max_discount',
40
+                  'exclusive')
40
 
41
 
41
     def clean(self):
42
     def clean(self):
42
         cleaned_data = super(RestrictionsForm, self).clean()
43
         cleaned_data = super(RestrictionsForm, self).clean()

+ 4
- 0
src/oscar/apps/dashboard/vouchers/forms.py View File

44
     benefit_value = forms.DecimalField(
44
     benefit_value = forms.DecimalField(
45
         label=_('Discount value'))
45
         label=_('Discount value'))
46
 
46
 
47
+    exclusive = forms.BooleanField(
48
+        required=False,
49
+        label=_("Exclusive offers cannot be combined on the same items"))
50
+
47
     def __init__(self, voucher=None, *args, **kwargs):
51
     def __init__(self, voucher=None, *args, **kwargs):
48
         self.voucher = voucher
52
         self.voucher = voucher
49
         super(VoucherForm, self).__init__(*args, **kwargs)
53
         super(VoucherForm, self).__init__(*args, **kwargs)

+ 10
- 0
src/oscar/apps/dashboard/vouchers/views.py View File

84
         ctx['title'] = _('Create voucher')
84
         ctx['title'] = _('Create voucher')
85
         return ctx
85
         return ctx
86
 
86
 
87
+    def get_initial(self):
88
+        return dict(
89
+            exclusive=True
90
+        )
91
+
87
     @transaction.atomic()
92
     @transaction.atomic()
88
     def form_valid(self, form):
93
     def form_valid(self, form):
89
         # Create offer and benefit
94
         # Create offer and benefit
103
             offer_type=ConditionalOffer.VOUCHER,
108
             offer_type=ConditionalOffer.VOUCHER,
104
             benefit=benefit,
109
             benefit=benefit,
105
             condition=condition,
110
             condition=condition,
111
+            exclusive=form.cleaned_data['exclusive'],
106
         )
112
         )
107
         voucher = Voucher.objects.create(
113
         voucher = Voucher.objects.create(
108
             name=name,
114
             name=name,
166
             'benefit_type': benefit.type,
172
             'benefit_type': benefit.type,
167
             'benefit_range': benefit.range,
173
             'benefit_range': benefit.range,
168
             'benefit_value': benefit.value,
174
             'benefit_value': benefit.value,
175
+            'exclusive': offer.exclusive,
169
         }
176
         }
170
 
177
 
171
     @transaction.atomic()
178
     @transaction.atomic()
182
         offer.condition.range = form.cleaned_data['benefit_range']
189
         offer.condition.range = form.cleaned_data['benefit_range']
183
         offer.condition.save()
190
         offer.condition.save()
184
 
191
 
192
+        offer.exclusive = form.cleaned_data['exclusive']
193
+        offer.save()
194
+
185
         benefit = voucher.benefit
195
         benefit = voucher.benefit
186
         benefit.range = form.cleaned_data['benefit_range']
196
         benefit.range = form.cleaned_data['benefit_range']
187
         benefit.type = form.cleaned_data['benefit_type']
197
         benefit.type = form.cleaned_data['benefit_type']

+ 7
- 1
src/oscar/apps/offer/abstract_models.py View File

62
     offer_type = models.CharField(
62
     offer_type = models.CharField(
63
         _("Type"), choices=TYPE_CHOICES, default=SITE, max_length=128)
63
         _("Type"), choices=TYPE_CHOICES, default=SITE, max_length=128)
64
 
64
 
65
+    exclusive = models.BooleanField(
66
+        _("Exclusive offer"),
67
+        help_text=_("Exclusive offers cannot be combined on the same items"),
68
+        default=True
69
+    )
70
+
65
     # We track a status variable so it's easier to load offers that are
71
     # We track a status variable so it's easier to load offers that are
66
     # 'available' in some sense.
72
     # 'available' in some sense.
67
     OPEN, SUSPENDED, CONSUMED = "Open", "Suspended", "Consumed"
73
     OPEN, SUSPENDED, CONSUMED = "Open", "Suspended", "Consumed"
650
             if not price:
656
             if not price:
651
                 # Avoid zero price products
657
                 # Avoid zero price products
652
                 continue
658
                 continue
653
-            if line.quantity_without_discount == 0:
659
+            if line.quantity_without_offer_discount(offer) == 0:
654
                 continue
660
                 continue
655
             line_tuples.append((price, line))
661
             line_tuples.append((price, line))
656
 
662
 

+ 14
- 12
src/oscar/apps/offer/benefits.py View File

2
 
2
 
3
 from django.utils.translation import ugettext_lazy as _
3
 from django.utils.translation import ugettext_lazy as _
4
 
4
 
5
-from oscar.apps.offer import conditions, results, utils
5
+from oscar.apps.offer import results, utils, conditions
6
 from oscar.core.loading import get_model
6
 from oscar.core.loading import get_model
7
 from oscar.templatetags.currency_filters import currency
7
 from oscar.templatetags.currency_filters import currency
8
 
8
 
16
 ]
16
 ]
17
 
17
 
18
 
18
 
19
-def apply_discount(line, discount, quantity):
19
+def apply_discount(line, discount, quantity, offer=None):
20
     """
20
     """
21
     Apply a given discount to the passed basket
21
     Apply a given discount to the passed basket
22
     """
22
     """
23
-    line.discount(discount, quantity, incl_tax=False)
23
+    line.discount(discount, quantity, incl_tax=False, offer=offer)
24
 
24
 
25
 
25
 
26
 class PercentageDiscountBenefit(Benefit):
26
 class PercentageDiscountBenefit(Benefit):
66
             if discount_amount_available == 0:
66
             if discount_amount_available == 0:
67
                 break
67
                 break
68
 
68
 
69
-            quantity_affected = min(line.quantity_without_discount,
70
-                                    max_affected_items - affected_items)
69
+            quantity_affected = min(
70
+                line.quantity_without_offer_discount(offer),
71
+                max_affected_items - affected_items)
71
             line_discount = self.round(discount_percent / D('100.0') * price
72
             line_discount = self.round(discount_percent / D('100.0') * price
72
                                        * int(quantity_affected))
73
                                        * int(quantity_affected))
73
 
74
 
75
                 line_discount = min(line_discount, discount_amount_available)
76
                 line_discount = min(line_discount, discount_amount_available)
76
                 discount_amount_available -= line_discount
77
                 discount_amount_available -= line_discount
77
 
78
 
78
-            apply_discount(line, line_discount, quantity_affected)
79
+            apply_discount(line, line_discount, quantity_affected, offer)
79
 
80
 
80
             affected_lines.append((line, line_discount, quantity_affected))
81
             affected_lines.append((line, line_discount, quantity_affected))
81
             affected_items += quantity_affected
82
             affected_items += quantity_affected
127
         for price, line in line_tuples:
128
         for price, line in line_tuples:
128
             if num_affected_items >= max_affected_items:
129
             if num_affected_items >= max_affected_items:
129
                 break
130
                 break
130
-            qty = min(line.quantity_without_discount,
131
-                      max_affected_items - num_affected_items)
131
+            qty = min(
132
+                line.quantity_without_offer_discount(offer),
133
+                max_affected_items - num_affected_items)
132
             lines_to_discount.append((line, price, qty))
134
             lines_to_discount.append((line, price, qty))
133
             num_affected_items += qty
135
             num_affected_items += qty
134
             affected_items_total += qty * price
136
             affected_items_total += qty * price
155
                 # Calculate a weighted discount for the line
157
                 # Calculate a weighted discount for the line
156
                 line_discount = self.round(
158
                 line_discount = self.round(
157
                     ((price * qty) / affected_items_total) * discount)
159
                     ((price * qty) / affected_items_total) * discount)
158
-            apply_discount(line, line_discount, qty)
160
+            apply_discount(line, line_discount, qty, offer)
159
             affected_lines.append((line, line_discount, qty))
161
             affected_lines.append((line, line_discount, qty))
160
             applied_discount += line_discount
162
             applied_discount += line_discount
161
 
163
 
210
                 quantity_affected = 1
212
                 quantity_affected = 1
211
             else:
213
             else:
212
                 quantity_affected = min(
214
                 quantity_affected = min(
213
-                    line.quantity_without_discount,
215
+                    line.quantity_without_offer_discount(offer),
214
                     num_permitted - num_affected)
216
                     num_permitted - num_affected)
215
             num_affected += quantity_affected
217
             num_affected += quantity_affected
216
             value_affected += quantity_affected * price
218
             value_affected += quantity_affected * price
232
             else:
234
             else:
233
                 line_discount = self.round(
235
                 line_discount = self.round(
234
                     discount * (price * quantity) / value_affected)
236
                     discount * (price * quantity) / value_affected)
235
-            apply_discount(line, line_discount, quantity)
237
+            apply_discount(line, line_discount, quantity, offer)
236
             discount_applied += line_discount
238
             discount_applied += line_discount
237
         return results.BasketDiscount(discount)
239
         return results.BasketDiscount(discount)
238
 
240
 
263
 
265
 
264
         # Cheapest line gives free product
266
         # Cheapest line gives free product
265
         discount, line = line_tuples[0]
267
         discount, line = line_tuples[0]
266
-        apply_discount(line, discount, 1)
268
+        apply_discount(line, discount, 1, offer)
267
 
269
 
268
         affected_lines = [(line, discount, 1)]
270
         affected_lines = [(line, discount, 1)]
269
         condition.consume_items(offer, basket, affected_lines)
271
         condition.consume_items(offer, basket, affected_lines)

+ 28
- 23
src/oscar/apps/offer/conditions.py View File

48
         num_matches = 0
48
         num_matches = 0
49
         for line in basket.all_lines():
49
         for line in basket.all_lines():
50
             if (self.can_apply_condition(line)
50
             if (self.can_apply_condition(line)
51
-                    and line.quantity_without_discount > 0):
52
-                num_matches += line.quantity_without_discount
51
+                    and line.quantity_without_offer_discount(offer) > 0):
52
+                num_matches += line.quantity_without_offer_discount(offer)
53
             if num_matches >= self.value:
53
             if num_matches >= self.value:
54
                 return True
54
                 return True
55
         return False
55
         return False
56
 
56
 
57
-    def _get_num_matches(self, basket):
57
+    def _get_num_matches(self, basket, offer):
58
         if hasattr(self, '_num_matches'):
58
         if hasattr(self, '_num_matches'):
59
             return getattr(self, '_num_matches')
59
             return getattr(self, '_num_matches')
60
         num_matches = 0
60
         num_matches = 0
61
         for line in basket.all_lines():
61
         for line in basket.all_lines():
62
             if (self.can_apply_condition(line)
62
             if (self.can_apply_condition(line)
63
-                    and line.quantity_without_discount > 0):
64
-                num_matches += line.quantity_without_discount
63
+                    and line.quantity_without_offer_discount(offer) > 0):
64
+                num_matches += line.quantity_without_offer_discount(offer)
65
         self._num_matches = num_matches
65
         self._num_matches = num_matches
66
         return num_matches
66
         return num_matches
67
 
67
 
68
     def is_partially_satisfied(self, offer, basket):
68
     def is_partially_satisfied(self, offer, basket):
69
-        num_matches = self._get_num_matches(basket)
69
+        num_matches = self._get_num_matches(basket, offer)
70
         return 0 < num_matches < self.value
70
         return 0 < num_matches < self.value
71
 
71
 
72
     def get_upsell_message(self, offer, basket):
72
     def get_upsell_message(self, offer, basket):
73
-        num_matches = self._get_num_matches(basket)
73
+        num_matches = self._get_num_matches(basket, offer)
74
         delta = self.value - num_matches
74
         delta = self.value - num_matches
75
         return ungettext('Buy %(delta)d more product from %(range)s',
75
         return ungettext('Buy %(delta)d more product from %(range)s',
76
                          'Buy %(delta)d more products from %(range)s', delta) \
76
                          'Buy %(delta)d more products from %(range)s', delta) \
96
 
96
 
97
         for __, line in self.get_applicable_lines(offer, basket,
97
         for __, line in self.get_applicable_lines(offer, basket,
98
                                                   most_expensive_first=True):
98
                                                   most_expensive_first=True):
99
-            quantity_to_consume = min(line.quantity_without_discount,
100
-                                      to_consume)
101
-            line.consume(quantity_to_consume)
99
+            quantity_to_consume = min(
100
+                line.quantity_without_offer_discount(offer), to_consume
101
+            )
102
+            line.consume(quantity_to_consume, offer=offer)
102
             to_consume -= quantity_to_consume
103
             to_consume -= quantity_to_consume
103
             if to_consume == 0:
104
             if to_consume == 0:
104
                 break
105
                 break
136
         """
137
         """
137
         covered_ids = []
138
         covered_ids = []
138
         for line in basket.all_lines():
139
         for line in basket.all_lines():
139
-            if not line.is_available_for_discount:
140
+            if not line.is_available_for_offer_discount(offer):
140
                 continue
141
                 continue
141
             product = line.product
142
             product = line.product
142
             if (self.can_apply_condition(line) and product.id not in
143
             if (self.can_apply_condition(line) and product.id not in
146
                 return True
147
                 return True
147
         return False
148
         return False
148
 
149
 
149
-    def _get_num_covered_products(self, basket):
150
+    def _get_num_covered_products(self, basket, offer):
150
         covered_ids = []
151
         covered_ids = []
151
         for line in basket.all_lines():
152
         for line in basket.all_lines():
152
-            if not line.is_available_for_discount:
153
+            if not line.is_available_for_offer_discount(offer):
153
                 continue
154
                 continue
154
             product = line.product
155
             product = line.product
155
             if (self.can_apply_condition(line) and product.id not in
156
             if (self.can_apply_condition(line) and product.id not in
158
         return len(covered_ids)
159
         return len(covered_ids)
159
 
160
 
160
     def get_upsell_message(self, offer, basket):
161
     def get_upsell_message(self, offer, basket):
161
-        delta = self.value - self._get_num_covered_products(basket)
162
+        delta = self.value - self._get_num_covered_products(basket, offer)
162
         return ungettext('Buy %(delta)d more product from %(range)s',
163
         return ungettext('Buy %(delta)d more product from %(range)s',
163
                          'Buy %(delta)d more products from %(range)s', delta) \
164
                          'Buy %(delta)d more products from %(range)s', delta) \
164
             % {'delta': delta, 'range': self.range}
165
             % {'delta': delta, 'range': self.range}
165
 
166
 
166
     def is_partially_satisfied(self, offer, basket):
167
     def is_partially_satisfied(self, offer, basket):
167
-        return 0 < self._get_num_covered_products(basket) < self.value
168
+        return 0 < self._get_num_covered_products(basket, offer) < self.value
168
 
169
 
169
     def consume_items(self, offer, basket, affected_lines):
170
     def consume_items(self, offer, basket, affected_lines):
170
         """
171
         """
187
                 continue
188
                 continue
188
             if product in consumed_products:
189
             if product in consumed_products:
189
                 continue
190
                 continue
190
-            if not line.is_available_for_discount:
191
+            if not line.is_available_for_offer_discount(offer):
191
                 continue
192
                 continue
192
             # Only consume a quantity of 1 from each line
193
             # Only consume a quantity of 1 from each line
193
-            line.consume(1)
194
+            line.consume(1, offer=offer)
194
             consumed_products.append(product)
195
             consumed_products.append(product)
195
             to_consume -= 1
196
             to_consume -= 1
196
             if to_consume == 0:
197
             if to_consume == 0:
241
         value_of_matches = D('0.00')
242
         value_of_matches = D('0.00')
242
         for line in basket.all_lines():
243
         for line in basket.all_lines():
243
             if (self.can_apply_condition(line) and
244
             if (self.can_apply_condition(line) and
244
-                    line.quantity_without_discount > 0):
245
+                    line.quantity_without_offer_discount(offer) > 0):
245
                 price = utils.unit_price(offer, line)
246
                 price = utils.unit_price(offer, line)
246
-                value_of_matches += price * int(line.quantity_without_discount)
247
+                value_of_matches += price * int(
248
+                    line.quantity_without_offer_discount(offer)
249
+                )
247
             if value_of_matches >= self.value:
250
             if value_of_matches >= self.value:
248
                 return True
251
                 return True
249
         return False
252
         return False
254
         value_of_matches = D('0.00')
257
         value_of_matches = D('0.00')
255
         for line in basket.all_lines():
258
         for line in basket.all_lines():
256
             if (self.can_apply_condition(line) and
259
             if (self.can_apply_condition(line) and
257
-                    line.quantity_without_discount > 0):
260
+                    line.quantity_without_offer_discount(offer) > 0):
258
                 price = utils.unit_price(offer, line)
261
                 price = utils.unit_price(offer, line)
259
-                value_of_matches += price * int(line.quantity_without_discount)
262
+                value_of_matches += price * int(
263
+                    line.quantity_without_offer_discount(offer)
264
+                )
260
         self._value_of_matches = value_of_matches
265
         self._value_of_matches = value_of_matches
261
         return value_of_matches
266
         return value_of_matches
262
 
267
 
291
         for price, line in self.get_applicable_lines(
296
         for price, line in self.get_applicable_lines(
292
                 offer, basket, most_expensive_first=True):
297
                 offer, basket, most_expensive_first=True):
293
             quantity_to_consume = min(
298
             quantity_to_consume = min(
294
-                line.quantity_without_discount,
299
+                line.quantity_without_offer_discount(offer),
295
                 (to_consume / price).quantize(D(1), ROUND_UP))
300
                 (to_consume / price).quantize(D(1), ROUND_UP))
296
-            line.consume(quantity_to_consume)
301
+            line.consume(quantity_to_consume, offer=offer)
297
             to_consume -= price * quantity_to_consume
302
             to_consume -= price * quantity_to_consume
298
             if to_consume <= 0:
303
             if to_consume <= 0:
299
                 break
304
                 break

+ 20
- 0
src/oscar/apps/offer/migrations/0007_conditionaloffer_exclusive.py View File

1
+# -*- coding: utf-8 -*-
2
+# Generated by Django 1.10.6 on 2017-07-04 07:59
3
+from __future__ import unicode_literals
4
+
5
+from django.db import migrations, models
6
+
7
+
8
+class Migration(migrations.Migration):
9
+
10
+    dependencies = [
11
+        ('offer', '0006_auto_20170504_0616'),
12
+    ]
13
+
14
+    operations = [
15
+        migrations.AddField(
16
+            model_name='conditionaloffer',
17
+            name='exclusive',
18
+            field=models.BooleanField(default=True, help_text='Exclusive offers cannot be combined on the same items', verbose_name='Exclusive offer'),
19
+        ),
20
+    ]

+ 90
- 0
tests/integration/basket/test_utils.py View File

1
+
2
+import pytest
3
+from oscar.test.factories import (
4
+    BasketFactory, ConditionalOfferFactory, ProductFactory)
5
+
6
+
7
+@pytest.fixture
8
+def filled_basket():
9
+    basket = BasketFactory()
10
+    product1 = ProductFactory()
11
+    product2 = ProductFactory()
12
+    basket.add_product(product1, quantity=10)
13
+    basket.add_product(product2, quantity=20)
14
+    return basket
15
+
16
+
17
+@pytest.mark.django_db
18
+class TestLineOfferConsumer(object):
19
+
20
+    def test_consumed_no_offer(self, filled_basket):
21
+        for line in filled_basket.all_lines():
22
+            assert line.consumer.consumed() == 0
23
+
24
+    def test_consumed_with_offer(self, filled_basket):
25
+        offer1 = ConditionalOfferFactory(name='offer1')
26
+        offer2 = ConditionalOfferFactory(name='offer2')
27
+        offer1.exclusive = False
28
+        offer2.exclusive = False
29
+
30
+        for line in filled_basket.all_lines():
31
+            assert line.consumer.consumed(offer1) == 0
32
+            assert line.consumer.consumed(offer2) == 0
33
+
34
+        line1 = filled_basket.all_lines()[0]
35
+        line2 = filled_basket.all_lines()[1]
36
+
37
+        line1.consumer.consume(1, offer1)
38
+        assert line1.consumer.consumed() == 1
39
+        assert line1.consumer.consumed(offer1) == 1
40
+        assert line1.consumer.consumed(offer2) == 0
41
+
42
+        line1.consumer.consume(9, offer1)
43
+        assert line1.consumer.consumed() == line1.quantity
44
+        assert line1.consumer.consumed(offer1) == line1.quantity
45
+        assert line1.consumer.consumed(offer2) == 0
46
+
47
+        line1.consumer.consume(99, offer1)
48
+        assert line1.consumer.consumed(offer1) == line1.quantity
49
+        assert line1.consumer.consumed(offer2) == 0
50
+
51
+        line1.consumer.consume(1, offer2)
52
+        line2.consumer.consume(1, offer2)
53
+
54
+        assert line1.consumer.consumed(offer2) == 1
55
+        assert line2.consumer.consumed(offer2) == 1
56
+
57
+    def test_consume(self, filled_basket):
58
+        line = filled_basket.all_lines()[0]
59
+        line.consume(1)
60
+        assert line.quantity_with_discount == 1
61
+        line.consume(99)
62
+        assert line.quantity_with_discount == 10
63
+
64
+    def test_consumed_with_exclusive_offer(self, filled_basket):
65
+        offer1 = ConditionalOfferFactory(name='offer1')
66
+        offer2 = ConditionalOfferFactory(name='offer2')
67
+        offer3 = ConditionalOfferFactory(name='offer3')
68
+        offer1.exclusive = True
69
+        offer2.exclusive = False
70
+        offer3.exclusive = False
71
+
72
+        for line in filled_basket.all_lines():
73
+            assert line.consumer.consumed(offer1) == 0
74
+            assert line.consumer.consumed(offer2) == 0
75
+
76
+        line1 = filled_basket.all_lines()[0]
77
+        line2 = filled_basket.all_lines()[1]
78
+
79
+        line1.consumer.consume(1, offer1)
80
+        assert line1.is_available_for_offer_discount(offer2) is True
81
+
82
+        line1.consumer.consume(99, offer1)
83
+        assert line1.is_available_for_offer_discount(offer2) is False
84
+
85
+        line2.consumer.consume(1, offer2)
86
+        assert line2.is_available_for_offer_discount(offer1) is True
87
+
88
+        line2.consumer.consume(99, offer2)
89
+        assert line2.is_available_for_offer_discount(offer1) is False
90
+        assert line2.is_available_for_offer_discount(offer3) is True

Loading…
Cancel
Save