Go to file
2026-07-14 20:31:31 +02:00
accounts first version 2026-07-14 20:10:17 +02:00
assets/icons feat: add django-whitenoise 2026-07-14 20:29:29 +02:00
osiris feat: add django-whitenoise 2026-07-14 20:29:29 +02:00
templates feat: add django-whitenoise 2026-07-14 20:29:29 +02:00
tracker feat: add django-whitenoise 2026-07-14 20:29:29 +02:00
.gitignore first version 2026-07-14 20:10:17 +02:00
.python-version feat: python 3.13 2026-07-14 20:31:31 +02:00
manage.py first version 2026-07-14 20:10:17 +02:00
pyproject.toml feat: python 3.13 2026-07-14 20:31:31 +02:00
README.md feat: add django-whitenoise 2026-07-14 20:29:29 +02:00
uv.lock feat: python 3.13 2026-07-14 20:31:31 +02:00

Osiris

Tracking Osiris the cat: which medication he actually got, and his daily pulse.

Django 5/6 + django-ninja, SQLite. The Django admin is the web UI for now; the API is there so the Flutter app can be a second client without backend changes.

Setup

uv sync
uv run python manage.py migrate
uv run python manage.py createsuperuser   # the only way to make an account: there is no registration
uv run python manage.py runserver

Then open http://127.0.0.1:8000/ — it redirects to the Today page.

To reach it from your phone on the same network:

DJANGO_CSRF_TRUSTED_ORIGINS=http://192.168.1.x:8000 uv run python manage.py runserver 0.0.0.0:8000

Using it

  1. Admin → Medications → Add: create a medication and give it one row per daily dose. "1/4 each morning" is one row; "1/4 morning and 1/4 evening" is two rows on the same medication.
  2. Today page (/admin/today/, linked from every admin page): tick off the doses, type the pulse, hit Save. Use Prev to fill in a day you missed.
  3. The pulse graph on that page covers the last 90 days and shows the trend in bpm/day, so a slow drift is visible rather than just day-to-day noise.

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.

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 (menu → Install app), and it then launches full-screen with its own icon, no browser chrome, and a year-long session. No APK, no Play Store, no Flutter.

It must be served over HTTPS — service workers and installability are disabled on plain http:// (except localhost). Over LAN HTTP you still get a usable mobile site, just a bookmark rather than an installed app. The easy ways to get a real certificate:

# Tailscale (private to your devices, no public exposure):
tailscale serve --bg 8000
# or Cloudflare Tunnel (public URL):
cloudflared tunnel --url http://localhost:8000

Whichever you use, add the hostname to DJANGO_ALLOWED_HOSTS and DJANGO_CSRF_TRUSTED_ORIGINS.

Offline behaviour is deliberately conservative: static files are cached, and the last-seen page is shown if you open the app with no signal, but /api/ calls and every write go straight to the network — a cached or replayed dose would be a lie about whether the cat was medicated.

If you ever do need a real APK (Play Store, or an installable file), the manifest here is all PWABuilder needs to wrap it in a Trusted Web Activity — no Android SDK on your side.

Data model

  • Medication — the drug, e.g. Vetmedin. Deactivate rather than delete when a treatment ends; the history stays intact.
  • ScheduledDose — one recurring dose: medication + time of day + amount.
  • DoseLog — one row per (scheduled dose, day). No row means "not recorded"; taken=False means "explicitly not given", so an untouched day is distinguishable from a missed dose.
  • PulseReading — one per day, unique on the date.

API

Browsable docs at /api/docs, OpenAPI schema at /api/openapi.json (feed that to a Dart client generator).

Session cookies authenticate the admin; the Flutter app uses a bearer token:

curl -X POST localhost:8000/api/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"username": "you", "password": "...", "device_name": "pixel"}'
# => {"token": "...", "username": "you"}

Send it as Authorization: Bearer <token> on every other call. Tokens do not expire; revoke them in Admin → Api tokens.

Method Path Purpose
POST /api/auth/login username + password → token
POST /api/auth/logout revoke the token used for the call
GET /api/auth/me current user
GET /api/tracker/medications the plan: medications and their scheduled doses
GET /api/tracker/day ?date= (default today): checklist + pulse
POST /api/tracker/doses tick a dose on/off; returns the updated day
GET /api/tracker/pulse ?days=90, oldest first — the graph data
POST /api/tracker/pulse record the pulse; posting twice a day overwrites
GET /api/tracker/pulse/stats average, range, and trend_bpm_per_day
DELETE /api/tracker/pulse/{date} remove a reading

GET /api/tracker/day is the one the app's home screen needs:

{
  "date": "2026-07-14",
  "doses": [
    {"id": 2, "medication_name": "Fortekor", "amount": "1/4",
     "time_of_day": 1, "time_of_day_label": "Morning", "taken": true, "notes": ""},
    {"id": 3, "medication_name": "Fortekor", "amount": "1/4",
     "time_of_day": 3, "time_of_day_label": "Evening", "taken": null, "notes": ""}
  ],
  "pulse": {"date": "2026-07-14", "bpm": 188, "notes": ""}
}

taken: null means not recorded yet, as opposed to false for a missed dose.

Tests

uv run python manage.py test
uv run ruff check . && uv run ruff format .