Merge branch 'feature/cart-redesign' into develop

This commit is contained in:
Nathan Chapman 2022-11-27 12:18:02 -07:00
commit b07806a0d7
25 changed files with 521 additions and 470 deletions

View File

@ -25,6 +25,14 @@ class Address(models.Model):
{self.city}, {self.state}, {self.postal_code}
"""
def __iter__(self):
yield ('address_line_1', self.street_address_1),
yield ('address_line_2', self.street_address_2),
yield ('admin_area_2', self.city),
yield ('admin_area_1', self.state),
yield ('postal_code', self.postal_code),
yield ('country_code', 'US')
class User(AbstractUser):
addresses = models.ManyToManyField(

View File

@ -23,14 +23,14 @@ def get_or_create_customer(request, form, shipping_address):
user.save()
else:
user, u_created = User.objects.get_or_create(
email=form.cleaned_data['email'].lower(),
email=shipping_address['email'].lower(),
defaults={
'username': form.cleaned_data['email'].lower(),
'username': shipping_address['email'].lower(),
'is_staff': False,
'is_active': True,
'is_superuser': False,
'first_name': form.cleaned_data['first_name'],
'last_name': form.cleaned_data['last_name'],
'first_name': address.first_name,
'last_name': address.last_name,
'default_shipping_address': address,
}
)

View File

@ -15,6 +15,8 @@
"fields": {
"usps_user_id": "012BETTE1249",
"default_shipping_rate": 1,
"free_shipping_min": "100.00"
"free_shipping_min": "100.00",
"max_cart_quantity": 20,
"max_cart_weight": "20:lb"
}
}]

View File

@ -0,0 +1,26 @@
# Generated by Django 4.0.2 on 2022-11-27 04:28
import core.weight
from django.db import migrations
import django_measurement.models
import measurement.measures.mass
class Migration(migrations.Migration):
dependencies = [
('core', '0012_sitesettings_max_cart_quantity_and_more'),
]
operations = [
migrations.AlterField(
model_name='shippingrate',
name='max_order_weight',
field=django_measurement.models.MeasurementField(blank=True, default=core.weight.zero_weight, measurement=measurement.measures.mass.Mass, null=True),
),
migrations.AlterField(
model_name='shippingrate',
name='min_order_weight',
field=django_measurement.models.MeasurementField(blank=True, default=core.weight.zero_weight, measurement=measurement.measures.mass.Mass, null=True),
),
]

View File

@ -0,0 +1,31 @@
# Generated by Django 4.0.2 on 2022-11-27 16:26
import core.weight
from django.db import migrations, models
import django_measurement.models
import measurement.measures.mass
class Migration(migrations.Migration):
dependencies = [
('core', '0013_alter_shippingrate_max_order_weight_and_more'),
]
operations = [
migrations.AddField(
model_name='sitesettings',
name='max_cart_weight',
field=django_measurement.models.MeasurementField(blank=True, default=core.weight.zero_weight, help_text='Maximum weight allowed for cart.', measurement=measurement.measures.mass.Mass, null=True),
),
migrations.AlterField(
model_name='sitesettings',
name='free_shipping_min',
field=models.DecimalField(blank=True, decimal_places=2, help_text='Minimum dollar amount in the cart subtotal to qualify for free shipping.', max_digits=12, null=True),
),
migrations.AlterField(
model_name='sitesettings',
name='max_cart_quantity',
field=models.PositiveIntegerField(blank=True, default=20, help_text='Maximum amount of items allowed in cart.', null=True),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.0.2 on 2022-11-27 18:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0014_sitesettings_max_cart_weight_and_more'),
]
operations = [
migrations.AlterField(
model_name='order',
name='coupon_amount',
field=models.DecimalField(decimal_places=2, default=0, max_digits=10),
),
]

View File

@ -270,11 +270,17 @@ class ShippingRate(models.Model):
choices=ShippingContainer.CHOICES,
default=ShippingContainer.VARIABLE
)
min_order_weight = models.PositiveIntegerField(
min_order_weight = MeasurementField(
measurement=Weight,
unit_choices=WeightUnits.CHOICES,
default=zero_weight,
blank=True,
null=True
)
max_order_weight = models.PositiveIntegerField(
max_order_weight = MeasurementField(
measurement=Weight,
unit_choices=WeightUnits.CHOICES,
default=zero_weight,
blank=True,
null=True
)
@ -350,7 +356,11 @@ class Order(models.Model):
decimal_places=2,
default=0
)
coupon_amount = models.CharField(max_length=255, blank=True)
coupon_amount = models.DecimalField(
max_digits=10,
decimal_places=2,
default=0
)
shipping_total = models.DecimalField(
max_digits=5,
decimal_places=2,
@ -514,10 +524,21 @@ class SiteSettings(SingletonBase):
decimal_places=settings.DEFAULT_DECIMAL_PLACES,
blank=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
default=20,
blank=True,
null=True,
help_text='Maximum amount of items allowed in cart.'
)
max_cart_weight = MeasurementField(
measurement=Weight,
unit_choices=WeightUnits.CHOICES,
default=zero_weight,
blank=True,
null=True,
help_text='Maximum weight allowed for cart.'
)
def __str__(self):

View File

@ -17,7 +17,7 @@ class WeightUnits:
def zero_weight():
"""Represent the zero weight value."""
return Weight(kg=0)
return Weight(lb=0)
def convert_weight(weight: Weight, unit: str) -> Weight:

View File

@ -33,6 +33,8 @@
<p>USPS User ID: {{ site_settings.usps_user_id }}</p>
<p>Default shipping rate: {{ site_settings.default_shipping_rate }}</p>
<p>Free shipping min: ${{ site_settings.free_shipping_min }}</p>
<p>Max cart quantity: {{ site_settings.max_cart_quantity }} items</p>
<p>Max cart weight: {{ site_settings.max_cart_weight }}</p>
</div>
</section>
</article>

View File

@ -22,7 +22,7 @@
<span class="order__status--display">
<div class="status__dot order__status--{{order.status}}"></div>
{{order.get_status_display}}</span>
<span>${{order.get_total_price_after_discount}}</span>
<span>${{order.total_amount}}</span>
</a>
{% empty %}
<span class="object__item">No orders</span>

View File

@ -66,7 +66,7 @@ logger = logging.getLogger(__name__)
class ProductCreateViewTests(TestCase):
fixtures = [
'shipping_rates.json',
'site_settings_and_shipping_rates',
'accounts.json',
'coupons.json',
'products.json',
@ -101,7 +101,7 @@ class ProductCreateViewTests(TestCase):
class OrderCancelViewTests(TestCase):
fixtures = [
'shipping_rates.json',
'site_settings_and_shipping_rates',
'accounts.json',
'coupons.json',
'products.json',

View File

@ -12,7 +12,7 @@ from django.contrib.staticfiles.testing import StaticLiveServerTestCase
class AddressTests(StaticLiveServerTestCase):
fixtures = ['shipping_rates.json', 'products.json']
fixtures = ['site_settings_and_shipping_rates', 'products.json']
@classmethod
def setUpClass(cls):

View File

@ -20,7 +20,7 @@ logger = logging.getLogger(__name__)
class CouponTests(StaticLiveServerTestCase):
fixtures = [
'shipping_rates.json', 'products.json', 'accounts.json', 'coupons.json'
'site_settings_and_shipping_rates', 'products.json', 'accounts.json', 'coupons.json'
]
@classmethod

View File

@ -106,7 +106,7 @@ DATABASES = {
CACHES = {'default': CACHE_CONFIG}
SESSION_ENGINE = 'django.contrib.sessions.backends.cached_db'
SESSION_SERIALIZER = 'django.contrib.sessions.serializers.PickleSerializer'
# SESSION_SERIALIZER = 'django.contrib.sessions.serializers.PickleSerializer'
# Password validation
# https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators
@ -179,6 +179,8 @@ STATICFILES_FINDERS = (
'compressor.finders.CompressorFinder',
)
FILE_UPLOAD_MAX_MEMORY_SIZE = 62914560
# Default primary key field type
# https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field
@ -238,7 +240,6 @@ LOGGING = {
},
}
CART_SESSION_ID = 'cart'
DEFAULT_COUNTRY = 'US'
DEFAULT_CURRENCY = 'USD'

View File

@ -8,7 +8,7 @@ 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 django.core.exceptions import ValidationError, ObjectDoesNotExist
from core.models import (
ProductCategory, Product, ProductVariant, OrderLine, Coupon, ShippingRate,
@ -28,23 +28,70 @@ from core import (
build_usps_rate_request
)
from .forms import UpdateCartItemForm
from .forms import CartItemUpdateForm
from .payments import CreateOrder
logger = logging.getLogger(__name__)
class CartItem:
update_form = UpdateCartItemForm
update_form = CartItemUpdateForm
order_line_class = OrderLine
variant = None
quantity = None
options = None
def __init__(self, item):
self.variant = item['variant']
self.quantity = item['quantity']
self.options = item['options']
try:
self.variant = ProductVariant.objects.get(pk=item['variant_pk'])
except ProductVariant.DoesNotExist:
self.quantity = None
else:
self.quantity = item['quantity']
self.options = item['options']
def __iter__(self):
yield ('name', f'{self.variant} {self.options_as_str()}')
yield ('description', self.variant.product.subtitle)
yield ('unit_amount', {
'currency_code': settings.DEFAULT_CURRENCY,
'value': str(self.variant.price),
})
yield ('quantity', str(self.quantity))
def __str__(self):
return f'{self.variant} [{self.quantity} × ${self.variant.price}]'
def serialize(self):
if self.variant is not None:
return \
{
'variant_pk': self.variant.pk,
'quantity': self.quantity,
'options': self.options
}
def deserialize(self, data):
self.variant = ProductVariant.objects.get(data['variant_pk'])
self.quantity = data['quantity']
self.options = data['options']
def options_as_str(self):
options = [f'{key}: {value}' for key, value in self.options.items()]
return '; '.join(options)
def as_order_line(self, order):
return self.order_line_class(
order=order,
variant=self.variant,
customer_note=self.options_as_str(),
unit_price=self.variant.price,
quantity=self.quantity
)
def get_update_form(self, index):
return self.update_form(initial={
'item_pk': index,
'item_index': index,
'quantity': self.quantity
})
@ -56,144 +103,133 @@ class CartItem:
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)
yield ('unit_amount', {
'currency_code': settings.DEFAULT_CURRENCY,
'value': f'{self.variant.price}',
})
yield ('quantity', f'{item["quantity"]}')
def __str__(self):
return f'{self.variant} [{self.quantity} × ${self.price}]'
class Cart:
item_class = CartItem
items = []
coupon = None
request = None
site_settings = None
def __init__(self, request):
self.request = request
self.session = request.session
self.site_settings = SiteSettings.load()
self.coupon_code = self.session.get('coupon_code')
cart = self.session.get(settings.CART_SESSION_ID)
if not cart:
cart = self.session[settings.CART_SESSION_ID] = []
self.cart = cart
def add(self, request, item, update_quantity=False):
if update_quantity:
self.cart[item['variant']]['quantity'] = item['quantity']
else:
self.add_or_update_item(item)
# TODO: abstract this to a function that will check the max amount of item in the cart
if self.check_max_cart_quantity(request) and self.check_max_shipping_weight(request):
self.check_item_stock_quantities(request)
self.save()
def add_or_update_item(self, new_item):
new_item_pk = int(new_item['variant'])
for item in self:
if new_item_pk == item['variant'].pk:
if new_item['options'] == item['options']:
item['quantity'] += new_item['quantity']
return
else:
continue
self.cart.append(new_item)
def save(self):
self.session[settings.CART_SESSION_ID] = self.cart
self.session.modified = True
logger.info(f'\nCart:\n{self.cart}\n')
def check_item_stock_quantities(self, request):
for item in self:
if item['variant'].track_inventory:
if item['quantity'] > item['variant'].stock:
if item['quantity'] > item['variant'].product.checkout_limit:
messages.warning(request, 'Quantity exceeds checkout limit.')
item['quantity'] = item['variant'].product.checkout_limit
continue
messages.warning(request, 'Quantity exceeds available stock.')
item['quantity'] = item['variant'].stock
elif item['quantity'] > item['variant'].product.checkout_limit:
messages.warning(request, 'Quantity exceeds checkout limit.')
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)
if self.session.get('cart'):
self.deserialize(self.session.get('cart'))
self.save()
def __iter__(self):
for item in self.cart:
pk = item['variant'].pk if isinstance(item['variant'], ProductVariant) else item['variant']
item['variant'] = ProductVariant.objects.get(pk=pk)
item['price_total'] = item['variant'].price * item['quantity']
for item in self.items:
yield item
def __len__(self):
return sum([item['quantity'] for item in self.cart])
return sum([item.quantity for item in self])
def get_item_prices_for_category(self, category):
def serialize(self):
return \
{
'coupon_code': self.coupon.code if self.coupon is not None else None,
'items': [item.serialize() for item in self]
}
def deserialize(self, data):
# Transform old cart
if type(data) is list:
return
try:
self.coupon = Coupon.objects.get(code=data.get('coupon_code'))
except Coupon.DoesNotExist:
self.coupon = None
self.items = [self.item_class(item) for item in data['items'] if item is not None]
def save(self):
if self.validate():
self.session['cart'] = self.serialize()
self.session.modified = True
def validate(self):
self.check_item_checkout_limit_and_stock()
if self.check_max_cart_quantity() and self.check_max_shipping_weight():
return True
return False
def check_item_checkout_limit_and_stock(self):
for item in self:
if item['variant'].product.category == category:
yield item['price_total']
else:
continue
if item.variant.track_inventory:
if item.quantity > item.variant.stock:
if item.quantity > item.variant.product.checkout_limit:
messages.warning(self.request, 'Quantity exceeds checkout limit.')
item.quantity = item.variant.product.checkout_limit
continue
messages.warning(self.request, 'Quantity exceeds available stock.')
item.quantity = item.variant.stock
elif item.quantity > item.variant.product.checkout_limit:
messages.warning(self.request, 'Quantity exceeds checkout limit.')
item.quantity = item.variant.product.checkout_limit
def get_total_price_for_category(self, category):
return sum(self.get_item_prices_for_category(category))
def check_max_cart_quantity(self):
if len(self) > self.site_settings.max_cart_quantity:
messages.warning(self.request, 'Cart is full: 20 items or less.')
return False
return True
def get_all_item_quantities(self):
for item in self.cart:
yield item['quantity']
def check_max_shipping_weight(self):
if self.total_weight > self.site_settings.max_cart_weight:
messages.warning(self.request, 'Weight exceeds shipping limit')
return False
return True
def get_single_item_total_quantity(self, item):
return sum([value['quantity'] for value in item['variations'].values()])
def clear(self):
del self.session['cart']
self.session.modified = True
def get_item_prices(self):
def add_item(self, new_item):
for item in self:
yield item['price_total']
if new_item.variant == item.variant:
if new_item.options == item.options:
item.quantity += new_item.quantity
self.save()
return
else:
continue
self.items.append(new_item)
self.save()
def get_total_price(self):
return sum(self.get_item_prices())
def update_item_quantity(self, item_index, quantity):
self.items[item_index].quantity = quantity
self.save()
def get_weight_for_all_items(self):
def remove_item(self, index):
self.items.pop(index)
self.save()
def add_coupon(self, coupon):
# TODO: Apply coupon validity checks
self.coupon = coupon
self.save()
def remove_coupon(self):
if self.coupon is not None:
del self.coupon
self.save()
def get_total_price_for_coupon_items(self):
for item in self:
yield round(item['variant'].weight.value * item['quantity'], 3)
def get_total_weight(self):
if len(self) > 0:
return sum(self.get_weight_for_all_items())
else:
return 0
if item.variant.product in self.coupon.products.all():
yield item.total_price
def get_shipping_container_choices(self):
is_selectable = Q(
is_selectable=True
)
min_weight_matched = Q(
min_order_weight__lte=self.get_total_weight()) | Q(
min_order_weight__lte=self.total_weight) | Q(
min_order_weight__isnull=True
)
max_weight_matched = Q(
max_order_weight__gte=self.get_total_weight()) | Q(
max_order_weight__gte=self.total_weight) | Q(
max_order_weight__isnull=True
)
containers = ShippingRate.objects.filter(
@ -201,24 +237,27 @@ class Cart:
)
return containers
def get_shipping_cost(self, container=None):
# free_shipping_min = self.site_settings.free_shipping_min
# if free_shipping_min is not None:
# category = ProductCategory.objects.get(name='Coffee')
# if self.get_total_price_for_category(category) >= free_shipping_min:
# return Decimal('0.00')
def get_shipping_container(self):
possible_containers = self.get_shipping_container_choices()
if len(possible_containers) == 0:
return self.site_settings.default_shipping_rate.container
return possible_containers[0].container
def get_shipping_price(self, container=None):
if container is None:
container = self.session.get('shipping_container').container
container = self.get_shipping_container()
if not self.total_weight > Weight(lb=0):
return Decimal('0.00')
if len(self) > 0 and self.session.get('shipping_address'):
usps_rate_request = build_usps_rate_request(
str(self.get_total_weight()),
str(self.total_weight.lb),
container,
str(self.session.get('shipping_address')['postal_code'])
)
usps = USPSApi(settings.USPS_USER_ID, test=True)
usps = USPSApi(settings.USPS_USER_ID, test=settings.DEBUG)
try:
validation = usps.get_rate(usps_rate_request)
@ -248,98 +287,72 @@ class Cart:
'Could not retrieve shipping address.'
)
def clear(self):
del self.session[settings.CART_SESSION_ID]
try:
del self.session['coupon_code']
except KeyError:
pass
self.session.modified = True
def build_order_params(self, container=None):
return \
{
'items': self,
'total_price': f'{self.get_total_price_after_discount()}',
'item_total': f'{self.get_total_price()}',
'discount': f'{self.get_discount()}',
'shipping_price': f'{self.get_shipping_cost()}',
'tax_total': '0',
'shipping_method': 'US POSTAL SERVICE ' + (
container if container else ''
),
'shipping_address': self.build_shipping_address(
self.session.get('shipping_address')
),
}
def create_order(self, container=None):
params = self.build_order_params(container)
def create_order(self):
params = self.build_order_params()
logger.info(f'\nParams: {params}\n')
if settings.DEBUG:
response = CreateOrder().create_order(params, debug=True)
else:
response = CreateOrder().create_order(params)
response = CreateOrder().create_order(params, debug=settings.DEBUG)
return response
def get_line_options(self, options_dict):
options = ''
for key, value in options_dict.items():
options += f'{key}: {value}; '
return options
def get_address_as_dict(self, address=None):
if address is None:
address = self.session.get('shipping_address')
def build_bulk_list(self, order):
bulk_list = []
for item in self:
bulk_list.append(OrderLine(
order=order,
variant=item['variant'],
customer_note=self.get_line_options(item['options']),
unit_price=item['variant'].price,
quantity=item['quantity']
))
return bulk_list
def build_shipping_address(self, address):
return \
{
'address_line_1': f'{address["street_address_1"]}',
'address_line_2': f'{address["street_address_2"]}',
'admin_area_2': f'{address["city"]}',
'admin_area_1': f'{address["state"]}',
'postal_code': f'{address["postal_code"]}',
'address_line_1': address.get('street_address_1', ''),
'address_line_2': address.get('street_address_2', ''),
'admin_area_2': address.get('city', ''),
'admin_area_1': address.get('state', ''),
'postal_code': address.get('postal_code', ''),
'country_code': 'US'
}
def build_order_params(self):
return \
{
'items': self.items,
'total_price': self.total_price,
'item_total': self.subtotal_price,
'discount': self.discount_amount,
'shipping_price': self.get_shipping_price(),
'tax_total': '0.00',
'shipping_method': 'US POSTAL SERVICE',
'shipping_address': self.get_address_as_dict(),
}
def build_bulk_list(self, order):
return [item.as_order_line(order) for item in self]
@property
def coupon(self):
if self.coupon_code:
return Coupon.objects.get(code=self.coupon_code)
return None
def item_variant_pks(self):
return [item.variant.pk for item in self]
def get_coupon_total_for_specific_products(self):
for item in self.cart:
if item['variant'].product in self.coupon.products.all():
yield item['price_total']
@property
def subtotal_price(self):
return sum([item.total_price for item in self])
def get_discount(self):
# SHIPPING
# ENTIRE_ORDER
# SPECIFIC_PRODUCT
if self.coupon:
@property
def discount_amount(self):
if self.coupon is not None:
if self.coupon.discount_value_type == DiscountValueType.FIXED:
return round(self.coupon.discount_value, 2)
return self.coupon.discount_value
elif self.coupon.discount_value_type == DiscountValueType.PERCENTAGE:
if self.coupon.type == VoucherType.ENTIRE_ORDER:
return round((self.coupon.discount_value / Decimal('100')) * self.get_total_price(), 2)
return round((self.coupon.discount_value / Decimal('100.00')) * self.subtotal_price, 2)
elif self.coupon.type == VoucherType.SPECIFIC_PRODUCT:
# Get the product in cart quantity
total = sum(self.get_coupon_total_for_specific_products())
return round((self.coupon.discount_value / Decimal('100')) * total, 2)
return Decimal('0')
total_price = sum(self.get_total_price_for_coupon_items())
return round((self.coupon.discount_value / Decimal('100.00')) * total_price, 2)
return Decimal('0.00')
def get_subtotal_price_after_discount(self):
return round(self.get_total_price() - self.get_discount(), 2)
@property
def subtotal_price_after_discount(self):
return self.subtotal_price - self.discount_amount
def get_total_price_after_discount(self):
return round(self.get_total_price() - self.get_discount() + self.get_shipping_cost(), 2)
@property
def total_price(self):
return self.subtotal_price - self.discount_amount + self.get_shipping_price()
@property
def total_weight(self):
return Weight(lb=sum([item.total_weight.lb for item in self]))

View File

@ -34,14 +34,9 @@ class AddToCartForm(forms.Form):
)
class UpdateCartItemForm(forms.Form):
item_pk = forms.IntegerField(widget=forms.HiddenInput())
class CartItemUpdateForm(forms.Form):
item_index = forms.IntegerField(widget=forms.HiddenInput())
quantity = forms.IntegerField(min_value=1, max_value=20, initial=1)
update = forms.BooleanField(
required=False,
initial=True,
widget=forms.HiddenInput()
)
class AddToSubscriptionForm(forms.Form):
@ -130,20 +125,9 @@ class CheckoutShippingForm(forms.Form):
class OrderCreateForm(forms.ModelForm):
email = forms.CharField(widget=forms.HiddenInput())
first_name = forms.CharField(widget=forms.HiddenInput())
last_name = forms.CharField(widget=forms.HiddenInput())
class Meta:
model = Order
fields = (
'total_amount',
'shipping_total',
)
widgets = {
'total_amount': forms.HiddenInput(),
'shipping_total': forms.HiddenInput()
}
fields = []
class CouponApplyForm(forms.Form):

View File

@ -90,24 +90,6 @@ class CreateOrder(PayPalClient):
def build_request_body(self, params):
"""Method to create body with CAPTURE intent"""
processed_items = [
{
# Shows within upper-right dropdown during payment approval
"name": f"{item['variant']} " + "; ".join(
f"{key}: {value}" for key, value in item["options"].items()
),
# Item details will also be in the completed paypal.com
# transaction view
"description": item["variant"].product.subtitle,
"unit_amount": {
"currency_code": settings.DEFAULT_CURRENCY,
"value": f"{item['variant'].price}",
},
"quantity": f"{item['quantity']}",
}
for item in params["items"]
]
request_body = {
"intent": "CAPTURE",
"application_context": {
@ -126,15 +108,15 @@ class CreateOrder(PayPalClient):
# "soft_descriptor": "HighFashions",
"amount": {
"currency_code": "USD",
"value": params["total_price"],
"value": str(params["total_price"]),
"breakdown": {
"item_total": {
"currency_code": "USD",
"value": params["item_total"],
"value": str(params["item_total"]),
},
"shipping": {
"currency_code": "USD",
"value": params["shipping_price"],
"value": str(params["shipping_price"]),
},
"tax_total": {
"currency_code": "USD",
@ -142,11 +124,11 @@ class CreateOrder(PayPalClient):
},
"discount": {
"currency_code": "USD",
"value": params["discount"],
"value": str(params["discount"]),
},
},
},
"items": processed_items,
"items": [dict(item) for item in params['items']],
"shipping": {
"method": params["shipping_method"],
"address": params["shipping_address"],

View File

@ -1,5 +1,5 @@
{% extends "base.html" %}
{% load grind_filter %}
{% load initialize_update_form %}
{% block head_title %}Cart | {% endblock %}
@ -25,9 +25,9 @@
{% for key, value in item.options.items %}
<p><strong>{{ key }}</strong>: {{ value }}</p>
{% endfor %}
<form class="item__form" action="{% url 'storefront:cart-update' product.pk %}" method="POST">
<form class="item__form" action="{% url 'storefront:cart-detail' %}" method="POST">
{% csrf_token %}
{{ item.update_quantity_form }}
{{ item|initialize_update_form:forloop.counter0 }}
<input type="submit" value="Update">
</form>
<p><a href="{% url 'storefront:cart-remove' forloop.counter0 %}">Remove item</a></p>
@ -62,7 +62,7 @@
<table class="cart__totals">
<tr>
<td>Subtotal</td>
<td>${{ cart.get_total_price|floatformat:"2" }}</td>
<td>${{ cart.subtotal_price }}</td>
</tr>
{% if cart.coupon and cart.coupon.type == 'entire_order' %}
<tr>
@ -72,7 +72,7 @@
{% endif %}
<tr>
<th>Total</th>
<td><strong>${{cart.get_subtotal_price_after_discount|floatformat:"2"}}</strong></td>
<td><strong>${{cart.subtotal_price_after_discount}}</strong></td>
</tr>
</table>
</div>

View File

@ -1,6 +1,5 @@
{% extends "base.html" %}
{% load static %}
{% load grind_filter %}
{% block head_title %}Checkout | {% endblock %}
@ -69,7 +68,7 @@
<table class="cart__totals">
<tr>
<td>Subtotal</td>
<td>${{cart.get_total_price|floatformat:"2"}}</td>
<td>${{ cart.subtotal_price }}</td>
</tr>
{% if cart.coupon and cart.coupon.type == 'entire_order' %}
<tr>
@ -79,11 +78,11 @@
{% endif %}
<tr>
<td>Shipping</td>
<td>${{ cart.get_shipping_cost }}</td>
<td>${{ cart.get_shipping_price }}</td>
</tr>
<tr>
<th>Total</th>
<td><strong>${{cart.get_total_price_after_discount|floatformat:"2"}}</strong></td>
<td><strong>${{ cart.total_price }}</strong></td>
</tr>
</table>
</div>

View File

@ -0,0 +1,9 @@
from django import template
from storefront.cart import CartItem
register = template.Library()
@register.filter
def initialize_update_form(item, index):
return item.get_update_form(index)

View File

@ -76,9 +76,9 @@ class CartItemTest(TestCase):
def test_calculates_total_weight(self):
cart_item = CartItem({
'options': {'Grind': 'Whole Beans'},
'variant_pk': self.variant_1.pk,
'quantity': 14,
'variant': self.variant_1
'options': {'Grind': 'Whole Beans'}
})
self.assertEqual(
cart_item.total_price,
@ -87,9 +87,9 @@ class CartItemTest(TestCase):
def test_calculates_total_price(self):
cart_item = CartItem({
'options': {'Grind': 'Whole Beans'},
'variant_pk': self.variant_1.pk,
'quantity': 14,
'variant': self.variant_1
'options': {'Grind': 'Whole Beans'}
})
self.assertEqual(
cart_item.total_weight,
@ -98,6 +98,8 @@ class CartItemTest(TestCase):
class CartTest(TestCase):
fixtures = ['site_settings_and_shipping_rates.json']
@classmethod
def setUpTestData(cls):
cls.customer = User.objects.create_user(
@ -116,20 +118,48 @@ class CartTest(TestCase):
product=cls.product,
name='16 oz',
sku='234987',
price=13.4,
price=Decimal('13.40'),
weight=Weight(oz=16),
)
cls.order = Order.objects.create(
customer=cls.customer,
total_amount=13.4
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'] = {
def test_cart_item_variations(self):
cart_detail_url = reverse('storefront:cart-detail')
response = self.client.get(cart_detail_url, follow=True)
logger.debug(response.context['messages'])
request = response.wsgi_request
cart = Cart(request)
cart.add_item(
CartItem({
'variant_pk': self.variant.pk,
'quantity': 1,
'options': {'Grind': 'Whole Beans'}
})
)
cart.add_item(
CartItem({
'variant_pk': self.variant.pk,
'quantity': 1,
'options': {'Grind': 'Espresso'}
})
)
for item in cart:
self.assertTrue(hasattr(item, 'variant'))
def test_add_item_to_cart(self):
cart_detail_url = reverse('storefront:cart-detail')
response = self.client.get(cart_detail_url)
request = response.wsgi_request
request.session['shipping_address'] = {
'first_name': 'Nathan',
'last_name': 'Chapman',
'email': 'contact@nathanjchapman.com',
@ -139,89 +169,53 @@ class CartTest(TestCase):
'state': 'UT',
'postal_code': '84341'
}
def test_cart_item_variations(self):
cart_detail_url = reverse('storefront:cart-detail')
response = self.client.get(cart_detail_url, follow=True)
logger.debug(response.context['messages'])
request = response.wsgi_request
cart = Cart(request)
cart.add(
request,
item={
'options': {'Grind': 'Whole Beans'},
'price_total': 13.4,
'quantity': 1,
'variant': self.variant.pk
}
)
cart.add(
request,
item={
'options': {'Grind': 'Espresso'},
'price_total': 13.4,
'quantity': 1,
'variant': self.variant.pk
}
)
for item in cart.cart:
self.assertTrue('variant' in item, item)
def test_add_item_to_cart(self):
cart_detail_url = reverse('storefront:cart-detail')
response = self.client.get(cart_detail_url)
request = response.wsgi_request
cart = Cart(request)
cart = Cart(request)
cart.add(
request,
item={
'options': {'Grind': 'Whole Beans'},
'price_total': 13.4,
cart.add_item(
CartItem({
'variant_pk': self.variant.pk,
'quantity': 1,
'variant': self.variant.pk
}
'options': {'Grind': 'Whole Beans'}
})
)
self.assertEqual(
cart.cart[0]['quantity'],
cart.items[0].quantity,
1
)
self.assertEqual(len(cart), 1)
self.assertEqual(sum(cart.get_item_prices()), Decimal('13.4'))
self.assertEqual(cart.get_total_price(), Decimal('13.4'))
cart.add(
request,
item={
'options': {'Grind': 'Whole Beans'},
'price_total': 13.4,
self.assertEqual(cart.total_weight, Weight(lb=1))
self.assertEqual(cart.subtotal_price, Decimal('13.40'))
self.assertEqual(cart.total_price, cart.get_shipping_price() + Decimal('13.40'))
cart.add_item(
CartItem({
'variant_pk': self.variant.pk,
'quantity': 1,
'variant': self.variant.pk
}
'options': {'Grind': 'Whole Beans'}
})
)
self.assertEqual(
cart.cart[0]['quantity'],
cart.items[0].quantity,
2
)
self.assertEqual(len(cart), 2)
cart.add(
request,
item={
'options': {'Grind': 'Espresso'},
'price_total': 40.2,
cart.add_item(
CartItem({
'variant_pk': self.variant.pk,
'quantity': 3,
'variant': self.variant.pk
}
'options': {'Grind': 'Espresso'}
})
)
self.assertEqual(
cart.cart[1]['quantity'],
cart.items[1].quantity,
3
)
self.assertEqual(len(cart), 5)
self.assertEqual(cart.get_total_price(), Decimal('67'))
self.assertEqual(cart.total_weight, Weight(lb=5))
self.assertEqual(cart.subtotal_price, Decimal('67.00'))
self.assertEqual(cart.total_price, cart.get_shipping_price() + Decimal('67.00'))
def test_update_cart_item_quantity(self):
cart_detail_url = reverse('storefront:cart-detail')
@ -230,32 +224,21 @@ class CartTest(TestCase):
cart = Cart(request)
cart = Cart(request)
cart.add(
request,
item={
'options': {'Grind': 'Whole Beans'},
'price_total': 40.2,
cart.add_item(
CartItem({
'variant_pk': self.variant.pk,
'quantity': 3,
'variant': self.variant.pk
}
'options': {'Grind': 'Whole Beans'}
})
)
self.assertEqual(
cart.cart[0]['quantity'],
cart.items[0].quantity,
3
)
cart.add(
request,
item={
'options': {'Grind': 'Whole Beans'},
'price_total': 13.4,
'quantity': 1,
'variant': 0
},
update_quantity=True
)
cart.update_item_quantity(0, 1)
self.assertEqual(
cart.cart[0]['quantity'],
cart.items[0].quantity,
1
)
@ -266,33 +249,29 @@ class CartTest(TestCase):
cart = Cart(request)
cart = Cart(request)
cart.add(
request,
item={
'options': {'Grind': 'Whole Beans'},
'price_total': 40.2,
cart.add_item(
CartItem({
'variant_pk': self.variant.pk,
'quantity': 3,
'variant': self.variant.pk
}
'options': {'Grind': 'Whole Beans'}
})
)
self.assertEqual(len(cart), 3)
cart.remove(0)
cart.remove_item(0)
self.assertEqual(len(cart), 0)
def test_cart_get_total_weight(self):
def test_cart_total_weight(self):
cart_detail_url = reverse('storefront:cart-detail')
response = self.client.get(cart_detail_url)
request = response.wsgi_request
cart = Cart(request)
cart = Cart(request)
cart.add(
request,
item={
'options': {'Grind': 'Whole Beans'},
'price_total': 40.2,
cart.add_item(
CartItem({
'variant_pk': self.variant.pk,
'quantity': 3,
'variant': self.variant.pk
}
'options': {'Grind': 'Whole Beans'}
})
)
self.assertEqual(cart.get_total_weight(), 3)
self.assertEqual(cart.total_weight, Weight(lb=3))

View File

@ -13,7 +13,7 @@ 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
from storefront.payments import CreateOrder
from . import RequestFaker
@ -48,23 +48,19 @@ class CreateOrderTest(TestCase):
request = response.wsgi_request
cart = Cart(request)
cart.add(
request,
item={
cart.add_item(
CartItem({
'variant_pk': self.variant.pk,
'options': {'Grind': 'Whole Beans'},
'price_total': '13.40',
'quantity': 1,
'variant': self.variant.pk
}
'quantity': 1
})
)
cart.add(
request,
item={
cart.add_item(
CartItem({
'variant_pk': self.variant.pk,
'options': {'Grind': 'Percolator'},
'price_total': '26.80',
'quantity': 2,
'variant': self.variant.pk
}
'quantity': 2
})
)
params = {
'items': cart,

View File

@ -13,7 +13,7 @@ from core.models import Product, ProductVariant, Order, Coupon
from core import CoffeeGrind
from storefront.forms import AddressForm, OrderCreateForm
from storefront.views import (
CartView, CartAddProductView, CartUpdateProductView, CouponApplyView,
CartView, CartAddProductView, CartItemUpdateView, CouponApplyView,
ProductListView, ProductDetailView,
CheckoutAddressView, OrderCreateView,
paypal_order_transaction_capture,

View File

@ -29,7 +29,7 @@ urlpatterns = [
),
path(
'cart/<int:pk>/update/',
views.CartUpdateProductView.as_view(),
views.CartItemUpdateView.as_view(),
name='cart-update',
),
path(

View File

@ -44,30 +44,40 @@ from core.forms import ShippingRateForm
from core import OrderStatus, ShippingContainer
from .forms import (
AddToCartForm, UpdateCartItemForm, OrderCreateForm,
AddToCartForm, CartItemUpdateForm, OrderCreateForm,
AddressForm, CouponApplyForm, ContactForm, CheckoutShippingForm,
SubscriptionCreateForm
)
from .cart import Cart
from .cart import CartItem, Cart
from .payments import CaptureOrder
logger = logging.getLogger(__name__)
class CartView(TemplateView):
class CartView(FormView):
template_name = 'storefront/cart_detail.html'
form_class = CartItemUpdateForm
def get_success_url(self):
return reverse('storefront:cart-detail')
def post(self, request, *args, **kwargs):
cart = Cart(request)
form = self.get_form()
if form.is_valid():
cart.update_item_quantity(
form.cleaned_data['item_index'],
form.cleaned_data['quantity']
)
return self.form_valid(form)
else:
return self.form_invalid(form)
def form_valid(self, form):
return super().form_valid(form)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
cart = Cart(self.request)
for i, item in enumerate(cart):
item['update_quantity_form'] = UpdateCartItemForm(
initial={
'item_pk': i,
'quantity': item['quantity']
}
)
context['cart'] = cart
context['coupon_apply_form'] = CouponApplyForm()
return context
@ -97,22 +107,20 @@ class CartAddProductView(SingleObjectMixin, FormView):
form = self.get_form()
if form.is_valid():
cleaned_data = form.cleaned_data
cart.add(
request=request,
item={
'variant': cleaned_data.pop('variant'),
cart.add_item(
CartItem({
'variant_pk': cleaned_data.pop('variant'),
'quantity': cleaned_data.pop('quantity'),
'options': cleaned_data
}
})
)
return self.form_valid(form)
else:
return self.form_invalid(form)
class CartUpdateProductView(SingleObjectMixin, FormView):
model = Product
form_class = UpdateCartItemForm
class CartItemUpdateView(FormView):
form_class = CartItemUpdateForm
http_method_names = ['post']
def get_success_url(self):
@ -122,13 +130,9 @@ class CartUpdateProductView(SingleObjectMixin, FormView):
cart = Cart(request)
form = self.get_form()
if form.is_valid():
cart.add(
request=request,
item={
'variant': form.cleaned_data['item_pk'],
'quantity': form.cleaned_data['quantity']
},
update_quantity=form.cleaned_data['update']
cart.update_item_quantity(
form.cleaned_data['item_index'],
form.cleaned_data['quantity']
)
return self.form_valid(form)
else:
@ -140,7 +144,7 @@ class CartUpdateProductView(SingleObjectMixin, FormView):
def cart_remove_product_view(request, pk):
cart = Cart(request)
cart.remove(pk)
cart.remove_item(pk)
return redirect('storefront:cart-detail')
@ -151,17 +155,16 @@ class CouponApplyView(FormView):
def form_valid(self, form):
today = timezone.localtime(timezone.now()).date()
code = form.cleaned_data['code'].upper()
try:
coupon = Coupon.objects.get(
code__iexact=code,
valid_from__date__lte=today,
valid_to__date__gte=today
)
coupon = Coupon.objects.get(code__iexact=form.cleaned_data['code'])
except Coupon.DoesNotExist:
messages(self.request, 'Coupon does not exist.')
else:
if coupon.is_valid:
self.request.session['coupon_code'] = coupon.code
except ObjectDoesNotExist:
self.request.session['coupon_code'] = None
cart = Cart(self.request)
cart.add_coupon(coupon)
else:
messages.warning(self.request, 'Coupon is invalid.')
return super().form_valid(form)
@ -226,7 +229,7 @@ class ProductDetailView(FormMixin, DetailView):
class CheckoutAddressView(FormView):
template_name = 'storefront/checkout_address.html'
form_class = AddressForm
success_url = reverse_lazy('storefront:checkout-shipping')
success_url = reverse_lazy('storefront:order-create')
def get_initial(self):
user = self.request.user
@ -284,7 +287,7 @@ class CheckoutShippingView(FormView):
def get_containers(self, request):
if self.containers is None:
cart = Cart(request)
self.containers = cart.get_shipping_container_choices()
self.containers = cart.get_shipping_container()
return self.containers
def get(self, request, *args, **kwargs):
@ -297,20 +300,16 @@ class CheckoutShippingView(FormView):
cart = Cart(self.request)
if len(self.get_containers(request)) == 0:
self.request.session['shipping_container'] = site_settings.default_shipping_rate
return HttpResponseRedirect(
reverse('storefront:order-create')
)
return HttpResponseRedirect(self.success_url)
elif len(self.get_containers(request)) == 1:
self.request.session['shipping_container'] = self.get_containers(request)[0]
return HttpResponseRedirect(
reverse('storefront:order-create')
)
return HttpResponseRedirect(self.success_url)
return super().get(request, *args, **kwargs)
def get_form(self, form_class=None):
cart = Cart(self.request)
for container in self.get_containers(self.request):
container.s_cost = cart.get_shipping_cost(container.container)
container.s_cost = cart.get_shipping_price(container.container)
if form_class is None:
form_class = self.get_form_class()
return form_class(self.get_containers(self.request), **self.get_form_kwargs())
@ -335,42 +334,21 @@ class OrderCreateView(CreateView):
return HttpResponseRedirect(
reverse('storefront:checkout-address')
)
elif self.request.session.get('coupon_code'):
address = self.request.session.get('shipping_address')
coupon = Coupon.objects.get(
code=self.request.session.get('coupon_code')
)
cart = Cart(request)
if cart.coupon is not None:
try:
user = User.objects.get(email=address['email'])
except ObjectDoesNotExist:
user = User.objects.get(
email=request.session.get('shipping_address').get('email')
)
except User.DoesNotExist:
user = None
if user in coupon.users.all():
del self.request.session['coupon_code']
if user in cart.coupon.users.all():
cart.remove_coupon()
messages.warning(request, 'Coupon already used.')
return super().get(request, *args, **kwargs)
def get_initial(self):
cart = Cart(self.request)
shipping_container = self.request.session.get(
'shipping_container'
).container
shipping_cost = cart.get_shipping_cost(shipping_container)
initial = {
'total_amount': cart.get_total_price(),
'shipping_total': shipping_cost
}
if self.request.session.get('shipping_address'):
a = self.request.session.get('shipping_address')
user_info = {
'email': a['email'],
'first_name': a['first_name'],
'last_name': a['last_name']
}
initial |= user_info
return initial
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['shipping_address'] = self.request.session.get('shipping_address')
@ -379,19 +357,21 @@ class OrderCreateView(CreateView):
def form_valid(self, form):
cart = Cart(self.request)
form.instance.subtotal_amount = cart.get_subtotal_price_after_discount()
form.instance.coupon_amount = cart.get_discount()
form.instance.total_amount = cart.get_total_price_after_discount()
form.instance.weight = cart.get_total_weight()
form.instance.subtotal_amount = cart.subtotal_price_after_discount
form.instance.coupon = cart.coupon
form.instance.coupon_amount = cart.discount_amount
form.instance.total_amount = cart.total_price
form.instance.weight = cart.total_weight
shipping_container = cart.get_shipping_container()
form.instance.shipping_total = cart.get_shipping_price(shipping_container)
shipping_address = self.request.session.get('shipping_address')
shipping_container = self.request.session.get('shipping_container').container
form.instance.customer, form.instance.shipping_address = get_or_create_customer(self.request, form, shipping_address)
form.instance.status = OrderStatus.DRAFT
self.object = form.save()
bulk_list = cart.build_bulk_list(self.object)
objs = OrderLine.objects.bulk_create(bulk_list)
response = cart.create_order(shipping_container)
response = cart.create_order()
data = response.result.__dict__['_dict']
self.request.session['order_id'] = self.object.pk