94 Commits

Author SHA1 Message Date
lukas 36411c99a7 Version 1.7.0
Django CI/CD / test (push) Successful in 38s
Django CI/CD / build-and-push (push) Has been skipped
## 1.7.0 / 2026-05-12

### 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
2026-05-12 18:36:46 +02:00
lukas 360e8f9eaf Make container more robust (#95)
Django CI/CD / build-and-push (push) Has been cancelled
Django CI/CD / test (push) Has been cancelled
Reviewed-on: #95

12 files changed (+149, -66)
Key changes:
1. Monolithic container — Replaced the two-service compose setup (backend + frontend/caddy) with a single timetracker container. Caddy is now built into the image rather than running as a separate container.
2. Supervisord process manager — Added supervisor.conf and installed supervisor in the Dockerfile. entrypoint.sh now delegates to supervisord to manage three processes: Caddy, Gunicorn, and Qcluster — replacing manual trap/signaling logic.
3. Bundled Caddy — The Dockerfile now downloads and installs Caddy v2.9.1 directly into the image (/usr/local/bin/caddy). The Caddyfile was updated to use reverse_proxy localhost:8001 and serves static files from /home/timetracker/app/static.
4. Configurable deployment — Added .env.example with configurable environment variables: TZ, PUID/PGID, TIMETRACKER_EXTERNAL_PORT, DATA_DIR, CSRF_TRUSTED_ORIGINS. docker-compose.yml now references these with sensible defaults.
5. UID/GID flexibility — entrypoint.sh uses usermod/groupmod at startup to remap the timetracker user to the host-specified PUID/PGID, avoiding permission issues with mounted volumes.
6. Database & static files — settings.py now respects DATA_DIR env var for the SQLite database path. STATIC_ROOT changed to BASE_DIR / "static".
7. Dev improvements — New Caddyfile.dev (with browse enabled for static files) and updated Makefile dev-prod target runs Caddy alongside Django in development.
8. Tests — Re-enabled the test step in the Docker build GitHub Actions workflow.
2026-05-12 16:29:34 +00:00
lukas c10b7a8013 Improve make dev-prod
Django CI/CD / test (push) Successful in 30s
Django CI/CD / build-and-push (push) Successful in 1m23s
2026-05-12 15:27:56 +02:00
lukas 103c29e234 Fix missing values for first and last game in stats view
Django CI/CD / test (push) Successful in 22s
Django CI/CD / build-and-push (push) Successful in 53s
2026-05-12 15:12:43 +02:00
lukas 5003b739d3 PR review
Django CI/CD / test (push) Successful in 28s
Django CI/CD / build-and-push (push) Successful in 55s
2026-05-12 14:56:59 +02:00
lukas 4ba3ed555f Add info on statuses 2026-05-12 14:51:59 +02:00
lukas e3b53cd4a9 Add needs_price_update field to Purchase model
Django CI/CD / test (push) Successful in 22s
Django CI/CD / build-and-push (push) Has been skipped
Replace fragile price change detection in Purchase.save() with a
lazy dirty flag approach. A pre_save/post_save signal pair detects
price/currency changes without extra DB queries, and convert_prices()
uses the flag to determine which purchases need conversion.

- Add needs_price_update BooleanField with db_index
- Add pre_save signal to store old price/currency values
- Add post_save signal to set needs_price_update=True when price/currency changes
- Simplify Purchase.save() to remove DB reload + comparison logic
- Remove price_or_currency_differ_from() method
- Update convert_prices() to filter on needs_price_update flag
- Extract _get_exchange_rate() and _save_converted_price() helpers
- Add tests for the new behavior
2026-05-12 13:57:59 +02:00
lukas a4e697a274 Add confirmation before deleting game
Django CI/CD / test (push) Successful in 28s
Django CI/CD / build-and-push (push) Successful in 1m1s
2026-05-12 13:37:55 +02:00
lukas b8187c32b1 Always abandon refunded games
Django CI/CD / test (push) Successful in 36s
Django CI/CD / build-and-push (push) Successful in 54s
2026-05-12 12:49:07 +02:00
lukas bf2b86ba1f Streamline evaluating game status 2026-05-12 12:48:14 +02:00
lukas 913c7d3a98 Scope URLs to the games namespace 2026-05-12 12:43:08 +02:00
lukas 37e3c69abc Make tests more robust, use django-pytest 2026-05-12 11:56:28 +02:00
lukas 0866eb25e9 update django-ninja to 1.6.2 2026-05-12 11:15:07 +02:00
lukas 39f21bc7db Remove GraphQL API 2026-05-12 11:15:07 +02:00
lukas 1416d00a37 Fix additional tests 2026-05-12 11:15:07 +02:00
lukas d9fe99963a Fix htmx_middleware tests 2026-05-12 11:01:48 +02:00
lukas 393476be85 Fix test_duration_format 2026-05-12 10:48:30 +02:00
lukas e32af2f576 Fix test_paths_return_200 2026-05-12 10:43:38 +02:00
lukas e565002244 Add simple table rendering tests
Django CI/CD / test (push) Successful in 30s
Django CI/CD / build-and-push (push) Successful in 1m6s
2026-05-12 10:21:33 +02:00
lukas 1a4e51c95a Update NameWithIcon
The `NameWithIcon()` function had a `platform` parameter that was immediately overwritten by `platform = None` and never used (dead code). The function mixed data lookup (database queries via IDs) with rendering, making it untestable.

**Fix**: Refactored `NameWithIcon()` to follow the `LinkedPurchase` pattern — accepts model objects (`Game`, `Session`) instead of IDs. Extracted `_resolve_name_with_icon()` helper for testable computation logic (name resolution, platform extraction, link creation). Fixed bug where `platform` was not extracted when `session` parameter was passed. Removed dead `platform` parameter from the public API. Updated all 3 production call sites (already using model objects). Added 10 unit tests for `_resolve_name_with_icon()` covering session override, custom names, linkify behavior, platform resolution, and edge cases. Updated 6 integration tests to use model-based parameters.
2026-05-12 10:05:15 +02:00
lukas eae020fd34 Add component tests 2026-05-12 09:43:45 +02:00
lukas 1f4dd60c54 Fix default mutable arguments
`attributes: list[HTMLAttribute] = []` and `children: list[HTMLTag] | HTMLTag = []` are a classic Python gotcha — the default is shared across all callers and could silently corrupt state if ever mutated in place. Changed 8 functions (`Component`, `Popover`, `A`, `Button`, `Div`, `Input`, `Form`, `Icon`) to use the `None` sentinel pattern, preventing future bugs and eliminating linter warnings.
2026-05-12 09:39:43 +02:00
lukas 656a96f55c Fix A() component
Replaced single `url` parameter with explicit `url_name` (URL pattern name resolved via `reverse()`) and `href` (literal path). Fixes:
- Silent fallback (typos like `"ad_puchase"` silently became broken links) → now raises `NoReverseMatch` at render time
- `type(url) is str` gate → removed (implicit dual-mode eliminated entirely)
- Callable parameter (`url: Callable`) dead code → removed
- Implicit dual-mode (`url="name"` vs `url=reverse("name")`) → `url_name` vs `href` are now mutually exclusive params
- Inconsistent type annotation mixing `Callable` with string default → cleaned up
- Added `ValueError` when both `url_name` and `href` are provided
- Updated all 10 call sites across 6 view files and internal callers (`LinkedPurchase()`, `NameWithIcon()`)
2026-05-12 09:01:05 +02:00
lukas 8c3e819a5f Consistent component return type 2026-05-12 08:43:39 +02:00
lukas ff11e35115 Add component tests 2026-05-12 08:31:17 +02:00
lukas ebef0bba87 Make randomid deterministic to improve caching 2026-05-12 08:27:11 +02:00
lukas 140f3d2bd6 Add caching tests 2026-05-12 08:21:48 +02:00
lukas 245a4f5b3e Add component improvement doc 2026-05-12 08:10:46 +02:00
lukas cd9f0b4111 Caching 1/? 2026-05-12 08:10:33 +02:00
lukas f82c61ef1e Add toast notification system
Django CI/CD / test (push) Successful in 35s
Django CI/CD / build-and-push (push) Successful in 54s
Add more toast types
2026-05-11 20:22:23 +02:00
lukas 4e3b0ddb08 Allow directly updating device in session list
Django CI/CD / test (push) Successful in 30s
Django CI/CD / build-and-push (push) Successful in 1m12s
2026-05-11 12:54:42 +02:00
lukas a549050860 Make edit_session use the same template as add_session
Django CI/CD / test (push) Successful in 34s
Django CI/CD / build-and-push (push) Successful in 1m36s
2026-05-06 10:43:57 +02:00
lukas 596d1ccfe1 Fix refund confirmation not working
Django CI/CD / test (push) Successful in 27s
Django CI/CD / build-and-push (push) Successful in 1m34s
2026-03-05 20:34:58 +01:00
lukas bb26fec5e3 Fix extra submit button when adding purchase
Django CI/CD / test (push) Successful in 35s
Django CI/CD / build-and-push (push) Successful in 1m13s
2026-02-25 08:04:48 +01:00
lukas 1ba7de0bb7 Use pointer cursor for search field button
Django CI/CD / test (push) Successful in 30s
Django CI/CD / build-and-push (push) Successful in 1m2s
2026-02-21 21:50:46 +01:00
lukas 3391fb72f2 Fix secondary submit buttons not working 2026-02-21 21:48:31 +01:00
lukas 0986e59fe7 Improve styles
Django CI/CD / test (push) Successful in 30s
Django CI/CD / build-and-push (push) Successful in 1m4s
2026-02-18 23:30:30 +01:00
lukas 46b1199863 Fix button not passing attributes 2026-02-18 23:30:12 +01:00
lukas bc1092b0b3 Add prompt to set game to Abandoned upon refund
Django CI/CD / test (push) Successful in 36s
Django CI/CD / build-and-push (push) Successful in 1m55s
2026-02-17 22:14:36 +01:00
lukas 996c0107c9 Housekeeping
* Updated flowbite to 4.x
* Start revamping styles
* Remove unused GraphQL code
* Make some templates more robuts
2026-02-17 22:14:16 +01:00
lukas 277ecd1b55 Update to 1.6.1
Django CI/CD / test (push) Successful in 24s
Django CI/CD / build-and-push (push) Has been skipped
2026-01-30 11:49:39 +01:00
lukas 4e3a5ef682 Make buttons use pointer cursor 2026-01-30 11:45:42 +01:00
lukas 233f63f18e Update Django et al
Django CI/CD / test (push) Successful in 27s
Django CI/CD / build-and-push (push) Successful in 1m25s
2026-01-29 16:53:45 +01:00
lukas 016f307240 Upgrade to Tailwind v4 2026-01-29 13:17:04 +01:00
lukas 715acd6244 Finish poetry migration 2026-01-29 12:56:45 +01:00
lukas 0bc48d01a7 Fix search field icon misalignment
Django CI/CD / test (push) Successful in 16s
Django CI/CD / build-and-push (push) Successful in 1m0s
2026-01-29 12:17:40 +01:00
lukas c5646d0451 Make sure Dockerfile is consistent with entrypoint.sh
Django CI/CD / test (push) Successful in 23s
Django CI/CD / build-and-push (push) Successful in 48s
2026-01-27 21:39:30 +01:00
lukas 710a0fc5bc Update entrypoint.sh
Django CI/CD / test (push) Successful in 23s
Django CI/CD / build-and-push (push) Successful in 57s
2026-01-27 21:30:04 +01:00
lukas 1d0d16b4d4 Disable cache
Django CI/CD / test (push) Successful in 21s
Django CI/CD / build-and-push (push) Successful in 49s
2026-01-27 21:15:39 +01:00
lukas 6b89bab0a6 Switch from poetry to uv
Django CI/CD / test (push) Successful in 9m34s
Django CI/CD / build-and-push (push) Failing after 1m55s
2026-01-27 20:03:39 +01:00
lukas 2bc2d98f88 Fix purchase form logic 2026-01-27 19:30:07 +01:00
lukas 06096d471e Improve dark/light mode 2026-01-27 19:28:05 +01:00
lukas 40869e25f3 Pre-calculate playevent time from last playevent 2026-01-27 18:39:09 +01:00
lukas 4f0ac21ba3 Fill up 2026-01-27 18:39:09 +01:00
lukas 3801949fdb Keep calculate_price_per_game stub
Django CI/CD / test (push) Successful in 24s
Django CI/CD / build-and-push (push) Successful in 2m1s
2026-01-16 12:32:32 +01:00
lukas f895dc1265 Prepare 1.6.0
Django CI/CD / test (push) Successful in 29s
Django CI/CD / build-and-push (push) Has been skipped
2026-01-15 23:15:03 +01:00
lukas 04601ca13d Replace game selector on game view with component 2026-01-15 23:12:54 +01:00
lukas d53575ab48 Create game status selector template 2026-01-15 23:11:13 +01:00
lukas 4e1f55855d Update history when status changes 2026-01-15 22:43:17 +01:00
lukas 95af4ceed6 Use c-gamestatus everywhere 2026-01-15 22:40:29 +01:00
lukas 6bb89438df CI: switch to checkout@v6
Django CI/CD / test (push) Successful in 27s
Django CI/CD / build-and-push (push) Successful in 2m32s
2026-01-15 21:19:18 +01:00
lukas bd5525e57e Add verbose names to selected fields 2026-01-15 21:18:58 +01:00
lukas 5cac19be7b Fix dropdown and formatting 2026-01-15 21:18:40 +01:00
lukas a6577a9e53 Remove redundant task
price_per_game is a generated column, which means it's update
automatically
2026-01-15 17:49:30 +01:00
lukas 243830a84a Make removing games and associated purchases more robust 2026-01-15 17:49:03 +01:00
lukas 7032b8c7c7 Fix signals interfering with deleting a game with sessions
Django CI/CD / test (push) Successful in 2m29s
Django CI/CD / build-and-push (push) Failing after 1m31s
2025-12-30 13:24:09 +01:00
lukas 5cc1652002 Always set game status change timestamp to now instead of game's last updated_at
Django CI/CD / test (push) Successful in 1m16s
Django CI/CD / build-and-push (push) Successful in 2m15s
2025-06-07 20:28:14 +02:00
lukas 7cf2180192 Allow setting game to Finished when creating PlayEvent
Django CI/CD / test (push) Successful in 1m17s
Django CI/CD / build-and-push (push) Successful in 2m21s
2025-06-07 20:14:14 +02:00
lukas ad0641f95b Fix playtime stats per year
Django CI/CD / test (push) Successful in 58s
Django CI/CD / build-and-push (push) Successful in 2m39s
2025-04-17 16:15:38 +02:00
lukas abdcfdfe64 Redirect to previous page after editing a session
Django CI/CD / test (push) Successful in 1m9s
Django CI/CD / build-and-push (push) Successful in 2m17s
2025-04-01 15:36:40 +02:00
lukas 31daf2efe0 Make game overview playthrough dropdown close when clicked outside
Django CI/CD / test (push) Successful in 1m6s
Django CI/CD / build-and-push (push) Successful in 2m17s
2025-03-28 13:43:46 +01:00
lukas 6d53fca910 Always return timedelta in update_game_playtime
Django CI/CD / test (push) Successful in 1m3s
Django CI/CD / build-and-push (push) Successful in 2m19s
2025-03-26 12:05:10 +01:00
lukas f7e426e030 Make it easier to create a play event 2025-03-26 12:04:46 +01:00
lukas b29e4edd72 Continue making use of improved duration handling
Django CI/CD / test (push) Successful in 1m5s
Django CI/CD / build-and-push (push) Successful in 2m22s
2025-03-25 23:38:04 +01:00
lukas 3c58851b88 Improve form fields for duration and currency
Django CI/CD / test (push) Successful in 1m0s
Django CI/CD / build-and-push (push) Successful in 2m10s
2025-03-25 22:46:17 +01:00
lukas 99f3540825 Improve duration handling for sessions and games 2025-03-25 22:46:01 +01:00
lukas 5e778bec30 Fix stats having hardcoded year
Django CI/CD / test (push) Successful in 1m20s
Django CI/CD / build-and-push (push) Successful in 2m35s
2025-03-25 15:56:20 +01:00
lukas fea9d9784d Fix purchase-name partial
Django CI/CD / test (push) Successful in 1m5s
Django CI/CD / build-and-push (push) Successful in 2m13s
2025-03-24 21:02:13 +01:00
lukas 23b4a7a069 Make it possible to edit and delete status changes
Django CI/CD / test (push) Successful in 1m11s
Django CI/CD / build-and-push (push) Successful in 2m18s
2025-03-22 23:45:02 +01:00
lukas 89de85c00d Introduce game status, playevents
Django CI/CD / test (push) Successful in 1m10s
Django CI/CD / build-and-push (push) Successful in 2m21s
2025-03-22 20:59:23 +01:00
lukas d892659132 Add debugging config 2025-03-22 20:58:30 +01:00
lukas 341e62283b Change gamestatus to non-block element 2025-03-22 20:54:20 +01:00
lukas 61b6c1c55f Set default logger to WARNING, add games handler at INFO 2025-03-22 20:52:39 +01:00
lukas eeaa02bada Add helper scripts for getting exchange rates 2025-03-22 20:52:02 +01:00
lukas 9d16bc2546 Update exchangerates.yaml 2025-03-22 20:49:21 +01:00
lukas 7a52b59b3d Improve logging in tasks.py
Django CI/CD / test (push) Successful in 1m8s
Django CI/CD / build-and-push (push) Successful in 2m9s
2025-03-22 16:57:27 +01:00
lukas 0ce59a8cc6 Fix a bug in convert_prices
Prevents actually finding any new prices
2025-03-22 16:57:27 +01:00
lukas e0dfc0fc3e update dependencies 2025-03-22 09:14:46 +01:00
lukas 8cb67ca002 Add updated_at to Game 2025-03-17 08:36:41 +01:00
lukas be2a01840c Fix != None 2025-03-17 08:35:48 +01:00
lukas 612c42ebb7 Standardize blank and null fields in models 2025-03-17 08:35:07 +01:00
lukas e2255a1c85 Update django-cotton to 1.6.0 2025-03-17 08:33:43 +01:00
lukas 0b274b4403 Calculate stats for last 7/14 days from manual as well 2025-03-17 08:30:57 +01:00
lukas ddd75f22b0 Allow games to be set to Mastered 2025-03-17 08:26:56 +01:00
119 changed files with 11654 additions and 5822 deletions
-1
View File
@@ -9,7 +9,6 @@ static
.drone.yml .drone.yml
.editorconfig .editorconfig
.gitignore .gitignore
Caddyfile
CHANGELOG.md CHANGELOG.md
db.sqlite3 db.sqlite3
docker-compose* docker-compose*
+21
View File
@@ -0,0 +1,21 @@
# 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
+26 -12
View File
@@ -9,28 +9,42 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-python@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
with: with:
python-version: 3.12 enable-cache: false
- run: | python-version: "3.14"
python -m pip install poetry
poetry install - name: Install dependencies
poetry env info run: uv sync --frozen
poetry run python manage.py migrate
# PROD=1 poetry run pytest - name: Run Migrations
run: uv run python manage.py migrate
- name: Run Tests
run: uv run --with pytest-django pytest
build-and-push: build-and-push:
needs: test needs: test
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' if: github.ref == 'refs/heads/main'
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v5 - name: Set Version
run: echo "VERSION_NUMBER=1.7.0" >> $GITHUB_ENV
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v5
with: with:
context: . context: .
push: true push: true
tags: | tags: |
registry.kucharczyk.xyz/timetracker:latest registry.kucharczyk.xyz/timetracker:latest
registry.kucharczyk.xyz/timetracker:${{ env.VERSION_NUMBER }} registry.kucharczyk.xyz/timetracker:${{ env.VERSION_NUMBER }}
env: # cache-from: type=gha
VERSION_NUMBER: 1.5.1 # cache-to: type=gha,mode=max
+1
View File
@@ -5,6 +5,7 @@ __pycache__
node_modules node_modules
package-lock.json package-lock.json
db.sqlite3 db.sqlite3
data/
/static/ /static/
dist/ dist/
.DS_Store .DS_Store
+26
View File
@@ -0,0 +1,26 @@
{
// 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"
}
]
}
+56 -5
View File
@@ -1,6 +1,57 @@
## Unreleased ## 1.7.0 / 2026-05-12
## New ### 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
* Render notes as Markdown * Render notes as Markdown
* Require login by default * Require login by default
* Add stats for dropped purchases, monthly playtimes * Add stats for dropped purchases, monthly playtimes
@@ -11,7 +62,7 @@
* Add emulated property to sessions * Add emulated property to sessions
* Add today's and last 7 days playtime stats to navbar * Add today's and last 7 days playtime stats to navbar
## Improved ### Improved
* mark refunded purchases red on game overview * mark refunded purchases red on game overview
* increase session count on game overview when starting a new session * increase session count on game overview when starting a new session
* game overview: * game overview:
@@ -22,7 +73,7 @@
* session list: use display name instead of sort name * session list: use display name instead of sort name
* unify the appearance of game links, and make them expand to full size on hover * 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 * Fix title not being displayed on the Recent sessions page
* Avoid errors when displaying game overview with zero sessions * Avoid errors when displaying game overview with zero sessions
@@ -147,7 +198,7 @@
* Use the same form when editing a session as when adding a session * 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 * 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) * 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 list (https://git.kucharczyk.xyz/lukas/timetracker/issues/53) * Improve session listing (https://git.kucharczyk.xyz/lukas/timetracker/issues/53)
### Fixes ### Fixes
+5 -4
View File
@@ -1,14 +1,15 @@
{ {
auto_https off auto_https off
admin off
} }
:8000 { :8000 {
handle_path /static/* { handle_path /static/* {
root * /usr/share/caddy root * /home/timetracker/app/static
file_server file_server
} }
handle { handle /robots.txt {
reverse_proxy backend:8001 root * /home/timetracker/app/games/static
file_server
} }
reverse_proxy localhost:8001
} }
+15
View File
@@ -0,0 +1,15 @@
{
auto_https off
}
:8000 {
handle_path /static/* {
root * static
file_server browse
}
handle /robots.txt {
root * games/static
file_server browse
}
reverse_proxy :8001
}
+48 -38
View File
@@ -1,45 +1,55 @@
FROM python:3.12.0-slim-bullseye FROM ghcr.io/astral-sh/uv:python3.14-bookworm-slim AS builder
ENV VERSION_NUMBER=1.5.2 \ ENV UV_LINK_MODE=copy \
PROD=1 \ UV_COMPILE_BYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1
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 upgrade -y \
&& apt-get install --no-install-recommends -y \
bash \
curl \
&& 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 WORKDIR /home/timetracker/app
COPY . /home/timetracker/app/
RUN chown -R timetracker:timetracker /home/timetracker/app RUN --mount=type=cache,target=/root/.cache/uv \
COPY entrypoint.sh / --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 \
PYTHONUNBUFFERED=1 \
PATH="/home/timetracker/app/.venv/bin:$PATH"
RUN apt-get update && apt-get install -y --no-install-recommends \
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
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 /
RUN chmod +x /entrypoint.sh RUN chmod +x /entrypoint.sh
RUN --mount=type=cache,target="$POETRY_CACHE_DIR" \ ENV VERSION_NUMBER=1.7.0
echo "$PROD" \
&& poetry version \
&& poetry run pip install -U pip \
&& poetry install --only main --no-interaction --no-ansi --sync
USER timetracker
EXPOSE 8000 EXPOSE 8000
CMD [ "/entrypoint.sh" ] ENTRYPOINT ["/entrypoint.sh"]
+30 -22
View File
@@ -9,64 +9,72 @@ npm:
npm install npm install
css: common/input.css css: common/input.css
npx tailwindcss -i ./common/input.css -o ./games/static/base.css npx @tailwindcss/cli -i ./common/input.css -o ./games/static/base.css
makemigrations: makemigrations:
poetry run python manage.py makemigrations uv run python manage.py makemigrations
migrate: makemigrations migrate: makemigrations
poetry run python manage.py migrate uv run python manage.py migrate
init: init:
pyenv install -s $(PYTHON_VERSION) uv install $(PYTHON_VERSION)
pyenv local $(PYTHON_VERSION) uv sync
pip install poetry
poetry install
npm install npm install
$(MAKE) sethookdir
$(MAKE) loadplatforms
sethookdir:
git config core.hooksPath .githooks
chmod +x .githooks/*
dev: dev:
@npx concurrently \ @npx concurrently \
--names "Django,Tailwind" \ --names "Django,Tailwind" \
--prefix-colors "blue,green" \ --prefix-colors "blue,green" \
"poetry run python -Wa manage.py runserver" \ "uv run python -Wa manage.py runserver" \
"npx tailwindcss -i ./common/input.css -o ./games/static/base.css --watch" "npx @tailwindcss/cli -i ./common/input.css -o ./games/static/base.css --watch"
caddy: caddy:
caddy run --watch caddy run --watch
dev-prod: migrate collectstatic dev-prod: migrate collectstatic
PROD=1 poetry run python -m gunicorn --bind 0.0.0.0:8001 timetracker.asgi:application -k uvicorn.workers.UvicornWorker @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"
dumpgames: dumpgames:
poetry run python manage.py dumpdata --format yaml games --output tracker_fixture.yaml uv run python manage.py dumpdata --format yaml games --output tracker_fixture.yaml
loadplatforms: loadplatforms:
poetry run python manage.py loaddata platforms.yaml uv run python manage.py loaddata platforms.yaml
loadall: loadall:
poetry run python manage.py loaddata data.yaml uv run python manage.py loaddata data.yaml
loadsample: loadsample:
poetry run python manage.py loaddata sample.yaml uv run python manage.py loaddata sample.yaml
createsuperuser: createsuperuser:
poetry run python manage.py createsuperuser uv run python manage.py createsuperuser
shell: shell:
poetry run python manage.py shell uv run python manage.py shell
collectstatic: collectstatic:
poetry run python manage.py collectstatic --clear --no-input uv run python manage.py collectstatic --clear --no-input
poetry.lock: pyproject.toml uv.lock: pyproject.toml
poetry install uv sync
test: poetry.lock test: uv.lock
poetry run pytest uv run --with pytest-django pytest
date: date:
poetry run python -c 'import datetime; from zoneinfo import ZoneInfo; print(datetime.datetime.isoformat(datetime.datetime.now(ZoneInfo("Europe/Prague")), timespec="minutes", sep=" "))' uv run python -c 'import datetime; from zoneinfo import ZoneInfo; print(datetime.datetime.isoformat(datetime.datetime.now(ZoneInfo("Europe/Prague")), timespec="minutes", sep=" "))'
cleanstatic: cleanstatic:
rm -r static/* rm -r static/*
+157
View File
@@ -0,0 +1,157 @@
# 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.)
+46
View File
@@ -0,0 +1,46 @@
# Suggested Improvements to common/components.py
## Completed
### Caching on template rendering
- Added `functools.lru_cache` on `_render_cached()` wrapper around `render_to_string`
- Cache key: `(template_path, json.dumps(context, sort_keys=True))` — deterministic and unique
- `maxsize=4096` in production, disabled entirely in DEBUG mode (so template changes are reflected immediately)
- Only caches `template` path calls; `tag_name` calls are already nanosecond string ops
- Verified working: identical calls return identical output, different inputs produce separate cache entries
### Non-deterministic IDs
`randomid()` was replaced with `hashlib.sha1(content_hash.encode()).hexdigest()[:10]` for deterministic ID generation.
- `Popover()` passes content hash (`wrapped_content:popover_content:wrapped_classes`) so IDs are deterministic per unique content
- `games/templatetags/randomid.py` uses the same hash-based approach
- Fixes: caching (Popover output now cacheable), page consistency, thread safety
### Inconsistent return types
All component functions now return `SafeText` and are annotated accordingly. Redundant `mark_safe()` wrappers removed from `LinkedPurchase()` and `NameWithIcon()`.
### Fragile A() URL resolution
Replaced single `url` parameter with explicit `url_name` (URL pattern name resolved via `reverse()`) and `href` (literal path). Removed dead `Callable` type hint. `reverse()` now raises `NoReverseMatch` instead of silently falling back to literal text. Added mutual exclusion check — providing both parameters raises `ValueError`. Updated all 10 call sites across 6 view files and internal callers (`LinkedPurchase()`, `NameWithIcon()`).
### Toast XSS vulnerability
The vulnerable `Toast()` component (which used unsafe string escaping for
Alpine.js interpolation) had no callers and was deleted entirely. Toast display
is handled by the existing event-driven pipeline: middleware → `HX-Trigger`
headers → `show-toast` CustomEvent → Alpine store.
### Default mutable arguments
All functions with mutable defaults (`attributes` and `children`) changed from `= []` to `| None = None` with `or []` conversion in the body.
What was fixed: `attributes: list[HTMLAttribute] = []` and `children: list[HTMLTag] | HTMLTag = []` are a classic Python gotcha — the default is shared across all callers and could silently corrupt state if ever mutated in place. Changed 8 functions (`Component`, `Popover`, `A`, `Button`, `Div`, `Input`, `Form`, `Icon`) to use the `None` sentinel pattern, preventing future bugs and eliminating linter warnings.
### NameWithIcon dead code and untestable design
The `NameWithIcon()` function had a `platform` parameter that was immediately overwritten by `platform = None` and never used (dead code). The function mixed data lookup (database queries via IDs) with rendering, making it untestable.
**Fix**: Refactored `NameWithIcon()` to follow the `LinkedPurchase` pattern — accepts model objects (`Game`, `Session`) instead of IDs. Extracted `_resolve_name_with_icon()` helper for testable computation logic (name resolution, platform extraction, link creation). Fixed bug where `platform` was not extracted when `session` parameter was passed. Removed dead `platform` parameter from the public API. Updated all 3 production call sites (already using model objects). Added 10 unit tests for `_resolve_name_with_icon()` covering session override, custom names, linkify behavior, platform resolution, and edge cases. Updated 6 integration tests to use model-based parameters.
### No tests
Zero test coverage for the entire component system.
**Fix**: Add unit tests for each component function — basic rendering, edge cases,
and cache hit/miss verification.
**Done**: 96 unit tests covering all component functions (`Component`, `randomid`, `Popover`, `PopoverTruncated`, `A`, `Button`, `Div`, `Icon`, `Form`, `Input`, `NameWithIcon`, `LinkedPurchase`, `PurchasePrice`, `_render_cached`, `enable_cache`). Includes template rendering, deterministic ID generation, LRU cache behavior, HTML output validation, edge cases, error handling, and model-dependent integration tests.
+138 -81
View File
@@ -1,11 +1,13 @@
from random import choices as random_choices import hashlib
from string import ascii_lowercase import json
from typing import Any, Callable from functools import lru_cache
from typing import Any
from django.conf import settings
from django.template import TemplateDoesNotExist from django.template import TemplateDoesNotExist
from django.template.defaultfilters import floatformat from django.template.defaultfilters import floatformat
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.urls import NoReverseMatch, reverse from django.urls import reverse
from django.utils.safestring import SafeText, mark_safe from django.utils.safestring import SafeText, mark_safe
from common.utils import truncate from common.utils import truncate
@@ -15,12 +17,32 @@ HTMLAttribute = tuple[str, str | int | bool]
HTMLTag = str HTMLTag = str
def _render_cached_impl(template: str, context_json: str) -> str:
context = json.loads(context_json)
context["slot"] = mark_safe(context["slot"])
return render_to_string(template, context)
if not settings.DEBUG:
_render_cached = lru_cache(maxsize=4096)(_render_cached_impl)
else:
_render_cached = _render_cached_impl
def enable_cache():
"""Wrap _render_cached with LRU cache (for testing in DEBUG mode)."""
global _render_cached
_render_cached = lru_cache(maxsize=4096)(_render_cached_impl)
def Component( def Component(
attributes: list[HTMLAttribute] = [], attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag = [], children: list[HTMLTag] | HTMLTag | None = None,
template: str = "", template: str = "",
tag_name: str = "", tag_name: str = "",
) -> HTMLTag: ) -> SafeText:
attributes = attributes or []
children = children or []
if not tag_name and not template: if not tag_name and not template:
raise ValueError("One of template or tag_name is required.") raise ValueError("One of template or tag_name is required.")
if isinstance(children, str): if isinstance(children, str):
@@ -37,28 +59,32 @@ def Component(
if tag_name != "": if tag_name != "":
tag = f"<{tag_name}{attributesBlob}>{childrenBlob}</{tag_name}>" tag = f"<{tag_name}{attributesBlob}>{childrenBlob}</{tag_name}>"
elif template != "": elif template != "":
tag = render_to_string( context = {name: value for name, value in attributes} | {"slot": "\n".join(children)}
template, tag = _render_cached(template, json.dumps(context, sort_keys=True))
{name: value for name, value in attributes}
| {"slot": mark_safe("\n".join(children))},
)
return mark_safe(tag) return mark_safe(tag)
def randomid(seed: str = "", length: int = 10) -> str: def randomid(seed: str = "", content: str = "", length: int = 10) -> str:
return seed + "".join(random_choices(ascii_lowercase, k=length)) if not seed and not content:
return seed
hash_input = f"{seed}:{content}" if seed else content
content_hash = hashlib.sha1(hash_input.encode()).hexdigest()
base = content_hash[:length] if not seed else content_hash[:max(0, length - len(seed))]
return seed + base
def Popover( def Popover(
popover_content: str, popover_content: str,
wrapped_content: str = "", wrapped_content: str = "",
wrapped_classes: str = "", wrapped_classes: str = "",
children: list[HTMLTag] = [], children: list[HTMLTag] | None = None,
attributes: list[HTMLAttribute] = [], attributes: list[HTMLAttribute] | None = None,
) -> str: ) -> str:
attributes = attributes or []
children = children or []
if not wrapped_content and not children: if not wrapped_content and not children:
raise ValueError("One of wrapped_content or children is required.") raise ValueError("One of wrapped_content or children is required.")
id = randomid() id = randomid(content=f"{wrapped_content}:{popover_content}:{wrapped_classes}")
return Component( return Component(
attributes=attributes attributes=attributes
+ [ + [
@@ -105,60 +131,71 @@ def PopoverTruncated(
def A( def A(
attributes: list[HTMLAttribute] = [], attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag = [], children: list[HTMLTag] | HTMLTag | None = None,
url: str | Callable[..., Any] = "", url_name: str | None = None,
): href: str | None = None,
) -> SafeText:
""" """
Returns the HTML tag "a". Returns an anchor <a> tag.
"url" can either be:
- URL (string) Accepts one of two mutually-exclusive URL specifications:
- path name passed to reverse() (string) - url_name: URL pattern name, resolved via reverse()
- function - 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 = [] additional_attributes = []
if url: if url_name is not None:
if type(url) is str: additional_attributes = [("href", reverse(url_name))]
try: elif href is not None:
url_result = reverse(url) additional_attributes = [("href", href)]
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( return Component(
tag_name="a", attributes=attributes + additional_attributes, children=children tag_name="a", attributes=attributes + additional_attributes, children=children
) )
def Button( def Button(
attributes: list[HTMLAttribute] = [], attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag = [], children: list[HTMLTag] | HTMLTag | None = None,
size: str = "base", size: str = "base",
icon: bool = False, icon: bool = False,
color: str = "blue", color: str = "blue",
): ) -> SafeText:
attributes = attributes or []
children = children or []
return Component( return Component(
template="cotton/button.html", template="cotton/button.html",
attributes=attributes + [("size", size), ("icon", icon), ("color", color)], attributes=attributes
+ [
("size", size),
("icon", icon),
("color", color),
("class", "hover:cursor-pointer"),
],
children=children, children=children,
) )
def Div( def Div(
attributes: list[HTMLAttribute] = [], attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag = [], children: list[HTMLTag] | HTMLTag | None = None,
): ) -> SafeText:
attributes = attributes or []
children = children or []
return Component(tag_name="div", attributes=attributes, children=children) return Component(tag_name="div", attributes=attributes, children=children)
def Input( def Input(
type: str = "text", type: str = "text",
attributes: list[HTMLAttribute] = [], attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag = [], children: list[HTMLTag] | HTMLTag | None = None,
): ) -> SafeText:
attributes = attributes or []
children = children or []
return Component( return Component(
tag_name="input", attributes=attributes + [("type", type)], children=children tag_name="input", attributes=attributes + [("type", type)], children=children
) )
@@ -167,9 +204,11 @@ def Input(
def Form( def Form(
action="", action="",
method="get", method="get",
attributes: list[HTMLAttribute] = [], attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag = [], children: list[HTMLTag] | HTMLTag | None = None,
): ) -> SafeText:
attributes = attributes or []
children = children or []
return Component( return Component(
tag_name="form", tag_name="form",
attributes=attributes + [("action", action), ("method", method)], attributes=attributes + [("action", action), ("method", method)],
@@ -179,8 +218,9 @@ def Form(
def Icon( def Icon(
name: str, name: str,
attributes: list[HTMLAttribute] = [], attributes: list[HTMLAttribute] | None = None,
): ) -> SafeText:
attributes = attributes or []
try: try:
result = Component(template=f"cotton/icon/{name}.html", attributes=attributes) result = Component(template=f"cotton/icon/{name}.html", attributes=attributes)
except TemplateDoesNotExist: except TemplateDoesNotExist:
@@ -189,7 +229,7 @@ def Icon(
def LinkedPurchase(purchase: Purchase) -> SafeText: def LinkedPurchase(purchase: Purchase) -> SafeText:
link = reverse("view_purchase", args=[int(purchase.id)]) link = reverse("games:view_purchase", args=[int(purchase.id)])
link_content = "" link_content = ""
popover_content = "" popover_content = ""
game_count = purchase.games.count() game_count = purchase.games.count()
@@ -226,35 +266,20 @@ def LinkedPurchase(purchase: Purchase) -> SafeText:
), ),
], ],
) )
return mark_safe(A(url=link, children=[a_content])) return A(href=link, children=[a_content])
def NameWithIcon( def NameWithIcon(
name: str = "", name: str = "",
platform: str = "", game: Game | None = None,
game_id: int = 0, session: Session | None = None,
session_id: int = 0,
purchase_id: int = 0,
linkify: bool = True, linkify: bool = True,
emulated: bool = False, emulated: bool = False,
) -> SafeText: ) -> SafeText:
create_link = False _name, platform, final_emulated, create_link, link = _resolve_name_with_icon(
link = "" name, game, session, linkify
platform = None )
if (game_id != 0 or session_id != 0 or purchase_id != 0) and linkify:
create_link = True
if session_id:
session = Session.objects.get(pk=session_id)
emulated = session.emulated
game_id = session.game.pk
if purchase_id:
purchase = Purchase.objects.get(pk=purchase_id)
game_id = purchase.games.first().pk
if game_id:
game = Game.objects.get(pk=game_id)
name = name or game.name
platform = game.platform
link = reverse("view_game", args=[int(game_id)])
content = Div( content = Div(
[("class", "inline-flex gap-2 items-center")], [("class", "inline-flex gap-2 items-center")],
[ [
@@ -264,24 +289,56 @@ def NameWithIcon(
) )
if platform if platform
else "", else "",
Icon("emulated", [("title", "Emulated")]) if emulated else "", Icon("emulated", [("title", "Emulated")]) if final_emulated else "",
PopoverTruncated(name), PopoverTruncated(_name),
], ],
) )
return mark_safe( return (
A( A(
url=link, href=link,
children=[content], children=[content],
) )
if create_link if create_link
else content, else content
) )
def PurchasePrice(purchase) -> str: 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( return Popover(
popover_content=f"{floatformat(purchase.price)} {purchase.price_currency}", popover_content=f"{floatformat(purchase.price)} {purchase.price_currency}",
wrapped_content=f"{floatformat(purchase.converted_price)} {purchase.converted_currency}", wrapped_content=f"{floatformat(purchase.converted_price)} {purchase.converted_currency}",
wrapped_classes="underline decoration-dotted", wrapped_classes="underline decoration-dotted",
) )
+132 -94
View File
@@ -1,63 +1,120 @@
@tailwind base; @import 'tailwindcss';
@tailwind components;
@tailwind utilities;
@plugin '@tailwindcss/typography';
@plugin '@tailwindcss/forms';
@plugin 'flowbite/plugin';
@source "../node_modules/flowbite";
@import "flowbite/src/themes/default";
@custom-variant dark (&:is(.dark *));
@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';
--color-accent: #7c3aed;
--color-background: #1f2937;
}
/*
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.
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);
}
}
@utility min-w-20char {
min-width: 20ch;
}
@utility max-w-20char {
max-width: 20ch;
}
@utility min-w-30char {
min-width: 30ch;
}
@utility max-w-30char {
max-width: 30ch;
}
@utility max-w-35char {
max-width: 35ch;
}
@utility max-w-40char {
max-width: 40ch;
}
@layer utilities {
@font-face { @font-face {
font-family: "IBM Plex Mono"; font-family: 'IBM Plex Mono';
src: url("fonts/IBMPlexMono-Regular.woff2") format("woff2"); src: url('fonts/IBMPlexMono-Regular.woff2') format('woff2');
font-weight: 400; font-weight: 400;
font-style: normal; font-style: normal;
} }
@font-face { @font-face {
font-family: "IBM Plex Sans"; font-family: 'IBM Plex Sans';
src: url("fonts/IBMPlexSans-Regular.woff2") format("woff2"); src: url('fonts/IBMPlexSans-Regular.woff2') format('woff2');
font-weight: 400; font-weight: 400;
font-style: normal; font-style: normal;
} }
@font-face { @font-face {
font-family: "IBM Plex Serif"; font-family: 'IBM Plex Serif';
src: url("fonts/IBMPlexSerif-Regular.woff2") format("woff2"); src: url('fonts/IBMPlexSerif-Regular.woff2') format('woff2');
font-weight: 400; font-weight: 400;
font-style: normal; font-style: normal;
} }
@font-face { @font-face {
font-family: "IBM Plex Serif"; font-family: 'IBM Plex Serif';
src: url("fonts/IBMPlexSerif-Bold.woff2") format("woff2"); src: url('fonts/IBMPlexSerif-Bold.woff2') format('woff2');
font-weight: 700; font-weight: 700;
font-style: normal; font-style: normal;
} }
@font-face { @font-face {
font-family: "IBM Plex Sans Condensed"; font-family: 'IBM Plex Sans Condensed';
src: url("fonts/IBMPlexSansCondensed-Regular.woff2") format("woff2"); src: url('fonts/IBMPlexSansCondensed-Regular.woff2') format('woff2');
font-weight: 400; font-weight: 400;
font-style: normal; font-style: normal;
} }
/* a:hover {
text-decoration-color: #ff4400;
color: rgb(254, 185, 160);
transition: all 0.2s ease-out;
} */
/* form label {
@apply dark:text-slate-400;
} */
.responsive-table { .responsive-table {
@apply dark:text-white mx-auto table-fixed; @apply dark:text-white mx-auto table-fixed;
} }
.responsive-table tr:nth-child(even) { .responsive-table tr:nth-child(even) {
@apply bg-slate-800 @apply bg-indigo-100 dark:bg-slate-800;
} }
.responsive-table tbody tr:nth-child(odd) { .responsive-table tbody tr:nth-child(odd) {
@apply bg-slate-900 @apply bg-indigo-200 dark:bg-slate-900;
} }
.responsive-table thead th { .responsive-table thead th {
@@ -68,60 +125,19 @@
.responsive-table td:not(:first-child) { .responsive-table td:not(:first-child) {
@apply border-l border-l-slate-500; @apply border-l border-l-slate-500;
} }
@layer utilities {
.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;
}
}
/* form input,
select,
textarea {
@apply dark:border dark:border-slate-900 dark:bg-slate-500 dark:text-slate-100;
} */
form input:disabled, form input:disabled,
select:disabled, select:disabled,
textarea:disabled { textarea:disabled {
@apply dark:bg-slate-800 dark:text-slate-500 cursor-not-allowed; @apply cursor-not-allowed bg-neutral-secondary-strong text-fg-disabled;
} }
.errorlist { .errorlist {
@apply mt-4 mb-1 pl-3 py-2 bg-red-600 text-slate-200 w-[300px]; @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 { #button-container button {
@apply mx-1; @apply mx-1;
} }
@@ -131,7 +147,7 @@ textarea:disabled {
} }
.basic-button { .basic-button {
@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; @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;
} }
.markdown-content ul { .markdown-content ul {
@@ -162,34 +178,56 @@ textarea:disabled {
padding-left: 1em; padding-left: 1em;
} }
/* .truncate-container { #add-form {
@apply inline-block relative; label + select, input, textarea {
a { @apply mt-1;
@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; }
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 { label {
@apply dark:text-slate-500; @apply mb-2.5 text-sm font-medium text-heading;
}
input:not([type="checkbox"]) {
@apply mb-3 bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand block w-full px-3 py-2.5 shadow-xs placeholder:text-body;
}
input[type="checkbox"] {
@apply w-4 h-4 border border-default-medium rounded-xs bg-neutral-secondary-medium focus:ring-2 focus:ring-brand-soft;
}
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;
}
} }
[type="text"], [type="password"], [type="datetime-local"], [type="datetime"], [type="date"], [type="number"], select, textarea { @layer utilities {
@apply dark:bg-slate-600 dark:text-slate-300; .toast-container {
@apply fixed z-50 flex flex-col items-end bottom-0 right-0 p-4;
} }
[type="submit"] {
@apply dark:text-white font-bold dark:bg-blue-600 px-4 py-2;
}
form div label {
@apply dark:text-white;
}
form div {
@apply flex flex-col;
}
div [type="submit"] {
@apply mt-3;
} }
+1 -1
View File
@@ -13,7 +13,7 @@ durationformat_manual: str = "%H hours"
def _safe_timedelta(duration: timedelta | int | None): def _safe_timedelta(duration: timedelta | int | None):
if duration == None: if duration is None:
return timedelta(0) return timedelta(0)
elif isinstance(duration, int): elif isinstance(duration, int):
return timedelta(seconds=duration) return timedelta(seconds=duration)
+38 -1
View File
@@ -1,10 +1,14 @@
import operator import operator
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date from datetime import date
from functools import reduce from functools import reduce, wraps
from typing import Any, Callable, Generator, Literal, TypeVar from typing import Any, Callable, Generator, Literal, TypeVar
from urllib.parse import urlencode
from django.db.models import Q from django.db.models import Q
from django.http import HttpRequest
from django.shortcuts import redirect
def safe_division(numerator: int | float, denominator: int | float) -> int | float: def safe_division(numerator: int | float, denominator: int | float) -> int | float:
""" """
@@ -128,3 +132,36 @@ def build_dynamic_filter(
processed_filters, processed_filters,
Q(), 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)
response = view_func(
request, *args, **kwargs
) # Execute the original view logic
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})}"
@@ -0,0 +1,33 @@
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)
+65
View File
@@ -0,0 +1,65 @@
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()
+12 -21
View File
@@ -1,30 +1,21 @@
--- ---
services: services:
backend: timetracker:
image: registry.kucharczyk.xyz/timetracker image: ${REGISTRY_URL:-registry.kucharczyk.xyz}/timetracker:1.7.0
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: timetracker
environment: environment:
- TZ=Europe/Prague - TZ=${TZ:-Europe/Prague}
- CSRF_TRUSTED_ORIGINS="https://tracker.kucharczyk.xyz" - CSRF_TRUSTED_ORIGINS=https://tracker.kucharczyk.xyz
user: "1000" - PUID=${PUID:-1000}
- PGID=${PGID:-100}
- DATA_DIR=${DATA_DIR:-/home/timetracker/app/data}
ports:
- "${TIMETRACKER_EXTERNAL_PORT:-8000}:8000"
volumes: volumes:
- "static-files:/var/www/django/static" - "./data:/home/timetracker/app/data"
- "$PWD/db.sqlite3:/home/timetracker/app/db.sqlite3" - "${DOCKER_STORAGE_PATH:-/tmp}/timetracker/backups:/home/timetracker/app/games/fixtures/backups"
restart: unless-stopped 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:
+16 -16
View File
@@ -1,23 +1,23 @@
#!/bin/bash #!/bin/bash
# Apply database migrations
set -euo pipefail set -euo pipefail
echo "Apply database migrations"
poetry run python manage.py migrate
echo "Collect static files" PUID=${PUID:-1000}
poetry run python manage.py collectstatic --clear --no-input PGID=${PGID:-100}
_term() { USERHOME=$(grep timetracker /etc/passwd | cut -d ":" -f6)
echo "Caught SIGTERM signal!" usermod -d "/root" timetracker
kill -SIGTERM "$gunicorn_pid" groupmod -o -g "$PGID" timetracker
kill -SIGTERM "$django_q_pid" usermod -o -u "$PUID" timetracker
} usermod -d "${USERHOME}" timetracker
trap _term SIGTERM
echo "Starting Django-Q cluster" mkdir -p /home/timetracker/app/data /var/log/supervisor
poetry run python manage.py qcluster & django_q_pid=$! chmod 755 /home/timetracker/app
chmod 755 /home/timetracker/app/.venv
echo "Starting app" chown "$PUID:$PGID" /home/timetracker/app/data
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" /var/log/supervisor
wait "$gunicorn_pid" "$django_q_pid" python manage.py migrate
python manage.py collectstatic --clear --no-input
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisor.conf
+116
View File
@@ -0,0 +1,116 @@
from datetime import date, datetime
from typing import List
from django.contrib import messages
from django.shortcuts import get_object_or_404
from django.utils.timezone import now as django_timezone_now
from ninja import Field, ModelSchema, NinjaAPI, Router, Schema, Status
from games.models import Game, PlayEvent, Session
api = NinjaAPI()
playevent_router = Router()
game_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
@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)
api.add_router("/playevent", playevent_router)
api.add_router("/games", game_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)
+21 -20
View File
@@ -1,9 +1,10 @@
from datetime import timedelta # from datetime import timedelta
from django.apps import AppConfig from django.apps import AppConfig
from django.core.management import call_command from django.core.management import call_command
from django.db.models.signals import post_migrate from django.db.models.signals import post_migrate
from django.utils.timezone import now
# from django.utils.timezone import now
class GamesConfig(AppConfig): class GamesConfig(AppConfig):
@@ -17,26 +18,26 @@ class GamesConfig(AppConfig):
def schedule_tasks(sender, **kwargs): def schedule_tasks(sender, **kwargs):
from django_q.models import Schedule # from django_q.models import Schedule
from django_q.tasks import schedule # from django_q.tasks import schedule
if not Schedule.objects.filter(name="Update converted prices").exists(): # if not Schedule.objects.filter(name="Update converted prices").exists():
schedule( # schedule(
"games.tasks.convert_prices", # "games.tasks.convert_prices",
name="Update converted prices", # name="Update converted prices",
schedule_type=Schedule.MINUTES, # schedule_type=Schedule.MINUTES,
next_run=now() + timedelta(seconds=30), # next_run=now() + timedelta(seconds=30),
catchup=False, # catchup=False,
) # )
if not Schedule.objects.filter(name="Update price per game").exists(): # if not Schedule.objects.filter(name="Update price per game").exists():
schedule( # schedule(
"games.tasks.calculate_price_per_game", # "games.tasks.calculate_price_per_game",
name="Update price per game", # name="Update price per game",
schedule_type=Schedule.MINUTES, # schedule_type=Schedule.MINUTES,
next_run=now() + timedelta(seconds=30), # next_run=now() + timedelta(seconds=30),
catchup=False, # catchup=False,
) # )
from games.models import ExchangeRate from games.models import ExchangeRate
+392
View File
@@ -110,3 +110,395 @@
currency_to: CZK currency_to: CZK
year: 2018 year: 2018
rate: 3.268 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
+77 -6
View File
@@ -1,8 +1,17 @@
from django import forms from django import forms
from django.db import transaction
from django.urls import reverse from django.urls import reverse
from common.utils import safe_getattr from common.utils import safe_getattr
from games.models import Device, Game, Platform, Purchase, Session from games.models import (
Device,
Game,
GameStatusChange,
Platform,
PlayEvent,
Purchase,
Session,
)
custom_date_widget = forms.DateInput(attrs={"type": "date"}) custom_date_widget = forms.DateInput(attrs={"type": "date"})
custom_datetime_widget = forms.DateTimeInput( custom_datetime_widget = forms.DateTimeInput(
@@ -27,6 +36,13 @@ class SessionForm(forms.ModelForm):
widget=forms.Select(attrs={"autofocus": "autofocus"}), 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")) device = forms.ModelChoiceField(queryset=Device.objects.order_by("name"))
mark_as_played = forms.BooleanField( mark_as_played = forms.BooleanField(
@@ -79,7 +95,7 @@ class PurchaseForm(forms.ModelForm):
# Automatically update related_purchase <select/> # Automatically update related_purchase <select/>
# to only include purchases of the selected game. # to only include purchases of the selected game.
related_purchase_by_game_url = reverse("related_purchase_by_game") related_purchase_by_game_url = reverse("games:related_purchase_by_game")
self.fields["games"].widget.attrs.update( self.fields["games"].widget.attrs.update(
{ {
"hx-trigger": "load, click", "hx-trigger": "load, click",
@@ -99,12 +115,22 @@ class PurchaseForm(forms.ModelForm):
required=False, required=False,
) )
price_currency = forms.CharField(
widget=forms.TextInput(
attrs={
"x-mask": "aaa",
"placeholder": "CZK",
"x-data": "",
"class": "uppercase",
}
),
label="Currency",
)
class Meta: class Meta:
widgets = { widgets = {
"date_purchased": custom_date_widget, "date_purchased": custom_date_widget,
"date_refunded": custom_date_widget, "date_refunded": custom_date_widget,
"date_finished": custom_date_widget,
"date_dropped": custom_date_widget,
} }
model = Purchase model = Purchase
fields = [ fields = [
@@ -112,8 +138,6 @@ class PurchaseForm(forms.ModelForm):
"platform", "platform",
"date_purchased", "date_purchased",
"date_refunded", "date_refunded",
"date_finished",
"date_dropped",
"infinite", "infinite",
"price", "price",
"price_currency", "price_currency",
@@ -171,8 +195,10 @@ class GameForm(forms.ModelForm):
"name", "name",
"sort_name", "sort_name",
"platform", "platform",
"original_year_released",
"year_released", "year_released",
"status", "status",
"mastered",
"wikidata", "wikidata",
] ]
widgets = {"name": autofocus_input_widget} widgets = {"name": autofocus_input_widget}
@@ -194,3 +220,48 @@ class DeviceForm(forms.ModelForm):
model = Device model = Device
fields = ["name", "type"] fields = ["name", "type"]
widgets = {"name": autofocus_input_widget} widgets = {"name": autofocus_input_widget}
class PlayEventForm(forms.ModelForm):
game = GameModelChoiceField(
queryset=Game.objects.order_by("sort_name"),
widget=forms.Select(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,
}
-1
View File
@@ -1 +0,0 @@
from .game import Mutation as GameMutation
-29
View File
@@ -1,29 +0,0 @@
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()
-5
View File
@@ -1,5 +0,0 @@
from .device import Query as DeviceQuery
from .game import Query as GameQuery
from .platform import Query as PlatformQuery
from .purchase import Query as PurchaseQuery
from .session import Query as SessionQuery
-11
View File
@@ -1,11 +0,0 @@
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()
-18
View File
@@ -1,18 +0,0 @@
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
-11
View File
@@ -1,11 +0,0 @@
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()
-11
View File
@@ -1,11 +0,0 @@
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()
-11
View File
@@ -1,11 +0,0 @@
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()
-44
View File
@@ -1,44 +0,0 @@
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__"
+64
View File
@@ -0,0 +1,64 @@
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
@@ -0,0 +1,59 @@
# 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=''),
),
]
+18
View File
@@ -0,0 +1,18 @@
# 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,190 @@
# 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,21 @@
# 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,17 @@
# 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',
),
]
@@ -0,0 +1,20 @@
# 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,32 @@
# 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(),
),
),
]
+35
View File
@@ -0,0 +1,35 @@
# 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,19 @@
# 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()),
),
]
@@ -0,0 +1,39 @@
# 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,22 @@
# 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 != ''",
),
]
+181 -52
View File
@@ -1,27 +1,38 @@
import logging
from datetime import timedelta from datetime import timedelta
import requests
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.db.models import F, Sum from django.db.models import F, Q, Sum
from django.db.models.expressions import RawSQL
from django.db.models.fields.generated import GeneratedField
from django.db.models.functions import Coalesce
from django.template.defaultfilters import floatformat, pluralize, slugify from django.template.defaultfilters import floatformat, pluralize, slugify
from django.utils import timezone from django.utils import timezone
from common.time import format_duration from common.time import format_duration
logger = logging.getLogger("games")
class Game(models.Model): class Game(models.Model):
class Meta: class Meta:
unique_together = [["name", "platform", "year_released"]] unique_together = [["name", "platform", "year_released"]]
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
sort_name = models.CharField(max_length=255, null=True, blank=True, default=None) sort_name = models.CharField(max_length=255, blank=True, default="")
year_released = models.IntegerField(null=True, blank=True, default=None) year_released = models.IntegerField(null=True, blank=True, default=None)
wikidata = models.CharField(max_length=50, null=True, blank=True, default=None) original_year_released = models.IntegerField(null=True, blank=True, default=None)
wikidata = models.CharField(max_length=50, blank=True, default="")
platform = models.ForeignKey( platform = models.ForeignKey(
"Platform", on_delete=models.SET_DEFAULT, null=True, blank=True, default=None "Platform", on_delete=models.SET_DEFAULT, null=True, blank=True, default=None
) )
playtime = models.DurationField(blank=True, editable=False, default=timedelta(0))
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Status(models.TextChoices): class Status(models.TextChoices):
UNPLAYED = ( UNPLAYED = (
@@ -54,6 +65,25 @@ class Game(models.Model):
def __str__(self): def __str__(self):
return self.name return self.name
def finished(self):
return (self.status == self.Status.FINISHED or
self.playevents.filter(ended__isnull=False).exists())
def abandoned(self):
return self.status == self.Status.ABANDONED
def retired(self):
return self.status == self.Status.RETIRED
def played(self):
return self.status == self.Status.PLAYED
def unplayed(self):
return self.status == self.Status.UNPLAYED
def playtime_formatted(self):
return format_duration(self.playtime, "%2.1H")
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self.platform is None: if self.platform is None:
self.platform = get_sentinel_platform() self.platform = get_sentinel_platform()
@@ -68,7 +98,7 @@ def get_sentinel_platform():
class Platform(models.Model): class Platform(models.Model):
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
group = models.CharField(max_length=255, null=True, blank=True, default=None) group = models.CharField(max_length=255, blank=True, default="")
icon = models.SlugField(blank=True) icon = models.SlugField(blank=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
@@ -88,12 +118,22 @@ class PurchaseQueryset(models.QuerySet):
def not_refunded(self): def not_refunded(self):
return self.filter(date_refunded__isnull=True) return self.filter(date_refunded__isnull=True)
def finished(self):
return self.filter(date_finished__isnull=False)
def games_only(self): def games_only(self):
return self.filter(type=Purchase.GAME) return self.filter(type=Purchase.GAME)
def finished(self):
return self.filter(
Q(games__status="f") | Q(games__playevents__ended__isnull=False)
).distinct()
def abandoned(self):
return self.filter(games__status="a").distinct()
def dropped(self):
return self.filter(
Q(games__status="a") | Q(date_refunded__isnull=False)
).distinct()
class Purchase(models.Model): class Purchase(models.Model):
PHYSICAL = "ph" PHYSICAL = "ph"
@@ -127,33 +167,36 @@ class Purchase(models.Model):
objects = PurchaseQueryset().as_manager() objects = PurchaseQueryset().as_manager()
games = models.ManyToManyField(Game, related_name="purchases", blank=True) games = models.ManyToManyField(Game, related_name="purchases")
platform = models.ForeignKey( platform = models.ForeignKey(
Platform, on_delete=models.CASCADE, default=None, null=True, blank=True Platform, on_delete=models.CASCADE, default=None, null=True, blank=True
) )
date_purchased = models.DateField() date_purchased = models.DateField(verbose_name="Purchased")
date_refunded = models.DateField(blank=True, null=True) date_refunded = models.DateField(blank=True, null=True, verbose_name="Refunded")
date_finished = models.DateField(blank=True, null=True)
date_dropped = models.DateField(blank=True, null=True)
infinite = models.BooleanField(default=False) infinite = models.BooleanField(default=False)
price = models.FloatField(default=0) price = models.FloatField(default=0)
price_currency = models.CharField(max_length=3, default="USD") price_currency = models.CharField(max_length=3, default="USD")
converted_price = models.FloatField(null=True) converted_price = models.FloatField(null=True)
converted_currency = models.CharField(max_length=3, null=True) converted_currency = models.CharField(max_length=3, blank=True, default="")
price_per_game = models.FloatField(null=True) needs_price_update = models.BooleanField(default=True, db_index=True)
price_per_game = GeneratedField(
expression=Coalesce(F("converted_price"), F("price"), 0) / F("num_purchases"),
output_field=models.FloatField(),
db_persist=True,
editable=False,
)
num_purchases = models.IntegerField(default=0) num_purchases = models.IntegerField(default=0)
ownership_type = models.CharField( ownership_type = models.CharField(
max_length=2, choices=OWNERSHIP_TYPES, default=DIGITAL max_length=2, choices=OWNERSHIP_TYPES, default=DIGITAL
) )
type = models.CharField(max_length=255, choices=TYPES, default=GAME) type = models.CharField(max_length=255, choices=TYPES, default=GAME)
name = models.CharField(max_length=255, default="", null=True, blank=True) name = models.CharField(max_length=255, blank=True, default="")
related_purchase = models.ForeignKey( related_purchase = models.ForeignKey(
"self", "self",
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
default=None, default=None,
null=True, null=True,
blank=True,
related_name="related_purchases", related_name="related_purchases",
) )
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
@@ -198,21 +241,15 @@ class Purchase(models.Model):
def is_game(self): def is_game(self):
return self.type == self.GAME return self.type == self.GAME
def refund(self):
self.date_refunded = timezone.now()
self.save()
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self.type != Purchase.GAME and not self.related_purchase: if self.type != Purchase.GAME and not self.related_purchase:
raise ValidationError( raise ValidationError(
f"{self.get_type_display()} must have a related purchase." f"{self.get_type_display()} must have a related purchase."
) )
if self.pk is not None:
# Retrieve the existing instance from the database
existing_purchase = Purchase.objects.get(pk=self.pk)
# If price has changed, reset converted fields
if (
existing_purchase.price != self.price
or existing_purchase.price_currency != self.price_currency
):
self.converted_price = None
self.converted_currency = None
super().save(*args, **kwargs) super().save(*args, **kwargs)
@@ -247,15 +284,27 @@ class Session(models.Model):
game = models.ForeignKey( game = models.ForeignKey(
Game, Game,
on_delete=models.CASCADE, on_delete=models.CASCADE,
blank=True,
null=True, null=True,
default=None, default=None,
related_name="sessions", related_name="sessions",
) )
timestamp_start = models.DateTimeField() timestamp_start = models.DateTimeField(verbose_name="Start")
timestamp_end = models.DateTimeField(blank=True, null=True) timestamp_end = models.DateTimeField(blank=True, null=True, verbose_name="End")
duration_manual = models.DurationField(blank=True, null=True, default=timedelta(0)) duration_manual = models.DurationField(
duration_calculated = models.DurationField(blank=True, null=True) blank=True, null=True, default=timedelta(0), verbose_name="Manual duration"
)
duration_calculated = GeneratedField(
expression=Coalesce(F("timestamp_end") - F("timestamp_start"), 0),
output_field=models.DurationField(),
db_persist=True,
editable=False,
)
duration_total = GeneratedField(
expression=F("duration_calculated") + F("duration_manual"),
output_field=models.DurationField(),
db_persist=True,
editable=False,
)
device = models.ForeignKey( device = models.ForeignKey(
"Device", "Device",
on_delete=models.SET_DEFAULT, on_delete=models.SET_DEFAULT,
@@ -263,7 +312,7 @@ class Session(models.Model):
blank=True, blank=True,
default=None, default=None,
) )
note = models.TextField(blank=True, null=True) note = models.TextField(blank=True, default="")
emulated = models.BooleanField(default=False) emulated = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
@@ -272,7 +321,7 @@ class Session(models.Model):
objects = SessionQuerySet.as_manager() objects = SessionQuerySet.as_manager()
def __str__(self): def __str__(self):
mark = ", manual" if self.is_manual() else "" mark = "*" if self.is_manual() else ""
return f"{str(self.game)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})" return f"{str(self.game)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})"
def finish_now(self): def finish_now(self):
@@ -281,32 +330,18 @@ class Session(models.Model):
def start_now(): def start_now():
self.timestamp_start = timezone.now() self.timestamp_start = timezone.now()
def duration_seconds(self) -> timedelta:
manual = timedelta(0)
calculated = timedelta(0)
if self.is_manual() and isinstance(self.duration_manual, timedelta):
manual = self.duration_manual
if self.timestamp_end != None and self.timestamp_start != None:
calculated = self.timestamp_end - self.timestamp_start
return timedelta(seconds=(manual + calculated).total_seconds())
def duration_formatted(self) -> str: def duration_formatted(self) -> str:
result = format_duration(self.duration_seconds(), "%02.0H:%02.0m") result = format_duration(self.duration_total, "%02.1H")
return result return result
def duration_formatted_with_mark(self) -> str:
mark = "*" if self.is_manual() else ""
return f"{self.duration_formatted()}{mark}"
def is_manual(self) -> bool: def is_manual(self) -> bool:
return not self.duration_manual == timedelta(0) return not self.duration_manual == timedelta(0)
@property
def duration_sum(self) -> str:
return Session.objects.all().total_duration_formatted()
def save(self, *args, **kwargs) -> None: def save(self, *args, **kwargs) -> None:
if self.timestamp_start != None and self.timestamp_end != None:
self.duration_calculated = self.timestamp_end - self.timestamp_start
else:
self.duration_calculated = timedelta(0)
if not isinstance(self.duration_manual, timedelta): if not isinstance(self.duration_manual, timedelta):
self.duration_manual = timedelta(0) self.duration_manual = timedelta(0)
@@ -352,3 +387,97 @@ class ExchangeRate(models.Model):
def __str__(self): def __str__(self):
return f"{self.currency_from}/{self.currency_to} - {self.rate} ({self.year})" return f"{self.currency_from}/{self.currency_to} - {self.rate} ({self.year})"
def get_or_create_rate(currency_from: str, currency_to: str, year: int) -> float | None:
exchange_rate = None
result = ExchangeRate.objects.filter(
currency_from=currency_from, currency_to=currency_to, year=year
)
if result:
exchange_rate = result[0].rate
else:
try:
# this API endpoint only accepts lowercase currency string
response = requests.get(
f"https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@{year}-01-01/v1/currencies/{currency_from.lower()}.json"
)
response.raise_for_status()
data = response.json()
currency_from_data = data.get(currency_from.lower())
rate = currency_from_data.get(currency_to.lower())
if rate:
logger.info(f"[convert_prices]: Got {rate}, saving...")
exchange_rate = ExchangeRate.objects.create(
currency_from=currency_from,
currency_to=currency_to,
year=year,
rate=floatformat(rate, 2),
)
exchange_rate = exchange_rate.rate
else:
logger.info("[convert_prices]: Could not get an exchange rate.")
except requests.RequestException as e:
logger.info(
f"[convert_prices]: Failed to fetch exchange rate for {currency_from}->{currency_to} in {year}: {e}"
)
return exchange_rate
class PlayEvent(models.Model):
game = models.ForeignKey(Game, related_name="playevents", on_delete=models.CASCADE)
started = models.DateField(null=True, blank=True)
ended = models.DateField(null=True, blank=True)
days_to_finish = GeneratedField(
# special cases:
# missing ended, started, or both = 0
# same day = 1 day to finish
expression=RawSQL(
"""
COALESCE(
CASE
WHEN date(ended) = date(started) THEN 1
ELSE julianday(ended) - julianday(started)
END, 0
)
""",
[],
),
output_field=models.IntegerField(),
db_persist=True,
editable=False,
blank=True,
)
note = models.CharField(max_length=255, blank=True, default="")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
# class PlayMarker(models.Model):
# game = models.ForeignKey(Game, related_name="markers", on_delete=models.CASCADE)
# played_since = models.DurationField()
# played_total = models.DurationField()
# note = models.CharField(max_length=255)
class GameStatusChange(models.Model):
"""
Tracks changes to the status of a Game.
"""
game = models.ForeignKey(
Game, on_delete=models.CASCADE, related_name="status_changes"
)
old_status = models.CharField(
max_length=1, choices=Game.Status.choices, blank=True, null=True
)
new_status = models.CharField(max_length=1, choices=Game.Status.choices)
timestamp = models.DateTimeField(null=True)
def __str__(self):
return f"{self.game.name}: {self.old_status or 'None'} -> {self.new_status} at {self.timestamp}"
class Meta:
ordering = ["-timestamp"]
-30
View File
@@ -1,30 +0,0 @@
import graphene
from games.graphql.mutations import GameMutation
from games.graphql.queries import (
DeviceQuery,
EditionQuery,
GameQuery,
PlatformQuery,
PurchaseQuery,
SessionQuery,
)
class Query(
GameQuery,
EditionQuery,
DeviceQuery,
PlatformQuery,
PurchaseQuery,
SessionQuery,
graphene.ObjectType,
):
pass
class Mutation(GameMutation, graphene.ObjectType):
pass
schema = graphene.Schema(query=Query, mutation=Mutation)
+104 -4
View File
@@ -1,12 +1,112 @@
from django.db.models.signals import m2m_changed import logging
from datetime import timedelta
from django.db.models import F, Sum
from django.db.models.signals import (
m2m_changed,
post_delete,
post_save,
pre_delete,
pre_save,
)
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.timezone import now from django.utils.timezone import now
from games.models import Purchase from games.models import Game, GameStatusChange, Purchase, Session
logger = logging.getLogger("games")
@receiver(pre_save, sender=Purchase)
def store_purchase_price_snapshot(sender, instance, **kwargs):
"""Store old price values before save so we can detect changes."""
if instance.pk is not None:
try:
old_instance = sender.objects.get(pk=instance.pk)
instance._old_price = old_instance.price
instance._old_currency = old_instance.price_currency
except sender.DoesNotExist:
pass
@receiver(post_save, sender=Purchase)
def mark_needs_price_update(sender, instance, created, **kwargs):
"""Mark purchase for price update if price or currency changed."""
if not created and hasattr(instance, "_old_price"):
if (
instance.price != instance._old_price
or instance.price_currency != instance._old_currency
):
sender.objects.filter(pk=instance.pk).update(needs_price_update=True)
@receiver(m2m_changed, sender=Purchase.games.through) @receiver(m2m_changed, sender=Purchase.games.through)
def update_num_purchases(sender, instance, **kwargs): def update_num_purchases(sender, instance, action, reverse, **kwargs):
if not reverse and action.startswith("post_"):
instance.num_purchases = instance.games.count() instance.num_purchases = instance.games.count()
instance.updated_at = now() instance.updated_at = now()
instance.save(update_fields=["num_purchases"]) instance.save(update_fields=["num_purchases", "updated_at"])
@receiver(pre_delete, sender=Game)
def update_purchase_counts_on_game_delete(sender, instance, **kwargs):
"""
Update num_purchases on related Purchase objects when a Game is deleted.
m2m_changed is not fired when a related object is deleted.
"""
for purchase in instance.purchases.all():
if purchase.num_purchases > 0:
purchase.num_purchases -= 1
if purchase.num_purchases == 0:
purchase.delete()
else:
purchase.updated_at = now()
purchase.save(update_fields=["num_purchases", "updated_at"])
@receiver([post_save, post_delete], sender=Session)
def update_game_playtime(sender, instance, **kwargs):
# During cascade deletes the related Game may already have been removed.
# Use the FK id to look up the Game safely and bail out if it no longer exists.
game_pk = getattr(instance, "game_id", None)
if not game_pk:
return
game = Game.objects.filter(pk=game_pk).first()
if not game:
return
total_playtime = game.sessions.aggregate(
total_playtime=Sum(F("duration_calculated") + F("duration_manual"))
)["total_playtime"]
game.playtime = total_playtime if total_playtime else timedelta(0)
game.save(update_fields=["playtime"])
@receiver(pre_save, sender=Game)
def game_status_changed(sender, instance, **kwargs):
"""
Signal handler to create a GameStatusChange record whenever a Game's status is updated.
"""
try:
old_instance = sender.objects.get(pk=instance.pk)
old_status = old_instance.status
logger.info("[game_status_changed]: Previous status exists.")
except sender.DoesNotExist:
# Handle the case where the instance was deleted before the signal was sent
logger.info("[game_status_changed]: Previous status does not exist.")
return
if old_status != instance.status:
logger.info(
"[game_status_changed]: Status changed from {} to {}".format(
old_status, instance.status
)
)
GameStatusChange.objects.create(
game=instance,
old_status=old_status,
new_status=instance.status,
timestamp=now(),
)
else:
logger.info("[game_status_changed]: Status has not changed")
+4676 -2876
View File
File diff suppressed because it is too large Load Diff
+2 -18
View File
@@ -21,27 +21,11 @@ function setupElementHandlers() {
"#id_name", "#id_name",
"#id_related_purchase", "#id_related_purchase",
]); ]);
disableElementsWhenValueNotEqual(
"#id_type",
["game", "dlc"],
["#id_date_finished"]
);
} }
document.addEventListener("DOMContentLoaded", setupElementHandlers); document.addEventListener("DOMContentLoaded", setupElementHandlers);
document.addEventListener("htmx:afterSwap", setupElementHandlers); document.addEventListener("htmx:afterSwap", setupElementHandlers);
getEl("#id_type").onchange = () => { getEl("#id_type").addEventListener("change", () => {
setupElementHandlers(); setupElementHandlers();
};
document.body.addEventListener("htmx:beforeRequest", function (event) {
// Assuming 'Purchase1' is the element that triggers the HTMX request
if (event.target.id === "id_games") {
var idEditionValue = document.getElementById("id_games").value;
// Condition to check - replace this with your actual logic
if (idEditionValue != "") {
event.preventDefault(); // This cancels the HTMX request
} }
} );
});
+37
View File
@@ -0,0 +1,37 @@
(function() {
htmx.defineExtension("hx-redirect-toast", {
isInlineSwap: function(swapStyle) {
return swapStyle === "hx-redirect-toast";
},
handleSwap: function(swapStyle, target, fragment, settleInfo, htmxConfig) {
var xhr = htmxConfig.xhr;
var hxRedirect = xhr.getResponseHeader("HX-Redirect");
var hxTrigger = xhr.getResponseHeader("HX-Trigger");
// Redirect immediately (toast will be shown on the new page)
if (hxRedirect) {
window.location.href = hxRedirect;
}
// Only dispatch HX-Trigger events for toasts when not redirecting
if (!hxRedirect && hxTrigger) {
var triggers = JSON.parse(hxTrigger);
var events = Array.isArray(triggers) ? triggers : [triggers];
events.forEach(function(triggerObj) {
Object.entries(triggerObj).forEach(function(entry) {
var name = entry[0];
var detail = entry[1];
try { detail = JSON.parse(detail); } catch(e) {}
target.dispatchEvent(new CustomEvent(name, {
detail: detail,
bubbles: true,
cancelable: true
}));
});
});
}
// Return null to prevent any DOM swap
return null;
}
});
})();
+1 -1
View File
File diff suppressed because one or more lines are too long
+173
View File
@@ -0,0 +1,173 @@
document.addEventListener("alpine:init", () => {
let idCounter = 0;
console.log("[toast] Alpine available:", typeof Alpine !== "undefined");
Alpine.store("toasts", {
toasts: [],
addToast(message, type) {
console.log("[toast] addToast called:", { message, type });
if (!type) type = "info";
const validTypes = ["success", "error", "info", "warning", "debug"];
if (!validTypes.includes(type)) type = "info";
if (this.toasts.length >= 3) {
console.log("[toast] max 3 toasts reached, removing oldest");
this.toasts.shift();
}
const id = ++idCounter;
console.log("[toast] toast added, count:", this.toasts.length);
this.toasts.push({ id, message, type, visible: true, timer: null, pausedAt: null });
if (type !== "error") {
const toast = this.toasts[this.toasts.length - 1];
const autoDismissDelay = type === "debug" ? 3000 : 5000;
toast.timer = setTimeout(() => {
console.log("[toast] auto-dismiss after " + (autoDismissDelay / 1000) + "s");
this.dismissToast(id);
}, autoDismissDelay);
}
},
dismissToast(id) {
console.log("[toast] dismissToast for id:", id);
const idx = this.toasts.findIndex((t) => t.id === id);
if (idx === -1) { console.log("[toast] toast not found"); return; }
const toast = this.toasts[idx];
if (toast.timer) clearTimeout(toast.timer);
toast.visible = false;
setTimeout(() => {
this.toasts = this.toasts.filter((t) => t.id !== id);
console.log("[toast] after dismiss, count:", this.toasts.length);
}, 300);
},
clearToastTimer(id) {
const toast = this.toasts.find((t) => t.id === id);
if (toast?.timer) {
console.log("[toast] pause timer for toast id:", id);
clearTimeout(toast.timer);
toast.timer = null;
toast.pausedAt = Date.now();
}
},
resumeToastTimer(id, duration) {
const toast = this.toasts.find((t) => t.id === id);
if (toast?.pausedAt && toast.timer === null) {
console.log("[toast] resume timer for toast id:", id);
toast.timer = setTimeout(() => {
this.dismissToast(id);
}, duration);
toast.pausedAt = null;
}
},
});
Alpine.data("toastStore", () => ({
init() {
console.log("[toast] toastStore.init running");
console.log("[toast] Alpine store toasts:", Alpine.store("toasts").toasts);
window.addEventListener("show-toast", (e) => {
console.log("[toast] show-toast event received:", e.detail);
if (Array.isArray(e.detail)) {
e.detail.forEach((msg) => {
Alpine.store("toasts").addToast(msg.message, msg.type);
});
} else {
Alpine.store("toasts").addToast(e.detail.message, e.detail.type);
}
});
try {
const script = document.getElementById("django-messages");
if (script) {
const msgs = JSON.parse(
script.textContent || script.innerText || "[]"
);
console.log("[toast] django-messages script found:", msgs);
if (Array.isArray(msgs)) {
msgs.forEach((msg) => {
console.log("[toast] loading django-message:", msg);
Alpine.store("toasts").addToast(msg.message, msg.type || "info");
});
}
}
} catch (e) {
console.error("[toast] localStorage restore failed:", e);
// ignore parse errors
}
},
addToast(message, type) {
console.log("[toast] toastStore.addToast delegating:", { message, type });
Alpine.store("toasts").addToast(message, type);
},
dismissToast(id) {
console.log("[toast] toastStore.dismissToast delegating:", id);
Alpine.store("toasts").dismissToast(id);
},
}));
});
function toast(message, type) {
console.log("[toast] toast() called:", { message, type });
const evt = new CustomEvent("show-toast", {
detail: { message, type },
bubbles: true,
});
document.dispatchEvent(evt);
console.log("[toast] CustomEvent dispatched, type:", evt.type);
}
window.toast = toast;
/**
* Wrapper around fetch() that dispatches HTMX HX-Trigger events.
* Use this for any fetch() call that expects HX-Trigger headers
* (e.g., to show toasts via the HTMX middleware).
*
* @todo Migrate these call sites to hx-post + hx-on::after-request
* for HTMX-native toast handling.
*/
window.fetchWithHtmxTriggers = function fetchWithHtmxTriggers(url, options = {}) {
console.log("[fetchWithHtmxTriggers] fetching:", url);
return fetch(url, options).then(async (response) => {
console.log("[fetchWithHtmxTriggers] response status:", response.status);
const htmxTrigger = response.headers.get("HX-Trigger");
console.log("[fetchWithHtmxTriggers] HX-Trigger header:", htmxTrigger);
if (htmxTrigger) {
let triggers;
try {
triggers = JSON.parse(htmxTrigger);
console.log("[fetchWithHtmxTriggers] parsed triggers:", triggers);
} catch {
console.warn("[fetchWithHtmxTriggers] failed to parse HX-Trigger JSON");
return response;
}
// Handle both single object and array of events
const events = Array.isArray(triggers) ? triggers : [triggers];
events.forEach((triggerObj) => {
Object.entries(triggerObj).forEach(([name, detail]) => {
console.log("[fetchWithHtmxTriggers] dispatching event:", name, detail);
let parsedDetail = detail;
try {
parsedDetail = JSON.parse(detail);
} catch {
// keep as string
}
document.dispatchEvent(new CustomEvent(name, {
detail: parsedDetail,
bubbles: true,
}));
});
});
}
return response;
});
};
+5
View File
@@ -43,6 +43,7 @@ function syncSelectInputUntilChanged(syncData, parentSelector = document) {
const targetElement = document.querySelector(syncItem.target); const targetElement = document.querySelector(syncItem.target);
if (targetElement && valueToSync !== null) { if (targetElement && valueToSync !== null) {
console.log(`Changing value of ${syncItem.target} to ${valueToSync}`)
targetElement[syncItem.target_value] = valueToSync; targetElement[syncItem.target_value] = valueToSync;
} }
} }
@@ -184,13 +185,17 @@ function disableElementsWhenValueNotEqual(
function disableElementsWhenTrue(targetSelect, targetValue, elementList) { function disableElementsWhenTrue(targetSelect, targetValue, elementList) {
return conditionalElementHandler([ return conditionalElementHandler([
() => { () => {
console.log(`${disableElementsWhenTrue.name}: triggered on ${targetSelect}`)
console.log(`Value of ${targetSelect} is ${targetValue}: ${getEl(targetSelect).value == targetValue}`)
return getEl(targetSelect).value == targetValue; return getEl(targetSelect).value == targetValue;
}, },
elementList, elementList,
(el) => { (el) => {
console.log(`${disableElementsWhenTrue.name}: disabling ${el.id}`)
el.disabled = "disabled"; el.disabled = "disabled";
}, },
(el) => { (el) => {
console.log(`${disableElementsWhenTrue.name}: enabling ${el.id}`)
el.disabled = ""; el.disabled = "";
}, },
]); ]);
+62 -50
View File
@@ -1,8 +1,10 @@
import logging
import requests import requests
from django.db.models import ExpressionWrapper, F, FloatField, Q from django.db import models
from django.template.defaultfilters import floatformat from django.template.defaultfilters import floatformat
from django.utils.timezone import now
from django_q.models import Task logger = logging.getLogger("games")
from games.models import ExchangeRate, Purchase from games.models import ExchangeRate, Purchase
@@ -11,36 +13,18 @@ currency_to = "CZK"
currency_to = currency_to.upper() currency_to = currency_to.upper()
def save_converted_info(purchase, converted_price, converted_currency): def _get_exchange_rate(currency_from, currency_to, year):
print( logger.debug(
f"Changing converted price of {purchase} to {converted_price} {converted_currency} " f"[convert_prices]: Looking for exchange rate in database: {currency_from}->{currency_to}"
) )
purchase.converted_price = converted_price rate = ExchangeRate.objects.filter(
purchase.converted_currency = converted_currency
purchase.save()
def convert_prices():
purchases = Purchase.objects.filter(
converted_price__isnull=True, converted_currency__isnull=True
)
for purchase in purchases:
if purchase.price_currency.upper() == currency_to or purchase.price == 0:
save_converted_info(purchase, purchase.price, currency_to)
continue
year = purchase.date_purchased.year
currency_from = purchase.price_currency.upper()
exchange_rate = ExchangeRate.objects.filter(
currency_from=currency_from, currency_to=currency_to, year=year currency_from=currency_from, currency_to=currency_to, year=year
).first() ).first()
if not rate:
if not exchange_rate: logger.debug(
print( f"[convert_prices]: Getting exchange rate from {currency_from} to {currency_to} for {year}..."
f"Getting exchange rate from {currency_from} to {currency_to} for {year}..."
) )
try: try:
# this API endpoint only accepts lowercase currency string
response = requests.get( response = requests.get(
f"https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@{year}-01-01/v1/currencies/{currency_from.lower()}.json" f"https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@{year}-01-01/v1/currencies/{currency_from.lower()}.json"
) )
@@ -48,41 +32,69 @@ def convert_prices():
data = response.json() data = response.json()
currency_from_data = data.get(currency_from.lower()) currency_from_data = data.get(currency_from.lower())
rate = currency_from_data.get(currency_to.lower()) rate = currency_from_data.get(currency_to.lower())
if rate: if rate:
print(f"Got {rate}, saving...") logger.info(f"[convert_prices]: Got {rate}, saving...")
exchange_rate = ExchangeRate.objects.create( exchange_rate = ExchangeRate.objects.create(
currency_from=currency_from, currency_from=currency_from,
currency_to=currency_to, currency_to=currency_to,
year=year, year=year,
rate=floatformat(rate, 2), rate=floatformat(rate, 2),
) )
rate = exchange_rate.rate
else: else:
print("Could not get an exchange rate.") logger.info("[convert_prices]: Could not get an exchange rate.")
except requests.RequestException as e: except requests.RequestException as e:
print( logger.info(
f"Failed to fetch exchange rate for {currency_from}->{currency_to} in {year}: {e}" f"[convert_prices]: Failed to fetch exchange rate for {currency_from}->{currency_to} in {year}: {e}"
) )
if exchange_rate: elif rate:
save_converted_info( rate = rate.rate
return rate
def _save_converted_price(purchase, converted_price, needs_update):
logger.info(
f"Setting converted price of {purchase} to {converted_price} {currency_to} (originally {purchase.price} {purchase.price_currency})"
)
purchase.converted_price = converted_price
purchase.converted_currency = currency_to
if needs_update:
purchase.needs_price_update = False
purchase.save(update_fields=["converted_price", "converted_currency", "needs_price_update"])
def convert_prices():
purchases = Purchase.objects.filter(
models.Q(needs_price_update=True) | models.Q(converted_price__isnull=True)
).distinct()
if purchases.count() == 0:
logger.info("[convert_prices]: No prices to convert.")
return
for purchase in purchases:
needs_update = purchase.needs_price_update
if purchase.price_currency.upper() == currency_to or purchase.price == 0:
_save_converted_price(purchase, purchase.price, needs_update)
continue
year = purchase.date_purchased.year
currency_from = purchase.price_currency.upper()
rate = _get_exchange_rate(currency_from, currency_to, year)
if rate:
_save_converted_price(
purchase, purchase,
floatformat(purchase.price * exchange_rate.rate, 0), floatformat(purchase.price * rate, 0),
currency_to, needs_update,
) )
def calculate_price_per_game(): def calculate_price_per_game():
"""
This task is deprecated because price_per_game is now a GeneratedField.
It is kept here to prevent errors from lingering scheduled tasks.
"""
try: try:
last_task = Task.objects.filter(group="Update price per game").first() from django_q.models import Schedule
last_run = last_task.started
except Task.DoesNotExist or AttributeError: Schedule.objects.filter(func="games.tasks.calculate_price_per_game").delete()
last_run = now() except Exception:
purchases = Purchase.objects.filter(converted_price__isnull=False).filter( pass
Q(updated_at__gte=last_run) | Q(price_per_game__isnull=True)
)
print(f"Updating {purchases.count()} purchases.")
purchases.update(
price_per_game=ExpressionWrapper(
F("converted_price") / F("num_purchases"), output_field=FloatField()
)
)
+4 -2
View File
@@ -1,7 +1,9 @@
<c-layouts.add> <c-layouts.add>
<c-slot name="additional_row"> <c-slot name="additional_row">
<input type="submit" <c-button type="submit" color="gray"
name="submit_and_redirect" name="submit_and_redirect"
value="Submit & Create Purchase" /> >
Submit & Create Purchase
</c-button>
</c-slot> </c-slot>
</c-layouts.add> </c-layouts.add>
+5 -2
View File
@@ -3,9 +3,12 @@
<tr> <tr>
<td></td> <td></td>
<td> <td>
<input type="submit" <c-button type="submit"
color="gray"
name="submit_and_redirect" name="submit_and_redirect"
value="Submit & Create Session" /> >
Submit & Create Session
</c-button>
</td> </td>
</tr> </tr>
</c-slot> </c-slot>
+25 -23
View File
@@ -1,36 +1,38 @@
<c-layouts.add> <c-layouts.add>
<c-slot name="form_content"> <c-slot name="form_content">
<form method="post" enctype="multipart/form-data"> <div class="max-width-container">
<table class="mx-auto"> <div id="add-form" class="form-container max-w-xl mx-auto">
<form method="post" enctype="multipart/form-data" class="">
{% csrf_token %} {% csrf_token %}
{% for field in form %} {% for field in form %}
<tr> <div>
<th>{{ field.label_tag }}</th> {{ field.label_tag }}
{% if field.name == "note" %} {% if field.name == "note" %}
<td>{{ field }}</td> {{ field }}
{% else %} {% else %}
<td>{{ field }}</td> {{ field }}
{% endif %} {% endif %}
{% if field.name == "timestamp_start" or field.name == "timestamp_end" %} {% if field.name == "timestamp_start" or field.name == "timestamp_end" %}
<td> <span class="form-row-button-group flex-row gap-3 justify-start mt-3" hx-boost="false">
<div class="basic-button-container" hx-boost="false"> <c-button data-target="{{ field.name }}" data-type="now" size="xs">Set to now</c-button>
<button class="basic-button" data-target="{{ field.name }}" data-type="now">Set to now</button> <c-button data-target="{{ field.name }}" data-type="toggle" size="xs">Toggle text</c-button>
<button class="basic-button" <c-button data-target="{{ field.name }}" data-type="copy" size="xs">
data-target="{{ field.name }}" Copy {%if field.name == "timestamp_start" %}start{% else %}end{% endif %} value to {%if field.name == "timestamp_start" %}end{% else %}start{% endif %}
data-type="toggle">Toggle text</button> </c-button>
<button class="basic-button" data-target="{{ field.name }}" data-type="copy">Copy</button> </span>
</div>
</td>
{% endif %} {% endif %}
</tr> </div>
{% endfor %} {% endfor %}
<tr> <div>
<td></td> <c-button type="submit">
<td> Submit
<input type="submit" value="Submit" /> </c-button>
</td> </div>
</tr> <div class="submit-button-container">
</table> {{ additional_row }}
</div>
</form> </form>
</div>
</div>
</c-slot> </c-slot>
</c-layouts.add> </c-layouts.add>
+12 -4
View File
@@ -1,6 +1,14 @@
<c-vars color="blue" size="base" /> <c-vars color="blue" size="base" type="button" />
<button type="button" <button
title="{{ title }}" {% if hx_get %}hx-get="{{ hx_get }}"{% endif %}
class=" {% if color == "blue" %} bg-blue-700 dark:bg-blue-600 dark:focus:ring-blue-800 dark:hover:bg-blue-700 focus:ring-blue-300 hover:bg-blue-800 text-white {% elif color == "red" %} bg-red-700 dark:bg-red-600 dark:focus:ring-red-900 dark:hover:bg-red-700 focus:ring-red-300 hover:bg-red-800 text-white {% elif color == "gray" %} bg-white border-gray-200 dark:bg-gray-800 dark:border-gray-600 dark:focus:ring-gray-700 dark:hover:bg-gray-700 dark:hover:text-white dark:text-gray-400 focus:ring-gray-100 hover:bg-gray-100 hover:text-blue-700 text-gray-900 border {% elif color == "green" %} bg-green-700 dark:bg-green-600 dark:focus:ring-green-800 dark:hover:bg-green-700 focus:ring-green-300 hover:bg-green-800 text-white {% endif %} focus:outline-none focus:ring-4 font-medium mb-2 me-2 rounded-lg {% if size == "xs" %} px-3 py-2 text-xs {% elif size == "sm" %} px-3 py-2 text-sm {% elif size == "base" %} px-5 py-2.5 text-sm {% elif size == "lg" %} px-5 py-3 text-base {% elif size == "xl" %} px-6 py-3.5 text-base {% endif %} {% if icon %} inline-flex text-center items-center gap-2 {% else %} {% endif %} "> {% if hx_target %}hx-target="{{ hx_target }}"{% endif %}
{% if hx_swap %}hx-swap="{{ hx_swap }}"{% endif %}
{% if type %}type="{{ type }}"{% endif %}
{% if title %}title="{{ title }}"{% endif %}
{% if onclick %}onclick="{{ onclick }}"{% endif %}
{% if data_target %}data-target="{{ data_target }}"{% endif %}
{% if data_type %}data-type="{{ data_type }}"{% endif %}
{% if name %}name="{{ name }}"{% endif %}
class="{% if class %}{{ class }} {%else%}{%endif%}{% if color == "blue" %}text-white bg-brand box-border border border-transparent hover:bg-brand-strong focus:ring-4 focus:ring-brand-medium {% elif color == "red" %} bg-red-700 dark:bg-red-600 dark:focus:ring-red-900 dark:hover:bg-red-700 focus:ring-red-300 hover:bg-red-800 text-white {% elif color == "gray" %} bg-white border-gray-200 dark:bg-gray-800 dark:border-gray-600 dark:focus:ring-gray-700 dark:hover:bg-gray-700 dark:hover:text-white dark:text-gray-400 focus:ring-gray-100 hover:bg-gray-100 hover:text-blue-700 text-gray-900 border {% elif color == "green" %} bg-green-700 dark:bg-green-600 dark:focus:ring-green-800 dark:hover:bg-green-700 focus:ring-green-300 hover:bg-green-800 text-white {% endif %} leading-5 focus:outline-hidden focus:ring-4 font-medium mb-2 me-2 rounded-base {% if size == "xs" %} px-3 py-2 text-xs shadow-xs {% elif size == "sm" %} px-3 py-2 text-sm {% elif size == "base" %} px-5 py-2.5 text-sm {% elif size == "lg" %} px-5 py-3 text-base {% elif size == "xl" %} px-6 py-3.5 text-base {% endif %} {% if icon %} inline-flex text-center items-center gap-2 {% else %} {% endif %} ">
{{ slot }} {{ slot }}
</button> </button>
+2 -2
View File
@@ -1,8 +1,8 @@
<div class="inline-flex rounded-md shadow-sm" role="group"> <div class="inline-flex rounded-md shadow-xs" role="group">
{% if slot %}{{ slot }}{% endif %} {% if slot %}{{ slot }}{% endif %}
{% for button in buttons %} {% for button in buttons %}
{% if button.slot %} {% if button.slot %}
<c-button-group-button-sm :href=button.href :slot=button.slot :color=button.color :hover=button.hover :title=button.title /> <c-button-group-button-sm :href=button.href :slot=button.slot :color=button.color :hover=button.hover :title=button.title :hx_get=button.hx_get :hx_target=button.hx_target :hx_swap=button.hx_swap />
{% endif %} {% endif %}
{% endfor %} {% endfor %}
</div> </div>
@@ -1,22 +1,25 @@
<c-vars color="gray" /> <c-vars color="gray" />
<a href="{{ href }}" <a href="{{ href }}"
{% if hx_get %}hx-get="{{ hx_get }}"{% endif %}
{% if hx_target %}hx-target="{{ hx_target }}"{% endif %}
{% if click %}@click="{{ click }}"{% endif %}
class="[&:first-of-type_button]:rounded-s-lg [&:last-of-type_button]:rounded-e-lg"> class="[&:first-of-type_button]:rounded-s-lg [&:last-of-type_button]:rounded-e-lg">
{% if color == "gray" %} {% if color == "gray" %}
<button type="button" <button type="button"
title="{{ title }}" title="{{ title }}"
class="px-2 py-1 text-xs font-medium text-gray-900 bg-white border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white"> class="px-2 py-1 text-xs font-medium text-gray-900 bg-white border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer">
{{ slot }} {{ slot }}
</button> </button>
{% elif color == "red" %} {% elif color == "red" %}
<button type="button" <button type="button"
title="{{ title }}" title="{{ title }}"
class="px-2 py-1 text-xs font-medium text-gray-900 bg-white border border-gray-200 hover:bg-red-500 hover:text-white focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:border-red-700 dark:hover:bg-red-700 dark:focus:ring-blue-500 dark:focus:text-white"> class="px-2 py-1 text-xs font-medium text-gray-900 bg-white border border-gray-200 hover:bg-red-500 hover:text-white focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:border-red-700 dark:hover:bg-red-700 dark:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer">
{{ slot }} {{ slot }}
</button> </button>
{% elif color == "green" %} {% elif color == "green" %}
<button type="button" <button type="button"
title="{{ title }}" title="{{ title }}"
class="px-2 py-1 text-xs font-medium text-gray-900 bg-white border border-gray-200 hover:bg-green-500 hover:border-green-600 hover:text-white focus:z-10 focus:ring-2 focus:ring-green-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:border-green-700 dark:hover:bg-green-600 dark:focus:ring-green-500 dark:focus:text-white"> class="px-2 py-1 text-xs font-medium text-gray-900 bg-white border border-gray-200 hover:bg-green-500 hover:border-green-600 hover:text-white focus:z-10 focus:ring-2 focus:ring-green-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:border-green-700 dark:hover:bg-green-600 dark:focus:ring-green-500 dark:focus:text-white hover:cursor-pointer">
{{ slot }} {{ slot }}
</button> </button>
{% endif %} {% endif %}
+1 -1
View File
@@ -4,7 +4,7 @@ text
{% endcomment %} {% endcomment %}
<a href="{{ link }}" <a href="{{ link }}"
title="{{ title }}" title="{{ title }}"
class="truncate max-w-xs py-1 px-2 text-xs bg-green-600 hover:bg-green-700 focus:ring-green-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-sm"> class="truncate max-w-xs py-1 px-2 text-xs bg-green-600 hover:bg-green-700 focus:ring-green-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center font-semibold shadow-md focus:outline-hidden focus:ring-2 focus:ring-offset-2 rounded-xs">
{% comment %} <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="self-center w-6 h-6 inline"> {% comment %} <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="self-center w-6 h-6 inline">
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" />
</svg> </svg>
+1 -1
View File
@@ -5,7 +5,7 @@ text
<button type="button" <button type="button"
title="{{ title }}" title="{{ title }}"
autofocus autofocus
class="truncate max-w-xs sm:max-w-md lg:max-w-lg py-1 px-2 bg-green-600 hover:bg-green-700 focus:ring-green-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-lg"> class="truncate max-w-xs sm:max-w-md lg:max-w-lg py-1 px-2 bg-green-600 hover:bg-green-700 focus:ring-green-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-hidden focus:ring-2 focus:ring-offset-2 rounded-lg">
<svg xmlns="http://www.w3.org/2000/svg" <svg xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
+1 -1
View File
@@ -1,6 +1,6 @@
<span class="truncate-container"> <span class="truncate-container">
<a class="underline decoration-slate-500 sm:decoration-2" <a class="underline decoration-slate-500 sm:decoration-2"
href="{% url 'view_game' game_id %}"> href="{% url 'games:view_game' game_id %}">
{% if slot %} {% if slot %}
{{ slot }} {{ slot }}
{% else %} {% else %}
+3 -3
View File
@@ -1,5 +1,5 @@
<div class="relative ml-3"> <span class="{% if display == 'flex' %}flex{% else %}inline-flex{% endif %} gap-2 items-center align-middle {{class}}">
<span class="rounded-xl w-2 h-2 absolute -left-3 top-2 <span class="rounded-xl w-3 h-3
{% if status == "u" %} {% if status == "u" %}
bg-gray-500 bg-gray-500
{% elif status == "p" %} {% elif status == "p" %}
@@ -13,4 +13,4 @@
{% endif %} {% endif %}
">&nbsp;</span> ">&nbsp;</span>
{{ slot }} {{ slot }}
</div> </span>
+1 -1
View File
@@ -1,7 +1,7 @@
<h1 class="{% if badge %}flex items-center {% endif %}mb-4 text-3xl font-extrabold leading-none tracking-tight text-gray-900 dark:text-white"> <h1 class="{% if badge %}flex items-center {% endif %}mb-4 text-3xl font-extrabold leading-none tracking-tight text-gray-900 dark:text-white">
{{ slot }} {{ slot }}
{% if badge %} {% if badge %}
<span class="bg-blue-100 text-blue-800 text-2xl font-semibold me-2 px-2.5 py-0.5 rounded dark:bg-blue-200 dark:text-blue-800 ms-2"> <span 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">
{{ badge }} {{ badge }}
</span> </span>
{% endif %} {% endif %}
@@ -0,0 +1,3 @@
<svg class="dark:text-white w-3" viewBox="5 8 14 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 9L12 15L18 9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 284 B

+1 -1
View File
@@ -2,7 +2,7 @@
x="0px" x="0px"
y="0px" y="0px"
viewBox="0 0 48 48" viewBox="0 0 48 48"
class="text-black dark:text-white w-4 h-4"> class="w-4 h-4">
<path fill="currentColor" d="M 11.396484 4.1113281 C 9.1042001 4.2020187 7 6.0721788 7 8.5917969 L 7 39.408203 C 7 42.767694 10.742758 44.971891 13.681641 43.34375 L 41.490234 27.935547 C 44.513674 26.260259 44.513674 21.739741 41.490234 20.064453 L 13.681641 4.65625 C 12.94692 4.2492148 12.160579 4.0810979 11.396484 4.1113281 z M 11.431641 7.0664062 C 11.690234 7.0652962 11.961284 7.1323321 12.226562 7.2792969 L 40.037109 22.6875 C 41.13567 23.296212 41.13567 24.703788 40.037109 25.3125 L 12.226562 40.720703 C 11.165446 41.308562 10 40.620712 10 39.408203 L 10 8.5917969 C 10 7.9855423 10.290709 7.5116121 10.714844 7.2617188 C 10.926911 7.136772 11.173048 7.0675163 11.431641 7.0664062 z"> <path fill="currentColor" d="M 11.396484 4.1113281 C 9.1042001 4.2020187 7 6.0721788 7 8.5917969 L 7 39.408203 C 7 42.767694 10.742758 44.971891 13.681641 43.34375 L 41.490234 27.935547 C 44.513674 26.260259 44.513674 21.739741 41.490234 20.064453 L 13.681641 4.65625 C 12.94692 4.2492148 12.160579 4.0810979 11.396484 4.1113281 z M 11.431641 7.0664062 C 11.690234 7.0652962 11.961284 7.1323321 12.226562 7.2792969 L 40.037109 22.6875 C 41.13567 23.296212 41.13567 24.703788 40.037109 25.3125 L 12.226562 40.720703 C 11.165446 41.308562 10 40.620712 10 39.408203 L 10 8.5917969 C 10 7.9855423 10.290709 7.5116121 10.714844 7.2617188 C 10.926911 7.136772 11.173048 7.0675163 11.431641 7.0664062 z">
</path> </path>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 861 B

After

Width:  |  Height:  |  Size: 834 B

+7 -3
View File
@@ -3,12 +3,16 @@
{% if form_content %} {% if form_content %}
{{ form_content }} {{ form_content }}
{% else %} {% else %}
<div class="max-width-container"> <div id="add-form" class="max-width-container">
<div class="form-container max-w-xl mx-auto"> <div id="add-form" class="form-container max-w-xl mx-auto">
<form method="post" enctype="multipart/form-data"> <form method="post" enctype="multipart/form-data">
{% csrf_token %} {% csrf_token %}
{{ form.as_div }} {{ form.as_div }}
<div><input type="submit" value="Submit" /></div> <div>
<c-button type="submit" class="mt-3">
Submit
</c-button>
</div>
<div class="submit-button-container"> <div class="submit-button-container">
{{ additional_row }} {{ additional_row }}
</div> </div>
+149 -18
View File
@@ -9,9 +9,18 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Timetracker - {{ title }}</title> <title>Timetracker - {{ title }}</title>
<script src="{% static 'js/htmx.min.js' %}"></script> <script src="{% static 'js/htmx.min.js' %}"></script>
<script>
htmx.config.scrollBehavior = 'smooth';
htmx.config.selfRequestsOnly = false;
</script>
<script src="{% static 'js/htmx-redirect-toast.js' %}"></script>
{% django_htmx_script %} {% django_htmx_script %}
<link rel="stylesheet" href="{% static 'base.css' %}" /> <link rel="stylesheet" href="{% static 'base.css' %}" />
<script src="https://cdn.jsdelivr.net/npm/flowbite@2.4.1/dist/flowbite.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/flowbite@2.4.1/dist/flowbite.min.js"></script>
{% comment %} <script src="//unpkg.com/alpinejs" defer></script>
<script src="//unpkg.com/@alpinejs/mask" defer></script> {% endcomment %}
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/mask@3.x.x/dist/cdn.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<script> <script>
// On page load or when changing themes, best to add inline in `head` to avoid FOUC // On page load or when changing themes, best to add inline in `head` to avoid FOUC
if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) { if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
@@ -21,7 +30,14 @@
} }
</script> </script>
</head> </head>
<body hx-indicator="#indicator"> <body hx-indicator="#indicator" class="bg-neutral-primary">
<script id="django-messages" type="application/json">
[
{% for message in messages %}
{"message": "{{ message|escapejs }}", "type": "{{ message.tags|default:'info' }}"}{% if not forloop.last %},{% endif %}
{% endfor %}
]
</script>
<img id="indicator" <img id="indicator"
src="{% static 'icons/loading.png' %}" src="{% static 'icons/loading.png' %}"
class="absolute right-3 top-3 animate-spin htmx-indicator" class="absolute right-3 top-3 animate-spin htmx-indicator"
@@ -30,26 +46,38 @@
alt="loading indicator" /> alt="loading indicator" />
<div class="flex flex-col min-h-screen"> <div class="flex flex-col min-h-screen">
{% include "navbar.html" %} {% include "navbar.html" %}
<div class="flex flex-1 flex-col dark:bg-gray-800 pt-8">{{ slot }}</div> <div id="main-container" class="flex flex-1 flex-col pt-8 pb-16">{{ slot }}</div>
{% load version %} {% load version %}
<span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{% version %} ({% version_date %})</span> <span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{% version %} ({% version_date %})</span>
</div> </div>
{{ scripts }} {{ scripts }}
<script> <script type="module">
var themeToggleDarkIcon = document.getElementById('theme-toggle-dark-icon'); document.addEventListener('DOMContentLoaded', () => {
var themeToggleLightIcon = document.getElementById('theme-toggle-light-icon'); if (window.mountCrownIcon) {
window.mountCrownIcon('#crown-icon-mount-point', {
// Change the icons inside the button based on previous settings mastered: {{ game.mastered|yesno:"true,false" }}
if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) { });
themeToggleLightIcon.classList.remove('hidden');
} else {
themeToggleDarkIcon.classList.remove('hidden');
} }
var themeToggleBtn = document.getElementById('theme-toggle'); // Theme toggle logic
const themeToggleDarkIcon = document.getElementById('theme-toggle-dark-icon');
const themeToggleLightIcon = document.getElementById('theme-toggle-light-icon');
const themeToggleBtn = document.getElementById('theme-toggle');
// Ensure all elements are found before proceeding
if (themeToggleDarkIcon && themeToggleLightIcon && themeToggleBtn) {
// Initial state of icons based on current theme
// The FOUC script in <head> already set document.documentElement.classList.add/remove('dark')
// So we just need to set the icon visibility based on that.
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 () { themeToggleBtn.addEventListener('click', function () {
// toggle icons inside button // toggle icons inside button
themeToggleDarkIcon.classList.toggle('hidden'); themeToggleDarkIcon.classList.toggle('hidden');
themeToggleLightIcon.classList.toggle('hidden'); themeToggleLightIcon.classList.toggle('hidden');
@@ -59,23 +87,126 @@
if (localStorage.getItem('color-theme') === 'light') { if (localStorage.getItem('color-theme') === 'light') {
document.documentElement.classList.add('dark'); document.documentElement.classList.add('dark');
localStorage.setItem('color-theme', 'dark'); localStorage.setItem('color-theme', 'dark');
} else { } else { // current theme is dark, switch to light
document.documentElement.classList.remove('dark'); document.documentElement.classList.remove('dark');
localStorage.setItem('color-theme', 'light'); localStorage.setItem('color-theme', 'light');
} }
// if NOT set via local storage previously // if NOT set via local storage previously
} else { } else { // no theme in local storage, use system preference
if (document.documentElement.classList.contains('dark')) { if (document.documentElement.classList.contains('dark')) { // currently dark, switch to light
document.documentElement.classList.remove('dark'); document.documentElement.classList.remove('dark');
localStorage.setItem('color-theme', 'light'); localStorage.setItem('color-theme', 'light');
} else { } else { // currently light, switch to dark
document.documentElement.classList.add('dark'); document.documentElement.classList.add('dark');
localStorage.setItem('color-theme', 'dark'); localStorage.setItem('color-theme', 'dark');
} }
} }
});
}
}); });
</script> </script>
// hx-swap-oob makes sure the modal gets removed upon any HTMX response
<div id="global-modal-container" hx-swap-oob="true"></div>
<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>
<script src="{% static 'js/toast.js' %}"></script>
</body> </body>
</html> </html>
+17
View File
@@ -0,0 +1,17 @@
<c-vars without_buttons="false" submit_text="Submit" close_text="Cancel" />
<div id="modal-container">
<div class="tt-modal fixed inset-0 bg-black/70 dark:bg-gray-600/50 overflow-y-auto h-full w-full flex items-center justify-center">
<div 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">
<div class="{{ container_class }}">
{{ slot }}
{% if not without_buttons %}
<div class="items-center mt-5">
<c-button color="blue" size="lg" type="submit" class="w-full">{{ submit_text }}</c-button>
<c-button color="gray" size="base" class="mt-0 w-full" onclick="this.closest('.tt-modal').remove()">{{ close_text }}</c-button>
</div>
{% endif %}
</form>
</div>
</div>
</div>
</div>
+1 -1
View File
@@ -2,7 +2,7 @@
<div data-popover <div data-popover
id="{{ id }}" id="{{ id }}"
role="tooltip" role="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-sm opacity-0 dark:text-white dark:border-purple-600 dark:bg-purple-800"> 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 class="px-3 py-2">{{ popover_content }}</div> <div class="px-3 py-2">{{ popover_content }}</div>
<div data-popper-arrow></div> <div data-popper-arrow></div>
<!-- for Tailwind CSS to generate decoration-dotted CSS from Python component --> <!-- for Tailwind CSS to generate decoration-dotted CSS from Python component -->
+15 -2
View File
@@ -1,12 +1,25 @@
<c-vars :name="id" /> <c-vars :name="id" />
<div class="pb-4 bg-white dark:bg-gray-900"> <!-- <div class="pb-4 bg-white dark:bg-gray-900">
<label for="table-search" class="sr-only">Search</label> <label for="table-search" class="sr-only">Search</label>
<div class="relative mt-1"> <div class="relative mt-1">
<div class="absolute inset-y-0 rtl:inset-r-0 start-0 flex items-center ps-3 pointer-events-none"> <div class="absolute inset-y-3 rtl:inset-r-0 start-0 flex items-center ps-3 pointer-events-none">
<svg class="w-4 h-4 text-gray-500 dark:text-gray-400" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20"> <svg class="w-4 h-4 text-gray-500 dark:text-gray-400" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"/> <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"/>
</svg> </svg>
</div> </div>
<input type="text" id="{{ id }}" name="{{ name }}" value="{{ search_string }}" class="block pt-2 ps-10 text-sm text-gray-900 border border-gray-300 rounded-lg w-80 bg-gray-50 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="{% if placeholder %}{{ placeholder }}{% else %}Search{% endif %}"> <input type="text" id="{{ id }}" name="{{ name }}" value="{{ search_string }}" class="block pt-2 ps-10 text-sm text-gray-900 border border-gray-300 rounded-lg w-80 bg-gray-50 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="{% if placeholder %}{{ placeholder }}{% else %}Search{% endif %}">
</div> </div>
</div> -->
<form class="max-w-md mx-auto">
<label for="search" class="block mb-2.5 text-sm font-medium text-heading sr-only ">Search</label>
<div class="relative">
<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" width="24" height="24" 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> </div>
<input type="search" id="{{ id }}" name="{{ name }}" 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="{% if placeholder %}{{ placeholder }}{% else %}Search{% endif %}" required />
<button type="button" 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">Search</button>
</div>
</form>
+2 -2
View File
@@ -7,12 +7,12 @@
{{ header_action }} {{ header_action }}
</c-table-header> </c-table-header>
{% endif %} {% endif %}
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400 [&_th:not(:first-child):not(:last-child)]:max-sm:hidden"> <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">
<tr> <tr>
{% for column in columns %}<th scope="col" class="px-6 py-3">{{ column }}</th>{% endfor %} {% for column in columns %}<th scope="col" class="px-6 py-3">{{ column }}</th>{% endfor %}
</tr> </tr>
</thead> </thead>
<tbody class="dark:divide-y [&_td:not(:first-child):not(:last-child)]:max-sm:hidden"> <tbody class="dark:divide-y max-sm:[&_td:not(:first-child):not(:last-child)]:hidden">
{% for row in rows %}<c-table-row :data=row />{% endfor %} {% for row in rows %}<c-table-row :data=row />{% endfor %}
</tbody> </tbody>
</table> </table>
+18 -3
View File
@@ -1,11 +1,26 @@
<tr class="odd:bg-white odd:dark:bg-gray-900 even:bg-gray-50 even:dark: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 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"
{% if data.row_id %}id="{{ data.row_id }}"{% endif %}
{% if data.hx_trigger %}hx-trigger="{{ data.hx_trigger }}"{% endif %}
{% if data.hx_get %}hx-get="{{ data.hx_get }}"{% endif %}
{% if data.hx_select %}hx-select="{{ data.hx_select }}"{% endif %}
{% if data.hx_swap %}hx-swap="{{ data.hx_swap }}"{% endif %}
>
{% if slot %} {% if slot %}
{{ slot }} {{ slot }}
{% elif data.row_id %}
{% for td in data.cell_data %}
{% if forloop.first %}
<th scope="row" class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">{{ td }}</th>
{% else %}
<c-table-td>
{{ td }}
</c-table-td>
{% endif %}
{% endfor %}
{% else %} {% else %}
{% for td in data %} {% for td in data %}
{% if forloop.first %} {% if forloop.first %}
<th scope="row" <th scope="row" class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">{{ td }}</th>
class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">{{ td }}</th>
{% else %} {% else %}
<c-table-td> <c-table-td>
{{ td }} {{ td }}
@@ -0,0 +1,16 @@
<c-layouts.base>
{% load static %}
<div class="2xl:max-w-(--breakpoint-2xl) xl:max-w-(--breakpoint-xl) md:max-w-(--breakpoint-md) sm:max-w-(--breakpoint-sm) self-center">
<form method="post" class="dark:text-white">
{% csrf_token %}
<div>
<p>Are you sure you want to delete this status change?</p>
<c-button color="red" type="submit" size="lg" class="w-full">Delete</c-button>
<a href="{% url 'games:view_game' object.game.id %}" class="">
<c-button color="gray" class="w-full">Cancel</c-button>
</a>
</div>
</form>
</div>
</c-layouts.base>
@@ -0,0 +1,7 @@
<c-layouts.base>
{% load static %}
<div class="2xl:max-w-(--breakpoint-2xl) xl:max-w-(--breakpoint-xl) md:max-w-(--breakpoint-md) sm:max-w-(--breakpoint-sm) self-center">
<c-simple-table :columns=["Test"] :rows=data.rows :page_obj=page_obj :elided_page_range=elided_page_range :header_action=data.header_action />
</div>
</c-layouts.base>
+1 -1
View File
@@ -3,7 +3,7 @@
{{ title }} {{ title }}
{% endblock title %} {% endblock title %}
{% block content %} {% block content %}
<div class="text-slate-300 mx-auto max-w-screen-lg text-center"> <div class="text-slate-300 mx-auto max-w-(--breakpoint-lg) text-center">
{% if session_count > 0 %} {% if session_count > 0 %}
You have played a total of {{ session_count }} sessions for a total of {{ total_duration_formatted }}. You have played a total of {{ session_count }} sessions for a total of {{ total_duration_formatted }}.
{% elif not game_available or not platform_available %} {% elif not game_available or not platform_available %}
+6
View File
@@ -0,0 +1,6 @@
<c-layouts.base>
{% load static %}
<div class="2xl:max-w-(--breakpoint-2xl) xl:max-w-(--breakpoint-xl) md:max-w-(--breakpoint-md) sm:max-w-(--breakpoint-sm) self-center">
<c-simple-table :columns=data.columns :rows=data.rows :page_obj=page_obj :elided_page_range=elided_page_range :header_action=data.header_action />
</div>
</c-layouts.base>
+1 -1
View File
@@ -1,6 +1,6 @@
<c-layouts.base> <c-layouts.base>
{% load static %} {% load static %}
<div class="2xl:max-w-screen-2xl xl:max-w-screen-xl md:max-w-screen-md sm:max-w-screen-sm self-center"> <div class="2xl:max-w-(--breakpoint-2xl) xl:max-w-(--breakpoint-xl) md:max-w-(--breakpoint-md) sm:max-w-(--breakpoint-sm) self-center">
<c-simple-table :columns=data.columns :rows=data.rows :page_obj=page_obj :elided_page_range=elided_page_range :header_action=data.header_action /> <c-simple-table :columns=data.columns :rows=data.rows :page_obj=page_obj :elided_page_range=elided_page_range :header_action=data.header_action />
</div> </div>
</c-layouts.base> </c-layouts.base>
+4 -4
View File
@@ -6,7 +6,7 @@
{% block content %} {% block content %}
<div class="flex-col"> <div class="flex-col">
{% if dataset_count >= 1 %} {% if dataset_count >= 1 %}
{% url 'list_sessions_start_session_from_session' last.id as start_session_url %} {% url 'games:list_sessions_start_session_from_session' last.id as start_session_url %}
<div class="mx-auto text-center my-4"> <div class="mx-auto text-center my-4">
<a id="last-session-start" <a id="last-session-start"
href="{{ start_session_url }}" href="{{ start_session_url }}"
@@ -35,8 +35,8 @@
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name relative align-top w-24 h-12 group"> <td class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name relative align-top w-24 h-12 group">
<span class="inline-block relative"> <span class="inline-block relative">
<a class="underline decoration-slate-500 sm:decoration-2 inline-block truncate max-w-20char 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 group-hover:decoration-purple-900 group-hover:text-purple-100" <a class="underline decoration-slate-500 sm:decoration-2 inline-block truncate max-w-20char 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-xs group-hover:outline-dashed group-hover:outline-purple-400 group-hover:outline-4 group-hover:decoration-purple-900 group-hover:text-purple-100"
href="{% url 'view_game' session.game.id %}"> href="{% url 'games:view_game' session.game.id %}">
{{ session.game.name }} {{ session.game.name }}
</a> </a>
</span> </span>
@@ -46,7 +46,7 @@
</td> </td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden lg:table-cell"> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden lg:table-cell">
{% if not session.timestamp_end %} {% if not session.timestamp_end %}
{% url 'list_sessions_end_session' session.id as end_session_url %} {% url 'games:list_sessions_end_session' session.id as end_session_url %}
<a href="{{ end_session_url }}" <a href="{{ end_session_url }}"
hx-get="{{ end_session_url }}" hx-get="{{ end_session_url }}"
hx-target="closest tr" hx-target="closest tr"
+39 -25
View File
@@ -1,7 +1,7 @@
{% load static %} {% load static %}
<nav class="bg-white border-gray-200 dark:bg-gray-900 dark:border-gray-700"> <nav class="bg-neutral-primary-soft border-b border-default">
<div class="max-w-screen-xl flex flex-wrap items-center justify-between mx-auto p-4"> <div class="max-w-(--breakpoint-xl) flex flex-wrap items-center justify-between mx-auto p-4">
<a href="{% url 'index' %}" <a href="{% url 'games:index' %}"
class="flex items-center space-x-3 rtl:space-x-reverse"> class="flex items-center space-x-3 rtl:space-x-reverse">
<img src="{% static 'icons/schedule.png' %}" <img src="{% static 'icons/schedule.png' %}"
height="48" height="48"
@@ -12,7 +12,7 @@
</a> </a>
<button data-collapse-toggle="navbar-dropdown" <button data-collapse-toggle="navbar-dropdown"
type="button" 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-none focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600" 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-controls="navbar-dropdown"
aria-expanded="false"> aria-expanded="false">
<span class="sr-only">Open main menu</span> <span class="sr-only">Open main menu</span>
@@ -26,19 +26,29 @@
</button> </button>
<div class="hidden w-full md:block md:w-auto" id="navbar-dropdown"> <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"> <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="text-white flex flex-col items-center text-xs"> <li class="flex items-center">
<span class="flex uppercase gap-1">Today<span class="text-gray-400">·</span>Last 7 days</span> <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">
<span class="flex items-center gap-1">{{ today_played }}<span class="text-gray-400">·</span>{{ last_7_played }}</span> <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>
<li> <li>
<a href="#" <a href="#"
class="block py-2 px-3 text-white bg-blue-700 rounded md:bg-transparent md:text-blue-700 md:p-0 md:dark:text-blue-500 dark:bg-blue-600 md:dark:bg-transparent" 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> aria-current="page">Home</a>
</li> </li>
<li> <li>
<button id="dropdownNavbarNewLink" <button id="dropdownNavbarNewLink"
data-dropdown-toggle="dropdownNavbarNew" data-dropdown-toggle="dropdownNavbarNew"
class="flex items-center justify-between w-full py-2 px-3 text-gray-900 rounded 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"> 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 New
<svg class="w-2.5 h-2.5 ms-2.5" <svg class="w-2.5 h-2.5 ms-2.5"
aria-hidden="true" aria-hidden="true"
@@ -50,27 +60,27 @@
</button> </button>
<!-- Dropdown menu --> <!-- Dropdown menu -->
<div id="dropdownNavbarNew" <div id="dropdownNavbarNew"
class="z-10 hidden font-normal bg-white divide-y divide-gray-100 rounded-lg shadow w-44 dark:bg-gray-700 dark:divide-gray-600"> 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" <ul class="py-2 text-sm text-gray-700 dark:text-gray-400"
aria-labelledby="dropdownLargeButton"> aria-labelledby="dropdownLargeButton">
<li> <li>
<a href="{% url 'add_device' %}" <a href="{% url 'games:add_device' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Device</a> class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Device</a>
</li> </li>
<li> <li>
<a href="{% url 'add_game' %}" <a href="{% url 'games:add_game' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Game</a> class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Game</a>
</li> </li>
<li> <li>
<a href="{% url 'add_platform' %}" <a href="{% url 'games:add_platform' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platform</a> class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platform</a>
</li> </li>
<li> <li>
<a href="{% url 'add_purchase' %}" <a href="{% url 'games:add_purchase' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Purchase</a> class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Purchase</a>
</li> </li>
<li> <li>
<a href="{% url 'add_session' %}" <a href="{% url 'games:add_session' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Session</a> class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Session</a>
</li> </li>
</ul> </ul>
@@ -79,7 +89,7 @@
<li> <li>
<button id="dropdownNavbarManageLink" <button id="dropdownNavbarManageLink"
data-dropdown-toggle="dropdownNavbarManage" data-dropdown-toggle="dropdownNavbarManage"
class="flex items-center justify-between w-full py-2 px-3 text-gray-900 rounded 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"> 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 Manage
<svg class="w-2.5 h-2.5 ms-2.5" <svg class="w-2.5 h-2.5 ms-2.5"
aria-hidden="true" aria-hidden="true"
@@ -91,39 +101,43 @@
</button> </button>
<!-- Dropdown menu --> <!-- Dropdown menu -->
<div id="dropdownNavbarManage" <div id="dropdownNavbarManage"
class="z-10 hidden font-normal bg-white divide-y divide-gray-100 rounded-lg shadow w-44 dark:bg-gray-700 dark:divide-gray-600"> 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" <ul class="py-2 text-sm text-gray-700 dark:text-gray-400"
aria-labelledby="dropdownLargeButton"> aria-labelledby="dropdownLargeButton">
<li> <li>
<a href="{% url 'list_devices' %}" <a href="{% url 'games:list_devices' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Devices</a> class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Devices</a>
</li> </li>
<li> <li>
<a href="{% url 'list_games' %}" <a href="{% url 'games:list_games' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Games</a> class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Games</a>
</li> </li>
<li> <li>
<a href="{% url 'list_platforms' %}" <a href="{% url 'games:list_platforms' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platforms</a> class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platforms</a>
</li> </li>
<li> <li>
<a href="{% url 'list_purchases' %}" <a href="{% url '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="{% url 'games:list_purchases' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Purchases</a> class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Purchases</a>
</li> </li>
<li> <li>
<a href="{% url 'list_sessions' %}" <a href="{% url 'games:list_sessions' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Sessions</a> class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Sessions</a>
</li> </li>
</ul> </ul>
</div> </div>
</li> </li>
<li> <li>
<a href="{% url 'stats_by_year' global_current_year %}" <a href="{% url 'games:stats_by_year' global_current_year %}"
class="block py-2 px-3 text-gray-900 rounded 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> class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Stats</a>
</li> </li>
<li> <li>
<a href="{% url 'logout' %}" <a href="{% url 'logout' %}"
class="block py-2 px-3 text-gray-900 rounded 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 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> out</a>
</li> </li>
</ul> </ul>
@@ -0,0 +1,36 @@
<div id="delete-game-confirmation-modal" class="fixed inset-0 bg-black/70 dark:bg-gray-600/50 overflow-y-auto h-full w-full flex items-center justify-center">
<div 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">
<div class="">
<h1 class="text-2xl leading-6 font-medium dark:text-white text-center">Delete Game</h1>
<p class="dark:text-white text-center mt-5">
Are you sure you want to delete <strong>{{ game.name }}</strong>?
</p>
<form class=""
hx-post="{% url 'games:delete_game' game.id %}"
hx-replace-url="true"
hx-target="#main-container"
hx-select="#main-container"
hx-swap="outerHTML"
hw-swap-oob="#global-modal-container"
>
{% csrf_token %}
<p class="dark:text-white text-center mt-3 text-sm text-gray-600 dark:text-gray-400">
This will permanently delete this game and all associated data:
</p>
<ul class="dark:text-white text-center mt-1 text-sm text-gray-600 dark:text-gray-400 list-disc list-inside">
{% if session_count %}<li>{{ session_count }} session(s)</li>{% endif %}
{% if purchase_count %}<li>{{ purchase_count }} purchase(s)</li>{% endif %}
{% if playevent_count %}<li>{{ playevent_count }} play event(s)</li>{% endif %}
{% if not session_count and not purchase_count and not playevent_count %}<li>No associated data</li>{% endif %}
</ul>
<p class="dark:text-white text-center mt-3 text-sm font-medium text-red-600 dark:text-red-400">
This action cannot be undone.
</p>
<div class="items-center mt-5">
<c-button color="red" size="lg" type="submit" class="w-full">Delete</c-button>
<c-button color="gray" size="base" class="mt-0 w-full" onclick="this.closest('#delete-game-confirmation-modal').remove()">Cancel</c-button>
</div>
</form>
</div>
</div>
</div>
@@ -0,0 +1,49 @@
<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;
// TODO: migrate to hx-post + hx-on::after-request for HTMX-native toast handling
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);
}
}"
>
<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">
<span class="flex flex-row gap-4 justify-between items-center">
{% for status_value, status_label in game_statuses %}
<template x-if="status == '{{ status_value }}'">
<c-gamestatus display="flex" status="{{ status_value }}">{{ status_label }}</c-gamestatus>
</template>
{% endfor %}
<c-icon.arrowdown />
</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">
{% for status_value, status_label in game_statuses %}
<li><a href="#" @click.prevent.stop="setStatus('{{ status_value }}', '{{ status_label }}'); open = false;" class="block px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white rounded-sm no-underline! border-0!" :class="{ 'font-bold': status === '{{ status_value }}' }"><c-gamestatus display="flex" status="{{ status_value }}" class="text-slate-300">{{ status_label }}</c-gamestatus></a></li>
{% endfor %}
</ul>
</div>
</button>
</div>
</div>
+6
View File
@@ -0,0 +1,6 @@
<ul class="list-disc list-inside">
{% for change in statuschanges %}
<li class="text-slate-500">
{% if change.timestamp %}{{ change.timestamp | date:"d/m/Y H:i" }}: Changed{% else %}At some point changed{% endif %} status from <c-gamestatus :status="change.old_status">{{ change.get_old_status_display }}</c-gamestatus> to <c-gamestatus :status="change.new_status">{{ change.get_new_status_display }}</c-gamestatus> (<a href="{% url 'games:edit_statuschange' change.id %}">Edit</a>, <a href="{% url 'games:delete_statuschange' change.id %}">Delete</a>)</li>
{% endfor %}
</ul>
@@ -0,0 +1,20 @@
<div id="refund-confirmation-modal" class="fixed inset-0 bg-black/70 dark:bg-gray-600/50 overflow-y-auto h-full w-full flex items-center justify-center">
<div 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">
<div class="">
<h1 class="text-2xl leading-6 font-medium dark:text-white text-center">Confirm Refund</h1>
<p class="dark:text-white text-center mt-5">
Are you sure you want to mark this purchase as refunded?
</p>
<form class="" hx-post="{% url 'games:refund_purchase' purchase_id %}" hx-target="#purchase-row-{{ purchase_id }}" hx-swap="outerHTML">
{% csrf_token %}
<p class="dark:text-white text-center mt-3 text-sm">
Games will be marked as abandoned.
</p>
<div class="items-center mt-5">
<c-button color="blue" size="lg" type="submit" class="w-full">Refund</c-button>
<c-button color="gray" size="base" class="mt-0 w-full" onclick="this.closest('#refund-confirmation-modal').remove()">Cancel</c-button>
</div>
</form>
</div>
</div>
</div>
@@ -0,0 +1,49 @@
<div class="flex gap-2 items-center"
x-data="{
originalDeviceId: {{ session.device.id|default:'null' }},
originalDeviceName: '{{ session.device.name|default:'Unknown'|escapejs }}',
deviceId: {{ session.device.id|default:'null' }},
deviceName: '{{ session.device.name|default:'Unknown'|escapejs }}',
open: false,
saving: false,
setDevice(newDeviceId, newDeviceName) {
this.deviceId = newDeviceId;
this.deviceName = newDeviceName;
this.saving = true;
// TODO: migrate to hx-post + hx-on::after-request for HTMX-native toast handling
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);
}
}"
>
<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">
<span class="flex flex-row gap-4 justify-between items-center">
<span x-text="deviceName"></span>
<c-icon.arrowdown />
</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">
{% for device in session_devices %}
<li><a href="#" @click.prevent.stop="setDevice({{ device.id }}, '{{ device.name|escapejs }}'); open = false;" class="block px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white rounded-sm no-underline! border-0!" :class="{ 'font-bold': deviceId === {{ device.id }} }">{{ device.name }}</a></li>
{% endfor %}
</ul>
</div>
</button>
</div>
</div>
+11 -6
View File
@@ -1,13 +1,18 @@
<c-layouts.base> <c-layouts.base>
{% load static %} {% load static %}
{% load duration_formatter %}
{% partialdef purchase-name %} {% partialdef purchase-name %}
{% if purchase.type != 'game' %} {% if purchase.type != 'game' %}
<c-gamelink :game_id=purchase.first_game.id> <c-gamelink :game_id=purchase.first_game.id>
{{ purchase.name }} ({{ purchase.first_game.name }} {{ purchase.get_type_display }}) {% if purchase.game_name %}{{ purchase.game_name }}{% else %}{{ purchase.name }}{% endif %} ({{ purchase.first_game.name }} {{ purchase.get_type_display }})
</c-gamelink> </c-gamelink>
{% else %}
{% if purchase.game_name %}
<c-gamelink :game_id=purchase.first_game.id :name=purchase.game_name />
{% else %} {% else %}
<c-gamelink :game_id=purchase.first_game.id :name=purchase.first_game.name /> <c-gamelink :game_id=purchase.first_game.id :name=purchase.first_game.name />
{% endif %} {% endif %}
{% endif %}
{% endpartialdef %} {% endpartialdef %}
<div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto"> <div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto">
<div class="flex justify-center items-center"> <div class="flex justify-center items-center">
@@ -107,7 +112,7 @@
{% for month in month_playtimes %} {% for month in month_playtimes %}
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">{{ month.month | date:"F" }}</td> <td class="px-2 sm:px-4 md:px-6 md:py-2">{{ month.month | date:"F" }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ month.playtime }}</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ month.playtime | format_duration }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@@ -153,7 +158,7 @@
<thead> <thead>
<tr> <tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Name</th> <th class="px-2 sm:px-4 md:px-6 md:py-2">Name</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Playtime (hours)</th> <th class="px-2 sm:px-4 md:px-6 md:py-2">Playtime</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -162,7 +167,7 @@
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
<c-gamelink :game_id=game.id :name=game.name /> <c-gamelink :game_id=game.id :name=game.name />
</td> </td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ game.formatted_playtime }}</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ game.total_playtime | format_duration }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@@ -172,14 +177,14 @@
<thead> <thead>
<tr> <tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Platform</th> <th class="px-2 sm:px-4 md:px-6 md:py-2">Platform</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Playtime (hours)</th> <th class="px-2 sm:px-4 md:px-6 md:py-2">Playtime</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for item in total_playtime_per_platform %} {% for item in total_playtime_per_platform %}
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ item.platform_name }}</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ item.platform_name }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ item.formatted_playtime }}</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ item.playtime | format_duration }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
+83 -14
View File
@@ -2,7 +2,7 @@
<div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto"> <div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto">
<div id="game-info" class="mb-10"> <div id="game-info" class="mb-10">
<div class="flex gap-5 mb-3"> <div class="flex gap-5 mb-3">
<span class="text-balance max-w-[30rem] text-4xl"> <span class="text-balance max-w-120 text-4xl">
<span class="font-bold font-serif">{{ game.name }}</span>{% if game.year_released %}&nbsp;<c-popover id="popover-year" popover_content="Original release year" class="text-slate-500 text-2xl">{{ game.year_released }}</c-popover>{% endif %} <span class="font-bold font-serif">{{ game.name }}</span>{% if game.year_released %}&nbsp;<c-popover id="popover-year" popover_content="Original release year" class="text-slate-500 text-2xl">{{ game.year_released }}</c-popover>{% endif %}
</span> </span>
</div> </div>
@@ -16,7 +16,7 @@
class="size-6"> class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg> </svg>
{{ hours_sum }} {{ game.playtime_formatted }}
</c-popover> </c-popover>
<c-popover id="popover-sessions" popover_content="Number of sessions" class="flex gap-2 items-center"> <c-popover id="popover-sessions" popover_content="Number of sessions" class="flex gap-2 items-center">
<svg xmlns="http://www.w3.org/2000/svg" <svg xmlns="http://www.w3.org/2000/svg"
@@ -52,28 +52,84 @@
{{ playrange }} {{ playrange }}
</c-popover> </c-popover>
</div> </div>
<div class="mb-6 text-slate-400"> <div class="flex flex-col mb-6 text-gray-600 dark:text-slate-400 gap-y-4">
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<span class="uppercase font-bold text-slate-300">Status</span> <span class="uppercase">Original year</span>
<c-gamestatus :status="game.status"> <span class="text-black dark:text-slate-300">{{ game.original_year_released }}</span>
{{ game.get_status_display }}
</c-gamestatus>
</div> </div>
<div class="flex gap-2 items-center"
>
<span class="uppercase">Status</span>
{% include "partials/gamestatus_selector.html" %}
{% if game.mastered %}👑{% endif %}
</div>
<div class="flex gap-2 items-center"
x-data="{ open: false }"
>
<span class="uppercase">Played</span>
<div class="inline-flex rounded-md shadow-2xs" role="group" x-data="{ played: {{ game.playevents.count }} }">
<a href="{% url 'games:add_playevent' %}">
<button type="button" class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-s-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer">
<span x-text="played"></span> times
</button>
</a>
<button type="button" x-on:click="open = !open" @click.outside="open = false" class="relative px-4 py-2 text-sm font-medium text-gray-900 bg-white border-e border-b border-t border-gray-200 rounded-e-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white align-middle hover:cursor-pointer">
<c-icon.arrowdown />
<div
class="absolute top-full -left-px w-auto whitespace-nowrap z-10 text-sm font-medium bg-gray-800/20 backdrop-blur-lg rounded-md rounded-tl-none border border-gray-200 dark:border-gray-700"
x-show="open"
>
<ul
class=""
>
<li class="px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white rounded-tr-md">
<a href="{% url 'games:add_playevent_for_game' game.id %}">Add playthrough...</a>
</li>
<li
x-on:click="createPlayEvent"
class="relative px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white rounded-b-md"
>
Played times +1
</li>
<script>
function createPlayEvent() {
this.played++;
// TODO: migrate to hx-post + hx-on::after-request for HTMX-native toast handling
fetchWithHtmxTriggers('{% url 'api-1.0.0:create_playevent' %}', {
method: 'POST',
headers: { 'X-CSRFToken': '{{ csrf_token }}', 'Content-Type': 'application/json' },
body: '{"game_id": {{ game.id }}}'
})
.catch(() => {
this.played--;
console.error('Failed to record play');
});
}
</script>
</ul>
</div>
</button>
</div>
</div>
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<span class="uppercase font-bold text-slate-300">Platform</span> <span class="uppercase">Platform</span>
<span>{{ game.platform }}</span> <span class="text-black dark:text-slate-300">{{ game.platform }}</span>
</div> </div>
</div> </div>
<div class="inline-flex rounded-md shadow-sm mb-3" role="group"> <div class="inline-flex rounded-md shadow-xs mb-3" role="group">
<a href="{% url 'edit_game' game.id %}"> <a href="{% url 'games:edit_game' game.id %}">
<button type="button" <button type="button"
class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-s-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white"> class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-s-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer">
Edit Edit
</button> </button>
</a> </a>
<a href="{% url 'delete_game' game.id %}"> <a href="#" hx-get="{% url 'games:delete_game_confirmation' game.id %}" hx-target="#global-modal-container">
<button type="button" <button type="button"
class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-e-lg hover:bg-red-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-red-700 dark:focus:ring-blue-500 dark:focus:text-white"> class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-e-lg hover:bg-red-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-red-700 dark:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer">
Delete Delete
</button> </button>
</a> </a>
@@ -95,6 +151,19 @@
No sessions yet. No sessions yet.
{% endif %} {% endif %}
</div> </div>
<!-- list all playevents -->
<div class="mb-6">
<c-h1 :badge="playevent_count">Play Events</c-h1>
{% if playevent_count %}
<c-simple-table :rows=playevent_data.rows :columns=playevent_data.columns />
{% else %}
No play events yet.
{% endif %}
</div>
<div class="mb-6" id="history-container" hx-get="" hx-trigger="status-changed from:body" hx-select="#history-container" hx-swap="outerHTML">
<c-h1 :badge="statuschange_count">History</c-h1>
{% include "partials/history.html" %}
</div>
</div> </div>
<script> <script>
function getSessionCount() { function getSessionCount() {
+4 -4
View File
@@ -9,19 +9,19 @@
{{ purchase.name }} {{ purchase.name }}
{% endif %} {% endif %}
</div> </div>
<span class="text-balance max-w-[30rem] text-4xl"> <span class="text-balance max-w-120 text-4xl">
<span class="font-bold font-serif"> <span class="font-bold font-serif">
{{ purchase.date_purchased }} ({{ purchase.num_purchases }} game{{ purchase.num_purchases|pluralize}}) {{ purchase.date_purchased }} ({{ purchase.num_purchases }} game{{ purchase.num_purchases|pluralize}})
</span> </span>
</span> </span>
<div class="inline-flex rounded-md shadow-sm mb-3" role="group"> <div class="inline-flex rounded-md shadow-xs mb-3" role="group">
<a href="{% url 'edit_purchase' purchase.id %}"> <a href="{% url 'games:edit_purchase' purchase.id %}">
<button type="button" <button type="button"
class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-s-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white"> class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-s-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white">
Edit Edit
</button> </button>
</a> </a>
<a href="{% url 'delete_purchase' purchase.id %}"> <a href="{% url 'games:delete_purchase' purchase.id %}">
<button type="button" <button type="button"
class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-e-lg hover:bg-red-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-red-700 dark:focus:ring-blue-500 dark:focus:text-white"> class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-e-lg hover:bg-red-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-red-700 dark:focus:ring-blue-500 dark:focus:text-white">
Delete Delete
+12
View File
@@ -0,0 +1,12 @@
from datetime import timedelta
from django import template
from common.time import durationformat, format_duration
register = template.Library()
@register.filter(name="format_duration")
def filter_format_duration(duration: timedelta, argument: str = durationformat):
return format_duration(duration, format_string=argument)
+5 -3
View File
@@ -1,5 +1,4 @@
import random import hashlib
import string
from django import template from django import template
@@ -8,4 +7,7 @@ register = template.Library()
@register.simple_tag @register.simple_tag
def randomid(seed: str = "") -> str: def randomid(seed: str = "") -> str:
return str(hash(seed + "".join(random.choices(string.ascii_lowercase, k=10)))) content_hash = hashlib.sha1(seed.encode()).hexdigest()
if seed:
return content_hash[:max(0, 10 - len(seed))] + seed
return content_hash[:10]
+100 -1
View File
@@ -1,3 +1,102 @@
from datetime import date
from django.test import TestCase from django.test import TestCase
# Create your tests here. from games.models import Game, Platform, Purchase
from games.tasks import convert_prices
class PurchaseNeedsPriceUpdateTest(TestCase):
def setUp(self):
self.platform = Platform.objects.create(name="PC", icon="pc", group="PC")
self.game = Game.objects.create(name="Test Game", platform=self.platform)
def test_new_purchase_has_needs_price_update_true(self):
purchase = Purchase.objects.create(
price=50.0,
price_currency="USD",
date_purchased=date(2025, 1, 1),
)
purchase.games.add(self.game)
self.assertTrue(purchase.needs_price_update)
def test_convert_prices_sets_flag_to_false(self):
purchase = Purchase.objects.create(
price=50.0,
price_currency="USD",
date_purchased=date(2025, 1, 1),
)
purchase.games.add(self.game)
self.assertTrue(purchase.needs_price_update)
convert_prices()
purchase.refresh_from_db()
self.assertFalse(purchase.needs_price_update)
def test_price_change_sets_needs_price_update(self):
purchase = Purchase.objects.create(
price=50.0,
price_currency="USD",
date_purchased=date(2025, 1, 1),
)
purchase.games.add(self.game)
purchase.converted_price = 1000
purchase.converted_currency = "CZK"
purchase.needs_price_update = False
purchase.save()
purchase.price = 60.0
purchase.save()
purchase.refresh_from_db()
self.assertTrue(purchase.needs_price_update)
def test_currency_change_sets_needs_price_update(self):
purchase = Purchase.objects.create(
price=50.0,
price_currency="USD",
date_purchased=date(2025, 1, 1),
)
purchase.games.add(self.game)
purchase.converted_price = 1000
purchase.converted_currency = "CZK"
purchase.needs_price_update = False
purchase.save()
purchase.price_currency = "EUR"
purchase.save()
purchase.refresh_from_db()
self.assertTrue(purchase.needs_price_update)
def test_name_change_does_not_set_needs_price_update(self):
purchase = Purchase.objects.create(
price=50.0,
price_currency="USD",
date_purchased=date(2025, 1, 1),
)
purchase.games.add(self.game)
purchase.converted_price = 1000
purchase.converted_currency = "CZK"
purchase.needs_price_update = False
purchase.save()
purchase.name = "New Name"
purchase.save()
purchase.refresh_from_db()
self.assertFalse(purchase.needs_price_update)
def test_convert_prices_skips_already_converted(self):
purchase = Purchase.objects.create(
price=50.0,
price_currency="USD",
date_purchased=date(2025, 1, 1),
)
purchase.games.add(self.game)
purchase.converted_price = 1000
purchase.converted_currency = "CZK"
purchase.needs_price_update = False
purchase.save()
convert_prices()
purchase.refresh_from_db()
self.assertFalse(purchase.needs_price_update)
+56 -1
View File
@@ -1,6 +1,18 @@
from django.urls import path from django.urls import path
from games.views import device, game, general, platform, purchase, session app_name = "games"
from games.api import api
from games.views import (
device,
game,
general,
platform,
playevent,
purchase,
session,
statuschange,
)
urlpatterns = [ urlpatterns = [
path("", general.index, name="index"), path("", general.index, name="index"),
@@ -11,6 +23,7 @@ urlpatterns = [
path("game/add", game.add_game, name="add_game"), path("game/add", game.add_game, name="add_game"),
path("game/<int:game_id>/edit", game.edit_game, name="edit_game"), path("game/<int:game_id>/edit", game.edit_game, name="edit_game"),
path("game/<int:game_id>/view", game.view_game, name="view_game"), path("game/<int:game_id>/view", game.view_game, name="view_game"),
path("game/<int:game_id>/delete/confirm", game.delete_game_confirmation, name="delete_game_confirmation"),
path("game/<int:game_id>/delete", game.delete_game, name="delete_game"), path("game/<int:game_id>/delete", game.delete_game, name="delete_game"),
path("game/list", game.list_games, name="list_games"), path("game/list", game.list_games, name="list_games"),
path("platform/add", platform.add_platform, name="add_platform"), path("platform/add", platform.add_platform, name="add_platform"),
@@ -25,6 +38,23 @@ urlpatterns = [
name="delete_platform", name="delete_platform",
), ),
path("platform/list", platform.list_platforms, name="list_platforms"), path("platform/list", platform.list_platforms, name="list_platforms"),
path("playevent/list", playevent.list_playevents, name="list_playevents"),
path("playevent/add", playevent.add_playevent, name="add_playevent"),
path(
"playevent/add/for-game/<int:game_id>",
playevent.add_playevent,
name="add_playevent_for_game",
),
path(
"playevent/edit/<int:playevent_id>",
playevent.edit_playevent,
name="edit_playevent",
),
path(
"playevent/delete/<int:playevent_id>",
playevent.delete_playevent,
name="delete_playevent",
),
path("purchase/add", purchase.add_purchase, name="add_purchase"), path("purchase/add", purchase.add_purchase, name="add_purchase"),
path( path(
"purchase/add/for-game/<int:game_id>", "purchase/add/for-game/<int:game_id>",
@@ -61,6 +91,11 @@ urlpatterns = [
purchase.list_purchases, purchase.list_purchases,
name="list_purchases", name="list_purchases",
), ),
path(
"purchase/<int:purchase_id>/refund/confirm",
purchase.refund_purchase_confirmation,
name="refund_purchase_confirmation",
),
path( path(
"purchase/<int:purchase_id>/refund", "purchase/<int:purchase_id>/refund",
purchase.refund_purchase, purchase.refund_purchase,
@@ -109,6 +144,26 @@ urlpatterns = [
), ),
path("session/list", session.list_sessions, name="list_sessions"), path("session/list", session.list_sessions, name="list_sessions"),
path("session/search", session.search_sessions, name="search_sessions"), path("session/search", session.search_sessions, name="search_sessions"),
path(
"statuschange/add",
statuschange.AddStatusChangeView.as_view(),
name="add_statuschange",
),
path(
"statuschange/edit/<int:statuschange_id>",
statuschange.EditStatusChangeView.as_view(),
name="edit_statuschange",
),
path(
"statuschange/delete/<int:pk>",
statuschange.GameStatusChangeDeleteView.as_view(),
name="delete_statuschange",
),
path(
"statuschange/list",
statuschange.GameStatusChangeListView.as_view(),
name="list_statuschanges",
),
path("stats/", general.stats_alltime, name="stats_alltime"), path("stats/", general.stats_alltime, name="stats_alltime"),
path( path(
"stats/<int:year>", "stats/<int:year>",
+6 -6
View File
@@ -36,7 +36,7 @@ def list_devices(request: HttpRequest) -> HttpResponse:
else None else None
), ),
"data": { "data": {
"header_action": A([], Button([], "Add device"), url="add_device"), "header_action": A([], Button([], "Add device"), url_name="games:add_device"),
"columns": [ "columns": [
"Name", "Name",
"Type", "Type",
@@ -53,12 +53,12 @@ def list_devices(request: HttpRequest) -> HttpResponse:
{ {
"buttons": [ "buttons": [
{ {
"href": reverse("edit_device", args=[device.pk]), "href": reverse("games:edit_device", args=[device.pk]),
"slot": Icon("edit"), "slot": Icon("edit"),
"color": "gray", "color": "gray",
}, },
{ {
"href": reverse("delete_device", args=[device.pk]), "href": reverse("games:delete_device", args=[device.pk]),
"slot": Icon("delete"), "slot": Icon("delete"),
"color": "red", "color": "red",
}, },
@@ -79,7 +79,7 @@ def edit_device(request: HttpRequest, device_id: int = 0) -> HttpResponse:
form = DeviceForm(request.POST or None, instance=device) form = DeviceForm(request.POST or None, instance=device)
if form.is_valid(): if form.is_valid():
form.save() form.save()
return redirect("list_devices") return redirect("games:list_devices")
context: dict[str, Any] = {"form": form, "title": "Edit device"} context: dict[str, Any] = {"form": form, "title": "Edit device"}
return render(request, "add.html", context) return render(request, "add.html", context)
@@ -89,7 +89,7 @@ def edit_device(request: HttpRequest, device_id: int = 0) -> HttpResponse:
def delete_device(request: HttpRequest, device_id: int) -> HttpResponse: def delete_device(request: HttpRequest, device_id: int) -> HttpResponse:
device = get_object_or_404(Device, id=device_id) device = get_object_or_404(Device, id=device_id)
device.delete() device.delete()
return redirect("list_sessions") return redirect("games:list_sessions")
@login_required @login_required
@@ -98,7 +98,7 @@ def add_device(request: HttpRequest) -> HttpResponse:
form = DeviceForm(request.POST or None) form = DeviceForm(request.POST or None)
if form.is_valid(): if form.is_valid():
form.save() form.save()
return redirect("index") return redirect("games:index")
context["form"] = form context["form"] = form
context["title"] = "Add New Device" context["title"] = "Add New Device"
+73 -28
View File
@@ -22,8 +22,6 @@ from common.components import (
) )
from common.time import ( from common.time import (
dateformat, dateformat,
durationformat,
durationformat_manual,
format_duration, format_duration,
local_strftime, local_strftime,
timeformat, timeformat,
@@ -32,6 +30,7 @@ from common.utils import build_dynamic_filter, safe_division, truncate
from games.forms import GameForm from games.forms import GameForm
from games.models import Game, Purchase from games.models import Game, Purchase
from games.views.general import use_custom_redirect from games.views.general import use_custom_redirect
from games.views.playevent import create_playevent_tabledata
@login_required @login_required
@@ -90,7 +89,7 @@ def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
) )
] ]
), ),
A([], Button([], "Add game"), url="add_game"), A([], Button([], "Add game"), url_name="games:add_game"),
], ],
attributes=[("class", "flex justify-between")], attributes=[("class", "flex justify-between")],
), ),
@@ -105,7 +104,7 @@ def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
], ],
"rows": [ "rows": [
[ [
NameWithIcon(game_id=game.pk), NameWithIcon(game=game),
PopoverTruncated( PopoverTruncated(
game.sort_name game.sort_name
if game.sort_name is not None and game.name != game.sort_name if game.sort_name is not None and game.name != game.sort_name
@@ -113,8 +112,12 @@ def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
), ),
game.year_released, game.year_released,
render_to_string( render_to_string(
"cotton/gamestatus.html", "partials/gamestatus_selector.html",
{"status": game.status, "slot": game.get_status_display()}, {
"game": game,
"game_statuses": Game.Status.choices,
},
request=request,
), ),
game.wikidata, game.wikidata,
local_strftime(game.created_at, dateformat), local_strftime(game.created_at, dateformat),
@@ -123,12 +126,12 @@ def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
{ {
"buttons": [ "buttons": [
{ {
"href": reverse("edit_game", args=[game.pk]), "href": reverse("games:edit_game", args=[game.pk]),
"slot": Icon("edit"), "slot": Icon("edit"),
"color": "gray", "color": "gray",
}, },
{ {
"href": reverse("delete_game", args=[game.pk]), "href": reverse("games:delete_game", args=[game.pk]),
"slot": Icon("delete"), "slot": Icon("delete"),
"color": "red", "color": "red",
}, },
@@ -151,10 +154,10 @@ def add_game(request: HttpRequest) -> HttpResponse:
game = form.save() game = form.save()
if "submit_and_redirect" in request.POST: if "submit_and_redirect" in request.POST:
return HttpResponseRedirect( return HttpResponseRedirect(
reverse("add_purchase_for_game", kwargs={"game_id": game.id}) reverse("games:add_purchase_for_game", kwargs={"game_id": game.id})
) )
else: else:
return redirect("list_games") return redirect("games:list_games")
context["form"] = form context["form"] = form
context["title"] = "Add New Game" context["title"] = "Add New Game"
@@ -162,11 +165,29 @@ def add_game(request: HttpRequest) -> HttpResponse:
return render(request, "add_game.html", context) return render(request, "add_game.html", context)
@login_required
def delete_game_confirmation(request: HttpRequest, game_id: int) -> HttpResponse:
game = get_object_or_404(Game, id=game_id)
session_count = game.sessions.count()
purchase_count = game.purchases.count()
playevent_count = game.playevents.count()
return render(
request,
"partials/delete_game_confirmation.html",
{
"game": game,
"session_count": session_count,
"purchase_count": purchase_count,
"playevent_count": playevent_count,
},
)
@login_required @login_required
def delete_game(request: HttpRequest, game_id: int) -> HttpResponse: def delete_game(request: HttpRequest, game_id: int) -> HttpResponse:
game = get_object_or_404(Game, id=game_id) game = get_object_or_404(Game, id=game_id)
game.delete() game.delete()
return redirect("list_sessions") return redirect("games:list_sessions")
@login_required @login_required
@@ -177,7 +198,7 @@ def edit_game(request: HttpRequest, game_id: int) -> HttpResponse:
form = GameForm(request.POST or None, instance=purchase) form = GameForm(request.POST or None, instance=purchase)
if form.is_valid(): if form.is_valid():
form.save() form.save()
return redirect("list_sessions") return redirect("games:list_sessions")
context["title"] = "Edit Game" context["title"] = "Edit Game"
context["form"] = form context["form"] = form
return render(request, "add.html", context) return render(request, "add.html", context)
@@ -239,12 +260,12 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
{ {
"buttons": [ "buttons": [
{ {
"href": reverse("edit_purchase", args=[purchase.pk]), "href": reverse("games:edit_purchase", args=[purchase.pk]),
"slot": Icon("edit"), "slot": Icon("edit"),
"color": "gray", "color": "gray",
}, },
{ {
"href": reverse("delete_purchase", args=[purchase.pk]), "href": reverse("games:delete_purchase", args=[purchase.pk]),
"slot": Icon("delete"), "slot": Icon("delete"),
"color": "red", "color": "red",
}, },
@@ -271,7 +292,7 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
"header_action": Div( "header_action": Div(
children=[ children=[
A( A(
url="add_session", url_name="games:add_session",
children=Button( children=Button(
icon=True, icon=True,
size="xs", size="xs",
@@ -279,8 +300,8 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
), ),
), ),
A( A(
url=reverse( href=reverse(
"list_sessions_start_session_from_session", "games:list_sessions_start_session_from_session",
args=[last_session.pk], args=[last_session.pk],
), ),
children=Popover( children=Popover(
@@ -305,22 +326,16 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
"columns": ["Game", "Date", "Duration", "Actions"], "columns": ["Game", "Date", "Duration", "Actions"],
"rows": [ "rows": [
[ [
NameWithIcon( NameWithIcon(session=session),
session_id=session.pk,
),
f"{local_strftime(session.timestamp_start)}{f'{local_strftime(session.timestamp_end, timeformat)}' if session.timestamp_end else ''}", f"{local_strftime(session.timestamp_start)}{f'{local_strftime(session.timestamp_end, timeformat)}' if session.timestamp_end else ''}",
( session.duration_formatted_with_mark,
format_duration(session.duration_calculated, durationformat)
if session.duration_calculated
else f"{format_duration(session.duration_manual, durationformat_manual)}*"
),
render_to_string( render_to_string(
"cotton/button_group.html", "cotton/button_group.html",
{ {
"buttons": [ "buttons": [
{ {
"href": reverse( "href": reverse(
"list_sessions_end_session", args=[session.pk] "games:list_sessions_end_session", args=[session.pk]
), ),
"slot": Icon("end"), "slot": Icon("end"),
"title": "Finish session now", "title": "Finish session now",
@@ -334,12 +349,12 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
# in the button group component # in the button group component
else {}, else {},
{ {
"href": reverse("edit_session", args=[session.pk]), "href": reverse("games:edit_session", args=[session.pk]),
"slot": Icon("edit"), "slot": Icon("edit"),
"color": "gray", "color": "gray",
}, },
{ {
"href": reverse("delete_session", args=[session.pk]), "href": reverse("games:delete_session", args=[session.pk]),
"slot": Icon("delete"), "slot": Icon("delete"),
"color": "red", "color": "red",
}, },
@@ -351,8 +366,36 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
], ],
} }
playevents = game.playevents.all()
playevent_count = playevents.count()
playevent_data = create_playevent_tabledata(playevents, exclude_columns=["Game"])
statuschanges = game.status_changes.all()
statuschange_count = statuschanges.count()
statuschange_data = {
"columns": [
"Old Status",
"New Status",
"Timestamp",
],
"rows": [
[
statuschange.get_old_status_display()
if statuschange.old_status
else "-",
statuschange.get_new_status_display(),
local_strftime(statuschange.timestamp, dateformat),
]
for statuschange in statuschanges
],
}
context: dict[str, Any] = { context: dict[str, Any] = {
"statuschange_data": statuschange_data,
"statuschange_count": statuschange_count,
"statuschanges": statuschanges,
"game": game, "game": game,
"game_statuses": Game.Status.choices,
"playrange": playrange, "playrange": playrange,
"purchase_count": game.purchases.count(), "purchase_count": game.purchases.count(),
"session_average_without_manual": round( "session_average_without_manual": round(
@@ -366,6 +409,8 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
"title": f"Game Overview - {game.name}", "title": f"Game Overview - {game.name}",
"hours_sum": total_hours, "hours_sum": total_hours,
"purchase_data": purchase_data, "purchase_data": purchase_data,
"playevent_data": playevent_data,
"playevent_count": playevent_count,
"session_data": session_data, "session_data": session_data,
"session_page_obj": session_page_obj, "session_page_obj": session_page_obj,
"session_elided_page_range": ( "session_elided_page_range": (
+109 -72
View File
@@ -2,9 +2,9 @@ from datetime import datetime, timedelta
from typing import Any, Callable from typing import Any, Callable
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.db.models import Avg, Count, ExpressionWrapper, F, Prefetch, Q, Sum, fields from django.db.models import Avg, Count, ExpressionWrapper, F, Max, OuterRef, Prefetch, Q, Subquery, Sum, fields
from django.db.models.functions import TruncDate, TruncMonth from django.db.models.functions import TruncDate, TruncMonth
from django.db.models.manager import BaseManager
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.urls import reverse from django.urls import reverse
@@ -22,10 +22,10 @@ def model_counts(request: HttpRequest) -> dict[str, bool]:
timestamp_start__day=this_day, timestamp_start__day=this_day,
timestamp_start__month=this_month, timestamp_start__month=this_month,
timestamp_start__year=this_year, timestamp_start__year=this_year,
).aggregate(time=Sum(F("duration_calculated")))["time"] ).aggregate(time=Sum(F("duration_total")))["time"]
last_7_played = Session.objects.filter( last_7_played = Session.objects.filter(
timestamp_start__gte=(now - timedelta(days=7)) timestamp_start__gte=(now - timedelta(days=7))
).aggregate(time=Sum(F("duration_calculated")))["time"] ).aggregate(time=Sum(F("duration_total")))["time"]
return { return {
"game_available": Game.objects.exists(), "game_available": Game.objects.exists(),
@@ -90,26 +90,34 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
this_year_purchases = Purchase.objects.all() this_year_purchases = Purchase.objects.all()
this_year_purchases_with_currency = this_year_purchases.select_related("games") this_year_purchases_with_currency = this_year_purchases.select_related("games")
this_year_purchases_without_refunded = this_year_purchases_with_currency.filter( this_year_purchases_without_refunded = Purchase.objects.filter(
date_refunded=None date_refunded=None
) )
this_year_purchases_refunded = this_year_purchases_with_currency.refunded() this_year_purchases_refunded = Purchase.objects.refunded()
this_year_purchases_unfinished_dropped_nondropped = ( this_year_purchases_unfinished_dropped_nondropped = (
this_year_purchases_without_refunded.filter(date_finished__isnull=True) this_year_purchases_without_refunded.filter(
~Q(games__status="f")
& ~Q(games__playevents__ended__isnull=False)
)
.filter(infinite=False) .filter(infinite=False)
.filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC)) .filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC))
) # do not count battle passes etc. ) # do not count battle passes etc.
this_year_purchases_unfinished = ( this_year_purchases_unfinished = (
this_year_purchases_unfinished_dropped_nondropped.filter( this_year_purchases_unfinished_dropped_nondropped.filter(
date_dropped__isnull=True ~Q(games__status="r")
& ~Q(games__status="a")
) )
) )
this_year_purchases_dropped = ( this_year_purchases_dropped = (
this_year_purchases_unfinished_dropped_nondropped.filter( this_year_purchases.filter(
date_dropped__isnull=False ~Q(games__status="f")
& ~Q(games__playevents__ended__isnull=False)
) )
.filter(Q(games__status="a") | Q(date_refunded__isnull=False))
.filter(infinite=False)
.filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC))
) )
this_year_purchases_without_refunded_count = ( this_year_purchases_without_refunded_count = (
@@ -124,32 +132,43 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
* 100 * 100
) )
purchases_finished_this_year: BaseManager[Purchase] = Purchase.objects.finished() _finished_purchases_qs = Purchase.objects.finished()
purchases_finished_this_year_released_this_year = ( _finished_with_date = _finished_purchases_qs.annotate(
purchases_finished_this_year.all().order_by("date_finished") date_finished=Subquery(
Purchase.objects.filter(pk=OuterRef("pk"))
.annotate(max_ended=Max("games__playevents__ended"))
.values("max_ended")[:1]
)
)
purchases_finished_this_year = _finished_with_date
purchases_finished_this_year_released_this_year = _finished_with_date.order_by(
"-date_finished"
) )
purchased_this_year_finished_this_year = ( purchased_this_year_finished_this_year = (
this_year_purchases_without_refunded.all() this_year_purchases_without_refunded.filter(pk__in=_finished_purchases_qs.values("pk"))
).order_by("date_finished") .annotate(
date_finished=Subquery(
Purchase.objects.filter(pk=OuterRef("pk"))
.annotate(max_ended=Max("games__playevents__ended"))
.values("max_ended")[:1]
)
)
).order_by("-date_finished")
this_year_spendings = this_year_purchases_without_refunded.aggregate( this_year_spendings = this_year_purchases_without_refunded.aggregate(
total_spent=Sum(F("converted_price")) total_spent=Sum(F("converted_price"))
) )
total_spent = this_year_spendings["total_spent"] or 0 total_spent = this_year_spendings["total_spent"] or 0
games_with_playtime = ( games_with_playtime = Game.objects.filter(
Game.objects.filter(sessions__in=this_year_sessions) sessions__in=this_year_sessions
.annotate( ).distinct().annotate(
total_playtime=Sum( total_playtime=Sum(F("sessions__duration_total"))
F("sessions__duration_calculated") + F("sessions__duration_manual") ).filter(total_playtime__gt=timedelta(0))
)
)
.values("id", "name", "total_playtime")
)
month_playtimes = ( month_playtimes = (
this_year_sessions.annotate(month=TruncMonth("timestamp_start")) this_year_sessions.annotate(month=TruncMonth("timestamp_start"))
.values("month") .values("month")
.annotate(playtime=Sum("duration_calculated")) .annotate(playtime=Sum("duration_total"))
.order_by("month") .order_by("month")
) )
for month in month_playtimes: for month in month_playtimes:
@@ -162,25 +181,23 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
.first() .first()
) )
top_10_games_by_playtime = games_with_playtime.order_by("-total_playtime")[:10] top_10_games_by_playtime = games_with_playtime.order_by("-total_playtime")[:10]
for game in top_10_games_by_playtime:
game["formatted_playtime"] = format_duration(game["total_playtime"], "%2.0H")
total_playtime_per_platform = ( total_playtime_per_platform = (
this_year_sessions.values("game__platform__name") this_year_sessions.values("game__platform__name")
.annotate(total_playtime=Sum(F("duration_calculated") + F("duration_manual"))) .annotate(playtime=Sum(F("duration_total")))
.annotate(platform_name=F("game__platform__name")) .annotate(platform_name=F("game__platform__name"))
.values("platform_name", "total_playtime") .values("platform_name", "playtime")
.order_by("-total_playtime") .order_by("-playtime")
) )
for item in total_playtime_per_platform:
item["formatted_playtime"] = format_duration(item["total_playtime"], "%2.0H")
backlog_decrease_count = ( backlog_decrease_count = (
Purchase.objects.all().intersection(purchases_finished_this_year).count() purchases_finished_this_year.count()
) )
first_play_date = "N/A" first_play_date = "N/A"
last_play_date = "N/A" last_play_date = "N/A"
first_play_game = None
last_play_game = None
if this_year_sessions: if this_year_sessions:
first_session = this_year_sessions.earliest() first_session = this_year_sessions.earliest()
first_play_game = first_session.game first_play_game = first_session.game
@@ -267,9 +284,9 @@ def stats_alltime(request: HttpRequest) -> HttpResponse:
def stats(request: HttpRequest, year: int = 0) -> HttpResponse: def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
selected_year = request.GET.get("year") selected_year = request.GET.get("year")
if selected_year: if selected_year:
return HttpResponseRedirect(reverse("stats_by_year", args=[selected_year])) return HttpResponseRedirect(reverse("games:stats_by_year", args=[selected_year]))
if year == 0: if year == 0:
return HttpResponseRedirect(reverse("stats_alltime")) return HttpResponseRedirect(reverse("games:stats_alltime"))
this_year_sessions = Session.objects.filter( this_year_sessions = Session.objects.filter(
timestamp_start__year=year timestamp_start__year=year
).prefetch_related("game") ).prefetch_related("game")
@@ -305,28 +322,45 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
sessions__in=this_year_sessions sessions__in=this_year_sessions
).distinct() ).distinct()
this_year_purchases = Purchase.objects.filter(date_purchased__year=year) this_year_purchases = Purchase.objects.filter(
this_year_purchases_with_currency = this_year_purchases.prefetch_related("games") date_purchased__year=year
this_year_purchases_without_refunded = this_year_purchases_with_currency.filter( ).prefetch_related("games")
date_refunded=None # purchased this year
).exclude(ownership_type=Purchase.DEMO) # not refunded
this_year_purchases_refunded = this_year_purchases_with_currency.refunded() this_year_purchases_without_refunded = Purchase.objects.filter(
date_refunded=None, date_purchased__year=year
)
# purchased this year
# not refunded
# not finished
# not infinite
# only Game and DLC
this_year_purchases_unfinished_dropped_nondropped = ( this_year_purchases_unfinished_dropped_nondropped = (
this_year_purchases_without_refunded.filter(date_finished__isnull=True) this_year_purchases_without_refunded.filter(
~Q(games__status="f")
& ~Q(games__playevents__ended__year=year)
)
.filter(infinite=False) .filter(infinite=False)
.filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC)) .filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC))
) # do not count battle passes etc. )
# unfinished = not finished AND not dropped
this_year_purchases_unfinished = ( this_year_purchases_unfinished = (
this_year_purchases_unfinished_dropped_nondropped.filter( this_year_purchases_unfinished_dropped_nondropped.filter(
date_dropped__isnull=True ~Q(games__status="r")
& ~Q(games__status="a")
) )
) )
# dropped = abandoned OR retired OR refunded (OR logic for transition)
this_year_purchases_dropped = ( this_year_purchases_dropped = (
this_year_purchases_unfinished_dropped_nondropped.filter( this_year_purchases.filter(
date_dropped__isnull=False ~Q(games__status="f")
& ~Q(games__playevents__ended__year=year)
) )
.filter(Q(games__status="a") | Q(date_refunded__isnull=False))
.filter(infinite=False)
.filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC))
) )
this_year_purchases_without_refunded_count = ( this_year_purchases_without_refunded_count = (
@@ -341,15 +375,21 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
* 100 * 100
) )
purchases_finished_this_year = Purchase.objects.filter(date_finished__year=year) purchases_finished_this_year = Purchase.objects.finished().filter(
games__playevents__ended__year=year
).annotate(game_name=F("games__name"), date_finished=F("games__playevents__ended"))
purchases_finished_this_year_released_this_year = ( purchases_finished_this_year_released_this_year = (
purchases_finished_this_year.filter(games__year_released=year).order_by( purchases_finished_this_year.filter(games__year_released=year).order_by(
"date_finished" "games__playevents__ended"
) )
) )
purchased_this_year_finished_this_year = ( purchased_this_year_finished_this_year = (
this_year_purchases_without_refunded.filter(date_finished__year=year) this_year_purchases_without_refunded.filter(
).order_by("date_finished") games__playevents__ended__year=year
).annotate(
game_name=F("games__name"), date_finished=F("games__playevents__ended")
)
).order_by("games__playevents__ended")
this_year_spendings = this_year_purchases_without_refunded.aggregate( this_year_spendings = this_year_purchases_without_refunded.aggregate(
total_spent=Sum(F("converted_price")) total_spent=Sum(F("converted_price"))
@@ -357,22 +397,21 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
total_spent = this_year_spendings["total_spent"] or 0 total_spent = this_year_spendings["total_spent"] or 0
games_with_playtime = ( games_with_playtime = (
Game.objects.filter(sessions__in=this_year_sessions) Game.objects.filter(sessions__timestamp_start__year=year)
.annotate( .annotate(
total_playtime=Sum( total_playtime=Sum(
F("sessions__duration_calculated") + F("sessions__duration_manual") F("sessions__duration_calculated"),
) )
) )
.values("id", "name", "total_playtime") .filter(total_playtime__gt=timedelta(0))
) )
month_playtimes = ( month_playtimes = (
this_year_sessions.annotate(month=TruncMonth("timestamp_start")) this_year_sessions.annotate(month=TruncMonth("timestamp_start"))
.values("month") .values("month")
.annotate(playtime=Sum("duration_calculated")) .annotate(playtime=Sum("duration_total"))
.order_by("month") .order_by("month")
) )
for month in month_playtimes:
month["playtime"] = format_duration(month["playtime"], "%2.0H")
highest_session_average_game = ( highest_session_average_game = (
Game.objects.filter(sessions__in=this_year_sessions) Game.objects.filter(sessions__in=this_year_sessions)
@@ -381,22 +420,19 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
.first() .first()
) )
top_10_games_by_playtime = games_with_playtime.order_by("-total_playtime") top_10_games_by_playtime = games_with_playtime.order_by("-total_playtime")
for game in top_10_games_by_playtime:
game["formatted_playtime"] = format_duration(game["total_playtime"], "%2.0H")
total_playtime_per_platform = ( total_playtime_per_platform = (
this_year_sessions.values("game__platform__name") this_year_sessions.values("game__platform__name")
.annotate(total_playtime=Sum(F("duration_calculated") + F("duration_manual"))) .annotate(playtime=Sum(F("duration_total")))
.annotate(platform_name=F("game__platform__name")) .annotate(platform_name=F("game__platform__name"))
.values("platform_name", "total_playtime") .values("platform_name", "playtime")
.order_by("-total_playtime") .order_by("-playtime")
) )
for item in total_playtime_per_platform:
item["formatted_playtime"] = format_duration(item["total_playtime"], "%2.0H")
backlog_decrease_count = ( backlog_decrease_count = (
Purchase.objects.filter(date_purchased__year__lt=year) Purchase.objects.filter(date_purchased__year__lt=year)
.intersection(purchases_finished_this_year) .filter(games__status="f")
.filter(games__playevents__ended__year=year)
.count() .count()
) )
@@ -412,7 +448,10 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
last_play_game = last_session.game last_play_game = last_session.game
last_play_date = last_session.timestamp_start.strftime(dateformat) last_play_date = last_session.timestamp_start.strftime(dateformat)
all_purchased_this_year_count = this_year_purchases_with_currency.count() all_purchased_this_year_count = this_year_purchases.count()
this_year_purchases_refunded = Purchase.objects.exclude(date_refunded=None).filter(
date_purchased__year=year
)
all_purchased_refunded_this_year_count = this_year_purchases_refunded.count() all_purchased_refunded_this_year_count = this_year_purchases_refunded.count()
this_year_purchases_dropped_count = this_year_purchases_dropped.count() this_year_purchases_dropped_count = this_year_purchases_dropped.count()
@@ -439,15 +478,15 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
), ),
"all_finished_this_year": purchases_finished_this_year.prefetch_related( "all_finished_this_year": purchases_finished_this_year.prefetch_related(
"games" "games"
).order_by("date_finished"), ).order_by("games__playevents__ended"),
"all_finished_this_year_count": purchases_finished_this_year.count(), "all_finished_this_year_count": purchases_finished_this_year.count(),
"this_year_finished_this_year": purchases_finished_this_year_released_this_year.prefetch_related( "this_year_finished_this_year": purchases_finished_this_year_released_this_year.prefetch_related(
"games" "games"
).order_by("date_finished"), ).order_by("games__playevents__ended"),
"this_year_finished_this_year_count": purchases_finished_this_year_released_this_year.count(), "this_year_finished_this_year_count": purchases_finished_this_year_released_this_year.count(),
"purchased_this_year_finished_this_year": purchased_this_year_finished_this_year.prefetch_related( "purchased_this_year_finished_this_year": purchased_this_year_finished_this_year.prefetch_related(
"games" "games"
).order_by("date_finished"), ).order_by("games__playevents__ended"),
"total_sessions": this_year_sessions.count(), "total_sessions": this_year_sessions.count(),
"unique_days": unique_days["dates"], "unique_days": unique_days["dates"],
"unique_days_percent": int(unique_days["dates"] / 365 * 100), "unique_days_percent": int(unique_days["dates"] / 365 * 100),
@@ -465,9 +504,7 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
), ),
"all_purchased_refunded_this_year": this_year_purchases_refunded, "all_purchased_refunded_this_year": this_year_purchases_refunded,
"all_purchased_refunded_this_year_count": all_purchased_refunded_this_year_count, "all_purchased_refunded_this_year_count": all_purchased_refunded_this_year_count,
"all_purchased_this_year": this_year_purchases_with_currency.order_by( "all_purchased_this_year": this_year_purchases.order_by("date_purchased"),
"date_purchased"
),
"all_purchased_this_year_count": all_purchased_this_year_count, "all_purchased_this_year_count": all_purchased_this_year_count,
"backlog_decrease_count": backlog_decrease_count, "backlog_decrease_count": backlog_decrease_count,
"longest_session_time": ( "longest_session_time": (
@@ -507,4 +544,4 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
@login_required @login_required
def index(request: HttpRequest) -> HttpResponse: def index(request: HttpRequest) -> HttpResponse:
return redirect("list_sessions") return redirect("games:list_sessions")
+6 -6
View File
@@ -37,7 +37,7 @@ def list_platforms(request: HttpRequest) -> HttpResponse:
else None else None
), ),
"data": { "data": {
"header_action": A([], Button([], "Add platform"), url="add_platform"), "header_action": A([], Button([], "Add platform"), url_name="games:add_platform"),
"columns": [ "columns": [
"Name", "Name",
"Icon", "Icon",
@@ -57,14 +57,14 @@ def list_platforms(request: HttpRequest) -> HttpResponse:
"buttons": [ "buttons": [
{ {
"href": reverse( "href": reverse(
"edit_platform", args=[platform.pk] "games:edit_platform", args=[platform.pk]
), ),
"slot": Icon("edit"), "slot": Icon("edit"),
"color": "gray", "color": "gray",
}, },
{ {
"href": reverse( "href": reverse(
"delete_platform", args=[platform.pk] "games:delete_platform", args=[platform.pk]
), ),
"slot": Icon("delete"), "slot": Icon("delete"),
"color": "red", "color": "red",
@@ -84,7 +84,7 @@ def list_platforms(request: HttpRequest) -> HttpResponse:
def delete_platform(request: HttpRequest, platform_id: int) -> HttpResponse: def delete_platform(request: HttpRequest, platform_id: int) -> HttpResponse:
platform = get_object_or_404(Platform, id=platform_id) platform = get_object_or_404(Platform, id=platform_id)
platform.delete() platform.delete()
return redirect("list_platforms") return redirect("games:list_platforms")
@login_required @login_required
@@ -95,7 +95,7 @@ def edit_platform(request: HttpRequest, platform_id: int) -> HttpResponse:
form = PlatformForm(request.POST or None, instance=platform) form = PlatformForm(request.POST or None, instance=platform)
if form.is_valid(): if form.is_valid():
form.save() form.save()
return redirect("list_platforms") return redirect("games:list_platforms")
context["title"] = "Edit Platform" context["title"] = "Edit Platform"
context["form"] = form context["form"] = form
return render(request, "add.html", context) return render(request, "add.html", context)
@@ -107,7 +107,7 @@ def add_platform(request: HttpRequest) -> HttpResponse:
form = PlatformForm(request.POST or None) form = PlatformForm(request.POST or None)
if form.is_valid(): if form.is_valid():
form.save() form.save()
return redirect("index") return redirect("games:index")
context["form"] = form context["form"] = form
context["title"] = "Add New Platform" context["title"] = "Add New Platform"
+220
View File
@@ -0,0 +1,220 @@
import logging
from datetime import datetime, timedelta
from typing import Any, Callable, TypedDict
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import QuerySet
from django.db.models.manager import BaseManager
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.shortcuts import get_object_or_404, render
from django.template.loader import render_to_string
from django.urls import reverse
from common.components import A, Button, Icon
from common.time import dateformat, format_duration, local_strftime
from games.forms import PlayEventForm
from games.models import Game, PlayEvent, Session
logger = logging.getLogger("games")
class TableData(TypedDict):
header_action: Callable[..., Any]
columns: list[str]
rows: list[list[Any]]
def create_playevent_tabledata(
playevents: list[PlayEvent] | BaseManager[PlayEvent] | QuerySet[PlayEvent],
exclude_columns: list[str] = [],
request: HttpRequest | None = None,
) -> TableData:
column_list = [
"Game",
"Started",
"Ended",
"Days to finish",
"Note",
"Created",
"Actions",
]
filtered_column_list = filter(
lambda x: x not in exclude_columns,
column_list,
)
excluded_column_indexes = [column_list.index(column) for column in exclude_columns]
row_list = [
[
playevent.game,
playevent.started.strftime(dateformat) if playevent.started else "-",
playevent.ended.strftime(dateformat) if playevent.ended else "-",
playevent.days_to_finish if playevent.days_to_finish else "-",
playevent.note,
local_strftime(playevent.created_at, dateformat),
render_to_string(
"cotton/button_group.html",
{
"buttons": [
{
"href": reverse("games:edit_playevent", args=[playevent.pk]),
"slot": Icon("edit"),
"color": "gray",
},
{
"href": reverse("games:delete_playevent", args=[playevent.pk]),
"slot": Icon("delete"),
"color": "red",
},
]
},
),
]
for playevent in playevents
]
filtered_row_list = [
[column for idx, column in enumerate(row) if idx not in excluded_column_indexes]
for row in row_list
]
return {
"header_action": A([], Button([], "Add play event"), url_name="games:add_playevent"),
"columns": list(filtered_column_list),
"rows": filtered_row_list,
}
def _get_formatted_playtime_for_game_sessions_in_range(
game: Game,
start_timestamp: datetime | None = None,
end_timestamp: datetime | None = None,
) -> str:
"""
Calculates and formats the total playtime for a game's sessions
between specified start and end timestamps. If timestamps are not provided,
it uses the earliest and latest session start times for the game.
Returns "0h 00m" if no sessions exist for the game or if the range is invalid.
"""
sessions_queryset = game.sessions.all()
if not sessions_queryset.exists():
return "0h 00m"
actual_start_ts = (
start_timestamp
if start_timestamp is not None
else sessions_queryset.earliest("timestamp_start").timestamp_start
)
actual_end_ts = (
end_timestamp
if end_timestamp is not None
else sessions_queryset.latest("timestamp_start").timestamp_start
)
sessions_in_range = sessions_queryset.filter(
timestamp_start__gte=actual_start_ts, timestamp_start__lte=actual_end_ts
)
return format_duration(sessions_in_range.total_duration_unformatted(), "%Hh %mm")
@login_required
def list_playevents(request: HttpRequest) -> HttpResponse:
page_number = request.GET.get("page", 1)
limit = request.GET.get("limit", 10)
playevents = PlayEvent.objects.order_by("-created_at")
page_obj = None
if int(limit) != 0:
paginator = Paginator(playevents, limit)
page_obj = paginator.get_page(page_number)
playevents = page_obj.object_list
context: dict[str, Any] = {
"title": "Manage play events",
"page_obj": page_obj or None,
"elided_page_range": (
page_obj.paginator.get_elided_page_range(
page_number, on_each_side=1, on_ends=1
)
if page_obj
else None
),
"data": create_playevent_tabledata(playevents, request=request),
}
return render(request, "list_playevents.html", context)
@login_required
def add_playevent(request: HttpRequest, game_id: int = 0) -> HttpResponse:
initial: dict[str, Any] = {}
if game_id:
# coming from add_playevent_for_game url path
game = get_object_or_404(Game, id=game_id)
initial["game"] = game
try:
# First, try to get the latest session. If no sessions, then no playtime.
latest_session = game.sessions.latest("timestamp_start")
latest_session_ts = latest_session.timestamp_start
# Now, determine the start date for the new playevent.
# This will be either the day after the last playevent ended, or the earliest session.
try:
latest_playevent = game.playevents.latest("ended")
# Start date for the new PlayEvent form
new_playevent_form_start_date = latest_playevent.ended + timedelta(
days=1
)
initial["started"] = new_playevent_form_start_date
# Start timestamp for playtime calculation
playtime_calc_start_ts = datetime.combine(
new_playevent_form_start_date, datetime.min.time()
)
except PlayEvent.DoesNotExist:
# No previous playevents, so the new playevent starts from the earliest session.
earliest_session_ts = game.sessions.earliest(
"timestamp_start"
).timestamp_start
initial["started"] = earliest_session_ts.date()
playtime_calc_start_ts = earliest_session_ts
# The end date for the new PlayEvent form and playtime calculation is the latest session's start date.
initial["ended"] = latest_session_ts.date()
playtime_calc_end_ts = latest_session_ts
initial["note"] = _get_formatted_playtime_for_game_sessions_in_range(
game, playtime_calc_start_ts, playtime_calc_end_ts
)
except Session.DoesNotExist:
initial["started"] = None
initial["ended"] = None
initial["note"] = "0h 00m"
form = PlayEventForm(request.POST or None, initial=initial)
if form.is_valid():
form.save()
if not game_id:
# coming from add_playevent url path
game_id = form.instance.game.id
return HttpResponseRedirect(reverse("games:view_game", args=[game_id]))
return render(request, "add.html", {"form": form, "title": "Add new playthrough"})
def edit_playevent(request: HttpRequest, playevent_id: int) -> HttpResponse:
context: dict[str, Any] = {}
playevent = get_object_or_404(PlayEvent, id=playevent_id)
form = PlayEventForm(request.POST or None, instance=playevent)
if form.is_valid():
form.save()
return HttpResponseRedirect(reverse("games:view_game", args=[playevent.game.id]))
context = {
"form": form,
"title": "Edit Play Event",
}
return render(request, "add.html", context)
def delete_playevent(request: HttpRequest, playevent_id: int) -> HttpResponse:
playevent = get_object_or_404(PlayEvent, id=playevent_id)
playevent.delete()
return HttpResponseRedirect(request.META.get("HTTP_REFERER", "/"))
+119 -103
View File
@@ -1,17 +1,18 @@
from typing import Any from typing import Any
from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.http import ( from django.http import (
HttpRequest, HttpRequest,
HttpResponse, HttpResponse,
HttpResponseBadRequest,
HttpResponseRedirect, HttpResponseRedirect,
) )
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.views.decorators.http import require_POST
from common.components import A, Button, Icon, LinkedPurchase, PurchasePrice from common.components import A, Button, Icon, LinkedPurchase, PurchasePrice
from common.time import dateformat from common.time import dateformat
@@ -20,6 +21,62 @@ from games.models import Game, Purchase
from games.views.general import use_custom_redirect from games.views.general import use_custom_redirect
def _render_purchase_buttons(purchase_id, is_refunded):
"""Return button group HTML for a purchase row."""
return render_to_string(
"cotton/button_group.html",
{
"buttons": [
{
"href": "#",
"hx_get": reverse(
"games:refund_purchase_confirmation",
args=[purchase_id],
),
"hx_target": "#global-modal-container",
"slot": Icon("refund"),
"title": "Mark as refunded",
}
if not is_refunded
else {},
{
"href": reverse("games:edit_purchase", args=[purchase_id]),
"slot": Icon("edit"),
"title": "Edit",
"color": "gray",
},
{
"href": reverse("games:delete_purchase", args=[purchase_id]),
"slot": Icon("delete"),
"title": "Delete",
"color": "red",
},
]
},
)
def _render_purchase_row(purchase):
"""Return a row dict for simple-table rendering."""
return {
"row_id": f"purchase-row-{purchase.id}",
"cell_data": [
LinkedPurchase(purchase),
purchase.get_type_display(),
PurchasePrice(purchase),
purchase.infinite,
purchase.date_purchased.strftime(dateformat),
(
purchase.date_refunded.strftime(dateformat)
if purchase.date_refunded
else "-"
),
purchase.created_at.strftime(dateformat),
_render_purchase_buttons(purchase.id, bool(purchase.date_refunded)),
],
}
@login_required @login_required
def list_purchases(request: HttpRequest) -> HttpResponse: def list_purchases(request: HttpRequest) -> HttpResponse:
context: dict[Any, Any] = {} context: dict[Any, Any] = {}
@@ -43,7 +100,7 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
else None else None
), ),
"data": { "data": {
"header_action": A([], Button([], "Add purchase"), url="add_purchase"), "header_action": A([], Button([], "Add purchase"), url_name="games:add_purchase"),
"columns": [ "columns": [
"Name", "Name",
"Type", "Type",
@@ -51,87 +108,10 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
"Infinite", "Infinite",
"Purchased", "Purchased",
"Refunded", "Refunded",
"Finished",
"Dropped",
"Created", "Created",
"Actions", "Actions",
], ],
"rows": [ "rows": [_render_purchase_row(purchase) for purchase in purchases],
[
LinkedPurchase(purchase),
purchase.get_type_display(),
PurchasePrice(purchase),
purchase.infinite,
purchase.date_purchased.strftime(dateformat),
(
purchase.date_refunded.strftime(dateformat)
if purchase.date_refunded
else "-"
),
(
purchase.date_finished.strftime(dateformat)
if purchase.date_finished
else "-"
),
(
purchase.date_dropped.strftime(dateformat)
if purchase.date_dropped
else "-"
),
purchase.created_at.strftime(dateformat),
render_to_string(
"cotton/button_group.html",
{
"buttons": [
{
"href": reverse(
"finish_purchase", args=[purchase.pk]
),
"slot": Icon("checkmark"),
"title": "Mark as finished",
}
if not purchase.date_finished
else {},
{
"href": reverse(
"drop_purchase", args=[purchase.pk]
),
"slot": Icon("eject"),
"title": "Mark as dropped",
}
if not purchase.date_dropped
else {},
{
"href": reverse(
"refund_purchase", args=[purchase.pk]
),
"slot": Icon("refund"),
"title": "Mark as refunded",
}
if not purchase.date_refunded
else {},
{
"href": reverse(
"edit_purchase", args=[purchase.pk]
),
"slot": Icon("edit"),
"title": "Edit",
"color": "gray",
},
{
"href": reverse(
"delete_purchase", args=[purchase.pk]
),
"slot": Icon("delete"),
"title": "Delete",
"color": "red",
},
]
},
),
]
for purchase in purchases
],
}, },
} }
return render(request, "list_purchases.html", context) return render(request, "list_purchases.html", context)
@@ -149,12 +129,12 @@ def add_purchase(request: HttpRequest, game_id: int = 0) -> HttpResponse:
if "submit_and_redirect" in request.POST: if "submit_and_redirect" in request.POST:
return HttpResponseRedirect( return HttpResponseRedirect(
reverse( reverse(
"add_session_for_game", "games:add_session_for_game",
kwargs={"game_id": purchase.first_game.id}, kwargs={"game_id": purchase.first_game.id},
) )
) )
else: else:
return redirect("list_purchases") return redirect("games:list_purchases")
else: else:
if game_id: if game_id:
game = Game.objects.get(id=game_id) game = Game.objects.get(id=game_id)
@@ -170,7 +150,7 @@ def add_purchase(request: HttpRequest, game_id: int = 0) -> HttpResponse:
context["form"] = form context["form"] = form
context["title"] = "Add New Purchase" context["title"] = "Add New Purchase"
# context["script_name"] = "add_purchase.js" context["script_name"] = "add_purchase.js"
return render(request, "add_purchase.html", context) return render(request, "add_purchase.html", context)
@@ -182,11 +162,11 @@ def edit_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
form = PurchaseForm(request.POST or None, instance=purchase) form = PurchaseForm(request.POST or None, instance=purchase)
if form.is_valid(): if form.is_valid():
form.save() form.save()
return redirect("list_sessions") return redirect("games:list_sessions")
context["title"] = "Edit Purchase" context["title"] = "Edit Purchase"
context["form"] = form context["form"] = form
context["purchase_id"] = str(purchase_id) context["purchase_id"] = str(purchase_id)
# context["script_name"] = "add_purchase.js" context["script_name"] = "add_purchase.js"
return render(request, "add_purchase.html", context) return render(request, "add_purchase.html", context)
@@ -194,7 +174,7 @@ def edit_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
def delete_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse: def delete_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
purchase = get_object_or_404(Purchase, id=purchase_id) purchase = get_object_or_404(Purchase, id=purchase_id)
purchase.delete() purchase.delete()
return redirect("list_purchases") return redirect("games:list_purchases")
@login_required @login_required
@@ -210,35 +190,71 @@ def view_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
@login_required @login_required
def drop_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse: def drop_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
purchase = get_object_or_404(Purchase, id=purchase_id) purchase = get_object_or_404(Purchase, id=purchase_id)
purchase.date_dropped = timezone.now() for game in purchase.games.all():
purchase.save() game.status = Game.Status.ABANDONED
return redirect("list_purchases") game.save()
return redirect("games:list_purchases")
@login_required @login_required
def refund_purchase_confirmation(
request: HttpRequest, purchase_id: int
) -> HttpResponse:
return render(
request,
"partials/refund_purchase_confirmation.html",
{"purchase_id": purchase_id},
)
@login_required
@require_POST
def refund_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse: def refund_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
purchase = get_object_or_404(Purchase, id=purchase_id) purchase = get_object_or_404(Purchase, id=purchase_id)
purchase.date_refunded = timezone.now()
purchase.save() for game in purchase.games.all():
return redirect("list_purchases") game.status = Game.Status.ABANDONED
game.save()
purchase.refund()
messages.success(request, "Purchase refunded")
row_data = _render_purchase_row(purchase)
row_html = render_to_string(
"cotton/table_row.html",
{"data": row_data},
)
modal_close = (
'<template id="refund-confirmation-modal" hx-swap-oob="outerHTML"></template>'
)
return HttpResponse(row_html + modal_close, status=200)
@login_required @login_required
def finish_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse: def finish_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
purchase = get_object_or_404(Purchase, id=purchase_id) purchase = get_object_or_404(Purchase, id=purchase_id)
purchase.date_finished = timezone.now() for game in purchase.games.all():
purchase.save() game.status = Game.Status.FINISHED
return redirect("list_purchases") game.save()
return redirect("games:list_purchases")
def related_purchase_by_game(request: HttpRequest) -> HttpResponse: def related_purchase_by_game(request: HttpRequest) -> HttpResponse:
games: list[str] = []
games = request.GET.getlist("games") games = request.GET.getlist("games")
if not games: context = {}
return HttpResponseBadRequest("Invalid game_id") if games:
if isinstance(games, int) or isinstance(games, str):
games = [games]
form = PurchaseForm() form = PurchaseForm()
form.fields["related_purchase"].queryset = Purchase.objects.filter( qs = Purchase.objects.filter(games__in=games, type=Purchase.GAME).order_by(
games__in=games, type=Purchase.GAME "games__sort_name"
).order_by("games__sort_name") )
return render(request, "partials/related_purchase_field.html", {"form": form})
form.fields["related_purchase"].queryset = qs
first_option = qs.first()
if first_option:
form.fields["related_purchase"].initial = first_option.id
context["form"] = form
return render(request, "partials/related_purchase_field.html", context)
else:
# abort swap
return HttpResponse(status=204)

Some files were not shown because too many files have changed in this diff Show More