feat: warn user about email change
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
# Crochet
|
||||
|
||||
Aplicación web para diseñar patrones de crochet con un editor visual,
|
||||
exportarlos a PDF y compartirlos con un enlace de solo lectura. Incluye
|
||||
cuentas de usuario para guardar y gestionar los propios patrones.
|
||||
|
||||
## Funcionalidades
|
||||
|
||||
- **Editor visual de patrones**: secciones de título, subtítulo, texto,
|
||||
nota, materiales, imágenes y "patrón" (puntos con contador), todo
|
||||
reordenable por arrastre. El contenido se guarda bilingüe (es/en).
|
||||
- **Personalización de página**: título, autor, imagen de portada, color de
|
||||
texto/acento/fondo, tipografía, tamaño de letra, alineación, tamaño y
|
||||
orientación de página.
|
||||
- **Exportación a PDF** con WeasyPrint, usando el propio diálogo de
|
||||
impresión del navegador.
|
||||
- **Vista de solo lectura compartible** (`pattern/<uuid>/`), sin necesidad
|
||||
de cuenta ni de cargar el editor.
|
||||
- **Cuentas de usuario**: registro (con email, necesario para poder
|
||||
recuperar la contraseña), login/logout, ajustes de cuenta (cambiar
|
||||
email/contraseña, con HTMX) y recuperación de contraseña por email.
|
||||
Cada patrón pertenece a quien lo creó; solo su propietario puede
|
||||
editarlo o borrarlo.
|
||||
- **"Mis patrones"**: listado de los patrones propios con portada, fecha de
|
||||
actualización y accesos directos a editar/ver/eliminar.
|
||||
- **i18n completo** (español/inglés): interfaz, URLs traducidas
|
||||
(`/es/patron/...` frente a `/en/pattern/...`) y contenido del propio
|
||||
patrón.
|
||||
|
||||
## Stack técnico
|
||||
|
||||
- **Backend**: Django 6, servido en ASGI con Uvicorn.
|
||||
- **Base de datos**: PostgreSQL en producción, SQLite por defecto en
|
||||
desarrollo (configurable con `DATABASE_URL`).
|
||||
- **Frontend del editor**: JavaScript vanilla (sin framework ni bundler),
|
||||
daisyUI + Tailwind CSS vía CDN con compilador JIT.
|
||||
- **Vista de solo lectura / PDF**: Tailwind precompilado sin JS (WeasyPrint
|
||||
no ejecuta JavaScript), generado con el CLI standalone de Tailwind (ver
|
||||
`python manage.py build_pattern_detail_css`).
|
||||
- **Imágenes**: Pillow (variantes de la portada del patrón en varios
|
||||
tamaños), almacenamiento en filesystem o S3 (`django-storages`, opcional).
|
||||
- **Estáticos**: WhiteNoise con manifest comprimido.
|
||||
- **HTMX** para los formularios de ajustes de cuenta.
|
||||
- **Gestión de dependencias**: [uv](https://docs.astral.sh/uv/).
|
||||
|
||||
## Desarrollo
|
||||
|
||||
### Requisitos
|
||||
|
||||
- Python 3.13
|
||||
- [uv](https://docs.astral.sh/uv/)
|
||||
- Librerías nativas de WeasyPrint (Pango, Cairo, GDK-Pixbuf) si vas a
|
||||
generar/probar el PDF fuera de Docker — en Debian/Ubuntu:
|
||||
`apt-get install libpango-1.0-0 libpangocairo-1.0-0 fonts-dejavu-core`.
|
||||
- `gettext` si vas a regenerar/compilar traducciones
|
||||
(`msgfmt`/`msguniq`/etc.) — en Debian/Ubuntu: `apt-get install gettext`.
|
||||
|
||||
### Puesta en marcha
|
||||
|
||||
```bash
|
||||
git clone <url-del-repositorio>
|
||||
cd crochet
|
||||
uv sync --group dev
|
||||
```
|
||||
|
||||
Crea un archivo `.env` en la raíz del proyecto (ver
|
||||
[Variables de entorno](#variables-de-entorno) más abajo). Para desarrollo
|
||||
local basta con:
|
||||
|
||||
```env
|
||||
DEBUG=True
|
||||
```
|
||||
|
||||
Con eso, la app arranca con SQLite, `SECRET_KEY` de desarrollo, envío de
|
||||
email al backend de consola (se imprime en la terminal en vez de mandarse
|
||||
de verdad) y sin credenciales adicionales.
|
||||
|
||||
```bash
|
||||
uv run python manage.py migrate
|
||||
uv run python manage.py createsuperuser # opcional, para /admin/
|
||||
uv run python manage.py runserver
|
||||
```
|
||||
|
||||
La app queda disponible en `http://localhost:8000/`.
|
||||
|
||||
### Tests
|
||||
|
||||
```bash
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
`pytest-cov` está incluido; para ver cobertura: `uv run pytest --cov`.
|
||||
|
||||
### Traducciones
|
||||
|
||||
Las cadenas ya traducidas están compiladas y listas para usar. Si añades o
|
||||
cambias texto traducible (`{% trans %}`/`{% blocktrans %}`, `gettext_lazy`):
|
||||
|
||||
```bash
|
||||
uv run python manage.py makemessages -l es -l en
|
||||
# revisa a mano cualquier entrada marcada como "#, fuzzy" antes de compilar
|
||||
uv run python manage.py compilemessages
|
||||
```
|
||||
|
||||
### CSS precompilado de la vista de solo lectura / PDF
|
||||
|
||||
Solo hace falta volver a generarlo si cambian las clases de Tailwind
|
||||
usadas en `pattern_detail.html` o `pattern_render.py`:
|
||||
|
||||
```bash
|
||||
uv run python manage.py build_pattern_detail_css
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
docker build -t crochet .
|
||||
docker run --rm -p 8000:8000 --env-file .env crochet
|
||||
```
|
||||
|
||||
El `Dockerfile` instala las dependencias del sistema necesarias para
|
||||
WeasyPrint y ejecuta `scripts/build.sh` (CSS precompilado, `collectstatic`,
|
||||
`compilemessages`) al construir la imagen, y `scripts/run.sh` (Uvicorn) al
|
||||
arrancar el contenedor.
|
||||
|
||||
## Variables de entorno
|
||||
|
||||
Todas se leen en `config/settings/env.py`; sin `.env`, o si falta alguna,
|
||||
se usan los valores por defecto indicados (pensados para desarrollo local).
|
||||
|
||||
| Variable | Por defecto | Descripción |
|
||||
|---|---|---|
|
||||
| `SECRET_KEY` | clave insegura de desarrollo | **Cámbiala en producción.** |
|
||||
| `DEBUG` | `False` | Activa páginas de error detalladas, Django Debug Toolbar y Silk. No debe estar activo en producción. |
|
||||
| `ALLOWED_HOSTS` | `*` | Lista separada por comas. Debe restringirse a los dominios reales en producción. |
|
||||
| `CSRF_TRUSTED_ORIGINS` | `http://localhost:8000` | Lista separada por comas. Necesario si la app se sirve detrás de un dominio/HTTPS distinto. |
|
||||
| `DATABASE_URL` | `sqlite:///db.sqlite3` | Formato `django-environ` (p. ej. `postgres://usuario:password@host:5432/nombre_bd`). |
|
||||
| `S3_ENABLED` | `False` | Si es `True`, los archivos subidos (imágenes de portada/patrón) se guardan en S3 en vez de en el filesystem local. |
|
||||
| `S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY` / `S3_STORAGE_BUCKET_NAME` / `S3_ENDPOINT_URL` | vacío | Credenciales del bucket, solo necesarias si `S3_ENABLED=True`. |
|
||||
| `EMAIL_BACKEND` | backend de consola | Usa `django.core.mail.backends.smtp.EmailBackend` para enviar emails de verdad (recuperación de contraseña). |
|
||||
| `EMAIL_HOST` / `EMAIL_HOST_USER` / `EMAIL_HOST_PASSWORD` / `EMAIL_PORT` | vacío / vacío / vacío / `587` | Credenciales SMTP, solo con el backend SMTP. |
|
||||
| `DEFAULT_FROM_EMAIL` | `webmaster@localhost` | Remitente de los emails salientes. La mayoría de proveedores SMTP rechazan enviar si no coincide con la cuenta autenticada (o un alias verificado). |
|
||||
| `SUPPORT_EMAIL` | `soporte@localhost` | Dirección de contacto que se muestra en el aviso que recibe el email anterior de una cuenta cuando alguien lo cambia (ver `EmailUpdateForm`). |
|
||||
| `LOG_LEVEL` | `INFO` | Nivel de log de Django y de la app (`crochet`). |
|
||||
|
||||
Existen además `CORS_ORIGIN_WHITELIST`, `REDIS_HOST`, `REDIS_PORT`,
|
||||
`CELERY_BROKER_URL`, `PAGE_SIZE` e `ITEMS_PER_PAGE` en `env.py`: quedaron de
|
||||
una plantilla de proyecto y actualmente no los usa ninguna parte de la
|
||||
aplicación (no hay Celery, cachés en Redis, CORS ni paginación
|
||||
configurados), así que no hace falta definirlos para desplegar.
|
||||
|
||||
## Despliegue a producción
|
||||
|
||||
Como mínimo hay que ajustar, respecto al `.env` de desarrollo:
|
||||
|
||||
1. **`SECRET_KEY`**: un valor único y secreto (no el de desarrollo).
|
||||
2. **`DEBUG=False`**.
|
||||
3. **`ALLOWED_HOSTS`**: los dominios reales, no `*`.
|
||||
4. **`CSRF_TRUSTED_ORIGINS`**: los orígenes reales (con esquema, p. ej.
|
||||
`https://patrones.ejemplo.com`).
|
||||
5. **`DATABASE_URL`**: apuntando a PostgreSQL.
|
||||
6. **`EMAIL_BACKEND`/`EMAIL_HOST`/`EMAIL_HOST_USER`/`EMAIL_HOST_PASSWORD`/`DEFAULT_FROM_EMAIL`**:
|
||||
sin esto, el registro de usuarios funciona pero la recuperación de
|
||||
contraseña no llega a enviarse (se queda en el log en vez de salir por
|
||||
SMTP).
|
||||
7. **Almacenamiento de archivos**: si vas a correr más de una instancia o
|
||||
quieres que las imágenes sobrevivan a un redeploy del contenedor,
|
||||
configura `S3_ENABLED=True` y las credenciales de `S3_*` — el
|
||||
filesystem local (por defecto) no es compartido ni persistente entre
|
||||
despliegues.
|
||||
|
||||
La imagen Docker ya ejecuta `collectstatic`, `compilemessages` y el
|
||||
CSS precompilado al construirse, y sirve la app con Uvicorn (variables
|
||||
`WSGI_HOST`/`WSGI_WORKERS` para ajustar host/nº de workers). WhiteNoise se
|
||||
encarga de servir los estáticos directamente desde la propia app, sin
|
||||
necesidad de un servidor/proxy de estáticos aparte.
|
||||
|
||||
Al hacer push a `master` se dispara la integración continua (tests) y,
|
||||
si pasan, se construye y publica una imagen Docker (ver
|
||||
`.gitea/workflows/test-build.yaml`).
|
||||
|
||||
@@ -146,9 +146,6 @@ USE_TZ = True
|
||||
STATIC_URL = 'static/'
|
||||
STATIC_ROOT = 'config/static'
|
||||
|
||||
# CompressedManifestStaticFilesStorage (WhiteNoise): sirve cada archivo ya
|
||||
# comprimido (gzip/brotli) y con hash en el nombre para poder cachearlos
|
||||
# "para siempre" sin arriesgarse a servir una versión vieja tras un deploy.
|
||||
STORAGES = {
|
||||
'default': {
|
||||
'BACKEND': 'django.core.files.storage.FileSystemStorage',
|
||||
@@ -161,8 +158,6 @@ STORAGES = {
|
||||
if S3_ENABLED:
|
||||
STORAGES['default'] = {'BACKEND': 'storages.backends.s3boto3.S3Boto3Storage'}
|
||||
|
||||
# Archivos subidos por el usuario (imágenes de patrón), separados de los
|
||||
# estáticos del propio proyecto.
|
||||
MEDIA_URL = 'media/'
|
||||
MEDIA_ROOT = BASE_DIR / 'media'
|
||||
|
||||
@@ -173,14 +168,6 @@ EMAIL_USE_SSL = False
|
||||
# Logging
|
||||
# https://docs.djangoproject.com/en/6.0/topics/logging/
|
||||
|
||||
# Se define entero (en vez de dejar el DEFAULT_LOGGING de Django) porque ese
|
||||
# default, con DEBUG=False, solo engancha un handler de consola cuando
|
||||
# DEBUG=True y manda los errores (django.request) por email a ADMINS cuando
|
||||
# DEBUG=False; aquí ni ADMINS ni un EMAIL_HOST de verdad están configurados
|
||||
# (ver env.py), así que en producción los 500 no se veían en ningún sitio.
|
||||
# Con un handler de consola sin ese filtro, cualquier error queda en
|
||||
# stdout/stderr, que es lo que recogen los logs del contenedor (`docker logs`,
|
||||
# lo que agregue la plataforma de despliegue, etc.).
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
'disable_existing_loggers': False,
|
||||
@@ -196,11 +183,6 @@ LOGGING = {
|
||||
'formatter': 'verbose',
|
||||
},
|
||||
},
|
||||
# A WARNING fijo (no LOG_LEVEL): captura cualquier logger que no esté
|
||||
# listado abajo (de terceros: weasyprint, whitenoise, PIL...) para que
|
||||
# nada se pierda del todo, pero sin su parloteo en INFO/DEBUG -algunas
|
||||
# librerías, como weasyprint, ya avisan cosas en INFO solo con
|
||||
# importarlas-. 'django' y 'crochet' sí usan LOG_LEVEL, más abajo.
|
||||
'root': {
|
||||
'handlers': ['console'],
|
||||
'level': 'WARNING',
|
||||
@@ -211,10 +193,6 @@ LOGGING = {
|
||||
'level': LOG_LEVEL,
|
||||
'propagate': False,
|
||||
},
|
||||
# Aquí es donde Django registra la traza completa de cualquier
|
||||
# excepción no controlada en una vista (un 500): sin esto configurado
|
||||
# explícitamente, en producción (DEBUG=False) solo se intentaba
|
||||
# mandar por email a ADMINS, no configurado.
|
||||
'django.request': {
|
||||
'handlers': ['console'],
|
||||
'level': 'ERROR',
|
||||
@@ -225,9 +203,6 @@ LOGGING = {
|
||||
'level': 'WARNING',
|
||||
'propagate': False,
|
||||
},
|
||||
# Logger propio de la app, para poder hacer
|
||||
# logging.getLogger(__name__) desde cualquier módulo de crochet/ sin
|
||||
# tener que tocar esta configuración cada vez.
|
||||
'crochet': {
|
||||
'handlers': ['console'],
|
||||
'level': LOG_LEVEL,
|
||||
|
||||
@@ -45,9 +45,8 @@ EMAIL_HOST_PASSWORD = env.str('EMAIL_HOST_PASSWORD', '')
|
||||
EMAIL_PORT = env.int('EMAIL_PORT', 587)
|
||||
DEFAULT_FROM_EMAIL = env.str('DEFAULT_FROM_EMAIL', 'webmaster@localhost')
|
||||
|
||||
# Nivel de log de la app y de Django (ver LOGGING en base.py). En producción
|
||||
# (DEBUG=False) Django, por defecto, solo manda los errores por email a
|
||||
# ADMINS (que aquí no está configurado), así que sin un LOGGING propio no se
|
||||
# ve ni un rastro de un 500 en ningún sitio; con esto van a stdout/stderr,
|
||||
# que es lo que recogen los logs del contenedor.
|
||||
# A dónde se remite al usuario en el aviso de cambio de email (ver
|
||||
# EmailUpdateForm) por si el cambio no lo ha hecho él.
|
||||
SUPPORT_EMAIL = env.str('SUPPORT_EMAIL', 'soporte@localhost')
|
||||
|
||||
LOG_LEVEL = env.str('LOG_LEVEL', 'INFO')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.forms import (
|
||||
AuthenticationForm,
|
||||
PasswordChangeForm,
|
||||
@@ -6,6 +7,8 @@ from django.contrib.auth.forms import (
|
||||
SetPasswordForm,
|
||||
UserCreationForm,
|
||||
)
|
||||
from django.core.mail import send_mail
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
# Los formularios de auth de Django no traen ninguna clase CSS en sus
|
||||
@@ -67,5 +70,24 @@ class EmailUpdateForm(forms.Form):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def save(self):
|
||||
old_email = self.user.email
|
||||
self.user.email = self.cleaned_data['email']
|
||||
self.user.save(update_fields=['email'])
|
||||
# Aviso a la dirección ANTERIOR (no a la nueva): así, si alguien más
|
||||
# ha cambiado el email de la cuenta (sesión robada, dispositivo
|
||||
# compartido...), quien de verdad era el dueño se entera por donde
|
||||
# todavía puede leerlo. Si no había email antes, o si "cambiar" no
|
||||
# ha cambiado nada, no hay a quién avisar ni de qué.
|
||||
if old_email and old_email != self.user.email:
|
||||
self._notify_previous_email(old_email)
|
||||
|
||||
def _notify_previous_email(self, old_email):
|
||||
context = {'user': self.user, 'old_email': old_email, 'site_name': 'Crochet', 'support_email': settings.SUPPORT_EMAIL}
|
||||
subject = ''.join(render_to_string('crochet/email/email_changed_subject.txt', context).splitlines())
|
||||
send_mail(
|
||||
subject=subject,
|
||||
message=render_to_string('crochet/email/email_changed_notice.txt', context),
|
||||
from_email=None,
|
||||
recipient_list=[old_email],
|
||||
html_message=render_to_string('crochet/email/email_changed_notice.html', context),
|
||||
)
|
||||
|
||||
@@ -2,13 +2,13 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: crochet\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-07-21 10:20+0000\n"
|
||||
"POT-Creation-Date: 2026-07-21 11:41+0000\n"
|
||||
"Language: en\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
|
||||
#: crochet/forms.py:30 crochet/forms.py:63
|
||||
#: crochet/forms.py:33 crochet/forms.py:66
|
||||
#: crochet/templates/crochet/account_settings.html:22
|
||||
msgid "Email"
|
||||
msgstr "Email"
|
||||
@@ -98,6 +98,74 @@ msgstr "Log out"
|
||||
msgid "Iniciar sesión"
|
||||
msgstr "Log in"
|
||||
|
||||
#: crochet/templates/crochet/email/base.html:43
|
||||
msgid ""
|
||||
"Este email se ha enviado automáticamente, no respondas a esta dirección."
|
||||
msgstr "This email was sent automatically, please don't reply to this address."
|
||||
|
||||
#: crochet/templates/crochet/email/email_changed_notice.html:4
|
||||
#: crochet/templates/crochet/email/email_changed_subject.txt:1
|
||||
#, python-format
|
||||
msgid "El email de tu cuenta en %(site_name)s ha cambiado"
|
||||
msgstr "The email on your %(site_name)s account has changed"
|
||||
|
||||
#: crochet/templates/crochet/email/email_changed_notice.html:8
|
||||
#: crochet/templates/crochet/email/email_changed_notice.txt:2
|
||||
#, python-format
|
||||
msgid ""
|
||||
"El email de tu cuenta en %(site_name)s se ha cambiado. Esta dirección "
|
||||
"(%(old_email)s) ha dejado de estar asociada a tu cuenta."
|
||||
msgstr ""
|
||||
"The email on your %(site_name)s account has changed. This address "
|
||||
"(%(old_email)s) is no longer associated with your account."
|
||||
|
||||
#: crochet/templates/crochet/email/email_changed_notice.html:12
|
||||
#: crochet/templates/crochet/email/email_changed_notice.txt:4
|
||||
msgid "Tu usuario:"
|
||||
msgstr "Your username:"
|
||||
|
||||
#: crochet/templates/crochet/email/email_changed_notice.html:16
|
||||
#: crochet/templates/crochet/email/email_changed_notice.txt:6
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Si no has sido tú quien ha hecho este cambio, contacta cuanto antes con "
|
||||
"soporte técnico en %(support_email)s."
|
||||
msgstr ""
|
||||
"If you didn't make this change, please contact technical support at "
|
||||
"%(support_email)s as soon as possible."
|
||||
|
||||
#: crochet/templates/crochet/email/password_reset.html:4
|
||||
#: crochet/templates/crochet/password_reset_done.html:4
|
||||
#: crochet/templates/crochet/password_reset_form.html:4
|
||||
#: crochet/templates/crochet/password_reset_form.html:14
|
||||
msgid "Recuperar contraseña"
|
||||
msgstr "Reset password"
|
||||
|
||||
#: crochet/templates/crochet/email/password_reset.html:8
|
||||
#: crochet/templates/crochet/password_reset_email.html:2
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Has recibido este email porque alguien ha solicitado restablecer la "
|
||||
"contraseña de tu cuenta en %(site_name)s."
|
||||
msgstr ""
|
||||
"You're receiving this email because someone requested a password reset for "
|
||||
"your account at %(site_name)s."
|
||||
|
||||
#: crochet/templates/crochet/email/password_reset.html:14
|
||||
#: crochet/templates/crochet/password_reset_confirm.html:4
|
||||
msgid "Elegir nueva contraseña"
|
||||
msgstr "Choose new password"
|
||||
|
||||
#: crochet/templates/crochet/email/password_reset.html:19
|
||||
#: crochet/templates/crochet/password_reset_email.html:7
|
||||
msgid "Tu usuario, por si lo has olvidado:"
|
||||
msgstr "Your username, in case you've forgotten:"
|
||||
|
||||
#: crochet/templates/crochet/email/password_reset.html:23
|
||||
#: crochet/templates/crochet/password_reset_email.html:9
|
||||
msgid "Si no has solicitado este cambio, puedes ignorar este email."
|
||||
msgstr "If you didn't request this change, you can ignore this email."
|
||||
|
||||
#: crochet/templates/crochet/home.html:4
|
||||
msgid "Crochet — Crea y comparte tus patrones"
|
||||
msgstr "Crochet — Create and share your patterns"
|
||||
@@ -167,10 +235,6 @@ msgstr "Password updated"
|
||||
msgid "Ya puedes iniciar sesión con tu nueva contraseña."
|
||||
msgstr "You can now log in with your new password."
|
||||
|
||||
#: crochet/templates/crochet/password_reset_confirm.html:4
|
||||
msgid "Elegir nueva contraseña"
|
||||
msgstr "Choose new password"
|
||||
|
||||
#: crochet/templates/crochet/password_reset_confirm.html:15
|
||||
msgid "Elige una contraseña nueva"
|
||||
msgstr "Choose a new password"
|
||||
@@ -191,12 +255,6 @@ msgstr ""
|
||||
msgid "Solicitar un enlace nuevo"
|
||||
msgstr "Request a new link"
|
||||
|
||||
#: crochet/templates/crochet/password_reset_done.html:4
|
||||
#: crochet/templates/crochet/password_reset_form.html:4
|
||||
#: crochet/templates/crochet/password_reset_form.html:14
|
||||
msgid "Recuperar contraseña"
|
||||
msgstr "Reset password"
|
||||
|
||||
#: crochet/templates/crochet/password_reset_done.html:14
|
||||
msgid "Revisa tu email"
|
||||
msgstr "Check your email"
|
||||
@@ -214,27 +272,10 @@ msgstr ""
|
||||
msgid "Volver a iniciar sesión"
|
||||
msgstr "Back to login"
|
||||
|
||||
#: crochet/templates/crochet/password_reset_email.html:2
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Has recibido este email porque alguien ha solicitado restablecer la "
|
||||
"contraseña de tu cuenta en %(site_name)s."
|
||||
msgstr ""
|
||||
"You're receiving this email because someone requested a password reset for "
|
||||
"your account at %(site_name)s."
|
||||
|
||||
#: crochet/templates/crochet/password_reset_email.html:4
|
||||
msgid "Sigue este enlace para elegir una contraseña nueva:"
|
||||
msgstr "Follow this link to choose a new password:"
|
||||
|
||||
#: crochet/templates/crochet/password_reset_email.html:7
|
||||
msgid "Tu usuario, por si lo has olvidado:"
|
||||
msgstr "Your username, in case you've forgotten:"
|
||||
|
||||
#: crochet/templates/crochet/password_reset_email.html:9
|
||||
msgid "Si no has solicitado este cambio, puedes ignorar este email."
|
||||
msgstr "If you didn't request this change, you can ignore this email."
|
||||
|
||||
#: crochet/templates/crochet/password_reset_form.html:16
|
||||
msgid ""
|
||||
"Escribe tu email y te enviaremos un enlace para elegir una contraseña nueva."
|
||||
@@ -588,15 +629,15 @@ msgstr ""
|
||||
msgid "account/password-reset/"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/urls.py:75
|
||||
#: crochet/urls.py:79
|
||||
msgid "account/password-reset/done/"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/urls.py:79
|
||||
#: crochet/urls.py:83
|
||||
msgid "account/reset/<uidb64>/<token>/"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/urls.py:87
|
||||
#: crochet/urls.py:91
|
||||
msgid "account/reset/done/"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -2,13 +2,13 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: crochet\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-07-21 10:20+0000\n"
|
||||
"POT-Creation-Date: 2026-07-21 11:41+0000\n"
|
||||
"Language: es\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
|
||||
#: crochet/forms.py:30 crochet/forms.py:63
|
||||
#: crochet/forms.py:33 crochet/forms.py:66
|
||||
#: crochet/templates/crochet/account_settings.html:22
|
||||
msgid "Email"
|
||||
msgstr ""
|
||||
@@ -98,6 +98,68 @@ msgstr ""
|
||||
msgid "Iniciar sesión"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/email/base.html:43
|
||||
msgid ""
|
||||
"Este email se ha enviado automáticamente, no respondas a esta dirección."
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/email/email_changed_notice.html:4
|
||||
#: crochet/templates/crochet/email/email_changed_subject.txt:1
|
||||
#, python-format
|
||||
msgid "El email de tu cuenta en %(site_name)s ha cambiado"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/email/email_changed_notice.html:8
|
||||
#: crochet/templates/crochet/email/email_changed_notice.txt:2
|
||||
#, python-format
|
||||
msgid ""
|
||||
"El email de tu cuenta en %(site_name)s se ha cambiado. Esta dirección "
|
||||
"(%(old_email)s) ha dejado de estar asociada a tu cuenta."
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/email/email_changed_notice.html:12
|
||||
#: crochet/templates/crochet/email/email_changed_notice.txt:4
|
||||
msgid "Tu usuario:"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/email/email_changed_notice.html:16
|
||||
#: crochet/templates/crochet/email/email_changed_notice.txt:6
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Si no has sido tú quien ha hecho este cambio, contacta cuanto antes con "
|
||||
"soporte técnico en %(support_email)s."
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/email/password_reset.html:4
|
||||
#: crochet/templates/crochet/password_reset_done.html:4
|
||||
#: crochet/templates/crochet/password_reset_form.html:4
|
||||
#: crochet/templates/crochet/password_reset_form.html:14
|
||||
msgid "Recuperar contraseña"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/email/password_reset.html:8
|
||||
#: crochet/templates/crochet/password_reset_email.html:2
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Has recibido este email porque alguien ha solicitado restablecer la "
|
||||
"contraseña de tu cuenta en %(site_name)s."
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/email/password_reset.html:14
|
||||
#: crochet/templates/crochet/password_reset_confirm.html:4
|
||||
msgid "Elegir nueva contraseña"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/email/password_reset.html:19
|
||||
#: crochet/templates/crochet/password_reset_email.html:7
|
||||
msgid "Tu usuario, por si lo has olvidado:"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/email/password_reset.html:23
|
||||
#: crochet/templates/crochet/password_reset_email.html:9
|
||||
msgid "Si no has solicitado este cambio, puedes ignorar este email."
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/home.html:4
|
||||
msgid "Crochet — Crea y comparte tus patrones"
|
||||
msgstr ""
|
||||
@@ -159,10 +221,6 @@ msgstr ""
|
||||
msgid "Ya puedes iniciar sesión con tu nueva contraseña."
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/password_reset_confirm.html:4
|
||||
msgid "Elegir nueva contraseña"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/password_reset_confirm.html:15
|
||||
msgid "Elige una contraseña nueva"
|
||||
msgstr ""
|
||||
@@ -181,12 +239,6 @@ msgstr ""
|
||||
msgid "Solicitar un enlace nuevo"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/password_reset_done.html:4
|
||||
#: crochet/templates/crochet/password_reset_form.html:4
|
||||
#: crochet/templates/crochet/password_reset_form.html:14
|
||||
msgid "Recuperar contraseña"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/password_reset_done.html:14
|
||||
msgid "Revisa tu email"
|
||||
msgstr ""
|
||||
@@ -202,25 +254,10 @@ msgstr ""
|
||||
msgid "Volver a iniciar sesión"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/password_reset_email.html:2
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Has recibido este email porque alguien ha solicitado restablecer la "
|
||||
"contraseña de tu cuenta en %(site_name)s."
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/password_reset_email.html:4
|
||||
msgid "Sigue este enlace para elegir una contraseña nueva:"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/password_reset_email.html:7
|
||||
msgid "Tu usuario, por si lo has olvidado:"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/password_reset_email.html:9
|
||||
msgid "Si no has solicitado este cambio, puedes ignorar este email."
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/password_reset_form.html:16
|
||||
msgid ""
|
||||
"Escribe tu email y te enviaremos un enlace para elegir una contraseña nueva."
|
||||
@@ -574,15 +611,15 @@ msgstr "cuenta/salir/"
|
||||
msgid "account/password-reset/"
|
||||
msgstr "cuenta/recuperar-contrasena/"
|
||||
|
||||
#: crochet/urls.py:75
|
||||
#: crochet/urls.py:79
|
||||
msgid "account/password-reset/done/"
|
||||
msgstr "cuenta/recuperar-contrasena/enviado/"
|
||||
|
||||
#: crochet/urls.py:79
|
||||
#: crochet/urls.py:83
|
||||
msgid "account/reset/<uidb64>/<token>/"
|
||||
msgstr "cuenta/restablecer/<uidb64>/<token>/"
|
||||
|
||||
#: crochet/urls.py:87
|
||||
#: crochet/urls.py:91
|
||||
msgid "account/reset/done/"
|
||||
msgstr "cuenta/restablecer/hecho/"
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
{% load i18n %}<!DOCTYPE html>
|
||||
<html lang="{% get_current_language as html_lang %}{{ html_lang }}">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="color-scheme" content="light">
|
||||
<title>{% block title %}Crochet{% endblock %}</title>
|
||||
</head>
|
||||
|
||||
<!--
|
||||
Plantilla base para los emails de la app (HTML): la heredan las
|
||||
plantillas concretas (ver crochet/email/password_reset.html) igual que
|
||||
crochet/base.html hace con las páginas normales, extendiendo esta y
|
||||
rellenando el bloque de contenido.
|
||||
|
||||
Estilos en línea (no en un elemento de estilos en head) y layout con
|
||||
table, no con flexbox/grid: muchos clientes de correo (Gmail, Outlook de
|
||||
escritorio...) ignoran los estilos puestos en head o no soportan CSS
|
||||
moderno, pero sí respetan los estilos puestos directamente en cada
|
||||
elemento. Colores calcados de --brand-* en css/main.css (aquí a mano,
|
||||
sin variables CSS por la misma razón de compatibilidad).
|
||||
-->
|
||||
<body style="margin:0; padding:0; background-color:#f7efe1; font-family:'Poppins', Arial, sans-serif; color:#2e2118;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f7efe1;">
|
||||
<tr>
|
||||
<td align="center" style="padding:32px 16px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:480px; background-color:#fffbf5; border:1px solid #ede0c9; border-radius:16px;">
|
||||
<tr>
|
||||
<td style="padding:24px 32px 0 32px;">
|
||||
<span style="font-size:22px; font-weight:600; color:#2e2118;">🧶 Crochet</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:16px 32px 32px 32px; font-size:15px; line-height:1.6; color:#2e2118;">
|
||||
{% block content %}{% endblock %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:480px;">
|
||||
<tr>
|
||||
<td style="padding:16px 32px; text-align:center; font-size:12px; color:#2e2118;">
|
||||
{% block footer %}{% trans 'Este email se ha enviado automáticamente, no respondas a esta dirección.' %}{% endblock %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,18 @@
|
||||
{% extends 'crochet/email/base.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% blocktrans %}El email de tu cuenta en {{ site_name }} ha cambiado{% endblocktrans %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p style="margin:0 0 16px 0;">
|
||||
{% blocktrans %}El email de tu cuenta en {{ site_name }} se ha cambiado. Esta dirección ({{ old_email }}) ha dejado de estar asociada a tu cuenta.{% endblocktrans %}
|
||||
</p>
|
||||
|
||||
<p style="margin:0 0 16px 0; font-size:13px; color:#2e2118;">
|
||||
{% trans 'Tu usuario:' %} {{ user.get_username }}
|
||||
</p>
|
||||
|
||||
<p style="margin:0; font-weight:600;">
|
||||
{% blocktrans %}Si no has sido tú quien ha hecho este cambio, contacta cuanto antes con soporte técnico en {{ support_email }}.{% endblocktrans %}
|
||||
</p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,7 @@
|
||||
{% load i18n %}{% autoescape off %}
|
||||
{% blocktrans %}El email de tu cuenta en {{ site_name }} se ha cambiado. Esta dirección ({{ old_email }}) ha dejado de estar asociada a tu cuenta.{% endblocktrans %}
|
||||
|
||||
{% trans 'Tu usuario:' %} {{ user.get_username }}
|
||||
|
||||
{% blocktrans %}Si no has sido tú quien ha hecho este cambio, contacta cuanto antes con soporte técnico en {{ support_email }}.{% endblocktrans %}
|
||||
{% endautoescape %}
|
||||
@@ -0,0 +1 @@
|
||||
{% load i18n %}{% blocktrans %}El email de tu cuenta en {{ site_name }} ha cambiado{% endblocktrans %}
|
||||
@@ -0,0 +1,25 @@
|
||||
{% extends 'crochet/email/base.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans 'Recuperar contraseña' %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p style="margin:0 0 16px 0;">
|
||||
{% blocktrans %}Has recibido este email porque alguien ha solicitado restablecer la contraseña de tu cuenta en {{ site_name }}.{% endblocktrans %}
|
||||
</p>
|
||||
|
||||
<p style="text-align:center; margin:24px 0;">
|
||||
<a href="{{ protocol }}://{{ domain }}{% url 'crochet:password_reset_confirm' uidb64=uid token=token %}"
|
||||
style="display:inline-block; background-color:#f1641e; color:#ffffff; text-decoration:none; padding:12px 28px; border-radius:999px; font-weight:600;">
|
||||
{% trans 'Elegir nueva contraseña' %}
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p style="margin:0 0 8px 0; font-size:13px; color:#2e2118;">
|
||||
{% trans 'Tu usuario, por si lo has olvidado:' %} {{ user.get_username }}
|
||||
</p>
|
||||
|
||||
<p style="margin:0; font-size:13px; color:#2e2118;">
|
||||
{% trans 'Si no has solicitado este cambio, puedes ignorar este email.' %}
|
||||
</p>
|
||||
{% endblock %}
|
||||
@@ -1,4 +1,5 @@
|
||||
from django.contrib.auth.models import User
|
||||
from django.core import mail
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import translation
|
||||
@@ -76,6 +77,42 @@ class AccountEmailUpdateViewTests(TestCase):
|
||||
other.refresh_from_db()
|
||||
self.assertEqual(other.email, 'other@example.com')
|
||||
|
||||
def test_changing_email_notifies_the_previous_address(self):
|
||||
self.client.force_login(self.user)
|
||||
|
||||
self.client.post(self.url, {'email': 'new@example.com'})
|
||||
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
message = mail.outbox[0]
|
||||
self.assertEqual(message.to, ['old@example.com'])
|
||||
self.assertIn('old@example.com', message.body)
|
||||
self.assertIn('crocheter', message.body)
|
||||
self.assertIn('soporte@localhost', message.body)
|
||||
html_bodies = [content for content, mimetype in message.alternatives if mimetype == 'text/html']
|
||||
self.assertEqual(len(html_bodies), 1)
|
||||
|
||||
def test_resubmitting_the_same_email_does_not_notify_anyone(self):
|
||||
self.client.force_login(self.user)
|
||||
|
||||
self.client.post(self.url, {'email': 'old@example.com'})
|
||||
|
||||
self.assertEqual(len(mail.outbox), 0)
|
||||
|
||||
def test_invalid_email_does_not_notify_anyone(self):
|
||||
self.client.force_login(self.user)
|
||||
|
||||
self.client.post(self.url, {'email': 'not-an-email'})
|
||||
|
||||
self.assertEqual(len(mail.outbox), 0)
|
||||
|
||||
def test_user_without_a_previous_email_gets_no_notification(self):
|
||||
user_without_email = User.objects.create_user(username='noemail', password='a-very-uncommon-pw-1')
|
||||
self.client.force_login(user_without_email)
|
||||
|
||||
self.client.post(self.url, {'email': 'new@example.com'})
|
||||
|
||||
self.assertEqual(len(mail.outbox), 0)
|
||||
|
||||
|
||||
class AccountPasswordChangeViewTests(TestCase):
|
||||
def setUp(self):
|
||||
|
||||
@@ -32,6 +32,14 @@ class PasswordResetRequestViewTests(TestCase):
|
||||
self.assertIn('crocheter@example.com', mail.outbox[0].to)
|
||||
self.assertIn('http://testserver', mail.outbox[0].body)
|
||||
|
||||
def test_email_also_attaches_an_html_alternative_with_the_link(self):
|
||||
self.client.post(self.url, {'email': 'crocheter@example.com'})
|
||||
|
||||
message = mail.outbox[0]
|
||||
html_bodies = [content for content, mimetype in message.alternatives if mimetype == 'text/html']
|
||||
self.assertEqual(len(html_bodies), 1)
|
||||
self.assertIn('http://testserver', html_bodies[0])
|
||||
|
||||
def test_unknown_email_does_not_send_anything_but_still_redirects(self):
|
||||
# Ni la vista ni la plantilla deben delatar si el email existe o no
|
||||
# en el sistema (comportamiento por defecto de PasswordResetForm):
|
||||
|
||||
@@ -66,6 +66,10 @@ urlpatterns = [
|
||||
# encontraría siempre las suyas primero, tapando las nuestras.
|
||||
template_name='crochet/password_reset_form.html',
|
||||
email_template_name='crochet/password_reset_email.html',
|
||||
# HTML además del texto plano de arriba (no en su lugar):
|
||||
# EmailMultiAlternatives adjunta esta versión como
|
||||
# "text/html", y el cliente de correo elige cuál mostrar.
|
||||
html_email_template_name='crochet/email/password_reset.html',
|
||||
subject_template_name='crochet/password_reset_subject.txt',
|
||||
form_class=StyledPasswordResetForm,
|
||||
success_url=reverse_lazy('crochet:password_reset_done'),
|
||||
|
||||
Reference in New Issue
Block a user