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),
    ]
Ejemplo n.º 2
0
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
Ejemplo n.º 3
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)
Ejemplo n.º 4
0
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')
Ejemplo n.º 5
0
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())
Ejemplo n.º 6
0
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)
Ejemplo n.º 7
0
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')
Ejemplo n.º 8
0
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
Ejemplo n.º 9
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)