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_URL = 'static/'
STATIC_ROOT = 'config/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 = { STORAGES = {
'default': { 'default': {
'BACKEND': 'django.core.files.storage.FileSystemStorage', 'BACKEND': 'django.core.files.storage.FileSystemStorage',
@@ -161,8 +158,6 @@ STORAGES = {
if S3_ENABLED: if S3_ENABLED:
STORAGES['default'] = {'BACKEND': 'storages.backends.s3boto3.S3Boto3Storage'} 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_URL = 'media/'
MEDIA_ROOT = BASE_DIR / 'media' MEDIA_ROOT = BASE_DIR / 'media'
@@ -173,14 +168,6 @@ EMAIL_USE_SSL = False
# Logging # Logging
# https://docs.djangoproject.com/en/6.0/topics/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 = { LOGGING = {
'version': 1, 'version': 1,
'disable_existing_loggers': False, 'disable_existing_loggers': False,
@@ -196,11 +183,6 @@ LOGGING = {
'formatter': 'verbose', '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': { 'root': {
'handlers': ['console'], 'handlers': ['console'],
'level': 'WARNING', 'level': 'WARNING',
@@ -211,11 +193,7 @@ LOGGING = {
'level': LOG_LEVEL, 'level': LOG_LEVEL,
'propagate': False, 'propagate': False,
}, },
# Aquí es donde Django registra la traza completa de cualquier 'django.request': {
# 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'], 'handlers': ['console'],
'level': 'ERROR', 'level': 'ERROR',
'propagate': False, 'propagate': False,
@@ -225,9 +203,6 @@ LOGGING = {
'level': 'WARNING', 'level': 'WARNING',
'propagate': False, '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': { 'crochet': {
'handlers': ['console'], 'handlers': ['console'],
'level': LOG_LEVEL, '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) EMAIL_PORT = env.int('EMAIL_PORT', 587)
DEFAULT_FROM_EMAIL = env.str('DEFAULT_FROM_EMAIL', 'webmaster@localhost') 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 # A dónde se remite al usuario en el aviso de cambio de email (ver
# (DEBUG=False) Django, por defecto, solo manda los errores por email a # EmailUpdateForm) por si el cambio no lo ha hecho él.
# ADMINS (que aquí no está configurado), así que sin un LOGGING propio no se SUPPORT_EMAIL = env.str('SUPPORT_EMAIL', 'soporte@localhost')
# 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.
LOG_LEVEL = env.str('LOG_LEVEL', 'INFO') LOG_LEVEL = env.str('LOG_LEVEL', 'INFO')
+22
View File
@@ -1,4 +1,5 @@
from django import forms from django import forms
from django.conf import settings
from django.contrib.auth.forms import ( from django.contrib.auth.forms import (
AuthenticationForm, AuthenticationForm,
PasswordChangeForm, PasswordChangeForm,
@@ -6,6 +7,8 @@ from django.contrib.auth.forms import (
SetPasswordForm, SetPasswordForm,
UserCreationForm, UserCreationForm,
) )
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
# Los formularios de auth de Django no traen ninguna clase CSS en sus # 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) super().__init__(*args, **kwargs)
def save(self): def save(self):
old_email = self.user.email
self.user.email = self.cleaned_data['email'] self.user.email = self.cleaned_data['email']
self.user.save(update_fields=['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 "" msgstr ""
"Project-Id-Version: crochet\n" "Project-Id-Version: crochet\n"
"Report-Msgid-Bugs-To: \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" "Language: en\n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\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 #: crochet/templates/crochet/account_settings.html:22
msgid "Email" msgid "Email"
msgstr "Email" msgstr "Email"
@@ -98,6 +98,74 @@ msgstr "Log out"
msgid "Iniciar sesión" msgid "Iniciar sesión"
msgstr "Log in" 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 #: crochet/templates/crochet/home.html:4
msgid "Crochet — Crea y comparte tus patrones" msgid "Crochet — Crea y comparte tus patrones"
msgstr "Crochet — Create and share your patterns" 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." msgid "Ya puedes iniciar sesión con tu nueva contraseña."
msgstr "You can now log in with your new password." 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 #: crochet/templates/crochet/password_reset_confirm.html:15
msgid "Elige una contraseña nueva" msgid "Elige una contraseña nueva"
msgstr "Choose a new password" msgstr "Choose a new password"
@@ -191,12 +255,6 @@ msgstr ""
msgid "Solicitar un enlace nuevo" msgid "Solicitar un enlace nuevo"
msgstr "Request a new link" 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 #: crochet/templates/crochet/password_reset_done.html:14
msgid "Revisa tu email" msgid "Revisa tu email"
msgstr "Check your email" msgstr "Check your email"
@@ -214,27 +272,10 @@ msgstr ""
msgid "Volver a iniciar sesión" msgid "Volver a iniciar sesión"
msgstr "Back to login" 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 #: crochet/templates/crochet/password_reset_email.html:4
msgid "Sigue este enlace para elegir una contraseña nueva:" msgid "Sigue este enlace para elegir una contraseña nueva:"
msgstr "Follow this link to choose a new password:" 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 #: crochet/templates/crochet/password_reset_form.html:16
msgid "" msgid ""
"Escribe tu email y te enviaremos un enlace para elegir una contraseña nueva." "Escribe tu email y te enviaremos un enlace para elegir una contraseña nueva."
@@ -588,15 +629,15 @@ msgstr ""
msgid "account/password-reset/" msgid "account/password-reset/"
msgstr "" msgstr ""
#: crochet/urls.py:75 #: crochet/urls.py:79
msgid "account/password-reset/done/" msgid "account/password-reset/done/"
msgstr "" msgstr ""
#: crochet/urls.py:79 #: crochet/urls.py:83
msgid "account/reset/<uidb64>/<token>/" msgid "account/reset/<uidb64>/<token>/"
msgstr "" msgstr ""
#: crochet/urls.py:87 #: crochet/urls.py:91
msgid "account/reset/done/" msgid "account/reset/done/"
msgstr "" msgstr ""
+67 -30
View File
@@ -2,13 +2,13 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: crochet\n" "Project-Id-Version: crochet\n"
"Report-Msgid-Bugs-To: \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" "Language: es\n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\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 #: crochet/templates/crochet/account_settings.html:22
msgid "Email" msgid "Email"
msgstr "" msgstr ""
@@ -98,6 +98,68 @@ msgstr ""
msgid "Iniciar sesión" msgid "Iniciar sesión"
msgstr "" 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 #: crochet/templates/crochet/home.html:4
msgid "Crochet — Crea y comparte tus patrones" msgid "Crochet — Crea y comparte tus patrones"
msgstr "" msgstr ""
@@ -159,10 +221,6 @@ msgstr ""
msgid "Ya puedes iniciar sesión con tu nueva contraseña." msgid "Ya puedes iniciar sesión con tu nueva contraseña."
msgstr "" msgstr ""
#: crochet/templates/crochet/password_reset_confirm.html:4
msgid "Elegir nueva contraseña"
msgstr ""
#: crochet/templates/crochet/password_reset_confirm.html:15 #: crochet/templates/crochet/password_reset_confirm.html:15
msgid "Elige una contraseña nueva" msgid "Elige una contraseña nueva"
msgstr "" msgstr ""
@@ -181,12 +239,6 @@ msgstr ""
msgid "Solicitar un enlace nuevo" msgid "Solicitar un enlace nuevo"
msgstr "" 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 #: crochet/templates/crochet/password_reset_done.html:14
msgid "Revisa tu email" msgid "Revisa tu email"
msgstr "" msgstr ""
@@ -202,25 +254,10 @@ msgstr ""
msgid "Volver a iniciar sesión" msgid "Volver a iniciar sesión"
msgstr "" 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 #: crochet/templates/crochet/password_reset_email.html:4
msgid "Sigue este enlace para elegir una contraseña nueva:" msgid "Sigue este enlace para elegir una contraseña nueva:"
msgstr "" 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 #: crochet/templates/crochet/password_reset_form.html:16
msgid "" msgid ""
"Escribe tu email y te enviaremos un enlace para elegir una contraseña nueva." "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/" msgid "account/password-reset/"
msgstr "cuenta/recuperar-contrasena/" msgstr "cuenta/recuperar-contrasena/"
#: crochet/urls.py:75 #: crochet/urls.py:79
msgid "account/password-reset/done/" msgid "account/password-reset/done/"
msgstr "cuenta/recuperar-contrasena/enviado/" msgstr "cuenta/recuperar-contrasena/enviado/"
#: crochet/urls.py:79 #: crochet/urls.py:83
msgid "account/reset/<uidb64>/<token>/" msgid "account/reset/<uidb64>/<token>/"
msgstr "cuenta/restablecer/<uidb64>/<token>/" msgstr "cuenta/restablecer/<uidb64>/<token>/"
#: crochet/urls.py:87 #: crochet/urls.py:91
msgid "account/reset/done/" msgid "account/reset/done/"
msgstr "cuenta/restablecer/hecho/" 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.contrib.auth.models import User
from django.core import mail
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from django.utils import translation from django.utils import translation
@@ -76,6 +77,42 @@ class AccountEmailUpdateViewTests(TestCase):
other.refresh_from_db() other.refresh_from_db()
self.assertEqual(other.email, 'other@example.com') 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): class AccountPasswordChangeViewTests(TestCase):
def setUp(self): def setUp(self):
+8
View File
@@ -32,6 +32,14 @@ class PasswordResetRequestViewTests(TestCase):
self.assertIn('crocheter@example.com', mail.outbox[0].to) self.assertIn('crocheter@example.com', mail.outbox[0].to)
self.assertIn('http://testserver', mail.outbox[0].body) 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): 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 # Ni la vista ni la plantilla deben delatar si el email existe o no
# en el sistema (comportamiento por defecto de PasswordResetForm): # 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. # encontraría siempre las suyas primero, tapando las nuestras.
template_name='crochet/password_reset_form.html', template_name='crochet/password_reset_form.html',
email_template_name='crochet/password_reset_email.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', subject_template_name='crochet/password_reset_subject.txt',
form_class=StyledPasswordResetForm, form_class=StyledPasswordResetForm,
success_url=reverse_lazy('crochet:password_reset_done'), success_url=reverse_lazy('crochet:password_reset_done'),