| accounts | ||
| assets/icons | ||
| deploy | ||
| frontend | ||
| osiris | ||
| templates | ||
| tracker | ||
| .env.example | ||
| .gitignore | ||
| .python-version | ||
| Makefile | ||
| manage.py | ||
| pyproject.toml | ||
| README.md | ||
| uv.lock | ||
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
- 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.
- 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. - 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 cron — make 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=Falsemeans "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 .