Make GameDetailView faster
This commit is contained in:
parent
4714894247
commit
16b12a7035
4 changed files with 157 additions and 109 deletions
|
|
@ -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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
|
||||||
121
twitch/views.py
121
twitch/views.py
|
|
@ -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,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue