class AbstractCondition(models.Model): """ A condition for an offer to be applied. You can either specify a custom proxy class, or need to specify a type, range and value. """ COUNT, VALUE, COVERAGE = ("Count", "Value", "Coverage") TYPE_CHOICES = ((COUNT, _("Depends on number of items in basket that are in " "condition range")), (VALUE, _("Depends on value of items in basket that are in " "condition range")), (COVERAGE, _("Needs to contain a set number of DISTINCT items " "from the condition range"))) range = models.ForeignKey('offer.Range', verbose_name=_("Range"), null=True, blank=True) type = models.CharField(_('Type'), max_length=128, choices=TYPE_CHOICES, blank=True) value = fields.PositiveDecimalField(_('Value'), decimal_places=2, max_digits=12, null=True, blank=True) proxy_class = fields.NullCharField(_("Custom class"), max_length=255, unique=True, default=None) class Meta: abstract = True app_label = 'offer' verbose_name = _("Condition") verbose_name_plural = _("Conditions") def proxy(self): """ Return the proxy model """ from oscar.apps.offer import conditions klassmap = { self.COUNT: conditions.CountCondition, self.VALUE: conditions.ValueCondition, self.COVERAGE: conditions.CoverageCondition } # Short-circuit logic if current class is already a proxy class. if self.__class__ in klassmap.values(): return self field_dict = dict(self.__dict__) for field in list(field_dict.keys()): if field.startswith('_'): del field_dict[field] if self.proxy_class: klass = utils.load_proxy(self.proxy_class) # Short-circuit again. if self.__class__ == klass: return self return klass(**field_dict) if self.type in klassmap: return klassmap[self.type](**field_dict) raise RuntimeError("Unrecognised condition type (%s)" % self.type) def __str__(self): return self.name @property def name(self): """ A plaintext description of the condition. Every proxy class has to implement it. This is used in the dropdowns within the offer dashboard. """ return self.proxy().name @property def description(self): """ A description of the condition. Defaults to the name. May contain HTML. """ return self.name def consume_items(self, offer, basket, affected_lines): pass def is_satisfied(self, offer, basket): """ Determines whether a given basket meets this condition. This is stubbed in this top-class object. The subclassing proxies are responsible for implementing it correctly. """ return False def is_partially_satisfied(self, offer, basket): """ Determine if the basket partially meets the condition. This is useful for up-selling messages to entice customers to buy something more in order to qualify for an offer. """ return False def get_upsell_message(self, offer, basket): return None def can_apply_condition(self, line): """ Determines whether the condition can be applied to a given basket line """ if not line.stockrecord_id: return False product = line.product return (self.range.contains_product(product) and product.get_is_discountable()) def get_applicable_lines(self, offer, basket, most_expensive_first=True): """ Return line data for the lines that can be consumed by this condition """ line_tuples = [] for line in basket.all_lines(): if not self.can_apply_condition(line): continue price = utils.unit_price(offer, line) if not price: continue line_tuples.append((price, line)) key = operator.itemgetter(0) if most_expensive_first: return sorted(line_tuples, reverse=True, key=key) return sorted(line_tuples, key=key)
class AbstractBenefit(models.Model): range = models.ForeignKey('offer.Range', null=True, blank=True, verbose_name=_("Range")) # Benefit types PERCENTAGE, FIXED, MULTIBUY, FIXED_PRICE = ("Percentage", "Absolute", "Multibuy", "Fixed price") SHIPPING_PERCENTAGE, SHIPPING_ABSOLUTE, SHIPPING_FIXED_PRICE = ( 'Shipping percentage', 'Shipping absolute', 'Shipping fixed price') TYPE_CHOICES = ( (PERCENTAGE, _("Discount is a percentage off of the product's value")), (FIXED, _("Discount is a fixed amount off of the product's value")), (MULTIBUY, _("Discount is to give the cheapest product for free")), (FIXED_PRICE, _("Get the products that meet the condition for a fixed price")), (SHIPPING_ABSOLUTE, _("Discount is a fixed amount of the shipping cost")), (SHIPPING_FIXED_PRICE, _("Get shipping for a fixed price")), (SHIPPING_PERCENTAGE, _("Discount is a percentage off of the shipping" " cost")), ) type = models.CharField(_("Type"), max_length=128, choices=TYPE_CHOICES, blank=True) # The value to use with the designated type. This can be either an integer # (eg for multibuy) or a decimal (eg an amount) which is slightly # confusing. value = fields.PositiveDecimalField(_("Value"), decimal_places=2, max_digits=12, null=True, blank=True) # If this is not set, then there is no upper limit on how many products # can be discounted by this benefit. max_affected_items = models.PositiveIntegerField( _("Max Affected Items"), blank=True, null=True, help_text=_("Set this to prevent the discount consuming all items " "within the range that are in the basket.")) # A custom benefit class can be used instead. This means the # type/value/max_affected_items fields should all be None. proxy_class = fields.NullCharField(_("Custom class"), max_length=255, default=None) class Meta: abstract = True app_label = 'offer' verbose_name = _("Benefit") verbose_name_plural = _("Benefits") def proxy(self): from oscar.apps.offer import benefits klassmap = { self.PERCENTAGE: benefits.PercentageDiscountBenefit, self.FIXED: benefits.AbsoluteDiscountBenefit, self.MULTIBUY: benefits.MultibuyDiscountBenefit, self.FIXED_PRICE: benefits.FixedPriceBenefit, self.SHIPPING_ABSOLUTE: benefits.ShippingAbsoluteDiscountBenefit, self.SHIPPING_FIXED_PRICE: benefits.ShippingFixedPriceBenefit, self.SHIPPING_PERCENTAGE: benefits.ShippingPercentageDiscountBenefit } # Short-circuit logic if current class is already a proxy class. if self.__class__ in klassmap.values(): return self field_dict = dict(self.__dict__) for field in list(field_dict.keys()): if field.startswith('_'): del field_dict[field] if self.proxy_class: klass = utils.load_proxy(self.proxy_class) # Short-circuit again. if self.__class__ == klass: return self return klass(**field_dict) if self.type in klassmap: return klassmap[self.type](**field_dict) raise RuntimeError("Unrecognised benefit type (%s)" % self.type) def __str__(self): return self.name @property def name(self): """ A plaintext description of the benefit. Every proxy class has to implement it. This is used in the dropdowns within the offer dashboard. """ return self.proxy().name @property def description(self): """ A description of the benefit. Defaults to the name. May contain HTML. """ return self.name def apply(self, basket, condition, offer): return results.ZERO_DISCOUNT def apply_deferred(self, basket, order, application): return None def clean(self): if not self.type: return method_name = 'clean_%s' % self.type.lower().replace(' ', '_') if hasattr(self, method_name): getattr(self, method_name)() def clean_multibuy(self): if not self.range: raise exceptions.ValidationError( _("Multibuy benefits require a product range")) if self.value: raise exceptions.ValidationError( _("Multibuy benefits don't require a value")) if self.max_affected_items: raise exceptions.ValidationError( _("Multibuy benefits don't require a 'max affected items' " "attribute")) def clean_percentage(self): if not self.range: raise exceptions.ValidationError( _("Percentage benefits require a product range")) if self.value > 100: raise exceptions.ValidationError( _("Percentage discount cannot be greater than 100")) def clean_shipping_absolute(self): if not self.value: raise exceptions.ValidationError(_("A discount value is required")) if self.range: raise exceptions.ValidationError( _("No range should be selected as this benefit does not " "apply to products")) if self.max_affected_items: raise exceptions.ValidationError( _("Shipping discounts don't require a 'max affected items' " "attribute")) def clean_shipping_percentage(self): if self.value > 100: raise exceptions.ValidationError( _("Percentage discount cannot be greater than 100")) if self.range: raise exceptions.ValidationError( _("No range should be selected as this benefit does not " "apply to products")) if self.max_affected_items: raise exceptions.ValidationError( _("Shipping discounts don't require a 'max affected items' " "attribute")) def clean_shipping_fixed_price(self): if self.range: raise exceptions.ValidationError( _("No range should be selected as this benefit does not " "apply to products")) if self.max_affected_items: raise exceptions.ValidationError( _("Shipping discounts don't require a 'max affected items' " "attribute")) def clean_fixed_price(self): if self.range: raise exceptions.ValidationError( _("No range should be selected as the condition range will " "be used instead.")) def clean_absolute(self): if not self.range: raise exceptions.ValidationError( _("Fixed discount benefits require a product range")) if not self.value: raise exceptions.ValidationError( _("Fixed discount benefits require a value")) def round(self, amount): """ Apply rounding to discount amount """ if hasattr(settings, 'OSCAR_OFFER_ROUNDING_FUNCTION'): return settings.OSCAR_OFFER_ROUNDING_FUNCTION(amount) return amount.quantize(D('.01'), ROUND_DOWN) def _effective_max_affected_items(self): """ Return the maximum number of items that can have a discount applied during the application of this benefit """ return self.max_affected_items if self.max_affected_items else 10000 def can_apply_benefit(self, line): """ Determines whether the benefit can be applied to a given basket line """ return line.stockrecord and line.product.is_discountable def get_applicable_lines(self, offer, basket, range=None): """ Return the basket lines that are available to be discounted :basket: The basket :range: The range of products to use for filtering. The fixed-price benefit ignores its range and uses the condition range """ if range is None: range = self.range line_tuples = [] for line in basket.all_lines(): product = line.product if (not range.contains(product) or not self.can_apply_benefit(line)): continue price = utils.unit_price(offer, line) if not price: # Avoid zero price products continue if line.quantity_without_discount == 0: continue line_tuples.append((price, line)) # We sort lines to be cheapest first to ensure consistent applications return sorted(line_tuples, key=operator.itemgetter(0)) def shipping_discount(self, charge): return D('0.00')
class AbstractBenefit(BaseOfferMixin, models.Model): range = models.ForeignKey('offer.Range', blank=True, null=True, on_delete=models.CASCADE, verbose_name=_("Range")) # Benefit types PERCENTAGE, FIXED, MULTIBUY, FIXED_PRICE = ("Percentage", "Absolute", "Multibuy", "Fixed price") SHIPPING_PERCENTAGE, SHIPPING_ABSOLUTE, SHIPPING_FIXED_PRICE = ( 'Shipping percentage', 'Shipping absolute', 'Shipping fixed price') TYPE_CHOICES = ( (PERCENTAGE, _("Discount is a percentage off of the product's value")), (FIXED, _("Discount is a fixed amount off of the product's value")), (MULTIBUY, _("Discount is to give the cheapest product for free")), (FIXED_PRICE, _("Get the products that meet the condition for a fixed price")), (SHIPPING_ABSOLUTE, _("Discount is a fixed amount of the shipping cost")), (SHIPPING_FIXED_PRICE, _("Get shipping for a fixed price")), (SHIPPING_PERCENTAGE, _("Discount is a percentage off of the shipping" " cost")), ) type = models.CharField(_("Type"), max_length=128, choices=TYPE_CHOICES, blank=True) # The value to use with the designated type. This can be either an integer # (eg for multibuy) or a decimal (eg an amount) which is slightly # confusing. value = fields.PositiveDecimalField(_("Value"), decimal_places=2, max_digits=12, null=True, blank=True) # If this is not set, then there is no upper limit on how many products # can be discounted by this benefit. max_affected_items = models.PositiveIntegerField( _("Max Affected Items"), blank=True, null=True, help_text=_("Set this to prevent the discount consuming all items " "within the range that are in the basket.")) # A custom benefit class can be used instead. This means the # type/value/max_affected_items fields should all be None. proxy_class = fields.NullCharField(_("Custom class"), max_length=255, default=None) class Meta: abstract = True app_label = 'offer' verbose_name = _("Benefit") verbose_name_plural = _("Benefits") @property def proxy_map(self): return { self.PERCENTAGE: get_class('offer.benefits', 'PercentageDiscountBenefit'), self.FIXED: get_class('offer.benefits', 'AbsoluteDiscountBenefit'), self.MULTIBUY: get_class('offer.benefits', 'MultibuyDiscountBenefit'), self.FIXED_PRICE: get_class('offer.benefits', 'FixedPriceBenefit'), self.SHIPPING_ABSOLUTE: get_class('offer.benefits', 'ShippingAbsoluteDiscountBenefit'), self.SHIPPING_FIXED_PRICE: get_class('offer.benefits', 'ShippingFixedPriceBenefit'), self.SHIPPING_PERCENTAGE: get_class('offer.benefits', 'ShippingPercentageDiscountBenefit') } def apply(self, basket, condition, offer): return ZERO_DISCOUNT def apply_deferred(self, basket, order, application): return None def clean(self): if not self.type: return method_name = 'clean_%s' % self.type.lower().replace(' ', '_') if hasattr(self, method_name): getattr(self, method_name)() def clean_multibuy(self): errors = [] if not self.range: errors.append(_("Multibuy benefits require a product range")) if self.value: errors.append(_("Multibuy benefits don't require a value")) if self.max_affected_items: errors.append( _("Multibuy benefits don't require a " "'max affected items' attribute")) if errors: raise exceptions.ValidationError(errors) def clean_percentage(self): errors = [] if not self.range: errors.append(_("Percentage benefits require a product range")) if not self.value: errors.append(_("Percentage discount benefits require a value")) elif self.value > 100: errors.append(_("Percentage discount cannot be greater than 100")) if errors: raise exceptions.ValidationError(errors) def clean_shipping_absolute(self): errors = [] if not self.value: errors.append(_("A discount value is required")) if self.range: errors.append( _("No range should be selected as this benefit does " "not apply to products")) if self.max_affected_items: errors.append( _("Shipping discounts don't require a " "'max affected items' attribute")) if errors: raise exceptions.ValidationError(errors) def clean_shipping_percentage(self): errors = [] if not self.value: errors.append(_("Percentage discount benefits require a value")) elif self.value > 100: errors.append(_("Percentage discount cannot be greater than 100")) if self.range: errors.append( _("No range should be selected as this benefit does " "not apply to products")) if self.max_affected_items: errors.append( _("Shipping discounts don't require a " "'max affected items' attribute")) if errors: raise exceptions.ValidationError(errors) def clean_shipping_fixed_price(self): errors = [] if self.range: errors.append( _("No range should be selected as this benefit does " "not apply to products")) if self.max_affected_items: errors.append( _("Shipping discounts don't require a " "'max affected items' attribute")) if errors: raise exceptions.ValidationError(errors) def clean_fixed_price(self): if self.range: raise exceptions.ValidationError( _("No range should be selected as the condition range will " "be used instead.")) def clean_absolute(self): errors = [] if not self.range: errors.append(_("Fixed discount benefits require a product range")) if not self.value: errors.append(_("Fixed discount benefits require a value")) if errors: raise exceptions.ValidationError(errors) def round(self, amount): """ Apply rounding to discount amount """ rounding_function_path = getattr(settings, 'OSCAR_OFFER_ROUNDING_FUNCTION', None) if rounding_function_path: rounding_function = cached_import_string(rounding_function_path) return rounding_function(amount) return amount.quantize(D('.01'), ROUND_DOWN) def _effective_max_affected_items(self): """ Return the maximum number of items that can have a discount applied during the application of this benefit """ return self.max_affected_items if self.max_affected_items else 10000 def can_apply_benefit(self, line): """ Determines whether the benefit can be applied to a given basket line """ return line.stockrecord and line.product.is_discountable def get_applicable_lines(self, offer, basket, range=None): """ Return the basket lines that are available to be discounted :basket: The basket :range: The range of products to use for filtering. The fixed-price benefit ignores its range and uses the condition range """ if range is None: range = self.range line_tuples = [] for line in basket.all_lines(): product = line.product if (not range.contains_product(product) or not self.can_apply_benefit(line)): continue price = unit_price(offer, line) if not price: # Avoid zero price products continue line_tuples.append((price, line)) # We sort lines to be cheapest first to ensure consistent applications return sorted(line_tuples, key=operator.itemgetter(0)) def shipping_discount(self, charge): return D('0.00')
class AbstractCondition(BaseOfferMixin, models.Model): """ A condition for an offer to be applied. You can either specify a custom proxy class, or need to specify a type, range and value. """ COUNT, VALUE, COVERAGE = ("Count", "Value", "Coverage") TYPE_CHOICES = ((COUNT, _("Depends on number of items in basket that are in " "condition range")), (VALUE, _("Depends on value of items in basket that are in " "condition range")), (COVERAGE, _("Needs to contain a set number of DISTINCT items " "from the condition range"))) range = models.ForeignKey('offer.Range', blank=True, null=True, on_delete=models.CASCADE, verbose_name=_("Range")) type = models.CharField(_('Type'), max_length=128, choices=TYPE_CHOICES, blank=True) value = fields.PositiveDecimalField(_('Value'), decimal_places=2, max_digits=12, null=True, blank=True) proxy_class = fields.NullCharField(_("Custom class"), max_length=255, default=None) class Meta: abstract = True app_label = 'offer' verbose_name = _("Condition") verbose_name_plural = _("Conditions") @property def proxy_map(self): return { self.COUNT: get_class('offer.conditions', 'CountCondition'), self.VALUE: get_class('offer.conditions', 'ValueCondition'), self.COVERAGE: get_class('offer.conditions', 'CoverageCondition'), } def consume_items(self, offer, basket, affected_lines): pass def is_satisfied(self, offer, basket): """ Determines whether a given basket meets this condition. This is stubbed in this top-class object. The subclassing proxies are responsible for implementing it correctly. """ return False def is_partially_satisfied(self, offer, basket): """ Determine if the basket partially meets the condition. This is useful for up-selling messages to entice customers to buy something more in order to qualify for an offer. """ return False def get_upsell_message(self, offer, basket): return None def can_apply_condition(self, line): """ Determines whether the condition can be applied to a given basket line """ if not line.stockrecord_id: return False product = line.product return (self.range.contains_product(product) and product.get_is_discountable()) def get_applicable_lines(self, offer, basket, most_expensive_first=True): """ Return line data for the lines that can be consumed by this condition """ line_tuples = [] for line in basket.all_lines(): if not self.can_apply_condition(line): continue price = unit_price(offer, line) if not price: continue line_tuples.append((price, line)) key = operator.itemgetter(0) if most_expensive_first: return sorted(line_tuples, reverse=True, key=key) return sorted(line_tuples, key=key)
class Fee(models.Model): range = models.ForeignKey('offer.Range', blank=True, null=True, on_delete=models.CASCADE, verbose_name=_("Range")) # Benefit types PERCENTAGE, FIXED = ("Percentage", "Absolute") TYPE_CHOICES = ( (PERCENTAGE, _("Fee is a percentage of the basket's value")), (FIXED, _("Fee is a fixed amount")), ) type = models.CharField(_("Type"), max_length=128, choices=TYPE_CHOICES, blank=True) # The value to use with the designated type. This can be either an integer # (eg for multibuy) or a decimal (eg an amount) which is slightly # confusing. value = fields.PositiveDecimalField(_("Value"), decimal_places=2, max_digits=12, null=True, blank=True) # A custom benefit class can be used instead. This means the # type/value/max_affected_items fields should all be None. proxy_class = fields.NullCharField(_("Custom class"), max_length=255, default=None) class Meta: # abstract = True app_label = 'django_oscar_fees' verbose_name = _("Fee") verbose_name_plural = _("Fees") def proxy(self): from . import fees klassmap = { self.PERCENTAGE: fees.PercentageFee, self.FIXED: fees.AbsoluteFee, } # Short-circuit logic if current class is already a proxy class. if self.__class__ in klassmap.values(): return self field_dict = dict(self.__dict__) for field in list(field_dict.keys()): if field.startswith('_'): del field_dict[field] if self.proxy_class: klass = utils.load_proxy(self.proxy_class) # Short-circuit again. if self.__class__ == klass: return self return klass(**field_dict) if self.type in klassmap: return klassmap[self.type](**field_dict) raise RuntimeError("Unrecognised benefit type (%s)" % self.type) def __str__(self): return self.name @property def name(self): """ A plaintext description of the benefit. Every proxy class has to implement it. This is used in the dropdowns within the offer dashboard. """ return self.proxy().name @property def description(self): """ A description of the benefit. Defaults to the name. May contain HTML. """ return self.name def apply(self, basket, condition, offer): from . import conditions if isinstance(condition, conditions.ValueCondition): return ZERO_FEE discount = max(self.value, D('0.00')) return BasketFee(discount) def clean(self): if not self.type: return method_name = 'clean_%s' % self.type.lower().replace(' ', '_') if hasattr(self, method_name): getattr(self, method_name)() def get_applicable_lines(self, fee, basket, range=None): """ Return the basket lines that are available for fee :basket: The basket :range: The range of products to use for filtering. The fixed-price benefit ignores its range and uses the condition range """ if range is None: range = self.range line_tuples = [] for line in basket.all_lines(): product = line.product if not range.contains(product): continue price = line.unit_effective_price if not price: # Avoid zero price products continue if line.quantity_without_discount == 0: continue line_tuples.append((price, line)) # We sort lines to be cheapest first to ensure consistent applications return sorted(line_tuples, key=operator.itemgetter(0)) def round(self, amount): """ Apply rounding to discount amount """ if hasattr(settings, 'OSCAR_OFFER_ROUNDING_FUNCTION'): return settings.OSCAR_OFFER_ROUNDING_FUNCTION(amount) return amount.quantize(D('.01'), ROUND_DOWN)