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