Make GamesGridView faster

This commit is contained in:
Joakim Hellsén 2026-04-12 05:02:56 +02:00
commit 4714894247
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
3 changed files with 168 additions and 50 deletions

View file

@ -9,9 +9,11 @@ import auto_prefetch
from django.conf import settings
from django.contrib.postgres.indexes import GinIndex
from django.db import models
from django.db.models import Exists
from django.db.models import F
from django.db.models import Prefetch
from django.db.models import Q
from django.db.models.functions import Coalesce
from django.urls import reverse
from django.utils import timezone
from django.utils.html import format_html
@ -242,6 +244,102 @@ class Game(auto_prefetch.Model):
"""Return dashboard-safe box art URL without touching deferred image fields."""
return normalize_twitch_box_art_url(self.box_art or "")
@classmethod
def with_campaign_counts(
cls,
now: datetime.datetime,
*,
with_campaigns_only: bool = False,
) -> models.QuerySet[Game]:
"""Return games annotated with total/active campaign counts.
Args:
now: Current timestamp used to evaluate active campaigns.
with_campaigns_only: If True, include only games with at least one campaign.
Returns:
QuerySet optimized for games list/grid rendering.
"""
campaigns_for_game = DropCampaign.objects.filter(
game_id=models.OuterRef("pk"),
)
campaign_count_subquery = (
campaigns_for_game
.order_by()
.values("game_id")
.annotate(total=models.Count("id"))
.values("total")[:1]
)
active_count_subquery = (
campaigns_for_game
.filter(start_at__lte=now, end_at__gte=now)
.order_by()
.values("game_id")
.annotate(total=models.Count("id"))
.values("total")[:1]
)
queryset: models.QuerySet[Game] = (
cls.objects
.only(
"twitch_id",
"display_name",
"name",
"slug",
"box_art",
"box_art_file",
"box_art_width",
"box_art_height",
)
.prefetch_related(
Prefetch(
"owners",
queryset=Organization.objects.only("twitch_id", "name").order_by(
"name",
),
),
)
.annotate(
campaign_count=Coalesce(
models.Subquery(
campaign_count_subquery,
output_field=models.IntegerField(),
),
models.Value(0),
),
active_count=Coalesce(
models.Subquery(
active_count_subquery,
output_field=models.IntegerField(),
),
models.Value(0),
),
)
.order_by("display_name")
)
if with_campaigns_only:
queryset = queryset.filter(Exists(campaigns_for_game))
return queryset
@staticmethod
def grouped_by_owner_for_grid(
games: models.QuerySet[Game],
) -> OrderedDict[Organization, list[dict[str, Game]]]:
"""Group games by owner organization for games grid/list pages.
Args:
games: QuerySet of games with prefetched owners.
Returns:
Ordered mapping of organizations to game dictionaries.
"""
grouped: OrderedDict[Organization, list[dict[str, Game]]] = OrderedDict()
for game in games:
for owner in game.owners.all():
grouped.setdefault(owner, []).append({"game": game})
return OrderedDict(sorted(grouped.items(), key=lambda item: item[0].name))
# MARK: TwitchGame
class TwitchGameData(auto_prefetch.Model):

View file

