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 import forms
from django.core.exceptions import ValidationError 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): class ProductVariantForm(forms.ModelForm):
@@ -127,8 +127,27 @@
</table> </table>
</div> </div>
<div class="mb-6"> <div class="flex justify-between items-center mb-2">
<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> <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>
<div class="flex justify-between items-center mb-2"> <div class="flex justify-between items-center mb-2">
@@ -55,9 +55,105 @@
</label> </label>
</div> </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"> <div class="flex gap-2">
<button class="btn btn-primary">Crear producto</button> <button class="btn btn-primary">Crear producto</button>
<a class="btn" href="{% url 'backoffice:product_list' %}">Cancelar</a> <a class="btn" href="{% url 'backoffice:product_list' %}">Cancelar</a>
</div> </div>
</form> </form>
{% endblock %} {% 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.categories.all()) == {cat1, cat2}
assert set(product.tags.all()) == {tag1} 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): def test_create_variant_for_product(self):
size_m = self.create_attribute_value('Talla', 'M') 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.forms import modelform_factory
from django.http import Http404, HttpResponse, HttpResponseRedirect from django.http import Http404, HttpResponse, HttpResponseRedirect
from django.shortcuts import get_object_or_404, render from django.shortcuts import get_object_or_404, render
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
from django.views.generic import CreateView, DeleteView, DetailView, ListView, UpdateView, View 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 ( from backoffice.mixins import (
BackofficeCRUDMixin, BackofficeCRUDMixin,
BackofficeHtmxMixin, 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 """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 detalle (incluido el combobox de marca/categorías/etiquetas), pero todo en
un único formulario: nada se envía al servidor hasta pulsar "Crear un único formulario: nada se envía al servidor hasta pulsar "Crear
producto" (variantes, precios, imágenes y remesas sí requieren que el producto". Las imágenes y el precio inicial se suben/crean en el mismo
producto exista, así que se añaden después desde el detalle).""" 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 model = Product
permission_required = 'shop.add_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') fields = ('sku', 'name', 'description', 'stock', 'brand', 'categories', 'tags', 'hidden', 'is_shipping_method')
template_name = 'backoffice/products/product_create.html' 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): def get_success_url(self):
return reverse_lazy('backoffice:product_detail', args=[self.object.pk]) 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): class ProductDeleteView(BackofficeCRUDMixin, BackofficeModalDeleteMixin, DeleteView):
model = Product model = Product
@@ -121,6 +162,7 @@ class ProductDetailView(BackofficeCRUDMixin, DetailView):
context['variants'] = self.object.variants.prefetch_related('attribute_values__attribute') context['variants'] = self.object.variants.prefetch_related('attribute_values__attribute')
context['batches'] = ProductBatch.objects.filter(product=self.object) context['batches'] = ProductBatch.objects.filter(product=self.object)
context['images'] = self.object.images.all() context['images'] = self.object.images.all()
context['prices'] = self.object.prices.select_related('tax')
return context return context
+5
View File
@@ -209,6 +209,11 @@ class ProductPrice(TimestampedModel):
self.price_with_tax = round(self.price + tax_value, 2) self.price_with_tax = round(self.price + tax_value, 2)
super().save() super().save()
if self.current:
ProductPrice.objects.filter(product=self.product, variant=self.variant).exclude(pk=self.pk).update(
current=False
)
class Meta: class Meta:
verbose_name = _('precio de producto') verbose_name = _('precio de producto')
verbose_name_plural = _('precio de producto') verbose_name_plural = _('precio de producto')