示例#1
0
 def test_slugfield_allow_unicode_kwargs_precedence(self):
     from oscar.models.fields.slugfield import SlugField
     with override_settings(OSCAR_SLUG_ALLOW_UNICODE=True):
         slug_field = SlugField(allow_unicode=False)
         self.assertFalse(slug_field.allow_unicode)
         slug_field = SlugField()
         self.assertTrue(slug_field.allow_unicode)
示例#2
0
class AbstractLine(models.Model):
    """A line of a basket (product and a quantity)

    Common approaches on ordering basket lines:

        a) First added at top. That's the history-like approach; new items are
           added to the bottom of the list. Changing quantities doesn't impact
           position.
           Oscar does this by default. It just sorts by Line.pk, which is
           guaranteed to increment after each creation.

        b) Last modified at top. That means items move to the top when you add
           another one, and new items are added to the top as well.  Amazon
           mostly does this, but doesn't change the position when you update
           the quantity in the basket view.
           To get this behaviour, add a date_updated field, change
           Meta.ordering and optionally do something similar on wishlist lines.
           Order lines should already be created in the order of the basket
           lines, and are sorted by their primary key, so no changes should be
           necessary there.

    """
    basket = models.ForeignKey('basket.Basket',
                               on_delete=models.CASCADE,
                               related_name='lines',
                               verbose_name=_("Basket"))

    # This is to determine which products belong to the same line
    # We can't just use product.id as you can have customised products
    # which should be treated as separate lines.  Set as a
    # SlugField as it is included in the path for certain views.
    line_reference = SlugField(_("Line Reference"),
                               max_length=128,
                               db_index=True)

    product = models.ForeignKey('catalogue.Product',
                                on_delete=models.CASCADE,
                                related_name='basket_lines',
                                verbose_name=_("Product"))

    # We store the stockrecord that should be used to fulfil this line.
    stockrecord = models.ForeignKey('partner.StockRecord',
                                    on_delete=models.CASCADE,
                                    related_name='basket_lines')

    quantity = models.PositiveIntegerField(_('Quantity'), default=1)

    # We store the unit price incl tax of the product when it is first added to
    # the basket.  This allows us to tell if a product has changed price since
    # a person first added it to their basket.
    price_currency = models.CharField(_("Currency"),
                                      max_length=12,
                                      default=get_default_currency)
    price_excl_tax = models.DecimalField(_('Price excl. Tax'),
                                         decimal_places=2,
                                         max_digits=12,
                                         null=True)
    price_incl_tax = models.DecimalField(_('Price incl. Tax'),
                                         decimal_places=2,
                                         max_digits=12,
                                         null=True)

    # Track date of first addition
    date_created = models.DateTimeField(_("Date Created"), auto_now_add=True)

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        # Instance variables used to persist discount information
        self._discount_excl_tax = D('0.00')
        self._discount_incl_tax = D('0.00')
        self.consumer = LineOfferConsumer(self)

    class Meta:
        abstract = True
        app_label = 'basket'
        # Enforce sorting by order of creation.
        ordering = ['date_created', 'pk']
        unique_together = ("basket", "line_reference")
        verbose_name = _('Basket line')
        verbose_name_plural = _('Basket lines')

    def __str__(self):
        return _("Basket #%(basket_id)d, Product #%(product_id)d, quantity"
                 " %(quantity)d") % {
                     'basket_id': self.basket.pk,
                     'product_id': self.product.pk,
                     'quantity': self.quantity
                 }

    def save(self, *args, **kwargs):
        if not self.basket.can_be_edited:
            raise PermissionDenied(
                _("You cannot modify a %s basket") %
                (self.basket.status.lower(), ))
        return super().save(*args, **kwargs)

    # =============
    # Offer methods
    # =============

    def clear_discount(self):
        """
        Remove any discounts from this line.
        """
        self._discount_excl_tax = D('0.00')
        self._discount_incl_tax = D('0.00')
        self.consumer = LineOfferConsumer(self)

    def discount(self,
                 discount_value,
                 affected_quantity,
                 incl_tax=True,
                 offer=None):
        """
        Apply a discount to this line
        """
        if incl_tax:
            if self._discount_excl_tax > 0:
                raise RuntimeError(
                    "Attempting to discount the tax-inclusive price of a line "
                    "when tax-exclusive discounts are already applied")
            self._discount_incl_tax += discount_value
        else:
            if self._discount_incl_tax > 0:
                raise RuntimeError(
                    "Attempting to discount the tax-exclusive price of a line "
                    "when tax-inclusive discounts are already applied")
            self._discount_excl_tax += discount_value
        self.consume(affected_quantity, offer=offer)

    def consume(self, quantity, offer=None):
        """
        Mark all or part of the line as 'consumed'

        Consumed items are no longer available to be used in offers.
        """
        self.consumer.consume(quantity, offer=offer)

    def get_price_breakdown(self):
        """
        Return a breakdown of line prices after discounts have been applied.

        Returns a list of (unit_price_incl_tax, unit_price_excl_tax, quantity)
        tuples.
        """
        if not self.is_tax_known:
            raise RuntimeError("A price breakdown can only be determined "
                               "when taxes are known")
        prices = []
        if not self.discount_value:
            prices.append((self.unit_price_incl_tax, self.unit_price_excl_tax,
                           self.quantity))
        else:
            # Need to split the discount among the affected quantity
            # of products.
            item_incl_tax_discount = (self.discount_value /
                                      int(self.consumer.consumed()))
            item_excl_tax_discount = item_incl_tax_discount * self._tax_ratio
            item_excl_tax_discount = round_half_up(item_excl_tax_discount)
            prices.append((self.unit_price_incl_tax - item_incl_tax_discount,
                           self.unit_price_excl_tax - item_excl_tax_discount,
                           self.consumer.consumed()))
            if self.quantity_without_discount:
                prices.append(
                    (self.unit_price_incl_tax, self.unit_price_excl_tax,
                     self.quantity_without_discount))
        return prices

    # =======
    # Helpers
    # =======

    @property
    def _tax_ratio(self):
        if not self.unit_price_incl_tax:
            return 0
        return self.unit_price_excl_tax / self.unit_price_incl_tax

    # ===============
    # Offer Discounts
    # ===============

    def has_offer_discount(self, offer):
        return self.consumer.consumed(offer) > 0

    def quantity_with_offer_discount(self, offer):
        return self.consumer.consumed(offer)

    def quantity_without_offer_discount(self, offer):
        return self.consumer.available(offer)

    def is_available_for_offer_discount(self, offer):
        return self.consumer.available(offer) > 0

    # ==========
    # Properties
    # ==========

    @property
    def has_discount(self):
        return bool(self.consumer.consumed())

    @property
    def quantity_with_discount(self):
        return self.consumer.consumed()

    @property
    def quantity_without_discount(self):
        return self.consumer.available()

    @property
    def is_available_for_discount(self):
        # deprecated
        return self.consumer.available() > 0

    @property
    def discount_value(self):
        # Only one of the incl- and excl- discounts should be non-zero
        return max(self._discount_incl_tax, self._discount_excl_tax)

    @property
    def purchase_info(self):
        """
        Return the stock/price info
        """
        if not hasattr(self, '_info'):
            # Cache the PurchaseInfo instance.
            self._info = self.basket.strategy.fetch_for_line(
                self, self.stockrecord)
        return self._info

    @property
    def is_tax_known(self):
        return self.purchase_info.price.is_tax_known

    @property
    def unit_effective_price(self):
        """
        The price to use for offer calculations
        """
        return self.purchase_info.price.effective_price

    @property
    def unit_price_excl_tax(self):
        return self.purchase_info.price.excl_tax

    @property
    def unit_price_incl_tax(self):
        return self.purchase_info.price.incl_tax

    @property
    def unit_tax(self):
        return self.purchase_info.price.tax

    @property
    def line_price_excl_tax(self):
        if self.unit_price_excl_tax is not None:
            return self.quantity * self.unit_price_excl_tax

    @property
    def line_price_excl_tax_incl_discounts(self):
        if self._discount_excl_tax and self.line_price_excl_tax is not None:
            return self.line_price_excl_tax - self._discount_excl_tax
        if self._discount_incl_tax and self.line_price_incl_tax is not None:
            # This is a tricky situation.  We know the discount as calculated
            # against tax inclusive prices but we need to guess how much of the
            # discount applies to tax-exclusive prices.  We do this by
            # assuming a linear tax and scaling down the original discount.
            return self.line_price_excl_tax - round_half_up(
                self._tax_ratio * self._discount_incl_tax)
        return self.line_price_excl_tax

    @property
    def line_price_incl_tax_incl_discounts(self):
        # We use whichever discount value is set.  If the discount value was
        # calculated against the tax-exclusive prices, then the line price
        # including tax
        if self.line_price_incl_tax is not None and self._discount_incl_tax:
            return self.line_price_incl_tax - self._discount_incl_tax
        elif self.line_price_excl_tax is not None and self._discount_excl_tax:
            return round_half_up(
                (self.line_price_excl_tax - self._discount_excl_tax) /
                self._tax_ratio)

        return self.line_price_incl_tax

    @property
    def line_tax(self):
        if self.is_tax_known:
            return self.line_price_incl_tax_incl_discounts - self.line_price_excl_tax_incl_discounts

    @property
    def line_price_incl_tax(self):
        if self.unit_price_incl_tax is not None:
            return self.quantity * self.unit_price_incl_tax

    @property
    def description(self):
        d = smart_text(self.product)
        ops = []
        for attribute in self.attributes.all():
            ops.append("%s = '%s'" % (attribute.option.name, attribute.value))
        if ops:
            d = "%s (%s)" % (d, ", ".join(ops))
        return d

    def get_warning(self):
        """
        Return a warning message about this basket line if one is applicable

        This could be things like the price has changed
        """
        if isinstance(self.purchase_info.availability, Unavailable):
            msg = "'%(product)s' is no longer available"
            return _(msg) % {'product': self.product.get_title()}

        if not self.price_incl_tax:
            return
        if not self.purchase_info.price.is_tax_known:
            return

        # Compare current price to price when added to basket
        current_price_incl_tax = self.purchase_info.price.incl_tax
        if current_price_incl_tax != self.price_incl_tax:
            product_prices = {
                'product': self.product.get_title(),
                'old_price': currency(self.price_incl_tax),
                'new_price': currency(current_price_incl_tax)
            }
            if current_price_incl_tax > self.price_incl_tax:
                warning = _("The price of '%(product)s' has increased from"
                            " %(old_price)s to %(new_price)s since you added"
                            " it to your basket")
                return warning % product_prices
            else:
                warning = _("The price of '%(product)s' has decreased from"
                            " %(old_price)s to %(new_price)s since you added"
                            " it to your basket")
                return warning % product_prices
