feat: added cover image to pattern
This commit is contained in:
+15
@@ -18,3 +18,18 @@ def collected_static_files():
|
||||
with override_settings(STATIC_ROOT=static_root):
|
||||
call_command('collectstatic', interactive=False, verbosity=0)
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -2,7 +2,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: crochet\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"
|
||||
"MIME-Version: 1.0\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."
|
||||
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"
|
||||
msgstr "Untitled pattern"
|
||||
|
||||
#: crochet/templates/crochet/account_home.html:30
|
||||
#: crochet/templates/crochet/account_home.html:37
|
||||
#, python-format
|
||||
msgid "Actualizado el %(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
|
||||
msgid "Ver patrón"
|
||||
msgstr "View pattern"
|
||||
|
||||
#: crochet/templates/crochet/account_home.html:34
|
||||
#: crochet/templates/crochet/account_home.html:41
|
||||
msgid "Editar"
|
||||
msgstr "Edit"
|
||||
|
||||
#: crochet/templates/crochet/account_home.html:38
|
||||
#: crochet/templates/crochet/account_home.html:45
|
||||
msgid "Eliminar"
|
||||
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."
|
||||
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
|
||||
msgid "Cerrar sesión"
|
||||
msgstr "Log out"
|
||||
@@ -110,184 +110,192 @@ msgid "Autor..."
|
||||
msgstr "Author..."
|
||||
|
||||
#: 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"
|
||||
msgstr "Text color"
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:71
|
||||
#: crochet/templates/crochet/pattern.html:82
|
||||
msgid "Color de acento (títulos)"
|
||||
msgstr "Accent color (headings)"
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:75
|
||||
#: crochet/templates/crochet/pattern.html:151
|
||||
#: crochet/templates/crochet/pattern.html:86
|
||||
#: crochet/templates/crochet/pattern.html:162
|
||||
msgid "Color de fondo"
|
||||
msgstr "Background color"
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:79
|
||||
#: crochet/templates/crochet/pattern.html:90
|
||||
msgid "Tipografía"
|
||||
msgstr "Font"
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:85
|
||||
#: crochet/templates/crochet/pattern.html:96
|
||||
msgid "Monoespaciada"
|
||||
msgstr "Monospace"
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:89
|
||||
#: crochet/templates/crochet/pattern.html:100
|
||||
msgid "Tamaño de letra"
|
||||
msgstr "Font size"
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:91
|
||||
#: crochet/templates/crochet/pattern.html:102
|
||||
msgid "Pequeño"
|
||||
msgstr "Small"
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:92
|
||||
#: crochet/templates/crochet/pattern.html:103
|
||||
msgid "Normal"
|
||||
msgstr "Normal"
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:93
|
||||
#: crochet/templates/crochet/pattern.html:104
|
||||
msgid "Grande"
|
||||
msgstr "Large"
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:97
|
||||
#: crochet/templates/crochet/pattern.html:108
|
||||
msgid "Alineación del texto"
|
||||
msgstr "Text alignment"
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:99
|
||||
#: crochet/templates/crochet/pattern.html:110
|
||||
msgid "Izquierda"
|
||||
msgstr "Left"
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:100
|
||||
#: crochet/templates/crochet/pattern.html:111
|
||||
msgid "Centrado"
|
||||
msgstr "Center"
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:101
|
||||
#: crochet/templates/crochet/pattern.html:112
|
||||
msgid "Justificado"
|
||||
msgstr "Justify"
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:105
|
||||
#: crochet/templates/crochet/pattern.html:116
|
||||
msgid "Tamaño de página"
|
||||
msgstr "Page size"
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:110
|
||||
#: crochet/templates/crochet/pattern.html:121
|
||||
msgid "Carta (Letter)"
|
||||
msgstr "Letter"
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:115
|
||||
#: crochet/templates/crochet/pattern.html:126
|
||||
msgid "Orientación"
|
||||
msgstr "Orientation"
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:117
|
||||
#: crochet/templates/crochet/pattern.html:128
|
||||
msgid "Vertical"
|
||||
msgstr "Portrait"
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:118
|
||||
#: crochet/templates/crochet/pattern.html:129
|
||||
msgid "Horizontal"
|
||||
msgstr "Landscape"
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:126
|
||||
#: crochet/templates/crochet/pattern.html:134
|
||||
#: crochet/templates/crochet/pattern.html:137
|
||||
#: crochet/templates/crochet/pattern.html:145
|
||||
msgid "Secciones"
|
||||
msgstr "Sections"
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:127
|
||||
#: crochet/templates/crochet/pattern.html:173
|
||||
#: crochet/templates/crochet/pattern.html:138
|
||||
#: crochet/templates/crochet/pattern.html:184
|
||||
msgid "Vista previa"
|
||||
msgstr "Preview"
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:136
|
||||
#: crochet/templates/crochet/pattern.html:147
|
||||
msgid "Colapsar todo"
|
||||
msgstr "Collapse all"
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:136
|
||||
#: crochet/templates/crochet/pattern.html:147
|
||||
msgid "Expandir todo"
|
||||
msgstr "Expand all"
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:141
|
||||
#: crochet/templates/crochet/pattern.html:152
|
||||
msgid "Añadir sección"
|
||||
msgstr "Add section"
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:144
|
||||
#: crochet/templates/crochet/pattern.html:155
|
||||
msgid "Título"
|
||||
msgstr "Title"
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:144
|
||||
#: crochet/templates/crochet/pattern.html:155
|
||||
msgid "Título..."
|
||||
msgstr "Title..."
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:146
|
||||
#: crochet/templates/crochet/pattern.html:157
|
||||
msgid "Subtítulo"
|
||||
msgstr "Subtitle"
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:146
|
||||
#: crochet/templates/crochet/pattern.html:157
|
||||
msgid "Subtítulo..."
|
||||
msgstr "Subtitle..."
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:148
|
||||
#: crochet/templates/crochet/pattern.html:159
|
||||
msgid "Texto"
|
||||
msgstr "Text"
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:148
|
||||
#: crochet/templates/crochet/pattern.html:159
|
||||
msgid "Escribe aquí..."
|
||||
msgstr "Write here..."
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:150
|
||||
#: crochet/templates/crochet/pattern.html:151
|
||||
#: crochet/templates/crochet/pattern.html:161
|
||||
#: crochet/templates/crochet/pattern.html:162
|
||||
msgid "Nota"
|
||||
msgstr "Note"
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:150
|
||||
#: crochet/templates/crochet/pattern.html:161
|
||||
msgid "Escribe una nota o consejo..."
|
||||
msgstr "Write a note or tip..."
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:153
|
||||
#: crochet/templates/crochet/pattern.html:164
|
||||
msgid "Materiales"
|
||||
msgstr "Materials"
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:153
|
||||
#: crochet/templates/crochet/pattern.html:164
|
||||
msgid "Añadir material..."
|
||||
msgstr "Add material..."
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:153
|
||||
#: crochet/templates/crochet/pattern.html:164
|
||||
msgid "Añadir línea"
|
||||
msgstr "Add line"
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:155
|
||||
#: crochet/templates/crochet/pattern.html:157
|
||||
#: crochet/templates/crochet/pattern.html:166
|
||||
#: crochet/templates/crochet/pattern.html:168
|
||||
msgid "Imagen"
|
||||
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."
|
||||
msgstr "Couldn't upload the image. Please try again."
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:159
|
||||
#: crochet/templates/crochet/pattern.html:170
|
||||
msgid "Patrón"
|
||||
msgstr "Pattern"
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:159
|
||||
#: crochet/templates/crochet/pattern.html:170
|
||||
msgid "Elementos"
|
||||
msgstr "Elements"
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:161
|
||||
#: crochet/templates/crochet/pattern.html:172
|
||||
msgid "Grupo"
|
||||
msgstr "Group"
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:165
|
||||
#: crochet/templates/crochet/pattern.html:176
|
||||
msgid "Arrastrar para reordenar"
|
||||
msgstr "Drag to reorder"
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:166
|
||||
#: crochet/templates/crochet/pattern.html:177
|
||||
msgid "Colapsar / expandir"
|
||||
msgstr "Collapse / expand"
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:167
|
||||
#: crochet/templates/crochet/pattern.html:178
|
||||
msgid "Duplicar sección"
|
||||
msgstr "Duplicate section"
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:168
|
||||
#: crochet/templates/crochet/pattern.html:179
|
||||
msgid "Eliminar sección"
|
||||
msgstr "Delete section"
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:169
|
||||
#: crochet/templates/crochet/pattern.html:180
|
||||
msgid ""
|
||||
"¿Seguro que quieres eliminar este grupo? Se eliminarán también todas las "
|
||||
"secciones que contiene."
|
||||
@@ -295,7 +303,7 @@ msgstr ""
|
||||
"Are you sure you want to delete this group? All sections inside it will also "
|
||||
"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."
|
||||
msgstr "Add a section to see the result here."
|
||||
|
||||
@@ -324,51 +332,55 @@ msgstr "Create account"
|
||||
msgid "¿Ya tienes cuenta? Inicia sesión"
|
||||
msgstr "Already have an account? Log in"
|
||||
|
||||
#: crochet/urls.py:30
|
||||
#: crochet/urls.py:31
|
||||
msgid "pattern/<uuid:uuid>/"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/urls.py:31
|
||||
#: crochet/urls.py:32
|
||||
msgid "pattern/<uuid:uuid>/edit/"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/urls.py:32
|
||||
#: crochet/urls.py:33
|
||||
msgid "pattern/<uuid:uuid>/save/"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/urls.py:33
|
||||
#: crochet/urls.py:34
|
||||
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/"
|
||||
msgid "pattern/<uuid:uuid>/cover/"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/urls.py:36
|
||||
msgid "pattern/new/"
|
||||
msgid "pattern/<uuid:uuid>/pdf/"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/urls.py:37
|
||||
msgid "account/"
|
||||
msgid "pattern/<uuid:uuid>/delete/"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/urls.py:38
|
||||
msgid "account/register/"
|
||||
msgid "pattern/new/"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/urls.py:39
|
||||
msgid "account/"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/urls.py:40
|
||||
msgid "account/register/"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/urls.py:42
|
||||
msgid "account/login/"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/urls.py:45
|
||||
#: crochet/urls.py:47
|
||||
msgid "account/logout/"
|
||||
msgstr ""
|
||||
|
||||
# crochet/views.py (PatternDetailView.EMPTY_MESSAGE)
|
||||
#: crochet/views.py:23
|
||||
#: crochet/views.py:24
|
||||
msgid "Este patrón todavía no tiene contenido."
|
||||
msgstr "This pattern doesn't have any content yet."
|
||||
|
||||
@@ -2,7 +2,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: crochet\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"
|
||||
"MIME-Version: 1.0\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."
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/account_home.html:27
|
||||
#: crochet/templates/crochet/account_home.html:34
|
||||
msgid "Patrón sin título"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/account_home.html:30
|
||||
#: crochet/templates/crochet/account_home.html:37
|
||||
#, python-format
|
||||
msgid "Actualizado el %(date)s"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/account_home.html:33
|
||||
#: crochet/templates/crochet/account_home.html:40
|
||||
#: crochet/templates/crochet/pattern.html:34
|
||||
msgid "Ver patrón"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/account_home.html:34
|
||||
#: crochet/templates/crochet/account_home.html:41
|
||||
msgid "Editar"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/account_home.html:38
|
||||
#: crochet/templates/crochet/account_home.html:45
|
||||
msgid "Eliminar"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/account_home.html:47
|
||||
#: crochet/templates/crochet/account_home.html:54
|
||||
msgid "Todavía no tienes ningún patrón."
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/account_home.html:53
|
||||
#: crochet/templates/crochet/account_home.html:60
|
||||
#: crochet/templates/crochet/base.html:34
|
||||
msgid "Cerrar sesión"
|
||||
msgstr ""
|
||||
@@ -109,190 +109,198 @@ msgid "Autor..."
|
||||
msgstr ""
|
||||
|
||||
#: 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"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:71
|
||||
#: crochet/templates/crochet/pattern.html:82
|
||||
msgid "Color de acento (títulos)"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:75
|
||||
#: crochet/templates/crochet/pattern.html:151
|
||||
#: crochet/templates/crochet/pattern.html:86
|
||||
#: crochet/templates/crochet/pattern.html:162
|
||||
msgid "Color de fondo"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:79
|
||||
#: crochet/templates/crochet/pattern.html:90
|
||||
msgid "Tipografía"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:85
|
||||
#: crochet/templates/crochet/pattern.html:96
|
||||
msgid "Monoespaciada"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:89
|
||||
#: crochet/templates/crochet/pattern.html:100
|
||||
msgid "Tamaño de letra"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:91
|
||||
#: crochet/templates/crochet/pattern.html:102
|
||||
msgid "Pequeño"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:92
|
||||
#: crochet/templates/crochet/pattern.html:103
|
||||
msgid "Normal"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:93
|
||||
#: crochet/templates/crochet/pattern.html:104
|
||||
msgid "Grande"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:97
|
||||
#: crochet/templates/crochet/pattern.html:108
|
||||
msgid "Alineación del texto"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:99
|
||||
#: crochet/templates/crochet/pattern.html:110
|
||||
msgid "Izquierda"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:100
|
||||
#: crochet/templates/crochet/pattern.html:111
|
||||
msgid "Centrado"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:101
|
||||
#: crochet/templates/crochet/pattern.html:112
|
||||
msgid "Justificado"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:105
|
||||
#: crochet/templates/crochet/pattern.html:116
|
||||
msgid "Tamaño de página"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:110
|
||||
#: crochet/templates/crochet/pattern.html:121
|
||||
msgid "Carta (Letter)"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:115
|
||||
#: crochet/templates/crochet/pattern.html:126
|
||||
msgid "Orientación"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:117
|
||||
#: crochet/templates/crochet/pattern.html:128
|
||||
msgid "Vertical"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:118
|
||||
#: crochet/templates/crochet/pattern.html:129
|
||||
msgid "Horizontal"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:126
|
||||
#: crochet/templates/crochet/pattern.html:134
|
||||
#: crochet/templates/crochet/pattern.html:137
|
||||
#: crochet/templates/crochet/pattern.html:145
|
||||
msgid "Secciones"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:127
|
||||
#: crochet/templates/crochet/pattern.html:173
|
||||
#: crochet/templates/crochet/pattern.html:138
|
||||
#: crochet/templates/crochet/pattern.html:184
|
||||
msgid "Vista previa"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:136
|
||||
#: crochet/templates/crochet/pattern.html:147
|
||||
msgid "Colapsar todo"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:136
|
||||
#: crochet/templates/crochet/pattern.html:147
|
||||
msgid "Expandir todo"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:141
|
||||
#: crochet/templates/crochet/pattern.html:152
|
||||
msgid "Añadir sección"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:144
|
||||
#: crochet/templates/crochet/pattern.html:155
|
||||
msgid "Título"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:144
|
||||
#: crochet/templates/crochet/pattern.html:155
|
||||
msgid "Título..."
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:146
|
||||
#: crochet/templates/crochet/pattern.html:157
|
||||
msgid "Subtítulo"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:146
|
||||
#: crochet/templates/crochet/pattern.html:157
|
||||
msgid "Subtítulo..."
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:148
|
||||
#: crochet/templates/crochet/pattern.html:159
|
||||
msgid "Texto"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:148
|
||||
#: crochet/templates/crochet/pattern.html:159
|
||||
msgid "Escribe aquí..."
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:150
|
||||
#: crochet/templates/crochet/pattern.html:151
|
||||
#: crochet/templates/crochet/pattern.html:161
|
||||
#: crochet/templates/crochet/pattern.html:162
|
||||
msgid "Nota"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:150
|
||||
#: crochet/templates/crochet/pattern.html:161
|
||||
msgid "Escribe una nota o consejo..."
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:153
|
||||
#: crochet/templates/crochet/pattern.html:164
|
||||
msgid "Materiales"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:153
|
||||
#: crochet/templates/crochet/pattern.html:164
|
||||
msgid "Añadir material..."
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:153
|
||||
#: crochet/templates/crochet/pattern.html:164
|
||||
msgid "Añadir línea"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:155
|
||||
#: crochet/templates/crochet/pattern.html:157
|
||||
#: crochet/templates/crochet/pattern.html:166
|
||||
#: crochet/templates/crochet/pattern.html:168
|
||||
msgid "Imagen"
|
||||
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."
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:159
|
||||
#: crochet/templates/crochet/pattern.html:170
|
||||
msgid "Patrón"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:159
|
||||
#: crochet/templates/crochet/pattern.html:170
|
||||
msgid "Elementos"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:161
|
||||
#: crochet/templates/crochet/pattern.html:172
|
||||
msgid "Grupo"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:165
|
||||
#: crochet/templates/crochet/pattern.html:176
|
||||
msgid "Arrastrar para reordenar"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:166
|
||||
#: crochet/templates/crochet/pattern.html:177
|
||||
msgid "Colapsar / expandir"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:167
|
||||
#: crochet/templates/crochet/pattern.html:178
|
||||
msgid "Duplicar sección"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:168
|
||||
#: crochet/templates/crochet/pattern.html:179
|
||||
msgid "Eliminar sección"
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:169
|
||||
#: crochet/templates/crochet/pattern.html:180
|
||||
msgid ""
|
||||
"¿Seguro que quieres eliminar este grupo? Se eliminarán también todas las "
|
||||
"secciones que contiene."
|
||||
msgstr ""
|
||||
|
||||
#: crochet/templates/crochet/pattern.html:175
|
||||
#: crochet/templates/crochet/pattern.html:186
|
||||
msgid "Añade una sección para ver aquí el resultado."
|
||||
msgstr ""
|
||||
|
||||
@@ -323,51 +331,55 @@ msgstr ""
|
||||
|
||||
# Rutas traducibles de crochet/urls.py: la base "pattern/" se traduce como
|
||||
# "patron/" (sin tilde, para no meter caracteres acentuados en la URL).
|
||||
#: crochet/urls.py:30
|
||||
#: crochet/urls.py:31
|
||||
msgid "pattern/<uuid:uuid>/"
|
||||
msgstr "patron/<uuid:uuid>/"
|
||||
|
||||
#: crochet/urls.py:31
|
||||
#: crochet/urls.py:32
|
||||
msgid "pattern/<uuid:uuid>/edit/"
|
||||
msgstr "patron/<uuid:uuid>/editar/"
|
||||
|
||||
#: crochet/urls.py:32
|
||||
#: crochet/urls.py:33
|
||||
msgid "pattern/<uuid:uuid>/save/"
|
||||
msgstr "patron/<uuid:uuid>/guardar/"
|
||||
|
||||
#: crochet/urls.py:33
|
||||
#: crochet/urls.py:34
|
||||
msgid "pattern/<uuid:uuid>/images/"
|
||||
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/"
|
||||
msgstr "patron/<uuid:uuid>/pdf/"
|
||||
|
||||
#: crochet/urls.py:35
|
||||
#: crochet/urls.py:37
|
||||
msgid "pattern/<uuid:uuid>/delete/"
|
||||
msgstr "patron/<uuid:uuid>/eliminar/"
|
||||
|
||||
#: crochet/urls.py:36
|
||||
#: crochet/urls.py:38
|
||||
msgid "pattern/new/"
|
||||
msgstr "patron/nuevo/"
|
||||
|
||||
# Igual que "pattern/" arriba: "account/" se traduce como "cuenta/".
|
||||
#: crochet/urls.py:37
|
||||
#: crochet/urls.py:39
|
||||
msgid "account/"
|
||||
msgstr "cuenta/"
|
||||
|
||||
#: crochet/urls.py:38
|
||||
#: crochet/urls.py:40
|
||||
msgid "account/register/"
|
||||
msgstr "cuenta/registro/"
|
||||
|
||||
#: crochet/urls.py:40
|
||||
#: crochet/urls.py:42
|
||||
msgid "account/login/"
|
||||
msgstr "cuenta/entrar/"
|
||||
|
||||
#: crochet/urls.py:45
|
||||
#: crochet/urls.py:47
|
||||
msgid "account/logout/"
|
||||
msgstr "cuenta/salir/"
|
||||
|
||||
#: crochet/views.py:23
|
||||
#: crochet/views.py:24
|
||||
msgid "Este patrón todavía no tiene contenido."
|
||||
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/'),
|
||||
),
|
||||
]
|
||||
@@ -22,6 +22,20 @@ class Pattern(models.Model):
|
||||
created_at = models.DateTimeField(auto_now_add=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):
|
||||
return self.page_settings.get('title') or str(self.uuid)
|
||||
|
||||
|
||||
@@ -138,18 +138,22 @@ def _render_section(section, lang, stitch_types_by_id):
|
||||
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
|
||||
para `lang`, en el mismo formato que genera renderOutput() en el
|
||||
editor: título/autor del documento primero, y luego cada sección de
|
||||
nivel superior en orden. `page_settings` debe venir ya validado por
|
||||
sanitize_page_settings()."""
|
||||
editor: título, portada y autor del documento primero, y luego cada
|
||||
sección de nivel superior en orden. `page_settings` debe venir ya
|
||||
validado por sanitize_page_settings()."""
|
||||
stitch_types_by_id = {st['id']: st for st in stitch_types}
|
||||
|
||||
parts = [
|
||||
_line('h1', page_settings['title'], 'text-[1.5em] font-bold mb-1'),
|
||||
_line('p', page_settings['author'], 'text-[0.875em] opacity-70 mb-2'),
|
||||
]
|
||||
parts = [_line('h1', page_settings['title'], 'text-[1.5em] font-bold mb-1')]
|
||||
# Mismas clases que _render_image() (misma pinta que una imagen de
|
||||
# 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:
|
||||
parts.append(format_html('<p class="opacity-50 italic">{0}</p>', empty_message))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -22,3 +22,9 @@ export const pageAlignSelect = document.getElementById('page-align-select');
|
||||
export const pageSizeSelect = document.getElementById('page-size-select');
|
||||
export const pageOrientationSelect = document.getElementById('page-orientation-select');
|
||||
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".
|
||||
|
||||
@@ -8,6 +8,7 @@ import { applyDefaultSkeleton, buildSectionsFrom } from './section-types.js';
|
||||
import { setAllSectionsCollapsed } from './sections.js';
|
||||
import './tabs.js';
|
||||
import './storage.js';
|
||||
import './cover-image.js';
|
||||
|
||||
// Mismo punto de corte que .panel-hidden-mobile (ver css/main.css).
|
||||
const MOBILE_BREAKPOINT_QUERY = '(max-width: 767px)';
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// repintarlo: al escribir, al cambiar de idioma, o al añadir/quitar/
|
||||
// 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 { renderOutputSection, appendOutputLine } from './section-types.js';
|
||||
|
||||
@@ -12,13 +12,25 @@ import { renderOutputSection, appendOutputLine } from './section-types.js';
|
||||
export function renderOutput() {
|
||||
outputText.innerHTML = '';
|
||||
|
||||
// Título y autor del patrón (ver "Personalización de página"): son
|
||||
// configuración de todo el documento, no una sección más, así que se
|
||||
// Título, portada y autor del patrón (ver "Personalización de página"):
|
||||
// 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.
|
||||
// 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
|
||||
// page-config.js): "rem" ignora el font-size de cualquier ancestro.
|
||||
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');
|
||||
|
||||
// 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">
|
||||
{% for pattern in patterns %}
|
||||
<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">
|
||||
<h2 class="card-title text-lg">
|
||||
{% if pattern.page_settings.title %}{{ pattern.page_settings.title }}{% else %}{% trans 'Patrón sin título' %}{% endif %}
|
||||
|
||||
@@ -62,6 +62,23 @@
|
||||
<input id="page-author-input" type="text" class="input input-bordered" placeholder="{% trans 'Autor...' %}">
|
||||
</label>
|
||||
</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">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-sm">{% trans 'Color del texto' %}</span>
|
||||
|
||||
@@ -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)
|
||||
@@ -74,13 +74,14 @@ class SanitizePageSettingsTests(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(
|
||||
sections,
|
||||
page_settings or DEFAULT_PAGE_SETTINGS,
|
||||
lang,
|
||||
stitch_types or [],
|
||||
'Este patrón todavía no tiene contenido.',
|
||||
cover_image_url=cover_image_url,
|
||||
)
|
||||
|
||||
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('<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):
|
||||
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)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import translation
|
||||
@@ -61,6 +63,18 @@ class PatternEditViewTests(TestCase):
|
||||
|
||||
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):
|
||||
with translation.override('es'):
|
||||
url = reverse('crochet:pattern_edit', args=[self.pattern.uuid])
|
||||
@@ -110,6 +124,18 @@ class PatternDetailViewTests(TestCase):
|
||||
self.assertContains(response, 'Ampharos')
|
||||
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):
|
||||
with translation.override('es'):
|
||||
url = reverse('crochet:pattern_detail', args=[self.pattern.uuid])
|
||||
@@ -291,6 +317,73 @@ class PatternImageUploadViewTests(TestCase):
|
||||
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):
|
||||
def setUp(self):
|
||||
self.owner = User.objects.create_user(username='crocheter', password='a-very-uncommon-pw-1')
|
||||
|
||||
@@ -5,6 +5,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from crochet.forms import StyledAuthenticationForm
|
||||
from crochet.views import (
|
||||
AccountHomeView,
|
||||
PatternCoverImageUploadView,
|
||||
PatternCreateView,
|
||||
PatternDeleteView,
|
||||
PatternDetailView,
|
||||
@@ -31,6 +32,7 @@ urlpatterns = [
|
||||
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>/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>/delete/'), name='pattern_delete', view=PatternDeleteView.as_view()),
|
||||
path(_('pattern/new/'), name='pattern_create', view=PatternCreateView.as_view()),
|
||||
|
||||
@@ -13,6 +13,7 @@ from django.utils.translation import get_language, gettext_lazy as _
|
||||
from django.views import View
|
||||
from django.views.generic import CreateView, DetailView, TemplateView
|
||||
|
||||
from crochet.cover_image import InvalidCoverImage, build_cover_image_variants
|
||||
from crochet.forms import StyledUserCreationForm
|
||||
from crochet.models import Pattern, PatternImage, StitchType
|
||||
from crochet.pattern_render import render_pattern_html, sanitize_page_settings
|
||||
@@ -57,6 +58,7 @@ def _pattern_detail_context(pattern):
|
||||
'page_settings': page_settings,
|
||||
'rendered_pattern': render_pattern_html(
|
||||
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})
|
||||
|
||||
|
||||
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):
|
||||
# UserCreationForm ya pide justo nombre de usuario + contraseña (dos
|
||||
# veces, para confirmarla): no hace falta un formulario propio, solo la
|
||||
|
||||
Reference in New Issue
Block a user