feat: websockets
CI / test (push) Successful in 1m56s
CI / build (push) Successful in 48s

This commit is contained in:
2026-07-31 14:27:50 +02:00
parent 125c9dd186
commit 393b39cacd
13 changed files with 626 additions and 2 deletions
+27
View File
@@ -0,0 +1,27 @@
import json
from channels.generic.websocket import AsyncWebsocketConsumer
from django.utils.html import escape
class EchoConsumer(AsyncWebsocketConsumer):
"""Consumer mínimo para verificar el cableado de Channels + htmx (ver
ws_demo.html). No representa ninguna funcionalidad real todavía."""
async def connect(self):
# Mismo criterio de acceso que BackofficeAccessMixin.
if not self.scope['user'].is_staff:
await self.close()
return
await self.accept()
async def receive(self, text_data):
try:
payload = json.loads(text_data)
except (TypeError, ValueError):
payload = {}
message = escape(payload.get('message', ''))
await self.send(text_data=f'<div id="ws-demo-log" hx-swap-oob="beforeend"><p>Eco: {message}</p></div>')
+7
View File
@@ -0,0 +1,7 @@
from django.urls import re_path
from backoffice.consumers import EchoConsumer
websocket_urlpatterns = [
re_path(r'^ws/backoffice/echo/$', EchoConsumer.as_asgi()),
]
@@ -50,6 +50,23 @@
{{ form.attribute_values.errors }}
</div>
{% if price_form %}
<div class="mb-4 max-w-md">
<h4 class="text-sm font-medium opacity-70 mb-1">Precio inicial (opcional)</h4>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
{{ price_form.price }}
{{ price_form.price.errors }}
</div>
<div>
{{ price_form.tax }}
{{ price_form.tax.errors }}
</div>
</div>
{{ price_form.non_field_errors }}
</div>
{% endif %}
{{ form.non_field_errors }}
<div class="modal-action">
@@ -0,0 +1,24 @@
{% extends 'backoffice/base.html' %}
{% block title %}Demo WebSocket{% endblock %}
{% block extra_js %}
<script src="https://unpkg.com/htmx-ext-ws@2.0.1/ws.js"></script>
{% endblock %}
{% block main %}
<h1 class="text-2xl font-semibold mb-4">Demo WebSocket</h1>
<p class="mb-4 opacity-70">
Verifica el cableado de Django Channels + htmx: el formulario envía el mensaje por WebSocket y
<code>EchoConsumer</code> lo devuelve como HTML que se añade al registro de abajo.
</p>
<div hx-ext="ws" ws-connect="/ws/backoffice/echo/" class="max-w-md">
<form ws-send class="flex gap-2 mb-4">
<input type="text" name="message" class="input input-bordered w-full" placeholder="Escribe un mensaje" autocomplete="off">
<button class="btn btn-primary">Enviar</button>
</form>
</div>
<div id="ws-demo-log" class="flex flex-col gap-1 max-w-md"></div>
{% endblock %}
+14
View File
@@ -29,3 +29,17 @@ class TestBackofficeAccess(TestCase):
response = self.client.get(reverse('backoffice:product_list'))
assert response.status_code == 403
def test_non_staff_user_gets_forbidden_on_ws_demo(self):
user = User.objects.create_user('leia', 'leia@rebels.com', 'ihatesand', is_staff=False)
self.client.force_login(user)
response = self.client.get(reverse('backoffice:ws_demo'))
assert response.status_code == 403
def test_superuser_can_access_ws_demo(self):
user = User.objects.create_superuser('vader2', 'vader2@empire.com', 'ihatesand')
self.client.force_login(user)
response = self.client.get(reverse('backoffice:ws_demo'))
assert response.status_code == 200
+27
View File
@@ -140,6 +140,33 @@ class TestBackofficeProducts(TestCase, CreateProductsMixin):
assert variant.product == self.product
assert size_m in variant.attribute_values.all()
def test_create_variant_with_initial_price_in_one_submit(self):
# El staff introduce el precio CON impuestos; se guarda sin impuestos,
# igual que en la creación de producto (ver ProductInitialPriceForm).
tax, created = Tax.objects.get_or_create(code='IVA', value=21)
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], 'price': '19.99', 'tax': tax.pk},
)
assert response.status_code == 302
variant = ProductVariant.objects.get(sku='V-M')
price = ProductPrice.objects.get(variant=variant)
assert price.price == Decimal('16.52')
assert price.price_with_tax == Decimal('19.99')
assert price.current is True
def test_create_variant_with_price_missing_tax_shows_error(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], 'price': '19.99'},
)
assert response.status_code == 200
assert not ProductVariant.objects.filter(sku='V-M').exists()
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])
+2
View File
@@ -1,11 +1,13 @@
from django.urls import include, path
from backoffice.views.dashboard import DashboardView
from backoffice.views.ws_demo import WebSocketDemoView
app_name = 'backoffice'
urlpatterns = [
path('', DashboardView.as_view(), name='dashboard'),
path('ws-demo/', WebSocketDemoView.as_view(), name='ws_demo'),
path('products/', include('backoffice.urls.products')),
path('orders/', include('backoffice.urls.orders')),
path('payments/', include('backoffice.urls.payments')),
+21
View File
@@ -350,11 +350,32 @@ class ProductVariantCreateView(ProductVariantAttributesContextMixin, BackofficeC
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
product = self.get_product()
context.setdefault('price_form', ProductInitialPriceForm())
context.update(
{'title': f'Añadir variante a {product.name}', 'cancel_url': reverse_lazy('backoffice:product_detail', args=[product.pk])}
)
return context
def form_valid(self, form):
# El precio es opcional y se crea en el mismo envío que la variante,
# igual que en ProductCreateView (ver ProductInitialPriceForm).
price_form = ProductInitialPriceForm(self.request.POST)
if not price_form.is_valid():
return self.render_to_response(self.get_context_data(form=form, price_form=price_form))
response = super().form_valid(form)
if price_form.cleaned_data.get('price') is not None:
ProductPrice.objects.create(
variant=self.object,
price=price_form.get_price_without_tax(),
tax=price_form.cleaned_data['tax'],
current=True,
)
return response
def get_success_url(self):
return reverse_lazy('backoffice:product_detail', args=[self.object.product_id])
+11
View File
@@ -0,0 +1,11 @@
from django.views.generic import TemplateView
from backoffice.mixins import BackofficeAccessMixin
class WebSocketDemoView(BackofficeAccessMixin, TemplateView):
"""Verifica el cableado de Channels + htmx (ver EchoConsumer). No es una
funcionalidad real, solo la base para construir encima."""
template_name = 'backoffice/ws_demo.html'
section = 'ws_demo'