From 02852431723db04a91f3e43dead29b44a32bec57 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Jun 2026 17:18:38 +0000 Subject: [PATCH] Embed labels in filter criteria (Stash-style) to retire pill resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Store {id, label} objects instead of bare IDs in MultiCriterion value/excludes. FilterSelect pills now render directly from the embedded labels — no DB round-trip to _resolve_game/device/platform_options. The filter URL and saved presets are self-describing. MultiCriterion.to_q() extracts ids for querying; bare ints are still accepted for backward compatibility. Closes #9 https://claude.ai/code/session_01EyAJcMoDktLrY9tSbdHViA --- common/components/filters.py | 81 ++++++++++++-------------------- common/criteria.py | 48 ++++++++++++++++--- games/static/js/filter_bar.js | 32 +++++++++++-- games/static/js/search_select.js | 6 ++- 4 files changed, 104 insertions(+), 63 deletions(-) diff --git a/common/components/filters.py b/common/components/filters.py index 783d8e3..099aa25 100644 --- a/common/components/filters.py +++ b/common/components/filters.py @@ -12,10 +12,15 @@ from common.components.search_select import FilterSelect class FilterChoice(NamedTuple): - """Parsed include/exclude/modifier state of a filter field from filter JSON.""" + """Parsed include/exclude/modifier state of a filter field from filter JSON. - selected: list[str] - excluded: list[str] + ``selected`` and ``excluded`` are lists of ``(value, label)`` pairs. For + model-backed fields the label is embedded in the filter JSON (Stash-style); + for enum fields the label is resolved from the fixed option list. + """ + + selected: list[tuple[str, str]] + excluded: list[tuple[str, str]] modifier: str @@ -50,6 +55,17 @@ def _filter_parse(filter_json: str) -> dict: return {} +def _extract_labeled(items: list) -> list[tuple[str, str]]: + """Convert a list of bare values or ``{id, label}`` dicts to ``(value, label)`` pairs.""" + result = [] + for item in items: + if isinstance(item, dict): + result.append((str(item.get("id", "")), str(item.get("label", "")))) + else: + result.append((str(item), "")) + return result + + def _filter_get_choice(existing: dict, field: str) -> FilterChoice: raw = existing.get(field, {}) if not isinstance(raw, dict): @@ -62,8 +78,8 @@ def _filter_get_choice(existing: dict, field: str) -> FilterChoice: if isinstance(excluded, str): excluded = [excluded] return FilterChoice( - selected=[str(v) for v in (value or [])], - excluded=[str(v) for v in (excluded or [])], + selected=_extract_labeled(value or []), + excluded=_extract_labeled(excluded or []), modifier=modifier or "", ) @@ -100,46 +116,13 @@ def _modifier_options(nullable: bool) -> list[tuple[str, str]]: return options -def _resolve_game_options(ids): - if not ids: - return [] - from games.models import Game - - return [ - {"value": game.id, "label": game.search_label} - for game in Game.objects.filter(pk__in=ids) - ] - - -def _resolve_device_options(ids): - if not ids: - return [] - from games.models import Device - - return [ - {"value": device.id, "label": device.name} - for device in Device.objects.filter(pk__in=ids) - ] - - -def _resolve_platform_options(ids): - if not ids: - return [] - from games.models import Platform - - return [ - {"value": platform.id, "label": platform.name} - for platform in Platform.objects.filter(pk__in=ids) - ] - - def _enum_filter( field_name: str, options, choice: FilterChoice, *, nullable ) -> SafeText: """A FilterSelect over a small, fully pre-rendered option set (enum field).""" options_str = [(str(value), label) for value, label in options] - included = [(value, _find_label(options_str, value)) for value in choice.selected] - excluded = [(value, _find_label(options_str, value)) for value in choice.excluded] + included = [(value, _find_label(options_str, value)) for value, _label in choice.selected] + excluded = [(value, _find_label(options_str, value)) for value, _label in choice.excluded] return FilterSelect( field_name=field_name, options=options_str, @@ -151,14 +134,17 @@ def _enum_filter( def _model_filter( - field_name: str, choice: FilterChoice, *, search_url, resolver, nullable + field_name: str, choice: FilterChoice, *, search_url, nullable ) -> SafeText: - """A FilterSelect backed by a search endpoint; only selected ids are resolved - to labels (for the pills) — the option rows are fetched on demand.""" + """A FilterSelect backed by a search endpoint. + + Labels are embedded in the filter JSON (Stash-style), so pills render + directly from ``choice`` with no DB round-trip. + """ return FilterSelect( field_name=field_name, - included=list(resolver(choice.selected)), - excluded=list(resolver(choice.excluded)), + included=[(value, label or value) for value, label in choice.selected], + excluded=[(value, label or value) for value, label in choice.excluded], modifier=choice.modifier, modifier_options=_modifier_options(nullable), search_url=search_url, @@ -694,7 +680,6 @@ def FilterBar( "platform", platform_choice, search_url="/api/platforms/search", - resolver=_resolve_platform_options, nullable=Game._meta.get_field("platform").null, ), ), @@ -778,7 +763,6 @@ def SessionFilterBar( "game", game_choice, search_url="/api/games/search", - resolver=_resolve_game_options, nullable=not Game._meta.get_field("name").has_default(), ), ), @@ -788,7 +772,6 @@ def SessionFilterBar( "device", device_choice, search_url="/api/devices/search", - resolver=_resolve_device_options, nullable=Session._meta.get_field("device").null, ), ), @@ -851,7 +834,6 @@ def PurchaseFilterBar( "games", game_choice, search_url="/api/games/search", - resolver=_resolve_game_options, nullable=False, ), ), @@ -861,7 +843,6 @@ def PurchaseFilterBar( "platform", platform_choice, search_url="/api/platforms/search", - resolver=_resolve_platform_options, nullable=Purchase._meta.get_field("platform").null, ), ), diff --git a/common/criteria.py b/common/criteria.py index 51d2de6..64d400a 100644 --- a/common/criteria.py +++ b/common/criteria.py @@ -313,18 +313,54 @@ class _SetCriterion(_Criterion): @dataclass class MultiCriterion(_SetCriterion): - """Filter on a many-to-many or ForeignKey relationship by ID list.""" + """Filter on a many-to-many or ForeignKey relationship by ID list. - value: list[int] = field(default_factory=list) - excludes: list[int] = field(default_factory=list) + Each entry in ``value`` and ``excludes`` may be either a bare integer id or + a ``{"id": , "label": }`` dict (Stash-style embedded label). The + label is display-only; only the id is used for querying. + """ + + value: list = field(default_factory=list) + excludes: list = field(default_factory=list) + + def _ids(self, items: list) -> list[int]: + """Extract integer ids from a mixed list of bare ints or {id, label} dicts.""" + result = [] + for item in items: + if isinstance(item, dict): + result.append(int(item["id"])) + else: + result.append(int(item)) + return result + + def to_q(self, field_name: str) -> Q: + modifier = self.modifier + value_ids = self._ids(self.value) + excludes_ids = self._ids(self.excludes) + if modifier in (Modifier.INCLUDES, Modifier.EQUALS): + q = Q() + if value_ids: + q &= Q(**{f"{field_name}__in": value_ids}) + if excludes_ids: + q &= ~Q(**{f"{field_name}__in": excludes_ids}) + return q + if modifier == Modifier.IS_NULL: + return Q(**{f"{field_name}__isnull": True}) + if modifier == Modifier.NOT_NULL: + return Q(**{f"{field_name}__isnull": False}) + extra = self._extra_q(field_name) + if extra is not None: + return extra + raise ValueError(f"Unsupported modifier {modifier} for MultiCriterion") def _extra_q(self, field_name: str) -> Q | None: + value_ids = self._ids(self.value) if self.modifier == Modifier.EXCLUDES: - return ~Q(**{f"{field_name}__in": self.value}) + return ~Q(**{f"{field_name}__in": value_ids}) if self.modifier == Modifier.INCLUDES_ALL: q = Q() - for value in self.value: - q &= Q(**{field_name: value}) + for id_val in value_ids: + q &= Q(**{field_name: id_val}) return q return None diff --git a/games/static/js/filter_bar.js b/games/static/js/filter_bar.js index c363565..64dcc23 100644 --- a/games/static/js/filter_bar.js +++ b/games/static/js/filter_bar.js @@ -76,11 +76,33 @@ field === "game" || field === "device" || field === "games"; - filter[field] = { - value: isIdField ? included.map(Number) : included, - excludes: isIdField ? excluded.map(Number) : excluded, - modifier: modifier || "INCLUDES", - }; + if (isIdField) { + // Store {id, label} objects so the filter URL/preset is self-describing + // and pills can be rendered without a DB lookup (Stash-style). + filter[field] = { + value: included.map(function (item) { + return typeof item === "object" + ? {id: parseInt(item.id, 10), label: item.label || ""} + : {id: parseInt(item, 10), label: ""}; + }), + excludes: excluded.map(function (item) { + return typeof item === "object" + ? {id: parseInt(item.id, 10), label: item.label || ""} + : {id: parseInt(item, 10), label: ""}; + }), + modifier: modifier || "INCLUDES", + }; + } else { + filter[field] = { + value: included.map(function (item) { + return typeof item === "object" ? item.id : item; + }), + excludes: excluded.map(function (item) { + return typeof item === "object" ? item.id : item; + }), + modifier: modifier || "INCLUDES", + }; + } } }); diff --git a/games/static/js/search_select.js b/games/static/js/search_select.js index 9888175..a476a7c 100644 --- a/games/static/js/search_select.js +++ b/games/static/js/search_select.js @@ -435,10 +435,12 @@ return; } var value = pill.getAttribute("data-value"); + var label = pill.getAttribute("data-label") || ""; + var entry = label ? {id: value, label: label} : value; if (pill.getAttribute("data-search-select-type") === "exclude") { - excluded.push(value); + excluded.push(entry); } else { - included.push(value); + included.push(entry); } }); }