Make GamesGridView faster
This commit is contained in:
parent
1d524a2ca9
commit
4714894247
3 changed files with 168 additions and 50 deletions
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue