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
@@ -3,6 +3,20 @@
{% csrf_token %}
<input type="hidden" name="product" value="{{ product.pk }}">
{% if product.has_variants %}
<div class="mb-4">
<label for="variant">{% translate 'Variante' %}</label>
<select class="input" id="variant" name="variant">
{% for variant in product.variants.all %}
<option value="{{ variant.pk }}" {% if not variant.stock %}disabled{% endif %}>
{% for attribute_value in variant.attribute_values.all %}{{ attribute_value }}{% if not forloop.last %}, {% endif %}{% endfor %}
{% if not variant.stock %}({% translate 'agotado' %}){% endif %}
</option>
{% endfor %}
</select>
</div>
{% endif %}
<div class="flex items-center">
<input class="input mr-4" id="quantity" aria-label="quantity" type="number" min="1" max="10" name="quantity" value="1">
<button class="btn btn-primary">
+6 -1
View File
@@ -26,6 +26,11 @@
>
{{ item.product.name }}
</a>
{% if item.variant %}
<p class="text-sm">
{% for attribute_value in item.variant.attribute_values.all %}{{ attribute_value }}{% if not forloop.last %}, {% endif %}{% endfor %}
</p>
{% endif %}
<div class="flex items-center gap-4">
<form class="flex" hx-post="{% url 'web:delete_cart_item' pk=item.pk %}"
@@ -50,7 +55,7 @@
</div>
<div class="text-end md:order-4 md:w-32">
<p class="text-base font-bold ">
{{ item.product.price.price_with_tax }}€
{{ item.price.price_with_tax }}€
</p>
</div>
</div>
@@ -25,11 +25,16 @@
class="text-sm font-semibold leading-none hover:underline">
{{ cart_item.product.name }}
</a>
{% if cart_item.variant %}
<p class="text-xs">
{% for attribute_value in cart_item.variant.attribute_values.all %}{{ attribute_value }}{% if not forloop.last %}, {% endif %}{% endfor %}
</p>
{% endif %}
<div class="flex items-center justify-between gap-6">
<p
class="mt-0.5 truncate text-sm font-normal ">
{{ cart_item.product.price.price_with_tax }}€
{{ cart_item.price.price_with_tax }}€
</p>
<form hx-post="{% url 'web:delete_cart_item' pk=cart_item.pk %}"
+5 -1
View File
@@ -39,7 +39,11 @@
<p
class="text-2xl font-extrabold sm:text-3xl "
>
{{ product.price.price_with_tax }} €
{% if product.price %}
{{ product.price.price_with_tax }} €
{% elif product.min_variant_price %}
{% translate 'Desde' %} {{ product.min_variant_price.price_with_tax }} €
{% endif %}
</p>
</div>
+59
View File
@@ -166,3 +166,62 @@ class TestCart(TestCase, CreateProductsMixin):
self.client.force_login(self.user)
response = self.client.get(reverse('web:cart'))
assert response.status_code == 200
def test_add_product_variant_to_cart(self):
size_m = self.create_attribute_value('Talla', 'M')
variant = self.create_product_variant(self.product, sku='V1', attribute_values=[size_m])
quantity = 2
response = self.client.post(
reverse('web:add_cart_item'),
{'product': self.product.pk, 'variant': variant.pk, 'quantity': quantity},
)
assert response.status_code == 201
cart_item = CartItem.objects.filter(cart__uuid=response.cookies.get(ANONYMOUS_CART_ID_COOKIE_NAME).value).first()
assert cart_item is not None
assert cart_item.variant == variant
assert cart_item.quantity == quantity
def test_add_product_with_variants_without_selecting_one_fails(self):
size_m = self.create_attribute_value('Talla', 'M')
self.create_product_variant(self.product, sku='V1', attribute_values=[size_m])
response = self.client.post(reverse('web:add_cart_item'), {'product': self.product.pk, 'quantity': 1})
assert response.status_code == 404
assert CartItem.objects.count() == 0
def test_add_same_product_different_variants_creates_separate_lines(self):
size_m = self.create_attribute_value('Talla', 'M')
size_l = self.create_attribute_value('Talla', 'L')
variant_m = self.create_product_variant(self.product, sku='V1', attribute_values=[size_m])
variant_l = self.create_product_variant(self.product, sku='V2', attribute_values=[size_l])
self.client.force_login(self.user)
response = self.client.post(
reverse('web:add_cart_item'), {'product': self.product.pk, 'variant': variant_m.pk, 'quantity': 1}
)
assert response.status_code == 201
response = self.client.post(
reverse('web:add_cart_item'), {'product': self.product.pk, 'variant': variant_l.pk, 'quantity': 1}
)
assert response.status_code == 201
cart = Cart.objects.get(user=self.user)
assert CartItem.objects.filter(cart=cart).count() == 2
def test_add_same_product_and_variant_multiple_times_sums_quantity(self):
size_m = self.create_attribute_value('Talla', 'M')
variant = self.create_product_variant(self.product, sku='V1', attribute_values=[size_m])
self.client.force_login(self.user)
self.client.post(
reverse('web:add_cart_item'), {'product': self.product.pk, 'variant': variant.pk, 'quantity': 1}
)
self.client.post(
reverse('web:add_cart_item'), {'product': self.product.pk, 'variant': variant.pk, 'quantity': 1}
)
cart = Cart.objects.get(user=self.user)
assert CartItem.objects.filter(cart=cart).count() == 1
assert CartItem.objects.get(cart=cart).quantity == 2
+35 -1
View File
@@ -4,7 +4,7 @@ from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import reverse
from shop.models import Cart, CartItem, CustomerAddress, Order, ShippingMethod, ShopSettings
from shop.models import Cart, CartItem, CustomerAddress, Order, OrderLine, ShippingMethod, ShopSettings
from shop.tests.mixins import CreateProductsMixin
@@ -111,3 +111,37 @@ class TestOrders(TestCase, CreateProductsMixin):
assert response.status_code == 200
assert not Order.objects.filter(user=self.user).exists()
def test_order_from_cart_with_variant_uses_variant_price(self):
size_m = self.create_attribute_value('Talla', 'M')
variant = self.create_product_variant(self.product, sku='V1', price=Decimal('25.00'), attribute_values=[size_m])
CartItem.objects.create(cart=self.cart_for_user, product=self.product, variant=variant, quantity=1)
self.client.force_login(self.user)
data = {
'email': self.user.email,
'shipping_address_full_name': self.address.full_name,
'shipping_address': self.address.address,
'shipping_address_town': self.address.address_town,
'shipping_address_zip': self.address.address_zip,
'shipping_address_state': self.address.address_state,
'shipping_address_country': self.address.address_country,
'shipping_address_phone': self.address.address_phone,
'same_as_shipping': True,
'billing_address_full_name': self.address.full_name,
'billing_address': self.address.address,
'billing_address_town': self.address.address_town,
'billing_address_zip': self.address.address_zip,
'billing_address_state': self.address.address_state,
'billing_address_country': self.address.address_country,
'billing_address_phone': self.address.address_phone,
'shipping_method': self.shipping_method.pk,
}
response = self.client.post(reverse('web:cart_detail'), data)
assert response.status_code == 302
order = Order.objects.get(user=self.user)
variant_line = OrderLine.objects.get(order=order, variant=variant)
assert variant_line.price == Decimal('25.00')
+1 -1
View File
@@ -82,7 +82,7 @@ def cart(request, *args, **kwargs):
tax_total = Decimal('0.00')
for item in items:
price = ProductPrice.objects.filter(product=item.product, current=True).first()
price = item.price
base_total += price.price * item.quantity
tax_total += round(price.price * Decimal(price.tax.value / 100) * item.quantity, 2)
+21 -5
View File
@@ -7,7 +7,16 @@ from django.utils.text import gettext_lazy as _
from django.views.decorators.http import require_http_methods
from django.views.generic import CreateView, TemplateView
from shop.models import CartItem, Order, OrderLine, Product, ProductCategory, ShippingMethod, WishlistedProduct
from shop.models import (
CartItem,
Order,
OrderLine,
Product,
ProductCategory,
ProductVariant,
ShippingMethod,
WishlistedProduct,
)
from shop.redsys import RedsysClient
from shop.utils import create_order_from_cart
from web.forms import CreateOrderForm
@@ -37,7 +46,7 @@ class CategoryView(TemplateView):
def get_context_data(self, **kwargs):
settings = WebSettings.load()
slug = kwargs.get('slug')
category = ProductCategory.objects.get(slug=slug)
category = get_object_or_404(ProductCategory, slug=slug)
return {
'title': _(f'{settings.web_title} - {category.name}'),
@@ -53,9 +62,11 @@ class ProductDetail(TemplateView):
def get_context_data(self, pk, slug, **kwargs):
settings = WebSettings.load()
product = get_object_or_404(Product, pk=pk)
variants = product.variants.prefetch_related('attribute_values__attribute')
return {
'product': product,
'variants': variants,
'title': f'{settings.web_title} - {product.name}',
'description': product.description,
'image': product.images.first(),
@@ -201,14 +212,19 @@ class OrdersView(TemplateView, FilteredQuerysetMixin, PaginatedQuerysetMixin):
@require_http_methods(['POST'])
def add_cart_item(request, *args, **kwargs):
product = get_object_or_404(Product, pk=request.POST.get('product'))
variant = None
if product.has_variants:
variant = get_object_or_404(ProductVariant, pk=request.POST.get('variant'), product=product)
cart, created = get_or_create_cart(request)
response = HttpResponse(status=201, headers={'HX-Trigger': 'updated-cart'})
if created and request.user.is_anonymous:
response.set_cookie(ANONYMOUS_CART_ID_COOKIE_NAME, cart.uuid, samesite='strict')
# Comprobamos si existe una línea de carrito para ese carrito de ese producto
existing_cart_item = CartItem.objects.filter(cart=cart, product=product).first()
# Comprobamos si existe una línea de carrito para ese carrito de ese producto/variante
existing_cart_item = CartItem.objects.filter(cart=cart, product=product, variant=variant).first()
# Si existe, simplemente le sumamos la cantidad a la línea ya existente
if existing_cart_item is not None:
@@ -216,7 +232,7 @@ def add_cart_item(request, *args, **kwargs):
existing_cart_item.save()
# Si no, lo creamos
else:
CartItem.objects.create(cart=cart, quantity=request.POST.get('quantity'), product=product)
CartItem.objects.create(cart=cart, quantity=request.POST.get('quantity'), product=product, variant=variant)
return response