Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
c2f1d8fe0a
|
|||
|
cd3e400297
|
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"name": "Django Time Tracker",
|
||||
"dockerFile": "../devcontainer.Dockerfile",
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"settings": {
|
||||
"python.pythonPath": "/usr/local/bin/python",
|
||||
"python.defaultInterpreterPath": "/usr/local/bin/python",
|
||||
"terminal.integrated.defaultProfile.linux": "bash"
|
||||
},
|
||||
"extensions": [
|
||||
"ms-python.python",
|
||||
"ms-python.debugpy",
|
||||
"ms-python.vscode-pylance",
|
||||
"ms-azuretools.vscode-docker",
|
||||
"batisteo.vscode-django",
|
||||
"charliermarsh.ruff",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"EditorConfig.EditorConfig"
|
||||
]
|
||||
}
|
||||
},
|
||||
"forwardPorts": [8000],
|
||||
"postCreateCommand": "poetry install && poetry run python manage.py migrate && npm install && make dev",
|
||||
}
|
||||
@@ -9,6 +9,7 @@ static
|
||||
.drone.yml
|
||||
.editorconfig
|
||||
.gitignore
|
||||
Caddyfile
|
||||
CHANGELOG.md
|
||||
db.sqlite3
|
||||
docker-compose*
|
||||
|
||||
@@ -15,6 +15,3 @@ indent_size = 4
|
||||
[**/*.js]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.html]
|
||||
insert_final_newline = false
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
# Docker registry URL (used in docker-compose.yml)
|
||||
REGISTRY_URL=registry.kucharczyk.xyz
|
||||
|
||||
# Container timezone
|
||||
TZ=Europe/Prague
|
||||
|
||||
# User/group IDs for container (used in entrypoint.sh)
|
||||
PUID=1000
|
||||
PGID=100
|
||||
|
||||
# External port mapping
|
||||
TIMETRACKER_EXTERNAL_PORT=8000
|
||||
|
||||
# Django production mode (set to "1" for production)
|
||||
PROD=1
|
||||
|
||||
# Database directory (defaults to project root)
|
||||
DATA_DIR=/home/timetracker/app/data
|
||||
|
||||
# CSRF trusted origins
|
||||
CSRF_TRUSTED_ORIGINS=https://tracker.kucharczyk.xyz
|
||||
@@ -9,42 +9,28 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
enable-cache: false
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --frozen
|
||||
|
||||
- name: Run Migrations
|
||||
run: uv run python manage.py migrate
|
||||
|
||||
- name: Run Tests
|
||||
run: uv run --with pytest-django pytest
|
||||
|
||||
python-version: 3.12
|
||||
- run: |
|
||||
python -m pip install poetry
|
||||
poetry install
|
||||
poetry env info
|
||||
poetry run python manage.py migrate
|
||||
# PROD=1 poetry run 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
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: |
|
||||
registry.kucharczyk.xyz/timetracker:latest
|
||||
registry.kucharczyk.xyz/timetracker:${{ env.VERSION_NUMBER }}
|
||||
# cache-from: type=gha
|
||||
# cache-to: type=gha,mode=max
|
||||
registry.kucharczyk.xyz/timetracker:${{ env.VERSION_NUMBER }}
|
||||
env:
|
||||
VERSION_NUMBER: 1.5.1
|
||||
|
||||
@@ -4,12 +4,9 @@ __pycache__
|
||||
.venv/
|
||||
node_modules
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
db.sqlite3
|
||||
data/
|
||||
/static/
|
||||
dist/
|
||||
.DS_Store
|
||||
.python-version
|
||||
.direnv
|
||||
.hermes/
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
repos:
|
||||
# disable due to incomaptible formatting between
|
||||
# black and ruff
|
||||
# TODO: replace with ruff when it works on NixOS
|
||||
# - repo: https://github.com/psf/black
|
||||
# rev: 24.8.0
|
||||
# hooks:
|
||||
# - id: black
|
||||
- repo: https://github.com/pycqa/isort
|
||||
rev: 5.13.2
|
||||
hooks:
|
||||
- id: isort
|
||||
name: isort (python)
|
||||
- repo: https://github.com/Riverside-Healthcare/djLint
|
||||
rev: v1.34.0
|
||||
hooks:
|
||||
- id: djlint-reformat-django
|
||||
args: ["--ignore", "H011"]
|
||||
- id: djlint-django
|
||||
args: ["--ignore", "H011"]
|
||||
Vendored
-26
@@ -1,26 +0,0 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Python Debugger: Current File",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"program": "${file}",
|
||||
"console": "integratedTerminal"
|
||||
},
|
||||
{
|
||||
"name": "Python Debugger: Django",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"args": [
|
||||
"runserver"
|
||||
],
|
||||
"django": true,
|
||||
"autoStartBrowser": false,
|
||||
"program": "${workspaceFolder}/manage.py"
|
||||
}
|
||||
]
|
||||
}
|
||||
+5
-59
@@ -1,68 +1,14 @@
|
||||
## 1.7.0 / 2026-05-12
|
||||
## Unreleased
|
||||
|
||||
### New
|
||||
* Add toast notification system with HTMX middleware integration
|
||||
* Add component system (Cotton-based): button, modal, table_row, search_field, gamelink
|
||||
* Add needs_price_update field to Purchase model for reliable price change detection
|
||||
* Add confirmation dialog before deleting a game
|
||||
* Add game status information documentation (STATUSES.md)
|
||||
* Allow directly updating device in session list via inline selector
|
||||
* Migrate from Poetry to uv for Python dependency management
|
||||
* Scope URLs to the games namespace
|
||||
* Start session template shared between add and edit views
|
||||
|
||||
### Improved
|
||||
* Major style overhaul: CSS variables, improved dark mode, Flowbite 4.x upgrade
|
||||
* Improve game status evaluation and add abandon prompt on refund
|
||||
* Robustify Docker container and fix default database location
|
||||
* Make component rendering deterministic for improved caching
|
||||
* Component caching: deterministic randomid generation
|
||||
* Component test suite with 1000+ lines of tests
|
||||
* Make tests more robust with django-pytest
|
||||
* Update NameWithIcon component: testable, fixed platform extraction bug
|
||||
* Pin Caddy version and improve make dev-prod
|
||||
* Add .env.example documenting environment variables
|
||||
* Unify A() component with explicit url_name vs href parameters
|
||||
|
||||
### Fixed
|
||||
* Fix refund confirmation not working
|
||||
* Fix stats view missing first and last game values
|
||||
* Fix A() component silent fallback on URL typos
|
||||
* Fix secondary submit buttons not working
|
||||
* Fix button not passing attributes
|
||||
* Fix default mutable arguments in component functions
|
||||
* Fix extra submit button when adding purchase
|
||||
* Fix pointer cursor on search field button
|
||||
|
||||
### Removed
|
||||
* Remove GraphQL API
|
||||
|
||||
### Dependencies
|
||||
* Update django-ninja to 1.6.2
|
||||
|
||||
## 1.6.1 / 2026-01-30 11:48+01:00
|
||||
|
||||
### New
|
||||
* Pre-fill time played into new playevent, also tracks time since last playevent
|
||||
* Improve light theme and fix light/dark theme switcher
|
||||
* Fix purchase form logic
|
||||
* Update dependencies
|
||||
|
||||
## 1.6.0 / 2025-01-15 23:13+01:00
|
||||
|
||||
### New
|
||||
* Visual overhaul of many pages
|
||||
## New
|
||||
* Render notes as Markdown
|
||||
* Require login by default
|
||||
* Add stats for dropped purchases, monthly playtimes
|
||||
* Allow deleting purchases
|
||||
* Add all-time stats
|
||||
* Manage purchases
|
||||
* Automatically convert purchase prices
|
||||
* Add emulated property to sessions
|
||||
* Add today's and last 7 days playtime stats to navbar
|
||||
|
||||
### Improved
|
||||
## Improved
|
||||
* mark refunded purchases red on game overview
|
||||
* increase session count on game overview when starting a new session
|
||||
* game overview:
|
||||
@@ -73,7 +19,7 @@
|
||||
* session list: use display name instead of sort name
|
||||
* unify the appearance of game links, and make them expand to full size on hover
|
||||
|
||||
### Fixed
|
||||
## Fixed
|
||||
* Fix title not being displayed on the Recent sessions page
|
||||
* Avoid errors when displaying game overview with zero sessions
|
||||
|
||||
@@ -198,7 +144,7 @@
|
||||
* Use the same form when editing a session as when adding a session
|
||||
* Change recent session view to current year instead of last 30 days
|
||||
* Add a hacky way not to reload a page when starting or ending a session (https://git.kucharczyk.xyz/lukas/timetracker/issues/52)
|
||||
* Improve session listing (https://git.kucharczyk.xyz/lukas/timetracker/issues/53)
|
||||
* Improve session list (https://git.kucharczyk.xyz/lukas/timetracker/issues/53)
|
||||
|
||||
### Fixes
|
||||
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Commands
|
||||
|
||||
| Task | Command |
|
||||
|------|---------|
|
||||
| Install dependencies | `make init` (installs Python via uv + npm packages, loads platform fixtures) |
|
||||
| Development server | `make dev` (runs Django runserver + Tailwind CSS watcher) |
|
||||
| Production-like dev | `make dev-prod` (Caddy + Gunicorn/Uvicorn + Django-Q cluster) |
|
||||
| Run tests | `make test` (or `uv run --with pytest-django pytest`) |
|
||||
| Make migrations | `make makemigrations` |
|
||||
| Apply migrations | `make migrate` |
|
||||
| CSS (Tailwind) | `make css` |
|
||||
| Django shell | `make shell` |
|
||||
| Create superuser | `make createsuperuser` |
|
||||
| Format Python | `make format` (or `uv run ruff format`) |
|
||||
| Lint Python | `make lint` (or `uv run ruff check`) |
|
||||
| Auto-fix lint | `make lint-fix` (`ruff check --fix`) |
|
||||
| Lint + format check + tests | `make check` (CI-style aggregate) |
|
||||
| Sync uv.lock | `uv sync` (after editing pyproject.toml) |
|
||||
| Load platform fixtures | `make loadplatforms` |
|
||||
| Load sample data | `make loadsample` |
|
||||
| Dump games data | `make dumpgames` |
|
||||
|
||||
## Architecture
|
||||
|
||||
A Django 6+ monolith (v1.7.0) with a single app (`games/`) for tracking video game purchases, play sessions, and statistics. Uses HTMX for interactivity with a pure-Python server-side component system, plus a Django Ninja REST API.
|
||||
|
||||
### Directory layout
|
||||
|
||||
```
|
||||
games/ — Django app: models, views, templates, forms, signals, tasks, API, filters
|
||||
common/ — Shared utilities: time formatting, component system, criteria, layout, icons
|
||||
timetracker/ — Django project: settings, URL root, ASGI/WSGI
|
||||
tests/ — Pytest tests
|
||||
contrib/ — One-off scripts (exchange rate import)
|
||||
docs/ — Additional documentation
|
||||
```
|
||||
|
||||
### Models (in `games/models.py`)
|
||||
|
||||
- **Game** — `name`, `platform` (FK), `status` (u/p/f/r/a), `mastered`, `playtime` (DurationField updated via signal), `year_released`, `sort_name`, `wikidata`
|
||||
- **Platform** — `name`, `group`, `icon` (slug, auto-generated from name)
|
||||
- **Purchase** — ownership type, prices, currency conversion (`converted_price`, `price_per_game` is a `GeneratedField`), links to Game via M2M. `num_purchases` counts linked games. DLC/SeasonPass/BattlePass must have a `related_purchase`
|
||||
- **Session** — `timestamp_start`/`timestamp_end`, `duration_manual`, `device` (FK), `note`, `emulated`. `duration_calculated` and `duration_total` are `GeneratedField`s (cannot be written directly)
|
||||
- **Device** — `name`, `type` (PC/Console/Handheld/Mobile/SBC/Unknown)
|
||||
- **PlayEvent** — marks when a game was started/finished (separate from Sessions), `days_to_finish` is a `GeneratedField`
|
||||
- **ExchangeRate** — cached FX rates per currency pair per year
|
||||
- **GameStatusChange** — audit log of status transitions, ordered by `-timestamp`
|
||||
- **FilterPreset** — saved filter configuration; `mode` (games/sessions/purchases/playevents), `find_filter`, `object_filter`, `ui_options` (all JSON). Follows Stash's SavedFilter pattern
|
||||
|
||||
**Sentinel objects**: `get_sentinel_platform()` returns an "Unspecified" platform used when a Game has no platform. A similar sentinel Device ("Unknown") is created when a Session has no device.
|
||||
|
||||
**GeneratedField constraint**: `duration_calculated`, `duration_total`, `price_per_game`, `days_to_finish` are computed by the database and cannot be written from application code.
|
||||
|
||||
### Key patterns
|
||||
|
||||
**Layout system** (`common/layout.py`): Views call `render_page(request, content, title=...)` instead of Django's `render()`. This assembles a full HTML document via `Page()` — analogous to FastHTML's `fast_app()`. `Page()` handles the `<head>`, navbar, toast container, 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/`): Pure-Python HTML builders, split into four submodules re-exported via `common/components/__init__.py`:
|
||||
|
||||
- **`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: `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()`
|
||||
- **`filters.py`** — Filter UI: `FilterBar()`, `SessionFilterBar()`, `PurchaseFilterBar()` (built from `FilterSelect` widgets)
|
||||
- **`search_select.py`** — `SearchSelect()` (form combobox) + `FilterSelect()` (include/exclude filter combobox with pinned Any/None modifiers) + `SearchSelectOption`, all built on a shared `_combobox_shell`; wired by `games/static/js/search_select.js`
|
||||
|
||||
**Filter system** (`games/filters.py` + `common/criteria.py`): Stash-inspired structured filtering.
|
||||
|
||||
- `common/criteria.py` defines typed criterion classes: `StringCriterion`, `IntCriterion`, `FloatCriterion`, `DateCriterion`, `BoolCriterion`, `MultiCriterion`, `ChoiceCriterion`. Each has a `modifier` (`Modifier` enum: EQUALS, NOT_EQUALS, INCLUDES, EXCLUDES, GREATER_THAN, LESS_THAN, BETWEEN, IS_NULL, etc.) and a `to_q(field_name)` method.
|
||||
- `OperatorFilter` base class provides AND/OR/NOT sub-filter composition and JSON serialization/deserialization.
|
||||
- `games/filters.py` defines `GameFilter`, `SessionFilter`, `PurchaseFilter` (all `@dataclass` subclasses of `OperatorFilter`) and `FindFilter` (sort/pagination). Filters serialize to/from JSON and are passed in the `?filter=` query parameter.
|
||||
- `parse_game_filter()`, `parse_session_filter()`, `parse_purchase_filter()` helpers deserialize from a JSON string.
|
||||
- `FilterPreset` model stores named filter configurations that users can save and reload.
|
||||
|
||||
**Views** (`games/views/`): Function-based views decorated with `@login_required`. Organized by domain entity:
|
||||
|
||||
- `session.py`, `game.py`, `purchase.py`, `playevent.py`, `platform.py`, `device.py`, `statuschange.py` — CRUD for each entity
|
||||
- `general.py` — `stats()`, `stats_alltime()`, `index()`, `model_counts` context processor, `global_current_year` context processor, `use_custom_redirect` decorator (redirects to `request.session["return_path"]` if set)
|
||||
- `stats_data.py` — `compute_stats(year)` returns a `StatsData` TypedDict; pure computation, no HTTP
|
||||
- `stats_content.py` — renders stats page content from a `StatsData` dict
|
||||
- `filter_presets.py` — `list_presets`, `save_preset`, `delete_preset`, `load_preset`
|
||||
- `auth.py` — custom `LoginView` subclassing Django's auth view, renders login page via `render_page()`
|
||||
|
||||
**Signals** (`games/signals.py`):
|
||||
- `pre_save` on Purchase: snapshots old price/currency for change detection
|
||||
- `post_save` on Purchase: sets `needs_price_update` if price/currency changed
|
||||
- `m2m_changed` on Purchase.games: updates `num_purchases` count
|
||||
- `pre_delete` on Game: decrements `num_purchases` on related Purchases (deletes Purchase if count reaches 0)
|
||||
- `post_save/post_delete` on Session: recalculates `Game.playtime` from session aggregate
|
||||
- `pre_save` on Game: creates `GameStatusChange` audit records when `status` changes
|
||||
|
||||
**Background tasks**: django-q2 cluster runs `games.tasks.convert_prices()` on a schedule to fetch exchange rates from `cdn.jsdelivr.net/npm/@fawazahmed0/currency-api` and convert purchase prices to CZK.
|
||||
|
||||
**HTMX toast middleware** (`games/htmx_middleware.py`): Converts Django messages into `HX-Trigger` headers with `show-toast` event. Skips if `HX-Redirect` is present. Toast rendering is handled client-side by Alpine.js (`games/static/js/toast.js`).
|
||||
|
||||
**REST API** (`games/api.py`): Django Ninja with routers mounted at `/api/`:
|
||||
- `GET /api/games/search` — search games for autocomplete
|
||||
- `PATCH /api/games/{id}/status` — update game status
|
||||
- `GET/POST /api/playevent/` — list/create play events
|
||||
- `GET/PATCH/DELETE /api/playevent/{id}` — get/update/delete play event
|
||||
- `PATCH /api/session/{id}/device` — update session device
|
||||
|
||||
### Templates
|
||||
|
||||
Only a small number of HTML templates remain (platform icon snippets and partials). The bulk of the UI is built via Python components. Template files:
|
||||
|
||||
- `games/templates/icons/<slug>.html` — SVG icon snippets (loaded by `common/icons.py` via `get_icon()`)
|
||||
- `games/templates/` — minimal partials for HTMX responses where needed
|
||||
|
||||
### Frontend stack
|
||||
|
||||
- **HTMX** (`games/static/js/htmx.min.js`) — partial page updates
|
||||
- **Alpine.js** (CDN) — reactive dropdowns (`GameStatusSelector`, `SessionDeviceSelector`), toast store
|
||||
- **Flowbite** (CDN) — navbar collapse, dropdown toggles
|
||||
- **Tailwind CSS** — utility classes, compiled from `common/input.css` → `games/static/base.css`
|
||||
- **Custom JS** in `games/static/js/`:
|
||||
- `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)
|
||||
- `utils.js` — shared helpers (e.g., `fetchWithHtmxTriggers`)
|
||||
|
||||
### Deployment
|
||||
|
||||
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.
|
||||
|
||||
### Database
|
||||
|
||||
SQLite with WAL journal mode. Connection timeout 20s. The `DATA_DIR` env var controls the database file location. Migrations live in `games/migrations/`. There are `GeneratedField`s on the models — these are computed by the database engine and cannot be written from application code.
|
||||
|
||||
### Configuration
|
||||
|
||||
- `DEBUG` is `True` unless `PROD` env var is set
|
||||
- `TIME_ZONE` defaults to `Europe/Prague` in debug, otherwise reads `TZ` env var (default `UTC`)
|
||||
- Django Admin, Debug Toolbar, and `django_extensions` are only available in `DEBUG` mode
|
||||
- `CSRF_TRUSTED_ORIGINS` is parsed from a comma-separated env var
|
||||
- `DATA_DIR` env var sets the SQLite database location (defaults to `BASE_DIR`)
|
||||
- django-q2 cluster: 1 worker, 60s timeout, 120s retry, ORM broker
|
||||
|
||||
### Testing
|
||||
|
||||
Tests live in `tests/`. Run with `make test` or `uv run --with pytest-django pytest`. Key test files:
|
||||
|
||||
- `test_components.py` — component rendering
|
||||
- `test_filter_bars.py`, `test_filter_helpers.py`, `test_filters.py` — filter system
|
||||
- `test_paths_return_200.py` — smoke test all list/view URLs
|
||||
- `test_rendered_pages.py` — HTML output of pages
|
||||
- `test_signals.py` — signal side-effects (playtime recalc, status change audit, etc.)
|
||||
- `test_stats.py` — stats computation
|
||||
- `test_streak.py`, `test_time.py`, `test_session_formatting.py` — utilities
|
||||
- `test_middleware_integration.py`, `test_toast_middleware.py` — HTMX middleware
|
||||
- `test_price_update.py` — currency conversion signals
|
||||
- `test_search_select.py` — SearchSelect component
|
||||
|
||||
Pytest settings are in `pyproject.toml` under `[tool.pytest.ini_options]` (`DJANGO_SETTINGS_MODULE = "timetracker.settings"`).
|
||||
|
||||
## Conventions for AI assistants
|
||||
|
||||
- **Never write to `GeneratedField`s** (`duration_calculated`, `duration_total`, `price_per_game`, `days_to_finish`). They are computed by the database.
|
||||
- **Name variables with complete words** — readable, unabbreviated identifiers in both Python and JavaScript (e.g. `template` not `tpl`, `event` not `e`, `element` not `el`, `removeButton` not `removeBtn`, `option`/`value` not single letters in loops). This applies to new code and to code you touch.
|
||||
- **Use `render_page()` not `render()`** for all full-page HTTP responses. Import from `common.layout`.
|
||||
- **Build UI with Python components** from `common.components`, not raw HTML strings or Django templates. `SafeText` children pass through unescaped; plain strings are auto-escaped.
|
||||
- **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.
|
||||
- **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`.
|
||||
- **Signals handle side-effects** — do not manually recalculate `Game.playtime` or `Purchase.num_purchases`; the signals in `games/signals.py` do this on save/delete.
|
||||
- **Button colors**: `blue` (primary action), `red` (destructive), `gray` (secondary), `green` (positive). Icon buttons use `icon=True`.
|
||||
- **Inline Alpine.js** is used for client-side reactivity in domain components (`GameStatusSelector`, `SessionDeviceSelector`). The pattern is `x-data="{...}"` with `fetchWithHtmxTriggers()` for PATCH API calls.
|
||||
- **Platform icons** are SVG snippets in `games/templates/icons/<slug>.html`. Add new ones there and reference them by slug in `Platform.icon`.
|
||||
- **Name compound types explicitly** — if a `tuple`, `dict`, or other compound value is passed between functions or appears in multiple signatures, give it a named type (`TypedDict`, `NamedTuple`, or a `type` alias) rather than repeating the structural annotation. This applies even to small types used in only a few places; the name carries intent that the structure cannot. Examples: `LabeledOption = tuple[str, str]` instead of repeating `tuple[str, str]` for (value, label) pairs; `RangeValues(min, max)` instead of `tuple[str, str]` for range bounds.
|
||||
@@ -1,15 +1,14 @@
|
||||
{
|
||||
auto_https off
|
||||
{
|
||||
auto_https off
|
||||
admin off
|
||||
}
|
||||
|
||||
:8000 {
|
||||
handle_path /static/* {
|
||||
root * /home/timetracker/app/static
|
||||
file_server
|
||||
}
|
||||
handle /robots.txt {
|
||||
root * /home/timetracker/app/games/static
|
||||
file_server
|
||||
}
|
||||
reverse_proxy localhost:8001
|
||||
}
|
||||
handle_path /static/* {
|
||||
root * /usr/share/caddy
|
||||
file_server
|
||||
}
|
||||
handle {
|
||||
reverse_proxy backend:8001
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
auto_https off
|
||||
}
|
||||
|
||||
:8000 {
|
||||
handle_path /static/* {
|
||||
root * static
|
||||
file_server browse
|
||||
}
|
||||
handle /robots.txt {
|
||||
root * games/static
|
||||
file_server browse
|
||||
}
|
||||
reverse_proxy :8001
|
||||
}
|
||||
+35
-45
@@ -1,55 +1,45 @@
|
||||
FROM ghcr.io/astral-sh/uv:python3.14-bookworm-slim AS builder
|
||||
FROM python:3.12.0-slim-bullseye
|
||||
|
||||
ENV UV_LINK_MODE=copy \
|
||||
UV_COMPILE_BYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /home/timetracker/app
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
--mount=type=bind,source=uv.lock,target=uv.lock \
|
||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||
uv sync --frozen --no-install-project --no-dev
|
||||
|
||||
COPY . .
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --frozen --no-dev
|
||||
|
||||
|
||||
FROM python:3.14-slim-bookworm
|
||||
|
||||
ENV PROD=1 \
|
||||
ENV VERSION_NUMBER=1.5.2 \
|
||||
PROD=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PATH="/home/timetracker/app/.venv/bin:$PATH"
|
||||
PYTHONFAULTHANDLER=1 \
|
||||
PYTHONHASHSEED=random \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
PIP_DEFAULT_TIMEOUT=100 \
|
||||
PIP_ROOT_USER_ACTION=ignore \
|
||||
POETRY_NO_INTERACTION=1 \
|
||||
POETRY_VIRTUALENVS_CREATE=false \
|
||||
POETRY_CACHE_DIR='/var/cache/pypoetry' \
|
||||
POETRY_HOME='/usr/local'
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
RUN apt-get update && apt-get upgrade -y \
|
||||
&& apt-get install --no-install-recommends -y \
|
||||
bash \
|
||||
curl \
|
||||
ca-certificates \
|
||||
libcap2-bin \
|
||||
supervisor \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& useradd -m --uid 1000 timetracker \
|
||||
&& mkdir -p /var/log/supervisor /etc/supervisor/conf.d /home/timetracker/data \
|
||||
&& chown timetracker:timetracker /var/log/supervisor /home/timetracker/data
|
||||
|
||||
ARG CADDY_VERSION=2.9.1
|
||||
RUN curl -sL "https://github.com/caddyserver/caddy/releases/download/v${CADDY_VERSION}/caddy_${CADDY_VERSION}_linux_amd64.tar.gz" \
|
||||
-o /tmp/caddy.tar.gz && \
|
||||
tar -xzf /tmp/caddy.tar.gz -C /tmp && \
|
||||
mv /tmp/caddy /usr/local/bin/caddy && \
|
||||
rm /tmp/caddy.tar.gz && \
|
||||
chmod +x /usr/local/bin/caddy
|
||||
&& curl -sSL 'https://install.python-poetry.org' | python - \
|
||||
&& poetry --version \
|
||||
&& apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \
|
||||
&& apt-get clean -y && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN useradd -m --uid 1000 timetracker \
|
||||
&& mkdir -p '/var/www/django/static' \
|
||||
&& chown timetracker:timetracker '/var/www/django/static'
|
||||
WORKDIR /home/timetracker/app
|
||||
|
||||
COPY --from=builder --chown=timetracker:timetracker /home/timetracker/app /home/timetracker/app
|
||||
|
||||
COPY --chown=timetracker:timetracker Caddyfile /etc/caddy/Caddyfile
|
||||
COPY --chown=timetracker:timetracker supervisor.conf /etc/supervisor/conf.d/supervisor.conf
|
||||
COPY --chown=timetracker:timetracker entrypoint.sh /
|
||||
COPY . /home/timetracker/app/
|
||||
RUN chown -R timetracker:timetracker /home/timetracker/app
|
||||
COPY entrypoint.sh /
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
ENV VERSION_NUMBER=1.7.0
|
||||
RUN --mount=type=cache,target="$POETRY_CACHE_DIR" \
|
||||
echo "$PROD" \
|
||||
&& poetry version \
|
||||
&& poetry run pip install -U pip \
|
||||
&& poetry install --only main --no-interaction --no-ansi --sync
|
||||
|
||||
USER timetracker
|
||||
|
||||
EXPOSE 8000
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
CMD [ "/entrypoint.sh" ]
|
||||
|
||||
@@ -1,91 +1,72 @@
|
||||
all: css migrate
|
||||
|
||||
initialize: npm css migrate loadplatforms
|
||||
initialize: npm css migrate sethookdir loadplatforms
|
||||
|
||||
HTMLFILES := $(shell find games/templates -type f)
|
||||
PYTHON_VERSION = 3.12
|
||||
|
||||
npm:
|
||||
pnpm install
|
||||
npm install
|
||||
|
||||
css: common/input.css
|
||||
pnpm tailwindcss -i ./common/input.css -o ./games/static/base.css
|
||||
npx tailwindcss -i ./common/input.css -o ./games/static/base.css
|
||||
|
||||
makemigrations:
|
||||
uv run python manage.py makemigrations
|
||||
poetry run python manage.py makemigrations
|
||||
|
||||
migrate: makemigrations
|
||||
uv run python manage.py migrate
|
||||
poetry run python manage.py migrate
|
||||
|
||||
init:
|
||||
uv python install $(PYTHON_VERSION)
|
||||
uv sync
|
||||
pnpm install
|
||||
$(MAKE) loadplatforms
|
||||
pyenv install -s $(PYTHON_VERSION)
|
||||
pyenv local $(PYTHON_VERSION)
|
||||
pip install poetry
|
||||
poetry install
|
||||
npm install
|
||||
|
||||
dev:
|
||||
@pnpm concurrently \
|
||||
@npx concurrently \
|
||||
--names "Django,Tailwind" \
|
||||
--prefix-colors "blue,green" \
|
||||
"uv run python -Wa manage.py runserver" \
|
||||
"pnpm tailwindcss -i ./common/input.css -o ./games/static/base.css --watch"
|
||||
"poetry run python -Wa manage.py runserver" \
|
||||
"npx tailwindcss -i ./common/input.css -o ./games/static/base.css --watch"
|
||||
|
||||
|
||||
caddy:
|
||||
caddy run --watch
|
||||
|
||||
dev-prod: migrate collectstatic
|
||||
@npx concurrently \
|
||||
--names "Caddy,Django,Django-Q" \
|
||||
"caddy run --config Caddyfile.dev" \
|
||||
"PROD=1 uv run python -m gunicorn --bind 0.0.0.0:8001 timetracker.asgi:application -k uvicorn.workers.UvicornWorker" \
|
||||
"PROD=1 uv run manage.py qcluster"
|
||||
PROD=1 poetry run python -m gunicorn --bind 0.0.0.0:8001 timetracker.asgi:application -k uvicorn.workers.UvicornWorker
|
||||
|
||||
dumpgames:
|
||||
uv run python manage.py dumpdata --format yaml games --output tracker_fixture.yaml
|
||||
poetry run python manage.py dumpdata --format yaml games --output tracker_fixture.yaml
|
||||
|
||||
loadplatforms:
|
||||
uv run python manage.py loaddata platforms.yaml
|
||||
poetry run python manage.py loaddata platforms.yaml
|
||||
|
||||
loadall:
|
||||
uv run python manage.py loaddata data.yaml
|
||||
poetry run python manage.py loaddata data.yaml
|
||||
|
||||
loadsample:
|
||||
uv run python manage.py loaddata sample.yaml
|
||||
poetry run python manage.py loaddata sample.yaml
|
||||
|
||||
createsuperuser:
|
||||
uv run python manage.py createsuperuser
|
||||
poetry run python manage.py createsuperuser
|
||||
|
||||
shell:
|
||||
uv run python manage.py shell
|
||||
poetry run python manage.py shell
|
||||
|
||||
collectstatic:
|
||||
uv run python manage.py collectstatic --clear --no-input
|
||||
poetry run python manage.py collectstatic --clear --no-input
|
||||
|
||||
uv.lock: pyproject.toml
|
||||
uv sync
|
||||
poetry.lock: pyproject.toml
|
||||
poetry install
|
||||
|
||||
test: uv.lock
|
||||
uv run --with pytest-django pytest
|
||||
|
||||
test-e2e: uv.lock
|
||||
uv run pytest e2e/
|
||||
|
||||
lint:
|
||||
uv run ruff check
|
||||
|
||||
lint-fix:
|
||||
uv run ruff check --fix
|
||||
|
||||
format:
|
||||
uv run ruff format
|
||||
|
||||
format-check:
|
||||
uv run ruff format --check
|
||||
|
||||
check: lint format-check test
|
||||
test: poetry.lock
|
||||
poetry run pytest
|
||||
|
||||
date:
|
||||
uv run python -c 'import datetime; from zoneinfo import ZoneInfo; print(datetime.datetime.isoformat(datetime.datetime.now(ZoneInfo("Europe/Prague")), timespec="minutes", sep=" "))'
|
||||
poetry run python -c 'import datetime; from zoneinfo import ZoneInfo; print(datetime.datetime.isoformat(datetime.datetime.now(ZoneInfo("Europe/Prague")), timespec="minutes", sep=" "))'
|
||||
|
||||
cleanstatic:
|
||||
rm -r static/*
|
||||
|
||||
@@ -4,12 +4,12 @@ A simple game catalogue and play session tracker.
|
||||
|
||||
# Development
|
||||
|
||||
The project uses `uv` to manage Python versions and dependencies.
|
||||
Simply run:
|
||||
The project uses `pyenv` to manage installed Python versions.
|
||||
If you have `pyenv` installed, you can simply run:
|
||||
|
||||
```
|
||||
make init
|
||||
```
|
||||
|
||||
This installs the correct Python version, syncs all dependencies, and installs npm packages.
|
||||
This will make sure the correct Python version is installed, and it will install all dependencies using `poetry`.
|
||||
Afterwards, you can start the development server using `make dev`.
|
||||
@@ -1,124 +0,0 @@
|
||||
"""Server-side HTML component library.
|
||||
|
||||
Split into core / primitives / domain / filters submodules; this package
|
||||
re-exports the public API so ``from common.components import X`` keeps working.
|
||||
"""
|
||||
|
||||
from common.utils import truncate
|
||||
|
||||
from common.components.core import (
|
||||
Component,
|
||||
HTMLAttribute,
|
||||
HTMLTag,
|
||||
_render_element,
|
||||
randomid,
|
||||
)
|
||||
from common.components.primitives import (
|
||||
A,
|
||||
AddForm,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
CsrfInput,
|
||||
Div,
|
||||
ExternalScript,
|
||||
H1,
|
||||
Icon,
|
||||
Input,
|
||||
Modal,
|
||||
ModuleScript,
|
||||
Pill,
|
||||
Popover,
|
||||
PopoverTruncated,
|
||||
SearchField,
|
||||
SimpleTable,
|
||||
Span,
|
||||
Label,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableTd,
|
||||
Template,
|
||||
YearPicker,
|
||||
paginated_table_content,
|
||||
)
|
||||
from common.components.search_select import (
|
||||
DEFAULT_PREFETCH,
|
||||
FilterSelect,
|
||||
LabeledOption,
|
||||
SearchSelect,
|
||||
SearchSelectOption,
|
||||
searchselect_selected,
|
||||
)
|
||||
from common.components.domain import (
|
||||
GameLink,
|
||||
GameStatus,
|
||||
GameStatusSelector,
|
||||
LinkedPurchase,
|
||||
NameWithIcon,
|
||||
PriceConverted,
|
||||
PurchasePrice,
|
||||
SessionDeviceSelector,
|
||||
_resolve_name_with_icon,
|
||||
)
|
||||
from common.components.filters import (
|
||||
FilterBar,
|
||||
PurchaseFilterBar,
|
||||
SessionFilterBar,
|
||||
DeviceFilterBar,
|
||||
PlatformFilterBar,
|
||||
PlayEventFilterBar,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"truncate",
|
||||
"Component",
|
||||
"HTMLAttribute",
|
||||
"HTMLTag",
|
||||
"_render_element",
|
||||
"randomid",
|
||||
"A",
|
||||
"AddForm",
|
||||
"Button",
|
||||
"ButtonGroup",
|
||||
"CsrfInput",
|
||||
"Div",
|
||||
"ExternalScript",
|
||||
"H1",
|
||||
"Icon",
|
||||
"Input",
|
||||
"Modal",
|
||||
"ModuleScript",
|
||||
"Pill",
|
||||
"Popover",
|
||||
"PopoverTruncated",
|
||||
"SearchField",
|
||||
"DEFAULT_PREFETCH",
|
||||
"FilterSelect",
|
||||
"LabeledOption",
|
||||
"SearchSelect",
|
||||
"SearchSelectOption",
|
||||
"searchselect_selected",
|
||||
"SimpleTable",
|
||||
"Span",
|
||||
"Label",
|
||||
"TableHeader",
|
||||
"TableRow",
|
||||
"TableTd",
|
||||
"Template",
|
||||
"YearPicker",
|
||||
"paginated_table_content",
|
||||
"GameLink",
|
||||
"GameStatus",
|
||||
"GameStatusSelector",
|
||||
"LinkedPurchase",
|
||||
"NameWithIcon",
|
||||
"PriceConverted",
|
||||
"PurchasePrice",
|
||||
"SessionDeviceSelector",
|
||||
"_resolve_name_with_icon",
|
||||
"FilterBar",
|
||||
"PurchaseFilterBar",
|
||||
"SessionFilterBar",
|
||||
"DeviceFilterBar",
|
||||
"PlatformFilterBar",
|
||||
"PlayEventFilterBar",
|
||||
]
|
||||
@@ -1,74 +0,0 @@
|
||||
"""Escaping core: the Component builder and its memoised renderer."""
|
||||
|
||||
import hashlib
|
||||
from functools import lru_cache
|
||||
|
||||
from django.utils.html import escape
|
||||
from django.utils.safestring import SafeText, mark_safe
|
||||
|
||||
|
||||
HTMLAttribute = tuple[str, str | int | bool]
|
||||
|
||||
|
||||
HTMLTag = str
|
||||
|
||||
|
||||
@lru_cache(maxsize=4096)
|
||||
def _render_element(
|
||||
tag_name: str,
|
||||
attrs_key: tuple[tuple[str, str], ...],
|
||||
children_key: tuple[tuple[str, bool], ...],
|
||||
) -> str:
|
||||
"""Pure, memoized HTML builder behind `Component`.
|
||||
|
||||
Inputs are fully hashable and fully determine the output, so identical
|
||||
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(
|
||||
child if is_safe else escape(child) for child, is_safe in children_key
|
||||
)
|
||||
if attrs_key:
|
||||
attributes_blob = " " + " ".join(
|
||||
f'{name}="{escape(value)}"' for name, value in attrs_key
|
||||
)
|
||||
else:
|
||||
attributes_blob = ""
|
||||
return f"<{tag_name}{attributes_blob}>{children_blob}</{tag_name}>"
|
||||
|
||||
|
||||
def Component(
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
tag_name: str = "",
|
||||
) -> SafeText:
|
||||
"""Render an HTML element. Attribute values are always escaped; children are
|
||||
escaped unless they are `SafeText` (so nested components pass through),
|
||||
preventing accidental HTML injection. Rendering is memoized via
|
||||
`_render_element`."""
|
||||
attributes = attributes or []
|
||||
children = children or []
|
||||
if not tag_name:
|
||||
raise ValueError("tag_name is required.")
|
||||
if isinstance(children, str):
|
||||
children = [children]
|
||||
attrs_key = tuple((name, str(value)) for name, value in attributes)
|
||||
children_key = tuple((child, isinstance(child, SafeText)) for child in children)
|
||||
return mark_safe(_render_element(tag_name, attrs_key, children_key))
|
||||
|
||||
|
||||
def randomid(seed: str = "", content: str = "", length: int = 10) -> str:
|
||||
if not seed and not content:
|
||||
return seed
|
||||
hash_input = f"{seed}:{content}" if seed else content
|
||||
content_hash = hashlib.sha1(hash_input.encode()).hexdigest()
|
||||
base = (
|
||||
content_hash[:length]
|
||||
if not seed
|
||||
else content_hash[: max(0, length - len(seed))]
|
||||
)
|
||||
return seed + base
|
||||
@@ -1,342 +0,0 @@
|
||||
"""Domain components for games / purchases / sessions."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from django.template.defaultfilters import floatformat
|
||||
from django.urls import reverse
|
||||
from django.utils.safestring import SafeText, mark_safe
|
||||
|
||||
from common.components.core import Component, HTMLTag
|
||||
from common.components.primitives import (
|
||||
A,
|
||||
Div,
|
||||
Icon,
|
||||
Popover,
|
||||
PopoverTruncated,
|
||||
Span,
|
||||
)
|
||||
from games.models import Game, Purchase, Session
|
||||
|
||||
|
||||
def GameLink(
|
||||
game_id: int,
|
||||
name: str = "",
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
"""Link to a game's detail page. Uses children (slot) if provided, otherwise name."""
|
||||
from django.urls import reverse
|
||||
|
||||
children = children or []
|
||||
display = children if children else [name]
|
||||
link = reverse("games:view_game", args=[game_id])
|
||||
|
||||
return Span(
|
||||
attributes=[("class", "truncate-container")],
|
||||
children=[
|
||||
Component(
|
||||
tag_name="a",
|
||||
attributes=[
|
||||
("href", link),
|
||||
("class", "underline decoration-slate-500 sm:decoration-2"),
|
||||
],
|
||||
children=display if isinstance(display, list) else [display],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
_STATUS_COLORS = {
|
||||
"u": "bg-gray-500",
|
||||
"p": "bg-orange-400",
|
||||
"f": "bg-green-500",
|
||||
"a": "bg-red-500",
|
||||
"r": "bg-purple-500",
|
||||
}
|
||||
|
||||
|
||||
def GameStatus(
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
status: str = "u",
|
||||
display: str = "",
|
||||
class_: str = "",
|
||||
) -> SafeText:
|
||||
"""Colored status dot with label. Status codes: u/p/f/a/r."""
|
||||
children = children or []
|
||||
outer_class = (
|
||||
f"{'flex' if display == 'flex' else 'inline-flex'} "
|
||||
"gap-2 items-center align-middle"
|
||||
)
|
||||
if class_:
|
||||
outer_class += f" {class_}"
|
||||
dot_color = _STATUS_COLORS.get(status, _STATUS_COLORS["u"])
|
||||
|
||||
dot = Span(
|
||||
attributes=[("class", f"rounded-xl w-3 h-3 {dot_color}")],
|
||||
children=["\xa0"],
|
||||
)
|
||||
|
||||
return Span(
|
||||
attributes=[("class", outer_class)],
|
||||
children=[dot] + (children if isinstance(children, list) else [children]),
|
||||
)
|
||||
|
||||
|
||||
def PriceConverted(
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
"""Wrap content in a span that indicates the price was converted."""
|
||||
children = children or []
|
||||
return Span(
|
||||
attributes=[
|
||||
("title", "Price is a result of conversion and rounding."),
|
||||
("class", "decoration-dotted underline"),
|
||||
],
|
||||
children=children if isinstance(children, list) else [children],
|
||||
)
|
||||
|
||||
|
||||
def LinkedPurchase(purchase: Purchase) -> SafeText:
|
||||
link = reverse("games:view_purchase", args=[int(purchase.id)])
|
||||
link_content = ""
|
||||
popover_content = ""
|
||||
game_count = purchase.games.count()
|
||||
popover_if_not_truncated = False
|
||||
if game_count == 1:
|
||||
link_content += purchase.games.first().name
|
||||
popover_content = link_content
|
||||
if game_count > 1:
|
||||
if purchase.name:
|
||||
link_content += f"{purchase.name}"
|
||||
popover_content += f"<h1>{purchase.name}</h1><br>"
|
||||
else:
|
||||
link_content += f"{game_count} games"
|
||||
popover_if_not_truncated = True
|
||||
popover_content += f"""
|
||||
<ul class="list-disc list-inside">
|
||||
{"".join(f"<li>{game.name}</li>" for game in purchase.games.all())}
|
||||
</ul>
|
||||
"""
|
||||
icon = (
|
||||
(purchase.platform.icon if purchase.platform else "unspecified")
|
||||
if game_count == 1
|
||||
else "unspecified"
|
||||
)
|
||||
if link_content == "":
|
||||
raise ValueError("link_content is empty!!")
|
||||
a_content = Div(
|
||||
[("class", "inline-flex gap-2 items-center")],
|
||||
[
|
||||
Icon(
|
||||
icon,
|
||||
[("title", "Multiple")],
|
||||
),
|
||||
PopoverTruncated(
|
||||
input_string=link_content,
|
||||
popover_content=mark_safe(popover_content),
|
||||
popover_if_not_truncated=popover_if_not_truncated,
|
||||
),
|
||||
],
|
||||
)
|
||||
return A(href=link, children=[a_content])
|
||||
|
||||
|
||||
def NameWithIcon(
|
||||
name: str = "",
|
||||
game: Game | None = None,
|
||||
session: Session | None = None,
|
||||
linkify: bool = True,
|
||||
emulated: bool = False,
|
||||
) -> SafeText:
|
||||
_name, platform, final_emulated, create_link, link = _resolve_name_with_icon(
|
||||
name, game, session, linkify
|
||||
)
|
||||
|
||||
content = Div(
|
||||
[("class", "inline-flex gap-2 items-center")],
|
||||
[
|
||||
Icon(
|
||||
platform.icon,
|
||||
[("title", platform.name)],
|
||||
)
|
||||
if platform
|
||||
else "",
|
||||
Icon("emulated", [("title", "Emulated")]) if final_emulated else "",
|
||||
PopoverTruncated(_name),
|
||||
],
|
||||
)
|
||||
|
||||
return (
|
||||
A(
|
||||
href=link,
|
||||
children=[content],
|
||||
)
|
||||
if create_link
|
||||
else content
|
||||
)
|
||||
|
||||
|
||||
def _resolve_name_with_icon(
|
||||
name: str,
|
||||
game: Game | None,
|
||||
session: Session | None,
|
||||
linkify: bool,
|
||||
) -> tuple[str, Any, bool, bool, str]:
|
||||
create_link = False
|
||||
link = ""
|
||||
platform = None
|
||||
final_emulated = False
|
||||
|
||||
if session is not None:
|
||||
game = session.game
|
||||
platform = game.platform
|
||||
final_emulated = session.emulated
|
||||
if linkify:
|
||||
create_link = True
|
||||
link = reverse("games:view_game", args=[int(game.pk)])
|
||||
elif game is not None:
|
||||
platform = game.platform
|
||||
if linkify:
|
||||
create_link = True
|
||||
link = reverse("games:view_game", args=[int(game.pk)])
|
||||
|
||||
_name = name or (game.name if game else "")
|
||||
|
||||
return _name, platform, final_emulated, create_link, link
|
||||
|
||||
|
||||
def PurchasePrice(purchase) -> SafeText:
|
||||
return Popover(
|
||||
popover_content=f"{floatformat(purchase.price)} {purchase.price_currency}",
|
||||
wrapped_content=f"{floatformat(purchase.converted_price)} {purchase.converted_currency}",
|
||||
wrapped_classes="underline decoration-dotted",
|
||||
)
|
||||
|
||||
|
||||
def GameStatusSelector(game, game_statuses, csrf_token: str) -> SafeText:
|
||||
"""Alpine.js dropdown to change a game's status."""
|
||||
options_html = "\n".join(
|
||||
f"<template x-if=\"status == '{value}'\">"
|
||||
f"{GameStatus(status=value, children=[label], display='flex')}"
|
||||
f"</template>"
|
||||
for value, label in game_statuses
|
||||
)
|
||||
list_items = "\n".join(
|
||||
f"<li><a href=\"#\" @click.prevent.stop=\"setStatus('{value}', '{label}'); 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': 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
|
||||
)
|
||||
}
|
||||
</div>
|
||||
""")
|
||||
|
||||
|
||||
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>"
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,954 +0,0 @@
|
||||
"""Generic HTML primitives (no domain knowledge)."""
|
||||
|
||||
from django.middleware.csrf import get_token
|
||||
from django.templatetags.static import static
|
||||
from django.urls import reverse
|
||||
from django.utils.html import conditional_escape
|
||||
from django.utils.safestring import SafeText, mark_safe
|
||||
|
||||
from common.icons import get_icon
|
||||
from common.utils import truncate
|
||||
from common.components.core import Component, HTMLAttribute, HTMLTag, randomid
|
||||
|
||||
|
||||
_COLOR_CLASSES = {
|
||||
"blue": "text-white bg-brand box-border border border-transparent hover:bg-brand-strong focus:ring-4 focus:ring-brand-medium",
|
||||
"red": "bg-red-700 dark:bg-red-600 dark:focus:ring-red-900 dark:hover:bg-red-700 focus:ring-red-300 hover:bg-red-800 text-white",
|
||||
"gray": "bg-white border-gray-200 dark:bg-gray-800 dark:border-gray-600 dark:focus:ring-gray-700 dark:hover:bg-gray-700 dark:hover:text-white dark:text-gray-400 focus:ring-gray-100 hover:bg-gray-100 hover:text-blue-700 text-gray-900 border",
|
||||
"green": "bg-green-700 dark:bg-green-600 dark:focus:ring-green-800 dark:hover:bg-green-700 focus:ring-green-300 hover:bg-green-800 text-white",
|
||||
}
|
||||
|
||||
|
||||
_SIZE_CLASSES = {
|
||||
"xs": "px-3 py-2 text-xs shadow-xs",
|
||||
"sm": "px-3 py-2 text-sm",
|
||||
"base": "px-5 py-2.5 text-sm",
|
||||
"lg": "px-5 py-3 text-base",
|
||||
"xl": "px-6 py-3.5 text-base",
|
||||
}
|
||||
|
||||
|
||||
def _popover_html(
|
||||
id: str,
|
||||
popover_content: str,
|
||||
wrapped_content: str = "",
|
||||
wrapped_classes: str = "",
|
||||
slot: str = "",
|
||||
) -> SafeText:
|
||||
"""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
|
||||
|
||||
span = Span(
|
||||
attributes=[
|
||||
("data-popover-target", id),
|
||||
("class", wrapped_classes),
|
||||
],
|
||||
children=[display_content] if display_content else [],
|
||||
)
|
||||
|
||||
popover_tooltip_class = (
|
||||
"absolute z-10 invisible inline-block text-sm text-white "
|
||||
"transition-opacity duration-300 bg-white border border-purple-200 "
|
||||
"rounded-lg shadow-xs opacity-0 dark:text-white dark:border-purple-600 "
|
||||
"dark:bg-purple-800"
|
||||
)
|
||||
|
||||
div = Component(
|
||||
tag_name="div",
|
||||
attributes=[
|
||||
("data-popover", ""),
|
||||
("id", id),
|
||||
("role", "tooltip"),
|
||||
("class", popover_tooltip_class),
|
||||
],
|
||||
children=[
|
||||
Component(
|
||||
tag_name="div",
|
||||
attributes=[("class", "px-3 py-2")],
|
||||
children=[popover_content],
|
||||
),
|
||||
Component(tag_name="div", attributes=[("data-popper-arrow", "")]),
|
||||
mark_safe( # nosec — intentional HTML comment for Tailwind JIT
|
||||
"<!-- for Tailwind CSS to generate decoration-dotted CSS "
|
||||
"from Python component -->"
|
||||
),
|
||||
Span(
|
||||
attributes=[("class", "hidden decoration-dotted")],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
return mark_safe(span + "\n" + div)
|
||||
|
||||
|
||||
def Popover(
|
||||
popover_content: str,
|
||||
wrapped_content: str = "",
|
||||
wrapped_classes: str = "",
|
||||
children: list[HTMLTag] | None = None,
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
id: str = "",
|
||||
) -> str:
|
||||
children = children or []
|
||||
if not wrapped_content and not children:
|
||||
raise ValueError("One of wrapped_content or children is required.")
|
||||
if not id:
|
||||
id = randomid(content=f"{wrapped_content}:{popover_content}:{wrapped_classes}")
|
||||
|
||||
slot = mark_safe("\n".join(children))
|
||||
return _popover_html(
|
||||
id=id,
|
||||
popover_content=popover_content,
|
||||
wrapped_content=wrapped_content,
|
||||
wrapped_classes=wrapped_classes,
|
||||
slot=slot,
|
||||
)
|
||||
|
||||
|
||||
def PopoverTruncated(
|
||||
input_string: str,
|
||||
popover_content: str = "",
|
||||
popover_if_not_truncated: bool = False,
|
||||
length: int = 30,
|
||||
ellipsis: str = "…",
|
||||
endpart: str = "",
|
||||
) -> str:
|
||||
"""
|
||||
Returns `input_string` truncated after `length` of characters
|
||||
and displays the untruncated text in a popover HTML element.
|
||||
The truncated text ends in `ellipsis`, and optionally
|
||||
an always-visible `endpart` can be specified.
|
||||
`popover_content` can be specified if:
|
||||
1. It needs to be always displayed regardless if text is truncated.
|
||||
2. It needs to differ from `input_string`.
|
||||
"""
|
||||
if (truncated := truncate(input_string, length, ellipsis, endpart)) != input_string:
|
||||
return Popover(
|
||||
wrapped_content=truncated,
|
||||
popover_content=popover_content if popover_content else input_string,
|
||||
)
|
||||
else:
|
||||
if popover_content and popover_if_not_truncated:
|
||||
return Popover(
|
||||
wrapped_content=input_string,
|
||||
popover_content=popover_content if popover_content else "",
|
||||
)
|
||||
else:
|
||||
return input_string
|
||||
|
||||
|
||||
def A(
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
url_name: str | None = None,
|
||||
href: str | None = None,
|
||||
) -> SafeText:
|
||||
"""
|
||||
Returns an anchor <a> tag.
|
||||
|
||||
Accepts one of two mutually-exclusive URL specifications:
|
||||
- url_name: URL pattern name, resolved via reverse()
|
||||
- href: Literal path string passed through as-is
|
||||
"""
|
||||
attributes = attributes or []
|
||||
children = children or []
|
||||
if url_name is not None and href is not None:
|
||||
raise ValueError("Provide exactly one of 'url_name' or 'href', not both.")
|
||||
|
||||
additional_attributes = []
|
||||
if url_name is not None:
|
||||
additional_attributes = [("href", reverse(url_name))]
|
||||
elif href is not None:
|
||||
additional_attributes = [("href", href)]
|
||||
return Component(
|
||||
tag_name="a", attributes=attributes + additional_attributes, children=children
|
||||
)
|
||||
|
||||
|
||||
def Button(
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
size: str = "base",
|
||||
icon: bool = False,
|
||||
color: str = "blue",
|
||||
type: str = "button",
|
||||
hx_get: str = "",
|
||||
hx_target: str = "",
|
||||
hx_swap: str = "",
|
||||
title: str = "",
|
||||
onclick: str = "",
|
||||
name: str = "",
|
||||
) -> SafeText:
|
||||
attributes = attributes or []
|
||||
children = children or []
|
||||
|
||||
# Separate custom class from other generic attributes
|
||||
custom_class = ""
|
||||
other_attrs: list[HTMLAttribute] = []
|
||||
for attr_name, attr_value in attributes:
|
||||
if attr_name == "class":
|
||||
custom_class = str(attr_value)
|
||||
else:
|
||||
other_attrs.append((attr_name, attr_value))
|
||||
|
||||
# Build class string: custom class first, then base, color, size, icon
|
||||
class_parts: list[str] = []
|
||||
if custom_class:
|
||||
class_parts.append(custom_class)
|
||||
class_parts.append(
|
||||
"hover:cursor-pointer leading-5 focus:outline-hidden focus:ring-4 "
|
||||
"font-medium mb-2 me-2 rounded-base"
|
||||
)
|
||||
class_parts.append(_COLOR_CLASSES.get(color, _COLOR_CLASSES["blue"]))
|
||||
class_parts.append(_SIZE_CLASSES.get(size, _SIZE_CLASSES["base"]))
|
||||
if icon:
|
||||
class_parts.append("inline-flex text-center items-center gap-2")
|
||||
|
||||
# Build the full attribute list for the button tag
|
||||
button_attrs: list[HTMLAttribute] = [
|
||||
("type", type),
|
||||
("class", " ".join(class_parts)),
|
||||
]
|
||||
if hx_get:
|
||||
button_attrs.append(("hx-get", hx_get))
|
||||
if hx_target:
|
||||
button_attrs.append(("hx-target", hx_target))
|
||||
if hx_swap:
|
||||
button_attrs.append(("hx-swap", hx_swap))
|
||||
if title:
|
||||
button_attrs.append(("title", title))
|
||||
if onclick:
|
||||
button_attrs.append(("onclick", onclick))
|
||||
if name:
|
||||
button_attrs.append(("name", name))
|
||||
button_attrs.extend(other_attrs)
|
||||
|
||||
return Component(
|
||||
tag_name="button",
|
||||
attributes=button_attrs,
|
||||
children=children,
|
||||
)
|
||||
|
||||
|
||||
_GROUP_BUTTON_COLORS = {
|
||||
"gray": (
|
||||
"px-2 py-1 text-xs font-medium text-gray-900 bg-white border "
|
||||
"border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 "
|
||||
"focus:ring-2 focus:ring-blue-700 focus:text-blue-700 "
|
||||
"dark:bg-gray-800 dark:border-gray-700 dark:text-white "
|
||||
"dark:hover:text-white dark:hover:bg-gray-700 "
|
||||
"dark:focus:ring-blue-500 dark:focus:text-white"
|
||||
),
|
||||
"red": (
|
||||
"px-2 py-1 text-xs font-medium text-gray-900 bg-white border "
|
||||
"border-gray-200 hover:bg-red-500 hover:text-white focus:z-10 "
|
||||
"focus:ring-2 focus:ring-blue-700 focus:text-blue-700 "
|
||||
"dark:bg-gray-800 dark:border-gray-700 dark:text-white "
|
||||
"dark:hover:text-white dark:hover:border-red-700 "
|
||||
"dark:hover:bg-red-700 dark:focus:ring-blue-500 dark:focus:text-white"
|
||||
),
|
||||
"green": (
|
||||
"px-2 py-1 text-xs font-medium text-gray-900 bg-white border "
|
||||
"border-gray-200 hover:bg-green-500 hover:border-green-600 "
|
||||
"hover:text-white focus:z-10 focus:ring-2 focus:ring-green-700 "
|
||||
"focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 "
|
||||
"dark:text-white dark:hover:text-white dark:hover:border-green-700 "
|
||||
"dark:hover:bg-green-600 dark:focus:ring-green-500 "
|
||||
"dark:focus:text-white"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _button_group_button(
|
||||
href: str,
|
||||
slot: str,
|
||||
color: str = "gray",
|
||||
title: str = "",
|
||||
hx_get: str = "",
|
||||
hx_target: str = "",
|
||||
) -> SafeText:
|
||||
"""Generate a single button-group button (inner <button> inside <a>)."""
|
||||
color_classes = _GROUP_BUTTON_COLORS.get(color, _GROUP_BUTTON_COLORS["gray"])
|
||||
|
||||
a_attrs: list[HTMLAttribute] = [("href", href)]
|
||||
if hx_get:
|
||||
a_attrs.append(("hx-get", hx_get))
|
||||
if hx_target:
|
||||
a_attrs.append(("hx-target", hx_target))
|
||||
a_attrs.append(
|
||||
(
|
||||
"class",
|
||||
"[&:first-of-type_button]:rounded-s-lg "
|
||||
"[&:last-of-type_button]:rounded-e-lg",
|
||||
)
|
||||
)
|
||||
|
||||
button = Component(
|
||||
tag_name="button",
|
||||
attributes=[
|
||||
("type", "button"),
|
||||
("title", title),
|
||||
("class", color_classes + " hover:cursor-pointer"),
|
||||
],
|
||||
children=[slot],
|
||||
)
|
||||
|
||||
return Component(tag_name="a", attributes=a_attrs, children=[button])
|
||||
|
||||
|
||||
def ButtonGroup(buttons: list[dict] | None = None) -> SafeText:
|
||||
"""Generate a button group div.
|
||||
|
||||
Each button dict accepts: href, slot (required), color, title, hx_get, hx_target.
|
||||
Empty dicts (no slot) are silently skipped — matching the template behavior
|
||||
for conditional buttons (e.g., end-session only when session is active).
|
||||
"""
|
||||
buttons = buttons or []
|
||||
children: list[SafeText] = []
|
||||
for btn in buttons:
|
||||
if not btn or not btn.get("slot"):
|
||||
continue
|
||||
children.append(
|
||||
_button_group_button(
|
||||
href=btn.get("href", "#"),
|
||||
slot=btn["slot"],
|
||||
color=btn.get("color", "gray"),
|
||||
title=btn.get("title", ""),
|
||||
hx_get=btn.get("hx_get", ""),
|
||||
hx_target=btn.get("hx_target", ""),
|
||||
)
|
||||
)
|
||||
|
||||
return Component(
|
||||
tag_name="div",
|
||||
attributes=[("class", "inline-flex rounded-md shadow-xs"), ("role", "group")],
|
||||
children=children,
|
||||
)
|
||||
|
||||
|
||||
def Div(
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
attributes = attributes or []
|
||||
children = children or []
|
||||
return Component(tag_name="div", attributes=attributes, children=children)
|
||||
|
||||
|
||||
def Input(
|
||||
type: str = "text",
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
attributes = attributes or []
|
||||
children = children or []
|
||||
return Component(
|
||||
tag_name="input", attributes=attributes + [("type", type)], children=children
|
||||
)
|
||||
|
||||
|
||||
def 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 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
|
||||
# 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
|
||||
# strings byte-for-byte so Tailwind generates them and server/JS pills match.
|
||||
_PILL_CLASS = (
|
||||
"inline-flex items-center gap-1 px-2 py-0.5 text-sm rounded "
|
||||
"bg-brand/15 text-heading"
|
||||
)
|
||||
_PILL_REMOVE_CLASS = "ml-1 text-body hover:text-heading font-bold cursor-pointer"
|
||||
|
||||
|
||||
def Pill(
|
||||
label: str,
|
||||
*,
|
||||
value: str = "",
|
||||
removable: bool = False,
|
||||
extra_class: str = "",
|
||||
label_slot: bool = False,
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
) -> SafeText:
|
||||
"""A small label pill, optionally removable (× button).
|
||||
|
||||
Styling is inline Tailwind utilities; ``data-pill`` / ``data-pill-remove``
|
||||
are JS hooks only (no CSS attached). ``value`` (when set) becomes
|
||||
``data-value``; extra ``attributes`` are appended to the outer span.
|
||||
|
||||
``label_slot=True`` wraps the label in a ``<span data-search-select-label>`` so JS can
|
||||
fill it when cloning the pill from a server-rendered ``<template>`` (keeps the
|
||||
markup single-sourced — see ``search_select.py``).
|
||||
"""
|
||||
attributes = attributes or []
|
||||
pill_class = f"{_PILL_CLASS} {extra_class}".strip()
|
||||
pill_attrs: list[HTMLAttribute] = [("class", pill_class), ("data-pill", "")]
|
||||
if value != "":
|
||||
pill_attrs.append(("data-value", str(value)))
|
||||
pill_attrs.extend(attributes)
|
||||
|
||||
label_child: HTMLTag = (
|
||||
Span(attributes=[("data-search-select-label", "")], children=[label])
|
||||
if label_slot
|
||||
else label
|
||||
)
|
||||
children: list[HTMLTag] = [label_child]
|
||||
if removable:
|
||||
children.append(
|
||||
Component(
|
||||
tag_name="button",
|
||||
attributes=[
|
||||
("type", "button"),
|
||||
("data-pill-remove", ""),
|
||||
("class", _PILL_REMOVE_CLASS),
|
||||
("aria-label", "Remove"),
|
||||
],
|
||||
children=["×"],
|
||||
)
|
||||
)
|
||||
|
||||
return Span(attributes=pill_attrs, children=children)
|
||||
|
||||
|
||||
def CsrfInput(request) -> SafeText:
|
||||
"""Hidden CSRF input, equivalent to the `{% csrf_token %}` template tag."""
|
||||
return mark_safe(
|
||||
f'<input type="hidden" name="csrfmiddlewaretoken" value="{get_token(request)}">'
|
||||
)
|
||||
|
||||
|
||||
def ModuleScript(filename: str) -> SafeText:
|
||||
"""A `<script type="module">` tag pointing at a static JS file."""
|
||||
return mark_safe(
|
||||
f'<script type="module" src="{static("js/" + filename)}"></script>'
|
||||
)
|
||||
|
||||
|
||||
def ExternalScript(url: str) -> SafeText:
|
||||
"""A plain `<script src=...>` tag for an external/CDN script."""
|
||||
return mark_safe(f'<script src="{url}"></script>')
|
||||
|
||||
|
||||
def YearPicker(
|
||||
year: int | None = None,
|
||||
available_years: tuple[int, ...] = (),
|
||||
url_template: str = "",
|
||||
) -> SafeText:
|
||||
"""A Flowbite-datepicker year picker.
|
||||
|
||||
`year` is the selected year, or ``None`` for the all-time view (the empty
|
||||
state). `available_years` are the years to enable in the popup grid.
|
||||
`url_template` is a navigation URL containing the literal ``__year__``
|
||||
placeholder, substituted with the chosen year in JS (keeps this component
|
||||
decoupled from the project's URL names).
|
||||
|
||||
The Flowbite-datepicker UMD bundle is *not* loaded here — the view hoists it
|
||||
via ``render_page(scripts=...)``.
|
||||
"""
|
||||
label = str(year) if year is not None else "Choose a year"
|
||||
selected = str(year) if year is not None else ""
|
||||
classes = (
|
||||
"bg-brand text-white border-transparent hover:bg-brand-strong"
|
||||
if year is not None
|
||||
else "bg-neutral-secondary-medium text-heading border border-default-medium "
|
||||
"hover:bg-neutral-tertiary-medium focus:ring-4 focus:ring-brand-medium"
|
||||
)
|
||||
years_csv = ",".join(str(y) for y in available_years)
|
||||
return mark_safe(f"""<div class="relative inline-block" x-data="{{ pickerOpen: false }}"
|
||||
@keydown.escape.window="pickerOpen = false">
|
||||
<button type="button"
|
||||
x-on:click="pickerOpen = !pickerOpen; $refs.pickerInput._pickerInstance && ($refs.pickerInput._pickerInstance.active ? $refs.pickerInput._pickerInstance.hide() : $refs.pickerInput._pickerInstance.show())"
|
||||
class="inline-flex items-center rounded-base px-4 py-2 text-sm font-medium {classes}">
|
||||
{label}
|
||||
<svg class="w-4 h-4 ms-2 rtl:rotate-180" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 10">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 5h12m0 0L9 1m4 4L9 9"/>
|
||||
</svg>
|
||||
</button>
|
||||
<input type="text" x-ref="pickerInput" id="year-picker-input"
|
||||
class="absolute opacity-0 pointer-events-none"
|
||||
style="width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); border: 0;"
|
||||
data-available-years="{years_csv}"
|
||||
data-selected-year="{selected}"
|
||||
data-url-template="{url_template}">
|
||||
</div>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {{
|
||||
const pickerEl = document.getElementById('year-picker-input');
|
||||
if (!pickerEl || pickerEl._pickerInstance) return;
|
||||
|
||||
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', (e) => {{
|
||||
const year = e.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();
|
||||
}}
|
||||
}});
|
||||
</script>""")
|
||||
|
||||
|
||||
def AddForm(
|
||||
form,
|
||||
*,
|
||||
request,
|
||||
fields: SafeText | str | None = None,
|
||||
additional_row: SafeText | str = "",
|
||||
submit_class: str = "mt-3",
|
||||
) -> SafeText:
|
||||
"""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
|
||||
session form, which lays out its fields manually). `additional_row` holds
|
||||
extra submit buttons rendered below the main Submit button. `submit_class`
|
||||
is applied to the main Submit button (the session form passes "" to match
|
||||
its original markup).
|
||||
"""
|
||||
field_markup = fields if fields is not None else mark_safe(form.as_div())
|
||||
submit_attrs = [("class", submit_class)] if submit_class else []
|
||||
|
||||
inner_form = Component(
|
||||
tag_name="form",
|
||||
attributes=[("method", "post"), ("enctype", "multipart/form-data")],
|
||||
children=[
|
||||
CsrfInput(request),
|
||||
field_markup,
|
||||
Div(children=[Button(submit_attrs, "Submit", type="submit")]),
|
||||
Div(
|
||||
[("class", "submit-button-container")],
|
||||
[additional_row] if additional_row else [],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
return Div(
|
||||
[("id", "add-form"), ("class", "max-width-container")],
|
||||
[
|
||||
Div(
|
||||
[("id", "add-form"), ("class", "form-container max-w-xl mx-auto")],
|
||||
[inner_form],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def SearchField(
|
||||
search_string: str = "",
|
||||
id: str = "search_string",
|
||||
placeholder: str = "Search",
|
||||
) -> SafeText:
|
||||
"""Generate a search form with icon, input field, and submit button."""
|
||||
return Component(
|
||||
tag_name="form",
|
||||
attributes=[("class", "max-w-md")],
|
||||
children=[
|
||||
Label(
|
||||
attributes=[
|
||||
("for", "search"),
|
||||
("class", "block mb-2.5 text-sm font-medium text-heading sr-only"),
|
||||
],
|
||||
children=["Search"],
|
||||
),
|
||||
Component(
|
||||
tag_name="div",
|
||||
attributes=[("class", "relative")],
|
||||
children=[
|
||||
mark_safe(
|
||||
'<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" '
|
||||
'fill="none" viewBox="0 0 24 24">'
|
||||
'<path stroke="currentColor" stroke-linecap="round" stroke-width="2" '
|
||||
'd="m21 21-3.5-3.5M17 10a7 7 0 1 1-14 0 7 7 0 0 1 14 0Z"/>'
|
||||
"</svg></div>"
|
||||
),
|
||||
Component(
|
||||
tag_name="input",
|
||||
attributes=[
|
||||
("type", "search"),
|
||||
("id", id),
|
||||
("name", id),
|
||||
("value", search_string),
|
||||
(
|
||||
"class",
|
||||
"block w-full p-3 ps-9 bg-neutral-secondary-medium "
|
||||
"border border-default-medium text-heading text-sm "
|
||||
"rounded-base focus:ring-brand focus:border-brand "
|
||||
"shadow-xs placeholder:text-body",
|
||||
),
|
||||
("placeholder", placeholder),
|
||||
("required", ""),
|
||||
],
|
||||
),
|
||||
Component(
|
||||
tag_name="button",
|
||||
attributes=[
|
||||
("type", "submit"),
|
||||
(
|
||||
"class",
|
||||
"absolute end-1.5 bottom-1.5 text-white bg-brand "
|
||||
"hover:bg-brand-strong box-border border border-transparent "
|
||||
"focus:ring-4 focus:ring-brand-medium shadow-xs font-medium "
|
||||
"leading-5 rounded text-xs px-3 py-1.5 focus:outline-none "
|
||||
"cursor-pointer",
|
||||
),
|
||||
],
|
||||
children=["Search"],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def H1(
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
badge: str = "",
|
||||
) -> SafeText:
|
||||
"""Heading with optional badge count."""
|
||||
children = children or []
|
||||
heading_class = "mb-4 text-3xl font-extrabold leading-none tracking-tight text-gray-900 dark:text-white"
|
||||
badge_html = ""
|
||||
|
||||
if badge:
|
||||
heading_class = "flex items-center " + heading_class
|
||||
badge_html = Span(
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
"bg-blue-100 text-blue-800 text-2xl font-semibold me-2 "
|
||||
"px-2.5 py-0.5 rounded-sm dark:bg-blue-200 dark:text-blue-800 ms-2",
|
||||
),
|
||||
],
|
||||
children=[badge],
|
||||
)
|
||||
|
||||
return Component(
|
||||
tag_name="h1",
|
||||
attributes=[("class", heading_class)],
|
||||
children=(children if isinstance(children, list) else [children])
|
||||
+ ([badge_html] if badge_html else []),
|
||||
)
|
||||
|
||||
|
||||
def Modal(
|
||||
modal_id: str,
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
"""Modal overlay with container. Content (form, buttons) goes in children."""
|
||||
children = children or []
|
||||
outer = Component(
|
||||
tag_name="div",
|
||||
attributes=[
|
||||
("id", modal_id),
|
||||
(
|
||||
"class",
|
||||
"fixed inset-0 bg-black/70 dark:bg-gray-600/50 overflow-y-auto "
|
||||
"h-full w-full flex items-center justify-center",
|
||||
),
|
||||
],
|
||||
children=[
|
||||
Component(
|
||||
tag_name="div",
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
"relative mx-auto p-5 border-accent border w-full max-w-md "
|
||||
"shadow-lg/50 rounded-md bg-white dark:bg-gray-900",
|
||||
),
|
||||
],
|
||||
children=(children if isinstance(children, list) else [children]),
|
||||
),
|
||||
],
|
||||
)
|
||||
return mark_safe(str(outer))
|
||||
|
||||
|
||||
def TableTd(
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
"""Styled table cell."""
|
||||
children = children or []
|
||||
return Component(
|
||||
tag_name="td",
|
||||
attributes=[("class", "px-6 py-4 min-w-20-char max-w-20-char")],
|
||||
children=children if isinstance(children, list) else [children],
|
||||
)
|
||||
|
||||
|
||||
def TableRow(data: dict | list | None = None) -> SafeText:
|
||||
"""Generate a <tr> from a row data dict or list.
|
||||
|
||||
Dict form: {"row_id": "...", "cell_data": [...], "hx_trigger": ..., ...}
|
||||
- first cell is <th>, rest <td>.
|
||||
List form: [...] — all cells are <td>.
|
||||
"""
|
||||
if data is None:
|
||||
data = {}
|
||||
if isinstance(data, dict):
|
||||
row_id = data.get("row_id", "")
|
||||
cells = data.get("cell_data", [])
|
||||
else:
|
||||
row_id = ""
|
||||
cells = data
|
||||
|
||||
tr_class = (
|
||||
"odd:bg-white dark:odd:bg-gray-900 even:bg-gray-50 "
|
||||
"dark:even:bg-gray-800 dark:border-gray-700 hover:bg-gray-50 "
|
||||
"dark:hover:bg-gray-600 [&_a]:underline [&_a]:underline-offset-4 "
|
||||
"[&_a]:decoration-2 [&_td:last-child]:text-right"
|
||||
)
|
||||
tr_attrs: list[HTMLAttribute] = [("class", tr_class)]
|
||||
if row_id:
|
||||
tr_attrs.append(("id", row_id))
|
||||
if isinstance(data, dict):
|
||||
if data.get("hx_trigger"):
|
||||
tr_attrs.append(("hx-trigger", data["hx_trigger"]))
|
||||
if data.get("hx_get"):
|
||||
tr_attrs.append(("hx-get", data["hx_get"]))
|
||||
if data.get("hx_select"):
|
||||
tr_attrs.append(("hx-select", data["hx_select"]))
|
||||
if data.get("hx_swap"):
|
||||
tr_attrs.append(("hx-swap", data["hx_swap"]))
|
||||
|
||||
cell_elements: list[SafeText] = []
|
||||
for i, cell in enumerate(cells):
|
||||
if i == 0:
|
||||
cell_elements.append(
|
||||
Component(
|
||||
tag_name="th",
|
||||
attributes=[
|
||||
("scope", "row"),
|
||||
(
|
||||
"class",
|
||||
"px-6 py-4 font-medium text-gray-900 "
|
||||
"whitespace-nowrap dark:text-white",
|
||||
),
|
||||
],
|
||||
children=[cell],
|
||||
)
|
||||
)
|
||||
else:
|
||||
cell_elements.append(TableTd(children=[cell]))
|
||||
|
||||
return Component(tag_name="tr", attributes=tr_attrs, children=cell_elements)
|
||||
|
||||
|
||||
def Icon(
|
||||
name: str,
|
||||
attributes: list[HTMLAttribute] | None = None,
|
||||
) -> SafeText:
|
||||
return mark_safe(get_icon(name))
|
||||
|
||||
|
||||
def TableHeader(
|
||||
children: list[HTMLTag] | HTMLTag | None = None,
|
||||
) -> SafeText:
|
||||
"""Table caption."""
|
||||
children = children or []
|
||||
return Component(
|
||||
tag_name="caption",
|
||||
attributes=[
|
||||
(
|
||||
"class",
|
||||
"p-2 text-lg font-semibold rtl:text-left text-right "
|
||||
"text-gray-900 bg-white dark:text-white dark:bg-gray-900",
|
||||
),
|
||||
],
|
||||
children=children if isinstance(children, list) else [children],
|
||||
)
|
||||
|
||||
|
||||
def _page_url(request, page) -> str:
|
||||
"""Current querystring with `page` replaced (mirrors {% param_replace %})."""
|
||||
if request is None:
|
||||
return f"?page={page}"
|
||||
params = request.GET.copy()
|
||||
params["page"] = page
|
||||
return "?" + params.urlencode()
|
||||
|
||||
|
||||
def _pagination_nav(page_obj, elided_page_range, request) -> str:
|
||||
pages_html = ""
|
||||
for page in elided_page_range:
|
||||
if page != page_obj.number:
|
||||
pages_html += (
|
||||
f'<li><a href="{_page_url(request, page)}" '
|
||||
'class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 '
|
||||
"bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700 "
|
||||
"dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 "
|
||||
f'dark:hover:text-white">{conditional_escape(page)}</a></li>'
|
||||
)
|
||||
else:
|
||||
pages_html += (
|
||||
'<li><a aria-current="page" '
|
||||
'class="cursor-not-allowed flex items-center justify-center px-3 h-8 leading-tight '
|
||||
"text-white border bg-gray-400 border-gray-300 dark:bg-gray-900 dark:border-gray-700 "
|
||||
f'dark:text-gray-200">{conditional_escape(page)}</a></li>'
|
||||
)
|
||||
|
||||
if page_obj.has_previous():
|
||||
prev_html = (
|
||||
f'<a href="{_page_url(request, page_obj.previous_page_number())}" '
|
||||
'class="flex items-center justify-center px-3 h-8 ms-0 leading-tight text-gray-500 '
|
||||
"bg-white border border-gray-300 rounded-s-lg hover:bg-gray-100 hover:text-gray-700 "
|
||||
"dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 "
|
||||
'dark:hover:text-white">Previous</a>'
|
||||
)
|
||||
else:
|
||||
prev_html = (
|
||||
'<a aria-current="page" class="cursor-not-allowed flex items-center justify-center '
|
||||
"px-3 h-8 leading-tight text-gray-300 bg-white border border-gray-300 rounded-s-lg "
|
||||
'dark:bg-gray-800 dark:border-gray-700 dark:text-gray-600">Previous</a>'
|
||||
)
|
||||
|
||||
if page_obj.has_next():
|
||||
next_html = (
|
||||
f'<a href="{_page_url(request, page_obj.next_page_number())}" '
|
||||
'class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 '
|
||||
"bg-white border border-gray-300 rounded-e-lg hover:bg-gray-100 hover:text-gray-700 "
|
||||
"dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 "
|
||||
'dark:hover:text-white">Next</a>'
|
||||
)
|
||||
else:
|
||||
next_html = (
|
||||
'<a aria-current="page" class="cursor-not-allowed flex items-center justify-center '
|
||||
"px-3 h-8 leading-tight text-gray-300 bg-white border border-gray-300 rounded-e-lg "
|
||||
'dark:bg-gray-800 dark:border-gray-700 dark:text-gray-600">Next</a>'
|
||||
)
|
||||
|
||||
return (
|
||||
'<nav class="flex items-center flex-col md:flex-row md:justify-between px-6 py-4 '
|
||||
'dark:bg-gray-900 sm:rounded-b-lg" aria-label="Table navigation">'
|
||||
'<span class="text-sm text-center font-normal text-gray-500 dark:text-gray-400 mb-4 '
|
||||
'md:mb-0 block w-full md:inline md:w-auto">'
|
||||
f'<span class="font-semibold text-gray-900 dark:text-white">{page_obj.start_index()}</span>—'
|
||||
f'<span class="font-semibold text-gray-900 dark:text-white">{page_obj.end_index()}</span> of '
|
||||
f'<span class="font-semibold text-gray-900 dark:text-white">{page_obj.paginator.count}</span></span>'
|
||||
'<ul class="inline-flex -space-x-px rtl:space-x-reverse text-sm h-8"><li>'
|
||||
f"{prev_html}{pages_html}{next_html}"
|
||||
"</li></ul></nav>"
|
||||
)
|
||||
|
||||
|
||||
def SimpleTable(
|
||||
columns: list[str] | None = None,
|
||||
rows: list | None = None,
|
||||
header_action: SafeText | str | None = None,
|
||||
page_obj=None,
|
||||
elided_page_range=None,
|
||||
request=None,
|
||||
) -> SafeText:
|
||||
"""Paginated table. Python equivalent of the old simple_table.html."""
|
||||
columns = columns or []
|
||||
rows = rows or []
|
||||
|
||||
header_html = ""
|
||||
if header_action:
|
||||
header_html = str(TableHeader(children=[header_action]))
|
||||
|
||||
columns_html = "".join(
|
||||
f'<th scope="col" class="px-6 py-3">{conditional_escape(col)}</th>'
|
||||
for col in columns
|
||||
)
|
||||
rows_html = "".join(str(TableRow(data=row)) for row in rows)
|
||||
|
||||
pagination_html = ""
|
||||
if page_obj and elided_page_range:
|
||||
pagination_html = _pagination_nav(page_obj, elided_page_range, request)
|
||||
|
||||
return mark_safe(
|
||||
'<div class="shadow-md" hx-boost="false">'
|
||||
'<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">'
|
||||
f"{header_html}"
|
||||
'<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 '
|
||||
'dark:text-gray-400 max-sm:[&_th:not(:first-child):not(:last-child)]:hidden">'
|
||||
f"<tr>{columns_html}</tr></thead>"
|
||||
'<tbody class="dark:divide-y max-sm:[&_td:not(:first-child):not(:last-child)]:hidden">'
|
||||
f"{rows_html}</tbody></table></div>"
|
||||
f"{pagination_html}</div>"
|
||||
)
|
||||
|
||||
|
||||
def paginated_table_content(
|
||||
data: dict,
|
||||
*,
|
||||
page_obj=None,
|
||||
elided_page_range=None,
|
||||
request=None,
|
||||
) -> SafeText:
|
||||
"""Standard list-page body: a max-width Div wrapping a SimpleTable.
|
||||
|
||||
`data` is the table dict with keys ``columns``, ``rows`` and
|
||||
``header_action`` (the same shape every list view already builds).
|
||||
"""
|
||||
return Div(
|
||||
[
|
||||
(
|
||||
"class",
|
||||
"2xl:max-w-(--breakpoint-2xl) xl:max-w-(--breakpoint-xl) "
|
||||
"md:max-w-(--breakpoint-md) sm:max-w-(--breakpoint-sm) self-center",
|
||||
)
|
||||
],
|
||||
[
|
||||
SimpleTable(
|
||||
columns=data["columns"],
|
||||
rows=data["rows"],
|
||||
header_action=data["header_action"],
|
||||
page_obj=page_obj,
|
||||
elided_page_range=elided_page_range,
|
||||
request=request,
|
||||
)
|
||||
],
|
||||
)
|
||||
@@ -1,566 +0,0 @@
|
||||
"""Search field + dropdown select component (pure Python, domain-agnostic).
|
||||
|
||||
Pairs a search box with a dropdown of options. Supports single/multi select;
|
||||
in multi-select, chosen items render as removable ``Pill``s, each backed by a
|
||||
hidden ``<input>`` so an existing ``ModelMultipleChoiceField`` keeps validating.
|
||||
|
||||
This module imports only from ``common.components`` — it has no Django-forms or
|
||||
``games`` knowledge. Styling is inline Tailwind utilities; behavioural hooks are
|
||||
``data-*`` attributes wired up by ``games/static/js/search_select.js``.
|
||||
|
||||
Option sourcing follows two axes. *Population*: options are either rendered
|
||||
inline up front (``options=``, no ``search_url``) or fetched from ``search_url``.
|
||||
*Completeness*: without a ``search_url`` the inline set is the whole dataset and
|
||||
filtering is purely client-side; with a ``search_url`` the loaded rows are a
|
||||
window, so the JS filters the loaded rows instantly on each keystroke while
|
||||
issuing a debounced server request for the rest. ``prefetch`` (rows to load on
|
||||
first open, ``0`` = none) seeds that window so the panel is populated before the
|
||||
user types.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable, Iterable
|
||||
from typing import TypedDict
|
||||
|
||||
from django.utils.safestring import SafeText
|
||||
|
||||
from common.components.core import Component, HTMLAttribute
|
||||
from common.components.primitives import Div, Input, Pill, Span, Template
|
||||
|
||||
|
||||
class SearchSelectOption(TypedDict):
|
||||
value: str | int
|
||||
label: str
|
||||
data: dict[str, str] # becomes data-* attrs on the row / pill
|
||||
|
||||
|
||||
# A lightweight (value, label) pair used wherever only those two fields are
|
||||
# needed — e.g. filter pill lists and modifier pseudo-options. The richer
|
||||
# SearchSelectOption adds a ``data`` dict for extra row attributes.
|
||||
LabeledOption = tuple[str, str]
|
||||
|
||||
|
||||
# The pills and the search box share one flex-wrap row (with padding) so the
|
||||
# widget reads as a single clickable field; the pills wrapper uses `contents`
|
||||
# so its pills/hidden inputs flow as direct participants of that row, inline
|
||||
# with the search input. The options panel is absolute, so it sits outside the
|
||||
# flex flow. (border omitted intentionally — see if it's needed later.)
|
||||
_CONTAINER_CLASS = (
|
||||
"relative flex flex-wrap items-center gap-1 p-2 "
|
||||
"rounded-base bg-neutral-secondary-medium"
|
||||
)
|
||||
_PILLS_CLASS = "contents"
|
||||
_SEARCH_CLASS = (
|
||||
"flex-1 min-w-[8rem] border-0 bg-transparent text-sm text-heading "
|
||||
"focus:ring-0 focus:outline-hidden placeholder:text-body"
|
||||
)
|
||||
# top-full anchors the panel to the container's bottom edge: as an absolutely
|
||||
# positioned child of the flex field, its static position would otherwise be
|
||||
# centered by items-center and overlap the search box.
|
||||
_OPTIONS_CLASS = (
|
||||
"absolute z-10 top-full left-0 right-0 mt-1 overflow-y-auto "
|
||||
"border border-default-medium rounded-base bg-neutral-secondary-medium shadow-lg"
|
||||
)
|
||||
_OPTION_ROW_CLASS = (
|
||||
"px-3 py-2 text-sm text-heading cursor-pointer "
|
||||
"hover:bg-brand/15 data-[search-select-highlighted]:bg-brand/15"
|
||||
)
|
||||
_NO_RESULTS_CLASS = "px-3 py-2 text-sm italic text-body hidden"
|
||||
|
||||
# Approximate rendered height of one option row (px-3 py-2 text-sm) in rem,
|
||||
# used to derive the panel's max-height from items_visible.
|
||||
_ROW_HEIGHT_REM = 2.25
|
||||
|
||||
# Default number of rows to fetch on first focus when a search_url is set.
|
||||
# Shared by filter and form widgets so the dropdown is populated for keyboard
|
||||
# navigation as soon as the user opens it.
|
||||
DEFAULT_PREFETCH = 20
|
||||
|
||||
# ── FilterSelect styling ───────────────────────────────────────────────────
|
||||
# Inline class strings (ported verbatim from the retired SelectableFilter CSS)
|
||||
# so the filter combobox is fully self-styled — nothing in input.css. JS-added
|
||||
# rows/pills are cloned from server-rendered <template>s, so these strings live
|
||||
# only here — never duplicated in search_select.js. The keyboard-highlighted
|
||||
# state is expressed via Tailwind `data-[search-select-highlighted]` and
|
||||
# `group-data-[search-select-highlighted]` variants on the row/label/button
|
||||
# classes below; the JS only toggles the data attribute on the row.
|
||||
_FILTER_INCLUDE_PILL_CLASS = (
|
||||
"inline-flex items-center gap-1 px-2 py-0.5 text-sm rounded "
|
||||
"bg-brand/15 text-heading"
|
||||
)
|
||||
_FILTER_EXCLUDE_PILL_CLASS = (
|
||||
"inline-flex items-center gap-1 px-2 py-0.5 text-sm rounded "
|
||||
"bg-red-500/15 text-red-600 line-through decoration-red-400"
|
||||
)
|
||||
_FILTER_MODIFIER_PILL_CLASS = (
|
||||
"inline-flex items-center px-2 py-0.5 text-sm rounded "
|
||||
"bg-amber-500/15 text-amber-600 cursor-pointer"
|
||||
)
|
||||
_FILTER_PILL_REMOVE_CLASS = "ml-1 text-body hover:text-heading font-bold cursor-pointer"
|
||||
_FILTER_OPTION_ROW_CLASS = (
|
||||
"group flex items-center justify-between px-2 py-1 rounded text-sm "
|
||||
"hover:bg-neutral-secondary-strong cursor-pointer "
|
||||
"data-[search-select-highlighted]:bg-brand "
|
||||
"data-[search-select-highlighted]:outline data-[search-select-highlighted]:outline-1 "
|
||||
"data-[search-select-highlighted]:outline-brand-strong"
|
||||
)
|
||||
_FILTER_OPTION_LABEL_CLASS = (
|
||||
"truncate text-body group-data-[search-select-highlighted]:text-white"
|
||||
)
|
||||
_FILTER_OPTION_BUTTONS_CLASS = "flex gap-1 ml-2 shrink-0"
|
||||
# text-body keeps the +/− readable on dark backgrounds; hover:border-brand-strong
|
||||
# keeps the edge visible against the brand hover fill. When the row is the
|
||||
# keyboard-highlighted one its bg is brand, so the button text/border switch
|
||||
# to white and the hover fill shifts to brand-strong for contrast.
|
||||
_FILTER_ACTION_BUTTON_CLASS = (
|
||||
"w-5 h-5 flex items-center justify-center text-xs font-bold rounded text-body "
|
||||
"border border-brand "
|
||||
"hover:bg-brand hover:text-white hover:border-brand-strong "
|
||||
"group-data-[search-select-highlighted]:text-white "
|
||||
"group-data-[search-select-highlighted]:border-white "
|
||||
"group-data-[search-select-highlighted]:hover:bg-brand-strong "
|
||||
"group-data-[search-select-highlighted]:hover:border-white"
|
||||
)
|
||||
_FILTER_MODIFIER_ROW_CLASS = (
|
||||
"px-2 py-1 text-sm text-body hover:bg-neutral-secondary-strong cursor-pointer"
|
||||
)
|
||||
|
||||
|
||||
def _normalize_option(option) -> SearchSelectOption:
|
||||
"""Coerce a dict option or a ``(value, label)`` tuple into the TypedDict."""
|
||||
if isinstance(option, dict):
|
||||
return {
|
||||
"value": option["value"],
|
||||
"label": option["label"],
|
||||
"data": option.get("data") or {},
|
||||
}
|
||||
value, label = option
|
||||
return {"value": value, "label": label, "data": {}}
|
||||
|
||||
|
||||
def _data_attributes(data: dict[str, str]) -> list[HTMLAttribute]:
|
||||
return [(f"data-{key}", str(value)) for key, value in data.items()]
|
||||
|
||||
|
||||
def _hidden_input(name: str, value) -> SafeText:
|
||||
return Input(type="hidden", attributes=[("name", name), ("value", str(value))])
|
||||
|
||||
|
||||
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
|
||||
one node when cloning the shape from a ``<template>``, so labels are the only
|
||||
thing the JS sets — all classes and structure stay server-side."""
|
||||
attributes: list[HTMLAttribute] = [("data-search-select-label", "")]
|
||||
if extra_class:
|
||||
attributes.append(("class", extra_class))
|
||||
return Span(attributes=attributes, children=[text])
|
||||
|
||||
|
||||
# A placeholder option for rendering template prototypes (JS overwrites it).
|
||||
_BLANK_OPTION: SearchSelectOption = {"value": "", "label": "", "data": {}}
|
||||
|
||||
|
||||
def _option_row(option: SearchSelectOption) -> SafeText:
|
||||
return Div(
|
||||
attributes=[
|
||||
("data-search-select-option", ""),
|
||||
("data-value", str(option["value"])),
|
||||
("data-label", option["label"]),
|
||||
("class", _OPTION_ROW_CLASS),
|
||||
*_data_attributes(option["data"]),
|
||||
],
|
||||
children=[_label_slot(option["label"])],
|
||||
)
|
||||
|
||||
|
||||
def _combobox_shell(
|
||||
*,
|
||||
container_attributes: list[HTMLAttribute],
|
||||
pills: SafeText,
|
||||
search_attributes: list[HTMLAttribute],
|
||||
options_children: list[SafeText],
|
||||
always_visible: bool,
|
||||
items_visible: int,
|
||||
templates: list[SafeText] | None = None,
|
||||
) -> SafeText:
|
||||
"""Assemble the shared, domain-agnostic combobox skeleton.
|
||||
|
||||
Every combobox built on top of this shell has the same three regions in the
|
||||
same order: the ``pills`` region, the search box, and the options panel (which
|
||||
always carries a trailing no-results node). Callers supply the already-built
|
||||
``pills`` region, the ``search_attributes`` for the text box, the
|
||||
``options_children`` (value rows plus any pinned pseudo-options), the
|
||||
``container_attributes`` that carry the widget's identity and behaviour flags,
|
||||
and any ``templates`` (inert ``<template>`` prototypes the JS clones for
|
||||
dynamically-added rows/pills). The shell knows nothing about how individual
|
||||
rows or pills look.
|
||||
"""
|
||||
search = Input(attributes=search_attributes)
|
||||
|
||||
no_results = Div(
|
||||
attributes=[
|
||||
("data-search-select-no-results", ""),
|
||||
("class", _NO_RESULTS_CLASS),
|
||||
],
|
||||
children=["No results"],
|
||||
)
|
||||
options_class = _OPTIONS_CLASS if always_visible else _OPTIONS_CLASS + " hidden"
|
||||
options_panel = Div(
|
||||
attributes=[
|
||||
("data-search-select-options", ""),
|
||||
("style", f"max-height: {items_visible * _ROW_HEIGHT_REM:.2f}rem"),
|
||||
("class", options_class),
|
||||
],
|
||||
children=[*options_children, no_results],
|
||||
)
|
||||
|
||||
children: list[SafeText] = [pills, search, options_panel, *(templates or [])]
|
||||
return Div(attributes=container_attributes, children=children)
|
||||
|
||||
|
||||
def SearchSelect(
|
||||
*,
|
||||
name: str,
|
||||
selected: list[SearchSelectOption] | None = None,
|
||||
options: list[SearchSelectOption] | None = None,
|
||||
search_url: str = "",
|
||||
multi_select: bool = False,
|
||||
always_visible: bool = False,
|
||||
items_visible: int = 5,
|
||||
items_scroll: int = 10,
|
||||
prefetch: int = 0,
|
||||
placeholder: str = "Search…",
|
||||
id: str = "",
|
||||
sync_url: bool = False,
|
||||
autofocus: bool = False,
|
||||
) -> SafeText:
|
||||
"""Render the search-select widget. See module docstring for the contract."""
|
||||
selected = [_normalize_option(option) for option in (selected or [])]
|
||||
options = [_normalize_option(option) for option in (options or [])]
|
||||
|
||||
# ── Pills + their hidden inputs (the submitted channel) ──
|
||||
# Multi-select renders a removable Pill per value; single-select renders no
|
||||
# pill — the committed label shows inside the search box instead, with a
|
||||
# lone hidden input carrying the value. Both keep the hidden input(s) inside
|
||||
# `[data-search-select-pills]` so the JS reads/writes values uniformly.
|
||||
pills_children: list[SafeText] = []
|
||||
search_value = ""
|
||||
if multi_select:
|
||||
for option in selected:
|
||||
pills_children.append(
|
||||
Pill(
|
||||
option["label"],
|
||||
value=str(option["value"]),
|
||||
removable=True,
|
||||
label_slot=True,
|
||||
attributes=_data_attributes(option["data"]),
|
||||
)
|
||||
)
|
||||
pills_children.append(_hidden_input(name, option["value"]))
|
||||
elif selected:
|
||||
option = selected[0]
|
||||
pills_children.append(_hidden_input(name, option["value"]))
|
||||
search_value = option["label"]
|
||||
|
||||
pills = Div(
|
||||
attributes=[("data-search-select-pills", ""), ("class", _PILLS_CLASS)],
|
||||
children=pills_children,
|
||||
)
|
||||
|
||||
# ── Search box (NO name — the query is never submitted) ──
|
||||
search_attrs: list[HTMLAttribute] = [
|
||||
("data-search-select-search", ""),
|
||||
("placeholder", placeholder),
|
||||
("autocomplete", "off"),
|
||||
("class", _SEARCH_CLASS),
|
||||
]
|
||||
if autofocus:
|
||||
search_attrs.append(("autofocus", ""))
|
||||
if search_value:
|
||||
search_attrs.append(("value", search_value))
|
||||
|
||||
# ── Options panel (pre-rendered only when there is no search_url) ──
|
||||
option_rows = [_option_row(option) for option in options] if not search_url else []
|
||||
|
||||
# ── Templates the JS clones: a row when results are fetched, a pill when
|
||||
# multi-select adds chosen items. ──
|
||||
templates: list[SafeText] = []
|
||||
if search_url:
|
||||
templates.append(
|
||||
Template(
|
||||
attributes=[("data-search-select-template", "row")],
|
||||
children=[_option_row(_BLANK_OPTION)],
|
||||
)
|
||||
)
|
||||
if multi_select:
|
||||
templates.append(
|
||||
Template(
|
||||
attributes=[("data-search-select-template", "pill")],
|
||||
children=[Pill("", value="", removable=True, label_slot=True)],
|
||||
)
|
||||
)
|
||||
|
||||
container_attributes: list[HTMLAttribute] = [
|
||||
("data-search-select", ""),
|
||||
("data-name", name),
|
||||
("data-search-url", search_url),
|
||||
("data-multi", "true" if multi_select else "false"),
|
||||
("data-always-visible", "true" if always_visible else "false"),
|
||||
("data-items-visible", str(items_visible)),
|
||||
("data-items-scroll", str(items_scroll)),
|
||||
("data-prefetch", str(prefetch)),
|
||||
("data-sync-url", "true" if sync_url else "false"),
|
||||
("class", _CONTAINER_CLASS),
|
||||
]
|
||||
if id:
|
||||
container_attributes.append(("id", id))
|
||||
|
||||
return _combobox_shell(
|
||||
container_attributes=container_attributes,
|
||||
pills=pills,
|
||||
search_attributes=search_attrs,
|
||||
options_children=option_rows,
|
||||
always_visible=always_visible,
|
||||
items_visible=items_visible,
|
||||
templates=templates,
|
||||
)
|
||||
|
||||
|
||||
def _filter_remove_button() -> SafeText:
|
||||
return Component(
|
||||
tag_name="button",
|
||||
attributes=[
|
||||
("type", "button"),
|
||||
("data-pill-remove", ""),
|
||||
("class", _FILTER_PILL_REMOVE_CLASS),
|
||||
("aria-label", "Remove"),
|
||||
],
|
||||
children=["×"],
|
||||
)
|
||||
|
||||
|
||||
def _filter_value_pill(option: SearchSelectOption, kind: str) -> SafeText:
|
||||
"""An include (✓) or exclude (✗) value pill. ``kind`` is "include"/"exclude"."""
|
||||
symbol = "✓" if kind == "include" else "✗"
|
||||
css = (
|
||||
_FILTER_INCLUDE_PILL_CLASS if kind == "include" else _FILTER_EXCLUDE_PILL_CLASS
|
||||
)
|
||||
return Span(
|
||||
attributes=[
|
||||
("class", css),
|
||||
("data-pill", ""),
|
||||
("data-value", str(option["value"])),
|
||||
("data-label", option["label"]),
|
||||
("data-search-select-type", kind),
|
||||
*_data_attributes(option["data"]),
|
||||
],
|
||||
children=[f"{symbol} ", _label_slot(option["label"]), _filter_remove_button()],
|
||||
)
|
||||
|
||||
|
||||
def _filter_modifier_pill(modifier_value: str, label: str) -> SafeText:
|
||||
"""The lone, sticky modifier pill (e.g. "(Any)"/"(None)")."""
|
||||
return Span(
|
||||
attributes=[
|
||||
("class", _FILTER_MODIFIER_PILL_CLASS),
|
||||
("data-pill", ""),
|
||||
("data-search-select-modifier", modifier_value),
|
||||
],
|
||||
children=[_label_slot(label), _filter_remove_button()],
|
||||
)
|
||||
|
||||
|
||||
def _filter_action_button(action: str, symbol: str, title: str) -> SafeText:
|
||||
return Component(
|
||||
tag_name="button",
|
||||
attributes=[
|
||||
("type", "button"),
|
||||
("data-search-select-action", action),
|
||||
("class", _FILTER_ACTION_BUTTON_CLASS),
|
||||
("title", title),
|
||||
],
|
||||
children=[symbol],
|
||||
)
|
||||
|
||||
|
||||
def _filter_option_row(value: str | int, label: str) -> SafeText:
|
||||
"""A value row with include (+) and exclude (−) buttons."""
|
||||
return Div(
|
||||
attributes=[
|
||||
("data-search-select-option", ""),
|
||||
("data-value", str(value)),
|
||||
("data-label", label),
|
||||
("class", _FILTER_OPTION_ROW_CLASS),
|
||||
],
|
||||
children=[
|
||||
_label_slot(label, extra_class=_FILTER_OPTION_LABEL_CLASS),
|
||||
Span(
|
||||
attributes=[("class", _FILTER_OPTION_BUTTONS_CLASS)],
|
||||
children=[
|
||||
_filter_action_button("include", "+", "Include"),
|
||||
_filter_action_button("exclude", "−", "Exclude"),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _filter_modifier_row(modifier_value: str, label: str) -> SafeText:
|
||||
"""A pinned pseudo-option row. It carries no ``data-search-select-option`` so the text
|
||||
filter never hides it — modifiers stay visible at the top of the panel."""
|
||||
return Div(
|
||||
attributes=[
|
||||
("data-search-select-modifier-option", modifier_value),
|
||||
("data-label", label),
|
||||
("class", _FILTER_MODIFIER_ROW_CLASS),
|
||||
],
|
||||
children=[label],
|
||||
)
|
||||
|
||||
|
||||
def FilterSelect(
|
||||
*,
|
||||
field_name: str,
|
||||
options: list[LabeledOption | SearchSelectOption] | None = None,
|
||||
included: list[LabeledOption | SearchSelectOption] | None = None,
|
||||
excluded: list[LabeledOption | SearchSelectOption] | None = None,
|
||||
modifier: str = "",
|
||||
modifier_options: list[LabeledOption] | None = None,
|
||||
search_url: str = "",
|
||||
prefetch: int = 0,
|
||||
items_visible: int = 6,
|
||||
items_scroll: int = 10,
|
||||
placeholder: str = "Search…",
|
||||
id: str = "",
|
||||
) -> SafeText:
|
||||
"""Include/exclude filter combobox built on the shared ``_combobox_shell``.
|
||||
|
||||
Like ``SearchSelect`` but each value row carries +/− buttons that add an
|
||||
*include* (✓) or *exclude* (✗) pill, plus an optional set of pinned
|
||||
``modifier_options`` (e.g. ``[("NOT_NULL", "(Any)"), ("IS_NULL", "(None)")]``)
|
||||
rendered above the value rows. Presence modifiers (NOT_NULL / IS_NULL) are
|
||||
mutually exclusive with value pills. Non-presence modifiers (INCLUDES_ALL /
|
||||
INCLUDES_ONLY) coexist with value pills — they govern how the include set
|
||||
matches and are only surfaced for many-to-many fields. State is read from
|
||||
the DOM into the filter JSON by ``readSearchSelect`` (filter mode) — nothing
|
||||
is submitted by ``name``.
|
||||
|
||||
``included``/``excluded`` are resolved options (value + label) so pills show
|
||||
labels even when the value rows come from ``search_url``. ``options``
|
||||
pre-renders the value rows for the complete-set (no ``search_url``) case.
|
||||
"""
|
||||
options = [_normalize_option(option) for option in (options or [])]
|
||||
included = [_normalize_option(option) for option in (included or [])]
|
||||
excluded = [_normalize_option(option) for option in (excluded or [])]
|
||||
modifier_options = modifier_options or []
|
||||
|
||||
active_modifier_label = ""
|
||||
for modifier_value, label in modifier_options:
|
||||
if modifier_value == modifier:
|
||||
active_modifier_label = label
|
||||
break
|
||||
|
||||
# ── Pills: modifier pill (if active), then include/exclude value pills ──
|
||||
# Presence modifiers (NOT_NULL / IS_NULL) are mutually exclusive with value
|
||||
# pills — but the stored state guarantees they never coexist, so we render
|
||||
# both channels unconditionally. Non-presence modifiers (INCLUDES_ALL /
|
||||
# INCLUDES_ONLY) coexist with value pills and render side by side.
|
||||
pills_children: list[SafeText] = []
|
||||
if active_modifier_label:
|
||||
pills_children.append(_filter_modifier_pill(modifier, active_modifier_label))
|
||||
for option in included:
|
||||
pills_children.append(_filter_value_pill(option, "include"))
|
||||
for option in excluded:
|
||||
pills_children.append(_filter_value_pill(option, "exclude"))
|
||||
|
||||
pills = Div(
|
||||
attributes=[("data-search-select-pills", ""), ("class", _PILLS_CLASS)],
|
||||
children=pills_children,
|
||||
)
|
||||
|
||||
# ── Search box (NO name — the query is never submitted) ──
|
||||
search_attributes: list[HTMLAttribute] = [
|
||||
("data-search-select-search", ""),
|
||||
("placeholder", placeholder),
|
||||
("autocomplete", "off"),
|
||||
("class", _SEARCH_CLASS),
|
||||
]
|
||||
|
||||
# ── Options: pinned modifier rows, then value rows (pre-rendered only when
|
||||
# there is no search_url; otherwise the JS fetches them) ──
|
||||
modifier_rows = [
|
||||
_filter_modifier_row(value, label) for value, label in modifier_options
|
||||
]
|
||||
value_rows = (
|
||||
[_filter_option_row(option["value"], option["label"]) for option in options]
|
||||
if not search_url
|
||||
else []
|
||||
)
|
||||
|
||||
# ── Templates the JS clones: include/exclude pills (added on click), the
|
||||
# modifier pill (when modifiers exist), and a value row (when fetched). ──
|
||||
templates: list[SafeText] = [
|
||||
Template(
|
||||
attributes=[("data-search-select-template", "pill-include")],
|
||||
children=[_filter_value_pill(_BLANK_OPTION, "include")],
|
||||
),
|
||||
Template(
|
||||
attributes=[("data-search-select-template", "pill-exclude")],
|
||||
children=[_filter_value_pill(_BLANK_OPTION, "exclude")],
|
||||
),
|
||||
]
|
||||
if modifier_options:
|
||||
templates.append(
|
||||
Template(
|
||||
attributes=[("data-search-select-template", "pill-modifier")],
|
||||
children=[_filter_modifier_pill("", "")],
|
||||
)
|
||||
)
|
||||
if search_url:
|
||||
templates.append(
|
||||
Template(
|
||||
attributes=[("data-search-select-template", "row")],
|
||||
children=[_filter_option_row("", "")],
|
||||
)
|
||||
)
|
||||
|
||||
container_attributes: list[HTMLAttribute] = [
|
||||
("data-search-select", ""),
|
||||
("data-search-select-mode", "filter"),
|
||||
("data-name", field_name),
|
||||
("data-search-url", search_url),
|
||||
("data-multi", "true"),
|
||||
("data-always-visible", "false"),
|
||||
("data-items-visible", str(items_visible)),
|
||||
("data-items-scroll", str(items_scroll)),
|
||||
("data-prefetch", str(prefetch)),
|
||||
("data-sync-url", "false"),
|
||||
("class", _CONTAINER_CLASS),
|
||||
]
|
||||
if modifier:
|
||||
container_attributes.append(("data-modifier", modifier))
|
||||
if id:
|
||||
container_attributes.append(("id", id))
|
||||
|
||||
return _combobox_shell(
|
||||
container_attributes=container_attributes,
|
||||
pills=pills,
|
||||
search_attributes=search_attributes,
|
||||
options_children=[*modifier_rows, *value_rows],
|
||||
always_visible=False,
|
||||
items_visible=items_visible,
|
||||
templates=templates,
|
||||
)
|
||||
|
||||
|
||||
def searchselect_selected(
|
||||
values: list,
|
||||
resolver: Callable[[list], Iterable[SearchSelectOption]],
|
||||
) -> list[SearchSelectOption]:
|
||||
"""Resolve ``values`` into ``SearchSelectOption``s via ``resolver``.
|
||||
|
||||
``resolver(values)`` should resolve ONLY the given ids (a ``pk__in`` query)
|
||||
— never iterating all choices, so it stays cheap.
|
||||
"""
|
||||
if not values:
|
||||
return []
|
||||
return [_normalize_option(option) for option in resolver(values)]
|
||||
@@ -1,512 +0,0 @@
|
||||
"""
|
||||
Typed criterion inputs for building structured filters.
|
||||
|
||||
Inspired by Stash's filter architecture: every filterable field uses a typed
|
||||
criterion with a value and a CriterionModifier. This separates *what* you're
|
||||
filtering from *how* you're comparing, and makes filter serialization trivial.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field, fields as dc_fields
|
||||
from enum import Enum
|
||||
from typing import Any, Self, TypeVar
|
||||
|
||||
from django.db.models import Q
|
||||
|
||||
# ── Modifier ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class Modifier(str, Enum):
|
||||
"""Comparison operators shared across all criterion types."""
|
||||
|
||||
EQUALS = "EQUALS"
|
||||
NOT_EQUALS = "NOT_EQUALS"
|
||||
GREATER_THAN = "GREATER_THAN"
|
||||
LESS_THAN = "LESS_THAN"
|
||||
BETWEEN = "BETWEEN"
|
||||
NOT_BETWEEN = "NOT_BETWEEN"
|
||||
INCLUDES = "INCLUDES"
|
||||
EXCLUDES = "EXCLUDES"
|
||||
INCLUDES_ALL = "INCLUDES_ALL"
|
||||
INCLUDES_ONLY = "INCLUDES_ONLY"
|
||||
IS_NULL = "IS_NULL"
|
||||
NOT_NULL = "NOT_NULL"
|
||||
MATCHES_REGEX = "MATCHES_REGEX"
|
||||
NOT_MATCHES_REGEX = "NOT_MATCHES_REGEX"
|
||||
|
||||
@classmethod
|
||||
def for_strings(cls) -> list[Self]:
|
||||
return [
|
||||
cls.EQUALS,
|
||||
cls.NOT_EQUALS,
|
||||
cls.INCLUDES,
|
||||
cls.EXCLUDES,
|
||||
cls.MATCHES_REGEX,
|
||||
cls.NOT_MATCHES_REGEX,
|
||||
cls.IS_NULL,
|
||||
cls.NOT_NULL,
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def for_numbers(cls) -> list[Self]:
|
||||
return [
|
||||
cls.EQUALS,
|
||||
cls.NOT_EQUALS,
|
||||
cls.GREATER_THAN,
|
||||
cls.LESS_THAN,
|
||||
cls.BETWEEN,
|
||||
cls.NOT_BETWEEN,
|
||||
cls.IS_NULL,
|
||||
cls.NOT_NULL,
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def for_dates(cls) -> list[Self]:
|
||||
return cls.for_numbers()
|
||||
|
||||
@classmethod
|
||||
def for_multi(cls) -> list[Self]:
|
||||
return [
|
||||
cls.INCLUDES,
|
||||
cls.EXCLUDES,
|
||||
cls.INCLUDES_ALL,
|
||||
cls.INCLUDES_ONLY,
|
||||
cls.IS_NULL,
|
||||
cls.NOT_NULL,
|
||||
]
|
||||
|
||||
|
||||
# ── Base criterion ─────────────────────────────────────────────────────────
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Criterion:
|
||||
"""Base for all typed criteria."""
|
||||
|
||||
value: Any = None
|
||||
modifier: Modifier = Modifier.EQUALS
|
||||
|
||||
def to_q(self, field_name: str) -> Q:
|
||||
raise NotImplementedError
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, data: dict | None) -> Self | None:
|
||||
if data is None or not isinstance(data, dict):
|
||||
return None
|
||||
kwargs: dict[str, Any] = {}
|
||||
for f in dc_fields(cls):
|
||||
if f.name in data:
|
||||
val = data[f.name]
|
||||
# Coerce string modifier to Modifier enum
|
||||
if f.name == "modifier" and isinstance(val, str):
|
||||
val = Modifier(val)
|
||||
kwargs[f.name] = val
|
||||
return cls(**kwargs)
|
||||
|
||||
def to_json(self) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {}
|
||||
for f in dc_fields(self):
|
||||
v = getattr(self, f.name)
|
||||
if v is not None and v != f.default:
|
||||
result[f.name] = v
|
||||
return result
|
||||
|
||||
|
||||
# ── Concrete criteria ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class StringCriterion(_Criterion):
|
||||
value: str = ""
|
||||
modifier: Modifier = Modifier.EQUALS
|
||||
|
||||
def to_q(self, field_name: str) -> Q:
|
||||
m = self.modifier
|
||||
if m == Modifier.EQUALS:
|
||||
return Q(**{field_name: self.value})
|
||||
if m == Modifier.NOT_EQUALS:
|
||||
return ~Q(**{field_name: self.value})
|
||||
if m == Modifier.INCLUDES:
|
||||
return Q(**{f"{field_name}__icontains": self.value})
|
||||
if m == Modifier.EXCLUDES:
|
||||
return ~Q(**{f"{field_name}__icontains": self.value})
|
||||
if m == Modifier.MATCHES_REGEX:
|
||||
return Q(**{f"{field_name}__regex": self.value})
|
||||
if m == Modifier.NOT_MATCHES_REGEX:
|
||||
return ~Q(**{f"{field_name}__regex": self.value})
|
||||
if m == Modifier.IS_NULL:
|
||||
return Q(**{f"{field_name}__isnull": True})
|
||||
if m == Modifier.NOT_NULL:
|
||||
return Q(**{f"{field_name}__isnull": False})
|
||||
raise ValueError(f"Unsupported modifier {m} for string field")
|
||||
|
||||
|
||||
@dataclass
|
||||
class IntCriterion(_Criterion):
|
||||
value: int = 0
|
||||
value2: int | None = None
|
||||
modifier: Modifier = Modifier.EQUALS
|
||||
|
||||
def to_q(self, field_name: str) -> Q:
|
||||
m = self.modifier
|
||||
if m == Modifier.EQUALS:
|
||||
return Q(**{field_name: self.value})
|
||||
if m == Modifier.NOT_EQUALS:
|
||||
return ~Q(**{field_name: self.value})
|
||||
if m == Modifier.GREATER_THAN:
|
||||
return Q(**{f"{field_name}__gt": self.value})
|
||||
if m == Modifier.LESS_THAN:
|
||||
return Q(**{f"{field_name}__lt": self.value})
|
||||
if m == Modifier.BETWEEN:
|
||||
if self.value2 is None:
|
||||
raise ValueError("BETWEEN requires value2")
|
||||
return Q(
|
||||
**{
|
||||
f"{field_name}__gte": min(self.value, self.value2),
|
||||
f"{field_name}__lte": max(self.value, self.value2),
|
||||
}
|
||||
)
|
||||
if m == Modifier.NOT_BETWEEN:
|
||||
if self.value2 is None:
|
||||
raise ValueError("NOT_BETWEEN requires value2")
|
||||
lo, hi = min(self.value, self.value2), max(self.value, self.value2)
|
||||
return Q(**{f"{field_name}__lt": lo}) | Q(**{f"{field_name}__gt": hi})
|
||||
if m == Modifier.IS_NULL:
|
||||
return Q(**{f"{field_name}__isnull": True})
|
||||
if m == Modifier.NOT_NULL:
|
||||
return Q(**{f"{field_name}__isnull": False})
|
||||
raise ValueError(f"Unsupported modifier {m} for int field")
|
||||
|
||||
|
||||
@dataclass
|
||||
class FloatCriterion(_Criterion):
|
||||
value: float = 0.0
|
||||
value2: float | None = None
|
||||
modifier: Modifier = Modifier.EQUALS
|
||||
|
||||
def to_q(self, field_name: str) -> Q:
|
||||
m = self.modifier
|
||||
if m == Modifier.EQUALS:
|
||||
return Q(**{field_name: self.value})
|
||||
if m == Modifier.NOT_EQUALS:
|
||||
return ~Q(**{field_name: self.value})
|
||||
if m == Modifier.GREATER_THAN:
|
||||
return Q(**{f"{field_name}__gt": self.value})
|
||||
if m == Modifier.LESS_THAN:
|
||||
return Q(**{f"{field_name}__lt": self.value})
|
||||
if m == Modifier.BETWEEN:
|
||||
if self.value2 is None:
|
||||
raise ValueError("BETWEEN requires value2")
|
||||
return Q(
|
||||
**{
|
||||
f"{field_name}__gte": min(self.value, self.value2),
|
||||
f"{field_name}__lte": max(self.value, self.value2),
|
||||
}
|
||||
)
|
||||
if m == Modifier.NOT_BETWEEN:
|
||||
if self.value2 is None:
|
||||
raise ValueError("NOT_BETWEEN requires value2")
|
||||
lo, hi = min(self.value, self.value2), max(self.value, self.value2)
|
||||
return Q(**{f"{field_name}__lt": lo}) | Q(**{f"{field_name}__gt": hi})
|
||||
if m == Modifier.IS_NULL:
|
||||
return Q(**{f"{field_name}__isnull": True})
|
||||
if m == Modifier.NOT_NULL:
|
||||
return Q(**{f"{field_name}__isnull": False})
|
||||
raise ValueError(f"Unsupported modifier {m} for float field")
|
||||
|
||||
|
||||
@dataclass
|
||||
class DateCriterion(_Criterion):
|
||||
value: str = ""
|
||||
value2: str | None = None
|
||||
modifier: Modifier = Modifier.EQUALS
|
||||
|
||||
def to_q(self, field_name: str) -> Q:
|
||||
m = self.modifier
|
||||
if m == Modifier.EQUALS:
|
||||
return Q(**{field_name: self.value})
|
||||
if m == Modifier.NOT_EQUALS:
|
||||
return ~Q(**{field_name: self.value})
|
||||
if m == Modifier.GREATER_THAN:
|
||||
return Q(**{f"{field_name}__gt": self.value})
|
||||
if m == Modifier.LESS_THAN:
|
||||
return Q(**{f"{field_name}__lt": self.value})
|
||||
if m == Modifier.BETWEEN:
|
||||
if self.value2 is None:
|
||||
raise ValueError("BETWEEN requires value2")
|
||||
return Q(
|
||||
**{f"{field_name}__gte": self.value, f"{field_name}__lte": self.value2}
|
||||
)
|
||||
if m == Modifier.NOT_BETWEEN:
|
||||
if self.value2 is None:
|
||||
raise ValueError("NOT_BETWEEN requires value2")
|
||||
return Q(**{f"{field_name}__lt": self.value}) | Q(
|
||||
**{f"{field_name}__gt": self.value2}
|
||||
)
|
||||
if m == Modifier.IS_NULL:
|
||||
return Q(**{f"{field_name}__isnull": True})
|
||||
if m == Modifier.NOT_NULL:
|
||||
return Q(**{f"{field_name}__isnull": False})
|
||||
raise ValueError(f"Unsupported modifier {m} for date field")
|
||||
|
||||
|
||||
@dataclass
|
||||
class BoolCriterion(_Criterion):
|
||||
value: bool = False
|
||||
# Bool only makes sense with EQUALS
|
||||
modifier: Modifier = Modifier.EQUALS
|
||||
|
||||
def to_q(self, field_name: str) -> Q:
|
||||
if self.modifier == Modifier.EQUALS:
|
||||
return Q(**{field_name: self.value})
|
||||
if self.modifier == Modifier.NOT_EQUALS:
|
||||
return ~Q(**{field_name: self.value})
|
||||
raise ValueError(f"Unsupported modifier {self.modifier} for bool field")
|
||||
|
||||
|
||||
@dataclass
|
||||
class _SetCriterion(_Criterion):
|
||||
"""Shared base for set-membership criteria (``MultiCriterion`` /
|
||||
``ChoiceCriterion``).
|
||||
|
||||
Two orthogonal channels, mirroring Stash's modifier model:
|
||||
|
||||
- ``value`` is the *include* set. The ``modifier`` governs how it matches:
|
||||
|
||||
- ``INCLUDES`` — in ``value`` (match *any*); ``EQUALS`` is an alias.
|
||||
- ``INCLUDES_ALL`` — related to *all* of ``value`` (meaningful for
|
||||
many-to-many fields, e.g. a purchase's games).
|
||||
- ``EXCLUDES`` — in none of ``value`` (match *none*); ``NOT_EQUALS`` is an
|
||||
alias.
|
||||
|
||||
- ``excludes`` is an *always-orthogonal* negative: it contributes
|
||||
``AND NOT IN (excludes)`` for every (non-presence) modifier, never
|
||||
swapped into the include set. An exclude-only criterion therefore means
|
||||
"everything except ``excludes``".
|
||||
|
||||
Empty lists contribute no constraint. ``IS_NULL`` / ``NOT_NULL`` test
|
||||
presence and ignore both lists.
|
||||
|
||||
The logic lives entirely here so the two subclasses (which differ only in
|
||||
their value type) cannot drift.
|
||||
"""
|
||||
|
||||
value: list = field(default_factory=list)
|
||||
excludes: list = field(default_factory=list)
|
||||
modifier: Modifier = Modifier.INCLUDES
|
||||
|
||||
def to_q(self, field_name: str) -> Q:
|
||||
modifier = self.modifier
|
||||
if modifier == Modifier.IS_NULL:
|
||||
return Q(**{f"{field_name}__isnull": True})
|
||||
if modifier == Modifier.NOT_NULL:
|
||||
return Q(**{f"{field_name}__isnull": False})
|
||||
# The modifier governs only the include set; ``excludes`` is an orthogonal
|
||||
# AND'd negative applied for every (non-presence) modifier.
|
||||
q = self._value_q(field_name)
|
||||
if self.excludes:
|
||||
q &= ~Q(**{f"{field_name}__in": self.excludes})
|
||||
return q
|
||||
|
||||
def _value_q(self, field_name: str) -> Q:
|
||||
"""Build the Q for the include (``value``) set, per the modifier."""
|
||||
modifier = self.modifier
|
||||
if modifier in (Modifier.INCLUDES, Modifier.EQUALS):
|
||||
return Q(**{f"{field_name}__in": self.value}) if self.value else Q()
|
||||
if modifier in (Modifier.EXCLUDES, Modifier.NOT_EQUALS):
|
||||
return ~Q(**{f"{field_name}__in": self.value}) if self.value else Q()
|
||||
if modifier in (Modifier.INCLUDES_ALL, Modifier.INCLUDES_ONLY):
|
||||
# INCLUDES_ALL ("related to all of these") and INCLUDES_ONLY
|
||||
# ("related to exactly these, nothing else") are only meaningful
|
||||
# for many-to-many fields. A naive Q(field=a) & Q(field=b)
|
||||
# collapses to a single join requiring one through-row to equal
|
||||
# both values (impossible), so the generic criterion layer cannot
|
||||
# build a correct Q. M2M callers must supply their own Q builder
|
||||
# at the filter level — see PurchaseFilter._games_to_q for the
|
||||
# chained-subquery pattern.
|
||||
assert False, (
|
||||
f"{modifier} requires a filter-level Q builder for M2M fields. "
|
||||
"See PurchaseFilter._games_to_q for the chained-subquery pattern."
|
||||
)
|
||||
raise ValueError(f"Unsupported modifier {modifier} for {type(self).__name__}")
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, data: dict | None) -> Self | None:
|
||||
result = super().from_json(data)
|
||||
if result is None:
|
||||
return None
|
||||
# Labels embedded as {id, label} dicts are display-only; strip to bare ids
|
||||
# so the querying layer stays clean and typed.
|
||||
result.value = [
|
||||
item["id"] if isinstance(item, dict) else item for item in result.value
|
||||
]
|
||||
result.excludes = [
|
||||
item["id"] if isinstance(item, dict) else item for item in result.excludes
|
||||
]
|
||||
return result
|
||||
|
||||
|
||||
@dataclass
|
||||
class MultiCriterion(_SetCriterion):
|
||||
"""Filter on a many-to-many or ForeignKey relationship by ID list.
|
||||
|
||||
All modifier logic (including ``INCLUDES_ALL`` and ``EXCLUDES``) lives in
|
||||
``_SetCriterion``; this subclass only refines the value type.
|
||||
"""
|
||||
|
||||
value: list[int] = field(default_factory=list)
|
||||
excludes: list[int] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChoiceCriterion(_SetCriterion):
|
||||
"""Filter on a choice/enum field with multi-select include/exclude.
|
||||
|
||||
Used by FilterSelect widgets for status, ownership_type, etc. Shares all
|
||||
modifier logic with ``MultiCriterion`` via ``_SetCriterion``.
|
||||
"""
|
||||
|
||||
value: list[str] = field(default_factory=list)
|
||||
excludes: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
# ── OperatorFilter base ────────────────────────────────────────────────────
|
||||
|
||||
F = TypeVar("F", bound="OperatorFilter")
|
||||
|
||||
|
||||
@dataclass
|
||||
class OperatorFilter:
|
||||
"""Mixin providing AND/OR/NOT composition for entity filter types.
|
||||
|
||||
Subclasses should declare nullable references to themselves::
|
||||
|
||||
@dataclass
|
||||
class GameFilter(OperatorFilter):
|
||||
AND: "GameFilter | None" = None
|
||||
OR: "GameFilter | None" = None
|
||||
NOT: "GameFilter | None" = None
|
||||
name: StringCriterion | None = None
|
||||
...
|
||||
"""
|
||||
|
||||
def sub_filter(self) -> OperatorFilter | None:
|
||||
"""Return the first non-None of AND / OR / NOT."""
|
||||
for attr in ("AND", "OR", "NOT"):
|
||||
if hasattr(self, attr):
|
||||
v = getattr(self, attr)
|
||||
if v is not None:
|
||||
return v
|
||||
return None
|
||||
|
||||
def _criterion_fields(self) -> list[str]:
|
||||
"""Return field names that hold a _Criterion instance."""
|
||||
names: list[str] = []
|
||||
for f in dc_fields(self):
|
||||
if f.name in ("AND", "OR", "NOT"):
|
||||
continue
|
||||
v = getattr(self, f.name)
|
||||
if isinstance(v, _Criterion):
|
||||
names.append(f.name)
|
||||
return names
|
||||
|
||||
def to_q(self) -> Q:
|
||||
"""Build a Django Q object from this filter and its sub-filters."""
|
||||
q = Q()
|
||||
for field_name in self._criterion_fields():
|
||||
c = getattr(self, field_name)
|
||||
if c is not None:
|
||||
q &= c.to_q(field_name)
|
||||
sub = self.sub_filter()
|
||||
if sub is not None:
|
||||
if getattr(self, "AND", None) is not None:
|
||||
q &= sub.to_q()
|
||||
elif getattr(self, "OR", None) is not None:
|
||||
q |= sub.to_q()
|
||||
elif getattr(self, "NOT", None) is not None:
|
||||
q &= ~sub.to_q()
|
||||
return q
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, data: dict[str, Any] | None) -> Self | None:
|
||||
if data is None or not isinstance(data, dict):
|
||||
return None
|
||||
# Resolve criterion class names to actual types
|
||||
criterion_types: dict[str, type[_Criterion]] = {
|
||||
"StringCriterion": StringCriterion,
|
||||
"IntCriterion": IntCriterion,
|
||||
"FloatCriterion": FloatCriterion,
|
||||
"DateCriterion": DateCriterion,
|
||||
"BoolCriterion": BoolCriterion,
|
||||
"MultiCriterion": MultiCriterion,
|
||||
"ChoiceCriterion": ChoiceCriterion,
|
||||
}
|
||||
kwargs: dict[str, Any] = {}
|
||||
for f in dc_fields(cls):
|
||||
if f.name not in data:
|
||||
continue
|
||||
raw = data[f.name]
|
||||
if raw is None:
|
||||
kwargs[f.name] = None
|
||||
continue
|
||||
# Recurse into sub-filters (AND / OR / NOT)
|
||||
if f.name in ("AND", "OR", "NOT"):
|
||||
kwargs[f.name] = cls.from_json(raw) if isinstance(raw, dict) else None
|
||||
continue
|
||||
# Resolve criterion fields from string type annotation
|
||||
f_type = f.type
|
||||
if isinstance(f_type, str):
|
||||
# e.g. "StringCriterion | None" → "StringCriterion"
|
||||
f_type = f_type.split("|")[0].strip()
|
||||
if isinstance(f_type, str) and f_type in criterion_types:
|
||||
criterion_cls = criterion_types[f_type]
|
||||
kwargs[f.name] = (
|
||||
criterion_cls.from_json(raw) if isinstance(raw, dict) else None
|
||||
)
|
||||
elif isinstance(f_type, type) and issubclass(f_type, _Criterion):
|
||||
kwargs[f.name] = (
|
||||
f_type.from_json(raw) if isinstance(raw, dict) else None
|
||||
)
|
||||
return cls(**kwargs)
|
||||
|
||||
def to_json(self) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {}
|
||||
for f in dc_fields(self):
|
||||
v = getattr(self, f.name)
|
||||
if v is None:
|
||||
continue
|
||||
if f.name in ("AND", "OR", "NOT"):
|
||||
result[f.name] = v.to_json()
|
||||
elif isinstance(v, _Criterion):
|
||||
j = v.to_json()
|
||||
if j:
|
||||
result[f.name] = j
|
||||
return result
|
||||
|
||||
|
||||
# ── JSON helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def filter_from_json(cls: type[F], json_str: str) -> F | None:
|
||||
"""Deserialize a filter from a JSON string.
|
||||
|
||||
Usage:
|
||||
f = filter_from_json(GameFilter, request.GET.get("filter", ""))
|
||||
games = Game.objects.filter(f.to_q())
|
||||
"""
|
||||
if not json_str:
|
||||
return None
|
||||
try:
|
||||
data = json.loads(json_str)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return cls.from_json(data)
|
||||
|
||||
|
||||
def filter_to_json(f: OperatorFilter) -> str:
|
||||
"""Serialize a filter to a JSON string for URL params or storage."""
|
||||
return json.dumps(f.to_json())
|
||||
@@ -1,25 +0,0 @@
|
||||
import functools
|
||||
from pathlib import Path
|
||||
|
||||
_ICON_DIR = Path(__file__).resolve().parent.parent / "games" / "templates" / "icons"
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def _load_icons() -> dict[str, str]:
|
||||
"""Load all icon HTML files into a dict.
|
||||
|
||||
Cached so files are read once per process lifetime.
|
||||
Delegation (e.g. nintendo-3ds -> nintendo) is handled by
|
||||
both files containing identical SVG content.
|
||||
"""
|
||||
icons: dict[str, str] = {}
|
||||
for filepath in _ICON_DIR.glob("*.html"):
|
||||
name = filepath.stem
|
||||
icons[name] = filepath.read_text()
|
||||
return icons
|
||||
|
||||
|
||||
def get_icon(name: str) -> str:
|
||||
"""Return the HTML for an icon by name. Falls back to 'unspecified'."""
|
||||
icons = _load_icons()
|
||||
return icons.get(name, icons.get("unspecified", ""))
|
||||
@@ -20,8 +20,8 @@ def import_data(data: DataList):
|
||||
# try exact match first
|
||||
try:
|
||||
game_id = Game.objects.get(name__iexact=name)
|
||||
except (Game.DoesNotExist, Game.MultipleObjectsReturned):
|
||||
game_id = None
|
||||
except:
|
||||
pass
|
||||
matching_names[name] = game_id
|
||||
print(f"Exact matched {len(matching_names)} games.")
|
||||
|
||||
|
||||
+97
-160
@@ -1,143 +1,127 @@
|
||||
@import 'tailwindcss';
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@plugin '@tailwindcss/typography';
|
||||
@plugin '@tailwindcss/forms';
|
||||
@plugin 'flowbite/plugin';
|
||||
@font-face {
|
||||
font-family: "IBM Plex Mono";
|
||||
src: url("fonts/IBMPlexMono-Regular.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@source "../node_modules/flowbite";
|
||||
@import "flowbite/src/themes/default";
|
||||
@font-face {
|
||||
font-family: "IBM Plex Sans";
|
||||
src: url("fonts/IBMPlexSans-Regular.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
@font-face {
|
||||
font-family: "IBM Plex Serif";
|
||||
src: url("fonts/IBMPlexSerif-Regular.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@theme {
|
||||
--font-sans:
|
||||
IBM Plex Sans, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji',
|
||||
'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
--font-mono:
|
||||
IBM Plex Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
'Liberation Mono', 'Courier New', monospace;
|
||||
--font-serif:
|
||||
IBM Plex Serif, ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
|
||||
--font-condensed:
|
||||
IBM Plex Sans Condensed, ui-sans-serif, system-ui, sans-serif,
|
||||
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
@font-face {
|
||||
font-family: "IBM Plex Serif";
|
||||
src: url("fonts/IBMPlexSerif-Bold.woff2") format("woff2");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
--color-accent: #7c3aed;
|
||||
--color-background: #1f2937;
|
||||
@font-face {
|
||||
font-family: "IBM Plex Sans Condensed";
|
||||
src: url("fonts/IBMPlexSansCondensed-Regular.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
||||
so we've added these compatibility styles to make sure everything still
|
||||
looks the same as it did with Tailwind CSS v3.
|
||||
/* a:hover {
|
||||
text-decoration-color: #ff4400;
|
||||
color: rgb(254, 185, 160);
|
||||
transition: all 0.2s ease-out;
|
||||
} */
|
||||
|
||||
If we ever want to remove these styles, we need to add an explicit border
|
||||
color utility to any element that depends on these defaults.
|
||||
*/
|
||||
@layer base {
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentcolor);
|
||||
}
|
||||
form label {
|
||||
@apply dark:text-slate-400;
|
||||
}
|
||||
|
||||
@utility min-w-20char {
|
||||
min-width: 20ch;
|
||||
.responsive-table {
|
||||
@apply dark:text-white mx-auto table-fixed;
|
||||
}
|
||||
|
||||
@utility max-w-20char {
|
||||
max-width: 20ch;
|
||||
.responsive-table tr:nth-child(even) {
|
||||
@apply bg-slate-800
|
||||
}
|
||||
|
||||
@utility min-w-30char {
|
||||
min-width: 30ch;
|
||||
.responsive-table tbody tr:nth-child(odd) {
|
||||
@apply bg-slate-900
|
||||
}
|
||||
|
||||
@utility max-w-30char {
|
||||
max-width: 30ch;
|
||||
.responsive-table thead th {
|
||||
@apply text-left border-b-2 border-b-slate-500 text-xl;
|
||||
}
|
||||
|
||||
@utility max-w-35char {
|
||||
max-width: 35ch;
|
||||
}
|
||||
|
||||
@utility max-w-40char {
|
||||
max-width: 40ch;
|
||||
.responsive-table thead th:not(:first-child),
|
||||
.responsive-table td:not(:first-child) {
|
||||
@apply border-l border-l-slate-500;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Mono';
|
||||
src: url('fonts/IBMPlexMono-Regular.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
.min-w-20char {
|
||||
min-width: 20ch;
|
||||
}
|
||||
.max-w-20char {
|
||||
max-width: 20ch;
|
||||
}
|
||||
.min-w-30char {
|
||||
min-width: 30ch;
|
||||
}
|
||||
.max-w-30char {
|
||||
max-width: 30ch;
|
||||
}
|
||||
.max-w-35char {
|
||||
max-width: 35ch;
|
||||
}
|
||||
.max-w-40char {
|
||||
max-width: 40ch;
|
||||
}
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Sans';
|
||||
src: url('fonts/IBMPlexSans-Regular.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Serif';
|
||||
src: url('fonts/IBMPlexSerif-Regular.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Serif';
|
||||
src: url('fonts/IBMPlexSerif-Bold.woff2') format('woff2');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Sans Condensed';
|
||||
src: url('fonts/IBMPlexSansCondensed-Regular.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.responsive-table {
|
||||
@apply dark:text-white mx-auto table-fixed;
|
||||
}
|
||||
|
||||
.responsive-table tr:nth-child(even) {
|
||||
@apply bg-indigo-100 dark:bg-slate-800;
|
||||
}
|
||||
|
||||
.responsive-table tbody tr:nth-child(odd) {
|
||||
@apply bg-indigo-200 dark:bg-slate-900;
|
||||
}
|
||||
|
||||
.responsive-table thead th {
|
||||
@apply text-left border-b-2 border-b-slate-500 text-xl;
|
||||
}
|
||||
|
||||
.responsive-table thead th:not(:first-child),
|
||||
.responsive-table td:not(:first-child) {
|
||||
@apply border-l border-l-slate-500;
|
||||
}
|
||||
form input,
|
||||
select,
|
||||
textarea {
|
||||
@apply dark:border dark:border-slate-900 dark:bg-slate-500 dark:text-slate-100;
|
||||
}
|
||||
|
||||
form input:disabled,
|
||||
select:disabled,
|
||||
textarea:disabled {
|
||||
@apply cursor-not-allowed bg-neutral-secondary-strong text-fg-disabled;
|
||||
|
||||
@apply dark:bg-slate-700 dark:text-slate-400;
|
||||
}
|
||||
|
||||
.errorlist {
|
||||
@apply mt-4 mb-1 pl-3 py-2 bg-red-600 text-slate-200 w-[300px];
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
form input,
|
||||
select,
|
||||
textarea {
|
||||
width: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
form input,
|
||||
select,
|
||||
textarea {
|
||||
width: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
#button-container button {
|
||||
@apply mx-1;
|
||||
}
|
||||
@@ -147,7 +131,7 @@ textarea:disabled {
|
||||
}
|
||||
|
||||
.basic-button {
|
||||
@apply inline-block px-6 py-2.5 bg-blue-600 text-white font-medium text-xs leading-tight uppercase rounded-sm shadow-md hover:bg-blue-700 hover:shadow-lg focus:bg-blue-700 focus:shadow-lg focus:outline-hidden focus:ring-0 active:bg-blue-800 active:shadow-lg transition duration-150 ease-in-out;
|
||||
@apply inline-block px-6 py-2.5 bg-blue-600 text-white font-medium text-xs leading-tight uppercase rounded shadow-md hover:bg-blue-700 hover:shadow-lg focus:bg-blue-700 focus:shadow-lg focus:outline-none focus:ring-0 active:bg-blue-800 active:shadow-lg transition duration-150 ease-in-out;
|
||||
}
|
||||
|
||||
.markdown-content ul {
|
||||
@@ -178,57 +162,10 @@ textarea:disabled {
|
||||
padding-left: 1em;
|
||||
}
|
||||
|
||||
#add-form {
|
||||
label + select, input, textarea {
|
||||
@apply mt-1;
|
||||
}
|
||||
form {
|
||||
@apply flex flex-col gap-3;
|
||||
}
|
||||
|
||||
.form-row-button-group {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@apply gap-0 p-0;
|
||||
button {
|
||||
@apply mr-0;
|
||||
&:first-child {
|
||||
@apply rounded-e-none;
|
||||
}
|
||||
&:nth-child(2) {
|
||||
@apply rounded-none;
|
||||
}
|
||||
&:last-child {
|
||||
@apply rounded-s-none;
|
||||
}
|
||||
}
|
||||
}
|
||||
label {
|
||||
@apply mb-2.5 text-sm font-medium text-heading;
|
||||
}
|
||||
input:not([type="checkbox"]):not([data-search-select-search]) {
|
||||
@apply mb-3 bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand block w-full px-3 py-2.5 shadow-xs placeholder:text-body;
|
||||
}
|
||||
input[type="checkbox"] {
|
||||
@apply w-4 h-4 border border-default-medium rounded-xs bg-neutral-secondary-medium focus:ring-2 focus:ring-brand-soft;
|
||||
}
|
||||
select {
|
||||
@apply w-full px-3 py-2.5 bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand shadow-xs placeholder:text-body;
|
||||
}
|
||||
textarea {
|
||||
@apply bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand block w-full p-3.5 shadow-xs placeholder:text-body;
|
||||
}
|
||||
:has(> label + input[type="checkbox"]) {
|
||||
@apply mt-3; /* needed because compared to all other form elements checkbox and its label are on the same row */
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.toast-container {
|
||||
@apply fixed z-50 flex flex-col items-end bottom-0 right-0 p-4;
|
||||
}
|
||||
}
|
||||
|
||||
/* .truncate-container {
|
||||
@apply inline-block relative;
|
||||
a {
|
||||
@apply inline-block truncate max-w-20char transition-all group-hover:absolute group-hover:max-w-none group-hover:-top-8 group-hover:-left-6 group-hover:min-w-60 group-hover:px-6 group-hover:py-3.5 group-hover:bg-purple-600 group-hover:rounded-sm group-hover:outline-dashed group-hover:outline-purple-400 group-hover:outline-4;
|
||||
|
||||
}
|
||||
} */
|
||||
|
||||
@@ -1,353 +0,0 @@
|
||||
"""A small fast_app-style layout system.
|
||||
|
||||
Instead of Django template inheritance (`{% extends "base.html" %}`), views
|
||||
build their page body with Python components and wrap it with `Page()` /
|
||||
`render_page()`. `Page()` is the equivalent of FastHTML's document wrapper:
|
||||
it hoists shared `<head>` content (the `_HEADERS` block, analogous to
|
||||
`fast_app(hdrs=...)`), renders the navbar, and assembles the full document.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from django.contrib.messages import get_messages
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.templatetags.static import static
|
||||
from django.urls import reverse
|
||||
from django.utils.html import conditional_escape
|
||||
from django.utils.safestring import SafeText, mark_safe
|
||||
from django_htmx.jinja import django_htmx_script
|
||||
|
||||
from games.templatetags.version import version, version_date
|
||||
|
||||
# Static head script that sets the dark/light class before paint (avoids FOUC).
|
||||
_THEME_FOUC_SCRIPT = """<script>
|
||||
if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
</script>"""
|
||||
|
||||
# The main module script: crown icon mount + theme-toggle wiring.
|
||||
# Split around the single dynamic value (game.mastered).
|
||||
_MAIN_SCRIPT_A = """<script type="module">
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (window.mountCrownIcon) {
|
||||
window.mountCrownIcon('#crown-icon-mount-point', {
|
||||
mastered: """
|
||||
_MAIN_SCRIPT_B = """
|
||||
});
|
||||
}
|
||||
|
||||
const themeToggleDarkIcon = document.getElementById('theme-toggle-dark-icon');
|
||||
const themeToggleLightIcon = document.getElementById('theme-toggle-light-icon');
|
||||
const themeToggleBtn = document.getElementById('theme-toggle');
|
||||
|
||||
if (themeToggleDarkIcon && themeToggleLightIcon && themeToggleBtn) {
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
themeToggleLightIcon.classList.remove('hidden');
|
||||
themeToggleDarkIcon.classList.add('hidden');
|
||||
} else {
|
||||
themeToggleDarkIcon.classList.remove('hidden');
|
||||
themeToggleLightIcon.classList.add('hidden');
|
||||
}
|
||||
|
||||
themeToggleBtn.addEventListener('click', function () {
|
||||
themeToggleDarkIcon.classList.toggle('hidden');
|
||||
themeToggleLightIcon.classList.toggle('hidden');
|
||||
|
||||
if (localStorage.getItem('color-theme')) {
|
||||
if (localStorage.getItem('color-theme') === 'light') {
|
||||
document.documentElement.classList.add('dark');
|
||||
localStorage.setItem('color-theme', 'dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
localStorage.setItem('color-theme', 'light');
|
||||
}
|
||||
} else {
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
document.documentElement.classList.remove('dark');
|
||||
localStorage.setItem('color-theme', 'light');
|
||||
} else {
|
||||
document.documentElement.classList.add('dark');
|
||||
localStorage.setItem('color-theme', 'dark');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>"""
|
||||
|
||||
# Toast notification region (Alpine.js). Verbatim from the old base.html.
|
||||
_TOAST_CONTAINER = """<div x-data="toastStore()"
|
||||
role="region"
|
||||
aria-label="Notifications"
|
||||
aria-atomic="true"
|
||||
class="fixed z-50 bottom-0 right-0 flex flex-col items-end pointer-events-none p-4">
|
||||
<template x-for="toast in $store.toasts.toasts" :key="toast.id">
|
||||
<div x-show="toast.visible"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 translate-x-8"
|
||||
x-transition:enter-end="opacity-100 translate-x-0"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 translate-x-0"
|
||||
x-transition:leave-end="opacity-0 translate-x-8"
|
||||
:role="toast.type === 'error' || toast.type === 'warning' ? 'alert' : 'status'"
|
||||
:aria-live="toast.type === 'error' ? 'assertive' : 'polite'"
|
||||
tabindex="0"
|
||||
class="pointer-events-auto max-w-sm w-72 cursor-pointer mb-3 last:mb-0"
|
||||
:class="{
|
||||
'success': toast.type === 'success',
|
||||
'error': toast.type === 'error',
|
||||
'info': toast.type === 'info',
|
||||
'warning': toast.type === 'warning',
|
||||
'debug': toast.type === 'debug'
|
||||
}"
|
||||
@click="dismissToast(toast.id)"
|
||||
@mouseenter="$store.toasts.clearToastTimer(toast.id)"
|
||||
@mouseleave="$store.toasts.resumeToastTimer(toast.id, 5000)"
|
||||
@keydown.escape="dismissToast(toast.id)">
|
||||
<div class="rounded-lg shadow-lg p-4 flex items-start gap-3"
|
||||
:class="{
|
||||
'bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700': toast.type === 'success',
|
||||
'bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700': toast.type === 'error',
|
||||
'bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700': toast.type === 'info',
|
||||
'bg-amber-50 dark:bg-amber-900 border border-amber-200 dark:border-amber-700': toast.type === 'warning',
|
||||
'bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700': toast.type === 'debug'
|
||||
}">
|
||||
<span class="flex-shrink-0 mt-0.5"
|
||||
:class="{
|
||||
'text-green-500': toast.type === 'success',
|
||||
'text-red-500': toast.type === 'error',
|
||||
'text-blue-500': toast.type === 'info',
|
||||
'text-amber-500': toast.type === 'warning',
|
||||
'text-gray-500': toast.type === 'debug'
|
||||
}">
|
||||
<template x-if="toast.type === 'success'">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
</template>
|
||||
<template x-if="toast.type === 'error'">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</template>
|
||||
<template x-if="toast.type === 'info'">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M12 20a8 8 0 100-16 8 8 0 000 16z"/>
|
||||
</svg>
|
||||
</template>
|
||||
<template x-if="toast.type === 'warning'">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 13l5 5 5-5M7 6l5 5 5-5"/>
|
||||
</svg>
|
||||
</template>
|
||||
<template x-if="toast.type === 'debug'">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
</template>
|
||||
</span>
|
||||
<p class="flex-1 text-sm"
|
||||
:class="{
|
||||
'text-green-800 dark:text-green-200': toast.type === 'success',
|
||||
'text-red-800 dark:text-red-200': toast.type === 'error',
|
||||
'text-blue-800 dark:text-blue-200': toast.type === 'info',
|
||||
'text-amber-800 dark:text-amber-200': toast.type === 'warning',
|
||||
'text-gray-800 dark:text-gray-200': toast.type === 'debug'
|
||||
}"
|
||||
x-text="toast.message"></p>
|
||||
<button @click.stop="dismissToast(toast.id)"
|
||||
class="flex-shrink-0"
|
||||
:class="{
|
||||
'text-green-400 hover:text-green-600 dark:text-green-500 dark:hover:text-green-300': toast.type === 'success',
|
||||
'text-red-400 hover:text-red-600 dark:text-red-500 dark:hover:text-red-300': toast.type === 'error',
|
||||
'text-blue-400 hover:text-blue-600 dark:text-blue-500 dark:hover:text-blue-300': toast.type === 'info',
|
||||
'text-amber-400 hover:text-amber-600 dark:text-amber-500 dark:hover:text-amber-300': toast.type === 'warning',
|
||||
'text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300': toast.type === 'debug'
|
||||
}">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>"""
|
||||
|
||||
|
||||
def _main_script(mastered: bool) -> str:
|
||||
return _MAIN_SCRIPT_A + ("true" if mastered else "false") + _MAIN_SCRIPT_B
|
||||
|
||||
|
||||
def Navbar(*, today_played: str, last_7_played: str, current_year: int) -> SafeText:
|
||||
"""Top navigation bar."""
|
||||
logo = static("icons/schedule.png")
|
||||
return mark_safe(f"""<nav class="bg-neutral-primary-soft border-b border-default">
|
||||
<div class="max-w-(--breakpoint-xl) flex flex-wrap items-center justify-between mx-auto p-4">
|
||||
<a href="{reverse("games:index")}"
|
||||
class="flex items-center space-x-3 rtl:space-x-reverse">
|
||||
<img src="{logo}" height="48" width="48" alt="Timetracker Logo" class="mr-4" />
|
||||
<span class="self-center text-2xl font-semibold whitespace-nowrap dark:text-white">Timetracker</span>
|
||||
</a>
|
||||
<button data-collapse-toggle="navbar-dropdown" type="button"
|
||||
class="inline-flex items-center p-2 w-10 h-10 justify-center text-sm text-gray-500 rounded-lg md:hidden hover:bg-gray-100 focus:outline-hidden focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600"
|
||||
aria-controls="navbar-dropdown" aria-expanded="false">
|
||||
<span class="sr-only">Open main menu</span>
|
||||
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 17 14">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 1h15M1 7h15M1 13h15" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="hidden w-full md:block md:w-auto" id="navbar-dropdown">
|
||||
<ul class="items-center flex flex-col font-medium p-4 md:p-0 mt-4 border border-gray-100 rounded-lg bg-gray-50 md:space-x-8 rtl:space-x-reverse md:flex-row md:mt-0 md:border-0 md:bg-white dark:bg-gray-800 md:dark:bg-gray-900 dark:border-gray-700">
|
||||
<li class="flex items-center">
|
||||
<button id="theme-toggle" type="button" class="p-2 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-hidden focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg text-sm hover:cursor-pointer">
|
||||
<svg id="theme-toggle-dark-icon" class="hidden w-5 h-5" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.32031 11.6835C3.32031 16.6541 7.34975 20.6835 12.3203 20.6835C16.1075 20.6835 19.3483 18.3443 20.6768 15.032C19.6402 15.4486 18.5059 15.6834 17.3203 15.6834C12.3497 15.6834 8.32031 11.654 8.32031 6.68342C8.32031 5.50338 8.55165 4.36259 8.96453 3.32996C5.65605 4.66028 3.32031 7.89912 3.32031 11.6835Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<svg id="theme-toggle-light-icon" class="hidden w-5 h-5" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 3V4M12 20V21M4 12H3M6.31412 6.31412L5.5 5.5M17.6859 6.31412L18.5 5.5M6.31412 17.69L5.5 18.5001M17.6859 17.69L18.5 18.5001M21 12H20M16 12C16 14.2091 14.2091 16 12 16C9.79086 16 8 14.2091 8 12C8 9.79086 9.79086 8 12 8C14.2091 8 16 9.79086 16 12Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</li>
|
||||
<li class="dark:text-white flex flex-col items-center text-xs">
|
||||
<span class="flex uppercase gap-1">Today<span class="dark:text-gray-400">·</span>Last 7 days</span>
|
||||
<span class="flex items-center gap-1">{today_played}<span class="dark:text-gray-400">·</span>{last_7_played}</span>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" class="block py-2 px-3 text-white bg-blue-700 rounded-sm md:bg-transparent md:text-blue-700 md:p-0 md:dark:text-blue-500 dark:bg-blue-600 md:dark:bg-transparent" aria-current="page">Home</a>
|
||||
</li>
|
||||
<li>
|
||||
<button id="dropdownNavbarNewLink" data-dropdown-toggle="dropdownNavbarNew"
|
||||
class="flex items-center justify-between w-full py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 md:w-auto dark:text-white md:dark:hover:text-blue-500 dark:focus:text-white dark:border-gray-700 dark:hover:bg-gray-700 md:dark:hover:bg-transparent hover:cursor-pointer">
|
||||
New
|
||||
<svg class="w-2.5 h-2.5 ms-2.5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 10 6">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4" />
|
||||
</svg>
|
||||
</button>
|
||||
<div id="dropdownNavbarNew" class="z-10 hidden font-normal bg-white divide-y divide-gray-100 rounded-lg shadow-sm w-44 dark:bg-gray-700 dark:divide-gray-600">
|
||||
<ul class="py-2 text-sm text-gray-700 dark:text-gray-400" aria-labelledby="dropdownLargeButton">
|
||||
<li><a href="{reverse("games:add_device")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Device</a></li>
|
||||
<li><a href="{reverse("games:add_game")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Game</a></li>
|
||||
<li><a href="{reverse("games:add_platform")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platform</a></li>
|
||||
<li><a href="{reverse("games:add_purchase")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Purchase</a></li>
|
||||
<li><a href="{reverse("games:add_session")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Session</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<button id="dropdownNavbarManageLink" data-dropdown-toggle="dropdownNavbarManage"
|
||||
class="flex items-center justify-between w-full py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 md:w-auto dark:text-white md:dark:hover:text-blue-500 dark:focus:text-white dark:border-gray-700 dark:hover:bg-gray-700 md:dark:hover:bg-transparent hover:cursor-pointer">
|
||||
Manage
|
||||
<svg class="w-2.5 h-2.5 ms-2.5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 10 6">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4" />
|
||||
</svg>
|
||||
</button>
|
||||
<div id="dropdownNavbarManage" class="z-10 hidden font-normal bg-white divide-y divide-gray-100 rounded-lg shadow-sm w-44 dark:bg-gray-700 dark:divide-gray-600">
|
||||
<ul class="py-2 text-sm text-gray-700 dark:text-gray-400" aria-labelledby="dropdownLargeButton">
|
||||
<li><a href="{reverse("games:list_devices")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Devices</a></li>
|
||||
<li><a href="{reverse("games:list_games")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Games</a></li>
|
||||
<li><a href="{reverse("games:list_platforms")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platforms</a></li>
|
||||
<li><a href="{reverse("games:list_playevents")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Play events</a></li>
|
||||
<li><a href="{reverse("games:list_purchases")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Purchases</a></li>
|
||||
<li><a href="{reverse("games:list_sessions")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Sessions</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{reverse("games:stats_by_year", args=[current_year])}" class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Stats</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{reverse("logout")}" class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Log out</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>""")
|
||||
|
||||
|
||||
def Page(
|
||||
content: SafeText | str,
|
||||
*,
|
||||
request: HttpRequest,
|
||||
title: str = "",
|
||||
scripts: SafeText | str = "",
|
||||
mastered: bool = False,
|
||||
) -> SafeText:
|
||||
"""Assemble a full HTML document around `content` (the fast_app equivalent)."""
|
||||
from games.views.general import global_current_year, model_counts
|
||||
|
||||
counts = model_counts(request)
|
||||
year = global_current_year(request)["global_current_year"]
|
||||
navbar = Navbar(
|
||||
today_played=counts["today_played"],
|
||||
last_7_played=counts["last_7_played"],
|
||||
current_year=year,
|
||||
)
|
||||
|
||||
messages = [
|
||||
{"message": str(m.message), "type": (m.tags or "info")}
|
||||
for m in get_messages(request)
|
||||
]
|
||||
# Embed as JSON; guard against `</script>` breaking out of the tag.
|
||||
messages_json = json.dumps(messages).replace("</", "<\\/")
|
||||
|
||||
head = (
|
||||
'<!DOCTYPE html>\n<html lang="en">\n <head>\n'
|
||||
' <meta charset="utf-8" />\n'
|
||||
' <meta name="description" content="Self-hosted time-tracker." />\n'
|
||||
' <meta name="keywords" content="time, tracking, video games, self-hosted" />\n'
|
||||
' <meta name="viewport" content="width=device-width, initial-scale=1" />\n'
|
||||
f" <title>Timetracker - {conditional_escape(title)}</title>\n"
|
||||
f' <script src="{static("js/htmx.min.js")}"></script>\n'
|
||||
" <script>\n"
|
||||
" htmx.config.scrollBehavior = 'smooth';\n"
|
||||
" htmx.config.selfRequestsOnly = false;\n"
|
||||
" </script>\n"
|
||||
f' <script src="{static("js/htmx-redirect-toast.js")}"></script>\n'
|
||||
f" {django_htmx_script(nonce=None)}\n"
|
||||
f' <link rel="stylesheet" href="{static("base.css")}" />\n'
|
||||
' <script src="https://cdn.jsdelivr.net/npm/flowbite@2.4.1/dist/flowbite.min.js"></script>\n'
|
||||
' <script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/mask@3.x.x/dist/cdn.min.js"></script>\n'
|
||||
' <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>\n'
|
||||
f" {_THEME_FOUC_SCRIPT}\n"
|
||||
" </head>\n"
|
||||
)
|
||||
|
||||
body = (
|
||||
' <body hx-indicator="#indicator" class="bg-neutral-primary">\n'
|
||||
f' <script id="django-messages" type="application/json">{messages_json}</script>\n'
|
||||
f' <img id="indicator" src="{static("icons/loading.png")}" class="absolute right-3 top-3 animate-spin htmx-indicator" height="24" width="24" alt="loading indicator" />\n'
|
||||
' <div class="flex flex-col min-h-screen">\n'
|
||||
f" {navbar}\n"
|
||||
f' <div id="main-container" class="flex flex-1 flex-col pt-8 pb-16">{content}</div>\n'
|
||||
f' <span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{version()} ({version_date()})</span>\n'
|
||||
" </div>\n"
|
||||
f" {scripts}\n"
|
||||
f" {_main_script(mastered)}\n"
|
||||
" <!-- hx-swap-oob makes sure the modal gets removed upon any HTMX response -->\n"
|
||||
' <div id="global-modal-container" hx-swap-oob="true"></div>\n'
|
||||
f" {_TOAST_CONTAINER}\n"
|
||||
f' <script src="{static("js/toast.js")}"></script>\n'
|
||||
" </body>\n</html>\n"
|
||||
)
|
||||
|
||||
return mark_safe(head + body)
|
||||
|
||||
|
||||
def render_page(
|
||||
request: HttpRequest,
|
||||
content: SafeText | str,
|
||||
*,
|
||||
title: str = "",
|
||||
scripts: SafeText | str = "",
|
||||
mastered: bool = False,
|
||||
status: int = 200,
|
||||
) -> HttpResponse:
|
||||
"""`render()`-style shortcut: build a full page and return an HttpResponse."""
|
||||
return HttpResponse(
|
||||
Page(content, request=request, title=title, scripts=scripts, mastered=mastered),
|
||||
status=status,
|
||||
)
|
||||
+2
-99
@@ -1,19 +1,9 @@
|
||||
import re
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from common.utils import generate_split_ranges
|
||||
|
||||
dateformat: str = "%d/%m/%Y"
|
||||
datetimeformat: str = "%d/%m/%Y %H:%M"
|
||||
timeformat: str = "%H:%M"
|
||||
durationformat: str = "%2.1H hours"
|
||||
durationformat_manual: str = "%H hours"
|
||||
from datetime import timedelta
|
||||
|
||||
|
||||
def _safe_timedelta(duration: timedelta | int | None):
|
||||
if duration is None:
|
||||
if duration == None:
|
||||
return timedelta(0)
|
||||
elif isinstance(duration, int):
|
||||
return timedelta(seconds=duration)
|
||||
@@ -80,90 +70,3 @@ def format_duration(
|
||||
rf"%\d*\.?\d*{pattern}", replacement, formatted_string
|
||||
)
|
||||
return formatted_string
|
||||
|
||||
|
||||
def local_strftime(datetime: datetime, format: str = datetimeformat) -> str:
|
||||
return timezone.localtime(datetime).strftime(format)
|
||||
|
||||
|
||||
def daterange(start: date, end: date, end_inclusive: bool = False) -> list[date]:
|
||||
time_between: timedelta = end - start
|
||||
if (days_between := time_between.days) < 1:
|
||||
raise ValueError("start and end have to be at least 1 day apart.")
|
||||
if end_inclusive:
|
||||
print(f"{end_inclusive=}")
|
||||
print(f"{days_between=}")
|
||||
days_between += 1
|
||||
print(f"{days_between=}")
|
||||
return [start + timedelta(x) for x in range(days_between)]
|
||||
|
||||
|
||||
def streak(datelist: list[date]) -> dict[str, int | tuple[date, date]]:
|
||||
if len(datelist) == 1:
|
||||
return {"days": 1, "dates": (datelist[0], datelist[0])}
|
||||
else:
|
||||
print(f"Processing {len(datelist)} dates.")
|
||||
missing = sorted(
|
||||
set(
|
||||
datelist[0] + timedelta(x)
|
||||
for x in range((datelist[-1] - datelist[0]).days)
|
||||
)
|
||||
- set(datelist)
|
||||
)
|
||||
print(f"{len(missing)} days missing.")
|
||||
datelist_with_missing = sorted(datelist + missing)
|
||||
ranges = list(generate_split_ranges(datelist_with_missing, missing))
|
||||
print(f"{len(ranges)} ranges calculated.")
|
||||
longest_consecutive_days = timedelta(0)
|
||||
longest_range: tuple[date, date] = (date(1970, 1, 1), date(1970, 1, 1))
|
||||
for start, end in ranges:
|
||||
if (current_streak := end - start) > longest_consecutive_days:
|
||||
longest_consecutive_days = current_streak
|
||||
longest_range = (start, end)
|
||||
return {"days": longest_consecutive_days.days + 1, "dates": longest_range}
|
||||
|
||||
|
||||
def streak_bruteforce(datelist: list[date]) -> dict[str, int | tuple[date, date]]:
|
||||
if (datelist_length := len(datelist)) == 0:
|
||||
raise ValueError("Number of dates in the list is 0.")
|
||||
datelist.sort()
|
||||
current_streak = 1
|
||||
current_start = datelist[0]
|
||||
current_end = datelist[0]
|
||||
current_date = datelist[0]
|
||||
highest_streak = 1
|
||||
highest_streak_daterange = (current_start, current_end)
|
||||
|
||||
def update_highest_streak():
|
||||
nonlocal highest_streak, highest_streak_daterange
|
||||
if current_streak > highest_streak:
|
||||
highest_streak = current_streak
|
||||
highest_streak_daterange = (current_start, current_end)
|
||||
|
||||
def reset_streak():
|
||||
nonlocal current_start, current_end, current_streak
|
||||
current_start = current_end = current_date
|
||||
current_streak = 1
|
||||
|
||||
def increment_streak():
|
||||
nonlocal current_end, current_streak
|
||||
current_end = current_date
|
||||
current_streak += 1
|
||||
|
||||
for i, datelist_item in enumerate(datelist, start=1):
|
||||
current_date = datelist_item
|
||||
if current_date == current_start or current_date == current_end:
|
||||
continue
|
||||
if current_date - timedelta(1) != current_end and i != datelist_length:
|
||||
update_highest_streak()
|
||||
reset_streak()
|
||||
elif current_date - timedelta(1) == current_end and i == datelist_length:
|
||||
increment_streak()
|
||||
update_highest_streak()
|
||||
else:
|
||||
increment_streak()
|
||||
return {"days": highest_streak, "dates": highest_streak_daterange}
|
||||
|
||||
|
||||
def available_stats_year_range():
|
||||
return range(datetime.now().year, 1999, -1)
|
||||
|
||||
+102
-145
@@ -1,36 +1,97 @@
|
||||
import operator
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from functools import reduce, wraps
|
||||
from typing import Any, Callable, Generator, Literal, TypeVar
|
||||
from urllib.parse import urlencode
|
||||
from random import choices
|
||||
from string import ascii_lowercase
|
||||
from typing import Any, Callable
|
||||
|
||||
from django.core.paginator import Page, Paginator
|
||||
from django.db.models import Q
|
||||
from django.http import HttpRequest
|
||||
from django.shortcuts import redirect
|
||||
from django.template.loader import render_to_string
|
||||
from django.urls import NoReverseMatch, reverse
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
|
||||
def paginate(request: HttpRequest, queryset, per_page: int = 10):
|
||||
"""Standard list-view pagination.
|
||||
|
||||
Reads ``page`` and ``limit`` from the query string (``limit=0`` disables
|
||||
pagination) and returns ``(object_list, page_obj, elided_page_range)`` ready
|
||||
to hand to ``paginated_table_content``.
|
||||
"""
|
||||
page_number = request.GET.get("page", 1)
|
||||
limit = int(request.GET.get("limit", per_page))
|
||||
object_list = queryset
|
||||
page_obj: Page | None = None
|
||||
if limit != 0:
|
||||
page_obj = Paginator(queryset, limit).get_page(page_number)
|
||||
object_list = page_obj.object_list
|
||||
elided_page_range = (
|
||||
page_obj.paginator.get_elided_page_range(page_number, on_each_side=1, on_ends=1)
|
||||
if page_obj
|
||||
else None
|
||||
def Popover(
|
||||
wrapped_content: str,
|
||||
popover_content: str = "",
|
||||
) -> str:
|
||||
id = randomid()
|
||||
if popover_content == "":
|
||||
popover_content = wrapped_content
|
||||
content = f"<span data-popover-target={id}>{wrapped_content}</span>"
|
||||
result = mark_safe(
|
||||
str(content)
|
||||
+ render_to_string(
|
||||
"cotton/popover.html",
|
||||
{
|
||||
"id": id,
|
||||
"slot": popover_content,
|
||||
},
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
HTMLAttribute = tuple[str, str]
|
||||
HTMLTag = str
|
||||
|
||||
|
||||
def Component(
|
||||
attributes: list[HTMLAttribute] = [],
|
||||
children: list[HTMLTag] | HTMLTag = [],
|
||||
template: str = "",
|
||||
tag_name: str = "",
|
||||
) -> HTMLTag:
|
||||
if not tag_name and not template:
|
||||
raise ValueError("One of template or tag_name is required.")
|
||||
if isinstance(children, str):
|
||||
children = [children]
|
||||
childrenBlob = "\n".join(children)
|
||||
attributesList = [f'{name} = "{value}"' for name, value in attributes]
|
||||
attributesBlob = " ".join(attributesList)
|
||||
tag: str = ""
|
||||
if tag_name != "":
|
||||
tag = f"<a {attributesBlob}>{childrenBlob}</a>"
|
||||
elif template != "":
|
||||
tag = render_to_string(
|
||||
template,
|
||||
{name: value for name, value in attributes} | {"slot": "\n".join(children)},
|
||||
)
|
||||
return mark_safe(tag)
|
||||
|
||||
|
||||
def A(
|
||||
attributes: list[HTMLAttribute] = [],
|
||||
children: list[HTMLTag] | HTMLTag = [],
|
||||
url: str | Callable[..., Any] = "",
|
||||
):
|
||||
"""
|
||||
Returns the HTML tag "a".
|
||||
"url" can either be:
|
||||
- URL (string)
|
||||
- path name passed to reverse() (string)
|
||||
- function
|
||||
"""
|
||||
additional_attributes = []
|
||||
if url:
|
||||
if type(url) is str:
|
||||
try:
|
||||
url_result = reverse(url)
|
||||
except NoReverseMatch:
|
||||
url_result = url
|
||||
elif callable(url):
|
||||
url_result = url()
|
||||
else:
|
||||
raise TypeError("'url' is neither str nor function.")
|
||||
additional_attributes = [("href", url_result)]
|
||||
return Component(
|
||||
tag_name="a", attributes=attributes + additional_attributes, children=children
|
||||
)
|
||||
|
||||
|
||||
def Button(
|
||||
attributes: list[HTMLAttribute] = [],
|
||||
children: list[HTMLTag] | HTMLTag = [],
|
||||
):
|
||||
return Component(
|
||||
template="cotton/button.html", attributes=attributes, children=children
|
||||
)
|
||||
return object_list, page_obj, elided_page_range
|
||||
|
||||
|
||||
def safe_division(numerator: int | float, denominator: int | float) -> int | float:
|
||||
@@ -65,126 +126,22 @@ def safe_getattr(obj: object, attr_chain: str, default: Any | None = None) -> ob
|
||||
return obj
|
||||
|
||||
|
||||
def truncate_(input_string: str, length: int = 30, ellipsis: str = "…") -> str:
|
||||
def truncate(input_string: str, length: int = 30, ellipsis: str = "…") -> str:
|
||||
return (
|
||||
(f"{input_string[: length - len(ellipsis)].rstrip()}{ellipsis}")
|
||||
if len(input_string) > length
|
||||
(f"{input_string[:length-len(ellipsis)]}{ellipsis}")
|
||||
if len(input_string) > 30
|
||||
else input_string
|
||||
)
|
||||
|
||||
|
||||
def truncate(
|
||||
input_string: str, length: int = 30, ellipsis: str = "…", endpart: str = ""
|
||||
) -> str:
|
||||
max_content_length = length - len(endpart)
|
||||
if max_content_length < 0:
|
||||
raise ValueError("Length cannot be shorter than the length of endpart.")
|
||||
|
||||
if len(input_string) > max_content_length:
|
||||
return f"{input_string[: max_content_length - len(ellipsis)].rstrip()}{ellipsis}{endpart}"
|
||||
|
||||
return (
|
||||
f"{input_string}{endpart}"
|
||||
if len(input_string) + len(endpart) <= length
|
||||
else f"{input_string[: length - len(ellipsis) - len(endpart)].rstrip()}{ellipsis}{endpart}"
|
||||
)
|
||||
def truncate_with_popover(input_string: str) -> str:
|
||||
if (truncated := truncate(input_string)) != input_string:
|
||||
print(f"Not the same after: {truncated=}")
|
||||
return Popover(wrapped_content=truncated, popover_content=input_string)
|
||||
else:
|
||||
print("Strings are the same!")
|
||||
return input_string
|
||||
|
||||
|
||||
T = TypeVar("T", str, int, date)
|
||||
|
||||
|
||||
def generate_split_ranges(
|
||||
value_list: list[T], split_points: list[T]
|
||||
) -> Generator[tuple[T, T], None, None]:
|
||||
for x in range(0, len(split_points) + 1):
|
||||
if x == 0:
|
||||
start = 0
|
||||
elif x >= len(split_points):
|
||||
start = value_list.index(split_points[x - 1]) + 1
|
||||
else:
|
||||
start = value_list.index(split_points[x - 1]) + 1
|
||||
try:
|
||||
end = value_list.index(split_points[x])
|
||||
except IndexError:
|
||||
end = len(value_list)
|
||||
yield (value_list[start], value_list[end - 1])
|
||||
|
||||
|
||||
def format_float_or_int(number: int | float):
|
||||
return int(number) if float(number).is_integer() else f"{number:03.2f}"
|
||||
|
||||
|
||||
OperatorType = Literal["|", "&"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class FilterEntry:
|
||||
condition: Q
|
||||
operator: OperatorType = "&"
|
||||
|
||||
|
||||
def build_dynamic_filter(
|
||||
filters: list[FilterEntry | Q], default_operator: OperatorType = "&"
|
||||
):
|
||||
"""
|
||||
Constructs a Django Q filter from a list of filter conditions.
|
||||
|
||||
Args:
|
||||
filters (list): A list where each item is either:
|
||||
- A Q object (default AND logic applied)
|
||||
- A tuple of (Q object, operator) where operator is "|" (OR) or "&" (AND)
|
||||
|
||||
Returns:
|
||||
Q: A combined Q object that can be passed to Django's filter().
|
||||
"""
|
||||
op_map: dict[OperatorType, Callable[[Q, Q], Q]] = {
|
||||
"|": operator.or_,
|
||||
"&": operator.and_,
|
||||
}
|
||||
|
||||
# Convert all plain Q objects into (Q, "&") for default AND behavior
|
||||
processed_filters = [
|
||||
FilterEntry(f, default_operator) if isinstance(f, Q) else f for f in filters
|
||||
]
|
||||
|
||||
# Reduce with dynamic operators
|
||||
return reduce(
|
||||
lambda combined_filters, filter: op_map[filter.operator](
|
||||
combined_filters, filter.condition
|
||||
),
|
||||
processed_filters,
|
||||
Q(),
|
||||
)
|
||||
|
||||
|
||||
def redirect_to(default_view: str, *default_args):
|
||||
"""
|
||||
A decorator that redirects the user back to the referring page or a default view if no 'next' parameter is provided.
|
||||
|
||||
:param default_view: The name of the default view to redirect to if 'next' is missing.
|
||||
:param default_args: Any arguments required for the default view.
|
||||
"""
|
||||
|
||||
def decorator(view_func):
|
||||
@wraps(view_func)
|
||||
def wrapped_view(request: HttpRequest, *args, **kwargs):
|
||||
next_url = request.GET.get("next")
|
||||
if not next_url:
|
||||
from django.urls import (
|
||||
reverse, # Import inside function to avoid circular imports
|
||||
)
|
||||
|
||||
next_url = reverse(default_view, args=default_args)
|
||||
|
||||
# Execute the original view logic for its side effects, then
|
||||
# redirect to `next_url` instead of returning its response.
|
||||
view_func(request, *args, **kwargs)
|
||||
return redirect(next_url)
|
||||
|
||||
return wrapped_view
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def add_next_param_to_url(url: str, nexturl: str) -> str:
|
||||
return f"{url}?{urlencode({'next': nexturl})}"
|
||||
def randomid(seed: str = "", length: int = 10) -> str:
|
||||
return seed + "".join(choices(ascii_lowercase, k=length))
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
from datetime import datetime
|
||||
|
||||
import requests
|
||||
|
||||
url = "https://data.kurzy.cz/json/meny/b[6]den[{0}].json"
|
||||
date_format = "%Y%m%d"
|
||||
years = range(2000, datetime.now().year + 1)
|
||||
dates = [
|
||||
datetime.strftime(datetime(day=1, month=1, year=year), format=date_format)
|
||||
for year in years
|
||||
]
|
||||
for date in dates:
|
||||
final_url = url.format(date)
|
||||
year = date[:4]
|
||||
response = requests.get(final_url)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if kurzy := data.get("kurzy"):
|
||||
with open("output.yaml", mode="a") as o:
|
||||
rates = [
|
||||
f"""
|
||||
- model: games.exchangerate
|
||||
fields:
|
||||
currency_from: {currency_name}
|
||||
currency_to: CZK
|
||||
year: {year}
|
||||
rate: {kurzy.get(currency_name, {}).get("dev_stred", 0)}
|
||||
"""
|
||||
for currency_name in ["EUR", "USD", "CNY"]
|
||||
if kurzy.get(currency_name)
|
||||
]
|
||||
o.writelines(rates)
|
||||
# time.sleep(0.5)
|
||||
@@ -1,65 +0,0 @@
|
||||
import sys
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
def load_yaml(filename):
|
||||
with open(filename, "r", encoding="utf-8") as file:
|
||||
return yaml.safe_load(file) or []
|
||||
|
||||
|
||||
def save_yaml(filename, data):
|
||||
with open(filename, "w", encoding="utf-8") as file:
|
||||
yaml.safe_dump(data, file, allow_unicode=True, default_flow_style=False)
|
||||
|
||||
|
||||
def extract_existing_combinations(data):
|
||||
return {
|
||||
(
|
||||
entry["fields"]["currency_from"],
|
||||
entry["fields"]["currency_to"],
|
||||
entry["fields"]["year"],
|
||||
)
|
||||
for entry in data
|
||||
if entry["model"] == "games.exchangerate"
|
||||
}
|
||||
|
||||
|
||||
def filter_new_entries(existing_combinations, additional_files):
|
||||
new_entries = []
|
||||
|
||||
for filename in additional_files:
|
||||
data = load_yaml(filename)
|
||||
for entry in data:
|
||||
if entry["model"] == "games.exchangerate":
|
||||
key = (
|
||||
entry["fields"]["currency_from"],
|
||||
entry["fields"]["currency_to"],
|
||||
entry["fields"]["year"],
|
||||
)
|
||||
if key not in existing_combinations:
|
||||
new_entries.append(entry)
|
||||
|
||||
return new_entries
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 3:
|
||||
print("Usage: script.py example.yaml additions1.yaml [additions2.yaml ...]")
|
||||
sys.exit(1)
|
||||
|
||||
example_file = sys.argv[1]
|
||||
additional_files = sys.argv[2:]
|
||||
output_file = "filtered_output.yaml"
|
||||
|
||||
existing_data = load_yaml(example_file)
|
||||
existing_combinations = extract_existing_combinations(existing_data)
|
||||
|
||||
new_entries = filter_new_entries(existing_combinations, additional_files)
|
||||
|
||||
save_yaml(output_file, new_entries)
|
||||
print(f"Filtered data saved to {output_file}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,24 +0,0 @@
|
||||
FROM python:3.13-slim
|
||||
|
||||
# Set up environment
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
WORKDIR /workspace
|
||||
|
||||
# Install Poetry
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
make \
|
||||
npm \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN curl -sSL https://install.python-poetry.org | python3 -
|
||||
ENV PATH="/root/.local/bin:$PATH"
|
||||
|
||||
# Copy pyproject.toml and poetry.lock for dependency installation
|
||||
COPY pyproject.toml poetry.lock* ./
|
||||
RUN poetry install --no-root
|
||||
|
||||
# Copy the rest of the application code
|
||||
COPY . .
|
||||
|
||||
# Set up Django development server
|
||||
EXPOSE 8000
|
||||
+21
-12
@@ -1,21 +1,30 @@
|
||||
---
|
||||
services:
|
||||
timetracker:
|
||||
image: ${REGISTRY_URL:-registry.kucharczyk.xyz}/timetracker:1.7.0
|
||||
backend:
|
||||
image: registry.kucharczyk.xyz/timetracker
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: timetracker
|
||||
environment:
|
||||
- TZ=${TZ:-Europe/Prague}
|
||||
- CSRF_TRUSTED_ORIGINS=https://tracker.kucharczyk.xyz
|
||||
- PUID=${PUID:-1000}
|
||||
- PGID=${PGID:-100}
|
||||
- DATA_DIR=${DATA_DIR:-/home/timetracker/app/data}
|
||||
ports:
|
||||
- "${TIMETRACKER_EXTERNAL_PORT:-8000}:8000"
|
||||
- TZ=Europe/Prague
|
||||
- CSRF_TRUSTED_ORIGINS="https://tracker.kucharczyk.xyz"
|
||||
user: "1000"
|
||||
volumes:
|
||||
- "./data:/home/timetracker/app/data"
|
||||
- "${DOCKER_STORAGE_PATH:-/tmp}/timetracker/backups:/home/timetracker/app/games/fixtures/backups"
|
||||
- "static-files:/var/www/django/static"
|
||||
- "$PWD/db.sqlite3:/home/timetracker/app/db.sqlite3"
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
image: caddy
|
||||
volumes:
|
||||
- "static-files:/usr/share/caddy:ro"
|
||||
- "$PWD/Caddyfile:/etc/caddy/Caddyfile"
|
||||
ports:
|
||||
- "8000:8000"
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
volumes:
|
||||
static-files:
|
||||
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
# Game & Purchase Status Definitions
|
||||
|
||||
## Game Statuses
|
||||
|
||||
Games have a `status` field with the following values:
|
||||
|
||||
| Status | Code | Description |
|
||||
|--------|------|-------------|
|
||||
| **Unplayed** | `u` | Game was purchased but never played |
|
||||
| **Played** | `p` | Game was played but not yet finished |
|
||||
| **Finished** | `f` | Game has been completed |
|
||||
| **Retired** | `r` | Game was intentionally retired (e.g., no longer accessible, collector's item) |
|
||||
| **Abandoned** | `a` | Game was played but the user gave up on it |
|
||||
|
||||
**Setting game status:**
|
||||
- Users explicitly set game status via the UI (finish/drop purchase buttons, status change form)
|
||||
- Status changes are tracked in `GameStatusChange` model
|
||||
- Refunding a purchase always marks its games as abandoned
|
||||
|
||||
---
|
||||
|
||||
## Purchase-Level Status Concepts
|
||||
|
||||
These concepts determine whether a purchase appears in the "unfinished" or "dropped" lists in stats views.
|
||||
|
||||
### Finished
|
||||
|
||||
A purchase is considered **finished** when:
|
||||
|
||||
```
|
||||
Game.status == "f" OR Purchase.games.* has a PlayEvent with an ended date
|
||||
```
|
||||
|
||||
Either signal indicates the game is complete:
|
||||
- **Explicit**: User marked the game as finished (`Game.status = "f"`)
|
||||
- **Implicit**: A PlayEvent exists with `ended` date set (data-driven)
|
||||
|
||||
This uses **OR** logic during a transition period. Later, these signals should be kept in sync so only one source of truth is needed.
|
||||
|
||||
### Dropped
|
||||
|
||||
A purchase is considered **dropped** when:
|
||||
|
||||
```
|
||||
Game.status == "a" OR Purchase.date_refunded IS NOT NULL
|
||||
```
|
||||
|
||||
Either signal indicates the user no longer has an active interest in the game:
|
||||
- **Explicit**: User marked the game as abandoned (`Game.status = "a"`)
|
||||
- **Implicit**: User refunded the purchase (which automatically sets games to abandoned)
|
||||
|
||||
Note: Refunding a purchase always marks its games as abandoned. There is no option to refund without abandoning.
|
||||
|
||||
---
|
||||
|
||||
## Unfinished vs. Dropped
|
||||
|
||||
The stats views categorize purchases into **unfinished** and **dropped** lists.
|
||||
|
||||
### Unfinished
|
||||
|
||||
A purchase is **unfinished** when:
|
||||
1. It was purchased in the relevant time period (this year for yearly stats, all time for all-time stats)
|
||||
2. It was NOT refunded (only counts toward unfinished/backlog)
|
||||
3. It is NOT finished (per the finished definition above)
|
||||
4. It is NOT dropped (per the dropped definition above)
|
||||
5. It is NOT infinite (subscription, etc.)
|
||||
6. It IS a game or DLC (not season passes or battle passes)
|
||||
|
||||
**Unfinished = Active backlog** — games the user may still play.
|
||||
|
||||
### Dropped
|
||||
|
||||
A purchase is **dropped** when:
|
||||
1. It was purchased in the relevant time period
|
||||
2. It is NOT finished (per the finished definition above)
|
||||
3. It matches at least one dropped signal (per the dropped definition above)
|
||||
4. It is NOT infinite
|
||||
5. It IS a game or DLC
|
||||
|
||||
**Dropped = Terminal state** — games the user has given up on or refunded.
|
||||
|
||||
### Summary Table
|
||||
|
||||
| Category | Includes Refunded? | Key Condition |
|
||||
|----------|-------------------|---------------|
|
||||
| **Unfinished** | No | NOT finished, NOT dropped |
|
||||
| **Dropped** | Yes | Finished OR Abandoned/Retired |
|
||||
| **Refunded** | Yes | `date_refunded IS NOT NULL` |
|
||||
| **Infinite** | Yes | `infinite = True` |
|
||||
|
||||
---
|
||||
|
||||
## Query Patterns
|
||||
|
||||
### Checking if a game is finished
|
||||
|
||||
```python
|
||||
game.finished() # Returns True if status="f" or has PlayEvent with ended date
|
||||
```
|
||||
|
||||
### Checking if a game is abandoned
|
||||
|
||||
```python
|
||||
game.abandoned() # Returns True if status="a"
|
||||
```
|
||||
|
||||
### Getting finished purchases
|
||||
|
||||
```python
|
||||
Purchase.objects.finished() # All purchases where games are finished
|
||||
```
|
||||
|
||||
### Getting dropped purchases
|
||||
|
||||
```python
|
||||
Purchase.objects.dropped() # All purchases that are abandoned or refunded
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Transition State
|
||||
|
||||
The system uses **OR logic** for both finished and dropped to catch any mismatch between explicit user actions and data signals:
|
||||
|
||||
- **Finished**: `status="f" OR PlayEvent.ended`
|
||||
- **Dropped**: `status="a" OR date_refunded`
|
||||
|
||||
This bridges the gap between the old model (where `date_finished` and `date_dropped` were on the Purchase model) and the new model (where `Game.status` and `PlayEvent` are the sources of truth).
|
||||
|
||||
**Future:** These signals should be kept in sync. For example:
|
||||
- Setting `Game.status = "f"` should create a PlayEvent with `ended` date
|
||||
- When the sync is reliable, the OR can be simplified to a single check
|
||||
|
||||
Note: Refunding a purchase always automatically sets its games' status to Abandoned. This is not optional — there is no way to refund without abandoning.
|
||||
|
||||
---
|
||||
|
||||
## Edge Cases
|
||||
|
||||
### Unplayed games
|
||||
- Unplayed games (`status="u"`) are considered **unfinished**, not dropped
|
||||
- They appear in the unfinished/backlog list since they are still games the user may play
|
||||
- Unplayed games that are refunded DO count as **dropped** (refund signal overrides)
|
||||
|
||||
### Multiple games per purchase
|
||||
- A purchase can have multiple games via `Purchase.games` (many-to-many)
|
||||
- A purchase is finished if ANY of its games is finished
|
||||
- A purchase is dropped if ANY of its games is abandoned OR the purchase itself is refunded
|
||||
|
||||
### PlayEvents without ended date
|
||||
- A PlayEvent with `started` but no `ended` does NOT count as finished
|
||||
- This represents a game that was started but not completed
|
||||
|
||||
### Retired games
|
||||
- Retired games (`status="r"`) are considered **dropped**
|
||||
- Retirement is for games the user intentionally removed from their collection (collector's items, no longer accessible, etc.)
|
||||
@@ -1,398 +0,0 @@
|
||||
# Form Overhaul Plan
|
||||
|
||||
> Last updated: 2026-05-12
|
||||
> Status: Decided — awaiting implementation
|
||||
>
|
||||
> **Decisions made:**
|
||||
> - All forms (simple and complex) get section headers for consistency
|
||||
> - Two-column layout uses **flexbox** (auto-reflow on different screen sizes)
|
||||
> - `cotton/layouts/add.html` enhanced with **Option A**: `c-section` component slots
|
||||
> - `add_purchase.html` dual-submit **simplified** — remove `<tr><td>`, use same `c-button` pattern as `add_game.html`
|
||||
> - GameStatusChange delete confirmation **converted to modal** (via HTMX trigger)
|
||||
|
||||
## Goal
|
||||
|
||||
Modernize all forms and form-like elements to align with Flowbite design, improve visual consistency, and adopt responsive multi-column layouts for complex forms.
|
||||
|
||||
---
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
### Form Pages (add/edit)
|
||||
|
||||
All use `cotton/layouts/add.html` — single column, `max-w-xl`, `form.as_div`:
|
||||
|
||||
| Page | Form | Fields | Complexity |
|
||||
|---|---|---|---|
|
||||
| Game | `GameForm` | 7 fields: name, sort_name, platform, year, year_orig, status, mastered, wikidata | Medium |
|
||||
| Purchase | `PurchaseForm` | 11 fields: games, platform, dates, price, currency, type, ownership, related, infinite, name | High |
|
||||
| Session | `SessionForm` | 8 fields: game, timestamps, duration, emulated, device, note, checkbox (custom rendering) | High |
|
||||
| Platform | `PlatformForm` | 3 fields: name, icon, group | Low |
|
||||
| Device | `DeviceForm` | 2 fields: name, type | Low |
|
||||
| PlayEvent | `PlayEventForm` | 5 fields: game, dates, note, checkbox | Low |
|
||||
| GameStatusChange | `GameStatusChangeForm` | 4 fields | Low |
|
||||
|
||||
### Other Form-Like Elements
|
||||
|
||||
| Element | Template | Notes |
|
||||
|---|---|---|
|
||||
| Login | `registration/login.html` | Flowbite card, already good |
|
||||
| Search | `cotton/search_field.html` | Reusable, already good |
|
||||
| Delete Game | `partials/delete_game_confirmation.html` | Inline modal, inconsistent button layout |
|
||||
| Delete PlayEvent | `gamestatuschange_confirm_delete.html` | Full-page form, no modal |
|
||||
| Refund Purchase | `partials/refund_purchase_confirmation.html` | Inline modal, inconsistent button layout |
|
||||
| Stats Year Select | `stats.html` | Manual `<select>`, no Flowbite styling |
|
||||
| Status Selector | `partials/gamestatus_selector.html` | Alpine.js dropdown, old Tailwind classes |
|
||||
| Device Selector | `partials/sessiondevice_selector.html` | Alpine.js dropdown, old Tailwind classes |
|
||||
|
||||
---
|
||||
|
||||
## Issues to Fix
|
||||
|
||||
### P0: Broken/Inconsistent
|
||||
|
||||
1. ~~**`modal.html` has a missing `<form>` tag** (line 13: `</form>` with no opening `<form>`)** — *Resolved: rewritten as proper component with form wrapping support, body + footer slots, reusable `close_button` component. Ready for standardizing all inline modals later.*
|
||||
2. **Delete confirmations are inconsistent** — three different patterns (inline modal, full-page form, inline modal)
|
||||
3. **`.errorlist` CSS** has fixed `width: 300px` — too narrow, breaks on mobile. *No scoping needed: Django auto-applies `.errorlist` to form error output only, never used explicitly in templates.*
|
||||
4. **`add_purchase.html` has `<tr><td>`** in a `c-slot` that renders inside a `<div>` — semantic mismatch. **Decision: simplify dual-submit** to match `add_game.html` pattern (use `<c-button>` only).
|
||||
5. **`#button-container` and `.basic-button` in `input.css`** — legacy patterns, unused or dead code
|
||||
|
||||
### P1: Layout & UX
|
||||
|
||||
6. **All add/edit forms are single-column** — PurchaseForm (11 fields) and GameForm (7 fields) would benefit from multi-column
|
||||
7. **No field grouping** — related fields listed flat without visual hierarchy
|
||||
8. **Stats year `<select>`** has no Flowbite styling
|
||||
9. **Search field** is not wrapped in `<form method="get">` — no native clear-on-Enter behavior
|
||||
|
||||
### P2: Styling Consistency
|
||||
|
||||
10. **Status/device selectors** use old Tailwind v3 patterns (`rounded-sm`, `shadow-2xs`, `border-gray-200` without explicit color)
|
||||
11. **`navbar.html` buttons** use `rounded-sm` instead of `rounded-base`
|
||||
12. **`simple_table.html` pagination buttons** use `rounded-s-lg`/`rounded-e-lg` — could be simplified
|
||||
|
||||
---
|
||||
|
||||
## Proposed Improvements
|
||||
|
||||
### 1. Two-Column Layout for Complex Forms (Flexbox)
|
||||
|
||||
**Scope**: `GameForm`, `PurchaseForm`, `PlayEventForm`, `SessionForm`
|
||||
|
||||
Use **flexbox** with wrap behavior so fields auto-reflow on different screen sizes. No fixed column count — fields sit side-by-side on `md:`+ and wrap naturally on smaller screens.
|
||||
|
||||
#### GameForm Layout
|
||||
```
|
||||
┌──────────────────────────────────┐
|
||||
│ Game Details │
|
||||
│ ┌──────────────────┬───────────┐ │
|
||||
│ │ Name │ Platform │ │
|
||||
│ │ Sort Name │ Year │ │
|
||||
│ │ Original Year │ Wikidata │ │
|
||||
│ └──────────────────┴───────────┘ │
|
||||
│ Status │
|
||||
│ ┌──────────────────┬───────────┐ │
|
||||
│ │ Status │ Mastered │ │
|
||||
│ └──────────────────┴───────────┘ │
|
||||
│ [Submit] │
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### PurchaseForm Layout (simplified)
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ Purchase Details │
|
||||
│ ┌──────────────────────┬───────────────┐ │
|
||||
│ │ Games (multi-select) │ Platform │ │
|
||||
│ │ Type │ Ownership │ │
|
||||
│ │ Name │ Related Purch │ │
|
||||
│ └──────────────────────┴───────────────┘ │
|
||||
│ Dates │ Price │
|
||||
│ ┌───────────────┬──────┴───────────────┐ │
|
||||
│ │ Date Purch │ Price Curr │ │
|
||||
│ │ Date Refund │ Infinite [ ] │ │
|
||||
│ └───────────────┴──────────────────────┘ │
|
||||
│ [Submit] [Submit + Session] │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Implementation**: `c-section` component accepts `columns="2"` (or `"3"`) which applies `flex flex-wrap gap-4 [&>div]:w-[calc(50%-0.5rem)]` on md+ screens. Each field wraps in a `<div>` inside the section slot.
|
||||
|
||||
**Decision**: Dual-submit in `add_purchase.html` simplified — remove `<tr><td>`, use same `<c-button>` pattern as `add_game.html`.
|
||||
|
||||
### 2. Field Grouping with Card Sections
|
||||
|
||||
**Decision**: ALL forms get section headers for consistency (not just complex forms).
|
||||
|
||||
Group related fields with section headings and subtle borders/backgrounds:
|
||||
|
||||
```html
|
||||
<c-section title="Game Details" columns="2">
|
||||
{{ form.name }}
|
||||
{{ form.platform }}
|
||||
{{ form.sort_name }}
|
||||
{{ form.year_released }}
|
||||
</c-section>
|
||||
```
|
||||
|
||||
Each section renders as:
|
||||
```html
|
||||
<fieldset class="form-section p-5 border-t border-default-medium bg-neutral-primary-soft/30 first-of-type:border-t-0 first-of-type:pt-0">
|
||||
<h3 class="text-sm font-medium text-heading uppercase mb-4">Section Title</h3>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<!-- fields in <div> wrappers, each taking calc(50% - 0.5rem) on md+ -->
|
||||
</div>
|
||||
</fieldset>
|
||||
```
|
||||
|
||||
Each section gets:
|
||||
- Subtle background (`bg-neutral-primary-soft/30`)
|
||||
- Top border with spacing (`border-t border-default-medium`)
|
||||
- Section heading (`text-sm font-medium text-heading uppercase mb-4`)
|
||||
- Flexbox gap for responsive field reflow
|
||||
|
||||
### 1b. `c-section` Component Specification
|
||||
|
||||
New cotton component for the `cotton/` directory:
|
||||
|
||||
```python
|
||||
# games/templates/cotton/section.py (or inline in components.py)
|
||||
from common.components import Div
|
||||
|
||||
def Section(title: str = "", columns: str = "1", children: str = "") -> SafeText:
|
||||
"""Renders a form field section with optional multi-column flexbox layout.
|
||||
|
||||
Args:
|
||||
title: Section heading (renders as uppercase label)
|
||||
columns: "1" (default), "2", or "3" — target column count on md+ screens
|
||||
children: Field markup (each field wrapped in <div> for flex wrapping)
|
||||
"""
|
||||
col_class = {
|
||||
"1": "flex flex-col",
|
||||
"2": "flex flex-wrap gap-4 [&>div]:w-[calc(50%-0.5rem)]",
|
||||
"3": "flex flex-wrap gap-4 [&>div]:w-[calc(33.333%-0.67rem)]",
|
||||
}.get(columns, "flex flex-col")
|
||||
|
||||
return Div(
|
||||
cls=f"form-section p-5 border-t border-default-medium bg-neutral-primary-soft/30 first-of-type:border-t-0 first-of-type:pt-0",
|
||||
children=f"""
|
||||
<h3 class="text-sm font-medium text-heading uppercase mb-4">{title}</h3>
|
||||
<div class="{col_class}">{children}</div>
|
||||
"""
|
||||
)
|
||||
```
|
||||
|
||||
**Template usage:**
|
||||
```django
|
||||
{# add_game.html #}
|
||||
<c-layouts.add title="New Game">
|
||||
<c-section title="Game Details" columns="2">
|
||||
<div>{{ form.name }}</div>
|
||||
<div>{{ form.platform }}</div>
|
||||
<div>{{ form.sort_name }}</div>
|
||||
<div>{{ form.year_released }}</div>
|
||||
<div>{{ form.original_year_released }}</div>
|
||||
<div>{{ form.wikidata }}</div>
|
||||
</c-section>
|
||||
<c-section title="Status" columns="2">
|
||||
<div>{{ form.status }}</div>
|
||||
<div>{{ form.mastered }}</div>
|
||||
</c-section>
|
||||
</c-layouts.add>
|
||||
```
|
||||
|
||||
**`cotton/layouts/add.html` changes:**
|
||||
- Remove hardcoded `{{ form.as_div }}` rendering
|
||||
- Accept optional `sections` variable (list of rendered `c-section` output)
|
||||
- If `sections` provided, render them; otherwise fall back to `{{ form.as_div }}` for simple forms
|
||||
- Keep `additional_row` slot for dual-submit buttons
|
||||
|
||||
### 3. CSS/Style Fixes
|
||||
|
||||
#### `input.css` changes:
|
||||
```css
|
||||
/* Update errorlist */
|
||||
.errorlist {
|
||||
@apply mt-4 mb-1 pl-3 py-2 bg-red-600 text-slate-200 w-full max-w-xl; /* was w-[300px] */
|
||||
}
|
||||
|
||||
/* Remove: #button-container, .basic-button — unused legacy */
|
||||
|
||||
/* Remove: .flowbite-input — custom class is code smell with Tailwind */
|
||||
/* Remove: flowbite-input @apply block (line 229-234) */
|
||||
|
||||
/* Add Flowbite styling for select in stats */
|
||||
#yearSelect {
|
||||
@apply bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand;
|
||||
}
|
||||
```
|
||||
|
||||
**Important**: The styling previously provided by `.flowbite-input` must be preserved. The element-level `@apply` rules for `input`, `select`, and `textarea` in `input.css` (lines 209-219) already provide equivalent styling. These rules automatically apply to all form inputs without needing custom classes:
|
||||
- `input:not([type="checkbox"])` — background, border, text, radius, focus ring, padding
|
||||
- `select` — same base styling as inputs
|
||||
- `textarea` — same base styling with adjusted padding
|
||||
|
||||
**Files to clean up:**
|
||||
- `common/input.css`: Remove `.flowbite-input` class entirely (lines 229-234)
|
||||
- `games/forms.py`: Remove `flowbite_input_widget` and `flowbite_password_widget` (lines 22-23)
|
||||
- `games/forms.py`: Remove `widget=` from `LoginForm` fields (lines 28, 32) — login template uses explicit Tailwind classes already
|
||||
|
||||
#### Rewrite `modal.html`:
|
||||
- Remove stray `</form>` tag and restructure as a proper cotton component
|
||||
- New `c-modal` component with: `modal_id`, `title`, `size="xl"`, `backdrop_close` variables
|
||||
- `{{ slot }}` (cotton default slot) for body content — passed as children of `<c-modal>`, no block tags needed
|
||||
- `{{ footer }}` (optional named slot via `<c-slot name="footer">`) for non-form buttons
|
||||
- Reusable `cotton/close_button.html` via `<c-close-button />`
|
||||
- Size mapping via inline `{% if %}`: `{% if size == 'sm' %}max-w-sm{% elif size == 'lg' %}max-w-lg{% else %}max-w-xl{% endif %}`
|
||||
- Horizontal centering: `mx-auto` on inner container (matching old modal pattern)
|
||||
- Click-to-dismiss backdrop with `event.stopPropagation()` on inner container
|
||||
- Flowbite-style styling: `rounded-lg shadow`, `bg-white dark:bg-gray-800`, `sm:p-5`
|
||||
|
||||
### 4. Unify Delete Confirmations (All Modal)
|
||||
|
||||
**Decision**: GameStatusChange delete confirmation converted from full-page to modal. All three use the same modal pattern.
|
||||
|
||||
**Target**: All confirmation modals use the same pattern:
|
||||
|
||||
```html
|
||||
<div class="fixed inset-0 bg-black/70 dark:bg-gray-600/50 ...">
|
||||
<div class="relative mx-auto p-6 bg-white dark:bg-gray-900 rounded-lg shadow-lg max-w-md w-full">
|
||||
<h2 class="text-xl font-medium text-center">Confirm Action</h2>
|
||||
<p class="text-center mt-4 text-sm text-body">Are you sure...?</p>
|
||||
{% if details %}
|
||||
<ul class="text-center mt-2 text-sm text-body list-disc list-inside">
|
||||
<li>{{ detail }}</li>
|
||||
</ul>
|
||||
{% endif %}
|
||||
<p class="text-center mt-3 text-sm font-medium text-red-600">This action cannot be undone.</p>
|
||||
<div class="flex gap-3 mt-6">
|
||||
<c-button color="red" class="w-full" type="submit">Delete</c-button>
|
||||
<c-button color="gray" class="w-full">Cancel</c-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- **Delete Game** (`partials/delete_game_confirmation.html`): Update template to match standard pattern
|
||||
- **Delete StatusChange** (`gamestatuschange_confirm_delete.html` → `partials/statuschange_delete_confirmation.html`): Adopt the same 2-view pattern as delete-game.
|
||||
- Add `delete_statuschange_confirmation` view (GET → renders modal partial) + URL before the delete URL
|
||||
- Update `partials/history.html` — add `hx-get="{% url 'games:delete_statuschange_confirmation' change.id %}" hx-target="#global-modal-container"` to the Delete link
|
||||
- Create new `partials/statuschange_delete_confirmation.html` using `<c-modal>`, same structure as `delete_game_confirmation.html` (detail list, red warning text, same button layout, `<c-gamestatus>` badge for old status)
|
||||
- Modify `GameStatusChangeDeleteView` to only handle POST (remove its GET-rendered template)
|
||||
- Delete old `gamestatuschange_confirm_delete.html` after migration
|
||||
- **Refund Purchase** (`partials/refund_purchase_confirmation.html`): Update template to match standard pattern
|
||||
|
||||
### 5. Search Form Enhancement
|
||||
|
||||
Wrap `search_field.html` in proper `<form method="get">`:
|
||||
|
||||
```html
|
||||
<form class="max-w-md mx-auto" method="get" x-data x-on:keydown.escape="this.querySelector('input').value=''; this.submit()">
|
||||
<!-- input + button -->
|
||||
</form>
|
||||
```
|
||||
|
||||
This enables:
|
||||
- Native form submission on Enter
|
||||
- Potential for "clear all" functionality
|
||||
- Proper browser form autofill behavior
|
||||
|
||||
### 6. Status/Device Selector Styling
|
||||
|
||||
Update Alpine.js dropdowns to use consistent button classes:
|
||||
- Replace `rounded-lg` with `rounded-base`
|
||||
- Replace `shadow-2xs` with `shadow-xs`
|
||||
- Standardize border colors with `border-default`
|
||||
- Use `text-heading` / `text-body` for dark mode compatibility
|
||||
|
||||
---
|
||||
|
||||
## Templates That Need Changes
|
||||
|
||||
| Template | Change | Effort |
|
||||
|---|---|---|
|
||||
| `cotton/layouts/add.html` | Add `c-section` component support (title, columns, fields slots) | Medium |
|
||||
| `add_game.html` | Multi-column flexbox layout, section headers | Medium |
|
||||
| `add_purchase.html` | Multi-column flexbox layout, simplify dual-submit, section headers | High |
|
||||
| `add_session.html` | Flexbox layout for timestamps+duration, section headers | Low |
|
||||
| `add_playevent.html` | Flexbox layout, section headers | Low |
|
||||
| `add_platform.html` | Section headers (was flat single-column) | Low |
|
||||
| `add_device.html` | Section headers (was flat single-column) | Low |
|
||||
| `partials/delete_game_confirmation.html` | Standardize to shared modal pattern | Low |
|
||||
| `partials/refund_purchase_confirmation.html` | Standardize to shared modal pattern | Low |
|
||||
| `partials/statuschange_delete_confirmation.html` | New — adopt same 2-view pattern as delete-game (modal, `<c-modal>`, HTMX triggers) | Medium |
|
||||
| `gamestatuschange_confirm_delete.html` | Delete (replaced by new partial) | Trivial |
|
||||
| `cotton/modal.html` | Fix missing `<form>` tag | Low |
|
||||
| `stats.html` | Add Flowbite select styling | Low |
|
||||
| `partials/gamestatus_selector.html` | Update button classes | Low |
|
||||
| `partials/sessiondevice_selector.html` | Update button classes | Low |
|
||||
| `cotton/search_field.html` | Wrap in `<form method="get">` | Low |
|
||||
| `common/input.css` | Remove legacy, fix errorlist, add select styles | Low |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
### Phase 1: Quick Wins (low risk, no breaking changes)
|
||||
|
||||
1. **CSS fixes** (`input.css`) — fix errorlist width, remove legacy `.basic-button` / `#button-container`, add select styles
|
||||
2. ~~**`modal.html` rewrite**~~ — add missing `<form>` tag, conditional form wrapper ✓ Implemented (uses `{{ slot }}` cotton default slot, no `{% partial %}` tags; `size` defaults to `"xl"` with inline `{% if %}` mapping)
|
||||
3. **Delete confirmation standardization** — 3 templates → all modal, same pattern (including GameStatusChange: full-page → modal)
|
||||
4. **Search field enhancement** — wrap in `<form method="get">`
|
||||
5. **Stats select styling** — add Flowbite select classes
|
||||
6. **Selector styling updates** — gamestatus + sessiondevice selectors, consistent classes
|
||||
|
||||
### Phase 2: `c-section` Component
|
||||
|
||||
7. **Create `c-section` component** — title, columns, fields slots
|
||||
8. **Update `cotton/layouts/add.html`** — support `sections` variable, fallback to `form.as_div`
|
||||
|
||||
### Phase 3: Form Layout Overhaul (largest change)
|
||||
|
||||
9. **`GameForm`** — section headers + 2-col flexbox (`add_game.html`)
|
||||
10. **`PlayEventForm`** — section headers + 2-col flexbox
|
||||
11. **`PurchaseForm`** — section headers + 2/3-col flexbox + simplify dual-submit (`add_purchase.html`)
|
||||
12. **`SessionForm`** — section headers + flexbox for timestamps+duration (custom rendering already exists)
|
||||
13. **Simple forms** — `add_platform.html`, `add_device.html` get section headers (single column)
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- Run `make test` after Phase 1 changes to verify nothing broke
|
||||
- `tests/test_paths_return_200.py` — URL-level smoke tests (186 tests). All views must have a `test_*_returns_200` test. Adding new views requires a corresponding test to prevent `TemplateDoesNotExist` regressions.
|
||||
- CSS changes do not require test changes (no test coverage for rendering), but visual verification is recommended
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
- [x] Simple forms section headers? → **All forms get section headers** for consistency
|
||||
- [x] CSS Grid or Flexbox? → **Flexbox** — auto-reflow on different screen sizes
|
||||
- [x] add.html layout variable? → **Option A** — `c-section` cotton component with `title` and `columns` slots
|
||||
- [x] add_purchase.html dual-submit? → **Simplify** — remove `<tr><td>`, use same `<c-button>` pattern as `add_game.html`
|
||||
- [x] GameStatusChange modal or full-page? → **Modal** — trigger via HTMX, same pattern as delete-game
|
||||
- [x] .flowbite-input class? → **Remove entirely** — rely on element-level `@apply` in `input.css`
|
||||
|
||||
## Decision Summary
|
||||
|
||||
| Question | Decision |
|
||||
|---|---|
|
||||
| Section headers on simple forms | Yes, all forms get them |
|
||||
| Layout approach for multi-column | Flexbox with wrap |
|
||||
| Layout mechanism in add.html | Option A: `c-section` cotton component |
|
||||
| Purchase dual-submit | Simplify — single submit button, same as Game |
|
||||
| GameStatusChange delete | Convert to modal (HTMX-triggered) |
|
||||
| .flowbite-input class | Remove — preserve styling via element-level `@apply` in `input.css` |
|
||||
| `modal.html` component | Rewrite with form wrapping, body + footer slots, reusable close button ✓ Implemented
|
||||
|
||||
## Build Step
|
||||
|
||||
After any CSS changes to `common/input.css`, the compiled output must be rebuilt:
|
||||
|
||||
- **`make css`** — one-shot build: `npx @tailwindcss/cli -i ./common/input.css -o ./games/static/base.css`
|
||||
- **`make dev`** — watch mode: Tailwind rebuilds automatically on every `input.css` save
|
||||
|
||||
Running `make dev` is sufficient for development since it concurrently runs Django and the CSS watcher.
|
||||
Only use `make css` if you only want to rebuild CSS without starting the dev server.
|
||||
|
||||
**Important**: Legacy CSS removals (`.basic-button`, `#button-container`, `.flowbite-input`) will only take effect in the browser after a rebuild. The old compiled `base.css` will still contain them until rebuilt.
|
||||
@@ -1,662 +0,0 @@
|
||||
# Comprehensive Filters Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Implement a comprehensive suite of backend filter classes and filter field expansions across all 6 main models (Game, Session, Purchase, Device, Platform, PlayEvent) using a subquery-based cross-entity approach.
|
||||
|
||||
**Architecture:** We will implement missing filter classes (`DeviceFilter`, `PlatformFilter`, `PlayEventFilter`) in `games/filters.py`. We will extend all filters to support powerful, deeply linked "cross-entity" subqueries (e.g. `GameFilter.session_filter` or `PlatformFilter.game_filter`) which builds robust `Q` objects without causing duplicate join rows in list queries.
|
||||
|
||||
**Tech Stack:** Django, Python dataclasses, Pytest.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Implement New Filter Classes (Device, Platform, PlayEvent)
|
||||
|
||||
**Files:**
|
||||
- Modify: `games/filters.py`
|
||||
- Test: `tests/test_filters.py`
|
||||
|
||||
- [ ] **Step 1: Implement DeviceFilter, PlatformFilter, and PlayEventFilter**
|
||||
|
||||
Add the three new operator filters to `games/filters.py`. Ensure we import all necessary criterion types and add the `parse_device_filter`, `parse_platform_filter`, and `parse_playevent_filter` helper functions at the end of the file.
|
||||
|
||||
```python
|
||||
# Insert new filter imports and classes in games/filters.py
|
||||
|
||||
@dataclass
|
||||
class DeviceFilter(OperatorFilter):
|
||||
"""Filter for the Device model."""
|
||||
|
||||
AND: DeviceFilter | None = None
|
||||
OR: DeviceFilter | None = None
|
||||
NOT: DeviceFilter | None = None
|
||||
|
||||
name: StringCriterion | None = None
|
||||
type: ChoiceCriterion | None = None
|
||||
created_at: StringCriterion | None = None
|
||||
|
||||
# Free-text search
|
||||
search: StringCriterion | None = None
|
||||
|
||||
# Cross-entity: Devices that have sessions matching these criteria
|
||||
session_filter: SessionFilter | None = None
|
||||
|
||||
def to_q(self) -> Q:
|
||||
q = Q()
|
||||
|
||||
if self.name is not None:
|
||||
q &= self.name.to_q("name")
|
||||
if self.type is not None:
|
||||
q &= self.type.to_q("type")
|
||||
if self.created_at is not None:
|
||||
q &= self.created_at.to_q("created_at")
|
||||
|
||||
# Free-text search
|
||||
if self.search is not None and self.search.value:
|
||||
search_q = (
|
||||
Q(name__icontains=self.search.value)
|
||||
| Q(type__icontains=self.search.value)
|
||||
)
|
||||
if self.search.modifier == Modifier.EXCLUDES:
|
||||
search_q = ~search_q
|
||||
q &= search_q
|
||||
|
||||
# Cross-entity filter: session_filter
|
||||
if self.session_filter is not None:
|
||||
from games.models import Session
|
||||
session_q = self.session_filter.to_q()
|
||||
matching_ids = Session.objects.filter(session_q).values_list("device_id", flat=True)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
sub = self.sub_filter()
|
||||
if sub is not None:
|
||||
if self.AND is not None:
|
||||
q &= sub.to_q()
|
||||
elif self.OR is not None:
|
||||
q |= sub.to_q()
|
||||
elif self.NOT is not None:
|
||||
q &= ~sub.to_q()
|
||||
|
||||
return q
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlatformFilter(OperatorFilter):
|
||||
"""Filter for the Platform model."""
|
||||
|
||||
AND: PlatformFilter | None = None
|
||||
OR: PlatformFilter | None = None
|
||||
NOT: PlatformFilter | None = None
|
||||
|
||||
name: StringCriterion | None = None
|
||||
group: StringCriterion | None = None
|
||||
icon: StringCriterion | None = None
|
||||
created_at: StringCriterion | None = None
|
||||
|
||||
# Free-text search
|
||||
search: StringCriterion | None = None
|
||||
|
||||
# Cross-entity
|
||||
game_filter: GameFilter | None = None
|
||||
purchase_filter: PurchaseFilter | None = None
|
||||
|
||||
def to_q(self) -> Q:
|
||||
q = Q()
|
||||
|
||||
if self.name is not None:
|
||||
q &= self.name.to_q("name")
|
||||
if self.group is not None:
|
||||
q &= self.group.to_q("group")
|
||||
if self.icon is not None:
|
||||
q &= self.icon.to_q("icon")
|
||||
if self.created_at is not None:
|
||||
q &= self.created_at.to_q("created_at")
|
||||
|
||||
# Free-text search
|
||||
if self.search is not None and self.search.value:
|
||||
search_q = (
|
||||
Q(name__icontains=self.search.value)
|
||||
| Q(group__icontains=self.search.value)
|
||||
)
|
||||
if self.search.modifier == Modifier.EXCLUDES:
|
||||
search_q = ~search_q
|
||||
q &= search_q
|
||||
|
||||
# Cross-entity filter: game_filter
|
||||
if self.game_filter is not None:
|
||||
from games.models import Game
|
||||
game_q = self.game_filter.to_q()
|
||||
matching_ids = Game.objects.filter(game_q).values_list("platform_id", flat=True)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
# Cross-entity filter: purchase_filter
|
||||
if self.purchase_filter is not None:
|
||||
from games.models import Purchase
|
||||
purchase_q = self.purchase_filter.to_q()
|
||||
matching_ids = Purchase.objects.filter(purchase_q).values_list("platform_id", flat=True)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
sub = self.sub_filter()
|
||||
if sub is not None:
|
||||
if self.AND is not None:
|
||||
q &= sub.to_q()
|
||||
elif self.OR is not None:
|
||||
q |= sub.to_q()
|
||||
elif self.NOT is not None:
|
||||
q &= ~sub.to_q()
|
||||
|
||||
return q
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlayEventFilter(OperatorFilter):
|
||||
"""Filter for the PlayEvent model."""
|
||||
|
||||
AND: PlayEventFilter | None = None
|
||||
OR: PlayEventFilter | None = None
|
||||
NOT: PlayEventFilter | None = None
|
||||
|
||||
game: MultiCriterion | None = None # filters on game_id
|
||||
started: StringCriterion | None = None # date string
|
||||
ended: StringCriterion | None = None # date string
|
||||
days_to_finish: IntCriterion | None = None
|
||||
note: StringCriterion | None = None
|
||||
created_at: StringCriterion | None = None
|
||||
|
||||
# Free-text search
|
||||
search: StringCriterion | None = None
|
||||
|
||||
# Cross-entity: PlayEvents for games matching these criteria
|
||||
game_filter: GameFilter | None = None
|
||||
|
||||
def to_q(self) -> Q:
|
||||
q = Q()
|
||||
|
||||
if self.game is not None:
|
||||
q &= self.game.to_q("game_id")
|
||||
if self.started is not None:
|
||||
q &= self.started.to_q("started")
|
||||
if self.ended is not None:
|
||||
q &= self.ended.to_q("ended")
|
||||
if self.days_to_finish is not None:
|
||||
q &= self.days_to_finish.to_q("days_to_finish")
|
||||
if self.note is not None:
|
||||
q &= self.note.to_q("note")
|
||||
if self.created_at is not None:
|
||||
q &= self.created_at.to_q("created_at")
|
||||
|
||||
# Free-text search
|
||||
if self.search is not None and self.search.value:
|
||||
search_q = (
|
||||
Q(game__name__icontains=self.search.value)
|
||||
| Q(note__icontains=self.search.value)
|
||||
)
|
||||
if self.search.modifier == Modifier.EXCLUDES:
|
||||
search_q = ~search_q
|
||||
q &= search_q
|
||||
|
||||
# Cross-entity filter: game_filter
|
||||
if self.game_filter is not None:
|
||||
from games.models import Game
|
||||
game_q = self.game_filter.to_q()
|
||||
matching_ids = Game.objects.filter(game_q).values_list("id", flat=True)
|
||||
q &= Q(game_id__in=matching_ids)
|
||||
|
||||
sub = self.sub_filter()
|
||||
if sub is not None:
|
||||
if self.AND is not None:
|
||||
q &= sub.to_q()
|
||||
elif self.OR is not None:
|
||||
q |= sub.to_q()
|
||||
elif self.NOT is not None:
|
||||
q &= ~sub.to_q()
|
||||
|
||||
return q
|
||||
|
||||
|
||||
# Add to convenience helpers section:
|
||||
def parse_device_filter(json_str: str) -> DeviceFilter | None:
|
||||
return filter_from_json(DeviceFilter, json_str)
|
||||
|
||||
|
||||
def parse_platform_filter(json_str: str) -> PlatformFilter | None:
|
||||
return filter_from_json(PlatformFilter, json_str)
|
||||
|
||||
|
||||
def parse_playevent_filter(json_str: str) -> PlayEventFilter | None:
|
||||
return filter_from_json(PlayEventFilter, json_str)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run existing tests to verify everything compiles**
|
||||
|
||||
Run: `pytest tests/test_filters.py -v`
|
||||
Expected: All existing tests PASS without issues.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Expand SessionFilter (Duration Fields + Cross-Entity)
|
||||
|
||||
**Files:**
|
||||
- Modify: `games/filters.py:SessionFilter`
|
||||
- Test: `tests/test_filters.py`
|
||||
|
||||
- [ ] **Step 1: Refactor SessionFilter and add new duration fields & device_filter**
|
||||
|
||||
Modify `SessionFilter` to replace `duration_minutes: IntCriterion` with `duration_total_minutes`, `duration_manual_minutes`, and `duration_calculated_minutes`. Add `device_filter: DeviceFilter`.
|
||||
|
||||
Update `to_q()` inside `SessionFilter` to map duration fields correctly to their respective GeneratedFields (`duration_total`, `duration_calculated`) or manual field (`duration_manual`). Use standard Python `timedelta` logic.
|
||||
|
||||
```python
|
||||
# Inside SessionFilter class:
|
||||
duration_total_minutes: IntCriterion | None = None
|
||||
duration_manual_minutes: IntCriterion | None = None
|
||||
duration_calculated_minutes: IntCriterion | None = None
|
||||
|
||||
# Cross-entity: sessions for devices matching these criteria
|
||||
device_filter: DeviceFilter | None = None
|
||||
```
|
||||
|
||||
```python
|
||||
# Helper inside SessionFilter or refactored:
|
||||
def _duration_to_q(self, c: IntCriterion, field: str) -> Q:
|
||||
from datetime import timedelta
|
||||
q = Q()
|
||||
td_val = timedelta(minutes=c.value)
|
||||
m = c.modifier
|
||||
if m == Modifier.EQUALS:
|
||||
q &= Q(
|
||||
**{
|
||||
f"{field}__gte": td_val,
|
||||
f"{field}__lt": timedelta(minutes=c.value + 1),
|
||||
}
|
||||
)
|
||||
elif m == Modifier.NOT_EQUALS:
|
||||
q &= ~Q(
|
||||
**{
|
||||
f"{field}__gte": td_val,
|
||||
f"{field}__lt": timedelta(minutes=c.value + 1),
|
||||
}
|
||||
)
|
||||
elif m == Modifier.GREATER_THAN:
|
||||
q &= Q(**{f"{field}__gt": td_val})
|
||||
elif m == Modifier.LESS_THAN:
|
||||
q &= Q(**{f"{field}__lt": td_val})
|
||||
elif m == Modifier.BETWEEN and c.value2 is not None:
|
||||
lo = timedelta(minutes=min(c.value, c.value2))
|
||||
hi = timedelta(minutes=max(c.value, c.value2))
|
||||
q &= Q(**{f"{field}__gte": lo, f"{f_field}__lte": hi})
|
||||
elif m == Modifier.NOT_BETWEEN and c.value2 is not None:
|
||||
lo = timedelta(minutes=min(c.value, c.value2))
|
||||
hi = timedelta(minutes=max(c.value, c.value2))
|
||||
q &= Q(**{f"{field}__lt": lo}) | Q(**{f"{field}__gt": hi})
|
||||
elif m == Modifier.IS_NULL:
|
||||
q &= Q(**{f"{field}": timedelta(0)})
|
||||
elif m == Modifier.NOT_NULL:
|
||||
q &= ~Q(**{f"{field}": timedelta(0)})
|
||||
return q
|
||||
```
|
||||
|
||||
Then in `to_q()` inside `SessionFilter`:
|
||||
```python
|
||||
if self.duration_total_minutes is not None:
|
||||
q &= self._duration_to_q(self.duration_total_minutes, "duration_total")
|
||||
if self.duration_manual_minutes is not None:
|
||||
q &= self._duration_to_q(self.duration_manual_minutes, "duration_manual")
|
||||
if self.duration_calculated_minutes is not None:
|
||||
q &= self._duration_to_q(self.duration_calculated_minutes, "duration_calculated")
|
||||
|
||||
# Cross-entity filter: device_filter
|
||||
if self.device_filter is not None:
|
||||
from games.models import Device
|
||||
device_q = self.device_filter.to_q()
|
||||
matching_ids = Device.objects.filter(device_q).values_list("id", flat=True)
|
||||
q &= Q(device_id__in=matching_ids)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify compiles correctly**
|
||||
|
||||
Run: `pytest tests/test_filters.py -v`
|
||||
Expected: PASS (existing tests may need updating if they referenced `duration_minutes`).
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Expand PurchaseFilter (Original Currency, Infinite, Needs Price Update, Converted Currency)
|
||||
|
||||
**Files:**
|
||||
- Modify: `games/filters.py:PurchaseFilter`
|
||||
- Test: `tests/test_filters.py`
|
||||
|
||||
- [ ] **Step 1: Add new fields to PurchaseFilter and platform_filter**
|
||||
|
||||
Expand `PurchaseFilter` with `infinite: BoolCriterion`, `needs_price_update: BoolCriterion`, `converted_currency: StringCriterion`, and `platform_filter: PlatformFilter`.
|
||||
|
||||
```python
|
||||
# Inside PurchaseFilter class:
|
||||
infinite: BoolCriterion | None = None
|
||||
needs_price_update: BoolCriterion | None = None
|
||||
converted_currency: StringCriterion | None = None
|
||||
|
||||
# Cross-entity
|
||||
platform_filter: PlatformFilter | None = None
|
||||
```
|
||||
|
||||
Update `to_q()` inside `PurchaseFilter`:
|
||||
```python
|
||||
if self.infinite is not None:
|
||||
q &= self.infinite.to_q("infinite")
|
||||
if self.needs_price_update is not None:
|
||||
q &= self.needs_price_update.to_q("needs_price_update")
|
||||
if self.converted_currency is not None:
|
||||
q &= self.converted_currency.to_q("converted_currency")
|
||||
|
||||
# Cross-entity filter: platform_filter
|
||||
if self.platform_filter is not None:
|
||||
from games.models import Platform
|
||||
platform_q = self.platform_filter.to_q()
|
||||
matching_ids = Platform.objects.filter(platform_q).values_list("id", flat=True)
|
||||
q &= Q(platform_id__in=matching_ids)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify test suite continues to pass**
|
||||
|
||||
Run: `pytest tests/test_filters.py -v`
|
||||
Expected: PASS
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Expand GameFilter (Has Purchases, Has PlayEvents, Session Stats, Cross-Entity)
|
||||
|
||||
**Files:**
|
||||
- Modify: `games/filters.py:GameFilter`
|
||||
- Test: `tests/test_filters.py`
|
||||
|
||||
- [ ] **Step 1: Expand GameFilter with session stats, purchase/playevent existence, and cross-entity filters**
|
||||
|
||||
Add fields and cross-entity filters to `GameFilter`:
|
||||
```python
|
||||
# Inside GameFilter class:
|
||||
has_purchases: BoolCriterion | None = None
|
||||
has_playevents: BoolCriterion | None = None
|
||||
session_count: IntCriterion | None = None
|
||||
session_average: IntCriterion | None = None # average in minutes
|
||||
|
||||
# Cross-entity filters
|
||||
session_filter: SessionFilter | None = None
|
||||
purchase_filter: PurchaseFilter | None = None
|
||||
playevent_filter: PlayEventFilter | None = None
|
||||
platform_filter: PlatformFilter | None = None
|
||||
```
|
||||
|
||||
Update `to_q()` inside `GameFilter`.
|
||||
For existence and session stats filters, we use Subqueries to avoid complex inline annotations during the generic filter generation (which is much cleaner and less bug-prone):
|
||||
|
||||
```python
|
||||
if self.has_purchases is not None:
|
||||
from games.models import Purchase
|
||||
purchased_ids = Purchase.objects.values_list("games__id", flat=True).distinct()
|
||||
if self.has_purchases.value:
|
||||
q &= Q(id__in=purchased_ids)
|
||||
else:
|
||||
q &= ~Q(id__in=purchased_ids)
|
||||
|
||||
if self.has_playevents is not None:
|
||||
from games.models import PlayEvent
|
||||
played_ids = PlayEvent.objects.values_list("game_id", flat=True).distinct()
|
||||
if self.has_playevents.value:
|
||||
q &= Q(id__in=played_ids)
|
||||
else:
|
||||
q &= ~Q(id__in=played_ids)
|
||||
|
||||
if self.session_count is not None:
|
||||
from games.models import Game
|
||||
from django.db.models import Count
|
||||
matching_ids = Game.objects.annotate(s_count=Count("sessions")).filter(self.session_count.to_q("s_count")).values_list("id", flat=True)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
if self.session_average is not None:
|
||||
from games.models import Game, Session
|
||||
from django.db.models import Avg, F, ExpressionWrapper, DurationField
|
||||
# Compute average session total duration.
|
||||
# Avg returns an interval/duration type, so we can convert it to minutes in Python or do duration comparisons directly.
|
||||
# To match the criterion easily, we can filter Game objects using Avg:
|
||||
matching_ids = Game.objects.annotate(s_avg=Avg("sessions__duration_total")).filter(self._playtime_to_q_for_field(self.session_average, "s_avg")).values_list("id", flat=True)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
# Cross-entity filters
|
||||
if self.session_filter is not None:
|
||||
from games.models import Session
|
||||
session_q = self.session_filter.to_q()
|
||||
matching_ids = Session.objects.filter(session_q).values_list("game_id", flat=True)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
if self.purchase_filter is not None:
|
||||
from games.models import Purchase
|
||||
purchase_q = self.purchase_filter.to_q()
|
||||
matching_ids = Purchase.objects.filter(purchase_q).values_list("games__id", flat=True)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
if self.playevent_filter is not None:
|
||||
from games.models import PlayEvent
|
||||
playevent_q = self.playevent_filter.to_q()
|
||||
matching_ids = PlayEvent.objects.filter(playevent_q).values_list("game_id", flat=True)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
if self.platform_filter is not None:
|
||||
from games.models import Platform
|
||||
platform_q = self.platform_filter.to_q()
|
||||
matching_ids = Platform.objects.filter(platform_q).values_list("id", flat=True)
|
||||
q &= Q(platform_id__in=matching_ids)
|
||||
```
|
||||
|
||||
Add a helper `_playtime_to_q_for_field` in `GameFilter` that works exactly like `_playtime_to_q` but accepts a customized field name (e.g. `s_avg`):
|
||||
```python
|
||||
@staticmethod
|
||||
def _playtime_to_q_for_field(c: IntCriterion, field: str) -> Q:
|
||||
from datetime import timedelta
|
||||
m = c.modifier
|
||||
td_val = timedelta(minutes=c.value)
|
||||
|
||||
if m == Modifier.EQUALS:
|
||||
return Q(
|
||||
**{
|
||||
f"{field}__gte": td_val,
|
||||
f"{field}__lt": timedelta(minutes=c.value + 1),
|
||||
}
|
||||
)
|
||||
if m == Modifier.NOT_EQUALS:
|
||||
return ~Q(
|
||||
**{
|
||||
f"{field}__gte": td_val,
|
||||
f"{field}__lt": timedelta(minutes=c.value + 1),
|
||||
}
|
||||
)
|
||||
if m == Modifier.GREATER_THAN:
|
||||
return Q(**{f"{field}__gt": td_val})
|
||||
if m == Modifier.LESS_THAN:
|
||||
return Q(**{f"{field}__lt": td_val})
|
||||
if m == Modifier.BETWEEN and c.value2 is not None:
|
||||
lo = timedelta(minutes=min(c.value, c.value2))
|
||||
hi = timedelta(minutes=max(c.value, c.value2))
|
||||
return Q(**{f"{field}__gte": lo, f"{field}__lte": hi})
|
||||
if m == Modifier.NOT_BETWEEN and c.value2 is not None:
|
||||
lo = timedelta(minutes=min(c.value, c.value2))
|
||||
hi = timedelta(minutes=max(c.value, c.value2))
|
||||
return Q(**{f"{field}__lt": lo}) | Q(**{f"{field}__gt": hi})
|
||||
if m == Modifier.IS_NULL:
|
||||
return Q(**{f"{field}": timedelta(0)})
|
||||
if m == Modifier.NOT_NULL:
|
||||
return ~Q(**{f"{field}": timedelta(0)})
|
||||
return Q()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update existing `_playtime_to_q` to delegate to `_playtime_to_q_for_field`**
|
||||
```python
|
||||
@staticmethod
|
||||
def _playtime_to_q(c: IntCriterion) -> Q:
|
||||
return GameFilter._playtime_to_q_for_field(c, "playtime")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Add Exhaustive DB Tests for the Expanded and New Filters
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/test_filters.py`
|
||||
|
||||
- [ ] **Step 1: Write DB-backed unit tests for the new filter behaviors**
|
||||
|
||||
Add comprehensive test cases inside `tests/test_filters.py` covering:
|
||||
- New cross-entity filters (e.g. Platform -> Game -> Session -> Device chain).
|
||||
- Session total vs manual vs calculated duration filters.
|
||||
- Game session stats (`session_count`, `session_average`) and presence flags (`has_purchases`, `has_playevents`).
|
||||
- Device, Platform, and PlayEvent specific filters.
|
||||
|
||||
```python
|
||||
# Add test class at the end of tests/test_filters.py:
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestExpandedFiltersAgainstDB:
|
||||
def _setup_entities(self):
|
||||
from games.models import Game, Platform, Device, Session, Purchase, PlayEvent
|
||||
import datetime
|
||||
from datetime import timedelta
|
||||
|
||||
# 1. Platform & Game
|
||||
plat, _ = Platform.objects.get_or_create(name="Retro Console", group="Nintendo", icon="retro")
|
||||
game, _ = Game.objects.get_or_create(name="Super Mario World", defaults={"platform": plat, "status": "f"})
|
||||
game2, _ = Game.objects.get_or_create(name="Zelda", defaults={"platform": plat, "status": "u"})
|
||||
|
||||
# 2. Device & Session
|
||||
dev, _ = Device.objects.get_or_create(name="Super Famicom", type="Console")
|
||||
|
||||
# Session 1: total 40 minutes (30 calc, 10 manual)
|
||||
s1 = Session.objects.create(
|
||||
game=game,
|
||||
device=dev,
|
||||
timestamp_start=datetime.datetime(2026, 6, 1, 12, 0, 0, tzinfo=datetime.timezone.utc),
|
||||
timestamp_end=datetime.datetime(2026, 6, 1, 12, 30, 0, tzinfo=datetime.timezone.utc),
|
||||
duration_manual=timedelta(minutes=10)
|
||||
)
|
||||
|
||||
# 3. Purchase
|
||||
pur = Purchase.objects.create(
|
||||
platform=plat,
|
||||
date_purchased=datetime.date(2026, 1, 1),
|
||||
infinite=True,
|
||||
price=49.99,
|
||||
price_currency="JPY",
|
||||
converted_price=45.00,
|
||||
converted_currency="USD",
|
||||
needs_price_update=False
|
||||
)
|
||||
pur.games.add(game)
|
||||
|
||||
# 4. PlayEvent
|
||||
pe = PlayEvent.objects.create(
|
||||
game=game,
|
||||
started=datetime.date(2026, 6, 1),
|
||||
ended=datetime.date(2026, 6, 2),
|
||||
note="Completed 100%"
|
||||
)
|
||||
|
||||
return {
|
||||
"plat": plat,
|
||||
"game": game,
|
||||
"game2": game2,
|
||||
"dev": dev,
|
||||
"s1": s1,
|
||||
"pur": pur,
|
||||
"pe": pe
|
||||
}
|
||||
|
||||
def test_device_filter_and_cross_entity(self):
|
||||
from games.filters import DeviceFilter, SessionFilter
|
||||
from games.models import Device
|
||||
|
||||
data = self._setup_entities()
|
||||
# Find devices that have sessions on "Super Mario World"
|
||||
df = DeviceFilter.from_json({
|
||||
"session_filter": {
|
||||
"game_filter": {
|
||||
"name": {"value": "Super Mario World", "modifier": "EQUALS"}
|
||||
}
|
||||
}
|
||||
})
|
||||
results = list(Device.objects.filter(df.to_q()))
|
||||
assert data["dev"] in results
|
||||
|
||||
def test_platform_filter_and_cross_entity(self):
|
||||
from games.filters import PlatformFilter, GameFilter
|
||||
from games.models import Platform
|
||||
|
||||
data = self._setup_entities()
|
||||
# Find platforms with games that are finished
|
||||
pf = PlatformFilter.from_json({
|
||||
"game_filter": {
|
||||
"status": {"value": ["f"], "modifier": "INCLUDES"}
|
||||
}
|
||||
})
|
||||
results = list(Platform.objects.filter(pf.to_q()))
|
||||
assert data["plat"] in results
|
||||
|
||||
def test_session_filter_duration_splits(self):
|
||||
from games.filters import SessionFilter
|
||||
from games.models import Session
|
||||
|
||||
data = self._setup_entities()
|
||||
|
||||
# Test duration_total_minutes equals 40
|
||||
sf_tot = SessionFilter.from_json({
|
||||
"duration_total_minutes": {"value": 40, "modifier": "EQUALS"}
|
||||
})
|
||||
assert Session.objects.filter(sf_tot.to_q()).count() == 1
|
||||
|
||||
# Test duration_manual_minutes equals 10
|
||||
sf_man = SessionFilter.from_json({
|
||||
"duration_manual_minutes": {"value": 10, "modifier": "EQUALS"}
|
||||
})
|
||||
assert Session.objects.filter(sf_man.to_q()).count() == 1
|
||||
|
||||
# Test duration_calculated_minutes equals 30
|
||||
sf_calc = SessionFilter.from_json({
|
||||
"duration_calculated_minutes": {"value": 30, "modifier": "EQUALS"}
|
||||
})
|
||||
assert Session.objects.filter(sf_calc.to_q()).count() == 1
|
||||
|
||||
def test_purchase_filter_new_fields(self):
|
||||
from games.filters import PurchaseFilter
|
||||
from games.models import Purchase
|
||||
|
||||
data = self._setup_entities()
|
||||
|
||||
pf = PurchaseFilter.from_json({
|
||||
"infinite": {"value": True, "modifier": "EQUALS"},
|
||||
"needs_price_update": {"value": False, "modifier": "EQUALS"},
|
||||
"converted_currency": {"value": "USD", "modifier": "EQUALS"}
|
||||
})
|
||||
assert Purchase.objects.filter(pf.to_q()).count() == 1
|
||||
|
||||
def test_game_filter_stats_and_existence(self):
|
||||
from games.filters import GameFilter
|
||||
from games.models import Game
|
||||
|
||||
data = self._setup_entities()
|
||||
|
||||
# has_purchases = True
|
||||
gf_pur = GameFilter.from_json({
|
||||
"has_purchases": {"value": True, "modifier": "EQUALS"}
|
||||
})
|
||||
assert data["game"] in list(Game.objects.filter(gf_pur.to_q()))
|
||||
assert data["game2"] not in list(Game.objects.filter(gf_pur.to_q()))
|
||||
|
||||
# session_count = 1
|
||||
gf_cnt = GameFilter.from_json({
|
||||
"session_count": {"value": 1, "modifier": "EQUALS"}
|
||||
})
|
||||
assert data["game"] in list(Game.objects.filter(gf_cnt.to_q()))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run all unit tests to confirm success**
|
||||
|
||||
Run: `pytest tests/test_filters.py -v`
|
||||
Expected: ALL tests pass perfectly.
|
||||
@@ -1,577 +0,0 @@
|
||||
# Frontend Filters Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Implement a comprehensive frontend filter bar interface for all 6 list views (Games, Sessions, Purchases, Devices, Platforms, PlayEvents) with specific field controls, simple cross-entity toggles, and full JSON preset support.
|
||||
|
||||
**Architecture:** We will extend existing components in `common/components/filters.py` and implement new filter bars (`DeviceFilterBar`, `PlatformFilterBar`, `PlayEventFilterBar`). We will update the views in `games/views/` to parse standard filter JSON from `request.GET.get('filter')`, apply them to querysets, render the filter bars, and export them in `common/components/__init__.py`.
|
||||
|
||||
**Tech Stack:** Django, Python dataclasses, Pytest.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Update existing FilterBars in `common/components/filters.py`
|
||||
|
||||
**Files:**
|
||||
- Modify: `common/components/filters.py`
|
||||
|
||||
- [ ] **Step 1: Add new fields to GameFilterBar**
|
||||
Add checkboxes for `has_purchases`, `has_playevents` and RangeSliders for `session_count`, `session_average`.
|
||||
|
||||
```python
|
||||
# Inside common/components/filters.py: FilterBar()
|
||||
|
||||
# Parse new values
|
||||
has_purchases_value = _parse_bool(existing, "has_purchases")
|
||||
has_playevents_value = _parse_bool(existing, "has_playevents")
|
||||
session_count_min, session_count_max = _parse_range(existing, "session_count")
|
||||
session_avg_min, session_avg_max = _parse_range(existing, "session_average")
|
||||
|
||||
# Add components to fields:
|
||||
# 1. Under status and platform, add the checkboxes for purchases/playevents
|
||||
# 2. Add RangeSliders for session count and average
|
||||
```
|
||||
|
||||
Code change to apply in `FilterBar`:
|
||||
```python
|
||||
fields = [
|
||||
Component(
|
||||
tag_name="div",
|
||||
attributes=[("class", _FILTER_GRID_CLASS)],
|
||||
children=[
|
||||
_filter_field(
|
||||
"Status",
|
||||
_enum_filter(
|
||||
"status",
|
||||
status_options,
|
||||
status_choice,
|
||||
nullable=not Game._meta.get_field("status").has_default(),
|
||||
),
|
||||
),
|
||||
_filter_field(
|
||||
"Platform",
|
||||
_model_filter(
|
||||
"platform",
|
||||
platform_choice,
|
||||
search_url="/api/platforms/search",
|
||||
nullable=Game._meta.get_field("platform").null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
RangeSlider(
|
||||
label="Year",
|
||||
input_name_prefix="filter-year",
|
||||
min_value=year_min,
|
||||
max_value=year_max,
|
||||
range_min=year_range_min,
|
||||
range_max=year_range_max,
|
||||
min_placeholder="e.g. 2020",
|
||||
max_placeholder="e.g. 2024",
|
||||
),
|
||||
Component(
|
||||
tag_name="div",
|
||||
attributes=[("class", "flex items-end gap-4 mb-4")],
|
||||
children=[
|
||||
_filter_checkbox("filter-mastered", "Mastered", mastered_value),
|
||||
_filter_checkbox("filter-has-purchases", "Has Purchases", has_purchases_value),
|
||||
_filter_checkbox("filter-has-playevents", "Has Play Events", has_playevents_value),
|
||||
],
|
||||
),
|
||||
RangeSlider(
|
||||
label="Playtime",
|
||||
input_name_prefix="filter-playtime",
|
||||
min_value=playtime_min,
|
||||
max_value=playtime_max,
|
||||
range_min=0,
|
||||
range_max=playtime_range_max,
|
||||
step="1",
|
||||
min_placeholder="e.g. 1",
|
||||
max_placeholder="e.g. 100",
|
||||
),
|
||||
RangeSlider(
|
||||
label="Session Count",
|
||||
input_name_prefix="filter-session-count",
|
||||
min_value=session_count_min,
|
||||
max_value=session_count_max,
|
||||
range_min=0,
|
||||
range_max=100,
|
||||
step="1",
|
||||
min_placeholder="e.g. 1",
|
||||
max_placeholder="e.g. 50",
|
||||
),
|
||||
RangeSlider(
|
||||
label="Average Session Duration (mins)",
|
||||
input_name_prefix="filter-session-average",
|
||||
min_value=session_avg_min,
|
||||
max_value=session_avg_max,
|
||||
range_min=0,
|
||||
range_max=240,
|
||||
step="1",
|
||||
min_placeholder="e.g. 10",
|
||||
max_placeholder="e.g. 120",
|
||||
),
|
||||
]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update SessionFilterBar to support split duration fields**
|
||||
Replace old `duration_minutes` RangeSlider with split total, manual, and calculated duration RangeSliders.
|
||||
|
||||
```python
|
||||
# Inside common/components/filters.py: SessionFilterBar()
|
||||
|
||||
dur_tot_min, dur_tot_max = _parse_range(existing, "duration_total_minutes")
|
||||
dur_man_min, dur_man_max = _parse_range(existing, "duration_manual_minutes")
|
||||
dur_calc_min, dur_calc_max = _parse_range(existing, "duration_calculated_minutes")
|
||||
|
||||
# Inside fields array, replace RangeSlider "Duration" with:
|
||||
RangeSlider(
|
||||
label="Total Duration (mins)",
|
||||
input_name_prefix="filter-duration-total-minutes",
|
||||
min_value=dur_tot_min,
|
||||
max_value=dur_tot_max,
|
||||
range_min=0,
|
||||
range_max=duration_range_max * 60, # Range sliders use minutes now
|
||||
step="1",
|
||||
min_placeholder="e.g. 30",
|
||||
max_placeholder="e.g. 180",
|
||||
),
|
||||
RangeSlider(
|
||||
label="Manual Duration (mins)",
|
||||
input_name_prefix="filter-duration-manual-minutes",
|
||||
min_value=dur_man_min,
|
||||
max_value=dur_man_max,
|
||||
range_min=0,
|
||||
range_max=240,
|
||||
step="1",
|
||||
min_placeholder="e.g. 10",
|
||||
max_placeholder="e.g. 120",
|
||||
),
|
||||
RangeSlider(
|
||||
label="Calculated Duration (mins)",
|
||||
input_name_prefix="filter-duration-calculated-minutes",
|
||||
min_value=dur_calc_min,
|
||||
max_value=dur_calc_max,
|
||||
range_min=0,
|
||||
range_max=duration_range_max * 60,
|
||||
step="1",
|
||||
min_placeholder="e.g. 30",
|
||||
max_placeholder="e.g. 180",
|
||||
),
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update PurchaseFilterBar to support original and converted currencies and infinite flag**
|
||||
Add Checkboxes `infinite`, `needs_price_update` and currency StringCriterion text field / Choice options.
|
||||
|
||||
```python
|
||||
# Inside common/components/filters.py: PurchaseFilterBar()
|
||||
|
||||
infinite_value = _parse_bool(existing, "infinite")
|
||||
needs_price_update_value = _parse_bool(existing, "needs_price_update")
|
||||
price_currency_value = existing.get("price_currency", {}).get("value", "")
|
||||
converted_currency_value = existing.get("converted_currency", {}).get("value", "")
|
||||
|
||||
# Expand fields component array with:
|
||||
Component(
|
||||
tag_name="div",
|
||||
attributes=[("class", "flex gap-4 mb-4")],
|
||||
children=[
|
||||
_filter_checkbox("filter-refunded", "Refunded", is_refunded_value),
|
||||
_filter_checkbox("filter-infinite", "Infinite", infinite_value),
|
||||
_filter_checkbox("filter-needs-price-update", "Needs Price Update", needs_price_update_value),
|
||||
],
|
||||
),
|
||||
```
|
||||
|
||||
Add currency text filters (as primitive `Input` controls for string criteria):
|
||||
```python
|
||||
Component(
|
||||
tag_name="div",
|
||||
attributes=[("class", _FILTER_GRID_CLASS)],
|
||||
children=[
|
||||
_filter_field(
|
||||
"Original Currency",
|
||||
Component(
|
||||
tag_name="input",
|
||||
attributes=[
|
||||
("type", "text"),
|
||||
("name", "filter-price_currency"),
|
||||
("value", price_currency_value),
|
||||
("placeholder", "e.g. USD, EUR"),
|
||||
("class", "w-full rounded border-default-medium p-2 bg-neutral-secondary-medium text-body"),
|
||||
],
|
||||
),
|
||||
),
|
||||
_filter_field(
|
||||
"Converted Currency",
|
||||
Component(
|
||||
tag_name="input",
|
||||
attributes=[
|
||||
("type", "text"),
|
||||
("name", "filter-converted_currency"),
|
||||
("value", converted_currency_value),
|
||||
("placeholder", "e.g. USD, EUR"),
|
||||
("class", "w-full rounded border-default-medium p-2 bg-neutral-secondary-medium text-body"),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Create New FilterBars in `common/components/filters.py`
|
||||
|
||||
**Files:**
|
||||
- Modify: `common/components/filters.py`
|
||||
|
||||
- [ ] **Step 1: Implement DeviceFilterBar, PlatformFilterBar, and PlayEventFilterBar**
|
||||
|
||||
Append these three new filter bar components to `common/components/filters.py`:
|
||||
|
||||
```python
|
||||
def DeviceFilterBar(
|
||||
filter_json="", preset_list_url="", preset_save_url=""
|
||||
) -> SafeText:
|
||||
"""Collapsible filter bar for the Device list."""
|
||||
from games.models import Device
|
||||
|
||||
existing = _filter_parse(filter_json)
|
||||
type_options = Device.DEVICE_TYPES
|
||||
type_choice = _filter_get_choice(existing, "type")
|
||||
|
||||
fields = [
|
||||
Component(
|
||||
tag_name="div",
|
||||
attributes=[("class", _FILTER_GRID_CLASS)],
|
||||
children=[
|
||||
_filter_field(
|
||||
"Device Type",
|
||||
_enum_filter(
|
||||
"type",
|
||||
type_options,
|
||||
type_choice,
|
||||
nullable=True,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
|
||||
|
||||
|
||||
def PlatformFilterBar(
|
||||
filter_json="", preset_list_url="", preset_save_url=""
|
||||
) -> SafeText:
|
||||
"""Collapsible filter bar for the Platform list."""
|
||||
existing = _filter_parse(filter_json)
|
||||
|
||||
name_value = existing.get("name", {}).get("value", "")
|
||||
group_value = existing.get("group", {}).get("value", "")
|
||||
|
||||
fields = [
|
||||
Component(
|
||||
tag_name="div",
|
||||
attributes=[("class", _FILTER_GRID_CLASS)],
|
||||
children=[
|
||||
_filter_field(
|
||||
"Platform Name",
|
||||
Component(
|
||||
tag_name="input",
|
||||
attributes=[
|
||||
("type", "text"),
|
||||
("name", "filter-name"),
|
||||
("value", name_value),
|
||||
("placeholder", "e.g. Nintendo Switch"),
|
||||
("class", "w-full rounded border-default-medium p-2 bg-neutral-secondary-medium text-body"),
|
||||
],
|
||||
),
|
||||
),
|
||||
_filter_field(
|
||||
"Platform Group",
|
||||
Component(
|
||||
tag_name="input",
|
||||
attributes=[
|
||||
("type", "text"),
|
||||
("name", "filter-group"),
|
||||
("value", group_value),
|
||||
("placeholder", "e.g. Nintendo"),
|
||||
("class", "w-full rounded border-default-medium p-2 bg-neutral-secondary-medium text-body"),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
|
||||
|
||||
|
||||
def PlayEventFilterBar(
|
||||
filter_json="", preset_list_url="", preset_save_url=""
|
||||
) -> SafeText:
|
||||
"""Collapsible filter bar for the PlayEvent list."""
|
||||
from games.models import PlayEvent
|
||||
|
||||
existing = _filter_parse(filter_json)
|
||||
game_choice = _filter_get_choice(existing, "game")
|
||||
days_min, days_max = _parse_range(existing, "days_to_finish")
|
||||
|
||||
fields = [
|
||||
Component(
|
||||
tag_name="div",
|
||||
attributes=[("class", _FILTER_GRID_CLASS)],
|
||||
children=[
|
||||
_filter_field(
|
||||
"Game",
|
||||
_model_filter(
|
||||
"game",
|
||||
game_choice,
|
||||
search_url="/api/games/search",
|
||||
nullable=False,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
RangeSlider(
|
||||
label="Days to Finish",
|
||||
input_name_prefix="filter-days-to-finish",
|
||||
min_value=days_min,
|
||||
max_value=days_max,
|
||||
range_min=0,
|
||||
range_max=365,
|
||||
step="1",
|
||||
min_placeholder="e.g. 1",
|
||||
max_placeholder="e.g. 30",
|
||||
),
|
||||
]
|
||||
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Export new FilterBars in `common/components/__init__.py`**
|
||||
|
||||
Modify: `common/components/__init__.py` to import and expose `DeviceFilterBar`, `PlatformFilterBar`, and `PlayEventFilterBar`.
|
||||
|
||||
```python
|
||||
# Import section:
|
||||
from common.components.filters import (
|
||||
FilterBar,
|
||||
PurchaseFilterBar,
|
||||
SessionFilterBar,
|
||||
DeviceFilterBar,
|
||||
PlatformFilterBar,
|
||||
PlayEventFilterBar,
|
||||
)
|
||||
|
||||
# In __all__:
|
||||
"FilterBar",
|
||||
"PurchaseFilterBar",
|
||||
"SessionFilterBar",
|
||||
"DeviceFilterBar",
|
||||
"PlatformFilterBar",
|
||||
"PlayEventFilterBar",
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Integrate FilterBars into `Device`, `Platform`, and `PlayEvent` views
|
||||
|
||||
**Files:**
|
||||
- Modify: `games/views/device.py`
|
||||
- Modify: `games/views/platform.py`
|
||||
- Modify: `games/views/playevent.py`
|
||||
|
||||
- [ ] **Step 1: Integrate FilterBar in `list_devices` in `games/views/device.py`**
|
||||
|
||||
Import and parse the filter, apply to queryset, instantiate `DeviceFilterBar`, prepend it to the output page content.
|
||||
|
||||
```python
|
||||
# At top of games/views/device.py:
|
||||
from django.utils.safestring import mark_safe
|
||||
from common.components import DeviceFilterBar, ModuleScript
|
||||
from games.filters import parse_device_filter
|
||||
|
||||
# Inside list_devices(request):
|
||||
devices = Device.objects.order_by("-created_at")
|
||||
|
||||
filter_json = request.GET.get("filter", "")
|
||||
if filter_json:
|
||||
device_filter = parse_device_filter(filter_json)
|
||||
if device_filter is not None:
|
||||
devices = devices.filter(device_filter.to_q())
|
||||
|
||||
devices, page_obj, elided_page_range = paginate(request, devices)
|
||||
|
||||
# ... create data dict ...
|
||||
|
||||
# Prepend the filter bar above table:
|
||||
filter_bar = DeviceFilterBar(
|
||||
filter_json=filter_json,
|
||||
preset_list_url=reverse("games:list_presets") + "?mode=devices",
|
||||
preset_save_url=reverse("games:save_preset") + "?mode=devices",
|
||||
)
|
||||
content = mark_safe(str(filter_bar) + str(content))
|
||||
return render_page(
|
||||
request,
|
||||
content,
|
||||
title="Manage devices",
|
||||
scripts=ModuleScript("range_slider.js")
|
||||
+ ModuleScript("search_select.js")
|
||||
+ ModuleScript("filter_bar.js"),
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Integrate FilterBar in `list_platforms` in `games/views/platform.py`**
|
||||
|
||||
Import and parse the filter, apply to platform queryset, instantiate platform filter bar, prepend to page content.
|
||||
|
||||
```python
|
||||
# At top of games/views/platform.py:
|
||||
from django.utils.safestring import mark_safe
|
||||
from common.components import PlatformFilterBar, ModuleScript
|
||||
from games.filters import parse_platform_filter
|
||||
|
||||
# Inside list_platforms(request):
|
||||
platforms = Platform.objects.order_by("name")
|
||||
|
||||
filter_json = request.GET.get("filter", "")
|
||||
if filter_json:
|
||||
platform_filter = parse_platform_filter(filter_json)
|
||||
if platform_filter is not None:
|
||||
platforms = platforms.filter(platform_filter.to_q())
|
||||
|
||||
platforms, page_obj, elided_page_range = paginate(request, platforms)
|
||||
|
||||
# ... create data dict ...
|
||||
|
||||
filter_bar = PlatformFilterBar(
|
||||
filter_json=filter_json,
|
||||
preset_list_url=reverse("games:list_presets") + "?mode=platforms",
|
||||
preset_save_url=reverse("games:save_preset") + "?mode=platforms",
|
||||
)
|
||||
content = mark_safe(str(filter_bar) + str(content))
|
||||
return render_page(
|
||||
request,
|
||||
content,
|
||||
title="Manage platforms",
|
||||
scripts=ModuleScript("range_slider.js")
|
||||
+ ModuleScript("search_select.js")
|
||||
+ ModuleScript("filter_bar.js"),
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Integrate FilterBar in `list_playevents` in `games/views/playevent.py`**
|
||||
|
||||
Import and parse the filter, apply to playevent queryset, instantiate playevent filter bar, prepend to page content.
|
||||
|
||||
```python
|
||||
# At top of games/views/playevent.py:
|
||||
from django.utils.safestring import mark_safe
|
||||
from common.components import PlayEventFilterBar
|
||||
from games.filters import parse_playevent_filter
|
||||
|
||||
# Inside list_playevents(request):
|
||||
playevents = PlayEvent.objects.order_by("-created_at")
|
||||
|
||||
filter_json = request.GET.get("filter", "")
|
||||
if filter_json:
|
||||
playevent_filter = parse_playevent_filter(filter_json)
|
||||
if playevent_filter is not None:
|
||||
playevents = playevents.filter(playevent_filter.to_q())
|
||||
|
||||
playevents, page_obj, elided_page_range = paginate(request, playevents)
|
||||
|
||||
# ... create data ...
|
||||
|
||||
filter_bar = PlayEventFilterBar(
|
||||
filter_json=filter_json,
|
||||
preset_list_url=reverse("games:list_presets") + "?mode=playevents",
|
||||
preset_save_url=reverse("games:save_preset") + "?mode=playevents",
|
||||
)
|
||||
content = mark_safe(str(filter_bar) + str(content))
|
||||
return render_page(
|
||||
request,
|
||||
content,
|
||||
title="Manage play events",
|
||||
scripts=ModuleScript("range_slider.js")
|
||||
+ ModuleScript("search_select.js")
|
||||
+ ModuleScript("filter_bar.js"),
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Support new preset modes in Preset View/Model
|
||||
|
||||
Ensure FilterPreset allows `devices` and `platforms` modes.
|
||||
|
||||
**Files:**
|
||||
- Modify: `games/models.py`
|
||||
- Modify: `games/views/filter_presets.py`
|
||||
|
||||
- [ ] **Step 1: Expand FilterPreset mode choices**
|
||||
|
||||
Verify or expand `MODE_CHOICES` inside `FilterPreset` model in `games/models.py`.
|
||||
|
||||
```python
|
||||
# Inside FilterPreset class:
|
||||
MODE_CHOICES = [
|
||||
("games", "Games"),
|
||||
("sessions", "Sessions"),
|
||||
("purchases", "Purchases"),
|
||||
("playevents", "Play Events"),
|
||||
("devices", "Devices"),
|
||||
("platforms", "Platforms"),
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Add Render Tests for new FilterBars
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/test_filter_bars.py`
|
||||
|
||||
- [ ] **Step 1: Write tests to verify new FilterBars render correctly**
|
||||
|
||||
Add test cases in `tests/test_filter_bars.py`:
|
||||
|
||||
```python
|
||||
def test_device_filter_bar(self):
|
||||
from common.components import DeviceFilterBar
|
||||
html = str(
|
||||
DeviceFilterBar(
|
||||
filter_json="",
|
||||
preset_list_url="/presets/devices/list",
|
||||
preset_save_url="/presets/devices/save",
|
||||
)
|
||||
)
|
||||
self._assert_shell(html, "/presets/devices/list", "/presets/devices/save")
|
||||
|
||||
def test_platform_filter_bar(self):
|
||||
from common.components import PlatformFilterBar
|
||||
html = str(
|
||||
PlatformFilterBar(
|
||||
filter_json="",
|
||||
preset_list_url="/presets/platforms/list",
|
||||
preset_save_url="/presets/platforms/save",
|
||||
)
|
||||
)
|
||||
self._assert_shell(html, "/presets/platforms/list", "/presets/platforms/save")
|
||||
|
||||
def test_playevent_filter_bar(self):
|
||||
from common.components import PlayEventFilterBar
|
||||
html = str(
|
||||
PlayEventFilterBar(
|
||||
filter_json="",
|
||||
preset_list_url="/presets/playevents/list",
|
||||
preset_save_url="/presets/playevents/save",
|
||||
)
|
||||
)
|
||||
self._assert_shell(html, "/presets/playevents/list", "/presets/playevents/save")
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run all test suites to confirm complete success**
|
||||
|
||||
Run: `pytest tests/test_filter_bars.py -v`
|
||||
Expected: ALL filter bar render tests pass.
|
||||
@@ -1 +0,0 @@
|
||||
# e2e tests package
|
||||
@@ -1,21 +0,0 @@
|
||||
import os
|
||||
import shutil
|
||||
import pytest
|
||||
|
||||
# Playwright runs an async event loop in the background, which triggers
|
||||
# Django's async safety checks when running synchronous tests. This allows
|
||||
# synchronous operations inside the async context safely.
|
||||
os.environ.setdefault("DJANGO_ALLOW_ASYNC_UNSAFE", "true")
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def browser_type_launch_args(browser_type_launch_args):
|
||||
# Try to find a system-installed Google Chrome or Chromium to bypass Nix/NixOS shared library issues
|
||||
for browser_name in ["google-chrome-stable", "google-chrome", "chromium", "chrome"]:
|
||||
path = shutil.which(browser_name)
|
||||
if path:
|
||||
return {
|
||||
**browser_type_launch_args,
|
||||
"executable_path": path,
|
||||
}
|
||||
# Fallback to default Playwright behavior
|
||||
return browser_type_launch_args
|
||||
@@ -1,101 +0,0 @@
|
||||
import pytest
|
||||
from django.urls import path
|
||||
from django.http import HttpResponse
|
||||
from django.test import override_settings
|
||||
from common.components import SearchSelect
|
||||
|
||||
def e2e_test_view(request):
|
||||
html = f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>SearchSelect E2E Test</title>
|
||||
<script src="/static/js/search_select.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div style="padding: 50px;">
|
||||
{SearchSelect(
|
||||
name="games",
|
||||
selected=[{"value": "7", "label": "Game A", "data": {}}],
|
||||
options=[
|
||||
{"value": "7", "label": "Game A", "data": {}},
|
||||
{"value": "8", "label": "Game B", "data": {}},
|
||||
],
|
||||
multi_select=False
|
||||
)}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
return HttpResponse(html)
|
||||
|
||||
urlpatterns = [
|
||||
path("test-search-select/", e2e_test_view),
|
||||
]
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_search_select_e2e")
|
||||
def test_search_select_backspace_clears_single_select(live_server, page):
|
||||
# Enable console log forwarding
|
||||
page.on("console", lambda msg: print(f"BROWSER CONSOLE: {msg.text}"))
|
||||
|
||||
page.goto(live_server.url + "/test-search-select/")
|
||||
|
||||
# Inject our event logger
|
||||
page.evaluate("""() => {
|
||||
const s = document.querySelector('input[data-search-select-search]');
|
||||
const c = document.querySelector('[data-search-select]');
|
||||
s.addEventListener('focus', () => console.log('JS-EVENT: focus, dirty=' + c._searchSelectDirty + ', value="' + s.value + '"'));
|
||||
s.addEventListener('blur', () => console.log('JS-EVENT: blur, dirty=' + c._searchSelectDirty + ', value="' + s.value + '"'));
|
||||
s.addEventListener('input', () => console.log('JS-EVENT: input, dirty=' + c._searchSelectDirty + ', value="' + s.value + '"'));
|
||||
s.addEventListener('keydown', (e) => console.log('JS-EVENT: keydown ' + e.key + ', dirty=' + c._searchSelectDirty + ', value="' + s.value + '"'));
|
||||
}""")
|
||||
|
||||
search_input = page.locator("input[data-search-select-search]")
|
||||
|
||||
assert search_input.input_value() == "Game A"
|
||||
|
||||
hidden_input = page.locator('input[name="games"]')
|
||||
assert hidden_input.first.get_attribute("value") == "7"
|
||||
|
||||
# Focus the input
|
||||
print("\n--- FOCUSING INPUT ---")
|
||||
search_input.focus()
|
||||
assert search_input.input_value() == ""
|
||||
|
||||
# Press Backspace using the raw keyboard API to avoid any high-level Playwright input simulation
|
||||
print("\n--- PRESSING BACKSPACE ---")
|
||||
page.keyboard.press("Backspace")
|
||||
|
||||
# Explicitly blur the input
|
||||
print("\n--- BLURRING INPUT ---")
|
||||
search_input.blur()
|
||||
|
||||
# Wait for blur microtasks/setTimeout to settle (120ms timeout in JS)
|
||||
page.wait_for_timeout(200)
|
||||
|
||||
# After Backspace and blur, the input should remain empty (the selection is cleared)
|
||||
assert search_input.input_value() == ""
|
||||
assert hidden_input.count() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(ROOT_URLCONF="e2e.test_search_select_e2e")
|
||||
def test_search_select_typing_replaces_single_select(live_server, page):
|
||||
page.goto(live_server.url + "/test-search-select/")
|
||||
|
||||
search_input = page.locator("input[data-search-select-search]")
|
||||
|
||||
search_input.focus()
|
||||
assert search_input.input_value() == ""
|
||||
|
||||
search_input.type("X")
|
||||
assert search_input.input_value() == "X"
|
||||
|
||||
search_input.blur()
|
||||
page.wait_for_timeout(200)
|
||||
|
||||
assert search_input.input_value() == "Game A"
|
||||
|
||||
hidden_input = page.locator('input[name="games"]')
|
||||
assert hidden_input.first.get_attribute("value") == "7"
|
||||
+13
-17
@@ -1,23 +1,19 @@
|
||||
#!/bin/bash
|
||||
# Apply database migrations
|
||||
set -euo pipefail
|
||||
echo "Apply database migrations"
|
||||
poetry run python manage.py migrate
|
||||
|
||||
PUID=${PUID:-1000}
|
||||
PGID=${PGID:-100}
|
||||
echo "Collect static files"
|
||||
poetry run python manage.py collectstatic --clear --no-input
|
||||
|
||||
USERHOME=$(grep timetracker /etc/passwd | cut -d ":" -f6)
|
||||
usermod -d "/root" timetracker
|
||||
groupmod -o -g "$PGID" timetracker
|
||||
usermod -o -u "$PUID" timetracker
|
||||
usermod -d "${USERHOME}" timetracker
|
||||
_term() {
|
||||
echo "Caught SIGTERM signal!"
|
||||
kill -SIGTERM "$gunicorn_pid"
|
||||
}
|
||||
trap _term SIGTERM
|
||||
|
||||
mkdir -p /home/timetracker/app/data /var/log/supervisor
|
||||
chmod 755 /home/timetracker/app
|
||||
chmod 755 /home/timetracker/app/.venv
|
||||
echo "Starting app"
|
||||
poetry run python -m gunicorn --bind 0.0.0.0:8001 timetracker.asgi:application -k uvicorn.workers.UvicornWorker --access-logfile - --error-logfile - & gunicorn_pid=$!
|
||||
|
||||
chown "$PUID:$PGID" /home/timetracker/app/data
|
||||
chown "$PUID:$PGID" /var/log/supervisor
|
||||
|
||||
python manage.py migrate
|
||||
python manage.py collectstatic --clear --no-input
|
||||
|
||||
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisor.conf
|
||||
wait "$gunicorn_pid"
|
||||
|
||||
+2
-9
@@ -1,18 +1,11 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from games.models import (
|
||||
Device,
|
||||
ExchangeRate,
|
||||
Game,
|
||||
Platform,
|
||||
Purchase,
|
||||
Session,
|
||||
)
|
||||
from games.models import Device, Edition, Game, Platform, Purchase, Session
|
||||
|
||||
# Register your models here.
|
||||
admin.site.register(Game)
|
||||
admin.site.register(Purchase)
|
||||
admin.site.register(Platform)
|
||||
admin.site.register(Session)
|
||||
admin.site.register(Edition)
|
||||
admin.site.register(Device)
|
||||
admin.site.register(ExchangeRate)
|
||||
|
||||
-159
@@ -1,159 +0,0 @@
|
||||
from datetime import date, datetime
|
||||
from typing import List
|
||||
|
||||
from django.contrib import messages
|
||||
from django.db.models import Q
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.timezone import now as django_timezone_now
|
||||
from ninja import Field, ModelSchema, NinjaAPI, Router, Schema, Status
|
||||
|
||||
from games.models import Device, Game, Platform, PlayEvent, Session
|
||||
|
||||
api = NinjaAPI()
|
||||
playevent_router = Router()
|
||||
game_router = Router()
|
||||
device_router = Router()
|
||||
platform_router = Router()
|
||||
|
||||
NOW_FACTORY = django_timezone_now
|
||||
|
||||
|
||||
class GameStatusUpdate(Schema):
|
||||
status: str
|
||||
|
||||
|
||||
class PlayEventIn(Schema):
|
||||
game_id: int
|
||||
started: date | None = None
|
||||
ended: date | None = None
|
||||
note: str = ""
|
||||
days_to_finish: int | None = None
|
||||
|
||||
|
||||
class AutoPlayEventIn(ModelSchema):
|
||||
class Meta:
|
||||
model = PlayEvent
|
||||
fields = ["game", "started", "ended", "note"]
|
||||
|
||||
|
||||
class UpdatePlayEventIn(Schema):
|
||||
started: date | None = None
|
||||
ended: date | None = None
|
||||
note: str = ""
|
||||
|
||||
|
||||
class PlayEventOut(Schema):
|
||||
id: int
|
||||
game: str = Field(..., alias="game.name")
|
||||
started: date | None = None
|
||||
ended: date | None = None
|
||||
days_to_finish: int | None = None
|
||||
note: str = ""
|
||||
updated_at: datetime
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class GameOption(Schema): # mirrors SearchSelectOption
|
||||
value: int
|
||||
label: str
|
||||
data: dict
|
||||
|
||||
|
||||
@game_router.get("/search", response=list[GameOption])
|
||||
def search_games(request, q: str = "", limit: int = 10):
|
||||
qs = Game.objects.select_related("platform").order_by("sort_name")
|
||||
if q:
|
||||
qs = qs.filter(Q(name__icontains=q) | Q(sort_name__icontains=q))
|
||||
return [
|
||||
{
|
||||
"value": g.id,
|
||||
"label": g.search_label,
|
||||
"data": {"platform": g.platform_id or ""},
|
||||
}
|
||||
for g in qs[:limit]
|
||||
]
|
||||
|
||||
|
||||
@game_router.patch("/{game_id}/status", response={204: None})
|
||||
def partial_update_game(request, game_id: int, payload: GameStatusUpdate):
|
||||
game = get_object_or_404(Game, id=game_id)
|
||||
setattr(game, "status", payload.status)
|
||||
game.save()
|
||||
messages.success(request, "Status updated")
|
||||
return Status(204, None)
|
||||
|
||||
|
||||
@playevent_router.get("/", response=List[PlayEventOut])
|
||||
def list_playevents(request):
|
||||
return PlayEvent.objects.all()
|
||||
|
||||
|
||||
@playevent_router.post("/", response={201: PlayEventOut})
|
||||
def create_playevent(request, payload: PlayEventIn):
|
||||
playevent = PlayEvent.objects.create(**payload.dict())
|
||||
messages.success(request, "Game played!")
|
||||
return playevent
|
||||
|
||||
|
||||
@playevent_router.get("/{playevent_id}", response=PlayEventOut)
|
||||
def get_playevent(request, playevent_id: int):
|
||||
playevent = get_object_or_404(PlayEvent, id=playevent_id)
|
||||
return playevent
|
||||
|
||||
|
||||
@playevent_router.patch("/{playevent_id}", response=PlayEventOut)
|
||||
def partial_update_playevent(request, playevent_id: int, payload: UpdatePlayEventIn):
|
||||
playevent = get_object_or_404(PlayEvent, id=playevent_id)
|
||||
for attr, value in payload.dict(exclude_unset=True).items():
|
||||
setattr(playevent, attr, value)
|
||||
playevent.save()
|
||||
return playevent
|
||||
|
||||
|
||||
@playevent_router.delete("/{playevent_id}", response={204: None})
|
||||
def delete_playevent(request, playevent_id: int):
|
||||
playevent = get_object_or_404(PlayEvent, id=playevent_id)
|
||||
playevent.delete()
|
||||
return Status(204, None)
|
||||
|
||||
|
||||
@device_router.get("/search", response=list[GameOption])
|
||||
def search_devices(request, q: str = "", limit: int = 10):
|
||||
qs = Device.objects.order_by("name")
|
||||
if q:
|
||||
qs = qs.filter(name__icontains=q)
|
||||
return [{"value": d.id, "label": d.name, "data": {}} for d in qs[:limit]]
|
||||
|
||||
|
||||
@platform_router.get("/search", response=list[GameOption])
|
||||
def search_platforms(request, q: str = "", limit: int = 10):
|
||||
qs = Platform.objects.order_by("name")
|
||||
if q:
|
||||
qs = qs.filter(name__icontains=q)
|
||||
return [{"value": p.id, "label": p.name, "data": {}} for p in qs[:limit]]
|
||||
|
||||
|
||||
api.add_router("/playevent", playevent_router)
|
||||
api.add_router("/games", game_router)
|
||||
api.add_router("/devices", device_router)
|
||||
api.add_router("/platforms", platform_router)
|
||||
|
||||
session_router = Router()
|
||||
|
||||
|
||||
class SessionDeviceUpdate(Schema):
|
||||
device_id: int
|
||||
|
||||
|
||||
@session_router.patch("/{session_id}/device", response={204: None})
|
||||
def partial_update_session_device(
|
||||
request, session_id: int, payload: SessionDeviceUpdate
|
||||
):
|
||||
session = get_object_or_404(Session, id=session_id)
|
||||
session.device_id = payload.device_id
|
||||
session.save()
|
||||
messages.success(request, "Device updated")
|
||||
return Status(204, None)
|
||||
|
||||
|
||||
api.add_router("/session", session_router)
|
||||
@@ -1,46 +1,6 @@
|
||||
# from datetime import timedelta
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.core.management import call_command
|
||||
from django.db.models.signals import post_migrate
|
||||
|
||||
# from django.utils.timezone import now
|
||||
|
||||
|
||||
class GamesConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "games"
|
||||
|
||||
def ready(self):
|
||||
import games.signals # noqa: F401
|
||||
|
||||
post_migrate.connect(schedule_tasks, sender=self)
|
||||
|
||||
|
||||
def schedule_tasks(sender, **kwargs):
|
||||
# from django_q.models import Schedule
|
||||
# from django_q.tasks import schedule
|
||||
|
||||
# if not Schedule.objects.filter(name="Update converted prices").exists():
|
||||
# schedule(
|
||||
# "games.tasks.convert_prices",
|
||||
# name="Update converted prices",
|
||||
# schedule_type=Schedule.MINUTES,
|
||||
# next_run=now() + timedelta(seconds=30),
|
||||
# catchup=False,
|
||||
# )
|
||||
|
||||
# if not Schedule.objects.filter(name="Update price per game").exists():
|
||||
# schedule(
|
||||
# "games.tasks.calculate_price_per_game",
|
||||
# name="Update price per game",
|
||||
# schedule_type=Schedule.MINUTES,
|
||||
# next_run=now() + timedelta(seconds=30),
|
||||
# catchup=False,
|
||||
# )
|
||||
|
||||
from games.models import ExchangeRate
|
||||
|
||||
if not ExchangeRate.objects.exists():
|
||||
print("ExchangeRate table is empty. Loading fixture...")
|
||||
call_command("loaddata", "exchangerates.yaml")
|
||||
|
||||
@@ -1,781 +0,0 @@
|
||||
"""
|
||||
Entity-specific filter types for the timetracker app.
|
||||
|
||||
Each filter class mirrors a Django model, with fields expressed as typed
|
||||
criteria from common.criteria. The to_q() method produces a Django Q object
|
||||
ready for queryset.filter().
|
||||
|
||||
Inspired by Stash's filter architecture: each entity has an OperatorFilter
|
||||
with AND/OR/NOT composition and typed criterion fields.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from django.db.models import Q
|
||||
|
||||
from common.criteria import (
|
||||
BoolCriterion,
|
||||
ChoiceCriterion,
|
||||
FloatCriterion,
|
||||
IntCriterion,
|
||||
Modifier,
|
||||
MultiCriterion,
|
||||
OperatorFilter,
|
||||
StringCriterion,
|
||||
filter_from_json,
|
||||
)
|
||||
|
||||
# ── FindFilter (sort / pagination) ─────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class FindFilter:
|
||||
"""Sorting and pagination, separate from filtering criteria (Stash-style)."""
|
||||
|
||||
q: str | None = None # free-text search
|
||||
page: int = 1
|
||||
per_page: int = 25
|
||||
sort: str | None = None # e.g. "-created_at"
|
||||
direction: str = "desc" # asc / desc
|
||||
|
||||
|
||||
# ── GameFilter ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class GameFilter(OperatorFilter):
|
||||
"""Filter for the Game model."""
|
||||
|
||||
AND: GameFilter | None = None
|
||||
OR: GameFilter | None = None
|
||||
NOT: GameFilter | None = None
|
||||
|
||||
name: StringCriterion | None = None
|
||||
sort_name: StringCriterion | None = None
|
||||
year_released: IntCriterion | None = None
|
||||
original_year_released: IntCriterion | None = None
|
||||
wikidata: StringCriterion | None = None
|
||||
platform: ChoiceCriterion | None = None # selectable filter widget
|
||||
status: ChoiceCriterion | None = None # selectable filter widget
|
||||
mastered: BoolCriterion | None = None
|
||||
playtime_minutes: IntCriterion | None = None # converted to timedelta on to_q()
|
||||
created_at: StringCriterion | None = None # date string
|
||||
updated_at: StringCriterion | None = None # date string
|
||||
|
||||
has_purchases: BoolCriterion | None = None
|
||||
has_playevents: BoolCriterion | None = None
|
||||
session_count: IntCriterion | None = None
|
||||
session_average: IntCriterion | None = None # average in minutes
|
||||
|
||||
# Free-text search (combines name + sort_name + platform name)
|
||||
search: StringCriterion | None = None
|
||||
|
||||
# Cross-entity filters
|
||||
session_filter: SessionFilter | None = None
|
||||
purchase_filter: PurchaseFilter | None = None
|
||||
playevent_filter: PlayEventFilter | None = None
|
||||
platform_filter: PlatformFilter | None = None
|
||||
|
||||
def to_q(self) -> Q:
|
||||
q = Q()
|
||||
|
||||
# ── individual criteria ──
|
||||
if self.name is not None:
|
||||
q &= self.name.to_q("name")
|
||||
if self.sort_name is not None:
|
||||
q &= self.sort_name.to_q("sort_name")
|
||||
if self.year_released is not None:
|
||||
q &= self.year_released.to_q("year_released")
|
||||
if self.original_year_released is not None:
|
||||
q &= self.original_year_released.to_q("original_year_released")
|
||||
if self.wikidata is not None:
|
||||
q &= self.wikidata.to_q("wikidata")
|
||||
if self.platform is not None:
|
||||
q &= self.platform.to_q("platform_id")
|
||||
if self.status is not None:
|
||||
q &= self.status.to_q("status")
|
||||
if self.mastered is not None:
|
||||
q &= self.mastered.to_q("mastered")
|
||||
if self.playtime_minutes is not None:
|
||||
q &= self._playtime_to_q(self.playtime_minutes)
|
||||
if self.created_at is not None:
|
||||
q &= self.created_at.to_q("created_at")
|
||||
if self.updated_at is not None:
|
||||
q &= self.updated_at.to_q("updated_at")
|
||||
|
||||
if self.has_purchases is not None:
|
||||
from games.models import Purchase
|
||||
purchased_ids = Purchase.objects.values_list("games__id", flat=True).distinct()
|
||||
if self.has_purchases.value:
|
||||
q &= Q(id__in=purchased_ids)
|
||||
else:
|
||||
q &= ~Q(id__in=purchased_ids)
|
||||
|
||||
if self.has_playevents is not None:
|
||||
from games.models import PlayEvent
|
||||
played_ids = PlayEvent.objects.values_list("game_id", flat=True).distinct()
|
||||
if self.has_playevents.value:
|
||||
q &= Q(id__in=played_ids)
|
||||
else:
|
||||
q &= ~Q(id__in=played_ids)
|
||||
|
||||
if self.session_count is not None:
|
||||
from games.models import Game
|
||||
from django.db.models import Count
|
||||
matching_ids = Game.objects.annotate(s_count=Count("sessions")).filter(self.session_count.to_q("s_count")).values_list("id", flat=True)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
if self.session_average is not None:
|
||||
from games.models import Game
|
||||
from django.db.models import Avg
|
||||
matching_ids = Game.objects.annotate(s_avg=Avg("sessions__duration_total")).filter(self._playtime_to_q_for_field(self.session_average, "s_avg")).values_list("id", flat=True)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
# ── free-text search (OR across multiple fields) ──
|
||||
if self.search is not None and self.search.value:
|
||||
search_q = (
|
||||
Q(name__icontains=self.search.value)
|
||||
| Q(sort_name__icontains=self.search.value)
|
||||
| Q(platform__name__icontains=self.search.value)
|
||||
)
|
||||
if self.search.modifier == Modifier.EXCLUDES:
|
||||
search_q = ~search_q
|
||||
q &= search_q
|
||||
|
||||
# Cross-entity filters
|
||||
if self.session_filter is not None:
|
||||
from games.models import Session
|
||||
session_q = self.session_filter.to_q()
|
||||
matching_ids = Session.objects.filter(session_q).values_list("game_id", flat=True)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
if self.purchase_filter is not None:
|
||||
from games.models import Purchase
|
||||
purchase_q = self.purchase_filter.to_q()
|
||||
matching_ids = Purchase.objects.filter(purchase_q).values_list("games__id", flat=True)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
if self.playevent_filter is not None:
|
||||
from games.models import PlayEvent
|
||||
playevent_q = self.playevent_filter.to_q()
|
||||
matching_ids = PlayEvent.objects.filter(playevent_q).values_list("game_id", flat=True)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
if self.platform_filter is not None:
|
||||
from games.models import Platform
|
||||
platform_q = self.platform_filter.to_q()
|
||||
matching_ids = Platform.objects.filter(platform_q).values_list("id", flat=True)
|
||||
q &= Q(platform_id__in=matching_ids)
|
||||
|
||||
# ── AND / OR / NOT sub-filters ──
|
||||
sub = self.sub_filter()
|
||||
if sub is not None:
|
||||
if self.AND is not None:
|
||||
q &= sub.to_q()
|
||||
elif self.OR is not None:
|
||||
q |= sub.to_q()
|
||||
elif self.NOT is not None:
|
||||
q &= ~sub.to_q()
|
||||
|
||||
return q
|
||||
|
||||
@staticmethod
|
||||
def _playtime_to_q(c: IntCriterion) -> Q:
|
||||
return GameFilter._playtime_to_q_for_field(c, "playtime")
|
||||
|
||||
@staticmethod
|
||||
def _playtime_to_q_for_field(c: IntCriterion, field: str) -> Q:
|
||||
"""Convert minutes-based criterion to a DurationField Q object.
|
||||
|
||||
Django stores DurationField as microseconds in SQLite, so we convert
|
||||
minutes → timedelta(microseconds=X) and use the appropriate lookups.
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
from common.criteria import Modifier
|
||||
|
||||
m = c.modifier
|
||||
td_val = timedelta(minutes=c.value)
|
||||
|
||||
if m == Modifier.EQUALS:
|
||||
return Q(
|
||||
**{
|
||||
f"{field}__gte": td_val,
|
||||
f"{field}__lt": timedelta(minutes=c.value + 1),
|
||||
}
|
||||
)
|
||||
if m == Modifier.NOT_EQUALS:
|
||||
return ~Q(
|
||||
**{
|
||||
f"{field}__gte": td_val,
|
||||
f"{field}__lt": timedelta(minutes=c.value + 1),
|
||||
}
|
||||
)
|
||||
if m == Modifier.GREATER_THAN:
|
||||
return Q(**{f"{field}__gt": td_val})
|
||||
if m == Modifier.LESS_THAN:
|
||||
return Q(**{f"{field}__lt": td_val})
|
||||
if m == Modifier.BETWEEN and c.value2 is not None:
|
||||
lo = timedelta(minutes=min(c.value, c.value2))
|
||||
hi = timedelta(minutes=max(c.value, c.value2))
|
||||
return Q(**{f"{field}__gte": lo, f"{field}__lte": hi})
|
||||
if m == Modifier.NOT_BETWEEN and c.value2 is not None:
|
||||
lo = timedelta(minutes=min(c.value, c.value2))
|
||||
hi = timedelta(minutes=max(c.value, c.value2))
|
||||
return Q(**{f"{field}__lt": lo}) | Q(**{f"{field}__gt": hi})
|
||||
if m == Modifier.IS_NULL:
|
||||
return Q(**{f"{field}": timedelta(0)})
|
||||
if m == Modifier.NOT_NULL:
|
||||
return ~Q(**{f"{field}": timedelta(0)})
|
||||
return Q()
|
||||
|
||||
|
||||
# ── SessionFilter ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionFilter(OperatorFilter):
|
||||
"""Filter for the Session model."""
|
||||
|
||||
AND: SessionFilter | None = None
|
||||
OR: SessionFilter | None = None
|
||||
NOT: SessionFilter | None = None
|
||||
|
||||
game: MultiCriterion | None = None # filters on game_id
|
||||
device: MultiCriterion | None = None # filters on device_id
|
||||
emulated: BoolCriterion | None = None
|
||||
note: StringCriterion | None = None
|
||||
duration_minutes: IntCriterion | None = None # on duration_total (legacy alias)
|
||||
duration_total_minutes: IntCriterion | None = None
|
||||
duration_manual_minutes: IntCriterion | None = None
|
||||
duration_calculated_minutes: IntCriterion | None = None
|
||||
is_active: BoolCriterion | None = None # timestamp_end IS NULL
|
||||
timestamp_start: StringCriterion | None = None # date string
|
||||
timestamp_end: StringCriterion | None = None # date string
|
||||
is_manual: BoolCriterion | None = None # duration_manual > 0
|
||||
created_at: StringCriterion | None = None
|
||||
|
||||
# Free-text search
|
||||
search: StringCriterion | None = None
|
||||
|
||||
# Cross-entity: sessions for games matching these criteria
|
||||
game_filter: GameFilter | None = None
|
||||
|
||||
# Cross-entity: sessions for devices matching these criteria
|
||||
device_filter: DeviceFilter | None = None
|
||||
|
||||
def _duration_to_q(self, c: IntCriterion, field: str) -> Q:
|
||||
from datetime import timedelta
|
||||
q = Q()
|
||||
td_val = timedelta(minutes=c.value)
|
||||
m = c.modifier
|
||||
if m == Modifier.EQUALS:
|
||||
q &= Q(
|
||||
**{
|
||||
f"{field}__gte": td_val,
|
||||
f"{field}__lt": timedelta(minutes=c.value + 1),
|
||||
}
|
||||
)
|
||||
elif m == Modifier.NOT_EQUALS:
|
||||
q &= ~Q(
|
||||
**{
|
||||
f"{field}__gte": td_val,
|
||||
f"{field}__lt": timedelta(minutes=c.value + 1),
|
||||
}
|
||||
)
|
||||
elif m == Modifier.GREATER_THAN:
|
||||
q &= Q(**{f"{field}__gt": td_val})
|
||||
elif m == Modifier.LESS_THAN:
|
||||
q &= Q(**{f"{field}__lt": td_val})
|
||||
elif m == Modifier.BETWEEN and c.value2 is not None:
|
||||
lo = timedelta(minutes=min(c.value, c.value2))
|
||||
hi = timedelta(minutes=max(c.value, c.value2))
|
||||
q &= Q(**{f"{field}__gte": lo, f"{field}__lte": hi})
|
||||
elif m == Modifier.NOT_BETWEEN and c.value2 is not None:
|
||||
lo = timedelta(minutes=min(c.value, c.value2))
|
||||
hi = timedelta(minutes=max(c.value, c.value2))
|
||||
q &= Q(**{f"{field}__lt": lo}) | Q(**{f"{field}__gt": hi})
|
||||
elif m == Modifier.IS_NULL:
|
||||
q &= Q(**{f"{field}": timedelta(0)})
|
||||
elif m == Modifier.NOT_NULL:
|
||||
q &= ~Q(**{f"{field}": timedelta(0)})
|
||||
return q
|
||||
|
||||
def to_q(self) -> Q:
|
||||
from datetime import timedelta
|
||||
|
||||
q = Q()
|
||||
|
||||
if self.game is not None:
|
||||
q &= self.game.to_q("game_id")
|
||||
if self.device is not None:
|
||||
q &= self.device.to_q("device_id")
|
||||
if self.emulated is not None:
|
||||
q &= self.emulated.to_q("emulated")
|
||||
if self.note is not None:
|
||||
q &= self.note.to_q("note")
|
||||
if self.duration_minutes is not None:
|
||||
q &= self._duration_to_q(self.duration_minutes, "duration_total")
|
||||
if self.duration_total_minutes is not None:
|
||||
q &= self._duration_to_q(self.duration_total_minutes, "duration_total")
|
||||
if self.duration_manual_minutes is not None:
|
||||
q &= self._duration_to_q(self.duration_manual_minutes, "duration_manual")
|
||||
if self.duration_calculated_minutes is not None:
|
||||
q &= self._duration_to_q(self.duration_calculated_minutes, "duration_calculated")
|
||||
if self.is_active is not None:
|
||||
if self.is_active.value:
|
||||
q &= Q(timestamp_end__isnull=True)
|
||||
else:
|
||||
q &= Q(timestamp_end__isnull=False)
|
||||
if self.timestamp_start is not None:
|
||||
q &= self.timestamp_start.to_q("timestamp_start")
|
||||
if self.timestamp_end is not None:
|
||||
q &= self.timestamp_end.to_q("timestamp_end")
|
||||
if self.is_manual is not None:
|
||||
if self.is_manual.value:
|
||||
q &= ~Q(duration_manual=timedelta(0))
|
||||
else:
|
||||
q &= Q(duration_manual=timedelta(0))
|
||||
if self.created_at is not None:
|
||||
q &= self.created_at.to_q("created_at")
|
||||
|
||||
# Free-text search
|
||||
if self.search is not None and self.search.value:
|
||||
search_q = (
|
||||
Q(game__name__icontains=self.search.value)
|
||||
| Q(game__platform__name__icontains=self.search.value)
|
||||
| Q(device__name__icontains=self.search.value)
|
||||
| Q(device__type__icontains=self.search.value)
|
||||
)
|
||||
if self.search.modifier == Modifier.EXCLUDES:
|
||||
search_q = ~search_q
|
||||
q &= search_q
|
||||
|
||||
# Cross-entity filter: sessions for games matching GameFilter
|
||||
if self.game_filter is not None:
|
||||
from games.models import Game
|
||||
|
||||
game_q = self.game_filter.to_q()
|
||||
matching_ids = Game.objects.filter(game_q).values_list("id", flat=True)
|
||||
q &= Q(game_id__in=matching_ids)
|
||||
|
||||
# Cross-entity filter: sessions for devices matching DeviceFilter
|
||||
if self.device_filter is not None:
|
||||
from games.models import Device
|
||||
|
||||
device_q = self.device_filter.to_q()
|
||||
matching_ids = Device.objects.filter(device_q).values_list("id", flat=True)
|
||||
q &= Q(device_id__in=matching_ids)
|
||||
|
||||
# AND / OR / NOT
|
||||
sub = self.sub_filter()
|
||||
if sub is not None:
|
||||
if self.AND is not None:
|
||||
q &= sub.to_q()
|
||||
elif self.OR is not None:
|
||||
q |= sub.to_q()
|
||||
elif self.NOT is not None:
|
||||
q &= ~sub.to_q()
|
||||
|
||||
return q
|
||||
|
||||
|
||||
# ── PurchaseFilter ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class PurchaseFilter(OperatorFilter):
|
||||
"""Filter for the Purchase model."""
|
||||
|
||||
AND: PurchaseFilter | None = None
|
||||
OR: PurchaseFilter | None = None
|
||||
NOT: PurchaseFilter | None = None
|
||||
|
||||
name: StringCriterion | None = None
|
||||
platform: ChoiceCriterion | None = None # platform_id
|
||||
games: ChoiceCriterion | None = None # games (M2M IDs)
|
||||
date_purchased: StringCriterion | None = None # date string
|
||||
date_refunded: StringCriterion | None = None # date string
|
||||
is_refunded: BoolCriterion | None = None # date_refunded IS NOT NULL
|
||||
price: FloatCriterion | None = None # on price field
|
||||
converted_price: FloatCriterion | None = None
|
||||
price_currency: StringCriterion | None = None
|
||||
num_purchases: IntCriterion | None = None
|
||||
ownership_type: ChoiceCriterion | None = None # ph/di/du/re/bo/tr/de/pi
|
||||
type: ChoiceCriterion | None = None # game/dlc/season_pass/battle_pass
|
||||
created_at: StringCriterion | None = None
|
||||
updated_at: StringCriterion | None = None
|
||||
|
||||
infinite: BoolCriterion | None = None
|
||||
needs_price_update: BoolCriterion | None = None
|
||||
converted_currency: StringCriterion | None = None
|
||||
|
||||
# Free-text search
|
||||
search: StringCriterion | None = None
|
||||
|
||||
# Cross-entity: purchases for games matching these criteria
|
||||
game_filter: GameFilter | None = None
|
||||
|
||||
# Cross-entity: purchases for platforms matching these criteria
|
||||
platform_filter: PlatformFilter | None = None
|
||||
|
||||
def to_q(self) -> Q:
|
||||
q = Q()
|
||||
|
||||
if self.name is not None:
|
||||
q &= self.name.to_q("name")
|
||||
if self.platform is not None:
|
||||
q &= self.platform.to_q("platform_id")
|
||||
if self.games is not None:
|
||||
q &= self._games_to_q(self.games)
|
||||
if self.date_purchased is not None:
|
||||
q &= self.date_purchased.to_q("date_purchased")
|
||||
if self.date_refunded is not None:
|
||||
q &= self.date_refunded.to_q("date_refunded")
|
||||
if self.is_refunded is not None:
|
||||
q &= Q(date_refunded__isnull=not self.is_refunded.value)
|
||||
if self.price is not None:
|
||||
q &= self.price.to_q("price")
|
||||
if self.converted_price is not None:
|
||||
q &= self.converted_price.to_q("converted_price")
|
||||
if self.price_currency is not None:
|
||||
q &= self.price_currency.to_q("price_currency")
|
||||
if self.num_purchases is not None:
|
||||
q &= self.num_purchases.to_q("num_purchases")
|
||||
if self.ownership_type is not None:
|
||||
q &= self.ownership_type.to_q("ownership_type")
|
||||
if self.type is not None:
|
||||
q &= self.type.to_q("type")
|
||||
if self.created_at is not None:
|
||||
q &= self.created_at.to_q("created_at")
|
||||
if self.updated_at is not None:
|
||||
q &= self.updated_at.to_q("updated_at")
|
||||
if self.infinite is not None:
|
||||
q &= self.infinite.to_q("infinite")
|
||||
if self.needs_price_update is not None:
|
||||
q &= self.needs_price_update.to_q("needs_price_update")
|
||||
if self.converted_currency is not None:
|
||||
q &= self.converted_currency.to_q("converted_currency")
|
||||
|
||||
# Free-text search
|
||||
if self.search is not None and self.search.value:
|
||||
search_q = (
|
||||
Q(name__icontains=self.search.value)
|
||||
| Q(games__name__icontains=self.search.value)
|
||||
| Q(platform__name__icontains=self.search.value)
|
||||
)
|
||||
if self.search.modifier == Modifier.EXCLUDES:
|
||||
search_q = ~search_q
|
||||
q &= search_q
|
||||
|
||||
# Cross-entity filter
|
||||
if self.game_filter is not None:
|
||||
from games.models import Game
|
||||
|
||||
game_q = self.game_filter.to_q()
|
||||
matching_ids = Game.objects.filter(game_q).values_list("id", flat=True)
|
||||
q &= Q(games__id__in=matching_ids)
|
||||
|
||||
# Cross-entity platform filter
|
||||
if self.platform_filter is not None:
|
||||
from games.models import Platform
|
||||
|
||||
platform_q = self.platform_filter.to_q()
|
||||
matching_ids = Platform.objects.filter(platform_q).values_list("id", flat=True)
|
||||
q &= Q(platform_id__in=matching_ids)
|
||||
|
||||
sub = self.sub_filter()
|
||||
if sub is not None:
|
||||
if self.AND is not None:
|
||||
q &= sub.to_q()
|
||||
elif self.OR is not None:
|
||||
q |= sub.to_q()
|
||||
elif self.NOT is not None:
|
||||
q &= ~sub.to_q()
|
||||
|
||||
return q
|
||||
|
||||
@staticmethod
|
||||
def _games_to_q(criterion: ChoiceCriterion) -> Q:
|
||||
"""Build the Q for the many-to-many ``games`` field.
|
||||
|
||||
``INCLUDES_ALL`` ("related to every selected game") and
|
||||
``INCLUDES_ONLY`` ("related to exactly these, nothing else") cannot be
|
||||
a single ``.filter(Q(games=a) & Q(games=b))`` — that collapses to one
|
||||
join and would require a single link row to be both games. Instead
|
||||
chain a filter per game so each gets its own join, then match by
|
||||
``pk``. ``INCLUDES_ONLY`` additionally excludes purchases that have
|
||||
any game outside the specified set.
|
||||
|
||||
``INCLUDES`` (plain "any") also uses a subquery instead of a raw
|
||||
``games__in`` join because a single purchase linked to *n* of the
|
||||
given games would appear *n* times in the result set (M2M join
|
||||
duplicates).
|
||||
|
||||
The orthogonal ``excludes`` channel is applied as a negative,
|
||||
consistent with every other modifier. All other modifiers delegate
|
||||
to the criterion.
|
||||
"""
|
||||
# Empty value means no constraint; still apply excludes if any
|
||||
if not criterion.value:
|
||||
if criterion.excludes:
|
||||
return ~Q(games__in=criterion.excludes)
|
||||
return Q()
|
||||
|
||||
from games.models import Game, Purchase
|
||||
|
||||
if criterion.modifier in (Modifier.INCLUDES_ALL, Modifier.INCLUDES_ONLY):
|
||||
subquery = Purchase.objects.all()
|
||||
for game_id in criterion.value:
|
||||
subquery = subquery.filter(games=game_id)
|
||||
|
||||
if criterion.modifier == Modifier.INCLUDES_ONLY:
|
||||
extra_ids = Game.objects.exclude(
|
||||
id__in=criterion.value
|
||||
).values_list("id", flat=True)
|
||||
if extra_ids:
|
||||
subquery = subquery.exclude(games__in=extra_ids)
|
||||
|
||||
q = Q(pk__in=subquery.values("pk"))
|
||||
if criterion.excludes:
|
||||
q &= ~Q(games__in=criterion.excludes)
|
||||
return q
|
||||
|
||||
if criterion.modifier == Modifier.INCLUDES:
|
||||
# Use subquery to avoid duplicate rows from M2M join
|
||||
subquery = Purchase.objects.filter(games__in=criterion.value)
|
||||
q = Q(pk__in=subquery.values("pk"))
|
||||
if criterion.excludes:
|
||||
q &= ~Q(games__in=criterion.excludes)
|
||||
return q
|
||||
|
||||
return criterion.to_q("games")
|
||||
|
||||
|
||||
# ── DeviceFilter ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceFilter(OperatorFilter):
|
||||
"""Filter for the Device model."""
|
||||
|
||||
AND: DeviceFilter | None = None
|
||||
OR: DeviceFilter | None = None
|
||||
NOT: DeviceFilter | None = None
|
||||
|
||||
name: StringCriterion | None = None
|
||||
type: ChoiceCriterion | None = None
|
||||
created_at: StringCriterion | None = None
|
||||
|
||||
# Free-text search
|
||||
search: StringCriterion | None = None
|
||||
|
||||
# Cross-entity: Devices that have sessions matching these criteria
|
||||
session_filter: SessionFilter | None = None
|
||||
|
||||
def to_q(self) -> Q:
|
||||
q = Q()
|
||||
|
||||
if self.name is not None:
|
||||
q &= self.name.to_q("name")
|
||||
if self.type is not None:
|
||||
q &= self.type.to_q("type")
|
||||
if self.created_at is not None:
|
||||
q &= self.created_at.to_q("created_at")
|
||||
|
||||
# Free-text search
|
||||
if self.search is not None and self.search.value:
|
||||
search_q = (
|
||||
Q(name__icontains=self.search.value)
|
||||
| Q(type__icontains=self.search.value)
|
||||
)
|
||||
if self.search.modifier == Modifier.EXCLUDES:
|
||||
search_q = ~search_q
|
||||
q &= search_q
|
||||
|
||||
# Cross-entity filter: session_filter
|
||||
if self.session_filter is not None:
|
||||
from games.models import Session
|
||||
session_q = self.session_filter.to_q()
|
||||
matching_ids = Session.objects.filter(session_q).values_list("device_id", flat=True)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
sub = self.sub_filter()
|
||||
if sub is not None:
|
||||
if self.AND is not None:
|
||||
q &= sub.to_q()
|
||||
elif self.OR is not None:
|
||||
q |= sub.to_q()
|
||||
elif self.NOT is not None:
|
||||
q &= ~sub.to_q()
|
||||
|
||||
return q
|
||||
|
||||
|
||||
# ── PlatformFilter ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlatformFilter(OperatorFilter):
|
||||
"""Filter for the Platform model."""
|
||||
|
||||
AND: PlatformFilter | None = None
|
||||
OR: PlatformFilter | None = None
|
||||
NOT: PlatformFilter | None = None
|
||||
|
||||
name: StringCriterion | None = None
|
||||
group: StringCriterion | None = None
|
||||
icon: StringCriterion | None = None
|
||||
created_at: StringCriterion | None = None
|
||||
|
||||
# Free-text search
|
||||
search: StringCriterion | None = None
|
||||
|
||||
# Cross-entity
|
||||
game_filter: GameFilter | None = None
|
||||
purchase_filter: PurchaseFilter | None = None
|
||||
|
||||
def to_q(self) -> Q:
|
||||
q = Q()
|
||||
|
||||
if self.name is not None:
|
||||
q &= self.name.to_q("name")
|
||||
if self.group is not None:
|
||||
q &= self.group.to_q("group")
|
||||
if self.icon is not None:
|
||||
q &= self.icon.to_q("icon")
|
||||
if self.created_at is not None:
|
||||
q &= self.created_at.to_q("created_at")
|
||||
|
||||
# Free-text search
|
||||
if self.search is not None and self.search.value:
|
||||
search_q = (
|
||||
Q(name__icontains=self.search.value)
|
||||
| Q(group__icontains=self.search.value)
|
||||
)
|
||||
if self.search.modifier == Modifier.EXCLUDES:
|
||||
search_q = ~search_q
|
||||
q &= search_q
|
||||
|
||||
# Cross-entity filter: game_filter
|
||||
if self.game_filter is not None:
|
||||
from games.models import Game
|
||||
game_q = self.game_filter.to_q()
|
||||
matching_ids = Game.objects.filter(game_q).values_list("platform_id", flat=True)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
# Cross-entity filter: purchase_filter
|
||||
if self.purchase_filter is not None:
|
||||
from games.models import Purchase
|
||||
purchase_q = self.purchase_filter.to_q()
|
||||
matching_ids = Purchase.objects.filter(purchase_q).values_list("platform_id", flat=True)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
sub = self.sub_filter()
|
||||
if sub is not None:
|
||||
if self.AND is not None:
|
||||
q &= sub.to_q()
|
||||
elif self.OR is not None:
|
||||
q |= sub.to_q()
|
||||
elif self.NOT is not None:
|
||||
q &= ~sub.to_q()
|
||||
|
||||
return q
|
||||
|
||||
|
||||
# ── PlayEventFilter ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlayEventFilter(OperatorFilter):
|
||||
"""Filter for the PlayEvent model."""
|
||||
|
||||
AND: PlayEventFilter | None = None
|
||||
OR: PlayEventFilter | None = None
|
||||
NOT: PlayEventFilter | None = None
|
||||
|
||||
game: MultiCriterion | None = None # filters on game_id
|
||||
started: StringCriterion | None = None # date string
|
||||
ended: StringCriterion | None = None # date string
|
||||
days_to_finish: IntCriterion | None = None
|
||||
note: StringCriterion | None = None
|
||||
created_at: StringCriterion | None = None
|
||||
|
||||
# Free-text search
|
||||
search: StringCriterion | None = None
|
||||
|
||||
# Cross-entity: PlayEvents for games matching these criteria
|
||||
game_filter: GameFilter | None = None
|
||||
|
||||
def to_q(self) -> Q:
|
||||
q = Q()
|
||||
|
||||
if self.game is not None:
|
||||
q &= self.game.to_q("game_id")
|
||||
if self.started is not None:
|
||||
q &= self.started.to_q("started")
|
||||
if self.ended is not None:
|
||||
q &= self.ended.to_q("ended")
|
||||
if self.days_to_finish is not None:
|
||||
q &= self.days_to_finish.to_q("days_to_finish")
|
||||
if self.note is not None:
|
||||
q &= self.note.to_q("note")
|
||||
if self.created_at is not None:
|
||||
q &= self.created_at.to_q("created_at")
|
||||
|
||||
# Free-text search
|
||||
if self.search is not None and self.search.value:
|
||||
search_q = (
|
||||
Q(game__name__icontains=self.search.value)
|
||||
| Q(note__icontains=self.search.value)
|
||||
)
|
||||
if self.search.modifier == Modifier.EXCLUDES:
|
||||
search_q = ~search_q
|
||||
q &= search_q
|
||||
|
||||
# Cross-entity filter: game_filter
|
||||
if self.game_filter is not None:
|
||||
from games.models import Game
|
||||
game_q = self.game_filter.to_q()
|
||||
matching_ids = Game.objects.filter(game_q).values_list("id", flat=True)
|
||||
q &= Q(game_id__in=matching_ids)
|
||||
|
||||
sub = self.sub_filter()
|
||||
if sub is not None:
|
||||
if self.AND is not None:
|
||||
q &= sub.to_q()
|
||||
elif self.OR is not None:
|
||||
q |= sub.to_q()
|
||||
elif self.NOT is not None:
|
||||
q &= ~sub.to_q()
|
||||
|
||||
return q
|
||||
|
||||
|
||||
# ── Convenience helpers ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def parse_game_filter(json_str: str) -> GameFilter | None:
|
||||
return filter_from_json(GameFilter, json_str)
|
||||
|
||||
|
||||
def parse_session_filter(json_str: str) -> SessionFilter | None:
|
||||
return filter_from_json(SessionFilter, json_str)
|
||||
|
||||
|
||||
def parse_purchase_filter(json_str: str) -> PurchaseFilter | None:
|
||||
return filter_from_json(PurchaseFilter, json_str)
|
||||
|
||||
|
||||
def parse_device_filter(json_str: str) -> DeviceFilter | None:
|
||||
return filter_from_json(DeviceFilter, json_str)
|
||||
|
||||
|
||||
def parse_platform_filter(json_str: str) -> PlatformFilter | None:
|
||||
return filter_from_json(PlatformFilter, json_str)
|
||||
|
||||
|
||||
def parse_playevent_filter(json_str: str) -> PlayEventFilter | None:
|
||||
return filter_from_json(PlayEventFilter, json_str)
|
||||
@@ -1,504 +0,0 @@
|
||||
- model: games.exchangerate
|
||||
pk: 1
|
||||
fields:
|
||||
currency_from: USD
|
||||
currency_to: CZK
|
||||
year: 2024
|
||||
rate: 23.4
|
||||
- model: games.exchangerate
|
||||
pk: 2
|
||||
fields:
|
||||
currency_from: CNY
|
||||
currency_to: CZK
|
||||
year: 2024
|
||||
rate: 3.267
|
||||
- model: games.exchangerate
|
||||
pk: 3
|
||||
fields:
|
||||
currency_from: USD
|
||||
currency_to: CZK
|
||||
year: 2019
|
||||
rate: 22.466
|
||||
- model: games.exchangerate
|
||||
pk: 4
|
||||
fields:
|
||||
currency_from: USD
|
||||
currency_to: CZK
|
||||
year: 2023
|
||||
rate: 22.63
|
||||
- model: games.exchangerate
|
||||
pk: 5
|
||||
fields:
|
||||
currency_from: USD
|
||||
currency_to: CZK
|
||||
year: 2017
|
||||
rate: 25.819
|
||||
- model: games.exchangerate
|
||||
pk: 6
|
||||
fields:
|
||||
currency_from: USD
|
||||
currency_to: CZK
|
||||
year: 2013
|
||||
rate: 19.023
|
||||
- model: games.exchangerate
|
||||
pk: 7
|
||||
fields:
|
||||
currency_from: CNY
|
||||
currency_to: CZK
|
||||
year: 2019
|
||||
rate: 3.295
|
||||
- model: games.exchangerate
|
||||
pk: 8
|
||||
fields:
|
||||
currency_from: CNY
|
||||
currency_to: CZK
|
||||
year: 2016
|
||||
rate: 3.795
|
||||
- model: games.exchangerate
|
||||
pk: 9
|
||||
fields:
|
||||
currency_from: CNY
|
||||
currency_to: CZK
|
||||
year: 2015
|
||||
rate: 3.707
|
||||
- model: games.exchangerate
|
||||
pk: 10
|
||||
fields:
|
||||
currency_from: CNY
|
||||
currency_to: CZK
|
||||
year: 2020
|
||||
rate: 3.26
|
||||
- model: games.exchangerate
|
||||
pk: 11
|
||||
fields:
|
||||
currency_from: EUR
|
||||
currency_to: CZK
|
||||
year: 2012
|
||||
rate: 25.51
|
||||
- model: games.exchangerate
|
||||
pk: 12
|
||||
fields:
|
||||
currency_from: EUR
|
||||
currency_to: CZK
|
||||
year: 2010
|
||||
rate: 26.465
|
||||
- model: games.exchangerate
|
||||
pk: 13
|
||||
fields:
|
||||
currency_from: EUR
|
||||
currency_to: CZK
|
||||
year: 2014
|
||||
rate: 27.52
|
||||
- model: games.exchangerate
|
||||
pk: 14
|
||||
fields:
|
||||
currency_from: EUR
|
||||
currency_to: CZK
|
||||
year: 2024
|
||||
rate: 25.21
|
||||
- model: games.exchangerate
|
||||
pk: 15
|
||||
fields:
|
||||
currency_from: EUR
|
||||
currency_to: CZK
|
||||
year: 2022
|
||||
rate: 24.325
|
||||
- model: games.exchangerate
|
||||
pk: 16
|
||||
fields:
|
||||
currency_from: CNY
|
||||
currency_to: CZK
|
||||
year: 2018
|
||||
rate: 3.268
|
||||
- model: games.exchangerate
|
||||
pk: 17
|
||||
fields:
|
||||
currency_from: CNY
|
||||
currency_to: CZK
|
||||
year: 2023
|
||||
rate: 3.281
|
||||
- model: games.exchangerate
|
||||
pk: 18
|
||||
fields:
|
||||
currency_from: EUR
|
||||
currency_to: CZK
|
||||
year: 2009
|
||||
rate: 26.445
|
||||
- model: games.exchangerate
|
||||
pk: 19
|
||||
fields:
|
||||
currency_from: CNY
|
||||
currency_to: CZK
|
||||
year: 2025
|
||||
rate: 3.35
|
||||
- model: games.exchangerate
|
||||
pk: 20
|
||||
fields:
|
||||
currency_from: EUR
|
||||
currency_to: CZK
|
||||
year: 2016
|
||||
rate: 27.033
|
||||
- model: games.exchangerate
|
||||
pk: 21
|
||||
fields:
|
||||
currency_from: EUR
|
||||
currency_to: CZK
|
||||
year: 2025
|
||||
rate: 25.2021966
|
||||
- model: games.exchangerate
|
||||
pk: 22
|
||||
fields:
|
||||
currency_from: EUR
|
||||
currency_to: CZK
|
||||
year: 2017
|
||||
rate: 26.33
|
||||
- model: games.exchangerate
|
||||
pk: 23
|
||||
fields:
|
||||
currency_from: EUR
|
||||
currency_to: CZK
|
||||
year: 2000
|
||||
rate: 36.13
|
||||
- model: games.exchangerate
|
||||
pk: 24
|
||||
fields:
|
||||
currency_from: USD
|
||||
currency_to: CZK
|
||||
year: 2000
|
||||
rate: 35.979
|
||||
- model: games.exchangerate
|
||||
pk: 25
|
||||
fields:
|
||||
currency_from: EUR
|
||||
currency_to: CZK
|
||||
year: 2001
|
||||
rate: 35.09
|
||||
- model: games.exchangerate
|
||||
pk: 26
|
||||
fields:
|
||||
currency_from: USD
|
||||
currency_to: CZK
|
||||
year: 2001
|
||||
rate: 37.813
|
||||
- model: games.exchangerate
|
||||
pk: 27
|
||||
fields:
|
||||
currency_from: EUR
|
||||
currency_to: CZK
|
||||
year: 2002
|
||||
rate: 31.98
|
||||
- model: games.exchangerate
|
||||
pk: 28
|
||||
fields:
|
||||
currency_from: USD
|
||||
currency_to: CZK
|
||||
year: 2002
|
||||
rate: 36.259
|
||||
- model: games.exchangerate
|
||||
pk: 29
|
||||
fields:
|
||||
currency_from: EUR
|
||||
currency_to: CZK
|
||||
year: 2003
|
||||
rate: 31.6
|
||||
- model: games.exchangerate
|
||||
pk: 30
|
||||
fields:
|
||||
currency_from: USD
|
||||
currency_to: CZK
|
||||
year: 2003
|
||||
rate: 30.141
|
||||
- model: games.exchangerate
|
||||
pk: 31
|
||||
fields:
|
||||
currency_from: EUR
|
||||
currency_to: CZK
|
||||
year: 2004
|
||||
rate: 32.405
|
||||
- model: games.exchangerate
|
||||
pk: 32
|
||||
fields:
|
||||
currency_from: USD
|
||||
currency_to: CZK
|
||||
year: 2004
|
||||
rate: 25.654
|
||||
- model: games.exchangerate
|
||||
pk: 33
|
||||
fields:
|
||||
currency_from: EUR
|
||||
currency_to: CZK
|
||||
year: 2005
|
||||
rate: 30.465
|
||||
- model: games.exchangerate
|
||||
pk: 34
|
||||
fields:
|
||||
currency_from: USD
|
||||
currency_to: CZK
|
||||
year: 2005
|
||||
rate: 22.365
|
||||
- model: games.exchangerate
|
||||
pk: 35
|
||||
fields:
|
||||
currency_from: EUR
|
||||
currency_to: CZK
|
||||
year: 2006
|
||||
rate: 29.005
|
||||
- model: games.exchangerate
|
||||
pk: 36
|
||||
fields:
|
||||
currency_from: USD
|
||||
currency_to: CZK
|
||||
year: 2006
|
||||
rate: 24.588
|
||||
- model: games.exchangerate
|
||||
pk: 37
|
||||
fields:
|
||||
currency_from: CNY
|
||||
currency_to: CZK
|
||||
year: 2006
|
||||
rate: 3.047
|
||||
- model: games.exchangerate
|
||||
pk: 38
|
||||
fields:
|
||||
currency_from: EUR
|
||||
currency_to: CZK
|
||||
year: 2007
|
||||
rate: 27.495
|
||||
- model: games.exchangerate
|
||||
pk: 39
|
||||
fields:
|
||||
currency_from: USD
|
||||
currency_to: CZK
|
||||
year: 2007
|
||||
rate: 20.876
|
||||
- model: games.exchangerate
|
||||
pk: 40
|
||||
fields:
|
||||
currency_from: CNY
|
||||
currency_to: CZK
|
||||
year: 2007
|
||||
rate: 2.674
|
||||
- model: games.exchangerate
|
||||
pk: 41
|
||||
fields:
|
||||
currency_from: EUR
|
||||
currency_to: CZK
|
||||
year: 2008
|
||||
rate: 26.62
|
||||
- model: games.exchangerate
|
||||
pk: 42
|
||||
fields:
|
||||
currency_from: USD
|
||||
currency_to: CZK
|
||||
year: 2008
|
||||
rate: 18.078
|
||||
- model: games.exchangerate
|
||||
pk: 43
|
||||
fields:
|
||||
currency_from: CNY
|
||||
currency_to: CZK
|
||||
year: 2008
|
||||
rate: 2.475
|
||||
- model: games.exchangerate
|
||||
pk: 44
|
||||
fields:
|
||||
currency_from: USD
|
||||
currency_to: CZK
|
||||
year: 2009
|
||||
rate: 19.346
|
||||
- model: games.exchangerate
|
||||
pk: 45
|
||||
fields:
|
||||
currency_from: CNY
|
||||
currency_to: CZK
|
||||
year: 2009
|
||||
rate: 2.836
|
||||
- model: games.exchangerate
|
||||
pk: 46
|
||||
fields:
|
||||
currency_from: USD
|
||||
currency_to: CZK
|
||||
year: 2010
|
||||
rate: 18.368
|
||||
- model: games.exchangerate
|
||||
pk: 47
|
||||
fields:
|
||||
currency_from: CNY
|
||||
currency_to: CZK
|
||||
year: 2010
|
||||
rate: 2.691
|
||||
- model: games.exchangerate
|
||||
pk: 48
|
||||
fields:
|
||||
currency_from: EUR
|
||||
currency_to: CZK
|
||||
year: 2011
|
||||
rate: 25.06
|
||||
- model: games.exchangerate
|
||||
pk: 49
|
||||
fields:
|
||||
currency_from: USD
|
||||
currency_to: CZK
|
||||
year: 2011
|
||||
rate: 18.751
|
||||
- model: games.exchangerate
|
||||
pk: 50
|
||||
fields:
|
||||
currency_from: CNY
|
||||
currency_to: CZK
|
||||
year: 2011
|
||||
rate: 2.845
|
||||
- model: games.exchangerate
|
||||
pk: 51
|
||||
fields:
|
||||
currency_from: USD
|
||||
currency_to: CZK
|
||||
year: 2012
|
||||
rate: 19.94
|
||||
- model: games.exchangerate
|
||||
pk: 52
|
||||
fields:
|
||||
currency_from: CNY
|
||||
currency_to: CZK
|
||||
year: 2012
|
||||
rate: 3.168
|
||||
- model: games.exchangerate
|
||||
pk: 53
|
||||
fields:
|
||||
currency_from: EUR
|
||||
currency_to: CZK
|
||||
year: 2013
|
||||
rate: 25.14
|
||||
- model: games.exchangerate
|
||||
pk: 54
|
||||
fields:
|
||||
currency_from: CNY
|
||||
currency_to: CZK
|
||||
year: 2013
|
||||
rate: 3.059
|
||||
- model: games.exchangerate
|
||||
pk: 55
|
||||
fields:
|
||||
currency_from: USD
|
||||
currency_to: CZK
|
||||
year: 2014
|
||||
rate: 19.894
|
||||
- model: games.exchangerate
|
||||
pk: 56
|
||||
fields:
|
||||
currency_from: CNY
|
||||
currency_to: CZK
|
||||
year: 2014
|
||||
rate: 3.286
|
||||
- model: games.exchangerate
|
||||
pk: 57
|
||||
fields:
|
||||
currency_from: EUR
|
||||
currency_to: CZK
|
||||
year: 2015
|
||||
rate: 27.725
|
||||
- model: games.exchangerate
|
||||
pk: 58
|
||||
fields:
|
||||
currency_from: USD
|
||||
currency_to: CZK
|
||||
year: 2015
|
||||
rate: 22.834
|
||||
- model: games.exchangerate
|
||||
pk: 59
|
||||
fields:
|
||||
currency_from: USD
|
||||
currency_to: CZK
|
||||
year: 2016
|
||||
rate: 24.824
|
||||
- model: games.exchangerate
|
||||
pk: 60
|
||||
fields:
|
||||
currency_from: CNY
|
||||
currency_to: CZK
|
||||
year: 2017
|
||||
rate: 3.693
|
||||
- model: games.exchangerate
|
||||
pk: 61
|
||||
fields:
|
||||
currency_from: EUR
|
||||
currency_to: CZK
|
||||
year: 2018
|
||||
rate: 25.54
|
||||
- model: games.exchangerate
|
||||
pk: 62
|
||||
fields:
|
||||
currency_from: USD
|
||||
currency_to: CZK
|
||||
year: 2018
|
||||
rate: 21.291
|
||||
- model: games.exchangerate
|
||||
pk: 63
|
||||
fields:
|
||||
currency_from: EUR
|
||||
currency_to: CZK
|
||||
year: 2019
|
||||
rate: 25.725
|
||||
- model: games.exchangerate
|
||||
pk: 64
|
||||
fields:
|
||||
currency_from: EUR
|
||||
currency_to: CZK
|
||||
year: 2020
|
||||
rate: 25.41
|
||||
- model: games.exchangerate
|
||||
pk: 65
|
||||
fields:
|
||||
currency_from: USD
|
||||
currency_to: CZK
|
||||
year: 2020
|
||||
rate: 22.621
|
||||
- model: games.exchangerate
|
||||
pk: 66
|
||||
fields:
|
||||
currency_from: EUR
|
||||
currency_to: CZK
|
||||
year: 2021
|
||||
rate: 26.245
|
||||
- model: games.exchangerate
|
||||
pk: 67
|
||||
fields:
|
||||
currency_from: USD
|
||||
currency_to: CZK
|
||||
year: 2021
|
||||
rate: 21.387
|
||||
- model: games.exchangerate
|
||||
pk: 68
|
||||
fields:
|
||||
currency_from: CNY
|
||||
currency_to: CZK
|
||||
year: 2021
|
||||
rate: 3.273
|
||||
- model: games.exchangerate
|
||||
pk: 69
|
||||
fields:
|
||||
currency_from: USD
|
||||
currency_to: CZK
|
||||
year: 2022
|
||||
rate: 21.951
|
||||
- model: games.exchangerate
|
||||
pk: 70
|
||||
fields:
|
||||
currency_from: CNY
|
||||
currency_to: CZK
|
||||
year: 2022
|
||||
rate: 3.458
|
||||
- model: games.exchangerate
|
||||
pk: 71
|
||||
fields:
|
||||
currency_from: EUR
|
||||
currency_to: CZK
|
||||
year: 2023
|
||||
rate: 24.115
|
||||
- model: games.exchangerate
|
||||
pk: 72
|
||||
fields:
|
||||
currency_from: USD
|
||||
currency_to: CZK
|
||||
year: 2025
|
||||
rate: 24.237
|
||||
@@ -2,34 +2,27 @@
|
||||
fields:
|
||||
name: Steam
|
||||
group: PC
|
||||
created_at: 2024-01-01T00:00:00Z
|
||||
- model: games.Platform
|
||||
fields:
|
||||
name: Xbox Gamepass
|
||||
group: PC
|
||||
created_at: 2024-01-01T00:00:00Z
|
||||
- model: games.Platform
|
||||
fields:
|
||||
name: Epic Games Store
|
||||
group: PC
|
||||
created_at: 2024-01-01T00:00:00Z
|
||||
- model: games.Platform
|
||||
fields:
|
||||
name: Playstation 5
|
||||
group: Playstation
|
||||
created_at: 2024-01-01T00:00:00Z
|
||||
- model: games.Platform
|
||||
fields:
|
||||
name: Playstation 4
|
||||
group: Playstation
|
||||
created_at: 2024-01-01T00:00:00Z
|
||||
- model: games.Platform
|
||||
fields:
|
||||
name: Nintendo Switch
|
||||
group: Nintendo
|
||||
created_at: 2024-01-01T00:00:00Z
|
||||
- model: games.Platform
|
||||
fields:
|
||||
name: Nintendo 3DS
|
||||
group: Nintendo
|
||||
created_at: 2024-01-01T00:00:00Z
|
||||
group: Nintendo
|
||||
+60
-281
@@ -1,22 +1,8 @@
|
||||
from django import forms
|
||||
from django.db import transaction
|
||||
from django.db.models import OuterRef, Subquery
|
||||
from django.urls import reverse
|
||||
|
||||
from common.components import (
|
||||
DEFAULT_PREFETCH,
|
||||
SearchSelect,
|
||||
SearchSelectOption,
|
||||
searchselect_selected,
|
||||
)
|
||||
from games.models import (
|
||||
Device,
|
||||
Game,
|
||||
GameStatusChange,
|
||||
Platform,
|
||||
PlayEvent,
|
||||
Purchase,
|
||||
Session,
|
||||
)
|
||||
from common.utils import safe_getattr
|
||||
from games.models import Device, Edition, Game, Platform, Purchase, Session
|
||||
|
||||
custom_date_widget = forms.DateInput(attrs={"type": "date"})
|
||||
custom_datetime_widget = forms.DateTimeInput(
|
||||
@@ -25,137 +11,16 @@ custom_datetime_widget = forms.DateTimeInput(
|
||||
autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"})
|
||||
|
||||
|
||||
class MultipleGameChoiceField(forms.ModelMultipleChoiceField):
|
||||
def label_from_instance(self, obj) -> str:
|
||||
return obj.search_label
|
||||
|
||||
|
||||
class SingleGameChoiceField(forms.ModelChoiceField):
|
||||
def label_from_instance(self, obj) -> str:
|
||||
return obj.search_label
|
||||
|
||||
|
||||
def _game_options(values) -> list[SearchSelectOption]:
|
||||
"""Resolve game ids (or instances) to SearchSelectOptions via one pk__in query."""
|
||||
return [
|
||||
{
|
||||
"value": g.id,
|
||||
"label": g.search_label,
|
||||
"data": {"platform": g.platform_id or ""},
|
||||
}
|
||||
for g in Game.objects.filter(pk__in=values).select_related("platform")
|
||||
]
|
||||
|
||||
|
||||
def _device_options(values) -> list[SearchSelectOption]:
|
||||
return [
|
||||
{"value": d.id, "label": d.name, "data": {}}
|
||||
for d in Device.objects.filter(pk__in=values)
|
||||
]
|
||||
|
||||
|
||||
def _platform_options(values) -> list[SearchSelectOption]:
|
||||
return [
|
||||
{"value": p.id, "label": p.name, "data": {}}
|
||||
for p in Platform.objects.filter(pk__in=values)
|
||||
]
|
||||
|
||||
|
||||
class SearchSelectWidget(forms.Widget):
|
||||
"""Thin Django adapter that renders a `SearchSelect()` component.
|
||||
|
||||
The only place that knows about Django/forms — the component itself stays
|
||||
reusable outside forms.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
search_url,
|
||||
options_resolver,
|
||||
multi_select=False,
|
||||
items_visible=5,
|
||||
items_scroll=10,
|
||||
prefetch=DEFAULT_PREFETCH,
|
||||
always_visible=False,
|
||||
placeholder="Search…",
|
||||
attrs=None,
|
||||
):
|
||||
super().__init__(attrs)
|
||||
self.search_url = search_url
|
||||
self.options_resolver = options_resolver
|
||||
self.multi_select = multi_select
|
||||
self.items_visible = items_visible
|
||||
self.items_scroll = items_scroll
|
||||
self.prefetch = prefetch
|
||||
self.always_visible = always_visible
|
||||
self.placeholder = placeholder
|
||||
|
||||
@staticmethod
|
||||
def _values(value) -> list:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [v for v in value if v not in (None, "")]
|
||||
return [value] if value not in (None, "") else []
|
||||
|
||||
def render(self, name, value, attrs=None, renderer=None):
|
||||
selected = searchselect_selected(self._values(value), self.options_resolver)
|
||||
autofocus = bool((attrs or {}).get("autofocus"))
|
||||
return SearchSelect(
|
||||
name=name,
|
||||
selected=selected,
|
||||
options=None,
|
||||
search_url=self.search_url,
|
||||
multi_select=self.multi_select,
|
||||
items_visible=self.items_visible,
|
||||
items_scroll=self.items_scroll,
|
||||
prefetch=self.prefetch,
|
||||
always_visible=self.always_visible,
|
||||
placeholder=self.placeholder,
|
||||
id=(attrs or {}).get("id", ""),
|
||||
autofocus=autofocus,
|
||||
)
|
||||
|
||||
def value_from_datadict(self, data, files, name):
|
||||
return data.get(name)
|
||||
|
||||
|
||||
class SearchSelectMultiple(SearchSelectWidget):
|
||||
def value_from_datadict(self, data, files, name):
|
||||
if hasattr(data, "getlist"):
|
||||
return data.getlist(name)
|
||||
return data.get(name)
|
||||
|
||||
|
||||
class SessionForm(forms.ModelForm):
|
||||
game = SingleGameChoiceField(
|
||||
queryset=Game.objects.order_by("sort_name"),
|
||||
widget=SearchSelectWidget(
|
||||
search_url="/api/games/search", options_resolver=_game_options
|
||||
),
|
||||
# purchase = forms.ModelChoiceField(
|
||||
# queryset=Purchase.objects.filter(date_refunded=None).order_by("edition__name")
|
||||
# )
|
||||
purchase = forms.ModelChoiceField(
|
||||
queryset=Purchase.objects.order_by("edition__sort_name"),
|
||||
widget=forms.Select(attrs={"autofocus": "autofocus"}),
|
||||
)
|
||||
|
||||
duration_manual = forms.DurationField(
|
||||
required=False,
|
||||
widget=forms.TextInput(
|
||||
attrs={"x-mask": "99:99:99", "placeholder": "HH:MM:SS", "x-data": ""}
|
||||
),
|
||||
label="Manual duration",
|
||||
)
|
||||
device = forms.ModelChoiceField(
|
||||
queryset=Device.objects.order_by("name"),
|
||||
required=False,
|
||||
widget=SearchSelectWidget(
|
||||
search_url="/api/devices/search", options_resolver=_device_options
|
||||
),
|
||||
)
|
||||
|
||||
mark_as_played = forms.BooleanField(
|
||||
required=False,
|
||||
initial={"mark_as_played": True},
|
||||
label="Set game status to Played if Unplayed",
|
||||
)
|
||||
device = forms.ModelChoiceField(queryset=Device.objects.order_by("name"))
|
||||
|
||||
class Meta:
|
||||
widgets = {
|
||||
@@ -164,101 +29,71 @@ class SessionForm(forms.ModelForm):
|
||||
}
|
||||
model = Session
|
||||
fields = [
|
||||
"game",
|
||||
"purchase",
|
||||
"timestamp_start",
|
||||
"timestamp_end",
|
||||
"duration_manual",
|
||||
"emulated",
|
||||
"device",
|
||||
"note",
|
||||
"mark_as_played",
|
||||
]
|
||||
|
||||
def save(self, commit=True):
|
||||
session = super().save(commit=False)
|
||||
if self.cleaned_data.get("mark_as_played"):
|
||||
game_instance = session.game
|
||||
if game_instance.status == "u":
|
||||
game_instance.status = "p"
|
||||
if commit:
|
||||
game_instance.save()
|
||||
if commit:
|
||||
session.save()
|
||||
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):
|
||||
class EditionChoiceField(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
|
||||
return f"{obj.sort_name} ({obj.platform}, {obj.year_released})"
|
||||
|
||||
|
||||
class IncludePlatformSelect(forms.Select):
|
||||
def create_option(self, name, value, *args, **kwargs):
|
||||
option = super().create_option(name, value, *args, **kwargs)
|
||||
if platform_id := safe_getattr(value, "instance.platform.id"):
|
||||
option["attrs"]["data-platform"] = platform_id
|
||||
return option
|
||||
|
||||
|
||||
class PurchaseForm(forms.ModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["platform"].queryset = Platform.objects.order_by("name")
|
||||
|
||||
games = MultipleGameChoiceField(
|
||||
queryset=Game.objects.order_by("sort_name"),
|
||||
widget=SearchSelectMultiple(
|
||||
search_url="/api/games/search",
|
||||
options_resolver=_game_options,
|
||||
multi_select=True,
|
||||
),
|
||||
)
|
||||
platform = forms.ModelChoiceField(
|
||||
queryset=Platform.objects.order_by("name"),
|
||||
widget=SearchSelectWidget(
|
||||
search_url="/api/platforms/search", options_resolver=_platform_options
|
||||
),
|
||||
)
|
||||
related_purchase = RelatedPurchaseChoiceField(
|
||||
queryset=related_purchase_queryset(),
|
||||
required=False,
|
||||
)
|
||||
|
||||
price_currency = forms.CharField(
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
"x-mask": "aaa",
|
||||
"placeholder": "CZK",
|
||||
"x-data": "",
|
||||
"class": "uppercase",
|
||||
# Automatically update related_purchase <select/>
|
||||
# to only include purchases of the selected edition.
|
||||
related_purchase_by_edition_url = reverse("related_purchase_by_edition")
|
||||
self.fields["edition"].widget.attrs.update(
|
||||
{
|
||||
"hx-trigger": "load, click",
|
||||
"hx-get": related_purchase_by_edition_url,
|
||||
"hx-target": "#id_related_purchase",
|
||||
"hx-swap": "outerHTML",
|
||||
}
|
||||
)
|
||||
|
||||
edition = EditionChoiceField(
|
||||
queryset=Edition.objects.order_by("sort_name"),
|
||||
widget=IncludePlatformSelect(attrs={"autoselect": "autoselect"}),
|
||||
)
|
||||
platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
|
||||
related_purchase = forms.ModelChoiceField(
|
||||
queryset=Purchase.objects.filter(type=Purchase.GAME).order_by(
|
||||
"edition__sort_name"
|
||||
),
|
||||
label="Currency",
|
||||
required=False,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
widgets = {
|
||||
"date_purchased": custom_date_widget,
|
||||
"date_refunded": custom_date_widget,
|
||||
"date_finished": custom_date_widget,
|
||||
"date_dropped": custom_date_widget,
|
||||
}
|
||||
model = Purchase
|
||||
fields = [
|
||||
"games",
|
||||
"edition",
|
||||
"platform",
|
||||
"date_purchased",
|
||||
"date_refunded",
|
||||
"date_finished",
|
||||
"date_dropped",
|
||||
"infinite",
|
||||
"price",
|
||||
"price_currency",
|
||||
@@ -305,38 +140,31 @@ class GameModelChoiceField(forms.ModelChoiceField):
|
||||
return obj.sort_name
|
||||
|
||||
|
||||
class GameForm(forms.ModelForm):
|
||||
class EditionForm(forms.ModelForm):
|
||||
game = GameModelChoiceField(
|
||||
queryset=Game.objects.order_by("sort_name"),
|
||||
widget=IncludeNameSelect(attrs={"autofocus": "autofocus"}),
|
||||
)
|
||||
platform = forms.ModelChoiceField(
|
||||
queryset=Platform.objects.order_by("name"),
|
||||
required=False,
|
||||
widget=SearchSelectWidget(
|
||||
search_url="/api/platforms/search", options_resolver=_platform_options
|
||||
),
|
||||
queryset=Platform.objects.order_by("name"), required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Edition
|
||||
fields = ["game", "name", "sort_name", "platform", "year_released", "wikidata"]
|
||||
|
||||
|
||||
class GameForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Game
|
||||
fields = [
|
||||
"name",
|
||||
"sort_name",
|
||||
"platform",
|
||||
"original_year_released",
|
||||
"year_released",
|
||||
"status",
|
||||
"mastered",
|
||||
"wikidata",
|
||||
]
|
||||
fields = ["name", "sort_name", "year_released", "wikidata"]
|
||||
widgets = {"name": autofocus_input_widget}
|
||||
|
||||
|
||||
class PlatformForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Platform
|
||||
fields = [
|
||||
"name",
|
||||
"icon",
|
||||
"group",
|
||||
]
|
||||
fields = ["name", "group"]
|
||||
widgets = {"name": autofocus_input_widget}
|
||||
|
||||
|
||||
@@ -345,52 +173,3 @@ class DeviceForm(forms.ModelForm):
|
||||
model = Device
|
||||
fields = ["name", "type"]
|
||||
widgets = {"name": autofocus_input_widget}
|
||||
|
||||
|
||||
class PlayEventForm(forms.ModelForm):
|
||||
game = SingleGameChoiceField(
|
||||
queryset=Game.objects.order_by("sort_name"),
|
||||
widget=SearchSelectWidget(
|
||||
search_url="/api/games/search",
|
||||
options_resolver=_game_options,
|
||||
attrs={"autofocus": "autofocus"},
|
||||
),
|
||||
)
|
||||
|
||||
mark_as_finished = forms.BooleanField(
|
||||
required=False,
|
||||
initial={"mark_as_finished": True},
|
||||
label="Set game status to Finished",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PlayEvent
|
||||
fields = ["game", "started", "ended", "note", "mark_as_finished"]
|
||||
widgets = {
|
||||
"started": custom_date_widget,
|
||||
"ended": custom_date_widget,
|
||||
}
|
||||
|
||||
def save(self, commit=True):
|
||||
with transaction.atomic():
|
||||
session = super().save(commit=False)
|
||||
if self.cleaned_data.get("mark_as_finished"):
|
||||
game_instance = session.game
|
||||
game_instance.status = "f"
|
||||
game_instance.save()
|
||||
session.save()
|
||||
return session
|
||||
|
||||
|
||||
class GameStatusChangeForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = GameStatusChange
|
||||
fields = [
|
||||
"game",
|
||||
"old_status",
|
||||
"new_status",
|
||||
"timestamp",
|
||||
]
|
||||
widgets = {
|
||||
"timestamp": custom_datetime_widget,
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from .game import Mutation as GameMutation
|
||||
@@ -0,0 +1,29 @@
|
||||
import graphene
|
||||
|
||||
from games.graphql.types import Game
|
||||
from games.models import Game as GameModel
|
||||
|
||||
|
||||
class UpdateGameMutation(graphene.Mutation):
|
||||
class Arguments:
|
||||
id = graphene.ID(required=True)
|
||||
name = graphene.String()
|
||||
year_released = graphene.Int()
|
||||
wikidata = graphene.String()
|
||||
|
||||
game = graphene.Field(Game)
|
||||
|
||||
def mutate(self, info, id, name=None, year_released=None, wikidata=None):
|
||||
game_instance = GameModel.objects.get(pk=id)
|
||||
if name is not None:
|
||||
game_instance.name = name
|
||||
if year_released is not None:
|
||||
game_instance.year_released = year_released
|
||||
if wikidata is not None:
|
||||
game_instance.wikidata = wikidata
|
||||
game_instance.save()
|
||||
return UpdateGameMutation(game=game_instance)
|
||||
|
||||
|
||||
class Mutation(graphene.ObjectType):
|
||||
update_game = UpdateGameMutation.Field()
|
||||
@@ -0,0 +1,6 @@
|
||||
from .device import Query as DeviceQuery
|
||||
from .edition import Query as EditionQuery
|
||||
from .game import Query as GameQuery
|
||||
from .platform import Query as PlatformQuery
|
||||
from .purchase import Query as PurchaseQuery
|
||||
from .session import Query as SessionQuery
|
||||
@@ -0,0 +1,11 @@
|
||||
import graphene
|
||||
|
||||
from games.graphql.types import Device
|
||||
from games.models import Device as DeviceModel
|
||||
|
||||
|
||||
class Query(graphene.ObjectType):
|
||||
devices = graphene.List(Device)
|
||||
|
||||
def resolve_devices(self, info, **kwargs):
|
||||
return DeviceModel.objects.all()
|
||||
@@ -0,0 +1,11 @@
|
||||
import graphene
|
||||
|
||||
from games.graphql.types import Edition
|
||||
from games.models import Game as EditionModel
|
||||
|
||||
|
||||
class Query(graphene.ObjectType):
|
||||
editions = graphene.List(Edition)
|
||||
|
||||
def resolve_editions(self, info, **kwargs):
|
||||
return EditionModel.objects.all()
|
||||
@@ -0,0 +1,18 @@
|
||||
import graphene
|
||||
|
||||
from games.graphql.types import Game
|
||||
from games.models import Game as GameModel
|
||||
|
||||
|
||||
class Query(graphene.ObjectType):
|
||||
games = graphene.List(Game)
|
||||
game_by_name = graphene.Field(Game, name=graphene.String(required=True))
|
||||
|
||||
def resolve_games(self, info, **kwargs):
|
||||
return GameModel.objects.all()
|
||||
|
||||
def resolve_game_by_name(self, info, name):
|
||||
try:
|
||||
return GameModel.objects.get(name=name)
|
||||
except GameModel.DoesNotExist:
|
||||
return None
|
||||
@@ -0,0 +1,11 @@
|
||||
import graphene
|
||||
|
||||
from games.graphql.types import Platform
|
||||
from games.models import Platform as PlatformModel
|
||||
|
||||
|
||||
class Query(graphene.ObjectType):
|
||||
platforms = graphene.List(Platform)
|
||||
|
||||
def resolve_platforms(self, info, **kwargs):
|
||||
return PlatformModel.objects.all()
|
||||
@@ -0,0 +1,11 @@
|
||||
import graphene
|
||||
|
||||
from games.graphql.types import Purchase
|
||||
from games.models import Purchase as PurchaseModel
|
||||
|
||||
|
||||
class Query(graphene.ObjectType):
|
||||
purchases = graphene.List(Purchase)
|
||||
|
||||
def resolve_purchases(self, info, **kwargs):
|
||||
return PurchaseModel.objects.all()
|
||||
@@ -0,0 +1,11 @@
|
||||
import graphene
|
||||
|
||||
from games.graphql.types import Session
|
||||
from games.models import Session as SessionModel
|
||||
|
||||
|
||||
class Query(graphene.ObjectType):
|
||||
sessions = graphene.List(Session)
|
||||
|
||||
def resolve_sessions(self, info, **kwargs):
|
||||
return SessionModel.objects.all()
|
||||
@@ -0,0 +1,44 @@
|
||||
from graphene_django import DjangoObjectType
|
||||
|
||||
from games.models import Device as DeviceModel
|
||||
from games.models import Edition as EditionModel
|
||||
from games.models import Game as GameModel
|
||||
from games.models import Platform as PlatformModel
|
||||
from games.models import Purchase as PurchaseModel
|
||||
from games.models import Session as SessionModel
|
||||
|
||||
|
||||
class Game(DjangoObjectType):
|
||||
class Meta:
|
||||
model = GameModel
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class Edition(DjangoObjectType):
|
||||
class Meta:
|
||||
model = EditionModel
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class Purchase(DjangoObjectType):
|
||||
class Meta:
|
||||
model = PurchaseModel
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class Session(DjangoObjectType):
|
||||
class Meta:
|
||||
model = SessionModel
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class Platform(DjangoObjectType):
|
||||
class Meta:
|
||||
model = PlatformModel
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class Device(DjangoObjectType):
|
||||
class Meta:
|
||||
model = DeviceModel
|
||||
fields = "__all__"
|
||||
@@ -1,66 +0,0 @@
|
||||
import json
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages as django_messages
|
||||
from django.contrib.messages import constants as message_constants
|
||||
|
||||
MESSAGE_LEVEL_MAP = {
|
||||
message_constants.DEBUG: "debug",
|
||||
message_constants.INFO: "info",
|
||||
message_constants.SUCCESS: "success",
|
||||
message_constants.WARNING: "warning",
|
||||
message_constants.ERROR: "error",
|
||||
}
|
||||
|
||||
|
||||
class HTMXMessagesMiddleware:
|
||||
"""
|
||||
Converts Django messages into HX-Trigger headers so toasts display
|
||||
automatically without changes to views.
|
||||
|
||||
Works for HTMX requests (processed natively by HTMX client),
|
||||
vanilla fetch() calls using fetchWithHtmxTriggers(), and is harmless
|
||||
for full-page loads (browsers ignore HX-Trigger).
|
||||
"""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
response = self.get_response(request)
|
||||
|
||||
# Skip HX-Trigger and don't consume messages if there's an HX-Redirect
|
||||
# so the message persists in the session for the redirect target page
|
||||
if "HX-Redirect" in response:
|
||||
return response
|
||||
|
||||
min_level = (
|
||||
message_constants.DEBUG if settings.DEBUG else message_constants.INFO
|
||||
)
|
||||
backend = django_messages.get_messages(request)
|
||||
if hasattr(backend, "_set_level") and backend._get_level() > min_level:
|
||||
backend._set_level(min_level)
|
||||
messages = list(backend)
|
||||
if not messages:
|
||||
return response
|
||||
|
||||
triggers = []
|
||||
for msg in messages:
|
||||
toast_type = MESSAGE_LEVEL_MAP.get(msg.level, "info")
|
||||
triggers.append(
|
||||
{
|
||||
"message": msg.message,
|
||||
"type": toast_type,
|
||||
}
|
||||
)
|
||||
|
||||
if triggers:
|
||||
# Use last message (most recent) as the primary toast
|
||||
trigger = triggers[-1]
|
||||
response["HX-Trigger"] = json.dumps(
|
||||
{
|
||||
"show-toast": trigger,
|
||||
}
|
||||
)
|
||||
|
||||
return response
|
||||
@@ -1,24 +0,0 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils.timezone import now
|
||||
from django_q.models import Schedule
|
||||
from django_q.tasks import schedule
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Manually schedule the next update_converted_prices task"
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
if not Schedule.objects.filter(name="Update converted prices").exists():
|
||||
schedule(
|
||||
"games.tasks.convert_prices",
|
||||
name="Update converted prices",
|
||||
schedule_type=Schedule.MINUTES,
|
||||
next_run=now() + timedelta(seconds=30),
|
||||
)
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS("Scheduled the update_converted_prices task.")
|
||||
)
|
||||
else:
|
||||
self.stdout.write(self.style.WARNING("Task is already scheduled."))
|
||||
@@ -1,18 +1,18 @@
|
||||
# Generated by Django 5.1.5 on 2025-01-29 21:26
|
||||
# Generated by Django 4.1.4 on 2023-01-02 18:27
|
||||
|
||||
import datetime
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Device",
|
||||
name="Game",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
@@ -24,22 +24,7 @@ class Migration(migrations.Migration):
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255)),
|
||||
(
|
||||
"type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("PC", "PC"),
|
||||
("Console", "Console"),
|
||||
("Handheld", "Handheld"),
|
||||
("Mobile", "Mobile"),
|
||||
("Single-board computer", "Single-board computer"),
|
||||
("Unknown", "Unknown"),
|
||||
],
|
||||
default="Unknown",
|
||||
max_length=255,
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("wikidata", models.CharField(max_length=50)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
@@ -55,82 +40,9 @@ class Migration(migrations.Migration):
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255)),
|
||||
(
|
||||
"group",
|
||||
models.CharField(
|
||||
blank=True, default=None, max_length=255, null=True
|
||||
),
|
||||
),
|
||||
("icon", models.SlugField(blank=True)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("group", models.CharField(max_length=255)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ExchangeRate",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("currency_from", models.CharField(max_length=255)),
|
||||
("currency_to", models.CharField(max_length=255)),
|
||||
("year", models.PositiveIntegerField()),
|
||||
("rate", models.FloatField()),
|
||||
],
|
||||
options={
|
||||
"unique_together": {("currency_from", "currency_to", "year")},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Game",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255)),
|
||||
(
|
||||
"sort_name",
|
||||
models.CharField(
|
||||
blank=True, default=None, max_length=255, null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"year_released",
|
||||
models.IntegerField(blank=True, default=None, null=True),
|
||||
),
|
||||
(
|
||||
"wikidata",
|
||||
models.CharField(
|
||||
blank=True, default=None, max_length=50, null=True
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"platform",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_DEFAULT,
|
||||
to="games.platform",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"unique_together": {("name", "platform", "year_released")},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Purchase",
|
||||
fields=[
|
||||
@@ -145,75 +57,19 @@ class Migration(migrations.Migration):
|
||||
),
|
||||
("date_purchased", models.DateField()),
|
||||
("date_refunded", models.DateField(blank=True, null=True)),
|
||||
("date_finished", models.DateField(blank=True, null=True)),
|
||||
("date_dropped", models.DateField(blank=True, null=True)),
|
||||
("infinite", models.BooleanField(default=False)),
|
||||
("price", models.FloatField(default=0)),
|
||||
("price_currency", models.CharField(default="USD", max_length=3)),
|
||||
("converted_price", models.FloatField(null=True)),
|
||||
("converted_currency", models.CharField(max_length=3, null=True)),
|
||||
(
|
||||
"ownership_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("ph", "Physical"),
|
||||
("di", "Digital"),
|
||||
("du", "Digital Upgrade"),
|
||||
("re", "Rented"),
|
||||
("bo", "Borrowed"),
|
||||
("tr", "Trial"),
|
||||
("de", "Demo"),
|
||||
("pi", "Pirated"),
|
||||
],
|
||||
default="di",
|
||||
max_length=2,
|
||||
),
|
||||
),
|
||||
(
|
||||
"type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("game", "Game"),
|
||||
("dlc", "DLC"),
|
||||
("season_pass", "Season Pass"),
|
||||
("battle_pass", "Battle Pass"),
|
||||
],
|
||||
default="game",
|
||||
max_length=255,
|
||||
),
|
||||
),
|
||||
(
|
||||
"name",
|
||||
models.CharField(blank=True, default="", max_length=255, null=True),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"games",
|
||||
models.ManyToManyField(
|
||||
blank=True, related_name="purchases", to="games.game"
|
||||
"game",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="games.game"
|
||||
),
|
||||
),
|
||||
(
|
||||
"platform",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="games.platform",
|
||||
),
|
||||
),
|
||||
(
|
||||
"related_purchase",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="related_purchases",
|
||||
to="games.purchase",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
@@ -229,42 +85,17 @@ class Migration(migrations.Migration):
|
||||
),
|
||||
),
|
||||
("timestamp_start", models.DateTimeField()),
|
||||
("timestamp_end", models.DateTimeField(blank=True, null=True)),
|
||||
(
|
||||
"duration_manual",
|
||||
models.DurationField(
|
||||
blank=True, default=datetime.timedelta(0), null=True
|
||||
),
|
||||
),
|
||||
("timestamp_end", models.DateTimeField()),
|
||||
("duration_manual", models.DurationField(blank=True, null=True)),
|
||||
("duration_calculated", models.DurationField(blank=True, null=True)),
|
||||
("note", models.TextField(blank=True, null=True)),
|
||||
("emulated", models.BooleanField(default=False)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("modified_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"device",
|
||||
"purchase",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_DEFAULT,
|
||||
to="games.device",
|
||||
),
|
||||
),
|
||||
(
|
||||
"game",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="sessions",
|
||||
to="games.game",
|
||||
to="games.purchase",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"get_latest_by": "timestamp_start",
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 4.1.4 on 2023-01-02 18:55
|
||||
|
||||
import datetime
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="session",
|
||||
name="duration_manual",
|
||||
field=models.DurationField(
|
||||
blank=True, default=datetime.timedelta(0), null=True
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,17 +0,0 @@
|
||||
# Generated by Django 5.1.5 on 2025-01-30 11:04
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="purchase",
|
||||
name="price_per_game",
|
||||
field=models.FloatField(null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.1.4 on 2023-01-02 23:11
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0002_alter_session_duration_manual"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="session",
|
||||
name="duration_manual",
|
||||
field=models.DurationField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="session",
|
||||
name="timestamp_end",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -1,17 +0,0 @@
|
||||
# Generated by Django 5.1.5 on 2025-01-30 11:12
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0002_purchase_price_per_game"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="purchase",
|
||||
name="updated_at",
|
||||
field=models.DateTimeField(auto_now=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 4.1.5 on 2023-01-09 14:49
|
||||
|
||||
import datetime
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0003_alter_session_duration_manual_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="session",
|
||||
name="duration_manual",
|
||||
field=models.DurationField(
|
||||
blank=True, default=datetime.timedelta(0), null=True
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,28 +0,0 @@
|
||||
# Generated by Django 5.1.5 on 2025-01-30 11:57
|
||||
|
||||
from django.db import migrations, models
|
||||
from django.db.models import Count
|
||||
|
||||
|
||||
def initialize_num_purchases(apps, schema_editor):
|
||||
Purchase = apps.get_model("games", "Purchase")
|
||||
purchases = Purchase.objects.annotate(num_games=Count("games"))
|
||||
|
||||
for purchase in purchases:
|
||||
purchase.num_purchases = purchase.num_games
|
||||
purchase.save(update_fields=["num_purchases"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0003_purchase_updated_at"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="purchase",
|
||||
name="num_purchases",
|
||||
field=models.IntegerField(default=0),
|
||||
),
|
||||
migrations.RunPython(initialize_num_purchases),
|
||||
]
|
||||
@@ -0,0 +1,35 @@
|
||||
# Generated by Django 4.1.5 on 2023-01-09 17:43
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def set_duration_calculated_none_to_zero(apps, schema_editor):
|
||||
Session = apps.get_model("games", "Session")
|
||||
for session in Session.objects.all():
|
||||
if session.duration_calculated == None:
|
||||
session.duration_calculated = timedelta(0)
|
||||
session.save()
|
||||
|
||||
|
||||
def revert_set_duration_calculated_none_to_zero(apps, schema_editor):
|
||||
Session = apps.get_model("games", "Session")
|
||||
for session in Session.objects.all():
|
||||
if session.duration_calculated == timedelta(0):
|
||||
session.duration_calculated = None
|
||||
session.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0004_alter_session_duration_manual"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
set_duration_calculated_none_to_zero,
|
||||
revert_set_duration_calculated_none_to_zero,
|
||||
)
|
||||
]
|
||||
@@ -1,38 +0,0 @@
|
||||
# Generated by Django 5.1.5 on 2025-02-01 19:18
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def set_finished_status(apps, schema_editor):
|
||||
Game = apps.get_model("games", "Game")
|
||||
Game.objects.filter(purchases__date_finished__isnull=False).update(status="f")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0004_purchase_num_purchases"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="game",
|
||||
name="mastered",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="game",
|
||||
name="status",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("u", "Unplayed"),
|
||||
("p", "Played"),
|
||||
("f", "Finished"),
|
||||
("r", "Retired"),
|
||||
("a", "Abandoned"),
|
||||
],
|
||||
default="u",
|
||||
max_length=1,
|
||||
),
|
||||
),
|
||||
migrations.RunPython(set_finished_status),
|
||||
]
|
||||
@@ -1,70 +0,0 @@
|
||||
# Generated by Django 5.1.5 on 2025-03-01 12:52
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0005_game_mastered_game_status"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="game",
|
||||
name="sort_name",
|
||||
field=models.CharField(blank=True, default="", max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="game",
|
||||
name="wikidata",
|
||||
field=models.CharField(blank=True, default="", max_length=50),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="platform",
|
||||
name="group",
|
||||
field=models.CharField(blank=True, default="", max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="purchase",
|
||||
name="converted_currency",
|
||||
field=models.CharField(blank=True, default="", max_length=3),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="purchase",
|
||||
name="games",
|
||||
field=models.ManyToManyField(related_name="purchases", to="games.game"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="purchase",
|
||||
name="name",
|
||||
field=models.CharField(blank=True, default="", max_length=255),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="purchase",
|
||||
name="related_purchase",
|
||||
field=models.ForeignKey(
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="related_purchases",
|
||||
to="games.purchase",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="session",
|
||||
name="game",
|
||||
field=models.ForeignKey(
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="sessions",
|
||||
to="games.game",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="session",
|
||||
name="note",
|
||||
field=models.TextField(blank=True, default=""),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,35 @@
|
||||
# Generated by Django 4.1.5 on 2023-01-09 18:04
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def set_duration_manual_none_to_zero(apps, schema_editor):
|
||||
Session = apps.get_model("games", "Session")
|
||||
for session in Session.objects.all():
|
||||
if session.duration_manual == None:
|
||||
session.duration_manual = timedelta(0)
|
||||
session.save()
|
||||
|
||||
|
||||
def revert_set_duration_manual_none_to_zero(apps, schema_editor):
|
||||
Session = apps.get_model("games", "Session")
|
||||
for session in Session.objects.all():
|
||||
if session.duration_manual == timedelta(0):
|
||||
session.duration_manual = None
|
||||
session.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0005_auto_20230109_1843"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
set_duration_manual_none_to_zero,
|
||||
revert_set_duration_manual_none_to_zero,
|
||||
)
|
||||
]
|
||||
@@ -0,0 +1,35 @@
|
||||
# Generated by Django 4.1.5 on 2023-01-19 18:30
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0006_auto_20230109_1904"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="purchase",
|
||||
name="game",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="games.game"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="purchase",
|
||||
name="platform",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="games.platform"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="session",
|
||||
name="purchase",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="games.purchase"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,17 +0,0 @@
|
||||
# Generated by Django 5.1.5 on 2025-03-17 07:36
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0006_alter_game_sort_name_alter_game_wikidata_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="game",
|
||||
name="updated_at",
|
||||
field=models.DateTimeField(auto_now=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,41 @@
|
||||
# Generated by Django 4.1.5 on 2023-02-18 16:29
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0007_alter_purchase_game_alter_purchase_platform_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Edition",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255)),
|
||||
(
|
||||
"game",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="games.game"
|
||||
),
|
||||
),
|
||||
(
|
||||
"platform",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="games.platform"
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -1,190 +0,0 @@
|
||||
# Generated by Django 5.1.7 on 2025-03-19 13:11
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.db.models.expressions
|
||||
from django.db import migrations, models
|
||||
from django.db.models import F, Min
|
||||
|
||||
|
||||
def copy_year_released(apps, schema_editor):
|
||||
Game = apps.get_model("games", "Game")
|
||||
Game.objects.update(original_year_released=F("year_released"))
|
||||
|
||||
|
||||
def set_abandoned_status(apps, schema_editor):
|
||||
Game = apps.get_model("games", "Game")
|
||||
Game = apps.get_model("games", "Game")
|
||||
PlayEvent = apps.get_model("games", "PlayEvent")
|
||||
|
||||
Game.objects.filter(purchases__date_refunded__isnull=False).update(status="a")
|
||||
Game.objects.filter(purchases__date_dropped__isnull=False).update(status="a")
|
||||
|
||||
finished = Game.objects.filter(purchases__date_finished__isnull=False)
|
||||
|
||||
for game in finished:
|
||||
for purchase in game.purchases.all():
|
||||
first_session = game.sessions.filter(
|
||||
timestamp_start__gte=purchase.date_purchased
|
||||
).aggregate(Min("timestamp_start"))["timestamp_start__min"]
|
||||
first_session_date = first_session.date() if first_session else None
|
||||
if purchase.date_finished:
|
||||
play_event = PlayEvent(
|
||||
game=game,
|
||||
started=first_session_date
|
||||
if first_session_date
|
||||
else purchase.date_purchased,
|
||||
ended=purchase.date_finished,
|
||||
)
|
||||
play_event.save()
|
||||
|
||||
|
||||
def create_game_status_changes(apps, schema_editor):
|
||||
Game = apps.get_model("games", "Game")
|
||||
GameStatusChange = apps.get_model("games", "GameStatusChange")
|
||||
|
||||
# if game has any sessions, find the earliest session and create a status change from unplayed to played with that sessions's timestamp_start
|
||||
for game in Game.objects.filter(sessions__isnull=False).distinct():
|
||||
if game.sessions.exists():
|
||||
earliest_session = game.sessions.earliest()
|
||||
GameStatusChange.objects.create(
|
||||
game=game,
|
||||
old_status="u",
|
||||
new_status="p",
|
||||
timestamp=earliest_session.timestamp_start,
|
||||
)
|
||||
|
||||
for game in Game.objects.filter(purchases__date_dropped__isnull=False):
|
||||
GameStatusChange.objects.create(
|
||||
game=game,
|
||||
old_status="p",
|
||||
new_status="a",
|
||||
timestamp=game.purchases.first().date_dropped,
|
||||
)
|
||||
|
||||
for game in Game.objects.filter(purchases__date_refunded__isnull=False):
|
||||
GameStatusChange.objects.create(
|
||||
game=game,
|
||||
old_status="p",
|
||||
new_status="a",
|
||||
timestamp=game.purchases.first().date_refunded,
|
||||
)
|
||||
|
||||
# check if game has any playevents, if so create a status change from current status to finished based on playevent's ended date
|
||||
# consider only the first playevent
|
||||
for game in Game.objects.filter(playevents__isnull=False):
|
||||
first_playevent = game.playevents.first()
|
||||
GameStatusChange.objects.create(
|
||||
game=game,
|
||||
old_status="p",
|
||||
new_status="f",
|
||||
timestamp=first_playevent.ended,
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0007_game_updated_at"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="game",
|
||||
name="original_year_released",
|
||||
field=models.IntegerField(blank=True, default=None, null=True),
|
||||
),
|
||||
migrations.RunPython(copy_year_released),
|
||||
migrations.CreateModel(
|
||||
name="GameStatusChange",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"old_status",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("u", "Unplayed"),
|
||||
("p", "Played"),
|
||||
("f", "Finished"),
|
||||
("r", "Retired"),
|
||||
("a", "Abandoned"),
|
||||
],
|
||||
max_length=1,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"new_status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("u", "Unplayed"),
|
||||
("p", "Played"),
|
||||
("f", "Finished"),
|
||||
("r", "Retired"),
|
||||
("a", "Abandoned"),
|
||||
],
|
||||
max_length=1,
|
||||
),
|
||||
),
|
||||
("timestamp", models.DateTimeField(null=True)),
|
||||
(
|
||||
"game",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="status_changes",
|
||||
to="games.game",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-timestamp"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="PlayEvent",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("started", models.DateField(blank=True, null=True)),
|
||||
("ended", models.DateField(blank=True, null=True)),
|
||||
(
|
||||
"days_to_finish",
|
||||
models.GeneratedField(
|
||||
db_persist=True,
|
||||
expression=django.db.models.expressions.RawSQL(
|
||||
"\n COALESCE(\n CASE \n WHEN date(ended) = date(started) THEN 1\n ELSE julianday(ended) - julianday(started)\n END, 0\n )\n ",
|
||||
[],
|
||||
),
|
||||
output_field=models.IntegerField(),
|
||||
),
|
||||
),
|
||||
("note", models.CharField(blank=True, default="", max_length=255)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"game",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="playevents",
|
||||
to="games.game",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.RunPython(set_abandoned_status),
|
||||
migrations.RunPython(create_game_status_changes),
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 4.1.5 on 2023-02-18 18:51
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def create_edition_of_game(apps, schema_editor):
|
||||
Game = apps.get_model("games", "Game")
|
||||
Edition = apps.get_model("games", "Edition")
|
||||
Platform = apps.get_model("games", "Platform")
|
||||
first_platform = Platform.objects.first()
|
||||
all_games = Game.objects.all()
|
||||
all_editions = Edition.objects.all()
|
||||
for game in all_games:
|
||||
existing_edition = None
|
||||
try:
|
||||
existing_edition = all_editions.objects.get(game=game.id)
|
||||
except:
|
||||
pass
|
||||
if existing_edition == None:
|
||||
edition = Edition()
|
||||
edition.id = game.id
|
||||
edition.game = game
|
||||
edition.name = game.name
|
||||
edition.platform = first_platform
|
||||
edition.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0008_edition"),
|
||||
]
|
||||
|
||||
operations = [migrations.RunPython(create_edition_of_game)]
|
||||
@@ -1,20 +0,0 @@
|
||||
# Generated by Django 5.1.7 on 2025-03-20 11:35
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0008_game_original_year_released_gamestatuschange_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="purchase",
|
||||
name="date_dropped",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="purchase",
|
||||
name="date_finished",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
# Generated by Django 4.1.5 on 2023-02-18 19:06
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0009_create_editions"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="purchase",
|
||||
name="game",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="games.edition"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,16 +0,0 @@
|
||||
# Generated by Django 5.1.7 on 2025-03-22 17:46
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0009_remove_purchase_date_dropped_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="purchase",
|
||||
name="price_per_game",
|
||||
),
|
||||
]
|
||||
@@ -1,29 +0,0 @@
|
||||
# Generated by Django 5.1.7 on 2025-03-22 17:46
|
||||
|
||||
import django.db.models.expressions
|
||||
import django.db.models.functions.comparison
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0010_remove_purchase_price_per_game"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="purchase",
|
||||
name="price_per_game",
|
||||
field=models.GeneratedField(
|
||||
db_persist=True,
|
||||
expression=django.db.models.expressions.CombinedExpression(
|
||||
django.db.models.functions.comparison.Coalesce(
|
||||
models.F("converted_price"), models.F("price"), 0
|
||||
),
|
||||
"/",
|
||||
models.F("num_purchases"),
|
||||
),
|
||||
output_field=models.FloatField(),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.1.5 on 2023-02-18 19:18
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0010_alter_purchase_game"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name="purchase",
|
||||
old_name="game",
|
||||
new_name="edition",
|
||||
),
|
||||
]
|
||||
@@ -1,32 +0,0 @@
|
||||
# Generated by Django 5.1.7 on 2025-03-25 20:30
|
||||
|
||||
import django.db.models.expressions
|
||||
import django.db.models.functions.comparison
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0011_purchase_price_per_game"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="session",
|
||||
name="duration_calculated",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="session",
|
||||
name="duration_calculated",
|
||||
field=models.GeneratedField(
|
||||
db_persist=True,
|
||||
expression=django.db.models.functions.comparison.Coalesce(
|
||||
django.db.models.expressions.CombinedExpression(
|
||||
models.F("timestamp_end"), "-", models.F("timestamp_start")
|
||||
),
|
||||
0,
|
||||
),
|
||||
output_field=models.DurationField(),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.1.5 on 2023-02-18 19:53
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0011_rename_game_purchase_edition"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="purchase",
|
||||
name="price",
|
||||
field=models.IntegerField(default=0),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="purchase",
|
||||
name="price_currency",
|
||||
field=models.CharField(default="USD", max_length=3),
|
||||
),
|
||||
]
|
||||
@@ -1,35 +0,0 @@
|
||||
# Generated by Django 5.1.7 on 2025-03-25 20:33
|
||||
|
||||
import datetime
|
||||
|
||||
from django.db import migrations, models
|
||||
from django.db.models import F, Sum
|
||||
|
||||
|
||||
def calculate_game_playtime(apps, schema_editor):
|
||||
Game = apps.get_model("games", "Game")
|
||||
games = Game.objects.all()
|
||||
for game in games:
|
||||
total_playtime = game.sessions.aggregate(
|
||||
total_playtime=Sum(F("duration_total"))
|
||||
)["total_playtime"]
|
||||
if total_playtime:
|
||||
game.playtime = total_playtime
|
||||
game.save(update_fields=["playtime"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0012_alter_session_duration_calculated"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="game",
|
||||
name="playtime",
|
||||
field=models.DurationField(
|
||||
blank=True, default=datetime.timedelta(0), editable=False
|
||||
),
|
||||
),
|
||||
migrations.RunPython(calculate_game_playtime),
|
||||
]
|
||||
@@ -0,0 +1,31 @@
|
||||
# Generated by Django 4.1.5 on 2023-02-18 19:54
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0012_purchase_price_purchase_price_currency"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="purchase",
|
||||
name="ownership_type",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("ph", "Physical"),
|
||||
("di", "Digital"),
|
||||
("du", "Digital Upgrade"),
|
||||
("re", "Rented"),
|
||||
("bo", "Borrowed"),
|
||||
("tr", "Trial"),
|
||||
("de", "Demo"),
|
||||
("pi", "Pirated"),
|
||||
],
|
||||
default="di",
|
||||
max_length=2,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,52 @@
|
||||
# Generated by Django 4.1.5 on 2023-02-18 19:59
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0013_purchase_ownership_type"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Device",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255)),
|
||||
(
|
||||
"type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("pc", "PC"),
|
||||
("co", "Console"),
|
||||
("ha", "Handheld"),
|
||||
("mo", "Mobile"),
|
||||
("sbc", "Single-board computer"),
|
||||
],
|
||||
default="pc",
|
||||
max_length=3,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="session",
|
||||
name="device",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="games.device",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,24 +0,0 @@
|
||||
# Generated by Django 5.1.7 on 2025-03-25 20:46
|
||||
|
||||
import django.db.models.expressions
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0013_game_playtime"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="session",
|
||||
name="duration_total",
|
||||
field=models.GeneratedField(
|
||||
db_persist=True,
|
||||
expression=django.db.models.expressions.CombinedExpression(
|
||||
models.F("duration_calculated"), "+", models.F("duration_manual")
|
||||
),
|
||||
output_field=models.DurationField(),
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,43 +0,0 @@
|
||||
# Generated by Django 5.1.7 on 2026-01-15 15:37
|
||||
|
||||
import datetime
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0014_session_duration_total"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="purchase",
|
||||
name="date_purchased",
|
||||
field=models.DateField(verbose_name="Purchased"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="purchase",
|
||||
name="date_refunded",
|
||||
field=models.DateField(blank=True, null=True, verbose_name="Refunded"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="session",
|
||||
name="duration_manual",
|
||||
field=models.DurationField(
|
||||
blank=True,
|
||||
default=datetime.timedelta(0),
|
||||
null=True,
|
||||
verbose_name="Manual duration",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="session",
|
||||
name="timestamp_end",
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name="End"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="session",
|
||||
name="timestamp_start",
|
||||
field=models.DateTimeField(verbose_name="Start"),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.1.5 on 2023-02-20 14:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0014_device_session_device"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="edition",
|
||||
name="wikidata",
|
||||
field=models.CharField(blank=True, default=None, max_length=50, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="edition",
|
||||
name="year_released",
|
||||
field=models.IntegerField(default=2023),
|
||||
),
|
||||
]
|
||||
@@ -1,21 +0,0 @@
|
||||
# Generated by Django 6.0.1 on 2026-05-12 11:57
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0015_alter_purchase_date_purchased_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="purchase",
|
||||
name="needs_price_update",
|
||||
field=models.BooleanField(db_index=True, default=True),
|
||||
),
|
||||
migrations.RunSQL(
|
||||
"UPDATE games_purchase SET needs_price_update = FALSE WHERE converted_price IS NOT NULL AND converted_currency != ''",
|
||||
reverse_sql="UPDATE games_purchase SET needs_price_update = TRUE WHERE converted_price IS NOT NULL AND converted_currency != ''",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,51 @@
|
||||
# Generated by Django 4.1.5 on 2023-11-06 11:10
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0015_edition_wikidata_edition_year_released"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="edition",
|
||||
name="platform",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="games.platform",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="edition",
|
||||
name="year_released",
|
||||
field=models.IntegerField(blank=True, default=None, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="game",
|
||||
name="wikidata",
|
||||
field=models.CharField(blank=True, default=None, max_length=50, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="platform",
|
||||
name="group",
|
||||
field=models.CharField(blank=True, default=None, max_length=255, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="session",
|
||||
name="device",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="games.device",
|
||||
),
|
||||
),
|
||||
]
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
# Generated by Django 4.1.5 on 2023-11-06 18:14
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def rename_duplicates(apps, schema_editor):
|
||||
Edition = apps.get_model("games", "Edition")
|
||||
|
||||
duplicates = (
|
||||
Edition.objects.values("name", "platform")
|
||||
.annotate(name_count=models.Count("id"))
|
||||
.filter(name_count__gt=1)
|
||||
)
|
||||
|
||||
for duplicate in duplicates:
|
||||
counter = 1
|
||||
duplicate_editions = Edition.objects.filter(
|
||||
name=duplicate["name"], platform_id=duplicate["platform"]
|
||||
).order_by("id")
|
||||
|
||||
for edition in duplicate_editions[1:]: # Skip the first one
|
||||
edition.name = f"{edition.name} {counter}"
|
||||
edition.save()
|
||||
counter += 1
|
||||
|
||||
|
||||
def update_game_year(apps, schema_editor):
|
||||
Game = apps.get_model("games", "Game")
|
||||
Edition = apps.get_model("games", "Edition")
|
||||
|
||||
for game in Game.objects.filter(year__isnull=True):
|
||||
# Try to get the first related edition with a non-null year_released
|
||||
edition = Edition.objects.filter(game=game, year_released__isnull=False).first()
|
||||
if edition:
|
||||
# If an edition is found, update the game's year
|
||||
game.year = edition.year_released
|
||||
game.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
replaces = [
|
||||
("games", "0016_alter_edition_platform_alter_edition_year_released_and_more"),
|
||||
("games", "0017_alter_device_type_alter_purchase_platform"),
|
||||
("games", "0018_auto_20231106_1825"),
|
||||
("games", "0019_alter_edition_unique_together"),
|
||||
("games", "0020_game_year"),
|
||||
("games", "0021_auto_20231106_1909"),
|
||||
("games", "0022_rename_year_game_year_released"),
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
("games", "0015_edition_wikidata_edition_year_released"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="edition",
|
||||
name="platform",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="games.platform",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="edition",
|
||||
name="year_released",
|
||||
field=models.IntegerField(blank=True, default=None, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="game",
|
||||
name="wikidata",
|
||||
field=models.CharField(blank=True, default=None, max_length=50, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="platform",
|
||||
name="group",
|
||||
field=models.CharField(blank=True, default=None, max_length=255, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="session",
|
||||
name="device",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="games.device",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="device",
|
||||
name="type",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("pc", "PC"),
|
||||
("co", "Console"),
|
||||
("ha", "Handheld"),
|
||||
("mo", "Mobile"),
|
||||
("sbc", "Single-board computer"),
|
||||
("un", "Unknown"),
|
||||
],
|
||||
default="un",
|
||||
max_length=3,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="purchase",
|
||||
name="platform",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="games.platform",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=rename_duplicates,
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="edition",
|
||||
unique_together={("name", "platform")},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="game",
|
||||
name="year",
|
||||
field=models.IntegerField(blank=True, default=None, null=True),
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=update_game_year,
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name="game",
|
||||
old_name="year",
|
||||
new_name="year_released",
|
||||
),
|
||||
]
|
||||
@@ -1,48 +0,0 @@
|
||||
# Generated by Django 6.0.1 on 2026-06-06 07:17
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0016_add_needs_price_update"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="FilterPreset",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255)),
|
||||
(
|
||||
"mode",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("games", "Games"),
|
||||
("sessions", "Sessions"),
|
||||
("purchases", "Purchases"),
|
||||
("playevents", "Play Events"),
|
||||
],
|
||||
default="games",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
("find_filter", models.JSONField(blank=True, default=dict)),
|
||||
("object_filter", models.JSONField(blank=True, default=dict)),
|
||||
("ui_options", models.JSONField(blank=True, default=dict)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
"ordering": ["name"],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,41 @@
|
||||
# Generated by Django 4.1.5 on 2023-11-06 16:53
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0016_alter_edition_platform_alter_edition_year_released_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="device",
|
||||
name="type",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("pc", "PC"),
|
||||
("co", "Console"),
|
||||
("ha", "Handheld"),
|
||||
("mo", "Mobile"),
|
||||
("sbc", "Single-board computer"),
|
||||
("un", "Unknown"),
|
||||
],
|
||||
default="un",
|
||||
max_length=3,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="purchase",
|
||||
name="platform",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
default=None,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="games.platform",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 6.0.1 on 2026-06-06 20:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('games', '0017_add_filter_preset'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='session',
|
||||
name='timestamp_start',
|
||||
field=models.DateTimeField(db_index=True, verbose_name='Start'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 4.1.5 on 2023-11-06 17:25
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def rename_duplicates(apps, schema_editor):
|
||||
Edition = apps.get_model("games", "Edition")
|
||||
|
||||
duplicates = (
|
||||
Edition.objects.values("name", "platform")
|
||||
.annotate(name_count=models.Count("id"))
|
||||
.filter(name_count__gt=1)
|
||||
)
|
||||
|
||||
for duplicate in duplicates:
|
||||
counter = 1
|
||||
duplicate_editions = Edition.objects.filter(
|
||||
name=duplicate["name"], platform_id=duplicate["platform"]
|
||||
).order_by("id")
|
||||
|
||||
for edition in duplicate_editions[1:]: # Skip the first one
|
||||
edition.name = f"{edition.name} {counter}"
|
||||
edition.save()
|
||||
counter += 1
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0017_alter_device_type_alter_purchase_platform"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(rename_duplicates),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 4.1.5 on 2023-11-06 17:26
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0018_auto_20231106_1825"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterUniqueTogether(
|
||||
name="edition",
|
||||
unique_together={("name", "platform")},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.1.5 on 2023-11-06 18:05
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0019_alter_edition_unique_together"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="game",
|
||||
name="year",
|
||||
field=models.IntegerField(blank=True, default=None, null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def update_game_year(apps, schema_editor):
|
||||
Game = apps.get_model("games", "Game")
|
||||
Edition = apps.get_model("games", "Edition")
|
||||
|
||||
for game in Game.objects.filter(year__isnull=True):
|
||||
# Try to get the first related edition with a non-null year_released
|
||||
edition = Edition.objects.filter(game=game, year_released__isnull=False).first()
|
||||
if edition:
|
||||
# If an edition is found, update the game's year
|
||||
game.year = edition.year_released
|
||||
game.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("games", "0020_game_year"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(update_game_year),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.1.5 on 2023-11-06 18:12
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0021_auto_20231106_1909"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name="game",
|
||||
old_name="year",
|
||||
new_name="year_released",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
# Generated by Django 4.1.5 on 2023-11-06 18:24
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
(
|
||||
"games",
|
||||
"0016_alter_edition_platform_alter_edition_year_released_and_more_squashed_0022_rename_year_game_year_released",
|
||||
),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="purchase",
|
||||
name="date_finished",
|
||||
field=models.DateField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user