From 778ec18b1767cec02cc65d8ac5fce559a39b18d1 Mon Sep 17 00:00:00 2001 From: Pablo Moreno Date: Fri, 17 Jul 2026 13:12:18 +0200 Subject: [PATCH] feat: added UI --- config/settings/base.py | 8 + crochet/forms.py | 22 ++ crochet/locale/en/LC_MESSAGES/django.po | 215 ++++++++++- crochet/locale/es/LC_MESSAGES/django.po | 348 ++++++++++++++++++ crochet/migrations/0006_pattern_created_by.py | 21 ++ crochet/models.py | 8 + crochet/static/css/main.css | 37 +- crochet/static/css/theme.css | 46 +++ .../templates/crochet/_platform_assets.html | 9 + crochet/templates/crochet/account_home.html | 56 +++ crochet/templates/crochet/base.html | 33 +- crochet/templates/crochet/pattern.html | 14 +- crochet/templates/crochet/pattern_detail.html | 4 + crochet/templates/registration/login.html | 37 ++ crochet/templates/registration/register.html | 37 ++ crochet/tests/test_auth.py | 154 ++++++++ crochet/tests/test_models.py | 16 + crochet/tests/test_views.py | 114 +++++- crochet/urls.py | 17 + crochet/views.py | 88 ++++- 20 files changed, 1251 insertions(+), 33 deletions(-) create mode 100644 crochet/forms.py create mode 100644 crochet/migrations/0006_pattern_created_by.py create mode 100644 crochet/static/css/theme.css create mode 100644 crochet/templates/crochet/_platform_assets.html create mode 100644 crochet/templates/crochet/account_home.html create mode 100644 crochet/templates/registration/login.html create mode 100644 crochet/templates/registration/register.html create mode 100644 crochet/tests/test_auth.py diff --git a/config/settings/base.py b/config/settings/base.py index 54ec4e3..eed9709 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -95,6 +95,14 @@ DATABASES = { } +# Auth +# https://docs.djangoproject.com/en/6.0/topics/auth/default/#all-authentication-views + +LOGIN_URL = 'crochet:login' +LOGIN_REDIRECT_URL = 'crochet:account_home' +LOGOUT_REDIRECT_URL = 'crochet:login' + + # Password validation # https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators diff --git a/crochet/forms.py b/crochet/forms.py new file mode 100644 index 0000000..e338dcd --- /dev/null +++ b/crochet/forms.py @@ -0,0 +1,22 @@ +from django.contrib.auth.forms import AuthenticationForm, UserCreationForm + +# Los formularios de auth de Django no traen ninguna clase CSS en sus +# widgets (piensan en HTML sin estilar): se añade aquí la clase de daisyUI +# para que login/registro (ver registration/*.html) tengan la misma pinta +# que el resto de inputs del sitio, sin depender de un paquete aparte +# (django-widget-tweaks) solo para dos formularios de tres campos. +INPUT_CLASSES = 'input input-bordered w-full' + + +class StyledAuthenticationForm(AuthenticationForm): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + for field in self.fields.values(): + field.widget.attrs['class'] = INPUT_CLASSES + + +class StyledUserCreationForm(UserCreationForm): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + for field in self.fields.values(): + field.widget.attrs['class'] = INPUT_CLASSES diff --git a/crochet/locale/en/LC_MESSAGES/django.po b/crochet/locale/en/LC_MESSAGES/django.po index 777fd77..b873078 100644 --- a/crochet/locale/en/LC_MESSAGES/django.po +++ b/crochet/locale/en/LC_MESSAGES/django.po @@ -2,182 +2,373 @@ msgid "" msgstr "" "Project-Id-Version: crochet\n" "Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-07-17 11:03+0000\n" "Language: en\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -# crochet/templates/crochet/pattern.html +#: crochet/templates/crochet/account_home.html:4 +msgid "Mis patrones" +msgstr "My patterns" -msgid "No se ha podido guardar. Inténtalo de nuevo." -msgstr "Couldn't save. Please try again." +#: crochet/templates/crochet/account_home.html:13 +#, python-format +msgid "Hola, %(username)s" +msgstr "Hi, %(username)s" -msgid "Guardar" -msgstr "Save" +#: crochet/templates/crochet/account_home.html:16 +msgid "Crear patrón" +msgstr "Create pattern" -msgid "Añadir sección" -msgstr "Add section" +#: crochet/templates/crochet/account_home.html:21 +#: crochet/templates/crochet/pattern.html:21 +msgid "¿Seguro que quieres eliminar este patrón? No podrás deshacerlo." +msgstr "Are you sure you want to delete this pattern? This cannot be undone." +#: crochet/templates/crochet/account_home.html:27 +msgid "Patrón sin título" +msgstr "Untitled pattern" + +#: crochet/templates/crochet/account_home.html:30 +#, python-format +msgid "Actualizado el %(date)s" +msgstr "Updated on %(date)s" + +#: crochet/templates/crochet/account_home.html:33 +#: crochet/templates/crochet/pattern.html:34 msgid "Ver patrón" msgstr "View pattern" -msgid "Descargar PDF" -msgstr "Download PDF" +#: crochet/templates/crochet/account_home.html:34 +msgid "Editar" +msgstr "Edit" +#: crochet/templates/crochet/account_home.html:38 +msgid "Eliminar" +msgstr "Delete" + +#: crochet/templates/crochet/account_home.html:47 +msgid "Todavía no tienes ningún patrón." +msgstr "You don't have any patterns yet." + +#: crochet/templates/crochet/account_home.html:53 +#: crochet/templates/crochet/base.html:34 +msgid "Cerrar sesión" +msgstr "Log out" + +#: crochet/templates/crochet/base.html:37 +#: crochet/templates/registration/login.html:4 +#: crochet/templates/registration/login.html:14 +msgid "Iniciar sesión" +msgstr "Log in" + +#: crochet/templates/crochet/base.html:38 +#: crochet/templates/registration/register.html:29 +msgid "Registrarme" +msgstr "Sign up" + +# crochet/templates/crochet/pattern.html +#: crochet/templates/crochet/pattern.html:31 +msgid "No se ha podido guardar. Inténtalo de nuevo." +msgstr "Couldn't save. Please try again." + +#: crochet/templates/crochet/pattern.html:31 +msgid "Guardar" +msgstr "Save" + +#: crochet/templates/crochet/pattern.html:35 msgid "Exportar a PDF" msgstr "Export to PDF" +#: crochet/templates/crochet/pattern.html:39 +msgid "Eliminar patrón" +msgstr "Delete pattern" + +#: crochet/templates/crochet/pattern.html:46 msgid "Guardado" msgstr "Saved" +#: crochet/templates/crochet/pattern.html:53 msgid "Personalización de página" msgstr "Page customization" +#: crochet/templates/crochet/pattern.html:57 msgid "Título del patrón" msgstr "Pattern title" +#: crochet/templates/crochet/pattern.html:58 msgid "Título del patrón..." msgstr "Pattern title..." +#: crochet/templates/crochet/pattern.html:61 msgid "Autor" msgstr "Author" +#: crochet/templates/crochet/pattern.html:62 msgid "Autor..." msgstr "Author..." +#: crochet/templates/crochet/pattern.html:67 +#: crochet/templates/crochet/pattern.html:151 msgid "Color del texto" msgstr "Text color" +#: crochet/templates/crochet/pattern.html:71 msgid "Color de acento (títulos)" msgstr "Accent color (headings)" +#: crochet/templates/crochet/pattern.html:75 +#: crochet/templates/crochet/pattern.html:151 msgid "Color de fondo" msgstr "Background color" +#: crochet/templates/crochet/pattern.html:79 msgid "Tipografía" msgstr "Font" +#: crochet/templates/crochet/pattern.html:85 msgid "Monoespaciada" msgstr "Monospace" +#: crochet/templates/crochet/pattern.html:89 msgid "Tamaño de letra" msgstr "Font size" +#: crochet/templates/crochet/pattern.html:91 msgid "Pequeño" msgstr "Small" +#: crochet/templates/crochet/pattern.html:92 msgid "Normal" msgstr "Normal" +#: crochet/templates/crochet/pattern.html:93 msgid "Grande" msgstr "Large" +#: crochet/templates/crochet/pattern.html:97 msgid "Alineación del texto" msgstr "Text alignment" +#: crochet/templates/crochet/pattern.html:99 msgid "Izquierda" msgstr "Left" +#: crochet/templates/crochet/pattern.html:100 msgid "Centrado" msgstr "Center" +#: crochet/templates/crochet/pattern.html:101 msgid "Justificado" msgstr "Justify" +#: crochet/templates/crochet/pattern.html:105 msgid "Tamaño de página" msgstr "Page size" +#: crochet/templates/crochet/pattern.html:110 msgid "Carta (Letter)" msgstr "Letter" +#: crochet/templates/crochet/pattern.html:115 msgid "Orientación" msgstr "Orientation" +#: crochet/templates/crochet/pattern.html:117 msgid "Vertical" msgstr "Portrait" +#: crochet/templates/crochet/pattern.html:118 msgid "Horizontal" msgstr "Landscape" +#: crochet/templates/crochet/pattern.html:126 +#: crochet/templates/crochet/pattern.html:134 msgid "Secciones" msgstr "Sections" +#: crochet/templates/crochet/pattern.html:127 +#: crochet/templates/crochet/pattern.html:173 msgid "Vista previa" msgstr "Preview" +#: crochet/templates/crochet/pattern.html:136 msgid "Colapsar todo" msgstr "Collapse all" +#: crochet/templates/crochet/pattern.html:136 msgid "Expandir todo" msgstr "Expand all" +#: crochet/templates/crochet/pattern.html:141 +msgid "Añadir sección" +msgstr "Add section" + +#: crochet/templates/crochet/pattern.html:144 msgid "Título" msgstr "Title" +#: crochet/templates/crochet/pattern.html:144 msgid "Título..." msgstr "Title..." +#: crochet/templates/crochet/pattern.html:146 msgid "Subtítulo" msgstr "Subtitle" +#: crochet/templates/crochet/pattern.html:146 msgid "Subtítulo..." msgstr "Subtitle..." +#: crochet/templates/crochet/pattern.html:148 msgid "Texto" msgstr "Text" +#: crochet/templates/crochet/pattern.html:148 msgid "Escribe aquí..." msgstr "Write here..." +#: crochet/templates/crochet/pattern.html:150 +#: crochet/templates/crochet/pattern.html:151 msgid "Nota" msgstr "Note" +#: crochet/templates/crochet/pattern.html:150 msgid "Escribe una nota o consejo..." msgstr "Write a note or tip..." +#: crochet/templates/crochet/pattern.html:153 msgid "Materiales" msgstr "Materials" +#: crochet/templates/crochet/pattern.html:153 msgid "Añadir material..." msgstr "Add material..." +#: crochet/templates/crochet/pattern.html:153 msgid "Añadir línea" msgstr "Add line" +#: crochet/templates/crochet/pattern.html:155 +#: crochet/templates/crochet/pattern.html:157 msgid "Imagen" msgstr "Image" +#: crochet/templates/crochet/pattern.html:157 msgid "No se ha podido subir la imagen. Inténtalo de nuevo." msgstr "Couldn't upload the image. Please try again." +#: crochet/templates/crochet/pattern.html:159 msgid "Patrón" msgstr "Pattern" +#: crochet/templates/crochet/pattern.html:159 msgid "Elementos" msgstr "Elements" +#: crochet/templates/crochet/pattern.html:161 msgid "Grupo" msgstr "Group" +#: crochet/templates/crochet/pattern.html:165 msgid "Arrastrar para reordenar" msgstr "Drag to reorder" +#: crochet/templates/crochet/pattern.html:166 msgid "Colapsar / expandir" msgstr "Collapse / expand" +#: crochet/templates/crochet/pattern.html:167 msgid "Duplicar sección" msgstr "Duplicate section" +#: crochet/templates/crochet/pattern.html:168 msgid "Eliminar sección" msgstr "Delete section" -msgid "¿Seguro que quieres eliminar este grupo? Se eliminarán también todas las secciones que contiene." -msgstr "Are you sure you want to delete this group? All sections inside it will also be deleted." +#: crochet/templates/crochet/pattern.html:169 +msgid "" +"¿Seguro que quieres eliminar este grupo? Se eliminarán también todas las " +"secciones que contiene." +msgstr "" +"Are you sure you want to delete this group? All sections inside it will also " +"be deleted." +#: crochet/templates/crochet/pattern.html:175 msgid "Añade una sección para ver aquí el resultado." msgstr "Add a section to see the result here." -# crochet/views.py (PatternDetailView.EMPTY_MESSAGE) +#: crochet/templates/crochet/pattern_detail.html:24 +msgid "Descargar PDF" +msgstr "Download PDF" +#: crochet/templates/registration/login.html:17 +msgid "Usuario o contraseña incorrectos." +msgstr "Incorrect username or password." + +#: crochet/templates/registration/login.html:29 +msgid "Entrar" +msgstr "Log in" + +#: crochet/templates/registration/login.html:32 +msgid "¿No tienes cuenta? Regístrate" +msgstr "Don't have an account? Sign up" + +#: crochet/templates/registration/register.html:4 +#: crochet/templates/registration/register.html:14 +msgid "Crear cuenta" +msgstr "Create account" + +#: crochet/templates/registration/register.html:32 +msgid "¿Ya tienes cuenta? Inicia sesión" +msgstr "Already have an account? Log in" + +#: crochet/urls.py:30 +msgid "pattern//" +msgstr "" + +#: crochet/urls.py:31 +msgid "pattern//edit/" +msgstr "" + +#: crochet/urls.py:32 +msgid "pattern//save/" +msgstr "" + +#: crochet/urls.py:33 +msgid "pattern//images/" +msgstr "" + +#: crochet/urls.py:34 +msgid "pattern//pdf/" +msgstr "" + +#: crochet/urls.py:35 +msgid "pattern//delete/" +msgstr "" + +#: crochet/urls.py:36 +msgid "pattern/new/" +msgstr "" + +#: crochet/urls.py:37 +msgid "account/" +msgstr "" + +#: crochet/urls.py:38 +msgid "account/register/" +msgstr "" + +#: crochet/urls.py:40 +msgid "account/login/" +msgstr "" + +#: crochet/urls.py:45 +msgid "account/logout/" +msgstr "" + +# crochet/views.py (PatternDetailView.EMPTY_MESSAGE) +#: crochet/views.py:23 msgid "Este patrón todavía no tiene contenido." msgstr "This pattern doesn't have any content yet." diff --git a/crochet/locale/es/LC_MESSAGES/django.po b/crochet/locale/es/LC_MESSAGES/django.po index 7a3faf4..4d676ec 100644 --- a/crochet/locale/es/LC_MESSAGES/django.po +++ b/crochet/locale/es/LC_MESSAGES/django.po @@ -2,24 +2,372 @@ msgid "" msgstr "" "Project-Id-Version: crochet\n" "Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-07-17 11:03+0000\n" "Language: es\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" +#: crochet/templates/crochet/account_home.html:4 +msgid "Mis patrones" +msgstr "" + +#: crochet/templates/crochet/account_home.html:13 +#, python-format +msgid "Hola, %(username)s" +msgstr "" + +#: crochet/templates/crochet/account_home.html:16 +msgid "Crear patrón" +msgstr "" + +#: crochet/templates/crochet/account_home.html:21 +#: crochet/templates/crochet/pattern.html:21 +msgid "¿Seguro que quieres eliminar este patrón? No podrás deshacerlo." +msgstr "" + +#: crochet/templates/crochet/account_home.html:27 +msgid "Patrón sin título" +msgstr "" + +#: crochet/templates/crochet/account_home.html:30 +#, python-format +msgid "Actualizado el %(date)s" +msgstr "" + +#: crochet/templates/crochet/account_home.html:33 +#: crochet/templates/crochet/pattern.html:34 +msgid "Ver patrón" +msgstr "" + +#: crochet/templates/crochet/account_home.html:34 +msgid "Editar" +msgstr "" + +#: crochet/templates/crochet/account_home.html:38 +msgid "Eliminar" +msgstr "" + +#: crochet/templates/crochet/account_home.html:47 +msgid "Todavía no tienes ningún patrón." +msgstr "" + +#: crochet/templates/crochet/account_home.html:53 +#: crochet/templates/crochet/base.html:34 +msgid "Cerrar sesión" +msgstr "" + +#: crochet/templates/crochet/base.html:37 +#: crochet/templates/registration/login.html:4 +#: crochet/templates/registration/login.html:14 +msgid "Iniciar sesión" +msgstr "" + +#: crochet/templates/crochet/base.html:38 +#: crochet/templates/registration/register.html:29 +msgid "Registrarme" +msgstr "" + +#: crochet/templates/crochet/pattern.html:31 +msgid "No se ha podido guardar. Inténtalo de nuevo." +msgstr "" + +#: crochet/templates/crochet/pattern.html:31 +msgid "Guardar" +msgstr "" + +#: crochet/templates/crochet/pattern.html:35 +msgid "Exportar a PDF" +msgstr "" + +#: crochet/templates/crochet/pattern.html:39 +msgid "Eliminar patrón" +msgstr "" + +#: crochet/templates/crochet/pattern.html:46 +msgid "Guardado" +msgstr "" + +#: crochet/templates/crochet/pattern.html:53 +msgid "Personalización de página" +msgstr "" + +#: crochet/templates/crochet/pattern.html:57 +msgid "Título del patrón" +msgstr "" + +#: crochet/templates/crochet/pattern.html:58 +msgid "Título del patrón..." +msgstr "" + +#: crochet/templates/crochet/pattern.html:61 +msgid "Autor" +msgstr "" + +#: crochet/templates/crochet/pattern.html:62 +msgid "Autor..." +msgstr "" + +#: crochet/templates/crochet/pattern.html:67 +#: crochet/templates/crochet/pattern.html:151 +msgid "Color del texto" +msgstr "" + +#: crochet/templates/crochet/pattern.html:71 +msgid "Color de acento (títulos)" +msgstr "" + +#: crochet/templates/crochet/pattern.html:75 +#: crochet/templates/crochet/pattern.html:151 +msgid "Color de fondo" +msgstr "" + +#: crochet/templates/crochet/pattern.html:79 +msgid "Tipografía" +msgstr "" + +#: crochet/templates/crochet/pattern.html:85 +msgid "Monoespaciada" +msgstr "" + +#: crochet/templates/crochet/pattern.html:89 +msgid "Tamaño de letra" +msgstr "" + +#: crochet/templates/crochet/pattern.html:91 +msgid "Pequeño" +msgstr "" + +#: crochet/templates/crochet/pattern.html:92 +msgid "Normal" +msgstr "" + +#: crochet/templates/crochet/pattern.html:93 +msgid "Grande" +msgstr "" + +#: crochet/templates/crochet/pattern.html:97 +msgid "Alineación del texto" +msgstr "" + +#: crochet/templates/crochet/pattern.html:99 +msgid "Izquierda" +msgstr "" + +#: crochet/templates/crochet/pattern.html:100 +msgid "Centrado" +msgstr "" + +#: crochet/templates/crochet/pattern.html:101 +msgid "Justificado" +msgstr "" + +#: crochet/templates/crochet/pattern.html:105 +msgid "Tamaño de página" +msgstr "" + +#: crochet/templates/crochet/pattern.html:110 +msgid "Carta (Letter)" +msgstr "" + +#: crochet/templates/crochet/pattern.html:115 +msgid "Orientación" +msgstr "" + +#: crochet/templates/crochet/pattern.html:117 +msgid "Vertical" +msgstr "" + +#: crochet/templates/crochet/pattern.html:118 +msgid "Horizontal" +msgstr "" + +#: crochet/templates/crochet/pattern.html:126 +#: crochet/templates/crochet/pattern.html:134 +msgid "Secciones" +msgstr "" + +#: crochet/templates/crochet/pattern.html:127 +#: crochet/templates/crochet/pattern.html:173 +msgid "Vista previa" +msgstr "" + +#: crochet/templates/crochet/pattern.html:136 +msgid "Colapsar todo" +msgstr "" + +#: crochet/templates/crochet/pattern.html:136 +msgid "Expandir todo" +msgstr "" + +#: crochet/templates/crochet/pattern.html:141 +msgid "Añadir sección" +msgstr "" + +#: crochet/templates/crochet/pattern.html:144 +msgid "Título" +msgstr "" + +#: crochet/templates/crochet/pattern.html:144 +msgid "Título..." +msgstr "" + +#: crochet/templates/crochet/pattern.html:146 +msgid "Subtítulo" +msgstr "" + +#: crochet/templates/crochet/pattern.html:146 +msgid "Subtítulo..." +msgstr "" + +#: crochet/templates/crochet/pattern.html:148 +msgid "Texto" +msgstr "" + +#: crochet/templates/crochet/pattern.html:148 +msgid "Escribe aquí..." +msgstr "" + +#: crochet/templates/crochet/pattern.html:150 +#: crochet/templates/crochet/pattern.html:151 +msgid "Nota" +msgstr "" + +#: crochet/templates/crochet/pattern.html:150 +msgid "Escribe una nota o consejo..." +msgstr "" + +#: crochet/templates/crochet/pattern.html:153 +msgid "Materiales" +msgstr "" + +#: crochet/templates/crochet/pattern.html:153 +msgid "Añadir material..." +msgstr "" + +#: crochet/templates/crochet/pattern.html:153 +msgid "Añadir línea" +msgstr "" + +#: crochet/templates/crochet/pattern.html:155 +#: crochet/templates/crochet/pattern.html:157 +msgid "Imagen" +msgstr "" + +#: crochet/templates/crochet/pattern.html:157 +msgid "No se ha podido subir la imagen. Inténtalo de nuevo." +msgstr "" + +#: crochet/templates/crochet/pattern.html:159 +msgid "Patrón" +msgstr "" + +#: crochet/templates/crochet/pattern.html:159 +msgid "Elementos" +msgstr "" + +#: crochet/templates/crochet/pattern.html:161 +msgid "Grupo" +msgstr "" + +#: crochet/templates/crochet/pattern.html:165 +msgid "Arrastrar para reordenar" +msgstr "" + +#: crochet/templates/crochet/pattern.html:166 +msgid "Colapsar / expandir" +msgstr "" + +#: crochet/templates/crochet/pattern.html:167 +msgid "Duplicar sección" +msgstr "" + +#: crochet/templates/crochet/pattern.html:168 +msgid "Eliminar sección" +msgstr "" + +#: crochet/templates/crochet/pattern.html:169 +msgid "" +"¿Seguro que quieres eliminar este grupo? Se eliminarán también todas las " +"secciones que contiene." +msgstr "" + +#: crochet/templates/crochet/pattern.html:175 +msgid "Añade una sección para ver aquí el resultado." +msgstr "" + +#: crochet/templates/crochet/pattern_detail.html:24 +msgid "Descargar PDF" +msgstr "" + +#: crochet/templates/registration/login.html:17 +msgid "Usuario o contraseña incorrectos." +msgstr "" + +#: crochet/templates/registration/login.html:29 +msgid "Entrar" +msgstr "" + +#: crochet/templates/registration/login.html:32 +msgid "¿No tienes cuenta? Regístrate" +msgstr "" + +#: crochet/templates/registration/register.html:4 +#: crochet/templates/registration/register.html:14 +msgid "Crear cuenta" +msgstr "" + +#: crochet/templates/registration/register.html:32 +msgid "¿Ya tienes cuenta? Inicia sesión" +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:30 msgid "pattern//" msgstr "patron//" +#: crochet/urls.py:31 msgid "pattern//edit/" msgstr "patron//editar/" +#: crochet/urls.py:32 msgid "pattern//save/" msgstr "patron//guardar/" +#: crochet/urls.py:33 msgid "pattern//images/" msgstr "patron//imagenes/" +#: crochet/urls.py:34 msgid "pattern//pdf/" msgstr "patron//pdf/" + +#: crochet/urls.py:35 +msgid "pattern//delete/" +msgstr "patron//eliminar/" + +#: crochet/urls.py:36 +msgid "pattern/new/" +msgstr "patron/nuevo/" + +# Igual que "pattern/" arriba: "account/" se traduce como "cuenta/". +#: crochet/urls.py:37 +msgid "account/" +msgstr "cuenta/" + +#: crochet/urls.py:38 +msgid "account/register/" +msgstr "cuenta/registro/" + +#: crochet/urls.py:40 +msgid "account/login/" +msgstr "cuenta/entrar/" + +#: crochet/urls.py:45 +msgid "account/logout/" +msgstr "cuenta/salir/" + +#: crochet/views.py:23 +msgid "Este patrón todavía no tiene contenido." +msgstr "" diff --git a/crochet/migrations/0006_pattern_created_by.py b/crochet/migrations/0006_pattern_created_by.py new file mode 100644 index 0000000..e079e0a --- /dev/null +++ b/crochet/migrations/0006_pattern_created_by.py @@ -0,0 +1,21 @@ +# Generated by Django 6.0.7 on 2026-07-17 10:17 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('crochet', '0005_seed_more_stitch_types'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='pattern', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='patterns', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/crochet/models.py b/crochet/models.py index 7eebaa1..fb3ed8a 100644 --- a/crochet/models.py +++ b/crochet/models.py @@ -1,5 +1,6 @@ import uuid +from django.conf import settings from django.db import models @@ -11,6 +12,13 @@ class Pattern(models.Model): # front, aquí solo hace falta persistirla). sections = models.JSONField(default=list, blank=True) page_settings = models.JSONField(default=dict, blank=True) + # Nullable porque los patrones creados antes de tener autenticación no + # tienen propietario; SET_NULL (no CASCADE) para que borrar una cuenta no + # se lleve por delante sus patrones. + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, null=True, blank=True, + on_delete=models.SET_NULL, related_name='patterns', + ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) diff --git a/crochet/static/css/main.css b/crochet/static/css/main.css index f88c88f..d762741 100644 --- a/crochet/static/css/main.css +++ b/crochet/static/css/main.css @@ -1,3 +1,27 @@ +/* Paleta de marca (estilo marketplace artesanal, tipo Etsy: naranja + terracota + crema). Se define aquí, una sola vez, porque main.css lo + carga toda plantilla (incluida pattern_detail.html, que no lleva + daisyUI); theme.css (solo en páginas con daisyUI, ver + _platform_assets.html) reusa estas mismas variables para el tema + "crochet" en vez de repetir los valores hexadecimales. */ +:root { + --brand-bg: #fffbf5; + --brand-bg-soft: #f7efe1; + --brand-border: #ede0c9; + --brand-text: #2e2118; + --brand-primary: #f1641e; + --brand-primary-content: #ffffff; + --brand-secondary: #2b6e63; + --brand-accent: #e2a32d; + + font-family: 'Poppins', system-ui, sans-serif; +} + +body { + background-color: var(--brand-bg); + color: var(--brand-text); +} + /* HTML: */ .breadcrumb { --s: 20px; /* control the shape */ @@ -43,17 +67,18 @@ .detail-btn { display: inline-flex; align-items: center; - padding: 0.375rem 0.75rem; + padding: 0.5rem 1rem; font-size: 0.875rem; font-weight: 600; - border: 1px solid #d1d5db; - border-radius: 0.5rem; - color: inherit; + border: 1px solid var(--brand-primary); + border-radius: 999px; + color: var(--brand-primary); text-decoration: none; - background-color: #fff; + background-color: transparent; } .detail-btn:hover { - background-color: #f3f4f6; + background-color: var(--brand-primary); + color: var(--brand-primary-content); } .note-box { diff --git a/crochet/static/css/theme.css b/crochet/static/css/theme.css new file mode 100644 index 0000000..69e9f5b --- /dev/null +++ b/crochet/static/css/theme.css @@ -0,0 +1,46 @@ +/* Tema daisyUI propio ("crochet"): paleta cálida de marketplace artesanal + (naranja terracota + crema), inspirada en plataformas tipo Etsy. Reusa los + valores de --brand-* definidos en main.css (que carga toda plantilla, con + o sin daisyUI) en vez de repetir los hexadecimales. + Se aplica vía data-theme="crochet" en (ver base.html) y debe + cargarse DESPUÉS del CDN de daisyUI (ver _platform_assets.html): ambas + reglas tienen la misma especificidad ([data-theme=...] vs :root), así que + gana la que aparece más tarde en la cascada. */ +[data-theme="crochet"] { + color-scheme: light; + + --color-base-100: var(--brand-bg); + --color-base-200: var(--brand-bg-soft); + --color-base-300: var(--brand-border); + --color-base-content: var(--brand-text); + + --color-primary: var(--brand-primary); + --color-primary-content: var(--brand-primary-content); + + --color-secondary: var(--brand-secondary); + --color-secondary-content: #ffffff; + + --color-accent: var(--brand-accent); + --color-accent-content: var(--brand-text); + + --color-neutral: var(--brand-text); + --color-neutral-content: #fbf4ec; + + --color-info: oklch(74% 0.16 232.661); + --color-info-content: oklch(29% 0.066 243.157); + --color-success: oklch(76% 0.177 163.223); + --color-success-content: oklch(37% 0.077 168.94); + --color-warning: oklch(82% 0.189 84.429); + --color-warning-content: oklch(41% 0.112 45.904); + --color-error: oklch(71% 0.194 13.428); + --color-error-content: oklch(27% 0.105 12.094); + + --radius-selector: 0.5rem; + --radius-field: 0.75rem; + --radius-box: 1rem; + --size-selector: 0.25rem; + --size-field: 0.25rem; + --border: 1px; + --depth: 1; + --noise: 0; +} \ No newline at end of file diff --git a/crochet/templates/crochet/_platform_assets.html b/crochet/templates/crochet/_platform_assets.html new file mode 100644 index 0000000..0f6aee5 --- /dev/null +++ b/crochet/templates/crochet/_platform_assets.html @@ -0,0 +1,9 @@ +{% load static %} +{# Un comentario Django de una sola línea (su lexer no cruza saltos de línea al buscar el cierre, y tampoco admite el propio delimitador de comentario dentro del texto). #} +{# Incluido por cada plantilla con look de "plataforma" (editor, cuenta): daisyUI + Tailwind vía CDN + el tema propio "crochet" (ver theme.css). pattern_detail.html NO lo incluye a propósito (ver ese archivo). #} + + + + + + \ No newline at end of file diff --git a/crochet/templates/crochet/account_home.html b/crochet/templates/crochet/account_home.html new file mode 100644 index 0000000..648b423 --- /dev/null +++ b/crochet/templates/crochet/account_home.html @@ -0,0 +1,56 @@ +{% extends 'crochet/base.html' %} +{% load i18n %} + +{% block title %}{% trans 'Mis patrones' %}{% endblock %} + +{% block extra_css %} + {% include 'crochet/_platform_assets.html' %} +{% endblock %} + +{% block content %} +
+
+

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

