feat: added SPA support and speed up tests
CI / test (push) Successful in 1m2s
CI / build (push) Successful in 22s

This commit is contained in:
2026-09-16 16:16:34 +02:00
parent a462c1595d
commit e76b4b7a9c
15 changed files with 358 additions and 26 deletions
+17
View File
@@ -18,10 +18,27 @@ from django.conf import settings
from django.conf.urls.i18n import i18n_patterns
from django.contrib import admin
from django.urls import include, path
from django.views.generic import TemplateView
from django.views.static import serve
urlpatterns = [
path('admin/', admin.site.urls),
# Sin prefijo de idioma (fuera de i18n_patterns, ver más abajo) ni bajo
# STATIC_URL: sw.js necesita servirse desde la raíz para que su ámbito
# (qué rutas puede interceptar un Service Worker) cubra el origen
# entero, no solo /static/ o /es/ (ver crochet/templates/sw.js). Se
# renderiza como plantilla (no un archivo estático de whitenoise) para
# que {% static %}/{% url %} resuelvan ahí mismo las rutas (con hash)
# vigentes en cada respuesta.
path(
'sw.js', name='service_worker',
view=TemplateView.as_view(template_name='sw.js', content_type='application/javascript'),
),
path(
'manifest.json', name='manifest',
view=TemplateView.as_view(template_name='manifest.json', content_type='application/manifest+json'),
),
path('offline/', name='offline', view=TemplateView.as_view(template_name='offline.html')),
]
# i18n_patterns antepone el prefijo de idioma (/es/..., /en/...) a cada URL,
+15
View File
@@ -6,6 +6,21 @@ from django.core.management import call_command
from django.test import override_settings
@pytest.fixture(scope='session', autouse=True)
def fast_password_hasher():
"""PBKDF2PasswordHasher (por defecto, ver AUTH_PASSWORD_VALIDATORS) es
deliberadamente lento -es justo lo que lo hace seguro en producción-,
pero eso mismo hace bastante caro cada create_user()/check_password()/
login de los tests: con cientos de ellos (y los de rate limiting, que
encima repiten el login 10-11 veces seguidas) esto es la mayor parte
del tiempo de toda la suite. MD5PasswordHasher no vale para producción,
pero aquí no protege nada real -las contraseñas son literales fijos
como 'a-very-uncommon-pw-1'-, así que no hay ninguna pérdida de
seguridad en usarlo solo para tests."""
with override_settings(PASSWORD_HASHERS=['django.contrib.auth.hashers.MD5PasswordHasher']):
yield
@pytest.fixture(autouse=True)
def clear_ratelimit_cache():
"""django-ratelimit (ver LOGIN_RATE/REGISTER_RATE/PASSWORD_RESET_RATE en
+31 -15
View File
@@ -2,7 +2,7 @@ msgid ""
msgstr ""
"Project-Id-Version: crochet\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-16 11:16+0000\n"
"POT-Creation-Date: 2026-09-16 14:05+0000\n"
"Language: en\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
@@ -46,7 +46,7 @@ msgstr ""
"made in a row from here."
#: crochet/templates/403.html:24 crochet/templates/404.html:19
#: crochet/templates/500.html:64
#: crochet/templates/500.html:65
#: crochet/templates/crochet/cookie_policy.html:54
msgid "Volver al inicio"
msgstr "Back to home"
@@ -63,11 +63,11 @@ msgstr "The page you're looking for doesn't exist or has moved."
msgid "Error del servidor"
msgstr "Server error"
#: crochet/templates/500.html:62
#: crochet/templates/500.html:63
msgid "Vaya, algo ha ido mal"
msgstr "Oops, something went wrong"
#: crochet/templates/500.html:63
#: crochet/templates/500.html:64
msgid "Ha ocurrido un error inesperado. Prueba a recargar en un momento."
msgstr "An unexpected error occurred. Try reloading in a moment."
@@ -225,9 +225,8 @@ msgid "Actualizado el %(date)s"
msgstr "Updated on %(date)s"
#: crochet/templates/crochet/account_home.html:44
#: crochet/templates/crochet/pattern.html:156
msgid "Vista previa"
msgstr "Preview"
msgid "Editar"
msgstr "Edit"
#: crochet/templates/crochet/account_home.html:48
#: crochet/templates/crochet/pattern.html:46
@@ -240,7 +239,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:62
#: crochet/templates/crochet/base.html:71
msgid "Ajustes de la cuenta"
msgstr "Account settings"
@@ -274,19 +273,19 @@ msgstr ""
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:37
#: crochet/templates/crochet/base.html:46
msgid "Cambiar tema"
msgstr "Change theme"
#: crochet/templates/crochet/base.html:49
#: crochet/templates/crochet/base.html:58
msgid "Cambiar idioma"
msgstr "Change language"
#: crochet/templates/crochet/base.html:66
#: crochet/templates/crochet/base.html:75
msgid "Cerrar sesión"
msgstr "Log out"
#: crochet/templates/crochet/base.html:72
#: crochet/templates/crochet/base.html:81
#: crochet/templates/crochet/home.html:25
#: crochet/templates/crochet/password_reset_complete.html:18
#: crochet/templates/registration/login.html:4
@@ -758,6 +757,10 @@ msgstr "Landscape"
msgid "Secciones"
msgstr "Sections"
#: crochet/templates/crochet/pattern.html:156
msgid "Vista previa"
msgstr "Preview"
#: crochet/templates/crochet/pattern.html:166
msgid "Idioma del patrón"
msgstr "Pattern language"
@@ -906,6 +909,22 @@ msgstr ""
msgid "Confirmar"
msgstr "Confirm"
#: crochet/templates/offline.html:20 crochet/templates/offline.html:70
msgid "Sin conexión"
msgstr "No connection"
#: crochet/templates/offline.html:71
msgid ""
"No se ha podido cargar esta página porque no tienes conexión a internet. "
"Comprueba tu conexión e inténtalo de nuevo."
msgstr ""
"This page couldn't be loaded because you're not connected to the internet. "
"Check your connection and try again."
#: crochet/templates/offline.html:72
msgid "Reintentar"
msgstr "Retry"
#: crochet/templates/registration/login.html:17
msgid "Usuario o contraseña incorrectos."
msgstr "Incorrect username or password."
@@ -1109,8 +1128,5 @@ msgstr "Your account has been deleted successfully."
#~ msgid "Idioma"
#~ msgstr "Language"
#~ msgid "Editar"
#~ msgstr "Edit"
#~ msgid "Eliminar patrón"
#~ msgstr "Delete pattern"
+28 -11
View File
@@ -2,7 +2,7 @@ msgid ""
msgstr ""
"Project-Id-Version: crochet\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-16 11:16+0000\n"
"POT-Creation-Date: 2026-09-16 14:05+0000\n"
"Language: es\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
@@ -44,7 +44,7 @@ msgid ""
msgstr ""
#: crochet/templates/403.html:24 crochet/templates/404.html:19
#: crochet/templates/500.html:64
#: crochet/templates/500.html:65
#: crochet/templates/crochet/cookie_policy.html:54
msgid "Volver al inicio"
msgstr ""
@@ -61,11 +61,11 @@ msgstr ""
msgid "Error del servidor"
msgstr ""
#: crochet/templates/500.html:62
#: crochet/templates/500.html:63
msgid "Vaya, algo ha ido mal"
msgstr ""
#: crochet/templates/500.html:63
#: crochet/templates/500.html:64
msgid "Ha ocurrido un error inesperado. Prueba a recargar en un momento."
msgstr ""
@@ -215,8 +215,7 @@ msgid "Actualizado el %(date)s"
msgstr ""
#: crochet/templates/crochet/account_home.html:44
#: crochet/templates/crochet/pattern.html:156
msgid "Vista previa"
msgid "Editar"
msgstr ""
#: crochet/templates/crochet/account_home.html:48
@@ -230,7 +229,7 @@ msgstr ""
#: crochet/templates/crochet/account_settings.html:4
#: crochet/templates/crochet/account_settings.html:18
#: crochet/templates/crochet/base.html:62
#: crochet/templates/crochet/base.html:71
msgid "Ajustes de la cuenta"
msgstr ""
@@ -262,19 +261,19 @@ msgstr ""
msgid "Esto borrará tu cuenta y todos tus patrones de forma permanente."
msgstr ""
#: crochet/templates/crochet/base.html:37
#: crochet/templates/crochet/base.html:46
msgid "Cambiar tema"
msgstr ""
#: crochet/templates/crochet/base.html:49
#: crochet/templates/crochet/base.html:58
msgid "Cambiar idioma"
msgstr ""
#: crochet/templates/crochet/base.html:66
#: crochet/templates/crochet/base.html:75
msgid "Cerrar sesión"
msgstr ""
#: crochet/templates/crochet/base.html:72
#: crochet/templates/crochet/base.html:81
#: crochet/templates/crochet/home.html:25
#: crochet/templates/crochet/password_reset_complete.html:18
#: crochet/templates/registration/login.html:4
@@ -718,6 +717,10 @@ msgstr ""
msgid "Secciones"
msgstr ""
#: crochet/templates/crochet/pattern.html:156
msgid "Vista previa"
msgstr ""
#: crochet/templates/crochet/pattern.html:166
msgid "Idioma del patrón"
msgstr ""
@@ -860,6 +863,20 @@ msgstr ""
msgid "Confirmar"
msgstr ""
#: crochet/templates/offline.html:20 crochet/templates/offline.html:70
msgid "Sin conexión"
msgstr ""
#: crochet/templates/offline.html:71
msgid ""
"No se ha podido cargar esta página porque no tienes conexión a internet. "
"Comprueba tu conexión e inténtalo de nuevo."
msgstr ""
#: crochet/templates/offline.html:72
msgid "Reintentar"
msgstr ""
#: crochet/templates/registration/login.html:17
msgid "Usuario o contraseña incorrectos."
msgstr ""
Binary file not shown.

After

Width:  |  Height:  |  Size: 490 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

+18
View File
@@ -0,0 +1,18 @@
// Registra el Service Worker de la PWA (ver crochet/templates/sw.js) para
// que la app sea instalable y tenga soporte offline básico. La ruta va a
// pelo ('/sw.js', no bajo /static/ ni con prefijo de idioma) porque tiene
// que coincidir exactamente con dónde lo sirve config/urls.py: el ámbito
// de un Service Worker es la carpeta de su propia URL y las de debajo, así
// que solo sirviéndolo (y registrándolo) desde la raíz cubre el origen
// entero en vez de quedarse limitado a /static/ o /es/.
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {
// Sin conexión, en un navegador sin soporte real pese a exponer el
// objeto, o serviceWorker.register() con documento no seguro (http
// sin ser localhost): la app sigue funcionando con normalidad, solo
// sin lo que aporta la PWA (instalación, caché offline), así que no
// hace falta avisar a quien la usa.
});
});
}
+1
View File
@@ -14,6 +14,7 @@
<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' %}">
<link rel="icon" href="{% static 'img/favicon.ico' %}">
<style>
body {
display: flex;
+10
View File
@@ -18,6 +18,15 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Crochet{% endblock %}</title>
<link rel="stylesheet" href="{% static 'css/main.css' %}">
<!-- PWA: instalable (manifest + iconos) y con soporte offline básico
(ver crochet/templates/sw.js, registrado al final de este documento).
pattern_detail.html (vista de solo lectura/PDF) hereda esto también:
no hace daño -no vuelve a registrar nada, el navegador ya lo tiene
activo- e instalar la PWA desde ahí sigue llevando al mismo shell. -->
<link rel="manifest" href="{% url 'manifest' %}">
<meta name="theme-color" content="#f1641e">
<link rel="icon" href="{% static 'img/favicon.ico' %}">
<link rel="apple-touch-icon" href="{% static 'img/icon-192.png' %}">
{% block extra_css %}{% endblock %}
</head>
@@ -75,6 +84,7 @@
</nav>
{% endblock %}
<script defer src="{% static 'js/theme-toggle.js' %}"></script>
<script defer src="{% static 'js/sw-register.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
+23
View File
@@ -0,0 +1,23 @@
{% load static %}{
"name": "Crochet",
"short_name": "Crochet",
"description": "Diseña patrones de crochet con un editor visual y expórtalos a PDF.",
"lang": "es",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#fffbf5",
"theme_color": "#f1641e",
"icons": [
{
"src": "{% static 'img/icon-192.png' %}",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "{% static 'img/icon-512.png' %}",
"sizes": "512x512",
"type": "image/png"
}
]
}
+76
View File
@@ -0,0 +1,76 @@
{% load i18n static %}
<!DOCTYPE html>
<!-- Autónoma, sin extender crochet/base.html: la sirve el Service Worker
(ver sw.js) como respuesta de reserva cuando falla una navegación sin
conexión, así que no puede depender de nada que a su vez necesite red
(el CDN de daisyUI/Tailwind, Google Fonts) ni de context processors -
mismo motivo que 500.html, que ya sigue este mismo patrón. -->
<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 'Sin conexión' %}</title>
<link rel="stylesheet" href="{% static 'css/main.css' %}">
<link rel="icon" href="{% static 'img/favicon.ico' %}">
<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 button {
border: none;
background-color: var(--brand-primary);
color: var(--brand-primary-content);
text-decoration: none;
font-weight: 600;
padding: .5rem 1.5rem;
border-radius: 999px;
cursor: pointer;
font-size: .9rem;
}
</style>
</head>
<body>
<div class="box">
<h1>{% trans 'Sin conexión' %}</h1>
<p>{% trans 'No se ha podido cargar esta página porque no tienes conexión a internet. Comprueba tu conexión e inténtalo de nuevo.' %}</p>
<button type="button" onclick="location.reload()">{% trans 'Reintentar' %}</button>
</div>
</body>
</html>
+96
View File
@@ -0,0 +1,96 @@
{% load static %}
// Service Worker de la PWA: soporte offline básico, no un cache completo de
// la aplicación (los patrones son contenido dinámico, guardado en base de
// datos, y no se cachean aquí). Se limita a dos cosas:
// 1. Cachear (cache-first) los estáticos propios de la app shell
// (main.css, theme.css, iconos), para que carguen al instante y sigan
// disponibles sin conexión.
// 2. Si una navegación (clic en un enlace, recargar la página) falla por
// no haber red, responder con la página de "sin conexión" (ver
// crochet/templates/offline.html) en vez del error genérico del
// navegador.
// Todo lo demás (peticiones a /media/, fetch de la propia app a
// pattern_save/pattern_image_upload, el CDN de daisyUI/Tailwind/Google
// Fonts) se deja pasar sin interceptar: ni se cachea ni se sirve un
// fallback, el navegador hace lo de siempre.
//
// Esta plantilla la sirve el TemplateView "service_worker" (ver
// config/urls.py) con content_type=application/javascript, no whitenoise:
// así el tag "static" de Django resuelve aquí el nombre real (con hash) de
// cada archivo en el momento de servir esta respuesta, en vez de tener que
// mantenerlo a mano.
//
// CACHE_NAME es fijo a propósito (no calculado a partir del hash de los
// estáticos): el propio hash cambia el contenido de este archivo cada vez
// que cambia main.css/theme.css, y los navegadores comparan el Service
// Worker byte a byte en cada visita para detectar actualizaciones -eso ya
// basta para que se reinstale solo y su "install" vuelva a poblar el cache
// con las URLs (con hash) nuevas-. Subir el número a mano de vez en cuando
// (aquí y en ningún otro sitio) sirve solo para forzar que "activate"
// limpie del todo cachés de una versión muy antigua, ver más abajo.
const CACHE_NAME = 'crochet-shell-v1';
const OFFLINE_URL = '{% url "offline" %}';
const APP_SHELL = [
OFFLINE_URL,
'{% static "css/main.css" %}',
'{% static "css/theme.css" %}',
'{% static "img/icon-192.png" %}',
'{% static "img/icon-512.png" %}',
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => cache.addAll(APP_SHELL))
// skipWaiting: sin esto, un Service Worker nuevo se queda en
// "waiting" hasta que se cierran todas las pestañas con el antiguo
// todavía activo, así que la próxima visita seguiría sirviendo el
// shell (y el offline.html) de la versión vieja.
.then(() => self.skipWaiting()),
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys()
.then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))))
.then(() => self.clients.claim()),
);
});
self.addEventListener('fetch', (event) => {
const { request } = event;
// mode 'navigate': una petición de navegación de verdad (clic en un
// enlace, recargar, escribir la URL), no un fetch/XHR de main.js/
// storage.js ni la petición de una imagen/script. Es justo lo único
// para lo que tiene sentido el fallback de offline.html.
if (request.mode === 'navigate') {
event.respondWith(
fetch(request).catch(() => caches.match(OFFLINE_URL)),
);
return;
}
const url = new URL(request.url);
// Mismo origen y bajo STATIC_URL únicamente: ni /media/ (portadas/
// imágenes de patrón, que pueden pesar bastante y no forman parte del
// "shell") ni el CDN externo de daisyUI/Tailwind/Google Fonts, que ya
// tiene su propio cache HTTP de navegador y no conviene duplicar aquí.
if (url.origin === self.location.origin && url.pathname.startsWith('/static/')) {
event.respondWith(
caches.match(request).then((cached) => {
if (cached) {
return cached;
}
return fetch(request).then((response) => {
const responseCopy = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, responseCopy));
return response;
});
}),
);
}
});
+43
View File
@@ -0,0 +1,43 @@
from django.test import TestCase
from django.urls import reverse
class ServiceWorkerViewTests(TestCase):
def test_served_at_the_domain_root(self):
response = self.client.get('/sw.js')
self.assertEqual(response.status_code, 200)
self.assertEqual(response['Content-Type'], 'application/javascript')
def test_references_the_offline_page_and_own_static_assets(self):
response = self.client.get('/sw.js')
content = response.content.decode()
self.assertIn("OFFLINE_URL = '/offline/'", content)
self.assertIn('/static/css/main.', content)
self.assertIn('/static/css/theme.', content)
self.assertIn('/static/img/icon-192.', content)
self.assertIn('/static/img/icon-512.', content)
class ManifestViewTests(TestCase):
def test_served_with_the_manifest_content_type(self):
response = self.client.get('/manifest.json')
self.assertEqual(response.status_code, 200)
self.assertEqual(response['Content-Type'], 'application/manifest+json')
def test_references_both_icon_sizes(self):
response = self.client.get('/manifest.json')
self.assertContains(response, '/static/img/icon-192.')
self.assertContains(response, '/static/img/icon-512.')
class OfflineViewTests(TestCase):
def test_renders_the_offline_page(self):
response = self.client.get(reverse('offline'))
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Sin conexión')