feat: backoffice setup
CI / ci (ubuntu-24.04, 3.13) (push) Has been cancelled

This commit is contained in:
2026-07-23 13:31:53 +02:00
parent 87405f31fa
commit 2a9407e676
45 changed files with 2148 additions and 1 deletions
View File
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class BackofficeConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'backoffice'
+35
View File
@@ -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)
+107
View File
@@ -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)
+68
View File
@@ -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 %}
View File
@@ -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
View File
+31
View File
@@ -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
+134
View File
@@ -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
+50
View File
@@ -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
+116
View File
@@ -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')
+40
View File
@@ -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'
+16
View File
@@ -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')),
]
+8
View File
@@ -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'),
]
+13
View File
@@ -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',
),
]
+7
View File
@@ -0,0 +1,7 @@
from django.urls import path
from backoffice.views import payments
urlpatterns = [
path('', payments.PaymentListView.as_view(), name='payment_list'),
]
+65
View File
@@ -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'),
]
+7
View File
@@ -0,0 +1,7 @@
from django.urls import path
from backoffice.views import providers
urlpatterns = [
path('', providers.ProviderListView.as_view(), name='provider_list'),
]
+8
View File
@@ -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'),
]
+7
View File
@@ -0,0 +1,7 @@
from django.urls import path
from backoffice.views import taxes
urlpatterns = [
path('', taxes.TaxListView.as_view(), name='tax_list'),
]
View File
+52
View File
@@ -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.
+23
View File
@@ -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
+84
View File
@@ -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
+31
View File
@@ -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
+708
View File
@@ -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
+29
View File
@@ -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.
+59
View File
@@ -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
+22
View File
@@ -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.
+1 -1
View File
@@ -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
+1
View File
@@ -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')),
]