Source code for shuup.core.models._products

# -*- coding: utf-8 -*-
# This file is part of Shuup.
#
# Copyright (c) 2012-2017, Shoop Commerce Ltd. All rights reserved.
#
# This source code is licensed under the OSL-3.0 license found in the
# LICENSE file in the root directory of this source tree.
from __future__ import unicode_literals, with_statement

import six
from django.core.exceptions import ObjectDoesNotExist
from django.db import models
from django.db.models import Q
from django.utils.encoding import python_2_unicode_compatible
from django.utils.translation import ugettext_lazy as _
from enumfields import Enum, EnumIntegerField
from parler.managers import TranslatableQuerySet
from parler.models import TranslatableModel, TranslatedFields

from shuup.core.excs import ImpossibleProductModeException
from shuup.core.fields import InternalIdentifierField, MeasurementField
from shuup.core.taxing import TaxableItem
from shuup.core.utils import context_cache
from shuup.core.utils.slugs import generate_multilanguage_slugs
from shuup.utils.analog import define_log_model, LogEntryKind

from ._attributes import AppliedAttribute, AttributableMixin, Attribute
from ._product_media import ProductMediaKind
from ._product_packages import ProductPackageLink
from ._product_variation import (
    get_all_available_combinations, get_combination_hash_from_variable_mapping,
    ProductVariationResult, ProductVariationVariable
)


# TODO (2.0): This should be extandable
class ProductMode(Enum):
    NORMAL = 0
    PACKAGE_PARENT = 1
    SIMPLE_VARIATION_PARENT = 2
    VARIABLE_VARIATION_PARENT = 3
    VARIATION_CHILD = 4
    SUBSCRIPTION = 5  # This is like package_parent and all the functionality is same under the hood.

    class Labels:
        NORMAL = _('normal')
        PACKAGE_PARENT = _('package parent')
        SIMPLE_VARIATION_PARENT = _('variation parent (simple)')
        VARIABLE_VARIATION_PARENT = _('variation parent (variable)')
        VARIATION_CHILD = _('variation child')
        SUBSCRIPTION = _('subscription')


class ProductVisibility(Enum):
    VISIBLE_TO_ALL = 1
    VISIBLE_TO_LOGGED_IN = 2
    VISIBLE_TO_GROUPS = 3

    class Labels:
        VISIBLE_TO_ALL = _('visible to all')
        VISIBLE_TO_LOGGED_IN = _('visible to logged in')
        VISIBLE_TO_GROUPS = _('visible to groups')


class StockBehavior(Enum):
    UNSTOCKED = 0
    STOCKED = 1

    class Labels:
        STOCKED = _('stocked')
        UNSTOCKED = _('unstocked')


class ProductCrossSellType(Enum):
    RECOMMENDED = 1
    RELATED = 2
    COMPUTED = 3
    BOUGHT_WITH = 4

    class Labels:
        RECOMMENDED = _('recommended')
        RELATED = _('related')
        COMPUTED = _('computed')
        BOUGHT_WITH = _('bought with')


class ShippingMode(Enum):
    NOT_SHIPPED = 0
    SHIPPED = 1

    class Labels:
        NOT_SHIPPED = _('not shipped')
        SHIPPED = _('shipped')


class ProductVerificationMode(Enum):
    NO_VERIFICATION_REQUIRED = 0
    ADMIN_VERIFICATION_REQUIRED = 1
    THIRD_PARTY_VERIFICATION_REQUIRED = 2

    class Labels:
        NO_VERIFICATION_REQUIRED = _('no verification required')
        ADMIN_VERIFICATION_REQUIRED = _('admin verification required')
        THIRD_PARTY_VERIFICATION_REQUIRED = _('third party verification required')


@python_2_unicode_compatible
class ProductType(TranslatableModel):
    identifier = InternalIdentifierField(unique=True)
    translations = TranslatedFields(
        name=models.CharField(max_length=64, verbose_name=_('name'), help_text=_(
                "Enter a descriptive name for your product type. "
                "Products and attributes for products of this type can be found under this name."
            )
        ),
    )
    attributes = models.ManyToManyField(
        "Attribute", blank=True, related_name='product_types',
        verbose_name=_('attributes'), help_text=_(
            "Select attributes that go with your product type. These are defined in Products Settings – Attributes."
        )
    )

    class Meta:
        verbose_name = _('product type')
        verbose_name_plural = _('product types')

    def __str__(self):
        return (self.safe_translation_getter("name") or self.identifier)


