Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 21af7cddd0 |
+14
-44
@@ -1,51 +1,21 @@
|
||||
# =============================================================================
|
||||
# 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.
|
||||
# =============================================================================
|
||||
# Docker registry URL (used in docker-compose.yml)
|
||||
REGISTRY_URL=registry.kucharczyk.xyz
|
||||
|
||||
# Turn DEBUG off in production. Defaults on for local development.
|
||||
# (The old PROD=1 variable still works but is deprecated; prefer DEBUG.)
|
||||
DEBUG=false
|
||||
|
||||
# Secret key. Required in production; an insecure default is used in DEBUG.
|
||||
# For Docker/K8s secrets, point SECRET_KEY__FILE at a mounted file instead.
|
||||
SECRET_KEY=change-me-to-a-long-random-string
|
||||
# SECRET_KEY__FILE=/run/secrets/timetracker_secret_key
|
||||
|
||||
# Public URL(s) of the site — one URL or comma-separated list of full URLs.
|
||||
# Derives ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS from all listed URLs.
|
||||
APP_URL=https://tracker.kucharczyk.xyz
|
||||
# APP_URL=https://tracker.kucharczyk.xyz,https://www.tracker.kucharczyk.xyz
|
||||
|
||||
# Override ALLOWED_HOSTS directly for edge cases (e.g. behind a reverse proxy).
|
||||
# ALLOWED_HOSTS=*
|
||||
|
||||
# Container timezone.
|
||||
# Container timezone
|
||||
TZ=Europe/Prague
|
||||
|
||||
# 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.
|
||||
# User/group IDs for container (used in entrypoint.sh)
|
||||
PUID=1000
|
||||
PGID=100
|
||||
|
||||
# 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.
|
||||
# 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
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
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 }}
|
||||
@@ -1,140 +0,0 @@
|
||||
name: Staging deployment
|
||||
|
||||
on:
|
||||
push:
|
||||
branches-ignore: [main]
|
||||
delete:
|
||||
pull_request:
|
||||
types: [opened]
|
||||
|
||||
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: Seed database from prod (first deploy of this branch only)
|
||||
run: |
|
||||
if docker volume inspect "timetracker-staging-${SLUG}" >/dev/null 2>&1; then
|
||||
echo "Volume exists, keeping current staging data"
|
||||
exit 0
|
||||
fi
|
||||
docker volume create "timetracker-staging-${SLUG}"
|
||||
# sqlite3.backup() takes a consistent online snapshot (WAL-safe);
|
||||
# prod is only read, never written.
|
||||
docker run --rm \
|
||||
-v /docker/timetracker/data:/prod \
|
||||
-v "timetracker-staging-${SLUG}:/dest" \
|
||||
python:3.14-slim-bookworm sh -c "
|
||||
python -c \"
|
||||
import sqlite3
|
||||
source = sqlite3.connect('file:/prod/db.sqlite3?mode=ro', uri=True)
|
||||
destination = sqlite3.connect('/dest/db.sqlite3')
|
||||
source.backup(destination)
|
||||
games = destination.execute('select count(*) from games_game').fetchone()[0]
|
||||
sessions = destination.execute('select count(*) from games_session').fetchone()[0]
|
||||
print(f'Seeded staging database: {games} games, {sessions} sessions')
|
||||
destination.close()
|
||||
source.close()
|
||||
\" && chown 1000:100 /dest/db.sqlite3"
|
||||
|
||||
- name: Deploy staging container
|
||||
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}"
|
||||
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}"
|
||||
|
||||
comment:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
BRANCH: ${{ github.event.pull_request.head.ref }}
|
||||
PR: ${{ github.event.pull_request.number }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- name: Comment staging URL on the new PR
|
||||
run: |
|
||||
SLUG=$(echo "$BRANCH" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9-]+/-/g; s/-+/-/g; s/^-//; s/-$//' | cut -c1-40)
|
||||
HOST="tracker-${SLUG}.home.arpa"
|
||||
auth="Authorization: token ${GITHUB_TOKEN}"
|
||||
api="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}"
|
||||
body="Staging deployment: https://${HOST}"
|
||||
if curl -fsS -H "$auth" "${api}/issues/${PR}/comments" \
|
||||
| jq -e --arg body "$body" 'any(.[]; .body == $body)' >/dev/null; then
|
||||
echo "Staging URL already commented on PR #${PR}"
|
||||
exit 0
|
||||
fi
|
||||
curl -fsS -X POST -H "$auth" -H 'Content-Type: application/json' \
|
||||
-d "$(jq -n --arg body "$body" '{body: $body}')" \
|
||||
"${api}/issues/${PR}/comments" >/dev/null
|
||||
echo "Commented staging URL on PR #${PR}"
|
||||
|
||||
teardown:
|
||||
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,20 +19,6 @@ 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
|
||||
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
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
|
||||
+1
-12
@@ -4,22 +4,11 @@ __pycache__
|
||||
.venv/
|
||||
node_modules
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
db.sqlite3
|
||||
db.sqlite3-shm
|
||||
db.sqlite3-wal
|
||||
data/
|
||||
/static/
|
||||
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/
|
||||
|
||||
@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
| Task | Command |
|
||||
|------|---------|
|
||||
| Install dependencies | `make init` (installs Python via uv + npm packages, loads platform fixtures) |
|
||||
| Install dependencies | `make init` (installs Python via uv + npm packages) |
|
||||
| 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`) |
|
||||
@@ -20,174 +20,67 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
| 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.
|
||||
A Django 6+ monolith with a single app (`games/`) for tracking video game purchases, play sessions, and statistics. Uses HTMX for interactivity with a custom 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
|
||||
games/ — Django app: models, views, templates, forms, signals, tasks, API
|
||||
common/ — Shared utilities: time formatting, component system, HTML helpers
|
||||
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_game` (the base Game the add-on belongs to; reverse accessor `game.addon_purchases`). **A multi-game Purchase is an *unsplittable* bundle** (one price, whole-purchase refund — e.g. a Humble Bundle). Independently-refundable multi-item orders (e.g. a Steam cart) are modeled as **separate single-game purchases**, not one bundle: the add-purchase form's "separate price per game" mode (≥2 games) creates them, and the row's **Split** action breaks an existing bundle into per-game purchases (price split evenly as a starting point). This is why per-game refund/price need no through-model — each refundable unit is its own Purchase.
|
||||
- **Session** — `timestamp_start`/`timestamp_end`, `duration_manual`, `device` (FK), `note`, `emulated`. `duration_calculated` and `duration_total` are `GeneratedField`s (cannot be written directly)
|
||||
- **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`
|
||||
- **Game** — name, platform, status (Unplayed/Played/Finished/Retired/Abandoned), mastered, playtime
|
||||
- **Platform** — name, group, icon slug
|
||||
- **Purchase** — ownership type, prices, currency conversion (`converted_price`, `price_per_game` is a GeneratedField), links to Game via M2M
|
||||
- **Session** — start/end timestamps, manual duration, device. `duration_calculated` and `duration_total` are GeneratedFields (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.
|
||||
- **GameStatusChange** — audit log of status transitions
|
||||
|
||||
### 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.py`): Python functions return HTML via django-cotton templates. Every component wraps `Component()` which calls `render_to_string` (LRU-cached in production). Key helpers: `A()`, `Button()`, `Icon()`, `Popover()`, `PopoverTruncated()`, `NameWithIcon()`, `LinkedPurchase()`, `Div()`, `Form()`.
|
||||
|
||||
**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()`
|
||||
**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`, `general.py`. The `general.py` has two context processors: `model_counts` and `global_current_year`.
|
||||
|
||||
**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
|
||||
- `pre_delete` on Game: decrements `num_purchases` on related Purchases
|
||||
- `post_save/post_delete` on Session: recalculates Game.playtime
|
||||
- `pre_save` on Game: creates GameStatusChange audit records
|
||||
|
||||
**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.
|
||||
**Background tasks**: django-q2 cluster runs `games.tasks.convert_prices()` on a schedule to fetch exchange rates 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`).
|
||||
**HTMX toast middleware** (`games/htmx_middleware.py`): Converts Django messages into `HX-Trigger` headers with `show-toast` event. Skips if `HX-Redirect` is present.
|
||||
|
||||
**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
|
||||
**REST API** (`games/api.py`): Django Ninja with routers for playevents, games, and sessions. Game status and session device can be PATCHed via the API.
|
||||
|
||||
### 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()`.
|
||||
Templates live in `games/templates/`. The layout uses django-cotton components in `templates/cotton/` — a reusable component library with `button.html`, `table.html`, `popover.html`, etc. Platform icons are stored as individual HTML snippet files under `cotton/icon/<slug>.html`. Partials for HTMX responses are in `templates/partials/`.
|
||||
|
||||
### 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.
|
||||
Docker-based: multi-stage Dockerfile (uv builder → 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 Drone (`.drone.yml`): runs tests, builds Docker image, deploys via Portainer webhook.
|
||||
|
||||
### 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.
|
||||
SQLite with WAL journal mode. Connection timeout 20s. The `DATA_DIR` env var controls the database file location. Migrations live in `games/migrations/`. There are GeneratedFields 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` accepts one full URL or a comma-separated list of full URLs; `ALLOWED_HOSTS` and `CSRF_TRUSTED_ORIGINS` are derived from all listed URLs. `ALLOWED_HOSTS` can still be overridden directly (e.g. `ALLOWED_HOSTS=*` behind a reverse proxy); `CSRF_TRUSTED_ORIGINS` is always derived from `APP_URL`.
|
||||
- `TIME_ZONE` reads `TZ` (defaults `Europe/Prague` in debug, `UTC` in prod).
|
||||
- Django Admin, Debug Toolbar, and `django_extensions` are only available in `DEBUG` mode.
|
||||
- **Container/entrypoint-only** flags (`PUID`, `PGID`, `CREATE_DEFAULT_SUPERUSER`, `STAGING`, `LOAD_SAMPLE_DATA`) live in `entrypoint.sh`, not the Python config — they are bootstrap concerns, not Django settings.
|
||||
- django-q2 cluster: 1 worker, 60s timeout, 120s retry, ORM broker
|
||||
|
||||
### 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.
|
||||
- `DEBUG` is `True` unless `PROD` env var is set
|
||||
- `TIME_ZONE` defaults to `Europe/Prague` in debug, otherwise reads `TZ` env var
|
||||
- Django Admin and Debug Toolbar are only available in DEBUG mode
|
||||
- `CSRF_TRUSTED_ORIGINS` is parsed from a comma-separated env var
|
||||
|
||||
-23
@@ -15,25 +15,6 @@ 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
|
||||
|
||||
@@ -63,10 +44,6 @@ 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 /
|
||||
|
||||
@@ -22,29 +22,12 @@ init:
|
||||
pnpm install
|
||||
$(MAKE) loadplatforms
|
||||
|
||||
server: gen-element-types
|
||||
dev:
|
||||
@pnpm concurrently \
|
||||
--names "Django,TS" \
|
||||
--names "Django,Tailwind" \
|
||||
--prefix-colors "blue,green" \
|
||||
"uv run python -Wa manage.py runserver" \
|
||||
"pnpm exec tsc --watch"
|
||||
|
||||
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: gen-element-types
|
||||
@pnpm concurrently \
|
||||
--names "Django,Tailwind,TS" \
|
||||
--prefix-colors "blue,green,magenta" \
|
||||
"uv run python -Wa manage.py runserver" \
|
||||
"pnpm tailwindcss -i ./common/input.css -o ./games/static/base.css --watch" \
|
||||
"pnpm exec tsc --watch"
|
||||
"pnpm tailwindcss -i ./common/input.css -o ./games/static/base.css --watch"
|
||||
|
||||
|
||||
caddy:
|
||||
@@ -84,9 +67,6 @@ 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
|
||||
|
||||
@@ -99,7 +79,7 @@ format:
|
||||
format-check:
|
||||
uv run ruff format --check
|
||||
|
||||
check: lint format-check ts-check test
|
||||
check: lint format-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=" "))'
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# 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.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,173 +0,0 @@
|
||||
"""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 (
|
||||
SelectionFields,
|
||||
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",
|
||||
"SelectionFields",
|
||||
"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",
|
||||
]
|
||||
@@ -1,353 +0,0 @@
|
||||
"""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
|
||||
@@ -1,181 +0,0 @@
|
||||
"""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 Node
|
||||
from common.components.primitives import (
|
||||
Div,
|
||||
Input,
|
||||
Label,
|
||||
Template,
|
||||
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")
|
||||
|
||||
|
||||
class SelectionFieldsProps(TypedDict):
|
||||
source: str # data-name of the source SearchSelect to mirror
|
||||
name_prefix: str # each rendered input is named f"{name_prefix}{item_id}"
|
||||
field_type: str # input type, e.g. "number"
|
||||
min_items: int # render nothing until at least this many items are selected
|
||||
active: bool # when false, render nothing (but preserve typed values)
|
||||
|
||||
|
||||
register_element("selection-fields", "SelectionFields", SelectionFieldsProps)
|
||||
|
||||
_SelectionFields = custom_element_builder("selection-fields")
|
||||
|
||||
|
||||
def SelectionFields(
|
||||
*,
|
||||
source: str,
|
||||
name_prefix: str,
|
||||
field_type: str = "text",
|
||||
min_items: int = 1,
|
||||
active: bool = False,
|
||||
input_attributes: list[tuple[str, str]] | None = None,
|
||||
) -> Node:
|
||||
"""Render one synced form field per selected item of a source SearchSelect.
|
||||
|
||||
General-purpose: it mirrors the SearchSelect named ``source`` and emits an
|
||||
input named ``f"{name_prefix}{item_id}"`` per selected item. Behavior lives
|
||||
in ``ts/elements/selection-fields.ts``; this is just the server-rendered
|
||||
light DOM (an empty rows container + a row ``<template>``). Inputs inherit
|
||||
the global ``#add-form`` styling, so the markup stays minimal.
|
||||
"""
|
||||
row_template = Template(attributes=[("data-selection-fields-row", "")])[
|
||||
Div(attributes=[("data-selection-fields-row-item", "")])[
|
||||
Label(attributes=[("data-selection-fields-label", "")]),
|
||||
Input(type=field_type, attributes=list(input_attributes or [])),
|
||||
]
|
||||
]
|
||||
return _SelectionFields(
|
||||
source=source,
|
||||
name_prefix=name_prefix,
|
||||
field_type=field_type,
|
||||
min_items=min_items,
|
||||
active="true" if active else "false",
|
||||
)[
|
||||
Div(attributes=[("data-selection-fields-rows", "")]),
|
||||
row_template,
|
||||
]
|
||||
@@ -1,354 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,308 +0,0 @@
|
||||
"""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
|
||||
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
|
||||
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
@@ -1,576 +0,0 @@
|
||||
"""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)]
|
||||
@@ -1,512 +0,0 @@
|
||||
"""
|
||||
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())
|
||||
+3
-1
@@ -1,7 +1,9 @@
|
||||
import functools
|
||||
from pathlib import Path
|
||||
|
||||
_ICON_DIR = Path(__file__).resolve().parent.parent / "games" / "templates" / "icons"
|
||||
_ICON_DIR = (
|
||||
Path(__file__).resolve().parent.parent / "games" / "templates" / "icons"
|
||||
)
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
|
||||
+4
-2
@@ -206,9 +206,12 @@ textarea:disabled {
|
||||
label {
|
||||
@apply mb-2.5 text-sm font-medium text-heading;
|
||||
}
|
||||
input:not([type="checkbox"]):not([data-search-select-search]) {
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -228,4 +231,3 @@ textarea:disabled {
|
||||
@apply fixed z-50 flex flex-col items-end bottom-0 right-0 p-4;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+27
-61
@@ -8,11 +8,9 @@ it hoists shared `<head>` content (the `_HEADERS` block, analogous to
|
||||
"""
|
||||
|
||||
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
|
||||
@@ -21,9 +19,6 @@ 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)) {
|
||||
@@ -187,20 +182,12 @@ 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
|
||||
|
||||
def Navbar(*, today_played: str, last_7_played: str, current_year: int) -> SafeText:
|
||||
"""Top navigation bar."""
|
||||
logo = static("icons/schedule.png")
|
||||
return Safe(f"""<nav class="bg-neutral-primary-soft border-b border-default">
|
||||
return mark_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")}"
|
||||
<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>
|
||||
@@ -242,11 +229,11 @@ def Navbar(
|
||||
</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>
|
||||
<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>
|
||||
@@ -260,23 +247,20 @@ def Navbar(
|
||||
</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>
|
||||
<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>
|
||||
<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>
|
||||
<a href="{reverse('logout')}" 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</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -285,37 +269,22 @@ def Navbar(
|
||||
|
||||
|
||||
def Page(
|
||||
content: "Node | SafeText | str",
|
||||
content: SafeText | str,
|
||||
*,
|
||||
request: HttpRequest,
|
||||
title: str = "",
|
||||
scripts: "Node | SafeText | str" = "",
|
||||
scripts: 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
|
||||
"""Assemble a full HTML document around `content` (the fast_app equivalent)."""
|
||||
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 = [
|
||||
@@ -340,12 +309,9 @@ def Page(
|
||||
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'
|
||||
' <script src="https://cdn.jsdelivr.net/npm/flowbite@2.4.1/dist/flowbite.min.js"></script>\n'
|
||||
' <script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/mask@3.x.x/dist/cdn.min.js"></script>\n'
|
||||
' <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>\n'
|
||||
f" {_THEME_FOUC_SCRIPT}\n"
|
||||
" </head>\n"
|
||||
)
|
||||
@@ -359,9 +325,9 @@ def Page(
|
||||
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" {scripts}\n"
|
||||
f" {_main_script(mastered)}\n"
|
||||
" <!-- hx-swap-oob makes sure the modal gets removed upon any HTMX response -->\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'
|
||||
@@ -373,10 +339,10 @@ def Page(
|
||||
|
||||
def render_page(
|
||||
request: HttpRequest,
|
||||
content: "Node | SafeText | str",
|
||||
content: SafeText | str,
|
||||
*,
|
||||
title: str = "",
|
||||
scripts: "Node | SafeText | str" = "",
|
||||
scripts: SafeText | str = "",
|
||||
mastered: bool = False,
|
||||
status: int = 200,
|
||||
) -> HttpResponse:
|
||||
|
||||
@@ -1,43 +1,17 @@
|
||||
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)
|
||||
|
||||
@@ -5,34 +5,11 @@ 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.
|
||||
|
||||
@@ -7,11 +7,8 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
container_name: timetracker
|
||||
environment:
|
||||
- DEBUG=false
|
||||
- TZ=Europe/Prague
|
||||
# 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
|
||||
- CSRF_TRUSTED_ORIGINS="https://tracker.kucharczyk.xyz"
|
||||
user: "1000"
|
||||
# volumes:
|
||||
# - "db:/home/timetracker/app/src/timetracker/db.sqlite3"
|
||||
|
||||
+2
-6
@@ -1,21 +1,17 @@
|
||||
---
|
||||
services:
|
||||
timetracker:
|
||||
image: ${REGISTRY_URL:-registry.kucharczyk.xyz}/timetracker:latest
|
||||
image: ${REGISTRY_URL:-registry.kucharczyk.xyz}/timetracker:1.7.0
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: timetracker
|
||||
environment:
|
||||
- DEBUG=${DEBUG:-false}
|
||||
- SECRET_KEY=${SECRET_KEY}
|
||||
- TZ=${TZ:-Europe/Prague}
|
||||
# APP_URL drives ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS unless overridden.
|
||||
- APP_URL=${APP_URL:-http://localhost:8000}
|
||||
- CSRF_TRUSTED_ORIGINS=https://tracker.kucharczyk.xyz
|
||||
- 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:
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
# Configuration
|
||||
|
||||
All configurable Django settings are read through a single helper,
|
||||
`config()` in [`timetracker/config.py`](../timetracker/config.py). It resolves
|
||||
each value from a fixed chain of sources so the same setting can come from an
|
||||
environment variable, a `.env` file, an `.ini` file, or a built-in default —
|
||||
without any per-setting special-casing in `settings.py`.
|
||||
|
||||
## Resolution priority
|
||||
|
||||
For a setting named `NAME`, the first source that provides a value wins:
|
||||
|
||||
| Priority | Source | Notes |
|
||||
|---------:|--------|-------|
|
||||
| 1 | `NAME__FILE` env var | Path to a file; its *stripped* contents are the value. Opt-in per setting (`allow_file=True`). For Docker/Kubernetes secrets. |
|
||||
| 2 | `NAME` env var | A real process environment variable. |
|
||||
| 3 | `.env` file | `KEY=value` lines (see [.env syntax](#env-syntax)). |
|
||||
| 4 | `settings.ini` file | The `[timetracker]` section, parsed with `configparser`. |
|
||||
| 5 | `default` | The in-code fallback in `settings.py`. |
|
||||
|
||||
If no source supplies a value and no `default` is defined, startup fails with
|
||||
`ImproperlyConfigured` rather than silently using an empty value.
|
||||
|
||||
**Worked example.** With `VALUE` set in the environment *and* in `.env` *and*
|
||||
in `settings.ini`, the environment variable wins. Remove it and `.env` wins;
|
||||
remove that and `settings.ini` wins; remove that and the code default applies.
|
||||
|
||||
## Settings reference
|
||||
|
||||
| Setting | Cast | Default | `__FILE`? | Description |
|
||||
|---------|------|---------|:---------:|-------------|
|
||||
| `SECRET_KEY` | str | insecure dev key | yes | Django secret key. **Required in production** (DEBUG off) — a missing value is a hard error, not a silent insecure fallback. |
|
||||
| `DEBUG` | bool | `true` (dev) | no | Debug mode. Turn **off** in production. Defaults on for local development. |
|
||||
| `APP_URL` | str (or comma-separated URLs) | `http://localhost:8000` | no | Public URL(s) of the site. One full URL or a comma-separated list. Derives `ALLOWED_HOSTS` and `CSRF_TRUSTED_ORIGINS` from all listed URLs. |
|
||||
| `ALLOWED_HOSTS` | list | derived from `APP_URL` | no | Comma-separated hostnames. Overrides the `APP_URL` derivation (useful for `ALLOWED_HOSTS=*` behind a reverse proxy). |
|
||||
| `TZ` | str | `Europe/Prague` (dev) / `UTC` (prod) | no | Time zone. |
|
||||
| `DATA_DIR` | path | project root | no | Directory holding the SQLite database. Also read by `entrypoint.sh`. |
|
||||
|
||||
`cast` understands `bool` (`true/1/yes/on` → `True`), `list` (comma-separated,
|
||||
whitespace-trimmed, empty items dropped), `int`, `Path`, or any callable.
|
||||
|
||||
## APP_URL, ALLOWED_HOSTS and CSRF
|
||||
|
||||
`APP_URL` accepts one full URL or a comma-separated list of full URLs. Both
|
||||
`ALLOWED_HOSTS` and `CSRF_TRUSTED_ORIGINS` are derived from all listed URLs —
|
||||
no need to repeat the same information in separate variables.
|
||||
|
||||
Single domain (common case):
|
||||
|
||||
```
|
||||
APP_URL=https://tracker.example.com
|
||||
# -> ALLOWED_HOSTS = ["tracker.example.com"]
|
||||
# -> CSRF_TRUSTED_ORIGINS = ["https://tracker.example.com"]
|
||||
```
|
||||
|
||||
Multiple domains:
|
||||
|
||||
```
|
||||
APP_URL=https://tracker.example.com,https://www.tracker.example.com
|
||||
# -> ALLOWED_HOSTS = ["tracker.example.com", "www.tracker.example.com"]
|
||||
# -> CSRF_TRUSTED_ORIGINS = ["https://tracker.example.com", "https://www.tracker.example.com"]
|
||||
```
|
||||
|
||||
`ALLOWED_HOSTS` can still be overridden directly for edge cases. A typical
|
||||
reverse-proxy setup where the proxy validates the host:
|
||||
|
||||
```
|
||||
ALLOWED_HOSTS=*
|
||||
```
|
||||
|
||||
## Secrets and `__FILE`
|
||||
|
||||
Secret managers (Docker secrets, Kubernetes) mount secrets as files. For any
|
||||
setting that opts in (currently `SECRET_KEY`), point a `*__FILE` variable at
|
||||
the mounted path:
|
||||
|
||||
```
|
||||
SECRET_KEY__FILE=/run/secrets/timetracker_secret_key
|
||||
```
|
||||
|
||||
The file contents are read and `.strip()`-ed. The strip matters: editors and
|
||||
`echo` often append a trailing newline, and a stray `\n` inside `SECRET_KEY`
|
||||
would silently invalidate every signed cookie/token when the file is recreated
|
||||
without it.
|
||||
|
||||
## .env syntax
|
||||
|
||||
```dotenv
|
||||
# full-line comment
|
||||
KEY=value
|
||||
export KEY=value # optional leading "export"
|
||||
QUOTED="value with spaces" # surrounding quotes are stripped
|
||||
SINGLE='also fine'
|
||||
WITH_HASH="a # b" # '#' inside quotes is literal
|
||||
INLINE=value # trailing comment after an unquoted value is dropped
|
||||
```
|
||||
|
||||
Deliberately **not** supported (documented limits, not bugs):
|
||||
|
||||
- variable interpolation (`${OTHER}`)
|
||||
- multiline values
|
||||
|
||||
File locations default to `.env` and `settings.ini` at the project root and
|
||||
can be moved with the `ENV_FILE` / `INI_FILE` environment variables. Missing
|
||||
files are ignored, so env-only deployments need neither. A `.env` file used by
|
||||
`docker-compose` for `${VAR}` substitution is the same file Django reads in
|
||||
local development; inside the container, real environment variables apply.
|
||||
|
||||
See [`.env.example`](../.env.example) and
|
||||
[`settings.ini.example`](../settings.ini.example) for starting points.
|
||||
|
||||
## Container / entrypoint-only variables
|
||||
|
||||
These are consumed by [`entrypoint.sh`](../entrypoint.sh) during container
|
||||
bootstrap, **not** by Django. They are intentionally not part of the Python
|
||||
config — moving them there would buy nothing and force a bash↔Python bridge.
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `PUID` / `PGID` | `1000` / `100` | uid/gid the container process runs as. |
|
||||
| `DATA_DIR` | `/home/timetracker/app/data` | Database directory. Shared with Django via the same env var + matching default. |
|
||||
| `CREATE_DEFAULT_SUPERUSER` | `false` | Create an `admin`/`admin` superuser on first start. |
|
||||
| `STAGING` | `false` | Scrub copied sessions / django-q schedule on staging. |
|
||||
| `LOAD_SAMPLE_DATA` | `false` | Seed sample fixtures when the database is empty. |
|
||||
|
||||
## Migrating from the old config
|
||||
|
||||
- `PROD=1` → `DEBUG=false`. `PROD` still works as a **deprecated alias** for
|
||||
one release and emits a `DeprecationWarning`.
|
||||
- `ALLOWED_HOSTS` is now configurable (it was previously hard-coded to `*`).
|
||||
After upgrading, set `APP_URL` (or `ALLOWED_HOSTS` explicitly) or the host
|
||||
will be rejected. Reverse-proxy deployments that relied on `*` should set
|
||||
`ALLOWED_HOSTS=*`.
|
||||
@@ -1,51 +0,0 @@
|
||||
# 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")` |
|
||||
@@ -1,398 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,485 +0,0 @@
|
||||
# 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"
|
||||
```
|
||||
@@ -1,662 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,577 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,177 +0,0 @@
|
||||
# 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
@@ -1,197 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,157 +0,0 @@
|
||||
# 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.
|
||||
@@ -1 +0,0 @@
|
||||
# e2e tests package
|
||||
@@ -1,22 +0,0 @@
|
||||
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
|
||||
@@ -1,112 +0,0 @@
|
||||
"""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
|
||||
@@ -1,84 +0,0 @@
|
||||
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
|
||||
@@ -1,167 +0,0 @@
|
||||
"""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",
|
||||
}
|
||||
@@ -1,326 +0,0 @@
|
||||
"""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",
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
"""Browser tests for the purchase pricing UX and the split action.
|
||||
|
||||
- A synthetic page isolates the general ``selection-fields`` element (no API,
|
||||
deterministic option values), mirroring ``test_search_select_e2e.py``.
|
||||
- The real-app tests drive the actual add-purchase form and the split modal
|
||||
against pytest-django's ``live_server``.
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
from django.http import HttpResponse
|
||||
from django.test import override_settings
|
||||
from django.urls import path, reverse
|
||||
from playwright.sync_api import Page, expect
|
||||
|
||||
from common.components import SearchSelect, SelectionFields
|
||||
from games.models import Game, Platform, Purchase
|
||||
|
||||
|
||||
def selection_fields_view(request):
|
||||
html = f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<script src="/static/js/htmx.min.js"></script>
|
||||
<script type="module" src="/static/js/search_select.js"></script>
|
||||
<script type="module" src="/static/js/dist/elements/selection-fields.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div style="padding: 50px;">
|
||||
{
|
||||
SearchSelect(
|
||||
name="games",
|
||||
selected=[],
|
||||
options=[
|
||||
{"value": "7", "label": "Game A", "data": {}},
|
||||
{"value": "8", "label": "Game B", "data": {}},
|
||||
],
|
||||
multi_select=True,
|
||||
)
|
||||
}
|
||||
{
|
||||
SelectionFields(
|
||||
source="games",
|
||||
name_prefix="price_for_game_",
|
||||
field_type="number",
|
||||
min_items=2,
|
||||
active=True,
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
return HttpResponse(html)
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("sf-test/", selection_fields_view),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_purchase_e2e")
|
||||
def test_selection_fields_syncs_with_source(live_server, page: Page):
|
||||
page.goto(live_server.url + "/sf-test/")
|
||||
|
||||
games = page.locator('[data-search-select][data-name="games"]')
|
||||
rows = page.locator("selection-fields [data-selection-fields-rows] input")
|
||||
|
||||
# Below min_items (2): nothing rendered.
|
||||
expect(rows).to_have_count(0)
|
||||
|
||||
games.locator("[data-search-select-search]").click()
|
||||
games.locator('[data-search-select-option][data-value="7"]').click()
|
||||
expect(rows).to_have_count(0) # only one selected, still below min_items
|
||||
|
||||
games.locator("[data-search-select-search]").click()
|
||||
games.locator('[data-search-select-option][data-value="8"]').click()
|
||||
expect(rows).to_have_count(2)
|
||||
|
||||
# One input per item, named by the prefix + item id.
|
||||
expect(
|
||||
page.locator('selection-fields input[name="price_for_game_7"]')
|
||||
).to_have_count(1)
|
||||
expect(
|
||||
page.locator('selection-fields input[name="price_for_game_8"]')
|
||||
).to_have_count(1)
|
||||
|
||||
# Typed values survive removing and re-adding another item.
|
||||
page.locator('selection-fields input[name="price_for_game_7"]').fill("12")
|
||||
games.locator('[data-pill][data-value="8"] [data-pill-remove]').click()
|
||||
expect(rows).to_have_count(0)
|
||||
games.locator("[data-search-select-search]").click()
|
||||
games.locator('[data-search-select-option][data-value="8"]').click()
|
||||
expect(rows).to_have_count(2)
|
||||
expect(
|
||||
page.locator('selection-fields input[name="price_for_game_7"]')
|
||||
).to_have_value("12")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def authenticated_page(live_server, page: Page, django_user_model) -> Page:
|
||||
django_user_model.objects.create_user(username="tester", password="secret123")
|
||||
page.goto(f"{live_server.url}{reverse('login')}")
|
||||
page.fill('input[name="username"]', "tester")
|
||||
page.fill('input[name="password"]', "secret123")
|
||||
page.click('input[type="submit"]')
|
||||
page.wait_for_url(f"{live_server.url}/tracker**")
|
||||
return page
|
||||
|
||||
|
||||
def _select_two_games(page: Page) -> None:
|
||||
games = page.locator('[data-search-select][data-name="games"]')
|
||||
games.locator("[data-search-select-search]").click()
|
||||
options = games.locator("[data-search-select-option]")
|
||||
expect(options).to_have_count(2) # prefetched on focus
|
||||
options.nth(0).click()
|
||||
options.nth(1).click()
|
||||
|
||||
|
||||
def test_add_purchase_per_game_toggle_reveals_inputs(
|
||||
authenticated_page: Page, live_server
|
||||
):
|
||||
"""The combined/per-game toggle appears only at 2+ games; turning it on
|
||||
hides the bundle Price and shows one price input per selected game.
|
||||
(Server-side creation of N purchases is covered by the unit tests.)"""
|
||||
page = authenticated_page
|
||||
platform = Platform.objects.create(name="PC", icon="pc", group="PC")
|
||||
Game.objects.create(name="Alpha Game", platform=platform)
|
||||
Game.objects.create(name="Beta Game", platform=platform)
|
||||
|
||||
page.goto(f"{live_server.url}{reverse('games:add_purchase')}")
|
||||
|
||||
checkbox_row = page.locator("#separate-prices-row")
|
||||
expect(checkbox_row).to_be_hidden()
|
||||
|
||||
_select_two_games(page)
|
||||
expect(checkbox_row).to_be_visible()
|
||||
|
||||
page.locator("#id_separate_prices").check()
|
||||
expect(page.locator("#id_price")).to_be_hidden()
|
||||
per_game_inputs = page.locator(
|
||||
"selection-fields [data-selection-fields-rows] input"
|
||||
)
|
||||
expect(per_game_inputs).to_have_count(2)
|
||||
|
||||
|
||||
def test_split_purchase_action(authenticated_page: Page, live_server):
|
||||
page = authenticated_page
|
||||
platform = Platform.objects.create(name="PC", icon="pc", group="PC")
|
||||
game_a = Game.objects.create(name="Alpha Game", platform=platform)
|
||||
game_b = Game.objects.create(name="Beta Game", platform=platform)
|
||||
bundle = Purchase.objects.create(
|
||||
price=30.0,
|
||||
price_currency="USD",
|
||||
date_purchased=date(2025, 1, 1),
|
||||
platform=platform,
|
||||
ownership_type=Purchase.DIGITAL,
|
||||
type=Purchase.GAME,
|
||||
)
|
||||
bundle.games.set([game_a, game_b])
|
||||
|
||||
page.goto(f"{live_server.url}{reverse('games:list_purchases')}")
|
||||
# Before: one bundle row.
|
||||
expect(page.locator('[id^="purchase-row-"]')).to_have_count(1)
|
||||
|
||||
page.locator('[title="Split into per-game purchases"]').click()
|
||||
modal = page.locator("#split-confirmation-modal")
|
||||
expect(modal).to_be_visible()
|
||||
modal.locator('button[type="submit"]', has_text="Split").click()
|
||||
|
||||
page.wait_for_url(f"{live_server.url}{reverse('games:list_purchases')}**")
|
||||
# After: the bundle row is gone, replaced by two per-game rows. Asserted via
|
||||
# the UI (not the ORM) to avoid live_server/SQLite write-read contention.
|
||||
expect(page.locator(f"#purchase-row-{bundle.id}")).to_have_count(0)
|
||||
expect(page.locator('[id^="purchase-row-"]')).to_have_count(2)
|
||||
@@ -1,114 +0,0 @@
|
||||
"""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
|
||||
@@ -1,109 +0,0 @@
|
||||
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"
|
||||
@@ -1,150 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,149 +0,0 @@
|
||||
"""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-game 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()
|
||||
|
||||
|
||||
def test_add_purchase_related_game_is_flat_game_search(
|
||||
authenticated_page: Page, live_server
|
||||
):
|
||||
"""The DLC/Season-Pass anchor is now a flat game search (related_game),
|
||||
wired to the games search API and present regardless of which games are
|
||||
selected — not the old parent-purchase dropdown filtered by chosen games."""
|
||||
page = authenticated_page
|
||||
page.goto(f"{live_server.url}{reverse('games:add_purchase')}")
|
||||
|
||||
related = page.locator('[data-search-select][data-name="related_game"]')
|
||||
expect(related).to_have_count(1)
|
||||
expect(related).to_have_attribute("data-search-url", "/api/games/search")
|
||||
+2
-41
@@ -1,16 +1,8 @@
|
||||
#!/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
|
||||
@@ -18,45 +10,14 @@ groupmod -o -g "$PGID" timetracker
|
||||
usermod -o -u "$PUID" timetracker
|
||||
usermod -d "${USERHOME}" timetracker
|
||||
|
||||
mkdir -p "$DATA_DIR" /var/log/supervisor
|
||||
mkdir -p /home/timetracker/app/data /var/log/supervisor
|
||||
chmod 755 /home/timetracker/app
|
||||
chmod 755 /home/timetracker/app/.venv
|
||||
|
||||
chown "$PUID:$PGID" "$DATA_DIR"
|
||||
chown "$PUID:$PGID" /home/timetracker/app/data
|
||||
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
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
# 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"
|
||||
+3
-61
@@ -2,18 +2,15 @@ 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 Device, Game, Platform, PlayEvent, Session
|
||||
from games.models import Game, PlayEvent, Session
|
||||
|
||||
api = NinjaAPI()
|
||||
playevent_router = Router()
|
||||
game_router = Router()
|
||||
device_router = Router()
|
||||
platform_router = Router()
|
||||
|
||||
NOW_FACTORY = django_timezone_now
|
||||
|
||||
@@ -53,33 +50,6 @@ 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)
|
||||
@@ -123,35 +93,8 @@ 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()
|
||||
|
||||
@@ -161,9 +104,7 @@ 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()
|
||||
@@ -172,3 +113,4 @@ def partial_update_session_device(
|
||||
|
||||
|
||||
api.add_router("/session", session_router)
|
||||
|
||||
|
||||
@@ -1,979 +0,0 @@
|
||||
"""
|
||||
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)
|
||||
+38
-57
@@ -1,90 +1,71 @@
|
||||
- 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]
|
||||
game: 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]
|
||||
game: 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]
|
||||
game: 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"
|
||||
- model: games.platform
|
||||
pk: 1
|
||||
fields:
|
||||
name: Steam
|
||||
group: PC
|
||||
- model: games.platform
|
||||
pk: 3
|
||||
fields:
|
||||
name: Xbox Gamepass
|
||||
group: PC
|
||||
- model: games.platform
|
||||
pk: 4
|
||||
fields:
|
||||
name: Epic Games Store
|
||||
group: PC
|
||||
- model: games.platform
|
||||
pk: 5
|
||||
fields:
|
||||
name: Playstation 5
|
||||
group: Playstation
|
||||
- model: games.platform
|
||||
pk: 6
|
||||
fields:
|
||||
name: Playstation 4
|
||||
group: Playstation
|
||||
- model: games.platform
|
||||
pk: 7
|
||||
fields:
|
||||
name: Nintendo Switch
|
||||
group: Nintendo
|
||||
- model: games.platform
|
||||
pk: 8
|
||||
fields:
|
||||
name: Nintendo 3DS
|
||||
group: Nintendo
|
||||
|
||||
+45
-200
@@ -1,14 +1,8 @@
|
||||
from django import forms
|
||||
from django.db import transaction
|
||||
from django.urls import reverse
|
||||
|
||||
from common.components import (
|
||||
DEFAULT_PREFETCH,
|
||||
SearchSelect,
|
||||
SearchSelectOption,
|
||||
render,
|
||||
searchselect_selected,
|
||||
)
|
||||
from common.components.primitives import Checkbox
|
||||
from common.utils import safe_getattr
|
||||
from games.models import (
|
||||
Device,
|
||||
Game,
|
||||
@@ -26,154 +20,20 @@ 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 obj.search_label
|
||||
return f"{obj.sort_name} ({obj.platform}, {obj.year_released})"
|
||||
|
||||
|
||||
class SingleGameChoiceField(forms.ModelChoiceField):
|
||||
def label_from_instance(self, obj) -> str:
|
||||
return obj.search_label
|
||||
return f"{obj.sort_name} ({obj.platform}, {obj.year_released})"
|
||||
|
||||
|
||||
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):
|
||||
class SessionForm(forms.ModelForm):
|
||||
game = SingleGameChoiceField(
|
||||
queryset=Game.objects.order_by("sort_name"),
|
||||
widget=SearchSelectWidget(
|
||||
search_url="/api/games/search", options_resolver=_game_options
|
||||
),
|
||||
widget=forms.Select(attrs={"autofocus": "autofocus"}),
|
||||
)
|
||||
|
||||
duration_manual = forms.DurationField(
|
||||
@@ -183,13 +43,7 @@ class SessionForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||
),
|
||||
label="Manual duration",
|
||||
)
|
||||
device = forms.ModelChoiceField(
|
||||
queryset=Device.objects.order_by("name"),
|
||||
required=False,
|
||||
widget=SearchSelectWidget(
|
||||
search_url="/api/devices/search", options_resolver=_device_options
|
||||
),
|
||||
)
|
||||
device = forms.ModelChoiceField(queryset=Device.objects.order_by("name"))
|
||||
|
||||
mark_as_played = forms.BooleanField(
|
||||
required=False,
|
||||
@@ -227,34 +81,38 @@ class SessionForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||
return session
|
||||
|
||||
|
||||
class PurchaseForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||
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
|
||||
|
||||
|
||||
class PurchaseForm(forms.ModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["platform"].queryset = Platform.objects.order_by("name")
|
||||
# The bundle Price is optional: in price-per-game mode it is hidden and
|
||||
# the per-game inputs carry the prices instead. Empty falls back to 0.
|
||||
self.fields["price"].required = False
|
||||
|
||||
# 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",
|
||||
}
|
||||
)
|
||||
|
||||
games = MultipleGameChoiceField(
|
||||
queryset=Game.objects.order_by("sort_name"),
|
||||
widget=SearchSelectMultiple(
|
||||
search_url="/api/games/search",
|
||||
options_resolver=_game_options,
|
||||
multi_select=True,
|
||||
),
|
||||
widget=IncludePlatformSelect(attrs={"autoselect": "autoselect"}),
|
||||
)
|
||||
platform = forms.ModelChoiceField(
|
||||
queryset=Platform.objects.order_by("name"),
|
||||
widget=SearchSelectWidget(
|
||||
search_url="/api/platforms/search", options_resolver=_platform_options
|
||||
),
|
||||
)
|
||||
related_game = forms.ModelChoiceField(
|
||||
queryset=Game.objects.order_by("sort_name"),
|
||||
platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
|
||||
related_purchase = forms.ModelChoiceField(
|
||||
queryset=Purchase.objects.filter(type=Purchase.GAME),
|
||||
required=False,
|
||||
widget=SearchSelectWidget(
|
||||
search_url="/api/games/search", options_resolver=_game_options
|
||||
),
|
||||
)
|
||||
|
||||
price_currency = forms.CharField(
|
||||
@@ -285,14 +143,14 @@ class PurchaseForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||
"price_currency",
|
||||
"ownership_type",
|
||||
"type",
|
||||
"related_game",
|
||||
"related_purchase",
|
||||
"name",
|
||||
]
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
purchase_type = cleaned_data.get("type")
|
||||
related_game = cleaned_data.get("related_game")
|
||||
related_purchase = cleaned_data.get("related_purchase")
|
||||
name = cleaned_data.get("name")
|
||||
|
||||
# Set the type on the instance to use get_type_display()
|
||||
@@ -301,18 +159,13 @@ class PurchaseForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||
|
||||
if purchase_type != Purchase.GAME:
|
||||
type_display = self.instance.get_type_display()
|
||||
if not related_game:
|
||||
if not related_purchase:
|
||||
self.add_error(
|
||||
"related_game",
|
||||
f"{type_display} must have a related game.",
|
||||
"related_purchase",
|
||||
f"{type_display} must have a related purchase.",
|
||||
)
|
||||
if not name:
|
||||
self.add_error("name", f"{type_display} must have a name.")
|
||||
|
||||
# An empty bundle Price (price-per-game mode) saves as 0, not NULL.
|
||||
if cleaned_data.get("price") is None:
|
||||
cleaned_data["price"] = 0
|
||||
|
||||
return cleaned_data
|
||||
|
||||
|
||||
@@ -331,13 +184,9 @@ class GameModelChoiceField(forms.ModelChoiceField):
|
||||
return obj.sort_name
|
||||
|
||||
|
||||
class GameForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||
class GameForm(forms.ModelForm):
|
||||
platform = forms.ModelChoiceField(
|
||||
queryset=Platform.objects.order_by("name"),
|
||||
required=False,
|
||||
widget=SearchSelectWidget(
|
||||
search_url="/api/platforms/search", options_resolver=_platform_options
|
||||
),
|
||||
queryset=Platform.objects.order_by("name"), required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -355,7 +204,7 @@ class GameForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||
widgets = {"name": autofocus_input_widget}
|
||||
|
||||
|
||||
class PlatformForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||
class PlatformForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Platform
|
||||
fields = [
|
||||
@@ -366,21 +215,17 @@ class PlatformForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||
widgets = {"name": autofocus_input_widget}
|
||||
|
||||
|
||||
class DeviceForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||
class DeviceForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Device
|
||||
fields = ["name", "type"]
|
||||
widgets = {"name": autofocus_input_widget}
|
||||
|
||||
|
||||
class PlayEventForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||
game = SingleGameChoiceField(
|
||||
class PlayEventForm(forms.ModelForm):
|
||||
game = GameModelChoiceField(
|
||||
queryset=Game.objects.order_by("sort_name"),
|
||||
widget=SearchSelectWidget(
|
||||
search_url="/api/games/search",
|
||||
options_resolver=_game_options,
|
||||
attrs={"autofocus": "autofocus"},
|
||||
),
|
||||
widget=forms.Select(attrs={"autofocus": "autofocus"}),
|
||||
)
|
||||
|
||||
mark_as_finished = forms.BooleanField(
|
||||
@@ -408,7 +253,7 @@ class PlayEventForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||
return session
|
||||
|
||||
|
||||
class GameStatusChangeForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||
class GameStatusChangeForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = GameStatusChange
|
||||
fields = [
|
||||
|
||||
@@ -34,11 +34,9 @@ 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:
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
"""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}"))
|
||||
@@ -1,28 +0,0 @@
|
||||
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,265 +6,99 @@ 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,14 +4,15 @@ 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,14 +4,15 @@ 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,66 +5,55 @@ 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,14 +4,15 @@ 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,17 +4,18 @@ 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,13 +4,14 @@ 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,24 +6,15 @@ 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,20 +5,15 @@ 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,39 +5,35 @@ 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,14 +4,15 @@ 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(
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
# 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"],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,17 +0,0 @@
|
||||
# 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"),
|
||||
),
|
||||
]
|
||||
@@ -1,28 +0,0 @@
|
||||
# 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,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,46 +0,0 @@
|
||||
# Generated by Django 6.0.6 on 2026-06-18 21:03
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def backfill_related_game(apps, schema_editor):
|
||||
"""Move each add-on purchase's parent link from the parent *purchase* to a
|
||||
parent *game*. For a parent bought as a multi-game bundle there is no single
|
||||
game, so use the bundle's first game (by sort_name) as the best guess."""
|
||||
Purchase = apps.get_model("games", "Purchase")
|
||||
for purchase in Purchase.objects.filter(related_purchase__isnull=False):
|
||||
parent_game = purchase.related_purchase.games.order_by("sort_name").first()
|
||||
if parent_game is not None:
|
||||
purchase.related_game = parent_game
|
||||
purchase.save(update_fields=["related_game"])
|
||||
|
||||
|
||||
def noop_reverse(apps, schema_editor):
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0019_alter_filterpreset_mode"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="purchase",
|
||||
name="related_game",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="addon_purchases",
|
||||
to="games.game",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(backfill_related_game, noop_reverse),
|
||||
migrations.RemoveField(
|
||||
model_name="purchase",
|
||||
name="related_purchase",
|
||||
),
|
||||
]
|
||||
+8
-47
@@ -65,15 +65,9 @@ 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
|
||||
@@ -198,13 +192,12 @@ class Purchase(models.Model):
|
||||
)
|
||||
type = models.CharField(max_length=255, choices=TYPES, default=GAME)
|
||||
name = models.CharField(max_length=255, blank=True, default="")
|
||||
related_game = models.ForeignKey(
|
||||
Game,
|
||||
related_purchase = models.ForeignKey(
|
||||
"self",
|
||||
on_delete=models.SET_NULL,
|
||||
default=None,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="addon_purchases",
|
||||
related_name="related_purchases",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
@@ -253,9 +246,9 @@ class Purchase(models.Model):
|
||||
self.save()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.type != Purchase.GAME and not self.related_game:
|
||||
if self.type != Purchase.GAME and not self.related_purchase:
|
||||
raise ValidationError(
|
||||
f"{self.get_type_display()} must have a related game."
|
||||
f"{self.get_type_display()} must have a related purchase."
|
||||
)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@@ -295,7 +288,7 @@ class Session(models.Model):
|
||||
default=None,
|
||||
related_name="sessions",
|
||||
)
|
||||
timestamp_start = models.DateTimeField(verbose_name="Start", db_index=True)
|
||||
timestamp_start = models.DateTimeField(verbose_name="Start")
|
||||
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"
|
||||
@@ -485,35 +478,3 @@ 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()})"
|
||||
|
||||
+49
-477
File diff suppressed because it is too large
Load Diff
@@ -1,63 +1,31 @@
|
||||
import { getEl, disableElementsWhenTrue, onSwap } from "./utils.js";
|
||||
import {
|
||||
syncSelectInputUntilChanged,
|
||||
getEl,
|
||||
disableElementsWhenTrue,
|
||||
disableElementsWhenValueNotEqual,
|
||||
} from "./utils.js";
|
||||
|
||||
// Switch between a single bundle price and one price per game. The per-game
|
||||
// inputs are the selection-fields element; this only sets the policy: the
|
||||
// hidden pricing_mode the view reads, the element's "active" flag, and whether
|
||||
// the bundle Price field is shown.
|
||||
function applyPricingMode(separate) {
|
||||
const pricingMode = getEl("#id_pricing_mode");
|
||||
if (pricingMode) pricingMode.value = separate ? "per_game" : "combined";
|
||||
let syncData = [
|
||||
{
|
||||
source: "#id_games",
|
||||
source_value: "dataset.platform",
|
||||
target: "#id_platform",
|
||||
target_value: "value",
|
||||
},
|
||||
];
|
||||
|
||||
const selectionFields = document.querySelector("selection-fields");
|
||||
if (selectionFields)
|
||||
selectionFields.setAttribute("active", separate ? "true" : "false");
|
||||
|
||||
const priceInput = getEl("#id_price");
|
||||
if (priceInput) {
|
||||
const wrapper = priceInput.closest("div");
|
||||
if (wrapper) wrapper.classList.toggle("hidden", separate);
|
||||
}
|
||||
}
|
||||
|
||||
// The games field is now a SearchSelect widget (a <div>, not a <select>), so we
|
||||
// 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;
|
||||
|
||||
// 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 = getEl("#id_platform");
|
||||
if (platformEl) platformEl.value = platformId;
|
||||
}
|
||||
|
||||
// The combined/per-game choice is only meaningful with 2+ games. Reveal the
|
||||
// checkbox there; below the threshold, fall back to a single bundle price.
|
||||
const separateRow = getEl("#separate-prices-row");
|
||||
const multipleGames = event.detail.values.length >= 2;
|
||||
if (separateRow) separateRow.classList.toggle("hidden", !multipleGames);
|
||||
if (!multipleGames) {
|
||||
const checkbox = getEl("#id_separate_prices");
|
||||
if (checkbox) checkbox.checked = false;
|
||||
applyPricingMode(false);
|
||||
}
|
||||
});
|
||||
|
||||
onSwap("#id_separate_prices", (checkbox) => {
|
||||
checkbox.addEventListener("change", () => applyPricingMode(checkbox.checked));
|
||||
});
|
||||
syncSelectInputUntilChanged(syncData, "form");
|
||||
|
||||
function setupElementHandlers() {
|
||||
disableElementsWhenTrue("#id_type", "game", [
|
||||
"#id_name",
|
||||
"#id_related_game",
|
||||
"#id_related_purchase",
|
||||
]);
|
||||
}
|
||||
|
||||
onSwap("#id_type", (typeSelect) => {
|
||||
document.addEventListener("DOMContentLoaded", setupElementHandlers);
|
||||
document.addEventListener("htmx:afterSwap", setupElementHandlers);
|
||||
getEl("#id_type").addEventListener("change", () => {
|
||||
setupElementHandlers();
|
||||
typeSelect.addEventListener("change", () => {
|
||||
setupElementHandlers();
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
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
@@ -1 +0,0 @@
|
||||
(()=>{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
@@ -1,530 +0,0 @@
|
||||
/**
|
||||
* 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
@@ -1,479 +0,0 @@
|
||||
/**
|
||||
* 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
@@ -1,230 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
})();
|
||||
@@ -1,664 +0,0 @@
|
||||
/**
|
||||
* 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,28 +1,3 @@
|
||||
/**
|
||||
* @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
|
||||
@@ -227,7 +202,6 @@ function disableElementsWhenTrue(targetSelect, targetValue, elementList) {
|
||||
}
|
||||
|
||||
export {
|
||||
onSwap,
|
||||
toISOUTCString,
|
||||
syncSelectInputUntilChanged,
|
||||
getEl,
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
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
-5
@@ -2,6 +2,8 @@ import logging
|
||||
|
||||
import requests
|
||||
from django.db import models
|
||||
from django.template.defaultfilters import floatformat
|
||||
|
||||
from games.models import ExchangeRate, Purchase
|
||||
|
||||
logger = logging.getLogger("games")
|
||||
@@ -36,7 +38,7 @@ def _get_exchange_rate(currency_from, currency_to, year):
|
||||
currency_from=currency_from,
|
||||
currency_to=currency_to,
|
||||
year=year,
|
||||
rate=rate,
|
||||
rate=floatformat(rate, 2),
|
||||
)
|
||||
rate = exchange_rate.rate
|
||||
else:
|
||||
@@ -58,9 +60,7 @@ 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():
|
||||
@@ -82,7 +82,7 @@ def convert_prices():
|
||||
if rate:
|
||||
_save_converted_price(
|
||||
purchase,
|
||||
round(purchase.price * rate, 0),
|
||||
floatformat(purchase.price * rate, 0),
|
||||
needs_update,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
class="text-black dark:text-white w-4 h-4">
|
||||
<path fill="currentColor" d="M14 4l2.29 2.29-2.88 2.88 1.42 1.42 2.88-2.88L20 12V4z M10 4H4v8l2.29-2.29 4.71 4.71V20h2v-8.41l-5.29-5.3z"></path>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 270 B |
@@ -9,5 +9,5 @@ register = template.Library()
|
||||
def randomid(seed: str = "") -> str:
|
||||
content_hash = hashlib.sha1(seed.encode()).hexdigest()
|
||||
if seed:
|
||||
return content_hash[: max(0, 10 - len(seed))] + seed
|
||||
return content_hash[:max(0, 10 - len(seed))] + seed
|
||||
return content_hash[:10]
|
||||
|
||||
+7
-26
@@ -2,7 +2,6 @@ from django.urls import path
|
||||
|
||||
from games.views import (
|
||||
device,
|
||||
filter_presets,
|
||||
game,
|
||||
general,
|
||||
platform,
|
||||
@@ -23,11 +22,7 @@ urlpatterns = [
|
||||
path("game/add", game.add_game, name="add_game"),
|
||||
path("game/<int:game_id>/edit", game.edit_game, name="edit_game"),
|
||||
path("game/<int:game_id>/view", game.view_game, name="view_game"),
|
||||
path(
|
||||
"game/<int:game_id>/delete/confirm",
|
||||
game.delete_game_confirmation,
|
||||
name="delete_game_confirmation",
|
||||
),
|
||||
path("game/<int:game_id>/delete/confirm", game.delete_game_confirmation, name="delete_game_confirmation"),
|
||||
path("game/<int:game_id>/delete", game.delete_game, name="delete_game"),
|
||||
path("game/list", game.list_games, name="list_games"),
|
||||
path("platform/add", platform.add_platform, name="add_platform"),
|
||||
@@ -106,14 +101,9 @@ urlpatterns = [
|
||||
name="refund_purchase",
|
||||
),
|
||||
path(
|
||||
"purchase/<int:purchase_id>/split/confirm",
|
||||
purchase.split_purchase_confirmation,
|
||||
name="split_purchase_confirmation",
|
||||
),
|
||||
path(
|
||||
"purchase/<int:purchase_id>/split",
|
||||
purchase.split_purchase,
|
||||
name="split_purchase",
|
||||
"purchase/related-purchase-by-game",
|
||||
purchase.related_purchase_by_game,
|
||||
name="related_purchase_by_game",
|
||||
),
|
||||
path("session/add", session.add_session, name="add_session"),
|
||||
path(
|
||||
@@ -170,18 +160,9 @@ urlpatterns = [
|
||||
name="list_statuschanges",
|
||||
),
|
||||
path("stats/", general.stats_alltime, name="stats_alltime"),
|
||||
path("stats/<int:year>", general.stats, name="stats_by_year"),
|
||||
# Filter presets
|
||||
path("filter/presets/list", filter_presets.list_presets, name="list_presets"),
|
||||
path("filter/presets/save", filter_presets.save_preset, name="save_preset"),
|
||||
path(
|
||||
"filter/presets/<int:preset_id>/delete",
|
||||
filter_presets.delete_preset,
|
||||
name="delete_preset",
|
||||
),
|
||||
path(
|
||||
"filter/presets/<int:preset_id>/load",
|
||||
filter_presets.load_preset,
|
||||
name="load_preset",
|
||||
"stats/<int:year>",
|
||||
general.stats,
|
||||
name="stats_by_year",
|
||||
),
|
||||
]
|
||||
|
||||
+15
-13
@@ -3,22 +3,24 @@ registration/login.html)."""
|
||||
|
||||
from django.contrib.auth import views as auth_views
|
||||
from django.http import HttpResponse
|
||||
from django.utils.safestring import SafeText, mark_safe
|
||||
|
||||
from common.components import CsrfInput, Div, Element, Input, Node, Safe
|
||||
from common.components.primitives import Td, Tr
|
||||
from common.components import Component, CsrfInput, Div, Input
|
||||
from common.layout import render_page
|
||||
|
||||
|
||||
def _login_content(form, request) -> Node:
|
||||
table = Element(
|
||||
"table",
|
||||
def _login_content(form, request) -> SafeText:
|
||||
table = Component(
|
||||
tag_name="table",
|
||||
children=[
|
||||
CsrfInput(request),
|
||||
Safe(str(form.as_table())),
|
||||
Tr(
|
||||
mark_safe(str(form.as_table())),
|
||||
Component(
|
||||
tag_name="tr",
|
||||
children=[
|
||||
Td(),
|
||||
Td(
|
||||
Component(tag_name="td"),
|
||||
Component(
|
||||
tag_name="td",
|
||||
children=[
|
||||
Input(type="submit", attributes=[("value", "Login")])
|
||||
],
|
||||
@@ -30,13 +32,13 @@ def _login_content(form, request) -> Node:
|
||||
return Div(
|
||||
[("class", "flex items-center flex-col")],
|
||||
[
|
||||
Element(
|
||||
"h2",
|
||||
Component(
|
||||
tag_name="h2",
|
||||
attributes=[("class", "text-3xl text-white mb-8")],
|
||||
children=["Please log in to continue"],
|
||||
),
|
||||
Element(
|
||||
"form",
|
||||
Component(
|
||||
tag_name="form",
|
||||
attributes=[("method", "post")],
|
||||
children=[table],
|
||||
),
|
||||
|
||||
+16
-27
@@ -1,4 +1,5 @@
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.paginator import Paginator
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.urls import reverse
|
||||
@@ -6,37 +7,35 @@ from django.urls import reverse
|
||||
from common.components import (
|
||||
A,
|
||||
AddForm,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
DeviceFilterBar,
|
||||
Fragment,
|
||||
Icon,
|
||||
StyledButton,
|
||||
paginated_table_content,
|
||||
)
|
||||
from common.layout import render_page
|
||||
from common.time import dateformat, local_strftime
|
||||
from common.utils import paginate
|
||||
from games.filters import parse_device_filter
|
||||
from games.forms import DeviceForm
|
||||
from games.models import Device
|
||||
|
||||
|
||||
@login_required
|
||||
def list_devices(request: HttpRequest) -> HttpResponse:
|
||||
page_number = request.GET.get("page", 1)
|
||||
limit = request.GET.get("limit", 10)
|
||||
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)
|
||||
page_obj = None
|
||||
if int(limit) != 0:
|
||||
paginator = Paginator(devices, limit)
|
||||
page_obj = paginator.get_page(page_number)
|
||||
devices = 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
|
||||
)
|
||||
|
||||
data = {
|
||||
"header_action": A(href=reverse("games:add_device"))[
|
||||
StyledButton()["Add device"]
|
||||
],
|
||||
"header_action": A([], Button([], "Add device"), url_name="games:add_device"),
|
||||
"columns": [
|
||||
"Name",
|
||||
"Type",
|
||||
@@ -72,17 +71,7 @@ def list_devices(request: HttpRequest) -> HttpResponse:
|
||||
elided_page_range=elided_page_range,
|
||||
request=request,
|
||||
)
|
||||
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 = Fragment(filter_bar, content)
|
||||
return render_page(
|
||||
request,
|
||||
content,
|
||||
title="Manage devices",
|
||||
)
|
||||
return render_page(request, content, title="Manage devices")
|
||||
|
||||
|
||||
@login_required
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
"""Views for managing saved filter presets (FilterPreset model)."""
|
||||
|
||||
import json
|
||||
from urllib.parse import quote
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.urls import reverse
|
||||
|
||||
from games.models import FilterPreset
|
||||
|
||||
|
||||
@login_required
|
||||
def list_presets(request: HttpRequest) -> HttpResponse:
|
||||
"""Return a preset dropdown as an HTML fragment."""
|
||||
mode = request.GET.get("mode", "games")
|
||||
presets = FilterPreset.objects.filter(mode=mode).order_by("name")
|
||||
|
||||
items: list[str] = []
|
||||
for preset in presets:
|
||||
filter_json = json.dumps(preset.object_filter) if preset.object_filter else ""
|
||||
list_url = reverse(f"games:list_{mode}")
|
||||
delete_url = reverse("games:delete_preset", args=[preset.id])
|
||||
|
||||
items.append(
|
||||
f"<li>"
|
||||
f'<a href="{list_url}?filter={quote(filter_json)}" '
|
||||
f'class="flex justify-between items-center px-4 py-2 text-sm '
|
||||
f'text-heading hover:bg-neutral-secondary-medium">'
|
||||
f"<span>{preset.name}</span>"
|
||||
f'<span class="text-red-500 hover:text-red-700 cursor-pointer ml-4" '
|
||||
f'data-delete-preset="{preset.id}" '
|
||||
f'href="{delete_url}">x</span>'
|
||||
f"</a></li>"
|
||||
)
|
||||
|
||||
if not items:
|
||||
items = ['<li class="px-4 py-2 text-sm text-body italic">No saved presets</li>']
|
||||
|
||||
return HttpResponse(f'<ul class="py-1">{"".join(items)}</ul>')
|
||||
|
||||
|
||||
@login_required
|
||||
def save_preset(request: HttpRequest) -> HttpResponse:
|
||||
"""Save the current filter as a new preset."""
|
||||
if request.method != "POST":
|
||||
return HttpResponse(status=405)
|
||||
|
||||
name = request.POST.get("name", "").strip()
|
||||
mode = request.POST.get("mode", "games")
|
||||
filter_json_str = request.POST.get("filter", "")
|
||||
|
||||
if not name:
|
||||
messages.error(request, "Preset name is required.")
|
||||
return HttpResponse(status=400)
|
||||
|
||||
object_filter: dict = {}
|
||||
if filter_json_str:
|
||||
try:
|
||||
object_filter = json.loads(filter_json_str)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
FilterPreset.objects.create(
|
||||
name=name,
|
||||
mode=mode,
|
||||
object_filter=object_filter,
|
||||
)
|
||||
messages.success(request, f'Filter preset "{name}" saved.')
|
||||
return HttpResponse(status=201)
|
||||
|
||||
|
||||
@login_required
|
||||
def delete_preset(request: HttpRequest, preset_id: int) -> HttpResponse:
|
||||
"""Delete a saved filter preset."""
|
||||
preset = get_object_or_404(FilterPreset, id=preset_id)
|
||||
name = preset.name
|
||||
preset.delete()
|
||||
messages.success(request, f'Preset "{name}" deleted.')
|
||||
return HttpResponse(status=200)
|
||||
|
||||
|
||||
@login_required
|
||||
def load_preset(request: HttpRequest, preset_id: int) -> HttpResponse:
|
||||
"""Load a preset and redirect to the appropriate list view."""
|
||||
preset = get_object_or_404(FilterPreset, id=preset_id)
|
||||
filter_json = json.dumps(preset.object_filter) if preset.object_filter else ""
|
||||
return redirect(
|
||||
f"{reverse(f'games:list_{preset.mode}')}?filter={quote(filter_json)}"
|
||||
)
|
||||
+335
-312
@@ -2,43 +2,38 @@ from typing import Any
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.paginator import Paginator
|
||||
from django.middleware.csrf import get_token
|
||||
from django.db.models import Q
|
||||
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
||||
from django.middleware.csrf import get_token
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.template.defaultfilters import date as date_filter
|
||||
from django.urls import reverse
|
||||
from django.utils.safestring import SafeText
|
||||
from django.utils.safestring import SafeText, mark_safe
|
||||
|
||||
from common.components import (
|
||||
H1,
|
||||
A,
|
||||
AddForm,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Component,
|
||||
CsrfInput,
|
||||
Div,
|
||||
Element,
|
||||
FilterBar,
|
||||
Fragment,
|
||||
GameStatus,
|
||||
GameStatusSelector,
|
||||
H1,
|
||||
Icon,
|
||||
SearchField,
|
||||
LinkedPurchase,
|
||||
Modal,
|
||||
ModuleScript,
|
||||
NameWithIcon,
|
||||
Node,
|
||||
Popover,
|
||||
PopoverTruncated,
|
||||
PurchasePrice,
|
||||
Safe,
|
||||
SearchField,
|
||||
SimpleTable,
|
||||
StyledButton,
|
||||
Ul,
|
||||
paginated_table_content,
|
||||
)
|
||||
from common.components.primitives import Li, P, Span, Strong
|
||||
from common.icons import get_icon
|
||||
from common.layout import render_page
|
||||
from common.time import (
|
||||
dateformat,
|
||||
@@ -46,8 +41,7 @@ from common.time import (
|
||||
local_strftime,
|
||||
timeformat,
|
||||
)
|
||||
from common.utils import build_dynamic_filter, paginate, safe_division, truncate
|
||||
from games.filters import parse_game_filter
|
||||
from common.utils import build_dynamic_filter, safe_division, truncate
|
||||
from games.forms import GameForm
|
||||
from games.models import Game
|
||||
from games.views.general import use_custom_redirect
|
||||
@@ -56,16 +50,10 @@ from games.views.playevent import create_playevent_tabledata
|
||||
|
||||
@login_required
|
||||
def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
|
||||
page_number = request.GET.get("page", 1)
|
||||
limit = request.GET.get("limit", 10)
|
||||
games = Game.objects.order_by("-created_at")
|
||||
|
||||
# ── Structured filter (Stash-style JSON) ──
|
||||
filter_json = request.GET.get("filter", "")
|
||||
if filter_json:
|
||||
game_filter = parse_game_filter(filter_json)
|
||||
if game_filter is not None:
|
||||
games = games.filter(game_filter.to_q())
|
||||
else:
|
||||
# ── Legacy free-text search ──
|
||||
page_obj = None
|
||||
search_string = request.GET.get("search_string", search_string)
|
||||
if search_string != "":
|
||||
filters = [
|
||||
@@ -80,21 +68,31 @@ def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
|
||||
if year_value:
|
||||
filters.append(Q(year_released=year_value))
|
||||
search_string_parts = search_string.split()
|
||||
# only search for status if it exactly matches and is the only word
|
||||
if len(search_string_parts) == 1:
|
||||
if search_string.title() in Game.Status.labels:
|
||||
search_status = Game.Status[search_string.upper()]
|
||||
filters.append(Q(status=search_status))
|
||||
games = games.filter(build_dynamic_filter(filters, "|"))
|
||||
if int(limit) != 0:
|
||||
paginator = Paginator(games, limit)
|
||||
page_obj = paginator.get_page(page_number)
|
||||
games = page_obj.object_list
|
||||
|
||||
games, page_obj, elided_page_range = paginate(request, games)
|
||||
elided_page_range = (
|
||||
page_obj.paginator.get_elided_page_range(page_number, on_each_side=1, on_ends=1)
|
||||
if page_obj
|
||||
else None
|
||||
)
|
||||
|
||||
data = {
|
||||
"header_action": Div(
|
||||
class_="flex justify-between",
|
||||
)[
|
||||
children=[
|
||||
SearchField(search_string=search_string),
|
||||
A(href=reverse("games:add_game"))[StyledButton()["Add game"]],
|
||||
A([], Button([], "Add game"), url_name="games:add_game"),
|
||||
],
|
||||
attributes=[("class", "flex justify-between")],
|
||||
),
|
||||
"columns": [
|
||||
"Name",
|
||||
"Sort Name",
|
||||
@@ -140,18 +138,7 @@ def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
|
||||
elided_page_range=elided_page_range,
|
||||
request=request,
|
||||
)
|
||||
# Prepend the filter bar above the table
|
||||
filter_bar = FilterBar(
|
||||
filter_json=filter_json,
|
||||
preset_list_url=reverse("games:list_presets"),
|
||||
preset_save_url=reverse("games:save_preset"),
|
||||
)
|
||||
content = Fragment(filter_bar, content)
|
||||
return render_page(
|
||||
request,
|
||||
content,
|
||||
title="Manage games",
|
||||
)
|
||||
return render_page(request, content, title="Manage games")
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -171,7 +158,7 @@ def add_game(request: HttpRequest) -> HttpResponse:
|
||||
AddForm(
|
||||
form,
|
||||
request=request,
|
||||
additional_row=StyledButton(
|
||||
additional_row=Button(
|
||||
[],
|
||||
"Submit & Create Purchase",
|
||||
color="gray",
|
||||
@@ -180,7 +167,7 @@ def add_game(request: HttpRequest) -> HttpResponse:
|
||||
),
|
||||
),
|
||||
title="Add New Game",
|
||||
scripts=ModuleScript("search_select.js") + ModuleScript("add_game.js"),
|
||||
scripts=ModuleScript("add_game.js"),
|
||||
)
|
||||
|
||||
|
||||
@@ -193,16 +180,22 @@ def _delete_game_confirmation_modal(
|
||||
) -> SafeText:
|
||||
data_items = []
|
||||
if session_count:
|
||||
data_items.append(Li(children=[f"{session_count} session(s)"]))
|
||||
data_items.append(
|
||||
Component(tag_name="li", children=[f"{session_count} session(s)"])
|
||||
)
|
||||
if purchase_count:
|
||||
data_items.append(Li(children=[f"{purchase_count} purchase(s)"]))
|
||||
data_items.append(
|
||||
Component(tag_name="li", children=[f"{purchase_count} purchase(s)"])
|
||||
)
|
||||
if playevent_count:
|
||||
data_items.append(Li(children=[f"{playevent_count} play event(s)"]))
|
||||
data_items.append(
|
||||
Component(tag_name="li", children=[f"{playevent_count} play event(s)"])
|
||||
)
|
||||
if not (session_count or purchase_count or playevent_count):
|
||||
data_items.append(Li(children=["No associated data"]))
|
||||
data_items.append(Component(tag_name="li", children=["No associated data"]))
|
||||
|
||||
form = Element(
|
||||
"form",
|
||||
form = Component(
|
||||
tag_name="form",
|
||||
attributes=[
|
||||
("hx-post", reverse("games:delete_game", args=[game.id])),
|
||||
("hx-replace-url", "true"),
|
||||
@@ -212,7 +205,8 @@ def _delete_game_confirmation_modal(
|
||||
],
|
||||
children=[
|
||||
CsrfInput(request),
|
||||
P(
|
||||
Component(
|
||||
tag_name="p",
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
@@ -224,7 +218,8 @@ def _delete_game_confirmation_modal(
|
||||
"This will permanently delete this game and all associated data:"
|
||||
],
|
||||
),
|
||||
Ul(
|
||||
Component(
|
||||
tag_name="ul",
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
@@ -234,7 +229,8 @@ def _delete_game_confirmation_modal(
|
||||
],
|
||||
children=data_items,
|
||||
),
|
||||
P(
|
||||
Component(
|
||||
tag_name="p",
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
@@ -247,14 +243,14 @@ def _delete_game_confirmation_modal(
|
||||
Div(
|
||||
[("class", "items-center mt-5")],
|
||||
[
|
||||
StyledButton(
|
||||
Button(
|
||||
[("class", "w-full")],
|
||||
"Delete",
|
||||
color="red",
|
||||
size="lg",
|
||||
type="submit",
|
||||
),
|
||||
StyledButton(
|
||||
Button(
|
||||
[("class", "mt-0 w-full")],
|
||||
"Cancel",
|
||||
color="gray",
|
||||
@@ -270,7 +266,8 @@ def _delete_game_confirmation_modal(
|
||||
return Modal(
|
||||
"delete-game-confirmation-modal",
|
||||
children=[
|
||||
P(
|
||||
Component(
|
||||
tag_name="h1",
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
@@ -279,11 +276,12 @@ def _delete_game_confirmation_modal(
|
||||
],
|
||||
children=["Delete Game"],
|
||||
),
|
||||
P(
|
||||
Component(
|
||||
tag_name="p",
|
||||
attributes=[("class", "dark:text-white text-center mt-5")],
|
||||
children=[
|
||||
"Are you sure you want to delete ",
|
||||
Strong(children=[game.name]),
|
||||
Component(tag_name="strong", children=[game.name]),
|
||||
"?",
|
||||
],
|
||||
),
|
||||
@@ -321,12 +319,7 @@ def edit_game(request: HttpRequest, game_id: int) -> HttpResponse:
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
return redirect("games:list_sessions")
|
||||
return render_page(
|
||||
request,
|
||||
AddForm(form, request=request),
|
||||
title="Edit Game",
|
||||
scripts=ModuleScript("search_select.js"),
|
||||
)
|
||||
return render_page(request, AddForm(form, request=request), title="Edit Game")
|
||||
|
||||
|
||||
# --- view_game content builders -------------------------------------------
|
||||
@@ -338,69 +331,69 @@ _STAT_SVGS = {
|
||||
"playrange": '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><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.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5m-9-6h.008v.008H12v-.008ZM12 15h.008v.008H12V15Zm0 2.25h.008v.008H12v-.008ZM9.75 15h.008v.008H9.75V15Zm0 2.25h.008v.008H9.75v-.008ZM7.5 15h.008v.008H7.5V15Zm0 2.25h.008v.008H7.5v-.008Zm6.75-4.5h.008v.008h-.008v-.008Zm0 2.25h.008v.008h-.008V15Zm0 2.25h.008v.008h-.008v-.008Zm2.25-4.5h.008v.008H16.5v-.008Zm0 2.25h.008v.008H16.5V15Z" /></svg>',
|
||||
}
|
||||
|
||||
_PLAYED_BTN = (
|
||||
"px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 "
|
||||
"hover:bg-gray-100 hover:text-blue-700 dark:bg-gray-800 dark:border-gray-700 "
|
||||
"dark:text-white dark:hover:bg-gray-700 hover:cursor-pointer"
|
||||
)
|
||||
_PLAYED_MENU = (
|
||||
"absolute top-full -left-px w-auto whitespace-nowrap z-10 text-sm font-medium "
|
||||
"bg-gray-800/20 backdrop-blur-lg rounded-md rounded-tl-none border "
|
||||
"border-gray-200 dark:border-gray-700"
|
||||
)
|
||||
_PLAYED_ROW_TEMPLATE = """<div class="flex gap-2 items-center" x-data="{ open: false }">
|
||||
<span class="uppercase">Played</span>
|
||||
<div class="inline-flex rounded-md shadow-2xs" role="group" x-data="{ played: @@PLAYED_COUNT@@ }">
|
||||
<a href="@@ADD_PE@@">
|
||||
<button type="button" class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-s-lg 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">
|
||||
<span x-text="played"></span> times
|
||||
</button>
|
||||
</a>
|
||||
<button type="button" x-on:click="open = !open" @click.outside="open = false" class="relative px-4 py-2 text-sm font-medium text-gray-900 bg-white border-e border-b border-t border-gray-200 rounded-e-lg 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 align-middle hover:cursor-pointer">
|
||||
@@ARROWDOWN@@
|
||||
<div
|
||||
class="absolute top-full -left-px w-auto whitespace-nowrap z-10 text-sm font-medium bg-gray-800/20 backdrop-blur-lg rounded-md rounded-tl-none border border-gray-200 dark:border-gray-700"
|
||||
x-show="open"
|
||||
>
|
||||
<ul
|
||||
class=""
|
||||
>
|
||||
<li class="px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white rounded-tr-md">
|
||||
<a href="@@ADD_PE_FOR_GAME@@">Add playthrough...</a>
|
||||
</li>
|
||||
<li
|
||||
x-on:click="createPlayEvent"
|
||||
class="relative px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white rounded-b-md"
|
||||
>
|
||||
Played times +1
|
||||
</li>
|
||||
<script>
|
||||
function createPlayEvent() {
|
||||
this.played++;
|
||||
// TODO: migrate to hx-post + hx-on::after-request for HTMX-native toast handling
|
||||
fetchWithHtmxTriggers('@@API_CREATE@@', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRFToken': '@@CSRF@@', 'Content-Type': 'application/json' },
|
||||
body: '{"game_id": @@GAME_ID@@}'
|
||||
})
|
||||
.catch(() => {
|
||||
this.played--;
|
||||
console.error('Failed to record play');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</ul>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>"""
|
||||
|
||||
|
||||
def _played_row(game: Game, request: HttpRequest) -> Node:
|
||||
"""'Played N times' control as a custom element (ts/elements/play-event-row.ts)."""
|
||||
from common.components import Element
|
||||
from common.components.custom_elements import _PlayEventRow
|
||||
from common.components.primitives import Button
|
||||
|
||||
played: int = 0
|
||||
played = game.playevents.count()
|
||||
|
||||
count_button = A(href=reverse("games:add_playevent"))[
|
||||
Button(class_=_PLAYED_BTN + " rounded-s-lg")[
|
||||
Span(data_count="")[str(played)], " times"
|
||||
]
|
||||
]
|
||||
menu = Div(data_menu="", hidden=True, class_=_PLAYED_MENU)[
|
||||
Ul()[
|
||||
Li(class_="px-4 py-2")[
|
||||
A(href=reverse("games:add_playevent_for_game", args=[game.id]))[
|
||||
"Add playthrough..."
|
||||
]
|
||||
],
|
||||
Li(class_="px-4 py-2 cursor-pointer")[
|
||||
Element(
|
||||
"button",
|
||||
[("type", "button"), ("data-add-play", "")],
|
||||
children=["Played times +1"],
|
||||
)
|
||||
],
|
||||
]
|
||||
]
|
||||
toggle = Element(
|
||||
"button",
|
||||
[
|
||||
("type", "button"),
|
||||
("data-toggle", ""),
|
||||
("class", _PLAYED_BTN + " rounded-e-lg"),
|
||||
],
|
||||
[Icon("arrowdown")],
|
||||
)
|
||||
# Menu is a SIBLING of the toggle (not nested inside it): a <button> may not
|
||||
# contain another <button>, and that invalid nesting makes the HTML parser
|
||||
# close ancestors early, ejecting later page sections from their container.
|
||||
toggle_group = Div(class_="relative inline-flex")[toggle, menu]
|
||||
group = Div(class_="inline-flex items-stretch rounded-md shadow-2xs")[
|
||||
count_button, toggle_group
|
||||
]
|
||||
return _PlayEventRow(
|
||||
game_id=game.id,
|
||||
csrf=get_token(request),
|
||||
api_create_url=reverse("api-1.0.0:create_playevent"),
|
||||
)[Div(class_="flex gap-2 items-center")[Span(class_="uppercase")["Played"], group]]
|
||||
def _played_row(game: Game, request: HttpRequest) -> SafeText:
|
||||
"""The 'Played N times' control with its Alpine.js dropdown."""
|
||||
replacements = {
|
||||
"@@PLAYED_COUNT@@": str(game.playevents.count()),
|
||||
"@@ADD_PE@@": reverse("games:add_playevent"),
|
||||
"@@ARROWDOWN@@": get_icon("arrowdown"),
|
||||
"@@ADD_PE_FOR_GAME@@": reverse("games:add_playevent_for_game", args=[game.id]),
|
||||
"@@API_CREATE@@": reverse("api-1.0.0:create_playevent"),
|
||||
"@@CSRF@@": get_token(request),
|
||||
"@@GAME_ID@@": str(game.id),
|
||||
}
|
||||
html = _PLAYED_ROW_TEMPLATE
|
||||
for token, value in replacements.items():
|
||||
html = html.replace(token, value)
|
||||
return mark_safe(html)
|
||||
|
||||
|
||||
def _stat_popover(popover_id: str, tooltip: str, svg_key: str, value: str) -> SafeText:
|
||||
@@ -408,13 +401,17 @@ def _stat_popover(popover_id: str, tooltip: str, svg_key: str, value: str) -> Sa
|
||||
popover_content=tooltip,
|
||||
wrapped_classes="flex gap-2 items-center",
|
||||
id=popover_id,
|
||||
children=[Safe(_STAT_SVGS[svg_key]), str(value)],
|
||||
children=[mark_safe(_STAT_SVGS[svg_key]), str(value)],
|
||||
)
|
||||
|
||||
|
||||
def _meta_row(label: str, value: Node | str, extra: Node | str = "") -> Node:
|
||||
children: list[Node | str] = [
|
||||
Span(attributes=[("class", "uppercase")], children=[label]),
|
||||
def _meta_row(
|
||||
label: str, value: SafeText | str, extra: SafeText | str = ""
|
||||
) -> SafeText:
|
||||
children: list[SafeText | str] = [
|
||||
Component(
|
||||
tag_name="span", attributes=[("class", "uppercase")], children=[label]
|
||||
),
|
||||
value,
|
||||
]
|
||||
if extra:
|
||||
@@ -437,25 +434,27 @@ def _game_action_buttons(game: Game) -> SafeText:
|
||||
"dark:text-white dark:hover:text-white dark:hover:bg-red-700 "
|
||||
"dark:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer"
|
||||
)
|
||||
edit_link = A(
|
||||
href=reverse("games:edit_game", args=[game.id]),
|
||||
edit_link = Component(
|
||||
tag_name="a",
|
||||
attributes=[("href", reverse("games:edit_game", args=[game.id]))],
|
||||
children=[
|
||||
Element(
|
||||
"button",
|
||||
Component(
|
||||
tag_name="button",
|
||||
attributes=[("type", "button"), ("class", edit_class)],
|
||||
children=["Edit"],
|
||||
)
|
||||
],
|
||||
)
|
||||
delete_link = A(
|
||||
href="#",
|
||||
delete_link = Component(
|
||||
tag_name="a",
|
||||
attributes=[
|
||||
("href", "#"),
|
||||
("hx-get", reverse("games:delete_game_confirmation", args=[game.id])),
|
||||
("hx-target", "#global-modal-container"),
|
||||
],
|
||||
children=[
|
||||
Element(
|
||||
"button",
|
||||
Component(
|
||||
tag_name="button",
|
||||
attributes=[("type", "button"), ("class", delete_class)],
|
||||
children=["Delete"],
|
||||
)
|
||||
@@ -482,16 +481,21 @@ def _game_history(statuschanges) -> SafeText:
|
||||
status=change.new_status,
|
||||
children=[change.get_new_status_display()],
|
||||
)
|
||||
edit = A(
|
||||
href=reverse("games:edit_statuschange", args=[change.id]),
|
||||
edit = Component(
|
||||
tag_name="a",
|
||||
attributes=[("href", reverse("games:edit_statuschange", args=[change.id]))],
|
||||
children=["Edit"],
|
||||
)
|
||||
delete = A(
|
||||
href=reverse("games:delete_statuschange", args=[change.id]),
|
||||
delete = Component(
|
||||
tag_name="a",
|
||||
attributes=[
|
||||
("href", reverse("games:delete_statuschange", args=[change.id]))
|
||||
],
|
||||
children=["Delete"],
|
||||
)
|
||||
items.append(
|
||||
Li(
|
||||
Component(
|
||||
tag_name="li",
|
||||
attributes=[("class", "text-slate-500")],
|
||||
children=[
|
||||
f"{prefix} status from ",
|
||||
@@ -506,7 +510,8 @@ def _game_history(statuschanges) -> SafeText:
|
||||
],
|
||||
)
|
||||
)
|
||||
return Ul(
|
||||
return Component(
|
||||
tag_name="ul",
|
||||
attributes=[("class", "list-disc list-inside")],
|
||||
children=items,
|
||||
)
|
||||
@@ -524,125 +529,36 @@ def _game_section(
|
||||
)
|
||||
|
||||
|
||||
def _game_overview_metrics(game: Game) -> dict[str, Any]:
|
||||
"""Request-free header metrics: total session count, play range, and the
|
||||
per-session average (excluding manually-logged sessions)."""
|
||||
@login_required
|
||||
def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
|
||||
game = Game.objects.get(id=game_id)
|
||||
purchases = game.purchases.order_by("date_purchased")
|
||||
|
||||
sessions = game.sessions
|
||||
session_count = sessions.count()
|
||||
session_count_without_manual = sessions.without_manual().count()
|
||||
session_count_without_manual = game.sessions.without_manual().count()
|
||||
|
||||
if sessions.exists():
|
||||
start = local_strftime(sessions.earliest().timestamp_start, "%b %Y")
|
||||
end = local_strftime(sessions.latest().timestamp_start, "%b %Y")
|
||||
playrange = start if start == end else f"{start} — {end}"
|
||||
playrange_start = local_strftime(sessions.earliest().timestamp_start, "%b %Y")
|
||||
latest_session = sessions.latest()
|
||||
playrange_end = local_strftime(latest_session.timestamp_start, "%b %Y")
|
||||
|
||||
playrange = (
|
||||
playrange_start
|
||||
if playrange_start == playrange_end
|
||||
else f"{playrange_start} — {playrange_end}"
|
||||
)
|
||||
else:
|
||||
playrange = "N/A"
|
||||
latest_session = None
|
||||
|
||||
total_hours_without_manual = float(
|
||||
format_duration(sessions.calculated_duration_unformatted(), "%2.1H")
|
||||
)
|
||||
session_average_without_manual = round(
|
||||
safe_division(total_hours_without_manual, int(session_count_without_manual)), 1
|
||||
)
|
||||
return {
|
||||
"session_count": session_count,
|
||||
"playrange": playrange,
|
||||
"session_average_without_manual": session_average_without_manual,
|
||||
}
|
||||
|
||||
|
||||
def _game_header(game: Game, request: HttpRequest, metrics: dict[str, Any]) -> SafeText:
|
||||
grey_value_class = "text-black dark:text-slate-300"
|
||||
title_span = Span(
|
||||
attributes=[("class", "text-balance max-w-120 text-4xl")],
|
||||
children=[
|
||||
Span(
|
||||
attributes=[("class", "font-bold font-serif")],
|
||||
children=[game.name],
|
||||
),
|
||||
]
|
||||
+ (
|
||||
[
|
||||
Safe(" "),
|
||||
Popover(
|
||||
popover_content="Original release year",
|
||||
wrapped_classes="text-slate-500 text-2xl",
|
||||
id="popover-year",
|
||||
children=[str(game.year_released)],
|
||||
),
|
||||
]
|
||||
if game.year_released
|
||||
else []
|
||||
),
|
||||
)
|
||||
stats_row = Div(
|
||||
[("class", "flex gap-4 dark:text-slate-400 mb-3")],
|
||||
[
|
||||
_stat_popover(
|
||||
"popover-hours",
|
||||
"Total hours played",
|
||||
"hours",
|
||||
game.playtime_formatted(),
|
||||
),
|
||||
_stat_popover(
|
||||
"popover-sessions",
|
||||
"Number of sessions",
|
||||
"sessions",
|
||||
metrics["session_count"],
|
||||
),
|
||||
_stat_popover(
|
||||
"popover-average",
|
||||
"Average playtime per session",
|
||||
"average",
|
||||
metrics["session_average_without_manual"],
|
||||
),
|
||||
_stat_popover(
|
||||
"popover-playrange",
|
||||
"Earliest and latest dates played",
|
||||
"playrange",
|
||||
metrics["playrange"],
|
||||
),
|
||||
],
|
||||
)
|
||||
metadata = Div(
|
||||
[("class", "flex flex-col mb-6 text-gray-600 dark:text-slate-400 gap-y-4")],
|
||||
[
|
||||
_meta_row(
|
||||
"Original year",
|
||||
Span(
|
||||
attributes=[("class", grey_value_class)],
|
||||
children=[str(game.original_year_released)],
|
||||
),
|
||||
),
|
||||
_meta_row(
|
||||
"Status",
|
||||
GameStatusSelector(game, Game.Status.choices, get_token(request)),
|
||||
"👑" if game.mastered else "",
|
||||
),
|
||||
_played_row(game, request),
|
||||
_meta_row(
|
||||
"Platform",
|
||||
Span(
|
||||
attributes=[("class", grey_value_class)],
|
||||
children=[str(game.platform)],
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
return Div(
|
||||
[("id", "game-info"), ("class", "mb-10")],
|
||||
[
|
||||
Div([("class", "flex gap-5 mb-3")], [title_span]),
|
||||
stats_row,
|
||||
metadata,
|
||||
_game_action_buttons(game),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _purchases_section(game: Game) -> SafeText:
|
||||
purchases = game.purchases.order_by("date_purchased")
|
||||
rows = [
|
||||
purchase_data: dict[str, Any] = {
|
||||
"columns": ["Name", "Type", "Date", "Price", "Actions"],
|
||||
"rows": [
|
||||
[
|
||||
LinkedPurchase(purchase),
|
||||
purchase.get_type_display(),
|
||||
@@ -656,7 +572,9 @@ def _purchases_section(game: Game) -> SafeText:
|
||||
"color": "gray",
|
||||
},
|
||||
{
|
||||
"href": reverse("games:delete_purchase", args=[purchase.pk]),
|
||||
"href": reverse(
|
||||
"games:delete_purchase", args=[purchase.pk]
|
||||
),
|
||||
"slot": Icon("delete"),
|
||||
"color": "red",
|
||||
},
|
||||
@@ -664,29 +582,31 @@ def _purchases_section(game: Game) -> SafeText:
|
||||
),
|
||||
]
|
||||
for purchase in purchases
|
||||
]
|
||||
table = SimpleTable(columns=["Name", "Type", "Date", "Price", "Actions"], rows=rows)
|
||||
return _game_section("Purchases", purchases.count(), table, "No purchases yet.")
|
||||
|
||||
|
||||
def _sessions_section(game: Game, request: HttpRequest) -> SafeText:
|
||||
sessions_all = game.sessions.order_by("-timestamp_start")
|
||||
session_count = sessions_all.count()
|
||||
last_session = sessions_all.latest() if sessions_all.exists() else None
|
||||
|
||||
page_number = request.GET.get("page", 1)
|
||||
page_obj = Paginator(sessions_all, 5).get_page(page_number)
|
||||
elided_page_range = (
|
||||
page_obj.paginator.get_elided_page_range(page_number, on_each_side=1, on_ends=1)
|
||||
if session_count > 5
|
||||
else None
|
||||
)
|
||||
|
||||
header_action = Div(
|
||||
children=[
|
||||
A(href=reverse("games:add_session"))[
|
||||
StyledButton(icon=True, color="blue", size="xs")[Icon("plus")]
|
||||
],
|
||||
}
|
||||
|
||||
sessions_all = game.sessions.order_by("-timestamp_start")
|
||||
|
||||
last_session = None
|
||||
if sessions_all.exists():
|
||||
last_session = sessions_all.latest()
|
||||
session_count = sessions_all.count()
|
||||
session_paginator = Paginator(sessions_all, 5)
|
||||
page_number = request.GET.get("page", 1)
|
||||
session_page_obj = session_paginator.get_page(page_number)
|
||||
sessions = session_page_obj.object_list
|
||||
|
||||
session_data: dict[str, Any] = {
|
||||
"header_action": Div(
|
||||
children=[
|
||||
A(
|
||||
url_name="games:add_session",
|
||||
children=Button(
|
||||
icon=True,
|
||||
size="xs",
|
||||
children=[Icon("play"), "LOG"],
|
||||
),
|
||||
),
|
||||
A(
|
||||
href=reverse(
|
||||
"games:list_sessions_start_session_from_session",
|
||||
@@ -695,7 +615,7 @@ def _sessions_section(game: Game, request: HttpRequest) -> SafeText:
|
||||
children=Popover(
|
||||
popover_content=last_session.game.name,
|
||||
children=[
|
||||
StyledButton(
|
||||
Button(
|
||||
icon=True,
|
||||
color="gray",
|
||||
size="xs",
|
||||
@@ -710,8 +630,9 @@ def _sessions_section(game: Game, request: HttpRequest) -> SafeText:
|
||||
if last_session
|
||||
else "",
|
||||
],
|
||||
)
|
||||
rows = [
|
||||
),
|
||||
"columns": ["Game", "Date", "Duration", "Actions"],
|
||||
"rows": [
|
||||
[
|
||||
NameWithIcon(session=session),
|
||||
f"{local_strftime(session.timestamp_start)}{f' — {local_strftime(session.timestamp_end, timeformat)}' if session.timestamp_end else ''}",
|
||||
@@ -741,31 +662,133 @@ def _sessions_section(game: Game, request: HttpRequest) -> SafeText:
|
||||
]
|
||||
),
|
||||
]
|
||||
for session in page_obj.object_list
|
||||
for session in sessions
|
||||
],
|
||||
}
|
||||
|
||||
playevents = game.playevents.all()
|
||||
playevent_count = playevents.count()
|
||||
playevent_data = create_playevent_tabledata(playevents, exclude_columns=["Game"])
|
||||
|
||||
statuschanges = game.status_changes.all()
|
||||
statuschange_count = statuschanges.count()
|
||||
|
||||
purchase_count = game.purchases.count()
|
||||
status_selector_html = GameStatusSelector(
|
||||
game, Game.Status.choices, get_token(request)
|
||||
)
|
||||
session_average_without_manual = round(
|
||||
safe_division(total_hours_without_manual, int(session_count_without_manual)),
|
||||
1,
|
||||
)
|
||||
|
||||
grey_value_class = "text-black dark:text-slate-300"
|
||||
title_span = Component(
|
||||
tag_name="span",
|
||||
attributes=[("class", "text-balance max-w-120 text-4xl")],
|
||||
children=[
|
||||
Component(
|
||||
tag_name="span",
|
||||
attributes=[("class", "font-bold font-serif")],
|
||||
children=[game.name],
|
||||
),
|
||||
]
|
||||
table = SimpleTable(
|
||||
columns=["Game", "Date", "Duration", "Actions"],
|
||||
rows=rows,
|
||||
header_action=header_action,
|
||||
page_obj=page_obj,
|
||||
elided_page_range=elided_page_range,
|
||||
+ (
|
||||
[
|
||||
mark_safe(" "),
|
||||
Popover(
|
||||
popover_content="Original release year",
|
||||
wrapped_classes="text-slate-500 text-2xl",
|
||||
id="popover-year",
|
||||
children=[str(game.year_released)],
|
||||
),
|
||||
]
|
||||
if game.year_released
|
||||
else []
|
||||
),
|
||||
)
|
||||
title_row = Div([("class", "flex gap-5 mb-3")], [title_span])
|
||||
|
||||
stats_row = Div(
|
||||
[("class", "flex gap-4 dark:text-slate-400 mb-3")],
|
||||
[
|
||||
_stat_popover(
|
||||
"popover-hours",
|
||||
"Total hours played",
|
||||
"hours",
|
||||
game.playtime_formatted(),
|
||||
),
|
||||
_stat_popover(
|
||||
"popover-sessions", "Number of sessions", "sessions", session_count
|
||||
),
|
||||
_stat_popover(
|
||||
"popover-average",
|
||||
"Average playtime per session",
|
||||
"average",
|
||||
session_average_without_manual,
|
||||
),
|
||||
_stat_popover(
|
||||
"popover-playrange",
|
||||
"Earliest and latest dates played",
|
||||
"playrange",
|
||||
playrange,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
metadata = Div(
|
||||
[("class", "flex flex-col mb-6 text-gray-600 dark:text-slate-400 gap-y-4")],
|
||||
[
|
||||
_meta_row(
|
||||
"Original year",
|
||||
Component(
|
||||
tag_name="span",
|
||||
attributes=[("class", grey_value_class)],
|
||||
children=[str(game.original_year_released)],
|
||||
),
|
||||
),
|
||||
_meta_row("Status", status_selector_html, "👑" if game.mastered else ""),
|
||||
_played_row(game, request),
|
||||
_meta_row(
|
||||
"Platform",
|
||||
Component(
|
||||
tag_name="span",
|
||||
attributes=[("class", grey_value_class)],
|
||||
children=[str(game.platform)],
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
game_info = Div(
|
||||
[("id", "game-info"), ("class", "mb-10")],
|
||||
[title_row, stats_row, metadata, _game_action_buttons(game)],
|
||||
)
|
||||
|
||||
session_elided_page_range = (
|
||||
session_page_obj.paginator.get_elided_page_range(
|
||||
page_number, on_each_side=1, on_ends=1
|
||||
)
|
||||
if session_page_obj and session_count > 5
|
||||
else None
|
||||
)
|
||||
|
||||
purchases_table = SimpleTable(
|
||||
columns=purchase_data["columns"], rows=purchase_data["rows"]
|
||||
)
|
||||
sessions_table = SimpleTable(
|
||||
columns=session_data["columns"],
|
||||
rows=session_data["rows"],
|
||||
header_action=session_data["header_action"],
|
||||
page_obj=session_page_obj,
|
||||
elided_page_range=session_elided_page_range,
|
||||
request=request,
|
||||
)
|
||||
return _game_section("Sessions", session_count, table, "No sessions yet.")
|
||||
|
||||
|
||||
def _playevents_section(game: Game) -> SafeText:
|
||||
playevents = game.playevents.all()
|
||||
data = create_playevent_tabledata(playevents, exclude_columns=["Game"])
|
||||
table = SimpleTable(columns=data["columns"], rows=data["rows"])
|
||||
return _game_section(
|
||||
"Play Events", playevents.count(), table, "No play events yet."
|
||||
playevents_table = SimpleTable(
|
||||
columns=playevent_data["columns"], rows=playevent_data["rows"]
|
||||
)
|
||||
|
||||
|
||||
def _history_section(game: Game) -> SafeText:
|
||||
statuschanges = game.status_changes.all()
|
||||
return Div(
|
||||
history = Div(
|
||||
[
|
||||
("class", "mb-6"),
|
||||
("id", "history-container"),
|
||||
@@ -775,36 +798,36 @@ def _history_section(game: Game) -> SafeText:
|
||||
("hx-swap", "outerHTML"),
|
||||
],
|
||||
[
|
||||
H1(children=["History"], badge=statuschanges.count()),
|
||||
H1(children=["History"], badge=statuschange_count),
|
||||
_game_history(statuschanges),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
_GET_SESSION_COUNT_SCRIPT = Safe(
|
||||
content = Div(
|
||||
[("class", "dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto")],
|
||||
[
|
||||
game_info,
|
||||
_game_section(
|
||||
"Purchases", purchase_count, purchases_table, "No purchases yet."
|
||||
),
|
||||
_game_section(
|
||||
"Sessions", session_count, sessions_table, "No sessions yet."
|
||||
),
|
||||
_game_section(
|
||||
"Play Events", playevent_count, playevents_table, "No play events yet."
|
||||
),
|
||||
history,
|
||||
mark_safe(
|
||||
"<script>\n"
|
||||
" function getSessionCount() {\n"
|
||||
" return document.getElementById('session-count')"
|
||||
'.textContent.match("[0-9]+");\n'
|
||||
" }\n"
|
||||
" </script>"
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
|
||||
game = Game.objects.get(id=game_id)
|
||||
content = Div(
|
||||
[("class", "dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto")],
|
||||
[
|
||||
_game_header(game, request, _game_overview_metrics(game)),
|
||||
_purchases_section(game),
|
||||
_sessions_section(game, request),
|
||||
_playevents_section(game),
|
||||
_history_section(game),
|
||||
_GET_SESSION_COUNT_SCRIPT,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
request.session["return_path"] = request.path
|
||||
return render_page(
|
||||
request,
|
||||
|
||||
+468
-17
@@ -3,36 +3,39 @@ from typing import Any, Callable
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db.models import (
|
||||
Avg,
|
||||
Count,
|
||||
ExpressionWrapper,
|
||||
F,
|
||||
Max,
|
||||
OuterRef,
|
||||
Prefetch,
|
||||
Q,
|
||||
Subquery,
|
||||
Sum,
|
||||
fields,
|
||||
)
|
||||
from django.db.models.functions import TruncDate, TruncMonth
|
||||
|
||||
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import localtime
|
||||
from django.utils.timezone import now as timezone_now
|
||||
|
||||
from common.layout import render_page
|
||||
from common.time import format_duration
|
||||
from common.time import available_stats_year_range, dateformat, format_duration
|
||||
from common.utils import safe_division
|
||||
from games.models import Game, Platform, Purchase, Session
|
||||
from games.views.stats_content import stats_content
|
||||
from games.views.stats_data import compute_stats
|
||||
|
||||
# The Flowbite-datepicker UMD bundle is declared as media on the YearPicker
|
||||
# component, so Page() loads it automatically on the stats pages.
|
||||
|
||||
|
||||
def model_counts(request: HttpRequest) -> dict[str, bool]:
|
||||
now = timezone_now()
|
||||
# Use a contiguous [midnight, next midnight) range in the active timezone
|
||||
# instead of day/month/year extracts: a range filter can use an index on
|
||||
# timestamp_start, whereas the extracts force a per-row datetime function.
|
||||
start_of_today = localtime(now).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
start_of_tomorrow = start_of_today + timedelta(days=1)
|
||||
this_day, this_month, this_year = now.day, now.month, now.year
|
||||
today_played = Session.objects.filter(
|
||||
timestamp_start__gte=start_of_today,
|
||||
timestamp_start__lt=start_of_tomorrow,
|
||||
timestamp_start__day=this_day,
|
||||
timestamp_start__month=this_month,
|
||||
timestamp_start__year=this_year,
|
||||
).aggregate(time=Sum(F("duration_total")))["time"]
|
||||
last_7_played = Session.objects.filter(
|
||||
timestamp_start__gte=(now - timedelta(days=7))
|
||||
@@ -72,9 +75,207 @@ def use_custom_redirect(
|
||||
|
||||
@login_required
|
||||
def stats_alltime(request: HttpRequest) -> HttpResponse:
|
||||
year = "Alltime"
|
||||
this_year_sessions = Session.objects.all().prefetch_related(Prefetch("game"))
|
||||
this_year_sessions_with_durations = this_year_sessions.annotate(
|
||||
duration=ExpressionWrapper(
|
||||
F("timestamp_end") - F("timestamp_start"),
|
||||
output_field=fields.DurationField(),
|
||||
)
|
||||
)
|
||||
longest_session = this_year_sessions_with_durations.order_by("-duration").first()
|
||||
this_year_games = Game.objects.filter(sessions__in=this_year_sessions).distinct()
|
||||
this_year_games_with_session_counts = this_year_games.annotate(
|
||||
session_count=Count("sessions"),
|
||||
)
|
||||
game_highest_session_count = this_year_games_with_session_counts.order_by(
|
||||
"-session_count"
|
||||
).first()
|
||||
selected_currency = "CZK"
|
||||
unique_days = (
|
||||
this_year_sessions.annotate(date=TruncDate("timestamp_start"))
|
||||
.values("date")
|
||||
.distinct()
|
||||
.aggregate(dates=Count("date"))
|
||||
)
|
||||
this_year_played_purchases = Purchase.objects.filter(
|
||||
games__sessions__in=this_year_sessions
|
||||
).distinct()
|
||||
|
||||
this_year_purchases = Purchase.objects.all()
|
||||
this_year_purchases_with_currency = this_year_purchases.select_related("games")
|
||||
this_year_purchases_without_refunded = Purchase.objects.filter(date_refunded=None)
|
||||
this_year_purchases_refunded = Purchase.objects.refunded()
|
||||
|
||||
this_year_purchases_unfinished_dropped_nondropped = (
|
||||
this_year_purchases_without_refunded.filter(
|
||||
~Q(games__status="f") & ~Q(games__playevents__ended__isnull=False)
|
||||
)
|
||||
.filter(infinite=False)
|
||||
.filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC))
|
||||
) # do not count battle passes etc.
|
||||
|
||||
this_year_purchases_unfinished = (
|
||||
this_year_purchases_unfinished_dropped_nondropped.filter(
|
||||
~Q(games__status="r") & ~Q(games__status="a")
|
||||
)
|
||||
)
|
||||
this_year_purchases_dropped = (
|
||||
this_year_purchases.filter(
|
||||
~Q(games__status="f") & ~Q(games__playevents__ended__isnull=False)
|
||||
)
|
||||
.filter(Q(games__status="a") | Q(date_refunded__isnull=False))
|
||||
.filter(infinite=False)
|
||||
.filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC))
|
||||
)
|
||||
|
||||
this_year_purchases_without_refunded_count = (
|
||||
this_year_purchases_without_refunded.count()
|
||||
)
|
||||
this_year_purchases_unfinished_count = this_year_purchases_unfinished.count()
|
||||
this_year_purchases_unfinished_percent = int(
|
||||
safe_division(
|
||||
this_year_purchases_unfinished_count,
|
||||
this_year_purchases_without_refunded_count,
|
||||
)
|
||||
* 100
|
||||
)
|
||||
|
||||
_finished_purchases_qs = Purchase.objects.finished()
|
||||
_finished_with_date = _finished_purchases_qs.annotate(
|
||||
date_finished=Subquery(
|
||||
Purchase.objects.filter(pk=OuterRef("pk"))
|
||||
.annotate(max_ended=Max("games__playevents__ended"))
|
||||
.values("max_ended")[:1]
|
||||
)
|
||||
)
|
||||
purchases_finished_this_year = _finished_with_date
|
||||
purchases_finished_this_year_released_this_year = _finished_with_date.order_by(
|
||||
"-date_finished"
|
||||
)
|
||||
|
||||
this_year_spendings = this_year_purchases_without_refunded.aggregate(
|
||||
total_spent=Sum(F("converted_price"))
|
||||
)
|
||||
total_spent = this_year_spendings["total_spent"] or 0
|
||||
|
||||
games_with_playtime = (
|
||||
Game.objects.filter(sessions__in=this_year_sessions)
|
||||
.distinct()
|
||||
.annotate(total_playtime=Sum(F("sessions__duration_total")))
|
||||
.filter(total_playtime__gt=timedelta(0))
|
||||
)
|
||||
month_playtimes = (
|
||||
this_year_sessions.annotate(month=TruncMonth("timestamp_start"))
|
||||
.values("month")
|
||||
.annotate(playtime=Sum("duration_total"))
|
||||
.order_by("month")
|
||||
)
|
||||
for month in month_playtimes:
|
||||
month["playtime"] = format_duration(month["playtime"], "%2.0H")
|
||||
|
||||
highest_session_average_game = (
|
||||
Game.objects.filter(sessions__in=this_year_sessions)
|
||||
.annotate(session_average=Avg("sessions__duration_calculated"))
|
||||
.order_by("-session_average")
|
||||
.first()
|
||||
)
|
||||
top_10_games_by_playtime = games_with_playtime.order_by("-total_playtime")[:10]
|
||||
|
||||
total_playtime_per_platform = (
|
||||
this_year_sessions.values("game__platform__name")
|
||||
.annotate(playtime=Sum(F("duration_total")))
|
||||
.annotate(platform_name=F("game__platform__name"))
|
||||
.values("platform_name", "playtime")
|
||||
.order_by("-playtime")
|
||||
)
|
||||
|
||||
backlog_decrease_count = purchases_finished_this_year.count()
|
||||
|
||||
first_play_date = "N/A"
|
||||
last_play_date = "N/A"
|
||||
first_play_game = None
|
||||
last_play_game = None
|
||||
if this_year_sessions:
|
||||
first_session = this_year_sessions.earliest()
|
||||
first_play_game = first_session.game
|
||||
first_play_date = first_session.timestamp_start.strftime(dateformat)
|
||||
last_session = this_year_sessions.latest()
|
||||
last_play_game = last_session.game
|
||||
last_play_date = last_session.timestamp_start.strftime(dateformat)
|
||||
|
||||
all_purchased_this_year_count = this_year_purchases_with_currency.count()
|
||||
all_purchased_refunded_this_year_count: int = this_year_purchases_refunded.count()
|
||||
|
||||
this_year_purchases_dropped_count = this_year_purchases_dropped.count()
|
||||
this_year_purchases_dropped_percentage = int(
|
||||
safe_division(this_year_purchases_dropped_count, all_purchased_this_year_count)
|
||||
* 100
|
||||
)
|
||||
context = {
|
||||
"total_hours": format_duration(
|
||||
this_year_sessions.total_duration_unformatted(), "%2.0H"
|
||||
),
|
||||
"total_year_games": this_year_played_purchases.all().count(),
|
||||
"top_10_games_by_playtime": top_10_games_by_playtime,
|
||||
"year": year,
|
||||
"total_playtime_per_platform": total_playtime_per_platform,
|
||||
"total_spent": total_spent,
|
||||
"total_spent_currency": selected_currency,
|
||||
"spent_per_game": int(
|
||||
safe_division(total_spent, this_year_purchases_without_refunded_count)
|
||||
),
|
||||
"this_year_finished_this_year_count": purchases_finished_this_year_released_this_year.count(),
|
||||
"total_sessions": this_year_sessions.count(),
|
||||
"unique_days": unique_days["dates"],
|
||||
"unique_days_percent": int(unique_days["dates"] / 365 * 100),
|
||||
"purchased_unfinished_count": this_year_purchases_unfinished_count,
|
||||
"unfinished_purchases_percent": this_year_purchases_unfinished_percent,
|
||||
"dropped_count": this_year_purchases_dropped_count,
|
||||
"dropped_percentage": this_year_purchases_dropped_percentage,
|
||||
"refunded_percent": int(
|
||||
safe_division(
|
||||
all_purchased_refunded_this_year_count,
|
||||
all_purchased_this_year_count,
|
||||
)
|
||||
* 100
|
||||
),
|
||||
"all_purchased_refunded_this_year": this_year_purchases_refunded,
|
||||
"all_purchased_refunded_this_year_count": all_purchased_refunded_this_year_count,
|
||||
"all_purchased_this_year_count": all_purchased_this_year_count,
|
||||
"backlog_decrease_count": backlog_decrease_count,
|
||||
"longest_session_time": (
|
||||
format_duration(longest_session.duration, "%2.0Hh %2.0mm")
|
||||
if longest_session
|
||||
else 0
|
||||
),
|
||||
"longest_session_game": (longest_session.game if longest_session else None),
|
||||
"highest_session_count": (
|
||||
game_highest_session_count.session_count
|
||||
if game_highest_session_count
|
||||
else 0
|
||||
),
|
||||
"highest_session_count_game": (
|
||||
game_highest_session_count if game_highest_session_count else None
|
||||
),
|
||||
"highest_session_average": (
|
||||
format_duration(
|
||||
highest_session_average_game.session_average, "%2.0Hh %2.0mm"
|
||||
)
|
||||
if highest_session_average_game
|
||||
else 0
|
||||
),
|
||||
"highest_session_average_game": highest_session_average_game,
|
||||
"first_play_game": first_play_game,
|
||||
"first_play_date": first_play_date,
|
||||
"last_play_game": last_play_game,
|
||||
"last_play_date": last_play_date,
|
||||
"title": f"{year} Stats",
|
||||
"stats_dropdown_year_range": available_stats_year_range(),
|
||||
}
|
||||
|
||||
request.session["return_path"] = request.path
|
||||
data = compute_stats(None)
|
||||
return render_page(request, stats_content(data), title=data["title"])
|
||||
return render_page(request, stats_content(context), title=context["title"])
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -86,9 +287,259 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
|
||||
)
|
||||
if year == 0:
|
||||
return HttpResponseRedirect(reverse("games:stats_alltime"))
|
||||
this_year_sessions = Session.objects.filter(
|
||||
timestamp_start__year=year
|
||||
).prefetch_related("game")
|
||||
this_year_sessions_with_durations = this_year_sessions.annotate(
|
||||
duration=ExpressionWrapper(
|
||||
F("timestamp_end") - F("timestamp_start"),
|
||||
output_field=fields.DurationField(),
|
||||
)
|
||||
)
|
||||
longest_session = this_year_sessions_with_durations.order_by("-duration").first()
|
||||
this_year_games = Game.objects.filter(sessions__in=this_year_sessions).distinct()
|
||||
this_year_games_with_session_counts = this_year_games.annotate(
|
||||
session_count=Count(
|
||||
"sessions",
|
||||
filter=Q(sessions__timestamp_start__year=year),
|
||||
)
|
||||
)
|
||||
game_highest_session_count = this_year_games_with_session_counts.order_by(
|
||||
"-session_count"
|
||||
).first()
|
||||
selected_currency = "CZK"
|
||||
unique_days = (
|
||||
this_year_sessions.annotate(date=TruncDate("timestamp_start"))
|
||||
.values("date")
|
||||
.distinct()
|
||||
.aggregate(dates=Count("date"))
|
||||
)
|
||||
this_year_played_purchases = Purchase.objects.filter(
|
||||
games__sessions__in=this_year_sessions
|
||||
).distinct()
|
||||
|
||||
this_year_played_games = Game.objects.filter(
|
||||
sessions__in=this_year_sessions
|
||||
).distinct()
|
||||
|
||||
this_year_purchases = Purchase.objects.filter(
|
||||
date_purchased__year=year
|
||||
).prefetch_related("games")
|
||||
# purchased this year
|
||||
# not refunded
|
||||
this_year_purchases_without_refunded = Purchase.objects.filter(
|
||||
date_refunded=None, date_purchased__year=year
|
||||
)
|
||||
|
||||
# purchased this year
|
||||
# not refunded
|
||||
# not finished
|
||||
# not infinite
|
||||
# only Game and DLC
|
||||
this_year_purchases_unfinished_dropped_nondropped = (
|
||||
this_year_purchases_without_refunded.filter(
|
||||
~Q(games__status="f") & ~Q(games__playevents__ended__year=year)
|
||||
)
|
||||
.filter(infinite=False)
|
||||
.filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC))
|
||||
)
|
||||
|
||||
# unfinished = not finished AND not dropped
|
||||
this_year_purchases_unfinished = (
|
||||
this_year_purchases_unfinished_dropped_nondropped.filter(
|
||||
~Q(games__status="r") & ~Q(games__status="a")
|
||||
)
|
||||
)
|
||||
# dropped = abandoned OR retired OR refunded (OR logic for transition)
|
||||
this_year_purchases_dropped = (
|
||||
this_year_purchases.filter(
|
||||
~Q(games__status="f") & ~Q(games__playevents__ended__year=year)
|
||||
)
|
||||
.filter(Q(games__status="a") | Q(date_refunded__isnull=False))
|
||||
.filter(infinite=False)
|
||||
.filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC))
|
||||
)
|
||||
|
||||
this_year_purchases_without_refunded_count = (
|
||||
this_year_purchases_without_refunded.count()
|
||||
)
|
||||
this_year_purchases_unfinished_count = this_year_purchases_unfinished.count()
|
||||
this_year_purchases_unfinished_percent = int(
|
||||
safe_division(
|
||||
this_year_purchases_unfinished_count,
|
||||
this_year_purchases_without_refunded_count,
|
||||
)
|
||||
* 100
|
||||
)
|
||||
|
||||
purchases_finished_this_year = (
|
||||
Purchase.objects.finished()
|
||||
.filter(games__playevents__ended__year=year)
|
||||
.annotate(
|
||||
game_name=F("games__name"), date_finished=F("games__playevents__ended")
|
||||
)
|
||||
)
|
||||
purchases_finished_this_year_released_this_year = (
|
||||
purchases_finished_this_year.filter(games__year_released=year).order_by(
|
||||
"games__playevents__ended"
|
||||
)
|
||||
)
|
||||
purchased_this_year_finished_this_year = (
|
||||
this_year_purchases_without_refunded.filter(
|
||||
games__playevents__ended__year=year
|
||||
).annotate(
|
||||
game_name=F("games__name"), date_finished=F("games__playevents__ended")
|
||||
)
|
||||
).order_by("games__playevents__ended")
|
||||
|
||||
this_year_spendings = this_year_purchases_without_refunded.aggregate(
|
||||
total_spent=Sum(F("converted_price"))
|
||||
)
|
||||
total_spent = this_year_spendings["total_spent"] or 0
|
||||
|
||||
games_with_playtime = (
|
||||
Game.objects.filter(sessions__timestamp_start__year=year)
|
||||
.annotate(
|
||||
total_playtime=Sum(
|
||||
F("sessions__duration_calculated"),
|
||||
)
|
||||
)
|
||||
.filter(total_playtime__gt=timedelta(0))
|
||||
)
|
||||
|
||||
month_playtimes = (
|
||||
this_year_sessions.annotate(month=TruncMonth("timestamp_start"))
|
||||
.values("month")
|
||||
.annotate(playtime=Sum("duration_total"))
|
||||
.order_by("month")
|
||||
)
|
||||
|
||||
highest_session_average_game = (
|
||||
Game.objects.filter(sessions__in=this_year_sessions)
|
||||
.annotate(session_average=Avg("sessions__duration_calculated"))
|
||||
.order_by("-session_average")
|
||||
.first()
|
||||
)
|
||||
top_10_games_by_playtime = games_with_playtime.order_by("-total_playtime")
|
||||
|
||||
total_playtime_per_platform = (
|
||||
this_year_sessions.values("game__platform__name")
|
||||
.annotate(playtime=Sum(F("duration_total")))
|
||||
.annotate(platform_name=F("game__platform__name"))
|
||||
.values("platform_name", "playtime")
|
||||
.order_by("-playtime")
|
||||
)
|
||||
|
||||
backlog_decrease_count = (
|
||||
Purchase.objects.filter(date_purchased__year__lt=year)
|
||||
.filter(games__status="f")
|
||||
.filter(games__playevents__ended__year=year)
|
||||
.count()
|
||||
)
|
||||
|
||||
first_play_date = "N/A"
|
||||
last_play_date = "N/A"
|
||||
first_play_game = None
|
||||
last_play_game = None
|
||||
if this_year_sessions:
|
||||
first_session = this_year_sessions.earliest()
|
||||
first_play_game = first_session.game
|
||||
first_play_date = first_session.timestamp_start.strftime(dateformat)
|
||||
last_session = this_year_sessions.latest()
|
||||
last_play_game = last_session.game
|
||||
last_play_date = last_session.timestamp_start.strftime(dateformat)
|
||||
|
||||
all_purchased_this_year_count = this_year_purchases.count()
|
||||
this_year_purchases_refunded = Purchase.objects.exclude(date_refunded=None).filter(
|
||||
date_purchased__year=year
|
||||
)
|
||||
all_purchased_refunded_this_year_count = this_year_purchases_refunded.count()
|
||||
|
||||
this_year_purchases_dropped_count = this_year_purchases_dropped.count()
|
||||
this_year_purchases_dropped_percentage = int(
|
||||
safe_division(this_year_purchases_dropped_count, all_purchased_this_year_count)
|
||||
* 100
|
||||
)
|
||||
context = {
|
||||
"total_hours": format_duration(
|
||||
this_year_sessions.total_duration_unformatted(), "%2.0H"
|
||||
),
|
||||
"total_games": this_year_played_games.count(),
|
||||
"total_year_games": this_year_played_purchases.filter(
|
||||
games__year_released=year
|
||||
).count(),
|
||||
"top_10_games_by_playtime": top_10_games_by_playtime,
|
||||
"year": year,
|
||||
"total_playtime_per_platform": total_playtime_per_platform,
|
||||
"total_spent": total_spent,
|
||||
"total_spent_currency": selected_currency,
|
||||
"spent_per_game": int(
|
||||
safe_division(total_spent, this_year_purchases_without_refunded_count)
|
||||
),
|
||||
"all_finished_this_year": purchases_finished_this_year.prefetch_related(
|
||||
"games"
|
||||
).order_by("games__playevents__ended"),
|
||||
"all_finished_this_year_count": purchases_finished_this_year.count(),
|
||||
"this_year_finished_this_year": purchases_finished_this_year_released_this_year.prefetch_related(
|
||||
"games"
|
||||
).order_by("games__playevents__ended"),
|
||||
"this_year_finished_this_year_count": purchases_finished_this_year_released_this_year.count(),
|
||||
"purchased_this_year_finished_this_year": purchased_this_year_finished_this_year.prefetch_related(
|
||||
"games"
|
||||
).order_by("games__playevents__ended"),
|
||||
"total_sessions": this_year_sessions.count(),
|
||||
"unique_days": unique_days["dates"],
|
||||
"unique_days_percent": int(unique_days["dates"] / 365 * 100),
|
||||
"purchased_unfinished": this_year_purchases_unfinished,
|
||||
"purchased_unfinished_count": this_year_purchases_unfinished_count,
|
||||
"unfinished_purchases_percent": this_year_purchases_unfinished_percent,
|
||||
"dropped_count": this_year_purchases_dropped_count,
|
||||
"dropped_percentage": this_year_purchases_dropped_percentage,
|
||||
"refunded_percent": int(
|
||||
safe_division(
|
||||
all_purchased_refunded_this_year_count,
|
||||
all_purchased_this_year_count,
|
||||
)
|
||||
* 100
|
||||
),
|
||||
"all_purchased_refunded_this_year": this_year_purchases_refunded,
|
||||
"all_purchased_refunded_this_year_count": all_purchased_refunded_this_year_count,
|
||||
"all_purchased_this_year": this_year_purchases.order_by("date_purchased"),
|
||||
"all_purchased_this_year_count": all_purchased_this_year_count,
|
||||
"backlog_decrease_count": backlog_decrease_count,
|
||||
"longest_session_time": (
|
||||
format_duration(longest_session.duration, "%2.0Hh %2.0mm")
|
||||
if longest_session
|
||||
else 0
|
||||
),
|
||||
"longest_session_game": (longest_session.game if longest_session else None),
|
||||
"highest_session_count": (
|
||||
game_highest_session_count.session_count
|
||||
if game_highest_session_count
|
||||
else 0
|
||||
),
|
||||
"highest_session_count_game": (
|
||||
game_highest_session_count if game_highest_session_count else None
|
||||
),
|
||||
"highest_session_average": (
|
||||
format_duration(
|
||||
highest_session_average_game.session_average, "%2.0Hh %2.0mm"
|
||||
)
|
||||
if highest_session_average_game
|
||||
else 0
|
||||
),
|
||||
"highest_session_average_game": highest_session_average_game,
|
||||
"first_play_game": first_play_game,
|
||||
"first_play_date": first_play_date,
|
||||
"last_play_game": last_play_game,
|
||||
"last_play_date": last_play_date,
|
||||
"title": f"{year} Stats",
|
||||
"month_playtimes": month_playtimes,
|
||||
"stats_dropdown_year_range": available_stats_year_range(),
|
||||
}
|
||||
|
||||
request.session["return_path"] = request.path
|
||||
data = compute_stats(year)
|
||||
return render_page(request, stats_content(data), title=data["title"])
|
||||
return render_page(request, stats_content(context), title=context["title"])
|
||||
|
||||
|
||||
@login_required
|
||||
|
||||
+18
-27
@@ -1,4 +1,5 @@
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.paginator import Paginator
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.urls import reverse
|
||||
@@ -6,17 +7,13 @@ from django.urls import reverse
|
||||
from common.components import (
|
||||
A,
|
||||
AddForm,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Fragment,
|
||||
Icon,
|
||||
PlatformFilterBar,
|
||||
StyledButton,
|
||||
paginated_table_content,
|
||||
)
|
||||
from common.layout import render_page
|
||||
from common.time import dateformat, local_strftime
|
||||
from common.utils import paginate
|
||||
from games.filters import parse_platform_filter
|
||||
from games.forms import PlatformForm
|
||||
from games.models import Platform
|
||||
from games.views.general import use_custom_redirect
|
||||
@@ -24,20 +21,24 @@ from games.views.general import use_custom_redirect
|
||||
|
||||
@login_required
|
||||
def list_platforms(request: HttpRequest) -> HttpResponse:
|
||||
page_number = request.GET.get("page", 1)
|
||||
limit = request.GET.get("limit", 10)
|
||||
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)
|
||||
page_obj = None
|
||||
if int(limit) != 0:
|
||||
paginator = Paginator(platforms, limit)
|
||||
page_obj = paginator.get_page(page_number)
|
||||
platforms = 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
|
||||
)
|
||||
|
||||
data = {
|
||||
"header_action": A(href=reverse("games:add_platform"))[
|
||||
StyledButton()["Add platform"]
|
||||
],
|
||||
"header_action": A(
|
||||
[], Button([], "Add platform"), url_name="games:add_platform"
|
||||
),
|
||||
"columns": [
|
||||
"Name",
|
||||
"Icon",
|
||||
@@ -77,17 +78,7 @@ def list_platforms(request: HttpRequest) -> HttpResponse:
|
||||
elided_page_range=elided_page_range,
|
||||
request=request,
|
||||
)
|
||||
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 = Fragment(filter_bar, content)
|
||||
return render_page(
|
||||
request,
|
||||
content,
|
||||
title="Manage platforms",
|
||||
)
|
||||
return render_page(request, content, title="Manage platforms")
|
||||
|
||||
|
||||
@login_required
|
||||
|
||||
+20
-38
@@ -3,6 +3,7 @@ from datetime import datetime, timedelta
|
||||
from typing import Any, Callable, TypedDict
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.paginator import Paginator
|
||||
from django.db.models import QuerySet
|
||||
from django.db.models.manager import BaseManager
|
||||
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
||||
@@ -12,18 +13,13 @@ from django.urls import reverse
|
||||
from common.components import (
|
||||
A,
|
||||
AddForm,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Fragment,
|
||||
Icon,
|
||||
ModuleScript,
|
||||
PlayEventFilterBar,
|
||||
StyledButton,
|
||||
paginated_table_content,
|
||||
)
|
||||
from common.layout import render_page
|
||||
from common.time import dateformat, format_duration, local_strftime
|
||||
from common.utils import paginate
|
||||
from games.filters import parse_playevent_filter
|
||||
from games.forms import PlayEventForm
|
||||
from games.models import Game, PlayEvent, Session
|
||||
|
||||
@@ -86,9 +82,9 @@ def create_playevent_tabledata(
|
||||
for row in row_list
|
||||
]
|
||||
return {
|
||||
"header_action": A(href=reverse("games:add_playevent"))[
|
||||
StyledButton()["Add play event"]
|
||||
],
|
||||
"header_action": A(
|
||||
[], Button([], "Add play event"), url_name="games:add_playevent"
|
||||
),
|
||||
"columns": list(filtered_column_list),
|
||||
"rows": filtered_row_list,
|
||||
}
|
||||
@@ -129,15 +125,19 @@ def _get_formatted_playtime_for_game_sessions_in_range(
|
||||
|
||||
@login_required
|
||||
def list_playevents(request: HttpRequest) -> HttpResponse:
|
||||
page_number = request.GET.get("page", 1)
|
||||
limit = request.GET.get("limit", 10)
|
||||
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)
|
||||
page_obj = None
|
||||
if int(limit) != 0:
|
||||
paginator = Paginator(playevents, limit)
|
||||
page_obj = paginator.get_page(page_number)
|
||||
playevents = 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
|
||||
)
|
||||
data = create_playevent_tabledata(playevents, request=request)
|
||||
content = paginated_table_content(
|
||||
data,
|
||||
@@ -145,17 +145,7 @@ def list_playevents(request: HttpRequest) -> HttpResponse:
|
||||
elided_page_range=elided_page_range,
|
||||
request=request,
|
||||
)
|
||||
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 = Fragment(filter_bar, content)
|
||||
return render_page(
|
||||
request,
|
||||
content,
|
||||
title="Manage play events",
|
||||
)
|
||||
return render_page(request, content, title="Manage play events")
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -213,10 +203,7 @@ def add_playevent(request: HttpRequest, game_id: int = 0) -> HttpResponse:
|
||||
return HttpResponseRedirect(reverse("games:view_game", args=[game_id]))
|
||||
|
||||
return render_page(
|
||||
request,
|
||||
AddForm(form, request=request),
|
||||
title="Add new playthrough",
|
||||
scripts=ModuleScript("search_select.js"),
|
||||
request, AddForm(form, request=request), title="Add new playthrough"
|
||||
)
|
||||
|
||||
|
||||
@@ -229,12 +216,7 @@ def edit_playevent(request: HttpRequest, playevent_id: int) -> HttpResponse:
|
||||
reverse("games:view_game", args=[playevent.game.id])
|
||||
)
|
||||
|
||||
return render_page(
|
||||
request,
|
||||
AddForm(form, request=request),
|
||||
title="Edit Play Event",
|
||||
scripts=ModuleScript("search_select.js"),
|
||||
)
|
||||
return render_page(request, AddForm(form, request=request), title="Edit Play Event")
|
||||
|
||||
|
||||
def delete_playevent(request: HttpRequest, playevent_id: int) -> HttpResponse:
|
||||
|
||||
+68
-262
@@ -1,53 +1,46 @@
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.paginator import Paginator
|
||||
from django.http import (
|
||||
HttpRequest,
|
||||
HttpResponse,
|
||||
HttpResponseRedirect,
|
||||
)
|
||||
from django.db import transaction
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.template.defaultfilters import date as date_filter
|
||||
from django.template.defaultfilters import floatformat
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.safestring import SafeText, mark_safe
|
||||
from django.views.decorators.http import require_POST
|
||||
|
||||
from django.template.defaultfilters import date as date_filter
|
||||
from django.template.defaultfilters import floatformat
|
||||
from django.utils.safestring import SafeText
|
||||
|
||||
from common.components import (
|
||||
A,
|
||||
AddForm,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Checkbox,
|
||||
Component,
|
||||
CsrfInput,
|
||||
Div,
|
||||
Element,
|
||||
Fragment,
|
||||
GameLink,
|
||||
Icon,
|
||||
Input,
|
||||
LinkedPurchase,
|
||||
Modal,
|
||||
ModuleScript,
|
||||
Node,
|
||||
PriceConverted,
|
||||
PurchasePrice,
|
||||
Safe,
|
||||
SelectionFields,
|
||||
StyledButton,
|
||||
TableRow,
|
||||
paginated_table_content,
|
||||
)
|
||||
from common.components.primitives import Li, P, Td, Tr, Ul
|
||||
from common.layout import render_page
|
||||
from common.time import dateformat
|
||||
from common.utils import paginate
|
||||
from games.forms import PurchaseForm
|
||||
from games.models import Game, Purchase
|
||||
from games.views.general import use_custom_redirect
|
||||
|
||||
|
||||
def _render_purchase_buttons(purchase_id, is_refunded, can_split=False):
|
||||
def _render_purchase_buttons(purchase_id, is_refunded):
|
||||
"""Return button group HTML for a purchase row."""
|
||||
return ButtonGroup(
|
||||
[
|
||||
@@ -63,19 +56,6 @@ def _render_purchase_buttons(purchase_id, is_refunded, can_split=False):
|
||||
}
|
||||
if not is_refunded
|
||||
else {},
|
||||
{
|
||||
"href": "#",
|
||||
"hx_get": reverse(
|
||||
"games:split_purchase_confirmation",
|
||||
args=[purchase_id],
|
||||
),
|
||||
"hx_target": "#global-modal-container",
|
||||
"slot": Icon("split"),
|
||||
"title": "Split into per-game purchases",
|
||||
"color": "gray",
|
||||
}
|
||||
if can_split
|
||||
else {},
|
||||
{
|
||||
"href": reverse("games:edit_purchase", args=[purchase_id]),
|
||||
"slot": Icon("edit"),
|
||||
@@ -108,33 +88,31 @@ def _render_purchase_row(purchase):
|
||||
else "-"
|
||||
),
|
||||
purchase.created_at.strftime(dateformat),
|
||||
_render_purchase_buttons(
|
||||
purchase.id,
|
||||
bool(purchase.date_refunded),
|
||||
can_split=purchase.num_purchases > 1,
|
||||
),
|
||||
_render_purchase_buttons(purchase.id, bool(purchase.date_refunded)),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@login_required
|
||||
def list_purchases(request: HttpRequest) -> HttpResponse:
|
||||
page_number = request.GET.get("page", 1)
|
||||
limit = request.GET.get("limit", 10)
|
||||
purchases = Purchase.objects.order_by("-date_purchased", "-created_at")
|
||||
|
||||
filter_json = request.GET.get("filter", "")
|
||||
if filter_json:
|
||||
from games.filters import parse_purchase_filter
|
||||
|
||||
pf = parse_purchase_filter(filter_json)
|
||||
if pf is not None:
|
||||
purchases = purchases.filter(pf.to_q())
|
||||
|
||||
purchases, page_obj, elided_page_range = paginate(request, purchases)
|
||||
page_obj = None
|
||||
if int(limit) != 0:
|
||||
paginator = Paginator(purchases, limit)
|
||||
page_obj = paginator.get_page(page_number)
|
||||
purchases = 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
|
||||
)
|
||||
|
||||
data = {
|
||||
"header_action": A(href=reverse("games:add_purchase"))[
|
||||
StyledButton()["Add purchase"]
|
||||
],
|
||||
"header_action": A(
|
||||
[], Button([], "Add purchase"), url_name="games:add_purchase"
|
||||
),
|
||||
"columns": [
|
||||
"Name",
|
||||
"Type",
|
||||
@@ -153,29 +131,19 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
|
||||
elided_page_range=elided_page_range,
|
||||
request=request,
|
||||
)
|
||||
from common.components import PurchaseFilterBar
|
||||
|
||||
filter_bar = PurchaseFilterBar(
|
||||
filter_json=filter_json,
|
||||
preset_list_url=reverse("games:list_presets"),
|
||||
preset_save_url=reverse("games:save_preset"),
|
||||
)
|
||||
content = Fragment(filter_bar, content)
|
||||
return render_page(
|
||||
request,
|
||||
content,
|
||||
title="Manage purchases",
|
||||
)
|
||||
return render_page(request, content, title="Manage purchases")
|
||||
|
||||
|
||||
def _purchase_additional_row() -> SafeText:
|
||||
"""The 'Submit & Create Session' row shown below the main Submit button."""
|
||||
return Tr(
|
||||
return Component(
|
||||
tag_name="tr",
|
||||
children=[
|
||||
Td(),
|
||||
Td(
|
||||
Component(tag_name="td"),
|
||||
Component(
|
||||
tag_name="td",
|
||||
children=[
|
||||
StyledButton(
|
||||
Button(
|
||||
[],
|
||||
"Submit & Create Session",
|
||||
color="gray",
|
||||
@@ -188,76 +156,6 @@ def _purchase_additional_row() -> SafeText:
|
||||
)
|
||||
|
||||
|
||||
def _pricing_controls() -> Node:
|
||||
"""Pricing UI for the add-purchase form.
|
||||
|
||||
By default the form's own single Price field is the bundle price. When 2+
|
||||
games are selected and "Separate price per game" is checked, the per-game
|
||||
inputs (the general ``selection-fields`` element) take over and the bundle
|
||||
Price is hidden. Toggle/visibility wiring lives in add_purchase.js; the
|
||||
hidden ``pricing_mode`` tells the view which path to take.
|
||||
"""
|
||||
return Div(attributes=[("id", "pricing-controls")])[
|
||||
Div(attributes=[("id", "separate-prices-row"), ("class", "hidden")])[
|
||||
Checkbox(
|
||||
name="separate_prices",
|
||||
label="Separate price per game",
|
||||
attributes=[("id", "id_separate_prices")],
|
||||
),
|
||||
],
|
||||
Input(
|
||||
type="hidden",
|
||||
attributes=[
|
||||
("name", "pricing_mode"),
|
||||
("id", "id_pricing_mode"),
|
||||
("value", "combined"),
|
||||
],
|
||||
),
|
||||
SelectionFields(
|
||||
source="games",
|
||||
name_prefix="price_for_game_",
|
||||
field_type="number",
|
||||
min_items=2,
|
||||
active=False,
|
||||
input_attributes=[
|
||||
("step", "0.01"),
|
||||
("min", "0"),
|
||||
("inputmode", "decimal"),
|
||||
("placeholder", "Price"),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def _create_separate_purchases(form: PurchaseForm, post) -> None:
|
||||
"""Create one single-game Purchase per selected game from the shared form
|
||||
fields, each priced from its own ``price_for_game_<id>`` input. The
|
||||
``m2m_changed`` signal sets ``num_purchases``/``price_per_game`` once each
|
||||
game is attached."""
|
||||
data = form.cleaned_data
|
||||
shared = {
|
||||
"platform": data.get("platform"),
|
||||
"date_purchased": data["date_purchased"],
|
||||
"date_refunded": data.get("date_refunded"),
|
||||
"infinite": data.get("infinite", False),
|
||||
"price_currency": data["price_currency"],
|
||||
"ownership_type": data["ownership_type"],
|
||||
"type": data["type"],
|
||||
"related_game": data.get("related_game"),
|
||||
"name": data.get("name") or "",
|
||||
}
|
||||
for game in data["games"]:
|
||||
raw_price = post.get(f"price_for_game_{game.id}", "")
|
||||
try:
|
||||
price = float(raw_price) if raw_price not in (None, "") else 0.0
|
||||
except ValueError:
|
||||
price = 0.0
|
||||
purchase = Purchase(price=price, **shared)
|
||||
purchase.save()
|
||||
purchase.games.set([game])
|
||||
|
||||
|
||||
@login_required
|
||||
def add_purchase(request: HttpRequest, game_id: int = 0) -> HttpResponse:
|
||||
initial = {"date_purchased": timezone.now()}
|
||||
@@ -265,9 +163,6 @@ def add_purchase(request: HttpRequest, game_id: int = 0) -> HttpResponse:
|
||||
if request.method == "POST":
|
||||
form = PurchaseForm(request.POST or None, initial=initial)
|
||||
if form.is_valid():
|
||||
if request.POST.get("pricing_mode") == "per_game":
|
||||
_create_separate_purchases(form, request.POST)
|
||||
return redirect("games:list_purchases")
|
||||
purchase = form.save()
|
||||
if "submit_and_redirect" in request.POST:
|
||||
return HttpResponseRedirect(
|
||||
@@ -293,16 +188,9 @@ def add_purchase(request: HttpRequest, game_id: int = 0) -> HttpResponse:
|
||||
|
||||
return render_page(
|
||||
request,
|
||||
AddForm(
|
||||
form,
|
||||
request=request,
|
||||
fields=Fragment(Safe(form.as_div()), _pricing_controls()),
|
||||
additional_row=_purchase_additional_row(),
|
||||
),
|
||||
AddForm(form, request=request, additional_row=_purchase_additional_row()),
|
||||
title="Add New Purchase",
|
||||
scripts=mark_safe(
|
||||
ModuleScript("search_select.js") + ModuleScript("add_purchase.js")
|
||||
),
|
||||
scripts=ModuleScript("add_purchase.js"),
|
||||
)
|
||||
|
||||
|
||||
@@ -318,9 +206,7 @@ def edit_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
|
||||
request,
|
||||
AddForm(form, request=request, additional_row=_purchase_additional_row()),
|
||||
title="Edit Purchase",
|
||||
scripts=mark_safe(
|
||||
ModuleScript("search_select.js") + ModuleScript("add_purchase.js")
|
||||
),
|
||||
scripts=ModuleScript("add_purchase.js"),
|
||||
)
|
||||
|
||||
|
||||
@@ -359,7 +245,8 @@ def _view_purchase_content(purchase: Purchase) -> SafeText:
|
||||
Div(
|
||||
[("class", row_class)],
|
||||
[
|
||||
P(
|
||||
Component(
|
||||
tag_name="p",
|
||||
children=[
|
||||
"Price per game: ",
|
||||
PriceConverted([floatformat(purchase.price_per_game, 0)]),
|
||||
@@ -369,9 +256,10 @@ def _view_purchase_content(purchase: Purchase) -> SafeText:
|
||||
],
|
||||
),
|
||||
Div([("class", row_class)], ["Games included in this purchase:"]),
|
||||
Ul(
|
||||
Component(
|
||||
tag_name="ul",
|
||||
children=[
|
||||
Li(children=[GameLink(game.id, game.name)])
|
||||
Component(tag_name="li", children=[GameLink(game.id, game.name)])
|
||||
for game in purchase.games.all()
|
||||
],
|
||||
),
|
||||
@@ -402,9 +290,9 @@ def drop_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
|
||||
return redirect("games:list_purchases")
|
||||
|
||||
|
||||
def _refund_confirmation_modal(purchase_id: int, request: HttpRequest) -> Node:
|
||||
form = Element(
|
||||
"form",
|
||||
def _refund_confirmation_modal(purchase_id: int, request: HttpRequest) -> SafeText:
|
||||
form = Component(
|
||||
tag_name="form",
|
||||
attributes=[
|
||||
("hx-post", reverse("games:refund_purchase", args=[purchase_id])),
|
||||
("hx-target", f"#purchase-row-{purchase_id}"),
|
||||
@@ -412,21 +300,22 @@ def _refund_confirmation_modal(purchase_id: int, request: HttpRequest) -> Node:
|
||||
],
|
||||
children=[
|
||||
CsrfInput(request),
|
||||
P(
|
||||
Component(
|
||||
tag_name="p",
|
||||
attributes=[("class", "dark:text-white text-center mt-3 text-sm")],
|
||||
children=["Games will be marked as abandoned."],
|
||||
),
|
||||
Div(
|
||||
[("class", "items-center mt-5")],
|
||||
[
|
||||
StyledButton(
|
||||
Button(
|
||||
[("class", "w-full")],
|
||||
"Refund",
|
||||
color="blue",
|
||||
size="lg",
|
||||
type="submit",
|
||||
),
|
||||
StyledButton(
|
||||
Button(
|
||||
[("class", "mt-0 w-full")],
|
||||
"Cancel",
|
||||
color="gray",
|
||||
@@ -440,8 +329,8 @@ def _refund_confirmation_modal(purchase_id: int, request: HttpRequest) -> Node:
|
||||
return Modal(
|
||||
"refund-confirmation-modal",
|
||||
children=[
|
||||
Element(
|
||||
"h1",
|
||||
Component(
|
||||
tag_name="h1",
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
@@ -450,7 +339,8 @@ def _refund_confirmation_modal(purchase_id: int, request: HttpRequest) -> Node:
|
||||
],
|
||||
children=["Confirm Refund"],
|
||||
),
|
||||
P(
|
||||
Component(
|
||||
tag_name="p",
|
||||
attributes=[("class", "dark:text-white text-center mt-5")],
|
||||
children=["Are you sure you want to mark this purchase as refunded?"],
|
||||
),
|
||||
@@ -486,108 +376,6 @@ def refund_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
|
||||
return HttpResponse(row_html + modal_close, status=200)
|
||||
|
||||
|
||||
def _split_confirmation_modal(purchase: Purchase, request: HttpRequest) -> Node:
|
||||
count = purchase.num_purchases
|
||||
form = Element(
|
||||
"form",
|
||||
attributes=[("hx-post", reverse("games:split_purchase", args=[purchase.id]))],
|
||||
children=[
|
||||
CsrfInput(request),
|
||||
P(
|
||||
attributes=[("class", "dark:text-white text-center mt-3 text-sm")],
|
||||
children=[
|
||||
f"Creates {count} separate purchases, one per game, with the "
|
||||
"price split evenly. Each can then be priced and refunded "
|
||||
"independently."
|
||||
],
|
||||
),
|
||||
Div(
|
||||
[("class", "items-center mt-5")],
|
||||
[
|
||||
StyledButton(
|
||||
[("class", "w-full")],
|
||||
"Split",
|
||||
color="blue",
|
||||
size="lg",
|
||||
type="submit",
|
||||
),
|
||||
StyledButton(
|
||||
[("class", "mt-0 w-full")],
|
||||
"Cancel",
|
||||
color="gray",
|
||||
size="base",
|
||||
onclick="this.closest('#split-confirmation-modal').remove()",
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
return Modal(
|
||||
"split-confirmation-modal",
|
||||
children=[
|
||||
Element(
|
||||
"h1",
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
"text-2xl leading-6 font-medium dark:text-white text-center",
|
||||
)
|
||||
],
|
||||
children=["Split purchase"],
|
||||
),
|
||||
P(
|
||||
attributes=[("class", "dark:text-white text-center mt-5")],
|
||||
children=[
|
||||
f"Split “{purchase.standardized_name}” into per-game purchases?"
|
||||
],
|
||||
),
|
||||
form,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def split_purchase_confirmation(request: HttpRequest, purchase_id: int) -> HttpResponse:
|
||||
purchase = get_object_or_404(Purchase, id=purchase_id)
|
||||
return HttpResponse(_split_confirmation_modal(purchase, request))
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
@transaction.atomic
|
||||
def split_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
|
||||
"""Replace one multi-game (unsplittable-style) purchase with one single-game
|
||||
purchase per game, splitting the price evenly as a starting point. Each new
|
||||
purchase is then independently priceable and refundable."""
|
||||
purchase = get_object_or_404(Purchase, id=purchase_id)
|
||||
games = list(purchase.games.all())
|
||||
count = len(games)
|
||||
if count > 1:
|
||||
share = purchase.price / count
|
||||
for game in games:
|
||||
new_purchase = Purchase(
|
||||
price=share,
|
||||
price_currency=purchase.price_currency,
|
||||
date_purchased=purchase.date_purchased,
|
||||
date_refunded=purchase.date_refunded,
|
||||
infinite=purchase.infinite,
|
||||
ownership_type=purchase.ownership_type,
|
||||
type=purchase.type,
|
||||
related_game=purchase.related_game,
|
||||
name=purchase.name,
|
||||
platform=purchase.platform,
|
||||
needs_price_update=True,
|
||||
)
|
||||
new_purchase.save()
|
||||
new_purchase.games.set([game])
|
||||
purchase.delete()
|
||||
messages.success(request, f"Split into {count} purchases")
|
||||
|
||||
response = HttpResponse(status=204)
|
||||
response["HX-Redirect"] = reverse("games:list_purchases")
|
||||
return response
|
||||
|
||||
|
||||
@login_required
|
||||
def finish_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
|
||||
purchase = get_object_or_404(Purchase, id=purchase_id)
|
||||
@@ -595,3 +383,21 @@ def finish_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
|
||||
game.status = Game.Status.FINISHED
|
||||
game.save()
|
||||
return redirect("games:list_purchases")
|
||||
|
||||
|
||||
def related_purchase_by_game(request: HttpRequest) -> HttpResponse:
|
||||
games: list[str] = request.GET.getlist("games")
|
||||
if games:
|
||||
form = PurchaseForm()
|
||||
qs = Purchase.objects.filter(games__in=games, type=Purchase.GAME).order_by(
|
||||
"games__sort_name"
|
||||
)
|
||||
|
||||
form.fields["related_purchase"].queryset = qs
|
||||
first_option = qs.first()
|
||||
if first_option:
|
||||
form.fields["related_purchase"].initial = first_option.id
|
||||
return HttpResponse(str(form["related_purchase"]))
|
||||
else:
|
||||
# abort swap
|
||||
return HttpResponse(status=204)
|
||||
|
||||
+80
-70
@@ -1,6 +1,7 @@
|
||||
from typing import Any
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.paginator import Paginator
|
||||
from django.db.models import Q
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.middleware.csrf import get_token
|
||||
@@ -13,48 +14,35 @@ from django.utils.safestring import SafeText, mark_safe
|
||||
from common.components import (
|
||||
A,
|
||||
AddForm,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Component,
|
||||
Div,
|
||||
Fragment,
|
||||
Icon,
|
||||
ModuleScript,
|
||||
NameWithIcon,
|
||||
Node,
|
||||
Popover,
|
||||
Safe,
|
||||
SearchField,
|
||||
SessionDeviceSelector,
|
||||
SessionTimestampButtons,
|
||||
StyledButton,
|
||||
paginated_table_content,
|
||||
)
|
||||
from common.components.primitives import Span, Td, Tr
|
||||
from common.layout import render_page
|
||||
from common.time import (
|
||||
dateformat,
|
||||
local_strftime,
|
||||
timeformat,
|
||||
)
|
||||
from common.utils import paginate, truncate
|
||||
from common.utils import truncate
|
||||
from games.forms import SessionForm
|
||||
from games.models import Device, Game, Session
|
||||
|
||||
|
||||
@login_required
|
||||
def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse:
|
||||
page_number = request.GET.get("page", 1)
|
||||
limit = request.GET.get("limit", 10)
|
||||
sessions = Session.objects.order_by("-timestamp_start", "created_at")
|
||||
device_list = Device.objects.order_by("name")
|
||||
|
||||
# ── Structured filter (JSON) ──
|
||||
filter_json = request.GET.get("filter", "")
|
||||
if filter_json:
|
||||
from games.filters import parse_session_filter
|
||||
|
||||
session_filter = parse_session_filter(filter_json)
|
||||
if session_filter is not None:
|
||||
sessions = sessions.filter(session_filter.to_q())
|
||||
else:
|
||||
# ── Legacy free-text search ──
|
||||
search_string = request.GET.get("search_string", search_string)
|
||||
if search_string != "":
|
||||
sessions = sessions.filter(
|
||||
@@ -68,7 +56,17 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
|
||||
last_session = sessions.latest()
|
||||
except Session.DoesNotExist:
|
||||
last_session = None
|
||||
sessions, page_obj, elided_page_range = paginate(request, sessions)
|
||||
page_obj = None
|
||||
if int(limit) != 0:
|
||||
paginator = Paginator(sessions, limit)
|
||||
page_obj = paginator.get_page(page_number)
|
||||
sessions = 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
|
||||
)
|
||||
|
||||
data = {
|
||||
"header_action": Div(
|
||||
@@ -77,13 +75,13 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
|
||||
Div(
|
||||
children=[
|
||||
A(
|
||||
href=reverse("games:add_session"),
|
||||
)[
|
||||
StyledButton(
|
||||
url_name="games:add_session",
|
||||
children=Button(
|
||||
icon=True,
|
||||
size="xs",
|
||||
)[Icon("play"), "LOG"]
|
||||
],
|
||||
children=[Icon("play"), "LOG"],
|
||||
),
|
||||
),
|
||||
A(
|
||||
href=reverse(
|
||||
"games:list_sessions_start_session_from_session",
|
||||
@@ -92,7 +90,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
|
||||
children=Popover(
|
||||
popover_content=last_session.game.name,
|
||||
children=[
|
||||
StyledButton(
|
||||
Button(
|
||||
icon=True,
|
||||
color="gray",
|
||||
size="xs",
|
||||
@@ -172,20 +170,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
|
||||
elided_page_range=elided_page_range,
|
||||
request=request,
|
||||
)
|
||||
from common.components import SessionFilterBar
|
||||
|
||||
filter_json = request.GET.get("filter", "")
|
||||
filter_bar = SessionFilterBar(
|
||||
filter_json=filter_json,
|
||||
preset_list_url=reverse("games:list_presets"),
|
||||
preset_save_url=reverse("games:save_preset"),
|
||||
)
|
||||
content = Fragment(filter_bar, content)
|
||||
return render_page(
|
||||
request,
|
||||
content,
|
||||
title="Manage sessions",
|
||||
)
|
||||
return render_page(request, content, title="Manage sessions")
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -193,45 +178,62 @@ def search_sessions(request: HttpRequest) -> HttpResponse:
|
||||
return list_sessions(request, search_string=request.GET.get("search_string", ""))
|
||||
|
||||
|
||||
def _session_fields(form) -> Fragment:
|
||||
def _session_fields(form) -> SafeText:
|
||||
"""Manual per-field layout for the session form.
|
||||
|
||||
Mirrors the old add_session.html: each field gets its label and widget,
|
||||
and the timestamp fields gain a row of now/toggle/copy helper buttons.
|
||||
"""
|
||||
rows: list[Node] = []
|
||||
rows: list[SafeText] = []
|
||||
for field in form:
|
||||
children: list[Node | str] = [
|
||||
Safe(str(field.label_tag())),
|
||||
Safe(str(field)),
|
||||
children: list[SafeText | str] = [
|
||||
mark_safe(str(field.label_tag())),
|
||||
mark_safe(str(field)),
|
||||
]
|
||||
if field.name in ("timestamp_start", "timestamp_end"):
|
||||
this_side = "start" if field.name == "timestamp_start" else "end"
|
||||
other_side = "end" if field.name == "timestamp_start" else "start"
|
||||
children.append(
|
||||
SessionTimestampButtons(
|
||||
class_="form-row-button-group flex-row gap-3 justify-start mt-3",
|
||||
hx_boost="false",
|
||||
)[
|
||||
StyledButton(data_target=field.name, data_type="now", size="xs")[
|
||||
"Set to now"
|
||||
Component(
|
||||
tag_name="span",
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
"form-row-button-group flex-row gap-3 justify-start mt-3",
|
||||
),
|
||||
("hx-boost", "false"),
|
||||
],
|
||||
StyledButton(data_target=field.name, data_type="toggle", size="xs")[
|
||||
"Toggle text"
|
||||
children=[
|
||||
Button(
|
||||
[("data-target", field.name), ("data-type", "now")],
|
||||
"Set to now",
|
||||
size="xs",
|
||||
),
|
||||
Button(
|
||||
[("data-target", field.name), ("data-type", "toggle")],
|
||||
"Toggle text",
|
||||
size="xs",
|
||||
),
|
||||
Button(
|
||||
[("data-target", field.name), ("data-type", "copy")],
|
||||
f"Copy {this_side} value to {other_side}",
|
||||
size="xs",
|
||||
),
|
||||
],
|
||||
StyledButton(data_target=field.name, data_type="copy", size="xs")[
|
||||
f"Copy {this_side} value to {other_side}"
|
||||
],
|
||||
]
|
||||
)
|
||||
)
|
||||
rows.append(Div(children=children))
|
||||
return Fragment(*rows, separator="\n")
|
||||
return mark_safe("\n".join(rows))
|
||||
|
||||
|
||||
@login_required
|
||||
def add_session(request: HttpRequest, game_id: int = 0) -> HttpResponse:
|
||||
initial: dict[str, Any] = {"timestamp_start": timezone.now()}
|
||||
|
||||
last = Session.objects.last()
|
||||
if last is not None:
|
||||
initial["game"] = last.game
|
||||
|
||||
if request.method == "POST":
|
||||
form = SessionForm(request.POST or None, initial=initial)
|
||||
if form.is_valid():
|
||||
@@ -254,7 +256,7 @@ def add_session(request: HttpRequest, game_id: int = 0) -> HttpResponse:
|
||||
request,
|
||||
AddForm(form, request=request, fields=_session_fields(form), submit_class=""),
|
||||
title="Add New Session",
|
||||
scripts=mark_safe(ModuleScript("search_select.js")),
|
||||
scripts=ModuleScript("add_session.js"),
|
||||
)
|
||||
|
||||
|
||||
@@ -269,15 +271,15 @@ def edit_session(request: HttpRequest, session_id: int) -> HttpResponse:
|
||||
request,
|
||||
AddForm(form, request=request, fields=_session_fields(form), submit_class=""),
|
||||
title="Edit Session",
|
||||
scripts=mark_safe(ModuleScript("search_select.js")),
|
||||
scripts=ModuleScript("add_session.js"),
|
||||
)
|
||||
|
||||
|
||||
def _session_row_fragment(session: Session) -> SafeText:
|
||||
"""A single session <tr> (the old list_sessions.html#session-row partial),
|
||||
returned by the inline end/clone-session HTMX endpoints."""
|
||||
name_link = A(
|
||||
href=reverse("games:view_game", args=[session.game.id]),
|
||||
name_link = Component(
|
||||
tag_name="a",
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
@@ -289,10 +291,12 @@ def _session_row_fragment(session: Session) -> SafeText:
|
||||
"group-hover:outline-purple-400 group-hover:outline-4 "
|
||||
"group-hover:decoration-purple-900 group-hover:text-purple-100",
|
||||
),
|
||||
("href", reverse("games:view_game", args=[session.game.id])),
|
||||
],
|
||||
children=[session.game.name],
|
||||
)
|
||||
name_td = Td(
|
||||
name_td = Component(
|
||||
tag_name="td",
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
@@ -301,13 +305,15 @@ def _session_row_fragment(session: Session) -> SafeText:
|
||||
)
|
||||
],
|
||||
children=[
|
||||
Span(
|
||||
Component(
|
||||
tag_name="span",
|
||||
attributes=[("class", "inline-block relative")],
|
||||
children=[name_link],
|
||||
)
|
||||
],
|
||||
)
|
||||
start_td = Td(
|
||||
start_td = Component(
|
||||
tag_name="td",
|
||||
attributes=[
|
||||
("class", "px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden sm:table-cell")
|
||||
],
|
||||
@@ -316,9 +322,10 @@ def _session_row_fragment(session: Session) -> SafeText:
|
||||
|
||||
if not session.timestamp_end:
|
||||
end_url = reverse("games:list_sessions_end_session", args=[session.id])
|
||||
end_inner: SafeText | str = A(
|
||||
href=end_url,
|
||||
end_inner: SafeText | str = Component(
|
||||
tag_name="a",
|
||||
attributes=[
|
||||
("href", end_url),
|
||||
("hx-get", end_url),
|
||||
("hx-target", "closest tr"),
|
||||
("hx-swap", "outerHTML"),
|
||||
@@ -330,7 +337,8 @@ def _session_row_fragment(session: Session) -> SafeText:
|
||||
),
|
||||
],
|
||||
children=[
|
||||
Span(
|
||||
Component(
|
||||
tag_name="span",
|
||||
attributes=[("class", "text-yellow-300")],
|
||||
children=["Finish now?"],
|
||||
)
|
||||
@@ -340,17 +348,19 @@ def _session_row_fragment(session: Session) -> SafeText:
|
||||
end_inner = "--"
|
||||
else:
|
||||
end_inner = date_filter(session.timestamp_end, "d/m/Y H:i")
|
||||
end_td = Td(
|
||||
end_td = Component(
|
||||
tag_name="td",
|
||||
attributes=[
|
||||
("class", "px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden lg:table-cell")
|
||||
],
|
||||
children=[end_inner],
|
||||
)
|
||||
duration_td = Td(
|
||||
duration_td = Component(
|
||||
tag_name="td",
|
||||
attributes=[("class", "px-2 sm:px-4 md:px-6 md:py-2 font-mono")],
|
||||
children=[session.duration_formatted()],
|
||||
)
|
||||
return Tr(children=[name_td, start_td, end_td, duration_td])
|
||||
return Component(tag_name="tr", children=[name_td, start_td, end_td, duration_td])
|
||||
|
||||
|
||||
def clone_session_by_id(session_id: int) -> Session:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user