2023-06-26 07:39:41 -06:00

368 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
elif item.quantity > item.variant.product.checkout_limit:
messages.warning(self.request, 'Quantity exceeds checkout limit.')
item.quantity = item.variant.product.checkout_limit
elif item.variant.order_limit and (item.quantity > item.variant.order_limit):
messages.warning(self.request, 'Quantity exceeds order limit.')
item.quantity = item.variant.order_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 get_item_by_pk(self, pk):
return next((i, v) for i, v in enumerate(self) if v.variant.pk == pk)
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:
logger.warning(validation.result)
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]))