feat: added cover image to pattern
CI / test (push) Successful in 45s
CI / build (push) Successful in 27s

This commit is contained in:
2026-07-17 13:48:34 +02:00
parent 778ec18b17
commit f722f0765e
18 changed files with 579 additions and 154 deletions
+15
View File
@@ -18,3 +18,18 @@ def collected_static_files():
with override_settings(STATIC_ROOT=static_root): with override_settings(STATIC_ROOT=static_root):
call_command('collectstatic', interactive=False, verbosity=0) call_command('collectstatic', interactive=False, verbosity=0)
yield yield
@pytest.fixture(scope='session', autouse=True)
def isolated_media_root():
"""Los tests que suben imágenes (PatternImage, portada de Pattern...)
escriben de verdad en MEDIA_ROOT; sin aislarlo, se acumulan en la
carpeta media/ real del proyecto de una ejecución a otra, y como
cover_image.py guarda cada variante con un nombre fijo (thumbnail.jpg,
large.jpg) dentro de una carpeta por mes, esas sobras podían chocar con
los nombres que espera un test concreto (ver
PatternCoverImageUploadViewTests) y hacerlo fallar según lo que hubiera
quedado de ejecuciones anteriores."""
with tempfile.TemporaryDirectory() as media_root:
with override_settings(MEDIA_ROOT=media_root):
yield
+52
View File
@@ -0,0 +1,52 @@
import io
from django.core.files.base import ContentFile
from PIL import Image, UnidentifiedImageError
# Miniatura para las tarjetas de "Mis patrones" (ver account_home.html) y
# versión grande para una futura vista de detalle/producto: dos tamaños
# cubren los usos actuales sin generar variantes de más.
THUMBNAIL_MAX_SIZE = (400, 400)
LARGE_MAX_SIZE = (1200, 1200)
class InvalidCoverImage(Exception):
pass
def _resized_jpeg(image, max_size):
resized = image.copy()
# thumbnail() redimensiona conservando el aspect ratio, sin salirse de
# la caja (max_size), a diferencia de resize() (que deformaría la
# imagen si no coincide con esa proporción).
resized.thumbnail(max_size, Image.Resampling.LANCZOS)
buffer = io.BytesIO()
resized.save(buffer, format='JPEG', quality=85, optimize=True)
return buffer.getvalue()
def build_cover_image_variants(uploaded_file):
# Devuelve (original, thumbnail, large) como ContentFile, listos para
# asignar a cover_image/cover_image_thumbnail/cover_image_large.
raw_bytes = uploaded_file.read()
try:
image = Image.open(io.BytesIO(raw_bytes))
image.load()
except UnidentifiedImageError as error:
raise InvalidCoverImage('El archivo no es una imagen válida.') from error
# JPEG no admite transparencia: se aplana sobre fondo blanco antes de
# generar las variantes en vez de dejar que Pillow falle al guardar un
# PNG/RGBA como JPEG.
if image.mode in ('RGBA', 'LA', 'P'):
rgba = image.convert('RGBA')
flattened = Image.new('RGB', image.size, (255, 255, 255))
flattened.paste(rgba, mask=rgba.split()[-1])
image = flattened
else:
image = image.convert('RGB')
original = ContentFile(raw_bytes, name=uploaded_file.name)
thumbnail = ContentFile(_resized_jpeg(image, THUMBNAIL_MAX_SIZE), name='thumbnail.jpg')
large = ContentFile(_resized_jpeg(image, LARGE_MAX_SIZE), name='large.jpg')
return original, thumbnail, large
+84 -72
View File
@@ -2,7 +2,7 @@ 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" "POT-Creation-Date: 2026-07-17 11:17+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"
@@ -26,33 +26,33 @@ msgstr "Create pattern"
msgid "¿Seguro que quieres eliminar este patrón? No podrás deshacerlo." 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." msgstr "Are you sure you want to delete this pattern? This cannot be undone."
#: crochet/templates/crochet/account_home.html:27 #: crochet/templates/crochet/account_home.html:34
msgid "Patrón sin título" msgid "Patrón sin título"
msgstr "Untitled pattern" msgstr "Untitled pattern"
#: crochet/templates/crochet/account_home.html:30 #: crochet/templates/crochet/account_home.html:37
#, python-format #, python-format
msgid "Actualizado el %(date)s" msgid "Actualizado el %(date)s"
msgstr "Updated on %(date)s" msgstr "Updated on %(date)s"
#: crochet/templates/crochet/account_home.html:33 #: crochet/templates/crochet/account_home.html:40
#: crochet/templates/crochet/pattern.html:34 #: crochet/templates/crochet/pattern.html:34
msgid "Ver patrón" msgid "Ver patrón"
msgstr "View pattern" msgstr "View pattern"
#: crochet/templates/crochet/account_home.html:34 #: crochet/templates/crochet/account_home.html:41
msgid "Editar" msgid "Editar"
msgstr "Edit" msgstr "Edit"
#: crochet/templates/crochet/account_home.html:38 #: crochet/templates/crochet/account_home.html:45
msgid "Eliminar" msgid "Eliminar"
msgstr "Delete" msgstr "Delete"
#: crochet/templates/crochet/account_home.html:47 #: crochet/templates/crochet/account_home.html:54
msgid "Todavía no tienes ningún patrón." msgid "Todavía no tienes ningún patrón."
msgstr "You don't have any patterns yet." msgstr "You don't have any patterns yet."
#: crochet/templates/crochet/account_home.html:53 #: crochet/templates/crochet/account_home.html:60
#: crochet/templates/crochet/base.html:34 #: crochet/templates/crochet/base.html:34
msgid "Cerrar sesión" msgid "Cerrar sesión"
msgstr "Log out" msgstr "Log out"
@@ -110,184 +110,192 @@ msgid "Autor..."
msgstr "Author..." msgstr "Author..."
#: crochet/templates/crochet/pattern.html:67 #: crochet/templates/crochet/pattern.html:67
#: crochet/templates/crochet/pattern.html:151 msgid "Imagen de portada"
msgstr "Cover image"
#: crochet/templates/crochet/pattern.html:70
msgid "No se ha podido subir la imagen de portada. Inténtalo de nuevo."
msgstr "Couldn't upload the cover image. Please try again."
#: crochet/templates/crochet/pattern.html:78
#: crochet/templates/crochet/pattern.html:162
msgid "Color del texto" msgid "Color del texto"
msgstr "Text color" msgstr "Text color"
#: crochet/templates/crochet/pattern.html:71 #: crochet/templates/crochet/pattern.html:82
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:86
#: crochet/templates/crochet/pattern.html:151 #: crochet/templates/crochet/pattern.html:162
msgid "Color de fondo" msgid "Color de fondo"
msgstr "Background color" msgstr "Background color"
#: crochet/templates/crochet/pattern.html:79 #: crochet/templates/crochet/pattern.html:90
msgid "Tipografía" msgid "Tipografía"
msgstr "Font" msgstr "Font"
#: crochet/templates/crochet/pattern.html:85 #: crochet/templates/crochet/pattern.html:96
msgid "Monoespaciada" msgid "Monoespaciada"
msgstr "Monospace" msgstr "Monospace"
#: crochet/templates/crochet/pattern.html:89 #: crochet/templates/crochet/pattern.html:100
msgid "Tamaño de letra" msgid "Tamaño de letra"
msgstr "Font size" msgstr "Font size"
#: crochet/templates/crochet/pattern.html:91 #: crochet/templates/crochet/pattern.html:102
msgid "Pequeño" msgid "Pequeño"
msgstr "Small" msgstr "Small"
#: crochet/templates/crochet/pattern.html:92 #: crochet/templates/crochet/pattern.html:103
msgid "Normal" msgid "Normal"
msgstr "Normal" msgstr "Normal"
#: crochet/templates/crochet/pattern.html:93 #: crochet/templates/crochet/pattern.html:104
msgid "Grande" msgid "Grande"
msgstr "Large" msgstr "Large"
#: crochet/templates/crochet/pattern.html:97 #: crochet/templates/crochet/pattern.html:108
msgid "Alineación del texto" msgid "Alineación del texto"
msgstr "Text alignment" msgstr "Text alignment"
#: crochet/templates/crochet/pattern.html:99 #: crochet/templates/crochet/pattern.html:110
msgid "Izquierda" msgid "Izquierda"
msgstr "Left" msgstr "Left"
#: crochet/templates/crochet/pattern.html:100 #: crochet/templates/crochet/pattern.html:111
msgid "Centrado" msgid "Centrado"
msgstr "Center" msgstr "Center"
#: crochet/templates/crochet/pattern.html:101 #: crochet/templates/crochet/pattern.html:112
msgid "Justificado" msgid "Justificado"
msgstr "Justify" msgstr "Justify"
#: crochet/templates/crochet/pattern.html:105 #: crochet/templates/crochet/pattern.html:116
msgid "Tamaño de página" msgid "Tamaño de página"
msgstr "Page size" msgstr "Page size"
#: crochet/templates/crochet/pattern.html:110 #: crochet/templates/crochet/pattern.html:121
msgid "Carta (Letter)" msgid "Carta (Letter)"
msgstr "Letter" msgstr "Letter"
#: crochet/templates/crochet/pattern.html:115 #: crochet/templates/crochet/pattern.html:126
msgid "Orientación" msgid "Orientación"
msgstr "Orientation" msgstr "Orientation"
#: crochet/templates/crochet/pattern.html:117 #: crochet/templates/crochet/pattern.html:128
msgid "Vertical" msgid "Vertical"
msgstr "Portrait" msgstr "Portrait"
#: crochet/templates/crochet/pattern.html:118 #: crochet/templates/crochet/pattern.html:129
msgid "Horizontal" msgid "Horizontal"
msgstr "Landscape" msgstr "Landscape"
#: crochet/templates/crochet/pattern.html:126 #: crochet/templates/crochet/pattern.html:137
#: crochet/templates/crochet/pattern.html:134 #: crochet/templates/crochet/pattern.html:145
msgid "Secciones" msgid "Secciones"
msgstr "Sections" msgstr "Sections"
#: crochet/templates/crochet/pattern.html:127 #: crochet/templates/crochet/pattern.html:138
#: crochet/templates/crochet/pattern.html:173 #: crochet/templates/crochet/pattern.html:184
msgid "Vista previa" msgid "Vista previa"
msgstr "Preview" msgstr "Preview"
#: crochet/templates/crochet/pattern.html:136 #: crochet/templates/crochet/pattern.html:147
msgid "Colapsar todo" msgid "Colapsar todo"
msgstr "Collapse all" msgstr "Collapse all"
#: crochet/templates/crochet/pattern.html:136 #: crochet/templates/crochet/pattern.html:147
msgid "Expandir todo" msgid "Expandir todo"
msgstr "Expand all" msgstr "Expand all"
#: crochet/templates/crochet/pattern.html:141 #: crochet/templates/crochet/pattern.html:152
msgid "Añadir sección" msgid "Añadir sección"
msgstr "Add section" msgstr "Add section"
#: crochet/templates/crochet/pattern.html:144 #: crochet/templates/crochet/pattern.html:155
msgid "Título" msgid "Título"
msgstr "Title" msgstr "Title"
#: crochet/templates/crochet/pattern.html:144 #: crochet/templates/crochet/pattern.html:155
msgid "Título..." msgid "Título..."
msgstr "Title..." msgstr "Title..."
#: crochet/templates/crochet/pattern.html:146 #: crochet/templates/crochet/pattern.html:157
msgid "Subtítulo" msgid "Subtítulo"
msgstr "Subtitle" msgstr "Subtitle"
#: crochet/templates/crochet/pattern.html:146 #: crochet/templates/crochet/pattern.html:157
msgid "Subtítulo..." msgid "Subtítulo..."
msgstr "Subtitle..." msgstr "Subtitle..."
#: crochet/templates/crochet/pattern.html:148 #: crochet/templates/crochet/pattern.html:159
msgid "Texto" msgid "Texto"
msgstr "Text" msgstr "Text"
#: crochet/templates/crochet/pattern.html:148 #: crochet/templates/crochet/pattern.html:159
msgid "Escribe aquí..." msgid "Escribe aquí..."
msgstr "Write here..." msgstr "Write here..."
#: crochet/templates/crochet/pattern.html:150 #: crochet/templates/crochet/pattern.html:161
#: crochet/templates/crochet/pattern.html:151 #: crochet/templates/crochet/pattern.html:162
msgid "Nota" msgid "Nota"
msgstr "Note" msgstr "Note"
#: crochet/templates/crochet/pattern.html:150 #: crochet/templates/crochet/pattern.html:161
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 #: crochet/templates/crochet/pattern.html:164
msgid "Materiales" msgid "Materiales"
msgstr "Materials" msgstr "Materials"
#: crochet/templates/crochet/pattern.html:153 #: crochet/templates/crochet/pattern.html:164
msgid "Añadir material..." msgid "Añadir material..."
msgstr "Add material..." msgstr "Add material..."
#: crochet/templates/crochet/pattern.html:153 #: crochet/templates/crochet/pattern.html:164
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:166
#: crochet/templates/crochet/pattern.html:157 #: crochet/templates/crochet/pattern.html:168
msgid "Imagen" msgid "Imagen"
msgstr "Image" msgstr "Image"
#: crochet/templates/crochet/pattern.html:157 #: crochet/templates/crochet/pattern.html:168
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 #: crochet/templates/crochet/pattern.html:170
msgid "Patrón" msgid "Patrón"
msgstr "Pattern" msgstr "Pattern"
#: crochet/templates/crochet/pattern.html:159 #: crochet/templates/crochet/pattern.html:170
msgid "Elementos" msgid "Elementos"
msgstr "Elements" msgstr "Elements"
#: crochet/templates/crochet/pattern.html:161 #: crochet/templates/crochet/pattern.html:172
msgid "Grupo" msgid "Grupo"
msgstr "Group" msgstr "Group"
#: crochet/templates/crochet/pattern.html:165 #: crochet/templates/crochet/pattern.html:176
msgid "Arrastrar para reordenar" msgid "Arrastrar para reordenar"
msgstr "Drag to reorder" msgstr "Drag to reorder"
#: crochet/templates/crochet/pattern.html:166 #: crochet/templates/crochet/pattern.html:177
msgid "Colapsar / expandir" msgid "Colapsar / expandir"
msgstr "Collapse / expand" msgstr "Collapse / expand"
#: crochet/templates/crochet/pattern.html:167 #: crochet/templates/crochet/pattern.html:178
msgid "Duplicar sección" msgid "Duplicar sección"
msgstr "Duplicate section" msgstr "Duplicate section"
#: crochet/templates/crochet/pattern.html:168 #: crochet/templates/crochet/pattern.html:179
msgid "Eliminar sección" msgid "Eliminar sección"
msgstr "Delete section" msgstr "Delete section"
#: crochet/templates/crochet/pattern.html:169 #: crochet/templates/crochet/pattern.html:180
msgid "" msgid ""
"¿Seguro que quieres eliminar este grupo? Se eliminarán también todas las " "¿Seguro que quieres eliminar este grupo? Se eliminarán también todas las "
"secciones que contiene." "secciones que contiene."
@@ -295,7 +303,7 @@ msgstr ""
"Are you sure you want to delete this group? All sections inside it will also " "Are you sure you want to delete this group? All sections inside it will also "
"be deleted." "be deleted."
#: crochet/templates/crochet/pattern.html:175 #: crochet/templates/crochet/pattern.html:186
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."
@@ -324,51 +332,55 @@ msgstr "Create account"
msgid "¿Ya tienes cuenta? Inicia sesión" msgid "¿Ya tienes cuenta? Inicia sesión"
msgstr "Already have an account? Log in" msgstr "Already have an account? Log in"
#: crochet/urls.py:30 #: crochet/urls.py:31
msgid "pattern/<uuid:uuid>/" msgid "pattern/<uuid:uuid>/"
msgstr "" msgstr ""
#: crochet/urls.py:31 #: crochet/urls.py:32
msgid "pattern/<uuid:uuid>/edit/" msgid "pattern/<uuid:uuid>/edit/"
msgstr "" msgstr ""
#: crochet/urls.py:32 #: crochet/urls.py:33
msgid "pattern/<uuid:uuid>/save/" msgid "pattern/<uuid:uuid>/save/"
msgstr "" msgstr ""
#: crochet/urls.py:33 #: crochet/urls.py:34
msgid "pattern/<uuid:uuid>/images/" msgid "pattern/<uuid:uuid>/images/"
msgstr "" msgstr ""
#: crochet/urls.py:34
msgid "pattern/<uuid:uuid>/pdf/"
msgstr ""
#: crochet/urls.py:35 #: crochet/urls.py:35
msgid "pattern/<uuid:uuid>/delete/" msgid "pattern/<uuid:uuid>/cover/"
msgstr "" msgstr ""
#: crochet/urls.py:36 #: crochet/urls.py:36
msgid "pattern/new/" msgid "pattern/<uuid:uuid>/pdf/"
msgstr "" msgstr ""
#: crochet/urls.py:37 #: crochet/urls.py:37
msgid "account/" msgid "pattern/<uuid:uuid>/delete/"
msgstr "" msgstr ""
#: crochet/urls.py:38 #: crochet/urls.py:38
msgid "account/register/" msgid "pattern/new/"
msgstr ""
#: crochet/urls.py:39
msgid "account/"
msgstr "" msgstr ""
#: crochet/urls.py:40 #: crochet/urls.py:40
msgid "account/register/"
msgstr ""
#: crochet/urls.py:42
msgid "account/login/" msgid "account/login/"
msgstr "" msgstr ""
#: crochet/urls.py:45 #: crochet/urls.py:47
msgid "account/logout/" msgid "account/logout/"
msgstr "" msgstr ""
# crochet/views.py (PatternDetailView.EMPTY_MESSAGE) # crochet/views.py (PatternDetailView.EMPTY_MESSAGE)
#: crochet/views.py:23 #: crochet/views.py:24
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."
+82 -70
View File
@@ -2,7 +2,7 @@ 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" "POT-Creation-Date: 2026-07-17 11:17+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"
@@ -26,33 +26,33 @@ msgstr ""
msgid "¿Seguro que quieres eliminar este patrón? No podrás deshacerlo." msgid "¿Seguro que quieres eliminar este patrón? No podrás deshacerlo."
msgstr "" msgstr ""
#: crochet/templates/crochet/account_home.html:27 #: crochet/templates/crochet/account_home.html:34
msgid "Patrón sin título" msgid "Patrón sin título"
msgstr "" msgstr ""
#: crochet/templates/crochet/account_home.html:30 #: crochet/templates/crochet/account_home.html:37
#, python-format #, python-format
msgid "Actualizado el %(date)s" msgid "Actualizado el %(date)s"
msgstr "" msgstr ""
#: crochet/templates/crochet/account_home.html:33 #: crochet/templates/crochet/account_home.html:40
#: crochet/templates/crochet/pattern.html:34 #: crochet/templates/crochet/pattern.html:34
msgid "Ver patrón" msgid "Ver patrón"
msgstr "" msgstr ""
#: crochet/templates/crochet/account_home.html:34 #: crochet/templates/crochet/account_home.html:41
msgid "Editar" msgid "Editar"
msgstr "" msgstr ""
#: crochet/templates/crochet/account_home.html:38 #: crochet/templates/crochet/account_home.html:45
msgid "Eliminar" msgid "Eliminar"
msgstr "" msgstr ""
#: crochet/templates/crochet/account_home.html:47 #: crochet/templates/crochet/account_home.html:54
msgid "Todavía no tienes ningún patrón." msgid "Todavía no tienes ningún patrón."
msgstr "" msgstr ""
#: crochet/templates/crochet/account_home.html:53 #: crochet/templates/crochet/account_home.html:60
#: crochet/templates/crochet/base.html:34 #: crochet/templates/crochet/base.html:34
msgid "Cerrar sesión" msgid "Cerrar sesión"
msgstr "" msgstr ""
@@ -109,190 +109,198 @@ msgid "Autor..."
msgstr "" msgstr ""
#: crochet/templates/crochet/pattern.html:67 #: crochet/templates/crochet/pattern.html:67
#: crochet/templates/crochet/pattern.html:151 msgid "Imagen de portada"
msgstr ""
#: crochet/templates/crochet/pattern.html:70
msgid "No se ha podido subir la imagen de portada. Inténtalo de nuevo."
msgstr ""
#: crochet/templates/crochet/pattern.html:78
#: crochet/templates/crochet/pattern.html:162
msgid "Color del texto" msgid "Color del texto"
msgstr "" msgstr ""
#: crochet/templates/crochet/pattern.html:71 #: crochet/templates/crochet/pattern.html:82
msgid "Color de acento (títulos)" msgid "Color de acento (títulos)"
msgstr "" msgstr ""
#: crochet/templates/crochet/pattern.html:75 #: crochet/templates/crochet/pattern.html:86
#: crochet/templates/crochet/pattern.html:151 #: crochet/templates/crochet/pattern.html:162
msgid "Color de fondo" msgid "Color de fondo"
msgstr "" msgstr ""
#: crochet/templates/crochet/pattern.html:79 #: crochet/templates/crochet/pattern.html:90
msgid "Tipografía" msgid "Tipografía"
msgstr "" msgstr ""
#: crochet/templates/crochet/pattern.html:85 #: crochet/templates/crochet/pattern.html:96
msgid "Monoespaciada" msgid "Monoespaciada"
msgstr "" msgstr ""
#: crochet/templates/crochet/pattern.html:89 #: crochet/templates/crochet/pattern.html:100
msgid "Tamaño de letra" msgid "Tamaño de letra"
msgstr "" msgstr ""
#: crochet/templates/crochet/pattern.html:91 #: crochet/templates/crochet/pattern.html:102
msgid "Pequeño" msgid "Pequeño"
msgstr "" msgstr ""
#: crochet/templates/crochet/pattern.html:92 #: crochet/templates/crochet/pattern.html:103
msgid "Normal" msgid "Normal"
msgstr "" msgstr ""
#: crochet/templates/crochet/pattern.html:93 #: crochet/templates/crochet/pattern.html:104
msgid "Grande" msgid "Grande"
msgstr "" msgstr ""
#: crochet/templates/crochet/pattern.html:97 #: crochet/templates/crochet/pattern.html:108
msgid "Alineación del texto" msgid "Alineación del texto"
msgstr "" msgstr ""
#: crochet/templates/crochet/pattern.html:99 #: crochet/templates/crochet/pattern.html:110
msgid "Izquierda" msgid "Izquierda"
msgstr "" msgstr ""
#: crochet/templates/crochet/pattern.html:100 #: crochet/templates/crochet/pattern.html:111
msgid "Centrado" msgid "Centrado"
msgstr "" msgstr ""
#: crochet/templates/crochet/pattern.html:101 #: crochet/templates/crochet/pattern.html:112
msgid "Justificado" msgid "Justificado"
msgstr "" msgstr ""
#: crochet/templates/crochet/pattern.html:105 #: crochet/templates/crochet/pattern.html:116
msgid "Tamaño de página" msgid "Tamaño de página"
msgstr "" msgstr ""
#: crochet/templates/crochet/pattern.html:110 #: crochet/templates/crochet/pattern.html:121
msgid "Carta (Letter)" msgid "Carta (Letter)"
msgstr "" msgstr ""
#: crochet/templates/crochet/pattern.html:115 #: crochet/templates/crochet/pattern.html:126
msgid "Orientación" msgid "Orientación"
msgstr "" msgstr ""
#: crochet/templates/crochet/pattern.html:117 #: crochet/templates/crochet/pattern.html:128
msgid "Vertical" msgid "Vertical"
msgstr "" msgstr ""
#: crochet/templates/crochet/pattern.html:118 #: crochet/templates/crochet/pattern.html:129
msgid "Horizontal" msgid "Horizontal"
msgstr "" msgstr ""
#: crochet/templates/crochet/pattern.html:126 #: crochet/templates/crochet/pattern.html:137
#: crochet/templates/crochet/pattern.html:134 #: crochet/templates/crochet/pattern.html:145
msgid "Secciones" msgid "Secciones"
msgstr "" msgstr ""
#: crochet/templates/crochet/pattern.html:127 #: crochet/templates/crochet/pattern.html:138
#: crochet/templates/crochet/pattern.html:173 #: crochet/templates/crochet/pattern.html:184
msgid "Vista previa" msgid "Vista previa"
msgstr "" msgstr ""
#: crochet/templates/crochet/pattern.html:136 #: crochet/templates/crochet/pattern.html:147
msgid "Colapsar todo" msgid "Colapsar todo"
msgstr "" msgstr ""
#: crochet/templates/crochet/pattern.html:136 #: crochet/templates/crochet/pattern.html:147
msgid "Expandir todo" msgid "Expandir todo"
msgstr "" msgstr ""
#: crochet/templates/crochet/pattern.html:141 #: crochet/templates/crochet/pattern.html:152
msgid "Añadir sección" msgid "Añadir sección"
msgstr "" msgstr ""
#: crochet/templates/crochet/pattern.html:144 #: crochet/templates/crochet/pattern.html:155
msgid "Título" msgid "Título"
msgstr "" msgstr ""
#: crochet/templates/crochet/pattern.html:144 #: crochet/templates/crochet/pattern.html:155
msgid "Título..." msgid "Título..."
msgstr "" msgstr ""
#: crochet/templates/crochet/pattern.html:146 #: crochet/templates/crochet/pattern.html:157
msgid "Subtítulo" msgid "Subtítulo"
msgstr "" msgstr ""
#: crochet/templates/crochet/pattern.html:146 #: crochet/templates/crochet/pattern.html:157
msgid "Subtítulo..." msgid "Subtítulo..."
msgstr "" msgstr ""
#: crochet/templates/crochet/pattern.html:148 #: crochet/templates/crochet/pattern.html:159
msgid "Texto" msgid "Texto"
msgstr "" msgstr ""
#: crochet/templates/crochet/pattern.html:148 #: crochet/templates/crochet/pattern.html:159
msgid "Escribe aquí..." msgid "Escribe aquí..."
msgstr "" msgstr ""
#: crochet/templates/crochet/pattern.html:150 #: crochet/templates/crochet/pattern.html:161
#: crochet/templates/crochet/pattern.html:151 #: crochet/templates/crochet/pattern.html:162
msgid "Nota" msgid "Nota"
msgstr "" msgstr ""
#: crochet/templates/crochet/pattern.html:150 #: crochet/templates/crochet/pattern.html:161
msgid "Escribe una nota o consejo..." msgid "Escribe una nota o consejo..."
msgstr "" msgstr ""
#: crochet/templates/crochet/pattern.html:153 #: crochet/templates/crochet/pattern.html:164
msgid "Materiales" msgid "Materiales"
msgstr "" msgstr ""
#: crochet/templates/crochet/pattern.html:153 #: crochet/templates/crochet/pattern.html:164
msgid "Añadir material..." msgid "Añadir material..."
msgstr "" msgstr ""
#: crochet/templates/crochet/pattern.html:153 #: crochet/templates/crochet/pattern.html:164
msgid "Añadir línea" msgid "Añadir línea"
msgstr "" msgstr ""
#: crochet/templates/crochet/pattern.html:155 #: crochet/templates/crochet/pattern.html:166
#: crochet/templates/crochet/pattern.html:157 #: crochet/templates/crochet/pattern.html:168
msgid "Imagen" msgid "Imagen"
msgstr "" msgstr ""
#: crochet/templates/crochet/pattern.html:157 #: crochet/templates/crochet/pattern.html:168
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 "" msgstr ""
#: crochet/templates/crochet/pattern.html:159 #: crochet/templates/crochet/pattern.html:170
msgid "Patrón" msgid "Patrón"
msgstr "" msgstr ""
#: crochet/templates/crochet/pattern.html:159 #: crochet/templates/crochet/pattern.html:170
msgid "Elementos" msgid "Elementos"
msgstr "" msgstr ""
#: crochet/templates/crochet/pattern.html:161 #: crochet/templates/crochet/pattern.html:172
msgid "Grupo" msgid "Grupo"
msgstr "" msgstr ""
#: crochet/templates/crochet/pattern.html:165 #: crochet/templates/crochet/pattern.html:176
msgid "Arrastrar para reordenar" msgid "Arrastrar para reordenar"
msgstr "" msgstr ""
#: crochet/templates/crochet/pattern.html:166 #: crochet/templates/crochet/pattern.html:177
msgid "Colapsar / expandir" msgid "Colapsar / expandir"
msgstr "" msgstr ""
#: crochet/templates/crochet/pattern.html:167 #: crochet/templates/crochet/pattern.html:178
msgid "Duplicar sección" msgid "Duplicar sección"
msgstr "" msgstr ""
#: crochet/templates/crochet/pattern.html:168 #: crochet/templates/crochet/pattern.html:179
msgid "Eliminar sección" msgid "Eliminar sección"
msgstr "" msgstr ""
#: crochet/templates/crochet/pattern.html:169 #: crochet/templates/crochet/pattern.html:180
msgid "" msgid ""
"¿Seguro que quieres eliminar este grupo? Se eliminarán también todas las " "¿Seguro que quieres eliminar este grupo? Se eliminarán también todas las "
"secciones que contiene." "secciones que contiene."
msgstr "" msgstr ""
#: crochet/templates/crochet/pattern.html:175 #: crochet/templates/crochet/pattern.html:186
msgid "Añade una sección para ver aquí el resultado." msgid "Añade una sección para ver aquí el resultado."
msgstr "" msgstr ""
@@ -323,51 +331,55 @@ 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 #: crochet/urls.py:31
msgid "pattern/<uuid:uuid>/" msgid "pattern/<uuid:uuid>/"
msgstr "patron/<uuid:uuid>/" msgstr "patron/<uuid:uuid>/"
#: crochet/urls.py:31 #: crochet/urls.py:32
msgid "pattern/<uuid:uuid>/edit/" msgid "pattern/<uuid:uuid>/edit/"
msgstr "patron/<uuid:uuid>/editar/" msgstr "patron/<uuid:uuid>/editar/"
#: crochet/urls.py:32 #: crochet/urls.py:33
msgid "pattern/<uuid:uuid>/save/" msgid "pattern/<uuid:uuid>/save/"
msgstr "patron/<uuid:uuid>/guardar/" msgstr "patron/<uuid:uuid>/guardar/"
#: crochet/urls.py:33 #: crochet/urls.py:34
msgid "pattern/<uuid:uuid>/images/" msgid "pattern/<uuid:uuid>/images/"
msgstr "patron/<uuid:uuid>/imagenes/" msgstr "patron/<uuid:uuid>/imagenes/"
#: crochet/urls.py:34 #: crochet/urls.py:35
msgid "pattern/<uuid:uuid>/cover/"
msgstr "patron/<uuid:uuid>/portada/"
#: crochet/urls.py:36
msgid "pattern/<uuid:uuid>/pdf/" msgid "pattern/<uuid:uuid>/pdf/"
msgstr "patron/<uuid:uuid>/pdf/" msgstr "patron/<uuid:uuid>/pdf/"
#: crochet/urls.py:35 #: crochet/urls.py:37
msgid "pattern/<uuid:uuid>/delete/" msgid "pattern/<uuid:uuid>/delete/"
msgstr "patron/<uuid:uuid>/eliminar/" msgstr "patron/<uuid:uuid>/eliminar/"
#: crochet/urls.py:36 #: crochet/urls.py:38
msgid "pattern/new/" msgid "pattern/new/"
msgstr "patron/nuevo/" msgstr "patron/nuevo/"
# Igual que "pattern/" arriba: "account/" se traduce como "cuenta/". # Igual que "pattern/" arriba: "account/" se traduce como "cuenta/".
#: crochet/urls.py:37 #: crochet/urls.py:39
msgid "account/" msgid "account/"
msgstr "cuenta/" msgstr "cuenta/"
#: crochet/urls.py:38 #: crochet/urls.py:40
msgid "account/register/" msgid "account/register/"
msgstr "cuenta/registro/" msgstr "cuenta/registro/"
#: crochet/urls.py:40 #: crochet/urls.py:42
msgid "account/login/" msgid "account/login/"
msgstr "cuenta/entrar/" msgstr "cuenta/entrar/"
#: crochet/urls.py:45 #: crochet/urls.py:47
msgid "account/logout/" msgid "account/logout/"
msgstr "cuenta/salir/" msgstr "cuenta/salir/"
#: crochet/views.py:23 #: crochet/views.py:24
msgid "Este patrón todavía no tiene contenido." msgid "Este patrón todavía no tiene contenido."
msgstr "" msgstr ""
@@ -0,0 +1,28 @@
# Generated by Django 6.0.7 on 2026-07-17 11:15
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('crochet', '0006_pattern_created_by'),
]
operations = [
migrations.AddField(
model_name='pattern',
name='cover_image',
field=models.ImageField(blank=True, null=True, upload_to='patterns/covers/originals/%Y/%m/'),
),
migrations.AddField(
model_name='pattern',
name='cover_image_large',
field=models.ImageField(blank=True, editable=False, null=True, upload_to='patterns/covers/large/%Y/%m/'),
),
migrations.AddField(
model_name='pattern',
name='cover_image_thumbnail',
field=models.ImageField(blank=True, editable=False, null=True, upload_to='patterns/covers/thumbnails/%Y/%m/'),
),
]
+14
View File
@@ -22,6 +22,20 @@ class Pattern(models.Model):
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)
# Portada del patrón: cover_image guarda el archivo tal cual lo sube el
# usuario; cover_image_thumbnail/cover_image_large son las variantes
# redimensionadas (ver crochet/cover_image.py) que se muestran de verdad
# en la web (tarjetas de "Mis patrones", futura vista de detalle...), no
# editable=False porque las genera PatternCoverImageUploadView, no un
# formulario.
cover_image = models.ImageField(upload_to='patterns/covers/originals/%Y/%m/', blank=True, null=True)
cover_image_thumbnail = models.ImageField(
upload_to='patterns/covers/thumbnails/%Y/%m/', blank=True, null=True, editable=False,
)
cover_image_large = models.ImageField(
upload_to='patterns/covers/large/%Y/%m/', blank=True, null=True, editable=False,
)
def __str__(self): def __str__(self):
return self.page_settings.get('title') or str(self.uuid) return self.page_settings.get('title') or str(self.uuid)
+12 -8
View File
@@ -138,18 +138,22 @@ def _render_section(section, lang, stitch_types_by_id):
return '' return ''
def render_pattern_html(sections, page_settings, lang, stitch_types, empty_message): def render_pattern_html(sections, page_settings, lang, stitch_types, empty_message, cover_image_url=None):
"""HTML (ya escapado y marcado como seguro) del contenido de un patrón """HTML (ya escapado y marcado como seguro) del contenido de un patrón
para `lang`, en el mismo formato que genera renderOutput() en el para `lang`, en el mismo formato que genera renderOutput() en el
editor: título/autor del documento primero, y luego cada sección de editor: título, portada y autor del documento primero, y luego cada
nivel superior en orden. `page_settings` debe venir ya validado por sección de nivel superior en orden. `page_settings` debe venir ya
sanitize_page_settings().""" validado por sanitize_page_settings()."""
stitch_types_by_id = {st['id']: st for st in stitch_types} stitch_types_by_id = {st['id']: st for st in stitch_types}
parts = [ parts = [_line('h1', page_settings['title'], 'text-[1.5em] font-bold mb-1')]
_line('h1', page_settings['title'], 'text-[1.5em] font-bold mb-1'), # Mismas clases que _render_image() (misma pinta que una imagen de
_line('p', page_settings['author'], 'text-[0.875em] opacity-70 mb-2'), # sección) y que la portada que pinta render.js en el editor.
] if cover_image_url:
parts.append(format_html(
'<img src="{0}" class="max-w-full max-h-96 object-contain rounded-2xl my-2" alt="">', cover_image_url,
))
parts.append(_line('p', page_settings['author'], 'text-[0.875em] opacity-70 mb-2'))
if not sections: if not sections:
parts.append(format_html('<p class="opacity-50 italic">{0}</p>', empty_message)) parts.append(format_html('<p class="opacity-50 italic">{0}</p>', empty_message))
+36
View File
@@ -0,0 +1,36 @@
// Subida de la portada del patrón (ver "Personalización de página" en
// pattern.html). Distinto de sections.js (esa sube imágenes DENTRO de una
// sección de tipo "Imagen"): esto es un atributo del patrón entero, así que
// se sube y aplica en cuanto se elige el archivo, sin esperar a "Guardar".
import { coverImageInput, coverImageLargeUrlInput, coverImagePreview } from './dom-refs.js';
import { getCsrfToken } from './csrf.js';
import { renderOutput } from './render.js';
coverImageInput.addEventListener('change', async () => {
const file = coverImageInput.files[0];
if (!file) return;
const formData = new FormData();
formData.append('cover_image', file);
try {
const response = await fetch(coverImageInput.dataset.uploadUrl, {
method: 'POST',
headers: { 'X-CSRFToken': getCsrfToken() },
body: formData,
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
coverImagePreview.src = data.thumbnailUrl;
coverImagePreview.classList.remove('hidden');
// "Vista previa" (ver render.js) pinta la portada a partir de este
// valor, igual que lee pageTitleInput/pageAuthorInput.
coverImageLargeUrlInput.value = data.largeUrl;
renderOutput();
} catch (error) {
console.error('No se ha podido subir la imagen de portada:', error);
alert(coverImageInput.dataset.errorMessage);
}
});
+6
View File
@@ -22,3 +22,9 @@ export const pageAlignSelect = document.getElementById('page-align-select');
export const pageSizeSelect = document.getElementById('page-size-select'); export const pageSizeSelect = document.getElementById('page-size-select');
export const pageOrientationSelect = document.getElementById('page-orientation-select'); export const pageOrientationSelect = document.getElementById('page-orientation-select');
export const pageSizeStyle = document.getElementById('page-size-style'); export const pageSizeStyle = document.getElementById('page-size-style');
// Portada del patrón (ver cover-image.js): configuración del documento
// entero, igual que título/autor de arriba, no una sección más.
export const coverImageInput = document.getElementById('cover-image-input');
export const coverImagePreview = document.getElementById('cover-image-preview'); // Miniatura dentro del propio control de subida.
export const coverImageLargeUrlInput = document.getElementById('cover-image-large-url'); // Leído por render.js para "Vista previa".
+1
View File
@@ -8,6 +8,7 @@ import { applyDefaultSkeleton, buildSectionsFrom } from './section-types.js';
import { setAllSectionsCollapsed } from './sections.js'; import { setAllSectionsCollapsed } from './sections.js';
import './tabs.js'; import './tabs.js';
import './storage.js'; import './storage.js';
import './cover-image.js';
// Mismo punto de corte que .panel-hidden-mobile (ver css/main.css). // Mismo punto de corte que .panel-hidden-mobile (ver css/main.css).
const MOBILE_BREAKPOINT_QUERY = '(max-width: 767px)'; const MOBILE_BREAKPOINT_QUERY = '(max-width: 767px)';
+15 -3
View File
@@ -3,7 +3,7 @@
// repintarlo: al escribir, al cambiar de idioma, o al añadir/quitar/ // repintarlo: al escribir, al cambiar de idioma, o al añadir/quitar/
// reordenar secciones o elementos soltados. // reordenar secciones o elementos soltados.
import { outputText, sectionsContainer, pageTitleInput, pageAuthorInput } from './dom-refs.js'; import { outputText, sectionsContainer, pageTitleInput, pageAuthorInput, coverImageLargeUrlInput } from './dom-refs.js';
import { TRANSLATABLE_FIELDS_SELECTOR, setTranslation, setupLanguageSelect } from './i18n.js'; import { TRANSLATABLE_FIELDS_SELECTOR, setTranslation, setupLanguageSelect } from './i18n.js';
import { renderOutputSection, appendOutputLine } from './section-types.js'; import { renderOutputSection, appendOutputLine } from './section-types.js';
@@ -12,13 +12,25 @@ import { renderOutputSection, appendOutputLine } from './section-types.js';
export function renderOutput() { export function renderOutput() {
outputText.innerHTML = ''; outputText.innerHTML = '';
// Título y autor del patrón (ver "Personalización de página"): son // Título, portada y autor del patrón (ver "Personalización de página"):
// configuración de todo el documento, no una sección más, así que se // son configuración de todo el documento, no una sección más, así que se
// pintan siempre delante, haya o no secciones añadidas. // pintan siempre delante, haya o no secciones añadidas.
// Tamaños en "em" (no las clases normales de Tailwind, en "rem") para que // Tamaños en "em" (no las clases normales de Tailwind, en "rem") para que
// escalen con el "Tamaño de letra" de #panel-output-text (ver // escalen con el "Tamaño de letra" de #panel-output-text (ver
// page-config.js): "rem" ignora el font-size de cualquier ancestro. // page-config.js): "rem" ignora el font-size de cualquier ancestro.
appendOutputLine(outputText, 'h1', pageTitleInput.value.trim(), 'text-[1.5em] font-bold mb-1'); appendOutputLine(outputText, 'h1', pageTitleInput.value.trim(), 'text-[1.5em] font-bold mb-1');
// Debajo del título: mismo tamaño "large" y mismas clases que pinta
// render_pattern_html() en la vista de solo lectura/PDF, para que ambos
// resultados sean visualmente idénticos (ver pattern_render.py).
if (coverImageLargeUrlInput.value) {
const coverImage = document.createElement('img');
coverImage.src = coverImageLargeUrlInput.value;
coverImage.alt = '';
coverImage.className = 'max-w-full max-h-96 object-contain rounded-2xl my-2';
outputText.appendChild(coverImage);
}
appendOutputLine(outputText, 'p', pageAuthorInput.value.trim(), 'text-[0.875em] opacity-70 mb-2'); appendOutputLine(outputText, 'p', pageAuthorInput.value.trim(), 'text-[0.875em] opacity-70 mb-2');
// Si aún no se ha añadido ninguna sección, avisar en vez de dejar el // Si aún no se ha añadido ninguna sección, avisar en vez de dejar el
@@ -22,6 +22,13 @@
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{% for pattern in patterns %} {% for pattern in patterns %}
<div class="card bg-base-100 shadow border border-base-300"> <div class="card bg-base-100 shadow border border-base-300">
<figure class="h-40 bg-base-200">
{% if pattern.cover_image_thumbnail %}
<img src="{{ pattern.cover_image_thumbnail.url }}" alt="" class="w-full h-full object-cover">
{% else %}
<span class="text-4xl">🧶</span>
{% endif %}
</figure>
<div class="card-body"> <div class="card-body">
<h2 class="card-title text-lg"> <h2 class="card-title text-lg">
{% if pattern.page_settings.title %}{{ pattern.page_settings.title }}{% else %}{% trans 'Patrón sin título' %}{% endif %} {% if pattern.page_settings.title %}{{ pattern.page_settings.title }}{% else %}{% trans 'Patrón sin título' %}{% endif %}
+17
View File
@@ -62,6 +62,23 @@
<input id="page-author-input" type="text" class="input input-bordered" placeholder="{% trans 'Autor...' %}"> <input id="page-author-input" type="text" class="input input-bordered" placeholder="{% trans 'Autor...' %}">
</label> </label>
</div> </div>
<div class="flex items-end gap-4">
<label class="flex flex-col gap-1 flex-1">
<span class="text-sm">{% trans 'Imagen de portada' %}</span>
<input id="cover-image-input" type="file" accept="image/*" class="file-input file-input-bordered"
data-upload-url="{% url 'crochet:pattern_cover_upload' pattern.uuid %}"
data-error-message="{% trans 'No se ha podido subir la imagen de portada. Inténtalo de nuevo.' %}">
</label>
<img id="cover-image-preview" alt=""
src="{% if pattern.cover_image_thumbnail %}{{ pattern.cover_image_thumbnail.url }}{% endif %}"
class="w-24 h-24 object-cover rounded-box border border-base-300{% if not pattern.cover_image_thumbnail %} hidden{% endif %}">
<!-- Tamaño "large" (no el thumbnail de arriba, pensado solo
para ese control): lo lee render.js para pintarla en
"Vista previa", debajo del título, igual que hace
render_pattern_html() en la vista de solo lectura/PDF. -->
<input type="hidden" id="cover-image-large-url"
value="{% if pattern.cover_image_large %}{{ pattern.cover_image_large.url }}{% endif %}">
</div>
<div class="flex flex-wrap items-end gap-4"> <div class="flex flex-wrap items-end gap-4">
<label class="flex flex-col gap-1"> <label class="flex flex-col gap-1">
<span class="text-sm">{% trans 'Color del texto' %}</span> <span class="text-sm">{% trans 'Color del texto' %}</span>
+60
View File
@@ -0,0 +1,60 @@
import io
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import SimpleTestCase
from PIL import Image
from crochet.cover_image import InvalidCoverImage, LARGE_MAX_SIZE, THUMBNAIL_MAX_SIZE, build_cover_image_variants
def make_image_file(size=(2000, 1000), mode='RGB', color='red', name='cover.png', format='PNG'):
buffer = io.BytesIO()
Image.new(mode, size, color=color).save(buffer, format=format)
return SimpleUploadedFile(name, buffer.getvalue(), content_type=f'image/{format.lower()}')
class BuildCoverImageVariantsTests(SimpleTestCase):
def test_thumbnail_and_large_fit_within_max_size(self):
original, thumbnail, large = build_cover_image_variants(make_image_file(size=(2000, 1000)))
thumbnail_image = Image.open(io.BytesIO(thumbnail.read()))
large_image = Image.open(io.BytesIO(large.read()))
self.assertLessEqual(thumbnail_image.width, THUMBNAIL_MAX_SIZE[0])
self.assertLessEqual(thumbnail_image.height, THUMBNAIL_MAX_SIZE[1])
self.assertLessEqual(large_image.width, LARGE_MAX_SIZE[0])
self.assertLessEqual(large_image.height, LARGE_MAX_SIZE[1])
def test_preserves_aspect_ratio(self):
_, thumbnail, _ = build_cover_image_variants(make_image_file(size=(2000, 1000)))
thumbnail_image = Image.open(io.BytesIO(thumbnail.read()))
self.assertAlmostEqual(thumbnail_image.width / thumbnail_image.height, 2.0, places=1)
def test_small_image_is_not_upscaled(self):
_, thumbnail, _ = build_cover_image_variants(make_image_file(size=(50, 50)))
thumbnail_image = Image.open(io.BytesIO(thumbnail.read()))
self.assertEqual(thumbnail_image.size, (50, 50))
def test_original_is_kept_unprocessed(self):
original, _, _ = build_cover_image_variants(make_image_file(size=(50, 50), name='mine.png'))
self.assertEqual(original.name, 'mine.png')
original_image = Image.open(io.BytesIO(original.read()))
self.assertEqual(original_image.size, (50, 50))
def test_transparent_png_is_flattened_for_jpeg_variants(self):
# Si el aplanado a RGB no funcionase, Pillow lanzaría un error al
# intentar guardar un canal alfa como JPEG (ver build_cover_image_variants).
uploaded = make_image_file(size=(50, 50), mode='RGBA', color=(255, 0, 0, 0))
_, thumbnail, large = build_cover_image_variants(uploaded)
self.assertEqual(Image.open(io.BytesIO(thumbnail.read())).mode, 'RGB')
self.assertEqual(Image.open(io.BytesIO(large.read())).mode, 'RGB')
def test_invalid_file_raises(self):
invalid_file = SimpleUploadedFile('not-an-image.png', b'this is not an image', content_type='image/png')
with self.assertRaises(InvalidCoverImage):
build_cover_image_variants(invalid_file)
+21 -1
View File
@@ -74,13 +74,14 @@ class SanitizePageSettingsTests(SimpleTestCase):
class RenderPatternHtmlTests(SimpleTestCase): class RenderPatternHtmlTests(SimpleTestCase):
def render(self, sections, lang='es', stitch_types=None, page_settings=None): def render(self, sections, lang='es', stitch_types=None, page_settings=None, cover_image_url=None):
return render_pattern_html( return render_pattern_html(
sections, sections,
page_settings or DEFAULT_PAGE_SETTINGS, page_settings or DEFAULT_PAGE_SETTINGS,
lang, lang,
stitch_types or [], stitch_types or [],
'Este patrón todavía no tiene contenido.', 'Este patrón todavía no tiene contenido.',
cover_image_url=cover_image_url,
) )
def test_empty_sections_shows_empty_message(self): def test_empty_sections_shows_empty_message(self):
@@ -92,6 +93,25 @@ class RenderPatternHtmlTests(SimpleTestCase):
self.assertIn('<h1 class="text-[1.5em] font-bold mb-1">Ampharos</h1>', html) self.assertIn('<h1 class="text-[1.5em] font-bold mb-1">Ampharos</h1>', html)
self.assertIn('<p class="text-[0.875em] opacity-70 mb-2">Nerea</p>', html) self.assertIn('<p class="text-[0.875em] opacity-70 mb-2">Nerea</p>', html)
def test_cover_image_renders_between_title_and_author(self):
html = self.render(
[], page_settings=sanitize_page_settings({'title': 'Ampharos', 'author': 'Nerea'}),
cover_image_url='/media/patterns/covers/large/2026/07/large.jpg',
)
title_index = html.index('Ampharos')
image_index = html.index('<img src="/media/patterns/covers/large/2026/07/large.jpg"')
author_index = html.index('Nerea')
self.assertLess(title_index, image_index)
self.assertLess(image_index, author_index)
def test_no_cover_image_renders_no_img_tag(self):
html = self.render([], cover_image_url=None)
self.assertNotIn('<img', html)
def test_cover_image_url_is_escaped(self):
html = self.render([], cover_image_url='"><script>alert(1)</script>')
self.assertNotIn('<script>', html)
def test_title_section(self): def test_title_section(self):
html = self.render([{'type': 'title', 'text': {'es': 'Materiales'}}]) html = self.render([{'type': 'title', 'text': {'es': 'Materiales'}}])
self.assertIn('<h3 class="text-[1.125em] font-bold mt-2 mb-1 text-[var(--accent-color)]">Materiales</h3>', html) self.assertIn('<h3 class="text-[1.125em] font-bold mt-2 mb-1 text-[var(--accent-color)]">Materiales</h3>', html)
+93
View File
@@ -1,7 +1,9 @@
import json import json
import os
import re import re
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.files.uploadedfile import SimpleUploadedFile
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
@@ -61,6 +63,18 @@ class PatternEditViewTests(TestCase):
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
def test_cover_image_large_url_is_available_to_the_live_preview(self):
self.pattern.cover_image_large.save(
'large.jpg', make_test_image_file(name='large.jpg'), save=True,
)
self.client.force_login(self.owner)
with translation.override('es'):
url = reverse('crochet:pattern_edit', args=[self.pattern.uuid])
response = self.client.get(url)
self.assertContains(response, 'id="cover-image-large-url"')
self.assertContains(response, f'value="{self.pattern.cover_image_large.url}"')
def test_anonymous_user_is_redirected_to_login(self): def test_anonymous_user_is_redirected_to_login(self):
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])
@@ -110,6 +124,18 @@ class PatternDetailViewTests(TestCase):
self.assertContains(response, 'Ampharos') self.assertContains(response, 'Ampharos')
self.assertContains(response, '3 Punto bajo') self.assertContains(response, '3 Punto bajo')
def test_cover_image_renders_below_title(self):
self.pattern.cover_image_large.save(
'large.jpg', make_test_image_file(name='large.jpg'), save=True,
)
with translation.override('es'):
url = reverse('crochet:pattern_detail', args=[self.pattern.uuid])
response = self.client.get(url)
body = response.content.decode()
self.assertContains(response, f'<img src="{self.pattern.cover_image_large.url}"')
self.assertLess(body.index('Ampharos'), body.index(self.pattern.cover_image_large.url))
def test_page_has_no_editable_elements(self): def test_page_has_no_editable_elements(self):
with translation.override('es'): with translation.override('es'):
url = reverse('crochet:pattern_detail', args=[self.pattern.uuid]) url = reverse('crochet:pattern_detail', args=[self.pattern.uuid])
@@ -291,6 +317,73 @@ class PatternImageUploadViewTests(TestCase):
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
class PatternCoverImageUploadViewTests(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)
self.client.force_login(self.owner)
with translation.override('es'):
self.url = reverse('crochet:pattern_cover_upload', args=[self.pattern.uuid])
def test_uploads_and_generates_variants(self):
response = self.client.post(self.url, data={'cover_image': make_test_image_file()})
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertIn('thumbnailUrl', data)
self.assertIn('largeUrl', data)
self.pattern.refresh_from_db()
self.assertTrue(self.pattern.cover_image)
self.assertTrue(self.pattern.cover_image_thumbnail)
self.assertTrue(self.pattern.cover_image_large)
def test_replacing_cover_deletes_previous_files(self):
self.client.post(self.url, data={'cover_image': make_test_image_file(name='first.png')})
self.pattern.refresh_from_db()
old_original_path = self.pattern.cover_image.path
old_thumbnail_path = self.pattern.cover_image_thumbnail.path
old_large_path = self.pattern.cover_image_large.path
self.client.post(self.url, data={'cover_image': make_test_image_file(name='second.png')})
self.pattern.refresh_from_db()
# cover_image conserva el nombre subido (cambia de "first.png" a
# "second.png"), así que el archivo antiguo debe desaparecer.
self.assertFalse(os.path.exists(old_original_path))
# cover_image_thumbnail/large usan un nombre fijo (thumbnail.jpg/
# large.jpg, ver cover_image.py): si nada más lo había reclamado ya,
# la segunda subida sobrescribe la misma ruta (no cambia); si sí
# cambia (otro patrón se quedó con ese nombre primero, ver la
# carpeta compartida por mes), la ruta antigua debe desaparecer. En
# ningún caso debe quedar huérfana.
if self.pattern.cover_image_thumbnail.path != old_thumbnail_path:
self.assertFalse(os.path.exists(old_thumbnail_path))
if self.pattern.cover_image_large.path != old_large_path:
self.assertFalse(os.path.exists(old_large_path))
def test_missing_file_returns_bad_request(self):
response = self.client.post(self.url, data={})
self.assertEqual(response.status_code, 400)
def test_invalid_image_returns_bad_request(self):
invalid_file = SimpleUploadedFile('not-an-image.png', b'not an image', content_type='image/png')
response = self.client.post(self.url, data={'cover_image': invalid_file})
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={'cover_image': make_test_image_file()})
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_cover_upload', args=[orphan_pattern.uuid])
response = self.client.post(url, data={'cover_image': make_test_image_file()})
self.assertEqual(response.status_code, 403)
class PatternDeleteViewTests(TestCase): class PatternDeleteViewTests(TestCase):
def setUp(self): def setUp(self):
self.owner = User.objects.create_user(username='crocheter', password='a-very-uncommon-pw-1') self.owner = User.objects.create_user(username='crocheter', password='a-very-uncommon-pw-1')
+2
View File
@@ -5,6 +5,7 @@ from django.utils.translation import gettext_lazy as _
from crochet.forms import StyledAuthenticationForm from crochet.forms import StyledAuthenticationForm
from crochet.views import ( from crochet.views import (
AccountHomeView, AccountHomeView,
PatternCoverImageUploadView,
PatternCreateView, PatternCreateView,
PatternDeleteView, PatternDeleteView,
PatternDetailView, PatternDetailView,
@@ -31,6 +32,7 @@ urlpatterns = [
path(_('pattern/<uuid:uuid>/edit/'), name='pattern_edit', view=PatternEditView.as_view()), path(_('pattern/<uuid:uuid>/edit/'), name='pattern_edit', view=PatternEditView.as_view()),
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>/cover/'), name='pattern_cover_upload', view=PatternCoverImageUploadView.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/<uuid:uuid>/delete/'), name='pattern_delete', view=PatternDeleteView.as_view()),
path(_('pattern/new/'), name='pattern_create', view=PatternCreateView.as_view()), path(_('pattern/new/'), name='pattern_create', view=PatternCreateView.as_view()),
+34
View File
@@ -13,6 +13,7 @@ from django.utils.translation import get_language, gettext_lazy as _
from django.views import View from django.views import View
from django.views.generic import CreateView, DetailView, TemplateView from django.views.generic import CreateView, DetailView, TemplateView
from crochet.cover_image import InvalidCoverImage, build_cover_image_variants
from crochet.forms import StyledUserCreationForm 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
@@ -57,6 +58,7 @@ def _pattern_detail_context(pattern):
'page_settings': page_settings, 'page_settings': page_settings,
'rendered_pattern': render_pattern_html( 'rendered_pattern': render_pattern_html(
pattern.sections, page_settings, lang, _stitch_types_data(), PATTERN_EMPTY_MESSAGE, pattern.sections, page_settings, lang, _stitch_types_data(), PATTERN_EMPTY_MESSAGE,
cover_image_url=pattern.cover_image_large.url if pattern.cover_image_large else None,
), ),
} }
@@ -193,6 +195,38 @@ class PatternImageUploadView(View):
return JsonResponse({'id': pattern_image.id, 'url': pattern_image.image.url}) return JsonResponse({'id': pattern_image.id, 'url': pattern_image.image.url})
class PatternCoverImageUploadView(View):
# Mismo motivo que en PatternSaveView: lo llama cover-image.js por fetch.
def post(self, request, uuid):
pattern = _get_owned_pattern_or_403(request, uuid)
uploaded_file = request.FILES.get('cover_image')
if not uploaded_file:
return HttpResponseBadRequest('Falta el archivo "cover_image"')
try:
original, thumbnail, large = build_cover_image_variants(uploaded_file)
except InvalidCoverImage as error:
return HttpResponseBadRequest(str(error))
# Reemplaza los archivos anteriores en vez de acumularlos: cada
# patrón tiene una única portada vigente.
for field_name, content_file in (
('cover_image', original), ('cover_image_thumbnail', thumbnail), ('cover_image_large', large),
):
field_file = getattr(pattern, field_name)
if field_file:
field_file.delete(save=False)
field_file.save(content_file.name, content_file, save=False)
pattern.save(update_fields=['cover_image', 'cover_image_thumbnail', 'cover_image_large'])
return JsonResponse({
'thumbnailUrl': pattern.cover_image_thumbnail.url,
'largeUrl': pattern.cover_image_large.url,
})
class RegisterView(CreateView): class RegisterView(CreateView):
# UserCreationForm ya pide justo nombre de usuario + contraseña (dos # UserCreationForm ya pide justo nombre de usuario + contraseña (dos
# veces, para confirmarla): no hace falta un formulario propio, solo la # veces, para confirmarla): no hace falta un formulario propio, solo la