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
+5 -3
View File
@@ -10,17 +10,19 @@ jobs:
os: [ubuntu-24.04]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Setup dependencies
run: pip install uv
- name: Lint
run: uv venv && uv run ruff check .
- name: Run tests
env:
DJANGO_SETTINGS_MODULE: config.settings
DEBUG: True
run: uv venv && uv run manage.py collectstatic && uv run pytest --cov --junitxml=junit.xml -o junit_family=legacy && uv run coverage xml
run: uv run manage.py collectstatic && uv run pytest --cov --junitxml=junit.xml -o junit_family=legacy && uv run coverage xml
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v4.0.1
with:
+2 -1
View File
@@ -1,5 +1,6 @@
FROM python:3.13-alpine3.23 AS builder
ENV UV_SYSTEM_PYTHON=1
RUN apk update && apk add gcc libpq-dev musl-dev
COPY pyproject.toml .
RUN pip install uv && uv pip install -r pyproject.toml && pip uninstall -y uv
@@ -16,7 +17,7 @@ ARG gid=1001
RUN addgroup -S ${user} && adduser -S ${user} -G ${user} -u ${uid} -s /bin/sh
RUN apk update && apk add gcc gettext vim libpq-dev
RUN apk update && apk add gettext libpq
RUN chown -R ${user}:${user} /code
USER ${user}
+8
View File
@@ -13,6 +13,14 @@ if not DEBUG:
ALLOWED_HOSTS.append(socket.gethostbyname(socket.gethostname()))
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_HSTS_SECONDS = 3600
SECURE_HSTS_INCLUDE_SUBDOMAINS = False
SECURE_HSTS_PRELOAD = False
DJANGO_APPS = [
'unfold', # before django.contrib.admin
+1 -1
View File
@@ -18,7 +18,6 @@ dependencies = [
"django-watchman==1.3.0",
"django-storages==1.13.2",
"django-unfold==0.42.0",
"django-watchman==1.3.0",
"dj-database-url==1.0.0",
"gonk==0.6.1",
"ipython==8.16.1",
@@ -91,4 +90,5 @@ dev = [
"pytest==9.0.2",
"pytest-cov==4.0.0",
"pytest-django==4.8.0",
"ruff==0.15.22",
]
+1 -1
View File
@@ -1,4 +1,4 @@
#!/bin/sh
python manage.py collectstatic
python manage.py compilemessages
python manage.py compilemessages --locale=en --locale=es
+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()
Generated
+317 -289
View File
File diff suppressed because it is too large Load Diff
@@ -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