feat: added SPA support and speed up tests
This commit is contained in:
@@ -18,10 +18,27 @@ from django.conf import settings
|
|||||||
from django.conf.urls.i18n import i18n_patterns
|
from django.conf.urls.i18n import i18n_patterns
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
|
from django.views.generic import TemplateView
|
||||||
from django.views.static import serve
|
from django.views.static import serve
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('admin/', admin.site.urls),
|
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,
|
# i18n_patterns antepone el prefijo de idioma (/es/..., /en/...) a cada URL,
|
||||||
|
|||||||
+15
@@ -6,6 +6,21 @@ from django.core.management import call_command
|
|||||||
from django.test import override_settings
|
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)
|
@pytest.fixture(autouse=True)
|
||||||
def clear_ratelimit_cache():
|
def clear_ratelimit_cache():
|
||||||
"""django-ratelimit (ver LOGIN_RATE/REGISTER_RATE/PASSWORD_RESET_RATE en
|
"""django-ratelimit (ver LOGIN_RATE/REGISTER_RATE/PASSWORD_RESET_RATE en
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: crochet\n"
|
"Project-Id-Version: crochet\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-09-16 11:16+0000\n"
|
"POT-Creation-Date: 2026-09-16 14:05+0000\n"
|
||||||
"Language: en\n"
|
"Language: en\n"
|
||||||
"MIME-Version: 1.0\n"
|
"MIME-Version: 1.0\n"
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
@@ -46,7 +46,7 @@ msgstr ""
|
|||||||
"made in a row from here."
|
"made in a row from here."
|
||||||
|
|
||||||
#: crochet/templates/403.html:24 crochet/templates/404.html:19
|
#: 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
|
#: crochet/templates/crochet/cookie_policy.html:54
|
||||||
msgid "Volver al inicio"
|
msgid "Volver al inicio"
|
||||||
msgstr "Back to home"
|
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"
|
msgid "Error del servidor"
|
||||||
msgstr "Server error"
|
msgstr "Server error"
|
||||||
|
|
||||||
#: crochet/templates/500.html:62
|
#: crochet/templates/500.html:63
|
||||||
msgid "Vaya, algo ha ido mal"
|
msgid "Vaya, algo ha ido mal"
|
||||||
msgstr "Oops, something went wrong"
|
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."
|
msgid "Ha ocurrido un error inesperado. Prueba a recargar en un momento."
|
||||||
msgstr "An unexpected error occurred. Try reloading in a moment."
|
msgstr "An unexpected error occurred. Try reloading in a moment."
|
||||||
|
|
||||||
@@ -225,9 +225,8 @@ msgid "Actualizado el %(date)s"
|
|||||||
msgstr "Updated on %(date)s"
|
msgstr "Updated on %(date)s"
|
||||||
|
|
||||||
#: crochet/templates/crochet/account_home.html:44
|
#: crochet/templates/crochet/account_home.html:44
|
||||||
#: crochet/templates/crochet/pattern.html:156
|
msgid "Editar"
|
||||||
msgid "Vista previa"
|
msgstr "Edit"
|
||||||
msgstr "Preview"
|
|
||||||
|
|
||||||
#: crochet/templates/crochet/account_home.html:48
|
#: crochet/templates/crochet/account_home.html:48
|
||||||
#: crochet/templates/crochet/pattern.html:46
|
#: 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:4
|
||||||
#: crochet/templates/crochet/account_settings.html:18
|
#: crochet/templates/crochet/account_settings.html:18
|
||||||
#: crochet/templates/crochet/base.html:62
|
#: crochet/templates/crochet/base.html:71
|
||||||
msgid "Ajustes de la cuenta"
|
msgid "Ajustes de la cuenta"
|
||||||
msgstr "Account settings"
|
msgstr "Account settings"
|
||||||
|
|
||||||
@@ -274,19 +273,19 @@ msgstr ""
|
|||||||
msgid "Esto borrará tu cuenta y todos tus patrones de forma permanente."
|
msgid "Esto borrará tu cuenta y todos tus patrones de forma permanente."
|
||||||
msgstr "This will permanently delete your account and all your patterns."
|
msgstr "This will permanently delete your account and all your patterns."
|
||||||
|
|
||||||
#: crochet/templates/crochet/base.html:37
|
#: crochet/templates/crochet/base.html:46
|
||||||
msgid "Cambiar tema"
|
msgid "Cambiar tema"
|
||||||
msgstr "Change theme"
|
msgstr "Change theme"
|
||||||
|
|
||||||
#: crochet/templates/crochet/base.html:49
|
#: crochet/templates/crochet/base.html:58
|
||||||
msgid "Cambiar idioma"
|
msgid "Cambiar idioma"
|
||||||
msgstr "Change language"
|
msgstr "Change language"
|
||||||
|
|
||||||
#: crochet/templates/crochet/base.html:66
|
#: crochet/templates/crochet/base.html:75
|
||||||
msgid "Cerrar sesión"
|
msgid "Cerrar sesión"
|
||||||
msgstr "Log out"
|
msgstr "Log out"
|
||||||
|
|
||||||
#: crochet/templates/crochet/base.html:72
|
#: crochet/templates/crochet/base.html:81
|
||||||
#: crochet/templates/crochet/home.html:25
|
#: crochet/templates/crochet/home.html:25
|
||||||
#: crochet/templates/crochet/password_reset_complete.html:18
|
#: crochet/templates/crochet/password_reset_complete.html:18
|
||||||
#: crochet/templates/registration/login.html:4
|
#: crochet/templates/registration/login.html:4
|
||||||
@@ -758,6 +757,10 @@ msgstr "Landscape"
|
|||||||
msgid "Secciones"
|
msgid "Secciones"
|
||||||
msgstr "Sections"
|
msgstr "Sections"
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/pattern.html:156
|
||||||
|
msgid "Vista previa"
|
||||||
|
msgstr "Preview"
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:166
|
#: crochet/templates/crochet/pattern.html:166
|
||||||
msgid "Idioma del patrón"
|
msgid "Idioma del patrón"
|
||||||
msgstr "Pattern language"
|
msgstr "Pattern language"
|
||||||
@@ -906,6 +909,22 @@ msgstr ""
|
|||||||
msgid "Confirmar"
|
msgid "Confirmar"
|
||||||
msgstr "Confirm"
|
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
|
#: crochet/templates/registration/login.html:17
|
||||||
msgid "Usuario o contraseña incorrectos."
|
msgid "Usuario o contraseña incorrectos."
|
||||||
msgstr "Incorrect username or password."
|
msgstr "Incorrect username or password."
|
||||||
@@ -1109,8 +1128,5 @@ msgstr "Your account has been deleted successfully."
|
|||||||
#~ msgid "Idioma"
|
#~ msgid "Idioma"
|
||||||
#~ msgstr "Language"
|
#~ msgstr "Language"
|
||||||
|
|
||||||
#~ msgid "Editar"
|
|
||||||
#~ msgstr "Edit"
|
|
||||||
|
|
||||||
#~ msgid "Eliminar patrón"
|
#~ msgid "Eliminar patrón"
|
||||||
#~ msgstr "Delete pattern"
|
#~ msgstr "Delete pattern"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: crochet\n"
|
"Project-Id-Version: crochet\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-09-16 11:16+0000\n"
|
"POT-Creation-Date: 2026-09-16 14:05+0000\n"
|
||||||
"Language: es\n"
|
"Language: es\n"
|
||||||
"MIME-Version: 1.0\n"
|
"MIME-Version: 1.0\n"
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
@@ -44,7 +44,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/403.html:24 crochet/templates/404.html:19
|
#: 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
|
#: crochet/templates/crochet/cookie_policy.html:54
|
||||||
msgid "Volver al inicio"
|
msgid "Volver al inicio"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -61,11 +61,11 @@ msgstr ""
|
|||||||
msgid "Error del servidor"
|
msgid "Error del servidor"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/500.html:62
|
#: crochet/templates/500.html:63
|
||||||
msgid "Vaya, algo ha ido mal"
|
msgid "Vaya, algo ha ido mal"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/500.html:63
|
#: crochet/templates/500.html:64
|
||||||
msgid "Ha ocurrido un error inesperado. Prueba a recargar en un momento."
|
msgid "Ha ocurrido un error inesperado. Prueba a recargar en un momento."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -215,8 +215,7 @@ msgid "Actualizado el %(date)s"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/account_home.html:44
|
#: crochet/templates/crochet/account_home.html:44
|
||||||
#: crochet/templates/crochet/pattern.html:156
|
msgid "Editar"
|
||||||
msgid "Vista previa"
|
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/account_home.html:48
|
#: 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:4
|
||||||
#: crochet/templates/crochet/account_settings.html:18
|
#: crochet/templates/crochet/account_settings.html:18
|
||||||
#: crochet/templates/crochet/base.html:62
|
#: crochet/templates/crochet/base.html:71
|
||||||
msgid "Ajustes de la cuenta"
|
msgid "Ajustes de la cuenta"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -262,19 +261,19 @@ msgstr ""
|
|||||||
msgid "Esto borrará tu cuenta y todos tus patrones de forma permanente."
|
msgid "Esto borrará tu cuenta y todos tus patrones de forma permanente."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/base.html:37
|
#: crochet/templates/crochet/base.html:46
|
||||||
msgid "Cambiar tema"
|
msgid "Cambiar tema"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/base.html:49
|
#: crochet/templates/crochet/base.html:58
|
||||||
msgid "Cambiar idioma"
|
msgid "Cambiar idioma"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/base.html:66
|
#: crochet/templates/crochet/base.html:75
|
||||||
msgid "Cerrar sesión"
|
msgid "Cerrar sesión"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/base.html:72
|
#: crochet/templates/crochet/base.html:81
|
||||||
#: crochet/templates/crochet/home.html:25
|
#: crochet/templates/crochet/home.html:25
|
||||||
#: crochet/templates/crochet/password_reset_complete.html:18
|
#: crochet/templates/crochet/password_reset_complete.html:18
|
||||||
#: crochet/templates/registration/login.html:4
|
#: crochet/templates/registration/login.html:4
|
||||||
@@ -718,6 +717,10 @@ msgstr ""
|
|||||||
msgid "Secciones"
|
msgid "Secciones"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: crochet/templates/crochet/pattern.html:156
|
||||||
|
msgid "Vista previa"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: crochet/templates/crochet/pattern.html:166
|
#: crochet/templates/crochet/pattern.html:166
|
||||||
msgid "Idioma del patrón"
|
msgid "Idioma del patrón"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -860,6 +863,20 @@ msgstr ""
|
|||||||
msgid "Confirmar"
|
msgid "Confirmar"
|
||||||
msgstr ""
|
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
|
#: crochet/templates/registration/login.html:17
|
||||||
msgid "Usuario o contraseña incorrectos."
|
msgid "Usuario o contraseña incorrectos."
|
||||||
msgstr ""
|
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 |
@@ -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.
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{% trans 'Error del servidor' %}</title>
|
<title>{% trans 'Error del servidor' %}</title>
|
||||||
<link rel="stylesheet" href="{% static 'css/main.css' %}">
|
<link rel="stylesheet" href="{% static 'css/main.css' %}">
|
||||||
|
<link rel="icon" href="{% static 'img/favicon.ico' %}">
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -18,6 +18,15 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{% block title %}Crochet{% endblock %}</title>
|
<title>{% block title %}Crochet{% endblock %}</title>
|
||||||
<link rel="stylesheet" href="{% static 'css/main.css' %}">
|
<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 %}
|
{% block extra_css %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
@@ -75,6 +84,7 @@
|
|||||||
</nav>
|
</nav>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
<script defer src="{% static 'js/theme-toggle.js' %}"></script>
|
<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
|
<!-- Mensajes de una sola vez tras una redirección (ver messages.success
|
||||||
en AccountDeleteView): no hay ninguna página a la que volver con el
|
en AccountDeleteView): no hay ninguna página a la que volver con el
|
||||||
resultado de la acción (la sesión ya no existe), así que se muestran
|
resultado de la acción (la sesión ya no existe), así que se muestran
|
||||||
|
|||||||
@@ -0,0 +1,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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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')
|
||||||
Reference in New Issue
Block a user