feat: Add stock management features including stock reservations and updates to cart and checkout processes
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
@@ -12,6 +12,17 @@
|
||||
{% if cart_items %}
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-8">
|
||||
{% if stock_issues %}
|
||||
<div class="alert alert-warning">
|
||||
<strong>Hay productos con stock insuficiente:</strong>
|
||||
<ul class="mb-0 mt-2">
|
||||
{% for issue in stock_issues %}
|
||||
<li>{{ issue.product_name }} - disponible: {{ issue.available }}, en carrito: {{ issue.requested }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div class="mt-2">Actualiza las cantidades antes de continuar al pago.</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<table class="table">
|
||||
@@ -20,6 +31,7 @@
|
||||
<th>Producto</th>
|
||||
<th>Precio (sin IVA)</th>
|
||||
<th>Cantidad</th>
|
||||
<th>Stock</th>
|
||||
<th>Subtotal (con IVA)</th>
|
||||
<th>Acciones</th>
|
||||
</tr>
|
||||
@@ -39,10 +51,17 @@
|
||||
<td>
|
||||
<form method="post" action="{% url 'update_cart_item' item.id %}" class="d-flex align-items-center" style="max-width: 150px;">
|
||||
{% csrf_token %}
|
||||
<input type="number" name="quantity" value="{{ item.quantity }}" min="1" class="form-control form-control-sm me-2" style="width: 70px;">
|
||||
<input type="number" name="quantity" value="{{ item.quantity }}" min="1" max="{{ item.product.stock }}" class="form-control form-control-sm me-2" style="width: 70px;">
|
||||
<button type="submit" class="btn btn-sm btn-primary">Actualizar</button>
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
{% if item.product.stock > 0 %}
|
||||
{{ item.product.stock }}
|
||||
{% else %}
|
||||
<span class="text-danger">0</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="price">{{ item.get_subtotal_with_vat|format_price }} €</td>
|
||||
<td>
|
||||
<form method="post" action="{% url 'remove_from_cart' item.id %}" style="display: inline;">
|
||||
@@ -82,7 +101,7 @@
|
||||
<strong class="price">{{ cart.get_total_with_vat|format_price }} €</strong>
|
||||
</div>
|
||||
<div class="d-grid gap-2">
|
||||
<a href="{% url 'checkout' %}" class="btn btn-primary btn-lg">Proceder al Pago</a>
|
||||
<a href="{% url 'checkout' %}" class="btn btn-primary btn-lg {% if stock_issues %}disabled{% endif %}" {% if stock_issues %}aria-disabled="true"{% endif %}>Proceder al Pago</a>
|
||||
<a href="{% url 'index' %}" class="btn btn-outline-secondary">Continuar Comprando</a>
|
||||
<form method="post" action="{% url 'clear_cart' %}">
|
||||
{% csrf_token %}
|
||||
|
||||
@@ -49,6 +49,23 @@
|
||||
</div>
|
||||
|
||||
{% if cart_items %}
|
||||
{% if stock_issues %}
|
||||
<div class="alert alert-warning">
|
||||
<strong>No hay stock suficiente para algunos productos:</strong>
|
||||
<ul class="mb-0 mt-2">
|
||||
{% for issue in stock_issues %}
|
||||
<li>{{ issue.product_name }} - disponible: {{ issue.available }}, en carrito: {{ issue.requested }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div class="mt-2">Vuelve al carrito y ajusta las cantidades antes de pagar.</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="alert alert-info">
|
||||
Al pulsar en pagar se reservará tu stock durante <strong>{{ reservation_minutes }} minutos</strong>.
|
||||
Si el pago no se completa en ese tiempo, la reserva se cancelará automáticamente.
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title mb-3">1) Selecciona la dirección de envío</h5>
|
||||
@@ -80,6 +97,7 @@
|
||||
<th>Producto</th>
|
||||
<th class="text-end">Precio (sin IVA)</th>
|
||||
<th class="text-end">Cantidad</th>
|
||||
<th class="text-end">Stock actual</th>
|
||||
<th class="text-end">Subtotal (con IVA)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -89,6 +107,7 @@
|
||||
<td>{{ item.product.name }}</td>
|
||||
<td class="text-end">{{ item.product.price|format_price }}€</td>
|
||||
<td class="text-end">{{ item.quantity }}</td>
|
||||
<td class="text-end">{{ item.product.stock }}</td>
|
||||
<td class="text-end">{{ item.get_subtotal_with_vat|format_price }}€</td>
|
||||
</tr>
|
||||
{% 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
|
||||
</button>
|
||||
|
||||
@@ -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
|
||||
</button>
|
||||
</div>
|
||||
@@ -139,7 +158,9 @@
|
||||
|
||||
<script>
|
||||
// Manejo del botón de PayPal
|
||||
document.getElementById('paypal-button').addEventListener('click', async function(e) {
|
||||
const paypalButton = document.getElementById('paypal-button');
|
||||
if (paypalButton) {
|
||||
paypalButton.addEventListener('click', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const shippingAddressSelect = document.getElementById('shipping-address');
|
||||
@@ -202,5 +223,6 @@
|
||||
button.innerHTML = originalText;
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -46,6 +46,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stock -->
|
||||
<div class="mb-3">
|
||||
<label for="stock" class="form-label">Stock disponible <span class="text-danger">*</span></label>
|
||||
<input type="number" class="form-control" id="stock" name="stock" required
|
||||
min="0" step="1" placeholder="0">
|
||||
<div class="form-text">Cantidad máxima que podrán comprar los clientes.</div>
|
||||
</div>
|
||||
|
||||
<!-- Categoría -->
|
||||
<div class="mb-3">
|
||||
<label for="category" class="form-label">Categoría <span class="text-danger">*</span></label>
|
||||
|
||||
@@ -46,6 +46,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stock -->
|
||||
<div class="mb-3">
|
||||
<label for="stock" class="form-label">Stock disponible <span class="text-danger">*</span></label>
|
||||
<input type="number" class="form-control" id="stock" name="stock" required
|
||||
min="0" step="1" value="{{ producto.stock }}" placeholder="0">
|
||||
<div class="form-text">Cantidad máxima que podrán comprar los clientes.</div>
|
||||
</div>
|
||||
|
||||
<!-- Categoría -->
|
||||
<div class="mb-3">
|
||||
<label for="category" class="form-label">Categoría <span class="text-danger">*</span></label>
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
<th>Categoría</th>
|
||||
<th class="text-end">Precio (sin IVA)</th>
|
||||
<th class="text-end">Precio (con IVA)</th>
|
||||
<th class="text-end">Stock</th>
|
||||
<th class="text-end">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -53,6 +54,7 @@
|
||||
<td>{{ producto.category.name }}</td>
|
||||
<td class="text-end">{{ producto.price|format_price }}€</td>
|
||||
<td class="text-end text-success"><strong>{{ producto.get_price_with_vat|format_price }}€</strong></td>
|
||||
<td class="text-end">{{ producto.stock }}</td>
|
||||
<td class="text-end">
|
||||
<div class="d-flex justify-content-end gap-2">
|
||||
<a href="{% url 'editar_producto' producto.id %}" class="btn btn-outline-primary btn-sm">Editar</a>
|
||||
|
||||
@@ -39,14 +39,21 @@
|
||||
<div id="descripcion">
|
||||
{{ product.briefdesc }}
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
{% if product.stock > 0 %}
|
||||
<span class="badge bg-success">Stock disponible: {{ product.stock }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger">Sin stock</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<form method="post" action="{% url 'add_to_cart' product.id %}" class="mt-4">
|
||||
{% csrf_token %}
|
||||
<div class="mb-3">
|
||||
<label for="quantity" class="form-label">Cantidad:</label>
|
||||
<input type="number" name="quantity" id="quantity" value="1" min="1" class="form-control" style="max-width: 100px;">
|
||||
<input type="number" name="quantity" id="quantity" value="1" min="1" max="{{ product.stock }}" class="form-control" style="max-width: 100px;" {% if product.stock == 0 %}disabled{% endif %}>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-lg">🛒 Agregar al Carrito</button>
|
||||
<button type="submit" class="btn btn-primary btn-lg" {% if product.stock == 0 %}disabled{% endif %}>🛒 Agregar al Carrito</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+161
-18
@@ -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:
|
||||
@@ -605,8 +616,24 @@ def add_to_cart(request: HttpRequest, product_id: int):
|
||||
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(
|
||||
@@ -633,6 +660,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.")
|
||||
return redirect('index')
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -653,9 +688,20 @@ def update_cart_item(request: HttpRequest, item_id: int):
|
||||
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})
|
||||
@@ -788,6 +842,15 @@ def crear_producto(request: HttpRequest):
|
||||
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)
|
||||
except Category.DoesNotExist:
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user