From e76b4b7a9ce4ea14501972a4e471cd8918827a41 Mon Sep 17 00:00:00 2001 From: Pablo Moreno Date: Wed, 16 Sep 2026 16:16:34 +0200 Subject: [PATCH] feat: added SPA support and speed up tests --- config/urls.py | 17 +++++ conftest.py | 15 ++++ crochet/locale/en/LC_MESSAGES/django.po | 46 ++++++++---- crochet/locale/es/LC_MESSAGES/django.po | 39 +++++++--- crochet/static/img/favicon-32.png | Bin 0 -> 490 bytes crochet/static/img/favicon.ico | Bin 0 -> 3244 bytes crochet/static/img/icon-192.png | Bin 0 -> 3155 bytes crochet/static/img/icon-512.png | Bin 0 -> 9434 bytes crochet/static/js/sw-register.js | 18 +++++ crochet/templates/500.html | 1 + crochet/templates/crochet/base.html | 10 +++ crochet/templates/manifest.json | 23 ++++++ crochet/templates/offline.html | 76 +++++++++++++++++++ crochet/templates/sw.js | 96 ++++++++++++++++++++++++ crochet/tests/test_pwa.py | 43 +++++++++++ 15 files changed, 358 insertions(+), 26 deletions(-) create mode 100644 crochet/static/img/favicon-32.png create mode 100644 crochet/static/img/favicon.ico create mode 100644 crochet/static/img/icon-192.png create mode 100644 crochet/static/img/icon-512.png create mode 100644 crochet/static/js/sw-register.js create mode 100644 crochet/templates/manifest.json create mode 100644 crochet/templates/offline.html create mode 100644 crochet/templates/sw.js create mode 100644 crochet/tests/test_pwa.py diff --git a/config/urls.py b/config/urls.py index 85781d5..0c7368f 100644 --- a/config/urls.py +++ b/config/urls.py @@ -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, diff --git a/conftest.py b/conftest.py index 9181ab0..ec99e7f 100644 --- a/conftest.py +++ b/conftest.py @@ -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 diff --git a/crochet/locale/en/LC_MESSAGES/django.po b/crochet/locale/en/LC_MESSAGES/django.po index 1599d98..3ee3494 100644 --- a/crochet/locale/en/LC_MESSAGES/django.po +++ b/crochet/locale/en/LC_MESSAGES/django.po @@ -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" diff --git a/crochet/locale/es/LC_MESSAGES/django.po b/crochet/locale/es/LC_MESSAGES/django.po index a396d52..9399c4c 100644 --- a/crochet/locale/es/LC_MESSAGES/django.po +++ b/crochet/locale/es/LC_MESSAGES/django.po @@ -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 "" diff --git a/crochet/static/img/favicon-32.png b/crochet/static/img/favicon-32.png new file mode 100644 index 0000000000000000000000000000000000000000..c353a042d7464340dc60e2dc3fa56c54909497ca GIT binary patch literal 490 zcmV!9L=izj zB~}t04nzpwQXDS zLzr9U3=qA7oO=Ha2!(Wb0lR+}WY`6W1E6Cw5LY%J9bbYBIa(L1tAmwr6jCr`(Mkw_ z<-Sk|?br8$E$v>A%Ro-WPJvQ8 g0p0DmA)eRsA2_0xuN#MCEdT%j07*qoM6N<$f-xV_?EnA( literal 0 HcmV?d00001 diff --git a/crochet/static/img/favicon.ico b/crochet/static/img/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..ac704f6201d64caedda787fd2b5b29567630e67c GIT binary patch literal 3244 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 B4wL`@ literal 0 HcmV?d00001 diff --git a/crochet/static/img/icon-192.png b/crochet/static/img/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..41c4c1dff195ca0ebdcc2ccccec44e04224c0e1e GIT binary patch literal 3155 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 literal 0 HcmV?d00001 diff --git a/crochet/static/js/sw-register.js b/crochet/static/js/sw-register.js new file mode 100644 index 0000000..aed958e --- /dev/null +++ b/crochet/static/js/sw-register.js @@ -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. + }); + }); +} diff --git a/crochet/templates/500.html b/crochet/templates/500.html index 7f46908..eb52617 100644 --- a/crochet/templates/500.html +++ b/crochet/templates/500.html @@ -14,6 +14,7 @@ {% trans 'Error del servidor' %} + + + + +
+

{% trans 'Sin conexión' %}

+

{% 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.' %}

+ +
+ + + diff --git a/crochet/templates/sw.js b/crochet/templates/sw.js new file mode 100644 index 0000000..cb40d53 --- /dev/null +++ b/crochet/templates/sw.js @@ -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; + }); + }), + ); + } +}); diff --git a/crochet/tests/test_pwa.py b/crochet/tests/test_pwa.py new file mode 100644 index 0000000..54ee3de --- /dev/null +++ b/crochet/tests/test_pwa.py @@ -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')