Compare commits

...

2 Commits

Author SHA1 Message Date
pablo 778ec18b17 feat: added UI
CI / test (push) Successful in 39s
CI / build (push) Successful in 1m10s
2026-07-17 13:12:18 +02:00
pablo 82399f1391 feat: htmx setup 2026-07-17 12:04:47 +02:00
22 changed files with 1312 additions and 105 deletions
+10
View File
@@ -29,6 +29,7 @@ THIRD_PARTY_APPS = [
'corsheaders', 'corsheaders',
'django_extensions', 'django_extensions',
'django_filters', 'django_filters',
'django_htmx',
'watchman', 'watchman',
] ]
@@ -55,6 +56,7 @@ MIDDLEWARE = [
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware',
'django_htmx.middleware.HtmxMiddleware',
] ]
if DEBUG: if DEBUG:
@@ -93,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 # Password validation
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators # https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators
+22
View File
@@ -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
+203 -12
View File
@@ -2,182 +2,373 @@ 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-17 11:03+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/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." #: crochet/templates/crochet/account_home.html:13
msgstr "Couldn't save. Please try again." #, python-format
msgid "Hola, %(username)s"
msgstr "Hi, %(username)s"
msgid "Guardar" #: crochet/templates/crochet/account_home.html:16
msgstr "Save" msgid "Crear patrón"
msgstr "Create pattern"
msgid "Añadir sección" #: crochet/templates/crochet/account_home.html:21
msgstr "Add section" #: 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" msgid "Ver patrón"
msgstr "View pattern" msgstr "View pattern"
msgid "Descargar PDF" #: crochet/templates/crochet/account_home.html:34
msgstr "Download PDF" 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" msgid "Exportar a PDF"
msgstr "Export to 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" msgid "Guardado"
msgstr "Saved" msgstr "Saved"
#: crochet/templates/crochet/pattern.html:53
msgid "Personalización de página" msgid "Personalización de página"
msgstr "Page customization" msgstr "Page customization"
#: crochet/templates/crochet/pattern.html:57
msgid "Título del patrón" msgid "Título del patrón"
msgstr "Pattern title" msgstr "Pattern title"
#: crochet/templates/crochet/pattern.html:58
msgid "Título del patrón..." msgid "Título del patrón..."
msgstr "Pattern title..." msgstr "Pattern title..."
#: crochet/templates/crochet/pattern.html:61
msgid "Autor" msgid "Autor"
msgstr "Author" msgstr "Author"
#: crochet/templates/crochet/pattern.html:62
msgid "Autor..." msgid "Autor..."
msgstr "Author..." msgstr "Author..."
#: crochet/templates/crochet/pattern.html:67
#: crochet/templates/crochet/pattern.html:151
msgid "Color del texto" msgid "Color del texto"
msgstr "Text color" msgstr "Text color"
#: crochet/templates/crochet/pattern.html:71
msgid "Color de acento (títulos)" msgid "Color de acento (títulos)"
msgstr "Accent color (headings)" msgstr "Accent color (headings)"
#: crochet/templates/crochet/pattern.html:75
#: crochet/templates/crochet/pattern.html:151
msgid "Color de fondo" msgid "Color de fondo"
msgstr "Background color" msgstr "Background color"
#: crochet/templates/crochet/pattern.html:79
msgid "Tipografía" msgid "Tipografía"
msgstr "Font" msgstr "Font"
#: crochet/templates/crochet/pattern.html:85
msgid "Monoespaciada" msgid "Monoespaciada"
msgstr "Monospace" msgstr "Monospace"
#: crochet/templates/crochet/pattern.html:89
msgid "Tamaño de letra" msgid "Tamaño de letra"
msgstr "Font size" msgstr "Font size"
#: crochet/templates/crochet/pattern.html:91
msgid "Pequeño" msgid "Pequeño"
msgstr "Small" msgstr "Small"
#: crochet/templates/crochet/pattern.html:92
msgid "Normal" msgid "Normal"
msgstr "Normal" msgstr "Normal"
#: crochet/templates/crochet/pattern.html:93
msgid "Grande" msgid "Grande"
msgstr "Large" msgstr "Large"
#: crochet/templates/crochet/pattern.html:97
msgid "Alineación del texto" msgid "Alineación del texto"
msgstr "Text alignment" msgstr "Text alignment"
#: crochet/templates/crochet/pattern.html:99
msgid "Izquierda" msgid "Izquierda"
msgstr "Left" msgstr "Left"
#: crochet/templates/crochet/pattern.html:100
msgid "Centrado" msgid "Centrado"
msgstr "Center" msgstr "Center"
#: crochet/templates/crochet/pattern.html:101
msgid "Justificado" msgid "Justificado"
msgstr "Justify" msgstr "Justify"
#: crochet/templates/crochet/pattern.html:105
msgid "Tamaño de página" msgid "Tamaño de página"
msgstr "Page size" msgstr "Page size"
#: crochet/templates/crochet/pattern.html:110
msgid "Carta (Letter)" msgid "Carta (Letter)"
msgstr "Letter" msgstr "Letter"
#: crochet/templates/crochet/pattern.html:115
msgid "Orientación" msgid "Orientación"
msgstr "Orientation" msgstr "Orientation"
#: crochet/templates/crochet/pattern.html:117
msgid "Vertical" msgid "Vertical"
msgstr "Portrait" msgstr "Portrait"
#: crochet/templates/crochet/pattern.html:118
msgid "Horizontal" msgid "Horizontal"
msgstr "Landscape" msgstr "Landscape"
#: crochet/templates/crochet/pattern.html:126
#: crochet/templates/crochet/pattern.html:134
msgid "Secciones" msgid "Secciones"
msgstr "Sections" msgstr "Sections"
#: crochet/templates/crochet/pattern.html:127
#: crochet/templates/crochet/pattern.html:173
msgid "Vista previa" msgid "Vista previa"
msgstr "Preview" msgstr "Preview"
#: crochet/templates/crochet/pattern.html:136
msgid "Colapsar todo" msgid "Colapsar todo"
msgstr "Collapse all" msgstr "Collapse all"
#: crochet/templates/crochet/pattern.html:136
msgid "Expandir todo" msgid "Expandir todo"
msgstr "Expand all" 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" msgid "Título"
msgstr "Title" msgstr "Title"
#: crochet/templates/crochet/pattern.html:144
msgid "Título..." msgid "Título..."
msgstr "Title..." msgstr "Title..."
#: crochet/templates/crochet/pattern.html:146
msgid "Subtítulo" msgid "Subtítulo"
msgstr "Subtitle" msgstr "Subtitle"
#: crochet/templates/crochet/pattern.html:146
msgid "Subtítulo..." msgid "Subtítulo..."
msgstr "Subtitle..." msgstr "Subtitle..."
#: crochet/templates/crochet/pattern.html:148
msgid "Texto" msgid "Texto"
msgstr "Text" msgstr "Text"
#: crochet/templates/crochet/pattern.html:148
msgid "Escribe aquí..." msgid "Escribe aquí..."
msgstr "Write here..." msgstr "Write here..."
#: crochet/templates/crochet/pattern.html:150
#: crochet/templates/crochet/pattern.html:151
msgid "Nota" msgid "Nota"
msgstr "Note" msgstr "Note"
#: crochet/templates/crochet/pattern.html:150
msgid "Escribe una nota o consejo..." msgid "Escribe una nota o consejo..."
msgstr "Write a note or tip..." msgstr "Write a note or tip..."
#: crochet/templates/crochet/pattern.html:153
msgid "Materiales" msgid "Materiales"
msgstr "Materials" msgstr "Materials"
#: crochet/templates/crochet/pattern.html:153
msgid "Añadir material..." msgid "Añadir material..."
msgstr "Add material..." msgstr "Add material..."
#: crochet/templates/crochet/pattern.html:153
msgid "Añadir línea" msgid "Añadir línea"
msgstr "Add line" msgstr "Add line"
#: crochet/templates/crochet/pattern.html:155
#: crochet/templates/crochet/pattern.html:157
msgid "Imagen" msgid "Imagen"
msgstr "Image" msgstr "Image"
#: crochet/templates/crochet/pattern.html:157
msgid "No se ha podido subir la imagen. Inténtalo de nuevo." msgid "No se ha podido subir la imagen. Inténtalo de nuevo."
msgstr "Couldn't upload the image. Please try again." msgstr "Couldn't upload the image. Please try again."
#: crochet/templates/crochet/pattern.html:159
msgid "Patrón" msgid "Patrón"
msgstr "Pattern" msgstr "Pattern"
#: crochet/templates/crochet/pattern.html:159
msgid "Elementos" msgid "Elementos"
msgstr "Elements" msgstr "Elements"
#: crochet/templates/crochet/pattern.html:161
msgid "Grupo" msgid "Grupo"
msgstr "Group" msgstr "Group"
#: crochet/templates/crochet/pattern.html:165
msgid "Arrastrar para reordenar" msgid "Arrastrar para reordenar"
msgstr "Drag to reorder" msgstr "Drag to reorder"
#: crochet/templates/crochet/pattern.html:166
msgid "Colapsar / expandir" msgid "Colapsar / expandir"
msgstr "Collapse / expand" msgstr "Collapse / expand"
#: crochet/templates/crochet/pattern.html:167
msgid "Duplicar sección" msgid "Duplicar sección"
msgstr "Duplicate section" msgstr "Duplicate section"
#: crochet/templates/crochet/pattern.html:168
msgid "Eliminar sección" msgid "Eliminar sección"
msgstr "Delete section" msgstr "Delete section"
msgid "¿Seguro que quieres eliminar este grupo? Se eliminarán también todas las secciones que contiene." #: crochet/templates/crochet/pattern.html:169
msgstr "Are you sure you want to delete this group? All sections inside it will also be deleted." 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." 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/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/<uuid:uuid>/"
msgstr ""
#: crochet/urls.py:31
msgid "pattern/<uuid:uuid>/edit/"
msgstr ""
#: crochet/urls.py:32
msgid "pattern/<uuid:uuid>/save/"
msgstr ""
#: crochet/urls.py:33
msgid "pattern/<uuid:uuid>/images/"
msgstr ""
#: crochet/urls.py:34
msgid "pattern/<uuid:uuid>/pdf/"
msgstr ""
#: crochet/urls.py:35
msgid "pattern/<uuid:uuid>/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." 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."
+348
View File
@@ -2,24 +2,372 @@ 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-17 11:03+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/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 # 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:30
msgid "pattern/<uuid:uuid>/" msgid "pattern/<uuid:uuid>/"
msgstr "patron/<uuid:uuid>/" msgstr "patron/<uuid:uuid>/"
#: crochet/urls.py:31
msgid "pattern/<uuid:uuid>/edit/" msgid "pattern/<uuid:uuid>/edit/"
msgstr "patron/<uuid:uuid>/editar/" msgstr "patron/<uuid:uuid>/editar/"
#: crochet/urls.py:32
msgid "pattern/<uuid:uuid>/save/" msgid "pattern/<uuid:uuid>/save/"
msgstr "patron/<uuid:uuid>/guardar/" msgstr "patron/<uuid:uuid>/guardar/"
#: crochet/urls.py:33
msgid "pattern/<uuid:uuid>/images/" msgid "pattern/<uuid:uuid>/images/"
msgstr "patron/<uuid:uuid>/imagenes/" msgstr "patron/<uuid:uuid>/imagenes/"
#: crochet/urls.py:34
msgid "pattern/<uuid:uuid>/pdf/" msgid "pattern/<uuid:uuid>/pdf/"
msgstr "patron/<uuid:uuid>/pdf/" msgstr "patron/<uuid:uuid>/pdf/"
#: crochet/urls.py:35
msgid "pattern/<uuid:uuid>/delete/"
msgstr "patron/<uuid:uuid>/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 ""
@@ -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),
),
]
+8
View File
@@ -1,5 +1,6 @@
import uuid import uuid
from django.conf import settings
from django.db import models from django.db import models
@@ -11,6 +12,13 @@ class Pattern(models.Model):
# front, aquí solo hace falta persistirla). # front, aquí solo hace falta persistirla).
sections = models.JSONField(default=list, blank=True) sections = models.JSONField(default=list, blank=True)
page_settings = models.JSONField(default=dict, 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) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
+31 -6
View File
@@ -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: <div class="breadcrumb"></div> */ /* HTML: <div class="breadcrumb"></div> */
.breadcrumb { .breadcrumb {
--s: 20px; /* control the shape */ --s: 20px; /* control the shape */
@@ -43,17 +67,18 @@
.detail-btn { .detail-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
padding: 0.375rem 0.75rem; padding: 0.5rem 1rem;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 600; font-weight: 600;
border: 1px solid #d1d5db; border: 1px solid var(--brand-primary);
border-radius: 0.5rem; border-radius: 999px;
color: inherit; color: var(--brand-primary);
text-decoration: none; text-decoration: none;
background-color: #fff; background-color: transparent;
} }
.detail-btn:hover { .detail-btn:hover {
background-color: #f3f4f6; background-color: var(--brand-primary);
color: var(--brand-primary-content);
} }
.note-box { .note-box {
+46
View File
@@ -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 <html> (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;
}
@@ -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). #}
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<link rel="stylesheet" href="{% static 'css/theme.css' %}">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap" rel="stylesheet">
@@ -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 %}
<main class="max-w-4xl mx-auto px-4 py-8">
<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>
<form method="post" action="{% url 'crochet:pattern_create' %}">
{% csrf_token %}
<button type="submit" class="btn btn-primary">+ {% trans 'Crear patrón' %}</button>
</form>
</div>
{% if patterns %}
{% trans '¿Seguro que quieres eliminar este patrón? No podrás deshacerlo.' as delete_confirm_message %}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{% for pattern in patterns %}
<div class="card bg-base-100 shadow border border-base-300">
<div class="card-body">
<h2 class="card-title text-lg">
{% if pattern.page_settings.title %}{{ pattern.page_settings.title }}{% else %}{% trans 'Patrón sin título' %}{% endif %}
</h2>
<p class="text-sm text-base-content/60">
{% blocktrans with date=pattern.updated_at|date:'d/m/Y' %}Actualizado el {{ date }}{% endblocktrans %}
</p>
<div class="card-actions justify-end mt-2">
<a href="{% url 'crochet:pattern_detail' pattern.uuid %}" class="btn btn-sm btn-outline">{% trans 'Ver patrón' %}</a>
<a href="{% url 'crochet:pattern_edit' pattern.uuid %}" class="btn btn-sm btn-primary">{% trans 'Editar' %}</a>
<form method="post" action="{% url 'crochet:pattern_delete' pattern.uuid %}"
onsubmit="return confirm('{{ delete_confirm_message }}')">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-outline btn-error">{% trans 'Eliminar' %}</button>
</form>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center py-16 text-base-content/60">
<p>{% trans 'Todavía no tienes ningún patrón.' %}</p>
</div>
{% endif %}
<form method="post" action="{% url 'crochet:logout' %}" class="mt-8">
{% csrf_token %}
<button type="submit" class="btn btn-outline btn-sm">{% trans 'Cerrar sesión' %}</button>
</form>
</main>
{% endblock %}
+47
View File
@@ -0,0 +1,47 @@
{% load static i18n %}
<!DOCTYPE html>
<!-- get_current_language (no la variable "lang" del contexto): la resuelve
LocaleMiddleware a partir del prefijo de la URL en cualquier vista, no
solo en las que la añaden a mano al contexto (PatternEditView,
PatternDetailView). -->
<html lang="{% get_current_language as html_lang %}{{ html_lang }}" data-theme="crochet">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Crochet{% endblock %}</title>
<link rel="stylesheet" href="{% static 'css/main.css' %}">
{% block extra_css %}{% endblock %}
</head>
<body{% block body_attrs %}{% endblock %}>
{% block navbar %}
<!-- Cabecera de marca compartida por el editor y las páginas de cuenta
(login/registro/mi cuenta): usa clases de daisyUI, así que solo
tiene sentido en plantillas que incluyan _platform_assets.html.
pattern_detail.html (vista de solo lectura/PDF) sobreescribe este
bloque dejándolo vacío: no carga daisyUI ni debe llevar navegación,
solo el propio patrón. no-print para que nunca salga en el PDF. -->
<nav class="navbar bg-base-100 border-b border-base-300 px-4 no-print">
<div class="flex-1">
<a href="{% url 'crochet:account_home' %}" class="btn btn-ghost text-xl px-2">🧶 Crochet</a>
</div>
<div class="flex-none flex items-center gap-2">
{% if user.is_authenticated %}
<span class="text-sm hidden sm:inline">{{ user.username }}</span>
<form method="post" action="{% url 'crochet:logout' %}">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-ghost">{% trans 'Cerrar sesión' %}</button>
</form>
{% else %}
<a href="{% url 'crochet:login' %}" class="btn btn-sm btn-ghost">{% trans 'Iniciar sesión' %}</a>
<a href="{% url 'crochet:register' %}" class="btn btn-sm btn-primary">{% trans 'Registrarme' %}</a>
{% endif %}
</div>
</nav>
{% endblock %}
{% block content %}{% endblock %}
{% block extra_js %}{% endblock %}
</body>
</html>
+29 -50
View File
@@ -1,33 +1,27 @@
{% load static i18n %} {% extends 'crochet/base.html' %}
<!DOCTYPE html> {% load static i18n django_htmx %}
<html lang="{{ lang }}">
<head> {% block title %}Crochet{% endblock %}
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> {% block body_attrs %} hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'{% endblock %}
{% block extra_css %}
<!-- Leído por storage.js para la cabecera X-CSRFToken al guardar por fetch. --> <!-- Leído por storage.js para la cabecera X-CSRFToken al guardar por fetch. -->
<meta name="csrf-token" content="{{ csrf_token }}"> <meta name="csrf-token" content="{{ csrf_token }}">
<title>Crochet</title> {% include 'crochet/_platform_assets.html' %}
<link rel="stylesheet" href="{% static 'css/main.css' %}"> {% htmx_script %}
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<!-- Rellenada por js/page-settings.js con el @page (tamaño/orientación)
elegido en "Personalización de página": un <style> aparte porque
@page no se puede cambiar como una propiedad normal desde JS. -->
<style id="page-size-style"></style>
</head>
<body> <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
<style id="page-size-style"></style>
{% endblock %}
{% block content %}
<main> <main>
<header class="flex flex-wrap items-center justify-between gap-2 p-4 no-print"> {% trans '¿Seguro que quieres eliminar este patrón? No podrás deshacerlo.' as delete_confirm_message %}
<h1 class="text-xl font-semibold">Crochet</h1> <header class="flex flex-wrap items-center justify-end gap-2 p-4 no-print">
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<!-- Idioma del contenido (título/subtítulo/texto de cada sección), no
de la interfaz: la estructura de secciones es la misma para
todos los idiomas, solo cambian los textos que el usuario ha
escrito para el idioma seleccionado. Añadir un idioma nuevo es
solo cuestión de añadir una <option> aquí. -->
<select id="language-select" class="select select-sm select-bordered"> <select id="language-select" class="select select-sm select-bordered">
<option value="es" {% if lang == 'es' %}selected{% endif %}>Español</option> <option value="es" {% if lang == 'es' %}selected{% endif %}>Español</option>
<option value="en" {% if lang == 'en' %}selected{% endif %}>English</option> <option value="en" {% if lang == 'en' %}selected{% endif %}>English</option>
@@ -35,18 +29,18 @@
<button id="save-pattern" type="button" class="btn btn-sm btn-outline" <button id="save-pattern" type="button" class="btn btn-sm btn-outline"
data-save-url="{% url 'crochet:pattern_save' pattern.uuid %}" data-save-url="{% url 'crochet:pattern_save' pattern.uuid %}"
data-error-message="{% trans 'No se ha podido guardar. Inténtalo de nuevo.' %}">{% trans 'Guardar' %}</button> data-error-message="{% trans 'No se ha podido guardar. Inténtalo de nuevo.' %}">{% trans 'Guardar' %}</button>
<!-- Enlace normal, no un botón con JS: solo navega a la vista de
solo lectura (ver PatternDetailView), que se guarda tal cual
desde la última vez que se pulsó "Guardar". target="_blank"
para no perder el editor con sus cambios sin guardar. -->
<a href="{% url 'crochet:pattern_detail' pattern.uuid %}" target="_blank" rel="noopener" <a href="{% url 'crochet:pattern_detail' pattern.uuid %}" target="_blank" rel="noopener"
class="btn btn-sm btn-outline">{% trans 'Ver patrón' %}</a> class="btn btn-sm btn-outline">{% trans 'Ver patrón' %}</a>
<button id="export-pdf" type="button" class="btn btn-sm">{% trans 'Exportar a PDF' %}</button> <button id="export-pdf" type="button" class="btn btn-sm btn-primary">{% trans 'Exportar a PDF' %}</button>
<form method="post" action="{% url 'crochet:pattern_delete' pattern.uuid %}"
onsubmit="return confirm('{{ delete_confirm_message }}')">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-outline btn-error">{% trans 'Eliminar patrón' %}</button>
</form>
</div> </div>
</header> </header>
<!-- Aviso breve de que "Guardar" ha funcionado: sin esto, guardar en
localStorage no da ninguna señal de que ha ocurrido algo. -->
<div id="save-toast" class="toast toast-top toast-end no-print hidden"> <div id="save-toast" class="toast toast-top toast-end no-print hidden">
<div class="alert alert-success"> <div class="alert alert-success">
<span data-message="{% trans 'Guardado' %}">{% trans 'Guardado' %}</span> <span data-message="{% trans 'Guardado' %}">{% trans 'Guardado' %}</span>
@@ -55,10 +49,6 @@
<div class="p-4 pt-0"> <div class="p-4 pt-0">
<!-- Configuración única para todo el documento (no es una sección más:
no se repite, no se reordena ni se duplica), por eso vive fuera
de #panel-top-sections. <details> nativo para no necesitar JS
solo para plegarla/desplegarla. -->
<details class="border border-base-300 rounded-box p-4 mb-4 no-print"> <details class="border border-base-300 rounded-box p-4 mb-4 no-print">
<summary class="text-lg font-semibold cursor-pointer select-none">{% trans 'Personalización de página' %}</summary> <summary class="text-lg font-semibold cursor-pointer select-none">{% trans 'Personalización de página' %}</summary>
<div class="flex flex-col gap-4 mt-4"> <div class="flex flex-col gap-4 mt-4">
@@ -145,13 +135,7 @@
<button id="toggle-collapse-all" type="button" class="btn btn-xs btn-outline" <button id="toggle-collapse-all" type="button" class="btn btn-xs btn-outline"
data-collapse-all-label="{% trans 'Colapsar todo' %}" data-expand-all-label="{% trans 'Expandir todo' %}">{% trans 'Colapsar todo' %}</button> data-collapse-all-label="{% trans 'Colapsar todo' %}" data-expand-all-label="{% trans 'Expandir todo' %}">{% trans 'Colapsar todo' %}</button>
</div> </div>
<!-- Un único botón con menú (en vez de 7-8 botones sueltos que se
partían en varias filas en pantallas estrechas): menos ruido
visual antes de llegar al contenido real. daisyUI abre
.dropdown-content con :focus-within, sin JS propio; el mismo
botón (clonado) se reutiliza dentro de cada "Grupo" (ver
addGroupSection en section-types.js), así que su
data-label también viaja con él. -->
<div class="dropdown mb-4"> <div class="dropdown mb-4">
<button id="add-section-toggle" type="button" class="btn btn-sm" tabindex="0" <button id="add-section-toggle" type="button" class="btn btn-sm" tabindex="0"
data-label="{% trans 'Añadir sección' %}">+ {% trans 'Añadir sección' %}</button> data-label="{% trans 'Añadir sección' %}">+ {% trans 'Añadir sección' %}</button>
@@ -195,17 +179,12 @@
</div> </div>
<!-- Catálogo de tipos de punto arrastrables (StitchType), leído por
dnd-items.js. Antes era una lista fija en este HTML; ahora viene de
la base de datos, con sus propias traducciones por idioma. -->
{{ stitch_types|json_script:"stitch-types" }} {{ stitch_types|json_script:"stitch-types" }}
<!-- Contenido guardado de este patrón (sections/pageSettings), leído
por main.js al arrancar. json_script escapa el JSON de forma
segura, así que nunca hay que interpolarlo a mano en un <script>. -->
{{ pattern_data|json_script:"pattern-data" }} {{ pattern_data|json_script:"pattern-data" }}
</main> </main>
</body> {% endblock %}
<script type="module" src="{% static 'js/main.js' %}"></script>
</html> {% block extra_js %}
<script type="module" src="{% static 'js/main.js' %}"></script>
{% endblock %}
+11 -29
View File
@@ -1,21 +1,9 @@
{% extends 'crochet/base.html' %}
{% load static i18n %} {% load static i18n %}
<!DOCTYPE html>
<html lang="{{ lang }}">
<head> {% block title %}{{ page_settings.title|default:"Crochet" }}{% endblock %}
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> {% block extra_css %}
<title>{{ page_settings.title|default:"Crochet" }}</title>
<link rel="stylesheet" href="{% static 'css/main.css' %}">
<!-- CSS de Tailwind precompilado (ver .tools/build/ y css/README-pattern-detail.md),
no el CDN con compilador JIT en JS que usa pattern.html: WeasyPrint
(ver PatternPdfView) no ejecuta JavaScript, así que ese CDN no
generaría ningún estilo al convertir esta misma página a PDF. Ninguna
<script> propia tampoco: esta página es puramente de lectura,
generada del todo en el servidor (ver pattern_render.py). No hay
ningún <input>/<button> de edición que "revelar" quitando una clase
desde las herramientas de desarrollo, porque ese marcado no existe
aquí (a diferencia de la vista de edición, que sí lo necesita). -->
<link rel="stylesheet" href="{% static 'css/pattern-detail.css' %}"> <link rel="stylesheet" href="{% static 'css/pattern-detail.css' %}">
<style> <style>
:root { :root {
@@ -24,19 +12,15 @@
} }
@page { size: {{ page_settings.pageSize }} {{ page_settings.orientation }}; } @page { size: {{ page_settings.pageSize }} {{ page_settings.orientation }}; }
</style> </style>
</head> {% endblock %}
<body> <!-- Sin daisyUI aquí (ver arriba), así que el navbar de base.html no tiene
ningún estilo que aplicarle: se deja vacío a propósito. -->
{% block navbar %}{% endblock %}
{% block content %}
<main class="p-4"> <main class="p-4">
<!-- Enlaces normales (sin JS): el tag "language" de i18n activa
temporalmente ese idioma para que "url" resuelva la ruta ya
traducida (ver crochet/urls.py), igual que hace LocaleMiddleware
por petición. no-print para que no aparezca en el PDF exportado
(impresión nativa del navegador sobre esta misma página). -->
<div class="flex justify-end gap-2 mb-4 no-print"> <div class="flex justify-end gap-2 mb-4 no-print">
<!-- Genera el PDF en el servidor con WeasyPrint (ver PatternPdfView):
renderiza esta misma plantilla (ya con el CSS precompilado, sin
depender de ningún JS) directamente a PDF. -->
<a href="{% url 'crochet:pattern_pdf' pattern.uuid %}" class="detail-btn">{% trans 'Descargar PDF' %}</a> <a href="{% url 'crochet:pattern_pdf' pattern.uuid %}" class="detail-btn">{% trans 'Descargar PDF' %}</a>
{% if lang != 'es' %} {% if lang != 'es' %}
{% language 'es' %} {% language 'es' %}
@@ -57,6 +41,4 @@
</div> </div>
</div> </div>
</main> </main>
</body> {% endblock %}
</html>
+37
View File
@@ -0,0 +1,37 @@
{% extends 'crochet/base.html' %}
{% load i18n %}
{% block title %}{% trans 'Iniciar sesión' %}{% endblock %}
{% block extra_css %}
{% include 'crochet/_platform_assets.html' %}
{% endblock %}
{% block content %}
<main class="flex justify-center px-4 py-12">
<div class="card w-full max-w-sm bg-base-100 shadow-xl border border-base-300">
<div class="card-body">
<h1 class="card-title">{% trans 'Iniciar sesión' %}</h1>
{% if form.errors %}
<div role="alert" class="alert alert-error alert-soft">
<span>{% trans 'Usuario o contraseña incorrectos.' %}</span>
</div>
{% endif %}
<form method="post" class="flex flex-col gap-3">
{% csrf_token %}
{% for field in form %}
<label class="flex flex-col gap-1">
<span class="text-sm">{{ field.label }}</span>
{{ field }}
</label>
{% endfor %}
<input type="hidden" name="next" value="{{ next }}">
<button type="submit" class="btn btn-primary mt-2">{% trans 'Entrar' %}</button>
</form>
<p class="text-sm text-center mt-2">
<a href="{% url 'crochet:register' %}" class="link link-primary">{% trans '¿No tienes cuenta? Regístrate' %}</a>
</p>
</div>
</div>
</main>
{% endblock %}
@@ -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 %}
<main class="flex justify-center px-4 py-12">
<div class="card w-full max-w-sm bg-base-100 shadow-xl border border-base-300">
<div class="card-body">
<h1 class="card-title">{% trans 'Crear cuenta' %}</h1>
<form method="post" class="flex flex-col gap-3">
{% csrf_token %}
{% for field in form %}
<label class="flex flex-col gap-1">
<span class="text-sm">{{ field.label }}</span>
{{ field }}
{% for error in field.errors %}
<span class="text-error text-xs">{{ error }}</span>
{% endfor %}
{% if field.help_text %}
<span class="text-xs text-base-content/60">{{ field.help_text }}</span>
{% endif %}
</label>
{% endfor %}
<button type="submit" class="btn btn-primary mt-2">{% trans 'Registrarme' %}</button>
</form>
<p class="text-sm text-center mt-2">
<a href="{% url 'crochet:login' %}" class="link link-primary">{% trans '¿Ya tienes cuenta? Inicia sesión' %}</a>
</p>
</div>
</div>
</main>
{% endblock %}
+154
View File
@@ -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)
+16
View File
@@ -1,5 +1,6 @@
import io import io
from django.contrib.auth.models import User
from django.core.files.uploadedfile import SimpleUploadedFile from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase from django.test import TestCase
from PIL import Image from PIL import Image
@@ -32,6 +33,21 @@ class PatternModelTests(TestCase):
second = Pattern.objects.create() second = Pattern.objects.create()
self.assertNotEqual(first.uuid, second.uuid) 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): class PatternImageModelTests(TestCase):
def test_deleted_with_pattern(self): def test_deleted_with_pattern(self):
+112 -2
View File
@@ -1,6 +1,7 @@
import json import json
import re import re
from django.contrib.auth.models import User
from django.test import TestCase 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
@@ -13,13 +14,16 @@ NO_HTML_COMMENTS_RE = re.compile(r'<!--.*?-->', re.S)
class PatternEditViewTests(TestCase): class PatternEditViewTests(TestCase):
def setUp(self): def setUp(self):
self.owner = User.objects.create_user(username='crocheter', password='a-very-uncommon-pw-1')
self.pattern = Pattern.objects.create( self.pattern = Pattern.objects.create(
sections=[{'type': 'title', 'text': {'es': 'Ampharos', 'en': 'Ampharos'}}], sections=[{'type': 'title', 'text': {'es': 'Ampharos', 'en': 'Ampharos'}}],
page_settings={'title': 'Ampharos'}, page_settings={'title': 'Ampharos'},
created_by=self.owner,
) )
StitchType.objects.create(translations={'es': 'Punto bajo', 'en': 'SC'}, order=1) StitchType.objects.create(translations={'es': 'Punto bajo', 'en': 'SC'}, order=1)
def test_get_in_spanish(self): def test_get_in_spanish(self):
self.client.force_login(self.owner)
with translation.override('es'): with translation.override('es'):
url = reverse('crochet:pattern_edit', args=[self.pattern.uuid]) url = reverse('crochet:pattern_edit', args=[self.pattern.uuid])
response = self.client.get(url) response = self.client.get(url)
@@ -30,6 +34,7 @@ class PatternEditViewTests(TestCase):
self.assertContains(response, 'Guardar') self.assertContains(response, 'Guardar')
def test_get_in_english(self): def test_get_in_english(self):
self.client.force_login(self.owner)
with translation.override('en'): with translation.override('en'):
url = reverse('crochet:pattern_edit', args=[self.pattern.uuid]) url = reverse('crochet:pattern_edit', args=[self.pattern.uuid])
response = self.client.get(url) response = self.client.get(url)
@@ -38,6 +43,7 @@ class PatternEditViewTests(TestCase):
self.assertContains(response, 'Save') self.assertContains(response, 'Save')
def test_context_includes_saved_pattern_and_stitch_catalog(self): def test_context_includes_saved_pattern_and_stitch_catalog(self):
self.client.force_login(self.owner)
with translation.override('es'): with translation.override('es'):
url = reverse('crochet:pattern_edit', args=[self.pattern.uuid]) url = reverse('crochet:pattern_edit', args=[self.pattern.uuid])
response = self.client.get(url) response = self.client.get(url)
@@ -48,12 +54,39 @@ class PatternEditViewTests(TestCase):
]) ])
def test_unknown_uuid_returns_404(self): def test_unknown_uuid_returns_404(self):
self.client.force_login(self.owner)
with translation.override('es'): with translation.override('es'):
url = reverse('crochet:pattern_edit', args=['00000000-0000-0000-0000-000000000000']) url = reverse('crochet:pattern_edit', args=['00000000-0000-0000-0000-000000000000'])
response = self.client.get(url) response = self.client.get(url)
self.assertEqual(response.status_code, 404) 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): class PatternDetailViewTests(TestCase):
def setUp(self): def setUp(self):
@@ -170,7 +203,9 @@ class PatternI18nUrlTests(TestCase):
class PatternSaveViewTests(TestCase): class PatternSaveViewTests(TestCase):
def setUp(self): 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'): with translation.override('es'):
self.url = reverse('crochet:pattern_save', args=[self.pattern.uuid]) 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') response = self.client.post(url, data=json.dumps({}), content_type='application/json')
self.assertEqual(response.status_code, 404) 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): class PatternImageUploadViewTests(TestCase):
def setUp(self): 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'): with translation.override('es'):
self.url = reverse('crochet:pattern_image_upload', args=[self.pattern.uuid]) 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): def test_missing_file_returns_bad_request(self):
response = self.client.post(self.url, data={}) response = self.client.post(self.url, data={})
self.assertEqual(response.status_code, 400) 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)
+17
View File
@@ -1,12 +1,18 @@
from django.contrib.auth import views as auth_views
from django.urls import path from django.urls import path
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from crochet.forms import StyledAuthenticationForm
from crochet.views import ( from crochet.views import (
AccountHomeView,
PatternCreateView,
PatternDeleteView,
PatternDetailView, PatternDetailView,
PatternEditView, PatternEditView,
PatternImageUploadView, PatternImageUploadView,
PatternPdfView, PatternPdfView,
PatternSaveView, PatternSaveView,
RegisterView,
) )
app_name = 'crochet' app_name = 'crochet'
@@ -26,4 +32,15 @@ urlpatterns = [
path(_('pattern/<uuid:uuid>/save/'), name='pattern_save', view=PatternSaveView.as_view()), path(_('pattern/<uuid:uuid>/save/'), name='pattern_save', view=PatternSaveView.as_view()),
path(_('pattern/<uuid:uuid>/images/'), name='pattern_image_upload', view=PatternImageUploadView.as_view()), path(_('pattern/<uuid:uuid>/images/'), name='pattern_image_upload', view=PatternImageUploadView.as_view()),
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/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()),
] ]
+82 -6
View File
@@ -1,15 +1,19 @@
import json import json
import weasyprint 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.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.template.loader import render_to_string
from django.urls import 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 DetailView from django.views.generic import CreateView, DetailView, TemplateView
from crochet.forms import 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
@@ -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): def _pattern_detail_context(pattern):
# Compartido por PatternDetailView (la pinta) y PatternPdfView (la # Compartido por PatternDetailView (la pinta) y PatternPdfView (la
# convierte a PDF con WeasyPrint a partir del mismo HTML), para no # 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 model = Pattern
template_name = 'crochet/pattern.html' template_name = 'crochet/pattern.html'
context_object_name = 'pattern' context_object_name = 'pattern'
slug_field = 'uuid' slug_field = 'uuid'
slug_url_kwarg = '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): 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 # Resuelto por LocaleMiddleware a partir del prefijo de idioma de la
@@ -135,8 +160,13 @@ class PatternPdfView(DetailView):
class PatternSaveView(View): 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): def post(self, request, uuid):
pattern = get_object_or_404(Pattern, uuid=uuid) pattern = _get_owned_pattern_or_403(request, uuid)
try: try:
data = json.loads(request.body) data = json.loads(request.body)
@@ -151,8 +181,9 @@ class PatternSaveView(View):
class PatternImageUploadView(View): class PatternImageUploadView(View):
# Mismo motivo que en PatternSaveView: lo llama sections.js por fetch.
def post(self, request, uuid): 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') image_file = request.FILES.get('image')
if not image_file: if not image_file:
@@ -160,3 +191,48 @@ class PatternImageUploadView(View):
pattern_image = PatternImage.objects.create(pattern=pattern, image=image_file) pattern_image = PatternImage.objects.create(pattern=pattern, image=image_file)
return JsonResponse({'id': pattern_image.id, 'url': pattern_image.image.url}) 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 <form> 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 <form> 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')
+1
View File
@@ -10,6 +10,7 @@ dependencies = [
"django-environ>=0.14.0", "django-environ>=0.14.0",
"django-extensions>=4.1", "django-extensions>=4.1",
"django-filter>=26.1", "django-filter>=26.1",
"django-htmx>=1.28.0",
"django-silk>=5.5.0", "django-silk>=5.5.0",
"django-storages>=1.14.6", "django-storages>=1.14.6",
"ipython>=9.15.0", "ipython>=9.15.0",
Generated
+15
View File
@@ -239,6 +239,7 @@ dependencies = [
{ name = "django-environ" }, { name = "django-environ" },
{ name = "django-extensions" }, { name = "django-extensions" },
{ name = "django-filter" }, { name = "django-filter" },
{ name = "django-htmx" },
{ name = "django-silk" }, { name = "django-silk" },
{ name = "django-storages" }, { name = "django-storages" },
{ name = "ipython" }, { name = "ipython" },
@@ -268,6 +269,7 @@ requires-dist = [
{ name = "django-environ", specifier = ">=0.14.0" }, { name = "django-environ", specifier = ">=0.14.0" },
{ name = "django-extensions", specifier = ">=4.1" }, { name = "django-extensions", specifier = ">=4.1" },
{ name = "django-filter", specifier = ">=26.1" }, { name = "django-filter", specifier = ">=26.1" },
{ name = "django-htmx", specifier = ">=1.28.0" },
{ name = "django-silk", specifier = ">=5.5.0" }, { name = "django-silk", specifier = ">=5.5.0" },
{ name = "django-storages", specifier = ">=1.14.6" }, { name = "django-storages", specifier = ">=1.14.6" },
{ name = "ipython", specifier = ">=9.15.0" }, { name = "ipython", specifier = ">=9.15.0" },
@@ -398,6 +400,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1f/01/afffed1e3c4540fb75bf550a18b6176a9f6371b5f3e52b69a28995b6480c/django_filter-26.1-py3-none-any.whl", hash = "sha256:7d98ef2899218e6242619b532cb1b95af14e09dfcf74844aecb550ad27b59ff2", size = 94069, upload-time = "2026-07-11T09:27:01.012Z" }, { url = "https://files.pythonhosted.org/packages/1f/01/afffed1e3c4540fb75bf550a18b6176a9f6371b5f3e52b69a28995b6480c/django_filter-26.1-py3-none-any.whl", hash = "sha256:7d98ef2899218e6242619b532cb1b95af14e09dfcf74844aecb550ad27b59ff2", size = 94069, upload-time = "2026-07-11T09:27:01.012Z" },
] ]
[[package]]
name = "django-htmx"
version = "1.28.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/47/d9/b31339fae8ede6a7cb0b9facaefe2ee4fc01d9f37a31a3dc03759765a2c3/django_htmx-1.28.0.tar.gz", hash = "sha256:699e229de0bbf19b3ee1a5d419702f5c154fa956e6921fec89faa7401b99e536", size = 100946, upload-time = "2026-07-12T22:19:19.589Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/d5/71b3a206f7a2135e2ab4a789be143a00222b52d22729c84f480915049e94/django_htmx-1.28.0-py3-none-any.whl", hash = "sha256:e6911acccf3e7905d511c4fd6ab86fe06572205e80a0f32ca7aa94a115ae0619", size = 98588, upload-time = "2026-07-12T22:19:18.225Z" },
]
[[package]] [[package]]
name = "django-silk" name = "django-silk"
version = "5.5.0" version = "5.5.0"