feat: added product variants

This commit is contained in:
2026-07-23 11:37:37 +02:00
parent 5786c07077
commit 87405f31fa
22 changed files with 818 additions and 315 deletions
+35 -3
View File
@@ -9,10 +9,13 @@ from shop.models import (
Order,
OrderLine,
Product,
ProductAttribute,
ProductAttributeValue,
ProductBatch,
ProductCategory,
ProductPrice,
ProductImage,
ProductVariant,
Provider,
ShippingMethod,
ShopSettings,
@@ -21,6 +24,13 @@ from shop.models import (
)
class ProductPriceInline(admin.TabularInline):
model = ProductPrice
fk_name = 'variant'
fields = ('price', 'tax', 'current')
extra = 1
# Register your models here.
@admin.register(Product)
class ProductAdmin(ModelAdmin):
@@ -42,8 +52,8 @@ class TaxAdmin(ModelAdmin):
@admin.register(OrderLine)
class OrderLineAdmin(ModelAdmin):
autocomplete_fields = ('product',)
list_display = ('id', 'product', 'price', 'quantity')
autocomplete_fields = ('product', 'variant')
list_display = ('id', 'product', 'variant', 'price', 'quantity')
@admin.register(Order)
@@ -78,7 +88,7 @@ class CartAdmin(ModelAdmin):
@admin.register(CartItem)
class CartItemAdmin(ModelAdmin):
list_display = ('id', 'cart', 'product', 'quantity')
list_display = ('id', 'cart', 'product', 'variant', 'quantity')
@admin.register(ShippingMethod)
@@ -108,3 +118,25 @@ class ShopSettingsAdmin(ModelAdmin):
@admin.register(ProductImage)
class ProductImageAdmin(ModelAdmin):
list_display = ('id', 'original')
@admin.register(ProductAttribute)
class ProductAttributeAdmin(ModelAdmin):
search_fields = ('name',)
list_display = ('id', 'name')
@admin.register(ProductAttributeValue)
class ProductAttributeValueAdmin(ModelAdmin):
autocomplete_fields = ('attribute',)
search_fields = ('value',)
list_display = ('id', 'attribute', 'value')
@admin.register(ProductVariant)
class ProductVariantAdmin(ModelAdmin):
autocomplete_fields = ('product',)
filter_horizontal = ('attribute_values',)
search_fields = ('sku', 'product__name')
list_display = ('id', 'product', 'sku', 'stock')
inlines = (ProductPriceInline,)
+19
View File
@@ -0,0 +1,19 @@
# Generated by Django 6.0.2 on 2026-07-23 08:50
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0004_alter_shopsettings_shared_secret_and_more'),
]
operations = [
migrations.AlterField(
model_name='order',
name='uuid',
field=models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='UUID'),
),
]
@@ -0,0 +1,78 @@
# Generated by Django 6.0.2 on 2026-07-23 09:20
import django.db.models.deletion
from decimal import Decimal
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0005_alter_order_uuid'),
]
operations = [
migrations.CreateModel(
name='ProductAttribute',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=32, unique=True, verbose_name='nombre')),
],
options={
'verbose_name': 'atributo de producto',
'verbose_name_plural': 'atributos de producto',
'ordering': ('name',),
},
),
migrations.AlterField(
model_name='productprice',
name='product',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='prices', to='shop.product', verbose_name='producto'),
),
migrations.CreateModel(
name='ProductAttributeValue',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('value', models.CharField(max_length=32, verbose_name='valor')),
('attribute', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='values', to='shop.productattribute', verbose_name='atributo')),
],
options={
'verbose_name': 'valor de atributo',
'verbose_name_plural': 'valores de atributo',
'ordering': ('attribute__name', 'value'),
'unique_together': {('attribute', 'value')},
},
),
migrations.CreateModel(
name='ProductVariant',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('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')),
('sku', models.CharField(max_length=64, unique=True, verbose_name='código de referencia')),
('stock', models.DecimalField(decimal_places=4, default=Decimal('0'), max_digits=13, verbose_name='stock')),
('attribute_values', models.ManyToManyField(blank=True, related_name='variants', to='shop.productattributevalue', verbose_name='valores de atributo')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='variants', to='shop.product', verbose_name='producto')),
],
options={
'verbose_name': 'variante de producto',
'verbose_name_plural': 'variantes de producto',
'ordering': ('product', 'sku'),
},
),
migrations.AddField(
model_name='cartitem',
name='variant',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='cart_items', to='shop.productvariant', verbose_name='variante'),
),
migrations.AddField(
model_name='orderline',
name='variant',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='order_lines', to='shop.productvariant', verbose_name='variante'),
),
migrations.AddField(
model_name='productprice',
name='variant',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='prices', to='shop.productvariant', verbose_name='variante'),
),
]
+110 -2
View File
@@ -4,6 +4,7 @@ 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
@@ -91,17 +92,100 @@ class Product(TimestampedModel):
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'
'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(
@@ -116,6 +200,10 @@ class ProductPrice(TimestampedModel):
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)
@@ -275,6 +363,14 @@ class OrderLine(TimestampedModel):
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')
)
@@ -336,7 +432,7 @@ class Order(TimestampedModel):
STATUS_REQUESTED_RETURN = 'RQT', _('devolución solicitada')
STATUS_RETURNED = 'RTN', _('devuelto')
uuid = models.UUIDField(default=uuid4, verbose_name=_('UUID'), db_index=True)
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'))
@@ -418,8 +514,20 @@ class Cart(models.Model):
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')
+18 -1
View File
@@ -2,7 +2,7 @@ from decimal import Decimal
from django.utils.timezone import now
from shop.models import Product, ProductPrice, Tax
from shop.models import Product, ProductAttribute, ProductAttributeValue, ProductPrice, ProductVariant, Tax
class CreateProductsMixin:
@@ -13,3 +13,20 @@ class CreateProductsMixin:
product = Product.objects.create(sku=sku, name=name, description=description, is_shipping_method=is_shipping)
ProductPrice.objects.create(price=price, product=product, date=now(), tax=tax, current=True)
return product
def create_attribute_value(self, attribute_name='Talla', value='M') -> ProductAttributeValue:
attribute, created = ProductAttribute.objects.get_or_create(name=attribute_name)
attribute_value, created = ProductAttributeValue.objects.get_or_create(attribute=attribute, value=value)
return attribute_value
def create_product_variant(
self, product: Product, sku='000001-V1', price=Decimal('10.00'), stock=Decimal('10'), attribute_values=None
) -> ProductVariant:
tax, created = Tax.objects.get_or_create(code='IVA', value=21)
variant = ProductVariant.objects.create(product=product, sku=sku, stock=stock)
if attribute_values:
variant.attribute_values.set(attribute_values)
ProductPrice.objects.create(price=price, variant=variant, date=now(), tax=tax, current=True)
return variant
+51
View File
@@ -0,0 +1,51 @@
from decimal import Decimal
from django.core.exceptions import ValidationError
from django.test import TestCase
from shop.models import ProductVariant
from shop.tests.mixins import CreateProductsMixin
class TestProductVariants(TestCase, CreateProductsMixin):
def setUp(self):
self.product = self.create_product()
self.size_m = self.create_attribute_value('Talla', 'M')
self.size_l = self.create_attribute_value('Talla', 'L')
def test_product_without_variants_has_variants_false(self):
assert self.product.has_variants is False
def test_product_with_variants_has_variants_true(self):
self.create_product_variant(self.product, sku='V1', attribute_values=[self.size_m])
assert self.product.has_variants is True
def test_variant_price_returns_current_price(self):
variant = self.create_product_variant(self.product, sku='V1', price=Decimal('15.00'), attribute_values=[self.size_m])
assert variant.price.price == Decimal('15.00')
def test_variant_str_includes_attribute_values(self):
variant = self.create_product_variant(self.product, sku='V1', attribute_values=[self.size_m])
assert 'Talla: M' in str(variant)
def test_min_variant_price_returns_lowest_current_price(self):
self.create_product_variant(self.product, sku='V1', price=Decimal('20.00'), attribute_values=[self.size_m])
self.create_product_variant(self.product, sku='V2', price=Decimal('12.00'), attribute_values=[self.size_l])
assert self.product.min_variant_price.price == Decimal('12.00')
def test_duplicated_attribute_combination_is_invalid(self):
self.create_product_variant(self.product, sku='V1', attribute_values=[self.size_m])
duplicated = self.create_product_variant(self.product, sku='V2', attribute_values=[self.size_m])
with self.assertRaises(ValidationError):
duplicated.full_clean()
def test_different_attribute_combination_is_valid(self):
self.create_product_variant(self.product, sku='V1', attribute_values=[self.size_m])
other = self.create_product_variant(self.product, sku='V2', attribute_values=[self.size_l])
other.full_clean()
+21 -4
View File
@@ -12,14 +12,29 @@ from django.utils import timezone
from django.utils.text import gettext_lazy as _
from shop.exceptions import RedsysPaymentException, RedsysValidationException
from shop.models import Cart, CartItem, Order, OrderLine, Payment, Product, ProductBatch, ProductPrice, ShippingMethod
from shop.models import (
Cart,
CartItem,
Order,
OrderLine,
Payment,
Product,
ProductBatch,
ProductPrice,
ProductVariant,
ShippingMethod,
)
from shop.settings import ERROR_CODES
User = get_user_model()
def create_order_line_for_product(product: Product, quantity: Decimal, order: Order):
price = product.prices.last()
def get_effective_price(product: Product, variant: ProductVariant = None) -> ProductPrice:
return variant.price if variant else product.price
def create_order_line_for_product(product: Product, quantity: Decimal, order: Order, variant: ProductVariant = None):
price = get_effective_price(product, variant)
base_total = round(price.price * quantity, 2)
tax_value = price.tax.value / Decimal('100')
taxes = round(base_total * tax_value, 2)
@@ -27,6 +42,7 @@ def create_order_line_for_product(product: Product, quantity: Decimal, order: Or
return OrderLine.objects.create(
order=order,
product=product,
variant=variant,
quantity=quantity,
price=price.price,
base_total=base_total,
@@ -111,7 +127,7 @@ def create_order_from_cart(
for item in cart.items.all():
product = item.product
price: ProductPrice = product.price
price: ProductPrice = item.price
base_total = price.price * item.quantity
tax_value = price.tax.value
taxes = base_total * tax_value / Decimal(100)
@@ -120,6 +136,7 @@ def create_order_from_cart(
OrderLine.objects.create(
order=order,
product=product,
variant=item.variant,
quantity=item.quantity,
price=price.price,
base_total=base_total,
+5
View File
@@ -1,3 +1,5 @@
import logging
from django.http.response import HttpResponse
from django.shortcuts import get_object_or_404
from django.views.decorators.csrf import csrf_exempt
@@ -5,6 +7,8 @@ from django.views.decorators.csrf import csrf_exempt
from shop.models import Order
from shop.utils import add_payment_to_order, validate_payment_for_order
logger = logging.getLogger(__name__)
@csrf_exempt
def webhook(request, uuid):
@@ -16,6 +20,7 @@ def webhook(request, uuid):
return HttpResponse(status=200)
except Exception:
logger.exception('Error processing payment webhook for order %s', order.uuid)
order.status = Order.Statuses.STATUS_ERROR
order.save()