diff --git a/essenza/cart/models.py b/essenza/cart/models.py index d40562d..d9c7ba1 100644 --- a/essenza/cart/models.py +++ b/essenza/cart/models.py @@ -1,3 +1,5 @@ +from decimal import Decimal + from django.db import models @@ -7,11 +9,19 @@ class Cart(models.Model): ) @property - def total_price(self): - total = 0 + def shipping(self): + return Decimal(4.99 if self.subtotal < 100 else 0) + + @property + def subtotal(self): + subtotal = 0 for product in self.cart_products.all(): - total += product.subtotal - return total + subtotal += product.subtotal + return Decimal(subtotal) + + @property + def total(self): + return self.subtotal + self.shipping def __str__(self): return f"Cart {self.id} by {self.user.email}" diff --git a/essenza/cart/tests.py b/essenza/cart/tests.py index ecff2dd..941581e 100644 --- a/essenza/cart/tests.py +++ b/essenza/cart/tests.py @@ -5,7 +5,6 @@ from cart.models import Cart, CartProduct -# Usamos get_user_model() porque usas un usuario personalizado (user.Usuario) User = get_user_model() @@ -23,7 +22,6 @@ def setUp(self): ) # 2. Crear Producto - # Usamos las choices reales de tu modelo self.product = Product.objects.create( name="Producto Test", description="Descripción de prueba", @@ -34,44 +32,38 @@ def setUp(self): is_active=True, ) - # 3. URLs (Sin namespace 'order' según tu urls.py actual) + # 3. URLs self.url_detail = reverse("cart_detail") self.url_add = reverse("add_to_cart", args=[self.product.pk]) self.url_update = reverse("update_cart_item", args=[self.product.pk]) self.url_remove = reverse("remove_from_cart", args=[self.product.pk]) + # URL ficticia para simular el "referer" (la página anterior) + self.url_catalog = reverse("catalog") + # --------------------------------------------------------- # BLOQUE 1: DETALLE DEL CARRITO (GET) # --------------------------------------------------------- def test_cart_detail_authenticated_empty(self): - """Usuario logueado sin carrito previo. Debe cargar vacío sin fallar.""" self.client.force_login(self.user) response = self.client.get(self.url_detail) self.assertEqual(response.status_code, 200) - # Tu vista pasa 'cart_products' vacío si falla el try/except o no hay carrito self.assertEqual(len(response.context.get("cart_products", [])), 0) def test_cart_detail_authenticated_with_items(self): - """Usuario logueado con carrito en DB.""" self.client.force_login(self.user) - - # Setup DB cart = Cart.objects.create(user=self.user) CartProduct.objects.create(cart=cart, product=self.product, quantity=2) response = self.client.get(self.url_detail) self.assertEqual(response.status_code, 200) - # Verifica que lee de la DB self.assertEqual(len(response.context["cart_products"]), 1) - self.assertEqual(response.context["total_price"], 20.00) # 2 * 10.00 + self.assertEqual(response.context["subtotal"], 20.00) def test_cart_detail_anonymous_session(self): - """Usuario anónimo con datos en sesión.""" self.client.logout() - - # Inyectar sesión session = self.client.session session["cart_session"] = { str(self.product.pk): {"quantity": 3, "price": "10.00"} @@ -81,33 +73,64 @@ def test_cart_detail_anonymous_session(self): response = self.client.get(self.url_detail) self.assertEqual(response.status_code, 200) - # Tu vista pasa 'cart_products' también para anónimos (lo vi en tu código) self.assertEqual(len(response.context["cart_products"]), 1) - self.assertEqual(response.context["total_price"], 30.00) # 3 * 10.00 + self.assertEqual(response.context["subtotal"], 30.00) # --------------------------------------------------------- - # BLOQUE 2: AÑADIR AL CARRITO (POST) + # BLOQUE 2: AÑADIR AL CARRITO (POST) - ACTUALIZADO # --------------------------------------------------------- - def test_add_item_authenticated(self): - """Añadir ítem crea Cart y CartProduct en DB.""" + def test_add_item_action_buy_redirects_to_cart(self): + """ + Prueba el flujo 'Comprar ahora' (action='buy'). + Debe añadir el producto y redirigir al carrito. + """ self.client.force_login(self.user) - response = self.client.post(self.url_add, {"quantity": 1}) - response = self.client.post(self.url_add, {"quantity": 3}) + # Enviamos action='buy' explícitamente + response = self.client.post(self.url_add, {"quantity": 1, "action": "buy"}) + # Debe redirigir a cart_detail self.assertRedirects(response, self.url_detail) # Verificar DB cart = Cart.objects.get(user=self.user) cp = CartProduct.objects.get(cart=cart, product=self.product) - self.assertEqual(cp.quantity, 4) + self.assertEqual(cp.quantity, 1) + + def test_add_item_action_add_stays_on_page(self): + """ + Prueba el flujo 'Añadir al carrito' (action='add'). + Debe añadir el producto y redirigir a la página anterior (Referer). + """ + self.client.force_login(self.user) + + # Simulamos que venimos del catálogo + referer = self.url_catalog + + # Enviamos action='add' y el HTTP_REFERER + response = self.client.post( + self.url_add, {"quantity": 2, "action": "add"}, HTTP_REFERER=referer + ) + + # Debe redirigir DE VUELTA al catálogo, no al carrito + self.assertRedirects(response, referer) + + # Verificar DB + cart = Cart.objects.get(user=self.user) + cp = CartProduct.objects.get(cart=cart, product=self.product) + self.assertEqual(cp.quantity, 2) + + # Verificar mensajes (feedback visual) + messages = list(response.wsgi_request._messages) + self.assertEqual(len(messages), 1) + self.assertIn("añadido al carrito", str(messages[0])) def test_add_item_anonymous(self): - """Añadir ítem guarda en Sesión.""" + """Añadir ítem guarda en Sesión (usando 'buy' para verificar redirect clásico).""" self.client.logout() - response = self.client.post(self.url_add, {"quantity": 1}) + response = self.client.post(self.url_add, {"quantity": 1, "action": "buy"}) self.assertRedirects(response, self.url_detail) @@ -122,24 +145,29 @@ def test_add_item_out_of_stock(self): self.client.force_login(self.user) - # Debería redirigir al catálogo (o donde definas 'catalog') y mostrar error - # Como no sé tu URL 'catalog', verificamos que NO se creó el CartProduct + # Simulamos venir del catálogo + referer = self.url_catalog + response = self.client.post(self.url_add, {"quantity": 1}, HTTP_REFERER=referer) + + # Debe redirigir atrás con error + self.assertRedirects(response, referer) + + # Verificar mensaje de error + messages = list(response.wsgi_request._messages) + self.assertIn("agotado", str(messages[0]).lower()) + + # Verificar que NO se creó nada en DB self.assertFalse(CartProduct.objects.filter(product=self.product).exists()) # --------------------------------------------------------- - # BLOQUE 3: ACTUALIZAR (POST) - AQUI ESTÁ EL PELIGRO + # BLOQUE 3: ACTUALIZAR (POST) # --------------------------------------------------------- def test_auth_update_item(self): - """ - Verifica update para logueados. - NOTA: Este test está 'trucado' para que pase con tu bug actual. - """ self.client.force_login(self.user) cart = Cart.objects.create(user=self.user) # TRUCO: Forzamos que el ID del CartProduct sea igual al del Product - # para que tu vista rota (pk=product_id) lo encuentre. cp = CartProduct( id=self.product.pk, cart=cart, product=self.product, quantity=1 ) @@ -152,10 +180,9 @@ def test_auth_update_item(self): self.assertEqual(cp.quantity, 5) def test_anon_update_item(self): - """Verifica update para anónimos (Sesión).""" self.client.logout() - # Añadimos primero - self.client.post(self.url_add, {"quantity": 1}) + # Añadimos primero (action buy para ir al carrito) + self.client.post(self.url_add, {"quantity": 1, "action": "buy"}) # Actualizamos response = self.client.post(self.url_update, {"quantity": 4}) @@ -169,10 +196,6 @@ def test_anon_update_item(self): # --------------------------------------------------------- def test_auth_remove_item(self): - """ - Verifica borrado para logueados. - NOTA: También trucado por tu bug. - """ self.client.force_login(self.user) cart = Cart.objects.create(user=self.user) @@ -188,7 +211,6 @@ def test_auth_remove_item(self): self.assertFalse(CartProduct.objects.filter(pk=cp.pk).exists()) def test_anon_remove_item(self): - """Verifica borrado para anónimos.""" self.client.logout() # Setup sesión session = self.client.session diff --git a/essenza/cart/views.py b/essenza/cart/views.py index 2cbc086..a90d936 100644 --- a/essenza/cart/views.py +++ b/essenza/cart/views.py @@ -1,3 +1,7 @@ +from decimal import Decimal + +from django.contrib import messages +from django.contrib.auth.mixins import UserPassesTestMixin from django.shortcuts import get_object_or_404, redirect, render from django.views import View from product.models import Product @@ -5,17 +9,25 @@ from .models import Cart, CartProduct -class CartDetailView(View): +class CartDetailView(UserPassesTestMixin, View): """ - Muestra el carrito. + Muestra el carrito (si no eres admin). - Si es usuario logueado: Lee de la base de datos - Si es anónimo: Lee de la sesión """ + def test_func(self): + return ( + not self.request.user.is_authenticated or self.request.user.role == "user" + ) + + def handle_no_permission(self): + return redirect("stock") + template_name = "cart/cart_detail.html" def get(self, request): - context = {"cart_products": [], "total_price": 0} + context = {"cart_products": [], "shipping": 0, "subtotal": 0, "total": 0} # Si esta logueado if request.user.is_authenticated: @@ -24,7 +36,9 @@ def get(self, request): cart = get_object_or_404(Cart, user=request.user) # Cogemos los datos del carrito desde la base de datos context["cart_products"] = cart.cart_products.all() - context["total_price"] = cart.total_price + context["shipping"] = cart.shipping + context["subtotal"] = cart.subtotal + context["total"] = cart.total context["cart"] = cart except Exception: pass @@ -33,7 +47,7 @@ def get(self, request): else: cart_session = request.session.get("cart_session", {}) cart_products = [] - total_price = 0 + subtotal = 0 if cart_session: # Obtenemos los productos @@ -43,35 +57,51 @@ def get(self, request): # Construimos los items del carrito for product in products: quantity = cart_session[str(product.pk)]["quantity"] - subtotal = quantity * product.price + product_subtotal = quantity * product.price # Añadimos al listado de items del carrito la info necesaria cart_products.append( { "product": product, "quantity": quantity, - "subtotal": subtotal, + "subtotal": product_subtotal, "pk": product.pk, } ) - total_price += subtotal + subtotal += product_subtotal context["cart_products"] = cart_products - context["total_price"] = total_price + context["subtotal"] = subtotal + context["shipping"] = Decimal(4.99 if subtotal < 100 else 0) + context["total"] = subtotal + context["shipping"] return render(request, self.template_name, context) -class AddToCartView(View): +class AddToCartView(UserPassesTestMixin, View): """ - Añade productos al carrito (DB o Sesión). + Añade productos al carrito. + - Acción 'add': Se queda en la página y muestra mensaje. + - Acción 'buy': Redirige al carrito. """ + def test_func(self): + return ( + not self.request.user.is_authenticated or self.request.user.role == "user" + ) + + def handle_no_permission(self): + return redirect("stock") + def post(self, request, product_id): product = get_object_or_404(Product, pk=product_id) + # Guardar la URL anterior para volver si es "Añadir" + next_url = request.META.get("HTTP_REFERER", "catalog") + if product.stock <= 0: - return redirect("catalog") + messages.error(request, "Este producto está agotado.") + return redirect(next_url) try: quantity = int(request.POST.get("quantity", 1)) @@ -80,36 +110,40 @@ def post(self, request, product_id): except ValueError: quantity = 1 - # Si el usuario está logueado + # --- LÓGICA DE AÑADIR (unificada) --- if request.user.is_authenticated: - cart, create = Cart.objects.get_or_create(user=request.user) + cart, _ = Cart.objects.get_or_create(user=request.user) + # Usamos get_or_create con defaults para evitar condiciones de carrera simples + cart_product, created = CartProduct.objects.get_or_create( + cart=cart, product=product, defaults={"quantity": 0} + ) - if cart: - cart_product, created = CartProduct.objects.get_or_create( - cart=cart, product=product, defaults={"quantity": quantity} - ) + # Si se acaba de crear, quantity es 0 (por el default), si ya existía tiene X + # Así que simplemente sumamos la cantidad nueva. + if created: + cart_product.quantity = quantity else: - cart_product, created = CartProduct.objects.get_or_create( - cart=create, product=product, defaults={"quantity": quantity} + cart_product.quantity += quantity + + # Validación Stock + if cart_product.quantity > product.stock: + cart_product.quantity = product.stock + messages.warning( + request, f"Has alcanzado el límite de stock ({product.stock})." ) - if not created: - if cart_product.quantity + quantity > product.stock: - cart_product.quantity = product.stock - return redirect("cart_detail") - else: - cart_product.quantity += quantity - cart_product.save() + cart_product.save() - # Si el usuario no está logueado, guardamos en sesión else: + # Lógica de Sesión cart_session = request.session.get("cart_session", {}) product_id_str = str(product_id) if product_id_str in cart_session: - if cart_session[product_id_str]["quantity"] + quantity > product.stock: + current_qty = cart_session[product_id_str]["quantity"] + if current_qty + quantity > product.stock: cart_session[product_id_str]["quantity"] = product.stock - return redirect("cart_detail") + messages.warning(request, "Has alcanzado el límite de stock.") else: cart_session[product_id_str]["quantity"] += quantity else: @@ -121,14 +155,31 @@ def post(self, request, product_id): request.session["cart_session"] = cart_session request.session.modified = True - return redirect("cart_detail") + # --- AQUÍ ESTÁ LA MAGIA DE LA REDIRECCIÓN --- + action = request.POST.get("action", "add") # 'add' o 'buy' + + if action == "buy": + # Si quiere comprar ya, lo llevamos al carrito + return redirect("cart_detail") + else: + # Si solo añade, le damos feedback y lo dejamos donde estaba + messages.success(request, f"¡{product.name} añadido al carrito!") + return redirect(next_url) -class RemoveFromCartView(View): +class RemoveFromCartView(UserPassesTestMixin, View): """ Elimina productos del carrito. """ + def test_func(self): + return ( + not self.request.user.is_authenticated or self.request.user.role == "user" + ) + + def handle_no_permission(self): + return redirect("stock") + def post(self, request, product_id): # Si el usuario está logueado if request.user.is_authenticated: @@ -156,11 +207,19 @@ def post(self, request, product_id): return redirect("cart_detail") -class UpdateCartItemView(View): +class UpdateCartItemView(UserPassesTestMixin, View): """ Actualiza la cantidad de un producto. """ + def test_func(self): + return ( + not self.request.user.is_authenticated or self.request.user.role == "user" + ) + + def handle_no_permission(self): + return redirect("stock") + def post(self, request, product_id): try: new_quantity = int(request.POST.get("quantity", 1)) diff --git a/essenza/order/models.py b/essenza/order/models.py index 44dc09f..8e62d10 100644 --- a/essenza/order/models.py +++ b/essenza/order/models.py @@ -1,5 +1,6 @@ import random import string +from decimal import Decimal from django.contrib.auth import get_user_model from django.db import models @@ -25,20 +26,28 @@ class Order(models.Model): address = models.CharField(max_length=255) placed_at = models.DateTimeField(default=timezone.now) status = models.CharField(choices=Status.choices, default=Status.EN_PREPARACION) - tracking_code = models.CharField( max_length=8, unique=True, editable=False, # No se puede editar manualmente verbose_name="Localizador", ) + is_paid = models.BooleanField(default=False) + + @property + def shipping(self): + return Decimal(4.99 if self.subtotal < 100 else 0) @property - def total_price(self): - total = 0 + def subtotal(self): + subtotal = 0 for product in self.order_products.all(): - total += product.subtotal - return total + subtotal += product.subtotal + return Decimal(subtotal) + + @property + def total(self): + return self.subtotal + self.shipping def save(self, *args, **kwargs): """ diff --git a/essenza/order/sample/sample.json b/essenza/order/sample/sample.json index 690bb1b..6d5667e 100644 --- a/essenza/order/sample/sample.json +++ b/essenza/order/sample/sample.json @@ -8,7 +8,8 @@ "address": "Calle Gran Vía, 23, Madrid, 28013", "placed_at": "2025-11-12T10:30:00Z", "status": "en_preparacion", - "tracking_code": "3MRRCY5O" + "tracking_code": "3MRRCY5O", + "is_paid": false } }, { @@ -19,7 +20,8 @@ "address": "Avenida de la Constitución, 8, Sevilla, 41001", "placed_at": "2025-11-11T15:10:00Z", "status": "enviado", - "tracking_code": "FPXUIJS7" + "tracking_code": "FPXUIJS7", + "is_paid": true } }, { @@ -30,7 +32,8 @@ "address": "Carrer de Pau Claris, 60, Barcelona, 08010", "placed_at": "2025-11-10T19:25:00Z", "status": "entregado", - "tracking_code": "HQYBE6JH" + "tracking_code": "HQYBE6JH", + "is_paid": true } }, { @@ -42,7 +45,8 @@ "address": "Calle Alcalá, 120, Madrid, 28009", "placed_at": "2025-11-09T09:00:00Z", "status": "en_preparacion", - "tracking_code": "Y60601X2" + "tracking_code": "Y60601X2", + "is_paid": true } }, { @@ -54,7 +58,8 @@ "address": "Plaza Nueva, 10, Bilbao, 48001", "placed_at": "2025-11-08T12:15:00Z", "status": "entregado", - "tracking_code": "XUL4SC0R" + "tracking_code": "XUL4SC0R", + "is_paid": true } }, { @@ -66,7 +71,8 @@ "address": "Calle Larios, 5, Málaga, 29001", "placed_at": "2025-11-07T14:00:00Z", "status": "enviado", - "tracking_code": "L7WRQHVK" + "tracking_code": "L7WRQHVK", + "is_paid": false } }, { @@ -78,7 +84,8 @@ "address": "Paseo de Gracia, 92, Barcelona, 08008", "placed_at": "2025-11-06T18:45:00Z", "status": "en_preparacion", - "tracking_code": "YZPOHNT8" + "tracking_code": "YZPOHNT8", + "is_paid": false } }, { @@ -90,7 +97,8 @@ "address": "Calle de la Paz, 1, Valencia, 46003", "placed_at": "2025-11-06T10:00:00Z", "status": "entregado", - "tracking_code": "RZJC560Y" + "tracking_code": "RZJC560Y", + "is_paid": true } }, { @@ -101,7 +109,8 @@ "address": "Calle Mayor, 30, Zaragoza, 50001", "placed_at": "2025-10-15T11:00:00Z", "status": "entregado", - "tracking_code": "UT0A32II" + "tracking_code": "UT0A32II", + "is_paid": true } }, { @@ -113,7 +122,8 @@ "address": "Rúa do Vilar, 50, Santiago de Compostela, 15705", "placed_at": "2025-10-28T08:30:00Z", "status": "enviado", - "tracking_code": "WW0XHB4C" + "tracking_code": "WW0XHB4C", + "is_paid": false } }, { diff --git a/essenza/order/tests.py b/essenza/order/tests.py index 62102e8..f9f930f 100644 --- a/essenza/order/tests.py +++ b/essenza/order/tests.py @@ -1,213 +1,333 @@ +from unittest.mock import MagicMock, patch + +from cart.models import Cart, CartProduct from django.contrib.auth import get_user_model from django.test import Client, TestCase from django.urls import reverse from product.models import Category, Product -from order.models import Order, OrderProduct, Status +from order.models import Order User = get_user_model() -# ============================================================ -# TESTS: LISTADO DE PEDIDOS DEL USUARIO -# ============================================================ - - -class OrderListUserViewTests(TestCase): - @classmethod - def setUpTestData(self): +class CheckoutFlowTests(TestCase): + def setUp(self): self.client = Client() - # Creamos usuario + # 1. Usuario de prueba (Cliente) self.user = User.objects.create_user( - username="user1", email="user@test.com", password="1234" + username="testuser@example.com", + email="testuser@example.com", + password="password123", + first_name="Test", + last_name="User", + role="user", ) - # Asignamos rol manualmente por seguridad - self.user.role = "user" - self.user.save() - self.other_user = User.objects.create_user( - username="user2", email="user2@test.com", password="1234" + # 2. Usuario Admin (para pruebas de permisos) + self.admin_user = User.objects.create_user( + username="admin@example.com", + email="admin@example.com", + password="password123", + role="admin", ) - self.other_user.role = "user" - self.other_user.save() + # 3. Producto con stock controlado (10 unidades) self.product = Product.objects.create( - name="Producto A", - price="10.00", + name="Producto Test", + description="Desc", + category=Category.MAQUILLAJE, + brand="Brand", + price=50.00, stock=10, is_active=True, - category=Category.MAQUILLAJE, - brand="Marca A", ) - # 1. Pedido visible del usuario (ENVIADO) - self.order_user = Order.objects.create( - user=self.user, - email=self.user.email, # Importante: ahora el modelo usa email - status=Status.ENVIADO, - address="Calle 1", - ) - OrderProduct.objects.create( - order=self.order_user, product=self.product, quantity=2 - ) + # 4. URLs + self.checkout_url = reverse("create_checkout") + self.success_url = reverse("successful_payment") + + def _create_cart_for_user(self, quantity=1): + """Helper para crear un carrito rápido""" + cart = Cart.objects.create(user=self.user) + CartProduct.objects.create(cart=cart, product=self.product, quantity=quantity) + return cart + + # ========================================== + # BLOQUE 1: CONTRARREMBOLSO (COD) + # ========================================== + def test_cod_checkout_success(self): + """ + Prueba el flujo completo de pago en EFECTIVO. + Debe: Crear orden, Restar stock, is_paid=False, Guardar dirección manual. + """ + self.client.force_login(self.user) + self._create_cart_for_user(quantity=2) # Compramos 2 (Total 100€) + + # Simulamos el envío del formulario + data = { + "payment_method": "cod", + "shipping_name": "Juan Pérez", + "shipping_email": "juan@test.com", + "shipping_address": "Calle Falsa 123", + "shipping_city": "Madrid", + "shipping_zip": "28001", + } - # 2. Pedido del usuario OCULTO (EN_PREPARACION - según la lógica de tu vista) - self.order_hidden = Order.objects.create( - user=self.user, - email=self.user.email, - status=Status.EN_PREPARACION, - address="Calle Oculta", - ) - OrderProduct.objects.create( - order=self.order_hidden, product=self.product, quantity=1 - ) + response = self.client.post(self.checkout_url, data) - # 3. Pedido de otro usuario (No debe verse) - self.order_other = Order.objects.create( - user=self.other_user, - email=self.other_user.email, - status=Status.ENVIADO, - address="Otra calle", - ) - OrderProduct.objects.create( - order=self.order_other, product=self.product, quantity=1 - ) + # 1. Debe renderizar la página de éxito (status 200) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "order/success.html") - # Asumiendo que la URL se llama 'order_history' en urls.py - try: - self.url = reverse("order_history") - except Exception: - self.url = "/order/history/" # Fallback si no existe el name + # 2. Verificar la Orden en BD + order = Order.objects.last() + self.assertIsNotNone(order) - def test_user_must_login(self): - """Un usuario anónimo debe ser redirigido al login.""" - resp = self.client.get(self.url) - self.assertEqual(resp.status_code, 302) - self.assertTrue("login" in resp.url) + # [VERDAD FINANCIERA] No pagado + self.assertFalse(order.is_paid) + # [VERDAD DE DATOS] La dirección se concatenó correctamente + expected_address = "Calle Falsa 123, Madrid (28001)" + self.assertEqual(order.address, expected_address) + self.assertEqual(order.email, "juan@test.com") -# ============================================================ -# TESTS: LISTADO DE PEDIDOS DEL ADMIN -# ============================================================ + # [VERDAD LOGÍSTICA] Stock restado (10 - 2 = 8) + self.product.refresh_from_db() + self.assertEqual(self.product.stock, 8) + # 3. Carrito borrado + self.assertFalse(Cart.objects.filter(user=self.user).exists()) -class OrderListAdminViewTests(TestCase): - @classmethod - def setUpTestData(self): - self.client = Client() + def test_cod_checkout_fails_missing_data(self): + """Si el usuario intenta trucar el form y enviar vacío, debe fallar.""" + self.client.force_login(self.user) + self._create_cart_for_user() - # Admin - self.admin = User.objects.create_user( - username="admin1", email="admin@test.com", password="1234" - ) - self.admin.role = "admin" - self.admin.save() + data = { + "payment_method": "cod", + # Falta shipping_address, name, etc. + "shipping_email": "hack@test.com", + } - # User normal - self.user = User.objects.create_user( - username="user3", email="user3@test.com", password="1234" - ) - self.user.role = "user" - self.user.save() + response = self.client.post(self.checkout_url, data) + + # Tu vista devuelve HttpResponse con status 400 (Bad Request) + self.assertEqual(response.status_code, 400) + self.assertEqual(Order.objects.count(), 0) # No se creó nada + + # ========================================== + # BLOQUE 2: STRIPE (MOCKED) + # ========================================== + @patch("stripe.checkout.Session.create") + def test_stripe_checkout_redirects_to_gateway(self, mock_stripe_create): + """ + Verifica que si elijo tarjeta, el backend llama a Stripe y me manda a su URL. + """ + self.client.force_login(self.user) + self._create_cart_for_user(quantity=1) + + # Mock de la respuesta de Stripe + mock_stripe_create.return_value.url = "https://checkout.stripe.com/fake-session" + + data = {"payment_method": "stripe"} + + response = self.client.post(self.checkout_url, data) + + # Verificar llamada a Stripe API + mock_stripe_create.assert_called_once() + + # Verificar redirección + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, "https://checkout.stripe.com/fake-session") + + @patch("stripe.checkout.Session.retrieve") + def test_stripe_success_callback_creates_order(self, mock_stripe_retrieve): + """ + Simula que el usuario vuelve de Stripe habiendo pagado. + Debe: Crear orden, is_paid=True, Usar dirección de Stripe. + """ + self.client.force_login(self.user) + self._create_cart_for_user(quantity=3) # Compramos 3 + + # Mock complejo: Simulamos el objeto Session de Stripe + mock_session = MagicMock() + mock_session.payment_status = "paid" + mock_session.customer_details.email = "stripe_customer@test.com" + + # Stripe devuelve la dirección en un objeto anidado + mock_session.customer_details.address.line1 = "Calle Stripe" + mock_session.customer_details.address.city = "Internet" + mock_session.customer_details.address.postal_code = "00000" + + mock_stripe_retrieve.return_value = mock_session + + # Simulamos la llamada a la URL de retorno + url = self.success_url + "?session_id=cs_test_fake123" + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + + # Verificaciones + order = Order.objects.last() + self.assertIsNotNone(order) + + # [VERDAD FINANCIERA] Pagado + self.assertTrue(order.is_paid) + + # [VERDAD LOGÍSTICA] Stock (10 - 3 = 7) + self.product.refresh_from_db() + self.assertEqual(self.product.stock, 7) + + # Dirección viene de Stripe + self.assertIn("Calle Stripe", order.address) + + @patch("stripe.checkout.Session.retrieve") + def test_stripe_payment_failed_or_unpaid(self, mock_stripe_retrieve): + """ + Si Stripe dice que NO está pagado (ej. tarjeta rechazada), + no debemos marcar la orden como pagada o no crearla. + """ + mock_session = MagicMock() + mock_session.payment_status = "unpaid" # <--- CASO FALLIDO + mock_stripe_retrieve.return_value = mock_session + + url = self.success_url + "?session_id=cs_fail_123" + response = self.client.get(url) + + # [CORREGIDO] Verificamos que la vista respondió (aunque sea success o error) + # Lo importante es lo que pasa en la base de datos + self.assertEqual(response.status_code, 200) + + # Verificamos si se creó orden + # Si tu lógica crea orden incluso si falla (lo cual es raro), debe ser is_paid=False + order = Order.objects.filter(email="stripe@user.com").first() + if order: + self.assertFalse( + order.is_paid, "La orden no debería estar pagada si Stripe falló" + ) + + def test_stripe_callback_without_session_id(self): + """Intentar acceder a la URL de éxito sin session_id debe fallar.""" + response = self.client.get(self.success_url) + # Tu vista devuelve HttpResponse("Error...") que es un 200 OK con texto + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Error") + + # ========================================== + # BLOQUE 3: USUARIO ANÓNIMO (GUEST) + # ========================================== + def test_guest_checkout_cod(self): + """ + Prueba que un usuario NO logueado puede comprar usando la sesión. + """ + # NO hacemos login + + # 1. Añadir al carrito (guarda en sesión) + session = self.client.session + session["cart_session"] = { + str(self.product.pk): {"quantity": 1, "price": "50.00"} + } + session.save() - self.product = Product.objects.create( - name="Prod", - price="5.00", - stock=10, - is_active=True, - category=Category.PERFUME, - brand="Brand", - ) + # 2. Pagar Contrarrembolso + data = { + "payment_method": "cod", + "shipping_name": "Invitado", + "shipping_email": "guest@test.com", + "shipping_address": "Hotel", + "shipping_city": "Bcn", + "shipping_zip": "08001", + } - # Creamos un pedido para probar - self.order = Order.objects.create( - user=self.user, - email="cliente@test.com", - status=Status.ENVIADO, - address="Dir Admin Test", + response = self.client.post(self.checkout_url, data) + + self.assertEqual(response.status_code, 200) + + # Verificar Orden + order = Order.objects.last() + self.assertIsNone(order.user) # No hay usuario asociado + self.assertEqual(order.email, "guest@test.com") + self.assertFalse(order.is_paid) + + # Verificar Stock + self.product.refresh_from_db() + self.assertEqual(self.product.stock, 9) + + # ========================================== + # BLOQUE 4: SEGURIDAD Y PERMISOS + # ========================================== + def test_admin_view_permission(self): + """Un usuario normal NO debe poder ver el listado global de pedidos.""" + self.client.force_login(self.user) # Usuario normal + response = self.client.get(reverse("order_list_admin")) + + # Tu handle_no_permission redirige al dashboard (302) + self.assertEqual(response.status_code, 302) + self.assertIn(reverse("dashboard"), response.url) + + def test_update_status_permission(self): + """Un usuario normal NO puede cambiar el estado de un pedido.""" + self.client.force_login(self.user) + # Creamos una orden dummy + order = Order.objects.create(email="test@test.com", address="X") + + url = reverse("order_update_status", args=[order.tracking_code]) + response = self.client.post(url, {"status": "enviado"}) + + # Redirige al dashboard por falta de permisos + self.assertEqual(response.status_code, 302) + + # El estado NO debe cambiar + order.refresh_from_db() + self.assertNotEqual(order.status, "enviado") + + # ========================================== + # BLOQUE 5: HISTORIAL Y TRACKING + # ========================================== + def test_order_search_found(self): + """El buscador público debe encontrar un pedido por código y email.""" + order = Order.objects.create(email="search@me.com", address="Home") + + data = {"tracking_code": order.tracking_code, "email": "search@me.com"} + response = self.client.post(reverse("order_search"), data) + + # Debe redirigir al tracking detallado + self.assertRedirects( + response, reverse("order_tracking", args=[order.tracking_code]) ) - OrderProduct.objects.create(order=self.order, product=self.product, quantity=1) - - try: - self.url = reverse("order_list_admin") - except Exception: - self.url = "/order/list/" - - def test_anonymous_redirects_to_login(self): - resp = self.client.get(self.url) - self.assertEqual(resp.status_code, 302) - self.assertTrue("login" in resp.url) - def test_admin_can_view_orders(self): - self.client.login(email="admin@test.com", password="1234") - resp = self.client.get(self.url) + def test_order_search_not_found_wrong_email(self): + """Si el código es correcto pero el email no, no debe mostrar nada (Privacidad).""" + order = Order.objects.create(email="real@me.com", address="Home") - self.assertEqual(resp.status_code, 200) - self.assertTemplateUsed(resp, "order/order_list_admin.html") - self.assertContains(resp, "Prod") - self.assertContains( - resp, self.order.tracking_code - ) # Debe salir el tracking code + data = { + "tracking_code": order.tracking_code, + "email": "hacker@evil.com", # Email incorrecto + } + response = self.client.post(reverse("order_search"), data) + self.assertEqual(response.status_code, 200) # Se queda en la misma página + self.assertContains(response, "No se ha encontrado") # Mensaje de error -class OrderTrackViewTests(TestCase): - @classmethod - def setUpTestData(self): - self.client = Client() + def test_order_history_isolation(self): + """Un usuario solo debe ver SUS pedidos, no los de otros.""" + self.client.force_login(self.user) - self.product = Product.objects.create( - name="Producto Track", - price="12.00", - stock=5, - is_active=True, - category=Category.CABELLO, - brand="Marca Track", + # Pedido propio + my_order = Order.objects.create( + user=self.user, email=self.user.email, address="Mine" ) - - # Creamos un pedido sin usuario (invitado) para probar el tracking público - self.order = Order.objects.create( - user=None, - email="track@test.com", - status=Status.ENVIADO, - address="Direccion de prueba", + # Pedido ajeno + other_user = User.objects.create(username="other", email="other@test.com") + other_order = Order.objects.create( + user=other_user, email="other@test.com", address="Theirs" ) - OrderProduct.objects.create(order=self.order, product=self.product, quantity=1) - - # URL de la vista de búsqueda (donde está el formulario) - # Asumiendo que en urls.py se llama 'order_search' - try: - self.url_search = reverse("order_search") - except Exception: - self.url_search = "/order/search/" - - def test_track_get_returns_form(self): - """GET debe mostrar el formulario vacío.""" - resp = self.client.get(self.url_search) - self.assertEqual(resp.status_code, 200) - self.assertTemplateUsed(resp, "order/order_search.html") - self.assertFalse(resp.context["searched"]) - - def test_track_post_valid_redirects_to_detail(self): - """POST correcto debe REDIRIGIR a la vista de detalle.""" - # Usamos los nombres de campo exactos de tu HTML: 'tracking_code' y 'email' - data = {"tracking_code": self.order.tracking_code, "email": "track@test.com"} - resp = self.client.post(self.url_search, data) - - # Tu vista hace: return redirect("order_tracking", ...) -> Código 302 - self.assertEqual(resp.status_code, 302) - - # Verificamos que redirige a la URL con el tracking code - # Asumiendo que la url de detalle es /order/track// - expected_url = reverse("order_tracking", args=[self.order.tracking_code]) - self.assertRedirects(resp, expected_url) - - def test_track_post_invalid_shows_error(self): - """POST con datos incorrectos muestra error en la misma página.""" - data = { - "tracking_code": "wrong", # Código falso - "email": "track@test.com", - } - resp = self.client.post(self.url_search, data) - self.assertEqual(resp.status_code, 200) - self.assertTrue(resp.context["searched"]) # Indica que se intentó buscar + + response = self.client.get(reverse("order_history")) + + # Debe contener mi pedido + self.assertContains(response, my_order.tracking_code) + # NO debe contener el pedido ajeno + self.assertNotContains(response, other_order.tracking_code) diff --git a/essenza/order/views.py b/essenza/order/views.py index 59f6e35..f85b2cd 100644 --- a/essenza/order/views.py +++ b/essenza/order/views.py @@ -4,7 +4,6 @@ from django.contrib import messages from django.contrib.auth import get_user_model from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin -from django.core.exceptions import PermissionDenied from django.core.mail import send_mail from django.db import transaction # Para la integridad de datos from django.db.models import ( @@ -65,9 +64,17 @@ def get(self, request): # ======================================================= # LISTADO DE PEDIDOS - USER # ======================================================= -class OrderHistoryView(LoginRequiredMixin, View): +class OrderHistoryView(LoginRequiredMixin, UserPassesTestMixin, View): template_name = "order/order_history.html" + def test_func(self): + return self.request.user.is_authenticated and self.request.user.role == "user" + + def handle_no_permission(self): + if not self.request.user.is_authenticated: + return redirect("login") + return redirect("stock") + def get(self, request): # CORRECCIÓN 1: Usamos Q para buscar por Usuario O por Email # Esto permite ver pedidos hechos como invitado si el email coincide @@ -90,9 +97,17 @@ def get(self, request): # ======================================================= # BUSQUEDA DE PEDIDO # ======================================================= -class OrderSearchView(View): +class OrderSearchView(UserPassesTestMixin, View): template_name = "order/order_search.html" + def test_func(self): + return ( + not self.request.user.is_authenticated or self.request.user.role == "user" + ) + + def handle_no_permission(self): + return redirect("stock") + def get(self, request): # Solo muestra el formulario vacío return render(request, self.template_name, {"searched": False}) @@ -149,7 +164,7 @@ def get(self, request, tracking_code): # ======================================================= # ACTUALIZAR ESTADO (SOLO ADMIN) # ======================================================= -class OrderUpdateStatusView(LoginRequiredMixin, View): +class OrderUpdateStatusView(LoginRequiredMixin, UserPassesTestMixin, View): """ Permite a un administrador cambiar el estado de un pedido haciendo clic en la barra de progreso. @@ -178,213 +193,260 @@ def post(self, request, tracking_code): return redirect("order_tracking", tracking_code=order.tracking_code) -def create_checkout(request): +def _process_order(request, user, email, address, is_paid): """ - Crea la sesión de pago en Stripe y configura la recolección de dirección. - Restringido: Los administradores NO pueden acceder aquí. + Función auxiliar interna para procesar el pedido. + Se usa tanto para el retorno de Stripe como para Contrarreembolso. """ - if request.user.is_authenticated and getattr(request.user, "role", None) == "admin": - raise PermissionDenied("Los administradores no pueden realizar compras.") - - domain_url = settings.DOMAIN_URL - cart_items_temp = [] - - if request.user.is_authenticated: - cart = get_object_or_404(Cart, user=request.user) - for item in cart.cart_products.all(): - cart_items_temp.append( - { - "product": item.product, - "quantity": item.quantity, - "price": item.product.price, - } - ) - else: + items_to_process = [] + cart_to_delete = None + + # 1. Obtener items (Lógica unificada para Auth/Anon) + if user: # Usuario autenticado + cart = Cart.objects.filter(user=user).first() + if cart: + cart_to_delete = cart + for cart_item in cart.cart_products.select_related("product").all(): + items_to_process.append( + { + "product": cart_item.product, + "quantity": cart_item.quantity, + } + ) + else: # Usuario anónimo (Sesión) cart_session = request.session.get("cart_session", {}) - if not cart_session: - return redirect("cart_detail") - - product_pks = [int(pk) for pk in cart_session.keys()] - products = Product.objects.filter(pk__in=product_pks) - - for product in products: - qty = cart_session[str(product.pk)]["quantity"] - cart_items_temp.append( - {"product": product, "quantity": qty, "price": product.price} - ) - - line_items_stripe = [] - for item in cart_items_temp: - amount_in_cents = int(item["price"] * 100) - line_items_stripe.append( - { - "price_data": { - "currency": "eur", - "unit_amount": amount_in_cents, - "product_data": { - "name": item["product"].name, - "description": item["product"].description[:100] - if item["product"].description - else "Producto Essenza", - }, - }, - "quantity": item["quantity"], - } - ) + if cart_session: + product_pks = [int(pk) for pk in cart_session.keys()] + products = Product.objects.filter(pk__in=product_pks) + for product in products: + qty = cart_session[str(product.pk)]["quantity"] + items_to_process.append({"product": product, "quantity": qty}) + + if not items_to_process: + return None # Carrito vacío o error + + # 2. Buscar usuario registrado para asociar (si existe por email) + User = get_user_model() + user_for_order = user if user else User.objects.filter(email=email).first() + + # 3. Crear el Pedido + new_order = Order.objects.create( + user=user_for_order, + email=email, + address=address, + status=Status.EN_PREPARACION, + is_paid=is_paid, + ) + + # 4. Crear OrderProducts y actualizar Stock + for item_data in items_to_process: + product = item_data["product"] + qty = item_data["quantity"] + + OrderProduct.objects.create(order=new_order, product=product, quantity=qty) + # Actualizamos stock de forma segura + Product.objects.filter(pk=product.pk).update(stock=F("stock") - qty) + + # 5. Borrar el carrito + if cart_to_delete: + cart_to_delete.delete() + else: + request.session["cart_session"] = {} + request.session.modified = True + # 6. Enviar Email (Lógica común) try: - customer_email = request.user.email if request.user.is_authenticated else None - - checkout_session = stripe.checkout.Session.create( - payment_method_types=["card"], - line_items=line_items_stripe, - mode="payment", - shipping_address_collection={ - "allowed_countries": ["ES"], - }, - customer_email=customer_email, - success_url=domain_url + "/order/success/?session_id={CHECKOUT_SESSION_ID}", - cancel_url=domain_url + "/order/cancelled/", + tracking_url = request.build_absolute_uri( + reverse("order_tracking", args=[new_order.tracking_code]) + ) + payment_msg = ( + "Pagado con Tarjeta" if is_paid else "Pendiente de pago (Contrarreembolso)" ) - return redirect(checkout_session.url, code=303) + subject = f"Confirmación de Pedido #{new_order.tracking_code} - Essenza" + message = f""" + Hola! + Gracias por tu compra en Essenza. + + Detalles del pedido: + Localizador: {new_order.tracking_code} + Total: {new_order.total:.2f} € + Estado del pago: {payment_msg} + Dirección: {new_order.address} + + Sigue tu pedido aquí: {tracking_url} + """ + send_mail( + subject, + message, + settings.DEFAULT_FROM_EMAIL, + [new_order.email], + fail_silently=True, + ) except Exception as e: - return HttpResponse(f"Error al conectar con Stripe: {e}") + print(f"Error enviando email: {e}") + return new_order -def successful_payment(request): + +def create_checkout(request): """ - Verifica el pago, crea el pedido y ACTUALIZA EL STOCK. - Usa una transacción atómica para asegurar que todo se guarda o nada. + Maneja el inicio del proceso de pago. """ - session_id = request.GET.get("session_id") + if request.method != "POST": + return redirect("cart_detail") + + payment_method = request.POST.get("payment_method") # 'stripe' o 'cod' + + # --- OPCIÓN A: CONTRARREMBOLSO (COD) --- + if payment_method == "cod": + # 1. Capturamos los datos DEL FORMULARIO HTML + name = request.POST.get("shipping_name") + email_input = request.POST.get("shipping_email") + address = request.POST.get("shipping_address") + city = request.POST.get("shipping_city") + zip_code = request.POST.get("shipping_zip") + + # 2. Validación básica (si falta algo, detenemos todo) + if not (name and email_input and address and city and zip_code): + return HttpResponse( + "Error: Faltan datos de envío obligatorios.", status=400 + ) - if not session_id: - return HttpResponse("Error: No se ha recibido confirmación de pago.") + # 3. Construimos la dirección completa en un solo string + full_address = f"{address}, {city} ({zip_code})" - try: - session = stripe.checkout.Session.retrieve(session_id) - customer_details = session.customer_details - stripe_email = customer_details.email + # 4. Determinamos el usuario y email + user = request.user if request.user.is_authenticated else None - address_data = customer_details.address - shipping_address = f"{address_data.line1}, {address_data.city}, {address_data.postal_code}, {address_data.country}" - if address_data.line2: - shipping_address += f", {address_data.line2}" + # Prioridad: Si el usuario escribió un email en el form, usamos ese. + final_email = email_input if email_input else user.email - if session.payment_status == "paid": - # --- INICIO DE TRANSACCIÓN --- - # Esto asegura que si falla la creación de productos, no se crea el pedido vacío - with transaction.atomic(): - items_to_process = [] - cart_to_delete = None - - # Si esta logueado - if request.user.is_authenticated: - cart = Cart.objects.filter(user=request.user).first() - if cart: - cart_to_delete = cart - for cart_item in cart.cart_products.select_related( - "product" - ).all(): - items_to_process.append( - { - "product": cart_item.product, - "quantity": cart_item.quantity, - } - ) - # Si no esta logueado, usamos la sesion - else: - cart_session = request.session.get("cart_session", {}) - if cart_session: - product_pks = [int(pk) for pk in cart_session.keys()] - products = Product.objects.filter(pk__in=product_pks) - for product in products: - qty = cart_session[str(product.pk)]["quantity"] - items_to_process.append( - {"product": product, "quantity": qty} - ) - - if not items_to_process: - # Si no hay productos, no creamos el pedido. - return HttpResponse( - "Error: No se encontraron productos en el carrito para procesar el pedido." - ) + if not final_email: + return HttpResponse("Error: Se requiere un email válido.", status=400) - # 3. Buscar usuario por email - User = get_user_model() - user_for_order = User.objects.filter(email=stripe_email).first() + # 5. Procesamos el pedido + with transaction.atomic(): + order = _process_order( + request, + user=user, + email=final_email, + address=full_address, + is_paid=False, # COD = No pagado aún + ) - # 4. Crear el Pedido - new_order = Order.objects.create( - user=user_for_order, # Si no existe el usuario, se pone None - status=Status.EN_PREPARACION, - address=shipping_address, - email=stripe_email, + if order: + # IMPORTANTE: Redirigimos pasando el ID del pedido para mostrar éxito + return render(request, "order/success.html", {"order": order}) + else: + return redirect("cart_detail") + + # --- OPCIÓN B: STRIPE --- + elif payment_method == "stripe": + # --- Lógica de Items para Stripe --- + cart_items_temp = [] + if request.user.is_authenticated: + cart = Cart.objects.filter(user=request.user).first() + if not cart: + return redirect("cart_detail") # Seguridad extra + for item in cart.cart_products.all(): + cart_items_temp.append( + { + "product": item.product, + "quantity": item.quantity, + "price": item.product.price, + } + ) + else: + cart_session = request.session.get("cart_session", {}) + if not cart_session: + return redirect("cart_detail") + products = Product.objects.filter( + pk__in=[int(k) for k in cart_session.keys()] + ) + for p in products: + cart_items_temp.append( + { + "product": p, + "quantity": cart_session[str(p.pk)]["quantity"], + "price": p.price, + } ) - # 5. Crear OrderProducts y actualizamos el Stock - for item_data in items_to_process: - product = item_data["product"] - qty = item_data["quantity"] + line_items_stripe = [] + for item in cart_items_temp: + line_items_stripe.append( + { + "price_data": { + "currency": "eur", + "unit_amount": int(item["price"] * 100), + "product_data": {"name": item["product"].name}, + }, + "quantity": item["quantity"], + } + ) - OrderProduct.objects.create( - order=new_order, product=product, quantity=qty - ) + # --- Lógica de Envío --- - Product.objects.filter(pk=product.pk).update(stock=F("stock") - qty) + domain_url = settings.DOMAIN_URL + try: + customer_email = ( + request.user.email if request.user.is_authenticated else None + ) - # 6. Borrar el carrito - if cart_to_delete: - cart_to_delete.delete() - else: - request.session["cart_session"] = {} - request.session.modified = True - # --- ENVÍO DE CORREO DE CONFIRMACIÓN --- - try: - # 1. Generar la URL absoluta de seguimiento - tracking_url = request.build_absolute_uri(reverse("order_search")) + checkout_session = stripe.checkout.Session.create( + payment_method_types=["card"], + line_items=line_items_stripe, + mode="payment", + shipping_address_collection={"allowed_countries": ["ES"]}, + customer_email=customer_email, + success_url=domain_url + + "/order/success/?session_id={CHECKOUT_SESSION_ID}", + cancel_url=domain_url + "/order/cancelled/", + ) + return redirect(checkout_session.url, code=303) - # 2. Definir asunto y mensaje - subject = f"Confirmación de Pedido #{new_order.tracking_code} - Essenza" + except Exception as e: + return HttpResponse(f"Error al conectar con Stripe: {e}") - # Mensaje simple en texto plano - message = f""" - Hola! + return redirect("cart_detail") - Gracias por tu compra en Essenza. - Tu pedido ha sido confirmado y se está preparando. - Detalles del pedido: - Nº de localizador: {new_order.tracking_code} - Total: {new_order.total_price} € - Dirección de envío: {new_order.address} +def successful_payment(request): + """ + Retorno de Stripe. + """ + session_id = request.GET.get("session_id") + if not session_id: + return HttpResponse("Error: No session ID") - Puedes seguir el estado de tu pedido aquí: - {tracking_url} + try: + session = stripe.checkout.Session.retrieve(session_id) + if session.payment_status == "paid": + # Extraer datos de Stripe + stripe_email = session.customer_details.email + address_data = session.customer_details.address + shipping_address = ( + f"{address_data.line1}, {address_data.city}, {address_data.postal_code}" + ) - Gracias por confiar en nosotros. - """ + user = request.user if request.user.is_authenticated else None - # 3. Enviar el correo - send_mail( - subject, - message, - settings.DEFAULT_FROM_EMAIL, # Asegúrate de tener esto en settings.py - [new_order.email], # El email del destinatario - fail_silently=True, # Si falla, no rompe la web + with transaction.atomic(): + # Llamamos a la función común + new_order = _process_order( + request, + user=user, + email=stripe_email, + address=shipping_address, + is_paid=True, # <--- STRIPE = TRUE ) - except Exception as e: - # Si falla el correo, lo imprimimos en consola pero dejamos pasar al usuario - print(f"Error enviando email: {e}") return render(request, "order/success.html", {"order": new_order}) - else: - return HttpResponse("El pago no se ha completado.") - + return render(request, "order/cancel.html") except Exception as e: - return HttpResponse(f"Error verificando el pago o creando el pedido: {e}") + return HttpResponse(f"Error: {e}") def cancelled_payment(request): diff --git a/essenza/product/tests.py b/essenza/product/tests.py index a28394b..c2efba8 100644 --- a/essenza/product/tests.py +++ b/essenza/product/tests.py @@ -500,10 +500,10 @@ def setUpTestData(cls): # --- TESTS DE ACCESO --- - def test_anonymous_user_redirects_to_dashboard(self): + def test_anonymous_user_redirects_to_login(self): resp = self.client.get(self.stock_url) self.assertEqual(resp.status_code, 302) - self.assertRedirects(resp, self.dashboard_url) + self.assertRedirects(resp, self.login_url) def test_non_admin_user_redirects_to_dashboard(self): self.client.login(email=self.user.email, password="pass1234") diff --git a/essenza/product/views.py b/essenza/product/views.py index fb59271..5d773d8 100644 --- a/essenza/product/views.py +++ b/essenza/product/views.py @@ -19,7 +19,7 @@ class DashboardView(UserPassesTestMixin, View): # 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" + not self.request.user.is_authenticated or self.request.user.role == "user" ) def handle_no_permission(self): @@ -68,8 +68,9 @@ class StockView(LoginRequiredMixin, UserPassesTestMixin, View): def test_func(self): return self.request.user.is_authenticated and self.request.user.role == "admin" - # Redirige a 'dashboard' si no pasa el test_func def handle_no_permission(self): + if not self.request.user.is_authenticated: + return redirect("login") return redirect("dashboard") def get(self, request): @@ -111,6 +112,11 @@ class ProductListView(LoginRequiredMixin, UserPassesTestMixin, View): def test_func(self): return self.request.user.is_authenticated and self.request.user.role == "admin" + def handle_no_permission(self): + if not self.request.user.is_authenticated: + return redirect("login") + return redirect("dashboard") + def get(self, request): q = request.GET.get("q", "").strip() if q: @@ -126,6 +132,11 @@ class ProductDetailView(LoginRequiredMixin, UserPassesTestMixin, View): def test_func(self): return self.request.user.is_authenticated and self.request.user.role == "admin" + def handle_no_permission(self): + if not self.request.user.is_authenticated: + return redirect("login") + return redirect("dashboard") + def get(self, request, pk): product = get_object_or_404(Product, pk=pk) return render(request, self.template_name, {"product": product}) @@ -138,6 +149,11 @@ class ProductCreateView(LoginRequiredMixin, UserPassesTestMixin, View): def test_func(self): return self.request.user.is_authenticated and self.request.user.role == "admin" + def handle_no_permission(self): + if not self.request.user.is_authenticated: + return redirect("login") + return redirect("dashboard") + def get(self, request): form = self.form_class() return render(request, self.template_name, {"form": form}) @@ -157,6 +173,11 @@ class ProductUpdateView(LoginRequiredMixin, UserPassesTestMixin, View): def test_func(self): return self.request.user.is_authenticated and self.request.user.role == "admin" + def handle_no_permission(self): + if not self.request.user.is_authenticated: + return redirect("login") + return redirect("dashboard") + def get(self, request, pk): product = get_object_or_404(Product, pk=pk) form = self.form_class(instance=product) @@ -177,6 +198,11 @@ class ProductDeleteView(LoginRequiredMixin, UserPassesTestMixin, View): def test_func(self): return self.request.user.is_authenticated and self.request.user.role == "admin" + def handle_no_permission(self): + if not self.request.user.is_authenticated: + return redirect("login") + return redirect("dashboard") + def get(self, request, pk): product = get_object_or_404(Product, pk=pk) return render(request, self.template_name, {"product": product}) @@ -187,9 +213,17 @@ def post(self, request, pk): return redirect("product_list") -class CatalogView(View): +class CatalogView(UserPassesTestMixin, View): template_name = "product/catalog.html" + def test_func(self): + return ( + not self.request.user.is_authenticated or self.request.user.role == "user" + ) + + def handle_no_permission(self): + return redirect("stock") + def get(self, request): q = request.GET.get("q", "").strip() if q: @@ -199,9 +233,17 @@ def get(self, request): return render(request, self.template_name, {"products": products, "query": q}) -class CatalogDetailView(View): +class CatalogDetailView(UserPassesTestMixin, View): template_name = "product/detail_user.html" + def test_func(self): + return ( + not self.request.user.is_authenticated or self.request.user.role == "user" + ) + + def handle_no_permission(self): + return redirect("stock") + def get(self, request, pk): product = get_object_or_404(Product, pk=pk, is_active=True) return render(request, self.template_name, {"product": product}) diff --git a/essenza/templates/cart/cart_detail.html b/essenza/templates/cart/cart_detail.html index 7c8561c..51e0834 100644 --- a/essenza/templates/cart/cart_detail.html +++ b/essenza/templates/cart/cart_detail.html @@ -7,22 +7,22 @@ {% block extra_head %} - -
- -
-

