Improve stats code smells
This commit is contained in:
+1217
-274
File diff suppressed because it is too large
Load Diff
+6
-470
@@ -3,19 +3,9 @@ from typing import Any, Callable
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db.models import (
|
||||
Avg,
|
||||
Count,
|
||||
ExpressionWrapper,
|
||||
F,
|
||||
Max,
|
||||
OuterRef,
|
||||
Prefetch,
|
||||
Q,
|
||||
Subquery,
|
||||
Sum,
|
||||
fields,
|
||||
)
|
||||
from django.db.models.functions import TruncDate, TruncMonth
|
||||
|
||||
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
||||
from django.shortcuts import redirect
|
||||
@@ -23,10 +13,10 @@ from django.urls import reverse
|
||||
from django.utils.timezone import now as timezone_now
|
||||
|
||||
from common.layout import render_page
|
||||
from common.time import available_stats_year_range, dateformat, format_duration
|
||||
from common.utils import safe_division
|
||||
from common.time import format_duration
|
||||
from games.models import Game, Platform, Purchase, Session
|
||||
from games.views.stats_content import stats_content
|
||||
from games.views.stats_data import compute_stats
|
||||
|
||||
|
||||
def model_counts(request: HttpRequest) -> dict[str, bool]:
|
||||
@@ -75,210 +65,9 @@ def use_custom_redirect(
|
||||
|
||||
@login_required
|
||||
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
|
||||
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
|
||||
@@ -290,262 +79,9 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
|
||||
)
|
||||
if year == 0:
|
||||
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
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user