ptcoffee_django/core/models.py

681 lines
20 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
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 accounts.models import User, Address
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
)
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.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 = models.ForeignKey(
Address,
related_name='+',
editable=False,
null=True,
on_delete=models.SET_NULL
)
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'