示例#3
0
class AbstractCategory(MP_Node):
    """
    A product category. Merely used for navigational purposes; has no
    effects on business logic.

    Uses :py:mod:`django-treebeard`.
    """
    #: Allow comparison of categories on a limited number of fields by ranges.
    #: When the Category model is overwritten to provide CMS content, defining
    #: this avoids fetching a lot of unneeded extra data from the database.
    COMPARISON_FIELDS = ('pk', 'path', 'depth')

    name = models.CharField(_('Name'), max_length=255, db_index=True)
    description = models.TextField(_('Description'), blank=True)
    image = models.ImageField(_('Image'),
                              upload_to='categories',
                              blank=True,
                              null=True,
                              max_length=255)
    slug = SlugField(_('Slug'), max_length=255, db_index=True)

    is_public = models.BooleanField(
        _('Is public'),
        default=True,
        db_index=True,
        help_text=_(
            "Show this category in search results and catalogue listings."))

    ancestors_are_public = models.BooleanField(
        _('Ancestor categories are public'),
        default=True,
        db_index=True,
        help_text=_("The ancestors of this category are public"))

    _slug_separator = '/'
    _full_name_separator = ' > '

    objects = CategoryQuerySet.as_manager()

    def __str__(self):
        return self.full_name

    @property
    def full_name(self):
        """
        Returns a string representation of the category and it's ancestors,
        e.g. 'Books > Non-fiction > Essential programming'.

        It's rarely used in Oscar, but used to be stored as a CharField and is
        hence kept for backwards compatibility. It's also sufficiently useful
        to keep around.
        """
        names = [category.name for category in self.get_ancestors_and_self()]
        return self._full_name_separator.join(names)

    def get_full_slug(self, parent_slug=None):
        if self.is_root():
            return self.slug

        cache_key = self.get_url_cache_key()
        full_slug = cache.get(cache_key)
        if full_slug is None:
            parent_slug = parent_slug if parent_slug is not None else self.get_parent(
            ).full_slug
            full_slug = "%s%s%s" % (parent_slug, self._slug_separator,
                                    self.slug)
            cache.set(cache_key, full_slug)

        return full_slug

    @property
    def full_slug(self):
        """
        Returns a string of this category's slug concatenated with the slugs
        of it's ancestors, e.g. 'books/non-fiction/essential-programming'.

        Oscar used to store this as in the 'slug' model field, but this field
        has been re-purposed to only store this category's slug and to not
        include it's ancestors' slugs.
        """
        return self.get_full_slug()

    def generate_slug(self):
        """
        Generates a slug for a category. This makes no attempt at generating
        a unique slug.
        """
        return slugify(self.name)

    def save(self, *args, **kwargs):
        """
        Oscar traditionally auto-generated slugs from names. As that is
        often convenient, we still do so if a slug is not supplied through
        other means. If you want to control slug creation, just create
        instances with a slug already set, or expose a field on the
        appropriate forms.
        """
        if not self.slug:
            self.slug = self.generate_slug()
        super().save(*args, **kwargs)

    def set_ancestors_are_public(self):
        # Update ancestors_are_public for the sub tree.
        # note: This doesn't trigger a new save for each instance, rather
        # just a SQL update.
        included_in_non_public_subtree = self.__class__.objects.filter(
            is_public=False,
            path__rstartswith=OuterRef("path"),
            depth__lt=OuterRef("depth"))
        self.get_descendants_and_self().update(ancestors_are_public=Exists(
            included_in_non_public_subtree.values("id"), negated=True))

        # Correctly populate ancestors_are_public
        self.refresh_from_db()

    @classmethod
    def fix_tree(cls, destructive=False):
        super().fix_tree(destructive)
        for node in cls.get_root_nodes():
            # ancestors_are_public *must* be True for root nodes, or all trees
            # will become non-public
            if not node.ancestors_are_public:
                node.ancestors_are_public = True
                node.save()
            else:
                node.set_ancestors_are_public()

    def get_ancestors_and_self(self):
        """
        Gets ancestors and includes itself. Use treebeard's get_ancestors
        if you don't want to include the category itself. It's a separate
        function as it's commonly used in templates.
        """
        if self.is_root():
            return [self]

        return list(self.get_ancestors()) + [self]

    def get_descendants_and_self(self):
        """
        Gets descendants and includes itself. Use treebeard's get_descendants
        if you don't want to include the category itself. It's a separate
        function as it's commonly used in templates.
        """
        return self.get_tree(self)

    def get_url_cache_key(self):
        current_locale = get_language()
        cache_key = 'CATEGORY_URL_%s_%s' % (current_locale, self.pk)
        return cache_key

    def _get_absolute_url(self, parent_slug=None):
        """
        Our URL scheme means we have to look up the category's ancestors. As
        that is a bit more expensive, we cache the generated URL. That is
        safe even for a stale cache, as the default implementation of
        ProductCategoryView does the lookup via primary key anyway. But if
        you change that logic, you'll have to reconsider the caching
        approach.
        """
        return reverse('catalogue:category',
                       kwargs={
                           'category_slug':
                           self.get_full_slug(parent_slug=parent_slug),
                           'pk':
                           self.pk
                       })

    def get_absolute_url(self):
        return self._get_absolute_url()

    class Meta:
        abstract = True
        app_label = 'catalogue'
        ordering = ['path']
        verbose_name = _('Category')
        verbose_name_plural = _('Categories')

    def has_children(self):
        return self.get_num_children() > 0

    def get_num_children(self):
        return self.get_children().count()
