Update CLAUDE.md with cache-busting, email fallback, and deploy context

Documents three things that came out of today's Phase 2 deploy session
and weren't previously written down:

1. Static Assets & Cache-Busting (new section): explains that production
   traffic goes through Cloudflare with 4h edge cache; the
   `deployment_timestamp` template variable is what breaks stale caches;
   and why `request.timestamp` must never be used (the silent-default-to-1.0
   bug that ate a couple of hours).

2. Environment Variables: inline notes for each var. Most important new
   fact is that DEFAULT_FROM_EMAIL is now optional — falls back to
   EMAIL_HOST_USER if unset (prevents the "Invalid address ''" failure
   mode on outbound mail). Also documents that .env lives at BASE_DIR.parent
   on Flatlogic and can only be edited via Gemini/shell.

3. Flatlogic Deployment: collectstatic isn't auto-run, django-dev.service
   runs manage.py runserver (dev server in prod — known but works at this
   scale), Cloudflare sits in front, VM has two git remotes (github +
   gitea) that must stay in sync, VM-local safety branches for rollback,
   and the "pick one write path" workflow rule to avoid divergence.

No code changes — documentation only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Konrad du Plessis 2026-04-22 04:42:32 +02:00
parent 5d6446ae75
commit a8ef7bb341

114
CLAUDE.md
View File

