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)
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
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()
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()
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()
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)