feat: create/edit product views

This commit is contained in:
2026-07-23 16:19:19 +02:00
parent 2a9407e676
commit 1d11ea9b6e
16 changed files with 574 additions and 175 deletions
+51
View File
@@ -63,6 +63,57 @@
document.body.addEventListener('backoffice:modal-close', function () {
document.getElementById('backoffice-modal').close();
});
// Combobox de búsqueda (DaisyUI + HTMX): el <input> de texto busca vía htmx
// (ver _combobox.html); seleccionar/quitar un resultado es JS puro, sin ida
// y vuelta al servidor, igual que ya se hace para cerrar el modal.
function backofficeComboboxSelect(containerId, value, label) {
const container = document.getElementById(containerId);
const multiple = container.dataset.multiple === 'true';
const fieldName = container.dataset.fieldName;
const chips = container.querySelector('.combobox-chips');
const hiddenInputs = container.querySelector('.combobox-hidden-inputs');
const search = container.querySelector('.combobox-search');
const results = container.querySelector('.combobox-results');
if (hiddenInputs.querySelector('input[value="' + value + '"]')) {
search.value = '';
results.innerHTML = '';
return;
}
if (!multiple) {
chips.innerHTML = '';
hiddenInputs.innerHTML = '';
}
const hidden = document.createElement('input');
hidden.type = 'hidden';
hidden.name = fieldName;
hidden.value = value;
hiddenInputs.appendChild(hidden);
const chip = document.createElement('span');
chip.className = 'badge badge-primary gap-1';
chip.dataset.value = value;
chip.appendChild(document.createTextNode(label));
const removeButton = document.createElement('button');
removeButton.type = 'button';
removeButton.className = 'leading-none';
removeButton.textContent = '×';
removeButton.onclick = function () { backofficeComboboxRemove(containerId, value); };
chip.appendChild(removeButton);
chips.appendChild(chip);
search.value = '';
results.innerHTML = '';
}
function backofficeComboboxRemove(containerId, value) {
const container = document.getElementById(containerId);
container.querySelector('.combobox-hidden-inputs input[value="' + value + '"]').remove();
container.querySelector('.combobox-chips [data-value="' + value + '"]').remove();
}
</script>
</body>
</html>
@@ -6,7 +6,11 @@
<div class="flex justify-between items-center mb-4">
<h1 class="text-2xl font-semibold">{{ title }}</h1>
{% if create_url %}
<button class="btn btn-primary" hx-get="{{ create_url }}" hx-target="#backoffice-modal-box" hx-swap="innerHTML">Añadir</button>
{% if create_is_page %}
<a class="btn btn-primary" href="{{ create_url }}">Añadir</a>
{% else %}
<button class="btn btn-primary" hx-get="{{ create_url }}" hx-target="#backoffice-modal-box" hx-swap="innerHTML">Añadir</button>
{% endif %}
{% endif %}
</div>
@@ -0,0 +1,28 @@
<div id="combobox-{{ field_name }}" class="combobox" data-field-name="{{ field_name }}" data-multiple="{{ multiple|yesno:'true,false' }}">
<div class="combobox-chips flex flex-wrap gap-1 mb-2">
{% for item in selected %}
<span class="badge badge-primary gap-1" data-value="{{ item.pk }}">
{{ item }}
<button type="button" class="leading-none" onclick="backofficeComboboxRemove('combobox-{{ field_name }}', '{{ item.pk }}')">×</button>
</span>
{% endfor %}
</div>
<div class="combobox-hidden-inputs">
{% for item in selected %}
<input type="hidden" name="{{ field_name }}" value="{{ item.pk }}">
{% endfor %}
</div>
<div class="dropdown w-full">
<input
type="text"
class="input input-bordered w-full combobox-search"
placeholder="Buscar..."
autocomplete="off"
hx-get="{% url 'backoffice:product_field_search' field_name %}"
hx-trigger="keyup changed delay:300ms, focus"
hx-target="next .combobox-results"
hx-swap="innerHTML"
name="q">
<ul class="combobox-results menu dropdown-content bg-base-100 rounded-box z-10 w-full p-2 shadow-sm"></ul>
</div>
</div>
@@ -0,0 +1,9 @@
{% for result in results %}
<li>
<button type="button" onclick="backofficeComboboxSelect('combobox-{{ field_name }}', '{{ result.pk }}', '{{ result|escapejs }}')">
{{ result }}
</button>
</li>
{% empty %}
<li><span class="opacity-50 px-2 py-1">Sin resultados</span></li>
{% endfor %}
@@ -0,0 +1,14 @@
<div id="product-inline-{{ field_name }}">
<form hx-post="{{ request.path }}" hx-target="#product-inline-{{ field_name }}" hx-swap="outerHTML">
{% csrf_token %}
{% if field_name == 'brand' or field_name == 'categories' or field_name == 'tags' %}
{% include 'backoffice/products/_combobox.html' %}
{% else %}
{{ form.as_p }}
{% endif %}
<div class="flex gap-2 mt-1">
<button class="btn btn-primary btn-xs">Guardar</button>
<button type="button" class="btn btn-xs" hx-get="{{ view_url }}" hx-target="#product-inline-{{ field_name }}" hx-swap="outerHTML">Cancelar</button>
</div>
</form>
</div>
@@ -0,0 +1,26 @@
<div id="product-inline-{{ field_name }}" class="flex items-start gap-2">
{% if field_name == 'name' %}
<h1 class="text-2xl font-semibold">{{ product.name }}</h1>
{% elif field_name == 'description' %}
<p class="whitespace-pre-line {% if not product.description %}opacity-50{% endif %}">{{ product.description|default:'Sin descripción.' }}</p>
{% elif field_name == 'brand' %}
<p class="{% if not product.brand %}opacity-50{% endif %}">{{ product.brand|default:'Sin marca' }}</p>
{% elif field_name == 'categories' %}
<div class="flex flex-wrap gap-1">
{% for category in product.categories.all %}
<span class="badge badge-ghost">{{ category }}</span>
{% empty %}
<span class="opacity-50">Sin categorías</span>
{% endfor %}
</div>
{% elif field_name == 'tags' %}
<div class="flex flex-wrap gap-1">
{% for tag in product.tags.all %}
<span class="badge badge-ghost">{{ tag }}</span>
{% empty %}
<span class="opacity-50">Sin etiquetas</span>
{% endfor %}
</div>
{% endif %}
<button class="btn btn-ghost btn-xs" hx-get="{{ edit_url }}" hx-target="#product-inline-{{ field_name }}" hx-swap="outerHTML" title="Editar"></button>
</div>
@@ -5,31 +5,81 @@
hx-target="#backoffice-detail"
hx-swap="outerHTML">
{% url 'backoffice:product_inline_view' product.pk 'name' as name_view_url %}
{% url 'backoffice:product_inline_edit' product.pk 'name' as name_edit_url %}
{% url 'backoffice:product_inline_view' product.pk 'description' as description_view_url %}
{% url 'backoffice:product_inline_edit' product.pk 'description' as description_edit_url %}
{% url 'backoffice:product_inline_view' product.pk 'brand' as brand_view_url %}
{% url 'backoffice:product_inline_edit' product.pk 'brand' as brand_edit_url %}
{% url 'backoffice:product_inline_view' product.pk 'categories' as categories_view_url %}
{% url 'backoffice:product_inline_edit' product.pk 'categories' as categories_edit_url %}
{% url 'backoffice:product_inline_view' product.pk 'tags' as tags_view_url %}
{% url 'backoffice:product_inline_edit' product.pk 'tags' as tags_edit_url %}
<div class="flex justify-between items-center mb-4">
<h1 class="text-2xl font-semibold">{{ product.name }}</h1>
{% include 'backoffice/products/_inline_view.html' with field_name='name' view_url=name_view_url edit_url=name_edit_url %}
<div class="flex gap-2">
<button class="btn btn-sm" hx-get="{% url 'backoffice:product_update' product.pk %}" hx-target="#backoffice-modal-box" hx-swap="innerHTML">Editar</button>
<button class="btn btn-sm btn-error" hx-get="{% url 'backoffice:product_delete' product.pk %}" hx-target="#backoffice-modal-box" hx-swap="innerHTML">Borrar</button>
</div>
</div>
<div class="stats shadow mb-6 bg-base-100">
<div class="stat">
<div class="stat-title">SKU</div>
<div class="stat-value text-lg">{{ product.sku }}</div>
</div>
<div class="stat">
<div class="stat-title">Stock</div>
<div class="stat-value text-lg">{{ product.stock }}</div>
</div>
<div class="stat">
<div class="stat-title">Precio actual</div>
<div class="stat-value text-lg">
{% if product.price %}{{ product.price.price_with_tax }} €{% else %}—{% endif %}
<div class="flex gap-6 mb-6">
{% with images.first as main_image %}
<div class="shrink-0 w-64">
{% if main_image %}
<img class="w-64 h-64 object-cover rounded-box border border-base-300 shadow" src="{{ main_image.l.url }}" alt="{{ product.name }}">
{% else %}
<div class="w-64 h-64 rounded-box border border-base-300 border-dashed flex items-center justify-center text-sm opacity-50">
Sin imagen
</div>
{% endif %}
</div>
</div>
{% endwith %}
<section class="w-full">
<div class="stats shadow bg-base-100 self-start">
<div class="stat">
<div class="stat-title">SKU</div>
<div class="stat-value text-lg">{{ product.sku }}</div>
</div>
<div class="stat">
<div class="stat-title">Stock</div>
<div class="stat-value text-lg">{{ product.stock }}</div>
</div>
<div class="stat">
<div class="stat-title">Precio actual</div>
<div class="stat-value text-lg">
{% if product.price %}{{ product.price.price_with_tax }} €{% else %}—{% endif %}
</div>
</div>
</div>
<div class="m-4">
{% include 'backoffice/products/_inline_view.html' with field_name='description' view_url=description_view_url edit_url=description_edit_url %}
</div>
<div class="grid grid-cols-3 gap-4 m-4">
<div>
<h3 class="text-sm font-medium opacity-70 mb-1">Marca</h3>
{% include 'backoffice/products/_inline_view.html' with field_name='brand' view_url=brand_view_url edit_url=brand_edit_url %}
</div>
<div>
<h3 class="text-sm font-medium opacity-70 mb-1">Categorías</h3>
{% include 'backoffice/products/_inline_view.html' with field_name='categories' view_url=categories_view_url edit_url=categories_edit_url %}
</div>
<div>
<h3 class="text-sm font-medium opacity-70 mb-1">Etiquetas</h3>
{% include 'backoffice/products/_inline_view.html' with field_name='tags' view_url=tags_view_url edit_url=tags_edit_url %}
</div>
</div>
</section>
</div>
<div class="flex justify-between items-center mb-2">
<h2 class="text-xl font-semibold">Imágenes</h2>
<button class="btn btn-sm btn-primary" hx-get="{% url 'backoffice:product_image_create' product.pk %}" hx-target="#backoffice-modal-box" hx-swap="innerHTML">Añadir imagen</button>
@@ -37,7 +87,10 @@
<div class="flex flex-wrap gap-4 mb-6">
{% for image in images %}
<div class="relative">
<img class="w-24 h-24 object-cover rounded-box border border-base-300" src="{{ image.s.url }}" alt="">
<img class="w-24 h-24 object-cover rounded-box border {% if forloop.first %}border-primary border-2{% else %}border-base-300{% endif %}" src="{{ image.s.url }}" alt="">
{% if forloop.first %}
<span class="badge badge-primary badge-xs absolute -top-2 -left-2">Principal</span>
{% endif %}
<button class="btn btn-xs btn-error absolute -top-2 -right-2" hx-get="{% url 'backoffice:product_image_delete' image.pk %}" hx-target="#backoffice-modal-box" hx-swap="innerHTML"></button>
</div>
{% empty %}
@@ -0,0 +1,63 @@
{% extends 'backoffice/base.html' %}
{% block title %}Nuevo producto{% endblock %}
{% block main %}
<form method="post" enctype="multipart/form-data" class="max-w-3xl">
{% csrf_token %}
<div class="mb-4">
<label class="text-sm font-medium opacity-70 mb-1 block" for="{{ form.name.id_for_label }}">Nombre</label>
{{ form.name }}
{{ form.name.errors }}
</div>
<div class="mb-6">
<label class="text-sm font-medium opacity-70 mb-1 block" for="{{ form.description.id_for_label }}">Descripción</label>
{{ form.description }}
{{ form.description.errors }}
</div>
<div class="grid grid-cols-3 gap-4 mb-6">
<div>
<h3 class="text-sm font-medium opacity-70 mb-1">Marca</h3>
{% include 'backoffice/products/_combobox.html' with field_name='brand' multiple=False %}
</div>
<div>
<h3 class="text-sm font-medium opacity-70 mb-1">Categorías</h3>
{% include 'backoffice/products/_combobox.html' with field_name='categories' multiple=True %}
</div>
<div>
<h3 class="text-sm font-medium opacity-70 mb-1">Etiquetas</h3>
{% include 'backoffice/products/_combobox.html' with field_name='tags' multiple=True %}
</div>
</div>
<div class="grid grid-cols-2 gap-4 mb-4 max-w-md">
<div>
<label class="text-sm font-medium opacity-70 mb-1 block" for="{{ form.sku.id_for_label }}">Código de referencia</label>
{{ form.sku }}
{{ form.sku.errors }}
</div>
<div>
<label class="text-sm font-medium opacity-70 mb-1 block" for="{{ form.stock.id_for_label }}">Stock</label>
{{ form.stock }}
{{ form.stock.errors }}
</div>
</div>
<div class="flex gap-6 mb-6">
<label class="flex items-center gap-2">
{{ form.hidden }} Oculto
</label>
<label class="flex items-center gap-2">
{{ form.is_shipping_method }} Es forma de envío
</label>
</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 %}
+9 -38
View File
@@ -17,53 +17,24 @@ class TestBackofficeHtmxContract(TestCase, CreateProductsMixin):
self.client.force_login(self.superuser)
self.product = self.create_product()
def test_htmx_get_create_form_returns_fragment_not_full_page(self):
def test_product_create_is_a_full_page_not_a_modal_fragment(self):
# ProductCreateView ya no es modal: navegar a la URL de creación siempre
# devuelve la página completa, incluso si la petición lleva HX-Request.
response = self.client.get(reverse('backoffice:product_create'), HTTP_HX_REQUEST='true')
assert response.status_code == 200
body = response.content.decode()
assert '<html' not in body
assert 'Añadir producto' in body
assert '<html' in body
assert 'Nuevo producto' in body
def test_htmx_create_success_returns_204_and_triggers_modal_close_and_refresh(self):
def test_product_create_redirects_to_detail_even_with_hx_request(self):
response = self.client.post(
reverse('backoffice:product_create'),
{
'sku': 'HTMX-1',
'name': 'Producto htmx',
'description': '',
'stock': '0',
'categories': [],
'tags': [],
},
{'sku': 'HTMX-1', 'name': 'Producto htmx', 'description': '', 'stock': '0'},
HTTP_HX_REQUEST='true',
)
assert response.status_code == 204
trigger = json.loads(response['HX-Trigger'])
assert trigger == {'backoffice:modal-close': True, 'backoffice:list-changed': True}
assert Product.objects.filter(sku='HTMX-1').exists()
def test_htmx_create_invalid_returns_200_with_errors_not_204(self):
response = self.client.post(
reverse('backoffice:product_create'),
{'sku': '', 'name': '', 'description': '', 'stock': '0', 'categories': [], 'tags': []},
HTTP_HX_REQUEST='true',
)
assert response.status_code == 200
assert 'HX-Trigger' not in response
def test_non_htmx_create_still_redirects(self):
response = self.client.post(
reverse('backoffice:product_create'),
{
'sku': 'PLAIN-1',
'name': 'Producto normal',
'description': '',
'stock': '0',
'categories': [],
'tags': [],
},
)
product = Product.objects.get(sku='HTMX-1')
assert response.status_code == 302
assert response.url == reverse('backoffice:product_detail', args=[product.pk])
assert 'HX-Trigger' not in response
def test_htmx_variant_create_triggers_refresh(self):
+118 -6
View File
@@ -7,7 +7,7 @@ from django.test import TestCase
from django.urls import reverse
from PIL import Image
from shop.models import Product, ProductImage, ProductPrice, ProductVariant, Tax
from shop.models import Brand, Product, ProductCategory, ProductImage, ProductPrice, ProductVariant, Tag, Tax
from shop.tests.mixins import CreateProductsMixin
@@ -23,19 +23,40 @@ class TestBackofficeProducts(TestCase, CreateProductsMixin):
assert self.product.name in response.content.decode()
def test_create_product(self):
response = self.client.get(reverse('backoffice:product_create'))
assert response.status_code == 200
response = self.client.post(
reverse('backoffice:product_create'),
{'sku': 'NEW-1', 'name': 'Producto nuevo', 'description': '', 'stock': '0'},
)
product = Product.objects.get(sku='NEW-1')
assert response.status_code == 302
assert response.url == reverse('backoffice:product_detail', args=[product.pk])
def test_create_product_with_brand_categories_and_tags_in_one_submit(self):
brand = Brand.objects.create(name='Marca X')
cat1 = ProductCategory.objects.create(name='Cat 1')
cat2 = ProductCategory.objects.create(name='Cat 2')
tag1 = Tag.objects.create(name='Tag 1')
response = self.client.post(
reverse('backoffice:product_create'),
{
'sku': 'NEW-1',
'name': 'Producto nuevo',
'sku': 'NEW-2',
'name': 'Producto completo',
'description': '',
'stock': '0',
'categories': [],
'tags': [],
'brand': brand.pk,
'categories': [cat1.pk, cat2.pk],
'tags': [tag1.pk],
},
)
assert response.status_code == 302
assert Product.objects.filter(sku='NEW-1').exists()
product = Product.objects.get(sku='NEW-2')
assert product.brand == brand
assert set(product.categories.all()) == {cat1, cat2}
assert set(product.tags.all()) == {tag1}
def test_create_variant_for_product(self):
size_m = self.create_attribute_value('Talla', 'M')
@@ -104,6 +125,97 @@ class TestBackofficeProducts(TestCase, CreateProductsMixin):
assert response.status_code == 302
assert not ProductImage.objects.filter(pk=image.pk).exists()
def test_inline_view_name(self):
response = self.client.get(reverse('backoffice:product_inline_view', args=[self.product.pk, 'name']))
assert response.status_code == 200
assert self.product.name in response.content.decode()
def test_inline_edit_name_form(self):
response = self.client.get(reverse('backoffice:product_inline_edit', args=[self.product.pk, 'name']))
assert response.status_code == 200
assert 'name="name"' in response.content.decode()
assert 'description' not in response.content.decode()
def test_inline_update_name(self):
response = self.client.post(
reverse('backoffice:product_inline_edit', args=[self.product.pk, 'name']), {'name': 'Nuevo nombre'}
)
assert response.status_code == 200
self.product.refresh_from_db()
assert self.product.name == 'Nuevo nombre'
assert 'Nuevo nombre' in response.content.decode()
def test_inline_update_description(self):
response = self.client.post(
reverse('backoffice:product_inline_edit', args=[self.product.pk, 'description']),
{'description': 'Descripción actualizada'},
)
assert response.status_code == 200
self.product.refresh_from_db()
assert self.product.description == 'Descripción actualizada'
def test_inline_update_brand(self):
brand = Brand.objects.create(name='Marca X')
response = self.client.post(
reverse('backoffice:product_inline_edit', args=[self.product.pk, 'brand']), {'brand': brand.pk}
)
assert response.status_code == 200
self.product.refresh_from_db()
assert self.product.brand == brand
assert 'Marca X' in response.content.decode()
def test_inline_update_categories_accepts_multiple(self):
cat1 = ProductCategory.objects.create(name='Cat 1')
cat2 = ProductCategory.objects.create(name='Cat 2')
response = self.client.post(
reverse('backoffice:product_inline_edit', args=[self.product.pk, 'categories']),
{'categories': [cat1.pk, cat2.pk]},
)
assert response.status_code == 200
self.product.refresh_from_db()
assert set(self.product.categories.all()) == {cat1, cat2}
def test_inline_update_tags_accepts_multiple(self):
tag1 = Tag.objects.create(name='Tag 1')
tag2 = Tag.objects.create(name='Tag 2')
response = self.client.post(
reverse('backoffice:product_inline_edit', args=[self.product.pk, 'tags']), {'tags': [tag1.pk, tag2.pk]}
)
assert response.status_code == 200
self.product.refresh_from_db()
assert set(self.product.tags.all()) == {tag1, tag2}
def test_inline_edit_rejects_arbitrary_field(self):
response = self.client.get(reverse('backoffice:product_inline_edit', args=[self.product.pk, 'sku']))
assert response.status_code == 404
def test_field_search_filters_by_query(self):
Brand.objects.create(name='Acme')
Brand.objects.create(name='Globex')
response = self.client.get(reverse('backoffice:product_field_search', args=['brand']), {'q': 'Acm'})
assert response.status_code == 200
body = response.content.decode()
assert 'Acme' in body
assert 'Globex' not in body
def test_field_search_without_query_returns_all(self):
Brand.objects.create(name='Acme')
Brand.objects.create(name='Globex')
response = self.client.get(reverse('backoffice:product_field_search', args=['brand']))
assert response.status_code == 200
body = response.content.decode()
assert 'Acme' in body
assert 'Globex' in body
def test_field_search_rejects_arbitrary_field(self):
response = self.client.get(reverse('backoffice:product_field_search', args=['sku']))
assert response.status_code == 404
def test_delete_batch_reduces_stock(self):
self.product.stock = Decimal('10')
self.product.save()
+15 -1
View File
@@ -6,8 +6,22 @@ urlpatterns = [
path('', products.ProductListView.as_view(), name='product_list'),
path('create/', products.ProductCreateView.as_view(), name='product_create'),
path('<int:pk>/', products.ProductDetailView.as_view(), name='product_detail'),
path('<int:pk>/edit/', products.ProductUpdateView.as_view(), name='product_update'),
path('<int:pk>/delete/', products.ProductDeleteView.as_view(), name='product_delete'),
path(
'<int:pk>/inline/<str:field_name>/',
products.ProductInlineViewFragment.as_view(),
name='product_inline_view',
),
path(
'<int:pk>/inline/<str:field_name>/edit/',
products.ProductInlineEditView.as_view(),
name='product_inline_edit',
),
path(
'inline-search/<str:field_name>/',
products.ProductFieldSearchView.as_view(),
name='product_field_search',
),
path('<int:product_pk>/prices/create/', products.ProductPriceCreateView.as_view(), name='product_price_create'),
path(
'<int:product_pk>/variants/create/',
+106 -29
View File
@@ -1,7 +1,8 @@
from django.http import HttpResponse, HttpResponseRedirect
from django.shortcuts import get_object_or_404
from django.urls import reverse_lazy
from django.views.generic import CreateView, DeleteView, DetailView, ListView, UpdateView
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.mixins import (
@@ -9,6 +10,7 @@ from backoffice.mixins import (
BackofficeHtmxMixin,
BackofficeModalDeleteMixin,
BackofficeModalFormMixin,
BackofficeStyledFormMixin,
)
from shop.models import (
Brand,
@@ -49,6 +51,7 @@ class ProductListView(BackofficeCRUDMixin, BackofficeHtmxMixin, ListView):
'title': 'Productos',
'columns': [('SKU', 'sku'), ('Nombre', 'name'), ('Stock', 'stock'), ('Oculto', 'hidden')],
'create_url': reverse_lazy('backoffice:product_create'),
'create_is_page': True,
'detail_url_name': 'backoffice:product_detail',
'delete_url_name': 'backoffice:product_delete',
}
@@ -56,35 +59,18 @@ class ProductListView(BackofficeCRUDMixin, BackofficeHtmxMixin, ListView):
return context
class ProductCreateView(BackofficeCRUDMixin, BackofficeModalFormMixin, CreateView):
class ProductCreateView(BackofficeCRUDMixin, BackofficeStyledFormMixin, CreateView):
"""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)."""
model = Product
permission_required = 'shop.add_product'
section = SECTION
fields = ('sku', 'name', 'description', 'stock', 'brand', 'categories', 'tags', 'hidden', 'is_shipping_method')
template_name = 'backoffice/generic/form.html'
fragment_template_name = FORM_FRAGMENT
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context.update({'title': 'Añadir producto', 'cancel_url': reverse_lazy('backoffice:product_list')})
return context
def get_success_url(self):
return reverse_lazy('backoffice:product_detail', args=[self.object.pk])
class ProductUpdateView(BackofficeCRUDMixin, BackofficeModalFormMixin, UpdateView):
model = Product
permission_required = 'shop.change_product'
section = SECTION
fields = ('sku', 'name', 'description', 'stock', 'brand', 'categories', 'tags', 'hidden', 'is_shipping_method')
template_name = 'backoffice/generic/form.html'
fragment_template_name = FORM_FRAGMENT
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context.update({'title': f'Editar {self.object.name}', 'cancel_url': reverse_lazy('backoffice:product_list')})
return context
template_name = 'backoffice/products/product_create.html'
def get_success_url(self):
return reverse_lazy('backoffice:product_detail', args=[self.object.pk])
@@ -138,6 +124,97 @@ class ProductDetailView(BackofficeCRUDMixin, DetailView):
return context
# --- Edición inline de Product.name / Product.description / Product.brand /
# Product.categories / Product.tags ---
INLINE_EDITABLE_PRODUCT_FIELDS = ('name', 'description', 'brand', 'categories', 'tags')
MULTI_VALUE_INLINE_FIELDS = ('categories', 'tags')
COMBOBOX_INLINE_FIELDS = {
'brand': Brand,
'categories': ProductCategory,
'tags': Tag,
}
class ProductInlineFieldMixin:
def get_field_name(self):
field_name = self.kwargs['field_name']
if field_name not in INLINE_EDITABLE_PRODUCT_FIELDS:
raise Http404
return field_name
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context.update(
{
'field_name': self.get_field_name(),
'view_url': reverse('backoffice:product_inline_view', args=[self.object.pk, self.get_field_name()]),
'edit_url': reverse('backoffice:product_inline_edit', args=[self.object.pk, self.get_field_name()]),
}
)
return context
class ProductInlineViewFragment(BackofficeCRUDMixin, ProductInlineFieldMixin, DetailView):
model = Product
permission_required = 'shop.view_product'
section = SECTION
template_name = 'backoffice/products/_inline_view.html'
class ProductInlineEditView(BackofficeCRUDMixin, BackofficeStyledFormMixin, ProductInlineFieldMixin, UpdateView):
model = Product
permission_required = 'shop.change_product'
section = SECTION
template_name = 'backoffice/products/_inline_edit.html'
def get_form_class(self):
return modelform_factory(Product, fields=[self.get_field_name()])
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
field_name = self.get_field_name()
if field_name in COMBOBOX_INLINE_FIELDS:
multiple = field_name in MULTI_VALUE_INLINE_FIELDS
value = getattr(self.object, field_name)
context.update(
{'multiple': multiple, 'selected': list(value.all()) if multiple else list(filter(None, [value]))}
)
return context
def form_valid(self, form):
self.object = form.save()
return render(self.request, 'backoffice/products/_inline_view.html', self.get_context_data())
class ProductFieldSearchView(BackofficeCRUDMixin, View):
permission_required = 'shop.change_product'
section = SECTION
def get(self, request, *args, **kwargs):
field_name = kwargs['field_name']
model = COMBOBOX_INLINE_FIELDS.get(field_name)
if model is None:
raise Http404
query = request.GET.get('q', '').strip()
queryset = model.objects.all()
if query:
queryset = queryset.filter(name__icontains=query)
return render(
request,
'backoffice/products/_combobox_results.html',
{'results': queryset[:10], 'field_name': field_name},
)
# --- ProductImage (nested under a product) ---