Go to file
2026-07-14 21:30:22 +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
osiris feat: pwa notifications 2026-07-14 20:49:30 +02:00
templates feat: add test notif button on admin 2026-07-14 21:30:22 +02:00
tracker feat: add test notif button on admin 2026-07-14 21:30:22 +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: pwa notifications 2026-07-14 20:49:30 +02:00
README.md feat: pwa notifications 2026-07-14 20:49:30 +02:00
uv.lock feat: pwa notifications 2026-07-14 20:49:30 +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.

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, every ten minutes:

*/10 * * * * cd /path/to/Osiris && uv run python manage.py send_reminders >> /var/log/osiris-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 .