diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..a34d4f8 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,59 @@ +name: Build and Push Docker Image + +on: + push: + branches: + - '**' # Esto aplica para cualquier rama + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout del código + uses: actions/checkout@v6 + - name: Configurar Python + uses: actions/setup-python@v6 + with: + python-version: '3.14' + - name: Instalar dependencias + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Ejecutar tests + env: + DJANGO_SETTINGS_MODULE: proyecto.settings + run: | + python manage.py test + docker: + runs-on: ubuntu-latest + needs: test + permissions: + contents: read + packages: write # Necesario para subir a GHCR + + steps: + - name: Checkout del código + uses: actions/checkout@v6 + + - name: Configurar Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Login en GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Preparar tag de imagen + run: | + TAG=$(echo "${{ github.ref_name }}" | sed 's/\//-/g') + echo "IMAGE_TAG=$TAG" >> $GITHUB_ENV + + - name: Build y Push + uses: docker/build-push-action@v6 + with: + context: . + push: true + # Sanitizamos el nombre de la rama (reemplazamos / por -) + tags: ghcr.io/dsaub/proyecto-mvc:${{ env.IMAGE_TAG }} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index acf9d2d..9852d82 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,14 +4,9 @@ ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 WORKDIR /app -RUN adduser -D -S app - COPY requirements.txt /app/ RUN apk --no-cache update && apk --no-cache upgrade -RUN apk add --no-cache --virtual .build-deps build-base mariadb-dev libffi-dev \ - && apk add --no-cache mariadb-connector-c \ - && pip install --no-cache-dir -r requirements.txt \ - && apk del .build-deps +RUN pip install --no-cache-dir -r requirements.txt COPY . /app/ RUN chmod +x /app/entrypoint.sh @@ -21,8 +16,4 @@ EXPOSE 8000 RUN mkdir -pv /fonts COPY tienda/static/fonts/ /fonts/ -RUN chown -R app: /app /fonts -RUN chmod 770 /app/entrypoint.sh -USER app - ENTRYPOINT ["/bin/sh", "/app/entrypoint.sh"] \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..076a0d0 --- /dev/null +++ b/Makefile @@ -0,0 +1,6 @@ +docker: + docker build -t ghcr.io/dsaub/proyecto-mvc:development . + docker push ghcr.io/dsaub/proyecto-mvc:development + +test: + ./manage.py test \ No newline at end of file diff --git a/entrypoint.sh b/entrypoint.sh index 7fdb705..c505069 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,11 +1,13 @@ #!/bin/sh +set -eu + echo "Sleeping due to mysql..." sleep 10 echo "Running DB migrations..." python manage.py migrate echo "Collecting STATIC..." -python manage.py collectstatic --noinput +python manage.py collectstatic --noinput --clear echo "Running server!" diff --git a/proyecto/settings.py b/proyecto/settings.py index 9eac4f9..2732df4 100644 --- a/proyecto/settings.py +++ b/proyecto/settings.py @@ -203,7 +203,11 @@ STORAGES = { 'BACKEND': 'django.core.files.storage.FileSystemStorage', }, 'staticfiles': { - 'BACKEND': 'whitenoise.storage.CompressedManifestStaticFilesStorage', + 'BACKEND': ( + 'django.contrib.staticfiles.storage.StaticFilesStorage' + if DEBUG + else 'whitenoise.storage.CompressedManifestStaticFilesStorage' + ), }, } diff --git a/tienda/admin.py b/tienda/admin.py index c31c77f..ab03a94 100644 --- a/tienda/admin.py +++ b/tienda/admin.py @@ -1,5 +1,5 @@ from django.contrib import admin -from .models import Category, Image, Product, Cart, CartItem, Order, OrderItem, OrderMessage, StockReservation, StockReservationItem, User, VerificationCode +from .models import Category, Image, Product, Cart, CartItem, Order, OrderItem, OrderMessage, StockReservation, StockReservationItem, User, VerificationCode, SavedPaymentMethod # Register your models here. admin.site.register(Category) @@ -86,4 +86,11 @@ class StockReservationAdmin(admin.ModelAdmin): list_display = ('id', 'user', 'session_key', 'status', 'payment_method', 'expires_at', 'created_at') list_filter = ('status', 'payment_method', 'created_at') search_fields = ('user__username', 'user__email', 'session_key') - inlines = [StockReservationItemInline] \ No newline at end of file + inlines = [StockReservationItemInline] + + +@admin.register(SavedPaymentMethod) +class SavedPaymentMethodAdmin(admin.ModelAdmin): + list_display = ('id', 'user', 'method_type', 'label', 'is_default', 'created_at') + list_filter = ('method_type', 'is_default', 'created_at') + search_fields = ('user__username', 'user__email', 'label', 'paypal_email') \ No newline at end of file diff --git a/tienda/migrations/0005_savedpaymentmethod.py b/tienda/migrations/0005_savedpaymentmethod.py new file mode 100644 index 0000000..45617ca --- /dev/null +++ b/tienda/migrations/0005_savedpaymentmethod.py @@ -0,0 +1,35 @@ +# Generated by Django 6.0.1 on 2026-04-10 06:09 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tienda', '0004_product_stock_stockreservation_stockreservationitem'), + ] + + operations = [ + migrations.CreateModel( + name='SavedPaymentMethod', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('method_type', models.CharField(choices=[('card', 'Tarjeta'), ('paypal', 'PayPal')], max_length=10)), + ('label', models.CharField(max_length=200, verbose_name='Etiqueta')), + ('stripe_customer_id', models.CharField(blank=True, default='', max_length=100)), + ('stripe_payment_method_id', models.CharField(blank=True, default='', max_length=100)), + ('paypal_email', models.CharField(blank=True, default='', max_length=254)), + ('paypal_payer_id', models.CharField(blank=True, default='', max_length=100)), + ('is_default', models.BooleanField(default=False, verbose_name='Predeterminado')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payment_methods', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Método de pago guardado', + 'verbose_name_plural': 'Métodos de pago guardados', + 'ordering': ['-is_default', '-created_at'], + }, + ), + ] diff --git a/tienda/migrations/0006_alter_category_name.py b/tienda/migrations/0006_alter_category_name.py new file mode 100644 index 0000000..83fbaeb --- /dev/null +++ b/tienda/migrations/0006_alter_category_name.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.4 on 2026-04-17 07:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tienda', '0005_savedpaymentmethod'), + ] + + operations = [ + migrations.AlterField( + model_name='category', + name='name', + field=models.CharField(max_length=200, unique=True), + ), + ] diff --git a/tienda/migrations/__pycache__/0005_savedpaymentmethod.cpython-314.pyc b/tienda/migrations/__pycache__/0005_savedpaymentmethod.cpython-314.pyc new file mode 100644 index 0000000..d1996ce Binary files /dev/null and b/tienda/migrations/__pycache__/0005_savedpaymentmethod.cpython-314.pyc differ diff --git a/tienda/migrations/__pycache__/0006_alter_category_name.cpython-314.pyc b/tienda/migrations/__pycache__/0006_alter_category_name.cpython-314.pyc new file mode 100644 index 0000000..808b8e4 Binary files /dev/null and b/tienda/migrations/__pycache__/0006_alter_category_name.cpython-314.pyc differ diff --git a/tienda/models.py b/tienda/models.py index 6b74a54..56d986e 100644 --- a/tienda/models.py +++ b/tienda/models.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from django.db import models from django.contrib.auth.models import User, AbstractUser from django.utils.crypto import get_random_string @@ -253,6 +255,41 @@ class OrderMessage(models.Model): return f"Mensaje de {self.sender} - {self.created_at}" +class SavedPaymentMethod(models.Model): + """Métodos de pago guardados por el usuario (tarjetas Stripe o cuentas PayPal).""" + TYPE_CARD = "card" + TYPE_PAYPAL = "paypal" + TYPE_CHOICES = [ + (TYPE_CARD, "Tarjeta"), + (TYPE_PAYPAL, "PayPal"), + ] + + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="payment_methods") + method_type = models.CharField(max_length=10, choices=TYPE_CHOICES) + label = models.CharField(max_length=200, verbose_name="Etiqueta") + # Stripe fields + stripe_customer_id = models.CharField(max_length=100, blank=True, default="") + stripe_payment_method_id = models.CharField(max_length=100, blank=True, default="") + # PayPal fields + paypal_email = models.CharField(max_length=254, blank=True, default="") + paypal_payer_id = models.CharField(max_length=100, blank=True, default="") + is_default = models.BooleanField(default=False, verbose_name="Predeterminado") + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + verbose_name = "Método de pago guardado" + verbose_name_plural = "Métodos de pago guardados" + ordering = ["-is_default", "-created_at"] + + def __str__(self): + return f"{self.user.username} – {self.label}" + + def save(self, *args, **kwargs): + if self.is_default: + SavedPaymentMethod.objects.filter(user=self.user, is_default=True).update(is_default=False) + super().save(*args, **kwargs) + + class ShippingAddress(models.Model): """Direcciones de entrega de los usuarios""" user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='shipping_addresses') @@ -279,4 +316,4 @@ class ShippingAddress(models.Model): # Si se marca como predeterminada, desmarcar las demás del usuario if self.is_default: ShippingAddress.objects.filter(user=self.user, is_default=True).update(is_default=False) - super().save(*args, **kwargs) \ No newline at end of file + super().save(*args, **kwargs) diff --git a/tienda/static/css/custom.css b/tienda/static/css/custom.css index b4bd2e0..58dc78e 100644 --- a/tienda/static/css/custom.css +++ b/tienda/static/css/custom.css @@ -234,6 +234,46 @@ p.price { object-position: center; } +/* Estilos para el footer */ +.footer-link { + color: inherit; + text-decoration: none; +} +.footer-link:hover { + text-decoration: underline; +} + +/* Estilos para páginas legales / informativas */ +.legal-container { + max-width: 860px; + margin: 2rem auto; + padding: 0 1rem; +} +.legal-container h1 { + margin-bottom: 1.5rem; +} + +.legal-section { + margin-bottom: 2rem; +} +.legal-section h2 { + font-size: 1.2rem; + margin-bottom: 0.75rem; + border-bottom: 1px solid #dee2e6; + padding-bottom: 0.25rem; +} +.legal-section h3 { + font-size: 1rem; + font-weight: 600; + margin-top: 1rem; + margin-bottom: 0.4rem; +} + +.legal-footer { + margin-top: 2rem; + color: #6c757d; +} + .texto-ajustado { overflow-wrap: anywhere; } diff --git a/tienda/templates/tienda/agregar_paypal.html b/tienda/templates/tienda/agregar_paypal.html new file mode 100644 index 0000000..56b57e2 --- /dev/null +++ b/tienda/templates/tienda/agregar_paypal.html @@ -0,0 +1,88 @@ +{% extends "tienda/base.html" %} +{% load static %} + +{% block head %} + +{% endblock %} + +{% block content %} +{% csrf_token %} +
+
+

Añadir cuenta de PayPal

+ +
+
+ +
+
+
+
+

+ Se realizará un pequeño pago de verificación de 0,01 € para confirmar + tu cuenta de PayPal. Tu cuenta quedará guardada para futuras compras. +

+ +
+ +
+ ✅ Cuenta de PayPal guardada correctamente. + Ver mis métodos de pago +
+
+ + Cancelar +
+
+
+
+ + +{% endblock %} diff --git a/tienda/templates/tienda/agregar_tarjeta.html b/tienda/templates/tienda/agregar_tarjeta.html new file mode 100644 index 0000000..1b1b0a5 --- /dev/null +++ b/tienda/templates/tienda/agregar_tarjeta.html @@ -0,0 +1,130 @@ +{% extends "tienda/base.html" %} +{% load static %} + +{% block head %} + + +{% endblock %} + +{% block content %} +{% csrf_token %} +
+
+

Añadir Tarjeta

+ +
+
+ +
+
+
+
+

+ Introduce los datos de tu tarjeta. No se realizará ningún cobro ahora; la tarjeta + se guardará de forma segura en Stripe para usar en tus próximas compras. +

+ +
+ +
+ +
+ + +
+
+

Procesando...

