358 lines
12 KiB
Python
358 lines
12 KiB
Python
import logging
|
||
from decimal import Decimal
|
||
|
||
from measurement.measures import Weight
|
||
from django.conf import settings
|
||
from django.contrib import messages
|
||
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, ObjectDoesNotExist
|
||
|
||
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
|
||
)
|
||
from core.shipping import build_usps_rate_request
|
||
from .forms import CartItemUpdateForm
|
||
from .payments import CreateOrder
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class CartItem:
|
||
update_form = CartItemUpdateForm
|
||
order_line_class = OrderLine
|
||
variant = None
|
||
quantity = None
|
||
options = None
|
||
|
||
def __init__(self, item):
|
||
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_index': index,
|
||
'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)
|
||
|
||
|
||
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()
|
||
if self.session.get('cart'):
|
||
self.deserialize(self.session.get('cart'))
|
||
self.save()
|
||
|
||
def __iter__(self):
|
||
for item in self.items:
|
||
yield item
|
||
|
||
def __len__(self):
|
||
return sum([item.quantity for item in self])
|
||
|
||
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):
|
||
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.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 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 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 clear(self):
|
||
del self.session['cart']
|
||
self.session.modified = True
|
||
|
||
def add_item(self, new_item):
|
||
for item in self:
|
||
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 update_item_quantity(self, item_index, quantity):
|
||
self.items[item_index].quantity = quantity
|
||
self.save()
|
||
|
||
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):
|
||
coupon_variants = self.coupon.variants.all()
|
||
for item in self:
|
||
if item.variant in coupon_variants:
|
||
yield item.total_price
|
||
|
||
def get_shipping_container_choices(self, total_weight=None):
|
||
if total_weight is None:
|
||
total_weight = self.total_weight
|
||
|
||
is_selectable = Q(
|
||
is_selectable=True
|
||
)
|
||
min_weight_matched = Q(
|
||
min_order_weight__lte=total_weight) | Q(
|
||
min_order_weight__isnull=True
|
||
)
|
||
max_weight_matched = Q(
|
||
max_order_weight__gte=total_weight) | Q(
|
||
max_order_weight__isnull=True
|
||
)
|
||
containers = ShippingRate.objects.filter(
|
||
is_selectable & min_weight_matched & max_weight_matched
|
||
)
|
||
return containers
|
||
|
||
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.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.total_weight.lb),
|
||
container,
|
||
str(self.session.get('shipping_address')['postal_code'])
|
||
)
|
||
|
||
usps = USPSApi(self.site_settings.usps_user_id, test=settings.DEBUG)
|
||
|
||
try:
|
||
validation = usps.get_rate(usps_rate_request)
|
||
except ConnectionError as e:
|
||
raise e(
|
||
'Could not connect to USPS, try again.'
|
||
)
|
||
|
||
logger.info(validation.result)
|
||
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:
|
||
raise ShippingAddressError(
|
||
'Could not retrieve shipping address.'
|
||
)
|
||
|
||
def create_order(self):
|
||
params = self.build_order_params()
|
||
logger.info(f'\nParams: {params}\n')
|
||
response = CreateOrder().create_order(params, debug=settings.DEBUG)
|
||
return response
|
||
|
||
def get_address_as_dict(self, address=None):
|
||
if address is None:
|
||
address = self.session.get('shipping_address')
|
||
|
||
return \
|
||
{
|
||
'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 item_variant_pks(self):
|
||
return [item.variant.pk for item in self]
|
||
|
||
@property
|
||
def subtotal_price(self):
|
||
return sum([item.total_price for item in self])
|
||
|
||
@property
|
||
def discount_amount(self):
|
||
if self.coupon is not None:
|
||
if self.coupon.discount_value_type == DiscountValueType.FIXED:
|
||
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.00')) * self.subtotal_price, 2)
|
||
elif self.coupon.type == VoucherType.SPECIFIC_PRODUCT:
|
||
# Get the product in cart quantity
|
||
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')
|
||
|
||
@property
|
||
def subtotal_price_after_discount(self):
|
||
return self.subtotal_price - self.discount_amount
|
||
|
||
@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]))
|