Merge pull request #1 from dsaub/development

Implement stock management, PDF generation, and accessibility updates
This commit is contained in:
Daniel (elordenador)
2026-04-09 10:52:11 +02:00
committed by GitHub
98 changed files with 1038 additions and 46 deletions
+29
View File
@@ -0,0 +1,29 @@
---
description: "Use when creating or updating Markdown documentation in docs/; writes polished docs with GitHub-style NOTE/IMPORTANT/CAUTION callouts and no terminal use."
name: "Docs Writer"
tools: [read, edit, search]
user-invocable: true
---
You are a documentation specialist for this repository. Your job is to create and improve Markdown documentation files under docs/ only.
## Constraints
- DO NOT use the terminal.
- DO NOT edit files outside docs/.
- MUST use only Python files inside tienda/ to make the documentations
- ONLY create or modify Markdown documentation.
- Prefer concise, well-structured prose.
- Use GitHub-style Markdown callouts for important information: > [!NOTE], > [!IMPORTANT], > [!CAUTION], and > [!TIP].
- Keep wording clear and practical.
## Approach
1. Inspect the existing documentation and related code only as needed.
2. Write content in Markdown with headings, short paragraphs, and callouts where useful.
3. Keep changes strictly scoped to documentation files under docs/.
4. Docs must follow this examples:
- tienda/views.py#function -> docs/views/function.md
- tienda/views.py#another_function -> docs/views/another_function.md
- tienda/pdf.py#class#function -> docs/pdf/class/function.md
5. Every folder in the docs folder must have a INDEX.md with a beautiful list of every md file in the folder and then every index.md of the folders in that folder.
## Output Format
Return the docs files changed and a short summary of what was added or improved.
+1
View File
@@ -77,6 +77,7 @@ Templates use Django's inheritance pattern:
- Bootstrap requires JavaScript for modals/toggles (reference data-bs-* attributes in base.html) - Bootstrap requires JavaScript for modals/toggles (reference data-bs-* attributes in base.html)
- SECRET_KEY is development-only (contains 'insecure' marker) - SECRET_KEY is development-only (contains 'insecure' marker)
- DEBUG=True - not production-ready - DEBUG=True - not production-ready
- Search for a virtual environment in the project files, if there is one use it, if not, create it.
## When Adding Features ## When Adding Features
- New models: Add to [tienda/models.py](tienda/models.py), run migrations, register in [tienda/admin.py](tienda/admin.py) - New models: Add to [tienda/models.py](tienda/models.py), run migrations, register in [tienda/admin.py](tienda/admin.py)
+2 -1
View File
@@ -14,5 +14,6 @@ COPY . /app/
RUN chmod +x /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh
EXPOSE 8000 EXPOSE 8000
RUN mkdir -pv /fonts
COPY tienda/static/fonts/ /fonts/
CMD ["/app/entrypoint.sh"] CMD ["/app/entrypoint.sh"]
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -1
View File
@@ -42,4 +42,5 @@ urllib3==2.6.3
vine==5.1.0 vine==5.1.0
wcwidth==0.6.0 wcwidth==0.6.0
whitenoise==6.12.0 whitenoise==6.12.0
mysqlclient mysqlclient
fpdf2==2.8.7
Executable
+101
View File
@@ -0,0 +1,101 @@
#!/bin/bash
set -u
readonly HOSTS=(
"aws-docker-mysql"
"aws-docker-redis"
"aws-docker-celery"
"aws-docker"
)
readonly WAIT_SECONDS=5
readonly REMOTE_DEPLOY_DIR="/root/deploys"
usage() {
echo "Uso: $0 {start|stop|restart|update}"
}
print_status() {
local action="$1"
local host="$2"
local status="$3"
# Estilo similar al output de OpenRC.
printf "* %-8s %-16s [%s]\n" "$action" "$host" "$status"
}
run_remote_compose() {
local host="$1"
local command="$2"
ssh -o BatchMode=yes -o LogLevel=ERROR -T "$host" "sudo -n sh -c \"cd '$REMOTE_DEPLOY_DIR' || exit 1; if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then docker compose $command; elif command -v docker-compose >/dev/null 2>&1; then docker-compose $command; else exit 1; fi\"" >/dev/null 2>&1
}
run_for_all_hosts() {
local mode="$1"
local host=""
local i=0
local total=${#HOSTS[@]}
for host in "${HOSTS[@]}"; do
case "$mode" in
start)
if run_remote_compose "$host" "up -d"; then
print_status "Started" "$host" "ok"
else
print_status "Started" "$host" "fail"
exit 1
fi
;;
stop)
if run_remote_compose "$host" "down"; then
print_status "Stopped" "$host" "ok"
else
print_status "Stopped" "$host" "fail"
exit 1
fi
;;
restart)
if run_remote_compose "$host" "down" && run_remote_compose "$host" "up -d"; then
print_status "Restarted" "$host" "ok"
else
print_status "Restarted" "$host" "fail"
exit 1
fi
;;
update)
if run_remote_compose "$host" "pull" && run_remote_compose "$host" "down" && run_remote_compose "$host" "up -d"; then
print_status "Updated" "$host" "ok"
else
print_status "Updated" "$host" "fail"
exit 1
fi
;;
*)
usage
exit 1
;;
esac
i=$((i + 1))
if [ "$i" -lt "$total" ]; then
sleep "$WAIT_SECONDS"
fi
done
}
if [ "$#" -ne 1 ]; then
usage
exit 1
fi
case "$1" in
start|stop|restart|update)
run_for_all_hosts "$1"
;;
*)
usage
exit 1
;;
esac
+27
View File
@@ -0,0 +1,27 @@
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="padding: 20px;">
<table width="600" border="0" cellspacing="0" cellpadding="0" style="border: 1px solid #eeeeee; background-color: #ffffff;">
<tr>
<td align="center" style="background-color: #007bff; padding: 40px;">
<h1 style="color: #ffffff; font-family: sans-serif; margin: 0;">¡Hola {{ name }}!</h1>
</td>
</tr>
<tr>
<td align="center" style="padding: 40px">
<svg fill="#FF0000" width="128px" height="128px" viewBox="-3.2 -3.2 38.40 38.40" version="1.1" xmlns="http://www.w3.org/2000/svg" stroke="#FF0000" stroke-width="0.00032"><g id="SVGRepo_bgCarrier" stroke-width="0" transform="translate(6.4,6.4), scale(0.6)"><rect x="-3.2" y="-3.2" width="38.40" height="38.40" rx="19.2" fill="#1a5fb4" strokewidth="0"></rect></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round" stroke="#CCCCCC" stroke-width="1.152"> <title>alert</title> <path d="M14.611 18.856c-0.346 0.352-0.52 0.782-0.52 1.292 0 0.551 0.197 1.014 0.59 1.389 0.363 0.346 0.799 0.519 1.309 0.519 0.521 0 0.971-0.188 1.346-0.566s0.562-0.828 0.562-1.35c0-0.504-0.182-0.943-0.545-1.318-0.363-0.381-0.801-0.571-1.311-0.571-0.567-0.001-1.044 0.201-1.431 0.605v0zM14.391 10.788c-0.299 0.451-0.447 1.011-0.447 1.679 0 0.545 0.092 1.146 0.276 1.802s0.435 1.271 0.751 1.846c0.428 0.779 0.76 1.169 0.994 1.169 0.24 0 0.557-0.305 0.949-0.914 0.346-0.539 0.622-1.152 0.83-1.841s0.312-1.332 0.312-1.93c0-0.902-0.244-1.6-0.73-2.092-0.363-0.375-0.805-0.563-1.326-0.563-0.703 0-1.24 0.282-1.609 0.844v0z"></path> </g><g id="SVGRepo_iconCarrier"> <title>alert</title> <path d="M14.611 18.856c-0.346 0.352-0.52 0.782-0.52 1.292 0 0.551 0.197 1.014 0.59 1.389 0.363 0.346 0.799 0.519 1.309 0.519 0.521 0 0.971-0.188 1.346-0.566s0.562-0.828 0.562-1.35c0-0.504-0.182-0.943-0.545-1.318-0.363-0.381-0.801-0.571-1.311-0.571-0.567-0.001-1.044 0.201-1.431 0.605v0zM14.391 10.788c-0.299 0.451-0.447 1.011-0.447 1.679 0 0.545 0.092 1.146 0.276 1.802s0.435 1.271 0.751 1.846c0.428 0.779 0.76 1.169 0.994 1.169 0.24 0 0.557-0.305 0.949-0.914 0.346-0.539 0.622-1.152 0.83-1.841s0.312-1.332 0.312-1.93c0-0.902-0.244-1.6-0.73-2.092-0.363-0.375-0.805-0.563-1.326-0.563-0.703 0-1.24 0.282-1.609 0.844v0z"></path> </g></svg>
</td>
</tr>
<tr>
<td style="padding: 30px; font-family: sans-serif; line-height: 1.5; color: #444444;">
<p>¡Alguien esta intentando cambiar la contraseña de tu cuenta!</p>
<p>Si has sido tu, haga click en el siguiente enlace. Si no, <strong>Elimine el correo de inmediato</strong></p>
<p></p>
<p>Para resetear tu contraseña, <a href="{{ protocol }}://{{ domain }}/tienda/reset-password-phase2/{{ code }}">Haga click aqui</a></p>
<p>Este email ha sido enviado automaticamente, no responda a este correo.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
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.
+24 -5
View File
@@ -1,12 +1,18 @@
from django.contrib import admin from django.contrib import admin
from .models import Category, Image, Product, Cart, CartItem, Order, OrderItem, OrderMessage, User, VerificationCode from .models import Category, Image, Product, Cart, CartItem, Order, OrderItem, OrderMessage, StockReservation, StockReservationItem, User, VerificationCode
# Register your models here. # Register your models here.
admin.site.register(Category) admin.site.register(Category)
admin.site.register(Image) admin.site.register(Image)
admin.site.register(Product)
admin.site.register(User) admin.site.register(User)
admin.site.register(VerificationCode) admin.site.register(VerificationCode)
@admin.register(Product)
class ProductAdmin(admin.ModelAdmin):
list_display = ('id', 'name', 'price', 'stock', 'category', 'creator')
search_fields = ('name', 'creator__username', 'creator__email')
list_filter = ('category',)
class CartItemInline(admin.TabularInline): class CartItemInline(admin.TabularInline):
model = CartItem model = CartItem
extra = 0 extra = 0
@@ -46,9 +52,9 @@ class OrderItemInline(admin.TabularInline):
@admin.register(Order) @admin.register(Order)
class OrderAdmin(admin.ModelAdmin): class OrderAdmin(admin.ModelAdmin):
list_display = ('id', 'buyer', 'total', 'status', 'payment_method', 'payment_reference', 'created_at') list_display = ('id', 'transaction_code', 'buyer', 'total', 'status', 'payment_method', 'payment_reference', 'created_at')
list_filter = ('status', 'payment_method', 'created_at') list_filter = ('status', 'payment_method', 'created_at')
search_fields = ('buyer__username', 'buyer__email', 'payment_reference') search_fields = ('buyer__username', 'buyer__email', 'payment_reference', 'transaction_code')
inlines = [OrderItemInline] inlines = [OrderItemInline]
@@ -67,4 +73,17 @@ class OrderMessageAdmin(admin.ModelAdmin):
def message_preview(self, obj): def message_preview(self, obj):
return obj.message[:50] + "..." if len(obj.message) > 50 else obj.message return obj.message[:50] + "..." if len(obj.message) > 50 else obj.message
message_preview.short_description = 'Mensaje' message_preview.short_description = 'Mensaje'
class StockReservationItemInline(admin.TabularInline):
model = StockReservationItem
extra = 0
@admin.register(StockReservation)
class StockReservationAdmin(admin.ModelAdmin):
list_display = ('id', 'user', 'session_key', 'status', 'payment_method', 'expires_at', 'created_at')
list_filter = ('status', 'payment_method', 'created_at')
search_fields = ('user__username', 'user__email', 'session_key')
inlines = [StockReservationItemInline]
@@ -0,0 +1,41 @@
# Generated by Django 6.0.1 on 2026-04-09
from django.db import migrations, models
from django.utils.crypto import get_random_string
TRANSACTION_CODE_PREFIX = "comal-"
TRANSACTION_CODE_LENGTH = 32
TRANSACTION_CODE_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
def populate_transaction_codes(apps, schema_editor):
Order = apps.get_model("tienda", "Order")
for order in Order.objects.filter(transaction_code__isnull=True):
while True:
code = f"{TRANSACTION_CODE_PREFIX}{get_random_string(TRANSACTION_CODE_LENGTH, TRANSACTION_CODE_ALPHABET)}"
if not Order.objects.filter(transaction_code=code).exists():
order.transaction_code = code
order.save(update_fields=["transaction_code"])
break
def noop_reverse(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
("tienda", "0002_verificationcode_code_mode_and_more"),
]
operations = [
migrations.AddField(
model_name="order",
name="transaction_code",
field=models.CharField(blank=True, db_index=True, max_length=38, null=True, unique=True),
),
migrations.RunPython(populate_transaction_codes, noop_reverse),
]
@@ -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')},
},
),
]
+61 -1
View File
@@ -1,7 +1,17 @@
from django.db import models from django.db import models
from django.contrib.auth.models import User, AbstractUser from django.contrib.auth.models import User, AbstractUser
from .vars import VAT_RATE from django.utils.crypto import get_random_string
from .vars import VAT_RATE, TRANSACTION_CODE_PREFIX, TRANSACTION_CODE_LENGTH, TRANSACTION_CODE_ALPHABET
import random, string import random, string
def generate_transaction_code() -> str:
while True:
code = f"{TRANSACTION_CODE_PREFIX}{get_random_string(TRANSACTION_CODE_LENGTH, TRANSACTION_CODE_ALPHABET)}"
if not Order.objects.filter(transaction_code=code).exists():
return code
class User(AbstractUser): class User(AbstractUser):
class RegisterStatus(models.TextChoices): class RegisterStatus(models.TextChoices):
CONFIRMATION_REQUIRED = "CR", "Confirmation Required" CONFIRMATION_REQUIRED = "CR", "Confirmation Required"
@@ -47,6 +57,7 @@ class Product(models.Model):
description = models.TextField(default = "") description = models.TextField(default = "")
briefdesc = models.TextField(default = "") briefdesc = models.TextField(default = "")
price = models.FloatField(default = 0) price = models.FloatField(default = 0)
stock = models.PositiveIntegerField(default=0)
category = models.ForeignKey(Category, on_delete=models.CASCADE) category = models.ForeignKey(Category, on_delete=models.CASCADE)
primary_image = models.ForeignKey(Image, on_delete=models.SET_NULL, null=True) primary_image = models.ForeignKey(Image, on_delete=models.SET_NULL, null=True)
secondary_images = models.ManyToManyField(Image, related_name='products_secondary', blank=True) secondary_images = models.ManyToManyField(Image, related_name='products_secondary', blank=True)
@@ -64,6 +75,49 @@ class Product(models.Model):
return round(self.price * VAT_RATE, 2) return round(self.price * VAT_RATE, 2)
class StockReservation(models.Model):
STATUS_ACTIVE = "active"
STATUS_COMPLETED = "completed"
STATUS_CANCELLED = "cancelled"
STATUS_EXPIRED = "expired"
STATUS_CHOICES = [
(STATUS_ACTIVE, "Activa"),
(STATUS_COMPLETED, "Completada"),
(STATUS_CANCELLED, "Cancelada"),
(STATUS_EXPIRED, "Expirada"),
]
PAYMENT_STRIPE = "stripe"
PAYMENT_PAYPAL = "paypal"
PAYMENT_CHOICES = [
(PAYMENT_STRIPE, "Stripe"),
(PAYMENT_PAYPAL, "PayPal"),
]
user = models.ForeignKey(User, on_delete=models.CASCADE, null=True, blank=True, related_name="stock_reservations")
session_key = models.CharField(max_length=40, null=True, blank=True)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default=STATUS_ACTIVE)
payment_method = models.CharField(max_length=20, choices=PAYMENT_CHOICES)
expires_at = models.DateTimeField(db_index=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f"Reserva {self.id} - {self.user or self.session_key} ({self.status})"
class StockReservationItem(models.Model):
reservation = models.ForeignKey(StockReservation, on_delete=models.CASCADE, related_name="items")
product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name="stock_reservation_items")
quantity = models.PositiveIntegerField(default=1)
class Meta:
unique_together = ("reservation", "product")
def __str__(self):
return f"{self.quantity}x {self.product.name} (reserva {self.reservation_id})"
class Cart(models.Model): class Cart(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, null=True, blank=True) user = models.ForeignKey(User, on_delete=models.CASCADE, null=True, blank=True)
session_key = models.CharField(max_length=40, null=True, blank=True) session_key = models.CharField(max_length=40, null=True, blank=True)
@@ -136,12 +190,18 @@ class Order(models.Model):
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default=STATUS_PAID) status = models.CharField(max_length=20, choices=STATUS_CHOICES, default=STATUS_PAID)
payment_method = models.CharField(max_length=20, choices=PAYMENT_CHOICES, default=PAYMENT_MANUAL) payment_method = models.CharField(max_length=20, choices=PAYMENT_CHOICES, default=PAYMENT_MANUAL)
payment_reference = models.CharField(max_length=200, blank=True, default="") payment_reference = models.CharField(max_length=200, blank=True, default="")
transaction_code = models.CharField(max_length=38, unique=True, null=True, blank=True, db_index=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
def __str__(self): def __str__(self):
return f"Pedido {self.id} - {self.buyer or self.session_key}" return f"Pedido {self.id} - {self.buyer or self.session_key}"
def save(self, *args, **kwargs):
if not self.transaction_code:
self.transaction_code = generate_transaction_code()
super().save(*args, **kwargs)
def get_items_count(self): def get_items_count(self):
return sum(item.quantity for item in self.items.all()) return sum(item.quantity for item in self.items.all())
+50
View File
@@ -0,0 +1,50 @@
import os
from django.conf import settings
from fpdf import FPDF
import string, random
class Recibo(FPDF):
def header(self):
self.set_font('Arial', 'B', 15)
self.cell(0, 10, "Comercialmeria S.L")
self.cell(0, 10, 'RECIBO DE PAGO', ln=True, align='R')
self.ln(10)
def footer(self):
self.set_y(-15)
self.set_font('Arial', 'I', 8)
self.cell(0, 10, f'Pagina {self.page_no()}', align='C')
def generar_recibo(cliente: str, total: float, objetos: list, metodo_pago: str, transaction_code: str):
pdf = Recibo()
font_path = "/fonts/Roboto-Regular.ttf"
pdf.add_font('Roboto', '', '/fonts/Roboto-Regular.ttf')
pdf.add_font('Roboto', 'B', '/fonts/Roboto-Bold.ttf')
pdf.add_page()
pdf.set_font('Roboto', size=12)
pdf.cell(0, 10, f"Cliente: {cliente}", ln=True)
pdf.cell(0, 10, f"ID de transaccion: {transaction_code}", ln=True)
pdf.cell(0, 10, f"")
DATA = []
DATA.append(
("Cant.", "Nombre", "Precio Unit.", "Subtotal")
)
for i in objetos:
DATA.append(
(str(i["amount"]), str(i["product_name"]), str(i["price"]), str(float(i["price"])*int(i["amount"])))
)
pdf.cell(0, 10, "DETALLE DEL COBRO", ln=True, align='R')
pdf.ln(5)
with pdf.table() as table:
for data_row in DATA:
row = table.row()
for datum in data_row:
row.cell(datum)
pdf.ln(5)
pdf.set_font('Roboto', size=12)
pdf.cell(0, 10, f'TOTAL A PAGAR: {total}', align="R")
return pdf.output(dest="S")
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.
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.
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+55 -2
View File
@@ -1,9 +1,11 @@
from celery import shared_task from celery import shared_task
from django.conf import settings from django.conf import settings
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.core.mail import EmailMessage
from .utilities import send_email, send_hemail from .utilities import send_email, send_hemail
from .vars import login_message, verify_message from .vars import login_message, verify_message
import random, string import random, string
from . import pdf
from .models import User, VerificationCode from .models import User, VerificationCode
@shared_task @shared_task
@@ -18,7 +20,8 @@ def enviar_correo_bienvenida(email_usuario: str, nombre_usuario: str):
send_hemail(email_usuario, "Inicio de Sesión correcto", html_content, "Has iniciado sesión...") send_hemail(email_usuario, "Inicio de Sesión correcto", html_content, "Has iniciado sesión...")
@shared_task @shared_task
def enviar_correo_confirmacion(usuario: User): def enviar_correo_confirmacion(id: int):
usuario = User.objects.get(id=id)
code = VerificationCode.objects.create( code = VerificationCode.objects.create(
user = usuario, user = usuario,
code_mode = VerificationCode.VerificationModes.VERIFY_ACCOUNT, code_mode = VerificationCode.VerificationModes.VERIFY_ACCOUNT,
@@ -26,4 +29,54 @@ def enviar_correo_confirmacion(usuario: User):
) )
message = verify_message.format(name = usuario.get_full_name(), protocol = settings.PROTOCOL, domain = settings.DOMAIN, code = code.code) message = verify_message.format(name = usuario.get_full_name(), protocol = settings.PROTOCOL, domain = settings.DOMAIN, code = code.code)
email_result = send_email(usuario.email, "Verificación de cuenta", message) email_result = send_email(usuario.email, "Verificación de cuenta", message)
@shared_task
def enviar_correo_recuperacion(email: str):
usuario = User.objects.get(email=email)
if usuario is not None:
ver_code = VerificationCode.objects.create(
code_mode = VerificationCode.VerificationModes.RESET_PASSWORD,
user = usuario,
code = ''.join(random.choices(string.digits, k=12))
)
ver_code.save()
html_content = render_to_string(
'emails/reset_pass.html',
{
"name": usuario.get_full_name(),
"domain": settings.DOMAIN,
"protocol": settings.PROTOCOL,
"code": ver_code.code
},
using='jinja2'
)
send_hemail(email, "Reset de Contraseña", html_content, "Estas reseteando la contraseña...")
# Purchased items should be a list of dictionary, the dictionary must follow this tags: amount, product name, price (each)
@shared_task
def process_purchase(user_id: int, purchased_items: list, payment_method: str, transaction_code: str):
user = User.objects.get(id=user_id)
total = 0
for i in purchased_items:
total += i["price"]*i["amount"]
pdf_data = pdf.generar_recibo(
user.get_full_name(),
total,
purchased_items,
payment_method,
transaction_code,
)
email = EmailMessage(
subject="Tu recibo de compra",
body = "Hola, adjunto encontrarás el recibo de tu reciente transacción",
from_email = settings.DEFAULT_FROM_EMAIL,
to = [user.email]
)
email.attach("recibo.pdf", pdf_data, "application/pdf")
email.send()
+7 -6
View File
@@ -6,7 +6,8 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title> <meta name="description" content="Sitio web de comercio local Almeriense">
<title>Comercialmeria</title>
<link rel="preload" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" as="style" onload="this.onload=null;this.rel='stylesheet'"> <link rel="preload" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript> <noscript>
@@ -78,7 +79,7 @@
</head> </head>
<body> <body>
{% cache 500 sidebar request.user.username %} {% cache 500 sidebar request.user.username %}
<nav class="navbar navbar-expand-md header"> <nav class="navbar navbar-expand-md header" role="banner">
<div class="container-fluid"> <div class="container-fluid">
<a class="navbar-brand" href="{% url 'home' %}"> <a class="navbar-brand" href="{% url 'home' %}">
<picture> <picture>
@@ -100,7 +101,7 @@
</button> </button>
<div class="collapse navbar-collapse" id="navbarContent"> <div class="collapse navbar-collapse" id="navbarContent">
<span class="navbar-text fw-semibold me-3">Comercialmeria</span> <span class="navbar-text fw-semibold me-3" style="color: #ffffff">Comercialmeria</span>
<!-- Barra de búsqueda con sugerencias --> <!-- Barra de búsqueda con sugerencias -->
<form class="search-suggestions-container" method="GET" action="{% url 'search' %}" role="search" id="searchForm"> <form class="search-suggestions-container" method="GET" action="{% url 'search' %}" role="search" id="searchForm">
<div class="input-group"> <div class="input-group">
@@ -110,7 +111,7 @@
<div class="search-suggestions" id="searchSuggestions"></div> <div class="search-suggestions" id="searchSuggestions"></div>
</form> </form>
<div class="navbar-nav ms-auto d-flex align-items-md-center gap-2 flex-wrap"> <div class="navbar-nav ms-auto d-flex align-items-md-center gap-2 flex-wrap" role="navigation">
<a href="{% url 'view_cart' %}" class="nav-link position-relative btn btn-outline-primary btn-sm"> <a href="{% url 'view_cart' %}" class="nav-link position-relative btn btn-outline-primary btn-sm">
🛒 Carrito 🛒 Carrito
{% if cart_count > 0 %} {% if cart_count > 0 %}
@@ -134,7 +135,7 @@
</nav> </nav>
{% endcache %} {% endcache %}
<div class="container-fluid"> <div class="container-fluid" role="main">
<!-- Mensajes --> <!-- Mensajes -->
{% if messages %} {% if messages %}
<div class="row mt-3"> <div class="row mt-3">
@@ -154,7 +155,7 @@
{% cache 500 footer %} {% cache 500 footer %}
<!-- Footer--> <!-- Footer-->
<div id="footer" class="row pt-2 pb-2 mt-5"> <div id="footer" class="row pt-2 pb-2 mt-5" role="contentinfo">
<div class="col-md-12 grid"> <div class="col-md-12 grid">
<p class="text-center">Enlace 1</p> <p class="text-center">Enlace 1</p>
<p class="text-center">Enlace 2</p> <p class="text-center">Enlace 2</p>
+21 -2
View File
@@ -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 %}
+25 -3
View File
@@ -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 %}
@@ -7,6 +7,9 @@
<div class="alert alert-success p-5"> <div class="alert alert-success p-5">
<h2 class="mb-3">¡Pago completado!</h2> <h2 class="mb-3">¡Pago completado!</h2>
<p class="mb-4">Tu pedido ha sido procesado correctamente.</p> <p class="mb-4">Tu pedido ha sido procesado correctamente.</p>
{% if order and order.transaction_code %}
<p class="mb-4"><strong>ID de transacción:</strong> {{ order.transaction_code }}</p>
{% endif %}
<a href="{% url 'index' %}" class="btn btn-primary">Volver a la tienda</a> <a href="{% url 'index' %}" class="btn btn-primary">Volver a la tienda</a>
</div> </div>
</div> </div>
@@ -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>
+1 -1
View File
@@ -34,7 +34,7 @@
</div> </div>
<div class="mt-3 text-center"> <div class="mt-3 text-center">
<a href="#" class="text-decoration-none">¿Olvidaste tu contraseña?</a> <a href="{% url 'reset_password' %}" class="text-decoration-none">¿Olvidaste tu contraseña?</a>
</div> </div>
<hr class="my-3"> <hr class="my-3">
@@ -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>
+2
View File
@@ -34,6 +34,7 @@
<thead> <thead>
<tr> <tr>
<th>Recibo #</th> <th>Recibo #</th>
<th>ID Transacción</th>
<th>Fecha</th> <th>Fecha</th>
<th>Total</th> <th>Total</th>
<th>Método</th> <th>Método</th>
@@ -44,6 +45,7 @@
{% for receipt in receipts %} {% for receipt in receipts %}
<tr> <tr>
<td>{{ receipt.id }}</td> <td>{{ receipt.id }}</td>
<td>{{ receipt.transaction_code|default:"-" }}</td>
<td>{{ receipt.created_at|date:"d/m/Y H:i" }}</td> <td>{{ receipt.created_at|date:"d/m/Y H:i" }}</td>
<td>{{ receipt.total }}€</td> <td>{{ receipt.total }}€</td>
<td>{{ receipt.get_payment_method_display }}</td> <td>{{ receipt.get_payment_method_display }}</td>
+9 -2
View File
@@ -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>
@@ -0,0 +1,34 @@
{% extends "tienda/base.html" %}
{% load static %}
{% block content %}
<div class="row justify-content-center mt-5">
<div class="col-md-6 col-lg-4">
<div class="card">
<div class="card-header">
<h3 class="text-center mb-0">Recuperar contraseña</h3>
</div>
<div class="card-body">
<form method="post" action="{% url 'reset_password' %}">
{% csrf_token %}
<div class="mb-3">
<label for="loginEmail" class="form-label">Correo Electrónico</label>
<input type="email" class="form-control" id="loginEmail" name="email" required>
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary">Recuperar contraseña</button>
</div>
<hr class="my-3">
<div class="text-center">
<p class="mb-0">¿No tienes cuenta? <a href="{% url 'register' %}">Regístrate aquí</a></p>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,39 @@
{% extends "tienda/base.html" %}
{% load static %}
{% block content %}
<div class="row justify-content-center mt-5">
<div class="col-md-6 col-lg-4">
<div class="card">
<div class="card-header">
<h3 class="text-center mb-0">Recuperar contraseña</h3>
</div>
<div class="card-body">
<form method="post" action="{% url 'reset_password_phase2' code %}">
{% csrf_token %}
<div class="mb-3">
<label for="password" class="form-label">Contraseña</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<div class="mb-3">
<label for="verify_password" class="form-label">Verificar contraseña</label>
<input type="password" class="form-control" id="verify_password" name="verify_password" required>
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary">Recuperar contraseña</button>
</div>
<hr class="my-3">
<div class="text-center">
<p class="mb-0">¿No tienes cuenta? <a href="{% url 'register' %}">Regístrate aquí</a></p>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+3 -1
View File
@@ -45,5 +45,7 @@ urlpatterns = [
path("usuario/direcciones/<int:id>/eliminar/", views.eliminar_direccion, name="eliminar_direccion"), path("usuario/direcciones/<int:id>/eliminar/", views.eliminar_direccion, name="eliminar_direccion"),
path("usuario/mensajes/", views.mensajes_comprador, name="mensajes_comprador"), path("usuario/mensajes/", views.mensajes_comprador, name="mensajes_comprador"),
path("verify/<str:code>", views.verify, name="verify"), path("verify/<str:code>", views.verify, name="verify"),
path("rgpd", views.rgpd, name="rgpd") path("rgpd", views.rgpd, name="rgpd"),
path("reset-password", views.reset_password, name="reset_password"),
path("reset-password-phase2/<str:code>", views.reset_password_phase2, name="reset_password_phase2")
] ]
+5
View File
@@ -1,5 +1,10 @@
PAGE_SIZE = 20 PAGE_SIZE = 20
VAT_RATE = 0.21 # IVA 21% VAT_RATE = 0.21 # IVA 21%
STOCK_RESERVATION_MINUTES = 5
TRANSACTION_CODE_PREFIX = "comal-"
TRANSACTION_CODE_LENGTH = 32
TRANSACTION_CODE_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
# Restricciones de envío # Restricciones de envío
SHIPPING_COUNTRY = "España" SHIPPING_COUNTRY = "España"
+433 -21
View File
@@ -1,22 +1,25 @@
from django.shortcuts import render, redirect, get_object_or_404 from django.shortcuts import render, redirect, get_object_or_404
from django.http import HttpRequest, HttpResponse, JsonResponse from django.http import Http404, HttpRequest, HttpResponse, JsonResponse
from django.contrib.auth import authenticate, login as auth_login, logout as auth_logout from django.contrib.auth import authenticate, login as auth_login, logout as auth_logout
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.contrib import messages from django.contrib import messages
from .models import User, Product, Category, Cart, CartItem, Image, Order, OrderItem, OrderMessage, ShippingAddress, VerificationCode from .models import User, Product, Category, Cart, CartItem, Image, Order, OrderItem, OrderMessage, ShippingAddress, StockReservation, StockReservationItem, VerificationCode
from . import tasks from . import tasks
from .vars import ( from .vars import (
PAGE_SIZE, PAGE_SIZE,
VAT_RATE, VAT_RATE,
SHIPPING_COUNTRY, SHIPPING_COUNTRY,
ALMERIA_POSTAL_CODE_PREFIX, ALMERIA_POSTAL_CODE_PREFIX,
ALMERIA_MUNICIPALITIES_DISPLAY ALMERIA_MUNICIPALITIES_DISPLAY,
STOCK_RESERVATION_MINUTES,
) )
from django.conf import settings from django.conf import settings
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from django.urls import reverse from django.urls import reverse
from django.utils import timezone
from decimal import Decimal, ROUND_HALF_UP from decimal import Decimal, ROUND_HALF_UP
from datetime import timedelta
import stripe import stripe
from django.db import models, transaction from django.db import models, transaction
from django.core.cache import cache from django.core.cache import cache
@@ -30,6 +33,8 @@ import logging
logger = logging.getLogger("tienda") logger = logging.getLogger("tienda")
audit_logger = logging.getLogger("tienda.audit") audit_logger = logging.getLogger("tienda.audit")
STOCK_RESERVATION_SESSION_KEY = "stock_reservation_id"
STOCK_RESERVATION_PAYMENT_SESSION_KEY = "stock_reservation_payment_method"
def _normalize_location_text(value: str) -> str: def _normalize_location_text(value: str) -> str:
@@ -237,7 +242,7 @@ def register(request: HttpRequest):
) )
tasks.enviar_correo_confirmacion.delay(user) tasks.enviar_correo_confirmacion.delay(user.id)
messages.success(request, f"¡Cuenta creada exitosamente! Por favor, verifica tu correo entrando al Link enviado.") messages.success(request, f"¡Cuenta creada exitosamente! Por favor, verifica tu correo entrando al Link enviado.")
return redirect("index") return redirect("index")
@@ -296,6 +301,165 @@ def get_or_create_cart(request):
return cart return cart
def _get_or_create_session_key(request: HttpRequest):
if not request.session.session_key:
request.session.create()
return request.session.session_key
def _get_reservation_owner_filters(request: HttpRequest):
if request.user.is_authenticated:
return {"user": request.user}
return {"session_key": _get_or_create_session_key(request)}
def _release_expired_stock_reservations():
now = timezone.now()
StockReservation.objects.filter(
status=StockReservation.STATUS_ACTIVE,
expires_at__lte=now,
).update(status=StockReservation.STATUS_EXPIRED)
def _clear_stock_reservation_session(request: HttpRequest):
request.session.pop(STOCK_RESERVATION_SESSION_KEY, None)
request.session.pop(STOCK_RESERVATION_PAYMENT_SESSION_KEY, None)
def _cancel_active_stock_reservations_for_request(request: HttpRequest):
_release_expired_stock_reservations()
StockReservation.objects.filter(
**_get_reservation_owner_filters(request),
status=StockReservation.STATUS_ACTIVE,
expires_at__gt=timezone.now(),
).update(status=StockReservation.STATUS_CANCELLED)
def _get_reserved_quantities_by_product(product_ids, exclude_reservation_ids=None):
if not product_ids:
return {}
reserved_qs = StockReservationItem.objects.filter(
product_id__in=product_ids,
reservation__status=StockReservation.STATUS_ACTIVE,
reservation__expires_at__gt=timezone.now(),
)
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_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_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_ids=None):
product_ids = [item.product_id for item in cart_items]
available_by_product = _get_available_stock_by_product(product_ids, exclude_reservation_ids=exclude_reservation_ids)
issues = []
for item in cart_items:
available = available_by_product.get(item.product_id, 0)
if item.quantity > available:
issues.append({
"product_name": item.product.name,
"requested": item.quantity,
"available": available,
})
return issues
def _build_stock_issue_message(issue):
return (
f"No hay stock suficiente de '{issue['product_name']}'. "
f"Disponible: {issue['available']}, solicitado: {issue['requested']}."
)
def _create_stock_reservation_for_cart(request: HttpRequest, cart_items, payment_method: str):
if not cart_items:
return None, ["El carrito está vacío."]
_release_expired_stock_reservations()
with transaction.atomic():
StockReservation.objects.select_for_update().filter(
**_get_reservation_owner_filters(request),
status=StockReservation.STATUS_ACTIVE,
expires_at__gt=timezone.now(),
).update(status=StockReservation.STATUS_CANCELLED)
product_ids = [item.product_id for item in cart_items]
products = Product.objects.select_for_update().filter(id__in=product_ids)
product_stock = {product.id: product.stock for product in products}
reserved = _get_reserved_quantities_by_product(product_ids)
issues = []
for item in cart_items:
available = max(product_stock.get(item.product_id, 0) - reserved.get(item.product_id, 0), 0)
if item.quantity > available:
issues.append(_build_stock_issue_message({
"product_name": item.product.name,
"requested": item.quantity,
"available": available,
}))
if issues:
return None, issues
reservation = StockReservation.objects.create(
user=request.user if request.user.is_authenticated else None,
session_key=None if request.user.is_authenticated else _get_or_create_session_key(request),
payment_method=payment_method,
expires_at=timezone.now() + timedelta(minutes=STOCK_RESERVATION_MINUTES),
)
StockReservationItem.objects.bulk_create([
StockReservationItem(
reservation=reservation,
product=item.product,
quantity=item.quantity,
)
for item in cart_items
])
return reservation, []
def _get_session_stock_reservation(request: HttpRequest, payment_method: str):
reservation_id = request.session.get(STOCK_RESERVATION_SESSION_KEY)
reservation_payment_method = request.session.get(STOCK_RESERVATION_PAYMENT_SESSION_KEY)
if not reservation_id or reservation_payment_method != payment_method:
return None
return StockReservation.objects.filter(
id=reservation_id,
status=StockReservation.STATUS_ACTIVE,
expires_at__gt=timezone.now(),
payment_method=payment_method,
**_get_reservation_owner_filters(request),
).first()
def _get_selected_shipping_address(request: HttpRequest): def _get_selected_shipping_address(request: HttpRequest):
"""Obtiene la dirección seleccionada desde JSON o form-data y valida pertenencia al usuario.""" """Obtiene la dirección seleccionada desde JSON o form-data y valida pertenencia al usuario."""
shipping_address_id = request.POST.get("shipping_address_id") shipping_address_id = request.POST.get("shipping_address_id")
@@ -318,16 +482,17 @@ def _get_selected_shipping_address(request: HttpRequest):
return ShippingAddress.objects.filter(id=shipping_address_id, user=request.user).first() return ShippingAddress.objects.filter(id=shipping_address_id, user=request.user).first()
def create_order_from_cart(request, payment_method, payment_reference="", shipping_address=None): def create_order_from_cart(request, payment_method, payment_reference="", shipping_address=None, stock_reservation=None):
"""Crea un pedido a partir del carrito actual y lo asigna a vendedores.""" """Crea un pedido a partir del carrito actual, validando y descontando stock."""
cart = get_or_create_cart(request) cart = get_or_create_cart(request)
cart_items = list(cart.items.select_related("product", "product__creator")) cart_items = list(cart.items.select_related("product", "product__creator"))
if not cart_items: if not cart_items:
return None return None, "El carrito está vacío."
order_total = Decimal("0.00") order_total = Decimal("0.00")
items_with_totals = [] items_with_totals = []
purchased_items = []
for item in cart_items: for item in cart_items:
product = item.product product = item.product
@@ -338,8 +503,70 @@ def create_order_from_cart(request, payment_method, payment_reference="", shippi
) )
order_total += line_total_with_vat order_total += line_total_with_vat
items_with_totals.append((item, unit_price_with_vat, line_total_with_vat)) items_with_totals.append((item, unit_price_with_vat, line_total_with_vat))
purchased_items.append(
{
"amount": item.quantity,
"product_name": product.name,
"price": float(unit_price_with_vat),
}
)
_release_expired_stock_reservations()
with transaction.atomic(): with transaction.atomic():
locked_reservation = None
reserved_by_product = {}
if stock_reservation is not None:
locked_reservation = StockReservation.objects.select_for_update().filter(
id=stock_reservation.id,
status=StockReservation.STATUS_ACTIVE,
expires_at__gt=timezone.now(),
).first()
if locked_reservation is None:
return None, (
f"La reserva de stock ha caducado. Tienes {STOCK_RESERVATION_MINUTES} minutos "
"desde que pulsas pagar. Revisa el carrito y vuelve a intentarlo."
)
for reservation_item in locked_reservation.items.all():
reserved_by_product[reservation_item.product_id] = reservation_item.quantity
product_ids = [item.product_id for item in cart_items]
products = Product.objects.select_for_update().filter(id__in=product_ids)
product_map = {product.id: product for product in products}
reserved_from_others = _get_reserved_quantities_by_product(
product_ids,
exclude_reservation_ids=[locked_reservation.id] if locked_reservation else None,
)
for item in cart_items:
product = product_map.get(item.product_id)
if product is None:
return None, f"El producto '{item.product.name}' ya no está disponible."
if locked_reservation is not None and item.quantity > reserved_by_product.get(item.product_id, 0):
return None, (
f"La cantidad de '{item.product.name}' ha cambiado desde la reserva. "
"Vuelve a intentar el pago."
)
available = max(product.stock - reserved_from_others.get(item.product_id, 0), 0)
if item.quantity > available:
return None, _build_stock_issue_message({
"product_name": item.product.name,
"requested": item.quantity,
"available": available,
})
if product.stock < item.quantity:
return None, _build_stock_issue_message({
"product_name": item.product.name,
"requested": item.quantity,
"available": product.stock,
})
order = Order.objects.create( order = Order.objects.create(
buyer=request.user if request.user.is_authenticated else None, buyer=request.user if request.user.is_authenticated else None,
shipping_address=shipping_address, shipping_address=shipping_address,
@@ -362,9 +589,25 @@ def create_order_from_cart(request, payment_method, payment_reference="", shippi
total_price=float(line_total_with_vat), total_price=float(line_total_with_vat),
) )
product_row = product_map.get(item.product_id)
product_row.stock -= item.quantity
product_row.save(update_fields=["stock"])
cart.items.all().delete() cart.items.all().delete()
return order if locked_reservation is not None:
locked_reservation.status = StockReservation.STATUS_COMPLETED
locked_reservation.save(update_fields=["status", "updated_at"])
if request.user.is_authenticated and purchased_items:
tasks.process_purchase.delay(
request.user.id,
purchased_items,
payment_method,
order.transaction_code,
)
return order, ""
def add_to_cart(request: HttpRequest, product_id: int): def add_to_cart(request: HttpRequest, product_id: int):
@@ -372,9 +615,25 @@ def add_to_cart(request: HttpRequest, product_id: int):
try: try:
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(
@@ -400,6 +659,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.")
@@ -409,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,
}) })
@@ -420,10 +687,21 @@ def update_cart_item(request: HttpRequest, item_id: int):
try: try:
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.")
@@ -436,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()
@@ -456,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')
@@ -537,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})
@@ -555,6 +841,15 @@ def crear_producto(request: HttpRequest):
messages.error(request, "El precio debe ser un número válido.") messages.error(request, "El precio debe ser un número válido.")
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)
@@ -577,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
@@ -609,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", {
@@ -633,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:
@@ -647,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:
@@ -693,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
@@ -722,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 = []
@@ -763,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:
@@ -770,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()
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", {}) 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", {})
@@ -825,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,
@@ -892,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:
@@ -946,16 +1307,28 @@ 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()
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", {}) return render(request, "tienda/checkout_success.html", {"order": order})
else: else:
error_message = payment.error.get("message", "Error desconocido") error_message = payment.error.get("message", "Error desconocido")
messages.error(request, f"Error al procesar el pago: {error_message}") messages.error(request, f"Error al procesar el pago: {error_message}")
@@ -965,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()
@@ -1256,4 +1631,41 @@ def reset_password(request: HttpRequest):
return render(request, "tienda/reset_password", {}) return render(request, "tienda/reset_password", {})
def rgpd(request: HttpRequest): def rgpd(request: HttpRequest):
return render(request, "tienda/rgpd.html", {}) return render(request, "tienda/rgpd.html", {})
def reset_password(request: HttpRequest):
if request.method == "GET":
return render(request, "tienda/reset_password.html", {})
else:
tasks.enviar_correo_recuperacion.delay(request.POST["email"])
messages.info(request, "Si tienes una cuenta con ese correo electronico, se ha enviado un correo con un enlace")
return render(request, "tienda/index.html", {})
def reset_password_phase2(request: HttpRequest, code: str):
try:
ver_code = VerificationCode.objects.get(code=code)
except VerificationCode.DoesNotExist:
raise Http404()
if ver_code.code_mode != VerificationCode.VerificationModes.RESET_PASSWORD: raise Http404()
if request.method == "GET":
return render(request, "tienda/reset_password_phase2.html", {
"code": code
})
elif request.method == "POST":
password = request.POST["password"]
vpassword = request.POST["verify_password"]
if password != vpassword:
messages.error(request, "Las contraseñas no coinciden")
return render(request, "tienda/reset_password_phase2.html", {"code": code})
user = ver_code.user
user.set_password(password)
user.save()
messages.success(request, "Se ha cambiado la contraseña!")
return redirect(reverse("index"))
else:
raise Http404()