class StoredBasket(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) owner_contact = models.ForeignKey("shoop.Contact", blank=True, null=True) owner_user = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True) created_on = models.DateTimeField(auto_now_add=True, db_index=True, editable=False) updated_on = models.DateTimeField(auto_now=True, db_index=True, editable=False) persistent = models.BooleanField(db_index=True, default=False) deleted = models.BooleanField(db_index=True, default=False) finished = models.BooleanField(db_index=True, default=False) title = models.CharField(max_length=64, blank=True) data = TaggedJSONField() # For statistics etc., as `data` is opaque: taxless_total = MoneyField(default=0, null=True, blank=True) taxful_total = MoneyField(default=0, null=True, blank=True) product_count = models.IntegerField(default=0) products = ManyToManyField("shoop.Product", blank=True) class Meta: app_label = "shoop_front"
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 SimpleProductPrice(models.Model): product = models.ForeignKey("shoop.Product", related_name="+") shop = models.ForeignKey("shoop.Shop", db_index=True) group = models.ForeignKey("shoop.ContactGroup", db_index=True) price = MoneyField() # TODO: (TAX) Check includes_tax consistency (see below) # # SimpleProductPrice entries in single shop should all have same # value of includes_tax, because inconsistencies in taxfulness of # prices may cause basket totals to be unsummable, since taxes are # unknown before customer has given their address and TaxfulPrice # cannot be summed with TaxlessPrice. class Meta: unique_together = (('product', 'shop', 'group'), ) verbose_name = _(u"product price") verbose_name_plural = _(u"product prices") def __repr__(self): return "<SimpleProductPrice (p%s,s%s,g%s): price %s" % ( self.product_id, self.shop_id, self.group_id, self.price, )
class OrderLineTax(ShoopModel, LineTax): order_line = models.ForeignKey( OrderLine, related_name='taxes', on_delete=models.PROTECT, verbose_name=_('order line')) tax = models.ForeignKey( # TODO: (TAX) Should we allow NULL? When deciding, see get_tax_summary "Tax", related_name="order_line_taxes", on_delete=models.PROTECT, verbose_name=_('tax') ) name = models.CharField(max_length=200, verbose_name=_('tax name')) amount = MoneyField(verbose_name=_('tax amount')) base_amount = MoneyField( 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 Tax(TranslatableShoopModel): identifier_attr = 'code' code = InternalIdentifierField(unique=True) translations = TranslatedFields(name=models.CharField(max_length=64), ) 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. Mutually exclusive with flat amounts." )) amount = MoneyField( default=None, blank=True, null=True, verbose_name=_('tax amount'), help_text= _("The flat amount of the tax. Mutually exclusive with percentage rates." )) enabled = models.BooleanField(default=True, verbose_name=_('enabled')) 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')) def save(self, *args, **kwargs): self.clean() if self.pk: # TODO: (TAX) Make it possible to disable Tax raise ImmutabilityError('Tax objects are immutable') super(Tax, self).save(*args, **kwargs) def calculate_amount(self, base_amount): 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) class Meta: verbose_name = _('tax') verbose_name_plural = _('taxes')
class Payment(models.Model): # TODO: Revise!!! order = models.ForeignKey("Order", related_name='payments', on_delete=models.PROTECT) created_on = models.DateTimeField(auto_now_add=True) gateway_id = models.CharField(max_length=32) # TODO: do we need this? payment_identifier = models.CharField(max_length=96, unique=True) amount = MoneyField() description = models.CharField(max_length=256, blank=True) # TODO: Currency here? class Meta: verbose_name = _('payment') verbose_name_plural = _('payments')
class Product(AttributableMixin, TranslatableModel): COMMON_SELECT_RELATED = ("type", "primary_image", "tax_class") # Metadata created_on = models.DateTimeField(auto_now_add=True, editable=False) modified_on = models.DateTimeField(auto_now=True, editable=False) deleted = models.BooleanField(default=False, editable=False, db_index=True) # Behavior mode = EnumIntegerField(ProductMode, default=ProductMode.NORMAL) 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) tax_class = models.ForeignKey("TaxClass", verbose_name=_('tax class')) # 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")) # 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. purchase_price = MoneyField(verbose_name=_('purchase price')) suggested_retail_price = MoneyField(verbose_name=_('suggested retail price')) manufacturer = models.ForeignKey("Manufacturer", blank=True, null=True, verbose_name=_('manufacturer')) primary_image = models.ForeignKey( "ProductMedia", null=True, blank=True, related_name="primary_image_for_products", on_delete=models.SET_NULL) 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 def get_shop_instance(self, shop): """ :type shop: shoop.core.models.shops.Shop :rtype: shoop.core.models.product_shops.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.filter(shop=shop).first() if shop_inst: shop_inst._product_cache = self shop_inst._shop_cache = shop shop_inst_cache[shop] = shop_inst return shop_inst def get_cheapest_child_price(self, context, quantity=1): return sorted( c.get_price(context, quantity=quantity) for c in self.variation_children.all() )[0] def get_price(self, context, quantity=1): """ :type context: shoop.core.contexts.PriceTaxContext :rtype: shoop.core.pricing.Price """ from shoop.core.pricing import get_pricing_module module = get_pricing_module() pricing_context = module.get_context(context) return module.get_price(pricing_context, product_id=self.pk, quantity=quantity) def get_base_price(self): from shoop.core.pricing import get_pricing_module module = get_pricing_module() return module.get_base_price(product_id=self.pk) def get_taxed_price(self, context, quantity=1): """ :type context: shoop.core.contexts.PriceTaxContext :rtype: shoop.core.pricing.TaxedPrice """ from shoop.core import taxing module = taxing.get_tax_module() return module.determine_product_tax(context, self) def get_available_attribute_queryset(self): if self.type_id: return self.type.attributes.visible() else: return Attribute.objects.none() @staticmethod def _get_slug_name(self): if self.deleted: return None return (self.safe_translation_getter("name") or self.sku) 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 def delete(self, using=None): raise NotImplementedError("Not implemented: Use `soft_delete()` for products.") 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",)) def verify_mode(self): if ProductPackageLink.objects.filter(parent=self).count(): self.mode = ProductMode.PACKAGE_PARENT self.external_url = None self.variation_children.clear() elif self.variation_children.count(): if ProductVariationResult.objects.filter(product=self).count(): 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 unlink_from_parent(self): if self.variation_parent: parent = self.variation_parent self.variation_parent = None self.save() parent.verify_mode() self.verify_mode() self.save() ProductVariationResult.objects.filter(result=self).delete() return True def link_to_parent(self, parent, variables=None): if parent.mode == ProductMode.VARIATION_CHILD: raise ValueError("Multilevel parentage hierarchies aren't supported (parent is a child already)") if parent.mode == ProductMode.VARIABLE_VARIATION_PARENT and not variables: raise ValueError("Parent is a variable variation parent, yet variables were not passed to `link_to_parent`") if parent.mode == ProductMode.SIMPLE_VARIATION_PARENT and variables: raise ValueError("Parent is a simple variation parent, yet variables were passed to `link_to_parent`") if self.mode == ProductMode.SIMPLE_VARIATION_PARENT: raise ValueError( "Multilevel parentage hierarchies aren't supported (this product is a simple variation parent)" ) if self.mode == ProductMode.VARIABLE_VARIATION_PARENT: raise ValueError( "Multilevel parentage hierarchies aren't supported (this product is a variable variation parent)" ) self.unlink_from_parent() self.variation_parent = parent self.verify_mode() self.save() if parent.mode not in (ProductMode.SIMPLE_VARIATION_PARENT, ProductMode.VARIABLE_VARIATION_PARENT): parent.verify_mode() parent.save() if variables: mapping = {} for variable_identifier, value_identifier in variables.items(): variable_identifier, _ = ProductVariationVariable.objects.get_or_create( product=parent, identifier=variable_identifier ) value_identifier, _ = ProductVariationVariableValue.objects.get_or_create( variable=variable_identifier, identifier=value_identifier ) mapping[variable_identifier] = value_identifier pvr = ProductVariationResult.objects.create( product=parent, combination_hash=hash_combination(mapping), result=self ) if parent.mode == ProductMode.SIMPLE_VARIATION_PARENT: parent.verify_mode() parent.save() return pvr else: return True def make_package(self, package_def): if self.mode != ProductMode.NORMAL: raise ValueError("Product is currently not a normal product, can't turn into package") for child_product, quantity in six.iteritems(package_def): if child_product.mode in (ProductMode.SIMPLE_VARIATION_PARENT, ProductMode.VARIABLE_VARIATION_PARENT): raise ValueError("Variation parents can not belong into a package") if child_product.mode == ProductMode.PACKAGE_PARENT: raise ValueError("Can't nest packages") if quantity <= 0: raise ValueError("Quantity %s is invalid" % quantity) ProductPackageLink.objects.create(parent=self, child=child_product, quantity=quantity) self.verify_mode() def get_package_child_to_quantity_map(self): if self.mode == ProductMode.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 {}
class Order(models.Model): # Identification shop = UnsavedForeignKey("Shop") created_on = models.DateTimeField(auto_now_add=True, editable=False) identifier = InternalIdentifierField(db_index=True, verbose_name=_('order identifier')) # TODO: label is actually a choice field, need to check migrations/choice deconstruction label = models.CharField(max_length=32, db_index=True, verbose_name=_('label')) # The key shouldn't be possible to deduce (i.e. it should be random), but it is # not a secret. (It could, however, be used as key material for an actual secret.) key = models.CharField(max_length=32, unique=True, blank=False, verbose_name=_('key')) reference_number = models.CharField(max_length=64, db_index=True, unique=True, blank=True, null=True, verbose_name=_('reference number')) # Contact information customer = UnsavedForeignKey("Contact", related_name='customer_orders', blank=True, null=True, verbose_name=_('customer')) orderer = UnsavedForeignKey("PersonContact", related_name='orderer_orders', blank=True, null=True, verbose_name=_('orderer')) billing_address = UnsavedForeignKey("Address", related_name="billing_orders", blank=True, null=True, on_delete=models.PROTECT, verbose_name=_('billing address')) shipping_address = UnsavedForeignKey("Address", related_name='shipping_orders', blank=True, null=True, on_delete=models.PROTECT, verbose_name=_('shipping address')) vat_code = models.CharField(max_length=20, blank=True, verbose_name=_('VAT code')) phone = models.CharField(max_length=32, blank=True, verbose_name=_('phone')) email = models.EmailField(max_length=128, blank=True, verbose_name=_('email address')) # Status creator = UnsavedForeignKey(settings.AUTH_USER_MODEL, related_name='orders_created', blank=True, null=True, verbose_name=_('creating user')) deleted = models.BooleanField(db_index=True, default=False) status = UnsavedForeignKey("OrderStatus", verbose_name=_('status')) payment_status = EnumIntegerField(PaymentStatus, db_index=True, default=PaymentStatus.NOT_PAID, verbose_name=_('payment status')) shipping_status = EnumIntegerField(ShippingStatus, db_index=True, default=ShippingStatus.NOT_SHIPPED, verbose_name=_('shipping status')) # Methods payment_method = UnsavedForeignKey("PaymentMethod", related_name="payment_orders", blank=True, null=True, default=None, on_delete=models.PROTECT, verbose_name=_('payment method')) payment_method_name = models.CharField( max_length=64, blank=True, default="", verbose_name=_('payment method name')) payment_data = JSONField(blank=True, null=True) shipping_method = UnsavedForeignKey("ShippingMethod", related_name='shipping_orders', blank=True, null=True, default=None, on_delete=models.PROTECT, verbose_name=_('shipping method')) shipping_method_name = models.CharField( max_length=64, blank=True, default="", verbose_name=_('shipping method name')) shipping_data = JSONField(blank=True, null=True) extra_data = JSONField(blank=True, null=True) # Money stuff taxful_total_price = MoneyField(editable=False, verbose_name=_('grand total')) taxless_total_price = MoneyField(editable=False, verbose_name=_('taxless total')) display_currency = models.CharField(max_length=4, blank=True) display_currency_rate = models.DecimalField(max_digits=36, decimal_places=9, default=1) # Other ip_address = models.GenericIPAddressField(null=True, blank=True, verbose_name=_('IP address')) # order_date is not `auto_now_add` for backdating purposes order_date = models.DateTimeField(editable=False, verbose_name=_('order date')) payment_date = models.DateTimeField(null=True, editable=False, verbose_name=_('payment date')) # TODO: (TAX) Add me? customer_tax_group = models.ForeignKey(CustomerTaxGroup, blank=True, null=True) language = LanguageField(blank=True, verbose_name=_('language')) customer_comment = models.TextField(blank=True, verbose_name=_('customer comment')) admin_comment = models.TextField(blank=True, verbose_name=_('admin comment/notes')) require_verification = models.BooleanField( default=False, verbose_name=_('requires verification')) all_verified = models.BooleanField(default=False, verbose_name=_('all lines verified')) marketing_permission = models.BooleanField( default=True, verbose_name=_('marketing permission')) common_select_related = ("billing_address", ) objects = OrderQuerySet.as_manager() class Meta: ordering = ("-id", ) verbose_name = _('order') verbose_name_plural = _('orders') def __str__(self): # pragma: no cover if self.billing_address_id: name = self.billing_address.name else: name = "-" if settings.SHOOP_ENABLE_MULTIPLE_SHOPS: return "Order %s (%s, %s)" % (self.identifier, self.shop.name, name) else: return "Order %s (%s)" % (self.identifier, name) def cache_prices(self): taxful_total = Decimal(0) taxless_total = Decimal(0) for line in self.lines.all(): taxful_total += line.taxful_total_price.amount taxless_total += line.taxless_total_price.amount self.taxful_total_price = _round_price(taxful_total) self.taxless_total_price = _round_price(taxless_total) def _cache_contact_values(self): sources = [ self.shipping_address, self.billing_address, self.customer, self.orderer, ] fields = ("vat_code", "email", "phone") for field in fields: if getattr(self, field, None): continue for source in sources: val = getattr(source, field, None) if val: setattr(self, field, val) break def _cache_values(self): self._cache_contact_values() if not self.label: self.label = settings.SHOOP_DEFAULT_ORDER_LABEL if not self.display_currency: self.display_currency = settings.SHOOP_HOME_CURRENCY self.display_currency_rate = 1 if self.shipping_method_id and not self.shipping_method_name: self.shipping_method_name = self.shipping_method.safe_translation_getter( "name", default=self.shipping_method.identifier, any_language=True) if self.payment_method_id and not self.payment_method_name: self.payment_method_name = self.payment_method.safe_translation_getter( "name", default=self.payment_method.identifier, any_language=True) if not self.key: self.key = get_random_string(32) def _save_identifiers(self): self.identifier = "%s" % (get_order_identifier(self)) self.reference_number = get_reference_number(self) super(Order, self).save(update_fields=( "identifier", "reference_number", )) def full_clean(self, exclude=None, validate_unique=True): self._cache_values() return super(Order, self).full_clean(exclude, validate_unique) def create_immutable_address_copies(self): for field in ("billing_address", "shipping_address"): address = getattr(self, field, None) if address and not address.is_immutable: if address.pk: address = address.copy() address.set_immutable() else: address.set_immutable() setattr(self, field, address) def save(self, *args, **kwargs): if not self.creator_id: if not settings.SHOOP_ALLOW_ANONYMOUS_ORDERS: raise ValidationError( "Anonymous (userless) orders are not allowed " "when SHOOP_ALLOW_ANONYMOUS_ORDERS is not enabled.") self._cache_values() first_save = (not self.pk) self.create_immutable_address_copies() super(Order, self).save(*args, **kwargs) if first_save: # Have to do a double save the first time around to be able to save identifiers self._save_identifiers() def delete(self, using=None): if not self.deleted: self.deleted = True self.add_log_entry("Deleted.", kind=LogEntryKind.DELETION) # Bypassing local `save()` on purpose. super(Order, self).save(update_fields=("deleted", ), using=using) def set_canceled(self): if self.status.role != OrderStatusRole.CANCELED: self.status = OrderStatus.objects.get_default_canceled() self.save() def _set_paid(self): if self.payment_status != PaymentStatus.FULLY_PAID: # pragma: no branch self.add_log_entry(_('Order marked as paid.')) self.payment_status = PaymentStatus.FULLY_PAID self.payment_date = now() self.save() def is_paid(self): return (self.payment_status == PaymentStatus.FULLY_PAID) def get_total_paid_amount(self): amounts = self.payments.values_list('amount', flat=True) return sum(amounts, Decimal(0)) def create_payment(self, amount, payment_identifier=None, description=''): """ Create a payment with given amount for this order. If the order already has payments and sum of their amounts is equal or greater than self.taxful_total_price, an exception is raised. If the end sum of all payments is equal or greater than self.taxful_total_price, then the order is marked as paid. :param amount: amount of the payment to be created :param gateway_id: identifier of the gateway used to make this payment. Leave empty for non-gateway payments. :param payment_identifier: Identifier of the created payment. If not set, default value of "gateway_id:order_id:number" will be used (where number is number of payments in the order). :param description: Description of the payment. Will be set to `method` property of the created payment. Returns the created Payment object. """ payments = self.payments.order_by('created_on') total_paid_amount = self.get_total_paid_amount() if total_paid_amount >= self.taxful_total_price: raise NoPaymentToCreateException( "Order %s has already been fully paid (%s >= %s)." % (self.pk, total_paid_amount, self.taxful_total_price)) if not payment_identifier: number = payments.count() + 1 payment_identifier = '%d:%d' % (self.id, number) payment = self.payments.create( payment_identifier=payment_identifier, amount=amount, description=description, ) if self.get_total_paid_amount() >= self.taxful_total_price: self._set_paid() # also calls save return payment def create_shipment(self, supplier, product_quantities): """ Create a shipment for this order from `product_quantities`. `product_quantities` is expected to be a dict mapping Product instances to quantities. Only quantities over 0 are taken into account, and if the mapping is empty or has no quantity value over 0, `NoProductsToShipException` will be raised. :param supplier: The Supplier for this product. No validation is made as to whether the given supplier supplies the products. :param product_quantities: a dict mapping Product instances to quantities to ship :type product_quantities: dict[shoop.shop.models.products.Product, decimal.Decimal] :raises: NoProductsToShipException :return: Saved, complete Shipment object :rtype: shoop.core.models.shipments.Shipment """ if not product_quantities or not any( quantity > 0 for quantity in product_quantities.values()): raise NoProductsToShipException( "No products to ship (`quantities` is empty or has no quantity over 0)." ) from .shipments import Shipment, ShipmentProduct shipment = Shipment(order=self, supplier=supplier) shipment.save() for product, quantity in product_quantities.items(): if quantity > 0: sp = ShipmentProduct(shipment=shipment, product=product, quantity=quantity) sp.cache_values() sp.save() shipment.cache_values() shipment.save() self.add_log_entry(_(u"Shipment #%d created.") % shipment.id) self.check_and_set_fully_shipped() return shipment def create_shipment_of_all_products(self, supplier=None): """ Create a shipment of all the products in this Order, no matter whether or not any have been previously marked as shipped or not. See the documentation for `create_shipment`. :param supplier: The Supplier to use. If `None`, the first supplier in the order is used. (If several are in the order, this fails.) :return: Saved, complete Shipment object :rtype: shoop.shop.models.shipments.Shipment """ suppliers_to_product_quantities = defaultdict( lambda: defaultdict(lambda: 0)) lines = (self.lines.filter(type=OrderLineType.PRODUCT).values_list( "supplier_id", "product_id", "quantity")) for supplier_id, product_id, quantity in lines: if product_id: suppliers_to_product_quantities[supplier_id][ product_id] += quantity if not suppliers_to_product_quantities: raise NoProductsToShipException( "Could not find any products to ship.") if supplier is None: if len(suppliers_to_product_quantities) > 1: # pragma: no cover raise ValueError( "Can only use create_shipment_of_all_products when there is only one supplier" ) supplier_id, quantities = suppliers_to_product_quantities.popitem() supplier = Supplier.objects.get(pk=supplier_id) else: quantities = suppliers_to_product_quantities[supplier.id] products = dict( (product.pk, product) for product in Product.objects.filter(pk__in=quantities.keys())) quantities = dict((products[product_id], quantity) for (product_id, quantity) in quantities.items()) return self.create_shipment(supplier, quantities) def check_all_verified(self): if not self.all_verified: new_all_verified = (not self.lines.filter(verified=False).exists()) if new_all_verified: self.all_verified = True if self.require_verification: self.add_log_entry( _('All rows requiring verification have been verified.' )) self.require_verification = False self.save() return self.all_verified def get_purchased_attachments(self): from .product_media import ProductMedia if self.payment_status != PaymentStatus.FULLY_PAID: return ProductMedia.objects.none() prods = self.lines.exclude(product=None).values_list("product_id", flat=True) return ProductMedia.objects.filter(product__in=prods, enabled=True, purchased=True) def get_tax_summary(self): """ :rtype: taxing.TaxSummary """ all_line_taxes = [] untaxed = TaxlessPrice(0) for line in self.lines.all(): line_taxes = list(line.taxes.all()) all_line_taxes.extend(line_taxes) if not line_taxes: untaxed += line.taxless_total_price return taxing.TaxSummary.from_line_taxes(all_line_taxes, untaxed) def get_product_ids_and_quantities(self): quantities = defaultdict(lambda: 0) for product_id, quantity in self.lines.filter( type=OrderLineType.PRODUCT).values_list( "product_id", "quantity"): quantities[product_id] += quantity return dict(quantities) def is_complete(self): return (self.status.role == OrderStatusRole.COMPLETE) def can_set_complete(self): fully_shipped = (self.shipping_status == ShippingStatus.FULLY_SHIPPED) canceled = (self.status.role == OrderStatusRole.CANCELED) return (not self.is_complete()) and fully_shipped and (not canceled) def check_and_set_fully_shipped(self): if self.shipping_status != ShippingStatus.FULLY_SHIPPED: if not self.get_unshipped_products(): self.shipping_status = ShippingStatus.FULLY_SHIPPED self.add_log_entry( _(u"All products have been shipped. Fully Shipped status set." )) self.save(update_fields=("shipping_status", )) return True def get_known_additional_data(self): """ Get a list of "known additional data" in this order's payment_data, shipping_data and extra_data. The list is returned in the order the fields are specified in the settings entries for said known keys. `dict(that_list)` can of course be used to "flatten" the list into a dict. :return: list of 2-tuples. """ output = [] for data_dict, name_mapping in ( (self.payment_data, settings.SHOOP_ORDER_KNOWN_PAYMENT_DATA_KEYS), (self.shipping_data, settings.SHOOP_ORDER_KNOWN_SHIPPING_DATA_KEYS), (self.extra_data, settings.SHOOP_ORDER_KNOWN_EXTRA_DATA_KEYS), ): if hasattr(data_dict, "get"): for key, display_name in name_mapping: if key in data_dict: output.append( (force_text(display_name), data_dict[key])) return output def get_product_summary(self): """Return a dict of product IDs -> {ordered, unshipped, shipped}""" products = defaultdict(lambda: defaultdict(lambda: Decimal(0))) lines = (self.lines.filter(type=OrderLineType.PRODUCT).values_list( "product_id", "quantity")) for product_id, quantity in lines: products[product_id]['ordered'] += quantity products[product_id]['unshipped'] += quantity from .shipments import ShipmentProduct shipment_prods = (ShipmentProduct.objects.filter( shipment__order=self).values_list("product_id", "quantity")) for product_id, quantity in shipment_prods: products[product_id]['shipped'] += quantity products[product_id]['unshipped'] -= quantity return products def get_unshipped_products(self): return dict( (product, summary_datum) for product, summary_datum in self.get_product_summary().items() if summary_datum['unshipped']) def get_status_display(self): return force_text(self.status)
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)
class ShopProduct(models.Model): shop = models.ForeignKey("Shop", related_name="shop_products") product = UnsavedForeignKey("Product", related_name="shop_products") 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) 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, taxfulness is determined in # `Shop.prices_include_tax` default_price = MoneyField(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 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 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")