From b1af78e7e037079ae21d6cf257fb689a4eb5a28a Mon Sep 17 00:00:00 2001 From: Nathan Chapman Date: Fri, 25 Nov 2022 17:08:03 -0700 Subject: [PATCH] Add more refined way to calculating shipping --- src/core/__init__.py | 94 ++++++++++++++++--- src/core/exceptions.py | 13 +++ ...sitesettings_max_cart_quantity_and_more.py | 23 +++++ src/core/models.py | 3 + src/storefront/cart.py | 63 +++++++++---- src/storefront/tests/test_cart.py | 82 +++++++++++++++- src/storefront/views.py | 7 +- 7 files changed, 247 insertions(+), 38 deletions(-) create mode 100644 src/core/exceptions.py create mode 100644 src/core/migrations/0012_sitesettings_max_cart_quantity_and_more.py diff --git a/src/core/__init__.py b/src/core/__init__.py index ce62b38..6ca70b1 100644 --- a/src/core/__init__.py +++ b/src/core/__init__.py @@ -1,4 +1,5 @@ from django.conf import settings +from measurement.measures import Weight class DiscountValueType: @@ -49,12 +50,25 @@ class OrderStatus: class TransactionStatus: - CREATED = 'CREATED' # 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. - 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. - VOIDED = 'VOIDED' # All purchase units in the order are voided. - COMPLETED = 'COMPLETED' # The payment was authorized or the authorized payment was captured for 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. + # The order was created with the specified context. + CREATED = 'CREATED' + # 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. + 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 = [ (CREATED, 'Created'), @@ -68,6 +82,7 @@ class TransactionStatus: class ShippingProvider: USPS = 'USPS' + USPS_MAX_SHIPPING_WEIGHT = Weight(lb=70) # UPS = 'UPS' # FEDEX = 'FEDEX' @@ -91,20 +106,68 @@ class ShippingService: 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' - REGIONAL_RATE_BOX_A = 'REGIONALRATEBOXA' - REGIONAL_RATE_BOX_B = 'REGIONALRATEBOXB' + LG_FLAT_RATE_BOX = 'LG FLAT RATE BOX' VARIABLE = 'VARIABLE' + # PRIORITY_COMMERCIAL + REGIONAL_RATE_BOX_A = 'REGIONALRATEBOXA' + REGIONAL_RATE_BOX_B = 'REGIONALRATEBOXB' + CHOICES = [ - (LG_FLAT_RATE_BOX, 'Flate Rate Box - Large'), - (MD_FLAT_RATE_BOX, 'Flate Rate Box - Medium'), - (REGIONAL_RATE_BOX_A, 'Regional Rate Box A'), - (REGIONAL_RATE_BOX_B, 'Regional Rate Box B'), - (VARIABLE, 'Variable') + ( + 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, ( + (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: WHOLE = 'whole-beans' @@ -130,9 +193,10 @@ class CoffeeGrind: def build_usps_rate_request(weight, container, zip_destination): + service = ShippingContainer.get_shipping_service_from_container(container) return \ { - 'service': ShippingService.PRIORITY_COMMERCIAL, + 'service': service, 'zip_origination': settings.DEFAULT_ZIP_ORIGINATION, 'zip_destination': zip_destination, 'pounds': weight, diff --git a/src/core/exceptions.py b/src/core/exceptions.py new file mode 100644 index 0000000..6536ccb --- /dev/null +++ b/src/core/exceptions.py @@ -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 diff --git a/src/core/migrations/0012_sitesettings_max_cart_quantity_and_more.py b/src/core/migrations/0012_sitesettings_max_cart_quantity_and_more.py new file mode 100644 index 0000000..6ad04b4 --- /dev/null +++ b/src/core/migrations/0012_sitesettings_max_cart_quantity_and_more.py @@ -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), + ), + ] diff --git a/src/core/models.py b/src/core/models.py index d89b653..a5afb80 100644 --- a/src/core/models.py +++ b/src/core/models.py @@ -516,6 +516,9 @@ class SiteSettings(SingletonBase): null=True, help_text='Minimum dollar amount in the cart subtotal to qualify for free shipping' ) + max_cart_quantity = models.PositiveIntegerField( + default=20 + ) def __str__(self): return 'Site Settings' diff --git a/src/storefront/cart.py b/src/storefront/cart.py index 00dfc75..6e9cc8f 100644 --- a/src/storefront/cart.py +++ b/src/storefront/cart.py @@ -8,18 +8,21 @@ from django.shortcuts import redirect, reverse from django.urls import reverse_lazy from django.core.cache import cache from django.db.models import OuterRef, Q, Subquery +from django.core.exceptions import ValidationError from core.models import ( ProductCategory, Product, ProductVariant, OrderLine, Coupon, ShippingRate, SiteSettings ) from core.usps import USPSApi +from core.exceptions import USPSPostageError, ShippingAddressError from core import ( DiscountValueType, VoucherType, TransactionStatus, OrderStatus, ShippingService, + ShippingProvider, ShippingContainer, CoffeeGrind, build_usps_rate_request @@ -45,6 +48,14 @@ class CartItem: '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): yield ('name', str(self.variant)) yield ('description', self.variant.product.subtitle) @@ -55,7 +66,7 @@ class CartItem: yield ('quantity', f'{item["quantity"]}') def __str__(self): - return str(self.variant) + return f'{self.variant} [{self.quantity} × ${self.price}]' class Cart: @@ -64,7 +75,7 @@ class Cart: def __init__(self, request): self.request = request self.session = request.session - # self.site_settings = SiteSettings.load() + self.site_settings = SiteSettings.load() self.coupon_code = self.session.get('coupon_code') cart = self.session.get(settings.CART_SESSION_ID) if not cart: @@ -78,11 +89,9 @@ class Cart: self.add_or_update_item(item) # 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.save() - else: - messages.warning(request, "Cart is full: 20 items or less.") def add_or_update_item(self, new_item): new_item_pk = int(new_item['variant']) @@ -115,6 +124,18 @@ class Cart: item['quantity'] = item['variant'].product.checkout_limit 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): self.cart.pop(pk) self.save() @@ -155,7 +176,7 @@ class Cart: def get_weight_for_all_items(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): if len(self) > 0: @@ -201,21 +222,31 @@ class Cart: try: validation = usps.get_rate(usps_rate_request) - except ConnectionError: - raise ValidationError( + except ConnectionError as e: + raise e( 'Could not connect to USPS, try again.' ) logger.info(validation.result) - package = dict(validation.result['RateV4Response']['Package']) - if 'Error' not in package: - rate = package['Postage']['CommercialRate'] - else: - logger.error('USPS Rate error') - rate = '0.00' - return Decimal(rate) + try: + postage = dict( + validation.result['RateV4Response']['Package']['Postage'] + ) + except KeyError: + raise USPSPostageError( + '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: - return Decimal('0.00') + raise ShippingAddressError( + 'Could not retrieve shipping address.' + ) def clear(self): del self.session[settings.CART_SESSION_ID] diff --git a/src/storefront/tests/test_cart.py b/src/storefront/tests/test_cart.py index 59b9bd0..daad13b 100644 --- a/src/storefront/tests/test_cart.py +++ b/src/storefront/tests/test_cart.py @@ -12,11 +12,91 @@ from accounts.models import User, Address from core.models import Product, ProductVariant, Order from core import CoffeeGrind from storefront.views import OrderCreateView -from storefront.cart import Cart +from storefront.cart import CartItem, Cart 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): @classmethod def setUpTestData(cls): diff --git a/src/storefront/views.py b/src/storefront/views.py index 67bd368..de544d8 100644 --- a/src/storefront/views.py +++ b/src/storefront/views.py @@ -354,12 +354,7 @@ class OrderCreateView(CreateView): shipping_container = self.request.session.get( 'shipping_container' ).container - try: - 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') + shipping_cost = cart.get_shipping_cost(shipping_container) initial = { 'total_amount': cart.get_total_price(),