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() if self.current: ProductPrice.objects.filter(product=self.product, variant=self.variant).exclude(pk=self.pk).update( current=False ) 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')