Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
ce5e5fb729
|
|||
|
846151d373
|
|||
|
1fcef255a6
|
|||
|
97fff21b28
|
|||
|
c6aa3d25cc
|
|||
| 733da3419b | |||
| f693f8280f | |||
| dfccfbff51 | |||
|
62f0c6c261
|
|||
|
d0d6b3f999
|
|||
|
6f58eb3fde
|
|||
|
d45ae357c4
|
|||
|
ae7fa5bae7
|
|||
|
be95c32e7b
|
|||
|
32588226de
|
|||
|
2ae01bfecf
|
+44
-17
@@ -1,24 +1,51 @@
|
|||||||
# Docker registry URL (used in docker-compose.yml)
|
# =============================================================================
|
||||||
REGISTRY_URL=registry.kucharczyk.xyz
|
# Django application settings (read by timetracker/config.py)
|
||||||
|
#
|
||||||
|
# Resolution priority, highest first:
|
||||||
|
# SECRET_KEY__FILE -> env var -> .env -> settings.ini -> built-in default
|
||||||
|
# See docs/configuration.md for the full reference.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
# Container timezone
|
# Turn DEBUG off in production. Defaults on for local development.
|
||||||
|
# (The old PROD=1 variable still works but is deprecated; prefer DEBUG.)
|
||||||
|
DEBUG=false
|
||||||
|
|
||||||
|
# Secret key. Required in production; an insecure default is used in DEBUG.
|
||||||
|
# For Docker/K8s secrets, point SECRET_KEY__FILE at a mounted file instead.
|
||||||
|
SECRET_KEY=change-me-to-a-long-random-string
|
||||||
|
# SECRET_KEY__FILE=/run/secrets/timetracker_secret_key
|
||||||
|
|
||||||
|
# Public URL(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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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/
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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" \
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
]
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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}
|
||||||
|
|||||||
@@ -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=*`.
|
||||||
@@ -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
@@ -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
@@ -1,8 +1,16 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Container-bootstrap configuration. These variables are consumed only by this
|
||||||
|
# entrypoint, NOT by Django (see timetracker/config.py for the app settings):
|
||||||
|
# PUID/PGID — uid/gid the container process runs as
|
||||||
|
# DATA_DIR — writable dir for the SQLite database (kept in
|
||||||
|
# sync with Django via the same env var + default)
|
||||||
|
# CREATE_DEFAULT_SUPERUSER — create an admin/admin user on first start
|
||||||
|
# STAGING / LOAD_SAMPLE_DATA — staging-only data bootstrap (see below)
|
||||||
PUID=${PUID:-1000}
|
PUID=${PUID:-1000}
|
||||||
PGID=${PGID:-100}
|
PGID=${PGID:-100}
|
||||||
|
DATA_DIR=${DATA_DIR:-/home/timetracker/app/data}
|
||||||
|
|
||||||
USERHOME=$(grep timetracker /etc/passwd | cut -d ":" -f6)
|
USERHOME=$(grep timetracker /etc/passwd | cut -d ":" -f6)
|
||||||
usermod -d "/root" timetracker
|
usermod -d "/root" timetracker
|
||||||
@@ -10,11 +18,11 @@ groupmod -o -g "$PGID" timetracker
|
|||||||
usermod -o -u "$PUID" timetracker
|
usermod -o -u "$PUID" timetracker
|
||||||
usermod -d "${USERHOME}" timetracker
|
usermod -d "${USERHOME}" timetracker
|
||||||
|
|
||||||
mkdir -p /home/timetracker/app/data /var/log/supervisor
|
mkdir -p "$DATA_DIR" /var/log/supervisor
|
||||||
chmod 755 /home/timetracker/app
|
chmod 755 /home/timetracker/app
|
||||||
chmod 755 /home/timetracker/app/.venv
|
chmod 755 /home/timetracker/app/.venv
|
||||||
|
|
||||||
chown "$PUID:$PGID" /home/timetracker/app/data
|
chown "$PUID:$PGID" "$DATA_DIR"
|
||||||
chown "$PUID:$PGID" /var/log/supervisor
|
chown "$PUID:$PGID" /var/log/supervisor
|
||||||
|
|
||||||
python manage.py migrate
|
python manage.py migrate
|
||||||
@@ -49,6 +57,6 @@ if not User.objects.filter(username='admin').exists():
|
|||||||
"
|
"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
chown -R "$PUID:$PGID" /home/timetracker/app/data
|
chown -R "$PUID:$PGID" "$DATA_DIR"
|
||||||
|
|
||||||
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisor.conf
|
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisor.conf
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ primary_region = "ams"
|
|||||||
dockerfile = "Dockerfile"
|
dockerfile = "Dockerfile"
|
||||||
|
|
||||||
[env]
|
[env]
|
||||||
PROD = "1"
|
DEBUG = "false"
|
||||||
TZ = "Europe/Prague"
|
TZ = "Europe/Prague"
|
||||||
DATA_DIR = "/home/timetracker/app/data"
|
DATA_DIR = "/home/timetracker/app/data"
|
||||||
LOAD_SAMPLE_DATA = "true"
|
LOAD_SAMPLE_DATA = "true"
|
||||||
|
|||||||
+18
-33
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Alternative to a .env file for non-Docker / bare-metal deployments.
|
||||||
|
# Copy to settings.ini (next to manage.py) or point INI_FILE at it.
|
||||||
|
# Real environment variables and a .env file both take precedence over this.
|
||||||
|
# See docs/configuration.md for the full reference.
|
||||||
|
|
||||||
|
[timetracker]
|
||||||
|
DEBUG = false
|
||||||
|
SECRET_KEY = change-me-to-a-long-random-string
|
||||||
|
APP_URL = https://tracker.kucharczyk.xyz
|
||||||
|
TZ = Europe/Prague
|
||||||
|
DATA_DIR = /var/lib/timetracker
|
||||||
|
|
||||||
|
# Optional explicit overrides (comma-separated); win over APP_URL when set.
|
||||||
|
# ALLOWED_HOSTS = *
|
||||||
|
# CSRF_TRUSTED_ORIGINS = https://tracker.kucharczyk.xyz
|
||||||
@@ -0,0 +1,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
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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
@@ -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 = []
|
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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" },
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user