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
+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