Embed labels in filter criteria (Stash-style) to retire pill resolver
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
This commit is contained in:
@@ -12,10 +12,15 @@ from common.components.search_select import FilterSelect
|
|||||||
|
|
||||||
|
|
||||||
class FilterChoice(NamedTuple):
|
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]
|
``selected`` and ``excluded`` are lists of ``(value, label)`` pairs. For
|
||||||
excluded: list[str]
|
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
|
modifier: str
|
||||||
|
|
||||||
|
|
||||||
@@ -50,6 +55,17 @@ def _filter_parse(filter_json: str) -> dict:
|
|||||||
return {}
|
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:
|
def _filter_get_choice(existing: dict, field: str) -> FilterChoice:
|
||||||
raw = existing.get(field, {})
|
raw = existing.get(field, {})
|
||||||
if not isinstance(raw, dict):
|
if not isinstance(raw, dict):
|
||||||
@@ -62,8 +78,8 @@ def _filter_get_choice(existing: dict, field: str) -> FilterChoice:
|
|||||||
if isinstance(excluded, str):
|
if isinstance(excluded, str):
|
||||||
excluded = [excluded]
|
excluded = [excluded]
|
||||||
return FilterChoice(
|
return FilterChoice(
|
||||||
selected=[str(v) for v in (value or [])],
|
selected=_extract_labeled(value or []),
|
||||||
excluded=[str(v) for v in (excluded or [])],
|
excluded=_extract_labeled(excluded or []),
|
||||||
modifier=modifier or "",
|
modifier=modifier or "",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -100,46 +116,13 @@ def _modifier_options(nullable: bool) -> list[tuple[str, str]]:
|
|||||||
return options
|
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(
|
def _enum_filter(
|
||||||
field_name: str, options, choice: FilterChoice, *, nullable
|
field_name: str, options, choice: FilterChoice, *, nullable
|
||||||
) -> SafeText:
|
) -> SafeText:
|
||||||
"""A FilterSelect over a small, fully pre-rendered option set (enum field)."""
|
"""A FilterSelect over a small, fully pre-rendered option set (enum field)."""
|
||||||
options_str = [(str(value), label) for value, label in options]
|
options_str = [(str(value), label) for value, label in options]
|
||||||
included = [(value, _find_label(options_str, value)) for value in choice.selected]
|
included = [(value, _find_label(options_str, value)) for value, _label in choice.selected]
|
||||||
excluded = [(value, _find_label(options_str, value)) for value in choice.excluded]
|
excluded = [(value, _find_label(options_str, value)) for value, _label in choice.excluded]
|
||||||
return FilterSelect(
|
return FilterSelect(
|
||||||
field_name=field_name,
|
field_name=field_name,
|
||||||
options=options_str,
|
options=options_str,
|
||||||
@@ -151,14 +134,17 @@ def _enum_filter(
|
|||||||
|
|
||||||
|
|
||||||
def _model_filter(
|
def _model_filter(
|
||||||
field_name: str, choice: FilterChoice, *, search_url, resolver, nullable
|
field_name: str, choice: FilterChoice, *, search_url, nullable
|
||||||
) -> SafeText:
|
) -> SafeText:
|
||||||
"""A FilterSelect backed by a search endpoint; only selected ids are resolved
|
"""A FilterSelect backed by a search endpoint.
|
||||||
to labels (for the pills) — the option rows are fetched on demand."""
|
|
||||||
|
Labels are embedded in the filter JSON (Stash-style), so pills render
|
||||||
|
directly from ``choice`` with no DB round-trip.
|
||||||
|
"""
|
||||||
return FilterSelect(
|
return FilterSelect(
|
||||||
field_name=field_name,
|
field_name=field_name,
|
||||||
included=list(resolver(choice.selected)),
|
included=[(value, label or value) for value, label in choice.selected],
|
||||||
excluded=list(resolver(choice.excluded)),
|
excluded=[(value, label or value) for value, label in choice.excluded],
|
||||||
modifier=choice.modifier,
|
modifier=choice.modifier,
|
||||||
modifier_options=_modifier_options(nullable),
|
modifier_options=_modifier_options(nullable),
|
||||||
search_url=search_url,
|
search_url=search_url,
|
||||||
@@ -694,7 +680,6 @@ def FilterBar(
|
|||||||
"platform",
|
"platform",
|
||||||
platform_choice,
|
platform_choice,
|
||||||
search_url="/api/platforms/search",
|
search_url="/api/platforms/search",
|
||||||
resolver=_resolve_platform_options,
|
|
||||||
nullable=Game._meta.get_field("platform").null,
|
nullable=Game._meta.get_field("platform").null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -778,7 +763,6 @@ def SessionFilterBar(
|
|||||||
"game",
|
"game",
|
||||||
game_choice,
|
game_choice,
|
||||||
search_url="/api/games/search",
|
search_url="/api/games/search",
|
||||||
resolver=_resolve_game_options,
|
|
||||||
nullable=not Game._meta.get_field("name").has_default(),
|
nullable=not Game._meta.get_field("name").has_default(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -788,7 +772,6 @@ def SessionFilterBar(
|
|||||||
"device",
|
"device",
|
||||||
device_choice,
|
device_choice,
|
||||||
search_url="/api/devices/search",
|
search_url="/api/devices/search",
|
||||||
resolver=_resolve_device_options,
|
|
||||||
nullable=Session._meta.get_field("device").null,
|
nullable=Session._meta.get_field("device").null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -851,7 +834,6 @@ def PurchaseFilterBar(
|
|||||||
"games",
|
"games",
|
||||||
game_choice,
|
game_choice,
|
||||||
search_url="/api/games/search",
|
search_url="/api/games/search",
|
||||||
resolver=_resolve_game_options,
|
|
||||||
nullable=False,
|
nullable=False,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -861,7 +843,6 @@ def PurchaseFilterBar(
|
|||||||
"platform",
|
"platform",
|
||||||
platform_choice,
|
platform_choice,
|
||||||
search_url="/api/platforms/search",
|
search_url="/api/platforms/search",
|
||||||
resolver=_resolve_platform_options,
|
|
||||||
nullable=Purchase._meta.get_field("platform").null,
|
nullable=Purchase._meta.get_field("platform").null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
+42
-6
@@ -313,18 +313,54 @@ class _SetCriterion(_Criterion):
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class MultiCriterion(_SetCriterion):
|
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)
|
Each entry in ``value`` and ``excludes`` may be either a bare integer id or
|
||||||
excludes: list[int] = field(default_factory=list)
|
a ``{"id": <int>, "label": <str>}`` 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:
|
def _extra_q(self, field_name: str) -> Q | None:
|
||||||
|
value_ids = self._ids(self.value)
|
||||||
if self.modifier == Modifier.EXCLUDES:
|
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:
|
if self.modifier == Modifier.INCLUDES_ALL:
|
||||||
q = Q()
|
q = Q()
|
||||||
for value in self.value:
|
for id_val in value_ids:
|
||||||
q &= Q(**{field_name: value})
|
q &= Q(**{field_name: id_val})
|
||||||
return q
|
return q
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -76,11 +76,33 @@
|
|||||||
field === "game" ||
|
field === "game" ||
|
||||||
field === "device" ||
|
field === "device" ||
|
||||||
field === "games";
|
field === "games";
|
||||||
|
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] = {
|
filter[field] = {
|
||||||
value: isIdField ? included.map(Number) : included,
|
value: included.map(function (item) {
|
||||||
excludes: isIdField ? excluded.map(Number) : excluded,
|
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",
|
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",
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -435,10 +435,12 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var value = pill.getAttribute("data-value");
|
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") {
|
if (pill.getAttribute("data-search-select-type") === "exclude") {
|
||||||
excluded.push(value);
|
excluded.push(entry);
|
||||||
} else {
|
} else {
|
||||||
included.push(value);
|
included.push(entry);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user