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 ProductInitialPriceForm, ProductPriceForm, ProductVariantForm from backoffice.mixins import ( BackofficeCRUDMixin, BackofficeHtmxMixin, BackofficeModalDeleteMixin, BackofficeModalFormMixin, BackofficeStyledFormMixin, ) 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'), 'create_is_page': True, 'detail_url_name': 'backoffice:product_detail', 'delete_url_name': 'backoffice:product_delete', } ) return context 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". 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' section = SECTION 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.get_price_without_tax(), tax=price_form.cleaned_data['tax'], current=True, ) return HttpResponseRedirect(self.get_success_url()) 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() context['prices'] = self.object.prices.select_related('tax') 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) --- 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) --- VARIANT_FORM_FRAGMENT = 'backoffice/products/_variant_form_fragment.html' class ProductVariantAttributesContextMixin: """Agrupa los valores de atributo por atributo para pintarlos como tabla (ver _variant_form_fragment.html) en vez del