529 lines
14 KiB
Python
529 lines
14 KiB
Python
import logging
|
||
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 accounts.models import User, Address
|
||
|
||
from . import (
|
||
DiscountValueType,
|
||
VoucherType,
|
||
TransactionStatus,
|
||
OrderStatus,
|
||
ShippingProvider,
|
||
ShippingContainer,
|
||
build_usps_rate_request
|
||
)
|
||
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')
|
||
|
||
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)
|
||
|
||
# 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)
|
||
stripe_id = 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,
|
||
blank=True,
|
||
null=True
|
||
)
|
||
track_inventory = models.BooleanField(default=False)
|
||
stock = models.IntegerField(
|
||
blank=True,
|
||
null=True,
|
||
validators=[MinValueValidator(0)]
|
||
)
|
||
sorting = 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,
|
||
)
|
||
|
||
products = models.ManyToManyField(Product, 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 = models.PositiveIntegerField(
|
||
blank=True,
|
||
null=True
|
||
)
|
||
max_order_weight = models.PositiveIntegerField(
|
||
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
|
||
)
|
||
billing_address = models.ForeignKey(
|
||
Address,
|
||
related_name="+",
|
||
editable=False,
|
||
null=True,
|
||
on_delete=models.SET_NULL
|
||
)
|
||
shipping_address = models.ForeignKey(
|
||
Address,
|
||
related_name="+",
|
||
editable=False,
|
||
null=True,
|
||
on_delete=models.SET_NULL
|
||
)
|
||
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.CharField(max_length=255, blank=True)
|
||
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
|
||
)
|
||
|
||
created_at = models.DateTimeField(auto_now_add=True, editable=False)
|
||
updated_at = models.DateTimeField(auto_now=True)
|
||
|
||
objects = OrderManager()
|
||
|
||
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',)
|
||
|
||
|
||
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
|
||
)
|
||
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,
|
||
)
|
||
tax_rate = models.DecimalField(
|
||
max_digits=5, decimal_places=2, default=Decimal('0.0')
|
||
)
|
||
|
||
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.track_inventory:
|
||
self.variant.stock -= self.quantity
|
||
self.variant.save()
|
||
|
||
def add_stock(self):
|
||
if 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 Subscription(models.Model):
|
||
stripe_id = models.CharField(max_length=255, blank=True)
|
||
customer = models.OneToOneField(
|
||
User,
|
||
related_name='subscription',
|
||
on_delete=models.SET_NULL,
|
||
null=True
|
||
)
|
||
|
||
|
||
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
|
||
)
|
||
|
||
def __str__(self):
|
||
return 'Site Settings'
|
||
|
||
class Meta:
|
||
verbose_name = 'Site Settings'
|
||
verbose_name_plural = 'Site Settings'
|