Pārlūkot izejas kodu

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 gadus atpakaļ
vecāks
revīzija
78823ca044

+ 0
- 1
docs/source/releases/v1.5.rst Parādīt failu

@@ -150,7 +150,6 @@ Backwards incompatible changes in Oscar 1.5
150 150
  - ``oscar.forms.fields.ExtendedURLField`` no longer accepts a ``verify_exists``
151 151
    argument.
152 152
 
153
-
154 153
 Dependency changes
155 154
 ------------------
156 155
 

+ 60
- 0
docs/source/releases/v1.6.rst Parādīt failu

@@ -0,0 +1,60 @@
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 Parādīt failu

@@ -18,6 +18,7 @@ from oscar.models.fields.slugfield import SlugField
18 18
 from oscar.templatetags.currency_filters import currency
19 19
 
20 20
 Unavailable = get_class('partner.availability', 'Unavailable')
21
+LineOfferConsumer = get_class('basket.utils', 'LineOfferConsumer')
21 22
 
22 23
 
23 24
 @python_2_unicode_compatible
@@ -634,7 +635,7 @@ class AbstractLine(models.Model):
634 635
         # Instance variables used to persist discount information
635 636
         self._discount_excl_tax = D('0.00')
636 637
         self._discount_incl_tax = D('0.00')
637
-        self._affected_quantity = 0
638
+        self.consumer = LineOfferConsumer(self)
638 639
 
639 640
     class Meta:
640 641
         abstract = True
@@ -669,9 +670,10 @@ class AbstractLine(models.Model):
669 670
         """
670 671
         self._discount_excl_tax = D('0.00')
671 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 678
         Apply a discount to this line
677 679
         """
@@ -687,19 +689,15 @@ class AbstractLine(models.Model):
687 689
                     "Attempting to discount the tax-exclusive price of a line "
688 690
                     "when tax-inclusive discounts are already applied")
689 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 696
         Mark all or part of the line as 'consumed'
695 697
 
696 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 702
     def get_price_breakdown(self):
