# pylint: disable=W0621, unused-argument
from decimal import Decimal as D
from django.conf import settings
from django.utils.translation import gettext_lazy as _
from oscar.core.loading import get_class, get_classes, get_model
from oscar.templatetags.currency_filters import currency
Benefit = get_model("offer", "Benefit")
BasketDiscount, SHIPPING_DISCOUNT, ZERO_DISCOUNT = get_classes(
"offer.results", ["BasketDiscount", "SHIPPING_DISCOUNT", "ZERO_DISCOUNT"]
)
CoverageCondition, ValueCondition = get_classes(
"offer.conditions", ["CoverageCondition", "ValueCondition"]
)
range_anchor = get_class("offer.utils", "range_anchor")
__all__ = [
"PercentageDiscountBenefit",
"AbsoluteDiscountBenefit",
"FixedUnitDiscountBenefit",
"FixedPriceBenefit",
"ShippingBenefit",
"MultibuyDiscountBenefit",
"ShippingAbsoluteDiscountBenefit",
"ShippingFixedPriceBenefit",
"ShippingPercentageDiscountBenefit",
]
def apply_discount(line, discount, quantity, offer=None, incl_tax=None):
"""
Apply a given discount to the passed basket
"""
# use OSCAR_OFFERS_INCL_TAX setting if incl_tax is left unspecified.
incl_tax = incl_tax if incl_tax is not None else settings.OSCAR_OFFERS_INCL_TAX
line.discount(discount, quantity, incl_tax=incl_tax, offer=offer)
[docs]class PercentageDiscountBenefit(Benefit):
"""
An offer benefit that gives a percentage discount
"""
_description = _("%(value)s%% discount on %(range)s")
@property
def name(self):
return self._description % {"value": self.value, "range": self.range.name}
@property
def description(self):
return self._description % {
"value": self.value,
"range": range_anchor(self.range),
}
class Meta:
app_label = "offer"
proxy = True
verbose_name = _("Percentage discount benefit")
verbose_name_plural = _("Percentage discount benefits")
# pylint: disable=unused-argument
def apply(
self,
basket,
condition,
offer,
discount_percent=None,
max_total_discount=None,
**kwargs
):
if discount_percent is None:
discount_percent = self.value
discount_amount_available = max_total_discount
line_tuples = self.get_applicable_lines(offer, basket)
discount_percent = min(discount_percent, D("100.0"))
discount = D("0.00")
affected_items = 0
max_affected_items = self._effective_max_affected_items()
for price, line in line_tuples:
affected_items += line.quantity_with_offer_discount(offer)
if affected_items >= max_affected_items:
break
if discount_amount_available == 0:
break
quantity_affected = min(
line.quantity_without_offer_discount(offer),
max_affected_items - affected_items,
)
if quantity_affected <= 0:
break
line_discount = self.round(
discount_percent / D("100.0") * price * int(quantity_affected),
basket.currency,
)
if discount_amount_available is not None:
line_discount = min(line_discount, discount_amount_available)
discount_amount_available -= line_discount
apply_discount(line, line_discount, quantity_affected, offer)
affected_items += quantity_affected
discount += line_discount
return BasketDiscount(discount)
[docs]class AbsoluteDiscountBenefit(Benefit):
"""
An offer benefit that gives an absolute discount
"""
_description = _("%(value)s discount on %(range)s")
@property
def name(self):
return self._description % {
"value": currency(self.value),
"range": self.range.name.lower(),
}
@property
def description(self):
return self._description % {
"value": currency(self.value),
"range": range_anchor(self.range),
}
class Meta:
app_label = "offer"
proxy = True
verbose_name = _("Absolute discount benefit")
verbose_name_plural = _("Absolute discount benefits")
def apply(
self,
basket,
condition,
offer,
discount_amount=None,
max_total_discount=None,
**kwargs
):
if discount_amount is None:
discount_amount = self.value
# Fetch basket lines that are in the range and available to be used in
# an offer.
line_tuples = self.get_applicable_lines(offer, basket)
# Determine which lines can have the discount applied to them
max_affected_items = self._effective_max_affected_items()
num_affected_items = 0
affected_items_total = D("0.00")
lines_to_discount = []
for price, line in line_tuples:
if num_affected_items >= max_affected_items:
break
qty = min(
line.quantity_without_offer_discount(offer),
max_affected_items - num_affected_items,
)
lines_to_discount.append((line, price, qty))
num_affected_items += qty
affected_items_total += qty * price
# Ensure we don't try to apply a discount larger than the total of the
# matching items.
discount = min(discount_amount, affected_items_total)
if max_total_discount is not None:
discount = min(discount, max_total_discount)
if discount == 0:
return ZERO_DISCOUNT
# spreading the discount is a policy decision that may not apply
# Apply discount equally amongst them
applied_discount = D("0.00")
last_line_idx = len(lines_to_discount) - 1
for i, (line, price, qty) in enumerate(lines_to_discount):
if i == last_line_idx:
# If last line, then take the delta as the discount to ensure
# the total discount is correct and doesn't mismatch due to
# rounding.
line_discount = discount - applied_discount
else:
# Calculate a weighted discount for the line
line_discount = self.round(
((price * qty) / affected_items_total) * discount, basket.currency
)
apply_discount(line, line_discount, qty, offer)
applied_discount += line_discount
return BasketDiscount(discount)
[docs]class FixedUnitDiscountBenefit(AbsoluteDiscountBenefit):
"""
An offer benefit that gives an absolute discount on each applicable product.
"""
class Meta:
app_label = "offer"
proxy = True
verbose_name = _("Fixed unit discount benefit")
verbose_name_plural = _("Fixed unit discount benefits")
def get_lines_to_discount(self, offer, line_tuples):
# Determine which lines can have the discount applied to them
max_affected_items = self._effective_max_affected_items()
num_affected_items = 0
lines_to_discount = []
for price, line in line_tuples:
if num_affected_items >= max_affected_items:
break
qty = min(
line.quantity_without_offer_discount(offer),
max_affected_items - num_affected_items,
)
lines_to_discount.append((line, price, qty))
num_affected_items += qty
return lines_to_discount
def apply(
self,
basket,
condition,
offer,
discount_amount=None,
max_total_discount=None,
**kwargs
):
# Fetch basket lines that are in the range and available to be used in an offer.
line_tuples = self.get_applicable_lines(offer, basket)
lines_to_discount = self.get_lines_to_discount(offer, line_tuples)
applied_discount = D("0.00")
for line, price, qty in lines_to_discount:
# If price is less than the fixed discount, then it will be free.
line_discount = min(price * qty, self.value * qty)
apply_discount(line, line_discount, qty, offer)
applied_discount += line_discount
return BasketDiscount(applied_discount)
[docs]class FixedPriceBenefit(Benefit):
"""
An offer benefit that gives the items in the condition for a
fixed price. This is useful for "bundle" offers.
Note that we ignore the benefit range here and only give a fixed price
for the products in the condition range. The condition cannot be a value
condition.
We also ignore the max_affected_items setting.
"""
_description = _("The products that meet the condition are sold for %(amount)s")
@property
def name(self):
return self._description % {"amount": currency(self.value)}
class Meta:
app_label = "offer"
proxy = True
verbose_name = _("Fixed price benefit")
verbose_name_plural = _("Fixed price benefits")
def apply(self, basket, condition, offer, **kwargs):
if isinstance(condition, ValueCondition):
return ZERO_DISCOUNT
# Fetch basket lines that are in the range and available to be used in
# an offer.
line_tuples = self.get_applicable_lines(offer, basket, range=condition.range)
if not line_tuples:
return ZERO_DISCOUNT
# Determine the lines to consume
num_permitted = int(condition.value)
num_affected = 0
value_affected = D("0.00")
covered_lines = []
for price, line in line_tuples:
if isinstance(condition, CoverageCondition):
quantity_affected = 1
else:
quantity_affected = min(
line.quantity_without_offer_discount(offer),
num_permitted - num_affected,
)
num_affected += quantity_affected
value_affected += quantity_affected * price
covered_lines.append((price, line, quantity_affected))
if num_affected >= num_permitted:
break
discount = max(value_affected - self.value, D("0.00"))
if not discount:
return ZERO_DISCOUNT
# Apply discount to the affected lines
discount_applied = D("0.00")
last_line = covered_lines[-1][1]
for price, line, quantity in covered_lines:
if line == last_line:
# If last line, we just take the difference to ensure that
# rounding doesn't lead to an off-by-one error
line_discount = discount - discount_applied
else:
line_discount = self.round(
discount * (price * quantity) / value_affected, basket.currency
)
apply_discount(line, line_discount, quantity, offer)
discount_applied += line_discount
return BasketDiscount(discount)
[docs]class MultibuyDiscountBenefit(Benefit):
_description = _("Cheapest product from %(range)s is free")
@property
def name(self):
return self._description % {"range": self.range.name.lower()}
@property
def description(self):
return self._description % {"range": range_anchor(self.range)}
class Meta:
app_label = "offer"
proxy = True
verbose_name = _("Multibuy discount benefit")
verbose_name_plural = _("Multibuy discount benefits")
def apply(self, basket, condition, offer, **kwargs):
line_tuples = self.get_applicable_lines(offer, basket)
if not line_tuples:
return ZERO_DISCOUNT
# Cheapest line gives free product
discount, line = line_tuples[0]
if line.quantity_with_offer_discount(offer) == 0:
apply_discount(line, discount, 1, offer)
affected_lines = [(line, discount, 1)]
condition.consume_items(offer, basket, affected_lines)
return BasketDiscount(discount)
else:
return ZERO_DISCOUNT
# =================
# Shipping benefits
# =================
[docs]class ShippingBenefit(Benefit):
def apply(self, basket, condition, offer, **kwargs):
condition.consume_items(offer, basket, affected_lines=())
return SHIPPING_DISCOUNT
class Meta:
app_label = "offer"
proxy = True
[docs]class ShippingAbsoluteDiscountBenefit(ShippingBenefit):
_description = _("%(amount)s off shipping cost")
@property
def name(self):
return self._description % {"amount": currency(self.value)}
class Meta:
app_label = "offer"
proxy = True
verbose_name = _("Shipping absolute discount benefit")
verbose_name_plural = _("Shipping absolute discount benefits")
def shipping_discount(self, charge, currency=None):
return min(charge, self.value)
[docs]class ShippingFixedPriceBenefit(ShippingBenefit):
_description = _("Get shipping for %(amount)s")
@property
def name(self):
return self._description % {"amount": currency(self.value)}
class Meta:
app_label = "offer"
proxy = True
verbose_name = _("Fixed price shipping benefit")
verbose_name_plural = _("Fixed price shipping benefits")
def shipping_discount(self, charge, currency=None):
if charge < self.value:
return D("0.00")
return charge - self.value
[docs]class ShippingPercentageDiscountBenefit(ShippingBenefit):
_description = _("%(value)s%% off of shipping cost")
@property
def name(self):
return self._description % {"value": self.value}
class Meta:
app_label = "offer"
proxy = True
verbose_name = _("Shipping percentage discount benefit")
verbose_name_plural = _("Shipping percentage discount benefits")
def shipping_discount(self, charge, currency=None):
discount = charge * self.value / D("100.0")
return discount.quantize(D("0.01"))