feat: added delete account and new display name
CI / test (push) Successful in 1m28s
CI / build (push) Successful in 29s

This commit is contained in:
2026-07-22 10:03:30 +02:00
parent 2839d60923
commit 1a1c8786a3
18 changed files with 630 additions and 160 deletions
+1 -1
View File
@@ -99,7 +99,7 @@ DATABASES = {
# https://docs.djangoproject.com/en/6.0/topics/auth/default/#all-authentication-views # https://docs.djangoproject.com/en/6.0/topics/auth/default/#all-authentication-views
LOGIN_URL = 'crochet:login' LOGIN_URL = 'crochet:login'
LOGIN_REDIRECT_URL = 'crochet:account_home' LOGIN_REDIRECT_URL = 'crochet:home'
LOGOUT_REDIRECT_URL = 'crochet:login' LOGOUT_REDIRECT_URL = 'crochet:login'
+48
View File
@@ -116,3 +116,51 @@ class EmailUpdateForm(forms.Form):
'crochet/email/email_changed_notice.html', 'crochet/email/email_changed_notice.html',
context, old_email, 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,
)
+115 -53
View File
@@ -2,17 +2,50 @@ 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 12:32+0000\n" "POT-Creation-Date: 2026-07-22 08:00+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:48 crochet/forms.py:96 #: crochet/forms.py:48 crochet/forms.py:96
#: crochet/templates/crochet/account_settings.html:22 #: crochet/templates/crochet/account_settings.html:29
msgid "Email" msgid "Email"
msgstr "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 #: crochet/templates/crochet/_account_email_form.html:10
msgid "Email actualizado." msgid "Email actualizado."
msgstr "Email updated." msgstr "Email updated."
@@ -26,7 +59,7 @@ msgid "Contraseña actualizada."
msgstr "Password updated." msgstr "Password updated."
#: crochet/templates/crochet/_account_password_form.html:19 #: 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 #: crochet/templates/crochet/password_reset_confirm.html:30
msgid "Cambiar contraseña" msgid "Cambiar contraseña"
msgstr "Change password" msgstr "Change password"
@@ -49,7 +82,6 @@ msgid "Entendido"
msgstr "Agree" msgstr "Agree"
#: crochet/templates/crochet/account_home.html:4 #: crochet/templates/crochet/account_home.html:4
#: crochet/templates/crochet/pattern_detail.html:43
msgid "Mis patrones" msgid "Mis patrones"
msgstr "My patterns" msgstr "My patterns"
@@ -96,10 +128,14 @@ msgstr "You don't have any patterns yet."
msgid "Ajustes de la cuenta" msgid "Ajustes de la cuenta"
msgstr "Account settings" msgstr "Account settings"
#: crochet/templates/crochet/account_settings.html:34 #: crochet/templates/crochet/account_settings.html:41
msgid "Volver a mis patrones" msgid "Volver a mis patrones"
msgstr "Back to my patterns" 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 #: crochet/templates/crochet/base.html:39
msgid "Cambiar idioma" msgid "Cambiar idioma"
msgstr "Change language" msgstr "Change language"
@@ -109,9 +145,8 @@ msgid "Cerrar sesión"
msgstr "Log out" msgstr "Log out"
#: crochet/templates/crochet/base.html:62 #: 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/password_reset_complete.html:18
#: crochet/templates/crochet/pattern_detail.html:45
#: crochet/templates/registration/login.html:4 #: crochet/templates/registration/login.html:4
#: crochet/templates/registration/login.html:14 #: crochet/templates/registration/login.html:14
msgid "Iniciar sesión" msgid "Iniciar sesión"
@@ -186,6 +221,45 @@ msgstr ""
msgid "Volver al inicio" msgid "Volver al inicio"
msgstr "Back to home" 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 #: crochet/templates/crochet/email/base.html:43
msgid "" msgid ""
"Este email se ha enviado automáticamente, no respondas a esta dirección." "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 " "The email on your %(site_name)s account has changed. This address "
"(%(old_email)s) is no longer associated with your account." "(%(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_notice.html:4
#: crochet/templates/crochet/email/password_changed_subject.txt:1 #: crochet/templates/crochet/email/password_changed_subject.txt:1
#, python-format #, python-format
@@ -286,11 +341,7 @@ msgstr ""
"Design your patterns with a visual editor, export them to PDF, and share " "Design your patterns with a visual editor, export them to PDF, and share "
"them with anyone, no installation needed." "them with anyone, no installation needed."
#: crochet/templates/crochet/home.html:21 #: crochet/templates/crochet/home.html:24
msgid "Ir a mis patrones"
msgstr "Go to my patterns"
#: crochet/templates/crochet/home.html:23
msgid "Crear cuenta gratis" msgid "Crear cuenta gratis"
msgstr "Create a free account" msgstr "Create a free account"
@@ -636,7 +687,7 @@ msgstr ""
msgid "Añade una sección para ver aquí el resultado." msgid "Añade una sección para ver aquí el resultado."
msgstr "Add a section to see the result here." 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" msgid "Descargar PDF"
msgstr "Download PDF" msgstr "Download PDF"
@@ -669,44 +720,40 @@ msgstr "Sign up"
msgid "¿Ya tienes cuenta? Inicia sesión" msgid "¿Ya tienes cuenta? Inicia sesión"
msgstr "Already have an account? Log in" msgstr "Already have an account? Log in"
#: crochet/urls.py:37 #: crochet/urls.py:38
msgid "cookies/" msgid "cookies/"
msgstr "" msgstr ""
#: crochet/urls.py:38 #: crochet/urls.py:39
msgid "pattern/<uuid:uuid>/" msgid "pattern/<uuid:uuid>/"
msgstr "" msgstr ""
#: crochet/urls.py:39 #: crochet/urls.py:40
msgid "pattern/<uuid:uuid>/edit/" msgid "pattern/<uuid:uuid>/edit/"
msgstr "" msgstr ""
#: crochet/urls.py:40 #: crochet/urls.py:41
msgid "pattern/<uuid:uuid>/save/" msgid "pattern/<uuid:uuid>/save/"
msgstr "" msgstr ""
#: crochet/urls.py:41 #: crochet/urls.py:42
msgid "pattern/<uuid:uuid>/images/" msgid "pattern/<uuid:uuid>/images/"
msgstr "" msgstr ""
#: crochet/urls.py:42 #: crochet/urls.py:43
msgid "pattern/<uuid:uuid>/cover/" msgid "pattern/<uuid:uuid>/cover/"
msgstr "" msgstr ""
#: crochet/urls.py:43 #: crochet/urls.py:44
msgid "pattern/<uuid:uuid>/pdf/" msgid "pattern/<uuid:uuid>/pdf/"
msgstr "" msgstr ""
#: crochet/urls.py:44 #: crochet/urls.py:45
msgid "pattern/<uuid:uuid>/delete/" msgid "pattern/<uuid:uuid>/delete/"
msgstr "" msgstr ""
#: crochet/urls.py:45
msgid "pattern/new/"
msgstr ""
#: crochet/urls.py:46 #: crochet/urls.py:46
msgid "account/" msgid "pattern/new/"
msgstr "" msgstr ""
#: crochet/urls.py:47 #: crochet/urls.py:47
@@ -721,39 +768,54 @@ msgstr ""
msgid "account/settings/password/" msgid "account/settings/password/"
msgstr "" 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/" msgid "account/register/"
msgstr "" msgstr ""
#: crochet/urls.py:55 #: crochet/urls.py:60
msgid "account/login/" msgid "account/login/"
msgstr "" msgstr ""
#: crochet/urls.py:60 #: crochet/urls.py:65
msgid "account/logout/" msgid "account/logout/"
msgstr "" msgstr ""
#: crochet/urls.py:62 #: crochet/urls.py:67
msgid "account/password-reset/" msgid "account/password-reset/"
msgstr "" msgstr ""
#: crochet/urls.py:81 #: crochet/urls.py:86
msgid "account/password-reset/done/" msgid "account/password-reset/done/"
msgstr "" msgstr ""
#: crochet/urls.py:85 #: crochet/urls.py:90
msgid "account/reset/<uidb64>/<token>/" msgid "account/reset/<uidb64>/<token>/"
msgstr "" msgstr ""
#: crochet/urls.py:93 #: crochet/urls.py:98
msgid "account/reset/done/" msgid "account/reset/done/"
msgstr "" msgstr ""
# crochet/views.py (PatternDetailView.EMPTY_MESSAGE) # crochet/views.py (PatternDetailView.EMPTY_MESSAGE)
#: crochet/views.py:24 #: crochet/views.py:29
msgid "Este patrón todavía no tiene contenido." msgid "Este patrón todavía no tiene contenido."
msgstr "This pattern doesn't have any content yet." 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" #~ msgid "Idioma"
#~ msgstr "Language" #~ msgstr "Language"
+110 -52
View File
@@ -2,17 +2,48 @@ 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 12:32+0000\n" "POT-Creation-Date: 2026-07-22 08:00+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:48 crochet/forms.py:96 #: crochet/forms.py:48 crochet/forms.py:96
#: crochet/templates/crochet/account_settings.html:22 #: crochet/templates/crochet/account_settings.html:29
msgid "Email" msgid "Email"
msgstr "" 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 #: crochet/templates/crochet/_account_email_form.html:10
msgid "Email actualizado." msgid "Email actualizado."
msgstr "" msgstr ""
@@ -26,7 +57,7 @@ msgid "Contraseña actualizada."
msgstr "" msgstr ""
#: crochet/templates/crochet/_account_password_form.html:19 #: 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 #: crochet/templates/crochet/password_reset_confirm.html:30
msgid "Cambiar contraseña" msgid "Cambiar contraseña"
msgstr "" msgstr ""
@@ -47,7 +78,6 @@ msgid "Entendido"
msgstr "" msgstr ""
#: crochet/templates/crochet/account_home.html:4 #: crochet/templates/crochet/account_home.html:4
#: crochet/templates/crochet/pattern_detail.html:43
msgid "Mis patrones" msgid "Mis patrones"
msgstr "" msgstr ""
@@ -94,10 +124,14 @@ msgstr ""
msgid "Ajustes de la cuenta" msgid "Ajustes de la cuenta"
msgstr "" msgstr ""
#: crochet/templates/crochet/account_settings.html:34 #: crochet/templates/crochet/account_settings.html:41
msgid "Volver a mis patrones" msgid "Volver a mis patrones"
msgstr "" 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 #: crochet/templates/crochet/base.html:39
msgid "Cambiar idioma" msgid "Cambiar idioma"
msgstr "" msgstr ""
@@ -107,9 +141,8 @@ msgid "Cerrar sesión"
msgstr "" msgstr ""
#: crochet/templates/crochet/base.html:62 #: 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/password_reset_complete.html:18
#: crochet/templates/crochet/pattern_detail.html:45
#: crochet/templates/registration/login.html:4 #: crochet/templates/registration/login.html:4
#: crochet/templates/registration/login.html:14 #: crochet/templates/registration/login.html:14
msgid "Iniciar sesión" msgid "Iniciar sesión"
@@ -177,6 +210,41 @@ msgstr ""
msgid "Volver al inicio" msgid "Volver al inicio"
msgstr "" 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 #: crochet/templates/crochet/email/base.html:43
msgid "" msgid ""
"Este email se ha enviado automáticamente, no respondas a esta dirección." "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." "(%(old_email)s) ha dejado de estar asociada a tu cuenta."
msgstr "" 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_notice.html:4
#: crochet/templates/crochet/email/password_changed_subject.txt:1 #: crochet/templates/crochet/email/password_changed_subject.txt:1
#, python-format #, python-format
@@ -269,11 +320,7 @@ msgid ""
"quien quieras, sin instalar nada." "quien quieras, sin instalar nada."
msgstr "" msgstr ""
#: crochet/templates/crochet/home.html:21 #: crochet/templates/crochet/home.html:24
msgid "Ir a mis patrones"
msgstr ""
#: crochet/templates/crochet/home.html:23
msgid "Crear cuenta gratis" msgid "Crear cuenta gratis"
msgstr "" msgstr ""
@@ -606,7 +653,7 @@ msgstr ""
msgid "Añade una sección para ver aquí el resultado." msgid "Añade una sección para ver aquí el resultado."
msgstr "" msgstr ""
#: crochet/templates/crochet/pattern_detail.html:31 #: crochet/templates/crochet/pattern_detail.html:29
msgid "Descargar PDF" msgid "Descargar PDF"
msgstr "" msgstr ""
@@ -639,49 +686,44 @@ msgstr ""
msgid "¿Ya tienes cuenta? Inicia sesión" msgid "¿Ya tienes cuenta? Inicia sesión"
msgstr "" msgstr ""
#: crochet/urls.py:37 #: crochet/urls.py:38
msgid "cookies/" msgid "cookies/"
msgstr "" msgstr ""
# Rutas traducibles de crochet/urls.py: la base "pattern/" se traduce como # Rutas traducibles de crochet/urls.py: la base "pattern/" se traduce como
# "patron/" (sin tilde, para no meter caracteres acentuados en la URL). # "patron/" (sin tilde, para no meter caracteres acentuados en la URL).
#: crochet/urls.py:38 #: crochet/urls.py:39
msgid "pattern/<uuid:uuid>/" msgid "pattern/<uuid:uuid>/"
msgstr "patron/<uuid:uuid>/" msgstr "patron/<uuid:uuid>/"
#: crochet/urls.py:39 #: crochet/urls.py:40
msgid "pattern/<uuid:uuid>/edit/" msgid "pattern/<uuid:uuid>/edit/"
msgstr "patron/<uuid:uuid>/editar/" msgstr "patron/<uuid:uuid>/editar/"
#: crochet/urls.py:40 #: crochet/urls.py:41
msgid "pattern/<uuid:uuid>/save/" msgid "pattern/<uuid:uuid>/save/"
msgstr "patron/<uuid:uuid>/guardar/" msgstr "patron/<uuid:uuid>/guardar/"
#: crochet/urls.py:41 #: crochet/urls.py:42
msgid "pattern/<uuid:uuid>/images/" msgid "pattern/<uuid:uuid>/images/"
msgstr "patron/<uuid:uuid>/imagenes/" msgstr "patron/<uuid:uuid>/imagenes/"
#: crochet/urls.py:42 #: crochet/urls.py:43
msgid "pattern/<uuid:uuid>/cover/" msgid "pattern/<uuid:uuid>/cover/"
msgstr "patron/<uuid:uuid>/portada/" msgstr "patron/<uuid:uuid>/portada/"
#: crochet/urls.py:43 #: crochet/urls.py:44
msgid "pattern/<uuid:uuid>/pdf/" msgid "pattern/<uuid:uuid>/pdf/"
msgstr "patron/<uuid:uuid>/pdf/" msgstr "patron/<uuid:uuid>/pdf/"
#: crochet/urls.py:44 #: crochet/urls.py:45
msgid "pattern/<uuid:uuid>/delete/" msgid "pattern/<uuid:uuid>/delete/"
msgstr "patron/<uuid:uuid>/eliminar/" msgstr "patron/<uuid:uuid>/eliminar/"
#: crochet/urls.py:45 #: crochet/urls.py:46
msgid "pattern/new/" msgid "pattern/new/"
msgstr "patron/nuevo/" msgstr "patron/nuevo/"
# Igual que "pattern/" arriba: "account/" se traduce como "cuenta/".
#: crochet/urls.py:46
msgid "account/"
msgstr "cuenta/"
#: crochet/urls.py:47 #: crochet/urls.py:47
msgid "account/settings/" msgid "account/settings/"
msgstr "cuenta/ajustes/" msgstr "cuenta/ajustes/"
@@ -694,34 +736,50 @@ msgstr "cuenta/ajustes/email/"
msgid "account/settings/password/" msgid "account/settings/password/"
msgstr "cuenta/ajustes/contrasena/" 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/" msgid "account/register/"
msgstr "cuenta/registro/" msgstr "cuenta/registro/"
#: crochet/urls.py:55 #: crochet/urls.py:60
msgid "account/login/" msgid "account/login/"
msgstr "cuenta/entrar/" msgstr "cuenta/entrar/"
#: crochet/urls.py:60 #: crochet/urls.py:65
msgid "account/logout/" msgid "account/logout/"
msgstr "cuenta/salir/" msgstr "cuenta/salir/"
#: crochet/urls.py:62 #: crochet/urls.py:67
msgid "account/password-reset/" msgid "account/password-reset/"
msgstr "cuenta/recuperar-contrasena/" msgstr "cuenta/recuperar-contrasena/"
#: crochet/urls.py:81 #: crochet/urls.py:86
msgid "account/password-reset/done/" msgid "account/password-reset/done/"
msgstr "cuenta/recuperar-contrasena/enviado/" msgstr "cuenta/recuperar-contrasena/enviado/"
#: crochet/urls.py:85 #: crochet/urls.py:90
msgid "account/reset/<uidb64>/<token>/" msgid "account/reset/<uidb64>/<token>/"
msgstr "cuenta/restablecer/<uidb64>/<token>/" msgstr "cuenta/restablecer/<uidb64>/<token>/"
#: crochet/urls.py:93 #: crochet/urls.py:98
msgid "account/reset/done/" msgid "account/reset/done/"
msgstr "cuenta/restablecer/hecho/" msgstr "cuenta/restablecer/hecho/"
#: crochet/views.py:24 #: crochet/views.py:29
msgid "Este patrón todavía no tiene contenido." msgid "Este patrón todavía no tiene contenido."
msgstr "" 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/"
@@ -0,0 +1,20 @@
{% load i18n %}
<!-- hx-confirm (no un onsubmit propio): htmx cancela la petición si se
rechaza el diálogo, sin depender de si un onsubmit normal llega a
ejecutarse antes que el propio listener de htmx. En éxito, la
respuesta no trae fragmento que pintar: manda la cabecera HX-Redirect
(ver AccountDeleteView) y htmx navega de verdad a esa URL, ya sin
sesión que mostrar en esta misma página. -->
<form hx-post="{% url 'crochet:account_settings_delete' %}" hx-target="this" hx-swap="outerHTML"
hx-confirm="{% trans '¿Seguro que quieres eliminar tu cuenta? Se borrarán también todos tus patrones. Esta acción no se puede deshacer.' %}"
class="flex flex-col gap-3">
{% csrf_token %}
<label class="flex flex-col gap-1">
<span class="text-sm">{{ delete_form.password.label }}</span>
{{ delete_form.password }}
{% for error in delete_form.password.errors %}
<span class="text-error text-xs">{{ error }}</span>
{% endfor %}
</label>
<button type="submit" class="btn btn-error self-start">{% trans 'Eliminar cuenta' %}</button>
</form>
@@ -0,0 +1,18 @@
{% load i18n %}
<form hx-post="{% url 'crochet:account_settings_display_name' %}" hx-target="this" hx-swap="outerHTML"
class="flex flex-col gap-3">
{% csrf_token %}
{% if display_name_updated %}
<div role="alert" class="alert alert-success alert-soft">
<span>{% trans 'Nombre actualizado.' %}</span>
</div>
{% endif %}
<label class="flex flex-col gap-1">
<span class="text-sm">{{ display_name_form.display_name.label }}</span>
{{ display_name_form.display_name }}
{% for error in display_name_form.display_name.errors %}
<span class="text-error text-xs">{{ error }}</span>
{% endfor %}
</label>
<button type="submit" class="btn btn-primary self-start">{% trans 'Guardar nombre' %}</button>
</form>
+1 -1
View File
@@ -10,7 +10,7 @@
{% block content %} {% block content %}
<main class="max-w-4xl mx-auto px-4 py-8"> <main class="max-w-4xl mx-auto px-4 py-8">
<div class="flex flex-wrap items-center justify-between gap-4 mb-6"> <div class="flex flex-wrap items-center justify-between gap-4 mb-6">
<h1 class="text-2xl font-semibold">{% blocktrans with username=user.username %}Hola, {{ username }}{% endblocktrans %}</h1> <h1 class="text-2xl font-semibold">{% blocktrans with username=user.get_full_name|default:user.username %}Hola, {{ username }}{% endblocktrans %}</h1>
<form method="post" action="{% url 'crochet:pattern_create' %}"> <form method="post" action="{% url 'crochet:pattern_create' %}">
{% csrf_token %} {% csrf_token %}
<button type="submit" class="btn btn-primary">+ {% trans 'Crear patrón' %}</button> <button type="submit" class="btn btn-primary">+ {% trans 'Crear patrón' %}</button>
@@ -17,6 +17,13 @@
<main class="max-w-lg mx-auto px-4 py-8 flex flex-col gap-8"> <main class="max-w-lg mx-auto px-4 py-8 flex flex-col gap-8">
<h1 class="text-2xl font-semibold">{% trans 'Ajustes de la cuenta' %}</h1> <h1 class="text-2xl font-semibold">{% trans 'Ajustes de la cuenta' %}</h1>
<section class="card bg-base-100 shadow border border-base-300">
<div class="card-body">
<h2 class="card-title text-lg">{% trans 'Nombre para mostrar' %}</h2>
{% include 'crochet/_account_display_name_form.html' %}
</div>
</section>
<section class="card bg-base-100 shadow border border-base-300"> <section class="card bg-base-100 shadow border border-base-300">
<div class="card-body"> <div class="card-body">
<h2 class="card-title text-lg">{% trans 'Email' %}</h2> <h2 class="card-title text-lg">{% trans 'Email' %}</h2>
@@ -31,6 +38,19 @@
</div> </div>
</section> </section>
<a href="{% url 'crochet:account_home' %}" class="link link-primary">{% trans 'Volver a mis patrones' %}</a> <a href="{% url 'crochet:home' %}" class="link link-primary">{% trans 'Volver a mis patrones' %}</a>
<!-- Zona de peligro, aparte del resto (border-error): que se note a
simple vista que esto es irreversible, distinto de los ajustes de
arriba. -->
<section class="card bg-base-100 shadow border border-error">
<div class="card-body">
<h2 class="card-title text-lg text-error">{% trans 'Eliminar cuenta' %}</h2>
<p class="text-sm text-base-content/70">
{% trans 'Esto borrará tu cuenta y todos tus patrones de forma permanente.' %}
</p>
{% include 'crochet/_account_delete_form.html' %}
</div>
</section>
</main> </main>
{% endblock %} {% endblock %}
+16 -1
View File
@@ -47,7 +47,7 @@
<!-- Dropdown CSS puro de daisyUI (sin JS propio): se abre con <!-- Dropdown CSS puro de daisyUI (sin JS propio): se abre con
:focus-within al pulsar/tabular al botón disparador. --> :focus-within al pulsar/tabular al botón disparador. -->
<div class="dropdown dropdown-end"> <div class="dropdown dropdown-end">
<button type="button" tabindex="0" class="btn btn-sm btn-ghost">{{ user.username }}</button> <button type="button" tabindex="0" class="btn btn-sm btn-ghost">{{ user.get_full_name|default:user.username }}</button>
<ul tabindex="0" class="dropdown-content menu menu-sm bg-base-100 border border-base-300 rounded-box z-10 w-52 p-2 shadow"> <ul tabindex="0" class="dropdown-content menu menu-sm bg-base-100 border border-base-300 rounded-box z-10 w-52 p-2 shadow">
<li><a href="{% url 'crochet:account_settings' %}">{% trans 'Ajustes de la cuenta' %}</a></li> <li><a href="{% url 'crochet:account_settings' %}">{% trans 'Ajustes de la cuenta' %}</a></li>
<li> <li>
@@ -64,6 +64,21 @@
</div> </div>
</nav> </nav>
{% endblock %} {% endblock %}
<!-- Mensajes de una sola vez tras una redirección (ver messages.success
en AccountDeleteView): no hay ninguna página a la que volver con el
resultado de la acción (la sesión ya no existe), así que se muestran
aquí, en la página a la que se redirige. django.contrib.messages ya
estaba instalado (middleware + procesador de contexto) pero sin
usar hasta ahora. -->
{% if messages %}
<div class="max-w-2xl mx-auto px-4 pt-4 flex flex-col gap-2 no-print">
{% for message in messages %}
<div role="alert" class="alert alert-soft{% if message.tags %} alert-{{ message.tags }}{% endif %}">
<span>{{ message }}</span>
</div>
{% endfor %}
</div>
{% endif %}
{% block content %}{% endblock %} {% block content %}{% endblock %}
<!-- pattern_detail.html (vista de solo lectura/PDF) sobreescribe este <!-- pattern_detail.html (vista de solo lectura/PDF) sobreescribe este
bloque dejándolo vacío, igual que el navbar: no carga daisyUI y no bloque dejándolo vacío, igual que el navbar: no carga daisyUI y no
@@ -0,0 +1,18 @@
{% extends 'crochet/email/base.html' %}
{% load i18n %}
{% block title %}{% blocktrans %}Tu cuenta en {{ site_name }} ha sido eliminada{% endblocktrans %}{% endblock %}
{% block content %}
<p style="margin:0 0 16px 0;">
{% blocktrans %}Tu cuenta en {{ site_name }} y todos tus patrones se han eliminado de forma permanente.{% 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 %}Tu cuenta en {{ site_name }} y todos tus patrones se han eliminado de forma permanente.{% 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 %}Tu cuenta en {{ site_name }} ha sido eliminada{% endblocktrans %}
+6 -6
View File
@@ -16,13 +16,13 @@
<p class="py-6 text-lg text-base-content/70"> <p class="py-6 text-lg text-base-content/70">
{% trans 'Diseña tus patrones con un editor visual, expórtalos a PDF y compártelos con quien quieras, sin instalar nada.' %} {% trans 'Diseña tus patrones con un editor visual, expórtalos a PDF y compártelos con quien quieras, sin instalar nada.' %}
</p> </p>
<!-- Sin el "if user.is_authenticated" que había aquí antes: con
sesión iniciada, HomeView ya no llega a renderizar esta
plantilla en absoluto (ver get_template_names), así que esa
rama nunca se alcanzaba. -->
<div class="flex flex-wrap justify-center gap-4"> <div class="flex flex-wrap justify-center gap-4">
{% if user.is_authenticated %} <a href="{% url 'crochet:register' %}" class="btn btn-primary btn-lg">{% trans 'Crear cuenta gratis' %}</a>
<a href="{% url 'crochet:account_home' %}" class="btn btn-primary btn-lg">{% trans 'Ir a mis patrones' %}</a> <a href="{% url 'crochet:login' %}" class="btn btn-outline btn-lg">{% trans 'Iniciar sesión' %}</a>
{% else %}
<a href="{% url 'crochet:register' %}" class="btn btn-primary btn-lg">{% trans 'Crear cuenta gratis' %}</a>
<a href="{% url 'crochet:login' %}" class="btn btn-outline btn-lg">{% trans 'Iniciar sesión' %}</a>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
+120 -1
View File
@@ -4,6 +4,8 @@ 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
from crochet.models import Pattern
class AccountSettingsViewTests(TestCase): class AccountSettingsViewTests(TestCase):
def setUp(self): def setUp(self):
@@ -146,7 +148,7 @@ class AccountPasswordChangeViewTests(TestCase):
# update_session_auth_hash: si no se llamara, esta petición # update_session_auth_hash: si no se llamara, esta petición
# (sesión ya cargada con el hash viejo) habría quedado deslogueada. # (sesión ya cargada con el hash viejo) habría quedado deslogueada.
with translation.override('es'): 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) still_logged_in_response = self.client.get(account_home_url)
self.assertEqual(still_logged_in_response.status_code, 200) self.assertEqual(still_logged_in_response.status_code, 200)
@@ -219,3 +221,120 @@ class AccountPasswordChangeViewTests(TestCase):
}) })
self.assertEqual(len(mail.outbox), 0) 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())
+35 -12
View File
@@ -27,7 +27,7 @@ class RegisterViewTests(TestCase):
user = User.objects.get(username='crocheter') user = User.objects.get(username='crocheter')
self.assertEqual(user.email, 'crocheter@example.com') 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) self.assertIn('_auth_user_id', self.client.session)
def test_post_with_mismatched_passwords_does_not_create_user(self): def test_post_with_mismatched_passwords_does_not_create_user(self):
@@ -72,7 +72,7 @@ class LoginViewTests(TestCase):
'password': 'a-very-uncommon-pw-1', '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) self.assertIn('_auth_user_id', self.client.session)
def test_post_with_wrong_password_does_not_log_in(self): 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) 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): def setUp(self):
with translation.override('es'): with translation.override('es'):
self.url = reverse('crochet:account_home') self.url = reverse('crochet: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}')
def test_logged_in_user_sees_their_username(self): def test_logged_in_user_sees_their_username(self):
user = User.objects.create_user(username='crocheter', password='a-very-uncommon-pw-1') 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.assertEqual(response.status_code, 200)
self.assertContains(response, 'crocheter') 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): def test_only_lists_own_patterns(self):
owner = User.objects.create_user(username='crocheter', password='a-very-uncommon-pw-1') 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') 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) self.assertEqual(pattern.created_by, user)
with translation.override('es'): with translation.override('es'):
edit_url = reverse('crochet:pattern_edit', args=[pattern.uuid]) edit_url = reverse('crochet:pattern_edit', args=[pattern.uuid])
self.assertRedirects(response, edit_url) 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)
+18 -5
View File
@@ -35,16 +35,20 @@ class HomeViewTests(TestCase):
self.assertContains(response, f'href="{register_url}"') self.assertContains(response, f'href="{register_url}"')
self.assertContains(response, f'href="{login_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') user = User.objects.create_user(username='crocheter', password='a-very-uncommon-pw-1')
self.client.force_login(user) self.client.force_login(user)
with translation.override('es'): with translation.override('es'):
url = reverse('crochet:home') url = reverse('crochet:home')
account_home_url = reverse('crochet:account_home')
response = self.client.get(url) response = self.client.get(url)
self.assertContains(response, f'href="{account_home_url}"') self.assertEqual(response.status_code, 200)
self.assertNotContains(response, 'Crear cuenta gratis') 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): def test_available_at_root_for_both_languages(self):
response_es = self.client.get('/es/') response_es = self.client.get('/es/')
@@ -144,6 +148,15 @@ class NavbarAccountDropdownTests(TestCase):
self.assertContains(response, f'href="{settings_url}"') self.assertContains(response, f'href="{settings_url}"')
self.assertContains(response, f'action="{logout_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): def test_anonymous_user_does_not_see_the_account_dropdown(self):
with translation.override('es'): with translation.override('es'):
settings_url = reverse('crochet:account_settings') settings_url = reverse('crochet:account_settings')
@@ -554,7 +567,7 @@ class PatternDeleteViewTests(TestCase):
response = self.client.post(self.url) response = self.client.post(self.url)
with translation.override('es'): with translation.override('es'):
account_home_url = reverse('crochet:account_home') account_home_url = reverse('crochet:home')
self.assertRedirects(response, account_home_url) self.assertRedirects(response, account_home_url)
self.assertFalse(Pattern.objects.filter(pk=self.pattern.pk).exists()) self.assertFalse(Pattern.objects.filter(pk=self.pattern.pk).exists())
+7 -2
View File
@@ -4,8 +4,9 @@ from django.utils.translation import gettext_lazy as _
from crochet.forms import StyledAuthenticationForm, StyledPasswordResetForm, StyledSetPasswordForm from crochet.forms import StyledAuthenticationForm, StyledPasswordResetForm, StyledSetPasswordForm
from crochet.views import ( from crochet.views import (
AccountDeleteView,
AccountDisplayNameUpdateView,
AccountEmailUpdateView, AccountEmailUpdateView,
AccountHomeView,
AccountPasswordChangeView, AccountPasswordChangeView,
AccountSettingsView, AccountSettingsView,
CookiePolicyView, CookiePolicyView,
@@ -43,13 +44,17 @@ urlpatterns = [
path(_('pattern/<uuid:uuid>/pdf/'), name='pattern_pdf', view=PatternPdfView.as_view()), path(_('pattern/<uuid:uuid>/pdf/'), name='pattern_pdf', view=PatternPdfView.as_view()),
path(_('pattern/<uuid:uuid>/delete/'), name='pattern_delete', view=PatternDeleteView.as_view()), path(_('pattern/<uuid:uuid>/delete/'), name='pattern_delete', view=PatternDeleteView.as_view()),
path(_('pattern/new/'), name='pattern_create', view=PatternCreateView.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/'), name='account_settings', view=AccountSettingsView.as_view()),
path(_('account/settings/email/'), name='account_settings_email', view=AccountEmailUpdateView.as_view()), path(_('account/settings/email/'), name='account_settings_email', view=AccountEmailUpdateView.as_view()),
path( path(
_('account/settings/password/'), name='account_settings_password', _('account/settings/password/'), name='account_settings_password',
view=AccountPasswordChangeView.as_view(), 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/register/'), name='register', view=RegisterView.as_view()),
path( path(
_('account/login/'), name='login', _('account/login/'), name='login',
+68 -25
View File
@@ -1,26 +1,31 @@
import json import json
import weasyprint 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.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import PermissionDenied, SuspiciousFileOperation from django.core.exceptions import PermissionDenied, SuspiciousFileOperation
from django.http import HttpResponse, HttpResponseBadRequest, JsonResponse from django.http import HttpResponse, HttpResponseBadRequest, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.template.loader import render_to_string 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.text import get_valid_filename
from django.utils.translation import get_language, gettext_lazy as _ from django.utils.translation import get_language, gettext_lazy as _
from django.views import View from django.views import View
from django.views.generic import CreateView, DetailView, TemplateView from django.views.generic import CreateView, DetailView, TemplateView
from crochet.cover_image import InvalidCoverImage, build_cover_image_variants 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.models import Pattern, PatternImage, StitchType
from crochet.pattern_render import render_pattern_html, sanitize_page_settings 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.') PATTERN_EMPTY_MESSAGE = _('Este patrón todavía no tiene contenido.')
@@ -65,6 +70,20 @@ def _pattern_detail_context(pattern):
class HomeView(TemplateView): class HomeView(TemplateView):
template_name = 'crochet/home.html' 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): class CookiePolicyView(TemplateView):
@@ -80,16 +99,14 @@ class PatternEditView(LoginRequiredMixin, DetailView):
def get_object(self, queryset=None): def get_object(self, queryset=None):
pattern = super().get_object(queryset) 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: if pattern.created_by_id is None or pattern.created_by_id != self.request.user.id:
raise PermissionDenied raise PermissionDenied
return pattern return pattern
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**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() context['lang'] = get_language()
# Se pasa tal cual a la plantilla con {{ pattern_data|json_script:"..." }} # Se pasa tal cual a la plantilla con {{ pattern_data|json_script:"..." }}
# para que main.js reconstruya el editor con el contenido guardado # 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). # versión con clases de daisyUI en sus widgets (ver crochet/forms.py).
form_class = StyledUserCreationForm form_class = StyledUserCreationForm
template_name = 'registration/register.html' template_name = 'registration/register.html'
success_url = reverse_lazy('crochet:account_home') success_url = reverse_lazy('crochet:home')
def form_valid(self, form): def form_valid(self, form):
response = super().form_valid(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) login(self.request, self.object)
return response 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): class AccountSettingsView(LoginRequiredMixin, TemplateView):
template_name = 'crochet/account_settings.html' template_name = 'crochet/account_settings.html'
@@ -268,6 +273,10 @@ class AccountSettingsView(LoginRequiredMixin, TemplateView):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context['email_form'] = EmailUpdateForm(user=self.request.user, initial={'email': self.request.user.email}) context['email_form'] = EmailUpdateForm(user=self.request.user, initial={'email': self.request.user.email})
context['password_form'] = StyledPasswordChangeForm(user=self.request.user) 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 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): class PatternCreateView(LoginRequiredMixin, View):
# Solo POST (ver el <form> en account_home.html): crear un patrón no # Solo POST (ver el <form> en account_home.html): crear un patrón no
# debería poder dispararse desde un simple enlace GET sin token CSRF. # debería poder dispararse desde un simple enlace GET sin token CSRF.
def post(self, request): 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) return redirect('crochet:pattern_edit', uuid=pattern.uuid)
@@ -328,4 +371,4 @@ class PatternDeleteView(LoginRequiredMixin, View):
def post(self, request, uuid): def post(self, request, uuid):
pattern = _get_owned_pattern_or_403(request, uuid) pattern = _get_owned_pattern_or_403(request, uuid)
pattern.delete() pattern.delete()
return redirect('crochet:account_home') return redirect('crochet:home')