@ -194,6 +194,52 @@ python manage.py check # System check
pair of body vars for dark mode).
- **Email/PDF payslips**: Worker name dominant — do NOT add prominent FoxFitt branding
## Static Assets & Cache-Busting (Cloudflare is in front)
Production traffic reaches the Flatlogic VM through **Cloudflare** (response headers
include `cf-ray`, `cf-cache-status`, and a `cache-control: max-age=14400`). Static
assets — including `custom.css` — are cached at Cloudflare's edge for up to 4 hours
per unique URL. This is great for performance and bad for deploys if the URL doesn't
change when the file does.
### How cache-busting works now
`base.html` loads CSS as:
```html
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp|default:'1' }}">
```
`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.
### The pitfall this replaced
Pre-Apr 2026, the template used `{{ request.timestamp|default:'1.0' }}`. But
`request.timestamp` is **not** a Django request attribute — the variable always
fell back to the literal `'1.0'`. Every deploy's CSS URL resolved to the same
`custom.css?v=1.0`, so Cloudflare held onto a pre-redesign copy for hours while
the VM served the new one. Symptom was "the deploy worked but the page looks wrong"
that only a hard refresh in incognito temporarily fixed. Never use `request.timestamp`
in templates — it doesn't exist.
### When CSS changes don't appear on production
1. Confirm Django is rendering the new URL: `curl -s https://foxlog.flatlogic.app/ | grep -oE 'custom\.css\?v=[^"]+'` — the `v=` number should change per request (or at least per restart)
2. Confirm the CDN honours it: `curl -sI "https://foxlog.flatlogic.app/static/css/custom.css?cb=test$(date +%s)" | grep -i cache` — expect `cf-cache-status: MISS` then `HIT` on repeat
3. If the Django URL still looks like `?v=1.0` (constant), `deployment_timestamp` isn't being injected — check that `core.context_processors.project_context` is listed in `TEMPLATES[0]['OPTIONS']['context_processors']` in `config/settings.py`
### `collectstatic` is required after CSS/JS changes on production
Flatlogic's rebuild does NOT automatically run `collectstatic`. If new CSS is on
disk but the VM's `staticfiles/` hasn't been refreshed, Apache serves the old
collected copy. Have Gemini run `python3 manage.py collectstatic --noinput`
after any PR that touches `static/`.
## PDF Generation (WeasyPrint)
Migrated from xhtml2pdf in Nov 2026. WeasyPrint is a browser-grade HTML→PDF renderer — it supports real CSS (flexbox, grid, `@font-face`, shadows, `border-radius`, proper cascade) that xhtml2pdf could not handle.
@ -503,23 +549,83 @@ python manage.py restore_data backup.json
## Environment Variables
```
DJANGO_SECRET_KEY, DJANGO_DEBUG, HOST_FQDN, CSRF_TRUSTED_ORIGIN
DJANGO_SECRET_KEY # required in prod — startup fails without it
DJANGO_DEBUG # "true"/"false"; defaults to false; keep false in prod
HOST_FQDN, CSRF_TRUSTED_ORIGIN # trusted hostnames (scheme-less ok, auto-prefixed https://)
DB_NAME, DB_USER, DB_PASS, DB_HOST (default: 127.0.0.1), DB_PORT (default: 3306)
USE_SQLITE # "true" → use SQLite instead of MySQL
EMAIL_HOST_USER, EMAIL_HOST_PASSWORD (Gmail App Password — 16 chars)
DEFAULT_FROM_EMAIL, SPARK_RECEIPT_EMAIL
USE_SQLITE # "true" → use SQLite instead of MySQL (local dev only)
EMAIL_HOST_USER # Gmail address — required for any outbound email
EMAIL_HOST_PASSWORD # Gmail App Password (16 chars, no spaces/non-breaking-space)
DEFAULT_FROM_EMAIL # Optional — falls back to EMAIL_HOST_USER if unset
SPARK_RECEIPT_EMAIL # Optional — defaults to FoxFitt's Spark Receipt address
PROJECT_DESCRIPTION, PROJECT_IMAGE_URL # Flatlogic branding
```
### Email fallback behaviour
`DEFAULT_FROM_EMAIL` is not strictly required — `config/settings.py` sets it as:
```python
DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", "") or EMAIL_HOST_USER
```
…so if the env var is unset or empty, the "From" address on every outbound email
falls back to the authenticated Gmail address (which is always valid since we
send AS that account). Without this fallback, receipt and payslip emails fail
with `Invalid address ""`. If you want to send FROM a different display address
than the authenticated one (e.g. "FoxFitt Payroll <payroll@foxfitt.co.za>"),
set `DEFAULT_FROM_EMAIL` explicitly — but Gmail will likely rewrite it to the
authenticated user anyway unless you've configured a "Send mail as" alias.
### Where env vars live on Flatlogic
Flatlogic's platform has no env-var UI. Values are set in a `.env` file at
`BASE_DIR.parent / ".env"` on the VM (one level up from the repo). Edit via
Gemini/shell — the user cannot modify via Flatlogic's web editor because
`.env` is outside the project tree. The file is loaded by
`python-dotenv` in `config/settings.py` before any `os.getenv()` calls.
## Flatlogic/AppWizzy Deployment
- **Branches**: `ai-dev` = development (Flatlogic AI + Claude Code). `master` = deploy target.
- **Workflow**: Push to `ai-dev` → Flatlogic auto-detects → "Pull Latest" → app rebuilds (~5 min)
- **Deploy from Git** (Settings): Full rebuild from `master` — use for production
- **Migrations**: Sometimes run automatically during rebuild, but NOT always reliable. If you get "Unknown column" errors after pulling latest, visit `/run-migrate/` in the browser to apply pending migrations manually. This endpoint runs `python manage.py migrate` on the production MySQL database.
- **Static files**: Flatlogic's rebuild does NOT auto-run `collectstatic`. After CSS/JS changes have Gemini run `python3 manage.py collectstatic --noinput` + restart the service, otherwise Apache keeps serving the previously-collected copy.
- **Service**: The Django app runs as `django-dev.service` (systemd). Gemini restarts it via `sudo systemctl restart django-dev.service`. It runs `python manage.py runserver 0.0.0.0:8000` — a **development server**, not gunicorn/uwsgi (Flatlogic default, works fine at this scale).
- **CDN**: All production traffic goes through Cloudflare. Response headers show `cf-ray`/`cf-cache-status`. Static assets are cached at the edge for 4h — see "Static Assets & Cache-Busting" section for how the `deployment_timestamp` token breaks stale caches.
- **Never edit `ai-dev` directly on GitHub** — Flatlogic pushes overwrite it
- **Gemini gotcha**: Flatlogic's Gemini AI reads `__pycache__/*.pyc` and gets confused. Tell it: "Do NOT read .pyc files. Only work with .py source files."
- **Sequential workflow**: Don't edit in Flatlogic and Claude Code at the same time
### Git remotes on the VM
The Flatlogic VM has TWO git remotes, both kept in sync:
- `github``https://github.com/Konradzar/LabourPay_v5.git` (our canonical source)
- `gitea``https://gitea.flatlogic.app/admin/<id>-vm.git` (Flatlogic's internal mirror — the one the platform UI watches)
Any push the VM makes must go to BOTH: `git push github ai-dev && git push gitea ai-dev`.
If the two diverge, Flatlogic's dashboard can show a different commit than GitHub,
which silently confuses deploys. Flatlogic's UI occasionally commits as
`Flatlogic Bot <support@flatlogic.com>` (autosaves from the in-browser file editor) —
those commits land on gitea but don't propagate to GitHub unless someone pushes.
### VM-local safety branches
When doing risky deploys (model migrations, branch resets, history rewrites), we
create a safety branch on the VM at the pre-deploy HEAD so Gemini can
`git reset --hard <safety-branch>` + service-restart to roll back in ~60 seconds:
```
git branch pre-<purpose>-YYYYMMDD HEAD
git branch --list "pre-*"
```
Safety branches are VM-local — not pushed to GitHub by default. They're
single-use rollback anchors; delete after 7 days of confirmed stability via
`git branch -D pre-<purpose>-YYYYMMDD`.
### Workflow options going forward
Either works — pick one and stick to it per change to avoid divergence:
1. **Claude → GitHub → Flatlogic pulls**: Claude pushes to origin/ai-dev; you click "Pull Latest" in the Flatlogic UI (or ask Gemini to `git pull + push gitea + restart`).
2. **Flatlogic UI → GitHub**: edit in Flatlogic's file editor; click "Push to GitHub" in their UI; Claude pulls locally with `git pull origin ai-dev`.
**Don't mix** paths in the same change — that's how divergence (and the "Ver XX.YY screeeewup" commits) happen.
## Security Notes
- Production: `SESSION_COOKIE_SECURE=True`, `CSRF_COOKIE_SECURE=True`, `SameSite=None` (cross-origin for Flatlogic iframe)
- Local dev: Secure cookies disabled when `USE_SQLITE=true`