Saleor: Shipping price by weight or size

Created on 4 Aug 2017  Â·  8Comments  Â·  Source: mirumee/saleor

First I contributed to this issue that has already been closed, but probably nobody noticed as the issue is closed.

Could someone give me a hint what is the best and cleanest way to implement shipping price calculation by weight?

I get better in understanding the code, but nevertheless it’s cumbersome without explanation.

You need a place where you set the shipping price and you can get hold of the items ordered.
Maybe when a DeliveryGroup is created in core.utils.random_data.create_delivery_group(order)? Or in checkout.core.Checkout.deliveries(self)?

Any help would be appreciated.

in progress

Most helpful comment

Okay, here are the instructions as promised: First of all, as a physicist I should use the correct term mass instead of weight.

  • [x] Add mass fields to Product and ProductVariant in saleor/saleor/product/models.py:
    In class Product(models.Model, ItemRange, index.Indexed):
    mass = models.FloatField(null=True, verbose_name=pgettext_lazy('Product field', 'mass in kg'))
    In class ProductVariant(models.Model, Item):
mass_override = models.FloatField(null=True, verbose_name=pgettext_lazy('Product variant field', 'mass in kg'))

    @property
    def mass(self):
        return self.mass_override or self.product.mass or 0
  • [x] Add the fields in the dashboard templates e. g. under the price fields:
    saleor/templates/dashboard/product/product_form.html:
                <div class="row">
                  {{ product_form.mass|materializecss }}
                </div>

saleor/templates/dashboard/product/variant_form.html:

                <div class="row">
                  {{ form.mass_override|materializecss }}
                </div>
  • [x] In saleor/saleor/checkout/core.py the shipping price is calculated in two places. The cart is split in partitions, afik in products that require shipping and those that don’t. A partition contains the ordered products with their masses that we need. In the standard implementation you need a ShippingMethodCountry object for every country except the ones that count as "rest of the world", which is cumbersome. It’s easier to pass along the shipping address to the shipping price calculation: Add the parameters partition, self.shipping_address to self.shipping_method.get_total() in Checkout.deliveries(self):
    shipping_cost = self.shipping_method.get_total(partition, self.shipping_address)

and Checkout.create_order(self):

    shipping_price = self.shipping_method.get_total(partition, self.shipping_address)

self.get_total() calls self.deliveries(), which should do the correct calculation

  • [x] In saleor/saleor/shipping/models.py we can now calculate the shipping price in class ShippingMethodCountry(models.Model) according to the partition and the shipping address. It’s more flexible to pass the whole address instead of just the country, so you can differentiate the cost within a country. This is not unrealistic, e. g. shipping costs to some German islands in the North Sea or Baltic Sea can be higher. Here is an example for the shipping cost calculation, just as a proof of concept. You might either hard code the calculation or read a config file or use foreign key objects pointing to ShippingMethodCountry, but then you need to show them somehow in the Dashboard or activate the standard Django admin.
    def get_total(self, partition=None, shipping_address=None):
        """Calculate shipping price by mass and shipping address if partition is available"""
        if partition:
            # TODO: correct calulation, maybe with data from config file or with foreign key objects
            # We ignore self.country_code and use shipping_address.country instead. We also ignore self.price:
            eu = ['AT', 'BE', 'BG', 'CY', 'CZ', 'DK', 'DE', 'EE', 'ES', 'FI', 'FR', 'GB', 'GR', 'HR', 'HU',
                        'IE', 'IT', 'LT', 'LU', 'LV', 'MT', 'NL', 'PL', 'PT', 'RO', 'SE', 'SI', 'SK']
            import datetime, pytz
            utc = pytz.timezone('UTC')
            t_brexit = datetime.datetime(2019, 4, 1, 0, 0, 0, tzinfo=pytz.utc) # or whatever
            if utc.localize(datetime.datetime.utcnow()) >= t_brexit:
                eu.remove('GB')

            cc = shipping_address.country

            # Determine the total mass of the partition:
            mass = 0
            for cart_line in partition:
                mass += cart_line.quantity * cart_line.variant.mass
            # Just an example: 1 €/kg for Germany, 1.5 €/kg for the rest of the EU and 2 €/kg for other countries:
            if cc == 'DE':
                shipping_price = Price(mass, currency=settings.DEFAULT_CURRENCY)
            elif cc in eu:
                shipping_price = Price(1.5 * mass, currency=settings.DEFAULT_CURRENCY)
            else:
                shipping_price = Price(2 * mass, currency=settings.DEFAULT_CURRENCY)
            return shipping_price
        # Standard behaviour just in case in order not to break things 
        else:
            return self.price

