Compare commits

15 Commits

Author SHA1 Message Date
pablo db21129960 fix: optimize Dockerfile
CI / test (push) Successful in 1m28s
CI / build (push) Successful in 57s
2026-07-22 13:32:54 +02:00
pablo 58df77b286 fix: UI tweaks
CI / test (push) Successful in 1m28s
CI / build (push) Successful in 59s
2026-07-22 12:54:36 +02:00
pablo 8920047c55 feat: improved UI
CI / test (push) Successful in 1m28s
CI / build (push) Successful in 29s
2026-07-22 11:26:57 +02:00
pablo 1a1c8786a3 feat: added delete account and new display name
CI / test (push) Successful in 1m28s
CI / build (push) Successful in 29s
2026-07-22 10:03:30 +02:00
pablo 2839d60923 fix: change pattern detail view
CI / test (push) Successful in 1m17s
CI / build (push) Successful in 27s
2026-07-21 15:01:28 +02:00
pablo 1ecca9362f feat: setup cookies
CI / test (push) Successful in 1m8s
CI / build (push) Successful in 27s
2026-07-21 14:14:40 +02:00
pablo d43bbbb5f2 feat: send email to warn user about password change
CI / test (push) Successful in 1m8s
CI / build (push) Failing after 37s
2026-07-21 14:04:28 +02:00
pablo 6b5c353a86 feat: warn user about email change
CI / test (push) Successful in 1m5s
CI / build (push) Successful in 37s
2026-07-21 13:44:56 +02:00
pablo 3e95ee2239 feat: added recover password view
CI / test (push) Successful in 1m3s
CI / build (push) Successful in 53s
2026-07-21 12:47:01 +02:00
pablo 2f58e23cd2 fix: remove sign up button
CI / test (push) Successful in 58s
CI / build (push) Successful in 27s
2026-07-20 13:00:18 +02:00
pablo 1a77cd77fb feat: improved UX
CI / test (push) Successful in 58s
CI / build (push) Successful in 28s
2026-07-20 12:55:19 +02:00
pablo 55186a0891 fix: improved language selector
CI / test (push) Successful in 1m0s
CI / build (push) Successful in 28s
2026-07-20 12:38:33 +02:00
pablo 73109a268b feat: improved UX
CI / test (push) Successful in 58s
CI / build (push) Successful in 29s
2026-07-20 11:21:29 +02:00
pablo 96738bf7d5 feat: added landing page with CTA
CI / test (push) Successful in 48s
CI / build (push) Successful in 27s
2026-07-17 13:56:54 +02:00
pablo f722f0765e feat: added cover image to pattern
CI / test (push) Successful in 45s
CI / build (push) Successful in 27s
2026-07-17 13:48:34 +02:00
69 changed files with 3716 additions and 456 deletions
+43
View File
@@ -0,0 +1,43 @@
# Sin esto, "COPY . /code" en el Dockerfile arrastra todo lo de abajo tal
# cual esté en el directorio de quien construya la imagen (.gitignore no
# aplica aquí, Docker no lo lee): en concreto .venv/, db.sqlite3 y .tools/
# (el binario de Tailwind, ~100MB) prácticamente doblaban el tamaño de la
# imagen final, y .env podía acabar filtrando credenciales reales en una
# capa de la imagen.
.git/
.gitea/
# Entorno virtual local: la imagen instala sus propias dependencias en el
# stage "builder" del Dockerfile, no necesita este.
.venv/
env/
venv/
ENV/
# Secretos y estado local: nunca deben acabar horneados en una capa de la
# imagen (quedarían ahí aunque se borren en una capa posterior).
.env
db.sqlite3
db.sqlite3-journal
media/
# CLI de Tailwind descargado por build_pattern_detail_css y su CSS de
# entrada generado: solo hacen falta durante el build (ver scripts/build.sh,
# que además los borra al terminar), nunca en tiempo de ejecución.
.tools/
# Generado dentro de la propia imagen por scripts/build.sh (collectstatic);
# si existe localmente, collectstatic lo sobrescribe de todos modos.
config/static/
__pycache__/
*.pyc
.pytest_cache/
.coverage
.coverage.*
htmlcov/
.idea/
.vscode/
.claude/
+1 -8
View File
@@ -20,13 +20,6 @@ jobs:
git checkout ${{ gitea.sha }}
- name: Install system dependencies
# libpango/libpangocairo + fuentes: las necesita WeasyPrint en
# tiempo de ejecución para generar el PDF del patrón (ver
# PatternPdfView y crochet/tests/test_pdf.py); sin fuentes
# instaladas, Pango no tiene nada que medir/dibujar y WeasyPrint
# termina haciendo segfault en vez de fallar con un error legible
# (mismo motivo por el que el Dockerfile instala pango + ttf-dejavu
# en Alpine).
run: |
apt-get update -qq
apt-get install -y --no-install-recommends libpango-1.0-0 libpangocairo-1.0-0 fonts-dejavu-core gettext
@@ -41,7 +34,7 @@ jobs:
run: uv run python manage.py compilemessages
- name: Run tests
run: uv run pytest
run: uv run pytest --cov
build:
runs-on: ubuntu-latest
+20 -3
View File
@@ -21,11 +21,28 @@ RUN addgroup -S ${user} && adduser -S ${user} -G ${user} -u ${uid} -s /bin/sh
# poder generar el PDF del patrón (ver PatternPdfView); sin fuentes
# instaladas, Pango no tiene nada que medir/dibujar y WeasyPrint termina
# haciendo segfault en vez de fallar con un error legible.
RUN apk update && apk add gcc gettext vim libpq-dev pango ttf-dejavu
# gettext: msgfmt, para compilemessages en build.sh.
# Sin gcc ni libpq-dev: psycopg[binary] (ver pyproject.toml) trae su propio
# libpq ya compilado dentro de la wheel (hay una para musllinux_x86_64,
# justo lo que necesita esta imagen), así que no hace falta ni compilar
# nada ni que psycopg encuentre una libpq del sistema en tiempo de
# ejecución. Probado a mano (build+run real): "import psycopg" funciona
# sin ninguno de los dos.
RUN apk add --no-cache gettext pango ttf-dejavu
RUN chown -R ${user}:${user} /code
USER ${user}
COPY --chown=${user}:${user} . /code
RUN ["sh", "./scripts/build.sh"]
# libstdc++/libgcc: solo los necesita en tiempo de ejecución el binario de
# Tailwind (ver build_pattern_detail_css, dentro de build.sh) para generar
# pattern-detail.css, no la app en sí; "--virtual" + "apk del" en la misma
# capa para que no se queden en la imagen final (build.sh además borra el
# propio binario de Tailwind al terminar, ver scripts/build.sh). build.sh
# se ejecuta como ${user} (no root), aunque este RUN empiece como root
# para poder instalar/desinstalar el paquete virtual.
RUN apk add --no-cache --virtual .tailwind-runtime libstdc++ libgcc \
&& su ${user} -c "sh ./scripts/build.sh" \
&& apk del .tailwind-runtime
USER ${user}
CMD ["sh", "./scripts/run.sh"]
+180
View File
@@ -0,0 +1,180 @@
# Crochet
Aplicación web para diseñar patrones de crochet con un editor visual,
exportarlos a PDF y compartirlos con un enlace de solo lectura. Incluye
cuentas de usuario para guardar y gestionar los propios patrones.
## Funcionalidades
- **Editor visual de patrones**: secciones de título, subtítulo, texto,
nota, materiales, imágenes y "patrón" (puntos con contador), todo
reordenable por arrastre. El contenido se guarda bilingüe (es/en).
- **Personalización de página**: título, autor, imagen de portada, color de
texto/acento/fondo, tipografía, tamaño de letra, alineación, tamaño y
orientación de página.
- **Exportación a PDF** con WeasyPrint, usando el propio diálogo de
impresión del navegador.
- **Vista de solo lectura compartible** (`pattern/<uuid>/`), sin necesidad
de cuenta ni de cargar el editor.
- **Cuentas de usuario**: registro (con email, necesario para poder
recuperar la contraseña), login/logout, ajustes de cuenta (cambiar
email/contraseña, con HTMX) y recuperación de contraseña por email.
Cada patrón pertenece a quien lo creó; solo su propietario puede
editarlo o borrarlo.
- **"Mis patrones"**: listado de los patrones propios con portada, fecha de
actualización y accesos directos a editar/ver/eliminar.
- **i18n completo** (español/inglés): interfaz, URLs traducidas
(`/es/patron/...` frente a `/en/pattern/...`) y contenido del propio
patrón.
## Stack técnico
- **Backend**: Django 6, servido en ASGI con Uvicorn.
- **Base de datos**: PostgreSQL en producción, SQLite por defecto en
desarrollo (configurable con `DATABASE_URL`).
- **Frontend del editor**: JavaScript vanilla (sin framework ni bundler),
daisyUI + Tailwind CSS vía CDN con compilador JIT.
- **Vista de solo lectura / PDF**: Tailwind precompilado sin JS (WeasyPrint
no ejecuta JavaScript), generado con el CLI standalone de Tailwind (ver
`python manage.py build_pattern_detail_css`).
- **Imágenes**: Pillow (variantes de la portada del patrón en varios
tamaños), almacenamiento en filesystem o S3 (`django-storages`, opcional).
- **Estáticos**: WhiteNoise con manifest comprimido.
- **HTMX** para los formularios de ajustes de cuenta.
- **Gestión de dependencias**: [uv](https://docs.astral.sh/uv/).
## Desarrollo
### Requisitos
- Python 3.13
- [uv](https://docs.astral.sh/uv/)
- Librerías nativas de WeasyPrint (Pango, Cairo, GDK-Pixbuf) si vas a
generar/probar el PDF fuera de Docker — en Debian/Ubuntu:
`apt-get install libpango-1.0-0 libpangocairo-1.0-0 fonts-dejavu-core`.
- `gettext` si vas a regenerar/compilar traducciones
(`msgfmt`/`msguniq`/etc.) — en Debian/Ubuntu: `apt-get install gettext`.
### Puesta en marcha
```bash
git clone <url-del-repositorio>
cd crochet
uv sync --group dev
```
Crea un archivo `.env` en la raíz del proyecto (ver
[Variables de entorno](#variables-de-entorno) más abajo). Para desarrollo
local basta con:
```env
DEBUG=True
```
Con eso, la app arranca con SQLite, `SECRET_KEY` de desarrollo, envío de
email al backend de consola (se imprime en la terminal en vez de mandarse
de verdad) y sin credenciales adicionales.
```bash
uv run python manage.py migrate
uv run python manage.py createsuperuser # opcional, para /admin/
uv run python manage.py runserver
```
La app queda disponible en `http://localhost:8000/`.
### Tests
```bash
uv run pytest
```
`pytest-cov` está incluido; para ver cobertura: `uv run pytest --cov`.
### Traducciones
Las cadenas ya traducidas están compiladas y listas para usar. Si añades o
cambias texto traducible (`{% trans %}`/`{% blocktrans %}`, `gettext_lazy`):
```bash
uv run python manage.py makemessages -l es -l en
# revisa a mano cualquier entrada marcada como "#, fuzzy" antes de compilar
uv run python manage.py compilemessages
```
### CSS precompilado de la vista de solo lectura / PDF
Solo hace falta volver a generarlo si cambian las clases de Tailwind
usadas en `pattern_detail.html` o `pattern_render.py`:
```bash
uv run python manage.py build_pattern_detail_css
```
### Docker
```bash
docker build -t crochet .
docker run --rm -p 8000:8000 --env-file .env crochet
```
El `Dockerfile` instala las dependencias del sistema necesarias para
WeasyPrint y ejecuta `scripts/build.sh` (CSS precompilado, `collectstatic`,
`compilemessages`) al construir la imagen, y `scripts/run.sh` (Uvicorn) al
arrancar el contenedor.
## Variables de entorno
Todas se leen en `config/settings/env.py`; sin `.env`, o si falta alguna,
se usan los valores por defecto indicados (pensados para desarrollo local).
| Variable | Por defecto | Descripción |
|---|---|---|
| `SECRET_KEY` | clave insegura de desarrollo | **Cámbiala en producción.** |
| `DEBUG` | `False` | Activa páginas de error detalladas, Django Debug Toolbar y Silk. No debe estar activo en producción. |
| `ALLOWED_HOSTS` | `*` | Lista separada por comas. Debe restringirse a los dominios reales en producción. |
| `CSRF_TRUSTED_ORIGINS` | `http://localhost:8000` | Lista separada por comas. Necesario si la app se sirve detrás de un dominio/HTTPS distinto. |
| `DATABASE_URL` | `sqlite:///db.sqlite3` | Formato `django-environ` (p. ej. `postgres://usuario:password@host:5432/nombre_bd`). |
| `S3_ENABLED` | `False` | Si es `True`, los archivos subidos (imágenes de portada/patrón) se guardan en S3 en vez de en el filesystem local. |
| `S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY` / `S3_STORAGE_BUCKET_NAME` / `S3_ENDPOINT_URL` | vacío | Credenciales del bucket, solo necesarias si `S3_ENABLED=True`. |
| `EMAIL_BACKEND` | backend de consola | Usa `django.core.mail.backends.smtp.EmailBackend` para enviar emails de verdad (recuperación de contraseña). |
| `EMAIL_HOST` / `EMAIL_HOST_USER` / `EMAIL_HOST_PASSWORD` / `EMAIL_PORT` | vacío / vacío / vacío / `587` | Credenciales SMTP, solo con el backend SMTP. |
| `DEFAULT_FROM_EMAIL` | `webmaster@localhost` | Remitente de los emails salientes. La mayoría de proveedores SMTP rechazan enviar si no coincide con la cuenta autenticada (o un alias verificado). |
| `SUPPORT_EMAIL` | `soporte@localhost` | Dirección de contacto que se muestra en el aviso que recibe el email anterior de una cuenta cuando alguien lo cambia (ver `EmailUpdateForm`). |
| `LOG_LEVEL` | `INFO` | Nivel de log de Django y de la app (`crochet`). |
Existen además `CORS_ORIGIN_WHITELIST`, `REDIS_HOST`, `REDIS_PORT`,
`CELERY_BROKER_URL`, `PAGE_SIZE` e `ITEMS_PER_PAGE` en `env.py`: quedaron de
una plantilla de proyecto y actualmente no los usa ninguna parte de la
aplicación (no hay Celery, cachés en Redis, CORS ni paginación
configurados), así que no hace falta definirlos para desplegar.
## Despliegue a producción
Como mínimo hay que ajustar, respecto al `.env` de desarrollo:
1. **`SECRET_KEY`**: un valor único y secreto (no el de desarrollo).
2. **`DEBUG=False`**.
3. **`ALLOWED_HOSTS`**: los dominios reales, no `*`.
4. **`CSRF_TRUSTED_ORIGINS`**: los orígenes reales (con esquema, p. ej.
`https://patrones.ejemplo.com`).
5. **`DATABASE_URL`**: apuntando a PostgreSQL.
6. **`EMAIL_BACKEND`/`EMAIL_HOST`/`EMAIL_HOST_USER`/`EMAIL_HOST_PASSWORD`/`DEFAULT_FROM_EMAIL`**:
sin esto, el registro de usuarios funciona pero la recuperación de
contraseña no llega a enviarse (se queda en el log en vez de salir por
SMTP).
7. **Almacenamiento de archivos**: si vas a correr más de una instancia o
quieres que las imágenes sobrevivan a un redeploy del contenedor,
configura `S3_ENABLED=True` y las credenciales de `S3_*` — el
filesystem local (por defecto) no es compartido ni persistente entre
despliegues.
La imagen Docker ya ejecuta `collectstatic`, `compilemessages` y el
CSS precompilado al construirse, y sirve la app con Uvicorn (variables
`WSGI_HOST`/`WSGI_WORKERS` para ajustar host/nº de workers). WhiteNoise se
encarga de servir los estáticos directamente desde la propia app, sin
necesidad de un servidor/proxy de estáticos aparte.
Al hacer push a `master` se dispara la integración continua (tests) y,
si pasan, se construye y publica una imagen Docker (ver
`.gitea/workflows/test-build.yaml`).
+2 -27
View File
@@ -99,7 +99,7 @@ DATABASES = {
# https://docs.djangoproject.com/en/6.0/topics/auth/default/#all-authentication-views
LOGIN_URL = 'crochet:login'
LOGIN_REDIRECT_URL = 'crochet:account_home'
LOGIN_REDIRECT_URL = 'crochet:home'
LOGOUT_REDIRECT_URL = 'crochet:login'
@@ -146,9 +146,6 @@ USE_TZ = True
STATIC_URL = 'static/'
STATIC_ROOT = 'config/static'
# CompressedManifestStaticFilesStorage (WhiteNoise): sirve cada archivo ya
# comprimido (gzip/brotli) y con hash en el nombre para poder cachearlos
# "para siempre" sin arriesgarse a servir una versión vieja tras un deploy.
STORAGES = {
'default': {
'BACKEND': 'django.core.files.storage.FileSystemStorage',
@@ -161,8 +158,6 @@ STORAGES = {
if S3_ENABLED:
STORAGES['default'] = {'BACKEND': 'storages.backends.s3boto3.S3Boto3Storage'}
# Archivos subidos por el usuario (imágenes de patrón), separados de los
# estáticos del propio proyecto.
MEDIA_URL = 'media/'
MEDIA_ROOT = BASE_DIR / 'media'
@@ -173,14 +168,6 @@ EMAIL_USE_SSL = False
# Logging
# https://docs.djangoproject.com/en/6.0/topics/logging/
# Se define entero (en vez de dejar el DEFAULT_LOGGING de Django) porque ese
# default, con DEBUG=False, solo engancha un handler de consola cuando
# DEBUG=True y manda los errores (django.request) por email a ADMINS cuando
# DEBUG=False; aquí ni ADMINS ni un EMAIL_HOST de verdad están configurados
# (ver env.py), así que en producción los 500 no se veían en ningún sitio.
# Con un handler de consola sin ese filtro, cualquier error queda en
# stdout/stderr, que es lo que recogen los logs del contenedor (`docker logs`,
# lo que agregue la plataforma de despliegue, etc.).
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
@@ -196,11 +183,6 @@ LOGGING = {
'formatter': 'verbose',
},
},
# A WARNING fijo (no LOG_LEVEL): captura cualquier logger que no esté
# listado abajo (de terceros: weasyprint, whitenoise, PIL...) para que
# nada se pierda del todo, pero sin su parloteo en INFO/DEBUG -algunas
# librerías, como weasyprint, ya avisan cosas en INFO solo con
# importarlas-. 'django' y 'crochet' sí usan LOG_LEVEL, más abajo.
'root': {
'handlers': ['console'],
'level': 'WARNING',
@@ -211,11 +193,7 @@ LOGGING = {
'level': LOG_LEVEL,
'propagate': False,
},
# Aquí es donde Django registra la traza completa de cualquier
# excepción no controlada en una vista (un 500): sin esto configurado
# explícitamente, en producción (DEBUG=False) solo se intentaba
# mandar por email a ADMINS, no configurado.
'django.request': {
'django.request': {
'handlers': ['console'],
'level': 'ERROR',
'propagate': False,
@@ -225,9 +203,6 @@ LOGGING = {
'level': 'WARNING',
'propagate': False,
},
# Logger propio de la app, para poder hacer
# logging.getLogger(__name__) desde cualquier módulo de crochet/ sin
# tener que tocar esta configuración cada vez.
'crochet': {
'handlers': ['console'],
'level': LOG_LEVEL,
+5 -5
View File
@@ -43,10 +43,10 @@ EMAIL_HOST = env.str('EMAIL_HOST', '')
EMAIL_HOST_USER = env.str('EMAIL_HOST_USER', '')
EMAIL_HOST_PASSWORD = env.str('EMAIL_HOST_PASSWORD', '')
EMAIL_PORT = env.int('EMAIL_PORT', 587)
DEFAULT_FROM_EMAIL = env.str('DEFAULT_FROM_EMAIL', 'webmaster@localhost')
# A dónde se remite al usuario en el aviso de cambio de email (ver
# EmailUpdateForm) por si el cambio no lo ha hecho él.
SUPPORT_EMAIL = env.str('SUPPORT_EMAIL', 'soporte@localhost')
# Nivel de log de la app y de Django (ver LOGGING en base.py). En producción
# (DEBUG=False) Django, por defecto, solo manda los errores por email a
# ADMINS (que aquí no está configurado), así que sin un LOGGING propio no se
# ve ni un rastro de un 500 en ningún sitio; con esto van a stdout/stderr,
# que es lo que recogen los logs del contenedor.
LOG_LEVEL = env.str('LOG_LEVEL', 'INFO')
+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
+145 -1
View File
@@ -1,4 +1,15 @@
from django.contrib.auth.forms import AuthenticationForm, UserCreationForm
from django import forms
from django.conf import settings
from django.contrib.auth.forms import (
AuthenticationForm,
PasswordChangeForm,
PasswordResetForm,
SetPasswordForm,
UserCreationForm,
)
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.utils.translation import gettext_lazy as _
# Los formularios de auth de Django no traen ninguna clase CSS en sus
# widgets (piensan en HTML sin estilar): se añade aquí la clase de daisyUI
@@ -8,6 +19,21 @@ from django.contrib.auth.forms import AuthenticationForm, UserCreationForm
INPUT_CLASSES = 'input input-bordered w-full'
def _send_account_notice(subject_template, text_template, html_template, context, to_email):
# Compartido por los avisos de seguridad de la cuenta (cambio de email,
# cambio de contraseña): mismo esqueleto de email (texto plano +
# alternativa HTML de crochet/email/base.html), solo cambian las
# plantillas y el destinatario.
subject = ''.join(render_to_string(subject_template, context).splitlines())
send_mail(
subject=subject,
message=render_to_string(text_template, context),
from_email=None,
recipient_list=[to_email],
html_message=render_to_string(html_template, context),
)
class StyledAuthenticationForm(AuthenticationForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -16,7 +42,125 @@ class StyledAuthenticationForm(AuthenticationForm):
class StyledUserCreationForm(UserCreationForm):
# UserCreationForm no pide email por defecto (piensa solo en
# usuario/contraseña); sin él, PasswordResetForm no tiene a qué
# dirección mandar el enlace de recuperación de estos usuarios.
email = forms.EmailField(label=_('Email'))
class Meta(UserCreationForm.Meta):
fields = ('username', 'email')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field in self.fields.values():
field.widget.attrs['class'] = INPUT_CLASSES
class StyledPasswordChangeForm(PasswordChangeForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field in self.fields.values():
field.widget.attrs['class'] = INPUT_CLASSES
def save(self, commit=True):
user = super().save(commit=commit)
# A diferencia del cambio de email, aquí no hay una dirección
# "anterior" distinta: el aviso va al propio email de la cuenta.
# Sin email no hay a quién avisar (cuentas antiguas sin uno).
if user.email:
context = {'user': user, 'site_name': 'Crochet', 'support_email': settings.SUPPORT_EMAIL}
_send_account_notice(
'crochet/email/password_changed_subject.txt',
'crochet/email/password_changed_notice.txt',
'crochet/email/password_changed_notice.html',
context, user.email,
)
return user
class StyledPasswordResetForm(PasswordResetForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field in self.fields.values():
field.widget.attrs['class'] = INPUT_CLASSES
class StyledSetPasswordForm(SetPasswordForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field in self.fields.values():
field.widget.attrs['class'] = INPUT_CLASSES
class EmailUpdateForm(forms.Form):
email = forms.EmailField(label=_('Email'), widget=forms.EmailInput(attrs={'class': INPUT_CLASSES}))
def __init__(self, *args, user, **kwargs):
self.user = user
super().__init__(*args, **kwargs)
def save(self):
old_email = self.user.email
self.user.email = self.cleaned_data['email']
self.user.save(update_fields=['email'])
# Aviso a la dirección ANTERIOR (no a la nueva): así, si alguien más
# ha cambiado el email de la cuenta (sesión robada, dispositivo
# compartido...), quien de verdad era el dueño se entera por donde
# todavía puede leerlo. Si no había email antes, o si "cambiar" no
# ha cambiado nada, no hay a quién avisar ni de qué.
if old_email and old_email != self.user.email:
context = {'user': self.user, 'old_email': old_email, 'site_name': 'Crochet', 'support_email': settings.SUPPORT_EMAIL}
_send_account_notice(
'crochet/email/email_changed_subject.txt',
'crochet/email/email_changed_notice.txt',
'crochet/email/email_changed_notice.html',
context, old_email,
)
class DisplayNameForm(forms.Form):
# Se guarda en el propio first_name de auth.User (no se usa para nada
# más en la app): no hace falta un modelo de perfil aparte solo para un
# campo de texto opcional. Se propone como autor por defecto al crear
# un patrón nuevo (ver PatternCreateView) y se muestra en el navbar/
# saludo de "Mis patrones" en vez del username, si está puesto.
display_name = forms.CharField(
label=_('Nombre para mostrar'), required=False, max_length=150,
widget=forms.TextInput(attrs={'class': INPUT_CLASSES}),
)
def __init__(self, *args, user, **kwargs):
self.user = user
super().__init__(*args, **kwargs)
def save(self):
self.user.first_name = self.cleaned_data['display_name']
self.user.save(update_fields=['first_name'])
class AccountDeleteForm(forms.Form):
password = forms.CharField(
label=_('Contraseña'), widget=forms.PasswordInput(attrs={'class': INPUT_CLASSES}),
)
def __init__(self, *args, user, **kwargs):
self.user = user
super().__init__(*args, **kwargs)
def clean_password(self):
password = self.cleaned_data['password']
if not self.user.check_password(password):
raise forms.ValidationError(_('Contraseña incorrecta.'))
return password
def notify_account_deleted(self):
# Antes de borrar la cuenta (ver AccountDeleteView), mientras el
# email todavía existe. Sin email no hay a quién avisar.
if self.user.email:
context = {'user': self.user, 'site_name': 'Crochet', 'support_email': settings.SUPPORT_EMAIL}
_send_account_notice(
'crochet/email/account_deleted_subject.txt',
'crochet/email/account_deleted_notice.txt',
'crochet/email/account_deleted_notice.html',
context, self.user.email,
)
+575 -104
View File
@@ -2,12 +2,85 @@ 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-22 08:53+0000\n"
"Language: en\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: crochet/forms.py:48 crochet/forms.py:96
#: crochet/templates/crochet/account_settings.html:49
msgid "Email"
msgstr "Email"
#: crochet/forms.py:128 crochet/templates/crochet/account_settings.html:40
msgid "Nombre para mostrar"
msgstr "Display name"
#: crochet/forms.py:143
msgid "Contraseña"
msgstr "Password"
#: crochet/forms.py:153
msgid "Contraseña incorrecta."
msgstr "Incorrect password."
#: crochet/templates/crochet/_account_delete_form.html:9
msgid ""
"¿Seguro que quieres eliminar tu cuenta? Se borrarán también todos tus "
"patrones. Esta acción no se puede deshacer."
msgstr ""
"Are you sure you want to delete your account? All your patterns will also be "
"deleted. This action cannot be undone."
#: crochet/templates/crochet/_account_delete_form.html:19
#: crochet/templates/crochet/account_settings.html:68
msgid "Eliminar cuenta"
msgstr "Delete account"
#: crochet/templates/crochet/_account_display_name_form.html:7
msgid "Nombre actualizado."
msgstr "Name updated."
#: crochet/templates/crochet/_account_display_name_form.html:17
msgid "Guardar nombre"
msgstr "Save name"
#: crochet/templates/crochet/_account_email_form.html:10
msgid "Email actualizado."
msgstr "Email updated."
#: crochet/templates/crochet/_account_email_form.html:20
msgid "Guardar email"
msgstr "Save email"
#: crochet/templates/crochet/_account_password_form.html:7
msgid "Contraseña actualizada."
msgstr "Password updated."
#: crochet/templates/crochet/_account_password_form.html:19
#: crochet/templates/crochet/account_settings.html:56
#: crochet/templates/crochet/password_reset_confirm.html:30
msgid "Cambiar contraseña"
msgstr "Change password"
#: crochet/templates/crochet/_cookie_banner.html:8
msgid ""
"Usamos únicamente las cookies necesarias para que la web funcione (mantener "
"tu sesión iniciada y proteger los formularios). No usamos cookies de "
"analítica ni de publicidad."
msgstr ""
"We only use the cookies necessary for the site to work (keeping you logged "
"in and protecting forms). We don't use analytics or advertising cookies."
#: crochet/templates/crochet/_cookie_banner.html:9
msgid "Más información"
msgstr "More information"
#: crochet/templates/crochet/_cookie_banner.html:11
msgid "Entendido"
msgstr "Agree"
#: crochet/templates/crochet/account_home.html:4
msgid "Mis patrones"
msgstr "My patterns"
@@ -22,272 +95,603 @@ msgid "Crear patrón"
msgstr "Create pattern"
#: crochet/templates/crochet/account_home.html:21
#: crochet/templates/crochet/pattern.html:21
#: crochet/templates/crochet/pattern.html:20
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:37
msgid "Patrón sin título"
msgstr "Untitled pattern"
#: crochet/templates/crochet/account_home.html:30
#: crochet/templates/crochet/account_home.html:41
#, python-format
msgid "Actualizado el %(date)s"
msgstr "Updated on %(date)s"
#: crochet/templates/crochet/account_home.html:33
#: crochet/templates/crochet/pattern.html:34
msgid "Ver patrón"
msgstr "View pattern"
#: crochet/templates/crochet/account_home.html:44
#: crochet/templates/crochet/pattern.html:182
msgid "Vista previa"
msgstr "Preview"
#: crochet/templates/crochet/account_home.html:34
msgid "Editar"
msgstr "Edit"
#: crochet/templates/crochet/account_home.html:38
#: crochet/templates/crochet/account_home.html:48
#: crochet/templates/crochet/pattern.html:55
msgid "Eliminar"
msgstr "Delete"
#: crochet/templates/crochet/account_home.html:47
#: crochet/templates/crochet/account_home.html:57
msgid "Todavía no tienes ningún patrón."
msgstr "You don't have any patterns yet."
#: crochet/templates/crochet/account_home.html:53
#: crochet/templates/crochet/base.html:34
#: crochet/templates/crochet/account_settings.html:4
#: crochet/templates/crochet/account_settings.html:18
#: crochet/templates/crochet/base.html:52
msgid "Ajustes de la cuenta"
msgstr "Account settings"
#: crochet/templates/crochet/account_settings.html:30
msgid "Perfil"
msgstr "Profile"
#: crochet/templates/crochet/account_settings.html:31
msgid "Seguridad"
msgstr "Security"
#: crochet/templates/crochet/account_settings.html:32
msgid "Zona de peligro"
msgstr "Danger zone"
#: crochet/templates/crochet/account_settings.html:70
msgid "Esto borrará tu cuenta y todos tus patrones de forma permanente."
msgstr "This will permanently delete your account and all your patterns."
#: crochet/templates/crochet/base.html:39
msgid "Cambiar idioma"
msgstr "Change language"
#: crochet/templates/crochet/base.html:56
msgid "Cerrar sesión"
msgstr "Log out"
#: crochet/templates/crochet/base.html:37
#: crochet/templates/crochet/base.html:62
#: crochet/templates/crochet/home.html:25
#: crochet/templates/crochet/password_reset_complete.html:18
#: crochet/templates/registration/login.html:4
#: crochet/templates/registration/login.html:14
msgid "Iniciar sesión"
msgstr "Log in"
#: crochet/templates/crochet/base.html:38
#: crochet/templates/registration/register.html:29
msgid "Registrarme"
msgstr "Sign up"
#: crochet/templates/crochet/cookie_policy.html:4
#: crochet/templates/crochet/cookie_policy.html:12
msgid "Política de cookies"
msgstr "Cookie policy"
#: crochet/templates/crochet/cookie_policy.html:15
msgid ""
"Una cookie es un pequeño archivo que una web guarda en tu navegador. Aquí "
"usamos únicamente las que hacen falta para que la página funcione; no usamos "
"cookies de analítica ni de publicidad, ni de terceros."
msgstr ""
"A cookie is a small file that a website stores in your browser. Here we only "
"use the ones needed for the page to work; we don't use analytics, "
"advertising, or third-party cookies."
#: crochet/templates/crochet/cookie_policy.html:20
msgid "Cookies que usamos"
msgstr "Cookies we use"
#: crochet/templates/crochet/cookie_policy.html:25
msgid "Nombre"
msgstr "Name"
#: crochet/templates/crochet/cookie_policy.html:26
msgid "Finalidad"
msgstr "Purpose"
#: crochet/templates/crochet/cookie_policy.html:27
msgid "Duración"
msgstr "Duration"
#: crochet/templates/crochet/cookie_policy.html:33
msgid "Mantiene tu sesión iniciada."
msgstr "Keeps you logged in."
#: crochet/templates/crochet/cookie_policy.html:34
msgid "Hasta que cierras sesión o caduca."
msgstr "Until you log out or it expires."
#: crochet/templates/crochet/cookie_policy.html:38
msgid ""
"Protege los formularios frente a ataques de falsificación de petición (CSRF)."
msgstr "Protects forms against cross-site request forgery (CSRF) attacks."
#: crochet/templates/crochet/cookie_policy.html:39
msgid "1 año."
msgstr "1 year."
#: crochet/templates/crochet/cookie_policy.html:45
msgid ""
"Además, tu navegador guarda localmente (no como cookie) que ya has visto el "
"aviso de cookies, para no volver a mostrártelo."
msgstr ""
"Your browser also stores locally (not as a cookie) that you've already seen "
"the cookie notice, so it isn't shown again."
#: crochet/templates/crochet/cookie_policy.html:51
msgid ""
"Ambas son cookies técnicas necesarias para el funcionamiento del sitio "
"(iniciar sesión y proteger los formularios), así que no requieren tu "
"consentimiento previo."
msgstr ""
"Both are technical cookies necessary for the site to work (logging in and "
"protecting forms), so they don't require your prior consent."
#: crochet/templates/crochet/cookie_policy.html:54
msgid "Volver al inicio"
msgstr "Back to home"
#: crochet/templates/crochet/email/account_deleted_notice.html:4
#: crochet/templates/crochet/email/account_deleted_subject.txt:1
#, python-format
msgid "Tu cuenta en %(site_name)s ha sido eliminada"
msgstr "Your %(site_name)s account has been deleted"
#: crochet/templates/crochet/email/account_deleted_notice.html:8
#: crochet/templates/crochet/email/account_deleted_notice.txt:2
#, python-format
msgid ""
"Tu cuenta en %(site_name)s y todos tus patrones se han eliminado de forma "
"permanente."
msgstr ""
"Your %(site_name)s account and all your patterns have been permanently "
"deleted."
#: crochet/templates/crochet/email/account_deleted_notice.html:12
#: crochet/templates/crochet/email/account_deleted_notice.txt:4
#: crochet/templates/crochet/email/email_changed_notice.html:12
#: crochet/templates/crochet/email/email_changed_notice.txt:4
#: crochet/templates/crochet/email/password_changed_notice.html:12
#: crochet/templates/crochet/email/password_changed_notice.txt:4
msgid "Tu usuario:"
msgstr "Your username:"
#: crochet/templates/crochet/email/account_deleted_notice.html:16
#: crochet/templates/crochet/email/account_deleted_notice.txt:6
#: crochet/templates/crochet/email/email_changed_notice.html:16
#: crochet/templates/crochet/email/email_changed_notice.txt:6
#: crochet/templates/crochet/email/password_changed_notice.html:16
#: crochet/templates/crochet/email/password_changed_notice.txt:6
#, python-format
msgid ""
"Si no has sido tú quien ha hecho este cambio, contacta cuanto antes con "
"soporte técnico en %(support_email)s."
msgstr ""
"If you didn't make this change, please contact technical support at "
"%(support_email)s as soon as possible."
#: crochet/templates/crochet/email/base.html:43
msgid ""
"Este email se ha enviado automáticamente, no respondas a esta dirección."
msgstr "This email was sent automatically, please don't reply to this address."
#: crochet/templates/crochet/email/email_changed_notice.html:4
#: crochet/templates/crochet/email/email_changed_subject.txt:1
#, python-format
msgid "El email de tu cuenta en %(site_name)s ha cambiado"
msgstr "The email on your %(site_name)s account has changed"
#: crochet/templates/crochet/email/email_changed_notice.html:8
#: crochet/templates/crochet/email/email_changed_notice.txt:2
#, python-format
msgid ""
"El email de tu cuenta en %(site_name)s se ha cambiado. Esta dirección "
"(%(old_email)s) ha dejado de estar asociada a tu cuenta."
msgstr ""
"The email on your %(site_name)s account has changed. This address "
"(%(old_email)s) is no longer associated with your account."
#: crochet/templates/crochet/email/password_changed_notice.html:4
#: crochet/templates/crochet/email/password_changed_subject.txt:1
#, python-format
msgid "La contraseña de tu cuenta en %(site_name)s ha cambiado"
msgstr "The password on your %(site_name)s account has changed"
#: crochet/templates/crochet/email/password_changed_notice.html:8
#: crochet/templates/crochet/email/password_changed_notice.txt:2
#, python-format
msgid "La contraseña de tu cuenta en %(site_name)s ha cambiado."
msgstr "The password on your %(site_name)s account has changed."
#: crochet/templates/crochet/email/password_reset.html:4
#: crochet/templates/crochet/password_reset_done.html:4
#: crochet/templates/crochet/password_reset_form.html:4
#: crochet/templates/crochet/password_reset_form.html:14
msgid "Recuperar contraseña"
msgstr "Reset password"
#: crochet/templates/crochet/email/password_reset.html:8
#: crochet/templates/crochet/password_reset_email.html:2
#, python-format
msgid ""
"Has recibido este email porque alguien ha solicitado restablecer la "
"contraseña de tu cuenta en %(site_name)s."
msgstr ""
"You're receiving this email because someone requested a password reset for "
"your account at %(site_name)s."
#: crochet/templates/crochet/email/password_reset.html:14
#: crochet/templates/crochet/password_reset_confirm.html:4
msgid "Elegir nueva contraseña"
msgstr "Choose new password"
#: crochet/templates/crochet/email/password_reset.html:19
#: crochet/templates/crochet/password_reset_email.html:7
msgid "Tu usuario, por si lo has olvidado:"
msgstr "Your username, in case you've forgotten:"
#: crochet/templates/crochet/email/password_reset.html:23
#: crochet/templates/crochet/password_reset_email.html:9
msgid "Si no has solicitado este cambio, puedes ignorar este email."
msgstr "If you didn't request this change, you can ignore this email."
#: crochet/templates/crochet/home.html:4
msgid "Crochet — Crea y comparte tus patrones"
msgstr "Crochet — Create and share your patterns"
#: crochet/templates/crochet/home.html:15
msgid "Crea y comparte tus patrones de crochet"
msgstr "Create and share your crochet patterns"
#: crochet/templates/crochet/home.html:17
msgid ""
"Diseña tus patrones con un editor visual, expórtalos a PDF y compártelos con "
"quien quieras, sin instalar nada."
msgstr ""
"Design your patterns with a visual editor, export them to PDF, and share "
"them with anyone, no installation needed."
#: crochet/templates/crochet/home.html:24
msgid "Crear cuenta gratis"
msgstr "Create a free account"
#: crochet/templates/crochet/home.html:35
msgid "Editor visual"
msgstr "Visual editor"
#: crochet/templates/crochet/home.html:37
msgid ""
"Añade títulos, instrucciones, materiales e imágenes por secciones, sin "
"complicarte con el formato."
msgstr ""
"Add titles, instructions, materials, and images section by section, without "
"fussing over formatting."
#: crochet/templates/crochet/home.html:44
msgid "Exporta a PDF"
msgstr "Export to PDF"
#: crochet/templates/crochet/home.html:46
msgid ""
"Descarga tu patrón listo para imprimir, con tu propia portada, colores y "
"tipografía."
msgstr ""
"Download your pattern ready to print, with your own cover image, colors, and "
"typography."
#: crochet/templates/crochet/home.html:53
msgid "Comparte con un enlace"
msgstr "Share with a link"
#: crochet/templates/crochet/home.html:55
msgid ""
"Envía tu patrón a quien quieras con un enlace de solo lectura: nadie más "
"podrá editarlo."
msgstr ""
"Send your pattern to anyone with a read-only link: no one else will be able "
"to edit it."
#: crochet/templates/crochet/password_reset_complete.html:4
#: crochet/templates/crochet/password_reset_complete.html:14
msgid "Contraseña actualizada"
msgstr "Password updated"
#: crochet/templates/crochet/password_reset_complete.html:16
msgid "Ya puedes iniciar sesión con tu nueva contraseña."
msgstr "You can now log in with your new password."
#: crochet/templates/crochet/password_reset_confirm.html:15
msgid "Elige una contraseña nueva"
msgstr "Choose a new password"
#: crochet/templates/crochet/password_reset_confirm.html:33
msgid "Enlace no válido"
msgstr "Invalid link"
#: crochet/templates/crochet/password_reset_confirm.html:35
msgid ""
"El enlace para restablecer la contraseña no es válido, puede que ya se haya "
"usado. Solicita uno nuevo."
msgstr ""
"The password reset link is invalid, possibly because it has already been "
"used. Please request a new one."
#: crochet/templates/crochet/password_reset_confirm.html:38
msgid "Solicitar un enlace nuevo"
msgstr "Request a new link"
#: crochet/templates/crochet/password_reset_done.html:14
msgid "Revisa tu email"
msgstr "Check your email"
#: crochet/templates/crochet/password_reset_done.html:16
msgid ""
"Si existe una cuenta con ese email, te hemos enviado un enlace para elegir "
"una contraseña nueva."
msgstr ""
"If an account exists with that email, we've sent you a link to choose a new "
"password."
#: crochet/templates/crochet/password_reset_done.html:19
#: crochet/templates/crochet/password_reset_form.html:32
msgid "Volver a iniciar sesión"
msgstr "Back to login"
#: crochet/templates/crochet/password_reset_email.html:4
msgid "Sigue este enlace para elegir una contraseña nueva:"
msgstr "Follow this link to choose a new password:"
#: crochet/templates/crochet/password_reset_form.html:16
msgid ""
"Escribe tu email y te enviaremos un enlace para elegir una contraseña nueva."
msgstr "Enter your email and we'll send you a link to choose a new password."
#: crochet/templates/crochet/password_reset_form.html:29
msgid "Enviar enlace"
msgstr "Send link"
#: crochet/templates/crochet/password_reset_subject.txt:1
#, python-format
msgid "Recupera tu contraseña en %(site_name)s"
msgstr "Reset your password on %(site_name)s"
#: crochet/templates/crochet/pattern.html:26
msgid "Idioma del patrón"
msgstr "Pattern language"
# crochet/templates/crochet/pattern.html
#: crochet/templates/crochet/pattern.html:31
#: crochet/templates/crochet/pattern.html:38
msgid "No se ha podido guardar. Inténtalo de nuevo."
msgstr "Couldn't save. Please try again."
#: crochet/templates/crochet/pattern.html:31
#: crochet/templates/crochet/pattern.html:38
msgid "Guardar"
msgstr "Save"
#: crochet/templates/crochet/pattern.html:35
#: crochet/templates/crochet/pattern.html:41
msgid "Ver patrón"
msgstr "View pattern"
#: crochet/templates/crochet/pattern.html:42
msgid "Exportar a PDF"
msgstr "Export to PDF"
#: crochet/templates/crochet/pattern.html:39
msgid "Eliminar patrón"
msgstr "Delete pattern"
#: crochet/templates/crochet/pattern.html:49
msgid "Más opciones"
msgstr "More options"
#: crochet/templates/crochet/pattern.html:46
#: crochet/templates/crochet/pattern.html:65
msgid "Guardado"
msgstr "Saved"
#: crochet/templates/crochet/pattern.html:53
msgid "Personalización de página"
msgstr "Page customization"
#: crochet/templates/crochet/pattern.html:57
#: crochet/templates/crochet/pattern.html:78
msgid "Título del patrón"
msgstr "Pattern title"
#: crochet/templates/crochet/pattern.html:58
#: crochet/templates/crochet/pattern.html:79
msgid "Título del patrón..."
msgstr "Pattern title..."
#: crochet/templates/crochet/pattern.html:61
#: crochet/templates/crochet/pattern.html:82
msgid "Imagen de portada"
msgstr "Cover image"
#: crochet/templates/crochet/pattern.html:95
msgid "Añadir imagen"
msgstr "Add image"
#: crochet/templates/crochet/pattern.html:98
msgid "Cambiar"
msgstr "Change"
#: crochet/templates/crochet/pattern.html:103
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:114
msgid "Personalización de página"
msgstr "Page customization"
#: crochet/templates/crochet/pattern.html:117
msgid "Autor"
msgstr "Author"
#: crochet/templates/crochet/pattern.html:62
#: crochet/templates/crochet/pattern.html:118
msgid "Autor..."
msgstr "Author..."
#: crochet/templates/crochet/pattern.html:67
#: crochet/templates/crochet/pattern.html:151
#: crochet/templates/crochet/pattern.html:122
#: crochet/templates/crochet/pattern.html:206
msgid "Color del texto"
msgstr "Text color"
#: crochet/templates/crochet/pattern.html:71
#: crochet/templates/crochet/pattern.html:126
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:130
#: crochet/templates/crochet/pattern.html:206
msgid "Color de fondo"
msgstr "Background color"
#: crochet/templates/crochet/pattern.html:79
#: crochet/templates/crochet/pattern.html:134
msgid "Tipografía"
msgstr "Font"
#: crochet/templates/crochet/pattern.html:85
#: crochet/templates/crochet/pattern.html:140
msgid "Monoespaciada"
msgstr "Monospace"
#: crochet/templates/crochet/pattern.html:89
#: crochet/templates/crochet/pattern.html:144
msgid "Tamaño de letra"
msgstr "Font size"
#: crochet/templates/crochet/pattern.html:91
#: crochet/templates/crochet/pattern.html:146
msgid "Pequeño"
msgstr "Small"
#: crochet/templates/crochet/pattern.html:92
#: crochet/templates/crochet/pattern.html:147
msgid "Normal"
msgstr "Normal"
#: crochet/templates/crochet/pattern.html:93
#: crochet/templates/crochet/pattern.html:148
msgid "Grande"
msgstr "Large"
#: crochet/templates/crochet/pattern.html:97
#: crochet/templates/crochet/pattern.html:152
msgid "Alineación del texto"
msgstr "Text alignment"
#: crochet/templates/crochet/pattern.html:99
#: crochet/templates/crochet/pattern.html:154
msgid "Izquierda"
msgstr "Left"
#: crochet/templates/crochet/pattern.html:100
#: crochet/templates/crochet/pattern.html:155
msgid "Centrado"
msgstr "Center"
#: crochet/templates/crochet/pattern.html:101
#: crochet/templates/crochet/pattern.html:156
msgid "Justificado"
msgstr "Justify"
#: crochet/templates/crochet/pattern.html:105
#: crochet/templates/crochet/pattern.html:160
msgid "Tamaño de página"
msgstr "Page size"
#: crochet/templates/crochet/pattern.html:110
#: crochet/templates/crochet/pattern.html:165
msgid "Carta (Letter)"
msgstr "Letter"
#: crochet/templates/crochet/pattern.html:115
#: crochet/templates/crochet/pattern.html:170
msgid "Orientación"
msgstr "Orientation"
#: crochet/templates/crochet/pattern.html:117
#: crochet/templates/crochet/pattern.html:172
msgid "Vertical"
msgstr "Portrait"
#: crochet/templates/crochet/pattern.html:118
#: crochet/templates/crochet/pattern.html:173
msgid "Horizontal"
msgstr "Landscape"
#: crochet/templates/crochet/pattern.html:126
#: crochet/templates/crochet/pattern.html:134
#: crochet/templates/crochet/pattern.html:181
#: crochet/templates/crochet/pattern.html:189
msgid "Secciones"
msgstr "Sections"
#: crochet/templates/crochet/pattern.html:127
#: crochet/templates/crochet/pattern.html:173
msgid "Vista previa"
msgstr "Preview"
#: crochet/templates/crochet/pattern.html:136
#: crochet/templates/crochet/pattern.html:191
msgid "Colapsar todo"
msgstr "Collapse all"
#: crochet/templates/crochet/pattern.html:136
#: crochet/templates/crochet/pattern.html:191
msgid "Expandir todo"
msgstr "Expand all"
#: crochet/templates/crochet/pattern.html:141
#: crochet/templates/crochet/pattern.html:196
msgid "Añadir sección"
msgstr "Add section"
#: crochet/templates/crochet/pattern.html:144
#: crochet/templates/crochet/pattern.html:199
msgid "Título"
msgstr "Title"
#: crochet/templates/crochet/pattern.html:144
#: crochet/templates/crochet/pattern.html:199
msgid "Título..."
msgstr "Title..."
#: crochet/templates/crochet/pattern.html:146
#: crochet/templates/crochet/pattern.html:201
msgid "Subtítulo"
msgstr "Subtitle"
#: crochet/templates/crochet/pattern.html:146
#: crochet/templates/crochet/pattern.html:201
msgid "Subtítulo..."
msgstr "Subtitle..."
#: crochet/templates/crochet/pattern.html:148
#: crochet/templates/crochet/pattern.html:203
msgid "Texto"
msgstr "Text"
#: crochet/templates/crochet/pattern.html:148
#: crochet/templates/crochet/pattern.html:203
msgid "Escribe aquí..."
msgstr "Write here..."
#: crochet/templates/crochet/pattern.html:150
#: crochet/templates/crochet/pattern.html:151
#: crochet/templates/crochet/pattern.html:205
#: crochet/templates/crochet/pattern.html:206
msgid "Nota"
msgstr "Note"
#: crochet/templates/crochet/pattern.html:150
#: crochet/templates/crochet/pattern.html:205
msgid "Escribe una nota o consejo..."
msgstr "Write a note or tip..."
#: crochet/templates/crochet/pattern.html:153
#: crochet/templates/crochet/pattern.html:208
msgid "Materiales"
msgstr "Materials"
#: crochet/templates/crochet/pattern.html:153
#: crochet/templates/crochet/pattern.html:208
msgid "Añadir material..."
msgstr "Add material..."
#: crochet/templates/crochet/pattern.html:153
#: crochet/templates/crochet/pattern.html:208
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:210
#: crochet/templates/crochet/pattern.html:212
msgid "Imagen"
msgstr "Image"
#: crochet/templates/crochet/pattern.html:157
#: crochet/templates/crochet/pattern.html:212
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:214
msgid "Patrón"
msgstr "Pattern"
#: crochet/templates/crochet/pattern.html:159
#: crochet/templates/crochet/pattern.html:214
msgid "Elementos"
msgstr "Elements"
#: crochet/templates/crochet/pattern.html:161
#: crochet/templates/crochet/pattern.html:216
msgid "Grupo"
msgstr "Group"
#: crochet/templates/crochet/pattern.html:165
#: crochet/templates/crochet/pattern.html:220
msgid "Arrastrar para reordenar"
msgstr "Drag to reorder"
#: crochet/templates/crochet/pattern.html:166
#: crochet/templates/crochet/pattern.html:221
msgid "Colapsar / expandir"
msgstr "Collapse / expand"
#: crochet/templates/crochet/pattern.html:167
#: crochet/templates/crochet/pattern.html:222
msgid "Duplicar sección"
msgstr "Duplicate section"
#: crochet/templates/crochet/pattern.html:168
#: crochet/templates/crochet/pattern.html:223
msgid "Eliminar sección"
msgstr "Delete section"
#: crochet/templates/crochet/pattern.html:169
#: crochet/templates/crochet/pattern.html:224
msgid ""
"¿Seguro que quieres eliminar este grupo? Se eliminarán también todas las "
"secciones que contiene."
@@ -295,11 +699,11 @@ 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:229
msgid "Añade una sección para ver aquí el resultado."
msgstr "Add a section to see the result here."
#: crochet/templates/crochet/pattern_detail.html:24
#: crochet/templates/crochet/pattern_detail.html:29
msgid "Descargar PDF"
msgstr "Download PDF"
@@ -312,6 +716,10 @@ msgid "Entrar"
msgstr "Log in"
#: crochet/templates/registration/login.html:32
msgid "¿Has olvidado tu contraseña?"
msgstr "Forgot your password?"
#: crochet/templates/registration/login.html:35
msgid "¿No tienes cuenta? Regístrate"
msgstr "Don't have an account? Sign up"
@@ -320,55 +728,118 @@ msgstr "Don't have an account? Sign up"
msgid "Crear cuenta"
msgstr "Create account"
#: crochet/templates/registration/register.html:29
msgid "Registrarme"
msgstr "Sign up"
#: crochet/templates/registration/register.html:32
msgid "¿Ya tienes cuenta? Inicia sesión"
msgstr "Already have an account? Log in"
#: crochet/urls.py:30
#: crochet/urls.py:38
msgid "cookies/"
msgstr ""
#: crochet/urls.py:39
msgid "pattern/<uuid:uuid>/"
msgstr ""
#: crochet/urls.py:31
#: crochet/urls.py:40
msgid "pattern/<uuid:uuid>/edit/"
msgstr ""
#: crochet/urls.py:32
#: crochet/urls.py:41
msgid "pattern/<uuid:uuid>/save/"
msgstr ""
#: crochet/urls.py:33
#: crochet/urls.py:42
msgid "pattern/<uuid:uuid>/images/"
msgstr ""
#: crochet/urls.py:34
#: crochet/urls.py:43
msgid "pattern/<uuid:uuid>/cover/"
msgstr ""
#: crochet/urls.py:44
msgid "pattern/<uuid:uuid>/pdf/"
msgstr ""
#: crochet/urls.py:35
#: crochet/urls.py:45
msgid "pattern/<uuid:uuid>/delete/"
msgstr ""
#: crochet/urls.py:36
#: crochet/urls.py:46
msgid "pattern/new/"
msgstr ""
#: crochet/urls.py:37
msgid "account/"
#: crochet/urls.py:47
msgid "account/settings/"
msgstr ""
#: crochet/urls.py:38
#: crochet/urls.py:48
msgid "account/settings/email/"
msgstr ""
#: crochet/urls.py:50
msgid "account/settings/password/"
msgstr ""
#: crochet/urls.py:54
msgid "account/settings/display-name/"
msgstr ""
#: crochet/urls.py:57
msgid "account/settings/delete/"
msgstr ""
#: crochet/urls.py:58
msgid "account/register/"
msgstr ""
#: crochet/urls.py:40
#: crochet/urls.py:60
msgid "account/login/"
msgstr ""
#: crochet/urls.py:45
#: crochet/urls.py:65
msgid "account/logout/"
msgstr ""
#: crochet/urls.py:67
msgid "account/password-reset/"
msgstr ""
#: crochet/urls.py:86
msgid "account/password-reset/done/"
msgstr ""
#: crochet/urls.py:90
msgid "account/reset/<uidb64>/<token>/"
msgstr ""
#: crochet/urls.py:98
msgid "account/reset/done/"
msgstr ""
# crochet/views.py (PatternDetailView.EMPTY_MESSAGE)
#: crochet/views.py:23
#: crochet/views.py:29
msgid "Este patrón todavía no tiene contenido."
msgstr "This pattern doesn't have any content yet."
#: crochet/views.py:350
msgid "Tu cuenta se ha eliminado correctamente."
msgstr "Your account has been deleted successfully."
#~ msgid "Volver a mis patrones"
#~ msgstr "Back to my patterns"
#~ msgid "Ir a mis patrones"
#~ msgstr "Go to my patterns"
#~ msgid "Idioma"
#~ msgstr "Language"
#~ msgid "Editar"
#~ msgstr "Edit"
#~ msgid "Eliminar patrón"
#~ msgstr "Delete pattern"
+606 -178
View File
@@ -2,12 +2,81 @@ 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-22 08:53+0000\n"
"Language: es\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: crochet/forms.py:48 crochet/forms.py:96
#: crochet/templates/crochet/account_settings.html:49
msgid "Email"
msgstr ""
#: crochet/forms.py:128 crochet/templates/crochet/account_settings.html:40
msgid "Nombre para mostrar"
msgstr ""
#: crochet/forms.py:143
msgid "Contraseña"
msgstr ""
#: crochet/forms.py:153
msgid "Contraseña incorrecta."
msgstr ""
#: crochet/templates/crochet/_account_delete_form.html:9
msgid ""
"¿Seguro que quieres eliminar tu cuenta? Se borrarán también todos tus "
"patrones. Esta acción no se puede deshacer."
msgstr ""
#: crochet/templates/crochet/_account_delete_form.html:19
#: crochet/templates/crochet/account_settings.html:68
msgid "Eliminar cuenta"
msgstr ""
#: crochet/templates/crochet/_account_display_name_form.html:7
msgid "Nombre actualizado."
msgstr ""
#: crochet/templates/crochet/_account_display_name_form.html:17
msgid "Guardar nombre"
msgstr ""
#: crochet/templates/crochet/_account_email_form.html:10
msgid "Email actualizado."
msgstr ""
#: crochet/templates/crochet/_account_email_form.html:20
msgid "Guardar email"
msgstr ""
#: crochet/templates/crochet/_account_password_form.html:7
msgid "Contraseña actualizada."
msgstr ""
#: crochet/templates/crochet/_account_password_form.html:19
#: crochet/templates/crochet/account_settings.html:56
#: crochet/templates/crochet/password_reset_confirm.html:30
msgid "Cambiar contraseña"
msgstr ""
#: crochet/templates/crochet/_cookie_banner.html:8
msgid ""
"Usamos únicamente las cookies necesarias para que la web funcione (mantener "
"tu sesión iniciada y proteger los formularios). No usamos cookies de "
"analítica ni de publicidad."
msgstr ""
#: crochet/templates/crochet/_cookie_banner.html:9
msgid "Más información"
msgstr ""
#: crochet/templates/crochet/_cookie_banner.html:11
msgid "Entendido"
msgstr ""
#: crochet/templates/crochet/account_home.html:4
msgid "Mis patrones"
msgstr ""
@@ -22,281 +91,585 @@ msgid "Crear patrón"
msgstr ""
#: crochet/templates/crochet/account_home.html:21
#: crochet/templates/crochet/pattern.html:21
#: crochet/templates/crochet/pattern.html:20
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:37
msgid "Patrón sin título"
msgstr ""
#: crochet/templates/crochet/account_home.html:30
#: crochet/templates/crochet/account_home.html:41
#, python-format
msgid "Actualizado el %(date)s"
msgstr ""
#: crochet/templates/crochet/account_home.html:33
#: crochet/templates/crochet/pattern.html:34
msgid "Ver patrón"
#: crochet/templates/crochet/account_home.html:44
#: crochet/templates/crochet/pattern.html:182
msgid "Vista previa"
msgstr ""
#: crochet/templates/crochet/account_home.html:34
msgid "Editar"
msgstr ""
#: crochet/templates/crochet/account_home.html:38
#: crochet/templates/crochet/account_home.html:48
#: crochet/templates/crochet/pattern.html:55
msgid "Eliminar"
msgstr ""
#: crochet/templates/crochet/account_home.html:47
#: crochet/templates/crochet/account_home.html:57
msgid "Todavía no tienes ningún patrón."
msgstr ""
#: crochet/templates/crochet/account_home.html:53
#: crochet/templates/crochet/base.html:34
#: crochet/templates/crochet/account_settings.html:4
#: crochet/templates/crochet/account_settings.html:18
#: crochet/templates/crochet/base.html:52
msgid "Ajustes de la cuenta"
msgstr ""
#: crochet/templates/crochet/account_settings.html:30
msgid "Perfil"
msgstr ""
#: crochet/templates/crochet/account_settings.html:31
msgid "Seguridad"
msgstr ""
#: crochet/templates/crochet/account_settings.html:32
msgid "Zona de peligro"
msgstr ""
#: crochet/templates/crochet/account_settings.html:70
msgid "Esto borrará tu cuenta y todos tus patrones de forma permanente."
msgstr ""
#: crochet/templates/crochet/base.html:39
msgid "Cambiar idioma"
msgstr ""
#: crochet/templates/crochet/base.html:56
msgid "Cerrar sesión"
msgstr ""
#: crochet/templates/crochet/base.html:37
#: crochet/templates/crochet/base.html:62
#: crochet/templates/crochet/home.html:25
#: crochet/templates/crochet/password_reset_complete.html:18
#: crochet/templates/registration/login.html:4
#: crochet/templates/registration/login.html:14
msgid "Iniciar sesión"
msgstr ""
#: crochet/templates/crochet/base.html:38
#: crochet/templates/registration/register.html:29
msgid "Registrarme"
#: crochet/templates/crochet/cookie_policy.html:4
#: crochet/templates/crochet/cookie_policy.html:12
msgid "Política de cookies"
msgstr ""
#: crochet/templates/crochet/pattern.html:31
#: crochet/templates/crochet/cookie_policy.html:15
msgid ""
"Una cookie es un pequeño archivo que una web guarda en tu navegador. Aquí "
"usamos únicamente las que hacen falta para que la página funcione; no usamos "
"cookies de analítica ni de publicidad, ni de terceros."
msgstr ""
#: crochet/templates/crochet/cookie_policy.html:20
msgid "Cookies que usamos"
msgstr ""
#: crochet/templates/crochet/cookie_policy.html:25
msgid "Nombre"
msgstr ""
#: crochet/templates/crochet/cookie_policy.html:26
msgid "Finalidad"
msgstr ""
#: crochet/templates/crochet/cookie_policy.html:27
msgid "Duración"
msgstr ""
#: crochet/templates/crochet/cookie_policy.html:33
msgid "Mantiene tu sesión iniciada."
msgstr ""
#: crochet/templates/crochet/cookie_policy.html:34
msgid "Hasta que cierras sesión o caduca."
msgstr ""
#: crochet/templates/crochet/cookie_policy.html:38
msgid ""
"Protege los formularios frente a ataques de falsificación de petición (CSRF)."
msgstr ""
#: crochet/templates/crochet/cookie_policy.html:39
msgid "1 año."
msgstr ""
#: crochet/templates/crochet/cookie_policy.html:45
msgid ""
"Además, tu navegador guarda localmente (no como cookie) que ya has visto el "
"aviso de cookies, para no volver a mostrártelo."
msgstr ""
#: crochet/templates/crochet/cookie_policy.html:51
msgid ""
"Ambas son cookies técnicas necesarias para el funcionamiento del sitio "
"(iniciar sesión y proteger los formularios), así que no requieren tu "
"consentimiento previo."
msgstr ""
#: crochet/templates/crochet/cookie_policy.html:54
msgid "Volver al inicio"
msgstr ""
#: crochet/templates/crochet/email/account_deleted_notice.html:4
#: crochet/templates/crochet/email/account_deleted_subject.txt:1
#, python-format
msgid "Tu cuenta en %(site_name)s ha sido eliminada"
msgstr ""
#: crochet/templates/crochet/email/account_deleted_notice.html:8
#: crochet/templates/crochet/email/account_deleted_notice.txt:2
#, python-format
msgid ""
"Tu cuenta en %(site_name)s y todos tus patrones se han eliminado de forma "
"permanente."
msgstr ""
#: crochet/templates/crochet/email/account_deleted_notice.html:12
#: crochet/templates/crochet/email/account_deleted_notice.txt:4
#: crochet/templates/crochet/email/email_changed_notice.html:12
#: crochet/templates/crochet/email/email_changed_notice.txt:4
#: crochet/templates/crochet/email/password_changed_notice.html:12
#: crochet/templates/crochet/email/password_changed_notice.txt:4
msgid "Tu usuario:"
msgstr ""
#: crochet/templates/crochet/email/account_deleted_notice.html:16
#: crochet/templates/crochet/email/account_deleted_notice.txt:6
#: crochet/templates/crochet/email/email_changed_notice.html:16
#: crochet/templates/crochet/email/email_changed_notice.txt:6
#: crochet/templates/crochet/email/password_changed_notice.html:16
#: crochet/templates/crochet/email/password_changed_notice.txt:6
#, python-format
msgid ""
"Si no has sido tú quien ha hecho este cambio, contacta cuanto antes con "
"soporte técnico en %(support_email)s."
msgstr ""
#: crochet/templates/crochet/email/base.html:43
msgid ""
"Este email se ha enviado automáticamente, no respondas a esta dirección."
msgstr ""
#: crochet/templates/crochet/email/email_changed_notice.html:4
#: crochet/templates/crochet/email/email_changed_subject.txt:1
#, python-format
msgid "El email de tu cuenta en %(site_name)s ha cambiado"
msgstr ""
#: crochet/templates/crochet/email/email_changed_notice.html:8
#: crochet/templates/crochet/email/email_changed_notice.txt:2
#, python-format
msgid ""
"El email de tu cuenta en %(site_name)s se ha cambiado. Esta dirección "
"(%(old_email)s) ha dejado de estar asociada a tu cuenta."
msgstr ""
#: crochet/templates/crochet/email/password_changed_notice.html:4
#: crochet/templates/crochet/email/password_changed_subject.txt:1
#, python-format
msgid "La contraseña de tu cuenta en %(site_name)s ha cambiado"
msgstr ""
#: crochet/templates/crochet/email/password_changed_notice.html:8
#: crochet/templates/crochet/email/password_changed_notice.txt:2
#, python-format
msgid "La contraseña de tu cuenta en %(site_name)s ha cambiado."
msgstr ""
#: crochet/templates/crochet/email/password_reset.html:4
#: crochet/templates/crochet/password_reset_done.html:4
#: crochet/templates/crochet/password_reset_form.html:4
#: crochet/templates/crochet/password_reset_form.html:14
msgid "Recuperar contraseña"
msgstr ""
#: crochet/templates/crochet/email/password_reset.html:8
#: crochet/templates/crochet/password_reset_email.html:2
#, python-format
msgid ""
"Has recibido este email porque alguien ha solicitado restablecer la "
"contraseña de tu cuenta en %(site_name)s."
msgstr ""
#: crochet/templates/crochet/email/password_reset.html:14
#: crochet/templates/crochet/password_reset_confirm.html:4
msgid "Elegir nueva contraseña"
msgstr ""
#: crochet/templates/crochet/email/password_reset.html:19
#: crochet/templates/crochet/password_reset_email.html:7
msgid "Tu usuario, por si lo has olvidado:"
msgstr ""
#: crochet/templates/crochet/email/password_reset.html:23
#: crochet/templates/crochet/password_reset_email.html:9
msgid "Si no has solicitado este cambio, puedes ignorar este email."
msgstr ""
#: crochet/templates/crochet/home.html:4
msgid "Crochet — Crea y comparte tus patrones"
msgstr ""
#: crochet/templates/crochet/home.html:15
msgid "Crea y comparte tus patrones de crochet"
msgstr ""
#: crochet/templates/crochet/home.html:17
msgid ""
"Diseña tus patrones con un editor visual, expórtalos a PDF y compártelos con "
"quien quieras, sin instalar nada."
msgstr ""
#: crochet/templates/crochet/home.html:24
msgid "Crear cuenta gratis"
msgstr ""
#: crochet/templates/crochet/home.html:35
msgid "Editor visual"
msgstr ""
#: crochet/templates/crochet/home.html:37
msgid ""
"Añade títulos, instrucciones, materiales e imágenes por secciones, sin "
"complicarte con el formato."
msgstr ""
#: crochet/templates/crochet/home.html:44
msgid "Exporta a PDF"
msgstr ""
#: crochet/templates/crochet/home.html:46
msgid ""
"Descarga tu patrón listo para imprimir, con tu propia portada, colores y "
"tipografía."
msgstr ""
#: crochet/templates/crochet/home.html:53
msgid "Comparte con un enlace"
msgstr ""
#: crochet/templates/crochet/home.html:55
msgid ""
"Envía tu patrón a quien quieras con un enlace de solo lectura: nadie más "
"podrá editarlo."
msgstr ""
#: crochet/templates/crochet/password_reset_complete.html:4
#: crochet/templates/crochet/password_reset_complete.html:14
msgid "Contraseña actualizada"
msgstr ""
#: crochet/templates/crochet/password_reset_complete.html:16
msgid "Ya puedes iniciar sesión con tu nueva contraseña."
msgstr ""
#: crochet/templates/crochet/password_reset_confirm.html:15
msgid "Elige una contraseña nueva"
msgstr ""
#: crochet/templates/crochet/password_reset_confirm.html:33
msgid "Enlace no válido"
msgstr ""
#: crochet/templates/crochet/password_reset_confirm.html:35
msgid ""
"El enlace para restablecer la contraseña no es válido, puede que ya se haya "
"usado. Solicita uno nuevo."
msgstr ""
#: crochet/templates/crochet/password_reset_confirm.html:38
msgid "Solicitar un enlace nuevo"
msgstr ""
#: crochet/templates/crochet/password_reset_done.html:14
msgid "Revisa tu email"
msgstr ""
#: crochet/templates/crochet/password_reset_done.html:16
msgid ""
"Si existe una cuenta con ese email, te hemos enviado un enlace para elegir "
"una contraseña nueva."
msgstr ""
#: crochet/templates/crochet/password_reset_done.html:19
#: crochet/templates/crochet/password_reset_form.html:32
msgid "Volver a iniciar sesión"
msgstr ""
#: crochet/templates/crochet/password_reset_email.html:4
msgid "Sigue este enlace para elegir una contraseña nueva:"
msgstr ""
#: crochet/templates/crochet/password_reset_form.html:16
msgid ""
"Escribe tu email y te enviaremos un enlace para elegir una contraseña nueva."
msgstr ""
#: crochet/templates/crochet/password_reset_form.html:29
msgid "Enviar enlace"
msgstr ""
#: crochet/templates/crochet/password_reset_subject.txt:1
#, python-format
msgid "Recupera tu contraseña en %(site_name)s"
msgstr ""
#: crochet/templates/crochet/pattern.html:26
msgid "Idioma del patrón"
msgstr ""
#: crochet/templates/crochet/pattern.html:38
msgid "No se ha podido guardar. Inténtalo de nuevo."
msgstr ""
#: crochet/templates/crochet/pattern.html:31
#: crochet/templates/crochet/pattern.html:38
msgid "Guardar"
msgstr ""
#: crochet/templates/crochet/pattern.html:35
#: crochet/templates/crochet/pattern.html:41
msgid "Ver patrón"
msgstr ""
#: crochet/templates/crochet/pattern.html:42
msgid "Exportar a PDF"
msgstr ""
#: crochet/templates/crochet/pattern.html:39
msgid "Eliminar patrón"
#: crochet/templates/crochet/pattern.html:49
msgid "Más opciones"
msgstr ""
#: crochet/templates/crochet/pattern.html:46
#: crochet/templates/crochet/pattern.html:65
msgid "Guardado"
msgstr ""
#: crochet/templates/crochet/pattern.html:53
msgid "Personalización de página"
msgstr ""
#: crochet/templates/crochet/pattern.html:57
#: crochet/templates/crochet/pattern.html:78
msgid "Título del patrón"
msgstr ""
#: crochet/templates/crochet/pattern.html:58
#: crochet/templates/crochet/pattern.html:79
msgid "Título del patrón..."
msgstr ""
#: crochet/templates/crochet/pattern.html:61
msgid "Autor"
#: crochet/templates/crochet/pattern.html:82
msgid "Imagen de portada"
msgstr ""
#: crochet/templates/crochet/pattern.html:62
msgid "Autor..."
#: crochet/templates/crochet/pattern.html:95
msgid "Añadir imagen"
msgstr ""
#: crochet/templates/crochet/pattern.html:67
#: crochet/templates/crochet/pattern.html:151
msgid "Color del texto"
#: crochet/templates/crochet/pattern.html:98
msgid "Cambiar"
msgstr ""
#: crochet/templates/crochet/pattern.html:71
msgid "Color de acento (títulos)"
#: crochet/templates/crochet/pattern.html:103
msgid "No se ha podido subir la imagen de portada. Inténtalo de nuevo."
msgstr ""
#: crochet/templates/crochet/pattern.html:75
#: crochet/templates/crochet/pattern.html:151
msgid "Color de fondo"
msgstr ""
#: crochet/templates/crochet/pattern.html:79
msgid "Tipografía"
msgstr ""
#: crochet/templates/crochet/pattern.html:85
msgid "Monoespaciada"
msgstr ""
#: crochet/templates/crochet/pattern.html:89
msgid "Tamaño de letra"
msgstr ""
#: crochet/templates/crochet/pattern.html:91
msgid "Pequeño"
msgstr ""
#: crochet/templates/crochet/pattern.html:92
msgid "Normal"
msgstr ""
#: crochet/templates/crochet/pattern.html:93
msgid "Grande"
msgstr ""
#: crochet/templates/crochet/pattern.html:97
msgid "Alineación del texto"
msgstr ""
#: crochet/templates/crochet/pattern.html:99
msgid "Izquierda"
msgstr ""
#: crochet/templates/crochet/pattern.html:100
msgid "Centrado"
msgstr ""
#: crochet/templates/crochet/pattern.html:101
msgid "Justificado"
msgstr ""
#: crochet/templates/crochet/pattern.html:105
msgid "Tamaño de página"
msgstr ""
#: crochet/templates/crochet/pattern.html:110
msgid "Carta (Letter)"
msgstr ""
#: crochet/templates/crochet/pattern.html:115
msgid "Orientación"
#: crochet/templates/crochet/pattern.html:114
msgid "Personalización de página"
msgstr ""
#: crochet/templates/crochet/pattern.html:117
msgid "Vertical"
msgid "Autor"
msgstr ""
#: crochet/templates/crochet/pattern.html:118
msgid "Horizontal"
msgid "Autor..."
msgstr ""
#: crochet/templates/crochet/pattern.html:122
#: crochet/templates/crochet/pattern.html:206
msgid "Color del texto"
msgstr ""
#: crochet/templates/crochet/pattern.html:126
msgid "Color de acento (títulos)"
msgstr ""
#: crochet/templates/crochet/pattern.html:130
#: crochet/templates/crochet/pattern.html:206
msgid "Color de fondo"
msgstr ""
#: crochet/templates/crochet/pattern.html:134
msgid "Secciones"
msgid "Tipografía"
msgstr ""
#: crochet/templates/crochet/pattern.html:127
#: crochet/templates/crochet/pattern.html:173
msgid "Vista previa"
msgstr ""
#: crochet/templates/crochet/pattern.html:136
msgid "Colapsar todo"
msgstr ""
#: crochet/templates/crochet/pattern.html:136
msgid "Expandir todo"
msgstr ""
#: crochet/templates/crochet/pattern.html:141
msgid "Añadir sección"
#: crochet/templates/crochet/pattern.html:140
msgid "Monoespaciada"
msgstr ""
#: crochet/templates/crochet/pattern.html:144
msgid "Título"
msgstr ""
#: crochet/templates/crochet/pattern.html:144
msgid "Título..."
msgid "Tamaño de letra"
msgstr ""
#: crochet/templates/crochet/pattern.html:146
msgid "Subtítulo"
msgid "Pequeño"
msgstr ""
#: crochet/templates/crochet/pattern.html:146
msgid "Subtítulo..."
#: crochet/templates/crochet/pattern.html:147
msgid "Normal"
msgstr ""
#: crochet/templates/crochet/pattern.html:148
msgid "Texto"
msgid "Grande"
msgstr ""
#: crochet/templates/crochet/pattern.html:148
msgid "Escribe aquí..."
#: crochet/templates/crochet/pattern.html:152
msgid "Alineación del texto"
msgstr ""
#: crochet/templates/crochet/pattern.html:150
#: crochet/templates/crochet/pattern.html:151
msgid "Nota"
msgstr ""
#: crochet/templates/crochet/pattern.html:150
msgid "Escribe una nota o consejo..."
msgstr ""
#: crochet/templates/crochet/pattern.html:153
msgid "Materiales"
msgstr ""
#: crochet/templates/crochet/pattern.html:153
msgid "Añadir material..."
msgstr ""
#: crochet/templates/crochet/pattern.html:153
msgid "Añadir línea"
#: crochet/templates/crochet/pattern.html:154
msgid "Izquierda"
msgstr ""
#: crochet/templates/crochet/pattern.html:155
#: crochet/templates/crochet/pattern.html:157
msgid "Imagen"
msgid "Centrado"
msgstr ""
#: crochet/templates/crochet/pattern.html:157
msgid "No se ha podido subir la imagen. Inténtalo de nuevo."
#: crochet/templates/crochet/pattern.html:156
msgid "Justificado"
msgstr ""
#: crochet/templates/crochet/pattern.html:159
msgid "Patrón"
msgstr ""
#: crochet/templates/crochet/pattern.html:159
msgid "Elementos"
msgstr ""
#: crochet/templates/crochet/pattern.html:161
msgid "Grupo"
#: crochet/templates/crochet/pattern.html:160
msgid "Tamaño de página"
msgstr ""
#: crochet/templates/crochet/pattern.html:165
msgid "Carta (Letter)"
msgstr ""
#: crochet/templates/crochet/pattern.html:170
msgid "Orientación"
msgstr ""
#: crochet/templates/crochet/pattern.html:172
msgid "Vertical"
msgstr ""
#: crochet/templates/crochet/pattern.html:173
msgid "Horizontal"
msgstr ""
#: crochet/templates/crochet/pattern.html:181
#: crochet/templates/crochet/pattern.html:189
msgid "Secciones"
msgstr ""
#: crochet/templates/crochet/pattern.html:191
msgid "Colapsar todo"
msgstr ""
#: crochet/templates/crochet/pattern.html:191
msgid "Expandir todo"
msgstr ""
#: crochet/templates/crochet/pattern.html:196
msgid "Añadir sección"
msgstr ""
#: crochet/templates/crochet/pattern.html:199
msgid "Título"
msgstr ""
#: crochet/templates/crochet/pattern.html:199
msgid "Título..."
msgstr ""
#: crochet/templates/crochet/pattern.html:201
msgid "Subtítulo"
msgstr ""
#: crochet/templates/crochet/pattern.html:201
msgid "Subtítulo..."
msgstr ""
#: crochet/templates/crochet/pattern.html:203
msgid "Texto"
msgstr ""
#: crochet/templates/crochet/pattern.html:203
msgid "Escribe aquí..."
msgstr ""
#: crochet/templates/crochet/pattern.html:205
#: crochet/templates/crochet/pattern.html:206
msgid "Nota"
msgstr ""
#: crochet/templates/crochet/pattern.html:205
msgid "Escribe una nota o consejo..."
msgstr ""
#: crochet/templates/crochet/pattern.html:208
msgid "Materiales"
msgstr ""
#: crochet/templates/crochet/pattern.html:208
msgid "Añadir material..."
msgstr ""
#: crochet/templates/crochet/pattern.html:208
msgid "Añadir línea"
msgstr ""
#: crochet/templates/crochet/pattern.html:210
#: crochet/templates/crochet/pattern.html:212
msgid "Imagen"
msgstr ""
#: crochet/templates/crochet/pattern.html:212
msgid "No se ha podido subir la imagen. Inténtalo de nuevo."
msgstr ""
#: crochet/templates/crochet/pattern.html:214
msgid "Patrón"
msgstr ""
#: crochet/templates/crochet/pattern.html:214
msgid "Elementos"
msgstr ""
#: crochet/templates/crochet/pattern.html:216
msgid "Grupo"
msgstr ""
#: crochet/templates/crochet/pattern.html:220
msgid "Arrastrar para reordenar"
msgstr ""
#: crochet/templates/crochet/pattern.html:166
#: crochet/templates/crochet/pattern.html:221
msgid "Colapsar / expandir"
msgstr ""
#: crochet/templates/crochet/pattern.html:167
#: crochet/templates/crochet/pattern.html:222
msgid "Duplicar sección"
msgstr ""
#: crochet/templates/crochet/pattern.html:168
#: crochet/templates/crochet/pattern.html:223
msgid "Eliminar sección"
msgstr ""
#: crochet/templates/crochet/pattern.html:169
#: crochet/templates/crochet/pattern.html:224
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:229
msgid "Añade una sección para ver aquí el resultado."
msgstr ""
#: crochet/templates/crochet/pattern_detail.html:24
#: crochet/templates/crochet/pattern_detail.html:29
msgid "Descargar PDF"
msgstr ""
@@ -309,6 +682,10 @@ msgid "Entrar"
msgstr ""
#: crochet/templates/registration/login.html:32
msgid "¿Has olvidado tu contraseña?"
msgstr ""
#: crochet/templates/registration/login.html:35
msgid "¿No tienes cuenta? Regístrate"
msgstr ""
@@ -317,57 +694,108 @@ msgstr ""
msgid "Crear cuenta"
msgstr ""
#: crochet/templates/registration/register.html:29
msgid "Registrarme"
msgstr ""
#: crochet/templates/registration/register.html:32
msgid "¿Ya tienes cuenta? Inicia sesión"
msgstr ""
#: crochet/urls.py:38
msgid "cookies/"
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:39
msgid "pattern/<uuid:uuid>/"
msgstr "patron/<uuid:uuid>/"
#: crochet/urls.py:31
#: crochet/urls.py:40
msgid "pattern/<uuid:uuid>/edit/"
msgstr "patron/<uuid:uuid>/editar/"
#: crochet/urls.py:32
#: crochet/urls.py:41
msgid "pattern/<uuid:uuid>/save/"
msgstr "patron/<uuid:uuid>/guardar/"
#: crochet/urls.py:33
#: crochet/urls.py:42
msgid "pattern/<uuid:uuid>/images/"
msgstr "patron/<uuid:uuid>/imagenes/"
#: crochet/urls.py:34
#: crochet/urls.py:43
msgid "pattern/<uuid:uuid>/cover/"
msgstr "patron/<uuid:uuid>/portada/"
#: crochet/urls.py:44
msgid "pattern/<uuid:uuid>/pdf/"
msgstr "patron/<uuid:uuid>/pdf/"
#: crochet/urls.py:35
#: crochet/urls.py:45
msgid "pattern/<uuid:uuid>/delete/"
msgstr "patron/<uuid:uuid>/eliminar/"
#: crochet/urls.py:36
#: crochet/urls.py:46
msgid "pattern/new/"
msgstr "patron/nuevo/"
# Igual que "pattern/" arriba: "account/" se traduce como "cuenta/".
#: crochet/urls.py:37
msgid "account/"
msgstr "cuenta/"
#: crochet/urls.py:47
msgid "account/settings/"
msgstr "cuenta/ajustes/"
#: crochet/urls.py:38
#: crochet/urls.py:48
msgid "account/settings/email/"
msgstr "cuenta/ajustes/email/"
#: crochet/urls.py:50
msgid "account/settings/password/"
msgstr "cuenta/ajustes/contrasena/"
#: crochet/urls.py:54
msgid "account/settings/display-name/"
msgstr "cuenta/ajustes/nombre/"
#: crochet/urls.py:57
msgid "account/settings/delete/"
msgstr "cuenta/ajustes/eliminar/"
#: crochet/urls.py:58
msgid "account/register/"
msgstr "cuenta/registro/"
#: crochet/urls.py:40
#: crochet/urls.py:60
msgid "account/login/"
msgstr "cuenta/entrar/"
#: crochet/urls.py:45
#: crochet/urls.py:65
msgid "account/logout/"
msgstr "cuenta/salir/"
#: crochet/views.py:23
#: crochet/urls.py:67
msgid "account/password-reset/"
msgstr "cuenta/recuperar-contrasena/"
#: crochet/urls.py:86
msgid "account/password-reset/done/"
msgstr "cuenta/recuperar-contrasena/enviado/"
#: crochet/urls.py:90
msgid "account/reset/<uidb64>/<token>/"
msgstr "cuenta/restablecer/<uidb64>/<token>/"
#: crochet/urls.py:98
msgid "account/reset/done/"
msgstr "cuenta/restablecer/hecho/"
#: crochet/views.py:29
msgid "Este patrón todavía no tiene contenido."
msgstr ""
#: crochet/views.py:350
msgid "Tu cuenta se ha eliminado correctamente."
msgstr ""
# Igual que "pattern/" arriba: "account/" se traduce como "cuenta/".
#~ msgid "account/"
#~ msgstr "cuenta/"
@@ -59,7 +59,13 @@ class Command(BaseCommand):
INPUT_CSS.parent.mkdir(parents=True, exist_ok=True)
sources = '\n'.join(f'@source "{path}";' for path in SOURCES)
INPUT_CSS.write_text(f'@import "tailwindcss";\n{sources}\n')
# source(none): sin esto, Tailwind v4 escanea IGUALMENTE todo el
# proyecto en busca de clases por su cuenta (detección automática de
# contenido), además de los @source de abajo; el resultado deja de
# estar acotado a SOURCES y arrastra clases de cualquier otra
# plantilla (pattern.html, home.html...), justo lo que este comando
# existe para evitar.
INPUT_CSS.write_text(f'@import "tailwindcss" source(none);\n{sources}\n')
self.stdout.write(f'Compilando {OUTPUT_CSS.relative_to(settings.BASE_DIR)}...')
result = subprocess.run(
@@ -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))
+3 -2
View File
@@ -89,10 +89,11 @@ body {
/* En pantallas pequeñas, "Secciones" y "Vista previa" se comportan como
pestañas: solo se muestra una a la vez (ver .tabs en pattern.html). A
partir del breakpoint md ambos paneles se muestran siempre, lado a lado.
partir del breakpoint lg (1024px, ver lg:flex-row/lg:w-1/2/lg:hidden en
pattern.html) ambos paneles se muestran siempre, lado a lado.
Se restringe a "screen" para que nunca afecte a la exportación a PDF
(@media print más abajo), sea cual sea el ancho de pantalla del usuario. */
@media screen and (max-width: 767px) {
@media screen and (max-width: 1023px) {
.panel-hidden-mobile {
display: none;
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,22 @@
// Panel lateral de ajustes de la cuenta (ver account_settings.html): solo
// alterna qué <section data-settings-panel> se ve, todo en la misma
// página. Los formularios de cada pestaña siguen siendo los mismos
// fragmentos HTMX de siempre, esto no los toca.
const tabLinks = document.querySelectorAll('[data-settings-tab]');
const panels = document.querySelectorAll('[data-settings-panel]');
function showTab(tabName) {
panels.forEach((panel) => {
panel.classList.toggle('hidden', panel.dataset.settingsPanel !== tabName);
});
tabLinks.forEach((link) => {
link.classList.toggle('menu-active', link.dataset.settingsTab === tabName);
});
}
tabLinks.forEach((link) => {
link.addEventListener('click', (event) => {
event.preventDefault();
showTab(link.dataset.settingsTab);
});
});
+18
View File
@@ -0,0 +1,18 @@
// Aviso de cookies (ver crochet/_cookie_banner.html): la preferencia se
// guarda en localStorage, no en una cookie propia, para no tener que crear
// una cookie solo para recordar que se ha descartado el aviso de cookies.
const STORAGE_KEY = 'cookieConsentAccepted';
const banner = document.getElementById('cookie-banner');
const acceptButton = document.getElementById('cookie-banner-accept');
if (banner && acceptButton) {
if (!localStorage.getItem(STORAGE_KEY)) {
banner.classList.remove('hidden');
}
acceptButton.addEventListener('click', () => {
localStorage.setItem(STORAGE_KEY, '1');
banner.classList.add('hidden');
});
}
+37
View File
@@ -0,0 +1,37 @@
// 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, coverImagePlaceholder, 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');
coverImagePlaceholder.classList.add('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);
}
});
+7
View File
@@ -22,3 +22,10 @@ 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 coverImagePlaceholder = document.getElementById('cover-image-placeholder'); // Icono + texto que tapa la miniatura mientras no hay portada.
export const coverImageLargeUrlInput = document.getElementById('cover-image-large-url'); // Leído por render.js para "Vista previa".
+2 -1
View File
@@ -8,9 +8,10 @@ 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)';
const MOBILE_BREAKPOINT_QUERY = '(max-width: 1023px)';
// El propio patrón (sections/pageSettings ya guardados) viaja embebido en la
// plantilla Django, ver {{ pattern_data|json_script:"pattern-data" }} en
+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
-1
View File
@@ -405,7 +405,6 @@ export function buildSectionsFrom(sectionsData, container = sectionsContainer) {
// reconstruyen recorriendo `children` a través de buildSectionsFrom(), el
// mismo mecanismo que restaura un patrón guardado.
const DEFAULT_SECTIONS = [
{ type: 'image', imageId: null, url: '' },
{
type: 'group',
children: [
+1 -1
View File
@@ -1,5 +1,5 @@
// Pestañas (solo en móvil, ver css/main.css): alternar entre el panel de
// "Secciones" y el de "Vista previa". A partir del breakpoint md la clase
// "Secciones" y el de "Vista previa". A partir del breakpoint lg la clase
// `panel-hidden-mobile` no tiene efecto y ambos paneles quedan visibles
// permanentemente, lado a lado. También la exportación a PDF, que se apoya
// en showTab() para asegurarse de que "Vista previa" esté visible antes de
@@ -0,0 +1,20 @@
{% load i18n %}
<!-- hx-confirm (no un onsubmit propio): htmx cancela la petición si se
rechaza el diálogo, sin depender de si un onsubmit normal llega a
ejecutarse antes que el propio listener de htmx. En éxito, la
respuesta no trae fragmento que pintar: manda la cabecera HX-Redirect
(ver AccountDeleteView) y htmx navega de verdad a esa URL, ya sin
sesión que mostrar en esta misma página. -->
<form hx-post="{% url 'crochet:account_settings_delete' %}" hx-target="this" hx-swap="outerHTML"
hx-confirm="{% trans '¿Seguro que quieres eliminar tu cuenta? Se borrarán también todos tus patrones. Esta acción no se puede deshacer.' %}"
class="flex flex-col gap-3">
{% csrf_token %}
<label class="flex flex-col gap-1">
<span class="text-sm">{{ delete_form.password.label }}</span>
{{ delete_form.password }}
{% for error in delete_form.password.errors %}
<span class="text-error text-xs">{{ error }}</span>
{% endfor %}
</label>
<button type="submit" class="btn btn-error self-start">{% trans 'Eliminar cuenta' %}</button>
</form>
@@ -0,0 +1,18 @@
{% load i18n %}
<form hx-post="{% url 'crochet:account_settings_display_name' %}" hx-target="this" hx-swap="outerHTML"
class="flex flex-col gap-3">
{% csrf_token %}
{% if display_name_updated %}
<div role="alert" class="alert alert-success alert-soft">
<span>{% trans 'Nombre actualizado.' %}</span>
</div>
{% endif %}
<label class="flex flex-col gap-1">
<span class="text-sm">{{ display_name_form.display_name.label }}</span>
{{ display_name_form.display_name }}
{% for error in display_name_form.display_name.errors %}
<span class="text-error text-xs">{{ error }}</span>
{% endfor %}
</label>
<button type="submit" class="btn btn-primary self-start">{% trans 'Guardar nombre' %}</button>
</form>
@@ -0,0 +1,21 @@
{% load i18n %}
<!-- hx-target="this"/hx-swap="outerHTML": la respuesta (éxito o con
errores de validación) sustituye este mismo <form>, así que no hace
falta JS propio para pintar el resultado. -->
<form hx-post="{% url 'crochet:account_settings_email' %}" hx-target="this" hx-swap="outerHTML"
class="flex flex-col gap-3">
{% csrf_token %}
{% if email_updated %}
<div role="alert" class="alert alert-success alert-soft">
<span>{% trans 'Email actualizado.' %}</span>
</div>
{% endif %}
<label class="flex flex-col gap-1">
<span class="text-sm">{{ email_form.email.label }}</span>
{{ email_form.email }}
{% for error in email_form.email.errors %}
<span class="text-error text-xs">{{ error }}</span>
{% endfor %}
</label>
<button type="submit" class="btn btn-primary self-start">{% trans 'Guardar email' %}</button>
</form>
@@ -0,0 +1,20 @@
{% load i18n %}
<form hx-post="{% url 'crochet:account_settings_password' %}" hx-target="this" hx-swap="outerHTML"
class="flex flex-col gap-3">
{% csrf_token %}
{% if password_updated %}
<div role="alert" class="alert alert-success alert-soft">
<span>{% trans 'Contraseña actualizada.' %}</span>
</div>
{% endif %}
{% for field in password_form %}
<label class="flex flex-col gap-1">
<span class="text-sm">{{ field.label }}</span>
{{ field }}
{% for error in field.errors %}
<span class="text-error text-xs">{{ error }}</span>
{% endfor %}
</label>
{% endfor %}
<button type="submit" class="btn btn-primary self-start">{% trans 'Cambiar contraseña' %}</button>
</form>
@@ -0,0 +1,13 @@
{% load i18n %}
<!-- Oculto por defecto (clase "hidden" de Tailwind): cookie-consent.js lo
muestra solo si el navegador no tiene ya guardado que se aceptó, para
que no parpadee visible un instante en cada carga de página. -->
<div id="cookie-banner" class="hidden fixed inset-x-0 bottom-0 z-50 p-4 no-print">
<div class="alert bg-base-100 border border-base-300 shadow-lg max-w-2xl mx-auto flex-col sm:flex-row items-center gap-4">
<span class="text-sm">
{% blocktrans %}Usamos únicamente las cookies necesarias para que la web funcione (mantener tu sesión iniciada y proteger los formularios). No usamos cookies de analítica ni de publicidad.{% endblocktrans %}
<a href="{% url 'crochet:cookie_policy' %}" class="link link-primary">{% trans 'Más información' %}</a>
</span>
<button type="button" id="cookie-banner-accept" class="btn btn-sm btn-primary shrink-0">{% trans 'Entendido' %}</button>
</div>
</div>
@@ -1,9 +1,10 @@
{% load static %}
{% load static django_htmx %}
{# Un comentario Django de una sola línea (su lexer no cruza saltos de línea al buscar el cierre, y tampoco admite el propio delimitador de comentario dentro del texto). #}
{# Incluido por cada plantilla con look de "plataforma" (editor, cuenta): daisyUI + Tailwind vía CDN + el tema propio "crochet" (ver theme.css). pattern_detail.html NO lo incluye a propósito (ver ese archivo). #}
{# Incluido por cada plantilla con look de "plataforma" (editor, cuenta, y también pattern_detail.html para tener el mismo navbar/cabecera): daisyUI + Tailwind vía CDN + el tema propio "crochet" (ver theme.css) + htmx. #}
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<link rel="stylesheet" href="{% static 'css/theme.css' %}">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap" rel="stylesheet">
{% htmx_script %}
+16 -11
View File
@@ -10,7 +10,7 @@
{% block content %}
<main class="max-w-4xl mx-auto px-4 py-8">
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
<h1 class="text-2xl font-semibold">{% blocktrans with username=user.username %}Hola, {{ username }}{% endblocktrans %}</h1>
<h1 class="text-2xl font-semibold">{% blocktrans with username=user.get_full_name|default:user.username %}Hola, {{ username }}{% endblocktrans %}</h1>
<form method="post" action="{% url 'crochet:pattern_create' %}">
{% csrf_token %}
<button type="submit" class="btn btn-primary">+ {% trans 'Crear patrón' %}</button>
@@ -22,16 +22,26 @@
<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">
<a href="{% url 'crochet:pattern_edit' pattern.uuid %}">
<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>
</a>
<div class="card-body">
<h2 class="card-title text-lg">
{% if pattern.page_settings.title %}{{ pattern.page_settings.title }}{% else %}{% trans 'Patrón sin título' %}{% endif %}
</h2>
<a href="{% url 'crochet:pattern_edit' pattern.uuid %}">
<h2 class="card-title text-lg">
{% if pattern.page_settings.title %}{{ pattern.page_settings.title }}{% else %}{% trans 'Patrón sin título' %}{% endif %}
</h2>
</a>
<p class="text-sm text-base-content/60">
{% blocktrans with date=pattern.updated_at|date:'d/m/Y' %}Actualizado el {{ date }}{% endblocktrans %}
</p>
<div class="card-actions justify-end mt-2">
<a href="{% url 'crochet:pattern_detail' pattern.uuid %}" class="btn btn-sm btn-outline">{% trans 'Ver patrón' %}</a>
<a href="{% url 'crochet:pattern_edit' pattern.uuid %}" class="btn btn-sm btn-primary">{% trans 'Editar' %}</a>
<a href="{% url 'crochet:pattern_detail' pattern.uuid %}" class="btn btn-sm btn-outline">{% trans 'Vista previa' %}</a>
<form method="post" action="{% url 'crochet:pattern_delete' pattern.uuid %}"
onsubmit="return confirm('{{ delete_confirm_message }}')">
{% csrf_token %}
@@ -47,10 +57,5 @@
<p>{% trans 'Todavía no tienes ningún patrón.' %}</p>
</div>
{% endif %}
<form method="post" action="{% url 'crochet:logout' %}" class="mt-8">
{% csrf_token %}
<button type="submit" class="btn btn-outline btn-sm">{% trans 'Cerrar sesión' %}</button>
</form>
</main>
{% endblock %}
@@ -0,0 +1,83 @@
{% extends 'crochet/base.html' %}
{% load static i18n %}
{% block title %}{% trans 'Ajustes de la cuenta' %}{% endblock %}
<!-- Igual que pattern.html: la cabecera X-CSRFToken que exige Django en
todo POST, leída del propio csrf_token en vez de la cookie (ver
_account_email_form.html/_account_password_form.html, que llegan por
hx-post). -->
{% block body_attrs %} hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'{% endblock %}
{% block extra_css %}
{% include 'crochet/_platform_assets.html' %}
{% endblock %}
{% block content %}
<main class="max-w-4xl mx-auto px-4 py-8">
<h1 class="text-2xl font-semibold mb-6">{% trans 'Ajustes de la cuenta' %}</h1>
<div class="flex flex-col md:flex-row gap-8">
<!-- Panel lateral: solo cambia qué <section data-settings-panel> se
ve (ver account-settings-tabs.js), no hay URL ni petición nueva
por pestaña; los formularios de cada una siguen siendo los
mismos fragmentos HTMX de siempre. -->
<nav class="md:w-56 shrink-0 no-print">
<!-- w-full: .menu de daisyUI se ajusta al contenido por defecto
(no ocupa el 100% de su contenedor), así que sin esto quedaba
visiblemente más estrecho que las tarjetas de abajo en móvil. -->
<ul class="menu bg-base-100 rounded-box border border-base-300 p-2 w-full md:flex-col">
<li><a href="#" data-settings-tab="profile" class="menu-active">{% trans 'Perfil' %}</a></li>
<li><a href="#" data-settings-tab="security">{% trans 'Seguridad' %}</a></li>
<li><a href="#" data-settings-tab="danger" class="text-error">{% trans 'Zona de peligro' %}</a></li>
</ul>
</nav>
<div class="flex-1 min-w-0">
<section data-settings-panel="profile" class="flex flex-col gap-8">
<section class="card bg-base-100 shadow border border-base-300">
<div class="card-body">
<h2 class="card-title text-lg">{% trans 'Nombre para mostrar' %}</h2>
{% include 'crochet/_account_display_name_form.html' %}
</div>
</section>
</section>
<section data-settings-panel="security" class="hidden flex-col gap-8">
<section class="card bg-base-100 shadow border border-base-300">
<div class="card-body">
<h2 class="card-title text-lg">{% trans 'Email' %}</h2>
{% include 'crochet/_account_email_form.html' %}
</div>
</section>
<section class="card bg-base-100 shadow border border-base-300">
<div class="card-body">
<h2 class="card-title text-lg">{% trans 'Cambiar contraseña' %}</h2>
{% include 'crochet/_account_password_form.html' %}
</div>
</section>
</section>
<!-- Zona de peligro en su propia pestaña, no solo con border-error
de por sí: separada del resto de ajustes, no algo con lo que
te puedas topar sin querer bajando por la página. -->
<section data-settings-panel="danger" class="hidden flex-col gap-8">
<section class="card bg-base-100 shadow border border-error">
<div class="card-body">
<h2 class="card-title text-lg text-error">{% trans 'Eliminar cuenta' %}</h2>
<p class="text-sm text-base-content/70">
{% trans 'Esto borrará tu cuenta y todos tus patrones de forma permanente.' %}
</p>
{% include 'crochet/_account_delete_form.html' %}
</div>
</section>
</section>
</div>
</div>
</main>
{% endblock %}
{% block extra_js %}
<script defer src="{% static 'js/account-settings-tabs.js' %}"></script>
{% endblock %}
+55 -9
View File
@@ -1,4 +1,4 @@
{% load static i18n %}
{% load static i18n crochet_i18n %}
<!DOCTYPE html>
<!-- get_current_language (no la variable "lang" del contexto): la resuelve
LocaleMiddleware a partir del prefijo de la URL en cualquier vista, no
@@ -24,23 +24,69 @@
solo el propio patrón. no-print para que nunca salga en el PDF. -->
<nav class="navbar bg-base-100 border-b border-base-300 px-4 no-print">
<div class="flex-1">
<a href="{% url 'crochet:account_home' %}" class="btn btn-ghost text-xl px-2">🧶 Crochet</a>
<a href="{% url 'crochet:home' %}" class="btn btn-ghost text-xl px-2">🧶 Crochet</a>
</div>
<div class="flex-none flex items-center gap-2">
{% get_current_language as CURRENT_LANGUAGE %}
{% get_available_languages as AVAILABLE_LANGUAGES %}
<!-- El idioma es una preferencia de la página, no de la cuenta: va
siempre en el mismo sitio del navbar, tanto si has iniciado
sesión como si no, en vez de vivir dentro del dropdown de
usuario (que no existe para anónimos). Cada opción es un <a>
normal (no un <select>), así que navegar con teclado entre
ellas no dispara nada hasta que se pulsa/activa una. -->
<div class="dropdown dropdown-end">
<button type="button" tabindex="0" class="btn btn-sm btn-ghost" aria-label="{% trans 'Cambiar idioma' %}">{{ CURRENT_LANGUAGE|language_flag }} {{ CURRENT_LANGUAGE|upper }}</button>
<ul tabindex="0" class="dropdown-content menu menu-sm bg-base-100 border border-base-300 rounded-box z-10 w-40 p-2 shadow">
{% for lang_code, lang_name in AVAILABLE_LANGUAGES %}
<li><a href="{% translate_url request.path lang_code %}"{% if lang_code == CURRENT_LANGUAGE %} class="menu-active"{% endif %}>{{ lang_code|language_flag }} {{ lang_name }}</a></li>
{% endfor %}
</ul>
</div>
{% if user.is_authenticated %}
<span class="text-sm hidden sm:inline">{{ user.username }}</span>
<form method="post" action="{% url 'crochet:logout' %}">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-ghost">{% trans 'Cerrar sesión' %}</button>
</form>
<!-- Dropdown CSS puro de daisyUI (sin JS propio): se abre con
:focus-within al pulsar/tabular al botón disparador. -->
<div class="dropdown dropdown-end">
<button type="button" tabindex="0" class="btn btn-sm btn-ghost">{{ user.get_full_name|default:user.username }}</button>
<ul tabindex="0" class="dropdown-content menu menu-sm bg-base-100 border border-base-300 rounded-box z-10 w-52 p-2 shadow">
<li><a href="{% url 'crochet:account_settings' %}">{% trans 'Ajustes de la cuenta' %}</a></li>
<li>
<form method="post" action="{% url 'crochet:logout' %}">
{% csrf_token %}
<button type="submit" class="w-full text-left">{% trans 'Cerrar sesión' %}</button>
</form>
</li>
</ul>
</div>
{% else %}
<a href="{% url 'crochet:login' %}" class="btn btn-sm btn-ghost">{% trans 'Iniciar sesión' %}</a>
<a href="{% url 'crochet:register' %}" class="btn btn-sm btn-primary">{% trans 'Registrarme' %}</a>
<a href="{% url 'crochet:login' %}" class="btn btn-sm btn-primary">{% trans 'Iniciar sesión' %}</a>
{% endif %}
</div>
</nav>
{% endblock %}
<!-- Mensajes de una sola vez tras una redirección (ver messages.success
en AccountDeleteView): no hay ninguna página a la que volver con el
resultado de la acción (la sesión ya no existe), así que se muestran
aquí, en la página a la que se redirige. django.contrib.messages ya
estaba instalado (middleware + procesador de contexto) pero sin
usar hasta ahora. -->
{% if messages %}
<div class="max-w-2xl mx-auto px-4 pt-4 flex flex-col gap-2 no-print">
{% for message in messages %}
<div role="alert" class="alert alert-soft{% if message.tags %} alert-{{ message.tags }}{% endif %}">
<span>{{ message }}</span>
</div>
{% endfor %}
</div>
{% endif %}
{% block content %}{% endblock %}
<!-- pattern_detail.html (vista de solo lectura/PDF) sobreescribe este
bloque dejándolo vacío, igual que el navbar: no carga daisyUI y no
debe llevar JS de más (WeasyPrint no lo ejecuta de todos modos). -->
{% block cookie_banner %}
{% include 'crochet/_cookie_banner.html' %}
<script defer src="{% static 'js/cookie-consent.js' %}"></script>
{% endblock %}
{% block extra_js %}{% endblock %}
</body>
@@ -0,0 +1,56 @@
{% extends 'crochet/base.html' %}
{% load i18n %}
{% block title %}{% trans 'Política de cookies' %}{% endblock %}
{% block extra_css %}
{% include 'crochet/_platform_assets.html' %}
{% endblock %}
{% block content %}
<main class="max-w-2xl mx-auto px-4 py-8 flex flex-col gap-6">
<h1 class="text-2xl font-semibold">{% trans 'Política de cookies' %}</h1>
<p>
{% blocktrans %}Una cookie es un pequeño archivo que una web guarda en tu navegador. Aquí usamos únicamente las que hacen falta para que la página funcione; no usamos cookies de analítica ni de publicidad, ni de terceros.{% endblocktrans %}
</p>
<section class="card bg-base-100 shadow border border-base-300">
<div class="card-body">
<h2 class="card-title text-lg">{% trans 'Cookies que usamos' %}</h2>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>{% trans 'Nombre' %}</th>
<th>{% trans 'Finalidad' %}</th>
<th>{% trans 'Duración' %}</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>sessionid</code></td>
<td>{% trans 'Mantiene tu sesión iniciada.' %}</td>
<td>{% trans 'Hasta que cierras sesión o caduca.' %}</td>
</tr>
<tr>
<td><code>csrftoken</code></td>
<td>{% trans 'Protege los formularios frente a ataques de falsificación de petición (CSRF).' %}</td>
<td>{% trans '1 año.' %}</td>
</tr>
</tbody>
</table>
</div>
<p class="text-sm text-base-content/70">
{% blocktrans %}Además, tu navegador guarda localmente (no como cookie) que ya has visto el aviso de cookies, para no volver a mostrártelo.{% endblocktrans %}
</p>
</div>
</section>
<p>
{% blocktrans %}Ambas son cookies técnicas necesarias para el funcionamiento del sitio (iniciar sesión y proteger los formularios), así que no requieren tu consentimiento previo.{% endblocktrans %}
</p>
<a href="{% url 'crochet:home' %}" class="link link-primary">{% trans 'Volver al inicio' %}</a>
</main>
{% endblock %}
@@ -0,0 +1,18 @@
{% extends 'crochet/email/base.html' %}
{% load i18n %}
{% block title %}{% blocktrans %}Tu cuenta en {{ site_name }} ha sido eliminada{% endblocktrans %}{% endblock %}
{% block content %}
<p style="margin:0 0 16px 0;">
{% blocktrans %}Tu cuenta en {{ site_name }} y todos tus patrones se han eliminado de forma permanente.{% endblocktrans %}
</p>
<p style="margin:0 0 16px 0; font-size:13px; color:#2e2118;">
{% trans 'Tu usuario:' %} {{ user.get_username }}
</p>
<p style="margin:0; font-weight:600;">
{% blocktrans %}Si no has sido tú quien ha hecho este cambio, contacta cuanto antes con soporte técnico en {{ support_email }}.{% endblocktrans %}
</p>
{% endblock %}
@@ -0,0 +1,7 @@
{% load i18n %}{% autoescape off %}
{% blocktrans %}Tu cuenta en {{ site_name }} y todos tus patrones se han eliminado de forma permanente.{% endblocktrans %}
{% trans 'Tu usuario:' %} {{ user.get_username }}
{% blocktrans %}Si no has sido tú quien ha hecho este cambio, contacta cuanto antes con soporte técnico en {{ support_email }}.{% endblocktrans %}
{% endautoescape %}
@@ -0,0 +1 @@
{% load i18n %}{% blocktrans %}Tu cuenta en {{ site_name }} ha sido eliminada{% endblocktrans %}
+52
View File
@@ -0,0 +1,52 @@
{% load i18n %}<!DOCTYPE html>
<html lang="{% get_current_language as html_lang %}{{ html_lang }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light">
<title>{% block title %}Crochet{% endblock %}</title>
</head>
<!--
Plantilla base para los emails de la app (HTML): la heredan las
plantillas concretas (ver crochet/email/password_reset.html) igual que
crochet/base.html hace con las páginas normales, extendiendo esta y
rellenando el bloque de contenido.
Estilos en línea (no en un elemento de estilos en head) y layout con
table, no con flexbox/grid: muchos clientes de correo (Gmail, Outlook de
escritorio...) ignoran los estilos puestos en head o no soportan CSS
moderno, pero sí respetan los estilos puestos directamente en cada
elemento. Colores calcados de --brand-* en css/main.css (aquí a mano,
sin variables CSS por la misma razón de compatibilidad).
-->
<body style="margin:0; padding:0; background-color:#f7efe1; font-family:'Poppins', Arial, sans-serif; color:#2e2118;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f7efe1;">
<tr>
<td align="center" style="padding:32px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:480px; background-color:#fffbf5; border:1px solid #ede0c9; border-radius:16px;">
<tr>
<td style="padding:24px 32px 0 32px;">
<span style="font-size:22px; font-weight:600; color:#2e2118;">🧶 Crochet</span>
</td>
</tr>
<tr>
<td style="padding:16px 32px 32px 32px; font-size:15px; line-height:1.6; color:#2e2118;">
{% block content %}{% endblock %}
</td>
</tr>
</table>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:480px;">
<tr>
<td style="padding:16px 32px; text-align:center; font-size:12px; color:#2e2118;">
{% block footer %}{% trans 'Este email se ha enviado automáticamente, no respondas a esta dirección.' %}{% endblock %}
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
@@ -0,0 +1,18 @@
{% extends 'crochet/email/base.html' %}
{% load i18n %}
{% block title %}{% blocktrans %}El email de tu cuenta en {{ site_name }} ha cambiado{% endblocktrans %}{% endblock %}
{% block content %}
<p style="margin:0 0 16px 0;">
{% blocktrans %}El email de tu cuenta en {{ site_name }} se ha cambiado. Esta dirección ({{ old_email }}) ha dejado de estar asociada a tu cuenta.{% endblocktrans %}
</p>
<p style="margin:0 0 16px 0; font-size:13px; color:#2e2118;">
{% trans 'Tu usuario:' %} {{ user.get_username }}
</p>
<p style="margin:0; font-weight:600;">
{% blocktrans %}Si no has sido tú quien ha hecho este cambio, contacta cuanto antes con soporte técnico en {{ support_email }}.{% endblocktrans %}
</p>
{% endblock %}
@@ -0,0 +1,7 @@
{% load i18n %}{% autoescape off %}
{% blocktrans %}El email de tu cuenta en {{ site_name }} se ha cambiado. Esta dirección ({{ old_email }}) ha dejado de estar asociada a tu cuenta.{% endblocktrans %}
{% trans 'Tu usuario:' %} {{ user.get_username }}
{% blocktrans %}Si no has sido tú quien ha hecho este cambio, contacta cuanto antes con soporte técnico en {{ support_email }}.{% endblocktrans %}
{% endautoescape %}
@@ -0,0 +1 @@
{% load i18n %}{% blocktrans %}El email de tu cuenta en {{ site_name }} ha cambiado{% endblocktrans %}
@@ -0,0 +1,18 @@
{% extends 'crochet/email/base.html' %}
{% load i18n %}
{% block title %}{% blocktrans %}La contraseña de tu cuenta en {{ site_name }} ha cambiado{% endblocktrans %}{% endblock %}
{% block content %}
<p style="margin:0 0 16px 0;">
{% blocktrans %}La contraseña de tu cuenta en {{ site_name }} ha cambiado.{% endblocktrans %}
</p>
<p style="margin:0 0 16px 0; font-size:13px; color:#2e2118;">
{% trans 'Tu usuario:' %} {{ user.get_username }}
</p>
<p style="margin:0; font-weight:600;">
{% blocktrans %}Si no has sido tú quien ha hecho este cambio, contacta cuanto antes con soporte técnico en {{ support_email }}.{% endblocktrans %}
</p>
{% endblock %}
@@ -0,0 +1,7 @@
{% load i18n %}{% autoescape off %}
{% blocktrans %}La contraseña de tu cuenta en {{ site_name }} ha cambiado.{% endblocktrans %}
{% trans 'Tu usuario:' %} {{ user.get_username }}
{% blocktrans %}Si no has sido tú quien ha hecho este cambio, contacta cuanto antes con soporte técnico en {{ support_email }}.{% endblocktrans %}
{% endautoescape %}
@@ -0,0 +1 @@
{% load i18n %}{% blocktrans %}La contraseña de tu cuenta en {{ site_name }} ha cambiado{% endblocktrans %}
@@ -0,0 +1,25 @@
{% extends 'crochet/email/base.html' %}
{% load i18n %}
{% block title %}{% trans 'Recuperar contraseña' %}{% endblock %}
{% block content %}
<p style="margin:0 0 16px 0;">
{% blocktrans %}Has recibido este email porque alguien ha solicitado restablecer la contraseña de tu cuenta en {{ site_name }}.{% endblocktrans %}
</p>
<p style="text-align:center; margin:24px 0;">
<a href="{{ protocol }}://{{ domain }}{% url 'crochet:password_reset_confirm' uidb64=uid token=token %}"
style="display:inline-block; background-color:#f1641e; color:#ffffff; text-decoration:none; padding:12px 28px; border-radius:999px; font-weight:600;">
{% trans 'Elegir nueva contraseña' %}
</a>
</p>
<p style="margin:0 0 8px 0; font-size:13px; color:#2e2118;">
{% trans 'Tu usuario, por si lo has olvidado:' %} {{ user.get_username }}
</p>
<p style="margin:0; font-size:13px; color:#2e2118;">
{% trans 'Si no has solicitado este cambio, puedes ignorar este email.' %}
</p>
{% endblock %}
+61
View File
@@ -0,0 +1,61 @@
{% extends 'crochet/base.html' %}
{% load i18n %}
{% block title %}{% trans 'Crochet — Crea y comparte tus patrones' %}{% endblock %}
{% block extra_css %}
{% include 'crochet/_platform_assets.html' %}
{% endblock %}
{% block content %}
<main>
<div class="hero py-16 px-4">
<div class="hero-content text-center">
<div class="max-w-xl">
<h1 class="text-4xl font-bold">{% trans 'Crea y comparte tus patrones de crochet' %}</h1>
<p class="py-6 text-lg text-base-content/70">
{% trans 'Diseña tus patrones con un editor visual, expórtalos a PDF y compártelos con quien quieras, sin instalar nada.' %}
</p>
<!-- Sin el "if user.is_authenticated" que había aquí antes: con
sesión iniciada, HomeView ya no llega a renderizar esta
plantilla en absoluto (ver get_template_names), así que esa
rama nunca se alcanzaba. -->
<div class="flex flex-wrap justify-center gap-4">
<a href="{% url 'crochet:register' %}" class="btn btn-primary btn-lg">{% trans 'Crear cuenta gratis' %}</a>
<a href="{% url 'crochet:login' %}" class="btn btn-outline btn-lg">{% trans 'Iniciar sesión' %}</a>
</div>
</div>
</div>
</div>
<div class="max-w-4xl mx-auto px-4 pb-16 grid gap-6 sm:grid-cols-3">
<div class="card bg-base-100 shadow border border-base-300">
<div class="card-body items-center text-center">
<span class="text-4xl mb-2">✏️</span>
<h2 class="card-title text-lg">{% trans 'Editor visual' %}</h2>
<p class="text-sm text-base-content/70">
{% trans 'Añade títulos, instrucciones, materiales e imágenes por secciones, sin complicarte con el formato.' %}
</p>
</div>
</div>
<div class="card bg-base-100 shadow border border-base-300">
<div class="card-body items-center text-center">
<span class="text-4xl mb-2">📄</span>
<h2 class="card-title text-lg">{% trans 'Exporta a PDF' %}</h2>
<p class="text-sm text-base-content/70">
{% trans 'Descarga tu patrón listo para imprimir, con tu propia portada, colores y tipografía.' %}
</p>
</div>
</div>
<div class="card bg-base-100 shadow border border-base-300">
<div class="card-body items-center text-center">
<span class="text-4xl mb-2">🔗</span>
<h2 class="card-title text-lg">{% trans 'Comparte con un enlace' %}</h2>
<p class="text-sm text-base-content/70">
{% trans 'Envía tu patrón a quien quieras con un enlace de solo lectura: nadie más podrá editarlo.' %}
</p>
</div>
</div>
</div>
</main>
{% endblock %}
@@ -0,0 +1,22 @@
{% extends 'crochet/base.html' %}
{% load i18n %}
{% block title %}{% trans 'Contraseña actualizada' %}{% endblock %}
{% block extra_css %}
{% include 'crochet/_platform_assets.html' %}
{% endblock %}
{% block content %}
<main class="flex justify-center px-4 py-12">
<div class="card w-full max-w-sm bg-base-100 shadow-xl border border-base-300">
<div class="card-body">
<h1 class="card-title">{% trans 'Contraseña actualizada' %}</h1>
<p class="text-sm text-base-content/70">
{% trans 'Ya puedes iniciar sesión con tu nueva contraseña.' %}
</p>
<a href="{% url 'crochet:login' %}" class="btn btn-primary mt-2">{% trans 'Iniciar sesión' %}</a>
</div>
</div>
</main>
{% endblock %}
@@ -0,0 +1,44 @@
{% extends 'crochet/base.html' %}
{% load i18n %}
{% block title %}{% trans 'Elegir nueva contraseña' %}{% endblock %}
{% block extra_css %}
{% include 'crochet/_platform_assets.html' %}
{% endblock %}
{% block content %}
<main class="flex justify-center px-4 py-12">
<div class="card w-full max-w-sm bg-base-100 shadow-xl border border-base-300">
<div class="card-body">
{% if validlink %}
<h1 class="card-title">{% trans 'Elige una contraseña nueva' %}</h1>
<form method="post" class="flex flex-col gap-3">
{% csrf_token %}
{% for field in form %}
<label class="flex flex-col gap-1">
<span class="text-sm">{{ field.label }}</span>
{{ field }}
{% for error in field.errors %}
<span class="text-error text-xs">{{ error }}</span>
{% endfor %}
{% if field.help_text %}
<span class="text-xs text-base-content/60">{{ field.help_text }}</span>
{% endif %}
</label>
{% endfor %}
<button type="submit" class="btn btn-primary mt-2">{% trans 'Cambiar contraseña' %}</button>
</form>
{% else %}
<h1 class="card-title">{% trans 'Enlace no válido' %}</h1>
<p class="text-sm text-base-content/70">
{% trans 'El enlace para restablecer la contraseña no es válido, puede que ya se haya usado. Solicita uno nuevo.' %}
</p>
<p class="text-sm text-center mt-2">
<a href="{% url 'crochet:password_reset' %}" class="link link-primary">{% trans 'Solicitar un enlace nuevo' %}</a>
</p>
{% endif %}
</div>
</div>
</main>
{% endblock %}
@@ -0,0 +1,24 @@
{% extends 'crochet/base.html' %}
{% load i18n %}
{% block title %}{% trans 'Recuperar contraseña' %}{% endblock %}
{% block extra_css %}
{% include 'crochet/_platform_assets.html' %}
{% endblock %}
{% block content %}
<main class="flex justify-center px-4 py-12">
<div class="card w-full max-w-sm bg-base-100 shadow-xl border border-base-300">
<div class="card-body">
<h1 class="card-title">{% trans 'Revisa tu email' %}</h1>
<p class="text-sm text-base-content/70">
{% trans 'Si existe una cuenta con ese email, te hemos enviado un enlace para elegir una contraseña nueva.' %}
</p>
<p class="text-sm text-center mt-2">
<a href="{% url 'crochet:login' %}" class="link link-primary">{% trans 'Volver a iniciar sesión' %}</a>
</p>
</div>
</div>
</main>
{% endblock %}
@@ -0,0 +1,10 @@
{% load i18n %}{% autoescape off %}
{% blocktrans %}Has recibido este email porque alguien ha solicitado restablecer la contraseña de tu cuenta en {{ site_name }}.{% endblocktrans %}
{% trans 'Sigue este enlace para elegir una contraseña nueva:' %}
{{ protocol }}://{{ domain }}{% url 'crochet:password_reset_confirm' uidb64=uid token=token %}
{% trans 'Tu usuario, por si lo has olvidado:' %} {{ user.get_username }}
{% trans 'Si no has solicitado este cambio, puedes ignorar este email.' %}
{% endautoescape %}
@@ -0,0 +1,37 @@
{% extends 'crochet/base.html' %}
{% load i18n %}
{% block title %}{% trans 'Recuperar contraseña' %}{% endblock %}
{% block extra_css %}
{% include 'crochet/_platform_assets.html' %}
{% endblock %}
{% block content %}
<main class="flex justify-center px-4 py-12">
<div class="card w-full max-w-sm bg-base-100 shadow-xl border border-base-300">
<div class="card-body">
<h1 class="card-title">{% trans 'Recuperar contraseña' %}</h1>
<p class="text-sm text-base-content/70">
{% trans 'Escribe tu email y te enviaremos un enlace para elegir una contraseña nueva.' %}
</p>
<form method="post" class="flex flex-col gap-3">
{% csrf_token %}
{% for field in form %}
<label class="flex flex-col gap-1">
<span class="text-sm">{{ field.label }}</span>
{{ field }}
{% for error in field.errors %}
<span class="text-error text-xs">{{ error }}</span>
{% endfor %}
</label>
{% endfor %}
<button type="submit" class="btn btn-primary mt-2">{% trans 'Enviar enlace' %}</button>
</form>
<p class="text-sm text-center mt-2">
<a href="{% url 'crochet:login' %}" class="link link-primary">{% trans 'Volver a iniciar sesión' %}</a>
</p>
</div>
</div>
</main>
{% endblock %}
@@ -0,0 +1 @@
{% load i18n %}{% blocktrans %}Recupera tu contraseña en {{ site_name }}{% endblocktrans %}
+68 -29
View File
@@ -1,5 +1,5 @@
{% extends 'crochet/base.html' %}
{% load static i18n django_htmx %}
{% load static i18n %}
{% block title %}Crochet{% endblock %}
@@ -9,7 +9,6 @@
<!-- Leído por storage.js para la cabecera X-CSRFToken al guardar por fetch. -->
<meta name="csrf-token" content="{{ csrf_token }}">
{% include 'crochet/_platform_assets.html' %}
{% htmx_script %}
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
<style id="page-size-style"></style>
@@ -19,13 +18,12 @@
<main>
{% trans '¿Seguro que quieres eliminar este patrón? No podrás deshacerlo.' as delete_confirm_message %}
<header class="flex flex-wrap items-center justify-end gap-2 p-4 no-print">
<div class="flex gap-2 items-center">
<select id="language-select" class="select select-sm select-bordered">
<option value="es" {% if lang == 'es' %}selected{% endif %}>Español</option>
<option value="en" {% if lang == 'en' %}selected{% endif %}>English</option>
</select>
<header class="flex flex-col-reverse sm:flex-row sm:items-center sm:justify-end gap-2 p-4 no-print">
<label id="pattern-title" class="flex flex-col gap-1 flex-1 mb-4">
<span class="text-sm">{% trans 'Título del patrón' %}</span>
<input id="page-title-input" type="text" class="input input-bordered" placeholder="{% trans 'Título del patrón...' %}">
</label>
<div id="patterns-action-buttons" class="flex flex-wrap items-center gap-2">
<button id="save-pattern" type="button" class="btn btn-sm btn-outline"
data-save-url="{% url 'crochet:pattern_save' pattern.uuid %}"
data-error-message="{% trans 'No se ha podido guardar. Inténtalo de nuevo.' %}">{% trans 'Guardar' %}</button>
@@ -33,11 +31,23 @@
<a href="{% url 'crochet:pattern_detail' pattern.uuid %}" target="_blank" rel="noopener"
class="btn btn-sm btn-outline">{% trans 'Ver patrón' %}</a>
<button id="export-pdf" type="button" class="btn btn-sm btn-primary">{% trans 'Exportar a PDF' %}</button>
<form method="post" action="{% url 'crochet:pattern_delete' pattern.uuid %}"
onsubmit="return confirm('{{ delete_confirm_message }}')">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-outline btn-error">{% trans 'Eliminar patrón' %}</button>
</form>
<!-- Eliminar va detrás de un menú aparte, no como un botón más al
lado de guardar/exportar: al ser destructiva y poco frecuente,
mezclarla con el resto aumenta el riesgo de pulsarla sin
querer, sobre todo en móvil. -->
<div class="dropdown dropdown-end">
<button type="button" tabindex="0" class="btn btn-sm btn-outline btn-square" aria-label="{% trans 'Más opciones' %}"></button>
<ul tabindex="0" class="dropdown-content menu menu-sm bg-base-100 border border-base-300 rounded-box z-10 w-40 p-2 shadow">
<li>
<form method="post" action="{% url 'crochet:pattern_delete' pattern.uuid %}"
onsubmit="return confirm('{{ delete_confirm_message }}')">
{% csrf_token %}
<button type="submit" class="w-full text-left text-error">{% trans 'Eliminar' %}</button>
</form>
</li>
</ul>
</div>
</div>
</header>
@@ -48,20 +58,39 @@
</div>
<div class="p-4 pt-0">
<div class="no-print">
<div class="flex flex-col gap-1 mb-4">
<span class="text-sm">{% trans 'Imagen de portada' %}</span>
<label for="cover-image-input"
class="group relative flex w-full max-w-md h-56 items-center justify-center overflow-hidden rounded-box border-2 border-dashed border-base-300 bg-base-200/40 cursor-pointer transition-colors hover:border-primary">
<img id="cover-image-preview" alt=""
src="{% if pattern.cover_image_thumbnail %}{{ pattern.cover_image_thumbnail.url }}{% endif %}"
class="absolute inset-0 h-full w-full object-cover{% if not pattern.cover_image_thumbnail %} hidden{% endif %}">
<span id="cover-image-placeholder"
class="flex flex-col items-center gap-2 px-2 text-center text-sm text-base-content/50{% if pattern.cover_image_thumbnail %} hidden{% endif %}">
<span class="text-5xl">🖼️</span>
{% trans 'Añadir imagen' %}
</span>
<span class="absolute inset-0 flex items-center justify-center bg-black/50 text-sm font-medium text-white opacity-0 transition-opacity group-hover:opacity-100">
{% trans 'Cambiar' %}
</span>
</label>
<input id="cover-image-input" type="file" accept="image/*" class="sr-only"
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.' %}">
<input type="hidden" id="cover-image-large-url"
value="{% if pattern.cover_image_large %}{{ pattern.cover_image_large.url }}{% endif %}">
</div>
</div>
<details class="border border-base-300 rounded-box p-4 mb-4 no-print">
<summary class="text-lg font-semibold cursor-pointer select-none">{% trans 'Personalización de página' %}</summary>
<div class="flex flex-col gap-4 mt-4">
<div class="flex flex-col sm:flex-row gap-4">
<label class="flex flex-col gap-1 flex-1">
<span class="text-sm">{% trans 'Título del patrón' %}</span>
<input id="page-title-input" type="text" class="input input-bordered" placeholder="{% trans 'Título del patrón...' %}">
</label>
<label class="flex flex-col gap-1 flex-1">
<span class="text-sm">{% trans 'Autor' %}</span>
<input id="page-author-input" type="text" class="input input-bordered" placeholder="{% trans 'Autor...' %}">
</label>
</div>
<label class="flex flex-col gap-1">
<span class="text-sm">{% trans 'Autor' %}</span>
<input id="page-author-input" type="text" class="input input-bordered" placeholder="{% trans 'Autor...' %}">
</label>
<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>
@@ -122,14 +151,25 @@
</div>
</details>
<div class="tabs tabs-boxed w-fit mb-4 md:hidden no-print">
<div class="tabs tabs-boxed w-fit mb-4 lg:hidden no-print">
<button id="tab-sections" type="button" class="tab tab-active">{% trans 'Secciones' %}</button>
<button id="tab-output" type="button" class="tab">{% trans 'Vista previa' %}</button>
</div>
<div class="flex flex-col md:flex-row gap-4">
<div class="flex flex-col lg:flex-row gap-4">
<section id="panel-top" class="w-full lg:w-1/2 border border-base-300 rounded-box p-4 no-print">
<!-- Idioma del CONTENIDO del patrón (qué versión de título/texto
se edita), no el de la interfaz: justo encima de "Secciones"
porque es lo que determina qué se ve/edita ahí debajo. -->
<div class="flex items-center gap-2 mb-4">
<label for="language-select" class="text-sm font-medium">{% trans 'Idioma del patrón' %}</label>
<select id="language-select" class="select select-sm select-bordered">
<option value="es" {% if lang == 'es' %}selected{% endif %}>Español</option>
<option value="en" {% if lang == 'en' %}selected{% endif %}>English</option>
</select>
</div>
<section id="panel-top" class="w-full md:w-1/2 border border-base-300 rounded-box p-4 no-print">
<div class="flex items-center justify-between mb-2">
<h2 class="text-lg font-semibold">{% trans 'Secciones' %}</h2>
<button id="toggle-collapse-all" type="button" class="btn btn-xs btn-outline"
@@ -169,8 +209,7 @@
data-remove-confirm="{% trans '¿Seguro que quieres eliminar este grupo? Se eliminarán también todas las secciones que contiene.' %}"></div>
</section>
<section id="panel-output" class="w-full md:w-1/2 border border-base-300 rounded-box p-4 panel-hidden-mobile">
<h2 id="preview-title" class="text-lg font-semibold mb-2 no-print">{% trans 'Vista previa' %}</h2>
<section id="panel-output" class="w-full lg:w-1/2 border border-base-300 rounded-box p-4 panel-hidden-mobile">
<div id="panel-output-text"
data-empty-message="{% trans 'Añade una sección para ver aquí el resultado.' %}"></div>
</section>
+8 -13
View File
@@ -4,6 +4,13 @@
{% block title %}{{ page_settings.title|default:"Crochet" }}{% endblock %}
{% block extra_css %}
<!-- El navbar real (ver base.html) usa clases de daisyUI, así que aquí
hace falta cargarlo igual que en el resto del sitio (ver
_platform_assets.html), aunque esta vista se genere también como
HTML de entrada para el PDF: al ser no-print, no se imprime, pero
WeasyPrint sí llega a pedir por red su CSS (daisyUI, Google Fonts)
para construirlo, aunque el resultado no se vea nunca en el PDF. -->
{% include 'crochet/_platform_assets.html' %}
<link rel="stylesheet" href="{% static 'css/pattern-detail.css' %}">
<style>
:root {
@@ -14,24 +21,12 @@
</style>
{% endblock %}
<!-- Sin daisyUI aquí (ver arriba), así que el navbar de base.html no tiene
ningún estilo que aplicarle: se deja vacío a propósito. -->
{% block navbar %}{% endblock %}
{% block cookie_banner %}{% endblock %}
{% block content %}
<main class="p-4">
<div class="flex justify-end gap-2 mb-4 no-print">
<a href="{% url 'crochet:pattern_pdf' pattern.uuid %}" class="detail-btn">{% trans 'Descargar PDF' %}</a>
{% if lang != 'es' %}
{% language 'es' %}
<a href="{% url 'crochet:pattern_detail' pattern.uuid %}" class="detail-btn">Español</a>
{% endlanguage %}
{% endif %}
{% if lang != 'en' %}
{% language 'en' %}
<a href="{% url 'crochet:pattern_detail' pattern.uuid %}" class="detail-btn">English</a>
{% endlanguage %}
{% endif %}
</div>
<div id="panel-output" class="border border-gray-300 rounded-2xl p-4 max-w-3xl mx-auto"
@@ -29,6 +29,9 @@
<button type="submit" class="btn btn-primary mt-2">{% trans 'Entrar' %}</button>
</form>
<p class="text-sm text-center mt-2">
<a href="{% url 'crochet:password_reset' %}" class="link link-primary">{% trans '¿Has olvidado tu contraseña?' %}</a>
</p>
<p class="text-sm text-center">
<a href="{% url 'crochet:register' %}" class="link link-primary">{% trans '¿No tienes cuenta? Regístrate' %}</a>
</p>
</div>
View File
+26
View File
@@ -0,0 +1,26 @@
from django import template
from django.urls import translate_url as django_translate_url
register = template.Library()
# Mismos idiomas que LANGUAGES en config/settings/base.py.
LANGUAGE_FLAGS = {
'es': '🇪🇸',
'en': '🇬🇧',
}
@register.simple_tag
def translate_url(path, lang_code):
# django.urls.translate_url: resuelve `path` con el idioma actual (el de
# la propia petición) y lo vuelve a construir con `lang_code`, en vez de
# cambiar solo el prefijo (/es/, /en/) a mano: cada ruta traducible (ver
# crochet/urls.py) cambia entera de un idioma a otro, no solo el
# prefijo (p.ej. /es/patron/<uuid>/editar/ frente a
# /en/pattern/<uuid>/edit/).
return django_translate_url(path, lang_code)
@register.filter
def language_flag(lang_code):
return LANGUAGE_FLAGS.get(lang_code, '')
+340
View File
@@ -0,0 +1,340 @@
from django.contrib.auth.models import User
from django.core import mail
from django.test import TestCase
from django.urls import reverse
from django.utils import translation
from crochet.models import Pattern
class AccountSettingsViewTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(
username='crocheter', password='a-very-uncommon-pw-1', email='old@example.com',
)
with translation.override('es'):
self.url = reverse('crochet:account_settings')
def test_anonymous_user_is_redirected_to_login(self):
response = self.client.get(self.url)
with translation.override('es'):
login_url = reverse('crochet:login')
self.assertRedirects(response, f'{login_url}?next={self.url}')
def test_get_renders_both_forms_with_current_email(self):
self.client.force_login(self.user)
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'crochet/account_settings.html')
self.assertContains(response, 'old@example.com')
self.assertContains(response, 'id_old_password')
self.assertContains(response, 'id_new_password1')
class AccountEmailUpdateViewTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(
username='crocheter', password='a-very-uncommon-pw-1', email='old@example.com',
)
with translation.override('es'):
self.url = reverse('crochet:account_settings_email')
def test_anonymous_user_is_redirected_to_login(self):
response = self.client.post(self.url, {'email': 'new@example.com'})
with translation.override('es'):
login_url = reverse('crochet:login')
self.assertRedirects(response, f'{login_url}?next={self.url}')
def test_valid_email_updates_user_and_shows_success(self):
self.client.force_login(self.user)
response = self.client.post(self.url, {'email': 'new@example.com'})
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'crochet/_account_email_form.html')
self.user.refresh_from_db()
self.assertEqual(self.user.email, 'new@example.com')
self.assertContains(response, 'Email actualizado.')
def test_invalid_email_does_not_update_user(self):
# 200, no 400: htmx no sustituye el contenido en respuestas que no
# sean 2xx (lo trata como fallo de red), así que el fragmento con
# el error de validación no se vería en el navegador con un 400.
self.client.force_login(self.user)
response = self.client.post(self.url, {'email': 'not-an-email'})
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'class="text-error text-xs"')
self.user.refresh_from_db()
self.assertEqual(self.user.email, 'old@example.com')
def test_only_updates_the_requesting_users_own_email(self):
other = User.objects.create_user(username='other', password='a-very-uncommon-pw-1', email='other@example.com')
self.client.force_login(self.user)
self.client.post(self.url, {'email': 'new@example.com'})
other.refresh_from_db()
self.assertEqual(other.email, 'other@example.com')
def test_changing_email_notifies_the_previous_address(self):
self.client.force_login(self.user)
self.client.post(self.url, {'email': 'new@example.com'})
self.assertEqual(len(mail.outbox), 1)
message = mail.outbox[0]
self.assertEqual(message.to, ['old@example.com'])
self.assertIn('old@example.com', message.body)
self.assertIn('crocheter', message.body)
self.assertIn('soporte@localhost', message.body)
html_bodies = [content for content, mimetype in message.alternatives if mimetype == 'text/html']
self.assertEqual(len(html_bodies), 1)
def test_resubmitting_the_same_email_does_not_notify_anyone(self):
self.client.force_login(self.user)
self.client.post(self.url, {'email': 'old@example.com'})
self.assertEqual(len(mail.outbox), 0)
def test_invalid_email_does_not_notify_anyone(self):
self.client.force_login(self.user)
self.client.post(self.url, {'email': 'not-an-email'})
self.assertEqual(len(mail.outbox), 0)
def test_user_without_a_previous_email_gets_no_notification(self):
user_without_email = User.objects.create_user(username='noemail', password='a-very-uncommon-pw-1')
self.client.force_login(user_without_email)
self.client.post(self.url, {'email': 'new@example.com'})
self.assertEqual(len(mail.outbox), 0)
class AccountPasswordChangeViewTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(
username='crocheter', password='the-old-password-1', email='crocheter@example.com',
)
with translation.override('es'):
self.url = reverse('crochet:account_settings_password')
def test_anonymous_user_is_redirected_to_login(self):
response = self.client.post(self.url, {})
with translation.override('es'):
login_url = reverse('crochet:login')
self.assertRedirects(response, f'{login_url}?next={self.url}')
def test_valid_password_change_updates_password_and_keeps_session(self):
self.client.force_login(self.user)
response = self.client.post(self.url, {
'old_password': 'the-old-password-1',
'new_password1': 'the-new-password-2',
'new_password2': 'the-new-password-2',
})
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'crochet/_account_password_form.html')
self.assertContains(response, 'Contraseña actualizada.')
self.user.refresh_from_db()
self.assertTrue(self.user.check_password('the-new-password-2'))
# update_session_auth_hash: si no se llamara, esta petición
# (sesión ya cargada con el hash viejo) habría quedado deslogueada.
with translation.override('es'):
account_home_url = reverse('crochet:home')
still_logged_in_response = self.client.get(account_home_url)
self.assertEqual(still_logged_in_response.status_code, 200)
def test_wrong_old_password_does_not_change_password(self):
# 200, no 400: ver el comentario equivalente en
# AccountEmailUpdateViewTests.test_invalid_email_does_not_update_user.
self.client.force_login(self.user)
response = self.client.post(self.url, {
'old_password': 'wrong-password',
'new_password1': 'the-new-password-2',
'new_password2': 'the-new-password-2',
})
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'class="text-error text-xs"')
self.user.refresh_from_db()
self.assertTrue(self.user.check_password('the-old-password-1'))
def test_mismatched_new_passwords_does_not_change_password(self):
self.client.force_login(self.user)
response = self.client.post(self.url, {
'old_password': 'the-old-password-1',
'new_password1': 'the-new-password-2',
'new_password2': 'something-else-3',
})
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'class="text-error text-xs"')
self.user.refresh_from_db()
self.assertTrue(self.user.check_password('the-old-password-1'))
def test_valid_password_change_notifies_the_users_email(self):
self.client.force_login(self.user)
self.client.post(self.url, {
'old_password': 'the-old-password-1',
'new_password1': 'the-new-password-2',
'new_password2': 'the-new-password-2',
})
self.assertEqual(len(mail.outbox), 1)
message = mail.outbox[0]
self.assertEqual(message.to, ['crocheter@example.com'])
self.assertIn('crocheter', message.body)
self.assertIn('soporte@localhost', message.body)
html_bodies = [content for content, mimetype in message.alternatives if mimetype == 'text/html']
self.assertEqual(len(html_bodies), 1)
def test_user_without_an_email_gets_no_notification(self):
user_without_email = User.objects.create_user(username='noemail', password='the-old-password-1')
self.client.force_login(user_without_email)
self.client.post(self.url, {
'old_password': 'the-old-password-1',
'new_password1': 'the-new-password-2',
'new_password2': 'the-new-password-2',
})
self.assertEqual(len(mail.outbox), 0)
def test_wrong_old_password_does_not_send_a_notification(self):
self.client.force_login(self.user)
self.client.post(self.url, {
'old_password': 'wrong-password',
'new_password1': 'the-new-password-2',
'new_password2': 'the-new-password-2',
})
self.assertEqual(len(mail.outbox), 0)
class AccountDisplayNameUpdateViewTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username='crocheter', password='a-very-uncommon-pw-1')
with translation.override('es'):
self.url = reverse('crochet:account_settings_display_name')
def test_anonymous_user_is_redirected_to_login(self):
response = self.client.post(self.url, {'display_name': 'Ana'})
with translation.override('es'):
login_url = reverse('crochet:login')
self.assertRedirects(response, f'{login_url}?next={self.url}')
def test_valid_name_updates_user_and_shows_success(self):
self.client.force_login(self.user)
response = self.client.post(self.url, {'display_name': 'Ana'})
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'crochet/_account_display_name_form.html')
self.user.refresh_from_db()
self.assertEqual(self.user.first_name, 'Ana')
self.assertContains(response, 'Nombre actualizado.')
def test_blank_name_is_allowed_and_clears_it(self):
self.user.first_name = 'Ana'
self.user.save(update_fields=['first_name'])
self.client.force_login(self.user)
response = self.client.post(self.url, {'display_name': ''})
self.assertEqual(response.status_code, 200)
self.user.refresh_from_db()
self.assertEqual(self.user.first_name, '')
def test_only_updates_the_requesting_users_own_name(self):
other = User.objects.create_user(username='other', password='a-very-uncommon-pw-1')
self.client.force_login(self.user)
self.client.post(self.url, {'display_name': 'Ana'})
other.refresh_from_db()
self.assertEqual(other.first_name, '')
class AccountDeleteViewTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(
username='crocheter', password='the-password-1', email='crocheter@example.com',
)
with translation.override('es'):
self.url = reverse('crochet:account_settings_delete')
def test_anonymous_user_is_redirected_to_login(self):
response = self.client.post(self.url, {'password': 'the-password-1'})
with translation.override('es'):
login_url = reverse('crochet:login')
self.assertRedirects(response, f'{login_url}?next={self.url}')
def test_wrong_password_does_not_delete_the_account(self):
self.client.force_login(self.user)
response = self.client.post(self.url, {'password': 'wrong-password'})
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'class="text-error text-xs"')
self.assertTrue(User.objects.filter(pk=self.user.pk).exists())
self.assertEqual(len(mail.outbox), 0)
def test_correct_password_deletes_the_account_and_its_patterns(self):
pattern = Pattern.objects.create(created_by=self.user)
self.client.force_login(self.user)
response = self.client.post(self.url, {'password': 'the-password-1'})
self.assertFalse(User.objects.filter(pk=self.user.pk).exists())
self.assertFalse(Pattern.objects.filter(pk=pattern.pk).exists())
with translation.override('es'):
home_url = reverse('crochet:home')
self.assertEqual(response['HX-Redirect'], home_url)
def test_deleting_the_account_logs_out_the_session(self):
self.client.force_login(self.user)
self.client.post(self.url, {'password': 'the-password-1'})
self.assertNotIn('_auth_user_id', self.client.session)
# El índice ya no requiere sesión (a diferencia del extinto
# account_home): tras el borrado, vuelve a mostrar la landing de
# marketing en vez del panel de patrones.
with translation.override('es'):
home_url = reverse('crochet:home')
response = self.client.get(home_url)
self.assertTemplateUsed(response, 'crochet/home.html')
def test_deleting_the_account_notifies_the_users_email(self):
self.client.force_login(self.user)
self.client.post(self.url, {'password': 'the-password-1'})
self.assertEqual(len(mail.outbox), 1)
message = mail.outbox[0]
self.assertEqual(message.to, ['crocheter@example.com'])
self.assertIn('crocheter', message.body)
self.assertIn('soporte@localhost', message.body)
html_bodies = [content for content, mimetype in message.alternatives if mimetype == 'text/html']
self.assertEqual(len(html_bodies), 1)
def test_only_deletes_the_requesting_users_own_account(self):
other = User.objects.create_user(username='other', password='a-very-uncommon-pw-1')
self.client.force_login(self.user)
self.client.post(self.url, {'password': 'the-password-1'})
self.assertTrue(User.objects.filter(pk=other.pk).exists())
+51 -13
View File
@@ -20,17 +20,20 @@ class RegisterViewTests(TestCase):
def test_post_creates_user_and_logs_in(self):
response = self.client.post(self.url, {
'username': 'crocheter',
'email': 'crocheter@example.com',
'password1': 'a-very-uncommon-pw-1',
'password2': 'a-very-uncommon-pw-1',
})
self.assertTrue(User.objects.filter(username='crocheter').exists())
self.assertRedirects(response, reverse('crochet:account_home'))
user = User.objects.get(username='crocheter')
self.assertEqual(user.email, 'crocheter@example.com')
self.assertRedirects(response, reverse('crochet:home'))
self.assertIn('_auth_user_id', self.client.session)
def test_post_with_mismatched_passwords_does_not_create_user(self):
response = self.client.post(self.url, {
'username': 'crocheter',
'email': 'crocheter@example.com',
'password1': 'a-very-uncommon-pw-1',
'password2': 'something-else',
})
@@ -38,6 +41,18 @@ class RegisterViewTests(TestCase):
self.assertEqual(response.status_code, 200)
self.assertFalse(User.objects.filter(username='crocheter').exists())
def test_post_without_email_does_not_create_user(self):
# Sin email no habría a dónde mandar el enlace de recuperación de
# contraseña (ver PasswordResetForm/StyledUserCreationForm).
response = self.client.post(self.url, {
'username': 'crocheter',
'password1': 'a-very-uncommon-pw-1',
'password2': 'a-very-uncommon-pw-1',
})
self.assertEqual(response.status_code, 200)
self.assertFalse(User.objects.filter(username='crocheter').exists())
class LoginViewTests(TestCase):
def setUp(self):
@@ -57,7 +72,7 @@ class LoginViewTests(TestCase):
'password': 'a-very-uncommon-pw-1',
})
self.assertRedirects(response, reverse('crochet:account_home'))
self.assertRedirects(response, reverse('crochet:home'))
self.assertIn('_auth_user_id', self.client.session)
def test_post_with_wrong_password_does_not_log_in(self):
@@ -83,17 +98,14 @@ class LogoutViewTests(TestCase):
self.assertNotIn('_auth_user_id', self.client.session)
class AccountHomeViewTests(TestCase):
class HomeViewDashboardTests(TestCase):
# El índice ("/") ya no requiere sesión (a diferencia del extinto
# account_home): para quien no ha iniciado sesión enseña la landing de
# marketing (ver HomeViewTests en test_views.py); estos tests cubren
# justo la otra rama, el contenido que ve quien sí la tiene.
def setUp(self):
with translation.override('es'):
self.url = reverse('crochet:account_home')
def test_anonymous_user_is_redirected_to_login(self):
response = self.client.get(self.url)
with translation.override('es'):
login_url = reverse('crochet:login')
self.assertRedirects(response, f'{login_url}?next={self.url}')
self.url = reverse('crochet:home')
def test_logged_in_user_sees_their_username(self):
user = User.objects.create_user(username='crocheter', password='a-very-uncommon-pw-1')
@@ -104,6 +116,14 @@ class AccountHomeViewTests(TestCase):
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'crocheter')
def test_greeting_uses_display_name_when_set(self):
user = User.objects.create_user(username='crocheter', password='a-very-uncommon-pw-1', first_name='Ana')
self.client.force_login(user)
response = self.client.get(self.url)
self.assertContains(response, 'Hola, Ana')
def test_only_lists_own_patterns(self):
owner = User.objects.create_user(username='crocheter', password='a-very-uncommon-pw-1')
other = User.objects.create_user(username='other', password='a-very-uncommon-pw-1')
@@ -151,4 +171,22 @@ class PatternCreateViewTests(TestCase):
self.assertEqual(pattern.created_by, user)
with translation.override('es'):
edit_url = reverse('crochet:pattern_edit', args=[pattern.uuid])
self.assertRedirects(response, edit_url)
self.assertRedirects(response, edit_url)
def test_new_pattern_defaults_author_to_the_users_display_name(self):
user = User.objects.create_user(username='crocheter', password='a-very-uncommon-pw-1', first_name='Ana')
self.client.force_login(user)
self.client.post(self.url)
pattern = Pattern.objects.get()
self.assertEqual(pattern.page_settings.get('author'), 'Ana')
def test_new_pattern_leaves_author_blank_without_a_display_name(self):
user = User.objects.create_user(username='crocheter', password='a-very-uncommon-pw-1')
self.client.force_login(user)
self.client.post(self.url)
pattern = Pattern.objects.get()
self.assertNotIn('author', pattern.page_settings)
+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)
+142
View File
@@ -0,0 +1,142 @@
from django.contrib.auth.models import User
from django.contrib.auth.tokens import default_token_generator
from django.core import mail
from django.test import TestCase
from django.urls import reverse
from django.utils import translation
from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode
class PasswordResetRequestViewTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(
username='crocheter', password='the-old-password-1', email='crocheter@example.com',
)
with translation.override('es'):
self.url = reverse('crochet:password_reset')
def test_get_renders_the_form(self):
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'crochet/password_reset_form.html')
def test_known_email_sends_a_reset_link(self):
response = self.client.post(self.url, {'email': 'crocheter@example.com'})
with translation.override('es'):
done_url = reverse('crochet:password_reset_done')
self.assertRedirects(response, done_url)
self.assertEqual(len(mail.outbox), 1)
self.assertIn('crocheter@example.com', mail.outbox[0].to)
self.assertIn('http://testserver', mail.outbox[0].body)
def test_email_also_attaches_an_html_alternative_with_the_link(self):
self.client.post(self.url, {'email': 'crocheter@example.com'})
message = mail.outbox[0]
html_bodies = [content for content, mimetype in message.alternatives if mimetype == 'text/html']
self.assertEqual(len(html_bodies), 1)
self.assertIn('http://testserver', html_bodies[0])
def test_unknown_email_does_not_send_anything_but_still_redirects(self):
# Ni la vista ni la plantilla deben delatar si el email existe o no
# en el sistema (comportamiento por defecto de PasswordResetForm):
# misma redirección tanto si existe como si no, para no permitir
# enumerar cuentas registradas probando emails.
response = self.client.post(self.url, {'email': 'nobody@example.com'})
with translation.override('es'):
done_url = reverse('crochet:password_reset_done')
self.assertRedirects(response, done_url)
self.assertEqual(len(mail.outbox), 0)
class PasswordResetDoneViewTests(TestCase):
def test_get_renders_the_confirmation_message(self):
with translation.override('es'):
url = reverse('crochet:password_reset_done')
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'crochet/password_reset_done.html')
class PasswordResetConfirmViewTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(
username='crocheter', password='the-old-password-1', email='crocheter@example.com',
)
uidb64 = urlsafe_base64_encode(force_bytes(self.user.pk))
token = default_token_generator.make_token(self.user)
with translation.override('es'):
self.confirm_url = reverse(
'crochet:password_reset_confirm', kwargs={'uidb64': uidb64, 'token': token},
)
def test_valid_link_allows_setting_a_new_password(self):
# PasswordResetConfirmView consume el token de la URL en el primer
# GET (redirige a una URL "set-password" en sesión) precisamente
# para que no quede reutilizable ni visible en el historial del
# navegador; hay que seguir esa redirección antes de poder enviar
# el formulario.
get_response = self.client.get(self.confirm_url, follow=True)
self.assertContains(get_response, 'id_new_password1')
response = self.client.post(get_response.redirect_chain[-1][0], {
'new_password1': 'the-new-password-2',
'new_password2': 'the-new-password-2',
})
with translation.override('es'):
complete_url = reverse('crochet:password_reset_complete')
self.assertRedirects(response, complete_url)
self.user.refresh_from_db()
self.assertTrue(self.user.check_password('the-new-password-2'))
def test_mismatched_passwords_do_not_change_the_password(self):
get_response = self.client.get(self.confirm_url, follow=True)
response = self.client.post(get_response.redirect_chain[-1][0], {
'new_password1': 'the-new-password-2',
'new_password2': 'something-else-3',
})
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'class="text-error text-xs"')
self.user.refresh_from_db()
self.assertTrue(self.user.check_password('the-old-password-1'))
def test_invalid_token_shows_the_invalid_link_message(self):
uidb64 = urlsafe_base64_encode(force_bytes(self.user.pk))
with translation.override('es'):
bogus_url = reverse(
'crochet:password_reset_confirm', kwargs={'uidb64': uidb64, 'token': 'not-a-real-token'},
)
response = self.client.get(bogus_url, follow=True)
self.assertContains(response, 'Enlace no válido')
self.assertNotContains(response, 'id_new_password1')
class PasswordResetCompleteViewTests(TestCase):
def test_get_renders_the_success_message(self):
with translation.override('es'):
url = reverse('crochet:password_reset_complete')
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'crochet/password_reset_complete.html')
class LoginPageForgotPasswordLinkTests(TestCase):
def test_login_page_links_to_password_reset(self):
with translation.override('es'):
login_url = reverse('crochet:login')
reset_url = reverse('crochet:password_reset')
response = self.client.get(login_url)
self.assertContains(response, f'href="{reset_url}"')
+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)
+269 -5
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
@@ -10,6 +12,169 @@ from crochet.models import Pattern, PatternImage, StitchType
from crochet.tests.test_models import make_test_image_file
NO_HTML_COMMENTS_RE = re.compile(r'<!--.*?-->', re.S)
NAV_RE = re.compile(r'<nav[ >].*?</nav>', re.S)
class HomeViewTests(TestCase):
def test_get_renders_home_template(self):
with translation.override('es'):
url = reverse('crochet:home')
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'crochet/home.html')
self.assertContains(response, 'Crea y comparte tus patrones de crochet')
def test_anonymous_user_sees_register_and_login_links(self):
with translation.override('es'):
url = reverse('crochet:home')
register_url = reverse('crochet:register')
login_url = reverse('crochet:login')
response = self.client.get(url)
self.assertContains(response, f'href="{register_url}"')
self.assertContains(response, f'href="{login_url}"')
def test_logged_in_user_sees_their_patterns_dashboard_instead_of_the_landing_page(self):
# El índice ("/") ya no enseña la landing de marketing a quien ya
# tiene cuenta: directamente "Mis patrones" (ver HomeView.get),
# sin cambiar de URL.
user = User.objects.create_user(username='crocheter', password='a-very-uncommon-pw-1')
self.client.force_login(user)
with translation.override('es'):
url = reverse('crochet:home')
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'crochet/account_home.html')
self.assertContains(response, 'Hola, crocheter')
self.assertNotContains(response, 'Crea y comparte tus patrones de crochet')
def test_available_at_root_for_both_languages(self):
response_es = self.client.get('/es/')
response_en = self.client.get('/en/')
self.assertEqual(response_es.status_code, 200)
self.assertEqual(response_en.status_code, 200)
class CookiePolicyViewTests(TestCase):
def test_get_renders_the_policy_page(self):
with translation.override('es'):
url = reverse('crochet:cookie_policy')
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'crochet/cookie_policy.html')
self.assertContains(response, 'sessionid')
self.assertContains(response, 'csrftoken')
class CookieBannerTests(TestCase):
def test_regular_pages_include_the_cookie_banner(self):
with translation.override('es'):
url = reverse('crochet:home')
policy_url = reverse('crochet:cookie_policy')
response = self.client.get(url)
self.assertContains(response, 'id="cookie-banner"')
self.assertContains(response, f'href="{policy_url}"')
self.assertContains(response, 'cookie-consent.js')
def test_pattern_detail_does_not_include_the_cookie_banner(self):
pattern = Pattern.objects.create()
with translation.override('es'):
url = reverse('crochet:pattern_detail', args=[pattern.uuid])
response = self.client.get(url)
self.assertNotContains(response, 'id="cookie-banner"')
self.assertNotContains(response, 'cookie-consent.js')
class LanguageSwitcherTests(TestCase):
def test_home_page_switcher_offers_the_same_page_in_english(self):
response = self.client.get('/es/')
self.assertContains(response, 'href="/en/"')
def test_translated_route_switcher_preserves_the_current_view(self):
# No solo cambia el prefijo (/es/ -> /en/): toda la ruta traducible
# cambia de idioma (ver crochet/urls.py: "cuenta/entrar/" frente a
# "account/login/"), así que la opción debe apuntar al equivalente
# en inglés de esta MISMA vista, no a request.path con el prefijo
# sustituido a mano.
with translation.override('es'):
es_url = reverse('crochet:login')
with translation.override('en'):
en_url = reverse('crochet:login')
response = self.client.get(es_url)
self.assertContains(response, f'href="{en_url}"')
def test_current_language_is_shown_on_the_trigger_button(self):
response = self.client.get('/es/')
self.assertContains(response, '🇪🇸 ES<')
def test_menu_options_show_a_flag_per_language(self):
response = self.client.get('/es/')
self.assertContains(response, '🇪🇸 Español')
self.assertContains(response, '🇬🇧 Inglés')
def test_switcher_is_shown_regardless_of_login_state(self):
# El idioma es una preferencia de la página, no de la cuenta: debe
# verse tanto si hay sesión iniciada como si no, no solo dentro del
# dropdown de usuario (que no existe para anónimos).
user = User.objects.create_user(username='crocheter', password='a-very-uncommon-pw-1')
self.client.force_login(user)
response = self.client.get('/es/')
self.assertContains(response, '🇪🇸 ES<')
class NavbarAccountDropdownTests(TestCase):
def test_logged_in_user_sees_settings_and_logout_inside_the_dropdown(self):
user = User.objects.create_user(username='crocheter', password='a-very-uncommon-pw-1')
self.client.force_login(user)
with translation.override('es'):
settings_url = reverse('crochet:account_settings')
logout_url = reverse('crochet:logout')
response = self.client.get('/es/')
self.assertContains(response, 'dropdown dropdown-end')
self.assertContains(response, '>crocheter<')
self.assertContains(response, f'href="{settings_url}"')
self.assertContains(response, f'action="{logout_url}"')
def test_dropdown_shows_display_name_when_set(self):
user = User.objects.create_user(username='crocheter', password='a-very-uncommon-pw-1', first_name='Ana')
self.client.force_login(user)
response = self.client.get('/es/')
self.assertContains(response, '>Ana<')
self.assertNotContains(response, '>crocheter<')
def test_anonymous_user_does_not_see_the_account_dropdown(self):
with translation.override('es'):
settings_url = reverse('crochet:account_settings')
response = self.client.get('/es/')
# El dropdown de idioma sí está presente para anónimos; lo que no
# debe verse es el de la cuenta (ajustes/cerrar sesión).
self.assertNotContains(response, f'href="{settings_url}"')
self.assertEqual(response.content.decode().count('class="dropdown dropdown-end"'), 1)
def test_language_dropdown_is_separate_from_the_account_dropdown(self):
user = User.objects.create_user(username='crocheter', password='a-very-uncommon-pw-1')
self.client.force_login(user)
response = self.client.get('/es/')
self.assertEqual(response.content.decode().count('class="dropdown dropdown-end"'), 2)
class PatternEditViewTests(TestCase):
@@ -61,6 +226,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,16 +287,36 @@ 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):
# El navbar compartido (ver base.html) sí trae sus propios <button>
# (desplegable de idioma/cuenta): no editan el patrón, así que se
# descarta antes de comprobar esto. La regla real es que el PATRÓN
# en sí -lo que hay fuera del navbar- no debe traer ningún control
# de edición, ni siquiera oculto con CSS: cualquiera podría
# revelarlo desde las herramientas de desarrollador del navegador y
# guardar cambios reales (esta vista comparte uuid con la de editar).
with translation.override('es'):
url = reverse('crochet:pattern_detail', args=[self.pattern.uuid])
response = self.client.get(url)
body_without_comments = NO_HTML_COMMENTS_RE.sub('', response.content.decode())
body_without_nav = NAV_RE.sub('', body_without_comments)
self.assertNotRegex(body_without_comments, r'<input[ >]')
self.assertNotRegex(body_without_comments, r'<button[ >]')
self.assertNotRegex(body_without_comments, r'<select[ >]')
self.assertNotIn('csrf-token', body_without_comments)
self.assertNotRegex(body_without_nav, r'<input[ >]')
self.assertNotRegex(body_without_nav, r'<button[ >]')
self.assertNotRegex(body_without_nav, r'<select[ >]')
self.assertNotIn('csrf-token', body_without_nav)
def test_malicious_text_is_escaped(self):
pattern = Pattern.objects.create(sections=[
@@ -291,6 +488,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')
@@ -303,7 +567,7 @@ class PatternDeleteViewTests(TestCase):
response = self.client.post(self.url)
with translation.override('es'):
account_home_url = reverse('crochet:account_home')
account_home_url = reverse('crochet:home')
self.assertRedirects(response, account_home_url)
self.assertFalse(Pattern.objects.filter(pk=self.pattern.pk).exists())
+59 -4
View File
@@ -1,10 +1,17 @@
from django.contrib.auth import views as auth_views
from django.urls import path
from django.urls import path, reverse_lazy
from django.utils.translation import gettext_lazy as _
from crochet.forms import StyledAuthenticationForm
from crochet.forms import StyledAuthenticationForm, StyledPasswordResetForm, StyledSetPasswordForm
from crochet.views import (
AccountHomeView,
AccountDeleteView,
AccountDisplayNameUpdateView,
AccountEmailUpdateView,
AccountPasswordChangeView,
AccountSettingsView,
CookiePolicyView,
HomeView,
PatternCoverImageUploadView,
PatternCreateView,
PatternDeleteView,
PatternDetailView,
@@ -27,14 +34,27 @@ app_name = 'crochet'
# entera según el prefijo de idioma, p.ej. /en/pattern/<uuid>/edit/ frente a
# /es/patron/<uuid>/editar/.
urlpatterns = [
path('', name='home', view=HomeView.as_view()),
path(_('cookies/'), name='cookie_policy', view=CookiePolicyView.as_view()),
path(_('pattern/<uuid:uuid>/'), name='pattern_detail', view=PatternDetailView.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>/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()),
path(_('account/'), name='account_home', view=AccountHomeView.as_view()),
path(_('account/settings/'), name='account_settings', view=AccountSettingsView.as_view()),
path(_('account/settings/email/'), name='account_settings_email', view=AccountEmailUpdateView.as_view()),
path(
_('account/settings/password/'), name='account_settings_password',
view=AccountPasswordChangeView.as_view(),
),
path(
_('account/settings/display-name/'), name='account_settings_display_name',
view=AccountDisplayNameUpdateView.as_view(),
),
path(_('account/settings/delete/'), name='account_settings_delete', view=AccountDeleteView.as_view()),
path(_('account/register/'), name='register', view=RegisterView.as_view()),
path(
_('account/login/'), name='login',
@@ -43,4 +63,39 @@ urlpatterns = [
),
),
path(_('account/logout/'), name='logout', view=auth_views.LogoutView.as_view()),
path(
_('account/password-reset/'), name='password_reset',
view=auth_views.PasswordResetView.as_view(
# No en registration/ (a diferencia de login.html/register.html):
# django.contrib.admin trae sus propias plantillas con estos
# mismos nombres bajo registration/ y, al ir antes que crochet
# en INSTALLED_APPS, el buscador de plantillas por APP_DIRS
# encontraría siempre las suyas primero, tapando las nuestras.
template_name='crochet/password_reset_form.html',
email_template_name='crochet/password_reset_email.html',
# HTML además del texto plano de arriba (no en su lugar):
# EmailMultiAlternatives adjunta esta versión como
# "text/html", y el cliente de correo elige cuál mostrar.
html_email_template_name='crochet/email/password_reset.html',
subject_template_name='crochet/password_reset_subject.txt',
form_class=StyledPasswordResetForm,
success_url=reverse_lazy('crochet:password_reset_done'),
),
),
path(
_('account/password-reset/done/'), name='password_reset_done',
view=auth_views.PasswordResetDoneView.as_view(template_name='crochet/password_reset_done.html'),
),
path(
_('account/reset/<uidb64>/<token>/'), name='password_reset_confirm',
view=auth_views.PasswordResetConfirmView.as_view(
template_name='crochet/password_reset_confirm.html',
form_class=StyledSetPasswordForm,
success_url=reverse_lazy('crochet:password_reset_complete'),
),
),
path(
_('account/reset/done/'), name='password_reset_complete',
view=auth_views.PasswordResetCompleteView.as_view(template_name='crochet/password_reset_complete.html'),
),
]
+156 -20
View File
@@ -1,25 +1,31 @@
import json
import weasyprint
from django.contrib.auth import login
from django.contrib import messages
from django.contrib.auth import login, logout, update_session_auth_hash
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import PermissionDenied, SuspiciousFileOperation
from django.http import HttpResponse, HttpResponseBadRequest, JsonResponse
from django.shortcuts import get_object_or_404, redirect
from django.shortcuts import get_object_or_404, redirect, render
from django.template.loader import render_to_string
from django.urls import reverse_lazy
from django.urls import reverse, reverse_lazy
from django.utils.text import get_valid_filename
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.forms import StyledUserCreationForm
from crochet.cover_image import InvalidCoverImage, build_cover_image_variants
from crochet.forms import (
AccountDeleteForm,
DisplayNameForm,
EmailUpdateForm,
StyledPasswordChangeForm,
StyledUserCreationForm,
)
from crochet.models import Pattern, PatternImage, StitchType
from crochet.pattern_render import render_pattern_html, sanitize_page_settings
# gettext_lazy (no gettext): se evalúa al construir el HTML de salida (dentro
# de render_pattern_html, vía format_html), no aquí al importar el módulo, así
# que respeta el idioma activo de cada petición aunque esto se cree una vez.
PATTERN_EMPTY_MESSAGE = _('Este patrón todavía no tiene contenido.')
@@ -57,10 +63,33 @@ 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,
),
}
class HomeView(TemplateView):
template_name = 'crochet/home.html'
account_template_name = 'crochet/account_home.html'
def get_template_names(self):
if self.request.user.is_authenticated:
return [self.account_template_name]
return [self.template_name]
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
if self.request.user.is_authenticated:
context['patterns'] = self.request.user.patterns.order_by('-updated_at')
return context
class CookiePolicyView(TemplateView):
template_name = 'crochet/cookie_policy.html'
class PatternEditView(LoginRequiredMixin, DetailView):
model = Pattern
template_name = 'crochet/pattern.html'
@@ -70,16 +99,14 @@ class PatternEditView(LoginRequiredMixin, DetailView):
def get_object(self, queryset=None):
pattern = super().get_object(queryset)
# LoginRequiredMixin ya garantiza que request.user está autenticado
# aquí (si no, redirige a login antes de llegar a get_object).
if pattern.created_by_id is None or pattern.created_by_id != self.request.user.id:
raise PermissionDenied
return pattern
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Resuelto por LocaleMiddleware a partir del prefijo de idioma de la
# URL (/es/..., /en/...; ver i18n_patterns en config/urls.py).
context['lang'] = get_language()
# Se pasa tal cual a la plantilla con {{ pattern_data|json_script:"..." }}
# para que main.js reconstruya el editor con el contenido guardado
@@ -193,37 +220,146 @@ 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
# versión con clases de daisyUI en sus widgets (ver crochet/forms.py).
form_class = StyledUserCreationForm
template_name = 'registration/register.html'
success_url = reverse_lazy('crochet:account_home')
success_url = reverse_lazy('crochet:home')
def form_valid(self, form):
response = super().form_valid(form)
# Registrarse ya cuenta como haber probado usuario/contraseña, así
# que se inicia sesión directamente en vez de mandar al usuario a
# loguearse otra vez con lo que acaba de escribir.
login(self.request, self.object)
return response
class AccountHomeView(LoginRequiredMixin, TemplateView):
template_name = 'crochet/account_home.html'
class AccountSettingsView(LoginRequiredMixin, TemplateView):
template_name = 'crochet/account_settings.html'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['patterns'] = self.request.user.patterns.order_by('-updated_at')
context['email_form'] = EmailUpdateForm(user=self.request.user, initial={'email': self.request.user.email})
context['password_form'] = StyledPasswordChangeForm(user=self.request.user)
context['display_name_form'] = DisplayNameForm(
user=self.request.user, initial={'display_name': self.request.user.first_name},
)
context['delete_form'] = AccountDeleteForm(user=self.request.user)
return context
class AccountEmailUpdateView(LoginRequiredMixin, View):
# Fragmento HTMX (ver _account_email_form.html, hx-post con
# hx-target="this"/hx-swap="outerHTML" en el propio <form>): en
# cualquier caso (éxito o error de validación) se devuelve el mismo
# parcial con 200, para que htmx reemplace el formulario con la
# versión actualizada sin recargar la página. htmx, por defecto, NO
# sustituye el contenido si la respuesta no es 2xx (lo trata como un
# error de red, sin pintar nada), así que un 400 aquí dejaría los
# errores de validación escritos en la respuesta pero invisibles.
def post(self, request):
form = EmailUpdateForm(request.POST, user=request.user)
if form.is_valid():
form.save()
form = EmailUpdateForm(user=request.user, initial={'email': request.user.email})
email_updated = True
else:
email_updated = False
return render(request, 'crochet/_account_email_form.html', {'email_form': form, 'email_updated': email_updated})
class AccountPasswordChangeView(LoginRequiredMixin, View):
# Mismo patrón que AccountEmailUpdateView (200 siempre, ver el porqué
# ahí). update_session_auth_hash es imprescindible: cambiar la
# contraseña invalida el hash de sesión que AuthenticationMiddleware
# comprueba en cada petición, así que sin esto el usuario se quedaría
# deslogueado justo después de cambiarla.
def post(self, request):
form = StyledPasswordChangeForm(user=request.user, data=request.POST)
if form.is_valid():
form.save()
update_session_auth_hash(request, form.user)
form = StyledPasswordChangeForm(user=request.user)
password_updated = True
else:
password_updated = False
return render(
request, 'crochet/_account_password_form.html',
{'password_form': form, 'password_updated': password_updated},
)
class AccountDisplayNameUpdateView(LoginRequiredMixin, View):
def post(self, request):
form = DisplayNameForm(request.POST, user=request.user)
if form.is_valid():
form.save()
form = DisplayNameForm(user=request.user, initial={'display_name': request.user.first_name})
display_name_updated = True
else:
display_name_updated = False
return render(
request, 'crochet/_account_display_name_form.html',
{'display_name_form': form, 'display_name_updated': display_name_updated},
)
class AccountDeleteView(LoginRequiredMixin, View):
def post(self, request):
form = AccountDeleteForm(request.POST, user=request.user)
if not form.is_valid():
return render(request, 'crochet/_account_delete_form.html', {'delete_form': form})
form.notify_account_deleted()
user = request.user
user.patterns.all().delete()
logout(request)
user.delete()
messages.success(request, _('Tu cuenta se ha eliminado correctamente.'))
response = HttpResponse()
response['HX-Redirect'] = reverse('crochet:home')
return response
class PatternCreateView(LoginRequiredMixin, View):
# Solo POST (ver el <form> en account_home.html): crear un patrón no
# debería poder dispararse desde un simple enlace GET sin token CSRF.
def post(self, request):
pattern = Pattern.objects.create(created_by=request.user)
page_settings = {'author': request.user.first_name} if request.user.first_name else {}
pattern = Pattern.objects.create(created_by=request.user, page_settings=page_settings)
return redirect('crochet:pattern_edit', uuid=pattern.uuid)
@@ -235,4 +371,4 @@ class PatternDeleteView(LoginRequiredMixin, View):
def post(self, request, uuid):
pattern = _get_owned_pattern_or_403(request, uuid)
pattern.delete()
return redirect('crochet:account_home')
return redirect('crochet:home')
+1 -1
View File
@@ -15,7 +15,7 @@ dependencies = [
"django-storages>=1.14.6",
"ipython>=9.15.0",
"pillow>=12.3.0",
"psycopg>=3.3.4",
"psycopg[binary]>=3.3.4",
"pytest-cov>=7.1.0",
"uvicorn>=0.51.0",
"watchman>=0.0.1",
+4
View File
@@ -1,3 +1,7 @@
python manage.py build_pattern_detail_css
# El binario de Tailwind (~100MB) y su CSS de entrada solo hacían falta
# para el paso de arriba: sin esto, se quedaban en la imagen final sin
# usarse nunca en tiempo de ejecución.
rm -rf .tools
python manage.py collectstatic --noinput
python manage.py compilemessages
Generated
+36 -2
View File
@@ -244,7 +244,7 @@ dependencies = [
{ name = "django-storages" },
{ name = "ipython" },
{ name = "pillow" },
{ name = "psycopg" },
{ name = "psycopg", extra = ["binary"] },
{ name = "pytest-cov" },
{ name = "uvicorn" },
{ name = "watchman" },
@@ -274,7 +274,7 @@ requires-dist = [
{ name = "django-storages", specifier = ">=1.14.6" },
{ name = "ipython", specifier = ">=9.15.0" },
{ name = "pillow", specifier = ">=12.3.0" },
{ name = "psycopg", specifier = ">=3.3.4" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.3.4" },
{ name = "pytest-cov", specifier = ">=7.1.0" },
{ name = "uvicorn", specifier = ">=0.51.0" },
{ name = "watchman", specifier = ">=0.0.1" },
@@ -748,6 +748,40 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/e0/7b3dee031daae7743609ce3c746565d4a3ed7c2c186479eb48e34e838c64/psycopg-3.3.4-py3-none-any.whl", hash = "sha256:b6bbc25ccf05c8fad3b061d9db2ef0909a555171b84b07f29458a447253d679a", size = 213001, upload-time = "2026-05-01T23:20:50.816Z" },
]
[package.optional-dependencies]
binary = [
{ name = "psycopg-binary", marker = "implementation_name != 'pypy'" },
]
[[package]]
name = "psycopg-binary"
version = "3.3.4"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/09/43/13e9c406fbbf354580476e248a16b64802a376873ebe6339e30bb655572d/psycopg_binary-3.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fbd1d4ed566895ad2d3bf4ddfd8bae90026930ddf29df3b9d91d32c8c47866a7", size = 4590377, upload-time = "2026-05-01T23:29:18.782Z" },
{ url = "https://files.pythonhosted.org/packages/22/be/2923cd7c3683e7afdecf4f10796a18de02f5c5ddc0969aa2ad0a8cdd3bbd/psycopg_binary-3.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:75a9067e236f9b9ae3535b66fe99bddb33d39c0de10112e49b9ab11eee53dc31", size = 4669023, upload-time = "2026-05-01T23:29:25.884Z" },
{ url = "https://files.pythonhosted.org/packages/96/a0/2c913d6fe13d6a8bd13597d36739bf47af063ad9399e402cfecab16f3c1e/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b56b603ebcea8aa10b46228b8410ba7f13e7c2ee54389d4d9be0927fd8ce2a70", size = 5467423, upload-time = "2026-05-01T23:29:33.416Z" },
{ url = "https://files.pythonhosted.org/packages/e7/38/205d10bc1ad0df4a21c5c51659126bd3ea0ef98fcad1e852f78c249bb9c3/psycopg_binary-3.3.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c677c4ad433cb7150c8cd304a0769ae3bcfbe5ea0676eb53faa7b1443b16d0d3", size = 5151137, upload-time = "2026-05-01T23:29:42.013Z" },
{ url = "https://files.pythonhosted.org/packages/36/fc/f0381ddcd45eff3bb70dbca6823a996048d7f507b2ec3fc92c6fabc0fe87/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26df2717e59c0473e4465a97dfb1b7afebaa479277870fd5784d1436470db47c", size = 6736671, upload-time = "2026-05-01T23:29:51.626Z" },
{ url = "https://files.pythonhosted.org/packages/95/40/fa545ae152c24327651e5624e4902121e808270be36c10b12e9939be09bc/psycopg_binary-3.3.4-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dc1f79fd16bb1f3f4421417a514607539f17804d95c7ed617265369d1981cae", size = 4979601, upload-time = "2026-05-01T23:29:56.961Z" },
{ url = "https://files.pythonhosted.org/packages/86/e4/2f8a47ee97f90cd2b933d0463081d35631ff419de2b8c984a5f369857de0/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:136f199a407b5348b9b857c504aff60c77622a28482e7195839ce1b51238c4cc", size = 4510513, upload-time = "2026-05-01T23:30:07.243Z" },
{ url = "https://files.pythonhosted.org/packages/0e/0e/94e842ff4a7f98ed162580ca2e8b8864b28c1e0350f2443f8ee47f821167/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b6f5a29e9c775b9f12a1a717aa7a2c80f9e1db6f27ba44a5b59c80ac61d2ffcf", size = 4187243, upload-time = "2026-05-01T23:30:15.352Z" },
{ url = "https://files.pythonhosted.org/packages/d0/83/fc6c174b672e29b7de996ea77b6cbddf46c891751c3355f6974292baa6b4/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ee17a2cf4943cde261adfad1bbc5bf38d6b3776d7afff74c7cabcbeaeb08c260", size = 3927347, upload-time = "2026-05-01T23:30:21.186Z" },
{ url = "https://files.pythonhosted.org/packages/e9/65/768364d4a97a15b1a7f47ba52688c1686f22941d8332a8398cefc468e25f/psycopg_binary-3.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c4ab71be17bdca30cb34c34c4e1496e2f5d6f20c199c12bad226070b22ef9bf", size = 4236393, upload-time = "2026-05-01T23:30:26.211Z" },
{ url = "https://files.pythonhosted.org/packages/bd/3b/218efbc9e645becd80cdf651acda05f85cfe546b7a9c0458c7cbc8fe1f74/psycopg_binary-3.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:dbfdb9b6cc79f31104a7b162a2b921b765fcc62af6c00540a167a8de47e4ed38", size = 3564592, upload-time = "2026-05-01T23:30:31.764Z" },
{ url = "https://files.pythonhosted.org/packages/48/a6/828c9185701dab71b234c2a76c38a08b098ebfec5020716b4e93807492b5/psycopg_binary-3.3.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:28b7398fdd19db3232c884fb24550bdfe951221f510e195e233299e4c9b78f97", size = 4607292, upload-time = "2026-05-01T23:30:38.962Z" },
{ url = "https://files.pythonhosted.org/packages/92/58/5b40dbc9d839045c9dae956960e4fb6d20bcabe6c59a2aa34fc3a371913f/psycopg_binary-3.3.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1fbaa292a3c8bb61b45df1ad3da1908ccee7cb889db9425e3557d9e34e2a4829", size = 4687023, upload-time = "2026-05-01T23:30:47.227Z" },
{ url = "https://files.pythonhosted.org/packages/85/a9/793f0ac107a9003b48441d0d1f9f616d96e0f37458dd8dc12528ceff55fb/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94596f9e7633ee3f6440711d43bb70aa31cc0a46a900ab8b4201a366ace5c9e7", size = 5486985, upload-time = "2026-05-01T23:30:55.517Z" },
{ url = "https://files.pythonhosted.org/packages/8f/26/42e8533497e2592334f68ec529cf5f840f7fa4e99575a4bb61aa184dbfbf/psycopg_binary-3.3.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8c0056529e68dbe9184cd4019a1f3d8f3a4ead2f6fc7a5afcf27d3314edd1277", size = 5168745, upload-time = "2026-05-01T23:31:01.904Z" },
{ url = "https://files.pythonhosted.org/packages/15/af/b7151776cc08d5935d45c833ec818a9beb417cf7c08239af1aafbdae78ee/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c09aad7051326e7603c14e50636db9c01f78272dc54b3accff03d46370461e6", size = 6761486, upload-time = "2026-05-01T23:31:14.511Z" },
{ url = "https://files.pythonhosted.org/packages/d0/ed/c92533b9124712d592cbf1cd6c76da933a2e0acea81dfe1fbe7e735f0cff/psycopg_binary-3.3.4-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:514404ed543efd620c85602b747df2a23cf1241b4067199e1a66f2d2757aaa41", size = 4997427, upload-time = "2026-05-01T23:31:20.901Z" },
{ url = "https://files.pythonhosted.org/packages/a2/23/ccadfd0de416aa188356daa199453af24087b042e296088706d190ae0295/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:46893c26858be12cc49ca4226ed6a60b4bfccadd946b3bebb783a60b38788228", size = 4533549, upload-time = "2026-05-01T23:31:26.204Z" },
{ url = "https://files.pythonhosted.org/packages/fd/a0/c8f43cee36386f7bc891ab41a9d31ea07cf9826038e732da79f26b1e5f34/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:df1d567fc430f6df15c9fcf67d87685fc49bdb325adc0db5af1adfb2f44eb5c9", size = 4210256, upload-time = "2026-05-01T23:31:33.884Z" },
{ url = "https://files.pythonhosted.org/packages/4e/2c/c1547871be3790676e8868b38655496422f94f0978dfb66b74bdba2f1676/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6b9016b1714da4dd5ecaaa75b82098aa5a0b87854ce9b092e21c27c4ae23e014", size = 3946204, upload-time = "2026-05-01T23:31:39.626Z" },
{ url = "https://files.pythonhosted.org/packages/c4/b1/f6670f00fa7ea601584623f6c11602ab92117d83eaff885e0210f6de7418/psycopg_binary-3.3.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:47c656a8a7ba6eb0cff1801a4caaa9c8bdc12d03080e273aff1c8ac39971a77e", size = 4255811, upload-time = "2026-05-01T23:31:44.986Z" },
{ url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849, upload-time = "2026-05-01T23:31:51.165Z" },
]
[[package]]
name = "ptyprocess"
version = "0.7.0"