feat: included 2FA and lots of feature from django-htmx-template
CI / test (push) Successful in 2m13s
CI / build (push) Successful in 55s

This commit is contained in:
2026-09-16 13:20:44 +02:00
parent db21129960
commit 4b0d404116
52 changed files with 3135 additions and 311 deletions
+3
View File
@@ -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
+20
View File
@@ -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
View File
@@ -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
+53
View File
@@ -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
+52 -6
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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/
+37
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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')
+5
View File
@@ -0,0 +1,5 @@
from django.conf import settings
def site_name(request):
return {'site_name': settings.SITE_NAME}
+48 -3
View File
@@ -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',
+403 -132
View File
@@ -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."
+378 -125
View File
@@ -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)),
],
),
]
+24
View File
@@ -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})'
+3 -1
View File
@@ -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)
+13
View File
@@ -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);
+42
View File
@@ -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;
}
+25
View File
@@ -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();
});
});
+33
View File
@@ -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 {}
});
}
+29
View File
@@ -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 %}
+24
View File
@@ -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 %}
+68
View File
@@ -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 %}
+11
View File
@@ -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 %}
+8 -3
View File
@@ -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, {
+1 -1
View File
@@ -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'):
+92
View File
@@ -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)
+30
View File
@@ -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)
+6 -2
View File
@@ -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):
+94
View File
@@ -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)
+395
View File
@@ -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}')
+50
View File
@@ -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')
+15
View File
@@ -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
View File
@@ -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
+40
View File
@@ -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
View File
@@ -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
+47
View File
@@ -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
View File
@@ -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
View File
@@ -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"
Generated
+287 -2
View File
@@ -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"