This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class BackofficeConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'backoffice'
|
||||
@@ -0,0 +1,35 @@
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from shop.models import ProductVariant
|
||||
|
||||
|
||||
class ProductVariantForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = ProductVariant
|
||||
fields = ('sku', 'stock', 'attribute_values')
|
||||
widgets = {'attribute_values': forms.CheckboxSelectMultiple}
|
||||
|
||||
def __init__(self, *args, product=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.product = product or self.instance.product
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
attribute_values = cleaned_data.get('attribute_values')
|
||||
|
||||
if attribute_values is not None:
|
||||
combination = set(attribute_values.values_list('pk', flat=True))
|
||||
siblings = ProductVariant.objects.filter(product=self.product).exclude(pk=self.instance.pk)
|
||||
|
||||
for sibling in siblings:
|
||||
if set(sibling.attribute_values.values_list('pk', flat=True)) == combination:
|
||||
raise ValidationError(
|
||||
'Ya existe una variante de este producto con la misma combinación de atributos.'
|
||||
)
|
||||
|
||||
return cleaned_data
|
||||
|
||||
def save(self, commit=True):
|
||||
self.instance.product = self.product
|
||||
return super().save(commit=commit)
|
||||
@@ -0,0 +1,107 @@
|
||||
import json
|
||||
|
||||
from django import forms
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin, UserPassesTestMixin
|
||||
from django.http import HttpResponse, HttpResponseRedirect
|
||||
|
||||
DAISYUI_WIDGET_CLASSES = (
|
||||
(forms.CheckboxInput, 'checkbox'),
|
||||
(forms.CheckboxSelectMultiple, 'checkbox'),
|
||||
(forms.ClearableFileInput, 'file-input file-input-bordered w-full'),
|
||||
(forms.Textarea, 'textarea textarea-bordered w-full'),
|
||||
(forms.Select, 'select select-bordered w-full'),
|
||||
)
|
||||
DAISYUI_DEFAULT_WIDGET_CLASS = 'input input-bordered w-full'
|
||||
|
||||
|
||||
class BackofficeSectionMixin:
|
||||
section = None
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['section'] = self.section
|
||||
return context
|
||||
|
||||
|
||||
class BackofficeAccessMixin(BackofficeSectionMixin, LoginRequiredMixin, UserPassesTestMixin):
|
||||
login_url = 'users:login'
|
||||
|
||||
def test_func(self):
|
||||
return self.request.user.is_staff
|
||||
|
||||
|
||||
class BackofficeCRUDMixin(BackofficeAccessMixin, PermissionRequiredMixin):
|
||||
raise_exception = True
|
||||
|
||||
|
||||
class BackofficeStyledFormMixin:
|
||||
"""Aplica clases de DaisyUI a los widgets del formulario según su tipo,
|
||||
sin necesitar declarar un ModelForm explícito por modelo (a diferencia de
|
||||
web.mixins.StylingMixin, que requiere listar `styled_fields` a mano)."""
|
||||
|
||||
def get_form(self, form_class=None):
|
||||
form = super().get_form(form_class)
|
||||
|
||||
for field in form.fields.values():
|
||||
css_class = DAISYUI_DEFAULT_WIDGET_CLASS
|
||||
|
||||
for widget_type, widget_css_class in DAISYUI_WIDGET_CLASSES:
|
||||
if isinstance(field.widget, widget_type):
|
||||
css_class = widget_css_class
|
||||
break
|
||||
|
||||
existing = field.widget.attrs.get('class', '')
|
||||
field.widget.attrs['class'] = f'{existing} {css_class}'.strip()
|
||||
|
||||
return form
|
||||
|
||||
|
||||
class BackofficeHtmxMixin:
|
||||
"""Toda vista de backoffice puede responder con la página completa (navegación
|
||||
directa) o con un fragmento (carga en el modal, o autorefresco de una lista/detalle)."""
|
||||
|
||||
fragment_template_name = None
|
||||
|
||||
def is_htmx(self):
|
||||
return self.request.headers.get('HX-Request') == 'true'
|
||||
|
||||
def get_template_names(self):
|
||||
if self.is_htmx() and self.fragment_template_name:
|
||||
return [self.fragment_template_name]
|
||||
return super().get_template_names()
|
||||
|
||||
|
||||
class BackofficeModalFormMixin(BackofficeStyledFormMixin, BackofficeHtmxMixin):
|
||||
"""Create/Update pensadas para abrirse en modal. Al guardar con éxito vía htmx
|
||||
no redirige (un <dialog> no navega): responde 204 + HX-Trigger para cerrar el
|
||||
modal y avisar a quien lo abrió (lista o detalle) de que se refresque."""
|
||||
|
||||
trigger_event = 'backoffice:list-changed'
|
||||
|
||||
def form_valid(self, form):
|
||||
self.object = form.save()
|
||||
|
||||
if self.is_htmx():
|
||||
response = HttpResponse(status=204)
|
||||
response['HX-Trigger'] = json.dumps({'backoffice:modal-close': True, self.trigger_event: True})
|
||||
return response
|
||||
|
||||
return HttpResponseRedirect(self.get_success_url())
|
||||
|
||||
|
||||
class BackofficeModalDeleteMixin(BackofficeHtmxMixin):
|
||||
trigger_event = 'backoffice:list-changed'
|
||||
|
||||
def perform_delete(self):
|
||||
self.object.delete()
|
||||
|
||||
def form_valid(self, form):
|
||||
success_url = self.get_success_url()
|
||||
self.perform_delete()
|
||||
|
||||
if self.is_htmx():
|
||||
response = HttpResponse(status=204)
|
||||
response['HX-Trigger'] = json.dumps({'backoffice:modal-close': True, self.trigger_event: True})
|
||||
return response
|
||||
|
||||
return HttpResponseRedirect(success_url)
|
||||
@@ -0,0 +1,68 @@
|
||||
{% load static %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="es" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>{% block title %}Backoffice{% endblock %}</title>
|
||||
<script src="https://unpkg.com/htmx.org@2.0.3"></script>
|
||||
<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">
|
||||
<a href="{% url 'backoffice:dashboard' %}" class="text-xl font-semibold block p-4">Backoffice</a>
|
||||
<ul class="menu w-full">
|
||||
<li>
|
||||
<a href="{% url 'backoffice:product_list' %}" class="{% if section == 'products' %}menu-active{% endif %}">Productos</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url 'backoffice:order_list' %}" class="{% if section == 'orders' %}menu-active{% endif %}">Pedidos</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url 'backoffice:payment_list' %}" class="{% if section == 'payments' %}menu-active{% endif %}">Pagos</a>
|
||||
</li>
|
||||
<li>
|
||||
<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>
|
||||
<main class="flex-1 p-6">
|
||||
{% block main %}
|
||||
{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<dialog id="backoffice-modal" class="modal">
|
||||
<div class="modal-box" id="backoffice-modal-box"></div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<script>
|
||||
document.body.addEventListener('htmx:afterSwap', function (event) {
|
||||
if (event.detail.target && event.detail.target.id === 'backoffice-modal-box') {
|
||||
document.getElementById('backoffice-modal').showModal();
|
||||
}
|
||||
});
|
||||
document.body.addEventListener('backoffice:modal-close', function () {
|
||||
document.getElementById('backoffice-modal').close();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,44 @@
|
||||
{% extends 'backoffice/base.html' %}
|
||||
|
||||
{% block title %}{{ object.email }}{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<h1 class="text-2xl font-semibold mb-1">{{ object.email }}</h1>
|
||||
<p class="mb-6 opacity-70">{{ object.first_name }} {{ object.last_name }}</p>
|
||||
|
||||
<h2 class="text-xl font-semibold mb-2">Direcciones</h2>
|
||||
<div class="overflow-x-auto bg-base-100 rounded-box border border-base-300 mb-6">
|
||||
<table class="table">
|
||||
<thead><tr><th>Tipo</th><th>Dirección</th><th>Localidad</th></tr></thead>
|
||||
<tbody>
|
||||
{% for address in addresses %}
|
||||
<tr class="hover">
|
||||
<td><span class="badge badge-ghost">{{ address.get_address_type_display }}</span></td>
|
||||
<td>{{ address.address }}</td>
|
||||
<td>{{ address.address_town }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="3" class="text-center py-6">Sin direcciones registradas.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2 class="text-xl font-semibold mb-2">Pedidos</h2>
|
||||
<div class="overflow-x-auto bg-base-100 rounded-box border border-base-300">
|
||||
<table class="table">
|
||||
<thead><tr><th>Código</th><th>Estado</th><th>Total</th></tr></thead>
|
||||
<tbody>
|
||||
{% for order in orders %}
|
||||
<tr class="hover">
|
||||
<td><a class="link" href="{% url 'backoffice:order_detail' order.pk %}">{{ order.code }}</a></td>
|
||||
<td><span class="badge badge-ghost">{{ order.get_status_display }}</span></td>
|
||||
<td>{{ order.total }} €</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="3" class="text-center py-6">Sin pedidos.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,17 @@
|
||||
{% extends 'backoffice/base.html' %}
|
||||
|
||||
{% block title %}Backoffice{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<h1 class="text-2xl font-semibold mb-6">Backoffice</h1>
|
||||
|
||||
<div class="grid 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">
|
||||
<h2 class="card-title">{{ section.label }}</h2>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,10 @@
|
||||
<h3 class="text-lg font-bold mb-4">Confirmar borrado</h3>
|
||||
<p class="mb-4">¿Seguro que quieres borrar «{{ object }}»?</p>
|
||||
|
||||
<form method="post" hx-post="{{ request.path }}" hx-target="#backoffice-modal-box" hx-swap="innerHTML">
|
||||
{% csrf_token %}
|
||||
<div class="modal-action">
|
||||
<button class="btn btn-error">Borrar</button>
|
||||
<button type="button" class="btn" onclick="document.getElementById('backoffice-modal').close()">Cancelar</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -0,0 +1,10 @@
|
||||
<h3 class="text-lg font-bold mb-4">{{ title }}</h3>
|
||||
|
||||
<form method="post" enctype="multipart/form-data" hx-encoding="multipart/form-data" hx-post="{{ request.path }}" hx-target="#backoffice-modal-box" hx-swap="innerHTML">
|
||||
{% csrf_token %}
|
||||
{{ form.as_p }}
|
||||
<div class="modal-action">
|
||||
<button class="btn btn-primary">Guardar</button>
|
||||
<button type="button" class="btn" onclick="document.getElementById('backoffice-modal').close()">Cancelar</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -0,0 +1,52 @@
|
||||
{% load backoffice_extras %}
|
||||
|
||||
<div id="backoffice-list"
|
||||
hx-trigger="backoffice:list-changed from:body"
|
||||
hx-get="{{ request.get_full_path }}"
|
||||
hx-target="#backoffice-list"
|
||||
hx-swap="outerHTML">
|
||||
<div class="overflow-x-auto bg-base-100 rounded-box border border-base-300">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
{% for label, attr in columns %}<th>{{ label }}</th>{% endfor %}
|
||||
{% if update_url_name or delete_url_name or detail_url_name %}<th></th>{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for object in object_list %}
|
||||
<tr class="hover">
|
||||
{% for label, attr in columns %}<td>{{ object|getattribute:attr }}</td>{% endfor %}
|
||||
{% if update_url_name or delete_url_name or detail_url_name %}
|
||||
<td class="text-right whitespace-nowrap">
|
||||
{% if detail_url_name %}
|
||||
<a class="btn btn-ghost btn-xs" href="{% url detail_url_name object.pk %}">Ver</a>
|
||||
{% endif %}
|
||||
{% if update_url_name %}
|
||||
<button class="btn btn-ghost btn-xs" hx-get="{% url update_url_name object.pk %}" hx-target="#backoffice-modal-box" hx-swap="innerHTML">Editar</button>
|
||||
{% endif %}
|
||||
{% if delete_url_name %}
|
||||
<button class="btn btn-ghost btn-xs text-error" hx-get="{% url delete_url_name object.pk %}" hx-target="#backoffice-modal-box" hx-swap="innerHTML">Borrar</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="{{ columns|length|add:1 }}" class="text-center py-6">No hay resultados.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if is_paginated %}
|
||||
<div class="join mt-4">
|
||||
{% if page_obj.has_previous %}
|
||||
<a class="join-item btn" href="?page={{ page_obj.previous_page_number }}">«</a>
|
||||
{% endif %}
|
||||
<span class="join-item btn btn-disabled">{{ page_obj.number }} / {{ page_obj.paginator.num_pages }}</span>
|
||||
{% if page_obj.has_next %}
|
||||
<a class="join-item btn" href="?page={{ page_obj.next_page_number }}">»</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -0,0 +1,16 @@
|
||||
{% extends 'backoffice/base.html' %}
|
||||
|
||||
{% block title %}Confirmar borrado{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<h1 class="text-2xl font-semibold mb-4">Confirmar borrado</h1>
|
||||
<p class="mb-4">¿Seguro que quieres borrar «{{ object }}»?</p>
|
||||
|
||||
<form method="post" class="max-w-xl">
|
||||
{% csrf_token %}
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-error">Borrar</button>
|
||||
{% if cancel_url %}<a class="btn" href="{{ cancel_url }}">Cancelar</a>{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,16 @@
|
||||
{% extends 'backoffice/base.html' %}
|
||||
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<h1 class="text-2xl font-semibold mb-4">{{ title }}</h1>
|
||||
|
||||
<form method="post" enctype="multipart/form-data" class="max-w-xl">
|
||||
{% csrf_token %}
|
||||
{{ form.as_p }}
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-primary">Guardar</button>
|
||||
{% if cancel_url %}<a class="btn" href="{{ cancel_url }}">Cancelar</a>{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,14 @@
|
||||
{% extends 'backoffice/base.html' %}
|
||||
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h1 class="text-2xl font-semibold">{{ title }}</h1>
|
||||
{% if create_url %}
|
||||
<button class="btn btn-primary" hx-get="{{ create_url }}" hx-target="#backoffice-modal-box" hx-swap="innerHTML">Añadir</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% include 'backoffice/generic/_list_fragment.html' %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,74 @@
|
||||
{% extends 'backoffice/base.html' %}
|
||||
|
||||
{% block title %}Pedido {{ object.code }}{% endblock %}
|
||||
|
||||
{% 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="stat">
|
||||
<div class="stat-title">Cliente</div>
|
||||
<div class="stat-value text-lg">{{ object.email }}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title">Estado</div>
|
||||
<div class="stat-value text-lg"><span class="badge badge-lg">{{ object.get_status_display }}</span></div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title">Total</div>
|
||||
<div class="stat-value text-lg">{{ object.total }} €</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mb-6">
|
||||
<span class="font-medium">Dirección de envío:</span>
|
||||
{{ object.shipping_address }}, {{ object.shipping_city }}, {{ object.shipping_zip }}
|
||||
</p>
|
||||
|
||||
<h2 class="text-xl font-semibold mb-2">Estado de envío</h2>
|
||||
<form method="post" action="{% url 'backoffice:order_update_shipping_status' object.pk %}" class="mb-6 max-w-sm">
|
||||
{% csrf_token %}
|
||||
{{ shipping_status_form.as_p }}
|
||||
<button class="btn btn-primary btn-sm">Actualizar</button>
|
||||
</form>
|
||||
|
||||
<h2 class="text-xl font-semibold mb-2">Líneas</h2>
|
||||
<div class="overflow-x-auto bg-base-100 rounded-box border border-base-300 mb-6">
|
||||
<table class="table">
|
||||
<thead><tr><th>Producto</th><th>Cantidad</th><th>Precio</th><th>Total</th></tr></thead>
|
||||
<tbody>
|
||||
{% for line in lines %}
|
||||
<tr class="hover">
|
||||
<td>
|
||||
{{ line.product.name }}
|
||||
{% if line.variant %}
|
||||
{% for attribute_value in line.variant.attribute_values.all %}<span class="badge badge-ghost ml-1">{{ attribute_value }}</span>{% endfor %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ line.quantity }}</td>
|
||||
<td>{{ line.price }} €</td>
|
||||
<td>{{ line.total }} €</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2 class="text-xl font-semibold mb-2">Pagos</h2>
|
||||
<div class="overflow-x-auto bg-base-100 rounded-box border border-base-300">
|
||||
<table class="table">
|
||||
<thead><tr><th>Fecha</th><th>Importe</th><th>Método</th></tr></thead>
|
||||
<tbody>
|
||||
{% for payment in payments %}
|
||||
<tr class="hover">
|
||||
<td>{{ payment.creation_date }}</td>
|
||||
<td>{{ payment.amount }} €</td>
|
||||
<td>{{ payment.get_method_display }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="3" class="text-center py-6">Sin pagos registrados.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,33 @@
|
||||
<div id="backoffice-detail"
|
||||
hx-trigger="backoffice:list-changed from:body"
|
||||
hx-get="{{ request.path }}"
|
||||
hx-target="#backoffice-detail"
|
||||
hx-swap="outerHTML">
|
||||
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h1 class="text-2xl font-semibold">{{ object.name }}</h1>
|
||||
<button class="btn btn-sm" hx-get="{% url 'backoffice:product_attribute_update' object.pk %}" hx-target="#backoffice-modal-box" hx-swap="innerHTML">Editar nombre</button>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<h2 class="text-xl font-semibold">Valores</h2>
|
||||
<button class="btn btn-sm btn-primary" hx-get="{% url 'backoffice:product_attribute_value_create' object.pk %}" hx-target="#backoffice-modal-box" hx-swap="innerHTML">Añadir valor</button>
|
||||
</div>
|
||||
<div class="overflow-x-auto bg-base-100 rounded-box border border-base-300">
|
||||
<table class="table">
|
||||
<thead><tr><th>Valor</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for value in values %}
|
||||
<tr class="hover">
|
||||
<td>{{ value.value }}</td>
|
||||
<td class="text-right">
|
||||
<button class="btn btn-ghost btn-xs text-error" hx-get="{% url 'backoffice:product_attribute_value_delete' value.pk %}" hx-target="#backoffice-modal-box" hx-swap="innerHTML">Borrar</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="2" class="text-center py-6">Este atributo no tiene valores todavía.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,105 @@
|
||||
{% load static %}
|
||||
<div id="backoffice-detail"
|
||||
hx-trigger="backoffice:list-changed from:body"
|
||||
hx-get="{{ request.path }}"
|
||||
hx-target="#backoffice-detail"
|
||||
hx-swap="outerHTML">
|
||||
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h1 class="text-2xl font-semibold">{{ product.name }}</h1>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-sm" hx-get="{% url 'backoffice:product_update' product.pk %}" hx-target="#backoffice-modal-box" hx-swap="innerHTML">Editar</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 class="stats shadow mb-6 bg-base-100">
|
||||
<div class="stat">
|
||||
<div class="stat-title">SKU</div>
|
||||
<div class="stat-value text-lg">{{ product.sku }}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title">Stock</div>
|
||||
<div class="stat-value text-lg">{{ product.stock }}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title">Precio actual</div>
|
||||
<div class="stat-value text-lg">
|
||||
{% if product.price %}{{ product.price.price_with_tax }} €{% else %}—{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<h2 class="text-xl font-semibold">Imágenes</h2>
|
||||
<button class="btn btn-sm btn-primary" hx-get="{% url 'backoffice:product_image_create' product.pk %}" hx-target="#backoffice-modal-box" hx-swap="innerHTML">Añadir imagen</button>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-4 mb-6">
|
||||
{% for image in images %}
|
||||
<div class="relative">
|
||||
<img class="w-24 h-24 object-cover rounded-box border border-base-300" src="{{ image.s.url }}" alt="">
|
||||
<button class="btn btn-xs btn-error absolute -top-2 -right-2" hx-get="{% url 'backoffice:product_image_delete' image.pk %}" hx-target="#backoffice-modal-box" hx-swap="innerHTML">✕</button>
|
||||
</div>
|
||||
{% empty %}
|
||||
<p class="opacity-70">Este producto no tiene imágenes.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<h2 class="text-xl font-semibold">Variantes</h2>
|
||||
<button class="btn btn-sm btn-primary" hx-get="{% url 'backoffice:product_variant_create' product.pk %}" hx-target="#backoffice-modal-box" hx-swap="innerHTML">Añadir variante</button>
|
||||
</div>
|
||||
<div class="overflow-x-auto bg-base-100 rounded-box border border-base-300 mb-6">
|
||||
<table class="table">
|
||||
<thead><tr><th>SKU</th><th>Atributos</th><th>Stock</th><th>Precio</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for variant in variants %}
|
||||
<tr class="hover">
|
||||
<td>{{ variant.sku }}</td>
|
||||
<td>{% for attribute_value in variant.attribute_values.all %}<span class="badge badge-ghost mr-1">{{ attribute_value }}</span>{% endfor %}</td>
|
||||
<td>{{ variant.stock }}</td>
|
||||
<td>
|
||||
{% if variant.price %}{{ variant.price.price_with_tax }} €{% else %}—{% endif %}
|
||||
<button class="link text-sm ml-2" hx-get="{% url 'backoffice:product_variant_price_create' variant.pk %}" hx-target="#backoffice-modal-box" hx-swap="innerHTML">+ precio</button>
|
||||
</td>
|
||||
<td class="text-right whitespace-nowrap">
|
||||
<button class="btn btn-ghost btn-xs" hx-get="{% url 'backoffice:product_variant_update' variant.pk %}" hx-target="#backoffice-modal-box" hx-swap="innerHTML">Editar</button>
|
||||
<button class="btn btn-ghost btn-xs text-error" hx-get="{% url 'backoffice:product_variant_delete' variant.pk %}" hx-target="#backoffice-modal-box" hx-swap="innerHTML">Borrar</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="5" class="text-center py-6">Este producto no tiene variantes.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<button class="btn btn-sm" hx-get="{% url 'backoffice:product_price_create' product.pk %}" hx-target="#backoffice-modal-box" hx-swap="innerHTML">Añadir precio al producto</button>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<h2 class="text-xl font-semibold">Remesas</h2>
|
||||
<button class="btn btn-sm btn-primary" hx-get="{% url 'backoffice:product_batch_create' product.pk %}" hx-target="#backoffice-modal-box" hx-swap="innerHTML">Añadir remesa</button>
|
||||
</div>
|
||||
<div class="overflow-x-auto bg-base-100 rounded-box border border-base-300">
|
||||
<table class="table">
|
||||
<thead><tr><th>Código</th><th>Cantidad</th><th>Caducidad</th><th>Proveedor</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for batch in batches %}
|
||||
<tr class="hover">
|
||||
<td>{{ batch.code }}</td>
|
||||
<td>{{ batch.quantity }}</td>
|
||||
<td>{{ batch.expiration_date|default:'—' }}</td>
|
||||
<td>{{ batch.provider|default:'—' }}</td>
|
||||
<td class="text-right">
|
||||
<button class="btn btn-ghost btn-xs text-error" hx-get="{% url 'backoffice:product_batch_delete' batch.pk %}" hx-target="#backoffice-modal-box" hx-swap="innerHTML">Borrar</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="5" class="text-center py-6">Este producto no tiene remesas.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,7 @@
|
||||
{% extends 'backoffice/base.html' %}
|
||||
|
||||
{% block title %}{{ object.name }}{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
{% include 'backoffice/products/_attribute_detail_fragment.html' %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,7 @@
|
||||
{% extends 'backoffice/base.html' %}
|
||||
|
||||
{% block title %}{{ product.name }}{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
{% include 'backoffice/products/_product_detail_fragment.html' %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,15 @@
|
||||
from django import template
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.filter
|
||||
def getattribute(obj, attr_path):
|
||||
value = obj
|
||||
for attr in attr_path.split('.'):
|
||||
if value is None:
|
||||
return ''
|
||||
value = getattr(value, attr, '')
|
||||
if callable(value):
|
||||
value = value()
|
||||
return value
|
||||
@@ -0,0 +1,31 @@
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
|
||||
class TestBackofficeAccess(TestCase):
|
||||
def test_anonymous_user_is_redirected_to_login(self):
|
||||
response = self.client.get(reverse('backoffice:dashboard'))
|
||||
assert response.status_code == 302
|
||||
assert reverse('users:login') in response.url
|
||||
|
||||
def test_authenticated_non_staff_user_gets_forbidden(self):
|
||||
user = User.objects.create_user('luke', 'luke@rebels.com', 'ihatesand', is_staff=False)
|
||||
self.client.force_login(user)
|
||||
|
||||
response = self.client.get(reverse('backoffice:dashboard'))
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_superuser_can_access_dashboard(self):
|
||||
user = User.objects.create_superuser('vader', 'vader@empire.com', 'ihatesand')
|
||||
self.client.force_login(user)
|
||||
|
||||
response = self.client.get(reverse('backoffice:dashboard'))
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_staff_without_specific_permission_gets_forbidden_on_product_list(self):
|
||||
user = User.objects.create_user('han', 'han@falcon.com', 'ihatesand', is_staff=True)
|
||||
self.client.force_login(user)
|
||||
|
||||
response = self.client.get(reverse('backoffice:product_list'))
|
||||
assert response.status_code == 403
|
||||
@@ -0,0 +1,134 @@
|
||||
import json
|
||||
from io import BytesIO
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.files.base import ContentFile
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from PIL import Image
|
||||
|
||||
from shop.models import Product, ProductImage, ProductVariant
|
||||
from shop.tests.mixins import CreateProductsMixin
|
||||
|
||||
|
||||
class TestBackofficeHtmxContract(TestCase, CreateProductsMixin):
|
||||
def setUp(self):
|
||||
self.superuser = User.objects.create_superuser('vader', 'vader@empire.com', 'ihatesand')
|
||||
self.client.force_login(self.superuser)
|
||||
self.product = self.create_product()
|
||||
|
||||
def test_htmx_get_create_form_returns_fragment_not_full_page(self):
|
||||
response = self.client.get(reverse('backoffice:product_create'), HTTP_HX_REQUEST='true')
|
||||
assert response.status_code == 200
|
||||
body = response.content.decode()
|
||||
assert '<html' not in body
|
||||
assert 'Añadir producto' in body
|
||||
|
||||
def test_htmx_create_success_returns_204_and_triggers_modal_close_and_refresh(self):
|
||||
response = self.client.post(
|
||||
reverse('backoffice:product_create'),
|
||||
{
|
||||
'sku': 'HTMX-1',
|
||||
'name': 'Producto htmx',
|
||||
'description': '',
|
||||
'stock': '0',
|
||||
'categories': [],
|
||||
'tags': [],
|
||||
},
|
||||
HTTP_HX_REQUEST='true',
|
||||
)
|
||||
assert response.status_code == 204
|
||||
trigger = json.loads(response['HX-Trigger'])
|
||||
assert trigger == {'backoffice:modal-close': True, 'backoffice:list-changed': True}
|
||||
assert Product.objects.filter(sku='HTMX-1').exists()
|
||||
|
||||
def test_htmx_create_invalid_returns_200_with_errors_not_204(self):
|
||||
response = self.client.post(
|
||||
reverse('backoffice:product_create'),
|
||||
{'sku': '', 'name': '', 'description': '', 'stock': '0', 'categories': [], 'tags': []},
|
||||
HTTP_HX_REQUEST='true',
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert 'HX-Trigger' not in response
|
||||
|
||||
def test_non_htmx_create_still_redirects(self):
|
||||
response = self.client.post(
|
||||
reverse('backoffice:product_create'),
|
||||
{
|
||||
'sku': 'PLAIN-1',
|
||||
'name': 'Producto normal',
|
||||
'description': '',
|
||||
'stock': '0',
|
||||
'categories': [],
|
||||
'tags': [],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 302
|
||||
assert 'HX-Trigger' not in response
|
||||
|
||||
def test_htmx_variant_create_triggers_refresh(self):
|
||||
size_m = self.create_attribute_value('Talla', 'M')
|
||||
|
||||
response = self.client.post(
|
||||
reverse('backoffice:product_variant_create', args=[self.product.pk]),
|
||||
{'sku': 'HTMX-V1', 'stock': '5', 'attribute_values': [size_m.pk]},
|
||||
HTTP_HX_REQUEST='true',
|
||||
)
|
||||
assert response.status_code == 204
|
||||
trigger = json.loads(response['HX-Trigger'])
|
||||
assert trigger['backoffice:modal-close'] is True
|
||||
assert trigger['backoffice:list-changed'] is True
|
||||
assert ProductVariant.objects.filter(sku='HTMX-V1').exists()
|
||||
|
||||
def test_htmx_variant_delete_stays_and_refreshes(self):
|
||||
size_m = self.create_attribute_value('Talla', 'M')
|
||||
variant = self.create_product_variant(self.product, sku='HTMX-V2', attribute_values=[size_m])
|
||||
|
||||
response = self.client.post(
|
||||
reverse('backoffice:product_variant_delete', args=[variant.pk]), HTTP_HX_REQUEST='true'
|
||||
)
|
||||
assert response.status_code == 204
|
||||
assert 'HX-Redirect' not in response
|
||||
trigger = json.loads(response['HX-Trigger'])
|
||||
assert trigger['backoffice:list-changed'] is True
|
||||
assert not ProductVariant.objects.filter(pk=variant.pk).exists()
|
||||
|
||||
def test_htmx_image_upload_triggers_refresh(self):
|
||||
image = Image.new('RGB', (64, 64), '#ACACAC')
|
||||
buffer = BytesIO()
|
||||
image.save(fp=buffer, format='WEBP')
|
||||
file = ContentFile(buffer.getvalue(), name='test.webp')
|
||||
|
||||
response = self.client.post(
|
||||
reverse('backoffice:product_image_create', args=[self.product.pk]),
|
||||
{'original': file},
|
||||
format='multipart',
|
||||
HTTP_HX_REQUEST='true',
|
||||
)
|
||||
assert response.status_code == 204
|
||||
trigger = json.loads(response['HX-Trigger'])
|
||||
assert trigger == {'backoffice:modal-close': True, 'backoffice:list-changed': True}
|
||||
assert ProductImage.objects.filter(product=self.product).exists()
|
||||
|
||||
def test_htmx_product_delete_uses_hx_redirect_instead_of_trigger(self):
|
||||
product = self.create_product(sku='HTMX-DEL')
|
||||
|
||||
response = self.client.post(reverse('backoffice:product_delete', args=[product.pk]), HTTP_HX_REQUEST='true')
|
||||
assert response.status_code == 200
|
||||
assert response['HX-Redirect'] == reverse('backoffice:product_list')
|
||||
assert 'HX-Trigger' not in response
|
||||
assert not Product.objects.filter(pk=product.pk).exists()
|
||||
|
||||
def test_htmx_list_self_refresh_returns_fragment(self):
|
||||
response = self.client.get(reverse('backoffice:product_list'), HTTP_HX_REQUEST='true')
|
||||
assert response.status_code == 200
|
||||
body = response.content.decode()
|
||||
assert '<html' not in body
|
||||
assert 'id="backoffice-list"' in body
|
||||
|
||||
def test_htmx_product_detail_self_refresh_returns_fragment(self):
|
||||
response = self.client.get(reverse('backoffice:product_detail', args=[self.product.pk]), HTTP_HX_REQUEST='true')
|
||||
assert response.status_code == 200
|
||||
body = response.content.decode()
|
||||
assert '<html' not in body
|
||||
assert 'id="backoffice-detail"' in body
|
||||
@@ -0,0 +1,50 @@
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from shop.models import Order
|
||||
from shop.tests.mixins import CreateProductsMixin
|
||||
|
||||
|
||||
class TestBackofficeOrders(TestCase, CreateProductsMixin):
|
||||
def setUp(self):
|
||||
self.superuser = User.objects.create_superuser('vader', 'vader@empire.com', 'ihatesand')
|
||||
self.client.force_login(self.superuser)
|
||||
self.product = self.create_product()
|
||||
self.order = Order.objects.create(
|
||||
email='client@example.com',
|
||||
billing_address='Tatooine',
|
||||
billing_city='Mos Eisley',
|
||||
billing_state='Tatooine',
|
||||
billing_country='Tatooine',
|
||||
billing_zip='00001',
|
||||
shipping_address='Tatooine',
|
||||
shipping_city='Mos Eisley',
|
||||
shipping_state='Tatooine',
|
||||
shipping_country='Tatooine',
|
||||
shipping_zip='00001',
|
||||
)
|
||||
|
||||
def test_order_list(self):
|
||||
response = self.client.get(reverse('backoffice:order_list'))
|
||||
assert response.status_code == 200
|
||||
assert self.order.code in response.content.decode()
|
||||
|
||||
def test_order_list_filtered_by_status(self):
|
||||
response = self.client.get(reverse('backoffice:order_list'), {'status': Order.Statuses.STATUS_PAID})
|
||||
assert response.status_code == 200
|
||||
assert self.order.code not in response.content.decode()
|
||||
|
||||
def test_order_detail(self):
|
||||
response = self.client.get(reverse('backoffice:order_detail', args=[self.order.pk]))
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_update_shipping_status(self):
|
||||
response = self.client.post(
|
||||
reverse('backoffice:order_update_shipping_status', args=[self.order.pk]),
|
||||
{'shipping_status': Order.ShippingStatuses.STATUS_SENT},
|
||||
)
|
||||
assert response.status_code == 302
|
||||
|
||||
self.order.refresh_from_db()
|
||||
assert self.order.shipping_status == Order.ShippingStatuses.STATUS_SENT
|
||||
@@ -0,0 +1,116 @@
|
||||
from decimal import Decimal
|
||||
from io import BytesIO
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.files.base import ContentFile
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from PIL import Image
|
||||
|
||||
from shop.models import Product, ProductImage, ProductPrice, ProductVariant, Tax
|
||||
from shop.tests.mixins import CreateProductsMixin
|
||||
|
||||
|
||||
class TestBackofficeProducts(TestCase, CreateProductsMixin):
|
||||
def setUp(self):
|
||||
self.superuser = User.objects.create_superuser('vader', 'vader@empire.com', 'ihatesand')
|
||||
self.client.force_login(self.superuser)
|
||||
self.product = self.create_product()
|
||||
|
||||
def test_product_list(self):
|
||||
response = self.client.get(reverse('backoffice:product_list'))
|
||||
assert response.status_code == 200
|
||||
assert self.product.name in response.content.decode()
|
||||
|
||||
def test_create_product(self):
|
||||
response = self.client.post(
|
||||
reverse('backoffice:product_create'),
|
||||
{
|
||||
'sku': 'NEW-1',
|
||||
'name': 'Producto nuevo',
|
||||
'description': '',
|
||||
'stock': '0',
|
||||
'categories': [],
|
||||
'tags': [],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 302
|
||||
assert Product.objects.filter(sku='NEW-1').exists()
|
||||
|
||||
def test_create_variant_for_product(self):
|
||||
size_m = self.create_attribute_value('Talla', 'M')
|
||||
|
||||
response = self.client.post(
|
||||
reverse('backoffice:product_variant_create', args=[self.product.pk]),
|
||||
{'sku': 'V-M', 'stock': '5', 'attribute_values': [size_m.pk]},
|
||||
)
|
||||
assert response.status_code == 302
|
||||
variant = ProductVariant.objects.get(sku='V-M')
|
||||
assert variant.product == self.product
|
||||
assert size_m in variant.attribute_values.all()
|
||||
|
||||
def test_create_duplicated_variant_combination_fails(self):
|
||||
size_m = self.create_attribute_value('Talla', 'M')
|
||||
self.create_product_variant(self.product, sku='V-M1', attribute_values=[size_m])
|
||||
|
||||
response = self.client.post(
|
||||
reverse('backoffice:product_variant_create', args=[self.product.pk]),
|
||||
{'sku': 'V-M2', 'stock': '5', 'attribute_values': [size_m.pk]},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert not ProductVariant.objects.filter(sku='V-M2').exists()
|
||||
|
||||
def test_create_price_for_product(self):
|
||||
tax, created = Tax.objects.get_or_create(code='IVA', value=21)
|
||||
|
||||
response = self.client.post(
|
||||
reverse('backoffice:product_price_create', args=[self.product.pk]),
|
||||
{'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()
|
||||
|
||||
def test_create_price_for_variant(self):
|
||||
tax, created = Tax.objects.get_or_create(code='IVA', value=21)
|
||||
size_m = self.create_attribute_value('Talla', 'M')
|
||||
variant = self.create_product_variant(self.product, sku='V-M', attribute_values=[size_m])
|
||||
|
||||
response = self.client.post(
|
||||
reverse('backoffice:product_variant_price_create', args=[variant.pk]),
|
||||
{'price': '19.99', 'tax': tax.pk, 'current': 'on'},
|
||||
)
|
||||
assert response.status_code == 302
|
||||
assert ProductPrice.objects.filter(variant=variant, price=Decimal('19.99')).exists()
|
||||
|
||||
def create_image_file(self, name='test.webp'):
|
||||
image = Image.new('RGB', (256, 256), '#ACACAC')
|
||||
buffer = BytesIO()
|
||||
image.save(fp=buffer, format='WEBP')
|
||||
return ContentFile(buffer.getvalue(), name=name)
|
||||
|
||||
def test_create_image_for_product(self):
|
||||
response = self.client.post(
|
||||
reverse('backoffice:product_image_create', args=[self.product.pk]),
|
||||
{'original': self.create_image_file()},
|
||||
format='multipart',
|
||||
)
|
||||
assert response.status_code == 302
|
||||
assert ProductImage.objects.filter(product=self.product).exists()
|
||||
|
||||
def test_delete_image(self):
|
||||
image = ProductImage.objects.create(product=self.product, original=self.create_image_file())
|
||||
|
||||
response = self.client.post(reverse('backoffice:product_image_delete', args=[image.pk]))
|
||||
assert response.status_code == 302
|
||||
assert not ProductImage.objects.filter(pk=image.pk).exists()
|
||||
|
||||
def test_delete_batch_reduces_stock(self):
|
||||
self.product.stock = Decimal('10')
|
||||
self.product.save()
|
||||
batch = self.product.productbatch_set.create(code='B1', quantity=Decimal('3'))
|
||||
|
||||
response = self.client.post(reverse('backoffice:product_batch_delete', args=[batch.pk]))
|
||||
assert response.status_code == 302
|
||||
|
||||
self.product.refresh_from_db()
|
||||
assert self.product.stock == Decimal('7')
|
||||
@@ -0,0 +1,40 @@
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from shop.models import ShopSettings
|
||||
from web.models import WebSettings
|
||||
|
||||
|
||||
class TestBackofficeSettings(TestCase):
|
||||
def setUp(self):
|
||||
self.superuser = User.objects.create_superuser('vader', 'vader@empire.com', 'ihatesand')
|
||||
self.client.force_login(self.superuser)
|
||||
|
||||
def test_get_brand_settings(self):
|
||||
response = self.client.get(reverse('backoffice:brand_settings'))
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_update_brand_settings(self):
|
||||
response = self.client.post(
|
||||
reverse('backoffice:brand_settings'),
|
||||
{'web_title': 'Mi tienda', 'web_description': '', 'bg_color': '', 'theme_color': ''},
|
||||
)
|
||||
assert response.status_code == 302
|
||||
|
||||
settings = WebSettings.objects.get(pk=1)
|
||||
assert settings.web_title == 'Mi tienda'
|
||||
|
||||
def test_get_shop_settings(self):
|
||||
response = self.client.get(reverse('backoffice:shop_settings'))
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_update_shop_settings(self):
|
||||
response = self.client.post(
|
||||
reverse('backoffice:shop_settings'),
|
||||
{'merchant_code': '999008881', 'currency_code': '978', 'terminal': '001', 'shared_secret': 'secret'},
|
||||
)
|
||||
assert response.status_code == 302
|
||||
|
||||
settings = ShopSettings.objects.get(pk=1)
|
||||
assert settings.merchant_code == '999008881'
|
||||
@@ -0,0 +1,16 @@
|
||||
from django.urls import include, path
|
||||
|
||||
from backoffice.views.dashboard import DashboardView
|
||||
|
||||
app_name = 'backoffice'
|
||||
|
||||
urlpatterns = [
|
||||
path('', DashboardView.as_view(), name='dashboard'),
|
||||
path('products/', include('backoffice.urls.products')),
|
||||
path('orders/', include('backoffice.urls.orders')),
|
||||
path('payments/', include('backoffice.urls.payments')),
|
||||
path('customers/', include('backoffice.urls.customers')),
|
||||
path('providers/', include('backoffice.urls.providers')),
|
||||
path('taxes/', include('backoffice.urls.taxes')),
|
||||
path('settings/', include('backoffice.urls.settings')),
|
||||
]
|
||||
@@ -0,0 +1,8 @@
|
||||
from django.urls import path
|
||||
|
||||
from backoffice.views import customers
|
||||
|
||||
urlpatterns = [
|
||||
path('', customers.CustomerListView.as_view(), name='customer_list'),
|
||||
path('<int:pk>/', customers.CustomerDetailView.as_view(), name='customer_detail'),
|
||||
]
|
||||
@@ -0,0 +1,13 @@
|
||||
from django.urls import path
|
||||
|
||||
from backoffice.views import orders
|
||||
|
||||
urlpatterns = [
|
||||
path('', orders.OrderListView.as_view(), name='order_list'),
|
||||
path('<int:pk>/', orders.OrderDetailView.as_view(), name='order_detail'),
|
||||
path(
|
||||
'<int:pk>/shipping-status/',
|
||||
orders.OrderUpdateShippingStatusView.as_view(),
|
||||
name='order_update_shipping_status',
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.urls import path
|
||||
|
||||
from backoffice.views import payments
|
||||
|
||||
urlpatterns = [
|
||||
path('', payments.PaymentListView.as_view(), name='payment_list'),
|
||||
]
|
||||
@@ -0,0 +1,65 @@
|
||||
from django.urls import path
|
||||
|
||||
from backoffice.views import products
|
||||
|
||||
urlpatterns = [
|
||||
path('', products.ProductListView.as_view(), name='product_list'),
|
||||
path('create/', products.ProductCreateView.as_view(), name='product_create'),
|
||||
path('<int:pk>/', products.ProductDetailView.as_view(), name='product_detail'),
|
||||
path('<int:pk>/edit/', products.ProductUpdateView.as_view(), name='product_update'),
|
||||
path('<int:pk>/delete/', products.ProductDeleteView.as_view(), name='product_delete'),
|
||||
path('<int:product_pk>/prices/create/', products.ProductPriceCreateView.as_view(), name='product_price_create'),
|
||||
path(
|
||||
'<int:product_pk>/variants/create/',
|
||||
products.ProductVariantCreateView.as_view(),
|
||||
name='product_variant_create',
|
||||
),
|
||||
path('variants/<int:pk>/edit/', products.ProductVariantUpdateView.as_view(), name='product_variant_update'),
|
||||
path('variants/<int:pk>/delete/', products.ProductVariantDeleteView.as_view(), name='product_variant_delete'),
|
||||
path(
|
||||
'variants/<int:variant_pk>/prices/create/',
|
||||
products.ProductVariantPriceCreateView.as_view(),
|
||||
name='product_variant_price_create',
|
||||
),
|
||||
path('<int:product_pk>/batches/create/', products.ProductBatchCreateView.as_view(), name='product_batch_create'),
|
||||
path('batches/<int:pk>/delete/', products.ProductBatchDeleteView.as_view(), name='product_batch_delete'),
|
||||
path('<int:product_pk>/images/create/', products.ProductImageCreateView.as_view(), name='product_image_create'),
|
||||
path('images/<int:pk>/delete/', products.ProductImageDeleteView.as_view(), name='product_image_delete'),
|
||||
path('categories/', products.ProductCategoryListView.as_view(), name='product_category_list'),
|
||||
path('categories/create/', products.ProductCategoryCreateView.as_view(), name='product_category_create'),
|
||||
path(
|
||||
'categories/<int:pk>/edit/', products.ProductCategoryUpdateView.as_view(), name='product_category_update'
|
||||
),
|
||||
path(
|
||||
'categories/<int:pk>/delete/', products.ProductCategoryDeleteView.as_view(), name='product_category_delete'
|
||||
),
|
||||
path('attributes/', products.ProductAttributeListView.as_view(), name='product_attribute_list'),
|
||||
path('attributes/create/', products.ProductAttributeCreateView.as_view(), name='product_attribute_create'),
|
||||
path('attributes/<int:pk>/', products.ProductAttributeDetailView.as_view(), name='product_attribute_detail'),
|
||||
path(
|
||||
'attributes/<int:pk>/edit/', products.ProductAttributeUpdateView.as_view(), name='product_attribute_update'
|
||||
),
|
||||
path(
|
||||
'attributes/<int:pk>/delete/',
|
||||
products.ProductAttributeDeleteView.as_view(),
|
||||
name='product_attribute_delete',
|
||||
),
|
||||
path(
|
||||
'attributes/<int:attribute_pk>/values/create/',
|
||||
products.ProductAttributeValueCreateView.as_view(),
|
||||
name='product_attribute_value_create',
|
||||
),
|
||||
path(
|
||||
'attributes/values/<int:pk>/delete/',
|
||||
products.ProductAttributeValueDeleteView.as_view(),
|
||||
name='product_attribute_value_delete',
|
||||
),
|
||||
path('brands/', products.BrandListView.as_view(), name='brand_list'),
|
||||
path('brands/create/', products.BrandCreateView.as_view(), name='brand_create'),
|
||||
path('brands/<int:pk>/edit/', products.BrandUpdateView.as_view(), name='brand_update'),
|
||||
path('brands/<int:pk>/delete/', products.BrandDeleteView.as_view(), name='brand_delete'),
|
||||
path('tags/', products.TagListView.as_view(), name='tag_list'),
|
||||
path('tags/create/', products.TagCreateView.as_view(), name='tag_create'),
|
||||
path('tags/<int:pk>/edit/', products.TagUpdateView.as_view(), name='tag_update'),
|
||||
path('tags/<int:pk>/delete/', products.TagDeleteView.as_view(), name='tag_delete'),
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.urls import path
|
||||
|
||||
from backoffice.views import providers
|
||||
|
||||
urlpatterns = [
|
||||
path('', providers.ProviderListView.as_view(), name='provider_list'),
|
||||
]
|
||||
@@ -0,0 +1,8 @@
|
||||
from django.urls import path
|
||||
|
||||
from backoffice.views import settings
|
||||
|
||||
urlpatterns = [
|
||||
path('brand/', settings.BrandSettingsView.as_view(), name='brand_settings'),
|
||||
path('shop/', settings.ShopSettingsView.as_view(), name='shop_settings'),
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.urls import path
|
||||
|
||||
from backoffice.views import taxes
|
||||
|
||||
urlpatterns = [
|
||||
path('', taxes.TaxListView.as_view(), name='tax_list'),
|
||||
]
|
||||
@@ -0,0 +1,52 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.views.generic import DetailView, ListView
|
||||
|
||||
from backoffice.mixins import BackofficeCRUDMixin, BackofficeHtmxMixin
|
||||
from shop.models import CustomerAddress, Order
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
SECTION = 'customers'
|
||||
|
||||
|
||||
class CustomerListView(BackofficeCRUDMixin, BackofficeHtmxMixin, ListView):
|
||||
model = User
|
||||
permission_required = 'auth.view_user'
|
||||
section = SECTION
|
||||
paginate_by = 20
|
||||
template_name = 'backoffice/generic/list.html'
|
||||
fragment_template_name = 'backoffice/generic/_list_fragment.html'
|
||||
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(is_staff=False).order_by('email')
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context.update(
|
||||
{
|
||||
'title': 'Clientes',
|
||||
'columns': [('E-mail', 'email'), ('Nombre', 'first_name'), ('Apellidos', 'last_name')],
|
||||
'detail_url_name': 'backoffice:customer_detail',
|
||||
}
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
class CustomerDetailView(BackofficeCRUDMixin, DetailView):
|
||||
model = User
|
||||
permission_required = 'auth.view_user'
|
||||
section = SECTION
|
||||
template_name = 'backoffice/customers/customer_detail.html'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context.update(
|
||||
{
|
||||
'addresses': CustomerAddress.objects.filter(user=self.object),
|
||||
'orders': Order.objects.filter(user=self.object),
|
||||
}
|
||||
)
|
||||
return context
|
||||
|
||||
# CRUD pendiente: la edición/borrado de clientes ya se gestiona a través del
|
||||
# admin de Django (auth.User) — aquí solo se expone consulta.
|
||||
@@ -0,0 +1,23 @@
|
||||
from django.views.generic import TemplateView
|
||||
|
||||
from backoffice.mixins import BackofficeAccessMixin
|
||||
|
||||
SECTIONS = [
|
||||
{'label': 'Productos', 'url_name': 'backoffice:product_list'},
|
||||
{'label': 'Pedidos', 'url_name': 'backoffice:order_list'},
|
||||
{'label': 'Pagos', 'url_name': 'backoffice:payment_list'},
|
||||
{'label': 'Clientes', 'url_name': 'backoffice:customer_list'},
|
||||
{'label': 'Proveedores', 'url_name': 'backoffice:provider_list'},
|
||||
{'label': 'Impuestos', 'url_name': 'backoffice:tax_list'},
|
||||
{'label': 'Marca y ajustes', 'url_name': 'backoffice:brand_settings'},
|
||||
]
|
||||
|
||||
|
||||
class DashboardView(BackofficeAccessMixin, TemplateView):
|
||||
template_name = 'backoffice/dashboard.html'
|
||||
section = 'dashboard'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['sections'] = SECTIONS
|
||||
return context
|
||||
@@ -0,0 +1,84 @@
|
||||
from django import forms
|
||||
from django.urls import reverse_lazy
|
||||
from django.views.generic import DetailView, ListView, UpdateView
|
||||
|
||||
from backoffice.mixins import BackofficeCRUDMixin, BackofficeHtmxMixin
|
||||
from shop.models import Order
|
||||
|
||||
SECTION = 'orders'
|
||||
|
||||
|
||||
class ShippingStatusForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Order
|
||||
fields = ('shipping_status',)
|
||||
widgets = {'shipping_status': forms.Select(attrs={'class': 'select select-bordered w-full'})}
|
||||
|
||||
|
||||
class OrderListView(BackofficeCRUDMixin, BackofficeHtmxMixin, ListView):
|
||||
model = Order
|
||||
permission_required = 'shop.view_order'
|
||||
section = SECTION
|
||||
paginate_by = 20
|
||||
template_name = 'backoffice/generic/list.html'
|
||||
fragment_template_name = 'backoffice/generic/_list_fragment.html'
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
status = self.request.GET.get('status')
|
||||
|
||||
if status:
|
||||
queryset = queryset.filter(status=status)
|
||||
|
||||
return queryset
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context.update(
|
||||
{
|
||||
'title': 'Pedidos',
|
||||
'columns': [('Código', 'code'), ('Cliente', 'email'), ('Estado', 'get_status_display'), ('Total', 'total')],
|
||||
'detail_url_name': 'backoffice:order_detail',
|
||||
}
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
class OrderDetailView(BackofficeCRUDMixin, DetailView):
|
||||
model = Order
|
||||
permission_required = 'shop.view_order'
|
||||
section = SECTION
|
||||
template_name = 'backoffice/orders/order_detail.html'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context.update(
|
||||
{
|
||||
'lines': self.object.lines.all(),
|
||||
'payments': self.object.payments.all(),
|
||||
'shipping_status_form': ShippingStatusForm(instance=self.object),
|
||||
}
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
class OrderUpdateShippingStatusView(BackofficeCRUDMixin, UpdateView):
|
||||
model = Order
|
||||
form_class = ShippingStatusForm
|
||||
permission_required = 'shop.change_order'
|
||||
section = SECTION
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse_lazy('backoffice:order_detail', args=[self.object.pk])
|
||||
|
||||
def form_invalid(self, form):
|
||||
return self.render_to_response(self.get_context_data(shipping_status_form=form))
|
||||
|
||||
def get_template_names(self):
|
||||
return ['backoffice/orders/order_detail.html']
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context.update({'lines': self.object.lines.all(), 'payments': self.object.payments.all()})
|
||||
context.setdefault('shipping_status_form', ShippingStatusForm(instance=self.object))
|
||||
return context
|
||||
@@ -0,0 +1,31 @@
|
||||
from django.views.generic import ListView
|
||||
|
||||
from backoffice.mixins import BackofficeCRUDMixin, BackofficeHtmxMixin
|
||||
from shop.models import Payment
|
||||
|
||||
SECTION = 'payments'
|
||||
|
||||
|
||||
class PaymentListView(BackofficeCRUDMixin, BackofficeHtmxMixin, ListView):
|
||||
model = Payment
|
||||
permission_required = 'shop.view_payment'
|
||||
section = SECTION
|
||||
paginate_by = 20
|
||||
template_name = 'backoffice/generic/list.html'
|
||||
fragment_template_name = 'backoffice/generic/_list_fragment.html'
|
||||
ordering = '-creation_date'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context.update(
|
||||
{
|
||||
'title': 'Pagos',
|
||||
'columns': [
|
||||
('Fecha', 'creation_date'),
|
||||
('Pedido', 'order.code'),
|
||||
('Importe', 'amount'),
|
||||
('Método', 'get_method_display'),
|
||||
],
|
||||
}
|
||||
)
|
||||
return context
|
||||
@@ -0,0 +1,708 @@
|
||||
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
|
||||
@@ -0,0 +1,29 @@
|
||||
from django.views.generic import ListView
|
||||
|
||||
from backoffice.mixins import BackofficeCRUDMixin, BackofficeHtmxMixin
|
||||
from shop.models import Provider
|
||||
|
||||
SECTION = 'providers'
|
||||
|
||||
|
||||
class ProviderListView(BackofficeCRUDMixin, BackofficeHtmxMixin, ListView):
|
||||
model = Provider
|
||||
permission_required = 'shop.view_provider'
|
||||
section = SECTION
|
||||
paginate_by = 20
|
||||
template_name = 'backoffice/generic/list.html'
|
||||
fragment_template_name = 'backoffice/generic/_list_fragment.html'
|
||||
ordering = 'name'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context.update(
|
||||
{
|
||||
'title': 'Proveedores',
|
||||
'columns': [('Nombre', 'name'), ('NIF/CIF', 'vat_id'), ('E-mail', 'email'), ('Teléfono', 'phone')],
|
||||
}
|
||||
)
|
||||
return context
|
||||
|
||||
# CRUD pendiente: replicar ProductCategoryCreateView/UpdateView/DeleteView de
|
||||
# backoffice/views/products.py sobre el modelo Provider.
|
||||
@@ -0,0 +1,59 @@
|
||||
from django.urls import reverse_lazy
|
||||
from django.views.generic import UpdateView
|
||||
|
||||
from backoffice.mixins import BackofficeCRUDMixin, BackofficeStyledFormMixin
|
||||
from shop.models import ShopSettings
|
||||
from web.models import WebSettings
|
||||
|
||||
SECTION = 'settings'
|
||||
|
||||
|
||||
class BrandSettingsView(BackofficeCRUDMixin, BackofficeStyledFormMixin, UpdateView):
|
||||
model = WebSettings
|
||||
permission_required = 'web.change_websettings'
|
||||
section = SECTION
|
||||
fields = (
|
||||
'web_title',
|
||||
'web_description',
|
||||
'logo',
|
||||
'business_name',
|
||||
'business_vat_id',
|
||||
'business_brand',
|
||||
'business_address',
|
||||
'business_state',
|
||||
'business_zip',
|
||||
'business_phone',
|
||||
'business_email',
|
||||
'business_email_2',
|
||||
'bg_color',
|
||||
'theme_color',
|
||||
)
|
||||
template_name = 'backoffice/generic/form.html'
|
||||
success_url = reverse_lazy('backoffice:brand_settings')
|
||||
|
||||
def get_object(self, queryset=None):
|
||||
obj, created = WebSettings.objects.get_or_create(pk=1)
|
||||
return obj
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context.update({'title': 'Marca y ajustes de la web', 'cancel_url': reverse_lazy('backoffice:dashboard')})
|
||||
return context
|
||||
|
||||
|
||||
class ShopSettingsView(BackofficeCRUDMixin, BackofficeStyledFormMixin, UpdateView):
|
||||
model = ShopSettings
|
||||
permission_required = 'shop.change_shopsettings'
|
||||
section = SECTION
|
||||
fields = ('merchant_code', 'currency_code', 'terminal', 'shared_secret', 'tpv_domain')
|
||||
template_name = 'backoffice/generic/form.html'
|
||||
success_url = reverse_lazy('backoffice:shop_settings')
|
||||
|
||||
def get_object(self, queryset=None):
|
||||
obj, created = ShopSettings.objects.get_or_create(pk=1)
|
||||
return obj
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context.update({'title': 'Ajustes de la tienda (TPV)', 'cancel_url': reverse_lazy('backoffice:dashboard')})
|
||||
return context
|
||||
@@ -0,0 +1,22 @@
|
||||
from django.views.generic import ListView
|
||||
|
||||
from backoffice.mixins import BackofficeCRUDMixin, BackofficeHtmxMixin
|
||||
from shop.models import Tax
|
||||
|
||||
SECTION = 'taxes'
|
||||
|
||||
|
||||
class TaxListView(BackofficeCRUDMixin, BackofficeHtmxMixin, ListView):
|
||||
model = Tax
|
||||
permission_required = 'shop.view_tax'
|
||||
section = SECTION
|
||||
template_name = 'backoffice/generic/list.html'
|
||||
fragment_template_name = 'backoffice/generic/_list_fragment.html'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context.update({'title': 'Impuestos', 'columns': [('Código', 'code'), ('Valor (%)', 'value')]})
|
||||
return context
|
||||
|
||||
# CRUD pendiente: replicar ProductCategoryCreateView/UpdateView/DeleteView de
|
||||
# backoffice/views/products.py sobre el modelo Tax.
|
||||
@@ -40,7 +40,7 @@ THIRD_PARTY_APPS = [
|
||||
'gonk',
|
||||
]
|
||||
|
||||
PROJECT_APPS = ['shop', 'users', 'web']
|
||||
PROJECT_APPS = ['shop', 'users', 'web', 'backoffice']
|
||||
|
||||
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + PROJECT_APPS
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ urlpatterns = [
|
||||
path('admin/', admin.site.urls),
|
||||
path('watchman/', include('watchman.urls')),
|
||||
path('tpv/', include('shop.urls', namespace='shop')),
|
||||
path('backoffice/', include('backoffice.urls', namespace='backoffice')),
|
||||
]
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user