Clone wiki

satchmo / DjangoConBreakout

Product Refactor

What we hate:

  1. All the special exceptions -- is_downloadable, is_subscribable
  2. The headache of creating a new custom type

Make product an effectively abstract model. A Product has the common information for a product but a product also has a number of configurable features. A SimpleProduct would be a basic. But DownloadableProduct, ConfigurableProduct, and SubscriptionProduct because separate ProductFeature components.

What would be needed in a ProductFeature interface?

class ProductType(object):
  product_related_model = None
  orderitem_related_model = None

  def compatible_with(self, other_product_types):
    return True


class BookProduct(models.Model):
  product = models.OneToOneField(BaseProduct)
  isbn = models.CharField(max_length=12, blank=True, null=True)
  pages = models.PositiveIntegerField(blank=True, null=True)
Search.register(BookProduct, field_list=['isbn'])

class BookProductType(ProductType):
  product_related_model = BookProduct
  orderitem_related_model = None


class DownloadableProduct(models.Model):
  product = models.OneToOneField(BaseProduct)
  max_downloads = models.PositiveIntegerField(null=True)

class DownloadableOrderItem(models.Model):
  orderitem = models.OneToOneField(OrderItem)
  download_key = models.CharField(max_length=32)
  download_count = models.PositiveIntegerField(default=0)

class ProductType(BaseProductType):
  product_related_model = DownloadableProduct
  orderitem_related_model = DownloadableOrderItem

  def is_compatible(self, product):
    return not product.has_product_type('satchmo.products.producttypes.shippable')


class ShippableProduct(models.Model):
  product = models.OneToOneField(BaseProduct)
  pounds = models.PositiveIntegerField()
  ounces = models.PositiveIntegerField()

class ShippableOrderItem(models.Model):
  orderitem = models.OneToOneField(OrderItem)
  pounds = models.PositiveIntegerField()
  ounces = models.PositiveIntegerField()


class BillOfMaterialsMaterial(models.Model):
  bill_of_materials = models.ForeignKey(BillOfMaterialsProduct)
  quantity = models.PositiveIntegerField()
  material = models.ForeignKey(BaseProduct)

class BillOfMaterialsProduct(models.Model):
  product = models.OneToOneField(BaseProduct)

  def add_to_cart(self, cart, quantity):
    # Infinite loop detector goes here
    cart.add_to_cart(material.product, material.quantity)

# billofmaterials/
class ProductType(BaseProductType):
  related_product_model = BillOfMaterialsProduct
  related_orderitem_model = None


class ConfigurableProductVariation(models.Model):
    option_group = models.ForeignKey(ConfigurableProductOptionGroup)
    name = models.CharField(_("Display value"), max_length=50, )
    value = models.CharField(_("Stored value"), max_length=50)
    price_change = models.DecimalField(_("Price Change"), null=True, blank=True,
        max_digits=14, decimal_places=6,
        help_text=_("This is the price differential for this option."))
    sort_order = models.IntegerField(_("Sort Order"))
    inventory = models.PositiveIntegerField()

class ConfigurableProductOptionGroup(models.Model):
  description = models.CharField(max_length=50) # e.g. "Shirt size", "Shirt Color"
  configurableproduct = models.ForeignKey('ConfigurableProduct')

class ConfigurableProduct(models.Model):
  product = models.OneToOneField(BaseProduct)

class ConfigurableOrderItemVariation(models.Model):
  configurableorderitem = models.ForeignKey('ConfigurableOrderItem')
  description = models.CharField(max_length=50)
  name = models.CharField(_("Display value"), max_length=50)
  value = models.CharField(_("Stored value"), max_length=50)
  price_change = models.DecimalField(_("Price Change"), null=True, blank=True,
        max_digits=14, decimal_places=6)

class ConfigurableOrderItem(models.Model):
  orderitem = models.ForeignKey(OrderItem)
  def calculate_line_item_price(self, orderitem):
    return orderitem.price + self.price_change

class ProductType(BaseProductType):
  related_product_model = ConfigurableProduct
  related_orderitem_model = ConfigurableOrderItem



I have difficulty with the use of a separate Price model. To me, the 'base_price' is a really core attribute of a product, and so should be a field of the BaseProduct model. Sales, quantity discounts, etc. are separate changes applied to the product's base_price. So I would favor something like:

class BaseProduct(models.Model):
    base_price = models.DecimalField(_("Base Price"), max_digits=8, decimal_places=2, core=True)

class BaseDiscount(models.Model):
    percent =  = models.DecimalField(_("Multiply Price By"), max_digits=4, decimal_places=4)
    amount =  = models.DecimalField(_("Add to Price"), max_digits=8, decimal_places=3)
    validProducts = models.ManyToManyField(Product, verbose_name=_("Valid Products") )
    validCategories = models.ManyToManyField(Category, verbose_name=_("Valid CategoriesProducts") )

    class Meta:
        abstract = True

class SaleDiscount(BaseDiscount):
    start_date = ....
    end_date = ....

class QuantityDiscount(BaseDiscount):
    quantity = models.DecimalField(_("Quantity"), max_digits=6, decimal_places=0)

