fix: improved UI
CI / test (push) Failing after 1m8s
CI / build (push) Has been skipped

This commit is contained in:
2026-07-23 16:53:16 +02:00
parent d0a4cabb3b
commit 332e0fc5f4
6 changed files with 264 additions and 6 deletions
+29 -1
View File
@@ -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):
@@ -127,8 +127,27 @@
</table>
</div>
<div class="mb-6">
<button class="btn btn-sm" hx-get="{% url 'backoffice:product_price_create' product.pk %}" hx-target="#backoffice-modal-box" hx-swap="innerHTML">Añadir precio al producto</button>
<div class="flex justify-between items-center mb-2">
<h2 class="text-xl font-semibold">Precios</h2>
<button class="btn btn-sm btn-primary" hx-get="{% url 'backoffice:product_price_create' product.pk %}" hx-target="#backoffice-modal-box" hx-swap="innerHTML">Añadir precio</button>
</div>
<div class="overflow-x-auto bg-base-100 rounded-box border border-base-300 mb-6">
<table class="table">
<thead><tr><th>Fecha</th><th>Precio</th><th>Impuesto</th><th>Con impuestos</th><th></th></tr></thead>
<tbody>
{% for price in prices %}
<tr class="hover">
<td>{{ price.date|date:'d/m/Y H:i' }}</td>
<td>{{ price.price }} €</td>
<td>{{ price.tax.code }}</td>
<td>{{ price.price_with_tax }} €</td>
<td>{% if price.current %}<span class="badge badge-success">Actual</span>{% endif %}</td>
</tr>
{% empty %}
<tr><td colspan="5" class="text-center py-6">Este producto no tiene precios.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="flex justify-between items-center mb-2">
@@ -55,9 +55,105 @@
</label>
</div>
<div class="mb-6 max-w-md">
<h3 class="text-sm font-medium opacity-70 mb-1">Precio inicial (opcional)</h3>
<div class="grid grid-cols-2 gap-4">
<div>
{{ price_form.price }}
{{ price_form.price.errors }}
</div>
<div>
{{ price_form.tax }}
{{ price_form.tax.errors }}
</div>
</div>
{{ price_form.non_field_errors }}
</div>
<div class="mb-6">
<div class="flex justify-between items-center mb-2">
<h3 class="text-sm font-medium opacity-70">Imágenes</h3>
<button type="button" class="btn btn-sm btn-primary" onclick="document.getElementById('id_images_input').click()">Añadir imagen</button>
</div>
<input type="file" id="id_images_input" name="images" multiple accept="image/*" class="hidden" onchange="productCreateAddImages(this.files)">
<div id="images-preview" class="flex flex-wrap gap-4">
<p class="opacity-70">Este producto no tiene imágenes.</p>
</div>
{% if image_errors %}
<ul class="text-error text-sm mt-1">
{% for error in image_errors %}<li>{{ error }}</li>{% endfor %}
</ul>
{% endif %}
</div>
<div class="flex gap-2">
<button class="btn btn-primary">Crear producto</button>
<a class="btn" href="{% url 'backoffice:product_list' %}">Cancelar</a>
</div>
</form>
{% endblock %}
{% block extra_js %}
<script>
let productCreateImageFiles = [];
function productCreateAddImages(fileList) {
for (const file of fileList) {
productCreateImageFiles.push(file);
}
renderImagePreviews();
syncImagesInput();
}
function productCreateRemoveImage(index) {
productCreateImageFiles.splice(index, 1);
renderImagePreviews();
syncImagesInput();
}
function renderImagePreviews() {
const container = document.getElementById('images-preview');
container.innerHTML = '';
if (productCreateImageFiles.length === 0) {
const empty = document.createElement('p');
empty.className = 'opacity-70';
empty.textContent = 'Este producto no tiene imágenes.';
container.appendChild(empty);
return;
}
productCreateImageFiles.forEach(function (file, index) {
const wrapper = document.createElement('div');
wrapper.className = 'relative';
const img = document.createElement('img');
img.className = 'w-24 h-24 object-cover rounded-box border ' + (index === 0 ? 'border-primary border-2' : 'border-base-300');
img.src = URL.createObjectURL(file);
wrapper.appendChild(img);
if (index === 0) {
const badge = document.createElement('span');
badge.className = 'badge badge-primary badge-xs absolute -top-2 -left-2';
badge.textContent = 'Principal';
wrapper.appendChild(badge);
}
const removeButton = document.createElement('button');
removeButton.type = 'button';
removeButton.className = 'btn btn-xs btn-error absolute -top-2 -right-2';
removeButton.textContent = '✕';
removeButton.onclick = function () { productCreateRemoveImage(index); };
wrapper.appendChild(removeButton);
container.appendChild(wrapper);
});
}
function syncImagesInput() {
const dataTransfer = new DataTransfer();
productCreateImageFiles.forEach(function (file) { dataTransfer.items.add(file); });
document.getElementById('id_images_input').files = dataTransfer.files;
}
</script>
{% endblock %}
+68
View File
@@ -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')
+45 -3
View File
@@ -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