Merge pull request #80 from KucharczykL/feat/issue-65-stats-filter-links
feat(stats): link stats-page rows and counts to filtered lists (#65)
This commit is contained in:
@@ -65,9 +65,19 @@ def _filter_parse(filter_json: str) -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
def _extract_labeled(items: list[dict]) -> list[LabeledOption]:
|
||||
"""Convert a list of ``{id, label}`` dicts to ``(value, label)`` pairs."""
|
||||
return [(str(item["id"]), str(item["label"])) for item in items]
|
||||
def _extract_labeled(items: list) -> list[LabeledOption]:
|
||||
"""Convert filter values to ``(value, label)`` pairs.
|
||||
|
||||
UI-built filters carry ``{id, label}`` dicts; programmatically-built ones
|
||||
(e.g. stats_links) carry bare ids/choices. A bare value uses itself as its
|
||||
own label so the bar renders any valid filter instead of crashing."""
|
||||
pairs: list[LabeledOption] = []
|
||||
for item in items:
|
||||
if isinstance(item, dict):
|
||||
pairs.append((str(item["id"]), str(item["label"])))
|
||||
else:
|
||||
pairs.append((str(item), str(item)))
|
||||
return pairs
|
||||
|
||||
|
||||
def _filter_get_choice(existing: dict, field: str) -> FilterChoice:
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
# Issue #65 — Stats-page filtered links
|
||||
|
||||
**Date:** 2026-06-21
|
||||
**Issue:** https://github.com/KucharczykL/timetracker/issues/65 (sub-issue of #61, follow-up to #56)
|
||||
**Prereq:** #67 — date-range filtering on `PlayEventFilter.ended`/`started`
|
||||
(**merged**, PR #69). `ended`/`started` are now `DateCriterion`; because they are
|
||||
`DateField`s (not `DateTimeField`s) the implementation uses a bare lookup, not
|
||||
`__date`. Tier 2 is therefore unblocked — both tiers can ship together.
|
||||
|
||||
## Problem
|
||||
|
||||
The stats page (`games/views/stats_content.py`, data from `stats_data.py`) shows
|
||||
aggregate playtime/purchase metrics and several lists. Rows and counts don't link
|
||||
to the underlying records. #56 introduced `filter_url()` and
|
||||
`OperatorFilter.where()`; this wires the stats page to those helpers so a row or
|
||||
count drills into the filtered list it represents.
|
||||
|
||||
The page is scoped either to a single year (`compute_stats(year)`, `data["year"]`
|
||||
is an int) or all-time (`compute_stats(None)`, `data["year"] == "Alltime"`).
|
||||
|
||||
## Scope
|
||||
|
||||
Two tiers, split by what the filter system can express **today**:
|
||||
|
||||
- **Tier 1 — implemented in this issue** (no new filter machinery).
|
||||
- **Tier 2 — needs "finished in year" filtering** from #67 (now merged, PR #69),
|
||||
so it can ship alongside Tier 1.
|
||||
|
||||
Also in scope (from design review): shorten long lists to 5 items with a "view
|
||||
all" link, and remove the "All purchases" list.
|
||||
|
||||
## Year scoping
|
||||
|
||||
Every link encodes the page's scope:
|
||||
|
||||
- **Per-year page**: sessions filtered by `timestamp_start` BETWEEN `{year}-01-01`
|
||||
and `{year}-12-31`; purchases by `date_purchased` BETWEEN the same bounds.
|
||||
- **All-time page**: no date constraint.
|
||||
|
||||
A single helper computes the year bounds (or `None`) once and is reused by every
|
||||
link builder so scoping is consistent.
|
||||
|
||||
## Design
|
||||
|
||||
### A. Per-row entity links
|
||||
|
||||
**Game rows** — superlative rows in `_playtime_table` (longest session, most
|
||||
sessions, highest average, first/last play) and the "Games by playtime" list.
|
||||
Keep the existing `GameLink` (→ game detail) and **add** a separate affordance (a
|
||||
small icon link) to that game's filtered session list:
|
||||
|
||||
```python
|
||||
filter_url(SessionFilter.where(game=[game.id], **session_year_bounds))
|
||||
```
|
||||
|
||||
**Platform rows** — "Platforms by playtime". Link each platform to its sessions:
|
||||
|
||||
```python
|
||||
filter_url(SessionFilter.where(
|
||||
game_filter=GameFilter.where(platform=[platform_id]),
|
||||
**session_year_bounds,
|
||||
))
|
||||
```
|
||||
|
||||
Requires a **data change**: `total_playtime_per_platform` rows currently carry
|
||||
only `platform_name`. Add `platform_id` to the dict in `stats_data.py`.
|
||||
|
||||
**Month rows** — "Playtime per month" (per-year only). Link each month to its
|
||||
sessions:
|
||||
|
||||
```python
|
||||
filter_url(SessionFilter.where(
|
||||
timestamp_start__between=(month_start_iso, month_end_iso)
|
||||
))
|
||||
```
|
||||
|
||||
### B. Aggregate count links
|
||||
|
||||
In `_playtime_table` (Tier 1 — added per design review item G):
|
||||
|
||||
- **Sessions** count → `SessionFilter.where(**session_year_bounds)`.
|
||||
- **Games** count → games played in scope:
|
||||
`GameFilter.where(session_filter=SessionFilter.where(**session_year_bounds))`.
|
||||
|
||||
In `_purchases_table`:
|
||||
|
||||
- **Total purchased** (Tier 1) → `PurchaseFilter.where(**purchase_year_bounds)`.
|
||||
- **Refunded** (Tier 1) →
|
||||
`PurchaseFilter.where(is_refunded=True, **purchase_year_bounds)`.
|
||||
- **Dropped** (Tier 2) → purchases that are abandoned-or-refunded and not
|
||||
finished; uses the `not_finished` composition (see Tier 2).
|
||||
- **Unfinished** (Tier 2) → the `purchased_unfinished` set (see Tier 2).
|
||||
- **Backlog decrease** (Tier 2) → per-year: purchases bought before the year whose
|
||||
game is finished-in-year (see Tier 2). Expressible with prereq #67 — no extra
|
||||
machinery.
|
||||
|
||||
### C. List capping + "view all"
|
||||
|
||||
Cap these lists to **5 rows** and append a **"View all (N) →"** link to the
|
||||
filtered list (N = the full count):
|
||||
|
||||
| List | Cap from | "View all" target |
|
||||
|------|----------|-------------------|
|
||||
| Games by playtime | 10 | games played in scope (Tier 1) |
|
||||
| Finished | all | finished purchases in scope (Tier 2) |
|
||||
| Finished (YYYY) | all | finished-in-year, released-in-year (Tier 2) |
|
||||
| Bought & Finished (YYYY) | all | purchased-in-year ∧ finished-in-year (Tier 2) |
|
||||
| Unfinished purchases | all | `purchased_unfinished` set (Tier 2) |
|
||||
|
||||
Capping is done in `stats_content.py` (slice to 5 for display); the full count
|
||||
for the link label comes from the existing `_count` keys in `StatsData` (or
|
||||
`len()` where no count key exists). The "Games by playtime" cap also reduces
|
||||
`top_10_games_by_playtime` usage to 5 (slice at render; the data key may keep its
|
||||
name or be renamed to `top_games_by_playtime` — implementer's choice, update both
|
||||
sites).
|
||||
|
||||
**Remove** the "All purchases" list (`all_purchased_this_year` rendering). Its
|
||||
entry point is the "Total purchased" count link. The `StatsData` key may remain
|
||||
(harmless) or be removed if no other consumer exists.
|
||||
|
||||
### D. Tier 2 — finished/dropped/unfinished/backlog (uses #67)
|
||||
|
||||
With #67 merged, `PlayEventFilter.ended` is a `DateCriterion` (bare `DateField`
|
||||
lookup), so "finished in year" is expressible and the chain
|
||||
`PurchaseFilter.game_filter → GameFilter.playevent_filter → PlayEventFilter`
|
||||
composes the Tier-2 targets. Reference semantics (from `stats_data.py`):
|
||||
|
||||
- **finished** (`Purchase.objects.finished()`) = game `status == FINISHED` **or**
|
||||
game has a playevent with `ended` set →
|
||||
`GameFilter.where(OR=...)` of `status=[FINISHED]` and
|
||||
`playevent_filter=PlayEventFilter.where(ended__notnull=True)`.
|
||||
- **finished in year** = above **and** `ended` within the year →
|
||||
add `playevent_filter=PlayEventFilter.where(ended__between=(jan1, dec31))`.
|
||||
- **finished (YYYY) released-in-year** = finished-in-year **and**
|
||||
`GameFilter year_released == year`.
|
||||
- **bought & finished (YYYY)** = `is_refunded=False`, `date_purchased` in year,
|
||||
game finished-in-year.
|
||||
- **dropped** = `type in (game, dlc)`, `infinite=False`, **and**
|
||||
(`game status=ABANDONED` **or** `is_refunded=True`), **and** not finished-in-year.
|
||||
- **unfinished** = `is_refunded=False`, `infinite=False`, `type in (game, dlc)`,
|
||||
game `status NOT IN (FINISHED, RETIRED, ABANDONED)`, **and** not finished-in-year.
|
||||
- **backlog decrease** (per-year) = `date_purchased__lt = {year}-01-01`, game
|
||||
`status=FINISHED`, **and** finished-in-year →
|
||||
`PurchaseFilter.where(date_purchased__lt=jan1, game_filter=GameFilter.where(
|
||||
status=[FINISHED], playevent_filter=PlayEventFilter.where(ended__between=(jan1, dec31))))`.
|
||||
All-time backlog decrease equals the all-time finished count (matches current
|
||||
`stats_data.py` behavior) → link to the all-time finished filter.
|
||||
|
||||
These are nested AND/OR/NOT compositions of existing fields plus the #67 date
|
||||
range — no further machinery.
|
||||
|
||||
### E. Components / rendering
|
||||
|
||||
Reuse existing builders in `stats_content.py` (`_td`, `_tr`, `GameLink`, `A`).
|
||||
Add small helpers:
|
||||
|
||||
- `_session_link_icon(game_id, year_bounds)` → an `A` wrapping an `Icon`, for the
|
||||
per-row game session affordance.
|
||||
- `_view_all(count, url)` → the "View all (N) →" row/footer link.
|
||||
|
||||
Year-bounds helper (e.g. `_year_bounds(year)`) returns the session/purchase
|
||||
`where()` kwargs (or empty dict for all-time), so every builder scopes
|
||||
identically.
|
||||
|
||||
## Sorting parity (#68 — merged)
|
||||
|
||||
The stats lists are sorted, so a "view all" link must reproduce both the set *and*
|
||||
the order. #68 (PR #78) added `games/sorting.py` and a `?sort=` query param (a
|
||||
signed comma-list of public sort keys) honored by the list views, with the keys
|
||||
this feature needs:
|
||||
|
||||
- `GAME_SORTS`: `playtime` (`Sum(sessions__duration_total)`), `finished`
|
||||
(`Max(playevents__ended)`).
|
||||
- `PURCHASE_SORTS`: `finished` (`Max(games__playevents__ended)`), `purchased`,
|
||||
`price`, `name`.
|
||||
|
||||
So each "view all" link passes the matching key via `filter_url(..., sort=...)`
|
||||
— **no TODOs**. The annotated keys (`playtime`, `finished`) mirror the aggregates
|
||||
`stats_data.py` uses, so order matches exactly:
|
||||
|
||||
| View all | sort= | matches stats order |
|
||||
|----------|-------|---------------------|
|
||||
| Games by playtime | `-playtime` | `top_*_games_by_playtime` (playtime desc) |
|
||||
| Finished | `finished` / `-finished` | the section's `order_by` in `stats_data.py` |
|
||||
| Finished (YYYY) | `finished` | `games__playevents__ended` asc |
|
||||
| Bought & Finished (YYYY) | `finished` | `games__playevents__ended` asc |
|
||||
| Unfinished purchases | `-purchased` | purchase list default order |
|
||||
|
||||
Direction (asc vs desc) per finished section is chosen to match that section's
|
||||
`order_by` in `stats_data.py`; the rendering test asserts the linked list's order
|
||||
matches the preview's.
|
||||
|
||||
## Exact-match requirement
|
||||
|
||||
A count or "view all" link must land on a list whose total equals the displayed
|
||||
number. The stats queries traverse M2M (`games__…`) joins; the filter layer
|
||||
resolves cross-entity criteria via `Game.objects.filter(...).values_list("id")`
|
||||
subqueries, so join/`distinct` semantics can differ. Each linked category gets a
|
||||
test asserting the linked filter's queryset count equals the corresponding
|
||||
`StatsData` count, on seeded data. Any category that can't be made to match
|
||||
exactly is reported (not silently shipped with a wrong number).
|
||||
|
||||
## Testing
|
||||
|
||||
- **Link builders** (unit): for each linked row/count, `filter_url(...)` produces
|
||||
the expected path and the `filter` JSON round-trips through the matching
|
||||
`parse_*_filter`. Year-scoped vs all-time variants both covered.
|
||||
- **Exact-match** (db): seed games/sessions/purchases/playevents spanning the year
|
||||
boundary and other years; assert each linked filter's count equals the
|
||||
`compute_stats(year)` / `compute_stats(None)` value for that category (both
|
||||
tiers, since #67 is merged).
|
||||
- **Rendering** (db): stats page renders; capped lists show ≤5 rows + a "View all"
|
||||
link; the "All purchases" list is gone; smoke-test that the generated link URLs
|
||||
return 200.
|
||||
|
||||
## Implementation order
|
||||
|
||||
1. **Tier 1 + capping + removal** (this issue, independent of #67): per-row game /
|
||||
platform / month session links, sessions/games/total-purchased/refunded count
|
||||
links, list capping with Tier-1 "view all" links, remove "All purchases",
|
||||
`platform_id` data change.
|
||||
2. **Tier 2** (unblocked — #67 is merged): finished/dropped/unfinished/
|
||||
backlog-decrease count and "view all" links.
|
||||
|
||||
Both tiers can ship together now that #67 is merged; Tier 1 remains independently
|
||||
shippable if a smaller first PR is preferred.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- The `view_game` table links (#66, the other #61 sub-issue).
|
||||
- List-view sort support itself (#68, merged) — #65 only *passes* the `sort` param
|
||||
to the existing keys (see "Sorting parity").
|
||||
+168
-27
@@ -14,7 +14,9 @@ from common.components import (
|
||||
A,
|
||||
Div,
|
||||
Element,
|
||||
Fragment,
|
||||
GameLink,
|
||||
Icon,
|
||||
Node,
|
||||
Safe,
|
||||
Td,
|
||||
@@ -23,11 +25,46 @@ from common.components import (
|
||||
YearPicker,
|
||||
)
|
||||
from common.time import durationformat, format_duration
|
||||
from games.filters import filter_url
|
||||
from games.views import stats_links
|
||||
|
||||
_CELL = "px-2 sm:px-4 md:px-6 md:py-2"
|
||||
_CELL_MONO = f"{_CELL} font-mono"
|
||||
_NAME_TH = f"{_CELL} purchase-name truncate max-w-20char"
|
||||
|
||||
# Stats lists are previews: capped to this many rows, with a "View all" link to
|
||||
# the full filtered list (#65).
|
||||
_LIST_CAP = 5
|
||||
|
||||
|
||||
def _session_link(game_id, year) -> Node:
|
||||
"""Small affordance linking a game row to its (year-scoped) session list.
|
||||
Sits next to the existing GameLink (which goes to the game detail page)."""
|
||||
return A(
|
||||
href=filter_url(stats_links.sessions_for_game(game_id, year)),
|
||||
class_="ml-1 inline-block align-middle hover:text-heading",
|
||||
title="View sessions",
|
||||
)[Icon("play")]
|
||||
|
||||
|
||||
def _count_link(value, url: str) -> Node:
|
||||
return A(href=url, class_="hover:underline decoration-dotted")[str(value)]
|
||||
|
||||
|
||||
def _view_all_row(count: int, url: str) -> Node:
|
||||
return _tr(
|
||||
[
|
||||
Td(
|
||||
attributes=[("class", _CELL), ("colspan", "3")],
|
||||
children=[
|
||||
A(href=url, class_="underline decoration-dotted")[
|
||||
f"View all ({count}) →"
|
||||
]
|
||||
],
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _td(children, cls: str = _CELL_MONO) -> Node:
|
||||
if not isinstance(children, list):
|
||||
@@ -119,14 +156,28 @@ def _playtime_table(ctx) -> Node:
|
||||
year = ctx.get("year")
|
||||
rows = [
|
||||
_kv("Hours", ctx.get("total_hours")),
|
||||
_kv("Sessions", ctx.get("total_sessions")),
|
||||
_kv(
|
||||
"Sessions",
|
||||
_count_link(
|
||||
ctx.get("total_sessions"),
|
||||
filter_url(stats_links.all_sessions(year)),
|
||||
),
|
||||
),
|
||||
_kv(
|
||||
"Days",
|
||||
f"{ctx.get('unique_days')} ({ctx.get('unique_days_percent')}%)",
|
||||
),
|
||||
]
|
||||
if ctx.get("total_games"):
|
||||
rows.append(_kv("Games", ctx.get("total_games")))
|
||||
rows.append(
|
||||
_kv(
|
||||
"Games",
|
||||
_count_link(
|
||||
ctx.get("total_games"),
|
||||
filter_url(stats_links.games_played(year)),
|
||||
),
|
||||
)
|
||||
)
|
||||
rows.append(_kv(f"Games ({year})", ctx.get("total_year_games")))
|
||||
if ctx.get("all_finished_this_year_count"):
|
||||
rows.append(_kv("Finished", ctx.get("all_finished_this_year_count")))
|
||||
@@ -138,7 +189,15 @@ def _playtime_table(ctx) -> Node:
|
||||
return _tr(
|
||||
[
|
||||
_td(label, _CELL),
|
||||
_td([str(value), " (", GameLink(game.id, game.name), ")"]),
|
||||
_td(
|
||||
[
|
||||
str(value),
|
||||
" (",
|
||||
GameLink(game.id, game.name),
|
||||
")",
|
||||
_session_link(game.id, year),
|
||||
]
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -171,6 +230,7 @@ def _playtime_table(ctx) -> Node:
|
||||
[
|
||||
GameLink(first_game.id, first_game.name),
|
||||
f" ({ctx.get('first_play_date')})",
|
||||
_session_link(first_game.id, year),
|
||||
]
|
||||
),
|
||||
]
|
||||
@@ -186,6 +246,7 @@ def _playtime_table(ctx) -> Node:
|
||||
[
|
||||
GameLink(last_game.id, last_game.name),
|
||||
f" ({ctx.get('last_play_date')})",
|
||||
_session_link(last_game.id, year),
|
||||
]
|
||||
),
|
||||
]
|
||||
@@ -195,23 +256,45 @@ def _playtime_table(ctx) -> Node:
|
||||
|
||||
|
||||
def _purchases_table(ctx) -> Node:
|
||||
year = ctx.get("year")
|
||||
rows = [
|
||||
_kv("Total", ctx.get("all_purchased_this_year_count")),
|
||||
_kv(
|
||||
"Total",
|
||||
_count_link(
|
||||
ctx.get("all_purchased_this_year_count"),
|
||||
filter_url(stats_links.purchases_total(year)),
|
||||
),
|
||||
),
|
||||
_kv(
|
||||
"Refunded",
|
||||
f"{ctx.get('all_purchased_refunded_this_year_count')} "
|
||||
f"({ctx.get('refunded_percent')}%)",
|
||||
_count_link(
|
||||
f"{ctx.get('all_purchased_refunded_this_year_count')} "
|
||||
f"({ctx.get('refunded_percent')}%)",
|
||||
filter_url(stats_links.purchases_refunded(year)),
|
||||
),
|
||||
),
|
||||
_kv(
|
||||
"Dropped",
|
||||
f"{ctx.get('dropped_count')} ({ctx.get('dropped_percentage')}%)",
|
||||
_count_link(
|
||||
f"{ctx.get('dropped_count')} ({ctx.get('dropped_percentage')}%)",
|
||||
filter_url(stats_links.purchases_dropped(year)),
|
||||
),
|
||||
),
|
||||
_kv(
|
||||
"Unfinished",
|
||||
f"{ctx.get('purchased_unfinished_count')} "
|
||||
f"({ctx.get('unfinished_purchases_percent')}%)",
|
||||
_count_link(
|
||||
f"{ctx.get('purchased_unfinished_count')} "
|
||||
f"({ctx.get('unfinished_purchases_percent')}%)",
|
||||
filter_url(stats_links.purchases_unfinished(year)),
|
||||
),
|
||||
),
|
||||
_kv(
|
||||
"Backlog Decrease",
|
||||
_count_link(
|
||||
ctx.get("backlog_decrease_count"),
|
||||
filter_url(stats_links.purchases_backlog_decrease(year)),
|
||||
),
|
||||
),
|
||||
_kv("Backlog Decrease", ctx.get("backlog_decrease_count")),
|
||||
_kv(
|
||||
f"Spendings ({ctx.get('total_spent_currency')})",
|
||||
f"{floatformat(ctx.get('total_spent'))} "
|
||||
@@ -221,34 +304,45 @@ def _purchases_table(ctx) -> Node:
|
||||
return _table(rows)
|
||||
|
||||
|
||||
def _two_col_table(header: str, items, name_key, value_fn) -> Node:
|
||||
def _two_col_table(header: str, items, name_key, value_fn, view_all_url=None) -> Node:
|
||||
thead = Element(
|
||||
"thead",
|
||||
children=[_tr([_th(header), _th("Playtime")])],
|
||||
)
|
||||
rows = [_tr([_td(name_key(item)), _td(value_fn(item))]) for item in items]
|
||||
items = list(items)
|
||||
display = items[:_LIST_CAP] if view_all_url else items
|
||||
rows = [_tr([_td(name_key(item)), _td(value_fn(item))]) for item in display]
|
||||
if view_all_url and len(items) > _LIST_CAP:
|
||||
rows.append(_view_all_row(len(items), view_all_url))
|
||||
return _table(rows, thead)
|
||||
|
||||
|
||||
def _finished_table(purchases) -> Node:
|
||||
def _finished_table(purchases, view_all_url=None, total=None) -> Node:
|
||||
thead = Element(
|
||||
"thead",
|
||||
children=[_tr([_th("Name", _NAME_TH), _th("Date")])],
|
||||
)
|
||||
purchases = list(purchases)
|
||||
display = purchases[:_LIST_CAP] if view_all_url else purchases
|
||||
rows = [
|
||||
_tr([_td(_purchase_name(p)), _td(date_filter(p.date_finished, "d/m/Y"))])
|
||||
for p in purchases
|
||||
for p in display
|
||||
]
|
||||
total = total if total is not None else len(purchases)
|
||||
if view_all_url and total > _LIST_CAP:
|
||||
rows.append(_view_all_row(total, view_all_url))
|
||||
return _table(rows, thead)
|
||||
|
||||
|
||||
def _priced_table(purchases, currency) -> Node:
|
||||
def _priced_table(purchases, currency, view_all_url=None, total=None) -> Node:
|
||||
thead = Element(
|
||||
"thead",
|
||||
children=[
|
||||
_tr([_th("Name", _NAME_TH), _th(f"Price ({currency})"), _th("Date")])
|
||||
],
|
||||
)
|
||||
purchases = list(purchases)
|
||||
display = purchases[:_LIST_CAP] if view_all_url else purchases
|
||||
rows = [
|
||||
_tr(
|
||||
[
|
||||
@@ -257,8 +351,11 @@ def _priced_table(purchases, currency) -> Node:
|
||||
_td(date_filter(p.date_purchased, "d/m/Y")),
|
||||
]
|
||||
)
|
||||
for p in purchases
|
||||
for p in display
|
||||
]
|
||||
total = total if total is not None else len(purchases)
|
||||
if view_all_url and total > _LIST_CAP:
|
||||
rows.append(_view_all_row(total, view_all_url))
|
||||
return _table(rows, thead)
|
||||
|
||||
|
||||
@@ -281,7 +378,21 @@ def stats_content(ctx: dict) -> Node:
|
||||
if months:
|
||||
sections.append(_h1("Playtime per month"))
|
||||
month_rows = [
|
||||
_kv(date_filter(m["month"], "F"), _dur(m["playtime"])) for m in months
|
||||
_tr(
|
||||
[
|
||||
_td(
|
||||
A(
|
||||
href=filter_url(
|
||||
stats_links.sessions_in_month(year, m["month"].month)
|
||||
),
|
||||
class_="hover:underline decoration-dotted",
|
||||
)[date_filter(m["month"], "F")],
|
||||
_CELL,
|
||||
),
|
||||
_td(_dur(m["playtime"])),
|
||||
]
|
||||
)
|
||||
for m in months
|
||||
]
|
||||
sections.append(_table(month_rows))
|
||||
|
||||
@@ -292,44 +403,74 @@ def stats_content(ctx: dict) -> Node:
|
||||
_two_col_table(
|
||||
"Name",
|
||||
ctx.get("top_10_games_by_playtime") or [],
|
||||
lambda g: GameLink(g.id, g.name),
|
||||
lambda g: Fragment(GameLink(g.id, g.name), _session_link(g.id, year)),
|
||||
lambda g: _dur(g.total_playtime),
|
||||
view_all_url=filter_url(stats_links.games_played(year), sort="-playtime"),
|
||||
),
|
||||
_h1("Platforms by playtime"),
|
||||
_two_col_table(
|
||||
"Platform",
|
||||
ctx.get("total_playtime_per_platform") or [],
|
||||
lambda item: item["platform_name"],
|
||||
lambda item: A(
|
||||
href=filter_url(
|
||||
stats_links.sessions_for_platform(item["platform_id"], year)
|
||||
),
|
||||
class_="hover:underline decoration-dotted",
|
||||
)[item["platform_name"] or "Unspecified"],
|
||||
lambda item: _dur(item["playtime"]),
|
||||
),
|
||||
]
|
||||
|
||||
all_finished = list(ctx.get("all_finished_this_year") or [])
|
||||
if all_finished:
|
||||
sections += [_h1("Finished"), _finished_table(all_finished)]
|
||||
sections += [
|
||||
_h1("Finished"),
|
||||
_finished_table(
|
||||
all_finished,
|
||||
view_all_url=filter_url(
|
||||
stats_links.purchases_finished(year), sort="finished"
|
||||
),
|
||||
total=ctx.get("all_finished_this_year_count"),
|
||||
),
|
||||
]
|
||||
|
||||
year_finished = list(ctx.get("this_year_finished_this_year") or [])
|
||||
if year_finished:
|
||||
sections += [_h1(f"Finished ({year} games)"), _finished_table(year_finished)]
|
||||
sections += [
|
||||
_h1(f"Finished ({year} games)"),
|
||||
_finished_table(
|
||||
year_finished,
|
||||
view_all_url=filter_url(
|
||||
stats_links.purchases_finished_released(year), sort="finished"
|
||||
),
|
||||
total=ctx.get("this_year_finished_this_year_count"),
|
||||
),
|
||||
]
|
||||
|
||||
bought_finished = list(ctx.get("purchased_this_year_finished_this_year") or [])
|
||||
if bought_finished:
|
||||
sections += [
|
||||
_h1(f"Bought and Finished ({year})"),
|
||||
_finished_table(bought_finished),
|
||||
_finished_table(
|
||||
bought_finished,
|
||||
view_all_url=filter_url(
|
||||
stats_links.purchases_bought_and_finished(year), sort="finished"
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
unfinished = list(ctx.get("purchased_unfinished") or [])
|
||||
if unfinished:
|
||||
sections += [
|
||||
_h1("Unfinished Purchases"),
|
||||
_priced_table(unfinished, currency),
|
||||
_priced_table(
|
||||
unfinished,
|
||||
currency,
|
||||
view_all_url=filter_url(stats_links.purchases_unfinished(year)),
|
||||
total=ctx.get("purchased_unfinished_count"),
|
||||
),
|
||||
]
|
||||
|
||||
all_purchased = list(ctx.get("all_purchased_this_year") or [])
|
||||
if all_purchased:
|
||||
sections += [_h1("All Purchases"), _priced_table(all_purchased, currency)]
|
||||
|
||||
return Div(
|
||||
[("class", "dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto")],
|
||||
sections,
|
||||
|
||||
@@ -247,7 +247,7 @@ def compute_stats(year: int | None = None) -> StatsData:
|
||||
.annotate(total_playtime=Sum("sessions__duration_total"))
|
||||
.filter(total_playtime__gt=timedelta(0))
|
||||
)
|
||||
top_games = games_with_playtime.order_by("-total_playtime")[:10]
|
||||
top_games = games_with_playtime.order_by("-total_playtime")
|
||||
else:
|
||||
games_with_playtime = (
|
||||
Game.objects.filter(sessions__timestamp_start__year=year)
|
||||
@@ -256,11 +256,16 @@ def compute_stats(year: int | None = None) -> StatsData:
|
||||
)
|
||||
top_games = games_with_playtime.order_by("-total_playtime")
|
||||
|
||||
# platform_id is carried alongside the name so the stats row can link to a
|
||||
# platform-scoped session list (#65).
|
||||
total_playtime_per_platform = (
|
||||
sessions.values("game__platform__name")
|
||||
sessions.values("game__platform__name", "game__platform__id")
|
||||
.annotate(playtime=Sum(F("duration_total")))
|
||||
.annotate(platform_name=F("game__platform__name"))
|
||||
.values("platform_name", "playtime")
|
||||
.annotate(
|
||||
platform_name=F("game__platform__name"),
|
||||
platform_id=F("game__platform__id"),
|
||||
)
|
||||
.values("platform_name", "platform_id", "playtime")
|
||||
.order_by("-playtime")
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Filter-link builders for the stats page (issue #65).
|
||||
|
||||
Each function returns a filter object describing exactly the records behind a
|
||||
stats row or count; `stats_content` wraps them with `filter_url()` to link to the
|
||||
matching list view. Keeping these as pure functions (no HTTP, no rendering) lets
|
||||
the parity tests assert each builder's queryset count equals the stat it links
|
||||
from.
|
||||
|
||||
Scope: `year` is an int for a calendar year, or the "Alltime" sentinel (or any
|
||||
non-int) for all-time — matching `StatsData["year"]`. For all-time the date
|
||||
bounds are omitted, so the links cover every record.
|
||||
"""
|
||||
|
||||
from calendar import monthrange
|
||||
|
||||
from common.criteria import (
|
||||
BoolCriterion,
|
||||
ChoiceCriterion,
|
||||
DateCriterion,
|
||||
IntCriterion,
|
||||
Modifier,
|
||||
)
|
||||
from games.filters import (
|
||||
GameFilter,
|
||||
PlayEventFilter,
|
||||
PurchaseFilter,
|
||||
SessionFilter,
|
||||
)
|
||||
from games.models import Game, Purchase
|
||||
|
||||
|
||||
def _is_year(year) -> bool:
|
||||
return isinstance(year, int)
|
||||
|
||||
|
||||
def _year_range(year: int) -> tuple[str, str]:
|
||||
return (f"{year}-01-01", f"{year}-12-31")
|
||||
|
||||
|
||||
def _session_bounds(year) -> dict:
|
||||
"""`where()` kwargs scoping sessions to the year (empty for all-time)."""
|
||||
if not _is_year(year):
|
||||
return {}
|
||||
return {"timestamp_start__between": _year_range(year)}
|
||||
|
||||
|
||||
def _purchase_bounds(year) -> dict:
|
||||
if not _is_year(year):
|
||||
return {}
|
||||
return {"date_purchased__between": _year_range(year)}
|
||||
|
||||
|
||||
# ── Sessions ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def all_sessions(year) -> SessionFilter:
|
||||
return SessionFilter.where(**_session_bounds(year))
|
||||
|
||||
|
||||
def sessions_for_game(game_id: int, year) -> SessionFilter:
|
||||
return SessionFilter.where(game=[game_id], **_session_bounds(year))
|
||||
|
||||
|
||||
def sessions_for_platform(platform_id: int, year) -> SessionFilter:
|
||||
session_filter = SessionFilter.where(**_session_bounds(year))
|
||||
session_filter.game_filter = GameFilter.where(platform=[platform_id])
|
||||
return session_filter
|
||||
|
||||
|
||||
def sessions_in_month(year: int, month: int) -> SessionFilter:
|
||||
last_day = monthrange(year, month)[1]
|
||||
start = f"{year}-{month:02d}-01"
|
||||
end = f"{year}-{month:02d}-{last_day:02d}"
|
||||
return SessionFilter.where(timestamp_start__between=(start, end))
|
||||
|
||||
|
||||
# ── Games ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def games_played(year) -> GameFilter:
|
||||
"""Games with at least one session in scope (matches `total_games`)."""
|
||||
return GameFilter(session_filter=all_sessions(year))
|
||||
|
||||
|
||||
# ── Purchases ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def purchases_total(year) -> PurchaseFilter:
|
||||
return PurchaseFilter.where(**_purchase_bounds(year))
|
||||
|
||||
|
||||
def purchases_refunded(year) -> PurchaseFilter:
|
||||
return PurchaseFilter.where(is_refunded=True, **_purchase_bounds(year))
|
||||
|
||||
|
||||
# ── Tier 2: finished / dropped / unfinished / backlog (uses #67) ─────────────
|
||||
#
|
||||
# These mirror the M2M-traversing queries in `stats_data.py`. The project models
|
||||
# multi-item orders as separate single-game purchases, so on that (dominant) data
|
||||
# the filter system's id-set semantics match the stats queries exactly; the only
|
||||
# divergence is unsplittable multi-game bundles, which the stats queries
|
||||
# themselves count inconsistently. Parity is verified per category in tests.
|
||||
|
||||
|
||||
def _ended_in_scope(year) -> PlayEventFilter:
|
||||
"""A game's finish: a playevent whose `ended` falls in scope (any, all-time)."""
|
||||
if _is_year(year):
|
||||
return PlayEventFilter.where(ended__between=_year_range(year))
|
||||
return PlayEventFilter.where(ended__notnull=True)
|
||||
|
||||
|
||||
def _not_finished_game(year, excluded_statuses: list) -> GameFilter:
|
||||
"""Games that are not finished in scope: status not in `excluded_statuses`
|
||||
(always includes FINISHED) and no finishing playevent in scope.
|
||||
|
||||
Mirrors `not_finished_q = ~Q(status=FINISHED) & ~ended_q` plus the extra
|
||||
status exclusions some categories add."""
|
||||
game_filter = GameFilter(
|
||||
status=ChoiceCriterion(value=excluded_statuses, modifier=Modifier.EXCLUDES)
|
||||
)
|
||||
game_filter.NOT = GameFilter(playevent_filter=_ended_in_scope(year))
|
||||
return game_filter
|
||||
|
||||
|
||||
def purchases_finished(year) -> PurchaseFilter:
|
||||
"""Purchases whose game is finished (in scope)."""
|
||||
if _is_year(year):
|
||||
return PurchaseFilter(
|
||||
game_filter=GameFilter(playevent_filter=_ended_in_scope(year))
|
||||
)
|
||||
# All-time `.finished()`: game status FINISHED *or* any ended playevent.
|
||||
game_filter = GameFilter(status=ChoiceCriterion(value=[Game.Status.FINISHED]))
|
||||
game_filter.OR = GameFilter(playevent_filter=_ended_in_scope(year))
|
||||
return PurchaseFilter(game_filter=game_filter)
|
||||
|
||||
|
||||
def purchases_finished_released(year) -> PurchaseFilter:
|
||||
"""Finished-in-scope purchases whose game was released that year."""
|
||||
if not _is_year(year):
|
||||
return purchases_finished(year)
|
||||
game_filter = GameFilter(
|
||||
year_released=IntCriterion(value=year, modifier=Modifier.EQUALS),
|
||||
playevent_filter=_ended_in_scope(year),
|
||||
)
|
||||
return PurchaseFilter(game_filter=game_filter)
|
||||
|
||||
|
||||
def purchases_bought_and_finished(year) -> PurchaseFilter:
|
||||
"""Not-refunded purchases bought in scope whose game finished in scope."""
|
||||
purchase_filter = PurchaseFilter.where(is_refunded=False, **_purchase_bounds(year))
|
||||
purchase_filter.game_filter = GameFilter(playevent_filter=_ended_in_scope(year))
|
||||
return purchase_filter
|
||||
|
||||
|
||||
def _abandoned_or_refunded() -> PurchaseFilter:
|
||||
purchase_filter = PurchaseFilter(
|
||||
game_filter=GameFilter(status=ChoiceCriterion(value=[Game.Status.ABANDONED]))
|
||||
)
|
||||
purchase_filter.OR = PurchaseFilter(is_refunded=BoolCriterion(value=True))
|
||||
return purchase_filter
|
||||
|
||||
|
||||
def purchases_dropped(year) -> PurchaseFilter:
|
||||
purchase_filter = PurchaseFilter.where(
|
||||
infinite=False,
|
||||
type=[Purchase.GAME, Purchase.DLC],
|
||||
**_purchase_bounds(year),
|
||||
)
|
||||
purchase_filter.game_filter = _not_finished_game(year, [Game.Status.FINISHED])
|
||||
purchase_filter.AND = _abandoned_or_refunded()
|
||||
return purchase_filter
|
||||
|
||||
|
||||
def purchases_unfinished(year) -> PurchaseFilter:
|
||||
purchase_filter = PurchaseFilter.where(
|
||||
is_refunded=False,
|
||||
infinite=False,
|
||||
type=[Purchase.GAME, Purchase.DLC],
|
||||
**_purchase_bounds(year),
|
||||
)
|
||||
purchase_filter.game_filter = _not_finished_game(
|
||||
year,
|
||||
[Game.Status.FINISHED, Game.Status.RETIRED, Game.Status.ABANDONED],
|
||||
)
|
||||
return purchase_filter
|
||||
|
||||
|
||||
def purchases_backlog_decrease(year) -> PurchaseFilter:
|
||||
"""Per-year: bought before the year, game finished in the year. All-time:
|
||||
equals the all-time finished count (matches `stats_data.py`)."""
|
||||
if not _is_year(year):
|
||||
return purchases_finished(year)
|
||||
purchase_filter = PurchaseFilter(
|
||||
date_purchased=DateCriterion(value=f"{year}-01-01", modifier=Modifier.LESS_THAN)
|
||||
)
|
||||
purchase_filter.game_filter = GameFilter(
|
||||
status=ChoiceCriterion(value=[Game.Status.FINISHED]),
|
||||
playevent_filter=_ended_in_scope(year),
|
||||
)
|
||||
return purchase_filter
|
||||
@@ -0,0 +1,18 @@
|
||||
"""The filter bar must render a filter whose choice/multi values are bare
|
||||
(no embedded {id, label}) — e.g. a programmatically built filter from
|
||||
stats_links — without crashing (#65)."""
|
||||
|
||||
from common.components.filters import _extract_labeled
|
||||
|
||||
|
||||
def test_extract_labeled_handles_labeled_dicts():
|
||||
assert _extract_labeled([{"id": "game", "label": "Game"}]) == [("game", "Game")]
|
||||
|
||||
|
||||
def test_extract_labeled_handles_bare_values():
|
||||
# bare scalars (ids/choices) fall back to using the value as its own label
|
||||
assert _extract_labeled(["game", "dlc"]) == [("game", "game"), ("dlc", "dlc")]
|
||||
|
||||
|
||||
def test_extract_labeled_handles_bare_ints():
|
||||
assert _extract_labeled([3, 7]) == [("3", "3"), ("7", "7")]
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Rendering tests: stats page wires rows/counts to filtered-list links (#65)."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from django.utils.html import escape
|
||||
|
||||
from games.filters import filter_url
|
||||
from games.models import Game, Platform, PlayEvent, Purchase, Session
|
||||
from games.views import stats_links
|
||||
from games.views.stats_content import stats_content
|
||||
from games.views.stats_data import compute_stats
|
||||
|
||||
YEAR = 2024
|
||||
|
||||
|
||||
def _dt(month, day, hour=12):
|
||||
return datetime(YEAR, month, day, hour, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rendered(db):
|
||||
pc = Platform.objects.create(name="PC")
|
||||
# 6 games each played in-year → games-by-playtime exceeds the cap of 5.
|
||||
games = []
|
||||
for index in range(6):
|
||||
game = Game.objects.create(
|
||||
name=f"Game {index}", platform=pc, status=Game.Status.PLAYED
|
||||
)
|
||||
start = _dt(6, index + 1)
|
||||
Session.objects.create(
|
||||
game=game,
|
||||
timestamp_start=start,
|
||||
timestamp_end=start + timedelta(hours=index + 1),
|
||||
)
|
||||
games.append(game)
|
||||
|
||||
abandoned = Game.objects.create(
|
||||
name="Abandoned", platform=pc, status=Game.Status.ABANDONED
|
||||
)
|
||||
Purchase.objects.create(date_purchased=_dt(1, 5), type=Purchase.GAME).games.set(
|
||||
[games[0]]
|
||||
)
|
||||
Purchase.objects.create(date_purchased=_dt(2, 5), type=Purchase.GAME).games.set(
|
||||
[abandoned]
|
||||
) # dropped
|
||||
Purchase.objects.create(
|
||||
date_purchased=_dt(3, 5), date_refunded=_dt(4, 5), type=Purchase.GAME
|
||||
).games.set([games[1]]) # refunded
|
||||
Purchase.objects.create(date_purchased=_dt(5, 5), type=Purchase.GAME).games.set(
|
||||
[games[2]]
|
||||
) # unfinished
|
||||
|
||||
finished_game = games[0]
|
||||
PlayEvent.objects.create(game=finished_game, ended=_dt(8, 1))
|
||||
|
||||
ctx = compute_stats(YEAR)
|
||||
return {"html": str(stats_content(ctx)), "pc": pc, "games": games}
|
||||
|
||||
|
||||
def _href(builder_filter, **extra):
|
||||
return escape(filter_url(builder_filter, **extra))
|
||||
|
||||
|
||||
def test_total_count_links_to_purchases(rendered):
|
||||
assert _href(stats_links.purchases_total(YEAR)) in rendered["html"]
|
||||
|
||||
|
||||
def test_refunded_count_links_to_refunded_purchases(rendered):
|
||||
assert _href(stats_links.purchases_refunded(YEAR)) in rendered["html"]
|
||||
|
||||
|
||||
def test_dropped_count_links_to_dropped_purchases(rendered):
|
||||
assert _href(stats_links.purchases_dropped(YEAR)) in rendered["html"]
|
||||
|
||||
|
||||
def test_unfinished_count_links_to_unfinished_purchases(rendered):
|
||||
assert _href(stats_links.purchases_unfinished(YEAR)) in rendered["html"]
|
||||
|
||||
|
||||
def test_platform_row_links_to_platform_sessions(rendered):
|
||||
url = _href(stats_links.sessions_for_platform(rendered["pc"].id, YEAR))
|
||||
assert url in rendered["html"]
|
||||
|
||||
|
||||
def test_game_row_has_session_link(rendered):
|
||||
# at least one games-by-playtime game links to its sessions
|
||||
any_game = rendered["games"][0]
|
||||
url = _href(stats_links.sessions_for_game(any_game.id, YEAR))
|
||||
assert url in rendered["html"]
|
||||
|
||||
|
||||
def test_games_by_playtime_capped_with_view_all(rendered):
|
||||
# 6 games played, capped to 5 → a "View all" link to games_played
|
||||
assert "View all" in rendered["html"]
|
||||
view_all = filter_url(stats_links.games_played(YEAR), sort="-playtime")
|
||||
# the filter portion (before &sort) must be present even after attr-escaping
|
||||
assert escape(view_all.split("&")[0]) in rendered["html"]
|
||||
|
||||
|
||||
def test_all_purchases_section_removed(rendered):
|
||||
assert "All Purchases" not in rendered["html"]
|
||||
|
||||
|
||||
def test_generated_links_resolve_to_200(rendered, client, django_user_model):
|
||||
"""A stats link, when visited, returns 200 with its filter applied."""
|
||||
user = django_user_model.objects.create_user(username="u", password="p")
|
||||
client.force_login(user)
|
||||
for builder in (
|
||||
stats_links.purchases_total(YEAR),
|
||||
stats_links.purchases_dropped(YEAR),
|
||||
stats_links.sessions_for_platform(rendered["pc"].id, YEAR),
|
||||
):
|
||||
response = client.get(filter_url(builder), follow=True)
|
||||
assert response.status_code == 200
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Parity tests for stats-page filter-link builders (issue #65).
|
||||
|
||||
Each builder returns a filter object; the test asserts the filter's queryset
|
||||
count equals the value the stats page displays for that category, so a link can
|
||||
never land on a list whose total differs from the number it was clicked from.
|
||||
|
||||
Data is single-game purchases (the project's modeling norm — multi-item orders
|
||||
are separate single-game purchases), where the filter system's id-set semantics
|
||||
match the stats queries' M2M traversal exactly.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from games.models import Game, Platform, PlayEvent, Purchase, Session
|
||||
from games.views import stats_links
|
||||
from games.views.stats_data import compute_stats
|
||||
|
||||
YEAR = 2024
|
||||
|
||||
|
||||
def _dt(year, month=6, day=1):
|
||||
return datetime(year, month, day, 12, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def world(db):
|
||||
pc = Platform.objects.create(name="PC")
|
||||
switch = Platform.objects.create(name="Switch")
|
||||
|
||||
finished_game = Game.objects.create(
|
||||
name="Finished", platform=pc, status=Game.Status.FINISHED, year_released=YEAR
|
||||
)
|
||||
abandoned_game = Game.objects.create(
|
||||
name="Abandoned", platform=pc, status=Game.Status.ABANDONED
|
||||
)
|
||||
playing_game = Game.objects.create(
|
||||
name="Playing", platform=switch, status=Game.Status.PLAYED
|
||||
)
|
||||
|
||||
# Sessions: in-year on two platforms + one out-of-year (excluded).
|
||||
Session.objects.create(game=finished_game, timestamp_start=_dt(YEAR, 6, 1))
|
||||
Session.objects.create(game=finished_game, timestamp_start=_dt(YEAR, 7, 2))
|
||||
Session.objects.create(game=playing_game, timestamp_start=_dt(YEAR, 6, 3))
|
||||
Session.objects.create(game=finished_game, timestamp_start=_dt(YEAR - 1, 6, 1))
|
||||
|
||||
# PlayEvents: finished_game ended in-year.
|
||||
PlayEvent.objects.create(game=finished_game, ended=_dt(YEAR, 8, 1))
|
||||
|
||||
# Purchases (single-game).
|
||||
Purchase.objects.create( # finished, bought in-year
|
||||
date_purchased=_dt(YEAR, 1, 5), type=Purchase.GAME
|
||||
).games.set([finished_game])
|
||||
Purchase.objects.create( # abandoned -> dropped
|
||||
date_purchased=_dt(YEAR, 2, 5), type=Purchase.GAME
|
||||
).games.set([abandoned_game])
|
||||
Purchase.objects.create( # refunded
|
||||
date_purchased=_dt(YEAR, 3, 5),
|
||||
date_refunded=_dt(YEAR, 4, 5),
|
||||
type=Purchase.GAME,
|
||||
).games.set([playing_game])
|
||||
Purchase.objects.create( # unfinished (playing, not refunded/finished)
|
||||
date_purchased=_dt(YEAR, 5, 5), type=Purchase.GAME
|
||||
).games.set([playing_game])
|
||||
# backlog decrease: bought prior year, game finished, ended in-year
|
||||
Purchase.objects.create(
|
||||
date_purchased=_dt(YEAR - 1, 5, 5), type=Purchase.GAME
|
||||
).games.set([finished_game])
|
||||
|
||||
return {
|
||||
"pc": pc,
|
||||
"switch": switch,
|
||||
"finished_game": finished_game,
|
||||
"playing_game": playing_game,
|
||||
}
|
||||
|
||||
|
||||
def _count(filter_obj, model):
|
||||
return model.objects.filter(filter_obj.to_q()).distinct().count()
|
||||
|
||||
|
||||
# ── Per-row session links ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_sessions_for_game_matches_year_scoped_sessions(world):
|
||||
game = world["finished_game"]
|
||||
expected = Session.objects.filter(
|
||||
timestamp_start__year=YEAR, game_id=game.id
|
||||
).count()
|
||||
assert expected == 2 # guard: the out-of-year session is excluded
|
||||
assert _count(stats_links.sessions_for_game(game.id, YEAR), Session) == expected
|
||||
|
||||
|
||||
def test_sessions_for_platform_matches_year_scoped_sessions(world):
|
||||
platform = world["pc"]
|
||||
expected = Session.objects.filter(
|
||||
timestamp_start__year=YEAR, game__platform_id=platform.id
|
||||
).count()
|
||||
assert (
|
||||
_count(stats_links.sessions_for_platform(platform.id, YEAR), Session)
|
||||
== expected
|
||||
)
|
||||
|
||||
|
||||
def test_sessions_in_month_matches_that_month(world):
|
||||
expected = Session.objects.filter(
|
||||
timestamp_start__year=YEAR, timestamp_start__month=6
|
||||
).count()
|
||||
assert expected == 2
|
||||
assert _count(stats_links.sessions_in_month(YEAR, 6), Session) == expected
|
||||
|
||||
|
||||
def test_all_sessions_matches_total_sessions(world):
|
||||
stats = compute_stats(YEAR)
|
||||
assert _count(stats_links.all_sessions(YEAR), Session) == stats["total_sessions"]
|
||||
|
||||
|
||||
# ── Count links ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_games_played_matches_total_games(world):
|
||||
stats = compute_stats(YEAR)
|
||||
assert _count(stats_links.games_played(YEAR), Game) == stats["total_games"]
|
||||
|
||||
|
||||
def test_total_purchases_matches_count(world):
|
||||
stats = compute_stats(YEAR)
|
||||
assert (
|
||||
_count(stats_links.purchases_total(YEAR), Purchase)
|
||||
== stats["all_purchased_this_year_count"]
|
||||
)
|
||||
|
||||
|
||||
def test_refunded_purchases_matches_count(world):
|
||||
stats = compute_stats(YEAR)
|
||||
assert (
|
||||
_count(stats_links.purchases_refunded(YEAR), Purchase)
|
||||
== stats["all_purchased_refunded_this_year_count"]
|
||||
)
|
||||
|
||||
|
||||
# ── Tier 2: finished / dropped / unfinished / backlog (uses #67) ─────────────
|
||||
|
||||
|
||||
def test_dropped_matches_count(world):
|
||||
stats = compute_stats(YEAR)
|
||||
assert stats["dropped_count"] == 2 # guard: discriminating, non-zero
|
||||
assert (
|
||||
_count(stats_links.purchases_dropped(YEAR), Purchase) == stats["dropped_count"]
|
||||
)
|
||||
|
||||
|
||||
def test_unfinished_matches_count(world):
|
||||
stats = compute_stats(YEAR)
|
||||
assert stats["purchased_unfinished_count"] == 1
|
||||
assert (
|
||||
_count(stats_links.purchases_unfinished(YEAR), Purchase)
|
||||
== stats["purchased_unfinished_count"]
|
||||
)
|
||||
|
||||
|
||||
def test_finished_matches_count(world):
|
||||
stats = compute_stats(YEAR)
|
||||
assert stats["all_finished_this_year_count"] == 2
|
||||
assert (
|
||||
_count(stats_links.purchases_finished(YEAR), Purchase)
|
||||
== stats["all_finished_this_year_count"]
|
||||
)
|
||||
|
||||
|
||||
def test_finished_released_matches_count(world):
|
||||
stats = compute_stats(YEAR)
|
||||
assert (
|
||||
_count(stats_links.purchases_finished_released(YEAR), Purchase)
|
||||
== stats["this_year_finished_this_year_count"]
|
||||
)
|
||||
|
||||
|
||||
def test_bought_and_finished_matches_list(world):
|
||||
stats = compute_stats(YEAR)
|
||||
expected = stats["purchased_this_year_finished_this_year"].count()
|
||||
assert expected == 1
|
||||
assert _count(stats_links.purchases_bought_and_finished(YEAR), Purchase) == expected
|
||||
|
||||
|
||||
def test_backlog_decrease_matches_count(world):
|
||||
stats = compute_stats(YEAR)
|
||||
assert stats["backlog_decrease_count"] == 1
|
||||
assert (
|
||||
_count(stats_links.purchases_backlog_decrease(YEAR), Purchase)
|
||||
== stats["backlog_decrease_count"]
|
||||
)
|
||||
|
||||
|
||||
# ── All-time scope (no date constraint) ──────────────────────────────────────
|
||||
|
||||
|
||||
def test_all_sessions_alltime_matches(world):
|
||||
stats = compute_stats(None)
|
||||
assert (
|
||||
_count(stats_links.all_sessions("Alltime"), Session) == stats["total_sessions"]
|
||||
)
|
||||
|
||||
|
||||
def test_finished_alltime_matches_backlog(world):
|
||||
stats = compute_stats(None)
|
||||
# all-time backlog_decrease_count == all-time finished count
|
||||
assert (
|
||||
_count(stats_links.purchases_backlog_decrease("Alltime"), Purchase)
|
||||
== stats["backlog_decrease_count"]
|
||||
)
|
||||
Reference in New Issue
Block a user