From d177d2ef0228a764841a01928d2910866aa18321 Mon Sep 17 00:00:00 2001 From: xgc1564 Date: Sun, 23 Nov 2025 12:39:37 +0100 Subject: [PATCH 1/2] =?UTF-8?q?Funcionalidad=20y=20test=20completos=20impl?= =?UTF-8?q?ementados=20de=20la=20gesti=C3=B3n=20de=20ventas=20para=20admin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- essenza/essenza/urls.py | 1 + essenza/info/tests.py | 132 ++++++++++++++++++++- essenza/info/urls.py | 11 ++ essenza/info/views.py | 63 +++++++++- essenza/templates/base.html | 1 + essenza/templates/info/product_sales.html | 29 +++++ essenza/templates/info/reports_master.html | 90 ++++++++++++++ essenza/templates/info/reports_nav.html | 9 ++ essenza/templates/info/sales_history.html | 29 +++++ essenza/templates/info/user_sales.html | 29 +++++ 10 files changed, 389 insertions(+), 5 deletions(-) create mode 100644 essenza/info/urls.py create mode 100644 essenza/templates/info/product_sales.html create mode 100644 essenza/templates/info/reports_master.html create mode 100644 essenza/templates/info/reports_nav.html create mode 100644 essenza/templates/info/sales_history.html create mode 100644 essenza/templates/info/user_sales.html diff --git a/essenza/essenza/urls.py b/essenza/essenza/urls.py index 5d0bbb82..47f73cd9 100644 --- a/essenza/essenza/urls.py +++ b/essenza/essenza/urls.py @@ -15,6 +15,7 @@ path("catalog//", CatalogDetailView.as_view(), name="catalog_detail"), path("cart/", include("cart.urls")), path("order/", include("order.urls")), + path('info/', include('info.urls')), ] if settings.DEBUG: diff --git a/essenza/info/tests.py b/essenza/info/tests.py index 7ce503c2..11637b78 100644 --- a/essenza/info/tests.py +++ b/essenza/info/tests.py @@ -1,3 +1,131 @@ -from django.test import TestCase +from django.test import TestCase, Client +from django.urls import reverse +from django.contrib.auth import get_user_model +from django.db.models import Sum, F +from django.utils import timezone -# Create your tests here. +# Importa tus modelos reales +from order.models import Order, OrderProduct, Status +from product.models import Product + +User = get_user_model() + +class SalesReportsViewTests(TestCase): + + @classmethod + def setUpTestData(cls): + cls.admin_user = User.objects.create( + username='admin_test', email='admin@test.com', password='password', role='admin' + ) + cls.staff_user = User.objects.create( + username='staff_test', email='staff@test.com', password='password', role='staff' + ) + cls.client_user = User.objects.create( + username='client_test', email='client@test.com', password='password', role='client' + ) + cls.admin_user.set_password('password') + cls.admin_user.save() + cls.client_user.set_password('password') + cls.client_user.save() + + cls.prod1 = Product.objects.create(name='Vela', price=10.00, stock=50) + cls.prod2 = Product.objects.create(name='Jabon', price=5.00, stock=100) + + # 3. Crear Órdenes de prueba, especificando placed_at + cls.order1 = Order.objects.create( + user=cls.client_user, email='client@test.com', address='Calle Falsa 123', status=Status.ENTREGADO, + placed_at=timezone.now() - timezone.timedelta(days=2) + ) + cls.order2 = Order.objects.create( + user=cls.admin_user, email='admin@test.com', address='Calle Real 456', status=Status.ENVIADO, + placed_at=timezone.now() - timezone.timedelta(days=1) + ) + + # 4. Crear Artículos de Pedido (OrderProduct) + OrderProduct.objects.create(order=cls.order1, product=cls.prod1, quantity=10) + OrderProduct.objects.create(order=cls.order1, product=cls.prod2, quantity=5) + OrderProduct.objects.create(order=cls.order2, product=cls.prod1, quantity=3) + + # 5. Definición de URLs (Accedidas por self.history_url, etc. en los tests) + cls.history_url = reverse('info:sales_reports_view', args=['history']) + cls.product_url = reverse('info:sales_reports_view', args=['product']) + cls.user_url = reverse('info:sales_reports_view', args=['user']) + + # El método setUp(self) ya no necesita redefinir los objetos. + def setUp(self): + pass + + + def test_unauthenticated_access_is_denied(self): + self.client.logout() + # history_url se obtiene de la clase (setUpTestData) + response = self.client.get(self.history_url) + self.assertEqual(response.status_code, 403) + + def test_non_admin_user_gets_forbidden_403(self): + # Accedemos a la instancia de usuario desde la clase: self.client_user + self.client.force_login(self.client_user) + response = self.client.get(self.history_url) + self.assertEqual(response.status_code, 403) + + def test_access_granted_to_admin_user(self): + # Accedemos a la instancia de usuario desde la clase: self.admin_user + self.client.force_login(self.admin_user) + response = self.client.get(self.history_url) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, 'info/reports_master.html') + + def test_report_with_no_sales(self): + """Asegura que el reporte funciona (200 OK) cuando no hay órdenes en la DB.""" + # Eliminar todos los datos de prueba + Order.objects.all().delete() + + self.client.force_login(self.admin_user) + response = self.client.get(self.history_url) + + self.assertEqual(response.status_code, 200) + + # Verificar que las listas de contexto están vacías + self.assertIn('orders', response.context) + self.assertEqual(response.context['orders'].count(), 0) + + # Probar el reporte por producto (debe dar 200 y lista vacía) + response_product = self.client.get(self.product_url) + self.assertEqual(response_product.status_code, 200) + self.assertEqual(response_product.context['sales_data'].count(), 0) + + + def test_report_excludes_null_users(self): + """Asegura que los pedidos sin usuario (anónimos) son excluidos del reporte de usuario.""" + + # Crear una orden sin usuario asignado (anónima) + Order.objects.create( + user=None, email='anon@test.com', address='Unknown', status=Status.ENVIADO + ) + + self.client.force_login(self.admin_user) + response = self.client.get(self.user_url) + self.assertEqual(response.context['sales_data'].count(), 2) + + usernames = [item['user__username'] for item in response.context['sales_data']] + self.assertNotIn(None, usernames) + + + def test_template_names_are_correct(self): + """Verifica que la plantilla de contenido y el título son correctos para cada tipo.""" + self.client.force_login(self.admin_user) + + # 1. Historial (Default) + response_h = self.client.get(self.history_url) + self.assertEqual(response_h.context['template_name'], 'info/sales_history.html') + self.assertEqual(response_h.context['report_title'], 'Historial Completo de Ventas') + + # 2. Producto + response_p = self.client.get(self.product_url) + self.assertEqual(response_p.context['template_name'], 'info/product_sales.html') + self.assertEqual(response_p.context['report_title'], 'Ventas Totales por Producto') + + # 3. Usuario + response_u = self.client.get(self.user_url) + self.assertEqual(response_u.context['template_name'], 'info/user_sales.html') + self.assertEqual(response_u.context['report_title'], 'Ventas Totales por Usuario') \ No newline at end of file diff --git a/essenza/info/urls.py b/essenza/info/urls.py new file mode 100644 index 00000000..a398b121 --- /dev/null +++ b/essenza/info/urls.py @@ -0,0 +1,11 @@ +# essenza/info/urls.py + +from django.urls import path +from . import views + +app_name = 'info' + +urlpatterns = [ + path("reports/", views.SalesReportsView.as_view(), {'report_type': 'history'}, name="sales_history_report"), + path("reports//", views.SalesReportsView.as_view(), name="sales_reports_view"), +] \ No newline at end of file diff --git a/essenza/info/views.py b/essenza/info/views.py index 31275674..1fd12ac3 100644 --- a/essenza/info/views.py +++ b/essenza/info/views.py @@ -1,6 +1,63 @@ -# views.py (donde se encuentra la función info_view) +# essenza/info/views.py + +from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin +from django.db.models import Sum, F, Count +from django.shortcuts import redirect, render +from django.urls import reverse +from django.views import View +from django.http import HttpResponseForbidden # Importación necesaria para 403 + +from order.models import Order, OrderProduct -from django.shortcuts import render def info_view(request): - return render(request, "info/info.html") \ No newline at end of file + return render(request, "info/info.html") + +class SalesReportsView(LoginRequiredMixin, UserPassesTestMixin, View): + """ + Maneja la visualización de los tres tipos de reportes: + history (Historial de Pedidos), product (Ventas por Producto), user (Ventas por Usuario). + """ + raise_exception = True + + def test_func(self): + return self.request.user.is_authenticated and self.request.user.role == "admin" + def get(self, request, report_type='history'): + reports_nav = [ + {'id': 'history', 'name': 'Historial de Ventas', 'url': reverse('info:sales_reports_view', args=['history'])}, + {'id': 'product', 'name': 'Ventas por Producto', 'url': reverse('info:sales_reports_view', args=['product'])}, + {'id': 'user', 'name': 'Ventas por Usuario', 'url': reverse('info:sales_reports_view', args=['user'])}, + ] + + context = { + 'reports_nav': reports_nav, + 'current_report': report_type, + } + + if report_type == 'product': + context['report_title'] = 'Ventas Totales por Producto' + context['template_name'] = 'info/product_sales.html' + context['sales_data'] = OrderProduct.objects.values( + 'product__id', 'product__name' + ).annotate( + total_sold=Sum('quantity'), + total_revenue=Sum(F('quantity') * F('product__price')) + ).order_by('-total_revenue') + + elif report_type == 'user': + context['report_title'] = 'Ventas Totales por Usuario' + context['template_name'] = 'info/user_sales.html' + context['sales_data'] = Order.objects.values( + 'user__id', 'user__username', 'user__email' + ).annotate( + total_spent=Sum(F('order_products__quantity') * F('order_products__product__price')) + ).exclude( + user__isnull=True + ).order_by('-total_spent') + + else: + context['report_title'] = 'Historial Completo de Ventas' + context['template_name'] = 'info/sales_history.html' + context['orders'] = Order.objects.all().order_by('-placed_at') + + return render(request, 'info/reports_master.html', context) \ No newline at end of file diff --git a/essenza/templates/base.html b/essenza/templates/base.html index d68aa8aa..eefd7ed6 100644 --- a/essenza/templates/base.html +++ b/essenza/templates/base.html @@ -518,6 +518,7 @@ Productos Pedidos Usuarios + Ventas {% else %} Escaparate Catalogo diff --git a/essenza/templates/info/product_sales.html b/essenza/templates/info/product_sales.html new file mode 100644 index 00000000..63b13511 --- /dev/null +++ b/essenza/templates/info/product_sales.html @@ -0,0 +1,29 @@ +
+

