Compare commits

..

2 Commits

Author SHA1 Message Date
lukas 89c9ff6367 feat: implement frontend filter bars and integration across all list views
Django CI/CD / test (push) Failing after 58s
Django CI/CD / build-and-push (push) Has been skipped
2026-06-09 13:56:02 +02:00
lukas 5887febbb7 feat: implement comprehensive filters and cross-entity queries
Django CI/CD / test (push) Failing after 1m28s
Django CI/CD / build-and-push (push) Has been skipped
2026-06-09 13:14:05 +02:00
12 changed files with 2079 additions and 178 deletions
+6
View File
@@ -63,6 +63,9 @@ from common.components.filters import (
FilterBar,
PurchaseFilterBar,
SessionFilterBar,
DeviceFilterBar,
PlatformFilterBar,
PlayEventFilterBar,
)
__all__ = [
@@ -115,4 +118,7 @@ __all__ = [
"FilterBar",
"PurchaseFilterBar",
"SessionFilterBar",
"DeviceFilterBar",
"PlatformFilterBar",
"PlayEventFilterBar",
]
+216 -10
View File
@@ -664,6 +664,11 @@ def FilterBar(
playtime_min = ""
playtime_max = ""
has_purchases_value = _parse_bool(existing, "has_purchases")
has_playevents_value = _parse_bool(existing, "has_playevents")
session_count_min, session_count_max = _parse_range(existing, "session_count")
session_avg_min, session_avg_max = _parse_range(existing, "session_average")
try:
year_aggregate = Game.objects.aggregate(
year_min=models.Min("year_released"), year_max=models.Max("year_released")
@@ -722,6 +727,8 @@ def FilterBar(
attributes=[("class", "flex items-end gap-4 mb-4")],
children=[
_filter_checkbox("filter-mastered", "Mastered", mastered_value),
_filter_checkbox("filter-has-purchases", "Has Purchases", has_purchases_value),
_filter_checkbox("filter-has-playevents", "Has Play Events", has_playevents_value),
],
),
RangeSlider(
@@ -735,6 +742,28 @@ def FilterBar(
min_placeholder="e.g. 1",
max_placeholder="e.g. 100",
),
RangeSlider(
label="Session Count",
input_name_prefix="filter-session-count",
min_value=session_count_min,
max_value=session_count_max,
range_min=0,
range_max=100,
step="1",
min_placeholder="e.g. 1",
max_placeholder="e.g. 50",
),
RangeSlider(
label="Average Session Duration (mins)",
input_name_prefix="filter-session-average",
min_value=session_avg_min,
max_value=session_avg_max,
range_min=0,
range_max=240,
step="1",
min_placeholder="e.g. 10",
max_placeholder="e.g. 120",
),
]
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
@@ -756,9 +785,9 @@ def SessionFilterBar(
game_choice = _filter_get_choice(existing, "game")
device_choice = _filter_get_choice(existing, "device")
duration_min, duration_max = _parse_range(existing, "duration_minutes")
duration_min = _filter_mins_to_hrs(duration_min)
duration_max = _filter_mins_to_hrs(duration_max)
dur_tot_min, dur_tot_max = _parse_range(existing, "duration_total_minutes")
dur_man_min, dur_man_max = _parse_range(existing, "duration_manual_minutes")
dur_calc_min, dur_calc_max = _parse_range(existing, "duration_calculated_minutes")
emulated_value = _parse_bool(existing, "emulated")
is_active_value = _parse_bool(existing, "is_active")
try:
@@ -800,14 +829,37 @@ def SessionFilterBar(
],
),
RangeSlider(
label="Duration",
input_name_prefix="filter-playtime",
min_value=duration_min,
max_value=duration_max,
label="Total Duration (mins)",
input_name_prefix="filter-duration-total-minutes",
min_value=dur_tot_min,
max_value=dur_tot_max,
range_min=0,
range_max=duration_range_max,
min_placeholder="e.g. 0.5",
max_placeholder="e.g. 10",
range_max=duration_range_max * 60, # Range sliders use minutes now
step="1",
min_placeholder="e.g. 30",
max_placeholder="e.g. 180",
),
RangeSlider(
label="Manual Duration (mins)",
input_name_prefix="filter-duration-manual-minutes",
min_value=dur_man_min,
max_value=dur_man_max,
range_min=0,
range_max=240,
step="1",
min_placeholder="e.g. 10",
max_placeholder="e.g. 120",
),
RangeSlider(
label="Calculated Duration (mins)",
input_name_prefix="filter-duration-calculated-minutes",
min_value=dur_calc_min,
max_value=dur_calc_max,
range_min=0,
range_max=duration_range_max * 60,
step="1",
min_placeholder="e.g. 30",
max_placeholder="e.g. 180",
),
Component(
tag_name="div",
@@ -836,6 +888,11 @@ def PurchaseFilterBar(
ownership_choice = _filter_get_choice(existing, "ownership_type")
price_min, price_max = _parse_range(existing, "price")
is_refunded_value = _parse_bool(existing, "is_refunded")
infinite_value = _parse_bool(existing, "infinite")
needs_price_update_value = _parse_bool(existing, "needs_price_update")
price_currency_value = existing.get("price_currency", {}).get("value", "")
converted_currency_value = existing.get("converted_currency", {}).get("value", "")
try:
price_aggregate = Purchase.objects.aggregate(
price_min=models.Min("price"), price_max=models.Max("price")
@@ -909,6 +966,40 @@ def PurchaseFilterBar(
attributes=[("class", "flex items-end gap-4 mb-4")],
children=[
_filter_checkbox("filter-refunded", "Refunded", is_refunded_value),
_filter_checkbox("filter-infinite", "Infinite", infinite_value),
_filter_checkbox("filter-needs-price-update", "Needs Price Update", needs_price_update_value),
],
),
Component(
tag_name="div",
attributes=[("class", _FILTER_GRID_CLASS)],
children=[
_filter_field(
"Original Currency",
Component(
tag_name="input",
attributes=[
("type", "text"),
("name", "filter-price_currency"),
("value", price_currency_value),
("placeholder", "e.g. USD, EUR"),
("class", "w-full rounded border-default-medium p-2 bg-neutral-secondary-medium text-body"),
],
),
),
_filter_field(
"Converted Currency",
Component(
tag_name="input",
attributes=[
("type", "text"),
("name", "filter-converted_currency"),
("value", converted_currency_value),
("placeholder", "e.g. USD, EUR"),
("class", "w-full rounded border-default-medium p-2 bg-neutral-secondary-medium text-body"),
],
),
),
],
),
RangeSlider(
@@ -934,3 +1025,118 @@ def PurchaseFilterBar(
),
]
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
def DeviceFilterBar(
filter_json="", preset_list_url="", preset_save_url=""
) -> SafeText:
"""Collapsible filter bar for the Device list."""
from games.models import Device
existing = _filter_parse(filter_json)
type_options = Device.DEVICE_TYPES
type_choice = _filter_get_choice(existing, "type")
fields = [
Component(
tag_name="div",
attributes=[("class", _FILTER_GRID_CLASS)],
children=[
_filter_field(
"Device Type",
_enum_filter(
"type",
type_options,
type_choice,
nullable=True,
),
),
],
),
]
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
def PlatformFilterBar(
filter_json="", preset_list_url="", preset_save_url=""
) -> SafeText:
"""Collapsible filter bar for the Platform list."""
existing = _filter_parse(filter_json)
name_value = existing.get("name", {}).get("value", "")
group_value = existing.get("group", {}).get("value", "")
fields = [
Component(
tag_name="div",
attributes=[("class", _FILTER_GRID_CLASS)],
children=[
_filter_field(
"Platform Name",
Component(
tag_name="input",
attributes=[
("type", "text"),
("name", "filter-name"),
("value", name_value),
("placeholder", "e.g. Nintendo Switch"),
("class", "w-full rounded border-default-medium p-2 bg-neutral-secondary-medium text-body"),
],
),
),
_filter_field(
"Platform Group",
Component(
tag_name="input",
attributes=[
("type", "text"),
("name", "filter-group"),
("value", group_value),
("placeholder", "e.g. Nintendo"),
("class", "w-full rounded border-default-medium p-2 bg-neutral-secondary-medium text-body"),
],
),
),
],
),
]
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
def PlayEventFilterBar(
filter_json="", preset_list_url="", preset_save_url=""
) -> SafeText:
"""Collapsible filter bar for the PlayEvent list."""
existing = _filter_parse(filter_json)
game_choice = _filter_get_choice(existing, "game")
days_min, days_max = _parse_range(existing, "days_to_finish")
fields = [
Component(
tag_name="div",
attributes=[("class", _FILTER_GRID_CLASS)],
children=[
_filter_field(
"Game",
_model_filter(
"game",
game_choice,
search_url="/api/games/search",
nullable=False,
),
),
],
),
RangeSlider(
label="Days to Finish",
input_name_prefix="filter-days-to-finish",
min_value=days_min,
max_value=days_max,
range_min=0,
range_max=365,
step="1",
min_placeholder="e.g. 1",
max_placeholder="e.g. 30",
),
]
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
@@ -0,0 +1,662 @@
# Comprehensive Filters Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Implement a comprehensive suite of backend filter classes and filter field expansions across all 6 main models (Game, Session, Purchase, Device, Platform, PlayEvent) using a subquery-based cross-entity approach.
**Architecture:** We will implement missing filter classes (`DeviceFilter`, `PlatformFilter`, `PlayEventFilter`) in `games/filters.py`. We will extend all filters to support powerful, deeply linked "cross-entity" subqueries (e.g. `GameFilter.session_filter` or `PlatformFilter.game_filter`) which builds robust `Q` objects without causing duplicate join rows in list queries.
**Tech Stack:** Django, Python dataclasses, Pytest.
---
### Task 1: Implement New Filter Classes (Device, Platform, PlayEvent)
**Files:**
- Modify: `games/filters.py`
- Test: `tests/test_filters.py`
- [ ] **Step 1: Implement DeviceFilter, PlatformFilter, and PlayEventFilter**
Add the three new operator filters to `games/filters.py`. Ensure we import all necessary criterion types and add the `parse_device_filter`, `parse_platform_filter`, and `parse_playevent_filter` helper functions at the end of the file.
```python
# Insert new filter imports and classes in games/filters.py
@dataclass
class DeviceFilter(OperatorFilter):
"""Filter for the Device model."""
AND: DeviceFilter | None = None
OR: DeviceFilter | None = None
NOT: DeviceFilter | None = None
name: StringCriterion | None = None
type: ChoiceCriterion | None = None
created_at: StringCriterion | None = None
# Free-text search
search: StringCriterion | None = None
# Cross-entity: Devices that have sessions matching these criteria
session_filter: SessionFilter | None = None
def to_q(self) -> Q:
q = Q()
if self.name is not None:
q &= self.name.to_q("name")
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")
# Free-text search
if self.search is not None and self.search.value:
search_q = (
Q(name__icontains=self.search.value)
| Q(type__icontains=self.search.value)
)
if self.search.modifier == Modifier.EXCLUDES:
search_q = ~search_q
q &= search_q
# Cross-entity filter: session_filter
if self.session_filter is not None:
from games.models import Session
session_q = self.session_filter.to_q()
matching_ids = Session.objects.filter(session_q).values_list("device_id", flat=True)
q &= Q(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
@dataclass
class PlatformFilter(OperatorFilter):
"""Filter for the Platform model."""
AND: PlatformFilter | None = None
OR: PlatformFilter | None = None
NOT: PlatformFilter | None = None
name: StringCriterion | None = None
group: StringCriterion | None = None
icon: StringCriterion | None = None
created_at: StringCriterion | None = None
# Free-text search
search: StringCriterion | None = None
# Cross-entity
game_filter: GameFilter | None = None
purchase_filter: PurchaseFilter | None = None
def to_q(self) -> Q:
q = Q()
if self.name is not None:
q &= self.name.to_q("name")
if self.group is not None:
q &= self.group.to_q("group")
if self.icon is not None:
q &= self.icon.to_q("icon")
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(name__icontains=self.search.value)
| Q(group__icontains=self.search.value)
)
if self.search.modifier == Modifier.EXCLUDES:
search_q = ~search_q
q &= search_q
# Cross-entity filter: game_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("platform_id", flat=True)
q &= Q(id__in=matching_ids)
# Cross-entity filter: purchase_filter
if self.purchase_filter is not None:
from games.models import Purchase
purchase_q = self.purchase_filter.to_q()
matching_ids = Purchase.objects.filter(purchase_q).values_list("platform_id", flat=True)
q &= Q(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
@dataclass
class PlayEventFilter(OperatorFilter):
"""Filter for the PlayEvent model."""
AND: PlayEventFilter | None = None
OR: PlayEventFilter | None = None
NOT: PlayEventFilter | None = None
game: MultiCriterion | None = None # filters on game_id
started: StringCriterion | None = None # date string
ended: StringCriterion | None = None # date string
days_to_finish: IntCriterion | None = None
note: StringCriterion | None = None
created_at: StringCriterion | None = None
# Free-text search
search: StringCriterion | None = None
# Cross-entity: PlayEvents for games matching these criteria
game_filter: GameFilter | None = None
def to_q(self) -> Q:
q = Q()
if self.game is not None:
q &= self.game.to_q("game_id")
if self.started is not None:
q &= self.started.to_q("started")
if self.ended is not None:
q &= self.ended.to_q("ended")
if self.days_to_finish is not None:
q &= self.days_to_finish.to_q("days_to_finish")
if self.note is not None:
q &= self.note.to_q("note")
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(note__icontains=self.search.value)
)
if self.search.modifier == Modifier.EXCLUDES:
search_q = ~search_q
q &= search_q
# Cross-entity filter: game_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(game_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
# Add to convenience helpers section:
def parse_device_filter(json_str: str) -> DeviceFilter | None:
return filter_from_json(DeviceFilter, json_str)
def parse_platform_filter(json_str: str) -> PlatformFilter | None:
return filter_from_json(PlatformFilter, json_str)
def parse_playevent_filter(json_str: str) -> PlayEventFilter | None:
return filter_from_json(PlayEventFilter, json_str)
```
- [ ] **Step 2: Run existing tests to verify everything compiles**
Run: `pytest tests/test_filters.py -v`
Expected: All existing tests PASS without issues.
---
### Task 2: Expand SessionFilter (Duration Fields + Cross-Entity)
**Files:**
- Modify: `games/filters.py:SessionFilter`
- Test: `tests/test_filters.py`
- [ ] **Step 1: Refactor SessionFilter and add new duration fields & device_filter**
Modify `SessionFilter` to replace `duration_minutes: IntCriterion` with `duration_total_minutes`, `duration_manual_minutes`, and `duration_calculated_minutes`. Add `device_filter: DeviceFilter`.
Update `to_q()` inside `SessionFilter` to map duration fields correctly to their respective GeneratedFields (`duration_total`, `duration_calculated`) or manual field (`duration_manual`). Use standard Python `timedelta` logic.
```python
# Inside SessionFilter class:
duration_total_minutes: IntCriterion | None = None
duration_manual_minutes: IntCriterion | None = None
duration_calculated_minutes: IntCriterion | None = None
# Cross-entity: sessions for devices matching these criteria
device_filter: DeviceFilter | None = None
```
```python
# Helper inside SessionFilter or refactored:
def _duration_to_q(self, c: IntCriterion, field: str) -> Q:
from datetime import timedelta
q = Q()
td_val = timedelta(minutes=c.value)
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"{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)})
return q
```
Then in `to_q()` inside `SessionFilter`:
```python
if self.duration_total_minutes is not None:
q &= self._duration_to_q(self.duration_total_minutes, "duration_total")
if self.duration_manual_minutes is not None:
q &= self._duration_to_q(self.duration_manual_minutes, "duration_manual")
if self.duration_calculated_minutes is not None:
q &= self._duration_to_q(self.duration_calculated_minutes, "duration_calculated")
# Cross-entity filter: device_filter
if self.device_filter is not None:
from games.models import Device
device_q = self.device_filter.to_q()
matching_ids = Device.objects.filter(device_q).values_list("id", flat=True)
q &= Q(device_id__in=matching_ids)
```
- [ ] **Step 2: Run tests to verify compiles correctly**
Run: `pytest tests/test_filters.py -v`
Expected: PASS (existing tests may need updating if they referenced `duration_minutes`).
---
### Task 3: Expand PurchaseFilter (Original Currency, Infinite, Needs Price Update, Converted Currency)
**Files:**
- Modify: `games/filters.py:PurchaseFilter`
- Test: `tests/test_filters.py`
- [ ] **Step 1: Add new fields to PurchaseFilter and platform_filter**
Expand `PurchaseFilter` with `infinite: BoolCriterion`, `needs_price_update: BoolCriterion`, `converted_currency: StringCriterion`, and `platform_filter: PlatformFilter`.
```python
# Inside PurchaseFilter class:
infinite: BoolCriterion | None = None
needs_price_update: BoolCriterion | None = None
converted_currency: StringCriterion | None = None
# Cross-entity
platform_filter: PlatformFilter | None = None
```
Update `to_q()` inside `PurchaseFilter`:
```python
if self.infinite is not None:
q &= self.infinite.to_q("infinite")
if self.needs_price_update is not None:
q &= self.needs_price_update.to_q("needs_price_update")
if self.converted_currency is not None:
q &= self.converted_currency.to_q("converted_currency")
# Cross-entity filter: platform_filter
if self.platform_filter is not None:
from games.models import Platform
platform_q = self.platform_filter.to_q()
matching_ids = Platform.objects.filter(platform_q).values_list("id", flat=True)
q &= Q(platform_id__in=matching_ids)
```
- [ ] **Step 2: Verify test suite continues to pass**
Run: `pytest tests/test_filters.py -v`
Expected: PASS
---
### Task 4: Expand GameFilter (Has Purchases, Has PlayEvents, Session Stats, Cross-Entity)
**Files:**
- Modify: `games/filters.py:GameFilter`
- Test: `tests/test_filters.py`
- [ ] **Step 1: Expand GameFilter with session stats, purchase/playevent existence, and cross-entity filters**
Add fields and cross-entity filters to `GameFilter`:
```python
# Inside GameFilter class:
has_purchases: BoolCriterion | None = None
has_playevents: BoolCriterion | None = None
session_count: IntCriterion | None = None
session_average: IntCriterion | None = None # average in minutes
# Cross-entity filters
session_filter: SessionFilter | None = None
purchase_filter: PurchaseFilter | None = None
playevent_filter: PlayEventFilter | None = None
platform_filter: PlatformFilter | None = None
```
Update `to_q()` inside `GameFilter`.
For existence and session stats filters, we use Subqueries to avoid complex inline annotations during the generic filter generation (which is much cleaner and less bug-prone):
```python
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.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)
q &= Q(id__in=matching_ids)
if self.session_average is not None:
from games.models import Game, Session
from django.db.models import Avg, F, ExpressionWrapper, DurationField
# Compute average session total duration.
# Avg returns an interval/duration type, so we can convert it to minutes in Python or do duration comparisons directly.
# To match the criterion easily, we can filter Game objects using 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)
q &= Q(id__in=matching_ids)
# Cross-entity filters
if self.session_filter is not None:
from games.models import Session
session_q = self.session_filter.to_q()
matching_ids = Session.objects.filter(session_q).values_list("game_id", flat=True)
q &= Q(id__in=matching_ids)
if self.purchase_filter is not None:
from games.models import Purchase
purchase_q = self.purchase_filter.to_q()
matching_ids = Purchase.objects.filter(purchase_q).values_list("games__id", flat=True)
q &= Q(id__in=matching_ids)
if self.playevent_filter is not None:
from games.models import PlayEvent
playevent_q = self.playevent_filter.to_q()
matching_ids = PlayEvent.objects.filter(playevent_q).values_list("game_id", flat=True)
q &= Q(id__in=matching_ids)
if self.platform_filter is not None:
from games.models import Platform
platform_q = self.platform_filter.to_q()
matching_ids = Platform.objects.filter(platform_q).values_list("id", flat=True)
q &= Q(platform_id__in=matching_ids)
```
Add a helper `_playtime_to_q_for_field` in `GameFilter` that works exactly like `_playtime_to_q` but accepts a customized field name (e.g. `s_avg`):
```python
@staticmethod
def _playtime_to_q_for_field(c: IntCriterion, field: str) -> Q:
from datetime import timedelta
m = c.modifier
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()
```
- [ ] **Step 2: Update existing `_playtime_to_q` to delegate to `_playtime_to_q_for_field`**
```python
@staticmethod
def _playtime_to_q(c: IntCriterion) -> Q:
return GameFilter._playtime_to_q_for_field(c, "playtime")
```
---
### Task 5: Add Exhaustive DB Tests for the Expanded and New Filters
**Files:**
- Modify: `tests/test_filters.py`
- [ ] **Step 1: Write DB-backed unit tests for the new filter behaviors**
Add comprehensive test cases inside `tests/test_filters.py` covering:
- New cross-entity filters (e.g. Platform -> Game -> Session -> Device chain).
- Session total vs manual vs calculated duration filters.
- Game session stats (`session_count`, `session_average`) and presence flags (`has_purchases`, `has_playevents`).
- Device, Platform, and PlayEvent specific filters.
```python
# Add test class at the end of tests/test_filters.py:
@pytest.mark.django_db
class TestExpandedFiltersAgainstDB:
def _setup_entities(self):
from games.models import Game, Platform, Device, Session, Purchase, PlayEvent
import datetime
from datetime import timedelta
# 1. Platform & Game
plat, _ = Platform.objects.get_or_create(name="Retro Console", group="Nintendo", icon="retro")
game, _ = Game.objects.get_or_create(name="Super Mario World", defaults={"platform": plat, "status": "f"})
game2, _ = Game.objects.get_or_create(name="Zelda", defaults={"platform": plat, "status": "u"})
# 2. Device & Session
dev, _ = Device.objects.get_or_create(name="Super Famicom", type="Console")
# Session 1: total 40 minutes (30 calc, 10 manual)
s1 = Session.objects.create(
game=game,
device=dev,
timestamp_start=datetime.datetime(2026, 6, 1, 12, 0, 0, tzinfo=datetime.timezone.utc),
timestamp_end=datetime.datetime(2026, 6, 1, 12, 30, 0, tzinfo=datetime.timezone.utc),
duration_manual=timedelta(minutes=10)
)
# 3. Purchase
pur = Purchase.objects.create(
platform=plat,
date_purchased=datetime.date(2026, 1, 1),
infinite=True,
price=49.99,
price_currency="JPY",
converted_price=45.00,
converted_currency="USD",
needs_price_update=False
)
pur.games.add(game)
# 4. PlayEvent
pe = PlayEvent.objects.create(
game=game,
started=datetime.date(2026, 6, 1),
ended=datetime.date(2026, 6, 2),
note="Completed 100%"
)
return {
"plat": plat,
"game": game,
"game2": game2,
"dev": dev,
"s1": s1,
"pur": pur,
"pe": pe
}
def test_device_filter_and_cross_entity(self):
from games.filters import DeviceFilter, SessionFilter
from games.models import Device
data = self._setup_entities()
# Find devices that have sessions on "Super Mario World"
df = DeviceFilter.from_json({
"session_filter": {
"game_filter": {
"name": {"value": "Super Mario World", "modifier": "EQUALS"}
}
}
})
results = list(Device.objects.filter(df.to_q()))
assert data["dev"] in results
def test_platform_filter_and_cross_entity(self):
from games.filters import PlatformFilter, GameFilter
from games.models import Platform
data = self._setup_entities()
# Find platforms with games that are finished
pf = PlatformFilter.from_json({
"game_filter": {
"status": {"value": ["f"], "modifier": "INCLUDES"}
}
})
results = list(Platform.objects.filter(pf.to_q()))
assert data["plat"] in results
def test_session_filter_duration_splits(self):
from games.filters import SessionFilter
from games.models import Session
data = self._setup_entities()
# Test duration_total_minutes equals 40
sf_tot = SessionFilter.from_json({
"duration_total_minutes": {"value": 40, "modifier": "EQUALS"}
})
assert Session.objects.filter(sf_tot.to_q()).count() == 1
# Test duration_manual_minutes equals 10
sf_man = SessionFilter.from_json({
"duration_manual_minutes": {"value": 10, "modifier": "EQUALS"}
})
assert Session.objects.filter(sf_man.to_q()).count() == 1
# Test duration_calculated_minutes equals 30
sf_calc = SessionFilter.from_json({
"duration_calculated_minutes": {"value": 30, "modifier": "EQUALS"}
})
assert Session.objects.filter(sf_calc.to_q()).count() == 1
def test_purchase_filter_new_fields(self):
from games.filters import PurchaseFilter
from games.models import Purchase
data = self._setup_entities()
pf = PurchaseFilter.from_json({
"infinite": {"value": True, "modifier": "EQUALS"},
"needs_price_update": {"value": False, "modifier": "EQUALS"},
"converted_currency": {"value": "USD", "modifier": "EQUALS"}
})
assert Purchase.objects.filter(pf.to_q()).count() == 1
def test_game_filter_stats_and_existence(self):
from games.filters import GameFilter
from games.models import Game
data = self._setup_entities()
# has_purchases = True
gf_pur = GameFilter.from_json({
"has_purchases": {"value": True, "modifier": "EQUALS"}
})
assert data["game"] in list(Game.objects.filter(gf_pur.to_q()))
assert data["game2"] not in list(Game.objects.filter(gf_pur.to_q()))
# session_count = 1
gf_cnt = GameFilter.from_json({
"session_count": {"value": 1, "modifier": "EQUALS"}
})
assert data["game"] in list(Game.objects.filter(gf_cnt.to_q()))
```
- [ ] **Step 2: Run all unit tests to confirm success**
Run: `pytest tests/test_filters.py -v`
Expected: ALL tests pass perfectly.
@@ -0,0 +1,577 @@
# Frontend Filters Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Implement a comprehensive frontend filter bar interface for all 6 list views (Games, Sessions, Purchases, Devices, Platforms, PlayEvents) with specific field controls, simple cross-entity toggles, and full JSON preset support.
**Architecture:** We will extend existing components in `common/components/filters.py` and implement new filter bars (`DeviceFilterBar`, `PlatformFilterBar`, `PlayEventFilterBar`). We will update the views in `games/views/` to parse standard filter JSON from `request.GET.get('filter')`, apply them to querysets, render the filter bars, and export them in `common/components/__init__.py`.
**Tech Stack:** Django, Python dataclasses, Pytest.
---
### Task 1: Update existing FilterBars in `common/components/filters.py`
**Files:**
- Modify: `common/components/filters.py`
- [ ] **Step 1: Add new fields to GameFilterBar**
Add checkboxes for `has_purchases`, `has_playevents` and RangeSliders for `session_count`, `session_average`.
```python
# Inside common/components/filters.py: FilterBar()
# Parse new values
has_purchases_value = _parse_bool(existing, "has_purchases")
has_playevents_value = _parse_bool(existing, "has_playevents")
session_count_min, session_count_max = _parse_range(existing, "session_count")
session_avg_min, session_avg_max = _parse_range(existing, "session_average")
# Add components to fields:
# 1. Under status and platform, add the checkboxes for purchases/playevents
# 2. Add RangeSliders for session count and average
```
Code change to apply in `FilterBar`:
```python
fields = [
Component(
tag_name="div",
attributes=[("class", _FILTER_GRID_CLASS)],
children=[
_filter_field(
"Status",
_enum_filter(
"status",
status_options,
status_choice,
nullable=not Game._meta.get_field("status").has_default(),
),
),
_filter_field(
"Platform",
_model_filter(
"platform",
platform_choice,
search_url="/api/platforms/search",
nullable=Game._meta.get_field("platform").null,
),
),
],
),
RangeSlider(
label="Year",
input_name_prefix="filter-year",
min_value=year_min,
max_value=year_max,
range_min=year_range_min,
range_max=year_range_max,
min_placeholder="e.g. 2020",
max_placeholder="e.g. 2024",
),
Component(
tag_name="div",
attributes=[("class", "flex items-end gap-4 mb-4")],
children=[
_filter_checkbox("filter-mastered", "Mastered", mastered_value),
_filter_checkbox("filter-has-purchases", "Has Purchases", has_purchases_value),
_filter_checkbox("filter-has-playevents", "Has Play Events", has_playevents_value),
],
),
RangeSlider(
label="Playtime",
input_name_prefix="filter-playtime",
min_value=playtime_min,
max_value=playtime_max,
range_min=0,
range_max=playtime_range_max,
step="1",
min_placeholder="e.g. 1",
max_placeholder="e.g. 100",
),
RangeSlider(
label="Session Count",
input_name_prefix="filter-session-count",
min_value=session_count_min,
max_value=session_count_max,
range_min=0,
range_max=100,
step="1",
min_placeholder="e.g. 1",
max_placeholder="e.g. 50",
),
RangeSlider(
label="Average Session Duration (mins)",
input_name_prefix="filter-session-average",
min_value=session_avg_min,
max_value=session_avg_max,
range_min=0,
range_max=240,
step="1",
min_placeholder="e.g. 10",
max_placeholder="e.g. 120",
),
]
```
- [ ] **Step 2: Update SessionFilterBar to support split duration fields**
Replace old `duration_minutes` RangeSlider with split total, manual, and calculated duration RangeSliders.
```python
# Inside common/components/filters.py: SessionFilterBar()
dur_tot_min, dur_tot_max = _parse_range(existing, "duration_total_minutes")
dur_man_min, dur_man_max = _parse_range(existing, "duration_manual_minutes")
dur_calc_min, dur_calc_max = _parse_range(existing, "duration_calculated_minutes")
# Inside fields array, replace RangeSlider "Duration" with:
RangeSlider(
label="Total Duration (mins)",
input_name_prefix="filter-duration-total-minutes",
min_value=dur_tot_min,
max_value=dur_tot_max,
range_min=0,
range_max=duration_range_max * 60, # Range sliders use minutes now
step="1",
min_placeholder="e.g. 30",
max_placeholder="e.g. 180",
),
RangeSlider(
label="Manual Duration (mins)",
input_name_prefix="filter-duration-manual-minutes",
min_value=dur_man_min,
max_value=dur_man_max,
range_min=0,
range_max=240,
step="1",
min_placeholder="e.g. 10",
max_placeholder="e.g. 120",
),
RangeSlider(
label="Calculated Duration (mins)",
input_name_prefix="filter-duration-calculated-minutes",
min_value=dur_calc_min,
max_value=dur_calc_max,
range_min=0,
range_max=duration_range_max * 60,
step="1",
min_placeholder="e.g. 30",
max_placeholder="e.g. 180",
),
```
- [ ] **Step 3: Update PurchaseFilterBar to support original and converted currencies and infinite flag**
Add Checkboxes `infinite`, `needs_price_update` and currency StringCriterion text field / Choice options.
```python
# Inside common/components/filters.py: PurchaseFilterBar()
infinite_value = _parse_bool(existing, "infinite")
needs_price_update_value = _parse_bool(existing, "needs_price_update")
price_currency_value = existing.get("price_currency", {}).get("value", "")
converted_currency_value = existing.get("converted_currency", {}).get("value", "")
# Expand fields component array with:
Component(
tag_name="div",
attributes=[("class", "flex gap-4 mb-4")],
children=[
_filter_checkbox("filter-refunded", "Refunded", is_refunded_value),
_filter_checkbox("filter-infinite", "Infinite", infinite_value),
_filter_checkbox("filter-needs-price-update", "Needs Price Update", needs_price_update_value),
],
),
```
Add currency text filters (as primitive `Input` controls for string criteria):
```python
Component(
tag_name="div",
attributes=[("class", _FILTER_GRID_CLASS)],
children=[
_filter_field(
"Original Currency",
Component(
tag_name="input",
attributes=[
("type", "text"),
("name", "filter-price_currency"),
("value", price_currency_value),
("placeholder", "e.g. USD, EUR"),
("class", "w-full rounded border-default-medium p-2 bg-neutral-secondary-medium text-body"),
],
),
),
_filter_field(
"Converted Currency",
Component(
tag_name="input",
attributes=[
("type", "text"),
("name", "filter-converted_currency"),
("value", converted_currency_value),
("placeholder", "e.g. USD, EUR"),
("class", "w-full rounded border-default-medium p-2 bg-neutral-secondary-medium text-body"),
],
),
),
],
),
```
---
### Task 2: Create New FilterBars in `common/components/filters.py`
**Files:**
- Modify: `common/components/filters.py`
- [ ] **Step 1: Implement DeviceFilterBar, PlatformFilterBar, and PlayEventFilterBar**
Append these three new filter bar components to `common/components/filters.py`:
```python
def DeviceFilterBar(
filter_json="", preset_list_url="", preset_save_url=""
) -> SafeText:
"""Collapsible filter bar for the Device list."""
from games.models import Device
existing = _filter_parse(filter_json)
type_options = Device.DEVICE_TYPES
type_choice = _filter_get_choice(existing, "type")
fields = [
Component(
tag_name="div",
attributes=[("class", _FILTER_GRID_CLASS)],
children=[
_filter_field(
"Device Type",
_enum_filter(
"type",
type_options,
type_choice,
nullable=True,
),
),
],
),
]
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
def PlatformFilterBar(
filter_json="", preset_list_url="", preset_save_url=""
) -> SafeText:
"""Collapsible filter bar for the Platform list."""
existing = _filter_parse(filter_json)
name_value = existing.get("name", {}).get("value", "")
group_value = existing.get("group", {}).get("value", "")
fields = [
Component(
tag_name="div",
attributes=[("class", _FILTER_GRID_CLASS)],
children=[
_filter_field(
"Platform Name",
Component(
tag_name="input",
attributes=[
("type", "text"),
("name", "filter-name"),
("value", name_value),
("placeholder", "e.g. Nintendo Switch"),
("class", "w-full rounded border-default-medium p-2 bg-neutral-secondary-medium text-body"),
],
),
),
_filter_field(
"Platform Group",
Component(
tag_name="input",
attributes=[
("type", "text"),
("name", "filter-group"),
("value", group_value),
("placeholder", "e.g. Nintendo"),
("class", "w-full rounded border-default-medium p-2 bg-neutral-secondary-medium text-body"),
],
),
),
],
),
]
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
def PlayEventFilterBar(
filter_json="", preset_list_url="", preset_save_url=""
) -> SafeText:
"""Collapsible filter bar for the PlayEvent list."""
from games.models import PlayEvent
existing = _filter_parse(filter_json)
game_choice = _filter_get_choice(existing, "game")
days_min, days_max = _parse_range(existing, "days_to_finish")
fields = [
Component(
tag_name="div",
attributes=[("class", _FILTER_GRID_CLASS)],
children=[
_filter_field(
"Game",
_model_filter(
"game",
game_choice,
search_url="/api/games/search",
nullable=False,
),
),
],
),
RangeSlider(
label="Days to Finish",
input_name_prefix="filter-days-to-finish",
min_value=days_min,
max_value=days_max,
range_min=0,
range_max=365,
step="1",
min_placeholder="e.g. 1",
max_placeholder="e.g. 30",
),
]
return _filter_bar(fields, filter_json, preset_list_url, preset_save_url)
```
- [ ] **Step 2: Export new FilterBars in `common/components/__init__.py`**
Modify: `common/components/__init__.py` to import and expose `DeviceFilterBar`, `PlatformFilterBar`, and `PlayEventFilterBar`.
```python
# Import section:
from common.components.filters import (
FilterBar,
PurchaseFilterBar,
SessionFilterBar,
DeviceFilterBar,
PlatformFilterBar,
PlayEventFilterBar,
)
# In __all__:
"FilterBar",
"PurchaseFilterBar",
"SessionFilterBar",
"DeviceFilterBar",
"PlatformFilterBar",
"PlayEventFilterBar",
```
---
### Task 3: Integrate FilterBars into `Device`, `Platform`, and `PlayEvent` views
**Files:**
- Modify: `games/views/device.py`
- Modify: `games/views/platform.py`
- Modify: `games/views/playevent.py`
- [ ] **Step 1: Integrate FilterBar in `list_devices` in `games/views/device.py`**
Import and parse the filter, apply to queryset, instantiate `DeviceFilterBar`, prepend it to the output page content.
```python
# At top of games/views/device.py:
from django.utils.safestring import mark_safe
from common.components import DeviceFilterBar, ModuleScript
from games.filters import parse_device_filter
# Inside list_devices(request):
devices = Device.objects.order_by("-created_at")
filter_json = request.GET.get("filter", "")
if filter_json:
device_filter = parse_device_filter(filter_json)
if device_filter is not None:
devices = devices.filter(device_filter.to_q())
devices, page_obj, elided_page_range = paginate(request, devices)
# ... create data dict ...
# Prepend the filter bar above table:
filter_bar = DeviceFilterBar(
filter_json=filter_json,
preset_list_url=reverse("games:list_presets") + "?mode=devices",
preset_save_url=reverse("games:save_preset") + "?mode=devices",
)
content = mark_safe(str(filter_bar) + str(content))
return render_page(
request,
content,
title="Manage devices",
scripts=ModuleScript("range_slider.js")
+ ModuleScript("search_select.js")
+ ModuleScript("filter_bar.js"),
)
```
- [ ] **Step 2: Integrate FilterBar in `list_platforms` in `games/views/platform.py`**
Import and parse the filter, apply to platform queryset, instantiate platform filter bar, prepend to page content.
```python
# At top of games/views/platform.py:
from django.utils.safestring import mark_safe
from common.components import PlatformFilterBar, ModuleScript
from games.filters import parse_platform_filter
# Inside list_platforms(request):
platforms = Platform.objects.order_by("name")
filter_json = request.GET.get("filter", "")
if filter_json:
platform_filter = parse_platform_filter(filter_json)
if platform_filter is not None:
platforms = platforms.filter(platform_filter.to_q())
platforms, page_obj, elided_page_range = paginate(request, platforms)
# ... create data dict ...
filter_bar = PlatformFilterBar(
filter_json=filter_json,
preset_list_url=reverse("games:list_presets") + "?mode=platforms",
preset_save_url=reverse("games:save_preset") + "?mode=platforms",
)
content = mark_safe(str(filter_bar) + str(content))
return render_page(
request,
content,
title="Manage platforms",
scripts=ModuleScript("range_slider.js")
+ ModuleScript("search_select.js")
+ ModuleScript("filter_bar.js"),
)
```
- [ ] **Step 3: Integrate FilterBar in `list_playevents` in `games/views/playevent.py`**
Import and parse the filter, apply to playevent queryset, instantiate playevent filter bar, prepend to page content.
```python
# At top of games/views/playevent.py:
from django.utils.safestring import mark_safe
from common.components import PlayEventFilterBar
from games.filters import parse_playevent_filter
# Inside list_playevents(request):
playevents = PlayEvent.objects.order_by("-created_at")
filter_json = request.GET.get("filter", "")
if filter_json:
playevent_filter = parse_playevent_filter(filter_json)
if playevent_filter is not None:
playevents = playevents.filter(playevent_filter.to_q())
playevents, page_obj, elided_page_range = paginate(request, playevents)
# ... create data ...
filter_bar = PlayEventFilterBar(
filter_json=filter_json,
preset_list_url=reverse("games:list_presets") + "?mode=playevents",
preset_save_url=reverse("games:save_preset") + "?mode=playevents",
)
content = mark_safe(str(filter_bar) + str(content))
return render_page(
request,
content,
title="Manage play events",
scripts=ModuleScript("range_slider.js")
+ ModuleScript("search_select.js")
+ ModuleScript("filter_bar.js"),
)
```
---
### Task 4: Support new preset modes in Preset View/Model
Ensure FilterPreset allows `devices` and `platforms` modes.
**Files:**
- Modify: `games/models.py`
- Modify: `games/views/filter_presets.py`
- [ ] **Step 1: Expand FilterPreset mode choices**
Verify or expand `MODE_CHOICES` inside `FilterPreset` model in `games/models.py`.
```python
# Inside FilterPreset class:
MODE_CHOICES = [
("games", "Games"),
("sessions", "Sessions"),
("purchases", "Purchases"),
("playevents", "Play Events"),
("devices", "Devices"),
("platforms", "Platforms"),
]
```
---
### Task 5: Add Render Tests for new FilterBars
**Files:**
- Modify: `tests/test_filter_bars.py`
- [ ] **Step 1: Write tests to verify new FilterBars render correctly**
Add test cases in `tests/test_filter_bars.py`:
```python
def test_device_filter_bar(self):
from common.components import DeviceFilterBar
html = str(
DeviceFilterBar(
filter_json="",
preset_list_url="/presets/devices/list",
preset_save_url="/presets/devices/save",
)
)
self._assert_shell(html, "/presets/devices/list", "/presets/devices/save")
def test_platform_filter_bar(self):
from common.components import PlatformFilterBar
html = str(
PlatformFilterBar(
filter_json="",
preset_list_url="/presets/platforms/list",
preset_save_url="/presets/platforms/save",
)
)
self._assert_shell(html, "/presets/platforms/list", "/presets/platforms/save")
def test_playevent_filter_bar(self):
from common.components import PlayEventFilterBar
html = str(
PlayEventFilterBar(
filter_json="",
preset_list_url="/presets/playevents/list",
preset_save_url="/presets/playevents/save",
)
)
self._assert_shell(html, "/presets/playevents/list", "/presets/playevents/save")
```
- [ ] **Step 2: Run all test suites to confirm complete success**
Run: `pytest tests/test_filter_bars.py -v`
Expected: ALL filter bar render tests pass.
+360 -36
View File
@@ -64,9 +64,20 @@ class GameFilter(OperatorFilter):
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
# Free-text search (combines name + sort_name + platform name)
search: StringCriterion | None = None
# Cross-entity filters
session_filter: SessionFilter | None = None
purchase_filter: PurchaseFilter | None = None
playevent_filter: PlayEventFilter | None = None
platform_filter: PlatformFilter | None = None
def to_q(self) -> Q:
q = Q()
@@ -94,6 +105,34 @@ 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.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)
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)
q &= Q(id__in=matching_ids)
# ── free-text search (OR across multiple fields) ──
if self.search is not None and self.search.value:
search_q = (
@@ -105,6 +144,31 @@ class GameFilter(OperatorFilter):
search_q = ~search_q
q &= search_q
# Cross-entity filters
if self.session_filter is not None:
from games.models import Session
session_q = self.session_filter.to_q()
matching_ids = Session.objects.filter(session_q).values_list("game_id", flat=True)
q &= Q(id__in=matching_ids)
if self.purchase_filter is not None:
from games.models import Purchase
purchase_q = self.purchase_filter.to_q()
matching_ids = Purchase.objects.filter(purchase_q).values_list("games__id", flat=True)
q &= Q(id__in=matching_ids)
if self.playevent_filter is not None:
from games.models import PlayEvent
playevent_q = self.playevent_filter.to_q()
matching_ids = PlayEvent.objects.filter(playevent_q).values_list("game_id", flat=True)
q &= Q(id__in=matching_ids)
if self.platform_filter is not None:
from games.models import Platform
platform_q = self.platform_filter.to_q()
matching_ids = Platform.objects.filter(platform_q).values_list("id", flat=True)
q &= Q(platform_id__in=matching_ids)
# ── AND / OR / NOT sub-filters ──
sub = self.sub_filter()
if sub is not None:
@@ -119,6 +183,10 @@ class GameFilter(OperatorFilter):
@staticmethod
def _playtime_to_q(c: IntCriterion) -> Q:
return GameFilter._playtime_to_q_for_field(c, "playtime")
@staticmethod
def _playtime_to_q_for_field(c: IntCriterion, field: str) -> Q:
"""Convert minutes-based criterion to a DurationField Q object.
Django stores DurationField as microseconds in SQLite, so we convert
@@ -129,7 +197,6 @@ class GameFilter(OperatorFilter):
from common.criteria import Modifier
m = c.modifier
field = "playtime"
td_val = timedelta(minutes=c.value)
if m == Modifier.EQUALS:
@@ -180,7 +247,10 @@ class SessionFilter(OperatorFilter):
device: MultiCriterion | None = None # filters on device_id
emulated: BoolCriterion | None = None
note: StringCriterion | None = None
duration_minutes: IntCriterion | None = None # on duration_total
duration_minutes: IntCriterion | None = None # on duration_total (legacy alias)
duration_total_minutes: IntCriterion | None = None
duration_manual_minutes: IntCriterion | None = None
duration_calculated_minutes: IntCriterion | None = None
is_active: BoolCriterion | None = None # timestamp_end IS NULL
timestamp_start: StringCriterion | None = None # date string
timestamp_end: StringCriterion | None = None # date string
@@ -193,6 +263,46 @@ class SessionFilter(OperatorFilter):
# Cross-entity: sessions for games matching these criteria
game_filter: GameFilter | None = None
# Cross-entity: sessions for devices matching these criteria
device_filter: DeviceFilter | None = None
def _duration_to_q(self, c: IntCriterion, field: str) -> Q:
from datetime import timedelta
q = Q()
td_val = timedelta(minutes=c.value)
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)})
return q
def to_q(self) -> Q:
from datetime import timedelta
@@ -207,40 +317,13 @@ class SessionFilter(OperatorFilter):
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)})
q &= self._duration_to_q(self.duration_minutes, "duration_total")
if self.duration_total_minutes is not None:
q &= self._duration_to_q(self.duration_total_minutes, "duration_total")
if self.duration_manual_minutes is not None:
q &= self._duration_to_q(self.duration_manual_minutes, "duration_manual")
if self.duration_calculated_minutes is not None:
q &= self._duration_to_q(self.duration_calculated_minutes, "duration_calculated")
if self.is_active is not None:
if self.is_active.value:
q &= Q(timestamp_end__isnull=True)
@@ -278,6 +361,14 @@ class SessionFilter(OperatorFilter):
matching_ids = Game.objects.filter(game_q).values_list("id", flat=True)
q &= Q(game_id__in=matching_ids)
# Cross-entity filter: sessions for devices matching DeviceFilter
if self.device_filter is not None:
from games.models import Device
device_q = self.device_filter.to_q()
matching_ids = Device.objects.filter(device_q).values_list("id", flat=True)
q &= Q(device_id__in=matching_ids)
# AND / OR / NOT
sub = self.sub_filter()
if sub is not None:
@@ -317,12 +408,19 @@ class PurchaseFilter(OperatorFilter):
created_at: StringCriterion | None = None
updated_at: StringCriterion | None = None
infinite: BoolCriterion | None = None
needs_price_update: BoolCriterion | None = None
converted_currency: StringCriterion | None = None
# Free-text search
search: StringCriterion | None = None
# Cross-entity: purchases for games matching these criteria
game_filter: GameFilter | None = None
# Cross-entity: purchases for platforms matching these criteria
platform_filter: PlatformFilter | None = None
def to_q(self) -> Q:
q = Q()
@@ -354,6 +452,12 @@ class PurchaseFilter(OperatorFilter):
q &= self.created_at.to_q("created_at")
if self.updated_at is not None:
q &= self.updated_at.to_q("updated_at")
if self.infinite is not None:
q &= self.infinite.to_q("infinite")
if self.needs_price_update is not None:
q &= self.needs_price_update.to_q("needs_price_update")
if self.converted_currency is not None:
q &= self.converted_currency.to_q("converted_currency")
# Free-text search
if self.search is not None and self.search.value:
@@ -374,6 +478,14 @@ class PurchaseFilter(OperatorFilter):
matching_ids = Game.objects.filter(game_q).values_list("id", flat=True)
q &= Q(games__id__in=matching_ids)
# Cross-entity platform filter
if self.platform_filter is not None:
from games.models import Platform
platform_q = self.platform_filter.to_q()
matching_ids = Platform.objects.filter(platform_q).values_list("id", flat=True)
q &= Q(platform_id__in=matching_ids)
sub = self.sub_filter()
if sub is not None:
if self.AND is not None:
@@ -442,6 +554,206 @@ class PurchaseFilter(OperatorFilter):
return criterion.to_q("games")
# ── DeviceFilter ───────────────────────────────────────────────────────────
@dataclass
class DeviceFilter(OperatorFilter):
"""Filter for the Device model."""
AND: DeviceFilter | None = None
OR: DeviceFilter | None = None
NOT: DeviceFilter | None = None
name: StringCriterion | None = None
type: ChoiceCriterion | None = None
created_at: StringCriterion | None = None
# Free-text search
search: StringCriterion | None = None
# Cross-entity: Devices that have sessions matching these criteria
session_filter: SessionFilter | None = None
def to_q(self) -> Q:
q = Q()
if self.name is not None:
q &= self.name.to_q("name")
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")
# Free-text search
if self.search is not None and self.search.value:
search_q = (
Q(name__icontains=self.search.value)
| Q(type__icontains=self.search.value)
)
if self.search.modifier == Modifier.EXCLUDES:
search_q = ~search_q
q &= search_q
# Cross-entity filter: session_filter
if self.session_filter is not None:
from games.models import Session
session_q = self.session_filter.to_q()
matching_ids = Session.objects.filter(session_q).values_list("device_id", flat=True)
q &= Q(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
# ── PlatformFilter ─────────────────────────────────────────────────────────
@dataclass
class PlatformFilter(OperatorFilter):
"""Filter for the Platform model."""
AND: PlatformFilter | None = None
OR: PlatformFilter | None = None
NOT: PlatformFilter | None = None
name: StringCriterion | None = None
group: StringCriterion | None = None
icon: StringCriterion | None = None
created_at: StringCriterion | None = None
# Free-text search
search: StringCriterion | None = None
# Cross-entity
game_filter: GameFilter | None = None
purchase_filter: PurchaseFilter | None = None
def to_q(self) -> Q:
q = Q()
if self.name is not None:
q &= self.name.to_q("name")
if self.group is not None:
q &= self.group.to_q("group")
if self.icon is not None:
q &= self.icon.to_q("icon")
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(name__icontains=self.search.value)
| Q(group__icontains=self.search.value)
)
if self.search.modifier == Modifier.EXCLUDES:
search_q = ~search_q
q &= search_q
# Cross-entity filter: game_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("platform_id", flat=True)
q &= Q(id__in=matching_ids)
# Cross-entity filter: purchase_filter
if self.purchase_filter is not None:
from games.models import Purchase
purchase_q = self.purchase_filter.to_q()
matching_ids = Purchase.objects.filter(purchase_q).values_list("platform_id", flat=True)
q &= Q(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
# ── PlayEventFilter ────────────────────────────────────────────────────────
@dataclass
class PlayEventFilter(OperatorFilter):
"""Filter for the PlayEvent model."""
AND: PlayEventFilter | None = None
OR: PlayEventFilter | None = None
NOT: PlayEventFilter | None = None
game: MultiCriterion | None = None # filters on game_id
started: StringCriterion | None = None # date string
ended: StringCriterion | None = None # date string
days_to_finish: IntCriterion | None = None
note: StringCriterion | None = None
created_at: StringCriterion | None = None
# Free-text search
search: StringCriterion | None = None
# Cross-entity: PlayEvents for games matching these criteria
game_filter: GameFilter | None = None
def to_q(self) -> Q:
q = Q()
if self.game is not None:
q &= self.game.to_q("game_id")
if self.started is not None:
q &= self.started.to_q("started")
if self.ended is not None:
q &= self.ended.to_q("ended")
if self.days_to_finish is not None:
q &= self.days_to_finish.to_q("days_to_finish")
if self.note is not None:
q &= self.note.to_q("note")
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(note__icontains=self.search.value)
)
if self.search.modifier == Modifier.EXCLUDES:
search_q = ~search_q
q &= search_q
# Cross-entity filter: game_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(game_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 ────────────────────────────────────────────────────
@@ -455,3 +767,15 @@ def parse_session_filter(json_str: str) -> SessionFilter | None:
def parse_purchase_filter(json_str: str) -> PurchaseFilter | None:
return filter_from_json(PurchaseFilter, json_str)
def parse_device_filter(json_str: str) -> DeviceFilter | None:
return filter_from_json(DeviceFilter, json_str)
def parse_platform_filter(json_str: str) -> PlatformFilter | None:
return filter_from_json(PlatformFilter, json_str)
def parse_playevent_filter(json_str: str) -> PlayEventFilter | None:
return filter_from_json(PlayEventFilter, json_str)
+2
View File
@@ -501,6 +501,8 @@ class FilterPreset(models.Model):
("sessions", "Sessions"),
("purchases", "Purchases"),
("playevents", "Play Events"),
("devices", "Devices"),
("platforms", "Platforms"),
]
name = models.CharField(max_length=255)
-120
View File
@@ -293,85 +293,27 @@
--leading-5: 20px;
--radius-base: 12px;
--color-body: var(--color-gray-600);
--color-body-subtle: var(--color-gray-500);
--color-heading: var(--color-gray-900);
--color-fg-brand-subtle: var(--color-blue-200);
--color-fg-brand: var(--color-blue-700);
--color-fg-brand-strong: var(--color-blue-900);
--color-fg-success: var(--color-emerald-700);
--color-fg-success-strong: var(--color-emerald-900);
--color-fg-danger: var(--color-rose-700);
--color-fg-danger-strong: var(--color-rose-900);
--color-fg-warning-subtle: var(--color-orange-600);
--color-fg-warning: var(--color-orange-900);
--color-fg-yellow: var(--color-yellow-400);
--color-fg-disabled: var(--color-gray-400);
--color-fg-purple: var(--color-purple-600);
--color-fg-cyan: var(--color-cyan-600);
--color-fg-indigo: var(--color-indigo-600);
--color-fg-pink: var(--color-pink-600);
--color-fg-lime: var(--color-lime-600);
--color-neutral-primary-soft: var(--color-white);
--color-neutral-primary: var(--color-white);
--color-neutral-primary-medium: var(--color-white);
--color-neutral-primary-strong: var(--color-white);
--color-neutral-secondary-soft: var(--color-gray-50);
--color-neutral-secondary: var(--color-gray-50);
--color-neutral-secondary-medium: var(--color-gray-50);
--color-neutral-secondary-strong: var(--color-gray-50);
--color-neutral-secondary-strongest: var(--color-gray-50);
--color-neutral-tertiary-soft: var(--color-gray-100);
--color-neutral-tertiary: var(--color-gray-100);
--color-neutral-tertiary-medium: var(--color-gray-100);
--color-neutral-quaternary: var(--color-gray-200);
--color-neutral-quaternary-medium: var(--color-gray-200);
--color-gray: var(--color-gray-300);
--color-brand-softer: var(--color-blue-50);
--color-brand-soft: var(--color-blue-100);
--color-brand: var(--color-blue-700);
--color-brand-medium: var(--color-blue-200);
--color-brand-strong: var(--color-blue-800);
--color-success-soft: var(--color-emerald-50);
--color-success: var(--color-emerald-700);
--color-success-medium: var(--color-emerald-100);
--color-success-strong: var(--color-emerald-800);
--color-danger-soft: var(--color-rose-50);
--color-danger: var(--color-rose-700);
--color-danger-medium: var(--color-rose-100);
--color-danger-strong: var(--color-rose-800);
--color-warning-soft: var(--color-orange-50);
--color-warning: var(--color-orange-500);
--color-warning-medium: var(--color-orange-100);
--color-warning-strong: var(--color-orange-700);
--color-dark-soft: var(--color-gray-800);
--color-dark: var(--color-gray-800);
--color-dark-strong: var(--color-gray-900);
--color-disabled: var(--color-gray-100);
--color-purple: var(--color-purple-500);
--color-sky: var(--color-sky-500);
--color-teal: var(--color-teal-600);
--color-pink: var(--color-pink-600);
--color-cyan: var(--color-cyan-500);
--color-fuchsia: var(--color-fuchsia-600);
--color-indigo: var(--color-indigo-600);
--color-orange: var(--color-orange-400);
--color-buffer: var(--color-white);
--color-buffer-medium: var(--color-white);
--color-buffer-strong: var(--color-white);
--color-muted: var(--color-gray-50);
--color-light-subtle: var(--color-gray-100);
--color-light: var(--color-gray-100);
--color-light-medium: var(--color-gray-100);
--color-default-subtle: var(--color-gray-200);
--color-default: var(--color-gray-200);
--color-default-medium: var(--color-gray-200);
--color-default-strong: var(--color-gray-200);
--color-success-subtle: var(--color-emerald-200);
--color-danger-subtle: var(--color-rose-200);
--color-warning-subtle: var(--color-orange-200);
--color-brand-subtle: var(--color-blue-200);
--color-brand-light: var(--color-blue-600);
--color-dark-subtle: var(--color-gray-800);
--color-dark-backdrop: var(--color-gray-950);
--color-accent: #7c3aed;
}
@@ -881,18 +823,12 @@
.start-0 {
inset-inline-start: calc(var(--spacing) * 0);
}
.end-1 {
inset-inline-end: calc(var(--spacing) * 1);
}
.end-1\.5 {
inset-inline-end: calc(var(--spacing) * 1.5);
}
.top-0 {
top: calc(var(--spacing) * 0);
}
.top-1 {
top: calc(var(--spacing) * 1);
}
.top-1\/2 {
top: calc(1 / 2 * 100%);
}
@@ -914,9 +850,6 @@
.bottom-0 {
bottom: calc(var(--spacing) * 0);
}
.bottom-1 {
bottom: calc(var(--spacing) * 1);
}
.bottom-1\.5 {
bottom: calc(var(--spacing) * 1.5);
}
@@ -1626,15 +1559,9 @@
text-align: center;
}
}
.w-1 {
width: calc(var(--spacing) * 1);
}
.w-1\/2 {
width: calc(1 / 2 * 100%);
}
.w-2 {
width: calc(var(--spacing) * 2);
}
.w-2\.5 {
width: calc(var(--spacing) * 2.5);
}
@@ -1752,9 +1679,6 @@
.shrink-0 {
flex-shrink: 0;
}
.border-collapse {
border-collapse: collapse;
}
.-translate-x-full {
--tw-translate-x: -100%;
translate: var(--tw-translate-x) var(--tw-translate-y);
@@ -1771,10 +1695,6 @@
--tw-translate-x: 100%;
translate: var(--tw-translate-x) var(--tw-translate-y);
}
.-translate-y-1 {
--tw-translate-y: calc(var(--spacing) * -1);
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);
@@ -2160,18 +2080,12 @@
.bg-amber-50 {
background-color: var(--color-amber-50);
}
.bg-amber-500 {
background-color: var(--color-amber-500);
}
.bg-amber-500\/15 {
background-color: color-mix(in srgb, oklch(76.9% 0.188 70.08) 15%, transparent);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-amber-500) 15%, transparent);
}
}
.bg-black {
background-color: var(--color-black);
}
.bg-black\/70 {
background-color: color-mix(in srgb, #000 70%, transparent);
@supports (color: color-mix(in lab, red, red)) {
@@ -2196,9 +2110,6 @@
background-color: color-mix(in oklab, var(--color-brand) 15%, transparent);
}
}
.bg-dark-backdrop {
background-color: var(--color-dark-backdrop);
}
.bg-dark-backdrop\/70 {
background-color: color-mix(in srgb, oklch(13% 0.028 261.692) 70%, transparent);
@supports (color: color-mix(in lab, red, red)) {
@@ -2217,18 +2128,12 @@
.bg-gray-500 {
background-color: var(--color-gray-500);
}
.bg-gray-800 {
background-color: var(--color-gray-800);
}
.bg-gray-800\/20 {
background-color: color-mix(in srgb, oklch(27.8% 0.033 256.848) 20%, transparent);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-gray-800) 20%, transparent);
}
}
.bg-gray-900 {
background-color: var(--color-gray-900);
}
.bg-gray-900\/50 {
background-color: color-mix(in srgb, oklch(21% 0.034 264.665) 50%, transparent);
@supports (color: color-mix(in lab, red, red)) {
@@ -2358,18 +2263,6 @@
fill: white !important;
}
}
.apexcharts-gridline {
stroke: var(--color-default) !important;
.dark & {
stroke: var(--color-default) !important;
}
}
.apexcharts-xcrosshairs {
stroke: var(--color-default) !important;
.dark & {
stroke: var(--color-default) !important;
}
}
.apexcharts-ycrosshairs {
stroke: var(--color-default) !important;
.dark & {
@@ -2428,9 +2321,6 @@
.px-6 {
padding-inline: calc(var(--spacing) * 6);
}
.py-0 {
padding-block: calc(var(--spacing) * 0);
}
.py-0\.5 {
padding-block: calc(var(--spacing) * 0.5);
}
@@ -2657,9 +2547,6 @@
.text-balance {
text-wrap: balance;
}
.text-wrap {
text-wrap: wrap;
}
.whitespace-nowrap {
white-space: nowrap;
}
@@ -2795,9 +2682,6 @@
.line-through {
text-decoration-line: line-through;
}
.no-underline {
text-decoration-line: none;
}
.no-underline\! {
text-decoration-line: none !important;
}
@@ -2864,10 +2748,6 @@
-webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
}
.backdrop-filter {
-webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
}
.transition {
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events;
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
+27 -4
View File
@@ -2,6 +2,7 @@ from django.contrib.auth.decorators import login_required
from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils.safestring import mark_safe
from common.components import (
A,
@@ -10,19 +11,28 @@ from common.components import (
ButtonGroup,
Icon,
paginated_table_content,
DeviceFilterBar,
ModuleScript,
)
from common.layout import render_page
from common.time import dateformat, local_strftime
from common.utils import paginate
from games.filters import parse_device_filter
from games.forms import DeviceForm
from games.models import Device
@login_required
def list_devices(request: HttpRequest) -> HttpResponse:
devices, page_obj, elided_page_range = paginate(
request, Device.objects.order_by("-created_at")
)
devices = Device.objects.order_by("-created_at")
filter_json = request.GET.get("filter", "")
if filter_json:
device_filter = parse_device_filter(filter_json)
if device_filter is not None:
devices = devices.filter(device_filter.to_q())
devices, page_obj, elided_page_range = paginate(request, devices)
data = {
"header_action": A([], Button([], "Add device"), url_name="games:add_device"),
@@ -61,7 +71,20 @@ def list_devices(request: HttpRequest) -> HttpResponse:
elided_page_range=elided_page_range,
request=request,
)
return render_page(request, content, title="Manage devices")
filter_bar = DeviceFilterBar(
filter_json=filter_json,
preset_list_url=reverse("games:list_presets") + "?mode=devices",
preset_save_url=reverse("games:save_preset") + "?mode=devices",
)
content = mark_safe(str(filter_bar) + str(content))
return render_page(
request,
content,
title="Manage devices",
scripts=ModuleScript("range_slider.js")
+ ModuleScript("search_select.js")
+ ModuleScript("filter_bar.js"),
)
@login_required
+27 -4
View File
@@ -2,6 +2,7 @@ from django.contrib.auth.decorators import login_required
from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils.safestring import mark_safe
from common.components import (
A,
@@ -10,10 +11,13 @@ from common.components import (
ButtonGroup,
Icon,
paginated_table_content,
PlatformFilterBar,
ModuleScript,
)
from common.layout import render_page
from common.time import dateformat, local_strftime
from common.utils import paginate
from games.filters import parse_platform_filter
from games.forms import PlatformForm
from games.models import Platform
from games.views.general import use_custom_redirect
@@ -21,9 +25,15 @@ from games.views.general import use_custom_redirect
@login_required
def list_platforms(request: HttpRequest) -> HttpResponse:
platforms, page_obj, elided_page_range = paginate(
request, Platform.objects.order_by("name")
)
platforms = Platform.objects.order_by("name")
filter_json = request.GET.get("filter", "")
if filter_json:
platform_filter = parse_platform_filter(filter_json)
if platform_filter is not None:
platforms = platforms.filter(platform_filter.to_q())
platforms, page_obj, elided_page_range = paginate(request, platforms)
data = {
"header_action": A(
@@ -68,7 +78,20 @@ def list_platforms(request: HttpRequest) -> HttpResponse:
elided_page_range=elided_page_range,
request=request,
)
return render_page(request, content, title="Manage platforms")
filter_bar = PlatformFilterBar(
filter_json=filter_json,
preset_list_url=reverse("games:list_presets") + "?mode=platforms",
preset_save_url=reverse("games:save_preset") + "?mode=platforms",
)
content = mark_safe(str(filter_bar) + str(content))
return render_page(
request,
content,
title="Manage platforms",
scripts=ModuleScript("range_slider.js")
+ ModuleScript("search_select.js")
+ ModuleScript("filter_bar.js"),
)
@login_required
+27 -4
View File
@@ -9,6 +9,8 @@ from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.shortcuts import get_object_or_404
from django.urls import reverse
from django.utils.safestring import mark_safe
from common.components import (
A,
AddForm,
@@ -17,10 +19,12 @@ from common.components import (
Icon,
ModuleScript,
paginated_table_content,
PlayEventFilterBar,
)
from common.layout import render_page
from common.time import dateformat, format_duration, local_strftime
from common.utils import paginate
from games.filters import parse_playevent_filter
from games.forms import PlayEventForm
from games.models import Game, PlayEvent, Session
@@ -126,9 +130,15 @@ def _get_formatted_playtime_for_game_sessions_in_range(
@login_required
def list_playevents(request: HttpRequest) -> HttpResponse:
playevents, page_obj, elided_page_range = paginate(
request, PlayEvent.objects.order_by("-created_at")
)
playevents = PlayEvent.objects.order_by("-created_at")
filter_json = request.GET.get("filter", "")
if filter_json:
playevent_filter = parse_playevent_filter(filter_json)
if playevent_filter is not None:
playevents = playevents.filter(playevent_filter.to_q())
playevents, page_obj, elided_page_range = paginate(request, playevents)
data = create_playevent_tabledata(playevents, request=request)
content = paginated_table_content(
data,
@@ -136,7 +146,20 @@ def list_playevents(request: HttpRequest) -> HttpResponse:
elided_page_range=elided_page_range,
request=request,
)
return render_page(request, content, title="Manage play events")
filter_bar = PlayEventFilterBar(
filter_json=filter_json,
preset_list_url=reverse("games:list_presets") + "?mode=playevents",
preset_save_url=reverse("games:save_preset") + "?mode=playevents",
)
content = mark_safe(str(filter_bar) + str(content))
return render_page(
request,
content,
title="Manage play events",
scripts=ModuleScript("range_slider.js")
+ ModuleScript("search_select.js")
+ ModuleScript("filter_bar.js"),
)
@login_required
+33
View File
@@ -186,3 +186,36 @@ class FilterBarRenderingTest(TestCase):
self.assertNotIn("data-match=", html)
self.assertIn("Finished", html)
self.assertNoEscapedTags(html)
def test_device_filter_bar(self):
from common.components import DeviceFilterBar
html = str(
DeviceFilterBar(
filter_json="",
preset_list_url="/presets/devices/list",
preset_save_url="/presets/devices/save",
)
)
self._assert_shell(html, "/presets/devices/list", "/presets/devices/save")
def test_platform_filter_bar(self):
from common.components import PlatformFilterBar
html = str(
PlatformFilterBar(
filter_json="",
preset_list_url="/presets/platforms/list",
preset_save_url="/presets/platforms/save",
)
)
self._assert_shell(html, "/presets/platforms/list", "/presets/platforms/save")
def test_playevent_filter_bar(self):
from common.components import PlayEventFilterBar
html = str(
PlayEventFilterBar(
filter_json="",
preset_list_url="/presets/playevents/list",
preset_save_url="/presets/playevents/save",
)
)
self._assert_shell(html, "/presets/playevents/list", "/presets/playevents/save")
+142
View File
@@ -657,3 +657,145 @@ class TestPurchaseNumPurchasesAgainstDB:
)
result = set(Purchase.objects.filter(pf.to_q()))
assert result == {seeded["single"]}
@pytest.mark.django_db
class TestExpandedFiltersAgainstDB:
def _setup_entities(self):
from games.models import Game, Platform, Device, Session, Purchase, PlayEvent
import datetime
from datetime import timedelta
# 1. Platform & Game
plat, _ = Platform.objects.get_or_create(name="Retro Console", group="Nintendo", icon="retro")
game, _ = Game.objects.get_or_create(name="Super Mario World", defaults={"platform": plat, "status": "f"})
game2, _ = Game.objects.get_or_create(name="Zelda", defaults={"platform": plat, "status": "u"})
# 2. Device & Session
dev, _ = Device.objects.get_or_create(name="Super Famicom", type="Console")
# Session 1: total 40 minutes (30 calc, 10 manual)
s1 = Session.objects.create(
game=game,
device=dev,
timestamp_start=datetime.datetime(2026, 6, 1, 12, 0, 0, tzinfo=datetime.timezone.utc),
timestamp_end=datetime.datetime(2026, 6, 1, 12, 30, 0, tzinfo=datetime.timezone.utc),
duration_manual=timedelta(minutes=10)
)
# 3. Purchase
pur = Purchase.objects.create(
platform=plat,
date_purchased=datetime.date(2026, 1, 1),
infinite=True,
price=49.99,
price_currency="JPY",
converted_price=45.00,
converted_currency="USD",
needs_price_update=False
)
pur.games.add(game)
# 4. PlayEvent
pe = PlayEvent.objects.create(
game=game,
started=datetime.date(2026, 6, 1),
ended=datetime.date(2026, 6, 2),
note="Completed 100%"
)
return {
"plat": plat,
"game": game,
"game2": game2,
"dev": dev,
"s1": s1,
"pur": pur,
"pe": pe
}
def test_device_filter_and_cross_entity(self):
from games.filters import DeviceFilter
from games.models import Device
data = self._setup_entities()
# Find devices that have sessions on "Super Mario World"
df = DeviceFilter.from_json({
"session_filter": {
"game_filter": {
"name": {"value": "Super Mario World", "modifier": "EQUALS"}
}
}
})
results = list(Device.objects.filter(df.to_q()))
assert data["dev"] in results
def test_platform_filter_and_cross_entity(self):
from games.filters import PlatformFilter
from games.models import Platform
data = self._setup_entities()
# Find platforms with games that are finished
pf = PlatformFilter.from_json({
"game_filter": {
"status": {"value": ["f"], "modifier": "INCLUDES"}
}
})
results = list(Platform.objects.filter(pf.to_q()))
assert data["plat"] in results
def test_session_filter_duration_splits(self):
from games.filters import SessionFilter
from games.models import Session
data = self._setup_entities()
# Test duration_total_minutes equals 40
sf_tot = SessionFilter.from_json({
"duration_total_minutes": {"value": 40, "modifier": "EQUALS"}
})
assert Session.objects.filter(sf_tot.to_q()).count() == 1
# Test duration_manual_minutes equals 10
sf_man = SessionFilter.from_json({
"duration_manual_minutes": {"value": 10, "modifier": "EQUALS"}
})
assert Session.objects.filter(sf_man.to_q()).count() == 1
# Test duration_calculated_minutes equals 30
sf_calc = SessionFilter.from_json({
"duration_calculated_minutes": {"value": 30, "modifier": "EQUALS"}
})
assert Session.objects.filter(sf_calc.to_q()).count() == 1
def test_purchase_filter_new_fields(self):
from games.filters import PurchaseFilter
from games.models import Purchase
data = self._setup_entities()
pf = PurchaseFilter.from_json({
"infinite": {"value": True, "modifier": "EQUALS"},
"needs_price_update": {"value": False, "modifier": "EQUALS"},
"converted_currency": {"value": "USD", "modifier": "EQUALS"}
})
assert Purchase.objects.filter(pf.to_q()).count() == 1
def test_game_filter_stats_and_existence(self):
from games.filters import GameFilter
from games.models import Game
data = self._setup_entities()
# has_purchases = True
gf_pur = GameFilter.from_json({
"has_purchases": {"value": True, "modifier": "EQUALS"}
})
assert data["game"] in list(Game.objects.filter(gf_pur.to_q()))
assert data["game2"] not in list(Game.objects.filter(gf_pur.to_q()))
# session_count = 1
gf_cnt = GameFilter.from_json({
"session_count": {"value": 1, "modifier": "EQUALS"}
})
assert data["game"] in list(Game.objects.filter(gf_cnt.to_q()))