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

This commit is contained in:
2026-06-07 20:22:50 +02:00
6 changed files with 197 additions and 46 deletions
+106 -24
View File
@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
| Task | Command | | Task | Command |
|------|---------| |------|---------|
| Install dependencies | `make init` (installs Python via uv + npm packages) | | Install dependencies | `make init` (installs Python via uv + npm packages, loads platform fixtures) |
| Development server | `make dev` (runs Django runserver + Tailwind CSS watcher) | | Development server | `make dev` (runs Django runserver + Tailwind CSS watcher) |
| Production-like dev | `make dev-prod` (Caddy + Gunicorn/Uvicorn + Django-Q cluster) | | Production-like dev | `make dev-prod` (Caddy + Gunicorn/Uvicorn + Django-Q cluster) |
| Run tests | `make test` (or `uv run --with pytest-django pytest`) | | Run tests | `make test` (or `uv run --with pytest-django pytest`) |
@@ -20,67 +20,149 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
| Auto-fix lint | `make lint-fix` (`ruff check --fix`) | | Auto-fix lint | `make lint-fix` (`ruff check --fix`) |
| Lint + format check + tests | `make check` (CI-style aggregate) | | Lint + format check + tests | `make check` (CI-style aggregate) |
| Sync uv.lock | `uv sync` (after editing pyproject.toml) | | 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 ## Architecture
A Django 6+ monolith with a single app (`games/`) for tracking video game purchases, play sessions, and statistics. Uses HTMX for interactivity with a custom server-side component system, plus a Django Ninja REST API. 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 ### Directory layout
``` ```
games/ — Django app: models, views, templates, forms, signals, tasks, API games/ — Django app: models, views, templates, forms, signals, tasks, API, filters
common/ — Shared utilities: time formatting, component system, HTML helpers common/ — Shared utilities: time formatting, component system, criteria, layout, icons
timetracker/ — Django project: settings, URL root, ASGI/WSGI timetracker/ — Django project: settings, URL root, ASGI/WSGI
tests/ — Pytest tests tests/ — Pytest tests
contrib/ — One-off scripts (exchange rate import) contrib/ — One-off scripts (exchange rate import)
docs/ — Additional documentation
``` ```
### Models (in `games/models.py`) ### Models (in `games/models.py`)
- **Game** — name, platform, status (Unplayed/Played/Finished/Retired/Abandoned), mastered, playtime - **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 - **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 - **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** — start/end timestamps, manual duration, device. `duration_calculated` and `duration_total` are GeneratedFields (cannot be written directly) - **Session** — `timestamp_start`/`timestamp_end`, `duration_manual`, `device` (FK), `note`, `emulated`. `duration_calculated` and `duration_total` are `GeneratedField`s (cannot be written directly)
- **Device** — name, type (PC/Console/Handheld/Mobile/SBC/Unknown) - **Device** — `name`, `type` (PC/Console/Handheld/Mobile/SBC/Unknown)
- **PlayEvent** — marks when a game was started/finished (separate from Sessions), `days_to_finish` is a GeneratedField - **PlayEvent** — marks when a game was started/finished (separate from Sessions), `days_to_finish` is a `GeneratedField`
- **ExchangeRate** — cached FX rates per currency pair per year - **ExchangeRate** — cached FX rates per currency pair per year
- **GameStatusChange** — audit log of status transitions - **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 ### Key patterns
**Component system** (`common/components.py`): Python functions return HTML via django-cotton templates. Every component wraps `Component()` which calls `render_to_string` (LRU-cached in production). Key helpers: `A()`, `Button()`, `Icon()`, `Popover()`, `PopoverTruncated()`, `NameWithIcon()`, `LinkedPurchase()`, `Div()`, `Form()`. **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.
**Views** (`games/views/`): Function-based views decorated with `@login_required`. Organized by domain entity: `session.py`, `game.py`, `purchase.py`, `playevent.py`, `platform.py`, `device.py`, `statuschange.py`, `general.py`. The `general.py` has two context processors: `model_counts` and `global_current_year`. **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()`, `SelectableFilter()` (clickable include/exclude chips)
- **`search_select.py`** — `SearchSelect()` + `SearchSelectOption`: search-as-you-type dropdown with removable pill selection, 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`): **Signals** (`games/signals.py`):
- `pre_save` on Purchase: snapshots old price/currency for change detection - `pre_save` on Purchase: snapshots old price/currency for change detection
- `post_save` on Purchase: sets `needs_price_update` if price/currency changed - `post_save` on Purchase: sets `needs_price_update` if price/currency changed
- `m2m_changed` on Purchase.games: updates `num_purchases` count - `m2m_changed` on Purchase.games: updates `num_purchases` count
- `pre_delete` on Game: decrements `num_purchases` on related Purchases - `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 - `post_save/post_delete` on Session: recalculates `Game.playtime` from session aggregate
- `pre_save` on Game: creates GameStatusChange audit records - `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 and convert purchase prices to CZK. **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. **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 for playevents, games, and sessions. Game status and session device can be PATCHed via the API. **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 ### Templates
Templates live in `games/templates/`. The layout uses django-cotton components in `templates/cotton/` — a reusable component library with `button.html`, `table.html`, `popover.html`, etc. Platform icons are stored as individual HTML snippet files under `cotton/icon/<slug>.html`. Partials for HTMX responses are in `templates/partials/`. 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)
- `selectable_filter.js` — SelectableFilter widget interaction
- `search_select.js` — SearchSelect widget (search-as-you-type, pills)
- `utils.js` — shared helpers (e.g., `fetchWithHtmxTriggers`)
### Deployment ### 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 Drone (`.drone.yml`): runs tests, builds Docker image, deploys via Portainer webhook. Docker-based: multi-stage Dockerfile (uv builder → slim runtime), Caddy as reverse proxy on port 8000, Gunicorn with UvicornWorker (ASGI), Supervisor to manage Caddy + Gunicorn + django-q2. `make dev-prod` mimics production locally. CI/CD via GitHub Actions (`.github/workflows/build-docker.yml`): builds Docker image; Drone CI (`.drone.yml`) also present for deployments via Portainer webhook.
### Database ### 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 GeneratedFields on the models — these are computed by the database engine and cannot be written from application code. SQLite with WAL journal mode. Connection timeout 20s. The `DATA_DIR` env var controls the database file location. Migrations live in `games/migrations/`. There are `GeneratedField`s on the models — these are computed by the database engine and cannot be written from application code.
### Configuration ### Configuration
- `DEBUG` is `True` unless `PROD` env var is set - `DEBUG` is `True` unless `PROD` env var is set
- `TIME_ZONE` defaults to `Europe/Prague` in debug, otherwise reads `TZ` env var - `TIME_ZONE` defaults to `Europe/Prague` in debug, otherwise reads `TZ` env var (default `UTC`)
- Django Admin and Debug Toolbar are only available in DEBUG mode - Django Admin, Debug Toolbar, and `django_extensions` are only available in `DEBUG` mode
- `CSRF_TRUSTED_ORIGINS` is parsed from a comma-separated env var - `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.
- **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.
- **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`.
+3 -9
View File
@@ -97,6 +97,7 @@ def SearchSelect(
placeholder: str = "Search…", placeholder: str = "Search…",
id: str = "", id: str = "",
sync_url: bool = False, sync_url: bool = False,
autofocus: bool = False,
) -> SafeText: ) -> SafeText:
"""Render the search-select widget. See module docstring for the contract.""" """Render the search-select widget. See module docstring for the contract."""
selected = [_normalize_option(o) for o in (selected or [])] selected = [_normalize_option(o) for o in (selected or [])]
@@ -139,17 +140,10 @@ def SearchSelect(
("autocomplete", "off"), ("autocomplete", "off"),
("class", _SEARCH_CLASS), ("class", _SEARCH_CLASS),
] ]
if search_value: if autofocus:
search_attrs.append(("value", search_value)) search_attrs.append(("autofocus", ""))
search = Component(tag_name="input", attributes=search_attrs) search = Component(tag_name="input", attributes=search_attrs)
# ── Field row: pills + search box combined into one visual field ──
field = Component(
tag_name="div",
attributes=[("data-ss-field", ""), ("class", _FIELD_CLASS)],
children=[pills, search],
)
# ── Options panel (pre-rendered only when there is no search_url) ── # ── Options panel (pre-rendered only when there is no search_url) ──
option_rows = [_option_row(o) for o in options] if not search_url else [] option_rows = [_option_row(o) for o in options] if not search_url else []
no_results = Component( no_results = Component(
+21 -1
View File
@@ -7,11 +7,13 @@ from django.shortcuts import get_object_or_404
from django.utils.timezone import now as django_timezone_now from django.utils.timezone import now as django_timezone_now
from ninja import Field, ModelSchema, NinjaAPI, Router, Schema, Status from ninja import Field, ModelSchema, NinjaAPI, Router, Schema, Status
from games.models import Game, PlayEvent, Session from games.models import Device, Game, Platform, PlayEvent, Session
api = NinjaAPI() api = NinjaAPI()
playevent_router = Router() playevent_router = Router()
game_router = Router() game_router = Router()
device_router = Router()
platform_router = Router()
NOW_FACTORY = django_timezone_now NOW_FACTORY = django_timezone_now
@@ -115,8 +117,26 @@ def delete_playevent(request, playevent_id: int):
return Status(204, None) 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("/playevent", playevent_router)
api.add_router("/games", game_router) api.add_router("/games", game_router)
api.add_router("/devices", device_router)
api.add_router("/platforms", platform_router)
session_router = Router() session_router = Router()
+49 -8
View File
@@ -46,6 +46,20 @@ def _game_options(values) -> list[SearchSelectOption]:
] ]
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): class SearchSelectWidget(forms.Widget):
"""Thin Django adapter that renders a `SearchSelect()` component. """Thin Django adapter that renders a `SearchSelect()` component.
@@ -57,6 +71,7 @@ class SearchSelectWidget(forms.Widget):
self, self,
*, *,
search_url, search_url,
options_resolver,
multi_select=False, multi_select=False,
items_visible=5, items_visible=5,
items_scroll=10, items_scroll=10,
@@ -66,6 +81,7 @@ class SearchSelectWidget(forms.Widget):
): ):
super().__init__(attrs) super().__init__(attrs)
self.search_url = search_url self.search_url = search_url
self.options_resolver = options_resolver
self.multi_select = multi_select self.multi_select = multi_select
self.items_visible = items_visible self.items_visible = items_visible
self.items_scroll = items_scroll self.items_scroll = items_scroll
@@ -81,7 +97,8 @@ class SearchSelectWidget(forms.Widget):
return [value] if value not in (None, "") else [] return [value] if value not in (None, "") else []
def render(self, name, value, attrs=None, renderer=None): def render(self, name, value, attrs=None, renderer=None):
selected = searchselect_selected(self._values(value), _game_options) selected = searchselect_selected(self._values(value), self.options_resolver)
autofocus = bool((attrs or {}).get("autofocus"))
return SearchSelect( return SearchSelect(
name=name, name=name,
selected=selected, selected=selected,
@@ -93,6 +110,7 @@ class SearchSelectWidget(forms.Widget):
always_visible=self.always_visible, always_visible=self.always_visible,
placeholder=self.placeholder, placeholder=self.placeholder,
id=(attrs or {}).get("id", ""), id=(attrs or {}).get("id", ""),
autofocus=autofocus,
) )
def value_from_datadict(self, data, files, name): def value_from_datadict(self, data, files, name):
@@ -109,7 +127,9 @@ class SearchSelectMultiple(SearchSelectWidget):
class SessionForm(forms.ModelForm): class SessionForm(forms.ModelForm):
game = SingleGameChoiceField( game = SingleGameChoiceField(
queryset=Game.objects.order_by("sort_name"), queryset=Game.objects.order_by("sort_name"),
widget=SearchSelectWidget(search_url="/api/games/search"), widget=SearchSelectWidget(
search_url="/api/games/search", options_resolver=_game_options
),
) )
duration_manual = forms.DurationField( duration_manual = forms.DurationField(
@@ -120,7 +140,11 @@ class SessionForm(forms.ModelForm):
label="Manual duration", label="Manual duration",
) )
device = forms.ModelChoiceField( device = forms.ModelChoiceField(
queryset=Device.objects.order_by("name"), required=False queryset=Device.objects.order_by("name"),
required=False,
widget=SearchSelectWidget(
search_url="/api/devices/search", options_resolver=_device_options
),
) )
mark_as_played = forms.BooleanField( mark_as_played = forms.BooleanField(
@@ -191,9 +215,18 @@ class PurchaseForm(forms.ModelForm):
games = MultipleGameChoiceField( games = MultipleGameChoiceField(
queryset=Game.objects.order_by("sort_name"), queryset=Game.objects.order_by("sort_name"),
widget=SearchSelectMultiple(search_url="/api/games/search", multi_select=True), 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
),
) )
platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
related_purchase = RelatedPurchaseChoiceField( related_purchase = RelatedPurchaseChoiceField(
queryset=related_purchase_queryset(), queryset=related_purchase_queryset(),
required=False, required=False,
@@ -270,7 +303,11 @@ class GameModelChoiceField(forms.ModelChoiceField):
class GameForm(forms.ModelForm): class GameForm(forms.ModelForm):
platform = forms.ModelChoiceField( platform = forms.ModelChoiceField(
queryset=Platform.objects.order_by("name"), required=False queryset=Platform.objects.order_by("name"),
required=False,
widget=SearchSelectWidget(
search_url="/api/platforms/search", options_resolver=_platform_options
),
) )
class Meta: class Meta:
@@ -307,9 +344,13 @@ class DeviceForm(forms.ModelForm):
class PlayEventForm(forms.ModelForm): class PlayEventForm(forms.ModelForm):
game = GameModelChoiceField( game = SingleGameChoiceField(
queryset=Game.objects.order_by("sort_name"), queryset=Game.objects.order_by("sort_name"),
widget=forms.Select(attrs={"autofocus": "autofocus"}), widget=SearchSelectWidget(
search_url="/api/games/search",
options_resolver=_game_options,
attrs={"autofocus": "autofocus"},
),
) )
mark_as_finished = forms.BooleanField( mark_as_finished = forms.BooleanField(
+7 -2
View File
@@ -180,7 +180,7 @@ def add_game(request: HttpRequest) -> HttpResponse:
), ),
), ),
title="Add New Game", title="Add New Game",
scripts=ModuleScript("add_game.js"), scripts=ModuleScript("search_select.js") + ModuleScript("add_game.js"),
) )
@@ -332,7 +332,12 @@ def edit_game(request: HttpRequest, game_id: int) -> HttpResponse:
if form.is_valid(): if form.is_valid():
form.save() form.save()
return redirect("games:list_sessions") return redirect("games:list_sessions")
return render_page(request, AddForm(form, request=request), title="Edit Game") return render_page(
request,
AddForm(form, request=request),
title="Edit Game",
scripts=ModuleScript("search_select.js"),
)
# --- view_game content builders ------------------------------------------- # --- view_game content builders -------------------------------------------
+11 -2
View File
@@ -15,6 +15,7 @@ from common.components import (
Button, Button,
ButtonGroup, ButtonGroup,
Icon, Icon,
ModuleScript,
paginated_table_content, paginated_table_content,
) )
from common.layout import render_page from common.layout import render_page
@@ -193,7 +194,10 @@ def add_playevent(request: HttpRequest, game_id: int = 0) -> HttpResponse:
return HttpResponseRedirect(reverse("games:view_game", args=[game_id])) return HttpResponseRedirect(reverse("games:view_game", args=[game_id]))
return render_page( return render_page(
request, AddForm(form, request=request), title="Add new playthrough" request,
AddForm(form, request=request),
title="Add new playthrough",
scripts=ModuleScript("search_select.js"),
) )
@@ -206,7 +210,12 @@ def edit_playevent(request: HttpRequest, playevent_id: int) -> HttpResponse:
reverse("games:view_game", args=[playevent.game.id]) reverse("games:view_game", args=[playevent.game.id])
) )
return render_page(request, AddForm(form, request=request), title="Edit Play Event") return render_page(
request,
AddForm(form, request=request),
title="Edit Play Event",
scripts=ModuleScript("search_select.js"),
)
def delete_playevent(request: HttpRequest, playevent_id: int) -> HttpResponse: def delete_playevent(request: HttpRequest, playevent_id: int) -> HttpResponse: