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 %}
|
{% if cart_items %}
|
||||||
<div class="row mt-4">
|
<div class="row mt-4">
|
||||||
<div class="col-md-8">
|
<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">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<table class="table">
|
<table class="table">
|
||||||
@@ -20,6 +31,7 @@
|
|||||||
<th>Producto</th>
|
<th>Producto</th>
|
||||||
<th>Precio (sin IVA)</th>
|
<th>Precio (sin IVA)</th>
|
||||||
<th>Cantidad</th>
|
<th>Cantidad</th>
|
||||||
|
<th>Stock</th>
|
||||||
<th>Subtotal (con IVA)</th>
|
<th>Subtotal (con IVA)</th>
|
||||||
<th>Acciones</th>
|
<th>Acciones</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -39,10 +51,17 @@
|
|||||||
<td>
|
<td>
|
||||||
<form method="post" action="{% url 'update_cart_item' item.id %}" class="d-flex align-items-center" style="max-width: 150px;">
|
<form method="post" action="{% url 'update_cart_item' item.id %}" class="d-flex align-items-center" style="max-width: 150px;">
|
||||||
{% csrf_token %}
|
{% 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>
|
<button type="submit" class="btn btn-sm btn-primary">Actualizar</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</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 class="price">{{ item.get_subtotal_with_vat|format_price }} €</td>
|
||||||
<td>
|
<td>
|
||||||
<form method="post" action="{% url 'remove_from_cart' item.id %}" style="display: inline;">
|
<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>
|
<strong class="price">{{ cart.get_total_with_vat|format_price }} €</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-grid gap-2">
|
<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>
|
<a href="{% url 'index' %}" class="btn btn-outline-secondary">Continuar Comprando</a>
|
||||||
<form method="post" action="{% url 'clear_cart' %}">
|
<form method="post" action="{% url 'clear_cart' %}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|||||||
@@ -49,6 +49,23 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if cart_items %}
|
{% 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 mb-4">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="card-title mb-3">1) Selecciona la dirección de envío</h5>
|
<h5 class="card-title mb-3">1) Selecciona la dirección de envío</h5>
|
||||||
@@ -80,6 +97,7 @@
|
|||||||
<th>Producto</th>
|
<th>Producto</th>
|
||||||
<th class="text-end">Precio (sin IVA)</th>
|
<th class="text-end">Precio (sin IVA)</th>
|
||||||
<th class="text-end">Cantidad</th>
|
<th class="text-end">Cantidad</th>
|
||||||
|
<th class="text-end">Stock actual</th>
|
||||||
<th class="text-end">Subtotal (con IVA)</th>
|
<th class="text-end">Subtotal (con IVA)</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -89,6 +107,7 @@
|
|||||||
<td>{{ item.product.name }}</td>
|
<td>{{ item.product.name }}</td>
|
||||||
<td class="text-end">{{ item.product.price|format_price }}€</td>
|
<td class="text-end">{{ item.product.price|format_price }}€</td>
|
||||||
<td class="text-end">{{ item.quantity }}</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>
|
<td class="text-end">{{ item.get_subtotal_with_vat|format_price }}€</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -118,7 +137,7 @@
|
|||||||
class="btn btn-primary payment-btn"
|
class="btn btn-primary payment-btn"
|
||||||
data-config-url="/tienda/config/"
|
data-config-url="/tienda/config/"
|
||||||
data-session-url="/tienda/create-checkout-session/"
|
data-session-url="/tienda/create-checkout-session/"
|
||||||
{% if not addresses %}disabled{% endif %}>
|
{% if not addresses or stock_issues %}disabled{% endif %}>
|
||||||
💳 Pagar con Stripe
|
💳 Pagar con Stripe
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -126,7 +145,7 @@
|
|||||||
id="paypal-button"
|
id="paypal-button"
|
||||||
class="btn btn-warning payment-btn"
|
class="btn btn-warning payment-btn"
|
||||||
data-payment-url="{% url 'create_paypal_payment' %}"
|
data-payment-url="{% url 'create_paypal_payment' %}"
|
||||||
{% if not addresses %}disabled{% endif %}>
|
{% if not addresses or stock_issues %}disabled{% endif %}>
|
||||||
🅿️ Pagar con PayPal
|
🅿️ Pagar con PayPal
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -139,7 +158,9 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Manejo del botón de PayPal
|
// 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();
|
e.preventDefault();
|
||||||
|
|
||||||
const shippingAddressSelect = document.getElementById('shipping-address');
|
const shippingAddressSelect = document.getElementById('shipping-address');
|
||||||
@@ -202,5 +223,6 @@
|
|||||||
button.innerHTML = originalText;
|
button.innerHTML = originalText;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -46,6 +46,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Categoría -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="category" class="form-label">Categoría <span class="text-danger">*</span></label>
|
<label for="category" class="form-label">Categoría <span class="text-danger">*</span></label>
|
||||||
|
|||||||
@@ -46,6 +46,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Categoría -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="category" class="form-label">Categoría <span class="text-danger">*</span></label>
|
<label for="category" class="form-label">Categoría <span class="text-danger">*</span></label>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
<th>Categoría</th>
|
<th>Categoría</th>
|
||||||
<th class="text-end">Precio (sin IVA)</th>
|
<th class="text-end">Precio (sin IVA)</th>
|
||||||
<th class="text-end">Precio (con IVA)</th>
|
<th class="text-end">Precio (con IVA)</th>
|
||||||
|
<th class="text-end">Stock</th>
|
||||||
<th class="text-end">Acciones</th>
|
<th class="text-end">Acciones</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -53,6 +54,7 @@
|
|||||||
<td>{{ producto.category.name }}</td>
|
<td>{{ producto.category.name }}</td>
|
||||||
<td class="text-end">{{ producto.price|format_price }}€</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 text-success"><strong>{{ producto.get_price_with_vat|format_price }}€</strong></td>
|
||||||
|
<td class="text-end">{{ producto.stock }}</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
<div class="d-flex justify-content-end gap-2">
|
<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>
|
<a href="{% url 'editar_producto' producto.id %}" class="btn btn-outline-primary btn-sm">Editar</a>
|
||||||
|
|||||||
@@ -39,14 +39,21 @@
|
|||||||
<div id="descripcion">
|
<div id="descripcion">
|
||||||
{{ product.briefdesc }}
|
{{ product.briefdesc }}
|
||||||
</div>
|
</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">
|
<form method="post" action="{% url 'add_to_cart' product.id %}" class="mt-4">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="quantity" class="form-label">Cantidad:</label>
|
<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>
|
</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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</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)
|
).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:
|
if not product_ids:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
@@ -344,27 +344,38 @@ def _get_reserved_quantities_by_product(product_ids, exclude_reservation_id=None
|
|||||||
reservation__status=StockReservation.STATUS_ACTIVE,
|
reservation__status=StockReservation.STATUS_ACTIVE,
|
||||||
reservation__expires_at__gt=timezone.now(),
|
reservation__expires_at__gt=timezone.now(),
|
||||||
)
|
)
|
||||||
if exclude_reservation_id:
|
if exclude_reservation_ids:
|
||||||
reserved_qs = reserved_qs.exclude(reservation_id=exclude_reservation_id)
|
reserved_qs = reserved_qs.exclude(reservation_id__in=exclude_reservation_ids)
|
||||||
|
|
||||||
reserved_totals = reserved_qs.values("product_id").annotate(total_reserved=models.Sum("quantity"))
|
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}
|
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()
|
_release_expired_stock_reservations()
|
||||||
products = Product.objects.filter(id__in=product_ids)
|
products = Product.objects.filter(id__in=product_ids)
|
||||||
stocks = {product.id: product.stock for product in products}
|
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 {
|
return {
|
||||||
product_id: max(stocks.get(product_id, 0) - reserved.get(product_id, 0), 0)
|
product_id: max(stocks.get(product_id, 0) - reserved.get(product_id, 0), 0)
|
||||||
for product_id in product_ids
|
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]
|
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 = []
|
issues = []
|
||||||
for item in cart_items:
|
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(
|
reserved_from_others = _get_reserved_quantities_by_product(
|
||||||
product_ids,
|
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:
|
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)
|
product = Product.objects.get(id=product_id)
|
||||||
cart = get_or_create_cart(request)
|
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
|
# Obtener cantidad del POST o por defecto 1
|
||||||
quantity = int(request.POST.get('quantity', 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
|
# Buscar si ya existe el producto en el carrito
|
||||||
cart_item, created = CartItem.objects.get_or_create(
|
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')
|
return redirect('view_cart')
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
messages.error(request, "Cantidad no válida.")
|
||||||
|
return redirect('producto', id=product_id)
|
||||||
|
|
||||||
except Product.DoesNotExist:
|
except Product.DoesNotExist:
|
||||||
messages.error(request, "Producto no encontrado.")
|
messages.error(request, "Producto no encontrado.")
|
||||||
return redirect('index')
|
return redirect('index')
|
||||||
@@ -641,9 +672,13 @@ def add_to_cart(request: HttpRequest, product_id: int):
|
|||||||
def view_cart(request: HttpRequest):
|
def view_cart(request: HttpRequest):
|
||||||
"""Muestra el contenido del carrito"""
|
"""Muestra el contenido del carrito"""
|
||||||
cart = get_or_create_cart(request)
|
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", {
|
return render(request, "tienda/cart.html", {
|
||||||
"cart": cart,
|
"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 = get_or_create_cart(request)
|
||||||
cart_item = CartItem.objects.get(id=item_id, cart=cart)
|
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))
|
quantity = int(request.POST.get('quantity', 1))
|
||||||
|
|
||||||
if quantity > 0:
|
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.quantity = quantity
|
||||||
cart_item.save()
|
cart_item.save()
|
||||||
messages.success(request, "Cantidad actualizada.")
|
messages.success(request, "Cantidad actualizada.")
|
||||||
@@ -668,12 +714,17 @@ def update_cart_item(request: HttpRequest, item_id: int):
|
|||||||
except CartItem.DoesNotExist:
|
except CartItem.DoesNotExist:
|
||||||
messages.error(request, "Producto no encontrado en el carrito.")
|
messages.error(request, "Producto no encontrado en el carrito.")
|
||||||
return redirect('view_cart')
|
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):
|
def remove_from_cart(request: HttpRequest, item_id: int):
|
||||||
"""Elimina un producto del carrito"""
|
"""Elimina un producto del carrito"""
|
||||||
try:
|
try:
|
||||||
cart = get_or_create_cart(request)
|
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)
|
cart_item = CartItem.objects.get(id=item_id, cart=cart)
|
||||||
product_name = cart_item.product.name
|
product_name = cart_item.product.name
|
||||||
cart_item.delete()
|
cart_item.delete()
|
||||||
@@ -688,6 +739,8 @@ def remove_from_cart(request: HttpRequest, item_id: int):
|
|||||||
def clear_cart(request: HttpRequest):
|
def clear_cart(request: HttpRequest):
|
||||||
"""Vacía todo el carrito"""
|
"""Vacía todo el carrito"""
|
||||||
cart = get_or_create_cart(request)
|
cart = get_or_create_cart(request)
|
||||||
|
_cancel_active_stock_reservations_for_request(request)
|
||||||
|
_clear_stock_reservation_session(request)
|
||||||
cart.items.all().delete()
|
cart.items.all().delete()
|
||||||
messages.success(request, "Carrito vaciado.")
|
messages.success(request, "Carrito vaciado.")
|
||||||
return redirect('view_cart')
|
return redirect('view_cart')
|
||||||
@@ -769,12 +822,13 @@ def crear_producto(request: HttpRequest):
|
|||||||
briefdesc = request.POST.get("briefdesc")
|
briefdesc = request.POST.get("briefdesc")
|
||||||
description = request.POST.get("description")
|
description = request.POST.get("description")
|
||||||
price = request.POST.get("price")
|
price = request.POST.get("price")
|
||||||
|
stock = request.POST.get("stock")
|
||||||
category_id = request.POST.get("category")
|
category_id = request.POST.get("category")
|
||||||
primary_image_file = request.FILES.get("primary_image")
|
primary_image_file = request.FILES.get("primary_image")
|
||||||
secondary_images_files = request.FILES.getlist("secondary_images")
|
secondary_images_files = request.FILES.getlist("secondary_images")
|
||||||
|
|
||||||
# Validaciones
|
# 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.")
|
messages.error(request, "Por favor completa todos los campos obligatorios.")
|
||||||
categories = Category.objects.all()
|
categories = Category.objects.all()
|
||||||
return render(request, "tienda/crear_producto.html", {"categories": categories})
|
return render(request, "tienda/crear_producto.html", {"categories": categories})
|
||||||
@@ -788,6 +842,15 @@ def crear_producto(request: HttpRequest):
|
|||||||
categories = Category.objects.all()
|
categories = Category.objects.all()
|
||||||
return render(request, "tienda/crear_producto.html", {"categories": categories})
|
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:
|
try:
|
||||||
category = Category.objects.get(id=category_id)
|
category = Category.objects.get(id=category_id)
|
||||||
except Category.DoesNotExist:
|
except Category.DoesNotExist:
|
||||||
@@ -809,6 +872,7 @@ def crear_producto(request: HttpRequest):
|
|||||||
briefdesc=briefdesc or "",
|
briefdesc=briefdesc or "",
|
||||||
description=description,
|
description=description,
|
||||||
price=price,
|
price=price,
|
||||||
|
stock=stock,
|
||||||
category=category,
|
category=category,
|
||||||
primary_image=primary_image,
|
primary_image=primary_image,
|
||||||
creator=request.user
|
creator=request.user
|
||||||
@@ -841,11 +905,12 @@ def editar_producto(request: HttpRequest, id: int):
|
|||||||
briefdesc = request.POST.get("briefdesc")
|
briefdesc = request.POST.get("briefdesc")
|
||||||
description = request.POST.get("description")
|
description = request.POST.get("description")
|
||||||
price = request.POST.get("price")
|
price = request.POST.get("price")
|
||||||
|
stock = request.POST.get("stock")
|
||||||
category_id = request.POST.get("category")
|
category_id = request.POST.get("category")
|
||||||
primary_image_file = request.FILES.get("primary_image")
|
primary_image_file = request.FILES.get("primary_image")
|
||||||
secondary_images_files = request.FILES.getlist("secondary_images")
|
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.")
|
messages.error(request, "Por favor completa todos los campos obligatorios.")
|
||||||
categories = Category.objects.all()
|
categories = Category.objects.all()
|
||||||
return render(request, "tienda/editar_producto.html", {
|
return render(request, "tienda/editar_producto.html", {
|
||||||
@@ -865,6 +930,18 @@ def editar_producto(request: HttpRequest, id: int):
|
|||||||
"producto": producto
|
"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:
|
try:
|
||||||
category = Category.objects.get(id=category_id)
|
category = Category.objects.get(id=category_id)
|
||||||
except Category.DoesNotExist:
|
except Category.DoesNotExist:
|
||||||
@@ -879,6 +956,7 @@ def editar_producto(request: HttpRequest, id: int):
|
|||||||
producto.briefdesc = briefdesc or ""
|
producto.briefdesc = briefdesc or ""
|
||||||
producto.description = description
|
producto.description = description
|
||||||
producto.price = price
|
producto.price = price
|
||||||
|
producto.stock = stock
|
||||||
producto.category = category
|
producto.category = category
|
||||||
|
|
||||||
if primary_image_file:
|
if primary_image_file:
|
||||||
@@ -925,12 +1003,16 @@ def borrar_producto(request: HttpRequest, id: int):
|
|||||||
@login_required
|
@login_required
|
||||||
def checkout(request: HttpRequest):
|
def checkout(request: HttpRequest):
|
||||||
cart = get_or_create_cart(request)
|
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)
|
addresses = ShippingAddress.objects.filter(user=request.user)
|
||||||
return render(request, "tienda/checkout.html", {
|
return render(request, "tienda/checkout.html", {
|
||||||
"cart": cart,
|
"cart": cart,
|
||||||
"cart_items": cart_items,
|
"cart_items": cart_items,
|
||||||
"addresses": addresses,
|
"addresses": addresses,
|
||||||
|
"stock_issues": stock_issues,
|
||||||
|
"reservation_minutes": STOCK_RESERVATION_MINUTES,
|
||||||
})
|
})
|
||||||
|
|
||||||
@csrf_exempt
|
@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)
|
return JsonResponse({"error": "Debes seleccionar una dirección de envío válida."}, status=400)
|
||||||
|
|
||||||
cart = get_or_create_cart(request)
|
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)
|
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
|
stripe.api_key = settings.STRIPE_SECRET_KEY
|
||||||
|
|
||||||
line_items = []
|
line_items = []
|
||||||
@@ -995,6 +1090,8 @@ def create_checkout_session(request: HttpRequest):
|
|||||||
|
|
||||||
request.session['stripe_session_id'] = session.id
|
request.session['stripe_session_id'] = session.id
|
||||||
request.session['selected_shipping_address_id'] = shipping_address.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})
|
return JsonResponse({"sessionId": session.id})
|
||||||
except Exception as e:
|
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)
|
return JsonResponse({"error": f"Error al crear sesión de pago: {str(e)}"}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
def checkout_success(request: HttpRequest):
|
def checkout_success(request: HttpRequest):
|
||||||
payment_reference = request.session.get('stripe_session_id', "")
|
payment_reference = request.session.get('stripe_session_id', "")
|
||||||
shipping_address_id = request.session.get('selected_shipping_address_id')
|
shipping_address_id = request.session.get('selected_shipping_address_id')
|
||||||
shipping_address = ShippingAddress.objects.filter(id=shipping_address_id, user=request.user).first()
|
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:
|
if 'stripe_session_id' in request.session:
|
||||||
del request.session['stripe_session_id']
|
del request.session['stripe_session_id']
|
||||||
if 'selected_shipping_address_id' in request.session:
|
if 'selected_shipping_address_id' in request.session:
|
||||||
del request.session['selected_shipping_address_id']
|
del request.session['selected_shipping_address_id']
|
||||||
|
_clear_stock_reservation_session(request)
|
||||||
messages.success(request, "Pago realizado correctamente. ¡Gracias por tu compra!")
|
messages.success(request, "Pago realizado correctamente. ¡Gracias por tu compra!")
|
||||||
return render(request, "tienda/checkout_success.html", {"order": order})
|
return render(request, "tienda/checkout_success.html", {"order": order})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
def checkout_cancel(request: HttpRequest):
|
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.")
|
messages.info(request, "Pago cancelado. Puedes intentarlo de nuevo cuando quieras.")
|
||||||
return render(request, "tienda/checkout_cancel.html", {})
|
return render(request, "tienda/checkout_cancel.html", {})
|
||||||
|
|
||||||
@@ -1057,11 +1171,24 @@ def create_paypal_payment(request: HttpRequest):
|
|||||||
import paypalrestsdk
|
import paypalrestsdk
|
||||||
|
|
||||||
cart = get_or_create_cart(request)
|
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)
|
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
|
# Configurar PayPal
|
||||||
paypalrestsdk.configure({
|
paypalrestsdk.configure({
|
||||||
"mode": settings.PAYPAL_MODE,
|
"mode": settings.PAYPAL_MODE,
|
||||||
@@ -1124,6 +1251,8 @@ def create_paypal_payment(request: HttpRequest):
|
|||||||
# Guardar el payment ID en sesión
|
# Guardar el payment ID en sesión
|
||||||
request.session['paypal_payment_id'] = payment.id
|
request.session['paypal_payment_id'] = payment.id
|
||||||
request.session['selected_shipping_address_id'] = shipping_address.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
|
# Encontrar el link de aprobación
|
||||||
for link in payment.links:
|
for link in payment.links:
|
||||||
@@ -1178,13 +1307,25 @@ def paypal_execute(request: HttpRequest):
|
|||||||
# Pago exitoso - crear pedido y limpiar el carrito
|
# Pago exitoso - crear pedido y limpiar el carrito
|
||||||
shipping_address_id = request.session.get('selected_shipping_address_id')
|
shipping_address_id = request.session.get('selected_shipping_address_id')
|
||||||
shipping_address = ShippingAddress.objects.filter(id=shipping_address_id, user=request.user).first()
|
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
|
# Limpiar la sesión
|
||||||
if 'paypal_payment_id' in request.session:
|
if 'paypal_payment_id' in request.session:
|
||||||
del request.session['paypal_payment_id']
|
del request.session['paypal_payment_id']
|
||||||
if 'selected_shipping_address_id' in request.session:
|
if 'selected_shipping_address_id' in request.session:
|
||||||
del request.session['selected_shipping_address_id']
|
del request.session['selected_shipping_address_id']
|
||||||
|
_clear_stock_reservation_session(request)
|
||||||
|
|
||||||
messages.success(request, "¡Pago realizado correctamente con PayPal! Gracias por tu compra.")
|
messages.success(request, "¡Pago realizado correctamente con PayPal! Gracias por tu compra.")
|
||||||
return render(request, "tienda/checkout_success.html", {"order": order})
|
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))
|
logger.exception("PAYPAL_EXECUTE_EXCEPTION user_id=%s error=%s", request.user.id, str(e))
|
||||||
messages.error(request, f"Error: {str(e)}")
|
messages.error(request, f"Error: {str(e)}")
|
||||||
return redirect("checkout")
|
return redirect("checkout")
|
||||||
|
|
||||||
|
|
||||||
def search_suggestions(request: HttpRequest):
|
def search_suggestions(request: HttpRequest):
|
||||||
"""API AJAX que retorna sugerencias de búsqueda en JSON"""
|
"""API AJAX que retorna sugerencias de búsqueda en JSON"""
|
||||||
query = request.GET.get('q', '').strip()
|
query = request.GET.get('q', '').strip()
|
||||||
|
|||||||
Reference in New Issue
Block a user