feat: send email to warn user about password change
CI / test (push) Successful in 1m8s
CI / build (push) Failing after 37s

This commit is contained in:
2026-07-21 14:04:28 +02:00
parent 6b5c353a86
commit d43bbbb5f2
7 changed files with 142 additions and 17 deletions
+35 -10
View File
@@ -19,6 +19,21 @@ from django.utils.translation import gettext_lazy as _
INPUT_CLASSES = 'input input-bordered w-full' INPUT_CLASSES = 'input input-bordered w-full'
def _send_account_notice(subject_template, text_template, html_template, context, to_email):
# Compartido por los avisos de seguridad de la cuenta (cambio de email,
# cambio de contraseña): mismo esqueleto de email (texto plano +
# alternativa HTML de crochet/email/base.html), solo cambian las
# plantillas y el destinatario.
subject = ''.join(render_to_string(subject_template, context).splitlines())
send_mail(
subject=subject,
message=render_to_string(text_template, context),
from_email=None,
recipient_list=[to_email],
html_message=render_to_string(html_template, context),
)
class StyledAuthenticationForm(AuthenticationForm): class StyledAuthenticationForm(AuthenticationForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -47,6 +62,21 @@ class StyledPasswordChangeForm(PasswordChangeForm):
for field in self.fields.values(): for field in self.fields.values():
field.widget.attrs['class'] = INPUT_CLASSES field.widget.attrs['class'] = INPUT_CLASSES
def save(self, commit=True):
user = super().save(commit=commit)
# A diferencia del cambio de email, aquí no hay una dirección
# "anterior" distinta: el aviso va al propio email de la cuenta.
# Sin email no hay a quién avisar (cuentas antiguas sin uno).
if user.email:
context = {'user': user, 'site_name': 'Crochet', 'support_email': settings.SUPPORT_EMAIL}
_send_account_notice(
'crochet/email/password_changed_subject.txt',
'crochet/email/password_changed_notice.txt',
'crochet/email/password_changed_notice.html',
context, user.email,
)
return user
class StyledPasswordResetForm(PasswordResetForm): class StyledPasswordResetForm(PasswordResetForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -79,15 +109,10 @@ class EmailUpdateForm(forms.Form):
# todavía puede leerlo. Si no había email antes, o si "cambiar" no # 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é. # ha cambiado nada, no hay a quién avisar ni de qué.
if old_email and old_email != self.user.email: 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} 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_account_notice(
send_mail( 'crochet/email/email_changed_subject.txt',
subject=subject, 'crochet/email/email_changed_notice.txt',
message=render_to_string('crochet/email/email_changed_notice.txt', context), 'crochet/email/email_changed_notice.html',
from_email=None, context, old_email,
recipient_list=[old_email],
html_message=render_to_string('crochet/email/email_changed_notice.html', context),
) )
+18 -2
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 11:41+0000\n" "POT-Creation-Date: 2026-07-21 11:59+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:33 crochet/forms.py:66 #: crochet/forms.py:48 crochet/forms.py:96
#: crochet/templates/crochet/account_settings.html:22 #: crochet/templates/crochet/account_settings.html:22
msgid "Email" msgid "Email"
msgstr "Email" msgstr "Email"
@@ -121,11 +121,15 @@ msgstr ""
#: crochet/templates/crochet/email/email_changed_notice.html:12 #: crochet/templates/crochet/email/email_changed_notice.html:12
#: crochet/templates/crochet/email/email_changed_notice.txt:4 #: crochet/templates/crochet/email/email_changed_notice.txt:4
#: crochet/templates/crochet/email/password_changed_notice.html:12
#: crochet/templates/crochet/email/password_changed_notice.txt:4
msgid "Tu usuario:" msgid "Tu usuario:"
msgstr "Your username:" msgstr "Your username:"
#: crochet/templates/crochet/email/email_changed_notice.html:16 #: crochet/templates/crochet/email/email_changed_notice.html:16
#: crochet/templates/crochet/email/email_changed_notice.txt:6 #: crochet/templates/crochet/email/email_changed_notice.txt:6
#: crochet/templates/crochet/email/password_changed_notice.html:16
#: crochet/templates/crochet/email/password_changed_notice.txt:6
#, python-format #, python-format
msgid "" msgid ""
"Si no has sido tú quien ha hecho este cambio, contacta cuanto antes con " "Si no has sido tú quien ha hecho este cambio, contacta cuanto antes con "
@@ -134,6 +138,18 @@ msgstr ""
"If you didn't make this change, please contact technical support at " "If you didn't make this change, please contact technical support at "
"%(support_email)s as soon as possible." "%(support_email)s as soon as possible."
#: crochet/templates/crochet/email/password_changed_notice.html:4
#: crochet/templates/crochet/email/password_changed_subject.txt:1
#, python-format
msgid "La contraseña de tu cuenta en %(site_name)s ha cambiado"
msgstr "The password on your %(site_name)s account has changed"
#: crochet/templates/crochet/email/password_changed_notice.html:8
#: crochet/templates/crochet/email/password_changed_notice.txt:2
#, python-format
msgid "La contraseña de tu cuenta en %(site_name)s ha cambiado."
msgstr "The password on your %(site_name)s account has changed."
#: crochet/templates/crochet/email/password_reset.html:4 #: crochet/templates/crochet/email/password_reset.html:4
#: crochet/templates/crochet/password_reset_done.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:4
+18 -2
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 11:41+0000\n" "POT-Creation-Date: 2026-07-21 11:59+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:33 crochet/forms.py:66 #: crochet/forms.py:48 crochet/forms.py:96
#: crochet/templates/crochet/account_settings.html:22 #: crochet/templates/crochet/account_settings.html:22
msgid "Email" msgid "Email"
msgstr "" msgstr ""
@@ -119,17 +119,33 @@ msgstr ""
#: crochet/templates/crochet/email/email_changed_notice.html:12 #: crochet/templates/crochet/email/email_changed_notice.html:12
#: crochet/templates/crochet/email/email_changed_notice.txt:4 #: crochet/templates/crochet/email/email_changed_notice.txt:4
#: crochet/templates/crochet/email/password_changed_notice.html:12
#: crochet/templates/crochet/email/password_changed_notice.txt:4
msgid "Tu usuario:" msgid "Tu usuario:"
msgstr "" msgstr ""
#: crochet/templates/crochet/email/email_changed_notice.html:16 #: crochet/templates/crochet/email/email_changed_notice.html:16
#: crochet/templates/crochet/email/email_changed_notice.txt:6 #: crochet/templates/crochet/email/email_changed_notice.txt:6
#: crochet/templates/crochet/email/password_changed_notice.html:16
#: crochet/templates/crochet/email/password_changed_notice.txt:6
#, python-format #, python-format
msgid "" msgid ""
"Si no has sido tú quien ha hecho este cambio, contacta cuanto antes con " "Si no has sido tú quien ha hecho este cambio, contacta cuanto antes con "
"soporte técnico en %(support_email)s." "soporte técnico en %(support_email)s."
msgstr "" msgstr ""
#: crochet/templates/crochet/email/password_changed_notice.html:4
#: crochet/templates/crochet/email/password_changed_subject.txt:1
#, python-format
msgid "La contraseña de tu cuenta en %(site_name)s ha cambiado"
msgstr ""
#: crochet/templates/crochet/email/password_changed_notice.html:8
#: crochet/templates/crochet/email/password_changed_notice.txt:2
#, python-format
msgid "La contraseña de tu cuenta en %(site_name)s ha cambiado."
msgstr ""
#: crochet/templates/crochet/email/password_reset.html:4 #: crochet/templates/crochet/email/password_reset.html:4
#: crochet/templates/crochet/password_reset_done.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:4
@@ -0,0 +1,18 @@
{% extends 'crochet/email/base.html' %}
{% load i18n %}
{% block title %}{% blocktrans %}La contraseña de tu cuenta en {{ site_name }} ha cambiado{% endblocktrans %}{% endblock %}
{% block content %}
<p style="margin:0 0 16px 0;">
{% blocktrans %}La contraseña de tu cuenta en {{ site_name }} ha cambiado.{% 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 %}La contraseña de tu cuenta en {{ site_name }} ha cambiado.{% 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 %}La contraseña de tu cuenta en {{ site_name }} ha cambiado{% endblocktrans %}
+43 -1
View File
@@ -116,7 +116,9 @@ class AccountEmailUpdateViewTests(TestCase):
class AccountPasswordChangeViewTests(TestCase): class AccountPasswordChangeViewTests(TestCase):
def setUp(self): def setUp(self):
self.user = User.objects.create_user(username='crocheter', password='the-old-password-1') self.user = User.objects.create_user(
username='crocheter', password='the-old-password-1', email='crocheter@example.com',
)
with translation.override('es'): with translation.override('es'):
self.url = reverse('crochet:account_settings_password') self.url = reverse('crochet:account_settings_password')
@@ -177,3 +179,43 @@ class AccountPasswordChangeViewTests(TestCase):
self.assertContains(response, 'class="text-error text-xs"') self.assertContains(response, 'class="text-error text-xs"')
self.user.refresh_from_db() self.user.refresh_from_db()
self.assertTrue(self.user.check_password('the-old-password-1')) self.assertTrue(self.user.check_password('the-old-password-1'))
def test_valid_password_change_notifies_the_users_email(self):
self.client.force_login(self.user)
self.client.post(self.url, {
'old_password': 'the-old-password-1',
'new_password1': 'the-new-password-2',
'new_password2': 'the-new-password-2',
})
self.assertEqual(len(mail.outbox), 1)
message = mail.outbox[0]
self.assertEqual(message.to, ['crocheter@example.com'])
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_user_without_an_email_gets_no_notification(self):
user_without_email = User.objects.create_user(username='noemail', password='the-old-password-1')
self.client.force_login(user_without_email)
self.client.post(self.url, {
'old_password': 'the-old-password-1',
'new_password1': 'the-new-password-2',
'new_password2': 'the-new-password-2',
})
self.assertEqual(len(mail.outbox), 0)
def test_wrong_old_password_does_not_send_a_notification(self):
self.client.force_login(self.user)
self.client.post(self.url, {
'old_password': 'wrong-password',
'new_password1': 'the-new-password-2',
'new_password2': 'the-new-password-2',
})
self.assertEqual(len(mail.outbox), 0)