def test_money_field_formfield(): field = MoneyField( 'price', currency='BTC', default='5', max_digits=9, decimal_places=2) form_field = field.formfield() assert isinstance(form_field, forms.MoneyField) assert form_field.currency == 'BTC' assert isinstance(form_field.widget, widgets.MoneyInput)
class ShippingMethod(models.Model): name = models.CharField(max_length=100) type = models.CharField(max_length=30, choices=ShippingMethodType.CHOICES) price = MoneyField(currency=settings.DEFAULT_CURRENCY, max_digits=12, decimal_places=settings.DEFAULT_DECIMAL_PLACES, default=0) shipping_zone = models.ForeignKey(ShippingZone, related_name='shipping_methods', on_delete=models.CASCADE) minimum_order_price = MoneyField( currency=settings.DEFAULT_CURRENCY, max_digits=12, decimal_places=settings.DEFAULT_DECIMAL_PLACES, default=0, blank=True, null=True) maximum_order_price = MoneyField( currency=settings.DEFAULT_CURRENCY, max_digits=12, decimal_places=settings.DEFAULT_DECIMAL_PLACES, blank=True, null=True) minimum_order_weight = MeasurementField(measurement=Weight, unit_choices=WeightUnits.CHOICES, default=zero_weight, blank=True, null=True) maximum_order_weight = MeasurementField(measurement=Weight, unit_choices=WeightUnits.CHOICES, blank=True, null=True) objects = ShippingMethodQueryset.as_manager() translated = TranslationProxy() def __str__(self): return self.name def __repr__(self): if self.type == ShippingMethodType.PRICE_BASED: minimum = '%s%s' % (self.minimum_order_price.amount, self.minimum_order_price.currency) max_price = self.maximum_order_price maximum = ('%s%s' % (max_price.amount, max_price.currency) if max_price else 'no limit') return 'ShippingMethod(type=%s min=%s, max=%s)' % ( self.type, minimum, maximum) return 'ShippingMethod(type=%s weight_range=(%s)' % ( self.type, get_weight_type_display(self.minimum_order_weight, self.maximum_order_weight)) def get_total(self, taxes=None): return get_taxed_shipping_price(self.price, taxes) def get_ajax_label(self): price_html = format_money(self.price) label = mark_safe('%s %s' % (self, price_html)) return label def get_type_display(self): if self.type == ShippingMethodType.PRICE_BASED: return get_price_type_display(self.minimum_order_price, self.maximum_order_price) return get_weight_type_display(self.minimum_order_weight, self.maximum_order_weight)
class Product(SeoModel, ModelWithMetadata, PublishableModel): product_type = models.ForeignKey(ProductType, related_name="products", on_delete=models.CASCADE) name = models.CharField(max_length=128) description = models.TextField(blank=True) description_json = SanitizedJSONField(blank=True, default=dict, sanitizer=clean_draft_js) category = models.ForeignKey(Category, related_name="products", on_delete=models.CASCADE) price = MoneyField( currency=settings.DEFAULT_CURRENCY, max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, ) attributes = FilterableJSONBField(default=dict, blank=True, validators=[validate_attribute_json]) updated_at = models.DateTimeField(auto_now=True, null=True) charge_taxes = models.BooleanField(default=True) weight = MeasurementField(measurement=Weight, unit_choices=WeightUnits.CHOICES, blank=True, null=True) objects = ProductsQueryset.as_manager() translated = TranslationProxy() class Meta: app_label = "product" ordering = ("name", ) permissions = (( "manage_products", pgettext_lazy("Permission description", "Manage products."), ), ) def __iter__(self): if not hasattr(self, "__variants"): setattr(self, "__variants", self.variants.all()) return iter(getattr(self, "__variants")) def __repr__(self): class_ = type(self) return "<%s.%s(pk=%r, name=%r)>" % ( class_.__module__, class_.__name__, self.pk, self.name, ) def __str__(self): return self.name @property def plain_text_description(self): if settings.USE_JSON_CONTENT: return json_content_to_raw_text(self.description_json) return strip_tags(self.description) @property def is_available(self): return self.is_visible and self.is_in_stock() def get_absolute_url(self): return reverse("product:details", kwargs={ "slug": self.get_slug(), "product_id": self.id }) def get_slug(self): return slugify(smart_text(unidecode(self.name))) def is_in_stock(self): return any(variant.is_in_stock() for variant in self) def get_first_image(self): images = list(self.images.all()) return images[0] if images else None def get_price_range(self, discounts: Iterable[DiscountInfo] = None): if self.variants.all(): prices = [variant.get_price(discounts) for variant in self] return MoneyRange(min(prices), max(prices)) price = calculate_discounted_price(self, self.price, discounts) return MoneyRange(start=price, stop=price)
class Product(SeoModel, ModelWithMetadata, PublishableModel): product_type = models.ForeignKey(ProductType, related_name="products", on_delete=models.CASCADE) name = models.CharField(max_length=250) slug = models.SlugField(max_length=255, unique=True, allow_unicode=True) description = models.TextField(blank=True) description_json = SanitizedJSONField(blank=True, default=dict, sanitizer=clean_draft_js) category = models.ForeignKey( Category, related_name="products", on_delete=models.SET_NULL, null=True, blank=True, ) currency = models.CharField( max_length=settings.DEFAULT_CURRENCY_CODE_LENGTH, default=settings.DEFAULT_CURRENCY, ) minimal_variant_price_amount = models.DecimalField( max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, blank=True, null=True, ) minimal_variant_price = MoneyField( amount_field="minimal_variant_price_amount", currency_field="currency") updated_at = models.DateTimeField(auto_now=True, null=True) charge_taxes = models.BooleanField(default=True) weight = MeasurementField(measurement=Weight, unit_choices=WeightUnits.CHOICES, blank=True, null=True) objects = ProductsQueryset.as_manager() translated = TranslationProxy() class Meta: app_label = "product" ordering = ("slug", ) permissions = ((ProductPermissions.MANAGE_PRODUCTS.codename, "Manage products."), ) def __iter__(self): if not hasattr(self, "__variants"): setattr(self, "__variants", self.variants.all()) return iter(getattr(self, "__variants")) def __repr__(self) -> str: class_ = type(self) return "<%s.%s(pk=%r, name=%r)>" % ( class_.__module__, class_.__name__, self.pk, self.name, ) def __str__(self) -> str: return self.name @property def plain_text_description(self) -> str: return json_content_to_raw_text(self.description_json) def get_first_image(self): images = list(self.images.all()) return images[0] if images else None @staticmethod def sort_by_attribute_fields() -> list: return ["concatenated_values_order", "concatenated_values", "name"]
class Cart(models.Model): """A shopping cart.""" created = models.DateTimeField(auto_now_add=True) last_change = models.DateTimeField(auto_now_add=True) user = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True, related_name='carts', on_delete=models.CASCADE) email = models.EmailField(blank=True, default='') token = models.UUIDField(primary_key=True, default=uuid4, editable=False) quantity = models.PositiveIntegerField(default=0) billing_address = models.ForeignKey(Address, related_name='+', editable=False, null=True, on_delete=models.SET_NULL) shipping_address = models.ForeignKey(Address, related_name='+', editable=False, null=True, on_delete=models.SET_NULL) shipping_method = models.ForeignKey(ShippingMethod, blank=True, null=True, related_name='carts', on_delete=models.SET_NULL) note = models.TextField(blank=True, default='') discount_amount = MoneyField( currency=settings.DEFAULT_CURRENCY, max_digits=12, decimal_places=settings.DEFAULT_DECIMAL_PLACES, default=0) discount_name = models.CharField(max_length=255, blank=True, null=True) translated_discount_name = models.CharField(max_length=255, blank=True, null=True) voucher_code = models.CharField(max_length=12, blank=True, null=True) objects = CartQueryset.as_manager() class Meta: ordering = ('-last_change', ) def __repr__(self): return 'Cart(quantity=%s)' % (self.quantity, ) def __iter__(self): return iter(self.lines.all()) def __len__(self): return self.lines.count() def is_shipping_required(self): """Return `True` if any of the lines requires shipping.""" return any(line.is_shipping_required() for line in self) def get_shipping_price(self, taxes): return (self.shipping_method.get_total_price(taxes) if self.shipping_method and self.is_shipping_required() else ZERO_TAXED_MONEY) def get_subtotal(self, discounts=None, taxes=None): """Return the total cost of the cart prior to shipping.""" subtotals = (line.get_total(discounts, taxes) for line in self) return sum(subtotals, ZERO_TAXED_MONEY) def get_total(self, discounts=None, taxes=None): """Return the total cost of the cart.""" return (self.get_subtotal(discounts, taxes) + self.get_shipping_price(taxes) - self.discount_amount) def get_total_weight(self): # Cannot use `sum` as it parses an empty Weight to an int weights = Weight(kg=0) for line in self: weights += line.variant.get_weight() * line.quantity return weights def get_line(self, variant): """Return a line matching the given variant and data if any.""" matching_lines = (line for line in self if line.variant == variant) return next(matching_lines, None)
class Product(SeoModel, ModelWithMetadata, PublishableModel): product_type = models.ForeignKey(ProductType, related_name="products", on_delete=models.CASCADE) name = models.CharField(max_length=250) slug = models.SlugField(max_length=255, unique=True) description = models.TextField(blank=True) description_json = SanitizedJSONField(blank=True, default=dict, sanitizer=clean_draft_js) category = models.ForeignKey( Category, related_name="products", on_delete=models.SET_NULL, null=True, blank=True, ) currency = models.CharField( max_length=settings.DEFAULT_CURRENCY_CODE_LENGTH, default=settings.DEFAULT_CURRENCY, ) price_amount = models.DecimalField( max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, ) price = MoneyField(amount_field="price_amount", currency_field="currency") minimal_variant_price_amount = models.DecimalField( max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, ) minimal_variant_price = MoneyField( amount_field="minimal_variant_price_amount", currency_field="currency") updated_at = models.DateTimeField(auto_now=True, null=True) charge_taxes = models.BooleanField(default=True) weight = MeasurementField(measurement=Weight, unit_choices=WeightUnits.CHOICES, blank=True, null=True) objects = ProductsQueryset.as_manager() translated = TranslationProxy() class Meta: app_label = "product" ordering = ("name", ) permissions = ((ProductPermissions.MANAGE_PRODUCTS.codename, "Manage products."), ) def __iter__(self): if not hasattr(self, "__variants"): setattr(self, "__variants", self.variants.all()) return iter(getattr(self, "__variants")) def __repr__(self) -> str: class_ = type(self) return "<%s.%s(pk=%r, name=%r)>" % ( class_.__module__, class_.__name__, self.pk, self.name, ) def __str__(self) -> str: return self.name def save(self, force_insert=False, force_update=False, using=None, update_fields=None): # Make sure the "minimal_variant_price_amount" is set if self.minimal_variant_price_amount is None: self.minimal_variant_price_amount = self.price_amount return super().save(force_insert, force_update, using, update_fields) @property def plain_text_description(self) -> str: return json_content_to_raw_text(self.description_json) def get_first_image(self): images = list(self.images.all()) return images[0] if images else None def get_price_range( self, discounts: Optional[Iterable[DiscountInfo]] = None) -> MoneyRange: import opentracing with opentracing.global_tracer().start_active_span("get_price_range"): if self.variants.all(): prices = [variant.get_price(discounts) for variant in self] return MoneyRange(min(prices), max(prices)) price = calculate_discounted_price( product=self, price=self.price, collections=self.collections.all(), discounts=discounts, ) return MoneyRange(start=price, stop=price) @staticmethod def sort_by_attribute_fields() -> list: return ["concatenated_values_order", "concatenated_values", "name"]
class Product(SeoModel, PublishableModel): product_type = models.ForeignKey(ProductType, related_name='products', on_delete=models.CASCADE) name = models.CharField(max_length=128) description = models.TextField() category = models.ForeignKey(Category, related_name='products', on_delete=models.CASCADE) price = MoneyField(currency=settings.DEFAULT_CURRENCY, max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES) attributes = HStoreField(default=dict, blank=True) updated_at = models.DateTimeField(auto_now=True, null=True) charge_taxes = models.BooleanField(default=True) tax_rate = models.CharField(max_length=128, default=DEFAULT_TAX_RATE_NAME, blank=True, choices=TaxRateType.CHOICES) weight = MeasurementField(measurement=Weight, unit_choices=WeightUnits.CHOICES, blank=True, null=True) translated = TranslationProxy() class Meta: app_label = 'product' ordering = ('name', ) permissions = (('manage_products', pgettext_lazy('Permission description', 'Manage products.')), ) def __iter__(self): if not hasattr(self, '__variants'): setattr(self, '__variants', self.variants.all()) return iter(getattr(self, '__variants')) def __repr__(self): class_ = type(self) return '<%s.%s(pk=%r, name=%r)>' % (class_.__module__, class_.__name__, self.pk, self.name) def __str__(self): return self.name def get_absolute_url(self): return reverse('product:details', kwargs={ 'slug': self.get_slug(), 'product_id': self.id }) def get_slug(self): return slugify(smart_text(unidecode(self.name))) def is_in_stock(self): return any(variant.is_in_stock() for variant in self) def get_first_image(self): images = list(self.images.all()) return images[0] if images else None def get_price_range(self, discounts=None, taxes=None): if self.variants.all(): prices = [ variant.get_price(discounts=discounts, taxes=taxes) for variant in self ] return TaxedMoneyRange(min(prices), max(prices)) price = calculate_discounted_price(self, self.price, discounts) if not self.charge_taxes: taxes = None tax_rate = self.tax_rate or self.product_type.tax_rate price = apply_tax_to_price(taxes, tax_rate, price) return TaxedMoneyRange(start=price, stop=price)
def test_money_field_get_prep_value(): field = MoneyField( 'price', currency='BTC', default='5', max_digits=9, decimal_places=2) assert field.get_prep_value(Money(5, 'BTC')) == Decimal(5)
class Voucher(models.Model): type = models.CharField(max_length=20, choices=VoucherType.CHOICES, default=VoucherType.ENTIRE_ORDER) name = models.CharField(max_length=255, null=True, blank=True) code = models.CharField(max_length=12, unique=True, db_index=True) usage_limit = models.PositiveIntegerField(null=True, blank=True) used = models.PositiveIntegerField(default=0, editable=False) start_date = models.DateTimeField(default=timezone.now) end_date = models.DateTimeField(null=True, blank=True) # this field indicates if discount should be applied per order or # individually to every item apply_once_per_order = models.BooleanField(default=False) apply_once_per_customer = models.BooleanField(default=False) discount_value_type = models.CharField( max_length=10, choices=DiscountValueType.CHOICES, default=DiscountValueType.FIXED, ) discount_value = models.DecimalField( max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, ) discount = MoneyField(amount_field="discount_value", currency_field="currency") # not mandatory fields, usage depends on type countries = CountryField(multiple=True, blank=True) currency = models.CharField( max_length=settings.DEFAULT_CURRENCY_CODE_LENGTH, default=settings.DEFAULT_CURRENCY, ) min_spent_amount = models.DecimalField( max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, blank=True, null=True, ) min_spent = MoneyField(amount_field="min_spent_amount", currency_field="currency") min_checkout_items_quantity = models.PositiveIntegerField(null=True, blank=True) products = models.ManyToManyField("product.Product", blank=True) collections = models.ManyToManyField("product.Collection", blank=True) categories = models.ManyToManyField("product.Category", blank=True) objects = VoucherQueryset.as_manager() translated = TranslationProxy() def __str__(self): if self.name: return self.name discount = "%s %s" % ( self.discount_value, self.get_discount_value_type_display(), ) if self.type == VoucherType.SHIPPING: if self.is_free: return "Free shipping" return f"{discount} off shipping" if self.type == VoucherType.SPECIFIC_PRODUCT: return f"%{discount} off specific products" return f"{discount} off" @property def is_free(self): return (self.discount_value == Decimal(100) and self.discount_value_type == DiscountValueType.PERCENTAGE) def get_discount(self): if self.discount_value_type == DiscountValueType.FIXED: discount_amount = Money(self.discount_value, settings.DEFAULT_CURRENCY) return partial(fixed_discount, discount=discount_amount) if self.discount_value_type == DiscountValueType.PERCENTAGE: return partial(percentage_discount, percentage=self.discount_value) raise NotImplementedError("Unknown discount type") def get_discount_amount_for(self, price: Money): discount = self.get_discount() after_discount = discount(price) if after_discount.amount < 0: return price return price - after_discount def validate_min_spent(self, value: Money): if self.min_spent and value < self.min_spent: msg = f"This offer is only valid for orders over {amount(self.min_spent)}." raise NotApplicable(msg, min_spent=self.min_spent) def validate_min_checkout_items_quantity(self, quantity): min_checkout_items_quantity = self.min_checkout_items_quantity if min_checkout_items_quantity and min_checkout_items_quantity > quantity: msg = ("This offer is only valid for orders with a minimum of " f"{min_checkout_items_quantity} quantity.") raise NotApplicable( msg, min_checkout_items_quantity=min_checkout_items_quantity, ) def validate_once_per_customer(self, customer_email): voucher_customer = VoucherCustomer.objects.filter( voucher=self, customer_email=customer_email) if voucher_customer: msg = "This offer is valid only once per customer." raise NotApplicable(msg)
class ProductVariant(ModelWithMetadata): sku = models.CharField(max_length=255, unique=True, null=True) name = models.CharField(max_length=255, blank=True) currency = models.CharField( max_length=settings.DEFAULT_CURRENCY_CODE_LENGTH, default=settings.DEFAULT_CURRENCY, blank=True, null=True, ) price_override_amount = models.DecimalField( max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, blank=True, null=True, ) price_override = MoneyField(amount_field="price_override_amount", currency_field="currency") product = models.ForeignKey(Product, related_name="variants", on_delete=models.CASCADE) images = models.ManyToManyField("ProductImage", through="VariantImage") videos = models.ManyToManyField("ProductVideo", through="VariantVideo") track_inventory = models.BooleanField(default=True) quantity = models.IntegerField(validators=[MinValueValidator(0)], default=Decimal(1)) quantity_allocated = models.IntegerField(validators=[MinValueValidator(0)], default=Decimal(1)) cost_price_amount = models.DecimalField( max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, blank=True, null=True, ) cost_price = MoneyField(amount_field="cost_price_amount", currency_field="currency") weight = MeasurementField(measurement=Weight, unit_choices=WeightUnits.CHOICES, blank=True, null=True) objects = ProductVariantQueryset.as_manager() translated = TranslationProxy() class Meta: app_label = "product" def __str__(self): return '' @property def quantity_available(self): # No need for video courses/quantity is limitless # return max(self.quantity - self.quantity_allocated, 0) return 1 @property def is_visible(self): return self.product.is_visible @property def is_available(self): return self.is_visible and self.is_in_stock() def check_quantity(self, quantity): """Check if there is at least the given quantity in stock. If stock handling is disabled, it simply run no check. """ # No need for video courses/quantity is limitless # if self.track_inventory and quantity > self.quantity_available: # raise InsufficientStock(self) pass @property def base_price(self): return (self.price_override if self.price_override is not None else self.product.price) def get_price(self, discounts: Iterable[DiscountInfo] = None): return calculate_discounted_price(self.product, self.base_price, discounts) def get_weight(self): return self.weight or self.product.weight or self.product.product_type.weight def get_absolute_url(self): slug = self.product.get_slug() product_id = self.product.id return reverse("product:details", kwargs={ "slug": slug, "product_id": product_id }) def is_shipping_required(self): return False def is_digital(self): return True def is_in_stock(self): return self.quantity_available > 0 def display_product(self, translated=False): if translated: product = self.product.translated variant_display = str(self.translated) else: variant_display = str(self) product = self.product product_display = (f"{product} ({variant_display})" if variant_display else str(product)) return smart_text(product_display) def get_first_image(self): images = list(self.images.all()) return images[0] if images else self.product.get_first_image() def get_ajax_label(self, discounts=None): price = self.get_price(discounts) return "%s, %s, %s" % (self.sku, self.display_product(), prices.amount(price))
def test_money_field_init(): field = MoneyField(currency='BTC', default='5', max_digits=9, decimal_places=2) assert field.get_default() == Money(5, 'BTC')
def test_money_field_from_db_value_checks_min_value(): field = MoneyField( 'price', currency='BTC', default='5', max_digits=9, decimal_places=2) invalid = Money(1, 'USD') with pytest.raises(ValueError): field.from_db_value(invalid, None, None, None)
def test_money_field_from_db_value_handles_none(): field = MoneyField( 'price', currency='BTC', default='5', max_digits=9, decimal_places=2) assert field.from_db_value(None, None, None, None) is None
def test_money_field_get_db_prep_save(): field = MoneyField( 'price', currency='BTC', default='5', max_digits=9, decimal_places=2) value = field.get_db_prep_save(Money(5, 'BTC'), connection) assert value == '5.00'
class Product(SeoModel): product_type = models.ForeignKey(ProductType, related_name='products', on_delete=models.CASCADE) name = models.CharField(max_length=128) description = models.TextField() category = models.ForeignKey(Category, related_name='products', on_delete=models.CASCADE) price = MoneyField(currency=settings.DEFAULT_CURRENCY, max_digits=12, decimal_places=settings.DEFAULT_DECIMAL_PLACES) available_on = models.DateField(blank=True, null=True) is_published = models.BooleanField(default=True) attributes = HStoreField(default={}, blank=True) updated_at = models.DateTimeField(auto_now=True, null=True) is_featured = models.BooleanField(default=False) charge_taxes = models.BooleanField(default=True) tax_rate = models.CharField(max_length=128, default=DEFAULT_TAX_RATE_NAME, blank=True) objects = ProductQuerySet.as_manager() class Meta: app_label = 'product' permissions = (('view_product', pgettext_lazy('Permission description', 'Can view products')), ('edit_product', pgettext_lazy('Permission description', 'Can edit products')), ('view_properties', pgettext_lazy('Permission description', 'Can view product properties')), ('edit_properties', pgettext_lazy('Permission description', 'Can edit product properties'))) def __iter__(self): if not hasattr(self, '__variants'): setattr(self, '__variants', self.variants.all()) return iter(getattr(self, '__variants')) def __repr__(self): class_ = type(self) return '<%s.%s(pk=%r, name=%r)>' % (class_.__module__, class_.__name__, self.pk, self.name) def __str__(self): return self.name def get_absolute_url(self): return reverse('product:details', kwargs={ 'slug': self.get_slug(), 'product_id': self.id }) def get_slug(self): return slugify(smart_text(unidecode(self.name))) def is_in_stock(self): return any(variant.is_in_stock() for variant in self) def is_available(self): today = datetime.date.today() return self.available_on is None or self.available_on <= today def get_first_image(self): first_image = self.images.first() return first_image.image if first_image else None def get_price_range(self, discounts=None, taxes=None): if self.variants.exists(): prices = [ variant.get_price(discounts=discounts, taxes=taxes) for variant in self ] return TaxedMoneyRange(min(prices), max(prices)) price = calculate_discounted_price(self, self.price, discounts) if not self.charge_taxes: taxes = None tax_rate = self.tax_rate or self.product_type.tax_rate price = apply_tax_to_price(taxes, tax_rate, price) return TaxedMoneyRange(start=price, stop=price)
class GiftCard(ModelWithMetadata): code = models.CharField(max_length=16, unique=True, db_index=True) is_active = models.BooleanField(default=True) created_by = models.ForeignKey( settings.AUTH_USER_MODEL, blank=True, null=True, on_delete=models.SET_NULL, related_name="+", ) used_by = models.ForeignKey( settings.AUTH_USER_MODEL, blank=True, null=True, on_delete=models.SET_NULL, related_name="gift_cards", ) created_by_email = models.EmailField(null=True, blank=True) used_by_email = models.EmailField(null=True, blank=True) app = models.ForeignKey( App, blank=True, null=True, on_delete=models.SET_NULL, related_name="+", ) expiry_date = models.DateField(null=True, blank=True) tags = models.ManyToManyField(GiftCardTag, "gift_cards") created = models.DateTimeField(auto_now_add=True) last_used_on = models.DateTimeField(null=True, blank=True) product = models.ForeignKey( "product.Product", null=True, blank=True, on_delete=models.SET_NULL, related_name="gift_cards", ) fulfillment_line = models.ForeignKey( "order.FulfillmentLine", related_name="gift_cards", on_delete=models.SET_NULL, blank=True, null=True, ) currency = models.CharField( max_length=settings.DEFAULT_CURRENCY_CODE_LENGTH, default=os.environ.get("DEFAULT_CURRENCY", "USD"), ) initial_balance_amount = models.DecimalField( max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, ) initial_balance = MoneyField(amount_field="initial_balance_amount", currency_field="currency") current_balance_amount = models.DecimalField( max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, ) current_balance = MoneyField(amount_field="current_balance_amount", currency_field="currency") objects = models.Manager.from_queryset(GiftCardQueryset)() class Meta: ordering = ("code", ) permissions = ((GiftcardPermissions.MANAGE_GIFT_CARD.codename, "Manage gift cards."), ) @property def display_code(self): return self.code[-4:]
class Order(models.Model): created = models.DateTimeField( default=now, editable=False) last_status_change = models.DateTimeField( default=now, editable=False) user = models.ForeignKey( settings.AUTH_USER_MODEL, blank=True, null=True, related_name='orders', on_delete=models.SET_NULL) language_code = models.CharField( max_length=35, default=settings.LANGUAGE_CODE) tracking_client_id = models.CharField( max_length=36, blank=True, editable=False) billing_address = models.ForeignKey( Address, related_name='+', editable=False, on_delete=models.PROTECT) shipping_address = models.ForeignKey( Address, related_name='+', editable=False, null=True, on_delete=models.PROTECT) user_email = models.EmailField( blank=True, default='', editable=False) shipping_price_net = MoneyField( currency=settings.DEFAULT_CURRENCY, max_digits=12, decimal_places=2, default=0, editable=False) shipping_price_gross = MoneyField( currency=settings.DEFAULT_CURRENCY, max_digits=12, decimal_places=2, default=0, editable=False) shipping_price = TaxedMoneyField( net_field='shipping_price_net', gross_field='shipping_price_gross') token = models.CharField(max_length=36, unique=True) total_net = MoneyField( currency=settings.DEFAULT_CURRENCY, max_digits=12, decimal_places=2, blank=True, null=True) total_gross = MoneyField( currency=settings.DEFAULT_CURRENCY, max_digits=12, decimal_places=2, blank=True, null=True) total = TaxedMoneyField(net_field='total_net', gross_field='total_gross') voucher = models.ForeignKey( Voucher, null=True, blank=True, related_name='+', on_delete=models.SET_NULL) discount_amount = MoneyField( currency=settings.DEFAULT_CURRENCY, max_digits=12, decimal_places=2, blank=True, null=True) discount_name = models.CharField(max_length=255, default='', blank=True) paytm_paid = models.BooleanField(default=False) objects = OrderQuerySet.as_manager() class Meta: ordering = ('-last_status_change',) permissions = ( ('view_order', pgettext_lazy('Permission description', 'Can view orders')), ('edit_order', pgettext_lazy('Permission description', 'Can edit orders'))) def save(self, *args, **kwargs): if not self.token: self.token = str(uuid4()) return super().save(*args, **kwargs) def get_lines(self): return OrderLine.objects.filter(delivery_group__order=self) def is_fully_paid(self): total_paid = sum( [ payment.get_total_price() for payment in self.payments.filter(status=PaymentStatus.CONFIRMED)], TaxedMoney( net=Money(0, currency=settings.DEFAULT_CURRENCY), gross=Money(0, currency=settings.DEFAULT_CURRENCY))) return total_paid.gross >= self.total.gross def get_user_current_email(self): return self.user.email if self.user else self.user_email def _index_billing_phone(self): return self.billing_address.phone def _index_shipping_phone(self): return self.shipping_address.phone def __iter__(self): return iter(self.groups.all()) def __repr__(self): return '<Order #%r>' % (self.id,) def __str__(self): return '#%d' % (self.id,) def get_absolute_url(self): return reverse('order:details', kwargs={'token': self.token}) def get_last_payment_status(self): last_payment = self.payments.last() if last_payment: return last_payment.status return None def get_last_payment_status_display(self): last_payment = self.payments.last() if last_payment: return last_payment.get_status_display() return None def is_pre_authorized(self): return self.payments.filter(status=PaymentStatus.PREAUTH).exists() def is_shipping_required(self): return any(group.is_shipping_required() for group in self.groups.all()) @property def is_past_due(self): if self.paytm_paid: return False return now().date() > self.created.date() @property def status(self): """Order status deduced from shipment groups.""" statuses = set([group.status for group in self.groups.all()]) return ( OrderStatus.OPEN if GroupStatus.NEW in statuses else OrderStatus.CLOSED) @property def is_open(self): return self.status == OrderStatus.OPEN def get_status_display(self): """Order status display text.""" return dict(OrderStatus.CHOICES)[self.status] def get_subtotal(self): subtotal_iterator = (line.get_total() for line in self.get_lines()) return sum(subtotal_iterator, ZERO_TAXED_MONEY) def can_cancel(self): return self.status == OrderStatus.OPEN
class OrderLine(models.Model): order = models.ForeignKey(Order, related_name="lines", editable=False, on_delete=models.CASCADE) variant = models.ForeignKey( "product.ProductVariant", related_name="order_lines", on_delete=models.SET_NULL, blank=True, null=True, ) # max_length is as produced by ProductVariant's display_product method product_name = models.CharField(max_length=386) translated_product_name = models.CharField(max_length=386, default="", blank=True) product_sku = models.CharField(max_length=32) is_shipping_required = models.BooleanField() quantity = models.IntegerField(validators=[MinValueValidator(1)]) quantity_fulfilled = models.IntegerField(validators=[MinValueValidator(0)], default=0) currency = models.CharField( max_length=settings.DEFAULT_CURRENCY_CODE_LENGTH, default=settings.DEFAULT_CURRENCY, ) unit_price_net_amount = models.DecimalField( max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, ) unit_price_net = MoneyField(amount_field="unit_price_net_amount", currency_field="currency") unit_price_gross_amount = models.DecimalField( max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, ) unit_price_gross = MoneyField(amount_field="unit_price_gross_amount", currency_field="currency") unit_price = TaxedMoneyField( net_amount_field="unit_price_net_amount", gross_amount_field="unit_price_gross_amount", currency="currency", ) tax_rate = models.DecimalField(max_digits=5, decimal_places=2, default=Decimal("0.0")) objects = OrderLineQueryset.as_manager() class Meta: ordering = ("pk", ) def __str__(self): return self.product_name def get_total(self): return self.unit_price * self.quantity @property def quantity_unfulfilled(self): return self.quantity - self.quantity_fulfilled @property def is_digital(self) -> bool: """Check if a variant is digital and contains digital content.""" is_digital = self.variant.is_digital() has_digital = hasattr(self.variant, "digital_content") return is_digital and has_digital
class Campaign(PublishableModel, TimeStampMixin): campaign_type = models.ForeignKey(CampaignType, related_name="campaigns", on_delete=models.CASCADE) name = models.CharField(max_length=250) slug = models.SlugField(max_length=255, unique=True) description = models.TextField(blank=True) story = models.TextField(blank=True) currency = models.CharField( max_length=settings.DEFAULT_CURRENCY_CODE_LENGTH, default=settings.DEFAULT_CURRENCY, ) total_investment_amount = models.DecimalField( max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, ) total_investment = MoneyField(amount_field="total_investment_amount", currency_field="currency") investment_raised_amount = models.DecimalField( max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, ) investment_raised = MoneyField(amount_field="investment_raised_amount", currency_field="currency") mininum_allowed_investment_amount = models.DecimalField( max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, ) mininum_allowed_investment = MoneyField( amount_field="mininum_allowed_investment_amount", currency_field="currency") location = models.OneToOneField(Address, related_name="location", on_delete=models.CASCADE, blank=True, null=True) estimated_investment_date = models.DateTimeField(blank=False) posted_by = models.ForeignKey( settings.AUTH_USER_MODEL, blank=True, null=True, related_name="campaigns_owened", on_delete=models.SET_NULL, ) investors = models.ManyToManyField(settings.AUTH_USER_MODEL, blank=True, related_name="campaigns_invested_in") status = models.CharField(max_length=32, default=CampaignStatus.UNFULFILLED, choices=CampaignStatus.CHOICES) class Meta: app_label = "campaign" ordering = ( "name", "total_investment_amount", ) permissions = ((CampaignPermissions.MANAGE_CAMPAIGNS.codename, "Manage campaigns."), ) def get_first_image(self): images = list(self.images.all()) return images[0] if images else None def get_first_image_url(self): images = list(self.images.all()) return images[ 0].image.url if images else "/static/assets/img/placeholder800x470.png" def get_first_image_alt(self): images = list(self.images.all()) return images[0].alt if images else "placeholder image" def get_campaign_progress(self): return round(((self.investment_raised_amount) / (self.total_investment_amount)) * 100, 1) def investment_raised_amount_formatted(self): return round(self.investment_raised_amount, 0) def total_investment_amount_formatted(self): return round(self.total_investment_amount, 0) def get_absolute_url(self): return reverse('campaign-detail', args=[self.id, self.slug]) def __repr__(self) -> str: class_ = type(self) return "<%s.%s(pk=%r, name=%r)>" % ( class_.__module__, class_.__name__, self.pk, self.name, ) def __str__(self) -> str: return self.name
class Cart(models.Model): """A shopping cart.""" status = models.CharField(max_length=32, choices=CartStatus.CHOICES, default=CartStatus.OPEN) created = models.DateTimeField(auto_now_add=True) last_status_change = models.DateTimeField(auto_now_add=True) user = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True, related_name='carts', on_delete=models.CASCADE) email = models.EmailField(blank=True, null=True) token = models.UUIDField(primary_key=True, default=uuid4, editable=False) voucher = models.ForeignKey('discount.Voucher', null=True, related_name='+', on_delete=models.SET_NULL) checkout_data = JSONField(null=True, editable=False) total = MoneyField(currency=settings.DEFAULT_CURRENCY, max_digits=12, decimal_places=2, default=0) quantity = models.PositiveIntegerField(default=0) objects = CartQueryset.as_manager() class Meta: ordering = ('-last_status_change', ) def __init__(self, *args, **kwargs): self.discounts = kwargs.pop('discounts', None) super().__init__(*args, **kwargs) def update_quantity(self): """Recalculate cart quantity based on lines.""" total_lines = self.count()['total_quantity'] if not total_lines: total_lines = 0 self.quantity = total_lines self.save(update_fields=['quantity']) def change_status(self, status): """Change cart status.""" # FIXME: investigate replacing with django-fsm transitions if status not in dict(CartStatus.CHOICES): raise ValueError('Not expected status') if status != self.status: self.status = status self.last_status_change = now() self.save() def change_user(self, user): """Assign cart to a user. If the user already has an open cart assigned, cancel it. """ open_cart = find_open_cart_for_user(user) if open_cart is not None: open_cart.change_status(status=CartStatus.CANCELED) self.user = user self.save(update_fields=['user']) def is_shipping_required(self): """Return `True` if any of the lines requires shipping.""" return any(line.is_shipping_required() for line in self.lines.all()) def __repr__(self): return 'Cart(quantity=%s)' % (self.quantity, ) def __len__(self): return self.lines.count() def get_total(self, discounts=None): """Return the total cost of the cart prior to shipping.""" subtotals = [line.get_total(discounts) for line in self.lines.all()] if not subtotals: raise AttributeError('Calling get_total() on an empty cart') return sum_prices(subtotals) def count(self): """Return the total quantity in cart.""" lines = self.lines.all() return lines.aggregate(total_quantity=models.Sum('quantity')) def clear(self): """Remove the cart.""" self.delete() def create_line(self, variant, quantity, data): """Create a cart line for given variant, quantity and optional data. The `data` parameter may be used to differentiate between items with different customization options. """ return self.lines.create(variant=variant, quantity=quantity, data=data or {}) def get_line(self, variant, data=None): """Return a line matching the given variant and data if any.""" all_lines = self.lines.all() if data is None: data = {} line = [ line for line in all_lines if line.variant_id == variant.id and line.data == data ] if line: return line[0] return None def add(self, variant, quantity=1, data=None, replace=False, check_quantity=True): """Add a product vartiant to cart. The `data` parameter may be used to differentiate between items with different customization options. If `replace` is truthy then any previous quantity is discarded instead of added to. """ cart_line, dummy_created = self.lines.get_or_create(variant=variant, defaults={ 'quantity': 0, 'data': data or {} }) if replace: new_quantity = quantity else: new_quantity = cart_line.quantity + quantity if new_quantity < 0: raise ValueError('%r is not a valid quantity (results in %r)' % (quantity, new_quantity)) if check_quantity: variant.check_quantity(new_quantity) cart_line.quantity = new_quantity if not cart_line.quantity: cart_line.delete() else: cart_line.save(update_fields=['quantity']) self.update_quantity() def partition(self): """Split the cart into a list of groups for shipping.""" grouper = (lambda p: 'physical' if p.is_shipping_required() else 'digital') subject = sorted(self.lines.all(), key=grouper) for _, lines in groupby(subject, key=grouper): yield ProductGroup(lines)
class Order(ModelWithMetadata): created = models.DateTimeField(default=now, editable=False) updated_at = models.DateTimeField(auto_now=True, editable=False, db_index=True) status = models.CharField(max_length=32, default=OrderStatus.UNFULFILLED, choices=OrderStatus.CHOICES) user = models.ForeignKey( settings.AUTH_USER_MODEL, blank=True, null=True, related_name="orders", on_delete=models.SET_NULL, ) language_code = models.CharField(max_length=35, choices=settings.LANGUAGES, default=settings.LANGUAGE_CODE) tracking_client_id = models.CharField(max_length=36, blank=True, editable=False) billing_address = models.ForeignKey( "account.Address", related_name="+", editable=False, null=True, on_delete=models.SET_NULL, ) shipping_address = models.ForeignKey( "account.Address", related_name="+", editable=False, null=True, on_delete=models.SET_NULL, ) user_email = models.EmailField(blank=True, default="") original = models.ForeignKey("self", null=True, blank=True, on_delete=models.SET_NULL) origin = models.CharField(max_length=32, choices=OrderOrigin.CHOICES) currency = models.CharField( max_length=settings.DEFAULT_CURRENCY_CODE_LENGTH, ) shipping_method = models.ForeignKey( ShippingMethod, blank=True, null=True, related_name="orders", on_delete=models.SET_NULL, ) collection_point = models.ForeignKey( "warehouse.Warehouse", blank=True, null=True, related_name="orders", on_delete=models.SET_NULL, ) shipping_method_name = models.CharField(max_length=255, null=True, default=None, blank=True, editable=False) collection_point_name = models.CharField(max_length=255, null=True, default=None, blank=True, editable=False) channel = models.ForeignKey( Channel, related_name="orders", on_delete=models.PROTECT, ) shipping_price_net_amount = models.DecimalField( max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, default=0, editable=False, ) shipping_price_net = MoneyField(amount_field="shipping_price_net_amount", currency_field="currency") shipping_price_gross_amount = models.DecimalField( max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, default=0, editable=False, ) shipping_price_gross = MoneyField( amount_field="shipping_price_gross_amount", currency_field="currency") shipping_price = TaxedMoneyField( net_amount_field="shipping_price_net_amount", gross_amount_field="shipping_price_gross_amount", currency_field="currency", ) shipping_tax_rate = models.DecimalField(max_digits=5, decimal_places=4, default=Decimal("0.0")) token = models.CharField(max_length=36, unique=True, blank=True) # Token of a checkout instance that this order was created from checkout_token = models.CharField(max_length=36, blank=True) total_net_amount = models.DecimalField( max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, default=0, ) undiscounted_total_net_amount = models.DecimalField( max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, default=0, ) total_net = MoneyField(amount_field="total_net_amount", currency_field="currency") undiscounted_total_net = MoneyField( amount_field="undiscounted_total_net_amount", currency_field="currency") total_gross_amount = models.DecimalField( max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, default=0, ) undiscounted_total_gross_amount = models.DecimalField( max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, default=0, ) total_gross = MoneyField(amount_field="total_gross_amount", currency_field="currency") undiscounted_total_gross = MoneyField( amount_field="undiscounted_total_gross_amount", currency_field="currency") total = TaxedMoneyField( net_amount_field="total_net_amount", gross_amount_field="total_gross_amount", currency_field="currency", ) undiscounted_total = TaxedMoneyField( net_amount_field="undiscounted_total_net_amount", gross_amount_field="undiscounted_total_gross_amount", currency_field="currency", ) total_paid_amount = models.DecimalField( max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, default=0, ) total_paid = MoneyField(amount_field="total_paid_amount", currency_field="currency") voucher = models.ForeignKey(Voucher, blank=True, null=True, related_name="+", on_delete=models.SET_NULL) gift_cards = models.ManyToManyField(GiftCard, blank=True, related_name="orders") display_gross_prices = models.BooleanField(default=True) customer_note = models.TextField(blank=True, default="") weight = MeasurementField( measurement=Weight, unit_choices=WeightUnits.CHOICES, # type: ignore default=zero_weight, ) redirect_url = models.URLField(blank=True, null=True) search_document = models.TextField(blank=True, default="") objects = models.Manager.from_queryset(OrderQueryset)() class Meta: ordering = ("-pk", ) permissions = ((OrderPermissions.MANAGE_ORDERS.codename, "Manage orders."), ) indexes = [ *ModelWithMetadata.Meta.indexes, GinIndex( name="order_search_gin", # `opclasses` and `fields` should be the same length fields=["search_document"], opclasses=["gin_trgm_ops"], ), GinIndex( name="order_email_search_gin", # `opclasses` and `fields` should be the same length fields=["user_email"], opclasses=["gin_trgm_ops"], ), ] def save(self, *args, **kwargs): if not self.token: self.token = str(uuid4()) return super().save(*args, **kwargs) def is_fully_paid(self): return self.total_paid >= self.total.gross def is_partly_paid(self): return self.total_paid_amount > 0 def get_customer_email(self): return self.user.email if self.user else self.user_email def update_total_paid(self): self.total_paid_amount = (sum( self.payments.values_list("captured_amount", flat=True)) or 0) self.save(update_fields=["total_paid_amount", "updated_at"]) def _index_billing_phone(self): return self.billing_address.phone def _index_shipping_phone(self): return self.shipping_address.phone def __repr__(self): return "<Order #%r>" % (self.id, ) def __str__(self): return "#%d" % (self.id, ) def get_last_payment(self): # Skipping a partial payment is a temporary workaround for storing a basic data # about partial payment from Adyen plugin. This is something that will removed # in 3.1 by introducing a partial payments feature. payments = [ payment for payment in self.payments.all() if not payment.partial ] return max(payments, default=None, key=attrgetter("pk")) def is_pre_authorized(self): return (self.payments.filter( is_active=True, transactions__kind=TransactionKind.AUTH, transactions__action_required=False, ).filter(transactions__is_success=True).exists()) def is_captured(self): return (self.payments.filter( is_active=True, transactions__kind=TransactionKind.CAPTURE, transactions__action_required=False, ).filter(transactions__is_success=True).exists()) def is_shipping_required(self): return any(line.is_shipping_required for line in self.lines.all()) def get_subtotal(self): return get_subtotal(self.lines.all(), self.currency) def get_total_quantity(self): return sum([line.quantity for line in self.lines.all()]) def is_draft(self): return self.status == OrderStatus.DRAFT def is_unconfirmed(self): return self.status == OrderStatus.UNCONFIRMED def is_open(self): statuses = {OrderStatus.UNFULFILLED, OrderStatus.PARTIALLY_FULFILLED} return self.status in statuses def can_cancel(self): statuses_allowed_to_cancel = [ FulfillmentStatus.CANCELED, FulfillmentStatus.REFUNDED, FulfillmentStatus.REPLACED, FulfillmentStatus.REFUNDED_AND_RETURNED, FulfillmentStatus.RETURNED, ] return (not self.fulfillments.exclude( status__in=statuses_allowed_to_cancel).exists() ) and self.status not in { OrderStatus.CANCELED, OrderStatus.DRAFT } def can_capture(self, payment=None): if not payment: payment = self.get_last_payment() if not payment: return False order_status_ok = self.status not in { OrderStatus.DRAFT, OrderStatus.CANCELED } return payment.can_capture() and order_status_ok def can_void(self, payment=None): if not payment: payment = self.get_last_payment() if not payment: return False return payment.can_void() def can_refund(self, payment=None): if not payment: payment = self.get_last_payment() if not payment: return False return payment.can_refund() def can_mark_as_paid(self, payments=None): if not payments: payments = self.payments.all() return len(payments) == 0 @property def total_authorized(self): return get_total_authorized(self.payments.all(), self.currency) @property def total_captured(self): return self.total_paid @property def total_balance(self): return self.total_captured - self.total.gross def get_total_weight(self, *_args): return self.weight
class Checkout(models.Model): """A shopping checkout.""" created = models.DateTimeField(auto_now_add=True) last_change = models.DateTimeField(auto_now_add=True) user = models.ForeignKey( settings.AUTH_USER_MODEL, blank=True, null=True, related_name="checkouts", on_delete=models.CASCADE, ) email = models.EmailField() token = models.UUIDField(primary_key=True, default=uuid4, editable=False) quantity = models.PositiveIntegerField(default=0) billing_address = models.ForeignKey(Address, related_name="+", editable=False, null=True, on_delete=models.SET_NULL) shipping_address = models.ForeignKey(Address, related_name="+", editable=False, null=True, on_delete=models.SET_NULL) shipping_method = models.ForeignKey( ShippingMethod, blank=True, null=True, related_name="checkouts", on_delete=models.SET_NULL, ) note = models.TextField(blank=True, default="") discount_amount = MoneyField( currency=settings.DEFAULT_CURRENCY, max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, default=zero_money, ) discount_name = models.CharField(max_length=255, blank=True, null=True) translated_discount_name = models.CharField(max_length=255, blank=True, null=True) voucher_code = models.CharField(max_length=12, blank=True, null=True) gift_cards = models.ManyToManyField(GiftCard, blank=True, related_name="checkouts") objects = CheckoutQueryset.as_manager() class Meta: ordering = ("-last_change", ) def __repr__(self): return "Checkout(quantity=%s)" % (self.quantity, ) def __iter__(self): return iter(self.lines.all()) def __len__(self): return self.lines.count() def is_shipping_required(self): """Return `True` if any of the lines requires shipping.""" return any(line.is_shipping_required() for line in self) def get_shipping_price(self): return (self.shipping_method.get_total() if self.shipping_method and self.is_shipping_required() else zero_money()) def get_subtotal(self, discounts=None): """Return the total cost of the checkout prior to shipping.""" subtotals = (line.get_total(discounts) for line in self) return sum(subtotals, zero_money(currency=settings.DEFAULT_CURRENCY)) def get_total(self, discounts=None): """Return the total cost of the checkout.""" total = (self.get_subtotal(discounts) + self.get_shipping_price() - self.discount_amount) return max(total, zero_money(total.currency)) def get_total_gift_cards_balance(self): """Return the total balance of the gift cards assigned to the checkout.""" balance = self.gift_cards.aggregate( models.Sum("current_balance"))["current_balance__sum"] return balance or zero_money(currency=settings.DEFAULT_CURRENCY) def get_total_weight(self): # Cannot use `sum` as it parses an empty Weight to an int weights = zero_weight() for line in self: weights += line.variant.get_weight() * line.quantity return weights def get_line(self, variant): """Return a line matching the given variant and data if any.""" matching_lines = (line for line in self if line.variant.pk == variant.pk) return next(matching_lines, None) def get_last_active_payment(self): payments = [ payment for payment in self.payments.all() if payment.is_active ] return max(payments, default=None, key=attrgetter("pk"))
class Checkout(ModelWithMetadata): """A shopping checkout.""" created = models.DateTimeField(auto_now_add=True) last_change = models.DateTimeField(auto_now=True) user = models.ForeignKey( settings.AUTH_USER_MODEL, blank=True, null=True, related_name="checkouts", on_delete=models.CASCADE, ) email = models.EmailField() token = models.UUIDField(primary_key=True, default=uuid4, editable=False) quantity = models.PositiveIntegerField(default=0) channel = models.ForeignKey( Channel, related_name="checkouts", on_delete=models.PROTECT, ) billing_address = models.ForeignKey(Address, related_name="+", editable=False, null=True, on_delete=models.SET_NULL) shipping_address = models.ForeignKey(Address, related_name="+", editable=False, null=True, on_delete=models.SET_NULL) shipping_method = models.ForeignKey( ShippingMethod, blank=True, null=True, related_name="checkouts", on_delete=models.SET_NULL, ) note = models.TextField(blank=True, default="") currency = models.CharField( max_length=settings.DEFAULT_CURRENCY_CODE_LENGTH, ) country = CountryField(default=get_default_country) discount_amount = models.DecimalField( max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, default=0, ) discount = MoneyField(amount_field="discount_amount", currency_field="currency") discount_name = models.CharField(max_length=255, blank=True, null=True) translated_discount_name = models.CharField(max_length=255, blank=True, null=True) voucher_code = models.CharField(max_length=12, blank=True, null=True) gift_cards = models.ManyToManyField(GiftCard, blank=True, related_name="checkouts") redirect_url = models.URLField(blank=True, null=True) tracking_code = models.CharField(max_length=255, blank=True, null=True) class Meta: ordering = ("-last_change", "pk") permissions = ((CheckoutPermissions.MANAGE_CHECKOUTS.codename, "Manage checkouts"), ) def __repr__(self): return "Checkout(quantity=%s)" % (self.quantity, ) def __iter__(self): return iter(self.lines.all()) def get_customer_email(self) -> str: return self.user.email if self.user else self.email def is_shipping_required(self) -> bool: """Return `True` if any of the lines requires shipping.""" return any(line.is_shipping_required() for line in self) def get_total_gift_cards_balance(self) -> Money: """Return the total balance of the gift cards assigned to the checkout.""" balance = self.gift_cards.aggregate( models.Sum( "current_balance_amount"))["current_balance_amount__sum"] if balance is None: return zero_money(currency=self.currency) return Money(balance, self.currency) def get_total_weight( self, lines: Optional[Iterable["CheckoutLineInfo"]] = None) -> "Weight": # Cannot use `sum` as it parses an empty Weight to an int weights = zero_weight() # TODO: we should use new data structure for lines in order like in checkout if lines is None: for line in self: weights += line.variant.get_weight() * line.quantity else: for checkout_line_info in lines: line = checkout_line_info.line weights += line.variant.get_weight() * line.quantity return weights def get_line(self, variant: "ProductVariant") -> Optional["CheckoutLine"]: """Return a line matching the given variant and data if any.""" matching_lines = (line for line in self if line.variant.pk == variant.pk) return next(matching_lines, None) def get_last_active_payment(self) -> Optional["Payment"]: payments = [ payment for payment in self.payments.all() if payment.is_active ] return max(payments, default=None, key=attrgetter("pk")) def set_country(self, country_code: str, commit: bool = False, replace: bool = True): """Set country for checkout.""" if not replace and self.country is not None: return self.country = Country(country_code) if commit: self.save(update_fields=["country"]) def get_country(self): address = self.shipping_address or self.billing_address saved_country = self.country if address is None or not address.country: return saved_country.code country_code = address.country.code if not country_code == saved_country.code: self.set_country(country_code, commit=True) return country_code
class ShippingMethod(models.Model): name = models.CharField(max_length=100) type = models.CharField(max_length=30, choices=ShippingMethodType.CHOICES) currency = models.CharField( max_length=settings.DEFAULT_CURRENCY_CODE_LENGTH, default=settings.DEFAULT_CURRENCY, ) price_amount = models.DecimalField( max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, default=0, ) price = MoneyField(amount_field="price_amount", currency_field="currency") shipping_zone = models.ForeignKey( ShippingZone, related_name="shipping_methods", on_delete=models.CASCADE ) minimum_order_price_amount = models.DecimalField( max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, default=0, blank=True, null=True, ) minimum_order_price = MoneyField( amount_field="minimum_order_price_amount", currency_field="currency" ) maximum_order_price_amount = models.DecimalField( max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, blank=True, null=True, ) maximum_order_price = MoneyField( amount_field="maximum_order_price_amount", currency_field="currency" ) minimum_order_weight = MeasurementField( measurement=Weight, unit_choices=WeightUnits.CHOICES, default=zero_weight, blank=True, null=True, ) maximum_order_weight = MeasurementField( measurement=Weight, unit_choices=WeightUnits.CHOICES, blank=True, null=True ) meta = JSONField(blank=True, default=dict, encoder=CustomJsonEncoder) objects = ShippingMethodQueryset.as_manager() translated = TranslationProxy() class Meta: ordering = ("pk",) def __str__(self): return self.name def __repr__(self): if self.type == ShippingMethodType.PRICE_BASED: minimum = "%s%s" % ( self.minimum_order_price.amount, self.minimum_order_price.currency, ) max_price = self.maximum_order_price maximum = ( "%s%s" % (max_price.amount, max_price.currency) if max_price else "no limit" ) return "ShippingMethod(type=%s min=%s, max=%s)" % ( self.type, minimum, maximum, ) return "ShippingMethod(type=%s weight_range=(%s)" % ( self.type, _get_weight_type_display( self.minimum_order_weight, self.maximum_order_weight ), ) def get_total(self): return self.price
class Order(ModelWithMetadata): created = models.DateTimeField(default=now, editable=False) status = models.CharField(max_length=32, default=OrderStatus.UNFULFILLED, choices=OrderStatus.CHOICES) user = models.ForeignKey( settings.AUTH_USER_MODEL, blank=True, null=True, related_name="orders", on_delete=models.SET_NULL, ) language_code = models.CharField(max_length=35, choices=settings.LANGUAGES, default=settings.LANGUAGE_CODE) tracking_client_id = models.CharField(max_length=36, blank=True, editable=False) billing_address = models.ForeignKey(Address, related_name="+", editable=False, null=True, on_delete=models.SET_NULL) shipping_address = models.ForeignKey(Address, related_name="+", editable=False, null=True, on_delete=models.SET_NULL) user_email = models.EmailField(blank=True, default="") currency = models.CharField( max_length=settings.DEFAULT_CURRENCY_CODE_LENGTH, ) shipping_method = models.ForeignKey( ShippingMethod, blank=True, null=True, related_name="orders", on_delete=models.SET_NULL, ) shipping_method_name = models.CharField(max_length=255, null=True, default=None, blank=True, editable=False) channel = models.ForeignKey( Channel, related_name="orders", on_delete=models.PROTECT, ) shipping_price_net_amount = models.DecimalField( max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, default=0, editable=False, ) shipping_price_net = MoneyField(amount_field="shipping_price_net_amount", currency_field="currency") shipping_price_gross_amount = models.DecimalField( max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, default=0, editable=False, ) shipping_price_gross = MoneyField( amount_field="shipping_price_gross_amount", currency_field="currency") shipping_price = TaxedMoneyField( net_amount_field="shipping_price_net_amount", gross_amount_field="shipping_price_gross_amount", currency_field="currency", ) shipping_tax_rate = models.DecimalField(max_digits=5, decimal_places=4, default=Decimal("0.0")) token = models.CharField(max_length=36, unique=True, blank=True) # Token of a checkout instance that this order was created from checkout_token = models.CharField(max_length=36, blank=True) total_net_amount = models.DecimalField( max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, default=0, ) undiscounted_total_net_amount = models.DecimalField( max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, default=0, ) total_net = MoneyField(amount_field="total_net_amount", currency_field="currency") undiscounted_total_net = MoneyField( amount_field="undiscounted_total_net_amount", currency_field="currency") total_gross_amount = models.DecimalField( max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, default=0, ) undiscounted_total_gross_amount = models.DecimalField( max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, default=0, ) total_gross = MoneyField(amount_field="total_gross_amount", currency_field="currency") undiscounted_total_gross = MoneyField( amount_field="undiscounted_total_gross_amount", currency_field="currency") total = TaxedMoneyField( net_amount_field="total_net_amount", gross_amount_field="total_gross_amount", currency_field="currency", ) undiscounted_total = TaxedMoneyField( net_amount_field="undiscounted_total_net_amount", gross_amount_field="undiscounted_total_gross_amount", currency_field="currency", ) voucher = models.ForeignKey(Voucher, blank=True, null=True, related_name="+", on_delete=models.SET_NULL) gift_cards = models.ManyToManyField(GiftCard, blank=True, related_name="orders") display_gross_prices = models.BooleanField(default=True) customer_note = models.TextField(blank=True, default="") weight = MeasurementField(measurement=Weight, unit_choices=WeightUnits.CHOICES, default=zero_weight) redirect_url = models.URLField(blank=True, null=True) objects = OrderQueryset.as_manager() class Meta(ModelWithMetadata.Meta): ordering = ("-pk", ) permissions = ((OrderPermissions.MANAGE_ORDERS.codename, "Manage orders."), ) def save(self, *args, **kwargs): if not self.token: self.token = str(uuid4()) return super().save(*args, **kwargs) def is_fully_paid(self): total_paid = self._total_paid() return total_paid.gross >= self.total.gross def is_partly_paid(self): total_paid = self._total_paid() return total_paid.gross.amount > 0 def get_customer_email(self): return self.user.email if self.user else self.user_email def _total_paid(self): # Get total paid amount from partially charged, # fully charged and partially refunded payments payments = self.payments.filter(charge_status__in=[ ChargeStatus.PARTIALLY_CHARGED, ChargeStatus.FULLY_CHARGED, ChargeStatus.PARTIALLY_REFUNDED, ]) total_captured = [ payment.get_captured_amount() for payment in payments ] total_paid = sum(total_captured, zero_taxed_money(currency=self.currency)) return total_paid def _index_billing_phone(self): return self.billing_address.phone def _index_shipping_phone(self): return self.shipping_address.phone def __repr__(self): return "<Order #%r>" % (self.id, ) def __str__(self): return "#%d" % (self.id, ) def get_last_payment(self): return max(self.payments.all(), default=None, key=attrgetter("pk")) def is_pre_authorized(self): return (self.payments.filter( is_active=True, transactions__kind=TransactionKind.AUTH, transactions__action_required=False, ).filter(transactions__is_success=True).exists()) def is_captured(self): return (self.payments.filter( is_active=True, transactions__kind=TransactionKind.CAPTURE, transactions__action_required=False, ).filter(transactions__is_success=True).exists()) def is_shipping_required(self): return any(line.is_shipping_required for line in self.lines.all()) def get_subtotal(self): return get_subtotal(self.lines.all(), self.currency) def get_total_quantity(self): return sum([line.quantity for line in self.lines.all()]) def is_draft(self): return self.status == OrderStatus.DRAFT def is_unconfirmed(self): return self.status == OrderStatus.UNCONFIRMED def is_open(self): statuses = {OrderStatus.UNFULFILLED, OrderStatus.PARTIALLY_FULFILLED} return self.status in statuses def can_cancel(self): statuses_allowed_to_cancel = [ FulfillmentStatus.CANCELED, FulfillmentStatus.REFUNDED, FulfillmentStatus.REPLACED, FulfillmentStatus.REFUNDED_AND_RETURNED, FulfillmentStatus.RETURNED, ] return (not self.fulfillments.exclude( status__in=statuses_allowed_to_cancel).exists() ) and self.status not in { OrderStatus.CANCELED, OrderStatus.DRAFT } def can_capture(self, payment=None): if not payment: payment = self.get_last_payment() if not payment: return False order_status_ok = self.status not in { OrderStatus.DRAFT, OrderStatus.CANCELED } return payment.can_capture() and order_status_ok def can_void(self, payment=None): if not payment: payment = self.get_last_payment() if not payment: return False return payment.can_void() def can_refund(self, payment=None): if not payment: payment = self.get_last_payment() if not payment: return False return payment.can_refund() def can_mark_as_paid(self, payments=None): if not payments: payments = self.payments.all() return len(payments) == 0 @property def total_authorized(self): return get_total_authorized(self.payments.all(), self.currency) @property def total_captured(self): return get_total_captured(self.payments.all(), self.currency) @property def total_balance(self): return self.total_captured - self.total.gross def get_total_weight(self, *_args): return self.weight
class Voucher(models.Model): type = models.CharField(max_length=20, choices=VoucherType.CHOICES, default=VoucherType.ENTIRE_ORDER) name = models.CharField(max_length=255, null=True, blank=True) code = models.CharField(max_length=12, unique=True, db_index=True) usage_limit = models.PositiveIntegerField(null=True, blank=True) used = models.PositiveIntegerField(default=0, editable=False) start_date = models.DateTimeField(default=timezone.now) end_date = models.DateTimeField(null=True, blank=True) # this field indicates if discount should be applied per order or # individually to every item apply_once_per_order = models.BooleanField(default=False) apply_once_per_customer = models.BooleanField(default=False) discount_value_type = models.CharField( max_length=10, choices=DiscountValueType.CHOICES, default=DiscountValueType.FIXED, ) discount_value = models.DecimalField( max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, ) # not mandatory fields, usage depends on type countries = CountryField(multiple=True, blank=True) min_amount_spent = MoneyField( currency=settings.DEFAULT_CURRENCY, max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, null=True, blank=True, ) min_checkout_items_quantity = models.PositiveIntegerField(null=True, blank=True) products = models.ManyToManyField("product.Product", blank=True) collections = models.ManyToManyField("product.Collection", blank=True) categories = models.ManyToManyField("product.Category", blank=True) objects = VoucherQueryset.as_manager() translated = TranslationProxy() def __str__(self): if self.name: return self.name discount = "%s %s" % ( self.discount_value, self.get_discount_value_type_display(), ) if self.type == VoucherType.SHIPPING: if self.is_free: return pgettext("Voucher type", "Free shipping") return pgettext("Voucher type", "%(discount)s off shipping") % { "discount": discount } if self.type == VoucherType.PRODUCT: products = len(self.products.all()) if products: return pgettext( "Voucher type", "%(discount)s off %(product_num)d products") % { "discount": discount, "product_num": products } if self.type == VoucherType.COLLECTION: collections = len(self.collections.all()) if collections: return pgettext( "Voucher type", "%(discount)s off %(collections_num)d collections") % { "discount": discount, "collections_num": collections } if self.type == VoucherType.CATEGORY: categories = len(self.categories.all()) if categories: return pgettext( "Voucher type", "%(discount)s off %(categories_num)d categories") % { "discount": discount, "categories_num": categories } return pgettext("Voucher type", "%(discount)s off") % { "discount": discount } @property def is_free(self): return (self.discount_value == Decimal(100) and self.discount_value_type == DiscountValueType.PERCENTAGE) def get_discount(self): if self.discount_value_type == DiscountValueType.FIXED: discount_amount = Money(self.discount_value, settings.DEFAULT_CURRENCY) return partial(fixed_discount, discount=discount_amount) if self.discount_value_type == DiscountValueType.PERCENTAGE: return partial(percentage_discount, percentage=self.discount_value) raise NotImplementedError("Unknown discount type") def get_discount_amount_for(self, price): discount = self.get_discount() after_discount = discount(price) if after_discount.amount < 0: return price return price - after_discount def validate_min_amount_spent(self, value): min_amount_spent = self.min_amount_spent if min_amount_spent and value < min_amount_spent: msg = pgettext( "Voucher not applicable", "This offer is only valid for orders over %(amount)s.", ) raise NotApplicable( msg % {"amount": amount(min_amount_spent)}, min_amount_spent=min_amount_spent, ) def validate_min_checkout_items_quantity(self, quantity): min_checkout_items_quantity = self.min_checkout_items_quantity if min_checkout_items_quantity and min_checkout_items_quantity > quantity: msg = pgettext( "Voucher not applicable", ("This offer is only valid for orders with a minimum of " "%(min_checkout_items_quantity)d quantity."), ) raise NotApplicable( msg % {"min_checkout_items_quantity": min_checkout_items_quantity}, min_checkout_items_quantity=min_checkout_items_quantity, ) def validate_once_per_customer(self, customer_email): voucher_customer = VoucherCustomer.objects.filter( voucher=self, customer_email=customer_email) if voucher_customer: msg = pgettext("Voucher not applicable", "This offer is valid only once per customer.") raise NotApplicable(msg)
class ProductVariant(ModelWithMetadata): sku = models.CharField(max_length=255, unique=True) name = models.CharField(max_length=255, blank=True) currency = models.CharField( max_length=settings.DEFAULT_CURRENCY_CODE_LENGTH, default=settings.DEFAULT_CURRENCY, blank=True, null=True, ) price_amount = models.DecimalField( max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, ) price = MoneyField(amount_field="price_amount", currency_field="currency") product = models.ForeignKey(Product, related_name="variants", on_delete=models.CASCADE) images = models.ManyToManyField("ProductImage", through="VariantImage") track_inventory = models.BooleanField(default=True) cost_price_amount = models.DecimalField( max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, blank=True, null=True, ) cost_price = MoneyField(amount_field="cost_price_amount", currency_field="currency") weight = MeasurementField(measurement=Weight, unit_choices=WeightUnits.CHOICES, blank=True, null=True) objects = ProductVariantQueryset.as_manager() translated = TranslationProxy() class Meta: ordering = ("sku", ) app_label = "product" def __str__(self) -> str: return self.name or self.sku @property def is_visible(self) -> bool: return self.product.is_visible def get_price( self, discounts: Optional[Iterable[DiscountInfo]] = None) -> "Money": return calculate_discounted_price( product=self.product, price=self.price, collections=self.product.collections.all(), discounts=discounts, ) def get_weight(self): return self.weight or self.product.weight or self.product.product_type.weight def is_shipping_required(self) -> bool: return self.product.product_type.is_shipping_required def is_digital(self) -> bool: is_digital = self.product.product_type.is_digital return not self.is_shipping_required() and is_digital def display_product(self, translated: bool = False) -> str: if translated: product = self.product.translated variant_display = str(self.translated) else: variant_display = str(self) product = self.product product_display = (f"{product} ({variant_display})" if variant_display else str(product)) return smart_text(product_display) def get_first_image(self) -> "ProductImage": images = list(self.images.all()) return images[0] if images else self.product.get_first_image()
class ShippingMethod(models.Model): name = models.CharField(max_length=100) type = models.CharField(max_length=30, choices=ShippingMethodType.CHOICES) price = MoneyField( currency=settings.DEFAULT_CURRENCY, max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, default=0, ) shipping_zone = models.ForeignKey(ShippingZone, related_name="shipping_methods", on_delete=models.CASCADE) minimum_order_price = MoneyField( currency=settings.DEFAULT_CURRENCY, max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, default=0, blank=True, null=True, ) maximum_order_price = MoneyField( currency=settings.DEFAULT_CURRENCY, max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, blank=True, null=True, ) minimum_order_weight = MeasurementField( measurement=Weight, unit_choices=WeightUnits.CHOICES, default=zero_weight, blank=True, null=True, ) maximum_order_weight = MeasurementField(measurement=Weight, unit_choices=WeightUnits.CHOICES, blank=True, null=True) # <ADD percentage = models.DecimalField( null=True, blank=True, default=None, max_digits=5, decimal_places=2, validators=[MinValueValidator(0), MaxValueValidator(100)]) # ADD> objects = ShippingMethodQueryset.as_manager() translated = TranslationProxy() class Meta: ordering = ("pk", ) def __str__(self): return self.name def __repr__(self): if self.type == ShippingMethodType.PRICE_BASED: minimum = "%s%s" % ( self.minimum_order_price.amount, self.minimum_order_price.currency, ) max_price = self.maximum_order_price maximum = ("%s%s" % (max_price.amount, max_price.currency) if max_price else "no limit") return "ShippingMethod(type=%s min=%s, max=%s)" % ( self.type, minimum, maximum, ) # <ADD elif self.type == ShippingMethodType.PERCENTAGE_BASED: minimum = '%s%s' % (self.minimum_order_price.amount, self.minimum_order_price.currency) max_price = self.maximum_order_price maximum = ('%s%s' % (max_price.amount, max_price.currency) if max_price else 'no limit') return 'ShippingMethod(type=%s min=%s, max=%s)' % ( self.type, minimum, maximum) # ADD> return "ShippingMethod(type=%s weight_range=(%s)" % ( self.type, get_weight_type_display(self.minimum_order_weight, self.maximum_order_weight), ) def get_total(self, taxes=None): return get_taxed_shipping_price(self.price, taxes) def get_ajax_label(self): price_html = format_money(self.price) label = mark_safe("%s %s" % (self, price_html)) return label def get_type_display(self): # <ADD or self.type == ShippingMethodType.PERCENTAGE_BASED in if if self.type == ShippingMethodType.PRICE_BASED or self.type == ShippingMethodType.PERCENTAGE_BASED: return get_price_type_display(self.minimum_order_price, self.maximum_order_price) return get_weight_type_display(self.minimum_order_weight, self.maximum_order_weight)
class ProductVariant(ModelWithMetadata): sku = models.CharField(max_length=32, unique=True) name = models.CharField(max_length=255, blank=True) price_override = MoneyField( currency=settings.DEFAULT_CURRENCY, max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, blank=True, null=True, ) product = models.ForeignKey(Product, related_name="variants", on_delete=models.CASCADE) attributes = FilterableJSONBField(default=dict, blank=True, validators=[validate_attribute_json]) images = models.ManyToManyField("ProductImage", through="VariantImage") track_inventory = models.BooleanField(default=True) quantity = models.IntegerField(validators=[MinValueValidator(0)], default=Decimal(1)) quantity_allocated = models.IntegerField(validators=[MinValueValidator(0)], default=Decimal(0)) cost_price = MoneyField( currency=settings.DEFAULT_CURRENCY, max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, blank=True, null=True, ) weight = MeasurementField(measurement=Weight, unit_choices=WeightUnits.CHOICES, blank=True, null=True) translated = TranslationProxy() class Meta: app_label = "product" def __str__(self): return self.name or self.sku @property def quantity_available(self): return max(self.quantity - self.quantity_allocated, 0) @property def is_visible(self): return self.product.is_visible @property def is_available(self): return self.product.is_available def check_quantity(self, quantity): """Check if there is at least the given quantity in stock. If stock handling is disabled, it simply run no check. """ if self.track_inventory and quantity > self.quantity_available: raise InsufficientStock(self) @property def base_price(self): return (self.price_override if self.price_override is not None else self.product.price) def get_price(self, discounts: Iterable[DiscountInfo] = None): return calculate_discounted_price(self.product, self.base_price, discounts) def get_weight(self): return self.weight or self.product.weight or self.product.product_type.weight def get_absolute_url(self): slug = self.product.get_slug() product_id = self.product.id return reverse("product:details", kwargs={ "slug": slug, "product_id": product_id }) def is_shipping_required(self): return self.product.product_type.is_shipping_required def is_digital(self): is_digital = self.product.product_type.is_digital return not self.is_shipping_required() and is_digital def is_in_stock(self): return self.quantity_available > 0 def display_product(self, translated=False): if translated: product = self.product.translated variant_display = str(self.translated) else: variant_display = str(self) product = self.product product_display = ("%s (%s)" % (product, variant_display) if variant_display else str(product)) return smart_text(product_display) def get_first_image(self): images = list(self.images.all()) return images[0] if images else self.product.get_first_image() def get_ajax_label(self, discounts=None): price = self.get_price(discounts) return "%s, %s, %s" % ( self.sku, self.display_product(), prices_i18n.amount(price), )
class Order(models.Model): created = models.DateTimeField(default=now, editable=False) status = models.CharField(max_length=32, default=OrderStatus.UNFULFILLED, choices=OrderStatus.CHOICES) user = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True, related_name='orders', on_delete=models.SET_NULL) language_code = models.CharField(max_length=35, default=settings.LANGUAGE_CODE) tracking_client_id = models.CharField(max_length=36, blank=True, editable=False) billing_address = models.ForeignKey(Address, related_name='+', editable=False, on_delete=models.PROTECT) shipping_address = models.ForeignKey(Address, related_name='+', editable=False, null=True, on_delete=models.PROTECT) user_email = models.EmailField(blank=True, default='', editable=False) shipping_price_net = MoneyField(currency=settings.DEFAULT_CURRENCY, max_digits=12, decimal_places=2, default=0, editable=False) shipping_price_gross = MoneyField(currency=settings.DEFAULT_CURRENCY, max_digits=12, decimal_places=2, default=0, editable=False) shipping_price = TaxedMoneyField(net_field='shipping_price_net', gross_field='shipping_price_gross') shipping_method_name = models.CharField(max_length=255, null=True, default=None, blank=True, editable=False) token = models.CharField(max_length=36, unique=True) total_net = MoneyField(currency=settings.DEFAULT_CURRENCY, max_digits=12, decimal_places=2, default=0) total_gross = MoneyField(currency=settings.DEFAULT_CURRENCY, max_digits=12, decimal_places=2, default=0) total = TaxedMoneyField(net_field='total_net', gross_field='total_gross') voucher = models.ForeignKey(Voucher, null=True, related_name='+', on_delete=models.SET_NULL) discount_amount = MoneyField(currency=settings.DEFAULT_CURRENCY, max_digits=12, decimal_places=2, blank=True, null=True) discount_name = models.CharField(max_length=255, default='', blank=True) class Meta: ordering = ('-pk', ) permissions = (('view_order', pgettext_lazy('Permission description', 'Can view orders')), ('edit_order', pgettext_lazy('Permission description', 'Can edit orders'))) def save(self, *args, **kwargs): if not self.token: self.token = str(uuid4()) return super().save(*args, **kwargs) def is_fully_paid(self): total_paid = sum([ payment.get_total_price() for payment in self.payments.filter(status=PaymentStatus.CONFIRMED) ], TaxedMoney(net=Money(0, settings.DEFAULT_CURRENCY), gross=Money(0, settings.DEFAULT_CURRENCY))) return total_paid.gross >= self.total.gross def get_user_current_email(self): return self.user.email if self.user else self.user_email def _index_billing_phone(self): return self.billing_address.phone def _index_shipping_phone(self): return self.shipping_address.phone def __iter__(self): return iter(self.lines.all()) def __repr__(self): return '<Order #%r>' % (self.id, ) def __str__(self): return '#%d' % (self.id, ) def get_absolute_url(self): return reverse('order:details', kwargs={'token': self.token}) def get_last_payment_status(self): last_payment = self.payments.last() if last_payment: return last_payment.status return None def get_last_payment_status_display(self): last_payment = self.payments.last() if last_payment: return last_payment.get_status_display() return None def is_pre_authorized(self): return self.payments.filter(status=PaymentStatus.PREAUTH).exists() @property def quantity_fulfilled(self): return sum([line.quantity_fulfilled for line in self]) def is_shipping_required(self): return any(line.is_shipping_required for line in self) def get_status_display(self): """Order status display text.""" return dict(OrderStatus.CHOICES)[self.status] def get_subtotal(self): subtotal_iterator = (line.get_total() for line in self) return sum(subtotal_iterator, ZERO_TAXED_MONEY) def get_total_quantity(self): return sum([line.quantity for line in self]) def can_edit(self): return self.status == OrderStatus.UNFULFILLED def can_fulfill(self): statuses = {OrderStatus.UNFULFILLED, OrderStatus.PARTIALLY_FULFILLED} return self.status in statuses def can_cancel(self): return self.status != OrderStatus.CANCELED
class Order(ModelWithMetadata): created = models.DateTimeField(default=now, editable=False) status = models.CharField(max_length=32, default=OrderStatus.UNFULFILLED, choices=OrderStatus.CHOICES) user = models.ForeignKey( settings.AUTH_USER_MODEL, blank=True, null=True, related_name="orders", on_delete=models.SET_NULL, ) language_code = models.CharField(max_length=35, default=settings.LANGUAGE_CODE) tracking_client_id = models.CharField(max_length=36, blank=True, editable=False) billing_address = models.ForeignKey(Address, related_name="+", editable=False, null=True, on_delete=models.SET_NULL) shipping_address = models.ForeignKey(Address, related_name="+", editable=False, null=True, on_delete=models.SET_NULL) user_email = models.EmailField(blank=True, default="") currency = models.CharField( max_length=settings.DEFAULT_CURRENCY_CODE_LENGTH, default=settings.DEFAULT_CURRENCY, ) shipping_method = models.ForeignKey( ShippingMethod, blank=True, null=True, related_name="orders", on_delete=models.SET_NULL, ) shipping_method_name = models.CharField(max_length=255, null=True, default=None, blank=True, editable=False) shipping_price_net_amount = models.DecimalField( max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, default=0, editable=False, ) shipping_price_net = MoneyField(amount_field="shipping_price_net_amount", currency_field="currency") shipping_price_gross_amount = models.DecimalField( max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, default=0, editable=False, ) shipping_price_gross = MoneyField( amount_field="shipping_price_gross_amount", currency_field="currency") shipping_price = TaxedMoneyField( net_amount_field="shipping_price_net_amount", gross_amount_field="shipping_price_gross_amount", currency_field="currency", ) token = models.CharField(max_length=36, unique=True, blank=True) # Token of a checkout instance that this order was created from checkout_token = models.CharField(max_length=36, blank=True) total_net_amount = models.DecimalField( max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, default=0, ) total_net = MoneyField(amount_field="total_net_amount", currency_field="currency") total_gross_amount = models.DecimalField( max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, default=0, ) total_gross = MoneyField(amount_field="total_gross_amount", currency_field="currency") total = TaxedMoneyField( net_amount_field="total_net_amount", gross_amount_field="total_gross_amount", currency_field="currency", ) voucher = models.ForeignKey(Voucher, blank=True, null=True, related_name="+", on_delete=models.SET_NULL) gift_cards = models.ManyToManyField(GiftCard, blank=True, related_name="orders") discount_amount = models.DecimalField( max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, default=0, ) discount = MoneyField(amount_field="discount_amount", currency_field="currency") discount_name = models.CharField(max_length=255, default="", blank=True) translated_discount_name = models.CharField(max_length=255, default="", blank=True) display_gross_prices = models.BooleanField(default=True) customer_note = models.TextField(blank=True, default="") weight = MeasurementField(measurement=Weight, unit_choices=WeightUnits.CHOICES, default=zero_weight) objects = OrderQueryset.as_manager() class Meta: ordering = ("-pk", ) permissions = (( "manage_orders", pgettext_lazy("Permission description", "Manage orders."), ), ) def save(self, *args, **kwargs): if not self.token: self.token = str(uuid4()) return super().save(*args, **kwargs) def is_fully_paid(self): total_paid = self._total_paid() return total_paid.gross >= self.total.gross def is_partly_paid(self): total_paid = self._total_paid() return total_paid.gross.amount > 0 def get_customer_email(self): return self.user.email if self.user else self.user_email def _total_paid(self): # Get total paid amount from partially charged, # fully charged and partially refunded payments payments = self.payments.filter(charge_status__in=[ ChargeStatus.PARTIALLY_CHARGED, ChargeStatus.FULLY_CHARGED, ChargeStatus.PARTIALLY_REFUNDED, ]) total_captured = [ payment.get_captured_amount() for payment in payments ] total_paid = sum(total_captured, zero_taxed_money()) return total_paid def _index_billing_phone(self): return self.billing_address.phone def _index_shipping_phone(self): return self.shipping_address.phone def __iter__(self): return iter(self.lines.all()) def __repr__(self): return "<Order #%r>" % (self.id, ) def __str__(self): return "#%d" % (self.id, ) def get_absolute_url(self): return reverse("order:details", kwargs={"token": self.token}) def get_last_payment(self): return max(self.payments.all(), default=None, key=attrgetter("pk")) def get_payment_status(self): last_payment = self.get_last_payment() if last_payment: return last_payment.charge_status return ChargeStatus.NOT_CHARGED def get_payment_status_display(self): last_payment = self.get_last_payment() if last_payment: return last_payment.get_charge_status_display() return dict(ChargeStatus.CHOICES).get(ChargeStatus.NOT_CHARGED) def is_pre_authorized(self): return (self.payments.filter( is_active=True, transactions__kind=TransactionKind.AUTH).filter( transactions__is_success=True).exists()) @property def quantity_fulfilled(self): return sum([line.quantity_fulfilled for line in self]) def is_shipping_required(self): return any(line.is_shipping_required for line in self) def get_subtotal(self): subtotal_iterator = (line.get_total() for line in self) return sum(subtotal_iterator, zero_taxed_money()) def get_total_quantity(self): return sum([line.quantity for line in self]) def is_draft(self): return self.status == OrderStatus.DRAFT def is_open(self): statuses = {OrderStatus.UNFULFILLED, OrderStatus.PARTIALLY_FULFILLED} return self.status in statuses def can_cancel(self): return self.status not in {OrderStatus.CANCELED, OrderStatus.DRAFT} def can_capture(self, payment=None): if not payment: payment = self.get_last_payment() if not payment: return False order_status_ok = self.status not in { OrderStatus.DRAFT, OrderStatus.CANCELED } return payment.can_capture() and order_status_ok def can_charge(self, payment=None): if not payment: payment = self.get_last_payment() if not payment: return False order_status_ok = self.status not in { OrderStatus.DRAFT, OrderStatus.CANCELED } return payment.can_charge() and order_status_ok def can_void(self, payment=None): if not payment: payment = self.get_last_payment() if not payment: return False return payment.can_void() def can_refund(self, payment=None): if not payment: payment = self.get_last_payment() if not payment: return False return payment.can_refund() def can_mark_as_paid(self): return len(self.payments.all()) == 0 @property def total_authorized(self): payment = self.get_last_payment() if payment: return payment.get_authorized_amount() return zero_money() @property def total_captured(self): payment = self.get_last_payment() if payment and payment.charge_status in ( ChargeStatus.PARTIALLY_CHARGED, ChargeStatus.FULLY_CHARGED, ChargeStatus.PARTIALLY_REFUNDED, ): return Money(payment.captured_amount, payment.currency) return zero_money() @property def total_balance(self): return self.total_captured - self.total.gross def get_total_weight(self): return self.weight
class ProductVariant(models.Model): sku = models.CharField(max_length=32, unique=True) name = models.CharField(max_length=255, blank=True) price_override = MoneyField( currency=settings.DEFAULT_CURRENCY, max_digits=12, decimal_places=settings.DEFAULT_DECIMAL_PLACES, blank=True, null=True) product = models.ForeignKey( Product, related_name='variants', on_delete=models.CASCADE) attributes = HStoreField(default={}, blank=True) images = models.ManyToManyField('ProductImage', through='VariantImage') quantity = models.IntegerField( validators=[MinValueValidator(0)], default=Decimal(1)) quantity_allocated = models.IntegerField( validators=[MinValueValidator(0)], default=Decimal(0)) cost_price = MoneyField( currency=settings.DEFAULT_CURRENCY, max_digits=12, decimal_places=settings.DEFAULT_DECIMAL_PLACES, blank=True, null=True) class Meta: app_label = 'product' def __str__(self): return self.name @property def quantity_available(self): return max(self.quantity - self.quantity_allocated, 0) def get_total(self): if self.cost_price: return TaxedMoney(net=self.cost_price, gross=self.cost_price) def check_quantity(self, quantity): if quantity > self.quantity_available: raise InsufficientStock(self) def get_price_per_item(self, discounts=None): price = self.price_override or self.product.price price = TaxedMoney(net=price, gross=price) price = calculate_discounted_price(self.product, price, discounts) return price def get_absolute_url(self): slug = self.product.get_slug() product_id = self.product.id return reverse('product:details', kwargs={'slug': slug, 'product_id': product_id}) def as_data(self): return { 'product_name': str(self), 'product_id': self.product.pk, 'variant_id': self.pk, 'unit_price': str(self.get_price_per_item().gross)} def is_shipping_required(self): return self.product.product_type.is_shipping_required def is_in_stock(self): return self.quantity_available > 0 def display_product(self): variant_display = str(self) product_display = ( '%s (%s)' % (self.product, variant_display) if variant_display else str(self.product)) return smart_text(product_display) def get_first_image(self): return self.product.get_first_image()
class ProductVariant(models.Model): sku = models.CharField(max_length=32, unique=True) name = models.CharField(max_length=255, blank=True) price_override = MoneyField(currency=settings.DEFAULT_CURRENCY, max_digits=12, decimal_places=settings.DEFAULT_DECIMAL_PLACES, blank=True, null=True) product = models.ForeignKey(Product, related_name='variants', on_delete=models.CASCADE) attributes = HStoreField(default={}, blank=True) images = models.ManyToManyField('ProductImage', through='VariantImage') track_inventory = models.BooleanField(default=True) quantity = models.IntegerField(validators=[MinValueValidator(0)], default=Decimal(1)) quantity_allocated = models.IntegerField(validators=[MinValueValidator(0)], default=Decimal(0)) cost_price = MoneyField(currency=settings.DEFAULT_CURRENCY, max_digits=12, decimal_places=settings.DEFAULT_DECIMAL_PLACES, blank=True, null=True) class Meta: app_label = 'product' def __str__(self): return self.name or self.sku @property def quantity_available(self): return max(self.quantity - self.quantity_allocated, 0) def check_quantity(self, quantity): """ Check if there is at least the given quantity in stock if stock handling is enabled. """ if self.track_inventory and quantity > self.quantity_available: raise InsufficientStock(self) @property def base_price(self): return self.price_override or self.product.price def get_price(self, discounts=None, taxes=None): price = calculate_discounted_price(self.product, self.base_price, discounts) if not self.product.charge_taxes: taxes = None tax_rate = (self.product.tax_rate or self.product.product_type.tax_rate) return apply_tax_to_price(taxes, tax_rate, price) def get_absolute_url(self): slug = self.product.get_slug() product_id = self.product.id return reverse('product:details', kwargs={ 'slug': slug, 'product_id': product_id }) def is_shipping_required(self): return self.product.product_type.is_shipping_required def is_in_stock(self): return self.quantity_available > 0 def display_product(self): variant_display = str(self) product_display = ('%s (%s)' % (self.product, variant_display) if variant_display else str(self.product)) return smart_text(product_display) def get_first_image(self): return self.product.get_first_image() def get_ajax_label(self, discounts=None): price = self.get_price(discounts).gross return '%s, %s, %s' % (self.sku, self.display_product(), prices_i18n.amount(price))
class Order(models.Model): created = models.DateTimeField(default=now, editable=False) status = models.CharField(max_length=32, default=OrderStatus.UNFULFILLED, choices=OrderStatus.CHOICES) user = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True, related_name='orders', on_delete=models.SET_NULL) language_code = models.CharField(max_length=35, default=settings.LANGUAGE_CODE) tracking_client_id = models.CharField(max_length=36, blank=True, editable=False) billing_address = models.ForeignKey(Address, related_name='+', editable=False, null=True, on_delete=models.SET_NULL) shipping_address = models.ForeignKey(Address, related_name='+', editable=False, null=True, on_delete=models.SET_NULL) user_email = models.EmailField(blank=True, default='') shipping_method = models.ForeignKey(ShippingMethod, blank=True, null=True, related_name='orders', on_delete=models.SET_NULL) shipping_price_net = MoneyField( currency=settings.DEFAULT_CURRENCY, max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, default=0, editable=False) shipping_price_gross = MoneyField( currency=settings.DEFAULT_CURRENCY, max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, default=0, editable=False) shipping_price = TaxedMoneyField(net_field='shipping_price_net', gross_field='shipping_price_gross') shipping_method_name = models.CharField(max_length=255, null=True, default=None, blank=True, editable=False) token = models.CharField(max_length=36, unique=True, blank=True) total_net = MoneyField(currency=settings.DEFAULT_CURRENCY, max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, default=zero_money) total_gross = MoneyField(currency=settings.DEFAULT_CURRENCY, max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, default=zero_money) total = TaxedMoneyField(net_field='total_net', gross_field='total_gross') voucher = models.ForeignKey(Voucher, blank=True, null=True, related_name='+', on_delete=models.SET_NULL) discount_amount = MoneyField( currency=settings.DEFAULT_CURRENCY, max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, default=zero_money) discount_name = models.CharField(max_length=255, default='', blank=True) translated_discount_name = models.CharField(max_length=255, default='', blank=True) display_gross_prices = models.BooleanField(default=True) customer_note = models.TextField(blank=True, default='') weight = MeasurementField(measurement=Weight, unit_choices=WeightUnits.CHOICES, default=zero_weight) objects = OrderQueryset.as_manager() class Meta: ordering = ('-pk', ) permissions = (('manage_orders', pgettext_lazy('Permission description', 'Manage orders.')), ) def save(self, *args, **kwargs): if not self.token: self.token = str(uuid4()) return super().save(*args, **kwargs) def is_fully_paid(self): payments = self.payments.filter(charge_status=ChargeStatus.CHARGED) total_captured = [ payment.get_captured_amount() for payment in payments ] total_paid = sum(total_captured, ZERO_TAXED_MONEY) return total_paid.gross >= self.total.gross def get_user_current_email(self): return self.user.email if self.user else self.user_email def _index_billing_phone(self): return self.billing_address.phone def _index_shipping_phone(self): return self.shipping_address.phone def __iter__(self): return iter(self.lines.all()) def __repr__(self): return '<Order #%r>' % (self.id, ) def __str__(self): return '#%d' % (self.id, ) def get_absolute_url(self): return reverse('order:details', kwargs={'token': self.token}) def get_last_payment(self): return max(self.payments.all(), default=None, key=attrgetter('pk')) def get_last_payment_status(self): last_payment = self.get_last_payment() if last_payment: return last_payment.charge_status return None def get_last_payment_status_display(self): last_payment = self.get_last_payment() if last_payment: return last_payment.get_charge_status_display() return None def is_pre_authorized(self): return self.payments.filter( is_active=True, transactions__kind=TransactionKind.AUTH).filter( transactions__is_success=True).exists() @property def quantity_fulfilled(self): return sum([line.quantity_fulfilled for line in self]) def is_shipping_required(self): return any(line.is_shipping_required for line in self) def get_subtotal(self): subtotal_iterator = (line.get_total() for line in self) return sum(subtotal_iterator, ZERO_TAXED_MONEY) def get_total_quantity(self): return sum([line.quantity for line in self]) def is_draft(self): return self.status == OrderStatus.DRAFT def is_open(self): statuses = {OrderStatus.UNFULFILLED, OrderStatus.PARTIALLY_FULFILLED} return self.status in statuses def can_cancel(self): return self.status not in {OrderStatus.CANCELED, OrderStatus.DRAFT} def can_capture(self, payment=None): if not payment: payment = self.get_last_payment() if not payment: return False order_status_ok = self.status not in { OrderStatus.DRAFT, OrderStatus.CANCELED } return payment.can_capture() and order_status_ok def can_charge(self, payment=None): if not payment: payment = self.get_last_payment() if not payment: return False order_status_ok = self.status not in { OrderStatus.DRAFT, OrderStatus.CANCELED } return payment.can_charge() and order_status_ok def can_void(self, payment=None): if not payment: payment = self.get_last_payment() if not payment: return False return payment.can_void() def can_refund(self, payment=None): if not payment: payment = self.get_last_payment() if not payment: return False return payment.can_refund() def can_mark_as_paid(self): return len(self.payments.all()) == 0 @property def total_authorized(self): payment = self.get_last_payment() if payment: return payment.get_authorized_amount() return zero_money() @property def total_captured(self): payment = self.get_last_payment() if payment and payment.charge_status == ChargeStatus.CHARGED: return Money(payment.captured_amount, payment.currency) return zero_money() @property def total_balance(self): return self.total_captured - self.total.gross def get_total_weight(self): # Cannot use `sum` as it parses an empty Weight to an int weights = Weight(kg=0) for line in self: weights += line.variant.get_weight() * line.quantity return weights
class OrderLine(models.Model): order = models.ForeignKey(Order, related_name="lines", editable=False, on_delete=models.CASCADE) variant = models.ForeignKey( "product.ProductVariant", related_name="order_lines", on_delete=models.SET_NULL, blank=True, null=True, ) # max_length is as produced by ProductVariant's display_product method product_name = models.CharField(max_length=386) variant_name = models.CharField(max_length=255, default="", blank=True) translated_product_name = models.CharField(max_length=386, default="", blank=True) translated_variant_name = models.CharField(max_length=255, default="", blank=True) product_sku = models.CharField(max_length=255) is_shipping_required = models.BooleanField() quantity = models.IntegerField(validators=[MinValueValidator(1)]) quantity_fulfilled = models.IntegerField(validators=[MinValueValidator(0)], default=0) currency = models.CharField( max_length=settings.DEFAULT_CURRENCY_CODE_LENGTH, ) unit_discount_amount = models.DecimalField( max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, default=0, ) unit_discount = MoneyField(amount_field="unit_discount_amount", currency_field="currency") unit_discount_type = models.CharField( max_length=10, choices=DiscountValueType.CHOICES, default=DiscountValueType.FIXED, ) unit_discount_reason = models.TextField(blank=True, null=True) unit_price_net_amount = models.DecimalField( max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, ) # stores the value of the applied discount. Like 20 of % unit_discount_value = models.DecimalField( max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, default=0, ) unit_price_net = MoneyField(amount_field="unit_price_net_amount", currency_field="currency") unit_price_gross_amount = models.DecimalField( max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, ) unit_price_gross = MoneyField(amount_field="unit_price_gross_amount", currency_field="currency") unit_price = TaxedMoneyField( net_amount_field="unit_price_net_amount", gross_amount_field="unit_price_gross_amount", currency="currency", ) total_price_net_amount = models.DecimalField( max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, ) total_price_net = MoneyField( amount_field="total_price_net_amount", currency_field="currency", ) total_price_gross_amount = models.DecimalField( max_digits=settings.DEFAULT_MAX_DIGITS, decimal_places=settings.DEFAULT_DECIMAL_PLACES, ) total_price_gross = MoneyField( amount_field="total_price_gross_amount", currency_field="currency", ) total_price = TaxedMoneyField( net_amount_field="total_price_net_amount", gross_amount_field="total_price_gross_amount", currency="currency", ) tax_rate = models.DecimalField(max_digits=5, decimal_places=4, default=Decimal("0.0")) objects = OrderLineQueryset.as_manager() class Meta: ordering = ("pk", ) def __str__(self): return (f"{self.product_name} ({self.variant_name})" if self.variant_name else self.product_name) @property def undiscounted_unit_price(self) -> "TaxedMoney": return self.unit_price + self.unit_discount @property def quantity_unfulfilled(self): return self.quantity - self.quantity_fulfilled @property def is_digital(self) -> Optional[bool]: """Check if a variant is digital and contains digital content.""" if not self.variant: return None is_digital = self.variant.is_digital() has_digital = hasattr(self.variant, "digital_content") return is_digital and has_digital
def test_money_field_init(): field = MoneyField( currency='BTC', default='5', max_digits=9, decimal_places=2) assert field.get_default() == Money(5, 'BTC')