feat: added product variants
This commit is contained in:
@@ -10,17 +10,19 @@ jobs:
|
|||||||
os: [ubuntu-24.04]
|
os: [ubuntu-24.04]
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-python@v2
|
- uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
- name: Setup dependencies
|
- name: Setup dependencies
|
||||||
run: pip install uv
|
run: pip install uv
|
||||||
|
- name: Lint
|
||||||
|
run: uv venv && uv run ruff check .
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
env:
|
env:
|
||||||
DJANGO_SETTINGS_MODULE: config.settings
|
DJANGO_SETTINGS_MODULE: config.settings
|
||||||
DEBUG: True
|
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
|
- name: Upload coverage reports to Codecov
|
||||||
uses: codecov/codecov-action@v4.0.1
|
uses: codecov/codecov-action@v4.0.1
|
||||||
with:
|
with:
|
||||||
|
|||||||
+2
-1
@@ -1,5 +1,6 @@
|
|||||||
FROM python:3.13-alpine3.23 AS builder
|
FROM python:3.13-alpine3.23 AS builder
|
||||||
ENV UV_SYSTEM_PYTHON=1
|
ENV UV_SYSTEM_PYTHON=1
|
||||||
|
RUN apk update && apk add gcc libpq-dev musl-dev
|
||||||
COPY pyproject.toml .
|
COPY pyproject.toml .
|
||||||
RUN pip install uv && uv pip install -r pyproject.toml && pip uninstall -y uv
|
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 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
|
RUN chown -R ${user}:${user} /code
|
||||||
|
|
||||||
USER ${user}
|
USER ${user}
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ if not DEBUG:
|
|||||||
|
|
||||||
ALLOWED_HOSTS.append(socket.gethostbyname(socket.gethostname()))
|
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 = [
|
DJANGO_APPS = [
|
||||||
'unfold', # before django.contrib.admin
|
'unfold', # before django.contrib.admin
|
||||||
|
|||||||
+1
-1
@@ -18,7 +18,6 @@ dependencies = [
|
|||||||
"django-watchman==1.3.0",
|
"django-watchman==1.3.0",
|
||||||
"django-storages==1.13.2",
|
"django-storages==1.13.2",
|
||||||
"django-unfold==0.42.0",
|
"django-unfold==0.42.0",
|
||||||
"django-watchman==1.3.0",
|
|
||||||
"dj-database-url==1.0.0",
|
"dj-database-url==1.0.0",
|
||||||
"gonk==0.6.1",
|
"gonk==0.6.1",
|
||||||
"ipython==8.16.1",
|
"ipython==8.16.1",
|
||||||
@@ -91,4 +90,5 @@ dev = [
|
|||||||
"pytest==9.0.2",
|
"pytest==9.0.2",
|
||||||
"pytest-cov==4.0.0",
|
"pytest-cov==4.0.0",
|
||||||
"pytest-django==4.8.0",
|
"pytest-django==4.8.0",
|
||||||
|
"ruff==0.15.22",
|
||||||
]
|
]
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
|
||||||
python manage.py collectstatic
|
python manage.py collectstatic
|
||||||
python manage.py compilemessages
|
python manage.py compilemessages --locale=en --locale=es
|
||||||
|
|||||||
+35
-3
@@ -9,10 +9,13 @@ from shop.models import (
|
|||||||
Order,
|
Order,
|
||||||
OrderLine,
|
OrderLine,
|
||||||
Product,
|
Product,
|
||||||
|
ProductAttribute,
|
||||||
|
ProductAttributeValue,
|
||||||
ProductBatch,
|
ProductBatch,
|
||||||
ProductCategory,
|
ProductCategory,
|
||||||
ProductPrice,
|
ProductPrice,
|
||||||
ProductImage,
|
ProductImage,
|
||||||
|
ProductVariant,
|
||||||
Provider,
|
Provider,
|
||||||
ShippingMethod,
|
ShippingMethod,
|
||||||
ShopSettings,
|
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.
|
# Register your models here.
|
||||||
@admin.register(Product)
|
@admin.register(Product)
|
||||||
class ProductAdmin(ModelAdmin):
|
class ProductAdmin(ModelAdmin):
|
||||||
@@ -42,8 +52,8 @@ class TaxAdmin(ModelAdmin):
|
|||||||
|
|
||||||
@admin.register(OrderLine)
|
@admin.register(OrderLine)
|
||||||
class OrderLineAdmin(ModelAdmin):
|
class OrderLineAdmin(ModelAdmin):
|
||||||
autocomplete_fields = ('product',)
|
autocomplete_fields = ('product', 'variant')
|
||||||
list_display = ('id', 'product', 'price', 'quantity')
|
list_display = ('id', 'product', 'variant', 'price', 'quantity')
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Order)
|
@admin.register(Order)
|
||||||
@@ -78,7 +88,7 @@ class CartAdmin(ModelAdmin):
|
|||||||
|
|
||||||
@admin.register(CartItem)
|
@admin.register(CartItem)
|
||||||
class CartItemAdmin(ModelAdmin):
|
class CartItemAdmin(ModelAdmin):
|
||||||
list_display = ('id', 'cart', 'product', 'quantity')
|
list_display = ('id', 'cart', 'product', 'variant', 'quantity')
|
||||||
|
|
||||||
|
|
||||||
@admin.register(ShippingMethod)
|
@admin.register(ShippingMethod)
|
||||||
@@ -108,3 +118,25 @@ class ShopSettingsAdmin(ModelAdmin):
|
|||||||
@admin.register(ProductImage)
|
@admin.register(ProductImage)
|
||||||
class ProductImageAdmin(ModelAdmin):
|
class ProductImageAdmin(ModelAdmin):
|
||||||
list_display = ('id', 'original')
|
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,)
|
||||||
|
|||||||
@@ -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
@@ -4,6 +4,7 @@ from uuid import uuid4
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
@@ -91,17 +92,100 @@ class Product(TimestampedModel):
|
|||||||
def price(self):
|
def price(self):
|
||||||
return self.prices.filter(current=True).first()
|
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:
|
class Meta:
|
||||||
verbose_name = _('producto')
|
verbose_name = _('producto')
|
||||||
verbose_name_plural = _('productos')
|
verbose_name_plural = _('productos')
|
||||||
ordering = ('id',)
|
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):
|
class ProductPrice(TimestampedModel):
|
||||||
price = models.DecimalField(max_digits=11, decimal_places=2, verbose_name=_('precio'))
|
price = models.DecimalField(max_digits=11, decimal_places=2, verbose_name=_('precio'))
|
||||||
date = models.DateTimeField(default=timezone.now, verbose_name=_('fecha'))
|
date = models.DateTimeField(default=timezone.now, verbose_name=_('fecha'))
|
||||||
product = models.ForeignKey(
|
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'))
|
tax = models.ForeignKey('shop.Tax', on_delete=models.PROTECT, verbose_name=_('impuesto aplicable'))
|
||||||
price_with_tax = models.DecimalField(
|
price_with_tax = models.DecimalField(
|
||||||
@@ -116,6 +200,10 @@ class ProductPrice(TimestampedModel):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f'{self.price} - {self.tax.code}'
|
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):
|
def save(self, *args, **kwargs):
|
||||||
tax_value = self.price * Decimal(self.tax.value / 100)
|
tax_value = self.price * Decimal(self.tax.value / 100)
|
||||||
self.price_with_tax = round(self.price + tax_value, 2)
|
self.price_with_tax = round(self.price + tax_value, 2)
|
||||||
@@ -275,6 +363,14 @@ class OrderLine(TimestampedModel):
|
|||||||
product = models.ForeignKey(
|
product = models.ForeignKey(
|
||||||
'shop.Product', on_delete=models.CASCADE, null=False, blank=False, verbose_name=_('producto')
|
'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(
|
quantity = models.DecimalField(
|
||||||
max_digits=13, decimal_places=4, default=Decimal('1'), null=False, blank=False, verbose_name=_('cantidad')
|
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_REQUESTED_RETURN = 'RQT', _('devolución solicitada')
|
||||||
STATUS_RETURNED = 'RTN', _('devuelto')
|
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)
|
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'))
|
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):
|
class CartItem(models.Model):
|
||||||
cart = models.ForeignKey('shop.Cart', on_delete=models.CASCADE, related_name='items', verbose_name=_('carrito'))
|
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'))
|
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'))
|
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:
|
class Meta:
|
||||||
verbose_name = _('línea de carrito')
|
verbose_name = _('línea de carrito')
|
||||||
verbose_name_plural = _('líneas de carrito')
|
verbose_name_plural = _('líneas de carrito')
|
||||||
|
|||||||
+18
-1
@@ -2,7 +2,7 @@ from decimal import Decimal
|
|||||||
|
|
||||||
from django.utils.timezone import now
|
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:
|
class CreateProductsMixin:
|
||||||
@@ -13,3 +13,20 @@ class CreateProductsMixin:
|
|||||||
product = Product.objects.create(sku=sku, name=name, description=description, is_shipping_method=is_shipping)
|
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)
|
ProductPrice.objects.create(price=price, product=product, date=now(), tax=tax, current=True)
|
||||||
return product
|
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
|
||||||
|
|||||||
@@ -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
@@ -12,14 +12,29 @@ from django.utils import timezone
|
|||||||
from django.utils.text import gettext_lazy as _
|
from django.utils.text import gettext_lazy as _
|
||||||
|
|
||||||
from shop.exceptions import RedsysPaymentException, RedsysValidationException
|
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
|
from shop.settings import ERROR_CODES
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
def create_order_line_for_product(product: Product, quantity: Decimal, order: Order):
|
def get_effective_price(product: Product, variant: ProductVariant = None) -> ProductPrice:
|
||||||
price = product.prices.last()
|
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)
|
base_total = round(price.price * quantity, 2)
|
||||||
tax_value = price.tax.value / Decimal('100')
|
tax_value = price.tax.value / Decimal('100')
|
||||||
taxes = round(base_total * tax_value, 2)
|
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(
|
return OrderLine.objects.create(
|
||||||
order=order,
|
order=order,
|
||||||
product=product,
|
product=product,
|
||||||
|
variant=variant,
|
||||||
quantity=quantity,
|
quantity=quantity,
|
||||||
price=price.price,
|
price=price.price,
|
||||||
base_total=base_total,
|
base_total=base_total,
|
||||||
@@ -111,7 +127,7 @@ def create_order_from_cart(
|
|||||||
|
|
||||||
for item in cart.items.all():
|
for item in cart.items.all():
|
||||||
product = item.product
|
product = item.product
|
||||||
price: ProductPrice = product.price
|
price: ProductPrice = item.price
|
||||||
base_total = price.price * item.quantity
|
base_total = price.price * item.quantity
|
||||||
tax_value = price.tax.value
|
tax_value = price.tax.value
|
||||||
taxes = base_total * tax_value / Decimal(100)
|
taxes = base_total * tax_value / Decimal(100)
|
||||||
@@ -120,6 +136,7 @@ def create_order_from_cart(
|
|||||||
OrderLine.objects.create(
|
OrderLine.objects.create(
|
||||||
order=order,
|
order=order,
|
||||||
product=product,
|
product=product,
|
||||||
|
variant=item.variant,
|
||||||
quantity=item.quantity,
|
quantity=item.quantity,
|
||||||
price=price.price,
|
price=price.price,
|
||||||
base_total=base_total,
|
base_total=base_total,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
from django.http.response import HttpResponse
|
from django.http.response import HttpResponse
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
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.models import Order
|
||||||
from shop.utils import add_payment_to_order, validate_payment_for_order
|
from shop.utils import add_payment_to_order, validate_payment_for_order
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@csrf_exempt
|
@csrf_exempt
|
||||||
def webhook(request, uuid):
|
def webhook(request, uuid):
|
||||||
@@ -16,6 +20,7 @@ def webhook(request, uuid):
|
|||||||
|
|
||||||
return HttpResponse(status=200)
|
return HttpResponse(status=200)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
logger.exception('Error processing payment webhook for order %s', order.uuid)
|
||||||
order.status = Order.Statuses.STATUS_ERROR
|
order.status = Order.Statuses.STATUS_ERROR
|
||||||
order.save()
|
order.save()
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,20 @@
|
|||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="product" value="{{ product.pk }}">
|
<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">
|
<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">
|
<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">
|
<button class="btn btn-primary">
|
||||||
|
|||||||
@@ -26,6 +26,11 @@
|
|||||||
>
|
>
|
||||||
{{ item.product.name }}
|
{{ item.product.name }}
|
||||||
</a>
|
</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">
|
<div class="flex items-center gap-4">
|
||||||
<form class="flex" hx-post="{% url 'web:delete_cart_item' pk=item.pk %}"
|
<form class="flex" hx-post="{% url 'web:delete_cart_item' pk=item.pk %}"
|
||||||
@@ -50,7 +55,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="text-end md:order-4 md:w-32">
|
<div class="text-end md:order-4 md:w-32">
|
||||||
<p class="text-base font-bold ">
|
<p class="text-base font-bold ">
|
||||||
{{ item.product.price.price_with_tax }}€
|
{{ item.price.price_with_tax }}€
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,11 +25,16 @@
|
|||||||
class="text-sm font-semibold leading-none hover:underline">
|
class="text-sm font-semibold leading-none hover:underline">
|
||||||
{{ cart_item.product.name }}
|
{{ cart_item.product.name }}
|
||||||
</a>
|
</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">
|
<div class="flex items-center justify-between gap-6">
|
||||||
<p
|
<p
|
||||||
class="mt-0.5 truncate text-sm font-normal ">
|
class="mt-0.5 truncate text-sm font-normal ">
|
||||||
{{ cart_item.product.price.price_with_tax }}€
|
{{ cart_item.price.price_with_tax }}€
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form hx-post="{% url 'web:delete_cart_item' pk=cart_item.pk %}"
|
<form hx-post="{% url 'web:delete_cart_item' pk=cart_item.pk %}"
|
||||||
|
|||||||
@@ -39,7 +39,11 @@
|
|||||||
<p
|
<p
|
||||||
class="text-2xl font-extrabold sm:text-3xl "
|
class="text-2xl font-extrabold sm:text-3xl "
|
||||||
>
|
>
|
||||||
|
{% if product.price %}
|
||||||
{{ product.price.price_with_tax }} €
|
{{ product.price.price_with_tax }} €
|
||||||
|
{% elif product.min_variant_price %}
|
||||||
|
{% translate 'Desde' %} {{ product.min_variant_price.price_with_tax }} €
|
||||||
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -166,3 +166,62 @@ class TestCart(TestCase, CreateProductsMixin):
|
|||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
response = self.client.get(reverse('web:cart'))
|
response = self.client.get(reverse('web:cart'))
|
||||||
assert response.status_code == 200
|
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.test import TestCase
|
||||||
from django.urls import reverse
|
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
|
from shop.tests.mixins import CreateProductsMixin
|
||||||
|
|
||||||
|
|
||||||
@@ -111,3 +111,37 @@ class TestOrders(TestCase, CreateProductsMixin):
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
assert not Order.objects.filter(user=self.user).exists()
|
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')
|
tax_total = Decimal('0.00')
|
||||||
|
|
||||||
for item in items:
|
for item in items:
|
||||||
price = ProductPrice.objects.filter(product=item.product, current=True).first()
|
price = item.price
|
||||||
base_total += price.price * item.quantity
|
base_total += price.price * item.quantity
|
||||||
tax_total += round(price.price * Decimal(price.tax.value / 100) * item.quantity, 2)
|
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.decorators.http import require_http_methods
|
||||||
from django.views.generic import CreateView, TemplateView
|
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.redsys import RedsysClient
|
||||||
from shop.utils import create_order_from_cart
|
from shop.utils import create_order_from_cart
|
||||||
from web.forms import CreateOrderForm
|
from web.forms import CreateOrderForm
|
||||||
@@ -37,7 +46,7 @@ class CategoryView(TemplateView):
|
|||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
settings = WebSettings.load()
|
settings = WebSettings.load()
|
||||||
slug = kwargs.get('slug')
|
slug = kwargs.get('slug')
|
||||||
category = ProductCategory.objects.get(slug=slug)
|
category = get_object_or_404(ProductCategory, slug=slug)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'title': _(f'{settings.web_title} - {category.name}'),
|
'title': _(f'{settings.web_title} - {category.name}'),
|
||||||
@@ -53,9 +62,11 @@ class ProductDetail(TemplateView):
|
|||||||
def get_context_data(self, pk, slug, **kwargs):
|
def get_context_data(self, pk, slug, **kwargs):
|
||||||
settings = WebSettings.load()
|
settings = WebSettings.load()
|
||||||
product = get_object_or_404(Product, pk=pk)
|
product = get_object_or_404(Product, pk=pk)
|
||||||
|
variants = product.variants.prefetch_related('attribute_values__attribute')
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'product': product,
|
'product': product,
|
||||||
|
'variants': variants,
|
||||||
'title': f'{settings.web_title} - {product.name}',
|
'title': f'{settings.web_title} - {product.name}',
|
||||||
'description': product.description,
|
'description': product.description,
|
||||||
'image': product.images.first(),
|
'image': product.images.first(),
|
||||||
@@ -201,14 +212,19 @@ class OrdersView(TemplateView, FilteredQuerysetMixin, PaginatedQuerysetMixin):
|
|||||||
@require_http_methods(['POST'])
|
@require_http_methods(['POST'])
|
||||||
def add_cart_item(request, *args, **kwargs):
|
def add_cart_item(request, *args, **kwargs):
|
||||||
product = get_object_or_404(Product, pk=request.POST.get('product'))
|
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)
|
cart, created = get_or_create_cart(request)
|
||||||
response = HttpResponse(status=201, headers={'HX-Trigger': 'updated-cart'})
|
response = HttpResponse(status=201, headers={'HX-Trigger': 'updated-cart'})
|
||||||
|
|
||||||
if created and request.user.is_anonymous:
|
if created and request.user.is_anonymous:
|
||||||
response.set_cookie(ANONYMOUS_CART_ID_COOKIE_NAME, cart.uuid, samesite='strict')
|
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
|
# 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).first()
|
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
|
# Si existe, simplemente le sumamos la cantidad a la línea ya existente
|
||||||
if existing_cart_item is not None:
|
if existing_cart_item is not None:
|
||||||
@@ -216,7 +232,7 @@ def add_cart_item(request, *args, **kwargs):
|
|||||||
existing_cart_item.save()
|
existing_cart_item.save()
|
||||||
# Si no, lo creamos
|
# Si no, lo creamos
|
||||||
else:
|
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
|
return response
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user