Add filters
Django CI/CD / test (push) Successful in 43s
Django CI/CD / build-and-push (push) Successful in 1m22s

This commit is contained in:
2026-06-06 12:13:04 +02:00
parent 36b1382015
commit b6864e59ce
17 changed files with 2743 additions and 44 deletions
+384
View File
@@ -0,0 +1,384 @@
"""
Entity-specific filter types for the timetracker app.
Each filter class mirrors a Django model, with fields expressed as typed
criteria from common.criteria. The to_q() method produces a Django Q object
ready for queryset.filter().
Inspired by Stash's filter architecture: each entity has an OperatorFilter
with AND/OR/NOT composition and typed criterion fields.
"""
from __future__ import annotations
from dataclasses import dataclass
from common.criteria import (
BoolCriterion,
ChoiceCriterion,
FloatCriterion,
IntCriterion,
Modifier,
MultiCriterion,
OperatorFilter,
StringCriterion,
filter_from_json,
)
# ── FindFilter (sort / pagination) ─────────────────────────────────────────
@dataclass
class FindFilter:
"""Sorting and pagination, separate from filtering criteria (Stash-style)."""
q: str | None = None # free-text search
page: int = 1
per_page: int = 25
sort: str | None = None # e.g. "-created_at"
direction: str = "desc" # asc / desc
# ── GameFilter ─────────────────────────────────────────────────────────────
@dataclass
class GameFilter(OperatorFilter):
"""Filter for the Game model."""
AND: GameFilter | None = None
OR: GameFilter | None = None
NOT: GameFilter | None = None
name: StringCriterion | None = None
sort_name: StringCriterion | None = None
year_released: IntCriterion | None = None
original_year_released: IntCriterion | None = None
wikidata: StringCriterion | None = None
platform: ChoiceCriterion | None = None # selectable filter widget
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
# Free-text search (combines name + sort_name + platform name)
search: StringCriterion | None = None
def to_q(self) -> "Q": # type: ignore[no-any-unimported]
from django.db.models import Q
q = Q()
# ── individual criteria ──
if self.name is not None:
q &= self.name.to_q("name")
if self.sort_name is not None:
q &= self.sort_name.to_q("sort_name")
if self.year_released is not None:
q &= self.year_released.to_q("year_released")
if self.original_year_released is not None:
q &= self.original_year_released.to_q("original_year_released")
if self.wikidata is not None:
q &= self.wikidata.to_q("wikidata")
if self.platform is not None:
q &= self.platform.to_q("platform_id")
if self.status is not None:
q &= self.status.to_q("status")
if self.mastered is not None:
q &= self.mastered.to_q("mastered")
if self.playtime_minutes is not None:
q &= self._playtime_to_q(self.playtime_minutes)
if self.created_at is not None:
q &= self.created_at.to_q("created_at")
if self.updated_at is not None:
q &= self.updated_at.to_q("updated_at")
# ── free-text search (OR across multiple fields) ──
if self.search is not None and self.search.value:
search_q = (
Q(name__icontains=self.search.value)
| Q(sort_name__icontains=self.search.value)
| Q(platform__name__icontains=self.search.value)
)
if self.search.modifier == Modifier.EXCLUDES:
search_q = ~search_q
q &= search_q
# ── AND / OR / NOT sub-filters ──
sub = self.sub_filter()
if sub is not None:
if self.AND is not None:
q &= sub.to_q()
elif self.OR is not None:
q |= sub.to_q()
elif self.NOT is not None:
q &= ~sub.to_q()
return q
@staticmethod
def _playtime_to_q(c: IntCriterion) -> "Q": # type: ignore[no-any-unimported]
"""Convert minutes-based criterion to a DurationField Q object.
Django stores DurationField as microseconds in SQLite, so we convert
minutes → timedelta(microseconds=X) and use the appropriate lookups.
"""
from datetime import timedelta
from common.criteria import Modifier
from django.db.models import Q
m = c.modifier
field = "playtime"
td_val = timedelta(minutes=c.value)
if m == Modifier.EQUALS:
return Q(**{f"{field}__gte": td_val, f"{field}__lt": timedelta(minutes=c.value + 1)})
if m == Modifier.NOT_EQUALS:
return ~Q(**{f"{field}__gte": td_val, f"{field}__lt": timedelta(minutes=c.value + 1)})
if m == Modifier.GREATER_THAN:
return Q(**{f"{field}__gt": td_val})
if m == Modifier.LESS_THAN:
return Q(**{f"{field}__lt": td_val})
if m == Modifier.BETWEEN and c.value2 is not None:
lo = timedelta(minutes=min(c.value, c.value2))
hi = timedelta(minutes=max(c.value, c.value2))
return Q(**{f"{field}__gte": lo, f"{field}__lte": hi})
if m == Modifier.NOT_BETWEEN and c.value2 is not None:
lo = timedelta(minutes=min(c.value, c.value2))
hi = timedelta(minutes=max(c.value, c.value2))
return Q(**{f"{field}__lt": lo}) | Q(**{f"{field}__gt": hi})
if m == Modifier.IS_NULL:
return Q(**{f"{field}": timedelta(0)})
if m == Modifier.NOT_NULL:
return ~Q(**{f"{field}": timedelta(0)})
return Q()
# ── SessionFilter ──────────────────────────────────────────────────────────
@dataclass
class SessionFilter(OperatorFilter):
"""Filter for the Session model."""
AND: SessionFilter | None = None
OR: SessionFilter | None = None
NOT: SessionFilter | None = None
game: MultiCriterion | None = None # filters on game_id
device: MultiCriterion | None = None # filters on device_id
emulated: BoolCriterion | None = None
note: StringCriterion | None = None
duration_minutes: IntCriterion | None = None # on duration_total
is_active: BoolCriterion | None = None # timestamp_end IS NULL
timestamp_start: StringCriterion | None = None # date string
timestamp_end: StringCriterion | None = None # date string
is_manual: BoolCriterion | None = None # duration_manual > 0
created_at: StringCriterion | None = None
# Free-text search
search: StringCriterion | None = None
# Cross-entity: sessions for games matching these criteria
game_filter: GameFilter | None = None
def to_q(self) -> "Q": # type: ignore[no-any-unimported]
from datetime import timedelta
from django.db.models import Q
q = Q()
if self.game is not None:
q &= self.game.to_q("game_id")
if self.device is not None:
q &= self.device.to_q("device_id")
if self.emulated is not None:
q &= self.emulated.to_q("emulated")
if self.note is not None:
q &= self.note.to_q("note")
if self.duration_minutes is not None:
c = self.duration_minutes
td_val = timedelta(minutes=c.value)
field = "duration_total"
m = c.modifier
if m == Modifier.EQUALS:
q &= Q(**{f"{field}__gte": td_val, f"{field}__lt": timedelta(minutes=c.value + 1)})
elif m == Modifier.NOT_EQUALS:
q &= ~Q(**{f"{field}__gte": td_val, f"{field}__lt": timedelta(minutes=c.value + 1)})
elif m == Modifier.GREATER_THAN:
q &= Q(**{f"{field}__gt": td_val})
elif m == Modifier.LESS_THAN:
q &= Q(**{f"{field}__lt": td_val})
elif m == Modifier.BETWEEN and c.value2 is not None:
lo = timedelta(minutes=min(c.value, c.value2))
hi = timedelta(minutes=max(c.value, c.value2))
q &= Q(**{f"{field}__gte": lo, f"{field}__lte": hi})
elif m == Modifier.NOT_BETWEEN and c.value2 is not None:
lo = timedelta(minutes=min(c.value, c.value2))
hi = timedelta(minutes=max(c.value, c.value2))
q &= Q(**{f"{field}__lt": lo}) | Q(**{f"{field}__gt": hi})
elif m == Modifier.IS_NULL:
q &= Q(**{f"{field}": timedelta(0)})
elif m == Modifier.NOT_NULL:
q &= ~Q(**{f"{field}": timedelta(0)})
if self.is_active is not None:
if self.is_active.value:
q &= Q(timestamp_end__isnull=True)
else:
q &= Q(timestamp_end__isnull=False)
if self.timestamp_start is not None:
q &= self.timestamp_start.to_q("timestamp_start")
if self.timestamp_end is not None:
q &= self.timestamp_end.to_q("timestamp_end")
if self.is_manual is not None:
if self.is_manual.value:
q &= ~Q(duration_manual=timedelta(0))
else:
q &= Q(duration_manual=timedelta(0))
if self.created_at is not None:
q &= self.created_at.to_q("created_at")
# Free-text search
if self.search is not None and self.search.value:
search_q = (
Q(game__name__icontains=self.search.value)
| Q(game__platform__name__icontains=self.search.value)
| Q(device__name__icontains=self.search.value)
| Q(device__type__icontains=self.search.value)
)
if self.search.modifier == Modifier.EXCLUDES:
search_q = ~search_q
q &= search_q
# Cross-entity filter: sessions for games matching GameFilter
if self.game_filter is not None:
from games.models import Game
game_q = self.game_filter.to_q()
matching_ids = Game.objects.filter(game_q).values_list("id", flat=True)
q &= Q(game_id__in=matching_ids)
# AND / OR / NOT
sub = self.sub_filter()
if sub is not None:
if self.AND is not None:
q &= sub.to_q()
elif self.OR is not None:
q |= sub.to_q()
elif self.NOT is not None:
q &= ~sub.to_q()
return q
# ── PurchaseFilter ─────────────────────────────────────────────────────────
@dataclass
class PurchaseFilter(OperatorFilter):
"""Filter for the Purchase model."""
AND: PurchaseFilter | None = None
OR: PurchaseFilter | None = None
NOT: PurchaseFilter | None = None
name: StringCriterion | None = None
platform: ChoiceCriterion | None = None # platform_id
games: ChoiceCriterion | None = None # games (M2M IDs)
date_purchased: StringCriterion | None = None # date string
date_refunded: StringCriterion | None = None # date string
is_refunded: BoolCriterion | None = None # date_refunded IS NOT NULL
price: FloatCriterion | None = None # on price field
converted_price: FloatCriterion | None = None
price_currency: StringCriterion | None = None
num_purchases: IntCriterion | None = None
ownership_type: ChoiceCriterion | None = None # ph/di/du/re/bo/tr/de/pi
type: ChoiceCriterion | None = None # game/dlc/season_pass/battle_pass
created_at: StringCriterion | None = None
updated_at: StringCriterion | None = None
# Free-text search
search: StringCriterion | None = None
# Cross-entity: purchases for games matching these criteria
game_filter: GameFilter | None = None
def to_q(self) -> "Q": # type: ignore[no-any-unimported]
from django.db.models import Q
q = Q()
if self.name is not None:
q &= self.name.to_q("name")
if self.platform is not None:
q &= self.platform.to_q("platform_id")
if self.games is not None:
q &= self.games.to_q("games")
if self.date_purchased is not None:
q &= self.date_purchased.to_q("date_purchased")
if self.date_refunded is not None:
q &= self.date_refunded.to_q("date_refunded")
if self.is_refunded is not None:
q &= Q(date_refunded__isnull=not self.is_refunded.value)
if self.price is not None:
q &= self.price.to_q("price")
if self.converted_price is not None:
q &= self.converted_price.to_q("converted_price")
if self.price_currency is not None:
q &= self.price_currency.to_q("price_currency")
if self.num_purchases is not None:
q &= self.num_purchases.to_q("num_purchases")
if self.ownership_type is not None:
q &= self.ownership_type.to_q("ownership_type")
if self.type is not None:
q &= self.type.to_q("type")
if self.created_at is not None:
q &= self.created_at.to_q("created_at")
if self.updated_at is not None:
q &= self.updated_at.to_q("updated_at")
# Free-text search
if self.search is not None and self.search.value:
search_q = (
Q(name__icontains=self.search.value)
| Q(games__name__icontains=self.search.value)
| Q(platform__name__icontains=self.search.value)
)
if self.search.modifier == Modifier.EXCLUDES:
search_q = ~search_q
q &= search_q
# Cross-entity filter
if self.game_filter is not None:
from games.models import Game
game_q = self.game_filter.to_q()
matching_ids = Game.objects.filter(game_q).values_list("id", flat=True)
q &= Q(games__id__in=matching_ids)
sub = self.sub_filter()
if sub is not None:
if self.AND is not None:
q &= sub.to_q()
elif self.OR is not None:
q |= sub.to_q()
elif self.NOT is not None:
q &= ~sub.to_q()
return q
# ── Convenience helpers ────────────────────────────────────────────────────
def parse_game_filter(json_str: str) -> GameFilter | None:
return filter_from_json(GameFilter, json_str)
def parse_session_filter(json_str: str) -> SessionFilter | None:
return filter_from_json(SessionFilter, json_str)
def parse_purchase_filter(json_str: str) -> PurchaseFilter | None:
return filter_from_json(PurchaseFilter, json_str)
+2 -1
View File
@@ -43,7 +43,7 @@ class SessionForm(forms.ModelForm):
),
label="Manual duration",
)
device = forms.ModelChoiceField(queryset=Device.objects.order_by("name"))
device = forms.ModelChoiceField(queryset=Device.objects.order_by("name"), required=False)
mark_as_played = forms.BooleanField(
required=False,
@@ -104,6 +104,7 @@ class PurchaseForm(forms.ModelForm):
"hx-swap": "outerHTML",
}
)
self.fields["platform"].queryset = Platform.objects.order_by("name")
games = MultipleGameChoiceField(
queryset=Game.objects.order_by("sort_name"),
@@ -0,0 +1,29 @@
# Generated by Django 6.0.1 on 2026-06-06 07:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0016_add_needs_price_update'),
]
operations = [
migrations.CreateModel(
name='FilterPreset',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('mode', models.CharField(choices=[('games', 'Games'), ('sessions', 'Sessions'), ('purchases', 'Purchases'), ('playevents', 'Play Events')], default='games', max_length=50)),
('find_filter', models.JSONField(blank=True, default=dict)),
('object_filter', models.JSONField(blank=True, default=dict)),
('ui_options', models.JSONField(blank=True, default=dict)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'ordering': ['name'],
},
),
]
+30
View File
@@ -478,3 +478,33 @@ class GameStatusChange(models.Model):
class Meta:
ordering = ["-timestamp"]
class FilterPreset(models.Model):
"""Saved filter configuration, following Stash's SavedFilter pattern.
Separates find_filter (sort/pagination), object_filter (criteria JSON),
and ui_options (presentation state) so they can evolve independently.
"""
class Meta:
ordering = ["name"]
MODE_CHOICES = [
("games", "Games"),
("sessions", "Sessions"),
("purchases", "Purchases"),
("playevents", "Play Events"),
]
name = models.CharField(max_length=255)
mode = models.CharField(max_length=50, choices=MODE_CHOICES, default="games")
find_filter = models.JSONField(default=dict, blank=True)
object_filter = models.JSONField(default=dict, blank=True)
ui_options = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f"{self.name} ({self.get_mode_display()})"
+161 -2
View File
@@ -826,6 +826,9 @@
.top-0 {
top: calc(var(--spacing) * 0);
}
.top-1\/2 {
top: calc(1 / 2 * 100%);
}
.top-3 {
top: calc(var(--spacing) * 3);
}
@@ -1273,6 +1276,9 @@
margin-left: -10px !important;
}
}
.ml-4 {
margin-left: calc(var(--spacing) * 4);
}
.ml-auto {
margin-left: auto;
}
@@ -1431,6 +1437,9 @@
width: calc(var(--spacing) * 6);
height: calc(var(--spacing) * 6);
}
.h-2 {
height: calc(var(--spacing) * 2);
}
.h-2\.5 {
height: calc(var(--spacing) * 2.5);
}
@@ -1461,9 +1470,15 @@
.h-full {
height: 100%;
}
.max-h-40 {
max-height: calc(var(--spacing) * 40);
}
.max-h-full {
max-height: 100%;
}
.min-h-\[28px\] {
min-height: 28px;
}
.min-h-screen {
min-height: 100vh;
}
@@ -1656,6 +1671,10 @@
--tw-translate-x: 100%;
translate: var(--tw-translate-x) var(--tw-translate-y);
}
.-translate-y-1\/2 {
--tw-translate-y: calc(calc(1 / 2 * 100%) * -1);
translate: var(--tw-translate-x) var(--tw-translate-y);
}
.-translate-y-full {
--tw-translate-y: -100%;
translate: var(--tw-translate-x) var(--tw-translate-y);
@@ -1694,6 +1713,12 @@
.list-disc {
list-style-type: disc;
}
.appearance-none {
appearance: none;
}
.grid-cols-1 {
grid-template-columns: repeat(1, minmax(0, 1fr));
}
.grid-cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
@@ -1826,6 +1851,9 @@
.rounded-base {
border-radius: var(--radius-base);
}
.rounded-full {
border-radius: calc(infinity * 1px);
}
.rounded-lg {
border-radius: var(--radius-lg);
}
@@ -1888,6 +1916,10 @@
border-style: var(--tw-border-style) !important;
border-width: 0px !important;
}
.border-2 {
border-style: var(--tw-border-style);
border-width: 2px;
}
.border-e {
border-inline-end-style: var(--tw-border-style);
border-inline-end-width: 1px;
@@ -1984,9 +2016,15 @@
.border-red-200 {
border-color: var(--color-red-200);
}
.border-red-500 {
border-color: var(--color-red-500);
}
.border-transparent {
border-color: transparent;
}
.border-white {
border-color: var(--color-white);
}
.apexcharts-active {
.apexcharts-canvas .apexcharts-tooltip-series-group& .apexcharts-tooltip-y-group {
padding: 0 !important;
@@ -2090,6 +2128,12 @@
.bg-neutral-secondary-medium {
background-color: var(--color-neutral-secondary-medium);
}
.bg-neutral-secondary-medium\/50 {
background-color: color-mix(in srgb, oklch(98.5% 0.002 247.839) 50%, transparent);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-neutral-secondary-medium) 50%, transparent);
}
}
.bg-neutral-tertiary-medium {
background-color: var(--color-neutral-tertiary-medium);
}
@@ -2287,6 +2331,9 @@
color: heading !important;
}
}
.pb-1 {
padding-bottom: calc(var(--spacing) * 1);
}
.pb-16 {
padding-bottom: calc(var(--spacing) * 16);
}
@@ -2446,6 +2493,10 @@
--tw-tracking: var(--tracking-tight);
letter-spacing: var(--tracking-tight);
}
.tracking-wide {
--tw-tracking: var(--tracking-wide);
letter-spacing: var(--tracking-wide);
}
.text-balance {
text-wrap: balance;
}
@@ -2500,6 +2551,9 @@
.text-body {
color: var(--color-body);
}
.text-brand {
color: var(--color-brand);
}
.text-fg-brand {
color: var(--color-fg-brand);
}
@@ -2569,6 +2623,9 @@
.uppercase {
text-transform: uppercase;
}
.italic {
font-style: italic;
}
.no-underline\! {
text-decoration-line: none !important;
}
@@ -2663,6 +2720,10 @@
--tw-ease: var(--ease-out);
transition-timing-function: var(--ease-out);
}
.select-none {
-webkit-user-select: none;
user-select: none;
}
.\[program\:caddy\] {
program: caddy;
}
@@ -2792,6 +2853,16 @@
background-color: var(--color-gray-50);
}
}
.hover\:scale-110 {
&:hover {
@media (hover: hover) {
--tw-scale-x: 110%;
--tw-scale-y: 110%;
--tw-scale-z: 110%;
scale: var(--tw-scale-x) var(--tw-scale-y);
}
}
}
.hover\:cursor-pointer {
&:hover {
@media (hover: hover) {
@@ -2862,6 +2933,13 @@
}
}
}
.hover\:bg-neutral-secondary-medium {
&:hover {
@media (hover: hover) {
background-color: var(--color-neutral-secondary-medium);
}
}
}
.hover\:bg-neutral-tertiary-medium {
&:hover {
@media (hover: hover) {
@@ -2967,6 +3045,13 @@
}
}
}
.hover\:text-red-700 {
&:hover {
@media (hover: hover) {
color: var(--color-red-700);
}
}
}
.hover\:text-white {
&:hover {
@media (hover: hover) {
@@ -2989,6 +3074,12 @@
color: var(--color-blue-700);
}
}
.focus\:ring-0 {
&:focus {
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
}
.focus\:ring-2 {
&:focus {
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
@@ -3082,9 +3173,9 @@
max-width: var(--container-xl);
}
}
.sm\:rounded-lg {
.sm\:grid-cols-2 {
@media (width >= 40rem) {
border-radius: var(--radius-lg);
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.sm\:rounded-t-lg {
@@ -3232,6 +3323,11 @@
max-width: var(--container-3xl);
}
}
.lg\:grid-cols-4 {
@media (width >= 64rem) {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
.xl\:max-w-\(--breakpoint-xl\) {
@media (width >= 80rem) {
max-width: var(--breakpoint-xl);
@@ -3799,6 +3895,51 @@
}
}
}
.\[\&\:\:-webkit-slider-thumb\]\:relative {
&::-webkit-slider-thumb {
position: relative;
}
}
.\[\&\:\:-webkit-slider-thumb\]\:z-10 {
&::-webkit-slider-thumb {
z-index: 10;
}
}
.\[\&\:\:-webkit-slider-thumb\]\:z-20 {
&::-webkit-slider-thumb {
z-index: 20;
}
}
.\[\&\:\:-webkit-slider-thumb\]\:h-4 {
&::-webkit-slider-thumb {
height: calc(var(--spacing) * 4);
}
}
.\[\&\:\:-webkit-slider-thumb\]\:w-4 {
&::-webkit-slider-thumb {
width: calc(var(--spacing) * 4);
}
}
.\[\&\:\:-webkit-slider-thumb\]\:cursor-pointer {
&::-webkit-slider-thumb {
cursor: pointer;
}
}
.\[\&\:\:-webkit-slider-thumb\]\:appearance-none {
&::-webkit-slider-thumb {
appearance: none;
}
}
.\[\&\:\:-webkit-slider-thumb\]\:rounded-full {
&::-webkit-slider-thumb {
border-radius: calc(infinity * 1px);
}
}
.\[\&\:\:-webkit-slider-thumb\]\:bg-brand {
&::-webkit-slider-thumb {
background-color: var(--color-brand);
}
}
.\[\&\:first-of-type_button\]\:rounded-s-lg {
&:first-of-type button {
border-start-start-radius: var(--radius-lg);
@@ -5032,6 +5173,21 @@ form input:disabled, select:disabled, textarea:disabled {
syntax: "*";
inherits: false;
}
@property --tw-scale-x {
syntax: "*";
inherits: false;
initial-value: 1;
}
@property --tw-scale-y {
syntax: "*";
inherits: false;
initial-value: 1;
}
@property --tw-scale-z {
syntax: "*";
inherits: false;
initial-value: 1;
}
@keyframes spin {
to {
transform: rotate(360deg);
@@ -5099,6 +5255,9 @@ form input:disabled, select:disabled, textarea:disabled {
--tw-backdrop-sepia: initial;
--tw-duration: initial;
--tw-ease: initial;
--tw-scale-x: 1;
--tw-scale-y: 1;
--tw-scale-z: 1;
}
}
}
+380
View File
@@ -0,0 +1,380 @@
/**
* Filter bar — vanilla JavaScript implementation.
*
* Handles form submission, preset loading/saving, and preset list rendering.
* No HTMX — plain fetch() and window.location for all interactions.
*/
(function () {
"use strict";
/** Build a criterion object from a value and optional second value. */
function criterion(value, value2, modifier) {
var c = { value: value, modifier: modifier };
if (value2 !== null && value2 !== undefined && value2 !== "") {
c.value2 = value2;
}
return c;
}
/** Read a <select> element's value, or "" if not found. */
function selectValue(form, name) {
var el = form.querySelector('[name="' + name + '"]');
return el ? el.value : "";
}
/** Read an <input type="number"> value, or "" if not found. */
function numberValue(form, name) {
var el = form.querySelector('[name="' + name + '"]');
if (!el || el.value === "") return "";
var val = parseFloat(el.value);
return isNaN(val) ? "" : val;
}
/** Read all checked checkboxes with a given name, returning an array of ints. */
function checkedValues(form, name) {
var els = form.querySelectorAll('[name="' + name + '"]:checked');
var ids = [];
els.forEach(function (el) {
var v = parseInt(el.value, 10);
if (!isNaN(v)) ids.push(v);
});
return ids;
}
/**
* Build the filter JSON object from form field values.
* Returns a plain object ready for JSON.stringify.
*/
function buildFilterJSON(form) {
// Read all SelectableFilter widgets first
readSelectableFilters(form);
var filter = {};
var yearMin = numberValue(form, "filter-year-min");
var yearMax = numberValue(form, "filter-year-max");
var playMin = numberValue(form, "filter-playtime-min");
var playMax = numberValue(form, "filter-playtime-max");
var mastered = form.querySelector('[name="filter-mastered"]');
// ── Search field ──
var searchInput = form.querySelector('[name="filter-search"]');
if (searchInput && searchInput.value.trim()) {
filter.search = { value: searchInput.value.trim(), modifier: "INCLUDES" };
}
// ── Generic SelectableFilter widgets ──
readSelectableFilters(form);
var widgets = form.querySelectorAll("[data-selectable-filter]");
widgets.forEach(function (w) {
var field = w.getAttribute("data-selectable-filter");
var inc = parseJSONAttr(w, "data-included");
var exc = parseJSONAttr(w, "data-excluded");
var mod = w.getAttribute("data-modifier");
if (mod === "NOT_NULL" || mod === "IS_NULL") {
filter[field] = { modifier: mod };
} else if (inc.length > 0 || exc.length > 0) {
var isIdField = field === "platform" || field === "game" || field === "device" || field === "games";
filter[field] = {
value: isIdField ? inc.map(Number) : inc,
excludes: isIdField ? exc.map(Number) : exc,
modifier: mod || "INCLUDES",
};
}
});
// ── Session-specific fields ──
var pageIsSessions = !!form.querySelector('[data-selectable-filter="game"]');
// Game (sessions page)
var gameWidget = form.querySelector('[data-selectable-filter="game"]');
if (gameWidget) {
var gIncluded = parseJSONAttr(gameWidget, "data-included");
var gExcluded = parseJSONAttr(gameWidget, "data-excluded");
var gMod = gameWidget.getAttribute("data-modifier");
if (gMod === "NOT_NULL" || gMod === "IS_NULL") {
filter.game = { modifier: gMod };
} else if (gIncluded.length > 0 || gExcluded.length > 0) {
filter.game = {
value: gIncluded.map(Number),
excludes: gExcluded.map(Number),
modifier: gMod || "INCLUDES",
};
}
}
// Device (sessions page)
var deviceWidget = form.querySelector('[data-selectable-filter="device"]');
if (deviceWidget) {
var dIncluded = parseJSONAttr(deviceWidget, "data-included");
var dExcluded = parseJSONAttr(deviceWidget, "data-excluded");
var dMod = deviceWidget.getAttribute("data-modifier");
if (dMod === "NOT_NULL" || dMod === "IS_NULL") {
filter.device = { modifier: dMod };
} else if (dIncluded.length > 0 || dExcluded.length > 0) {
filter.device = {
value: dIncluded.map(Number),
excludes: dExcluded.map(Number),
modifier: dMod || "INCLUDES",
};
}
}
// Emulated checkbox (sessions page)
var emulated = form.querySelector('[name="filter-emulated"]');
if (emulated && emulated.checked) {
filter.emulated = criterion(true, null, "EQUALS");
}
// Active checkbox (sessions page)
var active = form.querySelector('[name="filter-active"]');
if (active && active.checked) {
filter.is_active = criterion(true, null, "EQUALS");
}
if (yearMin !== "" && yearMax !== "") {
// Skip if both equal the data range extremes (no real filter)
var yrMinNum = parseInt(yearMin, 10);
var yrMaxNum = parseInt(yearMax, 10);
if (yrMinNum === yrMaxNum) {
// don't add filter
} else {
filter.year_released = criterion(yearMin, yearMax, "BETWEEN");
}
} else if (yearMin !== "") {
filter.year_released = criterion(yearMin, null, "GREATER_THAN");
} else if (yearMax !== "") {
filter.year_released = criterion(yearMax, null, "LESS_THAN");
}
if (playMin !== "" || playMax !== "") {
var pMin = playMin !== "" ? Math.round(playMin * 60) : 0;
var pMax = playMax !== "" ? Math.round(playMax * 60) : 0;
// Skip if both are 0 — means slider is at default (no real filter)
if (pMin === 0 && pMax === 0) {
// don't add filter
} else {
var durKey = pageIsSessions ? "duration_minutes" : "playtime_minutes";
if (playMin !== "" && playMax !== "") {
filter[durKey] = criterion(pMin, pMax, "BETWEEN");
} else if (playMin !== "") {
filter[durKey] = criterion(pMin, null, "GREATER_THAN");
} else if (playMax !== "") {
filter[durKey] = criterion(pMax, null, "LESS_THAN");
}
}
}
if (mastered && mastered.checked) {
filter.mastered = criterion(true, null, "EQUALS");
}
return filter;
}
/** Extract the current page's base URL (without query string). */
function baseUrl() {
return window.location.pathname;
}
/** Safely parse a JSON attribute, returning empty array on failure. */
function parseJSONAttr(el, attr) {
var raw = el.getAttribute(attr);
if (!raw) return [];
try { return JSON.parse(raw); } catch (e) { return []; }
}
/**
* Called on filter bar form submit.
* Serializes filter fields, navigates to URL with filter param.
*/
window.applyFilterBar = function (event) {
event.preventDefault();
var form = event.target;
var filter = buildFilterJSON(form);
var filterStr = JSON.stringify(filter);
var url = baseUrl();
if (filterStr && filterStr !== "{}") {
url += "?filter=" + encodeURIComponent(filterStr);
}
window.location.href = url;
return false;
};
/**
* Clear all filter fields and reload the unfiltered view.
*/
window.clearFilterBar = function (formId, filterInputId) {
var form = document.getElementById(formId);
if (!form) return;
form.reset();
window.location.href = baseUrl();
};
// ── Presets ─────────────────────────────────────────────────────────────
/** Fetch and render the preset list. */
function loadPresets() {
var dropdown = document.getElementById("preset-dropdown");
if (!dropdown) return;
var url = dropdown.getAttribute("data-preset-list-url");
if (!url) return;
var mode = "games";
if (window.location.pathname.indexOf("session") !== -1) mode = "sessions";
else if (window.location.pathname.indexOf("purchase") !== -1) mode = "purchases";
fetch(url + "?mode=" + mode, { credentials: "same-origin" })
.then(function (r) {
if (!r.ok) throw new Error("Failed to load presets");
return r.text();
})
.then(function (html) {
dropdown.innerHTML = html;
// Re-attach delete handlers (list_presets view uses onclick attributes,
// but we also need to wire up inline handlers if they use data attributes)
setupPresetDeleteHandlers(dropdown);
})
.catch(function (err) {
dropdown.innerHTML =
'<span class="text-sm text-body italic">Presets unavailable</span>';
console.error(err);
});
}
/** Wire up click handlers for preset delete buttons. */
function setupPresetDeleteHandlers(container) {
var deleteLinks = container.querySelectorAll('[data-delete-preset]');
deleteLinks.forEach(function (link) {
link.addEventListener("click", function (e) {
e.preventDefault();
var presetId = link.getAttribute("data-delete-preset");
var deleteUrl = link.getAttribute("href");
if (!deleteUrl) return;
if (!confirm("Delete this preset?")) return;
fetch(deleteUrl, {
method: "POST",
credentials: "same-origin",
headers: { "X-CSRFToken": getCsrfToken() },
})
.then(function () {
// Remove the parent <li>
var li = link.closest("li");
if (li) li.remove();
// If no items left, show empty message
var ul = container.querySelector("ul");
if (ul && ul.querySelectorAll("li").length === 0) {
ul.innerHTML =
'<li class="px-4 py-2 text-sm text-body italic">No saved presets</li>';
}
})
.catch(function (err) {
console.error("Delete failed:", err);
});
});
});
}
/** Show the preset name input field and the confirm button. */
window.showPresetNameInput = function () {
var input = document.getElementById("preset-name-input");
var saveBtn = document.getElementById("save-preset-btn");
var confirmBtn = document.getElementById("confirm-save-preset-btn");
if (input) input.classList.remove("hidden");
if (saveBtn) saveBtn.classList.add("hidden");
if (confirmBtn) confirmBtn.classList.remove("hidden");
if (input) input.focus();
};
/** Save the current filter as a named preset. */
window.savePreset = function (formId, filterInputId, saveUrl) {
var input = document.getElementById("preset-name-input");
var name = input ? input.value.trim() : "";
if (!name) {
if (input) input.classList.add("border-red-500");
return;
}
var filterInput = document.getElementById(filterInputId);
var form = document.getElementById(formId);
var filterObj = form ? buildFilterJSON(form) : {};
var body = new URLSearchParams();
body.append("name", name);
var mode = "games";
if (window.location.pathname.indexOf("session") !== -1) mode = "sessions";
else if (window.location.pathname.indexOf("purchase") !== -1) mode = "purchases";
body.append("mode", mode);
body.append("filter", JSON.stringify(filterObj));
fetch(saveUrl, {
method: "POST",
credentials: "same-origin",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"X-CSRFToken": getCsrfToken(),
},
body: body.toString(),
})
.then(function (r) {
if (!r.ok) throw new Error("Save failed");
// Reset UI
if (input) {
input.value = "";
input.classList.add("hidden");
input.classList.remove("border-red-500");
}
var saveBtn = document.getElementById("save-preset-btn");
var confirmBtn = document.getElementById("confirm-save-preset-btn");
if (saveBtn) saveBtn.classList.remove("hidden");
if (confirmBtn) confirmBtn.classList.add("hidden");
// Refresh the preset list
loadPresets();
})
.catch(function (err) {
console.error("Failed to save preset:", err);
});
};
/** Extract CSRF token from the page. */
function getCsrfToken() {
var cookie = document.cookie
.split("; ")
.find(function (row) {
return row.startsWith("csrftoken=");
});
if (cookie) return cookie.split("=")[1];
var el = document.querySelector('input[name="csrfmiddlewaretoken"]');
return el ? el.value : "";
}
// ── Init on page load ───────────────────────────────────────────────────
// ── Inject search inputs into filter forms ──
function injectSearchInputs() {
document.querySelectorAll('[id^="filter-bar-form"]').forEach(function (form) {
if (form.querySelector('[name="filter-search"]')) return; // already added
var input = document.createElement("input");
input.type = "text";
input.name = "filter-search";
input.placeholder = "Search\u2026";
input.className = "block w-full rounded-base border border-default-medium bg-neutral-secondary-medium text-sm text-heading p-2 mb-4 focus:ring-brand focus:border-brand";
// Pre-fill from existing filter JSON
var hidden = form.querySelector('[name="filter"]');
if (hidden && hidden.parentNode) {
try {
var existing = JSON.parse(hidden.value || "{}");
if (existing.search && existing.search.value) {
input.value = existing.search.value;
}
} catch (e) {}
hidden.parentNode.insertBefore(input, hidden.nextSibling);
}
});
}
injectSearchInputs();
document.addEventListener("DOMContentLoaded", function () {
injectSearchInputs();
loadPresets();
});
})();
+96
View File
@@ -0,0 +1,96 @@
/**
* Dual-handle range slider — pure JS with draggable handles.
*/
(function () {
"use strict";
function initAll(force) {
document.querySelectorAll(".range-slider").forEach(function (slider) {
if (force) slider._rsInit = false;
if (slider._rsInit) return;
slider._rsInit = true;
var minHandle = slider.querySelector(".range-handle-min");
var maxHandle = slider.querySelector(".range-handle-max");
var track = slider.querySelector(".range-track-fill");
if (!minHandle || !maxHandle) return;
var minTarget = document.getElementById(minHandle.getAttribute("data-target"));
var maxTarget = document.getElementById(maxHandle.getAttribute("data-target"));
var dMin = parseInt(slider.getAttribute("data-min"), 10);
var dMax = parseInt(slider.getAttribute("data-max"), 10);
var step = parseInt(slider.getAttribute("data-step"), 10) || 1;
function valueToPercent(v) { return ((v - dMin) / (dMax - dMin)) * 100; }
function percentToValue(p) {
var raw = dMin + (p / 100) * (dMax - dMin);
return Math.round(raw / step) * step;
}
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function getTargetVal(el) { return parseInt(el ? el.value : minTarget.value, 10) || dMin; }
function setTargetVal(el, v) { if (el) el.value = v; }
function update() {
var minV = getTargetVal(minTarget);
var maxV = getTargetVal(maxTarget);
minV = clamp(minV, dMin, dMax);
maxV = clamp(maxV, dMin, dMax);
if (minV > maxV) minV = maxV;
if (maxV < minV) maxV = minV;
setTargetVal(minTarget, minV);
setTargetVal(maxTarget, maxV);
var minP = valueToPercent(minV);
var maxP = valueToPercent(maxV);
minHandle.style.left = minP + "%";
maxHandle.style.left = maxP + "%";
if (track) {
track.style.left = minP + "%";
track.style.width = (maxP - minP) + "%";
}
}
function makeDraggable(handle, isMin) {
handle.addEventListener("mousedown", function (e) {
e.preventDefault();
var rect = slider.getBoundingClientRect();
function onMove(ev) {
var pct = ((ev.clientX - rect.left) / rect.width) * 100;
var v = percentToValue(clamp(pct, 0, 100));
if (isMin) {
minTarget.value = clamp(v, dMin, getTargetVal(maxTarget));
} else {
maxTarget.value = clamp(v, getTargetVal(minTarget), dMax);
}
update();
// Trigger input event on the target so any listeners fire
var tgt = isMin ? minTarget : maxTarget;
if (tgt) tgt.dispatchEvent(new Event("input", { bubbles: true }));
}
function onUp() {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
}
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
onMove(e);
});
}
makeDraggable(minHandle, true);
makeDraggable(maxHandle, false);
// Sync from inputs to slider
function fromInputs() { update(); }
if (minTarget) minTarget.addEventListener("input", fromInputs);
if (maxTarget) maxTarget.addEventListener("input", fromInputs);
update();
});
}
document.addEventListener("DOMContentLoaded", initAll);
document.addEventListener("htmx:afterSwap", initAll);
// Expose for manual re-init (filter bar toggle)
window.initRangeSliders = initAll;
})();
+149
View File
@@ -0,0 +1,149 @@
/**
* SelectableFilter widget — Stash-style choice filter with search,
* include/exclude buttons, and modifier tags (Any / None).
*/
(function () {
"use strict";
function initAll() {
document.querySelectorAll("[data-selectable-filter]").forEach(function (el) {
if (el._sfInit) return;
el._sfInit = true;
initWidget(el);
});
}
function initWidget(container) {
var search = container.querySelector(".sf-search");
var options = container.querySelector(".sf-options");
var selectedArea = container.querySelector(".sf-selected");
if (!search || !options || !selectedArea) return;
// ── Search ──
search.addEventListener("input", function () {
var q = search.value.toLowerCase();
options.querySelectorAll(".sf-option").forEach(function (item) {
var label = (item.getAttribute("data-label") || "").toLowerCase();
item.style.display = label.indexOf(q) !== -1 ? "" : "none";
});
});
// ── Include / Exclude clicks ──
options.addEventListener("click", function (e) {
var btn = e.target.closest("button");
if (btn) {
var action = btn.getAttribute("data-action");
var itemEl = btn.closest(".sf-option");
if (!itemEl) return;
var value = itemEl.getAttribute("data-value");
var label = itemEl.getAttribute("data-label");
if (!value) return;
if (action === "include") addTag(container, value, label, "include");
else if (action === "exclude") addTag(container, value, label, "exclude");
return;
}
// Click on modifier option (not a button)
var modOption = e.target.closest(".sf-modifier-option");
if (modOption) {
var modVal = modOption.getAttribute("data-modifier");
setModifier(container, modVal);
}
});
// ── Remove selected tag ──
selectedArea.addEventListener("click", function (e) {
var removeBtn = e.target.closest(".sf-remove");
if (removeBtn) {
removeBtn.closest(".sf-tag").remove();
return;
}
// Click on active modifier tag → deselect it
var modTag = e.target.closest(".sf-modifier-tag");
if (modTag) {
clearModifier(container);
}
});
}
/** Add a tag to the selected area and clear modifier. */
function addTag(container, value, label, type) {
clearModifier(container);
var selectedArea = container.querySelector(".sf-selected");
// Check if already present
var existing = selectedArea.querySelector('.sf-tag[data-value="' + value + '"]');
if (existing) {
if (existing.getAttribute("data-type") !== type) {
existing.setAttribute("data-type", type);
existing.classList.toggle("sf-excluded", type === "exclude");
var text = existing.querySelector(".sf-tag-text");
if (text) text.textContent = (type === "exclude" ? "✗ " : "✓ ") + label;
}
return;
}
var tag = document.createElement("span");
tag.className = "sf-tag" + (type === "exclude" ? " sf-excluded" : "");
tag.setAttribute("data-value", value);
tag.setAttribute("data-type", type);
tag.innerHTML =
'<span class="sf-tag-text">' + (type === "exclude" ? "✗ " : "✓ ") + label + "</span>" +
'<button type="button" class="sf-remove" aria-label="Remove">×</button>';
selectedArea.appendChild(tag);
}
/** Set a modifier (Any / None) — clears all tags. */
function setModifier(container, modVal) {
var selectedArea = container.querySelector(".sf-selected");
// Clear all tags
selectedArea.querySelectorAll(".sf-tag").forEach(function (t) { t.remove(); });
// Clear existing modifier tag
selectedArea.querySelectorAll(".sf-modifier-tag").forEach(function (t) { t.remove(); });
// Add new modifier tag
var label = modVal === "NOT_NULL" ? "(Any)" : "(None)";
var tag = document.createElement("span");
tag.className = "sf-modifier-tag active";
tag.setAttribute("data-modifier", modVal);
tag.textContent = label;
selectedArea.appendChild(tag);
container.setAttribute("data-modifier", modVal);
}
/** Clear any active modifier, removing the tag. */
function clearModifier(container) {
var selectedArea = container.querySelector(".sf-selected");
selectedArea.querySelectorAll(".sf-modifier-tag").forEach(function (t) { t.remove(); });
container.removeAttribute("data-modifier");
}
// Read selections for form submission
window.readSelectableFilters = function (form) {
form.querySelectorAll("[data-selectable-filter]").forEach(function (container) {
var modifier = container.getAttribute("data-modifier");
var modTag = container.querySelector(".sf-modifier-tag.active");
if (modTag) modifier = modTag.getAttribute("data-modifier");
var included = [];
var excluded = [];
container.querySelectorAll(".sf-tag").forEach(function (tag) {
var val = tag.getAttribute("data-value");
if (tag.getAttribute("data-type") === "exclude") excluded.push(val);
else included.push(val);
});
container.setAttribute("data-included", JSON.stringify(included));
container.setAttribute("data-excluded", JSON.stringify(excluded));
if (modifier) container.setAttribute("data-modifier", modifier);
});
};
document.addEventListener("DOMContentLoaded", initAll);
document.addEventListener("htmx:afterSwap", initAll);
})();
+14 -4
View File
@@ -2,6 +2,7 @@ from django.urls import path
from games.views import (
device,
filter_presets,
game,
general,
platform,
@@ -160,9 +161,18 @@ urlpatterns = [
name="list_statuschanges",
),
path("stats/", general.stats_alltime, name="stats_alltime"),
path("stats/<int:year>", general.stats, name="stats_by_year"),
# Filter presets
path("filter/presets/list", filter_presets.list_presets, name="list_presets"),
path("filter/presets/save", filter_presets.save_preset, name="save_preset"),
path(
"stats/<int:year>",
general.stats,
name="stats_by_year",
"filter/presets/<int:preset_id>/delete",
filter_presets.delete_preset,
name="delete_preset",
),
]
path(
"filter/presets/<int:preset_id>/load",
filter_presets.load_preset,
name="load_preset",
),
]
+100
View File
@@ -0,0 +1,100 @@
"""Views for managing saved filter presets (FilterPreset model)."""
import json
from urllib.parse import quote
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils.safestring import SafeText, mark_safe
from games.models import FilterPreset
@login_required
def list_presets(request: HttpRequest) -> HttpResponse:
"""Return a preset dropdown as an HTML fragment."""
mode = request.GET.get("mode", "games")
presets = FilterPreset.objects.filter(mode=mode).order_by("name")
items: list[str] = []
for preset in presets:
filter_json = (
json.dumps(preset.object_filter) if preset.object_filter else ""
)
list_url = reverse(f"games:list_{mode}")
delete_url = reverse("games:delete_preset", args=[preset.id])
items.append(
f"<li>"
f'<a href="{list_url}?filter={quote(filter_json)}" '
f'class="flex justify-between items-center px-4 py-2 text-sm '
f'text-heading hover:bg-neutral-secondary-medium">'
f"<span>{preset.name}</span>"
f'<span class="text-red-500 hover:text-red-700 cursor-pointer ml-4" '
f'data-delete-preset="{preset.id}" '
f'href="{delete_url}">x</span>'
f"</a></li>"
)
if not items:
items = [
'<li class="px-4 py-2 text-sm text-body italic">'
"No saved presets</li>"
]
return HttpResponse(
mark_safe(f'<ul class="py-1">{"".join(items)}</ul>')
)
@login_required
def save_preset(request: HttpRequest) -> HttpResponse:
"""Save the current filter as a new preset."""
if request.method != "POST":
return HttpResponse(status=405)
name = request.POST.get("name", "").strip()
mode = request.POST.get("mode", "games")
filter_json_str = request.POST.get("filter", "")
if not name:
messages.error(request, "Preset name is required.")
return HttpResponse(status=400)
object_filter: dict = {}
if filter_json_str:
try:
object_filter = json.loads(filter_json_str)
except json.JSONDecodeError:
pass
FilterPreset.objects.create(
name=name,
mode=mode,
object_filter=object_filter,
)
messages.success(request, f'Filter preset "{name}" saved.')
return HttpResponse(status=201)
@login_required
def delete_preset(request: HttpRequest, preset_id: int) -> HttpResponse:
"""Delete a saved filter preset."""
preset = get_object_or_404(FilterPreset, id=preset_id)
name = preset.name
preset.delete()
messages.success(request, f'Preset "{name}" deleted.')
return HttpResponse(status=200)
@login_required
def load_preset(request: HttpRequest, preset_id: int) -> HttpResponse:
"""Load a preset and redirect to the appropriate list view."""
preset = get_object_or_404(FilterPreset, id=preset_id)
filter_json = json.dumps(preset.object_filter) if preset.object_filter else ""
return redirect(
f"{reverse(f'games:list_{preset.mode}')}?filter={quote(filter_json)}"
)
+44 -21
View File
@@ -18,6 +18,7 @@ from common.components import (
Component,
CsrfInput,
Div,
FilterBar,
GameStatus,
GameStatusSelector,
H1,
@@ -42,6 +43,7 @@ from common.time import (
timeformat,
)
from common.utils import build_dynamic_filter, paginate, safe_division, truncate
from games.filters import parse_game_filter
from games.forms import GameForm
from games.models import Game
from games.views.general import use_custom_redirect
@@ -51,26 +53,35 @@ from games.views.playevent import create_playevent_tabledata
@login_required
def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
games = Game.objects.order_by("-created_at")
search_string = request.GET.get("search_string", search_string)
if search_string != "":
filters = [
Q(name__icontains=search_string),
Q(sort_name__icontains=search_string),
Q(platform__name__icontains=search_string),
]
try:
year_value = int(search_string)
except ValueError:
year_value = None
if year_value:
filters.append(Q(year_released=year_value))
search_string_parts = search_string.split()
# only search for status if it exactly matches and is the only word
if len(search_string_parts) == 1:
if search_string.title() in Game.Status.labels:
search_status = Game.Status[search_string.upper()]
filters.append(Q(status=search_status))
games = games.filter(build_dynamic_filter(filters, "|"))
# ── Structured filter (Stash-style JSON) ──
filter_json = request.GET.get("filter", "")
if filter_json:
game_filter = parse_game_filter(filter_json)
if game_filter is not None:
games = games.filter(game_filter.to_q())
else:
# ── Legacy free-text search ──
search_string = request.GET.get("search_string", search_string)
if search_string != "":
filters = [
Q(name__icontains=search_string),
Q(sort_name__icontains=search_string),
Q(platform__name__icontains=search_string),
]
try:
year_value = int(search_string)
except ValueError:
year_value = None
if year_value:
filters.append(Q(year_released=year_value))
search_string_parts = search_string.split()
if len(search_string_parts) == 1:
if search_string.title() in Game.Status.labels:
search_status = Game.Status[search_string.upper()]
filters.append(Q(status=search_status))
games = games.filter(build_dynamic_filter(filters, "|"))
games, page_obj, elided_page_range = paginate(request, games)
data = {
@@ -126,7 +137,19 @@ def list_games(request: HttpRequest, search_string: str = "") -> HttpResponse:
elided_page_range=elided_page_range,
request=request,
)
return render_page(request, content, title="Manage games")
# Prepend the filter bar above the table
filter_bar = FilterBar(
filter_json=filter_json,
preset_list_url=reverse("games:list_presets"),
preset_save_url=reverse("games:save_preset"),
)
content = mark_safe(str(filter_bar) + str(content))
return render_page(
request,
content,
title="Manage games",
scripts=ModuleScript("range_slider.js") + ModuleScript("selectable_filter.js") + ModuleScript("filter_bar.js"),
)
@login_required
+24 -5
View File
@@ -12,7 +12,7 @@ from django.views.decorators.http import require_POST
from django.template.defaultfilters import date as date_filter
from django.template.defaultfilters import floatformat
from django.utils.safestring import SafeText
from django.utils.safestring import SafeText, mark_safe
from common.components import (
A,
@@ -95,9 +95,16 @@ def _render_purchase_row(purchase):
@login_required
def list_purchases(request: HttpRequest) -> HttpResponse:
purchases, page_obj, elided_page_range = paginate(
request, Purchase.objects.order_by("-date_purchased", "-created_at")
)
purchases = Purchase.objects.order_by("-date_purchased", "-created_at")
filter_json = request.GET.get("filter", "")
if filter_json:
from games.filters import parse_purchase_filter
pf = parse_purchase_filter(filter_json)
if pf is not None:
purchases = purchases.filter(pf.to_q())
purchases, page_obj, elided_page_range = paginate(request, purchases)
data = {
"header_action": A(
@@ -121,7 +128,19 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
elided_page_range=elided_page_range,
request=request,
)
return render_page(request, content, title="Manage purchases")
from common.components import PurchaseFilterBar, ModuleScript
filter_bar = PurchaseFilterBar(
filter_json=filter_json,
preset_list_url=reverse("games:list_presets"),
preset_save_url=reverse("games:save_preset"),
)
content = mark_safe(str(filter_bar) + str(content))
return render_page(
request,
content,
title="Manage purchases",
scripts=ModuleScript("range_slider.js") + ModuleScript("selectable_filter.js") + ModuleScript("filter_bar.js"),
)
def _purchase_additional_row() -> SafeText:
+33 -10
View File
@@ -40,15 +40,25 @@ from games.models import Device, Game, Session
def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse:
sessions = Session.objects.order_by("-timestamp_start", "created_at")
device_list = Device.objects.order_by("name")
search_string = request.GET.get("search_string", search_string)
if search_string != "":
sessions = sessions.filter(
Q(game__name__icontains=search_string)
| Q(game__name__icontains=search_string)
| Q(game__platform__name__icontains=search_string)
| Q(device__name__icontains=search_string)
| Q(device__type__icontains=search_string)
)
# ── Structured filter (JSON) ──
filter_json = request.GET.get("filter", "")
if filter_json:
from games.filters import parse_session_filter
session_filter = parse_session_filter(filter_json)
if session_filter is not None:
sessions = sessions.filter(session_filter.to_q())
else:
# ── Legacy free-text search ──
search_string = request.GET.get("search_string", search_string)
if search_string != "":
sessions = sessions.filter(
Q(game__name__icontains=search_string)
| Q(game__name__icontains=search_string)
| Q(game__platform__name__icontains=search_string)
| Q(device__name__icontains=search_string)
| Q(device__type__icontains=search_string)
)
try:
last_session = sessions.latest()
except Session.DoesNotExist:
@@ -157,7 +167,20 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
elided_page_range=elided_page_range,
request=request,
)
return render_page(request, content, title="Manage sessions")
from common.components import SessionFilterBar
filter_json = request.GET.get("filter", "")
filter_bar = SessionFilterBar(
filter_json=filter_json,
preset_list_url=reverse("games:list_presets"),
preset_save_url=reverse("games:save_preset"),
)
content = mark_safe(str(filter_bar) + str(content))
return render_page(
request,
content,
title="Manage sessions",
scripts=ModuleScript("range_slider.js") + ModuleScript("selectable_filter.js") + ModuleScript("filter_bar.js"),
)
@login_required