Files
shoppy/backoffice/views/products.py
T
pablo 2a9407e676
CI / ci (ubuntu-24.04, 3.13) (push) Has been cancelled
feat: backoffice setup
2026-07-23 13:31:53 +02:00

709 lines
26 KiB
Python

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 backoffice.forms import ProductVariantForm
from backoffice.mixins import (
BackofficeCRUDMixin,
BackofficeHtmxMixin,
BackofficeModalDeleteMixin,
BackofficeModalFormMixin,
)
from shop.models import (
Brand,
Product,
ProductAttribute,
ProductAttributeValue,
ProductBatch,
ProductCategory,
ProductImage,
ProductPrice,
ProductVariant,
Tag,
)
from shop.utils import delete_product_batch
SECTION = 'products'
LIST_FRAGMENT = 'backoffice/generic/_list_fragment.html'
FORM_FRAGMENT = 'backoffice/generic/_form_fragment.html'
DELETE_FRAGMENT = 'backoffice/generic/_confirm_delete_fragment.html'
# --- Product ---
class ProductListView(BackofficeCRUDMixin, BackofficeHtmxMixin, ListView):
model = Product
permission_required = 'shop.view_product'
section = SECTION
paginate_by = 20
template_name = 'backoffice/generic/list.html'
fragment_template_name = LIST_FRAGMENT
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context.update(
{
'title': 'Productos',
'columns': [('SKU', 'sku'), ('Nombre', 'name'), ('Stock', 'stock'), ('Oculto', 'hidden')],
'create_url': reverse_lazy('backoffice:product_create'),
'detail_url_name': 'backoffice:product_detail',
'delete_url_name': 'backoffice:product_delete',
}
)
return context
class ProductCreateView(BackofficeCRUDMixin, BackofficeModalFormMixin, CreateView):
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
def get_success_url(self):
return reverse_lazy('backoffice:product_detail', args=[self.object.pk])
class ProductDeleteView(BackofficeCRUDMixin, BackofficeModalDeleteMixin, DeleteView):
model = Product
permission_required = 'shop.delete_product'
section = SECTION
template_name = 'backoffice/generic/confirm_delete.html'
fragment_template_name = DELETE_FRAGMENT
success_url = reverse_lazy('backoffice:product_list')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['cancel_url'] = reverse_lazy('backoffice:product_list')
return context
def form_valid(self, form):
# Borrar el producto invalida la página en la que estuvieras (lista o su
# propio detalle), así que aquí sí navegamos de verdad en vez de solo
# cerrar el modal y refrescar en sitio.
success_url = self.get_success_url()
self.perform_delete()
if self.is_htmx():
response = HttpResponse(status=200)
response['HX-Redirect'] = success_url
return response
return HttpResponseRedirect(success_url)
class ProductDetailView(BackofficeCRUDMixin, DetailView):
model = Product
permission_required = 'shop.view_product'
section = SECTION
template_name = 'backoffice/products/product_detail.html'
fragment_template_name = 'backoffice/products/_product_detail_fragment.html'
def get_template_names(self):
if self.request.headers.get('HX-Request') == 'true':
return [self.fragment_template_name]
return super().get_template_names()
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['variants'] = self.object.variants.prefetch_related('attribute_values__attribute')
context['batches'] = ProductBatch.objects.filter(product=self.object)
context['images'] = self.object.images.all()
return context
# --- ProductImage (nested under a product) ---
class ProductImageCreateView(BackofficeCRUDMixin, BackofficeModalFormMixin, CreateView):
model = ProductImage
permission_required = 'shop.add_productimage'
section = SECTION
fields = ('original',)
template_name = 'backoffice/generic/form.html'
fragment_template_name = FORM_FRAGMENT
def get_product(self):
return get_object_or_404(Product, pk=self.kwargs['product_pk'])
def get_form(self, form_class=None):
form = super().get_form(form_class)
form.instance.product = self.get_product()
return form
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
product = self.get_product()
context.update(
{'title': f'Añadir imagen a {product.name}', 'cancel_url': reverse_lazy('backoffice:product_detail', args=[product.pk])}
)
return context
def get_success_url(self):
return reverse_lazy('backoffice:product_detail', args=[self.object.product_id])
class ProductImageDeleteView(BackofficeCRUDMixin, BackofficeModalDeleteMixin, DeleteView):
model = ProductImage
permission_required = 'shop.delete_productimage'
section = SECTION
template_name = 'backoffice/generic/confirm_delete.html'
fragment_template_name = DELETE_FRAGMENT
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['cancel_url'] = reverse_lazy('backoffice:product_detail', args=[self.object.product_id])
return context
def get_success_url(self):
return reverse_lazy('backoffice:product_detail', args=[self.object.product_id])
# --- ProductVariant (nested under a product) ---
class ProductVariantCreateView(BackofficeCRUDMixin, BackofficeModalFormMixin, CreateView):
model = ProductVariant
form_class = ProductVariantForm
permission_required = 'shop.add_productvariant'
section = SECTION
template_name = 'backoffice/generic/form.html'
fragment_template_name = FORM_FRAGMENT
def get_product(self):
return get_object_or_404(Product, pk=self.kwargs['product_pk'])
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['product'] = self.get_product()
return kwargs
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
product = self.get_product()
context.update(
{'title': f'Añadir variante a {product.name}', 'cancel_url': reverse_lazy('backoffice:product_detail', args=[product.pk])}
)
return context
def get_success_url(self):
return reverse_lazy('backoffice:product_detail', args=[self.object.product_id])
class ProductVariantUpdateView(BackofficeCRUDMixin, BackofficeModalFormMixin, UpdateView):
model = ProductVariant
form_class = ProductVariantForm
permission_required = 'shop.change_productvariant'
section = SECTION
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 variante {self.object.sku}',
'cancel_url': reverse_lazy('backoffice:product_detail', args=[self.object.product_id]),
}
)
return context
def get_success_url(self):
return reverse_lazy('backoffice:product_detail', args=[self.object.product_id])
class ProductVariantDeleteView(BackofficeCRUDMixin, BackofficeModalDeleteMixin, DeleteView):
model = ProductVariant
permission_required = 'shop.delete_productvariant'
section = SECTION
template_name = 'backoffice/generic/confirm_delete.html'
fragment_template_name = DELETE_FRAGMENT
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['cancel_url'] = reverse_lazy('backoffice:product_detail', args=[self.object.product_id])
return context
def get_success_url(self):
return reverse_lazy('backoffice:product_detail', args=[self.object.product_id])
# --- ProductPrice (create only — la historia es de solo alta, ver shop/models.py:ProductPrice) ---
class ProductPriceCreateView(BackofficeCRUDMixin, BackofficeModalFormMixin, CreateView):
model = ProductPrice
permission_required = 'shop.add_productprice'
section = SECTION
fields = ('price', 'tax', 'current')
template_name = 'backoffice/generic/form.html'
fragment_template_name = FORM_FRAGMENT
def get_product(self):
return get_object_or_404(Product, pk=self.kwargs['product_pk'])
def get_form(self, form_class=None):
# El FK debe fijarse antes de is_valid(), porque ProductPrice.clean()
# exige exactamente uno de product/variant en el momento de validar.
form = super().get_form(form_class)
form.instance.product = self.get_product()
return form
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
product = self.get_product()
context.update(
{'title': f'Añadir precio a {product.name}', 'cancel_url': reverse_lazy('backoffice:product_detail', args=[product.pk])}
)
return context
def get_success_url(self):
return reverse_lazy('backoffice:product_detail', args=[self.object.product_id])
class ProductVariantPriceCreateView(BackofficeCRUDMixin, BackofficeModalFormMixin, CreateView):
model = ProductPrice
permission_required = 'shop.add_productprice'
section = SECTION
fields = ('price', 'tax', 'current')
template_name = 'backoffice/generic/form.html'
fragment_template_name = FORM_FRAGMENT
def get_variant(self):
return get_object_or_404(ProductVariant, pk=self.kwargs['variant_pk'])
def get_form(self, form_class=None):
form = super().get_form(form_class)
form.instance.variant = self.get_variant()
return form
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
variant = self.get_variant()
context.update(
{
'title': f'Añadir precio a la variante {variant.sku}',
'cancel_url': reverse_lazy('backoffice:product_detail', args=[variant.product_id]),
}
)
return context
def get_success_url(self):
return reverse_lazy('backoffice:product_detail', args=[self.object.variant.product_id])
# --- ProductBatch (nested under a product) ---
class ProductBatchCreateView(BackofficeCRUDMixin, BackofficeModalFormMixin, CreateView):
model = ProductBatch
permission_required = 'shop.add_productbatch'
section = SECTION
fields = ('code', 'quantity', 'expiration_date', 'provider')
template_name = 'backoffice/generic/form.html'
fragment_template_name = FORM_FRAGMENT
def get_product(self):
return get_object_or_404(Product, pk=self.kwargs['product_pk'])
def get_form(self, form_class=None):
form = super().get_form(form_class)
form.instance.product = self.get_product()
return form
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
product = self.get_product()
context.update(
{'title': f'Añadir remesa a {product.name}', 'cancel_url': reverse_lazy('backoffice:product_detail', args=[product.pk])}
)
return context
def get_success_url(self):
return reverse_lazy('backoffice:product_detail', args=[self.object.product_id])
class ProductBatchDeleteView(BackofficeCRUDMixin, BackofficeModalDeleteMixin, DeleteView):
model = ProductBatch
permission_required = 'shop.delete_productbatch'
section = SECTION
template_name = 'backoffice/generic/confirm_delete.html'
fragment_template_name = DELETE_FRAGMENT
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['cancel_url'] = reverse_lazy('backoffice:product_detail', args=[self.object.product_id])
return context
def get_success_url(self):
return reverse_lazy('backoffice:product_detail', args=[self.object.product_id])
def perform_delete(self):
# Reutiliza la lógica ya existente de descuento de stock al borrar una remesa.
delete_product_batch(self.object)
# --- ProductCategory ---
class ProductCategoryListView(BackofficeCRUDMixin, BackofficeHtmxMixin, ListView):
model = ProductCategory
permission_required = 'shop.view_productcategory'
section = SECTION
template_name = 'backoffice/generic/list.html'
fragment_template_name = LIST_FRAGMENT
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context.update(
{
'title': 'Categorías de producto',
'columns': [('Nombre', 'name'), ('Padre', 'parent'), ('Oculta', 'hidden')],
'create_url': reverse_lazy('backoffice:product_category_create'),
'update_url_name': 'backoffice:product_category_update',
'delete_url_name': 'backoffice:product_category_delete',
}
)
return context
class ProductCategoryCreateView(BackofficeCRUDMixin, BackofficeModalFormMixin, CreateView):
model = ProductCategory
permission_required = 'shop.add_productcategory'
section = SECTION
fields = ('name', 'parent', 'promoted', 'hidden', 'show_in_navbar')
template_name = 'backoffice/generic/form.html'
fragment_template_name = FORM_FRAGMENT
success_url = reverse_lazy('backoffice:product_category_list')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context.update({'title': 'Añadir categoría', 'cancel_url': reverse_lazy('backoffice:product_category_list')})
return context
class ProductCategoryUpdateView(BackofficeCRUDMixin, BackofficeModalFormMixin, UpdateView):
model = ProductCategory
permission_required = 'shop.change_productcategory'
section = SECTION
fields = ('name', 'parent', 'promoted', 'hidden', 'show_in_navbar')
template_name = 'backoffice/generic/form.html'
fragment_template_name = FORM_FRAGMENT
success_url = reverse_lazy('backoffice:product_category_list')
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_category_list')})
return context
class ProductCategoryDeleteView(BackofficeCRUDMixin, BackofficeModalDeleteMixin, DeleteView):
model = ProductCategory
permission_required = 'shop.delete_productcategory'
section = SECTION
template_name = 'backoffice/generic/confirm_delete.html'
fragment_template_name = DELETE_FRAGMENT
success_url = reverse_lazy('backoffice:product_category_list')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['cancel_url'] = reverse_lazy('backoffice:product_category_list')
return context
# --- ProductAttribute / ProductAttributeValue ---
class ProductAttributeListView(BackofficeCRUDMixin, BackofficeHtmxMixin, ListView):
model = ProductAttribute
permission_required = 'shop.view_productattribute'
section = SECTION
template_name = 'backoffice/generic/list.html'
fragment_template_name = LIST_FRAGMENT
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context.update(
{
'title': 'Atributos de producto',
'columns': [('Nombre', 'name')],
'create_url': reverse_lazy('backoffice:product_attribute_create'),
'detail_url_name': 'backoffice:product_attribute_detail',
'update_url_name': 'backoffice:product_attribute_update',
'delete_url_name': 'backoffice:product_attribute_delete',
}
)
return context
class ProductAttributeCreateView(BackofficeCRUDMixin, BackofficeModalFormMixin, CreateView):
model = ProductAttribute
permission_required = 'shop.add_productattribute'
section = SECTION
fields = ('name',)
template_name = 'backoffice/generic/form.html'
fragment_template_name = FORM_FRAGMENT
success_url = reverse_lazy('backoffice:product_attribute_list')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context.update({'title': 'Añadir atributo', 'cancel_url': reverse_lazy('backoffice:product_attribute_list')})
return context
class ProductAttributeUpdateView(BackofficeCRUDMixin, BackofficeModalFormMixin, UpdateView):
"""Solo edita el nombre del atributo. Los valores se gestionan en ProductAttributeDetailView."""
model = ProductAttribute
permission_required = 'shop.change_productattribute'
section = SECTION
fields = ('name',)
template_name = 'backoffice/generic/form.html'
fragment_template_name = FORM_FRAGMENT
success_url = reverse_lazy('backoffice:product_attribute_list')
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_attribute_list')})
return context
class ProductAttributeDeleteView(BackofficeCRUDMixin, BackofficeModalDeleteMixin, DeleteView):
model = ProductAttribute
permission_required = 'shop.delete_productattribute'
section = SECTION
template_name = 'backoffice/generic/confirm_delete.html'
fragment_template_name = DELETE_FRAGMENT
success_url = reverse_lazy('backoffice:product_attribute_list')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['cancel_url'] = reverse_lazy('backoffice:product_attribute_list')
return context
class ProductAttributeDetailView(BackofficeCRUDMixin, DetailView):
"""Gestión de los valores (ProductAttributeValue) de un atributo."""
model = ProductAttribute
permission_required = 'shop.view_productattribute'
section = SECTION
template_name = 'backoffice/products/attribute_detail.html'
fragment_template_name = 'backoffice/products/_attribute_detail_fragment.html'
def get_template_names(self):
if self.request.headers.get('HX-Request') == 'true':
return [self.fragment_template_name]
return super().get_template_names()
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['values'] = self.object.values.all()
return context
class ProductAttributeValueCreateView(BackofficeCRUDMixin, BackofficeModalFormMixin, CreateView):
model = ProductAttributeValue
permission_required = 'shop.add_productattributevalue'
section = SECTION
fields = ('value',)
template_name = 'backoffice/generic/form.html'
fragment_template_name = FORM_FRAGMENT
def get_attribute(self):
return get_object_or_404(ProductAttribute, pk=self.kwargs['attribute_pk'])
def get_form(self, form_class=None):
form = super().get_form(form_class)
form.instance.attribute = self.get_attribute()
return form
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
attribute = self.get_attribute()
context.update(
{
'title': f'Añadir valor a {attribute.name}',
'cancel_url': reverse_lazy('backoffice:product_attribute_detail', args=[attribute.pk]),
}
)
return context
def get_success_url(self):
return reverse_lazy('backoffice:product_attribute_detail', args=[self.object.attribute_id])
class ProductAttributeValueDeleteView(BackofficeCRUDMixin, BackofficeModalDeleteMixin, DeleteView):
model = ProductAttributeValue
permission_required = 'shop.delete_productattributevalue'
section = SECTION
template_name = 'backoffice/generic/confirm_delete.html'
fragment_template_name = DELETE_FRAGMENT
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['cancel_url'] = reverse_lazy('backoffice:product_attribute_detail', args=[self.object.attribute_id])
return context
def get_success_url(self):
return reverse_lazy('backoffice:product_attribute_detail', args=[self.object.attribute_id])
# --- Brand / Tag ---
class BrandListView(BackofficeCRUDMixin, BackofficeHtmxMixin, ListView):
model = Brand
permission_required = 'shop.view_brand'
section = SECTION
template_name = 'backoffice/generic/list.html'
fragment_template_name = LIST_FRAGMENT
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context.update(
{
'title': 'Marcas',
'columns': [('Nombre', 'name')],
'create_url': reverse_lazy('backoffice:brand_create'),
'update_url_name': 'backoffice:brand_update',
'delete_url_name': 'backoffice:brand_delete',
}
)
return context
class BrandCreateView(BackofficeCRUDMixin, BackofficeModalFormMixin, CreateView):
model = Brand
permission_required = 'shop.add_brand'
section = SECTION
fields = ('name',)
template_name = 'backoffice/generic/form.html'
fragment_template_name = FORM_FRAGMENT
success_url = reverse_lazy('backoffice:brand_list')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context.update({'title': 'Añadir marca', 'cancel_url': reverse_lazy('backoffice:brand_list')})
return context
class BrandUpdateView(BackofficeCRUDMixin, BackofficeModalFormMixin, UpdateView):
model = Brand
permission_required = 'shop.change_brand'
section = SECTION
fields = ('name',)
template_name = 'backoffice/generic/form.html'
fragment_template_name = FORM_FRAGMENT
success_url = reverse_lazy('backoffice:brand_list')
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:brand_list')})
return context
class BrandDeleteView(BackofficeCRUDMixin, BackofficeModalDeleteMixin, DeleteView):
model = Brand
permission_required = 'shop.delete_brand'
section = SECTION
template_name = 'backoffice/generic/confirm_delete.html'
fragment_template_name = DELETE_FRAGMENT
success_url = reverse_lazy('backoffice:brand_list')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['cancel_url'] = reverse_lazy('backoffice:brand_list')
return context
class TagListView(BackofficeCRUDMixin, BackofficeHtmxMixin, ListView):
model = Tag
permission_required = 'shop.view_tag'
section = SECTION
template_name = 'backoffice/generic/list.html'
fragment_template_name = LIST_FRAGMENT
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context.update(
{
'title': 'Etiquetas',
'columns': [('Nombre', 'name')],
'create_url': reverse_lazy('backoffice:tag_create'),
'update_url_name': 'backoffice:tag_update',
'delete_url_name': 'backoffice:tag_delete',
}
)
return context
class TagCreateView(BackofficeCRUDMixin, BackofficeModalFormMixin, CreateView):
model = Tag
permission_required = 'shop.add_tag'
section = SECTION
fields = ('name',)
template_name = 'backoffice/generic/form.html'
fragment_template_name = FORM_FRAGMENT
success_url = reverse_lazy('backoffice:tag_list')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context.update({'title': 'Añadir etiqueta', 'cancel_url': reverse_lazy('backoffice:tag_list')})
return context
class TagUpdateView(BackofficeCRUDMixin, BackofficeModalFormMixin, UpdateView):
model = Tag
permission_required = 'shop.change_tag'
section = SECTION
fields = ('name',)
template_name = 'backoffice/generic/form.html'
fragment_template_name = FORM_FRAGMENT
success_url = reverse_lazy('backoffice:tag_list')
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:tag_list')})
return context
class TagDeleteView(BackofficeCRUDMixin, BackofficeModalDeleteMixin, DeleteView):
model = Tag
permission_required = 'shop.delete_tag'
section = SECTION
template_name = 'backoffice/generic/confirm_delete.html'
fragment_template_name = DELETE_FRAGMENT
success_url = reverse_lazy('backoffice:tag_list')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['cancel_url'] = reverse_lazy('backoffice:tag_list')
return context