Compare commits

...

523 Commits

Author SHA1 Message Date
lukas 89c9ff6367 feat: implement frontend filter bars and integration across all list views
Django CI/CD / test (push) Failing after 58s
Django CI/CD / build-and-push (push) Has been skipped
2026-06-09 13:56:02 +02:00
lukas 5887febbb7 feat: implement comprehensive filters and cross-entity queries
Django CI/CD / test (push) Failing after 1m28s
Django CI/CD / build-and-push (push) Has been skipped
2026-06-09 13:14:05 +02:00
lukas e45be806fc Add E2E tests
Django CI/CD / test (push) Failing after 1m1s
Django CI/CD / build-and-push (push) Has been skipped
2026-06-09 12:47:44 +02:00
lukas 83aefcb849 Improve Backspace behavior in single-select SearchSelect
Django CI/CD / test (push) Successful in 44s
Django CI/CD / build-and-push (push) Successful in 1m15s
2026-06-09 12:25:58 +02:00
lukas c7c196a054 Fix non-filter select visuals 2026-06-09 11:56:01 +02:00
lukas c639196266 Search select JavaScript improvements 2026-06-09 11:48:36 +02:00
lukas ed086c9702 Fix prefetch 2026-06-09 11:37:41 +02:00
lukas 6f4841eaaa Update uv.lock
Django CI/CD / test (push) Successful in 47s
Django CI/CD / build-and-push (push) Successful in 1m14s
2026-06-09 09:47:21 +02:00
lukas 5c9bf45c61 Fix contrast issues with search select dropdown 2026-06-09 09:41:30 +02:00
lukas bd228365ed Add pnpm to shell.nix 2026-06-09 09:31:38 +02:00
lukas 1c9fb474df Unify UI for filter modifiers
Django CI/CD / test (push) Successful in 40s
Django CI/CD / build-and-push (push) Successful in 1m16s
2026-06-09 08:47:20 +02:00
lukas 737dd9275b Fix includes all query returning duplicates 2026-06-09 08:47:20 +02:00
lukas 9f436b245d Decrease debounce for search select 2026-06-09 08:47:20 +02:00
lukas 7ebaa51eb0 Ignore .hermes folder 2026-06-09 08:47:20 +02:00
lukas a7ff2962a6 Add number of games filter to purchases 2026-06-09 08:47:20 +02:00
lukas 103219a5e7 Add includes only matcher mode 2026-06-09 08:47:20 +02:00
lukas 14efff8078 Fix filter stuff 2026-06-09 08:47:20 +02:00
Claude ba9b92d419 Align set-criterion modifiers with Stash (any/all/none) and harmonize EXCLUDES
Closes #10.

Backend (common/criteria.py):
- Treat `excludes` as an always-orthogonal AND'd negative across both
  MultiCriterion and ChoiceCriterion; the modifier now governs only the
  `value` (include) set. This removes the prior divergence where
  MultiCriterion.EXCLUDES dropped the excludes list and ChoiceCriterion.EXCLUDES
  swapped include/exclude into a positive.
- Fold INCLUDES / INCLUDES_ALL / EXCLUDES (+ EQUALS/NOT_EQUALS aliases) into the
  shared _SetCriterion base so the two subclasses cannot drift; remove _extra_q.

M2M "has all" (games/filters.py):
- PurchaseFilter._games_to_q builds a pk__in subquery with one join per value so
  INCLUDES_ALL on the many-to-many games field works in a single .filter()
  (a naive Q(games=a) & Q(games=b) collapses to one join and matches nothing).

UI (FilterSelect + filter_bar.js):
- Add an optional any/all/none match-mode <select> (INCLUDES/INCLUDES_ALL/
  EXCLUDES) rendered before the pills via a new `leading` slot on the shared
  combobox shell. A native control so its value is its state. readSearchSelect
  serialises it to data-match; filter_bar folds it into the criterion modifier.
  Orthogonal to the (Any)/(None) presence pseudo-options and the exclude channel.
- Enable it for the M2M Purchase.games field (INCLUDES_ALL is only meaningful
  for multi-valued relations). Styled with already-compiled utilities.

Tests: harmonized EXCLUDES + INCLUDES_ALL for both criterion types, a DB-backed
INCLUDES_ALL vs INCLUDES contrast on Purchase.games, and FilterSelect /
PurchaseFilterBar rendering + round-trip of the match mode.

https://claude.ai/code/session_01KwVrGFbq13mZdhDL9G6zhg
2026-06-09 08:47:20 +02:00
Claude 05534875d6 Remove dead code and fix stale comments in filters.py
Django CI/CD / test (push) Successful in 46s
Django CI/CD / build-and-push (push) Successful in 1m12s
- Remove _filter_number() — defined but never called; take _FILTER_INPUT_CLASS
  with it since it was only used there.
- Remove the isinstance(value/excluded, str) single-string guards in
  _filter_get_choice — JS always emits arrays, this was backward-compat
  dead code.
- Remove identity-copy list comprehensions in PurchaseFilterBar; pass
  Purchase.TYPES and Purchase.OWNERSHIP_TYPES directly.
- Fix stale section comment that said model fields "resolve selected ids
  to labels" — they now use labels embedded in the filter JSON.
- Drop the now-unused escape import.

https://claude.ai/code/session_01EyAJcMoDktLrY9tSbdHViA
2026-06-08 21:41:03 +02:00
Claude 428edbcfe8 Remove bare-value fallback from _extract_labeled
The JS always emits {id, label} objects now; the else branch was dead code
and the docstring was wrong. Update the remaining test that was still
passing bare strings.

https://claude.ai/code/session_01EyAJcMoDktLrY9tSbdHViA
2026-06-08 21:41:03 +02:00
Claude 11cd62a3b9 Introduce LabeledOption and RangeValues named types
Replace all tuple[str, str] annotations with purpose-specific names:
- LabeledOption = tuple[str, str] for (value, label) pairs used in
  FilterChoice, FilterSelect params, _modifier_options, _find_label,
  and _extract_labeled.
- RangeValues(min, max) NamedTuple for _parse_range return values,
  making the two fields self-documenting at every call site.

Export LabeledOption from common.components alongside SearchSelectOption.
Document the "name compound types explicitly" convention in CLAUDE.md.

https://claude.ai/code/session_01EyAJcMoDktLrY9tSbdHViA
2026-06-08 21:41:03 +02:00
Claude d9902146dc Clean up label-embedding architecture
- Move {id,label} stripping into _SetCriterion.from_json() so both
  MultiCriterion and ChoiceCriterion normalise at the parse boundary;
  the querying layer stays typed (list[int] / list[str]) and clean.
- Revert MultiCriterion to a thin _extra_q() override; _SetCriterion.to_q()
  is no longer duplicated.
- JS: readSearchSelect always emits {id, label} objects — no conditional
  mixed-type arrays. filter_bar.js stores them as-is for all fields,
  removing the fragile isIdField hardcoded list.
- Update tests to use the {id, label} filter format.

https://claude.ai/code/session_01EyAJcMoDktLrY9tSbdHViA
2026-06-08 21:41:03 +02:00
Claude 83cbac9505 Update uv.lock
https://claude.ai/code/session_01EyAJcMoDktLrY9tSbdHViA
2026-06-08 21:41:03 +02:00
Claude 0285243172 Embed labels in filter criteria (Stash-style) to retire pill resolver
Store {id, label} objects instead of bare IDs in MultiCriterion value/excludes.
FilterSelect pills now render directly from the embedded labels — no DB round-trip
to _resolve_game/device/platform_options. The filter URL and saved presets are
self-describing. MultiCriterion.to_q() extracts ids for querying; bare ints are
still accepted for backward compatibility.

Closes #9

https://claude.ai/code/session_01EyAJcMoDktLrY9tSbdHViA
2026-06-08 21:41:03 +02:00
Claude 112d3107ef Consolidate Multi/Choice criteria into a shared _SetCriterion base
MultiCriterion and ChoiceCriterion were near-duplicate copies whose INCLUDES
branches had drifted — the exclude-only bug existed in one but not the other.
Extract the shared include/exclude/null set-membership logic into a _SetCriterion
base implemented once (INCLUDES with empty-list guards, EQUALS as an alias,
IS_NULL/NOT_NULL); subclasses contribute only their value type and their own
modifiers via _extra_q (INCLUDES_ALL for Multi; EXCLUDES/NOT_EQUALS for Choice).
Behaviour preserved (full modifier vocabulary kept); the duplication that caused
the drift is gone. Surfacing the modifier axis and harmonizing EXCLUDES is
tracked in #10.

https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS
2026-06-08 19:12:28 +02:00
Claude 22d7834ae9 Fix exclude-only multi filters matching nothing
MultiCriterion.to_q (used by SessionFilter for game/device) unconditionally added
field__in=value even when value was empty, and __in=[] matches no rows — so a
filter with only excludes (e.g. device excludes 11, no game/device includes)
returned zero results. Guard the empty value like ChoiceCriterion already does,
so an exclude-only criterion means 'all rows except the excluded ids'.

https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS
2026-06-08 19:12:28 +02:00
lukas 60773e7755 Fix variable names 2026-06-08 19:12:28 +02:00
Claude 79fa4bef44 Use element primitives instead of inline Component; add Template primitive
Add a Template() primitive for the standard <template> tag and export it. Replace
inline Component(tag_name="div"/"span"/"input"/"template") in search_select.py
and Pill with Div/Span/Input/Template; drop the private _template helper in favour
of Template at the call sites. Bare custom-styled <button>s stay on Component
(the opinionated Button() would inject unwanted classes). Document the
prefer-primitives convention in CLAUDE.md.

https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS
2026-06-08 19:12:28 +02:00
Claude 15bb3ce1b9 Expand the ss namespace prefix to search-select everywhere
Spell out the abbreviated data-ss-* hook attributes (data-search-select-option,
-label, -mode, -template, -action, -type, -modifier, -modifier-option, -pills,
-search, -options, -no-results) and the JS expando properties (_searchSelectInit,
_searchSelectLabel, _searchSelectDirty, _searchSelectOption) across components,
JS, and tests — no abbreviations left in the widget's hooks.

https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS
2026-06-08 19:12:28 +02:00
Claude a06e772e42 Rename data-ss-tpl attribute to data-ss-template
Spell out the abbreviation in the template marker attribute too, matching the
complete-words convention applied to the variables.

https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS
2026-06-08 19:12:28 +02:00
Claude 29b42e0f3d Use complete words for variable names; document the convention
Rename abbreviated identifiers in the PR's code to full words: tpl→template,
e→event, el→element, removeBtn→removeButton, and single-letter loop variables
(o→option, g/d/p→game/device/platform, v→value/modifier_value). Add a
'name variables with complete words' convention to CLAUDE.md.

https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS
2026-06-08 19:12:28 +02:00
Claude f210f818a9 Single-source combobox markup via <template> cloning
Eliminate the Python/JS class-string duplication: the server renders hidden
<template> prototypes (row, pill, include/exclude/modifier pills) using the same
component functions, and search_select.js clones them, filling only the
[data-ss-label] slot, value, and data-* attrs. All Tailwind class strings and DOM
structure now live solely in the Python components — the JS no longer hardcodes
any class. Pill gains an opt-in label_slot; the shell takes a templates list.

Companion issue #8 tracks the further HTMX-idiomatic step of returning rendered
row HTML from the search endpoint.

https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS
2026-06-08 19:12:28 +02:00
lukas 6bc7da9f2f Fix button visibility on select option hover 2026-06-08 19:12:28 +02:00
lukas c9189b9f8e Update allowed builders for pnpm 2026-06-08 19:12:28 +02:00
lukas a37257f9c8 Update uv.lock security 2026-06-08 19:12:28 +02:00
Claude db047dfaf2 Fix dropdown overlapping the search box
Anchor the options panel with top-full. As an absolutely positioned child of the
now-flex field container, its static position was centered by items-center,
placing the dropdown over the search box instead of below it.

https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS
2026-06-08 19:12:28 +02:00
Claude 6aff12b7b2 Address PR review: combobox field layout and dark-mode contrast
- Wire the long-defined-but-unused _FIELD_CLASS into the container so pills and
  the search input form a single padded flex row; the flex-1 input now fills the
  widget instead of looking unclickable inside a larger box (affects both
  SearchSelect and FilterSelect via the shared shell).
- Filter option labels get text-body so they're readable on dark backgrounds.
- Filter +/- buttons get text-body (readable at rest) and hover:border-brand-strong
  so the border stays visible against the brand hover fill.
- Mirror the filter class changes in search_select.js and rebuild base.css.

https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS
2026-06-08 19:12:28 +02:00
Claude 12b0b0af61 Remove the bespoke SelectableFilter widget
FilterSelect fully replaces it: delete SelectableFilter and its _selectable_*
helpers, the now-unused _get_filter_options, selectable_filter.js, and the .sf-*
rules in input.css (rebuilt base.css). The three list views load search_select.js
instead of selectable_filter.js. Drop the SelectableFilter export and refresh
docs/comments that referenced it.

https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS
2026-06-08 19:12:28 +02:00
Claude 1a206d719b Migrate filter bars to FilterSelect
Replace the bespoke SelectableFilter in all three bars with FilterSelect: enum
fields (status, type, ownership) pre-render their fixed options; model-backed
fields (game(s), platform, device) use the search endpoints with prefetch and
resolve only the selected ids to pill labels — dropping the per-page queries that
fetched every game/platform/device. filter_bar.js now reads filter-mode
SearchSelect widgets via readSearchSelect (data-included/excluded/modifier),
preserving the {value, excludes, modifier} JSON and id Number() coercion; the
redundant session game/device blocks are gone. Drop FilterBar's now-unused
platform_options param. Rebuild base.css for the inline filter-pill utilities and
update the bar tests to the new markup.

https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS
2026-06-08 19:12:28 +02:00
Claude a6532807cb Wire filter-mode behavior into search_select.js
Dispatch on data-ss-mode: in filter mode, value rows (server-rendered or fetched
via buildRow) carry +/- buttons that add include/exclude pills, and pinned
modifier pseudo-options set a lone, mutually-exclusive modifier pill. Pill removal
handles the modifier flag; filter pills carry no hidden inputs. Extend
readSearchSelect to serialise filter widgets into data-included / data-excluded /
data-modifier (the shape the filter bar consumes), leaving form widgets'
data-values path unchanged. JS class strings mirror the FilterSelect constants.

https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS
2026-06-08 19:12:28 +02:00
Claude d7e6efa68a Add FilterSelect: include/exclude combobox on the shared shell
FilterSelect renders value rows with +/- (include/exclude) buttons, check/cross
pills for the included/excluded sets, and an optional set of pinned modifier
pseudo-options (e.g. (Any)/(None)) that stay visible above the value rows. A
selected modifier is mutually exclusive with value pills. It delegates assembly
to _combobox_shell and supports both pre-rendered options (complete set) and
search_url + prefetch (windowed); included/excluded are passed as resolved
value+label so pills show labels even outside the fetched window. Styling is
inline (ported from the old SelectableFilter CSS) so nothing lives in input.css.

https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS
2026-06-08 19:12:28 +02:00
Claude 003e6ebe15 Add prefetch + instant-local/debounced-remote search to combobox
Introduce a general 'prefetch' option (rows to load on first open, default 0 =
unchanged) carried as data-prefetch. Rework the JS search so a search_url widget
filters its loaded window instantly on every keystroke while issuing a debounced
server request for the rest, with an AbortController so a slower earlier response
can never overwrite a newer one. No-results stays hidden until the server
response decides it, avoiding a flash over an incomplete window. On first focus a
prefetch-enabled widget seeds its window immediately. Rename single-letter locals
to full words while reworking these functions.

https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS
2026-06-08 19:12:28 +02:00
Claude e2cbd4a9f4 Extract _combobox_shell from SearchSelect
Pull the domain-agnostic combobox skeleton (pills region, search box, options
panel with its no-results node, outer container) into a private _combobox_shell
helper. SearchSelect now builds its form-specific pills and option rows and
delegates assembly to the shell. Rendered markup is byte-identical; a structural
test guards the fixed region order so future builders (e.g. a filter variant)
can share the shell without drift.

https://claude.ai/code/session_01XzhXvMvw42CQGc9kmin3GS
2026-06-08 19:12:28 +02:00
Claude 547894d8d0 Fix SearchSelect not showing selected label in single-select search box
Django CI/CD / test (push) Successful in 45s
Django CI/CD / build-and-push (push) Successful in 1m19s
The search_value was computed but never applied as a value attribute
on the search input element.

https://claude.ai/code/session_01BZHdra2YBPwS3umwsGrgUj
2026-06-07 20:52:56 +02:00
Claude 061b5e6d8a Fix add_session prefilling game from last session
When no game_id is provided, the session form should start with no game
selected rather than defaulting to the last session's game.

https://claude.ai/code/session_01BZHdra2YBPwS3umwsGrgUj
2026-06-07 20:52:56 +02:00
lukas 05e30610e9 Fix typo from merge
Django CI/CD / test (push) Failing after 44s
Django CI/CD / build-and-push (push) Has been skipped
2026-06-07 20:32:00 +02:00
lukas 0aa87a17fe Merge branch 'main' of github.com:KucharczykL/timetracker
Django CI/CD / test (push) Failing after 13m50s
Django CI/CD / build-and-push (push) Has been skipped
2026-06-07 20:22:50 +02:00
Claude 7c2c08501e Adopt SearchSelect for device, platform, and play event game fields
- Parameterize SearchSelectWidget with a required options_resolver so
  each widget explicitly names its resolver instead of implicitly using
  _game_options
- Add autofocus support: SearchSelect forwards it to the search input,
  and SearchSelectWidget extracts it from Django's attrs dict
- Add _device_options and _platform_options resolvers (single pk__in
  queries, same pattern as _game_options)
- Add /api/devices/search and /api/platforms/search endpoints
- Switch PlayEventForm.game from plain Select to SearchSelectWidget
  (preserving autofocus), and use SingleGameChoiceField for correct labels
- Switch SessionForm.device to SearchSelectWidget
- Switch PurchaseForm.platform and GameForm.platform to SearchSelectWidget
- Wire ModuleScript("search_select.js") into add/edit playevent and
  add/edit game views

https://claude.ai/code/session_013fpJD54HxRgxRv2xzwXGNo
2026-06-07 20:20:43 +02:00
lukas d3b29ff1d4 Merge pull request #3 from KucharczykL/claude/claude-md-docs-Yn7bE 2026-06-07 15:08:58 +02:00
Claude 1c17fbcb6d Update CLAUDE.md with current codebase state
Reflects the migration to pure-Python components, the new filter/criteria
architecture, FilterPreset model, stats split into data/content modules,
filter_presets views, layout.py render_page() pattern, and frontend stack.

https://claude.ai/code/session_01Nj9HbTK5LMVBYH6N741JMv
2026-06-07 12:32:15 +00:00
lukas 3b9c05d674 Improve year picker on stats page
Django CI/CD / test (push) Successful in 45s
Django CI/CD / build-and-push (push) Successful in 1m16s
2026-06-07 10:48:32 +02:00
lukas 2c2827df47 Merge branch 'main' of ssh://192.168.0.106:2022/lukas/timetracker
Django CI/CD / test (push) Successful in 51s
Django CI/CD / build-and-push (push) Successful in 1m24s
2026-06-07 09:01:23 +02:00
lukas a6384fc003 Improve search select 2026-06-07 09:01:18 +02:00
lukas 7f5384de48 Merge pull request #2 from KucharczykL/dependabot/uv/pytest-9.0.3
Django CI/CD / test (push) Successful in 57s
Django CI/CD / build-and-push (push) Successful in 1m19s
Bump pytest from 8.4.2 to 9.0.3
2026-06-07 07:41:35 +02:00
dependabot[bot] ffcc4ba0f3 Bump pytest from 8.4.2 to 9.0.3
Bumps [pytest](https://github.com/pytest-dev/pytest) from 8.4.2 to 9.0.3.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/8.4.2...9.0.3)

---
updated-dependencies:
- dependency-name: pytest
  dependency-version: 9.0.3
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-07 05:40:56 +00:00
lukas 7493f6fc28 Update Django et al
Django CI/CD / test (push) Successful in 53s
Django CI/CD / build-and-push (push) Successful in 1m41s
2026-06-07 07:36:10 +02:00
lukas f9b91c5900 update uv.lock
Django CI/CD / test (push) Successful in 50s
Django CI/CD / build-and-push (push) Successful in 1m31s
2026-06-07 07:31:28 +02:00
lukas 36098374c2 move stuff to docs 2026-06-07 07:31:28 +02:00
lukas afc16aabbb Implement search select component
Django CI/CD / test (push) Successful in 40s
Django CI/CD / build-and-push (push) Successful in 1m24s
2026-06-06 22:52:26 +02:00
lukas 3ce3356064 Refine filters 2026-06-06 19:37:14 +02:00
lukas ed8589a972 Fix more code smells
Django CI/CD / test (push) Successful in 39s
Django CI/CD / build-and-push (push) Successful in 1m19s
2026-06-06 13:14:55 +02:00
lukas f4161bf3f4 Improve stats code smells 2026-06-06 12:19:15 +02:00
lukas b6864e59ce Add filters
Django CI/CD / test (push) Successful in 43s
Django CI/CD / build-and-push (push) Successful in 1m22s
2026-06-06 12:13:04 +02:00
lukas 36b1382015 Fix code smells 2026-06-06 08:15:19 +02:00
lukas d101aecd70 Move from HTML templates to pure Python
Remove cruft
2026-06-06 07:51:10 +02:00
lukas 09db54e940 Add new lint and format checks 2026-06-06 07:10:54 +02:00
lukas f090643026 Keep moving towards pure Python components 2026-06-02 22:35:11 +02:00
lukas ec1828b823 Migrate cotton to Python + template tag shims
Django CI/CD / test (push) Successful in 32s
Django CI/CD / build-and-push (push) Successful in 1m22s
2026-06-02 22:19:55 +02:00
lukas 94c3d9050a Fix make init 2026-06-02 16:32:26 +02:00
lukas ad47684dc1 Automatically escape text in components 2026-06-02 16:09:39 +02:00
lukas 66ec8e1eed Add CLAUDE.md
Django CI/CD / test (push) Successful in 42s
Django CI/CD / build-and-push (push) Successful in 1m48s
2026-06-02 15:08:24 +02:00
lukas 1583c474b2 Update README.md 2026-06-02 15:07:53 +02:00
lukas 2f433c92da Update uv.lock
Django CI/CD / test (push) Successful in 44s
Django CI/CD / build-and-push (push) Successful in 1m26s
2026-05-12 18:57:13 +02:00
lukas 5b2b79f553 Fix comment not being a comment 2026-05-12 18:56:58 +02:00
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
lukas 843eed64d6 Add search field to game list
Django CI/CD / test (push) Successful in 1m7s
Django CI/CD / build-and-push (push) Successful in 2m1s
2025-02-10 18:20:46 +01:00
lukas 50e7efcfae Fix today's playtime stats
Django CI/CD / test (push) Successful in 57s
Django CI/CD / build-and-push (push) Successful in 2m0s
2025-02-09 09:00:28 +01:00
lukas 3e713a7637 Switch PRAGMA synchronous back to FULL
Django CI/CD / test (push) Successful in 1m8s
Django CI/CD / build-and-push (push) Successful in 2m8s
I had some data loss when restarting a container shortly after a database change, let's see if this prevents it.
2025-02-09 08:56:24 +01:00
lukas 2d7342c0d5 Fix add/edit session screen
Django CI/CD / test (push) Successful in 1m5s
Django CI/CD / build-and-push (push) Successful in 2m14s
2025-02-08 17:54:03 +01:00
lukas aba9bc994d Add playtime stats to navbar
Django CI/CD / test (push) Successful in 1m1s
Django CI/CD / build-and-push (push) Successful in 2m16s
2025-02-08 13:46:56 +01:00
lukas 967ff7df07 Fix wrong purchase form field
Django CI/CD / test (push) Successful in 1m16s
Django CI/CD / build-and-push (push) Successful in 2m22s
2025-02-07 20:21:14 +01:00
lukas 2ab497fd54 Also theme password fields 2025-02-07 20:20:33 +01:00
lukas 34148466c7 Improve forms
Django CI/CD / test (push) Successful in 1m17s
Django CI/CD / build-and-push (push) Successful in 2m10s
2025-02-04 20:09:25 +01:00
lukas b22e185d47 Add status, mastered to Game 2025-02-04 20:09:05 +01:00
lukas b2b69339b3 Improve game choices on the session form 2025-02-04 18:34:59 +01:00
lukas 89d1bbdd9e Fix games stats, show all played games instead of top 10
Django CI/CD / test (push) Successful in 56s
Django CI/CD / build-and-push (push) Successful in 2m29s
2025-02-01 10:02:23 +01:00
lukas 637e3e6493 Specifying name on NameWithIcon now works 2025-02-01 10:01:56 +01:00
lukas d213a3d35d Improve purchase view
Django CI/CD / test (push) Successful in 58s
Django CI/CD / build-and-push (push) Successful in 2m16s
2025-01-30 17:54:42 +01:00
lukas 2f4e16dd54 Improve database concurrency
Django CI/CD / test (push) Successful in 1m7s
Django CI/CD / build-and-push (push) Successful in 1m58s
2025-01-30 16:53:30 +01:00
lukas 6f62889e92 Improve price information
Django CI/CD / test (push) Successful in 1m1s
Django CI/CD / build-and-push (push) Successful in 2m9s
2025-01-30 16:38:13 +01:00
lukas 4ec808eeec Improve display when no purchases or sessions exist 2025-01-30 11:56:59 +01:00
lukas 69d27958f3 Fix possible server error 2025-01-30 11:56:47 +01:00
lukas 4ec1cf5f28 Improve purchase __str__
Django CI/CD / test (push) Successful in 1m2s
Django CI/CD / build-and-push (push) Successful in 2m13s
2025-01-30 11:41:01 +01:00
lukas d936fdc60d Fix currency API endpoint accepting only lowercase currency strings
Signed-off-by: Lukáš Kucharczyk <lukas@kucharczyk.xyz>
2025-01-30 11:40:22 +01:00
lukas 2116cfc219 Remove migrations
Django CI/CD / test (push) Successful in 1m9s
Django CI/CD / build-and-push (push) Successful in 2m12s
2025-01-29 22:28:00 +01:00
lukas 6bd8271291 Remove Edition
Django CI/CD / test (push) Failing after 54s
Django CI/CD / build-and-push (push) Has been skipped
2025-01-29 22:05:06 +01:00
lukas e571feadef Add platform to Game 2025-01-29 18:42:13 +01:00
lukas 23c1ce1f96 Set Edition related_name to editions 2025-01-29 18:02:17 +01:00
lukas 33103daebc Update Django to 5.1.5 and virtualenv to 20.29.1
Django CI/CD / test (push) Successful in 1m5s
Django CI/CD / build-and-push (push) Successful in 2m13s
2025-01-29 13:45:58 +01:00
lukas ba6028e43d Add emulated property to sessions
Django CI/CD / test (push) Successful in 1m16s
Django CI/CD / build-and-push (push) Has been cancelled
2025-01-29 13:43:35 +01:00
lukas c2853a3ecc purchases can now refer to multiple editions
Django CI/CD / test (push) Successful in 1m3s
Django CI/CD / build-and-push (push) Successful in 2m36s
allows purchases to be for more than one game
2025-01-08 21:00:19 +01:00
lukas cd90d60475 fix monthly playtime not displaying
Django CI/CD / test (push) Successful in 1m1s
Django CI/CD / build-and-push (push) Successful in 2m12s
this bug was introduced in d622ddfbf3
2024-12-01 11:00:15 +01:00
lukas 11cea2142a dont display year if none
Django CI/CD / test (push) Successful in 1m3s
Django CI/CD / build-and-push (push) Successful in 2m15s
2024-11-27 18:47:25 +01:00
lukas 24578b64fe disable djlint precommit hook 2024-11-27 18:47:16 +01:00
lukas 13e607f9a7 Add error handling if no Sessions exist
Django CI/CD / test (push) Successful in 1m12s
Django CI/CD / build-and-push (push) Successful in 2m30s
2024-11-27 18:35:44 +01:00
lukas fc0d8db8e8 consistently format prices everywhere
Django CI/CD / test (push) Successful in 1m7s
Django CI/CD / build-and-push (push) Successful in 2m10s
2024-11-15 22:53:07 +01:00
lukas 8acc4f9c5b make table work better on small screens
Django CI/CD / test (push) Successful in 56s
Django CI/CD / build-and-push (push) Successful in 2m12s
2024-11-13 21:28:44 +01:00
lukas 6b7a96dc06 make PopoverTruncated customizable 2024-11-13 21:28:17 +01:00
lukas 5c5fd5f26a truncate: strip trailing whitespace 2024-11-13 21:07:26 +01:00
lukas 7181b6472c fix mistakenly hardcoded value in truncate() 2024-11-13 21:06:52 +01:00
lukas af06d07ee3 pre-commit hook: disable isort 2024-11-13 21:06:38 +01:00
lukas 315e22a8ac Add yaml to dependencies
Django CI/CD / test (push) Successful in 1m28s
Django CI/CD / build-and-push (push) Successful in 2m33s
2024-11-11 18:14:48 +01:00
lukas 19676f8441 Implement converting prices (#79)
Django CI/CD / test (push) Successful in 1m17s
Django CI/CD / build-and-push (push) Successful in 2m10s
Reviewed-on: #79
2024-11-11 16:36:57 +00:00
lukas f61cde180f Pass search_string to search_field.html
Django CI/CD / test (push) Successful in 1m10s
Django CI/CD / build-and-push (push) Successful in 2m1s
2024-11-10 00:05:33 +01:00
lukas a53818257c Fix being unable to override c-vars from render_from_string 2024-11-10 00:05:11 +01:00
lukas 2d3ea714c4 Extend session search 2024-11-09 23:52:09 +01:00
lukas 832bb48983 Device: safe long type names directly in database 2024-11-09 23:51:28 +01:00
lukas c6b1badf39 add session search
Django CI/CD / test (push) Successful in 1m10s
Django CI/CD / build-and-push (push) Successful in 2m16s
2024-11-09 21:34:01 +00:00
lukas a3ed93c154 handle non-existent icons
Django CI/CD / test (push) Successful in 1m15s
Django CI/CD / build-and-push (push) Has been cancelled
2024-11-09 21:30:13 +00:00
lukas cf503a7b7d improve devcontainer
Django CI/CD / test (push) Successful in 1m7s
Django CI/CD / build-and-push (push) Successful in 2m26s
2024-11-09 11:56:20 +01:00
lukas d81df6452a add dev container
Django CI/CD / test (push) Successful in 1m15s
Django CI/CD / build-and-push (push) Successful in 2m26s
2024-11-09 11:22:21 +01:00
lukas d9290373b0 also sort purchases by created_at
Django CI/CD / test (push) Successful in 59s
Django CI/CD / build-and-push (push) Successful in 2m24s
2024-10-18 09:50:10 +02:00
lukas f8d621e710 fix stat dropdown
Django CI/CD / test (push) Successful in 1m16s
Django CI/CD / build-and-push (push) Successful in 2m7s
2024-10-16 18:31:12 +02:00
lukas 9992d9c9bd set edition platform to unspecified if none 2024-10-16 18:06:40 +02:00
lukas 2ae81bb00f update django-cotton to 1.2.1 2024-10-16 17:49:55 +02:00
lukas 993abb4710 editorconfig: do not add newline to HTML 2024-10-16 17:45:23 +02:00
lukas 23502eab85 do not throw error when no stats to calculate 2024-10-16 17:45:23 +02:00
lukas c517d735c7 use unified dateformat more 2024-10-16 17:45:23 +02:00
lukas 19056f846e view_game: display timezone-aware time for end timestamp 2024-10-16 17:45:23 +02:00
lukas 0759ad0804 make purchase price a float 2024-10-16 17:45:23 +02:00
lukas 228fc2bf5f avoid exception on game overview when sessions are 0 2024-10-16 17:45:23 +02:00
lukas a5a7041920 rename icon 2024-10-16 17:45:23 +02:00
lukas fbd829f70e order platforms by name 2024-10-16 17:45:23 +02:00
lukas 4873f25248 remove css cruft 2024-10-16 17:45:23 +02:00
lukas 3578f1707f add more icons 2024-10-16 17:45:23 +02:00
lukas b74ccb6eaa Remove extraneous statement 2024-10-16 17:45:23 +02:00
lukas b0b1bb2d42 add icon field to platform, use everywhere 2024-10-16 17:45:23 +02:00
lukas c40764a02f fix bug in Component
A leftover from when there was only the A component function,
this bug was not found earlier because we used
templates instead of tags most of the time.
2024-09-14 10:40:03 +02:00
lukas 649351efde implement platform icons
Django CI/CD / test (push) Successful in 1m1s
Django CI/CD / build-and-push (push) Has been skipped
2024-09-14 06:42:34 +02:00
lukas 698c8966c0 add purchase date to game view
Django CI/CD / test (push) Successful in 1m9s
Django CI/CD / build-and-push (push) Successful in 2m8s
2024-09-11 11:40:17 +02:00
lukas 7f6584ecf7 finish purchase from list 2024-09-11 11:39:54 +02:00
lukas 540f5ee42c align last column to the right 2024-09-11 11:39:48 +02:00
lukas 1c73268258 redirect to purchase list after modifying purchase 2024-09-10 14:50:49 +02:00
lukas 3063a3d143 refund purchase from list 2024-09-10 14:50:02 +02:00
lukas b589199ca6 drop purchase from list 2024-09-10 14:46:50 +02:00
lukas 2fc661dade re-add button titles 2024-09-10 14:46:10 +02:00
lukas 1f535a6e84 formatting 2024-09-10 14:26:06 +02:00
lukas a9c1135639 improve layout
Django CI/CD / test (push) Successful in 1m7s
Django CI/CD / build-and-push (push) Successful in 2m12s
2024-09-09 11:25:29 +02:00
lukas 58cfaca1a9 add table header actions
Django CI/CD / test (push) Successful in 1m3s
Django CI/CD / build-and-push (push) Successful in 2m23s
2024-09-08 21:03:37 +02:00
lukas c1b3493c80 Merge calculated and manual duration
Django CI/CD / test (push) Successful in 1m2s
Django CI/CD / build-and-push (push) Successful in 1m55s
2024-09-07 23:35:59 +02:00
lukas a1df8720f5 Fix missing variable reference
Django CI/CD / test (push) Successful in 1m11s
Django CI/CD / build-and-push (push) Successful in 2m3s
2024-09-07 23:20:17 +02:00
lukas 5a852bc2b9 tailwind: define accent and background colors
Django CI/CD / test (push) Successful in 55s
Django CI/CD / build-and-push (push) Successful in 2m6s
2024-09-04 21:59:29 +02:00
lukas 8ab9bfeeeb update deps 2024-09-04 21:59:06 +02:00
lukas 5eee7176d4 add streak-releted basic functionality 2024-09-04 21:58:56 +02:00
lukas 98c9c1faee move time-related functionality out of views.general
Django CI/CD / test (push) Successful in 58s
Django CI/CD / build-and-push (push) Successful in 1m52s
2024-09-04 21:55:22 +02:00
lukas 645ffa0dad update styles
Django CI/CD / test (push) Successful in 1m3s
Django CI/CD / build-and-push (push) Successful in 2m4s
2024-09-03 22:39:25 +02:00
lukas 4358708262 add links to add a new X to: game, edition, purchase, session, device, platform
Django CI/CD / test (push) Successful in 55s
Django CI/CD / build-and-push (push) Successful in 1m57s
2024-09-03 15:48:58 +02:00
lukas c738245783 Properly display non-game type names
Django CI/CD / test (push) Successful in 1m8s
Django CI/CD / build-and-push (push) Successful in 1m55s
2024-09-02 23:52:28 +02:00
lukas 57184ceea0 add one more breakpoint to better utilize smaller screens
Django CI/CD / test (push) Successful in 53s
Django CI/CD / build-and-push (push) Successful in 1m56s
2024-09-02 23:44:18 +02:00
lukas c2b9409562 update styles
Django CI/CD / test (push) Successful in 53s
Django CI/CD / build-and-push (push) Successful in 1m52s
2024-09-02 20:14:52 +02:00
lukas e067e65bce linkify game, edition, purchase, session references
Django CI/CD / test (push) Successful in 1m0s
Django CI/CD / build-and-push (push) Has been cancelled
also add link styles for links in a table row
2024-09-02 20:04:21 +02:00
lukas b8258e2937 replace slippers with django-cotton
Django CI/CD / test (push) Successful in 59s
Django CI/CD / build-and-push (push) Successful in 2m4s
main reason: slippers cannot pass request via context inside its
components, making it annoying to use template takes that take request.
more reasons: not actively worked on, no named slots, having to define
components in components.yaml + new components do not get registered
without restarting server
2024-09-02 17:43:41 +02:00
lukas 9af4c79947 improve game view
Django CI/CD / test (push) Successful in 56s
Django CI/CD / build-and-push (push) Has been skipped
2024-08-19 21:58:43 +02:00
lukas d8b8182b91 fix table top rounding 2024-08-13 08:36:40 +02:00
lukas 2fd44c1f53 separate views out 2/2
Django CI/CD / test (push) Successful in 57s
Django CI/CD / build-and-push (push) Has been skipped
2024-08-12 21:52:26 +02:00
lukas c3f99d124c update base.css 2024-08-12 21:42:56 +02:00
lukas 51f5b9fceb update ruff path 2024-08-12 21:42:47 +02:00
lukas 973f4416de separate views out 1/2 2024-08-12 21:42:34 +02:00
lukas a84209eb81 sort by timestamp
Django CI/CD / test (push) Successful in 51s
Django CI/CD / build-and-push (push) Has been skipped
2024-08-11 21:39:14 +02:00
lukas 498cd69328 improve display of game names, durations 2024-08-11 20:29:47 +02:00
lukas b28c42d945 delete platform
Django CI/CD / test (push) Successful in 51s
Django CI/CD / build-and-push (push) Has been skipped
2024-08-11 20:21:44 +02:00
lukas 3099f02145 list editions 2024-08-11 20:21:27 +02:00
lukas 74b9d0421c list platforms, fix editing platform 2024-08-11 18:34:50 +02:00
lukas c61adad180 list games 2024-08-11 18:21:11 +02:00
lukas 298ecb4092 formatting 2024-08-11 17:58:35 +02:00
lukas 020e12e20b remove session recent filter 2024-08-11 17:58:08 +02:00
lukas 6ef56bfed5 list, edit, and delete devices 2024-08-11 17:53:36 +02:00
lukas fda4913c97 add ruff to shell.nix
Django CI/CD / test (push) Successful in 56s
Django CI/CD / build-and-push (push) Has been skipped
2024-08-11 17:24:50 +02:00
lukas e85b32e22f update styles 2024-08-11 17:24:33 +02:00
lukas 2d6d6d24a4 formatting 2024-08-11 17:24:26 +02:00
lukas 00993a85db remove black 2024-08-11 17:24:19 +02:00
lukas 4f7e708255 vscode: replace black with ruff 2024-08-11 17:23:59 +02:00
lukas 238e4839e0 formatting 2024-08-11 17:23:28 +02:00
lukas b0ad806a93 fix version_date 2024-08-11 17:23:18 +02:00
lukas 453b4fd922 add manage -> sessions 2024-08-11 17:22:58 +02:00
lukas bb0d24809e make sure titles are truncated 2024-08-11 17:13:31 +02:00
lukas 3abd4c4af9 reuse existing variable 2024-08-09 13:59:14 +02:00
lukas 2e5e77b4e5 replace navbar
Django CI/CD / test (push) Successful in 1m12s
Django CI/CD / build-and-push (push) Has been skipped
2024-08-09 13:14:18 +02:00
lukas e79cf5de7a fix non-working views 2024-08-09 13:12:47 +02:00
lukas c15eaca205 only overflow table, not paginator, improve styling
Django CI/CD / test (push) Successful in 1m5s
Django CI/CD / build-and-push (push) Has been skipped
2024-08-09 12:42:54 +02:00
lukas 496c99ccf1 formatting 2024-08-09 12:23:49 +02:00
lukas 992622e8d1 make it possible to not use paginator when limit = 0 2024-08-09 12:23:40 +02:00
lukas cabe36c822 add dark/light mode toggle 2024-08-09 12:22:26 +02:00
lukas d84b67c460 improve pagination 2024-08-09 11:47:10 +02:00
lukas 1c28950b53 add pagination
Django CI/CD / test (push) Successful in 1m1s
Django CI/CD / build-and-push (push) Has been skipped
2024-08-08 22:54:15 +02:00
lukas b54bcdd9e9 remove cruft 2024-08-08 21:20:17 +02:00
lukas 9ec6c958c8 remove unnecessary styles 2024-08-08 21:20:08 +02:00
lukas 25deac6ea9 add more types 2024-08-08 21:19:43 +02:00
lukas a5ac10b20d use model variables for foreign keys where possible 2024-08-08 20:22:25 +02:00
lukas 3de40ccad3 create purchase list without paging 2024-08-08 20:17:43 +02:00
lukas 6a5dc9b62c even more formatting 2024-08-08 15:08:50 +02:00
lukas b6014a72e0 .gitignore: add .direnv 2024-08-08 14:49:09 +02:00
lukas 245b47b8b3 improve shell.nix
do not let poetry manage venvs
no need to override python3
2024-08-08 14:48:58 +02:00
lukas e33f23c18f add .envrc 2024-08-08 14:48:20 +02:00
lukas 33012bc328 vscode: add extensions and settings 2024-08-08 14:48:10 +02:00
lukas 447bd4820c reformat with djlint --reformat 2024-08-08 14:47:51 +02:00
lukas 72e89dae77 remove cruft
Django CI/CD / test (push) Successful in 1m7s
Django CI/CD / build-and-push (push) Successful in 2m0s
2024-08-08 09:47:06 +02:00
lukas 1cd0a8c0fb add shell.nix 2024-08-08 09:27:51 +02:00
lukas a9a430f856 change vscode settings 2024-08-08 09:27:36 +02:00
lukas 0ee4c50a24 update dependencies
Django CI/CD / test (push) Successful in 1m5s
Django CI/CD / build-and-push (push) Successful in 2m1s
2024-08-08 09:17:09 +02:00
lukas 714f0d97a9 Reformat
Django CI/CD / test (push) Successful in 1m0s
Django CI/CD / build-and-push (push) Successful in 2m10s
2024-08-04 22:40:43 +02:00
lukas d622ddfbf3 Add all-time stats 2024-08-04 22:40:37 +02:00
lukas 86fd40cc4a Do not save non-durations as manual
Django CI/CD / test (push) Successful in 1m56s
Django CI/CD / build-and-push (push) Successful in 2m23s
2024-07-23 09:51:15 +02:00
lukas e174850262 Update deps
Django CI/CD / test (push) Successful in 1m3s
Django CI/CD / build-and-push (push) Successful in 1m56s
2024-07-11 13:28:09 +02:00
lukas 6328d835ee Fix formatting 2024-07-09 23:04:14 +02:00
lukas 34d42e2af5 Fix list session links
Django CI/CD / test (push) Successful in 1m2s
Django CI/CD / build-and-push (push) Successful in 2m1s
2024-07-09 23:03:52 +02:00
lukas e19caf47bf Make game overview more appealing
Django CI/CD / build-and-push (push) Blocked by required conditions
Django CI/CD / test (push) Has been cancelled
2024-07-09 23:03:03 +02:00
lukas 72998ffc02 Fix incorrect font name 2024-07-09 20:38:03 +02:00
lukas ba44814474 Improve game links
Django CI/CD / test (push) Successful in 1m6s
Django CI/CD / build-and-push (push) Successful in 1m56s
2024-07-09 19:40:47 +02:00
lukas 86f8fde8fa Avoid errors when displaying game overview with zero sessions
Django CI/CD / test (push) Successful in 1m4s
Django CI/CD / build-and-push (push) Successful in 2m10s
2024-07-09 07:32:49 +02:00
lukas 811fec4b11 Ignore manual sessions when calculating session average
Django CI/CD / test (push) Successful in 1m1s
Django CI/CD / build-and-push (push) Successful in 1m58s
2024-07-02 17:27:44 +02:00
lukas fe6cf2758c make dev does not ignore warnings
Django CI/CD / test (push) Successful in 1m9s
Django CI/CD / build-and-push (push) Successful in 1m55s
2024-06-26 18:35:05 +02:00
lukas 1e1372ca56 Update Python deps 2024-06-26 18:34:38 +02:00
lukas d91c0bc255 Update npm deps
Django CI/CD / test (push) Successful in 1m1s
Django CI/CD / build-and-push (push) Successful in 1m57s
2024-06-26 17:39:53 +02:00
lukas a14f5d3ae5 Add npm-check-updates 2024-06-26 17:39:39 +02:00
lukas 4ac13053d5 Use new Poetry section for main deps 2024-06-26 17:31:43 +02:00
lukas e9311225e7 Make setting up and developing easier 2024-06-26 17:18:58 +02:00
lukas 44c70a5ee7 Formatting
Django CI/CD / test (push) Successful in 1m21s
Django CI/CD / build-and-push (push) Successful in 1m57s
2024-06-03 18:19:11 +02:00
lukas cd804f2c77 Sort url paths 2024-06-03 18:18:58 +02:00
lukas 15997bd5af Re-enable delete session delete view 2024-06-03 18:07:10 +02:00
lukas 880ea93424 Unify url path names 2024-06-03 18:05:34 +02:00
lukas dc1a9d5c4f Make sure attribute chains are evaluated safely 2024-05-30 14:26:38 +02:00
lukas 51c25659a9 djhtml formatting
Django CI/CD / test (push) Successful in 1m5s
Django CI/CD / build-and-push (push) Successful in 1m57s
2024-04-30 12:04:16 +02:00
lukas 973dda59d2 Improve game overview header 2024-04-30 12:03:52 +02:00
lukas 64edca9ffa Use display name in session list
Django CI/CD / test (push) Successful in 52s
Django CI/CD / build-and-push (push) Successful in 1m52s
2024-04-29 19:21:05 +02:00
lukas 86e25b84ab Allow deleting purchases
Django CI/CD / test (push) Successful in 53s
Django CI/CD / build-and-push (push) Successful in 1m47s
2024-04-29 16:35:54 +02:00
lukas edc1d062bc Update gunicorn to version 22.0.0
Django CI/CD / test (push) Successful in 1m46s
Django CI/CD / build-and-push (push) Successful in 2m28s
2024-04-17 12:28:10 +02:00
lukas 12a517c9fa Update sqlparse to version 0.5
Django CI/CD / test (push) Successful in 1m2s
Django CI/CD / build-and-push (push) Successful in 1m58s
2024-04-16 11:44:24 +02:00
lukas c1882f66e3 Improve purchase name consistency on stats page
Django CI/CD / test (push) Successful in 1m0s
Django CI/CD / build-and-push (push) Successful in 2m0s
2024-04-15 13:55:17 +02:00
lukas 1e87e67eb1 Reformat HTML with djhtml
Django CI/CD / test (push) Successful in 1m4s
Django CI/CD / build-and-push (push) Successful in 1m36s
2024-04-04 11:27:33 +02:00
lukas 84552e088b Update more dependencies 2024-04-04 11:27:14 +02:00
lukas 79dc8ae25c Update black
Django CI/CD / test (push) Failing after 44s
Django CI/CD / build-and-push (push) Has been skipped
2024-04-04 11:09:09 +02:00
lukas cee06e4f64 Update dependencies
Django CI/CD / test (push) Successful in 47s
Django CI/CD / build-and-push (push) Successful in 1m47s
2024-04-04 10:46:59 +02:00
lukas d9b5f0eab2 stats: add monthly playtimes
Django CI/CD / test (push) Successful in 58s
Django CI/CD / build-and-push (push) Successful in 1m54s
2024-04-02 08:18:58 +02:00
lukas ff28600710 Fix timestamp minutes on game page
Django CI/CD / test (push) Successful in 56s
Django CI/CD / build-and-push (push) Successful in 2m2s
Fixed #72
2024-03-27 14:38:00 +01:00
lukas 7517bf5f37 Add stats for dropped purchases
Django CI/CD / test (push) Successful in 1m2s
Django CI/CD / build-and-push (push) Successful in 1m57s
2024-03-10 22:48:46 +01:00
lukas 780a04d13f Do not edit sort_name invisibly
Django CI/CD / test (push) Successful in 1m0s
Django CI/CD / build-and-push (push) Successful in 2m0s
Fixes #64
2024-03-04 16:50:37 +01:00
lukas fd04e9fa77 Sort prefetch instead of the result
Django CI/CD / test (push) Successful in 57s
Django CI/CD / build-and-push (push) Successful in 1m52s
order_by on the final queryset results in duplicating editions, 1 for each purchase
to fix it we sort the thing we actually want to sort - non-game purchases - in a prefetch earlier in the code
2024-02-18 12:31:03 +01:00
lukas 18902aedac Reformat
Django CI/CD / test (push) Successful in 56s
Django CI/CD / build-and-push (push) Successful in 1m53s
2024-02-18 09:03:35 +01:00
lukas f9e37e9b1e Sort purchases also by date purchased
Django CI/CD / test (push) Successful in 1m4s
Django CI/CD / build-and-push (push) Has been cancelled
2024-02-18 09:02:08 +01:00
lukas c747cd1fd8 Reformat
Django CI/CD / test (push) Successful in 55s
Django CI/CD / build-and-push (push) Successful in 1m33s
2024-02-10 09:50:53 +01:00
lukas 6a5457191a Add logout button 2024-02-10 09:48:09 +01:00
lukas 76f6d0c377 Fix CSS bug 2024-02-10 09:03:16 +01:00
lukas ae93703c08 Remove login_required from clone_session_by_id
Django CI/CD / test (push) Successful in 53s
Django CI/CD / build-and-push (push) Successful in 1m35s
2024-02-09 22:27:28 +01:00
lukas c55176090c Temporarily disable tests
Django CI/CD / test (push) Successful in 52s
Django CI/CD / build-and-push (push) Successful in 1m41s
2024-02-09 22:08:49 +01:00
lukas 081b8a92de Require login by default
Django CI/CD / test (push) Failing after 1m1s
Django CI/CD / build-and-push (push) Has been skipped
2024-02-09 22:03:24 +01:00
lukas d02a60675f Render notes as Markdown
Django CI/CD / test (push) Failing after 1m5s
Django CI/CD / build-and-push (push) Has been skipped
2024-02-09 21:37:39 +01:00
lukas 4670568acb Add .DS_Store to .gitignore
Django CI/CD / test (push) Successful in 1m4s
Django CI/CD / build-and-push (push) Successful in 1m34s
2024-01-15 22:09:29 +01:00
lukas 4b75a1dea9 Increase session count on game overview when starting a new session
Django CI/CD / test (push) Successful in 50s
Django CI/CD / build-and-push (push) Successful in 1m32s
2024-01-15 21:41:25 +01:00
lukas e2b7ff2e15 Remove cruft
Django CI/CD / test (push) Successful in 53s
Django CI/CD / build-and-push (push) Successful in 1m30s
2024-01-15 19:17:27 +01:00
lukas b94aa49fc3 Fix title not being displayed on the Recent sessions page 2024-01-15 19:17:24 +01:00
lukas 73a92e5636 Mark refunded purchases red
Django CI/CD / test (push) Successful in 1m3s
Django CI/CD / build-and-push (push) Successful in 1m36s
2024-01-15 11:19:18 +01:00
lukas 42b28665e1 Version 1.5.2
Django CI/CD / test (push) Successful in 1m15s
Django CI/CD / build-and-push (push) Has been skipped
2024-01-14 21:28:38 +01:00
lukas 6ba187f8e4 Make it possible to end session from game overview 2024-01-14 21:27:18 +01:00
lukas a765fd8d00 More game overview optimizations
Django CI/CD / test (push) Successful in 1m15s
Django CI/CD / build-and-push (push) Successful in 1m37s
2024-01-14 17:04:06 +01:00
lukas 854e3cc54a Do not copy notes when cloning session
Django CI/CD / test (push) Successful in 1m13s
Django CI/CD / build-and-push (push) Successful in 1m45s
2024-01-14 13:05:45 +01:00
lukas 2d8eb32e90 Remove cruft
Django CI/CD / test (push) Successful in 1m3s
Django CI/CD / build-and-push (push) Successful in 1m29s
2024-01-10 17:13:59 +01:00
lukas 1f1ed79ee5 Optimize session listing 2024-01-10 16:57:01 +01:00
lukas 01fd7bad69 Remove cruft 2024-01-10 15:55:08 +01:00
lukas 44f49e5974 Session list: speed up starting new sessions
Django CI/CD / test (push) Successful in 1m1s
Django CI/CD / build-and-push (push) Successful in 1m33s
2024-01-10 15:54:09 +01:00
lukas 0cf3411f63 Make ending session from session list faster
Django CI/CD / test (push) Successful in 1m16s
Django CI/CD / build-and-push (push) Successful in 1m40s
2024-01-10 15:12:45 +01:00
lukas aa669710e1 Change update_session to template partial
Django CI/CD / test (push) Failing after 1m3s
Django CI/CD / build-and-push (push) Has been skipped
2024-01-10 14:10:13 +01:00
lukas 242833f886 Make it possible to drop purchases, or consider them infinite
Django CI/CD / build-and-push (push) Blocked by required conditions
Django CI/CD / test (push) Has been cancelled
2024-01-03 22:35:39 +01:00
lukas 0cdfd3c298 Stats: optimize
Django CI/CD / test (push) Successful in 56s
Django CI/CD / build-and-push (push) Successful in 1m33s
2024-01-03 21:35:47 +01:00
lukas a98b4839dd Fix wrong unfinished purchases calculation
Django CI/CD / test (push) Successful in 1m10s
Django CI/CD / build-and-push (push) Successful in 1m42s
2024-01-02 20:03:59 +01:00
lukas 1999f13cf2 stats: add first and last play
Django CI/CD / test (push) Successful in 1m12s
Django CI/CD / build-and-push (push) Successful in 1m36s
2024-01-01 18:42:14 +01:00
lukas 8466f67c86 Fix errors caused by empty values
Django CI/CD / test (push) Successful in 1m10s
Django CI/CD / build-and-push (push) Successful in 1m41s
2024-01-01 18:21:50 +01:00
lukas d9fbb4b896 Add title to stats page
Django CI/CD / build-and-push (push) Blocked by required conditions
Django CI/CD / test (push) Has been cancelled
2023-12-15 10:58:15 +01:00
lukas 4ff3692606 Remove duplicate block
Django CI/CD / test (push) Successful in 1m34s
Django CI/CD / build-and-push (push) Successful in 1m29s
2023-11-30 18:05:52 +01:00
lukas 8289c48896 Fix CI error
Django CI/CD / test (push) Successful in 1m14s
Django CI/CD / build-and-push (push) Successful in 1m24s
2023-11-30 17:44:31 +01:00
lukas d1b9202337 Update poetry.lock
Django CI/CD / test (push) Failing after 1m15s
Django CI/CD / build-and-push (push) Has been skipped
2023-11-30 17:35:44 +01:00
lukas fde93cb875 Organize better 2023-11-30 17:35:44 +01:00
lukas d1c3ac6079 Revert "Move GraphQL to separata app"
This reverts commit 6ac4209492.
2023-11-30 17:35:44 +01:00
lukas d921c2d8a6 Revert "Add UpdateGameMutation"
This reverts commit e9e61403a9.
2023-11-30 17:35:44 +01:00
lukas 52513e1ed8 Add UpdateGameMutation 2023-11-30 17:35:44 +01:00
lukas cb380814a7 Move GraphQL to separata app 2023-11-30 17:35:44 +01:00
lukas 5ef8c07f30 Initial working API 2023-11-30 17:35:44 +01:00
lukas 9573c3b8ff Better formatting
Django CI/CD / test (push) Successful in 1m11s
Django CI/CD / build-and-push (push) Successful in 1m22s
2023-11-29 22:26:43 +01:00
lukas c4354a1380 Update poetry.lock
Django CI/CD / test (push) Successful in 1m3s
Django CI/CD / build-and-push (push) Successful in 1m14s
2023-11-29 22:22:23 +01:00
lukas a245b6ff0f Fix longest session formatting
Django CI/CD / test (push) Successful in 1m1s
Django CI/CD / build-and-push (push) Successful in 1m24s
Put space between hours and minutes
2023-11-29 09:08:10 +01:00
lukas 6329d380b7 Editions are unique if name, platform OR year is different
Django CI/CD / test (push) Successful in 1m25s
Django CI/CD / build-and-push (push) Successful in 1m20s
2023-11-28 14:44:11 +01:00
lukas 76fbc39fed Disable hx-boost
Django CI/CD / test (push) Successful in 1m1s
Django CI/CD / build-and-push (push) Successful in 1m17s
2023-11-28 14:29:56 +01:00
lukas 4b6734c173 Add width, height, alt to images 2023-11-28 14:29:11 +01:00
lukas b505b5b430 Stats: add highest session average
Django CI/CD / test (push) Successful in 1m2s
Django CI/CD / build-and-push (push) Successful in 1m23s
2023-11-21 21:57:17 +01:00
lukas 87553ebdc5 Add djlint pre-commit hook
Django CI/CD / test (push) Successful in 1m13s
Django CI/CD / build-and-push (push) Successful in 1m15s
2023-11-21 18:19:25 +01:00
lukas ba4fc0cac5 Do not trigger hx-boost for non-submit buttons
Django CI/CD / test (push) Successful in 1m7s
Django CI/CD / build-and-push (push) Successful in 1m20s
2023-11-21 18:12:58 +01:00
lukas 8cb0276215 Use better way to find out if model record exists 2023-11-21 18:03:01 +01:00
lukas f9a51ee83d Remove experimental layout 2023-11-21 18:03:01 +01:00
lukas c9deba7d65 Add stats for most sessions, longest session 2023-11-21 17:02:44 +01:00
lukas c55fbe86b5 Support HTMX with Django Debug Toolbar 2023-11-21 16:59:21 +01:00
lukas 0e93993498 Add django-debug-toolbar 2023-11-21 14:42:37 +01:00
lukas 9fccdfbff0 Make links colorful 2023-11-20 23:07:11 +01:00
lukas d78139a5b3 Display finished DLCs in stats better
Django CI/CD / test (push) Successful in 1m6s
Django CI/CD / build-and-push (push) Successful in 1m26s
2023-11-20 21:56:16 +01:00
lukas 7dc43fbf77 Fix wrong export name 2023-11-20 21:54:51 +01:00
lukas 5442926457 Allow DLC to have date_finished set
Django CI/CD / test (push) Successful in 1m6s
Django CI/CD / build-and-push (push) Successful in 1m35s
2023-11-20 21:42:23 +01:00
lukas db4c635260 Remote JavaScript files 2023-11-20 21:25:21 +01:00
lukas 4a1d08d4df Fix CI, re-add test step
Django CI/CD / test (push) Successful in 1m0s
Django CI/CD / build-and-push (push) Successful in 1m45s
2023-11-18 11:10:33 +01:00
lukas c35b539c42 Merge sessions and notes
Django CI/CD / build-and-push (push) Successful in 1m9s
2023-11-17 21:20:33 +01:00
lukas bbe5e072b2 Don't display prices if zero 2023-11-17 21:10:56 +01:00
lukas 6fc2f623dc Apply djlint 2023-11-17 21:06:57 +01:00
lukas 9481bd5fef Add pre-commit
Django CI/CD / build-and-push (push) Successful in 1m33s
2023-11-17 09:34:51 +01:00
lukas 4083165123 Use the black profile for isort 2023-11-17 09:15:18 +01:00
lukas 45bb2681c7 Use isort on migrations 2023-11-17 09:15:06 +01:00
lukas dbb8ec3f9a Handle empty edition_id 2023-11-17 09:14:25 +01:00
lukas 206b5f6d46 Prevent HTMX from messing up the initial state
Django CI/CD / build-and-push (push) Successful in 1m15s
2023-11-16 20:33:56 +01:00
lukas b7e14ecc83 Account for no sessions
Django CI/CD / build-and-push (push) Successful in 1m21s
2023-11-16 20:29:08 +01:00
lukas 912e010729 Enable hx-boost everywhere
Django CI/CD / build-and-push (push) Successful in 1m18s
2023-11-16 19:56:08 +01:00
lukas a485237456 Fix form not syncing due to HTMX
Django CI/CD / build-and-push (push) Successful in 2m38s
2023-11-16 19:03:16 +01:00
lukas f5faf92ee0 Fix error
Django CI/CD / build-and-push (push) Successful in 1m57s
2023-11-16 16:53:59 +01:00
lukas 07452d8c43 Re-instance gitea actions
Django CI/CD / test (push) Failing after 34s
Django CI/CD / build-and-push (push) Has been skipped
2023-11-16 16:51:52 +01:00
lukas 229a79d266 Update .drone.yml testing
continuous-integration/drone/push Build is failing
2023-11-16 16:30:17 +01:00
lukas c6ed577fe3 Formatting
continuous-integration/drone/push Build is failing
2023-11-16 16:27:41 +01:00
lukas 171e4779a3 Move static files in prod 2023-11-16 16:27:41 +01:00
lukas 79f94e5984 Fix docker-compose.yml 2023-11-16 16:27:41 +01:00
lukas ccebcb89c6 Improve Dockerfile
Major inspiration (aka direct theft) from https://github.com/wemake-services/wemake-django-template
2023-11-16 16:27:41 +01:00
lukas fe0a6b39e3 Fix .dockerignore 2023-11-16 16:27:41 +01:00
lukas 6a495f951f Remove Django admin 2023-11-16 16:27:41 +01:00
lukas c8646d0a0c Update dependencies 2023-11-16 16:27:41 +01:00
lukas f2bb15e669 Fix naive date 2023-11-16 16:27:41 +01:00
lukas c49177d63c isort 2023-11-16 16:27:41 +01:00
lukas bd8d30eac1 Improve time-related stuff
Add created_at to all models
Add modified_at to Session
Get rid of custom now() function
Make sure aware datetime is used everywhere
2023-11-16 16:27:41 +01:00
lukas c44d8bf427 Improve time-related stuff
continuous-integration/drone/push Build is passing
Add created_at to all models
Add modified_at to Session
Get rid of custom now() function
Make sure aware datetime is used everywhere
2023-11-15 19:14:09 +01:00
lukas 3f037b4c7c Only allow choosing purchases of selected edition
continuous-integration/drone/push Build is passing
2023-11-15 14:25:42 +01:00
lukas 8783d1fc8e Name and related_purchase validation for non-games 2023-11-15 13:04:47 +01:00
lukas 9a1d24dbfd Sort imports, remove cruft 2023-11-15 12:19:31 +01:00
lukas 4720660cff Fix wrong playrange ordering 2023-11-15 10:40:52 +01:00
lukas e158bc0623 Improve how editions and purchases are displayed
continuous-integration/drone/push Build is passing
2023-11-15 10:37:24 +01:00
lukas 8982fc5086 Game View: order editions by year 2023-11-14 21:19:36 +01:00
lukas 729e1d939b Version 1.5.1
continuous-integration/drone/push Build encountered an error
2023-11-14 21:10:42 +01:00
lukas 2b4683e489 Improve and cleanup ConditionalElementHandler 2023-11-14 21:09:43 +01:00
lukas cce810e8cf Improve purchase __str__ 2023-11-14 19:55:56 +01:00
lukas 62cd17f702 Disallow choosing non-game purchase as related purchase 2023-11-14 19:55:19 +01:00
lukas f31280c682 Version 1.5.0
continuous-integration/drone/push Build is passing
2023-11-14 19:31:17 +01:00
lukas a745d16ec3 Order purchases by date on game view 2023-11-14 19:30:19 +01:00
lukas ae079e36ec Add purchase types 2023-11-14 19:27:00 +01:00
lukas c8a3212b77 CI: run migrations before tests
continuous-integration/drone/push Build was killed
2023-11-12 08:11:22 +01:00
lukas d211326c3f Make sure empty stats are 0
continuous-integration/drone/push Build is failing
2023-11-12 08:01:12 +01:00
lukas 270a291f05 Change stats years to 2000 up to current year 2023-11-12 07:50:12 +01:00
lukas 13b750ca92 Add stat for finished this year's games 2023-11-12 07:40:29 +01:00
lukas 015b6db2f7 Fix detecting manual durations
continuous-integration/drone/push Build is failing
2023-11-11 15:02:28 +01:00
lukas 667b161fff Remove deprecated USE_L10N
continuous-integration/drone/push Build is failing
2023-11-10 21:37:13 +01:00
lukas 5958cbf4a6 Add more tests 2023-11-10 21:34:36 +01:00
lukas 3b37f2c3f0 Fix edge case in format_duration
continuous-integration/drone/push Build is passing
Fixes #65

```python
def test_specific_precise_if_unncessary(self):
        delta = timedelta(hours=2, minutes=40)
        result = format_duration(delta, "%02.0H:%02.0m")
        self.assertEqual(result, "02:40")
```
This test fails by returning "03:40" instead. The problem is in the way `format_duration` handles fractional hours.
To fix it, we need to switch between using hours and fractional hours
depending on if minutes are present in the formatted string.
2023-11-10 20:07:41 +01:00
lukas 4517ff2b5a Fix ordering
continuous-integration/drone/push Build is passing
2023-11-09 21:43:17 +01:00
lukas 884ce13e26 Also prefill year between game and edition
continuous-integration/drone/push Build is passing
2023-11-09 21:20:12 +01:00
lukas dd219bae9d Version 1.4.0
continuous-integration/drone/push Build is passing
2023-11-09 21:11:43 +01:00
lukas 60d29090a1 Adding new games is easier 2023-11-09 21:11:28 +01:00
lukas 1bc3ca057b Refactor, remove cruft 2023-11-09 19:35:57 +01:00
lukas c2c0886451 Add backlog decrease count 2023-11-09 19:15:49 +01:00
lukas b0be7b5887 Fix sort names getting mangled
continuous-integration/drone/push Build is passing
2023-11-09 15:41:46 +01:00
lukas 099d989f16 Pre-fill year when adding edition 2023-11-09 15:20:30 +01:00
lukas a879360ebd UX improvements
continuous-integration/drone/push Build is passing
* ignore English articles when sorting names
  * added a new sort_name field that gets automatically created
* automatically fill certain values in forms:
  * new game: name and sort name after typing
  * new edition: name and sort name when selecting game
  * new purchase: platform when selecting edition
2023-11-09 14:49:00 +01:00
lukas 866f2526e6 Fix hardcoded year 2023-11-09 10:10:44 +01:00
lukas ce3c4b55f0 Order devices alphabetically on new session form
continuous-integration/drone/push Build is passing
2023-11-09 10:09:32 +01:00
lukas c52cd822ae Use safe_division in more places 2023-11-09 10:06:14 +01:00
lukas cdc6ca1324 Fix potential division by zero
continuous-integration/drone/push Build is passing
2023-11-09 09:18:49 +01:00
lukas e7ed349356 Add more stats
continuous-integration/drone/push Build is passing
* Finished (count)
* Unfinished (count)
* Refunded (count)
2023-11-08 18:13:48 +01:00
lukas 5052ca7dbf Add more stats
continuous-integration/drone/push Build is passing
* All finished games
* All finished 2023 games
* All finished games that were purchased this year
* Total sessions
* Days played
2023-11-08 16:24:22 +01:00
lukas f408bfd927 stats: change overall stats table layout 2023-11-08 15:48:06 +01:00
lukas 666dee33ba Model changes
continuous-integration/drone/push Build is passing
* More fields are now optional. This is to make it easier to add new items in bulk.
  * Game: Wikidata ID
  * Edition: Platform, Year
  * Purchase: Platform
  * Platform: Group
  * Session: Device
* New fields:
  * Game: Year Released
    * To record original year of release
    * Upon migration, this will be set to a year of any of the game's edition that has it set
  * Purchase: Date Finished
* Editions are now unique combination of name and platform
2023-11-06 19:48:12 +01:00
lukas e0b09e051a simplify playtime range display 2023-11-06 12:05:39 +01:00
lukas 4552cf7616 Version 1.3.0
continuous-integration/drone/push Build is passing
2023-11-05 15:10:56 +01:00
lukas a614b51d29 Make some pages redirect back instead to session list 2023-11-05 15:09:51 +01:00
lukas e67aa3fda1 Add more stats
continuous-integration/drone/push Build is passing
2023-11-02 20:12:32 +01:00
lukas 8423fd02b4 Extend stats range to 2018
continuous-integration/drone/push Build is passing
2023-11-02 15:32:57 +01:00
lukas 2bd07e5f2d Remove cruft
continuous-integration/drone/push Build is passing
2023-11-02 15:14:57 +01:00
lukas 058b83522c Group by game instead of purchase 2023-11-02 15:14:50 +01:00
lukas f13ed8a078 Reorder imports in views.py
continuous-integration/drone/push Build is passing
2023-11-02 09:53:28 +01:00
lukas 02d5adcb3c Remove hardcoded year 2023-11-02 09:52:59 +01:00
lukas d6fb16bb74 Make navigation more compact 2023-11-02 09:52:42 +01:00
lukas 71b90b8202 Add stats link, year selector 2023-11-02 09:20:09 +01:00
lukas 3ee36932c3 Limit stats of single year correctly 2023-11-02 09:17:08 +01:00
lukas 391fcc79a8 Version 1.2.0
continuous-integration/drone/push Build is passing
2023-11-01 20:35:58 +01:00
lukas 57d4fd7212 Add yearly stats page
Fixes #15
2023-11-01 20:35:52 +01:00
lukas a5b2854bf6 Add a button to start session from game overview
continuous-integration/drone/push Build is passing
Fix #62
2023-10-13 19:22:43 +02:00
lukas 518c0ecd56 Add more time tests for fractional numbers
continuous-integration/drone/push Build is passing
2023-10-13 17:01:33 +02:00
lukas a6cd7a3430 Do not format as float if no precision specified
continuous-integration/drone/push Build is passing
2023-10-13 16:58:12 +02:00
lukas dba8414fd9 Version 1.1.2
continuous-integration/drone/push Build is failing
2023-10-13 16:33:55 +02:00
lukas 0e2113eefd Display durations in a consistent manner
Fixes #61
2023-10-13 16:32:12 +02:00
lukas c4b0347f3b Version 1.1.1
continuous-integration/drone/push Build is passing
2023-10-09 20:56:23 +02:00
lukas c6ed21167c Remove debugging cruft from container 2023-10-09 20:56:13 +02:00
lukas 4ce15c44fc Add edit buttons to game overview, notes 2023-10-09 20:55:31 +02:00
lukas c814b4c2cb Version 1.1.0
continuous-integration/drone/push Build is passing
2023-10-09 00:04:46 +02:00
lukas 11b9c602de Improve game overview
- add counts for each section
- add average hours per session
2023-10-09 00:00:45 +02:00
lukas 9a332593f4 Fix playtime range not working with manual session
continuous-integration/drone/push Build is passing
Fixes #55
2023-10-08 21:13:32 +02:00
lukas 22935721ca Remove unused chart functionality
continuous-integration/drone/push Build is passing
2023-10-08 21:08:03 +02:00
lukas a2ecdcf44a ci: automatically redeploy container
continuous-integration/drone/push Build was killed
2023-10-04 20:21:00 +02:00
lukas 3c958c4a13 Organize changelog better
continuous-integration/drone/push Build is passing
2023-10-04 18:14:21 +02:00
lukas 3db1724e22 Fix session being wrongly considered in progress
continuous-integration/drone/push Build is passing
Fixes #58
2023-10-04 18:12:13 +02:00
lukas d2a9630b04 Fix date range on game overview
continuous-integration/drone/push Build is passing
2023-10-01 21:51:32 +02:00
lukas e3ee832d3f Adjust game edit URL
continuous-integration/drone/push Build is passing
2023-10-01 21:28:20 +02:00
lukas 7467e2732d Add game overview page
Fixes #8
2023-10-01 21:28:02 +02:00
lukas 787ee8640f Try to shutdown container gracefully and faster
continuous-integration/drone/push Build is passing
2023-10-01 19:57:15 +02:00
lukas ab41222f3c Switch fonts to WOFF2 2023-10-01 19:53:07 +02:00
lukas 29bf3b1946 Further improve session list
continuous-integration/drone/push Build is passing
2023-09-30 19:44:35 +02:00
lukas 3f7ccea2e2 fix tailwind config to only scan relevant files 2023-09-30 18:13:50 +02:00
lukas b5ffb3586b Update base.css 2023-09-30 16:25:19 +02:00
lukas 26d57a238e Update .vscode 2023-09-30 16:25:05 +02:00
lukas 2d5ad3182c Update CSS 2023-09-30 16:24:54 +02:00
lukas 49cc3ea0cc Improve session list
continuous-integration/drone/push Build is passing
Fixes #53
2023-09-30 15:55:38 +02:00
lukas 440e1cfb71 Focus important fields on forms
continuous-integration/drone/push Build is passing
2023-09-30 12:40:02 +02:00
lukas 1cbd8c5c55 Update generated CSS
continuous-integration/drone/push Build is passing
2023-09-20 19:06:04 +02:00
lukas bc81a0ee8e Add hacky way to not reload page when starting/ending session
continuous-integration/drone/push Build is passing
Partially fixes #52
2023-09-20 18:54:54 +02:00
lukas c5653977ff Add time copy button, improve session editing
continuous-integration/drone/push Build is passing
2023-09-18 20:21:05 +02:00
lukas f151730ab6 Fix bug when filtering only manual sessions (#51)
continuous-integration/drone/push Build is passing
2023-09-18 19:37:54 +02:00
lukas f469a67d94 add robots.txt
continuous-integration/drone/push Build is passing
2023-09-17 17:58:22 +02:00
lukas 104ffc9d03 disable deleting sessions in code
continuous-integration/drone/push Build is passing
2023-09-17 17:17:22 +02:00
lukas a4b13eb247 allow admin in prod 2/2
continuous-integration/drone/push Build is passing
2023-09-17 14:12:23 +02:00
lukas 2307fac83a allow admin in prod
continuous-integration/drone/push Build is passing
2023-09-17 14:03:46 +02:00
lukas 6b52c0d4c4 disable deleting sessions
continuous-integration/drone/push Build is passing
2023-09-17 13:28:15 +02:00
lukas ff5d8c215d install dev dependecies
continuous-integration/drone/push Build is passing
2023-09-16 18:24:10 +02:00
lukas cdb3b89b08 do not filter out refunded games when adding session
continuous-integration/drone/push Build is passing
2023-09-16 18:18:03 +02:00
lukas ffa8198540 allow django admit 2023-09-16 18:17:36 +02:00
lukas 0b7da3550c Change recent session view to current year
continuous-integration/drone/push Build is passing
2023-09-14 18:49:16 +02:00
lukas e1655d6cfa input datetime-local needs day to also be two digits
continuous-integration/drone/push Build is passing
2023-03-02 21:33:07 +01:00
lukas 29c41865d0 Exclude refunded purchases from start session form
continuous-integration/drone/push Build is passing
2023-03-01 22:07:49 +01:00
lukas d21b461726 Update styles
continuous-integration/drone/push Build is passing
2023-02-21 23:51:07 +01:00
lukas 95489cfb78 Add .editorconfig 2023-02-21 23:50:09 +01:00
lukas fa4f1c4810 Improve forms, add helper buttons on add session form 2023-02-21 23:49:57 +01:00
lukas 366c25a1ff Register Edition, Device in the admin UI 2023-02-20 22:01:20 +01:00
192 changed files with 26001 additions and 7992 deletions
+25
View File
@@ -0,0 +1,25 @@
{
"name": "Django Time Tracker",
"dockerFile": "../devcontainer.Dockerfile",
"customizations": {
"vscode": {
"settings": {
"python.pythonPath": "/usr/local/bin/python",
"python.defaultInterpreterPath": "/usr/local/bin/python",
"terminal.integrated.defaultProfile.linux": "bash"
},
"extensions": [
"ms-python.python",
"ms-python.debugpy",
"ms-python.vscode-pylance",
"ms-azuretools.vscode-docker",
"batisteo.vscode-django",
"charliermarsh.ruff",
"bradlc.vscode-tailwindcss",
"EditorConfig.EditorConfig"
]
}
},
"forwardPorts": [8000],
"postCreateCommand": "poetry install && poetry run python manage.py migrate && npm install && make dev",
}
+9 -1
View File
@@ -5,4 +5,12 @@
.venv
.vscode
node_modules
src/timetracker/static/*
static
.drone.yml
.editorconfig
.gitignore
CHANGELOG.md
db.sqlite3
docker-compose*
Dockerfile
Makefile
+20 -4
View File
@@ -5,23 +5,28 @@ name: default
steps:
- name: test
image: python:3.10
image: python:3.12
commands:
- python -m pip install poetry
- poetry install
- poetry env info
- poetry run python manage.py migrate
- poetry run pytest
- name: build container (prod)
- name: build-prod
image: plugins/docker
settings:
repo: registry.kucharczyk.xyz/timetracker
tags:
- latest
- 1.1.0
depends_on:
- "test"
when:
branch:
- main
- name: build container (non-prod)
- name: build-non-prod
image: plugins/docker
settings:
repo: registry.kucharczyk.xyz/timetracker
@@ -32,9 +37,20 @@ steps:
branch:
exclude:
- main
depends_on:
- "test"
- name: redeploy on portainer
image: plugins/webhook
settings:
urls:
from_secret: PORTAINER_TIMETRACKER_WEBHOOK_URL
depends_on:
- "build-prod"
trigger:
event:
- push
- cron
- cron
+20
View File
@@ -0,0 +1,20 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
[*.{js,py}]
charset = utf-8
# 4 space indentation
[*.py]
indent_style = space
indent_size = 4
[**/*.js]
indent_style = space
indent_size = 2
[*.html]
insert_final_newline = false
+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
+1
View File
@@ -0,0 +1 @@
use nix
+50
View File
@@ -0,0 +1,50 @@
name: Django CI/CD
on:
push:
paths-ignore: [ 'README.md' ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: false
python-version: "3.14"
- name: Install dependencies
run: uv sync --frozen
- name: Run Migrations
run: uv run python manage.py migrate
- name: Run Tests
run: uv run --with pytest-django pytest
build-and-push:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Set Version
run: echo "VERSION_NUMBER=1.7.0" >> $GITHUB_ENV
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
registry.kucharczyk.xyz/timetracker:latest
registry.kucharczyk.xyz/timetracker:${{ env.VERSION_NUMBER }}
# cache-from: type=gha
# cache-to: type=gha,mode=max
+8 -2
View File
@@ -1,9 +1,15 @@
__pycache__
.mypy_cache
.pytest_cache
.venv
.venv/
node_modules
package-lock.json
pnpm-lock.yaml
db.sqlite3
data/
/static/
dist/
dist/
.DS_Store
.python-version
.direnv
.hermes/
+11
View File
@@ -0,0 +1,11 @@
{
"recommendations": [
"charliermarsh.ruff",
"ms-python.python",
"ms-python.vscode-pylance",
"ms-python.debugpy",
"batisteo.vscode-django",
"bradlc.vscode-tailwindcss",
"EditorConfig.EditorConfig"
]
}
+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"
}
]
}
+27 -2
View File
@@ -4,5 +4,30 @@
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true,
"python.analysis.typeCheckingMode": "basic"
}
"python.analysis.typeCheckingMode": "strict",
"[python]": {
"editor.defaultFormatter": "charliermarsh.ruff",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.organizeImports": "explicit"
},
},
"ruff.path": ["/nix/store/jaibb3v0rrnlw5ib54qqq3452yhp1xcb-ruff-0.5.7/bin/ruff"],
"tailwind-fold.supportedLanguages": [
"html",
"typescriptreact",
"javascriptreact",
"typescript",
"javascript",
"vue-html",
"vue",
"php",
"markdown",
"coffeescript",
"svelte",
"astro",
"erb",
"django-html"
]
}
+208
View File
@@ -1,3 +1,211 @@
## 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
## 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
* Require login by default
* Add stats for dropped purchases, monthly playtimes
* Allow deleting purchases
* Add all-time stats
* Manage purchases
* Automatically convert purchase prices
* Add emulated property to sessions
* Add today's and last 7 days playtime stats to navbar
### Improved
* mark refunded purchases red on game overview
* increase session count on game overview when starting a new session
* game overview:
* sort purchases also by date purchased (on top of date released)
* improve header format, make it more appealing
* ignore manual sessions when calculating session average
* stats: improve purchase name consistency
* session list: use display name instead of sort name
* unify the appearance of game links, and make them expand to full size on hover
### Fixed
* Fix title not being displayed on the Recent sessions page
* Avoid errors when displaying game overview with zero sessions
## 1.5.2 / 2024-01-14 21:27+01:00
## Improved
* game overview:
* improve how editions and purchases are displayed
* make it possible to end session from overview
* add purchase: only allow choosing purchases of selected edition
* session list:
* starting and ending sessions is much faster/doest not reload the page
* listing sessions is much faster
## 1.5.1 / 2023-11-14 21:10+01:00
## Improved
* Disallow choosing non-game purchase as related purchase
* Improve display of purchases
## 1.5.0 / 2023-11-14 19:27+01:00
## New
* Add stat for finished this year's games
* Add purchase types:
* Game (previously all of them were this type)
* DLC
* Season Pass
* Battle Pass
## Fixed
* Order purchases by date on game view
## 1.4.0 / 2023-11-09 21:01+01:00
### New
* More fields are now optional. This is to make it easier to add new items in bulk.
* Game: Wikidata ID
* Edition: Platform, Year
* Purchase: Platform
* Platform: Group
* Session: Device
* New fields:
* Game: Year Released
* To record original year of release
* Upon migration, this will be set to a year of any of the game's edition that has it set
* Purchase: Date Finished
* Editions are now unique combination of name and platform
* Add more stats:
* All finished games
* All finished 2023 games
* All finished games that were purchased this year
* Sessions (count)
* Days played
* Finished (count)
* Unfinished (count)
* Refunded (count)
* Backlog Decrease (count)
* New workflow:
* Adding Game, Edition, Purchase, and Session in a row is now much faster
### Improved
* game overview: simplify playtime range display
* new session: order devices alphabetically
* ignore English articles when sorting names
* added a new sort_name field that gets automatically created
* automatically fill certain values in forms:
* new game: name and sort name after typing
* new edition: name, sort name, and year when selecting game
* new purchase: platform when selecting edition
## 1.3.0 / 2023-11-05 15:09+01:00
### New
* Add Stats to the main navigation
* Allow selecting year on the Stats page
### Improved
* Make some pages redirect back instead to session list
### Improved
* Make navigation more compact
### Fixed
* Correctly limit sessions to a single year for stats
## 1.2.0 / 2023-11-01 20:18+01:00
### New
* Add yearly stats page (https://git.kucharczyk.xyz/lukas/timetracker/issues/15)
### Enhancements
* Add a button to start session from game overview
## 1.1.2 / 2023-10-13 16:30+02:00
### Enhancements
* Durations are formatted in a consisent manner across all pages
### Fixes
* Game Overview: display duration when >1 hour instead of displaying 0
## 1.1.1 / 2023-10-09 20:52+02:00
### New
* Add notes section to game overview
### Enhancements
* Make it possible to add any data on the game overview page
## 1.1.0 / 2023-10-09 00:01+02:00
### New
* Add game overview page (https://git.kucharczyk.xyz/lukas/timetracker/issues/8)
* Add helper buttons next to datime fields
* Add copy button on Add session page to copy times between fields
* Change fonts to IBM Plex
### Enhancements
* Improve form appearance
* Focus important fields on forms
* Use the same form when editing a session as when adding a session
* Change recent session view to current year instead of last 30 days
* Add a hacky way not to reload a page when starting or ending a session (https://git.kucharczyk.xyz/lukas/timetracker/issues/52)
* Improve session listing (https://git.kucharczyk.xyz/lukas/timetracker/issues/53)
### Fixes
* Fix session being wrongly considered in progress if it had a certain amount of manual hours (https://git.kucharczyk.xyz/lukas/timetracker/issues/58)
* Fix bug when filtering only manual sessions (https://git.kucharczyk.xyz/lukas/timetracker/issues/51)
## 1.0.3 / 2023-02-20 17:16+01:00
* Add wikidata ID and year for editions
+170
View File
@@ -0,0 +1,170 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
| Task | Command |
|------|---------|
| Install dependencies | `make init` (installs Python via uv + npm packages, loads platform fixtures) |
| Development server | `make dev` (runs Django runserver + Tailwind CSS watcher) |
| Production-like dev | `make dev-prod` (Caddy + Gunicorn/Uvicorn + Django-Q cluster) |
| Run tests | `make test` (or `uv run --with pytest-django pytest`) |
| Make migrations | `make makemigrations` |
| Apply migrations | `make migrate` |
| CSS (Tailwind) | `make css` |
| Django shell | `make shell` |
| Create superuser | `make createsuperuser` |
| Format Python | `make format` (or `uv run ruff format`) |
| Lint Python | `make lint` (or `uv run ruff check`) |
| Auto-fix lint | `make lint-fix` (`ruff check --fix`) |
| Lint + format check + tests | `make check` (CI-style aggregate) |
| Sync uv.lock | `uv sync` (after editing pyproject.toml) |
| Load platform fixtures | `make loadplatforms` |
| Load sample data | `make loadsample` |
| Dump games data | `make dumpgames` |
## Architecture
A Django 6+ monolith (v1.7.0) with a single app (`games/`) for tracking video game purchases, play sessions, and statistics. Uses HTMX for interactivity with a pure-Python server-side component system, plus a Django Ninja REST API.
### Directory layout
```
games/ — Django app: models, views, templates, forms, signals, tasks, API, filters
common/ — Shared utilities: time formatting, component system, criteria, layout, icons
timetracker/ — Django project: settings, URL root, ASGI/WSGI
tests/ — Pytest tests
contrib/ — One-off scripts (exchange rate import)
docs/ — Additional documentation
```
### Models (in `games/models.py`)
- **Game** — `name`, `platform` (FK), `status` (u/p/f/r/a), `mastered`, `playtime` (DurationField updated via signal), `year_released`, `sort_name`, `wikidata`
- **Platform** — `name`, `group`, `icon` (slug, auto-generated from name)
- **Purchase** — ownership type, prices, currency conversion (`converted_price`, `price_per_game` is a `GeneratedField`), links to Game via M2M. `num_purchases` counts linked games. DLC/SeasonPass/BattlePass must have a `related_purchase`
- **Session** — `timestamp_start`/`timestamp_end`, `duration_manual`, `device` (FK), `note`, `emulated`. `duration_calculated` and `duration_total` are `GeneratedField`s (cannot be written directly)
- **Device** — `name`, `type` (PC/Console/Handheld/Mobile/SBC/Unknown)
- **PlayEvent** — marks when a game was started/finished (separate from Sessions), `days_to_finish` is a `GeneratedField`
- **ExchangeRate** — cached FX rates per currency pair per year
- **GameStatusChange** — audit log of status transitions, ordered by `-timestamp`
- **FilterPreset** — saved filter configuration; `mode` (games/sessions/purchases/playevents), `find_filter`, `object_filter`, `ui_options` (all JSON). Follows Stash's SavedFilter pattern
**Sentinel objects**: `get_sentinel_platform()` returns an "Unspecified" platform used when a Game has no platform. A similar sentinel Device ("Unknown") is created when a Session has no device.
**GeneratedField constraint**: `duration_calculated`, `duration_total`, `price_per_game`, `days_to_finish` are computed by the database and cannot be written from application code.
### Key patterns
**Layout system** (`common/layout.py`): Views call `render_page(request, content, title=...)` instead of Django's `render()`. This assembles a full HTML document via `Page()` — analogous to FastHTML's `fast_app()`. `Page()` handles the `<head>`, navbar, toast container, JS includes, and FOUC-prevention script. The navbar shows today's playtime and last-7-days playtime from the `model_counts` context processor.
**Component system** (`common/components/`): Pure-Python HTML builders, split into four submodules re-exported via `common/components/__init__.py`:
- **`core.py`** — `Component(tag_name, attributes, children)`: the fundamental builder. `_render_element()` is `@lru_cache`-memoized (4096 entries, always active). Attribute values are always HTML-escaped; children are escaped unless they are `SafeText`. `randomid()` generates stable hash-based IDs.
- **`primitives.py`** — Generic HTML: `A()`, `Button()` (with color/size/icon params), `ButtonGroup()`, `Div()`, `Span()`, `Label()`, `Input()`, `Icon()`, `Popover()`, `PopoverTruncated()`, `SearchField()`, `H1()`, `Modal()`, `SimpleTable()`, `TableRow()`, `TableTd()`, `TableHeader()`, `paginated_table_content()`, `AddForm()`, `Pill()`, `CsrfInput()`, `ModuleScript()`
- **`domain.py`** — Domain-specific: `GameLink()`, `GameStatus()` (colored dot + label), `GameStatusSelector()` (Alpine.js PATCH dropdown), `SessionDeviceSelector()` (Alpine.js PATCH dropdown), `LinkedPurchase()`, `NameWithIcon()`, `PriceConverted()`, `PurchasePrice()`
- **`filters.py`** — Filter UI: `FilterBar()`, `SessionFilterBar()`, `PurchaseFilterBar()` (built from `FilterSelect` widgets)
- **`search_select.py`** — `SearchSelect()` (form combobox) + `FilterSelect()` (include/exclude filter combobox with pinned Any/None modifiers) + `SearchSelectOption`, all built on a shared `_combobox_shell`; wired by `games/static/js/search_select.js`
**Filter system** (`games/filters.py` + `common/criteria.py`): Stash-inspired structured filtering.
- `common/criteria.py` defines typed criterion classes: `StringCriterion`, `IntCriterion`, `FloatCriterion`, `DateCriterion`, `BoolCriterion`, `MultiCriterion`, `ChoiceCriterion`. Each has a `modifier` (`Modifier` enum: EQUALS, NOT_EQUALS, INCLUDES, EXCLUDES, GREATER_THAN, LESS_THAN, BETWEEN, IS_NULL, etc.) and a `to_q(field_name)` method.
- `OperatorFilter` base class provides AND/OR/NOT sub-filter composition and JSON serialization/deserialization.
- `games/filters.py` defines `GameFilter`, `SessionFilter`, `PurchaseFilter` (all `@dataclass` subclasses of `OperatorFilter`) and `FindFilter` (sort/pagination). Filters serialize to/from JSON and are passed in the `?filter=` query parameter.
- `parse_game_filter()`, `parse_session_filter()`, `parse_purchase_filter()` helpers deserialize from a JSON string.
- `FilterPreset` model stores named filter configurations that users can save and reload.
**Views** (`games/views/`): Function-based views decorated with `@login_required`. Organized by domain entity:
- `session.py`, `game.py`, `purchase.py`, `playevent.py`, `platform.py`, `device.py`, `statuschange.py` — CRUD for each entity
- `general.py``stats()`, `stats_alltime()`, `index()`, `model_counts` context processor, `global_current_year` context processor, `use_custom_redirect` decorator (redirects to `request.session["return_path"]` if set)
- `stats_data.py``compute_stats(year)` returns a `StatsData` TypedDict; pure computation, no HTTP
- `stats_content.py` — renders stats page content from a `StatsData` dict
- `filter_presets.py``list_presets`, `save_preset`, `delete_preset`, `load_preset`
- `auth.py` — custom `LoginView` subclassing Django's auth view, renders login page via `render_page()`
**Signals** (`games/signals.py`):
- `pre_save` on Purchase: snapshots old price/currency for change detection
- `post_save` on Purchase: sets `needs_price_update` if price/currency changed
- `m2m_changed` on Purchase.games: updates `num_purchases` count
- `pre_delete` on Game: decrements `num_purchases` on related Purchases (deletes Purchase if count reaches 0)
- `post_save/post_delete` on Session: recalculates `Game.playtime` from session aggregate
- `pre_save` on Game: creates `GameStatusChange` audit records when `status` changes
**Background tasks**: django-q2 cluster runs `games.tasks.convert_prices()` on a schedule to fetch exchange rates from `cdn.jsdelivr.net/npm/@fawazahmed0/currency-api` and convert purchase prices to CZK.
**HTMX toast middleware** (`games/htmx_middleware.py`): Converts Django messages into `HX-Trigger` headers with `show-toast` event. Skips if `HX-Redirect` is present. Toast rendering is handled client-side by Alpine.js (`games/static/js/toast.js`).
**REST API** (`games/api.py`): Django Ninja with routers mounted at `/api/`:
- `GET /api/games/search` — search games for autocomplete
- `PATCH /api/games/{id}/status` — update game status
- `GET/POST /api/playevent/` — list/create play events
- `GET/PATCH/DELETE /api/playevent/{id}` — get/update/delete play event
- `PATCH /api/session/{id}/device` — update session device
### Templates
Only a small number of HTML templates remain (platform icon snippets and partials). The bulk of the UI is built via Python components. Template files:
- `games/templates/icons/<slug>.html` — SVG icon snippets (loaded by `common/icons.py` via `get_icon()`)
- `games/templates/` — minimal partials for HTMX responses where needed
### Frontend stack
- **HTMX** (`games/static/js/htmx.min.js`) — partial page updates
- **Alpine.js** (CDN) — reactive dropdowns (`GameStatusSelector`, `SessionDeviceSelector`), toast store
- **Flowbite** (CDN) — navbar collapse, dropdown toggles
- **Tailwind CSS** — utility classes, compiled from `common/input.css``games/static/base.css`
- **Custom JS** in `games/static/js/`:
- `toast.js` — Alpine.js toast store (listens for `show-toast` HTMX event)
- `search_select.js` — SearchSelect/FilterSelect widgets (search-as-you-type, pills, include/exclude filter mode)
- `utils.js` — shared helpers (e.g., `fetchWithHtmxTriggers`)
### Deployment
Docker-based: multi-stage Dockerfile (uv builder → slim runtime), Caddy as reverse proxy on port 8000, Gunicorn with UvicornWorker (ASGI), Supervisor to manage Caddy + Gunicorn + django-q2. `make dev-prod` mimics production locally. CI/CD via GitHub Actions (`.github/workflows/build-docker.yml`): builds Docker image; Drone CI (`.drone.yml`) also present for deployments via Portainer webhook.
### Database
SQLite with WAL journal mode. Connection timeout 20s. The `DATA_DIR` env var controls the database file location. Migrations live in `games/migrations/`. There are `GeneratedField`s on the models — these are computed by the database engine and cannot be written from application code.
### Configuration
- `DEBUG` is `True` unless `PROD` env var is set
- `TIME_ZONE` defaults to `Europe/Prague` in debug, otherwise reads `TZ` env var (default `UTC`)
- Django Admin, Debug Toolbar, and `django_extensions` are only available in `DEBUG` mode
- `CSRF_TRUSTED_ORIGINS` is parsed from a comma-separated env var
- `DATA_DIR` env var sets the SQLite database location (defaults to `BASE_DIR`)
- django-q2 cluster: 1 worker, 60s timeout, 120s retry, ORM broker
### Testing
Tests live in `tests/`. Run with `make test` or `uv run --with pytest-django pytest`. Key test files:
- `test_components.py` — component rendering
- `test_filter_bars.py`, `test_filter_helpers.py`, `test_filters.py` — filter system
- `test_paths_return_200.py` — smoke test all list/view URLs
- `test_rendered_pages.py` — HTML output of pages
- `test_signals.py` — signal side-effects (playtime recalc, status change audit, etc.)
- `test_stats.py` — stats computation
- `test_streak.py`, `test_time.py`, `test_session_formatting.py` — utilities
- `test_middleware_integration.py`, `test_toast_middleware.py` — HTMX middleware
- `test_price_update.py` — currency conversion signals
- `test_search_select.py` — SearchSelect component
Pytest settings are in `pyproject.toml` under `[tool.pytest.ini_options]` (`DJANGO_SETTINGS_MODULE = "timetracker.settings"`).
## Conventions for AI assistants
- **Never write to `GeneratedField`s** (`duration_calculated`, `duration_total`, `price_per_game`, `days_to_finish`). They are computed by the database.
- **Name variables with complete words** — readable, unabbreviated identifiers in both Python and JavaScript (e.g. `template` not `tpl`, `event` not `e`, `element` not `el`, `removeButton` not `removeBtn`, `option`/`value` not single letters in loops). This applies to new code and to code you touch.
- **Use `render_page()` not `render()`** for all full-page HTTP responses. Import from `common.layout`.
- **Build UI with Python components** from `common.components`, not raw HTML strings or Django templates. `SafeText` children pass through unescaped; plain strings are auto-escaped.
- **Prefer the named element primitives over raw `Component(tag_name=…)`** — use `Div()`, `Span()`, `Input()`, `Label()`, `Template()`, etc. from `common.components` instead of `Component(tag_name="div")`. Reach for `Component` directly only when no primitive fits (e.g. a bare, custom-styled `<button>` where the opinionated `Button()` would inject unwanted classes). Add a new primitive rather than repeating an inline `Component` for a standard tag.
- **Filter views** accept `?filter=<JSON>` (structured) and fall back to `?search_string=` (free-text). New filter criteria go in `games/filters.py`; new criterion types go in `common/criteria.py`.
- **Signals handle side-effects** — do not manually recalculate `Game.playtime` or `Purchase.num_purchases`; the signals in `games/signals.py` do this on save/delete.
- **Button colors**: `blue` (primary action), `red` (destructive), `gray` (secondary), `green` (positive). Icon buttons use `icon=True`.
- **Inline Alpine.js** is used for client-side reactivity in domain components (`GameStatusSelector`, `SessionDeviceSelector`). The pattern is `x-data="{...}"` with `fetchWithHtmxTriggers()` for PATCH API calls.
- **Platform icons** are SVG snippets in `games/templates/icons/<slug>.html`. Add new ones there and reference them by slug in `Platform.icon`.
- **Name compound types explicitly** — if a `tuple`, `dict`, or other compound value is passed between functions or appears in multiple signatures, give it a named type (`TypedDict`, `NamedTuple`, or a `type` alias) rather than repeating the structural annotation. This applies even to small types used in only a few places; the name carries intent that the structure cannot. Examples: `LabeledOption = tuple[str, str]` instead of repeating `tuple[str, str]` for (value, label) pairs; `RangeValues(min, max)` instead of `tuple[str, str]` for range bounds.
+12 -11
View File
@@ -1,14 +1,15 @@
{
auto_https off
admin off
{
auto_https off
}
:8000 {
handle_path /static/* {
root * /usr/share/caddy
file_server
}
handle {
reverse_proxy backend:8001
}
}
handle_path /static/* {
root * /home/timetracker/app/static
file_server
}
handle /robots.txt {
root * /home/timetracker/app/games/static
file_server
}
reverse_proxy localhost:8001
}
+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 -27
View File
@@ -1,34 +1,55 @@
FROM node as css
WORKDIR /app
COPY . /app
RUN npm install && \
npx tailwindcss -i ./common/input.css -o ./static/base.css --minify
FROM ghcr.io/astral-sh/uv:python3.14-bookworm-slim AS builder
FROM python:3.10.9-slim-bullseye
ENV UV_LINK_MODE=copy \
UV_COMPILE_BYTECODE=1 \
PYTHONUNBUFFERED=1
ENV VERSION_NUMBER 1.0.3
ENV PROD 1
ENV PYTHONUNBUFFERED=1
RUN apt update && \
apt install -y \
bash \
vim \
curl && \
rm -rf /var/lib/apt/lists/*
RUN useradd -m --uid 1000 timetracker
WORKDIR /home/timetracker/app
COPY . /home/timetracker/app/
RUN chown -R timetracker:timetracker /home/timetracker/app
COPY --from=css ./app/static/base.css /home/timetracker/app/static/base.css
COPY entrypoint.sh /
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --frozen --no-install-project --no-dev
COPY . .
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev
FROM python:3.14-slim-bookworm
ENV PROD=1 \
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
USER timetracker
ENV PATH="$PATH:/home/timetracker/.local/bin"
RUN pip install --no-cache-dir poetry
RUN poetry install --without dev
ENV VERSION_NUMBER=1.7.0
EXPOSE 8000
CMD [ "/entrypoint.sh" ]
ENTRYPOINT ["/entrypoint.sh"]
+53 -24
View File
@@ -1,62 +1,91 @@
all: css migrate
initialize: npm css migrate sethookdir loadplatforms
initialize: npm css migrate loadplatforms
HTMLFILES := $(shell find games/templates -type f)
PYTHON_VERSION = 3.12
npm:
npm install
pnpm install
css: common/input.css
npx tailwindcss -i ./common/input.css -o ./games/static/base.css
css-dev: css
npx tailwindcss -i ./common/input.css -o ./games/static/base.css --watch
pnpm tailwindcss -i ./common/input.css -o ./games/static/base.css
makemigrations:
poetry run python manage.py makemigrations
uv run python manage.py makemigrations
migrate: makemigrations
poetry run python manage.py migrate
uv run python manage.py migrate
init:
uv python install $(PYTHON_VERSION)
uv sync
pnpm install
$(MAKE) loadplatforms
dev:
@pnpm concurrently \
--names "Django,Tailwind" \
--prefix-colors "blue,green" \
"uv run python -Wa manage.py runserver" \
"pnpm tailwindcss -i ./common/input.css -o ./games/static/base.css --watch"
dev: migrate
poetry run python manage.py runserver
caddy:
caddy run --watch
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:
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:
poetry run python manage.py loaddata platforms.yaml
uv run python manage.py loaddata platforms.yaml
loadall:
poetry run python manage.py loaddata data.yaml
uv run python manage.py loaddata data.yaml
loadsample:
poetry run python manage.py loaddata sample.yaml
uv run python manage.py loaddata sample.yaml
createsuperuser:
poetry run python manage.py createsuperuser
uv run python manage.py createsuperuser
shell:
poetry run python manage.py shell
uv run python manage.py shell
collectstatic:
poetry run python manage.py collectstatic --clear --no-input
uv run python manage.py collectstatic --clear --no-input
poetry.lock: pyproject.toml
poetry install
uv.lock: pyproject.toml
uv sync
test: poetry.lock
poetry run pytest
test: uv.lock
uv run --with pytest-django pytest
test-e2e: uv.lock
uv run pytest e2e/
lint:
uv run ruff check
lint-fix:
uv run ruff check --fix
format:
uv run ruff format
format-check:
uv run ruff format --check
check: lint format-check test
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:
rm -r static/*
+13 -1
View File
@@ -1,3 +1,15 @@
# Timetracker
A simple game catalogue and play session tracker.
A simple game catalogue and play session tracker.
# Development
The project uses `uv` to manage Python versions and dependencies.
Simply run:
```
make init
```
This installs the correct Python version, syncs all dependencies, and installs npm packages.
Afterwards, you can start the development server using `make dev`.
+124
View File
@@ -0,0 +1,124 @@
"""Server-side HTML component library.
Split into core / primitives / domain / filters submodules; this package
re-exports the public API so ``from common.components import X`` keeps working.
"""
from common.utils import truncate
from common.components.core import (
Component,
HTMLAttribute,
HTMLTag,
_render_element,
randomid,
)
from common.components.primitives import (
A,
AddForm,
Button,
ButtonGroup,
CsrfInput,
Div,
ExternalScript,
H1,
Icon,
Input,
Modal,
ModuleScript,
Pill,
Popover,
PopoverTruncated,
SearchField,
SimpleTable,
Span,
Label,
TableHeader,
TableRow,
TableTd,
Template,
YearPicker,
paginated_table_content,
)
from common.components.search_select import (
DEFAULT_PREFETCH,
FilterSelect,
LabeledOption,
SearchSelect,
SearchSelectOption,
searchselect_selected,
)
from common.components.domain import (
GameLink,
GameStatus,
GameStatusSelector,
LinkedPurchase,
NameWithIcon,
PriceConverted,
PurchasePrice,
SessionDeviceSelector,
_resolve_name_with_icon,
)
from common.components.filters import (
FilterBar,
PurchaseFilterBar,
SessionFilterBar,
DeviceFilterBar,
PlatformFilterBar,
PlayEventFilterBar,
)
__all__ = [
"truncate",
"Component",
"HTMLAttribute",
"HTMLTag",
"_render_element",
"randomid",
"A",
"AddForm",
"Button",
"ButtonGroup",
"CsrfInput",
"Div",
"ExternalScript",
"H1",
"Icon",
"Input",
"Modal",
"ModuleScript",
"Pill",
"Popover",
"PopoverTruncated",
"SearchField",
"DEFAULT_PREFETCH",
"FilterSelect",
"LabeledOption",
"SearchSelect",
"SearchSelectOption",
"searchselect_selected",
"SimpleTable",
"Span",
"Label",
"TableHeader",
"TableRow",
"TableTd",
"Template",
"YearPicker",
"paginated_table_content",
"GameLink",
"GameStatus",
"GameStatusSelector",
"LinkedPurchase",
"NameWithIcon",
"PriceConverted",
"PurchasePrice",
"SessionDeviceSelector",
"_resolve_name_with_icon",
"FilterBar",
"PurchaseFilterBar",
"SessionFilterBar",
"DeviceFilterBar",
"PlatformFilterBar",
"PlayEventFilterBar",
]
+74
View File
@@ -0,0 +1,74 @@
"""Escaping core: the Component builder and its memoised renderer."""
import hashlib
from functools import lru_cache
from django.utils.html import escape
from django.utils.safestring import SafeText, mark_safe
HTMLAttribute = tuple[str, str | int | bool]
HTMLTag = str
@lru_cache(maxsize=4096)
def _render_element(
tag_name: str,
attrs_key: tuple[tuple[str, str], ...],
children_key: tuple[tuple[str, bool], ...],
) -> str:
"""Pure, memoized HTML builder behind `Component`.
Inputs are fully hashable and fully determine the output, so identical
elements are rendered once. `attrs_key` is (name, stringified value) pairs
(attribute values are always escaped). `children_key` is (child, is_safe)
pairs: SafeText children pass through, plain strings are escaped. The
`is_safe` flag is part of the key on purpose — otherwise a safe ``"<b>"``
and an unsafe ``"<b>"`` (equal as strings) would collide and one would
render with the wrong escaping.
"""
children_blob = "\n".join(
child if is_safe else escape(child) for child, is_safe in children_key
)
if attrs_key:
attributes_blob = " " + " ".join(
f'{name}="{escape(value)}"' for name, value in attrs_key
)
else:
attributes_blob = ""
return f"<{tag_name}{attributes_blob}>{children_blob}</{tag_name}>"
def Component(
attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None,
tag_name: str = "",
) -> SafeText:
"""Render an HTML element. Attribute values are always escaped; children are
escaped unless they are `SafeText` (so nested components pass through),
preventing accidental HTML injection. Rendering is memoized via
`_render_element`."""
attributes = attributes or []
children = children or []
if not tag_name:
raise ValueError("tag_name is required.")
if isinstance(children, str):
children = [children]
attrs_key = tuple((name, str(value)) for name, value in attributes)
children_key = tuple((child, isinstance(child, SafeText)) for child in children)
return mark_safe(_render_element(tag_name, attrs_key, children_key))
def randomid(seed: str = "", content: str = "", length: int = 10) -> str:
if not seed and not content:
return seed
hash_input = f"{seed}:{content}" if seed else content
content_hash = hashlib.sha1(hash_input.encode()).hexdigest()
base = (
content_hash[:length]
if not seed
else content_hash[: max(0, length - len(seed))]
)
return seed + base
+342
View File
@@ -0,0 +1,342 @@
"""Domain components for games / purchases / sessions."""
from typing import Any
from django.template.defaultfilters import floatformat
from django.urls import reverse
from django.utils.safestring import SafeText, mark_safe
from common.components.core import Component, HTMLTag
from common.components.primitives import (
A,
Div,
Icon,
Popover,
PopoverTruncated,
Span,
)
from games.models import Game, Purchase, Session
def GameLink(
game_id: int,
name: str = "",
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
"""Link to a game's detail page. Uses children (slot) if provided, otherwise name."""
from django.urls import reverse
children = children or []
display = children if children else [name]
link = reverse("games:view_game", args=[game_id])
return Span(
attributes=[("class", "truncate-container")],
children=[
Component(
tag_name="a",
attributes=[
("href", link),
("class", "underline decoration-slate-500 sm:decoration-2"),
],
children=display if isinstance(display, list) else [display],
),
],
)
_STATUS_COLORS = {
"u": "bg-gray-500",
"p": "bg-orange-400",
"f": "bg-green-500",
"a": "bg-red-500",
"r": "bg-purple-500",
}
def GameStatus(
children: list[HTMLTag] | HTMLTag | None = None,
status: str = "u",
display: str = "",
class_: str = "",
) -> SafeText:
"""Colored status dot with label. Status codes: u/p/f/a/r."""
children = children or []
outer_class = (
f"{'flex' if display == 'flex' else 'inline-flex'} "
"gap-2 items-center align-middle"
)
if class_:
outer_class += f" {class_}"
dot_color = _STATUS_COLORS.get(status, _STATUS_COLORS["u"])
dot = Span(
attributes=[("class", f"rounded-xl w-3 h-3 {dot_color}")],
children=["\xa0"],
)
return Span(
attributes=[("class", outer_class)],
children=[dot] + (children if isinstance(children, list) else [children]),
)
def PriceConverted(
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
"""Wrap content in a span that indicates the price was converted."""
children = children or []
return Span(
attributes=[
("title", "Price is a result of conversion and rounding."),
("class", "decoration-dotted underline"),
],
children=children if isinstance(children, list) else [children],
)
def LinkedPurchase(purchase: Purchase) -> SafeText:
link = reverse("games:view_purchase", args=[int(purchase.id)])
link_content = ""
popover_content = ""
game_count = purchase.games.count()
popover_if_not_truncated = False
if game_count == 1:
link_content += purchase.games.first().name
popover_content = link_content
if game_count > 1:
if purchase.name:
link_content += f"{purchase.name}"
popover_content += f"<h1>{purchase.name}</h1><br>"
else:
link_content += f"{game_count} games"
popover_if_not_truncated = True
popover_content += f"""
<ul class="list-disc list-inside">
{"".join(f"<li>{game.name}</li>" for game in purchase.games.all())}
</ul>
"""
icon = (
(purchase.platform.icon if purchase.platform else "unspecified")
if game_count == 1
else "unspecified"
)
if link_content == "":
raise ValueError("link_content is empty!!")
a_content = Div(
[("class", "inline-flex gap-2 items-center")],
[
Icon(
icon,
[("title", "Multiple")],
),
PopoverTruncated(
input_string=link_content,
popover_content=mark_safe(popover_content),
popover_if_not_truncated=popover_if_not_truncated,
),
],
)
return A(href=link, children=[a_content])
def NameWithIcon(
name: str = "",
game: Game | None = None,
session: Session | None = None,
linkify: bool = True,
emulated: bool = False,
) -> SafeText:
_name, platform, final_emulated, create_link, link = _resolve_name_with_icon(
name, game, session, linkify
)
content = Div(
[("class", "inline-flex gap-2 items-center")],
[
Icon(
platform.icon,
[("title", platform.name)],
)
if platform
else "",
Icon("emulated", [("title", "Emulated")]) if final_emulated else "",
PopoverTruncated(_name),
],
)
return (
A(
href=link,
children=[content],
)
if create_link
else content
)
def _resolve_name_with_icon(
name: str,
game: Game | None,
session: Session | None,
linkify: bool,
) -> tuple[str, Any, bool, bool, str]:
create_link = False
link = ""
platform = None
final_emulated = False
if session is not None:
game = session.game
platform = game.platform
final_emulated = session.emulated
if linkify:
create_link = True
link = reverse("games:view_game", args=[int(game.pk)])
elif game is not None:
platform = game.platform
if linkify:
create_link = True
link = reverse("games:view_game", args=[int(game.pk)])
_name = name or (game.name if game else "")
return _name, platform, final_emulated, create_link, link
def PurchasePrice(purchase) -> SafeText:
return Popover(
popover_content=f"{floatformat(purchase.price)} {purchase.price_currency}",
wrapped_content=f"{floatformat(purchase.converted_price)} {purchase.converted_currency}",
wrapped_classes="underline decoration-dotted",
)
def GameStatusSelector(game, game_statuses, csrf_token: str) -> SafeText:
"""Alpine.js dropdown to change a game's status."""
options_html = "\n".join(
f"<template x-if=\"status == '{value}'\">"
f"{GameStatus(status=value, children=[label], display='flex')}"
f"</template>"
for value, label in game_statuses
)
list_items = "\n".join(
f"<li><a href=\"#\" @click.prevent.stop=\"setStatus('{value}', '{label}'); open = false;\" "
f'class="block px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 '
f'dark:focus:ring-blue-500 dark:focus:text-white rounded-sm no-underline! border-0!" '
f":class=\"{{'font-bold': status === '{value}'}}\">"
f"{GameStatus(status=value, children=[label], display='flex', class_='text-slate-300')}"
f"</a></li>"
for value, label in game_statuses
)
return mark_safe(f"""
<div class="flex gap-2 items-center"
x-data="{{
status: '{game.status}',
status_display: '{game.get_status_display()}',
open: false,
saving: false,
setStatus(newStatus, newStatusDisplay) {{
this.status = newStatus;
this.status_display = newStatusDisplay;
this.saving = true;
fetchWithHtmxTriggers(`/api/games/{game.id}/status`, {{
method: 'PATCH',
headers: {{
'Content-Type': 'application/json',
'X-CSRFToken': '{csrf_token}'
}},
body: JSON.stringify({{ status: newStatus }})
}})
.then(() => {{
document.body.dispatchEvent(new CustomEvent('status-changed'));
}})
.catch(() => {{
console.error('Failed to update status');
}})
.finally(() => this.saving = false);
}}
}}">
{_dropdown_button_html(options_html, list_items)}
</div>
""")
def SessionDeviceSelector(session, session_devices, csrf_token: str) -> SafeText:
"""Alpine.js dropdown to change a session's device."""
device_id = session.device_id or "null"
device_name = (session.device.name if session.device else "Unknown").replace(
"'", "\\'"
)
list_items = "\n".join(
f'<li><a href="#" @click.prevent.stop="setDevice({d.id}, \'{d.name.replace(chr(39), chr(92) + chr(39))}\'); open = false;" '
f'class="block px-4 py-2 dark:hover:text-white dark:hover:bg-gray-700 '
f'dark:focus:ring-blue-500 dark:focus:text-white rounded-sm no-underline! border-0!" '
f":class=\"{{'font-bold': deviceId === {d.id}}}\">{d.name}</a></li>"
for d in session_devices
)
return mark_safe(f"""
<div class="flex gap-2 items-center"
x-data="{{
originalDeviceId: {device_id},
originalDeviceName: '{device_name}',
deviceId: {device_id},
deviceName: '{device_name}',
open: false,
saving: false,
setDevice(newDeviceId, newDeviceName) {{
this.deviceId = newDeviceId;
this.deviceName = newDeviceName;
this.saving = true;
fetchWithHtmxTriggers(`/api/session/{session.id}/device`, {{
method: 'PATCH',
headers: {{
'Content-Type': 'application/json',
'X-CSRFToken': '{csrf_token}'
}},
body: JSON.stringify({{ device_id: newDeviceId }})
}})
.then((res) => {{
document.body.dispatchEvent(new CustomEvent('device-changed'));
}})
.catch(() => {{
this.deviceName = this.originalDeviceName;
this.deviceId = this.originalDeviceId;
console.error('Failed to update device');
}})
.finally(() => this.saving = false);
}}
}}">
{
_dropdown_button_html(
'<span x-text="deviceName"></span>' + str(Icon("arrowdown")), list_items
)
}
</div>
""")
def _dropdown_button_html(button_content: str, list_items: str) -> str:
"""Shared dropdown button + list structure for Alpine.js selectors."""
return (
'<div class="inline-flex rounded-md shadow-2xs" role="group" @click.outside="open = false">'
'<button type="button" @click="open = !open" '
'class="relative px-4 py-2 text-sm font-medium bg-white border border-gray-200 '
"rounded-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 "
"focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 "
"dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 "
'dark:focus:text-white align-middle hover:cursor-pointer">'
f'<span class="flex flex-row gap-4 justify-between items-center">{button_content}</span>'
'<div class="absolute top-[105%] left-0 w-full whitespace-nowrap z-10 text-sm '
"font-medium bg-gray-800/20 backdrop-blur-lg rounded-md rounded-t-none border "
'border-gray-200 dark:border-gray-700" x-show="open" style="display: none;">'
'<ul class="[&_li:first-of-type_a]:rounded-none [&_li:last-of-type_a]:rounded-t-none">'
f"{list_items}"
"</ul>"
"</div>"
"</button>"
"</div>"
)
File diff suppressed because it is too large Load Diff
+954
View File
@@ -0,0 +1,954 @@
"""Generic HTML primitives (no domain knowledge)."""
from django.middleware.csrf import get_token
from django.templatetags.static import static
from django.urls import reverse
from django.utils.html import conditional_escape
from django.utils.safestring import SafeText, mark_safe
from common.icons import get_icon
from common.utils import truncate
from common.components.core import Component, HTMLAttribute, HTMLTag, randomid
_COLOR_CLASSES = {
"blue": "text-white bg-brand box-border border border-transparent hover:bg-brand-strong focus:ring-4 focus:ring-brand-medium",
"red": "bg-red-700 dark:bg-red-600 dark:focus:ring-red-900 dark:hover:bg-red-700 focus:ring-red-300 hover:bg-red-800 text-white",
"gray": "bg-white border-gray-200 dark:bg-gray-800 dark:border-gray-600 dark:focus:ring-gray-700 dark:hover:bg-gray-700 dark:hover:text-white dark:text-gray-400 focus:ring-gray-100 hover:bg-gray-100 hover:text-blue-700 text-gray-900 border",
"green": "bg-green-700 dark:bg-green-600 dark:focus:ring-green-800 dark:hover:bg-green-700 focus:ring-green-300 hover:bg-green-800 text-white",
}
_SIZE_CLASSES = {
"xs": "px-3 py-2 text-xs shadow-xs",
"sm": "px-3 py-2 text-sm",
"base": "px-5 py-2.5 text-sm",
"lg": "px-5 py-3 text-base",
"xl": "px-6 py-3.5 text-base",
}
def _popover_html(
id: str,
popover_content: str,
wrapped_content: str = "",
wrapped_classes: str = "",
slot: str = "",
) -> SafeText:
"""Generate popover HTML using Component(tag_name=...).
Single source of truth for popover HTML structure.
Used by Popover() and the python_popover template tag bridge.
"""
display_content = wrapped_content if wrapped_content else slot
span = Span(
attributes=[
("data-popover-target", id),
("class", wrapped_classes),
],
children=[display_content] if display_content else [],
)
popover_tooltip_class = (
"absolute z-10 invisible inline-block text-sm text-white "
"transition-opacity duration-300 bg-white border border-purple-200 "
"rounded-lg shadow-xs opacity-0 dark:text-white dark:border-purple-600 "
"dark:bg-purple-800"
)
div = Component(
tag_name="div",
attributes=[
("data-popover", ""),
("id", id),
("role", "tooltip"),
("class", popover_tooltip_class),
],
children=[
Component(
tag_name="div",
attributes=[("class", "px-3 py-2")],
children=[popover_content],
),
Component(tag_name="div", attributes=[("data-popper-arrow", "")]),
mark_safe( # nosec — intentional HTML comment for Tailwind JIT
"<!-- for Tailwind CSS to generate decoration-dotted CSS "
"from Python component -->"
),
Span(
attributes=[("class", "hidden decoration-dotted")],
),
],
)
return mark_safe(span + "\n" + div)
def Popover(
popover_content: str,
wrapped_content: str = "",
wrapped_classes: str = "",
children: list[HTMLTag] | None = None,
attributes: list[HTMLAttribute] | None = None,
id: str = "",
) -> str:
children = children or []
if not wrapped_content and not children:
raise ValueError("One of wrapped_content or children is required.")
if not id:
id = randomid(content=f"{wrapped_content}:{popover_content}:{wrapped_classes}")
slot = mark_safe("\n".join(children))
return _popover_html(
id=id,
popover_content=popover_content,
wrapped_content=wrapped_content,
wrapped_classes=wrapped_classes,
slot=slot,
)
def PopoverTruncated(
input_string: str,
popover_content: str = "",
popover_if_not_truncated: bool = False,
length: int = 30,
ellipsis: str = "",
endpart: str = "",
) -> str:
"""
Returns `input_string` truncated after `length` of characters
and displays the untruncated text in a popover HTML element.
The truncated text ends in `ellipsis`, and optionally
an always-visible `endpart` can be specified.
`popover_content` can be specified if:
1. It needs to be always displayed regardless if text is truncated.
2. It needs to differ from `input_string`.
"""
if (truncated := truncate(input_string, length, ellipsis, endpart)) != input_string:
return Popover(
wrapped_content=truncated,
popover_content=popover_content if popover_content else input_string,
)
else:
if popover_content and popover_if_not_truncated:
return Popover(
wrapped_content=input_string,
popover_content=popover_content if popover_content else "",
)
else:
return input_string
def A(
attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None,
url_name: str | None = None,
href: str | None = None,
) -> SafeText:
"""
Returns an anchor <a> tag.
Accepts one of two mutually-exclusive URL specifications:
- url_name: URL pattern name, resolved via reverse()
- href: Literal path string passed through as-is
"""
attributes = attributes or []
children = children or []
if url_name is not None and href is not None:
raise ValueError("Provide exactly one of 'url_name' or 'href', not both.")
additional_attributes = []
if url_name is not None:
additional_attributes = [("href", reverse(url_name))]
elif href is not None:
additional_attributes = [("href", href)]
return Component(
tag_name="a", attributes=attributes + additional_attributes, children=children
)
def Button(
attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None,
size: str = "base",
icon: bool = False,
color: str = "blue",
type: str = "button",
hx_get: str = "",
hx_target: str = "",
hx_swap: str = "",
title: str = "",
onclick: str = "",
name: str = "",
) -> SafeText:
attributes = attributes or []
children = children or []
# Separate custom class from other generic attributes
custom_class = ""
other_attrs: list[HTMLAttribute] = []
for attr_name, attr_value in attributes:
if attr_name == "class":
custom_class = str(attr_value)
else:
other_attrs.append((attr_name, attr_value))
# Build class string: custom class first, then base, color, size, icon
class_parts: list[str] = []
if custom_class:
class_parts.append(custom_class)
class_parts.append(
"hover:cursor-pointer leading-5 focus:outline-hidden focus:ring-4 "
"font-medium mb-2 me-2 rounded-base"
)
class_parts.append(_COLOR_CLASSES.get(color, _COLOR_CLASSES["blue"]))
class_parts.append(_SIZE_CLASSES.get(size, _SIZE_CLASSES["base"]))
if icon:
class_parts.append("inline-flex text-center items-center gap-2")
# Build the full attribute list for the button tag
button_attrs: list[HTMLAttribute] = [
("type", type),
("class", " ".join(class_parts)),
]
if hx_get:
button_attrs.append(("hx-get", hx_get))
if hx_target:
button_attrs.append(("hx-target", hx_target))
if hx_swap:
button_attrs.append(("hx-swap", hx_swap))
if title:
button_attrs.append(("title", title))
if onclick:
button_attrs.append(("onclick", onclick))
if name:
button_attrs.append(("name", name))
button_attrs.extend(other_attrs)
return Component(
tag_name="button",
attributes=button_attrs,
children=children,
)
_GROUP_BUTTON_COLORS = {
"gray": (
"px-2 py-1 text-xs font-medium text-gray-900 bg-white border "
"border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 "
"focus:ring-2 focus:ring-blue-700 focus:text-blue-700 "
"dark:bg-gray-800 dark:border-gray-700 dark:text-white "
"dark:hover:text-white dark:hover:bg-gray-700 "
"dark:focus:ring-blue-500 dark:focus:text-white"
),
"red": (
"px-2 py-1 text-xs font-medium text-gray-900 bg-white border "
"border-gray-200 hover:bg-red-500 hover:text-white focus:z-10 "
"focus:ring-2 focus:ring-blue-700 focus:text-blue-700 "
"dark:bg-gray-800 dark:border-gray-700 dark:text-white "
"dark:hover:text-white dark:hover:border-red-700 "
"dark:hover:bg-red-700 dark:focus:ring-blue-500 dark:focus:text-white"
),
"green": (
"px-2 py-1 text-xs font-medium text-gray-900 bg-white border "
"border-gray-200 hover:bg-green-500 hover:border-green-600 "
"hover:text-white focus:z-10 focus:ring-2 focus:ring-green-700 "
"focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 "
"dark:text-white dark:hover:text-white dark:hover:border-green-700 "
"dark:hover:bg-green-600 dark:focus:ring-green-500 "
"dark:focus:text-white"
),
}
def _button_group_button(
href: str,
slot: str,
color: str = "gray",
title: str = "",
hx_get: str = "",
hx_target: str = "",
) -> SafeText:
"""Generate a single button-group button (inner <button> inside <a>)."""
color_classes = _GROUP_BUTTON_COLORS.get(color, _GROUP_BUTTON_COLORS["gray"])
a_attrs: list[HTMLAttribute] = [("href", href)]
if hx_get:
a_attrs.append(("hx-get", hx_get))
if hx_target:
a_attrs.append(("hx-target", hx_target))
a_attrs.append(
(
"class",
"[&:first-of-type_button]:rounded-s-lg "
"[&:last-of-type_button]:rounded-e-lg",
)
)
button = Component(
tag_name="button",
attributes=[
("type", "button"),
("title", title),
("class", color_classes + " hover:cursor-pointer"),
],
children=[slot],
)
return Component(tag_name="a", attributes=a_attrs, children=[button])
def ButtonGroup(buttons: list[dict] | None = None) -> SafeText:
"""Generate a button group div.
Each button dict accepts: href, slot (required), color, title, hx_get, hx_target.
Empty dicts (no slot) are silently skipped — matching the template behavior
for conditional buttons (e.g., end-session only when session is active).
"""
buttons = buttons or []
children: list[SafeText] = []
for btn in buttons:
if not btn or not btn.get("slot"):
continue
children.append(
_button_group_button(
href=btn.get("href", "#"),
slot=btn["slot"],
color=btn.get("color", "gray"),
title=btn.get("title", ""),
hx_get=btn.get("hx_get", ""),
hx_target=btn.get("hx_target", ""),
)
)
return Component(
tag_name="div",
attributes=[("class", "inline-flex rounded-md shadow-xs"), ("role", "group")],
children=children,
)
def Div(
attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
attributes = attributes or []
children = children or []
return Component(tag_name="div", attributes=attributes, children=children)
def Input(
type: str = "text",
attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
attributes = attributes or []
children = children or []
return Component(
tag_name="input", attributes=attributes + [("type", type)], children=children
)
def Span(
attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
attributes = attributes or []
children = children or []
return Component(tag_name="span", attributes=attributes, children=children)
def Label(
attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
attributes = attributes or []
children = children or []
return Component(tag_name="label", attributes=attributes, children=children)
def Template(
attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
"""An inert ``<template>`` whose contents are not rendered until cloned by JS."""
attributes = attributes or []
children = children or []
return Component(tag_name="template", attributes=attributes, children=children)
# Inline Tailwind utilities for Pill (mirrors the .sf-tag / .sf-remove rules in
# input.css, written inline so styling stays encapsulated in the component). The
# JS that builds pills client-side (search_select.js) MUST emit these exact class
# strings byte-for-byte so Tailwind generates them and server/JS pills match.
_PILL_CLASS = (
"inline-flex items-center gap-1 px-2 py-0.5 text-sm rounded "
"bg-brand/15 text-heading"
)
_PILL_REMOVE_CLASS = "ml-1 text-body hover:text-heading font-bold cursor-pointer"
def Pill(
label: str,
*,
value: str = "",
removable: bool = False,
extra_class: str = "",
label_slot: bool = False,
attributes: list[HTMLAttribute] | None = None,
) -> SafeText:
"""A small label pill, optionally removable (× button).
Styling is inline Tailwind utilities; ``data-pill`` / ``data-pill-remove``
are JS hooks only (no CSS attached). ``value`` (when set) becomes
``data-value``; extra ``attributes`` are appended to the outer span.
``label_slot=True`` wraps the label in a ``<span data-search-select-label>`` so JS can
fill it when cloning the pill from a server-rendered ``<template>`` (keeps the
markup single-sourced — see ``search_select.py``).
"""
attributes = attributes or []
pill_class = f"{_PILL_CLASS} {extra_class}".strip()
pill_attrs: list[HTMLAttribute] = [("class", pill_class), ("data-pill", "")]
if value != "":
pill_attrs.append(("data-value", str(value)))
pill_attrs.extend(attributes)
label_child: HTMLTag = (
Span(attributes=[("data-search-select-label", "")], children=[label])
if label_slot
else label
)
children: list[HTMLTag] = [label_child]
if removable:
children.append(
Component(
tag_name="button",
attributes=[
("type", "button"),
("data-pill-remove", ""),
("class", _PILL_REMOVE_CLASS),
("aria-label", "Remove"),
],
children=["×"],
)
)
return Span(attributes=pill_attrs, children=children)
def CsrfInput(request) -> SafeText:
"""Hidden CSRF input, equivalent to the `{% csrf_token %}` template tag."""
return mark_safe(
f'<input type="hidden" name="csrfmiddlewaretoken" value="{get_token(request)}">'
)
def ModuleScript(filename: str) -> SafeText:
"""A `<script type="module">` tag pointing at a static JS file."""
return mark_safe(
f'<script type="module" src="{static("js/" + filename)}"></script>'
)
def ExternalScript(url: str) -> SafeText:
"""A plain `<script src=...>` tag for an external/CDN script."""
return mark_safe(f'<script src="{url}"></script>')
def YearPicker(
year: int | None = None,
available_years: tuple[int, ...] = (),
url_template: str = "",
) -> SafeText:
"""A Flowbite-datepicker year picker.
`year` is the selected year, or ``None`` for the all-time view (the empty
state). `available_years` are the years to enable in the popup grid.
`url_template` is a navigation URL containing the literal ``__year__``
placeholder, substituted with the chosen year in JS (keeps this component
decoupled from the project's URL names).
The Flowbite-datepicker UMD bundle is *not* loaded here — the view hoists it
via ``render_page(scripts=...)``.
"""
label = str(year) if year is not None else "Choose a year"
selected = str(year) if year is not None else ""
classes = (
"bg-brand text-white border-transparent hover:bg-brand-strong"
if year is not None
else "bg-neutral-secondary-medium text-heading border border-default-medium "
"hover:bg-neutral-tertiary-medium focus:ring-4 focus:ring-brand-medium"
)
years_csv = ",".join(str(y) for y in available_years)
return mark_safe(f"""<div class="relative inline-block" x-data="{{ pickerOpen: false }}"
@keydown.escape.window="pickerOpen = false">
<button type="button"
x-on:click="pickerOpen = !pickerOpen; $refs.pickerInput._pickerInstance && ($refs.pickerInput._pickerInstance.active ? $refs.pickerInput._pickerInstance.hide() : $refs.pickerInput._pickerInstance.show())"
class="inline-flex items-center rounded-base px-4 py-2 text-sm font-medium {classes}">
{label}
<svg class="w-4 h-4 ms-2 rtl:rotate-180" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 10">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 5h12m0 0L9 1m4 4L9 9"/>
</svg>
</button>
<input type="text" x-ref="pickerInput" id="year-picker-input"
class="absolute opacity-0 pointer-events-none"
style="width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); border: 0;"
data-available-years="{years_csv}"
data-selected-year="{selected}"
data-url-template="{url_template}">
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {{
const pickerEl = document.getElementById('year-picker-input');
if (!pickerEl || pickerEl._pickerInstance) return;
const selectedYear = pickerEl.dataset.selectedYear;
const urlTemplate = pickerEl.dataset.urlTemplate;
const currentYear = new Date().getFullYear();
const availableYears = new Set(pickerEl.dataset.availableYears
.split(',').map(s => parseInt(s.trim())).filter(n => !isNaN(n)));
const picker = new Datepicker(pickerEl, {{
pickLevel: 2,
format: 'yyyy',
minDate: new Date(1999, 0, 1),
maxDate: new Date(currentYear, 11, 31),
autohide: false,
orientation: 'bottom end',
showOnClick: false,
showOnFocus: false,
beforeShowYear: (date) => ({{ enabled: availableYears.has(date.getFullYear()) }})
}});
pickerEl._pickerInstance = picker;
picker.element.addEventListener('changeDate', (e) => {{
const year = e.detail.date?.getFullYear();
if (year && urlTemplate) {{
window.location.href = urlTemplate.replace('__year__', year);
}}
}});
if (selectedYear) {{
picker.dates = [new Date(parseInt(selectedYear), 0, 1)];
picker.update();
}}
}});
</script>""")
def AddForm(
form,
*,
request,
fields: SafeText | str | None = None,
additional_row: SafeText | str = "",
submit_class: str = "mt-3",
) -> SafeText:
"""Page body for the generic add/edit form (Python equivalent of add.html).
`fields` overrides the default ``form.as_div()`` field markup (used by the
session form, which lays out its fields manually). `additional_row` holds
extra submit buttons rendered below the main Submit button. `submit_class`
is applied to the main Submit button (the session form passes "" to match
its original markup).
"""
field_markup = fields if fields is not None else mark_safe(form.as_div())
submit_attrs = [("class", submit_class)] if submit_class else []
inner_form = Component(
tag_name="form",
attributes=[("method", "post"), ("enctype", "multipart/form-data")],
children=[
CsrfInput(request),
field_markup,
Div(children=[Button(submit_attrs, "Submit", type="submit")]),
Div(
[("class", "submit-button-container")],
[additional_row] if additional_row else [],
),
],
)
return Div(
[("id", "add-form"), ("class", "max-width-container")],
[
Div(
[("id", "add-form"), ("class", "form-container max-w-xl mx-auto")],
[inner_form],
)
],
)
def SearchField(
search_string: str = "",
id: str = "search_string",
placeholder: str = "Search",
) -> SafeText:
"""Generate a search form with icon, input field, and submit button."""
return Component(
tag_name="form",
attributes=[("class", "max-w-md")],
children=[
Label(
attributes=[
("for", "search"),
("class", "block mb-2.5 text-sm font-medium text-heading sr-only"),
],
children=["Search"],
),
Component(
tag_name="div",
attributes=[("class", "relative")],
children=[
mark_safe(
'<div class="absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none">'
'<svg class="w-4 h-4 text-body" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" '
'fill="none" viewBox="0 0 24 24">'
'<path stroke="currentColor" stroke-linecap="round" stroke-width="2" '
'd="m21 21-3.5-3.5M17 10a7 7 0 1 1-14 0 7 7 0 0 1 14 0Z"/>'
"</svg></div>"
),
Component(
tag_name="input",
attributes=[
("type", "search"),
("id", id),
("name", id),
("value", search_string),
(
"class",
"block w-full p-3 ps-9 bg-neutral-secondary-medium "
"border border-default-medium text-heading text-sm "
"rounded-base focus:ring-brand focus:border-brand "
"shadow-xs placeholder:text-body",
),
("placeholder", placeholder),
("required", ""),
],
),
Component(
tag_name="button",
attributes=[
("type", "submit"),
(
"class",
"absolute end-1.5 bottom-1.5 text-white bg-brand "
"hover:bg-brand-strong box-border border border-transparent "
"focus:ring-4 focus:ring-brand-medium shadow-xs font-medium "
"leading-5 rounded text-xs px-3 py-1.5 focus:outline-none "
"cursor-pointer",
),
],
children=["Search"],
),
],
),
],
)
def H1(
children: list[HTMLTag] | HTMLTag | None = None,
badge: str = "",
) -> SafeText:
"""Heading with optional badge count."""
children = children or []
heading_class = "mb-4 text-3xl font-extrabold leading-none tracking-tight text-gray-900 dark:text-white"
badge_html = ""
if badge:
heading_class = "flex items-center " + heading_class
badge_html = Span(
attributes=[
(
"class",
"bg-blue-100 text-blue-800 text-2xl font-semibold me-2 "
"px-2.5 py-0.5 rounded-sm dark:bg-blue-200 dark:text-blue-800 ms-2",
),
],
children=[badge],
)
return Component(
tag_name="h1",
attributes=[("class", heading_class)],
children=(children if isinstance(children, list) else [children])
+ ([badge_html] if badge_html else []),
)
def Modal(
modal_id: str,
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
"""Modal overlay with container. Content (form, buttons) goes in children."""
children = children or []
outer = Component(
tag_name="div",
attributes=[
("id", modal_id),
(
"class",
"fixed inset-0 bg-black/70 dark:bg-gray-600/50 overflow-y-auto "
"h-full w-full flex items-center justify-center",
),
],
children=[
Component(
tag_name="div",
attributes=[
(
"class",
"relative mx-auto p-5 border-accent border w-full max-w-md "
"shadow-lg/50 rounded-md bg-white dark:bg-gray-900",
),
],
children=(children if isinstance(children, list) else [children]),
),
],
)
return mark_safe(str(outer))
def TableTd(
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
"""Styled table cell."""
children = children or []
return Component(
tag_name="td",
attributes=[("class", "px-6 py-4 min-w-20-char max-w-20-char")],
children=children if isinstance(children, list) else [children],
)
def TableRow(data: dict | list | None = None) -> SafeText:
"""Generate a <tr> from a row data dict or list.
Dict form: {"row_id": "...", "cell_data": [...], "hx_trigger": ..., ...}
- first cell is <th>, rest <td>.
List form: [...] — all cells are <td>.
"""
if data is None:
data = {}
if isinstance(data, dict):
row_id = data.get("row_id", "")
cells = data.get("cell_data", [])
else:
row_id = ""
cells = data
tr_class = (
"odd:bg-white dark:odd:bg-gray-900 even:bg-gray-50 "
"dark:even:bg-gray-800 dark:border-gray-700 hover:bg-gray-50 "
"dark:hover:bg-gray-600 [&_a]:underline [&_a]:underline-offset-4 "
"[&_a]:decoration-2 [&_td:last-child]:text-right"
)
tr_attrs: list[HTMLAttribute] = [("class", tr_class)]
if row_id:
tr_attrs.append(("id", row_id))
if isinstance(data, dict):
if data.get("hx_trigger"):
tr_attrs.append(("hx-trigger", data["hx_trigger"]))
if data.get("hx_get"):
tr_attrs.append(("hx-get", data["hx_get"]))
if data.get("hx_select"):
tr_attrs.append(("hx-select", data["hx_select"]))
if data.get("hx_swap"):
tr_attrs.append(("hx-swap", data["hx_swap"]))
cell_elements: list[SafeText] = []
for i, cell in enumerate(cells):
if i == 0:
cell_elements.append(
Component(
tag_name="th",
attributes=[
("scope", "row"),
(
"class",
"px-6 py-4 font-medium text-gray-900 "
"whitespace-nowrap dark:text-white",
),
],
children=[cell],
)
)
else:
cell_elements.append(TableTd(children=[cell]))
return Component(tag_name="tr", attributes=tr_attrs, children=cell_elements)
def Icon(
name: str,
attributes: list[HTMLAttribute] | None = None,
) -> SafeText:
return mark_safe(get_icon(name))
def TableHeader(
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
"""Table caption."""
children = children or []
return Component(
tag_name="caption",
attributes=[
(
"class",
"p-2 text-lg font-semibold rtl:text-left text-right "
"text-gray-900 bg-white dark:text-white dark:bg-gray-900",
),
],
children=children if isinstance(children, list) else [children],
)
def _page_url(request, page) -> str:
"""Current querystring with `page` replaced (mirrors {% param_replace %})."""
if request is None:
return f"?page={page}"
params = request.GET.copy()
params["page"] = page
return "?" + params.urlencode()
def _pagination_nav(page_obj, elided_page_range, request) -> str:
pages_html = ""
for page in elided_page_range:
if page != page_obj.number:
pages_html += (
f'<li><a href="{_page_url(request, page)}" '
'class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 '
"bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700 "
"dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 "
f'dark:hover:text-white">{conditional_escape(page)}</a></li>'
)
else:
pages_html += (
'<li><a aria-current="page" '
'class="cursor-not-allowed flex items-center justify-center px-3 h-8 leading-tight '
"text-white border bg-gray-400 border-gray-300 dark:bg-gray-900 dark:border-gray-700 "
f'dark:text-gray-200">{conditional_escape(page)}</a></li>'
)
if page_obj.has_previous():
prev_html = (
f'<a href="{_page_url(request, page_obj.previous_page_number())}" '
'class="flex items-center justify-center px-3 h-8 ms-0 leading-tight text-gray-500 '
"bg-white border border-gray-300 rounded-s-lg hover:bg-gray-100 hover:text-gray-700 "
"dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 "
'dark:hover:text-white">Previous</a>'
)
else:
prev_html = (
'<a aria-current="page" class="cursor-not-allowed flex items-center justify-center '
"px-3 h-8 leading-tight text-gray-300 bg-white border border-gray-300 rounded-s-lg "
'dark:bg-gray-800 dark:border-gray-700 dark:text-gray-600">Previous</a>'
)
if page_obj.has_next():
next_html = (
f'<a href="{_page_url(request, page_obj.next_page_number())}" '
'class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 '
"bg-white border border-gray-300 rounded-e-lg hover:bg-gray-100 hover:text-gray-700 "
"dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 "
'dark:hover:text-white">Next</a>'
)
else:
next_html = (
'<a aria-current="page" class="cursor-not-allowed flex items-center justify-center '
"px-3 h-8 leading-tight text-gray-300 bg-white border border-gray-300 rounded-e-lg "
'dark:bg-gray-800 dark:border-gray-700 dark:text-gray-600">Next</a>'
)
return (
'<nav class="flex items-center flex-col md:flex-row md:justify-between px-6 py-4 '
'dark:bg-gray-900 sm:rounded-b-lg" aria-label="Table navigation">'
'<span class="text-sm text-center font-normal text-gray-500 dark:text-gray-400 mb-4 '
'md:mb-0 block w-full md:inline md:w-auto">'
f'<span class="font-semibold text-gray-900 dark:text-white">{page_obj.start_index()}</span>—'
f'<span class="font-semibold text-gray-900 dark:text-white">{page_obj.end_index()}</span> of '
f'<span class="font-semibold text-gray-900 dark:text-white">{page_obj.paginator.count}</span></span>'
'<ul class="inline-flex -space-x-px rtl:space-x-reverse text-sm h-8"><li>'
f"{prev_html}{pages_html}{next_html}"
"</li></ul></nav>"
)
def SimpleTable(
columns: list[str] | None = None,
rows: list | None = None,
header_action: SafeText | str | None = None,
page_obj=None,
elided_page_range=None,
request=None,
) -> SafeText:
"""Paginated table. Python equivalent of the old simple_table.html."""
columns = columns or []
rows = rows or []
header_html = ""
if header_action:
header_html = str(TableHeader(children=[header_action]))
columns_html = "".join(
f'<th scope="col" class="px-6 py-3">{conditional_escape(col)}</th>'
for col in columns
)
rows_html = "".join(str(TableRow(data=row)) for row in rows)
pagination_html = ""
if page_obj and elided_page_range:
pagination_html = _pagination_nav(page_obj, elided_page_range, request)
return mark_safe(
'<div class="shadow-md" hx-boost="false">'
'<div class="relative overflow-x-auto sm:rounded-t-lg">'
'<table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400">'
f"{header_html}"
'<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 '
'dark:text-gray-400 max-sm:[&_th:not(:first-child):not(:last-child)]:hidden">'
f"<tr>{columns_html}</tr></thead>"
'<tbody class="dark:divide-y max-sm:[&_td:not(:first-child):not(:last-child)]:hidden">'
f"{rows_html}</tbody></table></div>"
f"{pagination_html}</div>"
)
def paginated_table_content(
data: dict,
*,
page_obj=None,
elided_page_range=None,
request=None,
) -> SafeText:
"""Standard list-page body: a max-width Div wrapping a SimpleTable.
`data` is the table dict with keys ``columns``, ``rows`` and
``header_action`` (the same shape every list view already builds).
"""
return Div(
[
(
"class",
"2xl:max-w-(--breakpoint-2xl) xl:max-w-(--breakpoint-xl) "
"md:max-w-(--breakpoint-md) sm:max-w-(--breakpoint-sm) self-center",
)
],
[
SimpleTable(
columns=data["columns"],
rows=data["rows"],
header_action=data["header_action"],
page_obj=page_obj,
elided_page_range=elided_page_range,
request=request,
)
],
)
+566
View File
@@ -0,0 +1,566 @@
"""Search field + dropdown select component (pure Python, domain-agnostic).
Pairs a search box with a dropdown of options. Supports single/multi select;
in multi-select, chosen items render as removable ``Pill``s, each backed by a
hidden ``<input>`` so an existing ``ModelMultipleChoiceField`` keeps validating.
This module imports only from ``common.components`` — it has no Django-forms or
``games`` knowledge. Styling is inline Tailwind utilities; behavioural hooks are
``data-*`` attributes wired up by ``games/static/js/search_select.js``.
Option sourcing follows two axes. *Population*: options are either rendered
inline up front (``options=``, no ``search_url``) or fetched from ``search_url``.
*Completeness*: without a ``search_url`` the inline set is the whole dataset and
filtering is purely client-side; with a ``search_url`` the loaded rows are a
window, so the JS filters the loaded rows instantly on each keystroke while
issuing a debounced server request for the rest. ``prefetch`` (rows to load on
first open, ``0`` = none) seeds that window so the panel is populated before the
user types.
"""
from collections.abc import Callable, Iterable
from typing import TypedDict
from django.utils.safestring import SafeText
from common.components.core import Component, HTMLAttribute
from common.components.primitives import Div, Input, Pill, Span, Template
class SearchSelectOption(TypedDict):
value: str | int
label: str
data: dict[str, str] # becomes data-* attrs on the row / pill
# A lightweight (value, label) pair used wherever only those two fields are
# needed — e.g. filter pill lists and modifier pseudo-options. The richer
# SearchSelectOption adds a ``data`` dict for extra row attributes.
LabeledOption = tuple[str, str]
# The pills and the search box share one flex-wrap row (with padding) so the
# widget reads as a single clickable field; the pills wrapper uses `contents`
# so its pills/hidden inputs flow as direct participants of that row, inline
# with the search input. The options panel is absolute, so it sits outside the
# flex flow. (border omitted intentionally — see if it's needed later.)
_CONTAINER_CLASS = (
"relative flex flex-wrap items-center gap-1 p-2 "
"rounded-base bg-neutral-secondary-medium"
)
_PILLS_CLASS = "contents"
_SEARCH_CLASS = (
"flex-1 min-w-[8rem] border-0 bg-transparent text-sm text-heading "
"focus:ring-0 focus:outline-hidden placeholder:text-body"
)
# top-full anchors the panel to the container's bottom edge: as an absolutely
# positioned child of the flex field, its static position would otherwise be
# centered by items-center and overlap the search box.
_OPTIONS_CLASS = (
"absolute z-10 top-full left-0 right-0 mt-1 overflow-y-auto "
"border border-default-medium rounded-base bg-neutral-secondary-medium shadow-lg"
)
_OPTION_ROW_CLASS = (
"px-3 py-2 text-sm text-heading cursor-pointer "
"hover:bg-brand/15 data-[search-select-highlighted]:bg-brand/15"
)
_NO_RESULTS_CLASS = "px-3 py-2 text-sm italic text-body hidden"
# Approximate rendered height of one option row (px-3 py-2 text-sm) in rem,
# used to derive the panel's max-height from items_visible.
_ROW_HEIGHT_REM = 2.25
# Default number of rows to fetch on first focus when a search_url is set.
# Shared by filter and form widgets so the dropdown is populated for keyboard
# navigation as soon as the user opens it.
DEFAULT_PREFETCH = 20
# ── FilterSelect styling ───────────────────────────────────────────────────
# Inline class strings (ported verbatim from the retired SelectableFilter CSS)
# so the filter combobox is fully self-styled — nothing in input.css. JS-added
# rows/pills are cloned from server-rendered <template>s, so these strings live
# only here — never duplicated in search_select.js. The keyboard-highlighted
# state is expressed via Tailwind `data-[search-select-highlighted]` and
# `group-data-[search-select-highlighted]` variants on the row/label/button
# classes below; the JS only toggles the data attribute on the row.
_FILTER_INCLUDE_PILL_CLASS = (
"inline-flex items-center gap-1 px-2 py-0.5 text-sm rounded "
"bg-brand/15 text-heading"
)
_FILTER_EXCLUDE_PILL_CLASS = (
"inline-flex items-center gap-1 px-2 py-0.5 text-sm rounded "
"bg-red-500/15 text-red-600 line-through decoration-red-400"
)
_FILTER_MODIFIER_PILL_CLASS = (
"inline-flex items-center px-2 py-0.5 text-sm rounded "
"bg-amber-500/15 text-amber-600 cursor-pointer"
)
_FILTER_PILL_REMOVE_CLASS = "ml-1 text-body hover:text-heading font-bold cursor-pointer"
_FILTER_OPTION_ROW_CLASS = (
"group flex items-center justify-between px-2 py-1 rounded text-sm "
"hover:bg-neutral-secondary-strong cursor-pointer "
"data-[search-select-highlighted]:bg-brand "
"data-[search-select-highlighted]:outline data-[search-select-highlighted]:outline-1 "
"data-[search-select-highlighted]:outline-brand-strong"
)
_FILTER_OPTION_LABEL_CLASS = (
"truncate text-body group-data-[search-select-highlighted]:text-white"
)
_FILTER_OPTION_BUTTONS_CLASS = "flex gap-1 ml-2 shrink-0"
# text-body keeps the +/ readable on dark backgrounds; hover:border-brand-strong
# keeps the edge visible against the brand hover fill. When the row is the
# keyboard-highlighted one its bg is brand, so the button text/border switch
# to white and the hover fill shifts to brand-strong for contrast.
_FILTER_ACTION_BUTTON_CLASS = (
"w-5 h-5 flex items-center justify-center text-xs font-bold rounded text-body "
"border border-brand "
"hover:bg-brand hover:text-white hover:border-brand-strong "
"group-data-[search-select-highlighted]:text-white "
"group-data-[search-select-highlighted]:border-white "
"group-data-[search-select-highlighted]:hover:bg-brand-strong "
"group-data-[search-select-highlighted]:hover:border-white"
)
_FILTER_MODIFIER_ROW_CLASS = (
"px-2 py-1 text-sm text-body hover:bg-neutral-secondary-strong cursor-pointer"
)
def _normalize_option(option) -> SearchSelectOption:
"""Coerce a dict option or a ``(value, label)`` tuple into the TypedDict."""
if isinstance(option, dict):
return {
"value": option["value"],
"label": option["label"],
"data": option.get("data") or {},
}
value, label = option
return {"value": value, "label": label, "data": {}}
def _data_attributes(data: dict[str, str]) -> list[HTMLAttribute]:
return [(f"data-{key}", str(value)) for key, value in data.items()]
def _hidden_input(name: str, value) -> SafeText:
return Input(type="hidden", attributes=[("name", name), ("value", str(value))])
def _label_slot(text: str, *, extra_class: str = "") -> SafeText:
"""A ``<span data-search-select-label>`` holding a row/pill's visible label. JS fills this
one node when cloning the shape from a ``<template>``, so labels are the only
thing the JS sets — all classes and structure stay server-side."""
attributes: list[HTMLAttribute] = [("data-search-select-label", "")]
if extra_class:
attributes.append(("class", extra_class))
return Span(attributes=attributes, children=[text])
# A placeholder option for rendering template prototypes (JS overwrites it).
_BLANK_OPTION: SearchSelectOption = {"value": "", "label": "", "data": {}}
def _option_row(option: SearchSelectOption) -> SafeText:
return Div(
attributes=[
("data-search-select-option", ""),
("data-value", str(option["value"])),
("data-label", option["label"]),
("class", _OPTION_ROW_CLASS),
*_data_attributes(option["data"]),
],
children=[_label_slot(option["label"])],
)
def _combobox_shell(
*,
container_attributes: list[HTMLAttribute],
pills: SafeText,
search_attributes: list[HTMLAttribute],
options_children: list[SafeText],
always_visible: bool,
items_visible: int,
templates: list[SafeText] | None = None,
) -> SafeText:
"""Assemble the shared, domain-agnostic combobox skeleton.
Every combobox built on top of this shell has the same three regions in the
same order: the ``pills`` region, the search box, and the options panel (which
always carries a trailing no-results node). Callers supply the already-built
``pills`` region, the ``search_attributes`` for the text box, the
``options_children`` (value rows plus any pinned pseudo-options), the
``container_attributes`` that carry the widget's identity and behaviour flags,
and any ``templates`` (inert ``<template>`` prototypes the JS clones for
dynamically-added rows/pills). The shell knows nothing about how individual
rows or pills look.
"""
search = Input(attributes=search_attributes)
no_results = Div(
attributes=[
("data-search-select-no-results", ""),
("class", _NO_RESULTS_CLASS),
],
children=["No results"],
)
options_class = _OPTIONS_CLASS if always_visible else _OPTIONS_CLASS + " hidden"
options_panel = Div(
attributes=[
("data-search-select-options", ""),
("style", f"max-height: {items_visible * _ROW_HEIGHT_REM:.2f}rem"),
("class", options_class),
],
children=[*options_children, no_results],
)
children: list[SafeText] = [pills, search, options_panel, *(templates or [])]
return Div(attributes=container_attributes, children=children)
def SearchSelect(
*,
name: str,
selected: list[SearchSelectOption] | None = None,
options: list[SearchSelectOption] | None = None,
search_url: str = "",
multi_select: bool = False,
always_visible: bool = False,
items_visible: int = 5,
items_scroll: int = 10,
prefetch: int = 0,
placeholder: str = "Search…",
id: str = "",
sync_url: bool = False,
autofocus: bool = False,
) -> SafeText:
"""Render the search-select widget. See module docstring for the contract."""
selected = [_normalize_option(option) for option in (selected or [])]
options = [_normalize_option(option) for option in (options or [])]
# ── Pills + their hidden inputs (the submitted channel) ──
# Multi-select renders a removable Pill per value; single-select renders no
# pill — the committed label shows inside the search box instead, with a
# lone hidden input carrying the value. Both keep the hidden input(s) inside
# `[data-search-select-pills]` so the JS reads/writes values uniformly.
pills_children: list[SafeText] = []
search_value = ""
if multi_select:
for option in selected:
pills_children.append(
Pill(
option["label"],
value=str(option["value"]),
removable=True,
label_slot=True,
attributes=_data_attributes(option["data"]),
)
)
pills_children.append(_hidden_input(name, option["value"]))
elif selected:
option = selected[0]
pills_children.append(_hidden_input(name, option["value"]))
search_value = option["label"]
pills = Div(
attributes=[("data-search-select-pills", ""), ("class", _PILLS_CLASS)],
children=pills_children,
)
# ── Search box (NO name — the query is never submitted) ──
search_attrs: list[HTMLAttribute] = [
("data-search-select-search", ""),
("placeholder", placeholder),
("autocomplete", "off"),
("class", _SEARCH_CLASS),
]
if autofocus:
search_attrs.append(("autofocus", ""))
if search_value:
search_attrs.append(("value", search_value))
# ── Options panel (pre-rendered only when there is no search_url) ──
option_rows = [_option_row(option) for option in options] if not search_url else []
# ── Templates the JS clones: a row when results are fetched, a pill when
# multi-select adds chosen items. ──
templates: list[SafeText] = []
if search_url:
templates.append(
Template(
attributes=[("data-search-select-template", "row")],
children=[_option_row(_BLANK_OPTION)],
)
)
if multi_select:
templates.append(
Template(
attributes=[("data-search-select-template", "pill")],
children=[Pill("", value="", removable=True, label_slot=True)],
)
)
container_attributes: list[HTMLAttribute] = [
("data-search-select", ""),
("data-name", name),
("data-search-url", search_url),
("data-multi", "true" if multi_select else "false"),
("data-always-visible", "true" if always_visible else "false"),
("data-items-visible", str(items_visible)),
("data-items-scroll", str(items_scroll)),
("data-prefetch", str(prefetch)),
("data-sync-url", "true" if sync_url else "false"),
("class", _CONTAINER_CLASS),
]
if id:
container_attributes.append(("id", id))
return _combobox_shell(
container_attributes=container_attributes,
pills=pills,
search_attributes=search_attrs,
options_children=option_rows,
always_visible=always_visible,
items_visible=items_visible,
templates=templates,
)
def _filter_remove_button() -> SafeText:
return Component(
tag_name="button",
attributes=[
("type", "button"),
("data-pill-remove", ""),
("class", _FILTER_PILL_REMOVE_CLASS),
("aria-label", "Remove"),
],
children=["×"],
)
def _filter_value_pill(option: SearchSelectOption, kind: str) -> SafeText:
"""An include (✓) or exclude (✗) value pill. ``kind`` is "include"/"exclude"."""
symbol = "" if kind == "include" else ""
css = (
_FILTER_INCLUDE_PILL_CLASS if kind == "include" else _FILTER_EXCLUDE_PILL_CLASS
)
return Span(
attributes=[
("class", css),
("data-pill", ""),
("data-value", str(option["value"])),
("data-label", option["label"]),
("data-search-select-type", kind),
*_data_attributes(option["data"]),
],
children=[f"{symbol} ", _label_slot(option["label"]), _filter_remove_button()],
)
def _filter_modifier_pill(modifier_value: str, label: str) -> SafeText:
"""The lone, sticky modifier pill (e.g. "(Any)"/"(None)")."""
return Span(
attributes=[
("class", _FILTER_MODIFIER_PILL_CLASS),
("data-pill", ""),
("data-search-select-modifier", modifier_value),
],
children=[_label_slot(label), _filter_remove_button()],
)
def _filter_action_button(action: str, symbol: str, title: str) -> SafeText:
return Component(
tag_name="button",
attributes=[
("type", "button"),
("data-search-select-action", action),
("class", _FILTER_ACTION_BUTTON_CLASS),
("title", title),
],
children=[symbol],
)
def _filter_option_row(value: str | int, label: str) -> SafeText:
"""A value row with include (+) and exclude () buttons."""
return Div(
attributes=[
("data-search-select-option", ""),
("data-value", str(value)),
("data-label", label),
("class", _FILTER_OPTION_ROW_CLASS),
],
children=[
_label_slot(label, extra_class=_FILTER_OPTION_LABEL_CLASS),
Span(
attributes=[("class", _FILTER_OPTION_BUTTONS_CLASS)],
children=[
_filter_action_button("include", "+", "Include"),
_filter_action_button("exclude", "", "Exclude"),
],
),
],
)
def _filter_modifier_row(modifier_value: str, label: str) -> SafeText:
"""A pinned pseudo-option row. It carries no ``data-search-select-option`` so the text
filter never hides it — modifiers stay visible at the top of the panel."""
return Div(
attributes=[
("data-search-select-modifier-option", modifier_value),
("data-label", label),
("class", _FILTER_MODIFIER_ROW_CLASS),
],
children=[label],
)
def FilterSelect(
*,
field_name: str,
options: list[LabeledOption | SearchSelectOption] | None = None,
included: list[LabeledOption | SearchSelectOption] | None = None,
excluded: list[LabeledOption | SearchSelectOption] | None = None,
modifier: str = "",
modifier_options: list[LabeledOption] | None = None,
search_url: str = "",
prefetch: int = 0,
items_visible: int = 6,
items_scroll: int = 10,
placeholder: str = "Search…",
id: str = "",
) -> SafeText:
"""Include/exclude filter combobox built on the shared ``_combobox_shell``.
Like ``SearchSelect`` but each value row carries +/ buttons that add an
*include* (✓) or *exclude* (✗) pill, plus an optional set of pinned
``modifier_options`` (e.g. ``[("NOT_NULL", "(Any)"), ("IS_NULL", "(None)")]``)
rendered above the value rows. Presence modifiers (NOT_NULL / IS_NULL) are
mutually exclusive with value pills. Non-presence modifiers (INCLUDES_ALL /
INCLUDES_ONLY) coexist with value pills — they govern how the include set
matches and are only surfaced for many-to-many fields. State is read from
the DOM into the filter JSON by ``readSearchSelect`` (filter mode) — nothing
is submitted by ``name``.
``included``/``excluded`` are resolved options (value + label) so pills show
labels even when the value rows come from ``search_url``. ``options``
pre-renders the value rows for the complete-set (no ``search_url``) case.
"""
options = [_normalize_option(option) for option in (options or [])]
included = [_normalize_option(option) for option in (included or [])]
excluded = [_normalize_option(option) for option in (excluded or [])]
modifier_options = modifier_options or []
active_modifier_label = ""
for modifier_value, label in modifier_options:
if modifier_value == modifier:
active_modifier_label = label
break
# ── Pills: modifier pill (if active), then include/exclude value pills ──
# Presence modifiers (NOT_NULL / IS_NULL) are mutually exclusive with value
# pills — but the stored state guarantees they never coexist, so we render
# both channels unconditionally. Non-presence modifiers (INCLUDES_ALL /
# INCLUDES_ONLY) coexist with value pills and render side by side.
pills_children: list[SafeText] = []
if active_modifier_label:
pills_children.append(_filter_modifier_pill(modifier, active_modifier_label))
for option in included:
pills_children.append(_filter_value_pill(option, "include"))
for option in excluded:
pills_children.append(_filter_value_pill(option, "exclude"))
pills = Div(
attributes=[("data-search-select-pills", ""), ("class", _PILLS_CLASS)],
children=pills_children,
)
# ── Search box (NO name — the query is never submitted) ──
search_attributes: list[HTMLAttribute] = [
("data-search-select-search", ""),
("placeholder", placeholder),
("autocomplete", "off"),
("class", _SEARCH_CLASS),
]
# ── Options: pinned modifier rows, then value rows (pre-rendered only when
# there is no search_url; otherwise the JS fetches them) ──
modifier_rows = [
_filter_modifier_row(value, label) for value, label in modifier_options
]
value_rows = (
[_filter_option_row(option["value"], option["label"]) for option in options]
if not search_url
else []
)
# ── Templates the JS clones: include/exclude pills (added on click), the
# modifier pill (when modifiers exist), and a value row (when fetched). ──
templates: list[SafeText] = [
Template(
attributes=[("data-search-select-template", "pill-include")],
children=[_filter_value_pill(_BLANK_OPTION, "include")],
),
Template(
attributes=[("data-search-select-template", "pill-exclude")],
children=[_filter_value_pill(_BLANK_OPTION, "exclude")],
),
]
if modifier_options:
templates.append(
Template(
attributes=[("data-search-select-template", "pill-modifier")],
children=[_filter_modifier_pill("", "")],
)
)
if search_url:
templates.append(
Template(
attributes=[("data-search-select-template", "row")],
children=[_filter_option_row("", "")],
)
)
container_attributes: list[HTMLAttribute] = [
("data-search-select", ""),
("data-search-select-mode", "filter"),
("data-name", field_name),
("data-search-url", search_url),
("data-multi", "true"),
("data-always-visible", "false"),
("data-items-visible", str(items_visible)),
("data-items-scroll", str(items_scroll)),
("data-prefetch", str(prefetch)),
("data-sync-url", "false"),
("class", _CONTAINER_CLASS),
]
if modifier:
container_attributes.append(("data-modifier", modifier))
if id:
container_attributes.append(("id", id))
return _combobox_shell(
container_attributes=container_attributes,
pills=pills,
search_attributes=search_attributes,
options_children=[*modifier_rows, *value_rows],
always_visible=False,
items_visible=items_visible,
templates=templates,
)
def searchselect_selected(
values: list,
resolver: Callable[[list], Iterable[SearchSelectOption]],
) -> list[SearchSelectOption]:
"""Resolve ``values`` into ``SearchSelectOption``s via ``resolver``.
``resolver(values)`` should resolve ONLY the given ids (a ``pk__in`` query)
— never iterating all choices, so it stays cheap.
"""
if not values:
return []
return [_normalize_option(option) for option in resolver(values)]
+512
View File
@@ -0,0 +1,512 @@
"""
Typed criterion inputs for building structured filters.
Inspired by Stash's filter architecture: every filterable field uses a typed
criterion with a value and a CriterionModifier. This separates *what* you're
filtering from *how* you're comparing, and makes filter serialization trivial.
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field, fields as dc_fields
from enum import Enum
from typing import Any, Self, TypeVar
from django.db.models import Q
# ── Modifier ──────────────────────────────────────────────────────────────
class Modifier(str, Enum):
"""Comparison operators shared across all criterion types."""
EQUALS = "EQUALS"
NOT_EQUALS = "NOT_EQUALS"
GREATER_THAN = "GREATER_THAN"
LESS_THAN = "LESS_THAN"
BETWEEN = "BETWEEN"
NOT_BETWEEN = "NOT_BETWEEN"
INCLUDES = "INCLUDES"
EXCLUDES = "EXCLUDES"
INCLUDES_ALL = "INCLUDES_ALL"
INCLUDES_ONLY = "INCLUDES_ONLY"
IS_NULL = "IS_NULL"
NOT_NULL = "NOT_NULL"
MATCHES_REGEX = "MATCHES_REGEX"
NOT_MATCHES_REGEX = "NOT_MATCHES_REGEX"
@classmethod
def for_strings(cls) -> list[Self]:
return [
cls.EQUALS,
cls.NOT_EQUALS,
cls.INCLUDES,
cls.EXCLUDES,
cls.MATCHES_REGEX,
cls.NOT_MATCHES_REGEX,
cls.IS_NULL,
cls.NOT_NULL,
]
@classmethod
def for_numbers(cls) -> list[Self]:
return [
cls.EQUALS,
cls.NOT_EQUALS,
cls.GREATER_THAN,
cls.LESS_THAN,
cls.BETWEEN,
cls.NOT_BETWEEN,
cls.IS_NULL,
cls.NOT_NULL,
]
@classmethod
def for_dates(cls) -> list[Self]:
return cls.for_numbers()
@classmethod
def for_multi(cls) -> list[Self]:
return [
cls.INCLUDES,
cls.EXCLUDES,
cls.INCLUDES_ALL,
cls.INCLUDES_ONLY,
cls.IS_NULL,
cls.NOT_NULL,
]
# ── Base criterion ─────────────────────────────────────────────────────────
T = TypeVar("T")
@dataclass
class _Criterion:
"""Base for all typed criteria."""
value: Any = None
modifier: Modifier = Modifier.EQUALS
def to_q(self, field_name: str) -> Q:
raise NotImplementedError
@classmethod
def from_json(cls, data: dict | None) -> Self | None:
if data is None or not isinstance(data, dict):
return None
kwargs: dict[str, Any] = {}
for f in dc_fields(cls):
if f.name in data:
val = data[f.name]
# Coerce string modifier to Modifier enum
if f.name == "modifier" and isinstance(val, str):
val = Modifier(val)
kwargs[f.name] = val
return cls(**kwargs)
def to_json(self) -> dict[str, Any]:
result: dict[str, Any] = {}
for f in dc_fields(self):
v = getattr(self, f.name)
if v is not None and v != f.default:
result[f.name] = v
return result
# ── Concrete criteria ──────────────────────────────────────────────────────
@dataclass
class StringCriterion(_Criterion):
value: str = ""
modifier: Modifier = Modifier.EQUALS
def to_q(self, field_name: str) -> Q:
m = self.modifier
if m == Modifier.EQUALS:
return Q(**{field_name: self.value})
if m == Modifier.NOT_EQUALS:
return ~Q(**{field_name: self.value})
if m == Modifier.INCLUDES:
return Q(**{f"{field_name}__icontains": self.value})
if m == Modifier.EXCLUDES:
return ~Q(**{f"{field_name}__icontains": self.value})
if m == Modifier.MATCHES_REGEX:
return Q(**{f"{field_name}__regex": self.value})
if m == Modifier.NOT_MATCHES_REGEX:
return ~Q(**{f"{field_name}__regex": self.value})
if m == Modifier.IS_NULL:
return Q(**{f"{field_name}__isnull": True})
if m == Modifier.NOT_NULL:
return Q(**{f"{field_name}__isnull": False})
raise ValueError(f"Unsupported modifier {m} for string field")
@dataclass
class IntCriterion(_Criterion):
value: int = 0
value2: int | None = None
modifier: Modifier = Modifier.EQUALS
def to_q(self, field_name: str) -> Q:
m = self.modifier
if m == Modifier.EQUALS:
return Q(**{field_name: self.value})
if m == Modifier.NOT_EQUALS:
return ~Q(**{field_name: self.value})
if m == Modifier.GREATER_THAN:
return Q(**{f"{field_name}__gt": self.value})
if m == Modifier.LESS_THAN:
return Q(**{f"{field_name}__lt": self.value})
if m == Modifier.BETWEEN:
if self.value2 is None:
raise ValueError("BETWEEN requires value2")
return Q(
**{
f"{field_name}__gte": min(self.value, self.value2),
f"{field_name}__lte": max(self.value, self.value2),
}
)
if m == Modifier.NOT_BETWEEN:
if self.value2 is None:
raise ValueError("NOT_BETWEEN requires value2")
lo, hi = min(self.value, self.value2), max(self.value, self.value2)
return Q(**{f"{field_name}__lt": lo}) | Q(**{f"{field_name}__gt": hi})
if m == Modifier.IS_NULL:
return Q(**{f"{field_name}__isnull": True})
if m == Modifier.NOT_NULL:
return Q(**{f"{field_name}__isnull": False})
raise ValueError(f"Unsupported modifier {m} for int field")
@dataclass
class FloatCriterion(_Criterion):
value: float = 0.0
value2: float | None = None
modifier: Modifier = Modifier.EQUALS
def to_q(self, field_name: str) -> Q:
m = self.modifier
if m == Modifier.EQUALS:
return Q(**{field_name: self.value})
if m == Modifier.NOT_EQUALS:
return ~Q(**{field_name: self.value})
if m == Modifier.GREATER_THAN:
return Q(**{f"{field_name}__gt": self.value})
if m == Modifier.LESS_THAN:
return Q(**{f"{field_name}__lt": self.value})
if m == Modifier.BETWEEN:
if self.value2 is None:
raise ValueError("BETWEEN requires value2")
return Q(
**{
f"{field_name}__gte": min(self.value, self.value2),
f"{field_name}__lte": max(self.value, self.value2),
}
)
if m == Modifier.NOT_BETWEEN:
if self.value2 is None:
raise ValueError("NOT_BETWEEN requires value2")
lo, hi = min(self.value, self.value2), max(self.value, self.value2)
return Q(**{f"{field_name}__lt": lo}) | Q(**{f"{field_name}__gt": hi})
if m == Modifier.IS_NULL:
return Q(**{f"{field_name}__isnull": True})
if m == Modifier.NOT_NULL:
return Q(**{f"{field_name}__isnull": False})
raise ValueError(f"Unsupported modifier {m} for float field")
@dataclass
class DateCriterion(_Criterion):
value: str = ""
value2: str | None = None
modifier: Modifier = Modifier.EQUALS
def to_q(self, field_name: str) -> Q:
m = self.modifier
if m == Modifier.EQUALS:
return Q(**{field_name: self.value})
if m == Modifier.NOT_EQUALS:
return ~Q(**{field_name: self.value})
if m == Modifier.GREATER_THAN:
return Q(**{f"{field_name}__gt": self.value})
if m == Modifier.LESS_THAN:
return Q(**{f"{field_name}__lt": self.value})
if m == Modifier.BETWEEN:
if self.value2 is None:
raise ValueError("BETWEEN requires value2")
return Q(
**{f"{field_name}__gte": self.value, f"{field_name}__lte": self.value2}
)
if m == Modifier.NOT_BETWEEN:
if self.value2 is None:
raise ValueError("NOT_BETWEEN requires value2")
return Q(**{f"{field_name}__lt": self.value}) | Q(
**{f"{field_name}__gt": self.value2}
)
if m == Modifier.IS_NULL:
return Q(**{f"{field_name}__isnull": True})
if m == Modifier.NOT_NULL:
return Q(**{f"{field_name}__isnull": False})
raise ValueError(f"Unsupported modifier {m} for date field")
@dataclass
class BoolCriterion(_Criterion):
value: bool = False
# Bool only makes sense with EQUALS
modifier: Modifier = Modifier.EQUALS
def to_q(self, field_name: str) -> Q:
if self.modifier == Modifier.EQUALS:
return Q(**{field_name: self.value})
if self.modifier == Modifier.NOT_EQUALS:
return ~Q(**{field_name: self.value})
raise ValueError(f"Unsupported modifier {self.modifier} for bool field")
@dataclass
class _SetCriterion(_Criterion):
"""Shared base for set-membership criteria (``MultiCriterion`` /
``ChoiceCriterion``).
Two orthogonal channels, mirroring Stash's modifier model:
- ``value`` is the *include* set. The ``modifier`` governs how it matches:
- ``INCLUDES`` — in ``value`` (match *any*); ``EQUALS`` is an alias.
- ``INCLUDES_ALL`` — related to *all* of ``value`` (meaningful for
many-to-many fields, e.g. a purchase's games).
- ``EXCLUDES`` — in none of ``value`` (match *none*); ``NOT_EQUALS`` is an
alias.
- ``excludes`` is an *always-orthogonal* negative: it contributes
``AND NOT IN (excludes)`` for every (non-presence) modifier, never
swapped into the include set. An exclude-only criterion therefore means
"everything except ``excludes``".
Empty lists contribute no constraint. ``IS_NULL`` / ``NOT_NULL`` test
presence and ignore both lists.
The logic lives entirely here so the two subclasses (which differ only in
their value type) cannot drift.
"""
value: list = field(default_factory=list)
excludes: list = field(default_factory=list)
modifier: Modifier = Modifier.INCLUDES
def to_q(self, field_name: str) -> Q:
modifier = self.modifier
if modifier == Modifier.IS_NULL:
return Q(**{f"{field_name}__isnull": True})
if modifier == Modifier.NOT_NULL:
return Q(**{f"{field_name}__isnull": False})
# The modifier governs only the include set; ``excludes`` is an orthogonal
# AND'd negative applied for every (non-presence) modifier.
q = self._value_q(field_name)
if self.excludes:
q &= ~Q(**{f"{field_name}__in": self.excludes})
return q
def _value_q(self, field_name: str) -> Q:
"""Build the Q for the include (``value``) set, per the modifier."""
modifier = self.modifier
if modifier in (Modifier.INCLUDES, Modifier.EQUALS):
return Q(**{f"{field_name}__in": self.value}) if self.value else Q()
if modifier in (Modifier.EXCLUDES, Modifier.NOT_EQUALS):
return ~Q(**{f"{field_name}__in": self.value}) if self.value else Q()
if modifier in (Modifier.INCLUDES_ALL, Modifier.INCLUDES_ONLY):
# INCLUDES_ALL ("related to all of these") and INCLUDES_ONLY
# ("related to exactly these, nothing else") are only meaningful
# for many-to-many fields. A naive Q(field=a) & Q(field=b)
# collapses to a single join requiring one through-row to equal
# both values (impossible), so the generic criterion layer cannot
# build a correct Q. M2M callers must supply their own Q builder
# at the filter level — see PurchaseFilter._games_to_q for the
# chained-subquery pattern.
assert False, (
f"{modifier} requires a filter-level Q builder for M2M fields. "
"See PurchaseFilter._games_to_q for the chained-subquery pattern."
)
raise ValueError(f"Unsupported modifier {modifier} for {type(self).__name__}")
@classmethod
def from_json(cls, data: dict | None) -> Self | None:
result = super().from_json(data)
if result is None:
return None
# Labels embedded as {id, label} dicts are display-only; strip to bare ids
# so the querying layer stays clean and typed.
result.value = [
item["id"] if isinstance(item, dict) else item for item in result.value
]
result.excludes = [
item["id"] if isinstance(item, dict) else item for item in result.excludes
]
return result
@dataclass
class MultiCriterion(_SetCriterion):
"""Filter on a many-to-many or ForeignKey relationship by ID list.
All modifier logic (including ``INCLUDES_ALL`` and ``EXCLUDES``) lives in
``_SetCriterion``; this subclass only refines the value type.
"""
value: list[int] = field(default_factory=list)
excludes: list[int] = field(default_factory=list)
@dataclass
class ChoiceCriterion(_SetCriterion):
"""Filter on a choice/enum field with multi-select include/exclude.
Used by FilterSelect widgets for status, ownership_type, etc. Shares all
modifier logic with ``MultiCriterion`` via ``_SetCriterion``.
"""
value: list[str] = field(default_factory=list)
excludes: list[str] = field(default_factory=list)
# ── OperatorFilter base ────────────────────────────────────────────────────
F = TypeVar("F", bound="OperatorFilter")
@dataclass
class OperatorFilter:
"""Mixin providing AND/OR/NOT composition for entity filter types.
Subclasses should declare nullable references to themselves::
@dataclass
class GameFilter(OperatorFilter):
AND: "GameFilter | None" = None
OR: "GameFilter | None" = None
NOT: "GameFilter | None" = None
name: StringCriterion | None = None
...
"""
def sub_filter(self) -> OperatorFilter | None:
"""Return the first non-None of AND / OR / NOT."""
for attr in ("AND", "OR", "NOT"):
if hasattr(self, attr):
v = getattr(self, attr)
if v is not None:
return v
return None
def _criterion_fields(self) -> list[str]:
"""Return field names that hold a _Criterion instance."""
names: list[str] = []
for f in dc_fields(self):
if f.name in ("AND", "OR", "NOT"):
continue
v = getattr(self, f.name)
if isinstance(v, _Criterion):
names.append(f.name)
return names
def to_q(self) -> Q:
"""Build a Django Q object from this filter and its sub-filters."""
q = Q()
for field_name in self._criterion_fields():
c = getattr(self, field_name)
if c is not None:
q &= c.to_q(field_name)
sub = self.sub_filter()
if sub is not None:
if getattr(self, "AND", None) is not None:
q &= sub.to_q()
elif getattr(self, "OR", None) is not None:
q |= sub.to_q()
elif getattr(self, "NOT", None) is not None:
q &= ~sub.to_q()
return q
@classmethod
def from_json(cls, data: dict[str, Any] | None) -> Self | None:
if data is None or not isinstance(data, dict):
return None
# Resolve criterion class names to actual types
criterion_types: dict[str, type[_Criterion]] = {
"StringCriterion": StringCriterion,
"IntCriterion": IntCriterion,
"FloatCriterion": FloatCriterion,
"DateCriterion": DateCriterion,
"BoolCriterion": BoolCriterion,
"MultiCriterion": MultiCriterion,
"ChoiceCriterion": ChoiceCriterion,
}
kwargs: dict[str, Any] = {}
for f in dc_fields(cls):
if f.name not in data:
continue
raw = data[f.name]
if raw is None:
kwargs[f.name] = None
continue
# Recurse into sub-filters (AND / OR / NOT)
if f.name in ("AND", "OR", "NOT"):
kwargs[f.name] = cls.from_json(raw) if isinstance(raw, dict) else None
continue
# Resolve criterion fields from string type annotation
f_type = f.type
if isinstance(f_type, str):
# e.g. "StringCriterion | None" → "StringCriterion"
f_type = f_type.split("|")[0].strip()
if isinstance(f_type, str) and f_type in criterion_types:
criterion_cls = criterion_types[f_type]
kwargs[f.name] = (
criterion_cls.from_json(raw) if isinstance(raw, dict) else None
)
elif isinstance(f_type, type) and issubclass(f_type, _Criterion):
kwargs[f.name] = (
f_type.from_json(raw) if isinstance(raw, dict) else None
)
return cls(**kwargs)
def to_json(self) -> dict[str, Any]:
result: dict[str, Any] = {}
for f in dc_fields(self):
v = getattr(self, f.name)
if v is None:
continue
if f.name in ("AND", "OR", "NOT"):
result[f.name] = v.to_json()
elif isinstance(v, _Criterion):
j = v.to_json()
if j:
result[f.name] = j
return result
# ── JSON helpers ───────────────────────────────────────────────────────────
def filter_from_json(cls: type[F], json_str: str) -> F | None:
"""Deserialize a filter from a JSON string.
Usage:
f = filter_from_json(GameFilter, request.GET.get("filter", ""))
games = Game.objects.filter(f.to_q())
"""
if not json_str:
return None
try:
data = json.loads(json_str)
except json.JSONDecodeError:
return None
return cls.from_json(data)
def filter_to_json(f: OperatorFilter) -> str:
"""Serialize a filter to a JSON string for URL params or storage."""
return json.dumps(f.to_json())
+25
View File
@@ -0,0 +1,25 @@
import functools
from pathlib import Path
_ICON_DIR = Path(__file__).resolve().parent.parent / "games" / "templates" / "icons"
@functools.lru_cache(maxsize=1)
def _load_icons() -> dict[str, str]:
"""Load all icon HTML files into a dict.
Cached so files are read once per process lifetime.
Delegation (e.g. nintendo-3ds -> nintendo) is handled by
both files containing identical SVG content.
"""
icons: dict[str, str] = {}
for filepath in _ICON_DIR.glob("*.html"):
name = filepath.stem
icons[name] = filepath.read_text()
return icons
def get_icon(name: str) -> str:
"""Return the HTML for an icon by name. Falls back to 'unspecified'."""
icons = _load_icons()
return icons.get(name, icons.get("unspecified", ""))
+2 -2
View File
@@ -20,8 +20,8 @@ def import_data(data: DataList):
# try exact match first
try:
game_id = Game.objects.get(name__iexact=name)
except:
pass
except (Game.DoesNotExist, Game.MultipleObjectsReturned):
game_id = None
matching_names[name] = game_id
print(f"Exact matched {len(matching_names)} games.")
+212 -24
View File
@@ -1,46 +1,234 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import 'tailwindcss';
form label {
@apply dark:text-slate-400;
@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;
}
form input,
select,
textarea {
@apply dark:border dark:border-slate-900 dark:bg-slate-500 dark:text-slate-100;
/*
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);
}
}
#session-table {
display: grid;
grid-template-columns: 3fr 2fr repeat(2, 1fr) 0.5fr 1fr;
@utility min-w-20char {
min-width: 20ch;
}
.purchase-name > span:nth-child(2) {
@apply ml-4
@utility max-w-20char {
max-width: 20ch;
}
.purchase-name > span:nth-child(2) > a > img {
@apply opacity-0 transition-opacity duration-500
@utility min-w-30char {
min-width: 30ch;
}
.purchase-name:hover > span:nth-child(2) > a > img {
@apply opacity-50
@utility max-w-30char {
max-width: 30ch;
}
.purchase-name > span:nth-child(2) > a > img:hover {
@apply opacity-100
@utility max-w-35char {
max-width: 35ch;
}
@utility max-w-40char {
max-width: 40ch;
}
@layer utilities {
@font-face {
font-family: 'IBM Plex Mono';
src: url('fonts/IBMPlexMono-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'IBM Plex Sans';
src: url('fonts/IBMPlexSans-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'IBM Plex Serif';
src: url('fonts/IBMPlexSerif-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'IBM Plex Serif';
src: url('fonts/IBMPlexSerif-Bold.woff2') format('woff2');
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: 'IBM Plex Sans Condensed';
src: url('fonts/IBMPlexSansCondensed-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
}
.responsive-table {
@apply dark:text-white mx-auto table-fixed;
}
.responsive-table tr:nth-child(even) {
@apply bg-indigo-100 dark:bg-slate-800;
}
.responsive-table tbody tr:nth-child(odd) {
@apply bg-indigo-200 dark:bg-slate-900;
}
.responsive-table thead th {
@apply text-left border-b-2 border-b-slate-500 text-xl;
}
.responsive-table thead th:not(:first-child),
.responsive-table td:not(:first-child) {
@apply border-l border-l-slate-500;
}
}
form input:disabled,
select:disabled,
textarea:disabled {
@apply cursor-not-allowed bg-neutral-secondary-strong text-fg-disabled;
}
.errorlist {
@apply mt-4 mb-1 pl-3 py-2 bg-red-600 text-slate-200 w-[300px];
}
#button-container button {
@apply mx-1;
}
th {
@apply text-left;
.basic-button-container {
@apply flex space-x-2 justify-center;
}
th label {
@apply mr-4;
.basic-button {
@apply inline-block px-6 py-2.5 bg-blue-600 text-white font-medium text-xs leading-tight uppercase rounded-sm shadow-md hover:bg-blue-700 hover:shadow-lg focus:bg-blue-700 focus:shadow-lg focus:outline-hidden focus:ring-0 active:bg-blue-800 active:shadow-lg transition duration-150 ease-in-out;
}
.markdown-content ul {
list-style-type: disc;
list-style-position: inside;
padding-left: 1em;
}
.markdown-content ol {
list-style-type: decimal;
list-style-position: inside;
padding-left: 1em;
}
.markdown-content ul,
.markdown-content ol {
list-style-position: outside;
padding-left: 1em;
}
.markdown-content ul ul,
.markdown-content ul ol,
.markdown-content ol ul,
.markdown-content ol ol {
list-style-type: circle;
margin-top: 0.5em;
margin-bottom: 0.5em;
padding-left: 1em;
}
#add-form {
label + select, input, textarea {
@apply mt-1;
}
form {
@apply flex flex-col gap-3;
}
.form-row-button-group {
display: flex;
flex-direction: row;
@apply gap-0 p-0;
button {
@apply mr-0;
&:first-child {
@apply rounded-e-none;
}
&:nth-child(2) {
@apply rounded-none;
}
&:last-child {
@apply rounded-s-none;
}
}
}
label {
@apply mb-2.5 text-sm font-medium text-heading;
}
input:not([type="checkbox"]):not([data-search-select-search]) {
@apply mb-3 bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand block w-full px-3 py-2.5 shadow-xs placeholder:text-body;
}
input[type="checkbox"] {
@apply w-4 h-4 border border-default-medium rounded-xs bg-neutral-secondary-medium focus:ring-2 focus:ring-brand-soft;
}
select {
@apply w-full px-3 py-2.5 bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand shadow-xs placeholder:text-body;
}
textarea {
@apply bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand block w-full p-3.5 shadow-xs placeholder:text-body;
}
:has(> label + input[type="checkbox"]) {
@apply mt-3; /* needed because compared to all other form elements checkbox and its label are on the same row */
display: flex;
flex-direction: row;
justify-content: space-between;
}
}
@layer utilities {
.toast-container {
@apply fixed z-50 flex flex-col items-end bottom-0 right-0 p-4;
}
}
+353
View File
@@ -0,0 +1,353 @@
"""A small fast_app-style layout system.
Instead of Django template inheritance (`{% extends "base.html" %}`), views
build their page body with Python components and wrap it with `Page()` /
`render_page()`. `Page()` is the equivalent of FastHTML's document wrapper:
it hoists shared `<head>` content (the `_HEADERS` block, analogous to
`fast_app(hdrs=...)`), renders the navbar, and assembles the full document.
"""
import json
from django.contrib.messages import get_messages
from django.http import HttpRequest, HttpResponse
from django.templatetags.static import static
from django.urls import reverse
from django.utils.html import conditional_escape
from django.utils.safestring import SafeText, mark_safe
from django_htmx.jinja import django_htmx_script
from games.templatetags.version import version, version_date
# Static head script that sets the dark/light class before paint (avoids FOUC).
_THEME_FOUC_SCRIPT = """<script>
if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark')
}
</script>"""
# The main module script: crown icon mount + theme-toggle wiring.
# Split around the single dynamic value (game.mastered).
_MAIN_SCRIPT_A = """<script type="module">
document.addEventListener('DOMContentLoaded', () => {
if (window.mountCrownIcon) {
window.mountCrownIcon('#crown-icon-mount-point', {
mastered: """
_MAIN_SCRIPT_B = """
});
}
const themeToggleDarkIcon = document.getElementById('theme-toggle-dark-icon');
const themeToggleLightIcon = document.getElementById('theme-toggle-light-icon');
const themeToggleBtn = document.getElementById('theme-toggle');
if (themeToggleDarkIcon && themeToggleLightIcon && themeToggleBtn) {
if (document.documentElement.classList.contains('dark')) {
themeToggleLightIcon.classList.remove('hidden');
themeToggleDarkIcon.classList.add('hidden');
} else {
themeToggleDarkIcon.classList.remove('hidden');
themeToggleLightIcon.classList.add('hidden');
}
themeToggleBtn.addEventListener('click', function () {
themeToggleDarkIcon.classList.toggle('hidden');
themeToggleLightIcon.classList.toggle('hidden');
if (localStorage.getItem('color-theme')) {
if (localStorage.getItem('color-theme') === 'light') {
document.documentElement.classList.add('dark');
localStorage.setItem('color-theme', 'dark');
} else {
document.documentElement.classList.remove('dark');
localStorage.setItem('color-theme', 'light');
}
} else {
if (document.documentElement.classList.contains('dark')) {
document.documentElement.classList.remove('dark');
localStorage.setItem('color-theme', 'light');
} else {
document.documentElement.classList.add('dark');
localStorage.setItem('color-theme', 'dark');
}
}
});
}
});
</script>"""
# Toast notification region (Alpine.js). Verbatim from the old base.html.
_TOAST_CONTAINER = """<div x-data="toastStore()"
role="region"
aria-label="Notifications"
aria-atomic="true"
class="fixed z-50 bottom-0 right-0 flex flex-col items-end pointer-events-none p-4">
<template x-for="toast in $store.toasts.toasts" :key="toast.id">
<div x-show="toast.visible"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 translate-x-8"
x-transition:enter-end="opacity-100 translate-x-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 translate-x-0"
x-transition:leave-end="opacity-0 translate-x-8"
:role="toast.type === 'error' || toast.type === 'warning' ? 'alert' : 'status'"
:aria-live="toast.type === 'error' ? 'assertive' : 'polite'"
tabindex="0"
class="pointer-events-auto max-w-sm w-72 cursor-pointer mb-3 last:mb-0"
:class="{
'success': toast.type === 'success',
'error': toast.type === 'error',
'info': toast.type === 'info',
'warning': toast.type === 'warning',
'debug': toast.type === 'debug'
}"
@click="dismissToast(toast.id)"
@mouseenter="$store.toasts.clearToastTimer(toast.id)"
@mouseleave="$store.toasts.resumeToastTimer(toast.id, 5000)"
@keydown.escape="dismissToast(toast.id)">
<div class="rounded-lg shadow-lg p-4 flex items-start gap-3"
:class="{
'bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700': toast.type === 'success',
'bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700': toast.type === 'error',
'bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700': toast.type === 'info',
'bg-amber-50 dark:bg-amber-900 border border-amber-200 dark:border-amber-700': toast.type === 'warning',
'bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700': toast.type === 'debug'
}">
<span class="flex-shrink-0 mt-0.5"
:class="{
'text-green-500': toast.type === 'success',
'text-red-500': toast.type === 'error',
'text-blue-500': toast.type === 'info',
'text-amber-500': toast.type === 'warning',
'text-gray-500': toast.type === 'debug'
}">
<template x-if="toast.type === 'success'">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</template>
<template x-if="toast.type === 'error'">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</template>
<template x-if="toast.type === 'info'">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M12 20a8 8 0 100-16 8 8 0 000 16z"/>
</svg>
</template>
<template x-if="toast.type === 'warning'">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 13l5 5 5-5M7 6l5 5 5-5"/>
</svg>
</template>
<template x-if="toast.type === 'debug'">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
</template>
</span>
<p class="flex-1 text-sm"
:class="{
'text-green-800 dark:text-green-200': toast.type === 'success',
'text-red-800 dark:text-red-200': toast.type === 'error',
'text-blue-800 dark:text-blue-200': toast.type === 'info',
'text-amber-800 dark:text-amber-200': toast.type === 'warning',
'text-gray-800 dark:text-gray-200': toast.type === 'debug'
}"
x-text="toast.message"></p>
<button @click.stop="dismissToast(toast.id)"
class="flex-shrink-0"
:class="{
'text-green-400 hover:text-green-600 dark:text-green-500 dark:hover:text-green-300': toast.type === 'success',
'text-red-400 hover:text-red-600 dark:text-red-500 dark:hover:text-red-300': toast.type === 'error',
'text-blue-400 hover:text-blue-600 dark:text-blue-500 dark:hover:text-blue-300': toast.type === 'info',
'text-amber-400 hover:text-amber-600 dark:text-amber-500 dark:hover:text-amber-300': toast.type === 'warning',
'text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300': toast.type === 'debug'
}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</div>
</template>
</div>"""
def _main_script(mastered: bool) -> str:
return _MAIN_SCRIPT_A + ("true" if mastered else "false") + _MAIN_SCRIPT_B
def Navbar(*, today_played: str, last_7_played: str, current_year: int) -> SafeText:
"""Top navigation bar."""
logo = static("icons/schedule.png")
return mark_safe(f"""<nav class="bg-neutral-primary-soft border-b border-default">
<div class="max-w-(--breakpoint-xl) flex flex-wrap items-center justify-between mx-auto p-4">
<a href="{reverse("games:index")}"
class="flex items-center space-x-3 rtl:space-x-reverse">
<img src="{logo}" height="48" width="48" alt="Timetracker Logo" class="mr-4" />
<span class="self-center text-2xl font-semibold whitespace-nowrap dark:text-white">Timetracker</span>
</a>
<button data-collapse-toggle="navbar-dropdown" type="button"
class="inline-flex items-center p-2 w-10 h-10 justify-center text-sm text-gray-500 rounded-lg md:hidden hover:bg-gray-100 focus:outline-hidden focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600"
aria-controls="navbar-dropdown" aria-expanded="false">
<span class="sr-only">Open main menu</span>
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 17 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 1h15M1 7h15M1 13h15" />
</svg>
</button>
<div class="hidden w-full md:block md:w-auto" id="navbar-dropdown">
<ul class="items-center flex flex-col font-medium p-4 md:p-0 mt-4 border border-gray-100 rounded-lg bg-gray-50 md:space-x-8 rtl:space-x-reverse md:flex-row md:mt-0 md:border-0 md:bg-white dark:bg-gray-800 md:dark:bg-gray-900 dark:border-gray-700">
<li class="flex items-center">
<button id="theme-toggle" type="button" class="p-2 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-hidden focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg text-sm hover:cursor-pointer">
<svg id="theme-toggle-dark-icon" class="hidden w-5 h-5" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M3.32031 11.6835C3.32031 16.6541 7.34975 20.6835 12.3203 20.6835C16.1075 20.6835 19.3483 18.3443 20.6768 15.032C19.6402 15.4486 18.5059 15.6834 17.3203 15.6834C12.3497 15.6834 8.32031 11.654 8.32031 6.68342C8.32031 5.50338 8.55165 4.36259 8.96453 3.32996C5.65605 4.66028 3.32031 7.89912 3.32031 11.6835Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<svg id="theme-toggle-light-icon" class="hidden w-5 h-5" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M12 3V4M12 20V21M4 12H3M6.31412 6.31412L5.5 5.5M17.6859 6.31412L18.5 5.5M6.31412 17.69L5.5 18.5001M17.6859 17.69L18.5 18.5001M21 12H20M16 12C16 14.2091 14.2091 16 12 16C9.79086 16 8 14.2091 8 12C8 9.79086 9.79086 8 12 8C14.2091 8 16 9.79086 16 12Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</li>
<li class="dark:text-white flex flex-col items-center text-xs">
<span class="flex uppercase gap-1">Today<span class="dark:text-gray-400">·</span>Last 7 days</span>
<span class="flex items-center gap-1">{today_played}<span class="dark:text-gray-400">·</span>{last_7_played}</span>
</li>
<li>
<a href="#" class="block py-2 px-3 text-white bg-blue-700 rounded-sm md:bg-transparent md:text-blue-700 md:p-0 md:dark:text-blue-500 dark:bg-blue-600 md:dark:bg-transparent" aria-current="page">Home</a>
</li>
<li>
<button id="dropdownNavbarNewLink" data-dropdown-toggle="dropdownNavbarNew"
class="flex items-center justify-between w-full py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 md:w-auto dark:text-white md:dark:hover:text-blue-500 dark:focus:text-white dark:border-gray-700 dark:hover:bg-gray-700 md:dark:hover:bg-transparent hover:cursor-pointer">
New
<svg class="w-2.5 h-2.5 ms-2.5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 10 6">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4" />
</svg>
</button>
<div id="dropdownNavbarNew" class="z-10 hidden font-normal bg-white divide-y divide-gray-100 rounded-lg shadow-sm w-44 dark:bg-gray-700 dark:divide-gray-600">
<ul class="py-2 text-sm text-gray-700 dark:text-gray-400" aria-labelledby="dropdownLargeButton">
<li><a href="{reverse("games:add_device")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Device</a></li>
<li><a href="{reverse("games:add_game")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Game</a></li>
<li><a href="{reverse("games:add_platform")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platform</a></li>
<li><a href="{reverse("games:add_purchase")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Purchase</a></li>
<li><a href="{reverse("games:add_session")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Session</a></li>
</ul>
</div>
</li>
<li>
<button id="dropdownNavbarManageLink" data-dropdown-toggle="dropdownNavbarManage"
class="flex items-center justify-between w-full py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 md:w-auto dark:text-white md:dark:hover:text-blue-500 dark:focus:text-white dark:border-gray-700 dark:hover:bg-gray-700 md:dark:hover:bg-transparent hover:cursor-pointer">
Manage
<svg class="w-2.5 h-2.5 ms-2.5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 10 6">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4" />
</svg>
</button>
<div id="dropdownNavbarManage" class="z-10 hidden font-normal bg-white divide-y divide-gray-100 rounded-lg shadow-sm w-44 dark:bg-gray-700 dark:divide-gray-600">
<ul class="py-2 text-sm text-gray-700 dark:text-gray-400" aria-labelledby="dropdownLargeButton">
<li><a href="{reverse("games:list_devices")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Devices</a></li>
<li><a href="{reverse("games:list_games")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Games</a></li>
<li><a href="{reverse("games:list_platforms")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platforms</a></li>
<li><a href="{reverse("games:list_playevents")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Play events</a></li>
<li><a href="{reverse("games:list_purchases")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Purchases</a></li>
<li><a href="{reverse("games:list_sessions")}" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Sessions</a></li>
</ul>
</div>
</li>
<li>
<a href="{reverse("games:stats_by_year", args=[current_year])}" class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Stats</a>
</li>
<li>
<a href="{reverse("logout")}" class="block py-2 px-3 text-gray-900 rounded-sm hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Log out</a>
</li>
</ul>
</div>
</div>
</nav>""")
def Page(
content: SafeText | str,
*,
request: HttpRequest,
title: str = "",
scripts: SafeText | str = "",
mastered: bool = False,
) -> SafeText:
"""Assemble a full HTML document around `content` (the fast_app equivalent)."""
from games.views.general import global_current_year, model_counts
counts = model_counts(request)
year = global_current_year(request)["global_current_year"]
navbar = Navbar(
today_played=counts["today_played"],
last_7_played=counts["last_7_played"],
current_year=year,
)
messages = [
{"message": str(m.message), "type": (m.tags or "info")}
for m in get_messages(request)
]
# Embed as JSON; guard against `</script>` breaking out of the tag.
messages_json = json.dumps(messages).replace("</", "<\\/")
head = (
'<!DOCTYPE html>\n<html lang="en">\n <head>\n'
' <meta charset="utf-8" />\n'
' <meta name="description" content="Self-hosted time-tracker." />\n'
' <meta name="keywords" content="time, tracking, video games, self-hosted" />\n'
' <meta name="viewport" content="width=device-width, initial-scale=1" />\n'
f" <title>Timetracker - {conditional_escape(title)}</title>\n"
f' <script src="{static("js/htmx.min.js")}"></script>\n'
" <script>\n"
" htmx.config.scrollBehavior = 'smooth';\n"
" htmx.config.selfRequestsOnly = false;\n"
" </script>\n"
f' <script src="{static("js/htmx-redirect-toast.js")}"></script>\n'
f" {django_htmx_script(nonce=None)}\n"
f' <link rel="stylesheet" href="{static("base.css")}" />\n'
' <script src="https://cdn.jsdelivr.net/npm/flowbite@2.4.1/dist/flowbite.min.js"></script>\n'
' <script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/mask@3.x.x/dist/cdn.min.js"></script>\n'
' <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>\n'
f" {_THEME_FOUC_SCRIPT}\n"
" </head>\n"
)
body = (
' <body hx-indicator="#indicator" class="bg-neutral-primary">\n'
f' <script id="django-messages" type="application/json">{messages_json}</script>\n'
f' <img id="indicator" src="{static("icons/loading.png")}" class="absolute right-3 top-3 animate-spin htmx-indicator" height="24" width="24" alt="loading indicator" />\n'
' <div class="flex flex-col min-h-screen">\n'
f" {navbar}\n"
f' <div id="main-container" class="flex flex-1 flex-col pt-8 pb-16">{content}</div>\n'
f' <span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{version()} ({version_date()})</span>\n'
" </div>\n"
f" {scripts}\n"
f" {_main_script(mastered)}\n"
" <!-- hx-swap-oob makes sure the modal gets removed upon any HTMX response -->\n"
' <div id="global-modal-container" hx-swap-oob="true"></div>\n'
f" {_TOAST_CONTAINER}\n"
f' <script src="{static("js/toast.js")}"></script>\n'
" </body>\n</html>\n"
)
return mark_safe(head + body)
def render_page(
request: HttpRequest,
content: SafeText | str,
*,
title: str = "",
scripts: SafeText | str = "",
mastered: bool = False,
status: int = 200,
) -> HttpResponse:
"""`render()`-style shortcut: build a full page and return an HttpResponse."""
return HttpResponse(
Page(content, request=request, title=title, scripts=scripts, mastered=mastered),
status=status,
)
-95
View File
@@ -1,95 +0,0 @@
import base64
from datetime import datetime
from io import BytesIO
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
import pandas as pd
from django.db.models import F, IntegerField, QuerySet, Sum
from django.db.models.functions import TruncDay
from games.models import Session
def key_value_to_value_value(data):
return {data["date"]: data["hours"]}
def playtime_over_time_chart(queryset: QuerySet = Session.objects):
microsecond_in_second = 1000000
result = (
queryset.exclude(timestamp_end__exact=None)
.annotate(date=TruncDay("timestamp_start"))
.values("date")
.annotate(
hours=Sum(
F("duration_calculated"),
output_field=IntegerField(),
)
)
.values("date", "hours")
)
keys = []
values = []
running_total = int(0)
for item in result:
# date_value = datetime.strftime(item["date"], "%d-%m-%Y")
date_value = item["date"]
keys.append(date_value)
running_total += int(item["hours"] / (3600 * microsecond_in_second))
values.append(running_total)
data = [keys, values]
return get_chart(
data,
title="Playtime over time (manual excluded)",
xlabel="Date",
ylabel="Hours",
)
def get_graph():
buffer = BytesIO()
plt.savefig(buffer, format="svg", transparent=True)
buffer.seek(0)
image_png = buffer.getvalue()
graph = base64.b64encode(image_png)
graph = graph.decode("utf-8")
buffer.close()
return graph
def get_chart(data, title="", xlabel="", ylabel=""):
x = data[0]
y = data[1]
plt.style.use("dark_background")
plt.switch_backend("SVG")
fig, ax = plt.subplots()
fig.set_size_inches(10, 4)
lines = ax.plot(x, y, "-o")
first = x[0]
last = x[-1]
difference = last - first
if difference.days <= 14:
ax.xaxis.set_major_locator(mdates.DayLocator())
elif difference.days < 60 or len(x) < 60:
ax.xaxis.set_major_locator(mdates.WeekdayLocator())
ax.xaxis.set_minor_locator(mdates.DayLocator())
elif difference.days < 720:
ax.xaxis.set_major_locator(mdates.MonthLocator())
ax.xaxis.set_minor_locator(mdates.WeekdayLocator())
for line in lines:
line.set_marker("")
else:
for line in lines:
line.set_marker("")
ax.xaxis.set_major_locator(mdates.YearLocator())
ax.xaxis.set_minor_locator(mdates.MonthLocator())
ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m-%d"))
for label in ax.get_xticklabels(which="major"):
label.set(rotation=30, horizontalalignment="right")
ax.set_xlabel(xlabel)
ax.set_ylabel(ylabel)
ax.set_title(title)
fig.tight_layout()
chart = get_graph()
return chart
+125 -19
View File
@@ -1,16 +1,19 @@
import re
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from datetime import date, datetime, timedelta
from django.conf import settings
from django.utils import timezone
from common.utils import generate_split_ranges
def now() -> datetime:
return datetime.now(ZoneInfo(settings.TIME_ZONE))
dateformat: str = "%d/%m/%Y"
datetimeformat: str = "%d/%m/%Y %H:%M"
timeformat: str = "%H:%M"
durationformat: str = "%2.1H hours"
durationformat_manual: str = "%H hours"
def _safe_timedelta(duration: timedelta | int | None):
if duration == None:
if duration is None:
return timedelta(0)
elif isinstance(duration, int):
return timedelta(seconds=duration)
@@ -19,7 +22,7 @@ def _safe_timedelta(duration: timedelta | int | None):
def format_duration(
duration: timedelta | int | None, format_string: str = "%H hours"
duration: timedelta | int | float | None, format_string: str = "%H hours"
) -> str:
"""
Format timedelta into the specified format_string.
@@ -32,32 +35,135 @@ def format_duration(
from the formatting string. For example:
- 61 seconds as "%s" = 61 seconds
- 61 seconds as "%m %s" = 1 minutes 1 seconds"
Format specifiers can include width and precision options:
- %5.2H: hours formatted with width 5 and 2 decimal places (padded with zeros)
"""
minute_seconds = 60
hour_seconds = 60 * minute_seconds
day_seconds = 24 * hour_seconds
duration = _safe_timedelta(duration)
safe_duration = _safe_timedelta(duration)
# we don't need float
seconds_total = int(duration.total_seconds())
seconds_total = int(safe_duration.total_seconds())
# timestamps where end is before start
if seconds_total < 0:
seconds_total = 0
days = hours = minutes = seconds = 0
days = hours = hours_float = minutes = seconds = 0
remainder = seconds = seconds_total
if "%d" in format_string:
days, remainder = divmod(seconds_total, day_seconds)
if "%H" in format_string:
hours, remainder = divmod(remainder, hour_seconds)
if "%m" in format_string:
if re.search(r"%\d*\.?\d*H", format_string):
hours_float, remainder = divmod(remainder, hour_seconds)
hours = float(hours_float) + remainder / hour_seconds
if re.search(r"%\d*\.?\d*m", format_string):
minutes, seconds = divmod(remainder, minute_seconds)
literals = {
"%d": str(days),
"%H": str(hours),
"%m": str(minutes),
"%s": str(seconds),
"%r": str(seconds_total),
"d": str(days),
"H": str(hours) if "m" not in format_string else str(hours_float),
"m": str(minutes),
"s": str(seconds),
"r": str(seconds_total),
}
formatted_string = format_string
for pattern, replacement in literals.items():
formatted_string = re.sub(pattern, replacement, formatted_string)
# Match format specifiers with optional width and precision
match = re.search(rf"%(\d*\.?\d*){pattern}", formatted_string)
if match:
format_spec = match.group(1)
if "." in format_spec:
# Format the number as float if precision is specified
replacement = f"{float(replacement):{format_spec}f}"
else:
# Format the number as integer if no precision is specified
replacement = f"{int(float(replacement)):>{format_spec}}"
# Replace the format specifier with the formatted number
formatted_string = re.sub(
rf"%\d*\.?\d*{pattern}", replacement, formatted_string
)
return formatted_string
def local_strftime(datetime: datetime, format: str = datetimeformat) -> str:
return timezone.localtime(datetime).strftime(format)
def daterange(start: date, end: date, end_inclusive: bool = False) -> list[date]:
time_between: timedelta = end - start
if (days_between := time_between.days) < 1:
raise ValueError("start and end have to be at least 1 day apart.")
if end_inclusive:
print(f"{end_inclusive=}")
print(f"{days_between=}")
days_between += 1
print(f"{days_between=}")
return [start + timedelta(x) for x in range(days_between)]
def streak(datelist: list[date]) -> dict[str, int | tuple[date, date]]:
if len(datelist) == 1:
return {"days": 1, "dates": (datelist[0], datelist[0])}
else:
print(f"Processing {len(datelist)} dates.")
missing = sorted(
set(
datelist[0] + timedelta(x)
for x in range((datelist[-1] - datelist[0]).days)
)
- set(datelist)
)
print(f"{len(missing)} days missing.")
datelist_with_missing = sorted(datelist + missing)
ranges = list(generate_split_ranges(datelist_with_missing, missing))
print(f"{len(ranges)} ranges calculated.")
longest_consecutive_days = timedelta(0)
longest_range: tuple[date, date] = (date(1970, 1, 1), date(1970, 1, 1))
for start, end in ranges:
if (current_streak := end - start) > longest_consecutive_days:
longest_consecutive_days = current_streak
longest_range = (start, end)
return {"days": longest_consecutive_days.days + 1, "dates": longest_range}
def streak_bruteforce(datelist: list[date]) -> dict[str, int | tuple[date, date]]:
if (datelist_length := len(datelist)) == 0:
raise ValueError("Number of dates in the list is 0.")
datelist.sort()
current_streak = 1
current_start = datelist[0]
current_end = datelist[0]
current_date = datelist[0]
highest_streak = 1
highest_streak_daterange = (current_start, current_end)
def update_highest_streak():
nonlocal highest_streak, highest_streak_daterange
if current_streak > highest_streak:
highest_streak = current_streak
highest_streak_daterange = (current_start, current_end)
def reset_streak():
nonlocal current_start, current_end, current_streak
current_start = current_end = current_date
current_streak = 1
def increment_streak():
nonlocal current_end, current_streak
current_end = current_date
current_streak += 1
for i, datelist_item in enumerate(datelist, start=1):
current_date = datelist_item
if current_date == current_start or current_date == current_end:
continue
if current_date - timedelta(1) != current_end and i != datelist_length:
update_highest_streak()
reset_streak()
elif current_date - timedelta(1) == current_end and i == datelist_length:
increment_streak()
update_highest_streak()
else:
increment_streak()
return {"days": highest_streak, "dates": highest_streak_daterange}
def available_stats_year_range():
return range(datetime.now().year, 1999, -1)
+190
View File
@@ -0,0 +1,190 @@
import operator
from dataclasses import dataclass
from datetime import date
from functools import reduce, wraps
from typing import Any, Callable, Generator, Literal, TypeVar
from urllib.parse import urlencode
from django.core.paginator import Page, Paginator
from django.db.models import Q
from django.http import HttpRequest
from django.shortcuts import redirect
def paginate(request: HttpRequest, queryset, per_page: int = 10):
"""Standard list-view pagination.
Reads ``page`` and ``limit`` from the query string (``limit=0`` disables
pagination) and returns ``(object_list, page_obj, elided_page_range)`` ready
to hand to ``paginated_table_content``.
"""
page_number = request.GET.get("page", 1)
limit = int(request.GET.get("limit", per_page))
object_list = queryset
page_obj: Page | None = None
if limit != 0:
page_obj = Paginator(queryset, limit).get_page(page_number)
object_list = page_obj.object_list
elided_page_range = (
page_obj.paginator.get_elided_page_range(page_number, on_each_side=1, on_ends=1)
if page_obj
else None
)
return object_list, page_obj, elided_page_range
def safe_division(numerator: int | float, denominator: int | float) -> int | float:
"""
Divides without triggering division by zero exception.
Returns 0 if denominator is 0.
"""
try:
return numerator / denominator
except ZeroDivisionError:
return 0
def safe_getattr(obj: object, attr_chain: str, default: Any | None = None) -> object:
"""
Safely get the nested attribute from an object.
Parameters:
obj (object): The object from which to retrieve the attribute.
attr_chain (str): The chain of attributes, separated by dots.
default: The default value to return if any attribute in the chain does not exist.
Returns:
The value of the nested attribute if it exists, otherwise the default value.
"""
attrs = attr_chain.split(".")
for attr in attrs:
try:
obj = getattr(obj, attr)
except AttributeError:
return default
return obj
def truncate_(input_string: str, length: int = 30, ellipsis: str = "") -> str:
return (
(f"{input_string[: length - len(ellipsis)].rstrip()}{ellipsis}")
if len(input_string) > length
else input_string
)
def truncate(
input_string: str, length: int = 30, ellipsis: str = "", endpart: str = ""
) -> str:
max_content_length = length - len(endpart)
if max_content_length < 0:
raise ValueError("Length cannot be shorter than the length of endpart.")
if len(input_string) > max_content_length:
return f"{input_string[: max_content_length - len(ellipsis)].rstrip()}{ellipsis}{endpart}"
return (
f"{input_string}{endpart}"
if len(input_string) + len(endpart) <= length
else f"{input_string[: length - len(ellipsis) - len(endpart)].rstrip()}{ellipsis}{endpart}"
)
T = TypeVar("T", str, int, date)
def generate_split_ranges(
value_list: list[T], split_points: list[T]
) -> Generator[tuple[T, T], None, None]:
for x in range(0, len(split_points) + 1):
if x == 0:
start = 0
elif x >= len(split_points):
start = value_list.index(split_points[x - 1]) + 1
else:
start = value_list.index(split_points[x - 1]) + 1
try:
end = value_list.index(split_points[x])
except IndexError:
end = len(value_list)
yield (value_list[start], value_list[end - 1])
def format_float_or_int(number: int | float):
return int(number) if float(number).is_integer() else f"{number:03.2f}"
OperatorType = Literal["|", "&"]
@dataclass
class FilterEntry:
condition: Q
operator: OperatorType = "&"
def build_dynamic_filter(
filters: list[FilterEntry | Q], default_operator: OperatorType = "&"
):
"""
Constructs a Django Q filter from a list of filter conditions.
Args:
filters (list): A list where each item is either:
- A Q object (default AND logic applied)
- A tuple of (Q object, operator) where operator is "|" (OR) or "&" (AND)
Returns:
Q: A combined Q object that can be passed to Django's filter().
"""
op_map: dict[OperatorType, Callable[[Q, Q], Q]] = {
"|": operator.or_,
"&": operator.and_,
}
# Convert all plain Q objects into (Q, "&") for default AND behavior
processed_filters = [
FilterEntry(f, default_operator) if isinstance(f, Q) else f for f in filters
]
# Reduce with dynamic operators
return reduce(
lambda combined_filters, filter: op_map[filter.operator](
combined_filters, filter.condition
),
processed_filters,
Q(),
)
def redirect_to(default_view: str, *default_args):
"""
A decorator that redirects the user back to the referring page or a default view if no 'next' parameter is provided.
:param default_view: The name of the default view to redirect to if 'next' is missing.
:param default_args: Any arguments required for the default view.
"""
def decorator(view_func):
@wraps(view_func)
def wrapped_view(request: HttpRequest, *args, **kwargs):
next_url = request.GET.get("next")
if not next_url:
from django.urls import (
reverse, # Import inside function to avoid circular imports
)
next_url = reverse(default_view, args=default_args)
# Execute the original view logic for its side effects, then
# redirect to `next_url` instead of returning its response.
view_func(request, *args, **kwargs)
return redirect(next_url)
return wrapped_view
return decorator
def add_next_param_to_url(url: str, nexturl: str) -> str:
return f"{url}?{urlencode({'next': nexturl})}"
@@ -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()
+24
View File
@@ -0,0 +1,24 @@
FROM python:3.13-slim
# Set up environment
ENV PYTHONUNBUFFERED=1
WORKDIR /workspace
# Install Poetry
RUN apt-get update && apt-get install -y \
curl \
make \
npm \
&& rm -rf /var/lib/apt/lists/*
RUN curl -sSL https://install.python-poetry.org | python3 -
ENV PATH="/root/.local/bin:$PATH"
# Copy pyproject.toml and poetry.lock for dependency installation
COPY pyproject.toml poetry.lock* ./
RUN poetry install --no-root
# Copy the rest of the application code
COPY . .
# Set up Django development server
EXPOSE 8000
+12 -19
View File
@@ -1,28 +1,21 @@
---
services:
backend:
image: registry.kucharczyk.xyz/timetracker
timetracker:
image: ${REGISTRY_URL:-registry.kucharczyk.xyz}/timetracker:1.7.0
build:
context: .
dockerfile: Dockerfile
container_name: timetracker
environment:
- TZ=Europe/Prague
- CSRF_TRUSTED_ORIGINS="https://tracker.kucharczyk.xyz"
user: "1000"
- TZ=${TZ:-Europe/Prague}
- CSRF_TRUSTED_ORIGINS=https://tracker.kucharczyk.xyz
- PUID=${PUID:-1000}
- PGID=${PGID:-100}
- DATA_DIR=${DATA_DIR:-/home/timetracker/app/data}
ports:
- "${TIMETRACKER_EXTERNAL_PORT:-8000}:8000"
volumes:
- "static-files:/home/timetracker/app/static"
- "./data:/home/timetracker/app/data"
- "${DOCKER_STORAGE_PATH:-/tmp}/timetracker/backups:/home/timetracker/app/games/fixtures/backups"
restart: unless-stopped
frontend:
image: caddy
volumes:
- "static-files:/usr/share/caddy"
- "$PWD/Caddyfile:/etc/caddy/Caddyfile"
ports:
- "8000:8000"
depends_on:
- backend
volumes:
static-files:
+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.)
+398
View File
@@ -0,0 +1,398 @@
# Form Overhaul Plan
> Last updated: 2026-05-12
> Status: Decided — awaiting implementation
>
> **Decisions made:**
> - All forms (simple and complex) get section headers for consistency
> - Two-column layout uses **flexbox** (auto-reflow on different screen sizes)
> - `cotton/layouts/add.html` enhanced with **Option A**: `c-section` component slots
> - `add_purchase.html` dual-submit **simplified** — remove `<tr><td>`, use same `c-button` pattern as `add_game.html`
> - GameStatusChange delete confirmation **converted to modal** (via HTMX trigger)
## Goal
Modernize all forms and form-like elements to align with Flowbite design, improve visual consistency, and adopt responsive multi-column layouts for complex forms.
---
## Current State Analysis
### Form Pages (add/edit)
All use `cotton/layouts/add.html` — single column, `max-w-xl`, `form.as_div`:
| Page | Form | Fields | Complexity |
|---|---|---|---|
| Game | `GameForm` | 7 fields: name, sort_name, platform, year, year_orig, status, mastered, wikidata | Medium |
| Purchase | `PurchaseForm` | 11 fields: games, platform, dates, price, currency, type, ownership, related, infinite, name | High |
| Session | `SessionForm` | 8 fields: game, timestamps, duration, emulated, device, note, checkbox (custom rendering) | High |
| Platform | `PlatformForm` | 3 fields: name, icon, group | Low |
| Device | `DeviceForm` | 2 fields: name, type | Low |
| PlayEvent | `PlayEventForm` | 5 fields: game, dates, note, checkbox | Low |
| GameStatusChange | `GameStatusChangeForm` | 4 fields | Low |
### Other Form-Like Elements
| Element | Template | Notes |
|---|---|---|
| Login | `registration/login.html` | Flowbite card, already good |
| Search | `cotton/search_field.html` | Reusable, already good |
| Delete Game | `partials/delete_game_confirmation.html` | Inline modal, inconsistent button layout |
| Delete PlayEvent | `gamestatuschange_confirm_delete.html` | Full-page form, no modal |
| Refund Purchase | `partials/refund_purchase_confirmation.html` | Inline modal, inconsistent button layout |
| Stats Year Select | `stats.html` | Manual `<select>`, no Flowbite styling |
| Status Selector | `partials/gamestatus_selector.html` | Alpine.js dropdown, old Tailwind classes |
| Device Selector | `partials/sessiondevice_selector.html` | Alpine.js dropdown, old Tailwind classes |
---
## Issues to Fix
### P0: Broken/Inconsistent
1. ~~**`modal.html` has a missing `<form>` tag** (line 13: `</form>` with no opening `<form>`)** — *Resolved: rewritten as proper component with form wrapping support, body + footer slots, reusable `close_button` component. Ready for standardizing all inline modals later.*
2. **Delete confirmations are inconsistent** — three different patterns (inline modal, full-page form, inline modal)
3. **`.errorlist` CSS** has fixed `width: 300px` — too narrow, breaks on mobile. *No scoping needed: Django auto-applies `.errorlist` to form error output only, never used explicitly in templates.*
4. **`add_purchase.html` has `<tr><td>`** in a `c-slot` that renders inside a `<div>` — semantic mismatch. **Decision: simplify dual-submit** to match `add_game.html` pattern (use `<c-button>` only).
5. **`#button-container` and `.basic-button` in `input.css`** — legacy patterns, unused or dead code
### P1: Layout & UX
6. **All add/edit forms are single-column** — PurchaseForm (11 fields) and GameForm (7 fields) would benefit from multi-column
7. **No field grouping** — related fields listed flat without visual hierarchy
8. **Stats year `<select>`** has no Flowbite styling
9. **Search field** is not wrapped in `<form method="get">` — no native clear-on-Enter behavior
### P2: Styling Consistency
10. **Status/device selectors** use old Tailwind v3 patterns (`rounded-sm`, `shadow-2xs`, `border-gray-200` without explicit color)
11. **`navbar.html` buttons** use `rounded-sm` instead of `rounded-base`
12. **`simple_table.html` pagination buttons** use `rounded-s-lg`/`rounded-e-lg` — could be simplified
---
## Proposed Improvements
### 1. Two-Column Layout for Complex Forms (Flexbox)
**Scope**: `GameForm`, `PurchaseForm`, `PlayEventForm`, `SessionForm`
Use **flexbox** with wrap behavior so fields auto-reflow on different screen sizes. No fixed column count — fields sit side-by-side on `md:`+ and wrap naturally on smaller screens.
#### GameForm Layout
```
┌──────────────────────────────────┐
│ Game Details │
│ ┌──────────────────┬───────────┐ │
│ │ Name │ Platform │ │
│ │ Sort Name │ Year │ │
│ │ Original Year │ Wikidata │ │
│ └──────────────────┴───────────┘ │
│ Status │
│ ┌──────────────────┬───────────┐ │
│ │ Status │ Mastered │ │
│ └──────────────────┴───────────┘ │
│ [Submit] │
└──────────────────────────────────┘
```
#### PurchaseForm Layout (simplified)
```
┌──────────────────────────────────────────┐
│ Purchase Details │
│ ┌──────────────────────┬───────────────┐ │
│ │ Games (multi-select) │ Platform │ │
│ │ Type │ Ownership │ │
│ │ Name │ Related Purch │ │
│ └──────────────────────┴───────────────┘ │
│ Dates │ Price │
│ ┌───────────────┬──────┴───────────────┐ │
│ │ Date Purch │ Price Curr │ │
│ │ Date Refund │ Infinite [ ] │ │
│ └───────────────┴──────────────────────┘ │
│ [Submit] [Submit + Session] │
└──────────────────────────────────────────┘
```
**Implementation**: `c-section` component accepts `columns="2"` (or `"3"`) which applies `flex flex-wrap gap-4 [&>div]:w-[calc(50%-0.5rem)]` on md+ screens. Each field wraps in a `<div>` inside the section slot.
**Decision**: Dual-submit in `add_purchase.html` simplified — remove `<tr><td>`, use same `<c-button>` pattern as `add_game.html`.
### 2. Field Grouping with Card Sections
**Decision**: ALL forms get section headers for consistency (not just complex forms).
Group related fields with section headings and subtle borders/backgrounds:
```html
<c-section title="Game Details" columns="2">
{{ form.name }}
{{ form.platform }}
{{ form.sort_name }}
{{ form.year_released }}
</c-section>
```
Each section renders as:
```html
<fieldset class="form-section p-5 border-t border-default-medium bg-neutral-primary-soft/30 first-of-type:border-t-0 first-of-type:pt-0">
<h3 class="text-sm font-medium text-heading uppercase mb-4">Section Title</h3>
<div class="flex flex-wrap gap-4">
<!-- fields in <div> wrappers, each taking calc(50% - 0.5rem) on md+ -->
</div>
</fieldset>
```
Each section gets:
- Subtle background (`bg-neutral-primary-soft/30`)
- Top border with spacing (`border-t border-default-medium`)
- Section heading (`text-sm font-medium text-heading uppercase mb-4`)
- Flexbox gap for responsive field reflow
### 1b. `c-section` Component Specification
New cotton component for the `cotton/` directory:
```python
# games/templates/cotton/section.py (or inline in components.py)
from common.components import Div
def Section(title: str = "", columns: str = "1", children: str = "") -> SafeText:
"""Renders a form field section with optional multi-column flexbox layout.
Args:
title: Section heading (renders as uppercase label)
columns: "1" (default), "2", or "3" — target column count on md+ screens
children: Field markup (each field wrapped in <div> for flex wrapping)
"""
col_class = {
"1": "flex flex-col",
"2": "flex flex-wrap gap-4 [&>div]:w-[calc(50%-0.5rem)]",
"3": "flex flex-wrap gap-4 [&>div]:w-[calc(33.333%-0.67rem)]",
}.get(columns, "flex flex-col")
return Div(
cls=f"form-section p-5 border-t border-default-medium bg-neutral-primary-soft/30 first-of-type:border-t-0 first-of-type:pt-0",
children=f"""
<h3 class="text-sm font-medium text-heading uppercase mb-4">{title}</h3>
<div class="{col_class}">{children}</div>
"""
)
```
**Template usage:**
```django
{# add_game.html #}
<c-layouts.add title="New Game">
<c-section title="Game Details" columns="2">
<div>{{ form.name }}</div>
<div>{{ form.platform }}</div>
<div>{{ form.sort_name }}</div>
<div>{{ form.year_released }}</div>
<div>{{ form.original_year_released }}</div>
<div>{{ form.wikidata }}</div>
</c-section>
<c-section title="Status" columns="2">
<div>{{ form.status }}</div>
<div>{{ form.mastered }}</div>
</c-section>
</c-layouts.add>
```
**`cotton/layouts/add.html` changes:**
- Remove hardcoded `{{ form.as_div }}` rendering
- Accept optional `sections` variable (list of rendered `c-section` output)
- If `sections` provided, render them; otherwise fall back to `{{ form.as_div }}` for simple forms
- Keep `additional_row` slot for dual-submit buttons
### 3. CSS/Style Fixes
#### `input.css` changes:
```css
/* Update errorlist */
.errorlist {
@apply mt-4 mb-1 pl-3 py-2 bg-red-600 text-slate-200 w-full max-w-xl; /* was w-[300px] */
}
/* Remove: #button-container, .basic-button — unused legacy */
/* Remove: .flowbite-input — custom class is code smell with Tailwind */
/* Remove: flowbite-input @apply block (line 229-234) */
/* Add Flowbite styling for select in stats */
#yearSelect {
@apply bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand;
}
```
**Important**: The styling previously provided by `.flowbite-input` must be preserved. The element-level `@apply` rules for `input`, `select`, and `textarea` in `input.css` (lines 209-219) already provide equivalent styling. These rules automatically apply to all form inputs without needing custom classes:
- `input:not([type="checkbox"])` — background, border, text, radius, focus ring, padding
- `select` — same base styling as inputs
- `textarea` — same base styling with adjusted padding
**Files to clean up:**
- `common/input.css`: Remove `.flowbite-input` class entirely (lines 229-234)
- `games/forms.py`: Remove `flowbite_input_widget` and `flowbite_password_widget` (lines 22-23)
- `games/forms.py`: Remove `widget=` from `LoginForm` fields (lines 28, 32) — login template uses explicit Tailwind classes already
#### Rewrite `modal.html`:
- Remove stray `</form>` tag and restructure as a proper cotton component
- New `c-modal` component with: `modal_id`, `title`, `size="xl"`, `backdrop_close` variables
- `{{ slot }}` (cotton default slot) for body content — passed as children of `<c-modal>`, no block tags needed
- `{{ footer }}` (optional named slot via `<c-slot name="footer">`) for non-form buttons
- Reusable `cotton/close_button.html` via `<c-close-button />`
- Size mapping via inline `{% if %}`: `{% if size == 'sm' %}max-w-sm{% elif size == 'lg' %}max-w-lg{% else %}max-w-xl{% endif %}`
- Horizontal centering: `mx-auto` on inner container (matching old modal pattern)
- Click-to-dismiss backdrop with `event.stopPropagation()` on inner container
- Flowbite-style styling: `rounded-lg shadow`, `bg-white dark:bg-gray-800`, `sm:p-5`
### 4. Unify Delete Confirmations (All Modal)
**Decision**: GameStatusChange delete confirmation converted from full-page to modal. All three use the same modal pattern.
**Target**: All confirmation modals use the same pattern:
```html
<div class="fixed inset-0 bg-black/70 dark:bg-gray-600/50 ...">
<div class="relative mx-auto p-6 bg-white dark:bg-gray-900 rounded-lg shadow-lg max-w-md w-full">
<h2 class="text-xl font-medium text-center">Confirm Action</h2>
<p class="text-center mt-4 text-sm text-body">Are you sure...?</p>
{% if details %}
<ul class="text-center mt-2 text-sm text-body list-disc list-inside">
<li>{{ detail }}</li>
</ul>
{% endif %}
<p class="text-center mt-3 text-sm font-medium text-red-600">This action cannot be undone.</p>
<div class="flex gap-3 mt-6">
<c-button color="red" class="w-full" type="submit">Delete</c-button>
<c-button color="gray" class="w-full">Cancel</c-button>
</div>
</div>
</div>
```
- **Delete Game** (`partials/delete_game_confirmation.html`): Update template to match standard pattern
- **Delete StatusChange** (`gamestatuschange_confirm_delete.html``partials/statuschange_delete_confirmation.html`): Adopt the same 2-view pattern as delete-game.
- Add `delete_statuschange_confirmation` view (GET → renders modal partial) + URL before the delete URL
- Update `partials/history.html` — add `hx-get="{% url 'games:delete_statuschange_confirmation' change.id %}" hx-target="#global-modal-container"` to the Delete link
- Create new `partials/statuschange_delete_confirmation.html` using `<c-modal>`, same structure as `delete_game_confirmation.html` (detail list, red warning text, same button layout, `<c-gamestatus>` badge for old status)
- Modify `GameStatusChangeDeleteView` to only handle POST (remove its GET-rendered template)
- Delete old `gamestatuschange_confirm_delete.html` after migration
- **Refund Purchase** (`partials/refund_purchase_confirmation.html`): Update template to match standard pattern
### 5. Search Form Enhancement
Wrap `search_field.html` in proper `<form method="get">`:
```html
<form class="max-w-md mx-auto" method="get" x-data x-on:keydown.escape="this.querySelector('input').value=''; this.submit()">
<!-- input + button -->
</form>
```
This enables:
- Native form submission on Enter
- Potential for "clear all" functionality
- Proper browser form autofill behavior
### 6. Status/Device Selector Styling
Update Alpine.js dropdowns to use consistent button classes:
- Replace `rounded-lg` with `rounded-base`
- Replace `shadow-2xs` with `shadow-xs`
- Standardize border colors with `border-default`
- Use `text-heading` / `text-body` for dark mode compatibility
---
## Templates That Need Changes
| Template | Change | Effort |
|---|---|---|
| `cotton/layouts/add.html` | Add `c-section` component support (title, columns, fields slots) | Medium |
| `add_game.html` | Multi-column flexbox layout, section headers | Medium |
| `add_purchase.html` | Multi-column flexbox layout, simplify dual-submit, section headers | High |
| `add_session.html` | Flexbox layout for timestamps+duration, section headers | Low |
| `add_playevent.html` | Flexbox layout, section headers | Low |
| `add_platform.html` | Section headers (was flat single-column) | Low |
| `add_device.html` | Section headers (was flat single-column) | Low |
| `partials/delete_game_confirmation.html` | Standardize to shared modal pattern | Low |
| `partials/refund_purchase_confirmation.html` | Standardize to shared modal pattern | Low |
| `partials/statuschange_delete_confirmation.html` | New — adopt same 2-view pattern as delete-game (modal, `<c-modal>`, HTMX triggers) | Medium |
| `gamestatuschange_confirm_delete.html` | Delete (replaced by new partial) | Trivial |
| `cotton/modal.html` | Fix missing `<form>` tag | Low |
| `stats.html` | Add Flowbite select styling | Low |
| `partials/gamestatus_selector.html` | Update button classes | Low |
| `partials/sessiondevice_selector.html` | Update button classes | Low |
| `cotton/search_field.html` | Wrap in `<form method="get">` | Low |
| `common/input.css` | Remove legacy, fix errorlist, add select styles | Low |
---
## Implementation Order
### Phase 1: Quick Wins (low risk, no breaking changes)
1. **CSS fixes** (`input.css`) — fix errorlist width, remove legacy `.basic-button` / `#button-container`, add select styles
2. ~~**`modal.html` rewrite**~~ — add missing `<form>` tag, conditional form wrapper ✓ Implemented (uses `{{ slot }}` cotton default slot, no `{% partial %}` tags; `size` defaults to `"xl"` with inline `{% if %}` mapping)
3. **Delete confirmation standardization** — 3 templates → all modal, same pattern (including GameStatusChange: full-page → modal)
4. **Search field enhancement** — wrap in `<form method="get">`
5. **Stats select styling** — add Flowbite select classes
6. **Selector styling updates** — gamestatus + sessiondevice selectors, consistent classes
### Phase 2: `c-section` Component
7. **Create `c-section` component** — title, columns, fields slots
8. **Update `cotton/layouts/add.html`** — support `sections` variable, fallback to `form.as_div`
### Phase 3: Form Layout Overhaul (largest change)
9. **`GameForm`** — section headers + 2-col flexbox (`add_game.html`)
10. **`PlayEventForm`** — section headers + 2-col flexbox
11. **`PurchaseForm`** — section headers + 2/3-col flexbox + simplify dual-submit (`add_purchase.html`)
12. **`SessionForm`** — section headers + flexbox for timestamps+duration (custom rendering already exists)
13. **Simple forms**`add_platform.html`, `add_device.html` get section headers (single column)
---
## Testing Strategy
- Run `make test` after Phase 1 changes to verify nothing broke
- `tests/test_paths_return_200.py` — URL-level smoke tests (186 tests). All views must have a `test_*_returns_200` test. Adding new views requires a corresponding test to prevent `TemplateDoesNotExist` regressions.
- CSS changes do not require test changes (no test coverage for rendering), but visual verification is recommended
---
## Open Questions
- [x] Simple forms section headers? → **All forms get section headers** for consistency
- [x] CSS Grid or Flexbox? → **Flexbox** — auto-reflow on different screen sizes
- [x] add.html layout variable? → **Option A**`c-section` cotton component with `title` and `columns` slots
- [x] add_purchase.html dual-submit? → **Simplify** — remove `<tr><td>`, use same `<c-button>` pattern as `add_game.html`
- [x] GameStatusChange modal or full-page? → **Modal** — trigger via HTMX, same pattern as delete-game
- [x] .flowbite-input class? → **Remove entirely** — rely on element-level `@apply` in `input.css`
## Decision Summary
| Question | Decision |
|---|---|
| Section headers on simple forms | Yes, all forms get them |
| Layout approach for multi-column | Flexbox with wrap |
| Layout mechanism in add.html | Option A: `c-section` cotton component |
| Purchase dual-submit | Simplify — single submit button, same as Game |
| GameStatusChange delete | Convert to modal (HTMX-triggered) |
| .flowbite-input class | Remove — preserve styling via element-level `@apply` in `input.css` |
| `modal.html` component | Rewrite with form wrapping, body + footer slots, reusable close button ✓ Implemented
## Build Step
After any CSS changes to `common/input.css`, the compiled output must be rebuilt:
- **`make css`** — one-shot build: `npx @tailwindcss/cli -i ./common/input.css -o ./games/static/base.css`
- **`make dev`** — watch mode: Tailwind rebuilds automatically on every `input.css` save
Running `make dev` is sufficient for development since it concurrently runs Django and the CSS watcher.
Only use `make css` if you only want to rebuild CSS without starting the dev server.
**Important**: Legacy CSS removals (`.basic-button`, `#button-container`, `.flowbite-input`) will only take effect in the browser after a rebuild. The old compiled `base.css` will still contain them until rebuilt.
@@ -0,0 +1,662 @@
# Comprehensive Filters Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Implement a comprehensive suite of backend filter classes and filter field expansions across all 6 main models (Game, Session, Purchase, Device, Platform, PlayEvent) using a subquery-based cross-entity approach.
**Architecture:** We will implement missing filter classes (`DeviceFilter`, `PlatformFilter`, `PlayEventFilter`) in `games/filters.py`. We will extend all filters to support powerful, deeply linked "cross-entity" subqueries (e.g. `GameFilter.session_filter` or `PlatformFilter.game_filter`) which builds robust `Q` objects without causing duplicate join rows in list queries.
**Tech Stack:** Django, Python dataclasses, Pytest.
---
### Task 1: Implement New Filter Classes (Device, Platform, PlayEvent)
**Files:**
- Modify: `games/filters.py`
- Test: `tests/test_filters.py`
- [ ] **Step 1: Implement DeviceFilter, PlatformFilter, and PlayEventFilter**
Add the three new operator filters to `games/filters.py`. Ensure we import all necessary criterion types and add the `parse_device_filter`, `parse_platform_filter`, and `parse_playevent_filter` helper functions at the end of the file.
```python
# Insert new filter imports and classes in games/filters.py
@dataclass
class DeviceFilter(OperatorFilter):
"""Filter for the Device model."""
AND: DeviceFilter | None = None
OR: DeviceFilter | None = None
NOT: DeviceFilter | None = None
name: StringCriterion | None = None
type: ChoiceCriterion | None = None
created_at: StringCriterion | None = None
# Free-text search
search: StringCriterion | None = None
# Cross-entity: Devices that have sessions matching these criteria
session_filter: SessionFilter | None = None
def to_q(self) -> Q:
q = Q()
if self.name is not None:
q &= self.name.to_q("name")
if self.type is not None:
q &= self.type.to_q("type")
if self.created_at is not None:
q &= self.created_at.to_q("created_at")
# Free-text search
if self.search is not None and self.search.value:
search_q = (
Q(name__icontains=self.search.value)
| Q(type__icontains=self.search.value)
)
if self.search.modifier == Modifier.EXCLUDES:
search_q = ~search_q
q &= search_q
# Cross-entity filter: session_filter
if self.session_filter is not None:
from games.models import Session
session_q = self.session_filter.to_q()
matching_ids = Session.objects.filter(session_q).values_list("device_id", flat=True)
q &= Q(id__in=matching_ids)
sub = self.sub_filter()
if sub is not None:
if self.AND is not None:
q &= sub.to_q()
elif self.OR is not None:
q |= sub.to_q()
elif self.NOT is not None:
q &= ~sub.to_q()
return q
@dataclass
class PlatformFilter(OperatorFilter):
"""Filter for the Platform model."""
AND: PlatformFilter | None = None
OR: PlatformFilter | None = None
NOT: PlatformFilter | None = None
name: StringCriterion | None = None
group: StringCriterion | None = None
icon: StringCriterion | None = None
created_at: StringCriterion | None = None
# Free-text search
search: StringCriterion | None = None
# Cross-entity
game_filter: GameFilter | None = None
purchase_filter: PurchaseFilter | None = None
def to_q(self) -> Q:
q = Q()
if self.name is not None:
q &= self.name.to_q("name")
if self.group is not None:
q &= self.group.to_q("group")
if self.icon is not None:
q &= self.icon.to_q("icon")
if self.created_at is not None:
q &= self.created_at.to_q("created_at")
# Free-text search
if self.search is not None and self.search.value:
search_q = (
Q(name__icontains=self.search.value)
| Q(group__icontains=self.search.value)
)
if self.search.modifier == Modifier.EXCLUDES:
search_q = ~search_q
q &= search_q
# Cross-entity filter: game_filter
if self.game_filter is not None:
from games.models import Game
game_q = self.game_filter.to_q()
matching_ids = Game.objects.filter(game_q).values_list("platform_id", flat=True)
q &= Q(id__in=matching_ids)
# Cross-entity filter: purchase_filter
if self.purchase_filter is not None:
from games.models import Purchase
purchase_q = self.purchase_filter.to_q()
matching_ids = Purchase.objects.filter(purchase_q).values_list("platform_id", flat=True)
q &= Q(id__in=matching_ids)
sub = self.sub_filter()
if sub is not None:
if self.AND is not None:
q &= sub.to_q()
elif self.OR is not None:
q |= sub.to_q()
elif self.NOT is not None:
q &= ~sub.to_q()
return q
@dataclass
class PlayEventFilter(OperatorFilter):
"""Filter for the PlayEvent model."""
AND: PlayEventFilter | None = None
OR: PlayEventFilter | None = None
NOT: PlayEventFilter | None = None
game: MultiCriterion | None = None # filters on game_id
started: StringCriterion | None = None # date string
ended: StringCriterion | None = None # date string
days_to_finish: IntCriterion | None = None
note: StringCriterion | None = None
created_at: StringCriterion | None = None
# Free-text search
search: StringCriterion | None = None
# Cross-entity: PlayEvents for games matching these criteria
game_filter: GameFilter | None = None
def to_q(self) -> Q:
q = Q()
if self.game is not None:
q &= self.game.to_q("game_id")
if self.started is not None:
q &= self.started.to_q("started")
if self.ended is not None:
q &= self.ended.to_q("ended")
if self.days_to_finish is not None:
q &= self.days_to_finish.to_q("days_to_finish")
if self.note is not None:
q &= self.note.to_q("note")
if self.created_at is not None:
q &= self.created_at.to_q("created_at")
# Free-text search
if self.search is not None and self.search.value:
search_q = (
Q(game__name__icontains=self.search.value)
| Q(note__icontains=self.search.value)
)
if self.search.modifier == Modifier.EXCLUDES:
search_q = ~search_q
q &= search_q
# Cross-entity filter: game_filter
if self.game_filter is not None:
from games.models import Game
game_q = self.game_filter.to_q()
matching_ids = Game.objects.filter(game_q).values_list("id", flat=True)
q &= Q(game_id__in=matching_ids)
sub = self.sub_filter()
if sub is not None:
if self.AND is not None:
q &= sub.to_q()
elif self.OR is not None:
q |= sub.to_q()
elif self.NOT is not None:
q &= ~sub.to_q()
return q
# Add to convenience helpers section:
def parse_device_filter(json_str: str) -> DeviceFilter | None:
return filter_from_json(DeviceFilter, json_str)
def parse_platform_filter(json_str: str) -> PlatformFilter | None:
return filter_from_json(PlatformFilter, json_str)
def parse_playevent_filter(json_str: str) -> PlayEventFilter | None:
return filter_from_json(PlayEventFilter, json_str)
```
- [ ] **Step 2: Run existing tests to verify everything compiles**
Run: `pytest tests/test_filters.py -v`
Expected: All existing tests PASS without issues.
---
### Task 2: Expand SessionFilter (Duration Fields + Cross-Entity)
**Files:**
- Modify: `games/filters.py:SessionFilter`
- Test: `tests/test_filters.py`
- [ ] **Step 1: Refactor SessionFilter and add new duration fields & device_filter**
Modify `SessionFilter` to replace `duration_minutes: IntCriterion` with `duration_total_minutes`, `duration_manual_minutes`, and `duration_calculated_minutes`. Add `device_filter: DeviceFilter`.
Update `to_q()` inside `SessionFilter` to map duration fields correctly to their respective GeneratedFields (`duration_total`, `duration_calculated`) or manual field (`duration_manual`). Use standard Python `timedelta` logic.
```python
# Inside SessionFilter class:
duration_total_minutes: IntCriterion | None = None
duration_manual_minutes: IntCriterion | None = None
duration_calculated_minutes: IntCriterion | None = None
# Cross-entity: sessions for devices matching these criteria
device_filter: DeviceFilter | None = None
```
```python
# Helper inside SessionFilter or refactored:
def _duration_to_q(self, c: IntCriterion, field: str) -> Q:
from datetime import timedelta
q = Q()
td_val = timedelta(minutes=c.value)
m = c.modifier
if m == Modifier.EQUALS:
q &= Q(
**{
f"{field}__gte": td_val,
f"{field}__lt": timedelta(minutes=c.value + 1),
}
)
elif m == Modifier.NOT_EQUALS:
q &= ~Q(
**{
f"{field}__gte": td_val,
f"{field}__lt": timedelta(minutes=c.value + 1),
}
)
elif m == Modifier.GREATER_THAN:
q &= Q(**{f"{field}__gt": td_val})
elif m == Modifier.LESS_THAN:
q &= Q(**{f"{field}__lt": td_val})
elif m == Modifier.BETWEEN and c.value2 is not None:
lo = timedelta(minutes=min(c.value, c.value2))
hi = timedelta(minutes=max(c.value, c.value2))
q &= Q(**{f"{field}__gte": lo, f"{f_field}__lte": hi})
elif m == Modifier.NOT_BETWEEN and c.value2 is not None:
lo = timedelta(minutes=min(c.value, c.value2))
hi = timedelta(minutes=max(c.value, c.value2))
q &= Q(**{f"{field}__lt": lo}) | Q(**{f"{field}__gt": hi})
elif m == Modifier.IS_NULL:
q &= Q(**{f"{field}": timedelta(0)})
elif m == Modifier.NOT_NULL:
q &= ~Q(**{f"{field}": timedelta(0)})
return q
```
Then in `to_q()` inside `SessionFilter`:
```python
if self.duration_total_minutes is not None:
q &= self._duration_to_q(self.duration_total_minutes, "duration_total")
if self.duration_manual_minutes is not None:
q &= self._duration_to_q(self.duration_manual_minutes, "duration_manual")
if self.duration_calculated_minutes is not None:
q &= self._duration_to_q(self.duration_calculated_minutes, "duration_calculated")
# Cross-entity filter: device_filter
if self.device_filter is not None:
from games.models import Device
device_q = self.device_filter.to_q()
matching_ids = Device.objects.filter(device_q).values_list("id", flat=True)
q &= Q(device_id__in=matching_ids)
```
- [ ] **Step 2: Run tests to verify compiles correctly**
Run: `pytest tests/test_filters.py -v`
Expected: PASS (existing tests may need updating if they referenced `duration_minutes`).
---
### Task 3: Expand PurchaseFilter (Original Currency, Infinite, Needs Price Update, Converted Currency)
**Files:**
- Modify: `games/filters.py:PurchaseFilter`
- Test: `tests/test_filters.py`
- [ ] **Step 1: Add new fields to PurchaseFilter and platform_filter**
Expand `PurchaseFilter` with `infinite: BoolCriterion`, `needs_price_update: BoolCriterion`, `converted_currency: StringCriterion`, and `platform_filter: PlatformFilter`.
```python
# Inside PurchaseFilter class:
infinite: BoolCriterion | None = None
needs_price_update: BoolCriterion | None = None
converted_currency: StringCriterion | None = None
# Cross-entity
platform_filter: PlatformFilter | None = None
```
Update `to_q()` inside `PurchaseFilter`:
```python
if self.infinite is not None:
q &= self.infinite.to_q("infinite")
if self.needs_price_update is not None:
q &= self.needs_price_update.to_q("needs_price_update")
if self.converted_currency is not None:
q &= self.converted_currency.to_q("converted_currency")
# Cross-entity filter: platform_filter
if self.platform_filter is not None:
from games.models import Platform
platform_q = self.platform_filter.to_q()
matching_ids = Platform.objects.filter(platform_q).values_list("id", flat=True)
q &= Q(platform_id__in=matching_ids)
```
- [ ] **Step 2: Verify test suite continues to pass**
Run: `pytest tests/test_filters.py -v`
Expected: PASS
---
### Task 4: Expand GameFilter (Has Purchases, Has PlayEvents, Session Stats, Cross-Entity)
**Files:**
- Modify: `games/filters.py:GameFilter`
- Test: `tests/test_filters.py`
- [ ] **Step 1: Expand GameFilter with session stats, purchase/playevent existence, and cross-entity filters**
Add fields and cross-entity filters to `GameFilter`:
```python
# Inside GameFilter class:
has_purchases: BoolCriterion | None = None
has_playevents: BoolCriterion | None = None
session_count: IntCriterion | None = None
session_average: IntCriterion | None = None # average in minutes
# Cross-entity filters
session_filter: SessionFilter | None = None
purchase_filter: PurchaseFilter | None = None
playevent_filter: PlayEventFilter | None = None
platform_filter: PlatformFilter | None = None
```
Update `to_q()` inside `GameFilter`.
For existence and session stats filters, we use Subqueries to avoid complex inline annotations during the generic filter generation (which is much cleaner and less bug-prone):
```python
if self.has_purchases is not None:
from games.models import Purchase
purchased_ids = Purchase.objects.values_list("games__id", flat=True).distinct()
if self.has_purchases.value:
q &= Q(id__in=purchased_ids)
else:
q &= ~Q(id__in=purchased_ids)
if self.has_playevents is not None:
from games.models import PlayEvent
played_ids = PlayEvent.objects.values_list("game_id", flat=True).distinct()
if self.has_playevents.value:
q &= Q(id__in=played_ids)
else:
q &= ~Q(id__in=played_ids)
if self.session_count is not None:
from games.models import Game
from django.db.models import Count
matching_ids = Game.objects.annotate(s_count=Count("sessions")).filter(self.session_count.to_q("s_count")).values_list("id", flat=True)
q &= Q(id__in=matching_ids)
if self.session_average is not None:
from games.models import Game, Session
from django.db.models import Avg, F, ExpressionWrapper, DurationField
# Compute average session total duration.
# Avg returns an interval/duration type, so we can convert it to minutes in Python or do duration comparisons directly.
# To match the criterion easily, we can filter Game objects using Avg:
matching_ids = Game.objects.annotate(s_avg=Avg("sessions__duration_total")).filter(self._playtime_to_q_for_field(self.session_average, "s_avg")).values_list("id", flat=True)
q &= Q(id__in=matching_ids)
# Cross-entity filters
if self.session_filter is not None:
from games.models import Session
session_q = self.session_filter.to_q()
matching_ids = Session.objects.filter(session_q).values_list("game_id", flat=True)
q &= Q(id__in=matching_ids)
if self.purchase_filter is not None:
from games.models import Purchase
purchase_q = self.purchase_filter.to_q()
matching_ids = Purchase.objects.filter(purchase_q).values_list("games__id", flat=True)
q &= Q(id__in=matching_ids)
if self.playevent_filter is not None:
from games.models import PlayEvent
playevent_q = self.playevent_filter.to_q()
matching_ids = PlayEvent.objects.filter(playevent_q).values_list("game_id", flat=True)
q &= Q(id__in=matching_ids)
if self.platform_filter is not None:
from games.models import Platform
platform_q = self.platform_filter.to_q()
matching_ids = Platform.objects.filter(platform_q).values_list("id", flat=True)
q &= Q(platform_id__in=matching_ids)
```
Add a helper `_playtime_to_q_for_field` in `GameFilter` that works exactly like `_playtime_to_q` but accepts a customized field name (e.g. `s_avg`):
```python
@staticmethod
def _playtime_to_q_for_field(c: IntCriterion, field: str) -> Q:
from datetime import timedelta
m = c.modifier
td_val = timedelta(minutes=c.value)
if m == Modifier.EQUALS:
return Q(
**{
f"{field}__gte": td_val,
f"{field}__lt": timedelta(minutes=c.value + 1),
}
)
if m == Modifier.NOT_EQUALS:
return ~Q(
**{
f"{field}__gte": td_val,
f"{field}__lt": timedelta(minutes=c.value + 1),
}
)
if m == Modifier.GREATER_THAN:
return Q(**{f"{field}__gt": td_val})
if m == Modifier.LESS_THAN:
return Q(**{f"{field}__lt": td_val})
if m == Modifier.BETWEEN and c.value2 is not None:
lo = timedelta(minutes=min(c.value, c.value2))
hi = timedelta(minutes=max(c.value, c.value2))
return Q(**{f"{field}__gte": lo, f"{field}__lte": hi})
if m == Modifier.NOT_BETWEEN and c.value2 is not None:
lo = timedelta(minutes=min(c.value, c.value2))
hi = timedelta(minutes=max(c.value, c.value2))
return Q(**{f"{field}__lt": lo}) | Q(**{f"{field}__gt": hi})
if m == Modifier.IS_NULL:
return Q(**{f"{field}": timedelta(0)})
if m == Modifier.NOT_NULL:
return ~Q(**{f"{field}": timedelta(0)})
return Q()
```
- [ ] **Step 2: Update existing `_playtime_to_q` to delegate to `_playtime_to_q_for_field`**
```python
@staticmethod
def _playtime_to_q(c: IntCriterion) -> Q:
return GameFilter._playtime_to_q_for_field(c, "playtime")
```
---
### Task 5: Add Exhaustive DB Tests for the Expanded and New Filters
**Files:**
- Modify: `tests/test_filters.py`
- [ ] **Step 1: Write DB-backed unit tests for the new filter behaviors**
Add comprehensive test cases inside `tests/test_filters.py` covering:
- New cross-entity filters (e.g. Platform -> Game -> Session -> Device chain).
- Session total vs manual vs calculated duration filters.
- Game session stats (`session_count`, `session_average`) and presence flags (`has_purchases`, `has_playevents`).
- Device, Platform, and PlayEvent specific filters.
```python
# Add test class at the end of tests/test_filters.py:
@pytest.mark.django_db
class TestExpandedFiltersAgainstDB:
def _setup_entities(self):
from games.models import Game, Platform, Device, Session, Purchase, PlayEvent
import datetime
from datetime import timedelta
# 1. Platform & Game
plat, _ = Platform.objects.get_or_create(name="Retro Console", group="Nintendo", icon="retro")
game, _ = Game.objects.get_or_create(name="Super Mario World", defaults={"platform": plat, "status": "f"})
game2, _ = Game.objects.get_or_create(name="Zelda", defaults={"platform": plat, "status": "u"})
# 2. Device & Session
dev, _ = Device.objects.get_or_create(name="Super Famicom", type="Console")
# Session 1: total 40 minutes (30 calc, 10 manual)
s1 = Session.objects.create(
game=game,
device=dev,
timestamp_start=datetime.datetime(2026, 6, 1, 12, 0, 0, tzinfo=datetime.timezone.utc),
timestamp_end=datetime.datetime(2026, 6, 1, 12, 30, 0, tzinfo=datetime.timezone.utc),
duration_manual=timedelta(minutes=10)
)
# 3. Purchase
pur = Purchase.objects.create(
platform=plat,
date_purchased=datetime.date(2026, 1, 1),
infinite=True,
price=49.99,
price_currency="JPY",
converted_price=45.00,
converted_currency="USD",
needs_price_update=False
)
pur.games.add(game)
# 4. PlayEvent
pe = PlayEvent.objects.create(
game=game,
started=datetime.date(2026, 6, 1),
ended=datetime.date(2026, 6, 2),
note="Completed 100%"
)
return {
"plat": plat,
"game": game,
"game2": game2,
"dev": dev,
"s1": s1,
"pur": pur,
"pe": pe
}
def test_device_filter_and_cross_entity(self):
from games.filters import DeviceFilter, SessionFilter
from games.models import Device
data = self._setup_entities()
# Find devices that have sessions on "Super Mario World"
df = DeviceFilter.from_json({
"session_filter": {
"game_filter": {
"name": {"value": "Super Mario World", "modifier": "EQUALS"}
}
}
})
results = list(Device.objects.filter(df.to_q()))
assert data["dev"] in results
def test_platform_filter_and_cross_entity(self):
from games.filters import PlatformFilter, GameFilter
from games.models import Platform
data = self._setup_entities()
# Find platforms with games that are finished
pf = PlatformFilter.from_json({
"game_filter": {
"status": {"value": ["f"], "modifier": "INCLUDES"}
}
})
results = list(Platform.objects.filter(pf.to_q()))
assert data["plat"] in results
def test_session_filter_duration_splits(self):
from games.filters import SessionFilter
from games.models import Session
data = self._setup_entities()
# Test duration_total_minutes equals 40
sf_tot = SessionFilter.from_json({
"duration_total_minutes": {"value": 40, "modifier": "EQUALS"}
})
assert Session.objects.filter(sf_tot.to_q()).count() == 1
# Test duration_manual_minutes equals 10
sf_man = SessionFilter.from_json({
"duration_manual_minutes": {"value": 10, "modifier": "EQUALS"}
})
assert Session.objects.filter(sf_man.to_q()).count() == 1
# Test duration_calculated_minutes equals 30
sf_calc = SessionFilter.from_json({
"duration_calculated_minutes": {"value": 30, "modifier": "EQUALS"}
})
assert Session.objects.filter(sf_calc.to_q()).count() == 1
def test_purchase_filter_new_fields(self):
from games.filters import PurchaseFilter
from games.models import Purchase
data = self._setup_entities()
pf = PurchaseFilter.from_json({
"infinite": {"value": True, "modifier": "EQUALS"},
"needs_price_update": {"value": False, "modifier": "EQUALS"},
"converted_currency": {"value": "USD", "modifier": "EQUALS"}
})
assert Purchase.objects.filter(pf.to_q()).count() == 1
def test_game_filter_stats_and_existence(self):
from games.filters import GameFilter
from games.models import Game
data = self._setup_entities()
# has_purchases = True
gf_pur = GameFilter.from_json({
"has_purchases": {"value": True, "modifier": "EQUALS"}
})
assert data["game"] in list(Game.objects.filter(gf_pur.to_q()))
assert data["game2"] not in list(Game.objects.filter(gf_pur.to_q()))
# session_count = 1
gf_cnt = GameFilter.from_json({
"session_count": {"value": 1, "modifier": "EQUALS"}
})
assert data["game"] in list(Game.objects.filter(gf_cnt.to_q()))
```
- [ ] **Step 2: Run all unit tests to confirm success**
Run: `pytest tests/test_filters.py -v`
Expected: ALL tests pass perfectly.
@@ -0,0 +1,577 @@
# Frontend Filters Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Implement a comprehensive frontend filter bar interface for all 6 list views (Games, Sessions, Purchases, Devices, Platforms, PlayEvents) with specific field controls, simple cross-entity toggles, and full JSON preset support.
**Architecture:** We will extend existing components in `common/components/filters.py` and implement new filter bars (`DeviceFilterBar`, `PlatformFilterBar`, `PlayEventFilterBar`). We will update the views in `games/views/` to parse standard filter JSON from `request.GET.get('filter')`, apply them to querysets, render the filter bars, and export them in `common/components/__init__.py`.
**Tech Stack:** Django, Python dataclasses, Pytest.
---
### Task 1: Update existing FilterBars in `common/components/filters.py`
**Files:**
- Modify: `common/components/filters.py`
- [ ] **Step 1: Add new fields to GameFilterBar**
Add checkboxes for `has_purchases`, `has_playevents` and RangeSliders for `session_count`, `session_average`.
```python
# Inside common/components/filters.py: FilterBar()
# Parse new values
has_purchases_value = _parse_bool(existing, "has_purchases")
has_playevents_value = _parse_bool(existing, "has_playevents")
session_count_min, session_count_max = _parse_range(existing, "session_count")
session_avg_min, session_avg_max = _parse_range(existing, "session_average")
# Add components to fields:
# 1. Under status and platform, add the checkboxes for purchases/playevents
# 2. Add RangeSliders for session count and average
```
Code change to apply in `FilterBar`:
```python
fields = [
Component(
tag_name="div",
attributes=[("class", _FILTER_GRID_CLASS)],
children=[
_filter_field(
"Status",
_enum_filter(
"status",
status_options,
status_choice,
nullable=not Game._meta.get_field("status").has_default(),
),
),
_filter_field(
"Platform",
_model_filter(
"platform",
platform_choice,
search_url="/api/platforms/search",
nullable=Game._meta.get_field("platform").null,
),
),
],
),
RangeSlider(
label="Year",
input_name_prefix="filter-year",
min_value=year_min,
max_value=year_max,
range_min=year_range_min,
range_max=year_range_max,
min_placeholder="e.g. 2020",
max_placeholder="e.g. 2024",
),
Component(
tag_name="div",
attributes=[("class", "flex items-end gap-4 mb-4")],
children=[
_filter_checkbox("filter-mastered", "Mastered", mastered_value),
_filter_checkbox("filter-has-purchases", "Has Purchases", has_purchases_value),
_filter_checkbox("filter-has-playevents", "Has Play Events", has_playevents_value),
],
),
RangeSlider(
label="Playtime",
input_name_prefix="filter-playtime",
min_value=playtime_min,
max_value=playtime_max,
range_min=0,
range_max=playtime_range_max,
step="1",
min_placeholder="e.g. 1",
max_placeholder="e.g. 100",
),
RangeSlider(
label="Session Count",
input_name_prefix="filter-session-count",
min_value=session_count_min,
max_value=session_count_max,
range_min=0,
range_max=100,
step="1",
min_placeholder="e.g. 1",
max_placeholder="e.g. 50",
),
RangeSlider(
label="Average Session Duration (mins)",
input_name_prefix="filter-session-average",
min_value=session_avg_min,
max_value=session_avg_max,
range_min=0,
range_max=240,
step="1",
min_placeholder="e.g. 10",
max_placeholder="e.g. 120",
),
]
```
- [ ] **Step 2: Update SessionFilterBar to support split duration fields**
Replace old `duration_minutes` RangeSlider with split total, manual, and calculated duration RangeSliders.
```python
# Inside common/components/filters.py: SessionFilterBar()
dur_tot_min, dur_tot_max = _parse_range(existing, "duration_total_minutes")
dur_man_min, dur_man_max = _parse_range(existing, "duration_manual_minutes")
dur_calc_min, dur_calc_max = _parse_range(existing, "duration_calculated_minutes")
# Inside fields array, replace RangeSlider "Duration" with:
RangeSlider(
label="Total Duration (mins)",
input_name_prefix="filter-duration-total-minutes",
min_value=dur_tot_min,
max_value=dur_tot_max,
range_min=0,
range_max=duration_range_max * 60, # Range sliders use minutes now
step="1",
min_placeholder="e.g. 30",
max_placeholder="e.g. 180",
),
RangeSlider(
label="Manual Duration (mins)",
input_name_prefix="filter-duration-manual-minutes",
min_value=dur_man_min,
max_value=dur_man_max,
range_min=0,
range_max=240,
step="1",
min_placeholder="e.g. 10",
max_placeholder="e.g. 120",
),
RangeSlider(
label="Calculated Duration (mins)",
input_name_prefix="filter-duration-calculated-minutes",
min_value=dur_calc_min,
max_value=dur_calc_max,
range_min=0,
range_max=duration_range_max * 60,
step="1",
min_placeholder="e.g. 30",
max_placeholder="e.g. 180",
),
```
- [ ] **Step 3: Update PurchaseFilterBar to support original and converted currencies and infinite flag**
Add Checkboxes `infinite`, `needs_price_update` and currency StringCriterion text field / Choice options.
```python
# Inside common/components/filters.py: PurchaseFilterBar()
infinite_value = _parse_bool(existing, "infinite")
needs_price_update_value = _parse_bool(existing, "needs_price_update")
price_currency_value = existing.get("price_currency", {}).get("value", "")
converted_currency_value = existing.get("converted_currency", {}).get("value", "")
# Expand fields component array with:
Component(
tag_name="div",
attributes=[("class", "flex gap-4 mb-4")],
children=[
_filter_checkbox("filter-refunded", "Refunded", is_refunded_value),
_filter_checkbox("filter-infinite", "Infinite", infinite_value),
_filter_checkbox("filter-needs-price-update", "Needs Price Update", needs_price_update_value),
],
),
```
Add currency text filters (as primitive `Input` controls for string criteria):
```python
Component(
tag_name="div",
attributes=[("class", _FILTER_GRID_CLASS)],
children=[
_filter_field(
"Original Currency",
Component(
tag_name="input",
attributes=[
("type", "text"),
("name", "filter-price_currency"),
("value", price_currency_value),
("placeholder", "e.g. USD, EUR"),
("class", "w-full rounded border-default-medium p-2 bg-neutral-secondary-medium text-body"),
],
),
),
_filter_field(
"Converted Currency",
Component(
tag_name="input",
attributes=[
("type", "text"),
("name", "filter-converted_currency"),
("value", converted_currency_value),
("placeholder", "e.g. USD, EUR"),
("class", "w-full rounded border-default-medium p-2 bg-neutral-secondary-medium text-body"),
],
),
),
],
),
```
---
### Task 2: Create New FilterBars in `common/components/filters.py`
**Files:**
- Modify: `common/components/filters.py`
- [ ] **Step 1: Implement DeviceFilterBar, PlatformFilterBar, and PlayEventFilterBar**
Append these three new filter bar components to `common/components/filters.py`:
```python
def DeviceFilterBar(
filter_json="", preset_list_url="", preset_save_url=""
) -> SafeText:
"""Collapsible filter bar for the Device list."""
from games.models import Device
existing = _filter_parse(filter_json)
type_options = Device.DEVICE_TYPES
type_choice = _filter_get_choice(existing, "type")
fields = [
Component(
tag_name="div",
attributes=[("class", _FILTER_GRID_CLASS)],
children=[
_filter_field(
"Device Type",
_enum_filter(
"type",
type_options,
type_choice,
nullable=True,
),
),
],
),
]
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
def PlatformFilterBar(
filter_json="", preset_list_url="", preset_save_url=""
) -> SafeText:
"""Collapsible filter bar for the Platform list."""
existing = _filter_parse(filter_json)
name_value = existing.get("name", {}).get("value", "")
group_value = existing.get("group", {}).get("value", "")
fields = [
Component(
tag_name="div",
attributes=[("class", _FILTER_GRID_CLASS)],
children=[
_filter_field(
"Platform Name",
Component(
tag_name="input",
attributes=[
("type", "text"),
("name", "filter-name"),
("value", name_value),
("placeholder", "e.g. Nintendo Switch"),
("class", "w-full rounded border-default-medium p-2 bg-neutral-secondary-medium text-body"),
],
),
),
_filter_field(
"Platform Group",
Component(
tag_name="input",
attributes=[
("type", "text"),
("name", "filter-group"),
("value", group_value),
("placeholder", "e.g. Nintendo"),
("class", "w-full rounded border-default-medium p-2 bg-neutral-secondary-medium text-body"),
],
),
),
],
),
]
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
def PlayEventFilterBar(
filter_json="", preset_list_url="", preset_save_url=""
) -> SafeText:
"""Collapsible filter bar for the PlayEvent list."""
from games.models import PlayEvent
existing = _filter_parse(filter_json)
game_choice = _filter_get_choice(existing, "game")
days_min, days_max = _parse_range(existing, "days_to_finish")
fields = [
Component(
tag_name="div",
attributes=[("class", _FILTER_GRID_CLASS)],
children=[
_filter_field(
"Game",
_model_filter(
"game",
game_choice,
search_url="/api/games/search",
nullable=False,
),
),
],
),
RangeSlider(
label="Days to Finish",
input_name_prefix="filter-days-to-finish",
min_value=days_min,
max_value=days_max,
range_min=0,
range_max=365,
step="1",
min_placeholder="e.g. 1",
max_placeholder="e.g. 30",
),
]
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
```
- [ ] **Step 2: Export new FilterBars in `common/components/__init__.py`**
Modify: `common/components/__init__.py` to import and expose `DeviceFilterBar`, `PlatformFilterBar`, and `PlayEventFilterBar`.
```python
# Import section:
from common.components.filters import (
FilterBar,
PurchaseFilterBar,
SessionFilterBar,
DeviceFilterBar,
PlatformFilterBar,
PlayEventFilterBar,
)
# In __all__:
"FilterBar",
"PurchaseFilterBar",
"SessionFilterBar",
"DeviceFilterBar",
"PlatformFilterBar",
"PlayEventFilterBar",
```
---
### Task 3: Integrate FilterBars into `Device`, `Platform`, and `PlayEvent` views
**Files:**
- Modify: `games/views/device.py`
- Modify: `games/views/platform.py`
- Modify: `games/views/playevent.py`
- [ ] **Step 1: Integrate FilterBar in `list_devices` in `games/views/device.py`**
Import and parse the filter, apply to queryset, instantiate `DeviceFilterBar`, prepend it to the output page content.
```python
# At top of games/views/device.py:
from django.utils.safestring import mark_safe
from common.components import DeviceFilterBar, ModuleScript
from games.filters import parse_device_filter
# Inside list_devices(request):
devices = Device.objects.order_by("-created_at")
filter_json = request.GET.get("filter", "")
if filter_json:
device_filter = parse_device_filter(filter_json)
if device_filter is not None:
devices = devices.filter(device_filter.to_q())
devices, page_obj, elided_page_range = paginate(request, devices)
# ... create data dict ...
# Prepend the filter bar above table:
filter_bar = DeviceFilterBar(
filter_json=filter_json,
preset_list_url=reverse("games:list_presets") + "?mode=devices",
preset_save_url=reverse("games:save_preset") + "?mode=devices",
)
content = mark_safe(str(filter_bar) + str(content))
return render_page(
request,
content,
title="Manage devices",
scripts=ModuleScript("range_slider.js")
+ ModuleScript("search_select.js")
+ ModuleScript("filter_bar.js"),
)
```
- [ ] **Step 2: Integrate FilterBar in `list_platforms` in `games/views/platform.py`**
Import and parse the filter, apply to platform queryset, instantiate platform filter bar, prepend to page content.
```python
# At top of games/views/platform.py:
from django.utils.safestring import mark_safe
from common.components import PlatformFilterBar, ModuleScript
from games.filters import parse_platform_filter
# Inside list_platforms(request):
platforms = Platform.objects.order_by("name")
filter_json = request.GET.get("filter", "")
if filter_json:
platform_filter = parse_platform_filter(filter_json)
if platform_filter is not None:
platforms = platforms.filter(platform_filter.to_q())
platforms, page_obj, elided_page_range = paginate(request, platforms)
# ... create data dict ...
filter_bar = PlatformFilterBar(
filter_json=filter_json,
preset_list_url=reverse("games:list_presets") + "?mode=platforms",
preset_save_url=reverse("games:save_preset") + "?mode=platforms",
)
content = mark_safe(str(filter_bar) + str(content))
return render_page(
request,
content,
title="Manage platforms",
scripts=ModuleScript("range_slider.js")
+ ModuleScript("search_select.js")
+ ModuleScript("filter_bar.js"),
)
```
- [ ] **Step 3: Integrate FilterBar in `list_playevents` in `games/views/playevent.py`**
Import and parse the filter, apply to playevent queryset, instantiate playevent filter bar, prepend to page content.
```python
# At top of games/views/playevent.py:
from django.utils.safestring import mark_safe
from common.components import PlayEventFilterBar
from games.filters import parse_playevent_filter
# Inside list_playevents(request):
playevents = PlayEvent.objects.order_by("-created_at")
filter_json = request.GET.get("filter", "")
if filter_json:
playevent_filter = parse_playevent_filter(filter_json)
if playevent_filter is not None:
playevents = playevents.filter(playevent_filter.to_q())
playevents, page_obj, elided_page_range = paginate(request, playevents)
# ... create data ...
filter_bar = PlayEventFilterBar(
filter_json=filter_json,
preset_list_url=reverse("games:list_presets") + "?mode=playevents",
preset_save_url=reverse("games:save_preset") + "?mode=playevents",
)
content = mark_safe(str(filter_bar) + str(content))
return render_page(
request,
content,
title="Manage play events",
scripts=ModuleScript("range_slider.js")
+ ModuleScript("search_select.js")
+ ModuleScript("filter_bar.js"),
)
```
---
### Task 4: Support new preset modes in Preset View/Model
Ensure FilterPreset allows `devices` and `platforms` modes.
**Files:**
- Modify: `games/models.py`
- Modify: `games/views/filter_presets.py`
- [ ] **Step 1: Expand FilterPreset mode choices**
Verify or expand `MODE_CHOICES` inside `FilterPreset` model in `games/models.py`.
```python
# Inside FilterPreset class:
MODE_CHOICES = [
("games", "Games"),
("sessions", "Sessions"),
("purchases", "Purchases"),
("playevents", "Play Events"),
("devices", "Devices"),
("platforms", "Platforms"),
]
```
---
### Task 5: Add Render Tests for new FilterBars
**Files:**
- Modify: `tests/test_filter_bars.py`
- [ ] **Step 1: Write tests to verify new FilterBars render correctly**
Add test cases in `tests/test_filter_bars.py`:
```python
def test_device_filter_bar(self):
from common.components import DeviceFilterBar
html = str(
DeviceFilterBar(
filter_json="",
preset_list_url="/presets/devices/list",
preset_save_url="/presets/devices/save",
)
)
self._assert_shell(html, "/presets/devices/list", "/presets/devices/save")
def test_platform_filter_bar(self):
from common.components import PlatformFilterBar
html = str(
PlatformFilterBar(
filter_json="",
preset_list_url="/presets/platforms/list",
preset_save_url="/presets/platforms/save",
)
)
self._assert_shell(html, "/presets/platforms/list", "/presets/platforms/save")
def test_playevent_filter_bar(self):
from common.components import PlayEventFilterBar
html = str(
PlayEventFilterBar(
filter_json="",
preset_list_url="/presets/playevents/list",
preset_save_url="/presets/playevents/save",
)
)
self._assert_shell(html, "/presets/playevents/list", "/presets/playevents/save")
```
- [ ] **Step 2: Run all test suites to confirm complete success**
Run: `pytest tests/test_filter_bars.py -v`
Expected: ALL filter bar render tests pass.
+1
View File
@@ -0,0 +1 @@
# e2e tests package
+21
View File
@@ -0,0 +1,21 @@
import os
import shutil
import pytest
# Playwright runs an async event loop in the background, which triggers
# Django's async safety checks when running synchronous tests. This allows
# synchronous operations inside the async context safely.
os.environ.setdefault("DJANGO_ALLOW_ASYNC_UNSAFE", "true")
@pytest.fixture(scope="session")
def browser_type_launch_args(browser_type_launch_args):
# Try to find a system-installed Google Chrome or Chromium to bypass Nix/NixOS shared library issues
for browser_name in ["google-chrome-stable", "google-chrome", "chromium", "chrome"]:
path = shutil.which(browser_name)
if path:
return {
**browser_type_launch_args,
"executable_path": path,
}
# Fallback to default Playwright behavior
return browser_type_launch_args
+101
View File
@@ -0,0 +1,101 @@
import pytest
from django.urls import path
from django.http import HttpResponse
from django.test import override_settings
from common.components import SearchSelect
def e2e_test_view(request):
html = f"""
<!DOCTYPE html>
<html>
<head>
<title>SearchSelect E2E Test</title>
<script src="/static/js/search_select.js" defer></script>
</head>
<body>
<div style="padding: 50px;">
{SearchSelect(
name="games",
selected=[{"value": "7", "label": "Game A", "data": {}}],
options=[
{"value": "7", "label": "Game A", "data": {}},
{"value": "8", "label": "Game B", "data": {}},
],
multi_select=False
)}
</div>
</body>
</html>
"""
return HttpResponse(html)
urlpatterns = [
path("test-search-select/", e2e_test_view),
]
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_search_select_e2e")
def test_search_select_backspace_clears_single_select(live_server, page):
# Enable console log forwarding
page.on("console", lambda msg: print(f"BROWSER CONSOLE: {msg.text}"))
page.goto(live_server.url + "/test-search-select/")
# Inject our event logger
page.evaluate("""() => {
const s = document.querySelector('input[data-search-select-search]');
const c = document.querySelector('[data-search-select]');
s.addEventListener('focus', () => console.log('JS-EVENT: focus, dirty=' + c._searchSelectDirty + ', value="' + s.value + '"'));
s.addEventListener('blur', () => console.log('JS-EVENT: blur, dirty=' + c._searchSelectDirty + ', value="' + s.value + '"'));
s.addEventListener('input', () => console.log('JS-EVENT: input, dirty=' + c._searchSelectDirty + ', value="' + s.value + '"'));
s.addEventListener('keydown', (e) => console.log('JS-EVENT: keydown ' + e.key + ', dirty=' + c._searchSelectDirty + ', value="' + s.value + '"'));
}""")
search_input = page.locator("input[data-search-select-search]")
assert search_input.input_value() == "Game A"
hidden_input = page.locator('input[name="games"]')
assert hidden_input.first.get_attribute("value") == "7"
# Focus the input
print("\n--- FOCUSING INPUT ---")
search_input.focus()
assert search_input.input_value() == ""
# Press Backspace using the raw keyboard API to avoid any high-level Playwright input simulation
print("\n--- PRESSING BACKSPACE ---")
page.keyboard.press("Backspace")
# Explicitly blur the input
print("\n--- BLURRING INPUT ---")
search_input.blur()
# Wait for blur microtasks/setTimeout to settle (120ms timeout in JS)
page.wait_for_timeout(200)
# After Backspace and blur, the input should remain empty (the selection is cleared)
assert search_input.input_value() == ""
assert hidden_input.count() == 0
@pytest.mark.django_db
@override_settings(ROOT_URLCONF="e2e.test_search_select_e2e")
def test_search_select_typing_replaces_single_select(live_server, page):
page.goto(live_server.url + "/test-search-select/")
search_input = page.locator("input[data-search-select-search]")
search_input.focus()
assert search_input.input_value() == ""
search_input.type("X")
assert search_input.input_value() == "X"
search_input.blur()
page.wait_for_timeout(200)
assert search_input.input_value() == "Game A"
hidden_input = page.locator('input[name="games"]')
assert hidden_input.first.get_attribute("value") == "7"
+19 -7
View File
@@ -1,11 +1,23 @@
#!/bin/bash
# Apply database migrations
set -euo pipefail
echo "Apply database migrations"
poetry run python manage.py migrate
echo "Collect static files"
poetry run python manage.py collectstatic --clear --no-input
PUID=${PUID:-1000}
PGID=${PGID:-100}
echo "Starting app"
poetry run python -m gunicorn --bind 0.0.0.0:8001 timetracker.asgi:application -k uvicorn.workers.UvicornWorker --access-logfile - --error-logfile -
USERHOME=$(grep timetracker /etc/passwd | cut -d ":" -f6)
usermod -d "/root" timetracker
groupmod -o -g "$PGID" timetracker
usermod -o -u "$PUID" timetracker
usermod -d "${USERHOME}" timetracker
mkdir -p /home/timetracker/app/data /var/log/supervisor
chmod 755 /home/timetracker/app
chmod 755 /home/timetracker/app/.venv
chown "$PUID:$PGID" /home/timetracker/app/data
chown "$PUID:$PGID" /var/log/supervisor
python manage.py migrate
python manage.py collectstatic --clear --no-input
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisor.conf
+10 -1
View File
@@ -1,9 +1,18 @@
from django.contrib import admin
from games.models import Game, Platform, Purchase, Session
from games.models import (
Device,
ExchangeRate,
Game,
Platform,
Purchase,
Session,
)
# Register your models here.
admin.site.register(Game)
admin.site.register(Purchase)
admin.site.register(Platform)
admin.site.register(Session)
admin.site.register(Device)
admin.site.register(ExchangeRate)
+159
View File
@@ -0,0 +1,159 @@
from datetime import date, datetime
from typing import List
from django.contrib import messages
from django.db.models import Q
from django.shortcuts import get_object_or_404
from django.utils.timezone import now as django_timezone_now
from ninja import Field, ModelSchema, NinjaAPI, Router, Schema, Status
from games.models import Device, Game, Platform, PlayEvent, Session
api = NinjaAPI()
playevent_router = Router()
game_router = Router()
device_router = Router()
platform_router = Router()
NOW_FACTORY = django_timezone_now
class GameStatusUpdate(Schema):
status: str
class PlayEventIn(Schema):
game_id: int
started: date | None = None
ended: date | None = None
note: str = ""
days_to_finish: int | None = None
class AutoPlayEventIn(ModelSchema):
class Meta:
model = PlayEvent
fields = ["game", "started", "ended", "note"]
class UpdatePlayEventIn(Schema):
started: date | None = None
ended: date | None = None
note: str = ""
class PlayEventOut(Schema):
id: int
game: str = Field(..., alias="game.name")
started: date | None = None
ended: date | None = None
days_to_finish: int | None = None
note: str = ""
updated_at: datetime
created_at: datetime
class GameOption(Schema): # mirrors SearchSelectOption
value: int
label: str
data: dict
@game_router.get("/search", response=list[GameOption])
def search_games(request, q: str = "", limit: int = 10):
qs = Game.objects.select_related("platform").order_by("sort_name")
if q:
qs = qs.filter(Q(name__icontains=q) | Q(sort_name__icontains=q))
return [
{
"value": g.id,
"label": g.search_label,
"data": {"platform": g.platform_id or ""},
}
for g in qs[:limit]
]
@game_router.patch("/{game_id}/status", response={204: None})
def partial_update_game(request, game_id: int, payload: GameStatusUpdate):
game = get_object_or_404(Game, id=game_id)
setattr(game, "status", payload.status)
game.save()
messages.success(request, "Status updated")
return Status(204, None)
@playevent_router.get("/", response=List[PlayEventOut])
def list_playevents(request):
return PlayEvent.objects.all()
@playevent_router.post("/", response={201: PlayEventOut})
def create_playevent(request, payload: PlayEventIn):
playevent = PlayEvent.objects.create(**payload.dict())
messages.success(request, "Game played!")
return playevent
@playevent_router.get("/{playevent_id}", response=PlayEventOut)
def get_playevent(request, playevent_id: int):
playevent = get_object_or_404(PlayEvent, id=playevent_id)
return playevent
@playevent_router.patch("/{playevent_id}", response=PlayEventOut)
def partial_update_playevent(request, playevent_id: int, payload: UpdatePlayEventIn):
playevent = get_object_or_404(PlayEvent, id=playevent_id)
for attr, value in payload.dict(exclude_unset=True).items():
setattr(playevent, attr, value)
playevent.save()
return playevent
@playevent_router.delete("/{playevent_id}", response={204: None})
def delete_playevent(request, playevent_id: int):
playevent = get_object_or_404(PlayEvent, id=playevent_id)
playevent.delete()
return Status(204, None)
@device_router.get("/search", response=list[GameOption])
def search_devices(request, q: str = "", limit: int = 10):
qs = Device.objects.order_by("name")
if q:
qs = qs.filter(name__icontains=q)
return [{"value": d.id, "label": d.name, "data": {}} for d in qs[:limit]]
@platform_router.get("/search", response=list[GameOption])
def search_platforms(request, q: str = "", limit: int = 10):
qs = Platform.objects.order_by("name")
if q:
qs = qs.filter(name__icontains=q)
return [{"value": p.id, "label": p.name, "data": {}} for p in qs[:limit]]
api.add_router("/playevent", playevent_router)
api.add_router("/games", game_router)
api.add_router("/devices", device_router)
api.add_router("/platforms", platform_router)
session_router = Router()
class SessionDeviceUpdate(Schema):
device_id: int
@session_router.patch("/{session_id}/device", response={204: None})
def partial_update_session_device(
request, session_id: int, payload: SessionDeviceUpdate
):
session = get_object_or_404(Session, id=session_id)
session.device_id = payload.device_id
session.save()
messages.success(request, "Device updated")
return Status(204, None)
api.add_router("/session", session_router)
+40
View File
@@ -1,6 +1,46 @@
# from datetime import timedelta
from django.apps import AppConfig
from django.core.management import call_command
from django.db.models.signals import post_migrate
# from django.utils.timezone import now
class GamesConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "games"
def ready(self):
import games.signals # noqa: F401
post_migrate.connect(schedule_tasks, sender=self)
def schedule_tasks(sender, **kwargs):
# from django_q.models import Schedule
# from django_q.tasks import schedule
# if not Schedule.objects.filter(name="Update converted prices").exists():
# schedule(
# "games.tasks.convert_prices",
# name="Update converted prices",
# schedule_type=Schedule.MINUTES,
# next_run=now() + timedelta(seconds=30),
# catchup=False,
# )
# if not Schedule.objects.filter(name="Update price per game").exists():
# schedule(
# "games.tasks.calculate_price_per_game",
# name="Update price per game",
# schedule_type=Schedule.MINUTES,
# next_run=now() + timedelta(seconds=30),
# catchup=False,
# )
from games.models import ExchangeRate
if not ExchangeRate.objects.exists():
print("ExchangeRate table is empty. Loading fixture...")
call_command("loaddata", "exchangerates.yaml")
+781
View File
@@ -0,0 +1,781 @@
"""
Entity-specific filter types for the timetracker app.
Each filter class mirrors a Django model, with fields expressed as typed
criteria from common.criteria. The to_q() method produces a Django Q object
ready for queryset.filter().
Inspired by Stash's filter architecture: each entity has an OperatorFilter
with AND/OR/NOT composition and typed criterion fields.
"""
from __future__ import annotations
from dataclasses import dataclass
from django.db.models import Q
from common.criteria import (
BoolCriterion,
ChoiceCriterion,
FloatCriterion,
IntCriterion,
Modifier,
MultiCriterion,
OperatorFilter,
StringCriterion,
filter_from_json,
)
# ── FindFilter (sort / pagination) ─────────────────────────────────────────
@dataclass
class FindFilter:
"""Sorting and pagination, separate from filtering criteria (Stash-style)."""
q: str | None = None # free-text search
page: int = 1
per_page: int = 25
sort: str | None = None # e.g. "-created_at"
direction: str = "desc" # asc / desc
# ── GameFilter ─────────────────────────────────────────────────────────────
@dataclass
class GameFilter(OperatorFilter):
"""Filter for the Game model."""
AND: GameFilter | None = None
OR: GameFilter | None = None
NOT: GameFilter | None = None
name: StringCriterion | None = None
sort_name: StringCriterion | None = None
year_released: IntCriterion | None = None
original_year_released: IntCriterion | None = None
wikidata: StringCriterion | None = None
platform: ChoiceCriterion | None = None # selectable filter widget
status: ChoiceCriterion | None = None # selectable filter widget
mastered: BoolCriterion | None = None
playtime_minutes: IntCriterion | None = None # converted to timedelta on to_q()
created_at: StringCriterion | None = None # date string
updated_at: StringCriterion | None = None # date string
has_purchases: BoolCriterion | None = None
has_playevents: BoolCriterion | None = None
session_count: IntCriterion | None = None
session_average: IntCriterion | None = None # average in minutes
# Free-text search (combines name + sort_name + platform name)
search: StringCriterion | None = None
# Cross-entity filters
session_filter: SessionFilter | None = None
purchase_filter: PurchaseFilter | None = None
playevent_filter: PlayEventFilter | None = None
platform_filter: PlatformFilter | None = None
def to_q(self) -> Q:
q = Q()
# ── individual criteria ──
if self.name is not None:
q &= self.name.to_q("name")
if self.sort_name is not None:
q &= self.sort_name.to_q("sort_name")
if self.year_released is not None:
q &= self.year_released.to_q("year_released")
if self.original_year_released is not None:
q &= self.original_year_released.to_q("original_year_released")
if self.wikidata is not None:
q &= self.wikidata.to_q("wikidata")
if self.platform is not None:
q &= self.platform.to_q("platform_id")
if self.status is not None:
q &= self.status.to_q("status")
if self.mastered is not None:
q &= self.mastered.to_q("mastered")
if self.playtime_minutes is not None:
q &= self._playtime_to_q(self.playtime_minutes)
if self.created_at is not None:
q &= self.created_at.to_q("created_at")
if self.updated_at is not None:
q &= self.updated_at.to_q("updated_at")
if self.has_purchases is not None:
from games.models import Purchase
purchased_ids = Purchase.objects.values_list("games__id", flat=True).distinct()
if self.has_purchases.value:
q &= Q(id__in=purchased_ids)
else:
q &= ~Q(id__in=purchased_ids)
if self.has_playevents is not None:
from games.models import PlayEvent
played_ids = PlayEvent.objects.values_list("game_id", flat=True).distinct()
if self.has_playevents.value:
q &= Q(id__in=played_ids)
else:
q &= ~Q(id__in=played_ids)
if self.session_count is not None:
from games.models import Game
from django.db.models import Count
matching_ids = Game.objects.annotate(s_count=Count("sessions")).filter(self.session_count.to_q("s_count")).values_list("id", flat=True)
q &= Q(id__in=matching_ids)
if self.session_average is not None:
from games.models import Game
from django.db.models import Avg
matching_ids = Game.objects.annotate(s_avg=Avg("sessions__duration_total")).filter(self._playtime_to_q_for_field(self.session_average, "s_avg")).values_list("id", flat=True)
q &= Q(id__in=matching_ids)
# ── free-text search (OR across multiple fields) ──
if self.search is not None and self.search.value:
search_q = (
Q(name__icontains=self.search.value)
| Q(sort_name__icontains=self.search.value)
| Q(platform__name__icontains=self.search.value)
)
if self.search.modifier == Modifier.EXCLUDES:
search_q = ~search_q
q &= search_q
# Cross-entity filters
if self.session_filter is not None:
from games.models import Session
session_q = self.session_filter.to_q()
matching_ids = Session.objects.filter(session_q).values_list("game_id", flat=True)
q &= Q(id__in=matching_ids)
if self.purchase_filter is not None:
from games.models import Purchase
purchase_q = self.purchase_filter.to_q()
matching_ids = Purchase.objects.filter(purchase_q).values_list("games__id", flat=True)
q &= Q(id__in=matching_ids)
if self.playevent_filter is not None:
from games.models import PlayEvent
playevent_q = self.playevent_filter.to_q()
matching_ids = PlayEvent.objects.filter(playevent_q).values_list("game_id", flat=True)
q &= Q(id__in=matching_ids)
if self.platform_filter is not None:
from games.models import Platform
platform_q = self.platform_filter.to_q()
matching_ids = Platform.objects.filter(platform_q).values_list("id", flat=True)
q &= Q(platform_id__in=matching_ids)
# ── AND / OR / NOT sub-filters ──
sub = self.sub_filter()
if sub is not None:
if self.AND is not None:
q &= sub.to_q()
elif self.OR is not None:
q |= sub.to_q()
elif self.NOT is not None:
q &= ~sub.to_q()
return q
@staticmethod
def _playtime_to_q(c: IntCriterion) -> Q:
return GameFilter._playtime_to_q_for_field(c, "playtime")
@staticmethod
def _playtime_to_q_for_field(c: IntCriterion, field: str) -> Q:
"""Convert minutes-based criterion to a DurationField Q object.
Django stores DurationField as microseconds in SQLite, so we convert
minutes → timedelta(microseconds=X) and use the appropriate lookups.
"""
from datetime import timedelta
from common.criteria import Modifier
m = c.modifier
td_val = timedelta(minutes=c.value)
if m == Modifier.EQUALS:
return Q(
**{
f"{field}__gte": td_val,
f"{field}__lt": timedelta(minutes=c.value + 1),
}
)
if m == Modifier.NOT_EQUALS:
return ~Q(
**{
f"{field}__gte": td_val,
f"{field}__lt": timedelta(minutes=c.value + 1),
}
)
if m == Modifier.GREATER_THAN:
return Q(**{f"{field}__gt": td_val})
if m == Modifier.LESS_THAN:
return Q(**{f"{field}__lt": td_val})
if m == Modifier.BETWEEN and c.value2 is not None:
lo = timedelta(minutes=min(c.value, c.value2))
hi = timedelta(minutes=max(c.value, c.value2))
return Q(**{f"{field}__gte": lo, f"{field}__lte": hi})
if m == Modifier.NOT_BETWEEN and c.value2 is not None:
lo = timedelta(minutes=min(c.value, c.value2))
hi = timedelta(minutes=max(c.value, c.value2))
return Q(**{f"{field}__lt": lo}) | Q(**{f"{field}__gt": hi})
if m == Modifier.IS_NULL:
return Q(**{f"{field}": timedelta(0)})
if m == Modifier.NOT_NULL:
return ~Q(**{f"{field}": timedelta(0)})
return Q()
# ── SessionFilter ──────────────────────────────────────────────────────────
@dataclass
class SessionFilter(OperatorFilter):
"""Filter for the Session model."""
AND: SessionFilter | None = None
OR: SessionFilter | None = None
NOT: SessionFilter | None = None
game: MultiCriterion | None = None # filters on game_id
device: MultiCriterion | None = None # filters on device_id
emulated: BoolCriterion | None = None
note: StringCriterion | None = None
duration_minutes: IntCriterion | None = None # on duration_total (legacy alias)
duration_total_minutes: IntCriterion | None = None
duration_manual_minutes: IntCriterion | None = None
duration_calculated_minutes: IntCriterion | None = None
is_active: BoolCriterion | None = None # timestamp_end IS NULL
timestamp_start: StringCriterion | None = None # date string
timestamp_end: StringCriterion | None = None # date string
is_manual: BoolCriterion | None = None # duration_manual > 0
created_at: StringCriterion | None = None
# Free-text search
search: StringCriterion | None = None
# Cross-entity: sessions for games matching these criteria
game_filter: GameFilter | None = None
# Cross-entity: sessions for devices matching these criteria
device_filter: DeviceFilter | None = None
def _duration_to_q(self, c: IntCriterion, field: str) -> Q:
from datetime import timedelta
q = Q()
td_val = timedelta(minutes=c.value)
m = c.modifier
if m == Modifier.EQUALS:
q &= Q(
**{
f"{field}__gte": td_val,
f"{field}__lt": timedelta(minutes=c.value + 1),
}
)
elif m == Modifier.NOT_EQUALS:
q &= ~Q(
**{
f"{field}__gte": td_val,
f"{field}__lt": timedelta(minutes=c.value + 1),
}
)
elif m == Modifier.GREATER_THAN:
q &= Q(**{f"{field}__gt": td_val})
elif m == Modifier.LESS_THAN:
q &= Q(**{f"{field}__lt": td_val})
elif m == Modifier.BETWEEN and c.value2 is not None:
lo = timedelta(minutes=min(c.value, c.value2))
hi = timedelta(minutes=max(c.value, c.value2))
q &= Q(**{f"{field}__gte": lo, f"{field}__lte": hi})
elif m == Modifier.NOT_BETWEEN and c.value2 is not None:
lo = timedelta(minutes=min(c.value, c.value2))
hi = timedelta(minutes=max(c.value, c.value2))
q &= Q(**{f"{field}__lt": lo}) | Q(**{f"{field}__gt": hi})
elif m == Modifier.IS_NULL:
q &= Q(**{f"{field}": timedelta(0)})
elif m == Modifier.NOT_NULL:
q &= ~Q(**{f"{field}": timedelta(0)})
return q
def to_q(self) -> Q:
from datetime import timedelta
q = Q()
if self.game is not None:
q &= self.game.to_q("game_id")
if self.device is not None:
q &= self.device.to_q("device_id")
if self.emulated is not None:
q &= self.emulated.to_q("emulated")
if self.note is not None:
q &= self.note.to_q("note")
if self.duration_minutes is not None:
q &= self._duration_to_q(self.duration_minutes, "duration_total")
if self.duration_total_minutes is not None:
q &= self._duration_to_q(self.duration_total_minutes, "duration_total")
if self.duration_manual_minutes is not None:
q &= self._duration_to_q(self.duration_manual_minutes, "duration_manual")
if self.duration_calculated_minutes is not None:
q &= self._duration_to_q(self.duration_calculated_minutes, "duration_calculated")
if self.is_active is not None:
if self.is_active.value:
q &= Q(timestamp_end__isnull=True)
else:
q &= Q(timestamp_end__isnull=False)
if self.timestamp_start is not None:
q &= self.timestamp_start.to_q("timestamp_start")
if self.timestamp_end is not None:
q &= self.timestamp_end.to_q("timestamp_end")
if self.is_manual is not None:
if self.is_manual.value:
q &= ~Q(duration_manual=timedelta(0))
else:
q &= Q(duration_manual=timedelta(0))
if self.created_at is not None:
q &= self.created_at.to_q("created_at")
# Free-text search
if self.search is not None and self.search.value:
search_q = (
Q(game__name__icontains=self.search.value)
| Q(game__platform__name__icontains=self.search.value)
| Q(device__name__icontains=self.search.value)
| Q(device__type__icontains=self.search.value)
)
if self.search.modifier == Modifier.EXCLUDES:
search_q = ~search_q
q &= search_q
# Cross-entity filter: sessions for games matching GameFilter
if self.game_filter is not None:
from games.models import Game
game_q = self.game_filter.to_q()
matching_ids = Game.objects.filter(game_q).values_list("id", flat=True)
q &= Q(game_id__in=matching_ids)
# Cross-entity filter: sessions for devices matching DeviceFilter
if self.device_filter is not None:
from games.models import Device
device_q = self.device_filter.to_q()
matching_ids = Device.objects.filter(device_q).values_list("id", flat=True)
q &= Q(device_id__in=matching_ids)
# AND / OR / NOT
sub = self.sub_filter()
if sub is not None:
if self.AND is not None:
q &= sub.to_q()
elif self.OR is not None:
q |= sub.to_q()
elif self.NOT is not None:
q &= ~sub.to_q()
return q
# ── PurchaseFilter ─────────────────────────────────────────────────────────
@dataclass
class PurchaseFilter(OperatorFilter):
"""Filter for the Purchase model."""
AND: PurchaseFilter | None = None
OR: PurchaseFilter | None = None
NOT: PurchaseFilter | None = None
name: StringCriterion | None = None
platform: ChoiceCriterion | None = None # platform_id
games: ChoiceCriterion | None = None # games (M2M IDs)
date_purchased: StringCriterion | None = None # date string
date_refunded: StringCriterion | None = None # date string
is_refunded: BoolCriterion | None = None # date_refunded IS NOT NULL
price: FloatCriterion | None = None # on price field
converted_price: FloatCriterion | None = None
price_currency: StringCriterion | None = None
num_purchases: IntCriterion | None = None
ownership_type: ChoiceCriterion | None = None # ph/di/du/re/bo/tr/de/pi
type: ChoiceCriterion | None = None # game/dlc/season_pass/battle_pass
created_at: StringCriterion | None = None
updated_at: StringCriterion | None = None
infinite: BoolCriterion | None = None
needs_price_update: BoolCriterion | None = None
converted_currency: StringCriterion | None = None
# Free-text search
search: StringCriterion | None = None
# Cross-entity: purchases for games matching these criteria
game_filter: GameFilter | None = None
# Cross-entity: purchases for platforms matching these criteria
platform_filter: PlatformFilter | None = None
def to_q(self) -> Q:
q = Q()
if self.name is not None:
q &= self.name.to_q("name")
if self.platform is not None:
q &= self.platform.to_q("platform_id")
if self.games is not None:
q &= self._games_to_q(self.games)
if self.date_purchased is not None:
q &= self.date_purchased.to_q("date_purchased")
if self.date_refunded is not None:
q &= self.date_refunded.to_q("date_refunded")
if self.is_refunded is not None:
q &= Q(date_refunded__isnull=not self.is_refunded.value)
if self.price is not None:
q &= self.price.to_q("price")
if self.converted_price is not None:
q &= self.converted_price.to_q("converted_price")
if self.price_currency is not None:
q &= self.price_currency.to_q("price_currency")
if self.num_purchases is not None:
q &= self.num_purchases.to_q("num_purchases")
if self.ownership_type is not None:
q &= self.ownership_type.to_q("ownership_type")
if self.type is not None:
q &= self.type.to_q("type")
if self.created_at is not None:
q &= self.created_at.to_q("created_at")
if self.updated_at is not None:
q &= self.updated_at.to_q("updated_at")
if self.infinite is not None:
q &= self.infinite.to_q("infinite")
if self.needs_price_update is not None:
q &= self.needs_price_update.to_q("needs_price_update")
if self.converted_currency is not None:
q &= self.converted_currency.to_q("converted_currency")
# Free-text search
if self.search is not None and self.search.value:
search_q = (
Q(name__icontains=self.search.value)
| Q(games__name__icontains=self.search.value)
| Q(platform__name__icontains=self.search.value)
)
if self.search.modifier == Modifier.EXCLUDES:
search_q = ~search_q
q &= search_q
# Cross-entity filter
if self.game_filter is not None:
from games.models import Game
game_q = self.game_filter.to_q()
matching_ids = Game.objects.filter(game_q).values_list("id", flat=True)
q &= Q(games__id__in=matching_ids)
# Cross-entity platform filter
if self.platform_filter is not None:
from games.models import Platform
platform_q = self.platform_filter.to_q()
matching_ids = Platform.objects.filter(platform_q).values_list("id", flat=True)
q &= Q(platform_id__in=matching_ids)
sub = self.sub_filter()
if sub is not None:
if self.AND is not None:
q &= sub.to_q()
elif self.OR is not None:
q |= sub.to_q()
elif self.NOT is not None:
q &= ~sub.to_q()
return q
@staticmethod
def _games_to_q(criterion: ChoiceCriterion) -> Q:
"""Build the Q for the many-to-many ``games`` field.
``INCLUDES_ALL`` ("related to every selected game") and
``INCLUDES_ONLY`` ("related to exactly these, nothing else") cannot be
a single ``.filter(Q(games=a) & Q(games=b))`` — that collapses to one
join and would require a single link row to be both games. Instead
chain a filter per game so each gets its own join, then match by
``pk``. ``INCLUDES_ONLY`` additionally excludes purchases that have
any game outside the specified set.
``INCLUDES`` (plain "any") also uses a subquery instead of a raw
``games__in`` join because a single purchase linked to *n* of the
given games would appear *n* times in the result set (M2M join
duplicates).
The orthogonal ``excludes`` channel is applied as a negative,
consistent with every other modifier. All other modifiers delegate
to the criterion.
"""
# Empty value means no constraint; still apply excludes if any
if not criterion.value:
if criterion.excludes:
return ~Q(games__in=criterion.excludes)
return Q()
from games.models import Game, Purchase
if criterion.modifier in (Modifier.INCLUDES_ALL, Modifier.INCLUDES_ONLY):
subquery = Purchase.objects.all()
for game_id in criterion.value:
subquery = subquery.filter(games=game_id)
if criterion.modifier == Modifier.INCLUDES_ONLY:
extra_ids = Game.objects.exclude(
id__in=criterion.value
).values_list("id", flat=True)
if extra_ids:
subquery = subquery.exclude(games__in=extra_ids)
q = Q(pk__in=subquery.values("pk"))
if criterion.excludes:
q &= ~Q(games__in=criterion.excludes)
return q
if criterion.modifier == Modifier.INCLUDES:
# Use subquery to avoid duplicate rows from M2M join
subquery = Purchase.objects.filter(games__in=criterion.value)
q = Q(pk__in=subquery.values("pk"))
if criterion.excludes:
q &= ~Q(games__in=criterion.excludes)
return q
return criterion.to_q("games")
# ── DeviceFilter ───────────────────────────────────────────────────────────
@dataclass
class DeviceFilter(OperatorFilter):
"""Filter for the Device model."""
AND: DeviceFilter | None = None
OR: DeviceFilter | None = None
NOT: DeviceFilter | None = None
name: StringCriterion | None = None
type: ChoiceCriterion | None = None
created_at: StringCriterion | None = None
# Free-text search
search: StringCriterion | None = None
# Cross-entity: Devices that have sessions matching these criteria
session_filter: SessionFilter | None = None
def to_q(self) -> Q:
q = Q()
if self.name is not None:
q &= self.name.to_q("name")
if self.type is not None:
q &= self.type.to_q("type")
if self.created_at is not None:
q &= self.created_at.to_q("created_at")
# Free-text search
if self.search is not None and self.search.value:
search_q = (
Q(name__icontains=self.search.value)
| Q(type__icontains=self.search.value)
)
if self.search.modifier == Modifier.EXCLUDES:
search_q = ~search_q
q &= search_q
# Cross-entity filter: session_filter
if self.session_filter is not None:
from games.models import Session
session_q = self.session_filter.to_q()
matching_ids = Session.objects.filter(session_q).values_list("device_id", flat=True)
q &= Q(id__in=matching_ids)
sub = self.sub_filter()
if sub is not None:
if self.AND is not None:
q &= sub.to_q()
elif self.OR is not None:
q |= sub.to_q()
elif self.NOT is not None:
q &= ~sub.to_q()
return q
# ── PlatformFilter ─────────────────────────────────────────────────────────
@dataclass
class PlatformFilter(OperatorFilter):
"""Filter for the Platform model."""
AND: PlatformFilter | None = None
OR: PlatformFilter | None = None
NOT: PlatformFilter | None = None
name: StringCriterion | None = None
group: StringCriterion | None = None
icon: StringCriterion | None = None
created_at: StringCriterion | None = None
# Free-text search
search: StringCriterion | None = None
# Cross-entity
game_filter: GameFilter | None = None
purchase_filter: PurchaseFilter | None = None
def to_q(self) -> Q:
q = Q()
if self.name is not None:
q &= self.name.to_q("name")
if self.group is not None:
q &= self.group.to_q("group")
if self.icon is not None:
q &= self.icon.to_q("icon")
if self.created_at is not None:
q &= self.created_at.to_q("created_at")
# Free-text search
if self.search is not None and self.search.value:
search_q = (
Q(name__icontains=self.search.value)
| Q(group__icontains=self.search.value)
)
if self.search.modifier == Modifier.EXCLUDES:
search_q = ~search_q
q &= search_q
# Cross-entity filter: game_filter
if self.game_filter is not None:
from games.models import Game
game_q = self.game_filter.to_q()
matching_ids = Game.objects.filter(game_q).values_list("platform_id", flat=True)
q &= Q(id__in=matching_ids)
# Cross-entity filter: purchase_filter
if self.purchase_filter is not None:
from games.models import Purchase
purchase_q = self.purchase_filter.to_q()
matching_ids = Purchase.objects.filter(purchase_q).values_list("platform_id", flat=True)
q &= Q(id__in=matching_ids)
sub = self.sub_filter()
if sub is not None:
if self.AND is not None:
q &= sub.to_q()
elif self.OR is not None:
q |= sub.to_q()
elif self.NOT is not None:
q &= ~sub.to_q()
return q
# ── PlayEventFilter ────────────────────────────────────────────────────────
@dataclass
class PlayEventFilter(OperatorFilter):
"""Filter for the PlayEvent model."""
AND: PlayEventFilter | None = None
OR: PlayEventFilter | None = None
NOT: PlayEventFilter | None = None
game: MultiCriterion | None = None # filters on game_id
started: StringCriterion | None = None # date string
ended: StringCriterion | None = None # date string
days_to_finish: IntCriterion | None = None
note: StringCriterion | None = None
created_at: StringCriterion | None = None
# Free-text search
search: StringCriterion | None = None
# Cross-entity: PlayEvents for games matching these criteria
game_filter: GameFilter | None = None
def to_q(self) -> Q:
q = Q()
if self.game is not None:
q &= self.game.to_q("game_id")
if self.started is not None:
q &= self.started.to_q("started")
if self.ended is not None:
q &= self.ended.to_q("ended")
if self.days_to_finish is not None:
q &= self.days_to_finish.to_q("days_to_finish")
if self.note is not None:
q &= self.note.to_q("note")
if self.created_at is not None:
q &= self.created_at.to_q("created_at")
# Free-text search
if self.search is not None and self.search.value:
search_q = (
Q(game__name__icontains=self.search.value)
| Q(note__icontains=self.search.value)
)
if self.search.modifier == Modifier.EXCLUDES:
search_q = ~search_q
q &= search_q
# Cross-entity filter: game_filter
if self.game_filter is not None:
from games.models import Game
game_q = self.game_filter.to_q()
matching_ids = Game.objects.filter(game_q).values_list("id", flat=True)
q &= Q(game_id__in=matching_ids)
sub = self.sub_filter()
if sub is not None:
if self.AND is not None:
q &= sub.to_q()
elif self.OR is not None:
q |= sub.to_q()
elif self.NOT is not None:
q &= ~sub.to_q()
return q
# ── Convenience helpers ────────────────────────────────────────────────────
def parse_game_filter(json_str: str) -> GameFilter | None:
return filter_from_json(GameFilter, json_str)
def parse_session_filter(json_str: str) -> SessionFilter | None:
return filter_from_json(SessionFilter, json_str)
def parse_purchase_filter(json_str: str) -> PurchaseFilter | None:
return filter_from_json(PurchaseFilter, json_str)
def parse_device_filter(json_str: str) -> DeviceFilter | None:
return filter_from_json(DeviceFilter, json_str)
def parse_platform_filter(json_str: str) -> PlatformFilter | None:
return filter_from_json(PlatformFilter, json_str)
def parse_playevent_filter(json_str: str) -> PlayEventFilter | None:
return filter_from_json(PlayEventFilter, json_str)
+504
View File
@@ -0,0 +1,504 @@
- model: games.exchangerate
pk: 1
fields:
currency_from: USD
currency_to: CZK
year: 2024
rate: 23.4
- model: games.exchangerate
pk: 2
fields:
currency_from: CNY
currency_to: CZK
year: 2024
rate: 3.267
- model: games.exchangerate
pk: 3
fields:
currency_from: USD
currency_to: CZK
year: 2019
rate: 22.466
- model: games.exchangerate
pk: 4
fields:
currency_from: USD
currency_to: CZK
year: 2023
rate: 22.63
- model: games.exchangerate
pk: 5
fields:
currency_from: USD
currency_to: CZK
year: 2017
rate: 25.819
- model: games.exchangerate
pk: 6
fields:
currency_from: USD
currency_to: CZK
year: 2013
rate: 19.023
- model: games.exchangerate
pk: 7
fields:
currency_from: CNY
currency_to: CZK
year: 2019
rate: 3.295
- model: games.exchangerate
pk: 8
fields:
currency_from: CNY
currency_to: CZK
year: 2016
rate: 3.795
- model: games.exchangerate
pk: 9
fields:
currency_from: CNY
currency_to: CZK
year: 2015
rate: 3.707
- model: games.exchangerate
pk: 10
fields:
currency_from: CNY
currency_to: CZK
year: 2020
rate: 3.26
- model: games.exchangerate
pk: 11
fields:
currency_from: EUR
currency_to: CZK
year: 2012
rate: 25.51
- model: games.exchangerate
pk: 12
fields:
currency_from: EUR
currency_to: CZK
year: 2010
rate: 26.465
- model: games.exchangerate
pk: 13
fields:
currency_from: EUR
currency_to: CZK
year: 2014
rate: 27.52
- model: games.exchangerate
pk: 14
fields:
currency_from: EUR
currency_to: CZK
year: 2024
rate: 25.21
- model: games.exchangerate
pk: 15
fields:
currency_from: EUR
currency_to: CZK
year: 2022
rate: 24.325
- model: games.exchangerate
pk: 16
fields:
currency_from: CNY
currency_to: CZK
year: 2018
rate: 3.268
- model: games.exchangerate
pk: 17
fields:
currency_from: CNY
currency_to: CZK
year: 2023
rate: 3.281
- model: games.exchangerate
pk: 18
fields:
currency_from: EUR
currency_to: CZK
year: 2009
rate: 26.445
- model: games.exchangerate
pk: 19
fields:
currency_from: CNY
currency_to: CZK
year: 2025
rate: 3.35
- model: games.exchangerate
pk: 20
fields:
currency_from: EUR
currency_to: CZK
year: 2016
rate: 27.033
- model: games.exchangerate
pk: 21
fields:
currency_from: EUR
currency_to: CZK
year: 2025
rate: 25.2021966
- model: games.exchangerate
pk: 22
fields:
currency_from: EUR
currency_to: CZK
year: 2017
rate: 26.33
- model: games.exchangerate
pk: 23
fields:
currency_from: EUR
currency_to: CZK
year: 2000
rate: 36.13
- model: games.exchangerate
pk: 24
fields:
currency_from: USD
currency_to: CZK
year: 2000
rate: 35.979
- model: games.exchangerate
pk: 25
fields:
currency_from: EUR
currency_to: CZK
year: 2001
rate: 35.09
- model: games.exchangerate
pk: 26
fields:
currency_from: USD
currency_to: CZK
year: 2001
rate: 37.813
- model: games.exchangerate
pk: 27
fields:
currency_from: EUR
currency_to: CZK
year: 2002
rate: 31.98
- model: games.exchangerate
pk: 28
fields:
currency_from: USD
currency_to: CZK
year: 2002
rate: 36.259
- model: games.exchangerate
pk: 29
fields:
currency_from: EUR
currency_to: CZK
year: 2003
rate: 31.6
- model: games.exchangerate
pk: 30
fields:
currency_from: USD
currency_to: CZK
year: 2003
rate: 30.141
- model: games.exchangerate
pk: 31
fields:
currency_from: EUR
currency_to: CZK
year: 2004
rate: 32.405
- model: games.exchangerate
pk: 32
fields:
currency_from: USD
currency_to: CZK
year: 2004
rate: 25.654
- model: games.exchangerate
pk: 33
fields:
currency_from: EUR
currency_to: CZK
year: 2005
rate: 30.465
- model: games.exchangerate
pk: 34
fields:
currency_from: USD
currency_to: CZK
year: 2005
rate: 22.365
- model: games.exchangerate
pk: 35
fields:
currency_from: EUR
currency_to: CZK
year: 2006
rate: 29.005
- model: games.exchangerate
pk: 36
fields:
currency_from: USD
currency_to: CZK
year: 2006
rate: 24.588
- model: games.exchangerate
pk: 37
fields:
currency_from: CNY
currency_to: CZK
year: 2006
rate: 3.047
- model: games.exchangerate
pk: 38
fields:
currency_from: EUR
currency_to: CZK
year: 2007
rate: 27.495
- model: games.exchangerate
pk: 39
fields:
currency_from: USD
currency_to: CZK
year: 2007
rate: 20.876
- model: games.exchangerate
pk: 40
fields:
currency_from: CNY
currency_to: CZK
year: 2007
rate: 2.674
- model: games.exchangerate
pk: 41
fields:
currency_from: EUR
currency_to: CZK
year: 2008
rate: 26.62
- model: games.exchangerate
pk: 42
fields:
currency_from: USD
currency_to: CZK
year: 2008
rate: 18.078
- model: games.exchangerate
pk: 43
fields:
currency_from: CNY
currency_to: CZK
year: 2008
rate: 2.475
- model: games.exchangerate
pk: 44
fields:
currency_from: USD
currency_to: CZK
year: 2009
rate: 19.346
- model: games.exchangerate
pk: 45
fields:
currency_from: CNY
currency_to: CZK
year: 2009
rate: 2.836
- model: games.exchangerate
pk: 46
fields:
currency_from: USD
currency_to: CZK
year: 2010
rate: 18.368
- model: games.exchangerate
pk: 47
fields:
currency_from: CNY
currency_to: CZK
year: 2010
rate: 2.691
- model: games.exchangerate
pk: 48
fields:
currency_from: EUR
currency_to: CZK
year: 2011
rate: 25.06
- model: games.exchangerate
pk: 49
fields:
currency_from: USD
currency_to: CZK
year: 2011
rate: 18.751
- model: games.exchangerate
pk: 50
fields:
currency_from: CNY
currency_to: CZK
year: 2011
rate: 2.845
- model: games.exchangerate
pk: 51
fields:
currency_from: USD
currency_to: CZK
year: 2012
rate: 19.94
- model: games.exchangerate
pk: 52
fields:
currency_from: CNY
currency_to: CZK
year: 2012
rate: 3.168
- model: games.exchangerate
pk: 53
fields:
currency_from: EUR
currency_to: CZK
year: 2013
rate: 25.14
- model: games.exchangerate
pk: 54
fields:
currency_from: CNY
currency_to: CZK
year: 2013
rate: 3.059
- model: games.exchangerate
pk: 55
fields:
currency_from: USD
currency_to: CZK
year: 2014
rate: 19.894
- model: games.exchangerate
pk: 56
fields:
currency_from: CNY
currency_to: CZK
year: 2014
rate: 3.286
- model: games.exchangerate
pk: 57
fields:
currency_from: EUR
currency_to: CZK
year: 2015
rate: 27.725
- model: games.exchangerate
pk: 58
fields:
currency_from: USD
currency_to: CZK
year: 2015
rate: 22.834
- model: games.exchangerate
pk: 59
fields:
currency_from: USD
currency_to: CZK
year: 2016
rate: 24.824
- model: games.exchangerate
pk: 60
fields:
currency_from: CNY
currency_to: CZK
year: 2017
rate: 3.693
- model: games.exchangerate
pk: 61
fields:
currency_from: EUR
currency_to: CZK
year: 2018
rate: 25.54
- model: games.exchangerate
pk: 62
fields:
currency_from: USD
currency_to: CZK
year: 2018
rate: 21.291
- model: games.exchangerate
pk: 63
fields:
currency_from: EUR
currency_to: CZK
year: 2019
rate: 25.725
- model: games.exchangerate
pk: 64
fields:
currency_from: EUR
currency_to: CZK
year: 2020
rate: 25.41
- model: games.exchangerate
pk: 65
fields:
currency_from: USD
currency_to: CZK
year: 2020
rate: 22.621
- model: games.exchangerate
pk: 66
fields:
currency_from: EUR
currency_to: CZK
year: 2021
rate: 26.245
- model: games.exchangerate
pk: 67
fields:
currency_from: USD
currency_to: CZK
year: 2021
rate: 21.387
- model: games.exchangerate
pk: 68
fields:
currency_from: CNY
currency_to: CZK
year: 2021
rate: 3.273
- model: games.exchangerate
pk: 69
fields:
currency_from: USD
currency_to: CZK
year: 2022
rate: 21.951
- model: games.exchangerate
pk: 70
fields:
currency_from: CNY
currency_to: CZK
year: 2022
rate: 3.458
- model: games.exchangerate
pk: 71
fields:
currency_from: EUR
currency_to: CZK
year: 2023
rate: 24.115
- model: games.exchangerate
pk: 72
fields:
currency_from: USD
currency_to: CZK
year: 2025
rate: 24.237
+8 -1
View File
@@ -2,27 +2,34 @@
fields:
name: Steam
group: PC
created_at: 2024-01-01T00:00:00Z
- model: games.Platform
fields:
name: Xbox Gamepass
group: PC
created_at: 2024-01-01T00:00:00Z
- model: games.Platform
fields:
name: Epic Games Store
group: PC
created_at: 2024-01-01T00:00:00Z
- model: games.Platform
fields:
name: Playstation 5
group: Playstation
created_at: 2024-01-01T00:00:00Z
- model: games.Platform
fields:
name: Playstation 4
group: Playstation
created_at: 2024-01-01T00:00:00Z
- model: games.Platform
fields:
name: Nintendo Switch
group: Nintendo
created_at: 2024-01-01T00:00:00Z
- model: games.Platform
fields:
name: Nintendo 3DS
group: Nintendo
group: Nintendo
created_at: 2024-01-01T00:00:00Z
+331 -17
View File
@@ -1,16 +1,160 @@
from django import forms
from django.db import transaction
from django.db.models import OuterRef, Subquery
from games.models import Game, Platform, Purchase, Session, Edition, Device
from common.components import (
DEFAULT_PREFETCH,
SearchSelect,
SearchSelectOption,
searchselect_selected,
)
from games.models import (
Device,
Game,
GameStatusChange,
Platform,
PlayEvent,
Purchase,
Session,
)
custom_date_widget = forms.DateInput(attrs={"type": "date"})
custom_datetime_widget = forms.DateTimeInput(
attrs={"type": "datetime-local"}, format="%Y-%m-%d %H:%M"
)
autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"})
class MultipleGameChoiceField(forms.ModelMultipleChoiceField):
def label_from_instance(self, obj) -> str:
return obj.search_label
class SingleGameChoiceField(forms.ModelChoiceField):
def label_from_instance(self, obj) -> str:
return obj.search_label
def _game_options(values) -> list[SearchSelectOption]:
"""Resolve game ids (or instances) to SearchSelectOptions via one pk__in query."""
return [
{
"value": g.id,
"label": g.search_label,
"data": {"platform": g.platform_id or ""},
}
for g in Game.objects.filter(pk__in=values).select_related("platform")
]
def _device_options(values) -> list[SearchSelectOption]:
return [
{"value": d.id, "label": d.name, "data": {}}
for d in Device.objects.filter(pk__in=values)
]
def _platform_options(values) -> list[SearchSelectOption]:
return [
{"value": p.id, "label": p.name, "data": {}}
for p in Platform.objects.filter(pk__in=values)
]
class SearchSelectWidget(forms.Widget):
"""Thin Django adapter that renders a `SearchSelect()` component.
The only place that knows about Django/forms — the component itself stays
reusable outside forms.
"""
def __init__(
self,
*,
search_url,
options_resolver,
multi_select=False,
items_visible=5,
items_scroll=10,
prefetch=DEFAULT_PREFETCH,
always_visible=False,
placeholder="Search…",
attrs=None,
):
super().__init__(attrs)
self.search_url = search_url
self.options_resolver = options_resolver
self.multi_select = multi_select
self.items_visible = items_visible
self.items_scroll = items_scroll
self.prefetch = prefetch
self.always_visible = always_visible
self.placeholder = placeholder
@staticmethod
def _values(value) -> list:
if value is None:
return []
if isinstance(value, (list, tuple)):
return [v for v in value if v not in (None, "")]
return [value] if value not in (None, "") else []
def render(self, name, value, attrs=None, renderer=None):
selected = searchselect_selected(self._values(value), self.options_resolver)
autofocus = bool((attrs or {}).get("autofocus"))
return SearchSelect(
name=name,
selected=selected,
options=None,
search_url=self.search_url,
multi_select=self.multi_select,
items_visible=self.items_visible,
items_scroll=self.items_scroll,
prefetch=self.prefetch,
always_visible=self.always_visible,
placeholder=self.placeholder,
id=(attrs or {}).get("id", ""),
autofocus=autofocus,
)
def value_from_datadict(self, data, files, name):
return data.get(name)
class SearchSelectMultiple(SearchSelectWidget):
def value_from_datadict(self, data, files, name):
if hasattr(data, "getlist"):
return data.getlist(name)
return data.get(name)
class SessionForm(forms.ModelForm):
purchase = forms.ModelChoiceField(
queryset=Purchase.objects.order_by("edition__name")
game = SingleGameChoiceField(
queryset=Game.objects.order_by("sort_name"),
widget=SearchSelectWidget(
search_url="/api/games/search", options_resolver=_game_options
),
)
duration_manual = forms.DurationField(
required=False,
widget=forms.TextInput(
attrs={"x-mask": "99:99:99", "placeholder": "HH:MM:SS", "x-data": ""}
),
label="Manual duration",
)
device = forms.ModelChoiceField(
queryset=Device.objects.order_by("name"),
required=False,
widget=SearchSelectWidget(
search_url="/api/devices/search", options_resolver=_device_options
),
)
mark_as_played = forms.BooleanField(
required=False,
initial={"mark_as_played": True},
label="Set game status to Played if Unplayed",
)
class Meta:
@@ -20,23 +164,89 @@ class SessionForm(forms.ModelForm):
}
model = Session
fields = [
"purchase",
"game",
"timestamp_start",
"timestamp_end",
"duration_manual",
"emulated",
"device",
"note",
"mark_as_played",
]
def save(self, commit=True):
session = super().save(commit=False)
if self.cleaned_data.get("mark_as_played"):
game_instance = session.game
if game_instance.status == "u":
game_instance.status = "p"
if commit:
game_instance.save()
if commit:
session.save()
return session
class EditionChoiceField(forms.ModelChoiceField):
def related_purchase_queryset():
"""GAME purchases annotated with their first game's name.
Rendering the ``related_purchase`` ``<select>`` calls ``str()`` on every
option, and ``Purchase.__str__`` falls back to ``first_game`` — one extra
query per option (700+ on a large library). Annotating the first game's
name via a subquery lets the choice field build labels without those
per-row queries.
"""
first_game_name = Subquery(
Game.objects.filter(purchases=OuterRef("pk")).order_by("id").values("name")[:1]
)
return Purchase.objects.filter(type=Purchase.GAME).annotate(
_first_game_name=first_game_name
)
class RelatedPurchaseChoiceField(forms.ModelChoiceField):
def label_from_instance(self, obj) -> str:
return f"{obj.name} ({obj.platform}, {obj.year_released})"
# Mirrors Purchase.standardized_name but reads the annotated first-game
# name instead of querying first_game per option.
name = obj.name or getattr(obj, "_first_game_name", None)
return name or obj.standardized_name
class PurchaseForm(forms.ModelForm):
edition = EditionChoiceField(queryset=Edition.objects.order_by("name"))
platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["platform"].queryset = Platform.objects.order_by("name")
games = MultipleGameChoiceField(
queryset=Game.objects.order_by("sort_name"),
widget=SearchSelectMultiple(
search_url="/api/games/search",
options_resolver=_game_options,
multi_select=True,
),
)
platform = forms.ModelChoiceField(
queryset=Platform.objects.order_by("name"),
widget=SearchSelectWidget(
search_url="/api/platforms/search", options_resolver=_platform_options
),
)
related_purchase = RelatedPurchaseChoiceField(
queryset=related_purchase_queryset(),
required=False,
)
price_currency = forms.CharField(
widget=forms.TextInput(
attrs={
"x-mask": "aaa",
"placeholder": "CZK",
"x-data": "",
"class": "uppercase",
}
),
label="Currency",
)
class Meta:
widgets = {
@@ -45,38 +255,142 @@ class PurchaseForm(forms.ModelForm):
}
model = Purchase
fields = [
"edition",
"games",
"platform",
"date_purchased",
"date_refunded",
"infinite",
"price",
"price_currency",
"ownership_type",
"type",
"related_purchase",
"name",
]
def clean(self):
cleaned_data = super().clean()
purchase_type = cleaned_data.get("type")
related_purchase = cleaned_data.get("related_purchase")
name = cleaned_data.get("name")
class EditionForm(forms.ModelForm):
game = forms.ModelChoiceField(queryset=Game.objects.order_by("name"))
platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
# Set the type on the instance to use get_type_display()
# This is safe because we're not saving the instance.
self.instance.type = purchase_type
class Meta:
model = Edition
fields = ["game", "name", "platform", "year_released", "wikidata"]
if purchase_type != Purchase.GAME:
type_display = self.instance.get_type_display()
if not related_purchase:
self.add_error(
"related_purchase",
f"{type_display} must have a related purchase.",
)
if not name:
self.add_error("name", f"{type_display} must have a name.")
return cleaned_data
class IncludeNameSelect(forms.Select):
def create_option(self, name, value, *args, **kwargs):
option = super().create_option(name, value, *args, **kwargs)
if value:
option["attrs"]["data-name"] = value.instance.name
option["attrs"]["data-year"] = value.instance.year_released
return option
class GameModelChoiceField(forms.ModelChoiceField):
def label_from_instance(self, obj):
# Use sort_name as the label for the option
return obj.sort_name
class GameForm(forms.ModelForm):
platform = forms.ModelChoiceField(
queryset=Platform.objects.order_by("name"),
required=False,
widget=SearchSelectWidget(
search_url="/api/platforms/search", options_resolver=_platform_options
),
)
class Meta:
model = Game
fields = ["name", "wikidata"]
fields = [
"name",
"sort_name",
"platform",
"original_year_released",
"year_released",
"status",
"mastered",
"wikidata",
]
widgets = {"name": autofocus_input_widget}
class PlatformForm(forms.ModelForm):
class Meta:
model = Platform
fields = ["name", "group"]
fields = [
"name",
"icon",
"group",
]
widgets = {"name": autofocus_input_widget}
class DeviceForm(forms.ModelForm):
class Meta:
model = Device
fields = ["name", "type"]
widgets = {"name": autofocus_input_widget}
class PlayEventForm(forms.ModelForm):
game = SingleGameChoiceField(
queryset=Game.objects.order_by("sort_name"),
widget=SearchSelectWidget(
search_url="/api/games/search",
options_resolver=_game_options,
attrs={"autofocus": "autofocus"},
),
)
mark_as_finished = forms.BooleanField(
required=False,
initial={"mark_as_finished": True},
label="Set game status to Finished",
)
class Meta:
model = PlayEvent
fields = ["game", "started", "ended", "note", "mark_as_finished"]
widgets = {
"started": custom_date_widget,
"ended": custom_date_widget,
}
def save(self, commit=True):
with transaction.atomic():
session = super().save(commit=False)
if self.cleaned_data.get("mark_as_finished"):
game_instance = session.game
game_instance.status = "f"
game_instance.save()
session.save()
return session
class GameStatusChangeForm(forms.ModelForm):
class Meta:
model = GameStatusChange
fields = [
"game",
"old_status",
"new_status",
"timestamp",
]
widgets = {
"timestamp": custom_datetime_widget,
}
+66
View File
@@ -0,0 +1,66 @@
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,24 @@
from datetime import timedelta
from django.core.management.base import BaseCommand
from django.utils.timezone import now
from django_q.models import Schedule
from django_q.tasks import schedule
class Command(BaseCommand):
help = "Manually schedule the next update_converted_prices task"
def handle(self, *args, **kwargs):
if not Schedule.objects.filter(name="Update converted prices").exists():
schedule(
"games.tasks.convert_prices",
name="Update converted prices",
schedule_type=Schedule.MINUTES,
next_run=now() + timedelta(seconds=30),
)
self.stdout.write(
self.style.SUCCESS("Scheduled the update_converted_prices task.")
)
else:
self.stdout.write(self.style.WARNING("Task is already scheduled."))
+181 -12
View File
@@ -1,18 +1,18 @@
# Generated by Django 4.1.4 on 2023-01-02 18:27
# Generated by Django 5.1.5 on 2025-01-29 21:26
import datetime
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="Game",
name="Device",
fields=[
(
"id",
@@ -24,7 +24,22 @@ class Migration(migrations.Migration):
),
),
("name", models.CharField(max_length=255)),
("wikidata", models.CharField(max_length=50)),
(
"type",
models.CharField(
choices=[
("PC", "PC"),
("Console", "Console"),
("Handheld", "Handheld"),
("Mobile", "Mobile"),
("Single-board computer", "Single-board computer"),
("Unknown", "Unknown"),
],
default="Unknown",
max_length=255,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
],
),
migrations.CreateModel(
@@ -40,9 +55,82 @@ class Migration(migrations.Migration):
),
),
("name", models.CharField(max_length=255)),
("group", models.CharField(max_length=255)),
(
"group",
models.CharField(
blank=True, default=None, max_length=255, null=True
),
),
("icon", models.SlugField(blank=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
],
),
migrations.CreateModel(
name="ExchangeRate",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("currency_from", models.CharField(max_length=255)),
("currency_to", models.CharField(max_length=255)),
("year", models.PositiveIntegerField()),
("rate", models.FloatField()),
],
options={
"unique_together": {("currency_from", "currency_to", "year")},
},
),
migrations.CreateModel(
name="Game",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)),
(
"sort_name",
models.CharField(
blank=True, default=None, max_length=255, null=True
),
),
(
"year_released",
models.IntegerField(blank=True, default=None, null=True),
),
(
"wikidata",
models.CharField(
blank=True, default=None, max_length=50, null=True
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"platform",
models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.SET_DEFAULT,
to="games.platform",
),
),
],
options={
"unique_together": {("name", "platform", "year_released")},
},
),
migrations.CreateModel(
name="Purchase",
fields=[
@@ -57,19 +145,75 @@ class Migration(migrations.Migration):
),
("date_purchased", models.DateField()),
("date_refunded", models.DateField(blank=True, null=True)),
("date_finished", models.DateField(blank=True, null=True)),
("date_dropped", models.DateField(blank=True, null=True)),
("infinite", models.BooleanField(default=False)),
("price", models.FloatField(default=0)),
("price_currency", models.CharField(default="USD", max_length=3)),
("converted_price", models.FloatField(null=True)),
("converted_currency", models.CharField(max_length=3, null=True)),
(
"game",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="games.game"
"ownership_type",
models.CharField(
choices=[
("ph", "Physical"),
("di", "Digital"),
("du", "Digital Upgrade"),
("re", "Rented"),
("bo", "Borrowed"),
("tr", "Trial"),
("de", "Demo"),
("pi", "Pirated"),
],
default="di",
max_length=2,
),
),
(
"type",
models.CharField(
choices=[
("game", "Game"),
("dlc", "DLC"),
("season_pass", "Season Pass"),
("battle_pass", "Battle Pass"),
],
default="game",
max_length=255,
),
),
(
"name",
models.CharField(blank=True, default="", max_length=255, null=True),
),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"games",
models.ManyToManyField(
blank=True, related_name="purchases", to="games.game"
),
),
(
"platform",
models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="games.platform",
),
),
(
"related_purchase",
models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="related_purchases",
to="games.purchase",
),
),
],
),
migrations.CreateModel(
@@ -85,17 +229,42 @@ class Migration(migrations.Migration):
),
),
("timestamp_start", models.DateTimeField()),
("timestamp_end", models.DateTimeField()),
("duration_manual", models.DurationField(blank=True, null=True)),
("timestamp_end", models.DateTimeField(blank=True, null=True)),
(
"duration_manual",
models.DurationField(
blank=True, default=datetime.timedelta(0), null=True
),
),
("duration_calculated", models.DurationField(blank=True, null=True)),
("note", models.TextField(blank=True, null=True)),
("emulated", models.BooleanField(default=False)),
("created_at", models.DateTimeField(auto_now_add=True)),
("modified_at", models.DateTimeField(auto_now=True)),
(
"purchase",
"device",
models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.SET_DEFAULT,
to="games.device",
),
),
(
"game",
models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="games.purchase",
related_name="sessions",
to="games.game",
),
),
],
options={
"get_latest_by": "timestamp_start",
},
),
]
@@ -1,22 +0,0 @@
# Generated by Django 4.1.4 on 2023-01-02 18:55
import datetime
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="session",
name="duration_manual",
field=models.DurationField(
blank=True, default=datetime.timedelta(0), null=True
),
),
]
@@ -0,0 +1,17 @@
# Generated by Django 5.1.5 on 2025-01-30 11:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="price_per_game",
field=models.FloatField(null=True),
),
]
@@ -1,23 +0,0 @@
# Generated by Django 4.1.4 on 2023-01-02 23:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0002_alter_session_duration_manual"),
]
operations = [
migrations.AlterField(
model_name="session",
name="duration_manual",
field=models.DurationField(blank=True, null=True),
),
migrations.AlterField(
model_name="session",
name="timestamp_end",
field=models.DateTimeField(blank=True, null=True),
),
]
@@ -0,0 +1,17 @@
# Generated by Django 5.1.5 on 2025-01-30 11:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0002_purchase_price_per_game"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="updated_at",
field=models.DateTimeField(auto_now=True),
),
]
@@ -1,22 +0,0 @@
# Generated by Django 4.1.5 on 2023-01-09 14:49
import datetime
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0003_alter_session_duration_manual_and_more"),
]
operations = [
migrations.AlterField(
model_name="session",
name="duration_manual",
field=models.DurationField(
blank=True, default=datetime.timedelta(0), null=True
),
),
]
@@ -0,0 +1,28 @@
# Generated by Django 5.1.5 on 2025-01-30 11:57
from django.db import migrations, models
from django.db.models import Count
def initialize_num_purchases(apps, schema_editor):
Purchase = apps.get_model("games", "Purchase")
purchases = Purchase.objects.annotate(num_games=Count("games"))
for purchase in purchases:
purchase.num_purchases = purchase.num_games
purchase.save(update_fields=["num_purchases"])
class Migration(migrations.Migration):
dependencies = [
("games", "0003_purchase_updated_at"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="num_purchases",
field=models.IntegerField(default=0),
),
migrations.RunPython(initialize_num_purchases),
]
@@ -1,35 +0,0 @@
# Generated by Django 4.1.5 on 2023-01-09 17:43
from datetime import timedelta
from django.db import migrations
def set_duration_calculated_none_to_zero(apps, schema_editor):
Session = apps.get_model("games", "Session")
for session in Session.objects.all():
if session.duration_calculated == None:
session.duration_calculated = timedelta(0)
session.save()
def revert_set_duration_calculated_none_to_zero(apps, schema_editor):
Session = apps.get_model("games", "Session")
for session in Session.objects.all():
if session.duration_calculated == timedelta(0):
session.duration_calculated = None
session.save()
class Migration(migrations.Migration):
dependencies = [
("games", "0004_alter_session_duration_manual"),
]
operations = [
migrations.RunPython(
set_duration_calculated_none_to_zero,
revert_set_duration_calculated_none_to_zero,
)
]
@@ -0,0 +1,38 @@
# Generated by Django 5.1.5 on 2025-02-01 19:18
from django.db import migrations, models
def set_finished_status(apps, schema_editor):
Game = apps.get_model("games", "Game")
Game.objects.filter(purchases__date_finished__isnull=False).update(status="f")
class Migration(migrations.Migration):
dependencies = [
("games", "0004_purchase_num_purchases"),
]
operations = [
migrations.AddField(
model_name="game",
name="mastered",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="game",
name="status",
field=models.CharField(
choices=[
("u", "Unplayed"),
("p", "Played"),
("f", "Finished"),
("r", "Retired"),
("a", "Abandoned"),
],
default="u",
max_length=1,
),
),
migrations.RunPython(set_finished_status),
]
@@ -0,0 +1,70 @@
# 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=""),
),
]
@@ -1,35 +0,0 @@
# Generated by Django 4.1.5 on 2023-01-09 18:04
from datetime import timedelta
from django.db import migrations
def set_duration_manual_none_to_zero(apps, schema_editor):
Session = apps.get_model("games", "Session")
for session in Session.objects.all():
if session.duration_manual == None:
session.duration_manual = timedelta(0)
session.save()
def revert_set_duration_manual_none_to_zero(apps, schema_editor):
Session = apps.get_model("games", "Session")
for session in Session.objects.all():
if session.duration_manual == timedelta(0):
session.duration_manual = None
session.save()
class Migration(migrations.Migration):
dependencies = [
("games", "0005_auto_20230109_1843"),
]
operations = [
migrations.RunPython(
set_duration_manual_none_to_zero,
revert_set_duration_manual_none_to_zero,
)
]
@@ -1,35 +0,0 @@
# Generated by Django 4.1.5 on 2023-01-19 18:30
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("games", "0006_auto_20230109_1904"),
]
operations = [
migrations.AlterField(
model_name="purchase",
name="game",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="games.game"
),
),
migrations.AlterField(
model_name="purchase",
name="platform",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="games.platform"
),
),
migrations.AlterField(
model_name="session",
name="purchase",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="games.purchase"
),
),
]
+17
View File
@@ -0,0 +1,17 @@
# 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),
),
]
-41
View File
@@ -1,41 +0,0 @@
# Generated by Django 4.1.5 on 2023-02-18 16:29
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("games", "0007_alter_purchase_game_alter_purchase_platform_and_more"),
]
operations = [
migrations.CreateModel(
name="Edition",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)),
(
"game",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="games.game"
),
),
(
"platform",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="games.platform"
),
),
],
),
]
@@ -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),
]
-34
View File
@@ -1,34 +0,0 @@
# Generated by Django 4.1.5 on 2023-02-18 18:51
from django.db import migrations
def create_edition_of_game(apps, schema_editor):
Game = apps.get_model("games", "Game")
Edition = apps.get_model("games", "Edition")
Platform = apps.get_model("games", "Platform")
first_platform = Platform.objects.first()
all_games = Game.objects.all()
all_editions = Edition.objects.all()
for game in all_games:
existing_edition = None
try:
existing_edition = all_editions.objects.get(game=game.id)
except:
pass
if existing_edition == None:
edition = Edition()
edition.id = game.id
edition.game = game
edition.name = game.name
edition.platform = first_platform
edition.save()
class Migration(migrations.Migration):
dependencies = [
("games", "0008_edition"),
]
operations = [migrations.RunPython(create_edition_of_game)]
@@ -0,0 +1,20 @@
# 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",
),
]
@@ -1,21 +0,0 @@
# Generated by Django 4.1.5 on 2023-02-18 19:06
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("games", "0009_create_editions"),
]
operations = [
migrations.AlterField(
model_name="purchase",
name="game",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="games.edition"
),
),
]
@@ -0,0 +1,16 @@
# 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,29 @@
# 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(),
),
),
]
@@ -1,18 +0,0 @@
# Generated by Django 4.1.5 on 2023-02-18 19:18
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("games", "0010_alter_purchase_game"),
]
operations = [
migrations.RenameField(
model_name="purchase",
old_name="game",
new_name="edition",
),
]
@@ -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(),
),
),
]
@@ -1,23 +0,0 @@
# Generated by Django 4.1.5 on 2023-02-18 19:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0011_rename_game_purchase_edition"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="price",
field=models.IntegerField(default=0),
),
migrations.AddField(
model_name="purchase",
name="price_currency",
field=models.CharField(default="USD", max_length=3),
),
]
+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),
]
@@ -1,31 +0,0 @@
# Generated by Django 4.1.5 on 2023-02-18 19:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0012_purchase_price_purchase_price_currency"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="ownership_type",
field=models.CharField(
choices=[
("ph", "Physical"),
("di", "Digital"),
("du", "Digital Upgrade"),
("re", "Rented"),
("bo", "Borrowed"),
("tr", "Trial"),
("de", "Demo"),
("pi", "Pirated"),
],
default="di",
max_length=2,
),
),
]
@@ -1,52 +0,0 @@
# Generated by Django 4.1.5 on 2023-02-18 19:59
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("games", "0013_purchase_ownership_type"),
]
operations = [
migrations.CreateModel(
name="Device",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)),
(
"type",
models.CharField(
choices=[
("pc", "PC"),
("co", "Console"),
("ha", "Handheld"),
("mo", "Mobile"),
("sbc", "Single-board computer"),
],
default="pc",
max_length=3,
),
),
],
),
migrations.AddField(
model_name="session",
name="device",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="games.device",
),
),
]
@@ -0,0 +1,24 @@
# 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,43 @@
# 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"),
),
]
@@ -1,23 +0,0 @@
# Generated by Django 4.1.5 on 2023-02-20 14:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0014_device_session_device"),
]
operations = [
migrations.AddField(
model_name="edition",
name="wikidata",
field=models.CharField(blank=True, default=None, max_length=50, null=True),
),
migrations.AddField(
model_name="edition",
name="year_released",
field=models.IntegerField(default=2023),
),
]
@@ -0,0 +1,21 @@
# Generated by Django 6.0.1 on 2026-05-12 11:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0015_alter_purchase_date_purchased_and_more"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="needs_price_update",
field=models.BooleanField(db_index=True, default=True),
),
migrations.RunSQL(
"UPDATE games_purchase SET needs_price_update = FALSE WHERE converted_price IS NOT NULL AND converted_currency != ''",
reverse_sql="UPDATE games_purchase SET needs_price_update = TRUE WHERE converted_price IS NOT NULL AND converted_currency != ''",
),
]
@@ -0,0 +1,48 @@
# Generated by Django 6.0.1 on 2026-06-06 07:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0016_add_needs_price_update"),
]
operations = [
migrations.CreateModel(
name="FilterPreset",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)),
(
"mode",
models.CharField(
choices=[
("games", "Games"),
("sessions", "Sessions"),
("purchases", "Purchases"),
("playevents", "Play Events"),
],
default="games",
max_length=50,
),
),
("find_filter", models.JSONField(blank=True, default=dict)),
("object_filter", models.JSONField(blank=True, default=dict)),
("ui_options", models.JSONField(blank=True, default=dict)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={
"ordering": ["name"],
},
),
]
@@ -0,0 +1,18 @@
# Generated by Django 6.0.1 on 2026-06-06 20:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0017_add_filter_preset'),
]
operations = [
migrations.AlterField(
model_name='session',
name='timestamp_start',
field=models.DateTimeField(db_index=True, verbose_name='Start'),
),
]
+438 -66
View File
@@ -1,31 +1,145 @@
from datetime import datetime, timedelta
from typing import Any
from zoneinfo import ZoneInfo
import logging
from datetime import timedelta
import requests
from django.core.exceptions import ValidationError
from django.db import models
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.utils import timezone
from common.time import format_duration
from django.conf import settings
from django.db import models
from django.db.models import F, Manager, Sum
logger = logging.getLogger("games")
class Game(models.Model):
class Meta:
unique_together = [["name", "platform", "year_released"]]
name = models.CharField(max_length=255)
wikidata = models.CharField(max_length=50)
sort_name = models.CharField(max_length=255, blank=True, default="")
year_released = models.IntegerField(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", 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)
updated_at = models.DateTimeField(auto_now=True)
class Status(models.TextChoices):
UNPLAYED = (
"u",
"Unplayed",
)
PLAYED = (
"p",
"Played",
)
FINISHED = (
"f",
"Finished",
)
RETIRED = (
"r",
"Retired",
)
ABANDONED = (
"a",
"Abandoned",
)
status = models.CharField(max_length=1, choices=Status, default=Status.UNPLAYED)
mastered = models.BooleanField(default=False)
session_average: float | int | timedelta | None
session_count: int | None
def __str__(self):
return self.name
@property
def search_label(self) -> str:
return f"{self.sort_name} ({self.platform}, {self.year_released})"
class Edition(models.Model):
game = models.ForeignKey("Game", on_delete=models.CASCADE)
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):
if self.platform is None:
self.platform = get_sentinel_platform()
super().save(*args, **kwargs)
def get_sentinel_platform():
return Platform.objects.get_or_create(
name="Unspecified", icon="unspecified", group="Unspecified"
)[0]
class Platform(models.Model):
name = models.CharField(max_length=255)
platform = models.ForeignKey("Platform", on_delete=models.CASCADE)
year_released = models.IntegerField(default=datetime.today().year)
wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
group = models.CharField(max_length=255, blank=True, default="")
icon = models.SlugField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.name
def save(self, *args, **kwargs):
if not self.icon:
self.icon = slugify(self.name)
super().save(*args, **kwargs)
class PurchaseQueryset(models.QuerySet):
def refunded(self):
return self.filter(date_refunded__isnull=False)
def not_refunded(self):
return self.filter(date_refunded__isnull=True)
def games_only(self):
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):
PHYSICAL = "ph"
@@ -46,101 +160,359 @@ class Purchase(models.Model):
(DEMO, "Demo"),
(PIRATED, "Pirated"),
]
GAME = "game"
DLC = "dlc"
SEASONPASS = "season_pass"
BATTLEPASS = "battle_pass"
TYPES = [
(GAME, "Game"),
(DLC, "DLC"),
(SEASONPASS, "Season Pass"),
(BATTLEPASS, "Battle Pass"),
]
edition = models.ForeignKey("Edition", on_delete=models.CASCADE)
platform = models.ForeignKey("Platform", on_delete=models.CASCADE)
date_purchased = models.DateField()
date_refunded = models.DateField(blank=True, null=True)
price = models.IntegerField(default=0)
objects = PurchaseQueryset().as_manager()
games = models.ManyToManyField(Game, related_name="purchases")
platform = models.ForeignKey(
Platform, on_delete=models.CASCADE, default=None, null=True, blank=True
)
date_purchased = models.DateField(verbose_name="Purchased")
date_refunded = models.DateField(blank=True, null=True, verbose_name="Refunded")
infinite = models.BooleanField(default=False)
price = models.FloatField(default=0)
price_currency = models.CharField(max_length=3, default="USD")
converted_price = models.FloatField(null=True)
converted_currency = models.CharField(max_length=3, blank=True, default="")
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)
ownership_type = models.CharField(
max_length=2, choices=OWNERSHIP_TYPES, default=DIGITAL
)
type = models.CharField(max_length=255, choices=TYPES, default=GAME)
name = models.CharField(max_length=255, blank=True, default="")
related_purchase = models.ForeignKey(
"self",
on_delete=models.SET_NULL,
default=None,
null=True,
related_name="related_purchases",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@property
def standardized_price(self):
return (
f"{floatformat(self.converted_price, 0)} {self.converted_currency}"
if self.converted_price
else None
)
@property
def has_one_item(self):
return self.games.count() == 1
@property
def standardized_name(self):
return self.name or self.first_game.name
@property
def first_game(self):
return self.games.first()
def __str__(self):
platform_info = self.platform
if self.platform != self.edition.platform:
platform_info = f"{self.edition.platform} version on {self.platform}"
return f"{self.edition} ({platform_info}, {self.edition.year_released}, {self.get_ownership_type_display()})"
return self.standardized_name
@property
def full_name(self):
additional_info = [
str(item)
for item in [
f"{self.num_purchases} game{pluralize(self.num_purchases)}",
self.date_purchased,
self.standardized_price,
]
if item
]
return f"{self.standardized_name} ({', '.join(additional_info)})"
class Platform(models.Model):
name = models.CharField(max_length=255)
group = models.CharField(max_length=255)
def is_game(self):
return self.type == self.GAME
def __str__(self):
return self.name
def refund(self):
self.date_refunded = timezone.now()
self.save()
def save(self, *args, **kwargs):
if self.type != Purchase.GAME and not self.related_purchase:
raise ValidationError(
f"{self.get_type_display()} must have a related purchase."
)
super().save(*args, **kwargs)
class SessionQuerySet(models.QuerySet):
def total_duration(self):
def total_duration_formatted(self):
return format_duration(self.total_duration_unformatted())
def total_duration_unformatted(self):
result = self.aggregate(
duration=Sum(F("duration_calculated") + F("duration_manual"))
)
return format_duration(result["duration"])
return result["duration"]
def calculated_duration_formatted(self):
return format_duration(self.calculated_duration_unformatted())
def calculated_duration_unformatted(self):
result = self.aggregate(duration=Sum(F("duration_calculated")))
return result["duration"]
def without_manual(self):
return self.exclude(duration_calculated__iexact=0)
def only_manual(self):
return self.filter(duration_calculated__iexact=0)
class Session(models.Model):
purchase = models.ForeignKey("Purchase", on_delete=models.CASCADE)
timestamp_start = models.DateTimeField()
timestamp_end = models.DateTimeField(blank=True, null=True)
duration_manual = models.DurationField(blank=True, null=True, default=timedelta(0))
duration_calculated = models.DurationField(blank=True, null=True)
device = models.ForeignKey("Device", on_delete=models.CASCADE, null=True)
note = models.TextField(blank=True, null=True)
class Meta:
get_latest_by = "timestamp_start"
game = models.ForeignKey(
Game,
on_delete=models.CASCADE,
null=True,
default=None,
related_name="sessions",
)
timestamp_start = models.DateTimeField(verbose_name="Start", db_index=True)
timestamp_end = models.DateTimeField(blank=True, null=True, verbose_name="End")
duration_manual = models.DurationField(
blank=True, null=True, default=timedelta(0), verbose_name="Manual duration"
)
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",
on_delete=models.SET_DEFAULT,
null=True,
blank=True,
default=None,
)
note = models.TextField(blank=True, default="")
emulated = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
modified_at = models.DateTimeField(auto_now=True)
objects = SessionQuerySet.as_manager()
def __str__(self):
mark = ", manual" if self.duration_manual != None else ""
return f"{str(self.purchase)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})"
mark = "*" if self.is_manual() else ""
return f"{str(self.game)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})"
def finish_now(self):
self.timestamp_end = datetime.now(ZoneInfo(settings.TIME_ZONE))
def start_now():
self.timestamp_start = datetime.now(ZoneInfo(settings.TIME_ZONE))
def duration_seconds(self) -> timedelta:
manual = timedelta(0)
calculated = timedelta(0)
if not self.duration_manual in (None, 0, timedelta(0)):
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())
self.timestamp_end = timezone.now()
def duration_formatted(self) -> str:
result = format_duration(self.duration_seconds(), "%H:%m")
result = format_duration(self.duration_total, "%02.1H")
return result
@property
def duration_sum(self) -> str:
return Session.objects.all().total_duration()
def duration_formatted_with_mark(self) -> str:
mark = "*" if self.is_manual() else ""
return f"{self.duration_formatted()}{mark}"
def save(self, *args, **kwargs):
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)
def is_manual(self) -> bool:
return not self.duration_manual == timedelta(0)
def save(self, *args, **kwargs) -> None:
if not isinstance(self.duration_manual, timedelta):
self.duration_manual = timedelta(0)
if not self.device:
default_device, _ = Device.objects.get_or_create(
type=Device.UNKNOWN, defaults={"name": "Unknown"}
)
self.device = default_device
super(Session, self).save(*args, **kwargs)
class Device(models.Model):
PC = "pc"
CONSOLE = "co"
HANDHELD = "ha"
MOBILE = "mo"
SBC = "sbc"
PC = "PC"
CONSOLE = "Console"
HANDHELD = "Handheld"
MOBILE = "Mobile"
SBC = "Single-board computer"
UNKNOWN = "Unknown"
DEVICE_TYPES = [
(PC, "PC"),
(CONSOLE, "Console"),
(HANDHELD, "Handheld"),
(MOBILE, "Mobile"),
(SBC, "Single-board computer"),
(UNKNOWN, "Unknown"),
]
name = models.CharField(max_length=255)
type = models.CharField(max_length=3, choices=DEVICE_TYPES, default=PC)
type = models.CharField(max_length=255, choices=DEVICE_TYPES, default=UNKNOWN)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"{self.name} ({self.get_type_display()})"
return f"{self.name} ({self.type})"
class ExchangeRate(models.Model):
currency_from = models.CharField(max_length=255)
currency_to = models.CharField(max_length=255)
year = models.PositiveIntegerField()
rate = models.FloatField()
class Meta:
unique_together = ("currency_from", "currency_to", "year")
def __str__(self):
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"]
class FilterPreset(models.Model):
"""Saved filter configuration, following Stash's SavedFilter pattern.
Separates find_filter (sort/pagination), object_filter (criteria JSON),
and ui_options (presentation state) so they can evolve independently.
"""
class Meta:
ordering = ["name"]
MODE_CHOICES = [
("games", "Games"),
("sessions", "Sessions"),
("purchases", "Purchases"),
("playevents", "Play Events"),
("devices", "Devices"),
("platforms", "Platforms"),
]
name = models.CharField(max_length=255)
mode = models.CharField(max_length=50, choices=MODE_CHOICES, default="games")
find_filter = models.JSONField(default=dict, blank=True)
object_filter = models.JSONField(default=dict, blank=True)
ui_options = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f"{self.name} ({self.get_mode_display()})"
+112
View File
@@ -0,0 +1,112 @@
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.utils.timezone import now
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)
def update_num_purchases(sender, instance, action, reverse, **kwargs):
if not reverse and action.startswith("post_"):
instance.num_purchases = instance.games.count()
instance.updated_at = now()
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")
+5285 -5329
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 787 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 504 B

+24
View File
@@ -0,0 +1,24 @@
import { syncSelectInputUntilChanged } from "./utils.js";
let syncData = [
{
source: "#id_game",
source_value: "dataset.name",
target: "#id_name",
target_value: "value",
},
{
source: "#id_game",
source_value: "textContent",
target: "#id_sort_name",
target_value: "value",
},
{
source: "#id_game",
source_value: "dataset.year",
target: "#id_year_released",
target_value: "value",
},
];
syncSelectInputUntilChanged(syncData, "form");
+12
View File
@@ -0,0 +1,12 @@
import { syncSelectInputUntilChanged } from "./utils.js";
let syncData = [
{
source: "#id_name",
source_value: "value",
target: "#id_sort_name",
target_value: "value",
},
];
syncSelectInputUntilChanged(syncData, "form");
+45
View File
@@ -0,0 +1,45 @@
import { getEl, disableElementsWhenTrue } from "./utils.js";
const RELATED_PURCHASE_URL = "/tracker/purchase/related-purchase-by-game";
// The games field is now a SearchSelect widget (a <div>, not a <select>), so we
// react to its custom "search-select:change" event instead of syncing a select.
document.addEventListener("search-select:change", (event) => {
if (event.detail.name !== "games") return;
// (a) Auto-fill platform from the clicked option's data-platform.
const last = event.detail.last;
const platformId = last && last.data ? last.data.platform : "";
if (platformId) {
const platformEl = getEl("#id_platform");
if (platformEl) platformEl.value = platformId;
}
// (b) Refresh #id_related_purchase for the currently selected games.
const query = event.detail.values
.map((value) => "games=" + encodeURIComponent(value))
.join("&");
fetch(RELATED_PURCHASE_URL + "?" + query, { credentials: "same-origin" })
.then((response) => {
if (response.status === 204) return null;
return response.text();
})
.then((html) => {
if (html === null) return;
const target = getEl("#id_related_purchase");
if (target) target.outerHTML = html;
});
});
function setupElementHandlers() {
disableElementsWhenTrue("#id_type", "game", [
"#id_name",
"#id_related_purchase",
]);
}
document.addEventListener("DOMContentLoaded", setupElementHandlers);
document.addEventListener("htmx:afterSwap", setupElementHandlers);
getEl("#id_type").addEventListener("change", () => {
setupElementHandlers();
});
+23
View File
@@ -0,0 +1,23 @@
import { toISOUTCString } from "./utils.js";
for (let button of document.querySelectorAll("[data-target]")) {
let target = button.getAttribute("data-target");
let type = button.getAttribute("data-type");
let targetElement = document.querySelector(`#id_${target}`);
button.addEventListener("click", (event) => {
event.preventDefault();
if (type == "now") {
targetElement.value = toISOUTCString(new Date());
} else if (type == "copy") {
const oppositeName =
targetElement.name == "timestamp_start"
? "timestamp_end"
: "timestamp_start";
document.querySelector(`[name='${oppositeName}']`).value =
targetElement.value;
} else if (type == "toggle") {
if (targetElement.type == "datetime-local") targetElement.type = "text";
else targetElement.type = "datetime-local";
}
});
}
+354
View File
@@ -0,0 +1,354 @@
/**
* Filter bar — vanilla JavaScript implementation.
*
* Handles form submission, preset loading/saving, and preset list rendering.
* No HTMX — plain fetch() and window.location for all interactions.
*/
(function () {
"use strict";
/** Build a criterion object from a value and optional second value. */
function criterion(value, value2, modifier) {
var c = { value: value, modifier: modifier };
if (value2 !== null && value2 !== undefined && value2 !== "") {
c.value2 = value2;
}
return c;
}
/** Read a <select> element's value, or "" if not found. */
function selectValue(form, name) {
var el = form.querySelector('[name="' + name + '"]');
return el ? el.value : "";
}
/** Read an <input type="number"> value, or "" if not found. */
function numberValue(form, name) {
var el = form.querySelector('[name="' + name + '"]');
if (!el || el.value === "") return "";
var val = parseFloat(el.value);
return isNaN(val) ? "" : val;
}
/** Read all checked checkboxes with a given name, returning an array of ints. */
function checkedValues(form, name) {
var els = form.querySelectorAll('[name="' + name + '"]:checked');
var ids = [];
els.forEach(function (el) {
var v = parseInt(el.value, 10);
if (!isNaN(v)) ids.push(v);
});
return ids;
}
/**
* Build the filter JSON object from form field values.
* Returns a plain object ready for JSON.stringify.
*/
function buildFilterJSON(form) {
var filter = {};
var yearMin = numberValue(form, "filter-year-min");
var yearMax = numberValue(form, "filter-year-max");
var playMin = numberValue(form, "filter-playtime-min");
var playMax = numberValue(form, "filter-playtime-max");
var mastered = form.querySelector('[name="filter-mastered"]');
// ── Search field ──
var searchInput = form.querySelector('[name="filter-search"]');
if (searchInput && searchInput.value.trim()) {
filter.search = { value: searchInput.value.trim(), modifier: "INCLUDES" };
}
// ── FilterSelect widgets (data-search-select-mode="filter") ──
// readSearchSelect serialises each into data-included/data-excluded/data-modifier.
readSearchSelect(form);
var widgets = form.querySelectorAll('[data-search-select][data-search-select-mode="filter"]');
widgets.forEach(function (widget) {
var field = widget.getAttribute("data-name");
var included = parseJSONAttr(widget, "data-included");
var excluded = parseJSONAttr(widget, "data-excluded");
// Two orthogonal axes: a presence modifier (NOT_NULL/IS_NULL) from the
// pinned (Any)/(None) pseudo-options clears the value set and has no
// values; the non-presence modifier (INCLUDES_ALL/INCLUDES_ONLY) governs
// how the include set matches. When neither is set the implicit default
// is INCLUDES ("any"). Must match Python _PRESENCE_MODIFIERS.
var modifier = widget.getAttribute("data-modifier");
var IS_PRESENCE = modifier === "NOT_NULL" || modifier === "IS_NULL";
if (IS_PRESENCE) {
filter[field] = { modifier: modifier };
} else if (included.length > 0 || excluded.length > 0) {
// All filter pills carry {id, label}; store them as-is so the filter
// URL and saved presets are self-describing (Stash-style).
filter[field] = {
value: included.map(function (item) { return {id: item.id, label: item.label}; }),
excludes: excluded.map(function (item) { return {id: item.id, label: item.label}; }),
modifier: modifier || "INCLUDES",
};
}
});
// ── Session-specific fields ──
var pageIsSessions =
!!form.querySelector('[data-search-select][data-search-select-mode="filter"][data-name="game"]');
// Emulated checkbox (sessions page)
var emulated = form.querySelector('[name="filter-emulated"]');
if (emulated && emulated.checked) {
filter.emulated = criterion(true, null, "EQUALS");
}
// Active checkbox (sessions page)
var active = form.querySelector('[name="filter-active"]');
if (active && active.checked) {
filter.is_active = criterion(true, null, "EQUALS");
}
if (yearMin !== "" && yearMax !== "") {
filter.year_released = criterion(yearMin, yearMax, "BETWEEN");
} else if (yearMin !== "") {
filter.year_released = criterion(yearMin, null, "GREATER_THAN");
} else if (yearMax !== "") {
filter.year_released = criterion(yearMax, null, "LESS_THAN");
}
if (playMin !== "" || playMax !== "") {
var pMin = playMin !== "" ? Math.round(playMin * 60) : 0;
var pMax = playMax !== "" ? Math.round(playMax * 60) : 0;
// Skip if both are 0 — means slider is at default (no real filter)
if (pMin === 0 && pMax === 0) {
// don't add filter
} else {
var durKey = pageIsSessions ? "duration_minutes" : "playtime_minutes";
if (playMin !== "" && playMax !== "") {
filter[durKey] = criterion(pMin, pMax, "BETWEEN");
} else if (playMin !== "") {
filter[durKey] = criterion(pMin, null, "GREATER_THAN");
} else if (playMax !== "") {
filter[durKey] = criterion(pMax, null, "LESS_THAN");
}
}
}
// ── Purchase-specific: num_purchases ──
var numGamesMin = numberValue(form, "filter-num-purchases-min");
var numGamesMax = numberValue(form, "filter-num-purchases-max");
if (numGamesMin !== "" && numGamesMax !== "") {
filter.num_purchases = criterion(parseInt(numGamesMin, 10), parseInt(numGamesMax, 10), "BETWEEN");
} else if (numGamesMin !== "") {
filter.num_purchases = criterion(parseInt(numGamesMin, 10), null, "GREATER_THAN");
} else if (numGamesMax !== "") {
filter.num_purchases = criterion(parseInt(numGamesMax, 10), null, "LESS_THAN");
}
if (mastered && mastered.checked) {
filter.mastered = criterion(true, null, "EQUALS");
}
return filter;
}
/** Extract the current page's base URL (without query string). */
function baseUrl() {
return window.location.pathname;
}
/** Safely parse a JSON attribute, returning empty array on failure. */
function parseJSONAttr(el, attr) {
var raw = el.getAttribute(attr);
if (!raw) return [];
try { return JSON.parse(raw); } catch (e) { return []; }
}
/**
* Called on filter bar form submit.
* Serializes filter fields, navigates to URL with filter param.
*/
window.applyFilterBar = function (event) {
event.preventDefault();
var form = event.target;
var filter = buildFilterJSON(form);
var filterStr = JSON.stringify(filter);
var url = baseUrl();
if (filterStr && filterStr !== "{}") {
url += "?filter=" + encodeURIComponent(filterStr);
}
window.location.href = url;
return false;
};
/**
* Clear all filter fields and reload the unfiltered view.
*/
window.clearFilterBar = function (formId, filterInputId) {
var form = document.getElementById(formId);
if (!form) return;
form.reset();
window.location.href = baseUrl();
};
// ── Presets ─────────────────────────────────────────────────────────────
/** Fetch and render the preset list. */
function loadPresets() {
var dropdown = document.getElementById("preset-dropdown");
if (!dropdown) return;
var url = dropdown.getAttribute("data-preset-list-url");
if (!url) return;
var mode = "games";
if (window.location.pathname.indexOf("session") !== -1) mode = "sessions";
else if (window.location.pathname.indexOf("purchase") !== -1) mode = "purchases";
fetch(url + "?mode=" + mode, { credentials: "same-origin" })
.then(function (r) {
if (!r.ok) throw new Error("Failed to load presets");
return r.text();
})
.then(function (html) {
dropdown.innerHTML = html;
// Re-attach delete handlers (list_presets view uses onclick attributes,
// but we also need to wire up inline handlers if they use data attributes)
setupPresetDeleteHandlers(dropdown);
})
.catch(function (err) {
dropdown.innerHTML =
'<span class="text-sm text-body italic">Presets unavailable</span>';
console.error(err);
});
}
/** Wire up click handlers for preset delete buttons. */
function setupPresetDeleteHandlers(container) {
var deleteLinks = container.querySelectorAll('[data-delete-preset]');
deleteLinks.forEach(function (link) {
link.addEventListener("click", function (e) {
e.preventDefault();
var presetId = link.getAttribute("data-delete-preset");
var deleteUrl = link.getAttribute("href");
if (!deleteUrl) return;
if (!confirm("Delete this preset?")) return;
fetch(deleteUrl, {
method: "POST",
credentials: "same-origin",
headers: { "X-CSRFToken": getCsrfToken() },
})
.then(function () {
// Remove the parent <li>
var li = link.closest("li");
if (li) li.remove();
// If no items left, show empty message
var ul = container.querySelector("ul");
if (ul && ul.querySelectorAll("li").length === 0) {
ul.innerHTML =
'<li class="px-4 py-2 text-sm text-body italic">No saved presets</li>';
}
})
.catch(function (err) {
console.error("Delete failed:", err);
});
});
});
}
/** Show the preset name input field and the confirm button. */
window.showPresetNameInput = function () {
var input = document.getElementById("preset-name-input");
var saveBtn = document.getElementById("save-preset-btn");
var confirmBtn = document.getElementById("confirm-save-preset-btn");
if (input) input.classList.remove("hidden");
if (saveBtn) saveBtn.classList.add("hidden");
if (confirmBtn) confirmBtn.classList.remove("hidden");
if (input) input.focus();
};
/** Save the current filter as a named preset. */
window.savePreset = function (formId, filterInputId, saveUrl) {
var input = document.getElementById("preset-name-input");
var name = input ? input.value.trim() : "";
if (!name) {
if (input) input.classList.add("border-red-500");
return;
}
var filterInput = document.getElementById(filterInputId);
var form = document.getElementById(formId);
var filterObj = form ? buildFilterJSON(form) : {};
var body = new URLSearchParams();
body.append("name", name);
var mode = "games";
if (window.location.pathname.indexOf("session") !== -1) mode = "sessions";
else if (window.location.pathname.indexOf("purchase") !== -1) mode = "purchases";
body.append("mode", mode);
body.append("filter", JSON.stringify(filterObj));
fetch(saveUrl, {
method: "POST",
credentials: "same-origin",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"X-CSRFToken": getCsrfToken(),
},
body: body.toString(),
})
.then(function (r) {
if (!r.ok) throw new Error("Save failed");
// Reset UI
if (input) {
input.value = "";
input.classList.add("hidden");
input.classList.remove("border-red-500");
}
var saveBtn = document.getElementById("save-preset-btn");
var confirmBtn = document.getElementById("confirm-save-preset-btn");
if (saveBtn) saveBtn.classList.remove("hidden");
if (confirmBtn) confirmBtn.classList.add("hidden");
// Refresh the preset list
loadPresets();
})
.catch(function (err) {
console.error("Failed to save preset:", err);
});
};
/** Extract CSRF token from the page. */
function getCsrfToken() {
var cookie = document.cookie
.split("; ")
.find(function (row) {
return row.startsWith("csrftoken=");
});
if (cookie) return cookie.split("=")[1];
var el = document.querySelector('input[name="csrfmiddlewaretoken"]');
return el ? el.value : "";
}
// ── Init on page load ───────────────────────────────────────────────────
// ── Inject search inputs into filter forms ──
function injectSearchInputs() {
document.querySelectorAll('[id^="filter-bar-form"]').forEach(function (form) {
if (form.querySelector('[name="filter-search"]')) return; // already added
var input = document.createElement("input");
input.type = "text";
input.name = "filter-search";
input.placeholder = "Search\u2026";
input.className = "block w-full rounded-base border border-default-medium bg-neutral-secondary-medium text-sm text-heading p-2 mb-4 focus:ring-brand focus:border-brand";
// Pre-fill from existing filter JSON
var hidden = form.querySelector('[name="filter"]');
if (hidden && hidden.parentNode) {
try {
var existing = JSON.parse(hidden.value || "{}");
if (existing.search && existing.search.value) {
input.value = existing.search.value;
}
} catch (e) {}
hidden.parentNode.insertBefore(input, hidden.nextSibling);
}
});
}
document.addEventListener("DOMContentLoaded", function () {
injectSearchInputs();
loadPresets();
});
})();

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