Merge branch 'release/2.0.18'

This commit is contained in:
Nathan Chapman 2022-11-25 17:08:21 -07:00
commit cf81ffbc7e
7 changed files with 247 additions and 38 deletions

View File

@ -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,

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,
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'

View File

@ -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]

View File

@ -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):

View File

@ -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(),