示例#4
0
class AbstractCategory(MP_Node):
    """
    A product category. Merely used for navigational purposes; has no
    effects on business logic.

    Uses django-treebeard.
    """
    name = models.CharField(_('Name'), max_length=255, db_index=True)
    description = models.TextField(_('Description'), blank=True)
    image = models.ImageField(_('Image'),
                              upload_to='categories',
                              blank=True,
                              null=True,
                              max_length=255)
    slug = SlugField(_('Slug'), max_length=255, db_index=True)

    _slug_separator = '/'
    _full_name_separator = ' > '

    def __str__(self):
        return self.full_name

    @property
    def full_name(self):
        """
        Returns a string representation of the category and it's ancestors,
        e.g. 'Books > Non-fiction > Essential programming'.

        It's rarely used in Oscar's codebase, but used to be stored as a
        CharField and is hence kept for backwards compatibility. It's also
        sufficiently useful to keep around.
        """
        names = [category.name for category in self.get_ancestors_and_self()]
        return self._full_name_separator.join(names)

    @property
    def full_slug(self):
        """
        Returns a string of this category's slug concatenated with the slugs
        of it's ancestors, e.g. 'books/non-fiction/essential-programming'.

        Oscar used to store this as in the 'slug' model field, but this field
        has been re-purposed to only store this category's slug and to not
        include it's ancestors' slugs.
        """
        slugs = [category.slug for category in self.get_ancestors_and_self()]
        return self._slug_separator.join(slugs)

    def generate_slug(self):
        """
        Generates a slug for a category. This makes no attempt at generating
        a unique slug.
        """
        return slugify(self.name)

    def ensure_slug_uniqueness(self):
        """
        Ensures that the category's slug is unique amongst it's siblings.
        This is inefficient and probably not thread-safe.
        """
        unique_slug = self.slug
        siblings = self.get_siblings().exclude(pk=self.pk)
        next_num = 2
        while siblings.filter(slug=unique_slug).exists():
            unique_slug = '{slug}_{end}'.format(slug=self.slug, end=next_num)
            next_num += 1

        if unique_slug != self.slug:
            self.slug = unique_slug
            self.save()

    def save(self, *args, **kwargs):
        """
        Oscar traditionally auto-generated slugs from names. As that is
        often convenient, we still do so if a slug is not supplied through
        other means. If you want to control slug creation, just create
        instances with a slug already set, or expose a field on the
        appropriate forms.
        """
        if self.slug:
            # Slug was supplied. Hands off!
            super(AbstractCategory, self).save(*args, **kwargs)
        else:
            self.slug = self.generate_slug()
            super(AbstractCategory, self).save(*args, **kwargs)
            # We auto-generated a slug, so we need to make sure that it's
            # unique. As we need to be able to inspect the category's siblings
            # for that, we need to wait until the instance is saved. We
            # update the slug and save again if necessary.
            self.ensure_slug_uniqueness()

    def get_ancestors_and_self(self):
        """
        Gets ancestors and includes itself. Use treebeard's get_ancestors
        if you don't want to include the category itself. It's a separate
        function as it's commonly used in templates.
        """
        return list(self.get_ancestors()) + [self]

    def get_descendants_and_self(self):
        """
        Gets descendants and includes itself. Use treebeard's get_descendants
        if you don't want to include the category itself. It's a separate
        function as it's commonly used in templates.
        """
        return list(self.get_descendants()) + [self]

    def get_absolute_url(self):
        """
        Our URL scheme means we have to look up the category's ancestors. As
        that is a bit more expensive, we cache the generated URL. That is
        safe even for a stale cache, as the default implementation of
        ProductCategoryView does the lookup via primary key anyway. But if
        you change that logic, you'll have to reconsider the caching
        approach.
        """
        current_locale = get_language()
        cache_key = 'CATEGORY_URL_%s_%s' % (current_locale, self.pk)
        url = cache.get(cache_key)
        if not url:
            url = reverse('catalogue:category',
                          kwargs={
                              'category_slug': self.full_slug,
                              'pk': self.pk
                          })
            cache.set(cache_key, url)
        return url

    class Meta:
        abstract = True
        app_label = 'catalogue'
        ordering = ['path']
        verbose_name = _('Category')
        verbose_name_plural = _('Categories')

    def has_children(self):
        return self.get_num_children() > 0

    def get_num_children(self):
        return self.get_children().count()
