685 lines
20 KiB
Python
685 lines
20 KiB
Python
import logging
|
||
import json
|
||
from decimal import Decimal
|
||
from PIL import Image
|
||
from measurement.measures import Weight
|
||
|
||
from django.db import models
|
||
from django.db.models.functions import Coalesce
|
||
from django.conf import settings
|
||
from django.utils import timezone
|
||
from django.urls import reverse
|
||
from django.core.cache import cache
|
||
from django.core.validators import MinValueValidator, MaxValueValidator
|
||
from django.core.serializers.json import DjangoJSONEncoder
|
||
from django.contrib.postgres.fields import ArrayField, HStoreField
|
||
from django.forms.models import model_to_dict
|
||
|
||
from django_measurement.models import MeasurementField
|
||
from localflavor.us.us_states import USPS_CHOICES
|
||
|
||
from accounts.models import User
|
||
|
||
from . import (
|
||
DiscountValueType,
|
||
VoucherType,
|
||
TransactionStatus,
|
||
OrderStatus,
|
||
ShippingStatus,
|
||
ShippingProvider,
|
||
ShippingContainer
|
||
)
|
||
from .weight import WeightUnits, zero_weight
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class SingletonBase(models.Model):
|
||
def set_cache(self):
|
||
cache.set(self.__class__.__name__, self)
|
||
|
||
def save(self, *args, **kwargs):
|
||
self.pk = 1
|
||
super(SingletonBase, self).save(*args, **kwargs)
|
||
self.set_cache()
|
||
|
||
def delete(self, *args, **kwargs):
|
||
pass
|
||
|
||
@classmethod
|
||
def load(cls):
|
||
if cache.get(cls.__name__) is None:
|
||
obj, created = cls.objects.get_or_create(pk=1)
|
||
if not created:
|
||
obj.set_cache()
|
||
return cache.get(cls.__name__)
|
||
|
||
class Meta:
|
||
abstract = True
|
||
|
||
|
||
class ProductCategory(models.Model):
|
||
name = models.CharField(max_length=255)
|
||
main_category = models.BooleanField(default=True)
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
def get_absolute_url(self):
|
||
return reverse('dashboard:category-detail', kwargs={'pk': self.pk})
|
||
|
||
class Meta:
|
||
verbose_name = 'Product Category'
|
||
verbose_name_plural = 'Product Categories'
|
||
|
||
|
||
class ProductManager(models.Manager):
|
||
def get_queryset(self):
|
||
return super().get_queryset().prefetch_related(
|
||
'productphoto_set',
|
||
'options'
|
||
)
|
||
|
||
|
||
class Product(models.Model):
|
||
category = models.ForeignKey(
|
||
ProductCategory,
|
||
blank=True,
|
||
null=True,
|
||
on_delete=models.SET_NULL
|
||
)
|
||
name = models.CharField(max_length=250)
|
||
subtitle = models.CharField(max_length=250, blank=True)
|
||
description = models.TextField(blank=True)
|
||
checkout_limit = models.IntegerField(
|
||
default=0,
|
||
validators=[MinValueValidator(0)]
|
||
)
|
||
|
||
visible_in_listings = models.BooleanField(default=False)
|
||
sorting = models.PositiveIntegerField(blank=True, null=True)
|
||
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
updated_at = models.DateTimeField(auto_now=True)
|
||
|
||
objects = ProductManager()
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
def get_absolute_url(self):
|
||
return reverse('dashboard:product-detail', kwargs={'pk': self.pk})
|
||
|
||
def get_first_img(self):
|
||
return self.productphoto_set.first()
|
||
|
||
def get_second_img(self):
|
||
try:
|
||
return self.productphoto_set.all()[1]
|
||
except IndexError:
|
||
return 'No image'
|
||
|
||
class Meta:
|
||
ordering = ['sorting', 'name']
|
||
|
||
|
||
class ProductPhoto(models.Model):
|
||
product = models.ForeignKey(Product, on_delete=models.CASCADE)
|
||
image = models.ImageField(upload_to='products/images')
|
||
sorting = models.PositiveIntegerField(blank=True, null=True)
|
||
|
||
def __str__(self):
|
||
return f'{self.product.name} {self.image}'
|
||
|
||
def delete(self, *args, **kwargs):
|
||
storage, path = self.image.storage, self.image.path
|
||
|
||
super(ProductPhoto, self).delete(*args, **kwargs)
|
||
storage.delete(path)
|
||
|
||
class Meta:
|
||
ordering = ['sorting']
|
||
|
||
# def save(self, *args, **kwargs):
|
||
# super().save(*args, **kwargs)
|
||
|
||
# img = Image.open(self.image.path)
|
||
# if img.height > 400 or img.width > 400:
|
||
# output_size = (400, 400)
|
||
# img.thumbnail(output_size)
|
||
# img.save(self.image.path)
|
||
|
||
|
||
class ProductVariantManager(models.Manager):
|
||
def get_queryset(self):
|
||
return super().get_queryset().annotate(
|
||
num_ordered=models.Sum('order_lines__quantity')
|
||
)
|
||
|
||
|
||
class ProductVariant(models.Model):
|
||
product = models.ForeignKey(
|
||
Product,
|
||
on_delete=models.CASCADE,
|
||
related_name='variants'
|
||
)
|
||
image = models.ForeignKey(
|
||
ProductPhoto,
|
||
on_delete=models.SET_NULL,
|
||
related_name='+',
|
||
blank=True,
|
||
null=True
|
||
)
|
||
name = models.CharField(max_length=255)
|
||
sku = models.CharField(max_length=255, blank=True)
|
||
price = models.DecimalField(
|
||
max_digits=settings.DEFAULT_MAX_DIGITS,
|
||
decimal_places=settings.DEFAULT_DECIMAL_PLACES,
|
||
blank=True,
|
||
null=True,
|
||
)
|
||
weight = MeasurementField(
|
||
measurement=Weight,
|
||
unit_choices=WeightUnits.CHOICES,
|
||
default=zero_weight,
|
||
blank=True,
|
||
null=True
|
||
)
|
||
visible_in_listings = models.BooleanField(default=True)
|
||
track_inventory = models.BooleanField(default=False)
|
||
stock = models.IntegerField(
|
||
blank=True,
|
||
null=True,
|
||
validators=[MinValueValidator(0)]
|
||
)
|
||
sorting = models.PositiveIntegerField(blank=True, null=True)
|
||
order_limit = models.PositiveIntegerField(blank=True, null=True)
|
||
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
updated_at = models.DateTimeField(auto_now=True)
|
||
|
||
#objects = ProductVariantManager()
|
||
|
||
def __str__(self):
|
||
return f'{self.product}: {self.name}'
|
||
|
||
class Meta:
|
||
ordering = ['sorting', 'weight']
|
||
|
||
|
||
class ProductOption(models.Model):
|
||
"""
|
||
Description: Consistent accross all variants
|
||
"""
|
||
products = models.ManyToManyField(
|
||
Product,
|
||
related_name='options'
|
||
)
|
||
name = models.CharField(max_length=255)
|
||
options = ArrayField(
|
||
models.CharField(max_length=255)
|
||
)
|
||
|
||
def get_absolute_url(self):
|
||
return reverse('dashboard:option-detail', kwargs={'pk': self.pk})
|
||
|
||
def __str__(self):
|
||
return f'{self.name}'
|
||
|
||
|
||
class Coupon(models.Model):
|
||
type = models.CharField(
|
||
max_length=20,
|
||
choices=VoucherType.CHOICES,
|
||
default=VoucherType.ENTIRE_ORDER
|
||
)
|
||
name = models.CharField(max_length=255, null=True, blank=True)
|
||
code = models.CharField(max_length=12, unique=True, db_index=True)
|
||
valid_from = models.DateTimeField(default=timezone.now)
|
||
valid_to = models.DateTimeField(null=True, blank=True)
|
||
|
||
discount_value_type = models.CharField(
|
||
max_length=10,
|
||
choices=DiscountValueType.CHOICES,
|
||
default=DiscountValueType.FIXED,
|
||
)
|
||
discount_value = models.DecimalField(
|
||
max_digits=settings.DEFAULT_MAX_DIGITS,
|
||
decimal_places=settings.DEFAULT_DECIMAL_PLACES,
|
||
)
|
||
|
||
variants = models.ManyToManyField(ProductVariant, blank=True)
|
||
users = models.ManyToManyField(User, blank=True)
|
||
|
||
class Meta:
|
||
ordering = ['-valid_from', '-valid_to', 'code']
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
@property
|
||
def is_valid(self):
|
||
today = timezone.localtime(timezone.now())
|
||
return True if today >= self.valid_from and today <= self.valid_to else False
|
||
|
||
def get_absolute_url(self):
|
||
return reverse('dashboard:coupon-detail', kwargs={'pk': self.pk})
|
||
|
||
|
||
class ShippingRate(models.Model):
|
||
shipping_provider = models.CharField(
|
||
max_length=255,
|
||
choices=ShippingProvider.CHOICES,
|
||
default=ShippingProvider.USPS
|
||
)
|
||
name = models.CharField(max_length=255)
|
||
container = models.CharField(
|
||
max_length=255,
|
||
choices=ShippingContainer.CHOICES,
|
||
default=ShippingContainer.VARIABLE
|
||
)
|
||
min_order_weight = MeasurementField(
|
||
measurement=Weight,
|
||
unit_choices=WeightUnits.CHOICES,
|
||
default=zero_weight,
|
||
blank=True,
|
||
null=True
|
||
)
|
||
max_order_weight = MeasurementField(
|
||
measurement=Weight,
|
||
unit_choices=WeightUnits.CHOICES,
|
||
default=zero_weight,
|
||
blank=True,
|
||
null=True
|
||
)
|
||
is_selectable = models.BooleanField(default=True)
|
||
|
||
def get_absolute_url(self):
|
||
return reverse('dashboard:rate-detail', kwargs={'pk': self.pk})
|
||
|
||
def __str__(self):
|
||
return f'{self.shipping_provider}: {self.name} ({self.min_order_weight}–{self.max_order_weight})'
|
||
|
||
class Meta:
|
||
ordering = ['min_order_weight']
|
||
|
||
|
||
class OrderManager(models.Manager):
|
||
def with_lines(self):
|
||
return self.select_related('lines')
|
||
|
||
def without_drafts(self):
|
||
return self.exclude(
|
||
status=OrderStatus.DRAFT
|
||
)
|
||
|
||
def with_fulfillment(self):
|
||
return self.annotate(
|
||
total_quantity_fulfilled=models.Sum('lines__quantity_fulfilled'),
|
||
total_quantity_ordered=models.Sum('lines__quantity')
|
||
)
|
||
|
||
def with_fulfillment_and_filter(self, query=None):
|
||
return self.annotate(
|
||
total_quantity_fulfilled=models.Sum('lines__quantity_fulfilled'),
|
||
total_quantity_ordered=models.Sum('lines__quantity')
|
||
).filter(pk=query)
|
||
|
||
|
||
class Order(models.Model):
|
||
customer = models.ForeignKey(
|
||
User,
|
||
related_name='orders',
|
||
on_delete=models.SET_NULL,
|
||
null=True
|
||
)
|
||
status = models.CharField(
|
||
max_length=32,
|
||
default=OrderStatus.UNFULFILLED,
|
||
choices=OrderStatus.CHOICES
|
||
)
|
||
# Shipping address
|
||
shipping_first_name = models.CharField(max_length=256, blank=True)
|
||
shipping_last_name = models.CharField(max_length=256, blank=True)
|
||
shipping_street_address_1 = models.CharField(max_length=256, blank=True)
|
||
shipping_street_address_2 = models.CharField(max_length=256, blank=True)
|
||
shipping_city = models.CharField(max_length=256, blank=True)
|
||
shipping_state = models.CharField(
|
||
max_length=2,
|
||
choices=USPS_CHOICES,
|
||
blank=True
|
||
)
|
||
shipping_postal_code = models.CharField(max_length=20, blank=True)
|
||
coupon = models.ForeignKey(
|
||
Coupon,
|
||
related_name='orders',
|
||
on_delete=models.SET_NULL,
|
||
blank=True,
|
||
null=True
|
||
)
|
||
subtotal_amount = models.DecimalField(
|
||
max_digits=10,
|
||
decimal_places=2,
|
||
default=0
|
||
)
|
||
coupon_amount = models.DecimalField(
|
||
max_digits=10,
|
||
decimal_places=2,
|
||
default=0
|
||
)
|
||
shipping_total = models.DecimalField(
|
||
max_digits=5,
|
||
decimal_places=2,
|
||
default=0
|
||
)
|
||
total_amount = models.DecimalField(
|
||
max_digits=10,
|
||
decimal_places=2,
|
||
default=0
|
||
)
|
||
weight = MeasurementField(
|
||
measurement=Weight,
|
||
unit_choices=WeightUnits.CHOICES,
|
||
default=zero_weight,
|
||
blank=True,
|
||
null=True
|
||
)
|
||
subscription = models.ForeignKey(
|
||
'Subscription',
|
||
related_name='orders',
|
||
editable=False,
|
||
null=True,
|
||
on_delete=models.SET_NULL
|
||
)
|
||
subscription_description = models.CharField(max_length=500, blank=True)
|
||
|
||
created_at = models.DateTimeField(auto_now_add=True, editable=False)
|
||
updated_at = models.DateTimeField(auto_now=True)
|
||
|
||
objects = OrderManager()
|
||
|
||
def get_shipping_status(self):
|
||
has_tracking = self.tracking_numbers.count() > 0
|
||
if has_tracking and self.status == OrderStatus.FULFILLED:
|
||
return ShippingStatus.SHIPPED
|
||
elif has_tracking and self.status == OrderStatus.PARTIALLY_FULFILLED:
|
||
return ShippingStatus.PARTIALLY_SHIPPED
|
||
|
||
return ShippingStatus.NOT_SHIPPED
|
||
|
||
def minus_stock(self):
|
||
for line in self.lines.all():
|
||
line.minus_stock()
|
||
|
||
def add_stock(self):
|
||
for line in self.lines.all():
|
||
line.add_stock()
|
||
|
||
def get_total_quantity(self):
|
||
return sum([line.quantity for line in self])
|
||
|
||
def get_discount(self):
|
||
if self.coupon:
|
||
if self.coupon.discount_value_type == DiscountValueType.FIXED:
|
||
return self.coupon.discount_value
|
||
elif self.coupon.discount_value_type == DiscountValueType.PERCENTAGE:
|
||
return (self.coupon.discount_value / Decimal('100')) * self.subtotal_amount
|
||
return Decimal('0')
|
||
|
||
def get_total_price_after_discount(self):
|
||
return round((self.subtotal_amount - self.get_discount()) + self.shipping_total, 2)
|
||
|
||
def get_absolute_url(self):
|
||
return reverse('dashboard:order-detail', kwargs={'pk': self.pk})
|
||
|
||
class Meta:
|
||
ordering = ['-created_at']
|
||
permissions = [
|
||
('cancel_order', 'Can cancel order'),
|
||
]
|
||
|
||
|
||
class Transaction(models.Model):
|
||
status = models.CharField(
|
||
max_length=32,
|
||
blank=True,
|
||
default=TransactionStatus.CREATED,
|
||
choices=TransactionStatus.CHOICES
|
||
)
|
||
paypal_id = models.CharField(max_length=64, blank=True)
|
||
confirmation_email_sent = models.BooleanField(default=False)
|
||
order = models.OneToOneField(
|
||
Order,
|
||
editable=False,
|
||
blank=True,
|
||
null=True,
|
||
on_delete=models.CASCADE
|
||
)
|
||
|
||
|
||
class OrderLine(models.Model):
|
||
order = models.ForeignKey(
|
||
Order,
|
||
related_name='lines',
|
||
editable=False,
|
||
on_delete=models.CASCADE
|
||
)
|
||
product = models.ForeignKey(
|
||
Product,
|
||
related_name='order_lines',
|
||
on_delete=models.SET_NULL,
|
||
blank=True,
|
||
null=True,
|
||
)
|
||
variant = models.ForeignKey(
|
||
ProductVariant,
|
||
related_name='order_lines',
|
||
on_delete=models.SET_NULL,
|
||
blank=True,
|
||
null=True,
|
||
)
|
||
quantity = models.IntegerField(validators=[MinValueValidator(1)])
|
||
quantity_fulfilled = models.IntegerField(
|
||
validators=[MinValueValidator(0)], default=0
|
||
)
|
||
customer_note = models.TextField(blank=True, default='')
|
||
currency = models.CharField(
|
||
max_length=settings.DEFAULT_CURRENCY_CODE_LENGTH,
|
||
default=settings.DEFAULT_CURRENCY,
|
||
)
|
||
unit_price = models.DecimalField(
|
||
max_digits=settings.DEFAULT_MAX_DIGITS,
|
||
decimal_places=settings.DEFAULT_DECIMAL_PLACES,
|
||
)
|
||
|
||
def get_total(self):
|
||
return self.unit_price * self.quantity
|
||
|
||
@property
|
||
def quantity_unfulfilled(self):
|
||
return self.quantity - self.quantity_fulfilled
|
||
|
||
def minus_stock(self):
|
||
if self.variant and self.variant.track_inventory:
|
||
self.variant.stock -= self.quantity
|
||
self.variant.save()
|
||
|
||
def add_stock(self):
|
||
if self.variant and self.variant.track_inventory:
|
||
self.variant.stock += self.quantity
|
||
self.variant.save()
|
||
|
||
|
||
class TrackingNumber(models.Model):
|
||
order = models.ForeignKey(
|
||
Order,
|
||
related_name="tracking_numbers",
|
||
editable=False,
|
||
on_delete=models.CASCADE
|
||
)
|
||
tracking_id = models.CharField(max_length=256)
|
||
|
||
created_at = models.DateTimeField(auto_now_add=True, editable=False)
|
||
updated_at = models.DateTimeField(auto_now=True)
|
||
|
||
class Meta:
|
||
verbose_name = 'Tracking Number'
|
||
verbose_name_plural = 'Tracking Numbers'
|
||
|
||
def __str__(self):
|
||
return self.tracking_id
|
||
|
||
|
||
class SubscriptionManager(models.Manager):
|
||
def get_queryset(self):
|
||
return super().get_queryset().filter(is_active=True)
|
||
|
||
|
||
class Subscription(models.Model):
|
||
stripe_id = models.CharField(max_length=255, blank=True, db_index=True)
|
||
customer = models.ForeignKey(
|
||
User,
|
||
related_name='subscriptions',
|
||
on_delete=models.SET_NULL,
|
||
null=True
|
||
)
|
||
# Shipping address
|
||
shipping_first_name = models.CharField(max_length=256, blank=True)
|
||
shipping_last_name = models.CharField(max_length=256, blank=True)
|
||
shipping_street_address_1 = models.CharField(max_length=256, blank=True)
|
||
shipping_street_address_2 = models.CharField(max_length=256, blank=True)
|
||
shipping_city = models.CharField(max_length=256, blank=True)
|
||
shipping_state = models.CharField(
|
||
max_length=2,
|
||
choices=USPS_CHOICES,
|
||
blank=True
|
||
)
|
||
shipping_postal_code = models.CharField(max_length=20, blank=True)
|
||
items = ArrayField(
|
||
models.JSONField(blank=True, null=True),
|
||
default=list
|
||
)
|
||
metadata = models.JSONField(blank=True, null=True)
|
||
is_active = models.BooleanField(default=False)
|
||
total_weight = MeasurementField(
|
||
measurement=Weight,
|
||
unit_choices=WeightUnits.CHOICES,
|
||
default=zero_weight,
|
||
blank=True,
|
||
null=True
|
||
)
|
||
|
||
created_at = models.DateTimeField(auto_now_add=True, editable=False)
|
||
updated_at = models.DateTimeField(auto_now=True)
|
||
|
||
def convert_int_to_decimal(self, price):
|
||
return Decimal(str(price)[:-2] + '.' + str(price)[-2:])
|
||
|
||
def format_product(self, data):
|
||
return {
|
||
'product': Product.objects.get(pk=data['pk']),
|
||
'quantity': data['quantity']
|
||
}
|
||
|
||
def deserialize_subscription(self, data):
|
||
subscription = {}
|
||
|
||
for x in data:
|
||
if 'Coffee' in x['description']:
|
||
subscription['unit_price'] = self.convert_int_to_decimal(x['price']['unit_amount'])
|
||
subscription['description'] = x['description']
|
||
|
||
if 'Shipping' in x['description']:
|
||
subscription['shipping_cost'] = self.convert_int_to_decimal(x['amount'])
|
||
|
||
return subscription
|
||
|
||
def create_order(self, data_object):
|
||
subscription = self.deserialize_subscription(data_object['lines']['data'])
|
||
subscription['items'] = map(self.format_product, self.metadata['products_and_quantities'])
|
||
subscription['customer_note'] = f"Grind: {self.metadata['grind']}"
|
||
|
||
order = Order.objects.create(
|
||
customer=self.customer,
|
||
status=OrderStatus.UNFULFILLED,
|
||
shipping_address=self.shipping_address,
|
||
subtotal_amount=self.convert_int_to_decimal(
|
||
data_object['subtotal']) - subscription['shipping_cost'],
|
||
shipping_total=subscription['shipping_cost'],
|
||
total_amount=self.convert_int_to_decimal(data_object['total']),
|
||
weight=self.total_weight,
|
||
subscription=self,
|
||
subscription_description=subscription['description']
|
||
)
|
||
|
||
bulk_lines = [OrderLine(
|
||
order=order,
|
||
product=item['product'],
|
||
quantity=item['quantity'],
|
||
customer_note=subscription['customer_note'],
|
||
unit_price=subscription['unit_price']
|
||
) for item in subscription['items']]
|
||
OrderLine.objects.bulk_create(bulk_lines)
|
||
|
||
def format_metadata(self):
|
||
metadata = {}
|
||
for key, value in self.metadata.items():
|
||
if 'products_and_quantities' in key:
|
||
metadata[key] = json.dumps(value)
|
||
else:
|
||
metadata[key] = value
|
||
metadata['subscription_pk'] = self.pk
|
||
return metadata
|
||
|
||
def get_absolute_url(self):
|
||
return reverse('storefront:subscription-detail', kwargs={'pk': self.pk})
|
||
|
||
class Meta:
|
||
indexes = [
|
||
models.Index(fields=['stripe_id'])
|
||
]
|
||
|
||
|
||
class SiteSettings(SingletonBase):
|
||
usps_user_id = models.CharField(max_length=255)
|
||
default_shipping_rate = models.ForeignKey(
|
||
ShippingRate,
|
||
blank=True,
|
||
null=True,
|
||
related_name='+',
|
||
on_delete=models.SET_NULL
|
||
)
|
||
free_shipping_min = models.DecimalField(
|
||
max_digits=settings.DEFAULT_MAX_DIGITS,
|
||
decimal_places=settings.DEFAULT_DECIMAL_PLACES,
|
||
blank=True,
|
||
null=True,
|
||
help_text='Minimum dollar amount in the cart subtotal to qualify for free shipping.'
|
||
)
|
||
max_cart_quantity = models.PositiveIntegerField(
|
||
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.'
|
||
)
|
||
default_contact_email = models.CharField(max_length=255, blank=True)
|
||
order_from_email = models.CharField(max_length=255, blank=True)
|
||
default_zip_origination = models.CharField(
|
||
max_length=5, blank=True, default='98368'
|
||
)
|
||
|
||
def __str__(self):
|
||
return 'Site Settings'
|
||
|
||
class Meta:
|
||
verbose_name = 'Site Settings'
|
||
verbose_name_plural = 'Site Settings'
|