from django.shortcuts import render, redirect, get_object_or_404 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.decorators import login_required from django.contrib import messages from .models import User, Product, Category, Cart, CartItem, Image, Order, OrderItem, OrderMessage, ShippingAddress, StockReservation, StockReservationItem, VerificationCode, SavedPaymentMethod from . import tasks from .vars import ( PAGE_SIZE, VAT_RATE, SHIPPING_COUNTRY, ALMERIA_POSTAL_CODE_PREFIX, ALMERIA_MUNICIPALITIES_DISPLAY, STOCK_RESERVATION_MINUTES, ) from django.conf import settings from django.views.decorators.csrf import csrf_exempt from django.urls import reverse from django.utils import timezone from decimal import Decimal, ROUND_HALF_UP from datetime import timedelta import stripe from django.db import models, transaction from django.core.cache import cache import re import unicodedata import json import random, string import logging import requests # Create your views here. logger = logging.getLogger("tienda") audit_logger = logging.getLogger("tienda.audit") STOCK_RESERVATION_SESSION_KEY = "stock_reservation_id" STOCK_RESERVATION_PAYMENT_SESSION_KEY = "stock_reservation_payment_method" def _invalidate_product_cache(product_ids): unique_product_ids = {product_id for product_id in product_ids if product_id is not None} if not unique_product_ids: return cache.delete_many([f"product_{product_id}" for product_id in unique_product_ids]) def _normalize_location_text(value: str) -> str: normalized = unicodedata.normalize("NFD", (value or "")) without_accents = "".join(char for char in normalized if unicodedata.category(char) != "Mn") without_symbols = re.sub(r"[^a-zA-Z0-9\s-]", "", without_accents) collapsed = " ".join(without_symbols.replace("-", " ").lower().split()) return collapsed ALMERIA_MUNICIPALITIES = { _normalize_location_text(municipality) for municipality in ALMERIA_MUNICIPALITIES_DISPLAY } ALMERIA_MUNICIPALITIES.update( { municipality.removeprefix("la ") for municipality in ALMERIA_MUNICIPALITIES if municipality.startswith("la ") } ) ALMERIA_MUNICIPALITIES.update( { municipality.removeprefix("los ") for municipality in ALMERIA_MUNICIPALITIES if municipality.startswith("los ") } ) def _is_almeria_postal_code(postal_code: str) -> bool: """Valida que el código postal pertenezca a la provincia de Almería (04xxx).""" normalized = (postal_code or "").strip() return len(normalized) == 5 and normalized.isdigit() and normalized.startswith(ALMERIA_POSTAL_CODE_PREFIX) def _is_almeria_city(city: str) -> bool: """Valida que el municipio/pueblo pertenezca a la provincia de Almería.""" return _normalize_location_text(city) in ALMERIA_MUNICIPALITIES def _address_form_context(direccion=None): return { "direccion": direccion, "almeria_municipalities": ALMERIA_MUNICIPALITIES_DISPLAY, } def _get_client_ip(request: HttpRequest) -> str: forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR") if forwarded_for: return forwarded_for.split(",")[0].strip() return request.META.get("REMOTE_ADDR", "") # ==================== PAYPAL ORDERS API v2 HELPERS ==================== def _get_paypal_base_url() -> str: mode = getattr(settings, "PAYPAL_MODE", "sandbox") if mode == "live": return "https://api-m.paypal.com" return "https://api-m.sandbox.paypal.com" def _get_paypal_access_token() -> str: """Obtiene un access token de la API de PayPal.""" url = f"{_get_paypal_base_url()}/v1/oauth2/token" response = requests.post( url, auth=(settings.PAYPAL_CLIENT_ID, settings.PAYPAL_CLIENT_SECRET), data={"grant_type": "client_credentials"}, timeout=15, ) response.raise_for_status() return response.json()["access_token"] def _paypal_create_order(amount_eur: Decimal) -> dict: """Crea una orden PayPal y retorna el diccionario de respuesta con id y approve_link.""" token = _get_paypal_access_token() url = f"{_get_paypal_base_url()}/v2/checkout/orders" headers = { "Content-Type": "application/json", "Authorization": f"Bearer {token}", } payload = { "intent": "CAPTURE", "purchase_units": [ { "amount": { "currency_code": "EUR", "value": format(amount_eur, ".2f"), } } ], "application_context": { "brand_name": "Comercialmeria", "shipping_preference": "NO_SHIPPING", "user_action": "PAY_NOW", }, } response = requests.post(url, headers=headers, json=payload, timeout=15) response.raise_for_status() return response.json() def _paypal_capture_order(order_id: str) -> dict: """Captura una orden PayPal aprobada por el comprador.""" token = _get_paypal_access_token() url = f"{_get_paypal_base_url()}/v2/checkout/orders/{order_id}/capture" headers = { "Content-Type": "application/json", "Authorization": f"Bearer {token}", } response = requests.post(url, headers=headers, json={}, timeout=15) response.raise_for_status() return response.json() # ==================== STRIPE CUSTOMER HELPER ==================== def _get_or_create_stripe_customer(user) -> str: """Devuelve el stripe_customer_id del usuario, creando uno nuevo si es necesario.""" stripe.api_key = settings.STRIPE_SECRET_KEY existing = SavedPaymentMethod.objects.filter( user=user, method_type=SavedPaymentMethod.TYPE_CARD, stripe_customer_id__gt="", ).first() if existing: return existing.stripe_customer_id customer = stripe.Customer.create( email=user.email, name=(f"{user.first_name} {user.last_name}".strip()) or user.username, ) return customer.id def get_price_with_vat_decimal(price) -> Decimal: """Retorna un precio con IVA aplicado y redondeado a 2 decimales.""" return (Decimal(str(price)) * (Decimal("1") + Decimal(str(VAT_RATE)))).quantize( Decimal("0.01"), rounding=ROUND_HALF_UP, ) def home(request: HttpRequest): """Página de inicio del sitio""" categorias = Category.objects.all() # Mostrar productos destacados (últimos 8) featured_products = Product.objects.all().order_by('-id')[:8] # Contar productos y vendedores total_products = Product.objects.count() total_sellers = User.objects.filter(created_products__isnull=False).distinct().count() return render(request, "tienda/home.html", { "featured_products": featured_products, "categories": categorias, "total_products": total_products, "total_sellers": total_sellers }) def index(request: HttpRequest): """Página de productos (lista paginada)""" page = 1 if "page" in request.GET: page = int(request.GET["page"]) start = (page - 1) * PAGE_SIZE end = start + PAGE_SIZE products = Product.objects.all()[start:end] categorias = Category.objects.all() return render(request, "tienda/index.html", {"products": products, "categories": categorias}) def login(request: HttpRequest): if request.method == "POST": email = request.POST.get("email") password = request.POST.get("password") remember = request.POST.get("remember") client_ip = _get_client_ip(request) # Buscar usuario por email try: user_obj = User.objects.get(email=email) username = user_obj.username except User.DoesNotExist: audit_logger.warning( "LOGIN_FAILED email=%s reason=user_not_found ip=%s", email, client_ip, ) messages.error(request, "Correo electrónico o contraseña incorrectos.") return render(request, "tienda/login.html") # Autenticar usuario user = authenticate(request, username=username, password=password) user = User.objects.get(username=user.username) if user.registration_status == "CR": audit_logger.info( "LOGIN_FAILED email=%s reason=not_verified", 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") return render(request, "tienda/login.html") if user is not None: auth_login(request, user) # Configurar duración de sesión if not remember: request.session.set_expiry(0) else: request.session.set_expiry(1209600) # 14 días en segundos audit_logger.info( "LOGIN_SUCCESS user_id=%s email=%s ip=%s remember=%s", user.id, user.email, client_ip, bool(remember), ) tasks.enviar_correo_bienvenida.delay(user.email, "{} {}".format(user.first_name, user.last_name)) # result = send_email(user.email, "Inicio de sesión correcto", login_message.format(name = "{} {}".format(user.first_name, user.last_name))) messages.success(request, f"¡Bienvenido {user.first_name or user.username}!") return redirect("index") else: audit_logger.warning( "LOGIN_FAILED email=%s reason=invalid_credentials ip=%s", email, client_ip, ) messages.error(request, "Correo electrónico o contraseña incorrectos.") return render(request, "tienda/login.html") return render(request, "tienda/login.html") def register(request: HttpRequest): if request.user.is_authenticated: return redirect("index") if request.method == "POST": name = request.POST.get("name") email = request.POST.get("email") password = request.POST.get("password") password_confirm = request.POST.get("password_confirm") client_ip = _get_client_ip(request) # Validaciones if password != password_confirm: audit_logger.warning("REGISTER_FAILED email=%s reason=password_mismatch ip=%s", email, client_ip) messages.error(request, "Las contraseñas no coinciden.") return render(request, "tienda/register.html") if len(password) < 8: audit_logger.warning("REGISTER_FAILED email=%s reason=password_too_short ip=%s", email, client_ip) messages.error(request, "La contraseña debe tener al menos 8 caracteres.") return render(request, "tienda/register.html") if User.objects.filter(email=email).exists(): audit_logger.warning("REGISTER_FAILED email=%s reason=email_exists ip=%s", email, client_ip) messages.error(request, "Ya existe un usuario con este correo electrónico.") return render(request, "tienda/register.html") # Crear username a partir del email username = email.split("@")[0] # Si el username ya existe, agregar un número base_username = username counter = 1 while User.objects.filter(username=username).exists(): username = f"{base_username}{counter}" counter += 1 # Crear usuario user = User.objects.create_user( username=username, email=email, password=password, first_name=name ) audit_logger.info( "REGISTER_SUCCESS user_id=%s username=%s email=%s ip=%s", user.id, user.username, user.email, client_ip, ) tasks.enviar_correo_confirmacion.delay(user.id) messages.success(request, f"¡Cuenta creada exitosamente! Por favor, verifica tu correo entrando al Link enviado.") return redirect("index") return render(request, "tienda/register.html") def logout(request: HttpRequest): user_id = request.user.id if request.user.is_authenticated else None email = request.user.email if request.user.is_authenticated else None client_ip = _get_client_ip(request) auth_logout(request) audit_logger.info("LOGOUT user_id=%s email=%s ip=%s", user_id, email, client_ip) messages.success(request, "Has cerrado sesión exitosamente.") return redirect("index") def producto(request: HttpRequest, id: int): """Vista de detalle del producto con cacheo en Redis (5 minutos)""" cache_key = f'product_{id}' # Intentar obtener el producto del caché product = cache.get(cache_key) if product is None: # No está en caché, obtener de la base de datos product = Product.objects.select_related('category', 'primary_image', 'creator').prefetch_related('secondary_images').get(id=id) # Cachear el producto por 5 minutos (300 segundos) cache.set(cache_key, product, 300) return render(request, "tienda/producto.html", {"product": product}) def categoria(request: HttpRequest, id: int): page = 1 if "page" in request.GET: page = int(request.GET["page"]) start = (page - 1) * PAGE_SIZE end = start + PAGE_SIZE category = Category.objects.get(id=id) categories = Category.objects.all() products = Product.objects.filter(category=category)[start:end] return render(request, "tienda/index.html", {"products": products, "categories": categories}) # Funciones auxiliares para el carrito def get_or_create_cart(request): """Obtiene o crea un carrito para el usuario actual o sesión""" if request.user.is_authenticated: cart, created = Cart.objects.get_or_create(user=request.user) else: if not request.session.session_key: request.session.create() session_key = request.session.session_key cart, created = Cart.objects.get_or_create(session_key=session_key) 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 ]) _invalidate_product_cache(product_ids) 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): """Obtiene la dirección seleccionada desde JSON o form-data y valida pertenencia al usuario.""" shipping_address_id = request.POST.get("shipping_address_id") if not shipping_address_id: try: payload = json.loads(request.body.decode("utf-8") or "{}") shipping_address_id = payload.get("shipping_address_id") except (json.JSONDecodeError, UnicodeDecodeError): shipping_address_id = None if not shipping_address_id: return None try: shipping_address_id = int(shipping_address_id) except (TypeError, ValueError): return None 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, 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." order_total = Decimal("0.00") items_with_totals = [] purchased_items = [] for item in cart_items: product = item.product unit_price_with_vat = get_price_with_vat_decimal(product.price) line_total_with_vat = (unit_price_with_vat * item.quantity).quantize( Decimal("0.01"), rounding=ROUND_HALF_UP, ) order_total += 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(): 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( buyer=request.user if request.user.is_authenticated else None, shipping_address=shipping_address, session_key=None if request.user.is_authenticated else request.session.session_key, total=float(order_total), status=Order.STATUS_PAID, payment_method=payment_method, payment_reference=payment_reference or "", ) for item, unit_price_with_vat, line_total_with_vat in items_with_totals: product = item.product OrderItem.objects.create( order=order, product=product, product_name=product.name, seller=product.creator, quantity=item.quantity, unit_price=float(unit_price_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"]) _invalidate_product_cache(product_ids) cart.items.all().delete() 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): """Agrega un producto al carrito""" try: product = Product.objects.get(id=product_id) cart = get_or_create_cart(request) _cancel_active_stock_reservations_for_request(request) _clear_stock_reservation_session(request) # Obtener cantidad del POST o por defecto 1 quantity = int(request.POST.get('quantity', 1)) if quantity <= 0: messages.error(request, "La cantidad debe ser mayor que cero.") return redirect('producto', id=product_id) existing_item = CartItem.objects.filter(cart=cart, product=product).first() desired_quantity = quantity if existing_item is None else existing_item.quantity + quantity available = _get_available_stock_by_product([product.id]).get(product.id, 0) if desired_quantity > available: messages.error( request, f"No hay stock suficiente de '{product.name}'. Disponible: {available}, solicitado: {desired_quantity}.", ) return redirect('producto', id=product_id) # Buscar si ya existe el producto en el carrito cart_item, created = CartItem.objects.get_or_create( cart=cart, product=product, defaults={'quantity': quantity} ) if not created: # Si ya existe, incrementar la cantidad cart_item.quantity += quantity cart_item.save() messages.success(request, f"Se actualizó la cantidad de {product.name} en el carrito.") else: messages.success(request, f"{product.name} se agregó al carrito.") # Si es una petición AJAX, devolver JSON if request.headers.get('X-Requested-With') == 'XMLHttpRequest': return JsonResponse({ 'success': True, 'cart_count': cart.get_items_count(), 'message': str(messages.get_messages(request)) }) return redirect('view_cart') except ValueError: messages.error(request, "Cantidad no válida.") return redirect('producto', id=product_id) except Product.DoesNotExist: messages.error(request, "Producto no encontrado.") return redirect('index') def view_cart(request: HttpRequest): """Muestra el contenido del carrito""" cart = get_or_create_cart(request) cart_items = list(cart.items.select_related("product")) active_reservation_ids = _get_active_reservation_ids_for_request(request) stock_issues = _get_cart_stock_issues(cart_items, exclude_reservation_ids=active_reservation_ids) return render(request, "tienda/cart.html", { "cart": cart, "cart_items": cart_items, "stock_issues": stock_issues, }) def update_cart_item(request: HttpRequest, item_id: int): """Actualiza la cantidad de un item del carrito""" try: cart = get_or_create_cart(request) cart_item = CartItem.objects.get(id=item_id, cart=cart) _cancel_active_stock_reservations_for_request(request) _clear_stock_reservation_session(request) quantity = int(request.POST.get('quantity', 1)) if quantity > 0: available = _get_available_stock_by_product([cart_item.product_id]).get(cart_item.product_id, 0) if quantity > available: messages.error( request, f"No hay stock suficiente de '{cart_item.product.name}'. Disponible: {available}, solicitado: {quantity}.", ) return redirect('view_cart') cart_item.quantity = quantity cart_item.save() messages.success(request, "Cantidad actualizada.") else: cart_item.delete() messages.success(request, "Producto eliminado del carrito.") return redirect('view_cart') except CartItem.DoesNotExist: messages.error(request, "Producto no encontrado en el carrito.") return redirect('view_cart') except ValueError: messages.error(request, "Cantidad no válida.") return redirect('view_cart') def remove_from_cart(request: HttpRequest, item_id: int): """Elimina un producto del carrito""" try: cart = get_or_create_cart(request) _cancel_active_stock_reservations_for_request(request) _clear_stock_reservation_session(request) cart_item = CartItem.objects.get(id=item_id, cart=cart) product_name = cart_item.product.name cart_item.delete() messages.success(request, f"{product_name} eliminado del carrito.") except CartItem.DoesNotExist: messages.error(request, "Producto no encontrado en el carrito.") return redirect('view_cart') def clear_cart(request: HttpRequest): """Vacía todo el carrito""" cart = get_or_create_cart(request) _cancel_active_stock_reservations_for_request(request) _clear_stock_reservation_session(request) cart.items.all().delete() messages.success(request, "Carrito vaciado.") return redirect('view_cart') @login_required def mis_productos(request: HttpRequest): """Muestra los productos creados por el usuario autenticado""" productos = Product.objects.filter(creator=request.user).select_related('category', 'primary_image') return render(request, "tienda/mis_productos.html", { "productos": productos, "total_productos": productos.count() }) @login_required def pedidos_vendedor(request: HttpRequest): """Muestra los pedidos asignados al vendedor autenticado""" pedidos = OrderItem.objects.filter(seller=request.user).select_related( 'order', 'product', 'order__buyer', 'order__shipping_address' ).prefetch_related('messages__sender').order_by('-created_at') return render(request, "tienda/pedidos_vendedor.html", { "pedidos": pedidos, "total_pedidos": pedidos.count() }) @login_required def cambiar_estado_pedido(request: HttpRequest, item_id: int): """Cambia el estado de un pedido asignado al vendedor""" if request.method != "POST": messages.error(request, "Acción no permitida.") return redirect("pedidos_vendedor") order_item = get_object_or_404(OrderItem, id=item_id, seller=request.user) nuevo_estado = request.POST.get("estado") if nuevo_estado in dict(OrderItem.STATUS_CHOICES): order_item.status = nuevo_estado order_item.save() messages.success(request, f"Estado actualizado a '{order_item.get_status_display()}'.") else: messages.error(request, "Estado no válido.") return redirect("pedidos_vendedor") @login_required def enviar_mensaje_pedido(request: HttpRequest, item_id: int): """Envía un mensaje al comprador sobre un pedido""" if request.method != "POST": messages.error(request, "Acción no permitida.") return redirect("pedidos_vendedor") order_item = get_object_or_404(OrderItem, id=item_id, seller=request.user) mensaje = request.POST.get("mensaje", "").strip() if not mensaje: messages.error(request, "El mensaje no puede estar vacío.") return redirect("pedidos_vendedor") OrderMessage.objects.create( order_item=order_item, sender=request.user, message=mensaje ) messages.success(request, "Mensaje enviado correctamente.") return redirect("pedidos_vendedor") @login_required def crear_producto(request: HttpRequest): """Crea un nuevo producto""" if request.method == "POST": name = request.POST.get("name") briefdesc = request.POST.get("briefdesc") description = request.POST.get("description") price = request.POST.get("price") stock = request.POST.get("stock") category_id = request.POST.get("category") primary_image_file = request.FILES.get("primary_image") secondary_images_files = request.FILES.getlist("secondary_images") # Validaciones if not all([name, description, price, stock, category_id]): messages.error(request, "Por favor completa todos los campos obligatorios.") categories = Category.objects.all() return render(request, "tienda/crear_producto.html", {"categories": categories}) try: price = float(price) if price < 0: raise ValueError("El precio no puede ser negativo") except ValueError: messages.error(request, "El precio debe ser un número válido.") categories = Category.objects.all() return render(request, "tienda/crear_producto.html", {"categories": categories}) try: stock = int(stock) if stock < 0: raise ValueError("El stock no puede ser negativo") except ValueError: messages.error(request, "El stock debe ser un número entero válido.") categories = Category.objects.all() return render(request, "tienda/crear_producto.html", {"categories": categories}) try: category = Category.objects.get(id=category_id) except Category.DoesNotExist: messages.error(request, "Categoría no válida.") categories = Category.objects.all() return render(request, "tienda/crear_producto.html", {"categories": categories}) # Crear imagen principal si se proporciona primary_image = None if primary_image_file: primary_image = Image.objects.create( name=f"{name}_principal", image=primary_image_file ) # Crear producto producto = Product.objects.create( name=name, briefdesc=briefdesc or "", description=description, price=price, stock=stock, category=category, primary_image=primary_image, creator=request.user ) _invalidate_product_cache([producto.id]) # Agregar imágenes secundarias si se proporcionan if secondary_images_files: for idx, img_file in enumerate(secondary_images_files): secondary_img = Image.objects.create( name=f"{name}_secundaria_{idx+1}", image=img_file ) producto.secondary_images.add(secondary_img) messages.success(request, f"¡Producto '{name}' creado exitosamente!") return redirect("mis_productos") # GET request - mostrar formulario categories = Category.objects.all() return render(request, "tienda/crear_producto.html", {"categories": categories}) @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": name = request.POST.get("name") briefdesc = request.POST.get("briefdesc") description = request.POST.get("description") price = request.POST.get("price") stock = request.POST.get("stock") category_id = request.POST.get("category") primary_image_file = request.FILES.get("primary_image") secondary_images_files = request.FILES.getlist("secondary_images") if not all([name, description, price, stock, category_id]): messages.error(request, "Por favor completa todos los campos obligatorios.") categories = Category.objects.all() return render(request, "tienda/editar_producto.html", { "categories": categories, "producto": producto }) try: price = float(price) if price < 0: raise ValueError("El precio no puede ser negativo") except ValueError: messages.error(request, "El precio debe ser un número válido.") categories = Category.objects.all() return render(request, "tienda/editar_producto.html", { "categories": categories, "producto": producto }) try: stock = int(stock) if stock < 0: raise ValueError("El stock no puede ser negativo") except ValueError: messages.error(request, "El stock debe ser un número entero válido.") categories = Category.objects.all() return render(request, "tienda/editar_producto.html", { "categories": categories, "producto": producto }) try: category = Category.objects.get(id=category_id) except Category.DoesNotExist: messages.error(request, "Categoría no válida.") categories = Category.objects.all() return render(request, "tienda/editar_producto.html", { "categories": categories, "producto": producto }) producto.name = name producto.briefdesc = briefdesc or "" producto.description = description producto.price = price producto.stock = stock producto.category = category if primary_image_file: primary_image = Image.objects.create( name=f"{name}_principal", image=primary_image_file ) producto.primary_image = primary_image producto.save() _invalidate_product_cache([producto.id]) if secondary_images_files: producto.secondary_images.clear() for idx, img_file in enumerate(secondary_images_files): secondary_img = Image.objects.create( name=f"{name}_secundaria_{idx+1}", image=img_file ) producto.secondary_images.add(secondary_img) messages.success(request, f"¡Producto '{name}' actualizado exitosamente!") return redirect("mis_productos") categories = Category.objects.all() return render(request, "tienda/editar_producto.html", { "categories": categories, "producto": producto }) @login_required def borrar_producto(request: HttpRequest, id: int): """Borra un producto del usuario autenticado""" if request.method != "POST": messages.error(request, "Acción no permitida.") return redirect("mis_productos") producto = get_object_or_404(Product, id=id, creator=request.user) nombre = producto.name _invalidate_product_cache([producto.id]) producto.delete() messages.success(request, f"Producto '{nombre}' eliminado correctamente.") return redirect("mis_productos") @login_required def checkout(request: HttpRequest): 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) addresses = ShippingAddress.objects.filter(user=request.user) saved_cards = SavedPaymentMethod.objects.filter(user=request.user, method_type=SavedPaymentMethod.TYPE_CARD) saved_paypal = SavedPaymentMethod.objects.filter(user=request.user, method_type=SavedPaymentMethod.TYPE_PAYPAL).first() return render(request, "tienda/checkout.html", { "cart": cart, "cart_items": cart_items, "addresses": addresses, "stock_issues": stock_issues, "reservation_minutes": STOCK_RESERVATION_MINUTES, "saved_cards": saved_cards, "saved_paypal": saved_paypal, "stripe_publishable_key": settings.STRIPE_PUBLISHABLE_KEY, "paypal_client_id": settings.PAYPAL_CLIENT_ID, }) @csrf_exempt def stripe_config(request): if request.method == "GET": stripe_config = { "publicKey": settings.STRIPE_PUBLISHABLE_KEY } return JsonResponse(stripe_config, safe=False) @login_required @csrf_exempt def create_checkout_session(request: HttpRequest): if request.method != "POST": return JsonResponse({"error": "Método no permitido"}, status=405) try: shipping_address = _get_selected_shipping_address(request) if shipping_address is None: return JsonResponse({"error": "Debes seleccionar una dirección de envío válida."}, status=400) cart = get_or_create_cart(request) cart_items = list(cart.items.select_related("product")) if not cart_items: return JsonResponse({"error": "El carrito está vacío"}, status=400) active_reservation_ids = _get_active_reservation_ids_for_request(request) stock_issues = _get_cart_stock_issues(cart_items, exclude_reservation_ids=active_reservation_ids) if stock_issues: return JsonResponse({"error": _build_stock_issue_message(stock_issues[0])}, status=400) reservation, reservation_issues = _create_stock_reservation_for_cart( request, cart_items, StockReservation.PAYMENT_STRIPE, ) if reservation is None: return JsonResponse({"error": reservation_issues[0]}, status=400) stripe.api_key = settings.STRIPE_SECRET_KEY line_items = [] for item in cart_items: unit_price_with_vat = get_price_with_vat_decimal(item.product.price) unit_amount = int((unit_price_with_vat * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP)) if unit_amount <= 0: continue line_items.append({ "price_data": { "currency": "eur", "unit_amount": unit_amount, "product_data": { "name": item.product.name, "description": item.product.briefdesc or item.product.description }, }, "quantity": item.quantity, }) if not line_items: return JsonResponse({"error": "No hay productos válidos para pagar"}, status=400) success_url = request.build_absolute_uri(reverse("checkout_success")) cancel_url = request.build_absolute_uri(reverse("checkout_cancel")) session = stripe.checkout.Session.create( payment_method_types=["card"], mode="payment", line_items=line_items, success_url=success_url, cancel_url=cancel_url, ) request.session['stripe_session_id'] = session.id request.session['selected_shipping_address_id'] = shipping_address.id request.session[STOCK_RESERVATION_SESSION_KEY] = reservation.id request.session[STOCK_RESERVATION_PAYMENT_SESSION_KEY] = StockReservation.PAYMENT_STRIPE return JsonResponse({"sessionId": session.id}) except Exception as e: logger.exception("STRIPE_CHECKOUT_SESSION_ERROR user_id=%s error=%s", request.user.id, str(e)) return JsonResponse({"error": "Error al crear la sesión de pago. Por favor inténtalo de nuevo."}, status=500) @login_required def checkout_success(request: HttpRequest): payment_reference = request.session.get('stripe_session_id', "") shipping_address_id = request.session.get('selected_shipping_address_id') shipping_address = ShippingAddress.objects.filter(id=shipping_address_id, user=request.user).first() reservation = _get_session_stock_reservation(request, StockReservation.PAYMENT_STRIPE) order, order_error = create_order_from_cart( request, Order.PAYMENT_STRIPE, payment_reference, shipping_address, stock_reservation=reservation, ) if order is None: messages.error(request, order_error) return redirect("checkout") if 'stripe_session_id' in request.session: del request.session['stripe_session_id'] if 'selected_shipping_address_id' in request.session: del request.session['selected_shipping_address_id'] _clear_stock_reservation_session(request) messages.success(request, "Pago realizado correctamente. ¡Gracias por tu compra!") return render(request, "tienda/checkout_success.html", {"order": order}) @login_required def checkout_cancel(request: HttpRequest): _cancel_active_stock_reservations_for_request(request) _clear_stock_reservation_session(request) messages.info(request, "Pago cancelado. Puedes intentarlo de nuevo cuando quieras.") return render(request, "tienda/checkout_cancel.html", {}) def search(request: HttpRequest): """Vista para buscar productos""" query = request.GET.get('q', '').strip() products = [] categories = Category.objects.all() if query: # Buscar en nombre y descripción/briefdesc products = Product.objects.filter( models.Q(name__icontains=query) | models.Q(description__icontains=query) | models.Q(briefdesc__icontains=query) ).select_related('primary_image', 'creator') return render(request, "tienda/search.html", { "products": products, "query": query, "categories": categories }) def search_suggestions(request: HttpRequest): """API AJAX que retorna sugerencias de búsqueda en JSON""" query = request.GET.get('q', '').strip() suggestions = [] if query and len(query) >= 2: products = Product.objects.filter( models.Q(name__icontains=query) | models.Q(briefdesc__icontains=query) ).values_list('name', 'id', 'price', 'primary_image_id').distinct()[:8] for name, product_id, price, image_id in products: suggestions.append({ 'name': name, 'id': product_id, 'price': float(price), }) return JsonResponse({'suggestions': suggestions}) @login_required def create_paypal_payment(request: HttpRequest): """Crea un pago con PayPal y redirige a PayPal""" if request.method != "POST": return JsonResponse({"error": "Método no permitido"}, status=405) try: shipping_address = _get_selected_shipping_address(request) if shipping_address is None: return JsonResponse({"error": "Debes seleccionar una dirección de envío válida."}, status=400) import paypalrestsdk cart = get_or_create_cart(request) cart_items = list(cart.items.select_related("product")) if not cart_items: return JsonResponse({"error": "El carrito está vacío"}, status=400) active_reservation_ids = _get_active_reservation_ids_for_request(request) stock_issues = _get_cart_stock_issues(cart_items, exclude_reservation_ids=active_reservation_ids) if stock_issues: return JsonResponse({"error": _build_stock_issue_message(stock_issues[0])}, status=400) reservation, reservation_issues = _create_stock_reservation_for_cart( request, cart_items, StockReservation.PAYMENT_PAYPAL, ) if reservation is None: return JsonResponse({"error": reservation_issues[0]}, status=400) # Configurar PayPal paypalrestsdk.configure({ "mode": settings.PAYPAL_MODE, "client_id": settings.PAYPAL_CLIENT_ID, "client_secret": settings.PAYPAL_CLIENT_SECRET }) # Crear lista de items para PayPal payment_items = [] payment_total = Decimal("0.00") for item in cart_items: unit_price_with_vat = get_price_with_vat_decimal(item.product.price) line_total_with_vat = (unit_price_with_vat * item.quantity).quantize( Decimal("0.01"), rounding=ROUND_HALF_UP, ) payment_total += line_total_with_vat payment_items.append({ "name": item.product.name, "sku": f"product_{item.product.id}", "price": format(unit_price_with_vat, ".2f"), "currency": "EUR", "quantity": item.quantity }) total = format(payment_total, ".2f") # Crear el pago payment = paypalrestsdk.Payment({ "intent": "sale", "payer": { "payment_method": "paypal" }, "redirect_urls": { "return_url": request.build_absolute_uri(reverse("paypal_execute")), "cancel_url": request.build_absolute_uri(reverse("checkout_cancel")) }, "transactions": [ { "amount": { "total": total, "currency": "EUR", "details": { "subtotal": total, "tax": "0", "shipping": "0" } }, "description": "Compra de productos", "item_list": { "items": payment_items } } ] }) # Ejecutar el pago if payment.create(): # Guardar el payment ID en sesión request.session['paypal_payment_id'] = payment.id request.session['selected_shipping_address_id'] = shipping_address.id request.session[STOCK_RESERVATION_SESSION_KEY] = reservation.id request.session[STOCK_RESERVATION_PAYMENT_SESSION_KEY] = StockReservation.PAYMENT_PAYPAL # Encontrar el link de aprobación for link in payment.links: if link.rel == "approval_url": return JsonResponse({"redirect": link.href}) return JsonResponse({"error": "No se encontró la URL de aprobación"}, status=400) else: # Loguear el error error_msg = str(payment.error) if hasattr(payment, 'error') else "Error desconocido" logger.error("PAYPAL_CREATE_ERROR user_id=%s error=%s", request.user.id, error_msg) return JsonResponse({"error": f"Error al crear el pago: {error_msg}"}, status=400) except ImportError: logger.error("PAYPAL_SDK_NOT_INSTALLED") return JsonResponse({"error": "SDK de PayPal no instalado"}, status=500) except Exception as e: error_msg = str(e) logger.exception("PAYPAL_CREATE_EXCEPTION user_id=%s error=%s", request.user.id, error_msg) return JsonResponse({"error": f"Error: {error_msg}"}, status=500) @login_required def paypal_execute(request: HttpRequest): """Ejecuta el pago de PayPal después de la aprobación""" try: import paypalrestsdk except ImportError: messages.error(request, "PayPal SDK no está instalado") return redirect("checkout") payment_id = request.session.get('paypal_payment_id') payer_id = request.GET.get('PayerID') if not payment_id or not payer_id: messages.error(request, "Error: Datos de pago incompletos") return redirect("checkout") try: # Configurar PayPal paypalrestsdk.configure({ "mode": settings.PAYPAL_MODE, "client_id": settings.PAYPAL_CLIENT_ID, "client_secret": settings.PAYPAL_CLIENT_SECRET }) # Buscar el pago payment = paypalrestsdk.Payment.find(payment_id) # Ejecutar el pago if payment.execute({"payer_id": payer_id}): # Pago exitoso - crear pedido y limpiar el carrito shipping_address_id = request.session.get('selected_shipping_address_id') shipping_address = ShippingAddress.objects.filter(id=shipping_address_id, user=request.user).first() reservation = _get_session_stock_reservation(request, StockReservation.PAYMENT_PAYPAL) order, order_error = create_order_from_cart( request, Order.PAYMENT_PAYPAL, payment_id, shipping_address, stock_reservation=reservation, ) if order is None: messages.error(request, order_error) return redirect("checkout") # Limpiar la sesión if 'paypal_payment_id' in request.session: del request.session['paypal_payment_id'] if 'selected_shipping_address_id' in request.session: del request.session['selected_shipping_address_id'] _clear_stock_reservation_session(request) messages.success(request, "¡Pago realizado correctamente con PayPal! Gracias por tu compra.") return render(request, "tienda/checkout_success.html", {"order": order}) else: error_message = payment.error.get("message", "Error desconocido") messages.error(request, f"Error al procesar el pago: {error_message}") return redirect("checkout") except Exception as e: logger.exception("PAYPAL_EXECUTE_EXCEPTION user_id=%s error=%s", request.user.id, str(e)) messages.error(request, f"Error: {str(e)}") return redirect("checkout") # ==================== STRIPE PAYMENT INTENTS ==================== @login_required def crear_payment_intent(request: HttpRequest): """ Crea un Stripe PaymentIntent para el carrito actual. Acepta JSON: { shipping_address_id, saved_payment_method_id (opcional), save_card (bool) } """ if request.method != "POST": return JsonResponse({"error": "Método no permitido"}, status=405) try: payload = json.loads(request.body.decode("utf-8") or "{}") except (json.JSONDecodeError, UnicodeDecodeError): return JsonResponse({"error": "Cuerpo de la petición inválido"}, status=400) shipping_address = _get_selected_shipping_address(request) if shipping_address is None: return JsonResponse({"error": "Debes seleccionar una dirección de envío válida."}, status=400) cart = get_or_create_cart(request) cart_items = list(cart.items.select_related("product")) if not cart_items: return JsonResponse({"error": "El carrito está vacío"}, status=400) active_reservation_ids = _get_active_reservation_ids_for_request(request) stock_issues = _get_cart_stock_issues(cart_items, exclude_reservation_ids=active_reservation_ids) if stock_issues: return JsonResponse({"error": _build_stock_issue_message(stock_issues[0])}, status=400) reservation, reservation_issues = _create_stock_reservation_for_cart( request, cart_items, StockReservation.PAYMENT_STRIPE, ) if reservation is None: return JsonResponse({"error": reservation_issues[0]}, status=400) try: stripe.api_key = settings.STRIPE_SECRET_KEY order_total = sum( get_price_with_vat_decimal(item.product.price) * item.quantity for item in cart_items ) amount_cents = int( (order_total).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) * 100 ) pi_params = { "amount": amount_cents, "currency": "eur", "automatic_payment_methods": {"enabled": False}, "payment_method_types": ["card"], } # If using a saved card, attach customer + payment_method saved_pm_id = payload.get("saved_payment_method_id") if saved_pm_id: saved_pm = SavedPaymentMethod.objects.filter( id=saved_pm_id, user=request.user, method_type=SavedPaymentMethod.TYPE_CARD, ).first() if saved_pm is None: return JsonResponse({"error": "Método de pago no encontrado."}, status=400) pi_params["customer"] = saved_pm.stripe_customer_id pi_params["payment_method"] = saved_pm.stripe_payment_method_id payment_intent = stripe.PaymentIntent.create(**pi_params) request.session[STOCK_RESERVATION_SESSION_KEY] = reservation.id request.session[STOCK_RESERVATION_PAYMENT_SESSION_KEY] = StockReservation.PAYMENT_STRIPE request.session["selected_shipping_address_id"] = shipping_address.id request.session["stripe_save_card"] = bool(payload.get("save_card", False)) return JsonResponse({ "client_secret": payment_intent.client_secret, "payment_intent_id": payment_intent.id, }) except Exception as e: logger.exception("CREATE_PAYMENT_INTENT_ERROR user_id=%s error=%s", request.user.id, str(e)) return JsonResponse({"error": "Error al crear el pago. Por favor inténtalo de nuevo."}, status=500) @login_required def confirmar_pago_tarjeta(request: HttpRequest): """ Verificar que el PaymentIntent fue exitoso y crear el pedido. Acepta JSON: { payment_intent_id, payment_method_id (si nueva tarjeta) } """ if request.method != "POST": return JsonResponse({"error": "Método no permitido"}, status=405) try: payload = json.loads(request.body.decode("utf-8") or "{}") except (json.JSONDecodeError, UnicodeDecodeError): return JsonResponse({"error": "Cuerpo de la petición inválido"}, status=400) payment_intent_id = payload.get("payment_intent_id") if not payment_intent_id: return JsonResponse({"error": "Falta el ID del intento de pago"}, status=400) try: stripe.api_key = settings.STRIPE_SECRET_KEY payment_intent = stripe.PaymentIntent.retrieve(payment_intent_id) except Exception as e: logger.exception("RETRIEVE_PAYMENT_INTENT_ERROR user_id=%s error=%s", request.user.id, str(e)) return JsonResponse({"error": "Error al verificar el pago"}, status=500) if payment_intent.status != "succeeded": return JsonResponse({"error": f"El pago no fue completado (estado: {payment_intent.status})"}, status=400) shipping_address_id = request.session.get("selected_shipping_address_id") shipping_address = ShippingAddress.objects.filter(id=shipping_address_id, user=request.user).first() reservation = _get_session_stock_reservation(request, StockReservation.PAYMENT_STRIPE) order, order_error = create_order_from_cart( request, Order.PAYMENT_STRIPE, payment_intent_id, shipping_address, stock_reservation=reservation, ) if order is None: return JsonResponse({"error": order_error}, status=400) # Optionally save the card for future use save_card = request.session.pop("stripe_save_card", False) new_payment_method_id = payload.get("payment_method_id") if save_card and new_payment_method_id: try: customer_id = _get_or_create_stripe_customer(request.user) pm = stripe.PaymentMethod.retrieve(new_payment_method_id) stripe.PaymentMethod.attach(new_payment_method_id, customer=customer_id) card = pm.card label = f"{card.brand.capitalize()} •••• {card.last4}" SavedPaymentMethod.objects.create( user=request.user, method_type=SavedPaymentMethod.TYPE_CARD, label=label, stripe_customer_id=customer_id, stripe_payment_method_id=new_payment_method_id, is_default=not SavedPaymentMethod.objects.filter(user=request.user).exists(), ) except Exception as e: logger.warning("SAVE_CARD_ERROR user_id=%s error=%s", request.user.id, str(e)) if "selected_shipping_address_id" in request.session: del request.session["selected_shipping_address_id"] _clear_stock_reservation_session(request) return JsonResponse({"success": True, "order_id": order.id, "transaction_code": order.transaction_code}) # ==================== PAYPAL ORDERS API ==================== @login_required def crear_orden_paypal(request: HttpRequest): """ Crea una orden de PayPal con el total del carrito actual (Orders API v2). Acepta JSON: { shipping_address_id } Retorna: { id: paypal_order_id } """ if request.method != "POST": return JsonResponse({"error": "Método no permitido"}, status=405) shipping_address = _get_selected_shipping_address(request) if shipping_address is None: return JsonResponse({"error": "Debes seleccionar una dirección de envío válida."}, status=400) cart = get_or_create_cart(request) cart_items = list(cart.items.select_related("product")) if not cart_items: return JsonResponse({"error": "El carrito está vacío"}, status=400) active_reservation_ids = _get_active_reservation_ids_for_request(request) stock_issues = _get_cart_stock_issues(cart_items, exclude_reservation_ids=active_reservation_ids) if stock_issues: return JsonResponse({"error": _build_stock_issue_message(stock_issues[0])}, status=400) reservation, reservation_issues = _create_stock_reservation_for_cart( request, cart_items, StockReservation.PAYMENT_PAYPAL, ) if reservation is None: return JsonResponse({"error": reservation_issues[0]}, status=400) try: order_total = sum( get_price_with_vat_decimal(item.product.price) * item.quantity for item in cart_items ) order_total = order_total.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) paypal_order = _paypal_create_order(order_total) paypal_order_id = paypal_order.get("id") request.session["paypal_order_id"] = paypal_order_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 return JsonResponse({"id": paypal_order_id}) except Exception as e: logger.exception("CREAR_ORDEN_PAYPAL_ERROR user_id=%s error=%s", request.user.id, str(e)) return JsonResponse({"error": "Error al crear la orden de PayPal. Por favor inténtalo de nuevo."}, status=500) @login_required def capturar_orden_paypal(request: HttpRequest): """ Captura una orden de PayPal aprobada y crea el pedido en nuestra BD. Acepta JSON: { orderID } """ if request.method != "POST": return JsonResponse({"error": "Método no permitido"}, status=405) try: payload = json.loads(request.body.decode("utf-8") or "{}") except (json.JSONDecodeError, UnicodeDecodeError): return JsonResponse({"error": "Cuerpo de la petición inválido"}, status=400) paypal_order_id = payload.get("orderID") if not paypal_order_id: return JsonResponse({"error": "Falta el ID de la orden de PayPal"}, status=400) # Verify this order belongs to this session session_order_id = request.session.get("paypal_order_id") if session_order_id != paypal_order_id: logger.warning( "PAYPAL_ORDER_MISMATCH user_id=%s session=%s received=%s", request.user.id, session_order_id, paypal_order_id, ) return JsonResponse({"error": "ID de orden inválido"}, status=400) try: capture_data = _paypal_capture_order(paypal_order_id) except Exception as e: logger.exception("CAPTURAR_ORDEN_PAYPAL_ERROR user_id=%s error=%s", request.user.id, str(e)) return JsonResponse({"error": "Error al capturar el pago de PayPal. Por favor inténtalo de nuevo."}, status=500) capture_status = capture_data.get("status") if capture_status != "COMPLETED": return JsonResponse({"error": f"El pago de PayPal no fue completado (estado: {capture_status})"}, status=400) # Extract payer info to optionally save as payment method payer = capture_data.get("payer", {}) payer_email = payer.get("email_address", "") payer_id = payer.get("payer_id", "") shipping_address_id = request.session.get("selected_shipping_address_id") shipping_address = ShippingAddress.objects.filter(id=shipping_address_id, user=request.user).first() reservation = _get_session_stock_reservation(request, StockReservation.PAYMENT_PAYPAL) order, order_error = create_order_from_cart( request, Order.PAYMENT_PAYPAL, paypal_order_id, shipping_address, stock_reservation=reservation, ) if order is None: return JsonResponse({"error": order_error}, status=400) # Save payer info if they want to store the PayPal account (offered in the template) save_paypal = payload.get("save_paypal", False) if save_paypal and payer_email: already_saved = SavedPaymentMethod.objects.filter( user=request.user, method_type=SavedPaymentMethod.TYPE_PAYPAL, paypal_email=payer_email, ).exists() if not already_saved: SavedPaymentMethod.objects.create( user=request.user, method_type=SavedPaymentMethod.TYPE_PAYPAL, label=payer_email, paypal_email=payer_email, paypal_payer_id=payer_id, is_default=not SavedPaymentMethod.objects.filter(user=request.user).exists(), ) if "paypal_order_id" in request.session: del request.session["paypal_order_id"] if "selected_shipping_address_id" in request.session: del request.session["selected_shipping_address_id"] _clear_stock_reservation_session(request) return JsonResponse({ "success": True, "order_id": order.id, "transaction_code": order.transaction_code, "payer_email": payer_email, }) # ==================== MÉTODOS DE PAGO DEL USUARIO ==================== @login_required def metodos_pago(request: HttpRequest): """Lista los métodos de pago guardados del usuario.""" metodos = SavedPaymentMethod.objects.filter(user=request.user) return render(request, "tienda/metodos_pago.html", { "metodos": metodos, "cards_exist": metodos.filter(method_type=SavedPaymentMethod.TYPE_CARD).exists(), "paypal_exist": metodos.filter(method_type=SavedPaymentMethod.TYPE_PAYPAL).exists(), }) @login_required def agregar_tarjeta(request: HttpRequest): """Página para añadir una nueva tarjeta usando Stripe SetupIntent.""" return render(request, "tienda/agregar_tarjeta.html", { "stripe_publishable_key": settings.STRIPE_PUBLISHABLE_KEY, }) @login_required def crear_setup_intent(request: HttpRequest): """ Crea un Stripe SetupIntent y retorna el client_secret para que el frontend pueda montar el Card Element y confirmar sin realizar un cobro. """ if request.method != "POST": return JsonResponse({"error": "Método no permitido"}, status=405) try: stripe.api_key = settings.STRIPE_SECRET_KEY customer_id = _get_or_create_stripe_customer(request.user) setup_intent = stripe.SetupIntent.create( customer=customer_id, payment_method_types=["card"], ) return JsonResponse({ "client_secret": setup_intent.client_secret, "customer_id": customer_id, }) except Exception as e: logger.exception("CREATE_SETUP_INTENT_ERROR user_id=%s error=%s", request.user.id, str(e)) return JsonResponse({"error": "Error al iniciar el proceso de configuración. Por favor inténtalo de nuevo."}, status=500) @login_required def confirmar_setup_intent(request: HttpRequest): """ Tras la confirmación del SetupIntent en el frontend, guarda la tarjeta. Acepta JSON: { payment_method_id, setup_intent_id } """ if request.method != "POST": return JsonResponse({"error": "Método no permitido"}, status=405) try: payload = json.loads(request.body.decode("utf-8") or "{}") except (json.JSONDecodeError, UnicodeDecodeError): return JsonResponse({"error": "Cuerpo de la petición inválido"}, status=400) payment_method_id = payload.get("payment_method_id") if not payment_method_id: return JsonResponse({"error": "Falta el ID del método de pago"}, status=400) try: stripe.api_key = settings.STRIPE_SECRET_KEY customer_id = _get_or_create_stripe_customer(request.user) # Attach the PaymentMethod to the customer stripe.PaymentMethod.attach(payment_method_id, customer=customer_id) pm = stripe.PaymentMethod.retrieve(payment_method_id) card = pm.card label = f"{card.brand.capitalize()} •••• {card.last4} (exp. {card.exp_month:02d}/{card.exp_year})" has_existing = SavedPaymentMethod.objects.filter(user=request.user).exists() saved = SavedPaymentMethod.objects.create( user=request.user, method_type=SavedPaymentMethod.TYPE_CARD, label=label, stripe_customer_id=customer_id, stripe_payment_method_id=payment_method_id, is_default=not has_existing, ) return JsonResponse({"success": True, "label": label, "id": saved.id}) except Exception as e: logger.exception("CONFIRMAR_SETUP_INTENT_ERROR user_id=%s error=%s", request.user.id, str(e)) return JsonResponse({"error": "Error al guardar la tarjeta. Por favor inténtalo de nuevo."}, status=500) @login_required def eliminar_metodo_pago(request: HttpRequest, id: int): """Elimina un método de pago guardado del usuario.""" if request.method != "POST": messages.error(request, "Acción no permitida.") return redirect("metodos_pago") metodo = get_object_or_404(SavedPaymentMethod, id=id, user=request.user) # If it's a Stripe card, detach from Stripe too if metodo.method_type == SavedPaymentMethod.TYPE_CARD and metodo.stripe_payment_method_id: try: stripe.api_key = settings.STRIPE_SECRET_KEY stripe.PaymentMethod.detach(metodo.stripe_payment_method_id) except Exception as e: logger.warning("DETACH_PAYMENT_METHOD_ERROR user_id=%s error=%s", request.user.id, str(e)) metodo.delete() messages.success(request, "Método de pago eliminado correctamente.") return redirect("metodos_pago") @login_required def agregar_paypal(request: HttpRequest): """Página para guardar una cuenta de PayPal como método de pago (usa un pago de verificación de 0.01 €).""" return render(request, "tienda/agregar_paypal.html", { "paypal_client_id": settings.PAYPAL_CLIENT_ID, }) @login_required def crear_orden_paypal_setup(request: HttpRequest): """ Crea una orden PayPal de 0.01 € para verificar/guardar la cuenta. Retorna { id: paypal_order_id } """ if request.method != "POST": return JsonResponse({"error": "Método no permitido"}, status=405) try: paypal_order = _paypal_create_order(Decimal("0.01")) return JsonResponse({"id": paypal_order.get("id")}) except Exception as e: logger.exception("CREAR_ORDEN_PAYPAL_SETUP_ERROR user_id=%s error=%s", request.user.id, str(e)) return JsonResponse({"error": "Error al iniciar la verificación de PayPal. Por favor inténtalo de nuevo."}, status=500) @login_required def capturar_orden_paypal_setup(request: HttpRequest): """ Captura la orden de verificación de PayPal y guarda la cuenta del usuario. Acepta JSON: { orderID } """ if request.method != "POST": return JsonResponse({"error": "Método no permitido"}, status=405) try: payload = json.loads(request.body.decode("utf-8") or "{}") except (json.JSONDecodeError, UnicodeDecodeError): return JsonResponse({"error": "Cuerpo de la petición inválido"}, status=400) paypal_order_id = payload.get("orderID") if not paypal_order_id: return JsonResponse({"error": "Falta el ID de la orden"}, status=400) try: capture_data = _paypal_capture_order(paypal_order_id) except Exception as e: logger.exception("CAPTURAR_PAYPAL_SETUP_ERROR user_id=%s error=%s", request.user.id, str(e)) return JsonResponse({"error": "Error al verificar la cuenta de PayPal. Por favor inténtalo de nuevo."}, status=500) if capture_data.get("status") != "COMPLETED": return JsonResponse({"error": "No se pudo verificar la cuenta de PayPal"}, status=400) payer = capture_data.get("payer", {}) payer_email = payer.get("email_address", "") payer_id = payer.get("payer_id", "") if not payer_email: return JsonResponse({"error": "No se pudo obtener el email de PayPal"}, status=400) already_saved = SavedPaymentMethod.objects.filter( user=request.user, method_type=SavedPaymentMethod.TYPE_PAYPAL, paypal_email=payer_email, ).exists() if not already_saved: has_existing = SavedPaymentMethod.objects.filter(user=request.user).exists() SavedPaymentMethod.objects.create( user=request.user, method_type=SavedPaymentMethod.TYPE_PAYPAL, label=payer_email, paypal_email=payer_email, paypal_payer_id=payer_id, is_default=not has_existing, ) return JsonResponse({"success": True, "email": payer_email, "already_existed": False}) else: return JsonResponse({"success": True, "email": payer_email, "already_existed": True}) # ==================== PORTAL DE USUARIO ==================== @login_required def portal_usuario(request: HttpRequest): """Dashboard del portal de usuario""" # Obtener estadísticas del usuario total_orders = Order.objects.filter(buyer=request.user).count() total_addresses = ShippingAddress.objects.filter(user=request.user).count() # Obtener pedidos recientes (como comprador) recent_orders = Order.objects.filter(buyer=request.user).order_by('-created_at')[:5] # Obtener mensajes recientes sin leer (de vendedores) recent_messages = OrderMessage.objects.filter( order_item__order__buyer=request.user ).exclude(sender=request.user).order_by('-created_at')[:5] return render(request, "tienda/portal_usuario.html", { "total_orders": total_orders, "total_addresses": total_addresses, "recent_orders": recent_orders, "recent_messages": recent_messages, }) @login_required def mis_compras(request: HttpRequest): """Lista completa de compras del usuario autenticado""" orders = Order.objects.filter(buyer=request.user).prefetch_related('items').order_by('-created_at') return render(request, "tienda/mis_compras.html", { "orders": orders, "total_orders": orders.count(), }) @login_required def mis_recibos(request: HttpRequest): """Lista de recibos (pedidos pagados) del usuario autenticado""" receipts = Order.objects.filter( buyer=request.user, status=Order.STATUS_PAID ).prefetch_related('items').order_by('-created_at') return render(request, "tienda/mis_recibos.html", { "receipts": receipts, "total_receipts": receipts.count(), }) @login_required def editar_perfil(request: HttpRequest): """Edita la información del perfil del usuario""" if request.method == "POST": first_name = request.POST.get("first_name", "").strip() last_name = request.POST.get("last_name", "").strip() email = request.POST.get("email", "").strip() # Validar email único (excepto el propio) if email != request.user.email and User.objects.filter(email=email).exists(): messages.error(request, "Ya existe un usuario con este correo electrónico.") return render(request, "tienda/editar_perfil.html") # Actualizar usuario request.user.first_name = first_name request.user.last_name = last_name request.user.email = email request.user.save() messages.success(request, "Perfil actualizado correctamente.") return redirect("portal_usuario") return render(request, "tienda/editar_perfil.html") @login_required def cambiar_contrasena(request: HttpRequest): """Cambia la contraseña del usuario""" if request.method == "POST": current_password = request.POST.get("current_password") new_password = request.POST.get("new_password") confirm_password = request.POST.get("confirm_password") # Verificar contraseña actual if not request.user.check_password(current_password): messages.error(request, "La contraseña actual es incorrecta.") return render(request, "tienda/editar_perfil.html") # Validar nueva contraseña if new_password != confirm_password: messages.error(request, "Las contraseñas nuevas no coinciden.") return render(request, "tienda/editar_perfil.html") if len(new_password) < 8: messages.error(request, "La contraseña debe tener al menos 8 caracteres.") return render(request, "tienda/editar_perfil.html") # Cambiar contraseña request.user.set_password(new_password) request.user.save() # Mantener la sesión activa auth_login(request, request.user) messages.success(request, "Contraseña actualizada correctamente.") return redirect("portal_usuario") return redirect("editar_perfil") @login_required def direcciones_usuario(request: HttpRequest): """Lista las direcciones de entrega del usuario""" direcciones = ShippingAddress.objects.filter(user=request.user) return render(request, "tienda/direcciones.html", { "direcciones": direcciones }) @login_required def crear_direccion(request: HttpRequest): """Crea una nueva dirección de entrega""" if request.method == "POST": full_name = request.POST.get("full_name", "").strip() address_line_1 = request.POST.get("address_line_1", "").strip() address_line_2 = request.POST.get("address_line_2", "").strip() city = request.POST.get("city", "").strip() postal_code = request.POST.get("postal_code", "").strip() country = SHIPPING_COUNTRY phone = request.POST.get("phone", "").strip() is_default = request.POST.get("is_default") == "on" # Validaciones if not all([full_name, address_line_1, city, postal_code, phone]): messages.error(request, "Por favor completa todos los campos obligatorios.") return render(request, "tienda/editar_direccion.html", _address_form_context(request.POST)) if not _is_almeria_city(city): messages.error(request, "El pueblo/ciudad debe pertenecer a la provincia de Almería.") return render(request, "tienda/editar_direccion.html", _address_form_context(request.POST)) 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).") return render(request, "tienda/editar_direccion.html", _address_form_context(request.POST)) # Crear dirección ShippingAddress.objects.create( user=request.user, full_name=full_name, address_line_1=address_line_1, address_line_2=address_line_2, city=city, postal_code=postal_code, country=country, phone=phone, is_default=is_default ) messages.success(request, "Dirección creada correctamente.") return redirect("direcciones_usuario") return render(request, "tienda/editar_direccion.html", _address_form_context()) @login_required def editar_direccion(request: HttpRequest, id: int): """Edita una dirección de entrega existente""" direccion = get_object_or_404(ShippingAddress, id=id, user=request.user) if request.method == "POST": direccion.full_name = request.POST.get("full_name", "").strip() direccion.address_line_1 = request.POST.get("address_line_1", "").strip() direccion.address_line_2 = request.POST.get("address_line_2", "").strip() direccion.city = request.POST.get("city", "").strip() direccion.postal_code = request.POST.get("postal_code", "").strip() direccion.country = SHIPPING_COUNTRY direccion.phone = request.POST.get("phone", "").strip() direccion.is_default = request.POST.get("is_default") == "on" # Validaciones if not all([direccion.full_name, direccion.address_line_1, direccion.city, direccion.postal_code, direccion.phone]): messages.error(request, "Por favor completa todos los campos obligatorios.") return render(request, "tienda/editar_direccion.html", _address_form_context(direccion)) if not _is_almeria_city(direccion.city): messages.error(request, "El pueblo/ciudad debe pertenecer a la provincia de Almería.") return render(request, "tienda/editar_direccion.html", _address_form_context(direccion)) if not _is_almeria_postal_code(direccion.postal_code): 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)) direccion.save() messages.success(request, "Dirección actualizada correctamente.") return redirect("direcciones_usuario") return render(request, "tienda/editar_direccion.html", _address_form_context(direccion)) @login_required def eliminar_direccion(request: HttpRequest, id: int): """Elimina una dirección de entrega""" if request.method != "POST": messages.error(request, "Acción no permitida.") return redirect("direcciones_usuario") direccion = get_object_or_404(ShippingAddress, id=id, user=request.user) direccion.delete() messages.success(request, "Dirección eliminada correctamente.") return redirect("direcciones_usuario") @login_required def mensajes_comprador(request: HttpRequest): """Muestra los mensajes recibidos de vendedores""" # Obtener todos los order items del comprador con mensajes order_items = OrderItem.objects.filter( order__buyer=request.user ).prefetch_related( 'messages__sender', 'product', 'seller' ).order_by('-created_at') return render(request, "tienda/mensajes_comprador.html", { "order_items": order_items }) def send_test_email(request: HttpRequest): message = """ Correo de prueba, deberias recibir esto bien y esto deberia tener un enter """ result = send_email("danilacasito8@gmail.com", "Correo de Prueba", message) if result[0]: return HttpResponse("Mira tu bandeja") else: return HttpResponse(result[1]) def verify(request: HttpRequest, code: str): obj = None try: obj = VerificationCode.objects.get(code=code) except VerificationCode.DoesNotExist: return HttpResponse("
No existe el codigo de verificación
") if obj: if obj.code_mode == VerificationCode.VerificationModes.VERIFY_ACCOUNT: obj.user.registration_status = obj.user.RegisterStatus.ACTIVE obj.user.save() obj.delete() return redirect("index") else: return HttpResponse("No existe el codigo de verificación
") def reset_password(request: HttpRequest): if request.user.is_authenticated: return redirect("index") return render(request, "tienda/reset_password", {}) def rgpd(request: HttpRequest): 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()