Note that the stardard shipping price ShippingMethodCountry.price and in this example also ShippingMethodCountry.country_code are completely ignored. This might be confusing, but I wanted to change as few code as possible and not break existing functionality.

  • [ ] Finally hide the shipping cost in the cart, because they are not calculated when saleor/templates/cart/_subtotal_table.html is shown:
      <div class="col-5">
        <div class="row">
          <div class="col-md-12 col-12 text-right">
            <p class="cart__delivery-info__price">
              {% if default_country_options %}
                {# hide fixed standard shipping price {% price_range default_country_options %} #}
              {% endif %}
              {% trans "+ shipping" %}
            </p>
          </div>
        </div>
      </div>

To the maintainers of Saleor: Is there any other way I can contribute in this matter? Should I do a fork? We should somehow put this information into the documentation.

All 8 comments

Hello @MacLake

First of all - all code that lives in core.utils.random_data is only used to generate fake data (it's used by ./manage.py populatedb command.

To achieve price by weight, you should take a look on the code that creates order: https://github.com/mirumee/saleor/blob/master/saleor/checkout/core.py#L288 and wire it with shipping methods https://github.com/mirumee/saleor/blob/master/saleor/shipping/models.py#L98

I think it would be the easiest way to do it.

Hello @artursmet ,

Thanks a lot. I’ll try it this way and tell if it worked out.

Hi @MacLake I'm also interest in the shipping by weight. I plan to get started at the end of the month but if you could share any information about your progress I'd be glad to hear in order to not duplicate the functionality or even support your development on the matter.

Hi @curaloucura, I've solved the problem. I’ll give you some detailed instructions tomorrow. Actually it’s quite easy once you’ve understood the code, but that takes some time.

Okay, here are the instructions as promised: First of all, as a physicist I should use the correct term mass instead of weight.

  • [x] Add mass fields to Product and ProductVariant in saleor/saleor/product/models.py:
    In class Product(models.Model, ItemRange, index.Indexed):
    mass = models.FloatField(null=True, verbose_name=pgettext_lazy('Product field', 'mass in kg'))
    In class ProductVariant(models.Model, Item):
mass_override = models.FloatField(null=True, verbose_name=pgettext_lazy('Product variant field', 'mass in kg'))

    @property
    def mass(self):
        return self.mass_override or self.product.mass or 0
  • [x] Add the fields in the dashboard templates e. g. under the price fields:
    saleor/templates/dashboard/product/product_form.html:
                <div class="row">
                  {{ product_form.mass|materializecss }}
                </div>

saleor/templates/dashboard/product/variant_form.html:

                <div class="row">
                  {{ form.mass_override|materializecss }}
                </div>
  • [x] In saleor/saleor/checkout/core.py the shipping price is calculated in two places. The cart is split in partitions, afik in products that require shipping and those that don’t. A partition contains the ordered products with their masses that we need. In the standard implementation you need a ShippingMethodCountry object for every country except the ones that count as "rest of the world", which is cumbersome. It’s easier to pass along the shipping address to the shipping price calculation: Add the parameters partition, self.shipping_address to self.shipping_method.get_total() in Checkout.deliveries(self):
    shipping_cost = self.shipping_method.get_total(partition, self.shipping_address)

and Checkout.create_order(self):

    shipping_price = self.shipping_method.get_total(partition, self.shipping_address)

self.get_total() calls self.deliveries(), which should do the correct calculation

  • [x] In saleor/saleor/shipping/models.py we can now calculate the shipping price in class ShippingMethodCountry(models.Model) according to the partition and the shipping address. It’s more flexible to pass the whole address instead of just the country, so you can differentiate the cost within a country. This is not unrealistic, e. g. shipping costs to some German islands in the North Sea or Baltic Sea can be higher. Here is an example for the shipping cost calculation, just as a proof of concept. You might either hard code the calculation or read a config file or use foreign key objects pointing to ShippingMethodCountry, but then you need to show them somehow in the Dashboard or activate the standard Django admin.
    def get_total(self, partition=None, shipping_address=None):
        """Calculate shipping price by mass and shipping address if partition is available"""
        if partition:
            # TODO: correct calulation, maybe with data from config file or with foreign key objects
            # We ignore self.country_code and use shipping_address.country instead. We also ignore self.price:
            eu = ['AT', 'BE', 'BG', 'CY', 'CZ', 'DK', 'DE', 'EE', 'ES', 'FI', 'FR', 'GB', 'GR', 'HR', 'HU',
                        'IE', 'IT', 'LT', 'LU', 'LV', 'MT', 'NL', 'PL', 'PT', 'RO', 'SE', 'SI', 'SK']
            import datetime, pytz
            utc = pytz.timezone('UTC')
            t_brexit = datetime.datetime(2019, 4, 1, 0, 0, 0, tzinfo=pytz.utc) # or whatever
            if utc.localize(datetime.datetime.utcnow()) >= t_brexit:
                eu.remove('GB')

            cc = shipping_address.country

            # Determine the total mass of the partition:
            mass = 0
            for cart_line in partition:
                mass += cart_line.quantity * cart_line.variant.mass
            # Just an example: 1 €/kg for Germany, 1.5 €/kg for the rest of the EU and 2 €/kg for other countries:
            if cc == 'DE':
                shipping_price = Price(mass, currency=settings.DEFAULT_CURRENCY)
            elif cc in eu:
                shipping_price = Price(1.5 * mass, currency=settings.DEFAULT_CURRENCY)
            else:
                shipping_price = Price(2 * mass, currency=settings.DEFAULT_CURRENCY)
            return shipping_price
        # Standard behaviour just in case in order not to break things 
        else:
            return self.price

Note that the stardard shipping price ShippingMethodCountry.price and in this example also ShippingMethodCountry.country_code are completely ignored. This might be confusing, but I wanted to change as few code as possible and not break existing functionality.

  • [ ] Finally hide the shipping cost in the cart, because they are not calculated when saleor/templates/cart/_subtotal_table.html is shown:
      <div class="col-5">
        <div class="row">
          <div class="col-md-12 col-12 text-right">
            <p class="cart__delivery-info__price">
              {% if default_country_options %}
                {# hide fixed standard shipping price {% price_range default_country_options %} #}
              {% endif %}
              {% trans "+ shipping" %}
            </p>
          </div>
        </div>
      </div>

To the maintainers of Saleor: Is there any other way I can contribute in this matter? Should I do a fork? We should somehow put this information into the documentation.

Thanks @MacLake I was woundering how to this do right now heheheh, thanks again!

I ran into a problem that when selecting the Shipping Method, it already displays the old price. I decided to just hide it on:

checkout/forms/forms.py, inside ShippingCountryChoiceField

def label_from_instance(self, obj):
    # price_html = format_price(obj.price.gross, obj.price.currency)
    price_html = ''
    label = mark_safe('%s %s' % (obj.shipping_method, price_html))
    return label

Not neat but I just wanted to point out where it lives.

Shipping price by weight was introduced in #2529
For shipping price by size, we will keep #2029 issue as it contains more details

Was this page helpful?
0 / 5 - 0 ratings