190 lines
7.9 KiB
Markdown
190 lines
7.9 KiB
Markdown
# 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
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
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.
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
# 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](https://www.pwabuilder.com) 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:**
|
|
|
|
```bash
|
|
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:
|
|
|
|
```cron
|
|
*/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:
|
|
|
|
```bash
|
|
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:
|
|
|
|
```json
|
|
{
|
|
"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
|
|
|
|
```bash
|
|
uv run python manage.py test
|
|
uv run ruff check . && uv run ruff format .
|
|
```
|