diff --git a/proyecto/__pycache__/__init__.cpython-314.pyc b/proyecto/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 8250ca7..0000000 Binary files a/proyecto/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/proyecto/__pycache__/settings.cpython-314.pyc b/proyecto/__pycache__/settings.cpython-314.pyc deleted file mode 100644 index 3cb5057..0000000 Binary files a/proyecto/__pycache__/settings.cpython-314.pyc and /dev/null differ diff --git a/proyecto/__pycache__/urls.cpython-314.pyc b/proyecto/__pycache__/urls.cpython-314.pyc deleted file mode 100644 index 658ce21..0000000 Binary files a/proyecto/__pycache__/urls.cpython-314.pyc and /dev/null differ diff --git a/proyecto/__pycache__/wsgi.cpython-314.pyc b/proyecto/__pycache__/wsgi.cpython-314.pyc deleted file mode 100644 index dd040cd..0000000 Binary files a/proyecto/__pycache__/wsgi.cpython-314.pyc and /dev/null differ diff --git a/tienda/__pycache__/__init__.cpython-314.pyc b/tienda/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index f69534a..0000000 Binary files a/tienda/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/tienda/__pycache__/admin.cpython-314.pyc b/tienda/__pycache__/admin.cpython-314.pyc deleted file mode 100644 index bf9e0c8..0000000 Binary files a/tienda/__pycache__/admin.cpython-314.pyc and /dev/null differ diff --git a/tienda/__pycache__/apps.cpython-314.pyc b/tienda/__pycache__/apps.cpython-314.pyc deleted file mode 100644 index 9082874..0000000 Binary files a/tienda/__pycache__/apps.cpython-314.pyc and /dev/null differ diff --git a/tienda/__pycache__/context_processors.cpython-314.pyc b/tienda/__pycache__/context_processors.cpython-314.pyc deleted file mode 100644 index 0643b39..0000000 Binary files a/tienda/__pycache__/context_processors.cpython-314.pyc and /dev/null differ diff --git a/tienda/__pycache__/models.cpython-314.pyc b/tienda/__pycache__/models.cpython-314.pyc deleted file mode 100644 index 664bcdd..0000000 Binary files a/tienda/__pycache__/models.cpython-314.pyc and /dev/null differ diff --git a/tienda/__pycache__/urls.cpython-314.pyc b/tienda/__pycache__/urls.cpython-314.pyc deleted file mode 100644 index bcd2369..0000000 Binary files a/tienda/__pycache__/urls.cpython-314.pyc and /dev/null differ diff --git a/tienda/__pycache__/vars.cpython-314.pyc b/tienda/__pycache__/vars.cpython-314.pyc deleted file mode 100644 index 1507f0c..0000000 Binary files a/tienda/__pycache__/vars.cpython-314.pyc and /dev/null differ diff --git a/tienda/__pycache__/views.cpython-314.pyc b/tienda/__pycache__/views.cpython-314.pyc deleted file mode 100644 index 30f8c67..0000000 Binary files a/tienda/__pycache__/views.cpython-314.pyc and /dev/null differ diff --git a/tienda/migrations/0004_product_stock_stockreservation_stockreservationitem.py b/tienda/migrations/0004_product_stock_stockreservation_stockreservationitem.py new file mode 100644 index 0000000..1782119 --- /dev/null +++ b/tienda/migrations/0004_product_stock_stockreservation_stockreservationitem.py @@ -0,0 +1,45 @@ +# Generated by Django 6.0.1 on 2026-04-09 06:55 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tienda', '0003_order_transaction_code'), + ] + + operations = [ + migrations.AddField( + model_name='product', + name='stock', + field=models.PositiveIntegerField(default=0), + ), + migrations.CreateModel( + name='StockReservation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('session_key', models.CharField(blank=True, max_length=40, null=True)), + ('status', models.CharField(choices=[('active', 'Activa'), ('completed', 'Completada'), ('cancelled', 'Cancelada'), ('expired', 'Expirada')], default='active', max_length=20)), + ('payment_method', models.CharField(choices=[('stripe', 'Stripe'), ('paypal', 'PayPal')], max_length=20)), + ('expires_at', models.DateTimeField(db_index=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='stock_reservations', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='StockReservationItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('quantity', models.PositiveIntegerField(default=1)), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stock_reservation_items', to='tienda.product')), + ('reservation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='tienda.stockreservation')), + ], + options={ + 'unique_together': {('reservation', 'product')}, + }, + ), + ] diff --git a/tienda/migrations/__pycache__/0001_initial.cpython-314.pyc b/tienda/migrations/__pycache__/0001_initial.cpython-314.pyc deleted file mode 100644 index 4fa3f99..0000000 Binary files a/tienda/migrations/__pycache__/0001_initial.cpython-314.pyc and /dev/null differ diff --git a/tienda/migrations/__pycache__/0002_verificationcode_code_mode_and_more.cpython-314.pyc b/tienda/migrations/__pycache__/0002_verificationcode_code_mode_and_more.cpython-314.pyc deleted file mode 100644 index 6d2093c..0000000 Binary files a/tienda/migrations/__pycache__/0002_verificationcode_code_mode_and_more.cpython-314.pyc and /dev/null differ diff --git a/tienda/migrations/__pycache__/__init__.cpython-314.pyc b/tienda/migrations/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index ef4a82c..0000000 Binary files a/tienda/migrations/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/tienda/templates/tienda/cart.html b/tienda/templates/tienda/cart.html index 0938f61..bcde235 100644 --- a/tienda/templates/tienda/cart.html +++ b/tienda/templates/tienda/cart.html @@ -12,6 +12,17 @@ {% if cart_items %}
+ {% if stock_issues %} +
+ Hay productos con stock insuficiente: +
    + {% for issue in stock_issues %} +
  • {{ issue.product_name }} - disponible: {{ issue.available }}, en carrito: {{ issue.requested }}
  • + {% endfor %} +
+
Actualiza las cantidades antes de continuar al pago.
+
+ {% endif %}
@@ -20,6 +31,7 @@ + @@ -39,10 +51,17 @@ + + @@ -89,6 +107,7 @@ + {% endfor %} @@ -118,7 +137,7 @@ class="btn btn-primary payment-btn" data-config-url="/tienda/config/" data-session-url="/tienda/create-checkout-session/" - {% if not addresses %}disabled{% endif %}> + {% if not addresses or stock_issues %}disabled{% endif %}> 💳 Pagar con Stripe @@ -126,7 +145,7 @@ id="paypal-button" class="btn btn-warning payment-btn" data-payment-url="{% url 'create_paypal_payment' %}" - {% if not addresses %}disabled{% endif %}> + {% if not addresses or stock_issues %}disabled{% endif %}> 🅿️ Pagar con PayPal @@ -139,7 +158,9 @@ {% endblock %} \ No newline at end of file diff --git a/tienda/templates/tienda/crear_producto.html b/tienda/templates/tienda/crear_producto.html index 37ae71d..87a5b34 100644 --- a/tienda/templates/tienda/crear_producto.html +++ b/tienda/templates/tienda/crear_producto.html @@ -46,6 +46,14 @@ + +
+ + +
Cantidad máxima que podrán comprar los clientes.
+
+
diff --git a/tienda/templates/tienda/editar_producto.html b/tienda/templates/tienda/editar_producto.html index b181661..1e40f34 100644 --- a/tienda/templates/tienda/editar_producto.html +++ b/tienda/templates/tienda/editar_producto.html @@ -46,6 +46,14 @@
+ +
+ + +
Cantidad máxima que podrán comprar los clientes.
+
+
diff --git a/tienda/templates/tienda/mis_productos.html b/tienda/templates/tienda/mis_productos.html index ba8bbb5..f08e2ee 100644 --- a/tienda/templates/tienda/mis_productos.html +++ b/tienda/templates/tienda/mis_productos.html @@ -31,6 +31,7 @@
+ @@ -53,6 +54,7 @@ +
Producto Precio (sin IVA) CantidadStock Subtotal (con IVA) Acciones
{% csrf_token %} - +
+ {% if item.product.stock > 0 %} + {{ item.product.stock }} + {% else %} + 0 + {% endif %} + {{ item.get_subtotal_with_vat|format_price }} €
@@ -82,7 +101,7 @@ {{ cart.get_total_with_vat|format_price }} €
- Proceder al Pago + Proceder al Pago Continuar Comprando {% csrf_token %} diff --git a/tienda/templates/tienda/checkout.html b/tienda/templates/tienda/checkout.html index 4272896..8999c4b 100644 --- a/tienda/templates/tienda/checkout.html +++ b/tienda/templates/tienda/checkout.html @@ -49,6 +49,23 @@
{% if cart_items %} + {% if stock_issues %} +
+ No hay stock suficiente para algunos productos: +
    + {% for issue in stock_issues %} +
  • {{ issue.product_name }} - disponible: {{ issue.available }}, en carrito: {{ issue.requested }}
  • + {% endfor %} +