The only disadvantage I can see is when a large number of products have the same price AND all need to change to some new price. But that can be handled by creating a Discount that adds or subtracts the correct delta.



A nice model I have seen for discounts in a commercial financials system is to have a product prices (discounts) table that overrides the base price which is stored in the product model. Here's the pseudo Django'ised model of it but with some of my own ideas added in - possible too many . This model can also I think be used for simple product options or surcharges which are the reverse of a discount.

class Discount(models.Model):
    product_id = models.ForeignKey(BaseProduct)
    name = A friendly internal name/description so we can know why this discount exists
    description = a public description for the invoice/cart
    cartitem_line = True/False (does this discount line get its own cartitem and thus display the description)
    unit_of_measure = an optional unit of measure for the discount item (eg hours, kg, each)
    discount_method_contenttype = The table the discount criteria will come from [Product, Customer, Category, URL etc] List defined in shop settings
    discount_contenttype_field = The field the discount is determined by [Category_id, Customer_no, Customer_group etc] List defined in shop settings 
    discount_contenttype_value = The value of the actual data that is cross referenced with the product id [Customer Joe Average, Category xyz etc]
    min_qty = An optional minium quantity to get the discount
    max_qty = An optional maximum quantity to get the disc
    discount_amount = A fixed discount amount or option cost
    discount_percent = A percent discount amount or option percentage. #either discount percent or discount amount - not both
    cumulative = True/False (A discount can be additional to an existing discount - eg customer Joe Average gets +5%). So it would be added after any other possible discount lines. If it is not cumulative than it would just be compared with any other non-cumulative discounts and get evaluated first.
    allow_post_cumulative = True/False (Can anything be added after this discount/surcharge?) If two discounts do not allow_post_cumulative then they would be compared and the worst discarded.
    final_discount = True/False (If True the discount/surcharge is applied after all other discounts). Multiple final discounts are compared and the worst discarded.
    start_date = The optional start date for the discount. No date means applies to all time
    end_date = The optional end date for the discount . No date means no expiry

    def price_after_discount(self): 

For performance - this is not a real generic relation class, as the values that come from the foreign table are inserted at the time of the creation of the discount. Ideally discounts are created from the view of the content type that you are using that can impact on the price - so customer edit view, category edit view, product etc, but certainly a raw view of the discount table is useful to filter on and manually update/insert.

If the product model has a product option that influences price, the pricing characteristics are just a discount in reverse. For example if the product is a birthday card for $3 a written message may be an $1 option defined in the ConfigurableProductVariation for the Product (card). In this case what we would do is create a discount line that adds $1 to the price and contenttype is the ConfigurableProductVariation table, the field is the description field, and the value will be the option that alters the price (eg Custom Message).

If no discount_method_contenttype is used then it is considered to be a simple discount that is dependent only on the product_id. Discount methods could be based on some field in the product class but batch creation of discounts would be required for all product_ids that matched the criteria. ie for all products with a given attribute, or some price break for quantity (buy 5 and get 10% off across all products for example). Although this potentially can create a lot of records it provides transparency about would possible pricing will be available.

I imagine the product template would contain a custom inclusion template tag like {% get_discounted_price as price %} and {% get_discount_percent as discount_percent %}. The custom template tag would look up all the possible discounts and evaluate them one at a time. If there are multiple lines then they could be made available to the template.

The cart item needs a many_to_one to the Discount record so that the pricing can always be verified.

Updating a Discount line needs to have a business rule for existing cart items that use that discountline.

A Product should have an option to be not discountable (allow_discounts = False).

An example cart:

1. A Birthday Card, Price $3, Qty 50, 5+ discount 33%, Amount $100, Less Customer Joe Blogs extra 10% discount, Amount $90 message option Price $1 ea Amount $50, Less Customer Joe Blogs extra 10% discount, Amount $45

In this example the product_id is 1, but we have 3 discount lines that are applicable.

  • we have the qty discount which is cumulative with min_qty: 5. Potentially we could say that this is allow_post_cumulative = False and not allow any further discounts to be added. Except..
  • the customer discount is a final_discount which means it gives an extra 10% after all other calculations
  • the message option is a product variation so it has it's own cart_line=True which is tied to the Birthday Card because it is not a product in it's own right., it is cumulative on top of other discounts, but it can't have other discounts added to it (allow_post_cumulative=False) so the qty discount doesn't apply because it will always be after the qty discount has been applied. But the final discount applied for the customer is applied after all other discounts/surcharges so Joe Blogs still gets his 10%

I like the idea of trying to hold all the possible pluses an minuses to a base price in one table, it makes it as simple or as complex pricing rules as needed but allowing transparency, and easy updating because everything is in the same table. Generally speaking it should be simple to cache the rows themselves, and then have a cache if needed at the template tag level? which caches for anonymous, and then invalidates and re-caches for a logged in user.

Big caveat: I haven't written the logic for this yet so there are probably trucks driving through holes in this as I write ;)

SKU and inventory

Shouldn't ProductVariation have it's own SKU and inventory fields?