Compare commits
168 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
9d77fca009
|
|||
| 783fb324ef | |||
|
f1cafab525
|
|||
|
c2f9263f52
|
|||
| d8558eca89 | |||
| 2e9e6b4fcf | |||
| 6a3f66b1a9 | |||
| 1b0cccacf8 | |||
|
2b450c6d47
|
|||
|
9d02121c5b
|
|||
|
d2bf6efdb4
|
|||
|
227b1f674d
|
|||
| 017e3a61a8 | |||
| 2c699eb976 | |||
| f19d24ee98 | |||
| 263299ca52 | |||
| 0b7ddc260f | |||
| d9a8835696 | |||
| 029c65da79 | |||
| 008d92d433 | |||
|
9e17b94516
|
|||
|
507353bb48
|
|||
|
a9e148701d
|
|||
| c3de90e805 | |||
|
1d2dfd23af
|
|||
|
395f6e8dea
|
|||
|
abfdd03c6e
|
|||
|
e15b197623
|
|||
|
e12c667572
|
|||
| 874d3e236e | |||
|
f036a246a8
|
|||
|
7751c29529
|
|||
|
5f411b8ae9
|
|||
|
3fb9aa9f84
|
|||
|
138136e285
|
|||
|
2364d868fa
|
|||
|
ce976e8f2e
|
|||
|
c7af814364
|
|||
|
1258c529d2
|
|||
|
48644037f6
|
|||
|
04552aa8f6
|
|||
|
0f0dfc48fb
|
|||
|
763c00c50e
|
|||
|
5fd82c78d4
|
|||
| 58008d6f2c | |||
|
3ff3eed164
|
|||
|
7d46cc24b9
|
|||
|
3f95692746
|
|||
|
0527412265
|
|||
|
0c6c536d07
|
|||
|
544da26a9d
|
|||
|
7104605c06
|
|||
|
9c42d85f52
|
|||
|
bec7a1074c
|
|||
|
022d43a5a5
|
|||
|
1c5bff8651
|
|||
|
925cf007f4
|
|||
| 2d3ae4e04f | |||
| 0819ddb87d | |||
| 4031657bb5 | |||
| f673f3ac80 | |||
| e7db7eb0e8 | |||
| b68a131bae | |||
| 88cf374f33 | |||
| be919c992d | |||
| 0fa860c237 | |||
| 15a97dee9a | |||
| 1822ea8b51 | |||
| f32a88b47d | |||
| 6dfd6c83c9 | |||
| 19f1cdd197 | |||
| b5546ed828 | |||
| 9cb911401a | |||
| 2190b9d590 | |||
| 0c109cf2a1 | |||
| e8a49df2cf | |||
| 3c7ccbdd2b | |||
| 1322e6e71c | |||
| 58b274a452 | |||
| e309ff1b30 | |||
| 35d314768f | |||
| f9032eef9e | |||
| 99af73781b | |||
| 79d1be2852 | |||
| ebfc9aebfc | |||
| 03adcf99a7 | |||
| b1a4da2704 | |||
| 3ce6da708f | |||
| ab079cb447 | |||
| c2996fd91b | |||
| 22c688bd9a | |||
| 4e77934d06 | |||
| b8d807d302 | |||
| 67b40255ed | |||
| eda9d39cdc | |||
| 3a5b6e2d51 | |||
|
e45be806fc
|
|||
|
83aefcb849
|
|||
|
c7c196a054
|
|||
|
c639196266
|
|||
|
ed086c9702
|
|||
|
6f4841eaaa
|
|||
|
5c9bf45c61
|
|||
|
bd228365ed
|
|||
| 1c9fb474df | |||
| 737dd9275b | |||
| 9f436b245d | |||
| 7ebaa51eb0 | |||
| a7ff2962a6 | |||
| 103219a5e7 | |||
| 14efff8078 | |||
| ba9b92d419 | |||
| 05534875d6 | |||
| 428edbcfe8 | |||
| 11cd62a3b9 | |||
| d9902146dc | |||
| 83cbac9505 | |||
| 0285243172 | |||
| 112d3107ef | |||
| 22d7834ae9 | |||
| 60773e7755 | |||
| 79fa4bef44 | |||
| 15bb3ce1b9 | |||
| a06e772e42 | |||
| 29b42e0f3d | |||
| f210f818a9 | |||
| 6bc7da9f2f | |||
| c9189b9f8e | |||
| a37257f9c8 | |||
| db047dfaf2 | |||
| 6aff12b7b2 | |||
| 12b0b0af61 | |||
| 1a206d719b | |||
| a6532807cb | |||
| d7e6efa68a | |||
| 003e6ebe15 | |||
| e2cbd4a9f4 | |||
| 547894d8d0 | |||
| 061b5e6d8a | |||
| 05e30610e9 | |||
| 0aa87a17fe | |||
| 7c2c08501e | |||
| d3b29ff1d4 | |||
| 1c17fbcb6d | |||
| 3b9c05d674 | |||
| 2c2827df47 | |||
| a6384fc003 | |||
| 7f5384de48 | |||
| ffcc4ba0f3 | |||
|
7493f6fc28
|
|||
|
f9b91c5900
|
|||
|
36098374c2
|
|||
| afc16aabbb | |||
| 3ce3356064 | |||
| ed8589a972 | |||
| f4161bf3f4 | |||
| b6864e59ce | |||
| 36b1382015 | |||
| d101aecd70 | |||
| 09db54e940 | |||
| f090643026 | |||
| ec1828b823 | |||
| 94c3d9050a | |||
| ad47684dc1 | |||
| 66ec8e1eed | |||
| 1583c474b2 | |||
|
2f433c92da
|
|||
|
5b2b79f553
|
+44
-14
@@ -1,21 +1,51 @@
|
||||
# Docker registry URL (used in docker-compose.yml)
|
||||
REGISTRY_URL=registry.kucharczyk.xyz
|
||||
# =============================================================================
|
||||
# Django application settings (read by timetracker/config.py)
|
||||
#
|
||||
# Resolution priority, highest first:
|
||||
# SECRET_KEY__FILE -> env var -> .env -> settings.ini -> built-in default
|
||||
# See docs/configuration.md for the full reference.
|
||||
# =============================================================================
|
||||
|
||||
# Container timezone
|
||||
# Turn DEBUG off in production. Defaults on for local development.
|
||||
# (The old PROD=1 variable still works but is deprecated; prefer DEBUG.)
|
||||
DEBUG=false
|
||||
|
||||
# Secret key. Required in production; an insecure default is used in DEBUG.
|
||||
# For Docker/K8s secrets, point SECRET_KEY__FILE at a mounted file instead.
|
||||
SECRET_KEY=change-me-to-a-long-random-string
|
||||
# SECRET_KEY__FILE=/run/secrets/timetracker_secret_key
|
||||
|
||||
# Public URL of the site. Derives ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS.
|
||||
APP_URL=https://tracker.kucharczyk.xyz
|
||||
|
||||
# Optional explicit overrides (comma-separated). When set they win over APP_URL.
|
||||
# Useful behind a reverse proxy, e.g. ALLOWED_HOSTS=*
|
||||
# ALLOWED_HOSTS=*
|
||||
# CSRF_TRUSTED_ORIGINS=https://tracker.kucharczyk.xyz
|
||||
|
||||
# Container timezone.
|
||||
TZ=Europe/Prague
|
||||
|
||||
# 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
|
||||
PGID=100
|
||||
|
||||
# External port mapping
|
||||
# Create an admin/admin superuser on startup (for initial setup only).
|
||||
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
|
||||
|
||||
# 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
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
name: Django CI/CD
|
||||
|
||||
on:
|
||||
push:
|
||||
paths-ignore: [ 'README.md' ]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
enable-cache: false
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --frozen
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Install pnpm and JS dependencies
|
||||
run: corepack enable && pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Build TypeScript
|
||||
run: make ts
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: uv run playwright install --with-deps chromium
|
||||
|
||||
- name: Run Migrations
|
||||
run: uv run python manage.py migrate
|
||||
|
||||
- name: Run Tests
|
||||
run: uv run --with pytest-django pytest
|
||||
|
||||
build-and-push:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set Version
|
||||
run: echo "VERSION_NUMBER=1.7.0" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: |
|
||||
registry.kucharczyk.xyz/timetracker:latest
|
||||
registry.kucharczyk.xyz/timetracker:${{ env.VERSION_NUMBER }}
|
||||
@@ -0,0 +1,86 @@
|
||||
name: Staging deployment
|
||||
|
||||
on:
|
||||
push:
|
||||
branches-ignore: [main]
|
||||
delete:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
BRANCH: ${{ github.ref_name }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Compute staging name
|
||||
run: |
|
||||
SLUG=$(echo "$BRANCH" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9-]+/-/g; s/-+/-/g; s/^-//; s/-$//' | cut -c1-40)
|
||||
echo "SLUG=${SLUG}" >> "$GITHUB_ENV"
|
||||
echo "HOST=tracker-${SLUG}.home.arpa" >> "$GITHUB_ENV"
|
||||
# Per-staging secret so each instance has its own key, decoupling it
|
||||
# from prod even though the database is seeded from a prod snapshot.
|
||||
echo "STAGING_SECRET_KEY=staging-${SLUG}-$(head -c16 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9')" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build image
|
||||
run: docker build -t "timetracker:staging-${SLUG}" .
|
||||
|
||||
- name: Deploy staging container
|
||||
run: |
|
||||
docker rm -f "timetracker-staging-${SLUG}" 2>/dev/null || true
|
||||
docker run -d --name "timetracker-staging-${SLUG}" \
|
||||
--network docker-compose-templates_public \
|
||||
-e TZ=Europe/Prague \
|
||||
-e PUID=1000 \
|
||||
-e PGID=100 \
|
||||
-e DATA_DIR=/home/timetracker/app/data \
|
||||
-e STAGING=true \
|
||||
-e "SECRET_KEY=${STAGING_SECRET_KEY}" \
|
||||
-e "APP_URL=https://${HOST}" \
|
||||
-v "timetracker-staging-${SLUG}:/home/timetracker/app/data" \
|
||||
-l "caddy=${HOST}" \
|
||||
-l 'caddy.reverse_proxy={{ upstreams 8000 }}' \
|
||||
-l xyz.kucharczyk.staging=timetracker \
|
||||
-l "xyz.kucharczyk.staging.branch=${BRANCH}" \
|
||||
--restart unless-stopped \
|
||||
"timetracker:staging-${SLUG}"
|
||||
|
||||
- name: Summary
|
||||
run: echo "Deployed to https://${HOST}" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Comment staging URL on PR
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
auth="Authorization: token ${GITHUB_TOKEN}"
|
||||
api="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}"
|
||||
pr=$(curl -fsS -H "$auth" "${api}/pulls?state=open&limit=50" \
|
||||
| jq -r --arg branch "$BRANCH" '.[] | select(.head.ref == $branch) | .number' | head -n1)
|
||||
if [ -z "$pr" ]; then
|
||||
echo "No open PR for branch '${BRANCH}', skipping comment"
|
||||
exit 0
|
||||
fi
|
||||
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:
|
||||
if: github.event_name == 'delete' && github.event.ref_type == 'branch'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
BRANCH: ${{ github.event.ref }}
|
||||
steps:
|
||||
- name: Remove staging container, volume, and image
|
||||
run: |
|
||||
SLUG=$(echo "$BRANCH" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9-]+/-/g; s/-+/-/g; s/^-//; s/-$//' | cut -c1-40)
|
||||
docker rm -f "timetracker-staging-${SLUG}" 2>/dev/null || true
|
||||
docker volume rm "timetracker-staging-${SLUG}" 2>/dev/null || true
|
||||
docker rmi "timetracker:staging-${SLUG}" 2>/dev/null || true
|
||||
@@ -19,6 +19,20 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: uv sync --frozen
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Install pnpm and JS dependencies
|
||||
run: corepack enable && pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Build TypeScript
|
||||
run: make ts
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: uv run playwright install --with-deps chromium
|
||||
|
||||
- name: Run Migrations
|
||||
run: uv run python manage.py migrate
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
name: Staging deployment
|
||||
|
||||
on:
|
||||
push:
|
||||
branches-ignore: [main]
|
||||
delete:
|
||||
|
||||
concurrency:
|
||||
group: staging-${{ github.event.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
env:
|
||||
BRANCH: ${{ github.ref_name }}
|
||||
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Compute staging name
|
||||
run: |
|
||||
SLUG=$(echo "$BRANCH" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9-]+/-/g; s/-+/-/g; s/^-//; s/-$//' | cut -c1-30)
|
||||
APP="timetracker-staging-${SLUG}"
|
||||
echo "SLUG=${SLUG}" >> "$GITHUB_ENV"
|
||||
echo "APP=${APP}" >> "$GITHUB_ENV"
|
||||
echo "HOST=${APP}.fly.dev" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up flyctl
|
||||
uses: superfly/flyctl-actions/setup-flyctl@master
|
||||
|
||||
- name: Create app if missing
|
||||
run: |
|
||||
if ! flyctl status --app "$APP" >/dev/null 2>&1; then
|
||||
flyctl apps create "$APP" --org personal
|
||||
fi
|
||||
|
||||
- name: Set staging secrets
|
||||
run: |
|
||||
# Per-app SECRET_KEY so each staging instance is independent and no
|
||||
# session cookie is shared across instances or with production.
|
||||
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 \
|
||||
"SECRET_KEY=${SECRET_KEY}" \
|
||||
"APP_URL=https://${HOST}"
|
||||
|
||||
- name: Deploy
|
||||
run: flyctl deploy --app "$APP" --config fly.staging.toml --remote-only --yes
|
||||
|
||||
- name: Summary
|
||||
run: echo "Deployed to https://${HOST}" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Comment staging URL on PR
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const host = process.env.HOST;
|
||||
const branch = process.env.BRANCH;
|
||||
const body = `Staging deployment: https://${host}`;
|
||||
const { owner, repo } = context.repo;
|
||||
const pulls = await github.rest.pulls.list({
|
||||
owner, repo, state: "open", head: `${owner}:${branch}`,
|
||||
});
|
||||
const pr = pulls.data[0];
|
||||
if (!pr) {
|
||||
core.info(`No open PR for branch '${branch}', skipping comment`);
|
||||
return;
|
||||
}
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner, repo, issue_number: pr.number,
|
||||
});
|
||||
if (comments.some((comment) => comment.body === body)) {
|
||||
core.info(`Staging URL already commented on PR #${pr.number}`);
|
||||
return;
|
||||
}
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: pr.number, body,
|
||||
});
|
||||
core.info(`Commented staging URL on PR #${pr.number}`);
|
||||
|
||||
teardown:
|
||||
if: github.event_name == 'delete' && github.event.ref_type == 'branch'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
BRANCH: ${{ github.event.ref }}
|
||||
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
|
||||
steps:
|
||||
- name: Set up flyctl
|
||||
uses: superfly/flyctl-actions/setup-flyctl@master
|
||||
|
||||
- name: Destroy staging app
|
||||
run: |
|
||||
SLUG=$(echo "$BRANCH" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9-]+/-/g; s/-+/-/g; s/^-//; s/-$//' | cut -c1-30)
|
||||
APP="timetracker-staging-${SLUG}"
|
||||
flyctl apps destroy "$APP" --yes 2>/dev/null || true
|
||||
+10
@@ -10,4 +10,14 @@ data/
|
||||
dist/
|
||||
.DS_Store
|
||||
.python-version
|
||||
|
||||
# Local configuration (may contain secrets); examples are committed instead
|
||||
.env
|
||||
/settings.ini
|
||||
.direnv
|
||||
.hermes/
|
||||
|
||||
# Build artifacts: generated in CI/Docker assets stage, not committed
|
||||
/games/static/base.css
|
||||
/games/static/js/dist/
|
||||
/ts/generated/
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Commands
|
||||
|
||||
| Task | Command |
|
||||
|------|---------|
|
||||
| Install dependencies | `make init` (installs Python via uv + npm packages, loads platform fixtures) |
|
||||
| Development server | `make dev` (runs Django runserver + Tailwind CSS watcher) |
|
||||
| Production-like dev | `make dev-prod` (Caddy + Gunicorn/Uvicorn + Django-Q cluster) |
|
||||
| Run tests | `make test` (or `uv run --with pytest-django pytest`) |
|
||||
| Make migrations | `make makemigrations` |
|
||||
| Apply migrations | `make migrate` |
|
||||
| CSS (Tailwind) | `make css` |
|
||||
| Django shell | `make shell` |
|
||||
| Create superuser | `make createsuperuser` |
|
||||
| Format Python | `make format` (or `uv run ruff format`) |
|
||||
| Lint Python | `make lint` (or `uv run ruff check`) |
|
||||
| Auto-fix lint | `make lint-fix` (`ruff check --fix`) |
|
||||
| Lint + format check + tests | `make check` (CI-style aggregate) |
|
||||
| Sync uv.lock | `uv sync` (after editing pyproject.toml) |
|
||||
| Load platform fixtures | `make loadplatforms` |
|
||||
| Load sample data | `make loadsample` |
|
||||
| Dump games data | `make dumpgames` |
|
||||
|
||||
## Architecture
|
||||
|
||||
A Django 6+ monolith (v1.7.0) with a single app (`games/`) for tracking video game purchases, play sessions, and statistics. Uses HTMX for interactivity with a pure-Python server-side component system, plus a Django Ninja REST API.
|
||||
|
||||
### Directory layout
|
||||
|
||||
```
|
||||
games/ — Django app: models, views, templates, forms, signals, tasks, API, filters
|
||||
common/ — Shared utilities: time formatting, component system, criteria, layout, icons
|
||||
timetracker/ — Django project: settings, URL root, ASGI/WSGI
|
||||
tests/ — Pytest tests
|
||||
e2e/ — Playwright browser tests (run via `make test-e2e`)
|
||||
contrib/ — One-off scripts (exchange rate import)
|
||||
docs/ — Additional documentation
|
||||
```
|
||||
|
||||
### Models (in `games/models.py`)
|
||||
|
||||
- **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)
|
||||
- **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`
|
||||
- **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)
|
||||
- **PlayEvent** — marks when a game was started/finished (separate from Sessions), `days_to_finish` is a `GeneratedField`
|
||||
- **ExchangeRate** — cached FX rates per currency pair per year
|
||||
- **GameStatusChange** — audit log of status transitions, ordered by `-timestamp`
|
||||
- **FilterPreset** — saved filter configuration; `mode` (games/sessions/purchases/playevents), `find_filter`, `object_filter`, `ui_options` (all JSON). Follows Stash's SavedFilter pattern
|
||||
|
||||
**Sentinel objects**: `get_sentinel_platform()` returns an "Unspecified" platform used when a Game has no platform. A similar sentinel Device ("Unknown") is created when a Session has no device.
|
||||
|
||||
**GeneratedField constraint**: `duration_calculated`, `duration_total`, `price_per_game`, `days_to_finish` are computed by the database and cannot be written from application code.
|
||||
|
||||
### Key patterns
|
||||
|
||||
**Layout system** (`common/layout.py`): Views call `render_page(request, content, title=...)` instead of Django's `render()`. This assembles a full HTML document via `Page()` — analogous to FastHTML's `fast_app()`. `Page()` handles the `<head>`, navbar, toast container, FOUC-prevention script, and **JS includes**: it calls `collect_media(content)` to gather every component's declared `Media` and emits the `<script>` tags automatically — so views do **not** pass `scripts=` for component-owned JS. The `scripts=` argument remains only for page-specific glue not owned by a reusable component (e.g. the add-form helper `add_*.js`). The navbar shows today's/last-7-days playtime from the `model_counts` context processor.
|
||||
|
||||
**Component system** (`common/components/`): a FastHTML-style **lazy node tree**. Components are `Node` objects that render to HTML only when asked (`str(node)` / `Page()`), so `Page()` can walk a finished tree and collect each component's JS. Split into submodules re-exported via `common/components/__init__.py`:
|
||||
|
||||
- **`core.py`** — the node layer. `Node` (base; `__html__`/`__str__` return a `SafeString`), `Element` (the single class for *any* HTML element), `Safe` (wraps pre-rendered/trusted HTML), `Fragment` (ordered children, no wrapper tag — use instead of `str(a)+str(b)`), `BaseComponent` (base for higher-level components: implement `render()`, declare `media`), and `Media` (declarative JS deps with order-preserving dedup merge; `collect_media()` sums them over a tree, `node.with_media(...)` attaches them). `_render_element()` is `@lru_cache`-memoized (4096). Attribute values are always escaped. **Children: every string child is escaped — `SafeText`/`mark_safe` included; only `Node` children (so `Safe`) render unescaped.** Trusted pre-rendered HTML must be wrapped in `Safe(...)`, never passed as a safe string. `randomid()` generates stable hash-based IDs.
|
||||
- **`primitives.py`** — Generic HTML. Plain leaf builders (`Div`, `Span`, `P`, `Ul`, `Li`, `Strong`, `Label`, `Template`, `Td`, `Tr`, `Th`) are **generated from a whitelist** via the `_html_element(tag)` factory over `Element` — not hand-written per tag. Builders that add classes/behaviour are written out: `A()`, `Button()`, `ButtonGroup()`, `Input()`, `Checkbox()`, `Radio()`, `Pill()`, `Icon()`, `Popover()`, `PopoverTruncated()`, `SearchField()`, `H1()`, `Modal()`, `SimpleTable()`, `TableRow()`, `TableTd()`, `TableHeader()`, `paginated_table_content()`, `AddForm()`, `YearPicker()` (declares datepicker media), `CsrfInput()`/`ModuleScript()`/`StaticScript()` (script-tag string helpers used by `Page()`).
|
||||
- **`domain.py`** — Domain-specific: `GameLink()`, `GameStatus()` (colored dot + label), `GameStatusSelector()` (Alpine.js PATCH dropdown), `SessionDeviceSelector()` (Alpine.js PATCH dropdown), `LinkedPurchase()`, `NameWithIcon()`, `PriceConverted()`, `PurchasePrice()`
|
||||
- **`filters.py`** — Filter UI: `FilterBar()`, `SessionFilterBar()`, `PurchaseFilterBar()` (built from `FilterSelect` widgets)
|
||||
- **`search_select.py`** — `SearchSelect()` (form combobox) + `FilterSelect()` (include/exclude filter combobox with pinned Any/None modifiers) + `SearchSelectOption`, all built on a shared `_combobox_shell`; wired by `games/static/js/search_select.js`
|
||||
|
||||
**Filter system** (`games/filters.py` + `common/criteria.py`): Stash-inspired structured filtering.
|
||||
|
||||
- `common/criteria.py` defines typed criterion classes: `StringCriterion`, `IntCriterion`, `FloatCriterion`, `DateCriterion`, `BoolCriterion`, `MultiCriterion`, `ChoiceCriterion`. Each has a `modifier` (`Modifier` enum: EQUALS, NOT_EQUALS, INCLUDES, EXCLUDES, GREATER_THAN, LESS_THAN, BETWEEN, IS_NULL, etc.) and a `to_q(field_name)` method.
|
||||
- `OperatorFilter` base class provides AND/OR/NOT sub-filter composition and JSON serialization/deserialization.
|
||||
- `games/filters.py` defines `GameFilter`, `SessionFilter`, `PurchaseFilter` (all `@dataclass` subclasses of `OperatorFilter`) and `FindFilter` (sort/pagination). Filters serialize to/from JSON and are passed in the `?filter=` query parameter.
|
||||
- `parse_game_filter()`, `parse_session_filter()`, `parse_purchase_filter()` helpers deserialize from a JSON string.
|
||||
- `FilterPreset` model stores named filter configurations that users can save and reload.
|
||||
|
||||
**Views** (`games/views/`): Function-based views decorated with `@login_required`. Organized by domain entity:
|
||||
|
||||
- `session.py`, `game.py`, `purchase.py`, `playevent.py`, `platform.py`, `device.py`, `statuschange.py` — CRUD for each entity
|
||||
- `general.py` — `stats()`, `stats_alltime()`, `index()`, `model_counts` context processor, `global_current_year` context processor, `use_custom_redirect` decorator (redirects to `request.session["return_path"]` if set)
|
||||
- `stats_data.py` — `compute_stats(year)` returns a `StatsData` TypedDict; pure computation, no HTTP
|
||||
- `stats_content.py` — renders stats page content from a `StatsData` dict
|
||||
- `filter_presets.py` — `list_presets`, `save_preset`, `delete_preset`, `load_preset`
|
||||
- `auth.py` — custom `LoginView` subclassing Django's auth view, renders login page via `render_page()`
|
||||
|
||||
**Signals** (`games/signals.py`):
|
||||
- `pre_save` on Purchase: snapshots old price/currency for change detection
|
||||
- `post_save` on Purchase: sets `needs_price_update` if price/currency changed
|
||||
- `m2m_changed` on Purchase.games: updates `num_purchases` count
|
||||
- `pre_delete` on Game: decrements `num_purchases` on related Purchases (deletes Purchase if count reaches 0)
|
||||
- `post_save/post_delete` on Session: recalculates `Game.playtime` from session aggregate
|
||||
- `pre_save` on Game: creates `GameStatusChange` audit records when `status` changes
|
||||
|
||||
**Background tasks**: django-q2 cluster runs `games.tasks.convert_prices()` on a schedule to fetch exchange rates from `cdn.jsdelivr.net/npm/@fawazahmed0/currency-api` and convert purchase prices to CZK.
|
||||
|
||||
**HTMX toast middleware** (`games/htmx_middleware.py`): Converts Django messages into `HX-Trigger` headers with `show-toast` event. Skips if `HX-Redirect` is present. Toast rendering is handled client-side by Alpine.js (`games/static/js/toast.js`).
|
||||
|
||||
**REST API** (`games/api.py`): Django Ninja with routers mounted at `/api/`:
|
||||
- `GET /api/games/search` — search games for autocomplete
|
||||
- `PATCH /api/games/{id}/status` — update game status
|
||||
- `GET/POST /api/playevent/` — list/create play events
|
||||
- `GET/PATCH/DELETE /api/playevent/{id}` — get/update/delete play event
|
||||
- `PATCH /api/session/{id}/device` — update session device
|
||||
|
||||
### Templates
|
||||
|
||||
Only a small number of HTML templates remain (platform icon snippets and partials). The bulk of the UI is built via Python components. Template files:
|
||||
|
||||
- `games/templates/icons/<slug>.html` — SVG icon snippets (loaded by `common/icons.py` via `get_icon()`)
|
||||
- `games/templates/` — minimal partials for HTMX responses where needed
|
||||
|
||||
### Frontend stack
|
||||
|
||||
- **HTMX** (`games/static/js/htmx.min.js`) — partial page updates
|
||||
- **Alpine.js** (vendored: `alpine.min.js`, `alpine-mask.min.js`) — reactive dropdowns (`GameStatusSelector`, `SessionDeviceSelector`), toast store
|
||||
- **Flowbite** (vendored: `flowbite.min.js`; `datepicker.umd.js` for the stats YearPicker) — navbar collapse, dropdown toggles
|
||||
- **Tailwind CSS** — utility classes, compiled from `common/input.css` → `games/static/base.css`
|
||||
- All third-party JS is served locally from `games/static/js/` (no CDNs), so pages and browser tests work offline
|
||||
- **Custom JS** in `games/static/js/`:
|
||||
- `toast.js` — Alpine.js toast store (listens for `show-toast` HTMX event); also defines `window.fetchWithHtmxTriggers`
|
||||
- `search_select.js` — SearchSelect/FilterSelect widgets (search-as-you-type, pills, include/exclude filter mode)
|
||||
- `utils.js` — shared ES-module helpers (`onSwap`, `toISOUTCString`, …)
|
||||
- **Widget initialization**: widget JS registers with `onSwap(selector, initializeElement)` from `utils.js` — a port of FastHTML's `proc_htmx` built on `htmx.onLoad`. It runs the initializer once per matching element, on initial page load and inside every htmx-swapped fragment. Never hand-roll `DOMContentLoaded`/`htmx:afterSwap` listeners with per-element guard flags.
|
||||
|
||||
### Interactive components: custom elements + TypeScript
|
||||
|
||||
New interactive components are **custom elements**, not inline JS in Python. A component that needs behavior emits a semantic tag via `custom_element("tag", Props(...))` (light DOM, server-rendered inner markup built with the htpy-style node builders). Behavior lives in `ts/elements/<tag>.ts` (TypeScript, vanilla DOM, `customElements.define`); the native `connectedCallback` replaces `onSwap` (it fires on parse *and* htmx swap). The server↔client contract is one Python `TypedDict` per element registered with `register_element(...)` in `common/components/custom_elements.py`; `manage.py gen_element_types` codegens `ts/generated/props.ts` (interface + attribute reader) so renaming a prop fails `tsc`.
|
||||
|
||||
- **Build:** `tsc` per-module (`tsconfig.json`) compiles `ts/` → `games/static/js/dist/` (build-only, gitignored). `make ts` = codegen + compile; `make ts-check` (in `make check`) = codegen + `tsc --noEmit`; `make dev` runs `tsc --watch`. The Docker image builds CSS + TS in a Node stage. Run `make ts` after editing any `.ts` so e2e/local serving sees fresh output.
|
||||
- **htpy-style markup:** generic builders take kwargs attributes and `[]` children — `Div(class_="x", hx_get="/y")[child1, child2]` (`class_`→`class`, `hx_get`→`hx-get`, `True`→bare attr, `False`/`None`→omitted). Still a walkable `Element` tree, so `Media` bubbles.
|
||||
- **Do NOT** author HTML/JS as Python f-strings or add new inline Alpine `x-data` blobs. Alpine remains only for trivial pre-existing toggles (toast store, etc.).
|
||||
- **Tables collect cell media:** `SimpleTable` stringifies cells, so it explicitly `collect_media`s its rows/header and re-attaches it — a custom element in a table cell still gets its `<script>` emitted by `Page()`.
|
||||
|
||||
### Deployment
|
||||
|
||||
Docker-based: multi-stage Dockerfile (uv builder → Node assets stage → slim runtime), Caddy as reverse proxy on port 8000, Gunicorn with UvicornWorker (ASGI), Supervisor to manage Caddy + Gunicorn + django-q2. `make dev-prod` mimics production locally. CI/CD via GitHub Actions (`.github/workflows/build-docker.yml`): builds Docker image; Drone CI (`.drone.yml`) also present for deployments via Portainer webhook.
|
||||
|
||||
**Package manager (pnpm):** front-end deps use **pnpm**, not npm. The pnpm version is pinned in `package.json`'s `packageManager` field and provisioned via **Corepack** (bundled with Node) — the Docker assets stage runs `corepack enable` rather than `npm install -g pnpm`. To bump pnpm, update the `packageManager` field; local, CI, and Docker all follow it. pnpm disables dependency lifecycle scripts by default (opt in via `pnpm.onlyBuiltDependencies`), so the project is unaffected by npm v12's install-script changes.
|
||||
|
||||
### Database
|
||||
|
||||
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
|
||||
|
||||
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`.
|
||||
|
||||
- **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`.
|
||||
- `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.
|
||||
- `DEBUG` defaults `True` (dev), turned off with `DEBUG=false`. `PROD` is a **deprecated alias** kept for one release.
|
||||
- `SECRET_KEY` is required in production (insecure default only in DEBUG); supports `SECRET_KEY__FILE`.
|
||||
- `APP_URL` derives `ALLOWED_HOSTS` and `CSRF_TRUSTED_ORIGINS` when those aren't set explicitly; the two are never merged (different security checks) and each can be overridden directly.
|
||||
- `TIME_ZONE` reads `TZ` (defaults `Europe/Prague` in debug, `UTC` in prod).
|
||||
- Django Admin, Debug Toolbar, and `django_extensions` are only available in `DEBUG` mode.
|
||||
- **Container/entrypoint-only** flags (`PUID`, `PGID`, `CREATE_DEFAULT_SUPERUSER`, `STAGING`, `LOAD_SAMPLE_DATA`) live in `entrypoint.sh`, not the Python config — they are bootstrap concerns, not Django settings.
|
||||
- django-q2 cluster: 1 worker, 60s timeout, 120s retry, ORM broker
|
||||
|
||||
### Testing
|
||||
|
||||
Tests live in `tests/`. Run with `make test` or `uv run --with pytest-django pytest`. Key test files:
|
||||
|
||||
- `test_components.py` — component rendering
|
||||
- `test_filter_bars.py`, `test_filter_helpers.py`, `test_filters.py` — filter system
|
||||
- `test_paths_return_200.py` — smoke test all list/view URLs
|
||||
- `test_rendered_pages.py` — HTML output of pages
|
||||
- `test_signals.py` — signal side-effects (playtime recalc, status change audit, etc.)
|
||||
- `test_stats.py` — stats computation
|
||||
- `test_streak.py`, `test_time.py`, `test_session_formatting.py` — utilities
|
||||
- `test_middleware_integration.py`, `test_toast_middleware.py` — HTMX middleware
|
||||
- `test_price_update.py` — currency conversion signals
|
||||
- `test_search_select.py` — SearchSelect component
|
||||
|
||||
Pytest settings are in `pyproject.toml` under `[tool.pytest.ini_options]` (`DJANGO_SETTINGS_MODULE = "timetracker.settings"`).
|
||||
|
||||
**Browser/E2E tests** live in `e2e/` and run with `make test-e2e` (`pytest-playwright` driving a real Chromium against pytest-django's `live_server`). `e2e/conftest.py` sets `DJANGO_ALLOW_ASYNC_UNSAFE` and prefers a system Chrome/Chromium; otherwise install browsers once via `uv run playwright install chromium`. All JS (including Alpine/Flowbite) is vendored in `games/static/js/`, so the tests run fully offline. Note that a bare `pytest` (`make test`) collects `e2e/` too, so it needs a browser as well. Key files: `test_widgets_e2e.py` (onSwap initialization lifecycle, FilterSelect/RangeSlider/add-purchase behavior), `test_search_select_e2e.py` (single-select edge cases on a synthetic page).
|
||||
|
||||
## Conventions for AI assistants
|
||||
|
||||
- **Never write to `GeneratedField`s** (`duration_calculated`, `duration_total`, `price_per_game`, `days_to_finish`). They are computed by the database.
|
||||
- **Name variables with complete words** — readable, unabbreviated identifiers in both Python and JavaScript (e.g. `template` not `tpl`, `event` not `e`, `element` not `el`, `removeButton` not `removeBtn`, `option`/`value` not single letters in loops). This applies to new code and to code you touch.
|
||||
- **Use `render_page()` not `render()`** for all full-page HTTP responses. Import from `common.layout`.
|
||||
- **Build UI with Python components** from `common.components`, not raw HTML strings or Django templates. `SafeText` children pass through unescaped; plain strings are auto-escaped.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **Platform icons** are SVG snippets in `games/templates/icons/<slug>.html`. Add new ones there and reference them by slug in `Platform.icon`.
|
||||
- **Name compound types explicitly** — if a `tuple`, `dict`, or other compound value is passed between functions or appears in multiple signatures, give it a named type (`TypedDict`, `NamedTuple`, or a `type` alias) rather than repeating the structural annotation. This applies even to small types used in only a few places; the name carries intent that the structure cannot. Examples: `LabeledOption = tuple[str, str]` instead of repeating `tuple[str, str]` for (value, label) pairs; `RangeValues(min, max)` instead of `tuple[str, str]` for range bounds.
|
||||
+23
@@ -15,6 +15,25 @@ COPY . .
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --frozen --no-dev
|
||||
|
||||
# Codegen the TypeScript prop contracts (needs Django); tsc compiles them in
|
||||
# the assets stage below.
|
||||
RUN uv run python manage.py gen_element_types
|
||||
|
||||
|
||||
# Front-end assets: Tailwind CSS + the TypeScript custom elements. Built here so
|
||||
# the compiled output ships in the image (dist/ is build-only, not committed).
|
||||
FROM node:22-bookworm-slim AS assets
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
# Corepack ships with Node and activates the pnpm version pinned in
|
||||
# package.json's "packageManager" field — no npm bootstrap needed.
|
||||
RUN corepack enable && pnpm install --frozen-lockfile --ignore-scripts
|
||||
COPY . .
|
||||
COPY --from=builder /home/timetracker/app/ts/generated ./ts/generated
|
||||
RUN pnpm tailwindcss -i ./common/input.css -o ./games/static/base.css \
|
||||
&& pnpm exec tsc
|
||||
|
||||
|
||||
FROM python:3.14-slim-bookworm
|
||||
|
||||
@@ -44,6 +63,10 @@ WORKDIR /home/timetracker/app
|
||||
|
||||
COPY --from=builder --chown=timetracker:timetracker /home/timetracker/app /home/timetracker/app
|
||||
|
||||
# Built front-end assets from the Node stage (Tailwind CSS + compiled TS).
|
||||
COPY --from=assets --chown=timetracker:timetracker /app/games/static/base.css /home/timetracker/app/games/static/base.css
|
||||
COPY --from=assets --chown=timetracker:timetracker /app/games/static/js/dist /home/timetracker/app/games/static/js/dist
|
||||
|
||||
COPY --chown=timetracker:timetracker Caddyfile /etc/caddy/Caddyfile
|
||||
COPY --chown=timetracker:timetracker supervisor.conf /etc/supervisor/conf.d/supervisor.conf
|
||||
COPY --chown=timetracker:timetracker entrypoint.sh /
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
all: css migrate
|
||||
|
||||
initialize: npm css migrate sethookdir loadplatforms
|
||||
initialize: npm css migrate loadplatforms
|
||||
|
||||
HTMLFILES := $(shell find games/templates -type f)
|
||||
PYTHON_VERSION = 3.12
|
||||
|
||||
npm:
|
||||
npm install
|
||||
pnpm install
|
||||
|
||||
css: common/input.css
|
||||
npx @tailwindcss/cli -i ./common/input.css -o ./games/static/base.css
|
||||
pnpm tailwindcss -i ./common/input.css -o ./games/static/base.css
|
||||
|
||||
makemigrations:
|
||||
uv run python manage.py makemigrations
|
||||
@@ -18,22 +17,30 @@ migrate: makemigrations
|
||||
uv run python manage.py migrate
|
||||
|
||||
init:
|
||||
uv install $(PYTHON_VERSION)
|
||||
uv python install $(PYTHON_VERSION)
|
||||
uv sync
|
||||
npm install
|
||||
$(MAKE) sethookdir
|
||||
pnpm install
|
||||
$(MAKE) loadplatforms
|
||||
|
||||
sethookdir:
|
||||
git config core.hooksPath .githooks
|
||||
chmod +x .githooks/*
|
||||
server:
|
||||
uv run python -Wa manage.py runserver
|
||||
|
||||
gen-element-types:
|
||||
uv run python manage.py gen_element_types
|
||||
|
||||
ts: gen-element-types
|
||||
pnpm exec tsc
|
||||
|
||||
ts-check: gen-element-types
|
||||
pnpm exec tsc --noEmit
|
||||
|
||||
dev:
|
||||
@npx concurrently \
|
||||
--names "Django,Tailwind" \
|
||||
--prefix-colors "blue,green" \
|
||||
@pnpm concurrently \
|
||||
--names "Django,Tailwind,TS" \
|
||||
--prefix-colors "blue,green,magenta" \
|
||||
"uv run python -Wa manage.py runserver" \
|
||||
"npx @tailwindcss/cli -i ./common/input.css -o ./games/static/base.css --watch"
|
||||
"pnpm tailwindcss -i ./common/input.css -o ./games/static/base.css --watch" \
|
||||
"pnpm exec tsc --watch"
|
||||
|
||||
|
||||
caddy:
|
||||
@@ -73,6 +80,23 @@ uv.lock: pyproject.toml
|
||||
test: uv.lock
|
||||
uv run --with pytest-django pytest
|
||||
|
||||
test-e2e: uv.lock
|
||||
uv run pytest e2e/
|
||||
|
||||
lint:
|
||||
uv run ruff check
|
||||
|
||||
lint-fix:
|
||||
uv run ruff check --fix
|
||||
|
||||
format:
|
||||
uv run ruff format
|
||||
|
||||
format-check:
|
||||
uv run ruff format --check
|
||||
|
||||
check: lint format-check ts-check test
|
||||
|
||||
date:
|
||||
uv run python -c 'import datetime; from zoneinfo import ZoneInfo; print(datetime.datetime.isoformat(datetime.datetime.now(ZoneInfo("Europe/Prague")), timespec="minutes", sep=" "))'
|
||||
|
||||
|
||||
@@ -4,12 +4,12 @@ A simple game catalogue and play session tracker.
|
||||
|
||||
# Development
|
||||
|
||||
The project uses `pyenv` to manage installed Python versions.
|
||||
If you have `pyenv` installed, you can simply run:
|
||||
The project uses `uv` to manage Python versions and dependencies.
|
||||
Simply run:
|
||||
|
||||
```
|
||||
make init
|
||||
```
|
||||
|
||||
This will make sure the correct Python version is installed, and it will install all dependencies using `poetry`.
|
||||
This installs the correct Python version, syncs all dependencies, and installs npm packages.
|
||||
Afterwards, you can start the development server using `make dev`.
|
||||
@@ -1,46 +0,0 @@
|
||||
# Suggested Improvements to common/components.py
|
||||
|
||||
## Completed
|
||||
|
||||
### Caching on template rendering
|
||||
- Added `functools.lru_cache` on `_render_cached()` wrapper around `render_to_string`
|
||||
- Cache key: `(template_path, json.dumps(context, sort_keys=True))` — deterministic and unique
|
||||
- `maxsize=4096` in production, disabled entirely in DEBUG mode (so template changes are reflected immediately)
|
||||
- Only caches `template` path calls; `tag_name` calls are already nanosecond string ops
|
||||
- Verified working: identical calls return identical output, different inputs produce separate cache entries
|
||||
|
||||
### Non-deterministic IDs
|
||||
`randomid()` was replaced with `hashlib.sha1(content_hash.encode()).hexdigest()[:10]` for deterministic ID generation.
|
||||
- `Popover()` passes content hash (`wrapped_content:popover_content:wrapped_classes`) so IDs are deterministic per unique content
|
||||
- `games/templatetags/randomid.py` uses the same hash-based approach
|
||||
- Fixes: caching (Popover output now cacheable), page consistency, thread safety
|
||||
|
||||
### Inconsistent return types
|
||||
All component functions now return `SafeText` and are annotated accordingly. Redundant `mark_safe()` wrappers removed from `LinkedPurchase()` and `NameWithIcon()`.
|
||||
|
||||
### Fragile A() URL resolution
|
||||
Replaced single `url` parameter with explicit `url_name` (URL pattern name resolved via `reverse()`) and `href` (literal path). Removed dead `Callable` type hint. `reverse()` now raises `NoReverseMatch` instead of silently falling back to literal text. Added mutual exclusion check — providing both parameters raises `ValueError`. Updated all 10 call sites across 6 view files and internal callers (`LinkedPurchase()`, `NameWithIcon()`).
|
||||
|
||||
### Toast XSS vulnerability
|
||||
The vulnerable `Toast()` component (which used unsafe string escaping for
|
||||
Alpine.js interpolation) had no callers and was deleted entirely. Toast display
|
||||
is handled by the existing event-driven pipeline: middleware → `HX-Trigger`
|
||||
headers → `show-toast` CustomEvent → Alpine store.
|
||||
|
||||
### Default mutable arguments
|
||||
All functions with mutable defaults (`attributes` and `children`) changed from `= []` to `| None = None` with `or []` conversion in the body.
|
||||
|
||||
What was fixed: `attributes: list[HTMLAttribute] = []` and `children: list[HTMLTag] | HTMLTag = []` are a classic Python gotcha — the default is shared across all callers and could silently corrupt state if ever mutated in place. Changed 8 functions (`Component`, `Popover`, `A`, `Button`, `Div`, `Input`, `Form`, `Icon`) to use the `None` sentinel pattern, preventing future bugs and eliminating linter warnings.
|
||||
|
||||
### NameWithIcon dead code and untestable design
|
||||
The `NameWithIcon()` function had a `platform` parameter that was immediately overwritten by `platform = None` and never used (dead code). The function mixed data lookup (database queries via IDs) with rendering, making it untestable.
|
||||
|
||||
**Fix**: Refactored `NameWithIcon()` to follow the `LinkedPurchase` pattern — accepts model objects (`Game`, `Session`) instead of IDs. Extracted `_resolve_name_with_icon()` helper for testable computation logic (name resolution, platform extraction, link creation). Fixed bug where `platform` was not extracted when `session` parameter was passed. Removed dead `platform` parameter from the public API. Updated all 3 production call sites (already using model objects). Added 10 unit tests for `_resolve_name_with_icon()` covering session override, custom names, linkify behavior, platform resolution, and edge cases. Updated 6 integration tests to use model-based parameters.
|
||||
|
||||
### No tests
|
||||
Zero test coverage for the entire component system.
|
||||
|
||||
**Fix**: Add unit tests for each component function — basic rendering, edge cases,
|
||||
and cache hit/miss verification.
|
||||
|
||||
**Done**: 96 unit tests covering all component functions (`Component`, `randomid`, `Popover`, `PopoverTruncated`, `A`, `Button`, `Div`, `Icon`, `Form`, `Input`, `NameWithIcon`, `LinkedPurchase`, `PurchasePrice`, `_render_cached`, `enable_cache`). Includes template rendering, deterministic ID generation, LRU cache behavior, HTML output validation, edge cases, error handling, and model-dependent integration tests.
|
||||
@@ -1,344 +0,0 @@
|
||||
import hashlib
|
||||
import json
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
from django.template import TemplateDoesNotExist
|
||||
from django.template.defaultfilters import floatformat
|
||||
from django.template.loader import render_to_string
|
||||
from django.urls import reverse
|
||||
from django.utils.safestring import SafeText, mark_safe
|
||||
|
||||
from common.utils import truncate
|
||||
from games.models import Game, Purchase, Session
|
||||
|
||||
HTMLAttribute = tuple[str, str | int | bool]
|
||||
HTMLTag = str
|
||||
|
||||
|
||||
def _render_cached_impl(template: str, context_json: str) -> str:
|
||||
context = json.loads(context_json)
|
||||
context["slot"] = mark_safe(context["slot"])
|
||||
return render_to_string(template, context)
|
||||
|
||||
|
||||
if not settings.DEBUG:
|
||||
_render_cached = lru_cache(maxsize=4096)(_render_cached_impl)
|
||||
else:
|
||||
_render_cached = _render_cached_impl
|
||||
|
||||
|
||||
def enable_cache():
|
||||
"""Wrap _render_cached with LRU cache (for testing in DEBUG mode)."""
|
||||
global _render_cached
|
||||
_render_cached = lru_cache(maxsize=4096)(_render_cached_impl)
|
||||
|
||||
|
||||
def Component(
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
template: str = "",
|
||||
tag_name: str = "",
|
||||
) -> SafeText:
|
||||
attributes = attributes or []
|
||||
children = children or []
|
||||
if not tag_name and not template:
|
||||
raise ValueError("One of template or tag_name is required.")
|
||||
if isinstance(children, str):
|
||||
children = [children]
|
||||
childrenBlob = "\n".join(children)
|
||||
if len(attributes) == 0:
|
||||
attributesBlob = ""
|
||||
else:
|
||||
attributesList = [f'{name}="{value}"' for name, value in attributes]
|
||||
# make attribute list into a string
|
||||
# and insert space between tag and attribute list
|
||||
attributesBlob = f" {' '.join(attributesList)}"
|
||||
tag: str = ""
|
||||
if tag_name != "":
|
||||
tag = f"<{tag_name}{attributesBlob}>{childrenBlob}</{tag_name}>"
|
||||
elif template != "":
|
||||
context = {name: value for name, value in attributes} | {"slot": "\n".join(children)}
|
||||
tag = _render_cached(template, json.dumps(context, sort_keys=True))
|
||||
return mark_safe(tag)
|
||||
|
||||
|
||||
def randomid(seed: str = "", content: str = "", length: int = 10) -> str:
|
||||
if not seed and not content:
|
||||
return seed
|
||||
hash_input = f"{seed}:{content}" if seed else content
|
||||
content_hash = hashlib.sha1(hash_input.encode()).hexdigest()
|
||||
base = content_hash[:length] if not seed else content_hash[:max(0, length - len(seed))]
|
||||
return seed + base
|
||||
|
||||
|
||||
def Popover(
|
||||
popover_content: str,
|
||||
wrapped_content: str = "",
|
||||
wrapped_classes: str = "",
|
||||
children: list[HTMLTag] | None = None,
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
) -> str:
|
||||
attributes = attributes or []
|
||||
children = children or []
|
||||
if not wrapped_content and not children:
|
||||
raise ValueError("One of wrapped_content or children is required.")
|
||||
id = randomid(content=f"{wrapped_content}:{popover_content}:{wrapped_classes}")
|
||||
return Component(
|
||||
attributes=attributes
|
||||
+ [
|
||||
("id", id),
|
||||
("wrapped_content", wrapped_content),
|
||||
("popover_content", popover_content),
|
||||
("wrapped_classes", wrapped_classes),
|
||||
],
|
||||
children=children,
|
||||
template="cotton/popover.html",
|
||||
)
|
||||
|
||||
|
||||
def PopoverTruncated(
|
||||
input_string: str,
|
||||
popover_content: str = "",
|
||||
popover_if_not_truncated: bool = False,
|
||||
length: int = 30,
|
||||
ellipsis: str = "…",
|
||||
endpart: str = "",
|
||||
) -> str:
|
||||
"""
|
||||
Returns `input_string` truncated after `length` of characters
|
||||
and displays the untruncated text in a popover HTML element.
|
||||
The truncated text ends in `ellipsis`, and optionally
|
||||
an always-visible `endpart` can be specified.
|
||||
`popover_content` can be specified if:
|
||||
1. It needs to be always displayed regardless if text is truncated.
|
||||
2. It needs to differ from `input_string`.
|
||||
"""
|
||||
if (truncated := truncate(input_string, length, ellipsis, endpart)) != input_string:
|
||||
return Popover(
|
||||
wrapped_content=truncated,
|
||||
popover_content=popover_content if popover_content else input_string,
|
||||
)
|
||||
else:
|
||||
if popover_content and popover_if_not_truncated:
|
||||
return Popover(
|
||||
wrapped_content=input_string,
|
||||
popover_content=popover_content if popover_content else "",
|
||||
)
|
||||
else:
|
||||
return input_string
|
||||
|
||||
|
||||
def A(
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
url_name: str | None = None,
|
||||
href: str | None = None,
|
||||
) -> SafeText:
|
||||
"""
|
||||
Returns an anchor <a> tag.
|
||||
|
||||
Accepts one of two mutually-exclusive URL specifications:
|
||||
- url_name: URL pattern name, resolved via reverse()
|
||||
- href: Literal path string passed through as-is
|
||||
"""
|
||||
attributes = attributes or []
|
||||
children = children or []
|
||||
if url_name is not None and href is not None:
|
||||
raise ValueError("Provide exactly one of 'url_name' or 'href', not both.")
|
||||
|
||||
additional_attributes = []
|
||||
if url_name is not None:
|
||||
additional_attributes = [("href", reverse(url_name))]
|
||||
elif href is not None:
|
||||
additional_attributes = [("href", href)]
|
||||
return Component(
|
||||
tag_name="a", attributes=attributes + additional_attributes, children=children
|
||||
)
|
||||
|
||||
|
||||
def Button(
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
size: str = "base",
|
||||
icon: bool = False,
|
||||
color: str = "blue",
|
||||
) -> SafeText:
|
||||
attributes = attributes or []
|
||||
children = children or []
|
||||
return Component(
|
||||
template="cotton/button.html",
|
||||
attributes=attributes
|
||||
+ [
|
||||
("size", size),
|
||||
("icon", icon),
|
||||
("color", color),
|
||||
("class", "hover:cursor-pointer"),
|
||||
],
|
||||
children=children,
|
||||
)
|
||||
|
||||
|
||||
def Div(
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
attributes = attributes or []
|
||||
children = children or []
|
||||
return Component(tag_name="div", attributes=attributes, children=children)
|
||||
|
||||
|
||||
def Input(
|
||||
type: str = "text",
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
attributes = attributes or []
|
||||
children = children or []
|
||||
return Component(
|
||||
tag_name="input", attributes=attributes + [("type", type)], children=children
|
||||
)
|
||||
|
||||
|
||||
def Form(
|
||||
action="",
|
||||
method="get",
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
attributes = attributes or []
|
||||
children = children or []
|
||||
return Component(
|
||||
tag_name="form",
|
||||
attributes=attributes + [("action", action), ("method", method)],
|
||||
children=children,
|
||||
)
|
||||
|
||||
|
||||
def Icon(
|
||||
name: str,
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
) -> SafeText:
|
||||
attributes = attributes or []
|
||||
try:
|
||||
result = Component(template=f"cotton/icon/{name}.html", attributes=attributes)
|
||||
except TemplateDoesNotExist:
|
||||
result = Icon(name="unspecified", attributes=attributes)
|
||||
return result
|
||||
|
||||
|
||||
def LinkedPurchase(purchase: Purchase) -> SafeText:
|
||||
link = reverse("games:view_purchase", args=[int(purchase.id)])
|
||||
link_content = ""
|
||||
popover_content = ""
|
||||
game_count = purchase.games.count()
|
||||
popover_if_not_truncated = False
|
||||
if game_count == 1:
|
||||
link_content += purchase.games.first().name
|
||||
popover_content = link_content
|
||||
if game_count > 1:
|
||||
if purchase.name:
|
||||
link_content += f"{purchase.name}"
|
||||
popover_content += f"<h1>{purchase.name}</h1><br>"
|
||||
else:
|
||||
link_content += f"{game_count} games"
|
||||
popover_if_not_truncated = True
|
||||
popover_content += f"""
|
||||
<ul class="list-disc list-inside">
|
||||
{"".join(f"<li>{game.name}</li>" for game in purchase.games.all())}
|
||||
</ul>
|
||||
"""
|
||||
icon = purchase.platform.icon if game_count == 1 else "unspecified"
|
||||
if link_content == "":
|
||||
raise ValueError("link_content is empty!!")
|
||||
a_content = Div(
|
||||
[("class", "inline-flex gap-2 items-center")],
|
||||
[
|
||||
Icon(
|
||||
icon,
|
||||
[("title", "Multiple")],
|
||||
),
|
||||
PopoverTruncated(
|
||||
input_string=link_content,
|
||||
popover_content=mark_safe(popover_content),
|
||||
popover_if_not_truncated=popover_if_not_truncated,
|
||||
),
|
||||
],
|
||||
)
|
||||
return A(href=link, children=[a_content])
|
||||
|
||||
|
||||
def NameWithIcon(
|
||||
name: str = "",
|
||||
game: Game | None = None,
|
||||
session: Session | None = None,
|
||||
linkify: bool = True,
|
||||
emulated: bool = False,
|
||||
) -> SafeText:
|
||||
_name, platform, final_emulated, create_link, link = _resolve_name_with_icon(
|
||||
name, game, session, linkify
|
||||
)
|
||||
|
||||
content = Div(
|
||||
[("class", "inline-flex gap-2 items-center")],
|
||||
[
|
||||
Icon(
|
||||
platform.icon,
|
||||
[("title", platform.name)],
|
||||
)
|
||||
if platform
|
||||
else "",
|
||||
Icon("emulated", [("title", "Emulated")]) if final_emulated else "",
|
||||
PopoverTruncated(_name),
|
||||
],
|
||||
)
|
||||
|
||||
return (
|
||||
A(
|
||||
href=link,
|
||||
children=[content],
|
||||
)
|
||||
if create_link
|
||||
else content
|
||||
)
|
||||
|
||||
|
||||
def _resolve_name_with_icon(
|
||||
name: str,
|
||||
game: Game | None,
|
||||
session: Session | None,
|
||||
linkify: bool,
|
||||
) -> tuple[str, Any, bool, bool, str]:
|
||||
create_link = False
|
||||
link = ""
|
||||
platform = None
|
||||
final_emulated = False
|
||||
|
||||
if session is not None:
|
||||
game = session.game
|
||||
platform = game.platform
|
||||
final_emulated = session.emulated
|
||||
if linkify:
|
||||
create_link = True
|
||||
link = reverse("games:view_game", args=[int(game.pk)])
|
||||
elif game is not None:
|
||||
platform = game.platform
|
||||
if linkify:
|
||||
create_link = True
|
||||
link = reverse("games:view_game", args=[int(game.pk)])
|
||||
|
||||
_name = name or (game.name if game else "")
|
||||
|
||||
return _name, platform, final_emulated, create_link, link
|
||||
|
||||
|
||||
def PurchasePrice(purchase) -> SafeText:
|
||||
return Popover(
|
||||
popover_content=f"{floatformat(purchase.price)} {purchase.price_currency}",
|
||||
wrapped_content=f"{floatformat(purchase.converted_price)} {purchase.converted_currency}",
|
||||
wrapped_classes="underline decoration-dotted",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
"""Server-side HTML component library.
|
||||
|
||||
Split into core / primitives / domain / filters submodules; this package
|
||||
re-exports the public API so ``from common.components import X`` keeps working.
|
||||
"""
|
||||
|
||||
from common.components.core import (
|
||||
BaseComponent,
|
||||
Element,
|
||||
Fragment,
|
||||
HTMLAttribute,
|
||||
HTMLTag,
|
||||
Media,
|
||||
Node,
|
||||
Safe,
|
||||
_render_element,
|
||||
collect_media,
|
||||
randomid,
|
||||
render,
|
||||
)
|
||||
from common.components.custom_elements import SessionTimestampButtons, register_element
|
||||
from common.components.date_range_picker import (
|
||||
DateRangeCalendar,
|
||||
DateRangeField,
|
||||
DateRangePicker,
|
||||
)
|
||||
from common.components.domain import (
|
||||
GameLink,
|
||||
GameStatus,
|
||||
GameStatusSelector,
|
||||
LinkedPurchase,
|
||||
NameWithIcon,
|
||||
PriceConverted,
|
||||
PurchasePrice,
|
||||
SessionDeviceSelector,
|
||||
_resolve_name_with_icon,
|
||||
)
|
||||
from common.components.filters import (
|
||||
DeviceFilterBar,
|
||||
FilterBar,
|
||||
PlatformFilterBar,
|
||||
PlayEventFilterBar,
|
||||
PurchaseFilterBar,
|
||||
SessionFilterBar,
|
||||
StringFilter,
|
||||
)
|
||||
from common.components.primitives import (
|
||||
H1,
|
||||
A,
|
||||
AddForm,
|
||||
ButtonGroup,
|
||||
Checkbox,
|
||||
CsrfInput,
|
||||
Div,
|
||||
ExternalScript,
|
||||
Icon,
|
||||
Input,
|
||||
Label,
|
||||
Li,
|
||||
Modal,
|
||||
ModuleScript,
|
||||
Pill,
|
||||
Popover,
|
||||
PopoverTruncated,
|
||||
Radio,
|
||||
SearchField,
|
||||
SimpleTable,
|
||||
Span,
|
||||
StaticScript,
|
||||
StyledButton,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableTd,
|
||||
Td,
|
||||
Template,
|
||||
Th,
|
||||
Tr,
|
||||
Ul,
|
||||
YearPicker,
|
||||
custom_element_builder,
|
||||
paginated_table_content,
|
||||
)
|
||||
from common.components.search_select import (
|
||||
DEFAULT_PREFETCH,
|
||||
FilterSelect,
|
||||
LabeledOption,
|
||||
SearchSelect,
|
||||
SearchSelectOption,
|
||||
searchselect_selected,
|
||||
)
|
||||
from common.utils import truncate
|
||||
|
||||
__all__ = [
|
||||
"truncate",
|
||||
"BaseComponent",
|
||||
"register_element",
|
||||
"SessionTimestampButtons",
|
||||
"custom_element_builder",
|
||||
"Element",
|
||||
"Fragment",
|
||||
"Media",
|
||||
"Node",
|
||||
"Safe",
|
||||
"collect_media",
|
||||
"render",
|
||||
"HTMLAttribute",
|
||||
"HTMLTag",
|
||||
"_render_element",
|
||||
"randomid",
|
||||
"A",
|
||||
"AddForm",
|
||||
"StyledButton",
|
||||
"ButtonGroup",
|
||||
"Checkbox",
|
||||
"CsrfInput",
|
||||
"Div",
|
||||
"ExternalScript",
|
||||
"H1",
|
||||
"Icon",
|
||||
"Input",
|
||||
"Modal",
|
||||
"ModuleScript",
|
||||
"Pill",
|
||||
"Popover",
|
||||
"PopoverTruncated",
|
||||
"Radio",
|
||||
"SearchField",
|
||||
"DEFAULT_PREFETCH",
|
||||
"FilterSelect",
|
||||
"LabeledOption",
|
||||
"SearchSelect",
|
||||
"SearchSelectOption",
|
||||
"searchselect_selected",
|
||||
"SimpleTable",
|
||||
"Span",
|
||||
"StaticScript",
|
||||
"Label",
|
||||
"Li",
|
||||
"Td",
|
||||
"Th",
|
||||
"Tr",
|
||||
"Ul",
|
||||
"TableHeader",
|
||||
"TableRow",
|
||||
"TableTd",
|
||||
"Template",
|
||||
"YearPicker",
|
||||
"paginated_table_content",
|
||||
"GameLink",
|
||||
"GameStatus",
|
||||
"GameStatusSelector",
|
||||
"LinkedPurchase",
|
||||
"NameWithIcon",
|
||||
"PriceConverted",
|
||||
"PurchasePrice",
|
||||
"SessionDeviceSelector",
|
||||
"_resolve_name_with_icon",
|
||||
"DateRangeCalendar",
|
||||
"DateRangeField",
|
||||
"DateRangePicker",
|
||||
"FilterBar",
|
||||
"PurchaseFilterBar",
|
||||
"SessionFilterBar",
|
||||
"DeviceFilterBar",
|
||||
"PlatformFilterBar",
|
||||
"PlayEventFilterBar",
|
||||
"StringFilter",
|
||||
]
|
||||
@@ -0,0 +1,353 @@
|
||||
"""Node layer: the lazy component tree, its renderer, and media collection.
|
||||
|
||||
A FastHTML-style model. Everything renderable is a :class:`Node`. The single
|
||||
:class:`Element` class represents *any* HTML element (tag + attrs + children);
|
||||
named builders like ``Div`` / ``Span`` are generated from a whitelist rather
|
||||
than hand-written per tag (see ``primitives.py``). Higher-level, behaviour- or
|
||||
media-bearing components subclass :class:`BaseComponent` and implement
|
||||
``render()`` returning a node subtree.
|
||||
|
||||
Nodes are *lazy*: they hold structure and render to HTML only when asked
|
||||
(``str(node)`` / ``node.__html__()`` / :func:`render`). This is what lets
|
||||
``Page()`` walk a finished tree and collect every component's declared JS
|
||||
(:class:`Media`) instead of each view threading ``scripts=`` by hand.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
from collections.abc import Sequence
|
||||
from functools import lru_cache
|
||||
|
||||
from django.utils.html import escape
|
||||
from django.utils.safestring import SafeText, mark_safe
|
||||
|
||||
|
||||
HTMLAttribute = tuple[str, str | int | bool]
|
||||
|
||||
|
||||
# Type for a builder's ``attributes`` parameter. Covariant ``Sequence`` so a
|
||||
# caller's ``list[tuple[str, str]]`` is accepted (a plain ``list[HTMLAttribute]``
|
||||
# would be invariant and reject it). Locals that get ``.append()``-ed should
|
||||
# stay a concrete ``list[HTMLAttribute]``.
|
||||
Attributes = Sequence[HTMLAttribute]
|
||||
|
||||
|
||||
HTMLTag = str
|
||||
|
||||
|
||||
# ── Media: declarative JS dependencies ──────────────────────────────────────
|
||||
|
||||
|
||||
def _dedup(*sequences: tuple[str, ...]) -> tuple[str, ...]:
|
||||
"""First-seen dedup that preserves declaration order across sequences."""
|
||||
seen: dict[str, None] = {}
|
||||
for sequence in sequences:
|
||||
for item in sequence:
|
||||
seen.setdefault(item, None)
|
||||
return tuple(seen)
|
||||
|
||||
|
||||
class Media:
|
||||
"""A component's JS dependencies, modelled on ``django.forms.Media``.
|
||||
|
||||
``js`` are static ES-module filenames (rendered as ``ModuleScript``);
|
||||
``js_external`` are vendored UMD / classic bundles (rendered as
|
||||
``StaticScript``). Addition merges with first-seen, order-preserving dedup,
|
||||
so a page that uses a component many times emits each script once.
|
||||
"""
|
||||
|
||||
__slots__ = ("js", "js_external")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
js: tuple[str, ...] | list[str] = (),
|
||||
js_external: tuple[str, ...] | list[str] = (),
|
||||
) -> None:
|
||||
self.js = tuple(js)
|
||||
self.js_external = tuple(js_external)
|
||||
|
||||
def __add__(self, other: "Media | None") -> "Media":
|
||||
if not other:
|
||||
return self
|
||||
return Media(
|
||||
_dedup(self.js, other.js),
|
||||
_dedup(self.js_external, other.js_external),
|
||||
)
|
||||
|
||||
def __radd__(self, other: "Media | None") -> "Media":
|
||||
# Supports ``sum(medias, Media())`` and ``0 + media``.
|
||||
if not other or other == 0:
|
||||
return self
|
||||
return other.__add__(self)
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return bool(self.js or self.js_external)
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
return (
|
||||
isinstance(other, Media)
|
||||
and self.js == other.js
|
||||
and self.js_external == other.js_external
|
||||
)
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash((self.js, self.js_external))
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"Media(js={self.js!r}, js_external={self.js_external!r})"
|
||||
|
||||
|
||||
# ── Node tree ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class Node:
|
||||
"""Base class for everything renderable to HTML."""
|
||||
|
||||
# Declared dependencies. Class-level default is shared and empty; concrete
|
||||
# components override with their own ``Media(...)``.
|
||||
media: Media = Media()
|
||||
|
||||
def _render(self) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
def collect_media(self) -> Media:
|
||||
"""Total media of this node and its subtree."""
|
||||
return self.media
|
||||
|
||||
def with_media(self, media: Media) -> "Node":
|
||||
"""Attach JS dependencies to this node and return it (for fluent use).
|
||||
|
||||
Lets a function-built node declare its media without becoming a full
|
||||
``BaseComponent`` subclass: ``return Div(...).with_media(Media(js=...))``.
|
||||
"""
|
||||
self.media = self.media + media
|
||||
return self
|
||||
|
||||
# A node's rendered output is always safe HTML by construction (Element
|
||||
# escapes unsafe children; Safe wraps trusted markup; Fragment escapes plain
|
||||
# strings). So both `__html__` (Django's conditional_escape hook) and
|
||||
# `__str__` return a SafeString — this is what keeps ``str(node)`` safe when
|
||||
# fed back into a child list or template, matching the old SafeText shims.
|
||||
def __html__(self) -> SafeText:
|
||||
return mark_safe(self._render())
|
||||
|
||||
def __str__(self) -> SafeText:
|
||||
return mark_safe(self._render())
|
||||
|
||||
|
||||
# A renderable child is a node or a string. Strings are ALWAYS escaped (a string
|
||||
# is untrusted text — ``SafeText``/``mark_safe`` is escaped too); trusted
|
||||
# pre-rendered HTML must be a ``Safe`` node. ``Children`` is the type for a
|
||||
# builder's ``children``
|
||||
# parameter: a sequence of child nodes/strings, a bare string, or nothing. The
|
||||
# sequence is a covariant ``Sequence`` so ``list[Element]`` / ``list[Node]`` are
|
||||
# accepted (a plain ``list[str]`` would be invariant and reject them). A single
|
||||
# bare ``Node`` is accepted only by ``Element`` itself (which wraps it); the
|
||||
# higher-level builders take ``Children``.
|
||||
Child = Node | str
|
||||
Children = Sequence[Child] | Node | str | None
|
||||
|
||||
|
||||
def as_children(children: Children) -> list[Child]:
|
||||
"""Normalise a builder's ``children`` argument to a flat list.
|
||||
|
||||
Accepts ``None`` (→ empty), a single node/string (→ one-element list), or a
|
||||
sequence of them. Lets builders drop the ``children if isinstance(children,
|
||||
list) else [children]`` dance and get a properly typed ``list[Child]``.
|
||||
"""
|
||||
if children is None:
|
||||
return []
|
||||
if isinstance(children, (str, Node)):
|
||||
return [children]
|
||||
return list(children)
|
||||
|
||||
|
||||
def as_attributes(attributes: "Attributes | None") -> list[HTMLAttribute]:
|
||||
"""Normalise an ``attributes`` argument to a mutable ``list[HTMLAttribute]``.
|
||||
|
||||
Builders take a covariant ``Attributes`` (so callers can pass a
|
||||
``list[tuple[str, str]]``) but often append to or concatenate the value;
|
||||
this turns it into a concrete list they can mutate.
|
||||
"""
|
||||
return list(attributes) if attributes else []
|
||||
|
||||
|
||||
def _child_key(child: object) -> tuple[str, bool]:
|
||||
"""Normalise a child to a ``(text, is_safe)`` pair.
|
||||
|
||||
Only :class:`Node` children render unescaped — that includes :class:`Safe`,
|
||||
the one sanctioned way to put trusted pre-rendered HTML into the tree. Every
|
||||
*string* child is escaped, ``SafeText``/``mark_safe`` included: a string is
|
||||
always treated as untrusted text, so trusted markup must be wrapped in
|
||||
``Safe(...)`` rather than smuggled in as a safe string. ``is_safe`` is part
|
||||
of the render cache key so a safe ``"<b>"`` and an unsafe ``"<b>"`` never
|
||||
collide.
|
||||
"""
|
||||
if isinstance(child, Node):
|
||||
return (child._render(), True)
|
||||
if isinstance(child, str):
|
||||
return (child, False)
|
||||
return (str(child), False)
|
||||
|
||||
|
||||
@lru_cache(maxsize=4096)
|
||||
def _render_element(
|
||||
tag_name: str,
|
||||
attrs_key: tuple[tuple[str, str], ...],
|
||||
children_key: tuple[tuple[str, bool], ...],
|
||||
) -> str:
|
||||
"""Pure, memoized HTML builder. Identical (tag, attrs, children) render once.
|
||||
|
||||
``attrs_key`` is (name, stringified value) pairs (values always escaped);
|
||||
``children_key`` is (text, is_safe) pairs (safe passes through, else escaped).
|
||||
"""
|
||||
children_blob = "\n".join(
|
||||
child if is_safe else escape(child) for child, is_safe in children_key
|
||||
)
|
||||
if attrs_key:
|
||||
attributes_blob = " " + " ".join(
|
||||
f'{name}="{escape(value)}"' for name, value in attrs_key
|
||||
)
|
||||
else:
|
||||
attributes_blob = ""
|
||||
return f"<{tag_name}{attributes_blob}>{children_blob}</{tag_name}>"
|
||||
|
||||
|
||||
class Element(Node):
|
||||
"""Any HTML element: a tag name, attributes and children.
|
||||
|
||||
Children may be other nodes, ``SafeText``, or plain strings (escaped).
|
||||
Rendering goes through the memoized :func:`_render_element`.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tag_name: str,
|
||||
attributes: Attributes | None = None,
|
||||
children: "Children | Node" = None,
|
||||
) -> None:
|
||||
if not tag_name:
|
||||
raise ValueError("tag_name is required.")
|
||||
self.tag_name = tag_name
|
||||
self.attributes = attributes or []
|
||||
if children is None:
|
||||
children = []
|
||||
elif isinstance(children, (str, Node)):
|
||||
children = [children]
|
||||
self.children = children
|
||||
|
||||
def __getitem__(self, children: "Children | Node") -> "Element":
|
||||
"""htpy-style children: ``Div(class_="x")[child1, child2]``.
|
||||
|
||||
Returns an Element with the same tag/attributes/media and these
|
||||
children, so the tree stays walkable (Media still bubbles)."""
|
||||
items = children if isinstance(children, tuple) else (children,)
|
||||
clone = Element(self.tag_name, self.attributes, list(items))
|
||||
clone.media = self.media
|
||||
return clone
|
||||
|
||||
def collect_media(self) -> Media:
|
||||
media = self.media
|
||||
for child in self.children:
|
||||
if isinstance(child, Node):
|
||||
media = media + child.collect_media()
|
||||
return media
|
||||
|
||||
def _render(self) -> str:
|
||||
attrs_key = tuple((name, str(value)) for name, value in self.attributes)
|
||||
children_key = tuple(_child_key(child) for child in self.children)
|
||||
return _render_element(self.tag_name, attrs_key, children_key)
|
||||
|
||||
|
||||
class Safe(Node):
|
||||
"""A node wrapping pre-rendered, trusted HTML (the ``mark_safe`` analogue).
|
||||
|
||||
Used as the migration bridge for components still built from f-strings:
|
||||
they return ``Safe(html)`` and declare their ``media`` explicitly rather
|
||||
than atomising their markup into a node tree up front.
|
||||
"""
|
||||
|
||||
def __init__(self, html: object, media: Media | None = None) -> None:
|
||||
self._html = str(html)
|
||||
if media is not None:
|
||||
self.media = media
|
||||
|
||||
def _render(self) -> str:
|
||||
return self._html
|
||||
|
||||
|
||||
class Fragment(Node):
|
||||
"""An ordered group of children with no wrapping tag.
|
||||
|
||||
Replaces ``mark_safe(str(a) + str(b))`` / ``"\\n".join(...)`` composition,
|
||||
so media still bubbles up from the grouped children.
|
||||
"""
|
||||
|
||||
def __init__(self, *children: object, separator: str = "") -> None:
|
||||
self.children = [c for c in children if c is not None and c != ""]
|
||||
self.separator = separator
|
||||
|
||||
def collect_media(self) -> Media:
|
||||
media = Media()
|
||||
for child in self.children:
|
||||
if isinstance(child, Node):
|
||||
media = media + child.collect_media()
|
||||
return media
|
||||
|
||||
def _render(self) -> str:
|
||||
parts = []
|
||||
for child in self.children:
|
||||
text, is_safe = _child_key(child)
|
||||
parts.append(text if is_safe else escape(text))
|
||||
return self.separator.join(parts)
|
||||
|
||||
|
||||
class BaseComponent(Node):
|
||||
"""Base for higher-level components: implement ``render()`` returning a node
|
||||
subtree and declare ``media`` (a :class:`Media`).
|
||||
|
||||
``render()`` is called once and memoized; ``collect_media()`` returns this
|
||||
component's own media merged with the rendered subtree's.
|
||||
"""
|
||||
|
||||
def render(self) -> Node:
|
||||
raise NotImplementedError
|
||||
|
||||
def _tree(self) -> Node:
|
||||
cached = getattr(self, "_tree_cache", None)
|
||||
if cached is None:
|
||||
cached = self.render()
|
||||
self._tree_cache = cached
|
||||
return cached
|
||||
|
||||
def _render(self) -> str:
|
||||
return self._tree()._render()
|
||||
|
||||
def collect_media(self) -> Media:
|
||||
return self.media + self._tree().collect_media()
|
||||
|
||||
|
||||
def render(node: "Node | str") -> SafeText:
|
||||
"""Render a node (or pass a string through) to safe HTML."""
|
||||
if isinstance(node, Node):
|
||||
return mark_safe(node._render())
|
||||
return mark_safe(str(node))
|
||||
|
||||
|
||||
def collect_media(node: "Node | str") -> Media:
|
||||
"""Collect the media of a node tree (empty for a bare string)."""
|
||||
if isinstance(node, Node):
|
||||
return node.collect_media()
|
||||
return Media()
|
||||
|
||||
|
||||
def randomid(seed: str = "", content: str = "", length: int = 10) -> str:
|
||||
if not seed and not content:
|
||||
return seed
|
||||
hash_input = f"{seed}:{content}" if seed else content
|
||||
content_hash = hashlib.sha1(hash_input.encode()).hexdigest()
|
||||
base = (
|
||||
content_hash[:length]
|
||||
if not seed
|
||||
else content_hash[: max(0, length - len(seed))]
|
||||
)
|
||||
return seed + base
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Custom-element builder, registry, and TypeScript codegen.
|
||||
|
||||
A custom element is a light-DOM Web Component: the Python builder emits a
|
||||
semantic tag whose typed props become kebab-case attributes and whose behavior
|
||||
lives in a compiled TS module (loaded via Media). One ``TypedDict`` per element
|
||||
is the single source of truth for the server<->client contract;
|
||||
``gen_element_types`` turns each registered spec into a TS interface + attribute
|
||||
reader so drift fails ``tsc``.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TypedDict, get_type_hints
|
||||
|
||||
from common.components.core import Media
|
||||
from common.components.primitives import custom_element_builder
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ElementSpec:
|
||||
tag: str # e.g. "game-status-selector"
|
||||
ts_name: str # e.g. "GameStatusSelector"
|
||||
props: type # a TypedDict subclass
|
||||
|
||||
|
||||
ELEMENT_REGISTRY: list[ElementSpec] = []
|
||||
|
||||
|
||||
def register_element(tag: str, ts_name: str, props: type) -> None:
|
||||
"""Register an element so codegen can emit its TS contract."""
|
||||
ELEMENT_REGISTRY.append(ElementSpec(tag, ts_name, props))
|
||||
|
||||
|
||||
def _kebab(name: str) -> str:
|
||||
return name.replace("_", "-")
|
||||
|
||||
|
||||
# ── Codegen ──────────────────────────────────────────────────────────────────
|
||||
|
||||
_TYPE_MAP = {int: "number", float: "number", str: "string", bool: "boolean"}
|
||||
|
||||
|
||||
def _camel(name: str) -> str:
|
||||
head, *tail = name.split("_")
|
||||
return head + "".join(part.title() for part in tail)
|
||||
|
||||
|
||||
def _reader_expr(name: str, python_type: type) -> str:
|
||||
attr = _kebab(name)
|
||||
if python_type in (int, float):
|
||||
return f'Number(el.getAttribute("{attr}"))'
|
||||
if python_type is bool:
|
||||
return f'el.getAttribute("{attr}") === "true"'
|
||||
return f'el.getAttribute("{attr}") ?? ""'
|
||||
|
||||
|
||||
def _ts_for_spec(spec: ElementSpec) -> str:
|
||||
hints = get_type_hints(spec.props)
|
||||
interface_lines = "\n".join(
|
||||
f" {_camel(name)}: {_TYPE_MAP[python_type]};"
|
||||
for name, python_type in hints.items()
|
||||
)
|
||||
reader_lines = "\n".join(
|
||||
f" {_camel(name)}: {_reader_expr(name, python_type)},"
|
||||
for name, python_type in hints.items()
|
||||
)
|
||||
return (
|
||||
f"export interface {spec.ts_name}Props {{\n{interface_lines}\n}}\n\n"
|
||||
f"export function read{spec.ts_name}Props(el: HTMLElement): "
|
||||
f"{spec.ts_name}Props {{\n return {{\n{reader_lines}\n }};\n}}"
|
||||
)
|
||||
|
||||
|
||||
def render_props_module() -> str:
|
||||
"""The full ``ts/generated/props.ts`` content for every registered element."""
|
||||
header = "// GENERATED by `manage.py gen_element_types` — do not edit.\n"
|
||||
blocks = [_ts_for_spec(spec) for spec in ELEMENT_REGISTRY]
|
||||
return header + "\n" + "\n\n".join(blocks) + "\n"
|
||||
|
||||
|
||||
# ── Element prop schemas (registered at import time) ─────────────────────────
|
||||
|
||||
|
||||
class GameStatusSelectorProps(TypedDict):
|
||||
game_id: int
|
||||
status: str
|
||||
csrf: str
|
||||
|
||||
|
||||
register_element("game-status-selector", "GameStatusSelector", GameStatusSelectorProps)
|
||||
|
||||
|
||||
class SessionDeviceSelectorProps(TypedDict):
|
||||
session_id: int
|
||||
csrf: str
|
||||
|
||||
|
||||
register_element(
|
||||
"session-device-selector", "SessionDeviceSelector", SessionDeviceSelectorProps
|
||||
)
|
||||
|
||||
|
||||
class PlayEventRowProps(TypedDict):
|
||||
game_id: int
|
||||
csrf: str
|
||||
api_create_url: str
|
||||
|
||||
|
||||
register_element("play-event-row", "PlayEventRow", PlayEventRowProps)
|
||||
|
||||
|
||||
class SessionTimestampButtonsProps(TypedDict):
|
||||
pass
|
||||
|
||||
|
||||
register_element(
|
||||
"session-timestamp-buttons", "SessionTimestampButtons", SessionTimestampButtonsProps
|
||||
)
|
||||
|
||||
|
||||
# ── Named tag builders (consistent htpy-style with Div/Span) ─────────────────
|
||||
# Underscore-prefixed: used internally by domain wrappers.
|
||||
# Public ones (no domain wrapper): exported directly.
|
||||
|
||||
_GameStatusSelector = custom_element_builder("game-status-selector")
|
||||
_SessionDeviceSelector = custom_element_builder("session-device-selector")
|
||||
_PlayEventRow = custom_element_builder("play-event-row")
|
||||
SessionTimestampButtons = custom_element_builder("session-timestamp-buttons")
|
||||
@@ -0,0 +1,354 @@
|
||||
"""DateRangePicker: a segmented date-range input with a calendar popup.
|
||||
|
||||
``DateRangePicker`` composes two parts:
|
||||
|
||||
- ``DateRangeField`` — the visible widget, styled as a single input. Each
|
||||
date is split into per-part segments (``DD``/``MM``/``YYYY``, ordered by
|
||||
``common.time.dateformat_hyphenated``) that the user fills digit by digit,
|
||||
plus a calendar icon that opens the popup.
|
||||
- ``DateRangeCalendar`` — the popup: a preset column (today, yesterday,
|
||||
last 7 days, …), a month grid rendered client-side, and a
|
||||
Cancel / Clear / Select footer.
|
||||
|
||||
The committed value lives in two hidden ISO-date inputs named
|
||||
``{input_name_prefix}-min`` / ``{input_name_prefix}-max`` — the same contract
|
||||
as the older ``DateRangeFilter``, so ``filter_bar.js`` serializes either
|
||||
widget into a ``DateCriterion`` unchanged. All behaviour is wired by
|
||||
``games/static/js/date_range_picker.js``.
|
||||
"""
|
||||
|
||||
from common.components.core import Element, HTMLAttribute, Media, Node, Safe
|
||||
from common.components.primitives import Div, Input, Span
|
||||
from common.time import DatePartSpec, date_parts
|
||||
|
||||
# Wired by date_range_picker.js.
|
||||
_DATE_RANGE_MEDIA = Media(js=("date_range_picker.js",))
|
||||
|
||||
_FIELD_CONTAINER_CLASS = (
|
||||
"flex items-center gap-0.5 w-full rounded-base border border-default-medium "
|
||||
"bg-neutral-secondary-medium text-sm text-heading p-1.5 cursor-text "
|
||||
"focus-within:ring-1 focus-within:ring-brand focus-within:border-brand"
|
||||
)
|
||||
|
||||
# The segments must not stand out from the container: transparent background,
|
||||
# no border, and only a subtle highlight when active (focused).
|
||||
_SEGMENT_INPUT_CLASS = (
|
||||
"bg-transparent border-0 p-0 text-center text-sm text-heading "
|
||||
"placeholder:text-body rounded-xs focus:outline-none focus:ring-0 "
|
||||
"focus:bg-brand/30 caret-transparent"
|
||||
)
|
||||
|
||||
_SEGMENT_WIDTH_CLASSES = {2: "w-[2.5ch]", 4: "w-[4.5ch]"}
|
||||
|
||||
_CALENDAR_ICON_SVG = (
|
||||
'<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" '
|
||||
'stroke="currentColor" aria-hidden="true">'
|
||||
'<path stroke-linecap="round" stroke-linejoin="round" '
|
||||
'd="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5'
|
||||
"A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5"
|
||||
"A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5"
|
||||
'A2.25 2.25 0 0 1 21 11.25v7.5"/>'
|
||||
"</svg>"
|
||||
)
|
||||
|
||||
_PRESET_OPTIONS: list[tuple[str, str]] = [
|
||||
("today", "Today"),
|
||||
("yesterday", "Yesterday"),
|
||||
("last_7_days", "Last 7 days"),
|
||||
("last_30_days", "Last 30 days"),
|
||||
("this_month", "This month"),
|
||||
("last_month", "Last month"),
|
||||
("this_year", "This year"),
|
||||
]
|
||||
|
||||
_PRESET_BUTTON_CLASS = (
|
||||
"px-3 py-1.5 text-sm text-start text-body hover:text-heading "
|
||||
"hover:bg-neutral-tertiary-medium rounded-base cursor-pointer whitespace-nowrap"
|
||||
)
|
||||
|
||||
_NAV_BUTTON_CLASS = (
|
||||
"p-1.5 text-body hover:text-heading hover:bg-neutral-tertiary-medium "
|
||||
"rounded-base cursor-pointer"
|
||||
)
|
||||
|
||||
_FOOTER_BUTTON_CLASS = (
|
||||
"px-3 py-1.5 text-sm font-medium rounded-base cursor-pointer "
|
||||
"text-heading bg-neutral-secondary-medium border border-default-medium "
|
||||
"hover:bg-neutral-tertiary-medium"
|
||||
)
|
||||
|
||||
_FOOTER_SELECT_BUTTON_CLASS = (
|
||||
"px-3 py-1.5 text-sm font-medium rounded-base cursor-pointer "
|
||||
"text-white bg-brand border border-transparent hover:bg-brand-strong"
|
||||
)
|
||||
|
||||
|
||||
def _iso_part_values(iso_value: str, parts: list[DatePartSpec]) -> dict[str, str]:
|
||||
"""Split an ISO ``YYYY-MM-DD`` string into per-part initial values.
|
||||
|
||||
Returns an empty mapping for empty/malformed input so a bad stored filter
|
||||
renders as empty segments instead of crashing."""
|
||||
if not iso_value:
|
||||
return {}
|
||||
pieces = iso_value.split("-")
|
||||
if len(pieces) != 3:
|
||||
return {}
|
||||
year, month, day = pieces
|
||||
values = {"year": year, "month": month, "day": day}
|
||||
if any(not values[part.name].isdigit() for part in parts):
|
||||
return {}
|
||||
return values
|
||||
|
||||
|
||||
def _segment_input(*, part: DatePartSpec, side: str, label: str, value: str) -> Node:
|
||||
side_label = "from" if side == "min" else "to"
|
||||
return Input(
|
||||
attributes=[
|
||||
("inputmode", "numeric"),
|
||||
("autocomplete", "off"),
|
||||
("maxlength", str(part.length)),
|
||||
("placeholder", part.placeholder),
|
||||
("value", value),
|
||||
("data-date-part", part.name),
|
||||
("data-date-side", side),
|
||||
("aria-label", f"{label} {side_label} {part.name}"),
|
||||
(
|
||||
"class",
|
||||
f"{_SEGMENT_INPUT_CLASS} "
|
||||
f"{_SEGMENT_WIDTH_CLASSES.get(part.length, 'w-[4.5ch]')}",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _segment_group(*, side: str, label: str, iso_value: str) -> Node:
|
||||
"""One date's worth of segments (``DD - MM - YYYY``) for a range side."""
|
||||
parts = date_parts()
|
||||
initial_values = _iso_part_values(iso_value, parts)
|
||||
children: list[Node] = []
|
||||
for index, part in enumerate(parts):
|
||||
if index > 0:
|
||||
children.append(
|
||||
Span(
|
||||
attributes=[("class", "text-body select-none")],
|
||||
children=["-"],
|
||||
)
|
||||
)
|
||||
children.append(
|
||||
_segment_input(
|
||||
part=part,
|
||||
side=side,
|
||||
label=label,
|
||||
value=initial_values.get(part.name, ""),
|
||||
)
|
||||
)
|
||||
return Span(
|
||||
attributes=[
|
||||
("class", "flex items-center gap-0.5"),
|
||||
("data-date-range-side", side),
|
||||
],
|
||||
children=children,
|
||||
)
|
||||
|
||||
|
||||
def DateRangeField(
|
||||
*,
|
||||
label: str,
|
||||
input_name_prefix: str,
|
||||
min_value: str = "",
|
||||
max_value: str = "",
|
||||
) -> Node:
|
||||
"""The visible half of the DateRangePicker: a single-input-looking
|
||||
container holding two segmented dates, a calendar toggle, and the two
|
||||
hidden ISO inputs (``{prefix}-min`` / ``{prefix}-max``) that carry the
|
||||
committed value to ``filter_bar.js``."""
|
||||
min_input_id = f"{input_name_prefix}-min"
|
||||
max_input_id = f"{input_name_prefix}-max"
|
||||
return Div(
|
||||
attributes=[
|
||||
("class", _FIELD_CONTAINER_CLASS),
|
||||
("data-date-range-field", ""),
|
||||
],
|
||||
children=[
|
||||
Input(
|
||||
type="hidden",
|
||||
attributes=[
|
||||
("name", min_input_id),
|
||||
("id", min_input_id),
|
||||
("value", min_value),
|
||||
("data-date-range-hidden", "min"),
|
||||
],
|
||||
),
|
||||
Input(
|
||||
type="hidden",
|
||||
attributes=[
|
||||
("name", max_input_id),
|
||||
("id", max_input_id),
|
||||
("value", max_value),
|
||||
("data-date-range-hidden", "max"),
|
||||
],
|
||||
),
|
||||
_segment_group(side="min", label=label, iso_value=min_value),
|
||||
Span(
|
||||
attributes=[("class", "text-body select-none px-0.5")],
|
||||
children=["–"],
|
||||
),
|
||||
_segment_group(side="max", label=label, iso_value=max_value),
|
||||
Element(
|
||||
"button",
|
||||
attributes=[
|
||||
("type", "button"),
|
||||
("data-date-range-calendar-toggle", ""),
|
||||
("aria-label", f"Open {label} calendar"),
|
||||
(
|
||||
"class",
|
||||
"ms-auto p-1 text-body hover:text-heading rounded "
|
||||
"cursor-pointer shrink-0",
|
||||
),
|
||||
],
|
||||
children=[Safe(_CALENDAR_ICON_SVG)],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _calendar_nav_button(direction: str, arrow: str, label: str) -> Node:
|
||||
return Element(
|
||||
"button",
|
||||
attributes=[
|
||||
("type", "button"),
|
||||
(f"data-date-range-{direction}", ""),
|
||||
("aria-label", label),
|
||||
("class", _NAV_BUTTON_CLASS),
|
||||
],
|
||||
children=[arrow],
|
||||
)
|
||||
|
||||
|
||||
def _footer_button(action: str, label: str, button_class: str) -> Node:
|
||||
return Element(
|
||||
"button",
|
||||
attributes=[
|
||||
("type", "button"),
|
||||
(f"data-date-range-{action}", ""),
|
||||
("class", button_class),
|
||||
],
|
||||
children=[label],
|
||||
)
|
||||
|
||||
|
||||
def DateRangeCalendar(*, input_name_prefix: str) -> Node:
|
||||
"""The popup half of the DateRangePicker: preset column, month grid
|
||||
(filled client-side into ``[data-date-range-grid]``), and the
|
||||
Cancel / Clear / Select footer. Hidden until the calendar toggle opens it."""
|
||||
preset_buttons = [
|
||||
Element(
|
||||
"button",
|
||||
attributes=[
|
||||
("type", "button"),
|
||||
("data-date-range-preset", preset_value),
|
||||
("class", _PRESET_BUTTON_CLASS),
|
||||
],
|
||||
children=[preset_label],
|
||||
)
|
||||
for preset_value, preset_label in _PRESET_OPTIONS
|
||||
]
|
||||
return Div(
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
"hidden absolute z-20 top-full start-0 mt-1 flex "
|
||||
"rounded-base border border-default-medium "
|
||||
"bg-neutral-secondary-medium shadow-lg",
|
||||
),
|
||||
("data-date-range-calendar", ""),
|
||||
("data-input-name-prefix", input_name_prefix),
|
||||
],
|
||||
children=[
|
||||
Div(
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
"flex flex-col gap-0.5 p-2 border-e border-default-medium",
|
||||
),
|
||||
("data-date-range-presets", ""),
|
||||
],
|
||||
children=preset_buttons,
|
||||
),
|
||||
Div(
|
||||
attributes=[("class", "p-2")],
|
||||
children=[
|
||||
Div(
|
||||
attributes=[
|
||||
("class", "flex items-center justify-between gap-2"),
|
||||
],
|
||||
children=[
|
||||
_calendar_nav_button("prev", "‹", "Previous month"),
|
||||
Span(
|
||||
attributes=[
|
||||
("class", "text-sm font-medium text-heading"),
|
||||
("data-date-range-month-label", ""),
|
||||
],
|
||||
),
|
||||
_calendar_nav_button("next", "›", "Next month"),
|
||||
],
|
||||
),
|
||||
Div(
|
||||
attributes=[
|
||||
("class", "grid grid-cols-7 gap-y-0.5 mt-1"),
|
||||
("data-date-range-grid", ""),
|
||||
],
|
||||
),
|
||||
Div(
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
"flex justify-end gap-2 mt-2 pt-2 border-t "
|
||||
"border-default-medium",
|
||||
),
|
||||
],
|
||||
children=[
|
||||
_footer_button("cancel", "Cancel", _FOOTER_BUTTON_CLASS),
|
||||
_footer_button("clear", "Clear", _FOOTER_BUTTON_CLASS),
|
||||
_footer_button(
|
||||
"select", "Select", _FOOTER_SELECT_BUTTON_CLASS
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def DateRangePicker(
|
||||
*,
|
||||
label: str,
|
||||
input_name_prefix: str,
|
||||
min_value: str = "",
|
||||
max_value: str = "",
|
||||
) -> Node:
|
||||
"""A date-range widget: segmented manual entry plus a calendar popup.
|
||||
|
||||
Drop-in replacement for ``DateRangeFilter`` — exposes the same hidden
|
||||
``{prefix}-min`` / ``{prefix}-max`` ISO inputs, so the filter-bar
|
||||
serializer needs no changes. ``min_value`` / ``max_value`` are ISO
|
||||
``YYYY-MM-DD`` strings used to prefill both the segments and the hidden
|
||||
inputs."""
|
||||
attributes: list[HTMLAttribute] = [
|
||||
("class", "date-range-picker relative"),
|
||||
("data-date-range-picker", ""),
|
||||
("data-input-name-prefix", input_name_prefix),
|
||||
]
|
||||
return Div(
|
||||
attributes=attributes,
|
||||
children=[
|
||||
DateRangeField(
|
||||
label=label,
|
||||
input_name_prefix=input_name_prefix,
|
||||
min_value=min_value,
|
||||
max_value=max_value,
|
||||
),
|
||||
DateRangeCalendar(input_name_prefix=input_name_prefix),
|
||||
],
|
||||
).with_media(_DATE_RANGE_MEDIA)
|
||||
@@ -0,0 +1,308 @@
|
||||
"""Domain components for games / purchases / sessions."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from django.template.defaultfilters import floatformat
|
||||
from django.urls import reverse
|
||||
|
||||
from common.components.core import Children, Node, Safe, as_children
|
||||
from common.components.primitives import (
|
||||
A,
|
||||
Div,
|
||||
Icon,
|
||||
Popover,
|
||||
PopoverTruncated,
|
||||
Span,
|
||||
)
|
||||
from games.models import Game, Purchase, Session
|
||||
|
||||
|
||||
def GameLink(
|
||||
game_id: int,
|
||||
name: str = "",
|
||||
children: Children = None,
|
||||
) -> Node:
|
||||
"""Link to a game's detail page. Uses children (slot) if provided, otherwise name."""
|
||||
from django.urls import reverse
|
||||
|
||||
display = as_children(children) or [name]
|
||||
link = reverse("games:view_game", args=[game_id])
|
||||
|
||||
return Span(
|
||||
attributes=[("class", "truncate-container")],
|
||||
children=[
|
||||
A(
|
||||
href=link,
|
||||
attributes=[
|
||||
("class", "underline decoration-slate-500 sm:decoration-2"),
|
||||
],
|
||||
children=display,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
_STATUS_COLORS = {
|
||||
"u": "bg-gray-500",
|
||||
"p": "bg-orange-400",
|
||||
"f": "bg-green-500",
|
||||
"a": "bg-red-500",
|
||||
"r": "bg-purple-500",
|
||||
}
|
||||
|
||||
|
||||
def GameStatus(
|
||||
children: Children = None,
|
||||
status: str = "u",
|
||||
display: str = "",
|
||||
class_: str = "",
|
||||
) -> Node:
|
||||
"""Colored status dot with label. Status codes: u/p/f/a/r."""
|
||||
children = children or []
|
||||
outer_class = (
|
||||
f"{'flex' if display == 'flex' else 'inline-flex'} "
|
||||
"gap-2 items-center align-middle"
|
||||
)
|
||||
if class_:
|
||||
outer_class += f" {class_}"
|
||||
dot_color = _STATUS_COLORS.get(status, _STATUS_COLORS["u"])
|
||||
|
||||
dot = Span(
|
||||
attributes=[("class", f"rounded-xl w-3 h-3 {dot_color}")],
|
||||
children=["\xa0"],
|
||||
)
|
||||
|
||||
return Span(
|
||||
attributes=[("class", outer_class)],
|
||||
children=[dot] + as_children(children),
|
||||
)
|
||||
|
||||
|
||||
def PriceConverted(
|
||||
children: Children = None,
|
||||
) -> Node:
|
||||
"""Wrap content in a span that indicates the price was converted."""
|
||||
children = children or []
|
||||
return Span(
|
||||
attributes=[
|
||||
("title", "Price is a result of conversion and rounding."),
|
||||
("class", "decoration-dotted underline"),
|
||||
],
|
||||
children=as_children(children),
|
||||
)
|
||||
|
||||
|
||||
def LinkedPurchase(purchase: Purchase) -> Node:
|
||||
link = reverse("games:view_purchase", args=[int(purchase.id)])
|
||||
link_content = ""
|
||||
popover_content = ""
|
||||
game_count = purchase.games.count()
|
||||
popover_if_not_truncated = False
|
||||
if game_count == 1:
|
||||
link_content += purchase.games.first().name
|
||||
popover_content = link_content
|
||||
if game_count > 1:
|
||||
if purchase.name:
|
||||
link_content += f"{purchase.name}"
|
||||
popover_content += f"<h1>{purchase.name}</h1><br>"
|
||||
else:
|
||||
link_content += f"{game_count} games"
|
||||
popover_if_not_truncated = True
|
||||
popover_content += f"""
|
||||
<ul class="list-disc list-inside">
|
||||
{"".join(f"<li>{game.name}</li>" for game in purchase.games.all())}
|
||||
</ul>
|
||||
"""
|
||||
icon = (
|
||||
(purchase.platform.icon if purchase.platform else "unspecified")
|
||||
if game_count == 1
|
||||
else "unspecified"
|
||||
)
|
||||
if link_content == "":
|
||||
raise ValueError("link_content is empty!!")
|
||||
a_content = Div(
|
||||
[("class", "inline-flex gap-2 items-center")],
|
||||
[
|
||||
Icon(
|
||||
icon,
|
||||
[("title", "Multiple")],
|
||||
),
|
||||
PopoverTruncated(
|
||||
input_string=link_content,
|
||||
popover_content=Safe(popover_content),
|
||||
popover_if_not_truncated=popover_if_not_truncated,
|
||||
),
|
||||
],
|
||||
)
|
||||
return A(href=link, children=[a_content])
|
||||
|
||||
|
||||
def NameWithIcon(
|
||||
name: str = "",
|
||||
game: Game | None = None,
|
||||
session: Session | None = None,
|
||||
linkify: bool = True,
|
||||
emulated: bool = False,
|
||||
) -> Node:
|
||||
_name, platform, final_emulated, create_link, link = _resolve_name_with_icon(
|
||||
name, game, session, linkify
|
||||
)
|
||||
|
||||
content = Div(
|
||||
[("class", "inline-flex gap-2 items-center")],
|
||||
[
|
||||
Icon(
|
||||
platform.icon,
|
||||
[("title", platform.name)],
|
||||
)
|
||||
if platform
|
||||
else "",
|
||||
Icon("emulated", [("title", "Emulated")]) if final_emulated else "",
|
||||
PopoverTruncated(_name),
|
||||
],
|
||||
)
|
||||
|
||||
return (
|
||||
A(
|
||||
href=link,
|
||||
children=[content],
|
||||
)
|
||||
if create_link
|
||||
else content
|
||||
)
|
||||
|
||||
|
||||
def _resolve_name_with_icon(
|
||||
name: str,
|
||||
game: Game | None,
|
||||
session: Session | None,
|
||||
linkify: bool,
|
||||
) -> tuple[str, Any, bool, bool, str]:
|
||||
create_link = False
|
||||
link = ""
|
||||
platform = None
|
||||
final_emulated = False
|
||||
|
||||
if session is not None:
|
||||
game = session.game
|
||||
platform = game.platform
|
||||
final_emulated = session.emulated
|
||||
if linkify:
|
||||
create_link = True
|
||||
link = reverse("games:view_game", args=[int(game.pk)])
|
||||
elif game is not None:
|
||||
platform = game.platform
|
||||
if linkify:
|
||||
create_link = True
|
||||
link = reverse("games:view_game", args=[int(game.pk)])
|
||||
|
||||
_name = name or (game.name if game else "")
|
||||
|
||||
return _name, platform, final_emulated, create_link, link
|
||||
|
||||
|
||||
def PurchasePrice(purchase) -> Node:
|
||||
return Popover(
|
||||
popover_content=f"{floatformat(purchase.price)} {purchase.price_currency}",
|
||||
wrapped_content=f"{floatformat(purchase.converted_price)} {purchase.converted_currency}",
|
||||
wrapped_classes="underline decoration-dotted",
|
||||
)
|
||||
|
||||
|
||||
_SELECTOR_MENU_CLASS = (
|
||||
"absolute top-[105%] left-0 w-full whitespace-nowrap z-10 text-sm "
|
||||
"font-medium bg-gray-800/20 backdrop-blur-lg rounded-md rounded-t-none "
|
||||
"border border-gray-200 dark:border-gray-700"
|
||||
)
|
||||
_SELECTOR_TOGGLE_CLASS = (
|
||||
"relative px-4 py-2 text-sm font-medium bg-white border border-gray-200 "
|
||||
"rounded-lg hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-700 "
|
||||
"dark:hover:text-white dark:hover:bg-gray-700 hover:cursor-pointer"
|
||||
)
|
||||
_SELECTOR_OPTION_CLASS = (
|
||||
"block w-full text-left px-4 py-2 rounded-sm cursor-pointer "
|
||||
"hover:bg-gray-700 hover:text-white dark:hover:bg-gray-700 "
|
||||
"dark:hover:text-white border-0"
|
||||
)
|
||||
|
||||
|
||||
def GameStatusSelector(game, game_statuses, csrf_token: str) -> Node:
|
||||
"""Light-DOM custom element; behavior in ts/elements/game-status-selector.ts."""
|
||||
from common.components.core import Element
|
||||
from common.components.custom_elements import _GameStatusSelector, GameStatusSelectorProps
|
||||
from common.components.primitives import Li, Ul
|
||||
|
||||
options = [
|
||||
Li()[
|
||||
Element(
|
||||
"button",
|
||||
[
|
||||
("type", "button"),
|
||||
("data-option", ""),
|
||||
("data-value", str(value)),
|
||||
("class", _SELECTOR_OPTION_CLASS),
|
||||
],
|
||||
GameStatus(status=value, children=[label], display="flex"),
|
||||
)
|
||||
]
|
||||
for value, label in game_statuses
|
||||
]
|
||||
current_label = Span(data_label="")[
|
||||
GameStatus(
|
||||
status=game.status,
|
||||
children=[game.get_status_display()],
|
||||
display="flex",
|
||||
)
|
||||
]
|
||||
toggle = Element(
|
||||
"button",
|
||||
[("type", "button"), ("data-toggle", ""), ("class", _SELECTOR_TOGGLE_CLASS)],
|
||||
Span(class_="flex flex-row gap-4 justify-between items-center")[
|
||||
current_label, Icon("arrowdown")
|
||||
],
|
||||
)
|
||||
menu = Div(data_menu="", hidden=True, class_=_SELECTOR_MENU_CLASS)[Ul()[*options]]
|
||||
dropdown = Div(
|
||||
data_dropdown="", class_="inline-flex rounded-md shadow-2xs relative"
|
||||
)[toggle, menu]
|
||||
return _GameStatusSelector(game_id=game.id, status=game.status, csrf=csrf_token)[
|
||||
Div(class_="flex gap-2 items-center")[dropdown]
|
||||
]
|
||||
|
||||
|
||||
def SessionDeviceSelector(session, session_devices, csrf_token: str) -> Node:
|
||||
"""Light-DOM custom element; behavior in ts/elements/session-device-selector.ts."""
|
||||
from common.components.core import Element
|
||||
from common.components.custom_elements import _SessionDeviceSelector, SessionDeviceSelectorProps
|
||||
from common.components.primitives import Li, Ul
|
||||
|
||||
current_name = session.device.name if session.device else "Unknown"
|
||||
options = [
|
||||
Li()[
|
||||
Element(
|
||||
"button",
|
||||
[
|
||||
("type", "button"),
|
||||
("data-option", ""),
|
||||
("data-value", str(device.id)),
|
||||
("class", _SELECTOR_OPTION_CLASS),
|
||||
],
|
||||
children=[device.name],
|
||||
)
|
||||
]
|
||||
for device in session_devices
|
||||
]
|
||||
toggle = Element(
|
||||
"button",
|
||||
[("type", "button"), ("data-toggle", ""), ("class", _SELECTOR_TOGGLE_CLASS)],
|
||||
Span(class_="flex flex-row gap-4 justify-between items-center")[
|
||||
Span(data_label="")[current_name], Icon("arrowdown")
|
||||
],
|
||||
)
|
||||
menu = Div(data_menu="", hidden=True, class_=_SELECTOR_MENU_CLASS)[Ul()[*options]]
|
||||
dropdown = Div(
|
||||
data_dropdown="", class_="inline-flex rounded-md shadow-2xs relative"
|
||||
)[toggle, menu]
|
||||
return _SessionDeviceSelector(session_id=session.id, csrf=csrf_token)[
|
||||
Div(class_="flex gap-2 items-center")[dropdown]
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,576 @@
|
||||
"""Search field + dropdown select component (pure Python, domain-agnostic).
|
||||
|
||||
Pairs a search box with a dropdown of options. Supports single/multi select;
|
||||
in multi-select, chosen items render as removable ``Pill``s, each backed by a
|
||||
hidden ``<input>`` so an existing ``ModelMultipleChoiceField`` keeps validating.
|
||||
|
||||
This module imports only from ``common.components`` — it has no Django-forms or
|
||||
``games`` knowledge. Styling is inline Tailwind utilities; behavioural hooks are
|
||||
``data-*`` attributes wired up by ``games/static/js/search_select.js``.
|
||||
|
||||
Option sourcing follows two axes. *Population*: options are either rendered
|
||||
inline up front (``options=``, no ``search_url``) or fetched from ``search_url``.
|
||||
*Completeness*: without a ``search_url`` the inline set is the whole dataset and
|
||||
filtering is purely client-side; with a ``search_url`` the loaded rows are a
|
||||
window, so the JS filters the loaded rows instantly on each keystroke while
|
||||
issuing a debounced server request for the rest. ``prefetch`` (rows to load on
|
||||
first open, ``0`` = none) seeds that window so the panel is populated before the
|
||||
user types.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable, Iterable
|
||||
from typing import TypedDict
|
||||
|
||||
|
||||
from common.components.core import Attributes, Element, HTMLAttribute, Media, Node
|
||||
from common.components.primitives import Div, Input, Pill, Span, Template
|
||||
|
||||
# Both comboboxes are wired by search_select.js.
|
||||
_SEARCH_SELECT_MEDIA = Media(js=("search_select.js",))
|
||||
|
||||
|
||||
class SearchSelectOption(TypedDict):
|
||||
value: str | int
|
||||
label: str
|
||||
data: dict[str, str] # becomes data-* attrs on the row / pill
|
||||
|
||||
|
||||
# A lightweight (value, label) pair used wherever only those two fields are
|
||||
# needed — e.g. filter pill lists and modifier pseudo-options. The richer
|
||||
# SearchSelectOption adds a ``data`` dict for extra row attributes.
|
||||
LabeledOption = tuple[str, str]
|
||||
|
||||
|
||||
# The pills and the search box share one flex-wrap row (with padding) so the
|
||||
# widget reads as a single clickable field; the pills wrapper uses `contents`
|
||||
# so its pills/hidden inputs flow as direct participants of that row, inline
|
||||
# with the search input. The options panel is absolute, so it sits outside the
|
||||
# flex flow. (border omitted intentionally — see if it's needed later.)
|
||||
_CONTAINER_CLASS = (
|
||||
"relative flex flex-wrap items-center gap-1 p-2 "
|
||||
"rounded-base bg-neutral-secondary-medium"
|
||||
)
|
||||
_PILLS_CLASS = "contents"
|
||||
_SEARCH_CLASS = (
|
||||
"flex-1 min-w-[8rem] border-0 bg-transparent text-sm text-heading "
|
||||
"focus:ring-0 focus:outline-hidden placeholder:text-body"
|
||||
)
|
||||
# top-full anchors the panel to the container's bottom edge: as an absolutely
|
||||
# positioned child of the flex field, its static position would otherwise be
|
||||
# centered by items-center and overlap the search box.
|
||||
_OPTIONS_CLASS = (
|
||||
"absolute z-10 top-full left-0 right-0 mt-1 overflow-y-auto "
|
||||
"border border-default-medium rounded-base bg-neutral-secondary-medium shadow-lg"
|
||||
)
|
||||
_OPTION_ROW_CLASS = (
|
||||
"px-3 py-2 text-sm text-heading cursor-pointer "
|
||||
"hover:bg-brand/15 data-[search-select-highlighted]:bg-brand/15"
|
||||
)
|
||||
_NO_RESULTS_CLASS = "px-3 py-2 text-sm italic text-body hidden"
|
||||
|
||||
# Approximate rendered height of one option row (px-3 py-2 text-sm) in rem,
|
||||
# used to derive the panel's max-height from items_visible.
|
||||
_ROW_HEIGHT_REM = 2.25
|
||||
|
||||
# Default number of rows to fetch on first focus when a search_url is set.
|
||||
# Shared by filter and form widgets so the dropdown is populated for keyboard
|
||||
# navigation as soon as the user opens it.
|
||||
DEFAULT_PREFETCH = 20
|
||||
|
||||
# ── FilterSelect styling ───────────────────────────────────────────────────
|
||||
# Inline class strings (ported verbatim from the retired SelectableFilter CSS)
|
||||
# so the filter combobox is fully self-styled — nothing in input.css. JS-added
|
||||
# rows/pills are cloned from server-rendered <template>s, so these strings live
|
||||
# only here — never duplicated in search_select.js. The keyboard-highlighted
|
||||
# state is expressed via Tailwind `data-[search-select-highlighted]` and
|
||||
# `group-data-[search-select-highlighted]` variants on the row/label/button
|
||||
# classes below; the JS only toggles the data attribute on the row.
|
||||
_FILTER_INCLUDE_PILL_CLASS = (
|
||||
"inline-flex items-center gap-1 px-2 py-0.5 text-sm rounded "
|
||||
"bg-brand/15 text-heading"
|
||||
)
|
||||
_FILTER_EXCLUDE_PILL_CLASS = (
|
||||
"inline-flex items-center gap-1 px-2 py-0.5 text-sm rounded "
|
||||
"bg-red-500/15 text-red-600 line-through decoration-red-400"
|
||||
)
|
||||
_FILTER_MODIFIER_PILL_CLASS = (
|
||||
"inline-flex items-center px-2 py-0.5 text-sm rounded "
|
||||
"bg-amber-500/15 text-amber-600 cursor-pointer"
|
||||
)
|
||||
_FILTER_PILL_REMOVE_CLASS = "ml-1 text-body hover:text-heading font-bold cursor-pointer"
|
||||
_FILTER_OPTION_ROW_CLASS = (
|
||||
"group flex items-center justify-between px-2 py-1 rounded text-sm "
|
||||
"hover:bg-neutral-secondary-strong cursor-pointer "
|
||||
"data-[search-select-highlighted]:bg-brand "
|
||||
"data-[search-select-highlighted]:outline data-[search-select-highlighted]:outline-1 "
|
||||
"data-[search-select-highlighted]:outline-brand-strong"
|
||||
)
|
||||
_FILTER_OPTION_LABEL_CLASS = (
|
||||
"truncate text-body group-data-[search-select-highlighted]:text-white"
|
||||
)
|
||||
_FILTER_OPTION_BUTTONS_CLASS = "flex gap-1 ml-2 shrink-0"
|
||||
# text-body keeps the +/− readable on dark backgrounds; hover:border-brand-strong
|
||||
# keeps the edge visible against the brand hover fill. When the row is the
|
||||
# keyboard-highlighted one its bg is brand, so the button text/border switch
|
||||
# to white and the hover fill shifts to brand-strong for contrast.
|
||||
_FILTER_ACTION_BUTTON_CLASS = (
|
||||
"w-5 h-5 flex items-center justify-center text-xs font-bold rounded text-body "
|
||||
"border border-brand "
|
||||
"hover:bg-brand hover:text-white hover:border-brand-strong "
|
||||
"group-data-[search-select-highlighted]:text-white "
|
||||
"group-data-[search-select-highlighted]:border-white "
|
||||
"group-data-[search-select-highlighted]:hover:bg-brand-strong "
|
||||
"group-data-[search-select-highlighted]:hover:border-white"
|
||||
)
|
||||
_FILTER_MODIFIER_ROW_CLASS = (
|
||||
"px-2 py-1 text-sm text-body hover:bg-neutral-secondary-strong cursor-pointer"
|
||||
)
|
||||
|
||||
|
||||
def _normalize_option(option) -> SearchSelectOption:
|
||||
"""Coerce a dict option or a ``(value, label)`` tuple into the TypedDict."""
|
||||
if isinstance(option, dict):
|
||||
return {
|
||||
"value": option["value"],
|
||||
"label": option["label"],
|
||||
"data": option.get("data") or {},
|
||||
}
|
||||
value, label = option
|
||||
return {"value": value, "label": label, "data": {}}
|
||||
|
||||
|
||||
def _data_attributes(data: dict[str, str]) -> list[HTMLAttribute]:
|
||||
return [(f"data-{key}", str(value)) for key, value in data.items()]
|
||||
|
||||
|
||||
def _hidden_input(name: str, value) -> Node:
|
||||
return Input(type="hidden", attributes=[("name", name), ("value", str(value))])
|
||||
|
||||
|
||||
def _label_slot(text: str, *, extra_class: str = "") -> Node:
|
||||
"""A ``<span data-search-select-label>`` holding a row/pill's visible label. JS fills this
|
||||
one node when cloning the shape from a ``<template>``, so labels are the only
|
||||
thing the JS sets — all classes and structure stay server-side."""
|
||||
attributes: list[HTMLAttribute] = [("data-search-select-label", "")]
|
||||
if extra_class:
|
||||
attributes.append(("class", extra_class))
|
||||
return Span(attributes=attributes, children=[text])
|
||||
|
||||
|
||||
# A placeholder option for rendering template prototypes (JS overwrites it).
|
||||
_BLANK_OPTION: SearchSelectOption = {"value": "", "label": "", "data": {}}
|
||||
|
||||
|
||||
def _option_row(option: SearchSelectOption) -> Node:
|
||||
return Div(
|
||||
attributes=[
|
||||
("data-search-select-option", ""),
|
||||
("data-value", str(option["value"])),
|
||||
("data-label", option["label"]),
|
||||
("class", _OPTION_ROW_CLASS),
|
||||
*_data_attributes(option["data"]),
|
||||
],
|
||||
children=[_label_slot(option["label"])],
|
||||
)
|
||||
|
||||
|
||||
def _combobox_shell(
|
||||
*,
|
||||
container_attributes: Attributes,
|
||||
pills: Node,
|
||||
search_attributes: Attributes,
|
||||
options_children: list[Node],
|
||||
always_visible: bool,
|
||||
items_visible: int,
|
||||
templates: list[Node] | None = None,
|
||||
) -> Node:
|
||||
"""Assemble the shared, domain-agnostic combobox skeleton.
|
||||
|
||||
Every combobox built on top of this shell has the same three regions in the
|
||||
same order: the ``pills`` region, the search box, and the options panel (which
|
||||
always carries a trailing no-results node). Callers supply the already-built
|
||||
``pills`` region, the ``search_attributes`` for the text box, the
|
||||
``options_children`` (value rows plus any pinned pseudo-options), the
|
||||
``container_attributes`` that carry the widget's identity and behaviour flags,
|
||||
and any ``templates`` (inert ``<template>`` prototypes the JS clones for
|
||||
dynamically-added rows/pills). The shell knows nothing about how individual
|
||||
rows or pills look.
|
||||
"""
|
||||
search = Input(attributes=search_attributes)
|
||||
|
||||
no_results = Div(
|
||||
attributes=[
|
||||
("data-search-select-no-results", ""),
|
||||
("class", _NO_RESULTS_CLASS),
|
||||
],
|
||||
children=["No results"],
|
||||
)
|
||||
options_class = _OPTIONS_CLASS if always_visible else _OPTIONS_CLASS + " hidden"
|
||||
options_panel = Div(
|
||||
attributes=[
|
||||
("data-search-select-options", ""),
|
||||
("style", f"max-height: {items_visible * _ROW_HEIGHT_REM:.2f}rem"),
|
||||
("class", options_class),
|
||||
],
|
||||
children=[*options_children, no_results],
|
||||
)
|
||||
|
||||
children: list[Node] = [pills, search, options_panel, *(templates or [])]
|
||||
return Div(attributes=container_attributes, children=children)
|
||||
|
||||
|
||||
def SearchSelect(
|
||||
*,
|
||||
name: str,
|
||||
selected: list[SearchSelectOption] | None = None,
|
||||
options: list[SearchSelectOption] | None = None,
|
||||
search_url: str = "",
|
||||
multi_select: bool = False,
|
||||
always_visible: bool = False,
|
||||
items_visible: int = 5,
|
||||
items_scroll: int = 10,
|
||||
prefetch: int = 0,
|
||||
placeholder: str = "Search…",
|
||||
id: str = "",
|
||||
sync_url: bool = False,
|
||||
autofocus: bool = False,
|
||||
) -> Node:
|
||||
"""Render the search-select widget. See module docstring for the contract."""
|
||||
selected = [_normalize_option(option) for option in (selected or [])]
|
||||
options = [_normalize_option(option) for option in (options or [])]
|
||||
|
||||
# ── Pills + their hidden inputs (the submitted channel) ──
|
||||
# Multi-select renders a removable Pill per value; single-select renders no
|
||||
# pill — the committed label shows inside the search box instead, with a
|
||||
# lone hidden input carrying the value. Both keep the hidden input(s) inside
|
||||
# `[data-search-select-pills]` so the JS reads/writes values uniformly.
|
||||
pills_children: list[Node] = []
|
||||
search_value = ""
|
||||
if multi_select:
|
||||
for option in selected:
|
||||
pills_children.append(
|
||||
Pill(
|
||||
option["label"],
|
||||
value=str(option["value"]),
|
||||
removable=True,
|
||||
label_slot=True,
|
||||
attributes=_data_attributes(option["data"]),
|
||||
)
|
||||
)
|
||||
pills_children.append(_hidden_input(name, option["value"]))
|
||||
elif selected:
|
||||
option = selected[0]
|
||||
pills_children.append(_hidden_input(name, option["value"]))
|
||||
search_value = option["label"]
|
||||
|
||||
pills = Div(
|
||||
attributes=[("data-search-select-pills", ""), ("class", _PILLS_CLASS)],
|
||||
children=pills_children,
|
||||
)
|
||||
|
||||
# ── Search box (NO name — the query is never submitted) ──
|
||||
search_attrs: list[HTMLAttribute] = [
|
||||
("data-search-select-search", ""),
|
||||
("placeholder", placeholder),
|
||||
("autocomplete", "off"),
|
||||
("class", _SEARCH_CLASS),
|
||||
]
|
||||
if autofocus:
|
||||
search_attrs.append(("autofocus", ""))
|
||||
if search_value:
|
||||
search_attrs.append(("value", search_value))
|
||||
|
||||
# ── Options panel (pre-rendered only when there is no search_url) ──
|
||||
option_rows = [_option_row(option) for option in options] if not search_url else []
|
||||
|
||||
# ── Templates the JS clones: a row when results are fetched, a pill when
|
||||
# multi-select adds chosen items. ──
|
||||
templates: list[Node] = []
|
||||
if search_url:
|
||||
templates.append(
|
||||
Template(
|
||||
attributes=[("data-search-select-template", "row")],
|
||||
children=[_option_row(_BLANK_OPTION)],
|
||||
)
|
||||
)
|
||||
if multi_select:
|
||||
templates.append(
|
||||
Template(
|
||||
attributes=[("data-search-select-template", "pill")],
|
||||
children=[Pill("", value="", removable=True, label_slot=True)],
|
||||
)
|
||||
)
|
||||
|
||||
container_attributes: list[HTMLAttribute] = [
|
||||
("data-search-select", ""),
|
||||
("data-name", name),
|
||||
("data-search-url", search_url),
|
||||
("data-multi", "true" if multi_select else "false"),
|
||||
("data-always-visible", "true" if always_visible else "false"),
|
||||
("data-items-visible", str(items_visible)),
|
||||
("data-items-scroll", str(items_scroll)),
|
||||
("data-prefetch", str(prefetch)),
|
||||
("data-sync-url", "true" if sync_url else "false"),
|
||||
("class", _CONTAINER_CLASS),
|
||||
]
|
||||
if id:
|
||||
container_attributes.append(("id", id))
|
||||
|
||||
return _combobox_shell(
|
||||
container_attributes=container_attributes,
|
||||
pills=pills,
|
||||
search_attributes=search_attrs,
|
||||
options_children=option_rows,
|
||||
always_visible=always_visible,
|
||||
items_visible=items_visible,
|
||||
templates=templates,
|
||||
).with_media(_SEARCH_SELECT_MEDIA)
|
||||
|
||||
|
||||
def _filter_remove_button() -> Node:
|
||||
return Element(
|
||||
"button",
|
||||
attributes=[
|
||||
("type", "button"),
|
||||
("data-pill-remove", ""),
|
||||
("class", _FILTER_PILL_REMOVE_CLASS),
|
||||
("aria-label", "Remove"),
|
||||
],
|
||||
children=["×"],
|
||||
)
|
||||
|
||||
|
||||
def _filter_value_pill(option: SearchSelectOption, kind: str) -> Node:
|
||||
"""An include (✓) or exclude (✗) value pill. ``kind`` is "include"/"exclude"."""
|
||||
symbol = "✓" if kind == "include" else "✗"
|
||||
css = (
|
||||
_FILTER_INCLUDE_PILL_CLASS if kind == "include" else _FILTER_EXCLUDE_PILL_CLASS
|
||||
)
|
||||
return Span(
|
||||
attributes=[
|
||||
("class", css),
|
||||
("data-pill", ""),
|
||||
("data-value", str(option["value"])),
|
||||
("data-label", option["label"]),
|
||||
("data-search-select-type", kind),
|
||||
*_data_attributes(option["data"]),
|
||||
],
|
||||
children=[f"{symbol} ", _label_slot(option["label"]), _filter_remove_button()],
|
||||
)
|
||||
|
||||
|
||||
def _filter_modifier_pill(modifier_value: str, label: str) -> Node:
|
||||
"""The lone, sticky modifier pill (e.g. "(Any)"/"(None)")."""
|
||||
return Span(
|
||||
attributes=[
|
||||
("class", _FILTER_MODIFIER_PILL_CLASS),
|
||||
("data-pill", ""),
|
||||
("data-search-select-modifier", modifier_value),
|
||||
],
|
||||
children=[_label_slot(label), _filter_remove_button()],
|
||||
)
|
||||
|
||||
|
||||
def _filter_action_button(action: str, symbol: str, title: str) -> Node:
|
||||
return Element(
|
||||
"button",
|
||||
attributes=[
|
||||
("type", "button"),
|
||||
("data-search-select-action", action),
|
||||
("class", _FILTER_ACTION_BUTTON_CLASS),
|
||||
("title", title),
|
||||
],
|
||||
children=[symbol],
|
||||
)
|
||||
|
||||
|
||||
def _filter_option_row(value: str | int, label: str) -> Node:
|
||||
"""A value row with include (+) and exclude (−) buttons."""
|
||||
return Div(
|
||||
attributes=[
|
||||
("data-search-select-option", ""),
|
||||
("data-value", str(value)),
|
||||
("data-label", label),
|
||||
("class", _FILTER_OPTION_ROW_CLASS),
|
||||
],
|
||||
children=[
|
||||
_label_slot(label, extra_class=_FILTER_OPTION_LABEL_CLASS),
|
||||
Span(
|
||||
attributes=[("class", _FILTER_OPTION_BUTTONS_CLASS)],
|
||||
children=[
|
||||
_filter_action_button("include", "+", "Include"),
|
||||
_filter_action_button("exclude", "−", "Exclude"),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _filter_modifier_row(modifier_value: str, label: str) -> Node:
|
||||
"""A pinned pseudo-option row. It carries no ``data-search-select-option`` so the text
|
||||
filter never hides it — modifiers stay visible at the top of the panel."""
|
||||
return Div(
|
||||
attributes=[
|
||||
("data-search-select-modifier-option", modifier_value),
|
||||
("data-label", label),
|
||||
("class", _FILTER_MODIFIER_ROW_CLASS),
|
||||
],
|
||||
children=[label],
|
||||
)
|
||||
|
||||
|
||||
def FilterSelect(
|
||||
*,
|
||||
field_name: str,
|
||||
options: list[LabeledOption | SearchSelectOption] | None = None,
|
||||
included: list[LabeledOption | SearchSelectOption] | None = None,
|
||||
excluded: list[LabeledOption | SearchSelectOption] | None = None,
|
||||
modifier: str = "",
|
||||
modifier_options: list[LabeledOption] | None = None,
|
||||
search_url: str = "",
|
||||
prefetch: int = 0,
|
||||
items_visible: int = 6,
|
||||
items_scroll: int = 10,
|
||||
placeholder: str = "Search…",
|
||||
id: str = "",
|
||||
free_text: bool = False,
|
||||
) -> Node:
|
||||
"""Include/exclude filter combobox built on the shared ``_combobox_shell``.
|
||||
|
||||
Like ``SearchSelect`` but each value row carries +/− buttons that add an
|
||||
*include* (✓) or *exclude* (✗) pill, plus an optional set of pinned
|
||||
``modifier_options`` (e.g. ``[("NOT_NULL", "(Any)"), ("IS_NULL", "(None)")]``)
|
||||
rendered above the value rows. Presence modifiers (NOT_NULL / IS_NULL) are
|
||||
mutually exclusive with value pills. Non-presence modifiers (INCLUDES_ALL /
|
||||
INCLUDES_ONLY) coexist with value pills — they govern how the include set
|
||||
matches and are only surfaced for many-to-many fields. State is read from
|
||||
the DOM into the filter JSON by ``readSearchSelect`` (filter mode) — nothing
|
||||
is submitted by ``name``.
|
||||
|
||||
``included``/``excluded`` are resolved options (value + label) so pills show
|
||||
labels even when the value rows come from ``search_url``. ``options``
|
||||
pre-renders the value rows for the complete-set (no ``search_url``) case.
|
||||
|
||||
``free_text`` turns the widget into a typed-pill input: there is no backing
|
||||
option list, the JS builds an ephemeral option row from whatever the user
|
||||
types so the +/− buttons (and Enter) commit the typed string itself as an
|
||||
include / exclude pill.
|
||||
"""
|
||||
options = [_normalize_option(option) for option in (options or [])]
|
||||
included = [_normalize_option(option) for option in (included or [])]
|
||||
excluded = [_normalize_option(option) for option in (excluded or [])]
|
||||
modifier_options = modifier_options or []
|
||||
|
||||
active_modifier_label = ""
|
||||
for modifier_value, label in modifier_options:
|
||||
if modifier_value == modifier:
|
||||
active_modifier_label = label
|
||||
break
|
||||
|
||||
# ── Pills: modifier pill (if active), then include/exclude value pills ──
|
||||
# Presence modifiers (NOT_NULL / IS_NULL) are mutually exclusive with value
|
||||
# pills — but the stored state guarantees they never coexist, so we render
|
||||
# both channels unconditionally. Non-presence modifiers (INCLUDES_ALL /
|
||||
# INCLUDES_ONLY) coexist with value pills and render side by side.
|
||||
pills_children: list[Node] = []
|
||||
if active_modifier_label:
|
||||
pills_children.append(_filter_modifier_pill(modifier, active_modifier_label))
|
||||
for option in included:
|
||||
pills_children.append(_filter_value_pill(option, "include"))
|
||||
for option in excluded:
|
||||
pills_children.append(_filter_value_pill(option, "exclude"))
|
||||
|
||||
pills = Div(
|
||||
attributes=[("data-search-select-pills", ""), ("class", _PILLS_CLASS)],
|
||||
children=pills_children,
|
||||
)
|
||||
|
||||
# ── Search box (NO name — the query is never submitted) ──
|
||||
search_attributes: list[HTMLAttribute] = [
|
||||
("data-search-select-search", ""),
|
||||
("placeholder", placeholder),
|
||||
("autocomplete", "off"),
|
||||
("class", _SEARCH_CLASS),
|
||||
]
|
||||
|
||||
# ── Options: pinned modifier rows, then value rows (pre-rendered only when
|
||||
# there is no search_url; otherwise the JS fetches them) ──
|
||||
modifier_rows = [
|
||||
_filter_modifier_row(value, label) for value, label in modifier_options
|
||||
]
|
||||
value_rows = (
|
||||
[_filter_option_row(option["value"], option["label"]) for option in options]
|
||||
if not search_url
|
||||
else []
|
||||
)
|
||||
|
||||
# ── Templates the JS clones: include/exclude pills (added on click), the
|
||||
# modifier pill (when modifiers exist), and a value row (when fetched). ──
|
||||
templates: list[Node] = [
|
||||
Template(
|
||||
attributes=[("data-search-select-template", "pill-include")],
|
||||
children=[_filter_value_pill(_BLANK_OPTION, "include")],
|
||||
),
|
||||
Template(
|
||||
attributes=[("data-search-select-template", "pill-exclude")],
|
||||
children=[_filter_value_pill(_BLANK_OPTION, "exclude")],
|
||||
),
|
||||
]
|
||||
if modifier_options:
|
||||
templates.append(
|
||||
Template(
|
||||
attributes=[("data-search-select-template", "pill-modifier")],
|
||||
children=[_filter_modifier_pill("", "")],
|
||||
)
|
||||
)
|
||||
if search_url or free_text:
|
||||
templates.append(
|
||||
Template(
|
||||
attributes=[("data-search-select-template", "row")],
|
||||
children=[_filter_option_row("", "")],
|
||||
)
|
||||
)
|
||||
|
||||
container_attributes: list[HTMLAttribute] = [
|
||||
("data-search-select", ""),
|
||||
("data-search-select-mode", "filter"),
|
||||
("data-name", field_name),
|
||||
("data-search-url", search_url),
|
||||
("data-multi", "true"),
|
||||
("data-always-visible", "false"),
|
||||
("data-items-visible", str(items_visible)),
|
||||
("data-items-scroll", str(items_scroll)),
|
||||
("data-prefetch", str(prefetch)),
|
||||
("data-sync-url", "false"),
|
||||
("class", _CONTAINER_CLASS),
|
||||
]
|
||||
if free_text:
|
||||
container_attributes.append(("data-search-select-free-text", "true"))
|
||||
if modifier:
|
||||
container_attributes.append(("data-modifier", modifier))
|
||||
if id:
|
||||
container_attributes.append(("id", id))
|
||||
|
||||
return _combobox_shell(
|
||||
container_attributes=container_attributes,
|
||||
pills=pills,
|
||||
search_attributes=search_attributes,
|
||||
options_children=[*modifier_rows, *value_rows],
|
||||
always_visible=False,
|
||||
items_visible=items_visible,
|
||||
templates=templates,
|
||||
).with_media(_SEARCH_SELECT_MEDIA)
|
||||
|
||||
|
||||
def searchselect_selected(
|
||||
values: list,
|
||||
resolver: Callable[[list], Iterable[SearchSelectOption]],
|
||||
) -> list[SearchSelectOption]:
|
||||
"""Resolve ``values`` into ``SearchSelectOption``s via ``resolver``.
|
||||
|
||||
``resolver(values)`` should resolve ONLY the given ids (a ``pk__in`` query)
|
||||
— never iterating all choices, so it stays cheap.
|
||||
"""
|
||||
if not values:
|
||||
return []
|
||||
return [_normalize_option(option) for option in resolver(values)]
|
||||
@@ -0,0 +1,512 @@
|
||||
"""
|
||||
Typed criterion inputs for building structured filters.
|
||||
|
||||
Inspired by Stash's filter architecture: every filterable field uses a typed
|
||||
criterion with a value and a CriterionModifier. This separates *what* you're
|
||||
filtering from *how* you're comparing, and makes filter serialization trivial.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field, fields as dc_fields
|
||||
from enum import Enum
|
||||
from typing import Any, Self, TypeVar
|
||||
|
||||
from django.db.models import Q
|
||||
|
||||
# ── Modifier ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class Modifier(str, Enum):
|
||||
"""Comparison operators shared across all criterion types."""
|
||||
|
||||
EQUALS = "EQUALS"
|
||||
NOT_EQUALS = "NOT_EQUALS"
|
||||
GREATER_THAN = "GREATER_THAN"
|
||||
LESS_THAN = "LESS_THAN"
|
||||
BETWEEN = "BETWEEN"
|
||||
NOT_BETWEEN = "NOT_BETWEEN"
|
||||
INCLUDES = "INCLUDES"
|
||||
EXCLUDES = "EXCLUDES"
|
||||
INCLUDES_ALL = "INCLUDES_ALL"
|
||||
INCLUDES_ONLY = "INCLUDES_ONLY"
|
||||
IS_NULL = "IS_NULL"
|
||||
NOT_NULL = "NOT_NULL"
|
||||
MATCHES_REGEX = "MATCHES_REGEX"
|
||||
NOT_MATCHES_REGEX = "NOT_MATCHES_REGEX"
|
||||
|
||||
@classmethod
|
||||
def for_strings(cls) -> list[Self]:
|
||||
return [
|
||||
cls.EQUALS,
|
||||
cls.NOT_EQUALS,
|
||||
cls.INCLUDES,
|
||||
cls.EXCLUDES,
|
||||
cls.MATCHES_REGEX,
|
||||
cls.NOT_MATCHES_REGEX,
|
||||
cls.IS_NULL,
|
||||
cls.NOT_NULL,
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def for_numbers(cls) -> list[Self]:
|
||||
return [
|
||||
cls.EQUALS,
|
||||
cls.NOT_EQUALS,
|
||||
cls.GREATER_THAN,
|
||||
cls.LESS_THAN,
|
||||
cls.BETWEEN,
|
||||
cls.NOT_BETWEEN,
|
||||
cls.IS_NULL,
|
||||
cls.NOT_NULL,
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def for_dates(cls) -> list[Self]:
|
||||
return cls.for_numbers()
|
||||
|
||||
@classmethod
|
||||
def for_multi(cls) -> list[Self]:
|
||||
return [
|
||||
cls.INCLUDES,
|
||||
cls.EXCLUDES,
|
||||
cls.INCLUDES_ALL,
|
||||
cls.INCLUDES_ONLY,
|
||||
cls.IS_NULL,
|
||||
cls.NOT_NULL,
|
||||
]
|
||||
|
||||
|
||||
# ── Base criterion ─────────────────────────────────────────────────────────
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Criterion:
|
||||
"""Base for all typed criteria."""
|
||||
|
||||
value: Any = None
|
||||
modifier: Modifier = Modifier.EQUALS
|
||||
|
||||
def to_q(self, field_name: str) -> Q:
|
||||
raise NotImplementedError
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, data: dict | None) -> Self | None:
|
||||
if data is None or not isinstance(data, dict):
|
||||
return None
|
||||
kwargs: dict[str, Any] = {}
|
||||
for f in dc_fields(cls):
|
||||
if f.name in data:
|
||||
val = data[f.name]
|
||||
# Coerce string modifier to Modifier enum
|
||||
if f.name == "modifier" and isinstance(val, str):
|
||||
val = Modifier(val)
|
||||
kwargs[f.name] = val
|
||||
return cls(**kwargs)
|
||||
|
||||
def to_json(self) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {}
|
||||
for f in dc_fields(self):
|
||||
v = getattr(self, f.name)
|
||||
if v is not None and v != f.default:
|
||||
result[f.name] = v
|
||||
return result
|
||||
|
||||
|
||||
# ── Concrete criteria ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class StringCriterion(_Criterion):
|
||||
value: str = ""
|
||||
modifier: Modifier = Modifier.EQUALS
|
||||
|
||||
def to_q(self, field_name: str) -> Q:
|
||||
m = self.modifier
|
||||
if m == Modifier.EQUALS:
|
||||
return Q(**{field_name: self.value})
|
||||
if m == Modifier.NOT_EQUALS:
|
||||
return ~Q(**{field_name: self.value})
|
||||
if m == Modifier.INCLUDES:
|
||||
return Q(**{f"{field_name}__icontains": self.value})
|
||||
if m == Modifier.EXCLUDES:
|
||||
return ~Q(**{f"{field_name}__icontains": self.value})
|
||||
if m == Modifier.MATCHES_REGEX:
|
||||
return Q(**{f"{field_name}__regex": self.value})
|
||||
if m == Modifier.NOT_MATCHES_REGEX:
|
||||
return ~Q(**{f"{field_name}__regex": self.value})
|
||||
if m == Modifier.IS_NULL:
|
||||
return Q(**{f"{field_name}__isnull": True})
|
||||
if m == Modifier.NOT_NULL:
|
||||
return Q(**{f"{field_name}__isnull": False})
|
||||
raise ValueError(f"Unsupported modifier {m} for string field")
|
||||
|
||||
|
||||
@dataclass
|
||||
class IntCriterion(_Criterion):
|
||||
value: int = 0
|
||||
value2: int | None = None
|
||||
modifier: Modifier = Modifier.EQUALS
|
||||
|
||||
def to_q(self, field_name: str) -> Q:
|
||||
m = self.modifier
|
||||
if m == Modifier.EQUALS:
|
||||
return Q(**{field_name: self.value})
|
||||
if m == Modifier.NOT_EQUALS:
|
||||
return ~Q(**{field_name: self.value})
|
||||
if m == Modifier.GREATER_THAN:
|
||||
return Q(**{f"{field_name}__gt": self.value})
|
||||
if m == Modifier.LESS_THAN:
|
||||
return Q(**{f"{field_name}__lt": self.value})
|
||||
if m == Modifier.BETWEEN:
|
||||
if self.value2 is None:
|
||||
raise ValueError("BETWEEN requires value2")
|
||||
return Q(
|
||||
**{
|
||||
f"{field_name}__gte": min(self.value, self.value2),
|
||||
f"{field_name}__lte": max(self.value, self.value2),
|
||||
}
|
||||
)
|
||||
if m == Modifier.NOT_BETWEEN:
|
||||
if self.value2 is None:
|
||||
raise ValueError("NOT_BETWEEN requires value2")
|
||||
lo, hi = min(self.value, self.value2), max(self.value, self.value2)
|
||||
return Q(**{f"{field_name}__lt": lo}) | Q(**{f"{field_name}__gt": hi})
|
||||
if m == Modifier.IS_NULL:
|
||||
return Q(**{f"{field_name}__isnull": True})
|
||||
if m == Modifier.NOT_NULL:
|
||||
return Q(**{f"{field_name}__isnull": False})
|
||||
raise ValueError(f"Unsupported modifier {m} for int field")
|
||||
|
||||
|
||||
@dataclass
|
||||
class FloatCriterion(_Criterion):
|
||||
value: float = 0.0
|
||||
value2: float | None = None
|
||||
modifier: Modifier = Modifier.EQUALS
|
||||
|
||||
def to_q(self, field_name: str) -> Q:
|
||||
m = self.modifier
|
||||
if m == Modifier.EQUALS:
|
||||
return Q(**{field_name: self.value})
|
||||
if m == Modifier.NOT_EQUALS:
|
||||
return ~Q(**{field_name: self.value})
|
||||
if m == Modifier.GREATER_THAN:
|
||||
return Q(**{f"{field_name}__gt": self.value})
|
||||
if m == Modifier.LESS_THAN:
|
||||
return Q(**{f"{field_name}__lt": self.value})
|
||||
if m == Modifier.BETWEEN:
|
||||
if self.value2 is None:
|
||||
raise ValueError("BETWEEN requires value2")
|
||||
return Q(
|
||||
**{
|
||||
f"{field_name}__gte": min(self.value, self.value2),
|
||||
f"{field_name}__lte": max(self.value, self.value2),
|
||||
}
|
||||
)
|
||||
if m == Modifier.NOT_BETWEEN:
|
||||
if self.value2 is None:
|
||||
raise ValueError("NOT_BETWEEN requires value2")
|
||||
lo, hi = min(self.value, self.value2), max(self.value, self.value2)
|
||||
return Q(**{f"{field_name}__lt": lo}) | Q(**{f"{field_name}__gt": hi})
|
||||
if m == Modifier.IS_NULL:
|
||||
return Q(**{f"{field_name}__isnull": True})
|
||||
if m == Modifier.NOT_NULL:
|
||||
return Q(**{f"{field_name}__isnull": False})
|
||||
raise ValueError(f"Unsupported modifier {m} for float field")
|
||||
|
||||
|
||||
@dataclass
|
||||
class DateCriterion(_Criterion):
|
||||
value: str = ""
|
||||
value2: str | None = None
|
||||
modifier: Modifier = Modifier.EQUALS
|
||||
|
||||
def to_q(self, field_name: str) -> Q:
|
||||
m = self.modifier
|
||||
if m == Modifier.EQUALS:
|
||||
return Q(**{field_name: self.value})
|
||||
if m == Modifier.NOT_EQUALS:
|
||||
return ~Q(**{field_name: self.value})
|
||||
if m == Modifier.GREATER_THAN:
|
||||
return Q(**{f"{field_name}__gt": self.value})
|
||||
if m == Modifier.LESS_THAN:
|
||||
return Q(**{f"{field_name}__lt": self.value})
|
||||
if m == Modifier.BETWEEN:
|
||||
if self.value2 is None:
|
||||
raise ValueError("BETWEEN requires value2")
|
||||
return Q(
|
||||
**{f"{field_name}__gte": self.value, f"{field_name}__lte": self.value2}
|
||||
)
|
||||
if m == Modifier.NOT_BETWEEN:
|
||||
if self.value2 is None:
|
||||
raise ValueError("NOT_BETWEEN requires value2")
|
||||
return Q(**{f"{field_name}__lt": self.value}) | Q(
|
||||
**{f"{field_name}__gt": self.value2}
|
||||
)
|
||||
if m == Modifier.IS_NULL:
|
||||
return Q(**{f"{field_name}__isnull": True})
|
||||
if m == Modifier.NOT_NULL:
|
||||
return Q(**{f"{field_name}__isnull": False})
|
||||
raise ValueError(f"Unsupported modifier {m} for date field")
|
||||
|
||||
|
||||
@dataclass
|
||||
class BoolCriterion(_Criterion):
|
||||
value: bool = False
|
||||
# Bool only makes sense with EQUALS
|
||||
modifier: Modifier = Modifier.EQUALS
|
||||
|
||||
def to_q(self, field_name: str) -> Q:
|
||||
if self.modifier == Modifier.EQUALS:
|
||||
return Q(**{field_name: self.value})
|
||||
if self.modifier == Modifier.NOT_EQUALS:
|
||||
return ~Q(**{field_name: self.value})
|
||||
raise ValueError(f"Unsupported modifier {self.modifier} for bool field")
|
||||
|
||||
|
||||
@dataclass
|
||||
class _SetCriterion(_Criterion):
|
||||
"""Shared base for set-membership criteria (``MultiCriterion`` /
|
||||
``ChoiceCriterion``).
|
||||
|
||||
Two orthogonal channels, mirroring Stash's modifier model:
|
||||
|
||||
- ``value`` is the *include* set. The ``modifier`` governs how it matches:
|
||||
|
||||
- ``INCLUDES`` — in ``value`` (match *any*); ``EQUALS`` is an alias.
|
||||
- ``INCLUDES_ALL`` — related to *all* of ``value`` (meaningful for
|
||||
many-to-many fields, e.g. a purchase's games).
|
||||
- ``EXCLUDES`` — in none of ``value`` (match *none*); ``NOT_EQUALS`` is an
|
||||
alias.
|
||||
|
||||
- ``excludes`` is an *always-orthogonal* negative: it contributes
|
||||
``AND NOT IN (excludes)`` for every (non-presence) modifier, never
|
||||
swapped into the include set. An exclude-only criterion therefore means
|
||||
"everything except ``excludes``".
|
||||
|
||||
Empty lists contribute no constraint. ``IS_NULL`` / ``NOT_NULL`` test
|
||||
presence and ignore both lists.
|
||||
|
||||
The logic lives entirely here so the two subclasses (which differ only in
|
||||
their value type) cannot drift.
|
||||
"""
|
||||
|
||||
value: list = field(default_factory=list)
|
||||
excludes: list = field(default_factory=list)
|
||||
modifier: Modifier = Modifier.INCLUDES
|
||||
|
||||
def to_q(self, field_name: str) -> Q:
|
||||
modifier = self.modifier
|
||||
if modifier == Modifier.IS_NULL:
|
||||
return Q(**{f"{field_name}__isnull": True})
|
||||
if modifier == Modifier.NOT_NULL:
|
||||
return Q(**{f"{field_name}__isnull": False})
|
||||
# The modifier governs only the include set; ``excludes`` is an orthogonal
|
||||
# AND'd negative applied for every (non-presence) modifier.
|
||||
q = self._value_q(field_name)
|
||||
if self.excludes:
|
||||
q &= ~Q(**{f"{field_name}__in": self.excludes})
|
||||
return q
|
||||
|
||||
def _value_q(self, field_name: str) -> Q:
|
||||
"""Build the Q for the include (``value``) set, per the modifier."""
|
||||
modifier = self.modifier
|
||||
if modifier in (Modifier.INCLUDES, Modifier.EQUALS):
|
||||
return Q(**{f"{field_name}__in": self.value}) if self.value else Q()
|
||||
if modifier in (Modifier.EXCLUDES, Modifier.NOT_EQUALS):
|
||||
return ~Q(**{f"{field_name}__in": self.value}) if self.value else Q()
|
||||
if modifier in (Modifier.INCLUDES_ALL, Modifier.INCLUDES_ONLY):
|
||||
# INCLUDES_ALL ("related to all of these") and INCLUDES_ONLY
|
||||
# ("related to exactly these, nothing else") are only meaningful
|
||||
# for many-to-many fields. A naive Q(field=a) & Q(field=b)
|
||||
# collapses to a single join requiring one through-row to equal
|
||||
# both values (impossible), so the generic criterion layer cannot
|
||||
# build a correct Q. M2M callers must supply their own Q builder
|
||||
# at the filter level — see PurchaseFilter._games_to_q for the
|
||||
# chained-subquery pattern.
|
||||
assert False, (
|
||||
f"{modifier} requires a filter-level Q builder for M2M fields. "
|
||||
"See PurchaseFilter._games_to_q for the chained-subquery pattern."
|
||||
)
|
||||
raise ValueError(f"Unsupported modifier {modifier} for {type(self).__name__}")
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, data: dict | None) -> Self | None:
|
||||
result = super().from_json(data)
|
||||
if result is None:
|
||||
return None
|
||||
# Labels embedded as {id, label} dicts are display-only; strip to bare ids
|
||||
# so the querying layer stays clean and typed.
|
||||
result.value = [
|
||||
item["id"] if isinstance(item, dict) else item for item in result.value
|
||||
]
|
||||
result.excludes = [
|
||||
item["id"] if isinstance(item, dict) else item for item in result.excludes
|
||||
]
|
||||
return result
|
||||
|
||||
|
||||
@dataclass
|
||||
class MultiCriterion(_SetCriterion):
|
||||
"""Filter on a many-to-many or ForeignKey relationship by ID list.
|
||||
|
||||
All modifier logic (including ``INCLUDES_ALL`` and ``EXCLUDES``) lives in
|
||||
``_SetCriterion``; this subclass only refines the value type.
|
||||
"""
|
||||
|
||||
value: list[int] = field(default_factory=list)
|
||||
excludes: list[int] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChoiceCriterion(_SetCriterion):
|
||||
"""Filter on a choice/enum field with multi-select include/exclude.
|
||||
|
||||
Used by FilterSelect widgets for status, ownership_type, etc. Shares all
|
||||
modifier logic with ``MultiCriterion`` via ``_SetCriterion``.
|
||||
"""
|
||||
|
||||
value: list[str] = field(default_factory=list)
|
||||
excludes: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
# ── OperatorFilter base ────────────────────────────────────────────────────
|
||||
|
||||
F = TypeVar("F", bound="OperatorFilter")
|
||||
|
||||
|
||||
@dataclass
|
||||
class OperatorFilter:
|
||||
"""Mixin providing AND/OR/NOT composition for entity filter types.
|
||||
|
||||
Subclasses should declare nullable references to themselves::
|
||||
|
||||
@dataclass
|
||||
class GameFilter(OperatorFilter):
|
||||
AND: "GameFilter | None" = None
|
||||
OR: "GameFilter | None" = None
|
||||
NOT: "GameFilter | None" = None
|
||||
name: StringCriterion | None = None
|
||||
...
|
||||
"""
|
||||
|
||||
def sub_filter(self) -> OperatorFilter | None:
|
||||
"""Return the first non-None of AND / OR / NOT."""
|
||||
for attr in ("AND", "OR", "NOT"):
|
||||
if hasattr(self, attr):
|
||||
v = getattr(self, attr)
|
||||
if v is not None:
|
||||
return v
|
||||
return None
|
||||
|
||||
def _criterion_fields(self) -> list[str]:
|
||||
"""Return field names that hold a _Criterion instance."""
|
||||
names: list[str] = []
|
||||
for f in dc_fields(self):
|
||||
if f.name in ("AND", "OR", "NOT"):
|
||||
continue
|
||||
v = getattr(self, f.name)
|
||||
if isinstance(v, _Criterion):
|
||||
names.append(f.name)
|
||||
return names
|
||||
|
||||
def to_q(self) -> Q:
|
||||
"""Build a Django Q object from this filter and its sub-filters."""
|
||||
q = Q()
|
||||
for field_name in self._criterion_fields():
|
||||
c = getattr(self, field_name)
|
||||
if c is not None:
|
||||
q &= c.to_q(field_name)
|
||||
sub = self.sub_filter()
|
||||
if sub is not None:
|
||||
if getattr(self, "AND", None) is not None:
|
||||
q &= sub.to_q()
|
||||
elif getattr(self, "OR", None) is not None:
|
||||
q |= sub.to_q()
|
||||
elif getattr(self, "NOT", None) is not None:
|
||||
q &= ~sub.to_q()
|
||||
return q
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, data: dict[str, Any] | None) -> Self | None:
|
||||
if data is None or not isinstance(data, dict):
|
||||
return None
|
||||
# Resolve criterion class names to actual types
|
||||
criterion_types: dict[str, type[_Criterion]] = {
|
||||
"StringCriterion": StringCriterion,
|
||||
"IntCriterion": IntCriterion,
|
||||
"FloatCriterion": FloatCriterion,
|
||||
"DateCriterion": DateCriterion,
|
||||
"BoolCriterion": BoolCriterion,
|
||||
"MultiCriterion": MultiCriterion,
|
||||
"ChoiceCriterion": ChoiceCriterion,
|
||||
}
|
||||
kwargs: dict[str, Any] = {}
|
||||
for f in dc_fields(cls):
|
||||
if f.name not in data:
|
||||
continue
|
||||
raw = data[f.name]
|
||||
if raw is None:
|
||||
kwargs[f.name] = None
|
||||
continue
|
||||
# Recurse into sub-filters (AND / OR / NOT)
|
||||
if f.name in ("AND", "OR", "NOT"):
|
||||
kwargs[f.name] = cls.from_json(raw) if isinstance(raw, dict) else None
|
||||
continue
|
||||
# Resolve criterion fields from string type annotation
|
||||
f_type = f.type
|
||||
if isinstance(f_type, str):
|
||||
# e.g. "StringCriterion | None" → "StringCriterion"
|
||||
f_type = f_type.split("|")[0].strip()
|
||||
if isinstance(f_type, str) and f_type in criterion_types:
|
||||
criterion_cls = criterion_types[f_type]
|
||||
kwargs[f.name] = (
|
||||
criterion_cls.from_json(raw) if isinstance(raw, dict) else None
|
||||
)
|
||||
elif isinstance(f_type, type) and issubclass(f_type, _Criterion):
|
||||
kwargs[f.name] = (
|
||||
f_type.from_json(raw) if isinstance(raw, dict) else None
|
||||
)
|
||||
return cls(**kwargs)
|
||||
|
||||
def to_json(self) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {}
|
||||
for f in dc_fields(self):
|
||||
v = getattr(self, f.name)
|
||||
if v is None:
|
||||
continue
|
||||
if f.name in ("AND", "OR", "NOT"):
|
||||
result[f.name] = v.to_json()
|
||||
elif isinstance(v, _Criterion):
|
||||
j = v.to_json()
|
||||
if j:
|
||||
result[f.name] = j
|
||||
return result
|
||||
|
||||
|
||||
# ── JSON helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def filter_from_json(cls: type[F], json_str: str) -> F | None:
|
||||
"""Deserialize a filter from a JSON string.
|
||||
|
||||
Usage:
|
||||
f = filter_from_json(GameFilter, request.GET.get("filter", ""))
|
||||
games = Game.objects.filter(f.to_q())
|
||||
"""
|
||||
if not json_str:
|
||||
return None
|
||||
try:
|
||||
data = json.loads(json_str)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return cls.from_json(data)
|
||||
|
||||
|
||||
def filter_to_json(f: OperatorFilter) -> str:
|
||||
"""Serialize a filter to a JSON string for URL params or storage."""
|
||||
return json.dumps(f.to_json())
|
||||
@@ -0,0 +1,25 @@
|
||||
import functools
|
||||
from pathlib import Path
|
||||
|
||||
_ICON_DIR = Path(__file__).resolve().parent.parent / "games" / "templates" / "icons"
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def _load_icons() -> dict[str, str]:
|
||||
"""Load all icon HTML files into a dict.
|
||||
|
||||
Cached so files are read once per process lifetime.
|
||||
Delegation (e.g. nintendo-3ds -> nintendo) is handled by
|
||||
both files containing identical SVG content.
|
||||
"""
|
||||
icons: dict[str, str] = {}
|
||||
for filepath in _ICON_DIR.glob("*.html"):
|
||||
name = filepath.stem
|
||||
icons[name] = filepath.read_text()
|
||||
return icons
|
||||
|
||||
|
||||
def get_icon(name: str) -> str:
|
||||
"""Return the HTML for an icon by name. Falls back to 'unspecified'."""
|
||||
icons = _load_icons()
|
||||
return icons.get(name, icons.get("unspecified", ""))
|
||||
@@ -20,8 +20,8 @@ def import_data(data: DataList):
|
||||
# try exact match first
|
||||
try:
|
||||
game_id = Game.objects.get(name__iexact=name)
|
||||
except:
|
||||
pass
|
||||
except (Game.DoesNotExist, Game.MultipleObjectsReturned):
|
||||
game_id = None
|
||||
matching_names[name] = game_id
|
||||
print(f"Exact matched {len(matching_names)} games.")
|
||||
|
||||
|
||||
+3
-5
@@ -206,11 +206,8 @@ textarea:disabled {
|
||||
label {
|
||||
@apply mb-2.5 text-sm font-medium text-heading;
|
||||
}
|
||||
input:not([type="checkbox"]) {
|
||||
@apply mb-3 bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand block w-full px-3 py-2.5 shadow-xs placeholder:text-body;
|
||||
}
|
||||
input[type="checkbox"] {
|
||||
@apply w-4 h-4 border border-default-medium rounded-xs bg-neutral-secondary-medium focus:ring-2 focus:ring-brand-soft;
|
||||
input:not([type="checkbox"]):not([data-search-select-search]) {
|
||||
@apply mb-3 bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand block w-full px-3 py-2.5 shadow-xs placeholder:text-body;
|
||||
}
|
||||
select {
|
||||
@apply w-full px-3 py-2.5 bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand shadow-xs placeholder:text-body;
|
||||
@@ -231,3 +228,4 @@ textarea:disabled {
|
||||
@apply fixed z-50 flex flex-col items-end bottom-0 right-0 p-4;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
"""A small fast_app-style layout system.
|
||||
|
||||
Instead of Django template inheritance (`{% extends "base.html" %}`), views
|
||||
build their page body with Python components and wrap it with `Page()` /
|
||||
`render_page()`. `Page()` is the equivalent of FastHTML's document wrapper:
|
||||
it hoists shared `<head>` content (the `_HEADERS` block, analogous to
|
||||
`fast_app(hdrs=...)`), renders the navbar, and assembles the full document.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from django.contrib.messages import get_messages
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.middleware.csrf import get_token
|
||||
from django.templatetags.static import static
|
||||
from django.urls import reverse
|
||||
from django.utils.html import conditional_escape
|
||||
from django.utils.safestring import SafeText, mark_safe
|
||||
from django_htmx.jinja import django_htmx_script
|
||||
|
||||
from games.templatetags.version import version, version_date
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from common.components import Node
|
||||
|
||||
# Static head script that sets the dark/light class before paint (avoids FOUC).
|
||||
_THEME_FOUC_SCRIPT = """<script>
|
||||
if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
</script>"""
|
||||
|
||||
# The main module script: crown icon mount + theme-toggle wiring.
|
||||
# Split around the single dynamic value (game.mastered).
|
||||
_MAIN_SCRIPT_A = """<script type="module">
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (window.mountCrownIcon) {
|
||||
window.mountCrownIcon('#crown-icon-mount-point', {
|
||||
mastered: """
|
||||
_MAIN_SCRIPT_B = """
|
||||
});
|
||||
}
|
||||
|
||||
const themeToggleDarkIcon = document.getElementById('theme-toggle-dark-icon');
|
||||
const themeToggleLightIcon = document.getElementById('theme-toggle-light-icon');
|
||||
const themeToggleBtn = document.getElementById('theme-toggle');
|
||||
|
||||
if (themeToggleDarkIcon && themeToggleLightIcon && themeToggleBtn) {
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
themeToggleLightIcon.classList.remove('hidden');
|
||||
themeToggleDarkIcon.classList.add('hidden');
|
||||
} else {
|
||||
themeToggleDarkIcon.classList.remove('hidden');
|
||||
themeToggleLightIcon.classList.add('hidden');
|
||||
}
|
||||
|
||||
themeToggleBtn.addEventListener('click', function () {
|
||||
themeToggleDarkIcon.classList.toggle('hidden');
|
||||
themeToggleLightIcon.classList.toggle('hidden');
|
||||
|
||||
if (localStorage.getItem('color-theme')) {
|
||||
if (localStorage.getItem('color-theme') === 'light') {
|
||||
document.documentElement.classList.add('dark');
|
||||
localStorage.setItem('color-theme', 'dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
localStorage.setItem('color-theme', 'light');
|
||||
}
|
||||
} else {
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
document.documentElement.classList.remove('dark');
|
||||
localStorage.setItem('color-theme', 'light');
|
||||
} else {
|
||||
document.documentElement.classList.add('dark');
|
||||
localStorage.setItem('color-theme', 'dark');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>"""
|
||||
|
||||
# Toast notification region (Alpine.js). Verbatim from the old base.html.
|
||||
_TOAST_CONTAINER = """<div x-data="toastStore()"
|
||||
role="region"
|
||||
aria-label="Notifications"
|
||||
aria-atomic="true"
|
||||
class="fixed z-50 bottom-0 right-0 flex flex-col items-end pointer-events-none p-4">
|
||||
<template x-for="toast in $store.toasts.toasts" :key="toast.id">
|
||||
<div x-show="toast.visible"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 translate-x-8"
|
||||
x-transition:enter-end="opacity-100 translate-x-0"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 translate-x-0"
|
||||
x-transition:leave-end="opacity-0 translate-x-8"
|
||||
:role="toast.type === 'error' || toast.type === 'warning' ? 'alert' : 'status'"
|
||||
:aria-live="toast.type === 'error' ? 'assertive' : 'polite'"
|
||||
tabindex="0"
|
||||
class="pointer-events-auto max-w-sm w-72 cursor-pointer mb-3 last:mb-0"
|
||||
:class="{
|
||||
'success': toast.type === 'success',
|
||||
'error': toast.type === 'error',
|
||||
'info': toast.type === 'info',
|
||||
'warning': toast.type === 'warning',
|
||||
'debug': toast.type === 'debug'
|
||||
}"
|
||||
@click="dismissToast(toast.id)"
|
||||
@mouseenter="$store.toasts.clearToastTimer(toast.id)"
|
||||
@mouseleave="$store.toasts.resumeToastTimer(toast.id, 5000)"
|
||||
@keydown.escape="dismissToast(toast.id)">
|
||||
<div class="rounded-lg shadow-lg p-4 flex items-start gap-3"
|
||||
:class="{
|
||||
'bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700': toast.type === 'success',
|
||||
'bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700': toast.type === 'error',
|
||||
'bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700': toast.type === 'info',
|
||||
'bg-amber-50 dark:bg-amber-900 border border-amber-200 dark:border-amber-700': toast.type === 'warning',
|
||||
'bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700': toast.type === 'debug'
|
||||
}">
|
||||
<span class="flex-shrink-0 mt-0.5"
|
||||
:class="{
|
||||
'text-green-500': toast.type === 'success',
|
||||
'text-red-500': toast.type === 'error',
|
||||
'text-blue-500': toast.type === 'info',
|
||||
'text-amber-500': toast.type === 'warning',
|
||||
'text-gray-500': toast.type === 'debug'
|
||||
}">
|
||||
<template x-if="toast.type === 'success'">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
</template>
|
||||
<template x-if="toast.type === 'error'">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</template>
|
||||
<template x-if="toast.type === 'info'">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M12 20a8 8 0 100-16 8 8 0 000 16z"/>
|
||||
</svg>
|
||||
</template>
|
||||
<template x-if="toast.type === 'warning'">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 13l5 5 5-5M7 6l5 5 5-5"/>
|
||||
</svg>
|
||||
</template>
|
||||
<template x-if="toast.type === 'debug'">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
</template>
|
||||
</span>
|
||||
<p class="flex-1 text-sm"
|
||||
:class="{
|
||||
'text-green-800 dark:text-green-200': toast.type === 'success',
|
||||
'text-red-800 dark:text-red-200': toast.type === 'error',
|
||||
'text-blue-800 dark:text-blue-200': toast.type === 'info',
|
||||
'text-amber-800 dark:text-amber-200': toast.type === 'warning',
|
||||
'text-gray-800 dark:text-gray-200': toast.type === 'debug'
|
||||
}"
|
||||
x-text="toast.message"></p>
|
||||
<button @click.stop="dismissToast(toast.id)"
|
||||
class="flex-shrink-0"
|
||||
:class="{
|
||||
'text-green-400 hover:text-green-600 dark:text-green-500 dark:hover:text-green-300': toast.type === 'success',
|
||||
'text-red-400 hover:text-red-600 dark:text-red-500 dark:hover:text-red-300': toast.type === 'error',
|
||||
'text-blue-400 hover:text-blue-600 dark:text-blue-500 dark:hover:text-blue-300': toast.type === 'info',
|
||||
'text-amber-400 hover:text-amber-600 dark:text-amber-500 dark:hover:text-amber-300': toast.type === 'warning',
|
||||
'text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300': toast.type === 'debug'
|
||||
}">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>"""
|
||||
|
||||
|
||||
def _main_script(mastered: bool) -> str:
|
||||
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":
|
||||
"""Top navigation bar.
|
||||
|
||||
Static chrome, so it's a single ``Safe`` node wrapping its markup rather
|
||||
than a hand-built element tree — trusted HTML belongs in a ``Safe`` node,
|
||||
not a ``mark_safe`` string."""
|
||||
from common.components import Safe
|
||||
|
||||
logo = static("icons/schedule.png")
|
||||
return Safe(f"""<nav class="bg-neutral-primary-soft border-b border-default">
|
||||
<div class="max-w-(--breakpoint-xl) flex flex-wrap items-center justify-between mx-auto p-4">
|
||||
<a href="{reverse("games:index")}"
|
||||
class="flex items-center space-x-3 rtl:space-x-reverse">
|
||||
<img src="{logo}" height="48" width="48" alt="Timetracker Logo" class="mr-4" />
|
||||
<span class="self-center text-2xl font-semibold whitespace-nowrap dark:text-white">Timetracker</span>
|
||||
</a>
|
||||
<button data-collapse-toggle="navbar-dropdown" type="button"
|
||||
class="inline-flex items-center p-2 w-10 h-10 justify-center text-sm text-gray-500 rounded-lg md:hidden hover:bg-gray-100 focus:outline-hidden focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600"
|
||||
aria-controls="navbar-dropdown" aria-expanded="false">
|
||||
<span class="sr-only">Open main menu</span>
|
||||
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 17 14">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 1h15M1 7h15M1 13h15" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="hidden w-full md:block md:w-auto" id="navbar-dropdown">
|
||||
<ul class="items-center flex flex-col font-medium p-4 md:p-0 mt-4 border border-gray-100 rounded-lg bg-gray-50 md:space-x-8 rtl:space-x-reverse md:flex-row md:mt-0 md:border-0 md:bg-white dark:bg-gray-800 md:dark:bg-gray-900 dark:border-gray-700">
|
||||
<li class="flex items-center">
|
||||
<button id="theme-toggle" type="button" class="p-2 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-hidden focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg text-sm hover:cursor-pointer">
|
||||
<svg id="theme-toggle-dark-icon" class="hidden w-5 h-5" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.32031 11.6835C3.32031 16.6541 7.34975 20.6835 12.3203 20.6835C16.1075 20.6835 19.3483 18.3443 20.6768 15.032C19.6402 15.4486 18.5059 15.6834 17.3203 15.6834C12.3497 15.6834 8.32031 11.654 8.32031 6.68342C8.32031 5.50338 8.55165 4.36259 8.96453 3.32996C5.65605 4.66028 3.32031 7.89912 3.32031 11.6835Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<svg id="theme-toggle-light-icon" class="hidden w-5 h-5" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 3V4M12 20V21M4 12H3M6.31412 6.31412L5.5 5.5M17.6859 6.31412L18.5 5.5M6.31412 17.69L5.5 18.5001M17.6859 17.69L18.5 18.5001M21 12H20M16 12C16 14.2091 14.2091 16 12 16C9.79086 16 8 14.2091 8 12C8 9.79086 9.79086 8 12 8C14.2091 8 16 9.79086 16 12Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</li>
|
||||
<li class="dark:text-white flex flex-col items-center text-xs">
|
||||
<span class="flex uppercase gap-1">Today<span class="dark:text-gray-400">·</span>Last 7 days</span>
|
||||
<span class="flex items-center gap-1">{today_played}<span class="dark:text-gray-400">·</span>{last_7_played}</span>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" class="block py-2 px-3 text-white bg-blue-700 rounded-sm md:bg-transparent md:text-blue-700 md:p-0 md:dark:text-blue-500 dark:bg-blue-600 md:dark:bg-transparent" aria-current="page">Home</a>
|
||||
</li>
|
||||
<li>
|
||||
<button id="dropdownNavbarNewLink" data-dropdown-toggle="dropdownNavbarNew"
|
||||
class="flex items-center justify-between w-full py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 md:w-auto dark:text-white md:dark:hover:text-blue-500 dark:focus:text-white dark:border-gray-700 dark:hover:bg-gray-700 md:dark:hover:bg-transparent hover:cursor-pointer">
|
||||
New
|
||||
<svg class="w-2.5 h-2.5 ms-2.5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 10 6">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4" />
|
||||
</svg>
|
||||
</button>
|
||||
<div id="dropdownNavbarNew" class="z-10 hidden font-normal bg-white divide-y divide-gray-100 rounded-lg shadow-sm w-44 dark:bg-gray-700 dark:divide-gray-600">
|
||||
<ul class="py-2 text-sm text-gray-700 dark:text-gray-400" aria-labelledby="dropdownLargeButton">
|
||||
<li><a href="{reverse("games:add_device")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Device</a></li>
|
||||
<li><a href="{reverse("games:add_game")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Game</a></li>
|
||||
<li><a href="{reverse("games:add_platform")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platform</a></li>
|
||||
<li><a href="{reverse("games:add_purchase")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Purchase</a></li>
|
||||
<li><a href="{reverse("games:add_session")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Session</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<button id="dropdownNavbarManageLink" data-dropdown-toggle="dropdownNavbarManage"
|
||||
class="flex items-center justify-between w-full py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 md:w-auto dark:text-white md:dark:hover:text-blue-500 dark:focus:text-white dark:border-gray-700 dark:hover:bg-gray-700 md:dark:hover:bg-transparent hover:cursor-pointer">
|
||||
Manage
|
||||
<svg class="w-2.5 h-2.5 ms-2.5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 10 6">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4" />
|
||||
</svg>
|
||||
</button>
|
||||
<div id="dropdownNavbarManage" class="z-10 hidden font-normal bg-white divide-y divide-gray-100 rounded-lg shadow-sm w-44 dark:bg-gray-700 dark:divide-gray-600">
|
||||
<ul class="py-2 text-sm text-gray-700 dark:text-gray-400" aria-labelledby="dropdownLargeButton">
|
||||
<li><a href="{reverse("games:list_devices")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Devices</a></li>
|
||||
<li><a href="{reverse("games:list_games")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Games</a></li>
|
||||
<li><a href="{reverse("games:list_platforms")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platforms</a></li>
|
||||
<li><a href="{reverse("games:list_playevents")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Play events</a></li>
|
||||
<li><a href="{reverse("games:list_purchases")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Purchases</a></li>
|
||||
<li><a href="{reverse("games:list_sessions")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Sessions</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{reverse("games:stats_by_year", args=[current_year])}" class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Stats</a>
|
||||
</li>
|
||||
<li>
|
||||
<form method="post" action="{reverse("logout")}">
|
||||
<input type="hidden" name="csrfmiddlewaretoken" value="{csrf_token}">
|
||||
<button type="submit" class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Log out</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>""")
|
||||
|
||||
|
||||
def Page(
|
||||
content: "Node | SafeText | str",
|
||||
*,
|
||||
request: HttpRequest,
|
||||
title: str = "",
|
||||
scripts: "Node | SafeText | str" = "",
|
||||
mastered: bool = False,
|
||||
) -> SafeText:
|
||||
"""Assemble a full HTML document around `content` (the fast_app equivalent).
|
||||
|
||||
Scripts are collected from `content`'s component tree: every component
|
||||
declares its JS via `Media`, and `collect_media` gathers (deduped) the union
|
||||
for the whole page. The `scripts` argument remains for page-specific glue
|
||||
that isn't owned by a reusable component (e.g. the add-form helpers).
|
||||
"""
|
||||
from common.components import ModuleScript, StaticScript, collect_media
|
||||
from games.views.general import global_current_year, model_counts
|
||||
|
||||
media = collect_media(content)
|
||||
collected_scripts = "".join(
|
||||
[str(ModuleScript(name)) for name in media.js]
|
||||
+ [str(StaticScript(name)) for name in media.js_external]
|
||||
)
|
||||
all_scripts = collected_scripts + (str(scripts) if scripts else "")
|
||||
|
||||
counts = model_counts(request)
|
||||
year = global_current_year(request)["global_current_year"]
|
||||
navbar = Navbar(
|
||||
today_played=counts["today_played"],
|
||||
last_7_played=counts["last_7_played"],
|
||||
current_year=year,
|
||||
csrf_token=get_token(request),
|
||||
)
|
||||
|
||||
messages = [
|
||||
{"message": str(m.message), "type": (m.tags or "info")}
|
||||
for m in get_messages(request)
|
||||
]
|
||||
# Embed as JSON; guard against `</script>` breaking out of the tag.
|
||||
messages_json = json.dumps(messages).replace("</", "<\\/")
|
||||
|
||||
head = (
|
||||
'<!DOCTYPE html>\n<html lang="en">\n <head>\n'
|
||||
' <meta charset="utf-8" />\n'
|
||||
' <meta name="description" content="Self-hosted time-tracker." />\n'
|
||||
' <meta name="keywords" content="time, tracking, video games, self-hosted" />\n'
|
||||
' <meta name="viewport" content="width=device-width, initial-scale=1" />\n'
|
||||
f" <title>Timetracker - {conditional_escape(title)}</title>\n"
|
||||
f' <script src="{static("js/htmx.min.js")}"></script>\n'
|
||||
" <script>\n"
|
||||
" htmx.config.scrollBehavior = 'smooth';\n"
|
||||
" htmx.config.selfRequestsOnly = false;\n"
|
||||
" </script>\n"
|
||||
f' <script src="{static("js/htmx-redirect-toast.js")}"></script>\n'
|
||||
f" {django_htmx_script(nonce=None)}\n"
|
||||
f' <link rel="stylesheet" href="{static("base.css")}" />\n'
|
||||
# Vendored bundles (flowbite 2.4.1, alpinejs/@alpinejs/mask 3.15.12) —
|
||||
# served locally so pages work offline (and in browser tests). The mask
|
||||
# plugin must load before Alpine core; both stay deferred.
|
||||
f' <script src="{static("js/flowbite.min.js")}"></script>\n'
|
||||
f' <script defer src="{static("js/alpine-mask.min.js")}"></script>\n'
|
||||
f' <script defer src="{static("js/alpine.min.js")}"></script>\n'
|
||||
f" {_THEME_FOUC_SCRIPT}\n"
|
||||
" </head>\n"
|
||||
)
|
||||
|
||||
body = (
|
||||
' <body hx-indicator="#indicator" class="bg-neutral-primary">\n'
|
||||
f' <script id="django-messages" type="application/json">{messages_json}</script>\n'
|
||||
f' <img id="indicator" src="{static("icons/loading.png")}" class="absolute right-3 top-3 animate-spin htmx-indicator" height="24" width="24" alt="loading indicator" />\n'
|
||||
' <div class="flex flex-col min-h-screen">\n'
|
||||
f" {navbar}\n"
|
||||
f' <div id="main-container" class="flex flex-1 flex-col pt-8 pb-16">{content}</div>\n'
|
||||
f' <span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{version()} ({version_date()})</span>\n'
|
||||
" </div>\n"
|
||||
f" {all_scripts}\n"
|
||||
f" {_main_script(mastered)}\n"
|
||||
" <!-- hx-swap-oob makes sure the modal gets removed upon any HTMX response -->\n"
|
||||
' <div id="global-modal-container" hx-swap-oob="true"></div>\n'
|
||||
f" {_TOAST_CONTAINER}\n"
|
||||
f' <script src="{static("js/toast.js")}"></script>\n'
|
||||
" </body>\n</html>\n"
|
||||
)
|
||||
|
||||
return mark_safe(head + body)
|
||||
|
||||
|
||||
def render_page(
|
||||
request: HttpRequest,
|
||||
content: "Node | SafeText | str",
|
||||
*,
|
||||
title: str = "",
|
||||
scripts: "Node | SafeText | str" = "",
|
||||
mastered: bool = False,
|
||||
status: int = 200,
|
||||
) -> HttpResponse:
|
||||
"""`render()`-style shortcut: build a full page and return an HttpResponse."""
|
||||
return HttpResponse(
|
||||
Page(content, request=request, title=title, scripts=scripts, mastered=mastered),
|
||||
status=status,
|
||||
)
|
||||
@@ -1,17 +1,43 @@
|
||||
import re
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import NamedTuple
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from common.utils import generate_split_ranges
|
||||
|
||||
dateformat: str = "%d/%m/%Y"
|
||||
dateformat_hyphenated: str = "%d-%m-%Y"
|
||||
datetimeformat: str = "%d/%m/%Y %H:%M"
|
||||
timeformat: str = "%H:%M"
|
||||
durationformat: str = "%2.1H hours"
|
||||
durationformat_manual: str = "%H hours"
|
||||
|
||||
|
||||
class DatePartSpec(NamedTuple):
|
||||
"""One date part (day/month/year) of a hyphenated date format."""
|
||||
|
||||
name: str
|
||||
placeholder: str
|
||||
length: int
|
||||
|
||||
|
||||
_DATE_PART_SPECS: dict[str, DatePartSpec] = {
|
||||
"%d": DatePartSpec("day", "DD", 2),
|
||||
"%m": DatePartSpec("month", "MM", 2),
|
||||
"%Y": DatePartSpec("year", "YYYY", 4),
|
||||
}
|
||||
|
||||
|
||||
def date_parts(format_string: str = dateformat_hyphenated) -> list[DatePartSpec]:
|
||||
"""Split a hyphenated strftime date format into its ordered parts.
|
||||
|
||||
``"%d-%m-%Y"`` becomes ``[day, month, year]`` specs, each carrying the
|
||||
placeholder text (``DD``/``MM``/``YYYY``) and digit length shown by the
|
||||
DateRangeField segments."""
|
||||
return [_DATE_PART_SPECS[directive] for directive in format_string.split("-")]
|
||||
|
||||
|
||||
def _safe_timedelta(duration: timedelta | int | None):
|
||||
if duration is None:
|
||||
return timedelta(0)
|
||||
|
||||
+26
-3
@@ -5,11 +5,34 @@ from functools import reduce, wraps
|
||||
from typing import Any, Callable, Generator, Literal, TypeVar
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django.core.paginator import Page, Paginator
|
||||
from django.db.models import Q
|
||||
from django.http import HttpRequest
|
||||
from django.shortcuts import redirect
|
||||
|
||||
|
||||
def paginate(request: HttpRequest, queryset, per_page: int = 10):
|
||||
"""Standard list-view pagination.
|
||||
|
||||
Reads ``page`` and ``limit`` from the query string (``limit=0`` disables
|
||||
pagination) and returns ``(object_list, page_obj, elided_page_range)`` ready
|
||||
to hand to ``paginated_table_content``.
|
||||
"""
|
||||
page_number = request.GET.get("page", 1)
|
||||
limit = int(request.GET.get("limit", per_page))
|
||||
object_list = queryset
|
||||
page_obj: Page | None = None
|
||||
if limit != 0:
|
||||
page_obj = Paginator(queryset, limit).get_page(page_number)
|
||||
object_list = page_obj.object_list
|
||||
elided_page_range = (
|
||||
page_obj.paginator.get_elided_page_range(page_number, on_each_side=1, on_ends=1)
|
||||
if page_obj
|
||||
else None
|
||||
)
|
||||
return object_list, page_obj, elided_page_range
|
||||
|
||||
|
||||
def safe_division(numerator: int | float, denominator: int | float) -> int | float:
|
||||
"""
|
||||
Divides without triggering division by zero exception.
|
||||
@@ -153,9 +176,9 @@ def redirect_to(default_view: str, *default_args):
|
||||
|
||||
next_url = reverse(default_view, args=default_args)
|
||||
|
||||
response = view_func(
|
||||
request, *args, **kwargs
|
||||
) # Execute the original view logic
|
||||
# Execute the original view logic for its side effects, then
|
||||
# redirect to `next_url` instead of returning its response.
|
||||
view_func(request, *args, **kwargs)
|
||||
return redirect(next_url)
|
||||
|
||||
return wrapped_view
|
||||
|
||||
@@ -7,8 +7,11 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
container_name: timetracker
|
||||
environment:
|
||||
- DEBUG=false
|
||||
- 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"
|
||||
# volumes:
|
||||
# - "db:/home/timetracker/app/src/timetracker/db.sqlite3"
|
||||
|
||||
+6
-2
@@ -1,17 +1,21 @@
|
||||
---
|
||||
services:
|
||||
timetracker:
|
||||
image: ${REGISTRY_URL:-registry.kucharczyk.xyz}/timetracker:1.7.0
|
||||
image: ${REGISTRY_URL:-registry.kucharczyk.xyz}/timetracker:latest
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: timetracker
|
||||
environment:
|
||||
- DEBUG=${DEBUG:-false}
|
||||
- SECRET_KEY=${SECRET_KEY}
|
||||
- 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}
|
||||
- PGID=${PGID:-100}
|
||||
- DATA_DIR=${DATA_DIR:-/home/timetracker/app/data}
|
||||
- CREATE_DEFAULT_SUPERUSER=${CREATE_DEFAULT_SUPERUSER:-false}
|
||||
ports:
|
||||
- "${TIMETRACKER_EXTERNAL_PORT:-8000}:8000"
|
||||
volumes:
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
# Configuration
|
||||
|
||||
All configurable Django settings are read through a single helper,
|
||||
`config()` in [`timetracker/config.py`](../timetracker/config.py). It resolves
|
||||
each value from a fixed chain of sources so the same setting can come from an
|
||||
environment variable, a `.env` file, an `.ini` file, or a built-in default —
|
||||
without any per-setting special-casing in `settings.py`.
|
||||
|
||||
## Resolution priority
|
||||
|
||||
For a setting named `NAME`, the first source that provides a value wins:
|
||||
|
||||
| Priority | Source | Notes |
|
||||
|---------:|--------|-------|
|
||||
| 1 | `NAME__FILE` env var | Path to a file; its *stripped* contents are the value. Opt-in per setting (`allow_file=True`). For Docker/Kubernetes secrets. |
|
||||
| 2 | `NAME` env var | A real process environment variable. |
|
||||
| 3 | `.env` file | `KEY=value` lines (see [.env syntax](#env-syntax)). |
|
||||
| 4 | `settings.ini` file | The `[timetracker]` section, parsed with `configparser`. |
|
||||
| 5 | `default` | The in-code fallback in `settings.py`. |
|
||||
|
||||
If no source supplies a value and no `default` is defined, startup fails with
|
||||
`ImproperlyConfigured` rather than silently using an empty value.
|
||||
|
||||
**Worked example.** With `VALUE` set in the environment *and* in `.env` *and*
|
||||
in `settings.ini`, the environment variable wins. Remove it and `.env` wins;
|
||||
remove that and `settings.ini` wins; remove that and the code default applies.
|
||||
|
||||
## Settings reference
|
||||
|
||||
| Setting | Cast | Default | `__FILE`? | Description |
|
||||
|---------|------|---------|:---------:|-------------|
|
||||
| `SECRET_KEY` | str | insecure dev key | yes | Django secret key. **Required in production** (DEBUG off) — a missing value is a hard error, not a silent insecure fallback. |
|
||||
| `DEBUG` | bool | `true` (dev) | no | Debug mode. Turn **off** in production. Defaults on for local development. |
|
||||
| `APP_URL` | str | `http://localhost:8000` | no | Public URL of the site. Derives `ALLOWED_HOSTS` and `CSRF_TRUSTED_ORIGINS` when those are not set explicitly. |
|
||||
| `ALLOWED_HOSTS` | list | derived from `APP_URL` | no | Comma-separated hostnames. Overrides the `APP_URL` derivation. |
|
||||
| `CSRF_TRUSTED_ORIGINS` | list | derived from `APP_URL` | no | Comma-separated full origins (`https://host`). Overrides the `APP_URL` derivation. |
|
||||
| `TZ` | str | `Europe/Prague` (dev) / `UTC` (prod) | no | Time zone. |
|
||||
| `DATA_DIR` | path | project root | no | Directory holding the SQLite database. Also read by `entrypoint.sh`. |
|
||||
|
||||
`cast` understands `bool` (`true/1/yes/on` → `True`), `list` (comma-separated,
|
||||
whitespace-trimmed, empty items dropped), `int`, `Path`, or any callable.
|
||||
|
||||
## APP_URL, ALLOWED_HOSTS and CSRF
|
||||
|
||||
`ALLOWED_HOSTS` and `CSRF_TRUSTED_ORIGINS` guard different things — the `Host`
|
||||
header versus cross-origin requests — so they are **never merged**. For the
|
||||
common case you set only `APP_URL` and both are derived:
|
||||
|
||||
```
|
||||
APP_URL=https://tracker.example.com
|
||||
# -> ALLOWED_HOSTS = ["tracker.example.com"]
|
||||
# -> CSRF_TRUSTED_ORIGINS = ["https://tracker.example.com"]
|
||||
```
|
||||
|
||||
Power users override either independently. A typical reverse-proxy setup:
|
||||
|
||||
```
|
||||
ALLOWED_HOSTS=*
|
||||
CSRF_TRUSTED_ORIGINS=https://tracker.example.com
|
||||
```
|
||||
|
||||
## Secrets and `__FILE`
|
||||
|
||||
Secret managers (Docker secrets, Kubernetes) mount secrets as files. For any
|
||||
setting that opts in (currently `SECRET_KEY`), point a `*__FILE` variable at
|
||||
the mounted path:
|
||||
|
||||
```
|
||||
SECRET_KEY__FILE=/run/secrets/timetracker_secret_key
|
||||
```
|
||||
|
||||
The file contents are read and `.strip()`-ed. The strip matters: editors and
|
||||
`echo` often append a trailing newline, and a stray `\n` inside `SECRET_KEY`
|
||||
would silently invalidate every signed cookie/token when the file is recreated
|
||||
without it.
|
||||
|
||||
## .env syntax
|
||||
|
||||
```dotenv
|
||||
# full-line comment
|
||||
KEY=value
|
||||
export KEY=value # optional leading "export"
|
||||
QUOTED="value with spaces" # surrounding quotes are stripped
|
||||
SINGLE='also fine'
|
||||
WITH_HASH="a # b" # '#' inside quotes is literal
|
||||
INLINE=value # trailing comment after an unquoted value is dropped
|
||||
```
|
||||
|
||||
Deliberately **not** supported (documented limits, not bugs):
|
||||
|
||||
- variable interpolation (`${OTHER}`)
|
||||
- multiline values
|
||||
|
||||
File locations default to `.env` and `settings.ini` at the project root and
|
||||
can be moved with the `ENV_FILE` / `INI_FILE` environment variables. Missing
|
||||
files are ignored, so env-only deployments need neither. A `.env` file used by
|
||||
`docker-compose` for `${VAR}` substitution is the same file Django reads in
|
||||
local development; inside the container, real environment variables apply.
|
||||
|
||||
See [`.env.example`](../.env.example) and
|
||||
[`settings.ini.example`](../settings.ini.example) for starting points.
|
||||
|
||||
## Container / entrypoint-only variables
|
||||
|
||||
These are consumed by [`entrypoint.sh`](../entrypoint.sh) during container
|
||||
bootstrap, **not** by Django. They are intentionally not part of the Python
|
||||
config — moving them there would buy nothing and force a bash↔Python bridge.
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `PUID` / `PGID` | `1000` / `100` | uid/gid the container process runs as. |
|
||||
| `DATA_DIR` | `/home/timetracker/app/data` | Database directory. Shared with Django via the same env var + matching default. |
|
||||
| `CREATE_DEFAULT_SUPERUSER` | `false` | Create an `admin`/`admin` superuser on first start. |
|
||||
| `STAGING` | `false` | Scrub copied sessions / django-q schedule on staging. |
|
||||
| `LOAD_SAMPLE_DATA` | `false` | Seed sample fixtures when the database is empty. |
|
||||
|
||||
## Migrating from the old config
|
||||
|
||||
- `PROD=1` → `DEBUG=false`. `PROD` still works as a **deprecated alias** for
|
||||
one release and emits a `DeprecationWarning`.
|
||||
- `ALLOWED_HOSTS` is now configurable (it was previously hard-coded to `*`).
|
||||
After upgrading, set `APP_URL` (or `ALLOWED_HOSTS` explicitly) or the host
|
||||
will be rejected. Reverse-proxy deployments that relied on `*` should set
|
||||
`ALLOWED_HOSTS=*`.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Custom Element API: Two patterns, one goal
|
||||
|
||||
## Pattern 1: Named builder (current, preferred)
|
||||
|
||||
A tag builder with auto-attached `Media`, created via `custom_element_builder()`:
|
||||
|
||||
```python
|
||||
# definition (custom_elements.py)
|
||||
SessionTimestampButtons = custom_element_builder("session-timestamp-buttons")
|
||||
|
||||
# usage (session.py)
|
||||
SessionTimestampButtons(class_="form-row-button-group", hx_boost="false")[
|
||||
Button(data_target="timestamp_start", data_type="now", size="xs")["Set to now"],
|
||||
Button(data_target="timestamp_start", data_type="toggle", size="xs")["Toggle text"],
|
||||
]
|
||||
```
|
||||
|
||||
**Pros:** explicit dependency, visible import, fails loudly if builder deleted
|
||||
**Cons:** one line of ceremony per element
|
||||
|
||||
## Pattern 2: Element + registry (proposed, not implemented)
|
||||
|
||||
A global `CUSTOM_ELEMENT_MEDIA` dict in `core.py` that maps tag names to their `Media`. `register_element()` populates it automatically at import time, so `Element("session-timestamp-buttons")` silently picks up its JS dependency:
|
||||
|
||||
```python
|
||||
# definition (custom_elements.py)
|
||||
register_element("session-timestamp-buttons", "SessionTimestampButtons", EmptyProps)
|
||||
# CUSTOM_ELEMENT_MEDIA["session-timestamp-buttons"] = Media(js=("dist/elements/...",))
|
||||
|
||||
# usage (session.py) — no builder import needed
|
||||
Element("session-timestamp-buttons",
|
||||
[("class", "form-row-button-group"), ("hx-boost", "false")],
|
||||
children=[...],
|
||||
)
|
||||
```
|
||||
|
||||
**Pros:** one universal API — `Div(...)`, `Button(...)`, `Element("custom-tag")` all same pattern
|
||||
**Cons:** implicit dependency — deleting a `register_element()` call produces no error, just broken JS at runtime
|
||||
|
||||
## Recommendation
|
||||
|
||||
Start with Pattern 1 (named builders) — safe by default. Add Pattern 2 later if the ceremony becomes annoying. The two are **not mutually exclusive**: a named builder is just a thin wrapper around an `Element`; the registry can be added without changing any call sites.
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Want | Write |
|
||||
|------|-------|
|
||||
| Plain HTML tag | `Div(class_="flex")["text"]` |
|
||||
| Custom element (builder) | `SessionTimestampButtons(class_="...")[child]` |
|
||||
| Raw element | `Element("custom-tag", attributes_list, children=[...])` |
|
||||
| Builder from scratch | `custom_element_builder("tag-name")` |
|
||||
@@ -0,0 +1,398 @@
|
||||
# Form Overhaul Plan
|
||||
|
||||
> Last updated: 2026-05-12
|
||||
> Status: Decided — awaiting implementation
|
||||
>
|
||||
> **Decisions made:**
|
||||
> - All forms (simple and complex) get section headers for consistency
|
||||
> - Two-column layout uses **flexbox** (auto-reflow on different screen sizes)
|
||||
> - `cotton/layouts/add.html` enhanced with **Option A**: `c-section` component slots
|
||||
> - `add_purchase.html` dual-submit **simplified** — remove `<tr><td>`, use same `c-button` pattern as `add_game.html`
|
||||
> - GameStatusChange delete confirmation **converted to modal** (via HTMX trigger)
|
||||
|
||||
## Goal
|
||||
|
||||
Modernize all forms and form-like elements to align with Flowbite design, improve visual consistency, and adopt responsive multi-column layouts for complex forms.
|
||||
|
||||
---
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
### Form Pages (add/edit)
|
||||
|
||||
All use `cotton/layouts/add.html` — single column, `max-w-xl`, `form.as_div`:
|
||||
|
||||
| Page | Form | Fields | Complexity |
|
||||
|---|---|---|---|
|
||||
| Game | `GameForm` | 7 fields: name, sort_name, platform, year, year_orig, status, mastered, wikidata | Medium |
|
||||
| Purchase | `PurchaseForm` | 11 fields: games, platform, dates, price, currency, type, ownership, related, infinite, name | High |
|
||||
| Session | `SessionForm` | 8 fields: game, timestamps, duration, emulated, device, note, checkbox (custom rendering) | High |
|
||||
| Platform | `PlatformForm` | 3 fields: name, icon, group | Low |
|
||||
| Device | `DeviceForm` | 2 fields: name, type | Low |
|
||||
| PlayEvent | `PlayEventForm` | 5 fields: game, dates, note, checkbox | Low |
|
||||
| GameStatusChange | `GameStatusChangeForm` | 4 fields | Low |
|
||||
|
||||
### Other Form-Like Elements
|
||||
|
||||
| Element | Template | Notes |
|
||||
|---|---|---|
|
||||
| Login | `registration/login.html` | Flowbite card, already good |
|
||||
| Search | `cotton/search_field.html` | Reusable, already good |
|
||||
| Delete Game | `partials/delete_game_confirmation.html` | Inline modal, inconsistent button layout |
|
||||
| Delete PlayEvent | `gamestatuschange_confirm_delete.html` | Full-page form, no modal |
|
||||
| Refund Purchase | `partials/refund_purchase_confirmation.html` | Inline modal, inconsistent button layout |
|
||||
| Stats Year Select | `stats.html` | Manual `<select>`, no Flowbite styling |
|
||||
| Status Selector | `partials/gamestatus_selector.html` | Alpine.js dropdown, old Tailwind classes |
|
||||
| Device Selector | `partials/sessiondevice_selector.html` | Alpine.js dropdown, old Tailwind classes |
|
||||
|
||||
---
|
||||
|
||||
## Issues to Fix
|
||||
|
||||
### P0: Broken/Inconsistent
|
||||
|
||||
1. ~~**`modal.html` has a missing `<form>` tag** (line 13: `</form>` with no opening `<form>`)** — *Resolved: rewritten as proper component with form wrapping support, body + footer slots, reusable `close_button` component. Ready for standardizing all inline modals later.*
|
||||
2. **Delete confirmations are inconsistent** — three different patterns (inline modal, full-page form, inline modal)
|
||||
3. **`.errorlist` CSS** has fixed `width: 300px` — too narrow, breaks on mobile. *No scoping needed: Django auto-applies `.errorlist` to form error output only, never used explicitly in templates.*
|
||||
4. **`add_purchase.html` has `<tr><td>`** in a `c-slot` that renders inside a `<div>` — semantic mismatch. **Decision: simplify dual-submit** to match `add_game.html` pattern (use `<c-button>` only).
|
||||
5. **`#button-container` and `.basic-button` in `input.css`** — legacy patterns, unused or dead code
|
||||
|
||||
### P1: Layout & UX
|
||||
|
||||
6. **All add/edit forms are single-column** — PurchaseForm (11 fields) and GameForm (7 fields) would benefit from multi-column
|
||||
7. **No field grouping** — related fields listed flat without visual hierarchy
|
||||
8. **Stats year `<select>`** has no Flowbite styling
|
||||
9. **Search field** is not wrapped in `<form method="get">` — no native clear-on-Enter behavior
|
||||
|
||||
### P2: Styling Consistency
|
||||
|
||||
10. **Status/device selectors** use old Tailwind v3 patterns (`rounded-sm`, `shadow-2xs`, `border-gray-200` without explicit color)
|
||||
11. **`navbar.html` buttons** use `rounded-sm` instead of `rounded-base`
|
||||
12. **`simple_table.html` pagination buttons** use `rounded-s-lg`/`rounded-e-lg` — could be simplified
|
||||
|
||||
---
|
||||
|
||||
## Proposed Improvements
|
||||
|
||||
### 1. Two-Column Layout for Complex Forms (Flexbox)
|
||||
|
||||
**Scope**: `GameForm`, `PurchaseForm`, `PlayEventForm`, `SessionForm`
|
||||
|
||||
Use **flexbox** with wrap behavior so fields auto-reflow on different screen sizes. No fixed column count — fields sit side-by-side on `md:`+ and wrap naturally on smaller screens.
|
||||
|
||||
#### GameForm Layout
|
||||
```
|
||||
┌──────────────────────────────────┐
|
||||
│ Game Details │
|
||||
│ ┌──────────────────┬───────────┐ │
|
||||
│ │ Name │ Platform │ │
|
||||
│ │ Sort Name │ Year │ │
|
||||
│ │ Original Year │ Wikidata │ │
|
||||
│ └──────────────────┴───────────┘ │
|
||||
│ Status │
|
||||
│ ┌──────────────────┬───────────┐ │
|
||||
│ │ Status │ Mastered │ │
|
||||
│ └──────────────────┴───────────┘ │
|
||||
│ [Submit] │
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### PurchaseForm Layout (simplified)
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ Purchase Details │
|
||||
│ ┌──────────────────────┬───────────────┐ │
|
||||
│ │ Games (multi-select) │ Platform │ │
|
||||
│ │ Type │ Ownership │ │
|
||||
│ │ Name │ Related Purch │ │
|
||||
│ └──────────────────────┴───────────────┘ │
|
||||
│ Dates │ Price │
|
||||
│ ┌───────────────┬──────┴───────────────┐ │
|
||||
│ │ Date Purch │ Price Curr │ │
|
||||
│ │ Date Refund │ Infinite [ ] │ │
|
||||
│ └───────────────┴──────────────────────┘ │
|
||||
│ [Submit] [Submit + Session] │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Implementation**: `c-section` component accepts `columns="2"` (or `"3"`) which applies `flex flex-wrap gap-4 [&>div]:w-[calc(50%-0.5rem)]` on md+ screens. Each field wraps in a `<div>` inside the section slot.
|
||||
|
||||
**Decision**: Dual-submit in `add_purchase.html` simplified — remove `<tr><td>`, use same `<c-button>` pattern as `add_game.html`.
|
||||
|
||||
### 2. Field Grouping with Card Sections
|
||||
|
||||
**Decision**: ALL forms get section headers for consistency (not just complex forms).
|
||||
|
||||
Group related fields with section headings and subtle borders/backgrounds:
|
||||
|
||||
```html
|
||||
<c-section title="Game Details" columns="2">
|
||||
{{ form.name }}
|
||||
{{ form.platform }}
|
||||
{{ form.sort_name }}
|
||||
{{ form.year_released }}
|
||||
</c-section>
|
||||
```
|
||||
|
||||
Each section renders as:
|
||||
```html
|
||||
<fieldset class="form-section p-5 border-t border-default-medium bg-neutral-primary-soft/30 first-of-type:border-t-0 first-of-type:pt-0">
|
||||
<h3 class="text-sm font-medium text-heading uppercase mb-4">Section Title</h3>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<!-- fields in <div> wrappers, each taking calc(50% - 0.5rem) on md+ -->
|
||||
</div>
|
||||
</fieldset>
|
||||
```
|
||||
|
||||
Each section gets:
|
||||
- Subtle background (`bg-neutral-primary-soft/30`)
|
||||
- Top border with spacing (`border-t border-default-medium`)
|
||||
- Section heading (`text-sm font-medium text-heading uppercase mb-4`)
|
||||
- Flexbox gap for responsive field reflow
|
||||
|
||||
### 1b. `c-section` Component Specification
|
||||
|
||||
New cotton component for the `cotton/` directory:
|
||||
|
||||
```python
|
||||
# games/templates/cotton/section.py (or inline in components.py)
|
||||
from common.components import Div
|
||||
|
||||
def Section(title: str = "", columns: str = "1", children: str = "") -> SafeText:
|
||||
"""Renders a form field section with optional multi-column flexbox layout.
|
||||
|
||||
Args:
|
||||
title: Section heading (renders as uppercase label)
|
||||
columns: "1" (default), "2", or "3" — target column count on md+ screens
|
||||
children: Field markup (each field wrapped in <div> for flex wrapping)
|
||||
"""
|
||||
col_class = {
|
||||
"1": "flex flex-col",
|
||||
"2": "flex flex-wrap gap-4 [&>div]:w-[calc(50%-0.5rem)]",
|
||||
"3": "flex flex-wrap gap-4 [&>div]:w-[calc(33.333%-0.67rem)]",
|
||||
}.get(columns, "flex flex-col")
|
||||
|
||||
return Div(
|
||||
cls=f"form-section p-5 border-t border-default-medium bg-neutral-primary-soft/30 first-of-type:border-t-0 first-of-type:pt-0",
|
||||
children=f"""
|
||||
<h3 class="text-sm font-medium text-heading uppercase mb-4">{title}</h3>
|
||||
<div class="{col_class}">{children}</div>
|
||||
"""
|
||||
)
|
||||
```
|
||||
|
||||
**Template usage:**
|
||||
```django
|
||||
{# add_game.html #}
|
||||
<c-layouts.add title="New Game">
|
||||
<c-section title="Game Details" columns="2">
|
||||
<div>{{ form.name }}</div>
|
||||
<div>{{ form.platform }}</div>
|
||||
<div>{{ form.sort_name }}</div>
|
||||
<div>{{ form.year_released }}</div>
|
||||
<div>{{ form.original_year_released }}</div>
|
||||
<div>{{ form.wikidata }}</div>
|
||||
</c-section>
|
||||
<c-section title="Status" columns="2">
|
||||
<div>{{ form.status }}</div>
|
||||
<div>{{ form.mastered }}</div>
|
||||
</c-section>
|
||||
</c-layouts.add>
|
||||
```
|
||||
|
||||
**`cotton/layouts/add.html` changes:**
|
||||
- Remove hardcoded `{{ form.as_div }}` rendering
|
||||
- Accept optional `sections` variable (list of rendered `c-section` output)
|
||||
- If `sections` provided, render them; otherwise fall back to `{{ form.as_div }}` for simple forms
|
||||
- Keep `additional_row` slot for dual-submit buttons
|
||||
|
||||
### 3. CSS/Style Fixes
|
||||
|
||||
#### `input.css` changes:
|
||||
```css
|
||||
/* Update errorlist */
|
||||
.errorlist {
|
||||
@apply mt-4 mb-1 pl-3 py-2 bg-red-600 text-slate-200 w-full max-w-xl; /* was w-[300px] */
|
||||
}
|
||||
|
||||
/* Remove: #button-container, .basic-button — unused legacy */
|
||||
|
||||
/* Remove: .flowbite-input — custom class is code smell with Tailwind */
|
||||
/* Remove: flowbite-input @apply block (line 229-234) */
|
||||
|
||||
/* Add Flowbite styling for select in stats */
|
||||
#yearSelect {
|
||||
@apply bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand;
|
||||
}
|
||||
```
|
||||
|
||||
**Important**: The styling previously provided by `.flowbite-input` must be preserved. The element-level `@apply` rules for `input`, `select`, and `textarea` in `input.css` (lines 209-219) already provide equivalent styling. These rules automatically apply to all form inputs without needing custom classes:
|
||||
- `input:not([type="checkbox"])` — background, border, text, radius, focus ring, padding
|
||||
- `select` — same base styling as inputs
|
||||
- `textarea` — same base styling with adjusted padding
|
||||
|
||||
**Files to clean up:**
|
||||
- `common/input.css`: Remove `.flowbite-input` class entirely (lines 229-234)
|
||||
- `games/forms.py`: Remove `flowbite_input_widget` and `flowbite_password_widget` (lines 22-23)
|
||||
- `games/forms.py`: Remove `widget=` from `LoginForm` fields (lines 28, 32) — login template uses explicit Tailwind classes already
|
||||
|
||||
#### Rewrite `modal.html`:
|
||||
- Remove stray `</form>` tag and restructure as a proper cotton component
|
||||
- New `c-modal` component with: `modal_id`, `title`, `size="xl"`, `backdrop_close` variables
|
||||
- `{{ slot }}` (cotton default slot) for body content — passed as children of `<c-modal>`, no block tags needed
|
||||
- `{{ footer }}` (optional named slot via `<c-slot name="footer">`) for non-form buttons
|
||||
- Reusable `cotton/close_button.html` via `<c-close-button />`
|
||||
- Size mapping via inline `{% if %}`: `{% if size == 'sm' %}max-w-sm{% elif size == 'lg' %}max-w-lg{% else %}max-w-xl{% endif %}`
|
||||
- Horizontal centering: `mx-auto` on inner container (matching old modal pattern)
|
||||
- Click-to-dismiss backdrop with `event.stopPropagation()` on inner container
|
||||
- Flowbite-style styling: `rounded-lg shadow`, `bg-white dark:bg-gray-800`, `sm:p-5`
|
||||
|
||||
### 4. Unify Delete Confirmations (All Modal)
|
||||
|
||||
**Decision**: GameStatusChange delete confirmation converted from full-page to modal. All three use the same modal pattern.
|
||||
|
||||
**Target**: All confirmation modals use the same pattern:
|
||||
|
||||
```html
|
||||
<div class="fixed inset-0 bg-black/70 dark:bg-gray-600/50 ...">
|
||||
<div class="relative mx-auto p-6 bg-white dark:bg-gray-900 rounded-lg shadow-lg max-w-md w-full">
|
||||
<h2 class="text-xl font-medium text-center">Confirm Action</h2>
|
||||
<p class="text-center mt-4 text-sm text-body">Are you sure...?</p>
|
||||
{% if details %}
|
||||
<ul class="text-center mt-2 text-sm text-body list-disc list-inside">
|
||||
<li>{{ detail }}</li>
|
||||
</ul>
|
||||
{% endif %}
|
||||
<p class="text-center mt-3 text-sm font-medium text-red-600">This action cannot be undone.</p>
|
||||
<div class="flex gap-3 mt-6">
|
||||
<c-button color="red" class="w-full" type="submit">Delete</c-button>
|
||||
<c-button color="gray" class="w-full">Cancel</c-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- **Delete Game** (`partials/delete_game_confirmation.html`): Update template to match standard pattern
|
||||
- **Delete StatusChange** (`gamestatuschange_confirm_delete.html` → `partials/statuschange_delete_confirmation.html`): Adopt the same 2-view pattern as delete-game.
|
||||
- Add `delete_statuschange_confirmation` view (GET → renders modal partial) + URL before the delete URL
|
||||
- Update `partials/history.html` — add `hx-get="{% url 'games:delete_statuschange_confirmation' change.id %}" hx-target="#global-modal-container"` to the Delete link
|
||||
- Create new `partials/statuschange_delete_confirmation.html` using `<c-modal>`, same structure as `delete_game_confirmation.html` (detail list, red warning text, same button layout, `<c-gamestatus>` badge for old status)
|
||||
- Modify `GameStatusChangeDeleteView` to only handle POST (remove its GET-rendered template)
|
||||
- Delete old `gamestatuschange_confirm_delete.html` after migration
|
||||
- **Refund Purchase** (`partials/refund_purchase_confirmation.html`): Update template to match standard pattern
|
||||
|
||||
### 5. Search Form Enhancement
|
||||
|
||||
Wrap `search_field.html` in proper `<form method="get">`:
|
||||
|
||||
```html
|
||||
<form class="max-w-md mx-auto" method="get" x-data x-on:keydown.escape="this.querySelector('input').value=''; this.submit()">
|
||||
<!-- input + button -->
|
||||
</form>
|
||||
```
|
||||
|
||||
This enables:
|
||||
- Native form submission on Enter
|
||||
- Potential for "clear all" functionality
|
||||
- Proper browser form autofill behavior
|
||||
|
||||
### 6. Status/Device Selector Styling
|
||||
|
||||
Update Alpine.js dropdowns to use consistent button classes:
|
||||
- Replace `rounded-lg` with `rounded-base`
|
||||
- Replace `shadow-2xs` with `shadow-xs`
|
||||
- Standardize border colors with `border-default`
|
||||
- Use `text-heading` / `text-body` for dark mode compatibility
|
||||
|
||||
---
|
||||
|
||||
## Templates That Need Changes
|
||||
|
||||
| Template | Change | Effort |
|
||||
|---|---|---|
|
||||
| `cotton/layouts/add.html` | Add `c-section` component support (title, columns, fields slots) | Medium |
|
||||
| `add_game.html` | Multi-column flexbox layout, section headers | Medium |
|
||||
| `add_purchase.html` | Multi-column flexbox layout, simplify dual-submit, section headers | High |
|
||||
| `add_session.html` | Flexbox layout for timestamps+duration, section headers | Low |
|
||||
| `add_playevent.html` | Flexbox layout, section headers | Low |
|
||||
| `add_platform.html` | Section headers (was flat single-column) | Low |
|
||||
| `add_device.html` | Section headers (was flat single-column) | Low |
|
||||
| `partials/delete_game_confirmation.html` | Standardize to shared modal pattern | Low |
|
||||
| `partials/refund_purchase_confirmation.html` | Standardize to shared modal pattern | Low |
|
||||
| `partials/statuschange_delete_confirmation.html` | New — adopt same 2-view pattern as delete-game (modal, `<c-modal>`, HTMX triggers) | Medium |
|
||||
| `gamestatuschange_confirm_delete.html` | Delete (replaced by new partial) | Trivial |
|
||||
| `cotton/modal.html` | Fix missing `<form>` tag | Low |
|
||||
| `stats.html` | Add Flowbite select styling | Low |
|
||||
| `partials/gamestatus_selector.html` | Update button classes | Low |
|
||||
| `partials/sessiondevice_selector.html` | Update button classes | Low |
|
||||
| `cotton/search_field.html` | Wrap in `<form method="get">` | Low |
|
||||
| `common/input.css` | Remove legacy, fix errorlist, add select styles | Low |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
### Phase 1: Quick Wins (low risk, no breaking changes)
|
||||
|
||||
1. **CSS fixes** (`input.css`) — fix errorlist width, remove legacy `.basic-button` / `#button-container`, add select styles
|
||||
2. ~~**`modal.html` rewrite**~~ — add missing `<form>` tag, conditional form wrapper ✓ Implemented (uses `{{ slot }}` cotton default slot, no `{% partial %}` tags; `size` defaults to `"xl"` with inline `{% if %}` mapping)
|
||||
3. **Delete confirmation standardization** — 3 templates → all modal, same pattern (including GameStatusChange: full-page → modal)
|
||||
4. **Search field enhancement** — wrap in `<form method="get">`
|
||||
5. **Stats select styling** — add Flowbite select classes
|
||||
6. **Selector styling updates** — gamestatus + sessiondevice selectors, consistent classes
|
||||
|
||||
### Phase 2: `c-section` Component
|
||||
|
||||
7. **Create `c-section` component** — title, columns, fields slots
|
||||
8. **Update `cotton/layouts/add.html`** — support `sections` variable, fallback to `form.as_div`
|
||||
|
||||
### Phase 3: Form Layout Overhaul (largest change)
|
||||
|
||||
9. **`GameForm`** — section headers + 2-col flexbox (`add_game.html`)
|
||||
10. **`PlayEventForm`** — section headers + 2-col flexbox
|
||||
11. **`PurchaseForm`** — section headers + 2/3-col flexbox + simplify dual-submit (`add_purchase.html`)
|
||||
12. **`SessionForm`** — section headers + flexbox for timestamps+duration (custom rendering already exists)
|
||||
13. **Simple forms** — `add_platform.html`, `add_device.html` get section headers (single column)
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- Run `make test` after Phase 1 changes to verify nothing broke
|
||||
- `tests/test_paths_return_200.py` — URL-level smoke tests (186 tests). All views must have a `test_*_returns_200` test. Adding new views requires a corresponding test to prevent `TemplateDoesNotExist` regressions.
|
||||
- CSS changes do not require test changes (no test coverage for rendering), but visual verification is recommended
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
- [x] Simple forms section headers? → **All forms get section headers** for consistency
|
||||
- [x] CSS Grid or Flexbox? → **Flexbox** — auto-reflow on different screen sizes
|
||||
- [x] add.html layout variable? → **Option A** — `c-section` cotton component with `title` and `columns` slots
|
||||
- [x] add_purchase.html dual-submit? → **Simplify** — remove `<tr><td>`, use same `<c-button>` pattern as `add_game.html`
|
||||
- [x] GameStatusChange modal or full-page? → **Modal** — trigger via HTMX, same pattern as delete-game
|
||||
- [x] .flowbite-input class? → **Remove entirely** — rely on element-level `@apply` in `input.css`
|
||||
|
||||
## Decision Summary
|
||||
|
||||
| Question | Decision |
|
||||
|---|---|
|
||||
| Section headers on simple forms | Yes, all forms get them |
|
||||
| Layout approach for multi-column | Flexbox with wrap |
|
||||
| Layout mechanism in add.html | Option A: `c-section` cotton component |
|
||||
| Purchase dual-submit | Simplify — single submit button, same as Game |
|
||||
| GameStatusChange delete | Convert to modal (HTMX-triggered) |
|
||||
| .flowbite-input class | Remove — preserve styling via element-level `@apply` in `input.css` |
|
||||
| `modal.html` component | Rewrite with form wrapping, body + footer slots, reusable close button ✓ Implemented
|
||||
|
||||
## Build Step
|
||||
|
||||
After any CSS changes to `common/input.css`, the compiled output must be rebuilt:
|
||||
|
||||
- **`make css`** — one-shot build: `npx @tailwindcss/cli -i ./common/input.css -o ./games/static/base.css`
|
||||
- **`make dev`** — watch mode: Tailwind rebuilds automatically on every `input.css` save
|
||||
|
||||
Running `make dev` is sufficient for development since it concurrently runs Django and the CSS watcher.
|
||||
Only use `make css` if you only want to rebuild CSS without starting the dev server.
|
||||
|
||||
**Important**: Legacy CSS removals (`.basic-button`, `#button-container`, `.flowbite-input`) will only take effect in the browser after a rebuild. The old compiled `base.css` will still contain them until rebuilt.
|
||||
@@ -0,0 +1,485 @@
|
||||
# Boolean Filters Overhaul Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Overhaul the boolean criterion filters from a single checkbox (representing True/Not set) to a 2-radio-button UI representing True, False, and Unset states across all filter bars.
|
||||
|
||||
**Architecture:**
|
||||
1. Generalize `_filter_checkbox` into a filter-agnostic `Checkbox` component and introduce a `Radio` component in `common/components/primitives.py`.
|
||||
2. Implement a nullable boolean filter JSON parsing helper `_parse_bool_nullable` and a component helper `_filter_boolean_radio` in `common/components/filters.py`.
|
||||
3. Update `GameFilterBar`, `SessionFilterBar`, and `PurchaseFilterBar` in `common/components/filters.py` to leverage these new helpers.
|
||||
4. Enhance `games/static/js/filter_bar.js` with deselectable radio toggling behavior and updated checked-radio state serialization.
|
||||
|
||||
**Tech Stack:** Python, Django, vanilla JavaScript, HTML.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Generalize Checkbox and Introduce Radio in Primitives
|
||||
|
||||
**Files:**
|
||||
- Modify: `common/components/primitives.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing test for the new Checkbox and Radio primitives**
|
||||
|
||||
Create a new test class `ComponentPrimitivesTest` in `tests/test_components.py` (or verify where to append) to check the output of `Checkbox` and `Radio`.
|
||||
Add the following code to `tests/test_components.py`:
|
||||
|
||||
```python
|
||||
from common.components.primitives import Checkbox, Radio
|
||||
|
||||
class ComponentPrimitivesTest(SimpleTestCase):
|
||||
def test_checkbox_primitive(self):
|
||||
html = Checkbox(name="test-check", label="Accept Terms", checked=True, value="yes")
|
||||
self.assertIn('type="checkbox"', html)
|
||||
self.assertIn('name="test-check"', html)
|
||||
self.assertIn('value="yes"', html)
|
||||
self.assertIn('checked="true"', html)
|
||||
self.assertIn("Accept Terms", html)
|
||||
|
||||
def test_radio_primitive(self):
|
||||
html = Radio(name="test-radio", label="Option A", checked=False, value="A")
|
||||
self.assertIn('type="radio"', html)
|
||||
self.assertIn('name="test-radio"', html)
|
||||
self.assertIn('value="A"', html)
|
||||
self.assertNotIn('checked="true"', html)
|
||||
self.assertIn("Option A", html)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pytest tests/test_components.py -k ComponentPrimitivesTest`
|
||||
Expected output: Failures/errors due to `Checkbox` and `Radio` not being defined/imported.
|
||||
|
||||
- [ ] **Step 3: Implement Checkbox and Radio in `common/components/primitives.py`**
|
||||
|
||||
Open `common/components/primitives.py` and find the other basic primitives (e.g. `Input`, `Label`). Add the following implementations and ensure they are exported / added to imports/exports:
|
||||
|
||||
```python
|
||||
def Checkbox(
|
||||
name: str,
|
||||
label: str,
|
||||
checked: bool = False,
|
||||
value: str = "1",
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
) -> SafeText:
|
||||
"""A filter-agnostic Checkbox component."""
|
||||
attributes = attributes or []
|
||||
input_attrs = [
|
||||
("name", name),
|
||||
("value", value),
|
||||
("class", "rounded border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand"),
|
||||
] + attributes
|
||||
if checked:
|
||||
input_attrs.append(("checked", "true"))
|
||||
|
||||
return Label(
|
||||
attributes=[("class", "flex items-center gap-2 text-sm text-heading cursor-pointer")],
|
||||
children=[
|
||||
Input(type="checkbox", attributes=input_attrs),
|
||||
label,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def Radio(
|
||||
name: str,
|
||||
label: str,
|
||||
checked: bool = False,
|
||||
value: str = "",
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
) -> SafeText:
|
||||
"""A filter-agnostic Radio component."""
|
||||
attributes = attributes or []
|
||||
input_attrs = [
|
||||
("name", name),
|
||||
("value", value),
|
||||
("class", "rounded-full border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand"),
|
||||
] + attributes
|
||||
if checked:
|
||||
input_attrs.append(("checked", "true"))
|
||||
|
||||
return Label(
|
||||
attributes=[("class", "flex items-center gap-1.5 text-sm text-heading cursor-pointer")],
|
||||
children=[
|
||||
Input(type="radio", attributes=input_attrs),
|
||||
label,
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `pytest tests/test_components.py -k ComponentPrimitivesTest`
|
||||
Expected output: `2 passed`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git add common/components/primitives.py tests/test_components.py
|
||||
git commit -m "refactor: generalize Checkbox and add Radio primitive component"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Implement Filter Parsers & Helpers in filters.py
|
||||
|
||||
**Files:**
|
||||
- Modify: `common/components/filters.py`
|
||||
- Modify: `tests/test_filter_helpers.py`
|
||||
|
||||
- [ ] **Step 1: Write failing unit tests for `_parse_bool_nullable` in `tests/test_filter_helpers.py`**
|
||||
|
||||
Add a new test class `ParseBoolNullableTest` to `tests/test_filter_helpers.py`:
|
||||
|
||||
```python
|
||||
from common.components.filters import _parse_bool_nullable
|
||||
|
||||
class ParseBoolNullableTest(SimpleTestCase):
|
||||
def test_missing_key(self):
|
||||
self.assertIsNone(_parse_bool_nullable({}, "field"))
|
||||
|
||||
def test_null_value(self):
|
||||
self.assertIsNone(_parse_bool_nullable({"field": None}, "field"))
|
||||
self.assertIsNone(_parse_bool_nullable({"field": {}}, "field"))
|
||||
|
||||
def test_boolean_values(self):
|
||||
self.assertTrue(_parse_bool_nullable({"field": {"value": True}}, "field"))
|
||||
self.assertFalse(_parse_bool_nullable({"field": {"value": False}}, "field"))
|
||||
|
||||
def test_string_values(self):
|
||||
self.assertTrue(_parse_bool_nullable({"field": {"value": "true"}}, "field"))
|
||||
self.assertTrue(_parse_bool_nullable({"field": {"value": "1"}}, "field"))
|
||||
self.assertFalse(_parse_bool_nullable({"field": {"value": "false"}}, "field"))
|
||||
self.assertFalse(_parse_bool_nullable({"field": {"value": "0"}}, "field"))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `pytest tests/test_filter_helpers.py -k ParseBoolNullableTest`
|
||||
Expected output: Failures/errors due to `_parse_bool_nullable` not found.
|
||||
|
||||
- [ ] **Step 3: Implement `_parse_bool_nullable` and `_filter_boolean_radio` in `common/components/filters.py`**
|
||||
|
||||
1. Import `Checkbox` and `Radio` from `common.components.primitives` at the top of `common/components/filters.py`.
|
||||
2. Define `_FILTER_RADIO_CLASS` and add `_parse_bool_nullable`.
|
||||
3. Create `_filter_boolean_radio`.
|
||||
4. Refactor `_filter_checkbox` to use `Checkbox` instead of raw `Label` and `Input`.
|
||||
|
||||
Code to implement:
|
||||
```python
|
||||
_FILTER_RADIO_CLASS = (
|
||||
"rounded-full border-default-medium bg-neutral-secondary-medium "
|
||||
"text-brand focus:ring-brand"
|
||||
)
|
||||
|
||||
def _parse_bool_nullable(existing: dict, key: str) -> bool | None:
|
||||
"""Extract a nullable boolean value from a filter criterion."""
|
||||
if key not in existing:
|
||||
return None
|
||||
field = existing[key]
|
||||
if not isinstance(field, dict):
|
||||
return None
|
||||
val = field.get("value")
|
||||
if val is None:
|
||||
return None
|
||||
if isinstance(val, str):
|
||||
if val.lower() in ("true", "1", "yes"):
|
||||
return True
|
||||
if val.lower() in ("false", "0", "no"):
|
||||
return False
|
||||
return bool(val)
|
||||
|
||||
|
||||
def _filter_checkbox(name: str, label: str, checked: bool) -> SafeText:
|
||||
"""Thin adapter mapping legacy checkbox filters to the generalized Checkbox primitive."""
|
||||
return Checkbox(name=name, label=label, checked=checked)
|
||||
|
||||
|
||||
def _filter_boolean_radio(name: str, label: str, value: bool | None) -> SafeText:
|
||||
"""Renders a filter-specific boolean radio button group with 'True' and 'False' options."""
|
||||
return Div(
|
||||
attributes=[("class", "flex flex-col gap-1")],
|
||||
children=[
|
||||
Span(
|
||||
attributes=[("class", _FILTER_LABEL_CLASS)],
|
||||
children=[label],
|
||||
),
|
||||
Div(
|
||||
attributes=[("class", "flex items-center gap-4 h-9")],
|
||||
children=[
|
||||
Radio(name=name, label="True", checked=value is True, value="true"),
|
||||
Radio(name=name, label="False", checked=value is False, value="false"),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run unit tests to verify they pass**
|
||||
|
||||
Run: `pytest tests/test_filter_helpers.py`
|
||||
Expected output: All helper tests passed (including `ParseBoolNullableTest`).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git add common/components/filters.py tests/test_filter_helpers.py
|
||||
git commit -m "feat: implement _parse_bool_nullable and _filter_boolean_radio helper"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Replace Single Checkboxes with Radio Groups in Filter Bars
|
||||
|
||||
**Files:**
|
||||
- Modify: `common/components/filters.py`
|
||||
|
||||
- [ ] **Step 1: Update GameFilterBar**
|
||||
|
||||
In `common/components/filters.py` inside `GameFilterBar`:
|
||||
1. Parse using `_parse_bool_nullable` instead of `_parse_bool` for:
|
||||
- `mastered_value`
|
||||
- `purchase_refunded_value`
|
||||
- `purchase_infinite_value`
|
||||
- `session_emulated_value`
|
||||
2. Update the fields list to replace `_filter_checkbox` with `_filter_boolean_radio`, changing the wrapper div to have `gap-6` for better horizontal radio button spacing.
|
||||
|
||||
Code snippet modification:
|
||||
```python
|
||||
# Parsing:
|
||||
mastered_value = _parse_bool_nullable(existing, "mastered")
|
||||
# ...
|
||||
purchase_refunded_value = _parse_bool_nullable(existing, "purchase_refunded")
|
||||
purchase_infinite_value = _parse_bool_nullable(existing, "purchase_infinite")
|
||||
session_emulated_value = _parse_bool_nullable(existing, "session_emulated")
|
||||
|
||||
# Rendering (in fields):
|
||||
Div(
|
||||
attributes=[("class", "flex items-end gap-6 mb-4 flex-wrap")],
|
||||
children=[
|
||||
_filter_boolean_radio("filter-mastered", "Mastered", mastered_value),
|
||||
_filter_boolean_radio(
|
||||
"filter-purchase-refunded", "Refunded", purchase_refunded_value
|
||||
),
|
||||
_filter_boolean_radio(
|
||||
"filter-purchase-infinite", "Infinite", purchase_infinite_value
|
||||
),
|
||||
_filter_boolean_radio(
|
||||
"filter-session-emulated", "Emulated", session_emulated_value
|
||||
),
|
||||
],
|
||||
),
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update SessionFilterBar**
|
||||
|
||||
In `common/components/filters.py` inside `SessionFilterBar`:
|
||||
1. Parse using `_parse_bool_nullable` for:
|
||||
- `emulated_value`
|
||||
- `is_active_value`
|
||||
2. Update the fields to replace `_filter_checkbox` with `_filter_boolean_radio`.
|
||||
|
||||
Code snippet modification:
|
||||
```python
|
||||
# Parsing:
|
||||
emulated_value = _parse_bool_nullable(existing, "emulated")
|
||||
is_active_value = _parse_bool_nullable(existing, "is_active")
|
||||
|
||||
# Rendering (in fields):
|
||||
Div(
|
||||
attributes=[("class", "flex gap-6 mb-4")],
|
||||
children=[
|
||||
_filter_boolean_radio("filter-emulated", "Emulated", emulated_value),
|
||||
_filter_boolean_radio("filter-active", "Active", is_active_value),
|
||||
],
|
||||
),
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update PurchaseFilterBar**
|
||||
|
||||
In `common/components/filters.py` inside `PurchaseFilterBar`:
|
||||
1. Parse using `_parse_bool_nullable` for:
|
||||
- `is_refunded_value`
|
||||
- `infinite_value`
|
||||
- `needs_price_update_value`
|
||||
2. Update the fields to replace `_filter_checkbox` with `_filter_boolean_radio`.
|
||||
|
||||
Code snippet modification:
|
||||
```python
|
||||
# Parsing:
|
||||
is_refunded_value = _parse_bool_nullable(existing, "is_refunded")
|
||||
infinite_value = _parse_bool_nullable(existing, "infinite")
|
||||
needs_price_update_value = _parse_bool_nullable(existing, "needs_price_update")
|
||||
|
||||
# Rendering (in fields):
|
||||
Div(
|
||||
attributes=[("class", "flex flex-col items-start gap-4 mb-4")],
|
||||
children=[
|
||||
_filter_boolean_radio(
|
||||
"filter-refunded", "Refunded", is_refunded_value
|
||||
),
|
||||
_filter_boolean_radio("filter-infinite", "Infinite", infinite_value),
|
||||
_filter_boolean_radio(
|
||||
"filter-needs-price-update",
|
||||
"Needs Price Update",
|
||||
needs_price_update_value,
|
||||
),
|
||||
],
|
||||
),
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run component tests to verify output**
|
||||
|
||||
Run: `pytest tests/test_filter_bars.py`
|
||||
Expected output: Since we only changed the internal input type from checkbox to radio but kept the `name="..."` attribute intact, the tests asserting name occurrences should still pass!
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git add common/components/filters.py
|
||||
git commit -m "feat: replace single boolean checkboxes with radio groups in all FilterBars"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Frontend Behavior and Serialization in JS
|
||||
|
||||
**Files:**
|
||||
- Modify: `games/static/js/filter_bar.js`
|
||||
|
||||
- [ ] **Step 1: Update Radio Serialization in `buildFilterJSON`**
|
||||
|
||||
In `games/static/js/filter_bar.js`, locate the `// 2. Boolean Fields (Checkboxes)` section.
|
||||
Update the loop to check for `:checked` radio options:
|
||||
|
||||
```javascript
|
||||
// 2. Boolean Fields (Radio Button Groups)
|
||||
var booleanFields = [
|
||||
{ name: "filter-mastered", key: "mastered" },
|
||||
{ name: "filter-emulated", key: "emulated" },
|
||||
{ name: "filter-active", key: "is_active" },
|
||||
{ name: "filter-refunded", key: "is_refunded" },
|
||||
{ name: "filter-infinite", key: "infinite" },
|
||||
{ name: "filter-needs-price-update", key: "needs_price_update" },
|
||||
{ name: "filter-purchase-refunded", key: "purchase_refunded" },
|
||||
{ name: "filter-purchase-infinite", key: "purchase_infinite" },
|
||||
{ name: "filter-session-emulated", key: "session_emulated" }
|
||||
];
|
||||
booleanFields.forEach(function (bf) {
|
||||
var el = form.querySelector('[name="' + bf.name + '"]:checked');
|
||||
if (el) {
|
||||
var val = el.value === "true";
|
||||
filter[bf.key] = criterion(val, null, "EQUALS");
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add click-to-deselect functionality for radios**
|
||||
|
||||
In `games/static/js/filter_bar.js`, add `setupDeselectableRadios` and call it inside `DOMContentLoaded`:
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* Enable deselect-on-click behavior for filter radio buttons.
|
||||
*/
|
||||
function setupDeselectableRadios() {
|
||||
document.querySelectorAll('input[type="radio"]').forEach(function (radio) {
|
||||
radio.addEventListener('click', function (e) {
|
||||
if (this.wasChecked) {
|
||||
this.checked = false;
|
||||
this.wasChecked = false;
|
||||
this.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
} else {
|
||||
var name = this.getAttribute('name');
|
||||
if (name) {
|
||||
document.querySelectorAll('input[type="radio"][name="' + name + '"]').forEach(function (r) {
|
||||
r.wasChecked = false;
|
||||
});
|
||||
}
|
||||
this.wasChecked = true;
|
||||
}
|
||||
});
|
||||
if (radio.checked) {
|
||||
radio.wasChecked = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Locate the `document.addEventListener("DOMContentLoaded", ...)` callback at the bottom of the file and update it:
|
||||
```javascript
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
injectSearchInputs();
|
||||
setupDeselectableRadios();
|
||||
loadPresets();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run existing frontend / component tests to verify no syntax errors or simple breaks**
|
||||
|
||||
Run: `pytest tests/test_filter_bars.py`
|
||||
Expected output: PASS
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git add games/static/js/filter_bar.js
|
||||
git commit -m "feat: add click-to-deselect behavior and update checked-radio serialization in JS"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Add Comprehensive Test Coverage & Verification
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/test_filter_bars.py`
|
||||
|
||||
- [ ] **Step 1: Write explicit tests for boolean radio elements in filter bars**
|
||||
|
||||
Add a test case checking that the filter bars output `type="radio"` and contain `value="true"` and `value="false"` for boolean fields:
|
||||
|
||||
In `tests/test_filter_bars.py`, add the following test method:
|
||||
|
||||
```python
|
||||
def test_boolean_fields_render_as_radio_groups(self):
|
||||
"""Boolean fields must render as radio groups with True/False choices."""
|
||||
from common.components import FilterBar, SessionFilterBar, PurchaseFilterBar
|
||||
|
||||
# 1. Games Filter Bar
|
||||
games_html = str(FilterBar(filter_json=""))
|
||||
self.assertIn('type="radio"', games_html)
|
||||
self.assertIn('name="filter-mastered"', games_html)
|
||||
self.assertIn('value="true"', games_html)
|
||||
self.assertIn('value="false"', games_html)
|
||||
|
||||
# 2. Session Filter Bar
|
||||
session_html = str(SessionFilterBar(filter_json=""))
|
||||
self.assertIn('type="radio"', session_html)
|
||||
self.assertIn('name="filter-emulated"', session_html)
|
||||
self.assertIn('value="true"', session_html)
|
||||
self.assertIn('value="false"', session_html)
|
||||
|
||||
# 3. Purchase Filter Bar
|
||||
purchase_html = str(PurchaseFilterBar(filter_json=""))
|
||||
self.assertIn('type="radio"', purchase_html)
|
||||
self.assertIn('name="filter-refunded"', purchase_html)
|
||||
self.assertIn('value="true"', purchase_html)
|
||||
self.assertIn('value="false"', purchase_html)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run pytest to verify all tests (including new ones) pass**
|
||||
|
||||
Run: `pytest`
|
||||
Expected output: `356 passed` (including the new test case).
|
||||
|
||||
- [ ] **Step 3: Commit final tests**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
git add tests/test_filter_bars.py
|
||||
git commit -m "test: add explicit radio group and True/False choice checks for boolean fields"
|
||||
```
|
||||
@@ -0,0 +1,662 @@
|
||||
# Comprehensive Filters Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Implement a comprehensive suite of backend filter classes and filter field expansions across all 6 main models (Game, Session, Purchase, Device, Platform, PlayEvent) using a subquery-based cross-entity approach.
|
||||
|
||||
**Architecture:** We will implement missing filter classes (`DeviceFilter`, `PlatformFilter`, `PlayEventFilter`) in `games/filters.py`. We will extend all filters to support powerful, deeply linked "cross-entity" subqueries (e.g. `GameFilter.session_filter` or `PlatformFilter.game_filter`) which builds robust `Q` objects without causing duplicate join rows in list queries.
|
||||
|
||||
**Tech Stack:** Django, Python dataclasses, Pytest.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Implement New Filter Classes (Device, Platform, PlayEvent)
|
||||
|
||||
**Files:**
|
||||
- Modify: `games/filters.py`
|
||||
- Test: `tests/test_filters.py`
|
||||
|
||||
- [ ] **Step 1: Implement DeviceFilter, PlatformFilter, and PlayEventFilter**
|
||||
|
||||
Add the three new operator filters to `games/filters.py`. Ensure we import all necessary criterion types and add the `parse_device_filter`, `parse_platform_filter`, and `parse_playevent_filter` helper functions at the end of the file.
|
||||
|
||||
```python
|
||||
# Insert new filter imports and classes in games/filters.py
|
||||
|
||||
@dataclass
|
||||
class DeviceFilter(OperatorFilter):
|
||||
"""Filter for the Device model."""
|
||||
|
||||
AND: DeviceFilter | None = None
|
||||
OR: DeviceFilter | None = None
|
||||
NOT: DeviceFilter | None = None
|
||||
|
||||
name: StringCriterion | None = None
|
||||
type: ChoiceCriterion | None = None
|
||||
created_at: StringCriterion | None = None
|
||||
|
||||
# Free-text search
|
||||
search: StringCriterion | None = None
|
||||
|
||||
# Cross-entity: Devices that have sessions matching these criteria
|
||||
session_filter: SessionFilter | None = None
|
||||
|
||||
def to_q(self) -> Q:
|
||||
q = Q()
|
||||
|
||||
if self.name is not None:
|
||||
q &= self.name.to_q("name")
|
||||
if self.type is not None:
|
||||
q &= self.type.to_q("type")
|
||||
if self.created_at is not None:
|
||||
q &= self.created_at.to_q("created_at")
|
||||
|
||||
# Free-text search
|
||||
if self.search is not None and self.search.value:
|
||||
search_q = (
|
||||
Q(name__icontains=self.search.value)
|
||||
| Q(type__icontains=self.search.value)
|
||||
)
|
||||
if self.search.modifier == Modifier.EXCLUDES:
|
||||
search_q = ~search_q
|
||||
q &= search_q
|
||||
|
||||
# Cross-entity filter: session_filter
|
||||
if self.session_filter is not None:
|
||||
from games.models import Session
|
||||
session_q = self.session_filter.to_q()
|
||||
matching_ids = Session.objects.filter(session_q).values_list("device_id", flat=True)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
sub = self.sub_filter()
|
||||
if sub is not None:
|
||||
if self.AND is not None:
|
||||
q &= sub.to_q()
|
||||
elif self.OR is not None:
|
||||
q |= sub.to_q()
|
||||
elif self.NOT is not None:
|
||||
q &= ~sub.to_q()
|
||||
|
||||
return q
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlatformFilter(OperatorFilter):
|
||||
"""Filter for the Platform model."""
|
||||
|
||||
AND: PlatformFilter | None = None
|
||||
OR: PlatformFilter | None = None
|
||||
NOT: PlatformFilter | None = None
|
||||
|
||||
name: StringCriterion | None = None
|
||||
group: StringCriterion | None = None
|
||||
icon: StringCriterion | None = None
|
||||
created_at: StringCriterion | None = None
|
||||
|
||||
# Free-text search
|
||||
search: StringCriterion | None = None
|
||||
|
||||
# Cross-entity
|
||||
game_filter: GameFilter | None = None
|
||||
purchase_filter: PurchaseFilter | None = None
|
||||
|
||||
def to_q(self) -> Q:
|
||||
q = Q()
|
||||
|
||||
if self.name is not None:
|
||||
q &= self.name.to_q("name")
|
||||
if self.group is not None:
|
||||
q &= self.group.to_q("group")
|
||||
if self.icon is not None:
|
||||
q &= self.icon.to_q("icon")
|
||||
if self.created_at is not None:
|
||||
q &= self.created_at.to_q("created_at")
|
||||
|
||||
# Free-text search
|
||||
if self.search is not None and self.search.value:
|
||||
search_q = (
|
||||
Q(name__icontains=self.search.value)
|
||||
| Q(group__icontains=self.search.value)
|
||||
)
|
||||
if self.search.modifier == Modifier.EXCLUDES:
|
||||
search_q = ~search_q
|
||||
q &= search_q
|
||||
|
||||
# Cross-entity filter: game_filter
|
||||
if self.game_filter is not None:
|
||||
from games.models import Game
|
||||
game_q = self.game_filter.to_q()
|
||||
matching_ids = Game.objects.filter(game_q).values_list("platform_id", flat=True)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
# Cross-entity filter: purchase_filter
|
||||
if self.purchase_filter is not None:
|
||||
from games.models import Purchase
|
||||
purchase_q = self.purchase_filter.to_q()
|
||||
matching_ids = Purchase.objects.filter(purchase_q).values_list("platform_id", flat=True)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
sub = self.sub_filter()
|
||||
if sub is not None:
|
||||
if self.AND is not None:
|
||||
q &= sub.to_q()
|
||||
elif self.OR is not None:
|
||||
q |= sub.to_q()
|
||||
elif self.NOT is not None:
|
||||
q &= ~sub.to_q()
|
||||
|
||||
return q
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlayEventFilter(OperatorFilter):
|
||||
"""Filter for the PlayEvent model."""
|
||||
|
||||
AND: PlayEventFilter | None = None
|
||||
OR: PlayEventFilter | None = None
|
||||
NOT: PlayEventFilter | None = None
|
||||
|
||||
game: MultiCriterion | None = None # filters on game_id
|
||||
started: StringCriterion | None = None # date string
|
||||
ended: StringCriterion | None = None # date string
|
||||
days_to_finish: IntCriterion | None = None
|
||||
note: StringCriterion | None = None
|
||||
created_at: StringCriterion | None = None
|
||||
|
||||
# Free-text search
|
||||
search: StringCriterion | None = None
|
||||
|
||||
# Cross-entity: PlayEvents for games matching these criteria
|
||||
game_filter: GameFilter | None = None
|
||||
|
||||
def to_q(self) -> Q:
|
||||
q = Q()
|
||||
|
||||
if self.game is not None:
|
||||
q &= self.game.to_q("game_id")
|
||||
if self.started is not None:
|
||||
q &= self.started.to_q("started")
|
||||
if self.ended is not None:
|
||||
q &= self.ended.to_q("ended")
|
||||
if self.days_to_finish is not None:
|
||||
q &= self.days_to_finish.to_q("days_to_finish")
|
||||
if self.note is not None:
|
||||
q &= self.note.to_q("note")
|
||||
if self.created_at is not None:
|
||||
q &= self.created_at.to_q("created_at")
|
||||
|
||||
# Free-text search
|
||||
if self.search is not None and self.search.value:
|
||||
search_q = (
|
||||
Q(game__name__icontains=self.search.value)
|
||||
| Q(note__icontains=self.search.value)
|
||||
)
|
||||
if self.search.modifier == Modifier.EXCLUDES:
|
||||
search_q = ~search_q
|
||||
q &= search_q
|
||||
|
||||
# Cross-entity filter: game_filter
|
||||
if self.game_filter is not None:
|
||||
from games.models import Game
|
||||
game_q = self.game_filter.to_q()
|
||||
matching_ids = Game.objects.filter(game_q).values_list("id", flat=True)
|
||||
q &= Q(game_id__in=matching_ids)
|
||||
|
||||
sub = self.sub_filter()
|
||||
if sub is not None:
|
||||
if self.AND is not None:
|
||||
q &= sub.to_q()
|
||||
elif self.OR is not None:
|
||||
q |= sub.to_q()
|
||||
elif self.NOT is not None:
|
||||
q &= ~sub.to_q()
|
||||
|
||||
return q
|
||||
|
||||
|
||||
# Add to convenience helpers section:
|
||||
def parse_device_filter(json_str: str) -> DeviceFilter | None:
|
||||
return filter_from_json(DeviceFilter, json_str)
|
||||
|
||||
|
||||
def parse_platform_filter(json_str: str) -> PlatformFilter | None:
|
||||
return filter_from_json(PlatformFilter, json_str)
|
||||
|
||||
|
||||
def parse_playevent_filter(json_str: str) -> PlayEventFilter | None:
|
||||
return filter_from_json(PlayEventFilter, json_str)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run existing tests to verify everything compiles**
|
||||
|
||||
Run: `pytest tests/test_filters.py -v`
|
||||
Expected: All existing tests PASS without issues.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Expand SessionFilter (Duration Fields + Cross-Entity)
|
||||
|
||||
**Files:**
|
||||
- Modify: `games/filters.py:SessionFilter`
|
||||
- Test: `tests/test_filters.py`
|
||||
|
||||
- [ ] **Step 1: Refactor SessionFilter and add new duration fields & device_filter**
|
||||
|
||||
Modify `SessionFilter` to replace `duration_minutes: IntCriterion` with `duration_total_minutes`, `duration_manual_minutes`, and `duration_calculated_minutes`. Add `device_filter: DeviceFilter`.
|
||||
|
||||
Update `to_q()` inside `SessionFilter` to map duration fields correctly to their respective GeneratedFields (`duration_total`, `duration_calculated`) or manual field (`duration_manual`). Use standard Python `timedelta` logic.
|
||||
|
||||
```python
|
||||
# Inside SessionFilter class:
|
||||
duration_total_minutes: IntCriterion | None = None
|
||||
duration_manual_minutes: IntCriterion | None = None
|
||||
duration_calculated_minutes: IntCriterion | None = None
|
||||
|
||||
# Cross-entity: sessions for devices matching these criteria
|
||||
device_filter: DeviceFilter | None = None
|
||||
```
|
||||
|
||||
```python
|
||||
# Helper inside SessionFilter or refactored:
|
||||
def _duration_to_q(self, c: IntCriterion, field: str) -> Q:
|
||||
from datetime import timedelta
|
||||
q = Q()
|
||||
td_val = timedelta(minutes=c.value)
|
||||
m = c.modifier
|
||||
if m == Modifier.EQUALS:
|
||||
q &= Q(
|
||||
**{
|
||||
f"{field}__gte": td_val,
|
||||
f"{field}__lt": timedelta(minutes=c.value + 1),
|
||||
}
|
||||
)
|
||||
elif m == Modifier.NOT_EQUALS:
|
||||
q &= ~Q(
|
||||
**{
|
||||
f"{field}__gte": td_val,
|
||||
f"{field}__lt": timedelta(minutes=c.value + 1),
|
||||
}
|
||||
)
|
||||
elif m == Modifier.GREATER_THAN:
|
||||
q &= Q(**{f"{field}__gt": td_val})
|
||||
elif m == Modifier.LESS_THAN:
|
||||
q &= Q(**{f"{field}__lt": td_val})
|
||||
elif m == Modifier.BETWEEN and c.value2 is not None:
|
||||
lo = timedelta(minutes=min(c.value, c.value2))
|
||||
hi = timedelta(minutes=max(c.value, c.value2))
|
||||
q &= Q(**{f"{field}__gte": lo, f"{f_field}__lte": hi})
|
||||
elif m == Modifier.NOT_BETWEEN and c.value2 is not None:
|
||||
lo = timedelta(minutes=min(c.value, c.value2))
|
||||
hi = timedelta(minutes=max(c.value, c.value2))
|
||||
q &= Q(**{f"{field}__lt": lo}) | Q(**{f"{field}__gt": hi})
|
||||
elif m == Modifier.IS_NULL:
|
||||
q &= Q(**{f"{field}": timedelta(0)})
|
||||
elif m == Modifier.NOT_NULL:
|
||||
q &= ~Q(**{f"{field}": timedelta(0)})
|
||||
return q
|
||||
```
|
||||
|
||||
Then in `to_q()` inside `SessionFilter`:
|
||||
```python
|
||||
if self.duration_total_minutes is not None:
|
||||
q &= self._duration_to_q(self.duration_total_minutes, "duration_total")
|
||||
if self.duration_manual_minutes is not None:
|
||||
q &= self._duration_to_q(self.duration_manual_minutes, "duration_manual")
|
||||
if self.duration_calculated_minutes is not None:
|
||||
q &= self._duration_to_q(self.duration_calculated_minutes, "duration_calculated")
|
||||
|
||||
# Cross-entity filter: device_filter
|
||||
if self.device_filter is not None:
|
||||
from games.models import Device
|
||||
device_q = self.device_filter.to_q()
|
||||
matching_ids = Device.objects.filter(device_q).values_list("id", flat=True)
|
||||
q &= Q(device_id__in=matching_ids)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify compiles correctly**
|
||||
|
||||
Run: `pytest tests/test_filters.py -v`
|
||||
Expected: PASS (existing tests may need updating if they referenced `duration_minutes`).
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Expand PurchaseFilter (Original Currency, Infinite, Needs Price Update, Converted Currency)
|
||||
|
||||
**Files:**
|
||||
- Modify: `games/filters.py:PurchaseFilter`
|
||||
- Test: `tests/test_filters.py`
|
||||
|
||||
- [ ] **Step 1: Add new fields to PurchaseFilter and platform_filter**
|
||||
|
||||
Expand `PurchaseFilter` with `infinite: BoolCriterion`, `needs_price_update: BoolCriterion`, `converted_currency: StringCriterion`, and `platform_filter: PlatformFilter`.
|
||||
|
||||
```python
|
||||
# Inside PurchaseFilter class:
|
||||
infinite: BoolCriterion | None = None
|
||||
needs_price_update: BoolCriterion | None = None
|
||||
converted_currency: StringCriterion | None = None
|
||||
|
||||
# Cross-entity
|
||||
platform_filter: PlatformFilter | None = None
|
||||
```
|
||||
|
||||
Update `to_q()` inside `PurchaseFilter`:
|
||||
```python
|
||||
if self.infinite is not None:
|
||||
q &= self.infinite.to_q("infinite")
|
||||
if self.needs_price_update is not None:
|
||||
q &= self.needs_price_update.to_q("needs_price_update")
|
||||
if self.converted_currency is not None:
|
||||
q &= self.converted_currency.to_q("converted_currency")
|
||||
|
||||
# Cross-entity filter: platform_filter
|
||||
if self.platform_filter is not None:
|
||||
from games.models import Platform
|
||||
platform_q = self.platform_filter.to_q()
|
||||
matching_ids = Platform.objects.filter(platform_q).values_list("id", flat=True)
|
||||
q &= Q(platform_id__in=matching_ids)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify test suite continues to pass**
|
||||
|
||||
Run: `pytest tests/test_filters.py -v`
|
||||
Expected: PASS
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Expand GameFilter (Has Purchases, Has PlayEvents, Session Stats, Cross-Entity)
|
||||
|
||||
**Files:**
|
||||
- Modify: `games/filters.py:GameFilter`
|
||||
- Test: `tests/test_filters.py`
|
||||
|
||||
- [ ] **Step 1: Expand GameFilter with session stats, purchase/playevent existence, and cross-entity filters**
|
||||
|
||||
Add fields and cross-entity filters to `GameFilter`:
|
||||
```python
|
||||
# Inside GameFilter class:
|
||||
has_purchases: BoolCriterion | None = None
|
||||
has_playevents: BoolCriterion | None = None
|
||||
session_count: IntCriterion | None = None
|
||||
session_average: IntCriterion | None = None # average in minutes
|
||||
|
||||
# Cross-entity filters
|
||||
session_filter: SessionFilter | None = None
|
||||
purchase_filter: PurchaseFilter | None = None
|
||||
playevent_filter: PlayEventFilter | None = None
|
||||
platform_filter: PlatformFilter | None = None
|
||||
```
|
||||
|
||||
Update `to_q()` inside `GameFilter`.
|
||||
For existence and session stats filters, we use Subqueries to avoid complex inline annotations during the generic filter generation (which is much cleaner and less bug-prone):
|
||||
|
||||
```python
|
||||
if self.has_purchases is not None:
|
||||
from games.models import Purchase
|
||||
purchased_ids = Purchase.objects.values_list("games__id", flat=True).distinct()
|
||||
if self.has_purchases.value:
|
||||
q &= Q(id__in=purchased_ids)
|
||||
else:
|
||||
q &= ~Q(id__in=purchased_ids)
|
||||
|
||||
if self.has_playevents is not None:
|
||||
from games.models import PlayEvent
|
||||
played_ids = PlayEvent.objects.values_list("game_id", flat=True).distinct()
|
||||
if self.has_playevents.value:
|
||||
q &= Q(id__in=played_ids)
|
||||
else:
|
||||
q &= ~Q(id__in=played_ids)
|
||||
|
||||
if self.session_count is not None:
|
||||
from games.models import Game
|
||||
from django.db.models import Count
|
||||
matching_ids = Game.objects.annotate(s_count=Count("sessions")).filter(self.session_count.to_q("s_count")).values_list("id", flat=True)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
if self.session_average is not None:
|
||||
from games.models import Game, Session
|
||||
from django.db.models import Avg, F, ExpressionWrapper, DurationField
|
||||
# Compute average session total duration.
|
||||
# Avg returns an interval/duration type, so we can convert it to minutes in Python or do duration comparisons directly.
|
||||
# To match the criterion easily, we can filter Game objects using Avg:
|
||||
matching_ids = Game.objects.annotate(s_avg=Avg("sessions__duration_total")).filter(self._playtime_to_q_for_field(self.session_average, "s_avg")).values_list("id", flat=True)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
# Cross-entity filters
|
||||
if self.session_filter is not None:
|
||||
from games.models import Session
|
||||
session_q = self.session_filter.to_q()
|
||||
matching_ids = Session.objects.filter(session_q).values_list("game_id", flat=True)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
if self.purchase_filter is not None:
|
||||
from games.models import Purchase
|
||||
purchase_q = self.purchase_filter.to_q()
|
||||
matching_ids = Purchase.objects.filter(purchase_q).values_list("games__id", flat=True)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
if self.playevent_filter is not None:
|
||||
from games.models import PlayEvent
|
||||
playevent_q = self.playevent_filter.to_q()
|
||||
matching_ids = PlayEvent.objects.filter(playevent_q).values_list("game_id", flat=True)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
if self.platform_filter is not None:
|
||||
from games.models import Platform
|
||||
platform_q = self.platform_filter.to_q()
|
||||
matching_ids = Platform.objects.filter(platform_q).values_list("id", flat=True)
|
||||
q &= Q(platform_id__in=matching_ids)
|
||||
```
|
||||
|
||||
Add a helper `_playtime_to_q_for_field` in `GameFilter` that works exactly like `_playtime_to_q` but accepts a customized field name (e.g. `s_avg`):
|
||||
```python
|
||||
@staticmethod
|
||||
def _playtime_to_q_for_field(c: IntCriterion, field: str) -> Q:
|
||||
from datetime import timedelta
|
||||
m = c.modifier
|
||||
td_val = timedelta(minutes=c.value)
|
||||
|
||||
if m == Modifier.EQUALS:
|
||||
return Q(
|
||||
**{
|
||||
f"{field}__gte": td_val,
|
||||
f"{field}__lt": timedelta(minutes=c.value + 1),
|
||||
}
|
||||
)
|
||||
if m == Modifier.NOT_EQUALS:
|
||||
return ~Q(
|
||||
**{
|
||||
f"{field}__gte": td_val,
|
||||
f"{field}__lt": timedelta(minutes=c.value + 1),
|
||||
}
|
||||
)
|
||||
if m == Modifier.GREATER_THAN:
|
||||
return Q(**{f"{field}__gt": td_val})
|
||||
if m == Modifier.LESS_THAN:
|
||||
return Q(**{f"{field}__lt": td_val})
|
||||
if m == Modifier.BETWEEN and c.value2 is not None:
|
||||
lo = timedelta(minutes=min(c.value, c.value2))
|
||||
hi = timedelta(minutes=max(c.value, c.value2))
|
||||
return Q(**{f"{field}__gte": lo, f"{field}__lte": hi})
|
||||
if m == Modifier.NOT_BETWEEN and c.value2 is not None:
|
||||
lo = timedelta(minutes=min(c.value, c.value2))
|
||||
hi = timedelta(minutes=max(c.value, c.value2))
|
||||
return Q(**{f"{field}__lt": lo}) | Q(**{f"{field}__gt": hi})
|
||||
if m == Modifier.IS_NULL:
|
||||
return Q(**{f"{field}": timedelta(0)})
|
||||
if m == Modifier.NOT_NULL:
|
||||
return ~Q(**{f"{field}": timedelta(0)})
|
||||
return Q()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update existing `_playtime_to_q` to delegate to `_playtime_to_q_for_field`**
|
||||
```python
|
||||
@staticmethod
|
||||
def _playtime_to_q(c: IntCriterion) -> Q:
|
||||
return GameFilter._playtime_to_q_for_field(c, "playtime")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Add Exhaustive DB Tests for the Expanded and New Filters
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/test_filters.py`
|
||||
|
||||
- [ ] **Step 1: Write DB-backed unit tests for the new filter behaviors**
|
||||
|
||||
Add comprehensive test cases inside `tests/test_filters.py` covering:
|
||||
- New cross-entity filters (e.g. Platform -> Game -> Session -> Device chain).
|
||||
- Session total vs manual vs calculated duration filters.
|
||||
- Game session stats (`session_count`, `session_average`) and presence flags (`has_purchases`, `has_playevents`).
|
||||
- Device, Platform, and PlayEvent specific filters.
|
||||
|
||||
```python
|
||||
# Add test class at the end of tests/test_filters.py:
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestExpandedFiltersAgainstDB:
|
||||
def _setup_entities(self):
|
||||
from games.models import Game, Platform, Device, Session, Purchase, PlayEvent
|
||||
import datetime
|
||||
from datetime import timedelta
|
||||
|
||||
# 1. Platform & Game
|
||||
plat, _ = Platform.objects.get_or_create(name="Retro Console", group="Nintendo", icon="retro")
|
||||
game, _ = Game.objects.get_or_create(name="Super Mario World", defaults={"platform": plat, "status": "f"})
|
||||
game2, _ = Game.objects.get_or_create(name="Zelda", defaults={"platform": plat, "status": "u"})
|
||||
|
||||
# 2. Device & Session
|
||||
dev, _ = Device.objects.get_or_create(name="Super Famicom", type="Console")
|
||||
|
||||
# Session 1: total 40 minutes (30 calc, 10 manual)
|
||||
s1 = Session.objects.create(
|
||||
game=game,
|
||||
device=dev,
|
||||
timestamp_start=datetime.datetime(2026, 6, 1, 12, 0, 0, tzinfo=datetime.timezone.utc),
|
||||
timestamp_end=datetime.datetime(2026, 6, 1, 12, 30, 0, tzinfo=datetime.timezone.utc),
|
||||
duration_manual=timedelta(minutes=10)
|
||||
)
|
||||
|
||||
# 3. Purchase
|
||||
pur = Purchase.objects.create(
|
||||
platform=plat,
|
||||
date_purchased=datetime.date(2026, 1, 1),
|
||||
infinite=True,
|
||||
price=49.99,
|
||||
price_currency="JPY",
|
||||
converted_price=45.00,
|
||||
converted_currency="USD",
|
||||
needs_price_update=False
|
||||
)
|
||||
pur.games.add(game)
|
||||
|
||||
# 4. PlayEvent
|
||||
pe = PlayEvent.objects.create(
|
||||
game=game,
|
||||
started=datetime.date(2026, 6, 1),
|
||||
ended=datetime.date(2026, 6, 2),
|
||||
note="Completed 100%"
|
||||
)
|
||||
|
||||
return {
|
||||
"plat": plat,
|
||||
"game": game,
|
||||
"game2": game2,
|
||||
"dev": dev,
|
||||
"s1": s1,
|
||||
"pur": pur,
|
||||
"pe": pe
|
||||
}
|
||||
|
||||
def test_device_filter_and_cross_entity(self):
|
||||
from games.filters import DeviceFilter, SessionFilter
|
||||
from games.models import Device
|
||||
|
||||
data = self._setup_entities()
|
||||
# Find devices that have sessions on "Super Mario World"
|
||||
df = DeviceFilter.from_json({
|
||||
"session_filter": {
|
||||
"game_filter": {
|
||||
"name": {"value": "Super Mario World", "modifier": "EQUALS"}
|
||||
}
|
||||
}
|
||||
})
|
||||
results = list(Device.objects.filter(df.to_q()))
|
||||
assert data["dev"] in results
|
||||
|
||||
def test_platform_filter_and_cross_entity(self):
|
||||
from games.filters import PlatformFilter, GameFilter
|
||||
from games.models import Platform
|
||||
|
||||
data = self._setup_entities()
|
||||
# Find platforms with games that are finished
|
||||
pf = PlatformFilter.from_json({
|
||||
"game_filter": {
|
||||
"status": {"value": ["f"], "modifier": "INCLUDES"}
|
||||
}
|
||||
})
|
||||
results = list(Platform.objects.filter(pf.to_q()))
|
||||
assert data["plat"] in results
|
||||
|
||||
def test_session_filter_duration_splits(self):
|
||||
from games.filters import SessionFilter
|
||||
from games.models import Session
|
||||
|
||||
data = self._setup_entities()
|
||||
|
||||
# Test duration_total_minutes equals 40
|
||||
sf_tot = SessionFilter.from_json({
|
||||
"duration_total_minutes": {"value": 40, "modifier": "EQUALS"}
|
||||
})
|
||||
assert Session.objects.filter(sf_tot.to_q()).count() == 1
|
||||
|
||||
# Test duration_manual_minutes equals 10
|
||||
sf_man = SessionFilter.from_json({
|
||||
"duration_manual_minutes": {"value": 10, "modifier": "EQUALS"}
|
||||
})
|
||||
assert Session.objects.filter(sf_man.to_q()).count() == 1
|
||||
|
||||
# Test duration_calculated_minutes equals 30
|
||||
sf_calc = SessionFilter.from_json({
|
||||
"duration_calculated_minutes": {"value": 30, "modifier": "EQUALS"}
|
||||
})
|
||||
assert Session.objects.filter(sf_calc.to_q()).count() == 1
|
||||
|
||||
def test_purchase_filter_new_fields(self):
|
||||
from games.filters import PurchaseFilter
|
||||
from games.models import Purchase
|
||||
|
||||
data = self._setup_entities()
|
||||
|
||||
pf = PurchaseFilter.from_json({
|
||||
"infinite": {"value": True, "modifier": "EQUALS"},
|
||||
"needs_price_update": {"value": False, "modifier": "EQUALS"},
|
||||
"converted_currency": {"value": "USD", "modifier": "EQUALS"}
|
||||
})
|
||||
assert Purchase.objects.filter(pf.to_q()).count() == 1
|
||||
|
||||
def test_game_filter_stats_and_existence(self):
|
||||
from games.filters import GameFilter
|
||||
from games.models import Game
|
||||
|
||||
data = self._setup_entities()
|
||||
|
||||
# has_purchases = True
|
||||
gf_pur = GameFilter.from_json({
|
||||
"has_purchases": {"value": True, "modifier": "EQUALS"}
|
||||
})
|
||||
assert data["game"] in list(Game.objects.filter(gf_pur.to_q()))
|
||||
assert data["game2"] not in list(Game.objects.filter(gf_pur.to_q()))
|
||||
|
||||
# session_count = 1
|
||||
gf_cnt = GameFilter.from_json({
|
||||
"session_count": {"value": 1, "modifier": "EQUALS"}
|
||||
})
|
||||
assert data["game"] in list(Game.objects.filter(gf_cnt.to_q()))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run all unit tests to confirm success**
|
||||
|
||||
Run: `pytest tests/test_filters.py -v`
|
||||
Expected: ALL tests pass perfectly.
|
||||
@@ -0,0 +1,577 @@
|
||||
# Frontend Filters Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Implement a comprehensive frontend filter bar interface for all 6 list views (Games, Sessions, Purchases, Devices, Platforms, PlayEvents) with specific field controls, simple cross-entity toggles, and full JSON preset support.
|
||||
|
||||
**Architecture:** We will extend existing components in `common/components/filters.py` and implement new filter bars (`DeviceFilterBar`, `PlatformFilterBar`, `PlayEventFilterBar`). We will update the views in `games/views/` to parse standard filter JSON from `request.GET.get('filter')`, apply them to querysets, render the filter bars, and export them in `common/components/__init__.py`.
|
||||
|
||||
**Tech Stack:** Django, Python dataclasses, Pytest.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Update existing FilterBars in `common/components/filters.py`
|
||||
|
||||
**Files:**
|
||||
- Modify: `common/components/filters.py`
|
||||
|
||||
- [ ] **Step 1: Add new fields to GameFilterBar**
|
||||
Add checkboxes for `has_purchases`, `has_playevents` and RangeSliders for `session_count`, `session_average`.
|
||||
|
||||
```python
|
||||
# Inside common/components/filters.py: FilterBar()
|
||||
|
||||
# Parse new values
|
||||
has_purchases_value = _parse_bool(existing, "has_purchases")
|
||||
has_playevents_value = _parse_bool(existing, "has_playevents")
|
||||
session_count_min, session_count_max = _parse_range(existing, "session_count")
|
||||
session_avg_min, session_avg_max = _parse_range(existing, "session_average")
|
||||
|
||||
# Add components to fields:
|
||||
# 1. Under status and platform, add the checkboxes for purchases/playevents
|
||||
# 2. Add RangeSliders for session count and average
|
||||
```
|
||||
|
||||
Code change to apply in `FilterBar`:
|
||||
```python
|
||||
fields = [
|
||||
Component(
|
||||
tag_name="div",
|
||||
attributes=[("class", _FILTER_GRID_CLASS)],
|
||||
children=[
|
||||
_filter_field(
|
||||
"Status",
|
||||
_enum_filter(
|
||||
"status",
|
||||
status_options,
|
||||
status_choice,
|
||||
nullable=not Game._meta.get_field("status").has_default(),
|
||||
),
|
||||
),
|
||||
_filter_field(
|
||||
"Platform",
|
||||
_model_filter(
|
||||
"platform",
|
||||
platform_choice,
|
||||
search_url="/api/platforms/search",
|
||||
nullable=Game._meta.get_field("platform").null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
RangeSlider(
|
||||
label="Year",
|
||||
input_name_prefix="filter-year",
|
||||
min_value=year_min,
|
||||
max_value=year_max,
|
||||
range_min=year_range_min,
|
||||
range_max=year_range_max,
|
||||
min_placeholder="e.g. 2020",
|
||||
max_placeholder="e.g. 2024",
|
||||
),
|
||||
Component(
|
||||
tag_name="div",
|
||||
attributes=[("class", "flex items-end gap-4 mb-4")],
|
||||
children=[
|
||||
_filter_checkbox("filter-mastered", "Mastered", mastered_value),
|
||||
_filter_checkbox("filter-has-purchases", "Has Purchases", has_purchases_value),
|
||||
_filter_checkbox("filter-has-playevents", "Has Play Events", has_playevents_value),
|
||||
],
|
||||
),
|
||||
RangeSlider(
|
||||
label="Playtime",
|
||||
input_name_prefix="filter-playtime",
|
||||
min_value=playtime_min,
|
||||
max_value=playtime_max,
|
||||
range_min=0,
|
||||
range_max=playtime_range_max,
|
||||
step="1",
|
||||
min_placeholder="e.g. 1",
|
||||
max_placeholder="e.g. 100",
|
||||
),
|
||||
RangeSlider(
|
||||
label="Session Count",
|
||||
input_name_prefix="filter-session-count",
|
||||
min_value=session_count_min,
|
||||
max_value=session_count_max,
|
||||
range_min=0,
|
||||
range_max=100,
|
||||
step="1",
|
||||
min_placeholder="e.g. 1",
|
||||
max_placeholder="e.g. 50",
|
||||
),
|
||||
RangeSlider(
|
||||
label="Average Session Duration (mins)",
|
||||
input_name_prefix="filter-session-average",
|
||||
min_value=session_avg_min,
|
||||
max_value=session_avg_max,
|
||||
range_min=0,
|
||||
range_max=240,
|
||||
step="1",
|
||||
min_placeholder="e.g. 10",
|
||||
max_placeholder="e.g. 120",
|
||||
),
|
||||
]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update SessionFilterBar to support split duration fields**
|
||||
Replace old `duration_minutes` RangeSlider with split total, manual, and calculated duration RangeSliders.
|
||||
|
||||
```python
|
||||
# Inside common/components/filters.py: SessionFilterBar()
|
||||
|
||||
dur_tot_min, dur_tot_max = _parse_range(existing, "duration_total_minutes")
|
||||
dur_man_min, dur_man_max = _parse_range(existing, "duration_manual_minutes")
|
||||
dur_calc_min, dur_calc_max = _parse_range(existing, "duration_calculated_minutes")
|
||||
|
||||
# Inside fields array, replace RangeSlider "Duration" with:
|
||||
RangeSlider(
|
||||
label="Total Duration (mins)",
|
||||
input_name_prefix="filter-duration-total-minutes",
|
||||
min_value=dur_tot_min,
|
||||
max_value=dur_tot_max,
|
||||
range_min=0,
|
||||
range_max=duration_range_max * 60, # Range sliders use minutes now
|
||||
step="1",
|
||||
min_placeholder="e.g. 30",
|
||||
max_placeholder="e.g. 180",
|
||||
),
|
||||
RangeSlider(
|
||||
label="Manual Duration (mins)",
|
||||
input_name_prefix="filter-duration-manual-minutes",
|
||||
min_value=dur_man_min,
|
||||
max_value=dur_man_max,
|
||||
range_min=0,
|
||||
range_max=240,
|
||||
step="1",
|
||||
min_placeholder="e.g. 10",
|
||||
max_placeholder="e.g. 120",
|
||||
),
|
||||
RangeSlider(
|
||||
label="Calculated Duration (mins)",
|
||||
input_name_prefix="filter-duration-calculated-minutes",
|
||||
min_value=dur_calc_min,
|
||||
max_value=dur_calc_max,
|
||||
range_min=0,
|
||||
range_max=duration_range_max * 60,
|
||||
step="1",
|
||||
min_placeholder="e.g. 30",
|
||||
max_placeholder="e.g. 180",
|
||||
),
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update PurchaseFilterBar to support original and converted currencies and infinite flag**
|
||||
Add Checkboxes `infinite`, `needs_price_update` and currency StringCriterion text field / Choice options.
|
||||
|
||||
```python
|
||||
# Inside common/components/filters.py: PurchaseFilterBar()
|
||||
|
||||
infinite_value = _parse_bool(existing, "infinite")
|
||||
needs_price_update_value = _parse_bool(existing, "needs_price_update")
|
||||
price_currency_value = existing.get("price_currency", {}).get("value", "")
|
||||
converted_currency_value = existing.get("converted_currency", {}).get("value", "")
|
||||
|
||||
# Expand fields component array with:
|
||||
Component(
|
||||
tag_name="div",
|
||||
attributes=[("class", "flex gap-4 mb-4")],
|
||||
children=[
|
||||
_filter_checkbox("filter-refunded", "Refunded", is_refunded_value),
|
||||
_filter_checkbox("filter-infinite", "Infinite", infinite_value),
|
||||
_filter_checkbox("filter-needs-price-update", "Needs Price Update", needs_price_update_value),
|
||||
],
|
||||
),
|
||||
```
|
||||
|
||||
Add currency text filters (as primitive `Input` controls for string criteria):
|
||||
```python
|
||||
Component(
|
||||
tag_name="div",
|
||||
attributes=[("class", _FILTER_GRID_CLASS)],
|
||||
children=[
|
||||
_filter_field(
|
||||
"Original Currency",
|
||||
Component(
|
||||
tag_name="input",
|
||||
attributes=[
|
||||
("type", "text"),
|
||||
("name", "filter-price_currency"),
|
||||
("value", price_currency_value),
|
||||
("placeholder", "e.g. USD, EUR"),
|
||||
("class", "w-full rounded border-default-medium p-2 bg-neutral-secondary-medium text-body"),
|
||||
],
|
||||
),
|
||||
),
|
||||
_filter_field(
|
||||
"Converted Currency",
|
||||
Component(
|
||||
tag_name="input",
|
||||
attributes=[
|
||||
("type", "text"),
|
||||
("name", "filter-converted_currency"),
|
||||
("value", converted_currency_value),
|
||||
("placeholder", "e.g. USD, EUR"),
|
||||
("class", "w-full rounded border-default-medium p-2 bg-neutral-secondary-medium text-body"),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Create New FilterBars in `common/components/filters.py`
|
||||
|
||||
**Files:**
|
||||
- Modify: `common/components/filters.py`
|
||||
|
||||
- [ ] **Step 1: Implement DeviceFilterBar, PlatformFilterBar, and PlayEventFilterBar**
|
||||
|
||||
Append these three new filter bar components to `common/components/filters.py`:
|
||||
|
||||
```python
|
||||
def DeviceFilterBar(
|
||||
filter_json="", preset_list_url="", preset_save_url=""
|
||||
) -> SafeText:
|
||||
"""Collapsible filter bar for the Device list."""
|
||||
from games.models import Device
|
||||
|
||||
existing = _filter_parse(filter_json)
|
||||
type_options = Device.DEVICE_TYPES
|
||||
type_choice = _filter_get_choice(existing, "type")
|
||||
|
||||
fields = [
|
||||
Component(
|
||||
tag_name="div",
|
||||
attributes=[("class", _FILTER_GRID_CLASS)],
|
||||
children=[
|
||||
_filter_field(
|
||||
"Device Type",
|
||||
_enum_filter(
|
||||
"type",
|
||||
type_options,
|
||||
type_choice,
|
||||
nullable=True,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
|
||||
|
||||
|
||||
def PlatformFilterBar(
|
||||
filter_json="", preset_list_url="", preset_save_url=""
|
||||
) -> SafeText:
|
||||
"""Collapsible filter bar for the Platform list."""
|
||||
existing = _filter_parse(filter_json)
|
||||
|
||||
name_value = existing.get("name", {}).get("value", "")
|
||||
group_value = existing.get("group", {}).get("value", "")
|
||||
|
||||
fields = [
|
||||
Component(
|
||||
tag_name="div",
|
||||
attributes=[("class", _FILTER_GRID_CLASS)],
|
||||
children=[
|
||||
_filter_field(
|
||||
"Platform Name",
|
||||
Component(
|
||||
tag_name="input",
|
||||
attributes=[
|
||||
("type", "text"),
|
||||
("name", "filter-name"),
|
||||
("value", name_value),
|
||||
("placeholder", "e.g. Nintendo Switch"),
|
||||
("class", "w-full rounded border-default-medium p-2 bg-neutral-secondary-medium text-body"),
|
||||
],
|
||||
),
|
||||
),
|
||||
_filter_field(
|
||||
"Platform Group",
|
||||
Component(
|
||||
tag_name="input",
|
||||
attributes=[
|
||||
("type", "text"),
|
||||
("name", "filter-group"),
|
||||
("value", group_value),
|
||||
("placeholder", "e.g. Nintendo"),
|
||||
("class", "w-full rounded border-default-medium p-2 bg-neutral-secondary-medium text-body"),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
|
||||
|
||||
|
||||
def PlayEventFilterBar(
|
||||
filter_json="", preset_list_url="", preset_save_url=""
|
||||
) -> SafeText:
|
||||
"""Collapsible filter bar for the PlayEvent list."""
|
||||
from games.models import PlayEvent
|
||||
|
||||
existing = _filter_parse(filter_json)
|
||||
game_choice = _filter_get_choice(existing, "game")
|
||||
days_min, days_max = _parse_range(existing, "days_to_finish")
|
||||
|
||||
fields = [
|
||||
Component(
|
||||
tag_name="div",
|
||||
attributes=[("class", _FILTER_GRID_CLASS)],
|
||||
children=[
|
||||
_filter_field(
|
||||
"Game",
|
||||
_model_filter(
|
||||
"game",
|
||||
game_choice,
|
||||
search_url="/api/games/search",
|
||||
nullable=False,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
RangeSlider(
|
||||
label="Days to Finish",
|
||||
input_name_prefix="filter-days-to-finish",
|
||||
min_value=days_min,
|
||||
max_value=days_max,
|
||||
range_min=0,
|
||||
range_max=365,
|
||||
step="1",
|
||||
min_placeholder="e.g. 1",
|
||||
max_placeholder="e.g. 30",
|
||||
),
|
||||
]
|
||||
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Export new FilterBars in `common/components/__init__.py`**
|
||||
|
||||
Modify: `common/components/__init__.py` to import and expose `DeviceFilterBar`, `PlatformFilterBar`, and `PlayEventFilterBar`.
|
||||
|
||||
```python
|
||||
# Import section:
|
||||
from common.components.filters import (
|
||||
FilterBar,
|
||||
PurchaseFilterBar,
|
||||
SessionFilterBar,
|
||||
DeviceFilterBar,
|
||||
PlatformFilterBar,
|
||||
PlayEventFilterBar,
|
||||
)
|
||||
|
||||
# In __all__:
|
||||
"FilterBar",
|
||||
"PurchaseFilterBar",
|
||||
"SessionFilterBar",
|
||||
"DeviceFilterBar",
|
||||
"PlatformFilterBar",
|
||||
"PlayEventFilterBar",
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Integrate FilterBars into `Device`, `Platform`, and `PlayEvent` views
|
||||
|
||||
**Files:**
|
||||
- Modify: `games/views/device.py`
|
||||
- Modify: `games/views/platform.py`
|
||||
- Modify: `games/views/playevent.py`
|
||||
|
||||
- [ ] **Step 1: Integrate FilterBar in `list_devices` in `games/views/device.py`**
|
||||
|
||||
Import and parse the filter, apply to queryset, instantiate `DeviceFilterBar`, prepend it to the output page content.
|
||||
|
||||
```python
|
||||
# At top of games/views/device.py:
|
||||
from django.utils.safestring import mark_safe
|
||||
from common.components import DeviceFilterBar, ModuleScript
|
||||
from games.filters import parse_device_filter
|
||||
|
||||
# Inside list_devices(request):
|
||||
devices = Device.objects.order_by("-created_at")
|
||||
|
||||
filter_json = request.GET.get("filter", "")
|
||||
if filter_json:
|
||||
device_filter = parse_device_filter(filter_json)
|
||||
if device_filter is not None:
|
||||
devices = devices.filter(device_filter.to_q())
|
||||
|
||||
devices, page_obj, elided_page_range = paginate(request, devices)
|
||||
|
||||
# ... create data dict ...
|
||||
|
||||
# Prepend the filter bar above table:
|
||||
filter_bar = DeviceFilterBar(
|
||||
filter_json=filter_json,
|
||||
preset_list_url=reverse("games:list_presets") + "?mode=devices",
|
||||
preset_save_url=reverse("games:save_preset") + "?mode=devices",
|
||||
)
|
||||
content = mark_safe(str(filter_bar) + str(content))
|
||||
return render_page(
|
||||
request,
|
||||
content,
|
||||
title="Manage devices",
|
||||
scripts=ModuleScript("range_slider.js")
|
||||
+ ModuleScript("search_select.js")
|
||||
+ ModuleScript("filter_bar.js"),
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Integrate FilterBar in `list_platforms` in `games/views/platform.py`**
|
||||
|
||||
Import and parse the filter, apply to platform queryset, instantiate platform filter bar, prepend to page content.
|
||||
|
||||
```python
|
||||
# At top of games/views/platform.py:
|
||||
from django.utils.safestring import mark_safe
|
||||
from common.components import PlatformFilterBar, ModuleScript
|
||||
from games.filters import parse_platform_filter
|
||||
|
||||
# Inside list_platforms(request):
|
||||
platforms = Platform.objects.order_by("name")
|
||||
|
||||
filter_json = request.GET.get("filter", "")
|
||||
if filter_json:
|
||||
platform_filter = parse_platform_filter(filter_json)
|
||||
if platform_filter is not None:
|
||||
platforms = platforms.filter(platform_filter.to_q())
|
||||
|
||||
platforms, page_obj, elided_page_range = paginate(request, platforms)
|
||||
|
||||
# ... create data dict ...
|
||||
|
||||
filter_bar = PlatformFilterBar(
|
||||
filter_json=filter_json,
|
||||
preset_list_url=reverse("games:list_presets") + "?mode=platforms",
|
||||
preset_save_url=reverse("games:save_preset") + "?mode=platforms",
|
||||
)
|
||||
content = mark_safe(str(filter_bar) + str(content))
|
||||
return render_page(
|
||||
request,
|
||||
content,
|
||||
title="Manage platforms",
|
||||
scripts=ModuleScript("range_slider.js")
|
||||
+ ModuleScript("search_select.js")
|
||||
+ ModuleScript("filter_bar.js"),
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Integrate FilterBar in `list_playevents` in `games/views/playevent.py`**
|
||||
|
||||
Import and parse the filter, apply to playevent queryset, instantiate playevent filter bar, prepend to page content.
|
||||
|
||||
```python
|
||||
# At top of games/views/playevent.py:
|
||||
from django.utils.safestring import mark_safe
|
||||
from common.components import PlayEventFilterBar
|
||||
from games.filters import parse_playevent_filter
|
||||
|
||||
# Inside list_playevents(request):
|
||||
playevents = PlayEvent.objects.order_by("-created_at")
|
||||
|
||||
filter_json = request.GET.get("filter", "")
|
||||
if filter_json:
|
||||
playevent_filter = parse_playevent_filter(filter_json)
|
||||
if playevent_filter is not None:
|
||||
playevents = playevents.filter(playevent_filter.to_q())
|
||||
|
||||
playevents, page_obj, elided_page_range = paginate(request, playevents)
|
||||
|
||||
# ... create data ...
|
||||
|
||||
filter_bar = PlayEventFilterBar(
|
||||
filter_json=filter_json,
|
||||
preset_list_url=reverse("games:list_presets") + "?mode=playevents",
|
||||
preset_save_url=reverse("games:save_preset") + "?mode=playevents",
|
||||
)
|
||||
content = mark_safe(str(filter_bar) + str(content))
|
||||
return render_page(
|
||||
request,
|
||||
content,
|
||||
title="Manage play events",
|
||||
scripts=ModuleScript("range_slider.js")
|
||||
+ ModuleScript("search_select.js")
|
||||
+ ModuleScript("filter_bar.js"),
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Support new preset modes in Preset View/Model
|
||||
|
||||
Ensure FilterPreset allows `devices` and `platforms` modes.
|
||||
|
||||
**Files:**
|
||||
- Modify: `games/models.py`
|
||||
- Modify: `games/views/filter_presets.py`
|
||||
|
||||
- [ ] **Step 1: Expand FilterPreset mode choices**
|
||||
|
||||
Verify or expand `MODE_CHOICES` inside `FilterPreset` model in `games/models.py`.
|
||||
|
||||
```python
|
||||
# Inside FilterPreset class:
|
||||
MODE_CHOICES = [
|
||||
("games", "Games"),
|
||||
("sessions", "Sessions"),
|
||||
("purchases", "Purchases"),
|
||||
("playevents", "Play Events"),
|
||||
("devices", "Devices"),
|
||||
("platforms", "Platforms"),
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Add Render Tests for new FilterBars
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/test_filter_bars.py`
|
||||
|
||||
- [ ] **Step 1: Write tests to verify new FilterBars render correctly**
|
||||
|
||||
Add test cases in `tests/test_filter_bars.py`:
|
||||
|
||||
```python
|
||||
def test_device_filter_bar(self):
|
||||
from common.components import DeviceFilterBar
|
||||
html = str(
|
||||
DeviceFilterBar(
|
||||
filter_json="",
|
||||
preset_list_url="/presets/devices/list",
|
||||
preset_save_url="/presets/devices/save",
|
||||
)
|
||||
)
|
||||
self._assert_shell(html, "/presets/devices/list", "/presets/devices/save")
|
||||
|
||||
def test_platform_filter_bar(self):
|
||||
from common.components import PlatformFilterBar
|
||||
html = str(
|
||||
PlatformFilterBar(
|
||||
filter_json="",
|
||||
preset_list_url="/presets/platforms/list",
|
||||
preset_save_url="/presets/platforms/save",
|
||||
)
|
||||
)
|
||||
self._assert_shell(html, "/presets/platforms/list", "/presets/platforms/save")
|
||||
|
||||
def test_playevent_filter_bar(self):
|
||||
from common.components import PlayEventFilterBar
|
||||
html = str(
|
||||
PlayEventFilterBar(
|
||||
filter_json="",
|
||||
preset_list_url="/presets/playevents/list",
|
||||
preset_save_url="/presets/playevents/save",
|
||||
)
|
||||
)
|
||||
self._assert_shell(html, "/presets/playevents/list", "/presets/playevents/save")
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run all test suites to confirm complete success**
|
||||
|
||||
Run: `pytest tests/test_filter_bars.py -v`
|
||||
Expected: ALL filter bar render tests pass.
|
||||
@@ -0,0 +1,177 @@
|
||||
# Unify Form Checkboxes Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Unify all Django form checkboxes across the codebase by routing them through our new Python `Checkbox` primitive.
|
||||
**Architecture:**
|
||||
1. Modify `Checkbox` and `Radio` primitives in `common/components/primitives.py` to support headless (label-less) rendering when `label` is `None`, so they can be injected into Django's native `form.as_div()` rendering without duplicating labels.
|
||||
2. Create a `PrimitiveCheckboxWidget` in `games/forms.py` that extends `forms.CheckboxInput` but renders using our `Checkbox` Python component.
|
||||
3. Create a `PrimitiveWidgetsMixin` in `games/forms.py` that automatically applies the `PrimitiveCheckboxWidget` to all `forms.BooleanField` instances in a form. Add this mixin to all ModelForms.
|
||||
|
||||
**Tech Stack:** Python, Django Forms, HTML.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Update Primitives for Headless Rendering
|
||||
|
||||
**Files:**
|
||||
- Modify: `common/components/primitives.py`
|
||||
- Modify: `tests/test_components.py`
|
||||
|
||||
- [ ] **Step 1: Write a failing test for headless rendering**
|
||||
In `tests/test_components.py`, add a test to `ComponentPrimitivesTest`:
|
||||
```python
|
||||
def test_checkbox_headless(self):
|
||||
html = Checkbox(name="test-headless", label=None, checked=True)
|
||||
self.assertNotIn('<label', html)
|
||||
self.assertIn('<input', html)
|
||||
self.assertIn('type="checkbox"', html)
|
||||
self.assertIn('name="test-headless"', html)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
Run: `pytest tests/test_components.py -k test_checkbox_headless`
|
||||
Expected: Fail because `Checkbox` currently requires `label` as a `str` and always renders a `Label` wrapper.
|
||||
|
||||
- [ ] **Step 3: Update `Checkbox` and `Radio` in `common/components/primitives.py`**
|
||||
Update the function signatures to accept `label: str | None = None` and selectively return only the `Input` if `label` is missing.
|
||||
```python
|
||||
def Checkbox(
|
||||
name: str,
|
||||
label: str | None = None,
|
||||
checked: bool = False,
|
||||
value: str = "1",
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
) -> SafeText:
|
||||
"""A filter-agnostic Checkbox component."""
|
||||
attributes = attributes or []
|
||||
input_attrs = [
|
||||
("name", name),
|
||||
("value", value),
|
||||
("class", "rounded border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand"),
|
||||
] + attributes
|
||||
if checked:
|
||||
input_attrs.append(("checked", "true"))
|
||||
|
||||
input_el = Input(type="checkbox", attributes=input_attrs)
|
||||
if label is None:
|
||||
return input_el
|
||||
|
||||
return Label(
|
||||
attributes=[("class", "flex items-center gap-2 text-sm text-heading cursor-pointer")],
|
||||
children=[input_el, label],
|
||||
)
|
||||
|
||||
def Radio(
|
||||
name: str,
|
||||
label: str | None = None,
|
||||
checked: bool = False,
|
||||
value: str = "",
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
) -> SafeText:
|
||||
"""A filter-agnostic Radio component."""
|
||||
attributes = attributes or []
|
||||
input_attrs = [
|
||||
("name", name),
|
||||
("value", value),
|
||||
("class", "rounded-full border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand"),
|
||||
] + attributes
|
||||
if checked:
|
||||
input_attrs.append(("checked", "true"))
|
||||
|
||||
input_el = Input(type="radio", attributes=input_attrs)
|
||||
if label is None:
|
||||
return input_el
|
||||
|
||||
return Label(
|
||||
attributes=[("class", "flex items-center gap-1.5 text-sm text-heading cursor-pointer")],
|
||||
children=[input_el, label],
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
Run: `pytest tests/test_components.py -k ComponentPrimitivesTest`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
Run:
|
||||
```bash
|
||||
git add common/components/primitives.py tests/test_components.py
|
||||
git commit -m "refactor: allow Checkbox and Radio primitives to render headlessly without labels"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Create Django Widget Adapter and Mixin
|
||||
|
||||
**Files:**
|
||||
- Modify: `games/forms.py`
|
||||
|
||||
- [ ] **Step 1: Write the Widget and Mixin implementations**
|
||||
At the top of `games/forms.py`, import `Checkbox` and implement `PrimitiveCheckboxWidget` and `PrimitiveWidgetsMixin`.
|
||||
```python
|
||||
from common.components.primitives import Checkbox
|
||||
|
||||
class PrimitiveCheckboxWidget(forms.CheckboxInput):
|
||||
"""Adapts Django's CheckboxInput to use our Checkbox component."""
|
||||
def render(self, name, value, attrs=None, renderer=None):
|
||||
final_attrs = self.build_attrs(self.attrs, attrs)
|
||||
checked = self.check_test(value)
|
||||
attributes = [(k, str(v)) for k, v in final_attrs.items() if k not in ("type", "name", "value", "checked")]
|
||||
|
||||
# Django uses boolean values differently for checkboxes, we omit value if empty
|
||||
return str(Checkbox(
|
||||
name=name,
|
||||
label=None,
|
||||
checked=checked,
|
||||
value=str(value) if value else "1",
|
||||
attributes=attributes
|
||||
))
|
||||
|
||||
class PrimitiveWidgetsMixin:
|
||||
"""Automatically applies primitive custom widgets to native Django form fields."""
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
for field_name, field in self.fields.items():
|
||||
if isinstance(field, forms.BooleanField):
|
||||
field.widget = PrimitiveCheckboxWidget()
|
||||
# Maintain the field's explicit required status (usually False for booleans)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Apply the Mixin to all Forms**
|
||||
In `games/forms.py`, update all the ModelForm classes to inherit from `PrimitiveWidgetsMixin` as the **first** base class (before `forms.ModelForm`).
|
||||
Example:
|
||||
```python
|
||||
class SessionForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||
# ...
|
||||
|
||||
class PurchaseForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||
# ...
|
||||
|
||||
class GameForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||
# ...
|
||||
|
||||
class PlatformForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||
# ...
|
||||
|
||||
class DeviceForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||
# ...
|
||||
|
||||
class PlayEventForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||
# ...
|
||||
|
||||
class GameStatusChangeForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||
# ...
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Test Django Form Rendering**
|
||||
Run the full test suite to ensure forms still validate properly and render without error.
|
||||
Run: `pytest`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
Run:
|
||||
```bash
|
||||
git add games/forms.py
|
||||
git commit -m "feat: replace all form BooleanFields with PrimitiveCheckboxWidget via mixin"
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,197 @@
|
||||
# Design Spec: Boolean Filters Overhaul (Approach A with Reusable Primitives)
|
||||
|
||||
Expose a two-radio-button UI for all boolean filters to allow selecting "True" (Yes), "False" (No), or leaving the filter "Unset" (Not set).
|
||||
|
||||
## 1. Architectural Changes
|
||||
|
||||
### 1.1 Backend Primitives & Components
|
||||
|
||||
We will extract the `_filter_checkbox` rendering logic from `common/components/filters.py` and generalize it into a reusable, filter-agnostic `Checkbox` component in `common/components/primitives.py`. We will also add a corresponding `Radio` component.
|
||||
|
||||
#### In `common/components/primitives.py`:
|
||||
```python
|
||||
def Checkbox(
|
||||
name: str,
|
||||
label: str,
|
||||
checked: bool = False,
|
||||
value: str = "1",
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
) -> SafeText:
|
||||
"""A filter-agnostic Checkbox component."""
|
||||
attributes = attributes or []
|
||||
input_attrs = [
|
||||
("name", name),
|
||||
("value", value),
|
||||
("class", "rounded border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand"),
|
||||
] + attributes
|
||||
if checked:
|
||||
input_attrs.append(("checked", "true"))
|
||||
|
||||
return Label(
|
||||
attributes=[("class", "flex items-center gap-2 text-sm text-heading cursor-pointer")],
|
||||
children=[
|
||||
Input(type="checkbox", attributes=input_attrs),
|
||||
label,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def Radio(
|
||||
name: str,
|
||||
label: str,
|
||||
checked: bool = False,
|
||||
value: str = "",
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
) -> SafeText:
|
||||
"""A filter-agnostic Radio component."""
|
||||
attributes = attributes or []
|
||||
input_attrs = [
|
||||
("name", name),
|
||||
("value", value),
|
||||
("class", "rounded-full border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand"),
|
||||
] + attributes
|
||||
if checked:
|
||||
input_attrs.append(("checked", "true"))
|
||||
|
||||
return Label(
|
||||
attributes=[("class", "flex items-center gap-1.5 text-sm text-heading cursor-pointer")],
|
||||
children=[
|
||||
Input(type="radio", attributes=input_attrs),
|
||||
label,
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
#### In `common/components/filters.py`:
|
||||
We will import `Checkbox` and `Radio` from `common.components.primitives`. We will redefine `_filter_checkbox` as a thin adapter pointing to our new generalized `Checkbox` component (preserving any backward compatibility), and we will create a new helper `_filter_boolean_radio` using `Radio`:
|
||||
|
||||
```python
|
||||
_FILTER_RADIO_CLASS = (
|
||||
"rounded-full border-default-medium bg-neutral-secondary-medium "
|
||||
"text-brand focus:ring-brand"
|
||||
)
|
||||
|
||||
def _filter_checkbox(name: str, label: str, checked: bool) -> SafeText:
|
||||
"""Thin adapter mapping legacy checkbox filters to the generalized Checkbox primitive."""
|
||||
return Checkbox(name=name, label=label, checked=checked)
|
||||
|
||||
|
||||
def _filter_boolean_radio(name: str, label: str, value: bool | None) -> SafeText:
|
||||
"""Renders a filter-specific boolean radio button group with 'True' and 'False' options."""
|
||||
return Div(
|
||||
attributes=[("class", "flex flex-col gap-1")],
|
||||
children=[
|
||||
Span(
|
||||
attributes=[("class", _FILTER_LABEL_CLASS)],
|
||||
children=[label],
|
||||
),
|
||||
Div(
|
||||
attributes=[("class", "flex items-center gap-4 h-9")],
|
||||
children=[
|
||||
Radio(name=name, label="True", checked=value is True, value="true"),
|
||||
Radio(name=name, label="False", checked=value is False, value="false"),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
```
|
||||
|
||||
### 1.2 Parsing Filter JSON (Backend)
|
||||
|
||||
We will introduce a robust parsing function in `common/components/filters.py` to distinguish `True`, `False`, and `None` (unset):
|
||||
|
||||
```python
|
||||
def _parse_bool_nullable(existing: dict, key: str) -> bool | None:
|
||||
"""Extract a nullable boolean value from a filter criterion."""
|
||||
if key not in existing:
|
||||
return None
|
||||
field = existing[key]
|
||||
if not isinstance(field, dict):
|
||||
return None
|
||||
val = field.get("value")
|
||||
if val is None:
|
||||
return None
|
||||
if isinstance(val, str):
|
||||
if val.lower() in ("true", "1", "yes"):
|
||||
return True
|
||||
if val.lower() in ("false", "0", "no"):
|
||||
return False
|
||||
return bool(val)
|
||||
```
|
||||
|
||||
### 1.3 UI Overhauls in Filter Bars
|
||||
|
||||
We will update the following filter bars to use `_parse_bool_nullable` and `_filter_boolean_radio`:
|
||||
1. **GameFilterBar:** `mastered`, `purchase_refunded`, `purchase_infinite`, `session_emulated`.
|
||||
2. **SessionFilterBar:** `emulated`, `is_active`.
|
||||
3. **PurchaseFilterBar:** `is_refunded`, `infinite`, `needs_price_update`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Frontend JS Changes (`games/static/js/filter_bar.js`)
|
||||
|
||||
### 2.1 Deselectable Radios Behavior
|
||||
To support resetting filters back to "Unset" without resetting the whole form, we add click behavior that unchecks an already checked radio button when clicked.
|
||||
|
||||
```javascript
|
||||
function setupDeselectableRadios() {
|
||||
document.querySelectorAll('input[type="radio"]').forEach(function (radio) {
|
||||
radio.addEventListener('click', function (e) {
|
||||
if (this.wasChecked) {
|
||||
this.checked = false;
|
||||
this.wasChecked = false;
|
||||
this.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
} else {
|
||||
var name = this.getAttribute('name');
|
||||
if (name) {
|
||||
document.querySelectorAll('input[type="radio"][name="' + name + '"]').forEach(function (r) {
|
||||
r.wasChecked = false;
|
||||
});
|
||||
}
|
||||
this.wasChecked = true;
|
||||
}
|
||||
});
|
||||
if (radio.checked) {
|
||||
radio.wasChecked = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
We will call `setupDeselectableRadios()` during `DOMContentLoaded`.
|
||||
|
||||
### 2.2 Serializing Radio States
|
||||
Update `buildFilterJSON(form)` to collect checked radios from boolean field groups:
|
||||
|
||||
```javascript
|
||||
// 2. Boolean Fields (Radio Button Groups)
|
||||
var booleanFields = [
|
||||
{ name: "filter-mastered", key: "mastered" },
|
||||
{ name: "filter-emulated", key: "emulated" },
|
||||
{ name: "filter-active", key: "is_active" },
|
||||
{ name: "filter-refunded", key: "is_refunded" },
|
||||
{ name: "filter-infinite", key: "infinite" },
|
||||
{ name: "filter-needs-price-update", key: "needs_price_update" },
|
||||
{ name: "filter-purchase-refunded", key: "purchase_refunded" },
|
||||
{ name: "filter-purchase-infinite", key: "purchase_infinite" },
|
||||
{ name: "filter-session-emulated", key: "session_emulated" }
|
||||
];
|
||||
booleanFields.forEach(function (bf) {
|
||||
var el = form.querySelector('[name="' + bf.name + '"]:checked');
|
||||
if (el) {
|
||||
var val = el.value === "true";
|
||||
filter[bf.key] = criterion(val, null, "EQUALS");
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Testing Strategy
|
||||
|
||||
1. **Unit Tests (`tests/test_filter_helpers.py`):**
|
||||
- Add test coverage for `_parse_bool_nullable` covering `None`, `True`, `False`, strings, missing keys, etc.
|
||||
2. **Component Tests (`tests/test_filter_bars.py`):**
|
||||
- Update tests where the filters render checkbox elements to assert that radio groups are rendered instead (with "True" and "False" radio buttons).
|
||||
3. **Integration and End-to-End Tests:**
|
||||
- Execute the test suite using `pytest` to ensure that all 355 tests continue to pass and reflect the updated UI structure perfectly.
|
||||
@@ -0,0 +1,157 @@
|
||||
# HTML + JS component authoring — design
|
||||
|
||||
**Date:** 2026-06-13
|
||||
**Status:** Approved (design); pending implementation plan
|
||||
**Branch context:** follows the lazy node-tree component system (`Element`/`Safe`/`Fragment`/`Media`) and the `Children`/`Attributes` typing work.
|
||||
|
||||
## Problem
|
||||
|
||||
Trusted HTML and JavaScript are authored as Python f-strings in several places. Two distinct pains:
|
||||
|
||||
- **HTML-as-string** — `Navbar`, `_TOAST_CONTAINER`, the played-row markup skeleton, and the generally verbose `Element("div", attributes=[...], children=[...])` call shape.
|
||||
- **JS-in-string** — the genuinely ugly ones: `GameStatusSelector` (~70 lines) and `SessionDeviceSelector` (~50 lines) inline an Alpine `x-data="{...}"` blob with `fetchWithHtmxTriggers`, server-value interpolation (`{game.status}`), **and** `{{ }}` brace-doubling throughout; `_PLAYED_ROW_TEMPLATE` dodges the brace collision entirely by switching to `@@TOKEN@@` placeholders + a `.replace()` loop.
|
||||
|
||||
You cannot node-tree JavaScript, so the JS pain needs a different answer than the HTML pain. The newer widgets (`search_select`, `range_slider`, `filter_bar`) already moved behavior into real `.js` files wired by `onSwap` + `data-*` attributes; the Alpine selectors are the holdouts that still inline their JS.
|
||||
|
||||
## Goal
|
||||
|
||||
Establish the *right* way to author interactive, server-rendered components in this codebase, and convert a few exemplars to prove it. North-star principle:
|
||||
|
||||
> The server never writes a line of JavaScript. The server↔client boundary is a typed, declarative contract. Behavior lives in real, tooled TypeScript files.
|
||||
|
||||
## Decisions (locked during brainstorming)
|
||||
|
||||
| Decision | Choice |
|
||||
| --- | --- |
|
||||
| HTML authoring | **htpy-*style* sugar on the existing `Element`** (not the htpy library) — keeps `Media`/`collect_media`, no build step |
|
||||
| JS runtime model | **Custom Elements** (Web Components), light DOM |
|
||||
| Server↔client contract | **Typed contract + codegen** (one Python `Props` type → generated TS interface + reader) |
|
||||
| JS language | **TypeScript** (real `.ts`, compiled) |
|
||||
| Build tool | **`tsc` per-module** (no bundler) — preserves per-component `Media` loading |
|
||||
| Alpine, for converted components | **Retired** — behavior rewritten as vanilla TS in the element class |
|
||||
| Exemplars | **`GameStatusSelector` + `SessionDeviceSelector` + played-row** |
|
||||
| Compiled output | **Build-only, gitignored** (produced by `make` + Docker) |
|
||||
| Existing hand-written `.js` | **Left as-is**, migrated to TS later |
|
||||
|
||||
## Architecture
|
||||
|
||||
Three independent layers composing through one typed seam:
|
||||
|
||||
```
|
||||
Python (server) TypeScript (client)
|
||||
───────────────── ───────────────────
|
||||
htpy-style Element ──renders──► <game-status-selector ──connectedCallback──► game-status-selector.ts
|
||||
+ Media (kept) game-id="3" status="f"> (vanilla DOM behavior)
|
||||
│ ▲
|
||||
└── GameStatusSelectorProps ─codegen─┘ generated props.ts (interface + typed reader)
|
||||
(one Python type = the whole server↔client contract)
|
||||
```
|
||||
|
||||
- **Layer 1 — htpy-style HTML** removes HTML-string / verbose-`Element` ugliness, pure Python, no build, `Media` untouched.
|
||||
- **Layer 2 — Custom Elements (TS)** removes JS-string ugliness; behavior in real typed modules with a native lifecycle.
|
||||
- **Layer 3 — Typed contract codegen** makes the seam type-safe in both languages from a single Python source.
|
||||
|
||||
### Layer 1 — htpy-style sugar on `Element`
|
||||
|
||||
Additive only. Existing `Element("div", attributes=[...], children=[...])` and `Div([("class","x")], "hi")` keep working.
|
||||
|
||||
- **Attributes as kwargs:** `Div(class_="card", hx_get="/x", disabled=True)`. Translation: trailing `_` stripped (`class_`→`class`); inner `_`→`-` (`hx_get`→`hx-get`, `data_id`→`data-id`); `True`→bare attribute, `False`/`None`→omitted.
|
||||
- **Children via `[]`:** `Div(class_="card")[H1["Title"], body]`. `Element.__getitem__` normalizes through the existing `as_children` and returns an `Element` carrying the same attributes and media.
|
||||
|
||||
The result is still a walkable `Element` tree, so `collect_media` / `Media` are unaffected. This is the "htpy feel on our own node so the asset system survives" decision.
|
||||
|
||||
Example:
|
||||
|
||||
```python
|
||||
Div(class_="flex gap-2 items-center")[
|
||||
Icon("play"),
|
||||
Span(class_="label")[name],
|
||||
]
|
||||
```
|
||||
|
||||
### Layer 2 — Custom Elements (TypeScript, light DOM)
|
||||
|
||||
- Python builder emits a **semantic tag**: `Element("game-status-selector", attrs).with_media(Media(js=("dist/elements/game-status-selector.js",)))`.
|
||||
- **Light DOM** (no shadow root — Tailwind's global classes must apply). The server renders the inner markup (htpy-style); the element enhances it.
|
||||
- **Native lifecycle replaces `onSwap`:** `connectedCallback()` fires when the browser parses or htmx-swaps the element in; `disconnectedCallback()` provides free teardown. No init registry, no guard flags.
|
||||
- Behavior is **vanilla TS** — the element class owns its state (dropdown open/closed, PATCH-on-select via `fetchWithHtmxTriggers`). Alpine retired for these three.
|
||||
- Source `ts/elements/<tag>.ts` → compiled `games/static/js/dist/elements/<tag>.js`, loaded only on pages that use it (via `Media`).
|
||||
|
||||
### Layer 3 — Typed contract (one Python type → the whole seam)
|
||||
|
||||
Each element declares its props once, in Python:
|
||||
|
||||
```python
|
||||
class GameStatusSelectorProps(TypedDict):
|
||||
game_id: int
|
||||
status: str
|
||||
csrf: str
|
||||
```
|
||||
|
||||
- The **Python builder** takes these typed args and serializes them to kebab-case attributes (`game-id="3"`).
|
||||
- **Codegen** reads the registered Props types and emits, per component, into `ts/generated/props.ts`:
|
||||
- an **interface** — `GameStatusSelectorProps { gameId: number; status: string; csrf: string }`, and
|
||||
- a **typed reader** — `readGameStatusSelectorProps(el): GameStatusSelectorProps` that pulls and parses attributes (`Number(el.getAttribute("game-id"))`, etc.).
|
||||
- The element imports the generated reader. The entire server↔client boundary is generated from one Python type: rename `game_id` in Python, regenerate, and `tsc` fails until the element updates. Drift is caught at build time; no hand-written `getAttribute` soup, no silent attr-name drift.
|
||||
|
||||
Type map: `int`/`float` → `number`, `str` → `string`, `bool` → `boolean`. Field `game_id` → attr `game-id` → TS prop `gameId`. Reader parsing follows the type (number → `Number(...)`, bool → presence / `=== "true"`, string → `getAttribute(...) ?? ""`).
|
||||
|
||||
## Toolchain (`tsc` per-module, build-only)
|
||||
|
||||
Layout:
|
||||
|
||||
```
|
||||
ts/
|
||||
elements/game-status-selector.ts # hand-written element classes
|
||||
generated/props.ts # codegen output (gitignored)
|
||||
globals.d.ts # ambient: window.fetchWithHtmxTriggers, htmx
|
||||
tsconfig.json # strict, ES2022, lib [ES2022, DOM, DOM.Iterable]
|
||||
# rootDir: ts/ → outDir: games/static/js/dist/
|
||||
```
|
||||
|
||||
- **`games/static/js/dist/` is the only compiled output**, trivially gitignored, never colliding with hand-written `.js`. `Media` references `dist/elements/...`.
|
||||
- **package.json**: add `typescript` devDep; scripts `build:ts` (`tsc -p tsconfig.json`), `watch:ts` (`tsc -p tsconfig.json --watch`).
|
||||
- **Makefile**: `make ts` = codegen → `tsc`; `make dev` also runs `tsc --watch` (beside Django runserver + Tailwind watch); `make check` gains `tsc --noEmit` as a drift gate.
|
||||
- **.gitignore**: `games/static/js/dist/`, `ts/generated/`.
|
||||
- **Docker**: add a `make ts` step in the image build (npm already present for Tailwind); compiled JS baked into the image. Runtime stays offline.
|
||||
- **TS lint/format**: deferred — `tsc --strict` is the only gate for now.
|
||||
|
||||
### Codegen mechanics
|
||||
|
||||
- A registry maps `tag → Props type` (e.g. a decorator `@element("game-status-selector", GameStatusSelectorProps)` on the Python builder, collected into a module-level registry).
|
||||
- A Django management command (or script) imports the registry and writes `ts/generated/props.ts` (interface + reader per component).
|
||||
- **Ordering:** codegen runs before `tsc` (the generated file is a `tsc` input). CI runs codegen then `tsc --noEmit`, so Python/TS drift fails the build. No committed generated artifact to diff against — `tsc` failing on drift is the gate.
|
||||
|
||||
## Exemplar conversions
|
||||
|
||||
1. **`GameStatusSelector` → `<game-status-selector game-id status csrf>`** — Python builds the light-DOM htpy-style; `game-status-selector.ts` wires the dropdown toggle + click→PATCH `/api/games/{id}/status` via `fetchWithHtmxTriggers` with CSRF, and updates the displayed status. Deletes the ~70-line f-string + brace-doubling.
|
||||
2. **`SessionDeviceSelector` → `<session-device-selector>`** — same shape; PATCH `/api/session/{id}/device`.
|
||||
3. **played-row → `<play-event-row>`** (non-Alpine) — deletes `_PLAYED_ROW_TEMPLATE` and the `@@TOKEN@@` / `.replace()` hack; Python builds markup htpy-style; `play-event-row.ts` owns the dropdown + add-playthrough POST. URLs are server-reversed and passed as attributes. Proves the pattern is not Alpine-only.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Python**: builders render the correct tag + attributes (extend `test_components` / `test_rendered_pages`); assert no f-string remnants remain.
|
||||
- **Type-check**: `tsc --noEmit` in `make check` — type errors, including contract drift, fail CI.
|
||||
- **e2e (Playwright)**: real Chromium upgrades the custom elements natively; port/extend the existing widget-e2e pattern for all three (open dropdown → select → PATCH → DOM updates).
|
||||
|
||||
## Risks and mitigations
|
||||
|
||||
1. **Element module must be loaded before its tag appears.** Full-page render loads the module via `Media`; htmx row-swaps reuse the already-defined element. Constraint to document: a fragment response that introduces a brand-new element type must include that element's `Media`. (Same limitation class as today's "`onSwap` needs the script present.")
|
||||
2. **A build step is now required** for `make dev` and Docker. One-time wiring, mitigated by Make/Docker integration.
|
||||
3. **First TypeScript in the repo** — adds `typescript`, `tsconfig.json`, a Docker build step. Scoped to `ts/`; existing `.js` untouched.
|
||||
4. **CSRF/PATCH parity** — the vanilla TS must replicate the Alpine version's fetch/CSRF/`HX-Trigger` behavior; it reuses the existing `fetchWithHtmxTriggers`; e2e guards it.
|
||||
5. **Codegen ↔ build ordering** — codegen must precede `tsc`; encoded in `make ts`.
|
||||
|
||||
## Out of scope (YAGNI)
|
||||
|
||||
- Migrating the existing hand-written `.js` to TypeScript (later, incrementally).
|
||||
- Bundling / minification of app JS.
|
||||
- Shadow DOM / scoped styles.
|
||||
- A general island / props-blob hydration runtime (custom elements cover these three).
|
||||
- TS lint/format tooling (prettier/eslint).
|
||||
|
||||
## Future on-ramps (not now)
|
||||
|
||||
- **More custom elements**: migrate the remaining `onSwap` widgets to custom elements once the pattern is proven.
|
||||
- **Existing `.js` → TS**: incremental, file by file (`tsc` checks mixed projects).
|
||||
- The typed contract already positions the boundary for full type-safety as more client code becomes TS.
|
||||
@@ -0,0 +1 @@
|
||||
# e2e tests package
|
||||
@@ -0,0 +1,22 @@
|
||||
import os
|
||||
import shutil
|
||||
import pytest
|
||||
|
||||
# Playwright runs an async event loop in the background, which triggers
|
||||
# Django's async safety checks when running synchronous tests. This allows
|
||||
# synchronous operations inside the async context safely.
|
||||
os.environ.setdefault("DJANGO_ALLOW_ASYNC_UNSAFE", "true")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def browser_type_launch_args(browser_type_launch_args):
|
||||
# Try to find a system-installed Google Chrome or Chromium to bypass Nix/NixOS shared library issues
|
||||
for browser_name in ["google-chrome-stable", "google-chrome", "chromium", "chrome"]:
|
||||
path = shutil.which(browser_name)
|
||||
if path:
|
||||
return {
|
||||
**browser_type_launch_args,
|
||||
"executable_path": path,
|
||||
}
|
||||
# Fallback to default Playwright behavior
|
||||
return browser_type_launch_args
|
||||
@@ -0,0 +1,112 @@
|
||||
"""End-to-end Playwright test for boolean radio filter serialization and deselect behavior.
|
||||
|
||||
Covers:
|
||||
1. Selecting True/False serializes the boolean field as True/False.
|
||||
2. Unsetting/unchecking a radio button by clicking on it again, which deselects it, omitting the field from JSON.
|
||||
"""
|
||||
|
||||
import json
|
||||
import urllib.parse
|
||||
|
||||
import pytest
|
||||
from django.http import HttpResponse
|
||||
from django.test import override_settings
|
||||
from django.urls import path
|
||||
|
||||
from common.components import FilterBar
|
||||
|
||||
|
||||
def _bar_page(filter_json: str = "") -> str:
|
||||
return f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Boolean filter E2E</title>
|
||||
<script src="/static/js/htmx.min.js"></script>
|
||||
<script src="/static/js/range_slider.js" type="module"></script>
|
||||
<script src="/static/js/search_select.js" type="module"></script>
|
||||
<script src="/static/js/filter_bar.js" type="module"></script>
|
||||
</head>
|
||||
<body>
|
||||
{FilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
def empty_bar_view(request):
|
||||
return HttpResponse(_bar_page())
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("test-boolean-filter/", empty_bar_view),
|
||||
]
|
||||
|
||||
|
||||
def _filter_from_url(url: str) -> dict:
|
||||
"""Extract and parse the ?filter=... query param from a URL."""
|
||||
query = urllib.parse.urlparse(url).query
|
||||
params = urllib.parse.parse_qs(query)
|
||||
raw = params.get("filter", [""])[0]
|
||||
return json.loads(raw) if raw else {}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_boolean_filter_e2e")
|
||||
def test_no_selection_omits_boolean_filters(live_server, page):
|
||||
page.goto(live_server.url + "/test-boolean-filter/")
|
||||
with page.expect_navigation():
|
||||
page.evaluate(
|
||||
"document.getElementById('filter-bar-form')"
|
||||
".dispatchEvent(new Event('submit', {cancelable: true}))"
|
||||
)
|
||||
parsed = _filter_from_url(page.url)
|
||||
assert "mastered" not in parsed
|
||||
assert "purchase_refunded" not in parsed
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_boolean_filter_e2e")
|
||||
def test_select_true_and_false_serializes_correctly(live_server, page):
|
||||
page.goto(live_server.url + "/test-boolean-filter/")
|
||||
|
||||
# Select "True" for Mastered
|
||||
# Under PurchaseFilterBar: "filter-mastered" is the mastered radio name.
|
||||
# The true radio has value="true", false radio has value="false"
|
||||
true_radio = page.locator('input[name="filter-mastered"][value="true"]')
|
||||
true_radio.click()
|
||||
|
||||
# Select "False" for Refunded (filter-purchase-refunded)
|
||||
false_radio = page.locator('input[name="filter-purchase-refunded"][value="false"]')
|
||||
false_radio.click()
|
||||
|
||||
with page.expect_navigation():
|
||||
page.evaluate(
|
||||
"document.getElementById('filter-bar-form')"
|
||||
".dispatchEvent(new Event('submit', {cancelable: true}))"
|
||||
)
|
||||
parsed = _filter_from_url(page.url)
|
||||
assert parsed.get("mastered") == {"value": True, "modifier": "EQUALS"}
|
||||
assert parsed.get("purchase_refunded") == {"value": False, "modifier": "EQUALS"}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_boolean_filter_e2e")
|
||||
def test_click_to_deselect_radio_works(live_server, page):
|
||||
page.goto(live_server.url + "/test-boolean-filter/")
|
||||
|
||||
true_radio = page.locator('input[name="filter-mastered"][value="true"]')
|
||||
|
||||
# First click checks it
|
||||
true_radio.click()
|
||||
assert true_radio.is_checked()
|
||||
|
||||
# Second click deselects it
|
||||
true_radio.click()
|
||||
assert not true_radio.is_checked()
|
||||
|
||||
with page.expect_navigation():
|
||||
page.evaluate(
|
||||
"document.getElementById('filter-bar-form')"
|
||||
".dispatchEvent(new Event('submit', {cancelable: true}))"
|
||||
)
|
||||
parsed = _filter_from_url(page.url)
|
||||
assert "mastered" not in parsed
|
||||
@@ -0,0 +1,84 @@
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
from playwright.sync_api import Page, expect
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_game_status_selector_opens_and_patches(authenticated_page: Page, live_server):
|
||||
from games.models import Game, Platform
|
||||
|
||||
platform = Platform.objects.create(name="PC", icon="pc")
|
||||
game = Game.objects.create(name="Test Game", platform=platform, status="u")
|
||||
|
||||
page = authenticated_page
|
||||
page.goto(f"{live_server.url}{reverse('games:list_games')}")
|
||||
|
||||
host = page.locator("game-status-selector").first
|
||||
expect(host).to_be_attached()
|
||||
host.locator("[data-toggle]").click()
|
||||
expect(host.locator("[data-menu]")).to_be_visible()
|
||||
with page.expect_response(
|
||||
lambda r: "/status" in r.url and r.request.method == "PATCH"
|
||||
):
|
||||
host.locator('[data-option][data-value="f"]').click()
|
||||
expect(host.locator("[data-menu]")).to_be_hidden()
|
||||
game.refresh_from_db()
|
||||
assert game.status == "f"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_session_device_selector_patches(authenticated_page: Page, live_server):
|
||||
from games.models import Device, Game, Platform, Session
|
||||
|
||||
platform = Platform.objects.create(name="PC", icon="pc")
|
||||
game = Game.objects.create(name="Test Game", platform=platform)
|
||||
desktop = Device.objects.create(name="Desktop")
|
||||
deck = Device.objects.create(name="Deck")
|
||||
session = Session.objects.create(
|
||||
game=game, device=desktop, timestamp_start="2025-01-01 00:00:00+00:00"
|
||||
)
|
||||
|
||||
page = authenticated_page
|
||||
page.goto(f"{live_server.url}{reverse('games:list_sessions')}")
|
||||
|
||||
host = page.locator("session-device-selector").first
|
||||
expect(host).to_be_attached()
|
||||
host.locator("[data-toggle]").click()
|
||||
with page.expect_response(
|
||||
lambda r: "/device" in r.url and r.request.method == "PATCH"
|
||||
):
|
||||
host.locator(f'[data-option][data-value="{deck.id}"]').click()
|
||||
session.refresh_from_db()
|
||||
assert session.device_id == deck.id
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_play_event_row_increments(authenticated_page: Page, live_server):
|
||||
from games.models import Game, Platform
|
||||
|
||||
platform = Platform.objects.create(name="PC", icon="pc")
|
||||
game = Game.objects.create(name="Test Game", platform=platform)
|
||||
|
||||
page = authenticated_page
|
||||
page.goto(f"{live_server.url}{reverse('games:view_game', args=[game.id])}")
|
||||
|
||||
host = page.locator("play-event-row").first
|
||||
expect(host).to_be_attached()
|
||||
host.locator("[data-toggle]").click()
|
||||
with page.expect_response(
|
||||
lambda r: "playevent" in r.url.lower() and r.request.method == "POST"
|
||||
):
|
||||
host.locator("[data-add-play]").click()
|
||||
expect(host.locator("[data-count]")).to_have_text("1")
|
||||
assert game.playevents.count() == 1
|
||||
@@ -0,0 +1,167 @@
|
||||
"""End-to-end Playwright test for the date-range filter widget's JS submit path.
|
||||
|
||||
Covers the one layer the Django-Client tests in ``test_rendered_pages.py``
|
||||
cannot reach: ``filter_bar.js`` reading the two ``<input type="date">``
|
||||
elements, building a ``DateCriterion`` JSON object, and navigating the
|
||||
browser to ``?filter=<encoded>``.
|
||||
|
||||
The native ``<input type="date">`` path is exercised through the Refunded
|
||||
field — the Purchased field now uses the DateRangePicker component, covered
|
||||
by ``test_date_range_picker_e2e.py``.
|
||||
|
||||
Renders the bar at its own custom URL so the test doesn't need to auth
|
||||
against the real app — the bar's JS doesn't care what route serves it.
|
||||
"""
|
||||
|
||||
import json
|
||||
import urllib.parse
|
||||
|
||||
import pytest
|
||||
from django.http import HttpResponse
|
||||
from django.test import override_settings
|
||||
from django.urls import path
|
||||
|
||||
from common.components import PurchaseFilterBar
|
||||
|
||||
|
||||
def _bar_page(filter_json: str = "") -> str:
|
||||
return f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Date filter E2E</title>
|
||||
<script src="/static/js/htmx.min.js"></script>
|
||||
<script src="/static/js/range_slider.js" type="module"></script>
|
||||
<script src="/static/js/search_select.js" type="module"></script>
|
||||
<script src="/static/js/filter_bar.js" type="module"></script>
|
||||
</head>
|
||||
<body>
|
||||
{PurchaseFilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
def empty_bar_view(request):
|
||||
return HttpResponse(_bar_page())
|
||||
|
||||
|
||||
def prefilled_bar_view(request):
|
||||
filter_json = json.dumps(
|
||||
{
|
||||
"date_refunded": {
|
||||
"value": "2024-03-15",
|
||||
"value2": "2024-09-20",
|
||||
"modifier": "BETWEEN",
|
||||
}
|
||||
}
|
||||
)
|
||||
return HttpResponse(_bar_page(filter_json))
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("test-date-filter/", empty_bar_view),
|
||||
path("test-date-filter-prefilled/", prefilled_bar_view),
|
||||
]
|
||||
|
||||
|
||||
def _filter_from_url(url: str) -> dict:
|
||||
"""Extract and parse the ?filter=... query param from a URL."""
|
||||
query = urllib.parse.urlparse(url).query
|
||||
params = urllib.parse.parse_qs(query)
|
||||
raw = params.get("filter", [""])[0]
|
||||
return json.loads(raw) if raw else {}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_date_filter_e2e")
|
||||
def test_both_dates_serializes_as_between(live_server, page):
|
||||
page.goto(live_server.url + "/test-date-filter/")
|
||||
page.locator('input[name="filter-date-refunded-min"]').fill("2024-01-01")
|
||||
page.locator('input[name="filter-date-refunded-max"]').fill("2024-12-31")
|
||||
with page.expect_navigation():
|
||||
page.evaluate(
|
||||
"document.getElementById('filter-bar-form')"
|
||||
".dispatchEvent(new Event('submit', {cancelable: true}))"
|
||||
)
|
||||
parsed = _filter_from_url(page.url)
|
||||
assert parsed == {
|
||||
"date_refunded": {
|
||||
"value": "2024-01-01",
|
||||
"value2": "2024-12-31",
|
||||
"modifier": "BETWEEN",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_date_filter_e2e")
|
||||
def test_min_only_serializes_as_greater_than(live_server, page):
|
||||
page.goto(live_server.url + "/test-date-filter/")
|
||||
page.locator('input[name="filter-date-refunded-min"]').fill("2024-06-15")
|
||||
with page.expect_navigation():
|
||||
page.evaluate(
|
||||
"document.getElementById('filter-bar-form')"
|
||||
".dispatchEvent(new Event('submit', {cancelable: true}))"
|
||||
)
|
||||
parsed = _filter_from_url(page.url)
|
||||
assert parsed == {
|
||||
"date_refunded": {"value": "2024-06-15", "modifier": "GREATER_THAN"}
|
||||
}
|
||||
# value2 must not be present when there's no upper bound.
|
||||
assert "value2" not in parsed["date_refunded"]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_date_filter_e2e")
|
||||
def test_max_only_serializes_as_less_than(live_server, page):
|
||||
page.goto(live_server.url + "/test-date-filter/")
|
||||
page.locator('input[name="filter-date-refunded-max"]').fill("2025-06-30")
|
||||
with page.expect_navigation():
|
||||
page.evaluate(
|
||||
"document.getElementById('filter-bar-form')"
|
||||
".dispatchEvent(new Event('submit', {cancelable: true}))"
|
||||
)
|
||||
parsed = _filter_from_url(page.url)
|
||||
assert parsed == {"date_refunded": {"value": "2025-06-30", "modifier": "LESS_THAN"}}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_date_filter_e2e")
|
||||
def test_empty_inputs_omit_date_criterion(live_server, page):
|
||||
"""No date typed → the filter JSON simply has no date_purchased /
|
||||
date_refunded keys (vs. an empty-string crash)."""
|
||||
page.goto(live_server.url + "/test-date-filter/")
|
||||
with page.expect_navigation():
|
||||
page.evaluate(
|
||||
"document.getElementById('filter-bar-form')"
|
||||
".dispatchEvent(new Event('submit', {cancelable: true}))"
|
||||
)
|
||||
parsed = _filter_from_url(page.url)
|
||||
assert "date_purchased" not in parsed
|
||||
assert "date_refunded" not in parsed
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_date_filter_e2e")
|
||||
def test_prefilled_bar_reflects_existing_filter_in_inputs(live_server, page):
|
||||
"""A bar rendered with a BETWEEN filter_json pre-fills the inputs and
|
||||
re-submits the same bounds unchanged."""
|
||||
page.goto(live_server.url + "/test-date-filter-prefilled/")
|
||||
assert (
|
||||
page.locator('input[name="filter-date-refunded-min"]').input_value()
|
||||
== "2024-03-15"
|
||||
)
|
||||
assert (
|
||||
page.locator('input[name="filter-date-refunded-max"]').input_value()
|
||||
== "2024-09-20"
|
||||
)
|
||||
with page.expect_navigation():
|
||||
page.evaluate(
|
||||
"document.getElementById('filter-bar-form')"
|
||||
".dispatchEvent(new Event('submit', {cancelable: true}))"
|
||||
)
|
||||
parsed = _filter_from_url(page.url)
|
||||
assert parsed["date_refunded"] == {
|
||||
"value": "2024-03-15",
|
||||
"value2": "2024-09-20",
|
||||
"modifier": "BETWEEN",
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
"""End-to-end Playwright tests for the DateRangePicker component.
|
||||
|
||||
Exercises the behaviour layers the rendering tests cannot reach
|
||||
(``date_range_picker.js``): segmented digit entry with right-to-left
|
||||
placeholder fill and auto-advance, Backspace reverting a part, the calendar
|
||||
popup's anchor-style range picking, presets, the Cancel / Clear / Select
|
||||
footer, and the ``filter_bar.js`` serialization of the hidden ISO inputs
|
||||
into a ``DateCriterion``.
|
||||
|
||||
Like the other filter-bar e2e modules, the bar is served from its own
|
||||
minimal URLconf (no auth, no CSS) — the JS only cares about the DOM.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import urllib.parse
|
||||
|
||||
import pytest
|
||||
from django.http import HttpResponse
|
||||
from django.test import override_settings
|
||||
|
||||
from common.components import PurchaseFilterBar
|
||||
from django.urls import path
|
||||
|
||||
|
||||
def _bar_page(filter_json: str = "") -> str:
|
||||
return f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Date range picker E2E</title>
|
||||
<script src="/static/js/htmx.min.js"></script>
|
||||
<script src="/static/js/range_slider.js" type="module"></script>
|
||||
<script src="/static/js/search_select.js" type="module"></script>
|
||||
<script src="/static/js/date_range_picker.js" defer></script>
|
||||
<script src="/static/js/filter_bar.js" type="module"></script>
|
||||
</head>
|
||||
<body>
|
||||
{PurchaseFilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
def empty_bar_view(request):
|
||||
return HttpResponse(_bar_page())
|
||||
|
||||
|
||||
def prefilled_bar_view(request):
|
||||
filter_json = json.dumps(
|
||||
{
|
||||
"date_purchased": {
|
||||
"value": "2024-03-15",
|
||||
"value2": "2024-09-20",
|
||||
"modifier": "BETWEEN",
|
||||
}
|
||||
}
|
||||
)
|
||||
return HttpResponse(_bar_page(filter_json))
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("test-date-range-picker/", empty_bar_view),
|
||||
path("test-date-range-picker-prefilled/", prefilled_bar_view),
|
||||
]
|
||||
|
||||
|
||||
PICKER = '[data-date-range-picker][data-input-name-prefix="filter-date-purchased"]'
|
||||
POPUP = PICKER + " [data-date-range-calendar]"
|
||||
HIDDEN_MIN = 'input[name="filter-date-purchased-min"]'
|
||||
HIDDEN_MAX = 'input[name="filter-date-purchased-max"]'
|
||||
|
||||
|
||||
def _segment(page, side: str, part: str):
|
||||
return page.locator(
|
||||
f'{PICKER} input[data-date-side="{side}"][data-date-part="{part}"]'
|
||||
)
|
||||
|
||||
|
||||
def _day_cell(page, iso_date: str):
|
||||
return page.locator(
|
||||
f'{PICKER} [data-date-range-grid] button[data-date="{iso_date}"]'
|
||||
)
|
||||
|
||||
|
||||
def _popup_is_open(page) -> bool:
|
||||
return "hidden" not in (page.locator(POPUP).get_attribute("class") or "")
|
||||
|
||||
|
||||
def _submit_filter_bar(page):
|
||||
with page.expect_navigation():
|
||||
page.evaluate(
|
||||
"document.getElementById('filter-bar-form')"
|
||||
".dispatchEvent(new Event('submit', {cancelable: true}))"
|
||||
)
|
||||
|
||||
|
||||
def _filter_from_url(url: str) -> dict:
|
||||
query = urllib.parse.urlparse(url).query
|
||||
params = urllib.parse.parse_qs(query)
|
||||
raw = params.get("filter", [""])[0]
|
||||
return json.loads(raw) if raw else {}
|
||||
|
||||
|
||||
# ── Segmented manual entry ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
|
||||
def test_typing_fills_parts_and_serializes_between(live_server, page):
|
||||
"""Digits flow through the parts (DD → MM → YYYY → DD …) with
|
||||
auto-advance, ending in a BETWEEN criterion on submit."""
|
||||
page.goto(live_server.url + "/test-date-range-picker/")
|
||||
_segment(page, "min", "day").click()
|
||||
page.keyboard.type("1503202420092024")
|
||||
assert page.locator(HIDDEN_MIN).input_value() == "2024-03-15"
|
||||
assert page.locator(HIDDEN_MAX).input_value() == "2024-09-20"
|
||||
_submit_filter_bar(page)
|
||||
parsed = _filter_from_url(page.url)
|
||||
assert parsed == {
|
||||
"date_purchased": {
|
||||
"value": "2024-03-15",
|
||||
"value2": "2024-09-20",
|
||||
"modifier": "BETWEEN",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
|
||||
def test_placeholder_fills_from_the_right(live_server, page):
|
||||
"""Typing 19 into the YYYY part shows YYY1 then YY19."""
|
||||
page.goto(live_server.url + "/test-date-range-picker/")
|
||||
year_segment = _segment(page, "min", "year")
|
||||
year_segment.click()
|
||||
page.keyboard.press("1")
|
||||
assert year_segment.input_value() == "YYY1"
|
||||
page.keyboard.press("9")
|
||||
assert year_segment.input_value() == "YY19"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
|
||||
def test_min_side_only_serializes_greater_than(live_server, page):
|
||||
page.goto(live_server.url + "/test-date-range-picker/")
|
||||
_segment(page, "min", "day").click()
|
||||
page.keyboard.type("15062024")
|
||||
_submit_filter_bar(page)
|
||||
parsed = _filter_from_url(page.url)
|
||||
assert parsed == {
|
||||
"date_purchased": {"value": "2024-06-15", "modifier": "GREATER_THAN"}
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
|
||||
def test_backspace_reverts_part_to_placeholder(live_server, page):
|
||||
page.goto(live_server.url + "/test-date-range-picker/")
|
||||
_segment(page, "min", "day").click()
|
||||
page.keyboard.type("15032024")
|
||||
assert page.locator(HIDDEN_MIN).input_value() == "2024-03-15"
|
||||
month_segment = _segment(page, "min", "month")
|
||||
month_segment.click()
|
||||
page.keyboard.press("Backspace")
|
||||
assert month_segment.input_value() == ""
|
||||
# An incomplete date no longer commits to the hidden input.
|
||||
assert page.locator(HIDDEN_MIN).input_value() == ""
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
|
||||
def test_only_numbers_can_be_typed(live_server, page):
|
||||
page.goto(live_server.url + "/test-date-range-picker/")
|
||||
day_segment = _segment(page, "min", "day")
|
||||
day_segment.click()
|
||||
page.keyboard.type("ab-/")
|
||||
assert day_segment.input_value() == ""
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
|
||||
def test_invalid_calendar_date_does_not_commit(live_server, page):
|
||||
"""31-02-2024 fills all parts but is not a real date — no hidden value."""
|
||||
page.goto(live_server.url + "/test-date-range-picker/")
|
||||
_segment(page, "min", "day").click()
|
||||
page.keyboard.type("31022024")
|
||||
assert page.locator(HIDDEN_MIN).input_value() == ""
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
|
||||
def test_clicking_container_activates_first_part(live_server, page):
|
||||
page.goto(live_server.url + "/test-date-range-picker/")
|
||||
page.locator(PICKER + " [data-date-range-field]").click(position={"x": 5, "y": 5})
|
||||
focused = page.evaluate(
|
||||
"document.activeElement.getAttribute('data-date-part') + ':' +"
|
||||
"document.activeElement.getAttribute('data-date-side')"
|
||||
)
|
||||
assert focused == "day:min"
|
||||
|
||||
|
||||
# ── Calendar popup ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _open_calendar(page):
|
||||
page.locator(PICKER + " [data-date-range-calendar-toggle]").click()
|
||||
|
||||
|
||||
def _current_month_iso(day_of_month: int) -> str:
|
||||
today = datetime.date.today()
|
||||
return today.replace(day=day_of_month).isoformat()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
|
||||
def test_calendar_pick_range_then_select(live_server, page):
|
||||
page.goto(live_server.url + "/test-date-range-picker/")
|
||||
_open_calendar(page)
|
||||
assert _popup_is_open(page)
|
||||
first_pick = _current_month_iso(10)
|
||||
second_pick = _current_month_iso(20)
|
||||
_day_cell(page, first_pick).click()
|
||||
assert page.locator(HIDDEN_MIN).input_value() == first_pick
|
||||
assert page.locator(HIDDEN_MAX).input_value() == ""
|
||||
_day_cell(page, second_pick).click()
|
||||
assert page.locator(HIDDEN_MAX).input_value() == second_pick
|
||||
page.locator(PICKER + " [data-date-range-select]").click()
|
||||
assert not _popup_is_open(page)
|
||||
_submit_filter_bar(page)
|
||||
parsed = _filter_from_url(page.url)
|
||||
assert parsed == {
|
||||
"date_purchased": {
|
||||
"value": first_pick,
|
||||
"value2": second_pick,
|
||||
"modifier": "BETWEEN",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
|
||||
def test_picking_before_start_restarts_the_range(live_server, page):
|
||||
"""With the StartDate anchored, picking an earlier date clears the range
|
||||
and the clicked date becomes the new StartDate."""
|
||||
page.goto(live_server.url + "/test-date-range-picker/")
|
||||
_open_calendar(page)
|
||||
_day_cell(page, _current_month_iso(20)).click()
|
||||
_day_cell(page, _current_month_iso(10)).click()
|
||||
assert page.locator(HIDDEN_MIN).input_value() == _current_month_iso(10)
|
||||
assert page.locator(HIDDEN_MAX).input_value() == ""
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
|
||||
def test_completed_range_anchor_moves_to_end(live_server, page):
|
||||
"""After both dates are picked the EndDate becomes the anchor, so a
|
||||
further pick inside the range moves the StartDate."""
|
||||
page.goto(live_server.url + "/test-date-range-picker/")
|
||||
_open_calendar(page)
|
||||
_day_cell(page, _current_month_iso(10)).click()
|
||||
_day_cell(page, _current_month_iso(20)).click()
|
||||
_day_cell(page, _current_month_iso(15)).click()
|
||||
assert page.locator(HIDDEN_MIN).input_value() == _current_month_iso(15)
|
||||
assert page.locator(HIDDEN_MAX).input_value() == _current_month_iso(20)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
|
||||
def test_preset_fills_both_dates(live_server, page):
|
||||
page.goto(live_server.url + "/test-date-range-picker/")
|
||||
_open_calendar(page)
|
||||
page.locator(PICKER + ' [data-date-range-preset="last_7_days"]').click()
|
||||
today = datetime.date.today()
|
||||
assert (
|
||||
page.locator(HIDDEN_MIN).input_value()
|
||||
== (today - datetime.timedelta(days=6)).isoformat()
|
||||
)
|
||||
assert page.locator(HIDDEN_MAX).input_value() == today.isoformat()
|
||||
# Presets keep the popup open; Select commits and closes.
|
||||
assert _popup_is_open(page)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
|
||||
def test_clear_clears_dates_but_keeps_popup_open(live_server, page):
|
||||
page.goto(live_server.url + "/test-date-range-picker/")
|
||||
_open_calendar(page)
|
||||
_day_cell(page, _current_month_iso(10)).click()
|
||||
_day_cell(page, _current_month_iso(20)).click()
|
||||
page.locator(PICKER + " [data-date-range-clear]").click()
|
||||
assert page.locator(HIDDEN_MIN).input_value() == ""
|
||||
assert page.locator(HIDDEN_MAX).input_value() == ""
|
||||
assert _popup_is_open(page)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
|
||||
def test_cancel_clears_dates_and_closes_popup(live_server, page):
|
||||
page.goto(live_server.url + "/test-date-range-picker/")
|
||||
_open_calendar(page)
|
||||
_day_cell(page, _current_month_iso(10)).click()
|
||||
_day_cell(page, _current_month_iso(20)).click()
|
||||
page.locator(PICKER + " [data-date-range-cancel]").click()
|
||||
assert page.locator(HIDDEN_MIN).input_value() == ""
|
||||
assert page.locator(HIDDEN_MAX).input_value() == ""
|
||||
assert not _popup_is_open(page)
|
||||
|
||||
|
||||
# ── Prefill round-trip ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
|
||||
def test_prefilled_picker_round_trips_unchanged(live_server, page):
|
||||
page.goto(live_server.url + "/test-date-range-picker-prefilled/")
|
||||
assert _segment(page, "min", "day").input_value() == "15"
|
||||
assert _segment(page, "min", "month").input_value() == "03"
|
||||
assert _segment(page, "min", "year").input_value() == "2024"
|
||||
assert _segment(page, "max", "day").input_value() == "20"
|
||||
assert page.locator(HIDDEN_MIN).input_value() == "2024-03-15"
|
||||
assert page.locator(HIDDEN_MAX).input_value() == "2024-09-20"
|
||||
_submit_filter_bar(page)
|
||||
parsed = _filter_from_url(page.url)
|
||||
assert parsed["date_purchased"] == {
|
||||
"value": "2024-03-15",
|
||||
"value2": "2024-09-20",
|
||||
"modifier": "BETWEEN",
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
"""End-to-end Playwright test for the RangeSlider JS synchronization, cross-over, and clamping behavior."""
|
||||
|
||||
import pytest
|
||||
from django.http import HttpResponse
|
||||
from django.test import override_settings
|
||||
from django.urls import path
|
||||
|
||||
from common.components import FilterBar
|
||||
|
||||
|
||||
def _bar_page(filter_json: str = "") -> str:
|
||||
return f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Range Slider E2E</title>
|
||||
<script src="/static/js/htmx.min.js"></script>
|
||||
<script src="/static/js/range_slider.js" type="module"></script>
|
||||
<script src="/static/js/search_select.js" type="module"></script>
|
||||
<script src="/static/js/filter_bar.js" type="module"></script>
|
||||
</head>
|
||||
<body>
|
||||
{FilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
def empty_bar_view(request):
|
||||
return HttpResponse(_bar_page())
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("test-range-slider/", empty_bar_view),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_range_slider_e2e")
|
||||
def test_range_slider_crossover_min_higher_than_max(live_server, page):
|
||||
page.goto(live_server.url + "/test-range-slider/")
|
||||
|
||||
# 1. Start with known state: Min is empty, Max is empty
|
||||
min_input = page.locator('input[name="filter-session-count-min"]')
|
||||
max_input = page.locator('input[name="filter-session-count-max"]')
|
||||
|
||||
# 2. Type "20" into max input
|
||||
max_input.fill("20")
|
||||
|
||||
# 3. Type "50" into min input (which is higher than 20)
|
||||
min_input.fill("50")
|
||||
|
||||
# 4. Max input should have automatically synchronized/snapped to 50
|
||||
assert max_input.input_value() == "50"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_range_slider_e2e")
|
||||
def test_range_slider_crossover_max_less_than_min(live_server, page):
|
||||
page.goto(live_server.url + "/test-range-slider/")
|
||||
|
||||
min_input = page.locator('input[name="filter-session-count-min"]')
|
||||
max_input = page.locator('input[name="filter-session-count-max"]')
|
||||
|
||||
# 1. Type "50" into min input
|
||||
min_input.fill("50")
|
||||
|
||||
# 2. Type "30" into max input (which is less than 50)
|
||||
max_input.fill("30")
|
||||
|
||||
# 3. Min input should have automatically synchronized/snapped to 30
|
||||
assert min_input.input_value() == "30"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_range_slider_e2e")
|
||||
def test_range_slider_strict_bounds_clamping_on_blur(live_server, page):
|
||||
page.goto(live_server.url + "/test-range-slider/")
|
||||
|
||||
min_input = page.locator('input[name="filter-session-count-min"]')
|
||||
max_input = page.locator('input[name="filter-session-count-max"]')
|
||||
|
||||
# 1. Type value higher than dataMax (100 is max, type "150")
|
||||
max_input.fill("150")
|
||||
max_input.blur() # triggers "change" event
|
||||
|
||||
assert max_input.input_value() == "100"
|
||||
|
||||
# 2. Type value lower than dataMin (0 is min, type "-20")
|
||||
min_input.fill("-20")
|
||||
min_input.blur() # triggers "change" event
|
||||
|
||||
assert min_input.input_value() == "0"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_range_slider_e2e")
|
||||
def test_range_slider_empty_max_thumb_does_not_jump_to_beginning(live_server, page):
|
||||
page.goto(live_server.url + "/test-range-slider/")
|
||||
|
||||
# Locate handles
|
||||
max_handle = page.locator(
|
||||
'.range-handle-max[data-target="filter-session-count-max"]'
|
||||
)
|
||||
|
||||
# Initially, max_input is empty, so handle should sit at 100% (far right)
|
||||
style = max_handle.get_attribute("style")
|
||||
assert "left:100%" in style or "left: 100%" in style
|
||||
|
||||
# Set min to 50
|
||||
min_input = page.locator('input[name="filter-session-count-min"]')
|
||||
min_input.fill("50")
|
||||
|
||||
# Max handle should STILL stay at 100% since max input is still empty (defaults to max_value)
|
||||
style = max_handle.get_attribute("style")
|
||||
assert "left:100%" in style or "left: 100%" in style
|
||||
@@ -0,0 +1,109 @@
|
||||
import pytest
|
||||
from django.urls import path
|
||||
from django.http import HttpResponse
|
||||
from django.test import override_settings
|
||||
from common.components import SearchSelect
|
||||
|
||||
|
||||
def e2e_test_view(request):
|
||||
html = f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>SearchSelect E2E Test</title>
|
||||
<!-- search_select.js is an ES module and initializes via onSwap(),
|
||||
which rides on htmx.onLoad — so htmx must be present. -->
|
||||
<script src="/static/js/htmx.min.js"></script>
|
||||
<script type="module" src="/static/js/search_select.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div style="padding: 50px;">
|
||||
{
|
||||
SearchSelect(
|
||||
name="games",
|
||||
selected=[{"value": "7", "label": "Game A", "data": {}}],
|
||||
options=[
|
||||
{"value": "7", "label": "Game A", "data": {}},
|
||||
{"value": "8", "label": "Game B", "data": {}},
|
||||
],
|
||||
multi_select=False,
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
return HttpResponse(html)
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("test-search-select/", e2e_test_view),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_search_select_e2e")
|
||||
def test_search_select_backspace_clears_single_select(live_server, page):
|
||||
# Enable console log forwarding
|
||||
page.on("console", lambda msg: print(f"BROWSER CONSOLE: {msg.text}"))
|
||||
|
||||
page.goto(live_server.url + "/test-search-select/")
|
||||
|
||||
# Inject our event logger
|
||||
page.evaluate("""() => {
|
||||
const s = document.querySelector('input[data-search-select-search]');
|
||||
const c = document.querySelector('[data-search-select]');
|
||||
s.addEventListener('focus', () => console.log('JS-EVENT: focus, dirty=' + c._searchSelectDirty + ', value="' + s.value + '"'));
|
||||
s.addEventListener('blur', () => console.log('JS-EVENT: blur, dirty=' + c._searchSelectDirty + ', value="' + s.value + '"'));
|
||||
s.addEventListener('input', () => console.log('JS-EVENT: input, dirty=' + c._searchSelectDirty + ', value="' + s.value + '"'));
|
||||
s.addEventListener('keydown', (e) => console.log('JS-EVENT: keydown ' + e.key + ', dirty=' + c._searchSelectDirty + ', value="' + s.value + '"'));
|
||||
}""")
|
||||
|
||||
search_input = page.locator("input[data-search-select-search]")
|
||||
|
||||
assert search_input.input_value() == "Game A"
|
||||
|
||||
hidden_input = page.locator('input[name="games"]')
|
||||
assert hidden_input.first.get_attribute("value") == "7"
|
||||
|
||||
# Focus the input
|
||||
print("\n--- FOCUSING INPUT ---")
|
||||
search_input.focus()
|
||||
assert search_input.input_value() == ""
|
||||
|
||||
# Press Backspace using the raw keyboard API to avoid any high-level Playwright input simulation
|
||||
print("\n--- PRESSING BACKSPACE ---")
|
||||
page.keyboard.press("Backspace")
|
||||
|
||||
# Explicitly blur the input
|
||||
print("\n--- BLURRING INPUT ---")
|
||||
search_input.blur()
|
||||
|
||||
# Wait for blur microtasks/setTimeout to settle (120ms timeout in JS)
|
||||
page.wait_for_timeout(200)
|
||||
|
||||
# After Backspace and blur, the input should remain empty (the selection is cleared)
|
||||
assert search_input.input_value() == ""
|
||||
assert hidden_input.count() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_search_select_e2e")
|
||||
def test_search_select_typing_replaces_single_select(live_server, page):
|
||||
page.goto(live_server.url + "/test-search-select/")
|
||||
|
||||
search_input = page.locator("input[data-search-select-search]")
|
||||
|
||||
search_input.focus()
|
||||
assert search_input.input_value() == ""
|
||||
|
||||
search_input.type("X")
|
||||
assert search_input.input_value() == "X"
|
||||
|
||||
search_input.blur()
|
||||
page.wait_for_timeout(200)
|
||||
|
||||
assert search_input.input_value() == "Game A"
|
||||
|
||||
hidden_input = page.locator('input[name="games"]')
|
||||
assert hidden_input.first.get_attribute("value") == "7"
|
||||
@@ -0,0 +1,150 @@
|
||||
"""End-to-end Playwright test for String multi-mode filter serialization, null-state toggling, and prefill behaviors."""
|
||||
|
||||
import json
|
||||
import urllib.parse
|
||||
|
||||
import pytest
|
||||
from django.http import HttpResponse
|
||||
from django.test import override_settings
|
||||
from django.urls import path
|
||||
|
||||
from common.components import PlatformFilterBar
|
||||
|
||||
|
||||
def _bar_page(filter_json: str = "") -> str:
|
||||
return f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>String filter E2E</title>
|
||||
<script src="/static/js/htmx.min.js"></script>
|
||||
<script src="/static/js/range_slider.js" type="module"></script>
|
||||
<script src="/static/js/search_select.js" type="module"></script>
|
||||
<script src="/static/js/filter_bar.js" type="module"></script>
|
||||
</head>
|
||||
<body>
|
||||
{PlatformFilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
def empty_bar_view(request):
|
||||
return HttpResponse(_bar_page())
|
||||
|
||||
|
||||
def prefilled_bar_view(request):
|
||||
filter_json = json.dumps(
|
||||
{
|
||||
"name": {
|
||||
"value": "Switch",
|
||||
"modifier": "INCLUDES",
|
||||
},
|
||||
"group": {"modifier": "IS_NULL"},
|
||||
}
|
||||
)
|
||||
return HttpResponse(_bar_page(filter_json=filter_json))
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("test-string-filter-empty/", empty_bar_view),
|
||||
path("test-string-filter-prefilled/", prefilled_bar_view),
|
||||
]
|
||||
|
||||
|
||||
def _filter_from_url(url: str) -> dict:
|
||||
query = urllib.parse.urlparse(url).query
|
||||
params = urllib.parse.parse_qs(query)
|
||||
raw = params.get("filter", [""])[0]
|
||||
return json.loads(raw) if raw else {}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_string_filter_e2e")
|
||||
def test_string_filter_defaults_and_toggles(live_server, page):
|
||||
page.goto(live_server.url + "/test-string-filter-empty/")
|
||||
|
||||
# 1. Verify text inputs are active by default and modifier "is" (EQUALS) is checked
|
||||
name_input = page.locator('input[name="filter-name"]')
|
||||
assert name_input.is_enabled()
|
||||
|
||||
is_radio = page.locator('input[name="filter-name-modifier"][value="EQUALS"]')
|
||||
assert is_radio.is_checked()
|
||||
|
||||
# 2. Enter values, click "includes" (INCLUDES), and submit
|
||||
name_input.fill("PlayStation")
|
||||
includes_radio = page.locator(
|
||||
'input[name="filter-name-modifier"][value="INCLUDES"]'
|
||||
)
|
||||
includes_radio.click()
|
||||
|
||||
with page.expect_navigation():
|
||||
page.evaluate(
|
||||
"document.getElementById('filter-bar-form')"
|
||||
".dispatchEvent(new Event('submit', {cancelable: true}))"
|
||||
)
|
||||
parsed = _filter_from_url(page.url)
|
||||
assert parsed["name"] == {"value": "PlayStation", "modifier": "INCLUDES"}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_string_filter_e2e")
|
||||
def test_string_filter_null_states(live_server, page):
|
||||
page.goto(live_server.url + "/test-string-filter-empty/")
|
||||
|
||||
name_input = page.locator('input[name="filter-name"]')
|
||||
name_input.fill("Xbox")
|
||||
|
||||
# Click "is null"
|
||||
is_null_radio = page.locator('input[name="filter-name-modifier"][value="IS_NULL"]')
|
||||
is_null_radio.click()
|
||||
|
||||
# Verification of interactive disabling
|
||||
assert not name_input.is_enabled()
|
||||
assert name_input.input_value() == ""
|
||||
|
||||
with page.expect_navigation():
|
||||
page.evaluate(
|
||||
"document.getElementById('filter-bar-form')"
|
||||
".dispatchEvent(new Event('submit', {cancelable: true}))"
|
||||
)
|
||||
parsed = _filter_from_url(page.url)
|
||||
assert parsed["name"] == {"modifier": "IS_NULL"}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_string_filter_e2e")
|
||||
def test_string_filter_prefilled_states(live_server, page):
|
||||
page.goto(live_server.url + "/test-string-filter-prefilled/")
|
||||
|
||||
name_input = page.locator('input[name="filter-name"]')
|
||||
group_input = page.locator('input[name="filter-group"]')
|
||||
|
||||
# Verifies name matches "Switch" and "includes" is checked
|
||||
assert name_input.input_value() == "Switch"
|
||||
assert name_input.is_enabled()
|
||||
assert page.locator(
|
||||
'input[name="filter-name-modifier"][value="INCLUDES"]'
|
||||
).is_checked()
|
||||
|
||||
# Verifies group is empty, disabled, and "is null" is checked
|
||||
assert group_input.input_value() == ""
|
||||
assert not group_input.is_enabled()
|
||||
assert page.locator(
|
||||
'input[name="filter-group-modifier"][value="IS_NULL"]'
|
||||
).is_checked()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_string_filter_e2e")
|
||||
def test_string_filter_deselect_re_enables(live_server, page):
|
||||
page.goto(live_server.url + "/test-string-filter-empty/")
|
||||
|
||||
name_input = page.locator('input[name="filter-name"]')
|
||||
is_null_radio = page.locator('input[name="filter-name-modifier"][value="IS_NULL"]')
|
||||
|
||||
# 1. Click "is null" -> disables input
|
||||
is_null_radio.click()
|
||||
assert not name_input.is_enabled()
|
||||
|
||||
# 2. Click "is null" again to deselect/uncheck -> should re-enable the text input
|
||||
is_null_radio.click()
|
||||
assert name_input.is_enabled()
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Browser tests for widget JavaScript (search_select.js, range_slider.js,
|
||||
add_purchase.js) and their onSwap() initialization lifecycle.
|
||||
|
||||
These run a real Chromium via pytest-playwright against pytest-django's
|
||||
``live_server``. All JavaScript under test is served locally from
|
||||
``games/static/js/`` (htmx, Alpine, Flowbite and the widget files are
|
||||
vendored), so no network access is needed beyond the live server itself.
|
||||
|
||||
Browser binaries must be installed once: ``uv run playwright install chromium``.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
from playwright.sync_api import Page, expect
|
||||
|
||||
|
||||
@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 open_filter_bar(page: Page) -> None:
|
||||
page.click("#filter-bar button:has-text('Filters')")
|
||||
expect(page.locator("#filter-bar-body")).to_be_visible()
|
||||
|
||||
|
||||
def status_filter_widget(page: Page):
|
||||
return page.locator('[data-search-select][data-name="status"]')
|
||||
|
||||
|
||||
def test_search_select_initializes_on_page_load(authenticated_page: Page, live_server):
|
||||
"""Clicking into a FilterSelect search box opens its options panel —
|
||||
proof that onSwap ran the widget initializer on the initial page load."""
|
||||
page = authenticated_page
|
||||
page.goto(f"{live_server.url}{reverse('games:list_games')}")
|
||||
open_filter_bar(page)
|
||||
|
||||
widget = status_filter_widget(page)
|
||||
widget.locator("[data-search-select-search]").click()
|
||||
|
||||
options_panel = widget.locator("[data-search-select-options]")
|
||||
expect(options_panel).to_be_visible()
|
||||
# The pinned "(Any)" modifier pseudo-option is rendered server-side and
|
||||
# only becomes interactable through the initialized panel.
|
||||
expect(
|
||||
options_panel.locator("[data-search-select-modifier-option]").first
|
||||
).to_have_text("(Any)")
|
||||
|
||||
|
||||
def test_search_select_adds_include_pill(authenticated_page: Page, live_server):
|
||||
"""Clicking an enum option row adds an include pill (full widget wiring)."""
|
||||
page = authenticated_page
|
||||
page.goto(f"{live_server.url}{reverse('games:list_games')}")
|
||||
open_filter_bar(page)
|
||||
|
||||
widget = status_filter_widget(page)
|
||||
widget.locator("[data-search-select-search]").click()
|
||||
widget.locator('[data-search-select-option][data-label="Finished"]').click()
|
||||
|
||||
pill = widget.locator("[data-search-select-pills] [data-pill]")
|
||||
expect(pill).to_have_count(1)
|
||||
expect(pill).to_contain_text("Finished")
|
||||
|
||||
|
||||
def test_range_slider_mode_toggle_fires_exactly_once(
|
||||
authenticated_page: Page, live_server
|
||||
):
|
||||
"""One click on the mode toggle flips the slider from range to point mode
|
||||
exactly once. Double-bound listeners (the old force-re-init bug) would
|
||||
flip it twice, leaving data-mode unchanged."""
|
||||
page = authenticated_page
|
||||
page.goto(f"{live_server.url}{reverse('games:list_games')}")
|
||||
open_filter_bar(page)
|
||||
|
||||
block = page.locator(".range-slider-block").first
|
||||
slider = block.locator(".range-slider")
|
||||
expect(slider).to_have_attribute("data-mode", "range")
|
||||
|
||||
block.locator(".range-mode-toggle").click()
|
||||
expect(slider).to_have_attribute("data-mode", "point")
|
||||
|
||||
|
||||
def test_widgets_initialize_inside_htmx_swapped_content(
|
||||
authenticated_page: Page, live_server
|
||||
):
|
||||
"""Widgets arriving via an htmx swap initialize without a page load.
|
||||
|
||||
The filter bar is re-fetched and swapped in with htmx.ajax — fresh,
|
||||
uninitialized DOM. The swapped-in FilterSelect must open its panel and the
|
||||
swapped-in slider must toggle exactly once, proving the htmx:load half of
|
||||
onSwap and the once-per-element guard."""
|
||||
page = authenticated_page
|
||||
page.goto(f"{live_server.url}{reverse('games:list_games')}")
|
||||
|
||||
page.evaluate(
|
||||
"htmx.ajax('GET', window.location.pathname, "
|
||||
"{target: '#filter-bar', select: '#filter-bar', swap: 'outerHTML'})"
|
||||
)
|
||||
# The swapped-in bar arrives collapsed again; opening it proves the swap
|
||||
# happened and the fresh DOM is in place.
|
||||
open_filter_bar(page)
|
||||
|
||||
widget = status_filter_widget(page)
|
||||
widget.locator("[data-search-select-search]").click()
|
||||
expect(widget.locator("[data-search-select-options]")).to_be_visible()
|
||||
|
||||
block = page.locator(".range-slider-block").first
|
||||
slider = block.locator(".range-slider")
|
||||
expect(slider).to_have_attribute("data-mode", "range")
|
||||
block.locator(".range-mode-toggle").click()
|
||||
expect(slider).to_have_attribute("data-mode", "point")
|
||||
|
||||
|
||||
def test_add_purchase_type_toggles_disabled_fields(
|
||||
authenticated_page: Page, live_server
|
||||
):
|
||||
"""add_purchase.js disables name/related-purchase while type is "game"
|
||||
and re-enables them for other types."""
|
||||
page = authenticated_page
|
||||
page.goto(f"{live_server.url}{reverse('games:add_purchase')}")
|
||||
|
||||
name_input = page.locator("#id_name")
|
||||
expect(name_input).to_be_disabled()
|
||||
|
||||
page.select_option("#id_type", "dlc")
|
||||
expect(name_input).to_be_enabled()
|
||||
|
||||
page.select_option("#id_type", "game")
|
||||
expect(name_input).to_be_disabled()
|
||||
+41
-2
@@ -1,8 +1,16 @@
|
||||
#!/bin/bash
|
||||
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}
|
||||
PGID=${PGID:-100}
|
||||
DATA_DIR=${DATA_DIR:-/home/timetracker/app/data}
|
||||
|
||||
USERHOME=$(grep timetracker /etc/passwd | cut -d ":" -f6)
|
||||
usermod -d "/root" timetracker
|
||||
@@ -10,14 +18,45 @@ groupmod -o -g "$PGID" timetracker
|
||||
usermod -o -u "$PUID" 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/.venv
|
||||
|
||||
chown "$PUID:$PGID" /home/timetracker/app/data
|
||||
chown "$PUID:$PGID" "$DATA_DIR"
|
||||
chown "$PUID:$PGID" /var/log/supervisor
|
||||
|
||||
python manage.py migrate
|
||||
python manage.py collectstatic --clear --no-input
|
||||
|
||||
# Staging seeded from a production snapshot: remove copied sessions and the
|
||||
# inherited django-q schedule/queue so staging neither shares prod's session
|
||||
# cookies nor independently runs scheduled tasks (see issue #20).
|
||||
if [ "${STAGING:-false}" = "true" ]; then
|
||||
python manage.py scrub_staging
|
||||
fi
|
||||
|
||||
# Public staging with a fresh database (e.g. Fly.io): load demo data instead
|
||||
# of any production snapshot. Runs once while the games table is empty.
|
||||
if [ "${LOAD_SAMPLE_DATA:-false}" = "true" ]; then
|
||||
python manage.py shell -c "
|
||||
from games.models import Game
|
||||
from django.core.management import call_command
|
||||
if not Game.objects.exists():
|
||||
call_command('loaddata', 'sample.yaml')
|
||||
print('Loaded sample data.')
|
||||
"
|
||||
fi
|
||||
|
||||
if [ "${CREATE_DEFAULT_SUPERUSER:-false}" = "true" ]; then
|
||||
python manage.py shell -c "
|
||||
from django.contrib.auth import get_user_model
|
||||
User = get_user_model()
|
||||
if not User.objects.filter(username='admin').exists():
|
||||
User.objects.create_superuser('admin', '', 'admin')
|
||||
print('Created default superuser: admin / admin')
|
||||
"
|
||||
fi
|
||||
|
||||
chown -R "$PUID:$PGID" "$DATA_DIR"
|
||||
|
||||
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisor.conf
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Shared Fly.io configuration for ephemeral, per-branch GitHub staging deploys.
|
||||
#
|
||||
# The app name is NOT set here on purpose; each branch supplies its own via
|
||||
# `flyctl deploy --app timetracker-staging-<slug>`. These instances run with a
|
||||
# fresh database seeded from sample fixtures (never production data) and their
|
||||
# own SECRET_KEY, so they are safe to expose on a public *.fly.dev hostname.
|
||||
|
||||
primary_region = "ams"
|
||||
|
||||
[build]
|
||||
dockerfile = "Dockerfile"
|
||||
|
||||
[env]
|
||||
DEBUG = "false"
|
||||
TZ = "Europe/Prague"
|
||||
DATA_DIR = "/home/timetracker/app/data"
|
||||
LOAD_SAMPLE_DATA = "true"
|
||||
CREATE_DEFAULT_SUPERUSER = "true"
|
||||
|
||||
[http_service]
|
||||
internal_port = 8000
|
||||
force_https = true
|
||||
auto_stop_machines = "stop"
|
||||
auto_start_machines = true
|
||||
min_machines_running = 0
|
||||
|
||||
[[vm]]
|
||||
size = "shared-cpu-1x"
|
||||
memory = "512mb"
|
||||
+61
-3
@@ -2,15 +2,18 @@ from datetime import date, datetime
|
||||
from typing import List
|
||||
|
||||
from django.contrib import messages
|
||||
from django.db.models import Q
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.timezone import now as django_timezone_now
|
||||
from ninja import Field, ModelSchema, NinjaAPI, Router, Schema, Status
|
||||
|
||||
from games.models import Game, PlayEvent, Session
|
||||
from games.models import Device, Game, Platform, PlayEvent, Session
|
||||
|
||||
api = NinjaAPI()
|
||||
playevent_router = Router()
|
||||
game_router = Router()
|
||||
device_router = Router()
|
||||
platform_router = Router()
|
||||
|
||||
NOW_FACTORY = django_timezone_now
|
||||
|
||||
@@ -50,6 +53,33 @@ class PlayEventOut(Schema):
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class GameOption(Schema): # mirrors SearchSelectOption
|
||||
value: int
|
||||
label: str
|
||||
data: dict
|
||||
|
||||
|
||||
class StringOption(Schema): # SearchSelectOption with a string value (e.g. group names)
|
||||
value: str
|
||||
label: str
|
||||
data: dict
|
||||
|
||||
|
||||
@game_router.get("/search", response=list[GameOption])
|
||||
def search_games(request, q: str = "", limit: int = 10):
|
||||
qs = Game.objects.select_related("platform").order_by("sort_name")
|
||||
if q:
|
||||
qs = qs.filter(Q(name__icontains=q) | Q(sort_name__icontains=q))
|
||||
return [
|
||||
{
|
||||
"value": g.id,
|
||||
"label": g.search_label,
|
||||
"data": {"platform": g.platform_id or ""},
|
||||
}
|
||||
for g in qs[:limit]
|
||||
]
|
||||
|
||||
|
||||
@game_router.patch("/{game_id}/status", response={204: None})
|
||||
def partial_update_game(request, game_id: int, payload: GameStatusUpdate):
|
||||
game = get_object_or_404(Game, id=game_id)
|
||||
@@ -93,8 +123,35 @@ def delete_playevent(request, playevent_id: int):
|
||||
return Status(204, None)
|
||||
|
||||
|
||||
@device_router.get("/search", response=list[GameOption])
|
||||
def search_devices(request, q: str = "", limit: int = 10):
|
||||
qs = Device.objects.order_by("name")
|
||||
if q:
|
||||
qs = qs.filter(name__icontains=q)
|
||||
return [{"value": d.id, "label": d.name, "data": {}} for d in qs[:limit]]
|
||||
|
||||
|
||||
@platform_router.get("/search", response=list[GameOption])
|
||||
def search_platforms(request, q: str = "", limit: int = 10):
|
||||
qs = Platform.objects.order_by("name")
|
||||
if q:
|
||||
qs = qs.filter(name__icontains=q)
|
||||
return [{"value": p.id, "label": p.name, "data": {}} for p in qs[:limit]]
|
||||
|
||||
|
||||
@platform_router.get("/groups", response=list[StringOption])
|
||||
def search_platform_groups(request, q: str = "", limit: int = 10):
|
||||
qs = Platform.objects.exclude(group="")
|
||||
if q:
|
||||
qs = qs.filter(group__icontains=q)
|
||||
groups = qs.values_list("group", flat=True).distinct().order_by("group")
|
||||
return [{"value": group, "label": group, "data": {}} for group in groups[:limit]]
|
||||
|
||||
|
||||
api.add_router("/playevent", playevent_router)
|
||||
api.add_router("/games", game_router)
|
||||
api.add_router("/devices", device_router)
|
||||
api.add_router("/platforms", platform_router)
|
||||
|
||||
session_router = Router()
|
||||
|
||||
@@ -104,7 +161,9 @@ class SessionDeviceUpdate(Schema):
|
||||
|
||||
|
||||
@session_router.patch("/{session_id}/device", response={204: None})
|
||||
def partial_update_session_device(request, session_id: int, payload: SessionDeviceUpdate):
|
||||
def partial_update_session_device(
|
||||
request, session_id: int, payload: SessionDeviceUpdate
|
||||
):
|
||||
session = get_object_or_404(Session, id=session_id)
|
||||
session.device_id = payload.device_id
|
||||
session.save()
|
||||
@@ -113,4 +172,3 @@ def partial_update_session_device(request, session_id: int, payload: SessionDevi
|
||||
|
||||
|
||||
api.add_router("/session", session_router)
|
||||
|
||||
|
||||
@@ -0,0 +1,979 @@
|
||||
"""
|
||||
Entity-specific filter types for the timetracker app.
|
||||
|
||||
Each filter class mirrors a Django model, with fields expressed as typed
|
||||
criteria from common.criteria. The to_q() method produces a Django Q object
|
||||
ready for queryset.filter().
|
||||
|
||||
Inspired by Stash's filter architecture: each entity has an OperatorFilter
|
||||
with AND/OR/NOT composition and typed criterion fields.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from django.db.models import Q
|
||||
|
||||
from common.criteria import (
|
||||
BoolCriterion,
|
||||
ChoiceCriterion,
|
||||
DateCriterion,
|
||||
FloatCriterion,
|
||||
IntCriterion,
|
||||
Modifier,
|
||||
MultiCriterion,
|
||||
OperatorFilter,
|
||||
StringCriterion,
|
||||
filter_from_json,
|
||||
)
|
||||
|
||||
# ── FindFilter (sort / pagination) ─────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class FindFilter:
|
||||
"""Sorting and pagination, separate from filtering criteria (Stash-style)."""
|
||||
|
||||
q: str | None = None # free-text search
|
||||
page: int = 1
|
||||
per_page: int = 25
|
||||
sort: str | None = None # e.g. "-created_at"
|
||||
direction: str = "desc" # asc / desc
|
||||
|
||||
|
||||
# ── GameFilter ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class GameFilter(OperatorFilter):
|
||||
"""Filter for the Game model."""
|
||||
|
||||
AND: GameFilter | None = None
|
||||
OR: GameFilter | None = None
|
||||
NOT: GameFilter | None = None
|
||||
|
||||
name: StringCriterion | None = None
|
||||
sort_name: StringCriterion | None = None
|
||||
year_released: IntCriterion | None = None
|
||||
original_year_released: IntCriterion | None = None
|
||||
wikidata: StringCriterion | None = None
|
||||
platform: ChoiceCriterion | None = None # selectable filter widget
|
||||
platform_group: MultiCriterion | None = None # platform__group__in
|
||||
status: ChoiceCriterion | None = None # selectable filter widget
|
||||
mastered: BoolCriterion | None = None
|
||||
playtime_hours: IntCriterion | None = None # converted to timedelta on to_q()
|
||||
created_at: StringCriterion | None = None # date string
|
||||
updated_at: StringCriterion | None = None # date string
|
||||
|
||||
session_count: IntCriterion | None = None
|
||||
session_average: IntCriterion | None = None # average in hours
|
||||
purchase_count: IntCriterion | None = None # distinct purchases per game
|
||||
playevent_count: IntCriterion | None = None # playevents per game
|
||||
|
||||
# Aggregate session durations (hours), summed across the game's sessions
|
||||
manual_playtime_hours: IntCriterion | None = None
|
||||
calculated_playtime_hours: IntCriterion | None = None
|
||||
|
||||
# Cross-entity: any session played on these devices / matching these flags
|
||||
device: MultiCriterion | None = None # game has session on any of these devices
|
||||
session_emulated: BoolCriterion | None = None # game has emulated session
|
||||
|
||||
# Cross-entity: matches against the game's purchases
|
||||
purchase_refunded: BoolCriterion | None = None # game has refunded purchase
|
||||
purchase_infinite: BoolCriterion | None = None # game has infinite purchase
|
||||
purchase_price_total: FloatCriterion | None = None # sum of converted prices
|
||||
purchase_price_any: FloatCriterion | None = None # any single purchase in range
|
||||
purchase_type: ChoiceCriterion | None = None # game has purchase of type
|
||||
purchase_ownership_type: ChoiceCriterion | None = None # by ownership
|
||||
|
||||
# Cross-entity: substring match against the game's playevent notes
|
||||
playevent_note: StringCriterion | None = None
|
||||
|
||||
# Free-text search (combines name + sort_name + platform name)
|
||||
search: StringCriterion | None = None
|
||||
|
||||
# Cross-entity filters
|
||||
session_filter: SessionFilter | None = None
|
||||
purchase_filter: PurchaseFilter | None = None
|
||||
playevent_filter: PlayEventFilter | None = None
|
||||
platform_filter: PlatformFilter | None = None
|
||||
|
||||
def to_q(self) -> Q:
|
||||
q = Q()
|
||||
|
||||
# ── individual criteria ──
|
||||
if self.name is not None:
|
||||
q &= self.name.to_q("name")
|
||||
if self.sort_name is not None:
|
||||
q &= self.sort_name.to_q("sort_name")
|
||||
if self.year_released is not None:
|
||||
q &= self.year_released.to_q("year_released")
|
||||
if self.original_year_released is not None:
|
||||
q &= self.original_year_released.to_q("original_year_released")
|
||||
if self.wikidata is not None:
|
||||
q &= self.wikidata.to_q("wikidata")
|
||||
if self.platform is not None:
|
||||
q &= self.platform.to_q("platform_id")
|
||||
if self.status is not None:
|
||||
q &= self.status.to_q("status")
|
||||
if self.mastered is not None:
|
||||
q &= self.mastered.to_q("mastered")
|
||||
if self.playtime_hours is not None:
|
||||
q &= self._playtime_to_q(self.playtime_hours)
|
||||
if self.created_at is not None:
|
||||
q &= self.created_at.to_q("created_at")
|
||||
if self.updated_at is not None:
|
||||
q &= self.updated_at.to_q("updated_at")
|
||||
|
||||
if self.platform_group is not None:
|
||||
q &= self.platform_group.to_q("platform__group")
|
||||
|
||||
if self.session_count is not None:
|
||||
from django.db.models import Count
|
||||
|
||||
from games.models import Game
|
||||
|
||||
matching_ids = (
|
||||
Game.objects.annotate(s_count=Count("sessions", distinct=True))
|
||||
.filter(self.session_count.to_q("s_count"))
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
if self.session_average is not None:
|
||||
from django.db.models import Avg
|
||||
|
||||
from games.models import Game
|
||||
|
||||
matching_ids = (
|
||||
Game.objects.annotate(s_avg=Avg("sessions__duration_total"))
|
||||
.filter(self._playtime_to_q_for_field(self.session_average, "s_avg"))
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
if self.purchase_count is not None:
|
||||
from django.db.models import Count
|
||||
|
||||
from games.models import Game
|
||||
|
||||
matching_ids = (
|
||||
Game.objects.annotate(p_count=Count("purchases", distinct=True))
|
||||
.filter(self.purchase_count.to_q("p_count"))
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
if self.playevent_count is not None:
|
||||
from django.db.models import Count
|
||||
|
||||
from games.models import Game
|
||||
|
||||
matching_ids = (
|
||||
Game.objects.annotate(pe_count=Count("playevents", distinct=True))
|
||||
.filter(self.playevent_count.to_q("pe_count"))
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
if self.manual_playtime_hours is not None:
|
||||
from django.db.models import Sum
|
||||
|
||||
from games.models import Game
|
||||
|
||||
matching_ids = (
|
||||
Game.objects.annotate(s_manual=Sum("sessions__duration_manual"))
|
||||
.filter(
|
||||
self._playtime_to_q_for_field(
|
||||
self.manual_playtime_hours, "s_manual"
|
||||
)
|
||||
)
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
if self.calculated_playtime_hours is not None:
|
||||
from django.db.models import Sum
|
||||
|
||||
from games.models import Game
|
||||
|
||||
matching_ids = (
|
||||
Game.objects.annotate(s_calc=Sum("sessions__duration_calculated"))
|
||||
.filter(
|
||||
self._playtime_to_q_for_field(
|
||||
self.calculated_playtime_hours, "s_calc"
|
||||
)
|
||||
)
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
if self.device is not None:
|
||||
from games.models import Session
|
||||
|
||||
session_q = self.device.to_q("device_id")
|
||||
matching_ids = Session.objects.filter(session_q).values_list(
|
||||
"game_id", flat=True
|
||||
)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
if self.session_emulated is not None:
|
||||
from games.models import Session
|
||||
|
||||
emulated_ids = Session.objects.filter(
|
||||
emulated=self.session_emulated.value
|
||||
).values_list("game_id", flat=True)
|
||||
if self.session_emulated.value:
|
||||
q &= Q(id__in=emulated_ids)
|
||||
else:
|
||||
emulated_true_ids = Session.objects.filter(emulated=True).values_list(
|
||||
"game_id", flat=True
|
||||
)
|
||||
q &= ~Q(id__in=emulated_true_ids)
|
||||
|
||||
if self.purchase_refunded is not None:
|
||||
from games.models import Purchase
|
||||
|
||||
refunded_ids = Purchase.objects.filter(
|
||||
date_refunded__isnull=False
|
||||
).values_list("games__id", flat=True)
|
||||
if self.purchase_refunded.value:
|
||||
q &= Q(id__in=refunded_ids)
|
||||
else:
|
||||
q &= ~Q(id__in=refunded_ids)
|
||||
|
||||
if self.purchase_infinite is not None:
|
||||
from games.models import Purchase
|
||||
|
||||
infinite_ids = Purchase.objects.filter(infinite=True).values_list(
|
||||
"games__id", flat=True
|
||||
)
|
||||
if self.purchase_infinite.value:
|
||||
q &= Q(id__in=infinite_ids)
|
||||
else:
|
||||
q &= ~Q(id__in=infinite_ids)
|
||||
|
||||
if self.purchase_price_total is not None:
|
||||
from django.db.models import Sum
|
||||
|
||||
from games.models import Game
|
||||
|
||||
matching_ids = (
|
||||
Game.objects.annotate(p_total=Sum("purchases__converted_price"))
|
||||
.filter(self.purchase_price_total.to_q("p_total"))
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
if self.purchase_price_any is not None:
|
||||
from games.models import Purchase
|
||||
|
||||
price_q = self.purchase_price_any.to_q("converted_price")
|
||||
matching_ids = Purchase.objects.filter(price_q).values_list(
|
||||
"games__id", flat=True
|
||||
)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
if self.purchase_type is not None:
|
||||
from games.models import Purchase
|
||||
|
||||
type_q = self.purchase_type.to_q("type")
|
||||
matching_ids = Purchase.objects.filter(type_q).values_list(
|
||||
"games__id", flat=True
|
||||
)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
if self.purchase_ownership_type is not None:
|
||||
from games.models import Purchase
|
||||
|
||||
ownership_q = self.purchase_ownership_type.to_q("ownership_type")
|
||||
matching_ids = Purchase.objects.filter(ownership_q).values_list(
|
||||
"games__id", flat=True
|
||||
)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
if self.playevent_note is not None:
|
||||
q &= self._playevent_note_to_q(self.playevent_note)
|
||||
|
||||
# ── free-text search (OR across multiple fields) ──
|
||||
if self.search is not None and self.search.value:
|
||||
search_q = (
|
||||
Q(name__icontains=self.search.value)
|
||||
| Q(sort_name__icontains=self.search.value)
|
||||
| Q(platform__name__icontains=self.search.value)
|
||||
)
|
||||
if self.search.modifier == Modifier.EXCLUDES:
|
||||
search_q = ~search_q
|
||||
q &= search_q
|
||||
|
||||
# Cross-entity filters
|
||||
if self.session_filter is not None:
|
||||
from games.models import Session
|
||||
|
||||
session_q = self.session_filter.to_q()
|
||||
matching_ids = Session.objects.filter(session_q).values_list(
|
||||
"game_id", flat=True
|
||||
)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
if self.purchase_filter is not None:
|
||||
from games.models import Purchase
|
||||
|
||||
purchase_q = self.purchase_filter.to_q()
|
||||
matching_ids = Purchase.objects.filter(purchase_q).values_list(
|
||||
"games__id", flat=True
|
||||
)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
if self.playevent_filter is not None:
|
||||
from games.models import PlayEvent
|
||||
|
||||
playevent_q = self.playevent_filter.to_q()
|
||||
matching_ids = PlayEvent.objects.filter(playevent_q).values_list(
|
||||
"game_id", flat=True
|
||||
)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
if self.platform_filter is not None:
|
||||
from games.models import Platform
|
||||
|
||||
platform_q = self.platform_filter.to_q()
|
||||
matching_ids = Platform.objects.filter(platform_q).values_list(
|
||||
"id", flat=True
|
||||
)
|
||||
q &= Q(platform_id__in=matching_ids)
|
||||
|
||||
# ── AND / OR / NOT sub-filters ──
|
||||
sub = self.sub_filter()
|
||||
if sub is not None:
|
||||
if self.AND is not None:
|
||||
q &= sub.to_q()
|
||||
elif self.OR is not None:
|
||||
q |= sub.to_q()
|
||||
elif self.NOT is not None:
|
||||
q &= ~sub.to_q()
|
||||
|
||||
return q
|
||||
|
||||
@staticmethod
|
||||
def _playtime_to_q(c: IntCriterion) -> Q:
|
||||
return GameFilter._playtime_to_q_for_field(c, "playtime")
|
||||
|
||||
@staticmethod
|
||||
def _playtime_to_q_for_field(c: IntCriterion, field: str) -> Q:
|
||||
"""Convert hours-based criterion to a DurationField Q object.
|
||||
|
||||
Django stores DurationField as microseconds in SQLite, so we convert
|
||||
hours → timedelta(microseconds=X) and use the appropriate lookups.
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
from common.criteria import Modifier
|
||||
|
||||
m = c.modifier
|
||||
td_val = timedelta(hours=c.value)
|
||||
|
||||
if m == Modifier.EQUALS:
|
||||
return Q(
|
||||
**{
|
||||
f"{field}__gte": td_val,
|
||||
f"{field}__lt": timedelta(hours=c.value + 1),
|
||||
}
|
||||
)
|
||||
if m == Modifier.NOT_EQUALS:
|
||||
return ~Q(
|
||||
**{
|
||||
f"{field}__gte": td_val,
|
||||
f"{field}__lt": timedelta(hours=c.value + 1),
|
||||
}
|
||||
)
|
||||
if m == Modifier.GREATER_THAN:
|
||||
return Q(**{f"{field}__gt": td_val})
|
||||
if m == Modifier.LESS_THAN:
|
||||
return Q(**{f"{field}__lt": td_val})
|
||||
if m == Modifier.BETWEEN and c.value2 is not None:
|
||||
lo = timedelta(hours=min(c.value, c.value2))
|
||||
hi = timedelta(hours=max(c.value, c.value2))
|
||||
return Q(**{f"{field}__gte": lo, f"{field}__lte": hi})
|
||||
if m == Modifier.NOT_BETWEEN and c.value2 is not None:
|
||||
lo = timedelta(hours=min(c.value, c.value2))
|
||||
hi = timedelta(hours=max(c.value, c.value2))
|
||||
return Q(**{f"{field}__lt": lo}) | Q(**{f"{field}__gt": hi})
|
||||
if m == Modifier.IS_NULL:
|
||||
return Q(**{f"{field}": timedelta(0)})
|
||||
if m == Modifier.NOT_NULL:
|
||||
return ~Q(**{f"{field}": timedelta(0)})
|
||||
return Q()
|
||||
|
||||
@staticmethod
|
||||
def _playevent_note_to_q(criterion: StringCriterion) -> Q:
|
||||
"""Match games by substring / regex / null against their playevents' notes."""
|
||||
from games.models import PlayEvent
|
||||
|
||||
event_q = criterion.to_q("note")
|
||||
matching_ids = PlayEvent.objects.filter(event_q).values_list(
|
||||
"game_id", flat=True
|
||||
)
|
||||
return Q(id__in=matching_ids)
|
||||
|
||||
|
||||
# ── SessionFilter ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionFilter(OperatorFilter):
|
||||
"""Filter for the Session model."""
|
||||
|
||||
AND: SessionFilter | None = None
|
||||
OR: SessionFilter | None = None
|
||||
NOT: SessionFilter | None = None
|
||||
|
||||
game: MultiCriterion | None = None # filters on game_id
|
||||
device: MultiCriterion | None = None # filters on device_id
|
||||
emulated: BoolCriterion | None = None
|
||||
note: StringCriterion | None = None
|
||||
duration_hours: IntCriterion | None = None # on duration_total (legacy alias)
|
||||
duration_total_hours: IntCriterion | None = None
|
||||
duration_manual_hours: IntCriterion | None = None
|
||||
duration_calculated_hours: IntCriterion | None = None
|
||||
is_active: BoolCriterion | None = None # timestamp_end IS NULL
|
||||
timestamp_start: StringCriterion | None = None # date string
|
||||
timestamp_end: StringCriterion | None = None # date string
|
||||
is_manual: BoolCriterion | None = None # duration_manual > 0
|
||||
created_at: StringCriterion | None = None
|
||||
|
||||
# Free-text search
|
||||
search: StringCriterion | None = None
|
||||
|
||||
# Cross-entity: sessions for games matching these criteria
|
||||
game_filter: GameFilter | None = None
|
||||
|
||||
# Cross-entity: sessions for devices matching these criteria
|
||||
device_filter: DeviceFilter | None = None
|
||||
|
||||
def _duration_to_q(self, c: IntCriterion, field: str) -> Q:
|
||||
from datetime import timedelta
|
||||
|
||||
q = Q()
|
||||
td_val = timedelta(hours=c.value)
|
||||
m = c.modifier
|
||||
if m == Modifier.EQUALS:
|
||||
q &= Q(
|
||||
**{
|
||||
f"{field}__gte": td_val,
|
||||
f"{field}__lt": timedelta(hours=c.value + 1),
|
||||
}
|
||||
)
|
||||
elif m == Modifier.NOT_EQUALS:
|
||||
q &= ~Q(
|
||||
**{
|
||||
f"{field}__gte": td_val,
|
||||
f"{field}__lt": timedelta(hours=c.value + 1),
|
||||
}
|
||||
)
|
||||
elif m == Modifier.GREATER_THAN:
|
||||
q &= Q(**{f"{field}__gt": td_val})
|
||||
elif m == Modifier.LESS_THAN:
|
||||
q &= Q(**{f"{field}__lt": td_val})
|
||||
elif m == Modifier.BETWEEN and c.value2 is not None:
|
||||
lo = timedelta(hours=min(c.value, c.value2))
|
||||
hi = timedelta(hours=max(c.value, c.value2))
|
||||
q &= Q(**{f"{field}__gte": lo, f"{field}__lte": hi})
|
||||
elif m == Modifier.NOT_BETWEEN and c.value2 is not None:
|
||||
lo = timedelta(hours=min(c.value, c.value2))
|
||||
hi = timedelta(hours=max(c.value, c.value2))
|
||||
q &= Q(**{f"{field}__lt": lo}) | Q(**{f"{field}__gt": hi})
|
||||
elif m == Modifier.IS_NULL:
|
||||
q &= Q(**{f"{field}": timedelta(0)})
|
||||
elif m == Modifier.NOT_NULL:
|
||||
q &= ~Q(**{f"{field}": timedelta(0)})
|
||||
return q
|
||||
|
||||
def to_q(self) -> Q:
|
||||
from datetime import timedelta
|
||||
|
||||
q = Q()
|
||||
|
||||
if self.game is not None:
|
||||
q &= self.game.to_q("game_id")
|
||||
if self.device is not None:
|
||||
q &= self.device.to_q("device_id")
|
||||
if self.emulated is not None:
|
||||
q &= self.emulated.to_q("emulated")
|
||||
if self.note is not None:
|
||||
q &= self.note.to_q("note")
|
||||
if self.duration_hours is not None:
|
||||
q &= self._duration_to_q(self.duration_hours, "duration_total")
|
||||
if self.duration_total_hours is not None:
|
||||
q &= self._duration_to_q(self.duration_total_hours, "duration_total")
|
||||
if self.duration_manual_hours is not None:
|
||||
q &= self._duration_to_q(self.duration_manual_hours, "duration_manual")
|
||||
if self.duration_calculated_hours is not None:
|
||||
q &= self._duration_to_q(
|
||||
self.duration_calculated_hours, "duration_calculated"
|
||||
)
|
||||
if self.is_active is not None:
|
||||
if self.is_active.value:
|
||||
q &= Q(timestamp_end__isnull=True)
|
||||
else:
|
||||
q &= Q(timestamp_end__isnull=False)
|
||||
if self.timestamp_start is not None:
|
||||
q &= self.timestamp_start.to_q("timestamp_start")
|
||||
if self.timestamp_end is not None:
|
||||
q &= self.timestamp_end.to_q("timestamp_end")
|
||||
if self.is_manual is not None:
|
||||
if self.is_manual.value:
|
||||
q &= ~Q(duration_manual=timedelta(0))
|
||||
else:
|
||||
q &= Q(duration_manual=timedelta(0))
|
||||
if self.created_at is not None:
|
||||
q &= self.created_at.to_q("created_at")
|
||||
|
||||
# Free-text search
|
||||
if self.search is not None and self.search.value:
|
||||
search_q = (
|
||||
Q(game__name__icontains=self.search.value)
|
||||
| Q(game__platform__name__icontains=self.search.value)
|
||||
| Q(device__name__icontains=self.search.value)
|
||||
| Q(device__type__icontains=self.search.value)
|
||||
)
|
||||
if self.search.modifier == Modifier.EXCLUDES:
|
||||
search_q = ~search_q
|
||||
q &= search_q
|
||||
|
||||
# Cross-entity filter: sessions for games matching GameFilter
|
||||
if self.game_filter is not None:
|
||||
from games.models import Game
|
||||
|
||||
game_q = self.game_filter.to_q()
|
||||
matching_ids = Game.objects.filter(game_q).values_list("id", flat=True)
|
||||
q &= Q(game_id__in=matching_ids)
|
||||
|
||||
# Cross-entity filter: sessions for devices matching DeviceFilter
|
||||
if self.device_filter is not None:
|
||||
from games.models import Device
|
||||
|
||||
device_q = self.device_filter.to_q()
|
||||
matching_ids = Device.objects.filter(device_q).values_list("id", flat=True)
|
||||
q &= Q(device_id__in=matching_ids)
|
||||
|
||||
# AND / OR / NOT
|
||||
sub = self.sub_filter()
|
||||
if sub is not None:
|
||||
if self.AND is not None:
|
||||
q &= sub.to_q()
|
||||
elif self.OR is not None:
|
||||
q |= sub.to_q()
|
||||
elif self.NOT is not None:
|
||||
q &= ~sub.to_q()
|
||||
|
||||
return q
|
||||
|
||||
|
||||
# ── PurchaseFilter ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class PurchaseFilter(OperatorFilter):
|
||||
"""Filter for the Purchase model."""
|
||||
|
||||
AND: PurchaseFilter | None = None
|
||||
OR: PurchaseFilter | None = None
|
||||
NOT: PurchaseFilter | None = None
|
||||
|
||||
name: StringCriterion | None = None
|
||||
platform: ChoiceCriterion | None = None # platform_id
|
||||
games: ChoiceCriterion | None = None # games (M2M IDs)
|
||||
date_purchased: DateCriterion | None = None
|
||||
date_refunded: DateCriterion | None = None
|
||||
is_refunded: BoolCriterion | None = None # date_refunded IS NOT NULL
|
||||
price: FloatCriterion | None = None # on price field
|
||||
converted_price: FloatCriterion | None = None
|
||||
price_currency: StringCriterion | None = None
|
||||
num_purchases: IntCriterion | None = None
|
||||
ownership_type: ChoiceCriterion | None = None # ph/di/du/re/bo/tr/de/pi
|
||||
type: ChoiceCriterion | None = None # game/dlc/season_pass/battle_pass
|
||||
created_at: StringCriterion | None = None
|
||||
updated_at: StringCriterion | None = None
|
||||
|
||||
infinite: BoolCriterion | None = None
|
||||
needs_price_update: BoolCriterion | None = None
|
||||
converted_currency: StringCriterion | None = None
|
||||
|
||||
# Free-text search
|
||||
search: StringCriterion | None = None
|
||||
|
||||
# Cross-entity: purchases for games matching these criteria
|
||||
game_filter: GameFilter | None = None
|
||||
|
||||
# Cross-entity: purchases for platforms matching these criteria
|
||||
platform_filter: PlatformFilter | None = None
|
||||
|
||||
def to_q(self) -> Q:
|
||||
q = Q()
|
||||
|
||||
if self.name is not None:
|
||||
q &= self.name.to_q("name")
|
||||
if self.platform is not None:
|
||||
q &= self.platform.to_q("platform_id")
|
||||
if self.games is not None:
|
||||
q &= self._games_to_q(self.games)
|
||||
if self.date_purchased is not None:
|
||||
q &= self.date_purchased.to_q("date_purchased")
|
||||
if self.date_refunded is not None:
|
||||
q &= self.date_refunded.to_q("date_refunded")
|
||||
if self.is_refunded is not None:
|
||||
q &= Q(date_refunded__isnull=not self.is_refunded.value)
|
||||
if self.price is not None:
|
||||
q &= self.price.to_q("price")
|
||||
if self.converted_price is not None:
|
||||
q &= self.converted_price.to_q("converted_price")
|
||||
if self.price_currency is not None:
|
||||
q &= self.price_currency.to_q("price_currency")
|
||||
if self.num_purchases is not None:
|
||||
q &= self.num_purchases.to_q("num_purchases")
|
||||
if self.ownership_type is not None:
|
||||
q &= self.ownership_type.to_q("ownership_type")
|
||||
if self.type is not None:
|
||||
q &= self.type.to_q("type")
|
||||
if self.created_at is not None:
|
||||
q &= self.created_at.to_q("created_at")
|
||||
if self.updated_at is not None:
|
||||
q &= self.updated_at.to_q("updated_at")
|
||||
if self.infinite is not None:
|
||||
q &= self.infinite.to_q("infinite")
|
||||
if self.needs_price_update is not None:
|
||||
q &= self.needs_price_update.to_q("needs_price_update")
|
||||
if self.converted_currency is not None:
|
||||
q &= self.converted_currency.to_q("converted_currency")
|
||||
|
||||
# Free-text search
|
||||
if self.search is not None and self.search.value:
|
||||
search_q = (
|
||||
Q(name__icontains=self.search.value)
|
||||
| Q(games__name__icontains=self.search.value)
|
||||
| Q(platform__name__icontains=self.search.value)
|
||||
)
|
||||
if self.search.modifier == Modifier.EXCLUDES:
|
||||
search_q = ~search_q
|
||||
q &= search_q
|
||||
|
||||
# Cross-entity filter
|
||||
if self.game_filter is not None:
|
||||
from games.models import Game
|
||||
|
||||
game_q = self.game_filter.to_q()
|
||||
matching_ids = Game.objects.filter(game_q).values_list("id", flat=True)
|
||||
q &= Q(games__id__in=matching_ids)
|
||||
|
||||
# Cross-entity platform filter
|
||||
if self.platform_filter is not None:
|
||||
from games.models import Platform
|
||||
|
||||
platform_q = self.platform_filter.to_q()
|
||||
matching_ids = Platform.objects.filter(platform_q).values_list(
|
||||
"id", flat=True
|
||||
)
|
||||
q &= Q(platform_id__in=matching_ids)
|
||||
|
||||
sub = self.sub_filter()
|
||||
if sub is not None:
|
||||
if self.AND is not None:
|
||||
q &= sub.to_q()
|
||||
elif self.OR is not None:
|
||||
q |= sub.to_q()
|
||||
elif self.NOT is not None:
|
||||
q &= ~sub.to_q()
|
||||
|
||||
return q
|
||||
|
||||
@staticmethod
|
||||
def _games_to_q(criterion: ChoiceCriterion) -> Q:
|
||||
"""Build the Q for the many-to-many ``games`` field.
|
||||
|
||||
``INCLUDES_ALL`` ("related to every selected game") and
|
||||
``INCLUDES_ONLY`` ("related to exactly these, nothing else") cannot be
|
||||
a single ``.filter(Q(games=a) & Q(games=b))`` — that collapses to one
|
||||
join and would require a single link row to be both games. Instead
|
||||
chain a filter per game so each gets its own join, then match by
|
||||
``pk``. ``INCLUDES_ONLY`` additionally excludes purchases that have
|
||||
any game outside the specified set.
|
||||
|
||||
``INCLUDES`` (plain "any") also uses a subquery instead of a raw
|
||||
``games__in`` join because a single purchase linked to *n* of the
|
||||
given games would appear *n* times in the result set (M2M join
|
||||
duplicates).
|
||||
|
||||
The orthogonal ``excludes`` channel is applied as a negative,
|
||||
consistent with every other modifier. All other modifiers delegate
|
||||
to the criterion.
|
||||
"""
|
||||
# Empty value means no constraint; still apply excludes if any
|
||||
if not criterion.value:
|
||||
if criterion.excludes:
|
||||
return ~Q(games__in=criterion.excludes)
|
||||
return Q()
|
||||
|
||||
from games.models import Game, Purchase
|
||||
|
||||
if criterion.modifier in (Modifier.INCLUDES_ALL, Modifier.INCLUDES_ONLY):
|
||||
subquery = Purchase.objects.all()
|
||||
for game_id in criterion.value:
|
||||
subquery = subquery.filter(games=game_id)
|
||||
|
||||
if criterion.modifier == Modifier.INCLUDES_ONLY:
|
||||
extra_ids = Game.objects.exclude(id__in=criterion.value).values_list(
|
||||
"id", flat=True
|
||||
)
|
||||
if extra_ids:
|
||||
subquery = subquery.exclude(games__in=extra_ids)
|
||||
|
||||
q = Q(pk__in=subquery.values("pk"))
|
||||
if criterion.excludes:
|
||||
q &= ~Q(games__in=criterion.excludes)
|
||||
return q
|
||||
|
||||
if criterion.modifier == Modifier.INCLUDES:
|
||||
# Use subquery to avoid duplicate rows from M2M join
|
||||
subquery = Purchase.objects.filter(games__in=criterion.value)
|
||||
q = Q(pk__in=subquery.values("pk"))
|
||||
if criterion.excludes:
|
||||
q &= ~Q(games__in=criterion.excludes)
|
||||
return q
|
||||
|
||||
return criterion.to_q("games")
|
||||
|
||||
|
||||
# ── DeviceFilter ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceFilter(OperatorFilter):
|
||||
"""Filter for the Device model."""
|
||||
|
||||
AND: DeviceFilter | None = None
|
||||
OR: DeviceFilter | None = None
|
||||
NOT: DeviceFilter | None = None
|
||||
|
||||
name: StringCriterion | None = None
|
||||
type: ChoiceCriterion | None = None
|
||||
created_at: StringCriterion | None = None
|
||||
|
||||
# Free-text search
|
||||
search: StringCriterion | None = None
|
||||
|
||||
# Cross-entity: Devices that have sessions matching these criteria
|
||||
session_filter: SessionFilter | None = None
|
||||
|
||||
def to_q(self) -> Q:
|
||||
q = Q()
|
||||
|
||||
if self.name is not None:
|
||||
q &= self.name.to_q("name")
|
||||
if self.type is not None:
|
||||
q &= self.type.to_q("type")
|
||||
if self.created_at is not None:
|
||||
q &= self.created_at.to_q("created_at")
|
||||
|
||||
# Free-text search
|
||||
if self.search is not None and self.search.value:
|
||||
search_q = Q(name__icontains=self.search.value) | Q(
|
||||
type__icontains=self.search.value
|
||||
)
|
||||
if self.search.modifier == Modifier.EXCLUDES:
|
||||
search_q = ~search_q
|
||||
q &= search_q
|
||||
|
||||
# Cross-entity filter: session_filter
|
||||
if self.session_filter is not None:
|
||||
from games.models import Session
|
||||
|
||||
session_q = self.session_filter.to_q()
|
||||
matching_ids = Session.objects.filter(session_q).values_list(
|
||||
"device_id", flat=True
|
||||
)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
sub = self.sub_filter()
|
||||
if sub is not None:
|
||||
if self.AND is not None:
|
||||
q &= sub.to_q()
|
||||
elif self.OR is not None:
|
||||
q |= sub.to_q()
|
||||
elif self.NOT is not None:
|
||||
q &= ~sub.to_q()
|
||||
|
||||
return q
|
||||
|
||||
|
||||
# ── PlatformFilter ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlatformFilter(OperatorFilter):
|
||||
"""Filter for the Platform model."""
|
||||
|
||||
AND: PlatformFilter | None = None
|
||||
OR: PlatformFilter | None = None
|
||||
NOT: PlatformFilter | None = None
|
||||
|
||||
name: StringCriterion | None = None
|
||||
group: StringCriterion | None = None
|
||||
icon: StringCriterion | None = None
|
||||
created_at: StringCriterion | None = None
|
||||
|
||||
# Free-text search
|
||||
search: StringCriterion | None = None
|
||||
|
||||
# Cross-entity
|
||||
game_filter: GameFilter | None = None
|
||||
purchase_filter: PurchaseFilter | None = None
|
||||
|
||||
def to_q(self) -> Q:
|
||||
q = Q()
|
||||
|
||||
if self.name is not None:
|
||||
q &= self.name.to_q("name")
|
||||
if self.group is not None:
|
||||
q &= self.group.to_q("group")
|
||||
if self.icon is not None:
|
||||
q &= self.icon.to_q("icon")
|
||||
if self.created_at is not None:
|
||||
q &= self.created_at.to_q("created_at")
|
||||
|
||||
# Free-text search
|
||||
if self.search is not None and self.search.value:
|
||||
search_q = Q(name__icontains=self.search.value) | Q(
|
||||
group__icontains=self.search.value
|
||||
)
|
||||
if self.search.modifier == Modifier.EXCLUDES:
|
||||
search_q = ~search_q
|
||||
q &= search_q
|
||||
|
||||
# Cross-entity filter: game_filter
|
||||
if self.game_filter is not None:
|
||||
from games.models import Game
|
||||
|
||||
game_q = self.game_filter.to_q()
|
||||
matching_ids = Game.objects.filter(game_q).values_list(
|
||||
"platform_id", flat=True
|
||||
)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
# Cross-entity filter: purchase_filter
|
||||
if self.purchase_filter is not None:
|
||||
from games.models import Purchase
|
||||
|
||||
purchase_q = self.purchase_filter.to_q()
|
||||
matching_ids = Purchase.objects.filter(purchase_q).values_list(
|
||||
"platform_id", flat=True
|
||||
)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
sub = self.sub_filter()
|
||||
if sub is not None:
|
||||
if self.AND is not None:
|
||||
q &= sub.to_q()
|
||||
elif self.OR is not None:
|
||||
q |= sub.to_q()
|
||||
elif self.NOT is not None:
|
||||
q &= ~sub.to_q()
|
||||
|
||||
return q
|
||||
|
||||
|
||||
# ── PlayEventFilter ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlayEventFilter(OperatorFilter):
|
||||
"""Filter for the PlayEvent model."""
|
||||
|
||||
AND: PlayEventFilter | None = None
|
||||
OR: PlayEventFilter | None = None
|
||||
NOT: PlayEventFilter | None = None
|
||||
|
||||
game: MultiCriterion | None = None # filters on game_id
|
||||
started: StringCriterion | None = None # date string
|
||||
ended: StringCriterion | None = None # date string
|
||||
days_to_finish: IntCriterion | None = None
|
||||
note: StringCriterion | None = None
|
||||
created_at: StringCriterion | None = None
|
||||
|
||||
# Free-text search
|
||||
search: StringCriterion | None = None
|
||||
|
||||
# Cross-entity: PlayEvents for games matching these criteria
|
||||
game_filter: GameFilter | None = None
|
||||
|
||||
def to_q(self) -> Q:
|
||||
q = Q()
|
||||
|
||||
if self.game is not None:
|
||||
q &= self.game.to_q("game_id")
|
||||
if self.started is not None:
|
||||
q &= self.started.to_q("started")
|
||||
if self.ended is not None:
|
||||
q &= self.ended.to_q("ended")
|
||||
if self.days_to_finish is not None:
|
||||
q &= self.days_to_finish.to_q("days_to_finish")
|
||||
if self.note is not None:
|
||||
q &= self.note.to_q("note")
|
||||
if self.created_at is not None:
|
||||
q &= self.created_at.to_q("created_at")
|
||||
|
||||
# Free-text search
|
||||
if self.search is not None and self.search.value:
|
||||
search_q = Q(game__name__icontains=self.search.value) | Q(
|
||||
note__icontains=self.search.value
|
||||
)
|
||||
if self.search.modifier == Modifier.EXCLUDES:
|
||||
search_q = ~search_q
|
||||
q &= search_q
|
||||
|
||||
# Cross-entity filter: game_filter
|
||||
if self.game_filter is not None:
|
||||
from games.models import Game
|
||||
|
||||
game_q = self.game_filter.to_q()
|
||||
matching_ids = Game.objects.filter(game_q).values_list("id", flat=True)
|
||||
q &= Q(game_id__in=matching_ids)
|
||||
|
||||
sub = self.sub_filter()
|
||||
if sub is not None:
|
||||
if self.AND is not None:
|
||||
q &= sub.to_q()
|
||||
elif self.OR is not None:
|
||||
q |= sub.to_q()
|
||||
elif self.NOT is not None:
|
||||
q &= ~sub.to_q()
|
||||
|
||||
return q
|
||||
|
||||
|
||||
# ── Convenience helpers ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def parse_game_filter(json_str: str) -> GameFilter | None:
|
||||
return filter_from_json(GameFilter, json_str)
|
||||
|
||||
|
||||
def parse_session_filter(json_str: str) -> SessionFilter | None:
|
||||
return filter_from_json(SessionFilter, json_str)
|
||||
|
||||
|
||||
def parse_purchase_filter(json_str: str) -> PurchaseFilter | None:
|
||||
return filter_from_json(PurchaseFilter, json_str)
|
||||
|
||||
|
||||
def parse_device_filter(json_str: str) -> DeviceFilter | None:
|
||||
return filter_from_json(DeviceFilter, json_str)
|
||||
|
||||
|
||||
def parse_platform_filter(json_str: str) -> PlatformFilter | None:
|
||||
return filter_from_json(PlatformFilter, json_str)
|
||||
|
||||
|
||||
def parse_playevent_filter(json_str: str) -> PlayEventFilter | None:
|
||||
return filter_from_json(PlayEventFilter, json_str)
|
||||
@@ -2,27 +2,34 @@
|
||||
fields:
|
||||
name: Steam
|
||||
group: PC
|
||||
created_at: 2024-01-01T00:00:00Z
|
||||
- model: games.Platform
|
||||
fields:
|
||||
name: Xbox Gamepass
|
||||
group: PC
|
||||
created_at: 2024-01-01T00:00:00Z
|
||||
- model: games.Platform
|
||||
fields:
|
||||
name: Epic Games Store
|
||||
group: PC
|
||||
created_at: 2024-01-01T00:00:00Z
|
||||
- model: games.Platform
|
||||
fields:
|
||||
name: Playstation 5
|
||||
group: Playstation
|
||||
created_at: 2024-01-01T00:00:00Z
|
||||
- model: games.Platform
|
||||
fields:
|
||||
name: Playstation 4
|
||||
group: Playstation
|
||||
created_at: 2024-01-01T00:00:00Z
|
||||
- model: games.Platform
|
||||
fields:
|
||||
name: Nintendo Switch
|
||||
group: Nintendo
|
||||
created_at: 2024-01-01T00:00:00Z
|
||||
- model: games.Platform
|
||||
fields:
|
||||
name: Nintendo 3DS
|
||||
group: Nintendo
|
||||
group: Nintendo
|
||||
created_at: 2024-01-01T00:00:00Z
|
||||
+55
-36
@@ -1,71 +1,90 @@
|
||||
- model: games.game
|
||||
pk: 1
|
||||
fields:
|
||||
name: Nioh 2
|
||||
wikidata: Q67482292
|
||||
- model: games.game
|
||||
pk: 2
|
||||
fields:
|
||||
name: Elden Ring
|
||||
wikidata: Q64826862
|
||||
- model: games.game
|
||||
pk: 3
|
||||
fields:
|
||||
name: Cyberpunk 2077
|
||||
wikidata: Q3182559
|
||||
- model: games.purchase
|
||||
pk: 1
|
||||
fields:
|
||||
game: 1
|
||||
platform: 1
|
||||
date_purchased: 2021-02-13
|
||||
date_refunded: null
|
||||
- model: games.purchase
|
||||
pk: 2
|
||||
fields:
|
||||
game: 2
|
||||
platform: 1
|
||||
date_purchased: 2022-02-24
|
||||
date_refunded: null
|
||||
- model: games.purchase
|
||||
pk: 3
|
||||
fields:
|
||||
game: 3
|
||||
platform: 1
|
||||
date_purchased: 2020-12-07
|
||||
date_refunded: null
|
||||
- model: games.platform
|
||||
pk: 1
|
||||
fields:
|
||||
name: Steam
|
||||
group: PC
|
||||
created_at: "2020-01-01T00:00:00Z"
|
||||
- model: games.platform
|
||||
pk: 3
|
||||
fields:
|
||||
name: Xbox Gamepass
|
||||
group: PC
|
||||
created_at: "2020-01-01T00:00:00Z"
|
||||
- model: games.platform
|
||||
pk: 4
|
||||
fields:
|
||||
name: Epic Games Store
|
||||
group: PC
|
||||
created_at: "2020-01-01T00:00:00Z"
|
||||
- model: games.platform
|
||||
pk: 5
|
||||
fields:
|
||||
name: Playstation 5
|
||||
group: Playstation
|
||||
created_at: "2020-01-01T00:00:00Z"
|
||||
- model: games.platform
|
||||
pk: 6
|
||||
fields:
|
||||
name: Playstation 4
|
||||
group: Playstation
|
||||
created_at: "2020-01-01T00:00:00Z"
|
||||
- model: games.platform
|
||||
pk: 7
|
||||
fields:
|
||||
name: Nintendo Switch
|
||||
group: Nintendo
|
||||
created_at: "2020-01-01T00:00:00Z"
|
||||
- model: games.platform
|
||||
pk: 8
|
||||
fields:
|
||||
name: Nintendo 3DS
|
||||
group: Nintendo
|
||||
created_at: "2020-01-01T00:00:00Z"
|
||||
- model: games.game
|
||||
pk: 1
|
||||
fields:
|
||||
name: Nioh 2
|
||||
wikidata: Q67482292
|
||||
created_at: "2021-02-13T00:00:00Z"
|
||||
updated_at: "2021-02-13T00:00:00Z"
|
||||
- model: games.game
|
||||
pk: 2
|
||||
fields:
|
||||
name: Elden Ring
|
||||
wikidata: Q64826862
|
||||
created_at: "2022-02-24T00:00:00Z"
|
||||
updated_at: "2022-02-24T00:00:00Z"
|
||||
- model: games.game
|
||||
pk: 3
|
||||
fields:
|
||||
name: Cyberpunk 2077
|
||||
wikidata: Q3182559
|
||||
created_at: "2020-12-07T00:00:00Z"
|
||||
updated_at: "2020-12-07T00:00:00Z"
|
||||
- model: games.purchase
|
||||
pk: 1
|
||||
fields:
|
||||
games: [1]
|
||||
platform: 1
|
||||
date_purchased: 2021-02-13
|
||||
date_refunded: null
|
||||
created_at: "2021-02-13T00:00:00Z"
|
||||
updated_at: "2021-02-13T00:00:00Z"
|
||||
- model: games.purchase
|
||||
pk: 2
|
||||
fields:
|
||||
games: [2]
|
||||
platform: 1
|
||||
date_purchased: 2022-02-24
|
||||
date_refunded: null
|
||||
created_at: "2022-02-24T00:00:00Z"
|
||||
updated_at: "2022-02-24T00:00:00Z"
|
||||
- model: games.purchase
|
||||
pk: 3
|
||||
fields:
|
||||
games: [3]
|
||||
platform: 1
|
||||
date_purchased: 2020-12-07
|
||||
date_refunded: null
|
||||
created_at: "2020-12-07T00:00:00Z"
|
||||
updated_at: "2020-12-07T00:00:00Z"
|
||||
|
||||
+208
-38
@@ -1,8 +1,15 @@
|
||||
from django import forms
|
||||
from django.db import transaction
|
||||
from django.urls import reverse
|
||||
from django.db.models import OuterRef, Subquery
|
||||
|
||||
from common.utils import safe_getattr
|
||||
from common.components import (
|
||||
DEFAULT_PREFETCH,
|
||||
SearchSelect,
|
||||
SearchSelectOption,
|
||||
render,
|
||||
searchselect_selected,
|
||||
)
|
||||
from common.components.primitives import Checkbox
|
||||
from games.models import (
|
||||
Device,
|
||||
Game,
|
||||
@@ -20,20 +27,154 @@ custom_datetime_widget = forms.DateTimeInput(
|
||||
autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"})
|
||||
|
||||
|
||||
class PrimitiveCheckboxWidget(forms.CheckboxInput):
|
||||
"""Adapts Django's CheckboxInput to use our Checkbox component."""
|
||||
|
||||
def render(self, name, value, attrs=None, renderer=None):
|
||||
final_attrs = self.build_attrs(self.attrs, attrs)
|
||||
checked = self.check_test(value)
|
||||
attributes = [
|
||||
(k, str(v))
|
||||
for k, v in final_attrs.items()
|
||||
if k not in ("type", "name", "value", "checked")
|
||||
]
|
||||
|
||||
# Django uses boolean values differently for checkboxes, we omit value if empty
|
||||
# render() returns a safe string (Django widgets must not be autoescaped).
|
||||
return render(
|
||||
Checkbox(
|
||||
name=name,
|
||||
label=None,
|
||||
checked=checked,
|
||||
value=str(value) if value else "1",
|
||||
attributes=attributes,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class PrimitiveWidgetsMixin:
|
||||
"""Automatically applies primitive custom widgets to native Django form fields."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
for field_name, field in self.fields.items():
|
||||
if isinstance(field, forms.BooleanField):
|
||||
field.widget = PrimitiveCheckboxWidget()
|
||||
# Maintain the field's explicit required status (usually False for booleans)
|
||||
|
||||
|
||||
class MultipleGameChoiceField(forms.ModelMultipleChoiceField):
|
||||
def label_from_instance(self, obj) -> str:
|
||||
return f"{obj.sort_name} ({obj.platform}, {obj.year_released})"
|
||||
return obj.search_label
|
||||
|
||||
|
||||
class SingleGameChoiceField(forms.ModelChoiceField):
|
||||
def label_from_instance(self, obj) -> str:
|
||||
return f"{obj.sort_name} ({obj.platform}, {obj.year_released})"
|
||||
return obj.search_label
|
||||
|
||||
|
||||
class SessionForm(forms.ModelForm):
|
||||
def _game_options(values) -> list[SearchSelectOption]:
|
||||
"""Resolve game ids (or instances) to SearchSelectOptions via one pk__in query."""
|
||||
return [
|
||||
{
|
||||
"value": g.id,
|
||||
"label": g.search_label,
|
||||
"data": {"platform": g.platform_id or ""},
|
||||
}
|
||||
for g in Game.objects.filter(pk__in=values).select_related("platform")
|
||||
]
|
||||
|
||||
|
||||
def _device_options(values) -> list[SearchSelectOption]:
|
||||
return [
|
||||
{"value": d.id, "label": d.name, "data": {}}
|
||||
for d in Device.objects.filter(pk__in=values)
|
||||
]
|
||||
|
||||
|
||||
def _platform_options(values) -> list[SearchSelectOption]:
|
||||
return [
|
||||
{"value": p.id, "label": p.name, "data": {}}
|
||||
for p in Platform.objects.filter(pk__in=values)
|
||||
]
|
||||
|
||||
|
||||
class SearchSelectWidget(forms.Widget):
|
||||
"""Thin Django adapter that renders a `SearchSelect()` component.
|
||||
|
||||
The only place that knows about Django/forms — the component itself stays
|
||||
reusable outside forms.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
search_url,
|
||||
options_resolver,
|
||||
multi_select=False,
|
||||
items_visible=5,
|
||||
items_scroll=10,
|
||||
prefetch=DEFAULT_PREFETCH,
|
||||
always_visible=False,
|
||||
placeholder="Search…",
|
||||
attrs=None,
|
||||
):
|
||||
super().__init__(attrs)
|
||||
self.search_url = search_url
|
||||
self.options_resolver = options_resolver
|
||||
self.multi_select = multi_select
|
||||
self.items_visible = items_visible
|
||||
self.items_scroll = items_scroll
|
||||
self.prefetch = prefetch
|
||||
self.always_visible = always_visible
|
||||
self.placeholder = placeholder
|
||||
|
||||
@staticmethod
|
||||
def _values(value) -> list:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [v for v in value if v not in (None, "")]
|
||||
return [value] if value not in (None, "") else []
|
||||
|
||||
def render(self, name, value, attrs=None, renderer=None):
|
||||
selected = searchselect_selected(self._values(value), self.options_resolver)
|
||||
autofocus = bool((attrs or {}).get("autofocus"))
|
||||
# Django widgets must return a safe string; the component is a node.
|
||||
return render(
|
||||
SearchSelect(
|
||||
name=name,
|
||||
selected=selected,
|
||||
options=None,
|
||||
search_url=self.search_url,
|
||||
multi_select=self.multi_select,
|
||||
items_visible=self.items_visible,
|
||||
items_scroll=self.items_scroll,
|
||||
prefetch=self.prefetch,
|
||||
always_visible=self.always_visible,
|
||||
placeholder=self.placeholder,
|
||||
id=(attrs or {}).get("id", ""),
|
||||
autofocus=autofocus,
|
||||
)
|
||||
)
|
||||
|
||||
def value_from_datadict(self, data, files, name):
|
||||
return data.get(name)
|
||||
|
||||
|
||||
class SearchSelectMultiple(SearchSelectWidget):
|
||||
def value_from_datadict(self, data, files, name):
|
||||
if hasattr(data, "getlist"):
|
||||
return data.getlist(name)
|
||||
return data.get(name)
|
||||
|
||||
|
||||
class SessionForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||
game = SingleGameChoiceField(
|
||||
queryset=Game.objects.order_by("sort_name"),
|
||||
widget=forms.Select(attrs={"autofocus": "autofocus"}),
|
||||
widget=SearchSelectWidget(
|
||||
search_url="/api/games/search", options_resolver=_game_options
|
||||
),
|
||||
)
|
||||
|
||||
duration_manual = forms.DurationField(
|
||||
@@ -43,7 +184,13 @@ class SessionForm(forms.ModelForm):
|
||||
),
|
||||
label="Manual duration",
|
||||
)
|
||||
device = forms.ModelChoiceField(queryset=Device.objects.order_by("name"))
|
||||
device = forms.ModelChoiceField(
|
||||
queryset=Device.objects.order_by("name"),
|
||||
required=False,
|
||||
widget=SearchSelectWidget(
|
||||
search_url="/api/devices/search", options_resolver=_device_options
|
||||
),
|
||||
)
|
||||
|
||||
mark_as_played = forms.BooleanField(
|
||||
required=False,
|
||||
@@ -81,37 +228,52 @@ class SessionForm(forms.ModelForm):
|
||||
return session
|
||||
|
||||
|
||||
class IncludePlatformSelect(forms.SelectMultiple):
|
||||
def create_option(self, name, value, *args, **kwargs):
|
||||
option = super().create_option(name, value, *args, **kwargs)
|
||||
if platform_id := safe_getattr(value, "instance.platform.id"):
|
||||
option["attrs"]["data-platform"] = platform_id
|
||||
return option
|
||||
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 PurchaseForm(forms.ModelForm):
|
||||
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):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Automatically update related_purchase <select/>
|
||||
# to only include purchases of the selected game.
|
||||
related_purchase_by_game_url = reverse("games:related_purchase_by_game")
|
||||
self.fields["games"].widget.attrs.update(
|
||||
{
|
||||
"hx-trigger": "load, click",
|
||||
"hx-get": related_purchase_by_game_url,
|
||||
"hx-target": "#id_related_purchase",
|
||||
"hx-swap": "outerHTML",
|
||||
}
|
||||
)
|
||||
self.fields["platform"].queryset = Platform.objects.order_by("name")
|
||||
|
||||
games = MultipleGameChoiceField(
|
||||
queryset=Game.objects.order_by("sort_name"),
|
||||
widget=IncludePlatformSelect(attrs={"autoselect": "autoselect"}),
|
||||
widget=SearchSelectMultiple(
|
||||
search_url="/api/games/search",
|
||||
options_resolver=_game_options,
|
||||
multi_select=True,
|
||||
),
|
||||
)
|
||||
platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
|
||||
related_purchase = forms.ModelChoiceField(
|
||||
queryset=Purchase.objects.filter(type=Purchase.GAME),
|
||||
platform = forms.ModelChoiceField(
|
||||
queryset=Platform.objects.order_by("name"),
|
||||
widget=SearchSelectWidget(
|
||||
search_url="/api/platforms/search", options_resolver=_platform_options
|
||||
),
|
||||
)
|
||||
related_purchase = RelatedPurchaseChoiceField(
|
||||
queryset=related_purchase_queryset(),
|
||||
required=False,
|
||||
)
|
||||
|
||||
@@ -184,9 +346,13 @@ class GameModelChoiceField(forms.ModelChoiceField):
|
||||
return obj.sort_name
|
||||
|
||||
|
||||
class GameForm(forms.ModelForm):
|
||||
class GameForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||
platform = forms.ModelChoiceField(
|
||||
queryset=Platform.objects.order_by("name"), required=False
|
||||
queryset=Platform.objects.order_by("name"),
|
||||
required=False,
|
||||
widget=SearchSelectWidget(
|
||||
search_url="/api/platforms/search", options_resolver=_platform_options
|
||||
),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -204,7 +370,7 @@ class GameForm(forms.ModelForm):
|
||||
widgets = {"name": autofocus_input_widget}
|
||||
|
||||
|
||||
class PlatformForm(forms.ModelForm):
|
||||
class PlatformForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||
class Meta:
|
||||
model = Platform
|
||||
fields = [
|
||||
@@ -215,17 +381,21 @@ class PlatformForm(forms.ModelForm):
|
||||
widgets = {"name": autofocus_input_widget}
|
||||
|
||||
|
||||
class DeviceForm(forms.ModelForm):
|
||||
class DeviceForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||
class Meta:
|
||||
model = Device
|
||||
fields = ["name", "type"]
|
||||
widgets = {"name": autofocus_input_widget}
|
||||
|
||||
|
||||
class PlayEventForm(forms.ModelForm):
|
||||
game = GameModelChoiceField(
|
||||
class PlayEventForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||
game = SingleGameChoiceField(
|
||||
queryset=Game.objects.order_by("sort_name"),
|
||||
widget=forms.Select(attrs={"autofocus": "autofocus"}),
|
||||
widget=SearchSelectWidget(
|
||||
search_url="/api/games/search",
|
||||
options_resolver=_game_options,
|
||||
attrs={"autofocus": "autofocus"},
|
||||
),
|
||||
)
|
||||
|
||||
mark_as_finished = forms.BooleanField(
|
||||
@@ -253,7 +423,7 @@ class PlayEventForm(forms.ModelForm):
|
||||
return session
|
||||
|
||||
|
||||
class GameStatusChangeForm(forms.ModelForm):
|
||||
class GameStatusChangeForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||
class Meta:
|
||||
model = GameStatusChange
|
||||
fields = [
|
||||
|
||||
@@ -34,9 +34,11 @@ class HTMXMessagesMiddleware:
|
||||
if "HX-Redirect" in response:
|
||||
return response
|
||||
|
||||
min_level = message_constants.DEBUG if settings.DEBUG else message_constants.INFO
|
||||
min_level = (
|
||||
message_constants.DEBUG if settings.DEBUG else message_constants.INFO
|
||||
)
|
||||
backend = django_messages.get_messages(request)
|
||||
if hasattr(backend, '_set_level') and backend._get_level() > min_level:
|
||||
if hasattr(backend, "_set_level") and backend._get_level() > min_level:
|
||||
backend._set_level(min_level)
|
||||
messages = list(backend)
|
||||
if not messages:
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Write ts/generated/props.ts from the registered custom-element specs."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
# Importing the components package triggers element registration at import time.
|
||||
import common.components # noqa: F401
|
||||
from common.components.custom_elements import render_props_module
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Generate ts/generated/props.ts from registered custom elements."
|
||||
|
||||
def handle(self, *args, **options) -> None:
|
||||
output_dir = Path(settings.BASE_DIR) / "ts" / "generated"
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
target = output_dir / "props.ts"
|
||||
target.write_text(render_props_module(), encoding="utf-8")
|
||||
self.stdout.write(self.style.SUCCESS(f"Wrote {target}"))
|
||||
@@ -0,0 +1,28 @@
|
||||
from django.contrib.sessions.models import Session
|
||||
from django.core.management.base import BaseCommand
|
||||
from django_q.models import OrmQ, Schedule, Task
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = (
|
||||
"Remove copied production artifacts from a staging database seeded "
|
||||
"from a production snapshot: clears authenticated sessions and the "
|
||||
"django-q schedule/queue/results so staging does not share prod's "
|
||||
"session cookies or independently run scheduled tasks."
|
||||
)
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
sessions_deleted, _ = Session.objects.all().delete()
|
||||
schedules_deleted, _ = Schedule.objects.all().delete()
|
||||
tasks_deleted, _ = Task.objects.all().delete()
|
||||
queued_deleted, _ = OrmQ.objects.all().delete()
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
"Scrubbed staging database: "
|
||||
f"{sessions_deleted} session(s), "
|
||||
f"{schedules_deleted} schedule(s), "
|
||||
f"{tasks_deleted} task result(s), "
|
||||
f"{queued_deleted} queued task(s) removed."
|
||||
)
|
||||
)
|
||||
@@ -6,99 +6,265 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Device',
|
||||
name="Device",
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('type', models.CharField(choices=[('PC', 'PC'), ('Console', 'Console'), ('Handheld', 'Handheld'), ('Mobile', 'Mobile'), ('Single-board computer', 'Single-board computer'), ('Unknown', 'Unknown')], default='Unknown', max_length=255)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255)),
|
||||
(
|
||||
"type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("PC", "PC"),
|
||||
("Console", "Console"),
|
||||
("Handheld", "Handheld"),
|
||||
("Mobile", "Mobile"),
|
||||
("Single-board computer", "Single-board computer"),
|
||||
("Unknown", "Unknown"),
|
||||
],
|
||||
default="Unknown",
|
||||
max_length=255,
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Platform',
|
||||
name="Platform",
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('group', models.CharField(blank=True, default=None, max_length=255, null=True)),
|
||||
('icon', models.SlugField(blank=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255)),
|
||||
(
|
||||
"group",
|
||||
models.CharField(
|
||||
blank=True, default=None, max_length=255, null=True
|
||||
),
|
||||
),
|
||||
("icon", models.SlugField(blank=True)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ExchangeRate',
|
||||
name="ExchangeRate",
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('currency_from', models.CharField(max_length=255)),
|
||||
('currency_to', models.CharField(max_length=255)),
|
||||
('year', models.PositiveIntegerField()),
|
||||
('rate', models.FloatField()),
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("currency_from", models.CharField(max_length=255)),
|
||||
("currency_to", models.CharField(max_length=255)),
|
||||
("year", models.PositiveIntegerField()),
|
||||
("rate", models.FloatField()),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('currency_from', 'currency_to', 'year')},
|
||||
"unique_together": {("currency_from", "currency_to", "year")},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Game',
|
||||
name="Game",
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('sort_name', models.CharField(blank=True, default=None, max_length=255, null=True)),
|
||||
('year_released', models.IntegerField(blank=True, default=None, null=True)),
|
||||
('wikidata', models.CharField(blank=True, default=None, max_length=50, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('platform', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, to='games.platform')),
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255)),
|
||||
(
|
||||
"sort_name",
|
||||
models.CharField(
|
||||
blank=True, default=None, max_length=255, null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"year_released",
|
||||
models.IntegerField(blank=True, default=None, null=True),
|
||||
),
|
||||
(
|
||||
"wikidata",
|
||||
models.CharField(
|
||||
blank=True, default=None, max_length=50, null=True
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"platform",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_DEFAULT,
|
||||
to="games.platform",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('name', 'platform', 'year_released')},
|
||||
"unique_together": {("name", "platform", "year_released")},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Purchase',
|
||||
name="Purchase",
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('date_purchased', models.DateField()),
|
||||
('date_refunded', models.DateField(blank=True, null=True)),
|
||||
('date_finished', models.DateField(blank=True, null=True)),
|
||||
('date_dropped', models.DateField(blank=True, null=True)),
|
||||
('infinite', models.BooleanField(default=False)),
|
||||
('price', models.FloatField(default=0)),
|
||||
('price_currency', models.CharField(default='USD', max_length=3)),
|
||||
('converted_price', models.FloatField(null=True)),
|
||||
('converted_currency', models.CharField(max_length=3, null=True)),
|
||||
('ownership_type', models.CharField(choices=[('ph', 'Physical'), ('di', 'Digital'), ('du', 'Digital Upgrade'), ('re', 'Rented'), ('bo', 'Borrowed'), ('tr', 'Trial'), ('de', 'Demo'), ('pi', 'Pirated')], default='di', max_length=2)),
|
||||
('type', models.CharField(choices=[('game', 'Game'), ('dlc', 'DLC'), ('season_pass', 'Season Pass'), ('battle_pass', 'Battle Pass')], default='game', max_length=255)),
|
||||
('name', models.CharField(blank=True, default='', max_length=255, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('games', models.ManyToManyField(blank=True, related_name='purchases', to='games.game')),
|
||||
('platform', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='games.platform')),
|
||||
('related_purchase', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='related_purchases', to='games.purchase')),
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("date_purchased", models.DateField()),
|
||||
("date_refunded", models.DateField(blank=True, null=True)),
|
||||
("date_finished", models.DateField(blank=True, null=True)),
|
||||
("date_dropped", models.DateField(blank=True, null=True)),
|
||||
("infinite", models.BooleanField(default=False)),
|
||||
("price", models.FloatField(default=0)),
|
||||
("price_currency", models.CharField(default="USD", max_length=3)),
|
||||
("converted_price", models.FloatField(null=True)),
|
||||
("converted_currency", models.CharField(max_length=3, null=True)),
|
||||
(
|
||||
"ownership_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("ph", "Physical"),
|
||||
("di", "Digital"),
|
||||
("du", "Digital Upgrade"),
|
||||
("re", "Rented"),
|
||||
("bo", "Borrowed"),
|
||||
("tr", "Trial"),
|
||||
("de", "Demo"),
|
||||
("pi", "Pirated"),
|
||||
],
|
||||
default="di",
|
||||
max_length=2,
|
||||
),
|
||||
),
|
||||
(
|
||||
"type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("game", "Game"),
|
||||
("dlc", "DLC"),
|
||||
("season_pass", "Season Pass"),
|
||||
("battle_pass", "Battle Pass"),
|
||||
],
|
||||
default="game",
|
||||
max_length=255,
|
||||
),
|
||||
),
|
||||
(
|
||||
"name",
|
||||
models.CharField(blank=True, default="", max_length=255, null=True),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"games",
|
||||
models.ManyToManyField(
|
||||
blank=True, related_name="purchases", to="games.game"
|
||||
),
|
||||
),
|
||||
(
|
||||
"platform",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="games.platform",
|
||||
),
|
||||
),
|
||||
(
|
||||
"related_purchase",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="related_purchases",
|
||||
to="games.purchase",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Session',
|
||||
name="Session",
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('timestamp_start', models.DateTimeField()),
|
||||
('timestamp_end', models.DateTimeField(blank=True, null=True)),
|
||||
('duration_manual', models.DurationField(blank=True, default=datetime.timedelta(0), null=True)),
|
||||
('duration_calculated', models.DurationField(blank=True, null=True)),
|
||||
('note', models.TextField(blank=True, null=True)),
|
||||
('emulated', models.BooleanField(default=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('modified_at', models.DateTimeField(auto_now=True)),
|
||||
('device', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, to='games.device')),
|
||||
('game', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to='games.game')),
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("timestamp_start", models.DateTimeField()),
|
||||
("timestamp_end", models.DateTimeField(blank=True, null=True)),
|
||||
(
|
||||
"duration_manual",
|
||||
models.DurationField(
|
||||
blank=True, default=datetime.timedelta(0), null=True
|
||||
),
|
||||
),
|
||||
("duration_calculated", models.DurationField(blank=True, null=True)),
|
||||
("note", models.TextField(blank=True, null=True)),
|
||||
("emulated", models.BooleanField(default=False)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("modified_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"device",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_DEFAULT,
|
||||
to="games.device",
|
||||
),
|
||||
),
|
||||
(
|
||||
"game",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="sessions",
|
||||
to="games.game",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
'get_latest_by': 'timestamp_start',
|
||||
"get_latest_by": "timestamp_start",
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -4,15 +4,14 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('games', '0001_initial'),
|
||||
("games", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='purchase',
|
||||
name='price_per_game',
|
||||
model_name="purchase",
|
||||
name="price_per_game",
|
||||
field=models.FloatField(null=True),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -4,15 +4,14 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('games', '0002_purchase_price_per_game'),
|
||||
("games", "0002_purchase_price_per_game"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='purchase',
|
||||
name='updated_at',
|
||||
model_name="purchase",
|
||||
name="updated_at",
|
||||
field=models.DateTimeField(auto_now=True),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -5,55 +5,66 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('games', '0005_game_mastered_game_status'),
|
||||
("games", "0005_game_mastered_game_status"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='game',
|
||||
name='sort_name',
|
||||
field=models.CharField(blank=True, default='', max_length=255),
|
||||
model_name="game",
|
||||
name="sort_name",
|
||||
field=models.CharField(blank=True, default="", max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='game',
|
||||
name='wikidata',
|
||||
field=models.CharField(blank=True, default='', max_length=50),
|
||||
model_name="game",
|
||||
name="wikidata",
|
||||
field=models.CharField(blank=True, default="", max_length=50),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='platform',
|
||||
name='group',
|
||||
field=models.CharField(blank=True, default='', max_length=255),
|
||||
model_name="platform",
|
||||
name="group",
|
||||
field=models.CharField(blank=True, default="", max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='purchase',
|
||||
name='converted_currency',
|
||||
field=models.CharField(blank=True, default='', max_length=3),
|
||||
model_name="purchase",
|
||||
name="converted_currency",
|
||||
field=models.CharField(blank=True, default="", max_length=3),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='purchase',
|
||||
name='games',
|
||||
field=models.ManyToManyField(related_name='purchases', to='games.game'),
|
||||
model_name="purchase",
|
||||
name="games",
|
||||
field=models.ManyToManyField(related_name="purchases", to="games.game"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='purchase',
|
||||
name='name',
|
||||
field=models.CharField(blank=True, default='', max_length=255),
|
||||
model_name="purchase",
|
||||
name="name",
|
||||
field=models.CharField(blank=True, default="", max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='purchase',
|
||||
name='related_purchase',
|
||||
field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='related_purchases', to='games.purchase'),
|
||||
model_name="purchase",
|
||||
name="related_purchase",
|
||||
field=models.ForeignKey(
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="related_purchases",
|
||||
to="games.purchase",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='session',
|
||||
name='game',
|
||||
field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to='games.game'),
|
||||
model_name="session",
|
||||
name="game",
|
||||
field=models.ForeignKey(
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="sessions",
|
||||
to="games.game",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='session',
|
||||
name='note',
|
||||
field=models.TextField(blank=True, default=''),
|
||||
model_name="session",
|
||||
name="note",
|
||||
field=models.TextField(blank=True, default=""),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -4,15 +4,14 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('games', '0006_alter_game_sort_name_alter_game_wikidata_and_more'),
|
||||
("games", "0006_alter_game_sort_name_alter_game_wikidata_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='game',
|
||||
name='updated_at',
|
||||
model_name="game",
|
||||
name="updated_at",
|
||||
field=models.DateTimeField(auto_now=True),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -4,18 +4,17 @@ from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('games', '0008_game_original_year_released_gamestatuschange_and_more'),
|
||||
("games", "0008_game_original_year_released_gamestatuschange_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='purchase',
|
||||
name='date_dropped',
|
||||
model_name="purchase",
|
||||
name="date_dropped",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='purchase',
|
||||
name='date_finished',
|
||||
model_name="purchase",
|
||||
name="date_finished",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -4,14 +4,13 @@ from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('games', '0009_remove_purchase_date_dropped_and_more'),
|
||||
("games", "0009_remove_purchase_date_dropped_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='purchase',
|
||||
name='price_per_game',
|
||||
model_name="purchase",
|
||||
name="price_per_game",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -6,15 +6,24 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('games', '0010_remove_purchase_price_per_game'),
|
||||
("games", "0010_remove_purchase_price_per_game"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='purchase',
|
||||
name='price_per_game',
|
||||
field=models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(django.db.models.functions.comparison.Coalesce(models.F('converted_price'), models.F('price'), 0), '/', models.F('num_purchases')), output_field=models.FloatField()),
|
||||
model_name="purchase",
|
||||
name="price_per_game",
|
||||
field=models.GeneratedField(
|
||||
db_persist=True,
|
||||
expression=django.db.models.expressions.CombinedExpression(
|
||||
django.db.models.functions.comparison.Coalesce(
|
||||
models.F("converted_price"), models.F("price"), 0
|
||||
),
|
||||
"/",
|
||||
models.F("num_purchases"),
|
||||
),
|
||||
output_field=models.FloatField(),
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -5,15 +5,20 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('games', '0013_game_playtime'),
|
||||
("games", "0013_game_playtime"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='session',
|
||||
name='duration_total',
|
||||
field=models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(models.F('duration_calculated'), '+', models.F('duration_manual')), output_field=models.DurationField()),
|
||||
model_name="session",
|
||||
name="duration_total",
|
||||
field=models.GeneratedField(
|
||||
db_persist=True,
|
||||
expression=django.db.models.expressions.CombinedExpression(
|
||||
models.F("duration_calculated"), "+", models.F("duration_manual")
|
||||
),
|
||||
output_field=models.DurationField(),
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -5,35 +5,39 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('games', '0014_session_duration_total'),
|
||||
("games", "0014_session_duration_total"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='purchase',
|
||||
name='date_purchased',
|
||||
field=models.DateField(verbose_name='Purchased'),
|
||||
model_name="purchase",
|
||||
name="date_purchased",
|
||||
field=models.DateField(verbose_name="Purchased"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='purchase',
|
||||
name='date_refunded',
|
||||
field=models.DateField(blank=True, null=True, verbose_name='Refunded'),
|
||||
model_name="purchase",
|
||||
name="date_refunded",
|
||||
field=models.DateField(blank=True, null=True, verbose_name="Refunded"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='session',
|
||||
name='duration_manual',
|
||||
field=models.DurationField(blank=True, default=datetime.timedelta(0), null=True, verbose_name='Manual duration'),
|
||||
model_name="session",
|
||||
name="duration_manual",
|
||||
field=models.DurationField(
|
||||
blank=True,
|
||||
default=datetime.timedelta(0),
|
||||
null=True,
|
||||
verbose_name="Manual duration",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='session',
|
||||
name='timestamp_end',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='End'),
|
||||
model_name="session",
|
||||
name="timestamp_end",
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name="End"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='session',
|
||||
name='timestamp_start',
|
||||
field=models.DateTimeField(verbose_name='Start'),
|
||||
model_name="session",
|
||||
name="timestamp_start",
|
||||
field=models.DateTimeField(verbose_name="Start"),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -4,15 +4,14 @@ from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('games', '0015_alter_purchase_date_purchased_and_more'),
|
||||
("games", "0015_alter_purchase_date_purchased_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='purchase',
|
||||
name='needs_price_update',
|
||||
model_name="purchase",
|
||||
name="needs_price_update",
|
||||
field=models.BooleanField(db_index=True, default=True),
|
||||
),
|
||||
migrations.RunSQL(
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# Generated by Django 6.0.1 on 2026-06-06 07:17
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0016_add_needs_price_update"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="FilterPreset",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255)),
|
||||
(
|
||||
"mode",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("games", "Games"),
|
||||
("sessions", "Sessions"),
|
||||
("purchases", "Purchases"),
|
||||
("playevents", "Play Events"),
|
||||
],
|
||||
default="games",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
("find_filter", models.JSONField(blank=True, default=dict)),
|
||||
("object_filter", models.JSONField(blank=True, default=dict)),
|
||||
("ui_options", models.JSONField(blank=True, default=dict)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
"ordering": ["name"],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 6.0.1 on 2026-06-06 20:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0017_add_filter_preset"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="session",
|
||||
name="timestamp_start",
|
||||
field=models.DateTimeField(db_index=True, verbose_name="Start"),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 6.0.5 on 2026-06-13 18:48
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0018_alter_session_timestamp_start"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="filterpreset",
|
||||
name="mode",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("games", "Games"),
|
||||
("sessions", "Sessions"),
|
||||
("purchases", "Purchases"),
|
||||
("playevents", "Play Events"),
|
||||
("devices", "Devices"),
|
||||
("platforms", "Platforms"),
|
||||
],
|
||||
default="games",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
]
|
||||
+41
-6
@@ -65,9 +65,15 @@ class Game(models.Model):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def search_label(self) -> str:
|
||||
return f"{self.sort_name} ({self.platform}, {self.year_released})"
|
||||
|
||||
def finished(self):
|
||||
return (self.status == self.Status.FINISHED or
|
||||
self.playevents.filter(ended__isnull=False).exists())
|
||||
return (
|
||||
self.status == self.Status.FINISHED
|
||||
or self.playevents.filter(ended__isnull=False).exists()
|
||||
)
|
||||
|
||||
def abandoned(self):
|
||||
return self.status == self.Status.ABANDONED
|
||||
@@ -288,7 +294,7 @@ class Session(models.Model):
|
||||
default=None,
|
||||
related_name="sessions",
|
||||
)
|
||||
timestamp_start = models.DateTimeField(verbose_name="Start")
|
||||
timestamp_start = models.DateTimeField(verbose_name="Start", db_index=True)
|
||||
timestamp_end = models.DateTimeField(blank=True, null=True, verbose_name="End")
|
||||
duration_manual = models.DurationField(
|
||||
blank=True, null=True, default=timedelta(0), verbose_name="Manual duration"
|
||||
@@ -327,9 +333,6 @@ class Session(models.Model):
|
||||
def finish_now(self):
|
||||
self.timestamp_end = timezone.now()
|
||||
|
||||
def start_now():
|
||||
self.timestamp_start = timezone.now()
|
||||
|
||||
def duration_formatted(self) -> str:
|
||||
result = format_duration(self.duration_total, "%02.1H")
|
||||
return result
|
||||
@@ -481,3 +484,35 @@ class GameStatusChange(models.Model):
|
||||
|
||||
class Meta:
|
||||
ordering = ["-timestamp"]
|
||||
|
||||
|
||||
class FilterPreset(models.Model):
|
||||
"""Saved filter configuration, following Stash's SavedFilter pattern.
|
||||
|
||||
Separates find_filter (sort/pagination), object_filter (criteria JSON),
|
||||
and ui_options (presentation state) so they can evolve independently.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
ordering = ["name"]
|
||||
|
||||
MODE_CHOICES = [
|
||||
("games", "Games"),
|
||||
("sessions", "Sessions"),
|
||||
("purchases", "Purchases"),
|
||||
("playevents", "Play Events"),
|
||||
("devices", "Devices"),
|
||||
("platforms", "Platforms"),
|
||||
]
|
||||
|
||||
name = models.CharField(max_length=255)
|
||||
mode = models.CharField(max_length=50, choices=MODE_CHOICES, default="games")
|
||||
find_filter = models.JSONField(default=dict, blank=True)
|
||||
object_filter = models.JSONField(default=dict, blank=True)
|
||||
ui_options = models.JSONField(default=dict, blank=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.get_mode_display()})"
|
||||
|
||||
+481
-139
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,35 @@
|
||||
import {
|
||||
syncSelectInputUntilChanged,
|
||||
getEl,
|
||||
disableElementsWhenTrue,
|
||||
disableElementsWhenValueNotEqual,
|
||||
} from "./utils.js";
|
||||
import { disableElementsWhenTrue, onSwap } from "./utils.js";
|
||||
|
||||
let syncData = [
|
||||
{
|
||||
source: "#id_games",
|
||||
source_value: "dataset.platform",
|
||||
target: "#id_platform",
|
||||
target_value: "value",
|
||||
},
|
||||
];
|
||||
const RELATED_PURCHASE_URL = "/tracker/purchase/related-purchase-by-game";
|
||||
|
||||
syncSelectInputUntilChanged(syncData, "form");
|
||||
// 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.
|
||||
document.addEventListener("search-select:change", (event) => {
|
||||
if (event.detail.name !== "games") return;
|
||||
|
||||
// (a) Auto-fill platform from the clicked option's data-platform.
|
||||
const last = event.detail.last;
|
||||
const platformId = last && last.data ? last.data.platform : "";
|
||||
if (platformId) {
|
||||
const platformEl = document.querySelector("#id_platform");
|
||||
if (platformEl) platformEl.value = platformId;
|
||||
}
|
||||
|
||||
// (b) Refresh #id_related_purchase for the currently selected games.
|
||||
const query = event.detail.values
|
||||
.map((value) => "games=" + encodeURIComponent(value))
|
||||
.join("&");
|
||||
fetch(RELATED_PURCHASE_URL + "?" + query, { credentials: "same-origin" })
|
||||
.then((response) => {
|
||||
if (response.status === 204) return null;
|
||||
return response.text();
|
||||
})
|
||||
.then((html) => {
|
||||
if (html === null) return;
|
||||
const target = document.querySelector("#id_related_purchase");
|
||||
if (target) target.outerHTML = html;
|
||||
});
|
||||
});
|
||||
|
||||
function setupElementHandlers() {
|
||||
disableElementsWhenTrue("#id_type", "game", [
|
||||
@@ -23,9 +38,9 @@ function setupElementHandlers() {
|
||||
]);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", setupElementHandlers);
|
||||
document.addEventListener("htmx:afterSwap", setupElementHandlers);
|
||||
getEl("#id_type").addEventListener("change", () => {
|
||||
onSwap("#id_type", (typeSelect) => {
|
||||
setupElementHandlers();
|
||||
}
|
||||
);
|
||||
typeSelect.addEventListener("change", () => {
|
||||
setupElementHandlers();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { toISOUTCString } from "./utils.js";
|
||||
|
||||
for (let button of document.querySelectorAll("[data-target]")) {
|
||||
let target = button.getAttribute("data-target");
|
||||
let type = button.getAttribute("data-type");
|
||||
let targetElement = document.querySelector(`#id_${target}`);
|
||||
button.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
if (type == "now") {
|
||||
targetElement.value = toISOUTCString(new Date());
|
||||
} else if (type == "copy") {
|
||||
const oppositeName =
|
||||
targetElement.name == "timestamp_start"
|
||||
? "timestamp_end"
|
||||
: "timestamp_start";
|
||||
document.querySelector(`[name='${oppositeName}']`).value =
|
||||
targetElement.value;
|
||||
} else if (type == "toggle") {
|
||||
if (targetElement.type == "datetime-local") targetElement.type = "text";
|
||||
else targetElement.type = "datetime-local";
|
||||
}
|
||||
});
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
(()=>{function x(n){n.directive("mask",(e,{value:l,expression:r},{effect:s,evaluateLater:i,cleanup:u})=>{let p=()=>r,f="";queueMicrotask(()=>{if(["function","dynamic"].includes(l)){let o=i(r);s(()=>{p=t=>{let c;return n.dontAutoEvaluateFunctions(()=>{o(d=>{c=typeof d=="function"?d(t):d},{scope:{$input:t,$money:M.bind({el:e})}})}),c},a(e,!1)})}else a(e,!1);if(e._x_model){e._x_model.get()!==e.value&&(e._x_model.get()===null&&e.value===""||e._x_model.set(e.value));let o=e._x_forceModelUpdate;e._x_forceModelUpdate=t=>{t=String(t);let c=p(t);c&&c!=="false"&&(t=m(c,t)),f=t,o(t),e._x_model.set(t)}}});let g=new AbortController;u(()=>{g.abort()}),e.addEventListener("input",()=>a(e),{signal:g.signal,capture:!0}),e.addEventListener("blur",()=>a(e,!1),{signal:g.signal});function a(o,t=!0){let c=o.value,d=p(c);if(!d||d==="false")return!1;if(f.length-o.value.length===1)return f=o.value;let h=()=>{f=o.value=m(d,c)};t?v(o,d,()=>{h()}):h()}}).before("model")}function v(n,e,l){let r=n.selectionStart,s=n.value;l();let i=s.slice(0,r),u=m(e,i).length;n.setSelectionRange(u,u)}var _={9:/[0-9]/,a:/[a-zA-Z]/,"*":/[a-zA-Z0-9]/};function m(n,e){let l=0,r=0,s="";for(;l<n.length&&r<e.length;){let i=n[l],u=e[r];i in _?(_[i].test(u)&&(s+=u,l++),r++):(s+=i,l++,i===e[r]&&r++)}return s}function M(n,e=".",l,r=2){if(n==="-")return"-";if(/^\D+$/.test(n))return"9";l==null&&(l=e===","?".":",");let s=(f,g)=>{let a="",o=0;for(let t=f.length-1;t>=0;t--)f[t]!==g&&(o===3?(a=f[t]+g+a,o=0):a=f[t]+a,o++);return a},i=n.startsWith("-")?"-":"",u=n.replaceAll(new RegExp(`[^0-9\\${e}]`,"g"),""),p=Array.from({length:u.split(e)[0].length}).fill("9").join("");return p=`${i}${s(p,l)}`,r>0&&n.includes(e)&&(p+=`${e}`+"9".repeat(r)),queueMicrotask(()=>{this.el.value.endsWith(e)||this.el.value[this.el.selectionStart-1]===e&&this.el.setSelectionRange(this.el.selectionStart-1,this.el.selectionStart-1)}),p}document.addEventListener("alpine:init",()=>{window.Alpine.plugin(x)});})();
|
||||
Vendored
+5
File diff suppressed because one or more lines are too long
@@ -0,0 +1,530 @@
|
||||
/**
|
||||
* DateRangePicker — vanilla JavaScript implementation.
|
||||
*
|
||||
* Drives the DateRangePicker component (common/components/date_range_picker.py):
|
||||
*
|
||||
* - DateRangeField: segmented manual entry. Each date part (DD/MM/YYYY) is its
|
||||
* own input; digits fill the placeholder from the right (YYYY → YYY1 → YY19
|
||||
* → Y198 → 1987), full parts auto-advance to the next one, and
|
||||
* Backspace/Delete reverts the active part to its placeholder.
|
||||
* - DateRangeCalendar: popup month grid with a preset column and a
|
||||
* Cancel / Clear / Select footer. Picking works anchor-style: the first
|
||||
* pick becomes the StartDate anchor, the second pick sets the EndDate and
|
||||
* moves the anchor there so further picks adjust the StartDate. Picking on
|
||||
* the wrong side of the anchor clears the range and restarts from the
|
||||
* clicked date.
|
||||
*
|
||||
* The committed value lives in the two hidden ISO inputs ({prefix}-min /
|
||||
* {prefix}-max) that filter_bar.js serializes into a DateCriterion.
|
||||
*
|
||||
* NB: class strings below are emitted verbatim so the Tailwind scanner picks
|
||||
* them up — keep them as plain literals.
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var WEEKDAY_LABELS = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
|
||||
|
||||
var WEEKDAY_CLASS =
|
||||
"w-8 h-6 flex items-center justify-center text-xs text-body select-none";
|
||||
var DAY_BASE_CLASS =
|
||||
"date-range-day w-8 h-8 flex items-center justify-center text-sm " +
|
||||
"text-heading cursor-pointer hover:bg-neutral-tertiary-medium";
|
||||
var DAY_ROUNDED_CLASS = "rounded-base";
|
||||
var DAY_OUTSIDE_MONTH_CLASS = "opacity-40";
|
||||
var DAY_SELECTED_CLASS = "bg-brand text-white hover:bg-brand-strong";
|
||||
var DAY_ANCHOR_CLASS =
|
||||
"bg-brand text-white ring-2 ring-inset ring-brand-strong hover:bg-brand-strong";
|
||||
// The three visual states of the date range track (the days between the
|
||||
// two endpoints): outlined while picking the second date, filled once both
|
||||
// are picked, muted when showing an already-committed range read-only.
|
||||
var TRACK_OUTLINED_CLASS = "border-y border-brand/70 bg-brand/10";
|
||||
var TRACK_FILLED_CLASS = "bg-brand/30";
|
||||
var TRACK_MUTED_CLASS = "bg-brand/15";
|
||||
|
||||
// ── Date helpers (all local-time; values are ISO YYYY-MM-DD strings) ──
|
||||
|
||||
function padNumber(value, width) {
|
||||
var text = String(value);
|
||||
while (text.length < width) text = "0" + text;
|
||||
return text;
|
||||
}
|
||||
|
||||
function isoFromDate(dateObject) {
|
||||
return (
|
||||
padNumber(dateObject.getFullYear(), 4) +
|
||||
"-" +
|
||||
padNumber(dateObject.getMonth() + 1, 2) +
|
||||
"-" +
|
||||
padNumber(dateObject.getDate(), 2)
|
||||
);
|
||||
}
|
||||
|
||||
function dateFromIso(isoString) {
|
||||
var pieces = isoString.split("-");
|
||||
return new Date(
|
||||
parseInt(pieces[0], 10),
|
||||
parseInt(pieces[1], 10) - 1,
|
||||
parseInt(pieces[2], 10)
|
||||
);
|
||||
}
|
||||
|
||||
function addDays(dateObject, dayCount) {
|
||||
var copy = new Date(dateObject.getTime());
|
||||
copy.setDate(copy.getDate() + dayCount);
|
||||
return copy;
|
||||
}
|
||||
|
||||
/** Validate a (year, month, day) triple as a real calendar date. */
|
||||
function isoFromParts(year, month, day) {
|
||||
var candidate = new Date(year, month - 1, day);
|
||||
if (
|
||||
candidate.getFullYear() !== year ||
|
||||
candidate.getMonth() !== month - 1 ||
|
||||
candidate.getDate() !== day
|
||||
) {
|
||||
return "";
|
||||
}
|
||||
return isoFromDate(candidate);
|
||||
}
|
||||
|
||||
function presetRange(presetName) {
|
||||
var today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
var yesterday = addDays(today, -1);
|
||||
var year = today.getFullYear();
|
||||
var month = today.getMonth();
|
||||
switch (presetName) {
|
||||
case "today":
|
||||
return [today, today];
|
||||
case "yesterday":
|
||||
return [yesterday, yesterday];
|
||||
case "last_7_days":
|
||||
return [addDays(today, -6), today];
|
||||
case "last_30_days":
|
||||
return [addDays(today, -29), today];
|
||||
case "this_month":
|
||||
return [new Date(year, month, 1), new Date(year, month + 1, 0)];
|
||||
case "last_month":
|
||||
return [new Date(year, month - 1, 1), new Date(year, month, 0)];
|
||||
case "this_year":
|
||||
return [new Date(year, 0, 1), new Date(year, 11, 31)];
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── DateRangeField: segmented manual entry ──────────────────────────────
|
||||
|
||||
function segmentBuffer(segment) {
|
||||
return segment.dataset.typedDigits || "";
|
||||
}
|
||||
|
||||
function setSegmentBuffer(segment, buffer) {
|
||||
segment.dataset.typedDigits = buffer;
|
||||
if (buffer === "") {
|
||||
segment.value = "";
|
||||
return;
|
||||
}
|
||||
var placeholder = segment.getAttribute("placeholder");
|
||||
// Fill the placeholder from the right: typing 19 into YYYY shows YY19.
|
||||
segment.value = placeholder.slice(0, placeholder.length - buffer.length) + buffer;
|
||||
}
|
||||
|
||||
function segmentsForSide(picker, side) {
|
||||
return Array.prototype.slice.call(
|
||||
picker.querySelectorAll('input[data-date-side="' + side + '"]')
|
||||
);
|
||||
}
|
||||
|
||||
/** Recompute one hidden ISO input from its side's segment buffers. */
|
||||
function syncHiddenFromSegments(picker, side) {
|
||||
var hidden = picker.querySelector(
|
||||
'input[data-date-range-hidden="' + side + '"]'
|
||||
);
|
||||
var partValues = {};
|
||||
var complete = true;
|
||||
segmentsForSide(picker, side).forEach(function (segment) {
|
||||
var buffer = segmentBuffer(segment);
|
||||
if (buffer.length !== parseInt(segment.getAttribute("maxlength"), 10)) {
|
||||
complete = false;
|
||||
}
|
||||
partValues[segment.dataset.datePart] = buffer;
|
||||
});
|
||||
var previousValue = hidden.value;
|
||||
if (complete) {
|
||||
hidden.value = isoFromParts(
|
||||
parseInt(partValues.year, 10),
|
||||
parseInt(partValues.month, 10),
|
||||
parseInt(partValues.day, 10)
|
||||
);
|
||||
} else {
|
||||
hidden.value = "";
|
||||
}
|
||||
return hidden.value !== previousValue;
|
||||
}
|
||||
|
||||
/** Push an ISO value (or "") into a side's segments and hidden input. */
|
||||
function setSideValue(picker, side, isoString) {
|
||||
var hidden = picker.querySelector(
|
||||
'input[data-date-range-hidden="' + side + '"]'
|
||||
);
|
||||
hidden.value = isoString;
|
||||
var partValues = { year: "", month: "", day: "" };
|
||||
if (isoString) {
|
||||
var pieces = isoString.split("-");
|
||||
partValues = { year: pieces[0], month: pieces[1], day: pieces[2] };
|
||||
}
|
||||
segmentsForSide(picker, side).forEach(function (segment) {
|
||||
setSegmentBuffer(segment, partValues[segment.dataset.datePart]);
|
||||
});
|
||||
}
|
||||
|
||||
function initField(picker, calendarState) {
|
||||
var field = picker.querySelector("[data-date-range-field]");
|
||||
var segments = Array.prototype.slice.call(
|
||||
picker.querySelectorAll("input[data-date-part]")
|
||||
);
|
||||
|
||||
// Adopt server-rendered values (prefilled filter) as typed buffers.
|
||||
segments.forEach(function (segment) {
|
||||
if (segment.value) setSegmentBuffer(segment, segment.value);
|
||||
});
|
||||
|
||||
// Clicking anywhere in the container that is not a date part activates
|
||||
// the first date part.
|
||||
field.addEventListener("mousedown", function (event) {
|
||||
if (event.target.closest("input[data-date-part]")) return;
|
||||
if (event.target.closest("[data-date-range-calendar-toggle]")) return;
|
||||
event.preventDefault();
|
||||
segments[0].focus();
|
||||
});
|
||||
|
||||
segments.forEach(function (segment, segmentIndex) {
|
||||
segment.addEventListener("keydown", function (event) {
|
||||
if (event.key === "Tab") return; // native Tab / Shift+Tab navigation
|
||||
if (event.key === "Enter") return; // let the filter form submit
|
||||
if (event.key === "Backspace" || event.key === "Delete") {
|
||||
event.preventDefault();
|
||||
setSegmentBuffer(segment, "");
|
||||
syncHiddenFromSegments(picker, segment.dataset.dateSide);
|
||||
return;
|
||||
}
|
||||
if (event.ctrlKey || event.metaKey || event.altKey) return;
|
||||
event.preventDefault();
|
||||
if (!/^[0-9]$/.test(event.key)) return; // only numbers can be typed
|
||||
var maximumLength = parseInt(segment.getAttribute("maxlength"), 10);
|
||||
var buffer = segmentBuffer(segment);
|
||||
// Typing into an already-full part starts it over.
|
||||
buffer = buffer.length >= maximumLength ? event.key : buffer + event.key;
|
||||
setSegmentBuffer(segment, buffer);
|
||||
syncHiddenFromSegments(picker, segment.dataset.dateSide);
|
||||
if (buffer.length === maximumLength && segmentIndex + 1 < segments.length) {
|
||||
segments[segmentIndex + 1].focus();
|
||||
}
|
||||
});
|
||||
// Swallow any input that bypassed keydown (e.g. IME/paste).
|
||||
segment.addEventListener("input", function () {
|
||||
setSegmentBuffer(segment, segmentBuffer(segment));
|
||||
});
|
||||
segment.addEventListener("focus", function () {
|
||||
if (calendarState) calendarState.refreshFromField();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── DateRangeCalendar: popup month grid ────────────────────────────────
|
||||
|
||||
function createCalendarState(picker) {
|
||||
var popup = picker.querySelector("[data-date-range-calendar]");
|
||||
var grid = popup.querySelector("[data-date-range-grid]");
|
||||
var monthLabel = popup.querySelector("[data-date-range-month-label]");
|
||||
|
||||
var today = new Date();
|
||||
var state = {
|
||||
open: false,
|
||||
viewYear: today.getFullYear(),
|
||||
viewMonth: today.getMonth(),
|
||||
startIso: "",
|
||||
endIso: "",
|
||||
// The anchor is the fixed endpoint: "start" while picking the EndDate,
|
||||
// "end" once the range is complete (further picks move the StartDate).
|
||||
anchor: "",
|
||||
hoverIso: "",
|
||||
// True while showing a committed range the user has not edited yet —
|
||||
// the track renders muted until the first pick.
|
||||
readOnly: false,
|
||||
};
|
||||
|
||||
function hiddenValue(side) {
|
||||
return picker.querySelector(
|
||||
'input[data-date-range-hidden="' + side + '"]'
|
||||
).value;
|
||||
}
|
||||
|
||||
state.refreshFromField = function () {
|
||||
if (state.open) return;
|
||||
state.startIso = hiddenValue("min");
|
||||
state.endIso = hiddenValue("max");
|
||||
};
|
||||
|
||||
function syncSelectionToField() {
|
||||
setSideValue(picker, "min", state.startIso);
|
||||
setSideValue(picker, "max", state.endIso);
|
||||
}
|
||||
|
||||
function openPopup() {
|
||||
state.startIso = hiddenValue("min");
|
||||
state.endIso = hiddenValue("max");
|
||||
state.anchor = state.startIso && state.endIso ? "end" : state.startIso ? "start" : "";
|
||||
state.readOnly = Boolean(state.startIso && state.endIso);
|
||||
state.hoverIso = "";
|
||||
var focusDate = state.startIso ? dateFromIso(state.startIso) : new Date();
|
||||
state.viewYear = focusDate.getFullYear();
|
||||
state.viewMonth = focusDate.getMonth();
|
||||
state.open = true;
|
||||
popup.classList.remove("hidden");
|
||||
render();
|
||||
}
|
||||
|
||||
function closePopup() {
|
||||
state.open = false;
|
||||
state.hoverIso = "";
|
||||
popup.classList.add("hidden");
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
state.startIso = "";
|
||||
state.endIso = "";
|
||||
state.anchor = "";
|
||||
state.hoverIso = "";
|
||||
state.readOnly = false;
|
||||
syncSelectionToField();
|
||||
}
|
||||
|
||||
/**
|
||||
* Anchor-style picking:
|
||||
* - no selection: the pick becomes the StartDate anchor
|
||||
* - anchor=start (picking EndDate): a pick on/after the StartDate
|
||||
* completes the range and moves the anchor to the EndDate; a pick
|
||||
* before it clears the range and restarts
|
||||
* - anchor=end (adjusting StartDate): a pick on/before the EndDate
|
||||
* moves the StartDate (extend/shorten); a pick after it clears the
|
||||
* range and restarts from the clicked date
|
||||
*/
|
||||
function pickDate(isoString) {
|
||||
state.readOnly = false;
|
||||
if (!state.startIso) {
|
||||
state.startIso = isoString;
|
||||
state.anchor = "start";
|
||||
} else if (state.anchor === "start" && !state.endIso) {
|
||||
if (isoString >= state.startIso) {
|
||||
state.endIso = isoString;
|
||||
state.anchor = "end";
|
||||
} else {
|
||||
state.startIso = isoString;
|
||||
state.endIso = "";
|
||||
state.anchor = "start";
|
||||
}
|
||||
} else {
|
||||
if (isoString <= state.endIso) {
|
||||
state.startIso = isoString;
|
||||
} else {
|
||||
state.startIso = isoString;
|
||||
state.endIso = "";
|
||||
state.anchor = "start";
|
||||
}
|
||||
}
|
||||
syncSelectionToField();
|
||||
render();
|
||||
}
|
||||
|
||||
function applyPreset(presetName) {
|
||||
var range = presetRange(presetName);
|
||||
if (!range) return;
|
||||
state.startIso = isoFromDate(range[0]);
|
||||
state.endIso = isoFromDate(range[1]);
|
||||
state.anchor = "end";
|
||||
state.readOnly = false;
|
||||
state.viewYear = range[0].getFullYear();
|
||||
state.viewMonth = range[0].getMonth();
|
||||
syncSelectionToField();
|
||||
render();
|
||||
}
|
||||
|
||||
/** The (inclusive-exclusive of endpoints) track between the two range
|
||||
* ends; while picking the second date the hovered day acts as the
|
||||
* provisional other end. */
|
||||
function trackBounds() {
|
||||
if (state.startIso && state.endIso) {
|
||||
return [state.startIso, state.endIso, state.readOnly ? TRACK_MUTED_CLASS : TRACK_FILLED_CLASS];
|
||||
}
|
||||
if (state.startIso && state.hoverIso && state.hoverIso !== state.startIso) {
|
||||
var lower = state.hoverIso < state.startIso ? state.hoverIso : state.startIso;
|
||||
var upper = state.hoverIso < state.startIso ? state.startIso : state.hoverIso;
|
||||
return [lower, upper, TRACK_OUTLINED_CLASS];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function dayCellClass(isoString, inViewMonth) {
|
||||
var classes = [DAY_BASE_CLASS];
|
||||
var isStart = isoString === state.startIso;
|
||||
var isEnd = isoString === state.endIso;
|
||||
var isAnchor =
|
||||
(state.anchor === "start" && isStart) || (state.anchor === "end" && isEnd);
|
||||
var track = trackBounds();
|
||||
var inTrack = track && isoString > track[0] && isoString < track[1];
|
||||
if (inTrack) {
|
||||
classes.push(track[2]);
|
||||
} else {
|
||||
classes.push(DAY_ROUNDED_CLASS);
|
||||
}
|
||||
if (isAnchor && !state.readOnly) {
|
||||
classes.push(DAY_ANCHOR_CLASS);
|
||||
} else if (isStart || isEnd) {
|
||||
classes.push(DAY_SELECTED_CLASS);
|
||||
} else if (!inViewMonth) {
|
||||
classes.push(DAY_OUTSIDE_MONTH_CLASS);
|
||||
}
|
||||
return classes.join(" ");
|
||||
}
|
||||
|
||||
function render() {
|
||||
monthLabel.textContent = new Date(
|
||||
state.viewYear,
|
||||
state.viewMonth,
|
||||
1
|
||||
).toLocaleDateString(undefined, { month: "long", year: "numeric" });
|
||||
|
||||
grid.textContent = "";
|
||||
WEEKDAY_LABELS.forEach(function (weekdayLabel) {
|
||||
var headerCell = document.createElement("span");
|
||||
headerCell.className = WEEKDAY_CLASS;
|
||||
headerCell.textContent = weekdayLabel;
|
||||
grid.appendChild(headerCell);
|
||||
});
|
||||
|
||||
var firstOfMonth = new Date(state.viewYear, state.viewMonth, 1);
|
||||
// Monday-first offset of the leading overflow days.
|
||||
var leadingDays = (firstOfMonth.getDay() + 6) % 7;
|
||||
var cellDate = addDays(firstOfMonth, -leadingDays);
|
||||
for (var cellIndex = 0; cellIndex < 42; cellIndex++) {
|
||||
var isoString = isoFromDate(cellDate);
|
||||
var dayButton = document.createElement("button");
|
||||
dayButton.type = "button";
|
||||
dayButton.setAttribute("data-date", isoString);
|
||||
dayButton.className = dayCellClass(
|
||||
isoString,
|
||||
cellDate.getMonth() === state.viewMonth
|
||||
);
|
||||
dayButton.textContent = String(cellDate.getDate());
|
||||
grid.appendChild(dayButton);
|
||||
cellDate = addDays(cellDate, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Wiring ──
|
||||
picker
|
||||
.querySelector("[data-date-range-calendar-toggle]")
|
||||
.addEventListener("click", function () {
|
||||
if (state.open) closePopup();
|
||||
else openPopup();
|
||||
});
|
||||
|
||||
grid.addEventListener("click", function (event) {
|
||||
var dayButton = event.target.closest("button[data-date]");
|
||||
if (dayButton) pickDate(dayButton.getAttribute("data-date"));
|
||||
});
|
||||
|
||||
grid.addEventListener("mouseover", function (event) {
|
||||
if (!state.startIso || state.endIso) return;
|
||||
var dayButton = event.target.closest("button[data-date]");
|
||||
if (!dayButton) return;
|
||||
var hoveredIso = dayButton.getAttribute("data-date");
|
||||
if (hoveredIso === state.hoverIso) return;
|
||||
state.hoverIso = hoveredIso;
|
||||
render();
|
||||
});
|
||||
|
||||
popup
|
||||
.querySelector("[data-date-range-prev]")
|
||||
.addEventListener("click", function () {
|
||||
state.viewMonth -= 1;
|
||||
if (state.viewMonth < 0) {
|
||||
state.viewMonth = 11;
|
||||
state.viewYear -= 1;
|
||||
}
|
||||
render();
|
||||
});
|
||||
|
||||
popup
|
||||
.querySelector("[data-date-range-next]")
|
||||
.addEventListener("click", function () {
|
||||
state.viewMonth += 1;
|
||||
if (state.viewMonth > 11) {
|
||||
state.viewMonth = 0;
|
||||
state.viewYear += 1;
|
||||
}
|
||||
render();
|
||||
});
|
||||
|
||||
popup.querySelectorAll("[data-date-range-preset]").forEach(function (button) {
|
||||
button.addEventListener("click", function () {
|
||||
applyPreset(button.getAttribute("data-date-range-preset"));
|
||||
});
|
||||
});
|
||||
|
||||
// Cancel: close the popup and clear the selected dates.
|
||||
popup
|
||||
.querySelector("[data-date-range-cancel]")
|
||||
.addEventListener("click", function () {
|
||||
clearSelection();
|
||||
closePopup();
|
||||
});
|
||||
|
||||
// Clear: clear the selected dates but keep the popup open.
|
||||
popup
|
||||
.querySelector("[data-date-range-clear]")
|
||||
.addEventListener("click", function () {
|
||||
clearSelection();
|
||||
render();
|
||||
});
|
||||
|
||||
// Select: close the popup, keeping the selected dates.
|
||||
popup
|
||||
.querySelector("[data-date-range-select]")
|
||||
.addEventListener("click", function () {
|
||||
closePopup();
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", function (event) {
|
||||
if (event.key === "Escape" && state.open) closePopup();
|
||||
});
|
||||
|
||||
document.addEventListener("mousedown", function (event) {
|
||||
if (state.open && !picker.contains(event.target)) closePopup();
|
||||
});
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function initPicker(picker) {
|
||||
if (picker.dataset.dateRangePickerInitialized) return;
|
||||
picker.dataset.dateRangePickerInitialized = "true";
|
||||
var calendarState = createCalendarState(picker);
|
||||
initField(picker, calendarState);
|
||||
}
|
||||
|
||||
function initAllPickers() {
|
||||
document.querySelectorAll("[data-date-range-picker]").forEach(initPicker);
|
||||
}
|
||||
|
||||
window.initDateRangePickers = initAllPickers;
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", initAllPickers);
|
||||
} else {
|
||||
initAllPickers();
|
||||
}
|
||||
})();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,479 @@
|
||||
/**
|
||||
* Filter bar — vanilla JavaScript implementation.
|
||||
*
|
||||
* Handles form submission, preset loading/saving, and preset list rendering.
|
||||
* No HTMX — plain fetch() and window.location for all interactions.
|
||||
*/
|
||||
import { onSwap } from "./utils.js";
|
||||
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
/** Build a criterion object from a value and optional second value. */
|
||||
function criterion(value, value2, modifier) {
|
||||
var c = { value: value, modifier: modifier };
|
||||
if (value2 !== null && value2 !== undefined && value2 !== "") {
|
||||
c.value2 = value2;
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
/** Read a <select> element's value, or "" if not found. */
|
||||
function selectValue(form, name) {
|
||||
var el = form.querySelector('[name="' + name + '"]');
|
||||
return el ? el.value : "";
|
||||
}
|
||||
|
||||
/** Read an <input type="number"> value, or "" if not found. */
|
||||
function numberValue(form, name) {
|
||||
var el = form.querySelector('[name="' + name + '"]');
|
||||
if (!el || el.value === "") return "";
|
||||
var val = parseFloat(el.value);
|
||||
return isNaN(val) ? "" : val;
|
||||
}
|
||||
|
||||
/** Read a raw <input> value as string, or "" if not found. */
|
||||
function stringValue(form, name) {
|
||||
var el = form.querySelector('[name="' + name + '"]');
|
||||
return el ? el.value : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a range criterion ({value, value2?, modifier}) from a (min, max)
|
||||
* pair, or null if both bounds are empty. Shared by the numeric-range and
|
||||
* date-range serializers.
|
||||
*/
|
||||
function buildRangeCriterion(vMin, vMax) {
|
||||
if (vMin !== "" && vMax !== "") return criterion(vMin, vMax, "BETWEEN");
|
||||
if (vMin !== "") return criterion(vMin, null, "GREATER_THAN");
|
||||
if (vMax !== "") return criterion(vMax, null, "LESS_THAN");
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Read all checked checkboxes with a given name, returning an array of ints. */
|
||||
function checkedValues(form, name) {
|
||||
var els = form.querySelectorAll('[name="' + name + '"]:checked');
|
||||
var ids = [];
|
||||
els.forEach(function (el) {
|
||||
var v = parseInt(el.value, 10);
|
||||
if (!isNaN(v)) ids.push(v);
|
||||
});
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the filter JSON object from form field values.
|
||||
* Returns a plain object ready for JSON.stringify.
|
||||
*/
|
||||
function buildFilterJSON(form) {
|
||||
var filter = {};
|
||||
|
||||
// ── Search field ──
|
||||
var searchInput = form.querySelector('[name="filter-search"]');
|
||||
if (searchInput && searchInput.value.trim()) {
|
||||
filter.search = { value: searchInput.value.trim(), modifier: "INCLUDES" };
|
||||
}
|
||||
|
||||
// ── FilterSelect widgets (data-search-select-mode="filter") ──
|
||||
// readSearchSelect serialises each into data-included/data-excluded/data-modifier.
|
||||
readSearchSelect(form);
|
||||
var widgets = form.querySelectorAll('[data-search-select][data-search-select-mode="filter"]');
|
||||
widgets.forEach(function (widget) {
|
||||
var field = widget.getAttribute("data-name");
|
||||
var included = parseJSONAttr(widget, "data-included");
|
||||
var excluded = parseJSONAttr(widget, "data-excluded");
|
||||
// Two orthogonal axes: a presence modifier (NOT_NULL/IS_NULL) from the
|
||||
// pinned (Any)/(None) pseudo-options clears the value set and has no
|
||||
// values; the non-presence modifier (INCLUDES_ALL/INCLUDES_ONLY) governs
|
||||
// how the include set matches. When neither is set the implicit default
|
||||
// is INCLUDES ("any"). Must match Python _PRESENCE_MODIFIERS.
|
||||
var modifier = widget.getAttribute("data-modifier");
|
||||
var IS_PRESENCE = modifier === "NOT_NULL" || modifier === "IS_NULL";
|
||||
if (IS_PRESENCE) {
|
||||
filter[field] = { modifier: modifier };
|
||||
} else if (included.length > 0 || excluded.length > 0) {
|
||||
// All filter pills carry {id, label}; store them as-is so the filter
|
||||
// URL and saved presets are self-describing (Stash-style).
|
||||
filter[field] = {
|
||||
value: included.map(function (item) { return {id: item.id, label: item.label}; }),
|
||||
excludes: excluded.map(function (item) { return {id: item.id, label: item.label}; }),
|
||||
modifier: modifier || "INCLUDES",
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// 1. Text Fields
|
||||
var textFields = [
|
||||
{ name: "filter-price_currency", key: "price_currency" },
|
||||
{ name: "filter-converted_currency", key: "converted_currency" },
|
||||
{ name: "filter-name", key: "name" },
|
||||
{ name: "filter-group", key: "group" },
|
||||
{ name: "filter-playevent_note", key: "playevent_note" },
|
||||
{ name: "filter-note", key: "note" }
|
||||
];
|
||||
textFields.forEach(function (tf) {
|
||||
var modifierEl = form.querySelector('[name="' + tf.name + '-modifier"]:checked');
|
||||
var modifier = modifierEl ? modifierEl.value : "EQUALS";
|
||||
|
||||
var isPresence = modifier === "IS_NULL" || modifier === "NOT_NULL";
|
||||
if (isPresence) {
|
||||
filter[tf.key] = { modifier: modifier };
|
||||
} else {
|
||||
var el = form.querySelector('[name="' + tf.name + '"]');
|
||||
if (el && el.value.trim()) {
|
||||
filter[tf.key] = { value: el.value.trim(), modifier: modifier };
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Boolean Fields (Radio Button Groups)
|
||||
var booleanFields = [
|
||||
{ name: "filter-mastered", key: "mastered" },
|
||||
{ name: "filter-emulated", key: "emulated" },
|
||||
{ name: "filter-active", key: "is_active" },
|
||||
{ name: "filter-refunded", key: "is_refunded" },
|
||||
{ name: "filter-infinite", key: "infinite" },
|
||||
{ name: "filter-needs-price-update", key: "needs_price_update" },
|
||||
{ name: "filter-purchase-refunded", key: "purchase_refunded" },
|
||||
{ name: "filter-purchase-infinite", key: "purchase_infinite" },
|
||||
{ name: "filter-session-emulated", key: "session_emulated" }
|
||||
];
|
||||
booleanFields.forEach(function (bf) {
|
||||
var el = form.querySelector('[name="' + bf.name + '"]:checked');
|
||||
if (el) {
|
||||
var val = el.value === "true";
|
||||
filter[bf.key] = criterion(val, null, "EQUALS");
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Range Fields
|
||||
var rangeFields = [
|
||||
{ prefix: "filter-year", key: "year_released" },
|
||||
{ prefix: "filter-original-year", key: "original_year_released" },
|
||||
{ prefix: "filter-session-count", key: "session_count" },
|
||||
{ prefix: "filter-session-average", key: "session_average" },
|
||||
{ prefix: "filter-purchase-count", key: "purchase_count" },
|
||||
{ prefix: "filter-playevent-count", key: "playevent_count" },
|
||||
{ prefix: "filter-duration-total-hours", key: "duration_total_hours" },
|
||||
{ prefix: "filter-duration-manual-hours", key: "duration_manual_hours" },
|
||||
{ prefix: "filter-duration-calculated-hours", key: "duration_calculated_hours" },
|
||||
{ prefix: "filter-manual-playtime-hours", key: "manual_playtime_hours" },
|
||||
{ prefix: "filter-calculated-playtime-hours", key: "calculated_playtime_hours" },
|
||||
{ prefix: "filter-num-purchases", key: "num_purchases" },
|
||||
{ prefix: "filter-price", key: "price" },
|
||||
{ prefix: "filter-purchase-price-total", key: "purchase_price_total" },
|
||||
{ prefix: "filter-purchase-price-any", key: "purchase_price_any" },
|
||||
{ prefix: "filter-days-to-finish", key: "days_to_finish" },
|
||||
{ prefix: "filter-playtime-hours", key: "playtime_hours", ignoreZeroZero: true }
|
||||
];
|
||||
|
||||
rangeFields.forEach(function (rf) {
|
||||
var vMin = numberValue(form, rf.prefix + "-min");
|
||||
var vMax = numberValue(form, rf.prefix + "-max");
|
||||
|
||||
if (rf.convert) {
|
||||
if (vMin !== "") vMin = rf.convert(vMin);
|
||||
if (vMax !== "") vMax = rf.convert(vMax);
|
||||
}
|
||||
|
||||
if (rf.ignoreZeroZero && vMin === 0 && vMax === 0) {
|
||||
return; // both 0 means slider at default
|
||||
}
|
||||
|
||||
var c = buildRangeCriterion(vMin, vMax);
|
||||
if (c !== null) filter[rf.key] = c;
|
||||
});
|
||||
|
||||
// 4. Date Range Fields — ISO date strings from <input type="date">; no
|
||||
// numeric coercion. Same modifier derivation as numeric ranges.
|
||||
var dateRangeFields = [
|
||||
{ prefix: "filter-date-purchased", key: "date_purchased" },
|
||||
{ prefix: "filter-date-refunded", key: "date_refunded" },
|
||||
];
|
||||
dateRangeFields.forEach(function (df) {
|
||||
var vMin = stringValue(form, df.prefix + "-min");
|
||||
var vMax = stringValue(form, df.prefix + "-max");
|
||||
var c = buildRangeCriterion(vMin, vMax);
|
||||
if (c !== null) filter[df.key] = c;
|
||||
});
|
||||
|
||||
return filter;
|
||||
}
|
||||
|
||||
/** Extract the current page's base URL (without query string). */
|
||||
function baseUrl() {
|
||||
return window.location.pathname;
|
||||
}
|
||||
|
||||
/** Safely parse a JSON attribute, returning empty array on failure. */
|
||||
function parseJSONAttr(el, attr) {
|
||||
var raw = el.getAttribute(attr);
|
||||
if (!raw) return [];
|
||||
try { return JSON.parse(raw); } catch (e) { return []; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Called on filter bar form submit.
|
||||
* Serializes filter fields, navigates to URL with filter param.
|
||||
*/
|
||||
window.applyFilterBar = function (event) {
|
||||
event.preventDefault();
|
||||
var form = event.target;
|
||||
var filter = buildFilterJSON(form);
|
||||
var filterStr = JSON.stringify(filter);
|
||||
var url = baseUrl();
|
||||
if (filterStr && filterStr !== "{}") {
|
||||
url += "?filter=" + encodeURIComponent(filterStr);
|
||||
}
|
||||
window.location.href = url;
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear all filter fields and reload the unfiltered view.
|
||||
*/
|
||||
window.clearFilterBar = function (formId, filterInputId) {
|
||||
var form = document.getElementById(formId);
|
||||
if (!form) return;
|
||||
form.reset();
|
||||
window.location.href = baseUrl();
|
||||
};
|
||||
|
||||
// ── Presets ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Fetch and render the preset list. */
|
||||
function loadPresets() {
|
||||
var dropdown = document.getElementById("preset-dropdown");
|
||||
if (!dropdown) return;
|
||||
var url = dropdown.getAttribute("data-preset-list-url");
|
||||
if (!url) return;
|
||||
|
||||
var mode = "games";
|
||||
var path = window.location.pathname;
|
||||
if (path.indexOf("session") !== -1) mode = "sessions";
|
||||
else if (path.indexOf("purchase") !== -1) mode = "purchases";
|
||||
else if (path.indexOf("device") !== -1) mode = "devices";
|
||||
else if (path.indexOf("platform") !== -1) mode = "platforms";
|
||||
else if (path.indexOf("playevent") !== -1) mode = "playevents";
|
||||
|
||||
var query = "";
|
||||
if (url.indexOf("mode=") === -1) {
|
||||
query = (url.indexOf("?") !== -1 ? "&" : "?") + "mode=" + mode;
|
||||
}
|
||||
|
||||
fetch(url + query, { credentials: "same-origin" })
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error("Failed to load presets");
|
||||
return r.text();
|
||||
})
|
||||
.then(function (html) {
|
||||
dropdown.innerHTML = html;
|
||||
// Re-attach delete handlers (list_presets view uses onclick attributes,
|
||||
// but we also need to wire up inline handlers if they use data attributes)
|
||||
setupPresetDeleteHandlers(dropdown);
|
||||
})
|
||||
.catch(function (err) {
|
||||
dropdown.innerHTML =
|
||||
'<span class="text-sm text-body italic">Presets unavailable</span>';
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
|
||||
/** Wire up click handlers for preset delete buttons. */
|
||||
function setupPresetDeleteHandlers(container) {
|
||||
var deleteLinks = container.querySelectorAll('[data-delete-preset]');
|
||||
deleteLinks.forEach(function (link) {
|
||||
link.addEventListener("click", function (e) {
|
||||
e.preventDefault();
|
||||
var presetId = link.getAttribute("data-delete-preset");
|
||||
var deleteUrl = link.getAttribute("href");
|
||||
if (!deleteUrl) return;
|
||||
if (!confirm("Delete this preset?")) return;
|
||||
fetch(deleteUrl, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "X-CSRFToken": getCsrfToken() },
|
||||
})
|
||||
.then(function () {
|
||||
// Remove the parent <li>
|
||||
var li = link.closest("li");
|
||||
if (li) li.remove();
|
||||
// If no items left, show empty message
|
||||
var ul = container.querySelector("ul");
|
||||
if (ul && ul.querySelectorAll("li").length === 0) {
|
||||
ul.innerHTML =
|
||||
'<li class="px-4 py-2 text-sm text-body italic">No saved presets</li>';
|
||||
}
|
||||
})
|
||||
.catch(function (err) {
|
||||
console.error("Delete failed:", err);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Enable/disable the input text box depending on selected string modifier. */
|
||||
window.toggleStringFilterInput = function (radio) {
|
||||
var container = radio.closest(".flex-col");
|
||||
if (!container) return;
|
||||
var textInput = container.querySelector('input[type="text"]');
|
||||
if (!textInput) return;
|
||||
|
||||
// Find the currently checked radio in the container
|
||||
var checkedRadio = container.querySelector('input[type="radio"]:checked');
|
||||
var val = checkedRadio ? checkedRadio.value : "";
|
||||
|
||||
if (val === "IS_NULL" || val === "NOT_NULL") {
|
||||
textInput.disabled = true;
|
||||
textInput.value = "";
|
||||
textInput.classList.add("opacity-50", "cursor-not-allowed");
|
||||
} else {
|
||||
textInput.disabled = false;
|
||||
textInput.classList.remove("opacity-50", "cursor-not-allowed");
|
||||
}
|
||||
};
|
||||
|
||||
/** Show the preset name input field and the confirm button. */
|
||||
window.showPresetNameInput = function () {
|
||||
var input = document.getElementById("preset-name-input");
|
||||
var saveBtn = document.getElementById("save-preset-btn");
|
||||
var confirmBtn = document.getElementById("confirm-save-preset-btn");
|
||||
if (input) input.classList.remove("hidden");
|
||||
if (saveBtn) saveBtn.classList.add("hidden");
|
||||
if (confirmBtn) confirmBtn.classList.remove("hidden");
|
||||
if (input) input.focus();
|
||||
};
|
||||
|
||||
/** Save the current filter as a named preset. */
|
||||
window.savePreset = function (formId, filterInputId, saveUrl) {
|
||||
var input = document.getElementById("preset-name-input");
|
||||
var name = input ? input.value.trim() : "";
|
||||
if (!name) {
|
||||
if (input) input.classList.add("border-red-500");
|
||||
return;
|
||||
}
|
||||
|
||||
var filterInput = document.getElementById(filterInputId);
|
||||
var form = document.getElementById(formId);
|
||||
var filterObj = form ? buildFilterJSON(form) : {};
|
||||
|
||||
var body = new URLSearchParams();
|
||||
body.append("name", name);
|
||||
var mode = "games";
|
||||
var path = window.location.pathname;
|
||||
if (path.indexOf("session") !== -1) mode = "sessions";
|
||||
else if (path.indexOf("purchase") !== -1) mode = "purchases";
|
||||
else if (path.indexOf("device") !== -1) mode = "devices";
|
||||
else if (path.indexOf("platform") !== -1) mode = "platforms";
|
||||
else if (path.indexOf("playevent") !== -1) mode = "playevents";
|
||||
body.append("mode", mode);
|
||||
body.append("filter", JSON.stringify(filterObj));
|
||||
|
||||
fetch(saveUrl, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"X-CSRFToken": getCsrfToken(),
|
||||
},
|
||||
body: body.toString(),
|
||||
})
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error("Save failed");
|
||||
// Reset UI
|
||||
if (input) {
|
||||
input.value = "";
|
||||
input.classList.add("hidden");
|
||||
input.classList.remove("border-red-500");
|
||||
}
|
||||
var saveBtn = document.getElementById("save-preset-btn");
|
||||
var confirmBtn = document.getElementById("confirm-save-preset-btn");
|
||||
if (saveBtn) saveBtn.classList.remove("hidden");
|
||||
if (confirmBtn) confirmBtn.classList.add("hidden");
|
||||
// Refresh the preset list
|
||||
loadPresets();
|
||||
})
|
||||
.catch(function (err) {
|
||||
console.error("Failed to save preset:", err);
|
||||
});
|
||||
};
|
||||
|
||||
/** Extract CSRF token from the page. */
|
||||
function getCsrfToken() {
|
||||
var cookie = document.cookie
|
||||
.split("; ")
|
||||
.find(function (row) {
|
||||
return row.startsWith("csrftoken=");
|
||||
});
|
||||
if (cookie) return cookie.split("=")[1];
|
||||
var el = document.querySelector('input[name="csrfmiddlewaretoken"]');
|
||||
return el ? el.value : "";
|
||||
}
|
||||
|
||||
// ── Init on page load ───────────────────────────────────────────────────
|
||||
|
||||
// ── Inject the search input into a filter form ──
|
||||
function injectSearchInput(form) {
|
||||
if (form.querySelector('[name="filter-search"]')) return; // already added
|
||||
var input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.name = "filter-search";
|
||||
input.placeholder = "Search\u2026";
|
||||
input.className = "block w-full rounded-base border border-default-medium bg-neutral-secondary-medium text-sm text-heading p-2 mb-4 focus:ring-brand focus:border-brand";
|
||||
// Pre-fill from existing filter JSON
|
||||
var hidden = form.querySelector('[name="filter"]');
|
||||
if (hidden && hidden.parentNode) {
|
||||
try {
|
||||
var existing = JSON.parse(hidden.value || "{}");
|
||||
if (existing.search && existing.search.value) {
|
||||
input.value = existing.search.value;
|
||||
}
|
||||
} catch (e) {}
|
||||
hidden.parentNode.insertBefore(input, hidden.nextSibling);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable deselect-on-click behavior for filter radio buttons.
|
||||
*/
|
||||
function setupDeselectableRadios() {
|
||||
document.querySelectorAll('input[type="radio"]').forEach(function (radio) {
|
||||
radio.addEventListener('click', function (e) {
|
||||
if (this.wasChecked) {
|
||||
this.checked = false;
|
||||
this.wasChecked = false;
|
||||
this.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
} else {
|
||||
var name = this.getAttribute('name');
|
||||
if (name) {
|
||||
document.querySelectorAll('input[type="radio"][name="' + name + '"]').forEach(function (r) {
|
||||
r.wasChecked = false;
|
||||
});
|
||||
}
|
||||
this.wasChecked = true;
|
||||
}
|
||||
});
|
||||
if (radio.checked) {
|
||||
radio.wasChecked = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up event listeners for string modifier radio buttons.
|
||||
*/
|
||||
function setupStringFilters() {
|
||||
document.querySelectorAll('input[data-string-modifier-radio]').forEach(function (radio) {
|
||||
radio.addEventListener('change', function () {
|
||||
window.toggleStringFilterInput(this);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onSwap('[id^="filter-bar-form"]', function (form) {
|
||||
injectSearchInput(form);
|
||||
setupDeselectableRadios();
|
||||
setupStringFilters();
|
||||
loadPresets();
|
||||
});
|
||||
})();
|
||||
Vendored
+2
File diff suppressed because one or more lines are too long
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Range slider — custom draggable handles (no native <input type=range>).
|
||||
*
|
||||
* Supports two modes on each slider, toggled via the .range-mode-toggle button:
|
||||
* range (default) — two handles, min ≤ max constraint
|
||||
* point — single handle, sets both number inputs to the same value
|
||||
*
|
||||
* Handles track-fill positioning and sync between handles and the connected
|
||||
* number inputs (linked via data-target attributes).
|
||||
*/
|
||||
import { onSwap } from "./utils.js";
|
||||
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
function initializeSlider(slider) {
|
||||
var mode = slider.getAttribute("data-mode") || "range";
|
||||
var trackFill = slider.querySelector(".range-track-fill");
|
||||
var minHandle = slider.querySelector(".range-handle-min");
|
||||
var maxHandle = slider.querySelector(".range-handle-max");
|
||||
if (!minHandle || !maxHandle) return;
|
||||
|
||||
var minTarget = document.getElementById(
|
||||
minHandle.getAttribute("data-target")
|
||||
);
|
||||
var maxTarget = document.getElementById(
|
||||
maxHandle.getAttribute("data-target")
|
||||
);
|
||||
var dataMin = parseInt(slider.getAttribute("data-min"), 10);
|
||||
var dataMax = parseInt(slider.getAttribute("data-max"), 10);
|
||||
var step = parseInt(slider.getAttribute("data-step"), 10) || 1;
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
function valueToPercent(value) {
|
||||
return ((value - dataMin) / (dataMax - dataMin)) * 100;
|
||||
}
|
||||
function percentToValue(percent) {
|
||||
var raw = dataMin + (percent / 100) * (dataMax - dataMin);
|
||||
return Math.round(raw / step) * step;
|
||||
}
|
||||
function clamp(value, lo, hi) {
|
||||
return Math.max(lo, Math.min(hi, value));
|
||||
}
|
||||
|
||||
function getTargetValue(target, defaultVal) {
|
||||
if (!target || target.value === "") return defaultVal;
|
||||
var parsed = parseInt(target.value, 10);
|
||||
return isNaN(parsed) ? defaultVal : parsed;
|
||||
}
|
||||
function setTargetValue(target, value) {
|
||||
if (target) target.value = value;
|
||||
}
|
||||
|
||||
// ── Track fill positioning ──
|
||||
|
||||
function updateTrackFill() {
|
||||
if (!trackFill) return;
|
||||
var minVal = clamp(getTargetValue(minTarget, dataMin), dataMin, dataMax);
|
||||
var maxVal = clamp(getTargetValue(maxTarget, dataMax), dataMin, dataMax);
|
||||
if (mode === "point") {
|
||||
trackFill.style.left = "0%";
|
||||
trackFill.style.width = valueToPercent(maxVal) + "%";
|
||||
} else {
|
||||
var leftPct = valueToPercent(minVal);
|
||||
var rightPct = valueToPercent(maxVal);
|
||||
if (leftPct > rightPct) {
|
||||
var tmp = leftPct;
|
||||
leftPct = rightPct;
|
||||
rightPct = tmp;
|
||||
}
|
||||
var widthPct = rightPct - leftPct;
|
||||
trackFill.style.left = leftPct + "%";
|
||||
trackFill.style.width = widthPct + "%";
|
||||
}
|
||||
}
|
||||
|
||||
function updateHandles() {
|
||||
var minVal = clamp(getTargetValue(minTarget, dataMin), dataMin, dataMax);
|
||||
var maxVal = clamp(getTargetValue(maxTarget, dataMax), dataMin, dataMax);
|
||||
minHandle.style.left = valueToPercent(minVal) + "%";
|
||||
maxHandle.style.left = valueToPercent(maxVal) + "%";
|
||||
updateTrackFill();
|
||||
}
|
||||
|
||||
// ── Dragging ──
|
||||
|
||||
function makeDraggable(handle, isMin) {
|
||||
handle.addEventListener("mousedown", function (e) {
|
||||
e.preventDefault();
|
||||
var rect = slider.getBoundingClientRect();
|
||||
|
||||
function onMove(ev) {
|
||||
var pct = ((ev.clientX - rect.left) / rect.width) * 100;
|
||||
var value = percentToValue(clamp(pct, 0, 100));
|
||||
|
||||
if (mode === "point") {
|
||||
setTargetValue(minTarget, value);
|
||||
setTargetValue(maxTarget, value);
|
||||
if (minTarget)
|
||||
minTarget.dispatchEvent(
|
||||
new Event("input", { bubbles: true })
|
||||
);
|
||||
if (maxTarget)
|
||||
maxTarget.dispatchEvent(
|
||||
new Event("input", { bubbles: true })
|
||||
);
|
||||
} else if (isMin) {
|
||||
setTargetValue(
|
||||
minTarget,
|
||||
clamp(value, dataMin, getTargetValue(maxTarget, dataMax))
|
||||
);
|
||||
if (minTarget)
|
||||
minTarget.dispatchEvent(
|
||||
new Event("input", { bubbles: true })
|
||||
);
|
||||
} else {
|
||||
setTargetValue(
|
||||
maxTarget,
|
||||
clamp(value, getTargetValue(minTarget, dataMin), dataMax)
|
||||
);
|
||||
if (maxTarget)
|
||||
maxTarget.dispatchEvent(
|
||||
new Event("input", { bubbles: true })
|
||||
);
|
||||
}
|
||||
updateHandles();
|
||||
}
|
||||
|
||||
function onUp() {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
}
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
onMove(e);
|
||||
});
|
||||
}
|
||||
|
||||
makeDraggable(minHandle, true);
|
||||
makeDraggable(maxHandle, false);
|
||||
|
||||
// ── Sync from number inputs back to handles ──
|
||||
|
||||
function syncFromInputs(e) {
|
||||
if (mode === "point") {
|
||||
var src = (e && e.target) || minTarget || maxTarget;
|
||||
var val = src ? src.value : "";
|
||||
setTargetValue(minTarget, val);
|
||||
setTargetValue(maxTarget, val);
|
||||
} else if (e && e.target) {
|
||||
var minVal = getTargetValue(minTarget, dataMin);
|
||||
var maxVal = getTargetValue(maxTarget, dataMax);
|
||||
if (e.target === minTarget) {
|
||||
if (minVal > maxVal) {
|
||||
setTargetValue(maxTarget, minVal);
|
||||
}
|
||||
} else if (e.target === maxTarget) {
|
||||
if (maxVal < minVal) {
|
||||
setTargetValue(minTarget, maxVal);
|
||||
}
|
||||
}
|
||||
}
|
||||
updateHandles();
|
||||
}
|
||||
|
||||
function enforceStrictBounds(e) {
|
||||
if (e && e.target) {
|
||||
var val = parseInt(e.target.value, 10);
|
||||
if (!isNaN(val)) {
|
||||
var clamped = clamp(val, dataMin, dataMax);
|
||||
if (clamped !== val) {
|
||||
setTargetValue(e.target, clamped);
|
||||
e.target.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (minTarget) {
|
||||
minTarget.addEventListener("input", syncFromInputs);
|
||||
minTarget.addEventListener("change", enforceStrictBounds);
|
||||
}
|
||||
if (maxTarget) {
|
||||
maxTarget.addEventListener("input", syncFromInputs);
|
||||
maxTarget.addEventListener("change", enforceStrictBounds);
|
||||
}
|
||||
|
||||
// ── Mode toggle ──
|
||||
|
||||
var block = slider.closest(".range-slider-block");
|
||||
var toggleButton =
|
||||
block && block.querySelector(".range-mode-toggle");
|
||||
if (toggleButton) {
|
||||
toggleButton.addEventListener("click", function () {
|
||||
var newMode = mode === "range" ? "point" : "range";
|
||||
slider.setAttribute("data-mode", newMode);
|
||||
|
||||
// Swap toggle icons
|
||||
var iconRange = toggleButton.querySelector(
|
||||
".range-mode-icon-range"
|
||||
);
|
||||
var iconPoint = toggleButton.querySelector(
|
||||
".range-mode-icon-point"
|
||||
);
|
||||
if (iconRange) iconRange.classList.toggle("hidden");
|
||||
if (iconPoint) iconPoint.classList.toggle("hidden");
|
||||
|
||||
var dashSpan = block && block.querySelector(".range-dash");
|
||||
if (newMode === "point") {
|
||||
minHandle.style.display = "none";
|
||||
setTargetValue(minTarget, maxTarget ? maxTarget.value : "");
|
||||
if (minTarget) minTarget.classList.add("hidden");
|
||||
if (dashSpan) dashSpan.classList.add("hidden");
|
||||
} else {
|
||||
minHandle.style.display = "";
|
||||
if (minTarget) minTarget.classList.remove("hidden");
|
||||
if (dashSpan) dashSpan.classList.remove("hidden");
|
||||
}
|
||||
mode = newMode;
|
||||
updateHandles();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Initial position ──
|
||||
updateHandles();
|
||||
}
|
||||
|
||||
onSwap(".range-slider", initializeSlider);
|
||||
})();
|
||||
@@ -0,0 +1,664 @@
|
||||
/**
|
||||
* SearchSelect widget — a search box paired with a dropdown of options.
|
||||
* Multi-select renders chosen items as removable pills (inline with the search
|
||||
* box), each backed by a hidden <input>. Single-select renders no pill: the
|
||||
* committed label lives inside the search box (which doubles as a combobox —
|
||||
* focus clears it to search, picking an option fills it), with a lone hidden
|
||||
* <input> carrying the value. Both keep hidden inputs so Django validation works.
|
||||
*
|
||||
* Filter mode (data-search-select-mode="filter", rendered by FilterSelect): value rows
|
||||
* carry +/− buttons that add include (✓) / exclude (✗) pills, plus pinned
|
||||
* modifier pseudo-options ((Any)/(None)) that are mutually exclusive with value
|
||||
* pills. Filter widgets have no hidden inputs; readSearchSelect serialises their
|
||||
* state into data-included / data-excluded / data-modifier for the filter bar.
|
||||
*
|
||||
* Widgets are initialized via onSwap() (utils.js), which covers the initial
|
||||
* page load and every htmx-swapped fragment, once per widget.
|
||||
*
|
||||
* Dynamically-added rows and pills are cloned from hidden <template> elements
|
||||
* the server renders with the same Python components (Pill / SearchSelect /
|
||||
* FilterSelect). The JS only fills in the label slot ([data-search-select-label]), value,
|
||||
* and data-* attributes — so all markup and Tailwind class strings live in one
|
||||
* place (the Python components), never duplicated here.
|
||||
*/
|
||||
import { onSwap } from "./utils.js";
|
||||
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
const DEBOUNCE_MS = 100;
|
||||
|
||||
// Must match Python common/components/filters.py:_PRESENCE_MODIFIERS.
|
||||
// These modifiers are mutually exclusive with value pills — selecting
|
||||
// one clears all value pills. Non-presence modifiers (INCLUDES_ALL,
|
||||
// INCLUDES_ONLY) coexist with value pills.
|
||||
const PRESENCE_MODIFIERS = ["NOT_NULL", "IS_NULL"];
|
||||
|
||||
const initWidget = (container) => {
|
||||
const search = container.querySelector("[data-search-select-search]");
|
||||
const options = container.querySelector("[data-search-select-options]");
|
||||
const pills = container.querySelector("[data-search-select-pills]");
|
||||
if (!search || !options || !pills) return;
|
||||
|
||||
const name = container.getAttribute("data-name");
|
||||
const searchUrl = container.getAttribute("data-search-url");
|
||||
const isFilter = container.getAttribute("data-search-select-mode") === "filter";
|
||||
const freeText = container.getAttribute("data-search-select-free-text") === "true";
|
||||
const multi = container.getAttribute("data-multi") === "true";
|
||||
const alwaysVisible = container.getAttribute("data-always-visible") === "true";
|
||||
const prefetch = parseInt(container.getAttribute("data-prefetch"), 10) || 0;
|
||||
const syncUrl = container.getAttribute("data-sync-url") === "true";
|
||||
|
||||
const noResults = options.querySelector("[data-search-select-no-results]");
|
||||
let debounceTimer = null;
|
||||
let pendingRequest = null; // in-flight AbortController, so newer queries win
|
||||
let hasPrefetched = false;
|
||||
|
||||
const hasVisibleContent = () => {
|
||||
const optionRows = options.querySelectorAll("[data-search-select-option]");
|
||||
for (let i = 0; i < optionRows.length; i++) {
|
||||
if (optionRows[i].style.display !== "none") return true;
|
||||
}
|
||||
if (noResults && !noResults.classList.contains("hidden")) return true;
|
||||
if (options.querySelector("[data-search-select-modifier-option]")) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const showPanel = () => {
|
||||
if (alwaysVisible || hasVisibleContent()) {
|
||||
options.classList.remove("hidden");
|
||||
}
|
||||
};
|
||||
const hidePanel = () => {
|
||||
if (!alwaysVisible) options.classList.add("hidden");
|
||||
};
|
||||
|
||||
const setNoResults = (visible) => {
|
||||
if (!noResults) return;
|
||||
noResults.classList.toggle("hidden", !visible);
|
||||
if (visible) showPanel();
|
||||
};
|
||||
|
||||
// ── Highlight tracking (filter mode) ──
|
||||
let highlightedRow = null;
|
||||
|
||||
const highlightOption = (row) => {
|
||||
clearHighlight();
|
||||
if (!row) return;
|
||||
row.setAttribute("data-search-select-highlighted", "");
|
||||
highlightedRow = row;
|
||||
row.scrollIntoView({ block: "nearest" });
|
||||
};
|
||||
|
||||
const clearHighlight = () => {
|
||||
if (highlightedRow) {
|
||||
highlightedRow.removeAttribute("data-search-select-highlighted");
|
||||
highlightedRow = null;
|
||||
}
|
||||
};
|
||||
|
||||
const getVisibleOptions = () => {
|
||||
const all = options.querySelectorAll("[data-search-select-option]");
|
||||
return Array.from(all).filter(row => row.style.display !== "none");
|
||||
};
|
||||
|
||||
const autoHighlight = (query) => {
|
||||
const visible = getVisibleOptions();
|
||||
if (visible.length === 0) {
|
||||
clearHighlight();
|
||||
return;
|
||||
}
|
||||
const lower = query.toLowerCase();
|
||||
// 1. Starts-with match
|
||||
for (let i = 0; i < visible.length; i++) {
|
||||
const label = (visible[i].getAttribute("data-label") || "").toLowerCase();
|
||||
if (lower && label.startsWith(lower)) {
|
||||
highlightOption(visible[i]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 2. Substring match (fuzzy-lite)
|
||||
for (let j = 0; j < visible.length; j++) {
|
||||
const subLabel = (visible[j].getAttribute("data-label") || "").toLowerCase();
|
||||
if (lower && subLabel.includes(lower)) {
|
||||
highlightOption(visible[j]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 3. Fallback: first visible option
|
||||
highlightOption(visible[0]);
|
||||
};
|
||||
|
||||
// Get active values in both form and filter modes
|
||||
const getSelectedValues = () => {
|
||||
const vals = new Set();
|
||||
pills.querySelectorAll('input[type="hidden"]').forEach(input => {
|
||||
vals.add(input.value);
|
||||
});
|
||||
pills.querySelectorAll("[data-pill]").forEach(pill => {
|
||||
const val = pill.getAttribute("data-value");
|
||||
if (val) vals.add(val);
|
||||
});
|
||||
return vals;
|
||||
};
|
||||
|
||||
// ── Render server-fetched rows into the panel ──
|
||||
const renderRows = (items) => {
|
||||
const selectedVals = getSelectedValues();
|
||||
const preservedOptions = [];
|
||||
|
||||
// Extract existing option data for currently selected values before removing
|
||||
options.querySelectorAll("[data-search-select-option]").forEach(row => {
|
||||
const val = row.getAttribute("data-value");
|
||||
if (selectedVals.has(val)) {
|
||||
preservedOptions.push(optionFromRow(row));
|
||||
}
|
||||
row.remove();
|
||||
});
|
||||
|
||||
const renderedValues = new Set();
|
||||
|
||||
// Render preserved options first (to keep them at the top)
|
||||
preservedOptions.forEach(opt => {
|
||||
options.insertBefore(buildRow(opt), noResults || null);
|
||||
renderedValues.add(String(opt.value));
|
||||
});
|
||||
|
||||
// Render newly fetched items (excluding already rendered preserved ones)
|
||||
// Fix DOM-limit vs fetch mismatch: Do not slice the items, render all returned items.
|
||||
items.forEach(item => {
|
||||
if (!renderedValues.has(String(item.value))) {
|
||||
options.insertBefore(buildRow(item), noResults || null);
|
||||
renderedValues.add(String(item.value));
|
||||
}
|
||||
});
|
||||
|
||||
showPanel();
|
||||
};
|
||||
|
||||
// ── Clone a server-rendered <template> prototype by name. The server emits
|
||||
// the mode-appropriate prototypes, so the JS never names a class. ──
|
||||
const cloneTemplate = (name) => {
|
||||
const template = container.querySelector(`template[data-search-select-template="${name}"]`);
|
||||
return template
|
||||
? template.content.firstElementChild.cloneNode(true)
|
||||
: null;
|
||||
};
|
||||
|
||||
const setLabel = (node, label) => {
|
||||
const slot = node.querySelector("[data-search-select-label]");
|
||||
if (slot) slot.textContent = label;
|
||||
};
|
||||
|
||||
const applyData = (node, data = {}) => {
|
||||
Object.keys(data).forEach(key => {
|
||||
node.setAttribute(`data-${key}`, data[key]);
|
||||
});
|
||||
};
|
||||
|
||||
// Build an option row by cloning the "row" template (the same prototype the
|
||||
// server renders, so fetched and pre-rendered rows are identical).
|
||||
const buildRow = (option) => {
|
||||
const row = cloneTemplate("row");
|
||||
if (!row) return document.createComment("ss-row");
|
||||
row.setAttribute("data-value", option.value);
|
||||
row.setAttribute("data-label", option.label);
|
||||
applyData(row, option.data);
|
||||
setLabel(row, option.label);
|
||||
row._searchSelectOption = option;
|
||||
return row;
|
||||
};
|
||||
|
||||
// ── Client-side filter of the currently loaded rows. Returns the number of
|
||||
// visible rows so the caller decides whether to show the no-results node. ──
|
||||
const filterRows = (query) => {
|
||||
const lower = query.toLowerCase();
|
||||
let visibleCount = 0;
|
||||
options.querySelectorAll("[data-search-select-option]").forEach(item => {
|
||||
const label = (item.getAttribute("data-label") || "").toLowerCase();
|
||||
const match = label.includes(lower);
|
||||
item.style.display = match ? "" : "none";
|
||||
if (match) visibleCount += 1;
|
||||
});
|
||||
return visibleCount;
|
||||
};
|
||||
|
||||
// ── Fetch matching rows from the server. The previous in-flight request is
|
||||
// aborted so a slower earlier response can never overwrite a newer one. ──
|
||||
const fetchFromServer = (query) => {
|
||||
if (pendingRequest) pendingRequest.abort();
|
||||
pendingRequest = new AbortController();
|
||||
let url = `${searchUrl}?q=${encodeURIComponent(query)}`;
|
||||
if (prefetch && !query) url += `&limit=${prefetch}`;
|
||||
fetch(url, { credentials: "same-origin", signal: pendingRequest.signal })
|
||||
.then(response => response.json())
|
||||
.then(items => {
|
||||
pendingRequest = null;
|
||||
renderRows(items);
|
||||
// Re-apply the live query: the box may hold more text than was sent.
|
||||
setNoResults(filterRows(search.value.trim()) === 0);
|
||||
autoHighlight(search.value.trim());
|
||||
})
|
||||
.catch(error => {
|
||||
if (error?.name === "AbortError") return; // superseded
|
||||
pendingRequest = null;
|
||||
setNoResults(true);
|
||||
});
|
||||
};
|
||||
|
||||
// In free-text mode the typed text is the value itself: there is no
|
||||
// backing list, so we rebuild a single ephemeral option row reflecting the
|
||||
// current query so the +/− buttons (or Enter) can commit it as a pill.
|
||||
const rebuildFreeTextRow = (query) => {
|
||||
options.querySelectorAll("[data-search-select-option]").forEach(row => row.remove());
|
||||
if (!query) {
|
||||
setNoResults(false);
|
||||
clearHighlight();
|
||||
return;
|
||||
}
|
||||
const row = buildRow({ value: query, label: query, data: {} });
|
||||
options.insertBefore(row, noResults || null);
|
||||
setNoResults(false);
|
||||
highlightOption(row);
|
||||
};
|
||||
|
||||
// Called on every keystroke. With a search_url, filter the loaded window
|
||||
// instantly (zero latency) and debounce a server request for the rest;
|
||||
// no-results stays hidden until the response decides it, to avoid a flash
|
||||
// over an incomplete window. Without a search_url the loaded set is complete,
|
||||
// so the client-side filter is authoritative.
|
||||
const runSearch = () => {
|
||||
const query = search.value.trim();
|
||||
if (freeText) {
|
||||
rebuildFreeTextRow(query);
|
||||
showPanel();
|
||||
return;
|
||||
}
|
||||
if (searchUrl) {
|
||||
filterRows(query);
|
||||
setNoResults(false);
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
fetchFromServer(query);
|
||||
}, DEBOUNCE_MS);
|
||||
} else {
|
||||
setNoResults(filterRows(query) === 0);
|
||||
}
|
||||
autoHighlight(query);
|
||||
showPanel();
|
||||
};
|
||||
|
||||
// ── Single-select combobox: the search box shows the committed label;
|
||||
// focusing clears it to search, blurring restores it (or deselects). ──
|
||||
if (!multi) container._searchSelectLabel = search.value;
|
||||
|
||||
search.addEventListener("focus", () => {
|
||||
if (!multi) {
|
||||
// Hide the committed label so the box becomes a fresh search field.
|
||||
search.value = "";
|
||||
container._searchSelectDirty = false;
|
||||
}
|
||||
if (freeText) {
|
||||
rebuildFreeTextRow(search.value.trim());
|
||||
} else if (searchUrl) {
|
||||
if (prefetch && !hasPrefetched) {
|
||||
// Seed the window immediately on first open (not debounced).
|
||||
hasPrefetched = true;
|
||||
fetchFromServer("");
|
||||
} else {
|
||||
// Show whatever is already loaded; the server decides no-results.
|
||||
filterRows(search.value.trim());
|
||||
setNoResults(false);
|
||||
autoHighlight(search.value.trim());
|
||||
}
|
||||
} else {
|
||||
setNoResults(filterRows(search.value.trim()) === 0);
|
||||
autoHighlight(search.value.trim());
|
||||
}
|
||||
showPanel();
|
||||
});
|
||||
|
||||
search.addEventListener("input", () => {
|
||||
clearHighlight();
|
||||
if (!multi) {
|
||||
if (!container._searchSelectDirty) {
|
||||
const label = container._searchSelectLabel || "";
|
||||
if (search.value.startsWith(label)) {
|
||||
search.value = search.value.slice(label.length);
|
||||
}
|
||||
container._searchSelectDirty = true;
|
||||
}
|
||||
}
|
||||
runSearch();
|
||||
});
|
||||
|
||||
if (!multi) {
|
||||
search.addEventListener("blur", () => {
|
||||
// Defer so an option click (which fires before blur settles) wins.
|
||||
setTimeout(() => {
|
||||
if (container._searchSelectDirty && search.value.trim() === "") {
|
||||
// User intentionally cleared the box → deselect.
|
||||
pills.innerHTML = "";
|
||||
container._searchSelectLabel = "";
|
||||
emitChange(null);
|
||||
} else {
|
||||
// Focused-and-left, or typed a partial query without picking →
|
||||
// restore the committed label (no-op right after a selection).
|
||||
search.value = container._searchSelectLabel || "";
|
||||
}
|
||||
}, 120);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Keyboard navigation (both form and filter modes) ──
|
||||
search.addEventListener("keydown", (event) => {
|
||||
const { key } = event;
|
||||
|
||||
if (!multi && key === "Backspace" && !container._searchSelectDirty) {
|
||||
event.preventDefault();
|
||||
search.value = "";
|
||||
search.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!["ArrowDown", "ArrowUp", "Enter", "Escape"].includes(key)) return;
|
||||
const visible = getVisibleOptions();
|
||||
if (visible.length === 0) {
|
||||
if (key === "Escape") hidePanel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
showPanel();
|
||||
const downIdx = highlightedRow ? visible.indexOf(highlightedRow) : -1;
|
||||
highlightOption(visible[(downIdx + 1) % visible.length]);
|
||||
} else if (key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
showPanel();
|
||||
const upIdx = highlightedRow ? visible.indexOf(highlightedRow) : -1;
|
||||
highlightOption(visible[(upIdx - 1 + visible.length) % visible.length]);
|
||||
} else if (key === "Enter") {
|
||||
if (highlightedRow) {
|
||||
event.preventDefault();
|
||||
const option = optionFromRow(highlightedRow);
|
||||
if (isFilter) {
|
||||
addFilterPill(option, "include");
|
||||
search.value = "";
|
||||
} else {
|
||||
selectOption(option);
|
||||
}
|
||||
clearHighlight();
|
||||
hidePanel();
|
||||
}
|
||||
} else if (key === "Escape") {
|
||||
clearHighlight();
|
||||
hidePanel();
|
||||
}
|
||||
});
|
||||
|
||||
// Clicking an option must not blur the input before the click selects.
|
||||
options.addEventListener("mousedown", (event) => {
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
// ── Option click → select (form mode) or include/exclude (filter mode) ──
|
||||
options.addEventListener("click", (event) => {
|
||||
if (isFilter) {
|
||||
handleFilterOptionClick(event);
|
||||
return;
|
||||
}
|
||||
const row = event.target.closest("[data-search-select-option]");
|
||||
if (!row) return;
|
||||
selectOption(optionFromRow(row));
|
||||
});
|
||||
|
||||
const handleFilterOptionClick = (event) => {
|
||||
// Pinned modifier pseudo-option → set the (mutually exclusive) modifier.
|
||||
const modifierRow = event.target.closest("[data-search-select-modifier-option]");
|
||||
if (modifierRow) {
|
||||
setModifier(
|
||||
modifierRow.getAttribute("data-search-select-modifier-option"),
|
||||
modifierRow.getAttribute("data-label")
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Include / exclude button on a value row.
|
||||
const button = event.target.closest("[data-search-select-action]");
|
||||
if (button) {
|
||||
const row = button.closest("[data-search-select-option]");
|
||||
if (!row) return;
|
||||
addFilterPill(optionFromRow(row), button.getAttribute("data-search-select-action"));
|
||||
return;
|
||||
}
|
||||
// Click on the option row itself → include.
|
||||
const optionRow = event.target.closest("[data-search-select-option]");
|
||||
if (optionRow) {
|
||||
addFilterPill(optionFromRow(optionRow), "include");
|
||||
}
|
||||
};
|
||||
|
||||
// Add (or re-type) an include/exclude pill for a value. Selecting any value
|
||||
// clears a presence modifier — NOT_NULL / IS_NULL are mutually exclusive
|
||||
// with value pills. Non-presence modifiers (INCLUDES_ALL / INCLUDES_ONLY)
|
||||
// persist alongside value pills.
|
||||
const addFilterPill = (option, kind) => {
|
||||
const modPill = pills.querySelector("[data-search-select-modifier]");
|
||||
if (modPill) {
|
||||
const modVal = modPill.getAttribute("data-search-select-modifier");
|
||||
if (PRESENCE_MODIFIERS.includes(modVal)) {
|
||||
clearModifier();
|
||||
}
|
||||
}
|
||||
const existing = pills.querySelector(
|
||||
`[data-pill][data-value="${cssEscape(option.value)}"]`
|
||||
);
|
||||
if (existing) existing.remove();
|
||||
pills.appendChild(buildFilterValuePill(option, kind));
|
||||
search.value = "";
|
||||
emitChange(null);
|
||||
};
|
||||
|
||||
const buildFilterValuePill = (option, kind) => {
|
||||
const pill = cloneTemplate(kind === "include" ? "pill-include" : "pill-exclude");
|
||||
pill.setAttribute("data-value", option.value);
|
||||
pill.setAttribute("data-label", option.label);
|
||||
applyData(pill, option.data);
|
||||
setLabel(pill, option.label);
|
||||
return pill;
|
||||
};
|
||||
|
||||
// Set the modifier pill. Presence modifiers (NOT_NULL / IS_NULL) clear all
|
||||
// value pills — they are mutually exclusive. Non-presence modifiers
|
||||
// (INCLUDES_ALL / INCLUDES_ONLY) are prepended before existing value pills.
|
||||
const setModifier = (modifierValue, label) => {
|
||||
// Remove any existing modifier pill to avoid duplicates.
|
||||
clearModifierPill();
|
||||
if (PRESENCE_MODIFIERS.includes(modifierValue)) {
|
||||
pills.innerHTML = "";
|
||||
}
|
||||
const pill = cloneTemplate("pill-modifier");
|
||||
pill.setAttribute("data-search-select-modifier", modifierValue);
|
||||
setLabel(pill, label);
|
||||
pills.insertBefore(pill, pills.firstChild);
|
||||
container.setAttribute("data-modifier", modifierValue);
|
||||
hidePanel();
|
||||
emitChange(null);
|
||||
};
|
||||
|
||||
// Remove the modifier pill and its container attribute. Safe to call when
|
||||
// there is no modifier pill (no-op). Does not touch value pills.
|
||||
const clearModifierPill = () => {
|
||||
const modifierPill = pills.querySelector("[data-search-select-modifier]");
|
||||
if (modifierPill) modifierPill.remove();
|
||||
container.removeAttribute("data-modifier");
|
||||
};
|
||||
|
||||
const clearModifier = () => {
|
||||
clearModifierPill();
|
||||
};
|
||||
|
||||
const optionFromRow = (row) => {
|
||||
if (row._searchSelectOption) return row._searchSelectOption;
|
||||
const data = {};
|
||||
Object.keys(row.dataset).forEach(key => {
|
||||
if (key !== "value" && key !== "label" && key !== "ssOption") {
|
||||
data[key] = row.dataset[key];
|
||||
}
|
||||
});
|
||||
return {
|
||||
value: row.getAttribute("data-value"),
|
||||
label: row.getAttribute("data-label"),
|
||||
data,
|
||||
};
|
||||
};
|
||||
|
||||
const selectOption = (option) => {
|
||||
if (multi) {
|
||||
if (!pills.querySelector(`input[value="${cssEscape(option.value)}"]`)) {
|
||||
addPill(option);
|
||||
}
|
||||
search.value = "";
|
||||
} else {
|
||||
// Single-select: no pill — show the label in the search box and keep a
|
||||
// lone hidden input under [data-search-select-pills] for submission.
|
||||
pills.innerHTML = "";
|
||||
pills.appendChild(buildHidden(option.value));
|
||||
search.value = option.label;
|
||||
container._searchSelectLabel = option.label;
|
||||
container._searchSelectDirty = false;
|
||||
hidePanel();
|
||||
}
|
||||
emitChange(option);
|
||||
};
|
||||
|
||||
const addPill = (option) => {
|
||||
const pill = buildPill(option);
|
||||
if (pill) pills.appendChild(pill);
|
||||
pills.appendChild(buildHidden(option.value));
|
||||
};
|
||||
|
||||
const buildPill = (option) => {
|
||||
const pill = cloneTemplate("pill");
|
||||
if (!pill) return null;
|
||||
pill.setAttribute("data-value", option.value);
|
||||
applyData(pill, option.data);
|
||||
setLabel(pill, option.label);
|
||||
return pill;
|
||||
};
|
||||
|
||||
const buildHidden = (value) => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "hidden";
|
||||
input.name = name;
|
||||
input.value = value;
|
||||
return input;
|
||||
};
|
||||
|
||||
// ── Pill × → remove ──
|
||||
pills.addEventListener("click", (event) => {
|
||||
const removeButton = event.target.closest("[data-pill-remove]");
|
||||
if (!removeButton) return;
|
||||
const pill = removeButton.closest("[data-pill]");
|
||||
if (!pill) return;
|
||||
if (isFilter) {
|
||||
// Filter pills have no hidden input.
|
||||
if (pill.hasAttribute("data-search-select-modifier")) {
|
||||
clearModifierPill();
|
||||
} else {
|
||||
pill.remove();
|
||||
}
|
||||
emitChange(null);
|
||||
return;
|
||||
}
|
||||
const value = pill.getAttribute("data-value");
|
||||
pill.remove();
|
||||
const hidden = pills.querySelector(`input[value="${cssEscape(value)}"]`);
|
||||
if (hidden) hidden.remove();
|
||||
emitChange(null);
|
||||
});
|
||||
|
||||
const currentValues = () => {
|
||||
return Array.from(pills.querySelectorAll('input[type="hidden"]')).map(input => input.value);
|
||||
};
|
||||
|
||||
const emitChange = (last) => {
|
||||
const values = currentValues();
|
||||
if (syncUrl) syncToUrl(values);
|
||||
container.dispatchEvent(
|
||||
new CustomEvent("search-select:change", {
|
||||
bubbles: true,
|
||||
detail: { name, values, last },
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const syncToUrl = (values) => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.delete(name);
|
||||
values.forEach(v => {
|
||||
params.append(name, v);
|
||||
});
|
||||
const qs = params.toString();
|
||||
history.replaceState(null, "", qs ? `?${qs}` : window.location.pathname);
|
||||
};
|
||||
|
||||
// On init, restore from URL params if the server supplied no selected pills.
|
||||
if (syncUrl && !pills.querySelector("[data-pill]")) {
|
||||
const initial = new URLSearchParams(window.location.search).getAll(name);
|
||||
initial.forEach(v => {
|
||||
addPill({ value: v, label: v, data: {} });
|
||||
});
|
||||
}
|
||||
|
||||
// ── Close panel on outside click ──
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!container.contains(event.target)) hidePanel();
|
||||
});
|
||||
};
|
||||
|
||||
/** Minimal escape for use inside an attribute-value selector. */
|
||||
const cssEscape = (value) => String(value).replace(/["\\]/g, "\\$&");
|
||||
|
||||
// Serialise each widget's current state onto data-* attributes for the caller.
|
||||
// Form widgets expose data-values (the submitted hidden-input values); filter
|
||||
// widgets expose data-included / data-excluded / data-modifier for the filter
|
||||
// bar to read.
|
||||
window.readSearchSelect = (form) => {
|
||||
form.querySelectorAll("[data-search-select]").forEach(container => {
|
||||
const pills = container.querySelector("[data-search-select-pills]");
|
||||
if (container.getAttribute("data-search-select-mode") === "filter") {
|
||||
const included = [];
|
||||
const excluded = [];
|
||||
let modifier = "";
|
||||
if (pills) {
|
||||
pills.querySelectorAll("[data-pill]").forEach(pill => {
|
||||
const pillModifier = pill.getAttribute("data-search-select-modifier");
|
||||
if (pillModifier) {
|
||||
modifier = pillModifier; // last modifier pill wins
|
||||
return; // skip value extraction for this pill
|
||||
}
|
||||
const value = pill.getAttribute("data-value");
|
||||
const label = pill.getAttribute("data-label") || "";
|
||||
if (pill.getAttribute("data-search-select-type") === "exclude") {
|
||||
excluded.push({ id: value, label });
|
||||
} else {
|
||||
included.push({ id: value, label });
|
||||
}
|
||||
});
|
||||
}
|
||||
container.setAttribute("data-included", JSON.stringify(included));
|
||||
container.setAttribute("data-excluded", JSON.stringify(excluded));
|
||||
if (modifier) container.setAttribute("data-modifier", modifier);
|
||||
else container.removeAttribute("data-modifier");
|
||||
return;
|
||||
}
|
||||
const values = pills
|
||||
? Array.from(pills.querySelectorAll('input[type="hidden"]')).map(input => input.value)
|
||||
: [];
|
||||
container.setAttribute("data-values", JSON.stringify(values));
|
||||
});
|
||||
};
|
||||
|
||||
onSwap("[data-search-select]", initWidget);
|
||||
})();
|
||||
@@ -1,3 +1,28 @@
|
||||
/**
|
||||
* @description Runs initializeElement once for each element matching selector,
|
||||
* on initial page load and inside every htmx-swapped fragment (a port of
|
||||
* FastHTML's proc_htmx). htmx fires htmx:load for the initial document and for
|
||||
* each swapped-in element, so a single registration covers both; the WeakSet
|
||||
* guarantees once-per-element initialization, replacing the old
|
||||
* DOMContentLoaded + htmx:afterSwap + per-element guard-flag pattern.
|
||||
* @param {string} selector
|
||||
* @param {function(Element): void} initializeElement
|
||||
*/
|
||||
function onSwap(selector, initializeElement) {
|
||||
const initialized = new WeakSet();
|
||||
htmx.onLoad((swappedElement) => {
|
||||
const elements = Array.from(htmx.findAll(swappedElement, selector));
|
||||
if (swappedElement.matches && swappedElement.matches(selector)) {
|
||||
elements.unshift(swappedElement);
|
||||
}
|
||||
for (const element of elements) {
|
||||
if (initialized.has(element)) continue;
|
||||
initialized.add(element);
|
||||
initializeElement(element);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Formats Date to a UTC string accepted by the datetime-local input field.
|
||||
* @param {Date} date
|
||||
@@ -202,6 +227,7 @@ function disableElementsWhenTrue(targetSelect, targetValue, elementList) {
|
||||
}
|
||||
|
||||
export {
|
||||
onSwap,
|
||||
toISOUTCString,
|
||||
syncSelectInputUntilChanged,
|
||||
getEl,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { onSwap } from "./utils.js";
|
||||
|
||||
onSwap("#year-picker-input", function(pickerEl) {
|
||||
const selectedYear = pickerEl.dataset.selectedYear;
|
||||
const urlTemplate = pickerEl.dataset.urlTemplate;
|
||||
const currentYear = new Date().getFullYear();
|
||||
const availableYears = new Set(
|
||||
pickerEl.dataset.availableYears
|
||||
.split(",")
|
||||
.map(s => parseInt(s.trim()))
|
||||
.filter(n => !isNaN(n))
|
||||
);
|
||||
|
||||
const picker = new Datepicker(pickerEl, {
|
||||
pickLevel: 2,
|
||||
format: "yyyy",
|
||||
minDate: new Date(1999, 0, 1),
|
||||
maxDate: new Date(currentYear, 11, 31),
|
||||
autohide: false,
|
||||
orientation: "bottom end",
|
||||
showOnClick: false,
|
||||
showOnFocus: false,
|
||||
beforeShowYear: (date) => ({ enabled: availableYears.has(date.getFullYear()) }),
|
||||
});
|
||||
pickerEl._pickerInstance = picker;
|
||||
|
||||
picker.element.addEventListener("changeDate", (event) => {
|
||||
const year = event.detail.date?.getFullYear();
|
||||
if (year && urlTemplate) {
|
||||
window.location.href = urlTemplate.replace("__year__", year);
|
||||
}
|
||||
});
|
||||
|
||||
if (selectedYear) {
|
||||
picker.dates = [new Date(parseInt(selectedYear), 0, 1)];
|
||||
picker.update();
|
||||
}
|
||||
});
|
||||
+5
-3
@@ -4,10 +4,10 @@ import requests
|
||||
from django.db import models
|
||||
from django.template.defaultfilters import floatformat
|
||||
|
||||
logger = logging.getLogger("games")
|
||||
|
||||
from games.models import ExchangeRate, Purchase
|
||||
|
||||
logger = logging.getLogger("games")
|
||||
|
||||
# fixme: save preferred currency in user model
|
||||
currency_to = "CZK"
|
||||
currency_to = currency_to.upper()
|
||||
@@ -60,7 +60,9 @@ def _save_converted_price(purchase, converted_price, needs_update):
|
||||
purchase.converted_currency = currency_to
|
||||
if needs_update:
|
||||
purchase.needs_price_update = False
|
||||
purchase.save(update_fields=["converted_price", "converted_currency", "needs_price_update"])
|
||||
purchase.save(
|
||||
update_fields=["converted_price", "converted_currency", "needs_price_update"]
|
||||
)
|
||||
|
||||
|
||||
def convert_prices():
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
<c-layouts.add>
|
||||
</c-layouts.add>
|
||||
@@ -1,9 +0,0 @@
|
||||
<c-layouts.add>
|
||||
<c-slot name="additional_row">
|
||||
<c-button type="submit" color="gray"
|
||||
name="submit_and_redirect"
|
||||
>
|
||||
Submit & Create Purchase
|
||||
</c-button>
|
||||
</c-slot>
|
||||
</c-layouts.add>
|
||||
@@ -1,15 +0,0 @@
|
||||
<c-layouts.add>
|
||||
<c-slot name="additional_row">
|
||||
<tr>
|
||||
<td></td>
|
||||
<td>
|
||||
<c-button type="submit"
|
||||
color="gray"
|
||||
name="submit_and_redirect"
|
||||
>
|
||||
Submit & Create Session
|
||||
</c-button>
|
||||
</td>
|
||||
</tr>
|
||||
</c-slot>
|
||||
</c-layouts.add>
|
||||
@@ -1,38 +0,0 @@
|
||||
<c-layouts.add>
|
||||
<c-slot name="form_content">
|
||||
<div class="max-width-container">
|
||||
<div id="add-form" class="form-container max-w-xl mx-auto">
|
||||
<form method="post" enctype="multipart/form-data" class="">
|
||||
{% csrf_token %}
|
||||
{% for field in form %}
|
||||
<div>
|
||||
{{ field.label_tag }}
|
||||
{% if field.name == "note" %}
|
||||
{{ field }}
|
||||
{% else %}
|
||||
{{ field }}
|
||||
{% endif %}
|
||||
{% if field.name == "timestamp_start" or field.name == "timestamp_end" %}
|
||||
<span class="form-row-button-group flex-row gap-3 justify-start mt-3" hx-boost="false">
|
||||
<c-button data-target="{{ field.name }}" data-type="now" size="xs">Set to now</c-button>
|
||||
<c-button data-target="{{ field.name }}" data-type="toggle" size="xs">Toggle text</c-button>
|
||||
<c-button data-target="{{ field.name }}" data-type="copy" size="xs">
|
||||
Copy {%if field.name == "timestamp_start" %}start{% else %}end{% endif %} value to {%if field.name == "timestamp_start" %}end{% else %}start{% endif %}
|
||||
</c-button>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div>
|
||||
<c-button type="submit">
|
||||
Submit
|
||||
</c-button>
|
||||
</div>
|
||||
<div class="submit-button-container">
|
||||
{{ additional_row }}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</c-slot>
|
||||
</c-layouts.add>
|
||||
@@ -1,14 +0,0 @@
|
||||
<c-vars color="blue" size="base" type="button" />
|
||||
<button
|
||||
{% if hx_get %}hx-get="{{ hx_get }}"{% endif %}
|
||||
{% if hx_target %}hx-target="{{ hx_target }}"{% endif %}
|
||||
{% if hx_swap %}hx-swap="{{ hx_swap }}"{% endif %}
|
||||
{% if type %}type="{{ type }}"{% endif %}
|
||||
{% if title %}title="{{ title }}"{% endif %}
|
||||
{% if onclick %}onclick="{{ onclick }}"{% endif %}
|
||||
{% if data_target %}data-target="{{ data_target }}"{% endif %}
|
||||
{% if data_type %}data-type="{{ data_type }}"{% endif %}
|
||||
{% if name %}name="{{ name }}"{% endif %}
|
||||
class="{% if class %}{{ class }} {%else%}{%endif%}{% if color == "blue" %}text-white bg-brand box-border border border-transparent hover:bg-brand-strong focus:ring-4 focus:ring-brand-medium {% elif color == "red" %} bg-red-700 dark:bg-red-600 dark:focus:ring-red-900 dark:hover:bg-red-700 focus:ring-red-300 hover:bg-red-800 text-white {% elif color == "gray" %} bg-white border-gray-200 dark:bg-gray-800 dark:border-gray-600 dark:focus:ring-gray-700 dark:hover:bg-gray-700 dark:hover:text-white dark:text-gray-400 focus:ring-gray-100 hover:bg-gray-100 hover:text-blue-700 text-gray-900 border {% elif color == "green" %} bg-green-700 dark:bg-green-600 dark:focus:ring-green-800 dark:hover:bg-green-700 focus:ring-green-300 hover:bg-green-800 text-white {% endif %} leading-5 focus:outline-hidden focus:ring-4 font-medium mb-2 me-2 rounded-base {% if size == "xs" %} px-3 py-2 text-xs shadow-xs {% elif size == "sm" %} px-3 py-2 text-sm {% elif size == "base" %} px-5 py-2.5 text-sm {% elif size == "lg" %} px-5 py-3 text-base {% elif size == "xl" %} px-6 py-3.5 text-base {% endif %} {% if icon %} inline-flex text-center items-center gap-2 {% else %} {% endif %} ">
|
||||
{{ slot }}
|
||||
</button>
|
||||
@@ -1,8 +0,0 @@
|
||||
<div class="inline-flex rounded-md shadow-xs" role="group">
|
||||
{% if slot %}{{ slot }}{% endif %}
|
||||
{% for button in buttons %}
|
||||
{% if button.slot %}
|
||||
<c-button-group-button-sm :href=button.href :slot=button.slot :color=button.color :hover=button.hover :title=button.title :hx_get=button.hx_get :hx_target=button.hx_target :hx_swap=button.hx_swap />
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -1,26 +0,0 @@
|
||||
<c-vars color="gray" />
|
||||
<a href="{{ href }}"
|
||||
{% if hx_get %}hx-get="{{ hx_get }}"{% endif %}
|
||||
{% if hx_target %}hx-target="{{ hx_target }}"{% endif %}
|
||||
{% if click %}@click="{{ click }}"{% endif %}
|
||||
class="[&:first-of-type_button]:rounded-s-lg [&:last-of-type_button]:rounded-e-lg">
|
||||
{% if color == "gray" %}
|
||||
<button type="button"
|
||||
title="{{ title }}"
|
||||
class="px-2 py-1 text-xs font-medium text-gray-900 bg-white border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer">
|
||||
{{ slot }}
|
||||
</button>
|
||||
{% elif color == "red" %}
|
||||
<button type="button"
|
||||
title="{{ title }}"
|
||||
class="px-2 py-1 text-xs font-medium text-gray-900 bg-white border border-gray-200 hover:bg-red-500 hover:text-white focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:border-red-700 dark:hover:bg-red-700 dark:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer">
|
||||
{{ slot }}
|
||||
</button>
|
||||
{% elif color == "green" %}
|
||||
<button type="button"
|
||||
title="{{ title }}"
|
||||
class="px-2 py-1 text-xs font-medium text-gray-900 bg-white border border-gray-200 hover:bg-green-500 hover:border-green-600 hover:text-white focus:z-10 focus:ring-2 focus:ring-green-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:border-green-700 dark:hover:bg-green-600 dark:focus:ring-green-500 dark:focus:text-white hover:cursor-pointer">
|
||||
{{ slot }}
|
||||
</button>
|
||||
{% endif %}
|
||||
</a>
|
||||
@@ -1,13 +0,0 @@
|
||||
{% comment %}
|
||||
title
|
||||
text
|
||||
{% endcomment %}
|
||||
<a href="{{ link }}"
|
||||
title="{{ title }}"
|
||||
class="truncate max-w-xs py-1 px-2 text-xs bg-green-600 hover:bg-green-700 focus:ring-green-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center font-semibold shadow-md focus:outline-hidden focus:ring-2 focus:ring-offset-2 rounded-xs">
|
||||
{% comment %} <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="self-center w-6 h-6 inline">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" />
|
||||
</svg>
|
||||
{% endcomment %}
|
||||
{{ text }}
|
||||
</a>
|
||||
@@ -1,18 +0,0 @@
|
||||
{% comment %}
|
||||
title
|
||||
text
|
||||
{% endcomment %}
|
||||
<button type="button"
|
||||
title="{{ title }}"
|
||||
autofocus
|
||||
class="truncate max-w-xs sm:max-w-md lg:max-w-lg py-1 px-2 bg-green-600 hover:bg-green-700 focus:ring-green-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-hidden focus:ring-2 focus:ring-offset-2 rounded-lg">
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="self-center w-6 h-6 inline">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" />
|
||||
</svg>
|
||||
{{ text }}
|
||||
</button>
|
||||
@@ -1,10 +0,0 @@
|
||||
<span class="truncate-container">
|
||||
<a class="underline decoration-slate-500 sm:decoration-2"
|
||||
href="{% url 'games:view_game' game_id %}">
|
||||
{% if slot %}
|
||||
{{ slot }}
|
||||
{% else %}
|
||||
{{ name }}
|
||||
{% endif %}
|
||||
</a>
|
||||
</span>
|
||||
@@ -1,16 +0,0 @@
|
||||
<span class="{% if display == 'flex' %}flex{% else %}inline-flex{% endif %} gap-2 items-center align-middle {{class}}">
|
||||
<span class="rounded-xl w-3 h-3
|
||||
{% if status == "u" %}
|
||||
bg-gray-500
|
||||
{% elif status == "p" %}
|
||||
bg-orange-400
|
||||
{% elif status == "f" %}
|
||||
bg-green-500
|
||||
{% elif status == "a" %}
|
||||
bg-red-500
|
||||
{% elif status == "r" %}
|
||||
bg-purple-500
|
||||
{% endif %}
|
||||
"> </span>
|
||||
{{ slot }}
|
||||
</span>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user