diff --git a/backoffice/forms.py b/backoffice/forms.py
index 998445d..0308575 100644
--- a/backoffice/forms.py
+++ b/backoffice/forms.py
@@ -1,7 +1,35 @@
from django import forms
from django.core.exceptions import ValidationError
-from shop.models import ProductVariant
+from shop.models import ProductVariant, Tax
+
+
+class ProductInitialPriceForm(forms.Form):
+ """Precio opcional que se puede fijar al crear un producto, junto con el
+ resto de datos, en el mismo envío (ver backoffice/views/products.py:ProductCreateView)."""
+
+ price = forms.DecimalField(
+ max_digits=11,
+ decimal_places=2,
+ required=False,
+ widget=forms.NumberInput(attrs={'class': 'input input-bordered w-full', 'step': '0.01', 'placeholder': 'Precio'}),
+ )
+ tax = forms.ModelChoiceField(
+ queryset=Tax.objects.all(),
+ required=False,
+ widget=forms.Select(attrs={'class': 'select select-bordered w-full'}),
+ )
+
+ def clean(self):
+ cleaned_data = super().clean()
+
+ if cleaned_data.get('price') is not None and not cleaned_data.get('tax'):
+ raise ValidationError('Selecciona un impuesto para el precio.')
+
+ if cleaned_data.get('tax') and cleaned_data.get('price') is None:
+ raise ValidationError('Indica un precio para el impuesto seleccionado.')
+
+ return cleaned_data
class ProductVariantForm(forms.ModelForm):
diff --git a/backoffice/templates/backoffice/products/_product_detail_fragment.html b/backoffice/templates/backoffice/products/_product_detail_fragment.html
index c4159fc..5c5b79c 100644
--- a/backoffice/templates/backoffice/products/_product_detail_fragment.html
+++ b/backoffice/templates/backoffice/products/_product_detail_fragment.html
@@ -127,8 +127,27 @@
-
-
Añadir precio al producto
+
+
Precios
+ Añadir precio
+
+
+
+ Fecha Precio Impuesto Con impuestos
+
+ {% for price in prices %}
+
+ {{ price.date|date:'d/m/Y H:i' }}
+ {{ price.price }} €
+ {{ price.tax.code }}
+ {{ price.price_with_tax }} €
+ {% if price.current %}Actual {% endif %}
+
+ {% empty %}
+ Este producto no tiene precios.
+ {% endfor %}
+
+
diff --git a/backoffice/templates/backoffice/products/product_create.html b/backoffice/templates/backoffice/products/product_create.html
index 585a7b3..dac7966 100644
--- a/backoffice/templates/backoffice/products/product_create.html
+++ b/backoffice/templates/backoffice/products/product_create.html
@@ -55,9 +55,105 @@
+
+
Precio inicial (opcional)
+
+
+ {{ price_form.price }}
+ {{ price_form.price.errors }}
+
+
+ {{ price_form.tax }}
+ {{ price_form.tax.errors }}
+
+
+ {{ price_form.non_field_errors }}
+
+
+
+
+
Imágenes
+ Añadir imagen
+
+
+
+
Este producto no tiene imágenes.
+
+ {% if image_errors %}
+
+ {% for error in image_errors %}{{ error }} {% endfor %}
+
+ {% endif %}
+
+
{% endblock %}
+
+{% block extra_js %}
+
+{% endblock %}
diff --git a/backoffice/tests/test_products.py b/backoffice/tests/test_products.py
index 60b07fa..6c44112 100644
--- a/backoffice/tests/test_products.py
+++ b/backoffice/tests/test_products.py
@@ -58,6 +58,74 @@ class TestBackofficeProducts(TestCase, CreateProductsMixin):
assert set(product.categories.all()) == {cat1, cat2}
assert set(product.tags.all()) == {tag1}
+ def test_create_product_with_initial_price(self):
+ tax, created = Tax.objects.get_or_create(code='IVA', value=21)
+
+ response = self.client.post(
+ reverse('backoffice:product_create'),
+ {
+ 'sku': 'NEW-3',
+ 'name': 'Producto con precio',
+ 'description': '',
+ 'stock': '0',
+ 'price': '12.50',
+ 'tax': tax.pk,
+ },
+ )
+ assert response.status_code == 302
+ product = Product.objects.get(sku='NEW-3')
+ price = ProductPrice.objects.get(product=product)
+ assert price.price == Decimal('12.50')
+ assert price.current is True
+
+ def test_create_product_with_price_missing_tax_shows_error(self):
+ response = self.client.post(
+ reverse('backoffice:product_create'),
+ {'sku': 'NEW-4', 'name': 'Producto incompleto', 'description': '', 'stock': '0', 'price': '9.99'},
+ )
+ assert response.status_code == 200
+ assert not Product.objects.filter(sku='NEW-4').exists()
+
+ def test_create_product_with_images(self):
+ image = Image.new('RGB', (64, 64), '#ACACAC')
+ buffer = BytesIO()
+ image.save(fp=buffer, format='WEBP')
+ file1 = ContentFile(buffer.getvalue(), name='one.webp')
+ buffer2 = BytesIO()
+ image.save(fp=buffer2, format='WEBP')
+ file2 = ContentFile(buffer2.getvalue(), name='two.webp')
+
+ response = self.client.post(
+ reverse('backoffice:product_create'),
+ {
+ 'sku': 'NEW-5',
+ 'name': 'Producto con imagenes',
+ 'description': '',
+ 'stock': '0',
+ 'images': [file1, file2],
+ },
+ format='multipart',
+ )
+ assert response.status_code == 302
+ product = Product.objects.get(sku='NEW-5')
+ assert ProductImage.objects.filter(product=product).count() == 2
+
+ def test_new_current_price_unmarks_previous_current_price(self):
+ tax, created = Tax.objects.get_or_create(code='IVA', value=21)
+ old_price = self.product.price
+ assert old_price.current is True
+
+ response = self.client.post(
+ reverse('backoffice:product_price_create', args=[self.product.pk]),
+ {'price': '25.00', 'tax': tax.pk, 'current': 'on'},
+ )
+ assert response.status_code == 302
+
+ old_price.refresh_from_db()
+ assert old_price.current is False
+ new_price = self.product.prices.get(price=Decimal('25.00'))
+ assert new_price.current is True
+
def test_create_variant_for_product(self):
size_m = self.create_attribute_value('Talla', 'M')
diff --git a/backoffice/views/products.py b/backoffice/views/products.py
index 1c7f1aa..550a8f1 100644
--- a/backoffice/views/products.py
+++ b/backoffice/views/products.py
@@ -1,10 +1,12 @@
+from django import forms
+from django.core.exceptions import ValidationError
from django.forms import modelform_factory
from django.http import Http404, HttpResponse, HttpResponseRedirect
from django.shortcuts import get_object_or_404, render
from django.urls import reverse, reverse_lazy
from django.views.generic import CreateView, DeleteView, DetailView, ListView, UpdateView, View
-from backoffice.forms import ProductVariantForm
+from backoffice.forms import ProductInitialPriceForm, ProductVariantForm
from backoffice.mixins import (
BackofficeCRUDMixin,
BackofficeHtmxMixin,
@@ -63,8 +65,9 @@ class ProductCreateView(BackofficeCRUDMixin, BackofficeStyledFormMixin, CreateVi
"""Página completa (no modal), con el mismo aspecto/widgets que la página de
detalle (incluido el combobox de marca/categorías/etiquetas), pero todo en
un único formulario: nada se envía al servidor hasta pulsar "Crear
- producto" (variantes, precios, imágenes y remesas sí requieren que el
- producto exista, así que se añaden después desde el detalle)."""
+ producto". Las imágenes y el precio inicial se suben/crean en el mismo
+ envío (variantes y remesas sí requieren que el producto ya exista, así que
+ esas se añaden después desde el detalle)."""
model = Product
permission_required = 'shop.add_product'
@@ -72,9 +75,47 @@ class ProductCreateView(BackofficeCRUDMixin, BackofficeStyledFormMixin, CreateVi
fields = ('sku', 'name', 'description', 'stock', 'brand', 'categories', 'tags', 'hidden', 'is_shipping_method')
template_name = 'backoffice/products/product_create.html'
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context.setdefault('price_form', ProductInitialPriceForm())
+ context.setdefault('image_errors', [])
+ return context
+
def get_success_url(self):
return reverse_lazy('backoffice:product_detail', args=[self.object.pk])
+ def form_valid(self, form):
+ price_form = ProductInitialPriceForm(self.request.POST)
+ image_files = self.request.FILES.getlist('images')
+ image_field = forms.ImageField()
+ image_errors = []
+
+ for file in image_files:
+ try:
+ image_field.clean(file)
+ except ValidationError as exc:
+ image_errors.append(f'{file.name}: {", ".join(exc.messages)}')
+
+ if not price_form.is_valid() or image_errors:
+ return self.render_to_response(
+ self.get_context_data(form=form, price_form=price_form, image_errors=image_errors)
+ )
+
+ self.object = form.save()
+
+ for file in image_files:
+ ProductImage.objects.create(product=self.object, original=file)
+
+ if price_form.cleaned_data.get('price') is not None:
+ ProductPrice.objects.create(
+ product=self.object,
+ price=price_form.cleaned_data['price'],
+ tax=price_form.cleaned_data['tax'],
+ current=True,
+ )
+
+ return HttpResponseRedirect(self.get_success_url())
+
class ProductDeleteView(BackofficeCRUDMixin, BackofficeModalDeleteMixin, DeleteView):
model = Product
@@ -121,6 +162,7 @@ class ProductDetailView(BackofficeCRUDMixin, DetailView):
context['variants'] = self.object.variants.prefetch_related('attribute_values__attribute')
context['batches'] = ProductBatch.objects.filter(product=self.object)
context['images'] = self.object.images.all()
+ context['prices'] = self.object.prices.select_related('tax')
return context
diff --git a/shop/models.py b/shop/models.py
index ef64983..e12d743 100644
--- a/shop/models.py
+++ b/shop/models.py
@@ -209,6 +209,11 @@ class ProductPrice(TimestampedModel):
self.price_with_tax = round(self.price + tax_value, 2)
super().save()
+ if self.current:
+ ProductPrice.objects.filter(product=self.product, variant=self.variant).exclude(pk=self.pk).update(
+ current=False
+ )
+
class Meta:
verbose_name = _('precio de producto')
verbose_name_plural = _('precio de producto')