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
run: uv pip install --system -r pyproject.toml --group=dev
- name: Lint
run: uv run ruff check .
- name: Compile translations
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
# 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 chown -R ${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 \
&& su ${user} -c "sh ./scripts/build.sh" \
&& 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.
- **Vista de solo lectura compartible** (`pattern/<uuid>/`), sin necesidad
de cuenta ni de cargar el editor.
- **Cuentas de usuario**: registro (con email, necesario para poder
recuperar la contraseña), login/logout, ajustes de cuenta (cambiar
email/contraseña, con HTMX) y recuperación de contraseña por email.
Cada patrón pertenece a quien lo creó; solo su propietario puede
editarlo o borrarlo.
- **Cuentas de usuario**: registro con verificación de email, login/logout
(con verificación en dos pasos opcional por TOTP + códigos de respaldo),
ajustes de cuenta (cambiar email/contraseña, con HTMX) y recuperación de
contraseña por email. Login, registro y recuperación de contraseña están
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
actualización y accesos directos a editar/ver/eliminar.
- **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
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
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`). |
| `S3_ENABLED` | `False` | Si es `True`, los archivos subidos (imágenes de portada/patrón) se guardan en S3 en vez de en el filesystem local. |
| `S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY` / `S3_STORAGE_BUCKET_NAME` / `S3_ENDPOINT_URL` | vacío | Credenciales del bucket, solo necesarias si `S3_ENABLED=True`. |
| `EMAIL_BACKEND` | backend de consola | Usa `django.core.mail.backends.smtp.EmailBackend` para enviar emails de verdad (recuperación de contraseña). |
| `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. |
| `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`). |
| `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`). |
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
filesystem local (por defecto) no es compartido ni persistente entre
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
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.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/
"""
from pathlib import Path
from config.settings.env import *
# Application definition
DJANGO_APPS = [
@@ -30,6 +28,9 @@ THIRD_PARTY_APPS = [
'django_extensions',
'django_filters',
'django_htmx',
'django_otp',
'django_otp.plugins.otp_totp',
'django_otp.plugins.otp_static',
'watchman',
]
@@ -54,6 +55,7 @@ MIDDLEWARE = [
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django_otp.middleware.OTPMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'django_htmx.middleware.HtmxMiddleware',
@@ -79,6 +81,7 @@ TEMPLATES = [
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
'crochet.context_processors.site_name',
],
},
},
@@ -102,6 +105,11 @@ LOGIN_URL = 'crochet:login'
LOGIN_REDIRECT_URL = 'crochet:home'
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
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators
@@ -165,6 +173,15 @@ EMAIL_USE_TLS = True
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
# 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_STORAGE_BUCKET_NAME = env.str('S3_STORAGE_BUCKET_NAME', '')
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)
@@ -49,4 +56,34 @@ DEFAULT_FROM_EMAIL = env.str('DEFAULT_FROM_EMAIL', 'webmaster@localhost')
# EmailUpdateForm) por si el cambio no lo ha hecho él.
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')
# 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.urls.i18n import i18n_patterns
from django.contrib import admin
from django.urls import path, include
from django.urls import include, path
from django.views.static import serve
urlpatterns = [
+14
View File
@@ -1,10 +1,24 @@
import tempfile
import pytest
from django.core.cache import cache
from django.core.management import call_command
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)
def collected_static_files():
"""{% static %} (usado en pattern.html/pattern_detail.html) necesita el
+9 -1
View File
@@ -1,6 +1,6 @@
from django.contrib import admin
from crochet.models import Pattern, PatternImage, StitchType
from crochet.models import Pattern, PatternImage, StitchType, TwoFactorRememberedDevice
class PatternImageInline(admin.TabularInline):
@@ -26,3 +26,11 @@ class PatternImageAdmin(admin.ModelAdmin):
class StitchTypeAdmin(admin.ModelAdmin):
list_display = ('__str__', '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.
# Sin email no hay a quién avisar (cuentas antiguas sin uno).
if user.email:
context = {'user': user, 'site_name': 'Crochet', 'support_email': settings.SUPPORT_EMAIL}
context = {'user': user, 'site_name': settings.SITE_NAME, 'support_email': settings.SUPPORT_EMAIL}
_send_account_notice(
'crochet/email/password_changed_subject.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
# ha cambiado nada, no hay a quién avisar ni de qué.
if old_email and old_email != self.user.email:
context = {'user': self.user, 'old_email': old_email, 'site_name': 'Crochet', 'support_email': settings.SUPPORT_EMAIL}
context = {
'user': self.user, 'old_email': old_email,
'site_name': settings.SITE_NAME, 'support_email': settings.SUPPORT_EMAIL,
}
_send_account_notice(
'crochet/email/email_changed_subject.txt',
'crochet/email/email_changed_notice.txt',
@@ -138,6 +141,48 @@ class DisplayNameForm(forms.Form):
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):
password = forms.CharField(
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
# email todavía existe. Sin email no hay a quién avisar.
if self.user.email:
context = {'user': self.user, 'site_name': 'Crochet', 'support_email': settings.SUPPORT_EMAIL}
context = {'user': self.user, 'site_name': settings.SITE_NAME, 'support_email': settings.SUPPORT_EMAIL}
_send_account_notice(
'crochet/email/account_deleted_subject.txt',
'crochet/email/account_deleted_notice.txt',
+403 -132
View File
@@ -2,7 +2,7 @@ msgid ""
msgstr ""
"Project-Id-Version: crochet\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"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
@@ -13,18 +13,134 @@ msgstr ""
msgid "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"
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"
msgstr "Password"
#: crochet/forms.py:153
#: crochet/forms.py:182 crochet/forms.py:198
msgid "Contraseña incorrecta."
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
msgid ""
"¿Seguro que quieres eliminar tu cuenta? Se borrarán también todos tus "
@@ -34,7 +150,7 @@ msgstr ""
"deleted. This action cannot be undone."
#: 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"
msgstr "Delete account"
@@ -109,12 +225,12 @@ msgid "Actualizado el %(date)s"
msgstr "Updated on %(date)s"
#: crochet/templates/crochet/account_home.html:44
#: crochet/templates/crochet/pattern.html:182
#: crochet/templates/crochet/pattern.html:156
msgid "Vista previa"
msgstr "Preview"
#: crochet/templates/crochet/account_home.html:48
#: crochet/templates/crochet/pattern.html:55
#: crochet/templates/crochet/pattern.html:46
msgid "Eliminar"
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:18
#: crochet/templates/crochet/base.html:52
#: crochet/templates/crochet/base.html:62
msgid "Ajustes de la cuenta"
msgstr "Account settings"
@@ -140,19 +256,37 @@ msgstr "Security"
msgid "Zona de peligro"
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."
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"
msgstr "Change language"
#: crochet/templates/crochet/base.html:56
#: crochet/templates/crochet/base.html:66
msgid "Cerrar sesión"
msgstr "Log out"
#: crochet/templates/crochet/base.html:62
#: crochet/templates/crochet/base.html:72
#: crochet/templates/crochet/home.html:25
#: crochet/templates/crochet/password_reset_complete.html:18
#: crochet/templates/registration/login.html:4
@@ -225,10 +359,6 @@ msgstr ""
"Both are technical cookies necessary for the site to work (logging in and "
"protecting forms), so they don't require your prior consent."
#: crochet/templates/crochet/cookie_policy.html:54
msgid "Volver al inicio"
msgstr "Back to home"
#: crochet/templates/crochet/email/account_deleted_notice.html:4
#: crochet/templates/crochet/email/account_deleted_subject.txt:1
#, python-format
@@ -251,6 +381,8 @@ msgstr ""
#: crochet/templates/crochet/email/email_changed_notice.txt:4
#: crochet/templates/crochet/email/password_changed_notice.html:12
#: crochet/templates/crochet/email/password_changed_notice.txt:4
#: crochet/templates/crochet/email/verify_email.html:19
#: crochet/templates/crochet/email/verify_email.txt:7
msgid "Tu usuario:"
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."
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
msgid "Crochet — Crea y comparte tus patrones"
msgstr "Crochet — Create and share your patterns"
@@ -403,6 +563,8 @@ msgid "Elige una contraseña nueva"
msgstr "Choose a new password"
#: 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"
msgstr "Invalid link"
@@ -419,6 +581,7 @@ msgid "Solicitar un enlace nuevo"
msgstr "Request a new link"
#: crochet/templates/crochet/password_reset_done.html:14
#: crochet/templates/registration/register_done.html:14
msgid "Revisa tu email"
msgstr "Check your email"
@@ -432,6 +595,8 @@ msgstr ""
#: crochet/templates/crochet/password_reset_done.html:19
#: 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"
msgstr "Back to login"
@@ -453,245 +618,245 @@ msgstr "Send link"
msgid "Recupera tu contraseña en %(site_name)s"
msgstr "Reset your password on %(site_name)s"
#: crochet/templates/crochet/pattern.html:26
msgid "Idioma del patrón"
msgstr "Pattern language"
# crochet/templates/crochet/pattern.html
#: crochet/templates/crochet/pattern.html: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
#: crochet/templates/crochet/pattern.html:23
msgid "Título del patrón"
msgstr "Pattern title"
#: crochet/templates/crochet/pattern.html:79
#: crochet/templates/crochet/pattern.html:24
msgid "Título del patrón..."
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"
msgstr "Cover image"
#: crochet/templates/crochet/pattern.html:95
#: crochet/templates/crochet/pattern.html:73
msgid "Añadir imagen"
msgstr "Add image"
#: crochet/templates/crochet/pattern.html:98
#: crochet/templates/crochet/pattern.html:76
msgid "Cambiar"
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."
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"
msgstr "Page customization"
#: crochet/templates/crochet/pattern.html:117
#: crochet/templates/crochet/pattern.html:91
msgid "Autor"
msgstr "Author"
#: crochet/templates/crochet/pattern.html:118
#: crochet/templates/crochet/pattern.html:92
msgid "Autor..."
msgstr "Author..."
#: crochet/templates/crochet/pattern.html:122
#: crochet/templates/crochet/pattern.html:206
#: crochet/templates/crochet/pattern.html:96
#: crochet/templates/crochet/pattern.html:191
msgid "Color del texto"
msgstr "Text color"
#: crochet/templates/crochet/pattern.html:126
#: crochet/templates/crochet/pattern.html:100
msgid "Color de acento (títulos)"
msgstr "Accent color (headings)"
#: crochet/templates/crochet/pattern.html:130
#: crochet/templates/crochet/pattern.html:206
#: crochet/templates/crochet/pattern.html:104
#: crochet/templates/crochet/pattern.html:191
msgid "Color de fondo"
msgstr "Background color"
#: crochet/templates/crochet/pattern.html:134
#: crochet/templates/crochet/pattern.html:108
msgid "Tipografía"
msgstr "Font"
#: crochet/templates/crochet/pattern.html:140
#: crochet/templates/crochet/pattern.html:114
msgid "Monoespaciada"
msgstr "Monospace"
#: crochet/templates/crochet/pattern.html:144
#: crochet/templates/crochet/pattern.html:118
msgid "Tamaño de letra"
msgstr "Font size"
#: crochet/templates/crochet/pattern.html:146
#: crochet/templates/crochet/pattern.html:120
msgid "Pequeño"
msgstr "Small"
#: crochet/templates/crochet/pattern.html:147
#: crochet/templates/crochet/pattern.html:121
msgid "Normal"
msgstr "Normal"
#: crochet/templates/crochet/pattern.html:148
#: crochet/templates/crochet/pattern.html:122
msgid "Grande"
msgstr "Large"
#: crochet/templates/crochet/pattern.html:152
#: crochet/templates/crochet/pattern.html:126
msgid "Alineación del texto"
msgstr "Text alignment"
#: crochet/templates/crochet/pattern.html:154
#: crochet/templates/crochet/pattern.html:128
msgid "Izquierda"
msgstr "Left"
#: crochet/templates/crochet/pattern.html:155
#: crochet/templates/crochet/pattern.html:129
msgid "Centrado"
msgstr "Center"
#: crochet/templates/crochet/pattern.html:156
#: crochet/templates/crochet/pattern.html:130
msgid "Justificado"
msgstr "Justify"
#: crochet/templates/crochet/pattern.html:160
#: crochet/templates/crochet/pattern.html:134
msgid "Tamaño de página"
msgstr "Page size"
#: crochet/templates/crochet/pattern.html:165
#: crochet/templates/crochet/pattern.html:139
msgid "Carta (Letter)"
msgstr "Letter"
#: crochet/templates/crochet/pattern.html:170
#: crochet/templates/crochet/pattern.html:144
msgid "Orientación"
msgstr "Orientation"
#: crochet/templates/crochet/pattern.html:172
#: crochet/templates/crochet/pattern.html:146
msgid "Vertical"
msgstr "Portrait"
#: crochet/templates/crochet/pattern.html:173
#: crochet/templates/crochet/pattern.html:147
msgid "Horizontal"
msgstr "Landscape"
#: crochet/templates/crochet/pattern.html:181
#: crochet/templates/crochet/pattern.html:189
#: crochet/templates/crochet/pattern.html:155
#: crochet/templates/crochet/pattern.html:174
msgid "Secciones"
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"
msgstr "Collapse all"
#: crochet/templates/crochet/pattern.html:191
#: crochet/templates/crochet/pattern.html:176
msgid "Expandir todo"
msgstr "Expand all"
#: crochet/templates/crochet/pattern.html:196
#: crochet/templates/crochet/pattern.html:181
msgid "Añadir sección"
msgstr "Add section"
#: crochet/templates/crochet/pattern.html:199
#: crochet/templates/crochet/pattern.html:184
msgid "Título"
msgstr "Title"
#: crochet/templates/crochet/pattern.html:199
#: crochet/templates/crochet/pattern.html:184
msgid "Título..."
msgstr "Title..."
#: crochet/templates/crochet/pattern.html:201
#: crochet/templates/crochet/pattern.html:186
msgid "Subtítulo"
msgstr "Subtitle"
#: crochet/templates/crochet/pattern.html:201
#: crochet/templates/crochet/pattern.html:186
msgid "Subtítulo..."
msgstr "Subtitle..."
#: crochet/templates/crochet/pattern.html:203
#: crochet/templates/crochet/pattern.html:188
msgid "Texto"
msgstr "Text"
#: crochet/templates/crochet/pattern.html:203
#: crochet/templates/crochet/pattern.html:188
msgid "Escribe aquí..."
msgstr "Write here..."
#: crochet/templates/crochet/pattern.html:205
#: crochet/templates/crochet/pattern.html:206
#: crochet/templates/crochet/pattern.html:190
#: crochet/templates/crochet/pattern.html:191
msgid "Nota"
msgstr "Note"
#: crochet/templates/crochet/pattern.html:205
#: crochet/templates/crochet/pattern.html:190
msgid "Escribe una nota o consejo..."
msgstr "Write a note or tip..."
#: crochet/templates/crochet/pattern.html:208
#: crochet/templates/crochet/pattern.html:193
msgid "Materiales"
msgstr "Materials"
#: crochet/templates/crochet/pattern.html:208
#: crochet/templates/crochet/pattern.html:193
msgid "Añadir material..."
msgstr "Add material..."
#: crochet/templates/crochet/pattern.html:208
#: crochet/templates/crochet/pattern.html:193
msgid "Añadir línea"
msgstr "Add line"
#: crochet/templates/crochet/pattern.html:210
#: crochet/templates/crochet/pattern.html:212
#: crochet/templates/crochet/pattern.html:195
#: crochet/templates/crochet/pattern.html:197
msgid "Imagen"
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."
msgstr "Couldn't upload the image. Please try again."
#: crochet/templates/crochet/pattern.html:214
#: crochet/templates/crochet/pattern.html:199
msgid "Patrón"
msgstr "Pattern"
#: crochet/templates/crochet/pattern.html:214
#: crochet/templates/crochet/pattern.html:199
msgid "Elementos"
msgstr "Elements"
#: crochet/templates/crochet/pattern.html:216
#: crochet/templates/crochet/pattern.html:201
msgid "Grupo"
msgstr "Group"
#: crochet/templates/crochet/pattern.html:220
#: crochet/templates/crochet/pattern.html:205
msgid "Arrastrar para reordenar"
msgstr "Drag to reorder"
#: crochet/templates/crochet/pattern.html:221
#: crochet/templates/crochet/pattern.html:206
msgid "Colapsar / expandir"
msgstr "Collapse / expand"
#: crochet/templates/crochet/pattern.html:222
#: crochet/templates/crochet/pattern.html:207
msgid "Duplicar sección"
msgstr "Duplicate section"
#: crochet/templates/crochet/pattern.html:223
#: crochet/templates/crochet/pattern.html:208
msgid "Eliminar sección"
msgstr "Delete section"
#: crochet/templates/crochet/pattern.html:224
#: crochet/templates/crochet/pattern.html:209
msgid ""
"¿Seguro que quieres eliminar este grupo? Se eliminarán también todas las "
"secciones que contiene."
@@ -699,7 +864,7 @@ msgstr ""
"Are you sure you want to delete this group? All sections inside it will also "
"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."
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"
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
msgid "Usuario o contraseña incorrectos."
msgstr "Incorrect username or password."
@@ -736,96 +935,168 @@ msgstr "Sign up"
msgid "¿Ya tienes cuenta? Inicia sesión"
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/"
msgstr ""
#: crochet/urls.py:39
#: crochet/urls.py:47
msgid "pattern/<uuid:uuid>/"
msgstr ""
#: crochet/urls.py:40
#: crochet/urls.py:48
msgid "pattern/<uuid:uuid>/edit/"
msgstr ""
#: crochet/urls.py:41
#: crochet/urls.py:49
msgid "pattern/<uuid:uuid>/save/"
msgstr ""
#: crochet/urls.py:42
#: crochet/urls.py:50
msgid "pattern/<uuid:uuid>/images/"
msgstr ""
#: crochet/urls.py:43
#: crochet/urls.py:51
msgid "pattern/<uuid:uuid>/cover/"
msgstr ""
#: crochet/urls.py:44
#: crochet/urls.py:52
msgid "pattern/<uuid:uuid>/pdf/"
msgstr ""
#: crochet/urls.py:45
#: crochet/urls.py:53
msgid "pattern/<uuid:uuid>/delete/"
msgstr ""
#: crochet/urls.py:46
#: crochet/urls.py:54
msgid "pattern/new/"
msgstr ""
#: crochet/urls.py:47
#: crochet/urls.py:55
msgid "account/settings/"
msgstr ""
#: crochet/urls.py:48
#: crochet/urls.py:56
msgid "account/settings/email/"
msgstr ""
#: crochet/urls.py:50
#: crochet/urls.py:58
msgid "account/settings/password/"
msgstr ""
#: crochet/urls.py:54
#: crochet/urls.py:62
msgid "account/settings/display-name/"
msgstr ""
#: crochet/urls.py:57
#: crochet/urls.py:65
msgid "account/settings/delete/"
msgstr ""
#: crochet/urls.py:58
msgid "account/register/"
msgstr ""
#: crochet/urls.py:60
msgid "account/login/"
msgstr ""
#: crochet/urls.py:65
msgid "account/logout/"
#: crochet/urls.py:66
msgid "account/settings/2fa/setup/"
msgstr ""
#: 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/"
msgstr ""
#: crochet/urls.py:86
#: crochet/urls.py:103
msgid "account/password-reset/done/"
msgstr ""
#: crochet/urls.py:90
#: crochet/urls.py:107
msgid "account/reset/<uidb64>/<token>/"
msgstr ""
#: crochet/urls.py:98
#: crochet/urls.py:115
msgid "account/reset/done/"
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:29
#: crochet/views.py:51
msgid "Este patrón todavía no tiene contenido."
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."
msgstr "Your account has been deleted successfully."
+378 -125
View File
@@ -2,7 +2,7 @@ msgid ""
msgstr ""
"Project-Id-Version: crochet\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"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
@@ -13,18 +13,128 @@ msgstr ""
msgid "Email"
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"
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"
msgstr ""
#: crochet/forms.py:153
#: crochet/forms.py:182 crochet/forms.py:198
msgid "Contraseña incorrecta."
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
msgid ""
"¿Seguro que quieres eliminar tu cuenta? Se borrarán también todos tus "
@@ -32,7 +142,7 @@ msgid ""
msgstr ""
#: 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"
msgstr ""
@@ -105,12 +215,12 @@ msgid "Actualizado el %(date)s"
msgstr ""
#: crochet/templates/crochet/account_home.html:44
#: crochet/templates/crochet/pattern.html:182
#: crochet/templates/crochet/pattern.html:156
msgid "Vista previa"
msgstr ""
#: crochet/templates/crochet/account_home.html:48
#: crochet/templates/crochet/pattern.html:55
#: crochet/templates/crochet/pattern.html:46
msgid "Eliminar"
msgstr ""
@@ -120,7 +230,7 @@ msgstr ""
#: crochet/templates/crochet/account_settings.html:4
#: crochet/templates/crochet/account_settings.html:18
#: crochet/templates/crochet/base.html:52
#: crochet/templates/crochet/base.html:62
msgid "Ajustes de la cuenta"
msgstr ""
@@ -136,19 +246,35 @@ msgstr ""
msgid "Zona de peligro"
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."
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"
msgstr ""
#: crochet/templates/crochet/base.html:56
#: crochet/templates/crochet/base.html:66
msgid "Cerrar sesión"
msgstr ""
#: crochet/templates/crochet/base.html:62
#: crochet/templates/crochet/base.html:72
#: crochet/templates/crochet/home.html:25
#: crochet/templates/crochet/password_reset_complete.html:18
#: crochet/templates/registration/login.html:4
@@ -214,10 +340,6 @@ msgid ""
"consentimiento previo."
msgstr ""
#: crochet/templates/crochet/cookie_policy.html:54
msgid "Volver al inicio"
msgstr ""
#: crochet/templates/crochet/email/account_deleted_notice.html:4
#: crochet/templates/crochet/email/account_deleted_subject.txt:1
#, python-format
@@ -238,6 +360,8 @@ msgstr ""
#: crochet/templates/crochet/email/email_changed_notice.txt:4
#: crochet/templates/crochet/email/password_changed_notice.html:12
#: crochet/templates/crochet/email/password_changed_notice.txt:4
#: crochet/templates/crochet/email/verify_email.html:19
#: crochet/templates/crochet/email/verify_email.txt:7
msgid "Tu usuario:"
msgstr ""
@@ -314,6 +438,34 @@ msgstr ""
msgid "Si no has solicitado este cambio, puedes ignorar este email."
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
msgid "Crochet — Crea y comparte tus patrones"
msgstr ""
@@ -376,6 +528,8 @@ msgid "Elige una contraseña nueva"
msgstr ""
#: 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"
msgstr ""
@@ -390,6 +544,7 @@ msgid "Solicitar un enlace nuevo"
msgstr ""
#: crochet/templates/crochet/password_reset_done.html:14
#: crochet/templates/registration/register_done.html:14
msgid "Revisa tu email"
msgstr ""
@@ -401,6 +556,8 @@ msgstr ""
#: crochet/templates/crochet/password_reset_done.html:19
#: 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"
msgstr ""
@@ -422,250 +579,250 @@ msgstr ""
msgid "Recupera tu contraseña en %(site_name)s"
msgstr ""
#: crochet/templates/crochet/pattern.html:26
msgid "Idioma del patrón"
msgstr ""
#: crochet/templates/crochet/pattern.html:38
msgid "No se ha podido guardar. Inténtalo de nuevo."
msgstr ""
#: crochet/templates/crochet/pattern.html: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
#: crochet/templates/crochet/pattern.html:23
msgid "Título del patrón"
msgstr ""
#: crochet/templates/crochet/pattern.html:79
#: crochet/templates/crochet/pattern.html:24
msgid "Título del patrón..."
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"
msgstr ""
#: crochet/templates/crochet/pattern.html:95
#: crochet/templates/crochet/pattern.html:73
msgid "Añadir imagen"
msgstr ""
#: crochet/templates/crochet/pattern.html:98
#: crochet/templates/crochet/pattern.html:76
msgid "Cambiar"
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."
msgstr ""
#: crochet/templates/crochet/pattern.html:114
#: crochet/templates/crochet/pattern.html:88
msgid "Personalización de página"
msgstr ""
#: crochet/templates/crochet/pattern.html:117
#: crochet/templates/crochet/pattern.html:91
msgid "Autor"
msgstr ""
#: crochet/templates/crochet/pattern.html:118
#: crochet/templates/crochet/pattern.html:92
msgid "Autor..."
msgstr ""
#: crochet/templates/crochet/pattern.html:122
#: crochet/templates/crochet/pattern.html:206
#: crochet/templates/crochet/pattern.html:96
#: crochet/templates/crochet/pattern.html:191
msgid "Color del texto"
msgstr ""
#: crochet/templates/crochet/pattern.html:126
#: crochet/templates/crochet/pattern.html:100
msgid "Color de acento (títulos)"
msgstr ""
#: crochet/templates/crochet/pattern.html:130
#: crochet/templates/crochet/pattern.html:206
#: crochet/templates/crochet/pattern.html:104
#: crochet/templates/crochet/pattern.html:191
msgid "Color de fondo"
msgstr ""
#: crochet/templates/crochet/pattern.html:134
#: crochet/templates/crochet/pattern.html:108
msgid "Tipografía"
msgstr ""
#: crochet/templates/crochet/pattern.html:140
#: crochet/templates/crochet/pattern.html:114
msgid "Monoespaciada"
msgstr ""
#: crochet/templates/crochet/pattern.html:144
#: crochet/templates/crochet/pattern.html:118
msgid "Tamaño de letra"
msgstr ""
#: crochet/templates/crochet/pattern.html:146
#: crochet/templates/crochet/pattern.html:120
msgid "Pequeño"
msgstr ""
#: crochet/templates/crochet/pattern.html:147
#: crochet/templates/crochet/pattern.html:121
msgid "Normal"
msgstr ""
#: crochet/templates/crochet/pattern.html:148
#: crochet/templates/crochet/pattern.html:122
msgid "Grande"
msgstr ""
#: crochet/templates/crochet/pattern.html:152
#: crochet/templates/crochet/pattern.html:126
msgid "Alineación del texto"
msgstr ""
#: crochet/templates/crochet/pattern.html:154
#: crochet/templates/crochet/pattern.html:128
msgid "Izquierda"
msgstr ""
#: crochet/templates/crochet/pattern.html:155
#: crochet/templates/crochet/pattern.html:129
msgid "Centrado"
msgstr ""
#: crochet/templates/crochet/pattern.html:156
#: crochet/templates/crochet/pattern.html:130
msgid "Justificado"
msgstr ""
#: crochet/templates/crochet/pattern.html:160
#: crochet/templates/crochet/pattern.html:134
msgid "Tamaño de página"
msgstr ""
#: crochet/templates/crochet/pattern.html:165
#: crochet/templates/crochet/pattern.html:139
msgid "Carta (Letter)"
msgstr ""
#: crochet/templates/crochet/pattern.html:170
#: crochet/templates/crochet/pattern.html:144
msgid "Orientación"
msgstr ""
#: crochet/templates/crochet/pattern.html:172
#: crochet/templates/crochet/pattern.html:146
msgid "Vertical"
msgstr ""
#: crochet/templates/crochet/pattern.html:173
#: crochet/templates/crochet/pattern.html:147
msgid "Horizontal"
msgstr ""
#: crochet/templates/crochet/pattern.html:181
#: crochet/templates/crochet/pattern.html:189
#: crochet/templates/crochet/pattern.html:155
#: crochet/templates/crochet/pattern.html:174
msgid "Secciones"
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"
msgstr ""
#: crochet/templates/crochet/pattern.html:191
#: crochet/templates/crochet/pattern.html:176
msgid "Expandir todo"
msgstr ""
#: crochet/templates/crochet/pattern.html:196
#: crochet/templates/crochet/pattern.html:181
msgid "Añadir sección"
msgstr ""
#: crochet/templates/crochet/pattern.html:199
#: crochet/templates/crochet/pattern.html:184
msgid "Título"
msgstr ""
#: crochet/templates/crochet/pattern.html:199
#: crochet/templates/crochet/pattern.html:184
msgid "Título..."
msgstr ""
#: crochet/templates/crochet/pattern.html:201
#: crochet/templates/crochet/pattern.html:186
msgid "Subtítulo"
msgstr ""
#: crochet/templates/crochet/pattern.html:201
#: crochet/templates/crochet/pattern.html:186
msgid "Subtítulo..."
msgstr ""
#: crochet/templates/crochet/pattern.html:203
#: crochet/templates/crochet/pattern.html:188
msgid "Texto"
msgstr ""
#: crochet/templates/crochet/pattern.html:203
#: crochet/templates/crochet/pattern.html:188
msgid "Escribe aquí..."
msgstr ""
#: crochet/templates/crochet/pattern.html:205
#: crochet/templates/crochet/pattern.html:206
#: crochet/templates/crochet/pattern.html:190
#: crochet/templates/crochet/pattern.html:191
msgid "Nota"
msgstr ""
#: crochet/templates/crochet/pattern.html:205
#: crochet/templates/crochet/pattern.html:190
msgid "Escribe una nota o consejo..."
msgstr ""
#: crochet/templates/crochet/pattern.html:208
#: crochet/templates/crochet/pattern.html:193
msgid "Materiales"
msgstr ""
#: crochet/templates/crochet/pattern.html:208
#: crochet/templates/crochet/pattern.html:193
msgid "Añadir material..."
msgstr ""
#: crochet/templates/crochet/pattern.html:208
#: crochet/templates/crochet/pattern.html:193
msgid "Añadir línea"
msgstr ""
#: crochet/templates/crochet/pattern.html:210
#: crochet/templates/crochet/pattern.html:212
#: crochet/templates/crochet/pattern.html:195
#: crochet/templates/crochet/pattern.html:197
msgid "Imagen"
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."
msgstr ""
#: crochet/templates/crochet/pattern.html:214
#: crochet/templates/crochet/pattern.html:199
msgid "Patrón"
msgstr ""
#: crochet/templates/crochet/pattern.html:214
#: crochet/templates/crochet/pattern.html:199
msgid "Elementos"
msgstr ""
#: crochet/templates/crochet/pattern.html:216
#: crochet/templates/crochet/pattern.html:201
msgid "Grupo"
msgstr ""
#: crochet/templates/crochet/pattern.html:220
#: crochet/templates/crochet/pattern.html:205
msgid "Arrastrar para reordenar"
msgstr ""
#: crochet/templates/crochet/pattern.html:221
#: crochet/templates/crochet/pattern.html:206
msgid "Colapsar / expandir"
msgstr ""
#: crochet/templates/crochet/pattern.html:222
#: crochet/templates/crochet/pattern.html:207
msgid "Duplicar sección"
msgstr ""
#: crochet/templates/crochet/pattern.html:223
#: crochet/templates/crochet/pattern.html:208
msgid "Eliminar sección"
msgstr ""
#: crochet/templates/crochet/pattern.html:224
#: crochet/templates/crochet/pattern.html:209
msgid ""
"¿Seguro que quieres eliminar este grupo? Se eliminarán también todas las "
"secciones que contiene."
msgstr ""
#: crochet/templates/crochet/pattern.html:229
#: crochet/templates/crochet/pattern.html:214
msgid "Añade una sección para ver aquí el resultado."
msgstr ""
@@ -673,6 +830,36 @@ msgstr ""
msgid "Descargar PDF"
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
msgid "Usuario o contraseña incorrectos."
msgstr ""
@@ -702,97 +889,163 @@ msgstr ""
msgid "¿Ya tienes cuenta? Inicia sesión"
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/"
msgstr ""
# Rutas traducibles de crochet/urls.py: la base "pattern/" se traduce como
# "patron/" (sin tilde, para no meter caracteres acentuados en la URL).
#: crochet/urls.py:39
#: crochet/urls.py:47
msgid "pattern/<uuid:uuid>/"
msgstr "patron/<uuid:uuid>/"
#: crochet/urls.py:40
#: crochet/urls.py:48
msgid "pattern/<uuid:uuid>/edit/"
msgstr "patron/<uuid:uuid>/editar/"
#: crochet/urls.py:41
#: crochet/urls.py:49
msgid "pattern/<uuid:uuid>/save/"
msgstr "patron/<uuid:uuid>/guardar/"
#: crochet/urls.py:42
#: crochet/urls.py:50
msgid "pattern/<uuid:uuid>/images/"
msgstr "patron/<uuid:uuid>/imagenes/"
#: crochet/urls.py:43
#: crochet/urls.py:51
msgid "pattern/<uuid:uuid>/cover/"
msgstr "patron/<uuid:uuid>/portada/"
#: crochet/urls.py:44
#: crochet/urls.py:52
msgid "pattern/<uuid:uuid>/pdf/"
msgstr "patron/<uuid:uuid>/pdf/"
#: crochet/urls.py:45
#: crochet/urls.py:53
msgid "pattern/<uuid:uuid>/delete/"
msgstr "patron/<uuid:uuid>/eliminar/"
#: crochet/urls.py:46
#: crochet/urls.py:54
msgid "pattern/new/"
msgstr "patron/nuevo/"
#: crochet/urls.py:47
#: crochet/urls.py:55
msgid "account/settings/"
msgstr "cuenta/ajustes/"
#: crochet/urls.py:48
#: crochet/urls.py:56
msgid "account/settings/email/"
msgstr "cuenta/ajustes/email/"
#: crochet/urls.py:50
#: crochet/urls.py:58
msgid "account/settings/password/"
msgstr "cuenta/ajustes/contrasena/"
#: crochet/urls.py:54
#: crochet/urls.py:62
msgid "account/settings/display-name/"
msgstr "cuenta/ajustes/nombre/"
#: crochet/urls.py:57
#: crochet/urls.py:65
msgid "account/settings/delete/"
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/"
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/"
msgstr "cuenta/entrar/"
#: crochet/urls.py:65
#: crochet/urls.py:82
msgid "account/logout/"
msgstr "cuenta/salir/"
#: crochet/urls.py:67
#: crochet/urls.py:84
msgid "account/password-reset/"
msgstr "cuenta/recuperar-contrasena/"
#: crochet/urls.py:86
#: crochet/urls.py:103
msgid "account/password-reset/done/"
msgstr "cuenta/recuperar-contrasena/enviado/"
#: crochet/urls.py:90
#: crochet/urls.py:107
msgid "account/reset/<uidb64>/<token>/"
msgstr "cuenta/restablecer/<uidb64>/<token>/"
#: crochet/urls.py:98
#: crochet/urls.py:115
msgid "account/reset/done/"
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."
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."
msgstr ""
@@ -51,7 +51,10 @@ SOURCES = [
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):
if not TAILWIND_BIN.exists():
@@ -94,6 +97,6 @@ class Command(BaseCommand):
try:
urllib.request.urlretrieve(url, TAILWIND_BIN)
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)
@@ -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/')
uploaded_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f'{self.pattern} - {self.image.name}'
class StitchType(models.Model):
# 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):
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):
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:
return ''
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;
}
/* 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 {
background-color: var(--brand-bg);
color: var(--brand-text);
+42
View File
@@ -44,3 +44,45 @@
--depth: 1;
--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' %}
</div>
</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>
<!-- Zona de peligro en su propia pestaña, no solo con border-error
@@ -80,4 +90,5 @@
{% block extra_js %}
<script defer src="{% static 'js/account-settings-tabs.js' %}"></script>
<script defer src="{% static 'js/modal-reopen.js' %}"></script>
{% endblock %}
+11
View File
@@ -7,6 +7,13 @@
<html lang="{% get_current_language as html_lang %}{{ html_lang }}" 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>{% block title %}Crochet{% endblock %}</title>
@@ -27,6 +34,9 @@
<a href="{% url 'crochet:home' %}" class="btn btn-ghost text-xl px-2">🧶 Crochet</a>
</div>
<div class="flex-none flex items-center gap-2">
<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_available_languages as AVAILABLE_LANGUAGES %}
<!-- El idioma es una preferencia de la página, no de la cuenta: va
@@ -64,6 +74,7 @@
</div>
</nav>
{% endblock %}
<script defer src="{% static 'js/theme-toggle.js' %}"></script>
<!-- Mensajes de una sola vez tras una redirección (ver messages.success
en AccountDeleteView): no hay ninguna página a la que volver con el
resultado de la acción (la sesión ya no existe), así que se muestran
@@ -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.core import mail
from django.test import TestCase
from django.urls import reverse
from django.utils import translation
@@ -17,7 +18,7 @@ class RegisterViewTests(TestCase):
self.assertEqual(response.status_code, 200)
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, {
'username': 'crocheter',
'email': 'crocheter@example.com',
@@ -27,8 +28,12 @@ class RegisterViewTests(TestCase):
user = User.objects.get(username='crocheter')
self.assertEqual(user.email, 'crocheter@example.com')
self.assertRedirects(response, reverse('crochet:home'))
self.assertIn('_auth_user_id', self.client.session)
self.assertFalse(user.is_active)
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):
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 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'):
+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):
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):
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):
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)
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,
PatternPdfView,
PatternSaveView,
RateLimitedLoginView,
RateLimitedPasswordResetView,
RegisterDoneView,
RegisterView,
TwoFactorDisableView,
TwoFactorForgetDeviceView,
TwoFactorSetupView,
TwoFactorVerifyView,
VerifyEmailView,
)
app_name = 'crochet'
@@ -55,17 +63,26 @@ urlpatterns = [
view=AccountDisplayNameUpdateView.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/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(
_('account/login/'), name='login',
view=auth_views.LoginView.as_view(
view=RateLimitedLoginView.as_view(
template_name='registration/login.html', authentication_form=StyledAuthenticationForm,
),
),
path(_('account/logout/'), name='logout', view=auth_views.LogoutView.as_view()),
path(
_('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):
# django.contrib.admin trae sus propias plantillas con estos
# 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 secrets
from datetime import timedelta
import qrcode
import qrcode.image.svg
import weasyprint
from django.conf import settings
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.sites.shortcuts import get_current_site
from django.core.exceptions import PermissionDenied, SuspiciousFileOperation
from django.core.mail import send_mail
from django.http import HttpResponse, HttpResponseBadRequest, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.template.loader import render_to_string
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.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.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.forms import (
@@ -21,13 +39,95 @@ from crochet.forms import (
EmailUpdateForm,
StyledPasswordChangeForm,
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.tokens import email_verification_token
from crochet.user_agent import describe_device
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():
# 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):
# UserCreationForm ya pide justo nombre de usuario + contraseña (dos
# veces, para confirmarla): no hace falta un formulario propio, solo la
# versión con clases de daisyUI en sus widgets (ver crochet/forms.py).
form_class = StyledUserCreationForm
template_name = 'registration/register.html'
success_url = reverse_lazy('crochet:home')
success_url = reverse_lazy('crochet:register_done')
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)
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
@@ -277,6 +632,9 @@ class AccountSettingsView(LoginRequiredMixin, TemplateView):
user=self.request.user, initial={'display_name': self.request.user.first_name},
)
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
+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-filter>=26.1",
"django-htmx>=1.28.0",
"django-otp>=1.7.3",
"django-ratelimit>=4.1.0",
"django-silk>=5.5.0",
"django-storages>=1.14.6",
"django-storages[s3]>=1.14.6",
"ipython>=9.15.0",
"pillow>=12.3.0",
"psycopg[binary]>=3.3.4",
"pytest-cov>=7.1.0",
"qrcode>=8.2",
"uvicorn>=0.51.0",
"watchman>=0.0.1",
"weasyprint>=69.0",
@@ -29,9 +32,42 @@ dev = [
"django-browser-reload>=1.21.0",
"django-debug-toolbar>=7.0.0",
"django-watchfiles>=1.4.0",
"pre-commit>=4.6.2",
"pytest>=9.1.1",
"pytest-django>=4.12.0",
"ruff>=0.16.7",
]
[tool.pytest.ini_options]
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" },
]
[[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]]
name = "brotli"
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" },
]
[[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]]
name = "click"
version = "8.4.2"
@@ -240,12 +277,15 @@ dependencies = [
{ name = "django-extensions" },
{ name = "django-filter" },
{ name = "django-htmx" },
{ name = "django-otp" },
{ name = "django-ratelimit" },
{ name = "django-silk" },
{ name = "django-storages" },
{ name = "django-storages", extra = ["s3"] },
{ name = "ipython" },
{ name = "pillow" },
{ name = "psycopg", extra = ["binary"] },
{ name = "pytest-cov" },
{ name = "qrcode" },
{ name = "uvicorn" },
{ name = "watchman" },
{ name = "weasyprint" },
@@ -258,8 +298,10 @@ dev = [
{ name = "django-browser-reload" },
{ name = "django-debug-toolbar" },
{ name = "django-watchfiles" },
{ name = "pre-commit" },
{ name = "pytest" },
{ name = "pytest-django" },
{ name = "ruff" },
]
[package.metadata]
@@ -270,12 +312,15 @@ requires-dist = [
{ name = "django-extensions", specifier = ">=4.1" },
{ name = "django-filter", specifier = ">=26.1" },
{ 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-storages", specifier = ">=1.14.6" },
{ name = "django-storages", extras = ["s3"], specifier = ">=1.14.6" },
{ name = "ipython", specifier = ">=9.15.0" },
{ name = "pillow", specifier = ">=12.3.0" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.3.4" },
{ name = "pytest-cov", specifier = ">=7.1.0" },
{ name = "qrcode", specifier = ">=8.2" },
{ name = "uvicorn", specifier = ">=0.51.0" },
{ name = "watchman", specifier = ">=0.0.1" },
{ name = "weasyprint", specifier = ">=69.0" },
@@ -288,8 +333,10 @@ dev = [
{ name = "django-browser-reload", specifier = ">=1.21.0" },
{ name = "django-debug-toolbar", specifier = ">=7.0.0" },
{ name = "django-watchfiles", specifier = ">=1.4.0" },
{ name = "pre-commit", specifier = ">=4.6.2" },
{ name = "pytest", specifier = ">=9.1.1" },
{ name = "pytest-django", specifier = ">=4.12.0" },
{ name = "ruff", specifier = ">=0.16.7" },
]
[[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" },
]
[[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]]
name = "django"
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" },
]
[[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]]
name = "django-silk"
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" },
]
[package.optional-dependencies]
s3 = [
{ name = "boto3" },
]
[[package]]
name = "django-watchfiles"
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" },
]
[[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]]
name = "fonttools"
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" },
]
[[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]]
name = "idna"
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" },
]
[[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]]
name = "matplotlib-inline"
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" },
]
[[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]]
name = "packaging"
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" },
]
[[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]]
name = "pluggy"
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" },
]
[[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]]
name = "prompt-toolkit"
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" },
]
[[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]]
name = "sqlparse"
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" },
]
[[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]]
name = "uvicorn"
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" },
]
[[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]]
name = "watchfiles"
version = "1.2.0"