class ProductQuerySet(TranslatableQuerySet):

    def _get_invisible_modes(self):
        return [ProductMode.VARIATION_CHILD]

    def _visible(self, shop, customer, language=None):
        root = (self.language(language) if language else self)

        if customer and customer.is_all_seeing:
            qs = root.all().exclude(mode=ProductMode.VARIATION_CHILD).filter(shop_products__shop=shop)
        else:
            from ._product_shops import ShopProductVisibility
            qs = root.all().exclude(Q(
                        shop_products__shop=shop,
                        shop_products__visibility=ShopProductVisibility.NOT_VISIBLE
                    )).exclude(
                mode__in=self._get_invisible_modes()
            )
            if customer and not customer.is_anonymous:
                visible_to_logged_in_q = Q(shop_products__visibility_limit__in=(
                    ProductVisibility.VISIBLE_TO_ALL, ProductVisibility.VISIBLE_TO_LOGGED_IN
                ))
                visible_to_my_groups_q = Q(
                    shop_products__visibility_limit=ProductVisibility.VISIBLE_TO_GROUPS,
                    shop_products__visibility_groups__in=customer.groups.all()
                )
                qs = qs.filter(visible_to_logged_in_q | visible_to_my_groups_q)
            else:
                qs = qs.filter(shop_products__visibility_limit=ProductVisibility.VISIBLE_TO_ALL)

        qs = qs.select_related(*Product.COMMON_SELECT_RELATED).distinct()
        return qs.exclude(deleted=True)

    def _get_qs(self, shop, customer, language, visibility_type):
        qs = self._visible(shop=shop, customer=customer, language=language)
        if customer and customer.is_all_seeing:
            return qs
        else:
            from ._product_shops import ShopProductVisibility
            return qs.filter(
                shop_products__shop=shop,
                shop_products__visibility__in=(
                    visibility_type, ShopProductVisibility.ALWAYS_VISIBLE
                ),
            )

    def listed(self, shop, customer=None, language=None):
        from ._product_shops import ShopProductVisibility
        return self._get_qs(shop, customer, language, ShopProductVisibility.LISTED)

    def searchable(self, shop, customer=None, language=None):
        from ._product_shops import ShopProductVisibility
        return self._get_qs(shop, customer, language, ShopProductVisibility.SEARCHABLE)

    def all_except_deleted(self, language=None):
        qs = (self.language(language) if language else self).exclude(deleted=True)
        qs = qs.select_related(*Product.COMMON_SELECT_RELATED)
        return qs


