fix: improved UI
This commit is contained in:
+29
-1
@@ -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 %}
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user