Add more filters

This commit is contained in:
2026-06-09 17:19:09 +02:00
parent ad5c8d3bb1
commit 0179363684
9 changed files with 629 additions and 35 deletions
+173 -7
View File
@@ -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)
+9 -1
View File
@@ -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:
+15
View File
@@ -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
View File
@@ -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 ──────────────────────────────────────────────────────────
+11 -3
View File
@@ -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 }
];
+25 -1
View File
@@ -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;
+38
View File
@@ -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
View File
@@ -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()))
+13
View File
@@ -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"})