Corrige bugs de la revisión de home/views.py (dinero, bypass de pago, 500s)

Seguridad / dinero:
- store_novawow_view: recalcula precios y valida ítems contra la BD (Item) en vez
  de confiar en el precio/ítems que manda el cliente; guarda en sesión un carrito
  ya validado para la entrega
- *_success_view (x9): @require_paid_stripe verifica el pago real en Stripe por
  session_id (Stripe lo añade a la success_url) y marca StripeLog.fulfilled para
  evitar re-entregas; cierra el bypass de ir directo a la success-url sin pagar
- gold_character_view: deja de truncar el precio (int(4.99)->4); pasa el precio real

Correctitud / 500s:
- change_password_view: .get(...,'') evita AttributeError si falta un campo
- revive_character_view: lógica de éxito corregida (vacío=éxito, no vacío=error,
  None=fallo de comunicación) — antes marcaba error como éxito y caía a HTML
- store category y recruit_a_friend: guardas para Category.DoesNotExist y cuenta None

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 17:56:20 +00:00
parent faa9aabdbf
commit 758435fe77
4 changed files with 133 additions and 26 deletions
@@ -0,0 +1,18 @@
# Generated by Django 6.0.7 on 2026-07-12 17:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('home', '0012_alter_accountactivation_salt_and_more'),
]
operations = [
migrations.AddField(
model_name='stripelog',
name='fulfilled',
field=models.BooleanField(default=False),
),
]
+1
View File
@@ -296,6 +296,7 @@ class StripeLog(models.Model):
mode = models.CharField(max_length=10, choices=[("test", "Test"), ("live", "Live")]) # Modo de Stripe
character_name = models.CharField(max_length=100) # Nombre del personaje que compró
timestamp = models.DateTimeField(auto_now_add=True) # Fecha y hora
fulfilled = models.BooleanField(default=False) # Si la entrega ya se procesó (evita re-entregas)
def __str__(self):
return f"{self.username} compró {self.product_name} por {self.amount}€ ({self.mode})"
+17
View File
@@ -4,8 +4,25 @@ from .models import StripeLog
from django.utils.timezone import now
stripe.api_key = settings.STRIPE_SECRET_KEY
def is_session_paid(session_id):
"""True si la sesión de Stripe existe y está realmente pagada."""
if not session_id:
return False
try:
s = stripe.checkout.Session.retrieve(session_id)
return bool(s and getattr(s, "payment_status", None) == "paid")
except Exception:
return False
def create_checkout_session(account_id, username, email, acore_ip, amount, character_name, currency="eur", success_url=None, cancel_url=None, product_name=""):
try:
# Añadir el id de sesión a la success_url para poder verificar el pago
# al volver (Stripe sustituye {CHECKOUT_SESSION_ID} por el id real).
if success_url:
sep = "&" if "?" in success_url else "?"
success_url = f"{success_url}{sep}session_id={{CHECKOUT_SESSION_ID}}"
# 🔹 Crear sesión de pago en Stripe
session = stripe.checkout.Session.create(
payment_method_types=["card"],
+96 -25
View File
@@ -8,7 +8,8 @@ from django.contrib.auth import logout
from .pagos_sumup import crear_checkout, crear_checkout_battlepay, obtener_checkout
from django import forms
import stripe
from .pagos_stripe import create_checkout_session
from .pagos_stripe import create_checkout_session, is_session_paid
from functools import wraps
from .models import Noticia, ClienteCategoria, ServerSelection, RecruitAFriend, DownloadClientPage, ContentCreator, RecruitReward, ClaimedReward, AccountActivation, SecurityToken, GuildRenameSettings, VoteSite, VoteLog, HomeApiPoints, UnstuckHistory, ReviveHistory, RenamePrice, CustomizePrice, ChangeRacePrice, ChangeFactionPrice, LevelUpPrice, GoldPrice, TransferPrice, Category, Item, StripeLog, LoginAttempt, Pedido
from django.views.decorators.csrf import csrf_exempt
@@ -26,6 +27,33 @@ from decimal import Decimal, ROUND_HALF_UP
# Configuración del logger
logger = logging.getLogger(__name__)
def require_paid_stripe(redirect_to='index'):
"""
Protege las vistas de "éxito" de pago: exige un `session_id` de Stripe
realmente PAGADO en la URL (Stripe lo añade tras el pago) y evita re-entregas
marcando el StripeLog como `fulfilled`. Cierra el bypass de ir directo a la
success-url sin pagar.
"""
def deco(view):
@wraps(view)
def wrapper(request, *args, **kwargs):
session_id = request.GET.get('session_id')
if not is_session_paid(session_id):
messages.error(request, 'No se ha podido verificar el pago.')
return redirect(redirect_to)
log = StripeLog.objects.filter(session_id=session_id).first()
if log is not None and log.fulfilled:
messages.info(request, 'Este pago ya había sido procesado.')
return redirect(redirect_to)
response = view(request, *args, **kwargs)
if log is not None:
log.fulfilled = True
log.save(update_fields=['fulfilled'])
return response
return wrapper
return deco
@csrf_exempt
def stripe_webhook(request):
payload = request.body
@@ -542,6 +570,9 @@ def recruit_a_friend_view(request):
if not username:
return JsonResponse({'success': False, 'message': 'Debes estar logueado para reclamar una recompensa.'})
if not account_id or account_status is None:
return JsonResponse({'success': False, 'message': 'No se ha encontrado tu cuenta.'})
reward_id = request.POST.get('reward_id')
character_name = request.POST.get('character')
@@ -903,10 +934,10 @@ def change_password_view(request):
email = user_data['email']
if request.method == 'POST':
current_password = request.POST.get('cur-password').strip()
new_password = request.POST.get('new-password').strip()
conf_new_password = request.POST.get('conf-new-password').strip()
security_token = request.POST.get('security-token').strip()
current_password = request.POST.get('cur-password', '').strip()
new_password = request.POST.get('new-password', '').strip()
conf_new_password = request.POST.get('conf-new-password', '').strip()
security_token = request.POST.get('security-token', '').strip()
# Validar campos vacíos
if not current_password or not new_password or not conf_new_password or not security_token:
@@ -1321,6 +1352,7 @@ def rename_character_view(request):
return render(request, 'account/rename_character.html', {'characters': characters, 'price': price})
@require_paid_stripe()
def rename_success_view(request):
# Procesar el comando SOAP tras el pago exitoso
character_name = request.session.get('rename_character')
@@ -1592,6 +1624,7 @@ def customize_character_view(request):
return render(request, 'account/customize_character.html', {'characters': characters, 'price': price})
@require_paid_stripe()
def customize_success_view(request):
# Procesar el comando SOAP tras el pago exitoso
character_name = request.session.get('customize_character')
@@ -1682,6 +1715,7 @@ def change_race_character_view(request):
return render(request, 'account/change_race.html', {'characters': characters, 'price': price})
@require_paid_stripe()
def change_race_success_view(request):
# Procesar el comando SOAP tras el pago exitoso
character_name = request.session.get('change_race_character')
@@ -1772,6 +1806,7 @@ def change_faction_character_view(request):
return render(request, 'account/change_faction.html', {'characters': characters, 'price': price})
@require_paid_stripe()
def change_faction_success_view(request):
# Procesar el comando SOAP tras el pago exitoso
character_name = request.session.get('change_faction_character')
@@ -1863,6 +1898,7 @@ def level_up_character_view(request):
return render(request, 'account/level_up.html', {'characters': characters, 'price': price})
@require_paid_stripe()
def level_up_success_view(request):
character_name = request.session.get('levelup_character')
if not character_name:
@@ -1944,8 +1980,8 @@ def gold_character_view(request):
if not gold_price:
return JsonResponse({'success': False, 'message': '<span class="red-form-response">Cantidad de oro no válida.</span>'})
# Convertir el precio a formato que Stripe acepta (en centavos)
stripe_price = int(float(gold_price.price) * 1)
# El precio va en euros (con decimales); el helper lo pasa a céntimos (×100)
stripe_price = gold_price.price
# Crear sesión de pago con Stripe
success_url = request.build_absolute_uri(reverse('gold-success'))
@@ -1981,6 +2017,7 @@ def gold_character_view(request):
return render(request, 'account/gold_character.html', {'characters': characters, 'gold_prices': gold_prices})
@require_paid_stripe()
def gold_success_view(request):
# Procesar el pago exitoso
transaction = request.session.pop('gold_transaction', None)
@@ -2129,6 +2166,7 @@ def transfer_character_view(request):
@require_paid_stripe()
def transfer_success_view(request):
transfer_data = request.session.get('transfer_data')
if not transfer_data:
@@ -2185,8 +2223,11 @@ def store_novawow_view(request):
# Si se está solicitando una categoría específica, devolver solo los ítems de esa categoría
if category_name:
items_in_category = Category.objects.get(name=category_name).items.values()
return JsonResponse({'success': True, 'items': list(items_in_category)})
try:
category = Category.objects.get(name=category_name)
except Category.DoesNotExist:
return JsonResponse({'success': False, 'message': 'Categoría no encontrada'}, status=404)
return JsonResponse({'success': True, 'items': list(category.items.values())})
# Obtener carrito desde la sesión
carrito = request.session.get('carrito', [])
@@ -2199,25 +2240,49 @@ def store_novawow_view(request):
# Obtener datos del body
data = json.loads(request.body)
character_name = data.get('character')
carrito = data.get('carrito')
carrito_cliente = data.get('carrito') or []
if not character_name:
return JsonResponse({'success': False, 'message': 'Por favor, selecciona un personaje.'})
# ACTUALIZAR EL CARRITO Y FORZAR LA SESIÓN
request.session['carrito'] = carrito
request.session.modified = True # Forzar que Django guarde la sesión inmediatamente
# RECALCULAR EL PRECIO CON EL CARRITO ACTUALIZADO
total_price = sum(Decimal(item['precio']) for item in carrito)
total_price_with_iva = (total_price * Decimal('1.21')).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
# Verificar si el personaje pertenece al usuario
if character_name not in characters:
return JsonResponse({'success': False, 'message': 'No tienes permiso para renombrar este personaje.'})
return JsonResponse({'success': False, 'message': 'No tienes permiso para este personaje.'})
# Definir siempre `product_description` antes de usarlo
product_description = ", ".join([f"{item.get('nombre', 'Ítem desconocido')} x{item.get('cantidad', 1)}" for item in carrito])
if not carrito_cliente:
return JsonResponse({'success': False, 'message': 'El carrito está vacío.'})
# VALIDAR ÍTEMS Y PRECIOS CONTRA LA BASE DE DATOS (nunca confiar en el
# precio ni en los ítems que manda el cliente).
try:
ids_solicitados = [int(item['id']) for item in carrito_cliente]
except (KeyError, TypeError, ValueError):
return JsonResponse({'success': False, 'message': 'Carrito no válido.'})
items_db = {i.item_id: i for i in Item.objects.filter(item_id__in=ids_solicitados)}
carrito = [] # carrito saneado que se guardará en sesión
total_price = Decimal('0.00')
for item in carrito_cliente:
item_id = int(item['id'])
db_item = items_db.get(item_id)
if not db_item:
return JsonResponse({'success': False, 'message': f'Ítem no disponible en la tienda (ID {item_id}).'})
try:
cantidad = max(1, int(item.get('cantidad', 1)))
except (TypeError, ValueError):
cantidad = 1
total_price += db_item.price * cantidad
carrito.append({'id': item_id, 'nombre': db_item.name, 'cantidad': cantidad})
total_price_with_iva = (total_price * Decimal('1.21')).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
# Guardar en sesión el carrito YA VALIDADO (para la entrega tras el pago)
request.session['carrito'] = carrito
request.session.modified = True
# Descripción del producto a partir de los datos validados
product_description = ", ".join([f"{it['nombre']} x{it['cantidad']}" for it in carrito])
# Obtener detalles del usuario
with connections['acore_auth'].cursor() as cursor:
@@ -2266,6 +2331,7 @@ def store_novawow_view(request):
from collections import Counter
@require_paid_stripe(redirect_to='store-novawow')
def store_novawow_success_view(request):
# Procesar el comando SOAP tras el pago exitoso
character_name = request.session.get('store_novawow')
@@ -2350,13 +2416,17 @@ def revive_character_view(request):
command = f".revive {character_name}"
response = execute_soap_command(command)
# Si la respuesta es vacía o None, asumir éxito
if response:
# None = fallo de comunicación con el servidor
if response is None:
return JsonResponse({'success': False, 'message': '<span class="red-form-response">Error de comunicación con el servidor. Intente nuevamente más tarde.</span>'})
# Respuesta vacía = éxito (el comando .revive no devuelve texto si va bien)
if response.strip() == "":
ReviveHistory.objects.create(character_name=character_name, used_at=now())
return JsonResponse({'success': True, 'message': f'<span class="ok-form-response">El personaje {character_name} ha sido revivido con éxito.</span>'})
if response is None:
return JsonResponse({'success': False, 'message': '<span class="red-form-response">Error de comunicación con el servidor. Intente nuevamente más tarde.</span>'})
# Respuesta no vacía = el servidor devolvió un mensaje/error
return JsonResponse({'success': False, 'message': f'<span class="red-form-response">{response}</span>'})
return render(request, 'account/revive_character.html', {'characters': characters.keys()})
@@ -2516,6 +2586,7 @@ def rename_guild_view(request):
})
@require_paid_stripe()
def guild_rename_success_view(request):
# Obtener los datos de la sesión
guild_rename_data = request.session.get('guild_rename')