feat: warn user about email change
CI / test (push) Successful in 1m5s
CI / build (push) Successful in 37s

This commit is contained in:
2026-07-21 13:44:56 +02:00
parent 3e95ee2239
commit 6b5c353a86
14 changed files with 499 additions and 93 deletions
+180
View File
@@ -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`).
+1 -26
View File
@@ -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,
+4 -5
View File
@@ -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')
+22
View File
@@ -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),
)
+73 -32
View File
@@ -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 ""
+67 -30
View File
@@ -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/"
+52
View File
@@ -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 %}
+37
View File
@@ -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):
+8
View File
@@ -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):
+4
View File
@@ -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'),