Improve stats code smells

This commit is contained in:
2026-06-06 12:19:15 +02:00
parent b6864e59ce
commit f4161bf3f4
4 changed files with 1686 additions and 744 deletions
+1185 -242
View File
File diff suppressed because it is too large Load Diff
+6 -470
View File
@@ -3,19 +3,9 @@ from typing import Any, Callable
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.db.models import ( from django.db.models import (
Avg,
Count,
ExpressionWrapper,
F, F,
Max,
OuterRef,
Prefetch,
Q,
Subquery,
Sum, Sum,
fields,
) )
from django.db.models.functions import TruncDate, TruncMonth
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.shortcuts import redirect from django.shortcuts import redirect
@@ -23,10 +13,10 @@ from django.urls import reverse
from django.utils.timezone import now as timezone_now from django.utils.timezone import now as timezone_now
from common.layout import render_page from common.layout import render_page
from common.time import available_stats_year_range, dateformat, format_duration from common.time import format_duration
from common.utils import safe_division
from games.models import Game, Platform, Purchase, Session from games.models import Game, Platform, Purchase, Session
from games.views.stats_content import stats_content from games.views.stats_content import stats_content
from games.views.stats_data import compute_stats
def model_counts(request: HttpRequest) -> dict[str, bool]: def model_counts(request: HttpRequest) -> dict[str, bool]:
@@ -75,210 +65,9 @@ def use_custom_redirect(
@login_required @login_required
def stats_alltime(request: HttpRequest) -> HttpResponse: def stats_alltime(request: HttpRequest) -> HttpResponse:
year = "Alltime"
this_year_sessions = Session.objects.all().prefetch_related(Prefetch("game"))
this_year_sessions_with_durations = this_year_sessions.annotate(
duration=ExpressionWrapper(
F("timestamp_end") - F("timestamp_start"),
output_field=fields.DurationField(),
)
)
longest_session = this_year_sessions_with_durations.order_by("-duration").first()
this_year_games = Game.objects.filter(sessions__in=this_year_sessions).distinct()
this_year_games_with_session_counts = this_year_games.annotate(
session_count=Count("sessions"),
)
game_highest_session_count = this_year_games_with_session_counts.order_by(
"-session_count"
).first()
selected_currency = "CZK"
unique_days = (
this_year_sessions.annotate(date=TruncDate("timestamp_start"))
.values("date")
.distinct()
.aggregate(dates=Count("date"))
)
this_year_played_purchases = Purchase.objects.filter(
games__sessions__in=this_year_sessions
).distinct()
this_year_purchases = Purchase.objects.all()
this_year_purchases_with_currency = this_year_purchases.select_related("games")
this_year_purchases_without_refunded = Purchase.objects.filter(date_refunded=None)
this_year_purchases_refunded = Purchase.objects.refunded()
this_year_purchases_unfinished_dropped_nondropped = (
this_year_purchases_without_refunded.filter(
~Q(games__status=Game.Status.FINISHED)
& ~Q(games__playevents__ended__isnull=False)
)
.filter(infinite=False)
.filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC))
) # do not count battle passes etc.
this_year_purchases_unfinished = (
this_year_purchases_unfinished_dropped_nondropped.filter(
~Q(games__status=Game.Status.RETIRED)
& ~Q(games__status=Game.Status.ABANDONED)
)
)
this_year_purchases_dropped = (
this_year_purchases.filter(
~Q(games__status=Game.Status.FINISHED)
& ~Q(games__playevents__ended__isnull=False)
)
.filter(Q(games__status=Game.Status.ABANDONED) | Q(date_refunded__isnull=False))
.filter(infinite=False)
.filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC))
)
this_year_purchases_without_refunded_count = (
this_year_purchases_without_refunded.count()
)
this_year_purchases_unfinished_count = this_year_purchases_unfinished.count()
this_year_purchases_unfinished_percent = int(
safe_division(
this_year_purchases_unfinished_count,
this_year_purchases_without_refunded_count,
)
* 100
)
_finished_purchases_qs = Purchase.objects.finished()
_finished_with_date = _finished_purchases_qs.annotate(
date_finished=Subquery(
Purchase.objects.filter(pk=OuterRef("pk"))
.annotate(max_ended=Max("games__playevents__ended"))
.values("max_ended")[:1]
)
)
purchases_finished_this_year = _finished_with_date
purchases_finished_this_year_released_this_year = _finished_with_date.order_by(
"-date_finished"
)
this_year_spendings = this_year_purchases_without_refunded.aggregate(
total_spent=Sum(F("converted_price"))
)
total_spent = this_year_spendings["total_spent"] or 0
games_with_playtime = (
Game.objects.filter(sessions__in=this_year_sessions)
.distinct()
.annotate(total_playtime=Sum(F("sessions__duration_total")))
.filter(total_playtime__gt=timedelta(0))
)
month_playtimes = (
this_year_sessions.annotate(month=TruncMonth("timestamp_start"))
.values("month")
.annotate(playtime=Sum("duration_total"))
.order_by("month")
)
for month in month_playtimes:
month["playtime"] = format_duration(month["playtime"], "%2.0H")
highest_session_average_game = (
Game.objects.filter(sessions__in=this_year_sessions)
.annotate(session_average=Avg("sessions__duration_calculated"))
.order_by("-session_average")
.first()
)
top_10_games_by_playtime = games_with_playtime.order_by("-total_playtime")[:10]
total_playtime_per_platform = (
this_year_sessions.values("game__platform__name")
.annotate(playtime=Sum(F("duration_total")))
.annotate(platform_name=F("game__platform__name"))
.values("platform_name", "playtime")
.order_by("-playtime")
)
backlog_decrease_count = purchases_finished_this_year.count()
first_play_date = "N/A"
last_play_date = "N/A"
first_play_game = None
last_play_game = None
if this_year_sessions:
first_session = this_year_sessions.earliest()
first_play_game = first_session.game
first_play_date = first_session.timestamp_start.strftime(dateformat)
last_session = this_year_sessions.latest()
last_play_game = last_session.game
last_play_date = last_session.timestamp_start.strftime(dateformat)
all_purchased_this_year_count = this_year_purchases_with_currency.count()
all_purchased_refunded_this_year_count: int = this_year_purchases_refunded.count()
this_year_purchases_dropped_count = this_year_purchases_dropped.count()
this_year_purchases_dropped_percentage = int(
safe_division(this_year_purchases_dropped_count, all_purchased_this_year_count)
* 100
)
context = {
"total_hours": format_duration(
this_year_sessions.total_duration_unformatted(), "%2.0H"
),
"total_year_games": this_year_played_purchases.all().count(),
"top_10_games_by_playtime": top_10_games_by_playtime,
"year": year,
"total_playtime_per_platform": total_playtime_per_platform,
"total_spent": total_spent,
"total_spent_currency": selected_currency,
"spent_per_game": int(
safe_division(total_spent, this_year_purchases_without_refunded_count)
),
"this_year_finished_this_year_count": purchases_finished_this_year_released_this_year.count(),
"total_sessions": this_year_sessions.count(),
"unique_days": unique_days["dates"],
"unique_days_percent": int(unique_days["dates"] / 365 * 100),
"purchased_unfinished_count": this_year_purchases_unfinished_count,
"unfinished_purchases_percent": this_year_purchases_unfinished_percent,
"dropped_count": this_year_purchases_dropped_count,
"dropped_percentage": this_year_purchases_dropped_percentage,
"refunded_percent": int(
safe_division(
all_purchased_refunded_this_year_count,
all_purchased_this_year_count,
)
* 100
),
"all_purchased_refunded_this_year": this_year_purchases_refunded,
"all_purchased_refunded_this_year_count": all_purchased_refunded_this_year_count,
"all_purchased_this_year_count": all_purchased_this_year_count,
"backlog_decrease_count": backlog_decrease_count,
"longest_session_time": (
format_duration(longest_session.duration, "%2.0Hh %2.0mm")
if longest_session
else 0
),
"longest_session_game": (longest_session.game if longest_session else None),
"highest_session_count": (
game_highest_session_count.session_count
if game_highest_session_count
else 0
),
"highest_session_count_game": (
game_highest_session_count if game_highest_session_count else None
),
"highest_session_average": (
format_duration(
highest_session_average_game.session_average, "%2.0Hh %2.0mm"
)
if highest_session_average_game
else 0
),
"highest_session_average_game": highest_session_average_game,
"first_play_game": first_play_game,
"first_play_date": first_play_date,
"last_play_game": last_play_game,
"last_play_date": last_play_date,
"title": f"{year} Stats",
"stats_dropdown_year_range": available_stats_year_range(),
}
request.session["return_path"] = request.path request.session["return_path"] = request.path
return render_page(request, stats_content(context), title=context["title"]) data = compute_stats(None)
return render_page(request, stats_content(data), title=data["title"])
@login_required @login_required
@@ -290,262 +79,9 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
) )
if year == 0: if year == 0:
return HttpResponseRedirect(reverse("games:stats_alltime")) return HttpResponseRedirect(reverse("games:stats_alltime"))
this_year_sessions = Session.objects.filter(
timestamp_start__year=year
).prefetch_related("game")
this_year_sessions_with_durations = this_year_sessions.annotate(
duration=ExpressionWrapper(
F("timestamp_end") - F("timestamp_start"),
output_field=fields.DurationField(),
)
)
longest_session = this_year_sessions_with_durations.order_by("-duration").first()
this_year_games = Game.objects.filter(sessions__in=this_year_sessions).distinct()
this_year_games_with_session_counts = this_year_games.annotate(
session_count=Count(
"sessions",
filter=Q(sessions__timestamp_start__year=year),
)
)
game_highest_session_count = this_year_games_with_session_counts.order_by(
"-session_count"
).first()
selected_currency = "CZK"
unique_days = (
this_year_sessions.annotate(date=TruncDate("timestamp_start"))
.values("date")
.distinct()
.aggregate(dates=Count("date"))
)
this_year_played_purchases = Purchase.objects.filter(
games__sessions__in=this_year_sessions
).distinct()
this_year_played_games = Game.objects.filter(
sessions__in=this_year_sessions
).distinct()
this_year_purchases = Purchase.objects.filter(
date_purchased__year=year
).prefetch_related("games")
# purchased this year
# not refunded
this_year_purchases_without_refunded = Purchase.objects.filter(
date_refunded=None, date_purchased__year=year
)
# purchased this year
# not refunded
# not finished
# not infinite
# only Game and DLC
this_year_purchases_unfinished_dropped_nondropped = (
this_year_purchases_without_refunded.filter(
~Q(games__status=Game.Status.FINISHED)
& ~Q(games__playevents__ended__year=year)
)
.filter(infinite=False)
.filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC))
)
# unfinished = not finished AND not dropped
this_year_purchases_unfinished = (
this_year_purchases_unfinished_dropped_nondropped.filter(
~Q(games__status=Game.Status.RETIRED)
& ~Q(games__status=Game.Status.ABANDONED)
)
)
# dropped = abandoned OR retired OR refunded (OR logic for transition)
this_year_purchases_dropped = (
this_year_purchases.filter(
~Q(games__status=Game.Status.FINISHED)
& ~Q(games__playevents__ended__year=year)
)
.filter(Q(games__status=Game.Status.ABANDONED) | Q(date_refunded__isnull=False))
.filter(infinite=False)
.filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC))
)
this_year_purchases_without_refunded_count = (
this_year_purchases_without_refunded.count()
)
this_year_purchases_unfinished_count = this_year_purchases_unfinished.count()
this_year_purchases_unfinished_percent = int(
safe_division(
this_year_purchases_unfinished_count,
this_year_purchases_without_refunded_count,
)
* 100
)
purchases_finished_this_year = (
Purchase.objects.finished()
.filter(games__playevents__ended__year=year)
.annotate(
game_name=F("games__name"), date_finished=F("games__playevents__ended")
)
)
purchases_finished_this_year_released_this_year = (
purchases_finished_this_year.filter(games__year_released=year).order_by(
"games__playevents__ended"
)
)
purchased_this_year_finished_this_year = (
this_year_purchases_without_refunded.filter(
games__playevents__ended__year=year
).annotate(
game_name=F("games__name"), date_finished=F("games__playevents__ended")
)
).order_by("games__playevents__ended")
this_year_spendings = this_year_purchases_without_refunded.aggregate(
total_spent=Sum(F("converted_price"))
)
total_spent = this_year_spendings["total_spent"] or 0
games_with_playtime = (
Game.objects.filter(sessions__timestamp_start__year=year)
.annotate(
total_playtime=Sum(
F("sessions__duration_calculated"),
)
)
.filter(total_playtime__gt=timedelta(0))
)
month_playtimes = (
this_year_sessions.annotate(month=TruncMonth("timestamp_start"))
.values("month")
.annotate(playtime=Sum("duration_total"))
.order_by("month")
)
highest_session_average_game = (
Game.objects.filter(sessions__in=this_year_sessions)
.annotate(session_average=Avg("sessions__duration_calculated"))
.order_by("-session_average")
.first()
)
top_10_games_by_playtime = games_with_playtime.order_by("-total_playtime")
total_playtime_per_platform = (
this_year_sessions.values("game__platform__name")
.annotate(playtime=Sum(F("duration_total")))
.annotate(platform_name=F("game__platform__name"))
.values("platform_name", "playtime")
.order_by("-playtime")
)
backlog_decrease_count = (
Purchase.objects.filter(date_purchased__year__lt=year)
.filter(games__status=Game.Status.FINISHED)
.filter(games__playevents__ended__year=year)
.count()
)
first_play_date = "N/A"
last_play_date = "N/A"
first_play_game = None
last_play_game = None
if this_year_sessions:
first_session = this_year_sessions.earliest()
first_play_game = first_session.game
first_play_date = first_session.timestamp_start.strftime(dateformat)
last_session = this_year_sessions.latest()
last_play_game = last_session.game
last_play_date = last_session.timestamp_start.strftime(dateformat)
all_purchased_this_year_count = this_year_purchases.count()
this_year_purchases_refunded = Purchase.objects.exclude(date_refunded=None).filter(
date_purchased__year=year
)
all_purchased_refunded_this_year_count = this_year_purchases_refunded.count()
this_year_purchases_dropped_count = this_year_purchases_dropped.count()
this_year_purchases_dropped_percentage = int(
safe_division(this_year_purchases_dropped_count, all_purchased_this_year_count)
* 100
)
context = {
"total_hours": format_duration(
this_year_sessions.total_duration_unformatted(), "%2.0H"
),
"total_games": this_year_played_games.count(),
"total_year_games": this_year_played_purchases.filter(
games__year_released=year
).count(),
"top_10_games_by_playtime": top_10_games_by_playtime,
"year": year,
"total_playtime_per_platform": total_playtime_per_platform,
"total_spent": total_spent,
"total_spent_currency": selected_currency,
"spent_per_game": int(
safe_division(total_spent, this_year_purchases_without_refunded_count)
),
"all_finished_this_year": purchases_finished_this_year.prefetch_related(
"games"
).order_by("games__playevents__ended"),
"all_finished_this_year_count": purchases_finished_this_year.count(),
"this_year_finished_this_year": purchases_finished_this_year_released_this_year.prefetch_related(
"games"
).order_by("games__playevents__ended"),
"this_year_finished_this_year_count": purchases_finished_this_year_released_this_year.count(),
"purchased_this_year_finished_this_year": purchased_this_year_finished_this_year.prefetch_related(
"games"
).order_by("games__playevents__ended"),
"total_sessions": this_year_sessions.count(),
"unique_days": unique_days["dates"],
"unique_days_percent": int(unique_days["dates"] / 365 * 100),
"purchased_unfinished": this_year_purchases_unfinished,
"purchased_unfinished_count": this_year_purchases_unfinished_count,
"unfinished_purchases_percent": this_year_purchases_unfinished_percent,
"dropped_count": this_year_purchases_dropped_count,
"dropped_percentage": this_year_purchases_dropped_percentage,
"refunded_percent": int(
safe_division(
all_purchased_refunded_this_year_count,
all_purchased_this_year_count,
)
* 100
),
"all_purchased_refunded_this_year": this_year_purchases_refunded,
"all_purchased_refunded_this_year_count": all_purchased_refunded_this_year_count,
"all_purchased_this_year": this_year_purchases.order_by("date_purchased"),
"all_purchased_this_year_count": all_purchased_this_year_count,
"backlog_decrease_count": backlog_decrease_count,
"longest_session_time": (
format_duration(longest_session.duration, "%2.0Hh %2.0mm")
if longest_session
else 0
),
"longest_session_game": (longest_session.game if longest_session else None),
"highest_session_count": (
game_highest_session_count.session_count
if game_highest_session_count
else 0
),
"highest_session_count_game": (
game_highest_session_count if game_highest_session_count else None
),
"highest_session_average": (
format_duration(
highest_session_average_game.session_average, "%2.0Hh %2.0mm"
)
if highest_session_average_game
else 0
),
"highest_session_average_game": highest_session_average_game,
"first_play_game": first_play_game,
"first_play_date": first_play_date,
"last_play_game": last_play_game,
"last_play_date": last_play_date,
"title": f"{year} Stats",
"month_playtimes": month_playtimes,
"stats_dropdown_year_range": available_stats_year_range(),
}
request.session["return_path"] = request.path request.session["return_path"] = request.path
return render_page(request, stats_content(context), title=context["title"]) data = compute_stats(year)
return render_page(request, stats_content(data), title=data["title"])
@login_required @login_required
+351
View File
@@ -0,0 +1,351 @@
"""Request-free stats computation: the data half of the stats page.
`compute_stats(year)` returns a `StatsData` dict (the documented seam between
*computing* metrics and *rendering* them in `stats_content`). Today it computes
from the ORM; this is also the function a future materialization job would call,
and the shape it would populate from a pre-calculated table.
`year=None` means all-time; otherwise the metrics are scoped to that calendar
year. The two scopes genuinely diverge (different aggregations, and all-time
hides the per-purchase list sections), so the differences are kept explicit.
"""
from datetime import date, timedelta
from typing import Any, NotRequired, TypedDict
from django.db.models import (
Avg,
Count,
ExpressionWrapper,
F,
Max,
OuterRef,
Q,
Subquery,
Sum,
fields,
)
from django.db.models.functions import TruncDate, TruncMonth
from common.time import available_stats_year_range, dateformat, format_duration
from common.utils import safe_division
from games.models import Game, Purchase, Session
class StatsData(TypedDict):
# --- always present (both scopes) ---
year: Any # int for a year, "Alltime" for all-time
title: str
total_hours: str
total_sessions: int
unique_days: int
unique_days_percent: int
total_year_games: int
this_year_finished_this_year_count: int
top_10_games_by_playtime: Any
total_playtime_per_platform: Any
total_spent: Any
total_spent_currency: str
spent_per_game: int
all_purchased_this_year_count: int
all_purchased_refunded_this_year: Any
all_purchased_refunded_this_year_count: int
refunded_percent: int
dropped_count: int
dropped_percentage: int
purchased_unfinished_count: int
unfinished_purchases_percent: int
backlog_decrease_count: int
longest_session_time: Any
longest_session_game: Any
highest_session_count: int
highest_session_count_game: Any
highest_session_average: Any
highest_session_average_game: Any
first_play_game: Any
first_play_date: str
last_play_game: Any
last_play_date: str
stats_dropdown_year_range: Any
# --- per-year only (omitted for all-time, which hides these sections) ---
total_games: NotRequired[int]
month_playtimes: NotRequired[Any]
all_finished_this_year: NotRequired[Any]
all_finished_this_year_count: NotRequired[int]
this_year_finished_this_year: NotRequired[Any]
purchased_this_year_finished_this_year: NotRequired[Any]
purchased_unfinished: NotRequired[Any]
all_purchased_this_year: NotRequired[Any]
def _days_played_percent(unique_days: int, first: date, last: date) -> int:
"""Share of days played across the span actually played (all-time).
Unlike the per-year metric (``unique_days / 365``), the all-time span is the
real number of days between the first and last session, so the result stays
meaningful (and ≤100%) across multiple years.
"""
span = (last - first).days + 1
if span <= 0:
return 0
return min(int(unique_days / span * 100), 100)
def compute_stats(year: int | None = None) -> StatsData:
is_alltime = year is None
currency = "CZK"
# ── Scope ──────────────────────────────────────────────────────────────
if is_alltime:
sessions = Session.objects.all().prefetch_related("game")
purchases = Purchase.objects.all()
without_refunded = Purchase.objects.filter(date_refunded=None)
refunded = Purchase.objects.refunded()
ended_q = Q(games__playevents__ended__isnull=False)
session_count = Count("sessions")
else:
sessions = Session.objects.filter(timestamp_start__year=year).prefetch_related(
"game"
)
purchases = Purchase.objects.filter(date_purchased__year=year)
without_refunded = Purchase.objects.filter(
date_refunded=None, date_purchased__year=year
)
refunded = Purchase.objects.exclude(date_refunded=None).filter(
date_purchased__year=year
)
ended_q = Q(games__playevents__ended__year=year)
session_count = Count(
"sessions", filter=Q(sessions__timestamp_start__year=year)
)
not_finished_q = ~Q(games__status=Game.Status.FINISHED) & ~ended_q
# ── Session superlatives ─────────────────────────────────────────────────
longest_session = (
sessions.annotate(
duration=ExpressionWrapper(
F("timestamp_end") - F("timestamp_start"),
output_field=fields.DurationField(),
)
)
.order_by("-duration")
.first()
)
games_in_scope = Game.objects.filter(sessions__in=sessions).distinct()
highest_session_count_game = (
games_in_scope.annotate(session_count=session_count)
.order_by("-session_count")
.first()
)
highest_session_average_game = (
Game.objects.filter(sessions__in=sessions)
.annotate(session_average=Avg("sessions__duration_calculated"))
.order_by("-session_average")
.first()
)
# ── Days played + play range ─────────────────────────────────────────────
unique_days = (
sessions.annotate(date=TruncDate("timestamp_start"))
.values("date")
.distinct()
.aggregate(dates=Count("date"))["dates"]
)
first_session = sessions.earliest() if sessions.exists() else None
last_session = sessions.latest() if sessions.exists() else None
first_play_game = first_session.game if first_session else None
last_play_game = last_session.game if last_session else None
first_play_date = (
first_session.timestamp_start.strftime(dateformat) if first_session else "N/A"
)
last_play_date = (
last_session.timestamp_start.strftime(dateformat) if last_session else "N/A"
)
if is_alltime:
unique_days_percent = (
_days_played_percent(
unique_days,
first_session.timestamp_start.date(),
last_session.timestamp_start.date(),
)
if first_session
else 0
)
else:
unique_days_percent = int(unique_days / 365 * 100)
# ── Spending ─────────────────────────────────────────────────────────────
total_spent = without_refunded.aggregate(total=Sum(F("converted_price")))["total"] or 0
without_refunded_count = without_refunded.count()
# ── Purchase breakdown ───────────────────────────────────────────────────
only_games_and_dlc = Q(type=Purchase.GAME) | Q(type=Purchase.DLC)
unfinished = (
without_refunded.filter(not_finished_q)
.filter(infinite=False)
.filter(only_games_and_dlc)
.filter(~Q(games__status=Game.Status.RETIRED) & ~Q(games__status=Game.Status.ABANDONED))
)
dropped = (
purchases.filter(not_finished_q)
.filter(Q(games__status=Game.Status.ABANDONED) | Q(date_refunded__isnull=False))
.filter(infinite=False)
.filter(only_games_and_dlc)
)
unfinished_count = unfinished.count()
dropped_count = dropped.count()
all_purchased_count = purchases.count()
refunded_count = refunded.count()
# ── Finished purchases (scope-divergent) ─────────────────────────────────
if is_alltime:
finished = Purchase.objects.finished().annotate(
date_finished=Subquery(
Purchase.objects.filter(pk=OuterRef("pk"))
.annotate(max_ended=Max("games__playevents__ended"))
.values("max_ended")[:1]
)
)
finished_released = finished.order_by("-date_finished")
backlog_decrease_count = finished.count()
else:
finished = (
Purchase.objects.finished()
.filter(games__playevents__ended__year=year)
.annotate(
game_name=F("games__name"), date_finished=F("games__playevents__ended")
)
)
finished_released = finished.filter(games__year_released=year).order_by(
"games__playevents__ended"
)
purchased_finished = (
without_refunded.filter(games__playevents__ended__year=year)
.annotate(
game_name=F("games__name"), date_finished=F("games__playevents__ended")
)
.order_by("games__playevents__ended")
)
backlog_decrease_count = (
Purchase.objects.filter(date_purchased__year__lt=year)
.filter(games__status=Game.Status.FINISHED)
.filter(games__playevents__ended__year=year)
.count()
)
# ── Games / platforms by playtime (unified on duration_total) ────────────
if is_alltime:
games_with_playtime = (
Game.objects.filter(sessions__in=sessions)
.distinct()
.annotate(total_playtime=Sum("sessions__duration_total"))
.filter(total_playtime__gt=timedelta(0))
)
top_games = games_with_playtime.order_by("-total_playtime")[:10]
else:
games_with_playtime = (
Game.objects.filter(sessions__timestamp_start__year=year)
.annotate(total_playtime=Sum("sessions__duration_total"))
.filter(total_playtime__gt=timedelta(0))
)
top_games = games_with_playtime.order_by("-total_playtime")
total_playtime_per_platform = (
sessions.values("game__platform__name")
.annotate(playtime=Sum(F("duration_total")))
.annotate(platform_name=F("game__platform__name"))
.values("platform_name", "playtime")
.order_by("-playtime")
)
played_purchases = Purchase.objects.filter(games__sessions__in=sessions).distinct()
total_year_games = (
played_purchases.count()
if is_alltime
else played_purchases.filter(games__year_released=year).count()
)
year_label = "Alltime" if is_alltime else year
data: StatsData = {
"year": year_label,
"title": f"{year_label} Stats",
"total_hours": format_duration(
sessions.total_duration_unformatted(), "%2.0H"
),
"total_sessions": sessions.count(),
"unique_days": unique_days,
"unique_days_percent": unique_days_percent,
"total_year_games": total_year_games,
"this_year_finished_this_year_count": finished_released.count(),
"top_10_games_by_playtime": top_games,
"total_playtime_per_platform": total_playtime_per_platform,
"total_spent": total_spent,
"total_spent_currency": currency,
"spent_per_game": int(safe_division(total_spent, without_refunded_count)),
"all_purchased_this_year_count": all_purchased_count,
"all_purchased_refunded_this_year": refunded,
"all_purchased_refunded_this_year_count": refunded_count,
"refunded_percent": int(
safe_division(refunded_count, all_purchased_count) * 100
),
"dropped_count": dropped_count,
"dropped_percentage": int(
safe_division(dropped_count, all_purchased_count) * 100
),
"purchased_unfinished_count": unfinished_count,
"unfinished_purchases_percent": int(
safe_division(unfinished_count, without_refunded_count) * 100
),
"backlog_decrease_count": backlog_decrease_count,
"longest_session_time": (
format_duration(longest_session.duration, "%2.0Hh %2.0mm")
if longest_session
else 0
),
"longest_session_game": longest_session.game if longest_session else None,
"highest_session_count": (
highest_session_count_game.session_count
if highest_session_count_game
else 0
),
"highest_session_count_game": highest_session_count_game,
"highest_session_average": (
format_duration(
highest_session_average_game.session_average, "%2.0Hh %2.0mm"
)
if highest_session_average_game
else 0
),
"highest_session_average_game": highest_session_average_game,
"first_play_game": first_play_game,
"first_play_date": first_play_date,
"last_play_game": last_play_game,
"last_play_date": last_play_date,
"stats_dropdown_year_range": available_stats_year_range(),
}
if not is_alltime:
data["total_games"] = games_in_scope.count()
data["month_playtimes"] = (
sessions.annotate(month=TruncMonth("timestamp_start"))
.values("month")
.annotate(playtime=Sum("duration_total"))
.order_by("month")
)
data["all_finished_this_year"] = finished.prefetch_related("games").order_by(
"games__playevents__ended"
)
data["all_finished_this_year_count"] = finished.count()
data["this_year_finished_this_year"] = finished_released.prefetch_related(
"games"
).order_by("games__playevents__ended")
data["purchased_this_year_finished_this_year"] = (
purchased_finished.prefetch_related("games").order_by(
"games__playevents__ended"
)
)
data["purchased_unfinished"] = unfinished
data["all_purchased_this_year"] = purchases.order_by("date_purchased")
return data
+112
View File
@@ -0,0 +1,112 @@
"""Behaviour tests for the stats provider (compute_stats).
Locks the metrics that must not change in the view-unification refactor, and
pins the two intentional fixes: all-time "days played %" is span-based, and
games-by-playtime uses duration_total (so manual sessions count).
"""
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from django.conf import settings
from django.test import TestCase
from games.models import Game, Platform, Session
from games.views.stats_data import _days_played_percent, compute_stats
TZ = ZoneInfo(settings.TIME_ZONE)
class DaysPlayedPercentTest(TestCase):
"""The span-based all-time percent must differ from the old /365."""
def test_span_based_differs_from_per_year(self):
first = datetime(2021, 1, 1).date()
last = datetime(2023, 12, 31).date() # ~1095-day span
# 100 unique days over a 3-year span = ~9%, not the old 100/365 = 27%.
self.assertEqual(_days_played_percent(100, first, last), 9)
def test_capped_at_100_and_safe_on_empty_span(self):
d = datetime(2023, 1, 1).date()
self.assertEqual(_days_played_percent(5, d, d), 100) # 1-day span
self.assertEqual(_days_played_percent(0, d, d), 0)
class ComputeStatsTest(TestCase):
def setUp(self):
self.platform = Platform.objects.create(name="PC", icon="pc")
self.game_a = Game.objects.create(
name="Game A", platform=self.platform, year_released=2022
)
self.game_b = Game.objects.create(
name="Game B", platform=self.platform, year_released=2023
)
def dt(y, mo, d, h, mi=0):
return datetime(y, mo, d, h, mi, tzinfo=TZ)
# Game A in 2023: 1h + 1.5h on the same day = 2.5h
Session.objects.create(
game=self.game_a, timestamp_start=dt(2023, 6, 10, 10), timestamp_end=dt(2023, 6, 10, 11)
)
Session.objects.create(
game=self.game_a, timestamp_start=dt(2023, 6, 10, 14), timestamp_end=dt(2023, 6, 10, 15, 30)
)
# Game B in 2023: 1h tracked + 2h manual (no end) = 3h total
Session.objects.create(
game=self.game_b, timestamp_start=dt(2023, 7, 1, 20), timestamp_end=dt(2023, 7, 1, 21)
)
Session.objects.create(
game=self.game_b,
timestamp_start=dt(2023, 7, 2, 12),
duration_manual=timedelta(hours=2),
)
# Game A in 2022 (only counts toward all-time): 2h
Session.objects.create(
game=self.game_a, timestamp_start=dt(2022, 5, 1, 10), timestamp_end=dt(2022, 5, 1, 12)
)
# ── shared metrics (characterization) ──
def test_session_and_day_counts(self):
year = compute_stats(2023)
alltime = compute_stats(None)
self.assertEqual(year["total_sessions"], 4)
self.assertEqual(alltime["total_sessions"], 5)
self.assertEqual(year["unique_days"], 3) # 06-10, 07-01, 07-02
self.assertEqual(alltime["unique_days"], 4) # + 2022-05-01
def test_per_year_percent_is_over_365(self):
self.assertEqual(compute_stats(2023)["unique_days_percent"], int(3 / 365 * 100))
def test_alltime_percent_is_span_based_and_sane(self):
pct = compute_stats(None)["unique_days_percent"]
self.assertGreaterEqual(pct, 0)
self.assertLessEqual(pct, 100)
# ── the duration_total fix ──
def test_games_by_playtime_includes_manual_sessions(self):
"""In 2023, Game B's manual 2h must count, putting it (3h) above A (2.5h)."""
top = list(compute_stats(2023)["top_10_games_by_playtime"])
self.assertEqual(top[0].id, self.game_b.id)
self.assertEqual(top[0].total_playtime, timedelta(hours=3))
def test_alltime_playtime_sums_all_years(self):
"""All-time Game A = 2.5h (2023) + 2h (2022) = 4.5h, ahead of B (3h)."""
top = list(compute_stats(None)["top_10_games_by_playtime"])
self.assertEqual(top[0].id, self.game_a.id)
self.assertEqual(top[0].total_playtime, timedelta(hours=4, minutes=30))
# ── section visibility (scope difference preserved) ──
def test_alltime_omits_per_year_list_sections(self):
alltime = compute_stats(None)
year = compute_stats(2023)
for key in ("month_playtimes", "all_purchased_this_year", "total_games"):
self.assertNotIn(key, alltime)
self.assertIn(key, year)
def test_year_label(self):
self.assertEqual(compute_stats(None)["year"], "Alltime")
self.assertEqual(compute_stats(2023)["year"], 2023)