Kaynağa Gözat

Merge pull request #4138 from django-oscar/register-discounts

Register discounts per line
master
Samar Hassan 1 yıl önce
ebeveyn
işleme
352119466f
No account linked to committer's email address

+ 1
- 2
docs/source/releases/v1.0.rst Dosyayı Görüntüle

@@ -552,8 +552,7 @@ Misc
552 552
 * Support for the ``OSCAR_OFFER_BLACKLIST_PRODUCT`` setting has been removed.
553 553
   It was only partially supported: it prevented products from being
554 554
   added to a range, but offers could be applied to the products nonetheless.
555
-  To prevent an offer being applied to a product, use ``is_discountable`` or
556
-  override ``get_is_discountable`` on your product instances.
555
+  To prevent an offer being applied to a product, use ``is_discountable``
557 556
 
558 557
 * ``Category.get_ancestors`` used to return a list of ancestors and would
559 558
   default to include itself. For consistency with get_descendants and to avoid

+ 93
- 46
src/oscar/apps/basket/abstract_models.py Dosyayı Görüntüle

@@ -14,12 +14,13 @@ from django.utils.translation import gettext_lazy as _
14 14
 from oscar.core.compat import AUTH_USER_MODEL
15 15
 from oscar.core.loading import get_class, get_classes
16 16
 from oscar.core.utils import get_default_currency, round_half_up
17
+from oscar.core.decorators import deprecated
17 18
 from oscar.models.fields.slugfield import SlugField
18 19
 from oscar.templatetags.currency_filters import currency
19 20
 
20 21
 OfferApplications = get_class("offer.results", "OfferApplications")
21 22
 Unavailable = get_class("partner.availability", "Unavailable")
22
-LineOfferConsumer = get_class("basket.utils", "LineOfferConsumer")
23
+LineDiscountRegistry = get_class("basket.utils", "LineDiscountRegistry")
23 24
 OpenBasketManager, SavedBasketManager = get_classes(
24 25
     "basket.managers", ["OpenBasketManager", "SavedBasketManager"]
25 26
 )
@@ -726,10 +727,7 @@ class AbstractLine(models.Model):
726 727
 
727 728
     def __init__(self, *args, **kwargs):
728 729
         super().__init__(*args, **kwargs)
729
-        # Instance variables used to persist discount information
730
-        self._discount_excl_tax = D("0.00")
731
-        self._discount_incl_tax = D("0.00")
732
-        self.consumer = LineOfferConsumer(self)
730
+        self.discounts = LineDiscountRegistry(self)
733 731
 
734 732
     class Meta:
735 733
         abstract = True
@@ -740,6 +738,11 @@ class AbstractLine(models.Model):
740 738
         verbose_name = _("Basket line")
741 739
         verbose_name_plural = _("Basket lines")
742 740
 
741
+    @property
742
+    @deprecated
743
+    def consumer(self):
744
+        return self.discounts
745
+
743 746
     def __str__(self):
