Compare commits

..

21 Commits

Author SHA1 Message Date
elordenador 1d319d8efa Merge branch 'latest' of github.com:dsaub/proyecto-final into latest 2026-05-31 18:52:49 +02:00
elordenador 874f4e29db refactor: update static and media URL handling for S3 integration 2026-05-31 18:51:39 +02:00
elordenador 131fe8fecc refactor: streamline environment variable handling using django-environ 2026-05-31 18:33:35 +02:00
Daniel (elordenador) b154da09a5 Merge pull request #103 from dsaub/dependabot/pip/stripe-15.2.0
build(deps): bump stripe from 15.1.0 to 15.2.0
2026-05-29 10:29:53 +02:00
dependabot[bot] f47fd21deb build(deps): bump stripe from 15.1.0 to 15.2.0
Bumps [stripe](https://github.com/stripe/stripe-python) from 15.1.0 to 15.2.0.
- [Release notes](https://github.com/stripe/stripe-python/releases)
- [Changelog](https://github.com/stripe/stripe-python/blob/master/CHANGELOG.md)
- [Commits](https://github.com/stripe/stripe-python/compare/v15.1.0...v15.2.0)

---
updated-dependencies:
- dependency-name: stripe
  dependency-version: 15.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-28 10:35:33 +00:00
elordenador ac27137b77 refactor: change table cells to headers for improved semantic structure in email templates 2026-05-26 13:46:08 +02:00
elordenador 7c445d4b66 refactor: change table cells to headers for improved semantic structure in ban email template 2026-05-26 13:36:59 +02:00
elordenador e4f0611ac5 refactor: replace parseInt with Number.parseInt for consistency and clarity
fix: add hidden input to card data labels for improved accessibility
refactor: add scope attributes to table headers for better semantic structure
2026-05-26 13:31:07 +02:00
elordenador 33dee87cb2 send_hemail now returns tuples of same length 2026-05-26 13:21:44 +02:00
elordenador 3de6d37e03 refactor: clean up send_email function and remove outdated SMTP implementation 2026-05-26 13:21:03 +02:00
elordenador 5503bbe8f7 refactor: organize constants and improve template rendering in views 2026-05-26 13:19:06 +02:00
elordenador dd5ecec3f6 fix: improve accessibility by adding aria-labelledby attributes to card input labels 2026-05-26 13:17:47 +02:00
Daniel (elordenador) c778669a7a Merge pull request #102 from dsaub/fix/major-issues
fix: resolver 9 issues MAJOR de SonarQube
2026-05-26 13:15:33 +02:00
Chroot 09f6f800de fix: script module con top-level await para S7785 2026-05-26 11:14:03 +00:00
Chroot 1ac17109a3 fix: usar async IIFE en loadReviews para S7785 2026-05-26 11:11:43 +00:00
Chroot 325e55417b fix: resolver 9 issues MAJOR de SonarQube Cloud
- views.py: eliminar parámetros no usados cart_items y product_ids
- views.py: reemplazar f-strings sin placeholders por strings normales
- base.html: añadir <title>Comercialmeria</title>
- add_review.html: asociar label 'Puntuación' con rating-input via for
- producto.html: promesa loadReviews con .catch()
- gestionar_imagenes.html: mejorar alt text descriptivo
- unban.html: quitar atributos deprecados width/cellspacing
2026-05-26 11:10:04 +00:00
Daniel (elordenador) e363bfd6dd Merge pull request #101 from dsaub/fix/const-self-ref-bug
fix: corregir constantes auto-referenciadas que rompían la app
2026-05-26 13:04:41 +02:00
Chroot 90308d2383 fix: corregir constantes auto-referenciadas que rompen la app
El sed de reemplazo de strings también modificó las definiciones
de constantes, dejando p.ej. LOGIN_TEMPLATE = LOGIN_TEMPLATE
en vez de LOGIN_TEMPLATE = "tienda/login.html", causando
NameError al importar el módulo.
2026-05-26 11:03:53 +00:00
Daniel (elordenador) de4f36a25c Merge pull request #99 from dsaub/fix/sonar-critical-issues
fix: resolver 12 issues CRITICAL de SonarQube Cloud
2026-05-26 12:57:57 +02:00
Chroot 424ffcffaf fix: resolver 12 issues CRITICAL de SonarQube Cloud
- forms.py: cambiar import wildcard por imports explícitos (S2208)
- views.py: definir constantes para strings duplicados (S1192)
- views.py: refactorizar login, create_order_from_cart, editar_producto (S3776)
2026-05-26 10:53:18 +00:00
elordenador f0a638be2e fix: update Docker workflows to use specific action versions and improve test command security 2026-05-26 12:12:03 +02:00
20 changed files with 416 additions and 431 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
env: env:
DJANGO_SETTINGS_MODULE: proyecto.settings DJANGO_SETTINGS_MODULE: proyecto.settings
run: | run: |
uv run python manage.py test SECRET_KEY=testkeynotuseinproducto uv run python manage.py test
docker: docker:
runs-on: ubuntu-latest runs-on: ubuntu-latest
+8 -8
View File
@@ -13,13 +13,13 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout del código - name: Checkout del código
uses: actions/checkout@v6 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Configurar Python - name: Configurar Python
uses: actions/setup-python@v6 uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with: with:
python-version: '3.14' python-version: '3.14'
- name: Configurar uv - name: Configurar uv
uses: astral-sh/setup-uv@v6 uses: astral-sh/setup-uv@d0d8abe699bfb85fec6de9f7adb5ae17292296ff # v6
- name: Instalar dependencias - name: Instalar dependencias
run: | run: |
uv sync --no-dev --no-install-project uv sync --no-dev --no-install-project
@@ -27,7 +27,7 @@ jobs:
env: env:
DJANGO_SETTINGS_MODULE: proyecto.settings DJANGO_SETTINGS_MODULE: proyecto.settings
run: | run: |
uv run python manage.py test SECRET_KEY=donotusethisinproductionitisunsafe uv run python manage.py test
docker: docker:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: test needs: test
@@ -37,13 +37,13 @@ jobs:
steps: steps:
- name: Checkout del código - name: Checkout del código
uses: actions/checkout@v6 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Configurar Docker Buildx - name: Configurar Docker Buildx
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
- name: Login en GHCR - name: Login en GHCR
uses: docker/login-action@v4 uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -55,7 +55,7 @@ jobs:
echo "IMAGE_TAG=$TAG" >> $GITHUB_ENV echo "IMAGE_TAG=$TAG" >> $GITHUB_ENV
- name: Build y Push - name: Build y Push
uses: docker/build-push-action@v6 uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with: with:
context: . context: .
push: true push: true
-33
View File
@@ -1,33 +0,0 @@
name: opencode
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
jobs:
opencode:
if: |
contains(github.event.comment.body, ' /oc') ||
startsWith(github.event.comment.body, '/oc') ||
contains(github.event.comment.body, ' /opencode') ||
startsWith(github.event.comment.body, '/opencode')
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
pull-requests: read
issues: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Run opencode
uses: anomalyco/opencode/github@latest
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
with:
model: openai/gpt-5.3-codex
+70 -100
View File
@@ -11,83 +11,47 @@ https://docs.djangoproject.com/en/6.0/ref/settings/
""" """
import logging import logging
import os, sys import os
import sys
from pathlib import Path from pathlib import Path
import environ
DEV_ENV = len(sys.argv) > 1 and sys.argv[1] == 'runserver' DEV_ENV = len(sys.argv) > 1 and sys.argv[1] == 'runserver'
RUNNING_TESTS = any(arg in {'test', 'pytest'} for arg in sys.argv) or 'PYTEST_CURRENT_TEST' in os.environ RUNNING_TESTS = any(arg in {'test', 'pytest'} for arg in sys.argv) or 'PYTEST_CURRENT_TEST' in os.environ
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)
def env_str(name: str, default: str = '') -> str:
value = os.getenv(name)
if value is None:
return default
return value.strip()
def env_optional_str(name: str) -> str | None:
value = os.getenv(name)
if value is None:
return None
value = value.strip()
return value or None
# 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') env = environ.Env(
DEBUG=(bool, True),
S3_ENABLE=(bool, False),
S3_USE_LOCAL_URLS=(bool, False),
POSTGRES_ENABLED=(bool, True),
POSTGRES_PORT=(int, 5432),
SMTP_PORT=(int, 587),
AWS_S3_USE_SSL=(bool, True),
AWS_QUERYSTRING_AUTH=(bool, False),
)
env.read_env(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 = os.getenv('SECRET_KEY', '') SECRET_KEY = env('SECRET_KEY', default='')
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = env_bool('DEBUG', True) DEBUG = env.bool('DEBUG')
S3_ENABLE = env_bool('S3_ENABLE', False) S3_ENABLE = env.bool('S3_ENABLE')
S3_USE_LOCAL_URLS = env_bool('S3_USE_LOCAL_URLS', False) S3_USE_LOCAL_URLS = env.bool('S3_USE_LOCAL_URLS')
ALLOWED_HOSTS = env_list('ALLOWED_HOSTS', [ ALLOWED_HOSTS = env.list('ALLOWED_HOSTS', default=[
'localhost', 'localhost',
'127.0.0.1', '127.0.0.1',
'zkqpv8r3-8000.uks1.devtunnels.ms'
]) ])
@@ -147,7 +111,7 @@ WSGI_APPLICATION = 'proyecto.wsgi.application'
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases # https://docs.djangoproject.com/en/6.0/ref/settings/#databases
# Usa PostgreSQL por defecto (POSTGRES_ENABLED=True); si no, SQLite. # Usa PostgreSQL por defecto (POSTGRES_ENABLED=True); si no, SQLite.
if RUNNING_TESTS or not env_bool('POSTGRES_ENABLED', True): if RUNNING_TESTS or not env.bool('POSTGRES_ENABLED'):
DATABASES = { DATABASES = {
'default': { 'default': {
'ENGINE': 'django.db.backends.sqlite3', 'ENGINE': 'django.db.backends.sqlite3',
@@ -158,11 +122,11 @@ else:
DATABASES = { DATABASES = {
'default': { 'default': {
'ENGINE': 'django.db.backends.postgresql', 'ENGINE': 'django.db.backends.postgresql',
'NAME': os.getenv('POSTGRES_DB', 'tienda'), 'NAME': env('POSTGRES_DB', default='tienda'),
'USER': os.getenv('POSTGRES_USER', 'postgres'), 'USER': env('POSTGRES_USER', default='postgres'),
'PASSWORD': os.getenv('POSTGRES_PASSWORD', ''), 'PASSWORD': env('POSTGRES_PASSWORD', default=''),
'HOST': os.getenv('POSTGRES_HOST', '127.0.0.1'), 'HOST': env('POSTGRES_HOST', default='127.0.0.1'),
'PORT': env_int('POSTGRES_PORT', 5432), 'PORT': env.int('POSTGRES_PORT'),
} }
} }
@@ -200,10 +164,10 @@ USE_TZ = True
# Static files (CSS, JavaScript, Images) # Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/6.0/howto/static-files/ # https://docs.djangoproject.com/en/6.0/howto/static-files/
STATIC_URL = 'static/'
STATIC_ROOT = BASE_DIR / 'staticfiles' STATIC_ROOT = BASE_DIR / 'staticfiles'
COMPRESS_ROOT = STATIC_ROOT COMPRESS_ROOT = STATIC_ROOT
COMPRESS_URL = STATIC_URL
STATICFILES_DIRS = [ STATICFILES_DIRS = [
BASE_DIR / 'tienda' / 'static', BASE_DIR / 'tienda' / 'static',
] ]
@@ -222,15 +186,15 @@ STORAGES = {
} }
if S3_ENABLE: if S3_ENABLE:
AWS_STORAGE_BUCKET_NAME = env_str('AWS_STORAGE_BUCKET_NAME') or None AWS_STORAGE_BUCKET_NAME = env('AWS_STORAGE_BUCKET_NAME', default='') or None
AWS_ACCESS_KEY_ID = env_optional_str('AWS_ACCESS_KEY_ID') AWS_ACCESS_KEY_ID = env('AWS_ACCESS_KEY_ID', default=None)
AWS_SECRET_ACCESS_KEY = env_optional_str('AWS_SECRET_ACCESS_KEY') AWS_SECRET_ACCESS_KEY = env('AWS_SECRET_ACCESS_KEY', default=None)
AWS_S3_REGION_NAME = env_optional_str('AWS_S3_REGION_NAME') AWS_S3_REGION_NAME = env('AWS_S3_REGION_NAME', default=None)
AWS_S3_ENDPOINT_URL = env_optional_str('AWS_S3_ENDPOINT_URL') AWS_S3_ENDPOINT_URL = env('AWS_S3_ENDPOINT_URL', default=None)
AWS_S3_CUSTOM_DOMAIN = env_optional_str('AWS_S3_CUSTOM_DOMAIN') AWS_S3_CUSTOM_DOMAIN = env('AWS_S3_CUSTOM_DOMAIN', default=None)
AWS_S3_USE_SSL = env_bool('AWS_S3_USE_SSL', True) AWS_S3_USE_SSL = env.bool('AWS_S3_USE_SSL')
AWS_QUERYSTRING_AUTH = env_bool('AWS_QUERYSTRING_AUTH', False) AWS_QUERYSTRING_AUTH = env.bool('AWS_QUERYSTRING_AUTH')
AWS_DEFAULT_ACL = env_str('AWS_DEFAULT_ACL', 'public-read') or None AWS_DEFAULT_ACL = env('AWS_DEFAULT_ACL', default='public-read') or None
AWS_S3_OBJECT_PARAMETERS = {} AWS_S3_OBJECT_PARAMETERS = {}
STORAGES = { STORAGES = {
@@ -242,6 +206,14 @@ if S3_ENABLE:
}, },
} }
if S3_ENABLE and AWS_S3_CUSTOM_DOMAIN:
STATIC_URL = f"https://{AWS_S3_CUSTOM_DOMAIN}/static/"
MEDIA_URL = f"https://{AWS_S3_CUSTOM_DOMAIN}/media/"
else:
STATIC_URL = env("STATIC_URL", default="static/")
MEDIA_URL = env("MEDIA_URL", default="media/")
COMPRESS_URL = STATIC_URL
STATICFILES_FINDERS = [ STATICFILES_FINDERS = [
'django.contrib.staticfiles.finders.FileSystemFinder', 'django.contrib.staticfiles.finders.FileSystemFinder',
'django.contrib.staticfiles.finders.AppDirectoriesFinder', 'django.contrib.staticfiles.finders.AppDirectoriesFinder',
@@ -250,15 +222,13 @@ STATICFILES_FINDERS = [
COMPRESS_PRECOMPILERS = () COMPRESS_PRECOMPILERS = ()
# Media files (User uploads) MEDIA_ROOT = Path(env('MEDIA_ROOT', default='/app/media'))
MEDIA_URL = 'media/'
MEDIA_ROOT = Path(os.getenv('MEDIA_ROOT', '/app/media'))
# Redis Configuration # Redis Configuration
CACHES = { CACHES = {
'default': { 'default': {
'BACKEND': 'django_redis.cache.RedisCache', 'BACKEND': 'django_redis.cache.RedisCache',
'LOCATION': os.getenv('REDIS_URL', 'redis://127.0.0.1:6379/1'), 'LOCATION': env('REDIS_URL', default='redis://127.0.0.1:6379/1'),
'OPTIONS': { 'OPTIONS': {
'CLIENT_CLASS': 'django_redis.client.DefaultClient', 'CLIENT_CLASS': 'django_redis.client.DefaultClient',
} }
@@ -283,30 +253,30 @@ MESSAGE_TAGS = {
# Login URL # Login URL
LOGIN_URL = '/tienda/login/' LOGIN_URL = '/tienda/login/'
STRIPE_PUBLISHABLE_KEY = os.getenv('STRIPE_PUBLISHABLE_KEY', '') STRIPE_PUBLISHABLE_KEY = env('STRIPE_PUBLISHABLE_KEY', default='')
STRIPE_SECRET_KEY = os.getenv('STRIPE_SECRET_KEY', '') STRIPE_SECRET_KEY = env('STRIPE_SECRET_KEY', default='')
# PayPal Configuration (Sandbox) # PayPal Configuration (Sandbox)
# Para obtener credenciales: https://sandbox.paypal.com/ # Para obtener credenciales: https://sandbox.paypal.com/
PAYPAL_CLIENT_ID = os.getenv('PAYPAL_CLIENT_ID', '') # Reemplazar con tu Client ID de PayPal Sandbox PAYPAL_CLIENT_ID = env('PAYPAL_CLIENT_ID', default='') # Reemplazar con tu Client ID de PayPal Sandbox
PAYPAL_CLIENT_SECRET = os.getenv('PAYPAL_CLIENT_SECRET', '') # Reemplazar con tu Client Secret de PayPal Sandbox PAYPAL_CLIENT_SECRET = env('PAYPAL_CLIENT_SECRET', default='') # Reemplazar con tu Client Secret de PayPal Sandbox
PAYPAL_MODE = os.getenv('PAYPAL_MODE', 'sandbox') # Cambiar a 'live' en producción PAYPAL_MODE = env('PAYPAL_MODE', default='sandbox') # Cambiar a 'live' en producción
SMTP_ENDPOINT = os.getenv('SMTP_ENDPOINT', 'smtp.email.eu-paris-1.oci.oraclecloud.com') SMTP_ENDPOINT = env('SMTP_ENDPOINT', default='smtp.email.eu-paris-1.oci.oraclecloud.com')
SMTP_PORT = env_int('SMTP_PORT', 587) SMTP_PORT = env.int('SMTP_PORT')
SECURITY = os.getenv('SECURITY', 'tls') SECURITY = env('SECURITY', default='tls')
SMTP_USERNAME = os.getenv('SMTP_USERNAME', None) SMTP_USERNAME = env('SMTP_USERNAME', default=None)
SMTP_PASSWORD = os.getenv('SMTP_PASSWORD', None) SMTP_PASSWORD = env('SMTP_PASSWORD', default=None)
SMTP_EMAIL = os.getenv("SMTP_EMAIL", None) SMTP_EMAIL = env('SMTP_EMAIL', default=None)
AUTH_USER_MODEL = 'tienda.User' AUTH_USER_MODEL = 'tienda.User'
DOMAIN = os.getenv("DOMAIN", "localhost") DOMAIN = env('DOMAIN', default='localhost')
PROTOCOL = os.getenv("PROTOCOL", "http") PROTOCOL = env('PROTOCOL', default='http')
default_csrf_trusted_origins = [] default_csrf_trusted_origins = []
if DOMAIN: if DOMAIN:
@@ -316,16 +286,16 @@ for host in ALLOWED_HOSTS:
if host and host != '*': if host and host != '*':
default_csrf_trusted_origins.append(f"{PROTOCOL}://{host}") default_csrf_trusted_origins.append(f"{PROTOCOL}://{host}")
CSRF_TRUSTED_ORIGINS = env_list( CSRF_TRUSTED_ORIGINS = env.list(
'CSRF_TRUSTED_ORIGINS', 'CSRF_TRUSTED_ORIGINS',
list(dict.fromkeys(default_csrf_trusted_origins)), default=list(dict.fromkeys(default_csrf_trusted_origins)),
) )
LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO').upper() LOG_LEVEL = env('LOG_LEVEL', default='INFO').upper()
LOG_DIR = Path(os.getenv('LOG_DIR', BASE_DIR / 'logs')) LOG_DIR = Path(env('LOG_DIR', default=str(BASE_DIR / 'logs')))
LOG_DIR.mkdir(parents=True, exist_ok=True) LOG_DIR.mkdir(parents=True, exist_ok=True)
LOG_FILE = LOG_DIR / os.getenv('LOG_FILE', 'app.log') LOG_FILE = LOG_DIR / env('LOG_FILE', default='app.log')
LOGGING = { LOGGING = {
@@ -399,13 +369,13 @@ EMAIL_HOST_USER = SMTP_USERNAME
EMAIL_HOST_PASSWORD = SMTP_PASSWORD EMAIL_HOST_PASSWORD = SMTP_PASSWORD
# El correo que se usará como remitente por defecto # El correo que se usará como remitente por defecto
DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL") or SMTP_EMAIL or "no-reply@localhost" DEFAULT_FROM_EMAIL = env('DEFAULT_FROM_EMAIL', default='') or SMTP_EMAIL or 'no-reply@localhost'
# URL de Redis (asumiendo que corre en el puerto default 6379) # URL de Redis (asumiendo que corre en el puerto default 6379)
CELERY_BROKER_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0") CELERY_BROKER_URL = env('REDIS_URL', default='redis://localhost:6379/0')
# Opcional: para guardar el resultado de las tareas # Opcional: para guardar el resultado de las tareas
CELERY_RESULT_BACKEND = os.getenv("REDIS_URL", "redis://localhost:6379/0") CELERY_RESULT_BACKEND = env('REDIS_URL', default='redis://localhost:6379/0')
# Configuraciones adicionales recomendadas # Configuraciones adicionales recomendadas
CELERY_ACCEPT_CONTENT = ['json'] CELERY_ACCEPT_CONTENT = ['json']
+2 -1
View File
@@ -7,6 +7,7 @@ dependencies = [
"celery==5.6.3", "celery==5.6.3",
"Django==6.0.5", "Django==6.0.5",
"django-compressor==4.6.0", "django-compressor==4.6.0",
"django-environ>=0.13.0",
"django-ninja>=1.6.2", "django-ninja>=1.6.2",
"django-redis==6.0.0", "django-redis==6.0.0",
# S3 backend requerido por tienda/storage_backends.py cuando S3_ENABLE=True. # S3 backend requerido por tienda/storage_backends.py cuando S3_ENABLE=True.
@@ -17,7 +18,7 @@ dependencies = [
"pillow==12.2.0", "pillow==12.2.0",
"psycopg2-binary==2.9.12", "psycopg2-binary==2.9.12",
"requests==2.34.2", "requests==2.34.2",
"stripe==15.1.0", "stripe==15.2.0",
"whitenoise==6.12.0", "whitenoise==6.12.0",
] ]
+1 -1
View File
@@ -2,7 +2,7 @@ from django import forms
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import FileExtensionValidator, MinLengthValidator, MaxLengthValidator from django.core.validators import FileExtensionValidator, MinLengthValidator, MaxLengthValidator
from .models import Category from .models import Category
from .constants import * from .constants import IMAGE_TYPE, EMAIL_FORMNAME, INCORRECT_PASSWORDS
ALLOWED_IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp'] ALLOWED_IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp']
ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'] ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
+7 -4
View File
@@ -22,13 +22,16 @@
{% csrf_token %} {% csrf_token %}
<div class="mb-4"> <div class="mb-4">
<label class="form-label">Puntuación</label> <label class="form-label" for="rating-input">
Puntuación
<div class="star-rating d-flex gap-1" id="star-rating"> <div class="star-rating d-flex gap-1" id="star-rating">
{% for i in "12345" %} {% for i in "12345" %}
<span class="star fs-2 {% if form.initial.rating|default:0 >= i|add:0 %}text-warning text-dark{% else %}text-secondary{% endif %}" data-value="{{ i }}" style="cursor: pointer; font-size: 2rem;"></span> <span class="star fs-2 {% if form.initial.rating|default:0 >= i|add:0 %}text-warning text-dark{% else %}text-secondary{% endif %}" data-value="{{ i }}" style="cursor: pointer; font-size: 2rem;"></span>
{% endfor %} {% endfor %}
</div> </div>
<input type="hidden" name="rating" id="rating-input" value="{{ form.initial.rating|default:1 }}"> <input type="hidden" name="rating" id="rating-input" value="{{ form.initial.rating|default:1 }}">
</label>
{% if form.rating.errors %} {% if form.rating.errors %}
<div class="text-danger small">{{ form.rating.errors }}</div> <div class="text-danger small">{{ form.rating.errors }}</div>
{% endif %} {% endif %}
@@ -83,12 +86,12 @@ document.addEventListener('DOMContentLoaded', function() {
stars.forEach(star => { stars.forEach(star => {
star.addEventListener('click', function() { star.addEventListener('click', function() {
const value = parseInt(this.dataset.value); const value = Number.parseInt(this.dataset.value);
updateStars(value); updateStars(value);
}); });
star.addEventListener('mouseenter', function() { star.addEventListener('mouseenter', function() {
const value = parseInt(this.dataset.value); const value = Number.parseInt(this.dataset.value);
stars.forEach((s, index) => { stars.forEach((s, index) => {
if (index < value) { if (index < value) {
s.classList.remove('text-secondary'); s.classList.remove('text-secondary');
@@ -98,7 +101,7 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
star.addEventListener('mouseleave', function() { star.addEventListener('mouseleave', function() {
updateStars(parseInt(ratingInput.value) || 1); updateStars(Number.parseInt(ratingInput.value) || 1);
}); });
}); });
}); });
+2 -2
View File
@@ -44,8 +44,8 @@
</p> </p>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Datos de la tarjeta</label> <label id="label-card-data" class="form-label">Datos de la tarjeta <input type="hidden"></label>
<div id="card-element"></div> <div id="card-element" aria-labelledby="label-card-data"></div>
<div id="card-errors" role="alert"></div> <div id="card-errors" role="alert"></div>
</div> </div>
+3 -2
View File
@@ -6,6 +6,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Comercialmeria</title>
<meta name="description" content="Sitio web de comercio local Almeriense"> <meta name="description" content="Sitio web de comercio local Almeriense">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
<link rel="preload" href="{% static 'css/custom.css' %}" as="style" onload="this.onload=null;this.rel='stylesheet'"> <link rel="preload" href="{% static 'css/custom.css' %}" as="style" onload="this.onload=null;this.rel='stylesheet'">
@@ -105,8 +106,8 @@
<!-- Barra de búsqueda con sugerencias --> <!-- Barra de búsqueda con sugerencias -->
<form class="search-suggestions-container" method="GET" action="{% url 'search' %}" role="search" id="searchForm"> <form class="search-suggestions-container" method="GET" action="{% url 'search' %}" role="search" id="searchForm">
<div class="input-group"> <div class="input-group">
<input class="form-control" type="search" name="q" id="searchInput" placeholder="Buscar productos..." aria-label="Buscar" autocomplete="off" role="combobox" aria-expanded="false" aria-autocomplete="list" aria-controls="searchSuggestions" aria-activedescendant="" aria-haspopup="listbox"> <input class="form-control" type="search" name="q" id="searchInput" placeholder="Buscar productos..." aria-label="Buscar" autocomplete="off" role="combobox" aria-expanded="false" aria-autocomplete="list" aria-controls="searchSuggestions" aria-activedescendant="searchbutton" aria-haspopup="listbox">
<button class="btn btn-outline-primary" type="submit" aria-label="Buscar productos">🔍 Buscar</button> <button class="btn btn-outline-primary" type="submit" id="searchbutton" aria-label="Buscar productos">🔍 Buscar</button>
</div> </div>
<div class="search-suggestions" id="searchSuggestions" role="listbox" aria-label="Sugerencias de búsqueda"></div> <div class="search-suggestions" id="searchSuggestions" role="listbox" aria-label="Sugerencias de búsqueda"></div>
</form> </form>
+16 -16
View File
@@ -84,11 +84,11 @@
<table class="table table-striped align-middle"> <table class="table table-striped align-middle">
<thead> <thead>
<tr> <tr>
<th>Producto</th> <th scope="col">Producto</th>
<th class="text-end">Precio (sin IVA)</th> <th scope="col" class="text-end">Precio (sin IVA)</th>
<th class="text-end">Cantidad</th> <th scope="col" class="text-end">Cantidad</th>
<th class="text-end">Stock actual</th> <th scope="col" class="text-end">Stock actual</th>
<th class="text-end">Subtotal (con IVA)</th> <th scope="col" class="text-end">Subtotal (con IVA)</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -104,16 +104,16 @@
</tbody> </tbody>
<tfoot> <tfoot>
<tr> <tr>
<th colspan="4" class="text-end">Subtotal:</th> <th colspan="4" scope="row" class="text-end">Subtotal:</th>
<th class="text-end">{{ cart.get_total|format_price }}€</th> <td class="text-end">{{ cart.get_total|format_price }}€</th>
</tr> </tr>
<tr> <tr>
<th colspan="4" class="text-end">IVA (21%):</th> <th scope="row" colspan="4" class="text-end">IVA (21%):</th>
<th class="text-end text-success">+{{ cart.get_vat_amount|format_price }}€</th> <td class="text-end text-success">+{{ cart.get_vat_amount|format_price }}€</th>
</tr> </tr>
<tr style="background-color: #f8f9fa;"> <tr style="background-color: #f8f9fa;">
<th colspan="4" class="text-end" style="font-size: 1.1rem;">Total:</th> <th scope="row" colspan="4" class="text-end" style="font-size: 1.1rem;">Total:</th>
<th class="text-end" style="font-size: 1.1rem;">{{ cart.get_total_with_vat|format_price }}€</th> <td class="text-end" style="font-size: 1.1rem;">{{ cart.get_total_with_vat|format_price }}€</th>
</tr> </tr>
</tfoot> </tfoot>
</table> </table>
@@ -125,7 +125,7 @@
<h5 class="card-title mb-3">2) Selecciona tu método de pago</h5> <h5 class="card-title mb-3">2) Selecciona tu método de pago</h5>
<!-- Tabs --> <!-- Tabs -->
<ul class="nav nav-tabs mb-3" id="paymentTabs" role="tablist"> <ul class="nav nav-tabs mb-3" id="paymentTabs">
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link active" id="tab-card" data-tab="pane-card" type="button" <button class="nav-link active" id="tab-card" data-tab="pane-card" type="button"
role="tab" aria-selected="true" aria-controls="pane-card" tabindex="0"> role="tab" aria-selected="true" aria-controls="pane-card" tabindex="0">
@@ -142,7 +142,7 @@
<!-- Tarjeta tab --> <!-- Tarjeta tab -->
<div id="pane-card" class="payment-tab-content active" <div id="pane-card" class="payment-tab-content active"
role="tabpanel" aria-labelledby="tab-card" tabindex="0"> role="tabpanel" aria-labelledby="tab-card">
{% if saved_cards %} {% if saved_cards %}
<fieldset class="mb-3"> <fieldset class="mb-3">
<legend class="fw-semibold fs-6 mb-2">Selección de tarjeta</legend> <legend class="fw-semibold fs-6 mb-2">Selección de tarjeta</legend>
@@ -164,8 +164,8 @@
<div id="new-card-section" {% if saved_cards %}style="display:none;"{% endif %}> <div id="new-card-section" {% if saved_cards %}style="display:none;"{% endif %}>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Número de tarjeta</label> <label id="label-card-number" class="form-label">Número de tarjeta <input type="hidden"></label>
<div id="card-element"></div> <div id="card-element" aria-labelledby="label-card-number"></div>
<div id="card-errors" role="alert"></div> <div id="card-errors" role="alert"></div>
</div> </div>
<div class="form-check mb-3"> <div class="form-check mb-3">
@@ -187,7 +187,7 @@
<!-- PayPal tab --> <!-- PayPal tab -->
<div id="pane-paypal" class="payment-tab-content" <div id="pane-paypal" class="payment-tab-content"
role="tabpanel" aria-labelledby="tab-paypal" tabindex="0"> role="tabpanel" aria-labelledby="tab-paypal">
{% if saved_paypal %} {% if saved_paypal %}
<div class="alert alert-light border mb-3"> <div class="alert alert-light border mb-3">
<small class="text-muted">Cuenta PayPal guardada:</small> <small class="text-muted">Cuenta PayPal guardada:</small>
+4 -4
View File
@@ -1,11 +1,11 @@
<table width="100%" border="0" cellspacing="0" cellpadding="0"> <table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr> <tr>
<td align="center" style="padding: 20px;"> <th align="center" style="padding: 20px;">
<table width="600" border="0" cellspacing="0" cellpadding="0" style="border: 1px solid #eeeeee; background-color: #ffffff;"> <table width="600" border="0" cellspacing="0" cellpadding="0" style="border: 1px solid #eeeeee; background-color: #ffffff;">
<tr> <tr>
<td align="center" style="background-color: #007bff; padding: 40px;"> <th scope="col" align="center" style="background-color: #007bff; padding: 40px;">
<h1 style="color: #ffffff; font-family: sans-serif; margin: 0;">Su cuenta ha sido bloqueada</h1> <h1 style="color: #ffffff; font-family: sans-serif; margin: 0;">Su cuenta ha sido bloqueada</h1>
</td> </th>
</tr> </tr>
<tr> <tr>
<td align="center" style="padding: 40px"> <td align="center" style="padding: 40px">
@@ -22,6 +22,6 @@
</td> </td>
</tr> </tr>
</table> </table>
</td> </th>
</tr> </tr>
</table> </table>
+4 -4
View File
@@ -1,11 +1,11 @@
<table width="100%" border="0" cellspacing="0" cellpadding="0"> <table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr> <tr>
<td align="center" style="padding: 20px;"> <th scope="col" align="center" style="padding: 20px;">
<table width="600" border="0" cellspacing="0" cellpadding="0" style="border: 1px solid #eeeeee; background-color: #ffffff;"> <table width="600" border="0" cellspacing="0" cellpadding="0" style="border: 1px solid #eeeeee; background-color: #ffffff;">
<tr> <tr>
<td align="center" style="background-color: #007bff; padding: 40px;"> <th scope="col" align="center" style="background-color: #007bff; padding: 40px;">
<h1 style="color: #ffffff; font-family: sans-serif; margin: 0;">¡Hola {{ name }}!</h1> <h1 style="color: #ffffff; font-family: sans-serif; margin: 0;">¡Hola {{ name }}!</h1>
</td> </th>
</tr> </tr>
<tr> <tr>
<td style="padding: 30px; font-family: sans-serif; line-height: 1.5; color: #444444;"> <td style="padding: 30px; font-family: sans-serif; line-height: 1.5; color: #444444;">
@@ -16,6 +16,6 @@
</td> </td>
</tr> </tr>
</table> </table>
</td> </th>
</tr> </tr>
</table> </table>
@@ -1,11 +1,11 @@
<table width="100%" border="0" cellspacing="0" cellpadding="0"> <table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr> <tr>
<td align="center" style="padding: 20px;"> <th scope="col" align="center" style="padding: 20px;">
<table width="600" border="0" cellspacing="0" cellpadding="0" style="border: 1px solid #eeeeee; background-color: #ffffff;"> <table width="600" border="0" cellspacing="0" cellpadding="0" style="border: 1px solid #eeeeee; background-color: #ffffff;">
<tr> <tr>
<td align="center" style="background-color: #007bff; padding: 40px;"> <th scope="col" align="center" style="background-color: #007bff; padding: 40px;">
<h1 style="color: #ffffff; font-family: sans-serif; margin: 0;">¡Hola {{ name }}!</h1> <h1 style="color: #ffffff; font-family: sans-serif; margin: 0;">¡Hola {{ name }}!</h1>
</td> </th>
</tr> </tr>
<tr> <tr>
<td align="center" style="padding: 40px"> <td align="center" style="padding: 40px">
@@ -22,6 +22,6 @@
</td> </td>
</tr> </tr>
</table> </table>
</td> </th>
</tr> </tr>
</table> </table>
+5 -5
View File
@@ -1,11 +1,11 @@
<table width="100%" border="0" cellspacing="0" cellpadding="0"> <table border="0" cellpadding="0" style="width: 100%;">
<tr> <tr>
<td align="center" style="padding: 20px;"> <th scope="col" align="center" style="padding: 20px;">
<table width="600" border="0" cellspacing="0" cellpadding="0" style="border: 1px solid #eeeeee; background-color: #ffffff;"> <table width="600" border="0" cellspacing="0" cellpadding="0" style="border: 1px solid #eeeeee; background-color: #ffffff;">
<tr> <tr>
<td align="center" style="background-color: #007bff; padding: 40px;"> <th scope="col" align="center" style="background-color: #007bff; padding: 40px;">
<h1 style="color: #ffffff; font-family: sans-serif; margin: 0;">Su cuenta ha sido desbloqueada</h1> <h1 style="color: #ffffff; font-family: sans-serif; margin: 0;">Su cuenta ha sido desbloqueada</h1>
</td> </th>
</tr> </tr>
<tr> <tr>
<td align="center" style="padding: 40px"> <td align="center" style="padding: 40px">
@@ -22,6 +22,6 @@
</td> </td>
</tr> </tr>
</table> </table>
</td> </th>
</tr> </tr>
</table> </table>
+4 -4
View File
@@ -1,11 +1,11 @@
<table width="100%" border="0" cellspacing="0" cellpadding="0"> <table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr> <tr>
<td align="center" style="padding: 20px;"> <th scope="col" align="center" style="padding: 20px;">
<table width="600" border="0" cellspacing="0" cellpadding="0" style="border: 1px solid #eeeeee; background-color: #ffffff;"> <table width="600" border="0" cellspacing="0" cellpadding="0" style="border: 1px solid #eeeeee; background-color: #ffffff;">
<tr> <tr>
<td align="center" style="background-color: #007bff; padding: 40px;"> <th scope="col" align="center" style="background-color: #007bff; padding: 40px;">
<h1 style="color: #ffffff; font-family: sans-serif; margin: 0;">¡Hola {{ name }}!</h1> <h1 style="color: #ffffff; font-family: sans-serif; margin: 0;">¡Hola {{ name }}!</h1>
</td> </th>
</tr> </tr>
<tr> <tr>
<td align="center" style="padding: 40px"> <td align="center" style="padding: 40px">
@@ -21,6 +21,6 @@
</td> </td>
</tr> </tr>
</table> </table>
</td> </th>
</tr> </tr>
</table> </table>
@@ -18,7 +18,7 @@
</div> </div>
<div class="card-body"> <div class="card-body">
{% if producto.primary_image %} {% if producto.primary_image %}
<img src="{{ producto.primary_image.image.url }}" alt="{{ producto.primary_image.alt|default:producto.name }}" class="rounded" style="width: 200px; height: 200px; object-fit: cover;"> <img src="{{ producto.primary_image.image.url }}" alt="{{ producto.primary_image.alt|default:producto.name }} - imagen principal" class="rounded" style="width: 200px; height: 200px; object-fit: cover;">
<p class="mt-2 text-muted mb-0">Esta imagen no se puede cambiar desde aquí.</p> <p class="mt-2 text-muted mb-0">Esta imagen no se puede cambiar desde aquí.</p>
{% else %} {% else %}
<p class="text-muted">No hay imagen principal asignada.</p> <p class="text-muted">No hay imagen principal asignada.</p>
+2 -2
View File
@@ -94,7 +94,7 @@
</div> </div>
</div> </div>
<script> <script type="module">
async function loadReviews() { async function loadReviews() {
try { try {
const response = await fetch("{% url 'product_reviews' product.id %}"); const response = await fetch("{% url 'product_reviews' product.id %}");
@@ -157,6 +157,6 @@ async function loadReviews() {
} }
} }
loadReviews(); await loadReviews();
</script> </script>
{% endblock %} {% endblock %}
+2 -26
View File
@@ -4,30 +4,6 @@ from django.conf import settings
logger = logging.getLogger("email.system") 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): def send_email(dest: str, title: str, body: str):
try: try:
@@ -40,7 +16,7 @@ def send_email(dest: str, title: str, body: str):
) )
logger.info("EMAIL_SENT to=%s subject=%s", dest, title) logger.info("EMAIL_SENT to=%s subject=%s", dest, title)
return (True,) return (True, None)
except Exception as e: except Exception as e:
logger.exception("EMAIL_SEND_FAILED to=%s subject=%s error=%s", dest, title, str(e)) logger.exception("EMAIL_SEND_FAILED to=%s subject=%s error=%s", dest, title, str(e))
return (False, e) return (False, e)
@@ -57,7 +33,7 @@ def send_hemail(dest: str, title: str, body: str, nbody: str):
) )
logger.info("EMAIL_SENT to=%s subject=%s", dest, title) logger.info("EMAIL_SENT to=%s subject=%s", dest, title)
return (True,) return (True, None)
except Exception as e: except Exception as e:
logger.exception("EMAIL_SEND_FAILED to=%s subject=%s error=%s", dest, title, str(e)) logger.exception("EMAIL_SEND_FAILED to=%s subject=%s error=%s", dest, title, str(e))
return (False, e) return (False, e)
+177 -121
View File
@@ -42,6 +42,16 @@ audit_logger = logging.getLogger("tienda.audit")
STOCK_RESERVATION_SESSION_KEY = "stock_reservation_id" STOCK_RESERVATION_SESSION_KEY = "stock_reservation_id"
STOCK_RESERVATION_PAYMENT_SESSION_KEY = "stock_reservation_payment_method" STOCK_RESERVATION_PAYMENT_SESSION_KEY = "stock_reservation_payment_method"
# Constantes para plantillas y mensajes reutilizados
LOGIN_TEMPLATE = "tienda/login.html"
INDEX_TEMPLATE = "tienda/index.html"
EDITAR_PERFIL_TEMPLATE = "tienda/editar_perfil.html"
EDITAR_DIRECCION_TEMPLATE = "tienda/editar_direccion.html"
MSG_CAMPOS_OBLIGATORIOS = "Por favor completa todos los campos obligatorios."
MSG_DIRECCION_INVALIDA = "Debes seleccionar una dirección de envío válida."
MSG_CARRITO_VACIO = "El carrito está vacío"
MSG_CUERPO_INVALIDO = "Cuerpo de la petición inválido"
def _mask_email(email: str) -> str: def _mask_email(email: str) -> str:
if not email or '@' not in email: if not email or '@' not in email:
@@ -232,65 +242,80 @@ def index(request: HttpRequest):
products = Product.objects.all()[start:end] products = Product.objects.all()[start:end]
categorias = Category.objects.all() categorias = Category.objects.all()
return render(request, "tienda/index.html", {"products": products, "categories": categorias}) return render(request, INDEX_TEMPLATE, {"products": products, "categories": categorias})
@require_http_methods(["GET", "POST"]) def _try_get_user(email: str):
def login(request: HttpRequest): """Intenta obtener un usuario por email, retorna (user, username) o (None, None)."""
if request.method == "POST":
form: UserLoginForm = UserLoginForm(request.POST)
if form.is_valid():
email: str = form.cleaned_data["email"]
password: str = form.cleaned_data["password"]
remember: bool = form.cleaned_data["remember"]
client_ip = _get_client_ip(request)
try: try:
user: User = User.objects.get(email=email) user = User.objects.get(email=email)
username = user.username return user, user.username
except User.DoesNotExist: except User.DoesNotExist:
audit_logger.warning("LOGIN FAILED email=%s reason=user_not_found ip=%s", _mask_email(email), client_ip) return None, None
messages.error(request, "El email o la contraseña es incorrecta")
return render(request, "tienda/login.html", {"form": form})
if user.registration_status == User.RegisterStatus.BANNED:
# Usuario baneado.
messages.error(request, "Esta cuenta esta bloqueada.")
return render(request, "tienda/login.html", {"form": form})
user = authenticate(request, username = username, password=password)
if user is None: def _handle_login_rate_limit(request, username, email):
data: str = cache.get(f"tries_login_{username}") """Verifica rate limit de intentos de login. Retorna True si está rate-limited."""
logins: int data = cache.get(f"tries_login_{username}")
if data is None: logins = 0 if data is None else int(data)
logins = 0
else:
logins = int(data)
if logins >= 5: if logins >= 5:
audit_logger.info("LOGIN FAILED email=%s reason=rate_limited", _mask_email(email)) audit_logger.info("LOGIN FAILED email=%s reason=rate_limited", _mask_email(email))
messages.error(request, "Has sufrido de Rate Limit por fallar 5 veces la contraseña") messages.error(request, "Has sufrido de Rate Limit por fallar 5 veces la contraseña")
return render(request, "tienda/login.html", {"form": form}) return True
logins+=1
logins += 1
cache.set(f"tries_login_{username}", str(logins), 600) cache.set(f"tries_login_{username}", str(logins), 600)
return False
@require_http_methods(["GET", "POST"])
def login(request: HttpRequest):
if request.method != "POST":
return render(request, LOGIN_TEMPLATE, {"form": UserLoginForm()})
form = UserLoginForm(request.POST)
if not form.is_valid():
return render(request, LOGIN_TEMPLATE, {"form": form})
email = form.cleaned_data["email"]
password = form.cleaned_data["password"]
remember = form.cleaned_data["remember"]
client_ip = _get_client_ip(request)
user, username = _try_get_user(email)
if user is None:
audit_logger.warning("LOGIN FAILED email=%s reason=user_not_found ip=%s", _mask_email(email), client_ip)
messages.error(request, "El email o la contraseña es incorrecta") messages.error(request, "El email o la contraseña es incorrecta")
return render(request, "tienda/login.html", {"form": form}) return render(request, LOGIN_TEMPLATE, {"form": form})
if user.registration_status == User.RegisterStatus.CONFIRMATION_REQUIRED:
if user.registration_status == User.RegisterStatus.BANNED:
messages.error(request, "Esta cuenta esta bloqueada.")
return render(request, LOGIN_TEMPLATE, {"form": form})
authenticated_user = authenticate(request, username=username, password=password)
if authenticated_user is None:
if _handle_login_rate_limit(request, username, email):
return render(request, LOGIN_TEMPLATE, {"form": form})
messages.error(request, "El email o la contraseña es incorrecta")
return render(request, LOGIN_TEMPLATE, {"form": form})
if authenticated_user.registration_status == User.RegisterStatus.CONFIRMATION_REQUIRED:
audit_logger.info("LOGIN_FAILED email=%s reason=not_verified", _mask_email(email)) audit_logger.info("LOGIN_FAILED email=%s reason=not_verified", _mask_email(email))
messages.error(request, "No se puede iniciar sesión porque no has verificado tu cuenta, comprueba tu email. Si eliminaste el email pero querias verificarte, contacta con el soporte tecnico") messages.error(request, "No se puede iniciar sesión porque no has verificado tu cuenta, comprueba tu email. Si eliminaste el email pero querias verificarte, contacta con el soporte tecnico")
return render(request, "tienda/login.html", {"form": form}) return render(request, LOGIN_TEMPLATE, {"form": form})
auth_login(request, user)
auth_login(request, authenticated_user)
if not remember: if not remember:
request.session.set_expiry(0) request.session.set_expiry(0)
else: else:
request.session.set_expiry(1209600) request.session.set_expiry(1209600)
audit_logger.info("LOGIN_SUCCESS user_id=%s email=%s ip=%s remember=%s", user.id, _mask_email(user.email), client_ip, bool(remember)) audit_logger.info("LOGIN_SUCCESS user_id=%s email=%s ip=%s remember=%s", authenticated_user.id, _mask_email(authenticated_user.email), client_ip, bool(remember))
tasks.enviar_correo_bienvenida.delay(user.email, f"{user.first_name} {user.last_name}") tasks.enviar_correo_bienvenida.delay(authenticated_user.email, f"{authenticated_user.first_name} {authenticated_user.last_name}")
messages.success(request, f"¡Bienvenido {user.first_name or user.username}!") messages.success(request, f"¡Bienvenido {authenticated_user.first_name or authenticated_user.username}!")
return redirect("index") return redirect("index")
else:
form = UserLoginForm()
return render(request, "tienda/login.html", {"form": form})
@require_http_methods(["GET", "POST"]) @require_http_methods(["GET", "POST"])
def register(request: HttpRequest): def register(request: HttpRequest):
@@ -390,7 +415,7 @@ def categoria(request: HttpRequest, id: int):
category = Category.objects.get(id=id) category = Category.objects.get(id=id)
categories = Category.objects.all() categories = Category.objects.all()
products = Product.objects.filter(category=category)[start:end] products = Product.objects.filter(category=category)[start:end]
return render(request, "tienda/index.html", {"products": products, "categories": categories}) return render(request, INDEX_TEMPLATE, {"products": products, "categories": categories})
# Funciones auxiliares para el carrito # Funciones auxiliares para el carrito
@@ -610,15 +635,9 @@ def _get_selected_shipping_address(request: HttpRequest):
return ShippingAddress.objects.filter(id=shipping_address_id, user=request.user).first() return ShippingAddress.objects.filter(id=shipping_address_id, user=request.user).first()
@require_GET
def create_order_from_cart(request, payment_method, payment_reference="", shipping_address=None, stock_reservation=None):
"""Crea un pedido a partir del carrito actual, validando y descontando stock."""
cart = get_or_create_cart(request)
cart_items = list(cart.items.select_related("product", "product__creator"))
if not cart_items:
return None, "El carrito está vacío."
def _calculate_order_totals(cart_items):
"""Calcula los totales de los items del carrito."""
order_total = Decimal("0.00") order_total = Decimal("0.00")
items_with_totals = [] items_with_totals = []
purchased_items = [] purchased_items = []
@@ -627,75 +646,73 @@ def create_order_from_cart(request, payment_method, payment_reference="", shippi
product = item.product product = item.product
unit_price_with_vat = get_price_with_vat_decimal(product.price) unit_price_with_vat = get_price_with_vat_decimal(product.price)
line_total_with_vat = (unit_price_with_vat * item.quantity).quantize( line_total_with_vat = (unit_price_with_vat * item.quantity).quantize(
Decimal("0.01"), Decimal("0.01"), rounding=ROUND_HALF_UP,
rounding=ROUND_HALF_UP,
) )
order_total += line_total_with_vat order_total += line_total_with_vat
items_with_totals.append((item, unit_price_with_vat, line_total_with_vat)) items_with_totals.append((item, unit_price_with_vat, line_total_with_vat))
purchased_items.append( purchased_items.append({
{
"amount": item.quantity, "amount": item.quantity,
"product_name": product.name, "product_name": product.name,
"price": float(unit_price_with_vat), "price": float(unit_price_with_vat),
} })
)
_release_expired_stock_reservations() return order_total, items_with_totals, purchased_items
with transaction.atomic():
locked_reservation = None
reserved_by_product = {}
if stock_reservation is not None: def _validate_locked_reservation(stock_reservation):
"""Valida y carga la reserva de stock, retorna (locked_reservation, reserved_by_product).
Si stock_reservation es None, retorna (None, {}). Si la reserva ha caducado, retorna (None, None)."""
if stock_reservation is None:
return None, {}
locked_reservation = StockReservation.objects.select_for_update().filter( locked_reservation = StockReservation.objects.select_for_update().filter(
id=stock_reservation.id, id=stock_reservation.id,
status=StockReservation.STATUS_ACTIVE, status=StockReservation.STATUS_ACTIVE,
expires_at__gt=timezone.now(), expires_at__gt=timezone.now(),
).first() ).first()
if locked_reservation is None: if locked_reservation is None:
return None, ( return None, None
f"La reserva de stock ha caducado. Tienes {STOCK_RESERVATION_MINUTES} minutos "
"desde que pulsas pagar. Revisa el carrito y vuelve a intentarlo."
)
reserved_by_product = {}
for reservation_item in locked_reservation.items.all(): for reservation_item in locked_reservation.items.all():
reserved_by_product[reservation_item.product_id] = reservation_item.quantity reserved_by_product[reservation_item.product_id] = reservation_item.quantity
product_ids = [item.product_id for item in cart_items] return locked_reservation, reserved_by_product
products = Product.objects.select_for_update().filter(id__in=product_ids)
product_map = {product.id: product for product in products}
reserved_from_others = _get_reserved_quantities_by_product(
product_ids,
exclude_reservation_ids=[locked_reservation.id] if locked_reservation else None,
)
def _validate_order_items(cart_items, product_map, locked_reservation, reserved_by_product, reserved_from_others):
"""Valida disponibilidad y stock de cada item. Retorna None si ok, o str con el error."""
for item in cart_items: for item in cart_items:
product = product_map.get(item.product_id) product = product_map.get(item.product_id)
if product is None: if product is None:
return None, f"El producto '{item.product.name}' ya no está disponible." return f"El producto '{item.product.name}' ya no está disponible."
if locked_reservation is not None and item.quantity > reserved_by_product.get(item.product_id, 0): if locked_reservation is not None and item.quantity > reserved_by_product.get(item.product_id, 0):
return None, ( return (
f"La cantidad de '{item.product.name}' ha cambiado desde la reserva. " f"La cantidad de '{item.product.name}' ha cambiado desde la reserva. "
"Vuelve a intentar el pago." "Vuelve a intentar el pago."
) )
available = max(product.stock - reserved_from_others.get(item.product_id, 0), 0) available = max(product.stock - reserved_from_others.get(item.product_id, 0), 0)
if item.quantity > available: if item.quantity > available:
return None, _build_stock_issue_message({ return _build_stock_issue_message({
"product_name": item.product.name, "product_name": item.product.name,
"requested": item.quantity, "requested": item.quantity,
"available": available, "available": available,
}) })
if product.stock < item.quantity: if product.stock < item.quantity:
return None, _build_stock_issue_message({ return _build_stock_issue_message({
"product_name": item.product.name, "product_name": item.product.name,
"requested": item.quantity, "requested": item.quantity,
"available": product.stock, "available": product.stock,
}) })
return None
def _create_order_and_items(request, order_total, items_with_totals, product_map, payment_method, payment_reference, shipping_address, locked_reservation):
"""Crea la orden y sus items, descuenta stock y marca reserva como completada."""
order = Order.objects.create( order = Order.objects.create(
buyer=request.user if request.user.is_authenticated else None, buyer=request.user if request.user.is_authenticated else None,
shipping_address=shipping_address, shipping_address=shipping_address,
@@ -717,19 +734,55 @@ def create_order_from_cart(request, payment_method, payment_reference="", shippi
unit_price=float(unit_price_with_vat), unit_price=float(unit_price_with_vat),
total_price=float(line_total_with_vat), total_price=float(line_total_with_vat),
) )
product_row = product_map.get(item.product_id) product_row = product_map.get(item.product_id)
product_row.stock = F('stock') - item.quantity product_row.stock = F('stock') - item.quantity
product_row.save(update_fields=["stock"]) product_row.save(update_fields=["stock"])
_invalidate_product_cache(product_ids)
cart.items.all().delete()
if locked_reservation is not None: if locked_reservation is not None:
locked_reservation.status = StockReservation.STATUS_COMPLETED locked_reservation.status = StockReservation.STATUS_COMPLETED
locked_reservation.save(update_fields=["status", "updated_at"]) locked_reservation.save(update_fields=["status", "updated_at"])
return order
@require_GET
def create_order_from_cart(request, payment_method, payment_reference="", shipping_address=None, stock_reservation=None):
"""Crea un pedido a partir del carrito actual, validando y descontando stock."""
cart = get_or_create_cart(request)
cart_items = list(cart.items.select_related("product", "product__creator"))
if not cart_items:
return None, MSG_CARRITO_VACIO + "."
order_total, items_with_totals, purchased_items = _calculate_order_totals(cart_items)
_release_expired_stock_reservations()
with transaction.atomic():
locked_reservation, reserved_by_product = _validate_locked_reservation(stock_reservation)
if locked_reservation is None and stock_reservation is not None:
return None, (
f"La reserva de stock ha caducado. Tienes {STOCK_RESERVATION_MINUTES} minutos "
"desde que pulsas pagar. Revisa el carrito y vuelve a intentarlo."
)
product_ids = [item.product_id for item in cart_items]
products = Product.objects.select_for_update().filter(id__in=product_ids)
product_map = {product.id: product for product in products}
reserved_from_others = _get_reserved_quantities_by_product(
product_ids,
exclude_reservation_ids=[locked_reservation.id] if locked_reservation else None,
)
error_msg = _validate_order_items(cart_items, product_map, locked_reservation, reserved_by_product, reserved_from_others)
if error_msg:
return None, error_msg
order = _create_order_and_items(request, order_total, items_with_totals, product_map, payment_method, payment_reference, shipping_address, locked_reservation)
_invalidate_product_cache(product_ids)
cart.items.all().delete()
if request.user.is_authenticated and purchased_items: if request.user.is_authenticated and purchased_items:
tasks.process_purchase.delay( tasks.process_purchase.delay(
request.user.id, request.user.id,
@@ -970,15 +1023,8 @@ def crear_producto(request: HttpRequest):
form = ProductForm() form = ProductForm()
return render(request, "tienda/crear_producto.html", {"form":form}) return render(request, "tienda/crear_producto.html", {"form":form})
@require_http_methods(["GET","POST"]) def _handle_edit_product_post(request, producto, form):
@login_required """Procesa el POST de editar producto. Retorna respuesta HTTP o None si hay error."""
def editar_producto(request: HttpRequest, id: int):
"""Edita un producto del usuario autenticado"""
producto = get_object_or_404(Product, id=id, creator=request.user)
if request.method == "POST":
form = ProductEditForm(request.POST, request.FILES)
if form.is_valid():
producto.name = form.cleaned_data["name"] producto.name = form.cleaned_data["name"]
producto.briefdesc = form.cleaned_data.get("briefdesc", "") or "" producto.briefdesc = form.cleaned_data.get("briefdesc", "") or ""
producto.description = form.cleaned_data["description"] producto.description = form.cleaned_data["description"]
@@ -1010,8 +1056,19 @@ def editar_producto(request: HttpRequest, id: int):
messages.success(request, f"¡Producto '{producto.name}' actualizado exitosamente!") messages.success(request, f"¡Producto '{producto.name}' actualizado exitosamente!")
return redirect("mis_productos") return redirect("mis_productos")
else:
messages.error(request, "Por favor completa todos los campos obligatorios.")
@require_http_methods(["GET","POST"])
@login_required
def editar_producto(request: HttpRequest, id: int):
"""Edita un producto del usuario autenticado"""
producto = get_object_or_404(Product, id=id, creator=request.user)
if request.method == "POST":
form = ProductEditForm(request.POST, request.FILES)
if form.is_valid():
return _handle_edit_product_post(request, producto, form)
messages.error(request, MSG_CAMPOS_OBLIGATORIOS)
else: else:
initial = { initial = {
"name": producto.name, "name": producto.name,
@@ -1023,10 +1080,9 @@ def editar_producto(request: HttpRequest, id: int):
} }
form = ProductEditForm(initial=initial) form = ProductEditForm(initial=initial)
categories = Category.objects.all()
return render(request, "tienda/editar_producto.html", { return render(request, "tienda/editar_producto.html", {
"form": form, "form": form,
"producto": producto "producto": producto,
}) })
@login_required @login_required
@@ -1130,13 +1186,13 @@ def create_checkout_session(request: HttpRequest):
try: try:
shipping_address = _get_selected_shipping_address(request) shipping_address = _get_selected_shipping_address(request)
if shipping_address is None: if shipping_address is None:
return JsonResponse({"error": "Debes seleccionar una dirección de envío válida."}, status=400) return JsonResponse({"error": MSG_DIRECCION_INVALIDA}, status=400)
cart = get_or_create_cart(request) cart = get_or_create_cart(request)
cart_items = list(cart.items.select_related("product")) cart_items = list(cart.items.select_related("product"))
if not cart_items: if not cart_items:
return JsonResponse({"error": "El carrito está vacío"}, status=400) return JsonResponse({"error": MSG_CARRITO_VACIO}, status=400)
active_reservation_ids = _get_active_reservation_ids_for_request(request) active_reservation_ids = _get_active_reservation_ids_for_request(request)
stock_issues = _get_cart_stock_issues(cart_items, exclude_reservation_ids=active_reservation_ids) stock_issues = _get_cart_stock_issues(cart_items, exclude_reservation_ids=active_reservation_ids)
@@ -1282,7 +1338,7 @@ def create_paypal_payment(request: HttpRequest):
try: try:
shipping_address = _get_selected_shipping_address(request) shipping_address = _get_selected_shipping_address(request)
if shipping_address is None: if shipping_address is None:
return JsonResponse({"error": "Debes seleccionar una dirección de envío válida."}, status=400) return JsonResponse({"error": MSG_DIRECCION_INVALIDA}, status=400)
import paypalrestsdk import paypalrestsdk
@@ -1290,7 +1346,7 @@ def create_paypal_payment(request: HttpRequest):
cart_items = list(cart.items.select_related("product")) cart_items = list(cart.items.select_related("product"))
if not cart_items: if not cart_items:
return JsonResponse({"error": "El carrito está vacío"}, status=400) return JsonResponse({"error": MSG_CARRITO_VACIO}, status=400)
active_reservation_ids = _get_active_reservation_ids_for_request(request) active_reservation_ids = _get_active_reservation_ids_for_request(request)
stock_issues = _get_cart_stock_issues(cart_items, exclude_reservation_ids=active_reservation_ids) stock_issues = _get_cart_stock_issues(cart_items, exclude_reservation_ids=active_reservation_ids)
@@ -1379,7 +1435,7 @@ def create_paypal_payment(request: HttpRequest):
else: else:
# Loguear el error # Loguear el error
logger.error("PAYPAL_CREATE_ERROR user_id=%s", request.user.id) logger.error("PAYPAL_CREATE_ERROR user_id=%s", request.user.id)
return JsonResponse({"error": f"Error al crear el pago"}, status=400) return JsonResponse({"error": "Error al crear el pago"}, status=400)
except ImportError: except ImportError:
logger.error("PAYPAL_SDK_NOT_INSTALLED") logger.error("PAYPAL_SDK_NOT_INSTALLED")
@@ -1387,7 +1443,7 @@ def create_paypal_payment(request: HttpRequest):
except Exception as e: except Exception as e:
error_msg = str(e) error_msg = str(e)
logger.exception("PAYPAL_CREATE_EXCEPTION user_id=%s", request.user.id) logger.exception("PAYPAL_CREATE_EXCEPTION user_id=%s", request.user.id)
return JsonResponse({"error": f"Error al crear el pago"}, status=500) return JsonResponse({"error": "Error al crear el pago"}, status=500)
@require_GET @require_GET
@@ -1469,17 +1525,17 @@ def crear_payment_intent(request: HttpRequest):
try: try:
payload = json.loads(request.body.decode("utf-8") or "{}") payload = json.loads(request.body.decode("utf-8") or "{}")
except (json.JSONDecodeError, UnicodeDecodeError): except (json.JSONDecodeError, UnicodeDecodeError):
return JsonResponse({"error": "Cuerpo de la petición inválido"}, status=400) return JsonResponse({"error": MSG_CUERPO_INVALIDO}, status=400)
shipping_address = _get_selected_shipping_address(request) shipping_address = _get_selected_shipping_address(request)
if shipping_address is None: if shipping_address is None:
return JsonResponse({"error": "Debes seleccionar una dirección de envío válida."}, status=400) return JsonResponse({"error": MSG_DIRECCION_INVALIDA}, status=400)
cart = get_or_create_cart(request) cart = get_or_create_cart(request)
cart_items = list(cart.items.select_related("product")) cart_items = list(cart.items.select_related("product"))
if not cart_items: if not cart_items:
return JsonResponse({"error": "El carrito está vacío"}, status=400) return JsonResponse({"error": MSG_CARRITO_VACIO}, status=400)
active_reservation_ids = _get_active_reservation_ids_for_request(request) active_reservation_ids = _get_active_reservation_ids_for_request(request)
stock_issues = _get_cart_stock_issues(cart_items, exclude_reservation_ids=active_reservation_ids) stock_issues = _get_cart_stock_issues(cart_items, exclude_reservation_ids=active_reservation_ids)
@@ -1551,7 +1607,7 @@ def confirmar_pago_tarjeta(request: HttpRequest):
try: try:
payload = json.loads(request.body.decode("utf-8") or "{}") payload = json.loads(request.body.decode("utf-8") or "{}")
except (json.JSONDecodeError, UnicodeDecodeError): except (json.JSONDecodeError, UnicodeDecodeError):
return JsonResponse({"error": "Cuerpo de la petición inválido"}, status=400) return JsonResponse({"error": MSG_CUERPO_INVALIDO}, status=400)
payment_intent_id = payload.get("payment_intent_id") payment_intent_id = payload.get("payment_intent_id")
if not payment_intent_id: if not payment_intent_id:
@@ -1624,13 +1680,13 @@ def crear_orden_paypal(request: HttpRequest):
shipping_address = _get_selected_shipping_address(request) shipping_address = _get_selected_shipping_address(request)
if shipping_address is None: if shipping_address is None:
return JsonResponse({"error": "Debes seleccionar una dirección de envío válida."}, status=400) return JsonResponse({"error": MSG_DIRECCION_INVALIDA}, status=400)
cart = get_or_create_cart(request) cart = get_or_create_cart(request)
cart_items = list(cart.items.select_related("product")) cart_items = list(cart.items.select_related("product"))
if not cart_items: if not cart_items:
return JsonResponse({"error": "El carrito está vacío"}, status=400) return JsonResponse({"error": MSG_CARRITO_VACIO}, status=400)
active_reservation_ids = _get_active_reservation_ids_for_request(request) active_reservation_ids = _get_active_reservation_ids_for_request(request)
stock_issues = _get_cart_stock_issues(cart_items, exclude_reservation_ids=active_reservation_ids) stock_issues = _get_cart_stock_issues(cart_items, exclude_reservation_ids=active_reservation_ids)
@@ -1677,7 +1733,7 @@ def capturar_orden_paypal(request: HttpRequest):
try: try:
payload = json.loads(request.body.decode("utf-8") or "{}") payload = json.loads(request.body.decode("utf-8") or "{}")
except (json.JSONDecodeError, UnicodeDecodeError): except (json.JSONDecodeError, UnicodeDecodeError):
return JsonResponse({"error": "Cuerpo de la petición inválido"}, status=400) return JsonResponse({"error": MSG_CUERPO_INVALIDO}, status=400)
paypal_order_id = payload.get("orderID") paypal_order_id = payload.get("orderID")
if not paypal_order_id: if not paypal_order_id:
@@ -1812,7 +1868,7 @@ def confirmar_setup_intent(request: HttpRequest):
try: try:
payload = json.loads(request.body.decode("utf-8") or "{}") payload = json.loads(request.body.decode("utf-8") or "{}")
except (json.JSONDecodeError, UnicodeDecodeError): except (json.JSONDecodeError, UnicodeDecodeError):
return JsonResponse({"error": "Cuerpo de la petición inválido"}, status=400) return JsonResponse({"error": MSG_CUERPO_INVALIDO}, status=400)
payment_method_id = payload.get("payment_method_id") payment_method_id = payload.get("payment_method_id")
if not payment_method_id: if not payment_method_id:
@@ -1931,7 +1987,7 @@ def capturar_orden_paypal_setup(request: HttpRequest):
try: try:
payload = json.loads(request.body.decode("utf-8") or "{}") payload = json.loads(request.body.decode("utf-8") or "{}")
except (json.JSONDecodeError, UnicodeDecodeError): except (json.JSONDecodeError, UnicodeDecodeError):
return JsonResponse({"error": "Cuerpo de la petición inválido"}, status=400) return JsonResponse({"error": MSG_CUERPO_INVALIDO}, status=400)
paypal_order_id = payload.get("orderID") paypal_order_id = payload.get("orderID")
if not paypal_order_id: if not paypal_order_id:
@@ -2037,7 +2093,7 @@ def editar_perfil(request: HttpRequest):
if email != request.user.email and User.objects.filter(email=email).exists(): if email != request.user.email and User.objects.filter(email=email).exists():
messages.error(request, "Ya existe un usuario con este correo electrónico.") messages.error(request, "Ya existe un usuario con este correo electrónico.")
return render(request, "tienda/editar_perfil.html", {"form": form}) return render(request, EDITAR_PERFIL_TEMPLATE, {"form": form})
request.user.first_name = form.cleaned_data["first_name"] request.user.first_name = form.cleaned_data["first_name"]
request.user.last_name = form.cleaned_data["last_name"] request.user.last_name = form.cleaned_data["last_name"]
@@ -2054,7 +2110,7 @@ def editar_perfil(request: HttpRequest):
} }
form = EditProfileForm(initial=initial) form = EditProfileForm(initial=initial)
return render(request, "tienda/editar_perfil.html", {"form": form}) return render(request, EDITAR_PERFIL_TEMPLATE, {"form": form})
@login_required @login_required
@@ -2069,11 +2125,11 @@ def cambiar_contrasena(request: HttpRequest):
if not request.user.check_password(current_password): if not request.user.check_password(current_password):
messages.error(request, "La contraseña actual es incorrecta.") messages.error(request, "La contraseña actual es incorrecta.")
return render(request, "tienda/editar_perfil.html", {"password_form": ChangePasswordForm()}) return render(request, EDITAR_PERFIL_TEMPLATE, {"password_form": ChangePasswordForm()})
if len(new_password) < 8: if len(new_password) < 8:
messages.error(request, "La contraseña debe tener al menos 8 caracteres.") messages.error(request, "La contraseña debe tener al menos 8 caracteres.")
return render(request, "tienda/editar_perfil.html", {"password_form": ChangePasswordForm()}) return render(request, EDITAR_PERFIL_TEMPLATE, {"password_form": ChangePasswordForm()})
request.user.set_password(new_password) request.user.set_password(new_password)
request.user.save() request.user.save()
@@ -2084,7 +2140,7 @@ def cambiar_contrasena(request: HttpRequest):
return redirect("portal_usuario") return redirect("portal_usuario")
else: else:
messages.error(request, "Las contraseñas nuevas no coinciden o son inválidas.") messages.error(request, "Las contraseñas nuevas no coinciden o son inválidas.")
return render(request, "tienda/editar_perfil.html", {"password_form": form}) return render(request, EDITAR_PERFIL_TEMPLATE, {"password_form": form})
return redirect("editar_perfil") return redirect("editar_perfil")
@@ -2112,11 +2168,11 @@ def crear_direccion(request: HttpRequest):
if not _is_almeria_city(city): if not _is_almeria_city(city):
messages.error(request, "El pueblo/ciudad debe pertenecer a la provincia de Almería.") messages.error(request, "El pueblo/ciudad debe pertenecer a la provincia de Almería.")
return render(request, "tienda/editar_direccion.html", _address_form_context(form=form)) return render(request, EDITAR_DIRECCION_TEMPLATE, _address_form_context(form=form))
if not _is_almeria_postal_code(postal_code): if not _is_almeria_postal_code(postal_code):
messages.error(request, "Solo realizamos envíos en la provincia de Almería (código postal 04xxx).") messages.error(request, "Solo realizamos envíos en la provincia de Almería (código postal 04xxx).")
return render(request, "tienda/editar_direccion.html", _address_form_context(form=form)) return render(request, EDITAR_DIRECCION_TEMPLATE, _address_form_context(form=form))
ShippingAddress.objects.create( ShippingAddress.objects.create(
user=request.user, user=request.user,
@@ -2133,11 +2189,11 @@ def crear_direccion(request: HttpRequest):
messages.success(request, "Dirección creada correctamente.") messages.success(request, "Dirección creada correctamente.")
return redirect("direcciones_usuario") return redirect("direcciones_usuario")
else: else:
messages.error(request, "Por favor completa todos los campos obligatorios.") messages.error(request, MSG_CAMPOS_OBLIGATORIOS)
else: else:
form = ShippingAddressForm() form = ShippingAddressForm()
return render(request, "tienda/editar_direccion.html", _address_form_context(form=form)) return render(request, EDITAR_DIRECCION_TEMPLATE, _address_form_context(form=form))
@login_required @login_required
@@ -2154,11 +2210,11 @@ def editar_direccion(request: HttpRequest, id: int):
if not _is_almeria_city(city): if not _is_almeria_city(city):
messages.error(request, "El pueblo/ciudad debe pertenece a la provincia de Almería.") messages.error(request, "El pueblo/ciudad debe pertenece a la provincia de Almería.")
return render(request, "tienda/editar_direccion.html", _address_form_context(direccion, form=form)) return render(request, EDITAR_DIRECCION_TEMPLATE, _address_form_context(direccion, form=form))
if not _is_almeria_postal_code(postal_code): if not _is_almeria_postal_code(postal_code):
messages.error(request, "Solo realizamos envíos en la provincia de Almería (código postal 04xxx).") messages.error(request, "Solo realizamos envíos en la provincia de Almería (código postal 04xxx).")
return render(request, "tienda/editar_direccion.html", _address_form_context(direccion, form=form)) return render(request, EDITAR_DIRECCION_TEMPLATE, _address_form_context(direccion, form=form))
direccion.full_name = form.cleaned_data["full_name"] direccion.full_name = form.cleaned_data["full_name"]
direccion.address_line_1 = form.cleaned_data["address_line_1"] direccion.address_line_1 = form.cleaned_data["address_line_1"]
@@ -2173,7 +2229,7 @@ def editar_direccion(request: HttpRequest, id: int):
messages.success(request, "Dirección actualizada correctamente.") messages.success(request, "Dirección actualizada correctamente.")
return redirect("direcciones_usuario") return redirect("direcciones_usuario")
else: else:
messages.error(request, "Por favor completa todos los campos obligatorios.") messages.error(request, MSG_CAMPOS_OBLIGATORIOS)
else: else:
initial = { initial = {
"full_name": direccion.full_name, "full_name": direccion.full_name,
@@ -2187,7 +2243,7 @@ def editar_direccion(request: HttpRequest, id: int):
} }
form = ShippingAddressForm(initial=initial) form = ShippingAddressForm(initial=initial)
return render(request, "tienda/editar_direccion.html", _address_form_context(direccion, form=form)) return render(request, EDITAR_DIRECCION_TEMPLATE, _address_form_context(direccion, form=form))
@login_required @login_required
@@ -2275,7 +2331,7 @@ def reset_password(request: HttpRequest):
if form.is_valid(): if form.is_valid():
tasks.enviar_correo_recuperacion.delay(form.cleaned_data["email"]) tasks.enviar_correo_recuperacion.delay(form.cleaned_data["email"])
messages.info(request, "Si tienes una cuenta con ese correo electronico, se ha enviado un correo con un enlace") messages.info(request, "Si tienes una cuenta con ese correo electronico, se ha enviado un correo con un enlace")
return render(request, "tienda/index.html", {}) return render(request, INDEX_TEMPLATE, {})
@require_http_methods(["GET", "POST"]) @require_http_methods(["GET", "POST"])
def reset_password_phase2(request: HttpRequest, code: str): def reset_password_phase2(request: HttpRequest, code: str):
Generated
+11
View File
@@ -333,6 +333,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d5/9d/9a0ba39f33574994e5b33aea55a68e8fad72b8dd923a82300e4e91774f59/django_compressor-4.6.0-py3-none-any.whl", hash = "sha256:6e7b21020a0d86272c5e37000c33accc4ebeb77394a3dd86d775a09aae7aade4", size = 96828, upload-time = "2025-11-10T13:12:10.001Z" }, { url = "https://files.pythonhosted.org/packages/d5/9d/9a0ba39f33574994e5b33aea55a68e8fad72b8dd923a82300e4e91774f59/django_compressor-4.6.0-py3-none-any.whl", hash = "sha256:6e7b21020a0d86272c5e37000c33accc4ebeb77394a3dd86d775a09aae7aade4", size = 96828, upload-time = "2025-11-10T13:12:10.001Z" },
] ]
[[package]]
name = "django-environ"
version = "0.13.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/18/3c/60983e6ec9b24a8d8588eecebfd21123cba980bce0a905807a27692f0860/django_environ-0.13.0.tar.gz", hash = "sha256:6c401e4c219442c2c4588c2116d5292b5484a6f69163ed09cd41f3943bfb645f", size = 63529, upload-time = "2026-02-18T01:08:08.791Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/00/3767393ece946084e1c6830a33ffb8e39d68642e27ad5ac7d4c8bd5de866/django_environ-0.13.0-py3-none-any.whl", hash = "sha256:37799d14cd78222c6fd8298e48bfe17965ff8e586091ad66a463e52e0e7b799e", size = 20682, upload-time = "2026-02-18T01:08:07.359Z" },
]
[[package]] [[package]]
name = "django-ninja" name = "django-ninja"
version = "1.6.2" version = "1.6.2"
@@ -536,6 +545,7 @@ dependencies = [
{ name = "celery" }, { name = "celery" },
{ name = "django" }, { name = "django" },
{ name = "django-compressor" }, { name = "django-compressor" },
{ name = "django-environ" },
{ name = "django-ninja" }, { name = "django-ninja" },
{ name = "django-redis" }, { name = "django-redis" },
{ name = "django-storages", extra = ["s3"] }, { name = "django-storages", extra = ["s3"] },
@@ -554,6 +564,7 @@ requires-dist = [
{ name = "celery", specifier = "==5.6.3" }, { name = "celery", specifier = "==5.6.3" },
{ name = "django", specifier = "==6.0.5" }, { name = "django", specifier = "==6.0.5" },
{ name = "django-compressor", specifier = "==4.6.0" }, { name = "django-compressor", specifier = "==4.6.0" },
{ name = "django-environ", specifier = ">=0.13.0" },
{ name = "django-ninja", specifier = ">=1.6.2" }, { name = "django-ninja", specifier = ">=1.6.2" },
{ name = "django-redis", specifier = "==6.0.0" }, { name = "django-redis", specifier = "==6.0.0" },
{ name = "django-storages", extras = ["s3"], specifier = "==1.14.6" }, { name = "django-storages", extras = ["s3"], specifier = "==1.14.6" },