@python_2_unicode_compatible
class Product(TaxableItem, AttributableMixin, TranslatableModel):
    COMMON_SELECT_RELATED = ("type", "primary_image", "tax_class")

    # Metadata
    created_on = models.DateTimeField(auto_now_add=True, editable=False, db_index=True, verbose_name=_('created on'))
    modified_on = models.DateTimeField(auto_now=True, editable=False, verbose_name=_('modified on'))
    deleted = models.BooleanField(default=False, editable=False, db_index=True, verbose_name=_('deleted'))

    # Behavior
    mode = EnumIntegerField(ProductMode, default=ProductMode.NORMAL, verbose_name=_('mode'))
    variation_parent = models.ForeignKey(
        "self", null=True, blank=True, related_name='variation_children',
        on_delete=models.PROTECT,
        verbose_name=_('variation parent'))
    stock_behavior = EnumIntegerField(
        StockBehavior, default=StockBehavior.UNSTOCKED, verbose_name=_('stock'),
        help_text=_("Set to stocked if inventory should be managed within Shuup.")
    )
    shipping_mode = EnumIntegerField(
        ShippingMode, default=ShippingMode.SHIPPED, verbose_name=_('shipping mode'),
        help_text=_("Set to shipped if the product requires shipment.")
    )
    sales_unit = models.ForeignKey(
        "SalesUnit", verbose_name=_('sales unit'), blank=True, null=True, on_delete=models.PROTECT, help_text=_(
            "Select a sales unit for your product. "
            "This is shown in your store front and is used to determine whether the product can be purchased using "
            "fractional amounts. Sales units are defined in Products - Sales Units."
        )
    )
    tax_class = models.ForeignKey("TaxClass", verbose_name=_('tax class'), on_delete=models.PROTECT, help_text=_(
            "Select a tax class for your product. "
            "The tax class is used to determine which taxes to apply to your product. "
            "Tax classes are defined in Settings - Tax Classes. "
            "The rules by which taxes are applied are defined in Settings - Tax Rules."
        )
    )

    # Identification
    type = models.ForeignKey(
        "ProductType", related_name='products',
        on_delete=models.PROTECT, db_index=True,
        verbose_name=_('product type'),
        help_text=_(
            "Select a product type for your product. "
            "These allow you to configure custom attributes to help with classification and analysis."
        )
    )
    sku = models.CharField(
        db_index=True, max_length=128, verbose_name=_('SKU'), unique=True,
        help_text=_(
            "Enter a SKU (Stock Keeping Unit) number for your product. "
            "This is a product identification code that helps you track it through your inventory. "
            "People often use the number by the barcode on the product, "
            "but you can set up any numerical system you want to keep track of products."
        )
    )
    gtin = models.CharField(blank=True, max_length=40, verbose_name=_('GTIN'), help_text=_(
        "You can enter a Global Trade Item Number. "
        "This is typically a 14 digit identification number for all of your trade items. "
        "It can often be found by the barcode."
    ))
    barcode = models.CharField(blank=True, max_length=40, verbose_name=_('barcode'), help_text=_(
        "You can enter the barcode number for your product. This is useful for inventory/stock tracking and analysis."
    ))
    accounting_identifier = models.CharField(max_length=32, blank=True, verbose_name=_('bookkeeping account'))
    profit_center = models.CharField(max_length=32, verbose_name=_('profit center'), blank=True)
    cost_center = models.CharField(max_length=32, verbose_name=_('cost center'), blank=True)

    # Physical dimensions
    width = MeasurementField(
        unit="mm", verbose_name=_('width (mm)'),
        help_text=_(
            "Set the measured width of your product or product packaging. "
            "This will provide customers with your product size and help with calculating shipping costs."
        )
    )
    height = MeasurementField(
        unit="mm", verbose_name=_('height (mm)'),
        help_text=_(
            "Set the measured height of your product or product packaging. "
            "This will provide customers with your product size and help with calculating shipping costs."
        )
    )
    depth = MeasurementField(
        unit="mm", verbose_name=_('depth (mm)'),
        help_text=_(
            "Set the measured depth or length of your product or product packaging. "
            "This will provide customers with your product size and help with calculating shipping costs."
        )
    )
    net_weight = MeasurementField(
        unit="g", verbose_name=_('net weight (g)'),
        help_text=_(
            "Set the measured weight of your product WITHOUT its packaging. "
            "This will provide customers with your product weight."
        )
    )
    gross_weight = MeasurementField(
        unit="g", verbose_name=_('gross weight (g)'),
        help_text=_(
            "Set the measured gross Weight of your product WITH its packaging. "
            "This will help with calculating shipping costs."
        )
    )

    # Misc.
    manufacturer = models.ForeignKey(
        "Manufacturer", blank=True, null=True,
        verbose_name=_('manufacturer'), on_delete=models.PROTECT, help_text=_(
            "Select a manufacturer for your product. These are defined in Products Settings - Manufacturers"
        )
    )
    primary_image = models.ForeignKey(
        "ProductMedia", null=True, blank=True,
        related_name="primary_image_for_products",
        on_delete=models.SET_NULL,
        verbose_name=_("primary image"))

    translations = TranslatedFields(
        name=models.CharField(
            max_length=256, verbose_name=_('name'),
            help_text=_("Enter a descriptive name for your product. This will be its title in your store.")),
        description=models.TextField(
            blank=True, verbose_name=_('description'),
            help_text=_(
                "To make your product stand out, give it an awesome description. "
                "This is what will help your shoppers learn about your products. "
                "It will also help shoppers find them in the store and on the web."
            )
        ),
        short_description=models.CharField(
            max_length=150, blank=True, verbose_name=_('short description'),
            help_text=_(
                "Enter a short description for your product. "
                "The short description will be used to get the attention of your "
                "customer with a small but precise description of your product."
            )
        ),
        slug=models.SlugField(
            verbose_name=_('slug'), max_length=255, blank=True, null=True,
            help_text=_(
                "Enter a URL Slug for your product. This is what your product page URL will be. "
                "A default will be created using the product name."
            )
        ),
        keywords=models.TextField(blank=True, verbose_name=_('keywords'), help_text=_(
                "You can enter keywords that describe your product. "
                "This will help your shoppers learn about your products. "
                "It will also help shoppers find them in the store and on the web."
            )
        ),
        status_text=models.CharField(
            max_length=128, blank=True,
            verbose_name=_('status text'),
            help_text=_(
                'This text will be shown alongside the product in the shop. '
                'It is useful for informing customers of special stock numbers or preorders. '
                '(Ex.: "Available in a month")'
            )
        ),
        variation_name=models.CharField(
            max_length=128, blank=True,
            verbose_name=_('variation name'),
            help_text=_(
                "You can enter a name for the variation of your product. "
                "This could be for example different colors or versions."
            )
        )
    )

    objects = ProductQuerySet.as_manager()

    class Meta:
        ordering = ('-id',)
        verbose_name = _('product')
        verbose_name_plural = _('products')

    def __str__(self):
        try:
            return u"%s" % self.name
        except ObjectDoesNotExist:
            return self.sku

