From 6b5c353a86d96393b3ac3c6fa31c072333678449 Mon Sep 17 00:00:00 2001 From: Pablo Moreno Date: Tue, 21 Jul 2026 13:44:56 +0200 Subject: [PATCH] feat: warn user about email change --- README.md | 180 ++++++++++++++++++ config/settings/base.py | 27 +-- config/settings/env.py | 9 +- crochet/forms.py | 22 +++ crochet/locale/en/LC_MESSAGES/django.po | 105 ++++++---- crochet/locale/es/LC_MESSAGES/django.po | 97 +++++++--- crochet/templates/crochet/email/base.html | 52 +++++ .../crochet/email/email_changed_notice.html | 18 ++ .../crochet/email/email_changed_notice.txt | 7 + .../crochet/email/email_changed_subject.txt | 1 + .../crochet/email/password_reset.html | 25 +++ crochet/tests/test_account_settings.py | 37 ++++ crochet/tests/test_password_reset.py | 8 + crochet/urls.py | 4 + 14 files changed, 499 insertions(+), 93 deletions(-) create mode 100644 crochet/templates/crochet/email/base.html create mode 100644 crochet/templates/crochet/email/email_changed_notice.html create mode 100644 crochet/templates/crochet/email/email_changed_notice.txt create mode 100644 crochet/templates/crochet/email/email_changed_subject.txt create mode 100644 crochet/templates/crochet/email/password_reset.html diff --git a/README.md b/README.md index e69de29..0b69af8 100644 --- a/README.md +++ b/README.md @@ -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//`), 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 +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`). diff --git a/config/settings/base.py b/config/settings/base.py index eed9709..d9b5d4a 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -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,11 +193,7 @@ 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': { + 'django.request': { 'handlers': ['console'], 'level': 'ERROR', 'propagate': False, @@ -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, diff --git a/config/settings/env.py b/config/settings/env.py index 603112a..940aa09 100644 --- a/config/settings/env.py +++ b/config/settings/env.py @@ -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') diff --git a/crochet/forms.py b/crochet/forms.py index 55e7c97..8ec599e 100644 --- a/crochet/forms.py +++ b/crochet/forms.py @@ -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), + ) diff --git a/crochet/locale/en/LC_MESSAGES/django.po b/crochet/locale/en/LC_MESSAGES/django.po index 046229b..f9bce0f 100644 --- a/crochet/locale/en/LC_MESSAGES/django.po +++ b/crochet/locale/en/LC_MESSAGES/django.po @@ -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///" msgstr "" -#: crochet/urls.py:87 +#: crochet/urls.py:91 msgid "account/reset/done/" msgstr "" diff --git a/crochet/locale/es/LC_MESSAGES/django.po b/crochet/locale/es/LC_MESSAGES/django.po index bd6c29b..75075b2 100644 --- a/crochet/locale/es/LC_MESSAGES/django.po +++ b/crochet/locale/es/LC_MESSAGES/django.po @@ -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///" msgstr "cuenta/restablecer///" -#: crochet/urls.py:87 +#: crochet/urls.py:91 msgid "account/reset/done/" msgstr "cuenta/restablecer/hecho/" diff --git a/crochet/templates/crochet/email/base.html b/crochet/templates/crochet/email/base.html new file mode 100644 index 0000000..51240ef --- /dev/null +++ b/crochet/templates/crochet/email/base.html @@ -0,0 +1,52 @@ +{% load i18n %} + + + + + + + {% block title %}Crochet{% endblock %} + + + + + + + + +
+ + + + + + + +
+ 🧶 Crochet +
+ {% block content %}{% endblock %} +
+ + + + +
+ {% block footer %}{% trans 'Este email se ha enviado automáticamente, no respondas a esta dirección.' %}{% endblock %} +
+
+ + + diff --git a/crochet/templates/crochet/email/email_changed_notice.html b/crochet/templates/crochet/email/email_changed_notice.html new file mode 100644 index 0000000..f347f13 --- /dev/null +++ b/crochet/templates/crochet/email/email_changed_notice.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 %} +

+ {% 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 %} +

+{% endblock %} diff --git a/crochet/templates/crochet/email/email_changed_notice.txt b/crochet/templates/crochet/email/email_changed_notice.txt new file mode 100644 index 0000000..303ec4b --- /dev/null +++ b/crochet/templates/crochet/email/email_changed_notice.txt @@ -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 %} diff --git a/crochet/templates/crochet/email/email_changed_subject.txt b/crochet/templates/crochet/email/email_changed_subject.txt new file mode 100644 index 0000000..487d52f --- /dev/null +++ b/crochet/templates/crochet/email/email_changed_subject.txt @@ -0,0 +1 @@ +{% load i18n %}{% blocktrans %}El email de tu cuenta en {{ site_name }} ha cambiado{% endblocktrans %} diff --git a/crochet/templates/crochet/email/password_reset.html b/crochet/templates/crochet/email/password_reset.html new file mode 100644 index 0000000..71fd847 --- /dev/null +++ b/crochet/templates/crochet/email/password_reset.html @@ -0,0 +1,25 @@ +{% extends 'crochet/email/base.html' %} +{% load i18n %} + +{% block title %}{% trans 'Recuperar contraseña' %}{% endblock %} + +{% block content %} +

+ {% blocktrans %}Has recibido este email porque alguien ha solicitado restablecer la contraseña de tu cuenta en {{ site_name }}.{% endblocktrans %} +

+ +

+ + {% trans 'Elegir nueva contraseña' %} + +

+ +

+ {% trans 'Tu usuario, por si lo has olvidado:' %} {{ user.get_username }} +

+ +

+ {% trans 'Si no has solicitado este cambio, puedes ignorar este email.' %} +

+{% endblock %} diff --git a/crochet/tests/test_account_settings.py b/crochet/tests/test_account_settings.py index fa8b7de..e0a1e78 100644 --- a/crochet/tests/test_account_settings.py +++ b/crochet/tests/test_account_settings.py @@ -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): diff --git a/crochet/tests/test_password_reset.py b/crochet/tests/test_password_reset.py index c217741..c07ab30 100644 --- a/crochet/tests/test_password_reset.py +++ b/crochet/tests/test_password_reset.py @@ -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): diff --git a/crochet/urls.py b/crochet/urls.py index 9c6c851..038af09 100644 --- a/crochet/urls.py +++ b/crochet/urls.py @@ -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'),