feat: Add user purchase and receipt management

- Implemented 'Mis Compras' and 'Mis Recibos' pages for users to view their orders and payment receipts.
- Enhanced address validation in 'editar_direccion.html' to ensure cities and postal codes belong to Almería.
- Added shipping address display in seller order details on 'pedidos_vendedor.html'.
- Updated user portal to include links to purchases and receipts.
- Introduced email verification functionality during user registration.
- Refactored email sending utility for better error handling and logging.
- Improved session management for checkout processes with selected shipping addresses.
This commit is contained in:
2026-03-10 13:08:10 +01:00
parent 01024bb97e
commit 162b63cae9
51 changed files with 1082 additions and 385 deletions
+2
View File
@@ -0,0 +1,2 @@
from .celery import app as celery_app
__all__ = ('celery_app',)
Binary file not shown.
Binary file not shown.
+155 -13
View File
@@ -10,26 +10,65 @@ For the full list of settings and their values, see
https://docs.djangoproject.com/en/6.0/ref/settings/ https://docs.djangoproject.com/en/6.0/ref/settings/
""" """
import logging
import os, sys
from pathlib import Path from pathlib import Path
def load_dotenv(dotenv_path: Path) -> None:
if not dotenv_path.exists():
return
for raw_line in dotenv_path.read_text(encoding='utf-8').splitlines():
line = raw_line.strip()
if not line or line.startswith('#') or '=' not in line:
continue
key, value = line.split('=', 1)
key = key.strip()
value = value.strip().strip('"').strip("'")
os.environ.setdefault(key, value)
def env_bool(name: str, default: bool = False) -> bool:
value = os.getenv(name)
if value is None:
return default
return value.strip().lower() in {'1', 'true', 'yes', 'on'}
def env_list(name: str, default: list[str] | None = None) -> list[str]:
value = os.getenv(name)
if value is None:
return default or []
return [item.strip() for item in value.split(',') if item.strip()]
def env_int(name: str, default: int) -> int:
value = os.getenv(name)
if value is None:
return default
return int(value)
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
load_dotenv(BASE_DIR / '.env')
# Quick-start development settings - unsuitable for production # Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/ # See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret! # SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-#g((q@lvnkt(j6)2(gvtn0px)r2r(911)pv59i(6w)5e!_-^ao' SECRET_KEY = os.getenv('SECRET_KEY', 'django-insecure-#g((q@lvnkt(j6)2(gvtn0px)r2r(911)pv59i(6w)5e!_-^ao')
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True DEBUG = env_bool('DEBUG', True)
ALLOWED_HOSTS = [ ALLOWED_HOSTS = env_list('ALLOWED_HOSTS', [
"192.168.1.142", '192.168.1.142',
"localhost", 'localhost',
"127.0.0.1" '127.0.0.1',
] ])
# Application definition # Application definition
@@ -152,7 +191,7 @@ MEDIA_ROOT = BASE_DIR / 'tienda' / 'static' / 'media'
CACHES = { CACHES = {
'default': { 'default': {
'BACKEND': 'django_redis.cache.RedisCache', 'BACKEND': 'django_redis.cache.RedisCache',
'LOCATION': 'redis://127.0.0.1:6379/1', 'LOCATION': os.getenv('REDIS_URL', 'redis://127.0.0.1:6379/1'),
'OPTIONS': { 'OPTIONS': {
'CLIENT_CLASS': 'django_redis.client.DefaultClient', 'CLIENT_CLASS': 'django_redis.client.DefaultClient',
} }
@@ -177,11 +216,114 @@ MESSAGE_TAGS = {
# Login URL # Login URL
LOGIN_URL = '/tienda/login/' LOGIN_URL = '/tienda/login/'
STRIPE_PUBLISHABLE_KEY = 'pk_test_51SxmSYJ2DN4I0upQDdiPeda51nmpB0ZEWfkNFKHhWBG4knIgtRoC1d9iFRoxRNdJKiLlQsIddlebU06R9XCfiSZH00ffoirwPw' STRIPE_PUBLISHABLE_KEY = os.getenv('STRIPE_PUBLISHABLE_KEY', '')
STRIPE_SECRET_KEY = 'sk_test_51SxmSYJ2DN4I0upQZb42dWKuIKToZxkQeK3vsCdijcaUr17EMEyFcLdIAm5AVEvUs96MAxl4KnZ4Yncp5VykO4ej00MZGs6c1F' STRIPE_SECRET_KEY = os.getenv('STRIPE_SECRET_KEY', '')
# PayPal Configuration (Sandbox) # PayPal Configuration (Sandbox)
# Para obtener credenciales: https://sandbox.paypal.com/ # Para obtener credenciales: https://sandbox.paypal.com/
PAYPAL_CLIENT_ID = 'AX3TIklQ41456StP2puciDfkQ6oSWAQWNYB8H9ThDsU6C_VYhWqwDZ1w0dK-No38Aa9IqAbrZbE-1kHJ' # Reemplazar con tu Client ID de PayPal Sandbox PAYPAL_CLIENT_ID = os.getenv('PAYPAL_CLIENT_ID', '') # Reemplazar con tu Client ID de PayPal Sandbox
PAYPAL_CLIENT_SECRET = 'EIXny9EkiebiCnwkfmWJa7ufwHwdUCTeSZ5TiUZycBPREradcN7U0vBKCUlg-PYd3SeXTW33D0kZb5BT' # Reemplazar con tu Client Secret de PayPal Sandbox PAYPAL_CLIENT_SECRET = os.getenv('PAYPAL_CLIENT_SECRET', '') # Reemplazar con tu Client Secret de PayPal Sandbox
PAYPAL_MODE = 'sandbox' # Cambiar a 'live' en producción PAYPAL_MODE = os.getenv('PAYPAL_MODE', 'sandbox') # Cambiar a 'live' en producción
SMTP_ENDPOINT = os.getenv('SMTP_ENDPOINT', 'smtp.email.eu-paris-1.oci.oraclecloud.com')
SMTP_PORT = env_int('SMTP_PORT', 587)
SECURITY = os.getenv('SECURITY', 'tls')
SMTP_USERNAME = os.getenv('SMTP_USERNAME', None)
SMTP_PASSWORD = os.getenv('SMTP_PASSWORD', None)
SMTP_EMAIL = os.getenv("SMTP_EMAIL", None)
if SMTP_USERNAME is None or SMTP_PASSWORD is None or SMTP_EMAIL is None:
print("Se requieren credenciales SMTP")
sys.exit(1)
AUTH_USER_MODEL = 'tienda.User'
DOMAIN = os.getenv("DOMAIN", "localhost")
PROTOCOL = os.getenv("PROTOCOL", "http")
LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO').upper()
LOG_DIR = Path(os.getenv('LOG_DIR', BASE_DIR / 'logs'))
LOG_DIR.mkdir(parents=True, exist_ok=True)
LOG_FILE = LOG_DIR / os.getenv('LOG_FILE', 'app.log')
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'standard': {
'format': '%(asctime)s | %(levelname)s | %(name)s | %(message)s',
},
},
'handlers': {
'console': {
'class': 'logging.StreamHandler',
'formatter': 'standard',
'level': LOG_LEVEL,
},
'file': {
'class': 'logging.handlers.RotatingFileHandler',
'formatter': 'standard',
'filename': str(LOG_FILE),
'maxBytes': 5 * 1024 * 1024,
'backupCount': 5,
'level': LOG_LEVEL,
'encoding': 'utf-8',
},
},
'loggers': {
'tienda': {
'handlers': ['console', 'file'],
'level': LOG_LEVEL,
'propagate': False,
},
'tienda.audit': {
'handlers': ['console', 'file'],
'level': LOG_LEVEL,
'propagate': False,
},
'django': {
'handlers': ['console'],
'level': 'INFO',
'propagate': False,
},
'email.system': {
'handlers': ['console', 'file'],
'level': LOG_LEVEL,
'propagate': False
}
},
}
logging.captureWarnings(True)
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_HOST = SMTP_ENDPOINT
EMAIL_PORT = SMTP_PORT
EMAIL_USE_TLS = (SECURITY == 'tls') # True si SECURITY es 'tls'
EMAIL_USE_SSL = (SECURITY == 'ssl') # True si SECURITY es 'ssl'
EMAIL_HOST_USER = SMTP_USERNAME
EMAIL_HOST_PASSWORD = SMTP_PASSWORD
# El correo que se usará como remitente por defecto
DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", SMTP_EMAIL)
# URL de Redis (asumiendo que corre en el puerto default 6379)
CELERY_BROKER_URL = 'redis://localhost:6379/0'
# Opcional: para guardar el resultado de las tareas
CELERY_RESULT_BACKEND = 'redis://localhost:6379/0'
# Configuraciones adicionales recomendadas
CELERY_ACCEPT_CONTENT = ['json']
CELERY_TASK_SERIALIZER = 'json'
CELERY_RESULT_SERIALIZER = 'json'
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+3 -3
View File
@@ -1,12 +1,12 @@
from django.contrib import admin from django.contrib import admin
from .models import Category, Image, Product, Cart, CartItem, Order, OrderItem, OrderMessage from .models import Category, Image, Product, Cart, CartItem, Order, OrderItem, OrderMessage, User, VerificationCode
# Register your models here. # Register your models here.
admin.site.register(Category) admin.site.register(Category)
admin.site.register(Image) admin.site.register(Image)
admin.site.register(Product) admin.site.register(Product)
admin.site.register(User)
admin.site.register(VerificationCode)
class CartItemInline(admin.TabularInline): class CartItemInline(admin.TabularInline):
model = CartItem model = CartItem
extra = 0 extra = 0
+158 -3
View File
@@ -1,6 +1,10 @@
# Generated by Django 6.0.1 on 2026-01-23 09:33 # Generated by Django 6.0.1 on 2026-03-10 07:56
import django.contrib.auth.models
import django.contrib.auth.validators
import django.db.models.deletion import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
@@ -9,6 +13,7 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
] ]
operations = [ operations = [
@@ -17,10 +22,160 @@ class Migration(migrations.Migration):
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200)), ('name', models.CharField(max_length=200)),
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='subcategories', to='tienda.category')), ],
),
migrations.CreateModel(
name='Image',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(default='', max_length=200)),
('image', models.ImageField(upload_to='images/')),
('alt', models.CharField(blank=True, default='', max_length=255, verbose_name='Texto alternativo')),
],
),
migrations.CreateModel(
name='User',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('registration_status', models.CharField(choices=[('CR', 'Confirmation Required'), ('AC', 'Active'), ('BN', 'Banned')], default='CR', max_length=2)),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
], ],
options={ options={
'verbose_name_plural': 'Categories', 'verbose_name': 'user',
'verbose_name_plural': 'users',
'abstract': False,
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
migrations.CreateModel(
name='Cart',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('session_key', models.CharField(blank=True, max_length=40, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='Order',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('session_key', models.CharField(blank=True, max_length=40, null=True)),
('total', models.FloatField(default=0)),
('status', models.CharField(choices=[('paid', 'Pagado'), ('cancelled', 'Cancelado')], default='paid', max_length=20)),
('payment_method', models.CharField(choices=[('stripe', 'Stripe'), ('paypal', 'PayPal'), ('manual', 'Manual')], default='manual', max_length=20)),
('payment_reference', models.CharField(blank=True, default='', max_length=200)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('buyer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='OrderItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('product_name', models.CharField(default='', max_length=200)),
('quantity', models.PositiveIntegerField(default=1)),
('unit_price', models.FloatField(default=0)),
('total_price', models.FloatField(default=0)),
('status', models.CharField(choices=[('pending', 'Pendiente'), ('processing', 'En preparación'), ('shipped', 'Enviado')], default='pending', max_length=20)),
('created_at', models.DateTimeField(auto_now_add=True)),
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='tienda.order')),
('seller', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='order_items_to_fulfill', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='OrderMessage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('message', models.TextField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('order_item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='tienda.orderitem')),
('sender', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sent_messages', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['created_at'],
},
),
migrations.CreateModel(
name='Product',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(default='', max_length=200)),
('description', models.TextField(default='')),
('briefdesc', models.TextField(default='')),
('price', models.FloatField(default=0)),
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tienda.category')),
('creator', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='created_products', to=settings.AUTH_USER_MODEL)),
('primary_image', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='tienda.image')),
('secondary_images', models.ManyToManyField(blank=True, related_name='products_secondary', to='tienda.image')),
],
),
migrations.AddField(
model_name='orderitem',
name='product',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='tienda.product'),
),
migrations.CreateModel(
name='ShippingAddress',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('full_name', models.CharField(max_length=200, verbose_name='Nombre completo')),
('address_line_1', models.CharField(max_length=250, verbose_name='Dirección')),
('address_line_2', models.CharField(blank=True, max_length=250, verbose_name='Dirección (línea 2)')),
('city', models.CharField(max_length=100, verbose_name='Ciudad')),
('postal_code', models.CharField(max_length=20, verbose_name='Código postal')),
('country', models.CharField(default='España', max_length=100, verbose_name='País')),
('phone', models.CharField(max_length=20, verbose_name='Teléfono')),
('is_default', models.BooleanField(default=False, verbose_name='Dirección predeterminada')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shipping_addresses', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Dirección de envío',
'verbose_name_plural': 'Direcciones de envío',
'ordering': ['-is_default', '-created_at'],
},
),
migrations.AddField(
model_name='order',
name='shipping_address',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='tienda.shippingaddress'),
),
migrations.CreateModel(
name='VerificationCode',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.TextField(default='')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_belongsto', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='CartItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.PositiveIntegerField(default=1)),
('added_at', models.DateTimeField(auto_now_add=True)),
('cart', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='tienda.cart')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tienda.product')),
],
options={
'unique_together': {('cart', 'product')},
}, },
), ),
] ]
@@ -1,21 +0,0 @@
# Generated by Django 6.0.1 on 2026-01-23 09:38
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('tienda', '0001_initial'),
]
operations = [
migrations.AlterModelOptions(
name='category',
options={},
),
migrations.RemoveField(
model_name='category',
name='parent',
),
]
@@ -0,0 +1,23 @@
# Generated by Django 6.0.1 on 2026-03-10 11:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tienda', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='verificationcode',
name='code_mode',
field=models.CharField(choices=[('VA', 'Verify Account'), ('RP', 'Reset Password')], default='VA', max_length=2),
),
migrations.AlterField(
model_name='verificationcode',
name='code',
field=models.TextField(default='', unique=True),
),
]
-21
View File
@@ -1,21 +0,0 @@
# Generated by Django 6.0.1 on 2026-01-23 09:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tienda', '0002_alter_category_options_remove_category_parent'),
]
operations = [
migrations.CreateModel(
name='Image',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(default='', max_length=200)),
('image', models.ImageField(upload_to='')),
],
),
]
@@ -1,18 +0,0 @@
# Generated by Django 6.0.1 on 2026-01-23 09:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tienda', '0003_image'),
]
operations = [
migrations.AlterField(
model_name='image',
name='image',
field=models.ImageField(upload_to='images/'),
),
]
-25
View File
@@ -1,25 +0,0 @@
# Generated by Django 6.0.1 on 2026-01-23 09:48
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tienda', '0004_alter_image_image'),
]
operations = [
migrations.CreateModel(
name='Product',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(default='', max_length=200)),
('description', models.TextField(default='')),
('price', models.FloatField(default=0)),
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tienda.category')),
('primary_image', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='tienda.image')),
],
),
]
@@ -1,18 +0,0 @@
# Generated by Django 6.0.1 on 2026-01-23 09:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tienda', '0005_product'),
]
operations = [
migrations.AddField(
model_name='product',
name='secondary_images',
field=models.ManyToManyField(blank=True, related_name='products_secondary', to='tienda.image'),
),
]
@@ -1,18 +0,0 @@
# Generated by Django 6.0.1 on 2026-02-06 07:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tienda', '0006_product_secondary_images'),
]
operations = [
migrations.AddField(
model_name='product',
name='briefdesc',
field=models.TextField(default=''),
),
]
-39
View File
@@ -1,39 +0,0 @@
# Generated by Django 6.0.1 on 2026-02-06 10:41
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tienda', '0007_product_briefdesc'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Cart',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('session_key', models.CharField(blank=True, max_length=40, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='CartItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.PositiveIntegerField(default=1)),
('added_at', models.DateTimeField(auto_now_add=True)),
('cart', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='tienda.cart')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tienda.product')),
],
options={
'unique_together': {('cart', 'product')},
},
),
]
-21
View File
@@ -1,21 +0,0 @@
# Generated by Django 6.0.1 on 2026-02-06 10:48
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tienda', '0008_cart_cartitem'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='product',
name='creator',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='created_products', to=settings.AUTH_USER_MODEL),
),
]
-45
View File
@@ -1,45 +0,0 @@
# Generated by Django 6.0.1 on 2026-02-09 09:06
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tienda', '0009_product_creator'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Order',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('session_key', models.CharField(blank=True, max_length=40, null=True)),
('total', models.FloatField(default=0)),
('status', models.CharField(choices=[('paid', 'Pagado'), ('cancelled', 'Cancelado')], default='paid', max_length=20)),
('payment_method', models.CharField(choices=[('stripe', 'Stripe'), ('paypal', 'PayPal'), ('manual', 'Manual')], default='manual', max_length=20)),
('payment_reference', models.CharField(blank=True, default='', max_length=200)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('buyer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='OrderItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('product_name', models.CharField(default='', max_length=200)),
('quantity', models.PositiveIntegerField(default=1)),
('unit_price', models.FloatField(default=0)),
('total_price', models.FloatField(default=0)),
('status', models.CharField(choices=[('pending', 'Pendiente'), ('processing', 'En preparación'), ('shipped', 'Enviado')], default='pending', max_length=20)),
('created_at', models.DateTimeField(auto_now_add=True)),
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='tienda.order')),
('product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='tienda.product')),
('seller', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='order_items_to_fulfill', to=settings.AUTH_USER_MODEL)),
],
),
]
-29
View File
@@ -1,29 +0,0 @@
# Generated by Django 6.0.1 on 2026-02-09 09:12
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tienda', '0010_order_orderitem'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='OrderMessage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('message', models.TextField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('order_item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='tienda.orderitem')),
('sender', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sent_messages', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['created_at'],
},
),
]
@@ -1,43 +0,0 @@
# Generated by Django 6.0.1 on 2026-02-16 11:57
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tienda', '0011_ordermessage'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='image',
name='alt',
field=models.CharField(blank=True, default='', max_length=255, verbose_name='Texto alternativo'),
),
migrations.CreateModel(
name='ShippingAddress',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('full_name', models.CharField(max_length=200, verbose_name='Nombre completo')),
('address_line_1', models.CharField(max_length=250, verbose_name='Dirección')),
('address_line_2', models.CharField(blank=True, max_length=250, verbose_name='Dirección (línea 2)')),
('city', models.CharField(max_length=100, verbose_name='Ciudad')),
('postal_code', models.CharField(max_length=20, verbose_name='Código postal')),
('country', models.CharField(default='España', max_length=100, verbose_name='País')),
('phone', models.CharField(max_length=20, verbose_name='Teléfono')),
('is_default', models.BooleanField(default=False, verbose_name='Dirección predeterminada')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shipping_addresses', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Dirección de envío',
'verbose_name_plural': 'Direcciones de envío',
'ordering': ['-is_default', '-created_at'],
},
),
]
+27 -1
View File
@@ -1,6 +1,31 @@
from django.db import models from django.db import models
from django.contrib.auth.models import User from django.contrib.auth.models import User, AbstractUser
from .vars import VAT_RATE from .vars import VAT_RATE
import random, string
class User(AbstractUser):
class RegisterStatus(models.TextChoices):
CONFIRMATION_REQUIRED = "CR", "Confirmation Required"
ACTIVE = "AC", "Active"
BANNED = "BN", "Banned"
registration_status = models.CharField(
max_length = 2,
choices = RegisterStatus.choices,
default = RegisterStatus.CONFIRMATION_REQUIRED
)
class VerificationCode(models.Model):
class VerificationModes(models.TextChoices):
VERIFY_ACCOUNT = "VA"
RESET_PASSWORD = "RP"
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="user_belongsto", null=False, blank=False)
code = models.TextField(default = "", unique=True)
code_mode = models.CharField(
max_length=2,
choices = VerificationModes.choices,
default = VerificationModes.VERIFY_ACCOUNT
)
# Create your models here. # Create your models here.
class Category(models.Model): class Category(models.Model):
@@ -105,6 +130,7 @@ class Order(models.Model):
] ]
buyer = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='orders') buyer = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='orders')
shipping_address = models.ForeignKey('ShippingAddress', on_delete=models.SET_NULL, null=True, blank=True, related_name='orders')
session_key = models.CharField(max_length=40, null=True, blank=True) session_key = models.CharField(max_length=40, null=True, blank=True)
total = models.FloatField(default=0) total = models.FloatField(default=0)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default=STATUS_PAID) status = models.CharField(max_length=20, choices=STATUS_CHOICES, default=STATUS_PAID)
+11 -1
View File
@@ -45,6 +45,14 @@ document.addEventListener("DOMContentLoaded", () => {
console.log("Stripe initialized"); console.log("Stripe initialized");
button.addEventListener("click", () => { button.addEventListener("click", () => {
const shippingAddressSelect = document.getElementById("shipping-address");
const selectedShippingAddress = shippingAddressSelect ? shippingAddressSelect.value : "";
if (!selectedShippingAddress) {
alert("Selecciona una dirección de envío para continuar.");
return;
}
console.log("Checkout button clicked"); console.log("Checkout button clicked");
button.disabled = true; button.disabled = true;
button.innerHTML = "Procesando..."; button.innerHTML = "Procesando...";
@@ -55,7 +63,9 @@ document.addEventListener("DOMContentLoaded", () => {
"Content-Type": "application/json", "Content-Type": "application/json",
"X-CSRFToken": getCookie("csrftoken") "X-CSRFToken": getCookie("csrftoken")
}, },
body: JSON.stringify({}) body: JSON.stringify({
shipping_address_id: selectedShippingAddress
})
}) })
.then((res) => { .then((res) => {
console.log("Session response status:", res.status); console.log("Session response status:", res.status);
+7
View File
@@ -0,0 +1,7 @@
from celery import shared_task
from .utilities import send_email
from .vars import login_message
@shared_task
def enviar_correo_bienvenida(email_usuario, nombre_usuario):
send_email(email_usuario, "Inicio de Sesión correcto", login_message.format(name = nombre_usuario))
+8 -3
View File
@@ -1,4 +1,5 @@
{% load static %} {% load static %}
{% load cache %}
{% load compress %} {% load compress %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="es"> <html lang="es">
@@ -69,6 +70,7 @@
{% block head %}{% endblock %} {% block head %}{% endblock %}
</head> </head>
<body> <body>
{% cache 500 sidebar request.user.username %}
<nav class="navbar navbar-expand-md header"> <nav class="navbar navbar-expand-md header">
<div class="container-fluid"> <div class="container-fluid">
<a class="navbar-brand" href="{% url 'home' %}"> <a class="navbar-brand" href="{% url 'home' %}">
@@ -102,7 +104,7 @@
{% if user.is_authenticated %} {% if user.is_authenticated %}
<a href="{% url 'mis_productos' %}" class="nav-link btn btn-outline-secondary btn-sm">Panel Vendedor</a> <a href="{% url 'mis_productos' %}" class="nav-link btn btn-outline-secondary btn-sm">Panel Vendedor</a>
<span class="nav-text d-none d-md-inline text-white">{{ user.first_name|default:user.username }}</span> <a href="{% url 'portal_usuario' %}" class="nav-link btn btn-outline-light btn-sm">{{ user.first_name|default:user.username }}</a>
<a href="{% url 'logout' %}" class="nav-link btn btn-primary btn-sm">Cerrar Sesión</a> <a href="{% url 'logout' %}" class="nav-link btn btn-primary btn-sm">Cerrar Sesión</a>
{% else %} {% else %}
<a href="{% url 'login' %}" class="nav-link btn btn-primary btn-sm">Iniciar Sesión</a> <a href="{% url 'login' %}" class="nav-link btn btn-primary btn-sm">Iniciar Sesión</a>
@@ -112,6 +114,7 @@
</div> </div>
</div> </div>
</nav> </nav>
{% endcache %}
<div class="container-fluid"> <div class="container-fluid">
<!-- Mensajes --> <!-- Mensajes -->
@@ -131,6 +134,7 @@
<!-- Contenido--> <!-- Contenido-->
{% block content %}{% endblock %} {% block content %}{% endblock %}
{% cache 500 footer %}
<!-- Footer--> <!-- Footer-->
<div id="footer" class="row pt-2 pb-2 mt-5"> <div id="footer" class="row pt-2 pb-2 mt-5">
<div class="col-md-12 grid"> <div class="col-md-12 grid">
@@ -152,9 +156,9 @@
<p class="text-center">Enlace 16</p> <p class="text-center">Enlace 16</p>
</div> </div>
</div> </div>
{% endcache %}
</div> </div>
{% cache 500 scripts %}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js" integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js" integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI" crossorigin="anonymous"></script>
<script> <script>
@@ -243,5 +247,6 @@
} }
}); });
</script> </script>
{% endcache %}
</body> </body>
</html> </html>
+38 -4
View File
@@ -49,6 +49,30 @@
</div> </div>
{% if cart_items %} {% if cart_items %}
<div class="card mb-4">
<div class="card-body">
<h5 class="card-title mb-3">1) Selecciona la dirección de envío</h5>
{% if addresses %}
<div class="mb-3">
<label for="shipping-address" class="form-label">Dirección</label>
<select id="shipping-address" class="form-select" required>
<option value="">Selecciona una dirección...</option>
{% for address in addresses %}
<option value="{{ address.id }}" {% if address.is_default %}selected{% endif %}>
{{ address.full_name }} - {{ address.address_line_1 }}, {{ address.postal_code }} {{ address.city }}
</option>
{% endfor %}
</select>
</div>
{% else %}
<div class="alert alert-warning mb-0 d-flex justify-content-between align-items-center flex-wrap gap-2">
<span>No tienes direcciones de envío creadas.</span>
<a href="{% url 'crear_direccion' %}" class="btn btn-primary btn-sm">Crear dirección</a>
</div>
{% endif %}
</div>
</div>
<div class="table-responsive mb-4"> <div class="table-responsive mb-4">
<table class="table table-striped align-middle"> <table class="table table-striped align-middle">
<thead> <thead>
@@ -87,20 +111,22 @@
</div> </div>
<div class="payment-section"> <div class="payment-section">
<h3>Selecciona tu método de pago</h3> <h3>2) Selecciona tu método de pago</h3>
<div class="payment-methods"> <div class="payment-methods">
<button <button
id="checkout-button" id="checkout-button"
class="btn btn-primary payment-btn" class="btn btn-primary payment-btn"
data-config-url="/tienda/config/" data-config-url="/tienda/config/"
data-session-url="/tienda/create-checkout-session/"> data-session-url="/tienda/create-checkout-session/"
{% if not addresses %}disabled{% endif %}>
💳 Pagar con Stripe 💳 Pagar con Stripe
</button> </button>
<button <button
id="paypal-button" id="paypal-button"
class="btn btn-warning payment-btn" class="btn btn-warning payment-btn"
data-payment-url="{% url 'create_paypal_payment' %}"> data-payment-url="{% url 'create_paypal_payment' %}"
{% if not addresses %}disabled{% endif %}>
🅿️ Pagar con PayPal 🅿️ Pagar con PayPal
</button> </button>
</div> </div>
@@ -116,6 +142,13 @@
document.getElementById('paypal-button').addEventListener('click', async function(e) { document.getElementById('paypal-button').addEventListener('click', async function(e) {
e.preventDefault(); e.preventDefault();
const shippingAddressSelect = document.getElementById('shipping-address');
const selectedShippingAddress = shippingAddressSelect ? shippingAddressSelect.value : '';
if (!selectedShippingAddress) {
alert('Selecciona una dirección de envío para continuar.');
return;
}
const button = this; const button = this;
const originalText = button.innerHTML; const originalText = button.innerHTML;
button.disabled = true; button.disabled = true;
@@ -138,7 +171,8 @@
headers: { headers: {
'X-CSRFToken': csrfToken || '', 'X-CSRFToken': csrfToken || '',
'Content-Type': 'application/json', 'Content-Type': 'application/json',
} },
body: JSON.stringify({ shipping_address_id: selectedShippingAddress })
}); });
console.log('Response status:', response.status); console.log('Response status:', response.status);
+74 -4
View File
@@ -35,17 +35,27 @@
</div> </div>
<div class="row"> <div class="row">
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
<label for="city" class="form-label">Ciudad *</label> <label for="city" class="form-label">Ciudad/Pueblo (Almería) *</label>
<input type="text" class="form-control" id="city" name="city" value="{{ direccion.city|default:'' }}" required> <input type="text" class="form-control" id="city" name="city" value="{{ direccion.city|default:'' }}" list="almeria-towns" autocomplete="off" required>
<datalist id="almeria-towns">
{% for town in almeria_municipalities %}
<option value="{{ town }}"></option>
{% endfor %}
</datalist>
<div class="form-text">Selecciona o escribe un municipio de la provincia de Almería.</div>
<div class="invalid-feedback" id="city-validation-message">
El pueblo/ciudad debe pertenecer a la provincia de Almería.
</div>
</div> </div>
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
<label for="postal_code" class="form-label">Código Postal *</label> <label for="postal_code" class="form-label">Código Postal *</label>
<input type="text" class="form-control" id="postal_code" name="postal_code" value="{{ direccion.postal_code|default:'' }}" required> <input type="text" class="form-control" id="postal_code" name="postal_code" value="{{ direccion.postal_code|default:'' }}" pattern="04[0-9]{3}" maxlength="5" placeholder="04XXX" required>
<div class="form-text">Solo aceptamos códigos postales de Almería (04xxx).</div>
</div> </div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="country" class="form-label">País *</label> <label for="country" class="form-label">País *</label>
<input type="text" class="form-control" id="country" name="country" value="{{ direccion.country|default:'España' }}" required> <input type="text" class="form-control" id="country" name="country" value="España" readonly>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="phone" class="form-label">Teléfono *</label> <label for="phone" class="form-label">Teléfono *</label>
@@ -67,4 +77,64 @@
</div> </div>
</div> </div>
<script>
(function () {
const cityInput = document.getElementById('city');
const cityValidationMessage = document.getElementById('city-validation-message');
const form = cityInput ? cityInput.form : null;
if (!cityInput || !form) {
return;
}
const almeriaTowns = new Set([
{% for town in almeria_municipalities %}
"{{ town|escapejs }}",
{% endfor %}
].map(normalizeTown));
function normalizeTown(value) {
return (value || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-zA-Z0-9\s-]/g, '')
.replace(/-/g, ' ')
.toLowerCase()
.trim()
.replace(/\s+/g, ' ')
.replace(/^(la|los)\s+/, '');
}
function validateTown() {
const normalized = normalizeTown(cityInput.value);
if (!normalized) {
cityInput.setCustomValidity('');
cityInput.classList.remove('is-invalid');
return;
}
const isValid = almeriaTowns.has(normalized);
if (isValid) {
cityInput.setCustomValidity('');
cityInput.classList.remove('is-invalid');
} else {
cityInput.setCustomValidity('El pueblo/ciudad debe pertenecer a la provincia de Almería.');
cityInput.classList.add('is-invalid');
}
cityValidationMessage.textContent = cityInput.validationMessage || 'El pueblo/ciudad debe pertenecer a la provincia de Almería.';
}
cityInput.addEventListener('input', validateTown);
cityInput.addEventListener('blur', validateTown);
form.addEventListener('submit', function () {
validateTown();
});
validateTown();
})();
</script>
{% endblock %} {% endblock %}
+63
View File
@@ -0,0 +1,63 @@
{% extends "tienda/base.html" %}
{% load static %}
{% block content %}
<div class="row mt-4">
<div class="col-12">
<h2>Mis Compras</h2>
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'portal_usuario' %}">Portal de Usuario</a></li>
<li class="breadcrumb-item active">Compras</li>
</ol>
</nav>
</div>
</div>
<div class="row mt-3">
<div class="col-12">
<div class="btn-group" role="group">
<a href="{% url 'portal_usuario' %}" class="btn btn-outline-primary">Inicio</a>
<a href="{% url 'mis_compras' %}" class="btn btn-primary">Compras</a>
<a href="{% url 'mis_recibos' %}" class="btn btn-outline-primary">Recibos</a>
<a href="{% url 'mensajes_comprador' %}" class="btn btn-outline-primary">Mensajes</a>
</div>
</div>
</div>
<div class="row mt-4">
<div class="col-12">
<p class="text-muted">Total de compras: <strong>{{ total_orders }}</strong></p>
{% if orders %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Pedido #</th>
<th>Fecha</th>
<th>Total</th>
<th>Estado</th>
<th>Método</th>
</tr>
</thead>
<tbody>
{% for order in orders %}
<tr>
<td>{{ order.id }}</td>
<td>{{ order.created_at|date:"d/m/Y H:i" }}</td>
<td>{{ order.total }}€</td>
<td><span class="badge bg-success">{{ order.get_status_display }}</span></td>
<td>{{ order.get_payment_method_display }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="alert alert-info mb-0">
Aún no has realizado compras.
</div>
{% endif %}
</div>
</div>
{% endblock %}
+63
View File
@@ -0,0 +1,63 @@
{% extends "tienda/base.html" %}
{% load static %}
{% block content %}
<div class="row mt-4">
<div class="col-12">
<h2>Mis Recibos</h2>
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'portal_usuario' %}">Portal de Usuario</a></li>
<li class="breadcrumb-item active">Recibos</li>
</ol>
</nav>
</div>
</div>
<div class="row mt-3">
<div class="col-12">
<div class="btn-group" role="group">
<a href="{% url 'portal_usuario' %}" class="btn btn-outline-primary">Inicio</a>
<a href="{% url 'mis_compras' %}" class="btn btn-outline-primary">Compras</a>
<a href="{% url 'mis_recibos' %}" class="btn btn-primary">Recibos</a>
<a href="{% url 'mensajes_comprador' %}" class="btn btn-outline-primary">Mensajes</a>
</div>
</div>
</div>
<div class="row mt-4">
<div class="col-12">
<p class="text-muted">Total de recibos: <strong>{{ total_receipts }}</strong></p>
{% if receipts %}
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Recibo #</th>
<th>Fecha</th>
<th>Total</th>
<th>Método</th>
<th>Referencia</th>
</tr>
</thead>
<tbody>
{% for receipt in receipts %}
<tr>
<td>{{ receipt.id }}</td>
<td>{{ receipt.created_at|date:"d/m/Y H:i" }}</td>
<td>{{ receipt.total }}€</td>
<td>{{ receipt.get_payment_method_display }}</td>
<td>{{ receipt.payment_reference|default:"-" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="alert alert-info mb-0">
No tienes recibos disponibles todavía.
</div>
{% endif %}
</div>
</div>
{% endblock %}
@@ -43,6 +43,22 @@
<li><strong>Precio total:</strong> {{ item.total_price }}€</li> <li><strong>Precio total:</strong> {{ item.total_price }}€</li>
<li><strong>Fecha:</strong> {{ item.created_at|date:"d/m/Y H:i" }}</li> <li><strong>Fecha:</strong> {{ item.created_at|date:"d/m/Y H:i" }}</li>
</ul> </ul>
<h6 class="mt-3">Dirección de envío</h6>
{% if item.order.shipping_address %}
<ul class="list-unstyled">
<li><strong>Destinatario:</strong> {{ item.order.shipping_address.full_name }}</li>
<li><strong>Dirección:</strong> {{ item.order.shipping_address.address_line_1 }}</li>
{% if item.order.shipping_address.address_line_2 %}
<li><strong>Detalle:</strong> {{ item.order.shipping_address.address_line_2 }}</li>
{% endif %}
<li><strong>Ciudad:</strong> {{ item.order.shipping_address.city }}</li>
<li><strong>Código Postal:</strong> {{ item.order.shipping_address.postal_code }}</li>
<li><strong>País:</strong> {{ item.order.shipping_address.country }}</li>
<li><strong>Teléfono:</strong> {{ item.order.shipping_address.phone }}</li>
</ul>
{% else %}
<p class="text-muted mb-0">Dirección no disponible.</p>
{% endif %}
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<h6>Cambiar Estado</h6> <h6>Cambiar Estado</h6>
@@ -14,6 +14,8 @@
<div class="col-12"> <div class="col-12">
<div class="btn-group" role="group"> <div class="btn-group" role="group">
<a href="{% url 'portal_usuario' %}" class="btn btn-primary">Inicio</a> <a href="{% url 'portal_usuario' %}" class="btn btn-primary">Inicio</a>
<a href="{% url 'mis_compras' %}" class="btn btn-outline-primary">Compras</a>
<a href="{% url 'mis_recibos' %}" class="btn btn-outline-primary">Recibos</a>
<a href="{% url 'editar_perfil' %}" class="btn btn-outline-primary">Mi Perfil</a> <a href="{% url 'editar_perfil' %}" class="btn btn-outline-primary">Mi Perfil</a>
<a href="{% url 'direcciones_usuario' %}" class="btn btn-outline-primary">Direcciones</a> <a href="{% url 'direcciones_usuario' %}" class="btn btn-outline-primary">Direcciones</a>
<a href="{% url 'mensajes_comprador' %}" class="btn btn-outline-primary">Mensajes</a> <a href="{% url 'mensajes_comprador' %}" class="btn btn-outline-primary">Mensajes</a>
@@ -29,6 +31,7 @@
<h5 class="card-title">📦 Mis Pedidos</h5> <h5 class="card-title">📦 Mis Pedidos</h5>
<p class="display-4">{{ total_orders }}</p> <p class="display-4">{{ total_orders }}</p>
<p class="text-muted">pedidos realizados</p> <p class="text-muted">pedidos realizados</p>
<a href="{% url 'mis_compras' %}" class="btn btn-sm btn-primary">Ver compras</a>
</div> </div>
</div> </div>
</div> </div>
@@ -54,6 +57,18 @@
</div> </div>
</div> </div>
<div class="row mt-2">
<div class="col-md-4 mb-3">
<div class="card">
<div class="card-body text-center">
<h5 class="card-title">🧾 Recibos</h5>
<p class="text-muted">consulta tus recibos de pago</p>
<a href="{% url 'mis_recibos' %}" class="btn btn-sm btn-primary">Ver recibos</a>
</div>
</div>
</div>
</div>
<!-- Pedidos recientes --> <!-- Pedidos recientes -->
<div class="row mt-4"> <div class="row mt-4">
<div class="col-12"> <div class="col-12">
+3
View File
@@ -35,6 +35,8 @@ urlpatterns = [
path("paypal/execute/", views.paypal_execute, name="paypal_execute"), path("paypal/execute/", views.paypal_execute, name="paypal_execute"),
# Portal de usuario # Portal de usuario
path("usuario/", views.portal_usuario, name="portal_usuario"), path("usuario/", views.portal_usuario, name="portal_usuario"),
path("usuario/compras/", views.mis_compras, name="mis_compras"),
path("usuario/recibos/", views.mis_recibos, name="mis_recibos"),
path("usuario/perfil/", views.editar_perfil, name="editar_perfil"), path("usuario/perfil/", views.editar_perfil, name="editar_perfil"),
path("usuario/perfil/cambiar-contrasena/", views.cambiar_contrasena, name="cambiar_contrasena"), path("usuario/perfil/cambiar-contrasena/", views.cambiar_contrasena, name="cambiar_contrasena"),
path("usuario/direcciones/", views.direcciones_usuario, name="direcciones_usuario"), path("usuario/direcciones/", views.direcciones_usuario, name="direcciones_usuario"),
@@ -42,4 +44,5 @@ urlpatterns = [
path("usuario/direcciones/<int:id>/editar/", views.editar_direccion, name="editar_direccion"), path("usuario/direcciones/<int:id>/editar/", views.editar_direccion, name="editar_direccion"),
path("usuario/direcciones/<int:id>/eliminar/", views.eliminar_direccion, name="eliminar_direccion"), path("usuario/direcciones/<int:id>/eliminar/", views.eliminar_direccion, name="eliminar_direccion"),
path("usuario/mensajes/", views.mensajes_comprador, name="mensajes_comprador"), path("usuario/mensajes/", views.mensajes_comprador, name="mensajes_comprador"),
path("verify/<str:code>", views.verify, name="verify")
] ]
+46
View File
@@ -0,0 +1,46 @@
from django.core.mail import send_mail
import logging
from django.conf import settings
logger = logging.getLogger("email.system")
#
#def send_email(dest: str, title: str, body: str):
# context = ssl.create_default_context()
# try:
# with smtplib.SMTP(settings.SMTP_ENDPOINT, settings.SMTP_PORT) as server:
#
#
# server.ehlo()
# server.starttls(context=context)
# server.ehlo()
# server.login(settings.SMTP_USERNAME, settings.SMTP_PASSWORD)
#
# message = """\
#Subject: {}
#{}
# """.format(title, body)
# server.sendmail(settings.SMTP_EMAIL, dest, message)
# logger.info("EMAIL_SENT to=%s subject=%s", dest, title)
#
# except Exception as e:
# logger.exception("EMAIL_SEND_FAILED to=%s subject=%s error=%s", dest, title, str(e))
# return (False, e)
#
# return (True,)
def send_email(dest: str, title: str, body: str):
try:
send_mail(
subject=title,
message=body,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[dest],
fail_silently=False,
)
logger.info("EMAIL_SENT to=%s subject=%s", dest, title)
return (True,)
except Exception as e:
logger.exception("EMAIL_SEND_FAILED to=%s subject=%s error=%s", dest, title, str(e))
return (False, e)
+40
View File
@@ -1,2 +1,42 @@
PAGE_SIZE = 20 PAGE_SIZE = 20
VAT_RATE = 0.21 # IVA 21% VAT_RATE = 0.21 # IVA 21%
# Restricciones de envío
SHIPPING_COUNTRY = "España"
ALMERIA_POSTAL_CODE_PREFIX = "04"
ALMERIA_MUNICIPALITIES_DISPLAY = (
"Abla", "Abrucena", "Adra", "Albánchez", "Alboloduy", "Albox", "Alcolea", "Alcóntar",
"Alcudia de Monteagud", "Alhabia", "Alhama de Almería", "Alicún", "Almería", "Almócita",
"Alsodux", "Antas", "Arboleas", "Armuña de Almanzora", "Bacares", "Bayárcal", "Bayarque",
"Bédar", "Beires", "Benahadux", "Benitagla", "Benizalón", "Bentarique", "Berja", "Canjáyar",
"Cantoria", "Carboneras", "Castro de Filabres", "Chercos", "Chirivel", "Cóbdar",
"Cuevas del Almanzora", "Dalías", "Enix", "Félix", "Fines", "Fiñana", "Fondón", "Gádor",
"Gallardos", "Los Gallardos", "Garrucha", "Gérgal", "Huécija", "Huércal de Almería",
"Huércal-Overa", "Íllar", "Instinción", "Laroya", "Laujar de Andarax", "Líjar", "Lubrín",
"Lucainena de las Torres", "Lúcar", "Macael", "María", "Mojácar", "Mojonera", "La Mojonera",
"Nacimiento", "Níjar", "Ohanes", "Olula de Castro", "Olula del Río", "Oria", "Padules",
"Partaloa", "Paterna del Río", "Pechina", "Pulpí", "Purchena", "Rágol", "Rioja",
"Roquetas de Mar", "Santa Cruz de Marchena", "Santa Fe de Mondújar", "Senés", "Serón", "Sierro",
"Somontín", "Sorbas", "Suflí", "Tabernas", "Taberno", "Tahal", "Terque", "Tíjola", "Turre",
"Turrillas", "Uleila del Campo", "Urrácal", "Velefique", "Vélez-Blanco", "Vélez-Rubio", "Vera",
"Viator", "Vícar", "Zurgena"
)
verify_message = """
¡Buenas {name}!
Muchas gracias por registrarte en Comercialmeria, para verificar que el correo que ha empleado es el suyo, por favor, haga click en el siguiente enlace.
Si por alguna razón no es su correo, eliminelo inmediatamente y no le de click al enlace.
{protocol}://{domain}/tienda/verify/{code}
Este email ha sido automatizado
"""
login_message = """
¡Buenas {name}!
Le enviamos este correo para indicarle que se acaba de iniciar sesión en un nuevo dispositivo.
En caso de que no sea usted, para proteger compras indebidas, ¡cambie la contraseña inmediatamente!
"""
+310 -35
View File
@@ -1,11 +1,21 @@
from django.shortcuts import render, redirect, get_object_or_404 from django.shortcuts import render, redirect, get_object_or_404
from django.http import HttpRequest, JsonResponse from django.http import HttpRequest, HttpResponse, JsonResponse
from django.contrib.auth import authenticate, login as auth_login, logout as auth_logout from django.contrib.auth import authenticate, login as auth_login, logout as auth_logout
from django.contrib.auth.models import User
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.contrib import messages from django.contrib import messages
from .models import Product, Category, Cart, CartItem, Image, Order, OrderItem, OrderMessage, ShippingAddress from .models import User, Product, Category, Cart, CartItem, Image, Order, OrderItem, OrderMessage, ShippingAddress, VerificationCode
from .vars import PAGE_SIZE from .utilities import send_email
from . import tasks
from .vars import (
PAGE_SIZE,
VAT_RATE,
SHIPPING_COUNTRY,
ALMERIA_POSTAL_CODE_PREFIX,
ALMERIA_MUNICIPALITIES_DISPLAY,
verify_message,
login_message
)
from django.conf import settings from django.conf import settings
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from django.urls import reverse from django.urls import reverse
@@ -13,8 +23,78 @@ from decimal import Decimal, ROUND_HALF_UP
import stripe import stripe
from django.db import models, transaction from django.db import models, transaction
from django.core.cache import cache from django.core.cache import cache
import re
import unicodedata
import json
import random, string
import logging
# Create your views here. # Create your views here.
logger = logging.getLogger("tienda")
audit_logger = logging.getLogger("tienda.audit")
def _normalize_location_text(value: str) -> str:
normalized = unicodedata.normalize("NFD", (value or ""))
without_accents = "".join(char for char in normalized if unicodedata.category(char) != "Mn")
without_symbols = re.sub(r"[^a-zA-Z0-9\s-]", "", without_accents)
collapsed = " ".join(without_symbols.replace("-", " ").lower().split())
return collapsed
ALMERIA_MUNICIPALITIES = {
_normalize_location_text(municipality)
for municipality in ALMERIA_MUNICIPALITIES_DISPLAY
}
ALMERIA_MUNICIPALITIES.update(
{
municipality.removeprefix("la ")
for municipality in ALMERIA_MUNICIPALITIES
if municipality.startswith("la ")
}
)
ALMERIA_MUNICIPALITIES.update(
{
municipality.removeprefix("los ")
for municipality in ALMERIA_MUNICIPALITIES
if municipality.startswith("los ")
}
)
def _is_almeria_postal_code(postal_code: str) -> bool:
"""Valida que el código postal pertenezca a la provincia de Almería (04xxx)."""
normalized = (postal_code or "").strip()
return len(normalized) == 5 and normalized.isdigit() and normalized.startswith(ALMERIA_POSTAL_CODE_PREFIX)
def _is_almeria_city(city: str) -> bool:
"""Valida que el municipio/pueblo pertenezca a la provincia de Almería."""
return _normalize_location_text(city) in ALMERIA_MUNICIPALITIES
def _address_form_context(direccion=None):
return {
"direccion": direccion,
"almeria_municipalities": ALMERIA_MUNICIPALITIES_DISPLAY,
}
def _get_client_ip(request: HttpRequest) -> str:
forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
if forwarded_for:
return forwarded_for.split(",")[0].strip()
return request.META.get("REMOTE_ADDR", "")
def get_price_with_vat_decimal(price) -> Decimal:
"""Retorna un precio con IVA aplicado y redondeado a 2 decimales."""
return (Decimal(str(price)) * (Decimal("1") + Decimal(str(VAT_RATE)))).quantize(
Decimal("0.01"),
rounding=ROUND_HALF_UP,
)
def home(request: HttpRequest): def home(request: HttpRequest):
"""Página de inicio del sitio""" """Página de inicio del sitio"""
categorias = Category.objects.all() categorias = Category.objects.all()
@@ -50,32 +130,57 @@ def login(request: HttpRequest):
email = request.POST.get("email") email = request.POST.get("email")
password = request.POST.get("password") password = request.POST.get("password")
remember = request.POST.get("remember") remember = request.POST.get("remember")
client_ip = _get_client_ip(request)
# Buscar usuario por email # Buscar usuario por email
try: try:
user_obj = User.objects.get(email=email) user_obj = User.objects.get(email=email)
username = user_obj.username username = user_obj.username
except User.DoesNotExist: except User.DoesNotExist:
audit_logger.warning(
"LOGIN_FAILED email=%s reason=user_not_found ip=%s",
email,
client_ip,
)
messages.error(request, "Correo electrónico o contraseña incorrectos.") messages.error(request, "Correo electrónico o contraseña incorrectos.")
return render(request, "tienda/login.html") return render(request, "tienda/login.html")
# Autenticar usuario # Autenticar usuario
user = authenticate(request, username=username, password=password) user = authenticate(request, username=username, password=password)
user = User.objects.get(username=user.username)
if user.registration_status == "CR":
audit_logger.info(
"LOGIN_FAILED email=%s reason=not_verified", email
)
messages.error(request, "No se puede iniciar sesión porque no has verificado tu cuenta, comprueba tu email. Si eliminaste el email pero querias verificarte, contacta con el soporte tecnico")
return render(request, "tienda/login.html")
if user is not None: if user is not None:
auth_login(request, user) auth_login(request, user)
# Configurar duración de sesión # Configurar duración de sesión
if not remember: if not remember:
# Si no marca "Recordarme", la sesión expira al cerrar el navegador
request.session.set_expiry(0) request.session.set_expiry(0)
else: else:
# Si marca "Recordarme", la sesión dura 2 semanas
request.session.set_expiry(1209600) # 14 días en segundos request.session.set_expiry(1209600) # 14 días en segundos
audit_logger.info(
"LOGIN_SUCCESS user_id=%s email=%s ip=%s remember=%s",
user.id,
user.email,
client_ip,
bool(remember),
)
tasks.enviar_correo_bienvenida.delay(user.email, "{} {}".format(user.first_name, user.last_name))
# result = send_email(user.email, "Inicio de sesión correcto", login_message.format(name = "{} {}".format(user.first_name, user.last_name)))
messages.success(request, f"¡Bienvenido {user.first_name or user.username}!") messages.success(request, f"¡Bienvenido {user.first_name or user.username}!")
return redirect("index") return redirect("index")
else: else:
audit_logger.warning(
"LOGIN_FAILED email=%s reason=invalid_credentials ip=%s",
email,
client_ip,
)
messages.error(request, "Correo electrónico o contraseña incorrectos.") messages.error(request, "Correo electrónico o contraseña incorrectos.")
return render(request, "tienda/login.html") return render(request, "tienda/login.html")
@@ -83,22 +188,28 @@ def login(request: HttpRequest):
def register(request: HttpRequest): def register(request: HttpRequest):
if request.user.is_authenticated:
return redirect("index")
if request.method == "POST": if request.method == "POST":
name = request.POST.get("name") name = request.POST.get("name")
email = request.POST.get("email") email = request.POST.get("email")
password = request.POST.get("password") password = request.POST.get("password")
password_confirm = request.POST.get("password_confirm") password_confirm = request.POST.get("password_confirm")
client_ip = _get_client_ip(request)
# Validaciones # Validaciones
if password != password_confirm: if password != password_confirm:
audit_logger.warning("REGISTER_FAILED email=%s reason=password_mismatch ip=%s", email, client_ip)
messages.error(request, "Las contraseñas no coinciden.") messages.error(request, "Las contraseñas no coinciden.")
return render(request, "tienda/register.html") return render(request, "tienda/register.html")
if len(password) < 8: if len(password) < 8:
audit_logger.warning("REGISTER_FAILED email=%s reason=password_too_short ip=%s", email, client_ip)
messages.error(request, "La contraseña debe tener al menos 8 caracteres.") messages.error(request, "La contraseña debe tener al menos 8 caracteres.")
return render(request, "tienda/register.html") return render(request, "tienda/register.html")
if User.objects.filter(email=email).exists(): if User.objects.filter(email=email).exists():
audit_logger.warning("REGISTER_FAILED email=%s reason=email_exists ip=%s", email, client_ip)
messages.error(request, "Ya existe un usuario con este correo electrónico.") messages.error(request, "Ya existe un usuario con este correo electrónico.")
return render(request, "tienda/register.html") return render(request, "tienda/register.html")
@@ -120,18 +231,36 @@ def register(request: HttpRequest):
first_name=name first_name=name
) )
# Iniciar sesión automáticamente audit_logger.info(
auth_login(request, user) "REGISTER_SUCCESS user_id=%s username=%s email=%s ip=%s",
request.session.set_expiry(1209600) # 14 días user.id,
user.username,
user.email,
client_ip,
)
messages.success(request, f"¡Cuenta creada exitosamente! Bienvenido {name}.") ver_code = ''.join(random.choices(string.digits, k=12))
codigo = VerificationCode.objects.create(
user = user,
code = ver_code,
code_mode = VerificationCode.VerificationModes.VERIFY_ACCOUNT
)
message = verify_message.format(name = name, protocol = settings.PROTOCOL, domain = settings.DOMAIN, code = ver_code)
email_result = send_email(email, "Verificación de cuenta", message)
messages.success(request, f"¡Cuenta creada exitosamente! Por favor, verifica tu correo entrando al Link enviado.")
return redirect("index") return redirect("index")
return render(request, "tienda/register.html") return render(request, "tienda/register.html")
def logout(request: HttpRequest): def logout(request: HttpRequest):
user_id = request.user.id if request.user.is_authenticated else None
email = request.user.email if request.user.is_authenticated else None
client_ip = _get_client_ip(request)
auth_logout(request) auth_logout(request)
audit_logger.info("LOGOUT user_id=%s email=%s ip=%s", user_id, email, client_ip)
messages.success(request, "Has cerrado sesión exitosamente.") messages.success(request, "Has cerrado sesión exitosamente.")
return redirect("index") return redirect("index")
@@ -178,25 +307,61 @@ def get_or_create_cart(request):
return cart return cart
def create_order_from_cart(request, payment_method, payment_reference=""): def _get_selected_shipping_address(request: HttpRequest):
"""Obtiene la dirección seleccionada desde JSON o form-data y valida pertenencia al usuario."""
shipping_address_id = request.POST.get("shipping_address_id")
if not shipping_address_id:
try:
payload = json.loads(request.body.decode("utf-8") or "{}")
shipping_address_id = payload.get("shipping_address_id")
except (json.JSONDecodeError, UnicodeDecodeError):
shipping_address_id = None
if not shipping_address_id:
return None
try:
shipping_address_id = int(shipping_address_id)
except (TypeError, ValueError):
return None
return ShippingAddress.objects.filter(id=shipping_address_id, user=request.user).first()
def create_order_from_cart(request, payment_method, payment_reference="", shipping_address=None):
"""Crea un pedido a partir del carrito actual y lo asigna a vendedores.""" """Crea un pedido a partir del carrito actual y lo asigna a vendedores."""
cart = get_or_create_cart(request) cart = get_or_create_cart(request)
cart_items = cart.items.select_related("product", "product__creator") cart_items = list(cart.items.select_related("product", "product__creator"))
if not cart_items.exists(): if not cart_items:
return None return None
order_total = Decimal("0.00")
items_with_totals = []
for item in cart_items:
product = item.product
unit_price_with_vat = get_price_with_vat_decimal(product.price)
line_total_with_vat = (unit_price_with_vat * item.quantity).quantize(
Decimal("0.01"),
rounding=ROUND_HALF_UP,
)
order_total += line_total_with_vat
items_with_totals.append((item, unit_price_with_vat, line_total_with_vat))
with transaction.atomic(): with transaction.atomic():
order = Order.objects.create( order = Order.objects.create(
buyer=request.user if request.user.is_authenticated else None, buyer=request.user if request.user.is_authenticated else None,
shipping_address=shipping_address,
session_key=None if request.user.is_authenticated else request.session.session_key, session_key=None if request.user.is_authenticated else request.session.session_key,
total=cart.get_total(), total=float(order_total),
status=Order.STATUS_PAID, status=Order.STATUS_PAID,
payment_method=payment_method, payment_method=payment_method,
payment_reference=payment_reference or "", payment_reference=payment_reference or "",
) )
for item in cart_items: for item, unit_price_with_vat, line_total_with_vat in items_with_totals:
product = item.product product = item.product
OrderItem.objects.create( OrderItem.objects.create(
order=order, order=order,
@@ -204,8 +369,8 @@ def create_order_from_cart(request, payment_method, payment_reference=""):
product_name=product.name, product_name=product.name,
seller=product.creator, seller=product.creator,
quantity=item.quantity, quantity=item.quantity,
unit_price=product.price, unit_price=float(unit_price_with_vat),
total_price=product.price * item.quantity, total_price=float(line_total_with_vat),
) )
cart.items.all().delete() cart.items.all().delete()
@@ -322,7 +487,7 @@ def mis_productos(request: HttpRequest):
def pedidos_vendedor(request: HttpRequest): def pedidos_vendedor(request: HttpRequest):
"""Muestra los pedidos asignados al vendedor autenticado""" """Muestra los pedidos asignados al vendedor autenticado"""
pedidos = OrderItem.objects.filter(seller=request.user).select_related( pedidos = OrderItem.objects.filter(seller=request.user).select_related(
'order', 'product', 'order__buyer' 'order', 'product', 'order__buyer', 'order__shipping_address'
).prefetch_related('messages__sender').order_by('-created_at') ).prefetch_related('messages__sender').order_by('-created_at')
return render(request, "tienda/pedidos_vendedor.html", { return render(request, "tienda/pedidos_vendedor.html", {
@@ -540,9 +705,11 @@ def borrar_producto(request: HttpRequest, id: int):
def checkout(request: HttpRequest): def checkout(request: HttpRequest):
cart = get_or_create_cart(request) cart = get_or_create_cart(request)
cart_items = cart.items.select_related("product") cart_items = cart.items.select_related("product")
addresses = ShippingAddress.objects.filter(user=request.user)
return render(request, "tienda/checkout.html", { return render(request, "tienda/checkout.html", {
"cart": cart, "cart": cart,
"cart_items": cart_items "cart_items": cart_items,
"addresses": addresses,
}) })
@csrf_exempt @csrf_exempt
@@ -561,6 +728,10 @@ def create_checkout_session(request: HttpRequest):
return JsonResponse({"error": "Método no permitido"}, status=405) return JsonResponse({"error": "Método no permitido"}, status=405)
try: try:
shipping_address = _get_selected_shipping_address(request)
if shipping_address is None:
return JsonResponse({"error": "Debes seleccionar una dirección de envío válida."}, status=400)
cart = get_or_create_cart(request) cart = get_or_create_cart(request)
cart_items = cart.items.select_related("product") cart_items = cart.items.select_related("product")
@@ -571,7 +742,8 @@ def create_checkout_session(request: HttpRequest):
line_items = [] line_items = []
for item in cart_items: for item in cart_items:
unit_amount = int((Decimal(str(item.product.price)) * 100).quantize(0, rounding=ROUND_HALF_UP)) unit_price_with_vat = get_price_with_vat_decimal(item.product.price)
unit_amount = int((unit_price_with_vat * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
if unit_amount <= 0: if unit_amount <= 0:
continue continue
line_items.append({ line_items.append({
@@ -601,18 +773,23 @@ def create_checkout_session(request: HttpRequest):
) )
request.session['stripe_session_id'] = session.id request.session['stripe_session_id'] = session.id
request.session['selected_shipping_address_id'] = shipping_address.id
return JsonResponse({"sessionId": session.id}) return JsonResponse({"sessionId": session.id})
except Exception as e: except Exception as e:
print(f"Stripe error: {str(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": f"Error al crear sesión de pago: {str(e)}"}, status=500)
def checkout_success(request: HttpRequest): def checkout_success(request: HttpRequest):
payment_reference = request.session.get('stripe_session_id', "") payment_reference = request.session.get('stripe_session_id', "")
create_order_from_cart(request, Order.PAYMENT_STRIPE, payment_reference) shipping_address_id = request.session.get('selected_shipping_address_id')
shipping_address = ShippingAddress.objects.filter(id=shipping_address_id, user=request.user).first()
create_order_from_cart(request, Order.PAYMENT_STRIPE, payment_reference, shipping_address)
if 'stripe_session_id' in request.session: if 'stripe_session_id' in request.session:
del request.session['stripe_session_id'] del request.session['stripe_session_id']
if 'selected_shipping_address_id' in request.session:
del request.session['selected_shipping_address_id']
messages.success(request, "Pago realizado correctamente. ¡Gracias por tu compra!") messages.success(request, "Pago realizado correctamente. ¡Gracias por tu compra!")
return render(request, "tienda/checkout_success.html", {}) return render(request, "tienda/checkout_success.html", {})
@@ -652,6 +829,10 @@ def create_paypal_payment(request: HttpRequest):
return JsonResponse({"error": "Método no permitido"}, status=405) return JsonResponse({"error": "Método no permitido"}, status=405)
try: try:
shipping_address = _get_selected_shipping_address(request)
if shipping_address is None:
return JsonResponse({"error": "Debes seleccionar una dirección de envío válida."}, status=400)
import paypalrestsdk import paypalrestsdk
cart = get_or_create_cart(request) cart = get_or_create_cart(request)
@@ -669,16 +850,24 @@ def create_paypal_payment(request: HttpRequest):
# Crear lista de items para PayPal # Crear lista de items para PayPal
payment_items = [] payment_items = []
payment_total = Decimal("0.00")
for item in cart_items: for item in cart_items:
unit_price_with_vat = get_price_with_vat_decimal(item.product.price)
line_total_with_vat = (unit_price_with_vat * item.quantity).quantize(
Decimal("0.01"),
rounding=ROUND_HALF_UP,
)
payment_total += line_total_with_vat
payment_items.append({ payment_items.append({
"name": item.product.name, "name": item.product.name,
"sku": f"product_{item.product.id}", "sku": f"product_{item.product.id}",
"price": str(round(float(item.product.price), 2)), "price": format(unit_price_with_vat, ".2f"),
"currency": "EUR", "currency": "EUR",
"quantity": item.quantity "quantity": item.quantity
}) })
total = str(round(float(cart.get_total()), 2)) total = format(payment_total, ".2f")
# Crear el pago # Crear el pago
payment = paypalrestsdk.Payment({ payment = paypalrestsdk.Payment({
@@ -713,6 +902,7 @@ def create_paypal_payment(request: HttpRequest):
if payment.create(): if payment.create():
# Guardar el payment ID en sesión # Guardar el payment ID en sesión
request.session['paypal_payment_id'] = payment.id request.session['paypal_payment_id'] = payment.id
request.session['selected_shipping_address_id'] = shipping_address.id
# Encontrar el link de aprobación # Encontrar el link de aprobación
for link in payment.links: for link in payment.links:
@@ -723,16 +913,15 @@ def create_paypal_payment(request: HttpRequest):
else: else:
# Loguear el error # Loguear el error
error_msg = str(payment.error) if hasattr(payment, 'error') else "Error desconocido" error_msg = str(payment.error) if hasattr(payment, 'error') else "Error desconocido"
print(f"PayPal Error: {error_msg}") logger.error("PAYPAL_CREATE_ERROR user_id=%s error=%s", request.user.id, error_msg)
return JsonResponse({"error": f"Error al crear el pago: {error_msg}"}, status=400) return JsonResponse({"error": f"Error al crear el pago: {error_msg}"}, status=400)
except ImportError: except ImportError:
logger.error("PAYPAL_SDK_NOT_INSTALLED")
return JsonResponse({"error": "SDK de PayPal no instalado"}, status=500) return JsonResponse({"error": "SDK de PayPal no instalado"}, status=500)
except Exception as e: except Exception as e:
error_msg = str(e) error_msg = str(e)
print(f"PayPal Exception: {error_msg}") logger.exception("PAYPAL_CREATE_EXCEPTION user_id=%s error=%s", request.user.id, error_msg)
import traceback
traceback.print_exc()
return JsonResponse({"error": f"Error: {error_msg}"}, status=500) return JsonResponse({"error": f"Error: {error_msg}"}, status=500)
@@ -766,11 +955,15 @@ def paypal_execute(request: HttpRequest):
# Ejecutar el pago # Ejecutar el pago
if payment.execute({"payer_id": payer_id}): if payment.execute({"payer_id": payer_id}):
# Pago exitoso - crear pedido y limpiar el carrito # Pago exitoso - crear pedido y limpiar el carrito
create_order_from_cart(request, Order.PAYMENT_PAYPAL, payment_id) shipping_address_id = request.session.get('selected_shipping_address_id')
shipping_address = ShippingAddress.objects.filter(id=shipping_address_id, user=request.user).first()
create_order_from_cart(request, Order.PAYMENT_PAYPAL, payment_id, shipping_address)
# Limpiar la sesión # Limpiar la sesión
if 'paypal_payment_id' in request.session: if 'paypal_payment_id' in request.session:
del request.session['paypal_payment_id'] del request.session['paypal_payment_id']
if 'selected_shipping_address_id' in request.session:
del request.session['selected_shipping_address_id']
messages.success(request, "¡Pago realizado correctamente con PayPal! Gracias por tu compra.") messages.success(request, "¡Pago realizado correctamente con PayPal! Gracias por tu compra.")
return render(request, "tienda/checkout_success.html", {}) return render(request, "tienda/checkout_success.html", {})
@@ -780,6 +973,7 @@ def paypal_execute(request: HttpRequest):
return redirect("checkout") return redirect("checkout")
except Exception as e: except Exception as e:
logger.exception("PAYPAL_EXECUTE_EXCEPTION user_id=%s error=%s", request.user.id, str(e))
messages.error(request, f"Error: {str(e)}") messages.error(request, f"Error: {str(e)}")
return redirect("checkout") return redirect("checkout")
def search_suggestions(request: HttpRequest): def search_suggestions(request: HttpRequest):
@@ -829,6 +1023,31 @@ def portal_usuario(request: HttpRequest):
}) })
@login_required
def mis_compras(request: HttpRequest):
"""Lista completa de compras del usuario autenticado"""
orders = Order.objects.filter(buyer=request.user).prefetch_related('items').order_by('-created_at')
return render(request, "tienda/mis_compras.html", {
"orders": orders,
"total_orders": orders.count(),
})
@login_required
def mis_recibos(request: HttpRequest):
"""Lista de recibos (pedidos pagados) del usuario autenticado"""
receipts = Order.objects.filter(
buyer=request.user,
status=Order.STATUS_PAID
).prefetch_related('items').order_by('-created_at')
return render(request, "tienda/mis_recibos.html", {
"receipts": receipts,
"total_receipts": receipts.count(),
})
@login_required @login_required
def editar_perfil(request: HttpRequest): def editar_perfil(request: HttpRequest):
"""Edita la información del perfil del usuario""" """Edita la información del perfil del usuario"""
@@ -908,14 +1127,22 @@ def crear_direccion(request: HttpRequest):
address_line_2 = request.POST.get("address_line_2", "").strip() address_line_2 = request.POST.get("address_line_2", "").strip()
city = request.POST.get("city", "").strip() city = request.POST.get("city", "").strip()
postal_code = request.POST.get("postal_code", "").strip() postal_code = request.POST.get("postal_code", "").strip()
country = request.POST.get("country", "España").strip() country = SHIPPING_COUNTRY
phone = request.POST.get("phone", "").strip() phone = request.POST.get("phone", "").strip()
is_default = request.POST.get("is_default") == "on" is_default = request.POST.get("is_default") == "on"
# Validaciones # Validaciones
if not all([full_name, address_line_1, city, postal_code, phone]): if not all([full_name, address_line_1, city, postal_code, phone]):
messages.error(request, "Por favor completa todos los campos obligatorios.") messages.error(request, "Por favor completa todos los campos obligatorios.")
return render(request, "tienda/editar_direccion.html") return render(request, "tienda/editar_direccion.html", _address_form_context(request.POST))
if not _is_almeria_city(city):
messages.error(request, "El pueblo/ciudad debe pertenecer a la provincia de Almería.")
return render(request, "tienda/editar_direccion.html", _address_form_context(request.POST))
if not _is_almeria_postal_code(postal_code):
messages.error(request, "Solo realizamos envíos en la provincia de Almería (código postal 04xxx).")
return render(request, "tienda/editar_direccion.html", _address_form_context(request.POST))
# Crear dirección # Crear dirección
ShippingAddress.objects.create( ShippingAddress.objects.create(
@@ -933,7 +1160,7 @@ def crear_direccion(request: HttpRequest):
messages.success(request, "Dirección creada correctamente.") messages.success(request, "Dirección creada correctamente.")
return redirect("direcciones_usuario") return redirect("direcciones_usuario")
return render(request, "tienda/editar_direccion.html", {"direccion": None}) return render(request, "tienda/editar_direccion.html", _address_form_context())
@login_required @login_required
@@ -947,7 +1174,7 @@ def editar_direccion(request: HttpRequest, id: int):
direccion.address_line_2 = request.POST.get("address_line_2", "").strip() direccion.address_line_2 = request.POST.get("address_line_2", "").strip()
direccion.city = request.POST.get("city", "").strip() direccion.city = request.POST.get("city", "").strip()
direccion.postal_code = request.POST.get("postal_code", "").strip() direccion.postal_code = request.POST.get("postal_code", "").strip()
direccion.country = request.POST.get("country", "España").strip() direccion.country = SHIPPING_COUNTRY
direccion.phone = request.POST.get("phone", "").strip() direccion.phone = request.POST.get("phone", "").strip()
direccion.is_default = request.POST.get("is_default") == "on" direccion.is_default = request.POST.get("is_default") == "on"
@@ -955,13 +1182,21 @@ def editar_direccion(request: HttpRequest, id: int):
if not all([direccion.full_name, direccion.address_line_1, direccion.city, if not all([direccion.full_name, direccion.address_line_1, direccion.city,
direccion.postal_code, direccion.phone]): direccion.postal_code, direccion.phone]):
messages.error(request, "Por favor completa todos los campos obligatorios.") messages.error(request, "Por favor completa todos los campos obligatorios.")
return render(request, "tienda/editar_direccion.html", {"direccion": direccion}) return render(request, "tienda/editar_direccion.html", _address_form_context(direccion))
if not _is_almeria_city(direccion.city):
messages.error(request, "El pueblo/ciudad debe pertenecer a la provincia de Almería.")
return render(request, "tienda/editar_direccion.html", _address_form_context(direccion))
if not _is_almeria_postal_code(direccion.postal_code):
messages.error(request, "Solo realizamos envíos en la provincia de Almería (código postal 04xxx).")
return render(request, "tienda/editar_direccion.html", _address_form_context(direccion))
direccion.save() direccion.save()
messages.success(request, "Dirección actualizada correctamente.") messages.success(request, "Dirección actualizada correctamente.")
return redirect("direcciones_usuario") return redirect("direcciones_usuario")
return render(request, "tienda/editar_direccion.html", {"direccion": direccion}) return render(request, "tienda/editar_direccion.html", _address_form_context(direccion))
@login_required @login_required
@@ -990,3 +1225,43 @@ def mensajes_comprador(request: HttpRequest):
return render(request, "tienda/mensajes_comprador.html", { return render(request, "tienda/mensajes_comprador.html", {
"order_items": order_items "order_items": order_items
}) })
def send_test_email(request: HttpRequest):
message = """
Correo de prueba, deberias recibir esto bien
y esto deberia tener un enter
"""
result = send_email("danilacasito8@gmail.com", "Correo de Prueba", message)
if result[0]:
return HttpResponse("Mira tu bandeja")
else:
return HttpResponse(result[1])
def verify(request: HttpRequest, code: str):
obj = None
try:
obj = VerificationCode.objects.get(code=code)
except VerificationCode.DoesNotExist:
return HttpResponse("<h1>Error</h1><p>No existe el codigo de verificación</p>")
if obj:
if obj.code_mode == VerificationCode.VerificationModes.VERIFY_ACCOUNT:
obj.user.registration_status = obj.user.RegisterStatus.ACTIVE
obj.user.save()
obj.delete()
return redirect("index")
else:
return HttpResponse("<h1>Error</h1><p>No existe el codigo de verificación</p>")
def reset_password(request: HttpRequest):
if request.user.is_authenticated:
return redirect("index")
return render(request, "tienda/reset_password", {})