+
+ {% csrf_token %} + +
+
+ + {% if patterns %} + {% trans '¿Seguro que quieres eliminar este patrón? No podrás deshacerlo.' as delete_confirm_message %} +
+ {% for pattern in patterns %} +
+
+

+ {% if pattern.page_settings.title %}{{ pattern.page_settings.title }}{% else %}{% trans 'Patrón sin título' %}{% endif %} +

+

+ {% blocktrans with date=pattern.updated_at|date:'d/m/Y' %}Actualizado el {{ date }}{% endblocktrans %} +

+
+ {% trans 'Ver patrón' %} + {% trans 'Editar' %} +
+ {% csrf_token %} + +
+
+
+
+ {% endfor %} +
+ {% else %} +
+

{% trans 'Todavía no tienes ningún patrón.' %}

+
+ {% endif %} + +
+ {% csrf_token %} + +
+
+{% endblock %} diff --git a/crochet/templates/crochet/base.html b/crochet/templates/crochet/base.html index 17e4c23..8f8f3f3 100644 --- a/crochet/templates/crochet/base.html +++ b/crochet/templates/crochet/base.html @@ -1,6 +1,10 @@ -{% load static %} +{% load static i18n %} - + + @@ -11,6 +15,31 @@ + {% block navbar %} + + + {% endblock %} {% block content %}{% endblock %} {% block extra_js %}{% endblock %} diff --git a/crochet/templates/crochet/pattern.html b/crochet/templates/crochet/pattern.html index 49cd450..b98e88c 100644 --- a/crochet/templates/crochet/pattern.html +++ b/crochet/templates/crochet/pattern.html @@ -8,8 +8,7 @@ {% block extra_css %} - - + {% include 'crochet/_platform_assets.html' %} {% htmx_script %} @@ -19,8 +18,8 @@ {% block content %}
-
-