705 703
         """
@@ -719,12 +717,12 @@ class AbstractLine(models.Model):
719 717
             # Need to split the discount among the affected quantity
720 718
             # of products.
721 719
             item_incl_tax_discount = (
722
-                self.discount_value / int(self._affected_quantity))
720
+                self.discount_value / int(self.consumer.consumed()))
723 721
             item_excl_tax_discount = item_incl_tax_discount * self._tax_ratio
724 722
             item_excl_tax_discount = item_excl_tax_discount.quantize(D('0.01'))
725 723
             prices.append((self.unit_price_incl_tax - item_incl_tax_discount,
726 724
                            self.unit_price_excl_tax - item_excl_tax_discount,
727
-                           self._affected_quantity))
725
+                           self.consumer.consumed()))
728 726
             if self.quantity_without_discount:
729 727
                 prices.append((self.unit_price_incl_tax,
730 728
                                self.unit_price_excl_tax,
@@ -741,25 +739,42 @@ class AbstractLine(models.Model):
741 739
             return 0
742 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 759
     # Properties
746 760
     # ==========
747 761
 
748 762
     @property
749 763
     def has_discount(self):
750
-        return self.quantity > self.quantity_without_discount
764
+        return bool(self.consumer.consumed())
751 765
 
752 766
     @property
753 767
     def quantity_with_discount(self):
754
-        return self._affected_quantity
768
+        return self.consumer.consumed()
755 769
 
756 770
     @property
757 771
     def quantity_without_discount(self):
758
-        return int(self.quantity - self._affected_quantity)
772
+        return self.consumer.available()
759 773
 
760 774
     @property
761 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 779
     @property
765 780
     def discount_value(self):

+ 89
- 1
src/oscar/apps/basket/utils.py Parādīt failu

@@ -1,6 +1,7 @@
1
+from collections import defaultdict
2
+
1 3
 from django.contrib import messages
2 4
 from django.template.loader import render_to_string
3
-
4 5
 from oscar.core.loading import get_class
5 6
 
6 7
 Applicator = get_class('offer.applicator', 'Applicator')
@@ -62,3 +63,90 @@ class BasketMessageGenerator(object):
62 63
 
63 64
         for level, msg in self.get_messages(request.basket, offers_before, offers_after):
64 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 Parādīt failu

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

+ 4
- 0
src/oscar/apps/dashboard/vouchers/forms.py Parādīt failu

@@ -44,6 +44,10 @@ class VoucherForm(forms.Form):
44 44
     benefit_value = forms.DecimalField(
45 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 51
     def __init__(self, voucher=None, *args, **kwargs):
48 52
         self.voucher = voucher
49 53
         super(VoucherForm, self).__init__(*args, **kwargs)

+ 10
- 0
src/oscar/apps/dashboard/vouchers/views.py Parādīt failu

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

+ 7
- 1
src/oscar/apps/offer/abstract_models.py Parādīt failu

@@ -62,6 +62,12 @@ class AbstractConditionalOffer(models.Model):
62 62
     offer_type = models.CharField(
63 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 71
     # We track a status variable so it's easier to load offers that are
66 72
     # 'available' in some sense.
67 73
     OPEN, SUSPENDED, CONSUMED = "Open", "Suspended", "Consumed"
@@ -650,7 +656,7 @@ class AbstractBenefit(models.Model):
650 656
             if not price:
651 657
                 # Avoid zero price products
652 658
                 continue
653
-            if line.quantity_without_discount == 0:
659
+            if line.quantity_without_offer_discount(offer) == 0:
654 660
                 continue
655 661
             line_tuples.append((price, line))
656 662
 

+ 14
- 12
src/oscar/apps/offer/benefits.py Parādīt failu

@@ -2,7 +2,7 @@ from decimal import Decimal as D
2 2
 
3 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 6
 from oscar.core.loading import get_model
7 7
 from oscar.templatetags.currency_filters import currency
8 8
 
@@ -16,11 +16,11 @@ __all__ = [
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 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 26
 class PercentageDiscountBenefit(Benefit):
@@ -66,8 +66,9 @@ class PercentageDiscountBenefit(Benefit):
66 66
             if discount_amount_available == 0:
67 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 72
             line_discount = self.round(discount_percent / D('100.0') * price
72 73
                                        * int(quantity_affected))
73 74
 
@@ -75,7 +76,7 @@ class PercentageDiscountBenefit(Benefit):
75 76
                 line_discount = min(line_discount, discount_amount_available)
76 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 81
             affected_lines.append((line, line_discount, quantity_affected))
81 82
             affected_items += quantity_affected
@@ -127,8 +128,9 @@ class AbsoluteDiscountBenefit(Benefit):
127 128
         for price, line in line_tuples:
128 129
             if num_affected_items >= max_affected_items:
129 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 134
             lines_to_discount.append((line, price, qty))
133 135
             num_affected_items += qty
134 136
             affected_items_total += qty * price
@@ -155,7 +157,7 @@ class AbsoluteDiscountBenefit(Benefit):
155 157
                 # Calculate a weighted discount for the line
156 158
                 line_discount = self.round(
157 159
                     ((price * qty) / affected_items_total) * discount)
158
-            apply_discount(line, line_discount, qty)
160
+            apply_discount(line, line_discount, qty, offer)
159 161
             affected_lines.append((line, line_discount, qty))
160 162
             applied_discount += line_discount
161 163
 
@@ -210,7 +212,7 @@ class FixedPriceBenefit(Benefit):
210 212
                 quantity_affected = 1
211 213
             else:
212 214
                 quantity_affected = min(
213
-                    line.quantity_without_discount,
215
+                    line.quantity_without_offer_discount(offer),
214 216
                     num_permitted - num_affected)
215 217
             num_affected += quantity_affected
216 218
             value_affected += quantity_affected * price
@@ -232,7 +234,7 @@ class FixedPriceBenefit(Benefit):
232 234
             else:
233 235
                 line_discount = self.round(
234 236
                     discount * (price * quantity) / value_affected)
235
-            apply_discount(line, line_discount, quantity)
237
+            apply_discount(line, line_discount, quantity, offer)
236 238
             discount_applied += line_discount
237 239
         return results.BasketDiscount(discount)
238 240
 
@@ -263,7 +265,7 @@ class MultibuyDiscountBenefit(Benefit):
263 265
 
264 266
         # Cheapest line gives free product
265 267
         discount, line = line_tuples[0]
266
-        apply_discount(line, discount, 1)
268
+        apply_discount(line, discount, 1, offer)
267 269
 
268 270
         affected_lines = [(line, discount, 1)]
269 271
         condition.consume_items(offer, basket, affected_lines)

+ 28
- 23
src/oscar/apps/offer/conditions.py Parādīt failu

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

+ 20
- 0
src/oscar/apps/offer/migrations/0007_conditionaloffer_exclusive.py Parādīt failu

@@ -0,0 +1,20 @@
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 Parādīt failu

@@ -0,0 +1,90 @@
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

Notiek ielāde…
Atcelt
Saglabāt