fix: responsive
CI / test (push) Failing after 1m8s
CI / build (push) Has been skipped

This commit is contained in:
2026-07-23 18:36:07 +02:00
parent 332e0fc5f4
commit ec15d8ef2e
9 changed files with 112 additions and 56 deletions
+40 -2
View File
@@ -1,7 +1,18 @@
from decimal import Decimal
from django import forms from django import forms
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from shop.models import ProductVariant, Tax from shop.models import ProductPrice, ProductVariant, Tax
def price_without_tax(price_with_tax, tax):
"""El staff introduce el precio con impuestos (lo que paga el cliente);
ProductPrice.price se guarda sin impuestos, que es lo que usa el resto de
la aplicación (carritos, pedidos, shop/utils.py) para calcular bases
imponibles/impuestos. ProductPrice.save() recalcula price_with_tax a
partir de este valor, así que no hace falta guardarlo aquí."""
return round(price_with_tax / (1 + Decimal(tax.value) / 100), 2)
class ProductInitialPriceForm(forms.Form): class ProductInitialPriceForm(forms.Form):
@@ -12,7 +23,10 @@ class ProductInitialPriceForm(forms.Form):
max_digits=11, max_digits=11,
decimal_places=2, decimal_places=2,
required=False, required=False,
widget=forms.NumberInput(attrs={'class': 'input input-bordered w-full', 'step': '0.01', 'placeholder': 'Precio'}), label='Precio con impuestos',
widget=forms.NumberInput(
attrs={'class': 'input input-bordered w-full', 'step': '0.01', 'placeholder': 'Precio con impuestos'}
),
) )
tax = forms.ModelChoiceField( tax = forms.ModelChoiceField(
queryset=Tax.objects.all(), queryset=Tax.objects.all(),
@@ -31,6 +45,30 @@ class ProductInitialPriceForm(forms.Form):
return cleaned_data return cleaned_data
def get_price_without_tax(self):
return price_without_tax(self.cleaned_data['price'], self.cleaned_data['tax'])
class ProductPriceForm(forms.ModelForm):
"""El campo `price` del formulario representa el precio CON impuestos (lo
que introduce el staff); al guardar se convierte al precio sin impuestos,
que es lo que almacena ProductPrice.price."""
price = forms.DecimalField(
max_digits=11,
decimal_places=2,
label='Precio con impuestos',
widget=forms.NumberInput(attrs={'step': '0.01'}),
)
class Meta:
model = ProductPrice
fields = ('price', 'tax', 'current')
def save(self, commit=True):
self.instance.price = price_without_tax(self.cleaned_data['price'], self.cleaned_data['tax'])
return super().save(commit=commit)
class ProductVariantForm(forms.ModelForm): class ProductVariantForm(forms.ModelForm):
class Meta: class Meta:
+44 -33
View File
@@ -1,4 +1,3 @@
{% load static %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="es" data-theme="light"> <html lang="es" data-theme="light">
<head> <head>
@@ -9,42 +8,54 @@
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css"/> <link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css"/>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script> <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<link href="https://cdn.jsdelivr.net/npm/daisyui@5/themes.css" rel="stylesheet" type="text/css"/> <link href="https://cdn.jsdelivr.net/npm/daisyui@5/themes.css" rel="stylesheet" type="text/css"/>
<link rel="stylesheet" href="{% static 'css/styles.css' %}">
{% block extra_js %} {% block extra_js %}
{% endblock %} {% endblock %}
</head> </head>
<body class="min-h-screen bg-base-200"> <body class="min-h-screen bg-base-200">
<div class="flex min-h-screen"> <div class="drawer lg:drawer-open">
<aside class="w-64 shrink-0 bg-base-100 border-r border-base-300"> <input id="backoffice-drawer" type="checkbox" class="drawer-toggle"/>
<a href="{% url 'backoffice:dashboard' %}" class="text-xl font-semibold block p-4">Backoffice</a>
<ul class="menu w-full"> <div class="drawer-content flex flex-col min-h-screen">
<li> <div class="navbar bg-base-100 border-b border-base-300 lg:hidden">
<a href="{% url 'backoffice:product_list' %}" class="{% if section == 'products' %}menu-active{% endif %}">Productos</a> <label for="backoffice-drawer" class="btn btn-square btn-ghost" aria-label="Abrir menú"></label>
</li> <span class="text-lg font-semibold ml-2">Backoffice</span>
<li> </div>
<a href="{% url 'backoffice:order_list' %}" class="{% if section == 'orders' %}menu-active{% endif %}">Pedidos</a>
</li> <main class="flex-1 p-4 lg:p-6 overflow-x-hidden">
<li> {% block main %}
<a href="{% url 'backoffice:payment_list' %}" class="{% if section == 'payments' %}menu-active{% endif %}">Pagos</a> {% endblock %}
</li> </main>
<li> </div>
<a href="{% url 'backoffice:customer_list' %}" class="{% if section == 'customers' %}menu-active{% endif %}">Clientes</a>
</li> <div class="drawer-side z-20">
<li> <label for="backoffice-drawer" aria-label="Cerrar menú" class="drawer-overlay"></label>
<a href="{% url 'backoffice:provider_list' %}" class="{% if section == 'providers' %}menu-active{% endif %}">Proveedores</a> <aside class="w-64 min-h-full bg-base-100 border-r border-base-300">
</li> <a href="{% url 'backoffice:dashboard' %}" class="text-xl font-semibold block p-4">Backoffice</a>
<li> <ul class="menu w-full">
<a href="{% url 'backoffice:tax_list' %}" class="{% if section == 'taxes' %}menu-active{% endif %}">Impuestos</a> <li>
</li> <a href="{% url 'backoffice:product_list' %}" class="{% if section == 'products' %}menu-active{% endif %}">Productos</a>
<li> </li>
<a href="{% url 'backoffice:brand_settings' %}" class="{% if section == 'settings' %}menu-active{% endif %}">Marca y ajustes</a> <li>
</li> <a href="{% url 'backoffice:order_list' %}" class="{% if section == 'orders' %}menu-active{% endif %}">Pedidos</a>
</ul> </li>
</aside> <li>
<main class="flex-1 p-6"> <a href="{% url 'backoffice:payment_list' %}" class="{% if section == 'payments' %}menu-active{% endif %}">Pagos</a>
{% block main %} </li>
{% endblock %} <li>
</main> <a href="{% url 'backoffice:customer_list' %}" class="{% if section == 'customers' %}menu-active{% endif %}">Clientes</a>
</li>
<li>
<a href="{% url 'backoffice:provider_list' %}" class="{% if section == 'providers' %}menu-active{% endif %}">Proveedores</a>
</li>
<li>
<a href="{% url 'backoffice:tax_list' %}" class="{% if section == 'taxes' %}menu-active{% endif %}">Impuestos</a>
</li>
<li>
<a href="{% url 'backoffice:brand_settings' %}" class="{% if section == 'settings' %}menu-active{% endif %}">Marca y ajustes</a>
</li>
</ul>
</aside>
</div>
</div> </div>
<dialog id="backoffice-modal" class="modal"> <dialog id="backoffice-modal" class="modal">
@@ -5,7 +5,7 @@
{% block main %} {% block main %}
<h1 class="text-2xl font-semibold mb-6">Backoffice</h1> <h1 class="text-2xl font-semibold mb-6">Backoffice</h1>
<div class="grid grid-cols-2 md:grid-cols-3 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{% for section in sections %} {% for section in sections %}
<a href="{% url section.url_name %}" class="card bg-base-100 border border-base-300 shadow-sm hover:shadow-md transition-shadow"> <a href="{% url section.url_name %}" class="card bg-base-100 border border-base-300 shadow-sm hover:shadow-md transition-shadow">
<div class="card-body"> <div class="card-body">
@@ -3,7 +3,7 @@
{% block title %}{{ title }}{% endblock %} {% block title %}{{ title }}{% endblock %}
{% block main %} {% block main %}
<div class="flex justify-between items-center mb-4"> <div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-2 mb-4">
<h1 class="text-2xl font-semibold">{{ title }}</h1> <h1 class="text-2xl font-semibold">{{ title }}</h1>
{% if create_url %} {% if create_url %}
{% if create_is_page %} {% if create_is_page %}
@@ -5,7 +5,7 @@
{% block main %} {% block main %}
<h1 class="text-2xl font-semibold mb-4">Pedido {{ object.code }}</h1> <h1 class="text-2xl font-semibold mb-4">Pedido {{ object.code }}</h1>
<div class="stats shadow mb-6 bg-base-100"> <div class="stats stats-vertical sm:stats-horizontal shadow mb-6 bg-base-100 w-full sm:w-auto">
<div class="stat"> <div class="stat">
<div class="stat-title">Cliente</div> <div class="stat-title">Cliente</div>
<div class="stat-value text-lg">{{ object.email }}</div> <div class="stat-value text-lg">{{ object.email }}</div>
@@ -16,20 +16,20 @@
{% url 'backoffice:product_inline_view' product.pk 'tags' as tags_view_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 %} {% url 'backoffice:product_inline_edit' product.pk 'tags' as tags_edit_url %}
<div class="flex justify-between items-center mb-4"> <div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-2 mb-4">
{% include 'backoffice/products/_inline_view.html' with field_name='name' view_url=name_view_url edit_url=name_edit_url %} {% 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"> <div class="flex gap-2">
<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> <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> </div>
<div class="flex gap-6 mb-6"> <div class="flex flex-col sm:flex-row gap-6 mb-6">
{% with images.first as main_image %} {% with images.first as main_image %}
<div class="shrink-0 w-64"> <div class="shrink-0 w-full sm:w-64">
{% if main_image %} {% 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 }}"> <img class="w-full h-64 sm:w-64 object-cover rounded-box border border-base-300 shadow" src="{{ main_image.l.url }}" alt="{{ product.name }}">
{% else %} {% else %}
<div class="w-64 h-64 rounded-box border border-base-300 border-dashed flex items-center justify-center text-sm opacity-50"> <div class="w-full h-64 sm:w-64 rounded-box border border-base-300 border-dashed flex items-center justify-center text-sm opacity-50">
Sin imagen Sin imagen
</div> </div>
{% endif %} {% endif %}
@@ -37,7 +37,7 @@
{% endwith %} {% endwith %}
<section class="w-full"> <section class="w-full">
<div class="stats shadow bg-base-100 self-start"> <div class="stats stats-vertical sm:stats-horizontal shadow bg-base-100 self-start w-full sm:w-auto">
<div class="stat"> <div class="stat">
<div class="stat-title">SKU</div> <div class="stat-title">SKU</div>
<div class="stat-value text-lg">{{ product.sku }}</div> <div class="stat-value text-lg">{{ product.sku }}</div>
@@ -58,7 +58,7 @@
{% include 'backoffice/products/_inline_view.html' with field_name='description' view_url=description_view_url edit_url=description_edit_url %} {% include 'backoffice/products/_inline_view.html' with field_name='description' view_url=description_view_url edit_url=description_edit_url %}
</div> </div>
<div class="grid grid-cols-3 gap-4 m-4"> <div class="grid grid-cols-1 sm:grid-cols-3 gap-4 m-4">
<div> <div>
<h3 class="text-sm font-medium opacity-70 mb-1">Marca</h3> <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 %} {% include 'backoffice/products/_inline_view.html' with field_name='brand' view_url=brand_view_url edit_url=brand_edit_url %}
@@ -18,7 +18,7 @@
{{ form.description.errors }} {{ form.description.errors }}
</div> </div>
<div class="grid grid-cols-3 gap-4 mb-6"> <div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
<div> <div>
<h3 class="text-sm font-medium opacity-70 mb-1">Marca</h3> <h3 class="text-sm font-medium opacity-70 mb-1">Marca</h3>
{% include 'backoffice/products/_combobox.html' with field_name='brand' multiple=False %} {% include 'backoffice/products/_combobox.html' with field_name='brand' multiple=False %}
@@ -33,7 +33,7 @@
</div> </div>
</div> </div>
<div class="grid grid-cols-2 gap-4 mb-4 max-w-md"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4 max-w-md">
<div> <div>
<label class="text-sm font-medium opacity-70 mb-1 block" for="{{ form.sku.id_for_label }}">Código de referencia</label> <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 }}
@@ -57,7 +57,7 @@
<div class="mb-6 max-w-md"> <div class="mb-6 max-w-md">
<h3 class="text-sm font-medium opacity-70 mb-1">Precio inicial (opcional)</h3> <h3 class="text-sm font-medium opacity-70 mb-1">Precio inicial (opcional)</h3>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
{{ price_form.price }} {{ price_form.price }}
{{ price_form.price.errors }} {{ price_form.price.errors }}
+11 -4
View File
@@ -75,7 +75,9 @@ class TestBackofficeProducts(TestCase, CreateProductsMixin):
assert response.status_code == 302 assert response.status_code == 302
product = Product.objects.get(sku='NEW-3') product = Product.objects.get(sku='NEW-3')
price = ProductPrice.objects.get(product=product) price = ProductPrice.objects.get(product=product)
assert price.price == Decimal('12.50') # 12.50 es el precio CON impuestos que introduce el staff; se guarda sin impuestos.
assert price.price == Decimal('10.33')
assert price.price_with_tax == Decimal('12.50')
assert price.current is True assert price.current is True
def test_create_product_with_price_missing_tax_shows_error(self): def test_create_product_with_price_missing_tax_shows_error(self):
@@ -123,7 +125,7 @@ class TestBackofficeProducts(TestCase, CreateProductsMixin):
old_price.refresh_from_db() old_price.refresh_from_db()
assert old_price.current is False assert old_price.current is False
new_price = self.product.prices.get(price=Decimal('25.00')) new_price = self.product.prices.get(price=Decimal('20.66'))
assert new_price.current is True assert new_price.current is True
def test_create_variant_for_product(self): def test_create_variant_for_product(self):
@@ -150,6 +152,7 @@ class TestBackofficeProducts(TestCase, CreateProductsMixin):
assert not ProductVariant.objects.filter(sku='V-M2').exists() assert not ProductVariant.objects.filter(sku='V-M2').exists()
def test_create_price_for_product(self): def test_create_price_for_product(self):
# El staff introduce el precio CON impuestos; se guarda sin impuestos.
tax, created = Tax.objects.get_or_create(code='IVA', value=21) tax, created = Tax.objects.get_or_create(code='IVA', value=21)
response = self.client.post( response = self.client.post(
@@ -157,7 +160,9 @@ class TestBackofficeProducts(TestCase, CreateProductsMixin):
{'price': '9.99', 'tax': tax.pk, 'current': 'on'}, {'price': '9.99', 'tax': tax.pk, 'current': 'on'},
) )
assert response.status_code == 302 assert response.status_code == 302
assert ProductPrice.objects.filter(product=self.product, price=Decimal('9.99')).exists() price = ProductPrice.objects.get(product=self.product, current=True)
assert price.price == Decimal('8.26')
assert price.price_with_tax == Decimal('9.99')
def test_create_price_for_variant(self): def test_create_price_for_variant(self):
tax, created = Tax.objects.get_or_create(code='IVA', value=21) tax, created = Tax.objects.get_or_create(code='IVA', value=21)
@@ -169,7 +174,9 @@ class TestBackofficeProducts(TestCase, CreateProductsMixin):
{'price': '19.99', 'tax': tax.pk, 'current': 'on'}, {'price': '19.99', 'tax': tax.pk, 'current': 'on'},
) )
assert response.status_code == 302 assert response.status_code == 302
assert ProductPrice.objects.filter(variant=variant, price=Decimal('19.99')).exists() price = ProductPrice.objects.get(variant=variant, current=True)
assert price.price == Decimal('16.52')
assert price.price_with_tax == Decimal('19.99')
def create_image_file(self, name='test.webp'): def create_image_file(self, name='test.webp'):
image = Image.new('RGB', (256, 256), '#ACACAC') image = Image.new('RGB', (256, 256), '#ACACAC')
+4 -4
View File
@@ -6,7 +6,7 @@ from django.shortcuts import get_object_or_404, render
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
from django.views.generic import CreateView, DeleteView, DetailView, ListView, UpdateView, View from django.views.generic import CreateView, DeleteView, DetailView, ListView, UpdateView, View
from backoffice.forms import ProductInitialPriceForm, ProductVariantForm from backoffice.forms import ProductInitialPriceForm, ProductPriceForm, ProductVariantForm
from backoffice.mixins import ( from backoffice.mixins import (
BackofficeCRUDMixin, BackofficeCRUDMixin,
BackofficeHtmxMixin, BackofficeHtmxMixin,
@@ -109,7 +109,7 @@ class ProductCreateView(BackofficeCRUDMixin, BackofficeStyledFormMixin, CreateVi
if price_form.cleaned_data.get('price') is not None: if price_form.cleaned_data.get('price') is not None:
ProductPrice.objects.create( ProductPrice.objects.create(
product=self.object, product=self.object,
price=price_form.cleaned_data['price'], price=price_form.get_price_without_tax(),
tax=price_form.cleaned_data['tax'], tax=price_form.cleaned_data['tax'],
current=True, current=True,
) )
@@ -378,9 +378,9 @@ class ProductVariantDeleteView(BackofficeCRUDMixin, BackofficeModalDeleteMixin,
class ProductPriceCreateView(BackofficeCRUDMixin, BackofficeModalFormMixin, CreateView): class ProductPriceCreateView(BackofficeCRUDMixin, BackofficeModalFormMixin, CreateView):
model = ProductPrice model = ProductPrice
form_class = ProductPriceForm
permission_required = 'shop.add_productprice' permission_required = 'shop.add_productprice'
section = SECTION section = SECTION
fields = ('price', 'tax', 'current')
template_name = 'backoffice/generic/form.html' template_name = 'backoffice/generic/form.html'
fragment_template_name = FORM_FRAGMENT fragment_template_name = FORM_FRAGMENT
@@ -408,9 +408,9 @@ class ProductPriceCreateView(BackofficeCRUDMixin, BackofficeModalFormMixin, Crea
class ProductVariantPriceCreateView(BackofficeCRUDMixin, BackofficeModalFormMixin, CreateView): class ProductVariantPriceCreateView(BackofficeCRUDMixin, BackofficeModalFormMixin, CreateView):
model = ProductPrice model = ProductPrice
form_class = ProductPriceForm
permission_required = 'shop.add_productprice' permission_required = 'shop.add_productprice'
section = SECTION section = SECTION
fields = ('price', 'tax', 'current')
template_name = 'backoffice/generic/form.html' template_name = 'backoffice/generic/form.html'
fragment_template_name = FORM_FRAGMENT fragment_template_name = FORM_FRAGMENT