From 332e0fc5f4d5dd698136ab2878e262149fa103ac Mon Sep 17 00:00:00 2001 From: Pablo Moreno Date: Thu, 23 Jul 2026 16:53:16 +0200 Subject: [PATCH] fix: improved UI --- backoffice/forms.py | 30 +++++- .../products/_product_detail_fragment.html | 23 ++++- .../backoffice/products/product_create.html | 96 +++++++++++++++++++ backoffice/tests/test_products.py | 68 +++++++++++++ backoffice/views/products.py | 48 +++++++++- shop/models.py | 5 + 6 files changed, 264 insertions(+), 6 deletions(-) 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 @@ -
- +
+

Precios

+ +
+
+ + + + {% for price in prices %} + + + + + + + + {% empty %} + + {% endfor %} + +
FechaPrecioImpuestoCon impuestos
{{ price.date|date:'d/m/Y H:i' }}{{ price.price }} €{{ price.tax.code }}{{ price.price_with_tax }} €{% if price.current %}Actual{% endif %}
Este producto no tiene precios.
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

+ +
+ +
+

Este producto no tiene imágenes.

+
+ {% if image_errors %} +
    + {% for error in image_errors %}
  • {{ error }}
  • {% endfor %} +
+ {% endif %} +
+
Cancelar
{% 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')