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.
## 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)
The Today page is a progressive web app: Chrome will offer *Add to home screen*

View File

@ -54,6 +54,9 @@ INSTALLED_APPS = [
MIDDLEWARE = [
"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.middleware.common.CommonMiddleware",
"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.
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_AUTO_FIELD = "django.db.models.BigAutoField"

View File

@ -20,6 +20,16 @@ urlpatterns = [
),
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
# come before the admin's own catch-all patterns.
path("admin/", include("tracker.urls")),

View File

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

View File

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

View File

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

View File

@ -1,5 +1,7 @@
import json
from datetime import timedelta
from django.conf import settings
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
@ -246,6 +248,18 @@ class PwaTests(TrackerTestCase):
self.assertContains(response, 'rel="manifest"')
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):
# The app opens on the login screen when the session lapses.
response = self.client.get("/admin/login/")

11
uv.lock
View File

@ -160,6 +160,7 @@ source = { virtual = "." }
dependencies = [
{ name = "django" },
{ name = "django-ninja" },
{ name = "whitenoise" },
]
[package.dev-dependencies]
@ -173,6 +174,7 @@ dev = [
requires-dist = [
{ name = "django", specifier = ">=5.2" },
{ name = "django-ninja", specifier = ">=1.4" },
{ name = "whitenoise", specifier = ">=6.12.0" },
]
[package.metadata.requires-dev]
@ -415,3 +417,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288be
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" },
]
[[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" },
]