Make GameDetailView faster

This commit is contained in:
Joakim Hellsén 2026-04-12 05:08:40 +02:00
commit 16b12a7035
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
4 changed files with 157 additions and 109 deletions

View file

@ -0,0 +1,22 @@
# Generated by Django 6.0.4 on 2026-04-12 03:06
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
"Add an index on the DropCampaign model for the game and end_at fields, with end_at in descending order. This is to optimize queries that filter by game and order by end_at descending."
dependencies = [
("twitch", "0021_channel_allowed_campaign_count_cache"),
]
operations = [
migrations.AddIndex(
model_name="dropcampaign",
index=models.Index(
fields=["game", "-end_at"],
name="tw_drop_game_end_desc_idx",
),
),
]

View file

@ -244,6 +244,30 @@ 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 for_detail_view(cls) -> models.QuerySet[Game]:
"""Return games with only fields/relations needed by game detail view."""
return cls.objects.only(
"twitch_id",
"slug",
"name",
"display_name",
"box_art",
"box_art_file",
"box_art_width",
"box_art_height",
"added_at",
"updated_at",
).prefetch_related(
Prefetch(
"owners",
queryset=Organization.objects.only("twitch_id", "name").order_by(
"name",
),
to_attr="owners_for_detail",
),
)
@classmethod @classmethod
def with_campaign_counts( def with_campaign_counts(
cls, cls,
@ -686,6 +710,11 @@ class DropCampaign(auto_prefetch.Model):
fields=["is_fully_imported", "start_at", "end_at"], fields=["is_fully_imported", "start_at", "end_at"],
name="tw_drop_imported_start_end_idx", name="tw_drop_imported_start_end_idx",
), ),
# For game detail campaigns listing by game with end date ordering.
models.Index(
fields=["game", "-end_at"],
name="tw_drop_game_end_desc_idx",
),
] ]
def __str__(self) -> str: def __str__(self) -> str:
@ -732,6 +761,48 @@ class DropCampaign(auto_prefetch.Model):
queryset = queryset.filter(end_at__lt=now) queryset = queryset.filter(end_at__lt=now)
return queryset return queryset
@classmethod
def for_game_detail(
cls,
game: Game,
) -> models.QuerySet[DropCampaign]:
"""Return campaigns with only game-detail-needed relations/fields loaded."""
return (
cls.objects
.filter(game=game)
.select_related("game")
.only(
"twitch_id",
"name",
"start_at",
"end_at",
"game",
"game__display_name",
)
.prefetch_related(
Prefetch(
"time_based_drops",
queryset=TimeBasedDrop.objects.only(
"twitch_id",
"campaign_id",
).prefetch_related(
Prefetch(
"benefits",
queryset=DropBenefit.objects.only(
"twitch_id",
"name",
"image_asset_url",
"image_file",
"image_width",
"image_height",
).order_by("name"),
),
),
),
)
.order_by("-end_at")
)
@classmethod @classmethod
def for_detail_view(cls, twitch_id: str) -> DropCampaign: def for_detail_view(cls, twitch_id: str) -> DropCampaign:
"""Return a campaign with only detail-view-required relations/fields loaded. """Return a campaign with only detail-view-required relations/fields loaded.

View file

@ -2235,6 +2235,58 @@ class TestChannelListView:
assert response.status_code == 200 assert response.status_code == 200
assert "game" in response.context assert "game" in response.context
@pytest.mark.django_db
def test_game_detail_campaign_query_plan_uses_game_end_index(self) -> None:
"""Game-detail campaign list query should use the game/end_at composite index."""
now: datetime.datetime = timezone.now()
game: Game = Game.objects.create(
twitch_id="game_detail_idx_game",
name="Game Detail Index Game",
display_name="Game Detail Index Game",
)
campaigns: list[DropCampaign] = []
for i in range(200):
campaigns.extend((
DropCampaign(
twitch_id=f"game_detail_idx_old_{i}",
name=f"Old campaign {i}",
game=game,
operation_names=["DropCampaignDetails"],
start_at=now - timedelta(days=90),
end_at=now - timedelta(days=60),
),
DropCampaign(
twitch_id=f"game_detail_idx_future_{i}",
name=f"Future campaign {i}",
game=game,
operation_names=["DropCampaignDetails"],
start_at=now + timedelta(days=60),
end_at=now + timedelta(days=90),
),
))
campaigns.append(
DropCampaign(
twitch_id="game_detail_idx_active",
name="Active campaign",
game=game,
operation_names=["DropCampaignDetails"],
start_at=now - timedelta(hours=1),
end_at=now + timedelta(hours=1),
),
)
DropCampaign.objects.bulk_create(campaigns)
plan: str = DropCampaign.for_game_detail(game).explain().lower()
if connection.vendor in {"sqlite", "postgresql"}:
assert "tw_drop_game_end_desc_idx" in plan, plan
else:
pytest.skip(
f"Unsupported DB vendor for index-name plan assertion: {connection.vendor}",
)
@pytest.mark.django_db @pytest.mark.django_db
def test_game_detail_image_aspect_ratio(self, client: Client, db: None) -> None: def test_game_detail_image_aspect_ratio(self, client: Client, db: None) -> None:
"""Box art should render with a width attribute only, preserving aspect ratio.""" """Box art should render with a width attribute only, preserving aspect ratio."""

View file

