diff --git a/essenza/_sample_assets/products/shampoo_anticaspa.jpg b/essenza/_sample_assets/products/champu_anticaspa.jpg similarity index 100% rename from essenza/_sample_assets/products/shampoo_anticaspa.jpg rename to essenza/_sample_assets/products/champu_anticaspa.jpg diff --git a/essenza/_sample_assets/products/shampoo_reconstructivo.jpg b/essenza/_sample_assets/products/champu_reconstructivo.jpg similarity index 100% rename from essenza/_sample_assets/products/shampoo_reconstructivo.jpg rename to essenza/_sample_assets/products/champu_reconstructivo.jpg diff --git a/essenza/_sample_assets/products/shampoo_voluminizador.jpg b/essenza/_sample_assets/products/champu_voluminizador.jpg similarity index 100% rename from essenza/_sample_assets/products/shampoo_voluminizador.jpg rename to essenza/_sample_assets/products/champu_voluminizador.jpg diff --git a/essenza/_sample_assets/products/servicio_asesoria.jpg b/essenza/_sample_assets/products/servicio_asesoria.jpg new file mode 100644 index 0000000..214f51d Binary files /dev/null and b/essenza/_sample_assets/products/servicio_asesoria.jpg differ diff --git a/essenza/_sample_assets/products/servicio_corte.jpg b/essenza/_sample_assets/products/servicio_corte.jpg new file mode 100644 index 0000000..15b2678 Binary files /dev/null and b/essenza/_sample_assets/products/servicio_corte.jpg differ diff --git a/essenza/_sample_assets/products/servicio_facial.jpg b/essenza/_sample_assets/products/servicio_facial.jpg new file mode 100644 index 0000000..f8ff7f4 Binary files /dev/null and b/essenza/_sample_assets/products/servicio_facial.jpg differ diff --git a/essenza/_sample_assets/products/servicio_manicura.jpg b/essenza/_sample_assets/products/servicio_manicura.jpg new file mode 100644 index 0000000..21260f0 Binary files /dev/null and b/essenza/_sample_assets/products/servicio_manicura.jpg differ diff --git a/essenza/_sample_assets/products/servicio_maquillaje.jpg b/essenza/_sample_assets/products/servicio_maquillaje.jpg new file mode 100644 index 0000000..266b41a Binary files /dev/null and b/essenza/_sample_assets/products/servicio_maquillaje.jpg differ diff --git a/essenza/_sample_assets/profile_pics/user4.jpg b/essenza/_sample_assets/profile_pics/user4.jpg new file mode 100644 index 0000000..8cf5bee Binary files /dev/null and b/essenza/_sample_assets/profile_pics/user4.jpg differ diff --git a/essenza/_sample_assets/profile_pics/user5.jpg b/essenza/_sample_assets/profile_pics/user5.jpg new file mode 100644 index 0000000..ef74810 Binary files /dev/null and b/essenza/_sample_assets/profile_pics/user5.jpg differ diff --git a/essenza/cart/__init__.py b/essenza/cart/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/essenza/cart/admin.py b/essenza/cart/admin.py new file mode 100644 index 0000000..a0ea3c3 --- /dev/null +++ b/essenza/cart/admin.py @@ -0,0 +1,9 @@ +from django.contrib import admin + +# Register your models here. +from .models import Cart, CartProduct + +# Register your models here. + +admin.site.register(Cart) +admin.site.register(CartProduct) diff --git a/essenza/cart/apps.py b/essenza/cart/apps.py new file mode 100644 index 0000000..f3e3ec9 --- /dev/null +++ b/essenza/cart/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CartConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'cart' diff --git a/essenza/cart/models.py b/essenza/cart/models.py new file mode 100644 index 0000000..d40562d --- /dev/null +++ b/essenza/cart/models.py @@ -0,0 +1,34 @@ +from django.db import models + + +class Cart(models.Model): + user = models.ForeignKey( + "user.Usuario", on_delete=models.CASCADE, related_name="cart" + ) + + @property + def total_price(self): + total = 0 + for product in self.cart_products.all(): + total += product.subtotal + return total + + def __str__(self): + return f"Cart {self.id} by {self.user.email}" + + +class CartProduct(models.Model): + cart = models.ForeignKey( + "cart.Cart", on_delete=models.CASCADE, related_name="cart_products" + ) + product = models.ForeignKey( + "product.Product", on_delete=models.CASCADE, related_name="product_carts" + ) + quantity = models.IntegerField() + + @property + def subtotal(self): + return self.quantity * self.product.price + + def __str__(self): + return f"{self.quantity} of {self.product.name} in cart {self.cart.id}" diff --git a/essenza/cart/tests.py b/essenza/cart/tests.py new file mode 100644 index 0000000..ecff2dd --- /dev/null +++ b/essenza/cart/tests.py @@ -0,0 +1,204 @@ +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 cart.models import Cart, CartProduct + +# Usamos get_user_model() porque usas un usuario personalizado (user.Usuario) +User = get_user_model() + + +class CartTests(TestCase): + def setUp(self): + self.client = Client() + + # 1. Crear Usuario + self.user = User.objects.create_user( + username="user1", + email="test@example.com", + password="password123", + first_name="Test", + last_name="User", + ) + + # 2. Crear Producto + # Usamos las choices reales de tu modelo + self.product = Product.objects.create( + name="Producto Test", + description="Descripción de prueba", + category=Category.MAQUILLAJE, + brand="Marca Test", + price=10.00, + stock=50, + is_active=True, + ) + + # 3. URLs (Sin namespace 'order' según tu urls.py actual) + 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]) + + # --------------------------------------------------------- + # 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 + + 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"} + } + session.save() + + 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 + + # --------------------------------------------------------- + # BLOQUE 2: AÑADIR AL CARRITO (POST) + # --------------------------------------------------------- + + def test_add_item_authenticated(self): + """Añadir ítem crea Cart y CartProduct en DB.""" + self.client.force_login(self.user) + + response = self.client.post(self.url_add, {"quantity": 1}) + response = self.client.post(self.url_add, {"quantity": 3}) + + 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) + + def test_add_item_anonymous(self): + """Añadir ítem guarda en Sesión.""" + self.client.logout() + + response = self.client.post(self.url_add, {"quantity": 1}) + + self.assertRedirects(response, self.url_detail) + + session = self.client.session + self.assertIn("cart_session", session) + self.assertEqual(session["cart_session"][str(self.product.pk)]["quantity"], 1) + + def test_add_item_out_of_stock(self): + """No se debe poder añadir productos sin stock.""" + self.product.stock = 0 + self.product.save() + + 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 + self.assertFalse(CartProduct.objects.filter(product=self.product).exists()) + + # --------------------------------------------------------- + # BLOQUE 3: ACTUALIZAR (POST) - AQUI ESTÁ EL PELIGRO + # --------------------------------------------------------- + + 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 + ) + cp.save() + + response = self.client.post(self.url_update, {"quantity": 5}) + + self.assertRedirects(response, self.url_detail) + cp.refresh_from_db() + 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}) + + # Actualizamos + response = self.client.post(self.url_update, {"quantity": 4}) + + self.assertRedirects(response, self.url_detail) + session = self.client.session + self.assertEqual(session["cart_session"][str(self.product.pk)]["quantity"], 4) + + # --------------------------------------------------------- + # BLOQUE 4: ELIMINAR (POST) + # --------------------------------------------------------- + + 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) + + # TRUCO: ID CartProduct == ID Product + cp = CartProduct( + id=self.product.pk, cart=cart, product=self.product, quantity=1 + ) + cp.save() + + response = self.client.post(self.url_remove) + + self.assertRedirects(response, self.url_detail) + 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 + session["cart_session"] = { + str(self.product.pk): {"quantity": 1, "price": "10.00"} + } + session.save() + + response = self.client.post(self.url_remove) + + self.assertRedirects(response, self.url_detail) + session = self.client.session + self.assertNotIn(str(self.product.pk), session["cart_session"]) diff --git a/essenza/cart/urls.py b/essenza/cart/urls.py new file mode 100644 index 0000000..6bcea2f --- /dev/null +++ b/essenza/cart/urls.py @@ -0,0 +1,18 @@ +from django.urls import path + +from .views import AddToCartView, CartDetailView, RemoveFromCartView, UpdateCartItemView + +urlpatterns = [ + path("", CartDetailView.as_view(), name="cart_detail"), + path("add//", AddToCartView.as_view(), name="add_to_cart"), + path( + "update//", + UpdateCartItemView.as_view(), + name="update_cart_item", + ), + path( + "remove//", + RemoveFromCartView.as_view(), + name="remove_from_cart", + ), +] diff --git a/essenza/cart/views.py b/essenza/cart/views.py new file mode 100644 index 0000000..530eb3d --- /dev/null +++ b/essenza/cart/views.py @@ -0,0 +1,204 @@ +from django.contrib import messages +from django.shortcuts import get_object_or_404, redirect, render +from django.views import View +from product.models import Product + +from .models import Cart, CartProduct + + +class CartDetailView(View): + """ + Muestra el carrito. + - Si es usuario logueado: Lee de la base de datos + - Si es anónimo: Lee de la sesión. + """ + + template_name = "cart/cart_detail.html" + + def get(self, request): + context = {"cart_products": [], "total_price": 0} + + # Si esta logueado + if request.user.is_authenticated: + # Busca un carrito exitente y si no lo hay, lo crea nuevo + try: + 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["cart"] = cart + except Exception: + pass + + # Si no está logueado, usamos la sesión + else: + cart_session = request.session.get("cart_session", {}) + cart_products = [] + total_price = 0 + + if cart_session: + # Obtenemos los productos + product_ids = [int(pk) for pk in cart_session.keys()] + products = Product.objects.filter(pk__in=product_ids) + + # Construimos los items del carrito + for product in products: + quantity = cart_session[str(product.pk)]["quantity"] + subtotal = quantity * product.price + + # Añadimos al listado de items del carrito la info necesaria + cart_products.append( + { + "product": product, + "quantity": quantity, + "subtotal": subtotal, + "pk": product.pk, + } + ) + total_price += subtotal + + context["cart_products"] = cart_products + context["total_price"] = total_price + + return render(request, self.template_name, context) + + +class AddToCartView(View): + """ + Añade productos al carrito (DB o Sesión). + """ + + def post(self, request, product_id): + product = get_object_or_404(Product, pk=product_id) + + if product.stock <= 0: + messages.error(request, f"Lo sentimos, '{product.name}' está agotado.") + return redirect("catalog") + + try: + quantity = int(request.POST.get("quantity", 1)) + if quantity < 1: + quantity = 1 + except ValueError: + quantity = 1 + + # Si el usuario está logueado + if request.user.is_authenticated: + cart, create = Cart.objects.get_or_create(user=request.user) + + if cart: + cart_product, created = CartProduct.objects.get_or_create( + cart=cart, product=product, defaults={"quantity": quantity} + ) + else: + cart_product, created = CartProduct.objects.get_or_create( + cart=create, product=product, defaults={"quantity": quantity} + ) + + 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() + msg = f"Se ha añadido otra unidad de {product.name}." + else: + msg = f"{product.name} añadido al carrito." + + messages.success(request, msg) + + # Si el usuario no está logueado, guardamos en sesión + else: + 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: + cart_session[product_id_str]["quantity"] = product.stock + return redirect("cart_detail") + else: + cart_session[product_id_str]["quantity"] += quantity + msg = f"Se ha añadido otra unidad de {product.name}." + else: + cart_session[product_id_str] = { + "quantity": quantity, + "price": str(product.price), + } + msg = f"{product.name} añadido al carrito." + + request.session["cart_session"] = cart_session + request.session.modified = True + messages.success(request, msg) + + return redirect("cart_detail") + + +class RemoveFromCartView(View): + """ + Elimina productos del carrito. + """ + + def post(self, request, product_id): + # Si el usuario está logueado + if request.user.is_authenticated: + cart = get_object_or_404(Cart, user=request.user) + # Buscamos el CartProduct que coincida con el usuario y el producto + cart_product = get_object_or_404( + CartProduct, + cart=cart, + pk=product_id, + ) + cart_product.delete() + if not cart.cart_products.exists(): + cart.delete() + + # Si el usuario no está logueado, eliminamos de la sesión + else: + cart_session = request.session.get("cart_session", {}) + product_id_str = str(product_id) + + if product_id_str in cart_session: + del cart_session[product_id_str] + request.session["cart_session"] = cart_session + request.session.modified = True + + return redirect("cart_detail") + + +class UpdateCartItemView(View): + """ + Actualiza la cantidad de un producto. + """ + + def post(self, request, product_id): + try: + new_quantity = int(request.POST.get("quantity", 1)) + except ValueError: + new_quantity = 1 + + # Si la cantidad es 0 o negativa, eliminamos el producto + if new_quantity <= 0: + return RemoveFromCartView().post(request, product_id) + + # Si el usuario está logueado + if request.user.is_authenticated: + cart_product = get_object_or_404( + CartProduct, + cart=get_object_or_404(Cart, user=request.user), + pk=product_id, + ) + cart_product.quantity = new_quantity + cart_product.save() + + # Si el usuario no está logueado, actualizamos en la sesión + else: + cart_session = request.session.get("cart_session", {}) + product_id_str = str(product_id) + + if product_id_str in cart_session: + cart_session[product_id_str]["quantity"] = new_quantity + request.session["cart_session"] = cart_session + request.session.modified = True + + return redirect("cart_detail") diff --git a/essenza/essenza/settings.py b/essenza/essenza/settings.py index 73abfdc..ee674bb 100644 --- a/essenza/essenza/settings.py +++ b/essenza/essenza/settings.py @@ -42,6 +42,7 @@ "product", "order", "info", + "cart", ] MIDDLEWARE = [ diff --git a/essenza/essenza/urls.py b/essenza/essenza/urls.py index ab21f7d..7919736 100644 --- a/essenza/essenza/urls.py +++ b/essenza/essenza/urls.py @@ -3,8 +3,7 @@ from django.contrib import admin from django.urls import include, path from info.views import info_view -from product.views import DashboardView -from product.views import CatalogView, CatalogDetailView +from product.views import CatalogDetailView, CatalogView, DashboardView urlpatterns = [ path("info/", info_view, name="info-home"), @@ -14,7 +13,7 @@ path("", DashboardView.as_view(), name="dashboard"), path("catalog/", CatalogView.as_view(), name="catalog"), path("catalog//", CatalogDetailView.as_view(), name="catalog_detail"), - path("order/", include("order.urls")), + path("cart/", include("cart.urls")), ] if settings.DEBUG: diff --git a/essenza/order/models.py b/essenza/order/models.py index cb43b2a..2e8f9e4 100644 --- a/essenza/order/models.py +++ b/essenza/order/models.py @@ -1,11 +1,13 @@ from django.db import models from django.utils import timezone + # Create your models here. class Status(models.TextChoices): - PENDING = "pending", "Pending" - PAID = "paid", "Paid" - SHIPPED = "shipped", "Shipped" + EN_PREPARACION = "en_preparacion", "En Preparación" + ENVIADO = "enviado", "Enviado" + ENTREGADO = "entregado", "Entregado" + class Order(models.Model): user = models.ForeignKey( @@ -13,15 +15,13 @@ class Order(models.Model): ) address = models.CharField(max_length=255, null=True, blank=True) placed_at = models.DateTimeField(default=timezone.now) - status = models.CharField( - max_length=10, choices=Status.choices, default=Status.PENDING - ) + status = models.CharField(choices=Status.choices, default=Status.EN_PREPARACION) @property def total_price(self): total = 0 for product in self.order_products.all(): - total += product.quantity * product.price + total += product.subtotal return total def __str__(self): diff --git a/essenza/order/sample/sample.json b/essenza/order/sample/sample.json index fc7017d..e2c9420 100644 --- a/essenza/order/sample/sample.json +++ b/essenza/order/sample/sample.json @@ -6,7 +6,7 @@ "user": 1, "address": "Calle Gran Vía, 23, Madrid, 28013", "placed_at": "2025-11-12T10:30:00Z", - "status": "pending" + "status": "en_preparacion" } }, { @@ -16,7 +16,7 @@ "user": 2, "address": "Avenida de la Constitución, 8, Sevilla, 41001", "placed_at": "2025-11-11T15:10:00Z", - "status": "paid" + "status": "enviado" } }, { @@ -26,7 +26,7 @@ "user": 3, "address": "Carrer de Pau Claris, 60, Barcelona, 08010", "placed_at": "2025-11-10T19:25:00Z", - "status": "shipped" + "status": "entregado" } }, { @@ -36,7 +36,7 @@ "user": 1, "address": "Calle Alcalá, 120, Madrid, 28009", "placed_at": "2025-11-09T09:00:00Z", - "status": "pending" + "status": "en_preparacion" } }, { @@ -46,7 +46,7 @@ "user": 2, "address": "Plaza Nueva, 10, Bilbao, 48001", "placed_at": "2025-11-08T12:15:00Z", - "status": "shipped" + "status": "entregado" } }, { @@ -56,7 +56,7 @@ "user": 3, "address": "Calle Larios, 5, Málaga, 29001", "placed_at": "2025-11-07T14:00:00Z", - "status": "paid" + "status": "enviado" } }, { @@ -66,7 +66,7 @@ "user": 1, "address": "Paseo de Gracia, 92, Barcelona, 08008", "placed_at": "2025-11-06T18:45:00Z", - "status": "pending" + "status": "en_preparacion" } }, { @@ -76,7 +76,7 @@ "user": 2, "address": "Calle de la Paz, 1, Valencia, 46003", "placed_at": "2025-11-06T10:00:00Z", - "status": "shipped" + "status": "entregado" } }, { @@ -86,7 +86,7 @@ "user": 3, "address": "Calle Mayor, 30, Zaragoza, 50001", "placed_at": "2025-10-15T11:00:00Z", - "status": "shipped" + "status": "entregado" } }, { @@ -96,7 +96,7 @@ "user": 1, "address": "Rúa do Vilar, 50, Santiago de Compostela, 15705", "placed_at": "2025-10-28T08:30:00Z", - "status": "paid" + "status": "enviado" } }, { @@ -240,7 +240,7 @@ "fields": { "order": 10, "product": 7, - "quantity": 5 + "quantity": 1 } }, { @@ -368,5 +368,14 @@ "product": 3, "quantity": 1 } + }, + { + "model": "order.orderproduct", + "pk": 31, + "fields": { + "order": 4, + "product": 21, + "quantity": 1 + } } ] \ No newline at end of file diff --git a/essenza/order/tests.py b/essenza/order/tests.py index e305ff8..e69de29 100644 --- a/essenza/order/tests.py +++ b/essenza/order/tests.py @@ -1,91 +0,0 @@ -from django.test import TestCase, Client -from django.urls import reverse -from django.contrib.auth import get_user_model - -from product.models import Product -from order.models import Order, OrderProduct, Status - - -User = get_user_model() - - -class CartTests(TestCase): - def setUp(self): - self.client = Client() - # Create a sample product - self.product = Product.objects.create( - name="Test Product", - description="Desc", - category="maquillaje", - brand="Marca", - price="9.99", - stock=10, - is_active=True, - ) - - # Regular user - self.user = User.objects.create_user( - email="user@example.com", username="user1", password="pass1234", role="user" - ) - - # Admin user - self.admin = User.objects.create_user( - email="admin@example.com", username="admin1", password="adminpass", role="admin", is_staff=True - ) - - def test_anonymous_add_to_cart_creates_session(self): - url = reverse('add_to_cart', kwargs={'product_pk': self.product.pk}) - response = self.client.post(url, {'quantity': 2}, follow=True) - - # Should redirect to cart_detail - self.assertEqual(response.status_code, 200) - session = self.client.session - self.assertIn('cart_session', session) - cart = session['cart_session'] - self.assertIn(str(self.product.pk), cart) - self.assertEqual(cart[str(self.product.pk)]['quantity'], 2) - - def test_authenticated_user_adds_to_db_cart(self): - self.client.login(email='user@example.com', password='pass1234') - url = reverse('add_to_cart', kwargs={'product_pk': self.product.pk}) - response = self.client.post(url, {'quantity': 3}, follow=True) - - # After adding, there should be a pending Order for the user - self.assertEqual(response.status_code, 200) - orders = Order.objects.filter(user=self.user, status=Status.PENDING) - self.assertTrue(orders.exists()) - order = orders.first() - # Check OrderProduct exists - op = OrderProduct.objects.filter(order=order, product=self.product).first() - self.assertIsNotNone(op) - self.assertEqual(op.quantity, 3) - - def test_admin_get_cart_forbidden(self): - # Admin should get 403 on cart detail - self.client.login(email='admin@example.com', password='adminpass') - url = reverse('cart_detail') - response = self.client.get(url) - self.assertEqual(response.status_code, 403) - - def test_admin_cannot_add_to_cart(self): - self.client.login(email='admin@example.com', password='adminpass') - url = reverse('add_to_cart', kwargs={'product_pk': self.product.pk}) - response = self.client.post(url) - self.assertEqual(response.status_code, 403) - - def test_cart_shows_empty_after_order_deleted(self): - # Create a pending order for the user with one OrderProduct - order = Order.objects.create(user=self.user, status=Status.PENDING, address='') - OrderProduct.objects.create(order=order, product=self.product, quantity=2) - - # Delete the order - order.delete() - - # Login as user and request cart detail - self.client.login(email='user@example.com', password='pass1234') - url = reverse('cart_detail') - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - self.assertIn('cart_items', response.context) - self.assertEqual(len(response.context['cart_items']), 0) - diff --git a/essenza/order/urls.py b/essenza/order/urls.py index 12fb04c..e69de29 100644 --- a/essenza/order/urls.py +++ b/essenza/order/urls.py @@ -1,13 +0,0 @@ -# order/urls.py -from django.urls import include, path -from . import views - -urlpatterns = [ - #path('order/', include('order.urls')), - path('', views.CartDetailView.as_view(), name='order_home'), - path('cart/', views.CartDetailView.as_view(), name='cart_detail'), - path('add//', views.AddToCartView.as_view(), name='add_to_cart'), - path('update//', views.UpdateCartItemView.as_view(), name='update_cart_item'), - path('update/session//', views.UpdateCartSessionView.as_view(), name='update_cart_session'), - -] \ No newline at end of file diff --git a/essenza/order/views.py b/essenza/order/views.py index 3678864..e69de29 100644 --- a/essenza/order/views.py +++ b/essenza/order/views.py @@ -1,229 +0,0 @@ -from django.shortcuts import get_object_or_404, redirect, render -from django.views import View -from django.contrib.auth.mixins import LoginRequiredMixin -from django.contrib import messages -from django.db.models import F # Importado para operaciones atómicas -from django.core.exceptions import ObjectDoesNotExist, PermissionDenied - -# Importaciones de tus modelos -from .models import Order, OrderProduct, Status -from product.models import Product - -# -------------------------------------------------------------------- -# 1. FUNCIÓN AUXILIAR NECESARIA -# -------------------------------------------------------------------- -def get_or_create_cart(request): - """ - Obtiene la Order más reciente con status='PENDING' (asumida como carrito) - o crea una nueva Order en estado 'PENDING'. Solo para usuarios logueados. - """ - # Deny access to admin/staff users explicitly - if request.user.is_authenticated and (getattr(request.user, 'role', None) == 'admin' or getattr(request.user, 'is_staff', False)): - raise PermissionDenied("Acceso denegado: administradores no pueden usar el carrito.") - - if request.user.is_authenticated: - # Lógica para usuarios logueados - try: - cart = Order.objects.filter( - user=request.user, - status=Status.PENDING - ).order_by('-placed_at').first() - - if cart is None: - raise ObjectDoesNotExist - - except ObjectDoesNotExist: - cart = Order.objects.create( - user=request.user, - status=Status.PENDING, - address="", # Provide an empty string to avoid IntegrityError - ) - - return cart - else: - # Los anónimos usan la sesión - return None - -# -------------------------------------------------------------------- -# 2. VISTAS -# -------------------------------------------------------------------- - -# order/views.py (Fragmento de CartDetailView) - -class CartDetailView(View): - """Muestra el contenido del carrito activo del usuario (DB) o de la sesión (Anónimo).""" - template_name = 'order/cart_detail.html' - - def get(self, request): - cart = None - if request.user.is_authenticated: - # LÓGICA 1: Usuario logueado (lee de la DB) - cart = get_or_create_cart(request) - cart_items = cart.order_products.all() - cart_total = sum(item.product.price * item.quantity for item in cart_items) if cart_items else 0 - else: - # LÓGICA 2: Usuario anónimo (lee de la Sesión) - cart_session = request.session.get('cart_session', {}) - cart_items = [] - cart_total = 0 - - # Si hay ítems en la sesión, construimos una lista para la plantilla - if cart_session: - product_pks = [int(pk) for pk in cart_session.keys()] - - # Buscamos todos los objetos Product de la DB de una vez - products = Product.objects.filter(pk__in=product_pks) - - # Iteramos sobre los productos para crear la lista de ítems del carrito - for product in products: - pk_str = str(product.pk) - quantity = cart_session[pk_str]['quantity'] - - # Creamos un objeto temporal para pasarlo al template - cart_items.append({ - 'product': product, - 'quantity': quantity, - 'subtotal': quantity * product.price, - 'pk': product.pk, - }) - cart_total = sum(item['product'].price * item['quantity'] for item in cart_items) - - context = { - 'cart': cart, # Será None para anónimos - 'cart_items': cart_items, # Lista de DB objects o dicts/temp objects - 'cart_total': cart_total, # Total calculado - } - return render(request, self.template_name, context) -# ======================================================= -# AÑADIR AL CARRITO (Añadido/Corregido) -# ======================================================= -class AddToCartView(View): # LoginRequiredMixin eliminado - """ - Añade un producto al carrito, usando DB (Logueado) o Session (Anónimo). - """ - def post(self, request, product_pk): - product = get_object_or_404(Product, pk=product_pk) - - try: - quantity = int(request.POST.get('quantity', 1)) - if quantity < 1: - quantity = 1 - except ValueError: - quantity = 1 - - cart = get_or_create_cart(request) # Devuelve Order (logueado) o None (anónimo) - - # --- LÓGICA DE MANEJO DEL CARRITO --- - if cart: - # 1. USUARIO LOGUEADO (cart es un objeto Order) - - # Línea 88: Ya no falla porque 'cart' es un objeto Order. - cart_item = cart.order_products.filter(product=product).first() - - if cart_item: - # UPDATE (DB) - cart_item.quantity = F('quantity') + quantity - cart_item.save(update_fields=['quantity']) - cart_item.refresh_from_db() - messages.success(request, f"Se ha añadido {quantity} unidad(es) de '{product.name}'. Cantidad total: {cart_item.quantity}") - else: - # CREATE (DB) - OrderProduct.objects.create( - order=cart, - product=product, - quantity=quantity - ) - messages.success(request, f"'{product.name}' se ha añadido al carrito.") - - else: - # 2. USUARIO ANÓNIMO (cart es None, usamos la sesión) - - cart_session = request.session.get('cart_session', {}) - product_pk_str = str(product_pk) - - if product_pk_str in cart_session: - # UPDATE (SESSION) - cart_session[product_pk_str]['quantity'] += quantity - messages.success(request, f"Se ha añadido {quantity} unidad(es) de '{product.name}'. Cantidad total en carrito: {cart_session[product_pk_str]['quantity']}") - else: - # CREATE (SESSION) - cart_session[product_pk_str] = { - 'quantity': quantity, - 'price': str(product.price) - } - messages.success(request, f"'{product.name}' se ha añadido al carrito.") - - # Guardar y marcar la sesión - request.session['cart_session'] = cart_session - request.session.modified = True - - return redirect('cart_detail') -# ======================================================= -# ACTUALIZAR CANTIDAD EN EL CARRITO -# ======================================================= -class UpdateCartSessionView(View): - """Actualiza la cantidad de un ítem existente en el carrito de la sesión (Anónimo).""" - def post(self, request, product_pk): - if request.user.is_authenticated: - # Protección: si un usuario logueado intenta usar esta URL, redirigir a la vista DB - return redirect('cart_detail') - - cart_session = request.session.get('cart_session', {}) - product_pk_str = str(product_pk) - - # 1. Obtener la nueva cantidad - try: - new_quantity = int(request.POST.get('quantity', 0)) - except ValueError: - new_quantity = -1 - - # Necesitamos el objeto Product para el nombre y el stock - product = get_object_or_404(Product, pk=product_pk) - - # 2. Lógica de Actualización/Eliminación - if product_pk_str in cart_session: - if new_quantity <= 0: - # ELIMINAR - del cart_session[product_pk_str] - messages.info(request, f"'{product.name}' ha sido eliminado del carrito.") - else: - # ACTUALIZAR - # Opcional: limitar al stock disponible - if new_quantity > product.stock: - new_quantity = product.stock - messages.warning(request, f"Solo quedan {product.stock} unidades de '{product.name}'. Cantidad limitada.") - else: - cart_session[product_pk_str]['quantity'] = new_quantity - messages.success(request, f"Cantidad de '{product.name}' actualizada a {new_quantity}.") - - # 3. Guardar sesión - request.session['cart_session'] = cart_session - request.session.modified = True - - return redirect('cart_detail') - -class UpdateCartItemView(View): - """Actualiza la cantidad de un ítem existente en el carrito.""" - def post(self, request, item_pk): - cart_item = get_object_or_404(OrderProduct, pk=item_pk) - cart = get_or_create_cart(request) - - if cart_item.order.pk != cart.pk: - messages.error(request, "El ítem no pertenece a tu carrito activo.") - return redirect('cart_detail') - - try: - new_quantity = int(request.POST.get('quantity', 0)) - except ValueError: - new_quantity = -1 - - if new_quantity <= 0: - item_name = cart_item.product.name - cart_item.delete() - messages.info(request, f"'{item_name}' ha sido eliminado del carrito.") - else: - cart_item.quantity = new_quantity - cart_item.save(update_fields=['quantity']) - messages.success(request, f"Cantidad de '{cart_item.product.name}' actualizada a {new_quantity}.") - - return redirect('cart_detail') \ No newline at end of file diff --git a/essenza/product/models.py b/essenza/product/models.py index fe62df7..e10da81 100644 --- a/essenza/product/models.py +++ b/essenza/product/models.py @@ -7,6 +7,7 @@ class Category(models.TextChoices): TRATAMIENTO = "tratamiento", "Tratamiento" CABELLO = "cabello", "Cabello" PERFUME = "perfume", "Perfume" + SERVICIO = "servicio", "Servicio" class Product(models.Model): diff --git a/essenza/product/sample/sample.json b/essenza/product/sample/sample.json index 9237432..d114e1d 100644 --- a/essenza/product/sample/sample.json +++ b/essenza/product/sample/sample.json @@ -22,7 +22,7 @@ "category": "tratamiento", "brand": "Pantene", "price": 6.99, - "photo": "products/shampoo_reconstructivo.jpg", + "photo": "products/champu_reconstructivo.jpg", "stock": 100, "is_active": true } @@ -30,6 +30,20 @@ { "model": "product.product", "pk": 3, + "fields": { + "name": "Sesión de Maquillaje Profesional", + "description": "Maquillaje completo para eventos, bodas o fiestas. Duración 60 min. Válido por una semana desde el momento de la compra.", + "category": "servicio", + "brand": "Essenza Studio", + "price": 45.00, + "photo": "products/servicio_maquillaje.jpg", + "stock": 1, + "is_active": true + } + }, + { + "model": "product.product", + "pk": 4, "fields": { "name": "Secador de Pelo", "description": "Secador de pelo con 3 niveles de temperatura, 2000W.", @@ -43,7 +57,7 @@ }, { "model": "product.product", - "pk": 4, + "pk": 5, "fields": { "name": "Perfume Floral", "description": "Perfume con notas de jazmín y rosa, 100ml.", @@ -57,7 +71,7 @@ }, { "model": "product.product", - "pk": 5, + "pk": 6, "fields": { "name": "Crema Hidratante", "description": "Crema hidratante para piel seca, 50ml.", @@ -71,7 +85,21 @@ }, { "model": "product.product", - "pk": 6, + "pk": 7, + "fields": { + "name": "Corte y Peinado Personalizado", + "description": "Asesoramiento de imagen, lavado, corte y peinado final. Válido por una semana desde el momento de la compra.", + "category": "servicio", + "brand": "Essenza Hair", + "price": 25.00, + "photo": "products/servicio_corte.jpg", + "stock": 1, + "is_active": true + } + }, + { + "model": "product.product", + "pk": 8, "fields": { "name": "Rizador de Pelo", "description": "Rizador de pelo con control de temperatura, 25mm.", @@ -85,7 +113,7 @@ }, { "model": "product.product", - "pk": 7, + "pk": 9, "fields": { "name": "Gel Antibacterial", "description": "Gel antibacterial para manos, 250ml.", @@ -99,21 +127,35 @@ }, { "model": "product.product", - "pk": 8, + "pk": 10, "fields": { "name": "Champú Anticaspa", "description": "Champú anticaspa para cuero cabelludo sensible, 400ml.", "category": "tratamiento", "brand": "Head & Shoulders", "price": 7.99, - "photo": "products/shampoo_anticaspa.jpg", + "photo": "products/champu_anticaspa.jpg", "stock": 90, "is_active": true } }, { "model": "product.product", - "pk": 9, + "pk": 11, + "fields": { + "name": "Limpieza Facial Profunda", + "description": "Tratamiento facial completo con exfoliación y vapor ozono. Válido por una semana desde el momento de la compra.", + "category": "servicio", + "brand": "Essenza Spa", + "price": 35.00, + "photo": "products/servicio_facial.jpg", + "stock": 1, + "is_active": true + } + }, + { + "model": "product.product", + "pk": 12, "fields": { "name": "Aceite Capilar", "description": "Aceite nutritivo para el cabello, 150ml.", @@ -127,7 +169,7 @@ }, { "model": "product.product", - "pk": 10, + "pk": 13, "fields": { "name": "Tinte de Pelo", "description": "Tinte permanente para pelo, color castaño claro.", @@ -141,7 +183,7 @@ }, { "model": "product.product", - "pk": 11, + "pk": 14, "fields": { "name": "Mascarilla Facial", "description": "Mascarilla hidratante para todo tipo de piel, 100ml.", @@ -155,26 +197,40 @@ }, { "model": "product.product", - "pk": 12, + "pk": 15, + "fields": { + "name": "Manicura Semipermanente", + "description": "Limado, retirada de cutículas y esmaltado de larga duración. Válido por una semana desde el momento de la compra.", + "category": "servicio", + "brand": "Essenza Nails", + "price": 18.00, + "photo": "products/servicio_manicura.jpg", + "stock": 1, + "is_active": true + } + }, + { + "model": "product.product", + "pk": 16, "fields": { "name": "Champú Voluminizador", "description": "Champú voluminizador para cabellos finos, 300ml.", "category": "tratamiento", "brand": "TRESemmé", "price": 5.99, - "photo": "products/shampoo_voluminizador.jpg", + "photo": "products/champu_voluminizador.jpg", "stock": 120, "is_active": true } }, { "model": "product.product", - "pk": 13, + "pk": 17, "fields": { "name": "Laca de Pelo", "description": "Laca fijadora para todo el día, 400ml.", "category": "cabello", - "brand": "Schwarzkopf", + "brand": "Nelly", "price": 10.99, "photo": "products/laca_pelo.jpg", "stock": 70, @@ -183,7 +239,7 @@ }, { "model": "product.product", - "pk": 14, + "pk": 18, "fields": { "name": "Crema Solar", "description": "Protección solar SPF 50+, 200ml.", @@ -197,7 +253,7 @@ }, { "model": "product.product", - "pk": 15, + "pk": 19, "fields": { "name": "Crema Antiedad", "description": "Crema antiarrugas para el rostro, 50ml.", @@ -211,7 +267,7 @@ }, { "model": "product.product", - "pk": 16, + "pk": 20, "fields": { "name": "Desodorante", "description": "Desodorante en barra, 75g.", @@ -225,7 +281,21 @@ }, { "model": "product.product", - "pk": 17, + "pk": 21, + "fields": { + "name": "Asesoría de Imagen Completa", + "description": "Estudio de colorimetría, morfología y estilo personal. Válido por una semana desde el momento de la compra.", + "category": "servicio", + "brand": "Essenza Style", + "price": 60.00, + "photo": "products/servicio_asesoria.jpg", + "stock": 1, + "is_active": true + } + }, + { + "model": "product.product", + "pk": 22, "fields": { "name": "Toallitas Desmaquillantes", "description": "Toallitas para desmaquillar, 25 unidades.", @@ -239,7 +309,7 @@ }, { "model": "product.product", - "pk": 18, + "pk": 23, "fields": { "name": "Pincel de Maquillaje", "description": "Pincel para base líquida, cerdas suaves.", @@ -253,7 +323,7 @@ }, { "model": "product.product", - "pk": 19, + "pk": 24, "fields": { "name": "Crema para Pies", "description": "Crema reparadora para pies agrietados, 100ml.", @@ -267,7 +337,7 @@ }, { "model": "product.product", - "pk": 20, + "pk": 25, "fields": { "name": "Limpieza Facial", "description": "Gel limpiador facial suave, 200ml.", @@ -279,4 +349,4 @@ "is_active": true } } -] +] \ No newline at end of file diff --git a/essenza/product/views.py b/essenza/product/views.py index f4caf79..7d60d84 100644 --- a/essenza/product/views.py +++ b/essenza/product/views.py @@ -24,7 +24,7 @@ def test_func(self): ) def get(self, request, *args, **kwargs): - q = request.GET.get('q', '').strip() + q = request.GET.get("q", "").strip() month_ago = timezone.now() - timezone.timedelta(days=30) year_ago = timezone.now() - timezone.timedelta(days=365) @@ -48,7 +48,9 @@ def get_top_selling_products(since): # If a search query is provided, show matching products instead of top sellers if q: products = Product.objects.filter(is_active=True, name__icontains=q) - return render(request, self.template_name, {"products": products, "query": q}) + return render( + request, self.template_name, {"products": products, "query": q} + ) products = get_top_selling_products(since=month_ago) if not products.exists(): @@ -70,7 +72,7 @@ def handle_no_permission(self): def get(self, request): # Carga y muestra todos los productos ordenados por nombre - q = request.GET.get('q', '').strip() + q = request.GET.get("q", "").strip() if q: products = Product.objects.filter(name__icontains=q).order_by("name") else: @@ -113,7 +115,7 @@ def test_func(self): return self.request.user.is_authenticated and self.request.user.role == "admin" def get(self, request): - q = request.GET.get('q', '').strip() + q = request.GET.get("q", "").strip() if q: products = Product.objects.filter(name__icontains=q) else: @@ -192,7 +194,7 @@ class CatalogView(View): template_name = "product/catalog.html" def get(self, request): - q = request.GET.get('q', '').strip() + q = request.GET.get("q", "").strip() if q: products = Product.objects.filter(is_active=True, name__icontains=q) else: @@ -206,6 +208,3 @@ class CatalogDetailView(View): 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/base.html b/essenza/templates/base.html index 26c0214..bed837f 100644 --- a/essenza/templates/base.html +++ b/essenza/templates/base.html @@ -196,7 +196,7 @@ .product-card img { width: 100%; height: 180px; - object-fit: cover; + object-fit: contain; border-radius: 10px; } diff --git a/essenza/templates/order/cart_detail.html b/essenza/templates/cart/cart_detail.html similarity index 90% rename from essenza/templates/order/cart_detail.html rename to essenza/templates/cart/cart_detail.html index 7156353..bdee429 100644 --- a/essenza/templates/order/cart_detail.html +++ b/essenza/templates/cart/cart_detail.html @@ -184,9 +184,9 @@

