Add more filters
This commit is contained in:
@@ -645,7 +645,7 @@ def FilterBar(
|
||||
preset_save_url: str = "",
|
||||
) -> SafeText:
|
||||
"""Collapsible filter bar for the Game list."""
|
||||
from games.models import Game
|
||||
from games.models import Game, Purchase
|
||||
|
||||
if status_options is None:
|
||||
status_options = [(s.value, s.label) for s in Game.Status]
|
||||
@@ -653,8 +653,16 @@ def FilterBar(
|
||||
existing = _filter_parse(filter_json)
|
||||
status_choice = _filter_get_choice(existing, "status")
|
||||
platform_choice = _filter_get_choice(existing, "platform")
|
||||
platform_group_choice = _filter_get_choice(existing, "platform_group")
|
||||
device_choice = _filter_get_choice(existing, "device")
|
||||
purchase_type_choice = _filter_get_choice(existing, "purchase_type")
|
||||
purchase_ownership_choice = _filter_get_choice(existing, "purchase_ownership_type")
|
||||
playevent_note_choice = _filter_get_choice(existing, "playevent_note")
|
||||
|
||||
year_min, year_max = _parse_range(existing, "year_released")
|
||||
original_year_min, original_year_max = _parse_range(
|
||||
existing, "original_year_released"
|
||||
)
|
||||
mastered_value = _parse_bool(existing, "mastered")
|
||||
playtime = existing.get("playtime_minutes", {})
|
||||
if isinstance(playtime, dict):
|
||||
@@ -664,10 +672,17 @@ def FilterBar(
|
||||
playtime_min = ""
|
||||
playtime_max = ""
|
||||
|
||||
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")
|
||||
purchase_count_min, purchase_count_max = _parse_range(existing, "purchase_count")
|
||||
playevent_count_min, playevent_count_max = _parse_range(existing, "playevent_count")
|
||||
manual_pt_min, manual_pt_max = _parse_range(existing, "manual_playtime_minutes")
|
||||
calc_pt_min, calc_pt_max = _parse_range(existing, "calculated_playtime_minutes")
|
||||
price_total_min, price_total_max = _parse_range(existing, "purchase_price_total")
|
||||
price_any_min, price_any_max = _parse_range(existing, "purchase_price_any")
|
||||
purchase_refunded_value = _parse_bool(existing, "purchase_refunded")
|
||||
purchase_infinite_value = _parse_bool(existing, "purchase_infinite")
|
||||
session_emulated_value = _parse_bool(existing, "session_emulated")
|
||||
|
||||
try:
|
||||
year_aggregate = Game.objects.aggregate(
|
||||
@@ -675,17 +690,39 @@ def FilterBar(
|
||||
)
|
||||
except Exception:
|
||||
year_aggregate = {}
|
||||
try:
|
||||
original_year_aggregate = Game.objects.aggregate(
|
||||
year_min=models.Min("original_year_released"),
|
||||
year_max=models.Max("original_year_released"),
|
||||
)
|
||||
except Exception:
|
||||
original_year_aggregate = {}
|
||||
try:
|
||||
playtime_aggregate = Game.objects.aggregate(playtime_max=models.Max("playtime"))
|
||||
except Exception:
|
||||
playtime_aggregate = {}
|
||||
try:
|
||||
price_aggregate = Purchase.objects.aggregate(
|
||||
price_min=models.Min("converted_price"),
|
||||
price_max=models.Max("converted_price"),
|
||||
)
|
||||
except Exception:
|
||||
price_aggregate = {}
|
||||
year_range_min = max(int(year_aggregate.get("year_min") or 1970), 1970)
|
||||
year_range_max = min(int(year_aggregate.get("year_max") or 2030), 2030)
|
||||
original_year_range_min = max(
|
||||
int(original_year_aggregate.get("year_min") or 1970), 1970
|
||||
)
|
||||
original_year_range_max = min(
|
||||
int(original_year_aggregate.get("year_max") or 2030), 2030
|
||||
)
|
||||
playtime_range_max = (
|
||||
int((playtime_aggregate.get("playtime_max") or 0).total_seconds() / 3600)
|
||||
if playtime_aggregate.get("playtime_max")
|
||||
else 200
|
||||
)
|
||||
price_range_min = int(price_aggregate.get("price_min") or 0)
|
||||
price_range_max = max(int(price_aggregate.get("price_max") or 100), 1)
|
||||
|
||||
fields = [
|
||||
Component(
|
||||
@@ -710,6 +747,54 @@ def FilterBar(
|
||||
nullable=Game._meta.get_field("platform").null,
|
||||
),
|
||||
),
|
||||
_filter_field(
|
||||
"Platform Group",
|
||||
_model_filter(
|
||||
"platform_group",
|
||||
platform_group_choice,
|
||||
search_url="/api/platforms/groups",
|
||||
nullable=False,
|
||||
),
|
||||
),
|
||||
_filter_field(
|
||||
"Device",
|
||||
_model_filter(
|
||||
"device",
|
||||
device_choice,
|
||||
search_url="/api/devices/search",
|
||||
nullable=False,
|
||||
),
|
||||
),
|
||||
_filter_field(
|
||||
"Purchase Type",
|
||||
_enum_filter(
|
||||
"purchase_type",
|
||||
Purchase.TYPES,
|
||||
purchase_type_choice,
|
||||
nullable=False,
|
||||
),
|
||||
),
|
||||
_filter_field(
|
||||
"Purchase Ownership",
|
||||
_enum_filter(
|
||||
"purchase_ownership_type",
|
||||
Purchase.OWNERSHIP_TYPES,
|
||||
purchase_ownership_choice,
|
||||
nullable=False,
|
||||
),
|
||||
),
|
||||
_filter_field(
|
||||
"Playevent Note",
|
||||
FilterSelect(
|
||||
field_name="playevent_note",
|
||||
included=playevent_note_choice.selected,
|
||||
excluded=playevent_note_choice.excluded,
|
||||
modifier=_split_modifier(playevent_note_choice.modifier),
|
||||
modifier_options=_modifier_options(nullable=False),
|
||||
free_text=True,
|
||||
placeholder="Type a note substring…",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
RangeSlider(
|
||||
@@ -722,17 +807,34 @@ def FilterBar(
|
||||
min_placeholder="e.g. 2020",
|
||||
max_placeholder="e.g. 2024",
|
||||
),
|
||||
RangeSlider(
|
||||
label="Original Year",
|
||||
input_name_prefix="filter-original-year",
|
||||
min_value=original_year_min,
|
||||
max_value=original_year_max,
|
||||
range_min=original_year_range_min,
|
||||
range_max=original_year_range_max,
|
||||
min_placeholder="e.g. 1985",
|
||||
max_placeholder="e.g. 2010",
|
||||
),
|
||||
Component(
|
||||
tag_name="div",
|
||||
attributes=[("class", "flex items-end gap-4 mb-4")],
|
||||
attributes=[("class", "flex items-end gap-4 mb-4 flex-wrap")],
|
||||
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),
|
||||
_filter_checkbox(
|
||||
"filter-purchase-refunded", "Refunded", purchase_refunded_value
|
||||
),
|
||||
_filter_checkbox(
|
||||
"filter-purchase-infinite", "Infinite", purchase_infinite_value
|
||||
),
|
||||
_filter_checkbox(
|
||||
"filter-session-emulated", "Emulated", session_emulated_value
|
||||
),
|
||||
],
|
||||
),
|
||||
RangeSlider(
|
||||
label="Playtime",
|
||||
label="Total playtime",
|
||||
input_name_prefix="filter-playtime",
|
||||
min_value=playtime_min,
|
||||
max_value=playtime_max,
|
||||
@@ -742,6 +844,28 @@ def FilterBar(
|
||||
min_placeholder="e.g. 1",
|
||||
max_placeholder="e.g. 100",
|
||||
),
|
||||
RangeSlider(
|
||||
label="Manual Playtime (mins)",
|
||||
input_name_prefix="filter-manual-playtime-minutes",
|
||||
min_value=manual_pt_min,
|
||||
max_value=manual_pt_max,
|
||||
range_min=0,
|
||||
range_max=max(playtime_range_max * 60, 240),
|
||||
step="1",
|
||||
min_placeholder="e.g. 10",
|
||||
max_placeholder="e.g. 120",
|
||||
),
|
||||
RangeSlider(
|
||||
label="Calculated Playtime (mins)",
|
||||
input_name_prefix="filter-calculated-playtime-minutes",
|
||||
min_value=calc_pt_min,
|
||||
max_value=calc_pt_max,
|
||||
range_min=0,
|
||||
range_max=max(playtime_range_max * 60, 240),
|
||||
step="1",
|
||||
min_placeholder="e.g. 30",
|
||||
max_placeholder="e.g. 180",
|
||||
),
|
||||
RangeSlider(
|
||||
label="Session Count",
|
||||
input_name_prefix="filter-session-count",
|
||||
@@ -764,6 +888,48 @@ def FilterBar(
|
||||
min_placeholder="e.g. 10",
|
||||
max_placeholder="e.g. 120",
|
||||
),
|
||||
RangeSlider(
|
||||
label="Number of Purchases",
|
||||
input_name_prefix="filter-purchase-count",
|
||||
min_value=purchase_count_min,
|
||||
max_value=purchase_count_max,
|
||||
range_min=0,
|
||||
range_max=20,
|
||||
step="1",
|
||||
min_placeholder="e.g. 1",
|
||||
max_placeholder="e.g. 5",
|
||||
),
|
||||
RangeSlider(
|
||||
label="Number of Play Events",
|
||||
input_name_prefix="filter-playevent-count",
|
||||
min_value=playevent_count_min,
|
||||
max_value=playevent_count_max,
|
||||
range_min=0,
|
||||
range_max=20,
|
||||
step="1",
|
||||
min_placeholder="e.g. 1",
|
||||
max_placeholder="e.g. 5",
|
||||
),
|
||||
RangeSlider(
|
||||
label="Total Purchase Price",
|
||||
input_name_prefix="filter-purchase-price-total",
|
||||
min_value=price_total_min,
|
||||
max_value=price_total_max,
|
||||
range_min=price_range_min,
|
||||
range_max=price_range_max,
|
||||
min_placeholder="0",
|
||||
max_placeholder=str(price_range_max),
|
||||
),
|
||||
RangeSlider(
|
||||
label="Any Purchase Price",
|
||||
input_name_prefix="filter-purchase-price-any",
|
||||
min_value=price_any_min,
|
||||
max_value=price_any_max,
|
||||
range_min=price_range_min,
|
||||
range_max=price_range_max,
|
||||
min_placeholder="0",
|
||||
max_placeholder=str(price_range_max),
|
||||
),
|
||||
]
|
||||
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
|
||||
|
||||
|
||||
@@ -431,6 +431,7 @@ def FilterSelect(
|
||||
items_scroll: int = 10,
|
||||
placeholder: str = "Search…",
|
||||
id: str = "",
|
||||
free_text: bool = False,
|
||||
) -> SafeText:
|
||||
"""Include/exclude filter combobox built on the shared ``_combobox_shell``.
|
||||
|
||||
@@ -447,6 +448,11 @@ def FilterSelect(
|
||||
``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.
|
||||
|
||||
``free_text`` turns the widget into a typed-pill input: there is no backing
|
||||
option list, the JS builds an ephemeral option row from whatever the user
|
||||
types so the +/− buttons (and Enter) commit the typed string itself as an
|
||||
include / exclude pill.
|
||||
"""
|
||||
options = [_normalize_option(option) for option in (options or [])]
|
||||
included = [_normalize_option(option) for option in (included or [])]
|
||||
@@ -515,7 +521,7 @@ def FilterSelect(
|
||||
children=[_filter_modifier_pill("", "")],
|
||||
)
|
||||
)
|
||||
if search_url:
|
||||
if search_url or free_text:
|
||||
templates.append(
|
||||
Template(
|
||||
attributes=[("data-search-select-template", "row")],
|
||||
@@ -536,6 +542,8 @@ def FilterSelect(
|
||||
("data-sync-url", "false"),
|
||||
("class", _CONTAINER_CLASS),
|
||||
]
|
||||
if free_text:
|
||||
container_attributes.append(("data-search-select-free-text", "true"))
|
||||
if modifier:
|
||||
container_attributes.append(("data-modifier", modifier))
|
||||
if id:
|
||||
|
||||
@@ -59,6 +59,12 @@ class GameOption(Schema): # mirrors SearchSelectOption
|
||||
data: dict
|
||||
|
||||
|
||||
class StringOption(Schema): # SearchSelectOption with a string value (e.g. group names)
|
||||
value: str
|
||||
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")
|
||||
@@ -133,6 +139,15 @@ def search_platforms(request, q: str = "", limit: int = 10):
|
||||
return [{"value": p.id, "label": p.name, "data": {}} for p in qs[:limit]]
|
||||
|
||||
|
||||
@platform_router.get("/groups", response=list[StringOption])
|
||||
def search_platform_groups(request, q: str = "", limit: int = 10):
|
||||
qs = Platform.objects.exclude(group="")
|
||||
if q:
|
||||
qs = qs.filter(group__icontains=q)
|
||||
groups = qs.values_list("group", flat=True).distinct().order_by("group")
|
||||
return [{"value": group, "label": group, "data": {}} for group in groups[:limit]]
|
||||
|
||||
|
||||
api.add_router("/playevent", playevent_router)
|
||||
api.add_router("/games", game_router)
|
||||
api.add_router("/devices", device_router)
|
||||
|
||||
+171
-21
@@ -58,16 +58,36 @@ class GameFilter(OperatorFilter):
|
||||
original_year_released: IntCriterion | None = None
|
||||
wikidata: StringCriterion | None = None
|
||||
platform: ChoiceCriterion | None = None # selectable filter widget
|
||||
platform_group: MultiCriterion | None = None # platform__group__in
|
||||
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
|
||||
purchase_count: IntCriterion | None = None # distinct purchases per game
|
||||
playevent_count: IntCriterion | None = None # playevents per game
|
||||
|
||||
# Aggregate session durations (minutes), summed across the game's sessions
|
||||
manual_playtime_minutes: IntCriterion | None = None
|
||||
calculated_playtime_minutes: IntCriterion | None = None
|
||||
|
||||
# Cross-entity: any session played on these devices / matching these flags
|
||||
device: MultiCriterion | None = None # game has session on any of these devices
|
||||
session_emulated: BoolCriterion | None = None # game has emulated session
|
||||
|
||||
# Cross-entity: matches against the game's purchases
|
||||
purchase_refunded: BoolCriterion | None = None # game has refunded purchase
|
||||
purchase_infinite: BoolCriterion | None = None # game has infinite purchase
|
||||
purchase_price_total: FloatCriterion | None = None # sum of converted prices
|
||||
purchase_price_any: FloatCriterion | None = None # any single purchase in range
|
||||
purchase_type: ChoiceCriterion | None = None # game has purchase of type
|
||||
purchase_ownership_type: ChoiceCriterion | None = None # by ownership
|
||||
|
||||
# Cross-entity: substring match against the game's playevent notes
|
||||
playevent_note: ChoiceCriterion | None = None
|
||||
|
||||
# Free-text search (combines name + sort_name + platform name)
|
||||
search: StringCriterion | None = None
|
||||
@@ -105,34 +125,138 @@ class GameFilter(OperatorFilter):
|
||||
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.platform_group is not None:
|
||||
q &= self.platform_group.to_q("platform__group")
|
||||
|
||||
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)
|
||||
|
||||
from games.models import Game
|
||||
matching_ids = (
|
||||
Game.objects.annotate(s_count=Count("sessions", distinct=True))
|
||||
.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)
|
||||
|
||||
from games.models import Game
|
||||
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)
|
||||
|
||||
if self.purchase_count is not None:
|
||||
from django.db.models import Count
|
||||
|
||||
from games.models import Game
|
||||
matching_ids = (
|
||||
Game.objects.annotate(p_count=Count("purchases", distinct=True))
|
||||
.filter(self.purchase_count.to_q("p_count"))
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
if self.playevent_count is not None:
|
||||
from django.db.models import Count
|
||||
|
||||
from games.models import Game
|
||||
matching_ids = (
|
||||
Game.objects.annotate(pe_count=Count("playevents", distinct=True))
|
||||
.filter(self.playevent_count.to_q("pe_count"))
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
if self.manual_playtime_minutes is not None:
|
||||
from django.db.models import Sum
|
||||
|
||||
from games.models import Game
|
||||
matching_ids = (
|
||||
Game.objects.annotate(s_manual=Sum("sessions__duration_manual"))
|
||||
.filter(self._playtime_to_q_for_field(self.manual_playtime_minutes, "s_manual"))
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
if self.calculated_playtime_minutes is not None:
|
||||
from django.db.models import Sum
|
||||
|
||||
from games.models import Game
|
||||
matching_ids = (
|
||||
Game.objects.annotate(s_calc=Sum("sessions__duration_calculated"))
|
||||
.filter(self._playtime_to_q_for_field(self.calculated_playtime_minutes, "s_calc"))
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
if self.device is not None:
|
||||
from games.models import Session
|
||||
session_q = self.device.to_q("device_id")
|
||||
matching_ids = Session.objects.filter(session_q).values_list("game_id", flat=True)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
if self.session_emulated is not None:
|
||||
from games.models import Session
|
||||
emulated_ids = Session.objects.filter(emulated=self.session_emulated.value).values_list("game_id", flat=True)
|
||||
if self.session_emulated.value:
|
||||
q &= Q(id__in=emulated_ids)
|
||||
else:
|
||||
emulated_true_ids = Session.objects.filter(emulated=True).values_list("game_id", flat=True)
|
||||
q &= ~Q(id__in=emulated_true_ids)
|
||||
|
||||
if self.purchase_refunded is not None:
|
||||
from games.models import Purchase
|
||||
refunded_ids = Purchase.objects.filter(date_refunded__isnull=False).values_list("games__id", flat=True)
|
||||
if self.purchase_refunded.value:
|
||||
q &= Q(id__in=refunded_ids)
|
||||
else:
|
||||
q &= ~Q(id__in=refunded_ids)
|
||||
|
||||
if self.purchase_infinite is not None:
|
||||
from games.models import Purchase
|
||||
infinite_ids = Purchase.objects.filter(infinite=True).values_list("games__id", flat=True)
|
||||
if self.purchase_infinite.value:
|
||||
q &= Q(id__in=infinite_ids)
|
||||
else:
|
||||
q &= ~Q(id__in=infinite_ids)
|
||||
|
||||
if self.purchase_price_total is not None:
|
||||
from django.db.models import Sum
|
||||
|
||||
from games.models import Game
|
||||
matching_ids = (
|
||||
Game.objects.annotate(p_total=Sum("purchases__converted_price"))
|
||||
.filter(self.purchase_price_total.to_q("p_total"))
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
if self.purchase_price_any is not None:
|
||||
from games.models import Purchase
|
||||
price_q = self.purchase_price_any.to_q("converted_price")
|
||||
matching_ids = Purchase.objects.filter(price_q).values_list("games__id", flat=True)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
if self.purchase_type is not None:
|
||||
from games.models import Purchase
|
||||
type_q = self.purchase_type.to_q("type")
|
||||
matching_ids = Purchase.objects.filter(type_q).values_list("games__id", flat=True)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
if self.purchase_ownership_type is not None:
|
||||
from games.models import Purchase
|
||||
ownership_q = self.purchase_ownership_type.to_q("ownership_type")
|
||||
matching_ids = Purchase.objects.filter(ownership_q).values_list("games__id", flat=True)
|
||||
q &= Q(id__in=matching_ids)
|
||||
|
||||
if self.playevent_note is not None:
|
||||
q &= self._playevent_note_to_q(self.playevent_note)
|
||||
|
||||
# ── free-text search (OR across multiple fields) ──
|
||||
if self.search is not None and self.search.value:
|
||||
search_q = (
|
||||
@@ -231,6 +355,32 @@ class GameFilter(OperatorFilter):
|
||||
return ~Q(**{f"{field}": timedelta(0)})
|
||||
return Q()
|
||||
|
||||
@staticmethod
|
||||
def _playevent_note_to_q(criterion: ChoiceCriterion) -> Q:
|
||||
"""Match games by substrings against their playevents' notes.
|
||||
|
||||
Each `value` entry is a substring OR'd into the include side; each
|
||||
`excludes` entry is AND'd as a NOT. Empty lists contribute nothing.
|
||||
"""
|
||||
from games.models import PlayEvent
|
||||
|
||||
q = Q()
|
||||
if criterion.value:
|
||||
include_q = Q()
|
||||
negate_include = criterion.modifier == Modifier.EXCLUDES
|
||||
for term in criterion.value:
|
||||
matching_ids = PlayEvent.objects.filter(
|
||||
note__icontains=term
|
||||
).values_list("game_id", flat=True)
|
||||
include_q |= Q(id__in=matching_ids)
|
||||
q &= ~include_q if negate_include else include_q
|
||||
for term in criterion.excludes:
|
||||
matching_ids = PlayEvent.objects.filter(
|
||||
note__icontains=term
|
||||
).values_list("game_id", flat=True)
|
||||
q &= ~Q(id__in=matching_ids)
|
||||
return q
|
||||
|
||||
|
||||
# ── SessionFilter ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -101,11 +101,12 @@
|
||||
{ name: "filter-mastered", key: "mastered" },
|
||||
{ name: "filter-emulated", key: "emulated" },
|
||||
{ name: "filter-active", key: "is_active" },
|
||||
{ name: "filter-has-purchases", key: "has_purchases" },
|
||||
{ name: "filter-has-playevents", key: "has_playevents" },
|
||||
{ name: "filter-refunded", key: "is_refunded" },
|
||||
{ name: "filter-infinite", key: "infinite" },
|
||||
{ name: "filter-needs-price-update", key: "needs_price_update" }
|
||||
{ name: "filter-needs-price-update", key: "needs_price_update" },
|
||||
{ name: "filter-purchase-refunded", key: "purchase_refunded" },
|
||||
{ name: "filter-purchase-infinite", key: "purchase_infinite" },
|
||||
{ name: "filter-session-emulated", key: "session_emulated" }
|
||||
];
|
||||
booleanFields.forEach(function (bf) {
|
||||
var el = form.querySelector('[name="' + bf.name + '"]');
|
||||
@@ -117,13 +118,20 @@
|
||||
// 3. Range Fields
|
||||
var rangeFields = [
|
||||
{ prefix: "filter-year", key: "year_released" },
|
||||
{ prefix: "filter-original-year", key: "original_year_released" },
|
||||
{ prefix: "filter-session-count", key: "session_count" },
|
||||
{ prefix: "filter-session-average", key: "session_average" },
|
||||
{ prefix: "filter-purchase-count", key: "purchase_count" },
|
||||
{ prefix: "filter-playevent-count", key: "playevent_count" },
|
||||
{ prefix: "filter-duration-total-minutes", key: "duration_total_minutes" },
|
||||
{ prefix: "filter-duration-manual-minutes", key: "duration_manual_minutes" },
|
||||
{ prefix: "filter-duration-calculated-minutes", key: "duration_calculated_minutes" },
|
||||
{ prefix: "filter-manual-playtime-minutes", key: "manual_playtime_minutes" },
|
||||
{ prefix: "filter-calculated-playtime-minutes", key: "calculated_playtime_minutes" },
|
||||
{ prefix: "filter-num-purchases", key: "num_purchases" },
|
||||
{ prefix: "filter-price", key: "price" },
|
||||
{ prefix: "filter-purchase-price-total", key: "purchase_price_total" },
|
||||
{ prefix: "filter-purchase-price-any", key: "purchase_price_any" },
|
||||
{ prefix: "filter-days-to-finish", key: "days_to_finish" },
|
||||
{ prefix: "filter-playtime", key: "playtime_minutes", convert: function(v) { return Math.round(v * 60); }, ignoreZeroZero: true }
|
||||
];
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
const name = container.getAttribute("data-name");
|
||||
const searchUrl = container.getAttribute("data-search-url");
|
||||
const isFilter = container.getAttribute("data-search-select-mode") === "filter";
|
||||
const freeText = container.getAttribute("data-search-select-free-text") === "true";
|
||||
const multi = container.getAttribute("data-multi") === "true";
|
||||
const alwaysVisible = container.getAttribute("data-always-visible") === "true";
|
||||
const prefetch = parseInt(container.getAttribute("data-prefetch"), 10) || 0;
|
||||
@@ -251,6 +252,22 @@
|
||||
});
|
||||
};
|
||||
|
||||
// In free-text mode the typed text is the value itself: there is no
|
||||
// backing list, so we rebuild a single ephemeral option row reflecting the
|
||||
// current query so the +/− buttons (or Enter) can commit it as a pill.
|
||||
const rebuildFreeTextRow = (query) => {
|
||||
options.querySelectorAll("[data-search-select-option]").forEach(row => row.remove());
|
||||
if (!query) {
|
||||
setNoResults(false);
|
||||
clearHighlight();
|
||||
return;
|
||||
}
|
||||
const row = buildRow({ value: query, label: query, data: {} });
|
||||
options.insertBefore(row, noResults || null);
|
||||
setNoResults(false);
|
||||
highlightOption(row);
|
||||
};
|
||||
|
||||
// Called on every keystroke. With a search_url, filter the loaded window
|
||||
// instantly (zero latency) and debounce a server request for the rest;
|
||||
// no-results stays hidden until the response decides it, to avoid a flash
|
||||
@@ -258,6 +275,11 @@
|
||||
// so the client-side filter is authoritative.
|
||||
const runSearch = () => {
|
||||
const query = search.value.trim();
|
||||
if (freeText) {
|
||||
rebuildFreeTextRow(query);
|
||||
showPanel();
|
||||
return;
|
||||
}
|
||||
if (searchUrl) {
|
||||
filterRows(query);
|
||||
setNoResults(false);
|
||||
@@ -282,7 +304,9 @@
|
||||
search.value = "";
|
||||
container._searchSelectDirty = false;
|
||||
}
|
||||
if (searchUrl) {
|
||||
if (freeText) {
|
||||
rebuildFreeTextRow(search.value.trim());
|
||||
} else if (searchUrl) {
|
||||
if (prefetch && !hasPrefetched) {
|
||||
// Seed the window immediately on first open (not debounced).
|
||||
hasPrefetched = true;
|
||||
|
||||
@@ -219,3 +219,41 @@ class FilterBarRenderingTest(TestCase):
|
||||
)
|
||||
)
|
||||
self._assert_shell(html, "/presets/playevents/list", "/presets/playevents/save")
|
||||
|
||||
def test_game_filter_bar_has_new_widgets(self):
|
||||
"""The expanded games FilterBar exposes platform_group, device, playevent_note,
|
||||
purchase_type / purchase_ownership_type, plus count and aggregate-playtime
|
||||
range sliders and the new boolean checkboxes."""
|
||||
html = str(
|
||||
FilterBar(
|
||||
filter_json="",
|
||||
preset_list_url="/l",
|
||||
preset_save_url="/s",
|
||||
)
|
||||
)
|
||||
# New search-backed selects
|
||||
self.assertIn('data-search-url="/api/devices/search"', html)
|
||||
self.assertIn('data-search-url="/api/platforms/groups"', html)
|
||||
# New enum selects (purchase type / ownership)
|
||||
self.assertIn('data-name="purchase_type"', html)
|
||||
self.assertIn('data-name="purchase_ownership_type"', html)
|
||||
# Free-text widget for playevent notes
|
||||
self.assertIn('data-name="playevent_note"', html)
|
||||
self.assertIn('data-search-select-free-text="true"', html)
|
||||
# New range slider input prefixes
|
||||
self.assertIn('name="filter-purchase-count-min"', html)
|
||||
self.assertIn('name="filter-playevent-count-min"', html)
|
||||
self.assertIn('name="filter-manual-playtime-minutes-min"', html)
|
||||
self.assertIn('name="filter-calculated-playtime-minutes-min"', html)
|
||||
self.assertIn('name="filter-original-year-min"', html)
|
||||
self.assertIn('name="filter-purchase-price-total-min"', html)
|
||||
self.assertIn('name="filter-purchase-price-any-min"', html)
|
||||
# New boolean checkboxes
|
||||
self.assertIn('name="filter-purchase-refunded"', html)
|
||||
self.assertIn('name="filter-purchase-infinite"', html)
|
||||
self.assertIn('name="filter-session-emulated"', html)
|
||||
# Removed boolean checkboxes
|
||||
self.assertNotIn('name="filter-has-purchases"', html)
|
||||
self.assertNotIn('name="filter-has-playevents"', html)
|
||||
# Playtime label renamed
|
||||
self.assertIn("Total playtime", html)
|
||||
|
||||
+174
-2
@@ -787,9 +787,9 @@ class TestExpandedFiltersAgainstDB:
|
||||
|
||||
data = self._setup_entities()
|
||||
|
||||
# has_purchases = True
|
||||
# purchase_count == 1 (replaces removed has_purchases boolean)
|
||||
gf_pur = GameFilter.from_json({
|
||||
"has_purchases": {"value": True, "modifier": "EQUALS"}
|
||||
"purchase_count": {"value": 1, "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()))
|
||||
@@ -799,3 +799,175 @@ class TestExpandedFiltersAgainstDB:
|
||||
"session_count": {"value": 1, "modifier": "EQUALS"}
|
||||
})
|
||||
assert data["game"] in list(Game.objects.filter(gf_cnt.to_q()))
|
||||
|
||||
def test_game_filter_purchase_count_range(self):
|
||||
from games.filters import GameFilter
|
||||
from games.models import Game
|
||||
|
||||
data = self._setup_entities()
|
||||
|
||||
# game has 1 purchase, game2 has 0
|
||||
gf = GameFilter.from_json({
|
||||
"purchase_count": {"value": 1, "modifier": "EQUALS"}
|
||||
})
|
||||
results = set(Game.objects.filter(gf.to_q()))
|
||||
assert data["game"] in results
|
||||
assert data["game2"] not in results
|
||||
|
||||
def test_game_filter_playevent_count(self):
|
||||
from games.filters import GameFilter
|
||||
from games.models import Game
|
||||
|
||||
data = self._setup_entities()
|
||||
gf = GameFilter.from_json({
|
||||
"playevent_count": {"value": 1, "modifier": "EQUALS"}
|
||||
})
|
||||
results = set(Game.objects.filter(gf.to_q()))
|
||||
assert data["game"] in results
|
||||
assert data["game2"] not in results
|
||||
|
||||
def test_game_filter_device(self):
|
||||
from games.filters import GameFilter
|
||||
from games.models import Game
|
||||
|
||||
data = self._setup_entities()
|
||||
gf = GameFilter.from_json({
|
||||
"device": {"value": [data["dev"].id], "modifier": "INCLUDES"}
|
||||
})
|
||||
results = set(Game.objects.filter(gf.to_q()))
|
||||
assert data["game"] in results
|
||||
assert data["game2"] not in results
|
||||
|
||||
def test_game_filter_platform_group(self):
|
||||
from games.filters import GameFilter
|
||||
from games.models import Game
|
||||
|
||||
data = self._setup_entities()
|
||||
gf = GameFilter.from_json({
|
||||
"platform_group": {"value": ["Nintendo"], "modifier": "INCLUDES"}
|
||||
})
|
||||
results = set(Game.objects.filter(gf.to_q()))
|
||||
# both games are on the same Nintendo platform
|
||||
assert data["game"] in results
|
||||
assert data["game2"] in results
|
||||
|
||||
def test_game_filter_session_emulated(self):
|
||||
from games.filters import GameFilter
|
||||
from games.models import Game, Session
|
||||
import datetime
|
||||
from datetime import timedelta
|
||||
|
||||
data = self._setup_entities()
|
||||
Session.objects.create(
|
||||
game=data["game2"],
|
||||
device=data["dev"],
|
||||
timestamp_start=datetime.datetime(2026, 6, 2, 12, 0, 0, tzinfo=datetime.timezone.utc),
|
||||
timestamp_end=datetime.datetime(2026, 6, 2, 12, 30, 0, tzinfo=datetime.timezone.utc),
|
||||
duration_manual=timedelta(0),
|
||||
emulated=True,
|
||||
)
|
||||
gf = GameFilter.from_json({
|
||||
"session_emulated": {"value": True, "modifier": "EQUALS"}
|
||||
})
|
||||
results = set(Game.objects.filter(gf.to_q()))
|
||||
assert data["game2"] in results
|
||||
assert data["game"] not in results
|
||||
|
||||
def test_game_filter_purchase_refunded_and_infinite(self):
|
||||
from games.filters import GameFilter
|
||||
from games.models import Game, Purchase
|
||||
import datetime
|
||||
|
||||
data = self._setup_entities()
|
||||
# data["pur"] is infinite=True, non-refunded.
|
||||
gf_inf = GameFilter.from_json({
|
||||
"purchase_infinite": {"value": True, "modifier": "EQUALS"}
|
||||
})
|
||||
assert data["game"] in set(Game.objects.filter(gf_inf.to_q()))
|
||||
assert data["game2"] not in set(Game.objects.filter(gf_inf.to_q()))
|
||||
|
||||
# Add a refunded purchase for game2.
|
||||
refunded = Purchase.objects.create(
|
||||
platform=data["plat"],
|
||||
date_purchased=datetime.date(2026, 1, 1),
|
||||
date_refunded=datetime.date(2026, 2, 1),
|
||||
price=10.0,
|
||||
price_currency="USD",
|
||||
converted_price=10.0,
|
||||
converted_currency="USD",
|
||||
)
|
||||
refunded.games.add(data["game2"])
|
||||
gf_ref = GameFilter.from_json({
|
||||
"purchase_refunded": {"value": True, "modifier": "EQUALS"}
|
||||
})
|
||||
results = set(Game.objects.filter(gf_ref.to_q()))
|
||||
assert data["game2"] in results
|
||||
assert data["game"] not in results
|
||||
|
||||
def test_game_filter_purchase_type_and_ownership(self):
|
||||
from games.filters import GameFilter
|
||||
from games.models import Game
|
||||
|
||||
data = self._setup_entities()
|
||||
# data["pur"] defaults to type=game, ownership_type=digital
|
||||
gf = GameFilter.from_json({
|
||||
"purchase_type": {"value": ["game"], "modifier": "INCLUDES"}
|
||||
})
|
||||
assert data["game"] in set(Game.objects.filter(gf.to_q()))
|
||||
|
||||
gf = GameFilter.from_json({
|
||||
"purchase_ownership_type": {"value": ["di"], "modifier": "INCLUDES"}
|
||||
})
|
||||
assert data["game"] in set(Game.objects.filter(gf.to_q()))
|
||||
|
||||
def test_game_filter_purchase_price_any_and_total(self):
|
||||
from games.filters import GameFilter
|
||||
from games.models import Game
|
||||
|
||||
data = self._setup_entities()
|
||||
# data["pur"] has converted_price=45.00 linked to data["game"]
|
||||
gf_any = GameFilter.from_json({
|
||||
"purchase_price_any": {"value": 40.0, "value2": 50.0, "modifier": "BETWEEN"}
|
||||
})
|
||||
results = set(Game.objects.filter(gf_any.to_q()))
|
||||
assert data["game"] in results
|
||||
assert data["game2"] not in results
|
||||
|
||||
gf_total = GameFilter.from_json({
|
||||
"purchase_price_total": {"value": 40.0, "value2": 50.0, "modifier": "BETWEEN"}
|
||||
})
|
||||
results = set(Game.objects.filter(gf_total.to_q()))
|
||||
assert data["game"] in results
|
||||
assert data["game2"] not in results
|
||||
|
||||
def test_game_filter_playevent_note_includes(self):
|
||||
from games.filters import GameFilter
|
||||
from games.models import Game
|
||||
|
||||
data = self._setup_entities()
|
||||
# data["pe"] has note="Completed 100%" on data["game"]
|
||||
gf = GameFilter.from_json({
|
||||
"playevent_note": {
|
||||
"value": [{"id": "Completed", "label": "Completed"}],
|
||||
"modifier": "INCLUDES",
|
||||
}
|
||||
})
|
||||
results = set(Game.objects.filter(gf.to_q()))
|
||||
assert data["game"] in results
|
||||
assert data["game2"] not in results
|
||||
|
||||
def test_game_filter_manual_and_calculated_playtime(self):
|
||||
from games.filters import GameFilter
|
||||
from games.models import Game
|
||||
|
||||
data = self._setup_entities()
|
||||
# data["s1"] has 10 minutes manual + 30 minutes calculated
|
||||
gf_manual = GameFilter.from_json({
|
||||
"manual_playtime_minutes": {"value": 10, "modifier": "EQUALS"}
|
||||
})
|
||||
assert data["game"] in set(Game.objects.filter(gf_manual.to_q()))
|
||||
|
||||
gf_calc = GameFilter.from_json({
|
||||
"calculated_playtime_minutes": {"value": 30, "modifier": "EQUALS"}
|
||||
})
|
||||
assert data["game"] in set(Game.objects.filter(gf_calc.to_q()))
|
||||
|
||||
@@ -60,3 +60,16 @@ class PathWorksTest(TestCase):
|
||||
def test_list_purchases_returns_200(self):
|
||||
response = self.client.get(reverse("games:list_purchases"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_platform_groups_api_returns_200(self):
|
||||
# Distinct platform groups are returned as string-valued options.
|
||||
Platform.objects.create(name="Switch", icon="switch", group="Nintendo")
|
||||
response = self.client.get("/api/platforms/groups")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = response.json()
|
||||
groups = {item["value"] for item in body}
|
||||
self.assertIn("Nintendo", groups)
|
||||
|
||||
filtered = self.client.get("/api/platforms/groups?q=nin")
|
||||
self.assertEqual(filtered.status_code, 200)
|
||||
self.assertEqual({item["value"] for item in filtered.json()}, {"Nintendo"})
|
||||
|
||||
Reference in New Issue
Block a user