Compare commits

...

16 Commits

Author SHA1 Message Date
lukas ce5e5fb729 Run gen_element_types before tsc --watch in make server/dev
Staging deployment / deploy (push) Successful in 10s
Staging deployment / comment (push) Has been skipped
Staging deployment / teardown (push) Has been skipped
Django CI/CD / build-and-push (push) Successful in 3m32s
Django CI/CD / test (push) Successful in 3m45s
make server and make dev were starting tsc --watch cold, so new element
registrations never landed in ts/generated/props.ts until make ts was run
manually. Adding gen-element-types as a dependency ensures props.ts is
always fresh before the watcher starts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 12:50:39 +02:00
lukas 846151d373 Fix converted price rounding (0 decimal places, not 2)
Django CI/CD / test (push) Failing after 5m49s
Staging deployment / deploy (push) Successful in 1m10s
Staging deployment / comment (push) Has been skipped
Staging deployment / teardown (push) Has been skipped
Django CI/CD / build-and-push (push) Has been skipped
round(..., 0) matches the original floatformat(..., 0) intent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 12:44:27 +02:00
lukas 1fcef255a6 Fix convert_prices storing exchange rate as string
Django CI/CD / test (push) Successful in 4m4s
Staging deployment / deploy (push) Failing after 31s
Staging deployment / comment (push) Has been skipped
Staging deployment / teardown (push) Has been skipped
Django CI/CD / build-and-push (push) Has been skipped
floatformat() returns a str; saving that to ExchangeRate.rate (FloatField)
via create() leaves the Python instance attribute as a str. Reading it back
on the same instance (rate = exchange_rate.rate) then caused
`purchase.price * rate` to fail with "can't multiply sequence by non-int
of type 'float'".

Fix: pass the raw float from the API directly to ExchangeRate.objects.create.
Also replace floatformat(..., 0) on the converted price with round(..., 2)
to keep a numeric type throughout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 12:43:46 +02:00
lukas 97fff21b28 Ignore more SQLite database files 2026-06-19 12:37:25 +02:00
lukas c6aa3d25cc Update uv.lock 2026-06-19 12:36:40 +02:00
lukas 733da3419b Model refundable orders as separate purchases; add split action
Django CI/CD / test (push) Successful in 3m41s
Staging deployment / deploy (push) Successful in 1m7s
Staging deployment / comment (push) Has been skipped
Staging deployment / teardown (push) Has been skipped
Django CI/CD / build-and-push (push) Failing after 12m17s
A multi-game Purchase is now treated as an *unsplittable* bundle (one
price, whole-purchase refund). Independently-refundable multi-item orders
(e.g. a Steam cart) are instead recorded as N separate single-game
purchases, so per-game pricing and per-game refunds work with the
existing single-purchase machinery — no through-model needed.

Add-purchase form (single form, single endpoint):
- 1 game: unchanged.
- 2+ games: a "Separate price per game" toggle appears (default off =
  one bundle price). On, the bundle Price hides and one price input per
  game appears; the view creates one single-game Purchase each from
  price_for_game_<id>. `price` is now optional so combined mode still
  validates.

Split action:
- A Split button on multi-game purchase rows opens a confirmation modal
  that replaces the bundle with one single-game purchase per game (price
  split evenly, needs_price_update set), then HX-Redirects to the list.

New general-purpose `selection-fields` custom element renders one synced
form field per selected item of a source SearchSelect (consuming the
existing search-select:change contract); it knows nothing about prices,
so it is reusable. Behavior in ts/elements/selection-fields.ts.

Adds the bundle-vs-separate-purchases convention to CLAUDE.md, a split
icon, and unit + Playwright e2e coverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 11:36:47 +02:00
lukas f693f8280f Fix pre-existing lint and format issues in domain.py and layout.py
Remove two unused `*Props` imports flagged by ruff (F401) and apply
`ruff format` line-wrapping. Pure cleanup, no behavior change — unblocks
`make check` independently of the purchase changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 11:36:47 +02:00
lukas dfccfbff51 Anchor DLC purchases to a base game instead of a parent purchase
Add-on purchases (DLC, Season Pass, Battle Pass) previously linked to a
parent *purchase* via the `related_purchase` self-FK. When the base game
was bought inside a multi-game purchase (e.g. a bundle), there was no
per-game purchase to point at — only the whole bundle.

Replace it with a `related_game` FK (Game -> Game): an add-on belongs to
a *game*, which is unambiguous regardless of how the base game was bought.

- models: drop `related_purchase`; add `related_game`
  (SET_NULL, related_name="addon_purchases"); require it for non-GAME
  types in `save()`.
- forms: replace the parent-purchase picker with a flat `related_game`
  game search (reusing SearchSelectWidget/_game_options); drop the now
  unused related_purchase_queryset/RelatedPurchaseChoiceField.
- views/urls: remove the obsolete related_purchase_by_game endpoint.
- add_purchase.js: drop the parent-dropdown refetch; keep platform
  auto-fill; retarget the type toggle to #id_related_game.
- migration 0020: add -> backfill (related_game = parent's first game by
  sort_name) -> remove related_purchase.
- tests: model validation unit tests + an e2e test for the flat picker.

related_game is deliberately game->game so it can later be synced from
IGDB's parent_game without schema changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 11:36:47 +02:00
lukas 62f0c6c261 Add tests for multiple APP_URLS
Django CI/CD / test (push) Successful in 3m20s
Django CI/CD / build-and-push (push) Successful in 3m48s
2026-06-19 11:28:16 +02:00
lukas d0d6b3f999 Make APP_URLS accept list 2026-06-19 11:28:16 +02:00
lukas 6f58eb3fde Surface the staging URL reliably
Echo the staging URL into the deploy log (not just the step summary),
and comment it when a PR is opened for an already-deployed branch
instead of waiting for the next push.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-19 11:28:16 +02:00
lukas d45ae357c4 Seed staging databases from a prod snapshot on first deploy
When a branch's staging volume doesn't exist yet, take a WAL-safe
online snapshot of the prod SQLite database (sqlite3.backup() in a
throwaway container, prod is only read) into the new volume. Later
pushes keep the staging data; deleting the branch (or the volume)
causes a fresh seed next time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-19 11:28:16 +02:00
lukas ae7fa5bae7 Add CSS-less dev mode
Django CI/CD / test (push) Successful in 5m54s
Django CI/CD / build-and-push (push) Successful in 3m27s
2026-06-19 11:26:01 +02:00
lukas be95c32e7b Change the default APP_URL in docker-compose.yml 2026-06-19 11:26:01 +02:00
lukas 32588226de Fix docker-compose not forwarding SECRET_KEY into container
SECRET_KEY, APP_URL, and DEBUG were hardcoded/missing in the compose
environment block, so passing SECRET_KEY from the host env had no effect
and the container always raised ImproperlyConfigured in production mode.

