Files
shoppy/shop/models.py
T
2026-07-23 16:19:19 +02:00

656 lines
25 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.exceptions import ValidationError
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()
@property
def has_variants(self):
return self.variants.exists()
@property
def min_variant_price(self):
return ProductPrice.objects.filter(variant__product=self, current=True).order_by('price').first()
class Meta:
verbose_name = _('producto')
verbose_name_plural = _('productos')
ordering = ('id',)
class ProductAttribute(models.Model):
name = models.CharField(max_length=32, unique=True, verbose_name=_('nombre'))
def __str__(self):
return self.name
class Meta:
verbose_name = _('atributo de producto')
verbose_name_plural = _('atributos de producto')
ordering = ('name',)
class ProductAttributeValue(models.Model):
attribute = models.ForeignKey(
'shop.ProductAttribute', on_delete=models.CASCADE, related_name='values', verbose_name=_('atributo')
)
value = models.CharField(max_length=32, verbose_name=_('valor'))
def __str__(self):
return f'{self.attribute.name}: {self.value}'
class Meta:
verbose_name = _('valor de atributo')
verbose_name_plural = _('valores de atributo')
unique_together = ('attribute', 'value')
ordering = ('attribute__name', 'value')
class ProductVariant(TimestampedModel):
product = models.ForeignKey(
'shop.Product', on_delete=models.CASCADE, related_name='variants', verbose_name=_('producto')
)
sku = models.CharField(max_length=64, blank=False, null=False, unique=True, verbose_name=_('código de referencia'))
stock = models.DecimalField(max_digits=13, decimal_places=4, default=Decimal('0'), verbose_name=_('stock'))
attribute_values = models.ManyToManyField(
'shop.ProductAttributeValue', related_name='variants', blank=True, verbose_name=_('valores de atributo')
)
def __str__(self):
attrs = ', '.join(str(value) for value in self.attribute_values.all())
return f'{self.product.name} ({attrs})' if attrs else self.product.name
@property
def price(self):
return self.prices.filter(current=True).first()
def clean(self):
if not self.pk:
return
combination = set(self.attribute_values.values_list('pk', flat=True))
for other in ProductVariant.objects.filter(product=self.product).exclude(pk=self.pk):
if set(other.attribute_values.values_list('pk', flat=True)) == combination:
raise ValidationError(_('Ya existe una variante de este producto con la misma combinación de atributos.'))
class Meta:
verbose_name = _('variante de producto')
verbose_name_plural = _('variantes de producto')
ordering = ('product', 'sku')
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',
blank=True,
null=True,
)
variant = models.ForeignKey(
'shop.ProductVariant',
on_delete=models.CASCADE,
verbose_name=_('variante'),
related_name='prices',
blank=True,
null=True,
)
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 clean(self):
if bool(self.product_id) == bool(self.variant_id):
raise ValidationError(_('El precio debe pertenecer a un producto o a una variante, pero no a ambos.'))
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 Meta:
verbose_name = _('imágenes de producto')
verbose_name_plural = _('imágenes de producto')
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'))
def __str__(self):
return self.name
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')
)
variant = models.ForeignKey(
'shop.ProductVariant',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='order_lines',
verbose_name=_('variante'),
)
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_RETURNED_PARTIAL = 'PRT', _('devuelto parcialmente')
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'), unique=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'))
variant = models.ForeignKey(
'shop.ProductVariant',
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='cart_items',
verbose_name=_('variante'),
)
quantity = models.PositiveIntegerField(default=1, verbose_name=_('cantidad'))
@property
def price(self):
return self.variant.price if self.variant else self.product.price
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'))
# Redsys
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')