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 - - - + - +
- i -
ESSENZA
- - - -
- - + i +
ESSENZA
+ +
+ + +
-

Catálogo Essenza

-

Explora nuestra selección de productos mejor valorados

+

Catálogo Essenza

+

Explora nuestra selección de productos mejor valorados

- - - - - + + + + +
- {% for product in products %} -
- {% if product.photo %} - {{ product.name }} - {% else %} - {{ product.name }} - {% endif %} - + {% for product in products %} +
+ {% if product.photo %} + {{ product.name }} + {% else %} + {{ product.name }} + {% endif %} -

{{ product.name }}

-

{{ product.price }} €

- {{ product.get_category_display }} -
- {% endfor %} +

{{ product.name }}

+

{{ product.price }} €

+ {{ product.get_category_display }} +
+ {% endfor %}
- - + diff --git a/essenza/templates/product/dashboard.html b/essenza/templates/product/dashboard.html index 92e5b7a..9e9be5f 100644 --- a/essenza/templates/product/dashboard.html +++ b/essenza/templates/product/dashboard.html @@ -281,7 +281,10 @@
- i
ESSENZA
@@ -316,15 +319,17 @@
+ {% 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 - - + +
-
-
-
- {% if product.photo %} - {{ product.name }} - {% else %} - Sin imagen - {% endif %} -
-
-

{{ product.name }}

-
{{ product.brand }}
-
€ {{ product.price }}
- {% if product.get_categoria_display %} - {{ product.get_categoria_display }} - {% endif %} -
- Stock: {{ product.stock }} unidades -
-
-
- - -
- Descripción: -

{{ product.description }}

+
+
+
+ {% if product.photo %} + {{ product.name }} + {% else %} + Foto de producto por defecto + {% endif %} +
+
+

{{ product.name }}

+
{{ product.brand }}
+
€ {{ product.price }}
+ {% if product.get_categoria_display %} + {{ product.get_categoria_display }} + {% endif %} +
+ Stock: {{ product.stock }} unidades
+
+
+
+ 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 - - - + +
-
- - -
- -
- {% if product.photo %} - {{ product.name }} - {% else %} - {{ product.name }} - {% endif %} - - -
- -
-

{{ product.name }}

-
{{ product.brand }}
-
€ {{ product.price }}
- - {% if product.get_categoria_display %} - {{ product.get_categoria_display }} - {% endif %} - -
- Stock: {{ product.stock }} unidades -
-
-
- - -
- Descripción: -

{{ product.description }}

+
+ +
+
+ {% if product.photo %} + {{ product.name }} + {% else %} + {{ product.name }} + {% endif %} +
+ +
+

{{ product.name }}

+
{{ product.brand }}
+
€ {{ product.price }}
+ + {% if product.get_categoria_display %} + {{ product.get_categoria_display }} + {% endif %} + +
+ Stock: {{ product.stock }} unidades
+
+
- -
-
-
Estado
-
- {% if product.is_active %} - ✓ Activo - {% else %} - ✗ Inactivo - {% endif %} -
-
-
-
ID Producto
-
#{{ product.id }}
-
-
- - -
- - - + +
+ Descripción: +

{{ product.description }}

+
- ← Volver + +
+
+
Estado
+
+ {% if product.is_active %} + ✓ Activo + {% else %} + ✗ Inactivo + {% endif %}
+
+
+
ID Producto
+
#{{ product.id }}
+
+
+ +
+ + + + ← 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
- - +
+ i +
ESSENZA
+ + +
-
- +
+ - + +
-

🌸 Productos Essenza

- - +

🌸 Productos Essenza

+ - {% if products %} -
- {% for product in products %} -
-
- {% if product.photo %} - {{ product.name }} - {% else %} - Sin imagen - {% endif %} -
-
-
{{ product.name }}
-
{{ product.brand }}
-
€ {{ product.price }}
-
Stock: {{ product.stock }}
-
- Ver - Editar - Borrar -
-
-
- {% endfor %} + {% if products %} +
+ {% for product in products %} +
+
+ {% if product.photo %} + {{ product.name }} + {% else %} + Sin imagen + {% endif %} +
+
+
{{ product.name }}
+
{{ product.brand }}
+
€ {{ product.price }}
+
Stock: {{ product.stock }}
+
+ Ver + Editar + Borrar
- {% else %} -
-

No hay productos disponibles. ¡Crea uno!

-
- {% 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);