diff --git a/docs/source/howto/how_to_configure_shipping.rst b/docs/source/howto/how_to_configure_shipping.rst index 2084612106f..88c49263b64 100644 --- a/docs/source/howto/how_to_configure_shipping.rst +++ b/docs/source/howto/how_to_configure_shipping.rst @@ -152,4 +152,4 @@ to implement your custom shipping charge logic. .. tip:: Most of the shipping logic should live in the repository class, the method - instance is only responsble for returning the charge for a given basket. + instance is only responsible for returning the charge for a given basket. diff --git a/oscar/apps/shipping/abstract_models.py b/oscar/apps/shipping/abstract_models.py new file mode 100644 index 00000000000..99e2892226a --- /dev/null +++ b/oscar/apps/shipping/abstract_models.py @@ -0,0 +1,187 @@ +# -*- coding: utf-8 -*- +from decimal import Decimal as D + +from django.db import models +from django.utils.translation import ugettext_lazy as _ + +from oscar.apps.shipping import Scales +from oscar.models.fields import AutoSlugField + + +class ShippingMethod(models.Model): + # Fields from shipping.base.ShippingMethod must be added here manually. + code = AutoSlugField(_("Slug"), max_length=128, unique=True, + populate_from='name') + name = models.CharField(_("Name"), max_length=128, unique=True) + description = models.TextField(_("Description"), blank=True) + + # We allow shipping methods to be linked to a specific set of countries + countries = models.ManyToManyField('address.Country', null=True, + blank=True, verbose_name=_("Countries")) + + is_discounted = False + discount = D('0.00') + _basket = None + + class Meta: + abstract = True + verbose_name = _("Shipping Method") + verbose_name_plural = _("Shipping Methods") + + def __unicode__(self): + return self.name + + def set_basket(self, basket): + self._basket = basket + + +class AbstractOrderAndItemCharges(ShippingMethod): + """ + Standard shipping method + + This method has two components: + * a charge per order + * a charge per item + + Many sites use shipping logic which fits into this system. However, for + more complex shipping logic, a custom shipping method object will need to + be provided that subclasses ShippingMethod. + """ + price_per_order = models.DecimalField( + _("Price per order"), decimal_places=2, max_digits=12, + default=D('0.00')) + price_per_item = models.DecimalField( + _("Price per item"), decimal_places=2, max_digits=12, + default=D('0.00')) + + # If basket value is above this threshold, then shipping is free + free_shipping_threshold = models.DecimalField( + _("Free Shipping"), decimal_places=2, max_digits=12, blank=True, + null=True) + + _basket = None + + class Meta: + abstract = True + verbose_name = _("Order and Item Charge") + verbose_name_plural = _("Order and Item Charges") + + @property + def charge_incl_tax(self): + """ + Return basket total including tax + """ + if (self.free_shipping_threshold is not None and + self._basket.total_incl_tax >= self.free_shipping_threshold): + return D('0.00') + + charge = self.price_per_order + for line in self._basket.lines.all(): + if line.product.is_shipping_required: + charge += line.quantity * self.price_per_item + return charge + + @property + def charge_excl_tax(self): + """ + Return basket total excluding tax. + + Default implementation assumes shipping is tax free. + """ + return self.charge_incl_tax + + @property + def is_tax_known(self): + # We assume tax is known + return True + + +class AbstractWeightBased(ShippingMethod): + upper_charge = models.DecimalField( + _("Upper Charge"), decimal_places=2, max_digits=12, null=True, + help_text=_("This is the charge when the weight of the basket " + "is greater than all the weight bands""")) + + weight_attribute = 'weight' + default_weight = models.DecimalField( + _("Default Weight"), decimal_places=2, max_digits=12, + default=D('0.00'), + help_text=_("Default product weight in Kg when no weight attribute " + "is defined")) + + class Meta: + abstract = True + verbose_name = _("Weight-based Shipping Method") + verbose_name_plural = _("Weight-based Shipping Methods") + + @property + def charge_incl_tax(self): + # Note, when weighing the basket, we don't check whether the item + # requires shipping or not. It is assumed that if something has a + # weight, then it requires shipping. + scales = Scales(attribute_code=self.weight_attribute, + default_weight=self.default_weight) + weight = scales.weigh_basket(self._basket) + band = self.get_band_for_weight(weight) + if not band: + if self.bands.all().exists() and self.upper_charge: + return self.upper_charge + else: + return D('0.00') + return band.charge + + @property + def charge_excl_tax(self): + return self.charge_incl_tax + + @property + def is_tax_known(self): + # We assume tax is known + return True + + def get_band_for_weight(self, weight): + """ + Return the weight band for a given weight + """ + bands = self.bands.filter( + upper_limit__gte=weight).order_by('upper_limit')[:1] + # Query return only one row, so we can evaluate it + if not bands: + # No band for this weight + return None + return bands[0] + + +class AbstractWeightBand(models.Model): + """ + Represents a weight band which are used by the WeightBasedShipping method. + """ + method = models.ForeignKey('shipping.WeightBased', related_name='bands', + verbose_name=_("Method")) + upper_limit = models.FloatField(_("Upper Limit"), + help_text=_("""Enter upper limit of this + weight band in Kg, the lower + limit will be determine by the + other weight bands""")) + charge = models.DecimalField(_("Charge"), decimal_places=2, max_digits=12) + + @property + def weight_from(self): + lower_bands = self.method.bands.filter( + upper_limit__lt=self.upper_limit).order_by('-upper_limit') + if not lower_bands: + return D('0.00') + return lower_bands[0].upper_limit + + @property + def weight_to(self): + return self.upper_limit + + class Meta: + abstract = True + ordering = ['upper_limit'] + verbose_name = _("Weight Band") + verbose_name_plural = _("Weight Bands") + + def __unicode__(self): + return _('Charge for weights up to %sKg') % (self.upper_limit,) diff --git a/oscar/apps/shipping/models.py b/oscar/apps/shipping/models.py index 53dccfdc83a..f1b5e363d3f 100644 --- a/oscar/apps/shipping/models.py +++ b/oscar/apps/shipping/models.py @@ -1,183 +1,17 @@ -from decimal import Decimal as D +from oscar.apps.shipping import abstract_models -from django.db import models -from django.utils.translation import ugettext_lazy as _ -from oscar.apps.shipping import Scales -from oscar.models.fields import AutoSlugField +# backwards-compatible import +ShippingMethod = abstract_models.ShippingMethod -class ShippingMethod(models.Model): - # Fields from shipping.base.ShippingMethod must be added here manually. - code = AutoSlugField(_("Slug"), max_length=128, unique=True, - populate_from='name') - name = models.CharField(_("Name"), max_length=128, unique=True) - description = models.TextField(_("Description"), blank=True) +class OrderAndItemCharges(abstract_models.AbstractOrderAndItemCharges): + pass - # We allow shipping methods to be linked to a specific set of countries - countries = models.ManyToManyField('address.Country', null=True, - blank=True, verbose_name=_("Countries")) - is_discounted = False - discount = D('0.00') - _basket = None +class WeightBased(abstract_models.AbstractWeightBased): + pass - class Meta: - abstract = True - verbose_name = _("Shipping Method") - verbose_name_plural = _("Shipping Methods") - def __unicode__(self): - return self.name - - def set_basket(self, basket): - self._basket = basket - - -class OrderAndItemCharges(ShippingMethod): - """ - Standard shipping method - - This method has two components: - * a charge per order - * a charge per item - - Many sites use shipping logic which fits into this system. However, for - more complex shipping logic, a custom shipping method object will need to - be provided that subclasses ShippingMethod. - """ - price_per_order = models.DecimalField( - _("Price per order"), decimal_places=2, max_digits=12, - default=D('0.00')) - price_per_item = models.DecimalField( - _("Price per item"), decimal_places=2, max_digits=12, - default=D('0.00')) - - # If basket value is above this threshold, then shipping is free - free_shipping_threshold = models.DecimalField( - _("Free Shipping"), decimal_places=2, max_digits=12, blank=True, - null=True) - - _basket = None - - class Meta: - verbose_name = _("Order and Item Charge") - verbose_name_plural = _("Order and Item Charges") - - @property - def charge_incl_tax(self): - """ - Return basket total including tax - """ - if self.free_shipping_threshold is not None and \ - self._basket.total_incl_tax >= self.free_shipping_threshold: - return D('0.00') - - charge = self.price_per_order - for line in self._basket.lines.all(): - if line.product.is_shipping_required: - charge += line.quantity * self.price_per_item - return charge - - @property - def charge_excl_tax(self): - """ - Return basket total excluding tax. - - Default implementation assumes shipping is tax free. - """ - return self.charge_incl_tax - - @property - def is_tax_known(self): - # We assume tax is known - return True - - -class WeightBased(ShippingMethod): - upper_charge = models.DecimalField( - _("Upper Charge"), decimal_places=2, max_digits=12, null=True, - help_text=_("This is the charge when the weight of the basket " - "is greater than all the weight bands""")) - - weight_attribute = 'weight' - default_weight = models.DecimalField( - _("Default Weight"), decimal_places=2, max_digits=12, - default=D('0.00'), - help_text=_("Default product weight in Kg when no weight attribute " - "is defined")) - - class Meta: - verbose_name = _("Weight-based Shipping Method") - verbose_name_plural = _("Weight-based Shipping Methods") - - @property - def charge_incl_tax(self): - # Note, when weighing the basket, we don't check whether the item - # requires shipping or not. It is assumed that if something has a - # weight, then it requires shipping. - scales = Scales(attribute_code=self.weight_attribute, - default_weight=self.default_weight) - weight = scales.weigh_basket(self._basket) - band = self.get_band_for_weight(weight) - if not band: - if self.bands.all().exists() and self.upper_charge: - return self.upper_charge - else: - return D('0.00') - return band.charge - - @property - def charge_excl_tax(self): - return self.charge_incl_tax - - @property - def is_tax_known(self): - # We assume tax is known - return True - - def get_band_for_weight(self, weight): - """ - Return the weight band for a given weight - """ - bands = self.bands.filter( - upper_limit__gte=weight).order_by('upper_limit')[:1] - # Query return only one row, so we can evaluate it - if not bands: - # No band for this weight - return None - return bands[0] - - -class WeightBand(models.Model): - """ - Represents a weight band which are used by the WeightBasedShipping method. - """ - method = models.ForeignKey(WeightBased, related_name='bands', - verbose_name=_("Method")) - upper_limit = models.FloatField(_("Upper Limit"), - help_text=_("""Enter upper limit of this - weight band in Kg, the lower - limit will be determine by the - other weight bands""")) - charge = models.DecimalField(_("Charge"), decimal_places=2, max_digits=12) - - @property - def weight_from(self): - lower_bands = self.method.bands.filter( - upper_limit__lt=self.upper_limit).order_by('-upper_limit') - if not lower_bands: - return D('0.00') - return lower_bands[0].upper_limit - - @property - def weight_to(self): - return self.upper_limit - - class Meta: - ordering = ['upper_limit'] - verbose_name = _("Weight Band") - verbose_name_plural = _("Weight Bands") - - def __unicode__(self): - return _('Charge for weights up to %sKg') % (self.upper_limit,) +class WeightBand(abstract_models.AbstractWeightBand): + pass