@ -2151,6 +2151,70 @@ class TestChannelListView:
assert response.status_code == 200
assert "games" in response.context
@pytest.mark.django_db
def test_games_grid_view_groups_only_games_with_campaigns(
self,
client: Client,
) -> None:
"""Games grid should group only games that actually have campaigns."""
now: datetime.datetime = timezone.now()
org_with_campaign: Organization = Organization.objects.create(
twitch_id="org-games-grid-with-campaign",
name="Org Games Grid With Campaign",
)
org_without_campaign: Organization = Organization.objects.create(
twitch_id="org-games-grid-without-campaign",
name="Org Games Grid Without Campaign",
)
game_with_campaign: Game = Game.objects.create(
twitch_id="game-games-grid-with-campaign",
name="Game Games Grid With Campaign",
display_name="Game Games Grid With Campaign",
)
game_with_campaign.owners.add(org_with_campaign)
game_without_campaign: Game = Game.objects.create(
twitch_id="game-games-grid-without-campaign",
name="Game Games Grid Without Campaign",
display_name="Game Games Grid Without Campaign",
)
game_without_campaign.owners.add(org_without_campaign)
DropCampaign.objects.create(
twitch_id="campaign-games-grid-with-campaign",
name="Campaign Games Grid With Campaign",
game=game_with_campaign,
operation_names=["DropCampaignDetails"],
start_at=now - timedelta(hours=1),
end_at=now + timedelta(hours=1),
)
response: _MonkeyPatchedWSGIResponse = client.get(reverse("twitch:games_grid"))
assert response.status_code == 200
context: ContextList | dict[str, Any] = response.context # type: ignore[assignment]
if isinstance(context, list):
context = context[-1]
games: list[Game] = list(context["games"])
games_by_org: OrderedDict[Organization, list[dict[str, Game]]] = context[
"games_by_org"
]
game_ids: set[str] = {game.twitch_id for game in games}
assert game_with_campaign.twitch_id in game_ids
assert game_without_campaign.twitch_id not in game_ids
grouped_ids: set[str] = {
item["game"].twitch_id
for grouped_games in games_by_org.values()
for item in grouped_games
}
assert game_with_campaign.twitch_id in grouped_ids
assert game_without_campaign.twitch_id not in grouped_ids
@pytest.mark.django_db
def test_games_list_view(self, client: Client) -> None:
"""Test games list view returns 200 and has games in context."""

View file

@ -4,8 +4,6 @@ import csv
import datetime
import json
import logging
from collections import OrderedDict
from collections import defaultdict
from typing import TYPE_CHECKING
from typing import Any
from typing import Literal
@ -15,9 +13,7 @@ from django.core.paginator import EmptyPage
from django.core.paginator import Page
from django.core.paginator import PageNotAnInteger
from django.core.paginator import Paginator
from django.db.models import Count
from django.db.models import Prefetch
from django.db.models import Q
from django.db.models.query import QuerySet
from django.http import Http404
from django.http import HttpResponse
@ -654,23 +650,9 @@ class GamesGridView(ListView):
Returns:
QuerySet: Annotated games queryset.
"""
now: datetime.datetime = timezone.now()
return (
super()
.get_queryset()
.prefetch_related("owners")
.annotate(
campaign_count=Count("drop_campaigns", distinct=True),
active_count=Count(
"drop_campaigns",
filter=Q(
drop_campaigns__start_at__lte=now,
drop_campaigns__end_at__gte=now,
),
distinct=True,
),
)
.order_by("display_name")
return Game.with_campaign_counts(
timezone.now(),
with_campaigns_only=True,
)
def get_context_data(self, **kwargs) -> dict[str, Any]:
@ -685,35 +667,9 @@ class GamesGridView(ListView):
dict: Context data with games grouped by organization.
"""
context: dict[str, Any] = super().get_context_data(**kwargs)
now: datetime.datetime = timezone.now()
games_with_campaigns: QuerySet[Game] = (
Game.objects
.filter(drop_campaigns__isnull=False)
.prefetch_related("owners")
.annotate(
campaign_count=Count("drop_campaigns", distinct=True),
active_count=Count(
"drop_campaigns",
filter=Q(
drop_campaigns__start_at__lte=now,
drop_campaigns__end_at__gte=now,
),
distinct=True,
),
)
.order_by("display_name")
)
games_by_org: defaultdict[Organization, list[dict[str, Game]]] = defaultdict(
list,
)
for game in games_with_campaigns:
for org in game.owners.all():
games_by_org[org].append({"game": game})
context["games_by_org"] = OrderedDict(
sorted(games_by_org.items(), key=lambda item: item[0].name),
games: QuerySet[Game] = context["games"]
context["games_by_org"] = Game.grouped_by_owner_for_grid(
games,
)
# CollectionPage schema for games list