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):
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
+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 ""
"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."
+82 -70
View File
@@ -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/'),
),
]
+14
View File
@@ -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)
+12 -8
View File
@@ -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))
+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 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".
+1
View File
@@ -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)';
+15 -3
View File
@@ -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 %}
+17
View File
@@ -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>
+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):
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)
+93
View File
@@ -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')
+2
View File
@@ -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()),
+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.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