diff --git a/essenza/essenza/urls.py b/essenza/essenza/urls.py index 4f381fc..ab21f7d 100644 --- a/essenza/essenza/urls.py +++ b/essenza/essenza/urls.py @@ -14,6 +14,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")), ] if settings.DEBUG: diff --git a/essenza/order/models.py b/essenza/order/models.py index 2857975..cb43b2a 100644 --- a/essenza/order/models.py +++ b/essenza/order/models.py @@ -1,19 +1,17 @@ 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" - class Order(models.Model): user = models.ForeignKey( "user.Usuario", on_delete=models.CASCADE, related_name="orders" ) - address = models.CharField(max_length=255) + 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 @@ -23,7 +21,7 @@ class Order(models.Model): def total_price(self): total = 0 for product in self.order_products.all(): - total += product.quantity * product.product.price + total += product.quantity * product.price return total def __str__(self): @@ -39,5 +37,9 @@ class OrderProduct(models.Model): ) 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 order {self.order.id}" diff --git a/essenza/order/tests.py b/essenza/order/tests.py index a39b155..e305ff8 100644 --- a/essenza/order/tests.py +++ b/essenza/order/tests.py @@ -1 +1,91 @@ -# Create your tests here. +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 new file mode 100644 index 0000000..12fb04c --- /dev/null +++ b/essenza/order/urls.py @@ -0,0 +1,13 @@ +# 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 91ea44a..3678864 100644 --- a/essenza/order/views.py +++ b/essenza/order/views.py @@ -1,3 +1,229 @@ -from django.shortcuts import render +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 -# Create your views here. +# 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/templates/base.html b/essenza/templates/base.html index 2bf2a82..0998659 100644 --- a/essenza/templates/base.html +++ b/essenza/templates/base.html @@ -241,6 +241,25 @@ box-shadow: 0 3px 8px rgba(192, 107, 62, 0.35), inset 0 1px 2px rgba(255, 255, 255, 0.25); } + /* ESTILOS AÑADIDOS PARA EL CARRITO */ + .cart-icon { + color: #c06b3e; + margin-right: 20px; + + display: inline-flex; + align-items: center; + text-decoration: none; + transition: color 0.2s ease; + } + .cart-icon:hover { + color: #bf6230; + } + .cart-icon svg { + /* Tamaño deseado del icono */ + width: 32px; + height: 32px; + } +/* FIN ESTILOS AÑADIDOS */ /* Responsive */ @media (max-width: 768px) { @@ -287,7 +306,15 @@ - + {% if not user.role == "admin" %} + + + + + + + + {% endif %} + {% endfor %} + + +
+
+ TOTAL: {{ cart_total|floatformat:2|intcomma }} € +
+
+ + {% else %} +
+

Tu carrito está vacío

+

¡Añade productos de nuestro Catálogo para empezar!

+
+ {% endif %} + + +{% endblock %} \ No newline at end of file diff --git a/essenza/templates/product/detail_user.html b/essenza/templates/product/detail_user.html index c038e20..6e2d073 100644 --- a/essenza/templates/product/detail_user.html +++ b/essenza/templates/product/detail_user.html @@ -2,6 +2,23 @@ - Essenza{% endblock %} {% block extra_head %}