class StockCount(models.Model): product = models.ForeignKey( "shoop.Product", related_name="+", editable=False, on_delete=models.CASCADE, verbose_name=_("product")) supplier = models.ForeignKey( "shoop.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")] @property def currency(self): return SHOOP_HOME_CURRENCY @property def includes_tax(self): return False @property def stock_unit_price_value(self): return (self.stock_value_value / self.logical_count if self.logical_count else 0)
class StockCount(models.Model): product = models.ForeignKey("shoop.Product", related_name="+", editable=False) supplier = models.ForeignKey("shoop.Supplier", editable=False) logical_count = QuantityField(default=0, editable=False) physical_count = QuantityField(default=0, editable=False) class Meta: unique_together = [("product", "supplier")]
class SuppliedProduct(models.Model): supplier = models.ForeignKey("Supplier") product = models.ForeignKey("Product") sku = models.CharField(db_index=True, max_length=128, verbose_name=_('SKU')) alert_limit = models.IntegerField(default=0, verbose_name=_('alert limit')) purchase_price = MoneyField(verbose_name=_('purchase price'), blank=True, null=True) suggested_retail_price = MoneyField(verbose_name=_('suggested retail price'), blank=True, null=True) physical_count = QuantityField(editable=False, verbose_name=_('physical stock count')) logical_count = QuantityField(editable=False, verbose_name=_('logical stock count')) class Meta: unique_together = (("supplier", "product", ), )
class ShipmentProduct(models.Model): shipment = models.ForeignKey(Shipment, related_name='products', on_delete=models.PROTECT) product = models.ForeignKey("Product", related_name='shipments') quantity = QuantityField() unit_volume = MeasurementField( unit="m3" ) # volume is m^3, not mm^3, because mm^3 are tiny. like ants. unit_weight = MeasurementField(unit="g") class Meta: verbose_name = _('sent product') verbose_name_plural = _('sent products') def __str__(self): # pragma: no cover return "%(quantity)s of '%(product)s' in Shipment #%(shipment_pk)s" % { 'product': self.product, 'quantity': self.quantity, 'shipment_pk': self.shipment_id, } def cache_values(self): prod = self.product self.unit_volume = (prod.width * prod.height * prod.depth) / CUBIC_MM_TO_CUBIC_METERS_DIVISOR self.unit_weight = prod.gross_weight
class ProductPackageLink(models.Model): parent = models.ForeignKey("Product", related_name='+') child = models.ForeignKey("Product", related_name='+') quantity = QuantityField(default=1) class Meta: unique_together = (( "parent", "child", ), )
class SuppliedProduct(models.Model): supplier = models.ForeignKey("Supplier", on_delete=models.CASCADE, verbose_name=_("supplier")) product = models.ForeignKey("Product", on_delete=models.CASCADE, verbose_name=_("product")) sku = models.CharField(db_index=True, max_length=128, verbose_name=_('SKU')) alert_limit = models.IntegerField(default=0, verbose_name=_('alert limit')) physical_count = QuantityField(editable=False, verbose_name=_('physical stock count')) logical_count = QuantityField(editable=False, verbose_name=_('logical stock count')) class Meta: unique_together = (( "supplier", "product", ), )
class StockAdjustment(models.Model): product = models.ForeignKey("shoop.Product", related_name="+") supplier = models.ForeignKey("shoop.Supplier") created_on = models.DateTimeField(auto_now_add=True, editable=False, db_index=True) created_by = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True) delta = QuantityField(default=0) class Meta: unique_together = [("product", "supplier")]
class ProductPackageLink(models.Model): parent = models.ForeignKey("Product", related_name='+', on_delete=models.CASCADE, verbose_name=_("parent product")) child = models.ForeignKey("Product", related_name='+', on_delete=models.CASCADE, verbose_name=_("child product")) quantity = QuantityField(default=1, verbose_name=_("quantity")) class Meta: unique_together = (( "parent", "child", ), )
class StockAdjustment(models.Model): product = models.ForeignKey("shoop.Product", related_name="+", on_delete=models.CASCADE, verbose_name=_("product")) supplier = models.ForeignKey("shoop.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") @property def currency(self): return SHOOP_HOME_CURRENCY @property def includes_tax(self): return False
class ShopProduct(MoneyPropped, models.Model): shop = models.ForeignKey("Shop", related_name="shop_products", on_delete=models.CASCADE) product = UnsavedForeignKey("Product", related_name="shop_products", on_delete=models.CASCADE) suppliers = models.ManyToManyField("Supplier", related_name="shop_products", blank=True) visible = models.BooleanField(default=True, db_index=True) listed = models.BooleanField(default=True, db_index=True) purchasable = models.BooleanField(default=True, db_index=True) searchable = models.BooleanField(default=True, db_index=True) visibility_limit = EnumIntegerField( ProductVisibility, db_index=True, default=ProductVisibility.VISIBLE_TO_ALL, verbose_name=_('visibility limitations')) visibility_groups = models.ManyToManyField( "ContactGroup", related_name='visible_products', verbose_name=_('visible for groups'), blank=True) purchase_multiple = QuantityField(default=0, verbose_name=_('purchase multiple')) minimum_purchase_quantity = QuantityField( default=1, verbose_name=_('minimum purchase')) limit_shipping_methods = models.BooleanField(default=False) limit_payment_methods = models.BooleanField(default=False) shipping_methods = models.ManyToManyField( "ShippingMethod", related_name='shipping_products', verbose_name=_('shipping methods'), blank=True) payment_methods = models.ManyToManyField("PaymentMethod", related_name='payment_products', verbose_name=_('payment methods'), blank=True) primary_category = models.ForeignKey("Category", related_name='primary_shop_products', verbose_name=_('primary category'), blank=True, null=True, on_delete=models.PROTECT) categories = models.ManyToManyField("Category", related_name='shop_products', verbose_name=_('categories'), blank=True) shop_primary_image = models.ForeignKey( "ProductMedia", null=True, blank=True, related_name="primary_image_for_shop_products", on_delete=models.SET_NULL) # 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) class Meta: unique_together = (( "shop", "product", ), ) 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.visible: 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 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: shoop.core.models.suppliers.Supplier :param quantity: Quantity to order. :type quantity: int|Decimal :param customer: Customer contact. :type customer: shoop.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.is_package_parent(): for child_product, child_quantity in six.iteritems( self.product.get_package_child_to_quantity_map()): child_shop_product = child_product.get_shop_instance( shop=self.shop) if not child_shop_product: yield ValidationError("%s: Not available in %s" % (child_product, self.shop), code="invalid_shop") for error in child_shop_product.get_orderability_errors( supplier=supplier, quantity=(quantity * child_quantity), customer=customer, ignore_minimum=ignore_minimum): code = getattr(error, "code", None) yield ValidationError("%s: %s" % (child_product, error), code=code) if supplier and self.product.stock_behavior == StockBehavior.STOCKED: for error in supplier.get_orderability_errors(self, quantity, customer=customer): yield error purchase_multiple = self.purchase_multiple if quantity > 0 and purchase_multiple > 1 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) if larger_p == smaller_p: message = _( 'The product can only be ordered in multiples of %(package_size)s, ' 'for example %(smaller_p)s %(unit)s.') % { "package_size": purchase_multiple, "smaller_p": smaller_p, "unit": self.product.sales_unit, } else: message = _( 'The product can only be ordered in multiples of %(package_size)s, ' 'for example %(smaller_p)s or %(larger_p)s %(unit)s.') % { "package_size": purchase_multiple, "smaller_p": smaller_p, "larger_p": larger_p, "unit": self.product.sales_unit, } yield ValidationError(message, code="invalid_purchase_multiple") 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 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): for message in self.get_orderability_errors(supplier=supplier, quantity=quantity, 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 }}"> """ if self.purchase_multiple: return self.purchase_multiple return self.product.sales_unit.quantity_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.product.sales_unit.round(self.minimum_purchase_quantity) @property def images(self): return self.product.media.filter( shops=self.shop, kind=ProductMediaKind.IMAGE).order_by("ordering")
class OrderLine(MoneyPropped, models.Model, Priceful): order = UnsavedForeignKey("Order", related_name='lines', on_delete=models.PROTECT, verbose_name=_('order')) product = UnsavedForeignKey("Product", blank=True, null=True, related_name="order_lines", on_delete=models.PROTECT, verbose_name=_('product')) supplier = UnsavedForeignKey("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') def __str__(self): return "%dx %s (%s)" % (self.quantity, self.text, self.get_type_display()) @property def tax_amount(self): """ :rtype: shoop.utils.money.Money """ zero = Money(0, self.order.currency) return sum((x.amount for x in self.taxes.all()), zero) 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(OrderLine, self).save(*args, **kwargs) if self.product_id: self.supplier.module.update_stock(self.product_id)
class OrderLine(models.Model, LinePriceMixin): order = UnsavedForeignKey("Order", related_name='lines', on_delete=models.PROTECT, verbose_name=_('order')) product = UnsavedForeignKey("Product", blank=True, null=True, related_name="order_lines", on_delete=models.PROTECT, verbose_name=_('product')) supplier = UnsavedForeignKey("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) # The following fields govern calculation of the prices quantity = QuantityField(verbose_name=_('quantity'), default=1) _unit_price_amount = MoneyField(verbose_name=_('unit price amount')) _total_discount_amount = MoneyField( verbose_name=_('total amount of discount')) _prices_include_tax = models.BooleanField(default=True) objects = OrderLineManager() class Meta: verbose_name = _('order line') verbose_name_plural = _('order lines') def __str__(self): return "%dx %s (%s)" % (self.quantity, self.text, self.get_type_display()) @property def unit_price(self): """ Unit price of OrderLine. :rtype: Price """ if self._prices_include_tax: return TaxfulPrice(self._unit_price_amount) else: return TaxlessPrice(self._unit_price_amount) @unit_price.setter def unit_price(self, price): """ Set unit price of OrderLine. :type price: TaxfulPrice|TaxlessPrice """ self._check_input_price(price) self._unit_price_amount = price.amount self._prices_include_tax = price.includes_tax @property def total_discount(self): """ Total discount of OrderLine. :rtype: Price """ if self._prices_include_tax: return TaxfulPrice(self._total_discount_amount) else: return TaxlessPrice(self._total_discount_amount) @total_discount.setter def total_discount(self, discount): """ Set total discount of OrderLine. :type discount: TaxfulPrice|TaxlessPrice """ self._check_input_price(discount) self._total_discount_amount = discount.amount self._prices_include_tax = discount.includes_tax @property def total_tax_amount(self): """ :rtype: decimal.Decimal """ return sum((x.amount for x in self.taxes.all()), decimal.Decimal(0)) def _check_input_price(self, price): if not isinstance(price, Price): raise TypeError('%r is not a Price object' % (price, )) if self._unit_price_amount or self._total_discount_amount: if price.includes_tax != self._prices_include_tax: tp = TaxfulPrice if self._prices_include_tax else TaxlessPrice msg = 'Cannot accept %r because we want a %s' raise TypeError(msg % (price, tp.__name__)) 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") return super(OrderLine, self).save(*args, **kwargs)