Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
9bf7215125
|
|||
|
5f5ff19390
|
|||
|
30d35a2368
|
|||
|
64392c3935
|
|||
|
a1304e19ad
|
|||
|
ab94617f06
|
|||
|
5d6646d8ac
|
|||
|
919d6c98ee
|
|||
|
d17e11f2bc
|
|||
|
17c5fdb8a8
|
|||
|
74dffaeae4
|
|||
|
7fc29fccb8
|
|||
|
00758d6a50
|
|||
|
508b04af19
|
|||
|
6d21ffc4c7
|
|||
|
9490e55f89
|
|||
|
0b9dd702e1
|
|||
|
af62120c8d
|
|||
|
dd2ebe5888
|
|||
|
835caf6a71
|
|||
|
231fa483e7
|
|||
|
32eb882a98
|
|||
|
0179363684
|
|||
|
ad5c8d3bb1
|
|||
|
89c9ff6367
|
|||
|
5887febbb7
|
+14
-44
@@ -1,51 +1,21 @@
|
|||||||
# =============================================================================
|
# Docker registry URL (used in docker-compose.yml)
|
||||||
# Django application settings (read by timetracker/config.py)
|
REGISTRY_URL=registry.kucharczyk.xyz
|
||||||
#
|
|
||||||
# Resolution priority, highest first:
|
|
||||||
# SECRET_KEY__FILE -> env var -> .env -> settings.ini -> built-in default
|
|
||||||
# See docs/configuration.md for the full reference.
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
# Turn DEBUG off in production. Defaults on for local development.
|
# Container timezone
|
||||||
# (The old PROD=1 variable still works but is deprecated; prefer DEBUG.)
|
|
||||||
DEBUG=false
|
|
||||||
|
|
||||||
# Secret key. Required in production; an insecure default is used in DEBUG.
|
|
||||||
# For Docker/K8s secrets, point SECRET_KEY__FILE at a mounted file instead.
|
|
||||||
SECRET_KEY=change-me-to-a-long-random-string
|
|
||||||
# SECRET_KEY__FILE=/run/secrets/timetracker_secret_key
|
|
||||||
|
|
||||||
# Public URL(s) of the site — one URL or comma-separated list of full URLs.
|
|
||||||
# Derives ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS from all listed URLs.
|
|
||||||
APP_URL=https://tracker.kucharczyk.xyz
|
|
||||||
# APP_URL=https://tracker.kucharczyk.xyz,https://www.tracker.kucharczyk.xyz
|
|
||||||
|
|
||||||
# Override ALLOWED_HOSTS directly for edge cases (e.g. behind a reverse proxy).
|
|
||||||
# ALLOWED_HOSTS=*
|
|
||||||
|
|
||||||
# Container timezone.
|
|
||||||
TZ=Europe/Prague
|
TZ=Europe/Prague
|
||||||
|
|
||||||
# Directory holding the SQLite database (defaults to the project root).
|
# User/group IDs for container (used in entrypoint.sh)
|
||||||
DATA_DIR=/home/timetracker/app/data
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Container / entrypoint-only settings (read by entrypoint.sh, NOT by Django)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
# User/group IDs the container process runs as.
|
|
||||||
PUID=1000
|
PUID=1000
|
||||||
PGID=100
|
PGID=100
|
||||||
|
|
||||||
# Create an admin/admin superuser on startup (for initial setup only).
|
# External port mapping
|
||||||
CREATE_DEFAULT_SUPERUSER=false
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# docker-compose-only settings (compose file substitution, not the app)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
# Docker registry URL (used in docker-compose.yml).
|
|
||||||
REGISTRY_URL=registry.kucharczyk.xyz
|
|
||||||
|
|
||||||
# External port mapping.
|
|
||||||
TIMETRACKER_EXTERNAL_PORT=8000
|
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
|
- name: Install dependencies
|
||||||
run: uv sync --frozen
|
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
|
- name: Run Migrations
|
||||||
run: uv run python manage.py migrate
|
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
-9
@@ -4,20 +4,12 @@ __pycache__
|
|||||||
.venv/
|
.venv/
|
||||||
node_modules
|
node_modules
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
pnpm-lock.yaml
|
||||||
db.sqlite3
|
db.sqlite3
|
||||||
data/
|
data/
|
||||||
/static/
|
/static/
|
||||||
dist/
|
dist/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.python-version
|
.python-version
|
||||||
|
|
||||||
# Local configuration (may contain secrets); examples are committed instead
|
|
||||||
.env
|
|
||||||
/settings.ini
|
|
||||||
.direnv
|
.direnv
|
||||||
.hermes/
|
.hermes/
|
||||||
|
|
||||||
# Build artifacts: generated in CI/Docker assets stage, not committed
|
|
||||||
/games/static/base.css
|
|
||||||
/games/static/js/dist/
|
|
||||||
/ts/generated/
|
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ games/ — Django app: models, views, templates, forms, signals, tasks,
|
|||||||
common/ — Shared utilities: time formatting, component system, criteria, layout, icons
|
common/ — Shared utilities: time formatting, component system, criteria, layout, icons
|
||||||
timetracker/ — Django project: settings, URL root, ASGI/WSGI
|
timetracker/ — Django project: settings, URL root, ASGI/WSGI
|
||||||
tests/ — Pytest tests
|
tests/ — Pytest tests
|
||||||
e2e/ — Playwright browser tests (run via `make test-e2e`)
|
|
||||||
contrib/ — One-off scripts (exchange rate import)
|
contrib/ — One-off scripts (exchange rate import)
|
||||||
docs/ — Additional documentation
|
docs/ — Additional documentation
|
||||||
```
|
```
|
||||||
@@ -44,7 +43,7 @@ docs/ — Additional documentation
|
|||||||
|
|
||||||
- **Game** — `name`, `platform` (FK), `status` (u/p/f/r/a), `mastered`, `playtime` (DurationField updated via signal), `year_released`, `sort_name`, `wikidata`
|
- **Game** — `name`, `platform` (FK), `status` (u/p/f/r/a), `mastered`, `playtime` (DurationField updated via signal), `year_released`, `sort_name`, `wikidata`
|
||||||
- **Platform** — `name`, `group`, `icon` (slug, auto-generated from name)
|
- **Platform** — `name`, `group`, `icon` (slug, auto-generated from name)
|
||||||
- **Purchase** — ownership type, prices, currency conversion (`converted_price`, `price_per_game` is a `GeneratedField`), links to Game via M2M. `num_purchases` counts linked games. DLC/SeasonPass/BattlePass must have a `related_game` (the base Game the add-on belongs to; reverse accessor `game.addon_purchases`)
|
- **Purchase** — ownership type, prices, currency conversion (`converted_price`, `price_per_game` is a `GeneratedField`), links to Game via M2M. `num_purchases` counts linked games. DLC/SeasonPass/BattlePass must have a `related_purchase`
|
||||||
- **Session** — `timestamp_start`/`timestamp_end`, `duration_manual`, `device` (FK), `note`, `emulated`. `duration_calculated` and `duration_total` are `GeneratedField`s (cannot be written directly)
|
- **Session** — `timestamp_start`/`timestamp_end`, `duration_manual`, `device` (FK), `note`, `emulated`. `duration_calculated` and `duration_total` are `GeneratedField`s (cannot be written directly)
|
||||||
- **Device** — `name`, `type` (PC/Console/Handheld/Mobile/SBC/Unknown)
|
- **Device** — `name`, `type` (PC/Console/Handheld/Mobile/SBC/Unknown)
|
||||||
- **PlayEvent** — marks when a game was started/finished (separate from Sessions), `days_to_finish` is a `GeneratedField`
|
- **PlayEvent** — marks when a game was started/finished (separate from Sessions), `days_to_finish` is a `GeneratedField`
|
||||||
@@ -58,12 +57,12 @@ docs/ — Additional documentation
|
|||||||
|
|
||||||
### Key patterns
|
### 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.
|
**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, JS includes, and FOUC-prevention script. The navbar shows today's playtime and last-7-days playtime from the `model_counts` context processor.
|
||||||
|
|
||||||
**Component system** (`common/components/`): a FastHTML-style **lazy node tree**. Components are `Node` objects that render to HTML only when asked (`str(node)` / `Page()`), so `Page()` can walk a finished tree and collect each component's JS. Split into submodules re-exported via `common/components/__init__.py`:
|
**Component system** (`common/components/`): Pure-Python HTML builders, split into four 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.
|
- **`core.py`** — `Component(tag_name, attributes, children)`: the fundamental builder. `_render_element()` is `@lru_cache`-memoized (4096 entries, always active). Attribute values are always HTML-escaped; children are escaped unless they are `SafeText`. `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()`).
|
- **`primitives.py`** — Generic HTML: `A()`, `Button()` (with color/size/icon params), `ButtonGroup()`, `Div()`, `Span()`, `Label()`, `Input()`, `Icon()`, `Popover()`, `PopoverTruncated()`, `SearchField()`, `H1()`, `Modal()`, `SimpleTable()`, `TableRow()`, `TableTd()`, `TableHeader()`, `paginated_table_content()`, `AddForm()`, `Pill()`, `CsrfInput()`, `ModuleScript()`
|
||||||
- **`domain.py`** — Domain-specific: `GameLink()`, `GameStatus()` (colored dot + label), `GameStatusSelector()` (Alpine.js PATCH dropdown), `SessionDeviceSelector()` (Alpine.js PATCH dropdown), `LinkedPurchase()`, `NameWithIcon()`, `PriceConverted()`, `PurchasePrice()`
|
- **`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)
|
- **`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`
|
- **`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`
|
||||||
@@ -114,47 +113,29 @@ Only a small number of HTML templates remain (platform icon snippets and partial
|
|||||||
### Frontend stack
|
### Frontend stack
|
||||||
|
|
||||||
- **HTMX** (`games/static/js/htmx.min.js`) — partial page updates
|
- **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
|
- **Alpine.js** (CDN) — reactive dropdowns (`GameStatusSelector`, `SessionDeviceSelector`), toast store
|
||||||
- **Flowbite** (vendored: `flowbite.min.js`; `datepicker.umd.js` for the stats YearPicker) — navbar collapse, dropdown toggles
|
- **Flowbite** (CDN) — navbar collapse, dropdown toggles
|
||||||
- **Tailwind CSS** — utility classes, compiled from `common/input.css` → `games/static/base.css`
|
- **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/`:
|
- **Custom JS** in `games/static/js/`:
|
||||||
- `toast.js` — Alpine.js toast store (listens for `show-toast` HTMX event); also defines `window.fetchWithHtmxTriggers`
|
- `toast.js` — Alpine.js toast store (listens for `show-toast` HTMX event)
|
||||||
- `search_select.js` — SearchSelect/FilterSelect widgets (search-as-you-type, pills, include/exclude filter mode)
|
- `search_select.js` — SearchSelect/FilterSelect widgets (search-as-you-type, pills, include/exclude filter mode)
|
||||||
- `utils.js` — shared ES-module helpers (`onSwap`, `toISOUTCString`, …)
|
- `utils.js` — shared helpers (e.g., `fetchWithHtmxTriggers`)
|
||||||
- **Widget initialization**: widget JS registers with `onSwap(selector, initializeElement)` from `utils.js` — a port of FastHTML's `proc_htmx` built on `htmx.onLoad`. It runs the initializer once per matching element, on initial page load and inside every htmx-swapped fragment. Never hand-roll `DOMContentLoaded`/`htmx:afterSwap` listeners with per-element guard flags.
|
|
||||||
|
|
||||||
### Interactive components: custom elements + TypeScript
|
|
||||||
|
|
||||||
New interactive components are **custom elements**, not inline JS in Python. A component that needs behavior emits a semantic tag via `custom_element("tag", Props(...))` (light DOM, server-rendered inner markup built with the htpy-style node builders). Behavior lives in `ts/elements/<tag>.ts` (TypeScript, vanilla DOM, `customElements.define`); the native `connectedCallback` replaces `onSwap` (it fires on parse *and* htmx swap). The server↔client contract is one Python `TypedDict` per element registered with `register_element(...)` in `common/components/custom_elements.py`; `manage.py gen_element_types` codegens `ts/generated/props.ts` (interface + attribute reader) so renaming a prop fails `tsc`.
|
|
||||||
|
|
||||||
- **Build:** `tsc` per-module (`tsconfig.json`) compiles `ts/` → `games/static/js/dist/` (build-only, gitignored). `make ts` = codegen + compile; `make ts-check` (in `make check`) = codegen + `tsc --noEmit`; `make dev` runs `tsc --watch`. The Docker image builds CSS + TS in a Node stage. Run `make ts` after editing any `.ts` so e2e/local serving sees fresh output.
|
|
||||||
- **htpy-style markup:** generic builders take kwargs attributes and `[]` children — `Div(class_="x", hx_get="/y")[child1, child2]` (`class_`→`class`, `hx_get`→`hx-get`, `True`→bare attr, `False`/`None`→omitted). Still a walkable `Element` tree, so `Media` bubbles.
|
|
||||||
- **Do NOT** author HTML/JS as Python f-strings or add new inline Alpine `x-data` blobs. Alpine remains only for trivial pre-existing toggles (toast store, etc.).
|
|
||||||
- **Tables collect cell media:** `SimpleTable` stringifies cells, so it explicitly `collect_media`s its rows/header and re-attaches it — a custom element in a table cell still gets its `<script>` emitted by `Page()`.
|
|
||||||
|
|
||||||
### Deployment
|
### 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.
|
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 GitHub Actions (`.github/workflows/build-docker.yml`): builds Docker image; Drone CI (`.drone.yml`) also present for deployments via Portainer webhook.
|
||||||
|
|
||||||
**Package manager (pnpm):** front-end deps use **pnpm**, not npm. The pnpm version is pinned in `package.json`'s `packageManager` field and provisioned via **Corepack** (bundled with Node) — the Docker assets stage runs `corepack enable` rather than `npm install -g pnpm`. To bump pnpm, update the `packageManager` field; local, CI, and Docker all follow it. pnpm disables dependency lifecycle scripts by default (opt in via `pnpm.onlyBuiltDependencies`), so the project is unaffected by npm v12's install-script changes.
|
|
||||||
|
|
||||||
### Database
|
### 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 `GeneratedField`s on the models — these are computed by the database engine and cannot be written from application code.
|
||||||
|
|
||||||
### Configuration
|
### 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`.
|
- `DEBUG` is `True` unless `PROD` env var is set
|
||||||
|
- `TIME_ZONE` defaults to `Europe/Prague` in debug, otherwise reads `TZ` env var (default `UTC`)
|
||||||
- **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`.
|
- Django Admin, Debug Toolbar, and `django_extensions` are only available in `DEBUG` mode
|
||||||
- `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.
|
- `CSRF_TRUSTED_ORIGINS` is parsed from a comma-separated env var
|
||||||
- `DEBUG` defaults `True` (dev), turned off with `DEBUG=false`. `PROD` is a **deprecated alias** kept for one release.
|
- `DATA_DIR` env var sets the SQLite database location (defaults to `BASE_DIR`)
|
||||||
- `SECRET_KEY` is required in production (insecure default only in DEBUG); supports `SECRET_KEY__FILE`.
|
|
||||||
- `APP_URL` accepts one full URL or a comma-separated list of full URLs; `ALLOWED_HOSTS` and `CSRF_TRUSTED_ORIGINS` are derived from all listed URLs. `ALLOWED_HOSTS` can still be overridden directly (e.g. `ALLOWED_HOSTS=*` behind a reverse proxy); `CSRF_TRUSTED_ORIGINS` is always derived from `APP_URL`.
|
|
||||||
- `TIME_ZONE` reads `TZ` (defaults `Europe/Prague` in debug, `UTC` in prod).
|
|
||||||
- Django Admin, Debug Toolbar, and `django_extensions` are only available in `DEBUG` mode.
|
|
||||||
- **Container/entrypoint-only** flags (`PUID`, `PGID`, `CREATE_DEFAULT_SUPERUSER`, `STAGING`, `LOAD_SAMPLE_DATA`) live in `entrypoint.sh`, not the Python config — they are bootstrap concerns, not Django settings.
|
|
||||||
- django-q2 cluster: 1 worker, 60s timeout, 120s retry, ORM broker
|
- django-q2 cluster: 1 worker, 60s timeout, 120s retry, ORM broker
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
@@ -174,18 +155,14 @@ Tests live in `tests/`. Run with `make test` or `uv run --with pytest-django pyt
|
|||||||
|
|
||||||
Pytest settings are in `pyproject.toml` under `[tool.pytest.ini_options]` (`DJANGO_SETTINGS_MODULE = "timetracker.settings"`).
|
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
|
## 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.
|
- **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.
|
- **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`.
|
- **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.
|
- **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).
|
- **Prefer the named element primitives over raw `Component(tag_name=…)`** — use `Div()`, `Span()`, `Input()`, `Label()`, `Template()`, etc. from `common.components` instead of `Component(tag_name="div")`. Reach for `Component` directly only when no primitive fits (e.g. a bare, custom-styled `<button>` where the opinionated `Button()` would inject unwanted classes). Add a new primitive rather than repeating an inline `Component` for a standard tag.
|
||||||
- **JS-bearing components declare `Media`, they don't rely on the view** — give a component `class Media: js = (...)` (a `BaseComponent`) or `return node.with_media(Media(js=...))`. `Page()` collects and emits it. Never re-add `scripts=ModuleScript(...)` threading in a view for a component that can declare its own dependency.
|
|
||||||
- **Filter views** accept `?filter=<JSON>` (structured) and fall back to `?search_string=` (free-text). New filter criteria go in `games/filters.py`; new criterion types go in `common/criteria.py`.
|
- **Filter views** accept `?filter=<JSON>` (structured) and fall back to `?search_string=` (free-text). New filter criteria go in `games/filters.py`; new criterion types go in `common/criteria.py`.
|
||||||
- **Read settings via `config()`** — new Django settings go through `config()` from `timetracker/config.py`, never bare `os.environ.get` in `settings.py`. Declare `cast`/`allow_file`/`required_in_prod` explicitly. Container-bootstrap flags belong in `entrypoint.sh`, not the Python config. See `docs/configuration.md`.
|
|
||||||
- **Signals handle side-effects** — do not manually recalculate `Game.playtime` or `Purchase.num_purchases`; the signals in `games/signals.py` do this on save/delete.
|
- **Signals handle side-effects** — do not manually recalculate `Game.playtime` or `Purchase.num_purchases`; the signals in `games/signals.py` do this on save/delete.
|
||||||
- **Button colors**: `blue` (primary action), `red` (destructive), `gray` (secondary), `green` (positive). Icon buttons use `icon=True`.
|
- **Button colors**: `blue` (primary action), `red` (destructive), `gray` (secondary), `green` (positive). Icon buttons use `icon=True`.
|
||||||
- **Inline Alpine.js** is used for client-side reactivity in domain components (`GameStatusSelector`, `SessionDeviceSelector`). The pattern is `x-data="{...}"` with `fetchWithHtmxTriggers()` for PATCH API calls.
|
- **Inline Alpine.js** is used for client-side reactivity in domain components (`GameStatusSelector`, `SessionDeviceSelector`). The pattern is `x-data="{...}"` with `fetchWithHtmxTriggers()` for PATCH API calls.
|
||||||
|
|||||||
-23
@@ -15,25 +15,6 @@ COPY . .
|
|||||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
uv sync --frozen --no-dev
|
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
|
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
|
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 Caddyfile /etc/caddy/Caddyfile
|
||||||
COPY --chown=timetracker:timetracker supervisor.conf /etc/supervisor/conf.d/supervisor.conf
|
COPY --chown=timetracker:timetracker supervisor.conf /etc/supervisor/conf.d/supervisor.conf
|
||||||
COPY --chown=timetracker:timetracker entrypoint.sh /
|
COPY --chown=timetracker:timetracker entrypoint.sh /
|
||||||
|
|||||||
@@ -25,22 +25,12 @@ init:
|
|||||||
server:
|
server:
|
||||||
uv run python -Wa manage.py runserver
|
uv run python -Wa manage.py runserver
|
||||||
|
|
||||||
gen-element-types:
|
|
||||||
uv run python manage.py gen_element_types
|
|
||||||
|
|
||||||
ts: gen-element-types
|
|
||||||
pnpm exec tsc
|
|
||||||
|
|
||||||
ts-check: gen-element-types
|
|
||||||
pnpm exec tsc --noEmit
|
|
||||||
|
|
||||||
dev:
|
dev:
|
||||||
@pnpm concurrently \
|
@pnpm concurrently \
|
||||||
--names "Django,Tailwind,TS" \
|
--names "Django,Tailwind" \
|
||||||
--prefix-colors "blue,green,magenta" \
|
--prefix-colors "blue,green" \
|
||||||
"uv run python -Wa manage.py runserver" \
|
"uv run python -Wa manage.py runserver" \
|
||||||
"pnpm tailwindcss -i ./common/input.css -o ./games/static/base.css --watch" \
|
"pnpm tailwindcss -i ./common/input.css -o ./games/static/base.css --watch"
|
||||||
"pnpm exec tsc --watch"
|
|
||||||
|
|
||||||
|
|
||||||
caddy:
|
caddy:
|
||||||
@@ -95,7 +85,7 @@ format:
|
|||||||
format-check:
|
format-check:
|
||||||
uv run ruff format --check
|
uv run ruff format --check
|
||||||
|
|
||||||
check: lint format-check ts-check test
|
check: lint format-check test
|
||||||
|
|
||||||
date:
|
date:
|
||||||
uv run python -c 'import datetime; from zoneinfo import ZoneInfo; print(datetime.datetime.isoformat(datetime.datetime.now(ZoneInfo("Europe/Prague")), timespec="minutes", sep=" "))'
|
uv run python -c 'import datetime; from zoneinfo import ZoneInfo; print(datetime.datetime.isoformat(datetime.datetime.now(ZoneInfo("Europe/Prague")), timespec="minutes", sep=" "))'
|
||||||
|
|||||||
@@ -5,24 +5,11 @@ re-exports the public API so ``from common.components import X`` keeps working.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from common.components.core import (
|
from common.components.core import (
|
||||||
BaseComponent,
|
Component,
|
||||||
Element,
|
|
||||||
Fragment,
|
|
||||||
HTMLAttribute,
|
HTMLAttribute,
|
||||||
HTMLTag,
|
HTMLTag,
|
||||||
Media,
|
|
||||||
Node,
|
|
||||||
Safe,
|
|
||||||
_render_element,
|
_render_element,
|
||||||
collect_media,
|
|
||||||
randomid,
|
randomid,
|
||||||
render,
|
|
||||||
)
|
|
||||||
from common.components.custom_elements import SessionTimestampButtons, register_element
|
|
||||||
from common.components.date_range_picker import (
|
|
||||||
DateRangeCalendar,
|
|
||||||
DateRangeField,
|
|
||||||
DateRangePicker,
|
|
||||||
)
|
)
|
||||||
from common.components.domain import (
|
from common.components.domain import (
|
||||||
GameLink,
|
GameLink,
|
||||||
@@ -48,6 +35,7 @@ from common.components.primitives import (
|
|||||||
H1,
|
H1,
|
||||||
A,
|
A,
|
||||||
AddForm,
|
AddForm,
|
||||||
|
Button,
|
||||||
ButtonGroup,
|
ButtonGroup,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
CsrfInput,
|
CsrfInput,
|
||||||
@@ -66,18 +54,14 @@ from common.components.primitives import (
|
|||||||
SearchField,
|
SearchField,
|
||||||
SimpleTable,
|
SimpleTable,
|
||||||
Span,
|
Span,
|
||||||
StaticScript,
|
|
||||||
StyledButton,
|
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
TableTd,
|
TableTd,
|
||||||
Td,
|
Td,
|
||||||
Template,
|
Template,
|
||||||
Th,
|
|
||||||
Tr,
|
Tr,
|
||||||
Ul,
|
Ul,
|
||||||
YearPicker,
|
YearPicker,
|
||||||
custom_element_builder,
|
|
||||||
paginated_table_content,
|
paginated_table_content,
|
||||||
)
|
)
|
||||||
from common.components.search_select import (
|
from common.components.search_select import (
|
||||||
@@ -92,24 +76,14 @@ from common.utils import truncate
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"truncate",
|
"truncate",
|
||||||
"BaseComponent",
|
"Component",
|
||||||
"register_element",
|
|
||||||
"SessionTimestampButtons",
|
|
||||||
"custom_element_builder",
|
|
||||||
"Element",
|
|
||||||
"Fragment",
|
|
||||||
"Media",
|
|
||||||
"Node",
|
|
||||||
"Safe",
|
|
||||||
"collect_media",
|
|
||||||
"render",
|
|
||||||
"HTMLAttribute",
|
"HTMLAttribute",
|
||||||
"HTMLTag",
|
"HTMLTag",
|
||||||
"_render_element",
|
"_render_element",
|
||||||
"randomid",
|
"randomid",
|
||||||
"A",
|
"A",
|
||||||
"AddForm",
|
"AddForm",
|
||||||
"StyledButton",
|
"Button",
|
||||||
"ButtonGroup",
|
"ButtonGroup",
|
||||||
"Checkbox",
|
"Checkbox",
|
||||||
"CsrfInput",
|
"CsrfInput",
|
||||||
@@ -133,13 +107,7 @@ __all__ = [
|
|||||||
"searchselect_selected",
|
"searchselect_selected",
|
||||||
"SimpleTable",
|
"SimpleTable",
|
||||||
"Span",
|
"Span",
|
||||||
"StaticScript",
|
|
||||||
"Label",
|
"Label",
|
||||||
"Li",
|
|
||||||
"Td",
|
|
||||||
"Th",
|
|
||||||
"Tr",
|
|
||||||
"Ul",
|
|
||||||
"TableHeader",
|
"TableHeader",
|
||||||
"TableRow",
|
"TableRow",
|
||||||
"TableTd",
|
"TableTd",
|
||||||
@@ -155,9 +123,6 @@ __all__ = [
|
|||||||
"PurchasePrice",
|
"PurchasePrice",
|
||||||
"SessionDeviceSelector",
|
"SessionDeviceSelector",
|
||||||
"_resolve_name_with_icon",
|
"_resolve_name_with_icon",
|
||||||
"DateRangeCalendar",
|
|
||||||
"DateRangeField",
|
|
||||||
"DateRangePicker",
|
|
||||||
"FilterBar",
|
"FilterBar",
|
||||||
"PurchaseFilterBar",
|
"PurchaseFilterBar",
|
||||||
"SessionFilterBar",
|
"SessionFilterBar",
|
||||||
|
|||||||
+27
-306
@@ -1,20 +1,6 @@
|
|||||||
"""Node layer: the lazy component tree, its renderer, and media collection.
|
"""Escaping core: the Component builder and its memoised renderer."""
|
||||||
|
|
||||||
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
|
import hashlib
|
||||||
from collections.abc import Sequence
|
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
|
||||||
from django.utils.html import escape
|
from django.utils.html import escape
|
||||||
@@ -24,181 +10,24 @@ from django.utils.safestring import SafeText, mark_safe
|
|||||||
HTMLAttribute = tuple[str, str | int | bool]
|
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
|
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)
|
@lru_cache(maxsize=4096)
|
||||||
def _render_element(
|
def _render_element(
|
||||||
tag_name: str,
|
tag_name: str,
|
||||||
attrs_key: tuple[tuple[str, str], ...],
|
attrs_key: tuple[tuple[str, str], ...],
|
||||||
children_key: tuple[tuple[str, bool], ...],
|
children_key: tuple[tuple[str, bool], ...],
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Pure, memoized HTML builder. Identical (tag, attrs, children) render once.
|
"""Pure, memoized HTML builder behind `Component`.
|
||||||
|
|
||||||
``attrs_key`` is (name, stringified value) pairs (values always escaped);
|
Inputs are fully hashable and fully determine the output, so identical
|
||||||
``children_key`` is (text, is_safe) pairs (safe passes through, else escaped).
|
elements are rendered once. `attrs_key` is (name, stringified value) pairs
|
||||||
|
(attribute values are always escaped). `children_key` is (child, is_safe)
|
||||||
|
pairs: SafeText children pass through, plain strings are escaped. The
|
||||||
|
`is_safe` flag is part of the key on purpose — otherwise a safe ``"<b>"``
|
||||||
|
and an unsafe ``"<b>"`` (equal as strings) would collide and one would
|
||||||
|
render with the wrong escaping.
|
||||||
"""
|
"""
|
||||||
children_blob = "\n".join(
|
children_blob = "\n".join(
|
||||||
child if is_safe else escape(child) for child, is_safe in children_key
|
child if is_safe else escape(child) for child, is_safe in children_key
|
||||||
@@ -212,132 +41,24 @@ def _render_element(
|
|||||||
return f"<{tag_name}{attributes_blob}>{children_blob}</{tag_name}>"
|
return f"<{tag_name}{attributes_blob}>{children_blob}</{tag_name}>"
|
||||||
|
|
||||||
|
|
||||||
class Element(Node):
|
def Component(
|
||||||
"""Any HTML element: a tag name, attributes and children.
|
attributes: list[HTMLAttribute] | None = None,
|
||||||
|
children: list[HTMLTag] | HTMLTag | None = None,
|
||||||
Children may be other nodes, ``SafeText``, or plain strings (escaped).
|
tag_name: str = "",
|
||||||
Rendering goes through the memoized :func:`_render_element`.
|
) -> SafeText:
|
||||||
"""
|
"""Render an HTML element. Attribute values are always escaped; children are
|
||||||
|
escaped unless they are `SafeText` (so nested components pass through),
|
||||||
def __init__(
|
preventing accidental HTML injection. Rendering is memoized via
|
||||||
self,
|
`_render_element`."""
|
||||||
tag_name: str,
|
attributes = attributes or []
|
||||||
attributes: Attributes | None = None,
|
children = children or []
|
||||||
children: "Children | Node" = None,
|
if not tag_name:
|
||||||
) -> None:
|
raise ValueError("tag_name is required.")
|
||||||
if not tag_name:
|
if isinstance(children, str):
|
||||||
raise ValueError("tag_name is required.")
|
children = [children]
|
||||||
self.tag_name = tag_name
|
attrs_key = tuple((name, str(value)) for name, value in attributes)
|
||||||
self.attributes = attributes or []
|
children_key = tuple((child, isinstance(child, SafeText)) for child in children)
|
||||||
if children is None:
|
return mark_safe(_render_element(tag_name, attrs_key, children_key))
|
||||||
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:
|
def randomid(seed: str = "", content: str = "", length: int = 10) -> str:
|
||||||
|
|||||||
@@ -1,127 +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 Media
|
|
||||||
from common.components.primitives import custom_element_builder
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ElementSpec:
|
|
||||||
tag: str # e.g. "game-status-selector"
|
|
||||||
ts_name: str # e.g. "GameStatusSelector"
|
|
||||||
props: type # a TypedDict subclass
|
|
||||||
|
|
||||||
|
|
||||||
ELEMENT_REGISTRY: list[ElementSpec] = []
|
|
||||||
|
|
||||||
|
|
||||||
def register_element(tag: str, ts_name: str, props: type) -> None:
|
|
||||||
"""Register an element so codegen can emit its TS contract."""
|
|
||||||
ELEMENT_REGISTRY.append(ElementSpec(tag, ts_name, props))
|
|
||||||
|
|
||||||
|
|
||||||
def _kebab(name: str) -> str:
|
|
||||||
return name.replace("_", "-")
|
|
||||||
|
|
||||||
|
|
||||||
# ── Codegen ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
_TYPE_MAP = {int: "number", float: "number", str: "string", bool: "boolean"}
|
|
||||||
|
|
||||||
|
|
||||||
def _camel(name: str) -> str:
|
|
||||||
head, *tail = name.split("_")
|
|
||||||
return head + "".join(part.title() for part in tail)
|
|
||||||
|
|
||||||
|
|
||||||
def _reader_expr(name: str, python_type: type) -> str:
|
|
||||||
attr = _kebab(name)
|
|
||||||
if python_type in (int, float):
|
|
||||||
return f'Number(el.getAttribute("{attr}"))'
|
|
||||||
if python_type is bool:
|
|
||||||
return f'el.getAttribute("{attr}") === "true"'
|
|
||||||
return f'el.getAttribute("{attr}") ?? ""'
|
|
||||||
|
|
||||||
|
|
||||||
def _ts_for_spec(spec: ElementSpec) -> str:
|
|
||||||
hints = get_type_hints(spec.props)
|
|
||||||
interface_lines = "\n".join(
|
|
||||||
f" {_camel(name)}: {_TYPE_MAP[python_type]};"
|
|
||||||
for name, python_type in hints.items()
|
|
||||||
)
|
|
||||||
reader_lines = "\n".join(
|
|
||||||
f" {_camel(name)}: {_reader_expr(name, python_type)},"
|
|
||||||
for name, python_type in hints.items()
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
f"export interface {spec.ts_name}Props {{\n{interface_lines}\n}}\n\n"
|
|
||||||
f"export function read{spec.ts_name}Props(el: HTMLElement): "
|
|
||||||
f"{spec.ts_name}Props {{\n return {{\n{reader_lines}\n }};\n}}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def render_props_module() -> str:
|
|
||||||
"""The full ``ts/generated/props.ts`` content for every registered element."""
|
|
||||||
header = "// GENERATED by `manage.py gen_element_types` — do not edit.\n"
|
|
||||||
blocks = [_ts_for_spec(spec) for spec in ELEMENT_REGISTRY]
|
|
||||||
return header + "\n" + "\n\n".join(blocks) + "\n"
|
|
||||||
|
|
||||||
|
|
||||||
# ── Element prop schemas (registered at import time) ─────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
class GameStatusSelectorProps(TypedDict):
|
|
||||||
game_id: int
|
|
||||||
status: str
|
|
||||||
csrf: str
|
|
||||||
|
|
||||||
|
|
||||||
register_element("game-status-selector", "GameStatusSelector", GameStatusSelectorProps)
|
|
||||||
|
|
||||||
|
|
||||||
class SessionDeviceSelectorProps(TypedDict):
|
|
||||||
session_id: int
|
|
||||||
csrf: str
|
|
||||||
|
|
||||||
|
|
||||||
register_element(
|
|
||||||
"session-device-selector", "SessionDeviceSelector", SessionDeviceSelectorProps
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class PlayEventRowProps(TypedDict):
|
|
||||||
game_id: int
|
|
||||||
csrf: str
|
|
||||||
api_create_url: str
|
|
||||||
|
|
||||||
|
|
||||||
register_element("play-event-row", "PlayEventRow", PlayEventRowProps)
|
|
||||||
|
|
||||||
|
|
||||||
class SessionTimestampButtonsProps(TypedDict):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
register_element(
|
|
||||||
"session-timestamp-buttons", "SessionTimestampButtons", SessionTimestampButtonsProps
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── Named tag builders (consistent htpy-style with Div/Span) ─────────────────
|
|
||||||
# Underscore-prefixed: used internally by domain wrappers.
|
|
||||||
# Public ones (no domain wrapper): exported directly.
|
|
||||||
|
|
||||||
_GameStatusSelector = custom_element_builder("game-status-selector")
|
|
||||||
_SessionDeviceSelector = custom_element_builder("session-device-selector")
|
|
||||||
_PlayEventRow = custom_element_builder("play-event-row")
|
|
||||||
SessionTimestampButtons = custom_element_builder("session-timestamp-buttons")
|
|
||||||
@@ -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)
|
|
||||||
+142
-109
@@ -4,8 +4,9 @@ from typing import Any
|
|||||||
|
|
||||||
from django.template.defaultfilters import floatformat
|
from django.template.defaultfilters import floatformat
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.utils.safestring import SafeText, mark_safe
|
||||||
|
|
||||||
from common.components.core import Children, Node, Safe, as_children
|
from common.components.core import HTMLTag
|
||||||
from common.components.primitives import (
|
from common.components.primitives import (
|
||||||
A,
|
A,
|
||||||
Div,
|
Div,
|
||||||
@@ -20,12 +21,13 @@ from games.models import Game, Purchase, Session
|
|||||||
def GameLink(
|
def GameLink(
|
||||||
game_id: int,
|
game_id: int,
|
||||||
name: str = "",
|
name: str = "",
|
||||||
children: Children = None,
|
children: list[HTMLTag] | HTMLTag | None = None,
|
||||||
) -> Node:
|
) -> SafeText:
|
||||||
"""Link to a game's detail page. Uses children (slot) if provided, otherwise name."""
|
"""Link to a game's detail page. Uses children (slot) if provided, otherwise name."""
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
display = as_children(children) or [name]
|
children = children or []
|
||||||
|
display = children if children else [name]
|
||||||
link = reverse("games:view_game", args=[game_id])
|
link = reverse("games:view_game", args=[game_id])
|
||||||
|
|
||||||
return Span(
|
return Span(
|
||||||
@@ -36,7 +38,7 @@ def GameLink(
|
|||||||
attributes=[
|
attributes=[
|
||||||
("class", "underline decoration-slate-500 sm:decoration-2"),
|
("class", "underline decoration-slate-500 sm:decoration-2"),
|
||||||
],
|
],
|
||||||
children=display,
|
children=display if isinstance(display, list) else [display],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -52,11 +54,11 @@ _STATUS_COLORS = {
|
|||||||
|
|
||||||
|
|
||||||
def GameStatus(
|
def GameStatus(
|
||||||
children: Children = None,
|
children: list[HTMLTag] | HTMLTag | None = None,
|
||||||
status: str = "u",
|
status: str = "u",
|
||||||
display: str = "",
|
display: str = "",
|
||||||
class_: str = "",
|
class_: str = "",
|
||||||
) -> Node:
|
) -> SafeText:
|
||||||
"""Colored status dot with label. Status codes: u/p/f/a/r."""
|
"""Colored status dot with label. Status codes: u/p/f/a/r."""
|
||||||
children = children or []
|
children = children or []
|
||||||
outer_class = (
|
outer_class = (
|
||||||
@@ -74,13 +76,13 @@ def GameStatus(
|
|||||||
|
|
||||||
return Span(
|
return Span(
|
||||||
attributes=[("class", outer_class)],
|
attributes=[("class", outer_class)],
|
||||||
children=[dot] + as_children(children),
|
children=[dot] + (children if isinstance(children, list) else [children]),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def PriceConverted(
|
def PriceConverted(
|
||||||
children: Children = None,
|
children: list[HTMLTag] | HTMLTag | None = None,
|
||||||
) -> Node:
|
) -> SafeText:
|
||||||
"""Wrap content in a span that indicates the price was converted."""
|
"""Wrap content in a span that indicates the price was converted."""
|
||||||
children = children or []
|
children = children or []
|
||||||
return Span(
|
return Span(
|
||||||
@@ -88,11 +90,11 @@ def PriceConverted(
|
|||||||
("title", "Price is a result of conversion and rounding."),
|
("title", "Price is a result of conversion and rounding."),
|
||||||
("class", "decoration-dotted underline"),
|
("class", "decoration-dotted underline"),
|
||||||
],
|
],
|
||||||
children=as_children(children),
|
children=children if isinstance(children, list) else [children],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def LinkedPurchase(purchase: Purchase) -> Node:
|
def LinkedPurchase(purchase: Purchase) -> SafeText:
|
||||||
link = reverse("games:view_purchase", args=[int(purchase.id)])
|
link = reverse("games:view_purchase", args=[int(purchase.id)])
|
||||||
link_content = ""
|
link_content = ""
|
||||||
popover_content = ""
|
popover_content = ""
|
||||||
@@ -129,7 +131,7 @@ def LinkedPurchase(purchase: Purchase) -> Node:
|
|||||||
),
|
),
|
||||||
PopoverTruncated(
|
PopoverTruncated(
|
||||||
input_string=link_content,
|
input_string=link_content,
|
||||||
popover_content=Safe(popover_content),
|
popover_content=mark_safe(popover_content),
|
||||||
popover_if_not_truncated=popover_if_not_truncated,
|
popover_if_not_truncated=popover_if_not_truncated,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -143,7 +145,7 @@ def NameWithIcon(
|
|||||||
session: Session | None = None,
|
session: Session | None = None,
|
||||||
linkify: bool = True,
|
linkify: bool = True,
|
||||||
emulated: bool = False,
|
emulated: bool = False,
|
||||||
) -> Node:
|
) -> SafeText:
|
||||||
_name, platform, final_emulated, create_link, link = _resolve_name_with_icon(
|
_name, platform, final_emulated, create_link, link = _resolve_name_with_icon(
|
||||||
name, game, session, linkify
|
name, game, session, linkify
|
||||||
)
|
)
|
||||||
@@ -201,7 +203,7 @@ def _resolve_name_with_icon(
|
|||||||
return _name, platform, final_emulated, create_link, link
|
return _name, platform, final_emulated, create_link, link
|
||||||
|
|
||||||
|
|
||||||
def PurchasePrice(purchase) -> Node:
|
def PurchasePrice(purchase) -> SafeText:
|
||||||
return Popover(
|
return Popover(
|
||||||
popover_content=f"{floatformat(purchase.price)} {purchase.price_currency}",
|
popover_content=f"{floatformat(purchase.price)} {purchase.price_currency}",
|
||||||
wrapped_content=f"{floatformat(purchase.converted_price)} {purchase.converted_currency}",
|
wrapped_content=f"{floatformat(purchase.converted_price)} {purchase.converted_currency}",
|
||||||
@@ -209,100 +211,131 @@ def PurchasePrice(purchase) -> Node:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
_SELECTOR_MENU_CLASS = (
|
def GameStatusSelector(game, game_statuses, csrf_token: str) -> SafeText:
|
||||||
"absolute top-[105%] left-0 w-full whitespace-nowrap z-10 text-sm "
|
"""Alpine.js dropdown to change a game's status."""
|
||||||
"font-medium bg-gray-800/20 backdrop-blur-lg rounded-md rounded-t-none "
|
options_html = "\n".join(
|
||||||
"border border-gray-200 dark:border-gray-700"
|
f"<template x-if=\"status == '{value}'\">"
|
||||||
)
|
f"{GameStatus(status=value, children=[label], display='flex')}"
|
||||||
_SELECTOR_TOGGLE_CLASS = (
|
f"</template>"
|
||||||
"relative px-4 py-2 text-sm font-medium bg-white border border-gray-200 "
|
|
||||||
"rounded-lg hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-700 "
|
|
||||||
"dark:hover:text-white dark:hover:bg-gray-700 hover:cursor-pointer"
|
|
||||||
)
|
|
||||||
_SELECTOR_OPTION_CLASS = (
|
|
||||||
"block w-full text-left px-4 py-2 rounded-sm cursor-pointer "
|
|
||||||
"hover:bg-gray-700 hover:text-white dark:hover:bg-gray-700 "
|
|
||||||
"dark:hover:text-white border-0"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def GameStatusSelector(game, game_statuses, csrf_token: str) -> Node:
|
|
||||||
"""Light-DOM custom element; behavior in ts/elements/game-status-selector.ts."""
|
|
||||||
from common.components.core import Element
|
|
||||||
from common.components.custom_elements import _GameStatusSelector, GameStatusSelectorProps
|
|
||||||
from common.components.primitives import Li, Ul
|
|
||||||
|
|
||||||
options = [
|
|
||||||
Li()[
|
|
||||||
Element(
|
|
||||||
"button",
|
|
||||||
[
|
|
||||||
("type", "button"),
|
|
||||||
("data-option", ""),
|
|
||||||
("data-value", str(value)),
|
|
||||||
("class", _SELECTOR_OPTION_CLASS),
|
|
||||||
],
|
|
||||||
GameStatus(status=value, children=[label], display="flex"),
|
|
||||||
)
|
|
||||||
]
|
|
||||||
for value, label in game_statuses
|
for value, label in game_statuses
|
||||||
]
|
)
|
||||||
current_label = Span(data_label="")[
|
list_items = "\n".join(
|
||||||
GameStatus(
|
f"<li><a href=\"#\" @click.prevent.stop=\"setStatus('{value}', '{label}'); open = false;\" "
|
||||||
status=game.status,
|
f'class="block px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 '
|
||||||
children=[game.get_status_display()],
|
f'dark:focus:ring-blue-500 dark:focus:text-white rounded-sm no-underline! border-0!" '
|
||||||
display="flex",
|
f":class=\"{{'font-bold': status === '{value}'}}\">"
|
||||||
|
f"{GameStatus(status=value, children=[label], display='flex', class_='text-slate-300')}"
|
||||||
|
f"</a></li>"
|
||||||
|
for value, label in game_statuses
|
||||||
|
)
|
||||||
|
|
||||||
|
return mark_safe(f"""
|
||||||
|
<div class="flex gap-2 items-center"
|
||||||
|
x-data="{{
|
||||||
|
status: '{game.status}',
|
||||||
|
status_display: '{game.get_status_display()}',
|
||||||
|
open: false,
|
||||||
|
saving: false,
|
||||||
|
setStatus(newStatus, newStatusDisplay) {{
|
||||||
|
this.status = newStatus;
|
||||||
|
this.status_display = newStatusDisplay;
|
||||||
|
this.saving = true;
|
||||||
|
fetchWithHtmxTriggers(`/api/games/{game.id}/status`, {{
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {{
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': '{csrf_token}'
|
||||||
|
}},
|
||||||
|
body: JSON.stringify({{ status: newStatus }})
|
||||||
|
}})
|
||||||
|
.then(() => {{
|
||||||
|
document.body.dispatchEvent(new CustomEvent('status-changed'));
|
||||||
|
}})
|
||||||
|
.catch(() => {{
|
||||||
|
console.error('Failed to update status');
|
||||||
|
}})
|
||||||
|
.finally(() => this.saving = false);
|
||||||
|
}}
|
||||||
|
}}">
|
||||||
|
{_dropdown_button_html(options_html, list_items)}
|
||||||
|
</div>
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def SessionDeviceSelector(session, session_devices, csrf_token: str) -> SafeText:
|
||||||
|
"""Alpine.js dropdown to change a session's device."""
|
||||||
|
device_id = session.device_id or "null"
|
||||||
|
device_name = (session.device.name if session.device else "Unknown").replace(
|
||||||
|
"'", "\\'"
|
||||||
|
)
|
||||||
|
|
||||||
|
list_items = "\n".join(
|
||||||
|
f'<li><a href="#" @click.prevent.stop="setDevice({d.id}, \'{d.name.replace(chr(39), chr(92) + chr(39))}\'); open = false;" '
|
||||||
|
f'class="block px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 '
|
||||||
|
f'dark:focus:ring-blue-500 dark:focus:text-white rounded-sm no-underline! border-0!" '
|
||||||
|
f":class=\"{{'font-bold': deviceId === {d.id}}}\">{d.name}</a></li>"
|
||||||
|
for d in session_devices
|
||||||
|
)
|
||||||
|
|
||||||
|
return mark_safe(f"""
|
||||||
|
<div class="flex gap-2 items-center"
|
||||||
|
x-data="{{
|
||||||
|
originalDeviceId: {device_id},
|
||||||
|
originalDeviceName: '{device_name}',
|
||||||
|
deviceId: {device_id},
|
||||||
|
deviceName: '{device_name}',
|
||||||
|
open: false,
|
||||||
|
saving: false,
|
||||||
|
setDevice(newDeviceId, newDeviceName) {{
|
||||||
|
this.deviceId = newDeviceId;
|
||||||
|
this.deviceName = newDeviceName;
|
||||||
|
this.saving = true;
|
||||||
|
fetchWithHtmxTriggers(`/api/session/{session.id}/device`, {{
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {{
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': '{csrf_token}'
|
||||||
|
}},
|
||||||
|
body: JSON.stringify({{ device_id: newDeviceId }})
|
||||||
|
}})
|
||||||
|
.then((res) => {{
|
||||||
|
document.body.dispatchEvent(new CustomEvent('device-changed'));
|
||||||
|
}})
|
||||||
|
.catch(() => {{
|
||||||
|
this.deviceName = this.originalDeviceName;
|
||||||
|
this.deviceId = this.originalDeviceId;
|
||||||
|
console.error('Failed to update device');
|
||||||
|
}})
|
||||||
|
.finally(() => this.saving = false);
|
||||||
|
}}
|
||||||
|
}}">
|
||||||
|
{
|
||||||
|
_dropdown_button_html(
|
||||||
|
'<span x-text="deviceName"></span>' + str(Icon("arrowdown")), list_items
|
||||||
)
|
)
|
||||||
]
|
}
|
||||||
toggle = Element(
|
</div>
|
||||||
"button",
|
""")
|
||||||
[("type", "button"), ("data-toggle", ""), ("class", _SELECTOR_TOGGLE_CLASS)],
|
|
||||||
Span(class_="flex flex-row gap-4 justify-between items-center")[
|
|
||||||
current_label, Icon("arrowdown")
|
def _dropdown_button_html(button_content: str, list_items: str) -> str:
|
||||||
],
|
"""Shared dropdown button + list structure for Alpine.js selectors."""
|
||||||
|
return (
|
||||||
|
'<div class="inline-flex rounded-md shadow-2xs" role="group" @click.outside="open = false">'
|
||||||
|
'<button type="button" @click="open = !open" '
|
||||||
|
'class="relative px-4 py-2 text-sm font-medium bg-white border border-gray-200 '
|
||||||
|
"rounded-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:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 "
|
||||||
|
'dark:focus:text-white align-middle hover:cursor-pointer">'
|
||||||
|
f'<span class="flex flex-row gap-4 justify-between items-center">{button_content}</span>'
|
||||||
|
'<div 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" x-show="open" style="display: none;">'
|
||||||
|
'<ul class="[&_li:first-of-type_a]:rounded-none [&_li:last-of-type_a]:rounded-t-none">'
|
||||||
|
f"{list_items}"
|
||||||
|
"</ul>"
|
||||||
|
"</div>"
|
||||||
|
"</button>"
|
||||||
|
"</div>"
|
||||||
)
|
)
|
||||||
menu = Div(data_menu="", hidden=True, class_=_SELECTOR_MENU_CLASS)[Ul()[*options]]
|
|
||||||
dropdown = Div(
|
|
||||||
data_dropdown="", class_="inline-flex rounded-md shadow-2xs relative"
|
|
||||||
)[toggle, menu]
|
|
||||||
return _GameStatusSelector(game_id=game.id, status=game.status, csrf=csrf_token)[
|
|
||||||
Div(class_="flex gap-2 items-center")[dropdown]
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def SessionDeviceSelector(session, session_devices, csrf_token: str) -> Node:
|
|
||||||
"""Light-DOM custom element; behavior in ts/elements/session-device-selector.ts."""
|
|
||||||
from common.components.core import Element
|
|
||||||
from common.components.custom_elements import _SessionDeviceSelector, SessionDeviceSelectorProps
|
|
||||||
from common.components.primitives import Li, Ul
|
|
||||||
|
|
||||||
current_name = session.device.name if session.device else "Unknown"
|
|
||||||
options = [
|
|
||||||
Li()[
|
|
||||||
Element(
|
|
||||||
"button",
|
|
||||||
[
|
|
||||||
("type", "button"),
|
|
||||||
("data-option", ""),
|
|
||||||
("data-value", str(device.id)),
|
|
||||||
("class", _SELECTOR_OPTION_CLASS),
|
|
||||||
],
|
|
||||||
children=[device.name],
|
|
||||||
)
|
|
||||||
]
|
|
||||||
for device in session_devices
|
|
||||||
]
|
|
||||||
toggle = Element(
|
|
||||||
"button",
|
|
||||||
[("type", "button"), ("data-toggle", ""), ("class", _SELECTOR_TOGGLE_CLASS)],
|
|
||||||
Span(class_="flex flex-row gap-4 justify-between items-center")[
|
|
||||||
Span(data_label="")[current_name], Icon("arrowdown")
|
|
||||||
],
|
|
||||||
)
|
|
||||||
menu = Div(data_menu="", hidden=True, class_=_SELECTOR_MENU_CLASS)[Ul()[*options]]
|
|
||||||
dropdown = Div(
|
|
||||||
data_dropdown="", class_="inline-flex rounded-md shadow-2xs relative"
|
|
||||||
)[toggle, menu]
|
|
||||||
return _SessionDeviceSelector(session_id=session.id, csrf=csrf_token)[
|
|
||||||
Div(class_="flex gap-2 items-center")[dropdown]
|
|
||||||
]
|
|
||||||
|
|||||||
+177
-230
@@ -3,9 +3,9 @@
|
|||||||
from typing import NamedTuple
|
from typing import NamedTuple
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.utils.safestring import SafeText, mark_safe
|
||||||
|
|
||||||
from common.components.core import BaseComponent, Element, Media, Node, Safe
|
from common.components.core import Component
|
||||||
from common.components.date_range_picker import DateRangePicker
|
|
||||||
from common.components.primitives import Checkbox, Div, Input, Label, Radio, Span
|
from common.components.primitives import Checkbox, Div, Input, Label, Radio, Span
|
||||||
from common.components.search_select import (
|
from common.components.search_select import (
|
||||||
DEFAULT_PREFETCH,
|
DEFAULT_PREFETCH,
|
||||||
@@ -52,13 +52,6 @@ _FILTER_RADIO_CLASS = (
|
|||||||
_FILTER_GRID_CLASS = "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-4"
|
_FILTER_GRID_CLASS = "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-4"
|
||||||
|
|
||||||
|
|
||||||
# range_slider.js wires RangeSlider; filter_bar.js wires the bar chrome
|
|
||||||
# (Apply/Clear, presets, search injection). Widget media (search_select.js,
|
|
||||||
# date_range_picker.js) bubbles up from the contained FilterSelect / picker.
|
|
||||||
_RANGE_SLIDER_MEDIA = Media(js=("range_slider.js",))
|
|
||||||
_FILTER_BAR_MEDIA = Media(js=("filter_bar.js",))
|
|
||||||
|
|
||||||
|
|
||||||
def _filter_parse(filter_json: str) -> dict:
|
def _filter_parse(filter_json: str) -> dict:
|
||||||
if not filter_json:
|
if not filter_json:
|
||||||
return {}
|
return {}
|
||||||
@@ -173,7 +166,9 @@ def _split_modifier(modifier: str, has_m2m: bool = False) -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _enum_filter(field_name: str, options, choice: FilterChoice, *, nullable) -> Node:
|
def _enum_filter(
|
||||||
|
field_name: str, options, choice: FilterChoice, *, nullable
|
||||||
|
) -> SafeText:
|
||||||
"""A FilterSelect over a small, fully pre-rendered option set (enum field).
|
"""A FilterSelect over a small, fully pre-rendered option set (enum field).
|
||||||
|
|
||||||
Enum fields are single-valued, so no M2M modifiers (all/only are
|
Enum fields are single-valued, so no M2M modifiers (all/only are
|
||||||
@@ -204,7 +199,7 @@ def _model_filter(
|
|||||||
search_url,
|
search_url,
|
||||||
nullable,
|
nullable,
|
||||||
m2m_modifiers: list[LabeledOption] | None = None,
|
m2m_modifiers: list[LabeledOption] | None = None,
|
||||||
) -> Node:
|
) -> SafeText:
|
||||||
"""A FilterSelect backed by a search endpoint.
|
"""A FilterSelect backed by a search endpoint.
|
||||||
|
|
||||||
Labels are embedded in the filter JSON (Stash-style), so pills render
|
Labels are embedded in the filter JSON (Stash-style), so pills render
|
||||||
@@ -237,43 +232,34 @@ def _filter_mins_to_hrs(val) -> str:
|
|||||||
return str(int(hrs)) if hrs == int(hrs) else f"{hrs:.1f}"
|
return str(int(hrs)) if hrs == int(hrs) else f"{hrs:.1f}"
|
||||||
|
|
||||||
|
|
||||||
def _widget_id(widget) -> str:
|
def _filter_field(label: str, widget, for_widget: str = None) -> SafeText:
|
||||||
"""Best-effort id of a widget node, for the field label's ``for`` target.
|
"""A labelled filter field: <div><label>…</label>{widget}</div>.
|
||||||
|
TODO: Use widget.attributes.get("id", "") to get the widget's ID
|
||||||
Widgets are nodes carrying ``.attributes``, so the id is now reachable
|
instead of the superfluous "for" argument. This requires refactoring
|
||||||
directly (the old free ``Component`` string couldn't expose it).
|
the Component function to be a class intead.
|
||||||
|
Also see RangeSlider's TODO
|
||||||
"""
|
"""
|
||||||
for name, value in getattr(widget, "attributes", []):
|
|
||||||
if name == "id":
|
|
||||||
return str(value)
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def _filter_field(label: str, widget) -> Node:
|
|
||||||
"""A labelled filter field: ``<div><label>…</label>{widget}</div>``.
|
|
||||||
|
|
||||||
The label's ``for`` points at the widget's own id when it has one;
|
|
||||||
composite widgets without a single root id simply omit ``for``.
|
|
||||||
"""
|
|
||||||
label_attributes = [("class", _FILTER_LABEL_CLASS)]
|
|
||||||
widget_id = _widget_id(widget)
|
|
||||||
if widget_id:
|
|
||||||
label_attributes.append(("for", widget_id))
|
|
||||||
return Div(
|
return Div(
|
||||||
attributes=[("class", "flex flex-col gap-1")],
|
attributes=[("class", "flex flex-col gap-1")],
|
||||||
children=[
|
children=[
|
||||||
Label(attributes=label_attributes, children=[label]),
|
Label(
|
||||||
|
attributes=[
|
||||||
|
("class", _FILTER_LABEL_CLASS),
|
||||||
|
("for", for_widget),
|
||||||
|
],
|
||||||
|
children=[label],
|
||||||
|
),
|
||||||
widget,
|
widget,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _filter_checkbox(name: str, label: str, checked: bool) -> Node:
|
def _filter_checkbox(name: str, label: str, checked: bool) -> SafeText:
|
||||||
"""Thin adapter mapping legacy checkbox filters to the generalized Checkbox primitive."""
|
"""Thin adapter mapping legacy checkbox filters to the generalized Checkbox primitive."""
|
||||||
return Checkbox(name=name, label=label, checked=checked)
|
return Checkbox(name=name, label=label, checked=checked)
|
||||||
|
|
||||||
|
|
||||||
def _filter_boolean_radio(name: str, label: str, value: bool | None) -> Node:
|
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."""
|
"""Renders a filter-specific boolean radio button group with 'True' and 'False' options."""
|
||||||
return Div(
|
return Div(
|
||||||
attributes=[("class", "flex flex-col gap-1")],
|
attributes=[("class", "flex flex-col gap-1")],
|
||||||
@@ -327,7 +313,7 @@ def RangeSlider(
|
|||||||
step: str = "1",
|
step: str = "1",
|
||||||
min_placeholder: str = "",
|
min_placeholder: str = "",
|
||||||
max_placeholder: str = "",
|
max_placeholder: str = "",
|
||||||
) -> Node:
|
) -> SafeText:
|
||||||
"""A labelled range slider with number inputs and range/point mode toggle.
|
"""A labelled range slider with number inputs and range/point mode toggle.
|
||||||
|
|
||||||
Renders a label row (label, two number inputs, toggle button) and a slider
|
Renders a label row (label, two number inputs, toggle button) and a slider
|
||||||
@@ -347,9 +333,14 @@ def RangeSlider(
|
|||||||
Div(
|
Div(
|
||||||
attributes=[("class", "flex items-center gap-2 mb-1")],
|
attributes=[("class", "flex items-center gap-2 mb-1")],
|
||||||
children=[
|
children=[
|
||||||
# The field label is rendered by the _filter_field wrapper.
|
# TODO: This should be done outside the RangeSlider component, but the current Component function doesn't allow getting the id
|
||||||
# This composite widget has no single labelable root, so the
|
# Label(
|
||||||
# label carries no `for` (the two inputs are named below).
|
# attributes=[
|
||||||
|
# ("class", _FILTER_LABEL_CLASS),
|
||||||
|
# ("for", min_input_id),
|
||||||
|
# ],
|
||||||
|
# children=[label],
|
||||||
|
# ),
|
||||||
Input(
|
Input(
|
||||||
attributes=[
|
attributes=[
|
||||||
("type", "number"),
|
("type", "number"),
|
||||||
@@ -384,8 +375,8 @@ def RangeSlider(
|
|||||||
("class", _RANGE_SLIDER_INPUT_CLASS),
|
("class", _RANGE_SLIDER_INPUT_CLASS),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Element(
|
Component(
|
||||||
"button",
|
tag_name="button",
|
||||||
attributes=[
|
attributes=[
|
||||||
("type", "button"),
|
("type", "button"),
|
||||||
(
|
(
|
||||||
@@ -411,7 +402,7 @@ def RangeSlider(
|
|||||||
+ (" hidden" if point_mode else ""),
|
+ (" hidden" if point_mode else ""),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
children=[Safe(_RANGE_ICON_SVG)],
|
children=[mark_safe(_RANGE_ICON_SVG)],
|
||||||
),
|
),
|
||||||
Span(
|
Span(
|
||||||
attributes=[
|
attributes=[
|
||||||
@@ -421,7 +412,7 @@ def RangeSlider(
|
|||||||
+ ("" if point_mode else " hidden"),
|
+ ("" if point_mode else " hidden"),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
children=[Safe(_POINT_ICON_SVG)],
|
children=[mark_safe(_POINT_ICON_SVG)],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -430,7 +421,7 @@ def RangeSlider(
|
|||||||
# ── Slider row ──
|
# ── Slider row ──
|
||||||
Div(
|
Div(
|
||||||
attributes=[
|
attributes=[
|
||||||
("class", "range-slider relative h-10 w-5/6 select-none mt-1"),
|
("class", "range-slider relative h-10 select-none mt-1"),
|
||||||
("data-mode", initial_mode),
|
("data-mode", initial_mode),
|
||||||
("data-min", str(range_min)),
|
("data-min", str(range_min)),
|
||||||
("data-max", str(range_max)),
|
("data-max", str(range_max)),
|
||||||
@@ -490,7 +481,7 @@ def RangeSlider(
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
).with_media(_RANGE_SLIDER_MEDIA)
|
)
|
||||||
|
|
||||||
|
|
||||||
_DATE_RANGE_INPUT_CLASS = (
|
_DATE_RANGE_INPUT_CLASS = (
|
||||||
@@ -507,7 +498,7 @@ def DateRangeFilter(
|
|||||||
max_value: str = "",
|
max_value: str = "",
|
||||||
min_placeholder: str = "From",
|
min_placeholder: str = "From",
|
||||||
max_placeholder: str = "To",
|
max_placeholder: str = "To",
|
||||||
) -> Node:
|
) -> SafeText:
|
||||||
"""A pair of ``<input type="date">`` elements representing a date range.
|
"""A pair of ``<input type="date">`` elements representing a date range.
|
||||||
|
|
||||||
Mirrors ``RangeSlider`` in shape (two inputs named ``{prefix}-min`` and
|
Mirrors ``RangeSlider`` in shape (two inputs named ``{prefix}-min`` and
|
||||||
@@ -562,16 +553,14 @@ _FILTER_FORM_ID = "filter-bar-form"
|
|||||||
_FILTER_INPUT_ID = "filter-json-input"
|
_FILTER_INPUT_ID = "filter-json-input"
|
||||||
|
|
||||||
|
|
||||||
def _filter_collapse_button() -> Node:
|
def _filter_collapse_button() -> SafeText:
|
||||||
return Element(
|
return Component(
|
||||||
"button",
|
tag_name="button",
|
||||||
attributes=[
|
attributes=[
|
||||||
("type", "button"),
|
("type", "button"),
|
||||||
# Slider handles are positioned in percentages, so initializing
|
|
||||||
# them while the body is hidden is safe — no re-init on reveal.
|
|
||||||
(
|
(
|
||||||
"onclick",
|
"onclick",
|
||||||
"document.getElementById('filter-bar-body').classList.toggle('hidden')",
|
"var b=document.getElementById('filter-bar-body');b.classList.toggle('hidden');if(!b.classList.contains('hidden')&&window.initRangeSliders)window.initRangeSliders()",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"class",
|
"class",
|
||||||
@@ -580,7 +569,7 @@ def _filter_collapse_button() -> Node:
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
children=[
|
children=[
|
||||||
Safe(
|
mark_safe(
|
||||||
'<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75" /></svg>'
|
'<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75" /></svg>'
|
||||||
),
|
),
|
||||||
"Filters",
|
"Filters",
|
||||||
@@ -588,12 +577,12 @@ def _filter_collapse_button() -> Node:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _filter_action_row(preset_list_url: str, preset_save_url: str) -> Node:
|
def _filter_action_row(preset_list_url: str, preset_save_url: str) -> SafeText:
|
||||||
return Div(
|
return Div(
|
||||||
attributes=[("class", "flex gap-3 items-center")],
|
attributes=[("class", "flex gap-3 items-center")],
|
||||||
children=[
|
children=[
|
||||||
Element(
|
Component(
|
||||||
"button",
|
tag_name="button",
|
||||||
attributes=[
|
attributes=[
|
||||||
("type", "submit"),
|
("type", "submit"),
|
||||||
(
|
(
|
||||||
@@ -605,8 +594,8 @@ def _filter_action_row(preset_list_url: str, preset_save_url: str) -> Node:
|
|||||||
],
|
],
|
||||||
children=["Apply"],
|
children=["Apply"],
|
||||||
),
|
),
|
||||||
Element(
|
Component(
|
||||||
"button",
|
tag_name="button",
|
||||||
attributes=[
|
attributes=[
|
||||||
("type", "button"),
|
("type", "button"),
|
||||||
(
|
(
|
||||||
@@ -642,8 +631,8 @@ def _filter_action_row(preset_list_url: str, preset_save_url: str) -> Node:
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Element(
|
Component(
|
||||||
"button",
|
tag_name="button",
|
||||||
attributes=[
|
attributes=[
|
||||||
("type", "button"),
|
("type", "button"),
|
||||||
("id", "save-preset-btn"),
|
("id", "save-preset-btn"),
|
||||||
@@ -659,8 +648,8 @@ def _filter_action_row(preset_list_url: str, preset_save_url: str) -> Node:
|
|||||||
],
|
],
|
||||||
children=["Save Preset"],
|
children=["Save Preset"],
|
||||||
),
|
),
|
||||||
Element(
|
Component(
|
||||||
"button",
|
tag_name="button",
|
||||||
attributes=[
|
attributes=[
|
||||||
("type", "button"),
|
("type", "button"),
|
||||||
("id", "confirm-save-preset-btn"),
|
("id", "confirm-save-preset-btn"),
|
||||||
@@ -696,104 +685,64 @@ def _filter_action_row(preset_list_url: str, preset_save_url: str) -> Node:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class _FilterBarBase(BaseComponent):
|
def _filter_bar(fields, filter_json, preset_list_url, preset_save_url) -> SafeText:
|
||||||
"""Shared collapsible filter-bar chrome.
|
"""Shared collapsible filter-bar chrome. `fields` is the per-entity body
|
||||||
|
(grids, sliders, checkboxes); the shell adds the collapse toggle, the form,
|
||||||
Subclasses implement ``build_fields()`` returning the per-entity body
|
the hidden filter-json input and the Apply/Clear/preset action row."""
|
||||||
(grids, sliders, checkboxes); this base wraps it in the collapse toggle,
|
return Div(
|
||||||
the form, the hidden filter-json input and the Apply/Clear/preset action
|
attributes=[("id", "filter-bar"), ("class", "mb-6")],
|
||||||
row. ``filter_bar.js`` (declared as this component's ``media``) wires the
|
children=[
|
||||||
chrome; widget media (search_select.js, range_slider.js,
|
_filter_collapse_button(),
|
||||||
date_range_picker.js) bubbles up from the contained widgets via the node
|
Div(
|
||||||
tree, so the view never threads ``scripts=`` by hand.
|
attributes=[
|
||||||
"""
|
("id", "filter-bar-body"),
|
||||||
|
(
|
||||||
media = _FILTER_BAR_MEDIA
|
"class",
|
||||||
|
"hidden border border-default-medium rounded-base p-4 "
|
||||||
def __init__(
|
"bg-neutral-secondary-medium/50",
|
||||||
self,
|
),
|
||||||
filter_json: str = "",
|
],
|
||||||
preset_list_url: str = "",
|
children=[
|
||||||
preset_save_url: str = "",
|
Component(
|
||||||
) -> None:
|
tag_name="form",
|
||||||
self.filter_json = filter_json
|
attributes=[
|
||||||
self.preset_list_url = preset_list_url
|
("id", _FILTER_FORM_ID),
|
||||||
self.preset_save_url = preset_save_url
|
("onsubmit", "return applyFilterBar(event)"),
|
||||||
self.existing = _filter_parse(filter_json)
|
],
|
||||||
|
children=[
|
||||||
def build_fields(self) -> list:
|
Input(
|
||||||
"""Return the per-entity filter body. Implemented by each subclass."""
|
attributes=[
|
||||||
raise NotImplementedError
|
("type", "hidden"),
|
||||||
|
("id", _FILTER_INPUT_ID),
|
||||||
def render(self) -> Node:
|
("name", "filter"),
|
||||||
return Div(
|
# NB: Component escapes attribute values, so the
|
||||||
attributes=[("id", "filter-bar"), ("class", "mb-6")],
|
# raw JSON is passed through (no double-escape).
|
||||||
children=[
|
("value", filter_json),
|
||||||
_filter_collapse_button(),
|
],
|
||||||
Div(
|
),
|
||||||
attributes=[
|
*fields,
|
||||||
("id", "filter-bar-body"),
|
_filter_action_row(preset_list_url, preset_save_url),
|
||||||
(
|
],
|
||||||
"class",
|
),
|
||||||
"hidden border border-default-medium rounded-base p-4 "
|
],
|
||||||
"bg-neutral-secondary-medium/50",
|
),
|
||||||
),
|
],
|
||||||
],
|
)
|
||||||
children=[
|
|
||||||
Element(
|
|
||||||
"form",
|
|
||||||
attributes=[
|
|
||||||
("id", _FILTER_FORM_ID),
|
|
||||||
("onsubmit", "return applyFilterBar(event)"),
|
|
||||||
],
|
|
||||||
children=[
|
|
||||||
Input(
|
|
||||||
attributes=[
|
|
||||||
("type", "hidden"),
|
|
||||||
("id", _FILTER_INPUT_ID),
|
|
||||||
("name", "filter"),
|
|
||||||
# NB: attribute values are escaped, so the
|
|
||||||
# raw JSON passes through (no double-escape).
|
|
||||||
("value", self.filter_json),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
*self.build_fields(),
|
|
||||||
_filter_action_row(
|
|
||||||
self.preset_list_url, self.preset_save_url
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class FilterBar(_FilterBarBase):
|
def FilterBar(
|
||||||
|
filter_json: str = "",
|
||||||
|
status_options: list[LabeledOption] | None = None,
|
||||||
|
preset_list_url: str = "",
|
||||||
|
preset_save_url: str = "",
|
||||||
|
) -> SafeText:
|
||||||
"""Collapsible filter bar for the Game list."""
|
"""Collapsible filter bar for the Game list."""
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
filter_json: str = "",
|
|
||||||
status_options: list[LabeledOption] | None = None,
|
|
||||||
preset_list_url: str = "",
|
|
||||||
preset_save_url: str = "",
|
|
||||||
) -> None:
|
|
||||||
super().__init__(filter_json, preset_list_url, preset_save_url)
|
|
||||||
self.status_options = status_options
|
|
||||||
|
|
||||||
def build_fields(self) -> list:
|
|
||||||
return _game_fields(self.existing, self.status_options)
|
|
||||||
|
|
||||||
|
|
||||||
def _game_fields(
|
|
||||||
existing: dict, status_options: list[LabeledOption] | None = None
|
|
||||||
) -> list:
|
|
||||||
from games.models import Game, Purchase
|
from games.models import Game, Purchase
|
||||||
|
|
||||||
if status_options is None:
|
if status_options is None:
|
||||||
status_options = [(s.value, s.label) for s in Game.Status]
|
status_options = [(s.value, s.label) for s in Game.Status]
|
||||||
|
|
||||||
|
existing = _filter_parse(filter_json)
|
||||||
status_choice = _filter_get_choice(existing, "status")
|
status_choice = _filter_get_choice(existing, "status")
|
||||||
platform_choice = _filter_get_choice(existing, "platform")
|
platform_choice = _filter_get_choice(existing, "platform")
|
||||||
platform_group_choice = _filter_get_choice(existing, "platform_group")
|
platform_group_choice = _filter_get_choice(existing, "platform_group")
|
||||||
@@ -810,10 +759,10 @@ def _game_fields(
|
|||||||
existing, "original_year_released"
|
existing, "original_year_released"
|
||||||
)
|
)
|
||||||
mastered_value = _parse_bool_nullable(existing, "mastered")
|
mastered_value = _parse_bool_nullable(existing, "mastered")
|
||||||
playtime = existing.get("playtime_hours", {})
|
playtime = existing.get("playtime_minutes", {})
|
||||||
if isinstance(playtime, dict):
|
if isinstance(playtime, dict):
|
||||||
playtime_min = playtime.get("value", "")
|
playtime_min = _filter_mins_to_hrs(playtime.get("value", ""))
|
||||||
playtime_max = playtime.get("value2", "")
|
playtime_max = _filter_mins_to_hrs(playtime.get("value2", ""))
|
||||||
else:
|
else:
|
||||||
playtime_min = ""
|
playtime_min = ""
|
||||||
playtime_max = ""
|
playtime_max = ""
|
||||||
@@ -822,8 +771,8 @@ def _game_fields(
|
|||||||
session_avg_min, session_avg_max = _parse_range(existing, "session_average")
|
session_avg_min, session_avg_max = _parse_range(existing, "session_average")
|
||||||
purchase_count_min, purchase_count_max = _parse_range(existing, "purchase_count")
|
purchase_count_min, purchase_count_max = _parse_range(existing, "purchase_count")
|
||||||
playevent_count_min, playevent_count_max = _parse_range(existing, "playevent_count")
|
playevent_count_min, playevent_count_max = _parse_range(existing, "playevent_count")
|
||||||
manual_pt_min, manual_pt_max = _parse_range(existing, "manual_playtime_hours")
|
manual_pt_min, manual_pt_max = _parse_range(existing, "manual_playtime_minutes")
|
||||||
calc_pt_min, calc_pt_max = _parse_range(existing, "calculated_playtime_hours")
|
calc_pt_min, calc_pt_max = _parse_range(existing, "calculated_playtime_minutes")
|
||||||
price_total_min, price_total_max = _parse_range(existing, "purchase_price_total")
|
price_total_min, price_total_max = _parse_range(existing, "purchase_price_total")
|
||||||
price_any_min, price_any_max = _parse_range(existing, "purchase_price_any")
|
price_any_min, price_any_max = _parse_range(existing, "purchase_price_any")
|
||||||
purchase_refunded_value = _parse_bool_nullable(existing, "purchase_refunded")
|
purchase_refunded_value = _parse_bool_nullable(existing, "purchase_refunded")
|
||||||
@@ -967,7 +916,7 @@ def _game_fields(
|
|||||||
"Total playtime",
|
"Total playtime",
|
||||||
RangeSlider(
|
RangeSlider(
|
||||||
label="Total playtime",
|
label="Total playtime",
|
||||||
input_name_prefix="filter-playtime-hours",
|
input_name_prefix="filter-playtime",
|
||||||
min_value=playtime_min,
|
min_value=playtime_min,
|
||||||
max_value=playtime_max,
|
max_value=playtime_max,
|
||||||
range_min=0,
|
range_min=0,
|
||||||
@@ -978,31 +927,45 @@ def _game_fields(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
_filter_field(
|
_filter_field(
|
||||||
"Manual Playtime (hrs)",
|
"Manual Playtime (mins)",
|
||||||
RangeSlider(
|
RangeSlider(
|
||||||
label="Manual Playtime (hrs)",
|
label="Manual Playtime (mins)",
|
||||||
input_name_prefix="filter-manual-playtime-hours",
|
input_name_prefix="filter-manual-playtime-minutes",
|
||||||
min_value=manual_pt_min,
|
min_value=manual_pt_min,
|
||||||
max_value=manual_pt_max,
|
max_value=manual_pt_max,
|
||||||
range_min=0,
|
range_min=0,
|
||||||
range_max=max(playtime_range_max, 4),
|
range_max=max(playtime_range_max * 60, 240),
|
||||||
step="1",
|
step="1",
|
||||||
min_placeholder="e.g. 1",
|
min_placeholder="e.g. 10",
|
||||||
max_placeholder="e.g. 10",
|
max_placeholder="e.g. 120",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_filter_field(
|
_filter_field(
|
||||||
"Calculated Playtime (hrs)",
|
"Calculated Playtime (mins)",
|
||||||
RangeSlider(
|
RangeSlider(
|
||||||
label="Calculated Playtime (hrs)",
|
label="Calculated Playtime (mins)",
|
||||||
input_name_prefix="filter-calculated-playtime-hours",
|
input_name_prefix="filter-calculated-playtime-minutes",
|
||||||
min_value=calc_pt_min,
|
min_value=calc_pt_min,
|
||||||
max_value=calc_pt_max,
|
max_value=calc_pt_max,
|
||||||
range_min=0,
|
range_min=0,
|
||||||
range_max=max(playtime_range_max, 4),
|
range_max=max(playtime_range_max * 60, 240),
|
||||||
step="1",
|
step="1",
|
||||||
min_placeholder="e.g. 1",
|
min_placeholder="e.g. 30",
|
||||||
max_placeholder="e.g. 10",
|
max_placeholder="e.g. 120",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_filter_field(
|
||||||
|
"Calculated Playtime (mins)",
|
||||||
|
RangeSlider(
|
||||||
|
label="Calculated Playtime (mins)",
|
||||||
|
input_name_prefix="filter-calculated-playtime-minutes",
|
||||||
|
min_value=calc_pt_min,
|
||||||
|
max_value=calc_pt_max,
|
||||||
|
range_min=0,
|
||||||
|
range_max=max(playtime_range_max * 60, 240),
|
||||||
|
step="1",
|
||||||
|
min_placeholder="e.g. 30",
|
||||||
|
max_placeholder="e.g. 180",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_filter_field(
|
_filter_field(
|
||||||
@@ -1105,7 +1068,7 @@ def _game_fields(
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
return fields
|
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
|
||||||
|
|
||||||
|
|
||||||
def _find_label(options: list[LabeledOption], value: str) -> str:
|
def _find_label(options: list[LabeledOption], value: str) -> str:
|
||||||
@@ -1115,24 +1078,21 @@ def _find_label(options: list[LabeledOption], value: str) -> str:
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
class SessionFilterBar(_FilterBarBase):
|
def SessionFilterBar(
|
||||||
|
filter_json="", preset_list_url="", preset_save_url=""
|
||||||
|
) -> SafeText:
|
||||||
"""Collapsible filter bar for the Session list."""
|
"""Collapsible filter bar for the Session list."""
|
||||||
|
|
||||||
def build_fields(self) -> list:
|
|
||||||
return _session_fields(self.existing)
|
|
||||||
|
|
||||||
|
|
||||||
def _session_fields(existing: dict) -> list:
|
|
||||||
from games.models import Game, Session
|
from games.models import Game, Session
|
||||||
|
|
||||||
|
existing = _filter_parse(filter_json)
|
||||||
game_choice = _filter_get_choice(existing, "game")
|
game_choice = _filter_get_choice(existing, "game")
|
||||||
device_choice = _filter_get_choice(existing, "device")
|
device_choice = _filter_get_choice(existing, "device")
|
||||||
note_value = existing.get("note", {}).get("value", "")
|
note_value = existing.get("note", {}).get("value", "")
|
||||||
note_modifier = existing.get("note", {}).get("modifier", "EQUALS")
|
note_modifier = existing.get("note", {}).get("modifier", "EQUALS")
|
||||||
|
|
||||||
dur_tot_min, dur_tot_max = _parse_range(existing, "duration_total_hours")
|
dur_tot_min, dur_tot_max = _parse_range(existing, "duration_total_minutes")
|
||||||
dur_man_min, dur_man_max = _parse_range(existing, "duration_manual_hours")
|
dur_man_min, dur_man_max = _parse_range(existing, "duration_manual_minutes")
|
||||||
dur_calc_min, dur_calc_max = _parse_range(existing, "duration_calculated_hours")
|
dur_calc_min, dur_calc_max = _parse_range(existing, "duration_calculated_minutes")
|
||||||
emulated_value = _parse_bool_nullable(existing, "emulated")
|
emulated_value = _parse_bool_nullable(existing, "emulated")
|
||||||
is_active_value = _parse_bool_nullable(existing, "is_active")
|
is_active_value = _parse_bool_nullable(existing, "is_active")
|
||||||
try:
|
try:
|
||||||
@@ -1182,37 +1142,37 @@ def _session_fields(existing: dict) -> list:
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
RangeSlider(
|
RangeSlider(
|
||||||
label="Total Duration (hrs)",
|
label="Total Duration (mins)",
|
||||||
input_name_prefix="filter-duration-total-hours",
|
input_name_prefix="filter-duration-total-minutes",
|
||||||
min_value=dur_tot_min,
|
min_value=dur_tot_min,
|
||||||
max_value=dur_tot_max,
|
max_value=dur_tot_max,
|
||||||
range_min=0,
|
range_min=0,
|
||||||
range_max=duration_range_max,
|
range_max=duration_range_max * 60, # Range sliders use minutes now
|
||||||
step="1",
|
step="1",
|
||||||
min_placeholder="e.g. 1",
|
min_placeholder="e.g. 30",
|
||||||
max_placeholder="e.g. 10",
|
max_placeholder="e.g. 180",
|
||||||
),
|
),
|
||||||
RangeSlider(
|
RangeSlider(
|
||||||
label="Manual Duration (hrs)",
|
label="Manual Duration (mins)",
|
||||||
input_name_prefix="filter-duration-manual-hours",
|
input_name_prefix="filter-duration-manual-minutes",
|
||||||
min_value=dur_man_min,
|
min_value=dur_man_min,
|
||||||
max_value=dur_man_max,
|
max_value=dur_man_max,
|
||||||
range_min=0,
|
range_min=0,
|
||||||
range_max=duration_range_max,
|
range_max=240,
|
||||||
step="1",
|
step="1",
|
||||||
min_placeholder="e.g. 1",
|
min_placeholder="e.g. 10",
|
||||||
max_placeholder="e.g. 10",
|
max_placeholder="e.g. 120",
|
||||||
),
|
),
|
||||||
RangeSlider(
|
RangeSlider(
|
||||||
label="Calculated Duration (hrs)",
|
label="Calculated Duration (mins)",
|
||||||
input_name_prefix="filter-duration-calculated-hours",
|
input_name_prefix="filter-duration-calculated-minutes",
|
||||||
min_value=dur_calc_min,
|
min_value=dur_calc_min,
|
||||||
max_value=dur_calc_max,
|
max_value=dur_calc_max,
|
||||||
range_min=0,
|
range_min=0,
|
||||||
range_max=duration_range_max,
|
range_max=duration_range_max * 60,
|
||||||
step="1",
|
step="1",
|
||||||
min_placeholder="e.g. 1",
|
min_placeholder="e.g. 30",
|
||||||
max_placeholder="e.g. 10",
|
max_placeholder="e.g. 180",
|
||||||
),
|
),
|
||||||
Div(
|
Div(
|
||||||
attributes=[("class", "flex gap-6 mb-4")],
|
attributes=[("class", "flex gap-6 mb-4")],
|
||||||
@@ -1222,21 +1182,18 @@ def _session_fields(existing: dict) -> list:
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
return fields
|
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
|
||||||
|
|
||||||
|
|
||||||
class PurchaseFilterBar(_FilterBarBase):
|
def PurchaseFilterBar(
|
||||||
|
filter_json="", preset_list_url="", preset_save_url=""
|
||||||
|
) -> SafeText:
|
||||||
"""Collapsible filter bar for the Purchase list."""
|
"""Collapsible filter bar for the Purchase list."""
|
||||||
|
|
||||||
def build_fields(self) -> list:
|
|
||||||
return _purchase_fields(self.existing)
|
|
||||||
|
|
||||||
|
|
||||||
def _purchase_fields(existing: dict) -> list:
|
|
||||||
from games.models import Purchase
|
from games.models import Purchase
|
||||||
|
|
||||||
type_options = Purchase.TYPES
|
type_options = Purchase.TYPES
|
||||||
ownership_options = Purchase.OWNERSHIP_TYPES
|
ownership_options = Purchase.OWNERSHIP_TYPES
|
||||||
|
existing = _filter_parse(filter_json)
|
||||||
game_choice = _filter_get_choice(existing, "games")
|
game_choice = _filter_get_choice(existing, "games")
|
||||||
platform_choice = _filter_get_choice(existing, "platform")
|
platform_choice = _filter_get_choice(existing, "platform")
|
||||||
type_choice = _filter_get_choice(existing, "type")
|
type_choice = _filter_get_choice(existing, "type")
|
||||||
@@ -1346,7 +1303,7 @@ def _purchase_fields(existing: dict) -> list:
|
|||||||
),
|
),
|
||||||
_filter_field(
|
_filter_field(
|
||||||
"Purchased",
|
"Purchased",
|
||||||
DateRangePicker(
|
DateRangeFilter(
|
||||||
label="Purchased",
|
label="Purchased",
|
||||||
input_name_prefix="filter-date-purchased",
|
input_name_prefix="filter-date-purchased",
|
||||||
min_value=date_purchased_min,
|
min_value=date_purchased_min,
|
||||||
@@ -1408,19 +1365,14 @@ def _purchase_fields(existing: dict) -> list:
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
return fields
|
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
|
||||||
|
|
||||||
|
|
||||||
class DeviceFilterBar(_FilterBarBase):
|
def DeviceFilterBar(filter_json="", preset_list_url="", preset_save_url="") -> SafeText:
|
||||||
"""Collapsible filter bar for the Device list."""
|
"""Collapsible filter bar for the Device list."""
|
||||||
|
|
||||||
def build_fields(self) -> list:
|
|
||||||
return _device_fields(self.existing)
|
|
||||||
|
|
||||||
|
|
||||||
def _device_fields(existing: dict) -> list:
|
|
||||||
from games.models import Device
|
from games.models import Device
|
||||||
|
|
||||||
|
existing = _filter_parse(filter_json)
|
||||||
type_options = Device.DEVICE_TYPES
|
type_options = Device.DEVICE_TYPES
|
||||||
type_choice = _filter_get_choice(existing, "type")
|
type_choice = _filter_get_choice(existing, "type")
|
||||||
|
|
||||||
@@ -1440,17 +1392,15 @@ def _device_fields(existing: dict) -> list:
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
return fields
|
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
|
||||||
|
|
||||||
|
|
||||||
class PlatformFilterBar(_FilterBarBase):
|
def PlatformFilterBar(
|
||||||
|
filter_json="", preset_list_url="", preset_save_url=""
|
||||||
|
) -> SafeText:
|
||||||
"""Collapsible filter bar for the Platform list."""
|
"""Collapsible filter bar for the Platform list."""
|
||||||
|
existing = _filter_parse(filter_json)
|
||||||
|
|
||||||
def build_fields(self) -> list:
|
|
||||||
return _platform_fields(self.existing)
|
|
||||||
|
|
||||||
|
|
||||||
def _platform_fields(existing: dict) -> list:
|
|
||||||
name_value = existing.get("name", {}).get("value", "")
|
name_value = existing.get("name", {}).get("value", "")
|
||||||
name_modifier = existing.get("name", {}).get("modifier", "EQUALS")
|
name_modifier = existing.get("name", {}).get("modifier", "EQUALS")
|
||||||
group_value = existing.get("group", {}).get("value", "")
|
group_value = existing.get("group", {}).get("value", "")
|
||||||
@@ -1481,17 +1431,14 @@ def _platform_fields(existing: dict) -> list:
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
return fields
|
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
|
||||||
|
|
||||||
|
|
||||||
class PlayEventFilterBar(_FilterBarBase):
|
def PlayEventFilterBar(
|
||||||
|
filter_json="", preset_list_url="", preset_save_url=""
|
||||||
|
) -> SafeText:
|
||||||
"""Collapsible filter bar for the PlayEvent list."""
|
"""Collapsible filter bar for the PlayEvent list."""
|
||||||
|
existing = _filter_parse(filter_json)
|
||||||
def build_fields(self) -> list:
|
|
||||||
return _playevent_fields(self.existing)
|
|
||||||
|
|
||||||
|
|
||||||
def _playevent_fields(existing: dict) -> list:
|
|
||||||
game_choice = _filter_get_choice(existing, "game")
|
game_choice = _filter_get_choice(existing, "game")
|
||||||
days_min, days_max = _parse_range(existing, "days_to_finish")
|
days_min, days_max = _parse_range(existing, "days_to_finish")
|
||||||
|
|
||||||
@@ -1522,7 +1469,7 @@ def _playevent_fields(existing: dict) -> list:
|
|||||||
max_placeholder="e.g. 30",
|
max_placeholder="e.g. 30",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
return fields
|
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
|
||||||
|
|
||||||
|
|
||||||
def StringFilter(
|
def StringFilter(
|
||||||
@@ -1530,7 +1477,7 @@ def StringFilter(
|
|||||||
value: str = "",
|
value: str = "",
|
||||||
modifier: str = "EQUALS",
|
modifier: str = "EQUALS",
|
||||||
placeholder: str = "",
|
placeholder: str = "",
|
||||||
) -> Node:
|
) -> SafeText:
|
||||||
"""Renders a string filter with 8 modifier radio options and a text input."""
|
"""Renders a string filter with 8 modifier radio options and a text input."""
|
||||||
from common.criteria import Modifier
|
from common.criteria import Modifier
|
||||||
|
|
||||||
|
|||||||
+235
-214
@@ -1,32 +1,12 @@
|
|||||||
"""Generic HTML primitives (no domain knowledge).
|
"""Generic HTML primitives (no domain knowledge)."""
|
||||||
|
|
||||||
Generic leaf elements (``Div``, ``Span``, ``Td`` …) are *not* hand-written one
|
|
||||||
per tag: they are generated from a whitelist via :func:`_html_element`, each a
|
|
||||||
thin builder over the single :class:`Element` node class. Only elements that add
|
|
||||||
classes or behaviour (``Button``, ``Pill``, ``Checkbox`` …) are written out.
|
|
||||||
Everything returns a :class:`Node`; string-built widgets return :class:`Safe`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from django.middleware.csrf import get_token
|
from django.middleware.csrf import get_token
|
||||||
from django.templatetags.static import static
|
from django.templatetags.static import static
|
||||||
|
from django.urls import reverse
|
||||||
from django.utils.html import conditional_escape
|
from django.utils.html import conditional_escape
|
||||||
from django.utils.safestring import SafeText, mark_safe
|
from django.utils.safestring import SafeText, mark_safe
|
||||||
|
|
||||||
from common.components.core import (
|
from common.components.core import Component, HTMLAttribute, HTMLTag, randomid
|
||||||
Attributes,
|
|
||||||
Child,
|
|
||||||
Children,
|
|
||||||
Element,
|
|
||||||
Fragment,
|
|
||||||
HTMLAttribute,
|
|
||||||
Media,
|
|
||||||
Node,
|
|
||||||
Safe,
|
|
||||||
as_attributes,
|
|
||||||
as_children,
|
|
||||||
collect_media,
|
|
||||||
randomid,
|
|
||||||
)
|
|
||||||
from common.icons import get_icon
|
from common.icons import get_icon
|
||||||
from common.utils import truncate
|
from common.utils import truncate
|
||||||
|
|
||||||
@@ -47,79 +27,18 @@ _SIZE_CLASSES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# ── Generic leaf elements ────────────────────────────────────────────────────
|
|
||||||
# A whitelist of plain tags, each turned into a builder over `Element`. The
|
|
||||||
# tag name is data, not a separate class/function body. Add a tag = one line.
|
|
||||||
|
|
||||||
|
|
||||||
def _attrs_from_kwargs(attrs: dict[str, object]) -> list[HTMLAttribute]:
|
|
||||||
"""Translate htpy-style attribute kwargs to (name, value) pairs.
|
|
||||||
|
|
||||||
``class_`` -> ``class`` (trailing underscore stripped); ``hx_get`` ->
|
|
||||||
``hx-get`` (inner underscores to hyphens); ``True`` -> bare attribute;
|
|
||||||
``False`` / ``None`` -> omitted."""
|
|
||||||
result: list[HTMLAttribute] = []
|
|
||||||
for key, value in attrs.items():
|
|
||||||
if value is None or value is False:
|
|
||||||
continue
|
|
||||||
name = key.rstrip("_").replace("_", "-")
|
|
||||||
result.append((name, name if value is True else value)) # type: ignore[arg-type]
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def custom_element_builder(tag_name: str):
|
|
||||||
"""Create a tag builder for a custom element with auto-attached Media.
|
|
||||||
|
|
||||||
The module path follows the convention ``ts/elements/<tag>.ts`` →
|
|
||||||
``dist/elements/<tag>.js``.
|
|
||||||
"""
|
|
||||||
return _html_element(tag_name, Media(js=(f"dist/elements/{tag_name}.js",)))
|
|
||||||
|
|
||||||
|
|
||||||
def _html_element(tag_name: str, media: Media | None = None):
|
|
||||||
"""Build a generic element builder for ``tag_name`` (the whitelist factory).
|
|
||||||
|
|
||||||
If ``media`` is provided, every node created by the builder will carry it
|
|
||||||
(used for custom elements whose compiled JS must be loaded automatically).
|
|
||||||
"""
|
|
||||||
|
|
||||||
def element(
|
|
||||||
attributes: Attributes | None = None,
|
|
||||||
children: Children = None,
|
|
||||||
**attrs: object,
|
|
||||||
) -> Element:
|
|
||||||
merged = as_attributes(attributes) + _attrs_from_kwargs(attrs)
|
|
||||||
node = Element(tag_name, merged, children)
|
|
||||||
return node.with_media(media) if media else node
|
|
||||||
|
|
||||||
element.__name__ = element.__qualname__ = tag_name[:1].upper() + tag_name[1:]
|
|
||||||
element.__doc__ = f"Builder for the <{tag_name}> element."
|
|
||||||
return element
|
|
||||||
|
|
||||||
|
|
||||||
A = _html_element("a")
|
|
||||||
Button = _html_element("button")
|
|
||||||
Div = _html_element("div")
|
|
||||||
P = _html_element("p")
|
|
||||||
Ul = _html_element("ul")
|
|
||||||
Li = _html_element("li")
|
|
||||||
Strong = _html_element("strong")
|
|
||||||
Span = _html_element("span")
|
|
||||||
Label = _html_element("label")
|
|
||||||
Template = _html_element("template")
|
|
||||||
Td = _html_element("td")
|
|
||||||
Tr = _html_element("tr")
|
|
||||||
Th = _html_element("th")
|
|
||||||
|
|
||||||
|
|
||||||
def _popover_html(
|
def _popover_html(
|
||||||
id: str,
|
id: str,
|
||||||
popover_content: Child,
|
popover_content: str,
|
||||||
wrapped_content: str = "",
|
wrapped_content: str = "",
|
||||||
wrapped_classes: str = "",
|
wrapped_classes: str = "",
|
||||||
slot: "Node | str" = "",
|
slot: str = "",
|
||||||
) -> Node:
|
) -> SafeText:
|
||||||
"""Generate popover HTML. Single source of truth for popover structure."""
|
"""Generate popover HTML using Component(tag_name=...).
|
||||||
|
|
||||||
|
Single source of truth for popover HTML structure.
|
||||||
|
Used by Popover() and the python_popover template tag bridge.
|
||||||
|
"""
|
||||||
display_content = wrapped_content if wrapped_content else slot
|
display_content = wrapped_content if wrapped_content else slot
|
||||||
|
|
||||||
span = Span(
|
span = Span(
|
||||||
@@ -150,7 +69,7 @@ def _popover_html(
|
|||||||
children=[popover_content],
|
children=[popover_content],
|
||||||
),
|
),
|
||||||
Div(attributes=[("data-popper-arrow", "")]),
|
Div(attributes=[("data-popper-arrow", "")]),
|
||||||
Safe( # nosec — intentional HTML comment for Tailwind JIT
|
mark_safe( # nosec — intentional HTML comment for Tailwind JIT
|
||||||
"<!-- for Tailwind CSS to generate decoration-dotted CSS "
|
"<!-- for Tailwind CSS to generate decoration-dotted CSS "
|
||||||
"from Python component -->"
|
"from Python component -->"
|
||||||
),
|
),
|
||||||
@@ -160,24 +79,24 @@ def _popover_html(
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
return Fragment(span, div, separator="\n")
|
return mark_safe(span + "\n" + div)
|
||||||
|
|
||||||
|
|
||||||
def Popover(
|
def Popover(
|
||||||
popover_content: Child,
|
popover_content: str,
|
||||||
wrapped_content: str = "",
|
wrapped_content: str = "",
|
||||||
wrapped_classes: str = "",
|
wrapped_classes: str = "",
|
||||||
children: Children = None,
|
children: list[HTMLTag] | None = None,
|
||||||
attributes: Attributes | None = None,
|
attributes: list[HTMLAttribute] | None = None,
|
||||||
id: str = "",
|
id: str = "",
|
||||||
) -> Node:
|
) -> str:
|
||||||
children = as_children(children)
|
children = children or []
|
||||||
if not wrapped_content and not children:
|
if not wrapped_content and not children:
|
||||||
raise ValueError("One of wrapped_content or children is required.")
|
raise ValueError("One of wrapped_content or children is required.")
|
||||||
if not id:
|
if not id:
|
||||||
id = randomid(content=f"{wrapped_content}:{popover_content}:{wrapped_classes}")
|
id = randomid(content=f"{wrapped_content}:{popover_content}:{wrapped_classes}")
|
||||||
|
|
||||||
slot = Fragment(*children, separator="\n") if children else ""
|
slot = mark_safe("\n".join(children))
|
||||||
return _popover_html(
|
return _popover_html(
|
||||||
id=id,
|
id=id,
|
||||||
popover_content=popover_content,
|
popover_content=popover_content,
|
||||||
@@ -189,12 +108,12 @@ def Popover(
|
|||||||
|
|
||||||
def PopoverTruncated(
|
def PopoverTruncated(
|
||||||
input_string: str,
|
input_string: str,
|
||||||
popover_content: Child = "",
|
popover_content: str = "",
|
||||||
popover_if_not_truncated: bool = False,
|
popover_if_not_truncated: bool = False,
|
||||||
length: int = 30,
|
length: int = 30,
|
||||||
ellipsis: str = "…",
|
ellipsis: str = "…",
|
||||||
endpart: str = "",
|
endpart: str = "",
|
||||||
) -> "Node | str":
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Returns `input_string` truncated after `length` of characters
|
Returns `input_string` truncated after `length` of characters
|
||||||
and displays the untruncated text in a popover HTML element.
|
and displays the untruncated text in a popover HTML element.
|
||||||
@@ -219,9 +138,37 @@ def PopoverTruncated(
|
|||||||
return input_string
|
return input_string
|
||||||
|
|
||||||
|
|
||||||
def StyledButton(
|
def A(
|
||||||
attributes: Attributes | None = None,
|
attributes: list[HTMLAttribute] | None = None,
|
||||||
children: Children = None,
|
children: list[HTMLTag] | HTMLTag | None = None,
|
||||||
|
url_name: str | None = None,
|
||||||
|
href: str | None = None,
|
||||||
|
) -> SafeText:
|
||||||
|
"""
|
||||||
|
Returns an anchor <a> tag.
|
||||||
|
|
||||||
|
Accepts one of two mutually-exclusive URL specifications:
|
||||||
|
- url_name: URL pattern name, resolved via reverse()
|
||||||
|
- href: Literal path string passed through as-is
|
||||||
|
"""
|
||||||
|
attributes = attributes or []
|
||||||
|
children = children or []
|
||||||
|
if url_name is not None and href is not None:
|
||||||
|
raise ValueError("Provide exactly one of 'url_name' or 'href', not both.")
|
||||||
|
|
||||||
|
additional_attributes = []
|
||||||
|
if url_name is not None:
|
||||||
|
additional_attributes = [("href", reverse(url_name))]
|
||||||
|
elif href is not None:
|
||||||
|
additional_attributes = [("href", href)]
|
||||||
|
return Component(
|
||||||
|
tag_name="a", attributes=attributes + additional_attributes, children=children
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def Button(
|
||||||
|
attributes: list[HTMLAttribute] | None = None,
|
||||||
|
children: list[HTMLTag] | HTMLTag | None = None,
|
||||||
size: str = "base",
|
size: str = "base",
|
||||||
icon: bool = False,
|
icon: bool = False,
|
||||||
color: str = "blue",
|
color: str = "blue",
|
||||||
@@ -232,9 +179,8 @@ def StyledButton(
|
|||||||
title: str = "",
|
title: str = "",
|
||||||
onclick: str = "",
|
onclick: str = "",
|
||||||
name: str = "",
|
name: str = "",
|
||||||
**attrs: object,
|
) -> SafeText:
|
||||||
) -> Element:
|
attributes = attributes or []
|
||||||
attributes = as_attributes(attributes) + _attrs_from_kwargs(attrs)
|
|
||||||
children = children or []
|
children = children or []
|
||||||
|
|
||||||
# Separate custom class from other generic attributes
|
# Separate custom class from other generic attributes
|
||||||
@@ -278,8 +224,8 @@ def StyledButton(
|
|||||||
button_attrs.append(("name", name))
|
button_attrs.append(("name", name))
|
||||||
button_attrs.extend(other_attrs)
|
button_attrs.extend(other_attrs)
|
||||||
|
|
||||||
return Element(
|
return Component(
|
||||||
"button",
|
tag_name="button",
|
||||||
attributes=button_attrs,
|
attributes=button_attrs,
|
||||||
children=children,
|
children=children,
|
||||||
)
|
)
|
||||||
@@ -321,7 +267,7 @@ def _button_group_button(
|
|||||||
title: str = "",
|
title: str = "",
|
||||||
hx_get: str = "",
|
hx_get: str = "",
|
||||||
hx_target: str = "",
|
hx_target: str = "",
|
||||||
) -> Element:
|
) -> SafeText:
|
||||||
"""Generate a single button-group button (inner <button> inside <a>)."""
|
"""Generate a single button-group button (inner <button> inside <a>)."""
|
||||||
color_classes = _GROUP_BUTTON_COLORS.get(color, _GROUP_BUTTON_COLORS["gray"])
|
color_classes = _GROUP_BUTTON_COLORS.get(color, _GROUP_BUTTON_COLORS["gray"])
|
||||||
|
|
||||||
@@ -338,8 +284,8 @@ def _button_group_button(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
button = Element(
|
button = Component(
|
||||||
"button",
|
tag_name="button",
|
||||||
attributes=[
|
attributes=[
|
||||||
("type", "button"),
|
("type", "button"),
|
||||||
("title", title),
|
("title", title),
|
||||||
@@ -348,10 +294,10 @@ def _button_group_button(
|
|||||||
children=[slot],
|
children=[slot],
|
||||||
)
|
)
|
||||||
|
|
||||||
return Element("a", attributes=a_attrs, children=[button])
|
return Component(tag_name="a", attributes=a_attrs, children=[button])
|
||||||
|
|
||||||
|
|
||||||
def ButtonGroup(buttons: list[dict] | None = None) -> Element:
|
def ButtonGroup(buttons: list[dict] | None = None) -> SafeText:
|
||||||
"""Generate a button group div.
|
"""Generate a button group div.
|
||||||
|
|
||||||
Each button dict accepts: href, slot (required), color, title, hx_get, hx_target.
|
Each button dict accepts: href, slot (required), color, title, hx_get, hx_target.
|
||||||
@@ -359,7 +305,7 @@ def ButtonGroup(buttons: list[dict] | None = None) -> Element:
|
|||||||
for conditional buttons (e.g., end-session only when session is active).
|
for conditional buttons (e.g., end-session only when session is active).
|
||||||
"""
|
"""
|
||||||
buttons = buttons or []
|
buttons = buttons or []
|
||||||
children: list[Node] = []
|
children: list[SafeText] = []
|
||||||
for btn in buttons:
|
for btn in buttons:
|
||||||
if not btn or not btn.get("slot"):
|
if not btn or not btn.get("slot"):
|
||||||
continue
|
continue
|
||||||
@@ -380,14 +326,79 @@ def ButtonGroup(buttons: list[dict] | None = None) -> Element:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def Div(
|
||||||
|
attributes: list[HTMLAttribute] | None = None,
|
||||||
|
children: list[HTMLTag] | HTMLTag | None = None,
|
||||||
|
) -> SafeText:
|
||||||
|
attributes = attributes or []
|
||||||
|
children = children or []
|
||||||
|
return Component(tag_name="div", attributes=attributes, children=children)
|
||||||
|
|
||||||
|
|
||||||
|
def P(
|
||||||
|
attributes: list[HTMLAttribute] | None = None,
|
||||||
|
children: list[HTMLTag] | HTMLTag | None = None,
|
||||||
|
) -> SafeText:
|
||||||
|
attributes = attributes or []
|
||||||
|
children = children or []
|
||||||
|
return Component(tag_name="p", attributes=attributes, children=children)
|
||||||
|
|
||||||
|
|
||||||
|
def Ul(
|
||||||
|
attributes: list[HTMLAttribute] | None = None,
|
||||||
|
children: list[HTMLTag] | HTMLTag | None = None,
|
||||||
|
) -> SafeText:
|
||||||
|
attributes = attributes or []
|
||||||
|
children = children or []
|
||||||
|
return Component(tag_name="ul", attributes=attributes, children=children)
|
||||||
|
|
||||||
|
|
||||||
|
def Li(
|
||||||
|
attributes: list[HTMLAttribute] | None = None,
|
||||||
|
children: list[HTMLTag] | HTMLTag | None = None,
|
||||||
|
) -> SafeText:
|
||||||
|
attributes = attributes or []
|
||||||
|
children = children or []
|
||||||
|
return Component(tag_name="li", attributes=attributes, children=children)
|
||||||
|
|
||||||
|
|
||||||
|
def Strong(
|
||||||
|
attributes: list[HTMLAttribute] | None = None,
|
||||||
|
children: list[HTMLTag] | HTMLTag | None = None,
|
||||||
|
) -> SafeText:
|
||||||
|
attributes = attributes or []
|
||||||
|
children = children or []
|
||||||
|
return Component(tag_name="strong", attributes=attributes, children=children)
|
||||||
|
|
||||||
|
|
||||||
def Input(
|
def Input(
|
||||||
type: str = "text",
|
type: str = "text",
|
||||||
attributes: Attributes | None = None,
|
attributes: list[HTMLAttribute] | None = None,
|
||||||
children: Children = None,
|
children: list[HTMLTag] | HTMLTag | None = None,
|
||||||
) -> Element:
|
) -> SafeText:
|
||||||
attributes = as_attributes(attributes)
|
attributes = attributes or []
|
||||||
children = children or []
|
children = children or []
|
||||||
return Element("input", attributes=attributes + [("type", type)], children=children)
|
return Component(
|
||||||
|
tag_name="input", attributes=attributes + [("type", type)], children=children
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def Span(
|
||||||
|
attributes: list[HTMLAttribute] | None = None,
|
||||||
|
children: list[HTMLTag] | HTMLTag | None = None,
|
||||||
|
) -> SafeText:
|
||||||
|
attributes = attributes or []
|
||||||
|
children = children or []
|
||||||
|
return Component(tag_name="span", attributes=attributes, children=children)
|
||||||
|
|
||||||
|
|
||||||
|
def Label(
|
||||||
|
attributes: list[HTMLAttribute] | None = None,
|
||||||
|
children: list[HTMLTag] | HTMLTag | None = None,
|
||||||
|
) -> SafeText:
|
||||||
|
attributes = attributes or []
|
||||||
|
children = children or []
|
||||||
|
return Component(tag_name="label", attributes=attributes, children=children)
|
||||||
|
|
||||||
|
|
||||||
def Checkbox(
|
def Checkbox(
|
||||||
@@ -395,10 +406,10 @@ def Checkbox(
|
|||||||
label: str | None = None,
|
label: str | None = None,
|
||||||
checked: bool = False,
|
checked: bool = False,
|
||||||
value: str = "1",
|
value: str = "1",
|
||||||
attributes: Attributes | None = None,
|
attributes: list[HTMLAttribute] | None = None,
|
||||||
) -> Node:
|
) -> SafeText:
|
||||||
"""A filter-agnostic Checkbox component."""
|
"""A filter-agnostic Checkbox component."""
|
||||||
attributes = as_attributes(attributes)
|
attributes = attributes or []
|
||||||
input_attrs = [
|
input_attrs = [
|
||||||
("name", name),
|
("name", name),
|
||||||
("value", value),
|
("value", value),
|
||||||
@@ -427,10 +438,10 @@ def Radio(
|
|||||||
label: str | None = None,
|
label: str | None = None,
|
||||||
checked: bool = False,
|
checked: bool = False,
|
||||||
value: str = "",
|
value: str = "",
|
||||||
attributes: Attributes | None = None,
|
attributes: list[HTMLAttribute] | None = None,
|
||||||
) -> Node:
|
) -> SafeText:
|
||||||
"""A filter-agnostic Radio component."""
|
"""A filter-agnostic Radio component."""
|
||||||
attributes = as_attributes(attributes)
|
attributes = attributes or []
|
||||||
input_attrs = [
|
input_attrs = [
|
||||||
("name", name),
|
("name", name),
|
||||||
("value", value),
|
("value", value),
|
||||||
@@ -454,6 +465,16 @@ def Radio(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def Template(
|
||||||
|
attributes: list[HTMLAttribute] | None = None,
|
||||||
|
children: list[HTMLTag] | HTMLTag | None = None,
|
||||||
|
) -> SafeText:
|
||||||
|
"""An inert ``<template>`` whose contents are not rendered until cloned by JS."""
|
||||||
|
attributes = attributes or []
|
||||||
|
children = children or []
|
||||||
|
return Component(tag_name="template", attributes=attributes, children=children)
|
||||||
|
|
||||||
|
|
||||||
# Inline Tailwind utilities for Pill (mirrors the .sf-tag / .sf-remove rules in
|
# Inline Tailwind utilities for Pill (mirrors the .sf-tag / .sf-remove rules in
|
||||||
# input.css, written inline so styling stays encapsulated in the component). The
|
# input.css, written inline so styling stays encapsulated in the component). The
|
||||||
# JS that builds pills client-side (search_select.js) MUST emit these exact class
|
# JS that builds pills client-side (search_select.js) MUST emit these exact class
|
||||||
@@ -472,8 +493,8 @@ def Pill(
|
|||||||
removable: bool = False,
|
removable: bool = False,
|
||||||
extra_class: str = "",
|
extra_class: str = "",
|
||||||
label_slot: bool = False,
|
label_slot: bool = False,
|
||||||
attributes: Attributes | None = None,
|
attributes: list[HTMLAttribute] | None = None,
|
||||||
) -> Node:
|
) -> SafeText:
|
||||||
"""A small label pill, optionally removable (× button).
|
"""A small label pill, optionally removable (× button).
|
||||||
|
|
||||||
Styling is inline Tailwind utilities; ``data-pill`` / ``data-pill-remove``
|
Styling is inline Tailwind utilities; ``data-pill`` / ``data-pill-remove``
|
||||||
@@ -484,23 +505,23 @@ def Pill(
|
|||||||
fill it when cloning the pill from a server-rendered ``<template>`` (keeps the
|
fill it when cloning the pill from a server-rendered ``<template>`` (keeps the
|
||||||
markup single-sourced — see ``search_select.py``).
|
markup single-sourced — see ``search_select.py``).
|
||||||
"""
|
"""
|
||||||
attributes = as_attributes(attributes)
|
attributes = attributes or []
|
||||||
pill_class = f"{_PILL_CLASS} {extra_class}".strip()
|
pill_class = f"{_PILL_CLASS} {extra_class}".strip()
|
||||||
pill_attrs: list[HTMLAttribute] = [("class", pill_class), ("data-pill", "")]
|
pill_attrs: list[HTMLAttribute] = [("class", pill_class), ("data-pill", "")]
|
||||||
if value != "":
|
if value != "":
|
||||||
pill_attrs.append(("data-value", str(value)))
|
pill_attrs.append(("data-value", str(value)))
|
||||||
pill_attrs.extend(attributes)
|
pill_attrs.extend(attributes)
|
||||||
|
|
||||||
label_child: "Node | str" = (
|
label_child: HTMLTag = (
|
||||||
Span(attributes=[("data-search-select-label", "")], children=[label])
|
Span(attributes=[("data-search-select-label", "")], children=[label])
|
||||||
if label_slot
|
if label_slot
|
||||||
else label
|
else label
|
||||||
)
|
)
|
||||||
children: list["Node | str"] = [label_child]
|
children: list[HTMLTag] = [label_child]
|
||||||
if removable:
|
if removable:
|
||||||
children.append(
|
children.append(
|
||||||
Element(
|
Component(
|
||||||
"button",
|
tag_name="button",
|
||||||
attributes=[
|
attributes=[
|
||||||
("type", "button"),
|
("type", "button"),
|
||||||
("data-pill-remove", ""),
|
("data-pill-remove", ""),
|
||||||
@@ -514,12 +535,9 @@ def Pill(
|
|||||||
return Span(attributes=pill_attrs, children=children)
|
return Span(attributes=pill_attrs, children=children)
|
||||||
|
|
||||||
|
|
||||||
def CsrfInput(request) -> Node:
|
def CsrfInput(request) -> SafeText:
|
||||||
"""Hidden CSRF input, equivalent to the `{% csrf_token %}` template tag.
|
"""Hidden CSRF input, equivalent to the `{% csrf_token %}` template tag."""
|
||||||
|
return mark_safe(
|
||||||
Returns a ``Safe`` node (not a safe string): it is always used as a tree
|
|
||||||
child, and only nodes render unescaped now."""
|
|
||||||
return Safe(
|
|
||||||
f'<input type="hidden" name="csrfmiddlewaretoken" value="{get_token(request)}">'
|
f'<input type="hidden" name="csrfmiddlewaretoken" value="{get_token(request)}">'
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -536,22 +554,11 @@ def ExternalScript(url: str) -> SafeText:
|
|||||||
return mark_safe(f'<script src="{url}"></script>')
|
return mark_safe(f'<script src="{url}"></script>')
|
||||||
|
|
||||||
|
|
||||||
def StaticScript(filename: str) -> SafeText:
|
|
||||||
"""A plain (classic, non-module) `<script src=...>` tag for a static JS
|
|
||||||
file — for vendored UMD bundles, which break inside module scope."""
|
|
||||||
return mark_safe(f'<script src="{static("js/" + filename)}"></script>')
|
|
||||||
|
|
||||||
|
|
||||||
# Media for the Flowbite-datepicker year picker (vendored UMD bundle). Declared
|
|
||||||
# on the YearPicker node so Page() loads it wherever a YearPicker appears.
|
|
||||||
_YEAR_PICKER_MEDIA = Media(js_external=("datepicker.umd.js",))
|
|
||||||
|
|
||||||
|
|
||||||
def YearPicker(
|
def YearPicker(
|
||||||
year: int | None = None,
|
year: int | None = None,
|
||||||
available_years: tuple[int, ...] = (),
|
available_years: tuple[int, ...] = (),
|
||||||
url_template: str = "",
|
url_template: str = "",
|
||||||
) -> Node:
|
) -> SafeText:
|
||||||
"""A Flowbite-datepicker year picker.
|
"""A Flowbite-datepicker year picker.
|
||||||
|
|
||||||
`year` is the selected year, or ``None`` for the all-time view (the empty
|
`year` is the selected year, or ``None`` for the all-time view (the empty
|
||||||
@@ -560,8 +567,8 @@ def YearPicker(
|
|||||||
placeholder, substituted with the chosen year in JS (keeps this component
|
placeholder, substituted with the chosen year in JS (keeps this component
|
||||||
decoupled from the project's URL names).
|
decoupled from the project's URL names).
|
||||||
|
|
||||||
The Flowbite-datepicker UMD bundle is declared as ``media`` on the returned
|
The Flowbite-datepicker UMD bundle is *not* loaded here — the view hoists it
|
||||||
node, so ``Page()`` loads it automatically.
|
via ``render_page(scripts=...)``.
|
||||||
"""
|
"""
|
||||||
label = str(year) if year is not None else "Choose a year"
|
label = str(year) if year is not None else "Choose a year"
|
||||||
selected = str(year) if year is not None else ""
|
selected = str(year) if year is not None else ""
|
||||||
@@ -572,8 +579,7 @@ def YearPicker(
|
|||||||
"hover:bg-neutral-tertiary-medium focus:ring-4 focus:ring-brand-medium"
|
"hover:bg-neutral-tertiary-medium focus:ring-4 focus:ring-brand-medium"
|
||||||
)
|
)
|
||||||
years_csv = ",".join(str(y) for y in available_years)
|
years_csv = ",".join(str(y) for y in available_years)
|
||||||
return Safe(
|
return mark_safe(f"""<div class="relative inline-block" x-data="{{ pickerOpen: false }}"
|
||||||
f"""<div class="relative inline-block" x-data="{{ pickerOpen: false }}"
|
|
||||||
@keydown.escape.window="pickerOpen = false">
|
@keydown.escape.window="pickerOpen = false">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
x-on:click="pickerOpen = !pickerOpen; $refs.pickerInput._pickerInstance && ($refs.pickerInput._pickerInstance.active ? $refs.pickerInput._pickerInstance.hide() : $refs.pickerInput._pickerInstance.show())"
|
x-on:click="pickerOpen = !pickerOpen; $refs.pickerInput._pickerInstance && ($refs.pickerInput._pickerInstance.active ? $refs.pickerInput._pickerInstance.hide() : $refs.pickerInput._pickerInstance.show())"
|
||||||
@@ -626,19 +632,17 @@ document.addEventListener('DOMContentLoaded', () => {{
|
|||||||
picker.update();
|
picker.update();
|
||||||
}}
|
}}
|
||||||
}});
|
}});
|
||||||
</script>""",
|
</script>""")
|
||||||
media=_YEAR_PICKER_MEDIA,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def AddForm(
|
def AddForm(
|
||||||
form,
|
form,
|
||||||
*,
|
*,
|
||||||
request,
|
request,
|
||||||
fields: Node | SafeText | str | None = None,
|
fields: SafeText | str | None = None,
|
||||||
additional_row: Node | SafeText | str = "",
|
additional_row: SafeText | str = "",
|
||||||
submit_class: str = "mt-3",
|
submit_class: str = "mt-3",
|
||||||
) -> Node:
|
) -> SafeText:
|
||||||
"""Page body for the generic add/edit form (Python equivalent of add.html).
|
"""Page body for the generic add/edit form (Python equivalent of add.html).
|
||||||
|
|
||||||
`fields` overrides the default ``form.as_div()`` field markup (used by the
|
`fields` overrides the default ``form.as_div()`` field markup (used by the
|
||||||
@@ -647,16 +651,16 @@ def AddForm(
|
|||||||
is applied to the main Submit button (the session form passes "" to match
|
is applied to the main Submit button (the session form passes "" to match
|
||||||
its original markup).
|
its original markup).
|
||||||
"""
|
"""
|
||||||
field_markup = fields if fields is not None else Safe(form.as_div())
|
field_markup = fields if fields is not None else mark_safe(form.as_div())
|
||||||
submit_attrs = [("class", submit_class)] if submit_class else []
|
submit_attrs = [("class", submit_class)] if submit_class else []
|
||||||
|
|
||||||
inner_form = Element(
|
inner_form = Component(
|
||||||
"form",
|
tag_name="form",
|
||||||
attributes=[("method", "post"), ("enctype", "multipart/form-data")],
|
attributes=[("method", "post"), ("enctype", "multipart/form-data")],
|
||||||
children=[
|
children=[
|
||||||
CsrfInput(request),
|
CsrfInput(request),
|
||||||
field_markup,
|
field_markup,
|
||||||
Div(children=[StyledButton(submit_attrs, "Submit", type="submit")]),
|
Div(children=[Button(submit_attrs, "Submit", type="submit")]),
|
||||||
Div(
|
Div(
|
||||||
[("class", "submit-button-container")],
|
[("class", "submit-button-container")],
|
||||||
[additional_row] if additional_row else [],
|
[additional_row] if additional_row else [],
|
||||||
@@ -679,10 +683,10 @@ def SearchField(
|
|||||||
search_string: str = "",
|
search_string: str = "",
|
||||||
id: str = "search_string",
|
id: str = "search_string",
|
||||||
placeholder: str = "Search",
|
placeholder: str = "Search",
|
||||||
) -> Element:
|
) -> SafeText:
|
||||||
"""Generate a search form with icon, input field, and submit button."""
|
"""Generate a search form with icon, input field, and submit button."""
|
||||||
return Element(
|
return Component(
|
||||||
"form",
|
tag_name="form",
|
||||||
attributes=[("class", "max-w-md")],
|
attributes=[("class", "max-w-md")],
|
||||||
children=[
|
children=[
|
||||||
Label(
|
Label(
|
||||||
@@ -695,7 +699,7 @@ def SearchField(
|
|||||||
Div(
|
Div(
|
||||||
attributes=[("class", "relative")],
|
attributes=[("class", "relative")],
|
||||||
children=[
|
children=[
|
||||||
Safe(
|
mark_safe(
|
||||||
'<div class="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none">'
|
'<div class="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none">'
|
||||||
'<svg class="w-4 h-4 text-body" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" '
|
'<svg class="w-4 h-4 text-body" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" '
|
||||||
'fill="none" viewBox="0 0 24 24">'
|
'fill="none" viewBox="0 0 24 24">'
|
||||||
@@ -720,8 +724,8 @@ def SearchField(
|
|||||||
("required", ""),
|
("required", ""),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Element(
|
Component(
|
||||||
"button",
|
tag_name="button",
|
||||||
attributes=[
|
attributes=[
|
||||||
("type", "submit"),
|
("type", "submit"),
|
||||||
(
|
(
|
||||||
@@ -742,13 +746,13 @@ def SearchField(
|
|||||||
|
|
||||||
|
|
||||||
def H1(
|
def H1(
|
||||||
children: Children = None,
|
children: list[HTMLTag] | HTMLTag | None = None,
|
||||||
badge: str = "",
|
badge: str = "",
|
||||||
) -> Element:
|
) -> SafeText:
|
||||||
"""Heading with optional badge count."""
|
"""Heading with optional badge count."""
|
||||||
children = children or []
|
children = children or []
|
||||||
heading_class = "mb-4 text-3xl font-extrabold leading-none tracking-tight text-gray-900 dark:text-white"
|
heading_class = "mb-4 text-3xl font-extrabold leading-none tracking-tight text-gray-900 dark:text-white"
|
||||||
badge_html: Node | str = ""
|
badge_html = ""
|
||||||
|
|
||||||
if badge:
|
if badge:
|
||||||
heading_class = "flex items-center " + heading_class
|
heading_class = "flex items-center " + heading_class
|
||||||
@@ -763,20 +767,21 @@ def H1(
|
|||||||
children=[badge],
|
children=[badge],
|
||||||
)
|
)
|
||||||
|
|
||||||
return Element(
|
return Component(
|
||||||
"h1",
|
tag_name="h1",
|
||||||
attributes=[("class", heading_class)],
|
attributes=[("class", heading_class)],
|
||||||
children=as_children(children) + ([badge_html] if badge_html else []),
|
children=(children if isinstance(children, list) else [children])
|
||||||
|
+ ([badge_html] if badge_html else []),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def Modal(
|
def Modal(
|
||||||
modal_id: str,
|
modal_id: str,
|
||||||
children: Children = None,
|
children: list[HTMLTag] | HTMLTag | None = None,
|
||||||
) -> Node:
|
) -> SafeText:
|
||||||
"""Modal overlay with container. Content (form, buttons) goes in children."""
|
"""Modal overlay with container. Content (form, buttons) goes in children."""
|
||||||
children = children or []
|
children = children or []
|
||||||
return Div(
|
outer = Div(
|
||||||
attributes=[
|
attributes=[
|
||||||
("id", modal_id),
|
("id", modal_id),
|
||||||
(
|
(
|
||||||
@@ -794,24 +799,52 @@ def Modal(
|
|||||||
"shadow-lg/50 rounded-md bg-white dark:bg-gray-900",
|
"shadow-lg/50 rounded-md bg-white dark:bg-gray-900",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
children=as_children(children),
|
children=(children if isinstance(children, list) else [children]),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
return mark_safe(str(outer))
|
||||||
|
|
||||||
|
|
||||||
|
def Td(
|
||||||
|
attributes: list[HTMLAttribute] | None = None,
|
||||||
|
children: list[HTMLTag] | HTMLTag | None = None,
|
||||||
|
) -> SafeText:
|
||||||
|
attributes = attributes or []
|
||||||
|
children = children or []
|
||||||
|
return Component(tag_name="td", attributes=attributes, children=children)
|
||||||
|
|
||||||
|
|
||||||
|
def Tr(
|
||||||
|
attributes: list[HTMLAttribute] | None = None,
|
||||||
|
children: list[HTMLTag] | HTMLTag | None = None,
|
||||||
|
) -> SafeText:
|
||||||
|
attributes = attributes or []
|
||||||
|
children = children or []
|
||||||
|
return Component(tag_name="tr", attributes=attributes, children=children)
|
||||||
|
|
||||||
|
|
||||||
|
def Th(
|
||||||
|
attributes: list[HTMLAttribute] | None = None,
|
||||||
|
children: list[HTMLTag] | HTMLTag | None = None,
|
||||||
|
) -> SafeText:
|
||||||
|
attributes = attributes or []
|
||||||
|
children = children or []
|
||||||
|
return Component(tag_name="th", attributes=attributes, children=children)
|
||||||
|
|
||||||
|
|
||||||
def TableTd(
|
def TableTd(
|
||||||
children: Children = None,
|
children: list[HTMLTag] | HTMLTag | None = None,
|
||||||
) -> Element:
|
) -> SafeText:
|
||||||
"""Styled table cell."""
|
"""Styled table cell."""
|
||||||
children = children or []
|
children = children or []
|
||||||
return Td(
|
return Td(
|
||||||
attributes=[("class", "px-6 py-4 min-w-20-char max-w-20-char")],
|
attributes=[("class", "px-6 py-4 min-w-20-char max-w-20-char")],
|
||||||
children=as_children(children),
|
children=children if isinstance(children, list) else [children],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def TableRow(data: dict | list | None = None) -> Element:
|
def TableRow(data: dict | list | None = None) -> SafeText:
|
||||||
"""Generate a <tr> from a row data dict or list.
|
"""Generate a <tr> from a row data dict or list.
|
||||||
|
|
||||||
Dict form: {"row_id": "...", "cell_data": [...], "hx_trigger": ..., ...}
|
Dict form: {"row_id": "...", "cell_data": [...], "hx_trigger": ..., ...}
|
||||||
@@ -846,7 +879,7 @@ def TableRow(data: dict | list | None = None) -> Element:
|
|||||||
if data.get("hx_swap"):
|
if data.get("hx_swap"):
|
||||||
tr_attrs.append(("hx-swap", data["hx_swap"]))
|
tr_attrs.append(("hx-swap", data["hx_swap"]))
|
||||||
|
|
||||||
cell_elements: list[Node] = []
|
cell_elements: list[SafeText] = []
|
||||||
for i, cell in enumerate(cells):
|
for i, cell in enumerate(cells):
|
||||||
if i == 0:
|
if i == 0:
|
||||||
cell_elements.append(
|
cell_elements.append(
|
||||||
@@ -870,18 +903,18 @@ def TableRow(data: dict | list | None = None) -> Element:
|
|||||||
|
|
||||||
def Icon(
|
def Icon(
|
||||||
name: str,
|
name: str,
|
||||||
attributes: Attributes | None = None,
|
attributes: list[HTMLAttribute] | None = None,
|
||||||
) -> Node:
|
) -> SafeText:
|
||||||
return Safe(get_icon(name))
|
return mark_safe(get_icon(name))
|
||||||
|
|
||||||
|
|
||||||
def TableHeader(
|
def TableHeader(
|
||||||
children: Children = None,
|
children: list[HTMLTag] | HTMLTag | None = None,
|
||||||
) -> Element:
|
) -> SafeText:
|
||||||
"""Table caption."""
|
"""Table caption."""
|
||||||
children = children or []
|
children = children or []
|
||||||
return Element(
|
return Component(
|
||||||
"caption",
|
tag_name="caption",
|
||||||
attributes=[
|
attributes=[
|
||||||
(
|
(
|
||||||
"class",
|
"class",
|
||||||
@@ -889,7 +922,7 @@ def TableHeader(
|
|||||||
"text-gray-900 bg-white dark:text-white dark:bg-gray-900",
|
"text-gray-900 bg-white dark:text-white dark:bg-gray-900",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
children=as_children(children),
|
children=children if isinstance(children, list) else [children],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -968,41 +1001,30 @@ def _pagination_nav(page_obj, elided_page_range, request) -> str:
|
|||||||
def SimpleTable(
|
def SimpleTable(
|
||||||
columns: list[str] | None = None,
|
columns: list[str] | None = None,
|
||||||
rows: list | None = None,
|
rows: list | None = None,
|
||||||
header_action: Child | None = None,
|
header_action: SafeText | str | None = None,
|
||||||
page_obj=None,
|
page_obj=None,
|
||||||
elided_page_range=None,
|
elided_page_range=None,
|
||||||
request=None,
|
request=None,
|
||||||
) -> Node:
|
) -> SafeText:
|
||||||
"""Paginated table. Python equivalent of the old simple_table.html."""
|
"""Paginated table. Python equivalent of the old simple_table.html."""
|
||||||
columns = columns or []
|
columns = columns or []
|
||||||
rows = rows or []
|
rows = rows or []
|
||||||
|
|
||||||
# Rows/header are stringified into the table markup, so their components'
|
|
||||||
# declared Media would be lost; collect it from the nodes first and attach
|
|
||||||
# it to the returned node so Page() still emits each cell component's JS
|
|
||||||
# (e.g. a <game-status-selector> in a cell).
|
|
||||||
media = Media()
|
|
||||||
|
|
||||||
header_html = ""
|
header_html = ""
|
||||||
if header_action:
|
if header_action:
|
||||||
header_node = TableHeader(children=[header_action])
|
header_html = str(TableHeader(children=[header_action]))
|
||||||
header_html = str(header_node)
|
|
||||||
media = media + collect_media(header_node)
|
|
||||||
|
|
||||||
columns_html = "".join(
|
columns_html = "".join(
|
||||||
f'<th scope="col" class="px-6 py-3">{conditional_escape(col)}</th>'
|
f'<th scope="col" class="px-6 py-3">{conditional_escape(col)}</th>'
|
||||||
for col in columns
|
for col in columns
|
||||||
)
|
)
|
||||||
row_nodes = [TableRow(data=row) for row in rows]
|
rows_html = "".join(str(TableRow(data=row)) for row in rows)
|
||||||
rows_html = "".join(str(node) for node in row_nodes)
|
|
||||||
for node in row_nodes:
|
|
||||||
media = media + collect_media(node)
|
|
||||||
|
|
||||||
pagination_html = ""
|
pagination_html = ""
|
||||||
if page_obj and elided_page_range:
|
if page_obj and elided_page_range:
|
||||||
pagination_html = _pagination_nav(page_obj, elided_page_range, request)
|
pagination_html = _pagination_nav(page_obj, elided_page_range, request)
|
||||||
|
|
||||||
return Safe(
|
return mark_safe(
|
||||||
'<div class="shadow-md" hx-boost="false">'
|
'<div class="shadow-md" hx-boost="false">'
|
||||||
'<div class="relative overflow-x-auto sm:rounded-t-lg">'
|
'<div class="relative overflow-x-auto sm:rounded-t-lg">'
|
||||||
'<table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400">'
|
'<table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400">'
|
||||||
@@ -1012,8 +1034,7 @@ def SimpleTable(
|
|||||||
f"<tr>{columns_html}</tr></thead>"
|
f"<tr>{columns_html}</tr></thead>"
|
||||||
'<tbody class="dark:divide-y max-sm:[&_td:not(:first-child):not(:last-child)]:hidden">'
|
'<tbody class="dark:divide-y max-sm:[&_td:not(:first-child):not(:last-child)]:hidden">'
|
||||||
f"{rows_html}</tbody></table></div>"
|
f"{rows_html}</tbody></table></div>"
|
||||||
f"{pagination_html}</div>",
|
f"{pagination_html}</div>"
|
||||||
media=media,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1023,7 +1044,7 @@ def paginated_table_content(
|
|||||||
page_obj=None,
|
page_obj=None,
|
||||||
elided_page_range=None,
|
elided_page_range=None,
|
||||||
request=None,
|
request=None,
|
||||||
) -> Node:
|
) -> SafeText:
|
||||||
"""Standard list-page body: a max-width Div wrapping a SimpleTable.
|
"""Standard list-page body: a max-width Div wrapping a SimpleTable.
|
||||||
|
|
||||||
`data` is the table dict with keys ``columns``, ``rows`` and
|
`data` is the table dict with keys ``columns``, ``rows`` and
|
||||||
|
|||||||
@@ -21,13 +21,11 @@ user types.
|
|||||||
from collections.abc import Callable, Iterable
|
from collections.abc import Callable, Iterable
|
||||||
from typing import TypedDict
|
from typing import TypedDict
|
||||||
|
|
||||||
|
from django.utils.safestring import SafeText
|
||||||
|
|
||||||
from common.components.core import Attributes, Element, HTMLAttribute, Media, Node
|
from common.components.core import Component, HTMLAttribute
|
||||||
from common.components.primitives import Div, Input, Pill, Span, Template
|
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):
|
class SearchSelectOption(TypedDict):
|
||||||
value: str | int
|
value: str | int
|
||||||
@@ -143,11 +141,11 @@ def _data_attributes(data: dict[str, str]) -> list[HTMLAttribute]:
|
|||||||
return [(f"data-{key}", str(value)) for key, value in data.items()]
|
return [(f"data-{key}", str(value)) for key, value in data.items()]
|
||||||
|
|
||||||
|
|
||||||
def _hidden_input(name: str, value) -> Node:
|
def _hidden_input(name: str, value) -> SafeText:
|
||||||
return Input(type="hidden", attributes=[("name", name), ("value", str(value))])
|
return Input(type="hidden", attributes=[("name", name), ("value", str(value))])
|
||||||
|
|
||||||
|
|
||||||
def _label_slot(text: str, *, extra_class: str = "") -> Node:
|
def _label_slot(text: str, *, extra_class: str = "") -> SafeText:
|
||||||
"""A ``<span data-search-select-label>`` holding a row/pill's visible label. JS fills this
|
"""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
|
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."""
|
thing the JS sets — all classes and structure stay server-side."""
|
||||||
@@ -161,7 +159,7 @@ def _label_slot(text: str, *, extra_class: str = "") -> Node:
|
|||||||
_BLANK_OPTION: SearchSelectOption = {"value": "", "label": "", "data": {}}
|
_BLANK_OPTION: SearchSelectOption = {"value": "", "label": "", "data": {}}
|
||||||
|
|
||||||
|
|
||||||
def _option_row(option: SearchSelectOption) -> Node:
|
def _option_row(option: SearchSelectOption) -> SafeText:
|
||||||
return Div(
|
return Div(
|
||||||
attributes=[
|
attributes=[
|
||||||
("data-search-select-option", ""),
|
("data-search-select-option", ""),
|
||||||
@@ -176,14 +174,14 @@ def _option_row(option: SearchSelectOption) -> Node:
|
|||||||
|
|
||||||
def _combobox_shell(
|
def _combobox_shell(
|
||||||
*,
|
*,
|
||||||
container_attributes: Attributes,
|
container_attributes: list[HTMLAttribute],
|
||||||
pills: Node,
|
pills: SafeText,
|
||||||
search_attributes: Attributes,
|
search_attributes: list[HTMLAttribute],
|
||||||
options_children: list[Node],
|
options_children: list[SafeText],
|
||||||
always_visible: bool,
|
always_visible: bool,
|
||||||
items_visible: int,
|
items_visible: int,
|
||||||
templates: list[Node] | None = None,
|
templates: list[SafeText] | None = None,
|
||||||
) -> Node:
|
) -> SafeText:
|
||||||
"""Assemble the shared, domain-agnostic combobox skeleton.
|
"""Assemble the shared, domain-agnostic combobox skeleton.
|
||||||
|
|
||||||
Every combobox built on top of this shell has the same three regions in the
|
Every combobox built on top of this shell has the same three regions in the
|
||||||
@@ -215,7 +213,7 @@ def _combobox_shell(
|
|||||||
children=[*options_children, no_results],
|
children=[*options_children, no_results],
|
||||||
)
|
)
|
||||||
|
|
||||||
children: list[Node] = [pills, search, options_panel, *(templates or [])]
|
children: list[SafeText] = [pills, search, options_panel, *(templates or [])]
|
||||||
return Div(attributes=container_attributes, children=children)
|
return Div(attributes=container_attributes, children=children)
|
||||||
|
|
||||||
|
|
||||||
@@ -234,7 +232,7 @@ def SearchSelect(
|
|||||||
id: str = "",
|
id: str = "",
|
||||||
sync_url: bool = False,
|
sync_url: bool = False,
|
||||||
autofocus: bool = False,
|
autofocus: bool = False,
|
||||||
) -> Node:
|
) -> SafeText:
|
||||||
"""Render the search-select widget. See module docstring for the contract."""
|
"""Render the search-select widget. See module docstring for the contract."""
|
||||||
selected = [_normalize_option(option) for option in (selected or [])]
|
selected = [_normalize_option(option) for option in (selected or [])]
|
||||||
options = [_normalize_option(option) for option in (options or [])]
|
options = [_normalize_option(option) for option in (options or [])]
|
||||||
@@ -244,7 +242,7 @@ def SearchSelect(
|
|||||||
# pill — the committed label shows inside the search box instead, with a
|
# 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
|
# lone hidden input carrying the value. Both keep the hidden input(s) inside
|
||||||
# `[data-search-select-pills]` so the JS reads/writes values uniformly.
|
# `[data-search-select-pills]` so the JS reads/writes values uniformly.
|
||||||
pills_children: list[Node] = []
|
pills_children: list[SafeText] = []
|
||||||
search_value = ""
|
search_value = ""
|
||||||
if multi_select:
|
if multi_select:
|
||||||
for option in selected:
|
for option in selected:
|
||||||
@@ -285,7 +283,7 @@ def SearchSelect(
|
|||||||
|
|
||||||
# ── Templates the JS clones: a row when results are fetched, a pill when
|
# ── Templates the JS clones: a row when results are fetched, a pill when
|
||||||
# multi-select adds chosen items. ──
|
# multi-select adds chosen items. ──
|
||||||
templates: list[Node] = []
|
templates: list[SafeText] = []
|
||||||
if search_url:
|
if search_url:
|
||||||
templates.append(
|
templates.append(
|
||||||
Template(
|
Template(
|
||||||
@@ -324,12 +322,12 @@ def SearchSelect(
|
|||||||
always_visible=always_visible,
|
always_visible=always_visible,
|
||||||
items_visible=items_visible,
|
items_visible=items_visible,
|
||||||
templates=templates,
|
templates=templates,
|
||||||
).with_media(_SEARCH_SELECT_MEDIA)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _filter_remove_button() -> Node:
|
def _filter_remove_button() -> SafeText:
|
||||||
return Element(
|
return Component(
|
||||||
"button",
|
tag_name="button",
|
||||||
attributes=[
|
attributes=[
|
||||||
("type", "button"),
|
("type", "button"),
|
||||||
("data-pill-remove", ""),
|
("data-pill-remove", ""),
|
||||||
@@ -340,7 +338,7 @@ def _filter_remove_button() -> Node:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _filter_value_pill(option: SearchSelectOption, kind: str) -> Node:
|
def _filter_value_pill(option: SearchSelectOption, kind: str) -> SafeText:
|
||||||
"""An include (✓) or exclude (✗) value pill. ``kind`` is "include"/"exclude"."""
|
"""An include (✓) or exclude (✗) value pill. ``kind`` is "include"/"exclude"."""
|
||||||
symbol = "✓" if kind == "include" else "✗"
|
symbol = "✓" if kind == "include" else "✗"
|
||||||
css = (
|
css = (
|
||||||
@@ -359,7 +357,7 @@ def _filter_value_pill(option: SearchSelectOption, kind: str) -> Node:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _filter_modifier_pill(modifier_value: str, label: str) -> Node:
|
def _filter_modifier_pill(modifier_value: str, label: str) -> SafeText:
|
||||||
"""The lone, sticky modifier pill (e.g. "(Any)"/"(None)")."""
|
"""The lone, sticky modifier pill (e.g. "(Any)"/"(None)")."""
|
||||||
return Span(
|
return Span(
|
||||||
attributes=[
|
attributes=[
|
||||||
@@ -371,9 +369,9 @@ def _filter_modifier_pill(modifier_value: str, label: str) -> Node:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _filter_action_button(action: str, symbol: str, title: str) -> Node:
|
def _filter_action_button(action: str, symbol: str, title: str) -> SafeText:
|
||||||
return Element(
|
return Component(
|
||||||
"button",
|
tag_name="button",
|
||||||
attributes=[
|
attributes=[
|
||||||
("type", "button"),
|
("type", "button"),
|
||||||
("data-search-select-action", action),
|
("data-search-select-action", action),
|
||||||
@@ -384,7 +382,7 @@ def _filter_action_button(action: str, symbol: str, title: str) -> Node:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _filter_option_row(value: str | int, label: str) -> Node:
|
def _filter_option_row(value: str | int, label: str) -> SafeText:
|
||||||
"""A value row with include (+) and exclude (−) buttons."""
|
"""A value row with include (+) and exclude (−) buttons."""
|
||||||
return Div(
|
return Div(
|
||||||
attributes=[
|
attributes=[
|
||||||
@@ -406,7 +404,7 @@ def _filter_option_row(value: str | int, label: str) -> Node:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _filter_modifier_row(modifier_value: str, label: str) -> Node:
|
def _filter_modifier_row(modifier_value: str, label: str) -> SafeText:
|
||||||
"""A pinned pseudo-option row. It carries no ``data-search-select-option`` so the text
|
"""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."""
|
filter never hides it — modifiers stay visible at the top of the panel."""
|
||||||
return Div(
|
return Div(
|
||||||
@@ -434,7 +432,7 @@ def FilterSelect(
|
|||||||
placeholder: str = "Search…",
|
placeholder: str = "Search…",
|
||||||
id: str = "",
|
id: str = "",
|
||||||
free_text: bool = False,
|
free_text: bool = False,
|
||||||
) -> Node:
|
) -> SafeText:
|
||||||
"""Include/exclude filter combobox built on the shared ``_combobox_shell``.
|
"""Include/exclude filter combobox built on the shared ``_combobox_shell``.
|
||||||
|
|
||||||
Like ``SearchSelect`` but each value row carries +/− buttons that add an
|
Like ``SearchSelect`` but each value row carries +/− buttons that add an
|
||||||
@@ -472,7 +470,7 @@ def FilterSelect(
|
|||||||
# pills — but the stored state guarantees they never coexist, so we render
|
# pills — but the stored state guarantees they never coexist, so we render
|
||||||
# both channels unconditionally. Non-presence modifiers (INCLUDES_ALL /
|
# both channels unconditionally. Non-presence modifiers (INCLUDES_ALL /
|
||||||
# INCLUDES_ONLY) coexist with value pills and render side by side.
|
# INCLUDES_ONLY) coexist with value pills and render side by side.
|
||||||
pills_children: list[Node] = []
|
pills_children: list[SafeText] = []
|
||||||
if active_modifier_label:
|
if active_modifier_label:
|
||||||
pills_children.append(_filter_modifier_pill(modifier, active_modifier_label))
|
pills_children.append(_filter_modifier_pill(modifier, active_modifier_label))
|
||||||
for option in included:
|
for option in included:
|
||||||
@@ -506,7 +504,7 @@ def FilterSelect(
|
|||||||
|
|
||||||
# ── Templates the JS clones: include/exclude pills (added on click), the
|
# ── Templates the JS clones: include/exclude pills (added on click), the
|
||||||
# modifier pill (when modifiers exist), and a value row (when fetched). ──
|
# modifier pill (when modifiers exist), and a value row (when fetched). ──
|
||||||
templates: list[Node] = [
|
templates: list[SafeText] = [
|
||||||
Template(
|
Template(
|
||||||
attributes=[("data-search-select-template", "pill-include")],
|
attributes=[("data-search-select-template", "pill-include")],
|
||||||
children=[_filter_value_pill(_BLANK_OPTION, "include")],
|
children=[_filter_value_pill(_BLANK_OPTION, "include")],
|
||||||
@@ -559,7 +557,7 @@ def FilterSelect(
|
|||||||
always_visible=False,
|
always_visible=False,
|
||||||
items_visible=items_visible,
|
items_visible=items_visible,
|
||||||
templates=templates,
|
templates=templates,
|
||||||
).with_media(_SEARCH_SELECT_MEDIA)
|
)
|
||||||
|
|
||||||
|
|
||||||
def searchselect_selected(
|
def searchselect_selected(
|
||||||
|
|||||||
+13
-45
@@ -8,11 +8,9 @@ it hoists shared `<head>` content (the `_HEADERS` block, analogous to
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from django.contrib.messages import get_messages
|
from django.contrib.messages import get_messages
|
||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
from django.middleware.csrf import get_token
|
|
||||||
from django.templatetags.static import static
|
from django.templatetags.static import static
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.html import conditional_escape
|
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
|
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).
|
# Static head script that sets the dark/light class before paint (avoids FOUC).
|
||||||
_THEME_FOUC_SCRIPT = """<script>
|
_THEME_FOUC_SCRIPT = """<script>
|
||||||
if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||||
@@ -187,16 +182,10 @@ def _main_script(mastered: bool) -> str:
|
|||||||
return _MAIN_SCRIPT_A + ("true" if mastered else "false") + _MAIN_SCRIPT_B
|
return _MAIN_SCRIPT_A + ("true" if mastered else "false") + _MAIN_SCRIPT_B
|
||||||
|
|
||||||
|
|
||||||
def Navbar(*, today_played: str, last_7_played: str, current_year: int, csrf_token: str) -> "Node":
|
def Navbar(*, today_played: str, last_7_played: str, current_year: int) -> SafeText:
|
||||||
"""Top navigation bar.
|
"""Top navigation bar."""
|
||||||
|
|
||||||
Static chrome, so it's a single ``Safe`` node wrapping its markup rather
|
|
||||||
than a hand-built element tree — trusted HTML belongs in a ``Safe`` node,
|
|
||||||
not a ``mark_safe`` string."""
|
|
||||||
from common.components import Safe
|
|
||||||
|
|
||||||
logo = static("icons/schedule.png")
|
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">
|
<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">
|
class="flex items-center space-x-3 rtl:space-x-reverse">
|
||||||
@@ -271,10 +260,7 @@ def Navbar(*, today_played: str, last_7_played: str, current_year: int, csrf_tok
|
|||||||
<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>
|
||||||
<li>
|
<li>
|
||||||
<form method="post" action="{reverse("logout")}">
|
<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>
|
||||||
<input type="hidden" name="csrfmiddlewaretoken" value="{csrf_token}">
|
|
||||||
<button type="submit" class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Log out</button>
|
|
||||||
</form>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -283,37 +269,22 @@ def Navbar(*, today_played: str, last_7_played: str, current_year: int, csrf_tok
|
|||||||
|
|
||||||
|
|
||||||
def Page(
|
def Page(
|
||||||
content: "Node | SafeText | str",
|
content: SafeText | str,
|
||||||
*,
|
*,
|
||||||
request: HttpRequest,
|
request: HttpRequest,
|
||||||
title: str = "",
|
title: str = "",
|
||||||
scripts: "Node | SafeText | str" = "",
|
scripts: SafeText | str = "",
|
||||||
mastered: bool = False,
|
mastered: bool = False,
|
||||||
) -> SafeText:
|
) -> SafeText:
|
||||||
"""Assemble a full HTML document around `content` (the fast_app equivalent).
|
"""Assemble a full HTML document around `content` (the fast_app equivalent)."""
|
||||||
|
|
||||||
Scripts are collected from `content`'s component tree: every component
|
|
||||||
declares its JS via `Media`, and `collect_media` gathers (deduped) the union
|
|
||||||
for the whole page. The `scripts` argument remains for page-specific glue
|
|
||||||
that isn't owned by a reusable component (e.g. the add-form helpers).
|
|
||||||
"""
|
|
||||||
from common.components import ModuleScript, StaticScript, collect_media
|
|
||||||
from games.views.general import global_current_year, model_counts
|
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)
|
counts = model_counts(request)
|
||||||
year = global_current_year(request)["global_current_year"]
|
year = global_current_year(request)["global_current_year"]
|
||||||
navbar = Navbar(
|
navbar = Navbar(
|
||||||
today_played=counts["today_played"],
|
today_played=counts["today_played"],
|
||||||
last_7_played=counts["last_7_played"],
|
last_7_played=counts["last_7_played"],
|
||||||
current_year=year,
|
current_year=year,
|
||||||
csrf_token=get_token(request),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
messages = [
|
messages = [
|
||||||
@@ -338,12 +309,9 @@ def Page(
|
|||||||
f' <script src="{static("js/htmx-redirect-toast.js")}"></script>\n'
|
f' <script src="{static("js/htmx-redirect-toast.js")}"></script>\n'
|
||||||
f" {django_htmx_script(nonce=None)}\n"
|
f" {django_htmx_script(nonce=None)}\n"
|
||||||
f' <link rel="stylesheet" href="{static("base.css")}" />\n'
|
f' <link rel="stylesheet" href="{static("base.css")}" />\n'
|
||||||
# Vendored bundles (flowbite 2.4.1, alpinejs/@alpinejs/mask 3.15.12) —
|
' <script src="https://cdn.jsdelivr.net/npm/flowbite@2.4.1/dist/flowbite.min.js"></script>\n'
|
||||||
# served locally so pages work offline (and in browser tests). The mask
|
' <script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/mask@3.x.x/dist/cdn.min.js"></script>\n'
|
||||||
# plugin must load before Alpine core; both stay deferred.
|
' <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>\n'
|
||||||
f' <script src="{static("js/flowbite.min.js")}"></script>\n'
|
|
||||||
f' <script defer src="{static("js/alpine-mask.min.js")}"></script>\n'
|
|
||||||
f' <script defer src="{static("js/alpine.min.js")}"></script>\n'
|
|
||||||
f" {_THEME_FOUC_SCRIPT}\n"
|
f" {_THEME_FOUC_SCRIPT}\n"
|
||||||
" </head>\n"
|
" </head>\n"
|
||||||
)
|
)
|
||||||
@@ -357,7 +325,7 @@ def Page(
|
|||||||
f' <div id="main-container" class="flex flex-1 flex-col pt-8 pb-16">{content}</div>\n'
|
f' <div id="main-container" class="flex flex-1 flex-col pt-8 pb-16">{content}</div>\n'
|
||||||
f' <span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{version()} ({version_date()})</span>\n'
|
f' <span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{version()} ({version_date()})</span>\n'
|
||||||
" </div>\n"
|
" </div>\n"
|
||||||
f" {all_scripts}\n"
|
f" {scripts}\n"
|
||||||
f" {_main_script(mastered)}\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'
|
' <div id="global-modal-container" hx-swap-oob="true"></div>\n'
|
||||||
@@ -371,10 +339,10 @@ def Page(
|
|||||||
|
|
||||||
def render_page(
|
def render_page(
|
||||||
request: HttpRequest,
|
request: HttpRequest,
|
||||||
content: "Node | SafeText | str",
|
content: SafeText | str,
|
||||||
*,
|
*,
|
||||||
title: str = "",
|
title: str = "",
|
||||||
scripts: "Node | SafeText | str" = "",
|
scripts: SafeText | str = "",
|
||||||
mastered: bool = False,
|
mastered: bool = False,
|
||||||
status: int = 200,
|
status: int = 200,
|
||||||
) -> HttpResponse:
|
) -> HttpResponse:
|
||||||
|
|||||||
@@ -1,43 +1,17 @@
|
|||||||
import re
|
import re
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
from typing import NamedTuple
|
|
||||||
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from common.utils import generate_split_ranges
|
from common.utils import generate_split_ranges
|
||||||
|
|
||||||
dateformat: str = "%d/%m/%Y"
|
dateformat: str = "%d/%m/%Y"
|
||||||
dateformat_hyphenated: str = "%d-%m-%Y"
|
|
||||||
datetimeformat: str = "%d/%m/%Y %H:%M"
|
datetimeformat: str = "%d/%m/%Y %H:%M"
|
||||||
timeformat: str = "%H:%M"
|
timeformat: str = "%H:%M"
|
||||||
durationformat: str = "%2.1H hours"
|
durationformat: str = "%2.1H hours"
|
||||||
durationformat_manual: str = "%H 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):
|
def _safe_timedelta(duration: timedelta | int | None):
|
||||||
if duration is None:
|
if duration is None:
|
||||||
return timedelta(0)
|
return timedelta(0)
|
||||||
|
|||||||
@@ -7,11 +7,8 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: timetracker
|
container_name: timetracker
|
||||||
environment:
|
environment:
|
||||||
- DEBUG=false
|
|
||||||
- TZ=Europe/Prague
|
- TZ=Europe/Prague
|
||||||
# APP_URL drives ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS unless overridden.
|
- CSRF_TRUSTED_ORIGINS="https://tracker.kucharczyk.xyz"
|
||||||
# Behind your own reverse proxy you may also set ALLOWED_HOSTS=* directly.
|
|
||||||
- APP_URL=https://tracker.kucharczyk.xyz
|
|
||||||
user: "1000"
|
user: "1000"
|
||||||
# volumes:
|
# volumes:
|
||||||
# - "db:/home/timetracker/app/src/timetracker/db.sqlite3"
|
# - "db:/home/timetracker/app/src/timetracker/db.sqlite3"
|
||||||
|
|||||||
+2
-6
@@ -1,21 +1,17 @@
|
|||||||
---
|
---
|
||||||
services:
|
services:
|
||||||
timetracker:
|
timetracker:
|
||||||
image: ${REGISTRY_URL:-registry.kucharczyk.xyz}/timetracker:latest
|
image: ${REGISTRY_URL:-registry.kucharczyk.xyz}/timetracker:1.7.0
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: timetracker
|
container_name: timetracker
|
||||||
environment:
|
environment:
|
||||||
- DEBUG=${DEBUG:-false}
|
|
||||||
- SECRET_KEY=${SECRET_KEY}
|
|
||||||
- TZ=${TZ:-Europe/Prague}
|
- TZ=${TZ:-Europe/Prague}
|
||||||
# APP_URL drives ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS unless overridden.
|
- CSRF_TRUSTED_ORIGINS=https://tracker.kucharczyk.xyz
|
||||||
- APP_URL=${APP_URL:-http://localhost:8000}
|
|
||||||
- PUID=${PUID:-1000}
|
- PUID=${PUID:-1000}
|
||||||
- PGID=${PGID:-100}
|
- PGID=${PGID:-100}
|
||||||
- DATA_DIR=${DATA_DIR:-/home/timetracker/app/data}
|
- DATA_DIR=${DATA_DIR:-/home/timetracker/app/data}
|
||||||
- CREATE_DEFAULT_SUPERUSER=${CREATE_DEFAULT_SUPERUSER:-false}
|
|
||||||
ports:
|
ports:
|
||||||
- "${TIMETRACKER_EXTERNAL_PORT:-8000}:8000"
|
- "${TIMETRACKER_EXTERNAL_PORT:-8000}:8000"
|
||||||
volumes:
|
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")` |
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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.
|
|
||||||
@@ -7,7 +7,6 @@ import pytest
|
|||||||
# synchronous operations inside the async context safely.
|
# synchronous operations inside the async context safely.
|
||||||
os.environ.setdefault("DJANGO_ALLOW_ASYNC_UNSAFE", "true")
|
os.environ.setdefault("DJANGO_ALLOW_ASYNC_UNSAFE", "true")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def browser_type_launch_args(browser_type_launch_args):
|
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
|
# Try to find a system-installed Google Chrome or Chromium to bypass Nix/NixOS shared library issues
|
||||||
|
|||||||
@@ -21,10 +21,9 @@ def _bar_page(filter_json: str = "") -> str:
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>Boolean filter E2E</title>
|
<title>Boolean filter E2E</title>
|
||||||
<script src="/static/js/htmx.min.js"></script>
|
<script src="/static/js/range_slider.js" defer></script>
|
||||||
<script src="/static/js/range_slider.js" type="module"></script>
|
<script src="/static/js/search_select.js" defer></script>
|
||||||
<script src="/static/js/search_select.js" type="module"></script>
|
<script src="/static/js/filter_bar.js" defer></script>
|
||||||
<script src="/static/js/filter_bar.js" type="module"></script>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{FilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
|
{FilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
|
||||||
|
|||||||
@@ -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
|
|
||||||
+16
-19
@@ -5,10 +5,6 @@ cannot reach: ``filter_bar.js`` reading the two ``<input type="date">``
|
|||||||
elements, building a ``DateCriterion`` JSON object, and navigating the
|
elements, building a ``DateCriterion`` JSON object, and navigating the
|
||||||
browser to ``?filter=<encoded>``.
|
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
|
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.
|
against the real app — the bar's JS doesn't care what route serves it.
|
||||||
"""
|
"""
|
||||||
@@ -29,10 +25,9 @@ def _bar_page(filter_json: str = "") -> str:
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>Date filter E2E</title>
|
<title>Date filter E2E</title>
|
||||||
<script src="/static/js/htmx.min.js"></script>
|
<script src="/static/js/range_slider.js" defer></script>
|
||||||
<script src="/static/js/range_slider.js" type="module"></script>
|
<script src="/static/js/search_select.js" defer></script>
|
||||||
<script src="/static/js/search_select.js" type="module"></script>
|
<script src="/static/js/filter_bar.js" defer></script>
|
||||||
<script src="/static/js/filter_bar.js" type="module"></script>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{PurchaseFilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
|
{PurchaseFilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
|
||||||
@@ -47,7 +42,7 @@ def empty_bar_view(request):
|
|||||||
def prefilled_bar_view(request):
|
def prefilled_bar_view(request):
|
||||||
filter_json = json.dumps(
|
filter_json = json.dumps(
|
||||||
{
|
{
|
||||||
"date_refunded": {
|
"date_purchased": {
|
||||||
"value": "2024-03-15",
|
"value": "2024-03-15",
|
||||||
"value2": "2024-09-20",
|
"value2": "2024-09-20",
|
||||||
"modifier": "BETWEEN",
|
"modifier": "BETWEEN",
|
||||||
@@ -75,8 +70,8 @@ def _filter_from_url(url: str) -> dict:
|
|||||||
@override_settings(ROOT_URLCONF="e2e.test_date_filter_e2e")
|
@override_settings(ROOT_URLCONF="e2e.test_date_filter_e2e")
|
||||||
def test_both_dates_serializes_as_between(live_server, page):
|
def test_both_dates_serializes_as_between(live_server, page):
|
||||||
page.goto(live_server.url + "/test-date-filter/")
|
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-purchased-min"]').fill("2024-01-01")
|
||||||
page.locator('input[name="filter-date-refunded-max"]').fill("2024-12-31")
|
page.locator('input[name="filter-date-purchased-max"]').fill("2024-12-31")
|
||||||
with page.expect_navigation():
|
with page.expect_navigation():
|
||||||
page.evaluate(
|
page.evaluate(
|
||||||
"document.getElementById('filter-bar-form')"
|
"document.getElementById('filter-bar-form')"
|
||||||
@@ -84,7 +79,7 @@ def test_both_dates_serializes_as_between(live_server, page):
|
|||||||
)
|
)
|
||||||
parsed = _filter_from_url(page.url)
|
parsed = _filter_from_url(page.url)
|
||||||
assert parsed == {
|
assert parsed == {
|
||||||
"date_refunded": {
|
"date_purchased": {
|
||||||
"value": "2024-01-01",
|
"value": "2024-01-01",
|
||||||
"value2": "2024-12-31",
|
"value2": "2024-12-31",
|
||||||
"modifier": "BETWEEN",
|
"modifier": "BETWEEN",
|
||||||
@@ -96,7 +91,7 @@ def test_both_dates_serializes_as_between(live_server, page):
|
|||||||
@override_settings(ROOT_URLCONF="e2e.test_date_filter_e2e")
|
@override_settings(ROOT_URLCONF="e2e.test_date_filter_e2e")
|
||||||
def test_min_only_serializes_as_greater_than(live_server, page):
|
def test_min_only_serializes_as_greater_than(live_server, page):
|
||||||
page.goto(live_server.url + "/test-date-filter/")
|
page.goto(live_server.url + "/test-date-filter/")
|
||||||
page.locator('input[name="filter-date-refunded-min"]').fill("2024-06-15")
|
page.locator('input[name="filter-date-purchased-min"]').fill("2024-06-15")
|
||||||
with page.expect_navigation():
|
with page.expect_navigation():
|
||||||
page.evaluate(
|
page.evaluate(
|
||||||
"document.getElementById('filter-bar-form')"
|
"document.getElementById('filter-bar-form')"
|
||||||
@@ -104,10 +99,10 @@ def test_min_only_serializes_as_greater_than(live_server, page):
|
|||||||
)
|
)
|
||||||
parsed = _filter_from_url(page.url)
|
parsed = _filter_from_url(page.url)
|
||||||
assert parsed == {
|
assert parsed == {
|
||||||
"date_refunded": {"value": "2024-06-15", "modifier": "GREATER_THAN"}
|
"date_purchased": {"value": "2024-06-15", "modifier": "GREATER_THAN"}
|
||||||
}
|
}
|
||||||
# value2 must not be present when there's no upper bound.
|
# value2 must not be present when there's no upper bound.
|
||||||
assert "value2" not in parsed["date_refunded"]
|
assert "value2" not in parsed["date_purchased"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@@ -121,7 +116,9 @@ def test_max_only_serializes_as_less_than(live_server, page):
|
|||||||
".dispatchEvent(new Event('submit', {cancelable: true}))"
|
".dispatchEvent(new Event('submit', {cancelable: true}))"
|
||||||
)
|
)
|
||||||
parsed = _filter_from_url(page.url)
|
parsed = _filter_from_url(page.url)
|
||||||
assert parsed == {"date_refunded": {"value": "2025-06-30", "modifier": "LESS_THAN"}}
|
assert parsed == {
|
||||||
|
"date_refunded": {"value": "2025-06-30", "modifier": "LESS_THAN"}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@@ -147,11 +144,11 @@ def test_prefilled_bar_reflects_existing_filter_in_inputs(live_server, page):
|
|||||||
re-submits the same bounds unchanged."""
|
re-submits the same bounds unchanged."""
|
||||||
page.goto(live_server.url + "/test-date-filter-prefilled/")
|
page.goto(live_server.url + "/test-date-filter-prefilled/")
|
||||||
assert (
|
assert (
|
||||||
page.locator('input[name="filter-date-refunded-min"]').input_value()
|
page.locator('input[name="filter-date-purchased-min"]').input_value()
|
||||||
== "2024-03-15"
|
== "2024-03-15"
|
||||||
)
|
)
|
||||||
assert (
|
assert (
|
||||||
page.locator('input[name="filter-date-refunded-max"]').input_value()
|
page.locator('input[name="filter-date-purchased-max"]').input_value()
|
||||||
== "2024-09-20"
|
== "2024-09-20"
|
||||||
)
|
)
|
||||||
with page.expect_navigation():
|
with page.expect_navigation():
|
||||||
@@ -160,7 +157,7 @@ def test_prefilled_bar_reflects_existing_filter_in_inputs(live_server, page):
|
|||||||
".dispatchEvent(new Event('submit', {cancelable: true}))"
|
".dispatchEvent(new Event('submit', {cancelable: true}))"
|
||||||
)
|
)
|
||||||
parsed = _filter_from_url(page.url)
|
parsed = _filter_from_url(page.url)
|
||||||
assert parsed["date_refunded"] == {
|
assert parsed["date_purchased"] == {
|
||||||
"value": "2024-03-15",
|
"value": "2024-03-15",
|
||||||
"value2": "2024-09-20",
|
"value2": "2024-09-20",
|
||||||
"modifier": "BETWEEN",
|
"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,4 +1,8 @@
|
|||||||
"""End-to-end Playwright test for the RangeSlider JS synchronization, cross-over, and clamping behavior."""
|
"""End-to-end Playwright test for the RangeSlider JS synchronization, cross-over, and clamping behavior.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
@@ -13,10 +17,9 @@ def _bar_page(filter_json: str = "") -> str:
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>Range Slider E2E</title>
|
<title>Range Slider E2E</title>
|
||||||
<script src="/static/js/htmx.min.js"></script>
|
<script src="/static/js/range_slider.js" defer></script>
|
||||||
<script src="/static/js/range_slider.js" type="module"></script>
|
<script src="/static/js/search_select.js" defer></script>
|
||||||
<script src="/static/js/search_select.js" type="module"></script>
|
<script src="/static/js/filter_bar.js" defer></script>
|
||||||
<script src="/static/js/filter_bar.js" type="module"></script>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{FilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
|
{FilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
|
||||||
@@ -80,13 +83,13 @@ def test_range_slider_strict_bounds_clamping_on_blur(live_server, page):
|
|||||||
|
|
||||||
# 1. Type value higher than dataMax (100 is max, type "150")
|
# 1. Type value higher than dataMax (100 is max, type "150")
|
||||||
max_input.fill("150")
|
max_input.fill("150")
|
||||||
max_input.blur() # triggers "change" event
|
max_input.blur() # triggers "change" event
|
||||||
|
|
||||||
assert max_input.input_value() == "100"
|
assert max_input.input_value() == "100"
|
||||||
|
|
||||||
# 2. Type value lower than dataMin (0 is min, type "-20")
|
# 2. Type value lower than dataMin (0 is min, type "-20")
|
||||||
min_input.fill("-20")
|
min_input.fill("-20")
|
||||||
min_input.blur() # triggers "change" event
|
min_input.blur() # triggers "change" event
|
||||||
|
|
||||||
assert min_input.input_value() == "0"
|
assert min_input.input_value() == "0"
|
||||||
|
|
||||||
@@ -97,9 +100,7 @@ def test_range_slider_empty_max_thumb_does_not_jump_to_beginning(live_server, pa
|
|||||||
page.goto(live_server.url + "/test-range-slider/")
|
page.goto(live_server.url + "/test-range-slider/")
|
||||||
|
|
||||||
# Locate handles
|
# Locate handles
|
||||||
max_handle = page.locator(
|
max_handle = page.locator('.range-handle-max[data-target="filter-session-count-max"]')
|
||||||
'.range-handle-max[data-target="filter-session-count-max"]'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Initially, max_input is empty, so handle should sit at 100% (far right)
|
# Initially, max_input is empty, so handle should sit at 100% (far right)
|
||||||
style = max_handle.get_attribute("style")
|
style = max_handle.get_attribute("style")
|
||||||
|
|||||||
@@ -4,43 +4,35 @@ from django.http import HttpResponse
|
|||||||
from django.test import override_settings
|
from django.test import override_settings
|
||||||
from common.components import SearchSelect
|
from common.components import SearchSelect
|
||||||
|
|
||||||
|
|
||||||
def e2e_test_view(request):
|
def e2e_test_view(request):
|
||||||
html = f"""
|
html = f"""
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>SearchSelect E2E Test</title>
|
<title>SearchSelect E2E Test</title>
|
||||||
<!-- search_select.js is an ES module and initializes via onSwap(),
|
<script src="/static/js/search_select.js" defer></script>
|
||||||
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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div style="padding: 50px;">
|
<div style="padding: 50px;">
|
||||||
{
|
{SearchSelect(
|
||||||
SearchSelect(
|
name="games",
|
||||||
name="games",
|
selected=[{"value": "7", "label": "Game A", "data": {}}],
|
||||||
selected=[{"value": "7", "label": "Game A", "data": {}}],
|
options=[
|
||||||
options=[
|
{"value": "7", "label": "Game A", "data": {}},
|
||||||
{"value": "7", "label": "Game A", "data": {}},
|
{"value": "8", "label": "Game B", "data": {}},
|
||||||
{"value": "8", "label": "Game B", "data": {}},
|
],
|
||||||
],
|
multi_select=False
|
||||||
multi_select=False,
|
)}
|
||||||
)
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
"""
|
"""
|
||||||
return HttpResponse(html)
|
return HttpResponse(html)
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("test-search-select/", e2e_test_view),
|
path("test-search-select/", e2e_test_view),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@override_settings(ROOT_URLCONF="e2e.test_search_select_e2e")
|
@override_settings(ROOT_URLCONF="e2e.test_search_select_e2e")
|
||||||
def test_search_select_backspace_clears_single_select(live_server, page):
|
def test_search_select_backspace_clears_single_select(live_server, page):
|
||||||
|
|||||||
@@ -16,10 +16,9 @@ def _bar_page(filter_json: str = "") -> str:
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>String filter E2E</title>
|
<title>String filter E2E</title>
|
||||||
<script src="/static/js/htmx.min.js"></script>
|
<script src="/static/js/range_slider.js" defer></script>
|
||||||
<script src="/static/js/range_slider.js" type="module"></script>
|
<script src="/static/js/search_select.js" defer></script>
|
||||||
<script src="/static/js/search_select.js" type="module"></script>
|
<script src="/static/js/filter_bar.js" defer></script>
|
||||||
<script src="/static/js/filter_bar.js" type="module"></script>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{PlatformFilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
|
{PlatformFilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
|
||||||
@@ -38,7 +37,9 @@ def prefilled_bar_view(request):
|
|||||||
"value": "Switch",
|
"value": "Switch",
|
||||||
"modifier": "INCLUDES",
|
"modifier": "INCLUDES",
|
||||||
},
|
},
|
||||||
"group": {"modifier": "IS_NULL"},
|
"group": {
|
||||||
|
"modifier": "IS_NULL"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return HttpResponse(_bar_page(filter_json=filter_json))
|
return HttpResponse(_bar_page(filter_json=filter_json))
|
||||||
@@ -71,9 +72,7 @@ def test_string_filter_defaults_and_toggles(live_server, page):
|
|||||||
|
|
||||||
# 2. Enter values, click "includes" (INCLUDES), and submit
|
# 2. Enter values, click "includes" (INCLUDES), and submit
|
||||||
name_input.fill("PlayStation")
|
name_input.fill("PlayStation")
|
||||||
includes_radio = page.locator(
|
includes_radio = page.locator('input[name="filter-name-modifier"][value="INCLUDES"]')
|
||||||
'input[name="filter-name-modifier"][value="INCLUDES"]'
|
|
||||||
)
|
|
||||||
includes_radio.click()
|
includes_radio.click()
|
||||||
|
|
||||||
with page.expect_navigation():
|
with page.expect_navigation():
|
||||||
@@ -121,16 +120,12 @@ def test_string_filter_prefilled_states(live_server, page):
|
|||||||
# Verifies name matches "Switch" and "includes" is checked
|
# Verifies name matches "Switch" and "includes" is checked
|
||||||
assert name_input.input_value() == "Switch"
|
assert name_input.input_value() == "Switch"
|
||||||
assert name_input.is_enabled()
|
assert name_input.is_enabled()
|
||||||
assert page.locator(
|
assert page.locator('input[name="filter-name-modifier"][value="INCLUDES"]').is_checked()
|
||||||
'input[name="filter-name-modifier"][value="INCLUDES"]'
|
|
||||||
).is_checked()
|
|
||||||
|
|
||||||
# Verifies group is empty, disabled, and "is null" is checked
|
# Verifies group is empty, disabled, and "is null" is checked
|
||||||
assert group_input.input_value() == ""
|
assert group_input.input_value() == ""
|
||||||
assert not group_input.is_enabled()
|
assert not group_input.is_enabled()
|
||||||
assert page.locator(
|
assert page.locator('input[name="filter-group-modifier"][value="IS_NULL"]').is_checked()
|
||||||
'input[name="filter-group-modifier"][value="IS_NULL"]'
|
|
||||||
).is_checked()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
|
|||||||
@@ -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
|
#!/bin/bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# Container-bootstrap configuration. These variables are consumed only by this
|
|
||||||
# entrypoint, NOT by Django (see timetracker/config.py for the app settings):
|
|
||||||
# PUID/PGID — uid/gid the container process runs as
|
|
||||||
# DATA_DIR — writable dir for the SQLite database (kept in
|
|
||||||
# sync with Django via the same env var + default)
|
|
||||||
# CREATE_DEFAULT_SUPERUSER — create an admin/admin user on first start
|
|
||||||
# STAGING / LOAD_SAMPLE_DATA — staging-only data bootstrap (see below)
|
|
||||||
PUID=${PUID:-1000}
|
PUID=${PUID:-1000}
|
||||||
PGID=${PGID:-100}
|
PGID=${PGID:-100}
|
||||||
DATA_DIR=${DATA_DIR:-/home/timetracker/app/data}
|
|
||||||
|
|
||||||
USERHOME=$(grep timetracker /etc/passwd | cut -d ":" -f6)
|
USERHOME=$(grep timetracker /etc/passwd | cut -d ":" -f6)
|
||||||
usermod -d "/root" timetracker
|
usermod -d "/root" timetracker
|
||||||
@@ -18,45 +10,14 @@ groupmod -o -g "$PGID" timetracker
|
|||||||
usermod -o -u "$PUID" timetracker
|
usermod -o -u "$PUID" timetracker
|
||||||
usermod -d "${USERHOME}" timetracker
|
usermod -d "${USERHOME}" timetracker
|
||||||
|
|
||||||
mkdir -p "$DATA_DIR" /var/log/supervisor
|
mkdir -p /home/timetracker/app/data /var/log/supervisor
|
||||||
chmod 755 /home/timetracker/app
|
chmod 755 /home/timetracker/app
|
||||||
chmod 755 /home/timetracker/app/.venv
|
chmod 755 /home/timetracker/app/.venv
|
||||||
|
|
||||||
chown "$PUID:$PGID" "$DATA_DIR"
|
chown "$PUID:$PGID" /home/timetracker/app/data
|
||||||
chown "$PUID:$PGID" /var/log/supervisor
|
chown "$PUID:$PGID" /var/log/supervisor
|
||||||
|
|
||||||
python manage.py migrate
|
python manage.py migrate
|
||||||
python manage.py collectstatic --clear --no-input
|
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
|
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"
|
|
||||||
+40
-42
@@ -62,18 +62,18 @@ class GameFilter(OperatorFilter):
|
|||||||
platform_group: MultiCriterion | None = None # platform__group__in
|
platform_group: MultiCriterion | None = None # platform__group__in
|
||||||
status: ChoiceCriterion | None = None # selectable filter widget
|
status: ChoiceCriterion | None = None # selectable filter widget
|
||||||
mastered: BoolCriterion | None = None
|
mastered: BoolCriterion | None = None
|
||||||
playtime_hours: IntCriterion | None = None # converted to timedelta on to_q()
|
playtime_minutes: IntCriterion | None = None # converted to timedelta on to_q()
|
||||||
created_at: StringCriterion | None = None # date string
|
created_at: StringCriterion | None = None # date string
|
||||||
updated_at: StringCriterion | None = None # date string
|
updated_at: StringCriterion | None = None # date string
|
||||||
|
|
||||||
session_count: IntCriterion | None = None
|
session_count: IntCriterion | None = None
|
||||||
session_average: IntCriterion | None = None # average in hours
|
session_average: IntCriterion | None = None # average in minutes
|
||||||
purchase_count: IntCriterion | None = None # distinct purchases per game
|
purchase_count: IntCriterion | None = None # distinct purchases per game
|
||||||
playevent_count: IntCriterion | None = None # playevents per game
|
playevent_count: IntCriterion | None = None # playevents per game
|
||||||
|
|
||||||
# Aggregate session durations (hours), summed across the game's sessions
|
# Aggregate session durations (minutes), summed across the game's sessions
|
||||||
manual_playtime_hours: IntCriterion | None = None
|
manual_playtime_minutes: IntCriterion | None = None
|
||||||
calculated_playtime_hours: IntCriterion | None = None
|
calculated_playtime_minutes: IntCriterion | None = None
|
||||||
|
|
||||||
# Cross-entity: any session played on these devices / matching these flags
|
# Cross-entity: any session played on these devices / matching these flags
|
||||||
device: MultiCriterion | None = None # game has session on any of these devices
|
device: MultiCriterion | None = None # game has session on any of these devices
|
||||||
@@ -119,8 +119,8 @@ class GameFilter(OperatorFilter):
|
|||||||
q &= self.status.to_q("status")
|
q &= self.status.to_q("status")
|
||||||
if self.mastered is not None:
|
if self.mastered is not None:
|
||||||
q &= self.mastered.to_q("mastered")
|
q &= self.mastered.to_q("mastered")
|
||||||
if self.playtime_hours is not None:
|
if self.playtime_minutes is not None:
|
||||||
q &= self._playtime_to_q(self.playtime_hours)
|
q &= self._playtime_to_q(self.playtime_minutes)
|
||||||
if self.created_at is not None:
|
if self.created_at is not None:
|
||||||
q &= self.created_at.to_q("created_at")
|
q &= self.created_at.to_q("created_at")
|
||||||
if self.updated_at is not None:
|
if self.updated_at is not None:
|
||||||
@@ -177,7 +177,7 @@ class GameFilter(OperatorFilter):
|
|||||||
)
|
)
|
||||||
q &= Q(id__in=matching_ids)
|
q &= Q(id__in=matching_ids)
|
||||||
|
|
||||||
if self.manual_playtime_hours is not None:
|
if self.manual_playtime_minutes is not None:
|
||||||
from django.db.models import Sum
|
from django.db.models import Sum
|
||||||
|
|
||||||
from games.models import Game
|
from games.models import Game
|
||||||
@@ -186,14 +186,14 @@ class GameFilter(OperatorFilter):
|
|||||||
Game.objects.annotate(s_manual=Sum("sessions__duration_manual"))
|
Game.objects.annotate(s_manual=Sum("sessions__duration_manual"))
|
||||||
.filter(
|
.filter(
|
||||||
self._playtime_to_q_for_field(
|
self._playtime_to_q_for_field(
|
||||||
self.manual_playtime_hours, "s_manual"
|
self.manual_playtime_minutes, "s_manual"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.values_list("id", flat=True)
|
.values_list("id", flat=True)
|
||||||
)
|
)
|
||||||
q &= Q(id__in=matching_ids)
|
q &= Q(id__in=matching_ids)
|
||||||
|
|
||||||
if self.calculated_playtime_hours is not None:
|
if self.calculated_playtime_minutes is not None:
|
||||||
from django.db.models import Sum
|
from django.db.models import Sum
|
||||||
|
|
||||||
from games.models import Game
|
from games.models import Game
|
||||||
@@ -202,7 +202,7 @@ class GameFilter(OperatorFilter):
|
|||||||
Game.objects.annotate(s_calc=Sum("sessions__duration_calculated"))
|
Game.objects.annotate(s_calc=Sum("sessions__duration_calculated"))
|
||||||
.filter(
|
.filter(
|
||||||
self._playtime_to_q_for_field(
|
self._playtime_to_q_for_field(
|
||||||
self.calculated_playtime_hours, "s_calc"
|
self.calculated_playtime_minutes, "s_calc"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.values_list("id", flat=True)
|
.values_list("id", flat=True)
|
||||||
@@ -362,30 +362,30 @@ class GameFilter(OperatorFilter):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _playtime_to_q_for_field(c: IntCriterion, field: str) -> Q:
|
def _playtime_to_q_for_field(c: IntCriterion, field: str) -> Q:
|
||||||
"""Convert hours-based criterion to a DurationField Q object.
|
"""Convert minutes-based criterion to a DurationField Q object.
|
||||||
|
|
||||||
Django stores DurationField as microseconds in SQLite, so we convert
|
Django stores DurationField as microseconds in SQLite, so we convert
|
||||||
hours → timedelta(microseconds=X) and use the appropriate lookups.
|
minutes → timedelta(microseconds=X) and use the appropriate lookups.
|
||||||
"""
|
"""
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
from common.criteria import Modifier
|
from common.criteria import Modifier
|
||||||
|
|
||||||
m = c.modifier
|
m = c.modifier
|
||||||
td_val = timedelta(hours=c.value)
|
td_val = timedelta(minutes=c.value)
|
||||||
|
|
||||||
if m == Modifier.EQUALS:
|
if m == Modifier.EQUALS:
|
||||||
return Q(
|
return Q(
|
||||||
**{
|
**{
|
||||||
f"{field}__gte": td_val,
|
f"{field}__gte": td_val,
|
||||||
f"{field}__lt": timedelta(hours=c.value + 1),
|
f"{field}__lt": timedelta(minutes=c.value + 1),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if m == Modifier.NOT_EQUALS:
|
if m == Modifier.NOT_EQUALS:
|
||||||
return ~Q(
|
return ~Q(
|
||||||
**{
|
**{
|
||||||
f"{field}__gte": td_val,
|
f"{field}__gte": td_val,
|
||||||
f"{field}__lt": timedelta(hours=c.value + 1),
|
f"{field}__lt": timedelta(minutes=c.value + 1),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if m == Modifier.GREATER_THAN:
|
if m == Modifier.GREATER_THAN:
|
||||||
@@ -393,12 +393,12 @@ class GameFilter(OperatorFilter):
|
|||||||
if m == Modifier.LESS_THAN:
|
if m == Modifier.LESS_THAN:
|
||||||
return Q(**{f"{field}__lt": td_val})
|
return Q(**{f"{field}__lt": td_val})
|
||||||
if m == Modifier.BETWEEN and c.value2 is not None:
|
if m == Modifier.BETWEEN and c.value2 is not None:
|
||||||
lo = timedelta(hours=min(c.value, c.value2))
|
lo = timedelta(minutes=min(c.value, c.value2))
|
||||||
hi = timedelta(hours=max(c.value, c.value2))
|
hi = timedelta(minutes=max(c.value, c.value2))
|
||||||
return Q(**{f"{field}__gte": lo, f"{field}__lte": hi})
|
return Q(**{f"{field}__gte": lo, f"{field}__lte": hi})
|
||||||
if m == Modifier.NOT_BETWEEN and c.value2 is not None:
|
if m == Modifier.NOT_BETWEEN and c.value2 is not None:
|
||||||
lo = timedelta(hours=min(c.value, c.value2))
|
lo = timedelta(minutes=min(c.value, c.value2))
|
||||||
hi = timedelta(hours=max(c.value, c.value2))
|
hi = timedelta(minutes=max(c.value, c.value2))
|
||||||
return Q(**{f"{field}__lt": lo}) | Q(**{f"{field}__gt": hi})
|
return Q(**{f"{field}__lt": lo}) | Q(**{f"{field}__gt": hi})
|
||||||
if m == Modifier.IS_NULL:
|
if m == Modifier.IS_NULL:
|
||||||
return Q(**{f"{field}": timedelta(0)})
|
return Q(**{f"{field}": timedelta(0)})
|
||||||
@@ -412,9 +412,7 @@ class GameFilter(OperatorFilter):
|
|||||||
from games.models import PlayEvent
|
from games.models import PlayEvent
|
||||||
|
|
||||||
event_q = criterion.to_q("note")
|
event_q = criterion.to_q("note")
|
||||||
matching_ids = PlayEvent.objects.filter(event_q).values_list(
|
matching_ids = PlayEvent.objects.filter(event_q).values_list("game_id", flat=True)
|
||||||
"game_id", flat=True
|
|
||||||
)
|
|
||||||
return Q(id__in=matching_ids)
|
return Q(id__in=matching_ids)
|
||||||
|
|
||||||
|
|
||||||
@@ -433,10 +431,10 @@ class SessionFilter(OperatorFilter):
|
|||||||
device: MultiCriterion | None = None # filters on device_id
|
device: MultiCriterion | None = None # filters on device_id
|
||||||
emulated: BoolCriterion | None = None
|
emulated: BoolCriterion | None = None
|
||||||
note: StringCriterion | None = None
|
note: StringCriterion | None = None
|
||||||
duration_hours: IntCriterion | None = None # on duration_total (legacy alias)
|
duration_minutes: IntCriterion | None = None # on duration_total (legacy alias)
|
||||||
duration_total_hours: IntCriterion | None = None
|
duration_total_minutes: IntCriterion | None = None
|
||||||
duration_manual_hours: IntCriterion | None = None
|
duration_manual_minutes: IntCriterion | None = None
|
||||||
duration_calculated_hours: IntCriterion | None = None
|
duration_calculated_minutes: IntCriterion | None = None
|
||||||
is_active: BoolCriterion | None = None # timestamp_end IS NULL
|
is_active: BoolCriterion | None = None # timestamp_end IS NULL
|
||||||
timestamp_start: StringCriterion | None = None # date string
|
timestamp_start: StringCriterion | None = None # date string
|
||||||
timestamp_end: StringCriterion | None = None # date string
|
timestamp_end: StringCriterion | None = None # date string
|
||||||
@@ -456,20 +454,20 @@ class SessionFilter(OperatorFilter):
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
q = Q()
|
q = Q()
|
||||||
td_val = timedelta(hours=c.value)
|
td_val = timedelta(minutes=c.value)
|
||||||
m = c.modifier
|
m = c.modifier
|
||||||
if m == Modifier.EQUALS:
|
if m == Modifier.EQUALS:
|
||||||
q &= Q(
|
q &= Q(
|
||||||
**{
|
**{
|
||||||
f"{field}__gte": td_val,
|
f"{field}__gte": td_val,
|
||||||
f"{field}__lt": timedelta(hours=c.value + 1),
|
f"{field}__lt": timedelta(minutes=c.value + 1),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
elif m == Modifier.NOT_EQUALS:
|
elif m == Modifier.NOT_EQUALS:
|
||||||
q &= ~Q(
|
q &= ~Q(
|
||||||
**{
|
**{
|
||||||
f"{field}__gte": td_val,
|
f"{field}__gte": td_val,
|
||||||
f"{field}__lt": timedelta(hours=c.value + 1),
|
f"{field}__lt": timedelta(minutes=c.value + 1),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
elif m == Modifier.GREATER_THAN:
|
elif m == Modifier.GREATER_THAN:
|
||||||
@@ -477,12 +475,12 @@ class SessionFilter(OperatorFilter):
|
|||||||
elif m == Modifier.LESS_THAN:
|
elif m == Modifier.LESS_THAN:
|
||||||
q &= Q(**{f"{field}__lt": td_val})
|
q &= Q(**{f"{field}__lt": td_val})
|
||||||
elif m == Modifier.BETWEEN and c.value2 is not None:
|
elif m == Modifier.BETWEEN and c.value2 is not None:
|
||||||
lo = timedelta(hours=min(c.value, c.value2))
|
lo = timedelta(minutes=min(c.value, c.value2))
|
||||||
hi = timedelta(hours=max(c.value, c.value2))
|
hi = timedelta(minutes=max(c.value, c.value2))
|
||||||
q &= Q(**{f"{field}__gte": lo, f"{field}__lte": hi})
|
q &= Q(**{f"{field}__gte": lo, f"{field}__lte": hi})
|
||||||
elif m == Modifier.NOT_BETWEEN and c.value2 is not None:
|
elif m == Modifier.NOT_BETWEEN and c.value2 is not None:
|
||||||
lo = timedelta(hours=min(c.value, c.value2))
|
lo = timedelta(minutes=min(c.value, c.value2))
|
||||||
hi = timedelta(hours=max(c.value, c.value2))
|
hi = timedelta(minutes=max(c.value, c.value2))
|
||||||
q &= Q(**{f"{field}__lt": lo}) | Q(**{f"{field}__gt": hi})
|
q &= Q(**{f"{field}__lt": lo}) | Q(**{f"{field}__gt": hi})
|
||||||
elif m == Modifier.IS_NULL:
|
elif m == Modifier.IS_NULL:
|
||||||
q &= Q(**{f"{field}": timedelta(0)})
|
q &= Q(**{f"{field}": timedelta(0)})
|
||||||
@@ -503,15 +501,15 @@ class SessionFilter(OperatorFilter):
|
|||||||
q &= self.emulated.to_q("emulated")
|
q &= self.emulated.to_q("emulated")
|
||||||
if self.note is not None:
|
if self.note is not None:
|
||||||
q &= self.note.to_q("note")
|
q &= self.note.to_q("note")
|
||||||
if self.duration_hours is not None:
|
if self.duration_minutes is not None:
|
||||||
q &= self._duration_to_q(self.duration_hours, "duration_total")
|
q &= self._duration_to_q(self.duration_minutes, "duration_total")
|
||||||
if self.duration_total_hours is not None:
|
if self.duration_total_minutes is not None:
|
||||||
q &= self._duration_to_q(self.duration_total_hours, "duration_total")
|
q &= self._duration_to_q(self.duration_total_minutes, "duration_total")
|
||||||
if self.duration_manual_hours is not None:
|
if self.duration_manual_minutes is not None:
|
||||||
q &= self._duration_to_q(self.duration_manual_hours, "duration_manual")
|
q &= self._duration_to_q(self.duration_manual_minutes, "duration_manual")
|
||||||
if self.duration_calculated_hours is not None:
|
if self.duration_calculated_minutes is not None:
|
||||||
q &= self._duration_to_q(
|
q &= self._duration_to_q(
|
||||||
self.duration_calculated_hours, "duration_calculated"
|
self.duration_calculated_minutes, "duration_calculated"
|
||||||
)
|
)
|
||||||
if self.is_active is not None:
|
if self.is_active is not None:
|
||||||
if self.is_active.value:
|
if self.is_active.value:
|
||||||
|
|||||||
+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
|
- model: games.game
|
||||||
pk: 1
|
pk: 1
|
||||||
fields:
|
fields:
|
||||||
name: Nioh 2
|
name: Nioh 2
|
||||||
wikidata: Q67482292
|
wikidata: Q67482292
|
||||||
created_at: "2021-02-13T00:00:00Z"
|
|
||||||
updated_at: "2021-02-13T00:00:00Z"
|
|
||||||
- model: games.game
|
- model: games.game
|
||||||
pk: 2
|
pk: 2
|
||||||
fields:
|
fields:
|
||||||
name: Elden Ring
|
name: Elden Ring
|
||||||
wikidata: Q64826862
|
wikidata: Q64826862
|
||||||
created_at: "2022-02-24T00:00:00Z"
|
|
||||||
updated_at: "2022-02-24T00:00:00Z"
|
|
||||||
- model: games.game
|
- model: games.game
|
||||||
pk: 3
|
pk: 3
|
||||||
fields:
|
fields:
|
||||||
name: Cyberpunk 2077
|
name: Cyberpunk 2077
|
||||||
wikidata: Q3182559
|
wikidata: Q3182559
|
||||||
created_at: "2020-12-07T00:00:00Z"
|
|
||||||
updated_at: "2020-12-07T00:00:00Z"
|
|
||||||
- model: games.purchase
|
- model: games.purchase
|
||||||
pk: 1
|
pk: 1
|
||||||
fields:
|
fields:
|
||||||
games: [1]
|
game: 1
|
||||||
platform: 1
|
platform: 1
|
||||||
date_purchased: 2021-02-13
|
date_purchased: 2021-02-13
|
||||||
date_refunded: null
|
date_refunded: null
|
||||||
created_at: "2021-02-13T00:00:00Z"
|
|
||||||
updated_at: "2021-02-13T00:00:00Z"
|
|
||||||
- model: games.purchase
|
- model: games.purchase
|
||||||
pk: 2
|
pk: 2
|
||||||
fields:
|
fields:
|
||||||
games: [2]
|
game: 2
|
||||||
platform: 1
|
platform: 1
|
||||||
date_purchased: 2022-02-24
|
date_purchased: 2022-02-24
|
||||||
date_refunded: null
|
date_refunded: null
|
||||||
created_at: "2022-02-24T00:00:00Z"
|
|
||||||
updated_at: "2022-02-24T00:00:00Z"
|
|
||||||
- model: games.purchase
|
- model: games.purchase
|
||||||
pk: 3
|
pk: 3
|
||||||
fields:
|
fields:
|
||||||
games: [3]
|
game: 3
|
||||||
platform: 1
|
platform: 1
|
||||||
date_purchased: 2020-12-07
|
date_purchased: 2020-12-07
|
||||||
date_refunded: null
|
date_refunded: null
|
||||||
created_at: "2020-12-07T00:00:00Z"
|
- model: games.platform
|
||||||
updated_at: "2020-12-07T00:00:00Z"
|
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
|
||||||
|
|||||||
+54
-44
@@ -1,11 +1,11 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
from django.db.models import OuterRef, Subquery
|
||||||
|
|
||||||
from common.components import (
|
from common.components import (
|
||||||
DEFAULT_PREFETCH,
|
DEFAULT_PREFETCH,
|
||||||
SearchSelect,
|
SearchSelect,
|
||||||
SearchSelectOption,
|
SearchSelectOption,
|
||||||
render,
|
|
||||||
searchselect_selected,
|
searchselect_selected,
|
||||||
)
|
)
|
||||||
from common.components.primitives import Checkbox
|
from common.components.primitives import Checkbox
|
||||||
@@ -28,32 +28,23 @@ autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"})
|
|||||||
|
|
||||||
class PrimitiveCheckboxWidget(forms.CheckboxInput):
|
class PrimitiveCheckboxWidget(forms.CheckboxInput):
|
||||||
"""Adapts Django's CheckboxInput to use our Checkbox component."""
|
"""Adapts Django's CheckboxInput to use our Checkbox component."""
|
||||||
|
|
||||||
def render(self, name, value, attrs=None, renderer=None):
|
def render(self, name, value, attrs=None, renderer=None):
|
||||||
final_attrs = self.build_attrs(self.attrs, attrs)
|
final_attrs = self.build_attrs(self.attrs, attrs)
|
||||||
checked = self.check_test(value)
|
checked = self.check_test(value)
|
||||||
attributes = [
|
attributes = [(k, str(v)) for k, v in final_attrs.items() if k not in ("type", "name", "value", "checked")]
|
||||||
(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
|
# Django uses boolean values differently for checkboxes, we omit value if empty
|
||||||
# render() returns a safe string (Django widgets must not be autoescaped).
|
return str(Checkbox(
|
||||||
return render(
|
name=name,
|
||||||
Checkbox(
|
label=None,
|
||||||
name=name,
|
checked=checked,
|
||||||
label=None,
|
value=str(value) if value else "1",
|
||||||
checked=checked,
|
attributes=attributes
|
||||||
value=str(value) if value else "1",
|
))
|
||||||
attributes=attributes,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class PrimitiveWidgetsMixin:
|
class PrimitiveWidgetsMixin:
|
||||||
"""Automatically applies primitive custom widgets to native Django form fields."""
|
"""Automatically applies primitive custom widgets to native Django form fields."""
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
for field_name, field in self.fields.items():
|
for field_name, field in self.fields.items():
|
||||||
@@ -139,22 +130,19 @@ class SearchSelectWidget(forms.Widget):
|
|||||||
def render(self, name, value, attrs=None, renderer=None):
|
def render(self, name, value, attrs=None, renderer=None):
|
||||||
selected = searchselect_selected(self._values(value), self.options_resolver)
|
selected = searchselect_selected(self._values(value), self.options_resolver)
|
||||||
autofocus = bool((attrs or {}).get("autofocus"))
|
autofocus = bool((attrs or {}).get("autofocus"))
|
||||||
# Django widgets must return a safe string; the component is a node.
|
return SearchSelect(
|
||||||
return render(
|
name=name,
|
||||||
SearchSelect(
|
selected=selected,
|
||||||
name=name,
|
options=None,
|
||||||
selected=selected,
|
search_url=self.search_url,
|
||||||
options=None,
|
multi_select=self.multi_select,
|
||||||
search_url=self.search_url,
|
items_visible=self.items_visible,
|
||||||
multi_select=self.multi_select,
|
items_scroll=self.items_scroll,
|
||||||
items_visible=self.items_visible,
|
prefetch=self.prefetch,
|
||||||
items_scroll=self.items_scroll,
|
always_visible=self.always_visible,
|
||||||
prefetch=self.prefetch,
|
placeholder=self.placeholder,
|
||||||
always_visible=self.always_visible,
|
id=(attrs or {}).get("id", ""),
|
||||||
placeholder=self.placeholder,
|
autofocus=autofocus,
|
||||||
id=(attrs or {}).get("id", ""),
|
|
||||||
autofocus=autofocus,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def value_from_datadict(self, data, files, name):
|
def value_from_datadict(self, data, files, name):
|
||||||
@@ -227,6 +215,31 @@ class SessionForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
|||||||
return session
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
def related_purchase_queryset():
|
||||||
|
"""GAME purchases annotated with their first game's name.
|
||||||
|
|
||||||
|
Rendering the ``related_purchase`` ``<select>`` calls ``str()`` on every
|
||||||
|
option, and ``Purchase.__str__`` falls back to ``first_game`` — one extra
|
||||||
|
query per option (700+ on a large library). Annotating the first game's
|
||||||
|
name via a subquery lets the choice field build labels without those
|
||||||
|
per-row queries.
|
||||||
|
"""
|
||||||
|
first_game_name = Subquery(
|
||||||
|
Game.objects.filter(purchases=OuterRef("pk")).order_by("id").values("name")[:1]
|
||||||
|
)
|
||||||
|
return Purchase.objects.filter(type=Purchase.GAME).annotate(
|
||||||
|
_first_game_name=first_game_name
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RelatedPurchaseChoiceField(forms.ModelChoiceField):
|
||||||
|
def label_from_instance(self, obj) -> str:
|
||||||
|
# Mirrors Purchase.standardized_name but reads the annotated first-game
|
||||||
|
# name instead of querying first_game per option.
|
||||||
|
name = obj.name or getattr(obj, "_first_game_name", None)
|
||||||
|
return name or obj.standardized_name
|
||||||
|
|
||||||
|
|
||||||
class PurchaseForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
class PurchaseForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
@@ -246,12 +259,9 @@ class PurchaseForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
|||||||
search_url="/api/platforms/search", options_resolver=_platform_options
|
search_url="/api/platforms/search", options_resolver=_platform_options
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
related_game = forms.ModelChoiceField(
|
related_purchase = RelatedPurchaseChoiceField(
|
||||||
queryset=Game.objects.order_by("sort_name"),
|
queryset=related_purchase_queryset(),
|
||||||
required=False,
|
required=False,
|
||||||
widget=SearchSelectWidget(
|
|
||||||
search_url="/api/games/search", options_resolver=_game_options
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
price_currency = forms.CharField(
|
price_currency = forms.CharField(
|
||||||
@@ -282,14 +292,14 @@ class PurchaseForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
|||||||
"price_currency",
|
"price_currency",
|
||||||
"ownership_type",
|
"ownership_type",
|
||||||
"type",
|
"type",
|
||||||
"related_game",
|
"related_purchase",
|
||||||
"name",
|
"name",
|
||||||
]
|
]
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
cleaned_data = super().clean()
|
cleaned_data = super().clean()
|
||||||
purchase_type = cleaned_data.get("type")
|
purchase_type = cleaned_data.get("type")
|
||||||
related_game = cleaned_data.get("related_game")
|
related_purchase = cleaned_data.get("related_purchase")
|
||||||
name = cleaned_data.get("name")
|
name = cleaned_data.get("name")
|
||||||
|
|
||||||
# Set the type on the instance to use get_type_display()
|
# Set the type on the instance to use get_type_display()
|
||||||
@@ -298,10 +308,10 @@ class PurchaseForm(PrimitiveWidgetsMixin, forms.ModelForm):
|
|||||||
|
|
||||||
if purchase_type != Purchase.GAME:
|
if purchase_type != Purchase.GAME:
|
||||||
type_display = self.instance.get_type_display()
|
type_display = self.instance.get_type_display()
|
||||||
if not related_game:
|
if not related_purchase:
|
||||||
self.add_error(
|
self.add_error(
|
||||||
"related_game",
|
"related_purchase",
|
||||||
f"{type_display} must have a related game.",
|
f"{type_display} must have a related purchase.",
|
||||||
)
|
)
|
||||||
if not name:
|
if not name:
|
||||||
self.add_error("name", f"{type_display} must have a name.")
|
self.add_error("name", f"{type_display} must have a name.")
|
||||||
|
|||||||
@@ -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."
|
|
||||||
)
|
|
||||||
)
|
|
||||||
@@ -4,14 +4,15 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("games", "0017_add_filter_preset"),
|
('games', '0017_add_filter_preset'),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name="session",
|
model_name='session',
|
||||||
name="timestamp_start",
|
name='timestamp_start',
|
||||||
field=models.DateTimeField(db_index=True, verbose_name="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",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
+5
-6
@@ -198,13 +198,12 @@ class Purchase(models.Model):
|
|||||||
)
|
)
|
||||||
type = models.CharField(max_length=255, choices=TYPES, default=GAME)
|
type = models.CharField(max_length=255, choices=TYPES, default=GAME)
|
||||||
name = models.CharField(max_length=255, blank=True, default="")
|
name = models.CharField(max_length=255, blank=True, default="")
|
||||||
related_game = models.ForeignKey(
|
related_purchase = models.ForeignKey(
|
||||||
Game,
|
"self",
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
default=None,
|
default=None,
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
related_name="related_purchases",
|
||||||
related_name="addon_purchases",
|
|
||||||
)
|
)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
@@ -253,9 +252,9 @@ class Purchase(models.Model):
|
|||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
if self.type != Purchase.GAME and not self.related_game:
|
if self.type != Purchase.GAME and not self.related_purchase:
|
||||||
raise ValidationError(
|
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)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|||||||
+39
-153
@@ -918,9 +918,6 @@
|
|||||||
.ms-2\.5 {
|
.ms-2\.5 {
|
||||||
margin-inline-start: calc(var(--spacing) * 2.5);
|
margin-inline-start: calc(var(--spacing) * 2.5);
|
||||||
}
|
}
|
||||||
.ms-auto {
|
|
||||||
margin-inline-start: auto;
|
|
||||||
}
|
|
||||||
.me-2 {
|
.me-2 {
|
||||||
margin-inline-end: calc(var(--spacing) * 2);
|
margin-inline-end: calc(var(--spacing) * 2);
|
||||||
}
|
}
|
||||||
@@ -1582,12 +1579,6 @@
|
|||||||
.w-5 {
|
.w-5 {
|
||||||
width: calc(var(--spacing) * 5);
|
width: calc(var(--spacing) * 5);
|
||||||
}
|
}
|
||||||
.w-5\/6 {
|
|
||||||
width: calc(5 / 6 * 100%);
|
|
||||||
}
|
|
||||||
.w-8 {
|
|
||||||
width: calc(var(--spacing) * 8);
|
|
||||||
}
|
|
||||||
.w-10 {
|
.w-10 {
|
||||||
width: calc(var(--spacing) * 10);
|
width: calc(var(--spacing) * 10);
|
||||||
}
|
}
|
||||||
@@ -1603,12 +1594,6 @@
|
|||||||
.w-72 {
|
.w-72 {
|
||||||
width: calc(var(--spacing) * 72);
|
width: calc(var(--spacing) * 72);
|
||||||
}
|
}
|
||||||
.w-\[2\.5ch\] {
|
|
||||||
width: 2.5ch;
|
|
||||||
}
|
|
||||||
.w-\[4\.5ch\] {
|
|
||||||
width: 4.5ch;
|
|
||||||
}
|
|
||||||
.w-\[300px\] {
|
.w-\[300px\] {
|
||||||
width: 300px;
|
width: 300px;
|
||||||
}
|
}
|
||||||
@@ -1748,9 +1733,6 @@
|
|||||||
.cursor-pointer {
|
.cursor-pointer {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.cursor-text {
|
|
||||||
cursor: text;
|
|
||||||
}
|
|
||||||
.resize {
|
.resize {
|
||||||
resize: both;
|
resize: both;
|
||||||
}
|
}
|
||||||
@@ -1790,9 +1772,6 @@
|
|||||||
.items-start {
|
.items-start {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
.items-stretch {
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
.justify-between {
|
.justify-between {
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
@@ -1805,9 +1784,6 @@
|
|||||||
.justify-start {
|
.justify-start {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
.gap-0\.5 {
|
|
||||||
gap: calc(var(--spacing) * 0.5);
|
|
||||||
}
|
|
||||||
.gap-1 {
|
.gap-1 {
|
||||||
gap: calc(var(--spacing) * 1);
|
gap: calc(var(--spacing) * 1);
|
||||||
}
|
}
|
||||||
@@ -1857,9 +1833,6 @@
|
|||||||
margin-inline-end: calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-x-reverse)));
|
margin-inline-end: calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-x-reverse)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.gap-y-0\.5 {
|
|
||||||
row-gap: calc(var(--spacing) * 0.5);
|
|
||||||
}
|
|
||||||
.gap-y-4 {
|
.gap-y-4 {
|
||||||
row-gap: calc(var(--spacing) * 4);
|
row-gap: calc(var(--spacing) * 4);
|
||||||
}
|
}
|
||||||
@@ -1928,9 +1901,6 @@
|
|||||||
.rounded-xl {
|
.rounded-xl {
|
||||||
border-radius: var(--radius-xl);
|
border-radius: var(--radius-xl);
|
||||||
}
|
}
|
||||||
.rounded-xs {
|
|
||||||
border-radius: var(--radius-xs);
|
|
||||||
}
|
|
||||||
.rounded-s-base {
|
.rounded-s-base {
|
||||||
border-start-start-radius: var(--radius-base);
|
border-start-start-radius: var(--radius-base);
|
||||||
border-end-start-radius: var(--radius-base);
|
border-end-start-radius: var(--radius-base);
|
||||||
@@ -1955,21 +1925,20 @@
|
|||||||
border-top-left-radius: 0;
|
border-top-left-radius: 0;
|
||||||
border-top-right-radius: 0;
|
border-top-right-radius: 0;
|
||||||
}
|
}
|
||||||
.rounded-l-lg {
|
|
||||||
border-top-left-radius: var(--radius-lg);
|
|
||||||
border-bottom-left-radius: var(--radius-lg);
|
|
||||||
}
|
|
||||||
.rounded-tl-none {
|
.rounded-tl-none {
|
||||||
border-top-left-radius: 0;
|
border-top-left-radius: 0;
|
||||||
}
|
}
|
||||||
.rounded-r-lg {
|
.rounded-tr-md {
|
||||||
border-top-right-radius: var(--radius-lg);
|
border-top-right-radius: var(--radius-md);
|
||||||
border-bottom-right-radius: var(--radius-lg);
|
|
||||||
}
|
}
|
||||||
.rounded-b {
|
.rounded-b {
|
||||||
border-bottom-right-radius: var(--radius);
|
border-bottom-right-radius: var(--radius);
|
||||||
border-bottom-left-radius: var(--radius);
|
border-bottom-left-radius: var(--radius);
|
||||||
}
|
}
|
||||||
|
.rounded-b-md {
|
||||||
|
border-bottom-right-radius: var(--radius-md);
|
||||||
|
border-bottom-left-radius: var(--radius-md);
|
||||||
|
}
|
||||||
.border {
|
.border {
|
||||||
border-style: var(--tw-border-style);
|
border-style: var(--tw-border-style);
|
||||||
border-width: 1px;
|
border-width: 1px;
|
||||||
@@ -1978,14 +1947,14 @@
|
|||||||
border-style: var(--tw-border-style);
|
border-style: var(--tw-border-style);
|
||||||
border-width: 0px;
|
border-width: 0px;
|
||||||
}
|
}
|
||||||
|
.border-0\! {
|
||||||
|
border-style: var(--tw-border-style) !important;
|
||||||
|
border-width: 0px !important;
|
||||||
|
}
|
||||||
.border-2 {
|
.border-2 {
|
||||||
border-style: var(--tw-border-style);
|
border-style: var(--tw-border-style);
|
||||||
border-width: 2px;
|
border-width: 2px;
|
||||||
}
|
}
|
||||||
.border-y {
|
|
||||||
border-block-style: var(--tw-border-style);
|
|
||||||
border-block-width: 1px;
|
|
||||||
}
|
|
||||||
.border-e {
|
.border-e {
|
||||||
border-inline-end-style: var(--tw-border-style);
|
border-inline-end-style: var(--tw-border-style);
|
||||||
border-inline-end-width: 1px;
|
border-inline-end-width: 1px;
|
||||||
@@ -2055,21 +2024,9 @@
|
|||||||
.border-blue-200 {
|
.border-blue-200 {
|
||||||
border-color: var(--color-blue-200);
|
border-color: var(--color-blue-200);
|
||||||
}
|
}
|
||||||
.border-blue-600 {
|
|
||||||
border-color: var(--color-blue-600);
|
|
||||||
}
|
|
||||||
.border-blue-700 {
|
|
||||||
border-color: var(--color-blue-700);
|
|
||||||
}
|
|
||||||
.border-brand {
|
.border-brand {
|
||||||
border-color: var(--color-brand);
|
border-color: var(--color-brand);
|
||||||
}
|
}
|
||||||
.border-brand\/70 {
|
|
||||||
border-color: color-mix(in srgb, oklch(48.8% 0.243 264.376) 70%, transparent);
|
|
||||||
@supports (color: color-mix(in lab, red, red)) {
|
|
||||||
border-color: color-mix(in oklab, var(--color-brand) 70%, transparent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.border-default {
|
.border-default {
|
||||||
border-color: var(--color-default);
|
border-color: var(--color-default);
|
||||||
}
|
}
|
||||||
@@ -2161,24 +2118,12 @@
|
|||||||
.bg-brand {
|
.bg-brand {
|
||||||
background-color: var(--color-brand);
|
background-color: var(--color-brand);
|
||||||
}
|
}
|
||||||
.bg-brand\/10 {
|
|
||||||
background-color: color-mix(in srgb, oklch(48.8% 0.243 264.376) 10%, transparent);
|
|
||||||
@supports (color: color-mix(in lab, red, red)) {
|
|
||||||
background-color: color-mix(in oklab, var(--color-brand) 10%, transparent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.bg-brand\/15 {
|
.bg-brand\/15 {
|
||||||
background-color: color-mix(in srgb, oklch(48.8% 0.243 264.376) 15%, transparent);
|
background-color: color-mix(in srgb, oklch(48.8% 0.243 264.376) 15%, transparent);
|
||||||
@supports (color: color-mix(in lab, red, red)) {
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
background-color: color-mix(in oklab, var(--color-brand) 15%, transparent);
|
background-color: color-mix(in oklab, var(--color-brand) 15%, transparent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.bg-brand\/30 {
|
|
||||||
background-color: color-mix(in srgb, oklch(48.8% 0.243 264.376) 30%, transparent);
|
|
||||||
@supports (color: color-mix(in lab, red, red)) {
|
|
||||||
background-color: color-mix(in oklab, var(--color-brand) 30%, transparent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.bg-dark-backdrop\/70 {
|
.bg-dark-backdrop\/70 {
|
||||||
background-color: color-mix(in srgb, oklch(13% 0.028 261.692) 70%, transparent);
|
background-color: color-mix(in srgb, oklch(13% 0.028 261.692) 70%, transparent);
|
||||||
@supports (color: color-mix(in lab, red, red)) {
|
@supports (color: color-mix(in lab, red, red)) {
|
||||||
@@ -2191,9 +2136,6 @@
|
|||||||
.bg-gray-100 {
|
.bg-gray-100 {
|
||||||
background-color: var(--color-gray-100);
|
background-color: var(--color-gray-100);
|
||||||
}
|
}
|
||||||
.bg-gray-200 {
|
|
||||||
background-color: var(--color-gray-200);
|
|
||||||
}
|
|
||||||
.bg-gray-400 {
|
.bg-gray-400 {
|
||||||
background-color: var(--color-gray-400);
|
background-color: var(--color-gray-400);
|
||||||
}
|
}
|
||||||
@@ -2351,9 +2293,6 @@
|
|||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.p-0 {
|
|
||||||
padding: calc(var(--spacing) * 0);
|
|
||||||
}
|
|
||||||
.p-1 {
|
.p-1 {
|
||||||
padding: calc(var(--spacing) * 1);
|
padding: calc(var(--spacing) * 1);
|
||||||
}
|
}
|
||||||
@@ -2378,9 +2317,6 @@
|
|||||||
.p-6 {
|
.p-6 {
|
||||||
padding: calc(var(--spacing) * 6);
|
padding: calc(var(--spacing) * 6);
|
||||||
}
|
}
|
||||||
.px-0\.5 {
|
|
||||||
padding-inline: calc(var(--spacing) * 0.5);
|
|
||||||
}
|
|
||||||
.px-2 {
|
.px-2 {
|
||||||
padding-inline: calc(var(--spacing) * 2);
|
padding-inline: calc(var(--spacing) * 2);
|
||||||
}
|
}
|
||||||
@@ -2480,9 +2416,6 @@
|
|||||||
.text-right {
|
.text-right {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
.text-start {
|
|
||||||
text-align: start;
|
|
||||||
}
|
|
||||||
.align-middle {
|
.align-middle {
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
@@ -2676,9 +2609,6 @@
|
|||||||
.text-blue-500 {
|
.text-blue-500 {
|
||||||
color: var(--color-blue-500);
|
color: var(--color-blue-500);
|
||||||
}
|
}
|
||||||
.text-blue-600 {
|
|
||||||
color: var(--color-blue-600);
|
|
||||||
}
|
|
||||||
.text-blue-800 {
|
.text-blue-800 {
|
||||||
color: var(--color-blue-800);
|
color: var(--color-blue-800);
|
||||||
}
|
}
|
||||||
@@ -2766,6 +2696,9 @@
|
|||||||
.line-through {
|
.line-through {
|
||||||
text-decoration-line: line-through;
|
text-decoration-line: line-through;
|
||||||
}
|
}
|
||||||
|
.no-underline\! {
|
||||||
|
text-decoration-line: none !important;
|
||||||
|
}
|
||||||
.underline {
|
.underline {
|
||||||
text-decoration-line: underline;
|
text-decoration-line: underline;
|
||||||
}
|
}
|
||||||
@@ -2778,15 +2711,9 @@
|
|||||||
.decoration-dotted {
|
.decoration-dotted {
|
||||||
text-decoration-style: dotted;
|
text-decoration-style: dotted;
|
||||||
}
|
}
|
||||||
.caret-transparent {
|
|
||||||
caret-color: transparent;
|
|
||||||
}
|
|
||||||
.opacity-0 {
|
.opacity-0 {
|
||||||
opacity: 0%;
|
opacity: 0%;
|
||||||
}
|
}
|
||||||
.opacity-40 {
|
|
||||||
opacity: 40%;
|
|
||||||
}
|
|
||||||
.opacity-50 {
|
.opacity-50 {
|
||||||
opacity: 50%;
|
opacity: 50%;
|
||||||
}
|
}
|
||||||
@@ -2822,13 +2749,6 @@
|
|||||||
--tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));
|
--tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));
|
||||||
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
||||||
}
|
}
|
||||||
.ring-2 {
|
|
||||||
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
|
|
||||||
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
|
||||||
}
|
|
||||||
.ring-brand-strong {
|
|
||||||
--tw-ring-color: var(--color-brand-strong);
|
|
||||||
}
|
|
||||||
.outline {
|
.outline {
|
||||||
outline-style: var(--tw-outline-style);
|
outline-style: var(--tw-outline-style);
|
||||||
outline-width: 1px;
|
outline-width: 1px;
|
||||||
@@ -2894,9 +2814,6 @@
|
|||||||
.\[program\:qcluster\] {
|
.\[program\:qcluster\] {
|
||||||
program: qcluster;
|
program: qcluster;
|
||||||
}
|
}
|
||||||
.ring-inset {
|
|
||||||
--tw-ring-inset: inset;
|
|
||||||
}
|
|
||||||
.group-hover\:absolute {
|
.group-hover\:absolute {
|
||||||
&:is(:where(.group):hover *) {
|
&:is(:where(.group):hover *) {
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
@@ -3038,22 +2955,6 @@
|
|||||||
padding-top: calc(var(--spacing) * 0);
|
padding-top: calc(var(--spacing) * 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.focus-within\:border-brand {
|
|
||||||
&:focus-within {
|
|
||||||
border-color: var(--color-brand);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.focus-within\:ring-1 {
|
|
||||||
&:focus-within {
|
|
||||||
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
|
|
||||||
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.focus-within\:ring-brand {
|
|
||||||
&:focus-within {
|
|
||||||
--tw-ring-color: var(--color-brand);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.hover\:scale-110 {
|
.hover\:scale-110 {
|
||||||
&:hover {
|
&:hover {
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
@@ -3085,13 +2986,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.hover\:border-gray-300 {
|
|
||||||
&:hover {
|
|
||||||
@media (hover: hover) {
|
|
||||||
border-color: var(--color-gray-300);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.hover\:border-green-600 {
|
.hover\:border-green-600 {
|
||||||
&:hover {
|
&:hover {
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
@@ -3151,13 +3045,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.hover\:bg-gray-700 {
|
|
||||||
&:hover {
|
|
||||||
@media (hover: hover) {
|
|
||||||
background-color: var(--color-gray-700);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.hover\:bg-green-500 {
|
.hover\:bg-green-500 {
|
||||||
&:hover {
|
&:hover {
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
@@ -3333,14 +3220,6 @@
|
|||||||
border-color: var(--color-brand);
|
border-color: var(--color-brand);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.focus\:bg-brand\/30 {
|
|
||||||
&:focus {
|
|
||||||
background-color: color-mix(in srgb, oklch(48.8% 0.243 264.376) 30%, transparent);
|
|
||||||
@supports (color: color-mix(in lab, red, red)) {
|
|
||||||
background-color: color-mix(in oklab, var(--color-brand) 30%, transparent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.focus\:text-blue-700 {
|
.focus\:text-blue-700 {
|
||||||
&:focus {
|
&:focus {
|
||||||
color: var(--color-blue-700);
|
color: var(--color-blue-700);
|
||||||
@@ -3480,6 +3359,11 @@
|
|||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sm\:grid-cols-4 {
|
||||||
|
@media (width >= 40rem) {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
.sm\:rounded-t-lg {
|
.sm\:rounded-t-lg {
|
||||||
@media (width >= 40rem) {
|
@media (width >= 40rem) {
|
||||||
border-top-left-radius: var(--radius-lg);
|
border-top-left-radius: var(--radius-lg);
|
||||||
@@ -3645,11 +3529,21 @@
|
|||||||
max-width: var(--breakpoint-2xl);
|
max-width: var(--breakpoint-2xl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.\@sm\:grid-cols-3 {
|
||||||
|
@container (width >= 24rem) {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
.\@md\:grid-cols-4 {
|
.\@md\:grid-cols-4 {
|
||||||
@container (width >= 28rem) {
|
@container (width >= 28rem) {
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.\@lg\:grid-cols-6 {
|
||||||
|
@container (width >= 32rem) {
|
||||||
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
.rtl\:rotate-180 {
|
.rtl\:rotate-180 {
|
||||||
&:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *) {
|
&:where(:dir(rtl), [dir="rtl"], [dir="rtl"] *) {
|
||||||
rotate: 180deg;
|
rotate: 180deg;
|
||||||
@@ -3695,11 +3589,6 @@
|
|||||||
border-color: var(--color-amber-700);
|
border-color: var(--color-amber-700);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.dark\:border-blue-500 {
|
|
||||||
&:is(.dark *) {
|
|
||||||
border-color: var(--color-blue-500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.dark\:border-blue-700 {
|
.dark\:border-blue-700 {
|
||||||
&:is(.dark *) {
|
&:is(.dark *) {
|
||||||
border-color: var(--color-blue-700);
|
border-color: var(--color-blue-700);
|
||||||
@@ -3735,11 +3624,6 @@
|
|||||||
border-color: var(--color-red-700);
|
border-color: var(--color-red-700);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.dark\:border-transparent {
|
|
||||||
&:is(.dark *) {
|
|
||||||
border-color: transparent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.dark\:bg-amber-900 {
|
.dark\:bg-amber-900 {
|
||||||
&:is(.dark *) {
|
&:is(.dark *) {
|
||||||
background-color: var(--color-amber-900);
|
background-color: var(--color-amber-900);
|
||||||
@@ -4037,15 +3921,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.dark\:hover\:text-blue-500 {
|
|
||||||
&:is(.dark *) {
|
|
||||||
&:hover {
|
|
||||||
@media (hover: hover) {
|
|
||||||
color: var(--color-blue-500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.dark\:hover\:text-gray-300 {
|
.dark\:hover\:text-gray-300 {
|
||||||
&:is(.dark *) {
|
&:is(.dark *) {
|
||||||
&:hover {
|
&:hover {
|
||||||
@@ -4196,6 +4071,17 @@
|
|||||||
text-underline-offset: 4px;
|
text-underline-offset: 4px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.\[\&_li\:first-of-type_a\]\:rounded-none {
|
||||||
|
& li:first-of-type a {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.\[\&_li\:last-of-type_a\]\:rounded-t-none {
|
||||||
|
& li:last-of-type a {
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
border-top-right-radius: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
.\[\&_td\:last-child\]\:text-right {
|
.\[\&_td\:last-child\]\:text-right {
|
||||||
& td:last-child {
|
& td:last-child {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
|
|||||||
@@ -1,29 +1,45 @@
|
|||||||
import { getEl, disableElementsWhenTrue, onSwap } from "./utils.js";
|
import { getEl, disableElementsWhenTrue } from "./utils.js";
|
||||||
|
|
||||||
|
const RELATED_PURCHASE_URL = "/tracker/purchase/related-purchase-by-game";
|
||||||
|
|
||||||
// The games field is now a SearchSelect widget (a <div>, not a <select>), so we
|
// The games field is now a SearchSelect widget (a <div>, not a <select>), so we
|
||||||
// react to its custom "search-select:change" event instead of syncing a select.
|
// react to its custom "search-select:change" event instead of syncing a select.
|
||||||
document.addEventListener("search-select:change", (event) => {
|
document.addEventListener("search-select:change", (event) => {
|
||||||
if (event.detail.name !== "games") return;
|
if (event.detail.name !== "games") return;
|
||||||
|
|
||||||
// Auto-fill platform from the clicked option's data-platform.
|
// (a) Auto-fill platform from the clicked option's data-platform.
|
||||||
const last = event.detail.last;
|
const last = event.detail.last;
|
||||||
const platformId = last && last.data ? last.data.platform : "";
|
const platformId = last && last.data ? last.data.platform : "";
|
||||||
if (platformId) {
|
if (platformId) {
|
||||||
const platformEl = getEl("#id_platform");
|
const platformEl = getEl("#id_platform");
|
||||||
if (platformEl) platformEl.value = platformId;
|
if (platformEl) platformEl.value = platformId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// (b) Refresh #id_related_purchase for the currently selected games.
|
||||||
|
const query = event.detail.values
|
||||||
|
.map((value) => "games=" + encodeURIComponent(value))
|
||||||
|
.join("&");
|
||||||
|
fetch(RELATED_PURCHASE_URL + "?" + query, { credentials: "same-origin" })
|
||||||
|
.then((response) => {
|
||||||
|
if (response.status === 204) return null;
|
||||||
|
return response.text();
|
||||||
|
})
|
||||||
|
.then((html) => {
|
||||||
|
if (html === null) return;
|
||||||
|
const target = getEl("#id_related_purchase");
|
||||||
|
if (target) target.outerHTML = html;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function setupElementHandlers() {
|
function setupElementHandlers() {
|
||||||
disableElementsWhenTrue("#id_type", "game", [
|
disableElementsWhenTrue("#id_type", "game", [
|
||||||
"#id_name",
|
"#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();
|
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
@@ -4,8 +4,6 @@
|
|||||||
* Handles form submission, preset loading/saving, and preset list rendering.
|
* Handles form submission, preset loading/saving, and preset list rendering.
|
||||||
* No HTMX — plain fetch() and window.location for all interactions.
|
* No HTMX — plain fetch() and window.location for all interactions.
|
||||||
*/
|
*/
|
||||||
import { onSwap } from "./utils.js";
|
|
||||||
|
|
||||||
(function () {
|
(function () {
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
@@ -154,17 +152,17 @@ import { onSwap } from "./utils.js";
|
|||||||
{ prefix: "filter-session-average", key: "session_average" },
|
{ prefix: "filter-session-average", key: "session_average" },
|
||||||
{ prefix: "filter-purchase-count", key: "purchase_count" },
|
{ prefix: "filter-purchase-count", key: "purchase_count" },
|
||||||
{ prefix: "filter-playevent-count", key: "playevent_count" },
|
{ prefix: "filter-playevent-count", key: "playevent_count" },
|
||||||
{ prefix: "filter-duration-total-hours", key: "duration_total_hours" },
|
{ prefix: "filter-duration-total-minutes", key: "duration_total_minutes" },
|
||||||
{ prefix: "filter-duration-manual-hours", key: "duration_manual_hours" },
|
{ prefix: "filter-duration-manual-minutes", key: "duration_manual_minutes" },
|
||||||
{ prefix: "filter-duration-calculated-hours", key: "duration_calculated_hours" },
|
{ prefix: "filter-duration-calculated-minutes", key: "duration_calculated_minutes" },
|
||||||
{ prefix: "filter-manual-playtime-hours", key: "manual_playtime_hours" },
|
{ prefix: "filter-manual-playtime-minutes", key: "manual_playtime_minutes" },
|
||||||
{ prefix: "filter-calculated-playtime-hours", key: "calculated_playtime_hours" },
|
{ prefix: "filter-calculated-playtime-minutes", key: "calculated_playtime_minutes" },
|
||||||
{ prefix: "filter-num-purchases", key: "num_purchases" },
|
{ prefix: "filter-num-purchases", key: "num_purchases" },
|
||||||
{ prefix: "filter-price", key: "price" },
|
{ prefix: "filter-price", key: "price" },
|
||||||
{ prefix: "filter-purchase-price-total", key: "purchase_price_total" },
|
{ prefix: "filter-purchase-price-total", key: "purchase_price_total" },
|
||||||
{ prefix: "filter-purchase-price-any", key: "purchase_price_any" },
|
{ prefix: "filter-purchase-price-any", key: "purchase_price_any" },
|
||||||
{ prefix: "filter-days-to-finish", key: "days_to_finish" },
|
{ prefix: "filter-days-to-finish", key: "days_to_finish" },
|
||||||
{ prefix: "filter-playtime-hours", key: "playtime_hours", ignoreZeroZero: true }
|
{ prefix: "filter-playtime", key: "playtime_minutes", convert: function(v) { return Math.round(v * 60); }, ignoreZeroZero: true }
|
||||||
];
|
];
|
||||||
|
|
||||||
rangeFields.forEach(function (rf) {
|
rangeFields.forEach(function (rf) {
|
||||||
@@ -412,25 +410,27 @@ import { onSwap } from "./utils.js";
|
|||||||
|
|
||||||
// ── Init on page load ───────────────────────────────────────────────────
|
// ── Init on page load ───────────────────────────────────────────────────
|
||||||
|
|
||||||
// ── Inject the search input into a filter form ──
|
// ── Inject search inputs into filter forms ──
|
||||||
function injectSearchInput(form) {
|
function injectSearchInputs() {
|
||||||
if (form.querySelector('[name="filter-search"]')) return; // already added
|
document.querySelectorAll('[id^="filter-bar-form"]').forEach(function (form) {
|
||||||
var input = document.createElement("input");
|
if (form.querySelector('[name="filter-search"]')) return; // already added
|
||||||
input.type = "text";
|
var input = document.createElement("input");
|
||||||
input.name = "filter-search";
|
input.type = "text";
|
||||||
input.placeholder = "Search\u2026";
|
input.name = "filter-search";
|
||||||
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";
|
input.placeholder = "Search\u2026";
|
||||||
// Pre-fill from existing filter JSON
|
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";
|
||||||
var hidden = form.querySelector('[name="filter"]');
|
// Pre-fill from existing filter JSON
|
||||||
if (hidden && hidden.parentNode) {
|
var hidden = form.querySelector('[name="filter"]');
|
||||||
try {
|
if (hidden && hidden.parentNode) {
|
||||||
var existing = JSON.parse(hidden.value || "{}");
|
try {
|
||||||
if (existing.search && existing.search.value) {
|
var existing = JSON.parse(hidden.value || "{}");
|
||||||
input.value = existing.search.value;
|
if (existing.search && existing.search.value) {
|
||||||
}
|
input.value = existing.search.value;
|
||||||
} catch (e) {}
|
}
|
||||||
hidden.parentNode.insertBefore(input, hidden.nextSibling);
|
} catch (e) {}
|
||||||
}
|
hidden.parentNode.insertBefore(input, hidden.nextSibling);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -438,25 +438,25 @@ import { onSwap } from "./utils.js";
|
|||||||
*/
|
*/
|
||||||
function setupDeselectableRadios() {
|
function setupDeselectableRadios() {
|
||||||
document.querySelectorAll('input[type="radio"]').forEach(function (radio) {
|
document.querySelectorAll('input[type="radio"]').forEach(function (radio) {
|
||||||
radio.addEventListener('click', function (e) {
|
radio.addEventListener('click', function (e) {
|
||||||
if (this.wasChecked) {
|
if (this.wasChecked) {
|
||||||
this.checked = false;
|
this.checked = false;
|
||||||
this.wasChecked = false;
|
this.wasChecked = false;
|
||||||
this.dispatchEvent(new Event('change', { bubbles: true }));
|
this.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
} else {
|
} else {
|
||||||
var name = this.getAttribute('name');
|
var name = this.getAttribute('name');
|
||||||
if (name) {
|
if (name) {
|
||||||
document.querySelectorAll('input[type="radio"][name="' + name + '"]').forEach(function (r) {
|
document.querySelectorAll('input[type="radio"][name="' + name + '"]').forEach(function (r) {
|
||||||
r.wasChecked = false;
|
r.wasChecked = false;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
this.wasChecked = true;
|
||||||
}
|
}
|
||||||
this.wasChecked = true;
|
});
|
||||||
|
if (radio.checked) {
|
||||||
|
radio.wasChecked = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (radio.checked) {
|
|
||||||
radio.wasChecked = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -464,14 +464,14 @@ import { onSwap } from "./utils.js";
|
|||||||
*/
|
*/
|
||||||
function setupStringFilters() {
|
function setupStringFilters() {
|
||||||
document.querySelectorAll('input[data-string-modifier-radio]').forEach(function (radio) {
|
document.querySelectorAll('input[data-string-modifier-radio]').forEach(function (radio) {
|
||||||
radio.addEventListener('change', function () {
|
radio.addEventListener('change', function () {
|
||||||
window.toggleStringFilterInput(this);
|
window.toggleStringFilterInput(this);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onSwap('[id^="filter-bar-form"]', function (form) {
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
injectSearchInput(form);
|
injectSearchInputs();
|
||||||
setupDeselectableRadios();
|
setupDeselectableRadios();
|
||||||
setupStringFilters();
|
setupStringFilters();
|
||||||
loadPresets();
|
loadPresets();
|
||||||
|
|||||||
Vendored
-2
File diff suppressed because one or more lines are too long
+207
-201
@@ -8,223 +8,229 @@
|
|||||||
* Handles track-fill positioning and sync between handles and the connected
|
* Handles track-fill positioning and sync between handles and the connected
|
||||||
* number inputs (linked via data-target attributes).
|
* number inputs (linked via data-target attributes).
|
||||||
*/
|
*/
|
||||||
import { onSwap } from "./utils.js";
|
|
||||||
|
|
||||||
(function () {
|
(function () {
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
function initializeSlider(slider) {
|
function initAll(force) {
|
||||||
var mode = slider.getAttribute("data-mode") || "range";
|
document.querySelectorAll(".range-slider").forEach(function (slider) {
|
||||||
var trackFill = slider.querySelector(".range-track-fill");
|
if (force) slider._rsInit = false;
|
||||||
var minHandle = slider.querySelector(".range-handle-min");
|
if (slider._rsInit) return;
|
||||||
var maxHandle = slider.querySelector(".range-handle-max");
|
slider._rsInit = true;
|
||||||
if (!minHandle || !maxHandle) return;
|
|
||||||
|
|
||||||
var minTarget = document.getElementById(
|
var mode = slider.getAttribute("data-mode") || "range";
|
||||||
minHandle.getAttribute("data-target")
|
var trackFill = slider.querySelector(".range-track-fill");
|
||||||
);
|
var minHandle = slider.querySelector(".range-handle-min");
|
||||||
var maxTarget = document.getElementById(
|
var maxHandle = slider.querySelector(".range-handle-max");
|
||||||
maxHandle.getAttribute("data-target")
|
if (!minHandle || !maxHandle) return;
|
||||||
);
|
|
||||||
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 ──
|
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;
|
||||||
|
|
||||||
function valueToPercent(value) {
|
// ── Helpers ──
|
||||||
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) {
|
function valueToPercent(value) {
|
||||||
if (!target || target.value === "") return defaultVal;
|
return ((value - dataMin) / (dataMax - dataMin)) * 100;
|
||||||
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 percentToValue(percent) {
|
||||||
|
var raw = dataMin + (percent / 100) * (dataMax - dataMin);
|
||||||
function updateHandles() {
|
return Math.round(raw / step) * step;
|
||||||
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 clamp(value, lo, hi) {
|
||||||
}
|
return Math.max(lo, Math.min(hi, value));
|
||||||
|
|
||||||
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) {
|
function getTargetValue(target, defaultVal) {
|
||||||
minTarget.addEventListener("input", syncFromInputs);
|
if (!target || target.value === "") return defaultVal;
|
||||||
minTarget.addEventListener("change", enforceStrictBounds);
|
var parsed = parseInt(target.value, 10);
|
||||||
}
|
return isNaN(parsed) ? defaultVal : parsed;
|
||||||
if (maxTarget) {
|
}
|
||||||
maxTarget.addEventListener("input", syncFromInputs);
|
function setTargetValue(target, value) {
|
||||||
maxTarget.addEventListener("change", enforceStrictBounds);
|
if (target) target.value = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Mode toggle ──
|
// ── Track fill positioning ──
|
||||||
|
|
||||||
var block = slider.closest(".range-slider-block");
|
function updateTrackFill() {
|
||||||
var toggleButton =
|
if (!trackFill) return;
|
||||||
block && block.querySelector(".range-mode-toggle");
|
var minVal = clamp(getTargetValue(minTarget, dataMin), dataMin, dataMax);
|
||||||
if (toggleButton) {
|
var maxVal = clamp(getTargetValue(maxTarget, dataMax), dataMin, dataMax);
|
||||||
toggleButton.addEventListener("click", function () {
|
if (mode === "point") {
|
||||||
var newMode = mode === "range" ? "point" : "range";
|
trackFill.style.left = "0%";
|
||||||
slider.setAttribute("data-mode", newMode);
|
trackFill.style.width = valueToPercent(maxVal) + "%";
|
||||||
|
|
||||||
// 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 {
|
} else {
|
||||||
minHandle.style.display = "";
|
var leftPct = valueToPercent(minVal);
|
||||||
if (minTarget) minTarget.classList.remove("hidden");
|
var rightPct = valueToPercent(maxVal);
|
||||||
if (dashSpan) dashSpan.classList.remove("hidden");
|
if (leftPct > rightPct) {
|
||||||
|
var tmp = leftPct;
|
||||||
|
leftPct = rightPct;
|
||||||
|
rightPct = tmp;
|
||||||
|
}
|
||||||
|
var widthPct = rightPct - leftPct;
|
||||||
|
trackFill.style.left = leftPct + "%";
|
||||||
|
trackFill.style.width = widthPct + "%";
|
||||||
}
|
}
|
||||||
mode = newMode;
|
}
|
||||||
updateHandles();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Initial position ──
|
function updateHandles() {
|
||||||
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);
|
document.addEventListener("DOMContentLoaded", initAll);
|
||||||
|
document.addEventListener("htmx:afterSwap", initAll);
|
||||||
|
window.initRangeSliders = initAll;
|
||||||
})();
|
})();
|
||||||
@@ -12,8 +12,8 @@
|
|||||||
* pills. Filter widgets have no hidden inputs; readSearchSelect serialises their
|
* pills. Filter widgets have no hidden inputs; readSearchSelect serialises their
|
||||||
* state into data-included / data-excluded / data-modifier for the filter bar.
|
* state into data-included / data-excluded / data-modifier for the filter bar.
|
||||||
*
|
*
|
||||||
* Widgets are initialized via onSwap() (utils.js), which covers the initial
|
* initAll() runs on DOMContentLoaded + htmx:afterSwap, each widget guarded with
|
||||||
* page load and every htmx-swapped fragment, once per widget.
|
* element._searchSelectInit.
|
||||||
*
|
*
|
||||||
* Dynamically-added rows and pills are cloned from hidden <template> elements
|
* Dynamically-added rows and pills are cloned from hidden <template> elements
|
||||||
* the server renders with the same Python components (Pill / SearchSelect /
|
* the server renders with the same Python components (Pill / SearchSelect /
|
||||||
@@ -21,8 +21,6 @@
|
|||||||
* and data-* attributes — so all markup and Tailwind class strings live in one
|
* and data-* attributes — so all markup and Tailwind class strings live in one
|
||||||
* place (the Python components), never duplicated here.
|
* place (the Python components), never duplicated here.
|
||||||
*/
|
*/
|
||||||
import { onSwap } from "./utils.js";
|
|
||||||
|
|
||||||
(() => {
|
(() => {
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
@@ -34,6 +32,14 @@ import { onSwap } from "./utils.js";
|
|||||||
// INCLUDES_ONLY) coexist with value pills.
|
// INCLUDES_ONLY) coexist with value pills.
|
||||||
const PRESENCE_MODIFIERS = ["NOT_NULL", "IS_NULL"];
|
const PRESENCE_MODIFIERS = ["NOT_NULL", "IS_NULL"];
|
||||||
|
|
||||||
|
const initAll = () => {
|
||||||
|
document.querySelectorAll("[data-search-select]").forEach(element => {
|
||||||
|
if (element._searchSelectInit) return;
|
||||||
|
element._searchSelectInit = true;
|
||||||
|
initWidget(element);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const initWidget = (container) => {
|
const initWidget = (container) => {
|
||||||
const search = container.querySelector("[data-search-select-search]");
|
const search = container.querySelector("[data-search-select-search]");
|
||||||
const options = container.querySelector("[data-search-select-options]");
|
const options = container.querySelector("[data-search-select-options]");
|
||||||
@@ -660,5 +666,6 @@ import { onSwap } from "./utils.js";
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
onSwap("[data-search-select]", initWidget);
|
document.addEventListener("DOMContentLoaded", initAll);
|
||||||
|
document.addEventListener("htmx:afterSwap", initAll);
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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.
|
* @description Formats Date to a UTC string accepted by the datetime-local input field.
|
||||||
* @param {Date} date
|
* @param {Date} date
|
||||||
@@ -227,7 +202,6 @@ function disableElementsWhenTrue(targetSelect, targetValue, elementList) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
onSwap,
|
|
||||||
toISOUTCString,
|
toISOUTCString,
|
||||||
syncSelectInputUntilChanged,
|
syncSelectInputUntilChanged,
|
||||||
getEl,
|
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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -105,6 +105,11 @@ urlpatterns = [
|
|||||||
purchase.refund_purchase,
|
purchase.refund_purchase,
|
||||||
name="refund_purchase",
|
name="refund_purchase",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"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("session/add", session.add_session, name="add_session"),
|
||||||
path(
|
path(
|
||||||
"session/add/for-game/<int:game_id>",
|
"session/add/for-game/<int:game_id>",
|
||||||
|
|||||||
+10
-9
@@ -3,18 +3,19 @@ registration/login.html)."""
|
|||||||
|
|
||||||
from django.contrib.auth import views as auth_views
|
from django.contrib.auth import views as auth_views
|
||||||
from django.http import HttpResponse
|
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 import Component, CsrfInput, Div, Input
|
||||||
from common.components.primitives import Td, Tr
|
from common.components.primitives import Td, Tr
|
||||||
from common.layout import render_page
|
from common.layout import render_page
|
||||||
|
|
||||||
|
|
||||||
def _login_content(form, request) -> Node:
|
def _login_content(form, request) -> SafeText:
|
||||||
table = Element(
|
table = Component(
|
||||||
"table",
|
tag_name="table",
|
||||||
children=[
|
children=[
|
||||||
CsrfInput(request),
|
CsrfInput(request),
|
||||||
Safe(str(form.as_table())),
|
mark_safe(str(form.as_table())),
|
||||||
Tr(
|
Tr(
|
||||||
children=[
|
children=[
|
||||||
Td(),
|
Td(),
|
||||||
@@ -30,13 +31,13 @@ def _login_content(form, request) -> Node:
|
|||||||
return Div(
|
return Div(
|
||||||
[("class", "flex items-center flex-col")],
|
[("class", "flex items-center flex-col")],
|
||||||
[
|
[
|
||||||
Element(
|
Component(
|
||||||
"h2",
|
tag_name="h2",
|
||||||
attributes=[("class", "text-3xl text-white mb-8")],
|
attributes=[("class", "text-3xl text-white mb-8")],
|
||||||
children=["Please log in to continue"],
|
children=["Please log in to continue"],
|
||||||
),
|
),
|
||||||
Element(
|
Component(
|
||||||
"form",
|
tag_name="form",
|
||||||
attributes=[("method", "post")],
|
attributes=[("method", "post")],
|
||||||
children=[table],
|
children=[table],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -2,16 +2,17 @@ from django.contrib.auth.decorators import login_required
|
|||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
from common.components import (
|
from common.components import (
|
||||||
A,
|
A,
|
||||||
AddForm,
|
AddForm,
|
||||||
|
Button,
|
||||||
ButtonGroup,
|
ButtonGroup,
|
||||||
DeviceFilterBar,
|
|
||||||
Fragment,
|
|
||||||
Icon,
|
Icon,
|
||||||
StyledButton,
|
|
||||||
paginated_table_content,
|
paginated_table_content,
|
||||||
|
DeviceFilterBar,
|
||||||
|
ModuleScript,
|
||||||
)
|
)
|
||||||
from common.layout import render_page
|
from common.layout import render_page
|
||||||
from common.time import dateformat, local_strftime
|
from common.time import dateformat, local_strftime
|
||||||
@@ -34,9 +35,7 @@ def list_devices(request: HttpRequest) -> HttpResponse:
|
|||||||
devices, page_obj, elided_page_range = paginate(request, devices)
|
devices, page_obj, elided_page_range = paginate(request, devices)
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"header_action": A(href=reverse("games:add_device"))[
|
"header_action": A([], Button([], "Add device"), url_name="games:add_device"),
|
||||||
StyledButton()["Add device"]
|
|
||||||
],
|
|
||||||
"columns": [
|
"columns": [
|
||||||
"Name",
|
"Name",
|
||||||
"Type",
|
"Type",
|
||||||
@@ -77,11 +76,14 @@ def list_devices(request: HttpRequest) -> HttpResponse:
|
|||||||
preset_list_url=reverse("games:list_presets") + "?mode=devices",
|
preset_list_url=reverse("games:list_presets") + "?mode=devices",
|
||||||
preset_save_url=reverse("games:save_preset") + "?mode=devices",
|
preset_save_url=reverse("games:save_preset") + "?mode=devices",
|
||||||
)
|
)
|
||||||
content = Fragment(filter_bar, content)
|
content = mark_safe(str(filter_bar) + str(content))
|
||||||
return render_page(
|
return render_page(
|
||||||
request,
|
request,
|
||||||
content,
|
content,
|
||||||
title="Manage devices",
|
title="Manage devices",
|
||||||
|
scripts=ModuleScript("range_slider.js")
|
||||||
|
+ ModuleScript("search_select.js")
|
||||||
|
+ ModuleScript("filter_bar.js"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from django.contrib.auth.decorators import login_required
|
|||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
from games.models import FilterPreset
|
from games.models import FilterPreset
|
||||||
|
|
||||||
@@ -39,7 +40,7 @@ def list_presets(request: HttpRequest) -> HttpResponse:
|
|||||||
if not items:
|
if not items:
|
||||||
items = ['<li class="px-4 py-2 text-sm text-body italic">No saved presets</li>']
|
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>')
|
return HttpResponse(mark_safe(f'<ul class="py-1">{"".join(items)}</ul>'))
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
|
|||||||
+96
-91
@@ -8,18 +8,18 @@ from django.middleware.csrf import get_token
|
|||||||
from django.shortcuts import get_object_or_404, redirect
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
from django.template.defaultfilters import date as date_filter
|
from django.template.defaultfilters import date as date_filter
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.safestring import SafeText
|
from django.utils.safestring import SafeText, mark_safe
|
||||||
|
|
||||||
from common.components import (
|
from common.components import (
|
||||||
H1,
|
H1,
|
||||||
A,
|
A,
|
||||||
AddForm,
|
AddForm,
|
||||||
|
Button,
|
||||||
ButtonGroup,
|
ButtonGroup,
|
||||||
|
Component,
|
||||||
CsrfInput,
|
CsrfInput,
|
||||||
Div,
|
Div,
|
||||||
Element,
|
|
||||||
FilterBar,
|
FilterBar,
|
||||||
Fragment,
|
|
||||||
GameStatus,
|
GameStatus,
|
||||||
GameStatusSelector,
|
GameStatusSelector,
|
||||||
Icon,
|
Icon,
|
||||||
@@ -27,18 +27,16 @@ from common.components import (
|
|||||||
Modal,
|
Modal,
|
||||||
ModuleScript,
|
ModuleScript,
|
||||||
NameWithIcon,
|
NameWithIcon,
|
||||||
Node,
|
|
||||||
Popover,
|
Popover,
|
||||||
PopoverTruncated,
|
PopoverTruncated,
|
||||||
PurchasePrice,
|
PurchasePrice,
|
||||||
Safe,
|
|
||||||
SearchField,
|
SearchField,
|
||||||
SimpleTable,
|
SimpleTable,
|
||||||
StyledButton,
|
|
||||||
Ul,
|
Ul,
|
||||||
paginated_table_content,
|
paginated_table_content,
|
||||||
)
|
)
|
||||||
from common.components.primitives import Li, P, Span, Strong
|
from common.components.primitives import Li, P, Span, Strong
|
||||||
|
from common.icons import get_icon
|
||||||
from common.layout import render_page
|
from common.layout import render_page
|
||||||
from common.time import (
|
from common.time import (
|
||||||
dateformat,
|
dateformat,
|
||||||
@@ -90,11 +88,12 @@ def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
|
|||||||
|
|
||||||
data = {
|
data = {
|
||||||
"header_action": Div(
|
"header_action": Div(
|
||||||
class_="flex justify-between",
|
children=[
|
||||||
)[
|
SearchField(search_string=search_string),
|
||||||
SearchField(search_string=search_string),
|
A([], Button([], "Add game"), url_name="games:add_game"),
|
||||||
A(href=reverse("games:add_game"))[StyledButton()["Add game"]],
|
],
|
||||||
],
|
attributes=[("class", "flex justify-between")],
|
||||||
|
),
|
||||||
"columns": [
|
"columns": [
|
||||||
"Name",
|
"Name",
|
||||||
"Sort Name",
|
"Sort Name",
|
||||||
@@ -146,11 +145,14 @@ def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
|
|||||||
preset_list_url=reverse("games:list_presets"),
|
preset_list_url=reverse("games:list_presets"),
|
||||||
preset_save_url=reverse("games:save_preset"),
|
preset_save_url=reverse("games:save_preset"),
|
||||||
)
|
)
|
||||||
content = Fragment(filter_bar, content)
|
content = mark_safe(str(filter_bar) + str(content))
|
||||||
return render_page(
|
return render_page(
|
||||||
request,
|
request,
|
||||||
content,
|
content,
|
||||||
title="Manage games",
|
title="Manage games",
|
||||||
|
scripts=ModuleScript("range_slider.js")
|
||||||
|
+ ModuleScript("search_select.js")
|
||||||
|
+ ModuleScript("filter_bar.js"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -171,7 +173,7 @@ def add_game(request: HttpRequest) -> HttpResponse:
|
|||||||
AddForm(
|
AddForm(
|
||||||
form,
|
form,
|
||||||
request=request,
|
request=request,
|
||||||
additional_row=StyledButton(
|
additional_row=Button(
|
||||||
[],
|
[],
|
||||||
"Submit & Create Purchase",
|
"Submit & Create Purchase",
|
||||||
color="gray",
|
color="gray",
|
||||||
@@ -201,8 +203,8 @@ def _delete_game_confirmation_modal(
|
|||||||
if not (session_count or purchase_count or playevent_count):
|
if not (session_count or purchase_count or playevent_count):
|
||||||
data_items.append(Li(children=["No associated data"]))
|
data_items.append(Li(children=["No associated data"]))
|
||||||
|
|
||||||
form = Element(
|
form = Component(
|
||||||
"form",
|
tag_name="form",
|
||||||
attributes=[
|
attributes=[
|
||||||
("hx-post", reverse("games:delete_game", args=[game.id])),
|
("hx-post", reverse("games:delete_game", args=[game.id])),
|
||||||
("hx-replace-url", "true"),
|
("hx-replace-url", "true"),
|
||||||
@@ -247,14 +249,14 @@ def _delete_game_confirmation_modal(
|
|||||||
Div(
|
Div(
|
||||||
[("class", "items-center mt-5")],
|
[("class", "items-center mt-5")],
|
||||||
[
|
[
|
||||||
StyledButton(
|
Button(
|
||||||
[("class", "w-full")],
|
[("class", "w-full")],
|
||||||
"Delete",
|
"Delete",
|
||||||
color="red",
|
color="red",
|
||||||
size="lg",
|
size="lg",
|
||||||
type="submit",
|
type="submit",
|
||||||
),
|
),
|
||||||
StyledButton(
|
Button(
|
||||||
[("class", "mt-0 w-full")],
|
[("class", "mt-0 w-full")],
|
||||||
"Cancel",
|
"Cancel",
|
||||||
color="gray",
|
color="gray",
|
||||||
@@ -338,69 +340,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>',
|
"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 = (
|
_PLAYED_ROW_TEMPLATE = """<div class="flex gap-2 items-center" x-data="{ open: false }">
|
||||||
"px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 "
|
<span class="uppercase">Played</span>
|
||||||
"hover:bg-gray-100 hover:text-blue-700 dark:bg-gray-800 dark:border-gray-700 "
|
<div class="inline-flex rounded-md shadow-2xs" role="group" x-data="{ played: @@PLAYED_COUNT@@ }">
|
||||||
"dark:text-white dark:hover:bg-gray-700 hover:cursor-pointer"
|
<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">
|
||||||
_PLAYED_MENU = (
|
<span x-text="played"></span> times
|
||||||
"absolute top-full -left-px w-auto whitespace-nowrap z-10 text-sm font-medium "
|
</button>
|
||||||
"bg-gray-800/20 backdrop-blur-lg rounded-md rounded-tl-none border "
|
</a>
|
||||||
"border-gray-200 dark:border-gray-700"
|
<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:
|
def _played_row(game: Game, request: HttpRequest) -> SafeText:
|
||||||
"""'Played N times' control as a custom element (ts/elements/play-event-row.ts)."""
|
"""The 'Played N times' control with its Alpine.js dropdown."""
|
||||||
from common.components import Element
|
replacements = {
|
||||||
from common.components.custom_elements import _PlayEventRow
|
"@@PLAYED_COUNT@@": str(game.playevents.count()),
|
||||||
from common.components.primitives import Button
|
"@@ADD_PE@@": reverse("games:add_playevent"),
|
||||||
|
"@@ARROWDOWN@@": get_icon("arrowdown"),
|
||||||
played: int = 0
|
"@@ADD_PE_FOR_GAME@@": reverse("games:add_playevent_for_game", args=[game.id]),
|
||||||
played = game.playevents.count()
|
"@@API_CREATE@@": reverse("api-1.0.0:create_playevent"),
|
||||||
|
"@@CSRF@@": get_token(request),
|
||||||
count_button = A(href=reverse("games:add_playevent"))[
|
"@@GAME_ID@@": str(game.id),
|
||||||
Button(class_=_PLAYED_BTN + " rounded-s-lg")[
|
}
|
||||||
Span(data_count="")[str(played)], " times"
|
html = _PLAYED_ROW_TEMPLATE
|
||||||
]
|
for token, value in replacements.items():
|
||||||
]
|
html = html.replace(token, value)
|
||||||
menu = Div(data_menu="", hidden=True, class_=_PLAYED_MENU)[
|
return mark_safe(html)
|
||||||
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 _stat_popover(popover_id: str, tooltip: str, svg_key: str, value: str) -> SafeText:
|
def _stat_popover(popover_id: str, tooltip: str, svg_key: str, value: str) -> SafeText:
|
||||||
@@ -408,12 +410,14 @@ def _stat_popover(popover_id: str, tooltip: str, svg_key: str, value: str) -> Sa
|
|||||||
popover_content=tooltip,
|
popover_content=tooltip,
|
||||||
wrapped_classes="flex gap-2 items-center",
|
wrapped_classes="flex gap-2 items-center",
|
||||||
id=popover_id,
|
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:
|
def _meta_row(
|
||||||
children: list[Node | str] = [
|
label: str, value: SafeText | str, extra: SafeText | str = ""
|
||||||
|
) -> SafeText:
|
||||||
|
children: list[SafeText | str] = [
|
||||||
Span(attributes=[("class", "uppercase")], children=[label]),
|
Span(attributes=[("class", "uppercase")], children=[label]),
|
||||||
value,
|
value,
|
||||||
]
|
]
|
||||||
@@ -440,8 +444,8 @@ def _game_action_buttons(game: Game) -> SafeText:
|
|||||||
edit_link = A(
|
edit_link = A(
|
||||||
href=reverse("games:edit_game", args=[game.id]),
|
href=reverse("games:edit_game", args=[game.id]),
|
||||||
children=[
|
children=[
|
||||||
Element(
|
Component(
|
||||||
"button",
|
tag_name="button",
|
||||||
attributes=[("type", "button"), ("class", edit_class)],
|
attributes=[("type", "button"), ("class", edit_class)],
|
||||||
children=["Edit"],
|
children=["Edit"],
|
||||||
)
|
)
|
||||||
@@ -454,8 +458,8 @@ def _game_action_buttons(game: Game) -> SafeText:
|
|||||||
("hx-target", "#global-modal-container"),
|
("hx-target", "#global-modal-container"),
|
||||||
],
|
],
|
||||||
children=[
|
children=[
|
||||||
Element(
|
Component(
|
||||||
"button",
|
tag_name="button",
|
||||||
attributes=[("type", "button"), ("class", delete_class)],
|
attributes=[("type", "button"), ("class", delete_class)],
|
||||||
children=["Delete"],
|
children=["Delete"],
|
||||||
)
|
)
|
||||||
@@ -563,7 +567,7 @@ def _game_header(game: Game, request: HttpRequest, metrics: dict[str, Any]) -> S
|
|||||||
]
|
]
|
||||||
+ (
|
+ (
|
||||||
[
|
[
|
||||||
Safe(" "),
|
mark_safe(" "),
|
||||||
Popover(
|
Popover(
|
||||||
popover_content="Original release year",
|
popover_content="Original release year",
|
||||||
wrapped_classes="text-slate-500 text-2xl",
|
wrapped_classes="text-slate-500 text-2xl",
|
||||||
@@ -684,9 +688,10 @@ def _sessions_section(game: Game, request: HttpRequest) -> SafeText:
|
|||||||
|
|
||||||
header_action = Div(
|
header_action = Div(
|
||||||
children=[
|
children=[
|
||||||
A(href=reverse("games:add_session"))[
|
A(
|
||||||
StyledButton(icon=True, color="blue", size="xs")[Icon("plus")]
|
url_name="games:add_session",
|
||||||
],
|
children=Button(icon=True, size="xs", children=[Icon("play"), "LOG"]),
|
||||||
|
),
|
||||||
A(
|
A(
|
||||||
href=reverse(
|
href=reverse(
|
||||||
"games:list_sessions_start_session_from_session",
|
"games:list_sessions_start_session_from_session",
|
||||||
@@ -695,7 +700,7 @@ def _sessions_section(game: Game, request: HttpRequest) -> SafeText:
|
|||||||
children=Popover(
|
children=Popover(
|
||||||
popover_content=last_session.game.name,
|
popover_content=last_session.game.name,
|
||||||
children=[
|
children=[
|
||||||
StyledButton(
|
Button(
|
||||||
icon=True,
|
icon=True,
|
||||||
color="gray",
|
color="gray",
|
||||||
size="xs",
|
size="xs",
|
||||||
@@ -781,7 +786,7 @@ def _history_section(game: Game) -> SafeText:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
_GET_SESSION_COUNT_SCRIPT = Safe(
|
_GET_SESSION_COUNT_SCRIPT = mark_safe(
|
||||||
"<script>\n"
|
"<script>\n"
|
||||||
" function getSessionCount() {\n"
|
" function getSessionCount() {\n"
|
||||||
" return document.getElementById('session-count')"
|
" return document.getElementById('session-count')"
|
||||||
|
|||||||
+11
-4
@@ -13,14 +13,17 @@ from django.urls import reverse
|
|||||||
from django.utils.timezone import localtime
|
from django.utils.timezone import localtime
|
||||||
from django.utils.timezone import now as timezone_now
|
from django.utils.timezone import now as timezone_now
|
||||||
|
|
||||||
|
from common.components import ExternalScript
|
||||||
from common.layout import render_page
|
from common.layout import render_page
|
||||||
from common.time import format_duration
|
from common.time import format_duration
|
||||||
from games.models import Game, Platform, Purchase, Session
|
from games.models import Game, Platform, Purchase, Session
|
||||||
from games.views.stats_content import stats_content
|
from games.views.stats_content import stats_content
|
||||||
from games.views.stats_data import compute_stats
|
from games.views.stats_data import compute_stats
|
||||||
|
|
||||||
# The Flowbite-datepicker UMD bundle is declared as media on the YearPicker
|
# Flowbite-datepicker UMD bundle, hoisted into the stats pages for YearPicker.
|
||||||
# component, so Page() loads it automatically on the stats pages.
|
_STATS_SCRIPTS = ExternalScript(
|
||||||
|
"https://cdn.jsdelivr.net/npm/flowbite-datepicker@2.0.0/dist/Datepicker.umd.min.js"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def model_counts(request: HttpRequest) -> dict[str, bool]:
|
def model_counts(request: HttpRequest) -> dict[str, bool]:
|
||||||
@@ -74,7 +77,9 @@ def use_custom_redirect(
|
|||||||
def stats_alltime(request: HttpRequest) -> HttpResponse:
|
def stats_alltime(request: HttpRequest) -> HttpResponse:
|
||||||
request.session["return_path"] = request.path
|
request.session["return_path"] = request.path
|
||||||
data = compute_stats(None)
|
data = compute_stats(None)
|
||||||
return render_page(request, stats_content(data), title=data["title"])
|
return render_page(
|
||||||
|
request, stats_content(data), title=data["title"], scripts=_STATS_SCRIPTS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@@ -88,7 +93,9 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
|
|||||||
return HttpResponseRedirect(reverse("games:stats_alltime"))
|
return HttpResponseRedirect(reverse("games:stats_alltime"))
|
||||||
request.session["return_path"] = request.path
|
request.session["return_path"] = request.path
|
||||||
data = compute_stats(year)
|
data = compute_stats(year)
|
||||||
return render_page(request, stats_content(data), title=data["title"])
|
return render_page(
|
||||||
|
request, stats_content(data), title=data["title"], scripts=_STATS_SCRIPTS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
|
|||||||
+11
-7
@@ -2,16 +2,17 @@ from django.contrib.auth.decorators import login_required
|
|||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
from common.components import (
|
from common.components import (
|
||||||
A,
|
A,
|
||||||
AddForm,
|
AddForm,
|
||||||
|
Button,
|
||||||
ButtonGroup,
|
ButtonGroup,
|
||||||
Fragment,
|
|
||||||
Icon,
|
Icon,
|
||||||
PlatformFilterBar,
|
|
||||||
StyledButton,
|
|
||||||
paginated_table_content,
|
paginated_table_content,
|
||||||
|
PlatformFilterBar,
|
||||||
|
ModuleScript,
|
||||||
)
|
)
|
||||||
from common.layout import render_page
|
from common.layout import render_page
|
||||||
from common.time import dateformat, local_strftime
|
from common.time import dateformat, local_strftime
|
||||||
@@ -35,9 +36,9 @@ def list_platforms(request: HttpRequest) -> HttpResponse:
|
|||||||
platforms, page_obj, elided_page_range = paginate(request, platforms)
|
platforms, page_obj, elided_page_range = paginate(request, platforms)
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"header_action": A(href=reverse("games:add_platform"))[
|
"header_action": A(
|
||||||
StyledButton()["Add platform"]
|
[], Button([], "Add platform"), url_name="games:add_platform"
|
||||||
],
|
),
|
||||||
"columns": [
|
"columns": [
|
||||||
"Name",
|
"Name",
|
||||||
"Icon",
|
"Icon",
|
||||||
@@ -82,11 +83,14 @@ def list_platforms(request: HttpRequest) -> HttpResponse:
|
|||||||
preset_list_url=reverse("games:list_presets") + "?mode=platforms",
|
preset_list_url=reverse("games:list_presets") + "?mode=platforms",
|
||||||
preset_save_url=reverse("games:save_preset") + "?mode=platforms",
|
preset_save_url=reverse("games:save_preset") + "?mode=platforms",
|
||||||
)
|
)
|
||||||
content = Fragment(filter_bar, content)
|
content = mark_safe(str(filter_bar) + str(content))
|
||||||
return render_page(
|
return render_page(
|
||||||
request,
|
request,
|
||||||
content,
|
content,
|
||||||
title="Manage platforms",
|
title="Manage platforms",
|
||||||
|
scripts=ModuleScript("range_slider.js")
|
||||||
|
+ ModuleScript("search_select.js")
|
||||||
|
+ ModuleScript("filter_bar.js"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,16 +9,17 @@ from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
|||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
from common.components import (
|
from common.components import (
|
||||||
A,
|
A,
|
||||||
AddForm,
|
AddForm,
|
||||||
|
Button,
|
||||||
ButtonGroup,
|
ButtonGroup,
|
||||||
Fragment,
|
|
||||||
Icon,
|
Icon,
|
||||||
ModuleScript,
|
ModuleScript,
|
||||||
PlayEventFilterBar,
|
|
||||||
StyledButton,
|
|
||||||
paginated_table_content,
|
paginated_table_content,
|
||||||
|
PlayEventFilterBar,
|
||||||
)
|
)
|
||||||
from common.layout import render_page
|
from common.layout import render_page
|
||||||
from common.time import dateformat, format_duration, local_strftime
|
from common.time import dateformat, format_duration, local_strftime
|
||||||
@@ -86,9 +87,9 @@ def create_playevent_tabledata(
|
|||||||
for row in row_list
|
for row in row_list
|
||||||
]
|
]
|
||||||
return {
|
return {
|
||||||
"header_action": A(href=reverse("games:add_playevent"))[
|
"header_action": A(
|
||||||
StyledButton()["Add play event"]
|
[], Button([], "Add play event"), url_name="games:add_playevent"
|
||||||
],
|
),
|
||||||
"columns": list(filtered_column_list),
|
"columns": list(filtered_column_list),
|
||||||
"rows": filtered_row_list,
|
"rows": filtered_row_list,
|
||||||
}
|
}
|
||||||
@@ -150,11 +151,14 @@ def list_playevents(request: HttpRequest) -> HttpResponse:
|
|||||||
preset_list_url=reverse("games:list_presets") + "?mode=playevents",
|
preset_list_url=reverse("games:list_presets") + "?mode=playevents",
|
||||||
preset_save_url=reverse("games:save_preset") + "?mode=playevents",
|
preset_save_url=reverse("games:save_preset") + "?mode=playevents",
|
||||||
)
|
)
|
||||||
content = Fragment(filter_bar, content)
|
content = mark_safe(str(filter_bar) + str(content))
|
||||||
return render_page(
|
return render_page(
|
||||||
request,
|
request,
|
||||||
content,
|
content,
|
||||||
title="Manage play events",
|
title="Manage play events",
|
||||||
|
scripts=ModuleScript("range_slider.js")
|
||||||
|
+ ModuleScript("search_select.js")
|
||||||
|
+ ModuleScript("filter_bar.js"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+40
-17
@@ -16,20 +16,18 @@ from django.views.decorators.http import require_POST
|
|||||||
from common.components import (
|
from common.components import (
|
||||||
A,
|
A,
|
||||||
AddForm,
|
AddForm,
|
||||||
|
Button,
|
||||||
ButtonGroup,
|
ButtonGroup,
|
||||||
|
Component,
|
||||||
CsrfInput,
|
CsrfInput,
|
||||||
Div,
|
Div,
|
||||||
Element,
|
|
||||||
Fragment,
|
|
||||||
GameLink,
|
GameLink,
|
||||||
Icon,
|
Icon,
|
||||||
LinkedPurchase,
|
LinkedPurchase,
|
||||||
Modal,
|
Modal,
|
||||||
ModuleScript,
|
ModuleScript,
|
||||||
Node,
|
|
||||||
PriceConverted,
|
PriceConverted,
|
||||||
PurchasePrice,
|
PurchasePrice,
|
||||||
StyledButton,
|
|
||||||
TableRow,
|
TableRow,
|
||||||
paginated_table_content,
|
paginated_table_content,
|
||||||
)
|
)
|
||||||
@@ -110,9 +108,9 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
|
|||||||
purchases, page_obj, elided_page_range = paginate(request, purchases)
|
purchases, page_obj, elided_page_range = paginate(request, purchases)
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
"header_action": A(href=reverse("games:add_purchase"))[
|
"header_action": A(
|
||||||
StyledButton()["Add purchase"]
|
[], Button([], "Add purchase"), url_name="games:add_purchase"
|
||||||
],
|
),
|
||||||
"columns": [
|
"columns": [
|
||||||
"Name",
|
"Name",
|
||||||
"Type",
|
"Type",
|
||||||
@@ -131,18 +129,21 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
|
|||||||
elided_page_range=elided_page_range,
|
elided_page_range=elided_page_range,
|
||||||
request=request,
|
request=request,
|
||||||
)
|
)
|
||||||
from common.components import PurchaseFilterBar
|
from common.components import ModuleScript, PurchaseFilterBar
|
||||||
|
|
||||||
filter_bar = PurchaseFilterBar(
|
filter_bar = PurchaseFilterBar(
|
||||||
filter_json=filter_json,
|
filter_json=filter_json,
|
||||||
preset_list_url=reverse("games:list_presets"),
|
preset_list_url=reverse("games:list_presets"),
|
||||||
preset_save_url=reverse("games:save_preset"),
|
preset_save_url=reverse("games:save_preset"),
|
||||||
)
|
)
|
||||||
content = Fragment(filter_bar, content)
|
content = mark_safe(str(filter_bar) + str(content))
|
||||||
return render_page(
|
return render_page(
|
||||||
request,
|
request,
|
||||||
content,
|
content,
|
||||||
title="Manage purchases",
|
title="Manage purchases",
|
||||||
|
scripts=ModuleScript("range_slider.js")
|
||||||
|
+ ModuleScript("search_select.js")
|
||||||
|
+ ModuleScript("filter_bar.js"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -153,7 +154,7 @@ def _purchase_additional_row() -> SafeText:
|
|||||||
Td(),
|
Td(),
|
||||||
Td(
|
Td(
|
||||||
children=[
|
children=[
|
||||||
StyledButton(
|
Button(
|
||||||
[],
|
[],
|
||||||
"Submit & Create Session",
|
"Submit & Create Session",
|
||||||
color="gray",
|
color="gray",
|
||||||
@@ -302,9 +303,9 @@ def drop_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
|
|||||||
return redirect("games:list_purchases")
|
return redirect("games:list_purchases")
|
||||||
|
|
||||||
|
|
||||||
def _refund_confirmation_modal(purchase_id: int, request: HttpRequest) -> Node:
|
def _refund_confirmation_modal(purchase_id: int, request: HttpRequest) -> SafeText:
|
||||||
form = Element(
|
form = Component(
|
||||||
"form",
|
tag_name="form",
|
||||||
attributes=[
|
attributes=[
|
||||||
("hx-post", reverse("games:refund_purchase", args=[purchase_id])),
|
("hx-post", reverse("games:refund_purchase", args=[purchase_id])),
|
||||||
("hx-target", f"#purchase-row-{purchase_id}"),
|
("hx-target", f"#purchase-row-{purchase_id}"),
|
||||||
@@ -319,14 +320,14 @@ def _refund_confirmation_modal(purchase_id: int, request: HttpRequest) -> Node:
|
|||||||
Div(
|
Div(
|
||||||
[("class", "items-center mt-5")],
|
[("class", "items-center mt-5")],
|
||||||
[
|
[
|
||||||
StyledButton(
|
Button(
|
||||||
[("class", "w-full")],
|
[("class", "w-full")],
|
||||||
"Refund",
|
"Refund",
|
||||||
color="blue",
|
color="blue",
|
||||||
size="lg",
|
size="lg",
|
||||||
type="submit",
|
type="submit",
|
||||||
),
|
),
|
||||||
StyledButton(
|
Button(
|
||||||
[("class", "mt-0 w-full")],
|
[("class", "mt-0 w-full")],
|
||||||
"Cancel",
|
"Cancel",
|
||||||
color="gray",
|
color="gray",
|
||||||
@@ -340,8 +341,8 @@ def _refund_confirmation_modal(purchase_id: int, request: HttpRequest) -> Node:
|
|||||||
return Modal(
|
return Modal(
|
||||||
"refund-confirmation-modal",
|
"refund-confirmation-modal",
|
||||||
children=[
|
children=[
|
||||||
Element(
|
Component(
|
||||||
"h1",
|
tag_name="h1",
|
||||||
attributes=[
|
attributes=[
|
||||||
(
|
(
|
||||||
"class",
|
"class",
|
||||||
@@ -393,3 +394,25 @@ def finish_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
|
|||||||
game.status = Game.Status.FINISHED
|
game.status = Game.Status.FINISHED
|
||||||
game.save()
|
game.save()
|
||||||
return redirect("games:list_purchases")
|
return redirect("games:list_purchases")
|
||||||
|
|
||||||
|
|
||||||
|
def related_purchase_by_game(request: HttpRequest) -> HttpResponse:
|
||||||
|
games: list[str] = request.GET.getlist("games")
|
||||||
|
if games:
|
||||||
|
from games.forms import related_purchase_queryset
|
||||||
|
|
||||||
|
form = PurchaseForm()
|
||||||
|
qs = (
|
||||||
|
related_purchase_queryset()
|
||||||
|
.filter(games__in=games)
|
||||||
|
.order_by("games__sort_name")
|
||||||
|
)
|
||||||
|
|
||||||
|
form.fields["related_purchase"].queryset = qs
|
||||||
|
first_option = qs.first()
|
||||||
|
if first_option:
|
||||||
|
form.fields["related_purchase"].initial = first_option.id
|
||||||
|
return HttpResponse(str(form["related_purchase"]))
|
||||||
|
else:
|
||||||
|
# abort swap
|
||||||
|
return HttpResponse(status=204)
|
||||||
|
|||||||
+47
-32
@@ -13,19 +13,15 @@ from django.utils.safestring import SafeText, mark_safe
|
|||||||
from common.components import (
|
from common.components import (
|
||||||
A,
|
A,
|
||||||
AddForm,
|
AddForm,
|
||||||
|
Button,
|
||||||
ButtonGroup,
|
ButtonGroup,
|
||||||
Div,
|
Div,
|
||||||
Fragment,
|
|
||||||
Icon,
|
Icon,
|
||||||
ModuleScript,
|
ModuleScript,
|
||||||
NameWithIcon,
|
NameWithIcon,
|
||||||
Node,
|
|
||||||
Popover,
|
Popover,
|
||||||
Safe,
|
|
||||||
SearchField,
|
SearchField,
|
||||||
SessionDeviceSelector,
|
SessionDeviceSelector,
|
||||||
SessionTimestampButtons,
|
|
||||||
StyledButton,
|
|
||||||
paginated_table_content,
|
paginated_table_content,
|
||||||
)
|
)
|
||||||
from common.components.primitives import Span, Td, Tr
|
from common.components.primitives import Span, Td, Tr
|
||||||
@@ -77,13 +73,13 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
|
|||||||
Div(
|
Div(
|
||||||
children=[
|
children=[
|
||||||
A(
|
A(
|
||||||
href=reverse("games:add_session"),
|
url_name="games:add_session",
|
||||||
)[
|
children=Button(
|
||||||
StyledButton(
|
|
||||||
icon=True,
|
icon=True,
|
||||||
size="xs",
|
size="xs",
|
||||||
)[Icon("play"), "LOG"]
|
children=[Icon("play"), "LOG"],
|
||||||
],
|
),
|
||||||
|
),
|
||||||
A(
|
A(
|
||||||
href=reverse(
|
href=reverse(
|
||||||
"games:list_sessions_start_session_from_session",
|
"games:list_sessions_start_session_from_session",
|
||||||
@@ -92,7 +88,7 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
|
|||||||
children=Popover(
|
children=Popover(
|
||||||
popover_content=last_session.game.name,
|
popover_content=last_session.game.name,
|
||||||
children=[
|
children=[
|
||||||
StyledButton(
|
Button(
|
||||||
icon=True,
|
icon=True,
|
||||||
color="gray",
|
color="gray",
|
||||||
size="xs",
|
size="xs",
|
||||||
@@ -180,11 +176,14 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
|
|||||||
preset_list_url=reverse("games:list_presets"),
|
preset_list_url=reverse("games:list_presets"),
|
||||||
preset_save_url=reverse("games:save_preset"),
|
preset_save_url=reverse("games:save_preset"),
|
||||||
)
|
)
|
||||||
content = Fragment(filter_bar, content)
|
content = mark_safe(str(filter_bar) + str(content))
|
||||||
return render_page(
|
return render_page(
|
||||||
request,
|
request,
|
||||||
content,
|
content,
|
||||||
title="Manage sessions",
|
title="Manage sessions",
|
||||||
|
scripts=ModuleScript("range_slider.js")
|
||||||
|
+ ModuleScript("search_select.js")
|
||||||
|
+ ModuleScript("filter_bar.js"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -193,39 +192,51 @@ def search_sessions(request: HttpRequest) -> HttpResponse:
|
|||||||
return list_sessions(request, search_string=request.GET.get("search_string", ""))
|
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.
|
"""Manual per-field layout for the session form.
|
||||||
|
|
||||||
Mirrors the old add_session.html: each field gets its label and widget,
|
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.
|
and the timestamp fields gain a row of now/toggle/copy helper buttons.
|
||||||
"""
|
"""
|
||||||
rows: list[Node] = []
|
rows: list[SafeText] = []
|
||||||
for field in form:
|
for field in form:
|
||||||
children: list[Node | str] = [
|
children: list[SafeText | str] = [
|
||||||
Safe(str(field.label_tag())),
|
mark_safe(str(field.label_tag())),
|
||||||
Safe(str(field)),
|
mark_safe(str(field)),
|
||||||
]
|
]
|
||||||
if field.name in ("timestamp_start", "timestamp_end"):
|
if field.name in ("timestamp_start", "timestamp_end"):
|
||||||
this_side = "start" if field.name == "timestamp_start" else "end"
|
this_side = "start" if field.name == "timestamp_start" else "end"
|
||||||
other_side = "end" if field.name == "timestamp_start" else "start"
|
other_side = "end" if field.name == "timestamp_start" else "start"
|
||||||
children.append(
|
children.append(
|
||||||
SessionTimestampButtons(
|
Span(
|
||||||
class_="form-row-button-group flex-row gap-3 justify-start mt-3",
|
attributes=[
|
||||||
hx_boost="false",
|
(
|
||||||
)[
|
"class",
|
||||||
StyledButton(data_target=field.name, data_type="now", size="xs")[
|
"form-row-button-group flex-row gap-3 justify-start mt-3",
|
||||||
"Set to now"
|
),
|
||||||
|
("hx-boost", "false"),
|
||||||
],
|
],
|
||||||
StyledButton(data_target=field.name, data_type="toggle", size="xs")[
|
children=[
|
||||||
"Toggle text"
|
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))
|
rows.append(Div(children=children))
|
||||||
return Fragment(*rows, separator="\n")
|
return mark_safe("\n".join(rows))
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@@ -254,7 +265,9 @@ def add_session(request: HttpRequest, game_id: int = 0) -> HttpResponse:
|
|||||||
request,
|
request,
|
||||||
AddForm(form, request=request, fields=_session_fields(form), submit_class=""),
|
AddForm(form, request=request, fields=_session_fields(form), submit_class=""),
|
||||||
title="Add New Session",
|
title="Add New Session",
|
||||||
scripts=mark_safe(ModuleScript("search_select.js")),
|
scripts=mark_safe(
|
||||||
|
ModuleScript("search_select.js") + ModuleScript("add_session.js")
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -269,7 +282,9 @@ def edit_session(request: HttpRequest, session_id: int) -> HttpResponse:
|
|||||||
request,
|
request,
|
||||||
AddForm(form, request=request, fields=_session_fields(form), submit_class=""),
|
AddForm(form, request=request, fields=_session_fields(form), submit_class=""),
|
||||||
title="Edit Session",
|
title="Edit Session",
|
||||||
scripts=mark_safe(ModuleScript("search_select.js")),
|
scripts=mark_safe(
|
||||||
|
ModuleScript("search_select.js") + ModuleScript("add_session.js")
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,19 +9,9 @@ from django.template.defaultfilters import date as date_filter
|
|||||||
from django.template.defaultfilters import floatformat
|
from django.template.defaultfilters import floatformat
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.html import conditional_escape
|
from django.utils.html import conditional_escape
|
||||||
|
from django.utils.safestring import SafeText, mark_safe
|
||||||
|
|
||||||
from common.components import (
|
from common.components import A, Component, Div, GameLink, YearPicker
|
||||||
A,
|
|
||||||
Div,
|
|
||||||
Element,
|
|
||||||
GameLink,
|
|
||||||
Node,
|
|
||||||
Safe,
|
|
||||||
Td,
|
|
||||||
Th,
|
|
||||||
Tr,
|
|
||||||
YearPicker,
|
|
||||||
)
|
|
||||||
from common.time import durationformat, format_duration
|
from common.time import durationformat, format_duration
|
||||||
|
|
||||||
_CELL = "px-2 sm:px-4 md:px-6 md:py-2"
|
_CELL = "px-2 sm:px-4 md:px-6 md:py-2"
|
||||||
@@ -29,40 +19,41 @@ _CELL_MONO = f"{_CELL} font-mono"
|
|||||||
_NAME_TH = f"{_CELL} purchase-name truncate max-w-20char"
|
_NAME_TH = f"{_CELL} purchase-name truncate max-w-20char"
|
||||||
|
|
||||||
|
|
||||||
def _td(children, cls: str = _CELL_MONO) -> Node:
|
def _td(children, cls: str = _CELL_MONO) -> SafeText:
|
||||||
if not isinstance(children, list):
|
if not isinstance(children, list):
|
||||||
children = [children]
|
children = [children]
|
||||||
return Td(attributes=[("class", cls)], children=children)
|
children = [c if isinstance(c, (str, SafeText)) else str(c) for c in children]
|
||||||
|
return Component(tag_name="td", attributes=[("class", cls)], children=children)
|
||||||
|
|
||||||
|
|
||||||
def _th(text: str, cls: str = _CELL) -> Node:
|
def _th(text: str, cls: str = _CELL) -> SafeText:
|
||||||
return Th(attributes=[("class", cls)], children=[text])
|
return Component(tag_name="th", attributes=[("class", cls)], children=[text])
|
||||||
|
|
||||||
|
|
||||||
def _tr(cells: list) -> Node:
|
def _tr(cells: list) -> SafeText:
|
||||||
return Tr(children=cells)
|
return Component(tag_name="tr", children=cells)
|
||||||
|
|
||||||
|
|
||||||
def _kv(label, value) -> Node:
|
def _kv(label, value) -> SafeText:
|
||||||
"""A label/value row: plain label cell + mono value cell."""
|
"""A label/value row: plain label cell + mono value cell."""
|
||||||
return _tr([_td(label, _CELL), _td(value)])
|
return _tr([_td(label, _CELL), _td(value)])
|
||||||
|
|
||||||
|
|
||||||
def _h1(title: str) -> Node:
|
def _h1(title: str) -> SafeText:
|
||||||
return Element(
|
return Component(
|
||||||
"h1",
|
tag_name="h1",
|
||||||
attributes=[("class", "text-3xl text-heading text-center my-6")],
|
attributes=[("class", "text-3xl text-heading text-center my-6")],
|
||||||
children=[title],
|
children=[title],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _table(rows: list, thead: Node | None = None) -> Node:
|
def _table(rows: list, thead: SafeText | None = None) -> SafeText:
|
||||||
children = []
|
children = []
|
||||||
if thead is not None:
|
if thead is not None:
|
||||||
children.append(thead)
|
children.append(thead)
|
||||||
children.append(Element("tbody", children=rows))
|
children.append(Component(tag_name="tbody", children=rows))
|
||||||
return Element(
|
return Component(
|
||||||
"table",
|
tag_name="table",
|
||||||
attributes=[("class", "responsive-table")],
|
attributes=[("class", "responsive-table")],
|
||||||
children=children,
|
children=children,
|
||||||
)
|
)
|
||||||
@@ -72,7 +63,7 @@ def _dur(value) -> str:
|
|||||||
return format_duration(value, durationformat)
|
return format_duration(value, durationformat)
|
||||||
|
|
||||||
|
|
||||||
def _purchase_name(purchase) -> Node:
|
def _purchase_name(purchase) -> SafeText:
|
||||||
"""Mirror of the `purchase-name` partial in the old template."""
|
"""Mirror of the `purchase-name` partial in the old template."""
|
||||||
game_name = getattr(purchase, "game_name", None)
|
game_name = getattr(purchase, "game_name", None)
|
||||||
first_game = purchase.first_game
|
first_game = purchase.first_game
|
||||||
@@ -80,12 +71,12 @@ def _purchase_name(purchase) -> Node:
|
|||||||
name = game_name or purchase.name
|
name = game_name or purchase.name
|
||||||
link = GameLink(first_game.id, name)
|
link = GameLink(first_game.id, name)
|
||||||
suffix = f" ({first_game.name} {purchase.get_type_display()})"
|
suffix = f" ({first_game.name} {purchase.get_type_display()})"
|
||||||
return Safe(str(link) + conditional_escape(suffix))
|
return mark_safe(str(link) + conditional_escape(suffix))
|
||||||
name = game_name or first_game.name
|
name = game_name or first_game.name
|
||||||
return GameLink(first_game.id, name)
|
return GameLink(first_game.id, name)
|
||||||
|
|
||||||
|
|
||||||
def _year_nav(year, year_range, url_template) -> Node:
|
def _year_nav(year, year_range, url_template) -> SafeText:
|
||||||
# `year` is an int for a specific year, or "Alltime" (from compute_stats)
|
# `year` is an int for a specific year, or "Alltime" (from compute_stats)
|
||||||
# for the all-time view. Normalize to int-or-None so nothing downstream has
|
# for the all-time view. Normalize to int-or-None so nothing downstream has
|
||||||
# to know about the "Alltime" sentinel.
|
# to know about the "Alltime" sentinel.
|
||||||
@@ -101,9 +92,10 @@ def _year_nav(year, year_range, url_template) -> Node:
|
|||||||
else "text-body hover:text-heading underline decoration-dotted"
|
else "text-body hover:text-heading underline decoration-dotted"
|
||||||
)
|
)
|
||||||
alltime_btn = A(
|
alltime_btn = A(
|
||||||
href=reverse("games:stats_alltime"),
|
url_name="games:stats_alltime",
|
||||||
class_=alltime_classes,
|
attributes=[("class", alltime_classes)],
|
||||||
)["All-time stats"]
|
children=["All-time stats"],
|
||||||
|
)
|
||||||
picker = YearPicker(
|
picker = YearPicker(
|
||||||
year=year_int,
|
year=year_int,
|
||||||
available_years=tuple(year_range or []),
|
available_years=tuple(year_range or []),
|
||||||
@@ -115,7 +107,7 @@ def _year_nav(year, year_range, url_template) -> Node:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _playtime_table(ctx) -> Node:
|
def _playtime_table(ctx) -> SafeText:
|
||||||
year = ctx.get("year")
|
year = ctx.get("year")
|
||||||
rows = [
|
rows = [
|
||||||
_kv("Hours", ctx.get("total_hours")),
|
_kv("Hours", ctx.get("total_hours")),
|
||||||
@@ -194,7 +186,7 @@ def _playtime_table(ctx) -> Node:
|
|||||||
return _table(rows)
|
return _table(rows)
|
||||||
|
|
||||||
|
|
||||||
def _purchases_table(ctx) -> Node:
|
def _purchases_table(ctx) -> SafeText:
|
||||||
rows = [
|
rows = [
|
||||||
_kv("Total", ctx.get("all_purchased_this_year_count")),
|
_kv("Total", ctx.get("all_purchased_this_year_count")),
|
||||||
_kv(
|
_kv(
|
||||||
@@ -221,18 +213,18 @@ def _purchases_table(ctx) -> Node:
|
|||||||
return _table(rows)
|
return _table(rows)
|
||||||
|
|
||||||
|
|
||||||
def _two_col_table(header: str, items, name_key, value_fn) -> Node:
|
def _two_col_table(header: str, items, name_key, value_fn) -> SafeText:
|
||||||
thead = Element(
|
thead = Component(
|
||||||
"thead",
|
tag_name="thead",
|
||||||
children=[_tr([_th(header), _th("Playtime")])],
|
children=[_tr([_th(header), _th("Playtime")])],
|
||||||
)
|
)
|
||||||
rows = [_tr([_td(name_key(item)), _td(value_fn(item))]) for item in items]
|
rows = [_tr([_td(name_key(item)), _td(value_fn(item))]) for item in items]
|
||||||
return _table(rows, thead)
|
return _table(rows, thead)
|
||||||
|
|
||||||
|
|
||||||
def _finished_table(purchases) -> Node:
|
def _finished_table(purchases) -> SafeText:
|
||||||
thead = Element(
|
thead = Component(
|
||||||
"thead",
|
tag_name="thead",
|
||||||
children=[_tr([_th("Name", _NAME_TH), _th("Date")])],
|
children=[_tr([_th("Name", _NAME_TH), _th("Date")])],
|
||||||
)
|
)
|
||||||
rows = [
|
rows = [
|
||||||
@@ -242,9 +234,9 @@ def _finished_table(purchases) -> Node:
|
|||||||
return _table(rows, thead)
|
return _table(rows, thead)
|
||||||
|
|
||||||
|
|
||||||
def _priced_table(purchases, currency) -> Node:
|
def _priced_table(purchases, currency) -> SafeText:
|
||||||
thead = Element(
|
thead = Component(
|
||||||
"thead",
|
tag_name="thead",
|
||||||
children=[
|
children=[
|
||||||
_tr([_th("Name", _NAME_TH), _th(f"Price ({currency})"), _th("Date")])
|
_tr([_th("Name", _NAME_TH), _th(f"Price ({currency})"), _th("Date")])
|
||||||
],
|
],
|
||||||
@@ -262,7 +254,7 @@ def _priced_table(purchases, currency) -> Node:
|
|||||||
return _table(rows, thead)
|
return _table(rows, thead)
|
||||||
|
|
||||||
|
|
||||||
def stats_content(ctx: dict) -> Node:
|
def stats_content(ctx: dict) -> SafeText:
|
||||||
year = ctx.get("year")
|
year = ctx.get("year")
|
||||||
currency = ctx.get("total_spent_currency")
|
currency = ctx.get("total_spent_currency")
|
||||||
# Build a navigation URL with an `__year__` placeholder the picker's JS
|
# Build a navigation URL with an `__year__` placeholder the picker's JS
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ from django.utils.safestring import SafeText
|
|||||||
from common.components import (
|
from common.components import (
|
||||||
A,
|
A,
|
||||||
AddForm,
|
AddForm,
|
||||||
|
Button,
|
||||||
|
Component,
|
||||||
CsrfInput,
|
CsrfInput,
|
||||||
Div,
|
Div,
|
||||||
Element,
|
|
||||||
StyledButton,
|
|
||||||
paginated_table_content,
|
paginated_table_content,
|
||||||
)
|
)
|
||||||
from common.components.primitives import P
|
from common.components.primitives import P
|
||||||
@@ -79,18 +79,18 @@ def _delete_statuschange_content(statuschange, request: HttpRequest) -> SafeText
|
|||||||
P(
|
P(
|
||||||
children=["Are you sure you want to delete this status change?"],
|
children=["Are you sure you want to delete this status change?"],
|
||||||
),
|
),
|
||||||
StyledButton(
|
Button(
|
||||||
[("class", "w-full")], "Delete", color="red", type="submit", size="lg"
|
[("class", "w-full")], "Delete", color="red", type="submit", size="lg"
|
||||||
),
|
),
|
||||||
A(
|
A(
|
||||||
[("class", "")],
|
[("class", "")],
|
||||||
StyledButton([("class", "w-full")], "Cancel", color="gray"),
|
Button([("class", "w-full")], "Cancel", color="gray"),
|
||||||
href=reverse("games:view_game", args=[statuschange.game.id]),
|
href=reverse("games:view_game", args=[statuschange.game.id]),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
form = Element(
|
form = Component(
|
||||||
"form",
|
tag_name="form",
|
||||||
attributes=[("method", "post"), ("class", "dark:text-white")],
|
attributes=[("method", "post"), ("class", "dark:text-white")],
|
||||||
children=[CsrfInput(request), inner],
|
children=[CsrfInput(request), inner],
|
||||||
)
|
)
|
||||||
|
|||||||
+1
-3
@@ -1,12 +1,10 @@
|
|||||||
{
|
{
|
||||||
"packageManager": "pnpm@10.33.0",
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/forms": "^0.5.7",
|
"@tailwindcss/forms": "^0.5.7",
|
||||||
"@tailwindcss/typography": "^0.5.13",
|
"@tailwindcss/typography": "^0.5.13",
|
||||||
"concurrently": "^8.2.2",
|
"concurrently": "^8.2.2",
|
||||||
"npm-check-updates": "^16.14.20",
|
"npm-check-updates": "^16.14.20",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18"
|
||||||
"typescript": "^5.6.0"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/cli": "^4.1.18",
|
"@tailwindcss/cli": "^4.1.18",
|
||||||
|
|||||||
Generated
-3358
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -1,2 +1,2 @@
|
|||||||
overrides:
|
allowBuilds:
|
||||||
tar: ^7.5.11
|
'@parcel/watcher': false
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
# Alternative to a .env file for non-Docker / bare-metal deployments.
|
|
||||||
# Copy to settings.ini (next to manage.py) or point INI_FILE at it.
|
|
||||||
# Real environment variables and a .env file both take precedence over this.
|
|
||||||
# See docs/configuration.md for the full reference.
|
|
||||||
|
|
||||||
[timetracker]
|
|
||||||
DEBUG = false
|
|
||||||
SECRET_KEY = change-me-to-a-long-random-string
|
|
||||||
APP_URL = https://tracker.kucharczyk.xyz
|
|
||||||
TZ = Europe/Prague
|
|
||||||
DATA_DIR = /var/lib/timetracker
|
|
||||||
|
|
||||||
# Optional explicit overrides (comma-separated); win over APP_URL when set.
|
|
||||||
# ALLOWED_HOSTS = *
|
|
||||||
# CSRF_TRUSTED_ORIGINS = https://tracker.kucharczyk.xyz
|
|
||||||
@@ -11,12 +11,6 @@ pkgs.mkShell {
|
|||||||
pnpm
|
pnpm
|
||||||
];
|
];
|
||||||
|
|
||||||
# manylinux wheels with native extensions (greenlet, pulled in by
|
|
||||||
# pytest-playwright) link against libstdc++.so.6, which the nixpkgs
|
|
||||||
# Python cannot find on its default search path. Scoped to this dev
|
|
||||||
# shell only — a global LD_LIBRARY_PATH would leak into other programs.
|
|
||||||
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ pkgs.stdenv.cc.cc.lib ];
|
|
||||||
|
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
uv venv --clear
|
uv venv --clear
|
||||||
. .venv/bin/activate
|
. .venv/bin/activate
|
||||||
|
|||||||
+156
-213
@@ -2,29 +2,21 @@ import unittest
|
|||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import django
|
import django
|
||||||
from django.test import SimpleTestCase
|
|
||||||
from django.utils.safestring import SafeText, mark_safe
|
from django.utils.safestring import SafeText, mark_safe
|
||||||
|
|
||||||
from common import components
|
from common import components
|
||||||
from games.models import Game, Platform, Purchase, Session
|
from games.models import Platform, Game, Purchase, Session
|
||||||
|
|
||||||
# Component builders return lazy ``Node`` objects; these tests assert on rendered
|
|
||||||
# HTML, so node-returning calls are wrapped in ``str(...)`` at the call site
|
|
||||||
# (``Node.__str__`` returns a ``SafeText``). Non-node helpers (``randomid``,
|
|
||||||
# ``_resolve_name_with_icon``, ``_render_element``) are called
|
|
||||||
# directly.
|
|
||||||
|
|
||||||
|
|
||||||
class ComponentIntegrationTest(unittest.TestCase):
|
class ComponentIntegrationTest(unittest.TestCase):
|
||||||
"""Test Element() renders correctly with caching transparent."""
|
"""Test Component() works correctly with caching transparent."""
|
||||||
|
|
||||||
def test_tag_name_component(self):
|
def test_tag_name_component(self):
|
||||||
result = str(
|
result = components.Component(
|
||||||
components.Element(
|
tag_name="div",
|
||||||
tag_name="div",
|
attributes=[("class", "test")],
|
||||||
attributes=[("class", "test")],
|
children="hello",
|
||||||
children="hello",
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
self.assertEqual(result, '<div class="test">hello</div>')
|
self.assertEqual(result, '<div class="test">hello</div>')
|
||||||
|
|
||||||
@@ -36,17 +28,9 @@ class ComponentCacheTest(unittest.TestCase):
|
|||||||
components._render_element.cache_clear()
|
components._render_element.cache_clear()
|
||||||
|
|
||||||
def test_identical_components_hit_cache(self):
|
def test_identical_components_hit_cache(self):
|
||||||
str(
|
components.Component(tag_name="div", attributes=[("class", "x")], children="hi")
|
||||||
components.Element(
|
|
||||||
tag_name="div", attributes=[("class", "x")], children="hi"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
misses = components._render_element.cache_info().misses
|
misses = components._render_element.cache_info().misses
|
||||||
str(
|
components.Component(tag_name="div", attributes=[("class", "x")], children="hi")
|
||||||
components.Element(
|
|
||||||
tag_name="div", attributes=[("class", "x")], children="hi"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
info = components._render_element.cache_info()
|
info = components._render_element.cache_info()
|
||||||
self.assertEqual(info.misses, misses) # no new miss
|
self.assertEqual(info.misses, misses) # no new miss
|
||||||
self.assertGreaterEqual(info.hits, 1) # served from cache
|
self.assertGreaterEqual(info.hits, 1) # served from cache
|
||||||
@@ -55,12 +39,10 @@ class ComponentCacheTest(unittest.TestCase):
|
|||||||
self.assertEqual(components._render_element.cache_parameters()["maxsize"], 4096)
|
self.assertEqual(components._render_element.cache_parameters()["maxsize"], 4096)
|
||||||
|
|
||||||
def test_safe_and_unsafe_children_do_not_collide(self):
|
def test_safe_and_unsafe_children_do_not_collide(self):
|
||||||
"""A Safe-node ``<b>`` and a plain-string ``<b>`` render differently —
|
"""A SafeText "<b>" and a plain "<b>" are equal as strings but must
|
||||||
the cache key must keep them distinct."""
|
render differently — the cache key must keep them distinct."""
|
||||||
safe = str(
|
safe = components.Component(tag_name="span", children=[mark_safe("<b>x</b>")])
|
||||||
components.Element(tag_name="span", children=[components.Safe("<b>x</b>")])
|
unsafe = components.Component(tag_name="span", children=["<b>x</b>"])
|
||||||
)
|
|
||||||
unsafe = str(components.Element(tag_name="span", children=["<b>x</b>"]))
|
|
||||||
self.assertIn("<b>x</b>", safe)
|
self.assertIn("<b>x</b>", safe)
|
||||||
self.assertIn("<b>x</b>", unsafe)
|
self.assertIn("<b>x</b>", unsafe)
|
||||||
self.assertNotEqual(safe, unsafe)
|
self.assertNotEqual(safe, unsafe)
|
||||||
@@ -132,37 +114,33 @@ class PopoverDeterministicTest(unittest.TestCase):
|
|||||||
"""Test that Popover() produces deterministic HTML output."""
|
"""Test that Popover() produces deterministic HTML output."""
|
||||||
|
|
||||||
def test_same_popover_same_id(self):
|
def test_same_popover_same_id(self):
|
||||||
r1 = str(components.Popover("hello", wrapped_content="hello"))
|
r1 = components.Popover("hello", wrapped_content="hello")
|
||||||
r2 = str(components.Popover("hello", wrapped_content="hello"))
|
r2 = components.Popover("hello", wrapped_content="hello")
|
||||||
self.assertEqual(r1, r2)
|
self.assertEqual(r1, r2)
|
||||||
|
|
||||||
def test_different_content_different_id(self):
|
def test_different_content_different_id(self):
|
||||||
r1 = str(components.Popover("content_a", wrapped_content="content_a"))
|
r1 = components.Popover("content_a", wrapped_content="content_a")
|
||||||
r2 = str(components.Popover("content_b", wrapped_content="content_b"))
|
r2 = components.Popover("content_b", wrapped_content="content_b")
|
||||||
self.assertNotEqual(r1, r2)
|
self.assertNotEqual(r1, r2)
|
||||||
|
|
||||||
def test_wrapped_classes_affect_id(self):
|
def test_wrapped_classes_affect_id(self):
|
||||||
r1 = str(
|
r1 = components.Popover("c", wrapped_content="c", wrapped_classes="class_x")
|
||||||
components.Popover("c", wrapped_content="c", wrapped_classes="class_x")
|
r2 = components.Popover("c", wrapped_content="c", wrapped_classes="class_y")
|
||||||
)
|
|
||||||
r2 = str(
|
|
||||||
components.Popover("c", wrapped_content="c", wrapped_classes="class_y")
|
|
||||||
)
|
|
||||||
self.assertNotEqual(r1, r2)
|
self.assertNotEqual(r1, r2)
|
||||||
|
|
||||||
def test_wrapped_content_affects_id(self):
|
def test_wrapped_content_affects_id(self):
|
||||||
r1 = str(components.Popover("popover", wrapped_content="wrapped_a"))
|
r1 = components.Popover("popover", wrapped_content="wrapped_a")
|
||||||
r2 = str(components.Popover("popover", wrapped_content="wrapped_b"))
|
r2 = components.Popover("popover", wrapped_content="wrapped_b")
|
||||||
self.assertNotEqual(r1, r2)
|
self.assertNotEqual(r1, r2)
|
||||||
|
|
||||||
def test_popover_content_affects_id(self):
|
def test_popover_content_affects_id(self):
|
||||||
r1 = str(components.Popover("popover_a", wrapped_content="wrapped"))
|
r1 = components.Popover("popover_a", wrapped_content="wrapped")
|
||||||
r2 = str(components.Popover("popover_b", wrapped_content="wrapped"))
|
r2 = components.Popover("popover_b", wrapped_content="wrapped")
|
||||||
self.assertNotEqual(r1, r2)
|
self.assertNotEqual(r1, r2)
|
||||||
|
|
||||||
def test_full_html_deterministic(self):
|
def test_full_html_deterministic(self):
|
||||||
r1 = str(components.Popover("hello world", wrapped_content="hello world"))
|
r1 = components.Popover("hello world", wrapped_content="hello world")
|
||||||
r2 = str(components.Popover("hello world", wrapped_content="hello world"))
|
r2 = components.Popover("hello world", wrapped_content="hello world")
|
||||||
self.assertEqual(r1.encode(), r2.encode())
|
self.assertEqual(r1.encode(), r2.encode())
|
||||||
|
|
||||||
|
|
||||||
@@ -202,50 +180,63 @@ class ComponentReturnTypeTest(unittest.TestCase):
|
|||||||
"""Test that component functions return SafeText and render correctly."""
|
"""Test that component functions return SafeText and render correctly."""
|
||||||
|
|
||||||
def test_div_returns_safe_text(self):
|
def test_div_returns_safe_text(self):
|
||||||
result = str(components.Div([("class", "x")], "hello"))
|
result = components.Div([("class", "x")], "hello")
|
||||||
self.assertIsInstance(result, SafeText)
|
self.assertIsInstance(result, SafeText)
|
||||||
|
|
||||||
def test_div_deterministic(self):
|
def test_div_deterministic(self):
|
||||||
r1 = str(components.Div([("class", "x")], "hello"))
|
r1 = components.Div([("class", "x")], "hello")
|
||||||
r2 = str(components.Div([("class", "x")], "hello"))
|
r2 = components.Div([("class", "x")], "hello")
|
||||||
self.assertEqual(r1, r2)
|
self.assertEqual(r1, r2)
|
||||||
self.assertIn('<div class="x">hello</div>', r1)
|
self.assertIn('<div class="x">hello</div>', r1)
|
||||||
|
|
||||||
def test_div_no_args(self):
|
def test_div_no_args(self):
|
||||||
result = str(components.Div(children="test"))
|
result = components.Div(children="test")
|
||||||
self.assertIsInstance(result, SafeText)
|
self.assertIsInstance(result, SafeText)
|
||||||
self.assertIn("<div>test</div>", result)
|
self.assertIn("<div>test</div>", result)
|
||||||
|
|
||||||
def test_a_returns_safe_text(self):
|
def test_a_returns_safe_text(self):
|
||||||
result = str(components.A([], "link"))
|
result = components.A([], "link")
|
||||||
self.assertIsInstance(result, SafeText)
|
self.assertIsInstance(result, SafeText)
|
||||||
|
|
||||||
def test_a_literal_href(self):
|
def test_a_literal_href(self):
|
||||||
result = str(components.A([], "x", href="/literal/path"))
|
result = components.A([], "x", href="/literal/path")
|
||||||
self.assertIn('href="/literal/path"', result)
|
self.assertIn('href="/literal/path"', result)
|
||||||
|
|
||||||
|
def test_a_url_name_reversed(self):
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"common.components.primitives.reverse", return_value="/resolved/url"
|
||||||
|
):
|
||||||
|
result = components.A([], "link", url_name="some_name")
|
||||||
|
self.assertIn('href="/resolved/url"', result)
|
||||||
|
|
||||||
def test_a_no_url_or_href(self):
|
def test_a_no_url_or_href(self):
|
||||||
result = str(components.A([], "link"))
|
result = components.A([], "link")
|
||||||
self.assertIn("<a>link</a>", result)
|
self.assertIn("<a>link</a>", result)
|
||||||
self.assertNotIn("href=", result)
|
self.assertNotIn("href=", result)
|
||||||
|
|
||||||
|
def test_a_both_url_name_and_href_raises(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
components.A(href="/path", url_name="some_name")
|
||||||
|
|
||||||
def test_button_returns_safe_text(self):
|
def test_button_returns_safe_text(self):
|
||||||
result = str(components.StyledButton([], "click"))
|
result = components.Button([], "click")
|
||||||
self.assertIsInstance(result, SafeText)
|
self.assertIsInstance(result, SafeText)
|
||||||
self.assertIn("<button", result)
|
self.assertIn("<button", result)
|
||||||
|
|
||||||
def test_button_default_colors(self):
|
def test_button_default_colors(self):
|
||||||
result = str(components.StyledButton([], "click"))
|
result = components.Button([], "click")
|
||||||
self.assertIn("text-white bg-brand", result)
|
self.assertIn("text-white bg-brand", result)
|
||||||
|
|
||||||
def test_name_with_icon_no_link(self):
|
def test_name_with_icon_no_link(self):
|
||||||
result = str(components.NameWithIcon(name="Game", linkify=False))
|
result = components.NameWithIcon(name="Game", linkify=False)
|
||||||
self.assertIsInstance(result, SafeText)
|
self.assertIsInstance(result, SafeText)
|
||||||
self.assertIn("Game", result)
|
self.assertIn("Game", result)
|
||||||
self.assertNotIn("<a ", result)
|
self.assertNotIn("<a ", result)
|
||||||
|
|
||||||
def test_name_with_icon_no_trailing_comma(self):
|
def test_name_with_icon_no_trailing_comma(self):
|
||||||
result = str(components.NameWithIcon(name="Test", linkify=False))
|
result = components.NameWithIcon(name="Test", linkify=False)
|
||||||
self.assertIsInstance(result, SafeText)
|
self.assertIsInstance(result, SafeText)
|
||||||
self.assertNotIsInstance(result, tuple)
|
self.assertNotIsInstance(result, tuple)
|
||||||
|
|
||||||
@@ -255,23 +246,21 @@ class ComponentOutputIsNotEscapedTest(unittest.TestCase):
|
|||||||
|
|
||||||
def test_component_output_starts_with_tag(self):
|
def test_component_output_starts_with_tag(self):
|
||||||
for label, html in [
|
for label, html in [
|
||||||
("A", str(components.A(href="/foo", children=["link"]))),
|
("A", components.A(href="/foo", children=["link"])),
|
||||||
("Button", str(components.StyledButton([], "click"))),
|
("Button", components.Button([], "click")),
|
||||||
("Div", str(components.Div([], ["hello"]))),
|
("Div", components.Div([], ["hello"])),
|
||||||
("Input", str(components.Input())),
|
("Input", components.Input()),
|
||||||
("ButtonGroup", str(components.ButtonGroup([]))),
|
("ButtonGroup", components.ButtonGroup([])),
|
||||||
(
|
(
|
||||||
"ButtonGroup with buttons",
|
"ButtonGroup with buttons",
|
||||||
str(
|
components.ButtonGroup(
|
||||||
components.ButtonGroup(
|
[{"href": "/", "slot": components.Icon("edit")}]
|
||||||
[{"href": "/", "slot": components.Icon("edit")}]
|
|
||||||
)
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
("SearchField", str(components.SearchField())),
|
("SearchField", components.SearchField()),
|
||||||
("PriceConverted", str(components.PriceConverted(["27 CZK"]))),
|
("PriceConverted", components.PriceConverted(["27 CZK"])),
|
||||||
("H1", str(components.H1(["Title"]))),
|
("H1", components.H1(["Title"])),
|
||||||
("H1 with badge", str(components.H1(["Title"], badge="3"))),
|
("H1 with badge", components.H1(["Title"], badge="3")),
|
||||||
]:
|
]:
|
||||||
with self.subTest(component=label):
|
with self.subTest(component=label):
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
@@ -280,112 +269,90 @@ class ComponentOutputIsNotEscapedTest(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_button_with_icon_children_not_escaped(self):
|
def test_button_with_icon_children_not_escaped(self):
|
||||||
result = str(
|
result = components.Button(
|
||||||
components.StyledButton(
|
icon=True,
|
||||||
icon=True,
|
size="xs",
|
||||||
size="xs",
|
children=[components.Icon("play"), "LOG"],
|
||||||
children=[components.Icon("play"), "LOG"],
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
self.assertTrue(str(result).startswith("<button"))
|
self.assertTrue(str(result).startswith("<button"))
|
||||||
|
|
||||||
def test_popover_with_button_children_not_escaped(self):
|
def test_popover_with_button_children_not_escaped(self):
|
||||||
result = str(
|
result = components.Popover(
|
||||||
components.Popover(
|
popover_content="test tooltip",
|
||||||
popover_content="test tooltip",
|
children=[
|
||||||
children=[
|
components.Button(
|
||||||
components.StyledButton(
|
icon=True,
|
||||||
icon=True,
|
color="gray",
|
||||||
color="gray",
|
size="xs",
|
||||||
size="xs",
|
children=[components.Icon("play"), "test"],
|
||||||
children=[components.Icon("play"), "test"],
|
),
|
||||||
),
|
],
|
||||||
],
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
self.assertTrue(str(result).startswith("<span data-popover-target"))
|
self.assertTrue(str(result).startswith("<span data-popover-target"))
|
||||||
|
|
||||||
def test_name_with_icon_output_not_escaped(self):
|
def test_name_with_icon_output_not_escaped(self):
|
||||||
result = str(components.NameWithIcon(name="Test", linkify=False))
|
result = components.NameWithIcon(name="Test", linkify=False)
|
||||||
self.assertTrue(str(result).startswith("<div"))
|
self.assertTrue(str(result).startswith("<div"))
|
||||||
|
|
||||||
|
|
||||||
class ComponentEdgeCasesTest(unittest.TestCase):
|
class ComponentEdgeCasesTest(unittest.TestCase):
|
||||||
"""Test Element() edge cases and error handling."""
|
"""Test Component() edge cases and error handling."""
|
||||||
|
|
||||||
def test_no_tag_name_raises(self):
|
def test_no_tag_name_raises(self):
|
||||||
with self.assertRaises(ValueError) as ctx:
|
with self.assertRaises(ValueError) as ctx:
|
||||||
str(components.Element("", children="hello"))
|
components.Component(children="hello")
|
||||||
self.assertIn("tag_name", str(ctx.exception))
|
self.assertIn("tag_name", str(ctx.exception))
|
||||||
|
|
||||||
def test_single_string_children_wrapped(self):
|
def test_single_string_children_wrapped(self):
|
||||||
result = str(components.Element(tag_name="span", children="hello"))
|
result = components.Component(tag_name="span", children="hello")
|
||||||
self.assertIn("hello", result)
|
self.assertIn("hello", result)
|
||||||
|
|
||||||
def test_multiple_children_joined_with_newlines(self):
|
def test_multiple_children_joined_with_newlines(self):
|
||||||
result = str(components.Element(tag_name="div", children=["hello", "world"]))
|
result = components.Component(tag_name="div", children=["hello", "world"])
|
||||||
self.assertIn("hello\nworld", result)
|
self.assertIn("hello\nworld", result)
|
||||||
self.assertIn("<div>", result)
|
self.assertIn("<div>", result)
|
||||||
self.assertIn("</div>", result)
|
self.assertIn("</div>", result)
|
||||||
|
|
||||||
def test_raw_html_children_are_escaped(self):
|
def test_raw_html_children_are_escaped(self):
|
||||||
result = str(
|
result = components.Component(
|
||||||
components.Element(
|
tag_name="div", children=["<script>alert('xss')</script>"]
|
||||||
tag_name="div", children=["<script>alert('xss')</script>"]
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
self.assertNotIn("<script>", result)
|
self.assertNotIn("<script>", result)
|
||||||
self.assertIn("<script>", result)
|
self.assertIn("<script>", result)
|
||||||
|
|
||||||
def test_safe_node_children_pass_through(self):
|
def test_mark_safe_children_pass_through(self):
|
||||||
result = str(
|
result = components.Component(
|
||||||
components.Element(
|
tag_name="div", children=[mark_safe("<span>safe</span>")]
|
||||||
tag_name="div", children=[components.Safe("<span>safe</span>")]
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
self.assertIn("<span>safe</span>", result)
|
self.assertIn("<span>safe</span>", result)
|
||||||
|
|
||||||
def test_mark_safe_string_children_are_escaped(self):
|
|
||||||
# Trusted markup must be a Safe node; a mark_safe string is still a
|
|
||||||
# string, so it is escaped like any other text child.
|
|
||||||
result = str(
|
|
||||||
components.Element(
|
|
||||||
tag_name="div", children=[mark_safe("<span>safe</span>")]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
self.assertIn("<span>safe</span>", result)
|
|
||||||
|
|
||||||
def test_attribute_values_are_escaped(self):
|
def test_attribute_values_are_escaped(self):
|
||||||
result = str(
|
result = components.Component(
|
||||||
components.Element(
|
tag_name="div",
|
||||||
tag_name="div",
|
attributes=[("data-x", 'foo"bar')],
|
||||||
attributes=[("data-x", 'foo"bar')],
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
self.assertIn(""", result)
|
self.assertIn(""", result)
|
||||||
self.assertNotIn('"foo"bar"', result)
|
self.assertNotIn('"foo"bar"', result)
|
||||||
|
|
||||||
def test_attributes_serialized_correctly(self):
|
def test_attributes_serialized_correctly(self):
|
||||||
result = str(
|
result = components.Component(
|
||||||
components.Element(
|
tag_name="div", attributes=[("class", "foo"), ("id", "bar")]
|
||||||
tag_name="div", attributes=[("class", "foo"), ("id", "bar")]
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
self.assertIn('class="foo"', result)
|
self.assertIn('class="foo"', result)
|
||||||
self.assertIn('id="bar"', result)
|
self.assertIn('id="bar"', result)
|
||||||
|
|
||||||
def test_empty_attributes_no_extra_space(self):
|
def test_empty_attributes_no_extra_space(self):
|
||||||
result = str(components.Element(tag_name="span", children="x"))
|
result = components.Component(tag_name="span", children="x")
|
||||||
self.assertEqual(result, "<span>x</span>")
|
self.assertEqual(result, "<span>x</span>")
|
||||||
self.assertNotIn(" <span", result)
|
self.assertNotIn(" <span", result)
|
||||||
|
|
||||||
def test_non_string_children_not_supported(self):
|
def test_non_string_children_not_supported(self):
|
||||||
"""Component only accepts str for children, not integers."""
|
"""Component only accepts str for children, not integers."""
|
||||||
result = str(components.Element(tag_name="span", children=str(42)))
|
result = components.Component(tag_name="span", children=str(42))
|
||||||
self.assertIn("42", result)
|
self.assertIn("42", result)
|
||||||
|
|
||||||
def test_returns_safetext(self):
|
def test_returns_safetext(self):
|
||||||
result = str(components.Element(tag_name="div", children="test"))
|
result = components.Component(tag_name="div", children="test")
|
||||||
self.assertIsInstance(result, SafeText)
|
self.assertIsInstance(result, SafeText)
|
||||||
|
|
||||||
|
|
||||||
@@ -393,22 +360,22 @@ class IconTest(unittest.TestCase):
|
|||||||
"""Test Icon() component function."""
|
"""Test Icon() component function."""
|
||||||
|
|
||||||
def test_valid_icon_renders_svg(self):
|
def test_valid_icon_renders_svg(self):
|
||||||
result = str(components.Icon("play"))
|
result = components.Icon("play")
|
||||||
self.assertIsInstance(result, SafeText)
|
self.assertIsInstance(result, SafeText)
|
||||||
self.assertIn("<svg", result)
|
self.assertIn("<svg", result)
|
||||||
self.assertIn("</svg>", result)
|
self.assertIn("</svg>", result)
|
||||||
|
|
||||||
def test_unavailable_icon_falls_back(self):
|
def test_unavailable_icon_falls_back(self):
|
||||||
result = str(components.Icon("zzz_nonexistent_platform"))
|
result = components.Icon("zzz_nonexistent_platform")
|
||||||
self.assertIsInstance(result, SafeText)
|
self.assertIsInstance(result, SafeText)
|
||||||
self.assertIn("<svg", result)
|
self.assertIn("<svg", result)
|
||||||
|
|
||||||
def test_icon_passes_attributes_to_template(self):
|
def test_icon_passes_attributes_to_template(self):
|
||||||
result = str(components.Icon("play", attributes=[("title", "Play")]))
|
result = components.Icon("play", attributes=[("title", "Play")])
|
||||||
self.assertIsInstance(result, SafeText)
|
self.assertIsInstance(result, SafeText)
|
||||||
|
|
||||||
def test_returns_safetext(self):
|
def test_returns_safetext(self):
|
||||||
result = str(components.Icon("delete"))
|
result = components.Icon("delete")
|
||||||
self.assertIsInstance(result, SafeText)
|
self.assertIsInstance(result, SafeText)
|
||||||
|
|
||||||
|
|
||||||
@@ -416,19 +383,17 @@ class InputTest(unittest.TestCase):
|
|||||||
"""Test the Input() component."""
|
"""Test the Input() component."""
|
||||||
|
|
||||||
def test_input_default_type_text(self):
|
def test_input_default_type_text(self):
|
||||||
result = str(components.Input())
|
result = components.Input()
|
||||||
self.assertIn("<input", result)
|
self.assertIn("<input", result)
|
||||||
self.assertIn('type="text"', result)
|
self.assertIn('type="text"', result)
|
||||||
|
|
||||||
def test_input_custom_type(self):
|
def test_input_custom_type(self):
|
||||||
result = str(components.Input(type="submit"))
|
result = components.Input(type="submit")
|
||||||
self.assertIn('type="submit"', result)
|
self.assertIn('type="submit"', result)
|
||||||
|
|
||||||
def test_input_attributes_merged_with_type(self):
|
def test_input_attributes_merged_with_type(self):
|
||||||
result = str(
|
result = components.Input(
|
||||||
components.Input(
|
type="email", attributes=[("id", "email"), ("class", "form-input")]
|
||||||
type="email", attributes=[("id", "email"), ("class", "form-input")]
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
self.assertIn('type="email"', result)
|
self.assertIn('type="email"', result)
|
||||||
self.assertIn('id="email"', result)
|
self.assertIn('id="email"', result)
|
||||||
@@ -439,12 +404,12 @@ class PopoverTruncatedTest(unittest.TestCase):
|
|||||||
"""Test PopoverTruncated() component function."""
|
"""Test PopoverTruncated() component function."""
|
||||||
|
|
||||||
def test_short_string_no_popover(self):
|
def test_short_string_no_popover(self):
|
||||||
result = str(components.PopoverTruncated("hi"))
|
result = components.PopoverTruncated("hi")
|
||||||
self.assertEqual(result, "hi")
|
self.assertEqual(result, "hi")
|
||||||
|
|
||||||
def test_long_string_wrapped_in_popover(self):
|
def test_long_string_wrapped_in_popover(self):
|
||||||
long_text = "a" * 100
|
long_text = "a" * 100
|
||||||
result = str(components.PopoverTruncated(long_text))
|
result = components.PopoverTruncated(long_text)
|
||||||
# Should NOT equal the truncated form directly
|
# Should NOT equal the truncated form directly
|
||||||
truncated = components.truncate(long_text, 30)
|
truncated = components.truncate(long_text, 30)
|
||||||
self.assertNotEqual(result, truncated)
|
self.assertNotEqual(result, truncated)
|
||||||
@@ -453,55 +418,47 @@ class PopoverTruncatedTest(unittest.TestCase):
|
|||||||
|
|
||||||
def test_custom_ellipsis_used(self):
|
def test_custom_ellipsis_used(self):
|
||||||
long_text = "a" * 50
|
long_text = "a" * 50
|
||||||
result = str(components.PopoverTruncated(long_text, ellipsis=">>"))
|
result = components.PopoverTruncated(long_text, ellipsis=">>")
|
||||||
# Django template escapes >> to >> in the wrapped_content
|
# Django template escapes >> to >> in the wrapped_content
|
||||||
self.assertIn(">>", result)
|
self.assertIn(">>", result)
|
||||||
|
|
||||||
def test_popover_if_not_truncated_flag(self):
|
def test_popover_if_not_truncated_flag(self):
|
||||||
short_text = "hi"
|
short_text = "hi"
|
||||||
result = str(
|
result = components.PopoverTruncated(
|
||||||
components.PopoverTruncated(
|
short_text, popover_content="full content", popover_if_not_truncated=True
|
||||||
short_text,
|
|
||||||
popover_content="full content",
|
|
||||||
popover_if_not_truncated=True,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
# Should be wrapped in popover even though short
|
# Should be wrapped in popover even though short
|
||||||
self.assertNotEqual(result, "hi")
|
self.assertNotEqual(result, "hi")
|
||||||
self.assertIn("data-popover-target", result)
|
self.assertIn("data-popover-target", result)
|
||||||
|
|
||||||
def test_popover_content_override(self):
|
def test_popover_content_override(self):
|
||||||
result = str(
|
result = components.PopoverTruncated("short", popover_content="custom popover")
|
||||||
components.PopoverTruncated("short", popover_content="custom popover")
|
|
||||||
)
|
|
||||||
# With popover_if_not_truncated=False (default), short text returns as-is
|
# With popover_if_not_truncated=False (default), short text returns as-is
|
||||||
self.assertEqual(result, "short")
|
self.assertEqual(result, "short")
|
||||||
|
|
||||||
def test_popover_content_override_with_flag(self):
|
def test_popover_content_override_with_flag(self):
|
||||||
result = str(
|
result = components.PopoverTruncated(
|
||||||
components.PopoverTruncated(
|
"short", popover_content="custom popover", popover_if_not_truncated=True
|
||||||
"short", popover_content="custom popover", popover_if_not_truncated=True
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
self.assertIn("custom popover", result)
|
self.assertIn("custom popover", result)
|
||||||
|
|
||||||
def test_endpart_visible_in_output(self):
|
def test_endpart_visible_in_output(self):
|
||||||
long_text = "a" * 50
|
long_text = "a" * 50
|
||||||
result = str(components.PopoverTruncated(long_text, endpart="..."))
|
result = components.PopoverTruncated(long_text, endpart="...")
|
||||||
self.assertIn("...", result)
|
self.assertIn("...", result)
|
||||||
|
|
||||||
def test_returns_safetext(self):
|
def test_returns_safetext(self):
|
||||||
result = str(components.PopoverTruncated("a" * 100))
|
result = components.PopoverTruncated("a" * 100)
|
||||||
self.assertIsInstance(result, SafeText)
|
self.assertIsInstance(result, SafeText)
|
||||||
|
|
||||||
def test_default_length(self):
|
def test_default_length(self):
|
||||||
text = "a" * 31
|
text = "a" * 31
|
||||||
result = str(components.PopoverTruncated(text))
|
result = components.PopoverTruncated(text)
|
||||||
# 31 chars exceeds default length of 30, so should be truncated
|
# 31 chars exceeds default length of 30, so should be truncated
|
||||||
self.assertIn("data-popover-target", result)
|
self.assertIn("data-popover-target", result)
|
||||||
|
|
||||||
def test_length_zero(self):
|
def test_length_zero(self):
|
||||||
result = str(components.PopoverTruncated("hello", length=0))
|
result = components.PopoverTruncated("hello", length=0)
|
||||||
# Even empty length triggers popover for any content
|
# Even empty length triggers popover for any content
|
||||||
self.assertIn("data-popover-target", result)
|
self.assertIn("data-popover-target", result)
|
||||||
|
|
||||||
@@ -533,7 +490,7 @@ class ModelDependentComponentsTest(django.test.TestCase):
|
|||||||
def test_name_with_icon_linkify_with_game(self):
|
def test_name_with_icon_linkify_with_game(self):
|
||||||
platform = self._create_platform(name="Steam", icon="steam")
|
platform = self._create_platform(name="Steam", icon="steam")
|
||||||
game = self._create_game(platform)
|
game = self._create_game(platform)
|
||||||
result = str(components.NameWithIcon(game=game, linkify=True))
|
result = components.NameWithIcon(game=game, linkify=True)
|
||||||
self.assertIsInstance(result, SafeText)
|
self.assertIsInstance(result, SafeText)
|
||||||
self.assertIn("<a ", result)
|
self.assertIn("<a ", result)
|
||||||
self.assertIn("Test Game", result)
|
self.assertIn("Test Game", result)
|
||||||
@@ -542,9 +499,7 @@ class ModelDependentComponentsTest(django.test.TestCase):
|
|||||||
def test_name_with_icon_no_linkify(self):
|
def test_name_with_icon_no_linkify(self):
|
||||||
platform = self._create_platform(name="GOG", icon="gog")
|
platform = self._create_platform(name="GOG", icon="gog")
|
||||||
game = self._create_game(platform)
|
game = self._create_game(platform)
|
||||||
result = str(
|
result = components.NameWithIcon(name="Test Game", game=game, linkify=False)
|
||||||
components.NameWithIcon(name="Test Game", game=game, linkify=False)
|
|
||||||
)
|
|
||||||
self.assertIsInstance(result, SafeText)
|
self.assertIsInstance(result, SafeText)
|
||||||
self.assertNotIn("<a ", result)
|
self.assertNotIn("<a ", result)
|
||||||
self.assertIn("Test Game", result)
|
self.assertIn("Test Game", result)
|
||||||
@@ -557,13 +512,13 @@ class ModelDependentComponentsTest(django.test.TestCase):
|
|||||||
timestamp_start="2025-01-01 00:00:00+00:00",
|
timestamp_start="2025-01-01 00:00:00+00:00",
|
||||||
emulated=True,
|
emulated=True,
|
||||||
)
|
)
|
||||||
result = str(components.NameWithIcon(session=session, linkify=True))
|
result = components.NameWithIcon(session=session, linkify=True)
|
||||||
self.assertIsInstance(result, SafeText)
|
self.assertIsInstance(result, SafeText)
|
||||||
self.assertIn("<a ", result)
|
self.assertIn("<a ", result)
|
||||||
self.assertIn("Emulated", result)
|
self.assertIn("Emulated", result)
|
||||||
|
|
||||||
def test_name_with_icon_no_platform(self):
|
def test_name_with_icon_no_platform(self):
|
||||||
result = str(components.NameWithIcon(name="Standalone", linkify=False))
|
result = components.NameWithIcon(name="Standalone", linkify=False)
|
||||||
self.assertIsInstance(result, SafeText)
|
self.assertIsInstance(result, SafeText)
|
||||||
self.assertIn("Standalone", result)
|
self.assertIn("Standalone", result)
|
||||||
|
|
||||||
@@ -574,7 +529,7 @@ class ModelDependentComponentsTest(django.test.TestCase):
|
|||||||
game=game,
|
game=game,
|
||||||
timestamp_start="2025-01-01 00:00:00+00:00",
|
timestamp_start="2025-01-01 00:00:00+00:00",
|
||||||
)
|
)
|
||||||
result = str(components.NameWithIcon(session=session, linkify=True))
|
result = components.NameWithIcon(session=session, linkify=True)
|
||||||
self.assertIsInstance(result, SafeText)
|
self.assertIsInstance(result, SafeText)
|
||||||
self.assertIn("Epic Game", result)
|
self.assertIn("Epic Game", result)
|
||||||
|
|
||||||
@@ -582,7 +537,7 @@ class ModelDependentComponentsTest(django.test.TestCase):
|
|||||||
platform = self._create_platform()
|
platform = self._create_platform()
|
||||||
game = self._create_game(platform)
|
game = self._create_game(platform)
|
||||||
purchase = self._create_purchase([game], price=29.99)
|
purchase = self._create_purchase([game], price=29.99)
|
||||||
result = str(components.PurchasePrice(purchase))
|
result = components.PurchasePrice(purchase)
|
||||||
self.assertIsInstance(result, SafeText)
|
self.assertIsInstance(result, SafeText)
|
||||||
# floatformat rounds to 1 decimal: 29.99 -> 30.0
|
# floatformat rounds to 1 decimal: 29.99 -> 30.0
|
||||||
self.assertIn("30.0", result)
|
self.assertIn("30.0", result)
|
||||||
@@ -593,7 +548,7 @@ class ModelDependentComponentsTest(django.test.TestCase):
|
|||||||
platform = self._create_platform(icon="steam")
|
platform = self._create_platform(icon="steam")
|
||||||
game = self._create_game(platform, name="Single Game")
|
game = self._create_game(platform, name="Single Game")
|
||||||
purchase = self._create_purchase([game], price=14.99)
|
purchase = self._create_purchase([game], price=14.99)
|
||||||
result = str(components.LinkedPurchase(purchase))
|
result = components.LinkedPurchase(purchase)
|
||||||
self.assertIsInstance(result, SafeText)
|
self.assertIsInstance(result, SafeText)
|
||||||
self.assertIn("Single Game", result)
|
self.assertIn("Single Game", result)
|
||||||
self.assertIn("<a ", result)
|
self.assertIn("<a ", result)
|
||||||
@@ -604,7 +559,7 @@ class ModelDependentComponentsTest(django.test.TestCase):
|
|||||||
game1 = self._create_game(platform, name="Game One")
|
game1 = self._create_game(platform, name="Game One")
|
||||||
game2 = self._create_game(platform, name="Game Two")
|
game2 = self._create_game(platform, name="Game Two")
|
||||||
purchase = self._create_purchase([game1, game2], price=24.99)
|
purchase = self._create_purchase([game1, game2], price=24.99)
|
||||||
result = str(components.LinkedPurchase(purchase))
|
result = components.LinkedPurchase(purchase)
|
||||||
self.assertIsInstance(result, SafeText)
|
self.assertIsInstance(result, SafeText)
|
||||||
self.assertIn("2 games", result)
|
self.assertIn("2 games", result)
|
||||||
self.assertIn("<a ", result)
|
self.assertIn("<a ", result)
|
||||||
@@ -620,7 +575,7 @@ class ModelDependentComponentsTest(django.test.TestCase):
|
|||||||
)
|
)
|
||||||
purchase.name = "Bundle"
|
purchase.name = "Bundle"
|
||||||
purchase.save()
|
purchase.save()
|
||||||
result = str(components.LinkedPurchase(purchase))
|
result = components.LinkedPurchase(purchase)
|
||||||
self.assertIsInstance(result, SafeText)
|
self.assertIsInstance(result, SafeText)
|
||||||
self.assertIn("Bundle", result)
|
self.assertIn("Bundle", result)
|
||||||
|
|
||||||
@@ -629,7 +584,7 @@ class ModelDependentComponentsTest(django.test.TestCase):
|
|||||||
game1 = self._create_game(platform, name="Alpha")
|
game1 = self._create_game(platform, name="Alpha")
|
||||||
game2 = self._create_game(platform, name="Beta")
|
game2 = self._create_game(platform, name="Beta")
|
||||||
purchase = self._create_purchase([game1, game2], price=19.99)
|
purchase = self._create_purchase([game1, game2], price=19.99)
|
||||||
result = str(components.LinkedPurchase(purchase))
|
result = components.LinkedPurchase(purchase)
|
||||||
self.assertIsInstance(result, SafeText)
|
self.assertIsInstance(result, SafeText)
|
||||||
self.assertIn("Alpha", result)
|
self.assertIn("Alpha", result)
|
||||||
self.assertIn("Beta", result)
|
self.assertIn("Beta", result)
|
||||||
@@ -640,18 +595,18 @@ class PurchaseTruncatedTest(unittest.TestCase):
|
|||||||
|
|
||||||
def test_endpart_shorter_than_length(self):
|
def test_endpart_shorter_than_length(self):
|
||||||
text = "a" * 50
|
text = "a" * 50
|
||||||
result = str(components.PopoverTruncated(text, length=10, endpart="x"))
|
result = components.PopoverTruncated(text, length=10, endpart="x")
|
||||||
# endpart=x takes 1 char, so content gets truncated at 9 chars
|
# endpart=x takes 1 char, so content gets truncated at 9 chars
|
||||||
self.assertIn("data-popover-target", result)
|
self.assertIn("data-popover-target", result)
|
||||||
self.assertIn("x", result)
|
self.assertIn("x", result)
|
||||||
|
|
||||||
def test_no_truncation_no_ellipsis(self):
|
def test_no_truncation_no_ellipsis(self):
|
||||||
result = str(components.PopoverTruncated("short text"))
|
result = components.PopoverTruncated("short text")
|
||||||
self.assertEqual(result, "short text")
|
self.assertEqual(result, "short text")
|
||||||
|
|
||||||
def test_custom_length(self):
|
def test_custom_length(self):
|
||||||
text = "hello world"
|
text = "hello world"
|
||||||
result = str(components.PopoverTruncated(text, length=6))
|
result = components.PopoverTruncated(text, length=6)
|
||||||
self.assertIn("data-popover-target", result)
|
self.assertIn("data-popover-target", result)
|
||||||
|
|
||||||
|
|
||||||
@@ -665,14 +620,12 @@ class NameWithIconPlatformTest(django.test.TestCase):
|
|||||||
cls.game = Game.objects.create(name="Zelda", platform=cls.platform)
|
cls.game = Game.objects.create(name="Zelda", platform=cls.platform)
|
||||||
|
|
||||||
def test_name_with_icon_shows_platform_icon(self):
|
def test_name_with_icon_shows_platform_icon(self):
|
||||||
result = str(
|
result = components.NameWithIcon(name="Zelda", game=self.game, linkify=True)
|
||||||
components.NameWithIcon(name="Zelda", game=self.game, linkify=True)
|
|
||||||
)
|
|
||||||
self.assertIsInstance(result, SafeText)
|
self.assertIsInstance(result, SafeText)
|
||||||
self.assertIn("Zelda", result)
|
self.assertIn("Zelda", result)
|
||||||
|
|
||||||
def test_name_with_icon_no_game_id_no_platform(self):
|
def test_name_with_icon_no_game_id_no_platform(self):
|
||||||
result = str(components.NameWithIcon(name="Unknown Game", linkify=False))
|
result = components.NameWithIcon(name="Unknown Game", linkify=False)
|
||||||
self.assertIsInstance(result, SafeText)
|
self.assertIsInstance(result, SafeText)
|
||||||
self.assertIn("Unknown Game", result)
|
self.assertIn("Unknown Game", result)
|
||||||
|
|
||||||
@@ -796,11 +749,9 @@ class SimpleTableRenderingTest(unittest.TestCase):
|
|||||||
def test_simple_table_renders_list_rows(self):
|
def test_simple_table_renders_list_rows(self):
|
||||||
"""Verify list-style rows render as <tr> with <th scope='row'> + <td>."""
|
"""Verify list-style rows render as <tr> with <th scope='row'> + <td>."""
|
||||||
result = str(
|
result = str(
|
||||||
str(
|
components.SimpleTable(
|
||||||
components.SimpleTable(
|
columns=["Game", "Started", "Ended"],
|
||||||
columns=["Game", "Started", "Ended"],
|
rows=[["Game1", "2025-01-01", "2025-03-01"]],
|
||||||
rows=[["Game1", "2025-01-01", "2025-03-01"]],
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
tbody = self._tbody(result)
|
tbody = self._tbody(result)
|
||||||
@@ -823,11 +774,9 @@ class SimpleTableRenderingTest(unittest.TestCase):
|
|||||||
def test_simple_table_multiple_rows(self):
|
def test_simple_table_multiple_rows(self):
|
||||||
"""Verify multiple rows all render."""
|
"""Verify multiple rows all render."""
|
||||||
result = str(
|
result = str(
|
||||||
str(
|
components.SimpleTable(
|
||||||
components.SimpleTable(
|
columns=["Game", "Started"],
|
||||||
columns=["Game", "Started"],
|
rows=[["GameA", "2025-01-01"], ["GameB", "2025-02-01"]],
|
||||||
rows=[["GameA", "2025-01-01"], ["GameB", "2025-02-01"]],
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
tbody = self._tbody(result)
|
tbody = self._tbody(result)
|
||||||
@@ -837,13 +786,13 @@ class SimpleTableRenderingTest(unittest.TestCase):
|
|||||||
|
|
||||||
def test_simple_table_header_action_as_caption(self):
|
def test_simple_table_header_action_as_caption(self):
|
||||||
"""Verify header_action renders inside <caption>."""
|
"""Verify header_action renders inside <caption>."""
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
result = str(
|
result = str(
|
||||||
str(
|
components.SimpleTable(
|
||||||
components.SimpleTable(
|
columns=["Game", "Started"],
|
||||||
columns=["Game", "Started"],
|
rows=[["Game1", "2025-01-01"]],
|
||||||
rows=[["Game1", "2025-01-01"]],
|
header_action=mark_safe('<a href="/add">Add</a>'),
|
||||||
header_action=components.Safe('<a href="/add">Add</a>'),
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.assertIn("<caption", result)
|
self.assertIn("<caption", result)
|
||||||
@@ -853,17 +802,15 @@ class SimpleTableRenderingTest(unittest.TestCase):
|
|||||||
def test_simple_table_dict_rows_with_cell_data(self):
|
def test_simple_table_dict_rows_with_cell_data(self):
|
||||||
"""Verify dict-style rows with row_id and cell_data render correctly."""
|
"""Verify dict-style rows with row_id and cell_data render correctly."""
|
||||||
result = str(
|
result = str(
|
||||||
str(
|
components.SimpleTable(
|
||||||
components.SimpleTable(
|
columns=["Name", "Date"],
|
||||||
columns=["Name", "Date"],
|
rows=[
|
||||||
rows=[
|
{
|
||||||
{
|
"row_id": "session-row-1",
|
||||||
"row_id": "session-row-1",
|
"hx_trigger": "device-changed",
|
||||||
"hx_trigger": "device-changed",
|
"cell_data": ["Game1", "2025-01-01"],
|
||||||
"cell_data": ["Game1", "2025-01-01"],
|
}
|
||||||
}
|
],
|
||||||
],
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
tbody = self._tbody(result)
|
tbody = self._tbody(result)
|
||||||
@@ -874,12 +821,14 @@ class SimpleTableRenderingTest(unittest.TestCase):
|
|||||||
self.assertIn("2025-01-01", tbody)
|
self.assertIn("2025-01-01", tbody)
|
||||||
|
|
||||||
|
|
||||||
|
from django.test import SimpleTestCase
|
||||||
|
from common.components.primitives import Checkbox, Radio
|
||||||
|
|
||||||
|
|
||||||
class ComponentPrimitivesTest(SimpleTestCase):
|
class ComponentPrimitivesTest(SimpleTestCase):
|
||||||
def test_checkbox_primitive(self):
|
def test_checkbox_primitive(self):
|
||||||
html = str(
|
html = Checkbox(
|
||||||
components.Checkbox(
|
name="test-check", label="Accept Terms", checked=True, value="yes"
|
||||||
name="test-check", label="Accept Terms", checked=True, value="yes"
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
self.assertIn('type="checkbox"', html)
|
self.assertIn('type="checkbox"', html)
|
||||||
self.assertIn('name="test-check"', html)
|
self.assertIn('name="test-check"', html)
|
||||||
@@ -888,18 +837,14 @@ class ComponentPrimitivesTest(SimpleTestCase):
|
|||||||
self.assertIn("Accept Terms", html)
|
self.assertIn("Accept Terms", html)
|
||||||
|
|
||||||
def test_checkbox_headless(self):
|
def test_checkbox_headless(self):
|
||||||
html = str(components.Checkbox(name="test-headless", label=None, checked=True))
|
html = Checkbox(name="test-headless", label=None, checked=True)
|
||||||
self.assertNotIn("<label", html)
|
self.assertNotIn("<label", html)
|
||||||
self.assertIn("<input", html)
|
self.assertIn("<input", html)
|
||||||
self.assertIn('type="checkbox"', html)
|
self.assertIn('type="checkbox"', html)
|
||||||
self.assertIn('name="test-headless"', html)
|
self.assertIn('name="test-headless"', html)
|
||||||
|
|
||||||
def test_radio_primitive(self):
|
def test_radio_primitive(self):
|
||||||
html = str(
|
html = Radio(name="test-radio", label="Option A", checked=False, value="A")
|
||||||
components.Radio(
|
|
||||||
name="test-radio", label="Option A", checked=False, value="A"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
self.assertIn('type="radio"', html)
|
self.assertIn('type="radio"', html)
|
||||||
self.assertIn('name="test-radio"', html)
|
self.assertIn('name="test-radio"', html)
|
||||||
self.assertIn('value="A"', html)
|
self.assertIn('value="A"', html)
|
||||||
@@ -910,7 +855,6 @@ class ComponentPrimitivesTest(SimpleTestCase):
|
|||||||
class PrimitiveWidgetsTest(SimpleTestCase):
|
class PrimitiveWidgetsTest(SimpleTestCase):
|
||||||
def test_mixin_applies_widget_to_boolean_fields_only(self):
|
def test_mixin_applies_widget_to_boolean_fields_only(self):
|
||||||
from django import forms
|
from django import forms
|
||||||
|
|
||||||
from games.forms import PrimitiveCheckboxWidget, PrimitiveWidgetsMixin
|
from games.forms import PrimitiveCheckboxWidget, PrimitiveWidgetsMixin
|
||||||
|
|
||||||
class DummyForm(PrimitiveWidgetsMixin, forms.Form):
|
class DummyForm(PrimitiveWidgetsMixin, forms.Form):
|
||||||
@@ -923,7 +867,6 @@ class PrimitiveWidgetsTest(SimpleTestCase):
|
|||||||
|
|
||||||
def test_primitive_checkbox_widget_renders_headless(self):
|
def test_primitive_checkbox_widget_renders_headless(self):
|
||||||
from games.forms import PrimitiveCheckboxWidget
|
from games.forms import PrimitiveCheckboxWidget
|
||||||
|
|
||||||
widget = PrimitiveCheckboxWidget()
|
widget = PrimitiveCheckboxWidget()
|
||||||
html = widget.render(name="agree", value=True)
|
html = widget.render(name="agree", value=True)
|
||||||
self.assertNotIn("<label", html)
|
self.assertNotIn("<label", html)
|
||||||
|
|||||||
@@ -1,277 +0,0 @@
|
|||||||
"""Tests for the configuration reader in ``timetracker/config.py``."""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from django.core.exceptions import DisallowedHost, ImproperlyConfigured
|
|
||||||
from django.middleware.csrf import CsrfViewMiddleware
|
|
||||||
from django.test import RequestFactory, override_settings
|
|
||||||
|
|
||||||
from timetracker import config as config_module
|
|
||||||
from timetracker.config import config, derive_hosts_and_origins
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _clear_caches():
|
|
||||||
"""Each test sees freshly parsed files."""
|
|
||||||
config_module.reset_caches()
|
|
||||||
yield
|
|
||||||
config_module.reset_caches()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def env_file(tmp_path, monkeypatch):
|
|
||||||
def _write(contents: str):
|
|
||||||
path = tmp_path / ".env"
|
|
||||||
path.write_text(contents)
|
|
||||||
monkeypatch.setenv("ENV_FILE", str(path))
|
|
||||||
config_module.reset_caches()
|
|
||||||
return path
|
|
||||||
|
|
||||||
return _write
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def ini_file(tmp_path, monkeypatch):
|
|
||||||
def _write(contents: str):
|
|
||||||
path = tmp_path / "settings.ini"
|
|
||||||
path.write_text(contents)
|
|
||||||
monkeypatch.setenv("INI_FILE", str(path))
|
|
||||||
config_module.reset_caches()
|
|
||||||
return path
|
|
||||||
|
|
||||||
return _write
|
|
||||||
|
|
||||||
|
|
||||||
def test_default_returned_when_unset():
|
|
||||||
assert config("TOTALLY_UNSET_VALUE", default="fallback") == "fallback"
|
|
||||||
|
|
||||||
|
|
||||||
def test_missing_without_default_raises():
|
|
||||||
with pytest.raises(ImproperlyConfigured):
|
|
||||||
config("TOTALLY_UNSET_VALUE")
|
|
||||||
|
|
||||||
|
|
||||||
def test_env_var_overrides_default(monkeypatch):
|
|
||||||
monkeypatch.setenv("SOME_SETTING", "from-env")
|
|
||||||
assert config("SOME_SETTING", default="fallback") == "from-env"
|
|
||||||
|
|
||||||
|
|
||||||
def test_priority_env_beats_files(monkeypatch, env_file, ini_file):
|
|
||||||
ini_file("[timetracker]\nVALUE = from-ini\n")
|
|
||||||
env_file("VALUE=from-dotenv\n")
|
|
||||||
monkeypatch.setenv("VALUE", "from-env")
|
|
||||||
assert config("VALUE") == "from-env"
|
|
||||||
|
|
||||||
|
|
||||||
def test_priority_dotenv_beats_ini(env_file, ini_file):
|
|
||||||
ini_file("[timetracker]\nVALUE = from-ini\n")
|
|
||||||
env_file("VALUE=from-dotenv\n")
|
|
||||||
assert config("VALUE") == "from-dotenv"
|
|
||||||
|
|
||||||
|
|
||||||
def test_priority_ini_beats_default(ini_file):
|
|
||||||
ini_file("[timetracker]\nVALUE = from-ini\n")
|
|
||||||
assert config("VALUE", default="fallback") == "from-ini"
|
|
||||||
|
|
||||||
|
|
||||||
def test_ini_preserves_key_case(ini_file):
|
|
||||||
ini_file("[timetracker]\nSECRET_KEY = abc\n")
|
|
||||||
assert config("SECRET_KEY") == "abc"
|
|
||||||
|
|
||||||
|
|
||||||
# --- __FILE secret pointer -------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_file_pointer_read_and_stripped(tmp_path, monkeypatch):
|
|
||||||
secret = tmp_path / "secret"
|
|
||||||
secret.write_text("super-secret-value\n") # trailing newline must be stripped
|
|
||||||
monkeypatch.setenv("SECRET_KEY__FILE", str(secret))
|
|
||||||
assert config("SECRET_KEY", allow_file=True) == "super-secret-value"
|
|
||||||
|
|
||||||
|
|
||||||
def test_file_pointer_ignored_without_allow_file(tmp_path, monkeypatch):
|
|
||||||
secret = tmp_path / "secret"
|
|
||||||
secret.write_text("ignored")
|
|
||||||
monkeypatch.setenv("SECRET_KEY__FILE", str(secret))
|
|
||||||
assert config("SECRET_KEY", default="fallback") == "fallback"
|
|
||||||
|
|
||||||
|
|
||||||
def test_file_pointer_beats_env(tmp_path, monkeypatch):
|
|
||||||
secret = tmp_path / "secret"
|
|
||||||
secret.write_text("from-file")
|
|
||||||
monkeypatch.setenv("SECRET_KEY__FILE", str(secret))
|
|
||||||
monkeypatch.setenv("SECRET_KEY", "from-env")
|
|
||||||
assert config("SECRET_KEY", allow_file=True) == "from-file"
|
|
||||||
|
|
||||||
|
|
||||||
# --- casting ---------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"raw,expected",
|
|
||||||
[
|
|
||||||
("true", True),
|
|
||||||
("True", True),
|
|
||||||
("1", True),
|
|
||||||
("yes", True),
|
|
||||||
("on", True),
|
|
||||||
("false", False),
|
|
||||||
("0", False),
|
|
||||||
("no", False),
|
|
||||||
("", False),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_cast_bool(monkeypatch, raw, expected):
|
|
||||||
monkeypatch.setenv("FLAG", raw)
|
|
||||||
assert config("FLAG", cast=bool) is expected
|
|
||||||
|
|
||||||
|
|
||||||
def test_cast_list(monkeypatch):
|
|
||||||
monkeypatch.setenv("HOSTS", "a.example, b.example , ,c.example")
|
|
||||||
assert config("HOSTS", cast=list) == ["a.example", "b.example", "c.example"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_cast_int(monkeypatch):
|
|
||||||
monkeypatch.setenv("COUNT", "42")
|
|
||||||
assert config("COUNT", cast=int) == 42
|
|
||||||
|
|
||||||
|
|
||||||
def test_cast_not_applied_to_default():
|
|
||||||
# A None default passes through untouched even with a cast set.
|
|
||||||
assert config("UNSET", default=None, cast=list) is None
|
|
||||||
|
|
||||||
|
|
||||||
# --- required_in_prod ------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_required_in_prod_raises_when_prod(monkeypatch):
|
|
||||||
monkeypatch.setenv("DEBUG", "false")
|
|
||||||
with pytest.raises(ImproperlyConfigured):
|
|
||||||
config("SECRET_KEY", default="dev-default", required_in_prod=True)
|
|
||||||
|
|
||||||
|
|
||||||
def test_required_in_prod_uses_default_in_debug(monkeypatch):
|
|
||||||
monkeypatch.setenv("DEBUG", "true")
|
|
||||||
assert config("SECRET_KEY", default="dev-default", required_in_prod=True) == (
|
|
||||||
"dev-default"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_deprecated_prod_var_implies_production(monkeypatch):
|
|
||||||
monkeypatch.delenv("DEBUG", raising=False)
|
|
||||||
monkeypatch.setenv("PROD", "1")
|
|
||||||
with pytest.raises(ImproperlyConfigured):
|
|
||||||
config("SECRET_KEY", default="dev-default", required_in_prod=True)
|
|
||||||
|
|
||||||
|
|
||||||
# --- .env parser edge cases ------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_env_parser_quotes_comments_and_export(env_file):
|
|
||||||
env_file(
|
|
||||||
"\n".join(
|
|
||||||
[
|
|
||||||
"# a comment line",
|
|
||||||
"PLAIN=value",
|
|
||||||
"export EXPORTED=exported-value",
|
|
||||||
'DOUBLE="quoted value"',
|
|
||||||
"SINGLE='single quoted'",
|
|
||||||
"INLINE=value # trailing comment",
|
|
||||||
'HASH_IN_QUOTES="a # b"',
|
|
||||||
"EMPTY=",
|
|
||||||
'QUOTED_THEN_COMMENT="keep" # drop',
|
|
||||||
]
|
|
||||||
)
|
|
||||||
+ "\n"
|
|
||||||
)
|
|
||||||
assert config("PLAIN") == "value"
|
|
||||||
assert config("EXPORTED") == "exported-value"
|
|
||||||
assert config("DOUBLE") == "quoted value"
|
|
||||||
assert config("SINGLE") == "single quoted"
|
|
||||||
assert config("INLINE") == "value"
|
|
||||||
assert config("HASH_IN_QUOTES") == "a # b"
|
|
||||||
assert config("EMPTY", default="x") == ""
|
|
||||||
assert config("QUOTED_THEN_COMMENT") == "keep"
|
|
||||||
|
|
||||||
|
|
||||||
def test_missing_files_are_ignored(monkeypatch, tmp_path):
|
|
||||||
monkeypatch.setenv("ENV_FILE", str(tmp_path / "does-not-exist.env"))
|
|
||||||
monkeypatch.setenv("INI_FILE", str(tmp_path / "does-not-exist.ini"))
|
|
||||||
config_module.reset_caches()
|
|
||||||
assert config("ANYTHING", default="fallback") == "fallback"
|
|
||||||
|
|
||||||
|
|
||||||
# --- derive_hosts_and_origins -----------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_single_url_derives_one_host_and_origin():
|
|
||||||
hosts, origins = derive_hosts_and_origins("https://tracker.example.com")
|
|
||||||
assert hosts == ["tracker.example.com"]
|
|
||||||
assert origins == ["https://tracker.example.com"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_multiple_urls_derive_multiple_hosts_and_origins():
|
|
||||||
hosts, origins = derive_hosts_and_origins(
|
|
||||||
"https://tracker.example.com,https://www.tracker.example.com"
|
|
||||||
)
|
|
||||||
assert hosts == ["tracker.example.com", "www.tracker.example.com"]
|
|
||||||
assert origins == ["https://tracker.example.com", "https://www.tracker.example.com"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_whitespace_around_commas_is_stripped():
|
|
||||||
hosts, origins = derive_hosts_and_origins(
|
|
||||||
"https://a.example.com , https://b.example.com"
|
|
||||||
)
|
|
||||||
assert hosts == ["a.example.com", "b.example.com"]
|
|
||||||
assert origins == ["https://a.example.com", "https://b.example.com"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_url_with_port_is_preserved_in_origin():
|
|
||||||
hosts, origins = derive_hosts_and_origins("http://localhost:8000")
|
|
||||||
assert hosts == ["localhost"]
|
|
||||||
assert origins == ["http://localhost:8000"]
|
|
||||||
|
|
||||||
|
|
||||||
# --- Django integration: derived values are accepted by Django internals -----
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"app_url,request_host",
|
|
||||||
[
|
|
||||||
("https://tracker.example.com", "tracker.example.com"),
|
|
||||||
(
|
|
||||||
"https://tracker.example.com,https://www.tracker.example.com",
|
|
||||||
"www.tracker.example.com",
|
|
||||||
),
|
|
||||||
("http://localhost:8000", "localhost"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_derived_hosts_accepted_by_django(app_url, request_host):
|
|
||||||
hosts, _ = derive_hosts_and_origins(app_url)
|
|
||||||
factory = RequestFactory()
|
|
||||||
with override_settings(ALLOWED_HOSTS=hosts):
|
|
||||||
request = factory.get("/", HTTP_HOST=request_host)
|
|
||||||
assert request.get_host() == request_host
|
|
||||||
|
|
||||||
|
|
||||||
def test_host_not_in_derived_list_is_rejected():
|
|
||||||
hosts, _ = derive_hosts_and_origins("https://tracker.example.com")
|
|
||||||
factory = RequestFactory()
|
|
||||||
with override_settings(ALLOWED_HOSTS=hosts):
|
|
||||||
request = factory.get("/", HTTP_HOST="evil.example.com")
|
|
||||||
with pytest.raises(DisallowedHost):
|
|
||||||
request.get_host()
|
|
||||||
|
|
||||||
|
|
||||||
def test_derived_origins_accepted_by_csrf_middleware():
|
|
||||||
_, origins = derive_hosts_and_origins(
|
|
||||||
"https://tracker.example.com,https://other.example.com"
|
|
||||||
)
|
|
||||||
factory = RequestFactory()
|
|
||||||
middleware = CsrfViewMiddleware(lambda request: None)
|
|
||||||
with override_settings(CSRF_TRUSTED_ORIGINS=origins):
|
|
||||||
for origin in origins:
|
|
||||||
request = factory.post("/", HTTP_ORIGIN=origin)
|
|
||||||
request.META["HTTP_REFERER"] = origin + "/"
|
|
||||||
# _check_token is not called here; _is_secure_referer_ok / origin
|
|
||||||
# matching is what we want — process_view returns None when trusted.
|
|
||||||
assert middleware.process_request(request) is None
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
import unittest
|
|
||||||
from typing import TypedDict
|
|
||||||
|
|
||||||
from common.components import custom_element_builder, render
|
|
||||||
from common.components.custom_elements import (
|
|
||||||
ElementSpec,
|
|
||||||
_ts_for_spec,
|
|
||||||
register_element,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SampleProps(TypedDict):
|
|
||||||
game_id: int
|
|
||||||
status: str
|
|
||||||
is_on: bool
|
|
||||||
|
|
||||||
|
|
||||||
class CustomElementBuilderTest(unittest.TestCase):
|
|
||||||
def test_serializes_props_to_kebab_attributes(self):
|
|
||||||
x_sample = custom_element_builder("x-sample")
|
|
||||||
html = render(x_sample(game_id=3, status="f")["hi"])
|
|
||||||
self.assertIn("<x-sample", html)
|
|
||||||
self.assertIn('game-id="3"', html)
|
|
||||||
self.assertIn('status="f"', html)
|
|
||||||
self.assertIn(">hi</x-sample>", html)
|
|
||||||
|
|
||||||
def test_declares_compiled_module_media(self):
|
|
||||||
from common.components import collect_media
|
|
||||||
|
|
||||||
x_sample = custom_element_builder("x-sample")
|
|
||||||
node = x_sample(game_id=3)
|
|
||||||
self.assertEqual(collect_media(node).js, ("dist/elements/x-sample.js",))
|
|
||||||
|
|
||||||
|
|
||||||
class CodegenTest(unittest.TestCase):
|
|
||||||
def test_emits_interface_and_reader(self):
|
|
||||||
spec = ElementSpec("x-sample", "XSample", SampleProps)
|
|
||||||
ts = _ts_for_spec(spec)
|
|
||||||
self.assertIn("export interface XSampleProps {", ts)
|
|
||||||
self.assertIn("gameId: number;", ts)
|
|
||||||
self.assertIn("status: string;", ts)
|
|
||||||
self.assertIn("isOn: boolean;", ts)
|
|
||||||
self.assertIn(
|
|
||||||
"export function readXSampleProps(el: HTMLElement): XSampleProps", ts
|
|
||||||
)
|
|
||||||
self.assertIn('Number(el.getAttribute("game-id"))', ts)
|
|
||||||
self.assertIn('el.getAttribute("status") ?? ""', ts)
|
|
||||||
self.assertIn('el.getAttribute("is-on") === "true"', ts)
|
|
||||||
|
|
||||||
|
|
||||||
class RegistryTest(unittest.TestCase):
|
|
||||||
def test_register_adds_spec(self):
|
|
||||||
from common.components.custom_elements import ELEMENT_REGISTRY
|
|
||||||
|
|
||||||
before = len(ELEMENT_REGISTRY)
|
|
||||||
register_element("x-reg-test", "XRegTest", SampleProps)
|
|
||||||
self.assertEqual(len(ELEMENT_REGISTRY), before + 1)
|
|
||||||
self.assertEqual(ELEMENT_REGISTRY[-1].tag, "x-reg-test")
|
|
||||||
|
|
||||||
|
|
||||||
class GameStatusSelectorRenderTest(unittest.TestCase):
|
|
||||||
def test_emits_tag_props_and_media(self):
|
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
from common.components import GameStatusSelector, collect_media, render
|
|
||||||
|
|
||||||
game = SimpleNamespace(id=7, status="f", get_status_display=lambda: "Finished")
|
|
||||||
node = GameStatusSelector(game, [("u", "Unplayed"), ("f", "Finished")], "tok")
|
|
||||||
html = render(node)
|
|
||||||
self.assertIn("<game-status-selector", html)
|
|
||||||
self.assertIn('game-id="7"', html)
|
|
||||||
self.assertIn('status="f"', html)
|
|
||||||
self.assertIn('csrf="tok"', html)
|
|
||||||
self.assertIn("data-option", html)
|
|
||||||
self.assertIn('data-value="u"', html)
|
|
||||||
self.assertNotIn("x-data", html) # no Alpine left
|
|
||||||
self.assertIn("dist/elements/game-status-selector.js", collect_media(node).js)
|
|
||||||
|
|
||||||
|
|
||||||
class SessionDeviceSelectorRenderTest(unittest.TestCase):
|
|
||||||
def test_emits_tag_and_options(self):
|
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
from common.components import SessionDeviceSelector, render
|
|
||||||
|
|
||||||
session = SimpleNamespace(id=4, device=SimpleNamespace(name="Desktop"))
|
|
||||||
devices = [
|
|
||||||
SimpleNamespace(id=1, name="Desktop"),
|
|
||||||
SimpleNamespace(id=2, name="Deck"),
|
|
||||||
]
|
|
||||||
html = render(SessionDeviceSelector(session, devices, "tok"))
|
|
||||||
self.assertIn("<session-device-selector", html)
|
|
||||||
self.assertIn('session-id="4"', html)
|
|
||||||
self.assertIn('data-value="2"', html)
|
|
||||||
self.assertNotIn("x-data", html)
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
"""Unit tests for the DateRangePicker component family.
|
|
||||||
|
|
||||||
Pins the structural contract of DateRangeField / DateRangeCalendar /
|
|
||||||
DateRangePicker — segment inputs ordered by ``dateformat_hyphenated``, the
|
|
||||||
hidden ISO ``{prefix}-min`` / ``{prefix}-max`` inputs that ``filter_bar.js``
|
|
||||||
serializes, the calendar's preset/footer hooks — and the PurchaseFilterBar
|
|
||||||
integration that replaced the native-date DateRangeFilter for the Purchased
|
|
||||||
field.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
|
|
||||||
from django.test import SimpleTestCase, TestCase
|
|
||||||
|
|
||||||
from common.components import (
|
|
||||||
DateRangeCalendar,
|
|
||||||
DateRangeField,
|
|
||||||
DateRangePicker,
|
|
||||||
PurchaseFilterBar,
|
|
||||||
)
|
|
||||||
from common.time import date_parts, dateformat_hyphenated
|
|
||||||
|
|
||||||
_ESCAPED_TAG_MARKERS = ["<div", "<span", "<button", "<input"]
|
|
||||||
|
|
||||||
|
|
||||||
class DatePartsTest(SimpleTestCase):
|
|
||||||
def test_default_format_yields_day_month_year(self):
|
|
||||||
parts = date_parts()
|
|
||||||
self.assertEqual([part.name for part in parts], ["day", "month", "year"])
|
|
||||||
self.assertEqual([part.placeholder for part in parts], ["DD", "MM", "YYYY"])
|
|
||||||
self.assertEqual([part.length for part in parts], [2, 2, 4])
|
|
||||||
|
|
||||||
def test_parts_follow_format_order(self):
|
|
||||||
parts = date_parts("%Y-%d-%m")
|
|
||||||
self.assertEqual([part.name for part in parts], ["year", "day", "month"])
|
|
||||||
|
|
||||||
def test_dateformat_hyphenated_is_parseable(self):
|
|
||||||
self.assertEqual(len(date_parts(dateformat_hyphenated)), 3)
|
|
||||||
|
|
||||||
|
|
||||||
class DateRangeFieldTest(SimpleTestCase):
|
|
||||||
def render(self, **kwargs):
|
|
||||||
defaults = {"label": "Purchased", "input_name_prefix": "filter-date-purchased"}
|
|
||||||
defaults.update(kwargs)
|
|
||||||
return str(DateRangeField(**defaults))
|
|
||||||
|
|
||||||
def test_renders_hidden_iso_inputs(self):
|
|
||||||
html = self.render(min_value="2024-03-15", max_value="2024-09-20")
|
|
||||||
self.assertIn('name="filter-date-purchased-min"', html)
|
|
||||||
self.assertIn('name="filter-date-purchased-max"', html)
|
|
||||||
self.assertIn('data-date-range-hidden="min"', html)
|
|
||||||
self.assertIn('data-date-range-hidden="max"', html)
|
|
||||||
self.assertIn('value="2024-03-15"', html)
|
|
||||||
self.assertIn('value="2024-09-20"', html)
|
|
||||||
|
|
||||||
def test_renders_segments_in_dateformat_order_for_both_sides(self):
|
|
||||||
html = self.render()
|
|
||||||
for side in ("min", "max"):
|
|
||||||
side_segments = re.findall(
|
|
||||||
rf'data-date-part="(\w+)" data-date-side="{side}"', html
|
|
||||||
)
|
|
||||||
self.assertEqual(side_segments, ["day", "month", "year"])
|
|
||||||
|
|
||||||
def test_segment_placeholders_and_lengths(self):
|
|
||||||
html = self.render()
|
|
||||||
self.assertEqual(html.count('placeholder="DD"'), 2)
|
|
||||||
self.assertEqual(html.count('placeholder="MM"'), 2)
|
|
||||||
self.assertEqual(html.count('placeholder="YYYY"'), 2)
|
|
||||||
self.assertEqual(html.count('maxlength="2"'), 4)
|
|
||||||
self.assertEqual(html.count('maxlength="4"'), 2)
|
|
||||||
self.assertEqual(html.count('inputmode="numeric"'), 6)
|
|
||||||
|
|
||||||
def test_prefills_segments_from_iso_values(self):
|
|
||||||
html = self.render(min_value="2024-03-15")
|
|
||||||
self.assertIn('value="15" data-date-part="day" data-date-side="min"', html)
|
|
||||||
self.assertIn('value="03" data-date-part="month" data-date-side="min"', html)
|
|
||||||
self.assertIn('value="2024" data-date-part="year" data-date-side="min"', html)
|
|
||||||
# The max side stays empty.
|
|
||||||
self.assertIn('value="" data-date-part="day" data-date-side="max"', html)
|
|
||||||
|
|
||||||
def test_malformed_iso_value_renders_empty_segments(self):
|
|
||||||
html = self.render(min_value="not-a-date")
|
|
||||||
self.assertIn('value="" data-date-part="day" data-date-side="min"', html)
|
|
||||||
|
|
||||||
def test_renders_calendar_toggle(self):
|
|
||||||
html = self.render()
|
|
||||||
self.assertIn("data-date-range-calendar-toggle", html)
|
|
||||||
self.assertIn('aria-label="Open Purchased calendar"', html)
|
|
||||||
|
|
||||||
def test_no_native_date_inputs(self):
|
|
||||||
self.assertNotIn('type="date"', self.render())
|
|
||||||
|
|
||||||
|
|
||||||
class DateRangeCalendarTest(SimpleTestCase):
|
|
||||||
def render(self):
|
|
||||||
return str(DateRangeCalendar(input_name_prefix="filter-date-purchased"))
|
|
||||||
|
|
||||||
def test_renders_all_presets(self):
|
|
||||||
html = self.render()
|
|
||||||
for preset in (
|
|
||||||
"today",
|
|
||||||
"yesterday",
|
|
||||||
"last_7_days",
|
|
||||||
"last_30_days",
|
|
||||||
"this_month",
|
|
||||||
"last_month",
|
|
||||||
"this_year",
|
|
||||||
):
|
|
||||||
self.assertIn(f'data-date-range-preset="{preset}"', html)
|
|
||||||
|
|
||||||
def test_renders_footer_buttons(self):
|
|
||||||
html = self.render()
|
|
||||||
self.assertIn("data-date-range-cancel", html)
|
|
||||||
self.assertIn("data-date-range-clear", html)
|
|
||||||
self.assertIn("data-date-range-select", html)
|
|
||||||
self.assertIn(">Cancel<", html)
|
|
||||||
self.assertIn(">Clear<", html)
|
|
||||||
self.assertIn(">Select<", html)
|
|
||||||
|
|
||||||
def test_renders_grid_and_navigation_hooks(self):
|
|
||||||
html = self.render()
|
|
||||||
self.assertIn("data-date-range-grid", html)
|
|
||||||
self.assertIn("data-date-range-month-label", html)
|
|
||||||
self.assertIn("data-date-range-prev", html)
|
|
||||||
self.assertIn("data-date-range-next", html)
|
|
||||||
|
|
||||||
def test_starts_hidden(self):
|
|
||||||
self.assertIn('class="hidden absolute', self.render())
|
|
||||||
|
|
||||||
def test_all_buttons_are_type_button(self):
|
|
||||||
"""No button inside the calendar may submit the surrounding filter form."""
|
|
||||||
html = self.render()
|
|
||||||
button_count = html.count("<button")
|
|
||||||
self.assertEqual(html.count('<button type="button"'), button_count)
|
|
||||||
|
|
||||||
|
|
||||||
class DateRangePickerTest(SimpleTestCase):
|
|
||||||
def test_composes_field_and_calendar(self):
|
|
||||||
html = str(
|
|
||||||
DateRangePicker(
|
|
||||||
label="Purchased",
|
|
||||||
input_name_prefix="filter-date-purchased",
|
|
||||||
min_value="2024-01-01",
|
|
||||||
max_value="2024-12-31",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
self.assertIn("data-date-range-picker", html)
|
|
||||||
self.assertIn('data-input-name-prefix="filter-date-purchased"', html)
|
|
||||||
self.assertIn("data-date-range-field", html)
|
|
||||||
self.assertIn("data-date-range-calendar", html)
|
|
||||||
for marker in _ESCAPED_TAG_MARKERS:
|
|
||||||
self.assertNotIn(marker, html)
|
|
||||||
|
|
||||||
|
|
||||||
class PurchaseFilterBarDateRangePickerTest(TestCase):
|
|
||||||
"""The Purchased filter uses the DateRangePicker; Refunded keeps the
|
|
||||||
native-date DateRangeFilter (the picker is a tryout on one field)."""
|
|
||||||
|
|
||||||
def render(self, filter_json=""):
|
|
||||||
return str(
|
|
||||||
PurchaseFilterBar(
|
|
||||||
filter_json=filter_json, preset_list_url="/l", preset_save_url="/s"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_purchased_uses_date_range_picker(self):
|
|
||||||
html = self.render()
|
|
||||||
self.assertIn("data-date-range-picker", html)
|
|
||||||
self.assertIn('data-input-name-prefix="filter-date-purchased"', html)
|
|
||||||
# The hidden ISO inputs keep the names filter_bar.js serializes.
|
|
||||||
self.assertIn('name="filter-date-purchased-min"', html)
|
|
||||||
self.assertIn('name="filter-date-purchased-max"', html)
|
|
||||||
|
|
||||||
def test_refunded_keeps_native_date_inputs(self):
|
|
||||||
html = self.render()
|
|
||||||
refunded_min = html.find('name="filter-date-refunded-min"')
|
|
||||||
self.assertGreater(refunded_min, 0)
|
|
||||||
self.assertIn('type="date"', html)
|
|
||||||
self.assertNotIn('data-input-name-prefix="filter-date-refunded"', html)
|
|
||||||
|
|
||||||
def test_prefilled_between_filter_round_trips_into_picker(self):
|
|
||||||
filter_json = json.dumps(
|
|
||||||
{
|
|
||||||
"date_purchased": {
|
|
||||||
"value": "2024-03-15",
|
|
||||||
"value2": "2024-09-20",
|
|
||||||
"modifier": "BETWEEN",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
html = self.render(filter_json)
|
|
||||||
self.assertIn('value="2024-03-15"', html)
|
|
||||||
self.assertIn('value="2024-09-20"', html)
|
|
||||||
self.assertIn('value="15" data-date-part="day" data-date-side="min"', html)
|
|
||||||
self.assertIn('value="20" data-date-part="day" data-date-side="max"', html)
|
|
||||||
@@ -246,8 +246,8 @@ class FilterBarRenderingTest(TestCase):
|
|||||||
# New range slider input prefixes
|
# New range slider input prefixes
|
||||||
self.assertIn('name="filter-purchase-count-min"', html)
|
self.assertIn('name="filter-purchase-count-min"', html)
|
||||||
self.assertIn('name="filter-playevent-count-min"', html)
|
self.assertIn('name="filter-playevent-count-min"', html)
|
||||||
self.assertIn('name="filter-manual-playtime-hours-min"', html)
|
self.assertIn('name="filter-manual-playtime-minutes-min"', html)
|
||||||
self.assertIn('name="filter-calculated-playtime-hours-min"', html)
|
self.assertIn('name="filter-calculated-playtime-minutes-min"', html)
|
||||||
self.assertIn('name="filter-original-year-min"', html)
|
self.assertIn('name="filter-original-year-min"', html)
|
||||||
self.assertIn('name="filter-purchase-price-total-min"', html)
|
self.assertIn('name="filter-purchase-price-total-min"', html)
|
||||||
self.assertIn('name="filter-purchase-price-any-min"', html)
|
self.assertIn('name="filter-purchase-price-any-min"', html)
|
||||||
@@ -362,3 +362,4 @@ class FilterBarRenderingTest(TestCase):
|
|||||||
self.assertIn('name="filter-refunded"', purchase_html)
|
self.assertIn('name="filter-refunded"', purchase_html)
|
||||||
self.assertIn('value="true"', purchase_html)
|
self.assertIn('value="true"', purchase_html)
|
||||||
self.assertIn('value="false"', purchase_html)
|
self.assertIn('value="false"', purchase_html)
|
||||||
|
|
||||||
|
|||||||
@@ -85,3 +85,4 @@ class ParseBoolNullableTest(SimpleTestCase):
|
|||||||
self.assertTrue(_parse_bool_nullable({"field": {"value": "1"}}, "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": "false"}}, "field"))
|
||||||
self.assertFalse(_parse_bool_nullable({"field": {"value": "0"}}, "field"))
|
self.assertFalse(_parse_bool_nullable({"field": {"value": "0"}}, "field"))
|
||||||
|
|
||||||
|
|||||||
+16
-22
@@ -560,14 +560,8 @@ class TestFilterBarRendering:
|
|||||||
|
|
||||||
def test_mastered_not_checked_by_default(self):
|
def test_mastered_not_checked_by_default(self):
|
||||||
html = str(FilterBar(filter_json=""))
|
html = str(FilterBar(filter_json=""))
|
||||||
assert (
|
assert 'name="filter-mastered" value="true" class="rounded-full border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand" checked="true"' not in html
|
||||||
'name="filter-mastered" value="true" class="rounded-full border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand" checked="true"'
|
assert 'name="filter-mastered" value="false" class="rounded-full border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand" checked="true"' not in html
|
||||||
not in html
|
|
||||||
)
|
|
||||||
assert (
|
|
||||||
'name="filter-mastered" value="false" class="rounded-full border-default-medium bg-neutral-secondary-medium text-brand focus:ring-brand" checked="true"'
|
|
||||||
not in html
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_mastered_checked_when_filtered(self):
|
def test_mastered_checked_when_filtered(self):
|
||||||
html = str(
|
html = str(
|
||||||
@@ -712,7 +706,7 @@ class TestExpandedFiltersAgainstDB:
|
|||||||
# 2. Device & Session
|
# 2. Device & Session
|
||||||
dev, _ = Device.objects.get_or_create(name="Super Famicom", type="Console")
|
dev, _ = Device.objects.get_or_create(name="Super Famicom", type="Console")
|
||||||
|
|
||||||
# Session 1: total 4 hours (3 hours calc, 1 hour manual)
|
# Session 1: total 40 minutes (30 calc, 10 manual)
|
||||||
s1 = Session.objects.create(
|
s1 = Session.objects.create(
|
||||||
game=game,
|
game=game,
|
||||||
device=dev,
|
device=dev,
|
||||||
@@ -720,9 +714,9 @@ class TestExpandedFiltersAgainstDB:
|
|||||||
2026, 6, 1, 12, 0, 0, tzinfo=datetime.timezone.utc
|
2026, 6, 1, 12, 0, 0, tzinfo=datetime.timezone.utc
|
||||||
),
|
),
|
||||||
timestamp_end=datetime.datetime(
|
timestamp_end=datetime.datetime(
|
||||||
2026, 6, 1, 15, 0, 0, tzinfo=datetime.timezone.utc
|
2026, 6, 1, 12, 30, 0, tzinfo=datetime.timezone.utc
|
||||||
),
|
),
|
||||||
duration_manual=timedelta(hours=1),
|
duration_manual=timedelta(minutes=10),
|
||||||
)
|
)
|
||||||
|
|
||||||
# 3. Purchase
|
# 3. Purchase
|
||||||
@@ -790,23 +784,23 @@ class TestExpandedFiltersAgainstDB:
|
|||||||
from games.filters import SessionFilter
|
from games.filters import SessionFilter
|
||||||
from games.models import Session
|
from games.models import Session
|
||||||
|
|
||||||
self._setup_entities()
|
data = self._setup_entities()
|
||||||
|
|
||||||
# Test duration_total_hours equals 4
|
# Test duration_total_minutes equals 40
|
||||||
sf_tot = SessionFilter.from_json(
|
sf_tot = SessionFilter.from_json(
|
||||||
{"duration_total_hours": {"value": 4, "modifier": "EQUALS"}}
|
{"duration_total_minutes": {"value": 40, "modifier": "EQUALS"}}
|
||||||
)
|
)
|
||||||
assert Session.objects.filter(sf_tot.to_q()).count() == 1
|
assert Session.objects.filter(sf_tot.to_q()).count() == 1
|
||||||
|
|
||||||
# Test duration_manual_hours equals 1
|
# Test duration_manual_minutes equals 10
|
||||||
sf_man = SessionFilter.from_json(
|
sf_man = SessionFilter.from_json(
|
||||||
{"duration_manual_hours": {"value": 1, "modifier": "EQUALS"}}
|
{"duration_manual_minutes": {"value": 10, "modifier": "EQUALS"}}
|
||||||
)
|
)
|
||||||
assert Session.objects.filter(sf_man.to_q()).count() == 1
|
assert Session.objects.filter(sf_man.to_q()).count() == 1
|
||||||
|
|
||||||
# Test duration_calculated_hours equals 3
|
# Test duration_calculated_minutes equals 30
|
||||||
sf_calc = SessionFilter.from_json(
|
sf_calc = SessionFilter.from_json(
|
||||||
{"duration_calculated_hours": {"value": 3, "modifier": "EQUALS"}}
|
{"duration_calculated_minutes": {"value": 30, "modifier": "EQUALS"}}
|
||||||
)
|
)
|
||||||
assert Session.objects.filter(sf_calc.to_q()).count() == 1
|
assert Session.objects.filter(sf_calc.to_q()).count() == 1
|
||||||
|
|
||||||
@@ -814,7 +808,7 @@ class TestExpandedFiltersAgainstDB:
|
|||||||
from games.filters import PurchaseFilter
|
from games.filters import PurchaseFilter
|
||||||
from games.models import Purchase
|
from games.models import Purchase
|
||||||
|
|
||||||
self._setup_entities()
|
data = self._setup_entities()
|
||||||
|
|
||||||
pf = PurchaseFilter.from_json(
|
pf = PurchaseFilter.from_json(
|
||||||
{
|
{
|
||||||
@@ -1023,14 +1017,14 @@ class TestExpandedFiltersAgainstDB:
|
|||||||
from games.models import Game
|
from games.models import Game
|
||||||
|
|
||||||
data = self._setup_entities()
|
data = self._setup_entities()
|
||||||
# data["s1"] has 1 hour manual + 3 hours calculated
|
# data["s1"] has 10 minutes manual + 30 minutes calculated
|
||||||
gf_manual = GameFilter.from_json(
|
gf_manual = GameFilter.from_json(
|
||||||
{"manual_playtime_hours": {"value": 1, "modifier": "EQUALS"}}
|
{"manual_playtime_minutes": {"value": 10, "modifier": "EQUALS"}}
|
||||||
)
|
)
|
||||||
assert data["game"] in set(Game.objects.filter(gf_manual.to_q()))
|
assert data["game"] in set(Game.objects.filter(gf_manual.to_q()))
|
||||||
|
|
||||||
gf_calc = GameFilter.from_json(
|
gf_calc = GameFilter.from_json(
|
||||||
{"calculated_playtime_hours": {"value": 3, "modifier": "EQUALS"}}
|
{"calculated_playtime_minutes": {"value": 30, "modifier": "EQUALS"}}
|
||||||
)
|
)
|
||||||
assert data["game"] in set(Game.objects.filter(gf_calc.to_q()))
|
assert data["game"] in set(Game.objects.filter(gf_calc.to_q()))
|
||||||
|
|
||||||
|
|||||||
@@ -1,221 +0,0 @@
|
|||||||
"""Phase 1: the lazy node layer (Node/Element/Safe/Fragment/BaseComponent/Media).
|
|
||||||
|
|
||||||
These cover the new machinery directly: rendering, escaping, media bubbling.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
from django.utils.safestring import mark_safe
|
|
||||||
|
|
||||||
from common.components import (
|
|
||||||
BaseComponent,
|
|
||||||
Element,
|
|
||||||
Fragment,
|
|
||||||
Media,
|
|
||||||
Node,
|
|
||||||
Safe,
|
|
||||||
collect_media,
|
|
||||||
render,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ElementRenderTest(unittest.TestCase):
|
|
||||||
def test_renders_tag_attrs_children(self):
|
|
||||||
element = Element("div", [("class", "test")], "hello")
|
|
||||||
self.assertEqual(render(element), '<div class="test">hello</div>')
|
|
||||||
|
|
||||||
def test_plain_string_children_escaped(self):
|
|
||||||
self.assertEqual(
|
|
||||||
render(Element("span", children=["<b>"])), "<span><b></span>"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_safe_node_child_passes_through(self):
|
|
||||||
self.assertEqual(
|
|
||||||
render(Element("span", children=[Safe("<b>x</b>")])),
|
|
||||||
"<span><b>x</b></span>",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_safetext_child_is_escaped(self):
|
|
||||||
# A string child is always escaped — even a mark_safe/SafeText one.
|
|
||||||
# Trusted markup must be a Safe node, not a safe string.
|
|
||||||
self.assertEqual(
|
|
||||||
render(Element("span", children=[mark_safe("<b>x</b>")])),
|
|
||||||
"<span><b>x</b></span>",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_node_children_render_safely(self):
|
|
||||||
inner = Element("b", children=["x"])
|
|
||||||
self.assertEqual(
|
|
||||||
render(Element("span", children=[inner])), "<span><b>x</b></span>"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SafeAndFragmentTest(unittest.TestCase):
|
|
||||||
def test_safe_passes_html_through(self):
|
|
||||||
self.assertEqual(render(Safe("<i>raw</i>")), "<i>raw</i>")
|
|
||||||
|
|
||||||
def test_fragment_concatenates(self):
|
|
||||||
frag = Fragment(
|
|
||||||
Element("span", children=["a"]), Element("span", children=["b"])
|
|
||||||
)
|
|
||||||
self.assertEqual(render(frag), "<span>a</span><span>b</span>")
|
|
||||||
|
|
||||||
def test_fragment_skips_empty_children(self):
|
|
||||||
frag = Fragment("", None, Element("span", children=["a"]))
|
|
||||||
self.assertEqual(render(frag), "<span>a</span>")
|
|
||||||
|
|
||||||
def test_fragment_escapes_plain_strings(self):
|
|
||||||
self.assertEqual(render(Fragment("<x>", Safe("<y>"))), "<x><y>")
|
|
||||||
|
|
||||||
|
|
||||||
class MediaTest(unittest.TestCase):
|
|
||||||
def test_merge_dedups_preserving_order(self):
|
|
||||||
merged = Media(js=["a.js", "b.js"]) + Media(js=["b.js", "c.js"])
|
|
||||||
self.assertEqual(merged.js, ("a.js", "b.js", "c.js"))
|
|
||||||
|
|
||||||
def test_external_kept_separate(self):
|
|
||||||
merged = Media(js=["a.js"]) + Media(js_external=["umd.js"])
|
|
||||||
self.assertEqual(merged.js, ("a.js",))
|
|
||||||
self.assertEqual(merged.js_external, ("umd.js",))
|
|
||||||
|
|
||||||
def test_sum_with_radd(self):
|
|
||||||
merged = sum([Media(js=["a.js"]), Media(js=["b.js"])], Media())
|
|
||||||
self.assertEqual(merged.js, ("a.js", "b.js"))
|
|
||||||
|
|
||||||
def test_falsy_when_empty(self):
|
|
||||||
self.assertFalse(Media())
|
|
||||||
self.assertTrue(Media(js=["a.js"]))
|
|
||||||
|
|
||||||
|
|
||||||
class MediaCollectionTest(unittest.TestCase):
|
|
||||||
def test_bubbles_through_element_children(self):
|
|
||||||
class Widget(BaseComponent):
|
|
||||||
media = Media(js=["widget.js"])
|
|
||||||
|
|
||||||
def render(self) -> Node:
|
|
||||||
return Element("div", children=["x"])
|
|
||||||
|
|
||||||
tree = Element("section", children=[Element("div", children=[Widget()])])
|
|
||||||
self.assertEqual(collect_media(tree).js, ("widget.js",))
|
|
||||||
|
|
||||||
def test_bubbles_through_fragment(self):
|
|
||||||
class Widget(BaseComponent):
|
|
||||||
media = Media(js=["w.js"])
|
|
||||||
|
|
||||||
def render(self) -> Node:
|
|
||||||
return Element("div")
|
|
||||||
|
|
||||||
self.assertEqual(collect_media(Fragment(Widget(), Element("p"))).js, ("w.js",))
|
|
||||||
|
|
||||||
def test_component_merges_own_and_subtree_media(self):
|
|
||||||
class Inner(BaseComponent):
|
|
||||||
media = Media(js=["inner.js"])
|
|
||||||
|
|
||||||
def render(self) -> Node:
|
|
||||||
return Element("span")
|
|
||||||
|
|
||||||
class Outer(BaseComponent):
|
|
||||||
media = Media(js=["outer.js"])
|
|
||||||
|
|
||||||
def render(self) -> Node:
|
|
||||||
return Element("div", children=[Inner()])
|
|
||||||
|
|
||||||
self.assertEqual(collect_media(Outer()).js, ("outer.js", "inner.js"))
|
|
||||||
|
|
||||||
def test_bare_string_has_no_media(self):
|
|
||||||
self.assertFalse(collect_media("just a string"))
|
|
||||||
|
|
||||||
|
|
||||||
class RealComponentMediaTest(unittest.TestCase):
|
|
||||||
"""Phase 3: JS-bearing components declare media that bubbles up the tree."""
|
|
||||||
|
|
||||||
def test_search_select_declares_its_script(self):
|
|
||||||
from common.components import SearchSelect
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
collect_media(SearchSelect(name="games")).js, ("search_select.js",)
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_filter_select_declares_its_script(self):
|
|
||||||
from common.components import FilterSelect
|
|
||||||
|
|
||||||
self.assertIn(
|
|
||||||
"search_select.js", collect_media(FilterSelect(field_name="type")).js
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_date_range_picker_declares_its_script(self):
|
|
||||||
from common.components import DateRangePicker
|
|
||||||
|
|
||||||
media = collect_media(
|
|
||||||
DateRangePicker(label="Played", input_name_prefix="played")
|
|
||||||
)
|
|
||||||
self.assertEqual(media.js, ("date_range_picker.js",))
|
|
||||||
|
|
||||||
def test_range_slider_declares_its_script(self):
|
|
||||||
from common.components.filters import RangeSlider
|
|
||||||
|
|
||||||
media = collect_media(
|
|
||||||
RangeSlider(
|
|
||||||
label="Year", input_name_prefix="year", range_min=2000, range_max=2025
|
|
||||||
)
|
|
||||||
)
|
|
||||||
self.assertEqual(media.js, ("range_slider.js",))
|
|
||||||
|
|
||||||
def test_filter_bar_collects_chrome_and_widget_media(self):
|
|
||||||
"""A FilterBar's media merges its own chrome script with the scripts that
|
|
||||||
bubble up from the FilterSelect and RangeSlider widgets it contains —
|
|
||||||
exactly the set the view used to thread by hand. (FilterBar wraps its DB
|
|
||||||
aggregates in try/except, so it builds without a database.)"""
|
|
||||||
from common.components import FilterBar
|
|
||||||
|
|
||||||
media = collect_media(FilterBar())
|
|
||||||
self.assertIn("filter_bar.js", media.js)
|
|
||||||
self.assertIn("search_select.js", media.js)
|
|
||||||
self.assertIn("range_slider.js", media.js)
|
|
||||||
|
|
||||||
|
|
||||||
class HtpyStyleSugarTest(unittest.TestCase):
|
|
||||||
def test_getitem_sets_children(self):
|
|
||||||
from common.components import Div, Span
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
render(Div(class_="card")[Span()["hi"]]),
|
|
||||||
'<div class="card"><span>hi</span></div>',
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_getitem_multiple_children(self):
|
|
||||||
from common.components import Div
|
|
||||||
|
|
||||||
self.assertEqual(render(Div()["a", "b"]), "<div>a\nb</div>")
|
|
||||||
|
|
||||||
def test_kwargs_class_underscore_becomes_class(self):
|
|
||||||
from common.components import Div
|
|
||||||
|
|
||||||
self.assertIn('class="x"', render(Div(class_="x")))
|
|
||||||
|
|
||||||
def test_kwargs_inner_underscore_becomes_hyphen(self):
|
|
||||||
from common.components import Div
|
|
||||||
|
|
||||||
self.assertIn('hx-get="/y"', render(Div(hx_get="/y")))
|
|
||||||
|
|
||||||
def test_kwargs_true_renders_bare_attr(self):
|
|
||||||
from common.components import Div
|
|
||||||
|
|
||||||
self.assertIn('hidden="hidden"', render(Div(hidden=True)))
|
|
||||||
|
|
||||||
def test_kwargs_false_and_none_omitted(self):
|
|
||||||
from common.components import Div
|
|
||||||
|
|
||||||
html = render(Div(hidden=False, title=None))
|
|
||||||
self.assertNotIn("hidden", html)
|
|
||||||
self.assertNotIn("title", html)
|
|
||||||
|
|
||||||
def test_getitem_preserves_media(self):
|
|
||||||
from common.components import Div, Media, collect_media
|
|
||||||
|
|
||||||
node = Div(class_="x").with_media(Media(js=("a.js",)))["child"]
|
|
||||||
self.assertEqual(collect_media(node).js, ("a.js",))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
from datetime import date
|
|
||||||
|
|
||||||
from django.core.exceptions import ValidationError
|
|
||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
from games.models import Game, Platform, Purchase
|
|
||||||
|
|
||||||
|
|
||||||
class PurchaseRelatedGameTest(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.platform = Platform.objects.create(name="PC", icon="pc", group="PC")
|
|
||||||
self.base_game = Game.objects.create(name="Base Game", platform=self.platform)
|
|
||||||
self.dlc_game = Game.objects.create(name="The DLC", platform=self.platform)
|
|
||||||
|
|
||||||
def test_non_game_purchase_requires_related_game(self):
|
|
||||||
purchase = Purchase(
|
|
||||||
price=10.0,
|
|
||||||
price_currency="USD",
|
|
||||||
date_purchased=date(2025, 1, 1),
|
|
||||||
type=Purchase.SEASONPASS,
|
|
||||||
name="Season Pass",
|
|
||||||
)
|
|
||||||
with self.assertRaises(ValidationError):
|
|
||||||
purchase.save()
|
|
||||||
|
|
||||||
def test_non_game_purchase_saves_with_related_game(self):
|
|
||||||
purchase = Purchase(
|
|
||||||
price=10.0,
|
|
||||||
price_currency="USD",
|
|
||||||
date_purchased=date(2025, 1, 1),
|
|
||||||
type=Purchase.SEASONPASS,
|
|
||||||
name="Season Pass",
|
|
||||||
related_game=self.base_game,
|
|
||||||
)
|
|
||||||
purchase.save()
|
|
||||||
purchase.games.add(self.dlc_game)
|
|
||||||
|
|
||||||
self.assertEqual(purchase.related_game, self.base_game)
|
|
||||||
# Reverse accessor: the base game lists its add-on purchases.
|
|
||||||
self.assertIn(purchase, self.base_game.addon_purchases.all())
|
|
||||||
|
|
||||||
def test_plain_game_purchase_needs_no_related_game(self):
|
|
||||||
purchase = Purchase(
|
|
||||||
price=50.0,
|
|
||||||
price_currency="USD",
|
|
||||||
date_purchased=date(2025, 1, 1),
|
|
||||||
type=Purchase.GAME,
|
|
||||||
)
|
|
||||||
purchase.save() # must not raise
|
|
||||||
self.assertIsNone(purchase.related_game)
|
|
||||||
@@ -57,22 +57,6 @@ class RenderedPagesTest(TestCase):
|
|||||||
marker, html, f"Found double-escaped markup ({marker!r}) in output"
|
marker, html, f"Found double-escaped markup ({marker!r}) in output"
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- scripts auto-collected from component media (Phase 4) ---------------
|
|
||||||
|
|
||||||
def test_list_page_auto_loads_widget_scripts(self):
|
|
||||||
"""The games list view passes no scripts= argument; the filter bar's
|
|
||||||
components declare their JS and Page() collects it."""
|
|
||||||
html = self.get("games:list_games").content.decode()
|
|
||||||
self.assertIn("js/filter_bar.js", html)
|
|
||||||
self.assertIn("js/search_select.js", html)
|
|
||||||
self.assertIn("js/range_slider.js", html)
|
|
||||||
|
|
||||||
def test_stats_page_auto_loads_datepicker(self):
|
|
||||||
"""YearPicker declares the datepicker UMD bundle as media; the stats
|
|
||||||
view no longer hoists it by hand."""
|
|
||||||
html = self.get("games:stats_alltime").content.decode()
|
|
||||||
self.assertIn("js/datepicker.umd.js", html)
|
|
||||||
|
|
||||||
# --- layout wrapper ------------------------------------------------------
|
# --- layout wrapper ------------------------------------------------------
|
||||||
|
|
||||||
def test_page_layout_wrapper(self):
|
def test_page_layout_wrapper(self):
|
||||||
@@ -139,7 +123,7 @@ class RenderedPagesTest(TestCase):
|
|||||||
|
|
||||||
def test_add_session_form_has_timestamp_helpers(self):
|
def test_add_session_form_has_timestamp_helpers(self):
|
||||||
html = self.get("games:add_session").content.decode()
|
html = self.get("games:add_session").content.decode()
|
||||||
self.assertIn("session-timestamp-buttons", html)
|
self.assertIn("add_session.js", html)
|
||||||
for marker in [
|
for marker in [
|
||||||
"Set to now",
|
"Set to now",
|
||||||
"Toggle text",
|
"Toggle text",
|
||||||
@@ -168,7 +152,7 @@ class RenderedPagesTest(TestCase):
|
|||||||
"Platform",
|
"Platform",
|
||||||
'id="history-container"',
|
'id="history-container"',
|
||||||
"status-changed from:body",
|
"status-changed from:body",
|
||||||
"<play-event-row", # the played-row custom element
|
"createPlayEvent", # the played-row Alpine dropdown script
|
||||||
'hx-target="#global-modal-container"', # delete trigger
|
'hx-target="#global-modal-container"', # delete trigger
|
||||||
"Purchases",
|
"Purchases",
|
||||||
"Sessions",
|
"Sessions",
|
||||||
@@ -179,14 +163,6 @@ class RenderedPagesTest(TestCase):
|
|||||||
self.assertNoEscapedTags(html)
|
self.assertNoEscapedTags(html)
|
||||||
self.assertEqual(html.count("<div"), html.count("</div>"))
|
self.assertEqual(html.count("<div"), html.count("</div>"))
|
||||||
|
|
||||||
def test_view_game_uses_play_event_row_element(self):
|
|
||||||
game = Game.objects.create(name="Played Game", platform=self.platform)
|
|
||||||
html = self.get("games:view_game", game.id).content.decode()
|
|
||||||
self.assertIn("<play-event-row", html)
|
|
||||||
self.assertIn('game-id="', html)
|
|
||||||
self.assertNotIn("@@", html) # token-replace hack gone
|
|
||||||
self.assertNotIn("createPlayEvent", html) # the old Alpine fn is gone
|
|
||||||
|
|
||||||
def test_view_game_empty_sections(self):
|
def test_view_game_empty_sections(self):
|
||||||
"""A game with no sessions/purchases/etc shows the empty messages."""
|
"""A game with no sessions/purchases/etc shows the empty messages."""
|
||||||
lonely = Game.objects.create(name="Lonely Game", platform=self.platform)
|
lonely = Game.objects.create(name="Lonely Game", platform=self.platform)
|
||||||
@@ -419,12 +395,15 @@ class PurchaseListDateFilterTest(TestCase):
|
|||||||
html,
|
html,
|
||||||
)
|
)
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
'name="filter-date-purchased-max" id="filter-date-purchased-max" value=""',
|
'name="filter-date-purchased-max" id="filter-date-purchased-max" '
|
||||||
|
'value=""',
|
||||||
html,
|
html,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_date_refunded_not_null(self):
|
def test_date_refunded_not_null(self):
|
||||||
response = self._get({"date_refunded": {"value": "", "modifier": "NOT_NULL"}})
|
response = self._get(
|
||||||
|
{"date_refunded": {"value": "", "modifier": "NOT_NULL"}}
|
||||||
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
html = response.content.decode()
|
html = response.content.decode()
|
||||||
self.assertNotIn("EARLY-MARKER", html)
|
self.assertNotIn("EARLY-MARKER", html)
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
from django.contrib.sessions.models import Session as DjangoSession
|
|
||||||
from django.core.management import call_command
|
|
||||||
from django.test import TransactionTestCase
|
|
||||||
from django.utils.timezone import now
|
|
||||||
from django_q.models import Schedule
|
|
||||||
|
|
||||||
|
|
||||||
class ScrubStagingTest(TransactionTestCase):
|
|
||||||
# TransactionTestCase flushes the DB before each test instead of wrapping
|
|
||||||
# in a savepoint. Required here because scrub_staging deletes all sessions
|
|
||||||
# — a TestCase savepoint rollback would restore any sessions committed by
|
|
||||||
# earlier tests (e.g. force_login in test_paths_return_200) and leak state
|
|
||||||
# into the e2e live-server tests that follow.
|
|
||||||
|
|
||||||
def test_scrub_removes_sessions_and_schedules(self):
|
|
||||||
DjangoSession.objects.create(
|
|
||||||
session_key="copied-from-prod",
|
|
||||||
session_data="",
|
|
||||||
expire_date=now() + timedelta(days=1),
|
|
||||||
)
|
|
||||||
Schedule.objects.create(
|
|
||||||
func="games.tasks.convert_prices",
|
|
||||||
name="Update converted prices",
|
|
||||||
schedule_type=Schedule.MINUTES,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(DjangoSession.objects.count(), 1)
|
|
||||||
self.assertEqual(Schedule.objects.count(), 1)
|
|
||||||
|
|
||||||
call_command("scrub_staging")
|
|
||||||
|
|
||||||
self.assertEqual(DjangoSession.objects.count(), 0)
|
|
||||||
self.assertEqual(Schedule.objects.count(), 0)
|
|
||||||
|
|
||||||
def test_scrub_is_safe_on_empty_database(self):
|
|
||||||
call_command("scrub_staging")
|
|
||||||
|
|
||||||
self.assertEqual(DjangoSession.objects.count(), 0)
|
|
||||||
self.assertEqual(Schedule.objects.count(), 0)
|
|
||||||
+87
-106
@@ -7,62 +7,57 @@ import django.test
|
|||||||
from django.utils.safestring import SafeText
|
from django.utils.safestring import SafeText
|
||||||
|
|
||||||
from common.components import (
|
from common.components import (
|
||||||
|
FilterSelect,
|
||||||
|
Pill,
|
||||||
|
SearchSelect,
|
||||||
searchselect_selected,
|
searchselect_selected,
|
||||||
)
|
)
|
||||||
from common.components import FilterSelect, Pill, SearchSelect
|
|
||||||
from games.models import Game, Platform
|
from games.models import Game, Platform
|
||||||
|
|
||||||
# These components are lazy nodes; the tests below assert on rendered HTML, so
|
|
||||||
# each call is wrapped in ``str(...)`` (``Node.__str__`` returns a ``SafeText``,
|
|
||||||
# which keeps the ``assertIsInstance(..., SafeText)`` checks meaningful and the
|
|
||||||
# string assertions working).
|
|
||||||
|
|
||||||
|
|
||||||
class PillTest(unittest.TestCase):
|
class PillTest(unittest.TestCase):
|
||||||
def test_returns_safetext(self):
|
def test_returns_safetext(self):
|
||||||
self.assertIsInstance(str(Pill("hi")), SafeText)
|
self.assertIsInstance(Pill("hi"), SafeText)
|
||||||
|
|
||||||
def test_plain_pill_has_data_pill_no_remove(self):
|
def test_plain_pill_has_data_pill_no_remove(self):
|
||||||
html = str(Pill("hi"))
|
html = Pill("hi")
|
||||||
self.assertIn("data-pill", html)
|
self.assertIn("data-pill", html)
|
||||||
self.assertNotIn("data-pill-remove", html)
|
self.assertNotIn("data-pill-remove", html)
|
||||||
|
|
||||||
def test_removable_adds_remove_button(self):
|
def test_removable_adds_remove_button(self):
|
||||||
html = str(Pill("hi", removable=True))
|
html = Pill("hi", removable=True)
|
||||||
self.assertIn("data-pill-remove", html)
|
self.assertIn("data-pill-remove", html)
|
||||||
self.assertIn('aria-label="Remove"', html)
|
self.assertIn('aria-label="Remove"', html)
|
||||||
|
|
||||||
def test_value_becomes_data_value(self):
|
def test_value_becomes_data_value(self):
|
||||||
html = str(Pill("hi", value="42"))
|
html = Pill("hi", value="42")
|
||||||
self.assertIn('data-value="42"', html)
|
self.assertIn('data-value="42"', html)
|
||||||
|
|
||||||
def test_no_value_omits_data_value(self):
|
def test_no_value_omits_data_value(self):
|
||||||
self.assertNotIn("data-value", str(Pill("hi")))
|
self.assertNotIn("data-value", Pill("hi"))
|
||||||
|
|
||||||
def test_label_is_escaped(self):
|
def test_label_is_escaped(self):
|
||||||
html = str(Pill("<b>x</b>"))
|
html = Pill("<b>x</b>")
|
||||||
self.assertIn("<b>", html)
|
self.assertIn("<b>", html)
|
||||||
self.assertNotIn("<b>x</b>", html)
|
self.assertNotIn("<b>x</b>", html)
|
||||||
|
|
||||||
def test_extra_data_attributes(self):
|
def test_extra_data_attributes(self):
|
||||||
html = str(Pill("hi", attributes=[("data-platform", "3")]))
|
html = Pill("hi", attributes=[("data-platform", "3")])
|
||||||
self.assertIn('data-platform="3"', html)
|
self.assertIn('data-platform="3"', html)
|
||||||
|
|
||||||
|
|
||||||
class SearchSelectComponentTest(unittest.TestCase):
|
class SearchSelectComponentTest(unittest.TestCase):
|
||||||
def test_returns_safetext(self):
|
def test_returns_safetext(self):
|
||||||
self.assertIsInstance(str(SearchSelect(name="games")), SafeText)
|
self.assertIsInstance(SearchSelect(name="games"), SafeText)
|
||||||
|
|
||||||
def test_empty_options_renders_no_results_scaffold(self):
|
def test_empty_options_renders_no_results_scaffold(self):
|
||||||
html = str(SearchSelect(name="games"))
|
html = SearchSelect(name="games")
|
||||||
self.assertIn("data-search-select-no-results", html)
|
self.assertIn("data-search-select-no-results", html)
|
||||||
self.assertIn("No results", html)
|
self.assertIn("No results", html)
|
||||||
|
|
||||||
def test_outer_container_carries_config(self):
|
def test_outer_container_carries_config(self):
|
||||||
html = str(
|
html = SearchSelect(
|
||||||
SearchSelect(
|
name="games", search_url="/api/games/search", multi_select=True
|
||||||
name="games", search_url="/api/games/search", multi_select=True
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
self.assertIn("data-search-select", html)
|
self.assertIn("data-search-select", html)
|
||||||
self.assertIn('data-name="games"', html)
|
self.assertIn('data-name="games"', html)
|
||||||
@@ -70,12 +65,10 @@ class SearchSelectComponentTest(unittest.TestCase):
|
|||||||
self.assertIn('data-multi="true"', html)
|
self.assertIn('data-multi="true"', html)
|
||||||
|
|
||||||
def test_multi_selected_renders_pills_and_hidden_inputs(self):
|
def test_multi_selected_renders_pills_and_hidden_inputs(self):
|
||||||
html = str(
|
html = SearchSelect(
|
||||||
SearchSelect(
|
name="games",
|
||||||
name="games",
|
multi_select=True,
|
||||||
multi_select=True,
|
selected=[{"value": 7, "label": "Game A", "data": {"platform": "2"}}],
|
||||||
selected=[{"value": 7, "label": "Game A", "data": {"platform": "2"}}],
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
self.assertIn("data-pill", html)
|
self.assertIn("data-pill", html)
|
||||||
self.assertIn('<input name="games" value="7" type="hidden">', html)
|
self.assertIn('<input name="games" value="7" type="hidden">', html)
|
||||||
@@ -85,11 +78,9 @@ class SearchSelectComponentTest(unittest.TestCase):
|
|||||||
self.assertEqual(html.count(' name="games"'), 1)
|
self.assertEqual(html.count(' name="games"'), 1)
|
||||||
|
|
||||||
def test_single_selected_has_no_pill_and_value_in_search_box(self):
|
def test_single_selected_has_no_pill_and_value_in_search_box(self):
|
||||||
html = str(
|
html = SearchSelect(
|
||||||
SearchSelect(
|
name="games",
|
||||||
name="games",
|
selected=[{"value": 7, "label": "Game A", "data": {"platform": "2"}}],
|
||||||
selected=[{"value": 7, "label": "Game A", "data": {"platform": "2"}}],
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
# single-select renders no pill — the label lives in the search box
|
# single-select renders no pill — the label lives in the search box
|
||||||
self.assertNotIn("data-pill", html)
|
self.assertNotIn("data-pill", html)
|
||||||
@@ -99,22 +90,20 @@ class SearchSelectComponentTest(unittest.TestCase):
|
|||||||
self.assertEqual(html.count(' name="games"'), 1)
|
self.assertEqual(html.count(' name="games"'), 1)
|
||||||
|
|
||||||
def test_search_box_has_no_name(self):
|
def test_search_box_has_no_name(self):
|
||||||
html = str(SearchSelect(name="games"))
|
html = SearchSelect(name="games")
|
||||||
self.assertIn("data-search-select-search", html)
|
self.assertIn("data-search-select-search", html)
|
||||||
# container exposes data-name, never a submittable name on the search box
|
# container exposes data-name, never a submittable name on the search box
|
||||||
self.assertEqual(html.count(' name="games"'), 0)
|
self.assertEqual(html.count(' name="games"'), 0)
|
||||||
|
|
||||||
def test_tuple_options_are_normalized(self):
|
def test_tuple_options_are_normalized(self):
|
||||||
html = str(SearchSelect(name="t", options=[("1", "One")]))
|
html = SearchSelect(name="t", options=[("1", "One")])
|
||||||
self.assertIn('data-search-select-option=""', html)
|
self.assertIn('data-search-select-option=""', html)
|
||||||
self.assertIn('data-value="1"', html)
|
self.assertIn('data-value="1"', html)
|
||||||
self.assertIn("One", html)
|
self.assertIn("One", html)
|
||||||
|
|
||||||
def test_options_omitted_when_search_url_set(self):
|
def test_options_omitted_when_search_url_set(self):
|
||||||
html = str(
|
html = SearchSelect(
|
||||||
SearchSelect(
|
name="t", options=[("1", "One")], search_url="/api/games/search"
|
||||||
name="t", options=[("1", "One")], search_url="/api/games/search"
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
# No pre-rendered rows in the live panel; the row prototype lives only in
|
# No pre-rendered rows in the live panel; the row prototype lives only in
|
||||||
# the cloneable <template>.
|
# the cloneable <template>.
|
||||||
@@ -125,9 +114,7 @@ class SearchSelectComponentTest(unittest.TestCase):
|
|||||||
def test_templates_carry_label_slot_for_js_cloning(self):
|
def test_templates_carry_label_slot_for_js_cloning(self):
|
||||||
# The dynamic shapes the JS clones expose a [data-search-select-label] slot so the JS
|
# The dynamic shapes the JS clones expose a [data-search-select-label] slot so the JS
|
||||||
# only fills text — classes/structure stay server-side.
|
# only fills text — classes/structure stay server-side.
|
||||||
html = str(
|
html = SearchSelect(name="t", search_url="/api/games/search", multi_select=True)
|
||||||
SearchSelect(name="t", search_url="/api/games/search", multi_select=True)
|
|
||||||
)
|
|
||||||
self.assertIn('data-search-select-template="row"', html)
|
self.assertIn('data-search-select-template="row"', html)
|
||||||
self.assertIn('data-search-select-template="pill"', html)
|
self.assertIn('data-search-select-template="pill"', html)
|
||||||
self.assertIn("data-search-select-label", html)
|
self.assertIn("data-search-select-label", html)
|
||||||
@@ -135,7 +122,7 @@ class SearchSelectComponentTest(unittest.TestCase):
|
|||||||
def test_shell_region_order_pills_search_options(self):
|
def test_shell_region_order_pills_search_options(self):
|
||||||
# The shared shell assembles the three regions in a fixed order; option
|
# The shared shell assembles the three regions in a fixed order; option
|
||||||
# rows precede the trailing no-results node inside the options panel.
|
# rows precede the trailing no-results node inside the options panel.
|
||||||
html = str(SearchSelect(name="t", options=[("1", "One")]))
|
html = SearchSelect(name="t", options=[("1", "One")])
|
||||||
pills = html.index("data-search-select-pills")
|
pills = html.index("data-search-select-pills")
|
||||||
search = html.index("data-search-select-search")
|
search = html.index("data-search-select-search")
|
||||||
options = html.index("data-search-select-options")
|
options = html.index("data-search-select-options")
|
||||||
@@ -148,11 +135,11 @@ class SearchSelectComponentTest(unittest.TestCase):
|
|||||||
|
|
||||||
def test_prefetch_attribute_and_defaults(self):
|
def test_prefetch_attribute_and_defaults(self):
|
||||||
# Default prefetch is 0 in SearchSelect
|
# Default prefetch is 0 in SearchSelect
|
||||||
html_default = str(SearchSelect(name="t"))
|
html_default = SearchSelect(name="t")
|
||||||
self.assertIn('data-prefetch="0"', html_default)
|
self.assertIn('data-prefetch="0"', html_default)
|
||||||
|
|
||||||
# Custom prefetch is rendered
|
# Custom prefetch is rendered
|
||||||
html_custom = str(SearchSelect(name="t", prefetch=42))
|
html_custom = SearchSelect(name="t", prefetch=42)
|
||||||
self.assertIn('data-prefetch="42"', html_custom)
|
self.assertIn('data-prefetch="42"', html_custom)
|
||||||
|
|
||||||
|
|
||||||
@@ -160,10 +147,10 @@ class FilterSelectComponentTest(unittest.TestCase):
|
|||||||
MODIFIERS = [("NOT_NULL", "(Any)"), ("IS_NULL", "(None)")]
|
MODIFIERS = [("NOT_NULL", "(Any)"), ("IS_NULL", "(None)")]
|
||||||
|
|
||||||
def test_returns_safetext(self):
|
def test_returns_safetext(self):
|
||||||
self.assertIsInstance(str(FilterSelect(field_name="type")), SafeText)
|
self.assertIsInstance(FilterSelect(field_name="type"), SafeText)
|
||||||
|
|
||||||
def test_is_filter_mode_on_shared_shell(self):
|
def test_is_filter_mode_on_shared_shell(self):
|
||||||
html = str(FilterSelect(field_name="type"))
|
html = FilterSelect(field_name="type")
|
||||||
# Reuses the SearchSelect shell (data-search-select) but flags filter mode.
|
# Reuses the SearchSelect shell (data-search-select) but flags filter mode.
|
||||||
self.assertIn("data-search-select", html)
|
self.assertIn("data-search-select", html)
|
||||||
self.assertIn('data-search-select-mode="filter"', html)
|
self.assertIn('data-search-select-mode="filter"', html)
|
||||||
@@ -172,19 +159,17 @@ class FilterSelectComponentTest(unittest.TestCase):
|
|||||||
self.assertEqual(html.count(' name="type"'), 0)
|
self.assertEqual(html.count(' name="type"'), 0)
|
||||||
|
|
||||||
def test_value_rows_have_include_exclude_buttons(self):
|
def test_value_rows_have_include_exclude_buttons(self):
|
||||||
html = str(FilterSelect(field_name="type", options=[("g", "Game")]))
|
html = FilterSelect(field_name="type", options=[("g", "Game")])
|
||||||
self.assertIn('data-search-select-action="include"', html)
|
self.assertIn('data-search-select-action="include"', html)
|
||||||
self.assertIn('data-search-select-action="exclude"', html)
|
self.assertIn('data-search-select-action="exclude"', html)
|
||||||
self.assertIn('data-value="g"', html)
|
self.assertIn('data-value="g"', html)
|
||||||
|
|
||||||
def test_included_renders_check_pill_excluded_renders_cross_pill(self):
|
def test_included_renders_check_pill_excluded_renders_cross_pill(self):
|
||||||
html = str(
|
html = FilterSelect(
|
||||||
FilterSelect(
|
field_name="platform",
|
||||||
field_name="platform",
|
options=[("1", "Steam"), ("2", "GOG")],
|
||||||
options=[("1", "Steam"), ("2", "GOG")],
|
included=[("1", "Steam")],
|
||||||
included=[("1", "Steam")],
|
excluded=[("2", "GOG")],
|
||||||
excluded=[("2", "GOG")],
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
# Labels live in a [data-search-select-label] slot (so JS can fill clones); the ✓/✗
|
# Labels live in a [data-search-select-label] slot (so JS can fill clones); the ✓/✗
|
||||||
# symbol is a sibling text node.
|
# symbol is a sibling text node.
|
||||||
@@ -197,7 +182,7 @@ class FilterSelectComponentTest(unittest.TestCase):
|
|||||||
self.assertIn("line-through", html) # excluded pill styling
|
self.assertIn("line-through", html) # excluded pill styling
|
||||||
|
|
||||||
def test_modifier_options_render_pinned_rows(self):
|
def test_modifier_options_render_pinned_rows(self):
|
||||||
html = str(FilterSelect(field_name="platform", modifier_options=self.MODIFIERS))
|
html = FilterSelect(field_name="platform", modifier_options=self.MODIFIERS)
|
||||||
# Pinned pseudo-options carry data-search-select-modifier-option, never data-search-select-option,
|
# Pinned pseudo-options carry data-search-select-modifier-option, never data-search-select-option,
|
||||||
# so the text filter leaves them visible.
|
# so the text filter leaves them visible.
|
||||||
self.assertIn('data-search-select-modifier-option="NOT_NULL"', html)
|
self.assertIn('data-search-select-modifier-option="NOT_NULL"', html)
|
||||||
@@ -206,29 +191,27 @@ class FilterSelectComponentTest(unittest.TestCase):
|
|||||||
def test_modifier_pill_coexists_with_value_pills(self):
|
def test_modifier_pill_coexists_with_value_pills(self):
|
||||||
"""Modifier and value pills both render server-side; the JS handles
|
"""Modifier and value pills both render server-side; the JS handles
|
||||||
mutual exclusivity for presence modifiers (PRESENCE_MODIFIERS)."""
|
mutual exclusivity for presence modifiers (PRESENCE_MODIFIERS)."""
|
||||||
html = str(
|
html = FilterSelect(
|
||||||
FilterSelect(
|
field_name="platform",
|
||||||
field_name="platform",
|
options=[("1", "Steam")],
|
||||||
options=[("1", "Steam")],
|
included=[("1", "Steam")],
|
||||||
included=[("1", "Steam")],
|
modifier="IS_NULL",
|
||||||
modifier="IS_NULL",
|
modifier_options=self.MODIFIERS,
|
||||||
modifier_options=self.MODIFIERS,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
# Both the modifier pill and the value pill render.
|
# Both the modifier pill and the value pill render.
|
||||||
self.assertIn('data-search-select-modifier="IS_NULL"', html)
|
self.assertIn('data-search-select-modifier="IS_NULL"', html)
|
||||||
self.assertIn("(None)", html)
|
self.assertIn("(None)", html)
|
||||||
self.assertIn('data-search-select-type="include"', html) # value pill present
|
self.assertIn(
|
||||||
|
'data-search-select-type="include"', html
|
||||||
|
) # value pill present
|
||||||
self.assertIn('data-modifier="IS_NULL"', html) # container carries it too
|
self.assertIn('data-modifier="IS_NULL"', html) # container carries it too
|
||||||
|
|
||||||
def test_search_url_omits_value_rows_but_keeps_modifiers(self):
|
def test_search_url_omits_value_rows_but_keeps_modifiers(self):
|
||||||
html = str(
|
html = FilterSelect(
|
||||||
FilterSelect(
|
field_name="game",
|
||||||
field_name="game",
|
search_url="/api/games/search",
|
||||||
search_url="/api/games/search",
|
prefetch=20,
|
||||||
prefetch=20,
|
modifier_options=self.MODIFIERS,
|
||||||
modifier_options=self.MODIFIERS,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
# No value rows in the live panel (they're fetched); the row prototype
|
# No value rows in the live panel (they're fetched); the row prototype
|
||||||
# lives only in a <template>.
|
# lives only in a <template>.
|
||||||
@@ -242,12 +225,10 @@ class FilterSelectComponentTest(unittest.TestCase):
|
|||||||
|
|
||||||
def test_search_url_pills_use_resolved_labels(self):
|
def test_search_url_pills_use_resolved_labels(self):
|
||||||
# A selected value outside the fetched window still shows its label.
|
# A selected value outside the fetched window still shows its label.
|
||||||
html = str(
|
html = FilterSelect(
|
||||||
FilterSelect(
|
field_name="game",
|
||||||
field_name="game",
|
search_url="/api/games/search",
|
||||||
search_url="/api/games/search",
|
excluded=[{"value": 4172, "label": "Obscure Game", "data": {}}],
|
||||||
excluded=[{"value": 4172, "label": "Obscure Game", "data": {}}],
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
self.assertIn(">Obscure Game</span>", html)
|
self.assertIn(">Obscure Game</span>", html)
|
||||||
self.assertIn('data-value="4172"', html)
|
self.assertIn('data-value="4172"', html)
|
||||||
@@ -260,38 +241,40 @@ class FilterSelectComponentTest(unittest.TestCase):
|
|||||||
def test_m2m_modifiers_render_as_option_rows(self):
|
def test_m2m_modifiers_render_as_option_rows(self):
|
||||||
"""M2M modifiers (All)/(Only) render as modifier-option rows in the
|
"""M2M modifiers (All)/(Only) render as modifier-option rows in the
|
||||||
dropdown, not as a separate <select>."""
|
dropdown, not as a separate <select>."""
|
||||||
html = str(
|
html = FilterSelect(
|
||||||
FilterSelect(
|
field_name="games",
|
||||||
field_name="games",
|
modifier_options=[
|
||||||
modifier_options=[
|
("NOT_NULL", "(Any)"),
|
||||||
("NOT_NULL", "(Any)"),
|
("IS_NULL", "(None)"),
|
||||||
("IS_NULL", "(None)"),
|
("INCLUDES_ALL", "(All)"),
|
||||||
("INCLUDES_ALL", "(All)"),
|
("INCLUDES_ONLY", "(Only)"),
|
||||||
("INCLUDES_ONLY", "(Only)"),
|
],
|
||||||
],
|
)
|
||||||
)
|
self.assertIn(
|
||||||
|
'data-search-select-modifier-option="INCLUDES_ALL"', html
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
'data-search-select-modifier-option="INCLUDES_ONLY"', html
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
'data-search-select-modifier-option="NOT_NULL"', html
|
||||||
)
|
)
|
||||||
self.assertIn('data-search-select-modifier-option="INCLUDES_ALL"', html)
|
|
||||||
self.assertIn('data-search-select-modifier-option="INCLUDES_ONLY"', html)
|
|
||||||
self.assertIn('data-search-select-modifier-option="NOT_NULL"', html)
|
|
||||||
# No legacy match-mode <select>.
|
# No legacy match-mode <select>.
|
||||||
self.assertNotIn("data-search-select-match", html)
|
self.assertNotIn("data-search-select-match", html)
|
||||||
|
|
||||||
def test_active_modifier_renders_pill(self):
|
def test_active_modifier_renders_pill(self):
|
||||||
"""When modifier is INCLUDES_ALL, the modifier pill renders with the
|
"""When modifier is INCLUDES_ALL, the modifier pill renders with the
|
||||||
(All) label alongside any value pills."""
|
(All) label alongside any value pills."""
|
||||||
html = str(
|
html = FilterSelect(
|
||||||
FilterSelect(
|
field_name="games",
|
||||||
field_name="games",
|
modifier="INCLUDES_ALL",
|
||||||
modifier="INCLUDES_ALL",
|
modifier_options=[
|
||||||
modifier_options=[
|
("NOT_NULL", "(Any)"),
|
||||||
("NOT_NULL", "(Any)"),
|
("IS_NULL", "(None)"),
|
||||||
("IS_NULL", "(None)"),
|
("INCLUDES_ALL", "(All)"),
|
||||||
("INCLUDES_ALL", "(All)"),
|
("INCLUDES_ONLY", "(Only)"),
|
||||||
("INCLUDES_ONLY", "(Only)"),
|
],
|
||||||
],
|
included=[{"value": 5, "label": "Hollow Knight", "data": {}}],
|
||||||
included=[{"value": 5, "label": "Hollow Knight", "data": {}}],
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
self.assertIn('data-modifier="INCLUDES_ALL"', html)
|
self.assertIn('data-modifier="INCLUDES_ALL"', html)
|
||||||
self.assertIn("(All)", html)
|
self.assertIn("(All)", html)
|
||||||
@@ -300,12 +283,10 @@ class FilterSelectComponentTest(unittest.TestCase):
|
|||||||
|
|
||||||
def test_presence_only_modifiers_no_m2m_rows(self):
|
def test_presence_only_modifiers_no_m2m_rows(self):
|
||||||
"""When modifier_options only has presence entries, no M2M rows appear."""
|
"""When modifier_options only has presence entries, no M2M rows appear."""
|
||||||
html = str(
|
html = FilterSelect(
|
||||||
FilterSelect(
|
field_name="status",
|
||||||
field_name="status",
|
modifier_options=[("NOT_NULL", "(Any)"), ("IS_NULL", "(None)")],
|
||||||
modifier_options=[("NOT_NULL", "(Any)"), ("IS_NULL", "(None)")],
|
options=[("f", "Finished")],
|
||||||
options=[("f", "Finished")],
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
self.assertNotIn("INCLUDES_ALL", html)
|
self.assertNotIn("INCLUDES_ALL", html)
|
||||||
self.assertNotIn("INCLUDES_ONLY", html)
|
self.assertNotIn("INCLUDES_ONLY", html)
|
||||||
|
|||||||
@@ -1,212 +0,0 @@
|
|||||||
"""
|
|
||||||
Centralized configuration reading for timetracker.
|
|
||||||
|
|
||||||
Every configurable Django setting is resolved through :func:`config`, which
|
|
||||||
consults several sources in a fixed priority order (highest first):
|
|
||||||
|
|
||||||
1. ``NAME__FILE`` — path to a file whose *stripped* contents are the value.
|
|
||||||
Only consulted when the setting opts in with
|
|
||||||
``allow_file=True``. Intended for Docker/Kubernetes
|
|
||||||
secrets, which are mounted as files rather than env vars.
|
|
||||||
2. ``NAME`` — a real process environment variable.
|
|
||||||
3. ``.env`` file — ``KEY=value`` lines (see the supported syntax below).
|
|
||||||
4. ``settings.ini`` — the ``[timetracker]`` section, parsed with
|
|
||||||
:mod:`configparser`.
|
|
||||||
5. ``default`` — the in-code fallback passed to :func:`config`.
|
|
||||||
|
|
||||||
If no source supplies a value and no ``default`` is given, an
|
|
||||||
:class:`~django.core.exceptions.ImproperlyConfigured` error is raised.
|
|
||||||
|
|
||||||
``.env`` syntax supported:
|
|
||||||
|
|
||||||
- ``KEY=value`` and ``export KEY=value``
|
|
||||||
- blank lines and ``#`` full-line comments
|
|
||||||
- single- or double-quoted values (the surrounding quotes are stripped); a
|
|
||||||
``#`` inside quotes is treated literally
|
|
||||||
- an inline ``# comment`` after an *unquoted* value
|
|
||||||
|
|
||||||
Deliberately NOT supported (documented limits, not bugs):
|
|
||||||
|
|
||||||
- variable interpolation (``${OTHER}``)
|
|
||||||
- multiline values
|
|
||||||
|
|
||||||
File locations default to ``.env`` and ``settings.ini`` next to the project
|
|
||||||
root and can be overridden with the ``ENV_FILE`` / ``INI_FILE`` environment
|
|
||||||
variables. Missing files are silently ignored so env-only deployments are
|
|
||||||
unaffected.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
from configparser import ConfigParser
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Callable
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
|
||||||
|
|
||||||
# Sentinel distinguishing "no default supplied" from an explicit ``None``.
|
|
||||||
NOT_SET: Any = object()
|
|
||||||
|
|
||||||
INI_SECTION = "timetracker"
|
|
||||||
|
|
||||||
_env_file_cache: dict[str, str] | None = None
|
|
||||||
_ini_file_cache: dict[str, str] | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def _unquote(value: str) -> str:
|
|
||||||
"""Strip surrounding quotes, or an inline comment from an unquoted value."""
|
|
||||||
if not value:
|
|
||||||
return value
|
|
||||||
quote = value[0]
|
|
||||||
if quote in "\"'":
|
|
||||||
closing = value.find(quote, 1)
|
|
||||||
if closing != -1:
|
|
||||||
return value[1:closing]
|
|
||||||
# Opening quote with no match: drop it and keep the rest verbatim.
|
|
||||||
return value[1:]
|
|
||||||
comment_index = value.find("#")
|
|
||||||
if comment_index != -1:
|
|
||||||
value = value[:comment_index]
|
|
||||||
return value.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_env_file(path: Path) -> dict[str, str]:
|
|
||||||
values: dict[str, str] = {}
|
|
||||||
for raw_line in path.read_text().splitlines():
|
|
||||||
line = raw_line.strip()
|
|
||||||
if not line or line.startswith("#"):
|
|
||||||
continue
|
|
||||||
if line.startswith("export "):
|
|
||||||
line = line[len("export ") :].lstrip()
|
|
||||||
if "=" not in line:
|
|
||||||
continue
|
|
||||||
name, _, value = line.partition("=")
|
|
||||||
name = name.strip()
|
|
||||||
if not name:
|
|
||||||
continue
|
|
||||||
values[name] = _unquote(value.strip())
|
|
||||||
return values
|
|
||||||
|
|
||||||
|
|
||||||
def _load_env_file() -> dict[str, str]:
|
|
||||||
global _env_file_cache
|
|
||||||
if _env_file_cache is None:
|
|
||||||
path = Path(os.environ.get("ENV_FILE", BASE_DIR / ".env"))
|
|
||||||
_env_file_cache = _parse_env_file(path) if path.is_file() else {}
|
|
||||||
return _env_file_cache
|
|
||||||
|
|
||||||
|
|
||||||
def _load_ini_file() -> dict[str, str]:
|
|
||||||
global _ini_file_cache
|
|
||||||
if _ini_file_cache is None:
|
|
||||||
path = Path(os.environ.get("INI_FILE", BASE_DIR / "settings.ini"))
|
|
||||||
if path.is_file():
|
|
||||||
parser = ConfigParser()
|
|
||||||
# Preserve key case; ConfigParser lowercases option names by default.
|
|
||||||
parser.optionxform = str # type: ignore[assignment, method-assign]
|
|
||||||
parser.read(path)
|
|
||||||
_ini_file_cache = (
|
|
||||||
dict(parser[INI_SECTION]) if parser.has_section(INI_SECTION) else {}
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
_ini_file_cache = {}
|
|
||||||
return _ini_file_cache
|
|
||||||
|
|
||||||
|
|
||||||
def derive_hosts_and_origins(
|
|
||||||
app_url: str,
|
|
||||||
) -> tuple[list[str], list[str]]:
|
|
||||||
"""Derive ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS from an APP_URL value.
|
|
||||||
|
|
||||||
``app_url`` may be a single full URL or a comma-separated list of full URLs.
|
|
||||||
Returns ``(allowed_hosts, csrf_trusted_origins)``.
|
|
||||||
"""
|
|
||||||
parsed_urls = [urlparse(raw_url.strip()) for raw_url in app_url.split(",")]
|
|
||||||
allowed_hosts = [parsed_url.hostname for parsed_url in parsed_urls]
|
|
||||||
csrf_trusted_origins = [
|
|
||||||
f"{parsed_url.scheme}://{parsed_url.netloc}" for parsed_url in parsed_urls
|
|
||||||
]
|
|
||||||
return allowed_hosts, csrf_trusted_origins
|
|
||||||
|
|
||||||
|
|
||||||
def reset_caches() -> None:
|
|
||||||
"""Clear parsed-file caches. Intended for use in tests."""
|
|
||||||
global _env_file_cache, _ini_file_cache
|
|
||||||
_env_file_cache = None
|
|
||||||
_ini_file_cache = None
|
|
||||||
|
|
||||||
|
|
||||||
def _cast_value(value: str, cast: Callable[[str], Any] | None) -> Any:
|
|
||||||
if cast is None:
|
|
||||||
return value
|
|
||||||
if cast is bool:
|
|
||||||
return value.strip().lower() in {"true", "1", "yes", "on"}
|
|
||||||
if cast is list:
|
|
||||||
return [item.strip() for item in value.split(",") if item.strip()]
|
|
||||||
return cast(value)
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_raw(name: str, allow_file: bool) -> str | None:
|
|
||||||
"""Return the first raw string from the source chain, or ``None``."""
|
|
||||||
if allow_file:
|
|
||||||
file_pointer = os.environ.get(f"{name}__FILE")
|
|
||||||
if file_pointer:
|
|
||||||
return Path(file_pointer).read_text().strip()
|
|
||||||
if name in os.environ:
|
|
||||||
return os.environ[name]
|
|
||||||
env_file = _load_env_file()
|
|
||||||
if name in env_file:
|
|
||||||
return env_file[name]
|
|
||||||
ini_file = _load_ini_file()
|
|
||||||
if name in ini_file:
|
|
||||||
return ini_file[name]
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _debug_enabled() -> bool:
|
|
||||||
"""Whether the app runs in DEBUG mode, mirroring ``settings.DEBUG``.
|
|
||||||
|
|
||||||
Defaults to on for local development; turned off by ``DEBUG=false`` or the
|
|
||||||
deprecated ``PROD`` env var. Used to decide whether ``required_in_prod``
|
|
||||||
settings may fall back to a development default.
|
|
||||||
"""
|
|
||||||
raw = _resolve_raw("DEBUG", allow_file=False)
|
|
||||||
if raw is not None:
|
|
||||||
return _cast_value(raw, bool)
|
|
||||||
return not bool(os.environ.get("PROD"))
|
|
||||||
|
|
||||||
|
|
||||||
def config(
|
|
||||||
name: str,
|
|
||||||
*,
|
|
||||||
default: Any = NOT_SET,
|
|
||||||
cast: Callable[[str], Any] | None = None,
|
|
||||||
allow_file: bool = False,
|
|
||||||
required_in_prod: bool = False,
|
|
||||||
) -> Any:
|
|
||||||
"""Resolve a configuration value from the source chain.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name: The setting / environment variable name.
|
|
||||||
default: Fallback when no source provides a value. If omitted, a
|
|
||||||
missing value raises ``ImproperlyConfigured``.
|
|
||||||
cast: Coercion applied to string values — ``bool``, ``list``, ``int``,
|
|
||||||
``Path``, or any callable taking a string. Defaults are returned
|
|
||||||
untouched.
|
|
||||||
allow_file: Whether to honor a ``NAME__FILE`` secret pointer.
|
|
||||||
required_in_prod: When ``True``, a missing value raises in production
|
|
||||||
(DEBUG off) even if a ``default`` is given, so insecure development
|
|
||||||
defaults never leak into a deployment.
|
|
||||||
"""
|
|
||||||
raw = _resolve_raw(name, allow_file=allow_file)
|
|
||||||
if raw is None:
|
|
||||||
if required_in_prod and not _debug_enabled():
|
|
||||||
raise ImproperlyConfigured(
|
|
||||||
f"{name} must be set in production (DEBUG is off)."
|
|
||||||
)
|
|
||||||
if default is NOT_SET:
|
|
||||||
raise ImproperlyConfigured(f"Required setting {name} is not configured.")
|
|
||||||
return default
|
|
||||||
return _cast_value(raw, cast)
|
|
||||||
+12
-36
@@ -11,9 +11,7 @@ https://docs.djangoproject.com/en/4.1/ref/settings/
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import warnings
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from timetracker.config import config, derive_hosts_and_origins
|
|
||||||
|
|
||||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
@@ -22,41 +20,13 @@ BASE_DIR = Path(__file__).resolve().parent.parent
|
|||||||
# Quick-start development settings - unsuitable for production
|
# Quick-start development settings - unsuitable for production
|
||||||
# See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/
|
# See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/
|
||||||
|
|
||||||
# SECURITY WARNING: don't run with debug turned on in production!
|
|
||||||
# DEBUG defaults on for local development. Production turns it off via
|
|
||||||
# DEBUG=false (preferred) or the deprecated PROD env var.
|
|
||||||
_debug = config("DEBUG", default=None, cast=bool)
|
|
||||||
if _debug is None:
|
|
||||||
if os.environ.get("PROD"):
|
|
||||||
warnings.warn(
|
|
||||||
"The PROD environment variable is deprecated; set DEBUG=false instead.",
|
|
||||||
DeprecationWarning,
|
|
||||||
stacklevel=2,
|
|
||||||
)
|
|
||||||
_debug = False
|
|
||||||
else:
|
|
||||||
_debug = True
|
|
||||||
DEBUG = _debug
|
|
||||||
|
|
||||||
# SECURITY WARNING: keep the secret key used in production secret!
|
# SECURITY WARNING: keep the secret key used in production secret!
|
||||||
# Each deployment supplies its own key (env, .env/.ini, or a SECRET_KEY__FILE
|
SECRET_KEY = "django-insecure-x0_t$gei=_o_p(%%!-db$jezka@y+d67$a8tvw13nl^8$l*t@="
|
||||||
# secret); falls back to an insecure default only in DEBUG. Missing in
|
|
||||||
# production is a hard error rather than a silent insecure fallback.
|
|
||||||
SECRET_KEY = config(
|
|
||||||
"SECRET_KEY",
|
|
||||||
default="django-insecure-x0_t$gei=_o_p(%%!-db$jezka@y+d67$a8tvw13nl^8$l*t@=",
|
|
||||||
allow_file=True,
|
|
||||||
required_in_prod=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# APP_URL accepts one or more comma-separated full URLs (single URL is the
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
# common case). Both ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS are derived from
|
DEBUG = False if os.environ.get("PROD") else True
|
||||||
# all listed URLs. ALLOWED_HOSTS can still be overridden directly for edge
|
|
||||||
# cases like ALLOWED_HOSTS=* behind a reverse proxy.
|
|
||||||
APP_URL = config("APP_URL", default="http://localhost:8000")
|
|
||||||
_derived_hosts, CSRF_TRUSTED_ORIGINS = derive_hosts_and_origins(APP_URL)
|
|
||||||
|
|
||||||
ALLOWED_HOSTS = config("ALLOWED_HOSTS", default=None, cast=list) or _derived_hosts
|
ALLOWED_HOSTS = ["*"]
|
||||||
|
|
||||||
|
|
||||||
# Application definition
|
# Application definition
|
||||||
@@ -139,7 +109,7 @@ WSGI_APPLICATION = "timetracker.wsgi.application"
|
|||||||
DATABASES = {
|
DATABASES = {
|
||||||
"default": {
|
"default": {
|
||||||
"ENGINE": "django.db.backends.sqlite3",
|
"ENGINE": "django.db.backends.sqlite3",
|
||||||
"NAME": config("DATA_DIR", default=BASE_DIR, cast=Path) / "db.sqlite3",
|
"NAME": Path(os.environ.get("DATA_DIR", str(BASE_DIR))) / "db.sqlite3",
|
||||||
"OPTIONS": {
|
"OPTIONS": {
|
||||||
"timeout": 20,
|
"timeout": 20,
|
||||||
"init_command": "PRAGMA synchronous=FULL; PRAGMA journal_mode=WAL;",
|
"init_command": "PRAGMA synchronous=FULL; PRAGMA journal_mode=WAL;",
|
||||||
@@ -172,7 +142,7 @@ AUTH_PASSWORD_VALIDATORS = [
|
|||||||
|
|
||||||
LANGUAGE_CODE = "en-us"
|
LANGUAGE_CODE = "en-us"
|
||||||
|
|
||||||
TIME_ZONE = config("TZ", default="Europe/Prague" if DEBUG else "UTC")
|
TIME_ZONE = "Europe/Prague" if DEBUG else os.environ.get("TZ", "UTC")
|
||||||
|
|
||||||
USE_I18N = True
|
USE_I18N = True
|
||||||
|
|
||||||
@@ -205,3 +175,9 @@ LOGGING = {
|
|||||||
"games": {"handlers": ["console"], "level": "INFO", "propagate": False},
|
"games": {"handlers": ["console"], "level": "INFO", "propagate": False},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_csrf_trusted_origins = os.environ.get("CSRF_TRUSTED_ORIGINS")
|
||||||
|
if _csrf_trusted_origins:
|
||||||
|
CSRF_TRUSTED_ORIGINS = _csrf_trusted_origins.split(",")
|
||||||
|
else:
|
||||||
|
CSRF_TRUSTED_ORIGINS = []
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
export interface DropdownConfig {
|
|
||||||
patchUrl: string;
|
|
||||||
bodyKey: string; // server field name, e.g. "status" or "device_id"
|
|
||||||
event: string; // dispatched on document.body after a successful PATCH
|
|
||||||
csrf: string;
|
|
||||||
numericValue?: boolean; // parse the option value as a number
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wires a light-DOM value-selector dropdown that lives inside `host`.
|
|
||||||
// Markup hooks (rendered server-side): [data-toggle], [data-menu],
|
|
||||||
// [data-label], and one or more [data-option][data-value].
|
|
||||||
export function initDropdown(host: HTMLElement, config: DropdownConfig): void {
|
|
||||||
const toggle = host.querySelector<HTMLElement>("[data-toggle]");
|
|
||||||
const menu = host.querySelector<HTMLElement>("[data-menu]");
|
|
||||||
const label = host.querySelector<HTMLElement>("[data-label]");
|
|
||||||
if (!toggle || !menu || !label) return;
|
|
||||||
|
|
||||||
const close = () => {
|
|
||||||
menu.hidden = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
toggle.addEventListener("click", (event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
menu.hidden = !menu.hidden;
|
|
||||||
});
|
|
||||||
document.addEventListener("click", (event) => {
|
|
||||||
if (!host.contains(event.target as Node)) close();
|
|
||||||
});
|
|
||||||
|
|
||||||
host.querySelectorAll<HTMLElement>("[data-option]").forEach((option) => {
|
|
||||||
option.addEventListener("click", (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
const raw = option.dataset.value ?? "";
|
|
||||||
label.innerHTML = option.innerHTML;
|
|
||||||
close();
|
|
||||||
const body: Record<string, unknown> = {
|
|
||||||
[config.bodyKey]: config.numericValue ? Number(raw) : raw,
|
|
||||||
};
|
|
||||||
window
|
|
||||||
.fetchWithHtmxTriggers(config.patchUrl, {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: { "Content-Type": "application/json", "X-CSRFToken": config.csrf },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
})
|
|
||||||
.then(() => document.body.dispatchEvent(new CustomEvent(config.event)))
|
|
||||||
.catch(() => console.error("Failed to update", config.patchUrl));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { readGameStatusSelectorProps } from "../generated/props.js";
|
|
||||||
import { initDropdown } from "./dropdown.js";
|
|
||||||
|
|
||||||
class GameStatusSelectorElement extends HTMLElement {
|
|
||||||
connectedCallback(): void {
|
|
||||||
const props = readGameStatusSelectorProps(this);
|
|
||||||
initDropdown(this, {
|
|
||||||
patchUrl: `/api/games/${props.gameId}/status`,
|
|
||||||
bodyKey: "status",
|
|
||||||
event: "status-changed",
|
|
||||||
csrf: props.csrf,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
customElements.define("game-status-selector", GameStatusSelectorElement);
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { readPlayEventRowProps } from "../generated/props.js";
|
|
||||||
|
|
||||||
class PlayEventRowElement extends HTMLElement {
|
|
||||||
connectedCallback(): void {
|
|
||||||
const props = readPlayEventRowProps(this);
|
|
||||||
const toggle = this.querySelector<HTMLElement>("[data-toggle]");
|
|
||||||
const menu = this.querySelector<HTMLElement>("[data-menu]");
|
|
||||||
const count = this.querySelector<HTMLElement>("[data-count]");
|
|
||||||
const addPlay = this.querySelector<HTMLElement>("[data-add-play]");
|
|
||||||
if (!toggle || !menu) return;
|
|
||||||
|
|
||||||
const close = () => {
|
|
||||||
menu.hidden = true;
|
|
||||||
};
|
|
||||||
toggle.addEventListener("click", (event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
menu.hidden = !menu.hidden;
|
|
||||||
});
|
|
||||||
document.addEventListener("click", (event) => {
|
|
||||||
if (!this.contains(event.target as Node)) close();
|
|
||||||
});
|
|
||||||
|
|
||||||
addPlay?.addEventListener("click", (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
if (count) count.textContent = String(Number(count.textContent) + 1);
|
|
||||||
close();
|
|
||||||
window
|
|
||||||
.fetchWithHtmxTriggers(props.apiCreateUrl, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json", "X-CSRFToken": props.csrf },
|
|
||||||
body: JSON.stringify({ game_id: props.gameId }),
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (count) count.textContent = String(Number(count.textContent) - 1);
|
|
||||||
console.error("Failed to record play");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
customElements.define("play-event-row", PlayEventRowElement);
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { readSessionDeviceSelectorProps } from "../generated/props.js";
|
|
||||||
import { initDropdown } from "./dropdown.js";
|
|
||||||
|
|
||||||
class SessionDeviceSelectorElement extends HTMLElement {
|
|
||||||
connectedCallback(): void {
|
|
||||||
const props = readSessionDeviceSelectorProps(this);
|
|
||||||
initDropdown(this, {
|
|
||||||
patchUrl: `/api/session/${props.sessionId}/device`,
|
|
||||||
bodyKey: "device_id",
|
|
||||||
event: "device-changed",
|
|
||||||
csrf: props.csrf,
|
|
||||||
numericValue: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
customElements.define("session-device-selector", SessionDeviceSelectorElement);
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
// import { toISOUTCString } from "../../games/static/js/utils.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description Formats Date to a UTC string accepted by the datetime-local input field.
|
|
||||||
* @param {Date} date
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
function toISOUTCString(date: Date): string {
|
|
||||||
function stringAndPad(number: number): string {
|
|
||||||
return number.toString().padStart(2, "0");
|
|
||||||
}
|
|
||||||
const year = date.getFullYear();
|
|
||||||
const month = stringAndPad(date.getMonth() + 1);
|
|
||||||
const day = stringAndPad(date.getDate());
|
|
||||||
const hours = stringAndPad(date.getHours());
|
|
||||||
const minutes = stringAndPad(date.getMinutes());
|
|
||||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
class SessionTimestampButtonsElement extends HTMLElement {
|
|
||||||
connectedCallback(): void {
|
|
||||||
for (const button of this.querySelectorAll("[data-target]")) {
|
|
||||||
const target = button.getAttribute("data-target");
|
|
||||||
const type = button.getAttribute("data-type");
|
|
||||||
if (!target || !type) continue;
|
|
||||||
const targetElement = document.querySelector(`#id_${target}`);
|
|
||||||
if (!(targetElement instanceof HTMLInputElement)) return;
|
|
||||||
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";
|
|
||||||
const opposite = document.querySelector(`[name='${oppositeName}']`);
|
|
||||||
if (!(opposite instanceof HTMLInputElement)) return;
|
|
||||||
opposite.value = targetElement.value;
|
|
||||||
} else if (type == "toggle") {
|
|
||||||
if (targetElement.type == "datetime-local") targetElement.type = "text";
|
|
||||||
else targetElement.type = "datetime-local";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
customElements.define("session-timestamp-buttons", SessionTimestampButtonsElement);
|
|
||||||
Vendored
-7
@@ -1,7 +0,0 @@
|
|||||||
export {};
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
fetchWithHtmxTriggers(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"module": "ES2022",
|
|
||||||
"moduleResolution": "Bundler",
|
|
||||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
||||||
"strict": true,
|
|
||||||
"noEmitOnError": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"rootDir": "ts",
|
|
||||||
"outDir": "games/static/js/dist"
|
|
||||||
},
|
|
||||||
"include": ["ts/**/*.ts"]
|
|
||||||
}
|
|
||||||
@@ -6,6 +6,10 @@ resolution-markers = [
|
|||||||
"python_full_version < '3.15'",
|
"python_full_version < '3.15'",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[options]
|
||||||
|
exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values.
|
||||||
|
exclude-newer-span = "P7D"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "annotated-types"
|
name = "annotated-types"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
@@ -26,11 +30,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "certifi"
|
name = "certifi"
|
||||||
version = "2026.6.17"
|
version = "2026.5.20"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" },
|
{ url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -149,25 +153,25 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "distlib"
|
name = "distlib"
|
||||||
version = "0.4.3"
|
version = "0.4.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" },
|
{ url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "django"
|
name = "django"
|
||||||
version = "6.0.6"
|
version = "6.0.5"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "asgiref" },
|
{ name = "asgiref" },
|
||||||
{ name = "sqlparse" },
|
{ name = "sqlparse" },
|
||||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/78/29/ac41e16097af67066d97a7d5775c5d8e7efc5d0284f6b0a159e07b9adb92/django-6.0.6.tar.gz", hash = "sha256:ad03916ba59523d781ae5c3f631960c23d69a9d9c43cecda52fc23b47e953713", size = 10905525, upload-time = "2026-06-03T13:02:46.503Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/5e/f1/bf85f0d29ef76abf901f193fe8fef4769d3da7794197832bc30151c071d8/django-6.0.5.tar.gz", hash = "sha256:bc6d6872e98a2864c836e42edd644b362db311147dd5aa8d5b82ba7a032f5269", size = 10924131, upload-time = "2026-05-05T13:54:39.329Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/eb/50/23f9dc45483419a3cc2085b498b25adfbf10642b2941c73e6d2dfaffc9ab/django-6.0.6-py3-none-any.whl", hash = "sha256:25148b1194c47c2e685e5f5e9c5d59c78b075dfd282cb9618861ba6c1708f4d2", size = 8373354, upload-time = "2026-06-03T13:02:41.72Z" },
|
{ url = "https://files.pythonhosted.org/packages/94/5b/1328f8b84fce040c404f76822bf8c57d254e368e8cbd8bd67ec2b26d75f5/django-6.0.5-py3-none-any.whl", hash = "sha256:9d58a7cb49244e74c8e161d5e403a46d6209f1009ba40f5a66d6aa0d0786a8f0", size = 8368680, upload-time = "2026-05-05T13:54:33.532Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -269,7 +273,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "djlint"
|
name = "djlint"
|
||||||
version = "1.39.2"
|
version = "1.36.4"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
@@ -282,36 +286,13 @@ dependencies = [
|
|||||||
{ name = "regex" },
|
{ name = "regex" },
|
||||||
{ name = "tqdm" },
|
{ name = "tqdm" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/50/d8/119f35832801129dc6a4bdf82c163bb80d0095891eea9fc3543eaf8c9792/djlint-1.39.2.tar.gz", hash = "sha256:dd27ef03bd26d1e0a167da26ec2a6fcb511dddd032b2553abf71a5c31eaa8b34", size = 56083, upload-time = "2026-06-11T14:03:01.223Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/74/89/ecf5be9f5c59a0c53bcaa29671742c5e269cc7d0e2622e3f65f41df251bf/djlint-1.36.4.tar.gz", hash = "sha256:17254f218b46fe5a714b224c85074c099bcb74e3b2e1f15c2ddc2cf415a408a1", size = 47849, upload-time = "2024-12-24T13:06:36.36Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/05/9b/d1a630b2495f8b6a60555012dd57c661a9fe683c1f8483287ecddae749dc/djlint-1.39.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c243a1f18211cb9a4fec5b95f0b43c61941414f71fdb3d77dcc9798f9644b623", size = 538489, upload-time = "2026-06-11T14:02:54.322Z" },
|
{ url = "https://files.pythonhosted.org/packages/da/83/88b4c885812921739f5529a29085c3762705154d41caf7eb9a8886a3380c/djlint-1.36.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ead475013bcac46095b1bbc8cf97ed2f06e83422335734363f8a76b4ba7e47c2", size = 354384, upload-time = "2024-12-24T13:06:20.809Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/87/39/7e15d99f40cf6ec1d624df48ab465725914cf6c789fdf6f6dc28db264666/djlint-1.39.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:74d65499c5b29e5269eb6a4bff9dbf426c22cba54f4810d30fbfbcb482c9035f", size = 510153, upload-time = "2026-06-11T14:02:10.287Z" },
|
{ url = "https://files.pythonhosted.org/packages/32/38/67695f7a150b3d9d62fadb65242213d96024151570c3cf5d966effa68b0e/djlint-1.36.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6c601dfa68ea253311deb4a29a7362b7a64933bdfcfb5a06618f3e70ad1fa835", size = 322971, upload-time = "2024-12-24T13:06:22.185Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/db/a6/9ab6b79728a3a7f8235eac0f0d21e01e7457adfe3f368a3a1132eaa52897/djlint-1.39.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25b955e23c09bdfdb04de6b40b848cf93b39952178aa37d940af921a9715da4b", size = 535815, upload-time = "2026-06-11T14:02:01.034Z" },
|
{ url = "https://files.pythonhosted.org/packages/ac/7a/cd851393291b12e7fe17cf5d4d8874b8ea133aebbe9235f5314aabc96a52/djlint-1.36.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bda5014f295002363381969864addeb2db13955f1b26e772657c3b273ed7809f", size = 410972, upload-time = "2024-12-24T13:06:24.077Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9e/9b/3eceecb4bead34e9087102078b58c084fa53f995827f03ea7cbf42fd5c48/djlint-1.39.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0a2c0191f93181557e8dcbca2a6766ab972f769bb9c32d488cefa1b5195a74d", size = 557939, upload-time = "2026-06-11T14:02:43.072Z" },
|
{ url = "https://files.pythonhosted.org/packages/6c/31/56469120394b970d4f079a552fde21ed27702ca729595ab0ed459eb6d240/djlint-1.36.4-cp313-cp313-win_amd64.whl", hash = "sha256:16ce37e085afe5a30953b2bd87cbe34c37843d94c701fc68a2dda06c1e428ff4", size = 362053, upload-time = "2024-12-24T13:06:25.432Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c9/cf/95bdb7d40699ae60d4dfda0ed7278e0a09f224c505bf0cd396f1e6ab4c23/djlint-1.39.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e0aa5e3087dd346ae3197781fa425aebccce6beed593f4dd17cbb00179ef8444", size = 541687, upload-time = "2026-06-11T14:02:11.683Z" },
|
{ url = "https://files.pythonhosted.org/packages/4b/67/f7aeea9be6fb3bd984487af8d0d80225a0b1e5f6f7126e3332d349fb13fe/djlint-1.36.4-py3-none-any.whl", hash = "sha256:e9699b8ac3057a6ed04fb90835b89bee954ed1959c01541ce4f8f729c938afdd", size = 52290, upload-time = "2024-12-24T13:06:33.76Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/73/a7/6b8ba672e1c7c8216306ecfacd4053b645ef583f573c69a3f2087c654922/djlint-1.39.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:30094c7533b0e919f4674baeaf0acd061b1eb6c8ffbf4d6d3dc856c686747411", size = 568429, upload-time = "2026-06-11T14:01:55.72Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d2/50/03b189c613fb3f0286e8ddfbc3a31856c6a2d3c7a01eb89cf8441ca25f21/djlint-1.39.2-cp313-cp313-win32.whl", hash = "sha256:3eb8942afbf2e5d0ffc208d88db33dc38abb585ce03fdd17d9a0f03e4b7e761a", size = 423325, upload-time = "2026-06-11T14:01:48.24Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5e/00/a0a34a80b732ae227b395bdd2718836c696336524bf6f97210cc6ff871e6/djlint-1.39.2-cp313-cp313-win_amd64.whl", hash = "sha256:9040e8ff1e7e141cd67402110f0edb67835955003c7a076c97398dcee5f814d1", size = 472106, upload-time = "2026-06-11T14:02:12.8Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bf/65/c28711b4e15c932e2be15eee9b0d37465394bab65acb0f2f5f51ee42decd/djlint-1.39.2-cp313-cp313-win_arm64.whl", hash = "sha256:a54edd4326adc48577d244425e8d3c175ec3cc6abd3732418efeccc0b92b9d1a", size = 404747, upload-time = "2026-06-11T14:02:33.238Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/46/3e/16cfba85037a7dcd2333263594c8c94f6443b0f3726f55cd2426fc7fac5b/djlint-1.39.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e2fefc27180dad754e76c9ece6f5dbe99a808c7a0dd4ff4213dd39d82f1878cd", size = 537519, upload-time = "2026-06-11T14:03:03.663Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bb/9b/dd5dffc2eadb46a3167c13151b6f7dbcef39996da56794d45f43b1826a61/djlint-1.39.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c62372a9d037d612f9b957829c0dbdef96e017dc2d9cf4970e094de6fee4ee55", size = 509380, upload-time = "2026-06-11T14:02:35.978Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0a/38/2e89eea336e5990999112fddd7b238407a3d9183a06acb38e00bd79d1dfd/djlint-1.39.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d27a88e84fd9e75cc49247b9d200f51c7bd037364efd58256d3b0e70c10775db", size = 538317, upload-time = "2026-06-11T14:02:49.693Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ae/fe/2546eecaaed67b2f92c2bd571b88b84692cd9b6883bdbfe6370391d86219/djlint-1.39.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13b77ce23aabab2426f5130948c0d605bd7414550bbca8a3892b7d255644c2da", size = 557757, upload-time = "2026-06-11T14:02:17.769Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3e/fa/36bc8176a4a1702ed8446b606ddecc9502d398a55c3803e69469ba8304f9/djlint-1.39.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4547b35fd6f5030ffe4555e66916f8ba8107e0e9c2c6a4e721fb980ba79e3fce", size = 544474, upload-time = "2026-06-11T14:02:45.64Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/62/69/5c0733745e5a2a275f3dad4977745890a777c5a74141b6c0e13a998992c2/djlint-1.39.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1257a2db80184ba70a195d8fe6026b54dab4dbc5156a90a61adbc916e500a6b1", size = 567721, upload-time = "2026-06-11T14:01:49.913Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5c/66/7c2a820f2d9948ff0b423d6395664a13979ff702edeb6efd51bf404b5c66/djlint-1.39.2-cp314-cp314-win32.whl", hash = "sha256:766d0266fbb0064c7579d66ed06487d9198d99880c5bbab03f24386edfa766f5", size = 429369, upload-time = "2026-06-11T14:02:20.447Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2e/32/006d9e22e2184c87cd556a7333d12c5fba2ec8b78bc6333621c4b067b81d/djlint-1.39.2-cp314-cp314-win_amd64.whl", hash = "sha256:345608f6eefe627d39f2491a673bc80688a86af3977113fc91b01f4b827bec2a", size = 481219, upload-time = "2026-06-11T14:01:59.669Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/73/56/5488c01bc730b86af531b72066c913d0ebe234ddb3101f9898eb044ffc44/djlint-1.39.2-cp314-cp314-win_arm64.whl", hash = "sha256:ed0d24f5af98f7ef8c50061b64ab4cdd61abfd133df9f99810d3efc82e25a3c1", size = 412865, upload-time = "2026-06-11T14:02:56.015Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/de/89/66b5260dc2f677264e0580565eb1a7b30bd842bebaba7940e11dc9a348b0/djlint-1.39.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:16cc5df9891a80090600f32111448e175148670cb7ca7789c04c4cdf9f9334d8", size = 584161, upload-time = "2026-06-11T14:02:57.393Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7a/38/e1a31e0fdd2cf090f4f862f282fe1fb0b363c0e248a105ed7f3aa16a5508/djlint-1.39.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c20df1b4a79bc05329c207110478ad26513deaef3a7409b4a3cab55216172749", size = 556646, upload-time = "2026-06-11T14:02:37.574Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/57/58/95733fc6f42a0bf31aa1186e0323fe224327bb840666d083261913cac90b/djlint-1.39.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c2f34d9ef9aa73536bf485648ce4fa381bb3d8d843fb97ec6725d5b3457b84d", size = 573320, upload-time = "2026-06-11T14:02:21.965Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7b/7c/9a03f9e859bc3dd05992dd8bf40298bcc18693dbbcba25a3eec58dd25fa5/djlint-1.39.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:851a9c78674120c99fe6842bcd3377bce698583823ee7861ae4992e84fff3e9b", size = 593052, upload-time = "2026-06-11T14:02:39.044Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b7/73/d0fa38536f1652584ddb6db9487bf8ef95322b92ce88ff4fe16719715526/djlint-1.39.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8c9df4a39d57476bcd2a02ae8d873796b92517517d4512f497b40be3e7398e84", size = 579381, upload-time = "2026-06-11T14:02:40.393Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ee/35/689726a28c99af26b925f97f66b7046eee3b3881ca3161a04695e8e8112b/djlint-1.39.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e0356e3a66357d72ca5d69f2fb78d47fed26efb31d104cba44f5e6fbf503a4e2", size = 602713, upload-time = "2026-06-11T14:02:44.356Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/91/03/5e59d32de6d3b4fc6113450e6198755d169b23fc7d8daaa5bbcc2ebc5c57/djlint-1.39.2-cp314-cp314t-win32.whl", hash = "sha256:ad5213ce1bbab6c18e1461498f219ca66382c40766d499e233ff2174a23026c6", size = 475348, upload-time = "2026-06-11T14:01:51.353Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bd/58/bd0ca13d2cfb31b666efc3b10af5dd37a72269b0282ec6490993e9655704/djlint-1.39.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d5711a2945844446fa19c35f151e077c2119411e49db30a3f97f32b9325b89bf", size = 535320, upload-time = "2026-06-11T14:03:00.083Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a4/c5/b2b8d8918f770a0dac8b2ad7f4a5caa3bc63c60f4cd178418b72f19f0a5a/djlint-1.39.2-cp314-cp314t-win_arm64.whl", hash = "sha256:f894464e6946e761003844ac5bfe4f51f362258a51d4e65a9641a4bd74da4ea2", size = 429535, upload-time = "2026-06-11T14:02:02.145Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/62/c1/ffb80fed94a3bf746d37134b504b794de893cb71e229ea2e1c91f14d1864/djlint-1.39.2-py3-none-any.whl", hash = "sha256:7c49bfda97816afd36273f12d67aa432ed44cd9f62d5c31f9ea9c316ebe808b2", size = 62347, upload-time = "2026-06-11T14:02:23.136Z" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -325,68 +306,68 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "filelock"
|
name = "filelock"
|
||||||
version = "3.29.4"
|
version = "3.29.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/e6/dc/be6cbe99670cd6e4ad387123647cb08e0c32975e223f82551e914c5568a6/filelock-3.29.4.tar.gz", hash = "sha256:10cdb3656fc44541cdf30652a93fb10ec6b05325620eb316bd26893e4201538a", size = 63028, upload-time = "2026-06-13T16:12:00.744Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/13/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-py3-none-any.whl", hash = "sha256:dac1648087d5115554850d113e7dd8c83ab2d38e3435dde2d4f163847e57b767", size = 42757, upload-time = "2026-06-13T16:11:59.582Z" },
|
{ url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "greenlet"
|
name = "greenlet"
|
||||||
version = "3.5.2"
|
version = "3.5.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/dd/8b/befc3cb36965f397d87e86fb3b00e3ec0dc67c1ecb0986d7f54ee528f018/greenlet-3.5.2.tar.gz", hash = "sha256:c1b906220d83c140361cdd12eef970fb5881a168b98ee58a43786426173da14c", size = 199243, upload-time = "2026-06-17T20:19:01.317Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/6d/6e/802acd792aebb2256fbbee8cacf2727faaeb6f240ac11008f09eae4414bc/greenlet-3.5.1.tar.gz", hash = "sha256:5a56aeb7d5d9cc4b3a735efb5095bd4b4f6f0e4f93e5ca876d0e2315137b7829", size = 197356, upload-time = "2026-05-20T15:05:03.917Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/d0/3c/bb37b9d40d65b0741a8b040ca5c307034d0a9822994dff5f825c88dd7a6b/greenlet-3.5.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:0629377725977252159de1ebd3c6e49c170a63856e585446797bb3d66d4d9c34", size = 287178, upload-time = "2026-06-17T17:35:25.132Z" },
|
{ url = "https://files.pythonhosted.org/packages/27/69/7f7e5372d998b81001899b1c0823c957aa413ba0f2662e65821611cc31e4/greenlet-3.5.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b", size = 285060, upload-time = "2026-05-20T13:08:51.899Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f0/a6/0c5902393f492f8ceb19d0b5cf139284e3a11b333a049739643b1036b6f8/greenlet-3.5.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2ddf9eddc617681108dd071b3feabf3f4a4cd64846254aec4d4ceda098b639a", size = 606900, upload-time = "2026-06-17T18:07:21.692Z" },
|
{ url = "https://files.pythonhosted.org/packages/b1/bf/387f9b6b865fd2ae0d0be09e0004827295a01b71be76ed350dd1e28a91a4/greenlet-3.5.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a", size = 604370, upload-time = "2026-05-20T14:00:07.492Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d8/7c/42899c31d4b87148ae4e3f87f63e13398824be6241f4dde42ded95768a34/greenlet-3.5.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f41feb9f2b59e2e61ac9bea4e344ddd9396bf3cacb2583f73a3595ed7df6f8e7", size = 619265, upload-time = "2026-06-17T18:29:44.837Z" },
|
{ url = "https://files.pythonhosted.org/packages/32/f5/169ce3d4e4c67291bd18f8cbe0299c9f3e45102c7f1fb3c14780c93e4532/greenlet-3.5.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283", size = 616987, upload-time = "2026-05-20T14:05:44.237Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6a/7e/28f991affb413b232b1e7d768db24c37b3f4d5daecc3f19b455d40bd2dea/greenlet-3.5.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9dc23f0e5ad76415457212a4b947d22ebe4dc80baf02adf7dd5647a90f38bb4e", size = 625044, upload-time = "2026-06-17T18:39:29.046Z" },
|
{ url = "https://files.pythonhosted.org/packages/19/ba/c24110c55dffa55aa6e1d98b45310da33801aeba7686ff0190fe5d46fd32/greenlet-3.5.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d40a890035c0058cadbdc4af7569800fd28a0e527a0fdbb7b5f9418f176846ce", size = 622911, upload-time = "2026-05-20T14:09:10.598Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d3/52/4ff8c98d3cfe62b4515f8584ae14510a58f35c549cc5292b78d9b7a40b70/greenlet-3.5.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:09201fa698768db245920b00fdc86ee3e73540f01ca6db162be9632642e1a473", size = 616187, upload-time = "2026-06-17T17:39:29.473Z" },
|
{ url = "https://files.pythonhosted.org/packages/ee/e5/7f2e41d5273be07e77560d61ea4e56485b4d6c316d2a84518c62d1364061/greenlet-3.5.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135", size = 613911, upload-time = "2026-05-20T13:14:27.539Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/29/05/0cc9ec660e7acff85f93b0a048b6654371c822c884add44c02a465cf70e0/greenlet-3.5.2-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:423167363c510a75b649f5cd58d873c29498ea03598b9e4b1c3b73e0f899f3d5", size = 427322, upload-time = "2026-06-17T18:41:20.892Z" },
|
{ url = "https://files.pythonhosted.org/packages/ec/7b/d20db2e8a5ad6c038702f3179b136f93f0a3d1a21a0c0777f3e470cdf4b2/greenlet-3.5.1-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:67821bb03e4e98664490edb787ff6af501194c29bbee0f5c1dfdcf1dc3d9d436", size = 425228, upload-time = "2026-05-20T14:01:40.837Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c9/a6/269c8bf9aefc13361ce1088f0e392b154cb21005de7862e42b5d782b81fd/greenlet-3.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a1759fa4f14c398508cf20dc8037de55cc23ae8bd14c185c2718257837195ca5", size = 1573778, upload-time = "2026-06-17T18:22:13.497Z" },
|
{ url = "https://files.pythonhosted.org/packages/c5/a4/fbdc67579b73615a1f91615e814303cc71e06128f7baaba87be79b8fb90c/greenlet-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd", size = 1570689, upload-time = "2026-05-20T14:02:27.225Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1f/9b/391d015cbc6323e81b14c02cf825fdca7e0049c9bb489bf4ac72883118ba/greenlet-3.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b9318cdeb9abdbfdd8bc8464ee4a06dffde2c7846e1def138365a6240ab2c9a5", size = 1638092, upload-time = "2026-06-17T17:40:08.163Z" },
|
{ url = "https://files.pythonhosted.org/packages/e6/b4/77abbe35078be39718a46cd49caf16bceb35662f97a34101dca28aa98e47/greenlet-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1", size = 1635602, upload-time = "2026-05-20T13:14:36.344Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/49/53/5b4df711f4356c62e85d9f819d87966d526d1cfb32bae49a8f7d6fc36ea4/greenlet-3.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:2c3b3311af72b3d3b03cc0f1ffd11f072e834be5d0444105cf715fc44434e39c", size = 239352, upload-time = "2026-06-17T17:38:51.593Z" },
|
{ url = "https://files.pythonhosted.org/packages/37/f7/129f27ca700845b8ee8ca88ce7f43435a1239c2eddb7677fc938822762cf/greenlet-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9", size = 238683, upload-time = "2026-05-20T13:11:50.57Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/bb/b6/18efc3a329ec035c3f344b8f2b60356451950ddf9b7b64ff00023778a1dd/greenlet-3.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:f9bbd6216c45a563c2a61e478e038b439d9f248bde44f775ea37d339da643af4", size = 237635, upload-time = "2026-06-17T17:35:36.632Z" },
|
{ url = "https://files.pythonhosted.org/packages/6d/5c/a485a36e87df8d8fd0632ee01511244f5156a20ed3746cc6599340326395/greenlet-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f16ba1efc0715b680a18b8123d90dad887c6112ae3555b4b5c32c149540c6b4e", size = 235499, upload-time = "2026-05-20T13:12:42.028Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c7/89/aaafc8e14de4ac882e02ccb963225329b0e8578aba4365e71eb678e45722/greenlet-3.5.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:1c31219badba285858ba8ed117f403dea7fafee6bade9a1991875aae530c3ceb", size = 287676, upload-time = "2026-06-17T17:33:31.514Z" },
|
{ url = "https://files.pythonhosted.org/packages/8a/cb/c62454606daf5640369c94d8a9dd540599b1bfc090e2d2180cb77f4038d2/greenlet-3.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07", size = 285579, upload-time = "2026-05-20T13:08:56.396Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b8/fc/2308249206c12ac70de7b9a00970f84f07d10b3cd60e05d2fbcaa84124e8/greenlet-3.5.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6f96ed6f4adc1066954ae95f45717657cb67468ef3b89e9a3632e14a625a8f39", size = 653552, upload-time = "2026-06-17T18:07:23.493Z" },
|
{ url = "https://files.pythonhosted.org/packages/ec/71/c4270398c2eba968a6071af1dfbdcaeee6ec1c24bc8b435b8cc452700da6/greenlet-3.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea", size = 651106, upload-time = "2026-05-20T14:00:09.448Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7c/24/47730d1f8f1336b9b089237521ed7a26eee997065dcb4cab81cdca333abc/greenlet-3.5.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5795e883e915333c0d5648faaa691857fbc7180136883edc377f50f0d509c2a8", size = 665756, upload-time = "2026-06-17T18:29:46.616Z" },
|
{ url = "https://files.pythonhosted.org/packages/1a/ab/71e34b78a44ec271fb5f550c17bc46d301ddc5953890d935f270b0dcdb5a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2", size = 663478, upload-time = "2026-05-20T14:05:45.88Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/23/5c/2664d290cbd1fef9eb3f69b5d3bc5aa91b6fa907519298ca6af93a90c6cb/greenlet-3.5.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6e9e49d732ee92a189bb7035e293029244aeba648297a9b856dc733d17ca7f0d", size = 669989, upload-time = "2026-06-17T18:39:30.79Z" },
|
{ url = "https://files.pythonhosted.org/packages/c6/2d/2d80842910da44f78c286532d084b8a5c3717c844ae80ceb3858738ae89a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c09df69dc1712d131332054a858a3e5cca400967fa3a672e2324fbb0971448c", size = 667767, upload-time = "2026-05-20T14:09:12.15Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/99/69/d6c99db15dc0b5e892ac3cc7b942c8b21f4a9cc3bd9ea0bc3b0f339ffbd4/greenlet-3.5.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26aed8d9503ca78889141a9739d71b383efea5f472a7c522b5410f7eb2a1b163", size = 663228, upload-time = "2026-06-17T17:39:31.073Z" },
|
{ url = "https://files.pythonhosted.org/packages/77/96/4efd6fa5c62c85426a0c19077a586258ebc3a2a146ff2493e4312a697a22/greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c", size = 660800, upload-time = "2026-05-20T13:14:29.129Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/42/d4/fcb53fa9847d7fbd4723fbed9469c3869b9e3544c4e001d9d5aa2f66162d/greenlet-3.5.2-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:537c5c4f30395020bb9f48f53146070e3b997c3c75da14011ab732aaa19ce3ef", size = 472888, upload-time = "2026-06-17T18:41:22.511Z" },
|
{ url = "https://files.pythonhosted.org/packages/e9/d3/dad2eecedfbb1ed7050a20dcfae40c1442b74bc7423608be2c7e03ee7133/greenlet-3.5.1-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:a4764e0bfc6a4d114c865b32520805c16a990ef5f286a514413b05d5ecd6a23d", size = 470786, upload-time = "2026-05-20T14:01:42.064Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4f/88/9e603f448e2bc107c883e95817b980fb9b45ba6aea0299b2e9978124bea2/greenlet-3.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:dbebc038fcdda8f8f21cce985fd04e34e0f42007e7fc7ab7ad285caf77974b95", size = 1620723, upload-time = "2026-06-17T18:22:14.817Z" },
|
{ url = "https://files.pythonhosted.org/packages/7a/e0/6c71401a25cac7000261304e866a2f2cc04dc74810d40e2f118aa4799495/greenlet-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0", size = 1617518, upload-time = "2026-05-20T14:02:28.662Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/11/91/26da17e3777858c16fdb8d020a4c68f3a03cb92f238de8f5351d5d5186e9/greenlet-3.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a207023f1cf8695fd82580b8099c09c5809be18bc2282362cdfb965dd884a317", size = 1684227, upload-time = "2026-06-17T17:40:09.536Z" },
|
{ url = "https://files.pythonhosted.org/packages/41/26/c5c06643e8c0af9e7bf18e16cb51d0ab7625155f0392e1c9015d66d556cd/greenlet-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc", size = 1681593, upload-time = "2026-05-20T13:14:39.417Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2d/44/b3a11f7aa34cb38f1b7f3df8bcd9fcd09bac9d342c2a2c9b8686c804bcd2/greenlet-3.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:c674a1dd4fe41f6a93febe7ab366ceabf15080ea31a9307811c56dac5f435f73", size = 240257, upload-time = "2026-06-17T17:35:23.359Z" },
|
{ url = "https://files.pythonhosted.org/packages/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3", size = 239800, upload-time = "2026-05-20T13:09:30.128Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/de/e3/3b62145fe917311732041a258adb218248add00542e3131c48bd047fbed5/greenlet-3.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:3c417cd6c593bbbef6f7aa31a79f37d3db7d18832fc56b694a2150130bde784e", size = 239038, upload-time = "2026-06-17T17:37:56.792Z" },
|
{ url = "https://files.pythonhosted.org/packages/47/f8/8e8e8417b7bf28639a5a56356ef934d0375e1d0c70a57e04d7701e870ffe/greenlet-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:7b5f5fae05b8ac6d176a61b60c394a8cbdc2b5b91b81793066e68745cf165e54", size = 236862, upload-time = "2026-05-20T13:09:10.498Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/47/ac/d3bad483e9f6cd1848604fdffa32cac25846dd6dfcec0e6f81c790185518/greenlet-3.5.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:a96457a30384de52d9c5d2fd33abf6c1daae3db392cd556738f408b1a79a1cf0", size = 295668, upload-time = "2026-06-17T17:36:02.293Z" },
|
{ url = "https://files.pythonhosted.org/packages/90/12/41bf27fde4d3605d3773ae57751eda182b8be2f5398011c041173b1d9534/greenlet-3.5.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad", size = 293637, upload-time = "2026-05-20T13:12:35.529Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/00/e9/3a7e557b895fd0469b00cd0b2bd498ba950e8bfdf6d7adeecf2c5e4130a6/greenlet-3.5.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4af5d4961818ab651d09c1448a03b1ba2a1726a076266ebb62330bab9f3238c", size = 652820, upload-time = "2026-06-17T18:07:24.95Z" },
|
{ url = "https://files.pythonhosted.org/packages/44/44/ba14b23e9757707050c2f397d305bbcae62e5d7cad122f8b6baec5ae4a1f/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e", size = 650840, upload-time = "2026-05-20T14:00:11.079Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/78/67/6225d5c5e4afc04be0fd161eec82e4b72017e8a100d222f25d7b42b0140d/greenlet-3.5.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a1789a6244ea1ba61fd4386c9a6a31873e9b0234762103364be98ef87dcb19f3", size = 658697, upload-time = "2026-06-17T18:29:48.365Z" },
|
{ url = "https://files.pythonhosted.org/packages/a8/37/5ddc2b686a6844f91abecef43411842426da2e1573f60b49ecf2547f4ae1/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986", size = 656416, upload-time = "2026-05-20T14:05:47.118Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/35/ad/9b3058f999b81750a9c6d9ec424f509462d232b58002086fe2ba63b66407/greenlet-3.5.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ee6288f1933d698b4f098127ed17bda2910a75d2807915bd16294a972055d6c", size = 658945, upload-time = "2026-06-17T18:39:32.509Z" },
|
{ url = "https://files.pythonhosted.org/packages/8c/46/5987dcd1a2570ba84f3b187536b2ca3ae97613387e57f5cfa99df068fe5e/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea37d5a157eb9493820d3792ac4ece28619a394391d2b9f2f78057d396ff0f0f", size = 656607, upload-time = "2026-05-20T14:09:13.949Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fa/99/6324b8ef916dcaddccb340b304c992ca3f947614ce0f2685d438187300b8/greenlet-3.5.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3be00501fb4a8c37f6b4b3c4773808ceb26ea65c7ea64fd5735d0f330b3786de", size = 656436, upload-time = "2026-06-17T17:39:32.509Z" },
|
{ url = "https://files.pythonhosted.org/packages/e1/f0/d17510297c35a2992712f0bf84de3779749999f7d3d63aa1f09db7c62dbe/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e", size = 654397, upload-time = "2026-05-20T13:14:30.696Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/92/75/1b6ecd8c027b69ab1b6798a84094df79aab5e69ac7e249c78b9d361dd1fa/greenlet-3.5.2-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:b4cad42662c796334c2d24607c411e3ed82481c1fb4e1e8ec3a5a8416060092e", size = 490529, upload-time = "2026-06-17T18:41:23.954Z" },
|
{ url = "https://files.pythonhosted.org/packages/2c/c1/6da0a9ddcc29d7e51ef14883fa3dc1e53b3f4ffba00582106c7bf55da1d8/greenlet-3.5.1-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:8d8a23250ea3ec7b36de8fa4b541e9e2db3ee82915cc060ab0631609ad8b28de", size = 488287, upload-time = "2026-05-20T14:01:43.143Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a9/ee/f5bf9daac27c5e1b011965f64b5630a32b415daf7381b312943629e12c2a/greenlet-3.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1d554cd96841a68d464d75a3736f8e87408a7b02b1930a75fa32feb408ad62f8", size = 1617193, upload-time = "2026-06-17T18:22:16.252Z" },
|
{ url = "https://files.pythonhosted.org/packages/37/eb/147387705bb89092645b012586e7273cb5ed3c90ef7eaf3a69173eaf0209/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d", size = 1614469, upload-time = "2026-05-20T14:02:30.192Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8a/21/b05d5b12715bda92ce27c118d64971d21e9b8f3563ed959a7d271e2d4223/greenlet-3.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3dff6cd3aac35f6cd3fc23460105acf576f5faf6c378de0bc088bf37c913864a", size = 1677512, upload-time = "2026-06-17T17:40:10.771Z" },
|
{ url = "https://files.pythonhosted.org/packages/a6/4e/37ee0da7732b7aa9896f17e15579a9df34b9fcb9dd494f0adfa749af6623/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78", size = 1675115, upload-time = "2026-05-20T13:14:40.972Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b8/97/1b8f1314b868041b327dc1051603e8142b826480cb0ecb8a7b7632aee9c4/greenlet-3.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:36cfea2aa075d544617176b2e84450480f0797070ad8799a8c41ada2fe449d32", size = 243145, upload-time = "2026-06-17T17:34:37.502Z" },
|
{ url = "https://files.pythonhosted.org/packages/57/f3/97dfcf4a6eb5077f8a672234216fb5923eb89f2cab7081cb10b2cf75b605/greenlet-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2", size = 245246, upload-time = "2026-05-20T13:12:22.646Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/36/07/1b5311775e04c718a118c504d7a3a312430e2a1bd1347226aff4774e4549/greenlet-3.5.2-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:a0314aa832c94633355dc6f3ee54f195159533355a323f26926fc63b98b2ccbb", size = 288315, upload-time = "2026-06-17T17:34:34.04Z" },
|
{ url = "https://files.pythonhosted.org/packages/5d/73/d7f72e34b582f694f4a9b248162db7b09cc458a259ba8f0c0bfa1a34ea7d/greenlet-3.5.1-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541", size = 285575, upload-time = "2026-05-20T13:12:07.043Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ed/cc/6abcd2a486b58b9f77b7a93b690d59cb2c11a5906ed2ad4c63c7b9c1113d/greenlet-3.5.2-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24c59cb7db9d5c694cb8fd0c76eef8e456b2123afdfa7e4b8f2a67a0860d7682", size = 659130, upload-time = "2026-06-17T18:07:26.354Z" },
|
{ url = "https://files.pythonhosted.org/packages/df/59/fa9c6e87dc8ad27a95dabe2f29f372b733d05a8a67470f6c901ed9975655/greenlet-3.5.1-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de", size = 656428, upload-time = "2026-05-20T14:00:12.556Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f2/12/f4aaad6d3d383233f700ab322568a4f29f2c701a4861d85f4811d99689b2/greenlet-3.5.2-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7bb811753703739ad318112f16eccfaabdac050037b6d092debaa8b23566b4ce", size = 669724, upload-time = "2026-06-17T18:29:50.13Z" },
|
{ url = "https://files.pythonhosted.org/packages/f6/f9/e753408871eaa61dfe35e619cfc67512b036fde99893685d50eea9e07146/greenlet-3.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64", size = 667064, upload-time = "2026-05-20T14:05:48.662Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/53/e0/4ce3a046b51e53934eae93d7f9c13975a97285741e9e1fcadf8751314c37/greenlet-3.5.2-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2debcd0ef9455b7d4879589903efc8e497d4b8fb8c0ae772309e44d1ca5e957f", size = 673494, upload-time = "2026-06-17T18:39:34.196Z" },
|
{ url = "https://files.pythonhosted.org/packages/dc/74/807a047255bf1e09303627c46dc043dca596b6958a354d904f32ab382005/greenlet-3.5.1-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:10a9a1c0bfbc93d41156ffcb90c75fbc05544054faf15dcc1fdf9765f8b607f0", size = 672962, upload-time = "2026-05-20T14:09:15.532Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/91/2a/a089811fc31c6bf8742f40a4e73470d6d401cef18e4314eb20dc399b377c/greenlet-3.5.2-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6d78b5c1c178dad90447f1b8452262709d3eef4c98f825569e74c9d0b2260ac9", size = 668089, upload-time = "2026-06-17T17:39:33.808Z" },
|
{ url = "https://files.pythonhosted.org/packages/96/27/5565b5b40389f1c7753003a07e21892fda8660926787036d5bc0308b8113/greenlet-3.5.1-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5", size = 665697, upload-time = "2026-05-20T13:14:32.943Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/52/e0/9c18721e63445dce02ee67e4c81c0f281626604ff55ae6f7b7f4354d7129/greenlet-3.5.2-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:9558cae989faeab6fbb425cd98a0cfa4190a47fba6443973fbee0a1eb0b0b6c3", size = 479721, upload-time = "2026-06-17T18:41:25.726Z" },
|
{ url = "https://files.pythonhosted.org/packages/76/32/19d4e13225193c29b13e308015223f7d75fd3d8623d49dd19040d2ce8ec1/greenlet-3.5.1-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:ef08c1567c78074b22d1a200183d52d04a14df447bf70bcbb6a3507a48e776fc", size = 476047, upload-time = "2026-05-20T14:01:44.39Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0f/1c/2f47c7d5fcfa98a62b705bf9a0505d86f4563c0d81cab1f7159ff1e743b7/greenlet-3.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:0977af2df83136f81c1f76e76d4e2fe7d0dc56ea9c101a86af26a95190b9ca32", size = 1625684, upload-time = "2026-06-17T18:22:17.664Z" },
|
{ url = "https://files.pythonhosted.org/packages/cf/82/e7de4178c0c2d1c9a5a3be3cc0b33e46a85b3ee4a77c071bf7ad8600e079/greenlet-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368", size = 1621256, upload-time = "2026-05-20T14:02:31.91Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b9/bf/661dd24624f70b7b32972d7693d0344ecde10278f647d7b828baf739899c/greenlet-3.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f9ed777c6891d8253e54468576f55e27f8fc1a662a664f946a191003574c0a74", size = 1688043, upload-time = "2026-06-17T17:40:12.403Z" },
|
{ url = "https://files.pythonhosted.org/packages/00/10/f2dddcf7dacac17dfc68691809589adad06135eb28930429cf58a6467a2f/greenlet-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26", size = 1685956, upload-time = "2026-05-20T13:14:42.55Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/60/49/d9bde1d15a21296b3b521fe083eb8aabd54ac05d15de9832918f3d639543/greenlet-3.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:c0ea4eb3de23f0bac1d75205e10ccfa9b418b17b01a2d7bf19e3b69dda08900a", size = 240531, upload-time = "2026-06-17T17:35:47.448Z" },
|
{ url = "https://files.pythonhosted.org/packages/22/17/4a232b32133230ada52f70e9d7f5b65b0caef8772f01849bd8d149e7e4ca/greenlet-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab", size = 239802, upload-time = "2026-05-20T13:13:15.481Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7f/4d/86d7768bd53e9907de0333df215c2018cd01a593b3715cbd79aa82dd94b7/greenlet-3.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:7a7bfc200be40d04961d7e80e8337d726c0c1a50777e588123c3ed8ba731dcb9", size = 239579, upload-time = "2026-06-17T17:39:39.954Z" },
|
{ url = "https://files.pythonhosted.org/packages/c2/ae/4e623a7e6d4d2a5f4cb8e4c82de4169fc637942caae68d6e676b8a128ac5/greenlet-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:92fd6d44ac5e5a887c8a5dc4a8ba0ba908527c31c12f78c6bc7dcfe8aab279f6", size = 236853, upload-time = "2026-05-20T13:15:37.301Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/92/15/907be5e8900901039bae752fa9a31c03a3c1e064833f35a4e49449184581/greenlet-3.5.2-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:98a52d6a50d4deaba304331d83ee3e10ebbdc1517fcca40b2715d1de4534065c", size = 296697, upload-time = "2026-06-17T17:37:15.887Z" },
|
{ url = "https://files.pythonhosted.org/packages/7a/57/816d9cff29119da3505b3d6a5e14a8af89006ac36f47f891ff293ee05af1/greenlet-3.5.1-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed", size = 293877, upload-time = "2026-05-20T13:10:19.078Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/95/5c/08c57be575c3d6a3c023bbf22144a1c7dc6ed4d134527bb36ded4dbf04a8/greenlet-3.5.2-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1587ff8b58fdf806993ed1490a06ac19c22d47b219c68b30954380029045d8d4", size = 656710, upload-time = "2026-06-17T18:07:28.046Z" },
|
{ url = "https://files.pythonhosted.org/packages/23/a1/59b0a7c7d140ff1a75626680b9a9899b79a9176cab298b394968fb023295/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244", size = 655333, upload-time = "2026-05-20T14:00:14.758Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8c/d0/749f917bdc9fc90fceea4aa65fbf6556e617a50714d1496bdc8ad190bb36/greenlet-3.5.2-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:feb721811d2754bfd16b48de151dd6b1f222c048e625151f2ca44cfdfd69f59c", size = 662629, upload-time = "2026-06-17T18:29:51.728Z" },
|
{ url = "https://files.pythonhosted.org/packages/72/1b/5efe127597625042218939d01855109f352779050768b670b52edcc16a6c/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c", size = 659443, upload-time = "2026-05-20T14:05:50.159Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/55/87/10776cd88df54d0f563e9e21e98363f2d6af94bedc553b1da0972fa87f80/greenlet-3.5.2-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a9476cbead736dc48ce89e3cd97acff95ecc48cbf21273603a438f9870c4a014", size = 663191, upload-time = "2026-06-17T18:39:35.639Z" },
|
{ url = "https://files.pythonhosted.org/packages/c9/9d/1dcdf7b95ab3cf8c7b6d7277c18a5e167312f2b362ddfcc5d5e6d8d84b43/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a57b0d05a0448eed231d59c0ceb287dde984551e54cbc51ac2d4865712838e9c", size = 659998, upload-time = "2026-05-20T14:09:16.912Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5a/a5/68cefae3a07f6d0093a490cf28ab604f14578f3e60205a2a2b2d5cd70af2/greenlet-3.5.2-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7fe6062b1f35534e1e8fb28dfed406cf4eeff3e0bca3a0d9f8ff69f20a4abb00", size = 660147, upload-time = "2026-06-17T17:39:35.068Z" },
|
{ url = "https://files.pythonhosted.org/packages/6c/6d/c404246ea4d22d097a7426d0efb5b781bd7eb67715f09e79001bd552ab18/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd", size = 658356, upload-time = "2026-05-20T13:14:35.091Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/02/aa/26ddf92826a99d87bfb8fdb8f3a262a6f16495a5d8e579737baa92fb4543/greenlet-3.5.2-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:5930d3946ecae99fa7fc0e3f3ae515426ad85058ebd9bfc6c00cca8016e6206b", size = 498199, upload-time = "2026-06-17T18:41:27.464Z" },
|
{ url = "https://files.pythonhosted.org/packages/05/7e/c4959664fc231d587d66d8e81f2095e98056ba1954beafdcbe635e251052/greenlet-3.5.1-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:b0703c2cef53e01baec47f7a3868009913ad71ec678bbecb42a6f40895e4ce62", size = 494470, upload-time = "2026-05-20T14:01:45.611Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d2/6b/b9156d8397e4750220f54c7c5c34650f1e740a8d2f66eab9cfd1b7b53b69/greenlet-3.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b4ac902af825cbac8e9b2fccab8122236fd2ba6c8b71a080116d2c2ec72671b1", size = 1621675, upload-time = "2026-06-17T18:22:18.873Z" },
|
{ url = "https://files.pythonhosted.org/packages/51/02/f8ee37fb6d2219329f350af241c27fcf12df57e723d11f6fc6d3bacdadaa/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e", size = 1619216, upload-time = "2026-05-20T14:02:33.403Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b0/e3/d3250f4fa01c211a93d04e34fded63187e648dbec17b9b1a14d388040593/greenlet-3.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:6f1e473c06ae8be00c9034c2bb10fa277b08a93287e3111c395b839f01d27e1f", size = 1680577, upload-time = "2026-06-17T17:40:14.055Z" },
|
{ url = "https://files.pythonhosted.org/packages/93/c5/3dc9475ace2c7a3680da12372cddd7f1ac874eb410a1ac48d3e9dab83782/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659", size = 1678427, upload-time = "2026-05-20T13:14:43.71Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/55/ba/eaee8bda4419770d7096b5a009ebff0ab20a2a28cdd83c4b591bfdf36fa9/greenlet-3.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:3c2315045f9983e2e50d7e89d95405c21bddb8745f2da4487bc080ab3525f904", size = 243482, upload-time = "2026-06-17T17:37:34.741Z" },
|
{ url = "https://files.pythonhosted.org/packages/df/4e/750c15c317a41ffb36f0bf40b933e3d744a7dede61889f74443ea69690cf/greenlet-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e", size = 245225, upload-time = "2026-05-20T13:13:59.366Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/37/45/f794a81c91e9942c61f9110bd1f9a38a0ea565eab57f8b08cd53d3131e48/greenlet-3.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:db548d5ab6c2a8ead82c013f875090d79b5d7d2b67fc513934ce6cf66492ad7f", size = 242062, upload-time = "2026-06-17T17:35:39.814Z" },
|
{ url = "https://files.pythonhosted.org/packages/4f/fd/d3baea2eeb7b617efd47e87ca06e2ec2c6118d303aa9e918e0ce16eadc10/greenlet-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:5028648bf2253ec4745add746129d3904121fa7fe871a76bed23c5720573ce0a", size = 239590, upload-time = "2026-05-20T13:13:37.382Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -421,11 +402,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
version = "3.18"
|
version = "3.17"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/b9/28/99c51f664567218d824af024c0251650fb27e4ca066df188dab0769c5b91/idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f", size = 196048, upload-time = "2026-05-28T14:32:38.55Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
|
{ url = "https://files.pythonhosted.org/packages/de/a7/f76514cc40ad6234098ecdebda08732d75964776c51a42845b7da10649e2/idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c", size = 65316, upload-time = "2026-05-28T14:32:37.035Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -743,7 +724,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pytest"
|
name = "pytest"
|
||||||
version = "9.1.0"
|
version = "9.0.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
@@ -752,9 +733,9 @@ dependencies = [
|
|||||||
{ name = "pluggy" },
|
{ name = "pluggy" },
|
||||||
{ name = "pygments" },
|
{ name = "pygments" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/84/0e/b5858858d74958632c49b72cb25a3976ff9f632397626715be71c89d3971/pytest-9.1.0.tar.gz", hash = "sha256:41dd9148c08072446394cefd3d79701701335a9f4cae69ba92e39f6c7f5c061c", size = 1634181, upload-time = "2026-06-13T18:52:45.983Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/8b/5a/ba30a81239b909821b3153e303e7def45178bf353da4f72380e6c5e8793b/pytest-9.1.0-py3-none-any.whl", hash = "sha256:8ebb0e7888bdf2bdfc602ec51f8f62d50200af37356c74e503c79a94f5c81f32", size = 386453, upload-time = "2026-06-13T18:52:44.045Z" },
|
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -811,15 +792,15 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-discovery"
|
name = "python-discovery"
|
||||||
version = "1.4.2"
|
version = "1.4.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "filelock" },
|
{ name = "filelock" },
|
||||||
{ name = "platformdirs" },
|
{ name = "platformdirs" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/0b/1a/cbbaf13b730abb0a16b964d984e19f2fe520c21a4dc664051359a3f5a9e7/python_discovery-1.4.2.tar.gz", hash = "sha256:8f3746c4b4968d22afbb97d36e1a0e5b66e6c0f297290f2e95f05b9b8bf18690", size = 70277, upload-time = "2026-06-11T16:10:42.383Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/a6/12/38c1a0b1e64806780c9563e3fc9f6e472251839662587cfbe9bfaf2ae10a/python_discovery-1.4.0.tar.gz", hash = "sha256:eb8bc7daad3c226c147e45bb4e970a1feb1bf4048ee178e6db59e197b8010ce3", size = 68455, upload-time = "2026-05-28T01:15:37.639Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/1a/82/a70006589557f267f15bd384c0642ad49f0d97b690c3a05b166b9dcbad3b/python_discovery-1.4.2-py3-none-any.whl", hash = "sha256:475803f53b7b2ed6e490e27373f9d8340f7d2eebf9acdaf645d7d714c97bb500", size = 33886, upload-time = "2026-06-11T16:10:41.192Z" },
|
{ url = "https://files.pythonhosted.org/packages/c8/8d/3d316429f65029532bb1e28ff77b797d86b5ac3915bb44ca4e19aa283d43/python_discovery-1.4.0-py3-none-any.whl", hash = "sha256:26ed78d703e234879a66244c7d4114563fb13ec5cd30a2d1357e5fb4850782da", size = 33217, upload-time = "2026-05-28T01:15:36.573Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -968,27 +949,27 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ruff"
|
name = "ruff"
|
||||||
version = "0.15.18"
|
version = "0.15.15"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/74/98/1295ad5a5aa9bc85bdcdfa5d82fe7b49c61af5657df4f227637ff9de0da6/ruff-0.15.18.tar.gz", hash = "sha256:2698a964c70e8bf402dcb99c8810472d270d141e7aa8c4e13599fd52033a2f33", size = 4761437, upload-time = "2026-06-18T18:25:39.224Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/84/6f/a76f7d96e5c962f5b69cee865e49c15c1116897c01990faa8a57edb62e7f/ruff-0.15.15.tar.gz", hash = "sha256:b8dff018130b46d8e5bf0f926ef6b60cf871d6d5ae45fc9334e09632daa741d6", size = 4706985, upload-time = "2026-05-28T14:16:57.784Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/b9/d0/686e984941269621e2be72612d5c1e461f8f7b38415a2a7d7a81c8ae6715/ruff-0.15.18-py3-none-linux_armv6l.whl", hash = "sha256:8b6850172348c8381b8b3084c5915a4393c2373b9b54cd5b5e1ea15812bc10df", size = 10887308, upload-time = "2026-06-18T18:25:03.062Z" },
|
{ url = "https://files.pythonhosted.org/packages/fa/9d/3a45c05b8ab04b4705989de70a79008e27c8003296a0feaee9edc18dd7e9/ruff-0.15.15-py3-none-linux_armv6l.whl", hash = "sha256:cf93e5388f412e1b108b1f8b34a6e036b70fe8aff89393befad96fe48670311b", size = 10710652, upload-time = "2026-05-28T14:16:06.701Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ed/21/bc4123e3f5515ee99f8ce1eb93a14a0628fe4d1678663cd08f933ac16931/ruff-0.15.18-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3fccc153a85417dcd976883160cacce486997b0a0058dd18f54b8aaaac7d1ce2", size = 11281305, upload-time = "2026-06-18T18:25:30.026Z" },
|
{ url = "https://files.pythonhosted.org/packages/05/66/da974431624bf3b49f6ee1f9543c02d929ff1cba78b0d5a79c38cf21f744/ruff-0.15.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e", size = 11096615, upload-time = "2026-05-28T14:16:23.313Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/51/93/4769464c25cf7ab2acb3c7dda9cad3d867eb41c59565b3e2a9d17249c90c/ruff-0.15.18-py3-none-macosx_11_0_arm64.whl", hash = "sha256:08d4c86a68f2c3ec2c9d56380a71fb4a4f65373055cbb8caabd645e9102f38d4", size = 10641215, upload-time = "2026-06-18T18:25:15.802Z" },
|
{ url = "https://files.pythonhosted.org/packages/8c/09/7443452e5d290230a712103f2fdceeef7184f3ec99a2bd01c8be78aaceb5/ruff-0.15.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530", size = 10436683, upload-time = "2026-05-28T14:16:40.974Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6c/42/56926d17120db2c208d76bf60a1a019644dd9e91dc27f0f95c9caddb1366/ruff-0.15.18-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37e5108745c2c0705da916d7d4de533ddf547051ef45f62888c31bae73f66318", size = 10957224, upload-time = "2026-06-18T18:25:36.955Z" },
|
{ url = "https://files.pythonhosted.org/packages/53/01/d330c26a57fa4f3943a14424904027428315b700fe4d14a84bb123a649e5/ruff-0.15.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7614ee79c69788cf6cedd568069ade9cecc22a1ad20494efe8d0c9ebb4b622d4", size = 10769064, upload-time = "2026-05-28T14:16:28.905Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/22/4f/d43fab8d8189afde803103022d000a8ef9f230616d436d52a8b2b8d63b50/ruff-0.15.18-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56949a6ce8b3abde54c0bcb22cebfe57e8771cadc84b407ae8b8eaf67ebdcd43", size = 10699024, upload-time = "2026-06-18T18:25:05.707Z" },
|
{ url = "https://files.pythonhosted.org/packages/1d/85/cc8770f8bdff541b1da8392d1634141fe4a0e3f4ee596605959b7906c27f/ruff-0.15.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3cdb1679e06a1f6b47bc384714ae96f6e2fb65ca441eb78c43d2ca554176ce1f", size = 10511987, upload-time = "2026-05-28T14:16:43.732Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/63/42/1e3e4c68bd408b9768cf3e439acbe2c78245225faef253f7028a0cdb63e0/ruff-0.15.18-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01a754cd6a1b630d3f97e33eb452cf7a98040482318e870f8bc52a5a30e62657", size = 11491458, upload-time = "2026-06-18T18:25:20.275Z" },
|
{ url = "https://files.pythonhosted.org/packages/7c/29/8c190c1472b63013583ba391f3342036e02010544c1270455ed8e519bdf3/ruff-0.15.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2728b93d7b23a603ea2c0ac6eb73d760bd38ec9de35f35fb41e18f7a3fee7622", size = 11275100, upload-time = "2026-05-28T14:16:55.244Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/20/77/47a3484bea8521e14a203d98c389c5c97846675e4f02734672da4a69b52a/ruff-0.15.18-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6ba7a07e03a44dbf10bb086ee06705b173625014ec99f73a7e6836a5e5590a0c", size = 12383752, upload-time = "2026-06-18T18:25:22.535Z" },
|
{ url = "https://files.pythonhosted.org/packages/9f/6b/7e145ce2cc8e63d6834eca03d83a0e18d121def5c69f91b4cf4011ed4879/ruff-0.15.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be582fcc0db438902c7792b08d6ddf6c9b9e21addaa10092c2c741cfb09e5a45", size = 12176903, upload-time = "2026-05-28T14:16:14.368Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0a/ca/054159590787023d83b658a1a1819c4c8910114e7015069340b71c0961cb/ruff-0.15.18-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a2c40a41a4cadbcf5897b548ab29dfe248b20c540961c0247d98a3973c70403", size = 11577923, upload-time = "2026-06-18T18:25:10.702Z" },
|
{ url = "https://files.pythonhosted.org/packages/80/a3/d5974637f68e451f7fadf015cf3101d1cd7d8ba5027cffe0b9e3826ebe6b/ruff-0.15.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7aa77465b8ecaf1a27bea098d696f7fed5e1eccbd10b321b682d6de586ae5627", size = 11404550, upload-time = "2026-05-28T14:16:20.138Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6d/ff/d353d6b7bbd73cc0ec37f4463d7540e45e894338abdd9964eee0de332708/ruff-0.15.18-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f0480ce690cbb6c4db6e5d08f19fce98e10ba131a8b60c1bcdac42771e3ae2d", size = 11583925, upload-time = "2026-06-18T18:25:32.391Z" },
|
{ url = "https://files.pythonhosted.org/packages/fe/1c/e6e5e568f22be4fb05d6244234aba384c06b451252453b821e1a529263cf/ruff-0.15.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4", size = 11382027, upload-time = "2026-05-28T14:16:46.615Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c1/4a/891f89b9c296ed3e5f3ece1a5629badc989d9a8fdaa30431aaf4774bc1c2/ruff-0.15.18-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2330215f1f393fa8733f55edce04fcf94c36a2c460fcde31f78cc84e4951e9b1", size = 11582834, upload-time = "2026-06-18T18:25:27.309Z" },
|
{ url = "https://files.pythonhosted.org/packages/1d/01/170921b49fcd2e8858825593f91cf7146c3e40a5c3e6df763e4bb0484dde/ruff-0.15.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a5015088452ca0081387063649ec67f06d3d1d6b8b936a1f836b5e9657ecd48c", size = 11366041, upload-time = "2026-05-28T14:16:26.247Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/32/a3/ed9e370154bf85de360b93c03026157f02d4943b2d01ff4945f4429f8e8a/ruff-0.15.18-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6aa6a3d979e48ae617578183674bf264fbe7d0114a796a26bd678d67963c7ff", size = 10927328, upload-time = "2026-06-18T18:25:34.676Z" },
|
{ url = "https://files.pythonhosted.org/packages/87/54/a7bad711d7de93254e15e06a4c375b89a03d18de45d3e5dcc86a4472fb1a/ruff-0.15.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5294aab6356c81600fcdea3a62bb1b924dfd5e91767c12318d3f68f86af57cd", size = 10741795, upload-time = "2026-05-28T14:16:17.11Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f5/d1/5cf5909329fedb5d39d555ee818ba5cf4638e1a301b89785d34f2905bfcb/ruff-0.15.18-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a81beadbbff2c9c245561ae3f77b16709d87f35eec650d0501679239d3449b22", size = 10693187, upload-time = "2026-06-18T18:25:08.245Z" },
|
{ url = "https://files.pythonhosted.org/packages/c9/31/38c075963668f8b41c6914ee0f6f318727fbe30ab9145cb29e6df464c5fa/ruff-0.15.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:db5bd4d802415cca656dc1616070b725952d6ae95eb5d4831e49fbd94a38f75f", size = 10511117, upload-time = "2026-05-28T14:16:31.767Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fd/44/ff6c635cf2c4f4e7b618b6640da057376baa36014695487d88aed4794268/ruff-0.15.18-py3-none-musllinux_1_2_i686.whl", hash = "sha256:2186d9e940ae332ab293623a75b5f4fe49565f449954d50a72a046683aa6b809", size = 11208721, upload-time = "2026-06-18T18:25:41.327Z" },
|
{ url = "https://files.pythonhosted.org/packages/9d/96/6ff689e1f7e375d1d97075eca022f74c2bab59554a432fe4d2e6f091986a/ruff-0.15.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:587a6278ed42059191c1a466e490bd7930fb50bd2e255398bc29616c895a61cb", size = 10994867, upload-time = "2026-05-28T14:16:35.149Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/88/d9/5baa2a30861adfb7022cf33c1e35b2fc18085b08c16f83eff4c7b99a5f48/ruff-0.15.18-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5c2abf140438032bc77b2284a6c9944ecd8a19e5f1c7b52b1b8e4a0a80d19a7a", size = 11678599, upload-time = "2026-06-18T18:25:13.607Z" },
|
{ url = "https://files.pythonhosted.org/packages/c3/c2/5dce0ab9f92a8d534fa62b9bf9caca3eddb8c1a81b616f5e195ada4f0d6e/ruff-0.15.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:df0c1c084f5f4be9812f61518a45c440d3c30d69ce4bf6c5270e66d38338f02a", size = 11482101, upload-time = "2026-05-28T14:16:49.598Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c3/1a/0725a7cfdc32ff769efb96ee782bec882e16448c5d9e3be947ec4c04ce27/ruff-0.15.18-py3-none-win32.whl", hash = "sha256:02299e6e9fa5b297a3f6d5d10d7bcd655c925b028bb8b9d4588214549c6b9ec4", size = 10901903, upload-time = "2026-06-18T18:25:24.755Z" },
|
{ url = "https://files.pythonhosted.org/packages/b1/c0/1003b60edd697c649faf61f1a34094b1abb38fb3d1181e3f895781250a08/ruff-0.15.15-py3-none-win32.whl", hash = "sha256:29428ea79694afbe756d45fd59b36f22b6b020dc0443cf7de0173046236964b9", size = 10716774, upload-time = "2026-05-28T14:16:52.337Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f3/51/805d9f6fb7970505c3504794a5ec350f605361b807fef4dcf214ebd35e72/ruff-0.15.18-py3-none-win_amd64.whl", hash = "sha256:dac80dc8d26b2257dbefabed62f5d255c3937b4ccb122da1fc634794fa3578b3", size = 12041189, upload-time = "2026-06-18T18:25:17.915Z" },
|
{ url = "https://files.pythonhosted.org/packages/02/a8/1269eddd6945a06c23f055ef7848886e37cf9d6a8bebb386a3115f01470c/ruff-0.15.15-py3-none-win_amd64.whl", hash = "sha256:8df0323902e15e24bc4bf246da830573d3cf3352bd0b9a164eab335d111ff4a4", size = 11868463, upload-time = "2026-05-28T14:16:11.333Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/29/4c/67bb45e41609eb4726f1bfeb59e083cf91d14c696d4bd14c234a980be93d/ruff-0.15.18-py3-none-win_arm64.whl", hash = "sha256:b2c9257fcbd4a3e5b977a1904e6facca016bafe2edc17df24db67cfaee03b4e4", size = 11329958, upload-time = "2026-06-18T18:25:43.686Z" },
|
{ url = "https://files.pythonhosted.org/packages/4e/b2/920464c907b191e37469d477a1aa8bc048b8f36c4c1610dfa4ab87b39e18/ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", size = 11138498, upload-time = "2026-05-28T14:16:38.425Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1085,14 +1066,14 @@ dev = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tqdm"
|
name = "tqdm"
|
||||||
version = "4.68.3"
|
version = "4.67.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/87/d7/0535a28b1f5f24f6612fb3ff1e89fb1a8d160fee0f976e0aa6803862134b/tqdm-4.68.3.tar.gz", hash = "sha256:00dfa48452b6b6cfae3dd9885636c23d3422d1ec97c66d96818cbd5e0821d482", size = 170596, upload-time = "2026-06-17T07:36:52.105Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/d8/8e/bb97bb0c71802080bfc8952937d174e49cfc50de5c951dd47b2496f0dcdb/tqdm-4.68.3-py3-none-any.whl", hash = "sha256:39832cc2def2789a6f29df83f172db7416cea70052c0907a57801c5f2fdccb03", size = 78337, upload-time = "2026-06-17T07:36:50.132Z" },
|
{ url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1149,7 +1130,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "virtualenv"
|
name = "virtualenv"
|
||||||
version = "21.5.1"
|
version = "21.4.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "distlib" },
|
{ name = "distlib" },
|
||||||
@@ -1157,7 +1138,7 @@ dependencies = [
|
|||||||
{ name = "platformdirs" },
|
{ name = "platformdirs" },
|
||||||
{ name = "python-discovery" },
|
{ name = "python-discovery" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/f1/a5/81f987504738e6defeed61ec1c47e2aefab3c35d8eeb87e1b3f38cf28254/virtualenv-21.5.1.tar.gz", hash = "sha256:dca3bf98275a59c652b69d68e73433e597d977c2da9198882479d1a7188009c8", size = 4578798, upload-time = "2026-06-16T16:23:58.603Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/e1/0d/4e93c8e6d1001a75763f87d8f5ecda8ebc7f4aa2153dddfaf4ae8892821a/virtualenv-21.4.2.tar.gz", hash = "sha256:38e6ee0a555615c0ea9da2ac7e9998fe8dc3b911dd33ad8eaad2020957653b0c", size = 7613326, upload-time = "2026-05-31T17:01:22.827Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/2c/02/3623e6169bed617ed1e2d372f7c69f92ec28d54c4dfc997055c8578ec148/virtualenv-21.5.1-py3-none-any.whl", hash = "sha256:55aa670b67bbfb991b03fda39bd3276d92c419d702376e98c5df1c9989a26783", size = 4558820, upload-time = "2026-06-16T16:23:56.963Z" },
|
{ url = "https://files.pythonhosted.org/packages/bf/c4/557dc082be035381b85fdb2b74e21d3d21b57750b74f2b47a32f3a639ff9/virtualenv-21.4.2-py3-none-any.whl", hash = "sha256:854210ca524a1a4d0d744734f4acbc721c3ffe163b85bbf5d56d14d5ae2f0fae", size = 7594079, upload-time = "2026-05-31T17:01:20.735Z" },
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user