+
+
+ ✅ Tarjeta guardada correctamente. + Ver mis métodos de pago +
+ + Cancelar +
+
+
+
+ + +{% endblock %} diff --git a/tienda/templates/tienda/aviso_legal.html b/tienda/templates/tienda/aviso_legal.html new file mode 100644 index 0000000..a847648 --- /dev/null +++ b/tienda/templates/tienda/aviso_legal.html @@ -0,0 +1,49 @@ +{% extends "tienda/base.html" %} +{% load static %} +{% block content %} + +
+
+ +
+
+ +{% endblock %} diff --git a/tienda/templates/tienda/ayuda.html b/tienda/templates/tienda/ayuda.html new file mode 100644 index 0000000..0d336c5 --- /dev/null +++ b/tienda/templates/tienda/ayuda.html @@ -0,0 +1,82 @@ +{% extends "tienda/base.html" %} +{% load static %} +{% block content %} + +
+
+ +
+
+ +{% endblock %} diff --git a/tienda/templates/tienda/base.html b/tienda/templates/tienda/base.html index 7a8150c..28364dd 100644 --- a/tienda/templates/tienda/base.html +++ b/tienda/templates/tienda/base.html @@ -77,7 +77,7 @@ {% block head %}{% endblock %} - + {% cache 500 sidebar request.user.username %} {% endcache %} -
+
{% if messages %}
@@ -155,26 +155,39 @@ {% cache 500 footer %} - {% cache 500 scripts %} @@ -268,4 +281,4 @@ {% endcache %} - \ No newline at end of file + diff --git a/tienda/templates/tienda/checkout.html b/tienda/templates/tienda/checkout.html index 8999c4b..1b88ee9 100644 --- a/tienda/templates/tienda/checkout.html +++ b/tienda/templates/tienda/checkout.html @@ -4,45 +4,33 @@ {% block head %} - - + {% endblock %} - {% block content %}
- {% csrf_token %} - +

Checkout

← Volver al carrito @@ -66,6 +54,7 @@ Si el pago no se completa en ese tiempo, la reserva se cancelará automáticamente.
+
1) Selecciona la dirección de envío
@@ -90,6 +79,7 @@
+
@@ -114,42 +104,110 @@ - - + + - - + + - - + +
Subtotal:{{ cart.get_total|format_price }}€Subtotal:{{ cart.get_total|format_price }}€
IVA (21%):+{{ cart.get_vat_amount|format_price }}€IVA (21%):+{{ cart.get_vat_amount|format_price }}€
Total:{{ cart.get_total_with_vat|format_price }}€Total:{{ cart.get_total_with_vat|format_price }}€
-
-

2) Selecciona tu método de pago

-
- - - + +
+
+
2) Selecciona tu método de pago
+ + + + + +
+ {% if saved_cards %} +
+

Tarjetas guardadas:

+ {% for card in saved_cards %} +
+ + +
+ {% endfor %} +
+ + +
+
+ {% endif %} + +
+
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+ {% if saved_paypal %} +
+ Cuenta PayPal guardada: + {{ saved_paypal.paypal_email }} +
+ {% endif %} +
+ + +
+
+ {% if not addresses or stock_issues %} +
Selecciona una dirección de envío válida para activar el pago.
+ {% endif %} +
+
+ + +
+ {% else %}
Tu carrito está vacío.
{% endif %} @@ -157,72 +215,169 @@
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/tienda/templates/tienda/cookies.html b/tienda/templates/tienda/cookies.html new file mode 100644 index 0000000..3fdee13 --- /dev/null +++ b/tienda/templates/tienda/cookies.html @@ -0,0 +1,80 @@ +{% extends "tienda/base.html" %} +{% load static %} +{% block content %} + +
+
+ +
+
+ +{% endblock %} diff --git a/tienda/templates/tienda/devoluciones.html b/tienda/templates/tienda/devoluciones.html new file mode 100644 index 0000000..613c82e --- /dev/null +++ b/tienda/templates/tienda/devoluciones.html @@ -0,0 +1,62 @@ +{% extends "tienda/base.html" %} +{% load static %} +{% block content %} + +
+
+ +
+
+ +{% endblock %} diff --git a/tienda/templates/tienda/direcciones.html b/tienda/templates/tienda/direcciones.html index 6f98cd6..2584d2a 100644 --- a/tienda/templates/tienda/direcciones.html +++ b/tienda/templates/tienda/direcciones.html @@ -21,6 +21,7 @@ Inicio Mi Perfil Direcciones + Métodos de Pago Mensajes
diff --git a/tienda/templates/tienda/editar_perfil.html b/tienda/templates/tienda/editar_perfil.html index d812059..61c2319 100644 --- a/tienda/templates/tienda/editar_perfil.html +++ b/tienda/templates/tienda/editar_perfil.html @@ -21,6 +21,7 @@ Inicio Mi Perfil Direcciones + Métodos de Pago Mensajes
diff --git a/tienda/templates/tienda/home.html b/tienda/templates/tienda/home.html index 201fcb9..ce7ab88 100644 --- a/tienda/templates/tienda/home.html +++ b/tienda/templates/tienda/home.html @@ -128,9 +128,11 @@ 🛍️ Explorar Productos + {% if not user.is_authenticated %} 📝 Registrarse + {% endif %}
@@ -219,6 +221,7 @@ {% endif %} +{% if not user.is_authenticated %}
@@ -232,4 +235,5 @@
+{% endif %} {% endblock %} diff --git a/tienda/templates/tienda/mensajes_comprador.html b/tienda/templates/tienda/mensajes_comprador.html index 98cafe8..4694f5a 100644 --- a/tienda/templates/tienda/mensajes_comprador.html +++ b/tienda/templates/tienda/mensajes_comprador.html @@ -21,6 +21,7 @@ Inicio Mi Perfil Direcciones + Métodos de Pago Mensajes diff --git a/tienda/templates/tienda/metodos_pago.html b/tienda/templates/tienda/metodos_pago.html new file mode 100644 index 0000000..4c8568d --- /dev/null +++ b/tienda/templates/tienda/metodos_pago.html @@ -0,0 +1,90 @@ +{% extends "tienda/base.html" %} +{% load static %} + +{% block content %} +
+
+

Métodos de Pago

+ +
+
+ + +
+
+ +
+
+ +
+ +
+
+
+
💳 Tarjetas
+ ➕ Añadir tarjeta +
+
+ {% with has_card=False %} + {% for metodo in metodos %}{% if metodo.method_type == 'card' %} +
+
+ {{ metodo.label }} + {% if metodo.is_default %}Predeterminada{% endif %} +
+
+ {% csrf_token %} + +
+
+ {% endif %}{% endfor %} + {% endwith %} + {% if not cards_exist %} +

No tienes tarjetas guardadas.

+ {% endif %} +
+
+
+ + +
+
+
+
🅿️ PayPal
+ ➕ Añadir PayPal +
+
+ {% for metodo in metodos %}{% if metodo.method_type == 'paypal' %} +
+
+ {{ metodo.paypal_email }} + {% if metodo.is_default %}Predeterminada{% endif %} +
+
+ {% csrf_token %} + +
+
+ {% endif %}{% endfor %} + {% if not paypal_exist %} +

No tienes cuentas de PayPal guardadas.

+ {% endif %} +
+
+
+
+ +{% endblock %} diff --git a/tienda/templates/tienda/mis_compras.html b/tienda/templates/tienda/mis_compras.html index 28dfdfd..16b4d8e 100644 --- a/tienda/templates/tienda/mis_compras.html +++ b/tienda/templates/tienda/mis_compras.html @@ -20,6 +20,7 @@ Inicio Compras Recibos + Métodos de Pago Mensajes diff --git a/tienda/templates/tienda/mis_recibos.html b/tienda/templates/tienda/mis_recibos.html index 3c87fcb..ae193db 100644 --- a/tienda/templates/tienda/mis_recibos.html +++ b/tienda/templates/tienda/mis_recibos.html @@ -20,6 +20,7 @@ Inicio Compras Recibos + Métodos de Pago Mensajes diff --git a/tienda/templates/tienda/portal_usuario.html b/tienda/templates/tienda/portal_usuario.html index 557497e..67546e3 100644 --- a/tienda/templates/tienda/portal_usuario.html +++ b/tienda/templates/tienda/portal_usuario.html @@ -18,6 +18,7 @@ Recibos Mi Perfil Direcciones + Métodos de Pago Mensajes @@ -67,6 +68,15 @@ +
+
+
+
💳 Métodos de Pago
+

gestiona tarjetas y cuentas PayPal

