Compare commits

...

32 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
elordenador a61664a46e a 2026-05-26 12:08:06 +02:00
elordenador 1a73a9e373 fix: replace random module with secrets for secure code generation in VerificationCode 2026-05-26 12:02:36 +02:00
elordenador 4877e859bd fix: update HTTP method requirements for borrar_producto and eliminar_direccion views to require POST only 2026-05-26 12:01:15 +02:00
elordenador 848a49c92d feat: add BlankToNoneCharField for handling empty strings in models and update Cart model to use it
fix: update view functions to require appropriate HTTP methods
2026-05-26 11:48:04 +02:00
elordenador ac9efaaf91 fix: update delete review URL to use review ID instead of product ID 2026-05-26 10:35:17 +02:00
elordenador 2024e2f90c fix: update session_key fields in Cart, Order, and StockReservation models for consistency 2026-05-26 10:29:06 +02:00
elordenador 6ec0f4e732 feat: add constants for image types and error messages in forms 2026-05-26 10:19:21 +02:00
elordenador 35e7e93600 fix: remove redundant type annotations for user in UserAdmin actions 2026-05-26 10:12:28 +02:00
elordenador a7f43483f0 refactor: remove obsolete service.sh script 2026-05-26 10:11:42 +02:00
elordenador d773addc53 fix: update database configuration to support PostgreSQL toggle 2026-05-26 10:10:45 +02:00
elordenador b143d92cb2 fix: consolidate RUN commands in Dockerfile for improved layer caching 2026-05-26 10:08:41 +02:00
31 changed files with 623 additions and 682 deletions
+11 -1
View File
@@ -6,4 +6,14 @@ venv
.venv
db.sqlite3
static
media
media
docs
logs
staticfiles
.gitignore
AGENTS.md
Dockerfile
Makefile
nginx.conf
Procfile
uv.lock
+7 -7
View File
@@ -14,13 +14,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout del código
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Configurar Python
uses: actions/setup-python@v6
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.14'
- name: Configurar uv
uses: astral-sh/setup-uv@v6
uses: astral-sh/setup-uv@d0d8abe699bfb85fec6de9f7adb5ae17292296ff # v6
- name: Instalar dependencias
run: |
uv sync --no-dev --no-install-project
@@ -28,7 +28,7 @@ jobs:
env:
DJANGO_SETTINGS_MODULE: proyecto.settings
run: |
uv run python manage.py test
SECRET_KEY=testkeynotuseinproducto uv run python manage.py test
docker:
runs-on: ubuntu-latest
@@ -38,13 +38,13 @@ jobs:
steps:
- name: Checkout del código
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Configurar Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
- name: Build (sin push)
uses: docker/build-push-action@v6
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with:
context: .
push: false
+8 -8
View File
@@ -13,13 +13,13 @@ jobs:
contents: read
steps:
- name: Checkout del código
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Configurar Python
uses: actions/setup-python@v6
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.14'
- name: Configurar uv
uses: astral-sh/setup-uv@v6
uses: astral-sh/setup-uv@d0d8abe699bfb85fec6de9f7adb5ae17292296ff # v6
- name: Instalar dependencias
run: |
uv sync --no-dev --no-install-project
@@ -27,7 +27,7 @@ jobs:
env:
DJANGO_SETTINGS_MODULE: proyecto.settings
run: |
uv run python manage.py test
SECRET_KEY=donotusethisinproductionitisunsafe uv run python manage.py test
docker:
runs-on: ubuntu-latest
needs: test
@@ -37,13 +37,13 @@ jobs:
steps:
- name: Checkout del código
uses: actions/checkout@v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Configurar Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
- name: Login en GHCR
uses: docker/login-action@v4
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -55,7 +55,7 @@ jobs:
echo "IMAGE_TAG=$TAG" >> $GITHUB_ENV
- name: Build y Push
uses: docker/build-push-action@v6
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with:
context: .
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
+21 -4
View File
@@ -5,16 +5,33 @@ ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY pyproject.toml uv.lock /app/
RUN apk --no-cache update && apk --no-cache upgrade
RUN pip install --no-cache-dir uv
RUN uv sync --no-dev --no-install-project # Install only dependencies, not the local project package
COPY . /app/
RUN apk --no-cache update \
&& apk --no-cache upgrade \
&& apk --no-cache add \
build-base \
freetype-dev \
jpeg-dev \
zlib-dev \
&& pip install --no-cache-dir uv \
&& uv sync --no-dev --no-install-project # Install only dependencies, not the local project package
COPY ./entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
COPY ./proyecto /app/proyecto
COPY ./tienda /app/tienda
COPY ./manage.py /app/manage.py
EXPOSE 8000
RUN mkdir -pv /fonts
COPY tienda/static/fonts/ /fonts/
RUN addgroup -S app \
&& adduser -S app -G app \
&& chown -R app:app /app /fonts
USER app
ENTRYPOINT ["/bin/sh", "/app/entrypoint.sh"]
+71 -109
View File
@@ -11,83 +11,47 @@ https://docs.djangoproject.com/en/6.0/ref/settings/
"""
import logging
import os, sys
import os
import sys
from pathlib import Path
import environ
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
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'.
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
# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/
# 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!
DEBUG = env_bool('DEBUG', True)
S3_ENABLE = env_bool('S3_ENABLE', False)
S3_USE_LOCAL_URLS = env_bool('S3_USE_LOCAL_URLS', False)
DEBUG = env.bool('DEBUG')
S3_ENABLE = env.bool('S3_ENABLE')
S3_USE_LOCAL_URLS = env.bool('S3_USE_LOCAL_URLS')
ALLOWED_HOSTS = env_list('ALLOWED_HOSTS', [
ALLOWED_HOSTS = env.list('ALLOWED_HOSTS', default=[
'localhost',
'127.0.0.1',
'zkqpv8r3-8000.uks1.devtunnels.ms'
])
@@ -147,33 +111,25 @@ WSGI_APPLICATION = 'proyecto.wsgi.application'
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases
# Usa PostgreSQL por defecto (POSTGRES_ENABLED=True); si no, SQLite.
if RUNNING_TESTS:
if RUNNING_TESTS or not env.bool('POSTGRES_ENABLED'):
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}
elif env_bool('POSTGRES_ENABLED', True):
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': os.getenv('POSTGRES_DB', 'tienda'),
'USER': os.getenv('POSTGRES_USER', 'postgres'),
'PASSWORD': os.getenv('POSTGRES_PASSWORD', ''),
'HOST': os.getenv('POSTGRES_HOST', '127.0.0.1'),
'PORT': env_int('POSTGRES_PORT', 5432),
}
}
else:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
'ENGINE': 'django.db.backends.postgresql',
'NAME': env('POSTGRES_DB', default='tienda'),
'USER': env('POSTGRES_USER', default='postgres'),
'PASSWORD': env('POSTGRES_PASSWORD', default=''),
'HOST': env('POSTGRES_HOST', default='127.0.0.1'),
'PORT': env.int('POSTGRES_PORT'),
}
}
# Password validation
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators
@@ -208,10 +164,10 @@ USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/6.0/howto/static-files/
STATIC_URL = 'static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'
COMPRESS_ROOT = STATIC_ROOT
COMPRESS_URL = STATIC_URL
STATICFILES_DIRS = [
BASE_DIR / 'tienda' / 'static',
]
@@ -230,15 +186,15 @@ STORAGES = {
}
if S3_ENABLE:
AWS_STORAGE_BUCKET_NAME = env_str('AWS_STORAGE_BUCKET_NAME') or None
AWS_ACCESS_KEY_ID = env_optional_str('AWS_ACCESS_KEY_ID')
AWS_SECRET_ACCESS_KEY = env_optional_str('AWS_SECRET_ACCESS_KEY')
AWS_S3_REGION_NAME = env_optional_str('AWS_S3_REGION_NAME')
AWS_S3_ENDPOINT_URL = env_optional_str('AWS_S3_ENDPOINT_URL')
AWS_S3_CUSTOM_DOMAIN = env_optional_str('AWS_S3_CUSTOM_DOMAIN')
AWS_S3_USE_SSL = env_bool('AWS_S3_USE_SSL', True)
AWS_QUERYSTRING_AUTH = env_bool('AWS_QUERYSTRING_AUTH', False)
AWS_DEFAULT_ACL = env_str('AWS_DEFAULT_ACL', 'public-read') or None
AWS_STORAGE_BUCKET_NAME = env('AWS_STORAGE_BUCKET_NAME', default='') or None
AWS_ACCESS_KEY_ID = env('AWS_ACCESS_KEY_ID', default=None)
AWS_SECRET_ACCESS_KEY = env('AWS_SECRET_ACCESS_KEY', default=None)
AWS_S3_REGION_NAME = env('AWS_S3_REGION_NAME', default=None)
AWS_S3_ENDPOINT_URL = env('AWS_S3_ENDPOINT_URL', default=None)
AWS_S3_CUSTOM_DOMAIN = env('AWS_S3_CUSTOM_DOMAIN', default=None)
AWS_S3_USE_SSL = env.bool('AWS_S3_USE_SSL')
AWS_QUERYSTRING_AUTH = env.bool('AWS_QUERYSTRING_AUTH')
AWS_DEFAULT_ACL = env('AWS_DEFAULT_ACL', default='public-read') or None
AWS_S3_OBJECT_PARAMETERS = {}
STORAGES = {
@@ -250,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 = [
'django.contrib.staticfiles.finders.FileSystemFinder',
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
@@ -258,15 +222,13 @@ STATICFILES_FINDERS = [
COMPRESS_PRECOMPILERS = ()
# Media files (User uploads)
MEDIA_URL = 'media/'
MEDIA_ROOT = Path(os.getenv('MEDIA_ROOT', '/app/media'))
MEDIA_ROOT = Path(env('MEDIA_ROOT', default='/app/media'))
# Redis Configuration
CACHES = {
'default': {
'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': {
'CLIENT_CLASS': 'django_redis.client.DefaultClient',
}
@@ -291,30 +253,30 @@ MESSAGE_TAGS = {
# Login URL
LOGIN_URL = '/tienda/login/'
STRIPE_PUBLISHABLE_KEY = os.getenv('STRIPE_PUBLISHABLE_KEY', '')
STRIPE_SECRET_KEY = os.getenv('STRIPE_SECRET_KEY', '')
STRIPE_PUBLISHABLE_KEY = env('STRIPE_PUBLISHABLE_KEY', default='')
STRIPE_SECRET_KEY = env('STRIPE_SECRET_KEY', default='')
# PayPal Configuration (Sandbox)
# 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_SECRET = os.getenv('PAYPAL_CLIENT_SECRET', '') # Reemplazar con tu Client Secret de PayPal Sandbox
PAYPAL_MODE = os.getenv('PAYPAL_MODE', 'sandbox') # Cambiar a 'live' en producción
PAYPAL_CLIENT_ID = env('PAYPAL_CLIENT_ID', default='') # Reemplazar con tu Client ID de PayPal Sandbox
PAYPAL_CLIENT_SECRET = env('PAYPAL_CLIENT_SECRET', default='') # Reemplazar con tu Client Secret de PayPal Sandbox
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_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)
SMTP_ENDPOINT = env('SMTP_ENDPOINT', default='smtp.email.eu-paris-1.oci.oraclecloud.com')
SMTP_PORT = env.int('SMTP_PORT')
SECURITY = env('SECURITY', default='tls')
SMTP_USERNAME = env('SMTP_USERNAME', default=None)
SMTP_PASSWORD = env('SMTP_PASSWORD', default=None)
SMTP_EMAIL = env('SMTP_EMAIL', default=None)
AUTH_USER_MODEL = 'tienda.User'
DOMAIN = os.getenv("DOMAIN", "localhost")
PROTOCOL = os.getenv("PROTOCOL", "http")
DOMAIN = env('DOMAIN', default='localhost')
PROTOCOL = env('PROTOCOL', default='http')
default_csrf_trusted_origins = []
if DOMAIN:
@@ -324,16 +286,16 @@ for host in ALLOWED_HOSTS:
if host and host != '*':
default_csrf_trusted_origins.append(f"{PROTOCOL}://{host}")
CSRF_TRUSTED_ORIGINS = env_list(
CSRF_TRUSTED_ORIGINS = env.list(
'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_DIR = Path(os.getenv('LOG_DIR', BASE_DIR / 'logs'))
LOG_LEVEL = env('LOG_LEVEL', default='INFO').upper()
LOG_DIR = Path(env('LOG_DIR', default=str(BASE_DIR / 'logs')))
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 = {
@@ -407,13 +369,13 @@ 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") 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)
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
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
CELERY_ACCEPT_CONTENT = ['json']
+2 -1
View File
@@ -7,6 +7,7 @@ dependencies = [
"celery==5.6.3",
"Django==6.0.5",
"django-compressor==4.6.0",
"django-environ>=0.13.0",
"django-ninja>=1.6.2",
"django-redis==6.0.0",
# S3 backend requerido por tienda/storage_backends.py cuando S3_ENABLE=True.
@@ -17,7 +18,7 @@ dependencies = [
"pillow==12.2.0",
"psycopg2-binary==2.9.12",
"requests==2.34.2",
"stripe==15.1.0",
"stripe==15.2.0",
"whitenoise==6.12.0",
]
-101
View File
@@ -1,101 +0,0 @@
#!/bin/bash
set -u
readonly HOSTS=(
"aws-docker-mysql"
"aws-docker-redis"
"aws-docker-celery"
"aws-docker"
)
readonly WAIT_SECONDS=5
readonly REMOTE_DEPLOY_DIR="/root/deploys"
usage() {
echo "Uso: $0 {start|stop|restart|update}"
}
print_status() {
local action="$1"
local host="$2"
local status="$3"
# Estilo similar al output de OpenRC.
printf "* %-8s %-16s [%s]\n" "$action" "$host" "$status"
}
run_remote_compose() {
local host="$1"
local command="$2"
ssh -o BatchMode=yes -o LogLevel=ERROR -T "$host" "sudo -n sh -c \"cd '$REMOTE_DEPLOY_DIR' || exit 1; if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then docker compose $command; elif command -v docker-compose >/dev/null 2>&1; then docker-compose $command; else exit 1; fi\"" >/dev/null 2>&1
}
run_for_all_hosts() {
local mode="$1"
local host=""
local i=0
local total=${#HOSTS[@]}
for host in "${HOSTS[@]}"; do
case "$mode" in
start)
if run_remote_compose "$host" "up -d"; then
print_status "Started" "$host" "ok"
else
print_status "Started" "$host" "fail"
exit 1
fi
;;
stop)
if run_remote_compose "$host" "down"; then
print_status "Stopped" "$host" "ok"
else
print_status "Stopped" "$host" "fail"
exit 1
fi
;;
restart)
if run_remote_compose "$host" "down" && run_remote_compose "$host" "up -d"; then
print_status "Restarted" "$host" "ok"
else
print_status "Restarted" "$host" "fail"
exit 1
fi
;;
update)
if run_remote_compose "$host" "pull" && run_remote_compose "$host" "down" && run_remote_compose "$host" "up -d"; then
print_status "Updated" "$host" "ok"
else
print_status "Updated" "$host" "fail"
exit 1
fi
;;
*)
usage
exit 1
;;
esac
i=$((i + 1))
if [ "$i" -lt "$total" ]; then
sleep "$WAIT_SECONDS"
fi
done
}
if [ "$#" -ne 1 ]; then
usage
exit 1
fi
case "$1" in
start|stop|restart|update)
run_for_all_hosts "$1"
;;
*)
usage
exit 1
;;
esac
-2
View File
@@ -20,7 +20,6 @@ class UserAdmin(admin.ModelAdmin):
def banear_usuario_action(self, request, queryset):
usuarios_baneados = 0
for user in queryset:
user: User = user
# Desactiva usuario
if user.registration_status == User.RegisterStatus.BANNED:
continue
@@ -43,7 +42,6 @@ class UserAdmin(admin.ModelAdmin):
def desbanear_usuario_action(self, request, queryset):
user_desbaneados = 0
for user in queryset:
user: User = user
if user.registration_status != User.RegisterStatus.BANNED:
continue
+3
View File
@@ -0,0 +1,3 @@
IMAGE_TYPE = "image/*"
EMAIL_FORMNAME = "Correo Electrónico"
INCORRECT_PASSWORDS = "Las contraseñas no coinciden"
+12 -9
View File
@@ -2,10 +2,13 @@ from django import forms
from django.core.exceptions import ValidationError
from django.core.validators import FileExtensionValidator, MinLengthValidator, MaxLengthValidator
from .models import Category
from .constants import IMAGE_TYPE, EMAIL_FORMNAME, INCORRECT_PASSWORDS
ALLOWED_IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp']
ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
def validate_image_file(value):
ext = value.name.split('.')[-1].lower()
if ext not in ALLOWED_IMAGE_EXTENSIONS:
@@ -76,7 +79,7 @@ class ProductForm(forms.Form):
widget = forms.ClearableFileInput(
attrs = {
'class': 'form-control',
'accept': 'image/*'
'accept': IMAGE_TYPE
}
)
)
@@ -133,7 +136,7 @@ class SecondaryImageForm(forms.Form):
widget = forms.ClearableFileInput(
attrs = {
'class': 'form-control',
'accept': 'image/*'
'accept': IMAGE_TYPE
}
)
)
@@ -192,7 +195,7 @@ class UserRegisterForm(forms.Form):
)
)
email = forms.EmailField(
label = "Correo Electrónico",
label = EMAIL_FORMNAME,
max_length = 255,
required = True,
widget = forms.TextInput(
@@ -236,7 +239,7 @@ class UserRegisterForm(forms.Form):
password = cleaned_data.get("password")
password_confirm = cleaned_data.get("password_confirm")
if password and password_confirm and password != password_confirm:
raise ValidationError("Las contraseñas no coinciden.")
raise ValidationError(INCORRECT_PASSWORDS)
class EditProfileForm(forms.Form):
@@ -253,7 +256,7 @@ class EditProfileForm(forms.Form):
widget=forms.TextInput(attrs={'class': 'form-control'})
)
email = forms.EmailField(
label="Correo Electrónico",
label=EMAIL_FORMNAME,
max_length=254,
required=True,
widget=forms.EmailInput(attrs={'class': 'form-control'})
@@ -285,7 +288,7 @@ class ChangePasswordForm(forms.Form):
new_password = cleaned_data.get("new_password")
confirm_password = cleaned_data.get("confirm_password")
if new_password and confirm_password and new_password != confirm_password:
raise ValidationError("Las contraseñas no coinciden.")
raise ValidationError(INCORRECT_PASSWORDS)
if new_password and len(new_password) < 8:
raise ValidationError("La contraseña debe tener al menos 8 caracteres.")
@@ -343,7 +346,7 @@ class ShippingAddressForm(forms.Form):
class ResetPasswordForm(forms.Form):
email = forms.EmailField(
label="Correo Electrónico",
label=EMAIL_FORMNAME,
max_length=254,
required=True,
widget=forms.EmailInput(attrs={'class': 'form-control', 'placeholder': 'tu@email.com'})
@@ -369,7 +372,7 @@ class ResetPasswordPhase2Form(forms.Form):
password = cleaned_data.get("password")
verify_password = cleaned_data.get("verify_password")
if password and verify_password and password != verify_password:
raise ValidationError("Las contraseñas no coinciden.")
raise ValidationError(INCORRECT_PASSWORDS)
class ReviewForm(forms.Form):
@@ -0,0 +1,28 @@
# Generated by Django 6.0.5 on 2026-05-26 08:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tienda', '0009_alter_cartitem_quantity_alter_orderitem_quantity_and_more'),
]
operations = [
migrations.AlterField(
model_name='cart',
name='session_key',
field=models.CharField(blank=True, default='', max_length=40),
),
migrations.AlterField(
model_name='order',
name='session_key',
field=models.CharField(blank=True, default='', max_length=40),
),
migrations.AlterField(
model_name='stockreservation',
name='session_key',
field=models.CharField(blank=True, default='', max_length=40),
),
]
+39 -5
View File
@@ -6,11 +6,37 @@ from django.contrib.auth.models import User, AbstractUser
from django.core.validators import MaxValueValidator
from django.utils.crypto import get_random_string
from .vars import VAT_RATE, TRANSACTION_CODE_PREFIX, TRANSACTION_CODE_LENGTH, TRANSACTION_CODE_ALPHABET
import random, string
import secrets
import string
MAX_QUANTITY = 9999
class BlankToNoneCharField(models.CharField):
"""Treat empty strings as None in Python, but store as empty strings in DB."""
def to_python(self, value):
value = super().to_python(value)
if value == "":
return None
return value
def from_db_value(self, value, expression, connection):
if value == "":
return None
return value
def get_prep_value(self, value):
if value is None or value == "":
return ""
return super().get_prep_value(value)
def deconstruct(self):
name, path, args, kwargs = super().deconstruct()
path = "django.db.models.CharField"
return name, path, args, kwargs
def generate_transaction_code() -> str:
while True:
code = f"{TRANSACTION_CODE_PREFIX}{get_random_string(TRANSACTION_CODE_LENGTH, TRANSACTION_CODE_ALPHABET)}"
@@ -48,9 +74,10 @@ class VerificationCode(models.Model):
default = VerificationModes.VERIFY_ACCOUNT
)
@staticmethod
def generate(user: User, code_mode: str) -> VerificationCode:
while True:
code = "".join(random.choices(string.ascii_letters+string.digits, k=64))
code = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(64))
if not VerificationCode.objects.filter(code=code).exists():
return VerificationCode.objects.create(
code = code,
@@ -163,7 +190,7 @@ class StockReservation(models.Model):
]
user = models.ForeignKey(User, on_delete=models.CASCADE, null=True, blank=True, related_name="stock_reservations")
session_key = models.CharField(max_length=40, null=True, blank=True)
session_key = models.CharField(max_length=40, default="", blank=True)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default=STATUS_ACTIVE)
payment_method = models.CharField(max_length=20, choices=PAYMENT_CHOICES)
expires_at = models.DateTimeField(db_index=True)
@@ -193,9 +220,16 @@ class StockReservationItem(models.Model):
class Cart(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, null=True, blank=True)
session_key = models.CharField(max_length=40, null=True, blank=True)
session_key = BlankToNoneCharField(max_length=40, default="", blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def save(self, *args, **kwargs):
if self.session_key is None:
self.session_key = ""
super().save(*args, **kwargs)
if self.session_key == "":
self.session_key = None
def __str__(self):
return f"Cart {self.id} - {self.user or self.session_key}"
@@ -258,7 +292,7 @@ class Order(models.Model):
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, default="", blank=True)
total = models.FloatField(default=0)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default=STATUS_PAID)
payment_method = models.CharField(max_length=20, choices=PAYMENT_CHOICES, default=PAYMENT_MANUAL)
+5 -2
View File
@@ -17,15 +17,18 @@ class Recibo(FPDF):
def generar_recibo(cliente: str, total: float, objetos: list, metodo_pago: str, transaction_code: str):
pdf = Recibo()
font_path = "/fonts/Roboto-Regular.ttf"
pdf.add_font('Roboto', '', '/fonts/Roboto-Regular.ttf')
pdf.add_font('Roboto', 'B', '/fonts/Roboto-Bold.ttf')
pdf.add_page()
pdf.set_font('Roboto', size=12)
METODOS_MAP = {"stripe": "Stripe", "paypal": "PayPal", "manual": "Manual"}
metodo_mostrar = METODOS_MAP.get(metodo_pago, metodo_pago)
pdf.cell(0, 10, f"Cliente: {cliente}", ln=True)
pdf.cell(0, 10, f"ID de transaccion: {transaction_code}", ln=True)
pdf.cell(0, 10, f"")
pdf.cell(0, 10, f"Metodo de pago: {metodo_mostrar}", ln=True)
pdf.cell(0, 10, "")
DATA = []
DATA.append(
+4 -3
View File
@@ -4,7 +4,8 @@ from django.template.loader import render_to_string
from django.core.mail import EmailMessage
from .utilities import send_email, send_hemail
from .vars import login_message, verify_message
import random, string
import secrets
import string
from . import pdf
from .models import User, VerificationCode
@@ -43,7 +44,7 @@ def enviar_correo_confirmacion(id: int):
code = VerificationCode.objects.create(
user = usuario,
code_mode = VerificationCode.VerificationModes.VERIFY_ACCOUNT,
code = ''.join(random.choices(string.digits, k=12))
code = ''.join(secrets.choice(string.digits) for _ in range(12))
)
message = verify_message.format(name = usuario.get_full_name(), protocol = settings.PROTOCOL, domain = settings.DOMAIN, code = code.code)
@@ -60,7 +61,7 @@ def enviar_correo_recuperacion(email: str):
ver_code = VerificationCode.objects.create(
code_mode = VerificationCode.VerificationModes.RESET_PASSWORD,
user = usuario,
code = ''.join(random.choices(string.digits, k=12))
code = ''.join(secrets.choice(string.digits) for _ in range(12))
)
ver_code.save()
html_content = render_to_string(
+12 -9
View File
@@ -22,13 +22,16 @@
{% csrf_token %}
<div class="mb-4">
<label class="form-label">Puntuación</label>
<div class="star-rating d-flex gap-1" id="star-rating">
{% 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>
{% endfor %}
</div>
<label class="form-label" for="rating-input">
Puntuación
<div class="star-rating d-flex gap-1" id="star-rating">
{% 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>
{% endfor %}
</div>
<input type="hidden" name="rating" id="rating-input" value="{{ form.initial.rating|default:1 }}">
</label>
{% if form.rating.errors %}
<div class="text-danger small">{{ form.rating.errors }}</div>
{% endif %}
@@ -83,12 +86,12 @@ document.addEventListener('DOMContentLoaded', function() {
stars.forEach(star => {
star.addEventListener('click', function() {
const value = parseInt(this.dataset.value);
const value = Number.parseInt(this.dataset.value);
updateStars(value);
});
star.addEventListener('mouseenter', function() {
const value = parseInt(this.dataset.value);
const value = Number.parseInt(this.dataset.value);
stars.forEach((s, index) => {
if (index < value) {
s.classList.remove('text-secondary');
@@ -98,7 +101,7 @@ document.addEventListener('DOMContentLoaded', 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>
<div class="mb-3">
<label class="form-label">Datos de la tarjeta</label>
<div id="card-element"></div>
<label id="label-card-data" class="form-label">Datos de la tarjeta <input type="hidden"></label>
<div id="card-element" aria-labelledby="label-card-data"></div>
<div id="card-errors" role="alert"></div>
</div>
+3 -2
View File
@@ -6,6 +6,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Comercialmeria</title>
<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 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 -->
<form class="search-suggestions-container" method="GET" action="{% url 'search' %}" role="search" id="searchForm">
<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">
<button class="btn btn-outline-primary" type="submit" aria-label="Buscar productos">🔍 Buscar</button>
<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" id="searchbutton" aria-label="Buscar productos">🔍 Buscar</button>
</div>
<div class="search-suggestions" id="searchSuggestions" role="listbox" aria-label="Sugerencias de búsqueda"></div>
</form>
+16 -16
View File
@@ -84,11 +84,11 @@
<table class="table table-striped align-middle">
<thead>
<tr>
<th>Producto</th>
<th class="text-end">Precio (sin IVA)</th>
<th class="text-end">Cantidad</th>
<th class="text-end">Stock actual</th>
<th class="text-end">Subtotal (con IVA)</th>
<th scope="col">Producto</th>
<th scope="col" class="text-end">Precio (sin IVA)</th>
<th scope="col" class="text-end">Cantidad</th>
<th scope="col" class="text-end">Stock actual</th>
<th scope="col" class="text-end">Subtotal (con IVA)</th>
</tr>
</thead>
<tbody>
@@ -104,16 +104,16 @@
</tbody>
<tfoot>
<tr>
<th colspan="4" class="text-end">Subtotal:</th>
<th class="text-end">{{ cart.get_total|format_price }}€</th>
<th colspan="4" scope="row" class="text-end">Subtotal:</th>
<td class="text-end">{{ cart.get_total|format_price }}€</th>
</tr>
<tr>
<th colspan="4" class="text-end">IVA (21%):</th>
<th class="text-end text-success">+{{ cart.get_vat_amount|format_price }}€</th>
<th scope="row" colspan="4" class="text-end">IVA (21%):</th>
<td class="text-end text-success">+{{ cart.get_vat_amount|format_price }}€</th>
</tr>
<tr style="background-color: #f8f9fa;">
<th 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>
<th scope="row" colspan="4" class="text-end" style="font-size: 1.1rem;">Total:</th>
<td class="text-end" style="font-size: 1.1rem;">{{ cart.get_total_with_vat|format_price }}€</th>
</tr>
</tfoot>
</table>
@@ -125,7 +125,7 @@
<h5 class="card-title mb-3">2) Selecciona tu método de pago</h5>
<!-- 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">
<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">
@@ -142,7 +142,7 @@
<!-- Tarjeta tab -->
<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 %}
<fieldset class="mb-3">
<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 class="mb-3">
<label class="form-label">Número de tarjeta</label>
<div id="card-element"></div>
<label id="label-card-number" class="form-label">Número de tarjeta <input type="hidden"></label>
<div id="card-element" aria-labelledby="label-card-number"></div>
<div id="card-errors" role="alert"></div>
</div>
<div class="form-check mb-3">
@@ -187,7 +187,7 @@
<!-- PayPal tab -->
<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 %}
<div class="alert alert-light border mb-3">
<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">
<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;">
<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>
</td>
</th>
</tr>
<tr>
<td align="center" style="padding: 40px">
@@ -22,6 +22,6 @@
</td>
</tr>
</table>
</td>
</th>
</tr>
</table>
+4 -4
View File
@@ -1,11 +1,11 @@
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<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;">
<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>
</td>
</th>
</tr>
<tr>
<td style="padding: 30px; font-family: sans-serif; line-height: 1.5; color: #444444;">
@@ -16,6 +16,6 @@
</td>
</tr>
</table>
</td>
</th>
</tr>
</table>
@@ -1,11 +1,11 @@
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<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;">
<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>
</td>
</th>
</tr>
<tr>
<td align="center" style="padding: 40px">
@@ -22,6 +22,6 @@
</td>
</tr>
</table>
</td>
</th>
</tr>
</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>
<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;">
<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>
</td>
</th>
</tr>
<tr>
<td align="center" style="padding: 40px">
@@ -22,6 +22,6 @@
</td>
</tr>
</table>
</td>
</th>
</tr>
</table>
+4 -4
View File
@@ -1,11 +1,11 @@
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<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;">
<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>
</td>
</th>
</tr>
<tr>
<td align="center" style="padding: 40px">
@@ -21,6 +21,6 @@
</td>
</tr>
</table>
</td>
</th>
</tr>
</table>
@@ -18,7 +18,7 @@
</div>
<div class="card-body">
{% 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>
{% else %}
<p class="text-muted">No hay imagen principal asignada.</p>
+3 -3
View File
@@ -78,7 +78,7 @@
{% elif user_has_review %}
<div class="ms-auto">
<a href="{% url 'add_review' product.id %}" class="btn btn-sm btn-outline-primary">Editar mi valoración</a>
<form method="post" action="{% url 'delete_review' product.id %}" style="display:inline;">
<form method="post" action="{% url 'delete_review' user_review_id %}" style="display:inline;">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-outline-danger" onclick="return confirm('¿Eliminar esta valoración?');">Eliminar</button>
</form>
@@ -94,7 +94,7 @@
</div>
</div>
<script>
<script type="module">
async function loadReviews() {
try {
const response = await fetch("{% url 'product_reviews' product.id %}");
@@ -157,6 +157,6 @@ async function loadReviews() {
}
}
loadReviews();
await loadReviews();
</script>
{% endblock %}
+5 -4
View File
@@ -16,6 +16,7 @@ from .models import (
)
from .forms import UserRegisterForm, UserLoginForm, EditProfileForm, ChangePasswordForm, ShippingAddressForm, ResetPasswordForm, ResetPasswordPhase2Form
from .vars import VAT_RATE, TRANSACTION_CODE_PREFIX
import secrets
import string
import random
@@ -335,7 +336,7 @@ class VerificationCodeModelTests(TestCase):
"""50 códigos pueden crearse sin conflictos."""
codes = []
for i in range(50):
mode = random.choice([
mode = secrets.choice([
VerificationCode.VerificationModes.VERIFY_ACCOUNT,
VerificationCode.VerificationModes.RESET_PASSWORD
])
@@ -377,7 +378,7 @@ class CategoryModelTests(TestCase):
"""100 categorías pueden crearse sin problemas."""
categories = []
for i in range(100):
cat = Category.objects.create(name=f"Category_{i}_{random.randint(1000, 9999)}")
cat = Category.objects.create(name=f"Category_{i}_{1000 + secrets.randbelow(9000)}")
categories.append(cat)
self.assertEqual(len(categories), 100)
@@ -1786,7 +1787,7 @@ class EndpointViewTests(TestCase):
self.assertTrue(OrderMessage.objects.filter(order_item=item, sender=self.seller).exists())
delete_get = self.client.get(reverse("borrar_producto", args=[created.id]))
self.assertEqual(delete_get.status_code, 302)
self.assertEqual(delete_get.status_code, 405)
delete_post = self.client.post(reverse("borrar_producto", args=[created.id]))
self.assertEqual(delete_post.status_code, 302)
self.assertFalse(Product.objects.filter(id=created.id).exists())
@@ -2068,7 +2069,7 @@ class EndpointViewTests(TestCase):
self.assertEqual(new_address.full_name, "Comprador Dos Editado")
delete_get = self.client.get(reverse("eliminar_direccion", args=[new_address.id]))
self.assertEqual(delete_get.status_code, 302)
self.assertEqual(delete_get.status_code, 405)
delete_post = self.client.post(reverse("eliminar_direccion", args=[new_address.id]))
self.assertEqual(delete_post.status_code, 302)
self.assertFalse(ShippingAddress.objects.filter(id=new_address.id).exists())
+1 -1
View File
@@ -70,6 +70,6 @@ urlpatterns = [
path("reset-password", views.reset_password, name="reset_password"),
path("reset-password-phase2/<str:code>", views.reset_password_phase2, name="reset_password_phase2"),
path("producto/<int:product_id>/valorar/", views.add_review, name="add_review"),
path("producto/<int:product_id>/valorar/eliminar/", views.delete_review, name="delete_review"),
path("valoracion/<int:review_id>/eliminar/", views.delete_review, name="delete_review"),
path("api/producto/<int:product_id>/valoraciones/", views.product_reviews, name="product_reviews"),
]
+2 -26
View File
@@ -4,30 +4,6 @@ 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:
@@ -40,7 +16,7 @@ def send_email(dest: str, title: str, body: str):
)
logger.info("EMAIL_SENT to=%s subject=%s", dest, title)
return (True,)
return (True, None)
except Exception as e:
logger.exception("EMAIL_SEND_FAILED to=%s subject=%s error=%s", dest, title, str(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)
return (True,)
return (True, None)
except Exception as e:
logger.exception("EMAIL_SEND_FAILED to=%s subject=%s error=%s", dest, title, str(e))
return (False, e)
+335 -312
View File
File diff suppressed because it is too large Load Diff
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" },
]
[[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]]
name = "django-ninja"
version = "1.6.2"
@@ -536,6 +545,7 @@ dependencies = [
{ name = "celery" },
{ name = "django" },
{ name = "django-compressor" },
{ name = "django-environ" },
{ name = "django-ninja" },
{ name = "django-redis" },
{ name = "django-storages", extra = ["s3"] },
@@ -554,6 +564,7 @@ requires-dist = [
{ name = "celery", specifier = "==5.6.3" },
{ name = "django", specifier = "==6.0.5" },
{ name = "django-compressor", specifier = "==4.6.0" },
{ name = "django-environ", specifier = ">=0.13.0" },
{ name = "django-ninja", specifier = ">=1.6.2" },
{ name = "django-redis", specifier = "==6.0.0" },
{ name = "django-storages", extras = ["s3"], specifier = "==1.14.6" },