diff --git a/README.md b/README.md index 3893d07..838395e 100644 --- a/README.md +++ b/README.md @@ -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* diff --git a/osiris/settings.py b/osiris/settings.py index 7cdddc4..7813507 100644 --- a/osiris/settings.py +++ b/osiris/settings.py @@ -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" diff --git a/osiris/urls.py b/osiris/urls.py index 7ab8d99..d8f6555 100644 --- a/osiris/urls.py +++ b/osiris/urls.py @@ -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")), diff --git a/pyproject.toml b/pyproject.toml index 81fd5d8..821fc90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ requires-python = ">=3.14" dependencies = [ "django>=5.2", "django-ninja>=1.4", + "whitenoise>=6.12.0", ] [dependency-groups] diff --git a/templates/admin/base_site.html b/templates/admin/base_site.html index aaec837..485b7b8 100644 --- a/templates/admin/base_site.html +++ b/templates/admin/base_site.html @@ -5,7 +5,7 @@ {% block extrahead %} {{ block.super }} - + diff --git a/assets/manifest.webmanifest b/templates/manifest.webmanifest similarity index 76% rename from assets/manifest.webmanifest rename to templates/manifest.webmanifest index e87304e..0923e54 100644 --- a/assets/manifest.webmanifest +++ b/templates/manifest.webmanifest @@ -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" diff --git a/tracker/tests.py b/tracker/tests.py index 812101a..979fcaf 100644 --- a/tracker/tests.py +++ b/tracker/tests.py @@ -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/") diff --git a/uv.lock b/uv.lock index dd9f7ce..644efa0 100644 --- a/uv.lock +++ b/uv.lock @@ -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" }, +]