744 747
         return _(
745 748
             "Basket #%(basket_id)d, Product #%(product_id)d, quantity %(quantity)d"
@@ -764,29 +767,25 @@ class AbstractLine(models.Model):
764 767
         """
765 768
         Remove any discounts from this line.
766 769
         """
767
-        self._discount_excl_tax = D("0.00")
768
-        self._discount_incl_tax = D("0.00")
769
-        self.consumer = LineOfferConsumer(self)
770
+        self.discounts = LineDiscountRegistry(self)
770 771
 
771 772
     def discount(self, discount_value, affected_quantity, incl_tax=True, offer=None):
772 773
         """
773 774
         Apply a discount to this line
774 775
         """
775 776
         if incl_tax:
776
-            if self._discount_excl_tax > 0:
777
+            if self.discounts.excl_tax > 0:
777 778
                 raise RuntimeError(
778 779
                     "Attempting to discount the tax-inclusive price of a line "
779 780
                     "when tax-exclusive discounts are already applied"
780 781
                 )
781
-            self._discount_incl_tax += discount_value
782 782
         else:
783
-            if self._discount_incl_tax > 0:
783
+            if self.discounts.incl_tax > 0:
784 784
                 raise RuntimeError(
785 785
                     "Attempting to discount the tax-exclusive price of a line "
786 786
                     "when tax-inclusive discounts are already applied"
787 787
                 )
788
-            self._discount_excl_tax += discount_value
789
-        self.consume(affected_quantity, offer=offer)
788
+        self.discounts.discount(discount_value, affected_quantity, incl_tax, offer)
790 789
 
791 790
     def consume(self, quantity, offer=None):
792 791
         """
@@ -794,7 +793,7 @@ class AbstractLine(models.Model):
794 793
 
795 794
         Consumed items are no longer available to be used in offers.
796 795
         """
797
-        return self.consumer.consume(quantity, offer=offer)
796
+        return self.discounts.consume(quantity, offer=offer)
798 797
 
799 798
     def get_price_breakdown(self):
800 799
         """
@@ -815,14 +814,16 @@ class AbstractLine(models.Model):
815 814
         else:
816 815
             # Need to split the discount among the affected quantity
817 816
             # of products.
818
-            item_incl_tax_discount = self.discount_value / int(self.consumer.consumed())
817
+            item_incl_tax_discount = self.discount_value / int(
818
+                self.discounts.num_consumed()
819
+            )
819 820
             item_excl_tax_discount = item_incl_tax_discount * self._tax_ratio
820 821
             item_excl_tax_discount = round_half_up(item_excl_tax_discount)
821 822
             prices.append(
822 823
                 (
823 824
                     self.unit_price_incl_tax - item_incl_tax_discount,
824 825
                     self.unit_price_excl_tax - item_excl_tax_discount,
825
-                    self.consumer.consumed(),
826
+                    self.discounts.num_consumed(),
826 827
                 )
827 828
             )
828 829
             if self.quantity_without_discount:
@@ -841,6 +842,14 @@ class AbstractLine(models.Model):
841 842
 
842 843
     @property
843 844
     def _tax_ratio(self):
845
+        # this function tries to computate the tax ratio based on the incl tax price
846
+        # versus the excl tax price. Since these values are allready rounded, this will
847
+        # NOT return the exact ratio corresponding to your tax rate.
848
+        # if this is a problem you need to provide your own implementation of _tax_ratio
849
+        # that returns the ratio based on the exact tax percentage in use.
850
+        # one way to make this value correct is to use 4 decimals for all prices everywhere,
851
+        # and round only at the last moment when presenting the values to the user.
852
+        # that would make this value precise and correct because there would be no rounding
844 853
         if not self.unit_price_incl_tax:
845 854
             return 0
846 855
         return self.unit_price_excl_tax / self.unit_price_incl_tax
@@ -850,16 +859,16 @@ class AbstractLine(models.Model):
850 859
     # ===============
851 860
 
852 861
     def has_offer_discount(self, offer):
853
-        return self.consumer.consumed(offer) > 0
862
+        return self.discounts.num_consumed(offer) > 0
854 863
 
855 864
     def quantity_with_offer_discount(self, offer):
856
-        return self.consumer.consumed(offer)
865
+        return self.discounts.num_consumed(offer)
857 866
 
858 867
     def quantity_without_offer_discount(self, offer):
859
-        return self.consumer.available(offer)
868
+        return self.discounts.available(offer)
860 869
 
861 870
     def is_available_for_offer_discount(self, offer):
862
-        return self.consumer.available(offer) > 0
871
+        return self.discounts.available(offer) > 0
863 872
 
864 873
     def quantity_available_for_offer(self, offer):
865 874
         return self.quantity_without_offer_discount(
@@ -870,22 +879,39 @@ class AbstractLine(models.Model):
870 879
     # Properties
871 880
     # ==========
872 881
 
882
+    @property
883
+    @deprecated
884
+    def _discount_incl_tax(self):
885
+        return self.discounts.incl_tax
886
+
887
+    @_discount_incl_tax.setter
888
+    def _discount_incl_tax(self, value):
889
+        raise Exception("You can not set _discount_incl_tax")
890
+
891
+    @property
892
+    @deprecated
893
+    def _discount_excl_tax(self):
894
+        return self.discounts.excl_tax
895
+
896
+    @_discount_excl_tax.setter
897
+    def _discount_excl_tax(self, value):
898
+        raise Exception("You can not set _discount_excl_tax")
899
+
873 900
     @property
874 901
     def has_discount(self):
875
-        return bool(self.consumer.consumed())
902
+        return bool(self.discounts.num_consumed())
876 903
 
877 904
     @property
878 905
     def quantity_with_discount(self):
879
-        return self.consumer.consumed()
906
+        return self.discounts.num_consumed()
880 907
 
881 908
     @property
882 909
     def quantity_without_discount(self):
883
-        return self.consumer.available()
910
+        return self.discounts.available()
884 911
 
885 912
     @property
886 913
     def discount_value(self):
887
-        # Only one of the incl- and excl- discounts should be non-zero
888
-        return max(self._discount_incl_tax, self._discount_excl_tax)
914
+        return self.discounts.total
889 915
 
890 916
     # pylint: disable=W0201
891 917
     @property
@@ -928,34 +954,55 @@ class AbstractLine(models.Model):
928 954
 
929 955
     @property
930 956
     def line_price_excl_tax_incl_discounts(self):
931
-        if self._discount_excl_tax and self.line_price_excl_tax is not None:
932
-            return max(0, self.line_price_excl_tax - self._discount_excl_tax)
933
-        if self._discount_incl_tax and self.line_price_incl_tax is not None:
934
-            # This is a tricky situation.  We know the discount as calculated
935
-            # against tax inclusive prices but we need to guess how much of the
936
-            # discount applies to tax-exclusive prices.  We do this by
937
-            # assuming a linear tax and scaling down the original discount.
938
-            return max(
939
-                0,
940
-                self.line_price_excl_tax
941
-                - round_half_up(self._tax_ratio * self._discount_incl_tax),
942
-            )
957
+        if self.line_price_excl_tax is None:
958
+            return None
959
+
960
+        excl_tax_discounts = self.discounts.excl_tax
961
+        if excl_tax_discounts:
962
+            # these are discounts that return a value excluding tax, we can simply
963
+            # subtract this value from line_price_excl_tax to get to line_price_excl_tax_incl_discounts
964
+            return max(0, self.line_price_excl_tax - excl_tax_discounts)
965
+
966
+        # This is a tricky situation.  We know the discount as calculated
967
+        # against tax inclusive prices but we need to guess how much of the
968
+        # discount applies to tax-exclusive prices.  We do this by
969
+        # assuming a linear tax and scaling down the original discount.
970
+        # Please refer to the _tax_ratio method for more details on how
971
+        # to make this calculation more precise.
972
+
973
+        incl_tax_discounts = self.discounts.incl_tax
974
+        if incl_tax_discounts and self._tax_ratio:
975
+            if self.line_price_excl_tax is not None:
976
+                # if we got a precise line_price_excl_tax use that first, if _tax_ratio is off,
977
+                # this will create the smallest deviation becaise incl_tax_discounts is usually
978
+                # smaller than line_price_excl_tax
979
+                return max(
980
+                    0,
981
+                    self.line_price_excl_tax
982
+                    - round_half_up(self._tax_ratio * incl_tax_discounts),
983
+                )
984
+            elif self.line_price_incl_tax is not None:
985
+                # when all else fails, compute based on line_price_incl_tax
986
+                return max(
987
+                    0, self._tax_ratio * (self.line_price_incl_tax - incl_tax_discounts)
988
+                )
989
+
990
+        # there are no discounts so just return the line_price_excl_tax
943 991
         return self.line_price_excl_tax
944 992
 
945 993
     @property
946 994
     def line_price_incl_tax_incl_discounts(self):
995
+        excl_tax_discounts = self.discounts.excl_tax
996
+        incl_tax_discounts = self.discounts.incl_tax
997
+
947 998
         # We use whichever discount value is set.  If the discount value was
948 999
         # calculated against the tax-exclusive prices, then the line price
949 1000
         # including tax
950
-        if self.line_price_incl_tax is not None and self._discount_incl_tax:
951
-            return max(0, self.line_price_incl_tax - self._discount_incl_tax)
952
-        elif self.line_price_excl_tax is not None and self._discount_excl_tax:
953
-            return max(
954
-                0,
955
-                round_half_up(
956
-                    (self.line_price_excl_tax - self._discount_excl_tax)
957
-                    / self._tax_ratio
958
-                ),
1001
+        if self.line_price_incl_tax is not None and incl_tax_discounts:
1002
+            return max(0, self.line_price_incl_tax - incl_tax_discounts)
1003
+        elif self.line_price_excl_tax is not None and excl_tax_discounts:
1004
+            return round_half_up(
1005
+                self.line_price_excl_tax_incl_discounts / self._tax_ratio
959 1006
             )
960 1007
 
961 1008
         return self.line_price_incl_tax

+ 57
- 4
src/oscar/apps/basket/utils.py Dosyayı Görüntüle

@@ -1,13 +1,18 @@
1
-from collections import defaultdict
1
+from collections import defaultdict, namedtuple
2 2
 
3 3
 from django.contrib import messages
4 4
 from django.template.loader import render_to_string
5 5
 
6 6
 from oscar.core.loading import get_class, get_model
7
+from oscar.core.decorators import deprecated
7 8
 
8 9
 Applicator = get_class("offer.applicator", "Applicator")
9 10
 ConditionalOffer = get_model("offer", "ConditionalOffer")
10 11
 
12
+DiscountApplication = namedtuple(
13
+    "DiscountApplication", ["amount", "quantity", "incl_tax", "offer"]
14
+)
15
+
11 16
 
12 17
 class BasketMessageGenerator(object):
13 18
     new_total_template_name = "oscar/basket/messages/new_total.html"
@@ -125,7 +130,11 @@ class LineOfferConsumer(object):
125 130
             self._consumptions[offer.pk] += num_consumed
126 131
         return num_consumed
127 132
 
133
+    @deprecated
128 134
     def consumed(self, offer=None):
135
+        return self.num_consumed(offer)
136
+
137
+    def num_consumed(self, offer=None):
129 138
         """
130 139
         check how many items on this line have been
131 140
         consumed by an offer
@@ -145,7 +154,7 @@ class LineOfferConsumer(object):
145 154
 
146 155
     @property
147 156
     def consumers(self):
148
-        return [x for x in self._offers.values() if self.consumed(x)]
157
+        return [x for x in self._offers.values() if self.num_consumed(x)]
149 158
 
150 159
     def available(self, offer=None) -> int:
151 160
         """
@@ -172,7 +181,9 @@ class LineOfferConsumer(object):
172 181
                         ):
173 182
                             # Exclusive offers cannot be applied if any other exclusive
174 183
                             # offer with higher priority is active already.
175
-                            max_affected_items = max_affected_items - self.consumed(a)
184
+                            max_affected_items = max_affected_items - self.num_consumed(
185
+                                a
186
+                            )
176 187
                             if max_affected_items == 0:
177 188
                                 return 0
178 189
 
@@ -191,4 +202,46 @@ class LineOfferConsumer(object):
191 202
                 if check and offer not in x.combined_offers:
192 203
                     return 0
193 204
 
194
-        return max_affected_items - self.consumed(offer)
205
+        return max_affected_items - self.num_consumed(offer)
206
+
207
+
208
+class LineDiscountRegistry(LineOfferConsumer):
209
+    def __init__(self, line):
210
+        super().__init__(line)
211
+        self._discounts = []
212
+        self._discount_excl_tax = None
213
+        self._discount_incl_tax = None
214
+
215
+    def discount(self, amount, quantity, incl_tax=True, offer=None):
216
+        self._discounts.append(DiscountApplication(amount, quantity, incl_tax, offer))
217
+        self.consume(quantity, offer=offer)
218
+        if incl_tax:
219
+            self._discount_incl_tax = None
220
+        else:
221
+            self._discount_excl_tax = None
222
+
223
+    @property
224
+    def excl_tax(self):
225
+        if self._discount_excl_tax is None:
226
+            self._discount_excl_tax = sum(
227
+                [d.amount for d in self._discounts if not d.incl_tax], 0
228
+            )
229
+        return self._discount_excl_tax
230
+
231
+    @property
232
+    def incl_tax(self):
233
+        if self._discount_incl_tax is None:
234
+            self._discount_incl_tax = sum(
235
+                [d.amount for d in self._discounts if d.incl_tax], 0
236
+            )
237
+        return self._discount_incl_tax
238
+
239
+    @property
240
+    def total(self):
241
+        return sum([d.amount for d in self._discounts], 0)
242
+
243
+    def all(self):
244
+        return self._discounts
245
+
246
+    def __iter__(self):
247
+        return iter(self._discounts)

+ 27
- 0
src/oscar/apps/order/abstract_models.py Dosyayı Görüntüle

@@ -1286,6 +1286,33 @@ class AbstractOrderDiscount(models.Model):
1286 1286
         return self.offer_name or ""
1287 1287
 
1288 1288
 
1289
+class AbstractOrderLineDiscount(models.Model):
1290
+    line = models.ForeignKey(
1291
+        "order.Line",
1292
+        on_delete=models.CASCADE,
1293
+        related_name="discounts",
1294
+        verbose_name=_("Line"),
1295
+    )
1296
+    order_discount = models.ForeignKey(
1297
+        "order.OrderDiscount",
1298
+        on_delete=models.CASCADE,
1299
+        related_name="discount_lines",
1300
+        verbose_name=_("Order discount"),
1301
+    )
1302
+
1303
+    is_incl_tax = models.BooleanField()
1304
+    amount = models.DecimalField(
1305
+        _("Line discount (excl. tax)"), decimal_places=2, max_digits=12, default=0
1306
+    )
1307
+
1308
+    class Meta:
1309
+        abstract = True
1310
+        app_label = "order"
1311
+        ordering = ["pk"]
1312
+        verbose_name = _("Order line discount")
1313
+        verbose_name_plural = _("Order line discounts")
1314
+
1315
+
1289 1316
 class AbstractSurcharge(models.Model):
1290 1317
     order = models.ForeignKey(
1291 1318
         "order.Order",

+ 66
- 0
src/oscar/apps/order/migrations/0015_orderlinediscount.py Dosyayı Görüntüle

@@ -0,0 +1,66 @@
1
+# Generated by Django 4.2.2 on 2023-07-19 13:16
2
+
3
+from django.db import migrations, models
4
+import django.db.models.deletion
5
+
6
+from django.utils.module_loading import import_string
7
+from django.conf import settings
8
+
9
+models_AutoField = import_string(settings.DEFAULT_AUTO_FIELD)
10
+
11
+
12
+class Migration(migrations.Migration):
13
+    dependencies = [
14
+        ("order", "0014_tax_code"),
15
+    ]
16
+
17
+    operations = [
18
+        migrations.CreateModel(
19
+            name="OrderLineDiscount",
20
+            fields=[
21
+                (
22
+                    "id",
23
+                    models_AutoField(
24
+                        auto_created=True,
25
+                        primary_key=True,
26
+                        serialize=False,
27
+                        verbose_name="ID",
28
+                    ),
29
+                ),
30
+                ("is_incl_tax", models.BooleanField()),
31
+                (
32
+                    "amount",
33
+                    models.DecimalField(
34
+                        decimal_places=2,
35
+                        default=0,
36
+                        max_digits=12,
37
+                        verbose_name="Line discount (excl. tax)",
38
+                    ),
39
+                ),
40
+                (
41
+                    "line",
42
+                    models.ForeignKey(
43
+                        on_delete=django.db.models.deletion.CASCADE,
44
+                        related_name="discounts",
45
+                        to="order.line",
46
+                        verbose_name="Line",
47
+                    ),
48
+                ),
49
+                (
50
+                    "order_discount",
51
+                    models.ForeignKey(
52
+                        on_delete=django.db.models.deletion.CASCADE,
53
+                        related_name="discount_lines",
54
+                        to="order.orderdiscount",
55
+                        verbose_name="Order discount",
56
+                    ),
57
+                ),
58
+            ],
59
+            options={
60
+                "verbose_name": "Order line discount",
61
+                "verbose_name_plural": "Order line discounts",
62
+                "ordering": ["pk"],
63
+                "abstract": False,
64
+            },
65
+        ),
66
+    ]

+ 9
- 0
src/oscar/apps/order/models.py Dosyayı Görüntüle

@@ -120,6 +120,15 @@ if not is_model_registered("order", "OrderDiscount"):
120 120
 
121 121
     __all__.append("OrderDiscount")
122 122
 
123
+
124
+if not is_model_registered("order", "OrderLineDiscount"):
125
+
126
+    class OrderLineDiscount(AbstractOrderLineDiscount):
127
+        pass
128
+
129
+    __all__.append("OrderDiscount")
130
+
131
+
123 132
 if not is_model_registered("order", "Surcharge"):
124 133
 
125 134
     class Surcharge(AbstractSurcharge):

+ 18
- 4
src/oscar/apps/order/utils.py Dosyayı Görüntüle

@@ -14,6 +14,7 @@ from . import exceptions
14 14
 Order = get_model("order", "Order")
15 15
 Line = get_model("order", "Line")
16 16
 OrderDiscount = get_model("order", "OrderDiscount")
17
+OrderLineDiscount = get_model("order", "OrderLineDiscount")
17 18
 CommunicationEvent = get_model("order", "CommunicationEvent")
18 19
 CommunicationEventType = get_model("communication", "CommunicationEventType")
19 20
 Dispatcher = get_class("communication.utils", "Dispatcher")
@@ -88,10 +89,6 @@ class OrderCreator(object):
88 89
                 request,
89 90
                 **kwargs
90 91
             )
91
-            for line in basket.all_lines():
92
-                self.create_line_models(order, line)
93
-                self.update_stock_records(line)
94
-
95 92
             for voucher in basket.vouchers.select_for_update():
96 93
                 if not voucher.is_active():  # basket ignores inactive vouchers
97 94
                     basket.vouchers.remove(voucher)
@@ -123,6 +120,10 @@ class OrderCreator(object):
123 120
             for voucher in basket.vouchers.all():
124 121
                 self.record_voucher_usage(order, voucher, user)
125 122
 
123
+            for line in basket.all_lines():
124
+                self.create_line_models(order, line)
125
+                self.update_stock_records(line)
126
+
126 127
         # Send signal for analytics to pick up
127 128
         order_placed.send(sender=self, order=order, user=user)
128 129
 
@@ -232,6 +233,7 @@ class OrderCreator(object):
232 233
         order_line = Line._default_manager.create(**line_data)
233 234
         self.create_line_price_models(order, order_line, basket_line)
234 235
         self.create_line_attributes(order, order_line, basket_line)
236
+        self.create_line_discount_models(order, order_line, basket_line)
235 237
         self.create_additional_line_models(order, order_line, basket_line)
236 238
 
237 239
         return order_line
@@ -243,6 +245,18 @@ class OrderCreator(object):
243 245
         if line.product.get_product_class().track_stock:
244 246
             line.stockrecord.allocate(line.quantity)
245 247
 
248
+    def create_line_discount_models(self, order, order_line, basket_line):
249
+        for discount in basket_line.discounts:
250
+            order_discount = order.discounts.filter(offer_id=discount.offer.id).first()
251
+            # If we are unable to find the discount we do not care, the total amount is still saved on the discount model,
252
+            # these models are only created so we know how much discount was given per line for each offer
253
+            if order_discount:
254
+                order_line.discounts.create(
255
+                    order_discount=order_discount,
256
+                    is_incl_tax=discount.incl_tax,
257
+                    amount=discount.amount,
258
+                )
259
+
246 260
     def create_additional_line_models(self, order, order_line, basket_line):
247 261
         """
248 262
         Empty method designed to be overridden.

+ 3
- 0
src/oscar/apps/partner/prices.py Dosyayı Görüntüle

@@ -1,4 +1,5 @@
1 1
 from oscar.core import prices
2
+from django.conf import settings
2 3
 
3 4
 
4 5
 class Base(object):
@@ -21,6 +22,8 @@ class Base(object):
21 22
     #: Price to use for offer calculations
22 23
     @property
23 24
     def effective_price(self):
25
+        if settings.OSCAR_OFFERS_INCL_TAX:
26
+            return self.incl_tax
24 27
         # Default to using the price excluding tax for calculations
25 28
         return self.excl_tax
26 29
 

+ 2
- 2
src/oscar/templates/oscar/dashboard/ranges/range_product_list.html Dosyayı Görüntüle

@@ -132,7 +132,7 @@
132 132
                     </td>
133 133
                     <td>{{ product.upc|default:"-" }}</td>
134 134
                     <td><a href="{% url 'dashboard:catalogue-product' pk=product.id %}">{{ product.get_title }}</a></td>
135
-                    <td>{% if product.get_is_discountable %}{% trans "Yes" %}{% else %}{% trans "No" %}{% endif %}</td>
135
+                    <td>{% if product.is_discountable %}{% trans "Yes" %}{% else %}{% trans "No" %}{% endif %}</td>
136 136
                     <td>
137 137
                       <a class="btn btn-danger" href="#" data-behaviours="remove">{% trans "Remove" %}</a>
138 138
                       {% if range.is_reorderable %}
@@ -242,7 +242,7 @@
242 242
                       </td>
243 243
                       <td>{{ product.upc|default:"-" }}</td>
244 244
                       <td><a href="{% url 'dashboard:catalogue-product' pk=product.id %}">{{ product.get_title }}</a></td>
245
-                      <td>{% if product.get_is_discountable %}{% trans "Yes" %}{% else %}{% trans "No" %}{% endif %}</td>
245
+                      <td>{% if product.is_discountable %}{% trans "Yes" %}{% else %}{% trans "No" %}{% endif %}</td>
246 246
                       <td><a class="btn btn-danger" href="#" data-behaviours="remove">{% trans "Remove" %}</a></td>
247 247
                     </tr>
248 248
                   {% endfor %}

+ 39
- 39
tests/integration/basket/test_utils.py Dosyayı Görüntüle

@@ -60,7 +60,7 @@ def multi_offers():
60 60
 class TestLineOfferConsumer:
61 61
     def test_consumed_no_offer(self, filled_basket):
62 62
         for line in filled_basket.all_lines():
63
-            assert line.consumer.consumed() == 0
63
+            assert line.discounts.num_consumed() == 0
64 64
 
65 65
     def test_available_with_offer(self):
66 66
         basket = BasketFactory()
@@ -78,8 +78,8 @@ class TestLineOfferConsumer:
78 78
 
79 79
         offer1 = ConditionalOfferFactory(name="offer1", benefit=benefit)
80 80
         lines = basket.all_lines()
81
-        assert lines[0].consumer.available(offer1) == 1
82
-        assert lines[1].consumer.available(offer1) == 10
81
+        assert lines[0].discounts.available(offer1) == 1
82
+        assert lines[1].discounts.available(offer1) == 10
83 83
 
84 84
     def test_consumed_with_offer(self, filled_basket):
85 85
         offer1 = ConditionalOfferFactory(name="offer1")
@@ -88,31 +88,31 @@ class TestLineOfferConsumer:
88 88
         offer2.exclusive = False
89 89
 
90 90
         for line in filled_basket.all_lines():
91
-            assert line.consumer.consumed(offer1) == 0
92
-            assert line.consumer.consumed(offer2) == 0
91
+            assert line.discounts.num_consumed(offer1) == 0
92
+            assert line.discounts.num_consumed(offer2) == 0
93 93
 
94 94
         line1 = filled_basket.all_lines()[0]
95 95
         line2 = filled_basket.all_lines()[1]
96 96
 
97
-        line1.consumer.consume(1, offer1)
98
-        assert line1.consumer.consumed() == 1
99
-        assert line1.consumer.consumed(offer1) == 1
100
-        assert line1.consumer.consumed(offer2) == 0
97
+        line1.discounts.consume(1, offer1)
98
+        assert line1.discounts.num_consumed() == 1
99
+        assert line1.discounts.num_consumed(offer1) == 1
100
+        assert line1.discounts.num_consumed(offer2) == 0
101 101
 
102
-        line1.consumer.consume(9, offer1)
103
-        assert line1.consumer.consumed() == line1.quantity
104
-        assert line1.consumer.consumed(offer1) == line1.quantity
105
-        assert line1.consumer.consumed(offer2) == 0
102
+        line1.discounts.consume(9, offer1)
103
+        assert line1.discounts.num_consumed() == line1.quantity
104
+        assert line1.discounts.num_consumed(offer1) == line1.quantity
105
+        assert line1.discounts.num_consumed(offer2) == 0
106 106
 
107
-        line1.consumer.consume(99, offer1)
108
-        assert line1.consumer.consumed(offer1) == line1.quantity
109
-        assert line1.consumer.consumed(offer2) == 0
107
+        line1.discounts.consume(99, offer1)
108
+        assert line1.discounts.num_consumed(offer1) == line1.quantity
109
+        assert line1.discounts.num_consumed(offer2) == 0
110 110
 
111
-        line1.consumer.consume(1, offer2)
112
-        line2.consumer.consume(1, offer2)
111
+        line1.discounts.consume(1, offer2)
112
+        line2.discounts.consume(1, offer2)
113 113
 
114
-        assert line1.consumer.consumed(offer2) == 1
115
-        assert line2.consumer.consumed(offer2) == 1
114
+        assert line1.discounts.num_consumed(offer2) == 1
115
+        assert line2.discounts.num_consumed(offer2) == 1
116 116
 
117 117
     def test_consume(self, filled_basket):
118 118
         line = filled_basket.all_lines()[0]
@@ -130,29 +130,29 @@ class TestLineOfferConsumer:
130 130
         offer3.exclusive = False
131 131
 
132 132
         for line in filled_basket.all_lines():
133
-            assert line.consumer.consumed(offer1) == 0
134
-            assert line.consumer.consumed(offer2) == 0
133
+            assert line.discounts.num_consumed(offer1) == 0
134
+            assert line.discounts.num_consumed(offer2) == 0
135 135
 
136 136
         line1, line2 = list(filled_basket.all_lines())
137 137
 
138 138
         # exclusive offer consumes one item on line1
139
-        line1.consumer.consume(1, offer1)
139
+        line1.discounts.consume(1, offer1)
140 140
 
141 141
         # offer1 is exclusive so that blocks other offers
142 142
         assert line1.is_available_for_offer_discount(offer2) is False
143 143
 
144
-        line1.consumer.consume(99, offer1)
144
+        line1.discounts.consume(99, offer1)
145 145
         # ran out of room for offer1
146 146
         assert line1.is_available_for_offer_discount(offer1) is False
147 147
         # offer2 was never an option
148 148
         assert line1.is_available_for_offer_discount(offer2) is False
149 149
 
150 150
         # exclusivity is per line so line2 is available for offer2
151
-        line2.consumer.consume(1, offer2)
151
+        line2.discounts.consume(1, offer2)
152 152
         # nope: exclusive and non-exclusive don't mix
153 153
         assert line2.is_available_for_offer_discount(offer1) is False
154 154
 
155
-        line2.consumer.consume(99, offer2)
155
+        line2.discounts.consume(99, offer2)
156 156
         # ran out of room for offer2
157 157
         assert line2.is_available_for_offer_discount(offer1) is False
158 158
         # but still room for offer3!
@@ -167,13 +167,13 @@ class TestLineOfferConsumer:
167 167
         offer3.exclusive = False
168 168
 
169 169
         for line in filled_basket.all_lines():
170
-            assert line.consumer.consumed(offer1) == 0
171
-            assert line.consumer.consumed(offer2) == 0
170
+            assert line.discounts.num_consumed(offer1) == 0
171
+            assert line.discounts.num_consumed(offer2) == 0
172 172
 
173 173
         line1, line2 = list(filled_basket.all_lines())
174 174
 
175 175
         # exclusive offer consumes one item on line1
176
-        line1.consumer.consume(1, offer1)
176
+        line1.discounts.consume(1, offer1)
177 177
         remaining1 = line1.quantity - 1
178 178
 
179 179
         assert line1.quantity_with_offer_discount(offer1) == 1
@@ -185,7 +185,7 @@ class TestLineOfferConsumer:
185 185
         assert line1.quantity_without_offer_discount(offer3) == 0
186 186
 
187 187
         # exclusive offer consumes all items on line1
188
-        line1.consumer.consume(remaining1, offer1)
188
+        line1.discounts.consume(remaining1, offer1)
189 189
         assert line1.quantity_with_offer_discount(offer1) == line1.quantity
190 190
         assert line1.quantity_with_offer_discount(offer2) == 0
191 191
         assert line1.quantity_with_offer_discount(offer3) == 0
@@ -195,7 +195,7 @@ class TestLineOfferConsumer:
195 195
         assert line1.quantity_without_offer_discount(offer3) == 0
196 196
 
197 197
         # non-exclusive offer consumes one item on line2
198
-        line2.consumer.consume(1, offer2)
198
+        line2.discounts.consume(1, offer2)
199 199
         remaining2 = line2.quantity - 1
200 200
 
201 201
         assert line2.quantity_with_offer_discount(offer1) == 0
@@ -207,7 +207,7 @@ class TestLineOfferConsumer:
207 207
         assert line2.quantity_without_offer_discount(offer3) == line2.quantity
208 208
 
209 209
         # non-exclusive offer consumes all items on line2
210
-        line2.consumer.consume(remaining2, offer2)
210
+        line2.discounts.consume(remaining2, offer2)
211 211
 
212 212
         assert line2.quantity_with_offer_discount(offer1) == 0
213 213
         assert line2.quantity_with_offer_discount(offer2) == line2.quantity
@@ -222,7 +222,7 @@ class TestLineOfferConsumer:
222 222
         basket = filled_basket
223 223
         Applicator().apply(basket)
224 224
         assert len(basket.offer_applications.offer_discounts) == 1
225
-        assert [x.consumer.consumed() for x in basket.all_lines()] == [1, 0]
225
+        assert [x.discounts.num_consumed() for x in basket.all_lines()] == [1, 0]
226 226
 
227 227
     def test_apply_multiple_vouchers(self, filled_basket):
228 228
         offer1 = ConditionalOfferFactory(
@@ -264,14 +264,14 @@ class TestLineOfferConsumer:
264 264
         assert offer2 in offer3.combined_offers
265 265
 
266 266
         for line in filled_basket.all_lines():
267
-            assert line.consumer.consumed(offer1) == 0
268
-            assert line.consumer.consumed(offer2) == 0
269
-            assert line.consumer.consumed(offer3) == 0
267
+            assert line.discounts.num_consumed(offer1) == 0
268
+            assert line.discounts.num_consumed(offer2) == 0
269
+            assert line.discounts.num_consumed(offer3) == 0
270 270
 
271 271
         line1 = filled_basket.all_lines()[0]
272 272
 
273 273
         # combinable offer consumes one item of line1
274
-        line1.consumer.consume(1, offer2)
274
+        line1.discounts.consume(1, offer2)
275 275
         remaining1 = line1.quantity - 1
276 276
 
277 277
         assert line1.quantity_with_offer_discount(offer1) == 0
@@ -285,7 +285,7 @@ class TestLineOfferConsumer:
285 285
         assert line1.quantity_without_offer_discount(offer4) == 0
286 286
 
287 287
         # combinable offer consumes one item of line1
288
-        line1.consumer.consume(1, offer3)
288
+        line1.discounts.consume(1, offer3)
289 289
         assert line1.quantity_with_offer_discount(offer1) == 0
290 290
         assert line1.quantity_with_offer_discount(offer2) == 1
291 291
         assert line1.quantity_with_offer_discount(offer3) == 1
@@ -297,7 +297,7 @@ class TestLineOfferConsumer:
297 297
         assert line1.quantity_without_offer_discount(offer4) == 0
298 298
 
299 299
         # combinable offer consumes all items of line1
300
-        line1.consumer.consume(remaining1, offer2)
300
+        line1.discounts.consume(remaining1, offer2)
301 301
 
302 302
         assert line1.quantity_with_offer_discount(offer1) == 0
303 303
         assert line1.quantity_with_offer_discount(offer2) == line1.quantity

+ 10
- 9
tests/integration/management_commands/test_oscar_fork_statics.py Dosyayı Görüntüle

@@ -2,6 +2,7 @@ import io
2 2
 import os
3 3
 import pathlib
4 4
 import tempfile
5
+import shutil
5 6
 
6 7
 from django.core.management import call_command
7 8
 from django.core.management.base import CommandError
@@ -18,7 +19,7 @@ class OscarForkStaticsTestCase(TestCase):
18 19
         self.oscar_static_dir_path = pathlib.Path(
19 20
             os.path.dirname(oscar.__file__), "static"
20 21
         )
21
-        self.oscar_static_dir_path.mkdir()
22
+        self.oscar_static_dir_path.mkdir(exist_ok=True)
22 23
         self.oscar_static_file_path = pathlib.Path(
23 24
             self.oscar_static_dir_path, "name.css"
24 25
         )
@@ -32,7 +33,10 @@ class OscarForkStaticsTestCase(TestCase):
32 33
     def tearDown(self):
33 34
         # Delete dummy Oscar static-files directory
34 35
         self.oscar_static_file_path.unlink()
35
-        self.oscar_static_dir_path.rmdir()
36
+        try:
37
+            self.oscar_static_dir_path.rmdir()
38
+        except OSError:
39
+            pass
36 40
 
37 41
     def test_command_with_already_existing_directory(self):
38 42
         project_static_dir_path = pathlib.Path(self.project_base_dir_path, "static")
@@ -45,7 +49,7 @@ class OscarForkStaticsTestCase(TestCase):
45 49
             "The folder %s already exists - aborting!" % project_static_dir_path,
46 50
         )
47 51
 
48
-        project_static_dir_path.rmdir()
52
+        shutil.rmtree(project_static_dir_path.rmdir(), ignore_errors=True)
49 53
 
50 54
     def test_command_with_default_target_path(self):
51 55
         project_static_dir_path = pathlib.Path(self.project_base_dir_path, "static")
@@ -64,8 +68,7 @@ class OscarForkStaticsTestCase(TestCase):
64 68
             "picked up" % project_static_dir_path,
65 69
         )