Tu Carrito de Compra

Productos seleccionados por ti.

- {% if cart_items %} + {% if cart_products %}
- {% for item in cart_items %} + {% for item in cart_products %}
@@ -204,13 +204,8 @@

{{ item.product.name }}

- {% if request.user.is_authenticated %} -
- {% else %} - - {% endif %} + {% csrf_token %} - {{ item.product.name }} data-item-pk="{{ item.pk }}" onchange="this.form.submit()" aria-label="Cantidad" + onkeydown="return false" + style="caret-color: transparent" > -
@@ -233,7 +233,7 @@

{{ item.product.name }}

- TOTAL: {{ cart_total|floatformat:2|intcomma }} € + TOTAL: {{ total_price|floatformat:2|intcomma }} €
diff --git a/essenza/templates/product/catalog.html b/essenza/templates/product/catalog.html index 5675991..30747e2 100644 --- a/essenza/templates/product/catalog.html +++ b/essenza/templates/product/catalog.html @@ -97,6 +97,11 @@ border-radius: 6px; font-size: 12px; } + .product-stock { + margin-top: 12px; + font-weight: 600; + font-size: 14px; + } @@ -121,7 +126,7 @@

Catálogo Essenza

- {% for product in products %} + {% if products %} {% for product in products %}
Catálogo Essenza onclick="location.href='{% url 'catalog_detail' product.pk %}'" > {% if product.photo %} - {{ product.name }} + {% else %} - {{ product.name }} + {% endif %}