Pedido #{{ order.id }}

-
- -
-

Estado: {{ order.get_status_display }}

-

Fecha: {{ order.placed_at|date:"d/m/Y H:i" }}

-

Dirección: {{ order.address|default:"Sin dirección" }}

-
- -

Productos

- -
- {% for op in order.order_products.all %} -
- {% if op.product.photo %} - {{ op.product.name }} - {% else %} - No image - {% endif %} - -
-

{{ op.product.name }}

-

Cantidad: {{ op.quantity }}

-

Precio: {{ op.product.price }} €

-

Subtotal: {{ op.subtotal }} €

-
-
- {% endfor %} -
- -
- Total: {{ order.total_price }} € -
- - ← Volver - - -
- -{% endblock %} diff --git a/essenza/templates/order/order_history.html b/essenza/templates/order/order_history.html index 2cb5a03..b26e517 100644 --- a/essenza/templates/order/order_history.html +++ b/essenza/templates/order/order_history.html @@ -169,11 +169,17 @@ min-height: 80px; } + .shipping-total { + font-size: 0.9rem; + font-weight: 400; + color: #888; + margin-top: 20px; + } + .order-total { font-size: 1.5rem; font-weight: 800; color: #c06b3e; - margin-top: 15px; } /* Badges */ @@ -208,15 +214,15 @@ color: #e0e0e0; } .btn-shop { - display: inline-block; - margin-top: 15px; - background-color: #c06b3e; - color: white; - padding: 10px 20px; - border-radius: 8px; - text-decoration: none; - font-weight: 600; - transition: background 0.2s; + display: inline-block; + margin-top: 15px; + background-color: #c06b3e; + color: white; + padding: 10px 20px; + border-radius: 8px; + text-decoration: none; + font-weight: 600; + transition: background 0.2s; } .btn-shop:hover { background-color: #a35a34; } @@ -313,9 +319,16 @@

Mis pedidos

{{ order.get_status_display }} {% endif %} - -
- {{ order.total_price|floatformat:2 }} € +
+ +
+ Envío: {{ order.shipping|floatformat:2 }} € +
+ + +
+ {{ order.total|floatformat:2 }} € +
diff --git a/essenza/templates/order/order_list_admin.html b/essenza/templates/order/order_list_admin.html index 4e88f88..55d4b3a 100644 --- a/essenza/templates/order/order_list_admin.html +++ b/essenza/templates/order/order_list_admin.html @@ -208,11 +208,17 @@ min-height: 80px; } + .shipping-total { + font-size: 0.9rem; + font-weight: 400; + color: #888; + margin-top: 20px; + } + .order-total { font-size: 1.5rem; /* Grande como en product-list */ font-weight: 800; color: #c06b3e; /* Color marca */ - margin-top: 15px; } /* Badges de Estado */ @@ -379,9 +385,16 @@

Pedidos

{{ order.get_status_display }} {% endif %} - -
- {{ order.total_price|floatformat:2 }} € +
+ +
+ Envío: {{ order.shipping|floatformat:2 }} € +
+ + +
+ {{ order.total|floatformat:2 }} € +
diff --git a/essenza/templates/order/tracking.html b/essenza/templates/order/tracking.html index 3c110af..5c4e259 100644 --- a/essenza/templates/order/tracking.html +++ b/essenza/templates/order/tracking.html @@ -246,18 +246,23 @@ padding-top: 20px; border-top: 2px solid #eee; display: flex; - justify-content: flex-end; - align-items: center; - gap: 20px; + flex-direction: column; + align-items: flex-end; + gap: 5px; } - .total-label { - font-size: 1.1rem; - color: #666; + .shipping-line { + font-size: 0.95rem; + color: #888; + font-weight: 500; + display: flex; + align-items: center; + gap: 8px; } - .order-total-price { - font-size: 1.5rem; + .total-line { + font-size: 1.6rem; color: #c06b3e; font-weight: 800; + line-height: 1.1; } .btn-back { @@ -421,8 +426,16 @@
- TOTAL PAGADO: - {{ order.total_price|floatformat:2 }} € + +
+ + Envío: {{ order.shipping|floatformat:2 }} € +
+ +
+ {{ order.total|floatformat:2 }} € +
+
diff --git a/essenza/templates/product/catalog.html b/essenza/templates/product/catalog.html index 783c57b..19e1685 100644 --- a/essenza/templates/product/catalog.html +++ b/essenza/templates/product/catalog.html @@ -16,7 +16,6 @@ padding: 20px 0; } .list-page .container { - /* Contenedor principal centrado para el contenido */ max-width: 1200px; margin: 0 auto; padding: 20px; @@ -209,7 +208,7 @@ max-width: 1100px; margin: 30px auto; display: grid; - grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 22px; } .card { @@ -219,8 +218,12 @@ box-shadow: 0 3px 12px rgba(0, 0, 0, 0.1); text-align: center; transition: 0.25s; - cursor: pointer; + cursor: default; position: relative; + display: flex; + flex-direction: column; + justify-content: space-between; + height: 100%; } .card:hover { transform: translateY(-6px); @@ -231,23 +234,27 @@ height: 180px; object-fit: contain; border-radius: 10px; + margin-bottom: 10px; } .card-link-overlay { position: absolute; top: 0; left: 0; width: 100%; - height: 100%; + height: 55%; /* Solo cubre imagen y título */ z-index: 1; - } + cursor: pointer; + } .price { - margin-top: 8px; + margin-top: 5px; color: #c06b3e; font-weight: bold; + font-size: 1.1rem; } .category-tag { display: inline-block; - margin-top: 8px; + margin-left: 50px; + margin-right: 50px; background: #f2e5df; color: #c06b3e; padding: 4px 10px; @@ -255,18 +262,162 @@ font-size: 12px; } .product-stock { - margin-top: 12px; + margin-top: 10px; + font-weight: 600; + font-size: 14px; + } + + /* --- NUEVOS ESTILOS: CONTROLES DE COMPRA --- */ + .purchase-form { + width: 100%; + margin-top: 15px; + position: relative; + z-index: 2; /* Encima del overlay */ + } + + /* Selector de Cantidad */ + .qty-control-wrapper { + display: flex; + justify-content: center; + margin-bottom: 12px; + } + + .qty-control { + display: flex; + align-items: center; + border: 1px solid #ddd; + border-radius: 6px; + overflow: hidden; + background: #fff; + } + + .btn-qty { + width: 32px; + height: 32px; + background-color: #f9f9f9; + border: none; + font-weight: bold; + color: #555; + cursor: pointer; + transition: background 0.2s; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + padding: 0; + } + + .btn-qty:hover { + background-color: #e0e0e0; + color: #333; + } + + .btn-qty:active { + background-color: #ccc; + } + + .qty-input { + width: 40px; + height: 32px; + border: none; + border-left: 1px solid #eee; + border-right: 1px solid #eee; + text-align: center; font-weight: 600; font-size: 14px; + color: #333; + background: #fff; + cursor: default; + } + .qty-input:focus { outline: none; } + .qty-input::-webkit-inner-spin-button, + .qty-input::-webkit-outer-spin-button { + -webkit-appearance: none; margin: 0; + } + + /* Botones de Acción */ + .btns-row { + display: flex; + gap: 8px; + width: 100%; + } + + .btn-card { + flex: 1; + padding: 10px 0; + border: none; + border-radius: 6px; + cursor: pointer; + font-weight: 700; + font-size: 13px; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + } + + .btn-add { + background-color: #fff; + border: 1px solid #c06b3e; + color: #c06b3e; + } + .btn-add:hover { + background-color: #fff5f0; + } + + .btn-buy { + background-color: #c06b3e; + color: white; + border: 1px solid #c06b3e; } + .btn-buy:hover { + background-color: #a35a34; + border-color: #a35a34; + } + + /* --- MENSAJES FLOTANTES --- */ + .messages-container { + position: fixed; + top: 90px; + right: 20px; + z-index: 9999; + display: flex; + flex-direction: column; + gap: 10px; + } + .msg { + padding: 12px 20px; + border-radius: 8px; + color: white; + font-weight: 600; + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + animation: slideIn 0.3s ease-out, fadeOut 0.5s ease-in 3.5s forwards; + font-size: 14px; + } + .msg.success { background-color: #2e7d32; } + .msg.warning { background-color: #f57c00; } + .msg.error { background-color: #c62828; } + + @keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } } + @keyframes fadeOut { to { opacity: 0; display: none; } } +
+ + {% if messages %} +
+ {% for message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} +

Catálogo Essenza

Explora nuestra selección de productos mejor valorados

-
-
{% if products %} {% for product in products %}
Catálogo Essenza

{{ product.name }}

{{ product.price }} €

{{ product.get_category_display }} +
{% if product.stock == 0 %} Producto agotado - {% endif %} {% if product.stock < 10 and product.stock > 0 %} - ¡Últimas unidades! + {% elif product.stock < 10 %} + ¡Últimas unidades! {% endif %}
+ +
+ {% csrf_token %} + + {% if product.stock > 0 %} +
+
+ + + +
+
+ +
+ + +
+ {% endif %} +
+
{% endfor %} {% else %}

@@ -339,7 +520,21 @@

{{ product.name }}

- - -{% endblock %} + } + })(); + +{% endblock %} \ No newline at end of file diff --git a/essenza/templates/product/detail_user.html b/essenza/templates/product/detail_user.html index 2e8ac7e..1b9a724 100644 --- a/essenza/templates/product/detail_user.html +++ b/essenza/templates/product/detail_user.html @@ -1,25 +1,11 @@ {% extends "base.html" %} {% load static %} {% block title %} {{ product.name }} · Essenza -{% endblock %} {% block extra_head %} +{% endblock %} + +{% block extra_head %} -{% endblock %} {% block content %} +{% endblock %} + +{% block content %}
+ + {% if messages %} +
+ {% for message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} +
-
+
{% if product.photo %} - {{ product.name }} + {{ product.name }} {% else %} - {{ product.name }} + {{ product.name }} {% endif %}

