From 36a82367e0c10761dfffccda85ce751fcae463d9 Mon Sep 17 00:00:00 2001 From: Maik Hoepfel Date: Thu, 10 Apr 2014 13:46:06 +0200 Subject: [PATCH] Abstract base classes for shipping models This is a common and useful Oscar pattern, and there's no reason to not use it for the shipping app. They are not imported anywhere in Oscar and just provided for convenience, so no imports had to be altered. --- .../howto/how_to_configure_shipping.rst | 2 +- oscar/apps/shipping/abstract_models.py | 187 ++++++++++++++++++ oscar/apps/shipping/models.py | 184 +---------------- 3 files changed, 197 insertions(+), 176 deletions(-) create mode 100644 oscar/apps/shipping/abstract_models.py 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