fix: responsive
This commit is contained in:
+40
-2
@@ -1,7 +1,18 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django import forms
|
||||
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):
|
||||
@@ -12,7 +23,10 @@ class ProductInitialPriceForm(forms.Form):
|
||||
max_digits=11,
|
||||
decimal_places=2,
|
||||
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(
|
||||
queryset=Tax.objects.all(),
|
||||
@@ -31,6 +45,30 @@ class ProductInitialPriceForm(forms.Form):
|
||||
|
||||
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 Meta:
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
{% load static %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="es" data-theme="light">
|
||||
<head>
|
||||
@@ -9,13 +8,28 @@
|
||||
<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>
|
||||
<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 %}
|
||||
{% endblock %}
|
||||
</head>
|
||||
<body class="min-h-screen bg-base-200">
|
||||
<div class="flex min-h-screen">
|
||||
<aside class="w-64 shrink-0 bg-base-100 border-r border-base-300">
|
||||
<div class="drawer lg:drawer-open">
|
||||
<input id="backoffice-drawer" type="checkbox" class="drawer-toggle"/>
|
||||
|
||||
<div class="drawer-content flex flex-col min-h-screen">
|
||||
<div class="navbar bg-base-100 border-b border-base-300 lg:hidden">
|
||||
<label for="backoffice-drawer" class="btn btn-square btn-ghost" aria-label="Abrir menú">☰</label>
|
||||
<span class="text-lg font-semibold ml-2">Backoffice</span>
|
||||
</div>
|
||||
|
||||
<main class="flex-1 p-4 lg:p-6 overflow-x-hidden">
|
||||
{% block main %}
|
||||
{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div class="drawer-side z-20">
|
||||
<label for="backoffice-drawer" aria-label="Cerrar menú" class="drawer-overlay"></label>
|
||||
<aside class="w-64 min-h-full bg-base-100 border-r border-base-300">
|
||||
<a href="{% url 'backoffice:dashboard' %}" class="text-xl font-semibold block p-4">Backoffice</a>
|
||||
<ul class="menu w-full">
|
||||
<li>
|
||||
@@ -41,10 +55,7 @@
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
<main class="flex-1 p-6">
|
||||
{% block main %}
|
||||
{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dialog id="backoffice-modal" class="modal">
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{% block main %}
|
||||
<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 %}
|
||||
<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">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
|
||||
{% 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>
|
||||
{% if create_url %}
|
||||
{% if create_is_page %}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{% block main %}
|
||||
<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-title">Cliente</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_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 %}
|
||||
<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>
|
||||
</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 %}
|
||||
<div class="shrink-0 w-64">
|
||||
<div class="shrink-0 w-full sm: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 }}">
|
||||
<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 %}
|
||||
<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
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -37,7 +37,7 @@
|
||||
{% endwith %}
|
||||
|
||||
<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-title">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 %}
|
||||
</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>
|
||||
<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 %}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
{{ form.description.errors }}
|
||||
</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>
|
||||
<h3 class="text-sm font-medium opacity-70 mb-1">Marca</h3>
|
||||
{% include 'backoffice/products/_combobox.html' with field_name='brand' multiple=False %}
|
||||
@@ -33,7 +33,7 @@
|
||||
</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>
|
||||
<label class="text-sm font-medium opacity-70 mb-1 block" for="{{ form.sku.id_for_label }}">Código de referencia</label>
|
||||
{{ form.sku }}
|
||||
@@ -57,7 +57,7 @@
|
||||
|
||||
<div class="mb-6 max-w-md">
|
||||
<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>
|
||||
{{ price_form.price }}
|
||||
{{ price_form.price.errors }}
|
||||
|
||||
@@ -75,7 +75,9 @@ class TestBackofficeProducts(TestCase, CreateProductsMixin):
|
||||
assert response.status_code == 302
|
||||
product = Product.objects.get(sku='NEW-3')
|
||||
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
|
||||
|
||||
def test_create_product_with_price_missing_tax_shows_error(self):
|
||||
@@ -123,7 +125,7 @@ class TestBackofficeProducts(TestCase, CreateProductsMixin):
|
||||
|
||||
old_price.refresh_from_db()
|
||||
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
|
||||
|
||||
def test_create_variant_for_product(self):
|
||||
@@ -150,6 +152,7 @@ class TestBackofficeProducts(TestCase, CreateProductsMixin):
|
||||
assert not ProductVariant.objects.filter(sku='V-M2').exists()
|
||||
|
||||
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)
|
||||
|
||||
response = self.client.post(
|
||||
@@ -157,7 +160,9 @@ class TestBackofficeProducts(TestCase, CreateProductsMixin):
|
||||
{'price': '9.99', 'tax': tax.pk, 'current': 'on'},
|
||||
)
|
||||
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):
|
||||
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'},
|
||||
)
|
||||
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'):
|
||||
image = Image.new('RGB', (256, 256), '#ACACAC')
|
||||
|
||||
@@ -6,7 +6,7 @@ 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, ProductVariantForm
|
||||
from backoffice.forms import ProductInitialPriceForm, ProductPriceForm, ProductVariantForm
|
||||
from backoffice.mixins import (
|
||||
BackofficeCRUDMixin,
|
||||
BackofficeHtmxMixin,
|
||||
@@ -109,7 +109,7 @@ class ProductCreateView(BackofficeCRUDMixin, BackofficeStyledFormMixin, CreateVi
|
||||
if price_form.cleaned_data.get('price') is not None:
|
||||
ProductPrice.objects.create(
|
||||
product=self.object,
|
||||
price=price_form.cleaned_data['price'],
|
||||
price=price_form.get_price_without_tax(),
|
||||
tax=price_form.cleaned_data['tax'],
|
||||
current=True,
|
||||
)
|
||||
@@ -378,9 +378,9 @@ class ProductVariantDeleteView(BackofficeCRUDMixin, BackofficeModalDeleteMixin,
|
||||
|
||||
class ProductPriceCreateView(BackofficeCRUDMixin, BackofficeModalFormMixin, CreateView):
|
||||
model = ProductPrice
|
||||
form_class = ProductPriceForm
|
||||
permission_required = 'shop.add_productprice'
|
||||
section = SECTION
|
||||
fields = ('price', 'tax', 'current')
|
||||
template_name = 'backoffice/generic/form.html'
|
||||
fragment_template_name = FORM_FRAGMENT
|
||||
|
||||
@@ -408,9 +408,9 @@ class ProductPriceCreateView(BackofficeCRUDMixin, BackofficeModalFormMixin, Crea
|
||||
|
||||
class ProductVariantPriceCreateView(BackofficeCRUDMixin, BackofficeModalFormMixin, CreateView):
|
||||
model = ProductPrice
|
||||
form_class = ProductPriceForm
|
||||
permission_required = 'shop.add_productprice'
|
||||
section = SECTION
|
||||
fields = ('price', 'tax', 'current')
|
||||
template_name = 'backoffice/generic/form.html'
|
||||
fragment_template_name = FORM_FRAGMENT
|
||||
|
||||
|
||||
Reference in New Issue
Block a user