Go to file
2026-07-18 02:51:28 +02:00
accounts feat: add test notif button on admin 2026-07-14 21:30:22 +02:00
assets/icons feat: add django-whitenoise 2026-07-14 20:29:29 +02:00
deploy fix: send-reminder cron 2026-07-17 18:21:34 +02:00
frontend fix: npm -> pnpm 2026-07-17 18:04:30 +02:00
osiris fix: npm -> pnpm 2026-07-17 18:04:30 +02:00
templates feat: frontend using django-vite + blabla 2026-07-17 18:02:42 +02:00
tracker fix: correct pulse range value 2026-07-18 02:51:28 +02:00
.env.example fix: send-reminder cron 2026-07-17 18:21:34 +02:00
.gitignore feat: frontend using django-vite + blabla 2026-07-17 18:02:42 +02:00
.python-version feat: python 3.13 2026-07-14 20:31:31 +02:00
Makefile fix: send-reminder cron 2026-07-17 18:21:34 +02:00
manage.py first version 2026-07-14 20:10:17 +02:00
pyproject.toml feat: frontend using django-vite + blabla 2026-07-17 18:02:42 +02:00
README.md fix: send-reminder cron 2026-07-17 18:21:34 +02:00
uv.lock feat: frontend using django-vite + blabla 2026-07-17 18:02:42 +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

make help lists everything. On a fresh box:

make install     # uv sync, write .env (fresh SECRET_KEY + VAPID keys), migrate, collectstatic
make superuser   # the only way to make an account: there is no registration
make service     # install + start the systemd unit (gunicorn on 127.0.0.1:8000) — needs sudo
make cron        # reminder job, every 10 minutes

Review DJANGO_ALLOWED_HOSTS and DJANGO_CSRF_TRUSTED_ORIGINS in .env before going live, and make restart after any change to it — the settings are read at startup, so an edited .env does nothing until the service restarts.

Put a TLS-terminating proxy (Caddy, nginx, a Cloudflare tunnel) in front of port 8000; the app itself is HTTP-only and bound to localhost. HTTPS is not optional if you want reminders — see below.

Day to day:

make run         # development server on :8000
make test
make lint        # or: make format
make deploy      # sync + migrate + collectstatic + restart
make logs        # journalctl -u osiris -f
make cron-test   # what the reminder job would send right now

.env is sourced by a shell wrapper, so its values must stay unquoted and shell-safe. make env generates the secret from a URL-safe alphabet for exactly that reason — a hand-written key containing ) or $ would break the cron job.

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.

Reminders (Web Push)

Android notifications for doses you haven't ticked off, delivered to the installed PWA. No second app, no Firebase account.

1. Generate a VAPID key pair, once:

uv run python manage.py generate_vapid_keys

Put both values in the server's environment (VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY). Regenerating them later invalidates every subscription and each device has to re-enable reminders, so keep them.

2. Set a reminder time per dose in the admin — Morning 08:00, Evening 19:00, and so on. A dose with no reminder time never notifies.

3. Turn reminders on in the Reminders card on the Today page, and use Send test there to confirm the whole chain works before trusting it with the cat.

4. Run the sender from cronmake cron installs the line below. Cron's environment is nearly empty, so uv is called by absolute path and loads .env (the VAPID keys) itself:

*/10 * * * * cd /path/to/Osiris && /path/to/uv run --no-sync --env-file .env python manage.py send_reminders >> reminders.log 2>&1

The command only pushes a dose that is due and not already ticked off, and records what it sent, so a dose is reminded at most once per day no matter how often cron fires. Doses due at the same time share one notification rather than buzzing once per pill. --grace-minutes (default 120) stops a server that was down all morning from firing the 08:00 reminder at 23:00; --dry-run shows what would go out without sending it.

Notifications require HTTPS and an installed PWA — see the Android section below.

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
POST /api/push/subscribe register a device for reminders
POST /api/push/unsubscribe stop reminders on a device
POST /api/push/test send a test notification now
GET /api/push/public-key the VAPID key a client needs in order to subscribe

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 .