Add more refined way to calculating shipping

This commit is contained in:
Nathan Chapman 2022-11-25 17:08:03 -07:00
parent 67070483da
commit b1af78e7e0
7 changed files with 247 additions and 38 deletions

View File

@ -1,4 +1,5 @@
from django.conf import settings from django.conf import settings
from measurement.measures import Weight
class DiscountValueType: class DiscountValueType:
@ -49,12 +50,25 @@ class OrderStatus:
class TransactionStatus: class TransactionStatus:
CREATED = 'CREATED' # The order was created with the specified context. # The order was created with the specified context.
SAVED = 'SAVED' # The order was saved and persisted. The order status continues to be in progress until a capture is made with final_capture = true for all purchase units within the order. CREATED = 'CREATED'
APPROVED = 'APPROVED' # The customer approved the payment through the PayPal wallet or another form of guest or unbranded payment. For example, a card, bank account, or so on. # The order was saved and persisted. The order status continues to
VOIDED = 'VOIDED' # All purchase units in the order are voided. # be in progress until a capture is made with final_capture = true
COMPLETED = 'COMPLETED' # The payment was authorized or the authorized payment was captured for the order. # for all purchase units within the order.
PAYER_ACTION_REQUIRED = 'PAYER_ACTION_REQUIRED' # The order requires an action from the payer (e.g. 3DS authentication). Redirect the payer to the 'rel':'payer-action' HATEOAS link returned as part of the response prior to authorizing or capturing the order. SAVED = 'SAVED'
# The customer approved the payment through the PayPal wallet or
# another form of guest or unbranded payment. For example, a card,
# bank account, or so on.
APPROVED = 'APPROVED'
# All purchase units in the order are voided.
VOIDED = 'VOIDED'
# The payment was authorized or the authorized payment was captured
# for the order.
COMPLETED = 'COMPLETED'
# The order requires an action from the payer (e.g. 3DS authentication).
# Redirect the payer to the 'rel':'payer-action' HATEOAS link returned as
# part of the response prior to authorizing or capturing the order.
PAYER_ACTION_REQUIRED = 'PAYER_ACTION_REQUIRED'
CHOICES = [ CHOICES = [
(CREATED, 'Created'), (CREATED, 'Created'),
@ -68,6 +82,7 @@ class TransactionStatus:
class ShippingProvider: class ShippingProvider:
USPS = 'USPS' USPS = 'USPS'
USPS_MAX_SHIPPING_WEIGHT = Weight(lb=70)
# UPS = 'UPS' # UPS = 'UPS'
# FEDEX = 'FEDEX' # FEDEX = 'FEDEX'
@ -91,20 +106,68 @@ class ShippingService:
class ShippingContainer: class ShippingContainer:
LG_FLAT_RATE_BOX = 'LG FLAT RATE BOX' PRIORITY = 'PRIORITY'
PRIORITY_COMMERCIAL = 'PRIORITY COMMERCIAL'
# PRIORITY
FLAT_RATE_ENVELOPE = 'FLAT RATE ENVELOPE'
LEGAL_FLAT_RATE_ENVELOPE = 'LEGAL FLAT RATE ENVELOPE'
PADDED_FLAT_RATE_ENVELOPE = 'PADDED FLAT RATE ENVELOPE'
SM_FLAT_RATE_ENVELOPE = 'SM FLAT RATE ENVELOPE'
WINDOW_FLAT_RATE_ENVELOPE = 'WINDOW FLAT RATE ENVELOPE'
GIFT_CARD_FLAT_RATE_ENVELOPE = 'GIFT CARD FLAT RATE ENVELOPE'
SM_FLAT_RATE_BOX = 'SM FLAT RATE BOX'
MD_FLAT_RATE_BOX = 'MD FLAT RATE BOX' MD_FLAT_RATE_BOX = 'MD FLAT RATE BOX'
REGIONAL_RATE_BOX_A = 'REGIONALRATEBOXA' LG_FLAT_RATE_BOX = 'LG FLAT RATE BOX'
REGIONAL_RATE_BOX_B = 'REGIONALRATEBOXB'
VARIABLE = 'VARIABLE' VARIABLE = 'VARIABLE'
# PRIORITY_COMMERCIAL
REGIONAL_RATE_BOX_A = 'REGIONALRATEBOXA'
REGIONAL_RATE_BOX_B = 'REGIONALRATEBOXB'
CHOICES = [ CHOICES = [
(LG_FLAT_RATE_BOX, 'Flate Rate Box - Large'), (
(MD_FLAT_RATE_BOX, 'Flate Rate Box - Medium'), PRIORITY, (
(REGIONAL_RATE_BOX_A, 'Regional Rate Box A'), (FLAT_RATE_ENVELOPE, 'Flat Rate Envelope'),
(REGIONAL_RATE_BOX_B, 'Regional Rate Box B'), (LEGAL_FLAT_RATE_ENVELOPE, 'Legal Flat Rate Envelope'),
(VARIABLE, 'Variable') (PADDED_FLAT_RATE_ENVELOPE, 'Padded Flat Rate Envelope'),
(SM_FLAT_RATE_ENVELOPE, 'Sm Flat Rate Envelope'),
(WINDOW_FLAT_RATE_ENVELOPE, 'Window Flat Rate Envelope'),
(GIFT_CARD_FLAT_RATE_ENVELOPE, 'Gift Card Flat Rate Envelope'),
(SM_FLAT_RATE_BOX, 'Sm Flat Rate Box'),
(MD_FLAT_RATE_BOX, 'Md Flat Rate Box'),
(LG_FLAT_RATE_BOX, 'Lg Flat Rate Box'),
(VARIABLE, 'Variable'),
)
), (
PRIORITY_COMMERCIAL, (
(REGIONAL_RATE_BOX_A, 'Regional Rate Box A'),
(REGIONAL_RATE_BOX_B, 'Regional Rate Box B'),
)
),
] ]
SERVICE_FROM_CONTAINER = {
# PRIORITY
FLAT_RATE_ENVELOPE: PRIORITY,
LEGAL_FLAT_RATE_ENVELOPE: PRIORITY,
PADDED_FLAT_RATE_ENVELOPE: PRIORITY,
SM_FLAT_RATE_ENVELOPE: PRIORITY,
WINDOW_FLAT_RATE_ENVELOPE: PRIORITY,
GIFT_CARD_FLAT_RATE_ENVELOPE: PRIORITY,
SM_FLAT_RATE_BOX: PRIORITY,
MD_FLAT_RATE_BOX: PRIORITY,
LG_FLAT_RATE_BOX: PRIORITY,
VARIABLE: PRIORITY,
# PRIORITY_COMMERCIAL
REGIONAL_RATE_BOX_A: PRIORITY_COMMERCIAL,
REGIONAL_RATE_BOX_B: PRIORITY_COMMERCIAL,
}
def get_shipping_service_from_container(container):
return ShippingContainer.SERVICE_FROM_CONTAINER[container]
class CoffeeGrind: class CoffeeGrind:
WHOLE = 'whole-beans' WHOLE = 'whole-beans'
@ -130,9 +193,10 @@ class CoffeeGrind:
def build_usps_rate_request(weight, container, zip_destination): def build_usps_rate_request(weight, container, zip_destination):
service = ShippingContainer.get_shipping_service_from_container(container)
return \ return \
{ {
'service': ShippingService.PRIORITY_COMMERCIAL, 'service': service,
'zip_origination': settings.DEFAULT_ZIP_ORIGINATION, 'zip_origination': settings.DEFAULT_ZIP_ORIGINATION,
'zip_destination': zip_destination, 'zip_destination': zip_destination,
'pounds': weight, 'pounds': weight,

13
src/core/exceptions.py Normal file
View File

@ -0,0 +1,13 @@
class Error(Exception):
"""Base class for other exceptions"""
pass
class USPSPostageError(Error):
"""Raised when the input value is too small"""
pass
class ShippingAddressError(Error):
"""Raised when the input value is too large"""
pass

View File

@ -0,0 +1,23 @@
# Generated by Django 4.0.2 on 2022-11-25 21:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0011_alter_productvariant_image'),
]
operations = [
migrations.AddField(
model_name='sitesettings',
name='max_cart_quantity',
field=models.PositiveIntegerField(default=20),
),
migrations.AlterField(
model_name='shippingrate',
name='container',
field=models.CharField(choices=[('PRIORITY', (('FLAT RATE ENVELOPE', 'Flat Rate Envelope'), ('LEGAL FLAT RATE ENVELOPE', 'Legal Flat Rate Envelope'), ('PADDED FLAT RATE ENVELOPE', 'Padded Flat Rate Envelope'), ('SM FLAT RATE ENVELOPE', 'Sm Flat Rate Envelope'), ('WINDOW FLAT RATE ENVELOPE', 'Window Flat Rate Envelope'), ('GIFT CARD FLAT RATE ENVELOPE', 'Gift Card Flat Rate Envelope'), ('SM FLAT RATE BOX', 'Sm Flat Rate Box'), ('MD FLAT RATE BOX', 'Md Flat Rate Box'), ('LG FLAT RATE BOX', 'Lg Flat Rate Box'), ('VARIABLE', 'Variable'))), ('PRIORITY COMMERCIAL', (('REGIONALRATEBOXA', 'Regional Rate Box A'), ('REGIONALRATEBOXB', 'Regional Rate Box B')))], default='VARIABLE', max_length=255),
),
]

View File

@ -516,6 +516,9 @@ class SiteSettings(SingletonBase):
null=True, null=True,
help_text='Minimum dollar amount in the cart subtotal to qualify for free shipping' help_text='Minimum dollar amount in the cart subtotal to qualify for free shipping'
) )
max_cart_quantity = models.PositiveIntegerField(
default=20
)
def __str__(self): def __str__(self):
return 'Site Settings' return 'Site Settings'

View File

@ -8,18 +8,21 @@ from django.shortcuts import redirect, reverse
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.core.cache import cache from django.core.cache import cache
from django.db.models import OuterRef, Q, Subquery from django.db.models import OuterRef, Q, Subquery
from django.core.exceptions import ValidationError
from core.models import ( from core.models import (
ProductCategory, Product, ProductVariant, OrderLine, Coupon, ShippingRate, ProductCategory, Product, ProductVariant, OrderLine, Coupon, ShippingRate,
SiteSettings SiteSettings
) )
from core.usps import USPSApi from core.usps import USPSApi
from core.exceptions import USPSPostageError, ShippingAddressError
from core import ( from core import (
DiscountValueType, DiscountValueType,
VoucherType, VoucherType,
TransactionStatus, TransactionStatus,
OrderStatus, OrderStatus,
ShippingService, ShippingService,
ShippingProvider,
ShippingContainer, ShippingContainer,
CoffeeGrind, CoffeeGrind,
build_usps_rate_request build_usps_rate_request
@ -45,6 +48,14 @@ class CartItem:
'quantity': self.quantity 'quantity': self.quantity
}) })
@property
def total_price(self):
return self.variant.price * self.quantity
@property
def total_weight(self):
return Weight(lb=self.variant.weight.lb * self.quantity)
def __iter__(self): def __iter__(self):
yield ('name', str(self.variant)) yield ('name', str(self.variant))
yield ('description', self.variant.product.subtitle) yield ('description', self.variant.product.subtitle)
@ -55,7 +66,7 @@ class CartItem:
yield ('quantity', f'{item["quantity"]}') yield ('quantity', f'{item["quantity"]}')
def __str__(self): def __str__(self):
return str(self.variant) return f'{self.variant} [{self.quantity} × ${self.price}]'
class Cart: class Cart:
@ -64,7 +75,7 @@ class Cart:
def __init__(self, request): def __init__(self, request):
self.request = request self.request = request
self.session = request.session self.session = request.session
# self.site_settings = SiteSettings.load() self.site_settings = SiteSettings.load()
self.coupon_code = self.session.get('coupon_code') self.coupon_code = self.session.get('coupon_code')
cart = self.session.get(settings.CART_SESSION_ID) cart = self.session.get(settings.CART_SESSION_ID)
if not cart: if not cart:
@ -78,11 +89,9 @@ class Cart:
self.add_or_update_item(item) self.add_or_update_item(item)
# TODO: abstract this to a function that will check the max amount of item in the cart # TODO: abstract this to a function that will check the max amount of item in the cart
if len(self) <= 20: if self.check_max_cart_quantity(request) and self.check_max_shipping_weight(request):
self.check_item_stock_quantities(request) self.check_item_stock_quantities(request)
self.save() self.save()
else:
messages.warning(request, "Cart is full: 20 items or less.")
def add_or_update_item(self, new_item): def add_or_update_item(self, new_item):
new_item_pk = int(new_item['variant']) new_item_pk = int(new_item['variant'])
@ -115,6 +124,18 @@ class Cart:
item['quantity'] = item['variant'].product.checkout_limit item['quantity'] = item['variant'].product.checkout_limit
self.save() self.save()
def check_max_cart_quantity(self, request):
if len(self) > self.site_settings.max_cart_quantity:
messages.warning(request, 'Cart is full: 20 items or less.')
return False
return True
def check_max_shipping_weight(self, request):
if self.get_total_weight() > ShippingProvider.USPS_MAX_SHIPPING_WEIGHT.lb:
messages.warning(request, 'Weight exceeds shipping limit')
return False
return True
def remove(self, pk): def remove(self, pk):
self.cart.pop(pk) self.cart.pop(pk)
self.save() self.save()
@ -155,7 +176,7 @@ class Cart:
def get_weight_for_all_items(self): def get_weight_for_all_items(self):
for item in self: for item in self:
yield round(Decimal(item['variant'].weight.value) * item['quantity'], 3) yield round(item['variant'].weight.value * item['quantity'], 3)
def get_total_weight(self): def get_total_weight(self):
if len(self) > 0: if len(self) > 0:
@ -201,21 +222,31 @@ class Cart:
try: try:
validation = usps.get_rate(usps_rate_request) validation = usps.get_rate(usps_rate_request)
except ConnectionError: except ConnectionError as e:
raise ValidationError( raise e(
'Could not connect to USPS, try again.' 'Could not connect to USPS, try again.'
) )
logger.info(validation.result) logger.info(validation.result)
package = dict(validation.result['RateV4Response']['Package']) try:
if 'Error' not in package: postage = dict(
rate = package['Postage']['CommercialRate'] validation.result['RateV4Response']['Package']['Postage']
else: )
logger.error('USPS Rate error') except KeyError:
rate = '0.00' raise USPSPostageError(
return Decimal(rate) 'Could not retrieve postage.'
)
if usps_rate_request['service'] == ShippingContainer.PRIORITY:
shipping_cost = Decimal(postage['Rate'])
elif usps_rate_request['service'] == ShippingContainer.PRIORITY_COMMERCIAL:
shipping_cost = Decimal(postage['CommercialRate'])
return shipping_cost
else: else:
return Decimal('0.00') raise ShippingAddressError(
'Could not retrieve shipping address.'
)
def clear(self): def clear(self):
del self.session[settings.CART_SESSION_ID] del self.session[settings.CART_SESSION_ID]

