Compare commits

..

10 Commits

Author SHA1 Message Date
elordenador 5503bbe8f7 refactor: organize constants and improve template rendering in views 2026-05-26 13:19:06 +02:00
elordenador dd5ecec3f6 fix: improve accessibility by adding aria-labelledby attributes to card input labels 2026-05-26 13:17:47 +02:00
Daniel (elordenador) c778669a7a Merge pull request #102 from dsaub/fix/major-issues
fix: resolver 9 issues MAJOR de SonarQube
2026-05-26 13:15:33 +02:00
Chroot 09f6f800de fix: script module con top-level await para S7785 2026-05-26 11:14:03 +00:00
Chroot 1ac17109a3 fix: usar async IIFE en loadReviews para S7785 2026-05-26 11:11:43 +00:00
Chroot 325e55417b fix: resolver 9 issues MAJOR de SonarQube Cloud
- views.py: eliminar parámetros no usados cart_items y product_ids
- views.py: reemplazar f-strings sin placeholders por strings normales
- base.html: añadir <title>Comercialmeria</title>
- add_review.html: asociar label 'Puntuación' con rating-input via for
- producto.html: promesa loadReviews con .catch()
- gestionar_imagenes.html: mejorar alt text descriptivo
- unban.html: quitar atributos deprecados width/cellspacing
2026-05-26 11:10:04 +00:00
Daniel (elordenador) e363bfd6dd Merge pull request #101 from dsaub/fix/const-self-ref-bug
fix: corregir constantes auto-referenciadas que rompían la app
2026-05-26 13:04:41 +02:00
Chroot 90308d2383 fix: corregir constantes auto-referenciadas que rompen la app
El sed de reemplazo de strings también modificó las definiciones
de constantes, dejando p.ej. LOGIN_TEMPLATE = LOGIN_TEMPLATE
en vez de LOGIN_TEMPLATE = "tienda/login.html", causando
NameError al importar el módulo.
2026-05-26 11:03:53 +00:00
Daniel (elordenador) de4f36a25c Merge pull request #99 from dsaub/fix/sonar-critical-issues
fix: resolver 12 issues CRITICAL de SonarQube Cloud
2026-05-26 12:57:57 +02:00
Chroot 424ffcffaf fix: resolver 12 issues CRITICAL de SonarQube Cloud
- forms.py: cambiar import wildcard por imports explícitos (S2208)
- views.py: definir constantes para strings duplicados (S1192)
- views.py: refactorizar login, create_order_from_cart, editar_producto (S3776)
2026-05-26 10:53:18 +00:00
9 changed files with 283 additions and 223 deletions
+1 -1
View File
@@ -2,7 +2,7 @@ from django import forms
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import FileExtensionValidator, MinLengthValidator, MaxLengthValidator from django.core.validators import FileExtensionValidator, MinLengthValidator, MaxLengthValidator
from .models import Category from .models import Category
from .constants import * from .constants import IMAGE_TYPE, EMAIL_FORMNAME, INCORRECT_PASSWORDS
ALLOWED_IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp'] ALLOWED_IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp']
ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'] ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
+4 -1
View File
@@ -22,13 +22,16 @@
{% csrf_token %} {% csrf_token %}
<div class="mb-4"> <div class="mb-4">
<label class="form-label">Puntuación</label> <label class="form-label" for="rating-input">
Puntuación
<div class="star-rating d-flex gap-1" id="star-rating"> <div class="star-rating d-flex gap-1" id="star-rating">
{% for i in "12345" %} {% for i in "12345" %}
<span class="star fs-2 {% if form.initial.rating|default:0 >= i|add:0 %}text-warning text-dark{% else %}text-secondary{% endif %}" data-value="{{ i }}" style="cursor: pointer; font-size: 2rem;"></span> <span class="star fs-2 {% if form.initial.rating|default:0 >= i|add:0 %}text-warning text-dark{% else %}text-secondary{% endif %}" data-value="{{ i }}" style="cursor: pointer; font-size: 2rem;"></span>
{% endfor %} {% endfor %}
</div> </div>
<input type="hidden" name="rating" id="rating-input" value="{{ form.initial.rating|default:1 }}"> <input type="hidden" name="rating" id="rating-input" value="{{ form.initial.rating|default:1 }}">
</label>
{% if form.rating.errors %} {% if form.rating.errors %}
<div class="text-danger small">{{ form.rating.errors }}</div> <div class="text-danger small">{{ form.rating.errors }}</div>
{% endif %} {% endif %}
+2 -2
View File
@@ -44,8 +44,8 @@
</p> </p>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Datos de la tarjeta</label> <label id="label-card-data" class="form-label">Datos de la tarjeta</label>
<div id="card-element"></div> <div id="card-element" aria-labelledby="label-card-data"></div>
<div id="card-errors" role="alert"></div> <div id="card-errors" role="alert"></div>
</div> </div>
+1
View File
@@ -6,6 +6,7 @@
<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>Comercialmeria</title>
<meta name="description" content="Sitio web de comercio local Almeriense"> <meta name="description" content="Sitio web de comercio local Almeriense">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
<link rel="preload" href="{% static 'css/custom.css' %}" as="style" onload="this.onload=null;this.rel='stylesheet'"> <link rel="preload" href="{% static 'css/custom.css' %}" as="style" onload="this.onload=null;this.rel='stylesheet'">
+2 -2
View File
@@ -164,8 +164,8 @@
<div id="new-card-section" {% if saved_cards %}style="display:none;"{% endif %}> <div id="new-card-section" {% if saved_cards %}style="display:none;"{% endif %}>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Número de tarjeta</label> <label id="label-card-number" class="form-label">Número de tarjeta</label>
<div id="card-element"></div> <div id="card-element" aria-labelledby="label-card-number"></div>
<div id="card-errors" role="alert"></div> <div id="card-errors" role="alert"></div>
</div> </div>
<div class="form-check mb-3"> <div class="form-check mb-3">
+1 -1
View File
@@ -1,4 +1,4 @@
<table width="100%" border="0" cellspacing="0" cellpadding="0"> <table border="0" cellpadding="0" style="width: 100%;">
<tr> <tr>
<td align="center" style="padding: 20px;"> <td align="center" style="padding: 20px;">
<table width="600" border="0" cellspacing="0" cellpadding="0" style="border: 1px solid #eeeeee; background-color: #ffffff;"> <table width="600" border="0" cellspacing="0" cellpadding="0" style="border: 1px solid #eeeeee; background-color: #ffffff;">
@@ -18,7 +18,7 @@
</div> </div>
<div class="card-body"> <div class="card-body">
{% if producto.primary_image %} {% if producto.primary_image %}
<img src="{{ producto.primary_image.image.url }}" alt="{{ producto.primary_image.alt|default:producto.name }}" class="rounded" style="width: 200px; height: 200px; object-fit: cover;"> <img src="{{ producto.primary_image.image.url }}" alt="{{ producto.primary_image.alt|default:producto.name }} - imagen principal" class="rounded" style="width: 200px; height: 200px; object-fit: cover;">
<p class="mt-2 text-muted mb-0">Esta imagen no se puede cambiar desde aquí.</p> <p class="mt-2 text-muted mb-0">Esta imagen no se puede cambiar desde aquí.</p>
{% else %} {% else %}
<p class="text-muted">No hay imagen principal asignada.</p> <p class="text-muted">No hay imagen principal asignada.</p>
+2 -2
View File
@@ -94,7 +94,7 @@
</div> </div>
</div> </div>
<script> <script type="module">
async function loadReviews() { async function loadReviews() {
try { try {
const response = await fetch("{% url 'product_reviews' product.id %}"); const response = await fetch("{% url 'product_reviews' product.id %}");
@@ -157,6 +157,6 @@ async function loadReviews() {
} }
} }
loadReviews(); await loadReviews();
</script> </script>
{% endblock %} {% endblock %}
+177 -121
View File
@@ -42,6 +42,16 @@ audit_logger = logging.getLogger("tienda.audit")
STOCK_RESERVATION_SESSION_KEY = "stock_reservation_id" STOCK_RESERVATION_SESSION_KEY = "stock_reservation_id"
STOCK_RESERVATION_PAYMENT_SESSION_KEY = "stock_reservation_payment_method" STOCK_RESERVATION_PAYMENT_SESSION_KEY = "stock_reservation_payment_method"
# Constantes para plantillas y mensajes reutilizados
LOGIN_TEMPLATE = "tienda/login.html"
INDEX_TEMPLATE = "tienda/index.html"
EDITAR_PERFIL_TEMPLATE = "tienda/editar_perfil.html"
EDITAR_DIRECCION_TEMPLATE = "tienda/editar_direccion.html"
MSG_CAMPOS_OBLIGATORIOS = "Por favor completa todos los campos obligatorios."
MSG_DIRECCION_INVALIDA = "Debes seleccionar una dirección de envío válida."
MSG_CARRITO_VACIO = "El carrito está vacío"
MSG_CUERPO_INVALIDO = "Cuerpo de la petición inválido"
def _mask_email(email: str) -> str: def _mask_email(email: str) -> str:
if not email or '@' not in email: if not email or '@' not in email:
@@ -232,65 +242,80 @@ def index(request: HttpRequest):
products = Product.objects.all()[start:end] products = Product.objects.all()[start:end]
categorias = Category.objects.all() categorias = Category.objects.all()
return render(request, "tienda/index.html", {"products": products, "categories": categorias}) return render(request, INDEX_TEMPLATE, {"products": products, "categories": categorias})
@require_http_methods(["GET", "POST"]) def _try_get_user(email: str):
def login(request: HttpRequest): """Intenta obtener un usuario por email, retorna (user, username) o (None, None)."""
if request.method == "POST":
form: UserLoginForm = UserLoginForm(request.POST)
if form.is_valid():
email: str = form.cleaned_data["email"]
password: str = form.cleaned_data["password"]
remember: bool = form.cleaned_data["remember"]
client_ip = _get_client_ip(request)
try: try:
user: User = User.objects.get(email=email) user = User.objects.get(email=email)
username = user.username return user, user.username
except User.DoesNotExist: except User.DoesNotExist:
audit_logger.warning("LOGIN FAILED email=%s reason=user_not_found ip=%s", _mask_email(email), client_ip) return None, None
messages.error(request, "El email o la contraseña es incorrecta")
return render(request, "tienda/login.html", {"form": form})
if user.registration_status == User.RegisterStatus.BANNED:
# Usuario baneado.
messages.error(request, "Esta cuenta esta bloqueada.")
return render(request, "tienda/login.html", {"form": form})
user = authenticate(request, username = username, password=password)
if user is None: def _handle_login_rate_limit(request, username, email):
data: str = cache.get(f"tries_login_{username}") """Verifica rate limit de intentos de login. Retorna True si está rate-limited."""
logins: int data = cache.get(f"tries_login_{username}")
if data is None: logins = 0 if data is None else int(data)
logins = 0
else:
logins = int(data)
if logins >= 5: if logins >= 5:
audit_logger.info("LOGIN FAILED email=%s reason=rate_limited", _mask_email(email)) audit_logger.info("LOGIN FAILED email=%s reason=rate_limited", _mask_email(email))
messages.error(request, "Has sufrido de Rate Limit por fallar 5 veces la contraseña") messages.error(request, "Has sufrido de Rate Limit por fallar 5 veces la contraseña")
return render(request, "tienda/login.html", {"form": form}) return True
logins+=1
logins += 1
cache.set(f"tries_login_{username}", str(logins), 600) cache.set(f"tries_login_{username}", str(logins), 600)
return False
@require_http_methods(["GET", "POST"])
def login(request: HttpRequest):
if request.method != "POST":
return render(request, LOGIN_TEMPLATE, {"form": UserLoginForm()})
form = UserLoginForm(request.POST)
if not form.is_valid():
return render(request, LOGIN_TEMPLATE, {"form": form})
email = form.cleaned_data["email"]
password = form.cleaned_data["password"]
remember = form.cleaned_data["remember"]
client_ip = _get_client_ip(request)
user, username = _try_get_user(email)
if user is None:
audit_logger.warning("LOGIN FAILED email=%s reason=user_not_found ip=%s", _mask_email(email), client_ip)
messages.error(request, "El email o la contraseña es incorrecta") messages.error(request, "El email o la contraseña es incorrecta")
return render(request, "tienda/login.html", {"form": form}) return render(request, LOGIN_TEMPLATE, {"form": form})
if user.registration_status == User.RegisterStatus.CONFIRMATION_REQUIRED:
if user.registration_status == User.RegisterStatus.BANNED:
messages.error(request, "Esta cuenta esta bloqueada.")
return render(request, LOGIN_TEMPLATE, {"form": form})
authenticated_user = authenticate(request, username=username, password=password)
if authenticated_user is None:
if _handle_login_rate_limit(request, username, email):
return render(request, LOGIN_TEMPLATE, {"form": form})
messages.error(request, "El email o la contraseña es incorrecta")
return render(request, LOGIN_TEMPLATE, {"form": form})
if authenticated_user.registration_status == User.RegisterStatus.CONFIRMATION_REQUIRED:
audit_logger.info("LOGIN_FAILED email=%s reason=not_verified", _mask_email(email)) audit_logger.info("LOGIN_FAILED email=%s reason=not_verified", _mask_email(email))
messages.error(request, "No se puede iniciar sesión porque no has verificado tu cuenta, comprueba tu email. Si eliminaste el email pero querias verificarte, contacta con el soporte tecnico") messages.error(request, "No se puede iniciar sesión porque no has verificado tu cuenta, comprueba tu email. Si eliminaste el email pero querias verificarte, contacta con el soporte tecnico")
return render(request, "tienda/login.html", {"form": form}) return render(request, LOGIN_TEMPLATE, {"form": form})
auth_login(request, user)
auth_login(request, authenticated_user)
if not remember: if not remember:
request.session.set_expiry(0) request.session.set_expiry(0)
else: else:
request.session.set_expiry(1209600) request.session.set_expiry(1209600)
audit_logger.info("LOGIN_SUCCESS user_id=%s email=%s ip=%s remember=%s", user.id, _mask_email(user.email), client_ip, bool(remember)) audit_logger.info("LOGIN_SUCCESS user_id=%s email=%s ip=%s remember=%s", authenticated_user.id, _mask_email(authenticated_user.email), client_ip, bool(remember))
tasks.enviar_correo_bienvenida.delay(user.email, f"{user.first_name} {user.last_name}") tasks.enviar_correo_bienvenida.delay(authenticated_user.email, f"{authenticated_user.first_name} {authenticated_user.last_name}")
messages.success(request, f"¡Bienvenido {user.first_name or user.username}!") messages.success(request, f"¡Bienvenido {authenticated_user.first_name or authenticated_user.username}!")
return redirect("index") return redirect("index")
else:
form = UserLoginForm()
return render(request, "tienda/login.html", {"form": form})
@require_http_methods(["GET", "POST"]) @require_http_methods(["GET", "POST"])
def register(request: HttpRequest): def register(request: HttpRequest):
@@ -390,7 +415,7 @@ def categoria(request: HttpRequest, id: int):
category = Category.objects.get(id=id) category = Category.objects.get(id=id)
categories = Category.objects.all() categories = Category.objects.all()
products = Product.objects.filter(category=category)[start:end] products = Product.objects.filter(category=category)[start:end]
return render(request, "tienda/index.html", {"products": products, "categories": categories}) return render(request, INDEX_TEMPLATE, {"products": products, "categories": categories})
# Funciones auxiliares para el carrito # Funciones auxiliares para el carrito
@@ -610,15 +635,9 @@ 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()
@require_GET
def create_order_from_cart(request, payment_method, payment_reference="", shipping_address=None, stock_reservation=None):
"""Crea un pedido a partir del carrito actual, validando y descontando stock."""
cart = get_or_create_cart(request)
cart_items = list(cart.items.select_related("product", "product__creator"))
if not cart_items:
return None, "El carrito está vacío."
def _calculate_order_totals(cart_items):
"""Calcula los totales de los items del carrito."""
order_total = Decimal("0.00") order_total = Decimal("0.00")
items_with_totals = [] items_with_totals = []
purchased_items = [] purchased_items = []
@@ -627,75 +646,73 @@ def create_order_from_cart(request, payment_method, payment_reference="", shippi
product = item.product product = item.product
unit_price_with_vat = get_price_with_vat_decimal(product.price) unit_price_with_vat = get_price_with_vat_decimal(product.price)
line_total_with_vat = (unit_price_with_vat * item.quantity).quantize( line_total_with_vat = (unit_price_with_vat * item.quantity).quantize(
Decimal("0.01"), Decimal("0.01"), rounding=ROUND_HALF_UP,
rounding=ROUND_HALF_UP,
) )
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( purchased_items.append({
{
"amount": item.quantity, "amount": item.quantity,
"product_name": product.name, "product_name": product.name,
"price": float(unit_price_with_vat), "price": float(unit_price_with_vat),
} })
)
_release_expired_stock_reservations() return order_total, items_with_totals, purchased_items
with transaction.atomic():
locked_reservation = None
reserved_by_product = {}
if stock_reservation is not None: def _validate_locked_reservation(stock_reservation):
"""Valida y carga la reserva de stock, retorna (locked_reservation, reserved_by_product).
Si stock_reservation es None, retorna (None, {}). Si la reserva ha caducado, retorna (None, None)."""
if stock_reservation is None:
return None, {}
locked_reservation = StockReservation.objects.select_for_update().filter( locked_reservation = StockReservation.objects.select_for_update().filter(
id=stock_reservation.id, id=stock_reservation.id,
status=StockReservation.STATUS_ACTIVE, status=StockReservation.STATUS_ACTIVE,
expires_at__gt=timezone.now(), expires_at__gt=timezone.now(),
).first() ).first()
if locked_reservation is None: if locked_reservation is None:
return None, ( return None, None
f"La reserva de stock ha caducado. Tienes {STOCK_RESERVATION_MINUTES} minutos "
"desde que pulsas pagar. Revisa el carrito y vuelve a intentarlo."
)
reserved_by_product = {}
for reservation_item in locked_reservation.items.all(): for reservation_item in locked_reservation.items.all():
reserved_by_product[reservation_item.product_id] = reservation_item.quantity reserved_by_product[reservation_item.product_id] = reservation_item.quantity
product_ids = [item.product_id for item in cart_items] return locked_reservation, reserved_by_product
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,
)
def _validate_order_items(cart_items, product_map, locked_reservation, reserved_by_product, reserved_from_others):
"""Valida disponibilidad y stock de cada item. Retorna None si ok, o str con el error."""
for item in cart_items: for item in cart_items:
product = product_map.get(item.product_id) product = product_map.get(item.product_id)
if product is None: if product is None:
return None, f"El producto '{item.product.name}' ya no está disponible." return 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): if locked_reservation is not None and item.quantity > reserved_by_product.get(item.product_id, 0):
return None, ( return (
f"La cantidad de '{item.product.name}' ha cambiado desde la reserva. " f"La cantidad de '{item.product.name}' ha cambiado desde la reserva. "
"Vuelve a intentar el pago." "Vuelve a intentar el pago."
) )
available = max(product.stock - reserved_from_others.get(item.product_id, 0), 0) available = max(product.stock - reserved_from_others.get(item.product_id, 0), 0)
if item.quantity > available: if item.quantity > available:
return None, _build_stock_issue_message({ return _build_stock_issue_message({
"product_name": item.product.name, "product_name": item.product.name,
"requested": item.quantity, "requested": item.quantity,
"available": available, "available": available,
}) })
if product.stock < item.quantity: if product.stock < item.quantity:
return None, _build_stock_issue_message({ return _build_stock_issue_message({
"product_name": item.product.name, "product_name": item.product.name,
"requested": item.quantity, "requested": item.quantity,
"available": product.stock, "available": product.stock,
}) })
return None
def _create_order_and_items(request, order_total, items_with_totals, product_map, payment_method, payment_reference, shipping_address, locked_reservation):
"""Crea la orden y sus items, descuenta stock y marca reserva como completada."""
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,
@@ -717,19 +734,55 @@ def create_order_from_cart(request, payment_method, payment_reference="", shippi
unit_price=float(unit_price_with_vat), unit_price=float(unit_price_with_vat),
total_price=float(line_total_with_vat), total_price=float(line_total_with_vat),
) )
product_row = product_map.get(item.product_id) product_row = product_map.get(item.product_id)
product_row.stock = F('stock') - item.quantity product_row.stock = F('stock') - item.quantity
product_row.save(update_fields=["stock"]) product_row.save(update_fields=["stock"])
_invalidate_product_cache(product_ids)
cart.items.all().delete()
if locked_reservation is not None: if locked_reservation is not None:
locked_reservation.status = StockReservation.STATUS_COMPLETED locked_reservation.status = StockReservation.STATUS_COMPLETED
locked_reservation.save(update_fields=["status", "updated_at"]) locked_reservation.save(update_fields=["status", "updated_at"])
return order
@require_GET
def create_order_from_cart(request, payment_method, payment_reference="", shipping_address=None, stock_reservation=None):
"""Crea un pedido a partir del carrito actual, validando y descontando stock."""
cart = get_or_create_cart(request)
cart_items = list(cart.items.select_related("product", "product__creator"))
if not cart_items:
return None, MSG_CARRITO_VACIO + "."
order_total, items_with_totals, purchased_items = _calculate_order_totals(cart_items)
_release_expired_stock_reservations()
with transaction.atomic():
locked_reservation, reserved_by_product = _validate_locked_reservation(stock_reservation)
if locked_reservation is None and stock_reservation is not 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."
)
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,
)
error_msg = _validate_order_items(cart_items, product_map, locked_reservation, reserved_by_product, reserved_from_others)
if error_msg:
return None, error_msg
order = _create_order_and_items(request, order_total, items_with_totals, product_map, payment_method, payment_reference, shipping_address, locked_reservation)
_invalidate_product_cache(product_ids)
cart.items.all().delete()
if request.user.is_authenticated and purchased_items: if request.user.is_authenticated and purchased_items:
tasks.process_purchase.delay( tasks.process_purchase.delay(
request.user.id, request.user.id,
@@ -970,15 +1023,8 @@ def crear_producto(request: HttpRequest):
form = ProductForm() form = ProductForm()
return render(request, "tienda/crear_producto.html", {"form":form}) return render(request, "tienda/crear_producto.html", {"form":form})
@require_http_methods(["GET","POST"]) def _handle_edit_product_post(request, producto, form):
@login_required """Procesa el POST de editar producto. Retorna respuesta HTTP o None si hay error."""
def editar_producto(request: HttpRequest, id: int):
"""Edita un producto del usuario autenticado"""
producto = get_object_or_404(Product, id=id, creator=request.user)
if request.method == "POST":
form = ProductEditForm(request.POST, request.FILES)
if form.is_valid():
producto.name = form.cleaned_data["name"] producto.name = form.cleaned_data["name"]
producto.briefdesc = form.cleaned_data.get("briefdesc", "") or "" producto.briefdesc = form.cleaned_data.get("briefdesc", "") or ""
producto.description = form.cleaned_data["description"] producto.description = form.cleaned_data["description"]
@@ -1010,8 +1056,19 @@ def editar_producto(request: HttpRequest, id: int):
messages.success(request, f"¡Producto '{producto.name}' actualizado exitosamente!") messages.success(request, f"¡Producto '{producto.name}' actualizado exitosamente!")
return redirect("mis_productos") return redirect("mis_productos")
else:
messages.error(request, "Por favor completa todos los campos obligatorios.")
@require_http_methods(["GET","POST"])
@login_required
def editar_producto(request: HttpRequest, id: int):
"""Edita un producto del usuario autenticado"""
producto = get_object_or_404(Product, id=id, creator=request.user)
if request.method == "POST":
form = ProductEditForm(request.POST, request.FILES)
if form.is_valid():
return _handle_edit_product_post(request, producto, form)
messages.error(request, MSG_CAMPOS_OBLIGATORIOS)
else: else:
initial = { initial = {
"name": producto.name, "name": producto.name,
@@ -1023,10 +1080,9 @@ def editar_producto(request: HttpRequest, id: int):
} }
form = ProductEditForm(initial=initial) form = ProductEditForm(initial=initial)
categories = Category.objects.all()
return render(request, "tienda/editar_producto.html", { return render(request, "tienda/editar_producto.html", {
"form": form, "form": form,
"producto": producto "producto": producto,
}) })
@login_required @login_required
@@ -1130,13 +1186,13 @@ def create_checkout_session(request: HttpRequest):
try: try:
shipping_address = _get_selected_shipping_address(request) shipping_address = _get_selected_shipping_address(request)
if shipping_address is None: if shipping_address is None:
return JsonResponse({"error": "Debes seleccionar una dirección de envío válida."}, status=400) return JsonResponse({"error": MSG_DIRECCION_INVALIDA}, status=400)
cart = get_or_create_cart(request) cart = get_or_create_cart(request)
cart_items = list(cart.items.select_related("product")) cart_items = list(cart.items.select_related("product"))
if not cart_items: if not cart_items:
return JsonResponse({"error": "El carrito está vacío"}, status=400) return JsonResponse({"error": MSG_CARRITO_VACIO}, status=400)
active_reservation_ids = _get_active_reservation_ids_for_request(request) active_reservation_ids = _get_active_reservation_ids_for_request(request)
stock_issues = _get_cart_stock_issues(cart_items, exclude_reservation_ids=active_reservation_ids) stock_issues = _get_cart_stock_issues(cart_items, exclude_reservation_ids=active_reservation_ids)
@@ -1282,7 +1338,7 @@ def create_paypal_payment(request: HttpRequest):
try: try:
shipping_address = _get_selected_shipping_address(request) shipping_address = _get_selected_shipping_address(request)
if shipping_address is None: if shipping_address is None:
return JsonResponse({"error": "Debes seleccionar una dirección de envío válida."}, status=400) return JsonResponse({"error": MSG_DIRECCION_INVALIDA}, status=400)
import paypalrestsdk import paypalrestsdk
@@ -1290,7 +1346,7 @@ def create_paypal_payment(request: HttpRequest):
cart_items = list(cart.items.select_related("product")) cart_items = list(cart.items.select_related("product"))
if not cart_items: if not cart_items:
return JsonResponse({"error": "El carrito está vacío"}, status=400) return JsonResponse({"error": MSG_CARRITO_VACIO}, status=400)
active_reservation_ids = _get_active_reservation_ids_for_request(request) active_reservation_ids = _get_active_reservation_ids_for_request(request)
stock_issues = _get_cart_stock_issues(cart_items, exclude_reservation_ids=active_reservation_ids) stock_issues = _get_cart_stock_issues(cart_items, exclude_reservation_ids=active_reservation_ids)
@@ -1379,7 +1435,7 @@ def create_paypal_payment(request: HttpRequest):
else: else:
# Loguear el error # Loguear el error
logger.error("PAYPAL_CREATE_ERROR user_id=%s", request.user.id) logger.error("PAYPAL_CREATE_ERROR user_id=%s", request.user.id)
return JsonResponse({"error": f"Error al crear el pago"}, status=400) return JsonResponse({"error": "Error al crear el pago"}, status=400)
except ImportError: except ImportError:
logger.error("PAYPAL_SDK_NOT_INSTALLED") logger.error("PAYPAL_SDK_NOT_INSTALLED")
@@ -1387,7 +1443,7 @@ def create_paypal_payment(request: HttpRequest):
except Exception as e: except Exception as e:
error_msg = str(e) error_msg = str(e)
logger.exception("PAYPAL_CREATE_EXCEPTION user_id=%s", request.user.id) logger.exception("PAYPAL_CREATE_EXCEPTION user_id=%s", request.user.id)
return JsonResponse({"error": f"Error al crear el pago"}, status=500) return JsonResponse({"error": "Error al crear el pago"}, status=500)
@require_GET @require_GET
@@ -1469,17 +1525,17 @@ def crear_payment_intent(request: HttpRequest):
try: try:
payload = json.loads(request.body.decode("utf-8") or "{}") payload = json.loads(request.body.decode("utf-8") or "{}")
except (json.JSONDecodeError, UnicodeDecodeError): except (json.JSONDecodeError, UnicodeDecodeError):
return JsonResponse({"error": "Cuerpo de la petición inválido"}, status=400) return JsonResponse({"error": MSG_CUERPO_INVALIDO}, status=400)
shipping_address = _get_selected_shipping_address(request) shipping_address = _get_selected_shipping_address(request)
if shipping_address is None: if shipping_address is None:
return JsonResponse({"error": "Debes seleccionar una dirección de envío válida."}, status=400) return JsonResponse({"error": MSG_DIRECCION_INVALIDA}, status=400)
cart = get_or_create_cart(request) cart = get_or_create_cart(request)
cart_items = list(cart.items.select_related("product")) cart_items = list(cart.items.select_related("product"))
if not cart_items: if not cart_items:
return JsonResponse({"error": "El carrito está vacío"}, status=400) return JsonResponse({"error": MSG_CARRITO_VACIO}, status=400)
active_reservation_ids = _get_active_reservation_ids_for_request(request) active_reservation_ids = _get_active_reservation_ids_for_request(request)
stock_issues = _get_cart_stock_issues(cart_items, exclude_reservation_ids=active_reservation_ids) stock_issues = _get_cart_stock_issues(cart_items, exclude_reservation_ids=active_reservation_ids)
@@ -1551,7 +1607,7 @@ def confirmar_pago_tarjeta(request: HttpRequest):
try: try:
payload = json.loads(request.body.decode("utf-8") or "{}") payload = json.loads(request.body.decode("utf-8") or "{}")
except (json.JSONDecodeError, UnicodeDecodeError): except (json.JSONDecodeError, UnicodeDecodeError):
return JsonResponse({"error": "Cuerpo de la petición inválido"}, status=400) return JsonResponse({"error": MSG_CUERPO_INVALIDO}, status=400)
payment_intent_id = payload.get("payment_intent_id") payment_intent_id = payload.get("payment_intent_id")
if not payment_intent_id: if not payment_intent_id:
@@ -1624,13 +1680,13 @@ def crear_orden_paypal(request: HttpRequest):
shipping_address = _get_selected_shipping_address(request) shipping_address = _get_selected_shipping_address(request)
if shipping_address is None: if shipping_address is None:
return JsonResponse({"error": "Debes seleccionar una dirección de envío válida."}, status=400) return JsonResponse({"error": MSG_DIRECCION_INVALIDA}, status=400)
cart = get_or_create_cart(request) cart = get_or_create_cart(request)
cart_items = list(cart.items.select_related("product")) cart_items = list(cart.items.select_related("product"))
if not cart_items: if not cart_items:
return JsonResponse({"error": "El carrito está vacío"}, status=400) return JsonResponse({"error": MSG_CARRITO_VACIO}, status=400)
active_reservation_ids = _get_active_reservation_ids_for_request(request) active_reservation_ids = _get_active_reservation_ids_for_request(request)
stock_issues = _get_cart_stock_issues(cart_items, exclude_reservation_ids=active_reservation_ids) stock_issues = _get_cart_stock_issues(cart_items, exclude_reservation_ids=active_reservation_ids)
@@ -1677,7 +1733,7 @@ def capturar_orden_paypal(request: HttpRequest):
try: try:
payload = json.loads(request.body.decode("utf-8") or "{}") payload = json.loads(request.body.decode("utf-8") or "{}")
except (json.JSONDecodeError, UnicodeDecodeError): except (json.JSONDecodeError, UnicodeDecodeError):
return JsonResponse({"error": "Cuerpo de la petición inválido"}, status=400) return JsonResponse({"error": MSG_CUERPO_INVALIDO}, status=400)
paypal_order_id = payload.get("orderID") paypal_order_id = payload.get("orderID")
if not paypal_order_id: if not paypal_order_id:
@@ -1812,7 +1868,7 @@ def confirmar_setup_intent(request: HttpRequest):
try: try:
payload = json.loads(request.body.decode("utf-8") or "{}") payload = json.loads(request.body.decode("utf-8") or "{}")
except (json.JSONDecodeError, UnicodeDecodeError): except (json.JSONDecodeError, UnicodeDecodeError):
return JsonResponse({"error": "Cuerpo de la petición inválido"}, status=400) return JsonResponse({"error": MSG_CUERPO_INVALIDO}, status=400)
payment_method_id = payload.get("payment_method_id") payment_method_id = payload.get("payment_method_id")
if not payment_method_id: if not payment_method_id:
@@ -1931,7 +1987,7 @@ def capturar_orden_paypal_setup(request: HttpRequest):
try: try:
payload = json.loads(request.body.decode("utf-8") or "{}") payload = json.loads(request.body.decode("utf-8") or "{}")
except (json.JSONDecodeError, UnicodeDecodeError): except (json.JSONDecodeError, UnicodeDecodeError):
return JsonResponse({"error": "Cuerpo de la petición inválido"}, status=400) return JsonResponse({"error": MSG_CUERPO_INVALIDO}, status=400)
paypal_order_id = payload.get("orderID") paypal_order_id = payload.get("orderID")
if not paypal_order_id: if not paypal_order_id:
@@ -2037,7 +2093,7 @@ def editar_perfil(request: HttpRequest):
if email != request.user.email and User.objects.filter(email=email).exists(): if email != request.user.email and User.objects.filter(email=email).exists():
messages.error(request, "Ya existe un usuario con este correo electrónico.") messages.error(request, "Ya existe un usuario con este correo electrónico.")
return render(request, "tienda/editar_perfil.html", {"form": form}) return render(request, EDITAR_PERFIL_TEMPLATE, {"form": form})
request.user.first_name = form.cleaned_data["first_name"] request.user.first_name = form.cleaned_data["first_name"]
request.user.last_name = form.cleaned_data["last_name"] request.user.last_name = form.cleaned_data["last_name"]
@@ -2054,7 +2110,7 @@ def editar_perfil(request: HttpRequest):
} }
form = EditProfileForm(initial=initial) form = EditProfileForm(initial=initial)
return render(request, "tienda/editar_perfil.html", {"form": form}) return render(request, EDITAR_PERFIL_TEMPLATE, {"form": form})
@login_required @login_required
@@ -2069,11 +2125,11 @@ def cambiar_contrasena(request: HttpRequest):
if not request.user.check_password(current_password): if not request.user.check_password(current_password):
messages.error(request, "La contraseña actual es incorrecta.") messages.error(request, "La contraseña actual es incorrecta.")
return render(request, "tienda/editar_perfil.html", {"password_form": ChangePasswordForm()}) return render(request, EDITAR_PERFIL_TEMPLATE, {"password_form": ChangePasswordForm()})
if len(new_password) < 8: if len(new_password) < 8:
messages.error(request, "La contraseña debe tener al menos 8 caracteres.") messages.error(request, "La contraseña debe tener al menos 8 caracteres.")
return render(request, "tienda/editar_perfil.html", {"password_form": ChangePasswordForm()}) return render(request, EDITAR_PERFIL_TEMPLATE, {"password_form": ChangePasswordForm()})
request.user.set_password(new_password) request.user.set_password(new_password)
request.user.save() request.user.save()
@@ -2084,7 +2140,7 @@ def cambiar_contrasena(request: HttpRequest):
return redirect("portal_usuario") return redirect("portal_usuario")
else: else:
messages.error(request, "Las contraseñas nuevas no coinciden o son inválidas.") messages.error(request, "Las contraseñas nuevas no coinciden o son inválidas.")
return render(request, "tienda/editar_perfil.html", {"password_form": form}) return render(request, EDITAR_PERFIL_TEMPLATE, {"password_form": form})
return redirect("editar_perfil") return redirect("editar_perfil")
@@ -2112,11 +2168,11 @@ def crear_direccion(request: HttpRequest):
if not _is_almeria_city(city): if not _is_almeria_city(city):
messages.error(request, "El pueblo/ciudad debe pertenecer a la provincia de Almería.") messages.error(request, "El pueblo/ciudad debe pertenecer a la provincia de Almería.")
return render(request, "tienda/editar_direccion.html", _address_form_context(form=form)) return render(request, EDITAR_DIRECCION_TEMPLATE, _address_form_context(form=form))
if not _is_almeria_postal_code(postal_code): if not _is_almeria_postal_code(postal_code):
messages.error(request, "Solo realizamos envíos en la provincia de Almería (código postal 04xxx).") messages.error(request, "Solo realizamos envíos en la provincia de Almería (código postal 04xxx).")
return render(request, "tienda/editar_direccion.html", _address_form_context(form=form)) return render(request, EDITAR_DIRECCION_TEMPLATE, _address_form_context(form=form))
ShippingAddress.objects.create( ShippingAddress.objects.create(
user=request.user, user=request.user,
@@ -2133,11 +2189,11 @@ def crear_direccion(request: HttpRequest):
messages.success(request, "Dirección creada correctamente.") messages.success(request, "Dirección creada correctamente.")
return redirect("direcciones_usuario") return redirect("direcciones_usuario")
else: else:
messages.error(request, "Por favor completa todos los campos obligatorios.") messages.error(request, MSG_CAMPOS_OBLIGATORIOS)
else: else:
form = ShippingAddressForm() form = ShippingAddressForm()
return render(request, "tienda/editar_direccion.html", _address_form_context(form=form)) return render(request, EDITAR_DIRECCION_TEMPLATE, _address_form_context(form=form))
@login_required @login_required
@@ -2154,11 +2210,11 @@ def editar_direccion(request: HttpRequest, id: int):
if not _is_almeria_city(city): if not _is_almeria_city(city):
messages.error(request, "El pueblo/ciudad debe pertenece a la provincia de Almería.") messages.error(request, "El pueblo/ciudad debe pertenece a la provincia de Almería.")
return render(request, "tienda/editar_direccion.html", _address_form_context(direccion, form=form)) return render(request, EDITAR_DIRECCION_TEMPLATE, _address_form_context(direccion, form=form))
if not _is_almeria_postal_code(postal_code): if not _is_almeria_postal_code(postal_code):
messages.error(request, "Solo realizamos envíos en la provincia de Almería (código postal 04xxx).") messages.error(request, "Solo realizamos envíos en la provincia de Almería (código postal 04xxx).")
return render(request, "tienda/editar_direccion.html", _address_form_context(direccion, form=form)) return render(request, EDITAR_DIRECCION_TEMPLATE, _address_form_context(direccion, form=form))
direccion.full_name = form.cleaned_data["full_name"] direccion.full_name = form.cleaned_data["full_name"]
direccion.address_line_1 = form.cleaned_data["address_line_1"] direccion.address_line_1 = form.cleaned_data["address_line_1"]
@@ -2173,7 +2229,7 @@ def editar_direccion(request: HttpRequest, id: int):
messages.success(request, "Dirección actualizada correctamente.") messages.success(request, "Dirección actualizada correctamente.")
return redirect("direcciones_usuario") return redirect("direcciones_usuario")
else: else:
messages.error(request, "Por favor completa todos los campos obligatorios.") messages.error(request, MSG_CAMPOS_OBLIGATORIOS)
else: else:
initial = { initial = {
"full_name": direccion.full_name, "full_name": direccion.full_name,
@@ -2187,7 +2243,7 @@ def editar_direccion(request: HttpRequest, id: int):
} }
form = ShippingAddressForm(initial=initial) form = ShippingAddressForm(initial=initial)
return render(request, "tienda/editar_direccion.html", _address_form_context(direccion, form=form)) return render(request, EDITAR_DIRECCION_TEMPLATE, _address_form_context(direccion, form=form))
@login_required @login_required
@@ -2275,7 +2331,7 @@ def reset_password(request: HttpRequest):
if form.is_valid(): if form.is_valid():
tasks.enviar_correo_recuperacion.delay(form.cleaned_data["email"]) tasks.enviar_correo_recuperacion.delay(form.cleaned_data["email"])
messages.info(request, "Si tienes una cuenta con ese correo electronico, se ha enviado un correo con un enlace") 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", {}) return render(request, INDEX_TEMPLATE, {})
@require_http_methods(["GET", "POST"]) @require_http_methods(["GET", "POST"])
def reset_password_phase2(request: HttpRequest, code: str): def reset_password_phase2(request: HttpRequest, code: str):