Files
shoppy/shop/models.py
T

759 lines
23 KiB
Python

from decimal import Decimal
from io import BytesIO
from uuid import uuid4
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.files.base import ContentFile
from django.db import models
from django.utils import timezone
from django.utils.text import gettext_lazy as _
from django.utils.text import slugify
from PIL import Image
from config.models import SingletonModel
from shop.settings import CURRENCY_CODES
User = get_user_model()
class TimestampedModel(models.Model):
creation_date = models.DateTimeField(
auto_now_add=True, verbose_name=_("fecha de creación")
)
last_modification_date = models.DateTimeField(
auto_now=True, verbose_name=_("fecha de última modificación")
)
class Meta:
abstract = True
class Tag(models.Model):
name = models.CharField(max_length=16)
def __str__(self):
return self.name
class Meta:
verbose_name = _("etiqueta")
verbose_name_plural = _("etiquetas")
ordering = ("name",)
class ProductCategory(models.Model):
name = models.CharField(max_length=32)
parent = models.ForeignKey(
"shop.ProductCategory",
on_delete=models.SET_NULL,
null=True,
blank=True,
verbose_name=_("categoría padre"),
)
promoted = models.BooleanField(default=False, verbose_name=_("promocionada"))
hidden = models.BooleanField(default=False, verbose_name=_("oculta"))
show_in_navbar = models.BooleanField(
default=False, verbose_name=_("mostrar en cabecera")
)
slug = models.SlugField(
max_length=48,
blank=True,
default="",
verbose_name=_("slug"),
unique=True,
db_index=True,
)
def __str__(self):
return self.name
def save(self, *args, **kwargs):
self.slug = slugify(self.name)
super().save()
class Meta:
verbose_name = _("categoría")
verbose_name_plural = _("categorías")
ordering = ("name",)
class Product(TimestampedModel):
sku = models.CharField(
max_length=64,
blank=False,
null=False,
unique=True,
verbose_name=_("código de referencia"),
)
name = models.CharField(
max_length=96,
blank=False,
null=False,
verbose_name=_("nombre"),
)
description = models.TextField(
blank=True,
default="",
verbose_name=_("descripción"),
)
stock = models.DecimalField(
max_digits=13,
decimal_places=4,
default=Decimal("0"),
verbose_name=_("stock"),
)
is_digital_asset = models.BooleanField(
default=False, verbose_name=_("es un activo digital")
)
url = models.URLField(blank=True, verbose_name=_("URL de descarga"))
tags = models.ManyToManyField("shop.Tag", blank=True, verbose_name=_("etiquetas"))
categories = models.ManyToManyField(
"shop.ProductCategory", blank=True, verbose_name=_("categorías")
)
brand = models.ForeignKey(
"shop.Brand",
blank=True,
null=True,
on_delete=models.SET_NULL,
verbose_name=_("marca"),
)
slug = models.SlugField(
default="", blank=True, null=True, max_length=128, verbose_name=_("Slug")
)
hidden = models.BooleanField(default=False, db_index=True, verbose_name=_("oculto"))
is_shipping_method = models.BooleanField(
default=False, verbose_name=_("es forma de envío")
)
def __str__(self):
return self.name
def save(self, *args, **kwargs):
if not self.pk:
self.slug = slugify(self.name)[:128]
super().save(*args, **kwargs)
@property
def price(self):
return self.prices.filter(current=True).first()
class Meta:
verbose_name = _("producto")
verbose_name_plural = _("productos")
ordering = ("id",)
class ProductPrice(TimestampedModel):
price = models.DecimalField(
max_digits=11, decimal_places=2, verbose_name=_("precio")
)
date = models.DateTimeField(default=timezone.now, verbose_name=_("fecha"))
product = models.ForeignKey(
"shop.Product",
on_delete=models.CASCADE,
verbose_name=_("producto"),
related_name="prices",
)
tax = models.ForeignKey(
"shop.Tax",
on_delete=models.PROTECT,
verbose_name=_("impuesto aplicable"),
)
price_with_tax = models.DecimalField(
max_digits=11,
decimal_places=2,
blank=True,
null=True,
verbose_name=_("precio con impuestos"),
)
current = models.BooleanField(default=False, verbose_name=_("es el precio actual"))
def __str__(self):
return f"{self.price} - {self.tax.code}"
def save(self, *args, **kwargs):
tax_value = self.price * Decimal(self.tax.value / 100)
self.price_with_tax = round(self.price + tax_value, 2)
super().save()
class Meta:
verbose_name = _("precio de producto")
verbose_name_plural = _("precio de producto")
ordering = ("-date",)
class ProductImage(models.Model):
product = models.ForeignKey(
"shop.Product",
on_delete=models.CASCADE,
blank=False,
null=False,
verbose_name=_("Producto"),
related_name="images",
)
original = models.ImageField(
upload_to="uploads", blank=False, null=False, verbose_name=_("Imagen original")
)
xl = models.ImageField(
upload_to="uploads", blank=True, null=True, verbose_name=_("XL")
)
xl_dark = models.ImageField(
upload_to="uploads", blank=True, null=True, verbose_name=_("XL (fondo oscuro)")
)
l = models.ImageField(
upload_to="uploads", blank=True, null=True, verbose_name=_("L")
)
l_dark = models.ImageField(
upload_to="uploads", blank=True, null=True, verbose_name=_("L (fondo oscuro)")
)
m = models.ImageField(
upload_to="uploads", blank=True, null=True, verbose_name=_("M")
)
m_dark = models.ImageField(
upload_to="uploads", blank=True, null=True, verbose_name=_("M (fondo oscuro)")
)
s = models.ImageField(
upload_to="uploads", blank=True, null=True, verbose_name=_("S")
)
s_dark = models.ImageField(
upload_to="uploads", blank=True, null=True, verbose_name=_("S (fondo oscuro)")
)
xs = models.ImageField(
upload_to="uploads", blank=True, null=True, verbose_name=_("XS")
)
xs_dark = models.ImageField(
upload_to="uploads", blank=True, null=True, verbose_name=_("XS (fondo oscuro)")
)
def __str__(self):
return self.original.url
def make_square(self, image, max_size=512, fill_color="#000000"):
width, height = image.size
if width > height:
ratio = width / max_size
else:
ratio = height / max_size
new_width = int(width / ratio)
new_height = int(height / ratio)
resized_image = image.resize((new_width, new_height), Image.Resampling.LANCZOS)
new_image = Image.new("RGB", (max_size, max_size), fill_color)
position_x = int((max_size - new_width) / 2)
position_y = int((max_size - new_height) / 2)
new_image.paste(resized_image, (position_x, position_y))
return new_image
def get_resized_image(self, image, size, fill_color="#000000"):
image = Image.open(image)
image = self.make_square(image, size, fill_color)
buffer = BytesIO()
image.save(fp=buffer, format="WEBP")
return ContentFile(buffer.getvalue(), name=f"{self.product.slug}_{size}.webp")
def save(self, *args, **kwargs):
self.xl = self.get_resized_image(self.original, 1024, fill_color="#FFFFFF")
self.xl_dark = self.get_resized_image(self.original, 1024)
self.l = self.get_resized_image(self.original, 512, fill_color="#FFFFFF")
self.l_dark = self.get_resized_image(self.original, 512)
self.m = self.get_resized_image(self.original, 256, fill_color="#FFFFFF")
self.m_dark = self.get_resized_image(self.original, 256)
self.s = self.get_resized_image(self.original, 128, fill_color="#FFFFFF")
self.s_dark = self.get_resized_image(self.original, 128)
self.xs = self.get_resized_image(self.original, 96, fill_color="#FFFFFF")
self.xs_dark = self.get_resized_image(self.original, 96)
return super().save(*args, **kwargs)
class ProductBatch(TimestampedModel):
code = models.CharField(
max_length=32, unique=True, db_index=True, verbose_name=_("código")
)
product = models.ForeignKey(
"shop.Product", on_delete=models.PROTECT, verbose_name=_("producto")
)
quantity = models.DecimalField(
max_digits=13,
decimal_places=4,
default=Decimal("1"),
verbose_name=_("cantidad"),
)
expiration_date = models.DateField(
blank=True, null=True, verbose_name=_("fecha de caducidad / consumo preferente")
)
provider = models.ForeignKey(
"shop.Provider",
on_delete=models.SET_NULL,
blank=True,
null=True,
verbose_name=_("proveedor"),
)
def __str__(self):
return f"{self.code} - {self.product.name} - {self.quantity}"
class Meta:
verbose_name = _("remesa de producto")
verbose_name_plural = _("remesas de productos")
class Tax(TimestampedModel):
code = models.CharField(
max_length=8, blank=False, unique=True, verbose_name=_("código de impuesto")
)
value = models.PositiveIntegerField(
null=False, blank=False, verbose_name=_("valor entero (porcentaje)")
)
def __str__(self):
return f"{self.code} - {self.value}%"
class Meta:
verbose_name = _("impuesto")
verbose_name_plural = _("impuestos")
ordering = ("id",)
class Provider(TimestampedModel):
vat_id = models.CharField(
max_length=32,
blank=False,
unique=True,
verbose_name=_("documento de identidad"),
)
name = models.CharField(max_length=64, blank=False, verbose_name=_("nombre"))
email = models.EmailField(blank=False, verbose_name=_("e-mail de contacto"))
phone = models.CharField(
max_length=16,
blank=True,
null=True,
default="",
verbose_name=_("Teléfono de contacto"),
)
address = models.CharField(max_length=255, blank=False, verbose_name=_("dirección"))
city = models.CharField(max_length=64, blank=False, verbose_name=_("ciudad"))
state = models.CharField(max_length=64, blank=False, verbose_name=_("región"))
country = models.CharField(max_length=64, blank=False, verbose_name=_("país"))
zip = models.CharField(max_length=32, blank=False, verbose_name=_("código postal"))
def __str__(self):
return f"{self.vat_id} - {self.name}"
class Meta:
verbose_name = _("proveedor")
verbose_name_plural = _("proveedores")
class Brand(TimestampedModel):
name = models.CharField(
max_length=100, unique=True, blank=False, null=False, verbose_name=_("nombre")
)
class Meta:
verbose_name = _("marca")
verbose_name_plural = _("marcas")
class OrderLine(TimestampedModel):
order = models.ForeignKey(
"shop.Order",
on_delete=models.CASCADE,
related_name="lines",
verbose_name=_("pedido"),
)
product = models.ForeignKey(
"shop.Product",
on_delete=models.CASCADE,
null=False,
blank=False,
verbose_name=_("producto"),
)
quantity = models.DecimalField(
max_digits=13,
decimal_places=4,
default=Decimal("1"),
null=False,
blank=False,
verbose_name=_("cantidad"),
)
# Precio del producto en el momento en el que se crea el pedido
price = models.DecimalField(
max_digits=13, decimal_places=4, verbose_name=_("precio")
)
# base_total = quantity * price
base_total = models.DecimalField(
max_digits=13,
decimal_places=4,
default=Decimal("0"),
null=False,
blank=False,
verbose_name=_("total sin impuestos"),
)
# Valor entero del impuesto en el momento en el que se crea el pedido
tax_value = models.PositiveIntegerField(
null=False, blank=False, verbose_name=_("valor de impuestos")
)
# taxes = price * quantity * (tax_value / 100)
taxes = models.DecimalField(
max_digits=13, decimal_places=4, verbose_name=_("impuestos")
)
# total = base_total + taxes
total = models.DecimalField(
max_digits=13, decimal_places=4, verbose_name=_("total")
)
def __str__(self):
return f"{self.product.name} - {self.quantity} - {self.price}"
class Meta:
verbose_name = _("línea de pedido")
verbose_name_plural = _("líneas de pedido")
def create_order_code(*args, **kwargs):
return (
timezone.now()
.isoformat()
.replace("-", "")
.replace("T", "")
.replace(":", "")
.replace(".", "")[:-5]
)
class Order(TimestampedModel):
class Statuses(models.TextChoices):
STATUS_PENDING = "PEN", _("pendiente de pago")
STATUS_PAID = "PAI", _("pagado")
STATUS_RETURNED = "RTN", _("devuelto")
STATUS_ERROR = "ERR", _("Error")
class ShippingStatuses(models.TextChoices):
STATUS_NOT_READY = "NOT", _("no preparado")
STATUS_READY = "RDY", _("preparado")
STATUS_TO_BE_SENT = "TBS", _("listo para ser enviado")
STATUS_SENT = "SNT", _("enviado")
STATUS_DELIVERED = "DLV", _("entregado")
STATUS_FINISHED = "FIN", _("finalizado")
STATUS_CANCELED = "CAN", _("cancelado")
STATUS_REQUESTED_RETURN = "RQT", _("devolución solicitada")
STATUS_RETURNED = "RTN", _("devuelto")
uuid = models.UUIDField(default=uuid4, verbose_name=_("UUID"), db_index=True)
code = models.CharField(default=create_order_code, max_length=20, unique=True)
status = models.CharField(
max_length=3, default=Statuses.STATUS_PENDING, verbose_name=_("estado")
)
shipping_status = models.CharField(
max_length=3,
default=ShippingStatuses.STATUS_NOT_READY,
verbose_name=_("estado de envío"),
)
base_total = models.DecimalField(
default=Decimal("0"),
max_digits=13,
decimal_places=2,
verbose_name=_("base imponible"),
)
total = models.DecimalField(
default=Decimal("0"), max_digits=13, decimal_places=2, verbose_name=_("total")
)
user = models.ForeignKey(
User, blank=True, null=True, on_delete=models.PROTECT, verbose_name=_("cliente")
)
email = models.EmailField(blank=True, verbose_name=_("e-mail"))
# Datos de facturación
billing_address = models.CharField(
max_length=255, blank=False, verbose_name=_("dirección de facturación")
)
billing_city = models.CharField(
max_length=64, blank=False, verbose_name=_("ciudad de facturación")
)
billing_state = models.CharField(
max_length=64, blank=False, verbose_name=_("región de facturación")
)
billing_country = models.CharField(
max_length=64, blank=False, verbose_name=_("país de facturación")
)
billing_zip = models.CharField(
max_length=32, blank=False, verbose_name=_("código postal de facturación")
)
# Datos de envío
shipping_address = models.CharField(
max_length=255, blank=False, verbose_name=_("dirección")
)
shipping_city = models.CharField(
max_length=64, blank=False, verbose_name=_("ciudad")
)
shipping_state = models.CharField(
max_length=64, blank=False, verbose_name=_("región")
)
shipping_country = models.CharField(
max_length=64, blank=False, verbose_name=_("país")
)
shipping_zip = models.CharField(
max_length=32, blank=False, verbose_name=_("código postal")
)
contact_phone = models.CharField(
max_length=32, blank=True, verbose_name=_("teléfono de contacto")
)
amount_paid = models.DecimalField(
default=Decimal("0.00"),
max_digits=11,
decimal_places=2,
verbose_name=_("cantidad pagada"),
)
shipping_method = models.ForeignKey(
"shop.ShippingMethod",
blank=True,
null=True,
on_delete=models.SET_NULL,
verbose_name=_("método de envío"),
)
from_cart = models.ForeignKey(
"shop.Cart",
on_delete=models.SET_NULL,
blank=True,
null=True,
verbose_name=_("carrito de origen de pedido"),
)
def calculate_total_from_lines(self):
self.base_total = self.lines.aggregate(base_total=models.Sum("base_total")).get(
"base_total"
)
self.total = self.lines.aggregate(base_total=models.Sum("total")).get(
"base_total"
)
self.save()
class Meta:
verbose_name = _("pedido")
verbose_name_plural = _("pedidos")
ordering = ("creation_date",)
class Cart(models.Model):
uuid = models.UUIDField(default=uuid4, primary_key=True, verbose_name=_("uuid"))
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
verbose_name=_("usuario"),
blank=True,
null=True,
)
creation_date = models.DateTimeField(
auto_now_add=True, verbose_name=_("fecha de creación")
)
class Meta:
verbose_name = _("carrito")
verbose_name_plural = _("carritos")
class CartItem(models.Model):
cart = models.ForeignKey(
"shop.Cart",
on_delete=models.CASCADE,
related_name="items",
verbose_name=_("carrito"),
)
product = models.ForeignKey(
"shop.Product", on_delete=models.CASCADE, verbose_name=_("producto")
)
quantity = models.PositiveIntegerField(default=1, verbose_name=_("cantidad"))
class Meta:
verbose_name = _("línea de carrito")
verbose_name_plural = _("líneas de carrito")
class CustomerAddress(models.Model):
class Types(models.TextChoices):
BILLING = "BILL", _("facturación")
SHIPPING = "SHIP", _("envío")
user = models.ForeignKey(
User, on_delete=models.CASCADE, null=True, verbose_name=_("usuario")
)
vat_id = models.CharField(
max_length=16,
blank=True,
null=True,
default="",
verbose_name=_("número de identificación fiscal"),
)
full_name = models.CharField(
max_length=128, blank=False, null=False, verbose_name=_("dirección")
)
address = models.CharField(
max_length=128, blank=False, null=False, verbose_name=_("dirección")
)
address_town = models.CharField(
max_length=64, blank=False, null=False, verbose_name=_("localidad")
)
address_zip = models.CharField(
max_length=16, blank=False, null=False, verbose_name=_("código postal")
)
address_state = models.CharField(
max_length=64, blank=False, null=False, verbose_name=_("provincia")
)
address_country = models.CharField(
max_length=64,
default=settings.SHOP_COUNTRY,
blank=False,
null=False,
verbose_name=_("país"),
)
address_phone = models.CharField(
max_length=16, blank=True, null=True, default="", verbose_name=_("teléfono")
)
address_type = models.CharField(
max_length=4,
choices=Types.choices,
default=Types.SHIPPING,
verbose_name=_("tipo de dirección"),
)
email = models.EmailField(blank=False, null=False, verbose_name=_("e-mail"))
default = models.BooleanField(default=True, verbose_name=_("por defecto"))
hidden = models.BooleanField(default=False, db_index=True, verbose_name=_("oculto"))
def __str__(self):
return f"{self.full_name} - {self.address}, {self.address_zip}, {self.address_state} - {self.address_country}"
class Meta:
verbose_name = _("dirección de cliente")
verbose_name_plural = _("direcciones de cliente")
class ShippingMethod(models.Model):
name = models.CharField(max_length=64, verbose_name=_("nombre"))
description = models.CharField(
max_length=256, default="", blank=True, verbose_name=_("description")
)
url = models.URLField(default="", blank=True, verbose_name=_("url"))
shipping_product = models.ForeignKey(
"shop.Product", on_delete=models.CASCADE, verbose_name=_("producto de envío")
)
enabled = models.BooleanField(default=True, verbose_name=_("habilitado"))
class Meta:
verbose_name = _("método de envío")
verbose_name_plural = _("métodos de envío")
class WishlistedProduct(TimestampedModel):
user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_("usuario"))
product = models.ForeignKey(
"shop.Product", on_delete=models.CASCADE, verbose_name=_("producto")
)
class Meta:
verbose_name = _("productos deseados")
verbose_name_plural = _("productos deseados")
unique_together = (
"user",
"product",
)
class Payment(models.Model):
class MethodChoices(models.TextChoices):
REDSYS = "REDSYS"
hash = models.UUIDField(verbose_name=_("Hash"), default=uuid4, primary_key=True)
amount = models.DecimalField(
max_digits=8,
decimal_places=2,
default=Decimal("0"),
verbose_name=_("Cantidad"),
)
order = models.ForeignKey(
"shop.Order",
on_delete=models.PROTECT,
verbose_name=_("pedido"),
related_name="payments",
)
user = models.ForeignKey(
User, blank=True, null=True, on_delete=models.PROTECT, verbose_name=_("cliente")
)
creation_date = models.DateTimeField(
auto_now_add=True, verbose_name=_("Fecha de creación")
)
metadata = models.JSONField(default=dict, verbose_name=_("Metadata"))
method = models.CharField(
max_length=6,
choices=MethodChoices.choices,
default=MethodChoices.REDSYS,
verbose_name=_("método de pago"),
)
class Meta:
verbose_name = _("Pago")
verbose_name_plural = _("Pagos")
class ShopSettings(SingletonModel):
debug = models.BooleanField(default=True, verbose_name=_("Modo depuración"))
merchant_code = models.CharField(
max_length=9,
blank=False,
null=False,
verbose_name=_("Identificación de comercio"),
)
currency_code = models.CharField(
max_length=4,
default="978",
choices=CURRENCY_CODES,
verbose_name=_("Código de moneda"),
) # 978 == EURO
terminal = models.CharField(max_length=8, verbose_name=_("Terminal"))
shared_secret = models.CharField(max_length=100, verbose_name=_("Clave de Redsys"))
tpv_domain = models.URLField(
max_length=128, blank=True, null=True, verbose_name=_("dominio del tpv")
)
class Meta:
verbose_name = _("ajustes de la tienda")
verbose_name_plural = _("ajustes de la tienda")