feat: add django-whitenoise

This commit is contained in:
Loïc Gremaud 2026-07-14 20:29:29 +02:00
parent 9a795dc3c9
commit f986105cf5
Signed by: Legrems
GPG Key ID: D4620E6DF3E0121D
8 changed files with 73 additions and 5 deletions

View File

@ -34,6 +34,26 @@ DJANGO_CSRF_TRUSTED_ORIGINS=http://192.168.1.x:8000 uv run python manage.py runs
Sessions last a year, so your phone stays logged in. Sessions last a year, so your phone stays logged in.
## Static files
WhiteNoise serves `/static/` from Django itself, so there is no nginx or S3 to
set up. `collectstatic` hashes every file's contents into its name and writes
gzip/brotli copies alongside, and WhiteNoise serves those with a ten-year
immutable cache header — worth having on a phone over a slow connection.
```bash
uv run python manage.py collectstatic --noinput
```
Run that whenever an icon or CSS file changes. In `DEBUG=1` Django serves the
files directly and you can skip it; with `DEBUG=0` the hashed manifest must
exist or pages referencing `{% static %}` will fail.
`manifest.webmanifest` and `sw.js` are templates rather than static files: the
manifest so `{% static %}` resolves the icons to their hashed names (the
staticfiles storage only rewrites URLs inside CSS and JS), and the worker
because it has to be served from `/` to control the `/admin/` pages it caches.
## Installing it on Android (PWA) ## Installing it on Android (PWA)
The Today page is a progressive web app: Chrome will offer *Add to home screen* The Today page is a progressive web app: Chrome will offer *Add to home screen*

View File

@ -54,6 +54,9 @@ INSTALLED_APPS = [
MIDDLEWARE = [ MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware", "django.middleware.security.SecurityMiddleware",
# Directly below SecurityMiddleware and above everything else, per WhiteNoise's
# docs: it serves /static/ itself so no nginx/S3 is needed in front of Django.
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware", "django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware", "django.middleware.csrf.CsrfViewMiddleware",
@ -132,6 +135,15 @@ STATIC_ROOT = BASE_DIR / "static"
# PWA icons and manifest live here; STATIC_ROOT is only the collectstatic output. # PWA icons and manifest live here; STATIC_ROOT is only the collectstatic output.
STATICFILES_DIRS = [BASE_DIR / "assets"] STATICFILES_DIRS = [BASE_DIR / "assets"]
STORAGES = {
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {
# Hashes each file's contents into its name and gzip/brotli-compresses it,
# so WhiteNoise can serve it with a far-future cache header.
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
},
}
# Default primary key field type # Default primary key field type
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"

View File

@ -20,6 +20,16 @@ urlpatterns = [
), ),
name="service-worker", name="service-worker",
), ),
# A template, not a static file, so {% static %} resolves the icons to their
# hashed names — .webmanifest is not URL-rewritten by the staticfiles storage.
path(
"manifest.webmanifest",
TemplateView.as_view(
template_name="manifest.webmanifest",
content_type="application/manifest+json",
),
name="manifest",
),
# Sits under /admin/ so it inherits the admin's styling and login, but must # Sits under /admin/ so it inherits the admin's styling and login, but must
# come before the admin's own catch-all patterns. # come before the admin's own catch-all patterns.
path("admin/", include("tracker.urls")), path("admin/", include("tracker.urls")),

View File

@ -7,6 +7,7 @@ requires-python = ">=3.14"
dependencies = [ dependencies = [
"django>=5.2", "django>=5.2",
"django-ninja>=1.4", "django-ninja>=1.4",
"whitenoise>=6.12.0",
] ]
[dependency-groups] [dependency-groups]

View File

@ -5,7 +5,7 @@
{% block extrahead %} {% block extrahead %}
{{ block.super }} {{ block.super }}
<link rel="manifest" href="{% static 'manifest.webmanifest' %}"> <link rel="manifest" href="{% url 'manifest' %}">
<meta name="theme-color" content="#202a35"> <meta name="theme-color" content="#202a35">
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<link rel="apple-touch-icon" href="{% static 'icons/icon-192.png' %}"> <link rel="apple-touch-icon" href="{% static 'icons/icon-192.png' %}">

