diff --git a/crochet/forms.py b/crochet/forms.py index 8ec599e..0bcc2f8 100644 --- a/crochet/forms.py +++ b/crochet/forms.py @@ -19,6 +19,21 @@ from django.utils.translation import gettext_lazy as _ 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): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -47,6 +62,21 @@ class StyledPasswordChangeForm(PasswordChangeForm): for field in self.fields.values(): 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): 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 # 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), - ) + context = {'user': self.user, 'old_email': old_email, 'site_name': 'Crochet', 'support_email': settings.SUPPORT_EMAIL} + _send_account_notice( + 'crochet/email/email_changed_subject.txt', + 'crochet/email/email_changed_notice.txt', + 'crochet/email/email_changed_notice.html', + context, old_email, + ) diff --git a/crochet/locale/en/LC_MESSAGES/django.po b/crochet/locale/en/LC_MESSAGES/django.po index f9bce0f..89ae263 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 11:41+0000\n" +"POT-Creation-Date: 2026-07-21 11:59+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:33 crochet/forms.py:66 +#: crochet/forms.py:48 crochet/forms.py:96 #: crochet/templates/crochet/account_settings.html:22 msgid "Email" msgstr "Email" @@ -121,11 +121,15 @@ msgstr "" #: crochet/templates/crochet/email/email_changed_notice.html:12 #: 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:" msgstr "Your username:" #: crochet/templates/crochet/email/email_changed_notice.html:16 #: 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 msgid "" "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 " "%(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/password_reset_done.html:4 #: crochet/templates/crochet/password_reset_form.html:4 diff --git a/crochet/locale/es/LC_MESSAGES/django.po b/crochet/locale/es/LC_MESSAGES/django.po index 75075b2..47759c9 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 11:41+0000\n" +"POT-Creation-Date: 2026-07-21 11:59+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:33 crochet/forms.py:66 +#: crochet/forms.py:48 crochet/forms.py:96 #: crochet/templates/crochet/account_settings.html:22 msgid "Email" msgstr "" @@ -119,17 +119,33 @@ msgstr "" #: crochet/templates/crochet/email/email_changed_notice.html:12 #: 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:" msgstr "" #: crochet/templates/crochet/email/email_changed_notice.html:16 #: 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 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_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/password_reset_done.html:4 #: crochet/templates/crochet/password_reset_form.html:4 diff --git a/crochet/templates/crochet/email/password_changed_notice.html b/crochet/templates/crochet/email/password_changed_notice.html new file mode 100644 index 0000000..642d0b9 --- /dev/null +++ b/crochet/templates/crochet/email/password_changed_notice.html @@ -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 %} +
+ {% 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 %} +
+{% endblock %} diff --git a/crochet/templates/crochet/email/password_changed_notice.txt b/crochet/templates/crochet/email/password_changed_notice.txt new file mode 100644 index 0000000..746c2ef --- /dev/null +++ b/crochet/templates/crochet/email/password_changed_notice.txt @@ -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 %} diff --git a/crochet/templates/crochet/email/password_changed_subject.txt b/crochet/templates/crochet/email/password_changed_subject.txt new file mode 100644 index 0000000..010d2f1 --- /dev/null +++ b/crochet/templates/crochet/email/password_changed_subject.txt @@ -0,0 +1 @@ +{% load i18n %}{% blocktrans %}La contraseña de tu cuenta en {{ site_name }} ha cambiado{% endblocktrans %} diff --git a/crochet/tests/test_account_settings.py b/crochet/tests/test_account_settings.py index e0a1e78..d4a4ea5 100644 --- a/crochet/tests/test_account_settings.py +++ b/crochet/tests/test_account_settings.py @@ -116,7 +116,9 @@ class AccountEmailUpdateViewTests(TestCase): class AccountPasswordChangeViewTests(TestCase): 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'): self.url = reverse('crochet:account_settings_password') @@ -177,3 +179,43 @@ class AccountPasswordChangeViewTests(TestCase): self.assertContains(response, 'class="text-error text-xs"') self.user.refresh_from_db() 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)