+ Gestionar +
+
+
diff --git a/tienda/templates/tienda/rgpd.html b/tienda/templates/tienda/rgpd.html index 09546c1..37ff7bb 100644 --- a/tienda/templates/tienda/rgpd.html +++ b/tienda/templates/tienda/rgpd.html @@ -1,5 +1,5 @@ -{% load static %} {% extends "tienda/base.html" %} +{% load static %} {% block content %}
@@ -61,4 +61,4 @@
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/tienda/templates/tienda/sobre_nosotros.html b/tienda/templates/tienda/sobre_nosotros.html new file mode 100644 index 0000000..5ce9573 --- /dev/null +++ b/tienda/templates/tienda/sobre_nosotros.html @@ -0,0 +1,44 @@ +{% extends "tienda/base.html" %} +{% load static %} +{% block content %} + +
+
+ +
+
+ +{% endblock %} diff --git a/tienda/templates/tienda/terminos.html b/tienda/templates/tienda/terminos.html new file mode 100644 index 0000000..31a36d6 --- /dev/null +++ b/tienda/templates/tienda/terminos.html @@ -0,0 +1,69 @@ +{% extends "tienda/base.html" %} +{% load static %} +{% block content %} + +
+
+ +
+
+ +{% endblock %} diff --git a/tienda/tests.py b/tienda/tests.py index c3e5a3b..1f6b083 100644 --- a/tienda/tests.py +++ b/tienda/tests.py @@ -1,111 +1,1866 @@ -from django.test import TestCase -from .models import * -import string, random -class UserTestCase(TestCase): - def setUp(self): - User.objects.create( - username="elordenador", - first_name="Hello", - last_name="World", - email="a@a.a" - ) - User.objects.create( - username="roader", - first_name="RODOR", - last_name="goll", - email="b@b.b" - ) - def test_users_should_not_be_verified(self): - elordenador = User.objects.get(username="elordenador") - roader = User.objects.get(username="roader") +import json +from unittest.mock import MagicMock, patch - self.assertEqual(elordenador.registration_status, User.RegisterStatus.CONFIRMATION_REQUIRED) - self.assertEqual(elordenador.registration_status, User.RegisterStatus.CONFIRMATION_REQUIRED) - def test_users_can_set_and_verify_password(self): - usernames = ["elordenador", "roader"] - for username in usernames: - user = User.objects.get(username=username) +from django.test import TestCase, override_settings +from django.contrib.auth import get_user_model +from django.utils import timezone +from django.db import IntegrityError +from django.urls import reverse +from datetime import timedelta +from .models import ( + User, VerificationCode, Category, Image, Product, + StockReservation, StockReservationItem, Cart, CartItem, + Order, OrderItem, OrderMessage, SavedPaymentMethod, ShippingAddress +) +from .vars import VAT_RATE, TRANSACTION_CODE_PREFIX +import string +import random - password = "".join(random.choices(string.digits, k=16)) - user.set_password(password) - self.assertTrue(user.check_password(password), "Log-in should work!!") - -class VerificationCodeTests(TestCase): - # First create a user - def setUp(self): - self.user = User.objects.create( - username = "test_user_01" - ) - def test_able_to_create_fifty_codes(self): - - for i in range(50): - modes = [VerificationCode.VerificationModes.VERIFY_ACCOUNT, VerificationCode.VerificationModes.RESET_PASSWORD] - code = VerificationCode.generate(self.user, random.choice(modes)) - -class CategoryTests(TestCase): - def setUp(self): - self.lista = [] - def test_able_to_create_a_hundred_categories(self): - for i in range(100): - cat_name = "test_{}_{}".format(i, "".join(random.choices(string.digits, k=3))) - category = Category.objects.create(name=cat_name) - self.lista.append(cat_name) - def test_category_have_that_name(self): - for i in self.lista: - self.assertTrue(Category.objects.filter(name=i).exists(), "Category does NOT exist") - -class ProductTests(TestCase): - def setUp(self): - self.categorias = [] - self.products = [] - for i in range(5): - cat_name = "test_{}_{}".format(i, "".join(random.choices(string.digits, k=3))) - category = Category.objects.create(name=cat_name) - self.categorias.append(cat_name) - self.user = User.objects.create(username="product_test_user") +# ==================== USER MODEL TESTS ==================== +class UserModelTests(TestCase): + """Tests exhaustivos para el modelo User.""" - def test_able_to_create_a_hundred_products(self): - for i in range(100): - cat = random.choice(self.categorias) - categoria = Category.objects.filter(name=cat).first() - - prod_name = "product_{}{}".format(i, "".join(random.choices(string.digits, k=3))) - prod_description = "".join(random.choices(string.ascii_letters, k=255)) - prod_briefdesc = "".join(random.choices(string.ascii_letters, k=50)) - prod_price = random.randint(1,1000) - prod_stock = random.randint(1,100) - creator = self.user.username - - product = Product.objects.create( - name = prod_name, - description = prod_description, - briefdesc = prod_briefdesc, - price = prod_price, - stock = prod_stock, - category = categoria, - creator = self.user + def setUp(self): + self.user_data = { + "username": "testuser", + "email": "test@example.com", + "first_name": "Test", + "last_name": "User", + "password": "TestPassword123!" + } + + def test_user_creation_with_defaults(self): + """Usuario nuevo debe tener estado CONFIRMATION_REQUIRED por defecto.""" + user = User.objects.create(**self.user_data) + self.assertEqual(user.registration_status, User.RegisterStatus.CONFIRMATION_REQUIRED) + self.assertEqual(user.username, "testuser") + self.assertEqual(user.email, "test@example.com") + + def test_user_registration_status_choices(self): + """Todos los estados de registro deben ser válidos.""" + for status_code, status_label in User.RegisterStatus.choices: + user = User.objects.create( + username=f"user_{status_code}", + registration_status=status_code ) + self.assertEqual(user.registration_status, status_code) + + def test_user_password_hashing(self): + """Las contraseñas deben hashearse correctamente.""" + user = User.objects.create(username="testuser") + password = "SecurePassword123!" + user.set_password(password) + user.save() + + self.assertTrue(user.check_password(password)) + self.assertFalse(user.check_password("WrongPassword")) + + def test_user_can_set_active_status(self): + """Usuario puede cambiar a estado ACTIVE.""" + user = User.objects.create(username="testuser") + user.registration_status = User.RegisterStatus.ACTIVE + user.save() + + refreshed = User.objects.get(username="testuser") + self.assertEqual(refreshed.registration_status, User.RegisterStatus.ACTIVE) + + def test_user_can_be_banned(self): + """Usuario puede ser marcado como BANNED.""" + user = User.objects.create(username="testuser") + user.registration_status = User.RegisterStatus.BANNED + user.save() + + refreshed = User.objects.get(username="testuser") + self.assertEqual(refreshed.registration_status, User.RegisterStatus.BANNED) + + def test_multiple_users_unique_username(self): + """Dos usuarios no pueden tener el mismo username.""" + User.objects.create(username="unique_user") + with self.assertRaises(IntegrityError): + User.objects.create(username="unique_user") + + def test_user_str_representation(self): + """La representación string del usuario debe ser correcta.""" + user = User.objects.create(username="testuser", first_name="Test") + # AbstractUser generalmente devuelve username + self.assertIn("testuser", str(user)) + + def test_user_email_validation(self): + """Email debe ser válido (Django validation).""" + user = User.objects.create(username="test", email="valid@example.com") + self.assertEqual(user.email, "valid@example.com") + + def test_user_with_empty_optional_fields(self): + """Usuario puede ser creado sin first_name/last_name.""" + user = User.objects.create(username="minimal_user") + self.assertEqual(user.first_name, "") + self.assertEqual(user.last_name, "") + + def test_user_related_products(self): + """User debe estar relacionado con sus productos creados.""" + user = User.objects.create(username="creator") + category = Category.objects.create(name="TestCat") + product = Product.objects.create( + name="TestProd", category=category, creator=user + ) + + self.assertIn(product, user.created_products.all()) + + def test_user_related_orders(self): + """User debe estar relacionado con sus pedidos.""" + user = User.objects.create(username="buyer") + order = Order.objects.create(buyer=user, status=Order.STATUS_PAID) + + self.assertIn(order, user.orders.all()) - diction = { - "name": prod_name, - "description": prod_description, - "briefdesc": prod_briefdesc, - "price": prod_price, - "stock": prod_stock, - "creator": creator - } - self.products.append(diction) - def test_verify_products(self): - for prod in self.products: - producto = Product.objects.filter(name=prod["name"]) - self.assertTrue(producto.exists(), "Product DOES NOT EXIST") +# ==================== VERIFICATION CODE MODEL TESTS ==================== +class VerificationCodeModelTests(TestCase): + """Tests exhaustivos para el modelo VerificationCode.""" + + def setUp(self): + self.user = User.objects.create(username="testuser") + + def test_verification_code_creation(self): + """Código de verificación debe crearse correctamente.""" + code = VerificationCode.generate( + self.user, + VerificationCode.VerificationModes.VERIFY_ACCOUNT + ) + + self.assertIsNotNone(code) + self.assertEqual(code.user, self.user) + self.assertEqual(code.code_mode, VerificationCode.VerificationModes.VERIFY_ACCOUNT) + + def test_verification_code_uniqueness(self): + """Dos códigos no pueden tener el mismo código.""" + code1 = VerificationCode.generate(self.user, VerificationCode.VerificationModes.VERIFY_ACCOUNT) + code2 = VerificationCode.generate(self.user, VerificationCode.VerificationModes.VERIFY_ACCOUNT) + + self.assertNotEqual(code1.code, code2.code) + + def test_verification_code_for_password_reset(self): + """Código puede ser para reset de contraseña.""" + code = VerificationCode.generate( + self.user, + VerificationCode.VerificationModes.RESET_PASSWORD + ) + + self.assertEqual(code.code_mode, VerificationCode.VerificationModes.RESET_PASSWORD) + + def test_verification_code_fifty_creations(self): + """50 códigos pueden crearse sin conflictos.""" + codes = [] + for i in range(50): + mode = random.choice([ + VerificationCode.VerificationModes.VERIFY_ACCOUNT, + VerificationCode.VerificationModes.RESET_PASSWORD + ]) + code = VerificationCode.generate(self.user, mode) + codes.append(code.code) + + # Verificar que todos son únicos + self.assertEqual(len(codes), len(set(codes))) + + def test_verification_code_related_to_user(self): + """Código debe estar relacionado correctamente con usuario.""" + code = VerificationCode.generate(self.user, VerificationCode.VerificationModes.VERIFY_ACCOUNT) + + self.assertIn(code, self.user.user_belongsto.all()) + + def test_verification_code_str_representation(self): + """La representación string del código debe ser válida.""" + code = VerificationCode.generate(self.user, VerificationCode.VerificationModes.VERIFY_ACCOUNT) + code_str = str(code) + self.assertIsNotNone(code_str) - producto = producto.first() - self.assertEqual(producto.name, prod["name"], "product name {} does not match".format(producto.name)) - self.assertEqual(producto.description, prod["description"], "product {} description does not match".format(producto.name)) - self.assertEqual(producto.briefdesc, prod["briefdesc"], "product {} brief description does not match".format(producto.name)) - self.assertEqual(producto.price, prod["price"], "product {} price does not match ({} != {})".format(producto.name, producto.price, prod["price"])) - self.assertEqual(producto.stock, prod["stock"], "product {} stock does not match".format(producto.name)) - self.assertEqual(producto.creator.username, prod["creator"], "product {} owner does not match".format(producto.name)) \ No newline at end of file + +# ==================== CATEGORY MODEL TESTS ==================== +class CategoryModelTests(TestCase): + """Tests exhaustivos para el modelo Category.""" + + def test_category_creation_basic(self): + """Categoría debe crearse correctamente.""" + category = Category.objects.create(name="Electronics") + self.assertEqual(category.name, "Electronics") + + def test_category_name_unique(self): + """Dos categorías no pueden tener el mismo nombre.""" + Category.objects.create(name="UniqueCategory") + with self.assertRaises(IntegrityError): + Category.objects.create(name="UniqueCategory") + + def test_category_hundred_creations(self): + """100 categorías pueden crearse sin problemas.""" + categories = [] + for i in range(100): + cat = Category.objects.create(name=f"Category_{i}_{random.randint(1000, 9999)}") + categories.append(cat) + + self.assertEqual(len(categories), 100) + self.assertEqual(Category.objects.count(), 100) + + def test_category_str_representation(self): + """La representación string debe ser el nombre.""" + category = Category.objects.create(name="TestCategory") + self.assertEqual(str(category), "TestCategory") + + def test_category_empty_name_not_allowed(self): + """Categoría puede crearse con nombre vacío a nivel de BD (validar en forms).""" + # Django permite guardar campos vacíos sin NULL constraint + # La validación debe hacerse en forms o modelo validators + cat = Category.objects.create(name="") + self.assertEqual(cat.name, "") + self.assertTrue(Category.objects.filter(name="").exists()) + + def test_category_special_characters_in_name(self): + """Categoría puede tener caracteres especiales.""" + category = Category.objects.create(name="Electrónica & Gadgets™") + self.assertEqual(category.name, "Electrónica & Gadgets™") + + def test_category_deletion(self): + """Categoría puede ser eliminada.""" + category = Category.objects.create(name="ToDelete") + cat_id = category.id + category.delete() + + self.assertFalse(Category.objects.filter(id=cat_id).exists()) + + +# ==================== IMAGE MODEL TESTS ==================== +class ImageModelTests(TestCase): + """Tests exhaustivos para el modelo Image.""" + + def test_image_creation_minimal(self): + """Imagen debe crearse con mínimos campos requeridos.""" + image = Image.objects.create( + name="TestImage", + image="path/to/image.jpg", + alt="Test Alt Text" + ) + self.assertEqual(image.name, "TestImage") + self.assertEqual(image.alt, "Test Alt Text") + + def test_image_alt_text_optional(self): + """Alt text puede estar vacío.""" + image = Image.objects.create( + name="TestImage", + image="path/to/image.jpg", + alt="" + ) + self.assertEqual(image.alt, "") + + def test_image_str_representation(self): + """La representación string debe ser el nombre.""" + image = Image.objects.create( + name="MyImage", + image="path/to/image.jpg" + ) + self.assertEqual(str(image), "MyImage") + + def test_image_name_default_empty(self): + """Nombre tiene default vacío.""" + image = Image(image="path/to/image.jpg") + self.assertEqual(image.name, "") + + def test_image_alt_default_empty(self): + """Alt text tiene default vacío.""" + image = Image(name="Test", image="path/to/image.jpg") + self.assertEqual(image.alt, "") + + +# ==================== PRODUCT MODEL TESTS ==================== +class ProductModelTests(TestCase): + """Tests exhaustivos para el modelo Product.""" + + def setUp(self): + self.category = Category.objects.create(name="TestCategory") + self.user = User.objects.create(username="seller") + self.image = Image.objects.create( + name="MainImage", + image="path/to/main.jpg" + ) + + def test_product_creation_full(self): + """Producto debe crearse con todos los campos.""" + product = Product.objects.create( + name="TestProduct", + description="Full description", + briefdesc="Brief", + price=99.99, + stock=50, + category=self.category, + primary_image=self.image, + creator=self.user + ) + + self.assertEqual(product.name, "TestProduct") + self.assertEqual(product.price, 99.99) + self.assertEqual(product.stock, 50) + + def test_product_defaults(self): + """Producto debe tener valores por defecto correctos.""" + product = Product.objects.create( + name="MinimalProduct", + category=self.category + ) + + self.assertEqual(product.description, "") + self.assertEqual(product.briefdesc, "") + self.assertEqual(product.price, 0) + self.assertEqual(product.stock, 0) + self.assertIsNone(product.primary_image) + + def test_product_get_price_with_vat(self): + """Precio con IVA debe calcularse correctamente.""" + product = Product.objects.create( + name="VATProduct", + price=100, + category=self.category + ) + + expected = round(100 * (1 + VAT_RATE), 2) + self.assertEqual(product.get_price_with_vat(), expected) + self.assertEqual(product.get_price_with_vat(), 121.0) + + def test_product_get_vat_amount(self): + """Cantidad de IVA debe calcularse correctamente.""" + product = Product.objects.create( + name="VATProduct", + price=100, + category=self.category + ) + + expected = round(100 * VAT_RATE, 2) + self.assertEqual(product.get_vat_amount(), expected) + self.assertEqual(product.get_vat_amount(), 21.0) + + def test_product_with_negative_price_allowed(self): + """Campo price es FloatField, permite valores negativos (validar en forms).""" + product = Product.objects.create( + name="NegativePrice", + price=-10, + category=self.category + ) + self.assertEqual(product.price, -10) + + def test_product_with_zero_stock(self): + """Producto puede tener stock 0.""" + product = Product.objects.create( + name="NoStock", + stock=0, + category=self.category + ) + self.assertEqual(product.stock, 0) + + def test_product_str_representation(self): + """La representación string debe incluir nombre y precio.""" + product = Product.objects.create( + name="StrProduct", + price=49.99, + category=self.category + ) + expected = f"StrProduct 49.99" + self.assertEqual(str(product), expected) + + def test_product_secondary_images_many_to_many(self): + """Producto puede tener múltiples imágenes secundarias.""" + product = Product.objects.create( + name="MultiImageProduct", + category=self.category + ) + + image1 = Image.objects.create(name="Image1", image="path1.jpg") + image2 = Image.objects.create(name="Image2", image="path2.jpg") + + product.secondary_images.add(image1, image2) + + self.assertEqual(product.secondary_images.count(), 2) + self.assertIn(image1, product.secondary_images.all()) + self.assertIn(image2, product.secondary_images.all()) + + def test_product_creator_optional(self): + """Producto puede crearse sin creator.""" + product = Product.objects.create( + name="NoCreator", + category=self.category, + creator=None + ) + self.assertIsNone(product.creator) + + def test_product_deletion_cascades(self): + """Eliminar producto debe mantener categoría.""" + product = Product.objects.create( + name="ToDelete", + category=self.category + ) + product_id = product.id + product.delete() + + self.assertFalse(Product.objects.filter(id=product_id).exists()) + self.assertTrue(Category.objects.filter(id=self.category.id).exists()) + + def test_product_hundred_creations(self): + """100 productos pueden crearse correctamente.""" + products = [] + for i in range(100): + product = Product.objects.create( + name=f"Product_{i}", + price=float(i), + stock=i, + category=self.category, + creator=self.user + ) + products.append(product) + + self.assertEqual(len(products), 100) + self.assertEqual(Product.objects.count(), 100) + + +# ==================== STOCK RESERVATION MODEL TESTS ==================== +class StockReservationModelTests(TestCase): + """Tests exhaustivos para el modelo StockReservation.""" + + def setUp(self): + self.user = User.objects.create(username="testuser") + self.expires_at = timezone.now() + timedelta(minutes=5) + + def test_stock_reservation_creation_user(self): + """Reserva de stock para usuario autenticado.""" + reservation = StockReservation.objects.create( + user=self.user, + status=StockReservation.STATUS_ACTIVE, + payment_method=StockReservation.PAYMENT_STRIPE, + expires_at=self.expires_at + ) + + self.assertEqual(reservation.user, self.user) + self.assertEqual(reservation.status, StockReservation.STATUS_ACTIVE) + + def test_stock_reservation_creation_session(self): + """Reserva de stock para sesión anónima.""" + reservation = StockReservation.objects.create( + session_key="abc123def456", + status=StockReservation.STATUS_ACTIVE, + payment_method=StockReservation.PAYMENT_PAYPAL, + expires_at=self.expires_at + ) + + self.assertEqual(reservation.session_key, "abc123def456") + self.assertIsNone(reservation.user) + + def test_stock_reservation_status_choices(self): + """Todos los estados deben ser válidos.""" + statuses = [ + StockReservation.STATUS_ACTIVE, + StockReservation.STATUS_COMPLETED, + StockReservation.STATUS_CANCELLED, + StockReservation.STATUS_EXPIRED + ] + + for i, status in enumerate(statuses): + reservation = StockReservation.objects.create( + user=self.user, + status=status, + payment_method=StockReservation.PAYMENT_STRIPE, + expires_at=self.expires_at + ) + self.assertEqual(reservation.status, status) + + def test_stock_reservation_payment_methods(self): + """Ambos métodos de pago deben ser válidos.""" + for method in [StockReservation.PAYMENT_STRIPE, StockReservation.PAYMENT_PAYPAL]: + reservation = StockReservation.objects.create( + user=self.user, + status=StockReservation.STATUS_ACTIVE, + payment_method=method, + expires_at=self.expires_at + ) + self.assertEqual(reservation.payment_method, method) + + def test_stock_reservation_timestamps(self): + """Las timestamps deben establecerse automáticamente.""" + reservation = StockReservation.objects.create( + user=self.user, + status=StockReservation.STATUS_ACTIVE, + payment_method=StockReservation.PAYMENT_STRIPE, + expires_at=self.expires_at + ) + + self.assertIsNotNone(reservation.created_at) + self.assertIsNotNone(reservation.updated_at) + self.assertLessEqual(reservation.created_at, timezone.now()) + + def test_stock_reservation_str_representation(self): + """La representación string debe ser válida.""" + reservation = StockReservation.objects.create( + user=self.user, + status=StockReservation.STATUS_ACTIVE, + payment_method=StockReservation.PAYMENT_STRIPE, + expires_at=self.expires_at + ) + + reservation_str = str(reservation) + self.assertIn("Reserva", reservation_str) + self.assertIn("active", reservation_str) + + +# ==================== STOCK RESERVATION ITEM MODEL TESTS ==================== +class StockReservationItemModelTests(TestCase): + """Tests exhaustivos para el modelo StockReservationItem.""" + + def setUp(self): + self.user = User.objects.create(username="testuser") + self.category = Category.objects.create(name="TestCat") + self.product = Product.objects.create( + name="TestProd", + category=self.category + ) + self.reservation = StockReservation.objects.create( + user=self.user, + status=StockReservation.STATUS_ACTIVE, + payment_method=StockReservation.PAYMENT_STRIPE, + expires_at=timezone.now() + timedelta(minutes=5) + ) + + def test_reservation_item_creation(self): + """Item de reserva debe crearse correctamente.""" + item = StockReservationItem.objects.create( + reservation=self.reservation, + product=self.product, + quantity=10 + ) + + self.assertEqual(item.quantity, 10) + self.assertEqual(item.product, self.product) + + def test_reservation_item_default_quantity(self): + """Cantidad por defecto es 1.""" + item = StockReservationItem.objects.create( + reservation=self.reservation, + product=self.product + ) + + self.assertEqual(item.quantity, 1) + + def test_reservation_item_unique_together(self): + """No pueden haber dos items del mismo producto en la misma reserva.""" + StockReservationItem.objects.create( + reservation=self.reservation, + product=self.product, + quantity=5 + ) + + with self.assertRaises(IntegrityError): + StockReservationItem.objects.create( + reservation=self.reservation, + product=self.product, + quantity=3 + ) + + def test_reservation_item_str_representation(self): + """La representación string debe ser válida.""" + item = StockReservationItem.objects.create( + reservation=self.reservation, + product=self.product, + quantity=5 + ) + + item_str = str(item) + self.assertIn("5x", item_str) + self.assertIn("TestProd", item_str) + + +# ==================== CART MODEL TESTS ==================== +class CartModelTests(TestCase): + """Tests exhaustivos para el modelo Cart.""" + + def setUp(self): + self.user = User.objects.create(username="cartuser") + self.category = Category.objects.create(name="TestCat") + self.product1 = Product.objects.create( + name="Product1", price=100, category=self.category + ) + self.product2 = Product.objects.create( + name="Product2", price=50, category=self.category + ) + + def test_cart_creation_with_user(self): + """Carrito debe crearse para usuario autenticado.""" + cart = Cart.objects.create(user=self.user) + self.assertEqual(cart.user, self.user) + self.assertIsNone(cart.session_key) + + def test_cart_creation_with_session(self): + """Carrito debe crearse para sesión anónima.""" + cart = Cart.objects.create(session_key="session123") + self.assertEqual(cart.session_key, "session123") + self.assertIsNone(cart.user) + + def test_cart_timestamps(self): + """Los timestamps deben establecerse automáticamente.""" + cart = Cart.objects.create(user=self.user) + self.assertIsNotNone(cart.created_at) + self.assertIsNotNone(cart.updated_at) + + def test_cart_get_total_empty(self): + """Total de carrito vacío debe ser 0.""" + cart = Cart.objects.create(user=self.user) + self.assertEqual(cart.get_total(), 0) + + def test_cart_get_total_with_items(self): + """Total debe calcularse correctamente.""" + cart = Cart.objects.create(user=self.user) + CartItem.objects.create(cart=cart, product=self.product1, quantity=2) # 200 + CartItem.objects.create(cart=cart, product=self.product2, quantity=1) # 50 + + self.assertEqual(cart.get_total(), 250) + + def test_cart_get_total_with_vat(self): + """Total con IVA debe ser correcto.""" + cart = Cart.objects.create(user=self.user) + CartItem.objects.create(cart=cart, product=self.product1, quantity=1) # 100 + + expected = round(100 * (1 + VAT_RATE), 2) + self.assertEqual(cart.get_total_with_vat(), expected) + + def test_cart_get_vat_amount(self): + """Cantidad de IVA debe calcularse correctamente.""" + cart = Cart.objects.create(user=self.user) + CartItem.objects.create(cart=cart, product=self.product1, quantity=1) # 100 + + expected = round(100 * VAT_RATE, 2) + self.assertEqual(cart.get_vat_amount(), expected) + + def test_cart_get_items_count(self): + """Conteo de items debe ser correcto.""" + cart = Cart.objects.create(user=self.user) + self.assertEqual(cart.get_items_count(), 0) + + CartItem.objects.create(cart=cart, product=self.product1, quantity=2) + CartItem.objects.create(cart=cart, product=self.product2, quantity=3) + + self.assertEqual(cart.get_items_count(), 5) + + def test_cart_str_representation(self): + """La representación string debe ser válida.""" + cart = Cart.objects.create(user=self.user) + self.assertIn("Cart", str(cart)) + + +# ==================== CART ITEM MODEL TESTS ==================== +class CartItemModelTests(TestCase): + """Tests exhaustivos para el modelo CartItem.""" + + def setUp(self): + self.user = User.objects.create(username="cartuser") + self.category = Category.objects.create(name="TestCat") + self.product = Product.objects.create( + name="TestProduct", price=50, category=self.category + ) + self.cart = Cart.objects.create(user=self.user) + + def test_cart_item_creation(self): + """Item del carrito debe crearse correctamente.""" + item = CartItem.objects.create( + cart=self.cart, + product=self.product, + quantity=5 + ) + + self.assertEqual(item.quantity, 5) + self.assertEqual(item.product, self.product) + + def test_cart_item_default_quantity(self): + """Cantidad por defecto es 1.""" + item = CartItem.objects.create(cart=self.cart, product=self.product) + self.assertEqual(item.quantity, 1) + + def test_cart_item_unique_together(self): + """No pueden haber dos items del mismo producto en el mismo carrito.""" + CartItem.objects.create(cart=self.cart, product=self.product, quantity=5) + + with self.assertRaises(IntegrityError): + CartItem.objects.create(cart=self.cart, product=self.product, quantity=3) + + def test_cart_item_get_subtotal(self): + """Subtotal debe calcularse correctamente.""" + item = CartItem.objects.create( + cart=self.cart, + product=self.product, + quantity=3 + ) + + self.assertEqual(item.get_subtotal(), 150) + + def test_cart_item_get_subtotal_with_vat(self): + """Subtotal con IVA debe ser correcto.""" + item = CartItem.objects.create( + cart=self.cart, + product=self.product, + quantity=2 + ) + + expected = round(100 * (1 + VAT_RATE), 2) + self.assertEqual(item.get_subtotal_with_vat(), expected) + + def test_cart_item_get_vat_amount(self): + """Cantidad de IVA del item debe ser correcta.""" + item = CartItem.objects.create( + cart=self.cart, + product=self.product, + quantity=2 + ) + + expected = round(100 * VAT_RATE, 2) + self.assertEqual(item.get_vat_amount(), expected) + + def test_cart_item_str_representation(self): + """La representación string debe ser válida.""" + item = CartItem.objects.create( + cart=self.cart, + product=self.product, + quantity=3 + ) + + self.assertEqual(str(item), "3x TestProduct") + + +# ==================== ORDER MODEL TESTS ==================== +class OrderModelTests(TestCase): + """Tests exhaustivos para el modelo Order.""" + + def setUp(self): + self.buyer = User.objects.create(username="buyer") + self.address = ShippingAddress.objects.create( + user=self.buyer, + full_name="John Doe", + address_line_1="123 Main St", + city="Almería", + postal_code="04001", + country="España", + phone="123456789" + ) + + def test_order_creation_full(self): + """Pedido debe crearse con todos los campos.""" + order = Order.objects.create( + buyer=self.buyer, + shipping_address=self.address, + total=150.50, + status=Order.STATUS_PAID, + payment_method=Order.PAYMENT_STRIPE + ) + + self.assertEqual(order.buyer, self.buyer) + self.assertEqual(order.total, 150.50) + self.assertEqual(order.status, Order.STATUS_PAID) + + def test_order_transaction_code_auto_generated(self): + """Código de transacción debe generarse automáticamente al guardar.""" + order = Order.objects.create( + buyer=self.buyer, + status=Order.STATUS_PAID + ) + + self.assertIsNotNone(order.transaction_code) + self.assertTrue(order.transaction_code.startswith(TRANSACTION_CODE_PREFIX)) + + def test_order_transaction_code_unique(self): + """Códigos de transacción deben ser únicos.""" + order1 = Order.objects.create( + buyer=self.buyer, + status=Order.STATUS_PAID + ) + order2 = Order.objects.create( + buyer=self.buyer, + status=Order.STATUS_PAID + ) + + self.assertNotEqual(order1.transaction_code, order2.transaction_code) + + def test_order_default_status(self): + """Estado por defecto es PAID.""" + order = Order.objects.create(buyer=self.buyer) + self.assertEqual(order.status, Order.STATUS_PAID) + + def test_order_default_payment_method(self): + """Método de pago por defecto es MANUAL.""" + order = Order.objects.create(buyer=self.buyer) + self.assertEqual(order.payment_method, Order.PAYMENT_MANUAL) + + def test_order_status_choices(self): + """Todos los estados deben ser válidos.""" + for i, status in enumerate([Order.STATUS_PAID, Order.STATUS_CANCELLED]): + order = Order.objects.create( + buyer=self.buyer, + status=status + ) + self.assertEqual(order.status, status) + + def test_order_payment_methods(self): + """Todos los métodos de pago deben ser válidos.""" + methods = [Order.PAYMENT_STRIPE, Order.PAYMENT_PAYPAL, Order.PAYMENT_MANUAL] + for method in methods: + order = Order.objects.create( + buyer=self.buyer, + payment_method=method + ) + self.assertEqual(order.payment_method, method) + + def test_order_anonymous_buyer(self): + """Pedido puede tener buyer nulo (comprador anónimo).""" + order = Order.objects.create( + buyer=None, + session_key="session123", + status=Order.STATUS_PAID + ) + self.assertIsNone(order.buyer) + + def test_order_payment_reference_optional(self): + """Referencia de pago es opcional.""" + order = Order.objects.create(buyer=self.buyer) + self.assertEqual(order.payment_reference, "") + + def test_order_timestamps(self): + """Los timestamps deben establecerse automáticamente.""" + order = Order.objects.create(buyer=self.buyer) + self.assertIsNotNone(order.created_at) + self.assertIsNotNone(order.updated_at) + + def test_order_get_items_count_empty(self): + """Conteo de items en pedido vacío debe ser 0.""" + order = Order.objects.create(buyer=self.buyer) + self.assertEqual(order.get_items_count(), 0) + + def test_order_str_representation(self): + """La representación string debe ser válida.""" + order = Order.objects.create(buyer=self.buyer) + order_str = str(order) + self.assertIn("Pedido", order_str) + + +# ==================== ORDER ITEM MODEL TESTS ==================== +class OrderItemModelTests(TestCase): + """Tests exhaustivos para el modelo OrderItem.""" + + def setUp(self): + self.buyer = User.objects.create(username="buyer") + self.seller = User.objects.create(username="seller") + self.category = Category.objects.create(name="TestCat") + self.product = Product.objects.create( + name="TestProduct", + price=100, + category=self.category, + creator=self.seller + ) + self.order = Order.objects.create(buyer=self.buyer) + + def test_order_item_creation_full(self): + """Item de pedido debe crearse correctamente.""" + item = OrderItem.objects.create( + order=self.order, + product=self.product, + product_name="TestProduct", + seller=self.seller, + quantity=5, + unit_price=100, + total_price=500, + status=OrderItem.STATUS_PENDING + ) + + self.assertEqual(item.quantity, 5) + self.assertEqual(item.unit_price, 100) + self.assertEqual(item.total_price, 500) + + def test_order_item_status_choices(self): + """Todos los estados deben ser válidos.""" + statuses = [ + OrderItem.STATUS_PENDING, + OrderItem.STATUS_PROCESSING, + OrderItem.STATUS_SHIPPED + ] + + for status in statuses: + item = OrderItem.objects.create( + order=self.order, + product=self.product, + product_name="Test", + status=status + ) + self.assertEqual(item.status, status) + + def test_order_item_default_status(self): + """Estado por defecto es PENDING.""" + item = OrderItem.objects.create( + order=self.order, + product_name="Test" + ) + self.assertEqual(item.status, OrderItem.STATUS_PENDING) + + def test_order_item_product_optional(self): + """Producto puede ser nulo (producto eliminado).""" + item = OrderItem.objects.create( + order=self.order, + product=None, + product_name="Deleted Product" + ) + self.assertIsNone(item.product) + + def test_order_item_seller_optional(self): + """Vendedor puede ser nulo.""" + item = OrderItem.objects.create( + order=self.order, + product_name="Test", + seller=None + ) + self.assertIsNone(item.seller) + + def test_order_item_timestamps(self): + """El timestamp debe establecerse automáticamente.""" + item = OrderItem.objects.create( + order=self.order, + product_name="Test" + ) + self.assertIsNotNone(item.created_at) + + def test_order_item_str_representation(self): + """La representación string debe ser válida.""" + item = OrderItem.objects.create( + order=self.order, + product_name="TestProduct", + quantity=3 + ) + + item_str = str(item) + self.assertIn("3x", item_str) + self.assertIn("TestProduct", item_str) + + +# ==================== ORDER MESSAGE MODEL TESTS ==================== +class OrderMessageModelTests(TestCase): + """Tests exhaustivos para el modelo OrderMessage.""" + + def setUp(self): + self.buyer = User.objects.create(username="buyer") + self.seller = User.objects.create(username="seller") + self.order = Order.objects.create(buyer=self.buyer) + self.order_item = OrderItem.objects.create( + order=self.order, + product_name="Test", + seller=self.seller + ) + + def test_order_message_creation(self): + """Mensaje debe crearse correctamente.""" + message = OrderMessage.objects.create( + order_item=self.order_item, + sender=self.buyer, + message="Hello seller!" + ) + + self.assertEqual(message.message, "Hello seller!") + self.assertEqual(message.sender, self.buyer) + + def test_order_message_sender_optional(self): + """Remitente puede ser nulo.""" + message = OrderMessage.objects.create( + order_item=self.order_item, + sender=None, + message="Anonymous message" + ) + self.assertIsNone(message.sender) + + def test_order_message_timestamp(self): + """El timestamp debe establecerse automáticamente.""" + message = OrderMessage.objects.create( + order_item=self.order_item, + sender=self.buyer, + message="Test" + ) + self.assertIsNotNone(message.created_at) + + def test_order_message_ordering(self): + """Los mensajes deben ordenarse por created_at.""" + msg1 = OrderMessage.objects.create( + order_item=self.order_item, + sender=self.buyer, + message="First" + ) + msg2 = OrderMessage.objects.create( + order_item=self.order_item, + sender=self.seller, + message="Second" + ) + + messages = list(self.order_item.messages.all()) + self.assertEqual(messages[0].message, "First") + self.assertEqual(messages[1].message, "Second") + + def test_order_message_str_representation(self): + """La representación string debe ser válida.""" + message = OrderMessage.objects.create( + order_item=self.order_item, + sender=self.buyer, + message="Test message" + ) + + message_str = str(message) + self.assertIn("buyer", message_str) + + +# ==================== SAVED PAYMENT METHOD MODEL TESTS ==================== +class SavedPaymentMethodModelTests(TestCase): + """Tests exhaustivos para el modelo SavedPaymentMethod.""" + + def setUp(self): + self.user = User.objects.create(username="paymentuser") + + def test_saved_payment_method_card_creation(self): + """Método de pago tarjeta debe crearse correctamente.""" + method = SavedPaymentMethod.objects.create( + user=self.user, + method_type=SavedPaymentMethod.TYPE_CARD, + label="Mi Tarjeta", + stripe_customer_id="cus_123", + stripe_payment_method_id="pm_456" + ) + + self.assertEqual(method.method_type, SavedPaymentMethod.TYPE_CARD) + self.assertEqual(method.label, "Mi Tarjeta") + + def test_saved_payment_method_paypal_creation(self): + """Método de pago PayPal debe crearse correctamente.""" + method = SavedPaymentMethod.objects.create( + user=self.user, + method_type=SavedPaymentMethod.TYPE_PAYPAL, + label="Mi PayPal", + paypal_email="user@example.com", + paypal_payer_id="ABC123" + ) + + self.assertEqual(method.method_type, SavedPaymentMethod.TYPE_PAYPAL) + self.assertEqual(method.paypal_email, "user@example.com") + + def test_saved_payment_method_default_false(self): + """Por defecto no es predeterminado.""" + method = SavedPaymentMethod.objects.create( + user=self.user, + method_type=SavedPaymentMethod.TYPE_CARD, + label="Test" + ) + + self.assertFalse(method.is_default) + + def test_saved_payment_method_set_default_unsets_others(self): + """Marcar como predeterminado debe desmarcar otros.""" + method1 = SavedPaymentMethod.objects.create( + user=self.user, + method_type=SavedPaymentMethod.TYPE_CARD, + label="Card1", + is_default=True + ) + + method2 = SavedPaymentMethod.objects.create( + user=self.user, + method_type=SavedPaymentMethod.TYPE_CARD, + label="Card2", + is_default=True + ) + + method1.refresh_from_db() + self.assertFalse(method1.is_default) + self.assertTrue(method2.is_default) + + def test_saved_payment_method_ordering(self): + """Los métodos deben ordenarse por predeterminado y fecha.""" + method2 = SavedPaymentMethod.objects.create( + user=self.user, + method_type=SavedPaymentMethod.TYPE_CARD, + label="Card2", + is_default=False + ) + + method1 = SavedPaymentMethod.objects.create( + user=self.user, + method_type=SavedPaymentMethod.TYPE_CARD, + label="Card1", + is_default=True + ) + + methods = list(SavedPaymentMethod.objects.filter(user=self.user)) + self.assertEqual(methods[0].id, method1.id) + + def test_saved_payment_method_timestamps(self): + """El timestamp debe establecerse automáticamente.""" + method = SavedPaymentMethod.objects.create( + user=self.user, + method_type=SavedPaymentMethod.TYPE_CARD, + label="Test" + ) + self.assertIsNotNone(method.created_at) + + def test_saved_payment_method_str_representation(self): + """La representación string debe ser válida.""" + method = SavedPaymentMethod.objects.create( + user=self.user, + method_type=SavedPaymentMethod.TYPE_CARD, + label="My Card" + ) + + method_str = str(method) + self.assertIn("paymentuser", method_str) + self.assertIn("My Card", method_str) + + +# ==================== SHIPPING ADDRESS MODEL TESTS ==================== +class ShippingAddressModelTests(TestCase): + """Tests exhaustivos para el modelo ShippingAddress.""" + + def setUp(self): + self.user = User.objects.create(username="addressuser") + + def test_shipping_address_creation_full(self): + """Dirección de envío debe crearse correctamente.""" + address = ShippingAddress.objects.create( + user=self.user, + full_name="John Doe", + address_line_1="123 Main St", + address_line_2="Apt 4B", + city="Almería", + postal_code="04001", + country="España", + phone="123456789" + ) + + self.assertEqual(address.full_name, "John Doe") + self.assertEqual(address.city, "Almería") + + def test_shipping_address_default_country(self): + """País por defecto es España.""" + address = ShippingAddress.objects.create( + user=self.user, + full_name="Test", + address_line_1="Test St", + city="Almería", + postal_code="04001", + phone="123456789" + ) + + self.assertEqual(address.country, "España") + + def test_shipping_address_line_2_optional(self): + """Línea de dirección 2 es opcional.""" + address = ShippingAddress.objects.create( + user=self.user, + full_name="Test", + address_line_1="Test St", + address_line_2="", + city="Almería", + postal_code="04001", + phone="123456789" + ) + + self.assertEqual(address.address_line_2, "") + + def test_shipping_address_is_default_false(self): + """Por defecto no es dirección predeterminada.""" + address = ShippingAddress.objects.create( + user=self.user, + full_name="Test", + address_line_1="Test St", + city="Almería", + postal_code="04001", + phone="123456789" + ) + + self.assertFalse(address.is_default) + + def test_shipping_address_set_default_unsets_others(self): + """Marcar como predeterminada debe desmarcar otras.""" + addr1 = ShippingAddress.objects.create( + user=self.user, + full_name="Address1", + address_line_1="St1", + city="Almería", + postal_code="04001", + phone="1", + is_default=True + ) + + addr2 = ShippingAddress.objects.create( + user=self.user, + full_name="Address2", + address_line_1="St2", + city="Almería", + postal_code="04002", + phone="2", + is_default=True + ) + + addr1.refresh_from_db() + self.assertFalse(addr1.is_default) + self.assertTrue(addr2.is_default) + + def test_shipping_address_ordering(self): + """Las direcciones deben ordenarse por predeterminada y fecha.""" + addr2 = ShippingAddress.objects.create( + user=self.user, + full_name="Address2", + address_line_1="St2", + city="Almería", + postal_code="04002", + phone="2", + is_default=False + ) + + addr1 = ShippingAddress.objects.create( + user=self.user, + full_name="Address1", + address_line_1="St1", + city="Almería", + postal_code="04001", + phone="1", + is_default=True + ) + + addresses = list(ShippingAddress.objects.filter(user=self.user)) + self.assertEqual(addresses[0].id, addr1.id) + + def test_shipping_address_timestamps(self): + """Los timestamps deben establecerse automáticamente.""" + address = ShippingAddress.objects.create( + user=self.user, + full_name="Test", + address_line_1="Test St", + city="Almería", + postal_code="04001", + phone="123456789" + ) + + self.assertIsNotNone(address.created_at) + self.assertIsNotNone(address.updated_at) + + def test_shipping_address_str_representation(self): + """La representación string debe ser válida.""" + address = ShippingAddress.objects.create( + user=self.user, + full_name="John Doe", + address_line_1="123 Main St", + city="Almería", + postal_code="04001", + phone="123456789" + ) + + address_str = str(address) + self.assertIn("John Doe", address_str) + self.assertIn("Almería", address_str) + + +@override_settings( + CACHES={"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}, + SESSION_ENGINE="django.contrib.sessions.backends.db", +) +class EndpointViewTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.password = "StrongPassword123!" + cls.buyer = User.objects.create_user( + username="buyer", + email="buyer@example.com", + password=cls.password, + registration_status=User.RegisterStatus.ACTIVE, + ) + cls.seller = User.objects.create_user( + username="seller", + email="seller@example.com", + password=cls.password, + registration_status=User.RegisterStatus.ACTIVE, + ) + cls.other_user = User.objects.create_user( + username="other", + email="other@example.com", + password=cls.password, + registration_status=User.RegisterStatus.ACTIVE, + ) + cls.category = Category.objects.create(name="Electrónica") + cls.image = Image.objects.create(name="imagen", image="images/test.jpg") + cls.product = Product.objects.create( + name="Producto test", + briefdesc="Breve", + description="Descripción", + price=10.0, + stock=20, + category=cls.category, + primary_image=cls.image, + creator=cls.seller, + ) + cls.address = ShippingAddress.objects.create( + user=cls.buyer, + full_name="Comprador Uno", + address_line_1="Calle Mayor 1", + city="Almería", + postal_code="04001", + phone="600000001", + is_default=True, + ) + cls.other_address = ShippingAddress.objects.create( + user=cls.other_user, + full_name="Otro Usuario", + address_line_1="Calle Otro 2", + city="Almería", + postal_code="04002", + phone="600000002", + ) + + def _login(self, user=None): + self.client.force_login(user or self.buyer) + + def _create_cart_item(self, quantity=1, user=None): + owner = user or self.buyer + cart, _ = Cart.objects.get_or_create(user=owner) + item, _ = CartItem.objects.get_or_create(cart=cart, product=self.product, defaults={"quantity": quantity}) + item.quantity = quantity + item.save() + return item + + def _post_json(self, url_name, data, **kwargs): + return self.client.post( + reverse(url_name, kwargs=kwargs or None), + data=json.dumps(data), + content_type="application/json", + ) + + def test_public_endpoints_render(self): + public_routes = [ + reverse("home"), + reverse("index"), + reverse("productos"), + reverse("producto", args=[self.product.id]), + reverse("categoria", args=[self.category.id]), + reverse("search"), + reverse("search_suggestions"), + reverse("login"), + reverse("register"), + reverse("rgpd"), + reverse("privacidad"), + reverse("devoluciones"), + reverse("aviso_legal"), + reverse("terminos"), + reverse("cookies"), + reverse("sobre_nosotros"), + reverse("ayuda"), + reverse("reset_password"), + ] + for url in public_routes: + with self.subTest(url=url): + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + def test_login_required_endpoints_redirect_anonymous(self): + secured_get_routes = [ + reverse("mis_productos"), + reverse("pedidos_vendedor"), + reverse("crear_producto"), + reverse("checkout"), + reverse("checkout_success"), + reverse("checkout_cancel"), + reverse("portal_usuario"), + reverse("mis_compras"), + reverse("mis_recibos"), + reverse("editar_perfil"), + reverse("direcciones_usuario"), + reverse("crear_direccion"), + reverse("mensajes_comprador"), + reverse("metodos_pago"), + reverse("agregar_tarjeta"), + reverse("agregar_paypal"), + reverse("editar_producto", args=[self.product.id]), + reverse("borrar_producto", args=[self.product.id]), + reverse("cambiar_estado_pedido", args=[1]), + reverse("enviar_mensaje_pedido", args=[1]), + reverse("editar_direccion", args=[self.address.id]), + reverse("eliminar_direccion", args=[self.address.id]), + reverse("eliminar_metodo_pago", args=[1]), + ] + for url in secured_get_routes: + with self.subTest(url=url): + response = self.client.get(url) + self.assertEqual(response.status_code, 302) + self.assertIn(reverse("login"), response.url) + + secured_post_routes = [ + "crear_payment_intent", + "confirmar_pago_tarjeta", + "crear_orden_paypal", + "capturar_orden_paypal", + "crear_setup_intent", + "confirmar_setup_intent", + "crear_orden_paypal_setup", + "capturar_orden_paypal_setup", + ] + for name in secured_post_routes: + with self.subTest(name=name): + response = self.client.post(reverse(name)) + self.assertEqual(response.status_code, 302) + self.assertIn(reverse("login"), response.url) + + @patch("tienda.views.tasks.enviar_correo_confirmacion.delay") + @patch("tienda.views.tasks.enviar_correo_bienvenida.delay") + def test_register_login_logout_and_verify_flows(self, welcome_delay, confirm_delay): + register_response = self.client.post(reverse("register"), data={ + "name": "Nuevo", + "email": "nuevo@example.com", + "password": self.password, + "password_confirm": self.password, + }) + self.assertEqual(register_response.status_code, 302) + confirm_delay.assert_called_once() + + created_user = User.objects.get(email="nuevo@example.com") + created_user.registration_status = User.RegisterStatus.ACTIVE + created_user.save(update_fields=["registration_status"]) + + login_response = self.client.post(reverse("login"), data={ + "email": "nuevo@example.com", + "password": self.password, + "remember": "on", + }) + self.assertEqual(login_response.status_code, 302) + self.assertEqual(login_response.url, reverse("index")) + welcome_delay.assert_called_once() + + logout_response = self.client.get(reverse("logout")) + self.assertEqual(logout_response.status_code, 302) + self.assertEqual(logout_response.url, reverse("index")) + + verification = VerificationCode.generate(created_user, VerificationCode.VerificationModes.VERIFY_ACCOUNT) + verify_response = self.client.get(reverse("verify", args=[verification.code])) + self.assertEqual(verify_response.status_code, 302) + created_user.refresh_from_db() + self.assertEqual(created_user.registration_status, User.RegisterStatus.ACTIVE) + + invalid_verify_response = self.client.get(reverse("verify", args=["codigo-invalido"])) + self.assertEqual(invalid_verify_response.status_code, 200) + + @patch("tienda.views.tasks.enviar_correo_recuperacion.delay") + def test_password_reset_endpoints(self, recovery_delay): + reset_get = self.client.get(reverse("reset_password")) + self.assertEqual(reset_get.status_code, 200) + reset_post = self.client.post(reverse("reset_password"), data={"email": self.buyer.email}) + self.assertEqual(reset_post.status_code, 200) + recovery_delay.assert_called_once_with(self.buyer.email) + + code = VerificationCode.generate(self.buyer, VerificationCode.VerificationModes.RESET_PASSWORD) + phase2_get = self.client.get(reverse("reset_password_phase2", args=[code.code])) + self.assertEqual(phase2_get.status_code, 200) + + mismatch = self.client.post(reverse("reset_password_phase2", args=[code.code]), data={ + "password": "NuevaPassword123!", + "verify_password": "DistintaPassword123!", + }) + self.assertEqual(mismatch.status_code, 200) + + success = self.client.post(reverse("reset_password_phase2", args=[code.code]), data={ + "password": "NuevaPassword123!", + "verify_password": "NuevaPassword123!", + }) + self.assertEqual(success.status_code, 302) + self.assertTrue(User.objects.get(id=self.buyer.id).check_password("NuevaPassword123!")) + + not_found = self.client.get(reverse("reset_password_phase2", args=["no-existe"])) + self.assertEqual(not_found.status_code, 404) + + def test_search_and_suggestions(self): + response = self.client.get(reverse("search"), data={"q": "Producto"}) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Producto test") + + suggestions = self.client.get(reverse("search_suggestions"), data={"q": "Pr"}) + self.assertEqual(suggestions.status_code, 200) + payload = suggestions.json() + self.assertTrue(payload["suggestions"]) + + def test_cart_endpoints(self): + self._login() + cart_view = self.client.get(reverse("view_cart")) + self.assertEqual(cart_view.status_code, 200) + + add_response = self.client.post(reverse("add_to_cart", args=[self.product.id]), data={"quantity": 2}) + self.assertEqual(add_response.status_code, 302) + item = CartItem.objects.get(cart__user=self.buyer, product=self.product) + self.assertEqual(item.quantity, 2) + + update_response = self.client.post(reverse("update_cart_item", args=[item.id]), data={"quantity": 3}) + self.assertEqual(update_response.status_code, 302) + item.refresh_from_db() + self.assertEqual(item.quantity, 3) + + remove_response = self.client.post(reverse("remove_from_cart", args=[item.id])) + self.assertEqual(remove_response.status_code, 302) + self.assertFalse(CartItem.objects.filter(id=item.id).exists()) + + self._create_cart_item(quantity=1) + clear_response = self.client.post(reverse("clear_cart")) + self.assertEqual(clear_response.status_code, 302) + self.assertEqual(CartItem.objects.filter(cart__user=self.buyer).count(), 0) + + def test_seller_panel_endpoints(self): + self._login(self.seller) + order = Order.objects.create( + buyer=self.buyer, + shipping_address=self.address, + total=12.1, + payment_method=Order.PAYMENT_STRIPE, + ) + item = OrderItem.objects.create( + order=order, + product=self.product, + product_name=self.product.name, + seller=self.seller, + quantity=1, + unit_price=12.1, + total_price=12.1, + ) + + self.assertEqual(self.client.get(reverse("mis_productos")).status_code, 200) + self.assertEqual(self.client.get(reverse("pedidos_vendedor")).status_code, 200) + self.assertEqual(self.client.get(reverse("crear_producto")).status_code, 200) + self.assertEqual(self.client.get(reverse("editar_producto", args=[self.product.id])).status_code, 200) + + create_response = self.client.post(reverse("crear_producto"), data={ + "name": "Nuevo producto", + "briefdesc": "Breve", + "description": "Descripción", + "price": "25.50", + "stock": "5", + "category": str(self.category.id), + }) + self.assertEqual(create_response.status_code, 302) + created = Product.objects.get(name="Nuevo producto") + + edit_response = self.client.post(reverse("editar_producto", args=[created.id]), data={ + "name": "Producto editado", + "briefdesc": "Actualizado", + "description": "Descripción nueva", + "price": "30.00", + "stock": "6", + "category": str(self.category.id), + }) + self.assertEqual(edit_response.status_code, 302) + created.refresh_from_db() + self.assertEqual(created.name, "Producto editado") + + status_response = self.client.post(reverse("cambiar_estado_pedido", args=[item.id]), data={ + "estado": OrderItem.STATUS_PROCESSING, + }) + self.assertEqual(status_response.status_code, 302) + item.refresh_from_db() + self.assertEqual(item.status, OrderItem.STATUS_PROCESSING) + + message_response = self.client.post(reverse("enviar_mensaje_pedido", args=[item.id]), data={"mensaje": "Preparando envío"}) + self.assertEqual(message_response.status_code, 302) + self.assertTrue(OrderMessage.objects.filter(order_item=item, sender=self.seller).exists()) + + delete_get = self.client.get(reverse("borrar_producto", args=[created.id])) + self.assertEqual(delete_get.status_code, 302) + delete_post = self.client.post(reverse("borrar_producto", args=[created.id])) + self.assertEqual(delete_post.status_code, 302) + self.assertFalse(Product.objects.filter(id=created.id).exists()) + + @patch("tienda.views.stripe.PaymentIntent.create") + @patch("tienda.views._create_stock_reservation_for_cart") + def test_crear_payment_intent_endpoint(self, reservation_mock, create_pi_mock): + self._login() + self._create_cart_item(quantity=2) + reservation_mock.return_value = (MagicMock(id=321), []) + create_pi_mock.return_value = MagicMock(client_secret="secret", id="pi_123") + + self.assertEqual(self.client.get(reverse("crear_payment_intent")).status_code, 405) + bad_json = self.client.post(reverse("crear_payment_intent"), data="{", content_type="application/json") + self.assertEqual(bad_json.status_code, 400) + + missing_address = self._post_json("crear_payment_intent", {}) + self.assertEqual(missing_address.status_code, 400) + + ok = self._post_json("crear_payment_intent", {"shipping_address_id": self.address.id, "save_card": True}) + self.assertEqual(ok.status_code, 200) + data = ok.json() + self.assertEqual(data["client_secret"], "secret") + self.assertEqual(data["payment_intent_id"], "pi_123") + + @patch("tienda.views.create_order_from_cart") + @patch("tienda.views.stripe.PaymentIntent.retrieve") + def test_confirmar_pago_tarjeta_endpoint(self, retrieve_mock, create_order_mock): + self._login() + self._create_cart_item(quantity=1) + session = self.client.session + session["selected_shipping_address_id"] = self.address.id + session.save() + + retrieve_mock.return_value = MagicMock(status="succeeded") + order = Order.objects.create( + buyer=self.buyer, + shipping_address=self.address, + total=12.1, + payment_method=Order.PAYMENT_STRIPE, + ) + create_order_mock.return_value = (order, "") + + self.assertEqual(self.client.get(reverse("confirmar_pago_tarjeta")).status_code, 405) + self.assertEqual(self.client.post(reverse("confirmar_pago_tarjeta"), data="{", content_type="application/json").status_code, 400) + self.assertEqual(self._post_json("confirmar_pago_tarjeta", {}).status_code, 400) + + ok = self._post_json("confirmar_pago_tarjeta", {"payment_intent_id": "pi_ok"}) + self.assertEqual(ok.status_code, 200) + self.assertTrue(ok.json()["success"]) + + @patch("tienda.views._paypal_create_order") + @patch("tienda.views._create_stock_reservation_for_cart") + def test_crear_orden_paypal_endpoint(self, reservation_mock, create_order_mock): + self._login() + self._create_cart_item(quantity=1) + reservation_mock.return_value = (MagicMock(id=555), []) + create_order_mock.return_value = {"id": "ORDER123"} + + self.assertEqual(self.client.get(reverse("crear_orden_paypal")).status_code, 405) + missing_address = self._post_json("crear_orden_paypal", {}) + self.assertEqual(missing_address.status_code, 400) + + ok = self._post_json("crear_orden_paypal", {"shipping_address_id": self.address.id}) + self.assertEqual(ok.status_code, 200) + self.assertEqual(ok.json()["id"], "ORDER123") + + @patch("tienda.views.create_order_from_cart") + @patch("tienda.views._paypal_capture_order") + def test_capturar_orden_paypal_endpoint(self, capture_mock, create_order_mock): + self._login() + self._create_cart_item(quantity=1) + session = self.client.session + session["paypal_order_id"] = "ORDER123" + session["selected_shipping_address_id"] = self.address.id + session.save() + + order = Order.objects.create( + buyer=self.buyer, + shipping_address=self.address, + total=12.1, + payment_method=Order.PAYMENT_PAYPAL, + ) + create_order_mock.return_value = (order, "") + + self.assertEqual(self.client.get(reverse("capturar_orden_paypal")).status_code, 405) + self.assertEqual(self.client.post(reverse("capturar_orden_paypal"), data="{", content_type="application/json").status_code, 400) + self.assertEqual(self._post_json("capturar_orden_paypal", {}).status_code, 400) + self.assertEqual(self._post_json("capturar_orden_paypal", {"orderID": "WRONG"}).status_code, 400) + + capture_mock.return_value = {"status": "APPROVED"} + not_completed = self._post_json("capturar_orden_paypal", {"orderID": "ORDER123"}) + self.assertEqual(not_completed.status_code, 400) + + capture_mock.return_value = { + "status": "COMPLETED", + "payer": {"email_address": "paypal@example.com", "payer_id": "payer_123"}, + } + ok = self._post_json("capturar_orden_paypal", {"orderID": "ORDER123", "save_paypal": True}) + self.assertEqual(ok.status_code, 200) + self.assertTrue(ok.json()["success"]) + self.assertTrue(SavedPaymentMethod.objects.filter(user=self.buyer, paypal_email="paypal@example.com").exists()) + + @patch("tienda.views.create_order_from_cart") + @patch("paypalrestsdk.Payment.find") + @patch("paypalrestsdk.configure") + def test_paypal_legacy_endpoints(self, configure_mock, find_mock, create_order_mock): + self._login() + self._create_cart_item(quantity=1) + + self.assertEqual(self.client.get(reverse("create_paypal_payment")).status_code, 405) + + with patch("paypalrestsdk.Payment") as payment_cls, patch("tienda.views._create_stock_reservation_for_cart") as reservation_mock: + reservation_mock.return_value = (MagicMock(id=777), []) + payment_instance = MagicMock() + payment_instance.create.return_value = True + payment_instance.id = "PAY-123" + payment_instance.links = [MagicMock(rel="approval_url", href="https://paypal.local/approve")] + payment_cls.return_value = payment_instance + create_payment = self.client.post(reverse("create_paypal_payment"), data={"shipping_address_id": self.address.id}) + self.assertEqual(create_payment.status_code, 200) + self.assertIn("redirect", create_payment.json()) + + missing_data = self.client.get(reverse("paypal_execute")) + self.assertEqual(missing_data.status_code, 302) + + session = self.client.session + session["paypal_payment_id"] = "PAY-123" + session["selected_shipping_address_id"] = self.address.id + session.save() + payment_found = MagicMock() + payment_found.execute.return_value = True + find_mock.return_value = payment_found + order = Order.objects.create( + buyer=self.buyer, + shipping_address=self.address, + total=12.1, + payment_method=Order.PAYMENT_PAYPAL, + ) + create_order_mock.return_value = (order, "") + execute = self.client.get(reverse("paypal_execute"), data={"PayerID": "payer"}) + self.assertEqual(execute.status_code, 200) + + @patch("tienda.views.stripe.SetupIntent.create") + @patch("tienda.views._get_or_create_stripe_customer") + def test_setup_intent_endpoints(self, customer_mock, setup_mock): + self._login() + self.assertEqual(self.client.get(reverse("metodos_pago")).status_code, 200) + self.assertEqual(self.client.get(reverse("agregar_tarjeta")).status_code, 200) + self.assertEqual(self.client.get(reverse("agregar_paypal")).status_code, 200) + + self.assertEqual(self.client.get(reverse("crear_setup_intent")).status_code, 405) + customer_mock.return_value = "cus_123" + setup_mock.return_value = MagicMock(client_secret="seti_secret") + setup_response = self.client.post(reverse("crear_setup_intent")) + self.assertEqual(setup_response.status_code, 200) + self.assertEqual(setup_response.json()["customer_id"], "cus_123") + + self.assertEqual(self.client.get(reverse("confirmar_setup_intent")).status_code, 405) + self.assertEqual(self.client.post(reverse("confirmar_setup_intent"), data="{", content_type="application/json").status_code, 400) + self.assertEqual(self._post_json("confirmar_setup_intent", {}).status_code, 400) + + with patch("tienda.views.stripe.PaymentMethod.attach"), patch("tienda.views.stripe.PaymentMethod.retrieve") as retrieve_pm: + retrieve_pm.return_value = MagicMock( + card=MagicMock(brand="visa", last4="4242", exp_month=1, exp_year=2030) + ) + confirm = self._post_json("confirmar_setup_intent", {"payment_method_id": "pm_123"}) + self.assertEqual(confirm.status_code, 200) + self.assertTrue(confirm.json()["success"]) + + @patch("tienda.views._paypal_create_order") + def test_paypal_setup_endpoints(self, create_order_mock): + self._login() + self.assertEqual(self.client.get(reverse("crear_orden_paypal_setup")).status_code, 405) + create_order_mock.return_value = {"id": "ORDER_SETUP"} + create_response = self.client.post(reverse("crear_orden_paypal_setup")) + self.assertEqual(create_response.status_code, 200) + self.assertEqual(create_response.json()["id"], "ORDER_SETUP") + + self.assertEqual(self.client.get(reverse("capturar_orden_paypal_setup")).status_code, 405) + self.assertEqual(self.client.post(reverse("capturar_orden_paypal_setup"), data="{", content_type="application/json").status_code, 400) + self.assertEqual(self._post_json("capturar_orden_paypal_setup", {}).status_code, 400) + + with patch("tienda.views._paypal_capture_order") as capture_mock: + capture_mock.return_value = {"status": "COMPLETED", "payer": {"email_address": "payer@example.com", "payer_id": "payer_1"}} + capture_response = self._post_json("capturar_orden_paypal_setup", {"orderID": "ORDER_SETUP"}) + self.assertEqual(capture_response.status_code, 200) + self.assertTrue(capture_response.json()["success"]) + + def test_user_portal_and_addresses_endpoints(self): + self._login() + order = Order.objects.create( + buyer=self.buyer, + shipping_address=self.address, + total=12.1, + payment_method=Order.PAYMENT_STRIPE, + ) + item = OrderItem.objects.create( + order=order, + product=self.product, + product_name=self.product.name, + seller=self.seller, + quantity=1, + unit_price=12.1, + total_price=12.1, + ) + OrderMessage.objects.create(order_item=item, sender=self.seller, message="Mensaje vendedor") + + self.assertEqual(self.client.get(reverse("portal_usuario")).status_code, 200) + self.assertEqual(self.client.get(reverse("mis_compras")).status_code, 200) + self.assertEqual(self.client.get(reverse("mis_recibos")).status_code, 200) + self.assertEqual(self.client.get(reverse("mensajes_comprador")).status_code, 200) + self.assertEqual(self.client.get(reverse("direcciones_usuario")).status_code, 200) + self.assertEqual(self.client.get(reverse("crear_direccion")).status_code, 200) + self.assertEqual(self.client.get(reverse("editar_direccion", args=[self.address.id])).status_code, 200) + + profile = self.client.post(reverse("editar_perfil"), data={ + "first_name": "Nombre", + "last_name": "Apellido", + "email": "buyer-updated@example.com", + }) + self.assertEqual(profile.status_code, 302) + self.buyer.refresh_from_db() + self.assertEqual(self.buyer.email, "buyer-updated@example.com") + + wrong_current = self.client.post(reverse("cambiar_contrasena"), data={ + "current_password": "incorrecta", + "new_password": "PasswordNueva123!", + "confirm_password": "PasswordNueva123!", + }) + self.assertEqual(wrong_current.status_code, 200) + changed = self.client.post(reverse("cambiar_contrasena"), data={ + "current_password": self.password, + "new_password": "PasswordNueva123!", + "confirm_password": "PasswordNueva123!", + }) + self.assertEqual(changed.status_code, 302) + self.buyer.refresh_from_db() + self.assertTrue(self.buyer.check_password("PasswordNueva123!")) + + invalid_city = self.client.post(reverse("crear_direccion"), data={ + "full_name": "Comprador Uno", + "address_line_1": "Calle Nueva 3", + "city": "Madrid", + "postal_code": "04003", + "phone": "600000003", + }) + self.assertEqual(invalid_city.status_code, 200) + invalid_postal = self.client.post(reverse("crear_direccion"), data={ + "full_name": "Comprador Uno", + "address_line_1": "Calle Nueva 3", + "city": "Almería", + "postal_code": "28001", + "phone": "600000003", + }) + self.assertEqual(invalid_postal.status_code, 200) + + create_ok = self.client.post(reverse("crear_direccion"), data={ + "full_name": "Comprador Dos", + "address_line_1": "Calle Nueva 3", + "city": "Almería", + "postal_code": "04003", + "phone": "600000003", + "is_default": "on", + }) + self.assertEqual(create_ok.status_code, 302) + new_address = ShippingAddress.objects.get(full_name="Comprador Dos") + self.assertEqual(new_address.country, "España") + + edit_ok = self.client.post(reverse("editar_direccion", args=[new_address.id]), data={ + "full_name": "Comprador Dos Editado", + "address_line_1": "Calle Editada 9", + "city": "Almería", + "postal_code": "04004", + "phone": "600000004", + }) + self.assertEqual(edit_ok.status_code, 302) + new_address.refresh_from_db() + self.assertEqual(new_address.full_name, "Comprador Dos Editado") + + delete_get = self.client.get(reverse("eliminar_direccion", args=[new_address.id])) + self.assertEqual(delete_get.status_code, 302) + delete_post = self.client.post(reverse("eliminar_direccion", args=[new_address.id])) + self.assertEqual(delete_post.status_code, 302) + self.assertFalse(ShippingAddress.objects.filter(id=new_address.id).exists()) + + def test_delete_payment_method_endpoint(self): + self._login() + method = SavedPaymentMethod.objects.create( + user=self.buyer, + method_type=SavedPaymentMethod.TYPE_CARD, + label="Visa 4242", + stripe_payment_method_id="pm_4242", + ) + self.assertEqual(self.client.get(reverse("eliminar_metodo_pago", args=[method.id])).status_code, 302) + with patch("tienda.views.stripe.PaymentMethod.detach"): + response = self.client.post(reverse("eliminar_metodo_pago", args=[method.id])) + self.assertEqual(response.status_code, 302) + self.assertFalse(SavedPaymentMethod.objects.filter(id=method.id).exists()) diff --git a/tienda/urls.py b/tienda/urls.py index 5978912..a473dcb 100644 --- a/tienda/urls.py +++ b/tienda/urls.py @@ -25,12 +25,15 @@ urlpatterns = [ path("cart/remove//", views.remove_from_cart, name="remove_from_cart"), path("cart/clear/", views.clear_cart, name="clear_cart"), path("checkout/", views.checkout, name="checkout"), - # Stripe - path("config/", views.stripe_config, name="stripe_config"), - path("create-checkout-session/", views.create_checkout_session, name="create_checkout_session"), + # Stripe Payment Intents (nuevo sistema) + path("checkout/crear-payment-intent/", views.crear_payment_intent, name="crear_payment_intent"), + path("checkout/confirmar-pago-tarjeta/", views.confirmar_pago_tarjeta, name="confirmar_pago_tarjeta"), path("checkout/success/", views.checkout_success, name="checkout_success"), path("checkout/cancel/", views.checkout_cancel, name="checkout_cancel"), - # PayPal + # PayPal Orders API (nuevo sistema) + path("paypal/crear-orden/", views.crear_orden_paypal, name="crear_orden_paypal"), + path("paypal/capturar-orden/", views.capturar_orden_paypal, name="capturar_orden_paypal"), + # PayPal (legacy - mantenido por compatibilidad) path("paypal/create-payment/", views.create_paypal_payment, name="create_paypal_payment"), path("paypal/execute/", views.paypal_execute, name="paypal_execute"), # Portal de usuario @@ -44,8 +47,24 @@ urlpatterns = [ path("usuario/direcciones//editar/", views.editar_direccion, name="editar_direccion"), path("usuario/direcciones//eliminar/", views.eliminar_direccion, name="eliminar_direccion"), path("usuario/mensajes/", views.mensajes_comprador, name="mensajes_comprador"), + # Métodos de pago del usuario + path("usuario/metodos-pago/", views.metodos_pago, name="metodos_pago"), + path("usuario/metodos-pago/agregar-tarjeta/", views.agregar_tarjeta, name="agregar_tarjeta"), + path("usuario/metodos-pago/agregar-tarjeta/crear-setup-intent/", views.crear_setup_intent, name="crear_setup_intent"), + path("usuario/metodos-pago/agregar-tarjeta/confirmar/", views.confirmar_setup_intent, name="confirmar_setup_intent"), + path("usuario/metodos-pago//eliminar/", views.eliminar_metodo_pago, name="eliminar_metodo_pago"), + path("usuario/metodos-pago/agregar-paypal/", views.agregar_paypal, name="agregar_paypal"), + path("usuario/metodos-pago/agregar-paypal/crear-orden/", views.crear_orden_paypal_setup, name="crear_orden_paypal_setup"), + path("usuario/metodos-pago/agregar-paypal/capturar/", views.capturar_orden_paypal_setup, name="capturar_orden_paypal_setup"), path("verify/", views.verify, name="verify"), path("rgpd", views.rgpd, name="rgpd"), + path("privacidad", views.rgpd, name="privacidad"), + path("devoluciones", views.devoluciones, name="devoluciones"), + path("aviso-legal", views.aviso_legal, name="aviso_legal"), + path("terminos", views.terminos, name="terminos"), + path("cookies", views.cookies, name="cookies"), + path("sobre-nosotros", views.sobre_nosotros, name="sobre_nosotros"), + path("ayuda", views.ayuda, name="ayuda"), path("reset-password", views.reset_password, name="reset_password"), path("reset-password-phase2/", views.reset_password_phase2, name="reset_password_phase2") ] diff --git a/tienda/views.py b/tienda/views.py index da6bc5a..a44db6b 100644 --- a/tienda/views.py +++ b/tienda/views.py @@ -4,7 +4,7 @@ from django.contrib.auth import authenticate, login as auth_login, logout as aut 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 +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, @@ -28,6 +28,7 @@ import unicodedata import json import random, string import logging +import requests # Create your views here. @@ -37,6 +38,13 @@ 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") @@ -90,6 +98,88 @@ def _get_client_ip(request: HttpRequest) -> str: 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( @@ -442,6 +532,8 @@ def _create_stock_reservation_for_cart(request: HttpRequest, cart_items, payment for item in cart_items ]) + _invalidate_product_cache(product_ids) + return reservation, [] @@ -593,6 +685,8 @@ def create_order_from_cart(request, payment_method, payment_reference="", shippi 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: @@ -763,10 +857,11 @@ def pedidos_vendedor(request: HttpRequest): pedidos = OrderItem.objects.filter(seller=request.user).select_related( 'order', 'product', 'order__buyer', 'order__shipping_address' ).prefetch_related('messages__sender').order_by('-created_at') + total_pedidos_por_enviar = pedidos.exclude(status=OrderItem.STATUS_SHIPPED).count() return render(request, "tienda/pedidos_vendedor.html", { "pedidos": pedidos, - "total_pedidos": pedidos.count() + "total_pedidos": total_pedidos_por_enviar }) @@ -865,7 +960,10 @@ def crear_producto(request: HttpRequest): name=f"{name}_principal", image=primary_image_file ) - + if stock > 4294967295: + messages.error(request, "No se puede tener mas de 4294967295 existencias. Por favor, intentelo de nuevo") + categories = Category.objects.all() + return render(request, "tienda/crear_producto.html", {"categories": categories}) # Crear producto producto = Product.objects.create( name=name, @@ -877,6 +975,7 @@ def crear_producto(request: HttpRequest): primary_image=primary_image, creator=request.user ) + _invalidate_product_cache([producto.id]) # Agregar imágenes secundarias si se proporcionan if secondary_images_files: @@ -934,6 +1033,13 @@ def editar_producto(request: HttpRequest, id: int): stock = int(stock) if stock < 0: raise ValueError("El stock no puede ser negativo") + if stock > 4294967295: + messages.error(request, "No se puede tener mas de 4294967295 de stock.") + categories = Category.objects.all() + return render(request, "tienda/editar_producto.html", { + "categories": categories, + "producto": producto + }) except ValueError: messages.error(request, "El stock debe ser un número entero válido.") categories = Category.objects.all() @@ -967,6 +1073,7 @@ def editar_producto(request: HttpRequest, id: int): producto.primary_image = primary_image producto.save() + _invalidate_product_cache([producto.id]) if secondary_images_files: producto.secondary_images.clear() @@ -996,6 +1103,7 @@ def borrar_producto(request: HttpRequest, id: int): 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") @@ -1007,12 +1115,18 @@ def checkout(request: HttpRequest): 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 @@ -1096,7 +1210,7 @@ def create_checkout_session(request: HttpRequest): 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": f"Error al crear sesión de pago: {str(e)}"}, status=500) + return JsonResponse({"error": "Error al crear la sesión de pago. Por favor inténtalo de nuevo."}, status=500) @login_required @@ -1155,7 +1269,28 @@ def search(request: HttpRequest): }) -# ==================== PAYPAL PAYMENT ==================== +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): @@ -1340,26 +1475,521 @@ def paypal_execute(request: HttpRequest): return redirect("checkout") -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: # Mínimo 2 caracteres para sugerir - # Buscar en nombre (primario) y descripción - products = Product.objects.filter( - models.Q(name__icontains=query) | - models.Q(briefdesc__icontains=query) - ).values_list('name', 'id', 'price', 'primary_image_id').distinct()[:8] # Máximo 8 sugerencias - - for name, product_id, price, image_id in products: - suggestions.append({ - 'name': name, - 'id': product_id, - 'price': float(price) - }) - - return JsonResponse({'suggestions': suggestions}) +# ==================== 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 + try: + stripe.PaymentMethod.attach(payment_method_id, customer=customer_id) + except stripe.error.InvalidRequestError as attach_err: + # The payment method may already be attached to a customer + pm_check = stripe.PaymentMethod.retrieve(payment_method_id) + if pm_check.get("customer") == customer_id: + # Already attached to this same customer – continue normally + pass + else: + logger.warning( + "CONFIRMAR_SETUP_INTENT_ALREADY_ATTACHED user_id=%s pm=%s error=%s", + request.user.id, payment_method_id, str(attach_err), + ) + return JsonResponse( + {"error": "Este método de pago ya está asociado a otra cuenta. " + "Por favor, usa una tarjeta diferente."}, + status=400, + ) + + 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})" + + # Avoid saving duplicates in our database + existing = SavedPaymentMethod.objects.filter( + user=request.user, + stripe_payment_method_id=payment_method_id, + ).first() + if existing: + return JsonResponse({"success": True, "label": existing.label, "id": existing.id}) + + 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 ==================== @@ -1633,6 +2263,24 @@ def reset_password(request: HttpRequest): def rgpd(request: HttpRequest): return render(request, "tienda/rgpd.html", {}) +def devoluciones(request: HttpRequest): + return render(request, "tienda/devoluciones.html", {}) + +def aviso_legal(request: HttpRequest): + return render(request, "tienda/aviso_legal.html", {}) + +def terminos(request: HttpRequest): + return render(request, "tienda/terminos.html", {}) + +def cookies(request: HttpRequest): + return render(request, "tienda/cookies.html", {}) + +def sobre_nosotros(request: HttpRequest): + return render(request, "tienda/sobre_nosotros.html", {}) + +def ayuda(request: HttpRequest): + return render(request, "tienda/ayuda.html", {}) + def reset_password(request: HttpRequest): if request.method == "GET": return render(request, "tienda/reset_password.html", {})