{{ 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! + {% endif %} +
- {% endfor %} + {% endfor %} {% else %} +

+ No hay productos disponibles en este momento. +

+ {% endif %}
{% endblock %} diff --git a/essenza/templates/product/list.html b/essenza/templates/product/list.html index 761e9a3..2fd5dc7 100644 --- a/essenza/templates/product/list.html +++ b/essenza/templates/product/list.html @@ -74,6 +74,7 @@ } .product-info { padding: 15px; + padding-bottom: 0; } .product-name { font-size: 18px; @@ -95,14 +96,14 @@ color: #333; margin-bottom: 8px; } - .product-stock { - font-size: 13px; - color: #999; - margin-bottom: 12px; - } .product-actions { display: flex; gap: 8px; + margin-bottom: 12px; + } + .product-stock { + font-weight: 600; + font-size: 16px; } .btn-small { flex: 1; @@ -197,12 +198,14 @@

Productos Essenza

>Borrar
+
+ {% if product.stock == 0 %} + ¡Producto agotado! + {% endif %} {% if product.stock < 10 and product.stock > 0 %} + ¡Últimas unidades! + {% endif %} +
- {% if product.stock == 0 %} - ¡Producto agotado! - {% endif %} {% if product.stock < 10 and product.stock > 0 %} - ¡Últimas unidades! - {% endif %}
{% endfor %} diff --git a/essenza/templates/product/stock.html b/essenza/templates/product/stock.html index 70edfee..212587e 100644 --- a/essenza/templates/product/stock.html +++ b/essenza/templates/product/stock.html @@ -60,6 +60,7 @@ /* Limita el texto a 2 líneas */ display: -webkit-box; -webkit-line-clamp: 2; + line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } @@ -77,12 +78,6 @@ font-size: 1.1em; } - .product-stock { - margin-top: 8px; - font-weight: 500; - font-size: 14px; - } - /* --- NUEVOS ESTILOS PARA EL STOCK --- */ .product-stock { margin-top: 10px; diff --git a/essenza/templates/user/profile.html b/essenza/templates/user/profile.html index b3127bd..5bab7eb 100644 --- a/essenza/templates/user/profile.html +++ b/essenza/templates/user/profile.html @@ -61,7 +61,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); diff --git a/essenza/user/sample/sample.json b/essenza/user/sample/sample.json index abcbead..130bdf7 100644 --- a/essenza/user/sample/sample.json +++ b/essenza/user/sample/sample.json @@ -41,6 +41,32 @@ { "model": "user.usuario", "pk": 4, + "fields": { + "email": "user4@example.com", + "username": "user4@example.com", + "first_name": "fourth", + "last_name": "user", + "photo": "profile_pics/user4.jpg", + "role": "user", + "password": "pbkdf2_sha256$1000000$qHIC4EeRho2OC2FLmNPo9t$atSjNjIY9vOi1GPqbotF19e8tcd1PBFTNkcYBpZyKmU=" + } + }, + { + "model": "user.usuario", + "pk": 5, + "fields": { + "email": "user5@example.com", + "username": "user5@example.com", + "first_name": "fifth", + "last_name": "user", + "photo": "profile_pics/user5.jpg", + "role": "user", + "password": "pbkdf2_sha256$1000000$qHIC4EeRho2OC2FLmNPo9t$atSjNjIY9vOi1GPqbotF19e8tcd1PBFTNkcYBpZyKmU=" + } + }, + { + "model": "user.usuario", + "pk": 6, "fields": { "email": "admin@example.com", "username": "admin@example.com", @@ -53,4 +79,4 @@ "password": "pbkdf2_sha256$1000000$sazfnRvfJ4niYZE6ixBVKR$pboaUb8AWvEXwuJZkWoh3xwfYq++7nik9p2e0TxNPws=" } } -] +] \ No newline at end of file