示例#5
0
class AbstractCategory(MP_Node):
    """
    A product category. Merely used for navigational purposes; has no
    effects on business logic.

    Uses django-treebeard.
    """
    #: Allow comparison of categories on a limited number of fields by ranges.
    #: When the Category model is overwriten to provide CMS content, defining
    #: this avoids fetching a lot of unneeded extra data from the database.
    COMPARISON_FIELDS = ('pk', 'path', 'depth')

    name = models.CharField(_('Name'), max_length=255, db_index=True)
    description = models.TextField(_('Description'), blank=True)
    image = models.ImageField(_('Image'),
                              upload_to='categories',
                              blank=True,
                              null=True,
                              max_length=255)
    slug = SlugField(_('Slug'), max_length=255, db_index=True)

    _slug_separator = '/'
    _full_name_separator = ' > '

    def __str__(self):
        return self.full_name

    @property
    def full_name(self):
        """
        Returns a string representation of the category and it's ancestors,
        e.g. 'Books > Non-fiction > Essential programming'.

        It's rarely used in Oscar's codebase, but used to be stored as a
        CharField and is hence kept for backwards compatibility. It's also
        sufficiently useful to keep around.
        """
        names = [category.name for category in self.get_ancestors_and_self()]
        return self._full_name_separator.join(names)

    @property
    def full_slug(self):
        """
        Returns a string of this category's slug concatenated with the slugs
        of it's ancestors, e.g. 'books/non-fiction/essential-programming'.

        Oscar used to store this as in the 'slug' model field, but this field
        has been re-purposed to only store this category's slug and to not
        include it's ancestors' slugs.
        """
        slugs = [category.slug for category in self.get_ancestors_and_self()]
        return self._slug_separator.join(slugs)

    def generate_slug(self):
        """
        Generates a slug for a category. This makes no attempt at generating
        a unique slug.
        """
        return slugify(self.name)

    def save(self, *args, **kwargs):
        """
        Oscar traditionally auto-generated slugs from names. As that is
        often convenient, we still do so if a slug is not supplied through
        other means. If you want to control slug creation, just create
        instances with a slug already set, or expose a field on the
        appropriate forms.
        """
        if not self.slug:
            self.slug = self.generate_slug()

        super().save(*args, **kwargs)

    def get_ancestors_and_self(self):
        """
        Gets ancestors and includes itself. Use treebeard's get_ancestors
        if you don't want to include the category itself. It's a separate
        function as it's commonly used in templates.
        """
        return list(self.get_ancestors()) + [self]

    def get_descendants_and_self(self):
        """
        Gets descendants and includes itself. Use treebeard's get_descendants
        if you don't want to include the category itself. It's a separate
        function as it's commonly used in templates.
        """
        return list(self.get_descendants()) + [self]

    def get_url_cache_key(self):
        current_locale = get_language()
        cache_key = 'CATEGORY_URL_%s_%s' % (current_locale, self.pk)
        return cache_key

    def get_absolute_url(self):
        """
        Our URL scheme means we have to look up the category's ancestors. As
        that is a bit more expensive, we cache the generated URL. That is
        safe even for a stale cache, as the default implementation of
        ProductCategoryView does the lookup via primary key anyway. But if
        you change that logic, you'll have to reconsider the caching
        approach.
        """
        cache_key = self.get_url_cache_key()
        url = cache.get(cache_key)
        if not url:
            url = reverse('catalogue:category',
                          kwargs={
                              'category_slug': self.full_slug,
                              'pk': self.pk
                          })
            cache.set(cache_key, url)
        return url

    class Meta:
        abstract = True
        app_label = 'catalogue'
        ordering = ['path']
        verbose_name = _('Category')
        verbose_name_plural = _('Categories')

    def has_children(self):
        return self.get_num_children() > 0

    def get_num_children(self):
        return self.get_children().count()
