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
+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) ---