class WaivingCostBehaviorComponent(TranslatableServiceBehaviorComponent): name = _("Waiving cost") help_text = _( "Add cost to price of the service if total price " "of products is less than a waive limit.") price_value = MoneyValueField( help_text=_("The cost to apply to this service if the total price is below the waive limit.")) waive_limit_value = MoneyValueField(help_text=_( "The total price of products at which this service cost is waived." )) description = TranslatedField(any_language=True) translations = TranslatedFields( description=models.CharField(max_length=100, blank=True, verbose_name=_("description"), help_text=_( "The order line text to display when this behavior is applied." )), ) def get_costs(self, service, source): waive_limit = source.create_price(self.waive_limit_value) product_total = source.total_price_of_products price = source.create_price(self.price_value) description = self.safe_translation_getter('description') zero_price = source.create_price(0) if product_total and product_total >= waive_limit: yield ServiceCost(zero_price, description, base_price=price) else: yield ServiceCost(price, description)
class AbstractPayment(MoneyPropped, models.Model): created_on = models.DateTimeField(auto_now_add=True, verbose_name=_('created on')) gateway_id = models.CharField( max_length=32, verbose_name=_('gateway ID')) # TODO: do we need this? payment_identifier = models.CharField(max_length=96, unique=True, verbose_name=_('identifier')) amount = MoneyProperty('amount_value', 'currency') foreign_amount = MoneyProperty('foreign_amount_value', 'foreign_currency') amount_value = MoneyValueField(verbose_name=_('amount')) foreign_amount_value = MoneyValueField(default=None, blank=True, null=True, verbose_name=_('foreign amount')) foreign_currency = CurrencyField(default=None, blank=True, null=True, verbose_name=_('foreign amount currency')) description = models.CharField(max_length=256, blank=True, verbose_name=_('description')) class Meta: abstract = True
class OrderLineTax(MoneyPropped, WshopModel, LineTax): order_line = models.ForeignKey(OrderLine, related_name='taxes', on_delete=models.PROTECT, verbose_name=_('order line')) tax = models.ForeignKey("Tax", related_name="order_line_taxes", on_delete=models.PROTECT, verbose_name=_('tax')) name = models.CharField(max_length=200, verbose_name=_('tax name')) amount = MoneyProperty('amount_value', 'order_line.order.currency') base_amount = MoneyProperty('base_amount_value', 'order_line.order.currency') amount_value = MoneyValueField(verbose_name=_('tax amount')) base_amount_value = MoneyValueField( verbose_name=_('base amount'), help_text=_('Amount that this tax is calculated from')) ordering = models.IntegerField(default=0, verbose_name=_('ordering')) class Meta: ordering = ["ordering"] def __str__(self): return "%s: %s on %s" % (self.name, self.amount, self.base_amount)
class OrderTotalLimitBehaviorComponent(ServiceBehaviorComponent): name = _("Order total limit") help_text = _("Limit service availability based on order total") min_price_value = MoneyValueField(blank=True, null=True, verbose_name=_("min price value")) max_price_value = MoneyValueField(blank=True, null=True, verbose_name=_("max price value")) def get_unavailability_reasons(self, service, source): total = ( source.taxful_total_price.value if source.shop.prices_include_tax else source.taxless_total_price.value) is_in_range = _is_in_range(total, self.min_price_value, self.max_price_value) if not is_in_range: yield ValidationError(_("Order total does not match with service limits."), code="order_total_out_of_range")
class BasketTotalAmountCondition(MoneyPropped, BasketCondition): identifier = "basket_amount_condition" name = _("Basket total value") amount = PriceProperty("amount_value", "campaign.shop.currency", "campaign.shop.prices_include_tax") amount_value = MoneyValueField(default=None, blank=True, null=True, verbose_name=_("basket total amount")) def matches(self, basket, lines): return (basket.total_price_of_products.value >= self.amount_value) @property def description(self): return _( "Limit the campaign to match when it has at least the total value entered here worth of products." ) @property def value(self): return self.amount_value @value.setter def value(self, value): self.amount_value = value
class StockAdjustment(models.Model): product = models.ForeignKey("wshop.Product", related_name="+", on_delete=models.CASCADE, verbose_name=_("product")) supplier = models.ForeignKey("wshop.Supplier", on_delete=models.CASCADE, verbose_name=_("supplier")) created_on = models.DateTimeField(auto_now_add=True, editable=False, db_index=True, verbose_name=_("created on")) created_by = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True, on_delete=models.PROTECT, verbose_name=_("created by")) delta = QuantityField(default=0, verbose_name=_("delta")) purchase_price_value = MoneyValueField(default=0) purchase_price = PriceProperty("purchase_price_value", "currency", "includes_tax") type = EnumIntegerField(StockAdjustmentType, db_index=True, default=StockAdjustmentType.INVENTORY, verbose_name=_("type")) @cached_property def currency(self): return _get_currency() @cached_property def includes_tax(self): return _get_prices_include_tax()
class DiscountFromCategoryProducts(BasketLineEffect): identifier = "discount_from_category_products_line_effect" model = Category name = _("Discount from Category products") discount_amount = MoneyValueField( default=None, blank=True, null=True, verbose_name=_("discount amount"), help_text=_("Flat amount of discount.")) discount_percentage = models.DecimalField( max_digits=6, decimal_places=5, blank=True, null=True, verbose_name=_("discount percentage"), help_text=_("The discount percentage for this campaign.")) category = models.ForeignKey(Category, verbose_name=_("category")) @property def description(self): return _( 'Select discount amount and category. ' 'Please note that the discount will be given to all matching products in basket.') def get_discount_lines(self, order_source, original_lines): if not (self.discount_percentage or self.discount_amount): return [] product_ids = self.category.shop_products.values_list("product_id", flat=True) for line in original_lines: # Use original lines since we don't want to discount free product lines if not line.type == OrderLineType.PRODUCT: continue if line.product.variation_parent: if line.product.variation_parent.pk not in product_ids and line.product.pk not in product_ids: continue else: if line.product.pk not in product_ids: continue amount = order_source.zero_price.value base_price = line.base_unit_price.value * line.quantity if self.discount_amount: amount = self.discount_amount * line.quantity elif self.discount_percentage: amount = base_price * self.discount_percentage # we use min() to limit the amount of discount to base price # also in percentage, since one can configure 150% of discount discount_price = order_source.create_price(min(base_price, amount)) if not line.discount_amount or line.discount_amount < discount_price: line.discount_amount = discount_price # check for minimum price, if set, and change the discount amount _limit_discount_amount_by_min_price(line, order_source) return []
class FixedCostBehaviorComponent(TranslatableServiceBehaviorComponent): name = _("Fixed cost") help_text = _("Add fixed cost to price of the service.") price_value = MoneyValueField(help_text=_("The fixed cost to apply to this service.")) description = TranslatedField(any_language=True) translations = TranslatedFields( description=models.CharField(max_length=100, blank=True, verbose_name=_("description"), help_text=_( "The order line text to display when this behavior is applied." )), ) def get_costs(self, service, source): price = source.create_price(self.price_value) description = self.safe_translation_getter('description') yield ServiceCost(price, description)
class DiscountFromProduct(BasketLineEffect): identifier = "discount_from_product_line_effect" model = Product name = _("Discount from Product") per_line_discount = models.BooleanField( default=True, verbose_name=_("per line discount"), help_text=_("Uncheck this if you want to give discount for each matched product.")) discount_amount = MoneyValueField( default=None, blank=True, null=True, verbose_name=_("discount amount"), help_text=_("Flat amount of discount.")) products = models.ManyToManyField(Product, verbose_name=_("product")) @property def description(self): return _("Select discount amount and products.") def get_discount_lines(self, order_source, original_lines): product_ids = self.products.values_list("pk", flat=True) for line in original_lines: if not line.type == OrderLineType.PRODUCT: continue if line.product.pk not in product_ids: continue base_price = line.base_unit_price.value * line.quantity amnt = (self.discount_amount * line.quantity) if not self.per_line_discount else self.discount_amount # we use min() to limit the amount of discount to the products price discount_price = order_source.create_price(min(base_price, amnt)) if not line.discount_amount or line.discount_amount < discount_price: line.discount_amount = discount_price # check for minimum price, if set, and change the discount amount _limit_discount_amount_by_min_price(line, order_source) return []
def get_objects(self): order_line_taxes = OrderLineTax.objects.filter( order_line__order__in=super(TaxesReport, self).get_objects()) tax = self.options.get("tax") tax_class = self.options.get("tax_class") filters = Q() if tax: filters &= Q(tax__in=tax) if tax_class: filters &= Q(order_line__product__tax_class__in=tax_class) return order_line_taxes.filter(filters).values( "tax", "tax__rate").annotate( total_tax_amount=Sum("amount_value"), total_pretax_amount=Sum("base_amount_value"), total=Sum(F('amount_value') + F('base_amount_value'), output_field=MoneyValueField()), order_count=Count("order_line__order", distinct=True)).order_by("total_tax_amount")
class StockCount(models.Model): alert_limit = QuantityField(default=0, editable=False, verbose_name=_("alert limit")) product = models.ForeignKey("wshop.Product", related_name="+", editable=False, on_delete=models.CASCADE, verbose_name=_("product")) supplier = models.ForeignKey("wshop.Supplier", editable=False, on_delete=models.CASCADE, verbose_name=_("supplier")) logical_count = QuantityField(default=0, editable=False, verbose_name=_("logical count")) physical_count = QuantityField(default=0, editable=False, verbose_name=_("physical count")) stock_value_value = MoneyValueField(default=0) stock_value = PriceProperty("stock_value_value", "currency", "includes_tax") stock_unit_price = PriceProperty("stock_unit_price_value", "currency", "includes_tax") class Meta: unique_together = [("product", "supplier")] @cached_property def currency(self): return _get_currency() @cached_property def includes_tax(self): return _get_prices_include_tax() @property def stock_unit_price_value(self): return (self.stock_value_value / self.logical_count if self.logical_count else 0)
class BasketTotalUndiscountedProductAmountCondition(MoneyPropped, BasketCondition): identifier = "basket_amount_condition_undiscounted" name = _("Undiscounted basket total value") amount = PriceProperty("amount_value", "campaign.shop.currency", "campaign.shop.prices_include_tax") amount_value = MoneyValueField(default=None, blank=True, null=True, verbose_name=_("basket total amount")) def matches(self, basket, lines): from wshop.campaigns.models import CatalogCampaign total_undiscounted_price_value = basket.total_price_of_products.value shop = basket.shop context = PricingContext(shop, basket.customer) for line in basket.get_product_lines(): if CatalogCampaign.get_matching( context, line.product.get_shop_instance(shop)): total_undiscounted_price_value -= line.price.value return (total_undiscounted_price_value >= self.amount_value) @property def description(self): return _( "Limit the campaign to match when it has at least the total value " "entered here worth of products which doesn't have already discounts." ) @property def value(self): return self.amount_value @value.setter def value(self, value): self.amount_value = value
class WeightBasedPriceRange(TranslatableModel): component = models.ForeignKey( "WeightBasedPricingBehaviorComponent", related_name="ranges", on_delete=models.CASCADE ) min_value = MeasurementField(unit="g", verbose_name=_("min weight (g)"), blank=True, null=True, help_text=_( "The minimum weight, in grams, for this price to apply." )) max_value = MeasurementField(unit="g", verbose_name=_("max weight (g)"), blank=True, null=True, help_text=_( "The maximum weight, in grams, before this price no longer applies." )) price_value = MoneyValueField(help_text=_("The cost to apply to this service when the weight criteria is met.")) description = TranslatedField(any_language=True) translations = TranslatedFields( description=models.CharField(max_length=100, blank=True, verbose_name=_("description"), help_text=_( "The order line text to display when this behavior is applied." )), ) def matches_to_value(self, value): return _is_in_range(value, self.min_value, self.max_value)
class CgpDiscount(MoneyPropped, CgpBase): discount_amount = PriceProperty("discount_amount_value", "shop.currency", "shop.prices_include_tax") discount_amount_value = MoneyValueField(verbose_name=_("discount amount")) class Meta(CgpBase.Meta): abstract = False verbose_name = _(u"product discount") verbose_name_plural = _(u"product discounts") def __repr__(self): return "<CgpDiscount (p%s,s%s,g%s): discount %s" % ( self.product_id, self.shop_id, self.group_id, self.discount_amount ) def save(self, *args, **kwargs): super(CgpDiscount, self).save(*args, **kwargs) # check if there is a shop product before bumping the cache if self.product.shop_products.filter(shop_id=self.shop.id).exists(): bump_cache_for_product(self.product, self.shop)
class BasketDiscountAmount(BasketDiscountEffect): identifier = "discount_amount_effect" name = _("Discount amount value") discount_amount = MoneyValueField(default=None, blank=True, null=True, verbose_name=_("discount amount"), help_text=_("Flat amount of discount.")) @property def description(self): return _("Give discount amount.") @property def value(self): return self.discount_amount @value.setter def value(self, value): self.discount_amount = value def apply_for_basket(self, order_source): return order_source.create_price(self.value)
class ProductDiscountAmount(ProductDiscountEffect): identifier = "discount_amount_effect" name = _("Discount amount value") discount_amount = MoneyValueField(default=None, blank=True, null=True, verbose_name=_("discount amount"), help_text=_("Flat amount of discount.")) @property def description(self): return _("Give discount amount.") @property def value(self): return self.discount_amount @value.setter def value(self, value): self.discount_amount = value def apply_for_product(self, context, product, price_info): return price_info.price.new(self.value * price_info.quantity)
class AbstractOrderLine(MoneyPropped, models.Model, Priceful): product = UnsavedForeignKey("wshop.Product", blank=True, null=True, related_name="order_lines", on_delete=models.PROTECT, verbose_name=_('product')) supplier = UnsavedForeignKey("wshop.Supplier", blank=True, null=True, related_name="order_lines", on_delete=models.PROTECT, verbose_name=_('supplier')) parent_line = UnsavedForeignKey("self", related_name="child_lines", blank=True, null=True, on_delete=models.PROTECT, verbose_name=_('parent line')) ordering = models.IntegerField(default=0, verbose_name=_('ordering')) type = EnumIntegerField(OrderLineType, default=OrderLineType.PRODUCT, verbose_name=_('line type')) sku = models.CharField(max_length=48, blank=True, verbose_name=_('line SKU')) text = models.CharField(max_length=256, verbose_name=_('line text')) accounting_identifier = models.CharField( max_length=32, blank=True, verbose_name=_('accounting identifier')) require_verification = models.BooleanField( default=False, verbose_name=_('require verification')) verified = models.BooleanField(default=False, verbose_name=_('verified')) extra_data = JSONField(blank=True, null=True, verbose_name=_('extra data')) # The following fields govern calculation of the prices quantity = QuantityField(verbose_name=_('quantity'), default=1) base_unit_price = PriceProperty('base_unit_price_value', 'order.currency', 'order.prices_include_tax') discount_amount = PriceProperty('discount_amount_value', 'order.currency', 'order.prices_include_tax') base_unit_price_value = MoneyValueField( verbose_name=_('unit price amount (undiscounted)'), default=0) discount_amount_value = MoneyValueField( verbose_name=_('total amount of discount'), default=0) objects = OrderLineManager() class Meta: verbose_name = _('order line') verbose_name_plural = _('order lines') abstract = True def __str__(self): return "%dx %s (%s)" % (self.quantity, self.text, self.get_type_display()) @property def tax_amount(self): """ :rtype: wshop.utils.money.Money """ zero = Money(0, self.order.currency) return sum((x.amount for x in self.taxes.all()), zero) @property def max_refundable_amount(self): """ :rtype: wshop.utils.money.Money """ refunds = self.child_lines.refunds().filter(parent_line=self) refund_total_value = sum(refund.taxful_price.amount.value for refund in refunds) return (self.taxful_price.amount + Money(refund_total_value, self.order.currency)) @property def max_refundable_quantity(self): if self.type != OrderLineType.PRODUCT: return 0 return self.quantity - self.refunded_quantity @property def refunded_quantity(self): return (self.child_lines.filter(type=OrderLineType.REFUND).aggregate( total=Sum("quantity"))["total"] or 0) @property def shipped_quantity(self): if not self.product: return 0 return ShipmentProduct.objects.filter( shipment__supplier=self.supplier.id, product_id=self.product.id, shipment__order=self.order).aggregate( total=Sum("quantity"))["total"] or 0 def save(self, *args, **kwargs): if not self.sku: self.sku = u"" if self.type == OrderLineType.PRODUCT and not self.product_id: raise ValidationError( "Product-type order line can not be saved without a set product" ) if self.product_id and self.type != OrderLineType.PRODUCT: raise ValidationError( "Order line has product but is not of Product type") if self.product_id and not self.supplier_id: raise ValidationError("Order line has product but no supplier") super(AbstractOrderLine, self).save(*args, **kwargs) if self.product_id: self.supplier.module.update_stock(self.product_id)
class Basket(MoneyPropped, models.Model): # A combination of the PK and key is used to retrieve a basket for session situations. key = models.CharField(max_length=32, default=generate_key, verbose_name=_('key'), unique=True, db_index=True) shop = models.ForeignKey("Shop", on_delete=models.CASCADE, verbose_name=_('shop')) customer = models.ForeignKey("Contact", blank=True, null=True, on_delete=models.CASCADE, related_name="customer_core_baskets", verbose_name=_('customer')) orderer = models.ForeignKey("PersonContact", blank=True, null=True, on_delete=models.CASCADE, related_name="orderer_core_baskets", verbose_name=_('orderer')) creator = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True, on_delete=models.CASCADE, related_name="core_baskets_created", verbose_name=_('creator')) created_on = models.DateTimeField(auto_now_add=True, db_index=True, editable=False, verbose_name=_('created on')) updated_on = models.DateTimeField(auto_now=True, db_index=True, editable=False, verbose_name=_('updated on')) persistent = models.BooleanField(db_index=True, default=False, verbose_name=_('persistent')) deleted = models.BooleanField(db_index=True, default=False, verbose_name=_('deleted')) finished = models.BooleanField(db_index=True, default=False, verbose_name=_('finished')) title = models.CharField(max_length=64, blank=True, verbose_name=_('title')) data = TaggedJSONField(verbose_name=_('data')) # For statistics etc., as `data` is opaque: taxful_total_price = TaxfulPriceProperty('taxful_total_price_value', 'currency') taxless_total_price = TaxlessPriceProperty('taxless_total_price_value', 'currency') taxless_total_price_value = MoneyValueField( default=0, null=True, blank=True, verbose_name=_('taxless total price')) taxful_total_price_value = MoneyValueField( default=0, null=True, blank=True, verbose_name=_('taxful total price')) currency = CurrencyField(verbose_name=_('currency')) prices_include_tax = models.BooleanField( verbose_name=_('prices include tax')) product_count = models.IntegerField(default=0, verbose_name=_('product_count')) products = ManyToManyField("Product", blank=True, verbose_name=_('products')) class Meta: verbose_name = _('basket') verbose_name_plural = _('baskets')
class Tax(MoneyPropped, ChangeProtected, TranslatableWshopModel): identifier_attr = 'code' change_protect_message = _( "Cannot change business critical fields of Tax that is in use") unprotected_fields = ['enabled'] code = InternalIdentifierField( unique=True, editable=True, verbose_name=_("code"), help_text=_("The abbreviated tax code name.")) translations = TranslatedFields(name=models.CharField( max_length=124, verbose_name=_("name"), help_text= _("The tax name. This is shown in order lines in order invoices and confirmations." )), ) rate = models.DecimalField(max_digits=6, decimal_places=5, blank=True, null=True, verbose_name=_("tax rate"), help_text=_("The percentage rate of the tax.")) amount = MoneyProperty('amount_value', 'currency') amount_value = MoneyValueField( default=None, blank=True, null=True, verbose_name=_("tax amount value"), help_text=_("The flat amount of the tax. " "Mutually exclusive with percentage rates.")) currency = CurrencyField(default=None, blank=True, null=True, verbose_name=_("currency of tax amount")) enabled = models.BooleanField( default=True, verbose_name=_('enabled'), help_text=_("Check this if this tax is valid and active.")) def clean(self): super(Tax, self).clean() if self.rate is None and self.amount is None: raise ValidationError(_('Either rate or amount is required')) if self.amount is not None and self.rate is not None: raise ValidationError(_('Cannot have both rate and amount')) if self.amount is not None and not self.currency: raise ValidationError( _("Currency is required if amount is specified")) def calculate_amount(self, base_amount): """ Calculate tax amount with this tax for given base amount. :type base_amount: wshop.utils.money.Money :rtype: wshop.utils.money.Money """ if self.amount is not None: return self.amount if self.rate is not None: return self.rate * base_amount raise ValueError("Improperly configured tax: %s" % self) def __str__(self): text = super(Tax, self).__str__() if self.rate is not None: text += " ({})".format(format_percent(self.rate, digits=3)) if self.amount is not None: text += " ({})".format(format_money(self.amount)) return text def _are_changes_protected(self): return self.order_line_taxes.exists() class Meta: verbose_name = _('tax') verbose_name_plural = _('taxes')
class ShopProduct(MoneyPropped, TranslatableModel): shop = models.ForeignKey("Shop", related_name="shop_products", on_delete=models.CASCADE, verbose_name=_("shop")) product = UnsavedForeignKey("Product", related_name="shop_products", on_delete=models.CASCADE, verbose_name=_("product")) suppliers = models.ManyToManyField( "Supplier", related_name="shop_products", blank=True, verbose_name=_("suppliers"), help_text= _("List your suppliers here. Suppliers can be found in Product Settings - Suppliers." )) visibility = EnumIntegerField( ShopProductVisibility, default=ShopProductVisibility.ALWAYS_VISIBLE, db_index=True, verbose_name=_("visibility"), help_text=mark_safe_lazy( _("Select if you want your product to be seen and found by customers. " "<p>Not visible: Product will not be shown in your store front or found in search.</p>" "<p>Searchable: Product will be shown in search but not listed on any category page.</p>" "<p>Listed: Product will be shown on category pages but not shown in search results.</p>" "<p>Always Visible: Product will be shown in your store front and found in search.</p>" ))) purchasable = models.BooleanField(default=True, db_index=True, verbose_name=_("purchasable")) visibility_limit = EnumIntegerField( ProductVisibility, db_index=True, default=ProductVisibility.VISIBLE_TO_ALL, verbose_name=_('visibility limitations'), help_text= _("Select whether you want your product to have special limitations on its visibility in your store. " "You can make products visible to all, visible to only logged in users, or visible only to certain " "customer groups.")) visibility_groups = models.ManyToManyField( "ContactGroup", related_name='visible_products', verbose_name=_('visible for groups'), blank=True, help_text= _(u"Select the groups you would like to make your product visible for. " u"These groups are defined in Contacts Settings - Contact Groups.")) backorder_maximum = QuantityField( default=0, blank=True, null=True, verbose_name=_('backorder maximum'), help_text=_( "The number of units that can be purchased after the product is out of stock. " "Set to blank for product to be purchasable without limits.")) purchase_multiple = QuantityField( default=0, verbose_name=_('purchase multiple'), help_text=_( "Set this if the product needs to be purchased in multiples. " "For example, if the purchase multiple is set to 2, then customers are required to order the product " "in multiples of 2.")) minimum_purchase_quantity = QuantityField( default=1, verbose_name=_('minimum purchase'), help_text=_( "Set a minimum number of products needed to be ordered for the purchase. " "This is useful for setting bulk orders and B2B purchases.")) limit_shipping_methods = models.BooleanField( default=False, verbose_name=_("limited for shipping methods"), help_text=_( "Check this if you want to limit your product to use only select payment methods. " "You can select the payment method(s) in the field below.")) limit_payment_methods = models.BooleanField( default=False, verbose_name=_("limited for payment methods"), help_text=_( "Check this if you want to limit your product to use only select payment methods. " "You can select the payment method(s) in the field below.")) shipping_methods = models.ManyToManyField( "ShippingMethod", related_name='shipping_products', verbose_name=_('shipping methods'), blank=True, help_text=_( "Select the shipping methods you would like to limit the product to using. " "These are defined in Settings - Shipping Methods.")) payment_methods = models.ManyToManyField( "PaymentMethod", related_name='payment_products', verbose_name=_('payment methods'), blank=True, help_text=_( "Select the payment methods you would like to limit the product to using. " "These are defined in Settings - Payment Methods.")) primary_category = models.ForeignKey( "Category", related_name='primary_shop_products', verbose_name=_('primary category'), blank=True, null=True, on_delete=models.PROTECT, help_text=_( "Choose the primary category for your product. " "This will be the main category for classification in the system. " "Your product can be found under this category in your store. " "Categories are defined in Products Settings - Categories.")) categories = models.ManyToManyField( "Category", related_name='shop_products', verbose_name=_('categories'), blank=True, help_text=_( "Add secondary categories for your product. " "These are other categories that your product fits under and that it can be found by in your store." )) shop_primary_image = models.ForeignKey( "ProductMedia", null=True, blank=True, related_name="primary_image_for_shop_products", on_delete=models.SET_NULL, verbose_name=_("primary image"), help_text= _("Click this to set this image as the primary display image for your product." )) # the default price of this product in the shop default_price = PriceProperty('default_price_value', 'shop.currency', 'shop.prices_include_tax') default_price_value = MoneyValueField( verbose_name=_("default price"), null=True, blank=True, help_text=_( "This is the default individual base unit (or multi-pack) price of the product. " "All discounts or coupons will be based off of this price.")) minimum_price = PriceProperty('minimum_price_value', 'shop.currency', 'shop.prices_include_tax') minimum_price_value = MoneyValueField( verbose_name=_("minimum price"), null=True, blank=True, help_text= _("This is the default price that the product cannot go under in your store, " "despite coupons or discounts being applied. " "This is useful to make sure your product price stays above cost.")) display_unit = models.ForeignKey( DisplayUnit, null=True, blank=True, verbose_name=_("display unit"), help_text=_("Unit for displaying quantities of this product")) translations = TranslatedFields( name=models.CharField( blank=True, null=True, 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, null=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( blank=True, null=True, max_length=150, 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." ))) class Meta: unique_together = (( "shop", "product", ), ) def save(self, *args, **kwargs): self.clean() super(ShopProduct, self).save(*args, **kwargs) for supplier in self.suppliers.all(): supplier.module.update_stock(product_id=self.product.id) def clean(self): pre_clean.send(type(self), instance=self) super(ShopProduct, self).clean() if self.display_unit: if self.display_unit.internal_unit != self.product.sales_unit: raise ValidationError({ 'display_unit': _("Invalid display unit: Internal unit of " "the selected display unit does not match " "with the sales unit of the product") }) post_clean.send(type(self), instance=self) def is_list_visible(self): """ Return True if this product should be visible in listings in general, without taking into account any other visibility limitations. :rtype: bool """ if self.product.deleted: return False if not self.listed: return False if self.product.is_variation_child(): return False return True @property def primary_image(self): if self.shop_primary_image_id: return self.shop_primary_image else: return self.product.primary_image @property def searchable(self): return self.visibility in (ShopProductVisibility.SEARCHABLE, ShopProductVisibility.ALWAYS_VISIBLE) @property def listed(self): return self.visibility in (ShopProductVisibility.LISTED, ShopProductVisibility.ALWAYS_VISIBLE) @property def visible(self): return not (self.visibility == ShopProductVisibility.NOT_VISIBLE) @property def public_primary_image(self): primary_image = self.primary_image return primary_image if primary_image and primary_image.public else None def get_visibility_errors(self, customer): if self.product.deleted: yield ValidationError(_('This product has been deleted.'), code="product_deleted") if customer and customer.is_all_seeing: # None of the further conditions matter for omniscient customers. return if not self.visible: yield ValidationError(_('This product is not visible.'), code="product_not_visible") is_logged_in = (bool(customer) and not customer.is_anonymous) if not is_logged_in and self.visibility_limit != ProductVisibility.VISIBLE_TO_ALL: yield ValidationError( _('The Product is invisible to users not logged in.'), code="product_not_visible_to_anonymous") if is_logged_in and self.visibility_limit == ProductVisibility.VISIBLE_TO_GROUPS: # TODO: Optimization user_groups = set(customer.groups.all().values_list("pk", flat=True)) my_groups = set(self.visibility_groups.values_list("pk", flat=True)) if not bool(user_groups & my_groups): yield ValidationError( _('This product is not visible to your group.'), code="product_not_visible_to_group") for receiver, response in get_visibility_errors.send( ShopProduct, shop_product=self, customer=customer): for error in response: yield error # TODO: Refactor get_orderability_errors, it's too complex def get_orderability_errors( # noqa (C901) self, supplier, quantity, customer, ignore_minimum=False): """ Yield ValidationErrors that would cause this product to not be orderable. :param supplier: Supplier to order this product from. May be None. :type supplier: wshop.core.models.Supplier :param quantity: Quantity to order. :type quantity: int|Decimal :param customer: Customer contact. :type customer: wshop.core.models.Contact :param ignore_minimum: Ignore any limitations caused by quantity minimums. :type ignore_minimum: bool :return: Iterable[ValidationError] """ for error in self.get_visibility_errors(customer): yield error if supplier is None and not self.suppliers.exists(): # `ShopProduct` must have at least one `Supplier`. # If supplier is not given and the `ShopProduct` itself # doesn't have suppliers we cannot sell this product. yield ValidationError(_('The product has no supplier.'), code="no_supplier") if not ignore_minimum and quantity < self.minimum_purchase_quantity: yield ValidationError(_( 'The purchase quantity needs to be at least %d for this product.' ) % self.minimum_purchase_quantity, code="purchase_quantity_not_met") if supplier and not self.suppliers.filter(pk=supplier.pk).exists(): yield ValidationError(_('The product is not supplied by %s.') % supplier, code="invalid_supplier") if self.product.mode == ProductMode.SIMPLE_VARIATION_PARENT: sellable = False for child_product in self.product.variation_children.all(): try: child_shop_product = child_product.get_shop_instance( self.shop) except ShopProduct.DoesNotExist: continue if child_shop_product.is_orderable( supplier=supplier, customer=customer, quantity=child_shop_product.minimum_purchase_quantity, allow_cache=False): sellable = True break if not sellable: yield ValidationError(_("Product has no sellable children"), code="no_sellable_children") elif self.product.mode == ProductMode.VARIABLE_VARIATION_PARENT: from wshop.core.models import ProductVariationResult sellable = False for combo in self.product.get_all_available_combinations(): res = ProductVariationResult.resolve( self.product, combo["variable_to_value"]) if not res: continue try: child_shop_product = res.get_shop_instance(self.shop) except ShopProduct.DoesNotExist: continue if child_shop_product.is_orderable( supplier=supplier, customer=customer, quantity=child_shop_product.minimum_purchase_quantity, allow_cache=False): sellable = True break if not sellable: yield ValidationError(_("Product has no sellable children"), code="no_sellable_children") if self.product.is_package_parent(): for child_product, child_quantity in six.iteritems( self.product.get_package_child_to_quantity_map()): try: child_shop_product = child_product.get_shop_instance( shop=self.shop, allow_cache=False) except ShopProduct.DoesNotExist: yield ValidationError("%s: Not available in %s" % (child_product, self.shop), code="invalid_shop") else: for error in child_shop_product.get_orderability_errors( supplier=supplier, quantity=(quantity * child_quantity), customer=customer, ignore_minimum=ignore_minimum): message = getattr(error, "message", "") code = getattr(error, "code", None) yield ValidationError("%s: %s" % (child_product, message), code=code) if supplier and self.product.stock_behavior == StockBehavior.STOCKED: for error in supplier.get_orderability_errors(self, quantity, customer=customer): yield error for error in self.get_quantity_errors(quantity): yield error for receiver, response in get_orderability_errors.send( ShopProduct, shop_product=self, customer=customer, supplier=supplier, quantity=quantity): for error in response: yield error def get_quantity_errors(self, quantity): purchase_multiple = self.purchase_multiple if quantity > 0 and purchase_multiple > 0 and (quantity % purchase_multiple) != 0: p = (quantity // purchase_multiple) smaller_p = max(purchase_multiple, p * purchase_multiple) larger_p = max(purchase_multiple, (p + 1) * purchase_multiple) render_qty = self.unit.render_quantity if larger_p == smaller_p: message = _("The product can only be ordered in multiples of " "{package_size}, for example {amount}").format( package_size=render_qty(purchase_multiple), amount=render_qty(smaller_p)) else: message = _("The product can only be ordered in multiples of " "{package_size}, for example {smaller_amount} or " "{larger_amount}").format( package_size=render_qty(purchase_multiple), smaller_amount=render_qty(smaller_p), larger_amount=render_qty(larger_p)) yield ValidationError(message, code="invalid_purchase_multiple") def raise_if_not_orderable(self, supplier, customer, quantity, ignore_minimum=False): for message in self.get_orderability_errors( supplier=supplier, quantity=quantity, customer=customer, ignore_minimum=ignore_minimum): raise ProductNotOrderableProblem(message.args[0]) def raise_if_not_visible(self, customer): for message in self.get_visibility_errors(customer=customer): raise ProductNotVisibleProblem(message.args[0]) def is_orderable(self, supplier, customer, quantity, allow_cache=True): key, val = context_cache.get_cached_value( identifier="is_orderable", item=self, context={"customer": customer}, supplier=supplier, quantity=quantity, allow_cache=allow_cache) if customer and val is not None: return val if not supplier: supplier = self.get_supplier(customer, quantity) for message in self.get_orderability_errors(supplier=supplier, quantity=quantity, customer=customer): if customer: context_cache.set_cached_value(key, False) return False if customer: context_cache.set_cached_value(key, True) return True def is_visible(self, customer): for message in self.get_visibility_errors(customer=customer): return False return True @property def quantity_step(self): """ Quantity step for purchasing this product. :rtype: decimal.Decimal Example: <input type="number" step="{{ shop_product.quantity_step }}"> """ step = self.purchase_multiple or self._sales_unit.quantity_step return self._sales_unit.round(step) @property def rounded_minimum_purchase_quantity(self): """ The minimum purchase quantity, rounded to the sales unit's precision. :rtype: decimal.Decimal Example: <input type="number" min="{{ shop_product.rounded_minimum_purchase_quantity }}" value="{{ shop_product.rounded_minimum_purchase_quantity }}"> """ return self._sales_unit.round(self.minimum_purchase_quantity) @property def display_quantity_step(self): """ Quantity step of this shop product in the display unit. Note: This can never be smaller than the display precision. """ return max(self.unit.to_display(self.quantity_step), self.unit.display_precision) @property def display_quantity_minimum(self): """ Quantity minimum of this shop product in the display unit. Note: This can never be smaller than the display precision. """ return max(self.unit.to_display(self.minimum_purchase_quantity), self.unit.display_precision) @property def unit(self): """ Unit of this product. :rtype: wshop.core.models.UnitInterface """ return UnitInterface(self._sales_unit, self.display_unit) @property def _sales_unit(self): return self.product.sales_unit or PiecesSalesUnit() @property def images(self): return self.product.media.filter( shops=self.shop, kind=ProductMediaKind.IMAGE).order_by("ordering") @property def public_images(self): return self.images.filter(public=True) def get_supplier(self, customer=None, quantity=None, shipping_address=None): supplier_strategy = cached_load( "WSHOP_SHOP_PRODUCT_SUPPLIERS_STRATEGY") kwargs = { "shop_product": self, "customer": customer, "quantity": quantity, "shipping_address": shipping_address } return supplier_strategy().get_supplier(**kwargs)