Compare commits
15 Commits
778ec18b17
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| db21129960 | |||
| 58df77b286 | |||
| 8920047c55 | |||
| 1a1c8786a3 | |||
| 2839d60923 | |||
| 1ecca9362f | |||
| d43bbbb5f2 | |||
| 6b5c353a86 | |||
| 3e95ee2239 | |||
| 2f58e23cd2 | |||
| 1a77cd77fb | |||
| 55186a0891 | |||
| 73109a268b | |||
| 96738bf7d5 | |||
| f722f0765e |
@@ -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/
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -18,3 +18,18 @@ def collected_static_files():
|
||||
with override_settings(STATIC_ROOT=static_root):
|
||||
call_command('collectstatic', interactive=False, verbosity=0)
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(scope='session', autouse=True)
|
||||
def isolated_media_root():
|
||||
"""Los tests que suben imágenes (PatternImage, portada de Pattern...)
|
||||
escriben de verdad en MEDIA_ROOT; sin aislarlo, se acumulan en la
|
||||
carpeta media/ real del proyecto de una ejecución a otra, y como
|
||||
cover_image.py guarda cada variante con un nombre fijo (thumbnail.jpg,
|
||||
large.jpg) dentro de una carpeta por mes, esas sobras podían chocar con
|
||||
los nombres que espera un test concreto (ver
|
||||
PatternCoverImageUploadViewTests) y hacerlo fallar según lo que hubiera
|
||||
quedado de ejecuciones anteriores."""
|
||||
with tempfile.TemporaryDirectory() as media_root:
|
||||
with override_settings(MEDIA_ROOT=media_root):
|
||||
yield
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import io
|
||||
|
||||
from django.core.files.base import ContentFile
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
|
||||
# Miniatura para las tarjetas de "Mis patrones" (ver account_home.html) y
|
||||
# versión grande para una futura vista de detalle/producto: dos tamaños
|
||||
# cubren los usos actuales sin generar variantes de más.
|
||||
THUMBNAIL_MAX_SIZE = (400, 400)
|
||||
LARGE_MAX_SIZE = (1200, 1200)
|
||||
|
||||
|
||||
class InvalidCoverImage(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _resized_jpeg(image, max_size):
|
||||
resized = image.copy()
|
||||
# thumbnail() redimensiona conservando el aspect ratio, sin salirse de
|
||||
# la caja (max_size), a diferencia de resize() (que deformaría la
|
||||
# imagen si no coincide con esa proporción).
|
||||
resized.thumbnail(max_size, Image.Resampling.LANCZOS)
|
||||
buffer = io.BytesIO()
|
||||
resized.save(buffer, format='JPEG', quality=85, optimize=True)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def build_cover_image_variants(uploaded_file):
|
||||
# Devuelve (original, thumbnail, large) como ContentFile, listos para
|
||||
# asignar a cover_image/cover_image_thumbnail/cover_image_large.
|
||||
raw_bytes = uploaded_file.read()
|
||||
try:
|
||||
image = Image.open(io.BytesIO(raw_bytes))
|
||||
image.load()
|
||||
except UnidentifiedImageError as error:
|
||||
raise InvalidCoverImage('El archivo no es una imagen válida.') from error
|
||||
|
||||
# JPEG no admite transparencia: se aplana sobre fondo blanco antes de
|
||||
# generar las variantes en vez de dejar que Pillow falle al guardar un
|
||||
# PNG/RGBA como JPEG.
|
||||
if image.mode in ('RGBA', 'LA', 'P'):
|
||||
rgba = image.convert('RGBA')
|
||||
flattened = Image.new('RGB', image.size, (255, 255, 255))
|
||||
flattened.paste(rgba, mask=rgba.split()[-1])
|
||||
image = flattened
|
||||
else:
|
||||
image = image.convert('RGB')
|
||||
|
||||
original = ContentFile(raw_bytes, name=uploaded_file.name)
|
||||
thumbnail = ContentFile(_resized_jpeg(image, THUMBNAIL_MAX_SIZE), name='thumbnail.jpg')
|
||||
large = ContentFile(_resized_jpeg(image, LARGE_MAX_SIZE), name='large.jpg')
|
||||
return original, thumbnail, large
|
||||
+145
-1
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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/'),
|
||||
),
|
||||
]
|
||||
@@ -22,6 +22,20 @@ class Pattern(models.Model):
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
# Portada del patrón: cover_image guarda el archivo tal cual lo sube el
|
||||
# usuario; cover_image_thumbnail/cover_image_large son las variantes
|
||||
# redimensionadas (ver crochet/cover_image.py) que se muestran de verdad
|
||||
# en la web (tarjetas de "Mis patrones", futura vista de detalle...), no
|
||||
# editable=False porque las genera PatternCoverImageUploadView, no un
|
||||
# formulario.
|
||||
cover_image = models.ImageField(upload_to='patterns/covers/originals/%Y/%m/', blank=True, null=True)
|
||||
cover_image_thumbnail = models.ImageField(
|
||||
upload_to='patterns/covers/thumbnails/%Y/%m/', blank=True, null=True, editable=False,
|
||||
)
|
||||
cover_image_large = models.ImageField(
|
||||
upload_to='patterns/covers/large/%Y/%m/', blank=True, null=True, editable=False,
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.page_settings.get('title') or str(self.uuid)
|
||||
|
||||
|
||||
@@ -138,18 +138,22 @@ def _render_section(section, lang, stitch_types_by_id):
|
||||
return ''
|
||||
|
||||
|
||||
def render_pattern_html(sections, page_settings, lang, stitch_types, empty_message):
|
||||
def render_pattern_html(sections, page_settings, lang, stitch_types, empty_message, cover_image_url=None):
|
||||
"""HTML (ya escapado y marcado como seguro) del contenido de un patrón
|
||||
para `lang`, en el mismo formato que genera renderOutput() en el
|
||||
editor: título/autor del documento primero, y luego cada sección de
|
||||
nivel superior en orden. `page_settings` debe venir ya validado por
|
||||
sanitize_page_settings()."""
|
||||
editor: título, portada y autor del documento primero, y luego cada
|
||||
sección de nivel superior en orden. `page_settings` debe venir ya
|
||||
validado por sanitize_page_settings()."""
|
||||
stitch_types_by_id = {st['id']: st for st in stitch_types}
|
||||
|
||||
parts = [
|
||||
_line('h1', page_settings['title'], 'text-[1.5em] font-bold mb-1'),
|
||||
_line('p', page_settings['author'], 'text-[0.875em] opacity-70 mb-2'),
|
||||
]
|
||||
parts = [_line('h1', page_settings['title'], 'text-[1.5em] font-bold mb-1')]
|
||||
# Mismas clases que _render_image() (misma pinta que una imagen de
|
||||
# sección) y que la portada que pinta render.js en el editor.
|
||||
if cover_image_url:
|
||||
parts.append(format_html(
|
||||
'<img src="{0}" class="max-w-full max-h-96 object-contain rounded-2xl my-2" alt="">', cover_image_url,
|
||||
))
|
||||
parts.append(_line('p', page_settings['author'], 'text-[0.875em] opacity-70 mb-2'))
|
||||
|
||||
if not sections:
|
||||
parts.append(format_html('<p class="opacity-50 italic">{0}</p>', empty_message))
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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".
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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">
|
||||
{% htmx_script %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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, '')
|
||||
@@ -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())
|
||||
+50
-12
@@ -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')
|
||||
@@ -152,3 +172,21 @@ class PatternCreateViewTests(TestCase):
|
||||
with translation.override('es'):
|
||||
edit_url = reverse('crochet:pattern_edit', args=[pattern.uuid])
|
||||
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)
|
||||
@@ -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)
|
||||
@@ -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}"')
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user