View File

@ -12,11 +12,91 @@ from accounts.models import User, Address
from core.models import Product, ProductVariant, Order from core.models import Product, ProductVariant, Order
from core import CoffeeGrind from core import CoffeeGrind
from storefront.views import OrderCreateView from storefront.views import OrderCreateView
from storefront.cart import Cart from storefront.cart import CartItem, Cart
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class CartItemTest(TestCase):
@classmethod
def setUpTestData(cls):
cls.customer = User.objects.create_user(
username='petertempler',
email='peter@testing.com',
password='peterspassword321'
)
cls.product = Product.objects.create(
name="Dante's Tornado",
subtitle='Medium Roast',
description='Coffee',
checkout_limit=10,
visible_in_listings=True
)
cls.variant_1 = ProductVariant.objects.create(
product=cls.product,
name='12 oz',
sku='234987',
price=Decimal('12.00'),
weight=Weight(oz=12),
)
cls.variant_2 = ProductVariant.objects.create(
product=cls.product,
name='16 oz',
sku='987621',
price=Decimal('16.00'),
weight=Weight(oz=16),
)
cls.variant_3 = ProductVariant.objects.create(
product=cls.product,
name='16 oz',
sku='65432',
price=Decimal('75.00'),
weight=Weight(lb=5),
)
cls.order = Order.objects.create(
customer=cls.customer,
total_amount=Decimal('13.40')
)
def setUp(self):
self.client = Client()
self.factory = RequestFactory()
self.client.force_login(self.customer)
self.client.session['shipping_address'] = {
'first_name': 'Nathan',
'last_name': 'Chapman',
'email': 'contact@nathanjchapman.com',
'street_address_1': '1504 N 230 E',
'street_address_2': '',
'city': 'North Logan',
'state': 'UT',
'postal_code': '84341'
}
def test_calculates_total_weight(self):
cart_item = CartItem({
'options': {'Grind': 'Whole Beans'},
'quantity': 14,
'variant': self.variant_1
})
self.assertEqual(
cart_item.total_price,
Decimal('168.00')
)
def test_calculates_total_price(self):
cart_item = CartItem({
'options': {'Grind': 'Whole Beans'},
'quantity': 14,
'variant': self.variant_1
})
self.assertEqual(
cart_item.total_weight,
Weight(lb=10.5)
)
class CartTest(TestCase): class CartTest(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):

View File

@ -354,12 +354,7 @@ class OrderCreateView(CreateView):
shipping_container = self.request.session.get( shipping_container = self.request.session.get(
'shipping_container' 'shipping_container'
).container ).container
try: shipping_cost = cart.get_shipping_cost(shipping_container)
shipping_cost = cart.get_shipping_cost(shipping_container)
except Exception as e:
logger.error('Could not get shipping information')
raise
shipping_cost = Decimal('0.00')
initial = { initial = {
'total_amount': cart.get_total_price(), 'total_amount': cart.get_total_price(),