feat: included 2FA and lots of feature from django-htmx-template
This commit is contained in:
@@ -30,6 +30,9 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: uv pip install --system -r pyproject.toml --group=dev
|
run: uv pip install --system -r pyproject.toml --group=dev
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: uv run ruff check .
|
||||||
|
|
||||||
- name: Compile translations
|
- name: Compile translations
|
||||||
run: uv run python manage.py compilemessages
|
run: uv run python manage.py compilemessages
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
repos:
|
||||||
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
|
rev: v0.16.7
|
||||||
|
hooks:
|
||||||
|
# Solo lint (--fix incluido): el formateador de ruff no se activa
|
||||||
|
# aquí a propósito, ver [tool.ruff.format] en pyproject.toml — el
|
||||||
|
# proyecto usa un estilo de wrapping más compacto del que aplicaría
|
||||||
|
# `ruff format` por defecto, y forzarlo reescribiría archivos enteros
|
||||||
|
# sin que nadie lo haya pedido. `ruff format` sigue disponible a mano
|
||||||
|
# (`make format`) para quien quiera adoptarlo.
|
||||||
|
- id: ruff
|
||||||
|
args: [--fix]
|
||||||
|
|
||||||
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
|
rev: v5.0.0
|
||||||
|
hooks:
|
||||||
|
- id: trailing-whitespace
|
||||||
|
- id: end-of-file-fixer
|
||||||
|
- id: check-merge-conflict
|
||||||
|
- id: check-added-large-files
|
||||||
-19
@@ -16,30 +16,11 @@ ARG gid=1001
|
|||||||
|
|
||||||
RUN addgroup -S ${user} && adduser -S ${user} -G ${user} -u ${uid} -s /bin/sh
|
RUN addgroup -S ${user} && adduser -S ${user} -G ${user} -u ${uid} -s /bin/sh
|
||||||
|
|
||||||
# pango + ttf-dejavu: librerías nativas que weasyprint necesita en tiempo de
|
|
||||||
# ejecución (no al instalar el paquete Python, que ya funciona sin esto) para
|
|
||||||
# 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.
|
|
||||||
# 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 apk add --no-cache gettext pango ttf-dejavu
|
||||||
RUN chown -R ${user}:${user} /code
|
RUN chown -R ${user}:${user} /code
|
||||||
|
|
||||||
COPY --chown=${user}:${user} . /code
|
COPY --chown=${user}:${user} . /code
|
||||||
|
|
||||||
# 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 \
|
RUN apk add --no-cache --virtual .tailwind-runtime libstdc++ libgcc \
|
||||||
&& su ${user} -c "sh ./scripts/build.sh" \
|
&& su ${user} -c "sh ./scripts/build.sh" \
|
||||||
&& apk del .tailwind-runtime
|
&& apk del .tailwind-runtime
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
|
.PHONY: help install run migrate makemigrations collectstatic superuser shell \
|
||||||
|
test coverage lint format messages compilemessages \
|
||||||
|
precommit-install precommit
|
||||||
|
|
||||||
|
help: ## Muestra esta ayuda
|
||||||
|
@grep -E '^[a-zA-Z0-9_-]+:.*## ' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}'
|
||||||
|
|
||||||
|
install: ## Instala dependencias (incluidas las de desarrollo)
|
||||||
|
uv sync --group dev
|
||||||
|
|
||||||
|
run: ## Arranca el servidor de desarrollo
|
||||||
|
uv run python manage.py runserver 0.0.0.0:8000
|
||||||
|
|
||||||
|
migrate: ## Aplica las migraciones pendientes
|
||||||
|
uv run python manage.py migrate
|
||||||
|
|
||||||
|
makemigrations: ## Genera migraciones a partir de cambios en los modelos
|
||||||
|
uv run python manage.py makemigrations
|
||||||
|
|
||||||
|
collectstatic: ## Recopila los archivos estáticos
|
||||||
|
uv run python manage.py collectstatic --noinput
|
||||||
|
|
||||||
|
superuser: ## Crea un superusuario (para /admin/)
|
||||||
|
uv run python manage.py createsuperuser
|
||||||
|
|
||||||
|
shell: ## Abre una shell de Django (django-extensions shell_plus si está instalado)
|
||||||
|
uv run python manage.py shell_plus
|
||||||
|
|
||||||
|
test: ## Ejecuta la suite de tests
|
||||||
|
uv run pytest
|
||||||
|
|
||||||
|
coverage: ## Ejecuta los tests con cobertura
|
||||||
|
uv run pytest --cov
|
||||||
|
|
||||||
|
lint: ## Comprueba estilo/errores con ruff (no modifica nada)
|
||||||
|
uv run ruff check .
|
||||||
|
|
||||||
|
format: ## Aplica el formateador de ruff (ver [tool.ruff.format] en pyproject.toml)
|
||||||
|
uv run ruff format .
|
||||||
|
|
||||||
|
messages: ## Regenera las cadenas traducibles (es/en)
|
||||||
|
uv run python manage.py makemessages -l es -l en
|
||||||
|
|
||||||
|
compilemessages: ## Compila las traducciones (.po -> .mo)
|
||||||
|
uv run python manage.py compilemessages
|
||||||
|
|
||||||
|
precommit-install: ## Instala el hook de pre-commit en este repo
|
||||||
|
uv run pre-commit install
|
||||||
|
|
||||||
|
precommit: ## Ejecuta todos los hooks de pre-commit sobre el repo entero
|
||||||
|
uv run pre-commit run --all-files
|
||||||
@@ -16,11 +16,12 @@ cuentas de usuario para guardar y gestionar los propios patrones.
|
|||||||
impresión del navegador.
|
impresión del navegador.
|
||||||
- **Vista de solo lectura compartible** (`pattern/<uuid>/`), sin necesidad
|
- **Vista de solo lectura compartible** (`pattern/<uuid>/`), sin necesidad
|
||||||
de cuenta ni de cargar el editor.
|
de cuenta ni de cargar el editor.
|
||||||
- **Cuentas de usuario**: registro (con email, necesario para poder
|
- **Cuentas de usuario**: registro con verificación de email, login/logout
|
||||||
recuperar la contraseña), login/logout, ajustes de cuenta (cambiar
|
(con verificación en dos pasos opcional por TOTP + códigos de respaldo),
|
||||||
email/contraseña, con HTMX) y recuperación de contraseña por email.
|
ajustes de cuenta (cambiar email/contraseña, con HTMX) y recuperación de
|
||||||
Cada patrón pertenece a quien lo creó; solo su propietario puede
|
contraseña por email. Login, registro y recuperación de contraseña están
|
||||||
editarlo o borrarlo.
|
protegidos contra fuerza bruta (`django-ratelimit`). 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
|
- **"Mis patrones"**: listado de los patrones propios con portada, fecha de
|
||||||
actualización y accesos directos a editar/ver/eliminar.
|
actualización y accesos directos a editar/ver/eliminar.
|
||||||
- **i18n completo** (español/inglés): interfaz, URLs traducidas
|
- **i18n completo** (español/inglés): interfaz, URLs traducidas
|
||||||
@@ -123,6 +124,42 @@ WeasyPrint y ejecuta `scripts/build.sh` (CSS precompilado, `collectstatic`,
|
|||||||
`compilemessages`) al construir la imagen, y `scripts/run.sh` (Uvicorn) al
|
`compilemessages`) al construir la imagen, y `scripts/run.sh` (Uvicorn) al
|
||||||
arrancar el contenedor.
|
arrancar el contenedor.
|
||||||
|
|
||||||
|
### Entorno local con Postgres + S3 (Garage)
|
||||||
|
|
||||||
|
`docker-compose.yml` levanta un Postgres y un
|
||||||
|
[Garage](https://garagehq.deuxfleurs.fr/) (S3 self-hosted) para poder
|
||||||
|
probar `S3_ENABLED` sin depender de un bucket real:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Con eso, en tu `.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
DATABASE_URL=postgres://django:django@localhost:6543/django
|
||||||
|
S3_ENABLED=True
|
||||||
|
S3_ACCESS_KEY_ID=GKa1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6
|
||||||
|
S3_SECRET_ACCESS_KEY=f0e1d2c3b4a5968778695a4b3c2d1e0ff0e1d2c3b4a5968778695a4b3c2d1e0f
|
||||||
|
S3_STORAGE_BUCKET_NAME=default-bucket
|
||||||
|
S3_ENDPOINT_URL=http://localhost:3900
|
||||||
|
S3_REGION_NAME=garage
|
||||||
|
S3_USE_SSL=False
|
||||||
|
S3_ADDRESSING_STYLE=path
|
||||||
|
```
|
||||||
|
|
||||||
|
La web de administración de Garage queda en `http://localhost:3909/`
|
||||||
|
(usuario `admin`, contraseña `garage-dev`; cámbiala en `docker-compose.yml`
|
||||||
|
si vas a exponerla más allá de tu propia máquina).
|
||||||
|
|
||||||
|
### Herramientas de desarrollo
|
||||||
|
|
||||||
|
`make help` lista los atajos disponibles (`make test`, `make lint`,
|
||||||
|
`make format`, `make messages`...). El estilo del código se comprueba con
|
||||||
|
[ruff](https://docs.astral.sh/ruff/) (`make lint`); `make precommit-install`
|
||||||
|
instala un hook de pre-commit que lo ejecuta automáticamente en cada
|
||||||
|
commit.
|
||||||
|
|
||||||
## Variables de entorno
|
## Variables de entorno
|
||||||
|
|
||||||
Todas se leen en `config/settings/env.py`; sin `.env`, o si falta alguna,
|
Todas se leen en `config/settings/env.py`; sin `.env`, o si falta alguna,
|
||||||
@@ -137,10 +174,15 @@ se usan los valores por defecto indicados (pensados para desarrollo local).
|
|||||||
| `DATABASE_URL` | `sqlite:///db.sqlite3` | Formato `django-environ` (p. ej. `postgres://usuario:password@host:5432/nombre_bd`). |
|
| `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_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`. |
|
| `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). |
|
| `S3_REGION_NAME` / `S3_USE_SSL` / `S3_ADDRESSING_STYLE` | vacío / `True` / `auto` | Para S3 compatibles que lo exigen (p. ej. Garage, ver [entorno local con Garage](#entorno-local-con-postgres--s3-garage)): región obligatoria y sin soporte de direccionamiento "virtual" por defecto. |
|
||||||
|
| `EMAIL_BACKEND` | backend de consola | Usa `django.core.mail.backends.smtp.EmailBackend` para enviar emails de verdad (verificación de cuenta, 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. |
|
| `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). |
|
| `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`). |
|
| `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`). |
|
||||||
|
| `SITE_NAME` | `Crochet` | Nombre mostrado en los emails de aviso de la cuenta (registro, verificación en dos pasos...). |
|
||||||
|
| `RATELIMIT_ENABLE` | `True` | Desactiva la limitación de peticiones en login/registro/recuperación de contraseña/verificación en dos pasos (pensado para tests o depuración, no para producción). |
|
||||||
|
| `SECURE_SSL_REDIRECT` / `SESSION_COOKIE_SECURE` / `CSRF_COOKIE_SECURE` | `False` | Actívalas en producción una vez la app esté detrás de HTTPS de verdad; en desarrollo local (HTTP plano) dejarlas activas rompe la sesión/CSRF. |
|
||||||
|
| `SECURE_HSTS_SECONDS` / `SECURE_HSTS_INCLUDE_SUBDOMAINS` / `SECURE_HSTS_PRELOAD` | `0` / `False` / `False` | HSTS; empieza con un valor bajo (p. ej. `3600`) antes de subirlo a uno largo (p. ej. `31536000` = 1 año) — un valor largo mal configurado deja el dominio inaccesible por HTTPS durante ese tiempo. |
|
||||||
| `LOG_LEVEL` | `INFO` | Nivel de log de Django y de la app (`crochet`). |
|
| `LOG_LEVEL` | `INFO` | Nivel de log de Django y de la app (`crochet`). |
|
||||||
|
|
||||||
Existen además `CORS_ORIGIN_WHITELIST`, `REDIS_HOST`, `REDIS_PORT`,
|
Existen además `CORS_ORIGIN_WHITELIST`, `REDIS_HOST`, `REDIS_PORT`,
|
||||||
@@ -168,6 +210,10 @@ Como mínimo hay que ajustar, respecto al `.env` de desarrollo:
|
|||||||
configura `S3_ENABLED=True` y las credenciales de `S3_*` — el
|
configura `S3_ENABLED=True` y las credenciales de `S3_*` — el
|
||||||
filesystem local (por defecto) no es compartido ni persistente entre
|
filesystem local (por defecto) no es compartido ni persistente entre
|
||||||
despliegues.
|
despliegues.
|
||||||
|
8. **HTTPS**: una vez la app esté detrás de HTTPS de verdad, activa
|
||||||
|
`SECURE_SSL_REDIRECT=True`, `SESSION_COOKIE_SECURE=True` y
|
||||||
|
`CSRF_COOKIE_SECURE=True` (y, si quieres, `SECURE_HSTS_SECONDS` — ver la
|
||||||
|
tabla de arriba antes de subirlo a un valor largo).
|
||||||
|
|
||||||
La imagen Docker ya ejecuta `collectstatic`, `compilemessages` y el
|
La imagen Docker ya ejecuta `collectstatic`, `compilemessages` y el
|
||||||
CSS precompilado al construirse, y sirve la app con Uvicorn (variables
|
CSS precompilado al construirse, y sirve la app con Uvicorn (variables
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
from config.settings.env import *
|
|
||||||
from config.settings.base import *
|
from config.settings.base import *
|
||||||
|
from config.settings.env import *
|
||||||
|
|||||||
+19
-2
@@ -10,10 +10,8 @@ For the full list of config and their values, see
|
|||||||
https://docs.djangoproject.com/en/6.0/ref/settings/
|
https://docs.djangoproject.com/en/6.0/ref/settings/
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from config.settings.env import *
|
from config.settings.env import *
|
||||||
|
|
||||||
|
|
||||||
# Application definition
|
# Application definition
|
||||||
|
|
||||||
DJANGO_APPS = [
|
DJANGO_APPS = [
|
||||||
@@ -30,6 +28,9 @@ THIRD_PARTY_APPS = [
|
|||||||
'django_extensions',
|
'django_extensions',
|
||||||
'django_filters',
|
'django_filters',
|
||||||
'django_htmx',
|
'django_htmx',
|
||||||
|
'django_otp',
|
||||||
|
'django_otp.plugins.otp_totp',
|
||||||
|
'django_otp.plugins.otp_static',
|
||||||
'watchman',
|
'watchman',
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -54,6 +55,7 @@ MIDDLEWARE = [
|
|||||||
'django.middleware.common.CommonMiddleware',
|
'django.middleware.common.CommonMiddleware',
|
||||||
'django.middleware.csrf.CsrfViewMiddleware',
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
|
'django_otp.middleware.OTPMiddleware',
|
||||||
'django.contrib.messages.middleware.MessageMiddleware',
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
'django_htmx.middleware.HtmxMiddleware',
|
'django_htmx.middleware.HtmxMiddleware',
|
||||||
@@ -79,6 +81,7 @@ TEMPLATES = [
|
|||||||
'django.template.context_processors.request',
|
'django.template.context_processors.request',
|
||||||
'django.contrib.auth.context_processors.auth',
|
'django.contrib.auth.context_processors.auth',
|
||||||
'django.contrib.messages.context_processors.messages',
|
'django.contrib.messages.context_processors.messages',
|
||||||
|
'crochet.context_processors.site_name',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -102,6 +105,11 @@ LOGIN_URL = 'crochet:login'
|
|||||||
LOGIN_REDIRECT_URL = 'crochet:home'
|
LOGIN_REDIRECT_URL = 'crochet:home'
|
||||||
LOGOUT_REDIRECT_URL = 'crochet:login'
|
LOGOUT_REDIRECT_URL = 'crochet:login'
|
||||||
|
|
||||||
|
# Nombre que muestran apps como Google Authenticator/Aegis junto al usuario
|
||||||
|
# al escanear el QR de alta de verificación en dos pasos (ver
|
||||||
|
# TOTPDevice.config_url, crochet/views.py).
|
||||||
|
OTP_TOTP_ISSUER = SITE_NAME
|
||||||
|
|
||||||
|
|
||||||
# Password validation
|
# Password validation
|
||||||
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators
|
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators
|
||||||
@@ -165,6 +173,15 @@ EMAIL_USE_TLS = True
|
|||||||
EMAIL_USE_SSL = False
|
EMAIL_USE_SSL = False
|
||||||
|
|
||||||
|
|
||||||
|
# Security
|
||||||
|
# https://docs.djangoproject.com/en/6.0/topics/security/
|
||||||
|
# SECURE_SSL_REDIRECT/SESSION_COOKIE_SECURE/CSRF_COOKIE_SECURE/SECURE_HSTS_*
|
||||||
|
# se leen de env.py (ver env.py): desactivados por defecto porque asumen
|
||||||
|
# HTTPS, que no hay en desarrollo local. SECURE_CONTENT_TYPE_NOSNIFF no
|
||||||
|
# depende de eso (no rompe nada servir por HTTP), así que va fijo aquí.
|
||||||
|
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||||
|
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
# https://docs.djangoproject.com/en/6.0/topics/logging/
|
# https://docs.djangoproject.com/en/6.0/topics/logging/
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,13 @@ AWS_ACCESS_KEY_ID = env.str('S3_ACCESS_KEY_ID', '')
|
|||||||
AWS_SECRET_ACCESS_KEY = env.str('S3_SECRET_ACCESS_KEY', '')
|
AWS_SECRET_ACCESS_KEY = env.str('S3_SECRET_ACCESS_KEY', '')
|
||||||
AWS_STORAGE_BUCKET_NAME = env.str('S3_STORAGE_BUCKET_NAME', '')
|
AWS_STORAGE_BUCKET_NAME = env.str('S3_STORAGE_BUCKET_NAME', '')
|
||||||
AWS_S3_ENDPOINT_URL = env.str('S3_ENDPOINT_URL', '')
|
AWS_S3_ENDPOINT_URL = env.str('S3_ENDPOINT_URL', '')
|
||||||
|
# Compatibilidad con Garage (ver docker-compose.yml/garage.toml, S3
|
||||||
|
# self-hosted para desarrollo local): a diferencia de AWS S3, exige indicar
|
||||||
|
# la región a mano y no soporta el estilo de direccionamiento "virtual"
|
||||||
|
# (bucket como subdominio) por defecto.
|
||||||
|
AWS_S3_REGION_NAME = env.str('S3_REGION_NAME', '')
|
||||||
|
AWS_S3_USE_SSL = env.bool('S3_USE_SSL', True)
|
||||||
|
AWS_S3_ADDRESSING_STYLE = env.str('S3_ADDRESSING_STYLE', 'auto')
|
||||||
|
|
||||||
ITEMS_PER_PAGE = env.int('ITEMS_PER_PAGE', 20)
|
ITEMS_PER_PAGE = env.int('ITEMS_PER_PAGE', 20)
|
||||||
|
|
||||||
@@ -49,4 +56,34 @@ DEFAULT_FROM_EMAIL = env.str('DEFAULT_FROM_EMAIL', 'webmaster@localhost')
|
|||||||
# EmailUpdateForm) por si el cambio no lo ha hecho él.
|
# EmailUpdateForm) por si el cambio no lo ha hecho él.
|
||||||
SUPPORT_EMAIL = env.str('SUPPORT_EMAIL', 'soporte@localhost')
|
SUPPORT_EMAIL = env.str('SUPPORT_EMAIL', 'soporte@localhost')
|
||||||
|
|
||||||
|
# Nombre mostrado en el navbar, el <title> y los emails de aviso de la
|
||||||
|
# cuenta (ver EmailUpdateForm/StyledPasswordChangeForm/AccountDeleteForm).
|
||||||
|
SITE_NAME = env.str('SITE_NAME', 'Crochet')
|
||||||
|
|
||||||
LOG_LEVEL = env.str('LOG_LEVEL', 'INFO')
|
LOG_LEVEL = env.str('LOG_LEVEL', 'INFO')
|
||||||
|
|
||||||
|
# Limitación de peticiones (ver crochet/views.py: login, registro,
|
||||||
|
# recuperación de contraseña y verificación en dos pasos) con
|
||||||
|
# django-ratelimit. Usa el cache framework de Django (settings.CACHES,
|
||||||
|
# "default" por defecto: LocMemCache si no se configura otra cosa) para
|
||||||
|
# contar peticiones; LocMemCache es propio de cada proceso, así que con
|
||||||
|
# más de un worker/instancia en producción el límite real es "por
|
||||||
|
# proceso", no global — configura ahí un backend compartido (Redis,
|
||||||
|
# Memcached) si eso te importa.
|
||||||
|
RATELIMIT_ENABLE = env.bool('RATELIMIT_ENABLE', True)
|
||||||
|
|
||||||
|
# Cabeceras/cookies de seguridad para servir por HTTPS en producción. Todo
|
||||||
|
# esto viene desactivado por defecto porque en desarrollo local se sirve
|
||||||
|
# por HTTP plano (activarlo ahí dejaría la app inaccesible o el navegador
|
||||||
|
# descartando las cookies de sesión/CSRF). Actívalo en el .env de
|
||||||
|
# producción una vez la app esté detrás de HTTPS de verdad.
|
||||||
|
SECURE_SSL_REDIRECT = env.bool('SECURE_SSL_REDIRECT', False)
|
||||||
|
SESSION_COOKIE_SECURE = env.bool('SESSION_COOKIE_SECURE', False)
|
||||||
|
CSRF_COOKIE_SECURE = env.bool('CSRF_COOKIE_SECURE', False)
|
||||||
|
# En segundos; 0 desactiva HSTS. Empieza bajo (p. ej. 3600) para probar en
|
||||||
|
# producción antes de subirlo a un valor largo (p. ej. 31536000 = 1 año):
|
||||||
|
# un valor largo con la configuración equivocada deja el dominio
|
||||||
|
# inaccesible por HTTPS durante ese tiempo para quien ya lo haya recibido.
|
||||||
|
SECURE_HSTS_SECONDS = env.int('SECURE_HSTS_SECONDS', 0)
|
||||||
|
SECURE_HSTS_INCLUDE_SUBDOMAINS = env.bool('SECURE_HSTS_INCLUDE_SUBDOMAINS', False)
|
||||||
|
SECURE_HSTS_PRELOAD = env.bool('SECURE_HSTS_PRELOAD', False)
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@ Including another URLconf
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.conf.urls.i18n import i18n_patterns
|
from django.conf.urls.i18n import i18n_patterns
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import path, include
|
from django.urls import include, path
|
||||||
from django.views.static import serve
|
from django.views.static import serve
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
|||||||
+14
@@ -1,10 +1,24 @@
|
|||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from django.core.cache import cache
|
||||||
from django.core.management import call_command
|
from django.core.management import call_command
|
||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clear_ratelimit_cache():
|
||||||
|
"""django-ratelimit (ver LOGIN_RATE/REGISTER_RATE/PASSWORD_RESET_RATE en
|
||||||
|
crochet/views.py) cuenta peticiones en el cache framework de Django, que
|
||||||
|
-a diferencia de la base de datos- no se resetea solo entre tests; sin
|
||||||
|
esto, tests de distintos módulos que llaman a la misma vista (login,
|
||||||
|
registro...) irían sumando al mismo contador y algún test acabaría
|
||||||
|
recibiendo un 403 por límite alcanzado en vez del resultado que espera,
|
||||||
|
según en qué orden se ejecuten."""
|
||||||
|
cache.clear()
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope='session', autouse=True)
|
@pytest.fixture(scope='session', autouse=True)
|
||||||
def collected_static_files():
|
def collected_static_files():
|
||||||
"""{% static %} (usado en pattern.html/pattern_detail.html) necesita el
|
"""{% static %} (usado en pattern.html/pattern_detail.html) necesita el
|
||||||
|
|||||||
+9
-1
@@ -1,6 +1,6 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from crochet.models import Pattern, PatternImage, StitchType
|
from crochet.models import Pattern, PatternImage, StitchType, TwoFactorRememberedDevice
|
||||||
|
|
||||||
|
|
||||||
class PatternImageInline(admin.TabularInline):
|
class PatternImageInline(admin.TabularInline):
|
||||||
@@ -26,3 +26,11 @@ class PatternImageAdmin(admin.ModelAdmin):
|
|||||||
class StitchTypeAdmin(admin.ModelAdmin):
|
class StitchTypeAdmin(admin.ModelAdmin):
|
||||||
list_display = ('__str__', 'order')
|
list_display = ('__str__', 'order')
|
||||||
list_editable = ('order',)
|
list_editable = ('order',)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(TwoFactorRememberedDevice)
|
||||||
|
class TwoFactorRememberedDeviceAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('user', 'device_name', 'created_at', 'expires_at')
|
||||||
|
list_filter = ('device_name',)
|
||||||
|
search_fields = ('user__username', 'device_name')
|
||||||
|
readonly_fields = ('token', 'created_at')
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
|
def site_name(request):
|
||||||
|
return {'site_name': settings.SITE_NAME}
|
||||||
+48
-3
@@ -68,7 +68,7 @@ class StyledPasswordChangeForm(PasswordChangeForm):
|
|||||||
# "anterior" distinta: el aviso va al propio email de la cuenta.
|
# "anterior" distinta: el aviso va al propio email de la cuenta.
|
||||||
# Sin email no hay a quién avisar (cuentas antiguas sin uno).
|
# Sin email no hay a quién avisar (cuentas antiguas sin uno).
|
||||||
if user.email:
|
if user.email:
|
||||||
context = {'user': user, 'site_name': 'Crochet', 'support_email': settings.SUPPORT_EMAIL}
|
context = {'user': user, 'site_name': settings.SITE_NAME, 'support_email': settings.SUPPORT_EMAIL}
|
||||||
_send_account_notice(
|
_send_account_notice(
|
||||||
'crochet/email/password_changed_subject.txt',
|
'crochet/email/password_changed_subject.txt',
|
||||||
'crochet/email/password_changed_notice.txt',
|
'crochet/email/password_changed_notice.txt',
|
||||||
@@ -109,7 +109,10 @@ class EmailUpdateForm(forms.Form):
|
|||||||
# todavía puede leerlo. Si no había email antes, o si "cambiar" no
|
# 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é.
|
# ha cambiado nada, no hay a quién avisar ni de qué.
|
||||||
if old_email and old_email != self.user.email:
|
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}
|
context = {
|
||||||
|
'user': self.user, 'old_email': old_email,
|
||||||
|
'site_name': settings.SITE_NAME, 'support_email': settings.SUPPORT_EMAIL,
|
||||||
|
}
|
||||||
_send_account_notice(
|
_send_account_notice(
|
||||||
'crochet/email/email_changed_subject.txt',
|
'crochet/email/email_changed_subject.txt',
|
||||||
'crochet/email/email_changed_notice.txt',
|
'crochet/email/email_changed_notice.txt',
|
||||||
@@ -138,6 +141,48 @@ class DisplayNameForm(forms.Form):
|
|||||||
self.user.save(update_fields=['first_name'])
|
self.user.save(update_fields=['first_name'])
|
||||||
|
|
||||||
|
|
||||||
|
class TwoFactorTokenForm(forms.Form):
|
||||||
|
# Un único campo de texto tanto para el código de 6 dígitos de la app
|
||||||
|
# (TOTP) como para un código de respaldo (StaticDevice): cuál de los
|
||||||
|
# dos es se decide al verificarlo (ver TwoFactorVerifyView/
|
||||||
|
# TwoFactorSetupView en crochet/views.py), no aquí. autocomplete
|
||||||
|
# "one-time-code" es el valor estándar para que el navegador/gestor de
|
||||||
|
# contraseñas ofrezca autorrellenar con el código recibido, si puede.
|
||||||
|
token = forms.CharField(
|
||||||
|
label=_('Código'),
|
||||||
|
widget=forms.TextInput(attrs={'class': INPUT_CLASSES, 'autocomplete': 'one-time-code'}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TwoFactorVerifyForm(TwoFactorTokenForm):
|
||||||
|
# Solo en el login (ver TwoFactorVerifyView), no en el alta
|
||||||
|
# (TwoFactorSetupView reusa TwoFactorTokenForm sin este campo): ahí no
|
||||||
|
# tiene sentido "recordar" un dispositivo que todavía se está
|
||||||
|
# configurando.
|
||||||
|
remember_device = forms.BooleanField(
|
||||||
|
label=_('Recordar este dispositivo 30 días'),
|
||||||
|
required=False,
|
||||||
|
widget=forms.CheckboxInput(attrs={'class': 'checkbox checkbox-sm'}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TwoFactorDisableForm(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
|
||||||
|
|
||||||
|
|
||||||
class AccountDeleteForm(forms.Form):
|
class AccountDeleteForm(forms.Form):
|
||||||
password = forms.CharField(
|
password = forms.CharField(
|
||||||
label=_('Contraseña'), widget=forms.PasswordInput(attrs={'class': INPUT_CLASSES}),
|
label=_('Contraseña'), widget=forms.PasswordInput(attrs={'class': INPUT_CLASSES}),
|
||||||
@@ -157,7 +202,7 @@ class AccountDeleteForm(forms.Form):
|
|||||||
# Antes de borrar la cuenta (ver AccountDeleteView), mientras el
|
# Antes de borrar la cuenta (ver AccountDeleteView), mientras el
|
||||||
# email todavía existe. Sin email no hay a quién avisar.
|
# email todavía existe. Sin email no hay a quién avisar.
|
||||||
if self.user.email:
|
if self.user.email:
|
||||||
context = {'user': self.user, 'site_name': 'Crochet', 'support_email': settings.SUPPORT_EMAIL}
|
context = {'user': self.user, 'site_name': settings.SITE_NAME, 'support_email': settings.SUPPORT_EMAIL}
|
||||||
_send_account_notice(
|
_send_account_notice(
|
||||||
'crochet/email/account_deleted_subject.txt',
|
'crochet/email/account_deleted_subject.txt',
|
||||||
'crochet/email/account_deleted_notice.txt',
|
'crochet/email/account_deleted_notice.txt',
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: crochet\n"
|
"Project-Id-Version: crochet\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-07-22 08:53+0000\n"
|
"POT-Creation-Date: 2026-09-16 11:16+0000\n"
|
||||||
"Language: en\n"
|
"Language: en\n"
|
||||||
"MIME-Version: 1.0\n"
|
"MIME-Version: 1.0\n"
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
@@ -13,18 +13,134 @@ msgstr ""
|
|||||||
msgid "Email"
|
msgid "Email"
|
||||||
msgstr "Email"
|
msgstr "Email"
|
||||||
|
|
||||||
#: crochet/forms.py:128 crochet/templates/crochet/account_settings.html:40
|
#: crochet/forms.py:131 crochet/templates/crochet/account_settings.html:40
|
||||||
msgid "Nombre para mostrar"
|
msgid "Nombre para mostrar"
|
||||||
msgstr "Display name"
|
msgstr "Display name"
|
||||||
|
|
||||||
#: crochet/forms.py:143
|
#: crochet/forms.py:152
|
||||||
|
msgid "Código"
|
||||||
|
msgstr "Code"
|
||||||
|
|
||||||
|
#: crochet/forms.py:163
|
||||||
|
msgid "Recordar este dispositivo 30 días"
|
||||||
|
msgstr "Remember this device for 30 days"
|
||||||
|
|
||||||
|
#: crochet/forms.py:171 crochet/forms.py:188
|
||||||
msgid "Contraseña"
|
msgid "Contraseña"
|
||||||
msgstr "Password"
|
msgstr "Password"
|
||||||
|
|
||||||
#: crochet/forms.py:153
|
#: crochet/forms.py:182 crochet/forms.py:198
|
||||||
msgid "Contraseña incorrecta."
|
msgid "Contraseña incorrecta."
|
||||||
msgstr "Incorrect password."
|
msgstr "Incorrect password."
|
||||||
|
|
||||||
|
#: crochet/templates/403.html:9 crochet/templates/403.html:19
|
||||||
|
msgid "Acceso denegado"
|
||||||
|
msgstr "Access denied"
|
||||||
|
|
||||||
|
#: crochet/templates/403.html:21
|
||||||
|
msgid ""
|
||||||
|
"No tienes permiso para ver esta página, o se han hecho demasiadas peticiones "
|
||||||
|
"seguidas desde aquí."
|
||||||
|
msgstr ""
|
||||||
|
"You don't have permission to view this page, or too many requests have been "
|
||||||
|
"made in a row from here."
|
||||||
|
|
||||||
|
#: crochet/templates/403.html:24 crochet/templates/404.html:19
|
||||||
|
#: crochet/templates/500.html:64
|
||||||
|
#: crochet/templates/crochet/cookie_policy.html:54
|
||||||
|
msgid "Volver al inicio"
|
||||||
|
msgstr "Back to home"
|
||||||
|
|
||||||
|
#: crochet/templates/404.html:4 crochet/templates/404.html:14
|
||||||
|
msgid "Página no encontrada"
|
||||||
|
msgstr "Page not found"
|
||||||
|
|
||||||
|
#: crochet/templates/404.html:16
|
||||||
|
msgid "La página que buscas no existe o se ha movido."
|
||||||
|
msgstr "The page you're looking for doesn't exist or has moved."
|
||||||
|
|
||||||
|
#: crochet/templates/500.html:15
|
||||||
|
msgid "Error del servidor"
|
||||||
|
msgstr "Server error"
|
||||||
|
|
||||||
|
#: crochet/templates/500.html:62
|
||||||
|
msgid "Vaya, algo ha ido mal"
|
||||||
|
msgstr "Oops, something went wrong"
|
||||||
|
|
||||||
|
#: crochet/templates/500.html:63
|
||||||
|
msgid "Ha ocurrido un error inesperado. Prueba a recargar en un momento."
|
||||||
|
msgstr "An unexpected error occurred. Try reloading in a moment."
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/_account_2fa_status.html:11
|
||||||
|
msgid "Activada"
|
||||||
|
msgstr "Enabled"
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/_account_2fa_status.html:17
|
||||||
|
#: crochet/templates/crochet/_account_2fa_status.html:90
|
||||||
|
msgid "Desactivar"
|
||||||
|
msgstr "Disable"
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/_account_2fa_status.html:23
|
||||||
|
msgid "Dispositivos recordados"
|
||||||
|
msgstr "Remembered devices"
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/_account_2fa_status.html:29
|
||||||
|
#, python-format
|
||||||
|
msgid "desde el %(date)s"
|
||||||
|
msgstr "since %(date)s"
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/_account_2fa_status.html:33
|
||||||
|
msgid "Olvidar este dispositivo"
|
||||||
|
msgstr "Forget this device"
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/_account_2fa_status.html:42
|
||||||
|
msgid "¿Olvidar este dispositivo?"
|
||||||
|
msgstr "Forget this device?"
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/_account_2fa_status.html:44
|
||||||
|
#, python-format
|
||||||
|
msgid ""
|
||||||
|
"La próxima vez que inicies sesión desde \"%(name)s\" volverá a pedirse el "
|
||||||
|
"código de verificación en dos pasos."
|
||||||
|
msgstr ""
|
||||||
|
"Next time you log in from \"%(name)s\" you'll be asked for the two-factor "
|
||||||
|
"code again."
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/_account_2fa_status.html:54
|
||||||
|
#: crochet/templates/crochet/_account_2fa_status.html:88
|
||||||
|
#: crochet/templates/crochet/two_factor_setup.html:45
|
||||||
|
msgid "Cancelar"
|
||||||
|
msgstr "Cancel"
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/_account_2fa_status.html:56
|
||||||
|
msgid "Olvidar"
|
||||||
|
msgstr "Forget"
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/_account_2fa_status.html:59
|
||||||
|
#: crochet/templates/crochet/_account_2fa_status.html:94
|
||||||
|
msgid "Cerrar"
|
||||||
|
msgstr "Close"
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/_account_2fa_status.html:68
|
||||||
|
msgid "Desactivar verificación en dos pasos"
|
||||||
|
msgstr "Disable two-factor authentication"
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/_account_2fa_status.html:70
|
||||||
|
msgid ""
|
||||||
|
"Confirma tu contraseña para desactivarla. Se olvidarán también todos los "
|
||||||
|
"dispositivos recordados."
|
||||||
|
msgstr ""
|
||||||
|
"Confirm your password to disable it. All remembered devices will also be "
|
||||||
|
"forgotten."
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/_account_2fa_status.html:98
|
||||||
|
msgid "Desactivada"
|
||||||
|
msgstr "Disabled"
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/_account_2fa_status.html:99
|
||||||
|
msgid "Activar"
|
||||||
|
msgstr "Enable"
|
||||||
|
|
||||||
#: crochet/templates/crochet/_account_delete_form.html:9
|
#: crochet/templates/crochet/_account_delete_form.html:9
|
||||||
msgid ""
|
msgid ""
|
||||||
"¿Seguro que quieres eliminar tu cuenta? Se borrarán también todos tus "
|
"¿Seguro que quieres eliminar tu cuenta? Se borrarán también todos tus "
|
||||||
@@ -34,7 +150,7 @@ msgstr ""
|
|||||||
"deleted. This action cannot be undone."
|
"deleted. This action cannot be undone."
|
||||||
|
|
||||||
#: crochet/templates/crochet/_account_delete_form.html:19
|
#: crochet/templates/crochet/_account_delete_form.html:19
|
||||||
#: crochet/templates/crochet/account_settings.html:68
|
#: crochet/templates/crochet/account_settings.html:78
|
||||||
msgid "Eliminar cuenta"
|
msgid "Eliminar cuenta"
|
||||||
msgstr "Delete account"
|
msgstr "Delete account"
|
||||||
|
|
||||||
@@ -109,12 +225,12 @@ msgid "Actualizado el %(date)s"
|
|||||||
msgstr "Updated on %(date)s"
|
msgstr "Updated on %(date)s"
|
||||||
|
|
||||||
#: crochet/templates/crochet/account_home.html:44
|
#: crochet/templates/crochet/account_home.html:44
|
||||||
#: crochet/templates/crochet/pattern.html:182
|
#: crochet/templates/crochet/pattern.html:156
|
||||||
msgid "Vista previa"
|
msgid "Vista previa"
|
||||||
msgstr "Preview"
|
msgstr "Preview"
|
||||||
|
|
||||||
#: crochet/templates/crochet/account_home.html:48
|
#: crochet/templates/crochet/account_home.html:48
|
||||||
#: crochet/templates/crochet/pattern.html:55
|
#: crochet/templates/crochet/pattern.html:46
|
||||||
msgid "Eliminar"
|
msgid "Eliminar"
|
||||||
msgstr "Delete"
|
msgstr "Delete"
|
||||||
|
|
||||||
@@ -124,7 +240,7 @@ msgstr "You don't have any patterns yet."
|
|||||||
|
|
||||||
#: crochet/templates/crochet/account_settings.html:4
|
#: crochet/templates/crochet/account_settings.html:4
|
||||||
#: crochet/templates/crochet/account_settings.html:18
|
#: crochet/templates/crochet/account_settings.html:18
|
||||||
#: crochet/templates/crochet/base.html:52
|
#: crochet/templates/crochet/base.html:62
|
||||||
msgid "Ajustes de la cuenta"
|
msgid "Ajustes de la cuenta"
|
||||||
msgstr "Account settings"
|
msgstr "Account settings"
|
||||||
|
|
||||||
@@ -140,19 +256,37 @@ msgstr "Security"
|
|||||||
msgid "Zona de peligro"
|
msgid "Zona de peligro"
|
||||||
msgstr "Danger zone"
|
msgstr "Danger zone"
|
||||||
|
|
||||||
#: crochet/templates/crochet/account_settings.html:70
|
#: crochet/templates/crochet/account_settings.html:63
|
||||||
|
#: crochet/templates/registration/two_factor_verify.html:4
|
||||||
|
#: crochet/templates/registration/two_factor_verify.html:14
|
||||||
|
msgid "Verificación en dos pasos"
|
||||||
|
msgstr "Two-factor authentication"
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/account_settings.html:65
|
||||||
|
msgid ""
|
||||||
|
"Pide un código de tu app de autenticación (Google Authenticator, Aegis...) "
|
||||||
|
"además de la contraseña al iniciar sesión."
|
||||||
|
msgstr ""
|
||||||
|
"Ask for a code from your authenticator app (Google Authenticator, Aegis...) "
|
||||||
|
"in addition to your password when logging in."
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/account_settings.html:80
|
||||||
msgid "Esto borrará tu cuenta y todos tus patrones de forma permanente."
|
msgid "Esto borrará tu cuenta y todos tus patrones de forma permanente."
|
||||||
msgstr "This will permanently delete your account and all your patterns."
|
msgstr "This will permanently delete your account and all your patterns."
|
||||||
|
|
||||||
#: crochet/templates/crochet/base.html:39
|
#: crochet/templates/crochet/base.html:37
|
||||||
|
msgid "Cambiar tema"
|
||||||
|
msgstr "Change theme"
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/base.html:49
|
||||||
msgid "Cambiar idioma"
|
msgid "Cambiar idioma"
|
||||||
msgstr "Change language"
|
msgstr "Change language"
|
||||||
|
|
||||||
#: crochet/templates/crochet/base.html:56
|
#: crochet/templates/crochet/base.html:66
|
||||||
msgid "Cerrar sesión"
|
msgid "Cerrar sesión"
|
||||||
msgstr "Log out"
|
msgstr "Log out"
|
||||||
|
|
||||||
#: crochet/templates/crochet/base.html:62
|
#: crochet/templates/crochet/base.html:72
|
||||||
#: crochet/templates/crochet/home.html:25
|
#: crochet/templates/crochet/home.html:25
|
||||||
#: crochet/templates/crochet/password_reset_complete.html:18
|
#: crochet/templates/crochet/password_reset_complete.html:18
|
||||||
#: crochet/templates/registration/login.html:4
|
#: crochet/templates/registration/login.html:4
|
||||||
@@ -225,10 +359,6 @@ msgstr ""
|
|||||||
"Both are technical cookies necessary for the site to work (logging in and "
|
"Both are technical cookies necessary for the site to work (logging in and "
|
||||||
"protecting forms), so they don't require your prior consent."
|
"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_notice.html:4
|
||||||
#: crochet/templates/crochet/email/account_deleted_subject.txt:1
|
#: crochet/templates/crochet/email/account_deleted_subject.txt:1
|
||||||
#, python-format
|
#, python-format
|
||||||
@@ -251,6 +381,8 @@ msgstr ""
|
|||||||
#: crochet/templates/crochet/email/email_changed_notice.txt:4
|
#: 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.html:12
|
||||||
#: crochet/templates/crochet/email/password_changed_notice.txt:4
|
#: crochet/templates/crochet/email/password_changed_notice.txt:4
|
||||||
|
#: crochet/templates/crochet/email/verify_email.html:19
|
||||||
|
#: crochet/templates/crochet/email/verify_email.txt:7
|
||||||
msgid "Tu usuario:"
|
msgid "Tu usuario:"
|
||||||
msgstr "Your username:"
|
msgstr "Your username:"
|
||||||
|
|
||||||
@@ -333,6 +465,34 @@ msgstr "Your username, in case you've forgotten:"
|
|||||||
msgid "Si no has solicitado este cambio, puedes ignorar este email."
|
msgid "Si no has solicitado este cambio, puedes ignorar este email."
|
||||||
msgstr "If you didn't request this change, you can ignore this email."
|
msgstr "If you didn't request this change, you can ignore this email."
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/email/verify_email.html:4
|
||||||
|
msgid "Confirma tu cuenta"
|
||||||
|
msgstr "Confirm your account"
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/email/verify_email.html:8
|
||||||
|
#: crochet/templates/crochet/email/verify_email.txt:2
|
||||||
|
#, python-format
|
||||||
|
msgid "Has recibido este email porque te has registrado en %(site_name)s."
|
||||||
|
msgstr "You're receiving this email because you signed up for %(site_name)s."
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/email/verify_email.html:14
|
||||||
|
msgid "Confirmar cuenta"
|
||||||
|
msgstr "Confirm account"
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/email/verify_email.html:23
|
||||||
|
#: crochet/templates/crochet/email/verify_email.txt:9
|
||||||
|
msgid "Si no has sido tú, puedes ignorar este email."
|
||||||
|
msgstr "If this wasn't you, you can ignore this email."
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/email/verify_email.txt:4
|
||||||
|
msgid "Sigue este enlace para confirmar tu cuenta:"
|
||||||
|
msgstr "Follow this link to confirm your account:"
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/email/verify_email_subject.txt:1
|
||||||
|
#, python-format
|
||||||
|
msgid "Confirma tu cuenta en %(site_name)s"
|
||||||
|
msgstr "Confirm your account on %(site_name)s"
|
||||||
|
|
||||||
#: crochet/templates/crochet/home.html:4
|
#: crochet/templates/crochet/home.html:4
|
||||||
msgid "Crochet — Crea y comparte tus patrones"
|
msgid "Crochet — Crea y comparte tus patrones"
|
||||||
msgstr "Crochet — Create and share your patterns"
|
msgstr "Crochet — Create and share your patterns"
|
||||||
@@ -403,6 +563,8 @@ msgid "Elige una contraseña nueva"
|
|||||||
msgstr "Choose a new password"
|
msgstr "Choose a new password"
|
||||||
|
|
||||||
#: crochet/templates/crochet/password_reset_confirm.html:33
|
#: crochet/templates/crochet/password_reset_confirm.html:33
|
||||||
|
#: crochet/templates/registration/verify_email_invalid.html:4
|
||||||
|
#: crochet/templates/registration/verify_email_invalid.html:14
|
||||||
msgid "Enlace no válido"
|
msgid "Enlace no válido"
|
||||||
msgstr "Invalid link"
|
msgstr "Invalid link"
|
||||||
|
|
||||||
@@ -419,6 +581,7 @@ msgid "Solicitar un enlace nuevo"
|
|||||||
msgstr "Request a new link"
|
msgstr "Request a new link"
|
||||||
|
|
||||||
#: crochet/templates/crochet/password_reset_done.html:14
|
#: crochet/templates/crochet/password_reset_done.html:14
|
||||||
|
#: crochet/templates/registration/register_done.html:14
|
||||||
msgid "Revisa tu email"
|
msgid "Revisa tu email"
|
||||||
msgstr "Check your email"
|
msgstr "Check your email"
|
||||||
|
|
||||||
@@ -432,6 +595,8 @@ msgstr ""
|
|||||||
|
|
||||||
#: crochet/templates/crochet/password_reset_done.html:19
|
#: crochet/templates/crochet/password_reset_done.html:19
|
||||||
#: crochet/templates/crochet/password_reset_form.html:32
|
#: crochet/templates/crochet/password_reset_form.html:32
|
||||||
|
#: crochet/templates/registration/register_done.html:19
|
||||||
|
#: crochet/templates/registration/two_factor_verify.html:33
|
||||||
msgid "Volver a iniciar sesión"
|
msgid "Volver a iniciar sesión"
|
||||||
msgstr "Back to login"
|
msgstr "Back to login"
|
||||||
|
|
||||||
@@ -453,245 +618,245 @@ msgstr "Send link"
|
|||||||
msgid "Recupera tu contraseña en %(site_name)s"
|
msgid "Recupera tu contraseña en %(site_name)s"
|
||||||
msgstr "Reset your password on %(site_name)s"
|
msgstr "Reset your password on %(site_name)s"
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:26
|
#: crochet/templates/crochet/pattern.html:23
|
||||||
msgid "Idioma del patrón"
|
|
||||||
msgstr "Pattern language"
|
|
||||||
|
|
||||||
# crochet/templates/crochet/pattern.html
|
|
||||||
#: 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:38
|
|
||||||
msgid "Guardar"
|
|
||||||
msgstr "Save"
|
|
||||||
|
|
||||||
#: 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:49
|
|
||||||
msgid "Más opciones"
|
|
||||||
msgstr "More options"
|
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:65
|
|
||||||
msgid "Guardado"
|
|
||||||
msgstr "Saved"
|
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:78
|
|
||||||
msgid "Título del patrón"
|
msgid "Título del patrón"
|
||||||
msgstr "Pattern title"
|
msgstr "Pattern title"
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:79
|
#: crochet/templates/crochet/pattern.html:24
|
||||||
msgid "Título del patrón..."
|
msgid "Título del patrón..."
|
||||||
msgstr "Pattern title..."
|
msgstr "Pattern title..."
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:82
|
# crochet/templates/crochet/pattern.html
|
||||||
|
#: crochet/templates/crochet/pattern.html:29
|
||||||
|
msgid "No se ha podido guardar. Inténtalo de nuevo."
|
||||||
|
msgstr "Couldn't save. Please try again."
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/pattern.html:29
|
||||||
|
msgid "Guardar"
|
||||||
|
msgstr "Save"
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/pattern.html:32
|
||||||
|
msgid "Ver patrón"
|
||||||
|
msgstr "View pattern"
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/pattern.html:33
|
||||||
|
msgid "Exportar a PDF"
|
||||||
|
msgstr "Export to PDF"
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/pattern.html:40
|
||||||
|
msgid "Más opciones"
|
||||||
|
msgstr "More options"
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/pattern.html:56
|
||||||
|
msgid "Guardado"
|
||||||
|
msgstr "Saved"
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/pattern.html:64
|
||||||
msgid "Imagen de portada"
|
msgid "Imagen de portada"
|
||||||
msgstr "Cover image"
|
msgstr "Cover image"
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:95
|
#: crochet/templates/crochet/pattern.html:73
|
||||||
msgid "Añadir imagen"
|
msgid "Añadir imagen"
|
||||||
msgstr "Add image"
|
msgstr "Add image"
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:98
|
#: crochet/templates/crochet/pattern.html:76
|
||||||
msgid "Cambiar"
|
msgid "Cambiar"
|
||||||
msgstr "Change"
|
msgstr "Change"
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:103
|
#: crochet/templates/crochet/pattern.html:81
|
||||||
msgid "No se ha podido subir la imagen de portada. Inténtalo de nuevo."
|
msgid "No se ha podido subir la imagen de portada. Inténtalo de nuevo."
|
||||||
msgstr "Couldn't upload the cover image. Please try again."
|
msgstr "Couldn't upload the cover image. Please try again."
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:114
|
#: crochet/templates/crochet/pattern.html:88
|
||||||
msgid "Personalización de página"
|
msgid "Personalización de página"
|
||||||
msgstr "Page customization"
|
msgstr "Page customization"
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:117
|
#: crochet/templates/crochet/pattern.html:91
|
||||||
msgid "Autor"
|
msgid "Autor"
|
||||||
msgstr "Author"
|
msgstr "Author"
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:118
|
#: crochet/templates/crochet/pattern.html:92
|
||||||
msgid "Autor..."
|
msgid "Autor..."
|
||||||
msgstr "Author..."
|
msgstr "Author..."
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:122
|
#: crochet/templates/crochet/pattern.html:96
|
||||||
#: crochet/templates/crochet/pattern.html:206
|
#: crochet/templates/crochet/pattern.html:191
|
||||||
msgid "Color del texto"
|
msgid "Color del texto"
|
||||||
msgstr "Text color"
|
msgstr "Text color"
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:126
|
#: crochet/templates/crochet/pattern.html:100
|
||||||
msgid "Color de acento (títulos)"
|
msgid "Color de acento (títulos)"
|
||||||
msgstr "Accent color (headings)"
|
msgstr "Accent color (headings)"
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:130
|
#: crochet/templates/crochet/pattern.html:104
|
||||||
#: crochet/templates/crochet/pattern.html:206
|
#: crochet/templates/crochet/pattern.html:191
|
||||||
msgid "Color de fondo"
|
msgid "Color de fondo"
|
||||||
msgstr "Background color"
|
msgstr "Background color"
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:134
|
#: crochet/templates/crochet/pattern.html:108
|
||||||
msgid "Tipografía"
|
msgid "Tipografía"
|
||||||
msgstr "Font"
|
msgstr "Font"
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:140
|
#: crochet/templates/crochet/pattern.html:114
|
||||||
msgid "Monoespaciada"
|
msgid "Monoespaciada"
|
||||||
msgstr "Monospace"
|
msgstr "Monospace"
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:144
|
#: crochet/templates/crochet/pattern.html:118
|
||||||
msgid "Tamaño de letra"
|
msgid "Tamaño de letra"
|
||||||
msgstr "Font size"
|
msgstr "Font size"
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:146
|
#: crochet/templates/crochet/pattern.html:120
|
||||||
msgid "Pequeño"
|
msgid "Pequeño"
|
||||||
msgstr "Small"
|
msgstr "Small"
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:147
|
#: crochet/templates/crochet/pattern.html:121
|
||||||
msgid "Normal"
|
msgid "Normal"
|
||||||
msgstr "Normal"
|
msgstr "Normal"
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:148
|
#: crochet/templates/crochet/pattern.html:122
|
||||||
msgid "Grande"
|
msgid "Grande"
|
||||||
msgstr "Large"
|
msgstr "Large"
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:152
|
#: crochet/templates/crochet/pattern.html:126
|
||||||
msgid "Alineación del texto"
|
msgid "Alineación del texto"
|
||||||
msgstr "Text alignment"
|
msgstr "Text alignment"
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:154
|
#: crochet/templates/crochet/pattern.html:128
|
||||||
msgid "Izquierda"
|
msgid "Izquierda"
|
||||||
msgstr "Left"
|
msgstr "Left"
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:155
|
#: crochet/templates/crochet/pattern.html:129
|
||||||
msgid "Centrado"
|
msgid "Centrado"
|
||||||
msgstr "Center"
|
msgstr "Center"
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:156
|
#: crochet/templates/crochet/pattern.html:130
|
||||||
msgid "Justificado"
|
msgid "Justificado"
|
||||||
msgstr "Justify"
|
msgstr "Justify"
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:160
|
#: crochet/templates/crochet/pattern.html:134
|
||||||
msgid "Tamaño de página"
|
msgid "Tamaño de página"
|
||||||
msgstr "Page size"
|
msgstr "Page size"
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:165
|
#: crochet/templates/crochet/pattern.html:139
|
||||||
msgid "Carta (Letter)"
|
msgid "Carta (Letter)"
|
||||||
msgstr "Letter"
|
msgstr "Letter"
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:170
|
#: crochet/templates/crochet/pattern.html:144
|
||||||
msgid "Orientación"
|
msgid "Orientación"
|
||||||
msgstr "Orientation"
|
msgstr "Orientation"
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:172
|
#: crochet/templates/crochet/pattern.html:146
|
||||||
msgid "Vertical"
|
msgid "Vertical"
|
||||||
msgstr "Portrait"
|
msgstr "Portrait"
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:173
|
#: crochet/templates/crochet/pattern.html:147
|
||||||
msgid "Horizontal"
|
msgid "Horizontal"
|
||||||
msgstr "Landscape"
|
msgstr "Landscape"
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:181
|
#: crochet/templates/crochet/pattern.html:155
|
||||||
#: crochet/templates/crochet/pattern.html:189
|
#: crochet/templates/crochet/pattern.html:174
|
||||||
msgid "Secciones"
|
msgid "Secciones"
|
||||||
msgstr "Sections"
|
msgstr "Sections"
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:191
|
#: crochet/templates/crochet/pattern.html:166
|
||||||
|
msgid "Idioma del patrón"
|
||||||
|
msgstr "Pattern language"
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/pattern.html:176
|
||||||
msgid "Colapsar todo"
|
msgid "Colapsar todo"
|
||||||
msgstr "Collapse all"
|
msgstr "Collapse all"
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:191
|
#: crochet/templates/crochet/pattern.html:176
|
||||||
msgid "Expandir todo"
|
msgid "Expandir todo"
|
||||||
msgstr "Expand all"
|
msgstr "Expand all"
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:196
|
#: crochet/templates/crochet/pattern.html:181
|
||||||
msgid "Añadir sección"
|
msgid "Añadir sección"
|
||||||
msgstr "Add section"
|
msgstr "Add section"
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:199
|
#: crochet/templates/crochet/pattern.html:184
|
||||||
msgid "Título"
|
msgid "Título"
|
||||||
msgstr "Title"
|
msgstr "Title"
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:199
|
#: crochet/templates/crochet/pattern.html:184
|
||||||
msgid "Título..."
|
msgid "Título..."
|
||||||
msgstr "Title..."
|
msgstr "Title..."
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:201
|
#: crochet/templates/crochet/pattern.html:186
|
||||||
msgid "Subtítulo"
|
msgid "Subtítulo"
|
||||||
msgstr "Subtitle"
|
msgstr "Subtitle"
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:201
|
#: crochet/templates/crochet/pattern.html:186
|
||||||
msgid "Subtítulo..."
|
msgid "Subtítulo..."
|
||||||
msgstr "Subtitle..."
|
msgstr "Subtitle..."
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:203
|
#: crochet/templates/crochet/pattern.html:188
|
||||||
msgid "Texto"
|
msgid "Texto"
|
||||||
msgstr "Text"
|
msgstr "Text"
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:203
|
#: crochet/templates/crochet/pattern.html:188
|
||||||
msgid "Escribe aquí..."
|
msgid "Escribe aquí..."
|
||||||
msgstr "Write here..."
|
msgstr "Write here..."
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:205
|
#: crochet/templates/crochet/pattern.html:190
|
||||||
#: crochet/templates/crochet/pattern.html:206
|
#: crochet/templates/crochet/pattern.html:191
|
||||||
msgid "Nota"
|
msgid "Nota"
|
||||||
msgstr "Note"
|
msgstr "Note"
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:205
|
#: crochet/templates/crochet/pattern.html:190
|
||||||
msgid "Escribe una nota o consejo..."
|
msgid "Escribe una nota o consejo..."
|
||||||
msgstr "Write a note or tip..."
|
msgstr "Write a note or tip..."
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:208
|
#: crochet/templates/crochet/pattern.html:193
|
||||||
msgid "Materiales"
|
msgid "Materiales"
|
||||||
msgstr "Materials"
|
msgstr "Materials"
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:208
|
#: crochet/templates/crochet/pattern.html:193
|
||||||
msgid "Añadir material..."
|
msgid "Añadir material..."
|
||||||
msgstr "Add material..."
|
msgstr "Add material..."
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:208
|
#: crochet/templates/crochet/pattern.html:193
|
||||||
msgid "Añadir línea"
|
msgid "Añadir línea"
|
||||||
msgstr "Add line"
|
msgstr "Add line"
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:210
|
#: crochet/templates/crochet/pattern.html:195
|
||||||
#: crochet/templates/crochet/pattern.html:212
|
#: crochet/templates/crochet/pattern.html:197
|
||||||
msgid "Imagen"
|
msgid "Imagen"
|
||||||
msgstr "Image"
|
msgstr "Image"
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:212
|
#: crochet/templates/crochet/pattern.html:197
|
||||||
msgid "No se ha podido subir la imagen. Inténtalo de nuevo."
|
msgid "No se ha podido subir la imagen. Inténtalo de nuevo."
|
||||||
msgstr "Couldn't upload the image. Please try again."
|
msgstr "Couldn't upload the image. Please try again."
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:214
|
#: crochet/templates/crochet/pattern.html:199
|
||||||
msgid "Patrón"
|
msgid "Patrón"
|
||||||
msgstr "Pattern"
|
msgstr "Pattern"
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:214
|
#: crochet/templates/crochet/pattern.html:199
|
||||||
msgid "Elementos"
|
msgid "Elementos"
|
||||||
msgstr "Elements"
|
msgstr "Elements"
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:216
|
#: crochet/templates/crochet/pattern.html:201
|
||||||
msgid "Grupo"
|
msgid "Grupo"
|
||||||
msgstr "Group"
|
msgstr "Group"
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:220
|
#: crochet/templates/crochet/pattern.html:205
|
||||||
msgid "Arrastrar para reordenar"
|
msgid "Arrastrar para reordenar"
|
||||||
msgstr "Drag to reorder"
|
msgstr "Drag to reorder"
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:221
|
#: crochet/templates/crochet/pattern.html:206
|
||||||
msgid "Colapsar / expandir"
|
msgid "Colapsar / expandir"
|
||||||
msgstr "Collapse / expand"
|
msgstr "Collapse / expand"
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:222
|
#: crochet/templates/crochet/pattern.html:207
|
||||||
msgid "Duplicar sección"
|
msgid "Duplicar sección"
|
||||||
msgstr "Duplicate section"
|
msgstr "Duplicate section"
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:223
|
#: crochet/templates/crochet/pattern.html:208
|
||||||
msgid "Eliminar sección"
|
msgid "Eliminar sección"
|
||||||
msgstr "Delete section"
|
msgstr "Delete section"
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:224
|
#: crochet/templates/crochet/pattern.html:209
|
||||||
msgid ""
|
msgid ""
|
||||||
"¿Seguro que quieres eliminar este grupo? Se eliminarán también todas las "
|
"¿Seguro que quieres eliminar este grupo? Se eliminarán también todas las "
|
||||||
"secciones que contiene."
|
"secciones que contiene."
|
||||||
@@ -699,7 +864,7 @@ msgstr ""
|
|||||||
"Are you sure you want to delete this group? All sections inside it will also "
|
"Are you sure you want to delete this group? All sections inside it will also "
|
||||||
"be deleted."
|
"be deleted."
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:229
|
#: crochet/templates/crochet/pattern.html:214
|
||||||
msgid "Añade una sección para ver aquí el resultado."
|
msgid "Añade una sección para ver aquí el resultado."
|
||||||
msgstr "Add a section to see the result here."
|
msgstr "Add a section to see the result here."
|
||||||
|
|
||||||
@@ -707,6 +872,40 @@ msgstr "Add a section to see the result here."
|
|||||||
msgid "Descargar PDF"
|
msgid "Descargar PDF"
|
||||||
msgstr "Download PDF"
|
msgstr "Download PDF"
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/two_factor_setup.html:4
|
||||||
|
#: crochet/templates/crochet/two_factor_setup.html:26
|
||||||
|
msgid "Activar verificación en dos pasos"
|
||||||
|
msgstr "Enable two-factor authentication"
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/two_factor_setup.html:15
|
||||||
|
msgid "Guarda tus códigos de respaldo"
|
||||||
|
msgstr "Save your backup codes"
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/two_factor_setup.html:17
|
||||||
|
msgid ""
|
||||||
|
"Cada uno sirve para iniciar sesión una vez si pierdes acceso a tu app de "
|
||||||
|
"autenticación. Guárdalos en un sitio seguro: no se van a volver a mostrar."
|
||||||
|
msgstr ""
|
||||||
|
"Each one lets you log in once if you lose access to your authenticator app. "
|
||||||
|
"Keep them somewhere safe: they won't be shown again."
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/two_factor_setup.html:24
|
||||||
|
msgid "Ya los he guardado"
|
||||||
|
msgstr "I've saved them"
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/two_factor_setup.html:28
|
||||||
|
msgid ""
|
||||||
|
"Escanea este código con tu app de autenticación (Google Authenticator, "
|
||||||
|
"Aegis, 1Password...) e introduce el código de 6 dígitos que te muestre para "
|
||||||
|
"confirmar."
|
||||||
|
msgstr ""
|
||||||
|
"Scan this code with your authenticator app (Google Authenticator, Aegis, "
|
||||||
|
"1Password...) and enter the 6-digit code it shows you to confirm."
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/two_factor_setup.html:42
|
||||||
|
msgid "Confirmar"
|
||||||
|
msgstr "Confirm"
|
||||||
|
|
||||||
#: crochet/templates/registration/login.html:17
|
#: crochet/templates/registration/login.html:17
|
||||||
msgid "Usuario o contraseña incorrectos."
|
msgid "Usuario o contraseña incorrectos."
|
||||||
msgstr "Incorrect username or password."
|
msgstr "Incorrect username or password."
|
||||||
@@ -736,96 +935,168 @@ msgstr "Sign up"
|
|||||||
msgid "¿Ya tienes cuenta? Inicia sesión"
|
msgid "¿Ya tienes cuenta? Inicia sesión"
|
||||||
msgstr "Already have an account? Log in"
|
msgstr "Already have an account? Log in"
|
||||||
|
|
||||||
#: crochet/urls.py:38
|
#: crochet/templates/registration/register_done.html:4
|
||||||
|
msgid "Confirma tu email"
|
||||||
|
msgstr "Confirm your email"
|
||||||
|
|
||||||
|
#: crochet/templates/registration/register_done.html:16
|
||||||
|
msgid ""
|
||||||
|
"Te hemos enviado un enlace para confirmar tu cuenta. Ábrelo desde tu bandeja "
|
||||||
|
"de entrada para poder iniciar sesión."
|
||||||
|
msgstr ""
|
||||||
|
"We've sent you a link to confirm your account. Open it from your inbox so "
|
||||||
|
"you can log in."
|
||||||
|
|
||||||
|
#: crochet/templates/registration/two_factor_verify.html:16
|
||||||
|
msgid ""
|
||||||
|
"Introduce el código de tu app de autenticación, o uno de tus códigos de "
|
||||||
|
"respaldo si no tienes acceso a ella."
|
||||||
|
msgstr ""
|
||||||
|
"Enter the code from your authenticator app, or one of your backup codes if "
|
||||||
|
"you don't have access to it."
|
||||||
|
|
||||||
|
#: crochet/templates/registration/two_factor_verify.html:30
|
||||||
|
msgid "Verificar"
|
||||||
|
msgstr "Verify"
|
||||||
|
|
||||||
|
#: crochet/templates/registration/verify_email_invalid.html:16
|
||||||
|
msgid ""
|
||||||
|
"El enlace de confirmación no es válido, puede que ya se haya usado o que la "
|
||||||
|
"cuenta ya esté verificada."
|
||||||
|
msgstr ""
|
||||||
|
"The confirmation link is invalid, it may have already been used, or the "
|
||||||
|
"account may already be verified."
|
||||||
|
|
||||||
|
#: crochet/templates/registration/verify_email_invalid.html:19
|
||||||
|
msgid "Ir a iniciar sesión"
|
||||||
|
msgstr "Go to login"
|
||||||
|
|
||||||
|
#: crochet/urls.py:46
|
||||||
msgid "cookies/"
|
msgid "cookies/"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/urls.py:39
|
#: crochet/urls.py:47
|
||||||
msgid "pattern/<uuid:uuid>/"
|
msgid "pattern/<uuid:uuid>/"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/urls.py:40
|
#: crochet/urls.py:48
|
||||||
msgid "pattern/<uuid:uuid>/edit/"
|
msgid "pattern/<uuid:uuid>/edit/"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/urls.py:41
|
#: crochet/urls.py:49
|
||||||
msgid "pattern/<uuid:uuid>/save/"
|
msgid "pattern/<uuid:uuid>/save/"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/urls.py:42
|
#: crochet/urls.py:50
|
||||||
msgid "pattern/<uuid:uuid>/images/"
|
msgid "pattern/<uuid:uuid>/images/"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/urls.py:43
|
#: crochet/urls.py:51
|
||||||
msgid "pattern/<uuid:uuid>/cover/"
|
msgid "pattern/<uuid:uuid>/cover/"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/urls.py:44
|
#: crochet/urls.py:52
|
||||||
msgid "pattern/<uuid:uuid>/pdf/"
|
msgid "pattern/<uuid:uuid>/pdf/"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/urls.py:45
|
#: crochet/urls.py:53
|
||||||
msgid "pattern/<uuid:uuid>/delete/"
|
msgid "pattern/<uuid:uuid>/delete/"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/urls.py:46
|
#: crochet/urls.py:54
|
||||||
msgid "pattern/new/"
|
msgid "pattern/new/"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/urls.py:47
|
#: crochet/urls.py:55
|
||||||
msgid "account/settings/"
|
msgid "account/settings/"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/urls.py:48
|
#: crochet/urls.py:56
|
||||||
msgid "account/settings/email/"
|
msgid "account/settings/email/"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/urls.py:50
|
#: crochet/urls.py:58
|
||||||
msgid "account/settings/password/"
|
msgid "account/settings/password/"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/urls.py:54
|
#: crochet/urls.py:62
|
||||||
msgid "account/settings/display-name/"
|
msgid "account/settings/display-name/"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/urls.py:57
|
#: crochet/urls.py:65
|
||||||
msgid "account/settings/delete/"
|
msgid "account/settings/delete/"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/urls.py:58
|
#: crochet/urls.py:66
|
||||||
msgid "account/register/"
|
msgid "account/settings/2fa/setup/"
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: crochet/urls.py:60
|
|
||||||
msgid "account/login/"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: crochet/urls.py:65
|
|
||||||
msgid "account/logout/"
|
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/urls.py:67
|
#: crochet/urls.py:67
|
||||||
|
msgid "account/settings/2fa/disable/"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/urls.py:69
|
||||||
|
msgid "account/settings/2fa/devices/<int:device_id>/forget/"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/urls.py:72
|
||||||
|
msgid "account/register/"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/urls.py:73
|
||||||
|
msgid "account/register/done/"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/urls.py:74
|
||||||
|
msgid "account/verify/<uidb64>/<token>/"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/urls.py:75
|
||||||
|
msgid "account/2fa/verify/"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/urls.py:77
|
||||||
|
msgid "account/login/"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/urls.py:82
|
||||||
|
msgid "account/logout/"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/urls.py:84
|
||||||
msgid "account/password-reset/"
|
msgid "account/password-reset/"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/urls.py:86
|
#: crochet/urls.py:103
|
||||||
msgid "account/password-reset/done/"
|
msgid "account/password-reset/done/"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/urls.py:90
|
#: crochet/urls.py:107
|
||||||
msgid "account/reset/<uidb64>/<token>/"
|
msgid "account/reset/<uidb64>/<token>/"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/urls.py:98
|
#: crochet/urls.py:115
|
||||||
msgid "account/reset/done/"
|
msgid "account/reset/done/"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/user_agent.py:33 crochet/user_agent.py:40
|
||||||
|
msgid "Dispositivo desconocido"
|
||||||
|
msgstr "Unknown device"
|
||||||
|
|
||||||
# crochet/views.py (PatternDetailView.EMPTY_MESSAGE)
|
# crochet/views.py (PatternDetailView.EMPTY_MESSAGE)
|
||||||
#: crochet/views.py:29
|
#: crochet/views.py:51
|
||||||
msgid "Este patrón todavía no tiene contenido."
|
msgid "Este patrón todavía no tiene contenido."
|
||||||
msgstr "This pattern doesn't have any content yet."
|
msgstr "This pattern doesn't have any content yet."
|
||||||
|
|
||||||
#: crochet/views.py:350
|
#: crochet/views.py:407
|
||||||
|
msgid "Tu cuenta ha sido verificada. ¡Bienvenido/a!"
|
||||||
|
msgstr "Your account has been verified. Welcome!"
|
||||||
|
|
||||||
|
#: crochet/views.py:473 crochet/views.py:541
|
||||||
|
msgid "Código incorrecto."
|
||||||
|
msgstr "Incorrect code."
|
||||||
|
|
||||||
|
#: crochet/views.py:708
|
||||||
msgid "Tu cuenta se ha eliminado correctamente."
|
msgid "Tu cuenta se ha eliminado correctamente."
|
||||||
msgstr "Your account has been deleted successfully."
|
msgstr "Your account has been deleted successfully."
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: crochet\n"
|
"Project-Id-Version: crochet\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-07-22 08:53+0000\n"
|
"POT-Creation-Date: 2026-09-16 11:16+0000\n"
|
||||||
"Language: es\n"
|
"Language: es\n"
|
||||||
"MIME-Version: 1.0\n"
|
"MIME-Version: 1.0\n"
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
@@ -13,18 +13,128 @@ msgstr ""
|
|||||||
msgid "Email"
|
msgid "Email"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/forms.py:128 crochet/templates/crochet/account_settings.html:40
|
#: crochet/forms.py:131 crochet/templates/crochet/account_settings.html:40
|
||||||
msgid "Nombre para mostrar"
|
msgid "Nombre para mostrar"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/forms.py:143
|
#: crochet/forms.py:152
|
||||||
|
msgid "Código"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/forms.py:163
|
||||||
|
msgid "Recordar este dispositivo 30 días"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/forms.py:171 crochet/forms.py:188
|
||||||
msgid "Contraseña"
|
msgid "Contraseña"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/forms.py:153
|
#: crochet/forms.py:182 crochet/forms.py:198
|
||||||
msgid "Contraseña incorrecta."
|
msgid "Contraseña incorrecta."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/templates/403.html:9 crochet/templates/403.html:19
|
||||||
|
msgid "Acceso denegado"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/templates/403.html:21
|
||||||
|
msgid ""
|
||||||
|
"No tienes permiso para ver esta página, o se han hecho demasiadas peticiones "
|
||||||
|
"seguidas desde aquí."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/templates/403.html:24 crochet/templates/404.html:19
|
||||||
|
#: crochet/templates/500.html:64
|
||||||
|
#: crochet/templates/crochet/cookie_policy.html:54
|
||||||
|
msgid "Volver al inicio"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/templates/404.html:4 crochet/templates/404.html:14
|
||||||
|
msgid "Página no encontrada"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/templates/404.html:16
|
||||||
|
msgid "La página que buscas no existe o se ha movido."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/templates/500.html:15
|
||||||
|
msgid "Error del servidor"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/templates/500.html:62
|
||||||
|
msgid "Vaya, algo ha ido mal"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/templates/500.html:63
|
||||||
|
msgid "Ha ocurrido un error inesperado. Prueba a recargar en un momento."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/_account_2fa_status.html:11
|
||||||
|
msgid "Activada"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/_account_2fa_status.html:17
|
||||||
|
#: crochet/templates/crochet/_account_2fa_status.html:90
|
||||||
|
msgid "Desactivar"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/_account_2fa_status.html:23
|
||||||
|
msgid "Dispositivos recordados"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/_account_2fa_status.html:29
|
||||||
|
#, python-format
|
||||||
|
msgid "desde el %(date)s"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/_account_2fa_status.html:33
|
||||||
|
msgid "Olvidar este dispositivo"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/_account_2fa_status.html:42
|
||||||
|
msgid "¿Olvidar este dispositivo?"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/_account_2fa_status.html:44
|
||||||
|
#, python-format
|
||||||
|
msgid ""
|
||||||
|
"La próxima vez que inicies sesión desde \"%(name)s\" volverá a pedirse el "
|
||||||
|
"código de verificación en dos pasos."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/_account_2fa_status.html:54
|
||||||
|
#: crochet/templates/crochet/_account_2fa_status.html:88
|
||||||
|
#: crochet/templates/crochet/two_factor_setup.html:45
|
||||||
|
msgid "Cancelar"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/_account_2fa_status.html:56
|
||||||
|
msgid "Olvidar"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/_account_2fa_status.html:59
|
||||||
|
#: crochet/templates/crochet/_account_2fa_status.html:94
|
||||||
|
msgid "Cerrar"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/_account_2fa_status.html:68
|
||||||
|
msgid "Desactivar verificación en dos pasos"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/_account_2fa_status.html:70
|
||||||
|
msgid ""
|
||||||
|
"Confirma tu contraseña para desactivarla. Se olvidarán también todos los "
|
||||||
|
"dispositivos recordados."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/_account_2fa_status.html:98
|
||||||
|
msgid "Desactivada"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/_account_2fa_status.html:99
|
||||||
|
msgid "Activar"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/_account_delete_form.html:9
|
#: crochet/templates/crochet/_account_delete_form.html:9
|
||||||
msgid ""
|
msgid ""
|
||||||
"¿Seguro que quieres eliminar tu cuenta? Se borrarán también todos tus "
|
"¿Seguro que quieres eliminar tu cuenta? Se borrarán también todos tus "
|
||||||
@@ -32,7 +142,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/_account_delete_form.html:19
|
#: crochet/templates/crochet/_account_delete_form.html:19
|
||||||
#: crochet/templates/crochet/account_settings.html:68
|
#: crochet/templates/crochet/account_settings.html:78
|
||||||
msgid "Eliminar cuenta"
|
msgid "Eliminar cuenta"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -105,12 +215,12 @@ msgid "Actualizado el %(date)s"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/account_home.html:44
|
#: crochet/templates/crochet/account_home.html:44
|
||||||
#: crochet/templates/crochet/pattern.html:182
|
#: crochet/templates/crochet/pattern.html:156
|
||||||
msgid "Vista previa"
|
msgid "Vista previa"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/account_home.html:48
|
#: crochet/templates/crochet/account_home.html:48
|
||||||
#: crochet/templates/crochet/pattern.html:55
|
#: crochet/templates/crochet/pattern.html:46
|
||||||
msgid "Eliminar"
|
msgid "Eliminar"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -120,7 +230,7 @@ msgstr ""
|
|||||||
|
|
||||||
#: crochet/templates/crochet/account_settings.html:4
|
#: crochet/templates/crochet/account_settings.html:4
|
||||||
#: crochet/templates/crochet/account_settings.html:18
|
#: crochet/templates/crochet/account_settings.html:18
|
||||||
#: crochet/templates/crochet/base.html:52
|
#: crochet/templates/crochet/base.html:62
|
||||||
msgid "Ajustes de la cuenta"
|
msgid "Ajustes de la cuenta"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -136,19 +246,35 @@ msgstr ""
|
|||||||
msgid "Zona de peligro"
|
msgid "Zona de peligro"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/account_settings.html:70
|
#: crochet/templates/crochet/account_settings.html:63
|
||||||
|
#: crochet/templates/registration/two_factor_verify.html:4
|
||||||
|
#: crochet/templates/registration/two_factor_verify.html:14
|
||||||
|
msgid "Verificación en dos pasos"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/account_settings.html:65
|
||||||
|
msgid ""
|
||||||
|
"Pide un código de tu app de autenticación (Google Authenticator, Aegis...) "
|
||||||
|
"además de la contraseña al iniciar sesión."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/account_settings.html:80
|
||||||
msgid "Esto borrará tu cuenta y todos tus patrones de forma permanente."
|
msgid "Esto borrará tu cuenta y todos tus patrones de forma permanente."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/base.html:39
|
#: crochet/templates/crochet/base.html:37
|
||||||
|
msgid "Cambiar tema"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/base.html:49
|
||||||
msgid "Cambiar idioma"
|
msgid "Cambiar idioma"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/base.html:56
|
#: crochet/templates/crochet/base.html:66
|
||||||
msgid "Cerrar sesión"
|
msgid "Cerrar sesión"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/base.html:62
|
#: crochet/templates/crochet/base.html:72
|
||||||
#: crochet/templates/crochet/home.html:25
|
#: crochet/templates/crochet/home.html:25
|
||||||
#: crochet/templates/crochet/password_reset_complete.html:18
|
#: crochet/templates/crochet/password_reset_complete.html:18
|
||||||
#: crochet/templates/registration/login.html:4
|
#: crochet/templates/registration/login.html:4
|
||||||
@@ -214,10 +340,6 @@ msgid ""
|
|||||||
"consentimiento previo."
|
"consentimiento previo."
|
||||||
msgstr ""
|
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_notice.html:4
|
||||||
#: crochet/templates/crochet/email/account_deleted_subject.txt:1
|
#: crochet/templates/crochet/email/account_deleted_subject.txt:1
|
||||||
#, python-format
|
#, python-format
|
||||||
@@ -238,6 +360,8 @@ msgstr ""
|
|||||||
#: crochet/templates/crochet/email/email_changed_notice.txt:4
|
#: 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.html:12
|
||||||
#: crochet/templates/crochet/email/password_changed_notice.txt:4
|
#: crochet/templates/crochet/email/password_changed_notice.txt:4
|
||||||
|
#: crochet/templates/crochet/email/verify_email.html:19
|
||||||
|
#: crochet/templates/crochet/email/verify_email.txt:7
|
||||||
msgid "Tu usuario:"
|
msgid "Tu usuario:"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -314,6 +438,34 @@ msgstr ""
|
|||||||
msgid "Si no has solicitado este cambio, puedes ignorar este email."
|
msgid "Si no has solicitado este cambio, puedes ignorar este email."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/email/verify_email.html:4
|
||||||
|
msgid "Confirma tu cuenta"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/email/verify_email.html:8
|
||||||
|
#: crochet/templates/crochet/email/verify_email.txt:2
|
||||||
|
#, python-format
|
||||||
|
msgid "Has recibido este email porque te has registrado en %(site_name)s."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/email/verify_email.html:14
|
||||||
|
msgid "Confirmar cuenta"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/email/verify_email.html:23
|
||||||
|
#: crochet/templates/crochet/email/verify_email.txt:9
|
||||||
|
msgid "Si no has sido tú, puedes ignorar este email."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/email/verify_email.txt:4
|
||||||
|
msgid "Sigue este enlace para confirmar tu cuenta:"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/email/verify_email_subject.txt:1
|
||||||
|
#, python-format
|
||||||
|
msgid "Confirma tu cuenta en %(site_name)s"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/home.html:4
|
#: crochet/templates/crochet/home.html:4
|
||||||
msgid "Crochet — Crea y comparte tus patrones"
|
msgid "Crochet — Crea y comparte tus patrones"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -376,6 +528,8 @@ msgid "Elige una contraseña nueva"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/password_reset_confirm.html:33
|
#: crochet/templates/crochet/password_reset_confirm.html:33
|
||||||
|
#: crochet/templates/registration/verify_email_invalid.html:4
|
||||||
|
#: crochet/templates/registration/verify_email_invalid.html:14
|
||||||
msgid "Enlace no válido"
|
msgid "Enlace no válido"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -390,6 +544,7 @@ msgid "Solicitar un enlace nuevo"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/password_reset_done.html:14
|
#: crochet/templates/crochet/password_reset_done.html:14
|
||||||
|
#: crochet/templates/registration/register_done.html:14
|
||||||
msgid "Revisa tu email"
|
msgid "Revisa tu email"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -401,6 +556,8 @@ msgstr ""
|
|||||||
|
|
||||||
#: crochet/templates/crochet/password_reset_done.html:19
|
#: crochet/templates/crochet/password_reset_done.html:19
|
||||||
#: crochet/templates/crochet/password_reset_form.html:32
|
#: crochet/templates/crochet/password_reset_form.html:32
|
||||||
|
#: crochet/templates/registration/register_done.html:19
|
||||||
|
#: crochet/templates/registration/two_factor_verify.html:33
|
||||||
msgid "Volver a iniciar sesión"
|
msgid "Volver a iniciar sesión"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -422,250 +579,250 @@ msgstr ""
|
|||||||
msgid "Recupera tu contraseña en %(site_name)s"
|
msgid "Recupera tu contraseña en %(site_name)s"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:26
|
#: crochet/templates/crochet/pattern.html:23
|
||||||
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:38
|
|
||||||
msgid "Guardar"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: 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:49
|
|
||||||
msgid "Más opciones"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:65
|
|
||||||
msgid "Guardado"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:78
|
|
||||||
msgid "Título del patrón"
|
msgid "Título del patrón"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:79
|
#: crochet/templates/crochet/pattern.html:24
|
||||||
msgid "Título del patrón..."
|
msgid "Título del patrón..."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:82
|
#: crochet/templates/crochet/pattern.html:29
|
||||||
|
msgid "No se ha podido guardar. Inténtalo de nuevo."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/pattern.html:29
|
||||||
|
msgid "Guardar"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/pattern.html:32
|
||||||
|
msgid "Ver patrón"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/pattern.html:33
|
||||||
|
msgid "Exportar a PDF"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/pattern.html:40
|
||||||
|
msgid "Más opciones"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/pattern.html:56
|
||||||
|
msgid "Guardado"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/pattern.html:64
|
||||||
msgid "Imagen de portada"
|
msgid "Imagen de portada"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:95
|
#: crochet/templates/crochet/pattern.html:73
|
||||||
msgid "Añadir imagen"
|
msgid "Añadir imagen"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:98
|
#: crochet/templates/crochet/pattern.html:76
|
||||||
msgid "Cambiar"
|
msgid "Cambiar"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:103
|
#: crochet/templates/crochet/pattern.html:81
|
||||||
msgid "No se ha podido subir la imagen de portada. Inténtalo de nuevo."
|
msgid "No se ha podido subir la imagen de portada. Inténtalo de nuevo."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:114
|
#: crochet/templates/crochet/pattern.html:88
|
||||||
msgid "Personalización de página"
|
msgid "Personalización de página"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:117
|
#: crochet/templates/crochet/pattern.html:91
|
||||||
msgid "Autor"
|
msgid "Autor"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:118
|
#: crochet/templates/crochet/pattern.html:92
|
||||||
msgid "Autor..."
|
msgid "Autor..."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:122
|
#: crochet/templates/crochet/pattern.html:96
|
||||||
#: crochet/templates/crochet/pattern.html:206
|
#: crochet/templates/crochet/pattern.html:191
|
||||||
msgid "Color del texto"
|
msgid "Color del texto"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:126
|
#: crochet/templates/crochet/pattern.html:100
|
||||||
msgid "Color de acento (títulos)"
|
msgid "Color de acento (títulos)"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:130
|
#: crochet/templates/crochet/pattern.html:104
|
||||||
#: crochet/templates/crochet/pattern.html:206
|
#: crochet/templates/crochet/pattern.html:191
|
||||||
msgid "Color de fondo"
|
msgid "Color de fondo"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:134
|
#: crochet/templates/crochet/pattern.html:108
|
||||||
msgid "Tipografía"
|
msgid "Tipografía"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:140
|
#: crochet/templates/crochet/pattern.html:114
|
||||||
msgid "Monoespaciada"
|
msgid "Monoespaciada"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:144
|
#: crochet/templates/crochet/pattern.html:118
|
||||||
msgid "Tamaño de letra"
|
msgid "Tamaño de letra"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:146
|
#: crochet/templates/crochet/pattern.html:120
|
||||||
msgid "Pequeño"
|
msgid "Pequeño"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:147
|
#: crochet/templates/crochet/pattern.html:121
|
||||||
msgid "Normal"
|
msgid "Normal"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:148
|
#: crochet/templates/crochet/pattern.html:122
|
||||||
msgid "Grande"
|
msgid "Grande"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:152
|
#: crochet/templates/crochet/pattern.html:126
|
||||||
msgid "Alineación del texto"
|
msgid "Alineación del texto"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:154
|
#: crochet/templates/crochet/pattern.html:128
|
||||||
msgid "Izquierda"
|
msgid "Izquierda"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:155
|
#: crochet/templates/crochet/pattern.html:129
|
||||||
msgid "Centrado"
|
msgid "Centrado"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:156
|
#: crochet/templates/crochet/pattern.html:130
|
||||||
msgid "Justificado"
|
msgid "Justificado"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:160
|
#: crochet/templates/crochet/pattern.html:134
|
||||||
msgid "Tamaño de página"
|
msgid "Tamaño de página"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:165
|
#: crochet/templates/crochet/pattern.html:139
|
||||||
msgid "Carta (Letter)"
|
msgid "Carta (Letter)"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:170
|
#: crochet/templates/crochet/pattern.html:144
|
||||||
msgid "Orientación"
|
msgid "Orientación"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:172
|
#: crochet/templates/crochet/pattern.html:146
|
||||||
msgid "Vertical"
|
msgid "Vertical"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:173
|
#: crochet/templates/crochet/pattern.html:147
|
||||||
msgid "Horizontal"
|
msgid "Horizontal"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:181
|
#: crochet/templates/crochet/pattern.html:155
|
||||||
#: crochet/templates/crochet/pattern.html:189
|
#: crochet/templates/crochet/pattern.html:174
|
||||||
msgid "Secciones"
|
msgid "Secciones"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:191
|
#: crochet/templates/crochet/pattern.html:166
|
||||||
|
msgid "Idioma del patrón"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/pattern.html:176
|
||||||
msgid "Colapsar todo"
|
msgid "Colapsar todo"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:191
|
#: crochet/templates/crochet/pattern.html:176
|
||||||
msgid "Expandir todo"
|
msgid "Expandir todo"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:196
|
#: crochet/templates/crochet/pattern.html:181
|
||||||
msgid "Añadir sección"
|
msgid "Añadir sección"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:199
|
#: crochet/templates/crochet/pattern.html:184
|
||||||
msgid "Título"
|
msgid "Título"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:199
|
#: crochet/templates/crochet/pattern.html:184
|
||||||
msgid "Título..."
|
msgid "Título..."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:201
|
#: crochet/templates/crochet/pattern.html:186
|
||||||
msgid "Subtítulo"
|
msgid "Subtítulo"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:201
|
#: crochet/templates/crochet/pattern.html:186
|
||||||
msgid "Subtítulo..."
|
msgid "Subtítulo..."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:203
|
#: crochet/templates/crochet/pattern.html:188
|
||||||
msgid "Texto"
|
msgid "Texto"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:203
|
#: crochet/templates/crochet/pattern.html:188
|
||||||
msgid "Escribe aquí..."
|
msgid "Escribe aquí..."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:205
|
#: crochet/templates/crochet/pattern.html:190
|
||||||
#: crochet/templates/crochet/pattern.html:206
|
#: crochet/templates/crochet/pattern.html:191
|
||||||
msgid "Nota"
|
msgid "Nota"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:205
|
#: crochet/templates/crochet/pattern.html:190
|
||||||
msgid "Escribe una nota o consejo..."
|
msgid "Escribe una nota o consejo..."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:208
|
#: crochet/templates/crochet/pattern.html:193
|
||||||
msgid "Materiales"
|
msgid "Materiales"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:208
|
#: crochet/templates/crochet/pattern.html:193
|
||||||
msgid "Añadir material..."
|
msgid "Añadir material..."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:208
|
#: crochet/templates/crochet/pattern.html:193
|
||||||
msgid "Añadir línea"
|
msgid "Añadir línea"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:210
|
#: crochet/templates/crochet/pattern.html:195
|
||||||
#: crochet/templates/crochet/pattern.html:212
|
#: crochet/templates/crochet/pattern.html:197
|
||||||
msgid "Imagen"
|
msgid "Imagen"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:212
|
#: crochet/templates/crochet/pattern.html:197
|
||||||
msgid "No se ha podido subir la imagen. Inténtalo de nuevo."
|
msgid "No se ha podido subir la imagen. Inténtalo de nuevo."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:214
|
#: crochet/templates/crochet/pattern.html:199
|
||||||
msgid "Patrón"
|
msgid "Patrón"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:214
|
#: crochet/templates/crochet/pattern.html:199
|
||||||
msgid "Elementos"
|
msgid "Elementos"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:216
|
#: crochet/templates/crochet/pattern.html:201
|
||||||
msgid "Grupo"
|
msgid "Grupo"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:220
|
#: crochet/templates/crochet/pattern.html:205
|
||||||
msgid "Arrastrar para reordenar"
|
msgid "Arrastrar para reordenar"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:221
|
#: crochet/templates/crochet/pattern.html:206
|
||||||
msgid "Colapsar / expandir"
|
msgid "Colapsar / expandir"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:222
|
#: crochet/templates/crochet/pattern.html:207
|
||||||
msgid "Duplicar sección"
|
msgid "Duplicar sección"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:223
|
#: crochet/templates/crochet/pattern.html:208
|
||||||
msgid "Eliminar sección"
|
msgid "Eliminar sección"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:224
|
#: crochet/templates/crochet/pattern.html:209
|
||||||
msgid ""
|
msgid ""
|
||||||
"¿Seguro que quieres eliminar este grupo? Se eliminarán también todas las "
|
"¿Seguro que quieres eliminar este grupo? Se eliminarán también todas las "
|
||||||
"secciones que contiene."
|
"secciones que contiene."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:229
|
#: crochet/templates/crochet/pattern.html:214
|
||||||
msgid "Añade una sección para ver aquí el resultado."
|
msgid "Añade una sección para ver aquí el resultado."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -673,6 +830,36 @@ msgstr ""
|
|||||||
msgid "Descargar PDF"
|
msgid "Descargar PDF"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/two_factor_setup.html:4
|
||||||
|
#: crochet/templates/crochet/two_factor_setup.html:26
|
||||||
|
msgid "Activar verificación en dos pasos"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/two_factor_setup.html:15
|
||||||
|
msgid "Guarda tus códigos de respaldo"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/two_factor_setup.html:17
|
||||||
|
msgid ""
|
||||||
|
"Cada uno sirve para iniciar sesión una vez si pierdes acceso a tu app de "
|
||||||
|
"autenticación. Guárdalos en un sitio seguro: no se van a volver a mostrar."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/two_factor_setup.html:24
|
||||||
|
msgid "Ya los he guardado"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/two_factor_setup.html:28
|
||||||
|
msgid ""
|
||||||
|
"Escanea este código con tu app de autenticación (Google Authenticator, "
|
||||||
|
"Aegis, 1Password...) e introduce el código de 6 dígitos que te muestre para "
|
||||||
|
"confirmar."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/two_factor_setup.html:42
|
||||||
|
msgid "Confirmar"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/registration/login.html:17
|
#: crochet/templates/registration/login.html:17
|
||||||
msgid "Usuario o contraseña incorrectos."
|
msgid "Usuario o contraseña incorrectos."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -702,97 +889,163 @@ msgstr ""
|
|||||||
msgid "¿Ya tienes cuenta? Inicia sesión"
|
msgid "¿Ya tienes cuenta? Inicia sesión"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/urls.py:38
|
#: crochet/templates/registration/register_done.html:4
|
||||||
|
msgid "Confirma tu email"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/templates/registration/register_done.html:16
|
||||||
|
msgid ""
|
||||||
|
"Te hemos enviado un enlace para confirmar tu cuenta. Ábrelo desde tu bandeja "
|
||||||
|
"de entrada para poder iniciar sesión."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/templates/registration/two_factor_verify.html:16
|
||||||
|
msgid ""
|
||||||
|
"Introduce el código de tu app de autenticación, o uno de tus códigos de "
|
||||||
|
"respaldo si no tienes acceso a ella."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/templates/registration/two_factor_verify.html:30
|
||||||
|
msgid "Verificar"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/templates/registration/verify_email_invalid.html:16
|
||||||
|
msgid ""
|
||||||
|
"El enlace de confirmación no es válido, puede que ya se haya usado o que la "
|
||||||
|
"cuenta ya esté verificada."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/templates/registration/verify_email_invalid.html:19
|
||||||
|
msgid "Ir a iniciar sesión"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/urls.py:46
|
||||||
msgid "cookies/"
|
msgid "cookies/"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
# Rutas traducibles de crochet/urls.py: la base "pattern/" se traduce como
|
# Rutas traducibles de crochet/urls.py: la base "pattern/" se traduce como
|
||||||
# "patron/" (sin tilde, para no meter caracteres acentuados en la URL).
|
# "patron/" (sin tilde, para no meter caracteres acentuados en la URL).
|
||||||
#: crochet/urls.py:39
|
#: crochet/urls.py:47
|
||||||
msgid "pattern/<uuid:uuid>/"
|
msgid "pattern/<uuid:uuid>/"
|
||||||
msgstr "patron/<uuid:uuid>/"
|
msgstr "patron/<uuid:uuid>/"
|
||||||
|
|
||||||
#: crochet/urls.py:40
|
#: crochet/urls.py:48
|
||||||
msgid "pattern/<uuid:uuid>/edit/"
|
msgid "pattern/<uuid:uuid>/edit/"
|
||||||
msgstr "patron/<uuid:uuid>/editar/"
|
msgstr "patron/<uuid:uuid>/editar/"
|
||||||
|
|
||||||
#: crochet/urls.py:41
|
#: crochet/urls.py:49
|
||||||
msgid "pattern/<uuid:uuid>/save/"
|
msgid "pattern/<uuid:uuid>/save/"
|
||||||
msgstr "patron/<uuid:uuid>/guardar/"
|
msgstr "patron/<uuid:uuid>/guardar/"
|
||||||
|
|
||||||
#: crochet/urls.py:42
|
#: crochet/urls.py:50
|
||||||
msgid "pattern/<uuid:uuid>/images/"
|
msgid "pattern/<uuid:uuid>/images/"
|
||||||
msgstr "patron/<uuid:uuid>/imagenes/"
|
msgstr "patron/<uuid:uuid>/imagenes/"
|
||||||
|
|
||||||
#: crochet/urls.py:43
|
#: crochet/urls.py:51
|
||||||
msgid "pattern/<uuid:uuid>/cover/"
|
msgid "pattern/<uuid:uuid>/cover/"
|
||||||
msgstr "patron/<uuid:uuid>/portada/"
|
msgstr "patron/<uuid:uuid>/portada/"
|
||||||
|
|
||||||
#: crochet/urls.py:44
|
#: crochet/urls.py:52
|
||||||
msgid "pattern/<uuid:uuid>/pdf/"
|
msgid "pattern/<uuid:uuid>/pdf/"
|
||||||
msgstr "patron/<uuid:uuid>/pdf/"
|
msgstr "patron/<uuid:uuid>/pdf/"
|
||||||
|
|
||||||
#: crochet/urls.py:45
|
#: crochet/urls.py:53
|
||||||
msgid "pattern/<uuid:uuid>/delete/"
|
msgid "pattern/<uuid:uuid>/delete/"
|
||||||
msgstr "patron/<uuid:uuid>/eliminar/"
|
msgstr "patron/<uuid:uuid>/eliminar/"
|
||||||
|
|
||||||
#: crochet/urls.py:46
|
#: crochet/urls.py:54
|
||||||
msgid "pattern/new/"
|
msgid "pattern/new/"
|
||||||
msgstr "patron/nuevo/"
|
msgstr "patron/nuevo/"
|
||||||
|
|
||||||
#: crochet/urls.py:47
|
#: crochet/urls.py:55
|
||||||
msgid "account/settings/"
|
msgid "account/settings/"
|
||||||
msgstr "cuenta/ajustes/"
|
msgstr "cuenta/ajustes/"
|
||||||
|
|
||||||
#: crochet/urls.py:48
|
#: crochet/urls.py:56
|
||||||
msgid "account/settings/email/"
|
msgid "account/settings/email/"
|
||||||
msgstr "cuenta/ajustes/email/"
|
msgstr "cuenta/ajustes/email/"
|
||||||
|
|
||||||
#: crochet/urls.py:50
|
#: crochet/urls.py:58
|
||||||
msgid "account/settings/password/"
|
msgid "account/settings/password/"
|
||||||
msgstr "cuenta/ajustes/contrasena/"
|
msgstr "cuenta/ajustes/contrasena/"
|
||||||
|
|
||||||
#: crochet/urls.py:54
|
#: crochet/urls.py:62
|
||||||
msgid "account/settings/display-name/"
|
msgid "account/settings/display-name/"
|
||||||
msgstr "cuenta/ajustes/nombre/"
|
msgstr "cuenta/ajustes/nombre/"
|
||||||
|
|
||||||
#: crochet/urls.py:57
|
#: crochet/urls.py:65
|
||||||
msgid "account/settings/delete/"
|
msgid "account/settings/delete/"
|
||||||
msgstr "cuenta/ajustes/eliminar/"
|
msgstr "cuenta/ajustes/eliminar/"
|
||||||
|
|
||||||
#: crochet/urls.py:58
|
#: crochet/urls.py:66
|
||||||
|
msgid "account/settings/2fa/setup/"
|
||||||
|
msgstr "cuenta/ajustes/2fa/activar/"
|
||||||
|
|
||||||
|
#: crochet/urls.py:67
|
||||||
|
msgid "account/settings/2fa/disable/"
|
||||||
|
msgstr "cuenta/ajustes/2fa/desactivar/"
|
||||||
|
|
||||||
|
#: crochet/urls.py:69
|
||||||
|
msgid "account/settings/2fa/devices/<int:device_id>/forget/"
|
||||||
|
msgstr "cuenta/ajustes/2fa/dispositivos/<int:device_id>/olvidar/"
|
||||||
|
|
||||||
|
#: crochet/urls.py:72
|
||||||
msgid "account/register/"
|
msgid "account/register/"
|
||||||
msgstr "cuenta/registro/"
|
msgstr "cuenta/registro/"
|
||||||
|
|
||||||
#: crochet/urls.py:60
|
#: crochet/urls.py:73
|
||||||
|
msgid "account/register/done/"
|
||||||
|
msgstr "cuenta/registro/hecho/"
|
||||||
|
|
||||||
|
#: crochet/urls.py:74
|
||||||
|
msgid "account/verify/<uidb64>/<token>/"
|
||||||
|
msgstr "cuenta/verificar/<uidb64>/<token>/"
|
||||||
|
|
||||||
|
#: crochet/urls.py:75
|
||||||
|
msgid "account/2fa/verify/"
|
||||||
|
msgstr "cuenta/2fa/verificar/"
|
||||||
|
|
||||||
|
#: crochet/urls.py:77
|
||||||
msgid "account/login/"
|
msgid "account/login/"
|
||||||
msgstr "cuenta/entrar/"
|
msgstr "cuenta/entrar/"
|
||||||
|
|
||||||
#: crochet/urls.py:65
|
#: crochet/urls.py:82
|
||||||
msgid "account/logout/"
|
msgid "account/logout/"
|
||||||
msgstr "cuenta/salir/"
|
msgstr "cuenta/salir/"
|
||||||
|
|
||||||
#: crochet/urls.py:67
|
#: crochet/urls.py:84
|
||||||
msgid "account/password-reset/"
|
msgid "account/password-reset/"
|
||||||
msgstr "cuenta/recuperar-contrasena/"
|
msgstr "cuenta/recuperar-contrasena/"
|
||||||
|
|
||||||
#: crochet/urls.py:86
|
#: crochet/urls.py:103
|
||||||
msgid "account/password-reset/done/"
|
msgid "account/password-reset/done/"
|
||||||
msgstr "cuenta/recuperar-contrasena/enviado/"
|
msgstr "cuenta/recuperar-contrasena/enviado/"
|
||||||
|
|
||||||
#: crochet/urls.py:90
|
#: crochet/urls.py:107
|
||||||
msgid "account/reset/<uidb64>/<token>/"
|
msgid "account/reset/<uidb64>/<token>/"
|
||||||
msgstr "cuenta/restablecer/<uidb64>/<token>/"
|
msgstr "cuenta/restablecer/<uidb64>/<token>/"
|
||||||
|
|
||||||
#: crochet/urls.py:98
|
#: crochet/urls.py:115
|
||||||
msgid "account/reset/done/"
|
msgid "account/reset/done/"
|
||||||
msgstr "cuenta/restablecer/hecho/"
|
msgstr "cuenta/restablecer/hecho/"
|
||||||
|
|
||||||
#: crochet/views.py:29
|
#: crochet/user_agent.py:33 crochet/user_agent.py:40
|
||||||
|
msgid "Dispositivo desconocido"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/views.py:51
|
||||||
msgid "Este patrón todavía no tiene contenido."
|
msgid "Este patrón todavía no tiene contenido."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/views.py:350
|
#: crochet/views.py:407
|
||||||
|
msgid "Tu cuenta ha sido verificada. ¡Bienvenido/a!"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/views.py:473 crochet/views.py:541
|
||||||
|
msgid "Código incorrecto."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/views.py:708
|
||||||
msgid "Tu cuenta se ha eliminado correctamente."
|
msgid "Tu cuenta se ha eliminado correctamente."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,10 @@ SOURCES = [
|
|||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = 'Recompila crochet/static/css/pattern-detail.css (Tailwind precompilado para la vista de solo lectura / PDF).'
|
help = (
|
||||||
|
'Recompila crochet/static/css/pattern-detail.css '
|
||||||
|
'(Tailwind precompilado para la vista de solo lectura / PDF).'
|
||||||
|
)
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
if not TAILWIND_BIN.exists():
|
if not TAILWIND_BIN.exists():
|
||||||
@@ -94,6 +97,6 @@ class Command(BaseCommand):
|
|||||||
try:
|
try:
|
||||||
urllib.request.urlretrieve(url, TAILWIND_BIN)
|
urllib.request.urlretrieve(url, TAILWIND_BIN)
|
||||||
except OSError as error:
|
except OSError as error:
|
||||||
raise CommandError(f'No se ha podido descargar {url}: {error}')
|
raise CommandError(f'No se ha podido descargar {url}: {error}') from error
|
||||||
|
|
||||||
TAILWIND_BIN.chmod(TAILWIND_BIN.stat().st_mode | stat.S_IEXEC)
|
TAILWIND_BIN.chmod(TAILWIND_BIN.stat().st_mode | stat.S_IEXEC)
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('crochet', '0007_pattern_cover_image'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='TwoFactorRememberedDevice',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('device_name', models.CharField(blank=True, default='', max_length=255)),
|
||||||
|
('token', models.CharField(max_length=64, unique=True)),
|
||||||
|
('expires_at', models.DateTimeField()),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='remembered_devices', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -51,6 +51,9 @@ class PatternImage(models.Model):
|
|||||||
image = models.ImageField(upload_to='patterns/%Y/%m/')
|
image = models.ImageField(upload_to='patterns/%Y/%m/')
|
||||||
uploaded_at = models.DateTimeField(auto_now_add=True)
|
uploaded_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.pattern} - {self.image.name}'
|
||||||
|
|
||||||
|
|
||||||
class StitchType(models.Model):
|
class StitchType(models.Model):
|
||||||
# Catálogo de tipos de punto arrastrables en una sección de "Patrón"
|
# Catálogo de tipos de punto arrastrables en una sección de "Patrón"
|
||||||
@@ -67,3 +70,24 @@ class StitchType(models.Model):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.translations.get('es') or f'StitchType #{self.pk}'
|
return self.translations.get('es') or f'StitchType #{self.pk}'
|
||||||
|
|
||||||
|
|
||||||
|
class TwoFactorRememberedDevice(models.Model):
|
||||||
|
# "Recordar este dispositivo" (ver TwoFactorVerifyView/
|
||||||
|
# RateLimitedLoginView en crochet/views.py): el token vive en una cookie
|
||||||
|
# del navegador, no aquí -esta fila solo sirve para poder comprobarlo
|
||||||
|
# server-side y para poder revocarlo (se borra entera al desactivar la
|
||||||
|
# verificación en dos pasos, ver TwoFactorDisableView)-.
|
||||||
|
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='remembered_devices')
|
||||||
|
# Descripción legible sacada del User-Agent al marcar "recordar este
|
||||||
|
# dispositivo" (ver crochet/user_agent.py), para que se reconozca en la
|
||||||
|
# lista de ajustes de cuenta ("Chrome (Windows)"...); no es un dato de
|
||||||
|
# seguridad -un User-Agent se falsifica sin esfuerzo-, solo una ayuda
|
||||||
|
# visual.
|
||||||
|
device_name = models.CharField(max_length=255, blank=True, default='')
|
||||||
|
token = models.CharField(max_length=64, unique=True)
|
||||||
|
expires_at = models.DateTimeField()
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.user} - {self.device_name} ({self.expires_at:%Y-%m-%d})'
|
||||||
|
|||||||
@@ -68,7 +68,9 @@ def _line(tag, text, css_class):
|
|||||||
|
|
||||||
|
|
||||||
def _render_group(section, lang, stitch_types_by_id):
|
def _render_group(section, lang, stitch_types_by_id):
|
||||||
inner = mark_safe(''.join(_render_section(child, lang, stitch_types_by_id) for child in section.get('children', [])))
|
inner = mark_safe(
|
||||||
|
''.join(_render_section(child, lang, stitch_types_by_id) for child in section.get('children', [])),
|
||||||
|
)
|
||||||
if not inner:
|
if not inner:
|
||||||
return ''
|
return ''
|
||||||
return format_html('<div class="my-2 flex flex-col gap-1">{0}</div>', inner)
|
return format_html('<div class="my-2 flex flex-col gap-1">{0}</div>', inner)
|
||||||
|
|||||||
@@ -17,6 +17,19 @@
|
|||||||
font-family: 'Poppins', system-ui, sans-serif;
|
font-family: 'Poppins', system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Contrapartida oscura de la paleta de arriba, activada por el mismo
|
||||||
|
data-theme="crochet-dark" que usa theme.css para el tema daisyUI a juego
|
||||||
|
(ver theme-toggle.js y el script del <head> en base.html). Primary/accent
|
||||||
|
se quedan igual (ya funcionan sobre fondo oscuro); solo cambian
|
||||||
|
fondo/texto y se aclara un poco el secondary para mantener contraste. */
|
||||||
|
[data-theme="crochet-dark"] {
|
||||||
|
--brand-bg: #21170f;
|
||||||
|
--brand-bg-soft: #2b2016;
|
||||||
|
--brand-border: #3c2e1f;
|
||||||
|
--brand-text: #f5ecdd;
|
||||||
|
--brand-secondary: #4da695;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: var(--brand-bg);
|
background-color: var(--brand-bg);
|
||||||
color: var(--brand-text);
|
color: var(--brand-text);
|
||||||
|
|||||||
@@ -44,3 +44,45 @@
|
|||||||
--depth: 1;
|
--depth: 1;
|
||||||
--noise: 0;
|
--noise: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Contrapartida oscura de "crochet", activada con el botón del navbar (ver
|
||||||
|
theme-toggle.js). Misma estructura que arriba, reusando las variables
|
||||||
|
--brand-* que main.css redefine para [data-theme="crochet-dark"]. */
|
||||||
|
[data-theme="crochet-dark"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
|
||||||
|
--color-base-100: var(--brand-bg);
|
||||||
|
--color-base-200: var(--brand-bg-soft);
|
||||||
|
--color-base-300: var(--brand-border);
|
||||||
|
--color-base-content: var(--brand-text);
|
||||||
|
|
||||||
|
--color-primary: var(--brand-primary);
|
||||||
|
--color-primary-content: var(--brand-primary-content);
|
||||||
|
|
||||||
|
--color-secondary: var(--brand-secondary);
|
||||||
|
--color-secondary-content: #ffffff;
|
||||||
|
|
||||||
|
--color-accent: var(--brand-accent);
|
||||||
|
--color-accent-content: #2e2118;
|
||||||
|
|
||||||
|
--color-neutral: var(--brand-text);
|
||||||
|
--color-neutral-content: #21170f;
|
||||||
|
|
||||||
|
--color-info: oklch(74% 0.16 232.661);
|
||||||
|
--color-info-content: oklch(29% 0.066 243.157);
|
||||||
|
--color-success: oklch(76% 0.177 163.223);
|
||||||
|
--color-success-content: oklch(37% 0.077 168.94);
|
||||||
|
--color-warning: oklch(82% 0.189 84.429);
|
||||||
|
--color-warning-content: oklch(41% 0.112 45.904);
|
||||||
|
--color-error: oklch(71% 0.194 13.428);
|
||||||
|
--color-error-content: oklch(27% 0.105 12.094);
|
||||||
|
|
||||||
|
--radius-selector: 0.5rem;
|
||||||
|
--radius-field: 0.75rem;
|
||||||
|
--radius-box: 1rem;
|
||||||
|
--size-selector: 0.25rem;
|
||||||
|
--size-field: 0.25rem;
|
||||||
|
--border: 1px;
|
||||||
|
--depth: 1;
|
||||||
|
--noise: 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// Reabre cualquier <dialog data-reopen> que llegue en un swap de HTMX.
|
||||||
|
// Hace falta porque el patrón habitual del proyecto (ver
|
||||||
|
// _account_2fa_status.html) intercambia el fragmento entero -incluido el
|
||||||
|
// propio <dialog>- al enviar un formulario dentro de un modal: el
|
||||||
|
// navegador crea un <dialog> nuevo, y uno nuevo no hereda el estado
|
||||||
|
// abierto del que reemplaza (showModal() no se "recuerda" entre
|
||||||
|
// elementos). El servidor añade data-reopen al renderizar el modal con
|
||||||
|
// errores de validación (el caso en el que realmente hace falta que siga
|
||||||
|
// abierto tras el swap).
|
||||||
|
//
|
||||||
|
// htmx:afterSettle, NO htmx:afterSwap. Cuando el elemento que target/swap
|
||||||
|
// reemplaza es un ANCESTRO del <form> que hizo la petición -este caso:
|
||||||
|
// hx-target="#account-2fa-status" en un <form> anidado dentro de ese mismo
|
||||||
|
// div-, "afterSwap" se dispara sobre el elemento que originó la petición,
|
||||||
|
// que en ese momento YA se ha quedado fuera del árbol del documento (el
|
||||||
|
// outerHTML swap lo reemplazó); un evento despachado sobre un nodo
|
||||||
|
// desconectado no burbujea a ningún sitio, así que un listener en
|
||||||
|
// document.body nunca lo ve. "afterSettle" en cambio se dispara sobre el
|
||||||
|
// nuevo contenido ya insertado en el árbol, así que sí burbujea con
|
||||||
|
// normalidad.
|
||||||
|
document.body.addEventListener('htmx:afterSettle', () => {
|
||||||
|
document.querySelectorAll('dialog[data-reopen]').forEach((dialog) => {
|
||||||
|
dialog.showModal();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// Alterna entre el tema claro y oscuro (ver theme.css: [data-theme="crochet"]
|
||||||
|
// vs [data-theme="crochet-dark"]) y recuerda la preferencia en localStorage.
|
||||||
|
// El tema inicial ya lo aplica el script del <head> en base.html (antes de
|
||||||
|
// pintar la página, para evitar el parpadeo del tema por defecto); este
|
||||||
|
// script solo atiende el clic del botón y mantiene su icono al día.
|
||||||
|
const THEME_STORAGE_KEY = 'theme';
|
||||||
|
const DARK_THEME = 'crochet-dark';
|
||||||
|
const LIGHT_THEME = 'crochet';
|
||||||
|
|
||||||
|
const toggleButton = document.getElementById('theme-toggle');
|
||||||
|
const icon = document.getElementById('theme-toggle-icon');
|
||||||
|
|
||||||
|
function currentTheme() {
|
||||||
|
return document.documentElement.dataset.theme === DARK_THEME ? DARK_THEME : LIGHT_THEME;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateIcon() {
|
||||||
|
// Se muestra el icono del tema al que se pasaría al pulsar, no el actual.
|
||||||
|
icon.textContent = currentTheme() === DARK_THEME ? '☀️' : '🌙';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toggleButton && icon) {
|
||||||
|
updateIcon();
|
||||||
|
|
||||||
|
toggleButton.addEventListener('click', () => {
|
||||||
|
const next = currentTheme() === DARK_THEME ? LIGHT_THEME : DARK_THEME;
|
||||||
|
document.documentElement.dataset.theme = next;
|
||||||
|
updateIcon();
|
||||||
|
try {
|
||||||
|
localStorage.setItem(THEME_STORAGE_KEY, next);
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{% extends 'crochet/base.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
{# Django la usa como plantilla de error 403 para cualquier PermissionDenied
|
||||||
|
sin capturar, incluida Ratelimited (ver crochet/views.py: LOGIN_RATE/
|
||||||
|
REGISTER_RATE/PASSWORD_RESET_RATE) — de ahí el mensaje genérico en vez de
|
||||||
|
uno específico de "demasiadas peticiones", ya que también cubre otros
|
||||||
|
403 (p. ej. _get_owned_pattern_or_403). #}
|
||||||
|
|
||||||
|
{% block title %}{% trans 'Acceso denegado' %}{% 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 text-center">
|
||||||
|
<h1 class="card-title justify-center">{% trans 'Acceso denegado' %}</h1>
|
||||||
|
<p class="text-sm text-base-content/70">
|
||||||
|
{% trans 'No tienes permiso para ver esta página, o se han hecho demasiadas peticiones seguidas desde aquí.' %}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-center mt-2">
|
||||||
|
<a href="{% url 'crochet:home' %}" class="link link-primary">{% trans 'Volver al inicio' %}</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{% extends 'crochet/base.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block title %}{% trans 'Página no encontrada' %}{% 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 text-center">
|
||||||
|
<h1 class="card-title justify-center">{% trans 'Página no encontrada' %}</h1>
|
||||||
|
<p class="text-sm text-base-content/70">
|
||||||
|
{% trans 'La página que buscas no existe o se ha movido.' %}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-center mt-2">
|
||||||
|
<a href="{% url 'crochet:home' %}" class="link link-primary">{% trans 'Volver al inicio' %}</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
{% load i18n static %}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es" data-theme="crochet">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<script>
|
||||||
|
try {
|
||||||
|
if (localStorage.getItem('theme') === 'crochet-dark') {
|
||||||
|
document.documentElement.dataset.theme = 'crochet-dark';
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
</script>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% trans 'Error del servidor' %}</title>
|
||||||
|
<link rel="stylesheet" href="{% static 'css/main.css' %}">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.box {
|
||||||
|
max-width: 24rem;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
background: var(--brand-bg-soft);
|
||||||
|
border: 1px solid var(--brand-border);
|
||||||
|
border-radius: 1rem;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.box h1 {
|
||||||
|
margin: 0 0 .5rem;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.box p {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
opacity: .7;
|
||||||
|
font-size: .9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.box a {
|
||||||
|
color: var(--brand-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.box a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="box">
|
||||||
|
<h1>{% trans 'Vaya, algo ha ido mal' %}</h1>
|
||||||
|
<p>{% trans 'Ha ocurrido un error inesperado. Prueba a recargar en un momento.' %}</p>
|
||||||
|
<a href="/">{% trans 'Volver al inicio' %}</a>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
{% load i18n %}
|
||||||
|
<!-- id fijo (en vez de hx-target="this" sobre el propio <form>, como en el
|
||||||
|
resto de fragmentos de ajustes de cuenta): aquí hay más de un <form>
|
||||||
|
dentro del mismo fragmento (desactivar 2FA, olvidar cada dispositivo),
|
||||||
|
así que todos apuntan a este mismo id y se intercambia el fragmento
|
||||||
|
entero pase lo que pase, sin duplicar el badge de arriba con solo el
|
||||||
|
<form> que disparó la petición. -->
|
||||||
|
<div id="account-2fa-status">
|
||||||
|
{% if two_factor_enabled %}
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<span class="badge badge-success badge-soft">{% trans 'Activada' %}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-error btn-sm"
|
||||||
|
onclick="document.getElementById('two-factor-disable-modal').showModal()"
|
||||||
|
>
|
||||||
|
{% trans 'Desactivar' %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if remembered_devices %}
|
||||||
|
<div class="mt-3">
|
||||||
|
<p class="text-sm font-medium mb-1">{% trans 'Dispositivos recordados' %}</p>
|
||||||
|
<ul class="flex flex-col gap-1 text-sm text-base-content/70">
|
||||||
|
{% for device in remembered_devices %}
|
||||||
|
<li class="flex items-center justify-between gap-2">
|
||||||
|
<span>{{ device.device_name }}</span>
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<span class="text-xs">{% blocktranslate with date=device.created_at|date:'d/m/Y' %}desde el {{ date }}{% endblocktranslate %}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs"
|
||||||
|
aria-label="{% trans 'Olvidar este dispositivo' %}"
|
||||||
|
onclick="document.getElementById('forget-device-modal-{{ device.pk }}').showModal()"
|
||||||
|
>
|
||||||
|
🗑️
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<dialog id="forget-device-modal-{{ device.pk }}" class="modal">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 class="font-bold text-lg">{% trans '¿Olvidar este dispositivo?' %}</h3>
|
||||||
|
<p class="py-2 text-sm text-base-content/70">
|
||||||
|
{% blocktranslate with name=device.device_name %}La próxima vez que inicies sesión desde "{{ name }}" volverá a pedirse el código de verificación en dos pasos.{% endblocktranslate %}
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
hx-post="{% url 'crochet:two_factor_forget_device' device.pk %}"
|
||||||
|
hx-target="#account-2fa-status"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
class="modal-action"
|
||||||
|
>
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="button" class="btn btn-ghost btn-sm" onclick="this.closest('dialog').close()">
|
||||||
|
{% trans 'Cancelar' %}
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-error btn-sm">{% trans 'Olvidar' %}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop"><button>{% trans 'Cerrar' %}</button></form>
|
||||||
|
</dialog>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<dialog id="two-factor-disable-modal" class="modal"{% if two_factor_disable_form.errors %} data-reopen{% endif %}>
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 class="font-bold text-lg">{% trans 'Desactivar verificación en dos pasos' %}</h3>
|
||||||
|
<p class="py-2 text-sm text-base-content/70">
|
||||||
|
{% trans 'Confirma tu contraseña para desactivarla. Se olvidarán también todos los dispositivos recordados.' %}
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
hx-post="{% url 'crochet:two_factor_disable' %}"
|
||||||
|
hx-target="#account-2fa-status"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
class="flex flex-col gap-3"
|
||||||
|
>
|
||||||
|
{% csrf_token %}
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-sm">{{ two_factor_disable_form.password.label }}</span>
|
||||||
|
{{ two_factor_disable_form.password }}
|
||||||
|
{% for error in two_factor_disable_form.password.errors %}
|
||||||
|
<span class="text-error text-xs">{{ error }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</label>
|
||||||
|
<div class="modal-action">
|
||||||
|
<button type="button" class="btn btn-ghost btn-sm" onclick="this.closest('dialog').close()">
|
||||||
|
{% trans 'Cancelar' %}
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-error btn-sm">{% trans 'Desactivar' %}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop"><button>{% trans 'Cerrar' %}</button></form>
|
||||||
|
</dialog>
|
||||||
|
{% else %}
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<span class="badge badge-ghost">{% trans 'Desactivada' %}</span>
|
||||||
|
<a href="{% url 'crochet:two_factor_setup' %}" class="btn btn-primary btn-sm">{% trans 'Activar' %}</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
@@ -57,6 +57,16 @@
|
|||||||
{% include 'crochet/_account_password_form.html' %}
|
{% include 'crochet/_account_password_form.html' %}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="card bg-base-100 shadow border border-base-300">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-lg">{% trans 'Verificación en dos pasos' %}</h2>
|
||||||
|
<p class="text-sm text-base-content/70">
|
||||||
|
{% trans 'Pide un código de tu app de autenticación (Google Authenticator, Aegis...) además de la contraseña al iniciar sesión.' %}
|
||||||
|
</p>
|
||||||
|
{% include 'crochet/_account_2fa_status.html' %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Zona de peligro en su propia pestaña, no solo con border-error
|
<!-- Zona de peligro en su propia pestaña, no solo con border-error
|
||||||
@@ -80,4 +90,5 @@
|
|||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
<script defer src="{% static 'js/account-settings-tabs.js' %}"></script>
|
<script defer src="{% static 'js/account-settings-tabs.js' %}"></script>
|
||||||
|
<script defer src="{% static 'js/modal-reopen.js' %}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -7,6 +7,13 @@
|
|||||||
<html lang="{% get_current_language as html_lang %}{{ html_lang }}" data-theme="crochet">
|
<html lang="{% get_current_language as html_lang %}{{ html_lang }}" data-theme="crochet">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
|
<script>
|
||||||
|
try {
|
||||||
|
if (localStorage.getItem('theme') === 'crochet-dark') {
|
||||||
|
document.documentElement.dataset.theme = 'crochet-dark';
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
</script>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{% block title %}Crochet{% endblock %}</title>
|
<title>{% block title %}Crochet{% endblock %}</title>
|
||||||
@@ -27,6 +34,9 @@
|
|||||||
<a href="{% url 'crochet: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>
|
||||||
<div class="flex-none flex items-center gap-2">
|
<div class="flex-none flex items-center gap-2">
|
||||||
|
<button type="button" id="theme-toggle" class="btn btn-sm btn-ghost" aria-label="{% trans 'Cambiar tema' %}">
|
||||||
|
<span id="theme-toggle-icon"></span>
|
||||||
|
</button>
|
||||||
{% get_current_language as CURRENT_LANGUAGE %}
|
{% get_current_language as CURRENT_LANGUAGE %}
|
||||||
{% get_available_languages as AVAILABLE_LANGUAGES %}
|
{% get_available_languages as AVAILABLE_LANGUAGES %}
|
||||||
<!-- El idioma es una preferencia de la página, no de la cuenta: va
|
<!-- El idioma es una preferencia de la página, no de la cuenta: va
|
||||||
@@ -64,6 +74,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
<script defer src="{% static 'js/theme-toggle.js' %}"></script>
|
||||||
<!-- Mensajes de una sola vez tras una redirección (ver messages.success
|
<!-- 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
|
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
|
resultado de la acción (la sesión ya no existe), así que se muestran
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{% extends 'crochet/email/base.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block title %}{% trans 'Confirma tu cuenta' %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<p style="margin:0 0 16px 0;">
|
||||||
|
{% blocktrans %}Has recibido este email porque te has registrado en {{ site_name }}.{% endblocktrans %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="text-align:center; margin:24px 0;">
|
||||||
|
<a href="{{ protocol }}://{{ domain }}{% url 'crochet:verify_email' 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 'Confirmar cuenta' %}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="margin:0 0 8px 0; font-size:13px; color:#2e2118;">
|
||||||
|
{% trans 'Tu usuario:' %} {{ user.get_username }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="margin:0; font-size:13px; color:#2e2118;">
|
||||||
|
{% trans 'Si no has sido tú, puedes ignorar este email.' %}
|
||||||
|
</p>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{% load i18n %}{% autoescape off %}
|
||||||
|
{% blocktrans %}Has recibido este email porque te has registrado en {{ site_name }}.{% endblocktrans %}
|
||||||
|
|
||||||
|
{% trans 'Sigue este enlace para confirmar tu cuenta:' %}
|
||||||
|
{{ protocol }}://{{ domain }}{% url 'crochet:verify_email' uidb64=uid token=token %}
|
||||||
|
|
||||||
|
{% trans 'Tu usuario:' %} {{ user.get_username }}
|
||||||
|
|
||||||
|
{% trans 'Si no has sido tú, puedes ignorar este email.' %}
|
||||||
|
{% endautoescape %}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{% load i18n %}{% blocktrans %}Confirma tu cuenta en {{ site_name }}{% endblocktrans %}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
{% extends 'crochet/base.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block title %}{% trans 'Activar verificación en dos pasos' %}{% 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-md bg-base-100 shadow-xl border border-base-300">
|
||||||
|
<div class="card-body">
|
||||||
|
{% if backup_codes %}
|
||||||
|
<h1 class="card-title">{% trans 'Guarda tus códigos de respaldo' %}</h1>
|
||||||
|
<p class="text-sm text-base-content/70">
|
||||||
|
{% trans 'Cada uno sirve para iniciar sesión una vez si pierdes acceso a tu app de autenticación. Guárdalos en un sitio seguro: no se van a volver a mostrar.' %}
|
||||||
|
</p>
|
||||||
|
<ul class="grid grid-cols-2 gap-2 font-mono text-sm bg-base-200 rounded-box p-4">
|
||||||
|
{% for code in backup_codes %}
|
||||||
|
<li>{{ code }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<a href="{% url 'crochet:account_settings' %}" class="btn btn-primary mt-4">{% trans 'Ya los he guardado' %}</a>
|
||||||
|
{% else %}
|
||||||
|
<h1 class="card-title">{% trans 'Activar verificación en dos pasos' %}</h1>
|
||||||
|
<p class="text-sm text-base-content/70">
|
||||||
|
{% trans 'Escanea este código con tu app de autenticación (Google Authenticator, Aegis, 1Password...) e introduce el código de 6 dígitos que te muestre para confirmar.' %}
|
||||||
|
</p>
|
||||||
|
<div class="flex justify-center py-2">
|
||||||
|
<div class="w-48 h-48 bg-white rounded-box p-3 [&>svg]:w-full [&>svg]:h-full">{{ qr_svg|safe }}</div>
|
||||||
|
</div>
|
||||||
|
<form method="post" class="flex flex-col gap-3">
|
||||||
|
{% csrf_token %}
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-sm">{{ setup_form.token.label }}</span>
|
||||||
|
{{ setup_form.token }}
|
||||||
|
{% for error in setup_form.token.errors %}
|
||||||
|
<span class="text-error text-xs">{{ error }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="btn btn-primary mt-2">{% trans 'Confirmar' %}</button>
|
||||||
|
</form>
|
||||||
|
<p class="text-sm text-center mt-2">
|
||||||
|
<a href="{% url 'crochet:account_settings' %}" class="link link-primary">{% trans 'Cancelar' %}</a>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{% extends 'crochet/base.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block title %}{% trans 'Confirma tu email' %}{% 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 text-center">
|
||||||
|
<h1 class="card-title justify-center">{% trans 'Revisa tu email' %}</h1>
|
||||||
|
<p class="text-sm text-base-content/70">
|
||||||
|
{% trans 'Te hemos enviado un enlace para confirmar tu cuenta. Ábrelo desde tu bandeja de entrada para poder iniciar sesión.' %}
|
||||||
|
</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,38 @@
|
|||||||
|
{% extends 'crochet/base.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block title %}{% trans 'Verificación en dos pasos' %}{% 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 'Verificación en dos pasos' %}</h1>
|
||||||
|
<p class="text-sm text-base-content/70">
|
||||||
|
{% trans 'Introduce el código de tu app de autenticación, o uno de tus códigos de respaldo si no tienes acceso a ella.' %}
|
||||||
|
</p>
|
||||||
|
<form method="post" class="flex flex-col gap-3">
|
||||||
|
{% csrf_token %}
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-sm">{{ form.token.label }}</span>
|
||||||
|
{{ form.token }}
|
||||||
|
{% for error in form.token.errors %}
|
||||||
|
<span class="text-error text-xs">{{ error }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
{{ form.remember_device }} {{ form.remember_device.label }}
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="btn btn-primary mt-2">{% trans 'Verificar' %}</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,24 @@
|
|||||||
|
{% extends 'crochet/base.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block title %}{% trans 'Enlace no válido' %}{% 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 'Enlace no válido' %}</h1>
|
||||||
|
<p class="text-sm text-base-content/70">
|
||||||
|
{% trans 'El enlace de confirmación no es válido, puede que ya se haya usado o que la cuenta ya esté verificada.' %}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-center mt-2">
|
||||||
|
<a href="{% url 'crochet:login' %}" class="link link-primary">{% trans 'Ir a iniciar sesión' %}</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
from django.core import mail
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import translation
|
from django.utils import translation
|
||||||
@@ -17,7 +18,7 @@ class RegisterViewTests(TestCase):
|
|||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertTemplateUsed(response, 'registration/register.html')
|
self.assertTemplateUsed(response, 'registration/register.html')
|
||||||
|
|
||||||
def test_post_creates_user_and_logs_in(self):
|
def test_post_creates_inactive_user_and_sends_verification_email(self):
|
||||||
response = self.client.post(self.url, {
|
response = self.client.post(self.url, {
|
||||||
'username': 'crocheter',
|
'username': 'crocheter',
|
||||||
'email': 'crocheter@example.com',
|
'email': 'crocheter@example.com',
|
||||||
@@ -27,8 +28,12 @@ class RegisterViewTests(TestCase):
|
|||||||
|
|
||||||
user = User.objects.get(username='crocheter')
|
user = User.objects.get(username='crocheter')
|
||||||
self.assertEqual(user.email, 'crocheter@example.com')
|
self.assertEqual(user.email, 'crocheter@example.com')
|
||||||
self.assertRedirects(response, reverse('crochet:home'))
|
self.assertFalse(user.is_active)
|
||||||
self.assertIn('_auth_user_id', self.client.session)
|
with translation.override('es'):
|
||||||
|
self.assertRedirects(response, reverse('crochet:register_done'))
|
||||||
|
self.assertNotIn('_auth_user_id', self.client.session)
|
||||||
|
self.assertEqual(len(mail.outbox), 1)
|
||||||
|
self.assertEqual(mail.outbox[0].to, ['crocheter@example.com'])
|
||||||
|
|
||||||
def test_post_with_mismatched_passwords_does_not_create_user(self):
|
def test_post_with_mismatched_passwords_does_not_create_user(self):
|
||||||
response = self.client.post(self.url, {
|
response = self.client.post(self.url, {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from django.core.files.uploadedfile import SimpleUploadedFile
|
|||||||
from django.test import SimpleTestCase
|
from django.test import SimpleTestCase
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from crochet.cover_image import InvalidCoverImage, LARGE_MAX_SIZE, THUMBNAIL_MAX_SIZE, build_cover_image_variants
|
from crochet.cover_image import LARGE_MAX_SIZE, THUMBNAIL_MAX_SIZE, InvalidCoverImage, build_cover_image_variants
|
||||||
|
|
||||||
|
|
||||||
def make_image_file(size=(2000, 1000), mode='RGB', color='red', name='cover.png', format='PNG'):
|
def make_image_file(size=(2000, 1000), mode='RGB', color='red', name='cover.png', format='PNG'):
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
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
|
||||||
|
|
||||||
|
from crochet.tokens import email_verification_token
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class VerifyEmailViewTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
username='testuser',
|
||||||
|
password='a-very-uncommon-pw-1',
|
||||||
|
email='testuser@example.com',
|
||||||
|
is_active=False,
|
||||||
|
)
|
||||||
|
self.uid = urlsafe_base64_encode(force_bytes(self.user.pk))
|
||||||
|
self.token = email_verification_token.make_token(self.user)
|
||||||
|
with translation.override('es'):
|
||||||
|
self.url = reverse('crochet:verify_email', kwargs={'uidb64': self.uid, 'token': self.token})
|
||||||
|
|
||||||
|
def test_valid_link_activates_and_logs_in(self):
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
|
||||||
|
self.user.refresh_from_db()
|
||||||
|
self.assertTrue(self.user.is_active)
|
||||||
|
self.assertIn('_auth_user_id', self.client.session)
|
||||||
|
with translation.override('es'):
|
||||||
|
home_url = reverse('crochet:home')
|
||||||
|
self.assertRedirects(response, home_url)
|
||||||
|
|
||||||
|
def test_already_active_user_rejects_the_token(self):
|
||||||
|
# El hash del token incluye is_active (ver EmailVerificationTokenGenerator):
|
||||||
|
# una vez activada la cuenta, el mismo enlace ya no debe volver a
|
||||||
|
# funcionar si alguien lo reutiliza (p. ej. interceptado en un
|
||||||
|
# cliente de correo compartido).
|
||||||
|
self.user.is_active = True
|
||||||
|
self.user.save(update_fields=['is_active'])
|
||||||
|
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
self.assertTemplateUsed(response, 'registration/verify_email_invalid.html')
|
||||||
|
self.assertNotIn('_auth_user_id', self.client.session)
|
||||||
|
|
||||||
|
def test_invalid_token_does_not_activate(self):
|
||||||
|
with translation.override('es'):
|
||||||
|
url = reverse('crochet:verify_email', kwargs={'uidb64': self.uid, 'token': 'not-a-real-token'})
|
||||||
|
|
||||||
|
response = self.client.get(url)
|
||||||
|
|
||||||
|
self.user.refresh_from_db()
|
||||||
|
self.assertFalse(self.user.is_active)
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
self.assertTemplateUsed(response, 'registration/verify_email_invalid.html')
|
||||||
|
|
||||||
|
def test_unknown_uid_does_not_activate(self):
|
||||||
|
bogus_uid = urlsafe_base64_encode(force_bytes(self.user.pk + 1000))
|
||||||
|
with translation.override('es'):
|
||||||
|
url = reverse('crochet:verify_email', kwargs={'uidb64': bogus_uid, 'token': self.token})
|
||||||
|
|
||||||
|
response = self.client.get(url)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
self.assertTemplateUsed(response, 'registration/verify_email_invalid.html')
|
||||||
|
|
||||||
|
def test_malformed_uid_does_not_error(self):
|
||||||
|
with translation.override('es'):
|
||||||
|
url = reverse('crochet:verify_email', kwargs={'uidb64': 'not-base64!!', 'token': self.token})
|
||||||
|
|
||||||
|
response = self.client.get(url)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
self.assertTemplateUsed(response, 'registration/verify_email_invalid.html')
|
||||||
|
|
||||||
|
def test_inactive_user_cannot_log_in_before_verifying(self):
|
||||||
|
with translation.override('es'):
|
||||||
|
login_url = reverse('crochet:login')
|
||||||
|
response = self.client.post(
|
||||||
|
login_url,
|
||||||
|
{
|
||||||
|
'username': 'testuser',
|
||||||
|
'password': 'a-very-uncommon-pw-1',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertNotIn('_auth_user_id', self.client.session)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
from django.test import RequestFactory, TestCase, override_settings
|
||||||
|
from django.views.defaults import server_error
|
||||||
|
|
||||||
|
|
||||||
|
class Custom404Tests(TestCase):
|
||||||
|
# DEBUG=False a propósito: con DEBUG=True Django muestra su propia
|
||||||
|
# página técnica de depuración para un 404 en vez de la plantilla
|
||||||
|
# personalizada (ver django.core.handlers.exception.response_for_exception),
|
||||||
|
# así que sin esto la prueba pasaría igual sin haber comprobado nada.
|
||||||
|
@override_settings(DEBUG=False)
|
||||||
|
def test_unknown_url_shows_the_custom_404_page(self):
|
||||||
|
response = self.client.get('/es/esta-ruta-no-existe-de-verdad/')
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
self.assertContains(response, 'Página no encontrada', status_code=404)
|
||||||
|
|
||||||
|
|
||||||
|
class Custom500Tests(TestCase):
|
||||||
|
def test_renders_without_any_request_context(self):
|
||||||
|
# server_error() renderiza la plantilla con "template.render()", sin
|
||||||
|
# ningún Context ni request (ver django/views/defaults.py) -por eso
|
||||||
|
# 500.html no puede depender de session/usuario/context processors-.
|
||||||
|
# El propio request de RequestFactory es solo para que el decorador
|
||||||
|
# @requires_csrf_token de la vista tenga algo real que mirar; no
|
||||||
|
# llega en ningún momento a la plantilla.
|
||||||
|
request = RequestFactory().get('/')
|
||||||
|
response = server_error(request)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 500)
|
||||||
|
self.assertContains(response, 'ido mal', status_code=500)
|
||||||
@@ -118,7 +118,9 @@ class RenderPatternHtmlTests(SimpleTestCase):
|
|||||||
|
|
||||||
def test_subtitle_section(self):
|
def test_subtitle_section(self):
|
||||||
html = self.render([{'type': 'subtitle', 'text': {'es': 'Vueltas 1-10'}}])
|
html = self.render([{'type': 'subtitle', 'text': {'es': 'Vueltas 1-10'}}])
|
||||||
self.assertIn('<h4 class="text-[1em] font-semibold mt-1 mb-1 text-[var(--accent-color)]">Vueltas 1-10</h4>', html)
|
self.assertIn(
|
||||||
|
'<h4 class="text-[1em] font-semibold mt-1 mb-1 text-[var(--accent-color)]">Vueltas 1-10</h4>', html,
|
||||||
|
)
|
||||||
|
|
||||||
def test_text_section(self):
|
def test_text_section(self):
|
||||||
html = self.render([{'type': 'text', 'text': {'es': 'Un párrafo cualquiera.'}}])
|
html = self.render([{'type': 'text', 'text': {'es': 'Un párrafo cualquiera.'}}])
|
||||||
@@ -180,7 +182,9 @@ class RenderPatternHtmlTests(SimpleTestCase):
|
|||||||
|
|
||||||
def test_dnd_section_uses_current_language(self):
|
def test_dnd_section_uses_current_language(self):
|
||||||
stitch_types = [{'id': 1, 'translations': {'es': 'Punto bajo', 'en': 'SC'}}]
|
stitch_types = [{'id': 1, 'translations': {'es': 'Punto bajo', 'en': 'SC'}}]
|
||||||
html = self.render([{'type': 'dnd', 'items': [{'stitchTypeId': 1, 'count': 2}]}], lang='en', stitch_types=stitch_types)
|
html = self.render(
|
||||||
|
[{'type': 'dnd', 'items': [{'stitchTypeId': 1, 'count': 2}]}], lang='en', stitch_types=stitch_types,
|
||||||
|
)
|
||||||
self.assertIn('2 SC', html)
|
self.assertIn('2 SC', html)
|
||||||
|
|
||||||
def test_dnd_section_skips_unknown_stitch_type(self):
|
def test_dnd_section_skips_unknown_stitch_type(self):
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import translation
|
||||||
|
from django_otp.plugins.otp_totp.models import TOTPDevice
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRateLimitTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
with translation.override('es'):
|
||||||
|
self.url = reverse('crochet:login')
|
||||||
|
|
||||||
|
def test_tenth_attempt_within_a_minute_still_goes_through(self):
|
||||||
|
for _ in range(10):
|
||||||
|
response = self.client.post(self.url, {'username': 'nobody', 'password': 'wrong'})
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_eleventh_attempt_within_a_minute_is_blocked(self):
|
||||||
|
for _ in range(10):
|
||||||
|
self.client.post(self.url, {'username': 'nobody', 'password': 'wrong'})
|
||||||
|
|
||||||
|
response = self.client.post(self.url, {'username': 'nobody', 'password': 'wrong'})
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
def test_get_requests_are_not_rate_limited(self):
|
||||||
|
# Solo se limita method='POST' (ver LOGIN_RATE en crochet/views.py):
|
||||||
|
# la propia página de login debe poder cargarse las veces que haga
|
||||||
|
# falta, el límite es sobre los intentos de credenciales.
|
||||||
|
for _ in range(15):
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterRateLimitTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
with translation.override('es'):
|
||||||
|
self.url = reverse('crochet:register')
|
||||||
|
|
||||||
|
def test_sixth_registration_attempt_within_a_minute_is_blocked(self):
|
||||||
|
# Solo el primer POST crea la cuenta (302); los siguientes fallan la
|
||||||
|
# validación por "nombre de usuario ya existe" (200 con errores en
|
||||||
|
# el formulario), pero eso no importa aquí: lo que se comprueba es
|
||||||
|
# que ninguno de los cinco primeros lo bloquea el límite (403), y
|
||||||
|
# que el sexto sí.
|
||||||
|
payload = {
|
||||||
|
'username': 'testuser',
|
||||||
|
'email': 'testuser@example.com',
|
||||||
|
'password1': 'a-very-uncommon-pw-1',
|
||||||
|
'password2': 'a-very-uncommon-pw-1',
|
||||||
|
}
|
||||||
|
for _ in range(5):
|
||||||
|
response = self.client.post(self.url, payload)
|
||||||
|
self.assertIn(response.status_code, (200, 302))
|
||||||
|
|
||||||
|
response = self.client.post(self.url, payload)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordResetRateLimitTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
with translation.override('es'):
|
||||||
|
self.url = reverse('crochet:password_reset')
|
||||||
|
|
||||||
|
def test_sixth_request_within_a_minute_is_blocked(self):
|
||||||
|
for _ in range(5):
|
||||||
|
response = self.client.post(self.url, {'email': 'nobody@example.com'})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
response = self.client.post(self.url, {'email': 'nobody@example.com'})
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
|
||||||
|
class TwoFactorVerifyRateLimitTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(username='testuser', password='a-very-uncommon-pw-1')
|
||||||
|
TOTPDevice.objects.create(user=self.user, confirmed=True)
|
||||||
|
with translation.override('es'):
|
||||||
|
login_url = reverse('crochet:login')
|
||||||
|
self.url = reverse('crochet:two_factor_verify')
|
||||||
|
self.client.post(login_url, {'username': 'testuser', 'password': 'a-very-uncommon-pw-1'})
|
||||||
|
|
||||||
|
def test_eleventh_attempt_within_a_minute_is_blocked(self):
|
||||||
|
for _ in range(10):
|
||||||
|
response = self.client.post(self.url, {'token': '000000'})
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
response = self.client.post(self.url, {'token': '000000'})
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone, translation
|
||||||
|
from django_otp.oath import totp
|
||||||
|
from django_otp.plugins.otp_static.models import StaticDevice, StaticToken
|
||||||
|
from django_otp.plugins.otp_totp.models import TOTPDevice
|
||||||
|
|
||||||
|
from crochet.models import TwoFactorRememberedDevice
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
def current_totp_code(device):
|
||||||
|
code = totp(device.bin_key, device.step, device.t0, device.digits, device.drift)
|
||||||
|
return str(code).zfill(device.digits)
|
||||||
|
|
||||||
|
|
||||||
|
class TwoFactorSetupViewTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(username='testuser', password='a-very-uncommon-pw-1')
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
with translation.override('es'):
|
||||||
|
self.url = reverse('crochet:two_factor_setup')
|
||||||
|
|
||||||
|
def test_anonymous_user_is_redirected_to_login(self):
|
||||||
|
self.client.logout()
|
||||||
|
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_shows_a_qr_code_and_creates_an_unconfirmed_device(self):
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, '<svg')
|
||||||
|
device = TOTPDevice.objects.get(user=self.user)
|
||||||
|
self.assertFalse(device.confirmed)
|
||||||
|
|
||||||
|
def test_reloading_the_page_reuses_the_same_device(self):
|
||||||
|
self.client.get(self.url)
|
||||||
|
first_key = TOTPDevice.objects.get(user=self.user).key
|
||||||
|
|
||||||
|
self.client.get(self.url)
|
||||||
|
|
||||||
|
self.assertEqual(TOTPDevice.objects.filter(user=self.user).count(), 1)
|
||||||
|
self.assertEqual(TOTPDevice.objects.get(user=self.user).key, first_key)
|
||||||
|
|
||||||
|
def test_correct_code_confirms_the_device_and_shows_backup_codes(self):
|
||||||
|
self.client.get(self.url)
|
||||||
|
device = TOTPDevice.objects.get(user=self.user)
|
||||||
|
|
||||||
|
response = self.client.post(self.url, {'token': current_totp_code(device)})
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
device.refresh_from_db()
|
||||||
|
self.assertTrue(device.confirmed)
|
||||||
|
static_device = StaticDevice.objects.get(user=self.user)
|
||||||
|
codes = list(static_device.token_set.values_list('token', flat=True))
|
||||||
|
self.assertEqual(len(codes), 10)
|
||||||
|
for code in codes:
|
||||||
|
self.assertContains(response, code)
|
||||||
|
|
||||||
|
def test_wrong_code_does_not_confirm_the_device(self):
|
||||||
|
self.client.get(self.url)
|
||||||
|
|
||||||
|
response = self.client.post(self.url, {'token': '000000'})
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, 'Código incorrecto.')
|
||||||
|
self.assertFalse(TOTPDevice.objects.get(user=self.user).confirmed)
|
||||||
|
self.assertFalse(StaticDevice.objects.filter(user=self.user).exists())
|
||||||
|
|
||||||
|
def test_already_enabled_user_is_redirected_to_account_settings(self):
|
||||||
|
TOTPDevice.objects.create(user=self.user, confirmed=True)
|
||||||
|
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
|
||||||
|
with translation.override('es'):
|
||||||
|
settings_url = reverse('crochet:account_settings')
|
||||||
|
self.assertRedirects(response, settings_url)
|
||||||
|
|
||||||
|
|
||||||
|
class TwoFactorLoginTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(username='testuser', password='a-very-uncommon-pw-1')
|
||||||
|
with translation.override('es'):
|
||||||
|
self.login_url = reverse('crochet:login')
|
||||||
|
self.verify_url = reverse('crochet:two_factor_verify')
|
||||||
|
|
||||||
|
def test_login_without_2fa_enabled_logs_in_directly(self):
|
||||||
|
response = self.client.post(
|
||||||
|
self.login_url,
|
||||||
|
{
|
||||||
|
'username': 'testuser',
|
||||||
|
'password': 'a-very-uncommon-pw-1',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
with translation.override('es'):
|
||||||
|
home_url = reverse('crochet:home')
|
||||||
|
self.assertRedirects(response, home_url)
|
||||||
|
self.assertIn('_auth_user_id', self.client.session)
|
||||||
|
|
||||||
|
def test_login_with_2fa_enabled_does_not_log_in_yet(self):
|
||||||
|
TOTPDevice.objects.create(user=self.user, confirmed=True)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
self.login_url,
|
||||||
|
{
|
||||||
|
'username': 'testuser',
|
||||||
|
'password': 'a-very-uncommon-pw-1',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertRedirects(response, self.verify_url)
|
||||||
|
self.assertNotIn('_auth_user_id', self.client.session)
|
||||||
|
|
||||||
|
def test_verify_with_correct_totp_code_logs_in(self):
|
||||||
|
device = TOTPDevice.objects.create(user=self.user, confirmed=True)
|
||||||
|
self.client.post(self.login_url, {'username': 'testuser', 'password': 'a-very-uncommon-pw-1'})
|
||||||
|
|
||||||
|
response = self.client.post(self.verify_url, {'token': current_totp_code(device)})
|
||||||
|
|
||||||
|
with translation.override('es'):
|
||||||
|
home_url = reverse('crochet:home')
|
||||||
|
self.assertRedirects(response, home_url)
|
||||||
|
self.assertIn('_auth_user_id', self.client.session)
|
||||||
|
|
||||||
|
def test_verify_with_wrong_code_does_not_log_in(self):
|
||||||
|
TOTPDevice.objects.create(user=self.user, confirmed=True)
|
||||||
|
self.client.post(self.login_url, {'username': 'testuser', 'password': 'a-very-uncommon-pw-1'})
|
||||||
|
|
||||||
|
response = self.client.post(self.verify_url, {'token': '000000'})
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, 'Código incorrecto.')
|
||||||
|
self.assertNotIn('_auth_user_id', self.client.session)
|
||||||
|
|
||||||
|
def test_verify_with_a_backup_code_logs_in_and_consumes_it(self):
|
||||||
|
TOTPDevice.objects.create(user=self.user, confirmed=True)
|
||||||
|
static_device = StaticDevice.objects.create(user=self.user, confirmed=True)
|
||||||
|
StaticToken.objects.create(device=static_device, token='abcd1234')
|
||||||
|
self.client.post(self.login_url, {'username': 'testuser', 'password': 'a-very-uncommon-pw-1'})
|
||||||
|
|
||||||
|
response = self.client.post(self.verify_url, {'token': 'abcd1234'})
|
||||||
|
|
||||||
|
with translation.override('es'):
|
||||||
|
home_url = reverse('crochet:home')
|
||||||
|
self.assertRedirects(response, home_url)
|
||||||
|
self.assertFalse(StaticToken.objects.filter(device=static_device, token='abcd1234').exists())
|
||||||
|
|
||||||
|
def test_reusing_a_backup_code_fails(self):
|
||||||
|
TOTPDevice.objects.create(user=self.user, confirmed=True)
|
||||||
|
static_device = StaticDevice.objects.create(user=self.user, confirmed=True)
|
||||||
|
StaticToken.objects.create(device=static_device, token='abcd1234')
|
||||||
|
self.client.post(self.login_url, {'username': 'testuser', 'password': 'a-very-uncommon-pw-1'})
|
||||||
|
self.client.post(self.verify_url, {'token': 'abcd1234'})
|
||||||
|
self.client.logout()
|
||||||
|
|
||||||
|
self.client.post(self.login_url, {'username': 'testuser', 'password': 'a-very-uncommon-pw-1'})
|
||||||
|
response = self.client.post(self.verify_url, {'token': 'abcd1234'})
|
||||||
|
|
||||||
|
self.assertContains(response, 'Código incorrecto.')
|
||||||
|
self.assertNotIn('_auth_user_id', self.client.session)
|
||||||
|
|
||||||
|
def test_verify_without_a_pending_login_redirects_to_login(self):
|
||||||
|
response = self.client.get(self.verify_url)
|
||||||
|
|
||||||
|
self.assertRedirects(response, self.login_url)
|
||||||
|
|
||||||
|
def test_unconfirmed_device_does_not_trigger_2fa(self):
|
||||||
|
# Un TOTPDevice sin confirmar (alta a medias, ver TwoFactorSetupView)
|
||||||
|
# no debe poder usarse para exigir el segundo paso: user_has_device()
|
||||||
|
# ya filtra por confirmed=True.
|
||||||
|
TOTPDevice.objects.create(user=self.user, confirmed=False)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
self.login_url,
|
||||||
|
{
|
||||||
|
'username': 'testuser',
|
||||||
|
'password': 'a-very-uncommon-pw-1',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
with translation.override('es'):
|
||||||
|
home_url = reverse('crochet:home')
|
||||||
|
self.assertRedirects(response, home_url)
|
||||||
|
self.assertIn('_auth_user_id', self.client.session)
|
||||||
|
|
||||||
|
|
||||||
|
class TwoFactorDisableViewTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(username='testuser', password='a-very-uncommon-pw-1')
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
TOTPDevice.objects.create(user=self.user, confirmed=True)
|
||||||
|
static_device = StaticDevice.objects.create(user=self.user, confirmed=True)
|
||||||
|
StaticToken.objects.create(device=static_device, token='abcd1234')
|
||||||
|
with translation.override('es'):
|
||||||
|
self.url = reverse('crochet:two_factor_disable')
|
||||||
|
|
||||||
|
def test_correct_password_disables_2fa_and_removes_backup_codes(self):
|
||||||
|
response = self.client.post(self.url, {'password': 'a-very-uncommon-pw-1'})
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, 'Desactivada')
|
||||||
|
self.assertFalse(TOTPDevice.objects.filter(user=self.user).exists())
|
||||||
|
self.assertFalse(StaticDevice.objects.filter(user=self.user).exists())
|
||||||
|
|
||||||
|
def test_wrong_password_does_not_disable_2fa(self):
|
||||||
|
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(TOTPDevice.objects.filter(user=self.user).exists())
|
||||||
|
|
||||||
|
def test_wrong_password_reopens_the_confirmation_modal(self):
|
||||||
|
# Al intercambiarse el fragmento entero (ver hx-target en
|
||||||
|
# _account_2fa_status.html), el <dialog> se sustituye por uno
|
||||||
|
# nuevo, que por defecto no está abierto: data-reopen es lo que le
|
||||||
|
# dice a modal-reopen.js que llame a showModal() tras el swap.
|
||||||
|
response = self.client.post(self.url, {'password': 'wrong-password'})
|
||||||
|
|
||||||
|
self.assertContains(response, 'data-reopen')
|
||||||
|
|
||||||
|
def test_anonymous_user_is_redirected_to_login(self):
|
||||||
|
self.client.logout()
|
||||||
|
response = self.client.post(self.url, {'password': 'a-very-uncommon-pw-1'})
|
||||||
|
|
||||||
|
with translation.override('es'):
|
||||||
|
login_url = reverse('crochet:login')
|
||||||
|
self.assertRedirects(response, f'{login_url}?next={self.url}')
|
||||||
|
|
||||||
|
|
||||||
|
class RememberDeviceTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(username='testuser', password='a-very-uncommon-pw-1')
|
||||||
|
self.device = TOTPDevice.objects.create(user=self.user, confirmed=True)
|
||||||
|
with translation.override('es'):
|
||||||
|
self.login_url = reverse('crochet:login')
|
||||||
|
self.verify_url = reverse('crochet:two_factor_verify')
|
||||||
|
|
||||||
|
def _login_and_verify(self, remember_device=False, user_agent=''):
|
||||||
|
self.client.post(self.login_url, {'username': 'testuser', 'password': 'a-very-uncommon-pw-1'})
|
||||||
|
data = {'token': current_totp_code(self.device)}
|
||||||
|
if remember_device:
|
||||||
|
data['remember_device'] = 'on'
|
||||||
|
return self.client.post(self.verify_url, data, HTTP_USER_AGENT=user_agent)
|
||||||
|
|
||||||
|
def _logout(self):
|
||||||
|
# Client.logout() vacía TODAS las cookies del cliente de test (ver
|
||||||
|
# su código fuente), no solo la de sesión, así que "olvidaría" de
|
||||||
|
# golpe la cookie de dispositivo recordado -algo que un logout de
|
||||||
|
# verdad (el de la propia app, ver crochet:logout) no hace-. Se prueba
|
||||||
|
# contra ese en vez de contra el atajo del test client.
|
||||||
|
with translation.override('es'):
|
||||||
|
logout_url = reverse('crochet:logout')
|
||||||
|
self.client.post(logout_url)
|
||||||
|
|
||||||
|
def test_marking_remember_device_sets_a_cookie_and_a_db_row(self):
|
||||||
|
response = self._login_and_verify(remember_device=True)
|
||||||
|
|
||||||
|
with translation.override('es'):
|
||||||
|
home_url = reverse('crochet:home')
|
||||||
|
self.assertRedirects(response, home_url)
|
||||||
|
self.assertIn('two_factor_remember', response.cookies)
|
||||||
|
self.assertEqual(TwoFactorRememberedDevice.objects.filter(user=self.user).count(), 1)
|
||||||
|
|
||||||
|
def test_without_remember_device_no_cookie_or_row_is_created(self):
|
||||||
|
response = self._login_and_verify(remember_device=False)
|
||||||
|
|
||||||
|
self.assertNotIn('two_factor_remember', response.cookies)
|
||||||
|
self.assertFalse(TwoFactorRememberedDevice.objects.filter(user=self.user).exists())
|
||||||
|
|
||||||
|
def test_next_login_from_a_remembered_device_skips_two_factor(self):
|
||||||
|
self._login_and_verify(remember_device=True)
|
||||||
|
self._logout()
|
||||||
|
|
||||||
|
response = self.client.post(self.login_url, {'username': 'testuser', 'password': 'a-very-uncommon-pw-1'})
|
||||||
|
|
||||||
|
with translation.override('es'):
|
||||||
|
home_url = reverse('crochet:home')
|
||||||
|
self.assertRedirects(response, home_url)
|
||||||
|
self.assertIn('_auth_user_id', self.client.session)
|
||||||
|
|
||||||
|
def test_expired_remembered_device_does_not_skip_two_factor(self):
|
||||||
|
self._login_and_verify(remember_device=True)
|
||||||
|
TwoFactorRememberedDevice.objects.update(expires_at=timezone.now() - timedelta(days=1))
|
||||||
|
self._logout()
|
||||||
|
|
||||||
|
response = self.client.post(self.login_url, {'username': 'testuser', 'password': 'a-very-uncommon-pw-1'})
|
||||||
|
|
||||||
|
self.assertRedirects(response, self.verify_url)
|
||||||
|
self.assertNotIn('_auth_user_id', self.client.session)
|
||||||
|
|
||||||
|
def test_a_remembered_device_from_another_user_does_not_skip_two_factor(self):
|
||||||
|
self._login_and_verify(remember_device=True)
|
||||||
|
self._logout()
|
||||||
|
other = User.objects.create_user(username='other', password='a-very-uncommon-pw-1')
|
||||||
|
TOTPDevice.objects.create(user=other, confirmed=True)
|
||||||
|
|
||||||
|
response = self.client.post(self.login_url, {'username': 'other', 'password': 'a-very-uncommon-pw-1'})
|
||||||
|
|
||||||
|
self.assertRedirects(response, self.verify_url)
|
||||||
|
self.assertNotIn('_auth_user_id', self.client.session)
|
||||||
|
|
||||||
|
def test_disabling_two_factor_forgets_remembered_devices(self):
|
||||||
|
self._login_and_verify(remember_device=True)
|
||||||
|
with translation.override('es'):
|
||||||
|
disable_url = reverse('crochet:two_factor_disable')
|
||||||
|
|
||||||
|
self.client.post(disable_url, {'password': 'a-very-uncommon-pw-1'})
|
||||||
|
|
||||||
|
self.assertFalse(TwoFactorRememberedDevice.objects.filter(user=self.user).exists())
|
||||||
|
|
||||||
|
def test_device_name_is_extracted_from_the_user_agent(self):
|
||||||
|
user_agent = (
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
|
||||||
|
'(KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36'
|
||||||
|
)
|
||||||
|
|
||||||
|
self._login_and_verify(remember_device=True, user_agent=user_agent)
|
||||||
|
|
||||||
|
device = TwoFactorRememberedDevice.objects.get(user=self.user)
|
||||||
|
self.assertEqual(device.device_name, 'Chrome (Windows)')
|
||||||
|
|
||||||
|
def test_account_settings_shows_the_remembered_device(self):
|
||||||
|
self._login_and_verify(remember_device=True, user_agent='Mozilla/5.0 Firefox/128.0')
|
||||||
|
with translation.override('es'):
|
||||||
|
settings_url = reverse('crochet:account_settings')
|
||||||
|
|
||||||
|
response = self.client.get(settings_url)
|
||||||
|
|
||||||
|
self.assertContains(response, 'Firefox')
|
||||||
|
|
||||||
|
|
||||||
|
class ForgetDeviceViewTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(username='testuser', password='a-very-uncommon-pw-1')
|
||||||
|
TOTPDevice.objects.create(user=self.user, confirmed=True)
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
self.device = TwoFactorRememberedDevice.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
token='abc123',
|
||||||
|
device_name='Firefox (Linux)',
|
||||||
|
expires_at=timezone.now() + timedelta(days=30),
|
||||||
|
)
|
||||||
|
with translation.override('es'):
|
||||||
|
self.url = reverse('crochet:two_factor_forget_device', args=[self.device.pk])
|
||||||
|
|
||||||
|
def test_forgets_the_device(self):
|
||||||
|
response = self.client.post(self.url)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertNotContains(response, 'Firefox (Linux)')
|
||||||
|
self.assertFalse(TwoFactorRememberedDevice.objects.filter(pk=self.device.pk).exists())
|
||||||
|
|
||||||
|
def test_forgetting_the_current_browsers_device_clears_its_cookie(self):
|
||||||
|
self.client.cookies['two_factor_remember'] = 'abc123'
|
||||||
|
|
||||||
|
response = self.client.post(self.url)
|
||||||
|
|
||||||
|
self.assertEqual(response.cookies['two_factor_remember'].value, '')
|
||||||
|
|
||||||
|
def test_forgetting_another_devices_cookie_is_left_alone(self):
|
||||||
|
self.client.cookies['two_factor_remember'] = 'someone-elses-token'
|
||||||
|
|
||||||
|
response = self.client.post(self.url)
|
||||||
|
|
||||||
|
self.assertNotIn('two_factor_remember', response.cookies)
|
||||||
|
|
||||||
|
def test_cannot_forget_another_users_device(self):
|
||||||
|
other = User.objects.create_user(username='other', password='a-very-uncommon-pw-1')
|
||||||
|
other_device = TwoFactorRememberedDevice.objects.create(
|
||||||
|
user=other, token='xyz789', expires_at=timezone.now() + timedelta(days=30),
|
||||||
|
)
|
||||||
|
with translation.override('es'):
|
||||||
|
url = reverse('crochet:two_factor_forget_device', args=[other_device.pk])
|
||||||
|
|
||||||
|
response = self.client.post(url)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
self.assertTrue(TwoFactorRememberedDevice.objects.filter(pk=other_device.pk).exists())
|
||||||
|
|
||||||
|
def test_anonymous_user_is_redirected_to_login(self):
|
||||||
|
self.client.logout()
|
||||||
|
response = self.client.post(self.url)
|
||||||
|
|
||||||
|
with translation.override('es'):
|
||||||
|
login_url = reverse('crochet:login')
|
||||||
|
self.assertRedirects(response, f'{login_url}?next={self.url}')
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from crochet.user_agent import describe_device
|
||||||
|
|
||||||
|
CHROME_WINDOWS = (
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
|
||||||
|
'(KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36'
|
||||||
|
)
|
||||||
|
FIREFOX_LINUX = 'Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0'
|
||||||
|
SAFARI_IPHONE = (
|
||||||
|
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 '
|
||||||
|
'(KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1'
|
||||||
|
)
|
||||||
|
EDGE_WINDOWS = (
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
|
||||||
|
'(KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 Edg/128.0.0.0'
|
||||||
|
)
|
||||||
|
CHROME_ANDROID = (
|
||||||
|
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 '
|
||||||
|
'(KHTML, like Gecko) Chrome/128.0.0.0 Mobile Safari/537.36'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DescribeDeviceTests(TestCase):
|
||||||
|
def test_chrome_on_windows(self):
|
||||||
|
self.assertEqual(describe_device(CHROME_WINDOWS), 'Chrome (Windows)')
|
||||||
|
|
||||||
|
def test_firefox_on_linux(self):
|
||||||
|
self.assertEqual(describe_device(FIREFOX_LINUX), 'Firefox (Linux)')
|
||||||
|
|
||||||
|
def test_safari_on_iphone(self):
|
||||||
|
self.assertEqual(describe_device(SAFARI_IPHONE), 'Safari (iPhone)')
|
||||||
|
|
||||||
|
def test_edge_is_not_mistaken_for_chrome(self):
|
||||||
|
self.assertEqual(describe_device(EDGE_WINDOWS), 'Edge (Windows)')
|
||||||
|
|
||||||
|
def test_chrome_on_android_is_not_mistaken_for_linux_desktop(self):
|
||||||
|
# "Android" no lleva "Linux" en la comprobación de _OPERATING_SYSTEMS,
|
||||||
|
# pero el User-Agent de Chrome/Android sí incluye la palabra "Linux"
|
||||||
|
# de fábrica (el kernel), así que Android tiene que comprobarse antes.
|
||||||
|
self.assertEqual(describe_device(CHROME_ANDROID), 'Chrome (Android)')
|
||||||
|
|
||||||
|
def test_empty_user_agent(self):
|
||||||
|
self.assertEqual(describe_device(''), 'Dispositivo desconocido')
|
||||||
|
|
||||||
|
def test_none_user_agent(self):
|
||||||
|
self.assertEqual(describe_device(None), 'Dispositivo desconocido')
|
||||||
|
|
||||||
|
def test_unrecognized_user_agent(self):
|
||||||
|
self.assertEqual(describe_device('SomeBot/1.0'), 'Dispositivo desconocido')
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
from django.contrib.auth.tokens import PasswordResetTokenGenerator
|
||||||
|
|
||||||
|
|
||||||
|
class EmailVerificationTokenGenerator(PasswordResetTokenGenerator):
|
||||||
|
# Mismo mecanismo que el token de recuperación de contraseña (hash
|
||||||
|
# firmado con SECRET_KEY, sin guardar nada en base de datos), pero con
|
||||||
|
# su propia clase: incluir is_active en el hash (a diferencia del
|
||||||
|
# genérico, que solo mira password/last_login) hace que el enlace deje
|
||||||
|
# de ser válido en cuanto se usa una vez, ya que activar la cuenta
|
||||||
|
# cambia is_active de False a True.
|
||||||
|
def _make_hash_value(self, user, timestamp):
|
||||||
|
return f'{user.pk}{user.is_active}{timestamp}'
|
||||||
|
|
||||||
|
|
||||||
|
email_verification_token = EmailVerificationTokenGenerator()
|
||||||
+19
-2
@@ -19,7 +19,15 @@ from crochet.views import (
|
|||||||
PatternImageUploadView,
|
PatternImageUploadView,
|
||||||
PatternPdfView,
|
PatternPdfView,
|
||||||
PatternSaveView,
|
PatternSaveView,
|
||||||
|
RateLimitedLoginView,
|
||||||
|
RateLimitedPasswordResetView,
|
||||||
|
RegisterDoneView,
|
||||||
RegisterView,
|
RegisterView,
|
||||||
|
TwoFactorDisableView,
|
||||||
|
TwoFactorForgetDeviceView,
|
||||||
|
TwoFactorSetupView,
|
||||||
|
TwoFactorVerifyView,
|
||||||
|
VerifyEmailView,
|
||||||
)
|
)
|
||||||
|
|
||||||
app_name = 'crochet'
|
app_name = 'crochet'
|
||||||
@@ -55,17 +63,26 @@ urlpatterns = [
|
|||||||
view=AccountDisplayNameUpdateView.as_view(),
|
view=AccountDisplayNameUpdateView.as_view(),
|
||||||
),
|
),
|
||||||
path(_('account/settings/delete/'), name='account_settings_delete', view=AccountDeleteView.as_view()),
|
path(_('account/settings/delete/'), name='account_settings_delete', view=AccountDeleteView.as_view()),
|
||||||
|
path(_('account/settings/2fa/setup/'), name='two_factor_setup', view=TwoFactorSetupView.as_view()),
|
||||||
|
path(_('account/settings/2fa/disable/'), name='two_factor_disable', view=TwoFactorDisableView.as_view()),
|
||||||
|
path(
|
||||||
|
_('account/settings/2fa/devices/<int:device_id>/forget/'),
|
||||||
|
name='two_factor_forget_device', view=TwoFactorForgetDeviceView.as_view(),
|
||||||
|
),
|
||||||
path(_('account/register/'), name='register', view=RegisterView.as_view()),
|
path(_('account/register/'), name='register', view=RegisterView.as_view()),
|
||||||
|
path(_('account/register/done/'), name='register_done', view=RegisterDoneView.as_view()),
|
||||||
|
path(_('account/verify/<uidb64>/<token>/'), name='verify_email', view=VerifyEmailView.as_view()),
|
||||||
|
path(_('account/2fa/verify/'), name='two_factor_verify', view=TwoFactorVerifyView.as_view()),
|
||||||
path(
|
path(
|
||||||
_('account/login/'), name='login',
|
_('account/login/'), name='login',
|
||||||
view=auth_views.LoginView.as_view(
|
view=RateLimitedLoginView.as_view(
|
||||||
template_name='registration/login.html', authentication_form=StyledAuthenticationForm,
|
template_name='registration/login.html', authentication_form=StyledAuthenticationForm,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
path(_('account/logout/'), name='logout', view=auth_views.LogoutView.as_view()),
|
path(_('account/logout/'), name='logout', view=auth_views.LogoutView.as_view()),
|
||||||
path(
|
path(
|
||||||
_('account/password-reset/'), name='password_reset',
|
_('account/password-reset/'), name='password_reset',
|
||||||
view=auth_views.PasswordResetView.as_view(
|
view=RateLimitedPasswordResetView.as_view(
|
||||||
# No en registration/ (a diferencia de login.html/register.html):
|
# No en registration/ (a diferencia de login.html/register.html):
|
||||||
# django.contrib.admin trae sus propias plantillas con estos
|
# django.contrib.admin trae sus propias plantillas con estos
|
||||||
# mismos nombres bajo registration/ y, al ir antes que crochet
|
# mismos nombres bajo registration/ y, al ir antes que crochet
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
from django.utils.translation import gettext
|
||||||
|
|
||||||
|
# Descripción legible del User-Agent para TwoFactorRememberedDevice.
|
||||||
|
# Heurística a base de subcadenas, no un parser exhaustivo: solo pretende
|
||||||
|
# ayudar a reconocer el dispositivo en la lista de ajustes de cuenta, no
|
||||||
|
# ser una fuente fiable (un User-Agent se falsifica sin esfuerzo). El orden
|
||||||
|
# importa: Edge/Opera/Chrome-en-iOS/Firefox-en-iOS incluyen "Chrome/" o
|
||||||
|
# "Safari/" en su propio User-Agent, así que hay que descartarlos antes de
|
||||||
|
# llegar a esas dos entradas genéricas.
|
||||||
|
_BROWSERS = (
|
||||||
|
('Edg/', 'Edge'),
|
||||||
|
('OPR/', 'Opera'),
|
||||||
|
('CriOS/', 'Chrome'),
|
||||||
|
('FxiOS/', 'Firefox'),
|
||||||
|
('Chrome/', 'Chrome'),
|
||||||
|
('Firefox/', 'Firefox'),
|
||||||
|
('Safari/', 'Safari'),
|
||||||
|
)
|
||||||
|
|
||||||
|
_OPERATING_SYSTEMS = (
|
||||||
|
('Windows', 'Windows'),
|
||||||
|
('Android', 'Android'),
|
||||||
|
('iPhone', 'iPhone'),
|
||||||
|
('iPad', 'iPad'),
|
||||||
|
('Mac OS X', 'macOS'),
|
||||||
|
('Linux', 'Linux'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def describe_device(user_agent):
|
||||||
|
user_agent = (user_agent or '').strip()
|
||||||
|
if not user_agent:
|
||||||
|
return gettext('Dispositivo desconocido')
|
||||||
|
|
||||||
|
browser = next((name for token, name in _BROWSERS if token in user_agent), None)
|
||||||
|
os_name = next((name for token, name in _OPERATING_SYSTEMS if token in user_agent), None)
|
||||||
|
|
||||||
|
if browser and os_name:
|
||||||
|
return f'{browser} ({os_name})'
|
||||||
|
return browser or os_name or gettext('Dispositivo desconocido')
|
||||||
+364
-6
@@ -1,18 +1,36 @@
|
|||||||
|
import io
|
||||||
import json
|
import json
|
||||||
|
import secrets
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
import qrcode
|
||||||
|
import qrcode.image.svg
|
||||||
import weasyprint
|
import weasyprint
|
||||||
|
from django.conf import settings
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth import login, logout, update_session_auth_hash
|
from django.contrib.auth import get_user_model, login, logout, update_session_auth_hash
|
||||||
|
from django.contrib.auth import views as auth_views
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
|
from django.contrib.sites.shortcuts import get_current_site
|
||||||
from django.core.exceptions import PermissionDenied, SuspiciousFileOperation
|
from django.core.exceptions import PermissionDenied, SuspiciousFileOperation
|
||||||
|
from django.core.mail import send_mail
|
||||||
from django.http import HttpResponse, HttpResponseBadRequest, JsonResponse
|
from django.http import HttpResponse, HttpResponseBadRequest, JsonResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.template.loader import render_to_string
|
from django.template.loader import render_to_string
|
||||||
from django.urls import reverse, reverse_lazy
|
from django.urls import reverse, reverse_lazy
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.decorators import method_decorator
|
||||||
|
from django.utils.encoding import force_bytes, force_str
|
||||||
|
from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode
|
||||||
from django.utils.text import get_valid_filename
|
from django.utils.text import get_valid_filename
|
||||||
from django.utils.translation import get_language, gettext_lazy as _
|
from django.utils.translation import get_language
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from django.views.generic import CreateView, DetailView, TemplateView
|
from django.views.generic import CreateView, DetailView, TemplateView
|
||||||
|
from django_otp import user_has_device
|
||||||
|
from django_otp.plugins.otp_static.models import StaticDevice, StaticToken
|
||||||
|
from django_otp.plugins.otp_totp.models import TOTPDevice
|
||||||
|
from django_ratelimit.decorators import ratelimit
|
||||||
|
|
||||||
from crochet.cover_image import InvalidCoverImage, build_cover_image_variants
|
from crochet.cover_image import InvalidCoverImage, build_cover_image_variants
|
||||||
from crochet.forms import (
|
from crochet.forms import (
|
||||||
@@ -21,13 +39,95 @@ from crochet.forms import (
|
|||||||
EmailUpdateForm,
|
EmailUpdateForm,
|
||||||
StyledPasswordChangeForm,
|
StyledPasswordChangeForm,
|
||||||
StyledUserCreationForm,
|
StyledUserCreationForm,
|
||||||
|
TwoFactorDisableForm,
|
||||||
|
TwoFactorTokenForm,
|
||||||
|
TwoFactorVerifyForm,
|
||||||
)
|
)
|
||||||
from crochet.models import Pattern, PatternImage, StitchType
|
from crochet.models import Pattern, PatternImage, StitchType, TwoFactorRememberedDevice
|
||||||
from crochet.pattern_render import render_pattern_html, sanitize_page_settings
|
from crochet.pattern_render import render_pattern_html, sanitize_page_settings
|
||||||
|
from crochet.tokens import email_verification_token
|
||||||
|
from crochet.user_agent import describe_device
|
||||||
|
|
||||||
PATTERN_EMPTY_MESSAGE = _('Este patrón todavía no tiene contenido.')
|
PATTERN_EMPTY_MESSAGE = _('Este patrón todavía no tiene contenido.')
|
||||||
|
|
||||||
|
# Por IP (no por cuenta, para que fallar a propósito no pueda bloquear la
|
||||||
|
# cuenta de otra persona): frena fuerza bruta en login y evita que
|
||||||
|
# registro/recuperación de contraseña se usen para bombardear una bandeja
|
||||||
|
# de entrada ajena o tantear qué emails/usuarios existen. Ver RATELIMIT_ENABLE
|
||||||
|
# en env.py; con la petición número 11 (login) o 6 (registro/reset/código
|
||||||
|
# de verificación en dos pasos) dentro del minuto, django-ratelimit lanza
|
||||||
|
# Ratelimited (subclase de PermissionDenied: Django la convierte en un 403
|
||||||
|
# normal, sin nada más que configurar) en vez de dejar pasar la vista.
|
||||||
|
LOGIN_RATE = '10/m'
|
||||||
|
REGISTER_RATE = '5/m'
|
||||||
|
PASSWORD_RESET_RATE = '5/m'
|
||||||
|
TWO_FACTOR_RATE = '10/m'
|
||||||
|
|
||||||
|
# Cuántos códigos de un solo uso se generan al activar la verificación en
|
||||||
|
# dos pasos (ver TwoFactorSetupView), por si se pierde el acceso a la app
|
||||||
|
# de autenticación.
|
||||||
|
BACKUP_CODE_COUNT = 10
|
||||||
|
|
||||||
|
# "Recordar este dispositivo" (ver TwoFactorVerifyForm/TwoFactorVerifyView):
|
||||||
|
# cuántos días se salta el segundo paso en el mismo navegador tras marcar
|
||||||
|
# la casilla, y el nombre de la cookie (firmada solo por su propia
|
||||||
|
# aleatoriedad -32 bytes de secrets.token_urlsafe-, no con django.core.signing:
|
||||||
|
# es un token opaco que se compara contra TwoFactorRememberedDevice, no un
|
||||||
|
# valor que haga falta decodificar).
|
||||||
|
REMEMBER_DEVICE_DAYS = 30
|
||||||
|
REMEMBER_DEVICE_COOKIE = 'two_factor_remember'
|
||||||
|
|
||||||
|
|
||||||
|
def _is_device_remembered(request, user):
|
||||||
|
token = request.COOKIES.get(REMEMBER_DEVICE_COOKIE)
|
||||||
|
if not token:
|
||||||
|
return False
|
||||||
|
# Aprovecha esta misma consulta (una por intento de login con 2FA
|
||||||
|
# activada) para ir limpiando las filas ya caducadas de este usuario,
|
||||||
|
# en vez de necesitar un comando de limpieza aparte.
|
||||||
|
TwoFactorRememberedDevice.objects.filter(user=user, expires_at__lte=timezone.now()).delete()
|
||||||
|
return TwoFactorRememberedDevice.objects.filter(
|
||||||
|
user=user, token=token, expires_at__gt=timezone.now(),
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def _remembered_devices(user):
|
||||||
|
# Se usa tanto al renderizar ajustes de cuenta como al reintentar
|
||||||
|
# desactivar el 2FA con la contraseña equivocada (TwoFactorDisableView
|
||||||
|
# reutiliza el mismo fragmento): sin esto, la lista desaparecería del
|
||||||
|
# HTMX swap en ese segundo caso, aunque los dispositivos sigan ahí.
|
||||||
|
return TwoFactorRememberedDevice.objects.filter(user=user, expires_at__gt=timezone.now()).order_by('-created_at')
|
||||||
|
|
||||||
|
|
||||||
|
def _render_2fa_status(request, disable_form):
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
'crochet/_account_2fa_status.html',
|
||||||
|
{
|
||||||
|
'two_factor_enabled': user_has_device(request.user),
|
||||||
|
'two_factor_disable_form': disable_form,
|
||||||
|
'remembered_devices': _remembered_devices(request.user),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _remember_device(request, response, user):
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
TwoFactorRememberedDevice.objects.create(
|
||||||
|
user=user,
|
||||||
|
token=token,
|
||||||
|
device_name=describe_device(request.META.get('HTTP_USER_AGENT', '')),
|
||||||
|
expires_at=timezone.now() + timedelta(days=REMEMBER_DEVICE_DAYS),
|
||||||
|
)
|
||||||
|
response.set_cookie(
|
||||||
|
REMEMBER_DEVICE_COOKIE,
|
||||||
|
token,
|
||||||
|
max_age=REMEMBER_DEVICE_DAYS * 24 * 60 * 60,
|
||||||
|
httponly=True,
|
||||||
|
secure=settings.SESSION_COOKIE_SECURE,
|
||||||
|
samesite='Lax',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _stitch_types_data():
|
def _stitch_types_data():
|
||||||
# Catálogo de puntos arrastrables (ver StitchType): es el mismo para
|
# Catálogo de puntos arrastrables (ver StitchType): es el mismo para
|
||||||
@@ -252,17 +352,272 @@ class PatternCoverImageUploadView(View):
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@method_decorator(ratelimit(key='ip', rate=REGISTER_RATE, method='POST', block=True), name='post')
|
||||||
class RegisterView(CreateView):
|
class RegisterView(CreateView):
|
||||||
# UserCreationForm ya pide justo nombre de usuario + contraseña (dos
|
# UserCreationForm ya pide justo nombre de usuario + contraseña (dos
|
||||||
# veces, para confirmarla): no hace falta un formulario propio, solo la
|
# veces, para confirmarla): no hace falta un formulario propio, solo la
|
||||||
# versión con clases de daisyUI en sus widgets (ver crochet/forms.py).
|
# versión con clases de daisyUI en sus widgets (ver crochet/forms.py).
|
||||||
form_class = StyledUserCreationForm
|
form_class = StyledUserCreationForm
|
||||||
template_name = 'registration/register.html'
|
template_name = 'registration/register.html'
|
||||||
success_url = reverse_lazy('crochet:home')
|
success_url = reverse_lazy('crochet:register_done')
|
||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
|
# Inactivo hasta que confirme el email (ver _send_verification_email
|
||||||
|
# y VerifyEmailView): ModelBackend.authenticate() ya rechaza a los
|
||||||
|
# usuarios inactivos, así que no hace falta ningún chequeo aparte
|
||||||
|
# para bloquearles el login mientras tanto.
|
||||||
|
form.instance.is_active = False
|
||||||
response = super().form_valid(form)
|
response = super().form_valid(form)
|
||||||
login(self.request, self.object)
|
self._send_verification_email()
|
||||||
|
return response
|
||||||
|
|
||||||
|
def _send_verification_email(self):
|
||||||
|
user = self.object
|
||||||
|
current_site = get_current_site(self.request)
|
||||||
|
context = {
|
||||||
|
'user': user,
|
||||||
|
'site_name': settings.SITE_NAME,
|
||||||
|
'domain': current_site.domain,
|
||||||
|
'protocol': 'https' if self.request.is_secure() else 'http',
|
||||||
|
'uid': urlsafe_base64_encode(force_bytes(user.pk)),
|
||||||
|
'token': email_verification_token.make_token(user),
|
||||||
|
}
|
||||||
|
subject = ''.join(render_to_string('crochet/email/verify_email_subject.txt', context).splitlines())
|
||||||
|
send_mail(
|
||||||
|
subject=subject,
|
||||||
|
message=render_to_string('crochet/email/verify_email.txt', context),
|
||||||
|
from_email=None,
|
||||||
|
recipient_list=[user.email],
|
||||||
|
html_message=render_to_string('crochet/email/verify_email.html', context),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterDoneView(TemplateView):
|
||||||
|
template_name = 'registration/register_done.html'
|
||||||
|
|
||||||
|
|
||||||
|
class VerifyEmailView(View):
|
||||||
|
def get(self, request, uidb64, token):
|
||||||
|
user = self._get_user(uidb64)
|
||||||
|
|
||||||
|
if user is not None and email_verification_token.check_token(user, token):
|
||||||
|
user.is_active = True
|
||||||
|
user.save(update_fields=['is_active'])
|
||||||
|
login(request, user)
|
||||||
|
messages.success(request, _('Tu cuenta ha sido verificada. ¡Bienvenido/a!'))
|
||||||
|
return redirect('crochet:home')
|
||||||
|
|
||||||
|
return render(request, 'registration/verify_email_invalid.html', status=400)
|
||||||
|
|
||||||
|
def _get_user(self, uidb64):
|
||||||
|
User = get_user_model()
|
||||||
|
try:
|
||||||
|
uid = force_str(urlsafe_base64_decode(uidb64))
|
||||||
|
return User.objects.get(pk=uid)
|
||||||
|
except (TypeError, ValueError, OverflowError, User.DoesNotExist):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@method_decorator(ratelimit(key='ip', rate=LOGIN_RATE, method='POST', block=True), name='post')
|
||||||
|
class RateLimitedLoginView(auth_views.LoginView):
|
||||||
|
# AuthenticationForm ya deja al usuario autenticado por contraseña en
|
||||||
|
# form.get_user(); si además tiene la verificación en dos pasos activada
|
||||||
|
# (ver TwoFactorSetupView), no se llama a login() todavía -eso dejaría
|
||||||
|
# la sesión ya completamente autenticada sin haber pedido el código-,
|
||||||
|
# sino que se guarda su id en sesión (sin loguearlo) y se le manda a
|
||||||
|
# completar el segundo paso en TwoFactorVerifyView. Salvo que este
|
||||||
|
# navegador ya esté recordado (ver TwoFactorVerifyForm.remember_device):
|
||||||
|
# entonces se salta directamente el segundo paso.
|
||||||
|
def form_valid(self, form):
|
||||||
|
user = form.get_user()
|
||||||
|
if user_has_device(user) and not _is_device_remembered(self.request, user):
|
||||||
|
self.request.session['pre_2fa_user_id'] = user.pk
|
||||||
|
self.request.session['pre_2fa_backend'] = user.backend
|
||||||
|
return redirect('crochet:two_factor_verify')
|
||||||
|
return super().form_valid(form)
|
||||||
|
|
||||||
|
|
||||||
|
@method_decorator(ratelimit(key='ip', rate=TWO_FACTOR_RATE, method='POST', block=True), name='post')
|
||||||
|
class TwoFactorVerifyView(View):
|
||||||
|
# Segundo paso del login (ver RateLimitedLoginView.form_valid): quien
|
||||||
|
# llega aquí ya ha demostrado conocer la contraseña, pero la sesión
|
||||||
|
# todavía no está autenticada -"pre_2fa_user_id" es solo un id guardado
|
||||||
|
# a mano en la sesión, no el resultado de login()-, así que no hay nada
|
||||||
|
# que proteger con LoginRequiredMixin.
|
||||||
|
def get(self, request):
|
||||||
|
if self._pending_user() is None:
|
||||||
|
return redirect('crochet:login')
|
||||||
|
return render(request, 'registration/two_factor_verify.html', {'form': TwoFactorVerifyForm()})
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
user = self._pending_user()
|
||||||
|
if user is None:
|
||||||
|
return redirect('crochet:login')
|
||||||
|
|
||||||
|
form = TwoFactorVerifyForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
device = self._verify(user, form.cleaned_data['token'])
|
||||||
|
if device is not None:
|
||||||
|
del request.session['pre_2fa_user_id']
|
||||||
|
user.backend = request.session.pop('pre_2fa_backend')
|
||||||
|
# user.otp_device: django-otp engancha esto automáticamente
|
||||||
|
# a la sesión en cuanto login() dispara la señal
|
||||||
|
# user_logged_in (ver django_otp/__init__.py), sin tener que
|
||||||
|
# llamar aparte a django_otp.login().
|
||||||
|
user.otp_device = device
|
||||||
|
login(request, user)
|
||||||
|
response = redirect('crochet:home')
|
||||||
|
if form.cleaned_data['remember_device']:
|
||||||
|
_remember_device(request, response, user)
|
||||||
|
return response
|
||||||
|
form.add_error('token', _('Código incorrecto.'))
|
||||||
|
|
||||||
|
return render(request, 'registration/two_factor_verify.html', {'form': form})
|
||||||
|
|
||||||
|
def _pending_user(self):
|
||||||
|
user_id = self.request.session.get('pre_2fa_user_id')
|
||||||
|
if not user_id:
|
||||||
|
return None
|
||||||
|
User = get_user_model()
|
||||||
|
try:
|
||||||
|
return User.objects.get(pk=user_id, is_active=True)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _verify(self, user, token):
|
||||||
|
# Se intenta primero el dispositivo TOTP (el código de 6 dígitos de
|
||||||
|
# la app) y solo si no hay o falla, los códigos de respaldo -no
|
||||||
|
# match_token() (recorre todos los dispositivos del usuario): según
|
||||||
|
# su propia documentación, un acierto contra uno cuenta como fallo
|
||||||
|
# frente a los demás a efectos de "throttling", lo que penalizaría
|
||||||
|
# sin motivo los códigos de respaldo cada vez que se usa la app
|
||||||
|
# normal.
|
||||||
|
totp_device = TOTPDevice.objects.filter(user=user, confirmed=True).first()
|
||||||
|
if totp_device is not None and totp_device.verify_token(token):
|
||||||
|
return totp_device
|
||||||
|
|
||||||
|
static_device = StaticDevice.objects.filter(user=user, confirmed=True).first()
|
||||||
|
if static_device is not None and static_device.verify_token(token):
|
||||||
|
return static_device
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@method_decorator(ratelimit(key='ip', rate=PASSWORD_RESET_RATE, method='POST', block=True), name='post')
|
||||||
|
class RateLimitedPasswordResetView(auth_views.PasswordResetView):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TwoFactorSetupView(LoginRequiredMixin, View):
|
||||||
|
def get(self, request):
|
||||||
|
if user_has_device(request.user):
|
||||||
|
return redirect('crochet:account_settings')
|
||||||
|
device = self._pending_device(request.user)
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
'crochet/two_factor_setup.html',
|
||||||
|
{
|
||||||
|
'qr_svg': self._qr_svg(device),
|
||||||
|
'setup_form': TwoFactorTokenForm(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
if user_has_device(request.user):
|
||||||
|
return redirect('crochet:account_settings')
|
||||||
|
device = self._pending_device(request.user)
|
||||||
|
form = TwoFactorTokenForm(request.POST)
|
||||||
|
if form.is_valid() and device.verify_token(form.cleaned_data['token']):
|
||||||
|
device.confirmed = True
|
||||||
|
device.save(update_fields=['confirmed'])
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
'crochet/two_factor_setup.html',
|
||||||
|
{
|
||||||
|
'backup_codes': self._generate_backup_codes(request.user),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
form.add_error('token', _('Código incorrecto.'))
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
'crochet/two_factor_setup.html',
|
||||||
|
{
|
||||||
|
'qr_svg': self._qr_svg(device),
|
||||||
|
'setup_form': form,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _pending_device(self, user):
|
||||||
|
# Un único dispositivo TOTP sin confirmar por usuario: si recarga o
|
||||||
|
# falla el código y lo intenta de nuevo, sigue siendo el mismo (y
|
||||||
|
# el mismo QR), no uno nuevo cada vez.
|
||||||
|
device, _created = TOTPDevice.objects.get_or_create(
|
||||||
|
user=user,
|
||||||
|
confirmed=False,
|
||||||
|
defaults={'name': 'default'},
|
||||||
|
)
|
||||||
|
return device
|
||||||
|
|
||||||
|
def _qr_svg(self, device):
|
||||||
|
# django_otp.qr.write_qrcode_image() usa qrcode.image.svg.SvgImage,
|
||||||
|
# que dibuja cada módulo como un <svg:rect> con el prefijo de
|
||||||
|
# espacio de nombres "svg:". Eso es XML válido, pero el HTML
|
||||||
|
# insertado con {{ qr_svg|safe }} lo procesa el parser de HTML, que
|
||||||
|
# no resuelve prefijos de espacio de nombres: "svg:rect" no es una
|
||||||
|
# etiqueta reconocida y no se pinta nada. SvgPathImage genera un
|
||||||
|
# único <path> sin prefijo, que sí renderiza bien inline.
|
||||||
|
img = qrcode.make(device.config_url, image_factory=qrcode.image.svg.SvgPathImage)
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
img.save(buffer)
|
||||||
|
return buffer.getvalue().decode('utf-8')
|
||||||
|
|
||||||
|
def _generate_backup_codes(self, user):
|
||||||
|
# Se regeneran de cero (no se añaden a las que hubiera): solo se
|
||||||
|
# llega aquí justo al confirmar el alta, cuando no puede haber
|
||||||
|
# ninguna todavía.
|
||||||
|
StaticDevice.objects.filter(user=user).delete()
|
||||||
|
device = StaticDevice.objects.create(user=user, name='backup')
|
||||||
|
codes = [StaticToken.random_token() for _ in range(BACKUP_CODE_COUNT)]
|
||||||
|
StaticToken.objects.bulk_create(StaticToken(device=device, token=code) for code in codes)
|
||||||
|
return codes
|
||||||
|
|
||||||
|
|
||||||
|
class TwoFactorDisableView(LoginRequiredMixin, View):
|
||||||
|
def post(self, request):
|
||||||
|
form = TwoFactorDisableForm(request.POST, user=request.user)
|
||||||
|
disabled = form.is_valid()
|
||||||
|
if disabled:
|
||||||
|
TOTPDevice.objects.filter(user=request.user).delete()
|
||||||
|
StaticDevice.objects.filter(user=request.user).delete()
|
||||||
|
# Si se reactiva más adelante, no debería quedar ningún
|
||||||
|
# navegador saltándose el segundo paso de una configuración de
|
||||||
|
# 2FA que ya no existe (ver TwoFactorRememberedDevice).
|
||||||
|
TwoFactorRememberedDevice.objects.filter(user=request.user).delete()
|
||||||
|
form = TwoFactorDisableForm(user=request.user)
|
||||||
|
|
||||||
|
response = _render_2fa_status(request, form)
|
||||||
|
if disabled:
|
||||||
|
response.delete_cookie(REMEMBER_DEVICE_COOKIE)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
class TwoFactorForgetDeviceView(LoginRequiredMixin, View):
|
||||||
|
# Botón "🗑️" por dispositivo en la lista de recordados (ver
|
||||||
|
# _account_2fa_status.html): no pide contraseña -a diferencia de
|
||||||
|
# desactivar el 2FA entero-, es un modal de confirmación simple, el
|
||||||
|
# riesgo de olvidar uno de tus propios dispositivos por error es bajo.
|
||||||
|
def post(self, request, device_id):
|
||||||
|
device = get_object_or_404(TwoFactorRememberedDevice, pk=device_id, user=request.user)
|
||||||
|
token = device.token
|
||||||
|
device.delete()
|
||||||
|
|
||||||
|
response = _render_2fa_status(request, TwoFactorDisableForm(user=request.user))
|
||||||
|
# Si el dispositivo borrado es este mismo navegador, que dicho
|
||||||
|
# navegador también deje de saltarse el segundo paso ahora mismo,
|
||||||
|
# no solo la próxima vez que alguien revise la lista.
|
||||||
|
if request.COOKIES.get(REMEMBER_DEVICE_COOKIE) == token:
|
||||||
|
response.delete_cookie(REMEMBER_DEVICE_COOKIE)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@@ -277,6 +632,9 @@ class AccountSettingsView(LoginRequiredMixin, TemplateView):
|
|||||||
user=self.request.user, initial={'display_name': self.request.user.first_name},
|
user=self.request.user, initial={'display_name': self.request.user.first_name},
|
||||||
)
|
)
|
||||||
context['delete_form'] = AccountDeleteForm(user=self.request.user)
|
context['delete_form'] = AccountDeleteForm(user=self.request.user)
|
||||||
|
context['two_factor_enabled'] = user_has_device(self.request.user)
|
||||||
|
context['two_factor_disable_form'] = TwoFactorDisableForm(user=self.request.user)
|
||||||
|
context['remembered_devices'] = _remembered_devices(self.request.user)
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:18-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: django
|
||||||
|
POSTGRES_PASSWORD: django
|
||||||
|
POSTGRES_DB: django
|
||||||
|
ports:
|
||||||
|
- '6543:5432'
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql
|
||||||
|
|
||||||
|
garage:
|
||||||
|
image: dxflrs/garage:v2.3.0
|
||||||
|
restart: unless-stopped
|
||||||
|
command: /garage server --single-node --default-bucket
|
||||||
|
environment:
|
||||||
|
GARAGE_DEFAULT_ACCESS_KEY: GKa1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6
|
||||||
|
GARAGE_DEFAULT_SECRET_KEY: f0e1d2c3b4a5968778695a4b3c2d1e0ff0e1d2c3b4a5968778695a4b3c2d1e0f
|
||||||
|
GARAGE_DEFAULT_BUCKET: default-bucket
|
||||||
|
ports:
|
||||||
|
- '3900:3900'
|
||||||
|
volumes:
|
||||||
|
- ./garage.toml:/etc/garage.toml:ro
|
||||||
|
- garage_meta:/var/lib/garage/meta
|
||||||
|
- garage_data:/var/lib/garage/data
|
||||||
|
|
||||||
|
garage-webui:
|
||||||
|
image: khairul169/garage-webui:1.1.0
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- garage
|
||||||
|
environment:
|
||||||
|
API_BASE_URL: http://garage:3903
|
||||||
|
S3_ENDPOINT_URL: http://garage:3900
|
||||||
|
# credenciales por defecto: usuario "admin", contraseña "garage-dev"
|
||||||
|
AUTH_USER_PASS: "admin:$2y$10$3vjqML.0sDjnnxgok9LYo.j5zGINkMwMFjzsFA9DQqZC55OVSd77K"
|
||||||
|
ports:
|
||||||
|
- '3909:3909'
|
||||||
|
volumes:
|
||||||
|
- ./garage.toml:/etc/garage.toml:ro
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
garage_meta:
|
||||||
|
garage_data:
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
metadata_dir = "/var/lib/garage/meta"
|
||||||
|
data_dir = "/var/lib/garage/data"
|
||||||
|
db_engine = "sqlite"
|
||||||
|
|
||||||
|
replication_factor = 1
|
||||||
|
|
||||||
|
rpc_bind_addr = "[::]:3901"
|
||||||
|
rpc_public_addr = "garage:3901"
|
||||||
|
rpc_secret = "f0e1d2c3b4a5968778695a4b3c2d1e0ff0e1d2c3b4a5968778695a4b3c2d1e0f"
|
||||||
|
|
||||||
|
[s3_api]
|
||||||
|
s3_region = "garage"
|
||||||
|
api_bind_addr = "[::]:3900"
|
||||||
|
|
||||||
|
[admin]
|
||||||
|
api_bind_addr = "[::]:3903"
|
||||||
|
admin_token = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
|
||||||
|
metrics_token = "Hx4dHBsaGRgXFhUUExIREA8ODQwLCgkIBwYFBAMCAQA="
|
||||||
+37
-1
@@ -11,12 +11,15 @@ dependencies = [
|
|||||||
"django-extensions>=4.1",
|
"django-extensions>=4.1",
|
||||||
"django-filter>=26.1",
|
"django-filter>=26.1",
|
||||||
"django-htmx>=1.28.0",
|
"django-htmx>=1.28.0",
|
||||||
|
"django-otp>=1.7.3",
|
||||||
|
"django-ratelimit>=4.1.0",
|
||||||
"django-silk>=5.5.0",
|
"django-silk>=5.5.0",
|
||||||
"django-storages>=1.14.6",
|
"django-storages[s3]>=1.14.6",
|
||||||
"ipython>=9.15.0",
|
"ipython>=9.15.0",
|
||||||
"pillow>=12.3.0",
|
"pillow>=12.3.0",
|
||||||
"psycopg[binary]>=3.3.4",
|
"psycopg[binary]>=3.3.4",
|
||||||
"pytest-cov>=7.1.0",
|
"pytest-cov>=7.1.0",
|
||||||
|
"qrcode>=8.2",
|
||||||
"uvicorn>=0.51.0",
|
"uvicorn>=0.51.0",
|
||||||
"watchman>=0.0.1",
|
"watchman>=0.0.1",
|
||||||
"weasyprint>=69.0",
|
"weasyprint>=69.0",
|
||||||
@@ -29,9 +32,42 @@ dev = [
|
|||||||
"django-browser-reload>=1.21.0",
|
"django-browser-reload>=1.21.0",
|
||||||
"django-debug-toolbar>=7.0.0",
|
"django-debug-toolbar>=7.0.0",
|
||||||
"django-watchfiles>=1.4.0",
|
"django-watchfiles>=1.4.0",
|
||||||
|
"pre-commit>=4.6.2",
|
||||||
"pytest>=9.1.1",
|
"pytest>=9.1.1",
|
||||||
"pytest-django>=4.12.0",
|
"pytest-django>=4.12.0",
|
||||||
|
"ruff>=0.16.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
DJANGO_SETTINGS_MODULE = "config.settings"
|
DJANGO_SETTINGS_MODULE = "config.settings"
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 120
|
||||||
|
target-version = "py313"
|
||||||
|
extend-exclude = ["*/migrations/*"]
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "W", "I", "UP", "B", "DJ"]
|
||||||
|
# B905 (zip sin strict=): no aplica a Python 3.13 sin más contexto, ruidoso
|
||||||
|
# más que útil en este proyecto.
|
||||||
|
ignore = ["B905"]
|
||||||
|
|
||||||
|
[tool.ruff.lint.isort]
|
||||||
|
known-first-party = ["crochet", "config"]
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
# base.py consume a propósito los nombres de env.py vía `from
|
||||||
|
# config.settings.env import *` (ver env.py): ruff no puede saber que
|
||||||
|
# vienen de ahí y los marca F403/F405, pero es justo el patrón que separa
|
||||||
|
# "qué lee del entorno" de "qué depende de ello", no un descuido.
|
||||||
|
"config/settings/base.py" = ["F403", "F405"]
|
||||||
|
# __init__.py existe solo para reexportar env.py + base.py como un único
|
||||||
|
# módulo "config.settings" (ver DJANGO_SETTINGS_MODULE en pyproject.toml):
|
||||||
|
# su única línea de trabajo son estos dos star-imports.
|
||||||
|
"config/settings/__init__.py" = ["F403"]
|
||||||
|
|
||||||
|
[tool.ruff.format]
|
||||||
|
# El proyecto entero usa comillas simples; sin esto, ruff format reescribe
|
||||||
|
# todas las cadenas a comillas dobles (su estilo por defecto, tipo Black) en
|
||||||
|
# cuanto se ejecute una vez.
|
||||||
|
quote-style = "single"
|
||||||
|
|||||||
@@ -32,6 +32,34 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/d4/2b/04b8a15f3a1c77bc79ddf5c73875327f34b4fa75982df2b76e45e402d364/asttokens-3.0.2-py3-none-any.whl", hash = "sha256:9da13157f5b28becde0bd374fc677dcd3c290614264eff096f167c469cd9f933", size = 28702, upload-time = "2026-07-12T03:31:47.542Z" },
|
{ url = "https://files.pythonhosted.org/packages/d4/2b/04b8a15f3a1c77bc79ddf5c73875327f34b4fa75982df2b76e45e402d364/asttokens-3.0.2-py3-none-any.whl", hash = "sha256:9da13157f5b28becde0bd374fc677dcd3c290614264eff096f167c469cd9f933", size = 28702, upload-time = "2026-07-12T03:31:47.542Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "boto3"
|
||||||
|
version = "1.43.95"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "botocore" },
|
||||||
|
{ name = "jmespath" },
|
||||||
|
{ name = "s3transfer" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/eb/ed/9d4d4e7d4b874c16f7b3a886efe3e45801e0f9ebb60058adf1bb69cb2073/boto3-1.43.95.tar.gz", hash = "sha256:9d71f299111e1f4e8c28f573a1b7c0555fe40d2147fe9bef852a02bd57cbde60", size = 112666, upload-time = "2026-09-15T19:23:49.395Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/66/6e/9c6fb58b1cdddf13b3e9edc94e0c6e08fff456355f6e097684d6117aa8d4/boto3-1.43.95-py3-none-any.whl", hash = "sha256:c906921c4f9ab41e9587af6586f072c8f2be6979e8bde2ec26aa443bb1745019", size = 140026, upload-time = "2026-09-15T19:23:47.563Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "botocore"
|
||||||
|
version = "1.43.95"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "jmespath" },
|
||||||
|
{ name = "python-dateutil" },
|
||||||
|
{ name = "urllib3" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/db/2c/0031468b521eefed325a3da2d6576e068aab95837af0c96effea6438851c/botocore-1.43.95.tar.gz", hash = "sha256:779588da32bd48a7bb0c097da4bcb747260e86d2bfc507369dca56f1450e0722", size = 16106887, upload-time = "2026-09-15T19:23:44.23Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/77/7ce7c937b31903f1402e478fdc97f98e727656fcea3cb54e74f1ca55793e/botocore-1.43.95-py3-none-any.whl", hash = "sha256:0fda26d16c7c7bf7082c421390a817b696f98a43d3da7c43dbbb093f76662876", size = 15800616, upload-time = "2026-09-15T19:23:41.228Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "brotli"
|
name = "brotli"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
@@ -154,6 +182,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" },
|
{ url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfgv"
|
||||||
|
version = "3.5.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "click"
|
name = "click"
|
||||||
version = "8.4.2"
|
version = "8.4.2"
|
||||||
@@ -240,12 +277,15 @@ dependencies = [
|
|||||||
{ name = "django-extensions" },
|
{ name = "django-extensions" },
|
||||||
{ name = "django-filter" },
|
{ name = "django-filter" },
|
||||||
{ name = "django-htmx" },
|
{ name = "django-htmx" },
|
||||||
|
{ name = "django-otp" },
|
||||||
|
{ name = "django-ratelimit" },
|
||||||
{ name = "django-silk" },
|
{ name = "django-silk" },
|
||||||
{ name = "django-storages" },
|
{ name = "django-storages", extra = ["s3"] },
|
||||||
{ name = "ipython" },
|
{ name = "ipython" },
|
||||||
{ name = "pillow" },
|
{ name = "pillow" },
|
||||||
{ name = "psycopg", extra = ["binary"] },
|
{ name = "psycopg", extra = ["binary"] },
|
||||||
{ name = "pytest-cov" },
|
{ name = "pytest-cov" },
|
||||||
|
{ name = "qrcode" },
|
||||||
{ name = "uvicorn" },
|
{ name = "uvicorn" },
|
||||||
{ name = "watchman" },
|
{ name = "watchman" },
|
||||||
{ name = "weasyprint" },
|
{ name = "weasyprint" },
|
||||||
@@ -258,8 +298,10 @@ dev = [
|
|||||||
{ name = "django-browser-reload" },
|
{ name = "django-browser-reload" },
|
||||||
{ name = "django-debug-toolbar" },
|
{ name = "django-debug-toolbar" },
|
||||||
{ name = "django-watchfiles" },
|
{ name = "django-watchfiles" },
|
||||||
|
{ name = "pre-commit" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "pytest-django" },
|
{ name = "pytest-django" },
|
||||||
|
{ name = "ruff" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
@@ -270,12 +312,15 @@ requires-dist = [
|
|||||||
{ name = "django-extensions", specifier = ">=4.1" },
|
{ name = "django-extensions", specifier = ">=4.1" },
|
||||||
{ name = "django-filter", specifier = ">=26.1" },
|
{ name = "django-filter", specifier = ">=26.1" },
|
||||||
{ name = "django-htmx", specifier = ">=1.28.0" },
|
{ name = "django-htmx", specifier = ">=1.28.0" },
|
||||||
|
{ name = "django-otp", specifier = ">=1.7.3" },
|
||||||
|
{ name = "django-ratelimit", specifier = ">=4.1.0" },
|
||||||
{ name = "django-silk", specifier = ">=5.5.0" },
|
{ name = "django-silk", specifier = ">=5.5.0" },
|
||||||
{ name = "django-storages", specifier = ">=1.14.6" },
|
{ name = "django-storages", extras = ["s3"], specifier = ">=1.14.6" },
|
||||||
{ name = "ipython", specifier = ">=9.15.0" },
|
{ name = "ipython", specifier = ">=9.15.0" },
|
||||||
{ name = "pillow", specifier = ">=12.3.0" },
|
{ name = "pillow", specifier = ">=12.3.0" },
|
||||||
{ name = "psycopg", extras = ["binary"], specifier = ">=3.3.4" },
|
{ name = "psycopg", extras = ["binary"], specifier = ">=3.3.4" },
|
||||||
{ name = "pytest-cov", specifier = ">=7.1.0" },
|
{ name = "pytest-cov", specifier = ">=7.1.0" },
|
||||||
|
{ name = "qrcode", specifier = ">=8.2" },
|
||||||
{ name = "uvicorn", specifier = ">=0.51.0" },
|
{ name = "uvicorn", specifier = ">=0.51.0" },
|
||||||
{ name = "watchman", specifier = ">=0.0.1" },
|
{ name = "watchman", specifier = ">=0.0.1" },
|
||||||
{ name = "weasyprint", specifier = ">=69.0" },
|
{ name = "weasyprint", specifier = ">=69.0" },
|
||||||
@@ -288,8 +333,10 @@ dev = [
|
|||||||
{ name = "django-browser-reload", specifier = ">=1.21.0" },
|
{ name = "django-browser-reload", specifier = ">=1.21.0" },
|
||||||
{ name = "django-debug-toolbar", specifier = ">=7.0.0" },
|
{ name = "django-debug-toolbar", specifier = ">=7.0.0" },
|
||||||
{ name = "django-watchfiles", specifier = ">=1.4.0" },
|
{ name = "django-watchfiles", specifier = ">=1.4.0" },
|
||||||
|
{ name = "pre-commit", specifier = ">=4.6.2" },
|
||||||
{ name = "pytest", specifier = ">=9.1.1" },
|
{ name = "pytest", specifier = ">=9.1.1" },
|
||||||
{ name = "pytest-django", specifier = ">=4.12.0" },
|
{ name = "pytest-django", specifier = ">=4.12.0" },
|
||||||
|
{ name = "ruff", specifier = ">=0.16.7" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -314,6 +361,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" },
|
{ url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "distlib"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "django"
|
name = "django"
|
||||||
version = "6.0.7"
|
version = "6.0.7"
|
||||||
@@ -413,6 +469,27 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/20/d5/71b3a206f7a2135e2ab4a789be143a00222b52d22729c84f480915049e94/django_htmx-1.28.0-py3-none-any.whl", hash = "sha256:e6911acccf3e7905d511c4fd6ab86fe06572205e80a0f32ca7aa94a115ae0619", size = 98588, upload-time = "2026-07-12T22:19:18.225Z" },
|
{ url = "https://files.pythonhosted.org/packages/20/d5/71b3a206f7a2135e2ab4a789be143a00222b52d22729c84f480915049e94/django_htmx-1.28.0-py3-none-any.whl", hash = "sha256:e6911acccf3e7905d511c4fd6ab86fe06572205e80a0f32ca7aa94a115ae0619", size = 98588, upload-time = "2026-07-12T22:19:18.225Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "django-otp"
|
||||||
|
version = "1.7.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "django" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/08/88/f98f860f5c209bfa1980c63ff38128b50166bf396d28c60d93c42bdb08de/django_otp-1.7.3.tar.gz", hash = "sha256:3ce501173bf1b936146ffe5ff0cfdae1a0c7eb46cce1e9e3fdddd984d993c74f", size = 78996, upload-time = "2026-09-06T16:44:21.098Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/a5/bb9d7b83b0ebf62603d3ff707cf492f75ec60077495b885ecbdf09774f67/django_otp-1.7.3-py3-none-any.whl", hash = "sha256:7d52fb0f76c4c1c86cbc71c196d3f79941b0b84aff980e45193bef89a70a7a12", size = 73702, upload-time = "2026-09-06T16:44:19.807Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "django-ratelimit"
|
||||||
|
version = "4.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/6f/8f/94038fe739b095aca3e4708ecc8a4e77f1fcfd87bed5d6baff43d4c80bc4/django-ratelimit-4.1.0.tar.gz", hash = "sha256:555943b283045b917ad59f196829530d63be2a39adb72788d985b90c81ba808b", size = 11551, upload-time = "2023-07-24T20:34:32.374Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/78/2c59b30cd8bc8068d02349acb6aeed5c4e05eb01cdf2107ccd76f2e81487/django_ratelimit-4.1.0-py2.py3-none-any.whl", hash = "sha256:d047a31cf94d83ef1465d7543ca66c6fc16695559b5f8d814d1b51df15110b92", size = 11608, upload-time = "2023-07-24T20:34:31.362Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "django-silk"
|
name = "django-silk"
|
||||||
version = "5.5.0"
|
version = "5.5.0"
|
||||||
@@ -439,6 +516,11 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/1f/21/3cedee63417bc5553eed0c204be478071c9ab208e5e259e97287590194f1/django_storages-1.14.6-py3-none-any.whl", hash = "sha256:11b7b6200e1cb5ffcd9962bd3673a39c7d6a6109e8096f0e03d46fab3d3aabd9", size = 33095, upload-time = "2025-04-02T02:34:53.291Z" },
|
{ url = "https://files.pythonhosted.org/packages/1f/21/3cedee63417bc5553eed0c204be478071c9ab208e5e259e97287590194f1/django_storages-1.14.6-py3-none-any.whl", hash = "sha256:11b7b6200e1cb5ffcd9962bd3673a39c7d6a6109e8096f0e03d46fab3d3aabd9", size = 33095, upload-time = "2025-04-02T02:34:53.291Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
s3 = [
|
||||||
|
{ name = "boto3" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "django-watchfiles"
|
name = "django-watchfiles"
|
||||||
version = "1.4.0"
|
version = "1.4.0"
|
||||||
@@ -461,6 +543,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" },
|
{ url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "filelock"
|
||||||
|
version = "3.32.7"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/0f/59/e19834834cb01a32febfbb0f8a23a9088088f5d45991824ff2bc3b5e8acb/filelock-3.32.7.tar.gz", hash = "sha256:37b8a3d9811b0f9aef7e5ec5c71bb320de52df51e6ca9bcd6f5ad81187660da7", size = 225154, upload-time = "2026-09-16T00:24:20.907Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/15/df/31098c5aeb4d966b553641472bd55fcf5fdfac953549894b8a765ba44e91/filelock-3.32.7-py3-none-any.whl", hash = "sha256:65ff0d0190ea42038b32bda4b77834fb05be2cad4c5b9b01aa4dfb3614536e52", size = 100157, upload-time = "2026-09-16T00:24:19.543Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fonttools"
|
name = "fonttools"
|
||||||
version = "4.63.0"
|
version = "4.63.0"
|
||||||
@@ -519,6 +610,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "identify"
|
||||||
|
version = "2.6.19"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
version = "3.18"
|
version = "3.18"
|
||||||
@@ -583,6 +683,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67", size = 4884812, upload-time = "2026-05-01T23:38:43.919Z" },
|
{ url = "https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67", size = 4884812, upload-time = "2026-05-01T23:38:43.919Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jmespath"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matplotlib-inline"
|
name = "matplotlib-inline"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -595,6 +704,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" },
|
{ url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nodeenv"
|
||||||
|
version = "1.10.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "packaging"
|
name = "packaging"
|
||||||
version = "26.2"
|
version = "26.2"
|
||||||
@@ -687,6 +805,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" },
|
{ url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "platformdirs"
|
||||||
|
version = "4.11.8"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/53/18/f3bb8ef0d3b930692343da8aa4d3cbcd6749477c053959395ac81965a6e9/platformdirs-4.11.8.tar.gz", hash = "sha256:f23abafea7dd4276d1f29104b83598d7dcc567cafd07c9c951e66665645437fc", size = 37182, upload-time = "2026-09-08T22:20:42.866Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/e1/5b7b8bbb55084d1425bcb9bc823ff519e1b2be05f6ebb0089e2eacc38413/platformdirs-4.11.8-py3-none-any.whl", hash = "sha256:52f2f181bbfde907966932cc8312d967d02976422d66d537ea16092b8e291081", size = 24027, upload-time = "2026-09-08T22:20:41.537Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pluggy"
|
name = "pluggy"
|
||||||
version = "1.6.0"
|
version = "1.6.0"
|
||||||
@@ -696,6 +823,22 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pre-commit"
|
||||||
|
version = "4.6.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "cfgv" },
|
||||||
|
{ name = "identify" },
|
||||||
|
{ name = "nodeenv" },
|
||||||
|
{ name = "pyyaml" },
|
||||||
|
{ name = "virtualenv" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/74/89/1f3e8e1fc3e97de0fa963495832f581f025f29471602a309e48808244292/pre_commit-4.6.2.tar.gz", hash = "sha256:8f5d7bfb021ecdbcd9d49d89847082dd24172ccde534390081a679ad046e2441", size = 198670, upload-time = "2026-08-10T22:07:18.421Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/e2/bbb7129c9e7999a6b8ee9cca3b66486c25c423ab5a75f34071798b74ce94/pre_commit-4.6.2-py2.py3-none-any.whl", hash = "sha256:e2dde9a75d3bce11bd3831c26d134df00a2803c1d818be6a0383c3dcda25dc4e", size = 226202, upload-time = "2026-08-10T22:07:16.942Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "prompt-toolkit"
|
name = "prompt-toolkit"
|
||||||
version = "3.0.52"
|
version = "3.0.52"
|
||||||
@@ -878,6 +1021,124 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/83/a5/41d091f697c09609e7ef1d5d61925494e0454ebf51de7de05f0f0a728f1d/pytest_django-4.12.0-py3-none-any.whl", hash = "sha256:3ff300c49f8350ba2953b90297d23bf5f589db69545f56f1ec5f8cff5da83e85", size = 26123, upload-time = "2026-02-14T18:40:47.381Z" },
|
{ url = "https://files.pythonhosted.org/packages/83/a5/41d091f697c09609e7ef1d5d61925494e0454ebf51de7de05f0f0a728f1d/pytest_django-4.12.0-py3-none-any.whl", hash = "sha256:3ff300c49f8350ba2953b90297d23bf5f589db69545f56f1ec5f8cff5da83e85", size = 26123, upload-time = "2026-02-14T18:40:47.381Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-dateutil"
|
||||||
|
version = "2.9.0.post0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "six" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-discovery"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "filelock" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/91/96/0f93e27c9f60a650838f2118159aa115fd5732c0716247917b7ba7ede665/python_discovery-1.6.0.tar.gz", hash = "sha256:6393b4eae1be8b2182670635e7baff89ac21cb9f8e86fd1ff40c7b1144febb4c", size = 82849, upload-time = "2026-08-28T17:30:02.366Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/43/5e/21abf578182fb15006a57faf3711a1e659e29d600d19b6e557eae908c81d/python_discovery-1.6.0-py3-none-any.whl", hash = "sha256:d4e244cf17b8b29819ed78003d55fbacf86eda23425b075454fff9271b79377a", size = 38451, upload-time = "2026-08-28T17:30:01.236Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyyaml"
|
||||||
|
version = "6.0.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "qrcode"
|
||||||
|
version = "8.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/8f/b2/7fc2931bfae0af02d5f53b174e9cf701adbb35f39d69c2af63d4a39f81a9/qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c", size = 43317, upload-time = "2025-05-01T15:44:24.726Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986, upload-time = "2025-05-01T15:44:22.781Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ruff"
|
||||||
|
version = "0.16.7"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/82/bb/5a449b9162e49b139d72f61672bd3ac1d790221f796d3304e2241fff4c58/ruff-0.16.7.tar.gz", hash = "sha256:5f71d004ac1263b22fa39462ac5ae618a4b77d58981af2cc79bf79a29c12b1a6", size = 4924184, upload-time = "2026-09-10T18:04:06.336Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/b2/c80aeeb7f9e469c0d63a85d2f1ab6e1ebfbe10ea7a8d2438b7e09e3ff09e/ruff-0.16.7-py3-none-linux_armv6l.whl", hash = "sha256:727307773e7c7f9181d3ed3a2484186e56c1fa1874255911c74585eb2c7c19f9", size = 10048917, upload-time = "2026-09-10T18:03:30.28Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/96/20bb7bcae008004df52afcb7ac83432d4a467f2c17b672fe46d26be231c5/ruff-0.16.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9d61c258deabf58f34c67bd4bb4d939c7f2e6b5f0e59c1cdd1cf771b11cde929", size = 10242929, upload-time = "2026-09-10T18:03:32.706Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/b2/f184b0d5abec02db69cfd7e49b688ae0237554528ca777136c613bf36bee/ruff-0.16.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ab81118df8945e0193d0240712aa4496573595b75185c3636ed825592a0f728", size = 9847245, upload-time = "2026-09-10T18:03:34.509Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/2d/db1633a641866ed801e34cc6b60ef236c5e16f9b2124ab1d49cc24a5fe4f/ruff-0.16.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c196c968874fc8019da8e7163de7a1a370f111e2309b4b7dfea0fce950198d0", size = 9961780, upload-time = "2026-09-10T18:03:36.618Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4d/98/edea21e1a3e38dbbc3bf6bb068b863b3b06184cf8533a4c7dbbe208a89d5/ruff-0.16.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac8c3bd0a7e10ad31e6ce51e7a99f3cb772e69aecdd6b9ea7e99b362f62a62c0", size = 9866337, upload-time = "2026-09-10T18:03:38.805Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/11/a15e60d4c87b214646f116ca9d204475bf993ee1047459bc9a360fd4d6d1/ruff-0.16.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:398d3988edde000b5c75dc1b3f584708da9bc990de069c18909142580fec1af9", size = 10562512, upload-time = "2026-09-10T18:03:40.71Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/29/42/eaff4c9b6d0c7cdf56df313a17e89ae854f5bbc0b0c8f9cce19be0ab7a8f/ruff-0.16.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce05b62b770a8217c4646a9c4139fca00efe8fe5d71f87df2b243ff20d4584d1", size = 11302938, upload-time = "2026-09-10T18:03:42.607Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/43/c75aa59a4ec181fe2ec06cab30e198c1c6d107229a9f008ae3a7c16cabd8/ruff-0.16.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af1b576fddb9d9ef2ececfb5fadcd6a624b25070ed85e3cfcfe449fc3ff6a7b9", size = 10840857, upload-time = "2026-09-10T18:03:44.604Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/21/33/81f3da371942ea031105ba679d8d6e28ec1660ccd690a45f42d381161356/ruff-0.16.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ce7f8f22df67c93ed96c717f9128eadb797144ac2bad475cf536f31d6100c55", size = 10370001, upload-time = "2026-09-10T18:03:46.706Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/0b/6345fb4dbf6dd0ed1cfe5d18391dc9c3f59cc81622a7b0a65b84b3e730ba/ruff-0.16.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:06d0e93d04f392996435ebd600c153f65b47d73fbec2415aa99c5ee5756b3a5f", size = 10548735, upload-time = "2026-09-10T18:03:48.658Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3f/4d/c5576adf511f92a328e5569dda190ecdd430da51f1a649f3a4a2fd73e21e/ruff-0.16.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:142151a5e7b93c1b11111337142f89dd2fbfee92161225c99a97222f22e32656", size = 10108496, upload-time = "2026-09-10T18:03:50.563Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/8c/667d83c16199a17a56adc6b0bd4c3beb5b767a2babcd16a56f76f9be7fd6/ruff-0.16.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e6651f97a342d8b35d54d8991544ca22169b86dc54111cb604666940c431b750", size = 9860136, upload-time = "2026-09-10T18:03:52.621Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/75/78d401106731999a1dd20cc5a6961e37e1eb9397a3b589f73f3a5ce146a3/ruff-0.16.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ef140c6eb935fa9a84c9c607dfb2cb1b85843c192e79265b0c54f35f557ea8e5", size = 10286290, upload-time = "2026-09-10T18:03:55.207Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/68/49/56f9c3a8b755df93a0ad318b2147bf4ef5dae9a7e5ec61c460109c67957f/ruff-0.16.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:53e39506a730fadeee0d998ed5946f30671f0db240c6c7c73bdabbe33604bb6f", size = 10745048, upload-time = "2026-09-10T18:03:57.299Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/ea/7f9b938a63ece4bec677ad7f9f7fa02df3383db1949ed93a382441c09a87/ruff-0.16.7-py3-none-win32.whl", hash = "sha256:2ea3470fcebcbc5df2fb0c6f3b90333fa9084c534e0111c038fa4a6ab9f1c4b7", size = 10059082, upload-time = "2026-09-10T18:03:59.632Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/39/11/480a6973a927aa653e1cead6a6416008640e03a99d05b34c0434b8c6c366/ruff-0.16.7-py3-none-win_amd64.whl", hash = "sha256:7ac26aca826e9e21d0f1cb25b54ac660760a9fdd094d3e4df9848232be98cfc6", size = 10593368, upload-time = "2026-09-10T18:04:01.999Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/4b/51327018d056f0dad2c2238f26d1fb0f53707a9d91b75dea6d1b3039f136/ruff-0.16.7-py3-none-win_arm64.whl", hash = "sha256:aab7f39e2c9df6c596216070f98eef1207b94f8516cca20c808826974971855b", size = 10412401, upload-time = "2026-09-10T18:04:04.098Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "s3transfer"
|
||||||
|
version = "0.19.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "botocore" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/76/43/35e4d8aa320bffe8287fe8f65f578fa2d2db0a64212f0e710dce58267854/s3transfer-0.19.2.tar.gz", hash = "sha256:ba0309fd86be3c27dbf78cdd813c13c5e1df16e5874b99d2535ebbdfb9892993", size = 165592, upload-time = "2026-07-22T19:30:44.432Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl", hash = "sha256:d8168eccca828cbb2cd573675333f3bddd254313a9c42494b84c76b539e8ba25", size = 90216, upload-time = "2026-07-22T19:30:43.251Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "six"
|
||||||
|
version = "1.17.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sqlparse"
|
name = "sqlparse"
|
||||||
version = "0.5.5"
|
version = "0.5.5"
|
||||||
@@ -943,6 +1204,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" },
|
{ url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "urllib3"
|
||||||
|
version = "2.8.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uvicorn"
|
name = "uvicorn"
|
||||||
version = "0.51.0"
|
version = "0.51.0"
|
||||||
@@ -956,6 +1226,21 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" },
|
{ url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "virtualenv"
|
||||||
|
version = "21.7.10"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "distlib" },
|
||||||
|
{ name = "filelock" },
|
||||||
|
{ name = "platformdirs" },
|
||||||
|
{ name = "python-discovery" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/45/9d/5acd348310e0803c658c8cf7c4d928e2d22fc4f79c29098b651cd3edfdba/virtualenv-21.7.10.tar.gz", hash = "sha256:a7bf10f37ecc36f1942d6e469d6b59f5fe308f60ac711f66f51ef3bd8cb2c9aa", size = 5350247, upload-time = "2026-09-15T23:36:05.739Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/85/7c/b75957b57ef372d84628b1099c4a530b3c361878cc6c54a8dfc5071d371a/virtualenv-21.7.10-py3-none-any.whl", hash = "sha256:d7ac9669ba19e675ffadbb97fe8e887ef92fb1743fe9a0032be62b947657327a", size = 5324900, upload-time = "2026-09-15T23:36:03.751Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "watchfiles"
|
name = "watchfiles"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user