From 1a1c8786a39c8b97bb00e88be614848337d3d5cf Mon Sep 17 00:00:00 2001 From: Pablo Moreno Date: Wed, 22 Jul 2026 10:03:30 +0200 Subject: [PATCH] feat: added delete account and new display name --- config/settings/base.py | 2 +- crochet/forms.py | 48 +++++ crochet/locale/en/LC_MESSAGES/django.po | 168 ++++++++++++------ crochet/locale/es/LC_MESSAGES/django.po | 162 +++++++++++------ .../crochet/_account_delete_form.html | 20 +++ .../crochet/_account_display_name_form.html | 18 ++ crochet/templates/crochet/account_home.html | 2 +- .../templates/crochet/account_settings.html | 22 ++- crochet/templates/crochet/base.html | 17 +- .../crochet/email/account_deleted_notice.html | 18 ++ .../crochet/email/account_deleted_notice.txt | 7 + .../crochet/email/account_deleted_subject.txt | 1 + crochet/templates/crochet/home.html | 12 +- crochet/tests/test_account_settings.py | 121 ++++++++++++- crochet/tests/test_auth.py | 47 +++-- crochet/tests/test_views.py | 23 ++- crochet/urls.py | 9 +- crochet/views.py | 93 +++++++--- 18 files changed, 630 insertions(+), 160 deletions(-) create mode 100644 crochet/templates/crochet/_account_delete_form.html create mode 100644 crochet/templates/crochet/_account_display_name_form.html create mode 100644 crochet/templates/crochet/email/account_deleted_notice.html create mode 100644 crochet/templates/crochet/email/account_deleted_notice.txt create mode 100644 crochet/templates/crochet/email/account_deleted_subject.txt diff --git a/config/settings/base.py b/config/settings/base.py index d9b5d4a..a461d94 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -99,7 +99,7 @@ DATABASES = { # https://docs.djangoproject.com/en/6.0/topics/auth/default/#all-authentication-views LOGIN_URL = 'crochet:login' -LOGIN_REDIRECT_URL = 'crochet:account_home' +LOGIN_REDIRECT_URL = 'crochet:home' LOGOUT_REDIRECT_URL = 'crochet:login' diff --git a/crochet/forms.py b/crochet/forms.py index 0bcc2f8..8d6b098 100644 --- a/crochet/forms.py +++ b/crochet/forms.py @@ -116,3 +116,51 @@ class EmailUpdateForm(forms.Form): 'crochet/email/email_changed_notice.html', context, old_email, ) + + +class DisplayNameForm(forms.Form): + # Se guarda en el propio first_name de auth.User (no se usa para nada + # más en la app): no hace falta un modelo de perfil aparte solo para un + # campo de texto opcional. Se propone como autor por defecto al crear + # un patrón nuevo (ver PatternCreateView) y se muestra en el navbar/ + # saludo de "Mis patrones" en vez del username, si está puesto. + display_name = forms.CharField( + label=_('Nombre para mostrar'), required=False, max_length=150, + widget=forms.TextInput(attrs={'class': INPUT_CLASSES}), + ) + + def __init__(self, *args, user, **kwargs): + self.user = user + super().__init__(*args, **kwargs) + + def save(self): + self.user.first_name = self.cleaned_data['display_name'] + self.user.save(update_fields=['first_name']) + + +class AccountDeleteForm(forms.Form): + password = forms.CharField( + label=_('Contraseña'), widget=forms.PasswordInput(attrs={'class': INPUT_CLASSES}), + ) + + def __init__(self, *args, user, **kwargs): + self.user = user + super().__init__(*args, **kwargs) + + def clean_password(self): + password = self.cleaned_data['password'] + if not self.user.check_password(password): + raise forms.ValidationError(_('Contraseña incorrecta.')) + return password + + def notify_account_deleted(self): + # Antes de borrar la cuenta (ver AccountDeleteView), mientras el + # email todavía existe. Sin email no hay a quién avisar. + if self.user.email: + context = {'user': self.user, 'site_name': 'Crochet', 'support_email': settings.SUPPORT_EMAIL} + _send_account_notice( + 'crochet/email/account_deleted_subject.txt', + 'crochet/email/account_deleted_notice.txt', + 'crochet/email/account_deleted_notice.html', + context, self.user.email, + ) diff --git a/crochet/locale/en/LC_MESSAGES/django.po b/crochet/locale/en/LC_MESSAGES/django.po index 6ef99e8..b795050 100644 --- a/crochet/locale/en/LC_MESSAGES/django.po +++ b/crochet/locale/en/LC_MESSAGES/django.po @@ -2,17 +2,50 @@ msgid "" msgstr "" "Project-Id-Version: crochet\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-21 12:32+0000\n" +"POT-Creation-Date: 2026-07-22 08:00+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:48 crochet/forms.py:96 -#: crochet/templates/crochet/account_settings.html:22 +#: crochet/templates/crochet/account_settings.html:29 msgid "Email" msgstr "Email" +#: crochet/forms.py:128 crochet/templates/crochet/account_settings.html:22 +msgid "Nombre para mostrar" +msgstr "Display name" + +#: crochet/forms.py:143 +msgid "Contraseña" +msgstr "Password" + +#: crochet/forms.py:153 +msgid "Contraseña incorrecta." +msgstr "Incorrect password." + +#: crochet/templates/crochet/_account_delete_form.html:9 +msgid "" +"¿Seguro que quieres eliminar tu cuenta? Se borrarán también todos tus " +"patrones. Esta acción no se puede deshacer." +msgstr "" +"Are you sure you want to delete your account? All your patterns will also be " +"deleted. This action cannot be undone." + +#: crochet/templates/crochet/_account_delete_form.html:19 +#: crochet/templates/crochet/account_settings.html:48 +msgid "Eliminar cuenta" +msgstr "Delete account" + +#: crochet/templates/crochet/_account_display_name_form.html:7 +msgid "Nombre actualizado." +msgstr "Name updated." + +#: crochet/templates/crochet/_account_display_name_form.html:17 +msgid "Guardar nombre" +msgstr "Save name" + #: crochet/templates/crochet/_account_email_form.html:10 msgid "Email actualizado." msgstr "Email updated." @@ -26,7 +59,7 @@ msgid "Contraseña actualizada." msgstr "Password updated." #: crochet/templates/crochet/_account_password_form.html:19 -#: crochet/templates/crochet/account_settings.html:29 +#: crochet/templates/crochet/account_settings.html:36 #: crochet/templates/crochet/password_reset_confirm.html:30 msgid "Cambiar contraseña" msgstr "Change password" @@ -49,7 +82,6 @@ msgid "Entendido" msgstr "Agree" #: crochet/templates/crochet/account_home.html:4 -#: crochet/templates/crochet/pattern_detail.html:43 msgid "Mis patrones" msgstr "My patterns" @@ -96,10 +128,14 @@ msgstr "You don't have any patterns yet." msgid "Ajustes de la cuenta" msgstr "Account settings" -#: crochet/templates/crochet/account_settings.html:34 +#: crochet/templates/crochet/account_settings.html:41 msgid "Volver a mis patrones" msgstr "Back to my patterns" +#: crochet/templates/crochet/account_settings.html:50 +msgid "Esto borrará tu cuenta y todos tus patrones de forma permanente." +msgstr "This will permanently delete your account and all your patterns." + #: crochet/templates/crochet/base.html:39 msgid "Cambiar idioma" msgstr "Change language" @@ -109,9 +145,8 @@ msgid "Cerrar sesión" msgstr "Log out" #: crochet/templates/crochet/base.html:62 -#: crochet/templates/crochet/home.html:24 +#: crochet/templates/crochet/home.html:25 #: crochet/templates/crochet/password_reset_complete.html:18 -#: crochet/templates/crochet/pattern_detail.html:45 #: crochet/templates/registration/login.html:4 #: crochet/templates/registration/login.html:14 msgid "Iniciar sesión" @@ -186,6 +221,45 @@ msgstr "" msgid "Volver al inicio" msgstr "Back to home" +#: crochet/templates/crochet/email/account_deleted_notice.html:4 +#: crochet/templates/crochet/email/account_deleted_subject.txt:1 +#, python-format +msgid "Tu cuenta en %(site_name)s ha sido eliminada" +msgstr "Your %(site_name)s account has been deleted" + +#: crochet/templates/crochet/email/account_deleted_notice.html:8 +#: crochet/templates/crochet/email/account_deleted_notice.txt:2 +#, python-format +msgid "" +"Tu cuenta en %(site_name)s y todos tus patrones se han eliminado de forma " +"permanente." +msgstr "" +"Your %(site_name)s account and all your patterns have been permanently " +"deleted." + +#: crochet/templates/crochet/email/account_deleted_notice.html:12 +#: crochet/templates/crochet/email/account_deleted_notice.txt:4 +#: 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/account_deleted_notice.html:16 +#: crochet/templates/crochet/email/account_deleted_notice.txt:6 +#: 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 "" +"If you didn't make this change, please contact technical support at " +"%(support_email)s as soon as possible." + #: crochet/templates/crochet/email/base.html:43 msgid "" "Este email se ha enviado automáticamente, no respondas a esta dirección." @@ -207,25 +281,6 @@ 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 -#: 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 " -"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_changed_notice.html:4 #: crochet/templates/crochet/email/password_changed_subject.txt:1 #, python-format @@ -286,11 +341,7 @@ msgstr "" "Design your patterns with a visual editor, export them to PDF, and share " "them with anyone, no installation needed." -#: crochet/templates/crochet/home.html:21 -msgid "Ir a mis patrones" -msgstr "Go to my patterns" - -#: crochet/templates/crochet/home.html:23 +#: crochet/templates/crochet/home.html:24 msgid "Crear cuenta gratis" msgstr "Create a free account" @@ -636,7 +687,7 @@ msgstr "" msgid "Añade una sección para ver aquí el resultado." msgstr "Add a section to see the result here." -#: crochet/templates/crochet/pattern_detail.html:31 +#: crochet/templates/crochet/pattern_detail.html:29 msgid "Descargar PDF" msgstr "Download PDF" @@ -669,44 +720,40 @@ msgstr "Sign up" msgid "¿Ya tienes cuenta? Inicia sesión" msgstr "Already have an account? Log in" -#: crochet/urls.py:37 +#: crochet/urls.py:38 msgid "cookies/" msgstr "" -#: crochet/urls.py:38 +#: crochet/urls.py:39 msgid "pattern//" msgstr "" -#: crochet/urls.py:39 +#: crochet/urls.py:40 msgid "pattern//edit/" msgstr "" -#: crochet/urls.py:40 +#: crochet/urls.py:41 msgid "pattern//save/" msgstr "" -#: crochet/urls.py:41 +#: crochet/urls.py:42 msgid "pattern//images/" msgstr "" -#: crochet/urls.py:42 +#: crochet/urls.py:43 msgid "pattern//cover/" msgstr "" -#: crochet/urls.py:43 +#: crochet/urls.py:44 msgid "pattern//pdf/" msgstr "" -#: crochet/urls.py:44 +#: crochet/urls.py:45 msgid "pattern//delete/" msgstr "" -#: crochet/urls.py:45 -msgid "pattern/new/" -msgstr "" - #: crochet/urls.py:46 -msgid "account/" +msgid "pattern/new/" msgstr "" #: crochet/urls.py:47 @@ -721,39 +768,54 @@ msgstr "" msgid "account/settings/password/" msgstr "" -#: crochet/urls.py:53 +#: crochet/urls.py:54 +msgid "account/settings/display-name/" +msgstr "" + +#: crochet/urls.py:57 +msgid "account/settings/delete/" +msgstr "" + +#: crochet/urls.py:58 msgid "account/register/" msgstr "" -#: crochet/urls.py:55 +#: crochet/urls.py:60 msgid "account/login/" msgstr "" -#: crochet/urls.py:60 +#: crochet/urls.py:65 msgid "account/logout/" msgstr "" -#: crochet/urls.py:62 +#: crochet/urls.py:67 msgid "account/password-reset/" msgstr "" -#: crochet/urls.py:81 +#: crochet/urls.py:86 msgid "account/password-reset/done/" msgstr "" -#: crochet/urls.py:85 +#: crochet/urls.py:90 msgid "account/reset///" msgstr "" -#: crochet/urls.py:93 +#: crochet/urls.py:98 msgid "account/reset/done/" msgstr "" # crochet/views.py (PatternDetailView.EMPTY_MESSAGE) -#: crochet/views.py:24 +#: crochet/views.py:29 msgid "Este patrón todavía no tiene contenido." msgstr "This pattern doesn't have any content yet." +#: crochet/views.py:350 +msgid "Tu cuenta se ha eliminado correctamente." +msgstr "Your account has been deleted successfully." + +#~ msgid "Ir a mis patrones" +#~ msgstr "Go to my patterns" + #~ msgid "Idioma" #~ msgstr "Language" diff --git a/crochet/locale/es/LC_MESSAGES/django.po b/crochet/locale/es/LC_MESSAGES/django.po index e763925..0d79eb3 100644 --- a/crochet/locale/es/LC_MESSAGES/django.po +++ b/crochet/locale/es/LC_MESSAGES/django.po @@ -2,17 +2,48 @@ msgid "" msgstr "" "Project-Id-Version: crochet\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-21 12:32+0000\n" +"POT-Creation-Date: 2026-07-22 08:00+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:48 crochet/forms.py:96 -#: crochet/templates/crochet/account_settings.html:22 +#: crochet/templates/crochet/account_settings.html:29 msgid "Email" msgstr "" +#: crochet/forms.py:128 crochet/templates/crochet/account_settings.html:22 +msgid "Nombre para mostrar" +msgstr "" + +#: crochet/forms.py:143 +msgid "Contraseña" +msgstr "" + +#: crochet/forms.py:153 +msgid "Contraseña incorrecta." +msgstr "" + +#: crochet/templates/crochet/_account_delete_form.html:9 +msgid "" +"¿Seguro que quieres eliminar tu cuenta? Se borrarán también todos tus " +"patrones. Esta acción no se puede deshacer." +msgstr "" + +#: crochet/templates/crochet/_account_delete_form.html:19 +#: crochet/templates/crochet/account_settings.html:48 +msgid "Eliminar cuenta" +msgstr "" + +#: crochet/templates/crochet/_account_display_name_form.html:7 +msgid "Nombre actualizado." +msgstr "" + +#: crochet/templates/crochet/_account_display_name_form.html:17 +msgid "Guardar nombre" +msgstr "" + #: crochet/templates/crochet/_account_email_form.html:10 msgid "Email actualizado." msgstr "" @@ -26,7 +57,7 @@ msgid "Contraseña actualizada." msgstr "" #: crochet/templates/crochet/_account_password_form.html:19 -#: crochet/templates/crochet/account_settings.html:29 +#: crochet/templates/crochet/account_settings.html:36 #: crochet/templates/crochet/password_reset_confirm.html:30 msgid "Cambiar contraseña" msgstr "" @@ -47,7 +78,6 @@ msgid "Entendido" msgstr "" #: crochet/templates/crochet/account_home.html:4 -#: crochet/templates/crochet/pattern_detail.html:43 msgid "Mis patrones" msgstr "" @@ -94,10 +124,14 @@ msgstr "" msgid "Ajustes de la cuenta" msgstr "" -#: crochet/templates/crochet/account_settings.html:34 +#: crochet/templates/crochet/account_settings.html:41 msgid "Volver a mis patrones" msgstr "" +#: crochet/templates/crochet/account_settings.html:50 +msgid "Esto borrará tu cuenta y todos tus patrones de forma permanente." +msgstr "" + #: crochet/templates/crochet/base.html:39 msgid "Cambiar idioma" msgstr "" @@ -107,9 +141,8 @@ msgid "Cerrar sesión" msgstr "" #: crochet/templates/crochet/base.html:62 -#: crochet/templates/crochet/home.html:24 +#: crochet/templates/crochet/home.html:25 #: crochet/templates/crochet/password_reset_complete.html:18 -#: crochet/templates/crochet/pattern_detail.html:45 #: crochet/templates/registration/login.html:4 #: crochet/templates/registration/login.html:14 msgid "Iniciar sesión" @@ -177,6 +210,41 @@ msgstr "" msgid "Volver al inicio" msgstr "" +#: crochet/templates/crochet/email/account_deleted_notice.html:4 +#: crochet/templates/crochet/email/account_deleted_subject.txt:1 +#, python-format +msgid "Tu cuenta en %(site_name)s ha sido eliminada" +msgstr "" + +#: crochet/templates/crochet/email/account_deleted_notice.html:8 +#: crochet/templates/crochet/email/account_deleted_notice.txt:2 +#, python-format +msgid "" +"Tu cuenta en %(site_name)s y todos tus patrones se han eliminado de forma " +"permanente." +msgstr "" + +#: crochet/templates/crochet/email/account_deleted_notice.html:12 +#: crochet/templates/crochet/email/account_deleted_notice.txt:4 +#: 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/account_deleted_notice.html:16 +#: crochet/templates/crochet/email/account_deleted_notice.txt:6 +#: 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/base.html:43 msgid "" "Este email se ha enviado automáticamente, no respondas a esta dirección." @@ -196,23 +264,6 @@ msgid "" "(%(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 -#: 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 @@ -269,11 +320,7 @@ msgid "" "quien quieras, sin instalar nada." msgstr "" -#: crochet/templates/crochet/home.html:21 -msgid "Ir a mis patrones" -msgstr "" - -#: crochet/templates/crochet/home.html:23 +#: crochet/templates/crochet/home.html:24 msgid "Crear cuenta gratis" msgstr "" @@ -606,7 +653,7 @@ msgstr "" msgid "Añade una sección para ver aquí el resultado." msgstr "" -#: crochet/templates/crochet/pattern_detail.html:31 +#: crochet/templates/crochet/pattern_detail.html:29 msgid "Descargar PDF" msgstr "" @@ -639,49 +686,44 @@ msgstr "" msgid "¿Ya tienes cuenta? Inicia sesión" msgstr "" -#: crochet/urls.py:37 +#: crochet/urls.py:38 msgid "cookies/" msgstr "" # Rutas traducibles de crochet/urls.py: la base "pattern/" se traduce como # "patron/" (sin tilde, para no meter caracteres acentuados en la URL). -#: crochet/urls.py:38 +#: crochet/urls.py:39 msgid "pattern//" msgstr "patron//" -#: crochet/urls.py:39 +#: crochet/urls.py:40 msgid "pattern//edit/" msgstr "patron//editar/" -#: crochet/urls.py:40 +#: crochet/urls.py:41 msgid "pattern//save/" msgstr "patron//guardar/" -#: crochet/urls.py:41 +#: crochet/urls.py:42 msgid "pattern//images/" msgstr "patron//imagenes/" -#: crochet/urls.py:42 +#: crochet/urls.py:43 msgid "pattern//cover/" msgstr "patron//portada/" -#: crochet/urls.py:43 +#: crochet/urls.py:44 msgid "pattern//pdf/" msgstr "patron//pdf/" -#: crochet/urls.py:44 +#: crochet/urls.py:45 msgid "pattern//delete/" msgstr "patron//eliminar/" -#: crochet/urls.py:45 +#: crochet/urls.py:46 msgid "pattern/new/" msgstr "patron/nuevo/" -# Igual que "pattern/" arriba: "account/" se traduce como "cuenta/". -#: crochet/urls.py:46 -msgid "account/" -msgstr "cuenta/" - #: crochet/urls.py:47 msgid "account/settings/" msgstr "cuenta/ajustes/" @@ -694,34 +736,50 @@ msgstr "cuenta/ajustes/email/" msgid "account/settings/password/" msgstr "cuenta/ajustes/contrasena/" -#: crochet/urls.py:53 +#: crochet/urls.py:54 +msgid "account/settings/display-name/" +msgstr "cuenta/ajustes/nombre/" + +#: crochet/urls.py:57 +msgid "account/settings/delete/" +msgstr "cuenta/ajustes/eliminar/" + +#: crochet/urls.py:58 msgid "account/register/" msgstr "cuenta/registro/" -#: crochet/urls.py:55 +#: crochet/urls.py:60 msgid "account/login/" msgstr "cuenta/entrar/" -#: crochet/urls.py:60 +#: crochet/urls.py:65 msgid "account/logout/" msgstr "cuenta/salir/" -#: crochet/urls.py:62 +#: crochet/urls.py:67 msgid "account/password-reset/" msgstr "cuenta/recuperar-contrasena/" -#: crochet/urls.py:81 +#: crochet/urls.py:86 msgid "account/password-reset/done/" msgstr "cuenta/recuperar-contrasena/enviado/" -#: crochet/urls.py:85 +#: crochet/urls.py:90 msgid "account/reset///" msgstr "cuenta/restablecer///" -#: crochet/urls.py:93 +#: crochet/urls.py:98 msgid "account/reset/done/" msgstr "cuenta/restablecer/hecho/" -#: crochet/views.py:24 +#: crochet/views.py:29 msgid "Este patrón todavía no tiene contenido." msgstr "" + +#: crochet/views.py:350 +msgid "Tu cuenta se ha eliminado correctamente." +msgstr "" + +# Igual que "pattern/" arriba: "account/" se traduce como "cuenta/". +#~ msgid "account/" +#~ msgstr "cuenta/" diff --git a/crochet/templates/crochet/_account_delete_form.html b/crochet/templates/crochet/_account_delete_form.html new file mode 100644 index 0000000..6355276 --- /dev/null +++ b/crochet/templates/crochet/_account_delete_form.html @@ -0,0 +1,20 @@ +{% load i18n %} + +
+ {% csrf_token %} + + +
diff --git a/crochet/templates/crochet/_account_display_name_form.html b/crochet/templates/crochet/_account_display_name_form.html new file mode 100644 index 0000000..b3885ff --- /dev/null +++ b/crochet/templates/crochet/_account_display_name_form.html @@ -0,0 +1,18 @@ +{% load i18n %} +
+ {% csrf_token %} + {% if display_name_updated %} + + {% endif %} + + +
diff --git a/crochet/templates/crochet/account_home.html b/crochet/templates/crochet/account_home.html index d0e650f..e501edb 100644 --- a/crochet/templates/crochet/account_home.html +++ b/crochet/templates/crochet/account_home.html @@ -10,7 +10,7 @@ {% block content %}
-

{% blocktrans with username=user.username %}Hola, {{ username }}{% endblocktrans %}

+

{% blocktrans with username=user.get_full_name|default:user.username %}Hola, {{ username }}{% endblocktrans %}

{% csrf_token %} diff --git a/crochet/templates/crochet/account_settings.html b/crochet/templates/crochet/account_settings.html index 5aa2dfb..424605f 100644 --- a/crochet/templates/crochet/account_settings.html +++ b/crochet/templates/crochet/account_settings.html @@ -17,6 +17,13 @@

{% trans 'Ajustes de la cuenta' %}

+
+
+

{% trans 'Nombre para mostrar' %}

+ {% include 'crochet/_account_display_name_form.html' %} +
+
+

{% trans 'Email' %}

@@ -31,6 +38,19 @@
- {% trans 'Volver a mis patrones' %} + {% trans 'Volver a mis patrones' %} + + +
+
+

{% trans 'Eliminar cuenta' %}

+

+ {% trans 'Esto borrará tu cuenta y todos tus patrones de forma permanente.' %} +

+ {% include 'crochet/_account_delete_form.html' %} +
+
{% endblock %} diff --git a/crochet/templates/crochet/base.html b/crochet/templates/crochet/base.html index a81dc90..80c2922 100644 --- a/crochet/templates/crochet/base.html +++ b/crochet/templates/crochet/base.html @@ -47,7 +47,7 @@ {% endblock %} + + {% if messages %} +
+ {% for message in messages %} + + {% endfor %} +
+ {% endif %} {% block content %}{% endblock %}
diff --git a/crochet/tests/test_account_settings.py b/crochet/tests/test_account_settings.py index d4a4ea5..30dddbb 100644 --- a/crochet/tests/test_account_settings.py +++ b/crochet/tests/test_account_settings.py @@ -4,6 +4,8 @@ from django.test import TestCase from django.urls import reverse from django.utils import translation +from crochet.models import Pattern + class AccountSettingsViewTests(TestCase): def setUp(self): @@ -146,7 +148,7 @@ class AccountPasswordChangeViewTests(TestCase): # update_session_auth_hash: si no se llamara, esta petición # (sesión ya cargada con el hash viejo) habría quedado deslogueada. with translation.override('es'): - account_home_url = reverse('crochet:account_home') + account_home_url = reverse('crochet:home') still_logged_in_response = self.client.get(account_home_url) self.assertEqual(still_logged_in_response.status_code, 200) @@ -219,3 +221,120 @@ class AccountPasswordChangeViewTests(TestCase): }) self.assertEqual(len(mail.outbox), 0) + + +class AccountDisplayNameUpdateViewTests(TestCase): + def setUp(self): + self.user = User.objects.create_user(username='crocheter', password='a-very-uncommon-pw-1') + with translation.override('es'): + self.url = reverse('crochet:account_settings_display_name') + + def test_anonymous_user_is_redirected_to_login(self): + response = self.client.post(self.url, {'display_name': 'Ana'}) + + with translation.override('es'): + login_url = reverse('crochet:login') + self.assertRedirects(response, f'{login_url}?next={self.url}') + + def test_valid_name_updates_user_and_shows_success(self): + self.client.force_login(self.user) + response = self.client.post(self.url, {'display_name': 'Ana'}) + + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, 'crochet/_account_display_name_form.html') + self.user.refresh_from_db() + self.assertEqual(self.user.first_name, 'Ana') + self.assertContains(response, 'Nombre actualizado.') + + def test_blank_name_is_allowed_and_clears_it(self): + self.user.first_name = 'Ana' + self.user.save(update_fields=['first_name']) + self.client.force_login(self.user) + + response = self.client.post(self.url, {'display_name': ''}) + + self.assertEqual(response.status_code, 200) + self.user.refresh_from_db() + self.assertEqual(self.user.first_name, '') + + def test_only_updates_the_requesting_users_own_name(self): + other = User.objects.create_user(username='other', password='a-very-uncommon-pw-1') + self.client.force_login(self.user) + + self.client.post(self.url, {'display_name': 'Ana'}) + + other.refresh_from_db() + self.assertEqual(other.first_name, '') + + +class AccountDeleteViewTests(TestCase): + def setUp(self): + self.user = User.objects.create_user( + username='crocheter', password='the-password-1', email='crocheter@example.com', + ) + with translation.override('es'): + self.url = reverse('crochet:account_settings_delete') + + def test_anonymous_user_is_redirected_to_login(self): + response = self.client.post(self.url, {'password': 'the-password-1'}) + + with translation.override('es'): + login_url = reverse('crochet:login') + self.assertRedirects(response, f'{login_url}?next={self.url}') + + def test_wrong_password_does_not_delete_the_account(self): + self.client.force_login(self.user) + + response = self.client.post(self.url, {'password': 'wrong-password'}) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'class="text-error text-xs"') + self.assertTrue(User.objects.filter(pk=self.user.pk).exists()) + self.assertEqual(len(mail.outbox), 0) + + def test_correct_password_deletes_the_account_and_its_patterns(self): + pattern = Pattern.objects.create(created_by=self.user) + self.client.force_login(self.user) + + response = self.client.post(self.url, {'password': 'the-password-1'}) + + self.assertFalse(User.objects.filter(pk=self.user.pk).exists()) + self.assertFalse(Pattern.objects.filter(pk=pattern.pk).exists()) + with translation.override('es'): + home_url = reverse('crochet:home') + self.assertEqual(response['HX-Redirect'], home_url) + + def test_deleting_the_account_logs_out_the_session(self): + self.client.force_login(self.user) + + self.client.post(self.url, {'password': 'the-password-1'}) + + self.assertNotIn('_auth_user_id', self.client.session) + # El índice ya no requiere sesión (a diferencia del extinto + # account_home): tras el borrado, vuelve a mostrar la landing de + # marketing en vez del panel de patrones. + with translation.override('es'): + home_url = reverse('crochet:home') + response = self.client.get(home_url) + self.assertTemplateUsed(response, 'crochet/home.html') + + def test_deleting_the_account_notifies_the_users_email(self): + self.client.force_login(self.user) + + self.client.post(self.url, {'password': 'the-password-1'}) + + 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_only_deletes_the_requesting_users_own_account(self): + other = User.objects.create_user(username='other', password='a-very-uncommon-pw-1') + self.client.force_login(self.user) + + self.client.post(self.url, {'password': 'the-password-1'}) + + self.assertTrue(User.objects.filter(pk=other.pk).exists()) diff --git a/crochet/tests/test_auth.py b/crochet/tests/test_auth.py index ee64fce..5a1ebb9 100644 --- a/crochet/tests/test_auth.py +++ b/crochet/tests/test_auth.py @@ -27,7 +27,7 @@ class RegisterViewTests(TestCase): user = User.objects.get(username='crocheter') self.assertEqual(user.email, 'crocheter@example.com') - self.assertRedirects(response, reverse('crochet:account_home')) + self.assertRedirects(response, reverse('crochet:home')) self.assertIn('_auth_user_id', self.client.session) def test_post_with_mismatched_passwords_does_not_create_user(self): @@ -72,7 +72,7 @@ class LoginViewTests(TestCase): 'password': 'a-very-uncommon-pw-1', }) - self.assertRedirects(response, reverse('crochet:account_home')) + self.assertRedirects(response, reverse('crochet:home')) self.assertIn('_auth_user_id', self.client.session) def test_post_with_wrong_password_does_not_log_in(self): @@ -98,17 +98,14 @@ class LogoutViewTests(TestCase): self.assertNotIn('_auth_user_id', self.client.session) -class AccountHomeViewTests(TestCase): +class HomeViewDashboardTests(TestCase): + # El índice ("/") ya no requiere sesión (a diferencia del extinto + # account_home): para quien no ha iniciado sesión enseña la landing de + # marketing (ver HomeViewTests en test_views.py); estos tests cubren + # justo la otra rama, el contenido que ve quien sí la tiene. def setUp(self): with translation.override('es'): - self.url = reverse('crochet:account_home') - - def test_anonymous_user_is_redirected_to_login(self): - response = self.client.get(self.url) - - with translation.override('es'): - login_url = reverse('crochet:login') - self.assertRedirects(response, f'{login_url}?next={self.url}') + self.url = reverse('crochet:home') def test_logged_in_user_sees_their_username(self): user = User.objects.create_user(username='crocheter', password='a-very-uncommon-pw-1') @@ -119,6 +116,14 @@ class AccountHomeViewTests(TestCase): self.assertEqual(response.status_code, 200) self.assertContains(response, 'crocheter') + def test_greeting_uses_display_name_when_set(self): + user = User.objects.create_user(username='crocheter', password='a-very-uncommon-pw-1', first_name='Ana') + self.client.force_login(user) + + response = self.client.get(self.url) + + self.assertContains(response, 'Hola, Ana') + def test_only_lists_own_patterns(self): owner = User.objects.create_user(username='crocheter', password='a-very-uncommon-pw-1') other = User.objects.create_user(username='other', password='a-very-uncommon-pw-1') @@ -166,4 +171,22 @@ class PatternCreateViewTests(TestCase): self.assertEqual(pattern.created_by, user) with translation.override('es'): edit_url = reverse('crochet:pattern_edit', args=[pattern.uuid]) - self.assertRedirects(response, edit_url) \ No newline at end of file + self.assertRedirects(response, edit_url) + + def test_new_pattern_defaults_author_to_the_users_display_name(self): + user = User.objects.create_user(username='crocheter', password='a-very-uncommon-pw-1', first_name='Ana') + self.client.force_login(user) + + self.client.post(self.url) + + pattern = Pattern.objects.get() + self.assertEqual(pattern.page_settings.get('author'), 'Ana') + + def test_new_pattern_leaves_author_blank_without_a_display_name(self): + user = User.objects.create_user(username='crocheter', password='a-very-uncommon-pw-1') + self.client.force_login(user) + + self.client.post(self.url) + + pattern = Pattern.objects.get() + self.assertNotIn('author', pattern.page_settings) \ No newline at end of file diff --git a/crochet/tests/test_views.py b/crochet/tests/test_views.py index c81c9d0..bb3ceee 100644 --- a/crochet/tests/test_views.py +++ b/crochet/tests/test_views.py @@ -35,16 +35,20 @@ class HomeViewTests(TestCase): self.assertContains(response, f'href="{register_url}"') self.assertContains(response, f'href="{login_url}"') - def test_logged_in_user_sees_link_to_their_patterns(self): + def test_logged_in_user_sees_their_patterns_dashboard_instead_of_the_landing_page(self): + # El índice ("/") ya no enseña la landing de marketing a quien ya + # tiene cuenta: directamente "Mis patrones" (ver HomeView.get), + # sin cambiar de URL. user = User.objects.create_user(username='crocheter', password='a-very-uncommon-pw-1') self.client.force_login(user) with translation.override('es'): url = reverse('crochet:home') - account_home_url = reverse('crochet:account_home') response = self.client.get(url) - self.assertContains(response, f'href="{account_home_url}"') - self.assertNotContains(response, 'Crear cuenta gratis') + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, 'crochet/account_home.html') + self.assertContains(response, 'Hola, crocheter') + self.assertNotContains(response, 'Crea y comparte tus patrones de crochet') def test_available_at_root_for_both_languages(self): response_es = self.client.get('/es/') @@ -144,6 +148,15 @@ class NavbarAccountDropdownTests(TestCase): self.assertContains(response, f'href="{settings_url}"') self.assertContains(response, f'action="{logout_url}"') + def test_dropdown_shows_display_name_when_set(self): + user = User.objects.create_user(username='crocheter', password='a-very-uncommon-pw-1', first_name='Ana') + self.client.force_login(user) + + response = self.client.get('/es/') + + self.assertContains(response, '>Ana<') + self.assertNotContains(response, '>crocheter<') + def test_anonymous_user_does_not_see_the_account_dropdown(self): with translation.override('es'): settings_url = reverse('crochet:account_settings') @@ -554,7 +567,7 @@ class PatternDeleteViewTests(TestCase): response = self.client.post(self.url) with translation.override('es'): - account_home_url = reverse('crochet:account_home') + account_home_url = reverse('crochet:home') self.assertRedirects(response, account_home_url) self.assertFalse(Pattern.objects.filter(pk=self.pattern.pk).exists()) diff --git a/crochet/urls.py b/crochet/urls.py index 82a335d..5e69063 100644 --- a/crochet/urls.py +++ b/crochet/urls.py @@ -4,8 +4,9 @@ from django.utils.translation import gettext_lazy as _ from crochet.forms import StyledAuthenticationForm, StyledPasswordResetForm, StyledSetPasswordForm from crochet.views import ( + AccountDeleteView, + AccountDisplayNameUpdateView, AccountEmailUpdateView, - AccountHomeView, AccountPasswordChangeView, AccountSettingsView, CookiePolicyView, @@ -43,13 +44,17 @@ urlpatterns = [ path(_('pattern//pdf/'), name='pattern_pdf', view=PatternPdfView.as_view()), path(_('pattern//delete/'), name='pattern_delete', view=PatternDeleteView.as_view()), path(_('pattern/new/'), name='pattern_create', view=PatternCreateView.as_view()), - path(_('account/'), name='account_home', view=AccountHomeView.as_view()), path(_('account/settings/'), name='account_settings', view=AccountSettingsView.as_view()), path(_('account/settings/email/'), name='account_settings_email', view=AccountEmailUpdateView.as_view()), path( _('account/settings/password/'), name='account_settings_password', view=AccountPasswordChangeView.as_view(), ), + path( + _('account/settings/display-name/'), name='account_settings_display_name', + view=AccountDisplayNameUpdateView.as_view(), + ), + path(_('account/settings/delete/'), name='account_settings_delete', view=AccountDeleteView.as_view()), path(_('account/register/'), name='register', view=RegisterView.as_view()), path( _('account/login/'), name='login', diff --git a/crochet/views.py b/crochet/views.py index b8c3a64..4a44019 100644 --- a/crochet/views.py +++ b/crochet/views.py @@ -1,26 +1,31 @@ import json import weasyprint -from django.contrib.auth import login, update_session_auth_hash +from django.contrib import messages +from django.contrib.auth import login, logout, update_session_auth_hash from django.contrib.auth.mixins import LoginRequiredMixin from django.core.exceptions import PermissionDenied, SuspiciousFileOperation from django.http import HttpResponse, HttpResponseBadRequest, JsonResponse from django.shortcuts import get_object_or_404, redirect, render from django.template.loader import render_to_string -from django.urls import reverse_lazy +from django.urls import reverse, reverse_lazy from django.utils.text import get_valid_filename from django.utils.translation import get_language, gettext_lazy as _ from django.views import View from django.views.generic import CreateView, DetailView, TemplateView from crochet.cover_image import InvalidCoverImage, build_cover_image_variants -from crochet.forms import EmailUpdateForm, StyledPasswordChangeForm, StyledUserCreationForm +from crochet.forms import ( + AccountDeleteForm, + DisplayNameForm, + EmailUpdateForm, + StyledPasswordChangeForm, + StyledUserCreationForm, +) from crochet.models import Pattern, PatternImage, StitchType from crochet.pattern_render import render_pattern_html, sanitize_page_settings -# gettext_lazy (no gettext): se evalúa al construir el HTML de salida (dentro -# de render_pattern_html, vía format_html), no aquí al importar el módulo, así -# que respeta el idioma activo de cada petición aunque esto se cree una vez. + PATTERN_EMPTY_MESSAGE = _('Este patrón todavía no tiene contenido.') @@ -65,6 +70,20 @@ def _pattern_detail_context(pattern): class HomeView(TemplateView): template_name = 'crochet/home.html' + account_template_name = 'crochet/account_home.html' + + def get_template_names(self): + if self.request.user.is_authenticated: + return [self.account_template_name] + return [self.template_name] + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + if self.request.user.is_authenticated: + context['patterns'] = self.request.user.patterns.order_by('-updated_at') + + return context class CookiePolicyView(TemplateView): @@ -80,16 +99,14 @@ class PatternEditView(LoginRequiredMixin, DetailView): def get_object(self, queryset=None): pattern = super().get_object(queryset) - # LoginRequiredMixin ya garantiza que request.user está autenticado - # aquí (si no, redirige a login antes de llegar a get_object). + if pattern.created_by_id is None or pattern.created_by_id != self.request.user.id: raise PermissionDenied return pattern def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - # Resuelto por LocaleMiddleware a partir del prefijo de idioma de la - # URL (/es/..., /en/...; ver i18n_patterns en config/urls.py). + context['lang'] = get_language() # Se pasa tal cual a la plantilla con {{ pattern_data|json_script:"..." }} # para que main.js reconstruya el editor con el contenido guardado @@ -241,26 +258,14 @@ class RegisterView(CreateView): # versión con clases de daisyUI en sus widgets (ver crochet/forms.py). form_class = StyledUserCreationForm template_name = 'registration/register.html' - success_url = reverse_lazy('crochet:account_home') + success_url = reverse_lazy('crochet:home') def form_valid(self, form): response = super().form_valid(form) - # Registrarse ya cuenta como haber probado usuario/contraseña, así - # que se inicia sesión directamente en vez de mandar al usuario a - # loguearse otra vez con lo que acaba de escribir. login(self.request, self.object) return response -class AccountHomeView(LoginRequiredMixin, TemplateView): - template_name = 'crochet/account_home.html' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['patterns'] = self.request.user.patterns.order_by('-updated_at') - return context - - class AccountSettingsView(LoginRequiredMixin, TemplateView): template_name = 'crochet/account_settings.html' @@ -268,6 +273,10 @@ class AccountSettingsView(LoginRequiredMixin, TemplateView): context = super().get_context_data(**kwargs) context['email_form'] = EmailUpdateForm(user=self.request.user, initial={'email': self.request.user.email}) context['password_form'] = StyledPasswordChangeForm(user=self.request.user) + context['display_name_form'] = DisplayNameForm( + user=self.request.user, initial={'display_name': self.request.user.first_name}, + ) + context['delete_form'] = AccountDeleteForm(user=self.request.user) return context @@ -312,11 +321,45 @@ class AccountPasswordChangeView(LoginRequiredMixin, View): ) +class AccountDisplayNameUpdateView(LoginRequiredMixin, View): + def post(self, request): + form = DisplayNameForm(request.POST, user=request.user) + if form.is_valid(): + form.save() + form = DisplayNameForm(user=request.user, initial={'display_name': request.user.first_name}) + display_name_updated = True + else: + display_name_updated = False + return render( + request, 'crochet/_account_display_name_form.html', + {'display_name_form': form, 'display_name_updated': display_name_updated}, + ) + + +class AccountDeleteView(LoginRequiredMixin, View): + def post(self, request): + form = AccountDeleteForm(request.POST, user=request.user) + if not form.is_valid(): + return render(request, 'crochet/_account_delete_form.html', {'delete_form': form}) + + form.notify_account_deleted() + user = request.user + user.patterns.all().delete() + logout(request) + user.delete() + messages.success(request, _('Tu cuenta se ha eliminado correctamente.')) + + response = HttpResponse() + response['HX-Redirect'] = reverse('crochet:home') + return response + + class PatternCreateView(LoginRequiredMixin, View): # Solo POST (ver el en account_home.html): crear un patrón no # debería poder dispararse desde un simple enlace GET sin token CSRF. def post(self, request): - pattern = Pattern.objects.create(created_by=request.user) + page_settings = {'author': request.user.first_name} if request.user.first_name else {} + pattern = Pattern.objects.create(created_by=request.user, page_settings=page_settings) return redirect('crochet:pattern_edit', uuid=pattern.uuid) @@ -328,4 +371,4 @@ class PatternDeleteView(LoginRequiredMixin, View): def post(self, request, uuid): pattern = _get_owned_pattern_or_403(request, uuid) pattern.delete() - return redirect('crochet:account_home') + return redirect('crochet:home')