From 16b12a7035c15913450ea33d96072770a2381698 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20Hells=C3=A9n?= Date: Sun, 12 Apr 2026 05:08:40 +0200 Subject: [PATCH] Make GameDetailView faster --- ..._dropcampaign_tw_drop_game_end_desc_idx.py | 22 ++++ twitch/models.py | 71 ++++++++++ twitch/tests/test_views.py | 52 ++++++++ twitch/views.py | 121 ++---------------- 4 files changed, 157 insertions(+), 109 deletions(-) create mode 100644 twitch/migrations/0022_dropcampaign_tw_drop_game_end_desc_idx.py diff --git a/twitch/migrations/0022_dropcampaign_tw_drop_game_end_desc_idx.py b/twitch/migrations/0022_dropcampaign_tw_drop_game_end_desc_idx.py new file mode 100644 index 0000000..16a8c35 --- /dev/null +++ b/twitch/migrations/0022_dropcampaign_tw_drop_game_end_desc_idx.py @@ -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", + ), + ), + ] diff --git a/twitch/models.py b/twitch/models.py index 11d2ef8..85bd565 100644 --- a/twitch/models.py +++ b/twitch/models.py @@ -244,6 +244,30 @@ 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 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 def with_campaign_counts( cls, @@ -686,6 +710,11 @@ class DropCampaign(auto_prefetch.Model): fields=["is_fully_imported", "start_at", "end_at"], 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: @@ -732,6 +761,48 @@ class DropCampaign(auto_prefetch.Model): queryset = queryset.filter(end_at__lt=now) 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 def for_detail_view(cls, twitch_id: str) -> DropCampaign: """Return a campaign with only detail-view-required relations/fields loaded. diff --git a/twitch/tests/test_views.py b/twitch/tests/test_views.py index df9f76d..5377555 100644 --- a/twitch/tests/test_views.py +++ b/twitch/tests/test_views.py @@ -2235,6 +2235,58 @@ class TestChannelListView: assert response.status_code == 200 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 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.""" diff --git a/twitch/views.py b/twitch/views.py index 0ce640b..4cc0e86 100644 --- a/twitch/views.py +++ b/twitch/views.py @@ -1,7 +1,6 @@ from __future__ import annotations import csv -import datetime import json import logging 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 PageNotAnInteger from django.core.paginator import Paginator -from django.db.models import Prefetch from django.db.models.query import QuerySet from django.http import Http404 from django.http import HttpResponse @@ -33,9 +31,10 @@ from twitch.models import DropCampaign from twitch.models import Game from twitch.models import Organization from twitch.models import RewardCampaign -from twitch.models import TimeBasedDrop if TYPE_CHECKING: + import datetime + from django.db.models import QuerySet from django.http import HttpRequest @@ -698,32 +697,12 @@ class GameDetailView(DetailView): model = Game template_name = "twitch/game_detail.html" 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: - """Get the game object using twitch_id as the primary key lookup. - - 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_queryset(self) -> QuerySet[Game]: + """Return game queryset optimized for the game detail page.""" + return Game.for_detail_view() def get_context_data(self, **kwargs) -> dict[str, Any]: # noqa: PLR0914 """Add additional context data. @@ -740,88 +719,13 @@ class GameDetailView(DetailView): game: Game = self.object # pyright: ignore[reportAssignmentType] now: datetime.datetime = timezone.now() - all_campaigns: QuerySet[DropCampaign] = ( - DropCampaign.objects - .filter(game=game) - .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") + campaigns_list: list[DropCampaign] = list(DropCampaign.for_game_detail(game)) + active_campaigns, upcoming_campaigns, expired_campaigns = ( + DropCampaign.split_for_channel_detail(campaigns_list, now) ) + owners: list[Organization] = list(getattr(game, "owners_for_detail", [])) - campaigns_list: list[DropCampaign] = list(all_campaigns) - - # 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_name: str = game.get_game_name game_description: str = f"Twitch drops for {game_name}." 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 @@ -902,7 +806,6 @@ class GameDetailView(DetailView): "expired_campaigns": expired_campaigns, "owner": owners[0] if owners else None, "owners": owners, - "drop_awarded_badges": drop_awarded_badges, "now": now, **seo_context, })