Crochet

+ {% trans '¿Seguro que quieres eliminar este patrón? No podrás deshacerlo.' as delete_confirm_message %} +
+ + +

+ {% trans '¿No tienes cuenta? Regístrate' %} +

+
+ +
+{% endblock %} diff --git a/crochet/templates/registration/register.html b/crochet/templates/registration/register.html new file mode 100644 index 0000000..5a3c18d --- /dev/null +++ b/crochet/templates/registration/register.html @@ -0,0 +1,37 @@ +{% extends 'crochet/base.html' %} +{% load i18n %} + +{% block title %}{% trans 'Crear cuenta' %}{% endblock %} + +{% block extra_css %} + {% include 'crochet/_platform_assets.html' %} +{% endblock %} + +{% block content %} +
+
+
+

{% trans 'Crear cuenta' %}

+
+ {% csrf_token %} + {% for field in form %} + + {% endfor %} + +
+

+ {% trans '¿Ya tienes cuenta? Inicia sesión' %} +

+
+
+
+{% endblock %} diff --git a/crochet/tests/test_auth.py b/crochet/tests/test_auth.py new file mode 100644 index 0000000..9b73c38 --- /dev/null +++ b/crochet/tests/test_auth.py @@ -0,0 +1,154 @@ +from django.contrib.auth.models import User +from django.test import TestCase +from django.urls import reverse +from django.utils import translation + +from crochet.models import Pattern + + +class RegisterViewTests(TestCase): + def setUp(self): + with translation.override('es'): + self.url = reverse('crochet:register') + + def test_get_renders_form(self): + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, 'registration/register.html') + + def test_post_creates_user_and_logs_in(self): + response = self.client.post(self.url, { + 'username': 'crocheter', + 'password1': 'a-very-uncommon-pw-1', + 'password2': 'a-very-uncommon-pw-1', + }) + + self.assertTrue(User.objects.filter(username='crocheter').exists()) + self.assertRedirects(response, reverse('crochet:account_home')) + self.assertIn('_auth_user_id', self.client.session) + + def test_post_with_mismatched_passwords_does_not_create_user(self): + response = self.client.post(self.url, { + 'username': 'crocheter', + 'password1': 'a-very-uncommon-pw-1', + 'password2': 'something-else', + }) + + self.assertEqual(response.status_code, 200) + self.assertFalse(User.objects.filter(username='crocheter').exists()) + + +class LoginViewTests(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:login') + + def test_get_renders_form(self): + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, 'registration/login.html') + + def test_post_with_correct_credentials_logs_in(self): + response = self.client.post(self.url, { + 'username': 'crocheter', + 'password': 'a-very-uncommon-pw-1', + }) + + self.assertRedirects(response, reverse('crochet:account_home')) + self.assertIn('_auth_user_id', self.client.session) + + def test_post_with_wrong_password_does_not_log_in(self): + response = self.client.post(self.url, { + 'username': 'crocheter', + 'password': 'wrong-password', + }) + + self.assertEqual(response.status_code, 200) + self.assertNotIn('_auth_user_id', self.client.session) + + +class LogoutViewTests(TestCase): + def test_post_logs_out(self): + 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:logout') + response = self.client.post(url) + + self.assertRedirects(response, reverse('crochet:login')) + self.assertNotIn('_auth_user_id', self.client.session) + + +class AccountHomeViewTests(TestCase): + 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}') + + def test_logged_in_user_sees_their_username(self): + user = User.objects.create_user(username='crocheter', password='a-very-uncommon-pw-1') + self.client.force_login(user) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'crocheter') + + 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') + own_pattern = Pattern.objects.create(created_by=owner, page_settings={'title': 'Bufanda'}) + Pattern.objects.create(created_by=other, page_settings={'title': 'Gorro'}) + Pattern.objects.create() # sin propietario + + self.client.force_login(owner) + response = self.client.get(self.url) + + self.assertEqual(list(response.context['patterns']), [own_pattern]) + self.assertContains(response, 'Bufanda') + self.assertNotContains(response, 'Gorro') + + +class PatternCreateViewTests(TestCase): + def setUp(self): + with translation.override('es'): + self.url = reverse('crochet:pattern_create') + + def test_anonymous_user_is_redirected_to_login(self): + response = self.client.post(self.url) + + with translation.override('es'): + login_url = reverse('crochet:login') + self.assertRedirects(response, f'{login_url}?next={self.url}') + self.assertEqual(Pattern.objects.count(), 0) + + def test_get_is_not_allowed(self): + user = User.objects.create_user(username='crocheter', password='a-very-uncommon-pw-1') + self.client.force_login(user) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 405) + + def test_post_creates_pattern_owned_by_current_user_and_redirects_to_editor(self): + user = User.objects.create_user(username='crocheter', password='a-very-uncommon-pw-1') + self.client.force_login(user) + + response = self.client.post(self.url) + + self.assertEqual(Pattern.objects.count(), 1) + pattern = Pattern.objects.get() + 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 diff --git a/crochet/tests/test_models.py b/crochet/tests/test_models.py index f9baddd..8f908a2 100644 --- a/crochet/tests/test_models.py +++ b/crochet/tests/test_models.py @@ -1,5 +1,6 @@ import io +from django.contrib.auth.models import User from django.core.files.uploadedfile import SimpleUploadedFile from django.test import TestCase from PIL import Image @@ -32,6 +33,21 @@ class PatternModelTests(TestCase): second = Pattern.objects.create() self.assertNotEqual(first.uuid, second.uuid) + def test_created_by_defaults_to_none(self): + pattern = Pattern.objects.create() + self.assertIsNone(pattern.created_by) + + def test_created_by_survives_owner_deletion(self): + # SET_NULL (no CASCADE): borrar la cuenta no debe llevarse por delante + # el patrón, solo desasociarlo. + owner = User.objects.create_user(username='crocheter', password='s3cret-pw') + pattern = Pattern.objects.create(created_by=owner) + + owner.delete() + pattern.refresh_from_db() + + self.assertIsNone(pattern.created_by) + class PatternImageModelTests(TestCase): def test_deleted_with_pattern(self): diff --git a/crochet/tests/test_views.py b/crochet/tests/test_views.py index 1df3e64..f39d44a 100644 --- a/crochet/tests/test_views.py +++ b/crochet/tests/test_views.py @@ -1,6 +1,7 @@ import json import re +from django.contrib.auth.models import User from django.test import TestCase from django.urls import reverse from django.utils import translation @@ -13,13 +14,16 @@ NO_HTML_COMMENTS_RE = re.compile(r'', re.S) class PatternEditViewTests(TestCase): def setUp(self): + self.owner = User.objects.create_user(username='crocheter', password='a-very-uncommon-pw-1') self.pattern = Pattern.objects.create( sections=[{'type': 'title', 'text': {'es': 'Ampharos', 'en': 'Ampharos'}}], page_settings={'title': 'Ampharos'}, + created_by=self.owner, ) StitchType.objects.create(translations={'es': 'Punto bajo', 'en': 'SC'}, order=1) def test_get_in_spanish(self): + self.client.force_login(self.owner) with translation.override('es'): url = reverse('crochet:pattern_edit', args=[self.pattern.uuid]) response = self.client.get(url) @@ -30,6 +34,7 @@ class PatternEditViewTests(TestCase): self.assertContains(response, 'Guardar') def test_get_in_english(self): + self.client.force_login(self.owner) with translation.override('en'): url = reverse('crochet:pattern_edit', args=[self.pattern.uuid]) response = self.client.get(url) @@ -38,6 +43,7 @@ class PatternEditViewTests(TestCase): self.assertContains(response, 'Save') def test_context_includes_saved_pattern_and_stitch_catalog(self): + self.client.force_login(self.owner) with translation.override('es'): url = reverse('crochet:pattern_edit', args=[self.pattern.uuid]) response = self.client.get(url) @@ -48,12 +54,39 @@ class PatternEditViewTests(TestCase): ]) def test_unknown_uuid_returns_404(self): + self.client.force_login(self.owner) with translation.override('es'): url = reverse('crochet:pattern_edit', args=['00000000-0000-0000-0000-000000000000']) response = self.client.get(url) self.assertEqual(response.status_code, 404) + def test_anonymous_user_is_redirected_to_login(self): + with translation.override('es'): + url = reverse('crochet:pattern_edit', args=[self.pattern.uuid]) + login_url = reverse('crochet:login') + response = self.client.get(url) + + self.assertRedirects(response, f'{login_url}?next={url}') + + def test_other_user_gets_403(self): + User.objects.create_user(username='other', password='a-very-uncommon-pw-1') + self.client.login(username='other', password='a-very-uncommon-pw-1') + with translation.override('es'): + url = reverse('crochet:pattern_edit', args=[self.pattern.uuid]) + response = self.client.get(url) + + self.assertEqual(response.status_code, 403) + + def test_pattern_without_owner_is_blocked_even_for_logged_in_user(self): + orphan_pattern = Pattern.objects.create() + self.client.force_login(self.owner) + with translation.override('es'): + url = reverse('crochet:pattern_edit', args=[orphan_pattern.uuid]) + response = self.client.get(url) + + self.assertEqual(response.status_code, 403) + class PatternDetailViewTests(TestCase): def setUp(self): @@ -170,7 +203,9 @@ class PatternI18nUrlTests(TestCase): class PatternSaveViewTests(TestCase): def setUp(self): - self.pattern = Pattern.objects.create() + self.owner = User.objects.create_user(username='crocheter', password='a-very-uncommon-pw-1') + self.pattern = Pattern.objects.create(created_by=self.owner) + self.client.force_login(self.owner) with translation.override('es'): self.url = reverse('crochet:pattern_save', args=[self.pattern.uuid]) @@ -205,10 +240,30 @@ class PatternSaveViewTests(TestCase): response = self.client.post(url, data=json.dumps({}), content_type='application/json') self.assertEqual(response.status_code, 404) + def test_anonymous_user_gets_403(self): + self.client.logout() + response = self.client.post(self.url, data=json.dumps({}), content_type='application/json') + self.assertEqual(response.status_code, 403) + + def test_other_user_gets_403(self): + User.objects.create_user(username='other', password='a-very-uncommon-pw-1') + self.client.login(username='other', password='a-very-uncommon-pw-1') + response = self.client.post(self.url, data=json.dumps({}), content_type='application/json') + self.assertEqual(response.status_code, 403) + + def test_pattern_without_owner_is_blocked_even_for_logged_in_user(self): + orphan_pattern = Pattern.objects.create() + with translation.override('es'): + url = reverse('crochet:pattern_save', args=[orphan_pattern.uuid]) + response = self.client.post(url, data=json.dumps({}), content_type='application/json') + self.assertEqual(response.status_code, 403) + class PatternImageUploadViewTests(TestCase): def setUp(self): - self.pattern = Pattern.objects.create() + self.owner = User.objects.create_user(username='crocheter', password='a-very-uncommon-pw-1') + self.pattern = Pattern.objects.create(created_by=self.owner) + self.client.force_login(self.owner) with translation.override('es'): self.url = reverse('crochet:pattern_image_upload', args=[self.pattern.uuid]) @@ -228,3 +283,58 @@ class PatternImageUploadViewTests(TestCase): def test_missing_file_returns_bad_request(self): response = self.client.post(self.url, data={}) self.assertEqual(response.status_code, 400) + + def test_other_user_gets_403(self): + User.objects.create_user(username='other', password='a-very-uncommon-pw-1') + self.client.login(username='other', password='a-very-uncommon-pw-1') + response = self.client.post(self.url, data={'image': make_test_image_file()}) + self.assertEqual(response.status_code, 403) + + +class PatternDeleteViewTests(TestCase): + def setUp(self): + self.owner = User.objects.create_user(username='crocheter', password='a-very-uncommon-pw-1') + self.pattern = Pattern.objects.create(created_by=self.owner) + with translation.override('es'): + self.url = reverse('crochet:pattern_delete', args=[self.pattern.uuid]) + + def test_owner_can_delete(self): + self.client.force_login(self.owner) + response = self.client.post(self.url) + + with translation.override('es'): + account_home_url = reverse('crochet:account_home') + self.assertRedirects(response, account_home_url) + self.assertFalse(Pattern.objects.filter(pk=self.pattern.pk).exists()) + + def test_anonymous_user_is_redirected_to_login(self): + response = self.client.post(self.url) + + with translation.override('es'): + login_url = reverse('crochet:login') + self.assertRedirects(response, f'{login_url}?next={self.url}') + self.assertTrue(Pattern.objects.filter(pk=self.pattern.pk).exists()) + + def test_other_user_gets_403(self): + User.objects.create_user(username='other', password='a-very-uncommon-pw-1') + self.client.login(username='other', password='a-very-uncommon-pw-1') + response = self.client.post(self.url) + + self.assertEqual(response.status_code, 403) + self.assertTrue(Pattern.objects.filter(pk=self.pattern.pk).exists()) + + def test_pattern_without_owner_is_blocked_even_for_logged_in_user(self): + orphan_pattern = Pattern.objects.create() + self.client.force_login(self.owner) + with translation.override('es'): + url = reverse('crochet:pattern_delete', args=[orphan_pattern.uuid]) + response = self.client.post(url) + + self.assertEqual(response.status_code, 403) + self.assertTrue(Pattern.objects.filter(pk=orphan_pattern.pk).exists()) + + def test_get_is_not_allowed(self): + self.client.force_login(self.owner) + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 405) diff --git a/crochet/urls.py b/crochet/urls.py index 91f4ca6..2292a55 100644 --- a/crochet/urls.py +++ b/crochet/urls.py @@ -1,12 +1,18 @@ +from django.contrib.auth import views as auth_views from django.urls import path from django.utils.translation import gettext_lazy as _ +from crochet.forms import StyledAuthenticationForm from crochet.views import ( + AccountHomeView, + PatternCreateView, + PatternDeleteView, PatternDetailView, PatternEditView, PatternImageUploadView, PatternPdfView, PatternSaveView, + RegisterView, ) app_name = 'crochet' @@ -26,4 +32,15 @@ urlpatterns = [ path(_('pattern//save/'), name='pattern_save', view=PatternSaveView.as_view()), path(_('pattern//images/'), name='pattern_image_upload', view=PatternImageUploadView.as_view()), 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/register/'), name='register', view=RegisterView.as_view()), + path( + _('account/login/'), name='login', + view=auth_views.LoginView.as_view( + template_name='registration/login.html', authentication_form=StyledAuthenticationForm, + ), + ), + path(_('account/logout/'), name='logout', view=auth_views.LogoutView.as_view()), ] diff --git a/crochet/views.py b/crochet/views.py index 33c88f4..26ff6c7 100644 --- a/crochet/views.py +++ b/crochet/views.py @@ -1,15 +1,19 @@ import json import weasyprint -from django.core.exceptions import SuspiciousFileOperation +from django.contrib.auth import login +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 +from django.shortcuts import get_object_or_404, redirect from django.template.loader import render_to_string +from django.urls import 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 DetailView +from django.views.generic import CreateView, DetailView, TemplateView +from crochet.forms import StyledUserCreationForm from crochet.models import Pattern, PatternImage, StitchType from crochet.pattern_render import render_pattern_html, sanitize_page_settings @@ -28,6 +32,19 @@ def _stitch_types_data(): ] +def _get_owned_pattern_or_403(request, uuid): + # Compartida por todo lo que modifica un patrón (guardar, subir imagen, + # borrar) y por PatternEditView: created_by=None son patrones de antes de + # tener autenticación, sin dueño al que atribuírselos, así que se + # bloquea su edición para todo el mundo en vez de dejarlos abiertos o + # asignárselos a quien los toque primero. + pattern = get_object_or_404(Pattern, uuid=uuid) + if pattern.created_by_id is None or not request.user.is_authenticated \ + or request.user.id != pattern.created_by_id: + raise PermissionDenied + return pattern + + def _pattern_detail_context(pattern): # Compartido por PatternDetailView (la pinta) y PatternPdfView (la # convierte a PDF con WeasyPrint a partir del mismo HTML), para no @@ -44,13 +61,21 @@ def _pattern_detail_context(pattern): } -class PatternEditView(DetailView): +class PatternEditView(LoginRequiredMixin, DetailView): model = Pattern template_name = 'crochet/pattern.html' context_object_name = 'pattern' slug_field = 'uuid' slug_url_kwarg = 'uuid' + 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 @@ -135,8 +160,13 @@ class PatternPdfView(DetailView): class PatternSaveView(View): + # Sin LoginRequiredMixin a propósito: esto lo llama storage.js por + # fetch, y su redirección a login (una respuesta 200 con el HTML del + # formulario) haría que "response.ok" diera un falso "guardado" en vez + # de disparar el aviso de error. _get_owned_pattern_or_403 ya cubre + # tanto "sin sesión" como "de otro usuario" con un 403 real. def post(self, request, uuid): - pattern = get_object_or_404(Pattern, uuid=uuid) + pattern = _get_owned_pattern_or_403(request, uuid) try: data = json.loads(request.body) @@ -151,8 +181,9 @@ class PatternSaveView(View): class PatternImageUploadView(View): + # Mismo motivo que en PatternSaveView: lo llama sections.js por fetch. def post(self, request, uuid): - pattern = get_object_or_404(Pattern, uuid=uuid) + pattern = _get_owned_pattern_or_403(request, uuid) image_file = request.FILES.get('image') if not image_file: @@ -160,3 +191,48 @@ class PatternImageUploadView(View): pattern_image = PatternImage.objects.create(pattern=pattern, image=image_file) return JsonResponse({'id': pattern_image.id, 'url': pattern_image.image.url}) + + +class RegisterView(CreateView): + # UserCreationForm ya pide justo nombre de usuario + contraseña (dos + # veces, para confirmarla): no hace falta un formulario propio, solo la + # 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') + + 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 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) + return redirect('crochet:pattern_edit', uuid=pattern.uuid) + + +class PatternDeleteView(LoginRequiredMixin, View): + # Con LoginRequiredMixin (a diferencia de PatternSaveView/ + # PatternImageUploadView): esto lo dispara un normal (ver + # account_home.html y pattern.html), no fetch, así que a un anónimo le + # viene mejor la redirección a login que un 403 a secas. + def post(self, request, uuid): + pattern = _get_owned_pattern_or_403(request, uuid) + pattern.delete() + return redirect('crochet:account_home')