[docs] def get_shop_instance(self, shop, allow_cache=False): """ :type shop: shuup.core.models.Shop :rtype: shuup.core.models.ShopProduct """ key, val = context_cache.get_cached_value( identifier="shop_product", item=self, context={"shop": shop}, allow_cache=allow_cache) if val is not None: return val shop_inst = self.shop_products.get(shop=shop) context_cache.set_cached_value(key, shop_inst) return shop_inst
[docs] def get_priced_children(self, context, quantity=1): """ Get child products with price infos sorted by price. :rtype: list[(Product,PriceInfo)] :return: List of products and their price infos sorted from cheapest to most expensive. """ priced_children = ( (child, child.get_price_info(context, quantity=quantity)) for child in self.variation_children.all() if child.get_shop_instance(context.shop).is_orderable(supplier=None, customer=context.customer, quantity=1) ) return sorted(priced_children, key=(lambda x: x[1].price))
[docs] def get_cheapest_child_price(self, context, quantity=1): price_info = self.get_cheapest_child_price_info(context, quantity) if price_info: return price_info.price
[docs] def get_child_price_range(self, context, quantity=1): """ Get the prices for cheapest and the most expensive child The attribute used for sorting is `PriceInfo.price`. Return (`None`, `None`) if `self.variation_children` do not exist. This is because we cannot return anything sensible. :type context: shuup.core.pricing.PricingContextable :type quantity: int :return: a tuple of prices :rtype: (shuup.core.pricing.Price, shuup.core.pricing.Price) """ items = [c.get_price_info(context, quantity=quantity) for c in self.variation_children.all()] if not items: return (None, None) infos = sorted(items, key=lambda x: x.price) return (infos[0].price, infos[-1].price)
[docs] def get_cheapest_child_price_info(self, context, quantity=1): """ Get the `PriceInfo` of the cheapest variation child The attribute used for sorting is `PriceInfo.price`. Return `None` if `self.variation_children` do not exist. This is because we cannot return anything sensible. :type context: shuup.core.pricing.PricingContextable :rtype: shuup.core.pricing.PriceInfo """ items = [c.get_price_info(context, quantity=quantity) for c in self.variation_children.all()] if not items: return None return sorted(items, key=lambda x: x.price)[0]
[docs] def get_price_info(self, context, quantity=1): """ Get `PriceInfo` object for the product in given context. Returned `PriceInfo` object contains calculated `price` and `base_price`. The calculation of prices is handled in the current pricing module. :type context: shuup.core.pricing.PricingContextable :rtype: shuup.core.pricing.PriceInfo """ from shuup.core.pricing import get_price_info return get_price_info(product=self, context=context, quantity=quantity)
[docs] def get_price(self, context, quantity=1): """ Get price of the product within given context. .. note:: When the current pricing module implements pricing steps, it is possible that ``p.get_price(ctx) * 123`` is not equal to ``p.get_price(ctx, quantity=123)``, since there could be quantity discounts in effect, but usually they are equal. :type context: shuup.core.pricing.PricingContextable :rtype: shuup.core.pricing.Price """ return self.get_price_info(context, quantity).price
[docs] def get_base_price(self, context, quantity=1): """ Get base price of the product within given context. Base price differs from the (effective) price when there are discounts in effect. :type context: shuup.core.pricing.PricingContextable :rtype: shuup.core.pricing.Price """ return self.get_price_info(context, quantity=quantity).base_price
[docs] def get_available_attribute_queryset(self): if self.type_id: return self.type.attributes.visible() else: return Attribute.objects.none()
[docs] def get_available_variation_results(self): """ Get a dict of `combination_hash` to product ID of variable variation results. :return: Mapping of combination hashes to product IDs :rtype: dict[str, int] """ return dict( ProductVariationResult.objects.filter(product=self).filter(status=1) .values_list("combination_hash", "result_id") )
[docs] def get_all_available_combinations(self): """ Generate all available combinations of variation variables. If the product is not a variable variation parent, the iterator is empty. Because of possible combinatorial explosion this is a generator function. (For example 6 variables with 5 options each explodes to 15,625 combinations.) :return: Iterable of combination information dicts. :rtype: Iterable[dict] """ return get_all_available_combinations(self)
[docs] def clear_variation(self): """ Fully remove variation information. Make this product a non-variation parent. """ self.simplify_variation() for child in self.variation_children.all(): if child.variation_parent_id == self.pk: child.unlink_from_parent() self.verify_mode() self.save()
[docs] def simplify_variation(self): """ Remove variation variables from the given variation parent, turning it into a simple variation (or a normal product, if it has no children). :param product: Variation parent to not be variable any longer. :type product: shuup.core.models.Product """ ProductVariationVariable.objects.filter(product=self).delete() ProductVariationResult.objects.filter(product=self).delete() self.verify_mode() self.save()
@staticmethod def _get_slug_name(self, translation=None): if self.deleted: return None return getattr(translation, "name", self.sku)
[docs] def save(self, *args, **kwargs): if self.net_weight and self.net_weight > 0: self.gross_weight = max(self.net_weight, self.gross_weight) rv = super(Product, self).save(*args, **kwargs) generate_multilanguage_slugs(self, self._get_slug_name) return rv
[docs] def delete(self, using=None): raise NotImplementedError("Not implemented: Use `soft_delete()` for products.")
[docs] def soft_delete(self, user=None): if not self.deleted: self.deleted = True self.add_log_entry("Deleted.", kind=LogEntryKind.DELETION, user=user) # Bypassing local `save()` on purpose. super(Product, self).save(update_fields=("deleted",))
[docs] def verify_mode(self): if ProductPackageLink.objects.filter(parent=self).exists(): self.mode = ProductMode.PACKAGE_PARENT self.external_url = None self.variation_children.clear() elif ProductVariationVariable.objects.filter(product=self).exists(): self.mode = ProductMode.VARIABLE_VARIATION_PARENT elif self.variation_children.exists(): if ProductVariationResult.objects.filter(product=self).exists(): self.mode = ProductMode.VARIABLE_VARIATION_PARENT else: self.mode = ProductMode.SIMPLE_VARIATION_PARENT self.external_url = None ProductPackageLink.objects.filter(parent=self).delete() elif self.variation_parent: self.mode = ProductMode.VARIATION_CHILD ProductPackageLink.objects.filter(parent=self).delete() self.variation_children.clear() self.external_url = None else: self.mode = ProductMode.NORMAL
def _raise_if_cant_link_to_parent(self, parent, variables): """ Validates relation possibility for `self.link_to_parent()` :param parent: parent product of self :type parent: Product :param variables: :type variables: dict|None """ if parent.is_variation_child(): raise ImpossibleProductModeException( _("Multilevel parentage hierarchies aren't supported (parent is a child already)"), code="multilevel" ) if parent.mode == ProductMode.VARIABLE_VARIATION_PARENT and not variables: raise ImpossibleProductModeException( _("Parent is a variable variation parent, yet variables were not passed"), code="no_variables" ) if parent.mode == ProductMode.SIMPLE_VARIATION_PARENT and variables: raise ImpossibleProductModeException( "Parent is a simple variation parent, yet variables were passed", code="extra_variables" ) if self.mode == ProductMode.SIMPLE_VARIATION_PARENT: raise ImpossibleProductModeException( _("Multilevel parentage hierarchies aren't supported (this product is a simple variation parent)"), code="multilevel" ) if self.mode == ProductMode.VARIABLE_VARIATION_PARENT: raise ImpossibleProductModeException( _("Multilevel parentage hierarchies aren't supported (this product is a variable variation parent)"), code="multilevel" )
[docs] def make_package(self, package_def): if self.mode != ProductMode.NORMAL: raise ImpossibleProductModeException( _("Product is currently not a normal product, can't turn into package"), code="abnormal" ) for child_product, quantity in six.iteritems(package_def): if child_product.pk == self.pk: raise ImpossibleProductModeException(_("Package can't contain itself"), code="content") # :type child_product: Product if child_product.is_variation_parent(): raise ImpossibleProductModeException( _("Variation parents can not belong into a package"), code="abnormal" ) if child_product.is_container(): raise ImpossibleProductModeException(_("Packages can't be nested"), code="multilevel") if quantity <= 0: raise ImpossibleProductModeException(_("Quantity %s is invalid") % quantity, code="quantity") ProductPackageLink.objects.create(parent=self, child=child_product, quantity=quantity) self.verify_mode()
[docs] def get_package_child_to_quantity_map(self): if self.is_container(): product_id_to_quantity = dict( ProductPackageLink.objects.filter(parent=self).values_list("child_id", "quantity") ) products = dict((p.pk, p) for p in Product.objects.filter(pk__in=product_id_to_quantity.keys())) return {products[product_id]: quantity for (product_id, quantity) in six.iteritems(product_id_to_quantity)} return {}
[docs] def is_variation_parent(self): return self.mode in (ProductMode.SIMPLE_VARIATION_PARENT, ProductMode.VARIABLE_VARIATION_PARENT)
[docs] def is_variation_child(self): return (self.mode == ProductMode.VARIATION_CHILD)
[docs] def get_variation_siblings(self): return Product.objects.filter(variation_parent=self.variation_parent).exclude(pk=self.pk)
[docs] def is_package_parent(self): return (self.mode == ProductMode.PACKAGE_PARENT)
[docs] def is_subscription_parent(self): return (self.mode == ProductMode.SUBSCRIPTION)
[docs] def is_package_child(self): return ProductPackageLink.objects.filter(child=self).exists()
[docs] def get_all_package_parents(self): return Product.objects.filter(pk__in=( ProductPackageLink.objects.filter(child=self).values_list("parent", flat=True) ))
[docs] def get_all_package_children(self): return Product.objects.filter(pk__in=( ProductPackageLink.objects.filter(parent=self).values_list("child", flat=True) ))
[docs] def get_public_media(self): return self.media.filter(enabled=True, public=True).exclude(kind=ProductMediaKind.IMAGE)
[docs] def is_stocked(self): return (self.stock_behavior == StockBehavior.STOCKED)
[docs] def is_container(self): return (self.is_package_parent() or self.is_subscription_parent())
ProductLogEntry = define_log_model(Product) class ProductCrossSell(models.Model): product1 = models.ForeignKey( Product, related_name="cross_sell_1", on_delete=models.CASCADE, verbose_name=_("primary product")) product2 = models.ForeignKey( Product, related_name="cross_sell_2", on_delete=models.CASCADE, verbose_name=_("secondary product")) weight = models.IntegerField(default=0, verbose_name=_("weight")) type = EnumIntegerField(ProductCrossSellType, verbose_name=_("type")) class Meta: verbose_name = _('cross sell link') verbose_name_plural = _('cross sell links') class ProductAttribute(AppliedAttribute): _applied_fk_field = "product" product = models.ForeignKey(Product, related_name='attributes', on_delete=models.CASCADE, verbose_name=_("product")) translations = TranslatedFields( translated_string_value=models.TextField(blank=True, verbose_name=_("translated value")) ) class Meta: abstract = False verbose_name = _('product attribute') verbose_name_plural = _('product attributes')