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 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 ) from .forms import UpdateCartItemForm from .payments import CreateOrder logger = logging.getLogger(__name__) class CartItem: update_form = UpdateCartItemForm def __init__(self, item): self.variant = item['variant'] self.quantity = item['quantity'] self.options = item['options'] def get_update_form(self, index): return self.update_form(initial={ 'item_pk': 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) 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 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) 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'] yield item def __len__(self): return sum([item['quantity'] for item in self.cart]) def get_item_prices_for_category(self, category): for item in self: if item['variant'].product.category == category: yield item['price_total'] else: continue def get_total_price_for_category(self, category): return sum(self.get_item_prices_for_category(category)) def get_all_item_quantities(self): for item in self.cart: yield item['quantity'] def get_single_item_total_quantity(self, item): return sum([value['quantity'] for value in item['variations'].values()]) def get_item_prices(self): for item in self: yield item['price_total'] def get_total_price(self): return sum(self.get_item_prices()) def get_weight_for_all_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 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__isnull=True ) max_weight_matched = Q( max_order_weight__gte=self.get_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_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') if container is None: container = self.session.get('shipping_container').container if len(self) > 0 and self.session.get('shipping_address'): usps_rate_request = build_usps_rate_request( str(self.get_total_weight()), container, str(self.session.get('shipping_address')['postal_code']) ) usps = USPSApi(settings.USPS_USER_ID, test=True) 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 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) logger.info(f'\nParams: {params}\n') if settings.DEBUG: response = CreateOrder().create_order(params, debug=True) else: response = CreateOrder().create_order(params) return response def get_line_options(self, options_dict): options = '' for key, value in options_dict.items(): options += f'{key}: {value}; ' return options 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"]}', 'country_code': 'US' } @property def coupon(self): if self.coupon_code: return Coupon.objects.get(code=self.coupon_code) return None 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'] def get_discount(self): # SHIPPING # ENTIRE_ORDER # SPECIFIC_PRODUCT if self.coupon: if self.coupon.discount_value_type == DiscountValueType.FIXED: return round(self.coupon.discount_value, 2) 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) 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') def get_subtotal_price_after_discount(self): return round(self.get_total_price() - self.get_discount(), 2) def get_total_price_after_discount(self): return round(self.get_total_price() - self.get_discount() + self.get_shipping_cost(), 2)