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.conf import settings
from django.contrib.postgres.indexes import GinIndex from django.contrib.postgres.indexes import GinIndex
from django.db import models from django.db import models
from django.db.models import Exists
from django.db.models import F from django.db.models import F
from django.db.models import Prefetch from django.db.models import Prefetch
from django.db.models import Q from django.db.models import Q
from django.db.models.functions import Coalesce
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.html import format_html 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 dashboard-safe box art URL without touching deferred image fields."""
return normalize_twitch_box_art_url(self.box_art or "") 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 # MARK: TwitchGame
class TwitchGameData(auto_prefetch.Model): class TwitchGameData(auto_prefetch.Model):

View file

@ -2151,6 +2151,70 @@ class TestChannelListView:
assert response.status_code == 200 assert response.status_code == 200
assert "games" in response.context 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 @pytest.mark.django_db
def test_games_list_view(self, client: Client) -> None: def test_games_list_view(self, client: Client) -> None:
"""Test games list view returns 200 and has games in context.""" """Test games list view returns 200 and has games in context."""

View file

@ -4,8 +4,6 @@ import csv
import datetime import datetime
import json import json
import logging import logging
from collections import OrderedDict
from collections import defaultdict
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from typing import Any from typing import Any
from typing import Literal 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 Page
from django.core.paginator import PageNotAnInteger from django.core.paginator import PageNotAnInteger
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.db.models import Count
from django.db.models import Prefetch from django.db.models import Prefetch
from django.db.models import Q
from django.db.models.query import QuerySet from django.db.models.query import QuerySet
from django.http import Http404 from django.http import Http404
from django.http import HttpResponse from django.http import HttpResponse
@ -654,23 +650,9 @@ class GamesGridView(ListView):
Returns: Returns:
QuerySet: Annotated games queryset. QuerySet: Annotated games queryset.
""" """
now: datetime.datetime = timezone.now() return Game.with_campaign_counts(
return ( timezone.now(),
super() with_campaigns_only=True,
.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")
) )
def get_context_data(self, **kwargs) -> dict[str, Any]: def get_context_data(self, **kwargs) -> dict[str, Any]:
@ -685,35 +667,9 @@ class GamesGridView(ListView):
dict: Context data with games grouped by organization. dict: Context data with games grouped by organization.
""" """
context: dict[str, Any] = super().get_context_data(**kwargs) context: dict[str, Any] = super().get_context_data(**kwargs)
now: datetime.datetime = timezone.now() games: QuerySet[Game] = context["games"]
context["games_by_org"] = Game.grouped_by_owner_for_grid(
games_with_campaigns: QuerySet[Game] = ( games,
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),
) )
# CollectionPage schema for games list # CollectionPage schema for games list