class Migration(migrations.Migration): dependencies = [ ('offer', '0004_conditionaloffer_groups'), ] operations = [ migrations.AlterField( model_name='condition', name='proxy_class', field=fields.NullCharField(default=None, max_length=255, verbose_name='Custom class'), ), migrations.RunPython(benefit_to_proxy_classes, benefit_from_proxy_classes), migrations.RunPython(condition_to_proxy_classes, condition_from_proxy_classes), ]
class AbstractRange(models.Model): """ Represents a range of products that can be used within an offer. Ranges only support adding parent or stand-alone products. Offers will consider child products automatically. """ name = models.CharField(_("Name"), max_length=128, unique=True) slug = fields.AutoSlugField(_("Slug"), max_length=128, unique=True, populate_from="name") description = models.TextField(blank=True) # Whether this range is public is_public = models.BooleanField( _('Is public?'), default=False, help_text=_("Public ranges have a customer-facing page")) includes_all_products = models.BooleanField(_('Includes all products?'), default=False) included_products = models.ManyToManyField( 'catalogue.Product', related_name='includes', blank=True, verbose_name=_("Included Products"), through='offer.RangeProduct') excluded_products = models.ManyToManyField( 'catalogue.Product', related_name='excludes', blank=True, verbose_name=_("Excluded Products")) classes = models.ManyToManyField('catalogue.ProductClass', related_name='classes', blank=True, verbose_name=_("Product Types")) included_categories = models.ManyToManyField( 'catalogue.Category', related_name='includes', blank=True, verbose_name=_("Included Categories")) # Allow a custom range instance to be specified proxy_class = fields.NullCharField(_("Custom class"), max_length=255, default=None, unique=True) date_created = models.DateTimeField(_("Date Created"), auto_now_add=True) __included_product_ids = None __excluded_product_ids = None __class_ids = None __category_ids = None objects = models.Manager() browsable = BrowsableRangeManager() class Meta: abstract = True app_label = 'offer' verbose_name = _("Range") verbose_name_plural = _("Ranges") def __str__(self): return self.name def get_absolute_url(self): return reverse('catalogue:range', kwargs={'slug': self.slug}) @cached_property def proxy(self): if self.proxy_class: return utils.load_proxy(self.proxy_class)() def add_product(self, product, display_order=None): """ Add product to the range When adding product that is already in the range, prevent re-adding it. If display_order is specified, update it. Default display_order for a new product in the range is 0; this puts the product at the top of the list. """ initial_order = display_order or 0 RangeProduct = get_model('offer', 'RangeProduct') relation, __ = RangeProduct.objects.get_or_create( range=self, product=product, defaults={'display_order': initial_order}) if (display_order is not None and relation.display_order != display_order): relation.display_order = display_order relation.save() def remove_product(self, product): """ Remove product from range. To save on queries, this function does not check if the product is in fact in the range. """ RangeProduct = get_model('offer', 'RangeProduct') RangeProduct.objects.filter(range=self, product=product).delete() # Making sure product will be excluded from range products list by adding to # respective field. Otherwise, it could be included as a product from included # category or etc. self.excluded_products.add(product) # Invalidating cached property value with list of IDs of already excluded products. self.invalidate_cached_ids() def contains_product(self, product): # noqa (too complex (12)) """ Check whether the passed product is part of this range. """ # Delegate to a proxy class if one is provided if self.proxy: return self.proxy.contains_product(product) excluded_product_ids = self._excluded_product_ids() if product.id in excluded_product_ids: return False if self.includes_all_products: return True if product.get_product_class().id in self._class_ids(): return True included_product_ids = self._included_product_ids() # If the product's parent is in the range, the child is automatically included as well if product.is_child and product.parent.id in included_product_ids: return True if product.id in included_product_ids: return True test_categories = self.included_categories.all() if test_categories: for category in product.get_categories().all(): for test_category in test_categories: if category == test_category \ or category.is_descendant_of(test_category): return True return False # Shorter alias contains = contains_product def __get_pks_and_child_pks(self, queryset): """ Expects a product queryset; gets the primary keys of the passed products and their children. Verbose, but database and memory friendly. """ # One query to get parent and children; [(4, None), (5, 10), (5, 11)] pk_tuples_iterable = queryset.values_list('pk', 'children__pk') # Flatten list without unpacking; [4, None, 5, 10, 5, 11] flat_iterable = itertools.chain.from_iterable(pk_tuples_iterable) # Ensure uniqueness and remove None; {4, 5, 10, 11} return set(flat_iterable) - {None} def _included_product_ids(self): if not self.id: return [] if self.__included_product_ids is None: self.__included_product_ids = self.__get_pks_and_child_pks( self.included_products) return self.__included_product_ids def _excluded_product_ids(self): if not self.id: return [] if self.__excluded_product_ids is None: self.__excluded_product_ids = self.__get_pks_and_child_pks( self.excluded_products) return self.__excluded_product_ids def _class_ids(self): if self.__class_ids is None: self.__class_ids = self.classes.values_list('pk', flat=True) return self.__class_ids def _category_ids(self): if self.__category_ids is None: category_ids_list = list( self.included_categories.values_list('pk', flat=True)) for category in self.included_categories.all(): children_ids = category.get_descendants().values_list( 'pk', flat=True) category_ids_list.extend(list(children_ids)) self.__category_ids = category_ids_list return self.__category_ids def invalidate_cached_ids(self): self.__category_ids = None self.__included_product_ids = None self.__excluded_product_ids = None def num_products(self): # Delegate to a proxy class if one is provided if self.proxy: return self.proxy.num_products() if self.includes_all_products: return None return self.all_products().count() def all_products(self): """ Return a queryset containing all the products in the range This includes included_products plus the products contained in the included classes and categories, minus the products in excluded_products. """ if self.proxy: return self.proxy.all_products() Product = get_model("catalogue", "Product") if self.includes_all_products: # Filter out child products return Product.browsable.all() return Product.objects.filter( Q(id__in=self._included_product_ids()) | Q(product_class_id__in=self._class_ids()) | Q(productcategory__category_id__in=self._category_ids()) ).exclude(id__in=self._excluded_product_ids()).distinct() @property def is_editable(self): """ Test whether this range can be edited in the dashboard. """ return not self.proxy_class @property def is_reorderable(self): """ Test whether products for the range can be re-ordered. """ return len(self._class_ids()) == 0 and len(self._category_ids()) == 0
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 AbstractRange(models.Model): """ Represents a range of products that can be used within an offer. Ranges only support adding parent or stand-alone products. Offers will consider child products automatically. """ name = models.CharField(_("Name"), max_length=128, unique=True) slug = fields.AutoSlugField(_("Slug"), max_length=128, unique=True, populate_from="name") description = models.TextField(blank=True) # Whether this range is public is_public = models.BooleanField( _('Is public?'), default=False, help_text=_("Public ranges have a customer-facing page")) includes_all_products = models.BooleanField(_('Includes all products?'), default=False) included_products = models.ManyToManyField( 'catalogue.Product', related_name='includes', blank=True, verbose_name=_("Included Products"), through='offer.RangeProduct') excluded_products = models.ManyToManyField( 'catalogue.Product', related_name='excludes', blank=True, verbose_name=_("Excluded Products")) classes = models.ManyToManyField('catalogue.ProductClass', related_name='classes', blank=True, verbose_name=_("Product Types")) included_categories = models.ManyToManyField( 'catalogue.Category', related_name='includes', blank=True, verbose_name=_("Included Categories")) # Allow a custom range instance to be specified proxy_class = fields.NullCharField(_("Custom class"), max_length=255, default=None, unique=True) date_created = models.DateTimeField(_("Date Created"), auto_now_add=True) objects = RangeManager() browsable = BrowsableRangeManager() class Meta: abstract = True app_label = 'offer' verbose_name = _("Range") verbose_name_plural = _("Ranges") def __str__(self): return self.name def get_absolute_url(self): return reverse('catalogue:range', kwargs={'slug': self.slug}) @cached_property def proxy(self): if self.proxy_class: return load_proxy(self.proxy_class)() def add_product(self, product, display_order=None): """ Add product to the range When adding product that is already in the range, prevent re-adding it. If display_order is specified, update it. Default display_order for a new product in the range is 0; this puts the product at the top of the list. """ initial_order = display_order or 0 RangeProduct = self.included_products.through relation, __ = RangeProduct.objects.get_or_create( range=self, product=product, defaults={'display_order': initial_order}) if (display_order is not None and relation.display_order != display_order): relation.display_order = display_order relation.save() # Remove product from excluded products if it was removed earlier and # re-added again, thus it returns back to the range product list. self.excluded_products.remove(product) # invalidate cache because queryset has changed self.invalidate_cached_queryset() def remove_product(self, product): """ Remove product from range. To save on queries, this function does not check if the product is in fact in the range. """ RangeProduct = self.included_products.through RangeProduct.objects.filter(range=self, product=product).delete() # Making sure product will be excluded from range products list by adding to # respective field. Otherwise, it could be included as a product from included # category or etc. self.excluded_products.add(product) # invalidate cache because queryset has changed self.invalidate_cached_queryset() def contains_product(self, product): if self.proxy: return self.proxy.contains_product(product) return self.product_queryset.filter(id=product.id).exists() # Deprecated alias @deprecated def contains(self, product): return self.contains_product(product) def invalidate_cached_queryset(self): try: del self.product_queryset except AttributeError: pass def num_products(self): # Delegate to a proxy class if one is provided if self.proxy: return self.proxy.num_products() if self.includes_all_products: return None return self.all_products().count() def all_products(self): """ Return a queryset containing all the products in the range This includes included_products plus the products contained in the included classes and categories, minus the products in excluded_products. """ if self.proxy: return self.proxy.all_products() return self.product_queryset @cached_property def product_queryset(self): "cached queryset of all the products in the Range" Product = self.included_products.model if self.includes_all_products: # Filter out child products and blacklisted products return Product.objects.browsable().exclude( id__in=self.excluded_products.values("id")) Category = self.included_categories.model # build query to select all category subtrees. included_in_subtree = self.included_categories.filter( path__rstartswith=OuterRef("path"), depth__lte=OuterRef("depth")) category_tree = Category.objects.annotate( is_included_in_subtree=Exists(included_in_subtree.values( "id"))).filter(is_included_in_subtree=True) # select all those product that are selected either by product class, # category, or explicitly by included_products. all_parents = (Product.objects.filter( Q(product_class_id__in=self.classes.values("id")) | Q(categories__in=category_tree)) | self.included_products.all()) # now go and exclude all explicitly excluded products excludes = self.excluded_products.values("id") selected_parents = all_parents.exclude( Q(parent_id__in=excludes) | Q(id__in=excludes)) # select parents and their children return ( selected_parents | Product.objects.filter(parent__in=selected_parents)).distinct() @property def is_editable(self): """ Test whether this range can be edited in the dashboard. """ return not self.proxy_class @property def is_reorderable(self): """ Test whether products for the range can be re-ordered. """ return not (self.included_categories.exists() or self.classes.exists())
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 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 AbstractRange(models.Model): """ Represents a range of products that can be used within an offer. Ranges only support adding parent or stand-alone products. Offers will consider child products automatically. 表示可在商品中使用的一系列产品。 范围仅支持添加父产品或独立产品。 优惠将自动考虑子产品。 """ name = models.CharField(_("Name"), max_length=128, unique=True) slug = fields.AutoSlugField( _("Slug"), max_length=128, unique=True, populate_from="name") description = models.TextField(blank=True) # Whether this range is public # 这个范围是否公开 is_public = models.BooleanField( _('Is public?'), default=False, help_text=_("Public ranges have a customer-facing page")) includes_all_products = models.BooleanField( _('Includes all products?'), default=False) included_products = models.ManyToManyField( 'catalogue.Product', related_name='includes', blank=True, verbose_name=_("Included Products"), through='offer.RangeProduct') excluded_products = models.ManyToManyField( 'catalogue.Product', related_name='excludes', blank=True, verbose_name=_("Excluded Products")) classes = models.ManyToManyField( 'catalogue.ProductClass', related_name='classes', blank=True, verbose_name=_("Product Types")) included_categories = models.ManyToManyField( 'catalogue.Category', related_name='includes', blank=True, verbose_name=_("Included Categories")) # Allow a custom range instance to be specified # 允许指定自定义范围实例 proxy_class = fields.NullCharField( _("Custom class"), max_length=255, default=None, unique=True) date_created = models.DateTimeField(_("Date Created"), auto_now_add=True) __included_product_ids = None __excluded_product_ids = None __class_ids = None __category_ids = None objects = models.Manager() browsable = BrowsableRangeManager() class Meta: abstract = True app_label = 'offer' verbose_name = _("Range") verbose_name_plural = _("Ranges") def __str__(self): return self.name def get_absolute_url(self): return reverse( 'catalogue:range', kwargs={'slug': self.slug}) @cached_property def proxy(self): if self.proxy_class: return load_proxy(self.proxy_class)() def add_product(self, product, display_order=None): """ Add product to the range When adding product that is already in the range, prevent re-adding it. If display_order is specified, update it. Default display_order for a new product in the range is 0; this puts the product at the top of the list. 将产品添加到该范围 添加已在范围内的产品时,请阻止重新添加。 如果指定了display_order,请更新它。 该范围内新产品的默认display_order为0; 这使产品位于列表的顶部。 """ initial_order = display_order or 0 RangeProduct = get_model('offer', 'RangeProduct') relation, __ = RangeProduct.objects.get_or_create( range=self, product=product, defaults={'display_order': initial_order}) if (display_order is not None and relation.display_order != display_order): relation.display_order = display_order relation.save() # Remove product from excluded products if it was removed earlier and # re-added again, thus it returns back to the range product list. # 如果先前删除了产品并再次重新添加,则从已排除的产品中删除该产品,然后 # 将其返回到范围产品列表中。 if product.id in self._excluded_product_ids(): self.excluded_products.remove(product) self.invalidate_cached_ids() def remove_product(self, product): """ Remove product from range. To save on queries, this function does not check if the product is in fact in the range. 从范围中移除产品。 为了节省查询,此功能不会检查产品是否实际上在范围内。 """ RangeProduct = get_model('offer', 'RangeProduct') RangeProduct.objects.filter(range=self, product=product).delete() # Making sure product will be excluded from range products list by adding to # respective field. Otherwise, it could be included as a product from included # category or etc. # 通过添加到相应字段,确保将产品从范围产品列表中排除。 否则,它可以作为包含类 # 别的产品等包含在内。 self.excluded_products.add(product) # Invalidating cached property value with list of IDs of already excluded products. # 使用已排除产品的ID列表使缓存的属性值无效。 self.invalidate_cached_ids() def contains_product(self, product): # noqa (too complex (12)) """ Check whether the passed product is part of this range. 检查通过的产品是否属于此范围。 """ # Delegate to a proxy class if one is provided # 如果提供了代理类,则委托给代理类 if self.proxy: return self.proxy.contains_product(product) excluded_product_ids = self._excluded_product_ids() if product.id in excluded_product_ids: return False if self.includes_all_products: return True if product.get_product_class().id in self._class_ids(): return True included_product_ids = self._included_product_ids() # If the product's parent is in the range, the child is automatically included as well # 如果产品的父级在范围内,则子项也会自动包含在内 if product.is_child and product.parent.id in included_product_ids: return True if product.id in included_product_ids: return True test_categories = self.included_categories.all() if test_categories: for category in product.get_categories().all(): for test_category in test_categories: if category == test_category \ or category.is_descendant_of(test_category): return True return False # Shorter alias 缩短别名 contains = contains_product def __get_pks_and_child_pks(self, queryset): """ Expects a product queryset; gets the primary keys of the passed products and their children. Verbose, but database and memory friendly. 期待产品查询集; 获取传递的产品及其子项的主键。 详细,但数据库和内存友好。 """ # One query to get parent and children; [(4, None), (5, 10), (5, 11)] # 获得父级和子产品的一个查询; [(4,无),(5,10),(5,11)] pk_tuples_iterable = queryset.values_list('pk', 'children__pk') # Flatten list without unpacking; [4, None, 5, 10, 5, 11] # 在不拆包的情况下展平列表; [4,无,5,10,5,11] flat_iterable = itertools.chain.from_iterable(pk_tuples_iterable) # Ensure uniqueness and remove None; {4, 5, 10, 11} # 确保唯一性并删除无; {4,5,10,11} return set(flat_iterable) - {None} def _included_product_ids(self): if not self.id: return [] if self.__included_product_ids is None: self.__included_product_ids = self.__get_pks_and_child_pks( self.included_products) return self.__included_product_ids def _excluded_product_ids(self): if not self.id: return [] if self.__excluded_product_ids is None: self.__excluded_product_ids = self.__get_pks_and_child_pks( self.excluded_products) return self.__excluded_product_ids def _class_ids(self): if self.__class_ids is None: self.__class_ids = self.classes.values_list('pk', flat=True) return self.__class_ids def _category_ids(self): if self.__category_ids is None: category_ids_list = list( self.included_categories.values_list('pk', flat=True)) for category in self.included_categories.all(): children_ids = category.get_descendants().values_list( 'pk', flat=True) category_ids_list.extend(list(children_ids)) self.__category_ids = category_ids_list return self.__category_ids def invalidate_cached_ids(self): self.__category_ids = None self.__included_product_ids = None self.__excluded_product_ids = None def num_products(self): # Delegate to a proxy class if one is provided # 如果提供了代理类,则委托给代理类 if self.proxy: return self.proxy.num_products() if self.includes_all_products: return None return self.all_products().count() def all_products(self): """ Return a queryset containing all the products in the range This includes included_products plus the products contained in the included classes and categories, minus the products in excluded_products. 返回包含范围内所有产品的查询集 这包括included_products以及包含的类和类别中包含的产品,减去excluded_products中的产品。 """ if self.proxy: return self.proxy.all_products() Product = get_model("catalogue", "Product") if self.includes_all_products: # Filter out child products # 过滤掉子产品 return Product.browsable.all() return Product.objects.filter( Q(id__in=self._included_product_ids()) | Q(product_class_id__in=self._class_ids()) | Q(productcategory__category_id__in=self._category_ids()) ).exclude(id__in=self._excluded_product_ids()).distinct() @property def is_editable(self): """ Test whether this range can be edited in the dashboard. 测试是否可以在仪表板中编辑此范围。 """ return not self.proxy_class @property def is_reorderable(self): """ Test whether products for the range can be re-ordered. 测试是否可以重新订购该系列的产品。 """ return len(self._class_ids()) == 0 and len(self._category_ids()) == 0
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)