示例#6
0
class Line(CoreAbstractLine):
    basket = models.ForeignKey(Basket,
                               on_delete=models.CASCADE,
                               related_name='lines',
                               verbose_name=_("Basket"))
    product = models.ForeignKey(Product,
                                related_name='basket_lines',
                                on_delete=models.SET_NULL,
                                verbose_name='Product',
                                blank=True,
                                null=True)
    stockrecord = models.ForeignKey('partner.StockRecord',
                                    on_delete=models.SET_NULL,
                                    related_name='basket_lines',
                                    blank=True,
                                    null=True)
    line_reference = SlugField(_("Line Reference"),
                               max_length=128,
                               db_index=True,
                               blank=True,
                               null=True)
    ledger_description = models.TextField(blank=True, null=True)
    oracle_code = models.CharField("Oracle Code",
                                   max_length=50,
                                   null=True,
                                   blank=True)
    price_excl_tax = models.DecimalField(_('Price excl. Tax'),
                                         decimal_places=12,
                                         max_digits=22,
                                         null=True)

    def __str__(self):
        return _(u"Basket #%(basket_id)d, Product #%(product_id)s, quantity"
                 u" %(quantity)d") % {
                     'basket_id': self.basket.pk,
                     'product_id': self.product.pk if self.product else None,
                     'quantity': self.quantity
                 }

    @property
    def purchase_info(self):
        """
        Return the stock/price info
        """
        if not self.basket.custom_ledger:
            if not hasattr(self, '_info'):
                # Cache the PurchaseInfo instance.
                self._info = self.basket.strategy.fetch_for_line(
                    self, self.stockrecord)
            return self._info
        else:
            #return None
            info = lambda: None
            info.price = lambda: None
            info.price.excl_tax = self.price_excl_tax
            info.price.incl_tax = self.price_incl_tax
            info.price.tax = (self.price_incl_tax - self.price_excl_tax)
            info.price.is_tax_known = True

            self._info = info
            return self._info

    @property
    def is_tax_known(self):
        return self.purchase_info.price.is_tax_known if self.purchase_info else (
            True if self.unit_tax else False)

    @property
    def unit_effective_price(self):
        """
        The price to use for offer calculations
        """
        return self.purchase_info.price.effective_price

    @property
    def unit_price_excl_tax(self):
        return self.purchase_info.price.excl_tax if self.purchase_info else self.price_excl_tax

    @property
    def unit_price_incl_tax(self):
        return self.purchase_info.price.incl_tax if self.purchase_info else self.price_incl_tax

    @property
    def unit_tax(self):
        return self.purchase_info.price.tax if self.purchase_info else (
            self.price_incl_tax - self.price_excl_tax)