feat: added product variants
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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 %}"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user