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.xps import get_data, get_quote 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 self.total_weight <= Weight(lb=0): return Decimal('0.00') if len(self) > 0 and self.session.get('shipping_address'): postal_code = str(self.session.get('shipping_address')['postal_code']) container = "box_a" if self.total_weight <= Weight(lb=3): container = "box_a" elif self.total_weight <= Weight(lb=6): container = "box_b" else: container = "large_flat_rate_box" shipping_price = get_quote(postal_code, str(self.total_weight.lb), container) return Decimal(shipping_price) 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]))