66 70
 
67
-        project_static_file_path.unlink()
68
-        project_static_dir_path.rmdir()
71
+        shutil.rmtree(project_static_dir_path)
69 72
 
70 73
     def test_command_with_relative_target_path(self):
71 74
         project_static_dir_path = pathlib.Path(
@@ -86,8 +89,7 @@ class OscarForkStaticsTestCase(TestCase):
86 89
             "picked up" % project_static_dir_path,
87 90
         )
88 91
 
89
-        project_static_file_path.unlink()
90
-        project_static_dir_path.rmdir()
92
+        shutil.rmtree(project_static_dir_path)
91 93
         project_static_dir_path.parent.rmdir()
92 94
 
93 95
     def test_command_with_absolute_target_path(self):
@@ -109,6 +111,5 @@ class OscarForkStaticsTestCase(TestCase):
109 111
             "picked up" % project_static_dir_path,
110 112
         )
111 113
 
112
-        project_static_file_path.unlink()
113
-        project_static_dir_path.rmdir()
114
+        shutil.rmtree(project_static_dir_path)
114 115
         project_static_dir_path.parent.rmdir()

+ 52
- 0
tests/integration/order/test_creator.py Dosyayı Görüntüle

@@ -13,6 +13,7 @@ from django.utils import timezone
13 13
 from oscar.apps.catalogue.models import Product, ProductClass
