diff --git a/essenza/product/tests.py b/essenza/product/tests.py
index 2f03894..a68511e 100644
--- a/essenza/product/tests.py
+++ b/essenza/product/tests.py
@@ -1,14 +1,242 @@
+from decimal import Decimal
+
+from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
-from django.contrib.auth import get_user_model
-from product.models import Product, Category
-from django.core.files.uploadedfile import SimpleUploadedFile
-from decimal import Decimal
+from django.utils import timezone
+from order.models import Order, OrderProduct
+
+from .models import Category, Product
+
User = get_user_model()
-class ProductCRUDTests(TestCase):
+class DashboardViewLogicTests(TestCase):
+ def setUp(self):
+ self.dashboard_url = reverse("dashboard")
+ self.login_url = reverse("login")
+ self.now = timezone.now()
+
+ # --- Usuarios (Usando tus roles 'admin' y 'user') ---
+ self.admin_user = User.objects.create_user(
+ username="admin", email="admin@test.com", password="pass", role="admin"
+ )
+ self.regular_user = User.objects.create_user(
+ username="user", email="user@test.com", password="pass", role="user"
+ )
+
+ # --- Productos ---
+ self.p_30_day = Product.objects.create(
+ name="Producto 30 Días",
+ description="Test Desc",
+ category=Category.MAQUILLAJE,
+ brand="TestBrand",
+ price=10.00,
+ stock=10,
+ is_active=True,
+ )
+ self.p_1_year = Product.objects.create(
+ name="Producto 1 Año",
+ description="Test Desc",
+ category=Category.CABELLO,
+ brand="TestBrand",
+ price=20.00,
+ stock=20,
+ is_active=True,
+ )
+ self.p_stock = Product.objects.create(
+ name="Producto Stock Alto",
+ description="Test Desc",
+ category=Category.PERFUME,
+ brand="TestBrand",
+ price=30.00,
+ stock=999,
+ is_active=True,
+ )
+ self.p_stock_low = Product.objects.create(
+ name="Producto Stock Bajo",
+ description="Test Desc",
+ category=Category.TRATAMIENTO,
+ brand="TestBrand",
+ price=40.00,
+ stock=1,
+ is_active=True,
+ )
+ self.p_inactive = Product.objects.create(
+ name="Producto Inactivo",
+ description="Test Desc",
+ category=Category.MAQUILLAJE,
+ brand="TestBrand",
+ price=50.00,
+ stock=1000,
+ is_active=False,
+ )
+
+ # --- 1. Tests de Permisos (test_func) ---
+
+ def test_anonymous_user_gets_200(self):
+ """Prueba que los anónimos pueden ver la vista."""
+ resp = self.client.get(self.dashboard_url)
+ self.assertEqual(resp.status_code, 200)
+ self.assertTemplateUsed(resp, "product/dashboard.html")
+
+ def test_regular_user_gets_200(self):
+ """Prueba que los usuarios 'user' pueden ver la vista."""
+ self.client.login(email="user@test.com", password="pass")
+ resp = self.client.get(self.dashboard_url)
+ self.assertEqual(resp.status_code, 200)
+ self.assertTemplateUsed(resp, "product/dashboard.html")
+
+ def test_admin_user_is_forbidden(self):
+ """Prueba que los 'admin' son bloqueados."""
+ self.client.login(email="admin@test.com", password="pass")
+ resp = self.client.get(self.dashboard_url)
+ self.assertEqual(resp.status_code, 403)
+
+ # --- 2. Tests de Lógica de Negocio (método get) ---
+
+ def test_logic_branch_1_shows_30_day_products(self):
+ """
+ Prueba el primer 'if': Muestra productos de 30 días.
+ Ignora ventas más antiguas aunque sean mayores.
+ """
+ # 1. Crear ventas recientes (hace 10 días)
+ order_recent = Order.objects.create(
+ user=self.regular_user,
+ address="Test Address 1",
+ placed_at=self.now
+ - timezone.timedelta(days=10), # <-- Controlamos la fecha
+ )
+ OrderProduct.objects.create(
+ order=order_recent, product=self.p_30_day, quantity=100
+ )
+
+ # 2. Crear ventas antiguas (hace 100 días)
+ order_old = Order.objects.create(
+ user=self.regular_user,
+ address="Test Address 2",
+ placed_at=self.now - timezone.timedelta(days=100),
+ )
+ OrderProduct.objects.create(
+ order=order_old,
+ product=self.p_1_year,
+ quantity=500, # Más ventas, pero antiguo
+ )
+
+ resp = self.client.get(self.dashboard_url)
+ products_in_context = list(resp.context["products"])
+
+ # ASERCIÓN: Solo debe aparecer el producto de 30 días
+ self.assertEqual(len(products_in_context), 1)
+ self.assertEqual(products_in_context[0], self.p_30_day)
+ self.assertEqual(products_in_context[0].total_quantity, 100)
+
+ def test_logic_branch_2_falls_back_to_1_year_products(self):
+ """
+ Prueba el segundo 'if': Falla 30 días, muestra 1 año.
+ Ignora ventas de hace más de 1 año.
+ """
+ # 1. NO crear ventas recientes
+
+ # 2. Crear ventas antiguas (hace 100 días)
+ order_old = Order.objects.create(
+ user=self.regular_user,
+ address="Test Address 1",
+ placed_at=self.now - timezone.timedelta(days=100),
+ )
+ OrderProduct.objects.create(
+ order=order_old, product=self.p_1_year, quantity=500
+ )
+
+ # 3. Crear ventas MUY antiguas (hace 400 días) - debe ignorarse
+ order_ancient = Order.objects.create(
+ user=self.regular_user,
+ address="Test Address 2",
+ placed_at=self.now - timezone.timedelta(days=400),
+ )
+ OrderProduct.objects.create(
+ order=order_ancient, product=self.p_30_day, quantity=999
+ )
+
+ resp = self.client.get(self.dashboard_url)
+ products_in_context = list(resp.context["products"])
+
+ # ASERCIÓN: Solo debe aparecer el producto de 1 año
+ self.assertEqual(len(products_in_context), 1)
+ self.assertEqual(products_in_context[0], self.p_1_year)
+ self.assertEqual(products_in_context[0].total_quantity, 500)
+
+ def test_logic_branch_3_falls_back_to_stock_products(self):
+ """
+ Prueba el tercer 'if': Falla 1 año, muestra por stock.
+ Ignora ventas de productos inactivos.
+ """
+ # 1. NO crear ventas recientes
+ # 2. NO crear ventas en el último año
+
+ # 3. Crear ventas MUY antiguas (hace 400 días) - para forzar el fallback
+ order_ancient = Order.objects.create(
+ user=self.regular_user,
+ address="Test Address 1",
+ placed_at=self.now - timezone.timedelta(days=400),
+ )
+ OrderProduct.objects.create(
+ order=order_ancient, product=self.p_30_day, quantity=999
+ )
+
+ # 4. Crear ventas de productos INACTIVOS (deben ignorarse siempre)
+ order_inactive = Order.objects.create(
+ user=self.regular_user,
+ address="Test Address 2",
+ placed_at=self.now - timezone.timedelta(days=10), # Reciente, pero inactivo
+ )
+ OrderProduct.objects.create(
+ order=order_inactive, product=self.p_inactive, quantity=5000
+ )
+
+ resp = self.client.get(self.dashboard_url)
+ products_in_context = list(resp.context["products"])
+
+ # ASERCIÓN: Debe mostrar los productos activos por stock descendente
+ # p_stock (999) > p_1_year (20) > p_30_day (10) > p_stock_low (1)
+ # p_inactive (1000) debe ser ignorado.
+ self.assertIn(self.p_stock, products_in_context)
+ self.assertIn(self.p_1_year, products_in_context)
+ self.assertNotIn(self.p_inactive, products_in_context) # Clave
+
+ # Comprobar el orden por stock
+ self.assertEqual(products_in_context[0], self.p_stock) # stock 999
+ self.assertEqual(products_in_context[1], self.p_1_year) # stock 20
+ self.assertEqual(products_in_context[2], self.p_30_day) # stock 10
+ self.assertEqual(products_in_context[3], self.p_stock_low) # stock 1
+
+ # Comprobar que no hay 'total_quantity' (viene de la consulta de stock)
+ self.assertFalse(hasattr(products_in_context[0], "total_quantity"))
+
+ def test_logic_branch_4_handles_empty_database(self):
+ """
+ Prueba el caso límite final: No hay productos activos en la BBDD.
+ La vista debe devolver una lista vacía, no romperse.
+ """
+
+ # 1. Borrar TODOS los productos creados en el setUp
+ # (Esto deja la BBDD sin productos activos)
+ Product.objects.all().delete()
+
+ # Cargar la vista
+ resp = self.client.get(self.dashboard_url)
+ self.assertEqual(resp.status_code, 200)
+
+ # ASERCIÓN:
+ # El contexto 'products' debe existir, pero estar vacío.
+ self.assertIn("products", resp.context)
+ products_in_context = list(resp.context["products"])
+
+ self.assertEqual(len(products_in_context), 0)
+
+
+class ProductCRUDTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(
username="user",
@@ -29,10 +257,10 @@ def setUp(self):
description="Descripción",
brand="Marca X",
price=10,
- photo= None,
+ photo=None,
stock=5,
category="maquillaje",
- is_active=True
+ is_active=True,
)
# URLs
self.list_url = reverse("product_list")
@@ -66,7 +294,7 @@ def test_create_requires_login(self):
def test_user_cannot_access_list(self):
self.client.login(username="user", password="pass1234")
resp = self.client.get(self.list_url)
- self.assertEqual(resp.status_code, 302) # Redirigido por permisos
+ self.assertEqual(resp.status_code, 302) # Redirigido por permisos
def test_user_cannot_access_detail(self):
self.client.login(username="user", password="pass1234")
@@ -84,22 +312,21 @@ def test_user_cannot_access_create(self):
def test_admin_can_access_list(self):
self.client.force_login(self.admin)
- url = reverse('product_list')
+ url = reverse("product_list")
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, "Producto Test")
-
def test_admin_can_access_detail(self):
self.client.force_login(self.admin)
- url = reverse('product_detail', args=[self.product.pk])
+ url = reverse("product_detail", args=[self.product.pk])
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, self.product.name)
def test_admin_can_delete_product(self):
self.client.force_login(self.admin)
- url = reverse('product_delete', args=[self.product.pk])
+ url = reverse("product_delete", args=[self.product.pk])
# GET renderiza el confirm delete
resp_get = self.client.get(url)
diff --git a/essenza/product/views.py b/essenza/product/views.py
index 0550e2a..4869951 100644
--- a/essenza/product/views.py
+++ b/essenza/product/views.py
@@ -1,37 +1,49 @@
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
+from django.db.models import Sum
from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
from django.views import View
+
from .forms import ProductForm
-from order.models import OrderProduct
from .models import Product
-from django.shortcuts import render, get_object_or_404
-class DashboardView(View):
+
+class DashboardView(UserPassesTestMixin, View):
template_name = "product/dashboard.html"
+ # Todos excepto los administradores pueden acceder a esta vista
+ def test_func(self):
+ return (
+ not self.request.user.is_authenticated or self.request.user.role != "admin"
+ )
+
def get(self, request, *args, **kwargs):
- # Obtenemos de los datos
- order_products = OrderProduct.objects.all()
month_ago = timezone.now() - timezone.timedelta(days=30)
- times_purchased = {}
- for order_product in order_products:
- order = order_product.order
- if order.placed_at >= month_ago:
- if order_product.product.id in times_purchased:
- times_purchased[order_product.product.id] += order_product.quantity
- else:
- times_purchased[order_product.product.id] = order_product.quantity
- times_purchased_ordered = dict(
- sorted(
- times_purchased.items(), key=lambda quantity: quantity[1], reverse=True
+ year_ago = timezone.now() - timezone.timedelta(days=365)
+
+ def get_top_selling_products(since):
+ # Paso 1: Definir el filtro base.
+ base_query = Product.objects.filter(
+ is_active=True, product_orders__order__placed_at__gte=since
)
- )
- most_purchased_products = list(times_purchased_ordered.keys())[:10]
- products = Product.objects.filter(
- is_active=True, id__in=most_purchased_products
- )
+ # Paso 2: Anotar (calcular) el total vendido para esos productos.
+ query_with_totals = base_query.annotate(
+ total_quantity=Sum("product_orders__quantity")
+ )
+ # Paso 3: Filtrar de nuevo, sobre el campo calculado.
+ filtered_query = query_with_totals.filter(total_quantity__gt=0)
+ # Paso 4: Ordenar (descendente).
+ ordered_query = filtered_query.order_by("-total_quantity")
+ # Paso 5: Limitar.
+ top_products = ordered_query[:10]
+ return top_products
+
+ products = get_top_selling_products(since=month_ago)
+ if not products.exists():
+ products = get_top_selling_products(since=year_ago)
+ if not products.exists():
+ products = Product.objects.filter(is_active=True).order_by("-stock")[:10]
return render(request, self.template_name, {"products": products})
@@ -68,80 +80,85 @@ def post(self, request):
# Recarga la misma página
return redirect("stock")
-
-class ProductListView(LoginRequiredMixin, UserPassesTestMixin,View):
- template_name = 'product/list.html'
+
+class ProductListView(LoginRequiredMixin, UserPassesTestMixin, View):
+ template_name = "product/list.html"
def test_func(self):
return self.request.user.is_authenticated and self.request.user.role == "admin"
def get(self, request):
products = Product.objects.all()
- return render(request, self.template_name, {'products': products})
+ return render(request, self.template_name, {"products": products})
+
-class ProductDetailView(LoginRequiredMixin, UserPassesTestMixin,View):
- template_name = 'product/detail.html'
+class ProductDetailView(LoginRequiredMixin, UserPassesTestMixin, View):
+ template_name = "product/detail.html"
def test_func(self):
return self.request.user.is_authenticated and self.request.user.role == "admin"
def get(self, request, pk):
product = get_object_or_404(Product, pk=pk)
- return render(request, self.template_name, {'product': product})
-
-class ProductCreateView(LoginRequiredMixin, UserPassesTestMixin,View):
- template_name = 'product/form.html'
+ return render(request, self.template_name, {"product": product})
+
+
+class ProductCreateView(LoginRequiredMixin, UserPassesTestMixin, View):
+ template_name = "product/form.html"
form_class = ProductForm
+
def test_func(self):
return self.request.user.is_authenticated and self.request.user.role == "admin"
def get(self, request):
form = self.form_class()
- return render(request, self.template_name, {'form': form})
+ return render(request, self.template_name, {"form": form})
def post(self, request):
form = self.form_class(request.POST, request.FILES)
if form.is_valid():
form.save()
- return redirect('product_list')
- return render(request, self.template_name, {'form': form})
+ return redirect("product_list")
+ return render(request, self.template_name, {"form": form})
+
-class ProductUpdateView(LoginRequiredMixin, UserPassesTestMixin,View):
- template_name = 'product/form.html'
+class ProductUpdateView(LoginRequiredMixin, UserPassesTestMixin, View):
+ template_name = "product/form.html"
form_class = ProductForm
-
+
def test_func(self):
return self.request.user.is_authenticated and self.request.user.role == "admin"
-
+
def get(self, request, pk):
product = get_object_or_404(Product, pk=pk)
form = self.form_class(instance=product)
- return render(request, self.template_name, {'form': form, 'product': product})
-
+ return render(request, self.template_name, {"form": form, "product": product})
def post(self, request, pk):
product = get_object_or_404(Product, pk=pk)
form = self.form_class(request.POST, request.FILES, instance=product)
if form.is_valid():
form.save()
- return redirect('product_detail', pk=product.pk)
- return render(request, self.template_name, {'form': form, 'product': product})
+ return redirect("product_list")
+ return render(request, self.template_name, {"form": form, "product": product})
+
+
+class ProductDeleteView(LoginRequiredMixin, UserPassesTestMixin, View):
+ template_name = "product/confirm_delete.html"
-class ProductDeleteView(LoginRequiredMixin, UserPassesTestMixin,View):
- template_name = 'product/confirm_delete.html'
def test_func(self):
return self.request.user.is_authenticated and self.request.user.role == "admin"
-
+
def get(self, request, pk):
product = get_object_or_404(Product, pk=pk)
- return render(request, self.template_name, {'product': product})
-
+ return render(request, self.template_name, {"product": product})
def post(self, request, pk):
product = get_object_or_404(Product, pk=pk)
product.delete()
- return redirect('product_list')
+ return redirect("product_list")
+
class CatalogView(View):
template_name = "product/catalog.html"
diff --git a/essenza/static/images/default_photo.png b/essenza/static/images/default_product.png
similarity index 100%
rename from essenza/static/images/default_photo.png
rename to essenza/static/images/default_product.png
diff --git a/essenza/templates/product/catalog.html b/essenza/templates/product/catalog.html
index 2bf34da..974a580 100644
--- a/essenza/templates/product/catalog.html
+++ b/essenza/templates/product/catalog.html
@@ -1,291 +1,308 @@
-{% load static %}
-{% load humanize %}
+{% load static %} {% load humanize %}
-
-
+
+
Catálogo · Essenza
-
-
-
+
-
+
+ {% if products|length > 1 %}
-
Top 10 Bestsellers
+
Top Bestsellers
Una cuidada selección de los productos estrella de la temporada.
+ {% endif %}
{% if products %} {% for p in products %}
-
+
{% if p.photo %}
diff --git a/essenza/templates/product/detail.html b/essenza/templates/product/detail.html
index aa9f19c..5c6953d 100644
--- a/essenza/templates/product/detail.html
+++ b/essenza/templates/product/detail.html
@@ -1,229 +1,236 @@
+{% load static %}
-
-
-
+
+
+
{{ product.name }} - Essenza
-
-
+
+
-
-
-
-
-
-
Descripción:
-
{{ product.description }}
+
+
+
+
Descripción:
+
{{ product.description }}
+
-
-
-
Estado
-
- {% if product.is_active %}
- ✓ Activo
- {% else %}
- ✗ Inactivo
- {% endif %}
-
-
-
-
ID Producto
-
#{{ product.id }}
-
+
+
+
Estado
+
+ {% if product.is_active %}
+ ✓ Activo
+ {% else %}
+ ✗ Inactivo
+ {% endif %}
+
+
+
ID Producto
+
#{{ product.id }}
+
+
-
-
+
+
-
+
diff --git a/essenza/templates/product/detail_user.html b/essenza/templates/product/detail_user.html
index 49db3f2..19d40c1 100644
--- a/essenza/templates/product/detail_user.html
+++ b/essenza/templates/product/detail_user.html
@@ -1,326 +1,348 @@
{% load static %}
-
-
-
+
+
+
{{ product.name }} - Essenza
-
-
-
+
+
-
-
-
-
-
-
-
-
Descripción:
-
{{ product.description }}
+
+
+
-
-
-
-
Estado
-
- {% if product.is_active %}
- ✓ Activo
- {% else %}
- ✗ Inactivo
- {% endif %}
-
-
-
-
ID Producto
-
#{{ product.id }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Añadir al carrito
-
-
-
-
- ✓ Añadido
-
-
-
+
+
+
Descripción:
+
{{ product.description }}
+
-
← Volver
+
+
+
+
Estado
+
+ {% if product.is_active %}
+ ✓ Activo
+ {% else %}
+ ✗ Inactivo
+ {% endif %}
+
+
+
ID Producto
+
#{{ product.id }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Añadir al carrito
+
+
+
+ ✓ Añadido
+
+
+
← Volver
+
-
-
+
diff --git a/essenza/templates/product/list.html b/essenza/templates/product/list.html
index c47b7c0..38f4cb6 100644
--- a/essenza/templates/product/list.html
+++ b/essenza/templates/product/list.html
@@ -1,400 +1,427 @@
{% load static %}
-
-
-
+
+
+
Lista de Productos - Essenza
-
-
+
+
-
-
i
-
ESSENZA
-
-
-
-
+
-
-
-
-
-
-
-
+
-
🌸 Productos Essenza
-
-
+
🌸 Productos Essenza
+
- {% if products %}
-
- {% for product in products %}
-
-
- {% if product.photo %}
-
- {% else %}
-
Sin imagen
- {% endif %}
-
-
-
{{ product.name }}
-
{{ product.brand }}
-
€ {{ product.price }}
-
Stock: {{ product.stock }}
-
-
-
- {% endfor %}
+ {% if products %}
+
+ {% for product in products %}
+
+
+ {% if product.photo %}
+
+ {% else %}
+
Sin imagen
+ {% endif %}
+
+
+
{{ product.name }}
+
{{ product.brand }}
+
€ {{ product.price }}
+
Stock: {{ product.stock }}
+
- {% else %}
-
- {% endif %}
-
-
-
+ {% endfor %}
+
+ {% else %}
+
+
+ No hay productos disponibles.
+ ¡Crea uno!
+
+
+ {% endif %}
+
+
-
-
\ No newline at end of file
+ * Cierra el desplegable si el usuario hace clic fuera de él
+ */
+ window.onclick = function (event) {
+ // Comprueba si el clic NO fue en el botón del ícono
+ if (!event.target.closest(".profile-icon-btn")) {
+ var dropdowns = document.getElementsByClassName("dropdown-content");
+ var i;
+ for (i = 0; i < dropdowns.length; i++) {
+ var openDropdown = dropdowns[i];
+ // Si el menú está abierto (tiene la clase .show), la cierra
+ if (openDropdown.classList.contains("show")) {
+ openDropdown.classList.remove("show");
+ }
+ }
+ }
+ };
+
+
+
diff --git a/essenza/templates/product/stock.html b/essenza/templates/product/stock.html
index 60ae8f0..e4779cf 100644
--- a/essenza/templates/product/stock.html
+++ b/essenza/templates/product/stock.html
@@ -157,7 +157,7 @@
.product-card img {
width: 140px;
height: 140px;
- object-fit: cover;
+ object-fit: contain;
border-radius: 10px;
flex-shrink: 0;
}
@@ -302,7 +302,7 @@
{% if p.photo %}
{% else %}
-
+
{% endif %}
diff --git a/essenza/templates/user/profile.html b/essenza/templates/user/profile.html
index 7bd27ee..35ec3b9 100644
--- a/essenza/templates/user/profile.html
+++ b/essenza/templates/user/profile.html
@@ -80,7 +80,7 @@
width: 150px;
height: 150px;
border-radius: 50%;
- object-fit: cover;
+ object-fit: contain;
display: block;
margin: 10px auto;
border: 2px solid var(--color-principal);