@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
import csv import csv
import datetime
import json import json
import logging import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@ -13,7 +12,6 @@ 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 Prefetch
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
@ -33,9 +31,10 @@ from twitch.models import DropCampaign
from twitch.models import Game from twitch.models import Game
from twitch.models import Organization from twitch.models import Organization
from twitch.models import RewardCampaign from twitch.models import RewardCampaign
from twitch.models import TimeBasedDrop
if TYPE_CHECKING: if TYPE_CHECKING:
import datetime
from django.db.models import QuerySet from django.db.models import QuerySet
from django.http import HttpRequest from django.http import HttpRequest
@ -698,32 +697,12 @@ class GameDetailView(DetailView):
model = Game model = Game
template_name = "twitch/game_detail.html" template_name = "twitch/game_detail.html"
context_object_name = "game" context_object_name = "game"
lookup_field = "twitch_id" slug_field = "twitch_id"
slug_url_kwarg = "twitch_id"
def get_object(self, queryset: QuerySet[Game] | None = None) -> Game: def get_queryset(self) -> QuerySet[Game]:
"""Get the game object using twitch_id as the primary key lookup. """Return game queryset optimized for the game detail page."""
return Game.for_detail_view()
Args:
queryset: Optional queryset to use.
Returns:
Game: The game object.
Raises:
Http404: If the game is not found.
"""
if queryset is None:
queryset = self.get_queryset()
# Use twitch_id as the lookup field since it's the primary key
twitch_id: str | None = self.kwargs.get("twitch_id")
try:
game: Game = queryset.get(twitch_id=twitch_id)
except Game.DoesNotExist as exc:
msg = "No game found matching the query"
raise Http404(msg) from exc
return game
def get_context_data(self, **kwargs) -> dict[str, Any]: # noqa: PLR0914 def get_context_data(self, **kwargs) -> dict[str, Any]: # noqa: PLR0914
"""Add additional context data. """Add additional context data.
@ -740,88 +719,13 @@ class GameDetailView(DetailView):
game: Game = self.object # pyright: ignore[reportAssignmentType] game: Game = self.object # pyright: ignore[reportAssignmentType]
now: datetime.datetime = timezone.now() now: datetime.datetime = timezone.now()
all_campaigns: QuerySet[DropCampaign] = ( campaigns_list: list[DropCampaign] = list(DropCampaign.for_game_detail(game))
DropCampaign.objects active_campaigns, upcoming_campaigns, expired_campaigns = (
.filter(game=game) DropCampaign.split_for_channel_detail(campaigns_list, now)
.select_related("game")
.prefetch_related(
Prefetch(
"time_based_drops",
queryset=TimeBasedDrop.objects.prefetch_related(
Prefetch(
"benefits",
queryset=DropBenefit.objects.order_by("name"),
),
),
),
)
.order_by("-end_at")
) )
owners: list[Organization] = list(getattr(game, "owners_for_detail", []))
campaigns_list: list[DropCampaign] = list(all_campaigns) game_name: str = game.get_game_name
# For each drop, find awarded badge (distribution_type BADGE)
drop_awarded_badges: dict[str, ChatBadge] = {}
benefit_badge_titles: set[str] = set()
for campaign in campaigns_list:
for drop in campaign.time_based_drops.all(): # pyright: ignore[reportAttributeAccessIssue]
for benefit in drop.benefits.all():
if benefit.distribution_type == "BADGE" and benefit.name:
benefit_badge_titles.add(benefit.name)
# Bulk-load all matching ChatBadge instances to avoid N+1 queries
badges_by_title: dict[str, ChatBadge] = {
badge.title: badge
for badge in ChatBadge.objects.filter(title__in=benefit_badge_titles)
}
for campaign in campaigns_list:
for drop in campaign.time_based_drops.all(): # pyright: ignore[reportAttributeAccessIssue]
for benefit in drop.benefits.all():
if benefit.distribution_type == "BADGE":
badge: ChatBadge | None = badges_by_title.get(benefit.name)
if badge:
drop_awarded_badges[drop.twitch_id] = badge
active_campaigns: list[DropCampaign] = [
campaign
for campaign in campaigns_list
if campaign.start_at is not None
and campaign.start_at <= now
and campaign.end_at is not None
and campaign.end_at >= now
]
active_campaigns.sort(
key=lambda c: (
c.end_at
if c.end_at is not None
else datetime.datetime.max.replace(tzinfo=datetime.UTC)
),
)
upcoming_campaigns: list[DropCampaign] = [
campaign
for campaign in campaigns_list
if campaign.start_at is not None and campaign.start_at > now
]
upcoming_campaigns.sort(
key=lambda c: (
c.start_at
if c.start_at is not None
else datetime.datetime.max.replace(tzinfo=datetime.UTC)
),
)
expired_campaigns: list[DropCampaign] = [
campaign
for campaign in campaigns_list
if campaign.end_at is not None and campaign.end_at < now
]
owners: list[Organization] = list(game.owners.all())
game_name: str = game.display_name or game.name or game.twitch_id
game_description: str = f"Twitch drops for {game_name}." game_description: str = f"Twitch drops for {game_name}."
game_image: str | None = game.box_art_best_url game_image: str | None = game.box_art_best_url
game_image_width: int | None = game.box_art_width if game.box_art_file else None game_image_width: int | None = game.box_art_width if game.box_art_file else None
@ -902,7 +806,6 @@ class GameDetailView(DetailView):
"expired_campaigns": expired_campaigns, "expired_campaigns": expired_campaigns,
"owner": owners[0] if owners else None, "owner": owners[0] if owners else None,
"owners": owners, "owners": owners,
"drop_awarded_badges": drop_awarded_badges,
"now": now, "now": now,
**seo_context, **seo_context,
}) })