feat: websockets
This commit is contained in:
@@ -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>')
|
||||
@@ -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 %}
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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')),
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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'
|
||||
Reference in New Issue
Block a user