Files
timetracker/games/forms.py
T
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

393 lines
11 KiB
Python

from django import forms
from django.db import transaction
from django.db.models import OuterRef, Subquery
from common.components import (
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,
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.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,
always_visible=self.always_visible,
placeholder=self.placeholder,
id=(attrs or {}).get("id", ""),
autofocus=autofocus,
)
def value_from_datadict(self, data, files, name):
return data.get(name)
class SearchSelectMultiple(SearchSelectWidget):
def value_from_datadict(self, data, files, name):
if hasattr(data, "getlist"):
return data.getlist(name)
return data.get(name)
class SessionForm(forms.ModelForm):
game = SingleGameChoiceField(
queryset=Game.objects.order_by("sort_name"),
widget=SearchSelectWidget(
search_url="/api/games/search", options_resolver=_game_options
),
)
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:
widgets = {
"timestamp_start": custom_datetime_widget,
"timestamp_end": custom_datetime_widget,
}
model = Session
fields = [
"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
def related_purchase_queryset():
"""GAME purchases annotated with their first game's name.
Rendering the ``related_purchase`` ``<select>`` calls ``str()`` on every
option, and ``Purchase.__str__`` falls back to ``first_game`` — one extra
query per option (700+ on a large library). Annotating the first game's
name via a subquery lets the choice field build labels without those
per-row queries.
"""
first_game_name = Subquery(
Game.objects.filter(purchases=OuterRef("pk")).order_by("id").values("name")[:1]
)
return Purchase.objects.filter(type=Purchase.GAME).annotate(
_first_game_name=first_game_name
)
class RelatedPurchaseChoiceField(forms.ModelChoiceField):
def label_from_instance(self, obj) -> str:
# Mirrors Purchase.standardized_name but reads the annotated first-game
# name instead of querying first_game per option.
name = obj.name or getattr(obj, "_first_game_name", None)
return name or obj.standardized_name
class PurchaseForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["platform"].queryset = Platform.objects.order_by("name")
games = MultipleGameChoiceField(
queryset=Game.objects.order_by("sort_name"),
widget=SearchSelectMultiple(
search_url="/api/games/search",
options_resolver=_game_options,
multi_select=True,
),
)
platform = forms.ModelChoiceField(
queryset=Platform.objects.order_by("name"),
widget=SearchSelectWidget(
search_url="/api/platforms/search", options_resolver=_platform_options
),
)
related_purchase = RelatedPurchaseChoiceField(
queryset=related_purchase_queryset(),
required=False,
)
price_currency = forms.CharField(
widget=forms.TextInput(
attrs={
"x-mask": "aaa",
"placeholder": "CZK",
"x-data": "",
"class": "uppercase",
}
),
label="Currency",
)
class Meta:
widgets = {
"date_purchased": custom_date_widget,
"date_refunded": custom_date_widget,
}
model = Purchase
fields = [
"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")
# 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
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",
"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",
"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,
}