Add more filters
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user