14 14
 from oscar.apps.checkout import calculators
15 15
 from oscar.apps.offer.utils import Applicator
16
+from oscar.apps.offer import models
16 17
 from oscar.apps.order.models import Order
17 18
 from oscar.apps.order.utils import OrderCreator
18 19
 from oscar.apps.shipping.methods import FixedPrice, Free
@@ -27,6 +28,7 @@ Range = get_class("offer.models", "Range")
27 28
 Benefit = get_class("offer.models", "Benefit")
28 29
 
29 30
 SurchargeApplicator = get_class("checkout.applicator", "SurchargeApplicator")
31
+UK = get_class("partner.strategy", "UK")
30 32
 
31 33
 
32 34
 def place_order(creator, **kwargs):
@@ -263,6 +265,56 @@ class TestShippingOfferForOrder(TestCase):
263 265
         self.assertEqual(D("34.00"), order.total_incl_tax)
264 266
 
265 267
 
268
+class TestOrderOfferCreation(TestCase):
269
+    def setUp(self):
270
+        self.creator = OrderCreator()
271
+        self.basket = factories.create_basket(empty=True)
272
+        self.basket.strategy = UK()
273
+        self.surcharges = SurchargeApplicator().get_applicable_surcharges(self.basket)
274
+        product_range = Range.objects.create(
275
+            name="All products range", includes_all_products=True
276
+        )
277
+        condition = models.CountCondition.objects.create(
278
+            range=product_range, type=models.Condition.COUNT, value=1
279
+        )
280
+        benefit = models.PercentageDiscountBenefit.objects.create(
281
+            range=product_range,
282
+            type=models.Benefit.PERCENTAGE,
283
+            value=20,
284
+        )
285
+        self.offer = models.ConditionalOffer(
286
+            name="Test",
287
+            offer_type=models.ConditionalOffer.SITE,
288
+            condition=condition,
289
+            benefit=benefit,
290
+        )
291
+        self.offer.save()
292
+        self.applicator = Applicator()
293
+
294
+    def test_multi_lines_discount(self):
295
+        add_product(self.basket, D(10))
296
+        add_product(self.basket, D(20))
297
+
298
+        self.applicator.apply_offers(self.basket, [self.offer])
299
+
300
+        place_order(
301
+            self.creator,
302
+            surcharges=self.surcharges,
303
+            basket=self.basket,
304
+            order_number="klatsieknoekie666",
305
+        )
306
+
307
+        order = Order.objects.get(number="klatsieknoekie666")
308
+
309
+        discount = order.discounts.first()
310
+        self.assertEqual(discount.amount, D("7.20"))
311
+        self.assertEqual(discount.discount_lines.count(), 2)
312
+        self.assertEqual(
313
+            discount.amount,
314
+            sum([discount.amount for discount in discount.discount_lines.all()]),
315
+        )
316
+
317
+
266 318
 class TestMultiSiteOrderCreation(TestCase):
267 319
     def setUp(self):
268 320
         self.creator = OrderCreator()

Loading…
İptal
Kaydet