Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions essenza/essenza/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
path("catalog/<int:pk>/", CatalogDetailView.as_view(), name="catalog_detail"),
path("cart/", include("cart.urls")),
path("order/", include("order.urls")),
path('info/', include('info.urls')),
]

if settings.DEBUG:
Expand Down
107 changes: 105 additions & 2 deletions essenza/info/tests.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,106 @@
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)

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)
)
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)

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'])

def test_unauthenticated_access_is_denied(self):
self.client.logout()
response = self.client.get(self.history_url)
self.assertEqual(response.status_code, 403)

def test_non_admin_user_gets_forbidden_403(self):
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):
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."""
Order.objects.all().delete()

self.client.force_login(self.admin_user)
response = self.client.get(self.history_url)

self.assertEqual(response.status_code, 200)
self.assertIn('orders', response.context)
self.assertEqual(response.context['orders'].count(), 0)
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."""
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)
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')
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')
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')
11 changes: 11 additions & 0 deletions essenza/info/urls.py
Original file line number Diff line number Diff line change
@@ -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/<str:report_type>/", views.SalesReportsView.as_view(), name="sales_reports_view"),
]
63 changes: 60 additions & 3 deletions essenza/info/views.py
Original file line number Diff line number Diff line change
@@ -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

from order.models import Order, OrderProduct

from django.shortcuts import render

def info_view(request):
return render(request, "info/info.html")
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__first_name', '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)
1 change: 1 addition & 0 deletions essenza/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,7 @@
<a href="{% url 'product_list' %}">Productos</a>
<a href="{% url 'order_list_admin' %}">Pedidos</a>
<a href="{% url 'user_list' %}">Usuarios</a>
<a href="{% url 'info:sales_history_report' %}">Ventas</a>
{% else %}
<a href="{% url 'dashboard' %}">Escaparate</a>
<a href="{% url 'catalog' %}">Catalogo</a>
Expand Down
27 changes: 27 additions & 0 deletions essenza/templates/info/product_sales.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<div class="report-table-container">
<h2>Resumen por Producto</h2>
<table class="report-table">
<thead>
<tr>
<th>ID Producto</th>
<th>Nombre del Producto</th>
<th>Cantidad Vendida</th>
<th>Ingresos Totales (€)</th>
</tr>
</thead>
<tbody>
{% for item in sales_data %}
<tr>
<td>{{ item.product__id }}</td>
<td>{{ item.product__name }}</td>
<td>{{ item.total_sold }}</td>
<td>{{ item.total_revenue|floatformat:2 }} €</td>
</tr>
{% empty %}
<tr>
<td colspan="4" style="text-align: center; color: #999;">No hay datos de ventas de productos.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
82 changes: 82 additions & 0 deletions essenza/templates/info/reports_master.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
{% extends "base.html" %}
{% load humanize %}
{% load static %}

{% block title %}Reportes · Essenza{% endblock %}

{% block top_nav %}{% endblock %}

{% block extra_head %}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<style>
/* =======================================================
INICIO DEL CSS COMPLETO (SOLO ESTILOS)
======================================================= */
/* === ESTILOS BASE Y CONTENEDOR DE PÁGINA === */
body { background-color: #faf7f2; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; color: #333; }

/* Clase envolvente única para el contenido de reporte */
.page-container-reports {
max-width: 1000px;
margin: 40px auto;
padding: 0 20px 60px;
}

/* --- CABECERA --- */
.page-header { margin-bottom: 30px; text-align: center; }
.page-header h1 { font-size: 2rem; color: #c06b3e; font-weight: 800; margin: 0 0 10px 0; letter-spacing: -0.5px; }
.page-header p { color: #666; font-size: 1rem; margin: 0; }

/* --- BARRA DE FILTROS (BOTONES DE NAVEGACIÓN) --- */
.filters { display: flex; justify-content: center; flex-wrap: wrap; gap: 15px; margin-bottom: 35px; }
.filter-btn {
background-color: white; border: 1px solid #e0e0e0; border-radius: 8px;
padding: 10px 20px; cursor: pointer; transition: all 0.3s ease;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05); color: #555;
font-weight: 600; text-decoration: none; display: inline-flex;
align-items: center; gap: 8px; font-size: 0.95rem;
}
.filter-btn:hover { border-color: #c06b3e; color: #c06b3e; transform: translateY(-2px); box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); }
.filter-btn.active {
background-color: #c06b3e; color: white; border-color: #c06b3e;
box-shadow: 0 4px 12px rgba(192, 107, 62, 0.2);
}

/* --- ESTILOS DE TABLAS DE REPORTE --- */
.report-table-container {
margin-top: 20px;
padding: 25px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
border: 1px solid #eee;
overflow-x: auto;
}
.report-table { width: 100%; border-collapse: collapse; min-width: 650px; }
.report-table th, .report-table td { padding: 12px 15px; text-align: left; border-bottom: 1px solid #f0f0f0; font-size: 0.95rem; }
.report-table th { background-color: #f8f8f8; color: #555; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; }
.report-table tr:hover { background-color: #fffaf7; }
.total-row { background-color: #fff5e8; font-weight: 800; color: #c06b3e; }

@media (max-width: 768px) {
.filters { flex-direction: column; align-items: stretch; }
.filter-btn { justify-content: center; }
}
</style>
{% endblock %}

{% block content %}
<div class="page-container-reports">

<div class="page-header">
<h1>Reportes Administrativos</h1>
<p>{{ report_title|default:"Selecciona un reporte de ventas" }}</p>
</div>

{% include 'info/reports_nav.html' %}

<div id="report-content">
{% include template_name %}
</div>
</div>
{% endblock %}
9 changes: 9 additions & 0 deletions essenza/templates/info/reports_nav.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{% load static %}
<div class="filters">
{% for nav_item in reports_nav %}
<a href="{{ nav_item.url }}"
class="filter-btn {% if current_report == nav_item.id %}active{% endif %}" >
{{ nav_item.name }}
</a>
{% endfor %}
</div>
29 changes: 29 additions & 0 deletions essenza/templates/info/sales_history.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<div class="report-table-container">
<h2>Listado de Ventas</h2>
<table class="report-table">
<thead>
<tr>
<th>ID Venta</th>
<th>Cliente</th>
<th>Email</th>
<th>Fecha</th>
<th>Total (€)</th>
</tr>
</thead>
<tbody>
{% for order in orders %}
<tr>
<td>#{{ order.id }}</td>
<td>{{ order.user.username|default:"Anónimo" }}</td>
<td>{{ order.email }}</td>
<td>{{ order.placed_at|date:"d M Y, H:i" }}</td>
<td>{{ order.total_price|floatformat:2 }} €</td>
</tr>
{% empty %}
<tr>
<td colspan="5" style="text-align: center; color: #999;">No hay ventas para mostrar.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
27 changes: 27 additions & 0 deletions essenza/templates/info/user_sales.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<div class="report-table-container">
<h2>Resumen por Cliente</h2>
<table class="report-table">
<thead>
<tr>
<th>ID Usuario</th>
<th>Nombre de Usuario</th>
<th>Correo Electrónico</th>
<th>Total Gastado (€)</th>
</tr>
</thead>
<tbody>
{% for sale in sales_data %}
<tr>
<td>{{ sale.user__id }}</td>
<td>{{ sale.user__first_name }}</td>
<td>{{ sale.user__email }}</td>
<td>{{ sale.total_spent|floatformat:2 }} €</td>
</tr>
{% empty %}
<tr>
<td colspan="4" style="text-align: center; color: #999;">No hay datos de ventas por usuario.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>