Source code for shoop.core.models._products

# -*- coding: utf-8 -*-
# This file is part of Shoop.
#
# Copyright (c) 2012-2016, Shoop Ltd. All rights reserved.
#
# This source code is licensed under the AGPLv3 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 shoop.core.excs import ImpossibleProductModeException
from shoop.core.fields import InternalIdentifierField, MeasurementField
from shoop.core.taxing import TaxableItem
from shoop.core.utils.slugs import generate_multilanguage_slugs
from shoop.utils.analog import define_log_model, LogEntryKind

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


class ProductMode(Enum):
    NORMAL = 0
    PACKAGE_PARENT = 1
    SIMPLE_VARIATION_PARENT = 2
    VARIABLE_VARIATION_PARENT = 3
    VARIATION_CHILD = 4

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


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')),
    )
    attributes = models.ManyToManyField(
        "Attribute", blank=True, related_name='product_types',
        verbose_name=_('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 list_visible(self, shop, customer, language=None):
        root = (self.language(language) if language else self)

        if customer and customer.is_all_seeing:
            exclude_q = Q(deleted=True) | Q(mode=ProductMode.VARIATION_CHILD)
            qs = root.all().exclude(exclude_q).filter(shop_products__shop=shop)
        else:
            qs = root.all().exclude(deleted=True).filter(
                shop_products__shop=shop,
                shop_products__visible=True,
                shop_products__listed=True,
                mode__in=(
                    ProductMode.NORMAL, ProductMode.PACKAGE_PARENT,
                    ProductMode.SIMPLE_VARIATION_PARENT, ProductMode.VARIABLE_VARIATION_PARENT
                )
            )
            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)
        return qs

    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, 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'))
    shipping_mode = EnumIntegerField(ShippingMode, default=ShippingMode.NOT_SHIPPED, verbose_name=_('shipping mode'))
    sales_unit = models.ForeignKey("SalesUnit", verbose_name=_('unit'), blank=True, null=True, on_delete=models.PROTECT)
    tax_class = models.ForeignKey("TaxClass", verbose_name=_('tax class'), on_delete=models.PROTECT)

    # Identification
    type = models.ForeignKey(
        "ProductType", related_name='products',
        on_delete=models.PROTECT, db_index=True,
        verbose_name=_('product type'))
    sku = models.CharField(db_index=True, max_length=128, verbose_name=_('SKU'), unique=True)
    gtin = models.CharField(blank=True, max_length=40, verbose_name=_('GTIN'), help_text=_('Global Trade Item Number'))
    barcode = models.CharField(blank=True, max_length=40, verbose_name=_('barcode'))
    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)
    # Category is duplicated here because not all products necessarily belong in Shops (i.e. have
    # ShopProduct instances), but they should nevertheless be searchable by category in other
    # places, such as administration UIs.
    category = models.ForeignKey(
        "Category", related_name='primary_products', blank=True, null=True,
        verbose_name=_('primary category'),
        help_text=_("only used for administration and reporting"), on_delete=models.PROTECT)

    # Physical dimensions
    width = MeasurementField(unit="mm", verbose_name=_('width (mm)'))
    height = MeasurementField(unit="mm", verbose_name=_('height (mm)'))
    depth = MeasurementField(unit="mm", verbose_name=_('depth (mm)'))
    net_weight = MeasurementField(unit="g", verbose_name=_('net weight (g)'))
    gross_weight = MeasurementField(unit="g", verbose_name=_('gross weight (g)'))

    # Misc.
    manufacturer = models.ForeignKey(
        "Manufacturer", blank=True, null=True,
        verbose_name=_('manufacturer'), on_delete=models.PROTECT)
    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')),
        description=models.TextField(blank=True, verbose_name=_('description')),
        slug=models.SlugField(verbose_name=_('slug'), max_length=255, null=True),
        keywords=models.TextField(blank=True, verbose_name=_('keywords')),
        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.'
                ' (Ex.: "Available in a month")')),
        variation_name=models.CharField(
            max_length=128, blank=True,
            verbose_name=_('variation name'))
    )

    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): """ :type shop: shoop.core.models.Shop :rtype: shoop.core.models.ShopProduct """ shop_inst_cache = self.__dict__.setdefault("_shop_inst_cache", {}) cached = shop_inst_cache.get(shop) if cached: return cached shop_inst = self.shop_products.get(shop=shop) shop_inst._product_cache = self shop_inst._shop_cache = shop shop_inst_cache[shop] = 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()) 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: shoop.core.pricing.PricingContextable :type quantity: int :return: a tuple of prices :rtype: (shoop.core.pricing.Price, shoop.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: shoop.core.pricing.PricingContextable :rtype: shoop.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: shoop.core.pricing.PricingContextable :rtype: shoop.core.pricing.PriceInfo """ from shoop.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: shoop.core.pricing.PricingContextable :rtype: shoop.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: shoop.core.pricing.PricingContextable :rtype: shoop.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: shoop.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): if self.deleted: return None return (self.safe_translation_getter("name") or 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): # :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_package_parent(): 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_package_parent(): 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_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)
[docs] def is_stocked(self): return (self.stock_behavior == StockBehavior.STOCKED)
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')