Add unified config system (issue #24)

Introduce timetracker/config.py with a single config() helper that resolves
settings from a fixed priority chain: NAME__FILE (opt-in secret) -> env var
-> .env -> settings.ini -> in-code default. Supports type casting
(bool/list/int/Path), file-based secrets with .strip(), and required_in_prod
validation.

Migrate settings.py off the previous ad-hoc idioms:
- DEBUG via config() (PROD kept as deprecated alias)
- SECRET_KEY required in prod, supports SECRET_KEY__FILE
- APP_URL derives ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS (kept separate,
  each independently overridable); ALLOWED_HOSTS is now configurable
- TZ and DATA_DIR via config()

Fix DATA_DIR inconsistency: entrypoint.sh now reads DATA_DIR (was hardcoded)
so the bash bootstrap and Django agree on the database directory.

Document the container/entrypoint-only flags (PUID/PGID/
CREATE_DEFAULT_SUPERUSER/STAGING/LOAD_SAMPLE_DATA) as bash concerns.

Update deployment configs to set APP_URL (and DEBUG), add docs/configuration.md,
settings.ini.example, regrouped .env.example, CLAUDE.md, and tests.

https://claude.ai/code/session_01FFn8BiGrQpEJarC8xGse8s
This commit is contained in:
Claude
2026-06-14 19:51:29 +00:00
parent 2e9e6b4fcf
commit d8558eca89
14 changed files with 653 additions and 46 deletions
+124
View File
@@ -0,0 +1,124 @@
# Configuration
All configurable Django settings are read through a single helper,
`config()` in [`timetracker/config.py`](../timetracker/config.py). It resolves
each value from a fixed chain of sources so the same setting can come from an
environment variable, a `.env` file, an `.ini` file, or a built-in default —
without any per-setting special-casing in `settings.py`.
## Resolution priority
For a setting named `NAME`, the first source that provides a value wins:
| Priority | Source | Notes |
|---------:|--------|-------|
| 1 | `NAME__FILE` env var | Path to a file; its *stripped* contents are the value. Opt-in per setting (`allow_file=True`). For Docker/Kubernetes secrets. |
| 2 | `NAME` env var | A real process environment variable. |
| 3 | `.env` file | `KEY=value` lines (see [.env syntax](#env-syntax)). |
| 4 | `settings.ini` file | The `[timetracker]` section, parsed with `configparser`. |
| 5 | `default` | The in-code fallback in `settings.py`. |
If no source supplies a value and no `default` is defined, startup fails with
`ImproperlyConfigured` rather than silently using an empty value.
**Worked example.** With `VALUE` set in the environment *and* in `.env` *and*
in `settings.ini`, the environment variable wins. Remove it and `.env` wins;
remove that and `settings.ini` wins; remove that and the code default applies.
## Settings reference
| Setting | Cast | Default | `__FILE`? | Description |
|---------|------|---------|:---------:|-------------|
| `SECRET_KEY` | str | insecure dev key | yes | Django secret key. **Required in production** (DEBUG off) — a missing value is a hard error, not a silent insecure fallback. |
| `DEBUG` | bool | `true` (dev) | no | Debug mode. Turn **off** in production. Defaults on for local development. |
| `APP_URL` | str | `http://localhost:8000` | no | Public URL of the site. Derives `ALLOWED_HOSTS` and `CSRF_TRUSTED_ORIGINS` when those are not set explicitly. |
| `ALLOWED_HOSTS` | list | derived from `APP_URL` | no | Comma-separated hostnames. Overrides the `APP_URL` derivation. |
| `CSRF_TRUSTED_ORIGINS` | list | derived from `APP_URL` | no | Comma-separated full origins (`https://host`). Overrides the `APP_URL` derivation. |
| `TZ` | str | `Europe/Prague` (dev) / `UTC` (prod) | no | Time zone. |
| `DATA_DIR` | path | project root | no | Directory holding the SQLite database. Also read by `entrypoint.sh`. |
`cast` understands `bool` (`true/1/yes/on``True`), `list` (comma-separated,
whitespace-trimmed, empty items dropped), `int`, `Path`, or any callable.
## APP_URL, ALLOWED_HOSTS and CSRF
`ALLOWED_HOSTS` and `CSRF_TRUSTED_ORIGINS` guard different things — the `Host`
header versus cross-origin requests — so they are **never merged**. For the
common case you set only `APP_URL` and both are derived:
```
APP_URL=https://tracker.example.com
# -> ALLOWED_HOSTS = ["tracker.example.com"]
# -> CSRF_TRUSTED_ORIGINS = ["https://tracker.example.com"]
```
Power users override either independently. A typical reverse-proxy setup:
```
ALLOWED_HOSTS=*
CSRF_TRUSTED_ORIGINS=https://tracker.example.com
```
## Secrets and `__FILE`
Secret managers (Docker secrets, Kubernetes) mount secrets as files. For any
setting that opts in (currently `SECRET_KEY`), point a `*__FILE` variable at
the mounted path:
```
SECRET_KEY__FILE=/run/secrets/timetracker_secret_key
```
The file contents are read and `.strip()`-ed. The strip matters: editors and
`echo` often append a trailing newline, and a stray `\n` inside `SECRET_KEY`
would silently invalidate every signed cookie/token when the file is recreated
without it.
## .env syntax
```dotenv
# full-line comment
KEY=value
export KEY=value # optional leading "export"
QUOTED="value with spaces" # surrounding quotes are stripped
SINGLE='also fine'
WITH_HASH="a # b" # '#' inside quotes is literal
INLINE=value # trailing comment after an unquoted value is dropped
```
Deliberately **not** supported (documented limits, not bugs):
- variable interpolation (`${OTHER}`)
- multiline values
File locations default to `.env` and `settings.ini` at the project root and
can be moved with the `ENV_FILE` / `INI_FILE` environment variables. Missing
files are ignored, so env-only deployments need neither. A `.env` file used by
`docker-compose` for `${VAR}` substitution is the same file Django reads in
local development; inside the container, real environment variables apply.
See [`.env.example`](../.env.example) and
[`settings.ini.example`](../settings.ini.example) for starting points.
## Container / entrypoint-only variables
These are consumed by [`entrypoint.sh`](../entrypoint.sh) during container
bootstrap, **not** by Django. They are intentionally not part of the Python
config — moving them there would buy nothing and force a bash↔Python bridge.
| Variable | Default | Purpose |
|----------|---------|---------|
| `PUID` / `PGID` | `1000` / `100` | uid/gid the container process runs as. |
| `DATA_DIR` | `/home/timetracker/app/data` | Database directory. Shared with Django via the same env var + matching default. |
| `CREATE_DEFAULT_SUPERUSER` | `false` | Create an `admin`/`admin` superuser on first start. |
| `STAGING` | `false` | Scrub copied sessions / django-q schedule on staging. |
| `LOAD_SAMPLE_DATA` | `false` | Seed sample fixtures when the database is empty. |
## Migrating from the old config
- `PROD=1``DEBUG=false`. `PROD` still works as a **deprecated alias** for
one release and emits a `DeprecationWarning`.
- `ALLOWED_HOSTS` is now configurable (it was previously hard-coded to `*`).
After upgrading, set `APP_URL` (or `ALLOWED_HOSTS` explicitly) or the host
will be rejected. Reverse-proxy deployments that relied on `*` should set
`ALLOWED_HOSTS=*`.