All three are now forwarded via ${VAR} substitution, consistent with
the other configurable values.
2026-06-19 11:26:01 +02:00
Claude 2ae01bfecf 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
2026-06-19 11:26:01 +02:00
33 changed files with 1732 additions and 193 deletions
+44 -17
View File
@@ -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(s) of the site — one URL or comma-separated list of full URLs.
# Derives ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS from all listed URLs.
APP_URL=https://tracker.kucharczyk.xyz
# APP_URL=https://tracker.kucharczyk.xyz,https://www.tracker.kucharczyk.xyz
# Override ALLOWED_HOSTS directly for edge cases (e.g. behind a reverse proxy).
# ALLOWED_HOSTS=*
# 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
+56 -2
View File
@@ -4,6 +4,8 @@ on:
push: push:
branches-ignore: [main] branches-ignore: [main]
delete: delete:
pull_request:
types: [opened]
jobs: jobs:
deploy: deploy:
@@ -26,6 +28,31 @@ jobs:
- name: Build image - name: Build image
run: docker build -t "timetracker:staging-${SLUG}" . run: docker build -t "timetracker:staging-${SLUG}" .
- name: Seed database from prod (first deploy of this branch only)
run: |
if docker volume inspect "timetracker-staging-${SLUG}" >/dev/null 2>&1; then
echo "Volume exists, keeping current staging data"
exit 0
fi
docker volume create "timetracker-staging-${SLUG}"
# sqlite3.backup() takes a consistent online snapshot (WAL-safe);
# prod is only read, never written.
docker run --rm \
-v /docker/timetracker/data:/prod \
-v "timetracker-staging-${SLUG}:/dest" \
python:3.14-slim-bookworm sh -c "
python -c \"
import sqlite3
source = sqlite3.connect('file:/prod/db.sqlite3?mode=ro', uri=True)
destination = sqlite3.connect('/dest/db.sqlite3')
source.backup(destination)
games = destination.execute('select count(*) from games_game').fetchone()[0]
sessions = destination.execute('select count(*) from games_session').fetchone()[0]
print(f'Seeded staging database: {games} games, {sessions} sessions')
destination.close()
source.close()
\" && chown 1000:100 /dest/db.sqlite3"
- name: Deploy staging container - name: Deploy staging container
run: | run: |
docker rm -f "timetracker-staging-${SLUG}" 2>/dev/null || true docker rm -f "timetracker-staging-${SLUG}" 2>/dev/null || true
@@ -37,7 +64,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 }}' \
@@ -47,7 +74,9 @@ jobs:
"timetracker:staging-${SLUG}" "timetracker:staging-${SLUG}"
- name: Summary - name: Summary
run: echo "Deployed to https://${HOST}" >> "$GITHUB_STEP_SUMMARY" run: |
echo "Deployed to https://${HOST}"
echo "Deployed to https://${HOST}" >> "$GITHUB_STEP_SUMMARY"
- name: Comment staging URL on PR - name: Comment staging URL on PR
env: env:
@@ -72,6 +101,31 @@ jobs:
"${api}/issues/${pr}/comments" >/dev/null "${api}/issues/${pr}/comments" >/dev/null
echo "Commented staging URL on PR #${pr}" echo "Commented staging URL on PR #${pr}"
comment:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
env:
BRANCH: ${{ github.event.pull_request.head.ref }}
PR: ${{ github.event.pull_request.number }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Comment staging URL on the new PR
run: |
SLUG=$(echo "$BRANCH" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9-]+/-/g; s/-+/-/g; s/^-//; s/-$//' | cut -c1-40)
HOST="tracker-${SLUG}.home.arpa"
auth="Authorization: token ${GITHUB_TOKEN}"
api="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}"
body="Staging deployment: https://${HOST}"
if curl -fsS -H "$auth" "${api}/issues/${PR}/comments" \
| jq -e --arg body "$body" 'any(.[]; .body == $body)' >/dev/null; then
echo "Staging URL already commented on PR #${PR}"
exit 0
fi
curl -fsS -X POST -H "$auth" -H 'Content-Type: application/json' \
-d "$(jq -n --arg body "$body" '{body: $body}')" \
"${api}/issues/${PR}/comments" >/dev/null
echo "Commented staging URL on PR #${PR}"
teardown: teardown:
if: github.event_name == 'delete' && github.event.ref_type == 'branch' if: github.event_name == 'delete' && github.event.ref_type == 'branch'
runs-on: ubuntu-latest runs-on: ubuntu-latest
+2 -1
View File
@@ -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
+6
View File
@@ -5,11 +5,17 @@ __pycache__
node_modules node_modules
package-lock.json package-lock.json
db.sqlite3 db.sqlite3
db.sqlite3-shm
db.sqlite3-wal
data/ data/
/static/ /static/
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/
+13 -7
View File
@@ -44,7 +44,7 @@ docs/ — Additional documentation
- **Game** — `name`, `platform` (FK), `status` (u/p/f/r/a), `mastered`, `playtime` (DurationField updated via signal), `year_released`, `sort_name`, `wikidata` - **Game** — `name`, `platform` (FK), `status` (u/p/f/r/a), `mastered`, `playtime` (DurationField updated via signal), `year_released`, `sort_name`, `wikidata`
- **Platform** — `name`, `group`, `icon` (slug, auto-generated from name) - **Platform** — `name`, `group`, `icon` (slug, auto-generated from name)
- **Purchase** — ownership type, prices, currency conversion (`converted_price`, `price_per_game` is a `GeneratedField`), links to Game via M2M. `num_purchases` counts linked games. DLC/SeasonPass/BattlePass must have a `related_purchase` - **Purchase** — ownership type, prices, currency conversion (`converted_price`, `price_per_game` is a `GeneratedField`), links to Game via M2M. `num_purchases` counts linked games. DLC/SeasonPass/BattlePass must have a `related_game` (the base Game the add-on belongs to; reverse accessor `game.addon_purchases`). **A multi-game Purchase is an *unsplittable* bundle** (one price, whole-purchase refund — e.g. a Humble Bundle). Independently-refundable multi-item orders (e.g. a Steam cart) are modeled as **separate single-game purchases**, not one bundle: the add-purchase form's "separate price per game" mode (≥2 games) creates them, and the row's **Split** action breaks an existing bundle into per-game purchases (price split evenly as a starting point). This is why per-game refund/price need no through-model — each refundable unit is its own Purchase.
- **Session** — `timestamp_start`/`timestamp_end`, `duration_manual`, `device` (FK), `note`, `emulated`. `duration_calculated` and `duration_total` are `GeneratedField`s (cannot be written directly) - **Session** — `timestamp_start`/`timestamp_end`, `duration_manual`, `device` (FK), `note`, `emulated`. `duration_calculated` and `duration_total` are `GeneratedField`s (cannot be written directly)
- **Device** — `name`, `type` (PC/Console/Handheld/Mobile/SBC/Unknown) - **Device** — `name`, `type` (PC/Console/Handheld/Mobile/SBC/Unknown)
- **PlayEvent** — marks when a game was started/finished (separate from Sessions), `days_to_finish` is a `GeneratedField` - **PlayEvent** — marks when a game was started/finished (separate from Sessions), `days_to_finish` is a `GeneratedField`
@@ -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` accepts one full URL or a comma-separated list of full URLs; `ALLOWED_HOSTS` and `CSRF_TRUSTED_ORIGINS` are derived from all listed URLs. `ALLOWED_HOSTS` can still be overridden directly (e.g. `ALLOWED_HOSTS=*` behind a reverse proxy); `CSRF_TRUSTED_ORIGINS` is always derived from `APP_URL`.
- `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 -3
View File
@@ -22,8 +22,12 @@ init:
pnpm install pnpm install
$(MAKE) loadplatforms $(MAKE) loadplatforms
server: server: gen-element-types
uv run python -Wa manage.py runserver @pnpm concurrently \
--names "Django,TS" \
--prefix-colors "blue,green" \
"uv run python -Wa manage.py runserver" \
"pnpm exec tsc --watch"
gen-element-types: gen-element-types:
uv run python manage.py gen_element_types uv run python manage.py gen_element_types
@@ -34,7 +38,7 @@ ts: gen-element-types
ts-check: gen-element-types ts-check: gen-element-types
pnpm exec tsc --noEmit pnpm exec tsc --noEmit
dev: dev: gen-element-types
@pnpm concurrently \ @pnpm concurrently \
--names "Django,Tailwind,TS" \ --names "Django,Tailwind,TS" \
--prefix-colors "blue,green,magenta" \ --prefix-colors "blue,green,magenta" \
+6 -1
View File
@@ -18,7 +18,11 @@ from common.components.core import (
randomid, randomid,
render, render,
) )
from common.components.custom_elements import SessionTimestampButtons, register_element from common.components.custom_elements import (
SelectionFields,
SessionTimestampButtons,
register_element,
)
from common.components.date_range_picker import ( from common.components.date_range_picker import (
DateRangeCalendar, DateRangeCalendar,
DateRangeField, DateRangeField,
@@ -94,6 +98,7 @@ __all__ = [
"truncate", "truncate",
"BaseComponent", "BaseComponent",
"register_element", "register_element",
"SelectionFields",
"SessionTimestampButtons", "SessionTimestampButtons",
"custom_element_builder", "custom_element_builder",
"Element", "Element",
+56 -2
View File
@@ -11,8 +11,14 @@ reader so drift fails ``tsc``.
from dataclasses import dataclass from dataclasses import dataclass
from typing import TypedDict, get_type_hints from typing import TypedDict, get_type_hints
from common.components.core import Media from common.components.core import Node
from common.components.primitives import custom_element_builder from common.components.primitives import (
Div,
Input,
Label,
Template,
custom_element_builder,
)
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -125,3 +131,51 @@ _GameStatusSelector = custom_element_builder("game-status-selector")
_SessionDeviceSelector = custom_element_builder("session-device-selector") _SessionDeviceSelector = custom_element_builder("session-device-selector")
_PlayEventRow = custom_element_builder("play-event-row") _PlayEventRow = custom_element_builder("play-event-row")
SessionTimestampButtons = custom_element_builder("session-timestamp-buttons") SessionTimestampButtons = custom_element_builder("session-timestamp-buttons")
class SelectionFieldsProps(TypedDict):
source: str # data-name of the source SearchSelect to mirror
name_prefix: str # each rendered input is named f"{name_prefix}{item_id}"
field_type: str # input type, e.g. "number"
min_items: int # render nothing until at least this many items are selected
active: bool # when false, render nothing (but preserve typed values)
register_element("selection-fields", "SelectionFields", SelectionFieldsProps)
_SelectionFields = custom_element_builder("selection-fields")
def SelectionFields(
*,
source: str,
name_prefix: str,
field_type: str = "text",
min_items: int = 1,
active: bool = False,
input_attributes: list[tuple[str, str]] | None = None,
) -> Node:
"""Render one synced form field per selected item of a source SearchSelect.
General-purpose: it mirrors the SearchSelect named ``source`` and emits an
input named ``f"{name_prefix}{item_id}"`` per selected item. Behavior lives
in ``ts/elements/selection-fields.ts``; this is just the server-rendered
light DOM (an empty rows container + a row ``<template>``). Inputs inherit
the global ``#add-form`` styling, so the markup stays minimal.
"""
row_template = Template(attributes=[("data-selection-fields-row", "")])[
Div(attributes=[("data-selection-fields-row-item", "")])[
Label(attributes=[("data-selection-fields-label", "")]),
Input(type=field_type, attributes=list(input_attributes or [])),
]
]
return _SelectionFields(
source=source,
name_prefix=name_prefix,
field_type=field_type,
min_items=min_items,
active="true" if active else "false",
)[
Div(attributes=[("data-selection-fields-rows", "")]),
row_template,
]
+2 -2
View File
@@ -229,7 +229,7 @@ _SELECTOR_OPTION_CLASS = (
def GameStatusSelector(game, game_statuses, csrf_token: str) -> Node: def GameStatusSelector(game, game_statuses, csrf_token: str) -> Node:
"""Light-DOM custom element; behavior in ts/elements/game-status-selector.ts.""" """Light-DOM custom element; behavior in ts/elements/game-status-selector.ts."""
from common.components.core import Element from common.components.core import Element
from common.components.custom_elements import _GameStatusSelector, GameStatusSelectorProps from common.components.custom_elements import _GameStatusSelector
from common.components.primitives import Li, Ul from common.components.primitives import Li, Ul
options = [ options = [
@@ -273,7 +273,7 @@ def GameStatusSelector(game, game_statuses, csrf_token: str) -> Node:
def SessionDeviceSelector(session, session_devices, csrf_token: str) -> Node: def SessionDeviceSelector(session, session_devices, csrf_token: str) -> Node:
"""Light-DOM custom element; behavior in ts/elements/session-device-selector.ts.""" """Light-DOM custom element; behavior in ts/elements/session-device-selector.ts."""
from common.components.core import Element from common.components.core import Element
from common.components.custom_elements import _SessionDeviceSelector, SessionDeviceSelectorProps from common.components.custom_elements import _SessionDeviceSelector
from common.components.primitives import Li, Ul from common.components.primitives import Li, Ul
current_name = session.device.name if session.device else "Unknown" current_name = session.device.name if session.device else "Unknown"
+3 -1
View File
@@ -187,7 +187,9 @@ def _main_script(mastered: bool) -> str:
return _MAIN_SCRIPT_A + ("true" if mastered else "false") + _MAIN_SCRIPT_B return _MAIN_SCRIPT_A + ("true" if mastered else "false") + _MAIN_SCRIPT_B
def Navbar(*, today_played: str, last_7_played: str, current_year: int, csrf_token: str) -> "Node": def Navbar(
*, today_played: str, last_7_played: str, current_year: int, csrf_token: str
) -> "Node":
"""Top navigation bar. """Top navigation bar.
Static chrome, so it's a single ``Safe`` node wrapping its markup rather Static chrome, so it's a single ``Safe`` node wrapping its markup rather
+4 -1
View File
@@ -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"
+5 -2
View File
@@ -1,14 +1,17 @@
--- ---
services: services:
timetracker: timetracker:
image: ${REGISTRY_URL:-registry.kucharczyk.xyz}/timetracker:1.7.0 image: ${REGISTRY_URL:-registry.kucharczyk.xyz}/timetracker:latest
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: timetracker container_name: timetracker
environment: environment:
- DEBUG=${DEBUG:-false}
- SECRET_KEY=${SECRET_KEY}
- 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=${APP_URL:-http://localhost:8000}
- 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}
+133
View File
@@ -0,0 +1,133 @@
# 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 (or comma-separated URLs) | `http://localhost:8000` | no | Public URL(s) of the site. One full URL or a comma-separated list. Derives `ALLOWED_HOSTS` and `CSRF_TRUSTED_ORIGINS` from all listed URLs. |
| `ALLOWED_HOSTS` | list | derived from `APP_URL` | no | Comma-separated hostnames. Overrides the `APP_URL` derivation (useful for `ALLOWED_HOSTS=*` behind a reverse proxy). |
| `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
`APP_URL` accepts one full URL or a comma-separated list of full URLs. Both
`ALLOWED_HOSTS` and `CSRF_TRUSTED_ORIGINS` are derived from all listed URLs —
no need to repeat the same information in separate variables.
Single domain (common case):
```
APP_URL=https://tracker.example.com
# -> ALLOWED_HOSTS = ["tracker.example.com"]
# -> CSRF_TRUSTED_ORIGINS = ["https://tracker.example.com"]
```
Multiple domains:
```
APP_URL=https://tracker.example.com,https://www.tracker.example.com
# -> ALLOWED_HOSTS = ["tracker.example.com", "www.tracker.example.com"]
# -> CSRF_TRUSTED_ORIGINS = ["https://tracker.example.com", "https://www.tracker.example.com"]
```
`ALLOWED_HOSTS` can still be overridden directly for edge cases. A typical
reverse-proxy setup where the proxy validates the host:
```
ALLOWED_HOSTS=*
```
## 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=*`.
+178
View File
@@ -0,0 +1,178 @@
"""Browser tests for the purchase pricing UX and the split action.
- A synthetic page isolates the general ``selection-fields`` element (no API,
deterministic option values), mirroring ``test_search_select_e2e.py``.
- The real-app tests drive the actual add-purchase form and the split modal
against pytest-django's ``live_server``.
"""
from datetime import date
import pytest
from django.http import HttpResponse
from django.test import override_settings
from django.urls import path, reverse
from playwright.sync_api import Page, expect
from common.components import SearchSelect, SelectionFields
from games.models import Game, Platform, Purchase
def selection_fields_view(request):
html = f"""
<!DOCTYPE html>
<html>
<head>
<script src="/static/js/htmx.min.js"></script>
<script type="module" src="/static/js/search_select.js"></script>
<script type="module" src="/static/js/dist/elements/selection-fields.js"></script>
</head>
<body>
<div style="padding: 50px;">
{
SearchSelect(
name="games",
selected=[],
options=[
{"value": "7", "label": "Game A", "data": {}},
{"value": "8", "label": "Game B", "data": {}},
],
multi_select=True,
)
}
{
SelectionFields(
source="games",
name_prefix="price_for_game_",
field_type="number",
min_items=2,
active=True,
)
}
</div>
</body>
</html>
"""
return HttpResponse(html)
urlpatterns = [
path("sf-test/", selection_fields_view),
]
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_purchase_e2e")
def test_selection_fields_syncs_with_source(live_server, page: Page):
page.goto(live_server.url + "/sf-test/")
games = page.locator('[data-search-select][data-name="games"]')
rows = page.locator("selection-fields [data-selection-fields-rows] input")
# Below min_items (2): nothing rendered.
expect(rows).to_have_count(0)
games.locator("[data-search-select-search]").click()
games.locator('[data-search-select-option][data-value="7"]').click()
expect(rows).to_have_count(0) # only one selected, still below min_items
games.locator("[data-search-select-search]").click()
games.locator('[data-search-select-option][data-value="8"]').click()
expect(rows).to_have_count(2)
# One input per item, named by the prefix + item id.
expect(
page.locator('selection-fields input[name="price_for_game_7"]')
).to_have_count(1)
expect(
page.locator('selection-fields input[name="price_for_game_8"]')
).to_have_count(1)
# Typed values survive removing and re-adding another item.
page.locator('selection-fields input[name="price_for_game_7"]').fill("12")
games.locator('[data-pill][data-value="8"] [data-pill-remove]').click()
expect(rows).to_have_count(0)
games.locator("[data-search-select-search]").click()
games.locator('[data-search-select-option][data-value="8"]').click()
expect(rows).to_have_count(2)
expect(
page.locator('selection-fields input[name="price_for_game_7"]')
).to_have_value("12")
@pytest.fixture
def authenticated_page(live_server, page: Page, django_user_model) -> Page:
django_user_model.objects.create_user(username="tester", password="secret123")
page.goto(f"{live_server.url}{reverse('login')}")
page.fill('input[name="username"]', "tester")
page.fill('input[name="password"]', "secret123")
page.click('input[type="submit"]')
page.wait_for_url(f"{live_server.url}/tracker**")
return page
def _select_two_games(page: Page) -> None:
games = page.locator('[data-search-select][data-name="games"]')
games.locator("[data-search-select-search]").click()
options = games.locator("[data-search-select-option]")
expect(options).to_have_count(2) # prefetched on focus
options.nth(0).click()
options.nth(1).click()
def test_add_purchase_per_game_toggle_reveals_inputs(
authenticated_page: Page, live_server
):
"""The combined/per-game toggle appears only at 2+ games; turning it on
hides the bundle Price and shows one price input per selected game.
(Server-side creation of N purchases is covered by the unit tests.)"""
page = authenticated_page
platform = Platform.objects.create(name="PC", icon="pc", group="PC")
Game.objects.create(name="Alpha Game", platform=platform)
Game.objects.create(name="Beta Game", platform=platform)
page.goto(f"{live_server.url}{reverse('games:add_purchase')}")
checkbox_row = page.locator("#separate-prices-row")
expect(checkbox_row).to_be_hidden()
_select_two_games(page)
expect(checkbox_row).to_be_visible()
page.locator("#id_separate_prices").check()
expect(page.locator("#id_price")).to_be_hidden()
per_game_inputs = page.locator(
"selection-fields [data-selection-fields-rows] input"
)
expect(per_game_inputs).to_have_count(2)
def test_split_purchase_action(authenticated_page: Page, live_server):
page = authenticated_page
platform = Platform.objects.create(name="PC", icon="pc", group="PC")
game_a = Game.objects.create(name="Alpha Game", platform=platform)
game_b = Game.objects.create(name="Beta Game", platform=platform)
bundle = Purchase.objects.create(
price=30.0,
price_currency="USD",
date_purchased=date(2025, 1, 1),
platform=platform,
ownership_type=Purchase.DIGITAL,
type=Purchase.GAME,
)
bundle.games.set([game_a, game_b])
page.goto(f"{live_server.url}{reverse('games:list_purchases')}")
# Before: one bundle row.
expect(page.locator('[id^="purchase-row-"]')).to_have_count(1)
page.locator('[title="Split into per-game purchases"]').click()
modal = page.locator("#split-confirmation-modal")
expect(modal).to_be_visible()
modal.locator('button[type="submit"]', has_text="Split").click()
page.wait_for_url(f"{live_server.url}{reverse('games:list_purchases')}**")
# After: the bundle row is gone, replaced by two per-game rows. Asserted via
# the UI (not the ORM) to avoid live_server/SQLite write-read contention.
expect(page.locator(f"#purchase-row-{bundle.id}")).to_have_count(0)
expect(page.locator('[id^="purchase-row-"]')).to_have_count(2)
+15 -1
View File
@@ -120,7 +120,7 @@ def test_widgets_initialize_inside_htmx_swapped_content(
def test_add_purchase_type_toggles_disabled_fields( def test_add_purchase_type_toggles_disabled_fields(
authenticated_page: Page, live_server authenticated_page: Page, live_server
): ):
"""add_purchase.js disables name/related-purchase while type is "game" """add_purchase.js disables name/related-game while type is "game"
and re-enables them for other types.""" and re-enables them for other types."""
page = authenticated_page page = authenticated_page
page.goto(f"{live_server.url}{reverse('games:add_purchase')}") page.goto(f"{live_server.url}{reverse('games:add_purchase')}")
@@ -133,3 +133,17 @@ def test_add_purchase_type_toggles_disabled_fields(
page.select_option("#id_type", "game") page.select_option("#id_type", "game")
expect(name_input).to_be_disabled() expect(name_input).to_be_disabled()
def test_add_purchase_related_game_is_flat_game_search(
authenticated_page: Page, live_server
):
"""The DLC/Season-Pass anchor is now a flat game search (related_game),
wired to the games search API and present regardless of which games are
selected — not the old parent-purchase dropdown filtered by chosen games."""
page = authenticated_page
page.goto(f"{live_server.url}{reverse('games:add_purchase')}")
related = page.locator('[data-search-select][data-name="related_game"]')
expect(related).to_have_count(1)
expect(related).to_have_attribute("data-search-url", "/api/games/search")
+11 -3
View File
@@ -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
View File
@@ -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"
+18 -33
View File
@@ -1,6 +1,5 @@
from django import forms from django import forms
from django.db import transaction from django.db import transaction
from django.db.models import OuterRef, Subquery
from common.components import ( from common.components import (
DEFAULT_PREFETCH, DEFAULT_PREFETCH,
@@ -228,35 +227,13 @@ class SessionForm(PrimitiveWidgetsMixin, forms.ModelForm):
return session return session
def related_purchase_queryset():
"""GAME purchases annotated with their first game's name.
Rendering the ``related_purchase`` ``<select>`` calls ``str()`` on every
option, and ``Purchase.__str__`` falls back to ``first_game`` — one extra
query per option (700+ on a large library). Annotating the first game's
name via a subquery lets the choice field build labels without those
per-row queries.
"""
first_game_name = Subquery(
Game.objects.filter(purchases=OuterRef("pk")).order_by("id").values("name")[:1]
)
return Purchase.objects.filter(type=Purchase.GAME).annotate(
_first_game_name=first_game_name
)
class RelatedPurchaseChoiceField(forms.ModelChoiceField):
def label_from_instance(self, obj) -> str:
# Mirrors Purchase.standardized_name but reads the annotated first-game
# name instead of querying first_game per option.
name = obj.name or getattr(obj, "_first_game_name", None)
return name or obj.standardized_name
class PurchaseForm(PrimitiveWidgetsMixin, forms.ModelForm): class PurchaseForm(PrimitiveWidgetsMixin, forms.ModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields["platform"].queryset = Platform.objects.order_by("name") self.fields["platform"].queryset = Platform.objects.order_by("name")
# The bundle Price is optional: in price-per-game mode it is hidden and
# the per-game inputs carry the prices instead. Empty falls back to 0.
self.fields["price"].required = False
games = MultipleGameChoiceField( games = MultipleGameChoiceField(
queryset=Game.objects.order_by("sort_name"), queryset=Game.objects.order_by("sort_name"),
@@ -272,9 +249,12 @@ class PurchaseForm(PrimitiveWidgetsMixin, forms.ModelForm):
search_url="/api/platforms/search", options_resolver=_platform_options search_url="/api/platforms/search", options_resolver=_platform_options
), ),
) )
related_purchase = RelatedPurchaseChoiceField( related_game = forms.ModelChoiceField(
queryset=related_purchase_queryset(), queryset=Game.objects.order_by("sort_name"),
required=False, required=False,
widget=SearchSelectWidget(
search_url="/api/games/search", options_resolver=_game_options
),
) )
price_currency = forms.CharField( price_currency = forms.CharField(
@@ -305,14 +285,14 @@ class PurchaseForm(PrimitiveWidgetsMixin, forms.ModelForm):
"price_currency", "price_currency",
"ownership_type", "ownership_type",
"type", "type",
"related_purchase", "related_game",
"name", "name",
] ]
def clean(self): def clean(self):
cleaned_data = super().clean() cleaned_data = super().clean()
purchase_type = cleaned_data.get("type") purchase_type = cleaned_data.get("type")
related_purchase = cleaned_data.get("related_purchase") related_game = cleaned_data.get("related_game")
name = cleaned_data.get("name") name = cleaned_data.get("name")
# Set the type on the instance to use get_type_display() # Set the type on the instance to use get_type_display()
@@ -321,13 +301,18 @@ class PurchaseForm(PrimitiveWidgetsMixin, forms.ModelForm):
if purchase_type != Purchase.GAME: if purchase_type != Purchase.GAME:
type_display = self.instance.get_type_display() type_display = self.instance.get_type_display()
if not related_purchase: if not related_game:
self.add_error( self.add_error(
"related_purchase", "related_game",
f"{type_display} must have a related purchase.", f"{type_display} must have a related game.",
) )
if not name: if not name:
self.add_error("name", f"{type_display} must have a name.") self.add_error("name", f"{type_display} must have a name.")
# An empty bundle Price (price-per-game mode) saves as 0, not NULL.
if cleaned_data.get("price") is None:
cleaned_data["price"] = 0
return cleaned_data return cleaned_data
@@ -0,0 +1,46 @@
# Generated by Django 6.0.6 on 2026-06-18 21:03
import django.db.models.deletion
from django.db import migrations, models
def backfill_related_game(apps, schema_editor):
"""Move each add-on purchase's parent link from the parent *purchase* to a
parent *game*. For a parent bought as a multi-game bundle there is no single
game, so use the bundle's first game (by sort_name) as the best guess."""
Purchase = apps.get_model("games", "Purchase")
for purchase in Purchase.objects.filter(related_purchase__isnull=False):
parent_game = purchase.related_purchase.games.order_by("sort_name").first()
if parent_game is not None:
purchase.related_game = parent_game
purchase.save(update_fields=["related_game"])
def noop_reverse(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
("games", "0019_alter_filterpreset_mode"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="related_game",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="addon_purchases",
to="games.game",
),
),
migrations.RunPython(backfill_related_game, noop_reverse),
migrations.RemoveField(
model_name="purchase",
name="related_purchase",
),
]
+6 -5
View File
@@ -198,12 +198,13 @@ class Purchase(models.Model):
) )
type = models.CharField(max_length=255, choices=TYPES, default=GAME) type = models.CharField(max_length=255, choices=TYPES, default=GAME)
name = models.CharField(max_length=255, blank=True, default="") name = models.CharField(max_length=255, blank=True, default="")
related_purchase = models.ForeignKey( related_game = models.ForeignKey(
"self", Game,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
default=None, default=None,
null=True, null=True,
related_name="related_purchases", blank=True,
related_name="addon_purchases",
) )
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
@@ -252,9 +253,9 @@ class Purchase(models.Model):
self.save() self.save()
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self.type != Purchase.GAME and not self.related_purchase: if self.type != Purchase.GAME and not self.related_game:
raise ValidationError( raise ValidationError(
f"{self.get_type_display()} must have a related purchase." f"{self.get_type_display()} must have a related game."
) )
super().save(*args, **kwargs) super().save(*args, **kwargs)
+34 -17
View File
@@ -1,13 +1,30 @@
import { getEl, disableElementsWhenTrue, onSwap } from "./utils.js"; import { getEl, disableElementsWhenTrue, onSwap } from "./utils.js";
const RELATED_PURCHASE_URL = "/tracker/purchase/related-purchase-by-game"; // Switch between a single bundle price and one price per game. The per-game
// inputs are the selection-fields element; this only sets the policy: the
// hidden pricing_mode the view reads, the element's "active" flag, and whether
// the bundle Price field is shown.
function applyPricingMode(separate) {
const pricingMode = getEl("#id_pricing_mode");
if (pricingMode) pricingMode.value = separate ? "per_game" : "combined";
const selectionFields = document.querySelector("selection-fields");
if (selectionFields)
selectionFields.setAttribute("active", separate ? "true" : "false");
const priceInput = getEl("#id_price");
if (priceInput) {
const wrapper = priceInput.closest("div");
if (wrapper) wrapper.classList.toggle("hidden", separate);
}
}
// The games field is now a SearchSelect widget (a <div>, not a <select>), so we // The games field is now a SearchSelect widget (a <div>, not a <select>), so we
// react to its custom "search-select:change" event instead of syncing a select. // react to its custom "search-select:change" event instead of syncing a select.
document.addEventListener("search-select:change", (event) => { document.addEventListener("search-select:change", (event) => {
if (event.detail.name !== "games") return; if (event.detail.name !== "games") return;
// (a) Auto-fill platform from the clicked option's data-platform. // Auto-fill platform from the clicked option's data-platform.
const last = event.detail.last; const last = event.detail.last;
const platformId = last && last.data ? last.data.platform : ""; const platformId = last && last.data ? last.data.platform : "";
if (platformId) { if (platformId) {
@@ -15,26 +32,26 @@ document.addEventListener("search-select:change", (event) => {
if (platformEl) platformEl.value = platformId; if (platformEl) platformEl.value = platformId;
} }
// (b) Refresh #id_related_purchase for the currently selected games. // The combined/per-game choice is only meaningful with 2+ games. Reveal the
const query = event.detail.values // checkbox there; below the threshold, fall back to a single bundle price.
.map((value) => "games=" + encodeURIComponent(value)) const separateRow = getEl("#separate-prices-row");
.join("&"); const multipleGames = event.detail.values.length >= 2;
fetch(RELATED_PURCHASE_URL + "?" + query, { credentials: "same-origin" }) if (separateRow) separateRow.classList.toggle("hidden", !multipleGames);
.then((response) => { if (!multipleGames) {
if (response.status === 204) return null; const checkbox = getEl("#id_separate_prices");
return response.text(); if (checkbox) checkbox.checked = false;
}) applyPricingMode(false);
.then((html) => { }
if (html === null) return; });
const target = getEl("#id_related_purchase");
if (target) target.outerHTML = html; onSwap("#id_separate_prices", (checkbox) => {
}); checkbox.addEventListener("change", () => applyPricingMode(checkbox.checked));
}); });
function setupElementHandlers() { function setupElementHandlers() {
disableElementsWhenTrue("#id_type", "game", [ disableElementsWhenTrue("#id_type", "game", [
"#id_name", "#id_name",
"#id_related_purchase", "#id_related_game",
]); ]);
} }
+2 -4
View File
@@ -2,8 +2,6 @@ import logging
import requests import requests
from django.db import models from django.db import models
from django.template.defaultfilters import floatformat
from games.models import ExchangeRate, Purchase from games.models import ExchangeRate, Purchase
logger = logging.getLogger("games") logger = logging.getLogger("games")
@@ -38,7 +36,7 @@ def _get_exchange_rate(currency_from, currency_to, year):
currency_from=currency_from, currency_from=currency_from,
currency_to=currency_to, currency_to=currency_to,
year=year, year=year,
rate=floatformat(rate, 2), rate=rate,
) )
rate = exchange_rate.rate rate = exchange_rate.rate
else: else:
@@ -84,7 +82,7 @@ def convert_prices():
if rate: if rate:
_save_converted_price( _save_converted_price(
purchase, purchase,
floatformat(purchase.price * rate, 0), round(purchase.price * rate, 0),
needs_update, needs_update,
) )
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
class="text-black dark:text-white w-4 h-4">
<path fill="currentColor" d="M14 4l2.29 2.29-2.88 2.88 1.42 1.42 2.88-2.88L20 12V4z M10 4H4v8l2.29-2.29 4.71 4.71V20h2v-8.41l-5.29-5.3z"></path>
</svg>

After

Width:  |  Height:  |  Size: 270 B

+8 -3
View File
@@ -106,9 +106,14 @@ urlpatterns = [
name="refund_purchase", name="refund_purchase",
), ),
path( path(
"purchase/related-purchase-by-game", "purchase/<int:purchase_id>/split/confirm",
purchase.related_purchase_by_game, purchase.split_purchase_confirmation,
name="related_purchase_by_game", name="split_purchase_confirmation",
),
path(
"purchase/<int:purchase_id>/split",
purchase.split_purchase,
name="split_purchase",
), ),
path("session/add", session.add_session, name="add_session"), path("session/add", session.add_session, name="add_session"),
path( path(
+205 -25
View File
@@ -5,6 +5,7 @@ from django.http import (
HttpResponse, HttpResponse,
HttpResponseRedirect, HttpResponseRedirect,
) )
from django.db import transaction
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.template.defaultfilters import date as date_filter from django.template.defaultfilters import date as date_filter
from django.template.defaultfilters import floatformat from django.template.defaultfilters import floatformat
@@ -17,18 +18,22 @@ from common.components import (
A, A,
AddForm, AddForm,
ButtonGroup, ButtonGroup,
Checkbox,
CsrfInput, CsrfInput,
Div, Div,
Element, Element,
Fragment, Fragment,
GameLink, GameLink,
Icon, Icon,
Input,
LinkedPurchase, LinkedPurchase,
Modal, Modal,
ModuleScript, ModuleScript,
Node, Node,
PriceConverted, PriceConverted,
PurchasePrice, PurchasePrice,
Safe,
SelectionFields,
StyledButton, StyledButton,
TableRow, TableRow,
paginated_table_content, paginated_table_content,
@@ -42,7 +47,7 @@ from games.models import Game, Purchase
from games.views.general import use_custom_redirect from games.views.general import use_custom_redirect
def _render_purchase_buttons(purchase_id, is_refunded): def _render_purchase_buttons(purchase_id, is_refunded, can_split=False):
"""Return button group HTML for a purchase row.""" """Return button group HTML for a purchase row."""
return ButtonGroup( return ButtonGroup(
[ [
@@ -58,6 +63,19 @@ def _render_purchase_buttons(purchase_id, is_refunded):
} }
if not is_refunded if not is_refunded
else {}, else {},
{
"href": "#",
"hx_get": reverse(
"games:split_purchase_confirmation",
args=[purchase_id],
),
"hx_target": "#global-modal-container",
"slot": Icon("split"),
"title": "Split into per-game purchases",
"color": "gray",
}
if can_split
else {},
{ {
"href": reverse("games:edit_purchase", args=[purchase_id]), "href": reverse("games:edit_purchase", args=[purchase_id]),
"slot": Icon("edit"), "slot": Icon("edit"),
@@ -90,7 +108,11 @@ def _render_purchase_row(purchase):
else "-" else "-"
), ),
purchase.created_at.strftime(dateformat), purchase.created_at.strftime(dateformat),
_render_purchase_buttons(purchase.id, bool(purchase.date_refunded)), _render_purchase_buttons(
purchase.id,
bool(purchase.date_refunded),
can_split=purchase.num_purchases > 1,
),
], ],
} }
@@ -166,6 +188,76 @@ def _purchase_additional_row() -> SafeText:
) )
def _pricing_controls() -> Node:
"""Pricing UI for the add-purchase form.
By default the form's own single Price field is the bundle price. When 2+
games are selected and "Separate price per game" is checked, the per-game
inputs (the general ``selection-fields`` element) take over and the bundle
Price is hidden. Toggle/visibility wiring lives in add_purchase.js; the
hidden ``pricing_mode`` tells the view which path to take.
"""
return Div(attributes=[("id", "pricing-controls")])[
Div(attributes=[("id", "separate-prices-row"), ("class", "hidden")])[
Checkbox(
name="separate_prices",
label="Separate price per game",
attributes=[("id", "id_separate_prices")],
),
],
Input(
type="hidden",
attributes=[
("name", "pricing_mode"),
("id", "id_pricing_mode"),
("value", "combined"),
],
),
SelectionFields(
source="games",
name_prefix="price_for_game_",
field_type="number",
min_items=2,
active=False,
input_attributes=[
("step", "0.01"),
("min", "0"),
("inputmode", "decimal"),
("placeholder", "Price"),
],
),
]
@transaction.atomic
def _create_separate_purchases(form: PurchaseForm, post) -> None:
"""Create one single-game Purchase per selected game from the shared form
fields, each priced from its own ``price_for_game_<id>`` input. The
``m2m_changed`` signal sets ``num_purchases``/``price_per_game`` once each
game is attached."""
data = form.cleaned_data
shared = {
"platform": data.get("platform"),
"date_purchased": data["date_purchased"],
"date_refunded": data.get("date_refunded"),
"infinite": data.get("infinite", False),
"price_currency": data["price_currency"],
"ownership_type": data["ownership_type"],
"type": data["type"],
"related_game": data.get("related_game"),
"name": data.get("name") or "",
}
for game in data["games"]:
raw_price = post.get(f"price_for_game_{game.id}", "")
try:
price = float(raw_price) if raw_price not in (None, "") else 0.0
except ValueError:
price = 0.0
purchase = Purchase(price=price, **shared)
purchase.save()
purchase.games.set([game])
@login_required @login_required
def add_purchase(request: HttpRequest, game_id: int = 0) -> HttpResponse: def add_purchase(request: HttpRequest, game_id: int = 0) -> HttpResponse:
initial = {"date_purchased": timezone.now()} initial = {"date_purchased": timezone.now()}
@@ -173,6 +265,9 @@ def add_purchase(request: HttpRequest, game_id: int = 0) -> HttpResponse:
if request.method == "POST": if request.method == "POST":
form = PurchaseForm(request.POST or None, initial=initial) form = PurchaseForm(request.POST or None, initial=initial)
if form.is_valid(): if form.is_valid():
if request.POST.get("pricing_mode") == "per_game":
_create_separate_purchases(form, request.POST)
return redirect("games:list_purchases")
purchase = form.save() purchase = form.save()
if "submit_and_redirect" in request.POST: if "submit_and_redirect" in request.POST:
return HttpResponseRedirect( return HttpResponseRedirect(
@@ -198,7 +293,12 @@ def add_purchase(request: HttpRequest, game_id: int = 0) -> HttpResponse:
return render_page( return render_page(
request, request,
AddForm(form, request=request, additional_row=_purchase_additional_row()), AddForm(
form,
request=request,
fields=Fragment(Safe(form.as_div()), _pricing_controls()),
additional_row=_purchase_additional_row(),
),
title="Add New Purchase", title="Add New Purchase",
scripts=mark_safe( scripts=mark_safe(
ModuleScript("search_select.js") + ModuleScript("add_purchase.js") ModuleScript("search_select.js") + ModuleScript("add_purchase.js")
@@ -386,6 +486,108 @@ def refund_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
return HttpResponse(row_html + modal_close, status=200) return HttpResponse(row_html + modal_close, status=200)
def _split_confirmation_modal(purchase: Purchase, request: HttpRequest) -> Node:
count = purchase.num_purchases
form = Element(
"form",
attributes=[("hx-post", reverse("games:split_purchase", args=[purchase.id]))],
children=[
CsrfInput(request),
P(
attributes=[("class", "dark:text-white text-center mt-3 text-sm")],
children=[
f"Creates {count} separate purchases, one per game, with the "
"price split evenly. Each can then be priced and refunded "
"independently."
],
),
Div(
[("class", "items-center mt-5")],
[
StyledButton(
[("class", "w-full")],
"Split",
color="blue",
size="lg",
type="submit",
),
StyledButton(
[("class", "mt-0 w-full")],
"Cancel",
color="gray",
size="base",
onclick="this.closest('#split-confirmation-modal').remove()",
),
],
),
],
)
return Modal(
"split-confirmation-modal",
children=[
Element(
"h1",
attributes=[
(
"class",
"text-2xl leading-6 font-medium dark:text-white text-center",
)
],
children=["Split purchase"],
),
P(
attributes=[("class", "dark:text-white text-center mt-5")],
children=[
f"Split “{purchase.standardized_name}” into per-game purchases?"
],
),
form,
],
)
@login_required
def split_purchase_confirmation(request: HttpRequest, purchase_id: int) -> HttpResponse:
purchase = get_object_or_404(Purchase, id=purchase_id)
return HttpResponse(_split_confirmation_modal(purchase, request))
@login_required
@require_POST
@transaction.atomic
def split_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
"""Replace one multi-game (unsplittable-style) purchase with one single-game
purchase per game, splitting the price evenly as a starting point. Each new
purchase is then independently priceable and refundable."""
purchase = get_object_or_404(Purchase, id=purchase_id)
games = list(purchase.games.all())
count = len(games)
if count > 1:
share = purchase.price / count
for game in games:
new_purchase = Purchase(
price=share,
price_currency=purchase.price_currency,
date_purchased=purchase.date_purchased,
date_refunded=purchase.date_refunded,
infinite=purchase.infinite,
ownership_type=purchase.ownership_type,
type=purchase.type,
related_game=purchase.related_game,
name=purchase.name,
platform=purchase.platform,
needs_price_update=True,
)
new_purchase.save()
new_purchase.games.set([game])
purchase.delete()
messages.success(request, f"Split into {count} purchases")
response = HttpResponse(status=204)
response["HX-Redirect"] = reverse("games:list_purchases")
return response
@login_required @login_required
def finish_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse: def finish_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
purchase = get_object_or_404(Purchase, id=purchase_id) purchase = get_object_or_404(Purchase, id=purchase_id)
@@ -393,25 +595,3 @@ def finish_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
game.status = Game.Status.FINISHED game.status = Game.Status.FINISHED
game.save() game.save()
return redirect("games:list_purchases") return redirect("games:list_purchases")
def related_purchase_by_game(request: HttpRequest) -> HttpResponse:
games: list[str] = request.GET.getlist("games")
if games:
from games.forms import related_purchase_queryset
form = PurchaseForm()
qs = (
related_purchase_queryset()
.filter(games__in=games)
.order_by("games__sort_name")
)
form.fields["related_purchase"].queryset = qs
first_option = qs.first()
if first_option:
form.fields["related_purchase"].initial = first_option.id
return HttpResponse(str(form["related_purchase"]))
else:
# abort swap
return HttpResponse(status=204)
+15
View File
@@ -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
+277
View File
@@ -0,0 +1,277 @@
"""Tests for the configuration reader in ``timetracker/config.py``."""
import pytest
from django.core.exceptions import DisallowedHost, ImproperlyConfigured
from django.middleware.csrf import CsrfViewMiddleware
from django.test import RequestFactory, override_settings
from timetracker import config as config_module
from timetracker.config import config, derive_hosts_and_origins
@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"
# --- derive_hosts_and_origins -----------------------------------------------
def test_single_url_derives_one_host_and_origin():
hosts, origins = derive_hosts_and_origins("https://tracker.example.com")
assert hosts == ["tracker.example.com"]
assert origins == ["https://tracker.example.com"]
def test_multiple_urls_derive_multiple_hosts_and_origins():
hosts, origins = derive_hosts_and_origins(
"https://tracker.example.com,https://www.tracker.example.com"
)
assert hosts == ["tracker.example.com", "www.tracker.example.com"]
assert origins == ["https://tracker.example.com", "https://www.tracker.example.com"]
def test_whitespace_around_commas_is_stripped():
hosts, origins = derive_hosts_and_origins(
"https://a.example.com , https://b.example.com"
)
assert hosts == ["a.example.com", "b.example.com"]
assert origins == ["https://a.example.com", "https://b.example.com"]
def test_url_with_port_is_preserved_in_origin():
hosts, origins = derive_hosts_and_origins("http://localhost:8000")
assert hosts == ["localhost"]
assert origins == ["http://localhost:8000"]
# --- Django integration: derived values are accepted by Django internals -----
@pytest.mark.parametrize(
"app_url,request_host",
[
("https://tracker.example.com", "tracker.example.com"),
(
"https://tracker.example.com,https://www.tracker.example.com",
"www.tracker.example.com",
),
("http://localhost:8000", "localhost"),
],
)
def test_derived_hosts_accepted_by_django(app_url, request_host):
hosts, _ = derive_hosts_and_origins(app_url)
factory = RequestFactory()
with override_settings(ALLOWED_HOSTS=hosts):
request = factory.get("/", HTTP_HOST=request_host)
assert request.get_host() == request_host
def test_host_not_in_derived_list_is_rejected():
hosts, _ = derive_hosts_and_origins("https://tracker.example.com")
factory = RequestFactory()
with override_settings(ALLOWED_HOSTS=hosts):
request = factory.get("/", HTTP_HOST="evil.example.com")
with pytest.raises(DisallowedHost):
request.get_host()
def test_derived_origins_accepted_by_csrf_middleware():
_, origins = derive_hosts_and_origins(
"https://tracker.example.com,https://other.example.com"
)
factory = RequestFactory()
middleware = CsrfViewMiddleware(lambda request: None)
with override_settings(CSRF_TRUSTED_ORIGINS=origins):
for origin in origins:
request = factory.post("/", HTTP_ORIGIN=origin)
request.META["HTTP_REFERER"] = origin + "/"
# _check_token is not called here; _is_secure_referer_ok / origin
# matching is what we want — process_view returns None when trusted.
assert middleware.process_request(request) is None
+50
View File
@@ -0,0 +1,50 @@
from datetime import date
from django.core.exceptions import ValidationError
from django.test import TestCase
from games.models import Game, Platform, Purchase
class PurchaseRelatedGameTest(TestCase):
def setUp(self):
self.platform = Platform.objects.create(name="PC", icon="pc", group="PC")
self.base_game = Game.objects.create(name="Base Game", platform=self.platform)
self.dlc_game = Game.objects.create(name="The DLC", platform=self.platform)
def test_non_game_purchase_requires_related_game(self):
purchase = Purchase(
price=10.0,
price_currency="USD",
date_purchased=date(2025, 1, 1),
type=Purchase.SEASONPASS,
name="Season Pass",
)
with self.assertRaises(ValidationError):
purchase.save()
def test_non_game_purchase_saves_with_related_game(self):
purchase = Purchase(
price=10.0,
price_currency="USD",
date_purchased=date(2025, 1, 1),
type=Purchase.SEASONPASS,
name="Season Pass",
related_game=self.base_game,
)
purchase.save()
purchase.games.add(self.dlc_game)
self.assertEqual(purchase.related_game, self.base_game)
# Reverse accessor: the base game lists its add-on purchases.
self.assertIn(purchase, self.base_game.addon_purchases.all())
def test_plain_game_purchase_needs_no_related_game(self):
purchase = Purchase(
price=50.0,
price_currency="USD",
date_purchased=date(2025, 1, 1),
type=Purchase.GAME,
)
purchase.save() # must not raise
self.assertIsNone(purchase.related_game)
+108
View File
@@ -0,0 +1,108 @@
from datetime import date
from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import reverse
from games.models import Game, Platform, Purchase
class AddPurchasePricingTest(TestCase):
def setUp(self):
self.user = User.objects.create_superuser("u", "u@e.com", "pw")
self.client.force_login(self.user)
self.platform = Platform.objects.create(name="PC", icon="pc", group="PC")
self.game_a = Game.objects.create(name="Game A", platform=self.platform)
self.game_b = Game.objects.create(name="Game B", platform=self.platform)
def _base_data(self, **overrides):
data = {
"games": [self.game_a.id, self.game_b.id],
"platform": self.platform.id,
"date_purchased": "2025-01-01",
"price_currency": "USD",
"ownership_type": Purchase.DIGITAL,
"type": Purchase.GAME,
"name": "",
}
data.update(overrides)
return data
def test_combined_creates_single_bundle(self):
data = self._base_data(pricing_mode="combined", price="30")
response = self.client.post(reverse("games:add_purchase"), data)
self.assertEqual(response.status_code, 302)
self.assertEqual(Purchase.objects.count(), 1)
bundle = Purchase.objects.get()
self.assertEqual(bundle.num_purchases, 2)
self.assertEqual(bundle.price, 30)
def test_per_game_creates_separate_single_game_purchases(self):
data = self._base_data(
pricing_mode="per_game",
**{
f"price_for_game_{self.game_a.id}": "10",
f"price_for_game_{self.game_b.id}": "20",
},
)
response = self.client.post(reverse("games:add_purchase"), data)
self.assertEqual(response.status_code, 302)
self.assertEqual(Purchase.objects.count(), 2)
for purchase in Purchase.objects.all():
self.assertEqual(purchase.num_purchases, 1)
self.assertEqual(sorted(p.price for p in Purchase.objects.all()), [10.0, 20.0])
linked_games = [
list(p.games.values_list("id", flat=True)) for p in Purchase.objects.all()
]
self.assertTrue(all(len(games) == 1 for games in linked_games))
self.assertEqual(
{games[0] for games in linked_games},
{self.game_a.id, self.game_b.id},
)
class SplitPurchaseTest(TestCase):
def setUp(self):
self.user = User.objects.create_superuser("u", "u@e.com", "pw")
self.client.force_login(self.user)
self.platform = Platform.objects.create(name="PC", icon="pc", group="PC")
self.game_a = Game.objects.create(name="Game A", platform=self.platform)
self.game_b = Game.objects.create(name="Game B", platform=self.platform)
def _bundle(self, games, price=30.0):
bundle = Purchase.objects.create(
price=price,
price_currency="USD",
date_purchased=date(2025, 1, 1),
platform=self.platform,
ownership_type=Purchase.DIGITAL,
type=Purchase.GAME,
)
bundle.games.set(games)
return bundle
def test_split_creates_per_game_purchases_and_deletes_original(self):
bundle = self._bundle([self.game_a, self.game_b], price=30.0)
response = self.client.post(reverse("games:split_purchase", args=[bundle.id]))
self.assertEqual(response.status_code, 204)
self.assertEqual(response["HX-Redirect"], reverse("games:list_purchases"))
self.assertFalse(Purchase.objects.filter(id=bundle.id).exists())
self.assertEqual(Purchase.objects.count(), 2)
for purchase in Purchase.objects.all():
self.assertEqual(purchase.num_purchases, 1)
self.assertEqual(purchase.price, 15.0) # 30 / 2, split evenly
self.assertTrue(purchase.needs_price_update)
def test_split_is_noop_for_single_game_purchase(self):
single = self._bundle([self.game_a], price=10.0)
response = self.client.post(reverse("games:split_purchase", args=[single.id]))
self.assertEqual(response.status_code, 204)
self.assertTrue(Purchase.objects.filter(id=single.id).exists())
self.assertEqual(Purchase.objects.count(), 1)
+212
View File
@@ -0,0 +1,212 @@
"""
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 urllib.parse import urlparse
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 derive_hosts_and_origins(
app_url: str,
) -> tuple[list[str], list[str]]:
"""Derive ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS from an APP_URL value.
``app_url`` may be a single full URL or a comma-separated list of full URLs.
Returns ``(allowed_hosts, csrf_trusted_origins)``.
"""
parsed_urls = [urlparse(raw_url.strip()) for raw_url in app_url.split(",")]
allowed_hosts = [parsed_url.hostname for parsed_url in parsed_urls]
csrf_trusted_origins = [
f"{parsed_url.scheme}://{parsed_url.netloc}" for parsed_url in parsed_urls
]
return allowed_hosts, csrf_trusted_origins
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)
+34 -15
View File
@@ -11,7 +11,9 @@ https://docs.djangoproject.com/en/4.1/ref/settings/
""" """
import os import os
import warnings
from pathlib import Path from pathlib import Path
from timetracker.config import config, derive_hosts_and_origins
# 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 +22,41 @@ 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! # APP_URL accepts one or more comma-separated full URLs (single URL is the
DEBUG = False if os.environ.get("PROD") else True # common case). Both ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS are derived from
# all listed URLs. ALLOWED_HOSTS can still be overridden directly for edge
# cases like ALLOWED_HOSTS=* behind a reverse proxy.
APP_URL = config("APP_URL", default="http://localhost:8000")
_derived_hosts, CSRF_TRUSTED_ORIGINS = derive_hosts_and_origins(APP_URL)
ALLOWED_HOSTS = ["*"] ALLOWED_HOSTS = config("ALLOWED_HOSTS", default=None, cast=list) or _derived_hosts
# Application definition # Application definition
@@ -114,7 +139,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 +172,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 +205,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 = []
+100
View File
@@ -0,0 +1,100 @@
import { readSelectionFieldsProps, SelectionFieldsProps } from "../generated/props.js";
/**
* Renders one form field per selected item of a source SearchSelect (matched by
* its data-name). Reacts to the SearchSelect's "search-select:change" event and
* to its own "active" attribute. Typed values are preserved (keyed by item id)
* across selection changes and active toggling.
*/
class SelectionFieldsElement extends HTMLElement {
static get observedAttributes(): string[] {
return ["active"];
}
private props!: SelectionFieldsProps;
private source: HTMLElement | null = null;
private typedValues = new Map<string, string>();
private readonly onSourceChange = (event: Event): void => {
const detail = (event as CustomEvent).detail;
if (!detail || detail.name !== this.props.source) return;
this.render();
};
connectedCallback(): void {
this.props = readSelectionFieldsProps(this);
this.source = document.querySelector<HTMLElement>(
`[data-search-select][data-name="${this.props.source}"]`,
);
document.addEventListener("search-select:change", this.onSourceChange);
this.render();
}
disconnectedCallback(): void {
document.removeEventListener("search-select:change", this.onSourceChange);
}
attributeChangedCallback(): void {
// connectedCallback assigns props; ignore the initial pre-connect call.
if (this.props) this.render();
}
private selectedItems(): { value: string; label: string }[] {
if (!this.source) return [];
const pills = this.source.querySelectorAll(
"[data-search-select-pills] [data-pill]",
);
const items: { value: string; label: string }[] = [];
pills.forEach((pill) => {
const value = pill.getAttribute("data-value");
if (!value) return;
const labelElement = pill.querySelector("[data-search-select-label]");
const label = labelElement?.textContent?.trim() || value;
items.push({ value, label });
});
return items;
}
private captureTypedValues(): void {
this.querySelectorAll<HTMLInputElement>(
"[data-selection-fields-rows] input",
).forEach((input) => {
const itemId = input.getAttribute("data-item-id");
if (itemId) this.typedValues.set(itemId, input.value);
});
}
private render(): void {
const rows = this.querySelector<HTMLElement>("[data-selection-fields-rows]");
const template = this.querySelector<HTMLTemplateElement>(
"template[data-selection-fields-row]",
);
if (!rows || !template) return;
this.captureTypedValues();
rows.replaceChildren();
const active = this.getAttribute("active") === "true";
const items = this.selectedItems();
if (!active || items.length < this.props.minItems) return;
const prototype = template.content.firstElementChild;
if (!prototype) return;
items.forEach(({ value, label }) => {
const row = prototype.cloneNode(true) as HTMLElement;
const labelElement = row.querySelector("[data-selection-fields-label]");
const input = row.querySelector<HTMLInputElement>("input");
if (labelElement) labelElement.textContent = label;
if (input) {
input.name = `${this.props.namePrefix}${value}`;
input.setAttribute("data-item-id", value);
const preserved = this.typedValues.get(value);
if (preserved !== undefined) input.value = preserved;
}
rows.appendChild(row);
});
}
}
customElements.define("selection-fields", SelectionFieldsElement);
Generated
+70 -47
View File
@@ -153,25 +153,25 @@ wheels = [
[[package]] [[package]]
name = "distlib" name = "distlib"
version = "0.4.0" version = "0.4.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" },
] ]
[[package]] [[package]]
name = "django" name = "django"
version = "6.0.5" version = "6.0.6"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "asgiref" }, { name = "asgiref" },
{ name = "sqlparse" }, { name = "sqlparse" },
{ name = "tzdata", marker = "sys_platform == 'win32'" }, { name = "tzdata", marker = "sys_platform == 'win32'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/5e/f1/bf85f0d29ef76abf901f193fe8fef4769d3da7794197832bc30151c071d8/django-6.0.5.tar.gz", hash = "sha256:bc6d6872e98a2864c836e42edd644b362db311147dd5aa8d5b82ba7a032f5269", size = 10924131, upload-time = "2026-05-05T13:54:39.329Z" } sdist = { url = "https://files.pythonhosted.org/packages/78/29/ac41e16097af67066d97a7d5775c5d8e7efc5d0284f6b0a159e07b9adb92/django-6.0.6.tar.gz", hash = "sha256:ad03916ba59523d781ae5c3f631960c23d69a9d9c43cecda52fc23b47e953713", size = 10905525, upload-time = "2026-06-03T13:02:46.503Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/94/5b/1328f8b84fce040c404f76822bf8c57d254e368e8cbd8bd67ec2b26d75f5/django-6.0.5-py3-none-any.whl", hash = "sha256:9d58a7cb49244e74c8e161d5e403a46d6209f1009ba40f5a66d6aa0d0786a8f0", size = 8368680, upload-time = "2026-05-05T13:54:33.532Z" }, { url = "https://files.pythonhosted.org/packages/eb/50/23f9dc45483419a3cc2085b498b25adfbf10642b2941c73e6d2dfaffc9ab/django-6.0.6-py3-none-any.whl", hash = "sha256:25148b1194c47c2e685e5f5e9c5d59c78b075dfd282cb9618861ba6c1708f4d2", size = 8373354, upload-time = "2026-06-03T13:02:41.72Z" },
] ]
[[package]] [[package]]
@@ -273,7 +273,7 @@ wheels = [
[[package]] [[package]]
name = "djlint" name = "djlint"
version = "1.36.4" version = "1.39.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "click" }, { name = "click" },
@@ -286,13 +286,36 @@ dependencies = [
{ name = "regex" }, { name = "regex" },
{ name = "tqdm" }, { name = "tqdm" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/74/89/ecf5be9f5c59a0c53bcaa29671742c5e269cc7d0e2622e3f65f41df251bf/djlint-1.36.4.tar.gz", hash = "sha256:17254f218b46fe5a714b224c85074c099bcb74e3b2e1f15c2ddc2cf415a408a1", size = 47849, upload-time = "2024-12-24T13:06:36.36Z" } sdist = { url = "https://files.pythonhosted.org/packages/50/d8/119f35832801129dc6a4bdf82c163bb80d0095891eea9fc3543eaf8c9792/djlint-1.39.2.tar.gz", hash = "sha256:dd27ef03bd26d1e0a167da26ec2a6fcb511dddd032b2553abf71a5c31eaa8b34", size = 56083, upload-time = "2026-06-11T14:03:01.223Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/da/83/88b4c885812921739f5529a29085c3762705154d41caf7eb9a8886a3380c/djlint-1.36.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ead475013bcac46095b1bbc8cf97ed2f06e83422335734363f8a76b4ba7e47c2", size = 354384, upload-time = "2024-12-24T13:06:20.809Z" }, { url = "https://files.pythonhosted.org/packages/05/9b/d1a630b2495f8b6a60555012dd57c661a9fe683c1f8483287ecddae749dc/djlint-1.39.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c243a1f18211cb9a4fec5b95f0b43c61941414f71fdb3d77dcc9798f9644b623", size = 538489, upload-time = "2026-06-11T14:02:54.322Z" },
{ url = "https://files.pythonhosted.org/packages/32/38/67695f7a150b3d9d62fadb65242213d96024151570c3cf5d966effa68b0e/djlint-1.36.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6c601dfa68ea253311deb4a29a7362b7a64933bdfcfb5a06618f3e70ad1fa835", size = 322971, upload-time = "2024-12-24T13:06:22.185Z" }, { url = "https://files.pythonhosted.org/packages/87/39/7e15d99f40cf6ec1d624df48ab465725914cf6c789fdf6f6dc28db264666/djlint-1.39.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:74d65499c5b29e5269eb6a4bff9dbf426c22cba54f4810d30fbfbcb482c9035f", size = 510153, upload-time = "2026-06-11T14:02:10.287Z" },
{ url = "https://files.pythonhosted.org/packages/ac/7a/cd851393291b12e7fe17cf5d4d8874b8ea133aebbe9235f5314aabc96a52/djlint-1.36.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bda5014f295002363381969864addeb2db13955f1b26e772657c3b273ed7809f", size = 410972, upload-time = "2024-12-24T13:06:24.077Z" }, { url = "https://files.pythonhosted.org/packages/db/a6/9ab6b79728a3a7f8235eac0f0d21e01e7457adfe3f368a3a1132eaa52897/djlint-1.39.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25b955e23c09bdfdb04de6b40b848cf93b39952178aa37d940af921a9715da4b", size = 535815, upload-time = "2026-06-11T14:02:01.034Z" },
{ url = "https://files.pythonhosted.org/packages/6c/31/56469120394b970d4f079a552fde21ed27702ca729595ab0ed459eb6d240/djlint-1.36.4-cp313-cp313-win_amd64.whl", hash = "sha256:16ce37e085afe5a30953b2bd87cbe34c37843d94c701fc68a2dda06c1e428ff4", size = 362053, upload-time = "2024-12-24T13:06:25.432Z" }, { url = "https://files.pythonhosted.org/packages/9e/9b/3eceecb4bead34e9087102078b58c084fa53f995827f03ea7cbf42fd5c48/djlint-1.39.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0a2c0191f93181557e8dcbca2a6766ab972f769bb9c32d488cefa1b5195a74d", size = 557939, upload-time = "2026-06-11T14:02:43.072Z" },
{ url = "https://files.pythonhosted.org/packages/4b/67/f7aeea9be6fb3bd984487af8d0d80225a0b1e5f6f7126e3332d349fb13fe/djlint-1.36.4-py3-none-any.whl", hash = "sha256:e9699b8ac3057a6ed04fb90835b89bee954ed1959c01541ce4f8f729c938afdd", size = 52290, upload-time = "2024-12-24T13:06:33.76Z" }, { url = "https://files.pythonhosted.org/packages/c9/cf/95bdb7d40699ae60d4dfda0ed7278e0a09f224c505bf0cd396f1e6ab4c23/djlint-1.39.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e0aa5e3087dd346ae3197781fa425aebccce6beed593f4dd17cbb00179ef8444", size = 541687, upload-time = "2026-06-11T14:02:11.683Z" },
{ url = "https://files.pythonhosted.org/packages/73/a7/6b8ba672e1c7c8216306ecfacd4053b645ef583f573c69a3f2087c654922/djlint-1.39.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:30094c7533b0e919f4674baeaf0acd061b1eb6c8ffbf4d6d3dc856c686747411", size = 568429, upload-time = "2026-06-11T14:01:55.72Z" },
{ url = "https://files.pythonhosted.org/packages/d2/50/03b189c613fb3f0286e8ddfbc3a31856c6a2d3c7a01eb89cf8441ca25f21/djlint-1.39.2-cp313-cp313-win32.whl", hash = "sha256:3eb8942afbf2e5d0ffc208d88db33dc38abb585ce03fdd17d9a0f03e4b7e761a", size = 423325, upload-time = "2026-06-11T14:01:48.24Z" },
{ url = "https://files.pythonhosted.org/packages/5e/00/a0a34a80b732ae227b395bdd2718836c696336524bf6f97210cc6ff871e6/djlint-1.39.2-cp313-cp313-win_amd64.whl", hash = "sha256:9040e8ff1e7e141cd67402110f0edb67835955003c7a076c97398dcee5f814d1", size = 472106, upload-time = "2026-06-11T14:02:12.8Z" },
{ url = "https://files.pythonhosted.org/packages/bf/65/c28711b4e15c932e2be15eee9b0d37465394bab65acb0f2f5f51ee42decd/djlint-1.39.2-cp313-cp313-win_arm64.whl", hash = "sha256:a54edd4326adc48577d244425e8d3c175ec3cc6abd3732418efeccc0b92b9d1a", size = 404747, upload-time = "2026-06-11T14:02:33.238Z" },
{ url = "https://files.pythonhosted.org/packages/46/3e/16cfba85037a7dcd2333263594c8c94f6443b0f3726f55cd2426fc7fac5b/djlint-1.39.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e2fefc27180dad754e76c9ece6f5dbe99a808c7a0dd4ff4213dd39d82f1878cd", size = 537519, upload-time = "2026-06-11T14:03:03.663Z" },
{ url = "https://files.pythonhosted.org/packages/bb/9b/dd5dffc2eadb46a3167c13151b6f7dbcef39996da56794d45f43b1826a61/djlint-1.39.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c62372a9d037d612f9b957829c0dbdef96e017dc2d9cf4970e094de6fee4ee55", size = 509380, upload-time = "2026-06-11T14:02:35.978Z" },
{ url = "https://files.pythonhosted.org/packages/0a/38/2e89eea336e5990999112fddd7b238407a3d9183a06acb38e00bd79d1dfd/djlint-1.39.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d27a88e84fd9e75cc49247b9d200f51c7bd037364efd58256d3b0e70c10775db", size = 538317, upload-time = "2026-06-11T14:02:49.693Z" },
{ url = "https://files.pythonhosted.org/packages/ae/fe/2546eecaaed67b2f92c2bd571b88b84692cd9b6883bdbfe6370391d86219/djlint-1.39.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13b77ce23aabab2426f5130948c0d605bd7414550bbca8a3892b7d255644c2da", size = 557757, upload-time = "2026-06-11T14:02:17.769Z" },
{ url = "https://files.pythonhosted.org/packages/3e/fa/36bc8176a4a1702ed8446b606ddecc9502d398a55c3803e69469ba8304f9/djlint-1.39.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4547b35fd6f5030ffe4555e66916f8ba8107e0e9c2c6a4e721fb980ba79e3fce", size = 544474, upload-time = "2026-06-11T14:02:45.64Z" },
{ url = "https://files.pythonhosted.org/packages/62/69/5c0733745e5a2a275f3dad4977745890a777c5a74141b6c0e13a998992c2/djlint-1.39.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1257a2db80184ba70a195d8fe6026b54dab4dbc5156a90a61adbc916e500a6b1", size = 567721, upload-time = "2026-06-11T14:01:49.913Z" },
{ url = "https://files.pythonhosted.org/packages/5c/66/7c2a820f2d9948ff0b423d6395664a13979ff702edeb6efd51bf404b5c66/djlint-1.39.2-cp314-cp314-win32.whl", hash = "sha256:766d0266fbb0064c7579d66ed06487d9198d99880c5bbab03f24386edfa766f5", size = 429369, upload-time = "2026-06-11T14:02:20.447Z" },
{ url = "https://files.pythonhosted.org/packages/2e/32/006d9e22e2184c87cd556a7333d12c5fba2ec8b78bc6333621c4b067b81d/djlint-1.39.2-cp314-cp314-win_amd64.whl", hash = "sha256:345608f6eefe627d39f2491a673bc80688a86af3977113fc91b01f4b827bec2a", size = 481219, upload-time = "2026-06-11T14:01:59.669Z" },
{ url = "https://files.pythonhosted.org/packages/73/56/5488c01bc730b86af531b72066c913d0ebe234ddb3101f9898eb044ffc44/djlint-1.39.2-cp314-cp314-win_arm64.whl", hash = "sha256:ed0d24f5af98f7ef8c50061b64ab4cdd61abfd133df9f99810d3efc82e25a3c1", size = 412865, upload-time = "2026-06-11T14:02:56.015Z" },
{ url = "https://files.pythonhosted.org/packages/de/89/66b5260dc2f677264e0580565eb1a7b30bd842bebaba7940e11dc9a348b0/djlint-1.39.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:16cc5df9891a80090600f32111448e175148670cb7ca7789c04c4cdf9f9334d8", size = 584161, upload-time = "2026-06-11T14:02:57.393Z" },
{ url = "https://files.pythonhosted.org/packages/7a/38/e1a31e0fdd2cf090f4f862f282fe1fb0b363c0e248a105ed7f3aa16a5508/djlint-1.39.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c20df1b4a79bc05329c207110478ad26513deaef3a7409b4a3cab55216172749", size = 556646, upload-time = "2026-06-11T14:02:37.574Z" },
{ url = "https://files.pythonhosted.org/packages/57/58/95733fc6f42a0bf31aa1186e0323fe224327bb840666d083261913cac90b/djlint-1.39.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c2f34d9ef9aa73536bf485648ce4fa381bb3d8d843fb97ec6725d5b3457b84d", size = 573320, upload-time = "2026-06-11T14:02:21.965Z" },
{ url = "https://files.pythonhosted.org/packages/7b/7c/9a03f9e859bc3dd05992dd8bf40298bcc18693dbbcba25a3eec58dd25fa5/djlint-1.39.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:851a9c78674120c99fe6842bcd3377bce698583823ee7861ae4992e84fff3e9b", size = 593052, upload-time = "2026-06-11T14:02:39.044Z" },
{ url = "https://files.pythonhosted.org/packages/b7/73/d0fa38536f1652584ddb6db9487bf8ef95322b92ce88ff4fe16719715526/djlint-1.39.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8c9df4a39d57476bcd2a02ae8d873796b92517517d4512f497b40be3e7398e84", size = 579381, upload-time = "2026-06-11T14:02:40.393Z" },
{ url = "https://files.pythonhosted.org/packages/ee/35/689726a28c99af26b925f97f66b7046eee3b3881ca3161a04695e8e8112b/djlint-1.39.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e0356e3a66357d72ca5d69f2fb78d47fed26efb31d104cba44f5e6fbf503a4e2", size = 602713, upload-time = "2026-06-11T14:02:44.356Z" },
{ url = "https://files.pythonhosted.org/packages/91/03/5e59d32de6d3b4fc6113450e6198755d169b23fc7d8daaa5bbcc2ebc5c57/djlint-1.39.2-cp314-cp314t-win32.whl", hash = "sha256:ad5213ce1bbab6c18e1461498f219ca66382c40766d499e233ff2174a23026c6", size = 475348, upload-time = "2026-06-11T14:01:51.353Z" },
{ url = "https://files.pythonhosted.org/packages/bd/58/bd0ca13d2cfb31b666efc3b10af5dd37a72269b0282ec6490993e9655704/djlint-1.39.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d5711a2945844446fa19c35f151e077c2119411e49db30a3f97f32b9325b89bf", size = 535320, upload-time = "2026-06-11T14:03:00.083Z" },
{ url = "https://files.pythonhosted.org/packages/a4/c5/b2b8d8918f770a0dac8b2ad7f4a5caa3bc63c60f4cd178418b72f19f0a5a/djlint-1.39.2-cp314-cp314t-win_arm64.whl", hash = "sha256:f894464e6946e761003844ac5bfe4f51f362258a51d4e65a9641a4bd74da4ea2", size = 429535, upload-time = "2026-06-11T14:02:02.145Z" },
{ url = "https://files.pythonhosted.org/packages/62/c1/ffb80fed94a3bf746d37134b504b794de893cb71e229ea2e1c91f14d1864/djlint-1.39.2-py3-none-any.whl", hash = "sha256:7c49bfda97816afd36273f12d67aa432ed44cd9f62d5c31f9ea9c316ebe808b2", size = 62347, upload-time = "2026-06-11T14:02:23.136Z" },
] ]
[[package]] [[package]]
@@ -306,11 +329,11 @@ wheels = [
[[package]] [[package]]
name = "filelock" name = "filelock"
version = "3.29.0" version = "3.29.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" } sdist = { url = "https://files.pythonhosted.org/packages/91/f5/3557bf28e0f1943e4849154c821533706e6dea010f96fb6aa0b6949037d1/filelock-3.29.3.tar.gz", hash = "sha256:7fc1b3f39cf172fd8203812043c57b8a65aef9969f38b6704f628b881f761a84", size = 61956, upload-time = "2026-06-10T17:37:11.832Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, { url = "https://files.pythonhosted.org/packages/81/8f/b61d427c4f49a8bdadc93f4e7e74df8a6df6f77ee6e26bf0df53d3925363/filelock-3.29.3-py3-none-any.whl", hash = "sha256:e58333029cc9b925f39aad59b1d8f0a1ad836af4e60d7217f4a4dba87461261d", size = 42324, upload-time = "2026-06-10T17:37:10.37Z" },
] ]
[[package]] [[package]]
@@ -402,11 +425,11 @@ wheels = [
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.17" version = "3.18"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b9/28/99c51f664567218d824af024c0251650fb27e4ca066df188dab0769c5b91/idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f", size = 196048, upload-time = "2026-05-28T14:32:38.55Z" } sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/de/a7/f76514cc40ad6234098ecdebda08732d75964776c51a42845b7da10649e2/idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c", size = 65316, upload-time = "2026-05-28T14:32:37.035Z" }, { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
] ]
[[package]] [[package]]
@@ -792,15 +815,15 @@ wheels = [
[[package]] [[package]]
name = "python-discovery" name = "python-discovery"
version = "1.4.0" version = "1.4.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "filelock" }, { name = "filelock" },
{ name = "platformdirs" }, { name = "platformdirs" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/a6/12/38c1a0b1e64806780c9563e3fc9f6e472251839662587cfbe9bfaf2ae10a/python_discovery-1.4.0.tar.gz", hash = "sha256:eb8bc7daad3c226c147e45bb4e970a1feb1bf4048ee178e6db59e197b8010ce3", size = 68455, upload-time = "2026-05-28T01:15:37.639Z" } sdist = { url = "https://files.pythonhosted.org/packages/0b/1a/cbbaf13b730abb0a16b964d984e19f2fe520c21a4dc664051359a3f5a9e7/python_discovery-1.4.2.tar.gz", hash = "sha256:8f3746c4b4968d22afbb97d36e1a0e5b66e6c0f297290f2e95f05b9b8bf18690", size = 70277, upload-time = "2026-06-11T16:10:42.383Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/8d/3d316429f65029532bb1e28ff77b797d86b5ac3915bb44ca4e19aa283d43/python_discovery-1.4.0-py3-none-any.whl", hash = "sha256:26ed78d703e234879a66244c7d4114563fb13ec5cd30a2d1357e5fb4850782da", size = 33217, upload-time = "2026-05-28T01:15:36.573Z" }, { url = "https://files.pythonhosted.org/packages/1a/82/a70006589557f267f15bd384c0642ad49f0d97b690c3a05b166b9dcbad3b/python_discovery-1.4.2-py3-none-any.whl", hash = "sha256:475803f53b7b2ed6e490e27373f9d8340f7d2eebf9acdaf645d7d714c97bb500", size = 33886, upload-time = "2026-06-11T16:10:41.192Z" },
] ]
[[package]] [[package]]
@@ -949,27 +972,27 @@ wheels = [
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.15.15" version = "0.15.17"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/84/6f/a76f7d96e5c962f5b69cee865e49c15c1116897c01990faa8a57edb62e7f/ruff-0.15.15.tar.gz", hash = "sha256:b8dff018130b46d8e5bf0f926ef6b60cf871d6d5ae45fc9334e09632daa741d6", size = 4706985, upload-time = "2026-05-28T14:16:57.784Z" } sdist = { url = "https://files.pythonhosted.org/packages/8c/a9/3abdf488f1bf3d24c699415e454ed554a6350d5d89ce183be1ee0a3361ac/ruff-0.15.17.tar.gz", hash = "sha256:2ec446937fd16c8c4de2674a209cc5af64d9c6f17d21fbf1151054fa0bcf5219", size = 4743346, upload-time = "2026-06-11T17:54:47.663Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/fa/9d/3a45c05b8ab04b4705989de70a79008e27c8003296a0feaee9edc18dd7e9/ruff-0.15.15-py3-none-linux_armv6l.whl", hash = "sha256:cf93e5388f412e1b108b1f8b34a6e036b70fe8aff89393befad96fe48670311b", size = 10710652, upload-time = "2026-05-28T14:16:06.701Z" }, { url = "https://files.pythonhosted.org/packages/db/4d/e11259f5da07cb6afb2d074c31bf09da9671993f7329d4f15d2fdc458301/ruff-0.15.17-py3-none-linux_armv6l.whl", hash = "sha256:d9feddb927fc68bd295f5eebc587a7e42cfaf9b65f60ca4a2386febff575da8f", size = 10856677, upload-time = "2026-06-11T17:54:49.533Z" },
{ url = "https://files.pythonhosted.org/packages/05/66/da974431624bf3b49f6ee1f9543c02d929ff1cba78b0d5a79c38cf21f744/ruff-0.15.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e", size = 11096615, upload-time = "2026-05-28T14:16:23.313Z" }, { url = "https://files.pythonhosted.org/packages/29/3e/772d679e1a0dc058e58875bd2c0cb713a0530877b4a76fee3c7966df0d49/ruff-0.15.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:25805a226d741c47d274a35ad5c10a7dde175fcddfa511d7cf3da0a21eb3eab7", size = 11223443, upload-time = "2026-06-11T17:55:00.573Z" },
{ url = "https://files.pythonhosted.org/packages/8c/09/7443452e5d290230a712103f2fdceeef7184f3ec99a2bd01c8be78aaceb5/ruff-0.15.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530", size = 10436683, upload-time = "2026-05-28T14:16:40.974Z" }, { url = "https://files.pythonhosted.org/packages/68/58/bd41f7688b2fd5623012605130ed70e60aa7f2244baa3d5066bdd61530c8/ruff-0.15.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f6ad73b14c2d18a3bf8ad7cb6974294d7f613a7898604826058e6ac64918ef4d", size = 10566458, upload-time = "2026-06-11T17:55:07.52Z" },
{ url = "https://files.pythonhosted.org/packages/53/01/d330c26a57fa4f3943a14424904027428315b700fe4d14a84bb123a649e5/ruff-0.15.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7614ee79c69788cf6cedd568069ade9cecc22a1ad20494efe8d0c9ebb4b622d4", size = 10769064, upload-time = "2026-05-28T14:16:28.905Z" }, { url = "https://files.pythonhosted.org/packages/d8/5b/733371013fcf1ec339e477ece6ab42bfe10bdd9bba8ee88a9516aa56bfc0/ruff-0.15.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ba0c1e4f95bcb3869d0d30cbd5917071ef2e28665abfec970cdab0492c713ed", size = 10914483, upload-time = "2026-06-11T17:55:05.501Z" },
{ url = "https://files.pythonhosted.org/packages/1d/85/cc8770f8bdff541b1da8392d1634141fe4a0e3f4ee596605959b7906c27f/ruff-0.15.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3cdb1679e06a1f6b47bc384714ae96f6e2fb65ca441eb78c43d2ca554176ce1f", size = 10511987, upload-time = "2026-05-28T14:16:43.732Z" }, { url = "https://files.pythonhosted.org/packages/bd/cc/6f24251cc0252f7239391ccb85833f320efad14ebe5b443943f37ced6332/ruff-0.15.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81647960f10bff57d2e51cadd0c3950fe598400c852863a038720ef5b8cca91e", size = 10647497, upload-time = "2026-06-11T17:54:57.733Z" },
{ url = "https://files.pythonhosted.org/packages/7c/29/8c190c1472b63013583ba391f3342036e02010544c1270455ed8e519bdf3/ruff-0.15.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2728b93d7b23a603ea2c0ac6eb73d760bd38ec9de35f35fb41e18f7a3fee7622", size = 11275100, upload-time = "2026-05-28T14:16:55.244Z" }, { url = "https://files.pythonhosted.org/packages/68/dd/0d10c17ce1a1624d6fc3156309c3f834fdb5dfaad026ec90c85684f3990e/ruff-0.15.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e01a84ddbc8c16c23055ba3924476850f1bbc1917cebbb9376665a63e74260d", size = 11416967, upload-time = "2026-06-11T17:54:51.461Z" },
{ url = "https://files.pythonhosted.org/packages/9f/6b/7e145ce2cc8e63d6834eca03d83a0e18d121def5c69f91b4cf4011ed4879/ruff-0.15.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be582fcc0db438902c7792b08d6ddf6c9b9e21addaa10092c2c741cfb09e5a45", size = 12176903, upload-time = "2026-05-28T14:16:14.368Z" }, { url = "https://files.pythonhosted.org/packages/2f/91/556bfb156f6144f355e831c23db00b2fc4120f86b3ce81cc5f7fd2df51f3/ruff-0.15.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fe9f653152f8f294f9f7e03bf3a453d8b4a27f7a59c78c8666167f2b17b96c", size = 12335770, upload-time = "2026-06-11T17:54:45.793Z" },
{ url = "https://files.pythonhosted.org/packages/80/a3/d5974637f68e451f7fadf015cf3101d1cd7d8ba5027cffe0b9e3826ebe6b/ruff-0.15.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7aa77465b8ecaf1a27bea098d696f7fed5e1eccbd10b321b682d6de586ae5627", size = 11404550, upload-time = "2026-05-28T14:16:20.138Z" }, { url = "https://files.pythonhosted.org/packages/88/82/8b5999aa13355e926f06d9f42a32dcca862f623bf0363785ff89d607dffd/ruff-0.15.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c0fe88a7676e7a05b73174d4d4a59cb2ac21ff8263583f87a81a6018475a978", size = 11575441, upload-time = "2026-06-11T17:54:32.661Z" },
{ url = "https://files.pythonhosted.org/packages/fe/1c/e6e5e568f22be4fb05d6244234aba384c06b451252453b821e1a529263cf/ruff-0.15.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4", size = 11382027, upload-time = "2026-05-28T14:16:46.615Z" }, { url = "https://files.pythonhosted.org/packages/11/93/f10377bb04109ca0e8cbc483ff1982c54b6d418210041776f93e8cdc7fa9/ruff-0.15.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecfc3c7878fff94633ab0348524e093f9ce3243080416dd7d14f8ba400174719", size = 11557614, upload-time = "2026-06-11T17:54:34.698Z" },
{ url = "https://files.pythonhosted.org/packages/1d/01/170921b49fcd2e8858825593f91cf7146c3e40a5c3e6df763e4bb0484dde/ruff-0.15.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a5015088452ca0081387063649ec67f06d3d1d6b8b936a1f836b5e9657ecd48c", size = 11366041, upload-time = "2026-05-28T14:16:26.247Z" }, { url = "https://files.pythonhosted.org/packages/c7/a6/eeeae7f7d5493df41649ab3db92f086b2d0a30199e4efdf8e3dd7a033f24/ruff-0.15.17-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b8461180b22420b1bdc289909410930761629fddf2a5aaf60fae1ab26cedc4c4", size = 11544450, upload-time = "2026-06-11T17:54:39.042Z" },
{ url = "https://files.pythonhosted.org/packages/87/54/a7bad711d7de93254e15e06a4c375b89a03d18de45d3e5dcc86a4472fb1a/ruff-0.15.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5294aab6356c81600fcdea3a62bb1b924dfd5e91767c12318d3f68f86af57cd", size = 10741795, upload-time = "2026-05-28T14:16:17.11Z" }, { url = "https://files.pythonhosted.org/packages/32/88/5991ce565129a24dd4a00db1254b3b5db2e53018cbe4018ea5a89738e727/ruff-0.15.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6eccbe50a038b503e7140b441aa9c7fc8c1f36edf23ebef9f4165c2f28f568b7", size = 10892524, upload-time = "2026-06-11T17:55:09.432Z" },
{ url = "https://files.pythonhosted.org/packages/c9/31/38c075963668f8b41c6914ee0f6f318727fbe30ab9145cb29e6df464c5fa/ruff-0.15.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:db5bd4d802415cca656dc1616070b725952d6ae95eb5d4831e49fbd94a38f75f", size = 10511117, upload-time = "2026-05-28T14:16:31.767Z" }, { url = "https://files.pythonhosted.org/packages/f5/1d/0fdd248313425f55223968af04b0a42125466a8d88d21c1d99c6af0a51e8/ruff-0.15.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:382fc0521025f5a8ad447d8bdd523545d0d7646adb718eb1c2dac5065ec27c0f", size = 10659573, upload-time = "2026-06-11T17:54:36.824Z" },
{ url = "https://files.pythonhosted.org/packages/9d/96/6ff689e1f7e375d1d97075eca022f74c2bab59554a432fe4d2e6f091986a/ruff-0.15.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:587a6278ed42059191c1a466e490bd7930fb50bd2e255398bc29616c895a61cb", size = 10994867, upload-time = "2026-05-28T14:16:35.149Z" }, { url = "https://files.pythonhosted.org/packages/9e/0e/072e8260deb9461062ce9311ced27a8e541229a6ffd483013dd37661e43e/ruff-0.15.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:456d41fcd1b2777ad63f09a6e7121d43f7b688bbc76a800c10f7f8fb1f912c3f", size = 11127818, upload-time = "2026-06-11T17:55:03.124Z" },
{ url = "https://files.pythonhosted.org/packages/c3/c2/5dce0ab9f92a8d534fa62b9bf9caca3eddb8c1a81b616f5e195ada4f0d6e/ruff-0.15.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:df0c1c084f5f4be9812f61518a45c440d3c30d69ce4bf6c5270e66d38338f02a", size = 11482101, upload-time = "2026-05-28T14:16:49.598Z" }, { url = "https://files.pythonhosted.org/packages/ab/b4/55060a34163121498014696b5f656db5b8c6963768f227dbf0d76b311073/ruff-0.15.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b1a04bcc94ae6194e9db05d16ad31f298a7194bfbcb08258bbe589cee1d587b8", size = 11655901, upload-time = "2026-06-11T17:54:53.562Z" },
{ url = "https://files.pythonhosted.org/packages/b1/c0/1003b60edd697c649faf61f1a34094b1abb38fb3d1181e3f895781250a08/ruff-0.15.15-py3-none-win32.whl", hash = "sha256:29428ea79694afbe756d45fd59b36f22b6b020dc0443cf7de0173046236964b9", size = 10716774, upload-time = "2026-05-28T14:16:52.337Z" }, { url = "https://files.pythonhosted.org/packages/49/71/9b29d6b87cef468d697f43c6a91e3fae4a80185779d7d5a4ef27d173439f/ruff-0.15.17-py3-none-win32.whl", hash = "sha256:596065960ab1ff593f744220c9fe6580eda00a95003cffa9f4048bb5b1bf0392", size = 10925574, upload-time = "2026-06-11T17:54:55.723Z" },
{ url = "https://files.pythonhosted.org/packages/02/a8/1269eddd6945a06c23f055ef7848886e37cf9d6a8bebb386a3115f01470c/ruff-0.15.15-py3-none-win_amd64.whl", hash = "sha256:8df0323902e15e24bc4bf246da830573d3cf3352bd0b9a164eab335d111ff4a4", size = 11868463, upload-time = "2026-05-28T14:16:11.333Z" }, { url = "https://files.pythonhosted.org/packages/3d/b2/8fc77f3723228836fa5d12497eb71c808f83782e10d058d2b15cfa14640b/ruff-0.15.17-py3-none-win_amd64.whl", hash = "sha256:6769e5fa1710b179b92e0bfa5a51735b35baea9013dadb06d5f44cbcf9547084", size = 12058788, upload-time = "2026-06-11T17:54:41.042Z" },
{ url = "https://files.pythonhosted.org/packages/4e/b2/920464c907b191e37469d477a1aa8bc048b8f36c4c1610dfa4ab87b39e18/ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", size = 11138498, upload-time = "2026-05-28T14:16:38.425Z" }, { url = "https://files.pythonhosted.org/packages/2d/c7/c53e8dbff9c9dc4b7928773421ae294a5d28fcb8dcda1a089579d3a7e510/ruff-0.15.17-py3-none-win_arm64.whl", hash = "sha256:f3be1fbb34bcdfd146240d8fb92a709d4c2c8191348580a3c044ec60fa0b4456", size = 11355275, upload-time = "2026-06-11T17:54:43.635Z" },
] ]
[[package]] [[package]]
@@ -1066,14 +1089,14 @@ dev = [
[[package]] [[package]]
name = "tqdm" name = "tqdm"
version = "4.67.3" version = "4.68.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" }, { name = "colorama", marker = "sys_platform == 'win32'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } sdist = { url = "https://files.pythonhosted.org/packages/85/05/0d5260f1f1ca784f4a4a0def9cbe6affe587f5b4025328d446c3d67765f4/tqdm-4.68.2.tar.gz", hash = "sha256:89c230e8dbc67c7615c142487111222f878c77427ea09549960f62389e258add", size = 171923, upload-time = "2026-06-09T13:26:42.539Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, { url = "https://files.pythonhosted.org/packages/eb/75/1a0392bcc21c44dcdf87b3cf2d137e7829be2c083a1e38d44efca3d57a16/tqdm-4.68.2-py3-none-any.whl", hash = "sha256:d4240441fb5353290b87d6a85968c9decc131a99b8c7faa28269d829de669ede", size = 78578, upload-time = "2026-06-09T13:26:40.731Z" },
] ]
[[package]] [[package]]
@@ -1130,7 +1153,7 @@ wheels = [
[[package]] [[package]]
name = "virtualenv" name = "virtualenv"
version = "21.4.2" version = "21.4.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "distlib" }, { name = "distlib" },
@@ -1138,7 +1161,7 @@ dependencies = [
{ name = "platformdirs" }, { name = "platformdirs" },
{ name = "python-discovery" }, { name = "python-discovery" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/e1/0d/4e93c8e6d1001a75763f87d8f5ecda8ebc7f4aa2153dddfaf4ae8892821a/virtualenv-21.4.2.tar.gz", hash = "sha256:38e6ee0a555615c0ea9da2ac7e9998fe8dc3b911dd33ad8eaad2020957653b0c", size = 7613326, upload-time = "2026-05-31T17:01:22.827Z" } sdist = { url = "https://files.pythonhosted.org/packages/4b/50/7564c805bb8966d9771caaba8a143fa5e57c848ce4e7fdf2d55a1feb2ead/virtualenv-21.4.3.tar.gz", hash = "sha256:938ff0fd3f4e0f0d3a025f67a3d2f25e3c3aabbcd5857ea6170619138d72d141", size = 7644454, upload-time = "2026-06-11T16:47:04.843Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/bf/c4/557dc082be035381b85fdb2b74e21d3d21b57750b74f2b47a32f3a639ff9/virtualenv-21.4.2-py3-none-any.whl", hash = "sha256:854210ca524a1a4d0d744734f4acbc721c3ffe163b85bbf5d56d14d5ae2f0fae", size = 7594079, upload-time = "2026-05-31T17:01:20.735Z" }, { url = "https://files.pythonhosted.org/packages/a2/8d/84b0d07c6b5f685f85ddf6c87a59d3a8a895a3dfd89e759666fabe951b94/virtualenv-21.4.3-py3-none-any.whl", hash = "sha256:75f4127d4067397c64f38579ce918fec6bf9ca2cd4f48685e82952cc3c035840", size = 7625544, upload-time = "2026-06-11T16:47:01.78Z" },
] ]