diff --git a/CLAUDE.md b/CLAUDE.md index d1d3e14..9303c1e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -242,16 +242,18 @@ change when the file does. ``` -`deployment_timestamp` comes from `core/context_processors.py::project_context` as -`int(time.time())` — meaning every Django request generates a new query-string value. -Cloudflare treats each new `?v=...` value as a new URL → `cf-cache-status: MISS` → -fresh fetch from the VM. Users always see the latest CSS as soon as the Django -process restarts. - -**Trade-off**: because the timestamp changes every second, CDN cache-hit rate on -CSS is effectively zero. For a low-traffic app this is fine. If traffic grows, -consider switching to a file-mtime-based token so the URL only changes when the -CSS actually changes. +`deployment_timestamp` comes from `core/context_processors.py::project_context` +via `_compute_cache_bust_token()` — returns the mtime of +`static/css/custom.css` as an integer. The token only changes when the +CSS file is modified, so Cloudflare's edge cache holds each version for +its full 4h TTL and repeat page loads in a session hit the browser +cache (304 Not Modified). Deploys that include a CSS change bump the +mtime → new token → cache busts. Pre-24-Apr-2026 this was +`int(time.time())` per-request, which defeated the CDN cache entirely +(effectively 0% hit rate on CSS). Degraded-mode fallback: if +`custom.css` isn't on disk (e.g., fresh container before +`collectstatic`), the function falls back to the old per-request +timestamp rather than crashing. ### The pitfall this replaced Pre-Apr 2026, the template used `{{ request.timestamp|default:'1.0' }}`. But diff --git a/core/context_processors.py b/core/context_processors.py index 0bf87c3..aaa2df3 100644 --- a/core/context_processors.py +++ b/core/context_processors.py @@ -1,13 +1,44 @@ +# === core/context_processors.py === +# Globals injected into every template render. +# +# `deployment_timestamp` is a cache-bust token on our CSS URL. Historically +# it was `int(time.time())` — a new value every second — which defeated +# Cloudflare's edge cache because every page load saw a different `?v=...` +# string. We now tie the token to `custom.css`'s mtime, so the URL +# changes ONLY when the CSS actually changes. Cloudflare can hold the +# file for its full 4h TTL, and users on repeat visits hit the browser +# cache (304 Not Modified). import os import time +from pathlib import Path + +from django.conf import settings + +# Path to the file whose mtime drives the cache-bust token. Module-level +# constant so tests can monkey-patch it to simulate "file missing". +_CSS_PATH_FOR_TOKEN = Path(settings.BASE_DIR) / 'static' / 'css' / 'custom.css' + + +def _compute_cache_bust_token(): + """Return an integer cache-bust token. + + Normal path: returns the CSS file's mtime (an integer). + Fallback: if the file can't be stat'd (doesn't exist / permission + error / disk issue), returns the current wall-clock time in seconds + — that degrades to the PRE-FIX behaviour (new token per request) + rather than crashing the whole request cycle. + """ + try: + return int(os.path.getmtime(_CSS_PATH_FOR_TOKEN)) + except (OSError, FileNotFoundError): + return int(time.time()) + def project_context(request): - """ - Adds project-specific environment variables to the template context globally. - """ + """Adds project-specific environment variables to the template context globally.""" return { "project_description": os.getenv("PROJECT_DESCRIPTION", ""), "project_image_url": os.getenv("PROJECT_IMAGE_URL", ""), - # Used for cache-busting static assets - "deployment_timestamp": int(time.time()), + # Cache-busts static assets — see _compute_cache_bust_token(). + "deployment_timestamp": _compute_cache_bust_token(), } diff --git a/core/tests.py b/core/tests.py index c08afa0..48b6c8d 100644 --- a/core/tests.py +++ b/core/tests.py @@ -5,6 +5,7 @@ import datetime from decimal import Decimal +from django.conf import settings from django.contrib.auth.models import User from django.test import TestCase from django.urls import reverse @@ -1237,3 +1238,44 @@ class AdjustmentsTabTests(TestCase): self.assertIn((self.team.id, self.w2.id), pair_set) self.assertIn((self.team2.id, self.w1.id), pair_set) self.assertIn((self.team2.id, self.w2.id), pair_set) + + +# === Cache-bust token tests === +# The deployment_timestamp context variable controls the ?v=... query +# string on our CSS URL. It MUST only change when custom.css changes +# — otherwise Cloudflare cache-HIT rate on the CSS drops to zero and +# every page reload re-fetches 64 KB from the VM. + +class CacheBustTokenTests(TestCase): + """Regression tests for the mtime-based cache-bust token.""" + + def test_token_is_an_integer(self): + """Token must be int (templates cast to str; float would show a dot).""" + from core.context_processors import project_context + ctx = project_context(request=None) + self.assertIsInstance(ctx['deployment_timestamp'], int) + + def test_token_is_stable_across_two_calls(self): + """Critical property: two back-to-back calls return the same + token — because custom.css hasn't changed between them. This + is the entire point of the mtime-based approach.""" + from core.context_processors import project_context + t1 = project_context(request=None)['deployment_timestamp'] + t2 = project_context(request=None)['deployment_timestamp'] + self.assertEqual(t1, t2) + + def test_token_falls_back_if_file_missing(self): + """If static/css/custom.css is somehow missing (fresh + container pre-collectstatic), we must NOT crash. We fall back + to int(time.time()) so every page still renders.""" + import core.context_processors as cp + original = cp._compute_cache_bust_token + try: + # Monkey-patch so the function sees a guaranteed-missing path. + cp._CSS_PATH_FOR_TOKEN = cp.Path('/definitely/does/not/exist.css') + # Should return an int and NOT raise. + token = cp._compute_cache_bust_token() + self.assertIsInstance(token, int) + finally: + # Reset the module-level cache so other tests get the real value. + cp._CSS_PATH_FOR_TOKEN = cp.Path(settings.BASE_DIR) / 'static' / 'css' / 'custom.css'