+
Vuelve al carrito y ajusta las cantidades antes de pagar.
+
+ {% endif %} + +
+ Al pulsar en pagar se reservará tu stock durante {{ reservation_minutes }} minutos. + Si el pago no se completa en ese tiempo, la reserva se cancelará automáticamente. +
+
1) Selecciona la dirección de envío
@@ -80,6 +97,7 @@
Producto Precio (sin IVA) CantidadStock actual Subtotal (con IVA)
{{ item.product.name }} {{ item.product.price|format_price }}€ {{ item.quantity }}{{ item.product.stock }} {{ item.get_subtotal_with_vat|format_price }}€
Categoría Precio (sin IVA) Precio (con IVA)Stock Acciones
{{ producto.category.name }} {{ producto.price|format_price }}€ {{ producto.get_price_with_vat|format_price }}€{{ producto.stock }}
Editar diff --git a/tienda/templates/tienda/producto.html b/tienda/templates/tienda/producto.html index 47b136c..b9b0bdc 100644 --- a/tienda/templates/tienda/producto.html +++ b/tienda/templates/tienda/producto.html @@ -39,14 +39,21 @@
{{ product.briefdesc }}
+
+ {% if product.stock > 0 %} + Stock disponible: {{ product.stock }} + {% else %} + Sin stock + {% endif %} +
{% csrf_token %}
- +
- +
diff --git a/tienda/templatetags/__pycache__/__init__.cpython-314.pyc b/tienda/templatetags/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 5c2839f..0000000 Binary files a/tienda/templatetags/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/tienda/templatetags/__pycache__/vat_filters.cpython-314.pyc b/tienda/templatetags/__pycache__/vat_filters.cpython-314.pyc deleted file mode 100644 index 20bb862..0000000 Binary files a/tienda/templatetags/__pycache__/vat_filters.cpython-314.pyc and /dev/null differ diff --git a/tienda/views.py b/tienda/views.py index b6c432f..da6bc5a 100644 --- a/tienda/views.py +++ b/tienda/views.py @@ -335,7 +335,7 @@ def _cancel_active_stock_reservations_for_request(request: HttpRequest): ).update(status=StockReservation.STATUS_CANCELLED) -def _get_reserved_quantities_by_product(product_ids, exclude_reservation_id=None): +def _get_reserved_quantities_by_product(product_ids, exclude_reservation_ids=None): if not product_ids: return {} @@ -344,27 +344,38 @@ def _get_reserved_quantities_by_product(product_ids, exclude_reservation_id=None reservation__status=StockReservation.STATUS_ACTIVE, reservation__expires_at__gt=timezone.now(), ) - if exclude_reservation_id: - reserved_qs = reserved_qs.exclude(reservation_id=exclude_reservation_id) + if exclude_reservation_ids: + reserved_qs = reserved_qs.exclude(reservation_id__in=exclude_reservation_ids) reserved_totals = reserved_qs.values("product_id").annotate(total_reserved=models.Sum("quantity")) return {row["product_id"]: row["total_reserved"] for row in reserved_totals} -def _get_available_stock_by_product(product_ids, exclude_reservation_id=None): +def _get_active_reservation_ids_for_request(request: HttpRequest): + _release_expired_stock_reservations() + return list( + StockReservation.objects.filter( + **_get_reservation_owner_filters(request), + status=StockReservation.STATUS_ACTIVE, + expires_at__gt=timezone.now(), + ).values_list("id", flat=True) + ) + + +def _get_available_stock_by_product(product_ids, exclude_reservation_ids=None): _release_expired_stock_reservations() products = Product.objects.filter(id__in=product_ids) stocks = {product.id: product.stock for product in products} - reserved = _get_reserved_quantities_by_product(product_ids, exclude_reservation_id=exclude_reservation_id) + reserved = _get_reserved_quantities_by_product(product_ids, exclude_reservation_ids=exclude_reservation_ids) return { product_id: max(stocks.get(product_id, 0) - reserved.get(product_id, 0), 0) for product_id in product_ids } -def _get_cart_stock_issues(cart_items, exclude_reservation_id=None): +def _get_cart_stock_issues(cart_items, exclude_reservation_ids=None): product_ids = [item.product_id for item in cart_items] - available_by_product = _get_available_stock_by_product(product_ids, exclude_reservation_id=exclude_reservation_id) + available_by_product = _get_available_stock_by_product(product_ids, exclude_reservation_ids=exclude_reservation_ids) issues = [] for item in cart_items: @@ -527,7 +538,7 @@ def create_order_from_cart(request, payment_method, payment_reference="", shippi reserved_from_others = _get_reserved_quantities_by_product( product_ids, - exclude_reservation_id=locked_reservation.id if locked_reservation else None, + exclude_reservation_ids=[locked_reservation.id] if locked_reservation else None, ) for item in cart_items: @@ -604,9 +615,25 @@ def add_to_cart(request: HttpRequest, product_id: int): try: product = Product.objects.get(id=product_id) cart = get_or_create_cart(request) + + _cancel_active_stock_reservations_for_request(request) + _clear_stock_reservation_session(request) # Obtener cantidad del POST o por defecto 1 quantity = int(request.POST.get('quantity', 1)) + if quantity <= 0: + messages.error(request, "La cantidad debe ser mayor que cero.") + return redirect('producto', id=product_id) + + existing_item = CartItem.objects.filter(cart=cart, product=product).first() + desired_quantity = quantity if existing_item is None else existing_item.quantity + quantity + available = _get_available_stock_by_product([product.id]).get(product.id, 0) + if desired_quantity > available: + messages.error( + request, + f"No hay stock suficiente de '{product.name}'. Disponible: {available}, solicitado: {desired_quantity}.", + ) + return redirect('producto', id=product_id) # Buscar si ya existe el producto en el carrito cart_item, created = CartItem.objects.get_or_create( @@ -632,6 +659,10 @@ def add_to_cart(request: HttpRequest, product_id: int): }) return redirect('view_cart') + + except ValueError: + messages.error(request, "Cantidad no válida.") + return redirect('producto', id=product_id) except Product.DoesNotExist: messages.error(request, "Producto no encontrado.") @@ -641,9 +672,13 @@ def add_to_cart(request: HttpRequest, product_id: int): def view_cart(request: HttpRequest): """Muestra el contenido del carrito""" cart = get_or_create_cart(request) + cart_items = list(cart.items.select_related("product")) + active_reservation_ids = _get_active_reservation_ids_for_request(request) + stock_issues = _get_cart_stock_issues(cart_items, exclude_reservation_ids=active_reservation_ids) return render(request, "tienda/cart.html", { "cart": cart, - "cart_items": cart.items.all() + "cart_items": cart_items, + "stock_issues": stock_issues, }) @@ -652,10 +687,21 @@ def update_cart_item(request: HttpRequest, item_id: int): try: cart = get_or_create_cart(request) cart_item = CartItem.objects.get(id=item_id, cart=cart) + + _cancel_active_stock_reservations_for_request(request) + _clear_stock_reservation_session(request) quantity = int(request.POST.get('quantity', 1)) if quantity > 0: + available = _get_available_stock_by_product([cart_item.product_id]).get(cart_item.product_id, 0) + if quantity > available: + messages.error( + request, + f"No hay stock suficiente de '{cart_item.product.name}'. Disponible: {available}, solicitado: {quantity}.", + ) + return redirect('view_cart') + cart_item.quantity = quantity cart_item.save() messages.success(request, "Cantidad actualizada.") @@ -668,12 +714,17 @@ def update_cart_item(request: HttpRequest, item_id: int): except CartItem.DoesNotExist: messages.error(request, "Producto no encontrado en el carrito.") return redirect('view_cart') + except ValueError: + messages.error(request, "Cantidad no válida.") + return redirect('view_cart') def remove_from_cart(request: HttpRequest, item_id: int): """Elimina un producto del carrito""" try: cart = get_or_create_cart(request) + _cancel_active_stock_reservations_for_request(request) + _clear_stock_reservation_session(request) cart_item = CartItem.objects.get(id=item_id, cart=cart) product_name = cart_item.product.name cart_item.delete() @@ -688,6 +739,8 @@ def remove_from_cart(request: HttpRequest, item_id: int): def clear_cart(request: HttpRequest): """Vacía todo el carrito""" cart = get_or_create_cart(request) + _cancel_active_stock_reservations_for_request(request) + _clear_stock_reservation_session(request) cart.items.all().delete() messages.success(request, "Carrito vaciado.") return redirect('view_cart') @@ -769,12 +822,13 @@ def crear_producto(request: HttpRequest): briefdesc = request.POST.get("briefdesc") description = request.POST.get("description") price = request.POST.get("price") + stock = request.POST.get("stock") category_id = request.POST.get("category") primary_image_file = request.FILES.get("primary_image") secondary_images_files = request.FILES.getlist("secondary_images") # Validaciones - if not all([name, description, price, category_id]): + if not all([name, description, price, stock, category_id]): messages.error(request, "Por favor completa todos los campos obligatorios.") categories = Category.objects.all() return render(request, "tienda/crear_producto.html", {"categories": categories}) @@ -787,6 +841,15 @@ def crear_producto(request: HttpRequest): messages.error(request, "El precio debe ser un número válido.") categories = Category.objects.all() return render(request, "tienda/crear_producto.html", {"categories": categories}) + + try: + stock = int(stock) + if stock < 0: + raise ValueError("El stock no puede ser negativo") + except ValueError: + messages.error(request, "El stock debe ser un número entero válido.") + categories = Category.objects.all() + return render(request, "tienda/crear_producto.html", {"categories": categories}) try: category = Category.objects.get(id=category_id) @@ -809,6 +872,7 @@ def crear_producto(request: HttpRequest): briefdesc=briefdesc or "", description=description, price=price, + stock=stock, category=category, primary_image=primary_image, creator=request.user @@ -841,11 +905,12 @@ def editar_producto(request: HttpRequest, id: int): briefdesc = request.POST.get("briefdesc") description = request.POST.get("description") price = request.POST.get("price") + stock = request.POST.get("stock") category_id = request.POST.get("category") primary_image_file = request.FILES.get("primary_image") secondary_images_files = request.FILES.getlist("secondary_images") - if not all([name, description, price, category_id]): + if not all([name, description, price, stock, category_id]): messages.error(request, "Por favor completa todos los campos obligatorios.") categories = Category.objects.all() return render(request, "tienda/editar_producto.html", { @@ -865,6 +930,18 @@ def editar_producto(request: HttpRequest, id: int): "producto": producto }) + try: + stock = int(stock) + if stock < 0: + raise ValueError("El stock no puede ser negativo") + except ValueError: + messages.error(request, "El stock debe ser un número entero válido.") + categories = Category.objects.all() + return render(request, "tienda/editar_producto.html", { + "categories": categories, + "producto": producto + }) + try: category = Category.objects.get(id=category_id) except Category.DoesNotExist: @@ -879,6 +956,7 @@ def editar_producto(request: HttpRequest, id: int): producto.briefdesc = briefdesc or "" producto.description = description producto.price = price + producto.stock = stock producto.category = category if primary_image_file: @@ -925,12 +1003,16 @@ def borrar_producto(request: HttpRequest, id: int): @login_required def checkout(request: HttpRequest): cart = get_or_create_cart(request) - cart_items = cart.items.select_related("product") + cart_items = list(cart.items.select_related("product")) + active_reservation_ids = _get_active_reservation_ids_for_request(request) + stock_issues = _get_cart_stock_issues(cart_items, exclude_reservation_ids=active_reservation_ids) addresses = ShippingAddress.objects.filter(user=request.user) return render(request, "tienda/checkout.html", { "cart": cart, "cart_items": cart_items, "addresses": addresses, + "stock_issues": stock_issues, + "reservation_minutes": STOCK_RESERVATION_MINUTES, }) @csrf_exempt @@ -954,11 +1036,24 @@ def create_checkout_session(request: HttpRequest): return JsonResponse({"error": "Debes seleccionar una dirección de envío válida."}, status=400) cart = get_or_create_cart(request) - cart_items = cart.items.select_related("product") + cart_items = list(cart.items.select_related("product")) - if not cart_items.exists(): + if not cart_items: return JsonResponse({"error": "El carrito está vacío"}, status=400) + active_reservation_ids = _get_active_reservation_ids_for_request(request) + stock_issues = _get_cart_stock_issues(cart_items, exclude_reservation_ids=active_reservation_ids) + if stock_issues: + return JsonResponse({"error": _build_stock_issue_message(stock_issues[0])}, status=400) + + reservation, reservation_issues = _create_stock_reservation_for_cart( + request, + cart_items, + StockReservation.PAYMENT_STRIPE, + ) + if reservation is None: + return JsonResponse({"error": reservation_issues[0]}, status=400) + stripe.api_key = settings.STRIPE_SECRET_KEY line_items = [] @@ -995,6 +1090,8 @@ def create_checkout_session(request: HttpRequest): request.session['stripe_session_id'] = session.id request.session['selected_shipping_address_id'] = shipping_address.id + request.session[STOCK_RESERVATION_SESSION_KEY] = reservation.id + request.session[STOCK_RESERVATION_PAYMENT_SESSION_KEY] = StockReservation.PAYMENT_STRIPE return JsonResponse({"sessionId": session.id}) except Exception as e: @@ -1002,20 +1099,37 @@ def create_checkout_session(request: HttpRequest): return JsonResponse({"error": f"Error al crear sesión de pago: {str(e)}"}, status=500) +@login_required def checkout_success(request: HttpRequest): payment_reference = request.session.get('stripe_session_id', "") shipping_address_id = request.session.get('selected_shipping_address_id') shipping_address = ShippingAddress.objects.filter(id=shipping_address_id, user=request.user).first() - order = create_order_from_cart(request, Order.PAYMENT_STRIPE, payment_reference, shipping_address) + reservation = _get_session_stock_reservation(request, StockReservation.PAYMENT_STRIPE) + order, order_error = create_order_from_cart( + request, + Order.PAYMENT_STRIPE, + payment_reference, + shipping_address, + stock_reservation=reservation, + ) + + if order is None: + messages.error(request, order_error) + return redirect("checkout") + if 'stripe_session_id' in request.session: del request.session['stripe_session_id'] if 'selected_shipping_address_id' in request.session: del request.session['selected_shipping_address_id'] + _clear_stock_reservation_session(request) messages.success(request, "Pago realizado correctamente. ¡Gracias por tu compra!") return render(request, "tienda/checkout_success.html", {"order": order}) +@login_required def checkout_cancel(request: HttpRequest): + _cancel_active_stock_reservations_for_request(request) + _clear_stock_reservation_session(request) messages.info(request, "Pago cancelado. Puedes intentarlo de nuevo cuando quieras.") return render(request, "tienda/checkout_cancel.html", {}) @@ -1057,11 +1171,24 @@ def create_paypal_payment(request: HttpRequest): import paypalrestsdk cart = get_or_create_cart(request) - cart_items = cart.items.select_related("product") + cart_items = list(cart.items.select_related("product")) - if not cart_items.exists(): + if not cart_items: return JsonResponse({"error": "El carrito está vacío"}, status=400) + active_reservation_ids = _get_active_reservation_ids_for_request(request) + stock_issues = _get_cart_stock_issues(cart_items, exclude_reservation_ids=active_reservation_ids) + if stock_issues: + return JsonResponse({"error": _build_stock_issue_message(stock_issues[0])}, status=400) + + reservation, reservation_issues = _create_stock_reservation_for_cart( + request, + cart_items, + StockReservation.PAYMENT_PAYPAL, + ) + if reservation is None: + return JsonResponse({"error": reservation_issues[0]}, status=400) + # Configurar PayPal paypalrestsdk.configure({ "mode": settings.PAYPAL_MODE, @@ -1124,6 +1251,8 @@ def create_paypal_payment(request: HttpRequest): # Guardar el payment ID en sesión request.session['paypal_payment_id'] = payment.id request.session['selected_shipping_address_id'] = shipping_address.id + request.session[STOCK_RESERVATION_SESSION_KEY] = reservation.id + request.session[STOCK_RESERVATION_PAYMENT_SESSION_KEY] = StockReservation.PAYMENT_PAYPAL # Encontrar el link de aprobación for link in payment.links: @@ -1178,13 +1307,25 @@ def paypal_execute(request: HttpRequest): # Pago exitoso - crear pedido y limpiar el carrito shipping_address_id = request.session.get('selected_shipping_address_id') shipping_address = ShippingAddress.objects.filter(id=shipping_address_id, user=request.user).first() - order = create_order_from_cart(request, Order.PAYMENT_PAYPAL, payment_id, shipping_address) + reservation = _get_session_stock_reservation(request, StockReservation.PAYMENT_PAYPAL) + order, order_error = create_order_from_cart( + request, + Order.PAYMENT_PAYPAL, + payment_id, + shipping_address, + stock_reservation=reservation, + ) + + if order is None: + messages.error(request, order_error) + return redirect("checkout") # Limpiar la sesión if 'paypal_payment_id' in request.session: del request.session['paypal_payment_id'] if 'selected_shipping_address_id' in request.session: del request.session['selected_shipping_address_id'] + _clear_stock_reservation_session(request) messages.success(request, "¡Pago realizado correctamente con PayPal! Gracias por tu compra.") return render(request, "tienda/checkout_success.html", {"order": order}) @@ -1197,6 +1338,8 @@ def paypal_execute(request: HttpRequest): logger.exception("PAYPAL_EXECUTE_EXCEPTION user_id=%s error=%s", request.user.id, str(e)) messages.error(request, f"Error: {str(e)}") return redirect("checkout") + + def search_suggestions(request: HttpRequest): """API AJAX que retorna sugerencias de búsqueda en JSON""" query = request.GET.get('q', '').strip()