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