Resumen por Producto

+ + + + + + + + + + + {% for item in sales_data %} + + + + + + + {% empty %} + + + + {% endfor %} + {# Aquí podrías añadir la fila de totales si calculas el total general en la vista #} + {# ... #} + +
ID ProductoNombre del ProductoCantidad VendidaIngresos Totales (€)
{{ item.product__id }}{{ item.product__name }}{{ item.total_sold }}{{ item.total_revenue|floatformat:2 }} €
No hay datos de ventas de productos.
+
\ No newline at end of file diff --git a/essenza/templates/info/reports_master.html b/essenza/templates/info/reports_master.html new file mode 100644 index 00000000..99d8fce0 --- /dev/null +++ b/essenza/templates/info/reports_master.html @@ -0,0 +1,90 @@ +{% extends "base.html" %} +{% load humanize %} +{% load static %} + +{% block title %}Reportes · Essenza{% endblock %} + +{# ESTE BLOQUE SILENCIA LA BARRA DE NAVEGACIÓN SUPERIOR SI ESTÁ DUPLICADA EN base.html #} +{% block top_nav %}{% endblock %} + +{% block extra_head %} + + +{% endblock %} + +{% block content %} +
+ + + + {% include 'info/reports_nav.html' %} + +
+ {# La vista define qué fragmento se incluye: sales_history.html, etc. #} + {% include template_name %} +
+
+{% endblock %} \ No newline at end of file diff --git a/essenza/templates/info/reports_nav.html b/essenza/templates/info/reports_nav.html new file mode 100644 index 00000000..08baa1d4 --- /dev/null +++ b/essenza/templates/info/reports_nav.html @@ -0,0 +1,9 @@ +{% load static %} +
+ {% for nav_item in reports_nav %} + + {{ nav_item.name }} + + {% endfor %} +
\ No newline at end of file diff --git a/essenza/templates/info/sales_history.html b/essenza/templates/info/sales_history.html new file mode 100644 index 00000000..60f48827 --- /dev/null +++ b/essenza/templates/info/sales_history.html @@ -0,0 +1,29 @@ +
+

Listado de Ventas

+ + + + + + + + + + + + {% for order in orders %} + + + + + + + + {% empty %} + + + + {% endfor %} + +
ID VentaClienteEmailFechaTotal (€)
#{{ order.id }}{{ order.user.username|default:"Anónimo" }}{{ order.email }}{{ order.placed_at|date:"d M Y, H:i" }}{{ order.total_price|floatformat:2 }} €
No hay ventas para mostrar.
+
\ No newline at end of file diff --git a/essenza/templates/info/user_sales.html b/essenza/templates/info/user_sales.html new file mode 100644 index 00000000..4a752969 --- /dev/null +++ b/essenza/templates/info/user_sales.html @@ -0,0 +1,29 @@ +
+

Resumen por Cliente

+ + + + + + + + + + + {% for sale in sales_data %} + + + + + + + {% empty %} + + + + {% endfor %} + {# Aquí podrías añadir la fila de totales si calculas el total general en la vista #} + {# ... #} + +
ID UsuarioNombre de UsuarioCorreo ElectrónicoTotal Gastado (€)
{{ sale.user__id }}{{ sale.user__username }}{{ sale.user__email }}{{ sale.total_spent|floatformat:2 }} €
No hay datos de ventas por usuario.
+
\ No newline at end of file From 86e8ee6b3cc5b7a88e11aa085d03aa8c779213f6 Mon Sep 17 00:00:00 2001 From: xgc1564 Date: Sun, 23 Nov 2025 15:21:57 +0100 Subject: [PATCH 2/2] =?UTF-8?q?Peque=C3=B1os=20cambios=20en=20la=20est?= =?UTF-8?q?=C3=A9tica?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- essenza/info/tests.py | 25 ---------------------- essenza/info/views.py | 4 ++-- essenza/templates/info/product_sales.html | 2 -- essenza/templates/info/reports_master.html | 8 ------- essenza/templates/info/user_sales.html | 4 +--- 5 files changed, 3 insertions(+), 40 deletions(-) diff --git a/essenza/info/tests.py b/essenza/info/tests.py index 11637b78..fb860b9f 100644 --- a/essenza/info/tests.py +++ b/essenza/info/tests.py @@ -31,7 +31,6 @@ def setUpTestData(cls): cls.prod1 = Product.objects.create(name='Vela', price=10.00, stock=50) cls.prod2 = Product.objects.create(name='Jabon', price=5.00, stock=100) - # 3. Crear Órdenes de prueba, especificando placed_at cls.order1 = Order.objects.create( user=cls.client_user, email='client@test.com', address='Calle Falsa 123', status=Status.ENTREGADO, placed_at=timezone.now() - timezone.timedelta(days=2) @@ -40,36 +39,25 @@ def setUpTestData(cls): user=cls.admin_user, email='admin@test.com', address='Calle Real 456', status=Status.ENVIADO, placed_at=timezone.now() - timezone.timedelta(days=1) ) - - # 4. Crear Artículos de Pedido (OrderProduct) OrderProduct.objects.create(order=cls.order1, product=cls.prod1, quantity=10) OrderProduct.objects.create(order=cls.order1, product=cls.prod2, quantity=5) OrderProduct.objects.create(order=cls.order2, product=cls.prod1, quantity=3) - # 5. Definición de URLs (Accedidas por self.history_url, etc. en los tests) cls.history_url = reverse('info:sales_reports_view', args=['history']) cls.product_url = reverse('info:sales_reports_view', args=['product']) cls.user_url = reverse('info:sales_reports_view', args=['user']) - # El método setUp(self) ya no necesita redefinir los objetos. - def setUp(self): - pass - - def test_unauthenticated_access_is_denied(self): self.client.logout() - # history_url se obtiene de la clase (setUpTestData) response = self.client.get(self.history_url) self.assertEqual(response.status_code, 403) def test_non_admin_user_gets_forbidden_403(self): - # Accedemos a la instancia de usuario desde la clase: self.client_user self.client.force_login(self.client_user) response = self.client.get(self.history_url) self.assertEqual(response.status_code, 403) def test_access_granted_to_admin_user(self): - # Accedemos a la instancia de usuario desde la clase: self.admin_user self.client.force_login(self.admin_user) response = self.client.get(self.history_url) self.assertEqual(response.status_code, 200) @@ -77,19 +65,14 @@ def test_access_granted_to_admin_user(self): def test_report_with_no_sales(self): """Asegura que el reporte funciona (200 OK) cuando no hay órdenes en la DB.""" - # Eliminar todos los datos de prueba Order.objects.all().delete() self.client.force_login(self.admin_user) response = self.client.get(self.history_url) self.assertEqual(response.status_code, 200) - - # Verificar que las listas de contexto están vacías self.assertIn('orders', response.context) self.assertEqual(response.context['orders'].count(), 0) - - # Probar el reporte por producto (debe dar 200 y lista vacía) response_product = self.client.get(self.product_url) self.assertEqual(response_product.status_code, 200) self.assertEqual(response_product.context['sales_data'].count(), 0) @@ -97,8 +80,6 @@ def test_report_with_no_sales(self): def test_report_excludes_null_users(self): """Asegura que los pedidos sin usuario (anónimos) son excluidos del reporte de usuario.""" - - # Crear una orden sin usuario asignado (anónima) Order.objects.create( user=None, email='anon@test.com', address='Unknown', status=Status.ENVIADO ) @@ -114,18 +95,12 @@ def test_report_excludes_null_users(self): def test_template_names_are_correct(self): """Verifica que la plantilla de contenido y el título son correctos para cada tipo.""" self.client.force_login(self.admin_user) - - # 1. Historial (Default) response_h = self.client.get(self.history_url) self.assertEqual(response_h.context['template_name'], 'info/sales_history.html') self.assertEqual(response_h.context['report_title'], 'Historial Completo de Ventas') - - # 2. Producto response_p = self.client.get(self.product_url) self.assertEqual(response_p.context['template_name'], 'info/product_sales.html') self.assertEqual(response_p.context['report_title'], 'Ventas Totales por Producto') - - # 3. Usuario response_u = self.client.get(self.user_url) self.assertEqual(response_u.context['template_name'], 'info/user_sales.html') self.assertEqual(response_u.context['report_title'], 'Ventas Totales por Usuario') \ No newline at end of file diff --git a/essenza/info/views.py b/essenza/info/views.py index 1fd12ac3..1f8605ee 100644 --- a/essenza/info/views.py +++ b/essenza/info/views.py @@ -5,7 +5,7 @@ from django.shortcuts import redirect, render from django.urls import reverse from django.views import View -from django.http import HttpResponseForbidden # Importación necesaria para 403 +from django.http import HttpResponseForbidden from order.models import Order, OrderProduct @@ -48,7 +48,7 @@ def get(self, request, report_type='history'): context['report_title'] = 'Ventas Totales por Usuario' context['template_name'] = 'info/user_sales.html' context['sales_data'] = Order.objects.values( - 'user__id', 'user__username', 'user__email' + 'user__id', 'user__first_name', 'user__email' ).annotate( total_spent=Sum(F('order_products__quantity') * F('order_products__product__price')) ).exclude( diff --git a/essenza/templates/info/product_sales.html b/essenza/templates/info/product_sales.html index 63b13511..f7c7381e 100644 --- a/essenza/templates/info/product_sales.html +++ b/essenza/templates/info/product_sales.html @@ -22,8 +22,6 @@

Resumen por Producto

No hay datos de ventas de productos. {% endfor %} - {# Aquí podrías añadir la fila de totales si calculas el total general en la vista #} - {# ... #} \ No newline at end of file diff --git a/essenza/templates/info/reports_master.html b/essenza/templates/info/reports_master.html index 99d8fce0..174d4a64 100644 --- a/essenza/templates/info/reports_master.html +++ b/essenza/templates/info/reports_master.html @@ -4,7 +4,6 @@ {% block title %}Reportes · Essenza{% endblock %} -{# ESTE BLOQUE SILENCIA LA BARRA DE NAVEGACIÓN SUPERIOR SI ESTÁ DUPLICADA EN base.html #} {% block top_nav %}{% endblock %} {% block extra_head %} @@ -59,16 +58,10 @@ .report-table tr:hover { background-color: #fffaf7; } .total-row { background-color: #fff5e8; font-weight: 800; color: #c06b3e; } - /* Media Queries y estilos de tarjeta (Mantener para coherencia) */ @media (max-width: 768px) { .filters { flex-direction: column; align-items: stretch; } .filter-btn { justify-content: center; } } - /* ... (Estilos de tarjeta) ... */ - - /* ======================================================= - FIN DEL CSS COMPLETO - ======================================================= */ {% endblock %} @@ -83,7 +76,6 @@

Reportes Administrativos

{% include 'info/reports_nav.html' %}
- {# La vista define qué fragmento se incluye: sales_history.html, etc. #} {% include template_name %}
diff --git a/essenza/templates/info/user_sales.html b/essenza/templates/info/user_sales.html index 4a752969..fb3a7543 100644 --- a/essenza/templates/info/user_sales.html +++ b/essenza/templates/info/user_sales.html @@ -13,7 +13,7 @@

Resumen por Cliente

{% for sale in sales_data %} {{ sale.user__id }} - {{ sale.user__username }} + {{ sale.user__first_name }} {{ sale.user__email }} {{ sale.total_spent|floatformat:2 }} € @@ -22,8 +22,6 @@

Resumen por Cliente

No hay datos de ventas por usuario. {% endfor %} - {# Aquí podrías añadir la fila de totales si calculas el total general en la vista #} - {# ... #} \ No newline at end of file