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:
+44
-17
@@ -1,24 +1,51 @@
|
|||||||
# Docker registry URL (used in docker-compose.yml)
|
# =============================================================================
|
||||||
REGISTRY_URL=registry.kucharczyk.xyz
|
# Django application settings (read by timetracker/config.py)
|
||||||
|
#
|
||||||
|
# Resolution priority, highest first:
|
||||||
|
# SECRET_KEY__FILE -> env var -> .env -> settings.ini -> built-in default
|
||||||
|
# See docs/configuration.md for the full reference.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
# Container timezone
|
# Turn DEBUG off in production. Defaults on for local development.
|
||||||
|
# (The old PROD=1 variable still works but is deprecated; prefer DEBUG.)
|
||||||
|
DEBUG=false
|
||||||
|
|
||||||
|
# Secret key. Required in production; an insecure default is used in DEBUG.
|
||||||
|
# For Docker/K8s secrets, point SECRET_KEY__FILE at a mounted file instead.
|
||||||
|
SECRET_KEY=change-me-to-a-long-random-string
|
||||||
|
# SECRET_KEY__FILE=/run/secrets/timetracker_secret_key
|
||||||
|
|
||||||
|
# Public URL of the site. Derives ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS.
|
||||||
|
APP_URL=https://tracker.kucharczyk.xyz
|
||||||
|
|
||||||
|
# Optional explicit overrides (comma-separated). When set they win over APP_URL.
|
||||||
|
# Useful behind a reverse proxy, e.g. ALLOWED_HOSTS=*
|
||||||
|
# ALLOWED_HOSTS=*
|
||||||
|
# CSRF_TRUSTED_ORIGINS=https://tracker.kucharczyk.xyz
|
||||||
|
|
||||||
|
# Container timezone.
|
||||||
TZ=Europe/Prague
|
TZ=Europe/Prague
|
||||||
|
|
||||||
# User/group IDs for container (used in entrypoint.sh)
|
# Directory holding the SQLite database (defaults to the project root).
|
||||||
|
DATA_DIR=/home/timetracker/app/data
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Container / entrypoint-only settings (read by entrypoint.sh, NOT by Django)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# User/group IDs the container process runs as.
|
||||||
PUID=1000
|
PUID=1000
|
||||||
PGID=100
|
PGID=100
|
||||||
|
|
||||||
# External port mapping
|
# Create an admin/admin superuser on startup (for initial setup only).
|
||||||
TIMETRACKER_EXTERNAL_PORT=8000
|
|
||||||
|
|
||||||
# Django production mode (set to "1" for production)
|
|
||||||
PROD=1
|
|
||||||
|
|
||||||
# Database directory (defaults to project root)
|
|
||||||
DATA_DIR=/home/timetracker/app/data
|
|
||||||
|
|
||||||
# CSRF trusted origins
|
|
||||||
CSRF_TRUSTED_ORIGINS=https://tracker.kucharczyk.xyz
|
|
||||||
|
|
||||||
# Create a default admin/admin superuser on startup (for initial setup only)
|
|
||||||
CREATE_DEFAULT_SUPERUSER=false
|
CREATE_DEFAULT_SUPERUSER=false
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# docker-compose-only settings (compose file substitution, not the app)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Docker registry URL (used in docker-compose.yml).
|
||||||
|
REGISTRY_URL=registry.kucharczyk.xyz
|
||||||
|
|
||||||
|
# External port mapping.
|
||||||
|
TIMETRACKER_EXTERNAL_PORT=8000
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ jobs:
|
|||||||
-e DATA_DIR=/home/timetracker/app/data \
|
-e DATA_DIR=/home/timetracker/app/data \
|
||||||
-e STAGING=true \
|
-e STAGING=true \
|
||||||
-e "SECRET_KEY=${STAGING_SECRET_KEY}" \
|
-e "SECRET_KEY=${STAGING_SECRET_KEY}" \
|
||||||
-e "CSRF_TRUSTED_ORIGINS=https://${HOST}" \
|
-e "APP_URL=https://${HOST}" \
|
||||||
-v "timetracker-staging-${SLUG}:/home/timetracker/app/data" \
|
-v "timetracker-staging-${SLUG}:/home/timetracker/app/data" \
|
||||||
-l "caddy=${HOST}" \
|
-l "caddy=${HOST}" \
|
||||||
-l 'caddy.reverse_proxy={{ upstreams 8000 }}' \
|
-l 'caddy.reverse_proxy={{ upstreams 8000 }}' \
|
||||||
|
|||||||
@@ -43,9 +43,10 @@ jobs:
|
|||||||
# Per-app SECRET_KEY so each staging instance is independent and no
|
# Per-app SECRET_KEY so each staging instance is independent and no
|
||||||
# session cookie is shared across instances or with production.
|
# session cookie is shared across instances or with production.
|
||||||
SECRET_KEY="staging-${SLUG}-$(head -c16 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9')"
|
SECRET_KEY="staging-${SLUG}-$(head -c16 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9')"
|
||||||
|
# APP_URL derives both ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS.
|
||||||
flyctl secrets set --app "$APP" --stage \
|
flyctl secrets set --app "$APP" --stage \
|
||||||
"SECRET_KEY=${SECRET_KEY}" \
|
"SECRET_KEY=${SECRET_KEY}" \
|
||||||
"CSRF_TRUSTED_ORIGINS=https://${HOST}"
|
"APP_URL=https://${HOST}"
|
||||||
|
|
||||||
- name: Deploy
|
- name: Deploy
|
||||||
run: flyctl deploy --app "$APP" --config fly.staging.toml --remote-only --yes
|
run: flyctl deploy --app "$APP" --config fly.staging.toml --remote-only --yes
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ data/
|
|||||||
dist/
|
dist/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.python-version
|
.python-version
|
||||||
|
|
||||||
|
# Local configuration (may contain secrets); examples are committed instead
|
||||||
|
.env
|
||||||
|
/settings.ini
|
||||||
.direnv
|
.direnv
|
||||||
.hermes/
|
.hermes/
|
||||||
|
|
||||||
|
|||||||
@@ -141,15 +141,20 @@ Docker-based: multi-stage Dockerfile (uv builder → Node assets stage → slim
|
|||||||
|
|
||||||
### Database
|
### Database
|
||||||
|
|
||||||
SQLite with WAL journal mode. Connection timeout 20s. The `DATA_DIR` env var controls the database file location. Migrations live in `games/migrations/`. There are `GeneratedField`s on the models — these are computed by the database engine and cannot be written from application code.
|
SQLite with WAL journal mode. Connection timeout 20s. The `DATA_DIR` setting controls the database file location and is read consistently by both `settings.py` and `entrypoint.sh` (same env var + matching default). Migrations live in `games/migrations/`. There are `GeneratedField`s on the models — these are computed by the database engine and cannot be written from application code.
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
- `DEBUG` is `True` unless `PROD` env var is set
|
All configurable Django settings are read through `config()` in `timetracker/config.py`, never via bare `os.environ` in `settings.py`. Full reference: `docs/configuration.md`.
|
||||||
- `TIME_ZONE` defaults to `Europe/Prague` in debug, otherwise reads `TZ` env var (default `UTC`)
|
|
||||||
- Django Admin, Debug Toolbar, and `django_extensions` are only available in `DEBUG` mode
|
- **Resolution priority** (highest first): `NAME__FILE` (opt-in file secret) → `NAME` env var → `.env` → `settings.ini` (`[timetracker]` section) → in-code default. Missing + no default = `ImproperlyConfigured`.
|
||||||
- `CSRF_TRUSTED_ORIGINS` is parsed from a comma-separated env var
|
- `config(name, *, default, cast, allow_file, required_in_prod)`: `cast` handles `bool`/`list`/`int`/`Path`/callable; `allow_file=True` honors `NAME__FILE` (contents `.strip()`-ed); `required_in_prod=True` hard-fails when missing and DEBUG is off.
|
||||||
- `DATA_DIR` env var sets the SQLite database location (defaults to `BASE_DIR`)
|
- `DEBUG` defaults `True` (dev), turned off with `DEBUG=false`. `PROD` is a **deprecated alias** kept for one release.
|
||||||
|
- `SECRET_KEY` is required in production (insecure default only in DEBUG); supports `SECRET_KEY__FILE`.
|
||||||
|
- `APP_URL` derives `ALLOWED_HOSTS` and `CSRF_TRUSTED_ORIGINS` when those aren't set explicitly; the two are never merged (different security checks) and each can be overridden directly.
|
||||||
|
- `TIME_ZONE` reads `TZ` (defaults `Europe/Prague` in debug, `UTC` in prod).
|
||||||
|
- Django Admin, Debug Toolbar, and `django_extensions` are only available in `DEBUG` mode.
|
||||||
|
- **Container/entrypoint-only** flags (`PUID`, `PGID`, `CREATE_DEFAULT_SUPERUSER`, `STAGING`, `LOAD_SAMPLE_DATA`) live in `entrypoint.sh`, not the Python config — they are bootstrap concerns, not Django settings.
|
||||||
- django-q2 cluster: 1 worker, 60s timeout, 120s retry, ORM broker
|
- django-q2 cluster: 1 worker, 60s timeout, 120s retry, ORM broker
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
@@ -180,6 +185,7 @@ Pytest settings are in `pyproject.toml` under `[tool.pytest.ini_options]` (`DJAN
|
|||||||
- **Components are nodes; use the named builders** — build with `Div()`, `Span()`, `Element("tag", ...)`, etc., which return `Node` objects. For a tag with no builder, add it to the whitelist in `primitives.py` (one line) or use `Element("tag", attrs, children)`. Use `Fragment(a, b, ...)` to group siblings (never `str(a)+str(b)`, which flattens the tree and drops media). Wrap trusted pre-rendered HTML in `Safe(html)` (the `mark_safe` analogue).
|
- **Components are nodes; use the named builders** — build with `Div()`, `Span()`, `Element("tag", ...)`, etc., which return `Node` objects. For a tag with no builder, add it to the whitelist in `primitives.py` (one line) or use `Element("tag", attrs, children)`. Use `Fragment(a, b, ...)` to group siblings (never `str(a)+str(b)`, which flattens the tree and drops media). Wrap trusted pre-rendered HTML in `Safe(html)` (the `mark_safe` analogue).
|
||||||
- **JS-bearing components declare `Media`, they don't rely on the view** — give a component `class Media: js = (...)` (a `BaseComponent`) or `return node.with_media(Media(js=...))`. `Page()` collects and emits it. Never re-add `scripts=ModuleScript(...)` threading in a view for a component that can declare its own dependency.
|
- **JS-bearing components declare `Media`, they don't rely on the view** — give a component `class Media: js = (...)` (a `BaseComponent`) or `return node.with_media(Media(js=...))`. `Page()` collects and emits it. Never re-add `scripts=ModuleScript(...)` threading in a view for a component that can declare its own dependency.
|
||||||
- **Filter views** accept `?filter=<JSON>` (structured) and fall back to `?search_string=` (free-text). New filter criteria go in `games/filters.py`; new criterion types go in `common/criteria.py`.
|
- **Filter views** accept `?filter=<JSON>` (structured) and fall back to `?search_string=` (free-text). New filter criteria go in `games/filters.py`; new criterion types go in `common/criteria.py`.
|
||||||
|
- **Read settings via `config()`** — new Django settings go through `config()` from `timetracker/config.py`, never bare `os.environ.get` in `settings.py`. Declare `cast`/`allow_file`/`required_in_prod` explicitly. Container-bootstrap flags belong in `entrypoint.sh`, not the Python config. See `docs/configuration.md`.
|
||||||
- **Signals handle side-effects** — do not manually recalculate `Game.playtime` or `Purchase.num_purchases`; the signals in `games/signals.py` do this on save/delete.
|
- **Signals handle side-effects** — do not manually recalculate `Game.playtime` or `Purchase.num_purchases`; the signals in `games/signals.py` do this on save/delete.
|
||||||
- **Button colors**: `blue` (primary action), `red` (destructive), `gray` (secondary), `green` (positive). Icon buttons use `icon=True`.
|
- **Button colors**: `blue` (primary action), `red` (destructive), `gray` (secondary), `green` (positive). Icon buttons use `icon=True`.
|
||||||
- **Inline Alpine.js** is used for client-side reactivity in domain components (`GameStatusSelector`, `SessionDeviceSelector`). The pattern is `x-data="{...}"` with `fetchWithHtmxTriggers()` for PATCH API calls.
|
- **Inline Alpine.js** is used for client-side reactivity in domain components (`GameStatusSelector`, `SessionDeviceSelector`). The pattern is `x-data="{...}"` with `fetchWithHtmxTriggers()` for PATCH API calls.
|
||||||
|
|||||||
@@ -7,8 +7,11 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: timetracker
|
container_name: timetracker
|
||||||
environment:
|
environment:
|
||||||
|
- DEBUG=false
|
||||||
- TZ=Europe/Prague
|
- TZ=Europe/Prague
|
||||||
- CSRF_TRUSTED_ORIGINS="https://tracker.kucharczyk.xyz"
|
# APP_URL drives ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS unless overridden.
|
||||||
|
# Behind your own reverse proxy you may also set ALLOWED_HOSTS=* directly.
|
||||||
|
- APP_URL=https://tracker.kucharczyk.xyz
|
||||||
user: "1000"
|
user: "1000"
|
||||||
# volumes:
|
# volumes:
|
||||||
# - "db:/home/timetracker/app/src/timetracker/db.sqlite3"
|
# - "db:/home/timetracker/app/src/timetracker/db.sqlite3"
|
||||||
|
|||||||
+3
-1
@@ -7,8 +7,10 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: timetracker
|
container_name: timetracker
|
||||||
environment:
|
environment:
|
||||||
|
- DEBUG=false
|
||||||
- TZ=${TZ:-Europe/Prague}
|
- TZ=${TZ:-Europe/Prague}
|
||||||
- CSRF_TRUSTED_ORIGINS=https://tracker.kucharczyk.xyz
|
# APP_URL drives ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS unless overridden.
|
||||||
|
- APP_URL=https://tracker.kucharczyk.xyz
|
||||||
- PUID=${PUID:-1000}
|
- PUID=${PUID:-1000}
|
||||||
- PGID=${PGID:-100}
|
- PGID=${PGID:-100}
|
||||||
- DATA_DIR=${DATA_DIR:-/home/timetracker/app/data}
|
- DATA_DIR=${DATA_DIR:-/home/timetracker/app/data}
|
||||||
|
|||||||
@@ -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=*`.
|
||||||
+11
-3
@@ -1,8 +1,16 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Container-bootstrap configuration. These variables are consumed only by this
|
||||||
|
# entrypoint, NOT by Django (see timetracker/config.py for the app settings):
|
||||||
|
# PUID/PGID — uid/gid the container process runs as
|
||||||
|
# DATA_DIR — writable dir for the SQLite database (kept in
|
||||||
|
# sync with Django via the same env var + default)
|
||||||
|
# CREATE_DEFAULT_SUPERUSER — create an admin/admin user on first start
|
||||||
|
# STAGING / LOAD_SAMPLE_DATA — staging-only data bootstrap (see below)
|
||||||
PUID=${PUID:-1000}
|
PUID=${PUID:-1000}
|
||||||
PGID=${PGID:-100}
|
PGID=${PGID:-100}
|
||||||
|
DATA_DIR=${DATA_DIR:-/home/timetracker/app/data}
|
||||||
|
|
||||||
USERHOME=$(grep timetracker /etc/passwd | cut -d ":" -f6)
|
USERHOME=$(grep timetracker /etc/passwd | cut -d ":" -f6)
|
||||||
usermod -d "/root" timetracker
|
usermod -d "/root" timetracker
|
||||||
@@ -10,11 +18,11 @@ groupmod -o -g "$PGID" timetracker
|
|||||||
usermod -o -u "$PUID" timetracker
|
usermod -o -u "$PUID" timetracker
|
||||||
usermod -d "${USERHOME}" timetracker
|
usermod -d "${USERHOME}" timetracker
|
||||||
|
|
||||||
mkdir -p /home/timetracker/app/data /var/log/supervisor
|
mkdir -p "$DATA_DIR" /var/log/supervisor
|
||||||
chmod 755 /home/timetracker/app
|
chmod 755 /home/timetracker/app
|
||||||
chmod 755 /home/timetracker/app/.venv
|
chmod 755 /home/timetracker/app/.venv
|
||||||
|
|
||||||
chown "$PUID:$PGID" /home/timetracker/app/data
|
chown "$PUID:$PGID" "$DATA_DIR"
|
||||||
chown "$PUID:$PGID" /var/log/supervisor
|
chown "$PUID:$PGID" /var/log/supervisor
|
||||||
|
|
||||||
python manage.py migrate
|
python manage.py migrate
|
||||||
@@ -49,6 +57,6 @@ if not User.objects.filter(username='admin').exists():
|
|||||||
"
|
"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
chown -R "$PUID:$PGID" /home/timetracker/app/data
|
chown -R "$PUID:$PGID" "$DATA_DIR"
|
||||||
|
|
||||||
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisor.conf
|
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisor.conf
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ primary_region = "ams"
|
|||||||
dockerfile = "Dockerfile"
|
dockerfile = "Dockerfile"
|
||||||
|
|
||||||
[env]
|
[env]
|
||||||
PROD = "1"
|
DEBUG = "false"
|
||||||
TZ = "Europe/Prague"
|
TZ = "Europe/Prague"
|
||||||
DATA_DIR = "/home/timetracker/app/data"
|
DATA_DIR = "/home/timetracker/app/data"
|
||||||
LOAD_SAMPLE_DATA = "true"
|
LOAD_SAMPLE_DATA = "true"
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Alternative to a .env file for non-Docker / bare-metal deployments.
|
||||||
|
# Copy to settings.ini (next to manage.py) or point INI_FILE at it.
|
||||||
|
# Real environment variables and a .env file both take precedence over this.
|
||||||
|
# See docs/configuration.md for the full reference.
|
||||||
|
|
||||||
|
[timetracker]
|
||||||
|
DEBUG = false
|
||||||
|
SECRET_KEY = change-me-to-a-long-random-string
|
||||||
|
APP_URL = https://tracker.kucharczyk.xyz
|
||||||
|
TZ = Europe/Prague
|
||||||
|
DATA_DIR = /var/lib/timetracker
|
||||||
|
|
||||||
|
# Optional explicit overrides (comma-separated); win over APP_URL when set.
|
||||||
|
# ALLOWED_HOSTS = *
|
||||||
|
# CSRF_TRUSTED_ORIGINS = https://tracker.kucharczyk.xyz
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
"""Tests for the configuration reader in ``timetracker/config.py``."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
|
|
||||||
|
from timetracker import config as config_module
|
||||||
|
from timetracker.config import config
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear_caches():
|
||||||
|
"""Each test sees freshly parsed files."""
|
||||||
|
config_module.reset_caches()
|
||||||
|
yield
|
||||||
|
config_module.reset_caches()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def env_file(tmp_path, monkeypatch):
|
||||||
|
def _write(contents: str):
|
||||||
|
path = tmp_path / ".env"
|
||||||
|
path.write_text(contents)
|
||||||
|
monkeypatch.setenv("ENV_FILE", str(path))
|
||||||
|
config_module.reset_caches()
|
||||||
|
return path
|
||||||
|
|
||||||
|
return _write
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def ini_file(tmp_path, monkeypatch):
|
||||||
|
def _write(contents: str):
|
||||||
|
path = tmp_path / "settings.ini"
|
||||||
|
path.write_text(contents)
|
||||||
|
monkeypatch.setenv("INI_FILE", str(path))
|
||||||
|
config_module.reset_caches()
|
||||||
|
return path
|
||||||
|
|
||||||
|
return _write
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_returned_when_unset():
|
||||||
|
assert config("TOTALLY_UNSET_VALUE", default="fallback") == "fallback"
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_without_default_raises():
|
||||||
|
with pytest.raises(ImproperlyConfigured):
|
||||||
|
config("TOTALLY_UNSET_VALUE")
|
||||||
|
|
||||||
|
|
||||||
|
def test_env_var_overrides_default(monkeypatch):
|
||||||
|
monkeypatch.setenv("SOME_SETTING", "from-env")
|
||||||
|
assert config("SOME_SETTING", default="fallback") == "from-env"
|
||||||
|
|
||||||
|
|
||||||
|
def test_priority_env_beats_files(monkeypatch, env_file, ini_file):
|
||||||
|
ini_file("[timetracker]\nVALUE = from-ini\n")
|
||||||
|
env_file("VALUE=from-dotenv\n")
|
||||||
|
monkeypatch.setenv("VALUE", "from-env")
|
||||||
|
assert config("VALUE") == "from-env"
|
||||||
|
|
||||||
|
|
||||||
|
def test_priority_dotenv_beats_ini(env_file, ini_file):
|
||||||
|
ini_file("[timetracker]\nVALUE = from-ini\n")
|
||||||
|
env_file("VALUE=from-dotenv\n")
|
||||||
|
assert config("VALUE") == "from-dotenv"
|
||||||
|
|
||||||
|
|
||||||
|
def test_priority_ini_beats_default(ini_file):
|
||||||
|
ini_file("[timetracker]\nVALUE = from-ini\n")
|
||||||
|
assert config("VALUE", default="fallback") == "from-ini"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ini_preserves_key_case(ini_file):
|
||||||
|
ini_file("[timetracker]\nSECRET_KEY = abc\n")
|
||||||
|
assert config("SECRET_KEY") == "abc"
|
||||||
|
|
||||||
|
|
||||||
|
# --- __FILE secret pointer -------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_pointer_read_and_stripped(tmp_path, monkeypatch):
|
||||||
|
secret = tmp_path / "secret"
|
||||||
|
secret.write_text("super-secret-value\n") # trailing newline must be stripped
|
||||||
|
monkeypatch.setenv("SECRET_KEY__FILE", str(secret))
|
||||||
|
assert config("SECRET_KEY", allow_file=True) == "super-secret-value"
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_pointer_ignored_without_allow_file(tmp_path, monkeypatch):
|
||||||
|
secret = tmp_path / "secret"
|
||||||
|
secret.write_text("ignored")
|
||||||
|
monkeypatch.setenv("SECRET_KEY__FILE", str(secret))
|
||||||
|
assert config("SECRET_KEY", default="fallback") == "fallback"
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_pointer_beats_env(tmp_path, monkeypatch):
|
||||||
|
secret = tmp_path / "secret"
|
||||||
|
secret.write_text("from-file")
|
||||||
|
monkeypatch.setenv("SECRET_KEY__FILE", str(secret))
|
||||||
|
monkeypatch.setenv("SECRET_KEY", "from-env")
|
||||||
|
assert config("SECRET_KEY", allow_file=True) == "from-file"
|
||||||
|
|
||||||
|
|
||||||
|
# --- casting ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"raw,expected",
|
||||||
|
[
|
||||||
|
("true", True),
|
||||||
|
("True", True),
|
||||||
|
("1", True),
|
||||||
|
("yes", True),
|
||||||
|
("on", True),
|
||||||
|
("false", False),
|
||||||
|
("0", False),
|
||||||
|
("no", False),
|
||||||
|
("", False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_cast_bool(monkeypatch, raw, expected):
|
||||||
|
monkeypatch.setenv("FLAG", raw)
|
||||||
|
assert config("FLAG", cast=bool) is expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_cast_list(monkeypatch):
|
||||||
|
monkeypatch.setenv("HOSTS", "a.example, b.example , ,c.example")
|
||||||
|
assert config("HOSTS", cast=list) == ["a.example", "b.example", "c.example"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_cast_int(monkeypatch):
|
||||||
|
monkeypatch.setenv("COUNT", "42")
|
||||||
|
assert config("COUNT", cast=int) == 42
|
||||||
|
|
||||||
|
|
||||||
|
def test_cast_not_applied_to_default():
|
||||||
|
# A None default passes through untouched even with a cast set.
|
||||||
|
assert config("UNSET", default=None, cast=list) is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- required_in_prod ------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_required_in_prod_raises_when_prod(monkeypatch):
|
||||||
|
monkeypatch.setenv("DEBUG", "false")
|
||||||
|
with pytest.raises(ImproperlyConfigured):
|
||||||
|
config("SECRET_KEY", default="dev-default", required_in_prod=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_required_in_prod_uses_default_in_debug(monkeypatch):
|
||||||
|
monkeypatch.setenv("DEBUG", "true")
|
||||||
|
assert config("SECRET_KEY", default="dev-default", required_in_prod=True) == (
|
||||||
|
"dev-default"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_deprecated_prod_var_implies_production(monkeypatch):
|
||||||
|
monkeypatch.delenv("DEBUG", raising=False)
|
||||||
|
monkeypatch.setenv("PROD", "1")
|
||||||
|
with pytest.raises(ImproperlyConfigured):
|
||||||
|
config("SECRET_KEY", default="dev-default", required_in_prod=True)
|
||||||
|
|
||||||
|
|
||||||
|
# --- .env parser edge cases ------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_env_parser_quotes_comments_and_export(env_file):
|
||||||
|
env_file(
|
||||||
|
"\n".join(
|
||||||
|
[
|
||||||
|
"# a comment line",
|
||||||
|
"PLAIN=value",
|
||||||
|
"export EXPORTED=exported-value",
|
||||||
|
'DOUBLE="quoted value"',
|
||||||
|
"SINGLE='single quoted'",
|
||||||
|
"INLINE=value # trailing comment",
|
||||||
|
'HASH_IN_QUOTES="a # b"',
|
||||||
|
"EMPTY=",
|
||||||
|
'QUOTED_THEN_COMMENT="keep" # drop',
|
||||||
|
]
|
||||||
|
)
|
||||||
|
+ "\n"
|
||||||
|
)
|
||||||
|
assert config("PLAIN") == "value"
|
||||||
|
assert config("EXPORTED") == "exported-value"
|
||||||
|
assert config("DOUBLE") == "quoted value"
|
||||||
|
assert config("SINGLE") == "single quoted"
|
||||||
|
assert config("INLINE") == "value"
|
||||||
|
assert config("HASH_IN_QUOTES") == "a # b"
|
||||||
|
assert config("EMPTY", default="x") == ""
|
||||||
|
assert config("QUOTED_THEN_COMMENT") == "keep"
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_files_are_ignored(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setenv("ENV_FILE", str(tmp_path / "does-not-exist.env"))
|
||||||
|
monkeypatch.setenv("INI_FILE", str(tmp_path / "does-not-exist.ini"))
|
||||||
|
config_module.reset_caches()
|
||||||
|
assert config("ANYTHING", default="fallback") == "fallback"
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"""
|
||||||
|
Centralized configuration reading for timetracker.
|
||||||
|
|
||||||
|
Every configurable Django setting is resolved through :func:`config`, which
|
||||||
|
consults several sources in a fixed priority order (highest first):
|
||||||
|
|
||||||
|
1. ``NAME__FILE`` — path to a file whose *stripped* contents are the value.
|
||||||
|
Only consulted when the setting opts in with
|
||||||
|
``allow_file=True``. Intended for Docker/Kubernetes
|
||||||
|
secrets, which are mounted as files rather than env vars.
|
||||||
|
2. ``NAME`` — a real process environment variable.
|
||||||
|
3. ``.env`` file — ``KEY=value`` lines (see the supported syntax below).
|
||||||
|
4. ``settings.ini`` — the ``[timetracker]`` section, parsed with
|
||||||
|
:mod:`configparser`.
|
||||||
|
5. ``default`` — the in-code fallback passed to :func:`config`.
|
||||||
|
|
||||||
|
If no source supplies a value and no ``default`` is given, an
|
||||||
|
:class:`~django.core.exceptions.ImproperlyConfigured` error is raised.
|
||||||
|
|
||||||
|
``.env`` syntax supported:
|
||||||
|
|
||||||
|
- ``KEY=value`` and ``export KEY=value``
|
||||||
|
- blank lines and ``#`` full-line comments
|
||||||
|
- single- or double-quoted values (the surrounding quotes are stripped); a
|
||||||
|
``#`` inside quotes is treated literally
|
||||||
|
- an inline ``# comment`` after an *unquoted* value
|
||||||
|
|
||||||
|
Deliberately NOT supported (documented limits, not bugs):
|
||||||
|
|
||||||
|
- variable interpolation (``${OTHER}``)
|
||||||
|
- multiline values
|
||||||
|
|
||||||
|
File locations default to ``.env`` and ``settings.ini`` next to the project
|
||||||
|
root and can be overridden with the ``ENV_FILE`` / ``INI_FILE`` environment
|
||||||
|
variables. Missing files are silently ignored so env-only deployments are
|
||||||
|
unaffected.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from configparser import ConfigParser
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
# Sentinel distinguishing "no default supplied" from an explicit ``None``.
|
||||||
|
NOT_SET: Any = object()
|
||||||
|
|
||||||
|
INI_SECTION = "timetracker"
|
||||||
|
|
||||||
|
_env_file_cache: dict[str, str] | None = None
|
||||||
|
_ini_file_cache: dict[str, str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _unquote(value: str) -> str:
|
||||||
|
"""Strip surrounding quotes, or an inline comment from an unquoted value."""
|
||||||
|
if not value:
|
||||||
|
return value
|
||||||
|
quote = value[0]
|
||||||
|
if quote in "\"'":
|
||||||
|
closing = value.find(quote, 1)
|
||||||
|
if closing != -1:
|
||||||
|
return value[1:closing]
|
||||||
|
# Opening quote with no match: drop it and keep the rest verbatim.
|
||||||
|
return value[1:]
|
||||||
|
comment_index = value.find("#")
|
||||||
|
if comment_index != -1:
|
||||||
|
value = value[:comment_index]
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_env_file(path: Path) -> dict[str, str]:
|
||||||
|
values: dict[str, str] = {}
|
||||||
|
for raw_line in path.read_text().splitlines():
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
if line.startswith("export "):
|
||||||
|
line = line[len("export ") :].lstrip()
|
||||||
|
if "=" not in line:
|
||||||
|
continue
|
||||||
|
name, _, value = line.partition("=")
|
||||||
|
name = name.strip()
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
values[name] = _unquote(value.strip())
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def _load_env_file() -> dict[str, str]:
|
||||||
|
global _env_file_cache
|
||||||
|
if _env_file_cache is None:
|
||||||
|
path = Path(os.environ.get("ENV_FILE", BASE_DIR / ".env"))
|
||||||
|
_env_file_cache = _parse_env_file(path) if path.is_file() else {}
|
||||||
|
return _env_file_cache
|
||||||
|
|
||||||
|
|
||||||
|
def _load_ini_file() -> dict[str, str]:
|
||||||
|
global _ini_file_cache
|
||||||
|
if _ini_file_cache is None:
|
||||||
|
path = Path(os.environ.get("INI_FILE", BASE_DIR / "settings.ini"))
|
||||||
|
if path.is_file():
|
||||||
|
parser = ConfigParser()
|
||||||
|
# Preserve key case; ConfigParser lowercases option names by default.
|
||||||
|
parser.optionxform = str # type: ignore[assignment, method-assign]
|
||||||
|
parser.read(path)
|
||||||
|
_ini_file_cache = (
|
||||||
|
dict(parser[INI_SECTION]) if parser.has_section(INI_SECTION) else {}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_ini_file_cache = {}
|
||||||
|
return _ini_file_cache
|
||||||
|
|
||||||
|
|
||||||
|
def reset_caches() -> None:
|
||||||
|
"""Clear parsed-file caches. Intended for use in tests."""
|
||||||
|
global _env_file_cache, _ini_file_cache
|
||||||
|
_env_file_cache = None
|
||||||
|
_ini_file_cache = None
|
||||||
|
|
||||||
|
|
||||||
|
def _cast_value(value: str, cast: Callable[[str], Any] | None) -> Any:
|
||||||
|
if cast is None:
|
||||||
|
return value
|
||||||
|
if cast is bool:
|
||||||
|
return value.strip().lower() in {"true", "1", "yes", "on"}
|
||||||
|
if cast is list:
|
||||||
|
return [item.strip() for item in value.split(",") if item.strip()]
|
||||||
|
return cast(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_raw(name: str, allow_file: bool) -> str | None:
|
||||||
|
"""Return the first raw string from the source chain, or ``None``."""
|
||||||
|
if allow_file:
|
||||||
|
file_pointer = os.environ.get(f"{name}__FILE")
|
||||||
|
if file_pointer:
|
||||||
|
return Path(file_pointer).read_text().strip()
|
||||||
|
if name in os.environ:
|
||||||
|
return os.environ[name]
|
||||||
|
env_file = _load_env_file()
|
||||||
|
if name in env_file:
|
||||||
|
return env_file[name]
|
||||||
|
ini_file = _load_ini_file()
|
||||||
|
if name in ini_file:
|
||||||
|
return ini_file[name]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _debug_enabled() -> bool:
|
||||||
|
"""Whether the app runs in DEBUG mode, mirroring ``settings.DEBUG``.
|
||||||
|
|
||||||
|
Defaults to on for local development; turned off by ``DEBUG=false`` or the
|
||||||
|
deprecated ``PROD`` env var. Used to decide whether ``required_in_prod``
|
||||||
|
settings may fall back to a development default.
|
||||||
|
"""
|
||||||
|
raw = _resolve_raw("DEBUG", allow_file=False)
|
||||||
|
if raw is not None:
|
||||||
|
return _cast_value(raw, bool)
|
||||||
|
return not bool(os.environ.get("PROD"))
|
||||||
|
|
||||||
|
|
||||||
|
def config(
|
||||||
|
name: str,
|
||||||
|
*,
|
||||||
|
default: Any = NOT_SET,
|
||||||
|
cast: Callable[[str], Any] | None = None,
|
||||||
|
allow_file: bool = False,
|
||||||
|
required_in_prod: bool = False,
|
||||||
|
) -> Any:
|
||||||
|
"""Resolve a configuration value from the source chain.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: The setting / environment variable name.
|
||||||
|
default: Fallback when no source provides a value. If omitted, a
|
||||||
|
missing value raises ``ImproperlyConfigured``.
|
||||||
|
cast: Coercion applied to string values — ``bool``, ``list``, ``int``,
|
||||||
|
``Path``, or any callable taking a string. Defaults are returned
|
||||||
|
untouched.
|
||||||
|
allow_file: Whether to honor a ``NAME__FILE`` secret pointer.
|
||||||
|
required_in_prod: When ``True``, a missing value raises in production
|
||||||
|
(DEBUG off) even if a ``default`` is given, so insecure development
|
||||||
|
defaults never leak into a deployment.
|
||||||
|
"""
|
||||||
|
raw = _resolve_raw(name, allow_file=allow_file)
|
||||||
|
if raw is None:
|
||||||
|
if required_in_prod and not _debug_enabled():
|
||||||
|
raise ImproperlyConfigured(
|
||||||
|
f"{name} must be set in production (DEBUG is off)."
|
||||||
|
)
|
||||||
|
if default is NOT_SET:
|
||||||
|
raise ImproperlyConfigured(f"Required setting {name} is not configured.")
|
||||||
|
return default
|
||||||
|
return _cast_value(raw, cast)
|
||||||
+39
-15
@@ -11,7 +11,11 @@ https://docs.djangoproject.com/en/4.1/ref/settings/
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import warnings
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from timetracker.config import config
|
||||||
|
|
||||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
@@ -20,18 +24,44 @@ BASE_DIR = Path(__file__).resolve().parent.parent
|
|||||||
# Quick-start development settings - unsuitable for production
|
# Quick-start development settings - unsuitable for production
|
||||||
# See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/
|
# See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/
|
||||||
|
|
||||||
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
|
# DEBUG defaults on for local development. Production turns it off via
|
||||||
|
# DEBUG=false (preferred) or the deprecated PROD env var.
|
||||||
|
_debug = config("DEBUG", default=None, cast=bool)
|
||||||
|
if _debug is None:
|
||||||
|
if os.environ.get("PROD"):
|
||||||
|
warnings.warn(
|
||||||
|
"The PROD environment variable is deprecated; set DEBUG=false instead.",
|
||||||
|
DeprecationWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
_debug = False
|
||||||
|
else:
|
||||||
|
_debug = True
|
||||||
|
DEBUG = _debug
|
||||||
|
|
||||||
# SECURITY WARNING: keep the secret key used in production secret!
|
# SECURITY WARNING: keep the secret key used in production secret!
|
||||||
# Read from the environment so each deployment (prod, staging) can supply its
|
# Each deployment supplies its own key (env, .env/.ini, or a SECRET_KEY__FILE
|
||||||
# own key; falls back to an insecure default for local development and tests.
|
# secret); falls back to an insecure default only in DEBUG. Missing in
|
||||||
SECRET_KEY = os.environ.get(
|
# production is a hard error rather than a silent insecure fallback.
|
||||||
|
SECRET_KEY = config(
|
||||||
"SECRET_KEY",
|
"SECRET_KEY",
|
||||||
"django-insecure-x0_t$gei=_o_p(%%!-db$jezka@y+d67$a8tvw13nl^8$l*t@=",
|
default="django-insecure-x0_t$gei=_o_p(%%!-db$jezka@y+d67$a8tvw13nl^8$l*t@=",
|
||||||
|
allow_file=True,
|
||||||
|
required_in_prod=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# SECURITY WARNING: don't run with debug turned on in production!
|
# ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS are configured independently (they
|
||||||
DEBUG = False if os.environ.get("PROD") else True
|
# guard different things), but both default off a single user-facing APP_URL
|
||||||
|
# when not set explicitly. Power users override either one directly — e.g.
|
||||||
|
# ALLOWED_HOSTS=* behind a reverse proxy while CSRF stays locked to the domain.
|
||||||
|
APP_URL = config("APP_URL", default="http://localhost:8000")
|
||||||
|
_app_url = urlparse(APP_URL)
|
||||||
|
|
||||||
ALLOWED_HOSTS = ["*"]
|
ALLOWED_HOSTS = config("ALLOWED_HOSTS", default=None, cast=list) or [_app_url.hostname]
|
||||||
|
CSRF_TRUSTED_ORIGINS = config("CSRF_TRUSTED_ORIGINS", default=None, cast=list) or [
|
||||||
|
f"{_app_url.scheme}://{_app_url.netloc}"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
# Application definition
|
# Application definition
|
||||||
@@ -114,7 +144,7 @@ WSGI_APPLICATION = "timetracker.wsgi.application"
|
|||||||
DATABASES = {
|
DATABASES = {
|
||||||
"default": {
|
"default": {
|
||||||
"ENGINE": "django.db.backends.sqlite3",
|
"ENGINE": "django.db.backends.sqlite3",
|
||||||
"NAME": Path(os.environ.get("DATA_DIR", str(BASE_DIR))) / "db.sqlite3",
|
"NAME": config("DATA_DIR", default=BASE_DIR, cast=Path) / "db.sqlite3",
|
||||||
"OPTIONS": {
|
"OPTIONS": {
|
||||||
"timeout": 20,
|
"timeout": 20,
|
||||||
"init_command": "PRAGMA synchronous=FULL; PRAGMA journal_mode=WAL;",
|
"init_command": "PRAGMA synchronous=FULL; PRAGMA journal_mode=WAL;",
|
||||||
@@ -147,7 +177,7 @@ AUTH_PASSWORD_VALIDATORS = [
|
|||||||
|
|
||||||
LANGUAGE_CODE = "en-us"
|
LANGUAGE_CODE = "en-us"
|
||||||
|
|
||||||
TIME_ZONE = "Europe/Prague" if DEBUG else os.environ.get("TZ", "UTC")
|
TIME_ZONE = config("TZ", default="Europe/Prague" if DEBUG else "UTC")
|
||||||
|
|
||||||
USE_I18N = True
|
USE_I18N = True
|
||||||
|
|
||||||
@@ -180,9 +210,3 @@ LOGGING = {
|
|||||||
"games": {"handlers": ["console"], "level": "INFO", "propagate": False},
|
"games": {"handlers": ["console"], "level": "INFO", "propagate": False},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
_csrf_trusted_origins = os.environ.get("CSRF_TRUSTED_ORIGINS")
|
|
||||||
if _csrf_trusted_origins:
|
|
||||||
CSRF_TRUSTED_ORIGINS = _csrf_trusted_origins.split(",")
|
|
||||||
else:
|
|
||||||
CSRF_TRUSTED_ORIGINS = []
|
|
||||||
|
|||||||
Reference in New Issue
Block a user