View File

@ -1,4 +1,4 @@
{ {% load static %}{
"name": "Osiris", "name": "Osiris",
"short_name": "Osiris", "short_name": "Osiris",
"description": "Medication and pulse tracking for Osiris the cat.", "description": "Medication and pulse tracking for Osiris the cat.",
@ -10,19 +10,19 @@
"theme_color": "#202a35", "theme_color": "#202a35",
"icons": [ "icons": [
{ {
"src": "/static/icons/icon-192.png", "src": "{% static 'icons/icon-192.png' %}",
"sizes": "192x192", "sizes": "192x192",
"type": "image/png", "type": "image/png",
"purpose": "any" "purpose": "any"
}, },
{ {
"src": "/static/icons/icon-512.png", "src": "{% static 'icons/icon-512.png' %}",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png", "type": "image/png",
"purpose": "any" "purpose": "any"
}, },
{ {
"src": "/static/icons/icon-maskable-512.png", "src": "{% static 'icons/icon-maskable-512.png' %}",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png", "type": "image/png",
"purpose": "maskable" "purpose": "maskable"

View File

@ -1,5 +1,7 @@
import json
from datetime import timedelta from datetime import timedelta
from django.conf import settings
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
@ -246,6 +248,18 @@ class PwaTests(TrackerTestCase):
self.assertContains(response, 'rel="manifest"') self.assertContains(response, 'rel="manifest"')
self.assertContains(response, "serviceWorker.register") self.assertContains(response, "serviceWorker.register")
def test_the_manifest_is_valid_json_pointing_at_real_icons(self):
response = self.client.get(reverse("manifest"))
self.assertEqual(response.status_code, 200)
self.assertEqual(response["Content-Type"], "application/manifest+json")
manifest = json.loads(response.content)
self.assertEqual(manifest["start_url"], reverse("tracker:today"))
self.assertEqual(len(manifest["icons"]), 3)
for icon in manifest["icons"]:
self.assertTrue(icon["src"].startswith(settings.STATIC_URL))
def test_the_login_page_is_installable_too(self): def test_the_login_page_is_installable_too(self):
# The app opens on the login screen when the session lapses. # The app opens on the login screen when the session lapses.
response = self.client.get("/admin/login/") response = self.client.get("/admin/login/")

11
uv.lock
View File

@ -160,6 +160,7 @@ source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "django" }, { name = "django" },
{ name = "django-ninja" }, { name = "django-ninja" },
{ name = "whitenoise" },
] ]
[package.dev-dependencies] [package.dev-dependencies]
@ -173,6 +174,7 @@ dev = [
requires-dist = [ requires-dist = [
{ name = "django", specifier = ">=5.2" }, { name = "django", specifier = ">=5.2" },
{ name = "django-ninja", specifier = ">=1.4" }, { name = "django-ninja", specifier = ">=1.4" },
{ name = "whitenoise", specifier = ">=6.12.0" },
] ]
[package.metadata.requires-dev] [package.metadata.requires-dev]
@ -415,3 +417,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288be
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" }, { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" },
] ]
[[package]]
name = "whitenoise"
version = "6.12.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cb/2a/55b3f3a4ec326cd077c1c3defeee656b9298372a69229134d930151acd01/whitenoise-6.12.0.tar.gz", hash = "sha256:f723ebb76a112e98816ff80fcea0a6c9b8ecde835f8ddda25df7a30a3c2db6ad", size = 26841, upload-time = "2026-02-27T00:05:42.028Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/db/eb/d5583a11486211f3ebd4b385545ae787f32363d453c19fffd81106c9c138/whitenoise-6.12.0-py3-none-any.whl", hash = "sha256:fc5e8c572e33ebf24795b47b6a7da8da3c00cff2349f5b04c02f28d0cc5a3cc2", size = 20302, upload-time = "2026-02-27T00:05:40.086Z" },
]