diff --git a/common/components/filters.py b/common/components/filters.py index 9cccd6c..20261eb 100644 --- a/common/components/filters.py +++ b/common/components/filters.py @@ -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) diff --git a/common/components/search_select.py b/common/components/search_select.py index 2e58ec7..9755592 100644 --- a/common/components/search_select.py +++ b/common/components/search_select.py @@ -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: diff --git a/games/api.py b/games/api.py index edd6e10..a33bb8b 100644 --- a/games/api.py +++ b/games/api.py @@ -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) diff --git a/games/filters.py b/games/filters.py index 10b977e..6c3531e 100644 --- a/games/filters.py +++ b/games/filters.py @@ -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 ────────────────────────────────────────────────────────── diff --git a/games/static/js/filter_bar.js b/games/static/js/filter_bar.js index dfd5371..ac43cf8 100644 --- a/games/static/js/filter_bar.js +++ b/games/static/js/filter_bar.js @@ -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 } ]; diff --git a/games/static/js/search_select.js b/games/static/js/search_select.js index a9a8894..40a2273 100644 --- a/games/static/js/search_select.js +++ b/games/static/js/search_select.js @@ -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; diff --git a/tests/test_filter_bars.py b/tests/test_filter_bars.py index 1125a67..a555ab1 100644 --- a/tests/test_filter_bars.py +++ b/tests/test_filter_bars.py @@ -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) diff --git a/tests/test_filters.py b/tests/test_filters.py index 04746e9..b430fb2 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -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())) diff --git a/tests/test_paths_return_200.py b/tests/test_paths_return_200.py index 94ca181..3fd971f 100644 --- a/tests/test_paths_return_200.py +++ b/tests/test_paths_return_200.py @@ -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"})