{{ product.name }}

{{ product.brand }}
-
{{ product.get_category_display }}
-
-
€ {{ product.price }}
-
- {% if product.stock == 0 %} - Producto agotado - {% endif %} {% if product.stock < 10 and product.stock > 0 %} - ¡Últimas unidades! - {% endif %} -
+ {{ product.get_category_display }} + +
€ {{ product.price|floatformat:2 }}
+ +
+ {% if product.stock == 0 %} + Producto agotado + {% elif product.stock < 10 %} + ¡Últimas unidades! + {% else %} + En Stock + {% endif %}
+
Descripción:

{{ product.description }}

-
-
- -
-
- {% csrf_token %} - -
- - ← Volver +
+
+ {% csrf_token %} + + {% if product.stock > 0 %} +
+ Cantidad: +
+ + + +
+
+ +
+ + +
+ {% else %} + + {% endif %} +
+ + ← Volver al catálogo +
+ +
-{% endblock %} + + +{% endblock %} \ No newline at end of file diff --git a/essenza/user/tests.py b/essenza/user/tests.py index c052e97..501b4f0 100644 --- a/essenza/user/tests.py +++ b/essenza/user/tests.py @@ -215,7 +215,7 @@ def test_logout_deletes_session_cookie(self): # 3. Comprobar que un usuario no autenticado también redirige correctamente def test_logout_redirects_even_if_not_authenticated(self): response = self.client.get(self.logout_url) - self.assertRedirects(response, self.dashboard_url) + self.assertRedirects(response, self.login_url) class UserAdminViewsTests(TestCase): diff --git a/essenza/user/views.py b/essenza/user/views.py index baf9696..631d4b2 100644 --- a/essenza/user/views.py +++ b/essenza/user/views.py @@ -1,8 +1,5 @@ from django.contrib.auth import authenticate, login, logout -from django.contrib.auth.mixins import ( # Para proteger vistas - LoginRequiredMixin, - UserPassesTestMixin, -) +from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin from django.db.models import F from django.shortcuts import get_object_or_404, redirect, render from django.views import View @@ -17,10 +14,19 @@ from .models import Usuario -class LoginView(View): +class LoginView(UserPassesTestMixin, View): form_class = LoginForm template_name = "user/login.html" + def test_func(self): + return not self.request.user.is_authenticated + + def handle_no_permission(self): + if not self.request.user.role == "user": + return redirect("dashboard") + else: + return redirect("stock") + def get(self, request, *args, **kwargs): # Si el usuario ya está autenticado, lo mandamos a dashboard if request.user.is_authenticated: @@ -50,7 +56,13 @@ def post(self, request, *args, **kwargs): return render(request, self.template_name, {"form": form}) -class LogoutView(View): +class LogoutView(LoginRequiredMixin, View): + def test_func(self): + return self.request.user.is_authenticated + + def handle_no_permission(self): + return redirect("login") + def get(self, request): logout(request) response = redirect("dashboard") @@ -64,10 +76,19 @@ def post(self, request): return response -class RegisterView(View): +class RegisterView(UserPassesTestMixin, View): form_class = RegisterForm template_name = "user/register.html" + def test_func(self): + return not self.request.user.is_authenticated + + def handle_no_permission(self): + if not self.request.user.role == "user": + return redirect("dashboard") + else: + return redirect("stock") + def get(self, request, *args, **kwargs): form = self.form_class() return render(request, self.template_name, {"form": form}) @@ -86,6 +107,12 @@ def post(self, request, *args, **kwargs): class ProfileView(LoginRequiredMixin, View): template_name = "user/profile.html" + def test_func(self): + return self.request.user.is_authenticated + + def handle_no_permission(self): + return redirect("login") + def get(self, request, *args, **kwargs): return render(request, self.template_name) @@ -94,6 +121,12 @@ class ProfileEditView(LoginRequiredMixin, View): form_class = ProfileEditForm template_name = "user/edit_profile.html" + def test_func(self): + return self.request.user.is_authenticated + + def handle_no_permission(self): + return redirect("login") + def get(self, request, *args, **kwargs): # Rellena el formulario con los datos actuales del usuario form = self.form_class(instance=request.user) @@ -124,6 +157,12 @@ def post(self, request, *args, **kwargs): class ProfileDeleteView(LoginRequiredMixin, View): template_name = "user/confirm_delete_profile.html" + def test_func(self): + return self.request.user.is_authenticated + + def handle_no_permission(self): + return redirect("login") + def get(self, request, *args, **kwargs): # Muestra la página de confirmación return render(request, self.template_name) @@ -195,6 +234,8 @@ def test_func(self): return self.request.user.is_authenticated and self.request.user.role == "admin" def handle_no_permission(self): + if not self.request.user.is_authenticated: + return redirect("login") return redirect("dashboard") def get(self, request, *args, **kwargs): @@ -220,6 +261,8 @@ def test_func(self): return self.request.user.is_authenticated and self.request.user.role == "admin" def handle_no_permission(self): + if not self.request.user.is_authenticated: + return redirect("login") return redirect("dashboard") def get(self, request, pk, *args, **kwargs): @@ -259,6 +302,8 @@ def test_func(self): return self.request.user.is_authenticated and self.request.user.role == "admin" def handle_no_permission(self): + if not self.request.user.is_authenticated: + return redirect("login") return redirect("dashboard") def get(self, request, pk, *args, **kwargs):