chore: makefile + setup
This commit is contained in:
parent
5fbe70b6db
commit
d2270a737b
17
.env.example
Normal file
17
.env.example
Normal file
@ -0,0 +1,17 @@
|
||||
# Copy to .env (which is gitignored) — `make env` generates one with fresh secrets.
|
||||
# Both the systemd service and the reminder cron job read this file.
|
||||
#
|
||||
# Values must stay shell-safe and unquoted: the cron wrapper sources this file, so
|
||||
# a secret containing ')' or '$' would break it. `make env` uses a URL-safe alphabet.
|
||||
|
||||
DJANGO_SECRET_KEY=change-me
|
||||
DJANGO_DEBUG=0
|
||||
DJANGO_ALLOWED_HOSTS=osiris.legrems.xyz,127.0.0.1,localhost
|
||||
DJANGO_CSRF_TRUSTED_ORIGINS=https://osiris.legrems.xyz
|
||||
DJANGO_TIME_ZONE=Europe/Zurich
|
||||
|
||||
# From `make vapid` / `manage.py generate_vapid_keys`. Without these, reminders
|
||||
# are disabled and the Today page's Reminders card says so.
|
||||
VAPID_PUBLIC_KEY=
|
||||
VAPID_PRIVATE_KEY=
|
||||
VAPID_CLAIM_EMAIL=flamelegrems@gmail.com
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -13,3 +13,4 @@ wheels/
|
||||
db.sqlite3
|
||||
static/
|
||||
.env
|
||||
reminders.log
|
||||
|
||||
132
Makefile
Normal file
132
Makefile
Normal file
@ -0,0 +1,132 @@
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
UV := $(shell command -v uv 2>/dev/null)
|
||||
PROJECT_DIR := $(patsubst %/,%,$(dir $(abspath $(lastword $(MAKEFILE_LIST)))))
|
||||
USER := $(shell id -un)
|
||||
PORT ?= 8000
|
||||
SERVICE := osiris
|
||||
UNIT_FILE := /etc/systemd/system/$(SERVICE).service
|
||||
REMINDER_SH := $(PROJECT_DIR)/deploy/send-reminders.sh
|
||||
CRON_SCHED ?= */10 * * * *
|
||||
CRON_LOG ?= $(PROJECT_DIR)/reminders.log
|
||||
MANAGE := $(UV) run python manage.py
|
||||
|
||||
.PHONY: help
|
||||
help: ## Show this help
|
||||
@grep -hE '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) \
|
||||
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
# --- Setup -----------------------------------------------------------------
|
||||
|
||||
.PHONY: install
|
||||
install: check-uv ## Install dependencies, create .env, migrate, collect static files
|
||||
$(UV) sync
|
||||
@$(MAKE) --no-print-directory env # after sync: it needs Django importable
|
||||
$(MANAGE) migrate
|
||||
$(MANAGE) collectstatic --noinput
|
||||
@echo "Installed. Next: 'make superuser' (once), then 'make service' and 'make cron'."
|
||||
|
||||
.PHONY: check-uv
|
||||
check-uv:
|
||||
@test -n "$(UV)" || { echo "uv is not installed: https://docs.astral.sh/uv/"; exit 1; }
|
||||
|
||||
.PHONY: env
|
||||
env: ## Create .env with a fresh SECRET_KEY and VAPID keys (keeps an existing one)
|
||||
@if [ -f .env ]; then \
|
||||
echo ".env already exists — leaving it alone."; \
|
||||
else \
|
||||
cp .env.example .env; \
|
||||
secret=$$($(UV) run python -c "import secrets; print(secrets.token_urlsafe(64))"); \
|
||||
sed -i "s|^DJANGO_SECRET_KEY=.*|DJANGO_SECRET_KEY=$$secret|" .env; \
|
||||
$(MAKE) --no-print-directory vapid; \
|
||||
echo "Wrote .env — review DJANGO_ALLOWED_HOSTS before going live."; \
|
||||
fi
|
||||
|
||||
.PHONY: vapid
|
||||
vapid: ## Generate VAPID keys and write them into .env
|
||||
@test -f .env || cp .env.example .env
|
||||
@keys=$$($(MANAGE) generate_vapid_keys 2>/dev/null | grep '^VAPID_'); \
|
||||
pub=$$(echo "$$keys" | grep '^VAPID_PUBLIC_KEY=' | cut -d= -f2); \
|
||||
priv=$$(echo "$$keys" | grep '^VAPID_PRIVATE_KEY=' | cut -d= -f2); \
|
||||
test -n "$$pub" || { echo "Key generation failed."; exit 1; }; \
|
||||
sed -i "s|^VAPID_PUBLIC_KEY=.*|VAPID_PUBLIC_KEY=$$pub|" .env; \
|
||||
sed -i "s|^VAPID_PRIVATE_KEY=.*|VAPID_PRIVATE_KEY=$$priv|" .env; \
|
||||
echo "VAPID keys written to .env. Restart the service, then re-enable reminders on each device."
|
||||
|
||||
.PHONY: superuser
|
||||
superuser: ## Create the login account (there is no registration)
|
||||
$(MANAGE) createsuperuser
|
||||
|
||||
# --- systemd ---------------------------------------------------------------
|
||||
|
||||
.PHONY: service
|
||||
service: ## Install, enable and start the systemd service (needs sudo)
|
||||
@sed -e 's|__USER__|$(USER)|g' \
|
||||
-e 's|__PROJECT_DIR__|$(PROJECT_DIR)|g' \
|
||||
-e 's|__UV__|$(UV)|g' \
|
||||
-e 's|__PORT__|$(PORT)|g' \
|
||||
deploy/osiris.service.template > /tmp/$(SERVICE).service
|
||||
sudo install -m 644 /tmp/$(SERVICE).service $(UNIT_FILE)
|
||||
@rm -f /tmp/$(SERVICE).service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now $(SERVICE)
|
||||
@sudo systemctl --no-pager --lines=0 status $(SERVICE) || true
|
||||
|
||||
.PHONY: restart
|
||||
restart: ## Restart the service (do this after changing .env)
|
||||
sudo systemctl restart $(SERVICE)
|
||||
|
||||
.PHONY: logs
|
||||
logs: ## Follow the service log
|
||||
journalctl -u $(SERVICE) -f
|
||||
|
||||
.PHONY: service-uninstall
|
||||
service-uninstall: ## Stop and remove the systemd service
|
||||
-sudo systemctl disable --now $(SERVICE)
|
||||
sudo rm -f $(UNIT_FILE)
|
||||
sudo systemctl daemon-reload
|
||||
|
||||
# --- cron ------------------------------------------------------------------
|
||||
|
||||
# The script path is the marker: it identifies our line without needing a literal
|
||||
# '#' comment, which make would strip as a comment of its own.
|
||||
.PHONY: cron
|
||||
cron: ## Install the reminder cron job (every 10 minutes, idempotent)
|
||||
@chmod +x $(REMINDER_SH)
|
||||
@( crontab -l 2>/dev/null | grep -vF '$(REMINDER_SH)' ; \
|
||||
echo "$(CRON_SCHED) $(REMINDER_SH) >> $(CRON_LOG) 2>&1" ) | crontab -
|
||||
@echo "Installed:"
|
||||
@crontab -l | grep -F '$(REMINDER_SH)'
|
||||
|
||||
.PHONY: cron-remove
|
||||
cron-remove: ## Remove the reminder cron job
|
||||
@crontab -l 2>/dev/null | grep -vF '$(REMINDER_SH)' | crontab -
|
||||
@echo "Removed."
|
||||
|
||||
.PHONY: cron-test
|
||||
cron-test: ## Run the reminder job once, reporting what it would send
|
||||
$(REMINDER_SH) --dry-run
|
||||
|
||||
# --- Development -----------------------------------------------------------
|
||||
|
||||
.PHONY: run
|
||||
run: ## Run the development server
|
||||
$(MANAGE) runserver $(PORT)
|
||||
|
||||
.PHONY: test
|
||||
test: ## Run the test suite
|
||||
$(MANAGE) test
|
||||
|
||||
.PHONY: lint
|
||||
lint: ## Check formatting and lints
|
||||
$(UV) run ruff format --check .
|
||||
$(UV) run ruff check .
|
||||
|
||||
.PHONY: format
|
||||
format: ## Reformat and autofix
|
||||
$(UV) run ruff format .
|
||||
$(UV) run ruff check --fix .
|
||||
|
||||
.PHONY: deploy
|
||||
deploy: install restart ## Pull dependencies, migrate, collect static, restart
|
||||
@echo "Deployed."
|
||||
39
README.md
39
README.md
@ -7,21 +7,38 @@ 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:
|
||||
`make help` lists everything. On a fresh box:
|
||||
|
||||
```bash
|
||||
DJANGO_CSRF_TRUSTED_ORIGINS=http://192.168.1.x:8000 uv run python manage.py runserver 0.0.0.0:8000
|
||||
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:
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
28
deploy/osiris.service.template
Normal file
28
deploy/osiris.service.template
Normal file
@ -0,0 +1,28 @@
|
||||
[Unit]
|
||||
Description=Osiris — cat medication and pulse tracking
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=notify
|
||||
User=__USER__
|
||||
WorkingDirectory=__PROJECT_DIR__
|
||||
EnvironmentFile=__PROJECT_DIR__/.env
|
||||
# --no-sync: 'make install' already synced. Without it, uv would try to resolve
|
||||
# dependencies at boot, which needs the network and can fail at the worst moment.
|
||||
ExecStart=__UV__ run --no-sync gunicorn osiris.wsgi:application \
|
||||
--bind 127.0.0.1:__PORT__ \
|
||||
--workers 2 \
|
||||
--access-logfile - \
|
||||
--error-logfile -
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
|
||||
# SQLite and the media/static dirs are the only things it needs to write.
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=full
|
||||
ProtectHome=read-only
|
||||
ReadWritePaths=__PROJECT_DIR__
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
16
deploy/send-reminders.sh
Executable file
16
deploy/send-reminders.sh
Executable file
@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
# Wrapper for the reminder cron job. Cron runs with almost no environment, so the
|
||||
# settings (and the VAPID keys) have to be loaded explicitly.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
if [ -f .env ]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
. ./.env
|
||||
set +a
|
||||
fi
|
||||
|
||||
exec "${UV:-uv}" run --no-sync python manage.py send_reminders "$@"
|
||||
@ -7,6 +7,7 @@ requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"django>=5.2",
|
||||
"django-ninja>=1.4",
|
||||
"gunicorn>=26.0.0",
|
||||
"pywebpush>=2.3.0",
|
||||
"whitenoise>=6.12.0",
|
||||
]
|
||||
|
||||
23
uv.lock
23
uv.lock
@ -459,6 +459,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gunicorn"
|
||||
version = "26.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "packaging" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6d/b7/a4a3f632f823e432ce6bc65f62961b7980c898c77f075a2f7118cb3846fe/gunicorn-26.0.0.tar.gz", hash = "sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf", size = 727286, upload-time = "2026-05-05T06:38:25.529Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/40/9c2384fc2be4ad25dd4a49decd5ad9ea5a3639814c11bd40ab77cb9f0a14/gunicorn-26.0.0-py3-none-any.whl", hash = "sha256:40233d26a5f0d1872916188c276e21641155111c2853f0c2cd55260aec0d24fc", size = 212009, upload-time = "2026-05-05T06:38:23.007Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-ece"
|
||||
version = "1.2.1"
|
||||
@ -623,6 +635,7 @@ source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
{ name = "django-ninja" },
|
||||
{ name = "gunicorn" },
|
||||
{ name = "pywebpush" },
|
||||
{ name = "whitenoise" },
|
||||
]
|
||||
@ -638,6 +651,7 @@ dev = [
|
||||
requires-dist = [
|
||||
{ name = "django", specifier = ">=5.2" },
|
||||
{ name = "django-ninja", specifier = ">=1.4" },
|
||||
{ name = "gunicorn", specifier = ">=26.0.0" },
|
||||
{ name = "pywebpush", specifier = ">=2.3.0" },
|
||||
{ name = "whitenoise", specifier = ">=6.12.0" },
|
||||
]
|
||||
@ -649,6 +663,15 @@ dev = [
|
||||
{ name = "ruff", specifier = ">=0.15.21" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parso"
|
||||
version = "0.8.7"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user